Object oriented programming¶
Object-oriented Programming (OOP) is a programming paradigm which provides a mean of structuring programs so that properties and behaviors are bundled into individual objects. (realpython)
Object-oriented programming is a programming paradigm based on the concept of objects, which may contain data, in the form of fields (often known as attributes), and code, in the form of procedures (often known as methods). (wikipedia)
It is a very powerfull feature of Python, which is very well adapted to individual-based models for instance.
It will be illustrated through a program that manages the displacement of vehicles. A vehicle will be at first defined by the following data (attributes):
Position (x, y)
Velocity (vx, vy)
Name of the vehicle.
Defining a class¶
An object is defined by using the class
keyword:
class Vehicle(object):
The class must contain a constructor (__init__
function), which wil define how a new object will be created (instanciation). For instance, regarding our vehicle, the class will look like this:
# creation d'une classe vehicule
class Vehicle(object):
def __init__(self, name, vx, vy, x, y):
self.name = name # sets the name of the vehicle
self.vx = vx # sets the speed
self.vy = vy
self.x = x # sets the position
self.y = y
In the above, the self
key word refers to the object himself.
With the given constructor, a new vehicle can be created as follows:
x = 10
y = 20
vx = 0
vy = 0
name = 'corsa'
veh1 = Vehicle(name, vx, vy, x, y)
print(veh1)
print(veh1.x)
<__main__.Vehicle object at 0x7f6c9edbd3d0>
10
There is the possibility to modify the constructor to allow for several constructors pythonconquerstheuniverse
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, *args):
if(len(args) == 5):
self.__init_from_int__(*args)
elif(len(args) == 3):
self.__init_from_list__(*args)
elif(len(args) == 1):
self.__init_from_name__(*args)
# Constructor when all the attributes are provided as arguments
def __init_from_int__(self, *args):
self.name, self.vx, self.vy, self.x, self.y = args
# Constructor when position and speed are provided as lists
def __init_from_list__(self, *args):
name = args[0]
list_speed = args[0]
list_pos = args[1]
self.__init_from_int__(name, list_speed[0], list_speed[1], list_pos[0], list_pos[1])
# Constructor when position and speed are not provided as lists
def __init_from_name__(self, name):
self.__init_from_int__(name, 0, 0, 0, 0)
veh1 = Vehicle('corsa', 1, 2, 3, 4) # uses __init_from_int__
veh2 = Vehicle('corsa', [1, 2], [3, 4]) # uses __init_from_list__
veh3 = Vehicle('corsa') # uses __init_from_name__
Note that in that case, the two additional constructors call the self.__init_from_int__
, in order to prevent too much copy/paste.
Adding methods¶
Methods can be added to the objects. For instance, a method to change the speed and change the position can be added.
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = self.x = self.y = 0
self.name = name
# moving vehicle using speed and time steps
def move(self, dt):
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
# update speed
def change_speed(self, dvx, dvy):
self.vx += dvx
self.vy += dvy
# returns speed
def speed(self):
return self.vx, self.vy
# returns position
def pos(self):
return self.x, self.y
veh1 = Vehicle('corsa')
print(veh1.pos(), veh1.speed())
veh1.change_speed(1, 2)
print(veh1.pos(), veh1.speed())
veh1.move(10)
print(veh1.pos(), veh1.speed())
(0, 0) (0, 0)
(0, 0) (1, 2)
(10, 20) (1, 2)
Since custom objects are mutables, they can be stored in a list and updated using a for
loop, as follows:
veh1 = Vehicle('corsa')
veh1.change_speed(1, 2)
veh2 = Vehicle('nissan')
veh2.change_speed(-1, -2)
print(veh1.pos())
print(veh2.pos())
vehicles = [veh1, veh2]
for v in vehicles:
v.move(10)
print(veh1.pos())
print(veh2.pos())
(0, 0)
(0, 0)
(10, 20)
(-10, -20)
Usefull methods¶
__str__
method¶
The __str__
method is used to change the output of print
functions. It must returns a string object
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = self.x = self.y = 0
self.name = name
veh1 = Vehicle('corsa')
print(veh1)
<__main__.Vehicle object at 0x7f6c9edc5490>
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = self.x = self.y = 0
self.name = name
def __str__(self):
output = 'vehicle=%s, pos=[%.2f, %.2f], speed=[%.2f, %.2f]' %(self.name, self.x, self.y, self.vx, self.vy)
return output
veh1 = Vehicle('corsa')
print(veh1)
vehicle=corsa, pos=[0.00, 0.00], speed=[0.00, 0.00]
__call__
method¶
The __call__
method can be used to make the obect callable (object(...)
). For instance, let’s replace the move
method by a __call__
method:
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = 2
self.x = self.y = 0
self.name = name
def __str__(self):
output = 'vehicle=%s, pos=[%.2f, %.2f], speed=[%.2f, %.2f]' %(self.name, self.x, self.y, self.vx, self.vy)
return output
def __call__(self, dt):
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
veh1 = Vehicle('corsa')
veh1(10)
print(veh1)
veh1(20)
print(veh1)
vehicle=corsa, pos=[20.00, 20.00], speed=[2.00, 2.00]
vehicle=corsa, pos=[60.00, 60.00], speed=[2.00, 2.00]
The __getitem__
method¶
The __getitem__
is used to make the object subscriptable (object[key]
).
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = 2
self.x = self.y = 0
self.name = name
def __str__(self):
output = 'vehicle=%s, pos=[%.2f, %.2f], speed=[%.2f, %.2f]' %(self.name, self.x, self.y, self.vx, self.vy)
return output
def __getitem__(self, key):
print(type(key), key)
output = {'name': self.name, 'pos': [self.x, self.y], 'speed':[self.vx, self.vy]}
if isinstance(key, str):
return(output[key.lower()])
veh1 = Vehicle('corsa')
print(veh1['name'])
<class 'str'> name
corsa
print(veh1['pos'])
<class 'str'> pos
[0, 0]
print(veh1['speed'])
<class 'str'> speed
[2, 2]
print(veh1[:]) # key provided as a slice
<class 'slice'> slice(None, None, None)
None
print(veh1[2:10:-1, :]) # key provided as a tuple of slice
<class 'tuple'> (slice(2, 10, -1), slice(None, None, None))
None
print(veh1['arg1', 'arg2']) # key provided as a tuple of strs
<class 'tuple'> ('arg1', 'arg2')
None
Encapsulation: getter and setters¶
In the above examples, there is a big issue. Indeed, the user has full control on the object’s data. Which can cause some issues.
# creation d'une classe vehicule
class Vehicle(object):
# Depending on the length of the number of
# arguments, call one constructor or the other
def __init__(self, name):
self.vx = self.vy = 2
self.x = self.y = 0
self.name = name
def __call__(self, dt):
self.x = self.x + self.vx * dt
self.y = self.y + self.vy * dt
def __str__(self):
output = 'vehicle=%s, pos=[%.2f, %.2f], speed=[%.2f, %.2f]' %(self.name, self.x, self.y, self.vx, self.vy)
return output
veh1 = Vehicle('corsa')
veh1(10)
print(veh1)
veh1.vx = 20 # user can change the value of vx!
veh1(10)
print(veh1)
vehicle=corsa, pos=[20.00, 20.00], speed=[2.00, 2.00]
vehicle=corsa, pos=[220.00, 40.00], speed=[20.00, 2.00]
If the user makes a mistake in the definition of vx, the code will crash.
veh1 = Vehicle('corsa')
veh1.vx = '20' # user can change the value of vx!
# veh1(10) # this crashes
Therefore, it is important to keep the control on the data, and to prevent the users to access them. This is what is called encapsulation.
It is achieved by defining setter and getter functions, using the built-in @property
and @setter
decorators.
The setter
will be called any time assigment is performed, while getter
is called elsewhere.
Let’s look at how it works for the vx
attribute.
class Vehicle(object):
def __init__(self, name):
self.vx = self.vy = self.x = self.y = 0
self.name = name
def __str__(self):
output = 'vehicle=%s, pos=[%.2f, %.2f], speed=[%.2f, %.2f]' %(self.name, self.x, self.y, self.vx, self.vy)
return output
@property
def vx(self):
print('getter', self.__vx)
return self.__vx
@vx.setter
def vx(self, value):
print('setter', value)
if(isinstance(value, (int, float))):
self.__vx = float(value)
else:
print('VX must be numeric. Unchanged')
veh1 = Vehicle('corsa') # calls setter (value = 0)
setter 0
At initialization, the setter
is called with input values equals tp 0
print(veh1) # __str__ calls getter
getter 0.0
vehicle=corsa, pos=[0.00, 0.00], speed=[0.00, 0.00]
veh1.vx = '20' # calls setter (provided as str, print error message)
veh1.vx
setter 20
VX must be numeric. Unchanged
getter 0.0
0.0
veh1.vx = 10 # calls setter (provided as int -> converted into float)
veh1.vx
setter 10
getter 10.0
10.0
There are several things to note:
The
vx
attribute has been replaced by a private attribute__vx
The getter and setter must have the same name, which corresponds to the name of the variable as we want to access.
Getters are preceded by the
@property
decoratorSetters are preceded by the
@X.setter
decorator, withX
the property to set.
Setters are called any time there is assigmnent, while getters are called elsewhere.
Inheritance¶
Imagine you want to separate vehicles into three categories: boats, cars, planes. These vehicles have common attributes (horizontal position for instance), but have some differences listed below.
Vehicle |
Over land |
Over sea |
3D |
---|---|---|---|
Boats |
False |
True |
False |
Cars |
True |
False |
False |
Planes |
True |
True |
True |
In order to easily define these three vehicles, it is possible to use inheritance. A mother class can be defined to manage the common attributes and methods, while child class will contain specific ones.
The super
keyword refers to the parent class. It can be used to call some mother’s class method, in order
to avoid copy and pastes.
Let’s define the mother class, called Vehicle
.
class Vehicle(object):
def __init__(self, x, y):
self.x = x
self.y = y
self.vx = self.vy = 1
def __call__(self, dt):
self.x += self.vx * dt
self.y += self.vy * dt
def __str__(self):
return str([self.x, self.y])
Now, let’s create a Boat
and a Car
class that inherits from Vehicle
:
class Boat(Vehicle):
def __init(self, x, y):
super().__init__(x, y) # calls mother class constructor
def inWater():
return True
def inLand():
return False
class Car(Vehicle):
def __init(self, x, y):
super().__init__(x, y)
def inWater():
return False
def inLand():
return True
Note that super()
always refer to the mother class.
Since Boat
and Car
are vehicles that move in 2D, there is no need to add any attributes or to overwrite methods. However, since planes can move in 3D, a height and vertical speed attribute need to be added. Furthermore, the __call__
and __str__
methods need to be changed as well.
class Plane(Vehicle):
def __init__(self, x, y, z):
super().__init__(x, y) # calls mother class constructor
self.z = z # add two attributes
self.vz = 10
def inWater():
return True
def inSea():
return True
def __call__(self, dt): # overwrites __call__method
super().__call__(dt) # call mother class __call__ function
self.z += self.vz * dt # add also the update of z
def __str__(self):
return str([self.x, self.y, self.z]) # overwrites __str__ method.
In the constructor, the x
and y
positions are set using the mother class constructor. Same thing in the __call__
method: the horizontal positions are updated using the mother __call__
function.
veh1 = Boat(1, 2)
veh2 = Car(3, 4)
veh3 = Plane(4, 5, 10)
print(isinstance(veh1, Boat))
print(isinstance(veh1, Vehicle))
print(isinstance(veh1, Car))
True
True
False
for v in [veh1, veh2, veh3]:
v(10)
print(v)
[11, 12]
[13, 14]
[14, 15, 110]