A general introduction to OOP in Python

The Object-Oriented Programming (OOP) paradigm is a widely-used concept that appears naturally in some problem domains. The core idea here is that we can model everything (cars, trees, animals, anything) as an object, just as how we see it in real life.

Generally, up until this point in the course, we've been separating everything we've learned into data (numeric values, strings, dicts, and so on) and actions (input/output, expressions, functions, etc.).

An object is a structure that both has something (data), and can also do something (actions), combining the two approaches into one.

As our introductory example, you can think of a car as an object that has components like seats, engines, passengers (and so on), which can be thought of its data. A car is also capable of some functionalities (drive(), accelerate(), park()), which could be examples of its actions.

image.png (from this week's slides)

In class, we thought about an example program that can draw geometric shapes in a computer screen using the OOP paradigm.

The simplest object would be some kind of Shape, that has a 2D coordinate (x and y) and has a draw() action that (hypothetically) draws the shape on the screen. Afterwards, we could use this base Shape to construct more complex shapes (squares, triangles, etc.) when we need to.

In Python, to create an object, we need to make a blueprint so that Python knows how to create that object. We do this using the class keyword:

class <class-name>:
    <optional initializer>
    <optional variable initialization>
    <optional functions>

Here's the most basic blueprint we can make:

To create a Shape object, we can call the class name like a function:

As we can see, we have created our own Shape object using the blueprint. Notice the terminology:

Now, admittedly, our shape doesn't do anything. It has no data and no actions.

You can think of Shape() as a factory, because using it I can make as many shape objects as I want (which also don't do anything):

Let's change Shape so that it actually does something.

Let's expand the class definition so that it has a 2D coordinate, it can "move", and it shows its location when we use print:

We added a couple of important things here:

self.x = starting_x
self.y = starting_y

means that we want to assign the given starting_x and starting_y values to our local variables of our created object so that we can use them later. To refer to our local variables x and y, we use self.x and self.y.

Let's see this in action by creating some shapes:

When we call Shape(10, 20) above, Python creates a Shape object and then executes Shape.__init__(self, 10, 20). Notice that we didn't write Shape(self, 10, 20), because Python automatically puts self for us.

Let's create another object:

From the output, we can see that using __init__, we can create objects that hold their own unique data.

However, it's getting tiring to print for me to print our Shape objects by printing every variable inside them. That is where __repr__ comes into play.

Again, notice that we don't write shape1.move(self, 100, 200), because Python automatically inserts self for us.

Example: A Complex class

Here's the example from class where we implemented our own Complex class.

For reference, this is a complex number in Python. It has a real part, and an imaginary part (with a j suffix):

Let's assume, for some reason, that I just don't want to use j to represent the imaginary part of a complex number, and I want to use i instead. I can create my own Complex class (with a capital 'C', so that its different from the built-in complex) to do that.

Now, our class also have a real and imaginary part. And the best part is, we can display it using i with our own __repr__ function, too:

However, a complex number should be able to do other things. One such thing is that we should be able to add two complex numbers together. The built-in complex can do this:

But we can't, because Python doesn't know how to add two Complex numbers:

We can do this in our class by "adding a + function" that adds two Complex numbers.

We do this in Python by overriding the __add__ method. This is an example of an OOP idea called "operator overloading":

Now, we should also implement two other operators (at least), which are:

As an example from the built-in complex:

That's good enough for our purposes, at least. There are other operators to override (such as __gt__ for >, etc.) that you can read about in the textbook.

Example: Counter

Here's the example we did of a Counter class, where a Counter object just increments its stored value when we call increment(), and resets to 0 upon calling reset().

Example: Liger

Here's the example we did in class of multiple inheritence using a Liger class (which is the offspring of a lion and a tiger).

The idea here is that a liger inherits its color from their Tiger parent, while it also inherits the roar() method from its Lion parent as well:

Aside: "Private" variables

Finally, I talked about how there are no truly private variables in Python (as needed for strict "encapsulation"). A variable inside a class can always be accessed in some way:

When leading with a double underscore (__), the variable name will be changed so that this fails:

However, we can always see what a class contains using dir():

And find the new name of the "super secret variable" from there: