Object-oriented python
In python we can use the class
keyword to create a type of our own.
class MyClass:
pass
my_instance_1 = MyClass()
my_instance_2 = MyClass()
We know that all values in python are objects.
The values 1
and 'hello'
are instances of int
and str
.
Our instances are of type MyClass
in exactly the same way.
A class
We can refer directly to the MyClass
class, for example, we can print it.
class MyClass:
pass
print(MyClass)
print(MyClass())
<class '__main__.MyClass'>
<__main__.MyClass object at 0x7f656f5d15d0>
Classes are callable and when we call them, they return an instance.
In python the file you execute is known as the
__main__
module. Double underscores indicate that__main__
is part of the python infrastructure.
Attributes
In python classes create a namespace which can be accessed using dot notation.
The below code is setting the apples
and data
attributes on the MyClass
class.
class MyClass:
pass
MyClass.apples = 'hello'
print(MyClass.apples)
MyClass.data = [1, 2, 3]
print(MyClass.data)
hello
[1, 2, 3]
This is very similar to creating variables, though the attributes are only available within the class namespace as
MyClass.apples
andMyClass.data
.
AttributeError
Attempting to access an attribute that has not been defined will raise an AttributeError
.
class MyClass:
pass
my_instance = MyClass()
print(my_instance.data)
Traceback (most recent call last):
File "path/to/my_class.py", line 5, in <module>
print(my_instance.data)
AttributeError: 'MyClass' object has no attribute 'data'
This can be circumvented by defining a
__getattr__
method.
Instance attributes
Instances are also namespaces. If an attribute is not found on an instance, it will be looked up on the class. In this way, classes provide attributes that are shared across all instances.
class MyClass:
pass
my_instance = MyClass()
another_instance = MyClass()
MyClass.apples = 'hello'
my_instance.apples = 'different value'
print(my_instance.apples)
print(another_instance.apples)
different value
hello
Default attributes
Classes can have attributes included in their definition. Anything we put inside the code block will be part of the class definition and will be available to all instances of the class.
class Coordinate:
x = 0
y = 0
c1 = Coordinate()
c2 = Coordinate()
c1.x = 2
c2.y = -3
print(f"{c1.x=}, {c1.y=}")
print(f"{c2.x=}, {c2.y=}")
c1.x=2, c1.y=0
c2.x=0, c2.y=-3
Methods
A function defined within a class is known as a method.
What happens when we try to call Coordinate.my_method()
from an instance?
class Coordinate:
def my_method():
pass
c1 = Coordinate()
c1.my_method()
TypeError: Coordinate.my_method() takes 0 positional arguments but 1 was given
Apparently our method was passed one argument, even though we passed nothing when we called it.
This is a copy of the instance and it is always automatically passed into methods.
What is this argument?
Let’s update our code to have a look at the argument. We’ll make two instances to see the impact.
Notice the
my_method()
function now takes one argument (arg
) and prints it.
class Coordinate:
def my_method(arg):
print(arg)
c1 = Coordinate()
c2 = Coordinate()
c1.my_method()
c2.my_method()
<__main__.Coordinate object at 0x7fc3e3cd0310>
<__main__.Coordinate object at 0x7fc3e3cd02e0>
Always use self
for this
By convention, python programmers always name this argument self
to avoid confusion.
Every method defined on a class should add self
as the first argument.
All subsequent arguments are treated as with normal functions.
class Coordinate:
x = 10
y = 5
def invert(self):
self.x, self.y = self.y, self.x
c1 = Coordinate()
print(c1.x, c1.y) # default class attributes
c1.invert()
print(c1.x, c1.y) # instance attributes
10 5
5 10
The constructor method __init__
This is awkward.
c1 = Coordinate()
c1.x = 10
c1.y = 5
We can define a custom constructor method Coordinate.__init__()
to allow this, more convenient syntax.
class Coordinate:
def __init__(self, x, y):
self.x = x
self.y = y
origin = Coordinate(0, 0)
c1 = Coordinate(3, 8)
c2 = Coordinate(-4, 2)
Again, we take an implicit self
argument plus whatever else we need.
Printing instances
When we call print()
on Coordinate
instances, we get output like this.
print(Coordinate(0, 0))
<__main__.Coordinate object at 0x7fec1d6d4280>
The print()
function calls str()
which calls the __str__
method of any object which is passed to it.
The object’s __str__
method must return a string representation of the object.
Remember, everything is an object.
print([i.__str__() for i in (1.1, True, Coordinate(0, 0))])
['1.1', 'True', '<__main__.Coordinate object at 0x7fabd3cafc10>']
Note this is
i.__str__()
, notstr(i)
. But they are equivalent becausestr(i)
callsi.__str__()
.
The default __str__
method
Our objects are responsible for telling the str()
constructor what a string representation should be.
So, we can define how our classes are converted into strings.
The default __str__
implementation is something like this:
def __str__(self):
md = self.__class__.__module__
cls = self.__class__.__name__
return f"<{md}.{cls} object at {id(self):#x}>"
It provides enough information to work out what something is and where it is defined, but its not very convenient if we want to see the instance attributes of our Coordinate
.
All instances have a
__class__
attribute. All classes have a__module__
and a__name__
.
A custom __str__
method
We can give our class a customised __str__
method to return a customised string.
class Coordinate:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'({self.x}, {self.y})'
for x in range(2):
for y in range(2):
print(Coordinate(x, y))
(0, 0)
(0, 1)
(1, 0)
(1, 1)
The __repr__
method
class Coordinate:
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
return f'({self.x}, {self.y})'
def __repr__(self):
return f"Coordinate(x={self.x}, y={self.y})"
for x in range(2):
for y in range(2):
print(repr(Coordinate(x, y)))
Coordinate(x=0, y=0)
Coordinate(x=0, y=1)
Coordinate(x=1, y=0)
Coordinate(x=1, y=1)
Operators
The exact same idea is used to implement operators. The following expressions are exactly equivalent.
a - b
a.__sub__(b)
Obviously, the first one is a neater way of doing it and is always preferred when we want to do subtraction.
The key point here is that our Coordinate
object can define how it interacts with operators.
Currently, it cannot be part of a subtraction operation.
a = Coordinate(1, 2)
b = Coordinate(2, 1)
a - b
Traceback (most recent call last):
File "path/to/coordinate.py", line 15, in <module>
a - b
TypeError: unsupported operand type(s) for -: 'Coordinate' and 'Coordinate'
Implementing a __sub__
method
class Coordinate:
def __init__(self, x, y):
self.x = x
self.y = y
def __sub__(self, other):
return Coordinate(self.x - other.x, self.y - other.y)
def __str__(self):
return f'Coordinate({self.x}, {self.y})'
def __repr__(self):
return f"Coordinate(x={self.x}, y={self.y})"
c1 = Coordinate(1, 2)
c2 = Coordinate(3, 4)
c3 = c1 - c2
print(f"{c1} - {c2} = {c3}")
(1, 2) - (3, 4) = (-2, -2)