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.
(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:
class Shape:
pass # pass does nothing, but we need it here since we don't have anything else
To create a Shape object, we can call the class name like a function:
my_shape = Shape()
type(my_shape)
__main__.Shape
As we can see, we have created our own Shape object using the blueprint. Notice the terminology:
Shape is the class (type) of our my_shape variable.my_shape is an object, or a class instance of the Shape class.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):
some_shape = Shape()
another_shape = Shape()
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:
class Shape:
color = 'black'
# Initializing function for an object
def __init__(self, starting_x, starting_y):
self.x = starting_x
self.y = starting_y
# print() uses this as the representation of these function
def __repr__(self):
return f'Shape at ({self.x}, {self.y})'
def move(self, new_x, new_y):
self.x = new_x
self.y = new_y
We added a couple of important things here:
color is an example of such a class variable. You can even access the variable without having an object (just using the class name):Shape.color
'black'
Now generally, that is not what we want. We would like to create different Shape objects that have different locations, different colors, etc. For an object to have its own data, we need to use the special __init__ function and we need to use the self keyword.
__init__(self, ...) is a special function for classes that is used when we create an object. In the definition above, we supply __init__ with the starting location of the shape that we can provide ourselves.
We use self when we want to refer to an object. Inside __init__, the statements
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.
self as a parameter to functions (such as __init__ and others) so that we can access our local data inside an object (example: self.x). However, when calling these functions using our objects, we don't have to write self, because Python does it for us behind the scenes.Let's see this in action by creating some shapes:
shape1 = Shape(10, 20) # Create a shape with x = 10, y = 20
print(shape1.x)
print(shape1.y)
10 20
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:
shape2 = Shape(30, 40) # Create a shape with x = 30, y = 40
print('shape1 location:', shape1.x, shape1.y)
print('shape2 location:', shape2.x, shape2.y)
shape1 location: 10 20 shape2 location: 30 40
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.
__repr__(self) is also a special function like __init__. When we use print() with a Shape object, this function will be used for getting the string representation of an object of this class:print(shape1)
print(shape2)
Shape at (10, 20) Shape at (30, 40)
move(self, new_x, new_y) is a function that we defined to change 2D location of a shape object to (new_x, new_y):print(shape1)
shape1.move(100, 200)
print(shape1) # Where is it now?
shape1.move(200, 400)
print(shape1)
Shape at (10, 20) Shape at (100, 200) Shape at (200, 400)
Again, notice that we don't write shape1.move(self, 100, 200), because Python automatically inserts self for us.
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):
c = 1+2j
print(c)
print(type(c))
(1+2j) <class 'complex'>
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.
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def __repr__(self):
return f'{self.real} + {self.imag}i'
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:
Complex(10, 20)
10 + 20i
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:
(1+2j) + (5+3j)
(6+5j)
But we can't, because Python doesn't know how to add two Complex numbers:
Complex(1, 2) + Complex(5, 3)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-14-d7a370773b98> in <module> ----> 1 Complex(1, 2) + Complex(5, 3) TypeError: unsupported operand type(s) for +: 'Complex' and 'Complex'
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":
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def __repr__(self):
return f'{self.real} + {self.imag}i'
# New add method
def __add__(self, c): # c: Another Complex object
new_real = self.real + c.real
new_imag = self.imag + c.imag
# Create a new complex number to store the result
result = Complex(new_real, new_imag)
return result
Complex(1, 2) + Complex(5, 3)
6 + 5i
Now, we should also implement two other operators (at least), which are:
__sub__ method.__mul__ method.As an example from the built-in complex:
(3+4j) - (1+3j)
(2+1j)
(1+2j) * (5+4j)
(-3+14j)
class Complex:
def __init__(self, real, imag):
self.real = real
self.imag = imag
def __repr__(self):
return f'{self.real} + {self.imag}i'
def __add__(self, c): # c: Another Complex object
new_real = self.real + c.real
new_imag = self.imag + c.imag
# Create a new complex number to store the result
result = Complex(new_real, new_imag)
return result
# New subtract method
def __sub__(self, c):
new_real = self.real - c.real
new_imag = self.imag - c.imag
return Complex(new_real, new_imag)
# New multiplication method
def __mul__(self, c):
new_real = self.real * c.real - self.imag * c.imag
new_imag = self.real * c.imag + self.imag * c.real
return Complex(new_real, new_imag)
Complex(3, 4) - Complex(1, 3)
2 + 1i
Complex(1, 2) * Complex(5, 4)
-3 + 14i
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.
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().
class Counter:
def __init__(self, starting_value):
self.starting_value = starting_value
def increment(self):
self.starting_value += 1
def reset(self):
self.starting_value = 0
def show(self):
return self.starting_value
counter = Counter(0)
counter.increment()
counter.show()
1
counter.reset()
counter.show()
0
class Tiger:
# Tigers are orange (and black?)
color = 'orange'
class Lion:
def roar(self):
return 'Roar!'
class Liger(Tiger, Lion):
# We're not doing anything with this class, so no need for an __init__() function
pass
liger = Liger()
liger.color
'orange'
liger.roar()
'Roar!'
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:
class Secret:
# No way to hide these, unfortunately
_secret_var = 'secret'
__super_secret_var = 'shh'
secret = Secret()
print(secret._secret_var)
secret
When leading with a double underscore (__), the variable name will be changed so that this fails:
print(secret.__super_secret_var)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) <ipython-input-33-6e3ea32acb02> in <module> ----> 1 print(secret.__super_secret_var) AttributeError: 'Secret' object has no attribute '__super_secret_var'
However, we can always see what a class contains using dir():
dir(secret)
['_Secret__super_secret_var', '__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__', '_secret_var']
And find the new name of the "super secret variable" from there:
print(secret._Secret__super_secret_var)
shh