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, dict
s, 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