Prof. Andrea Gallegati
What we used so far, is not really object-oriented:
without explicit relationships between programmer-defined types and functions operating on them.
To make these relationships explicit, let's transform those functions into methods.
Python
provides features to support object-oriented programming, with these defining characteristics:
Time
class is the way people record the time of day.
The defined functions are kinds of things people do with times.
Similarly, Point
and Rectangle
are well known geometrical concepts!
The object-oriented programming features that Python
supports are not strictly necessary:
No obvious connections between Time
class and the functions that followed, but every function takes Time
object as an argument!
This is the motivation for methods:
functions associated with a particular class.
There are many methods for:
... why not for programmer-defined types too?
Methods are semantically the same as functions, with two syntactic differences:
class Time:
"""Represents the time of day."""
def print_time(time):
print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))
this print_time
function need a Time
argument:
start = Time()
start.hour = 9
start.minute = 45
start.second = 0
print_time(start)
09:45:00
Let's make it a method, moving it's definition inside the class body (changing indentation):
class Time:
def print_time(time):
print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))
we can call in a couple ways ...
Time.print_time(start)
09:45:00
start.print_time()
09:45:00
In dot notation below
start.print_time()
09:45:00
print_time
is the method namestart
is the object the method is invoked on (aka subject)The subject of a sentence is what the sentence is about.
... the subject of a method invocation is what the method is about!
The subject is always assigned to the first parameter.
here, start
is assigned to time
class Time:
def print_time(time):
print('%.2d:%.2d:%.2d' % (time.hour, time.minute, time.second))
By convention, this is called self
and it's more common to write:
class Time:
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
The reason, is an implicit metaphor:
print_time(start)
says “Hey print_time! Here’s an object for you to print.”
start.print_time()
says “Hey start! Please print yourself.”
In this perspective (more polite and useful), shifting responsibility from the functions onto the objects:
Rewriting time_to_int
as a method, we might be tempted to rewrite int_to_time
as a method too!
def int_to_time(seconds):
time = Time()
minutes, time.second = divmod(seconds, 60)
time.hour, time.minute = divmod(minutes, 60)
return time
... but that doesn’t really make sense! There is no object to invoke it on.
class Time:
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
time_to_int
(here a method) is a pure function and not a modifier !
start.print_time()
end = start.increment(1337)
end.print_time()
09:45:00 10:07:17
The subject start
gets assigned to the first (self
) parameter.
The 1337
argument, gets assigned to the second (seconds
) parameter.
This mechanism can be confusing. Especially with errors.
For example, invoking increment
with two arguments:
end = start.increment(1337, 460)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-40-fc6b983fb3d4> in <module>() ----> 1 end = start.increment(1337, 460) TypeError: increment() takes 2 positional arguments but 3 were given
The error message itself is confusing!
The subject itself is an argument thus, overall, we had three arguments.
don’t have a parameter name (are not keyword arguments)
sketch(parrot, cage, dead=True)
parrot
and cage
are positionaldead
is a keyword argumentTo write the is_after
method is slightly more complicated because it takes two Time
objects as parameters.
It is conventional to name the first one self
and the second other
.
class Time:
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
Then, invoke it on an object passing the other one as an argument:
end.is_after(start)
True
... it almost reads like English: “end is after start?”
__init__
method¶is a special method whose full name (short for “initialization”) is:
It gets invoked when an object is instantiated.
An __init__
method for the Time
class might look like this:
class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
Usually __init__
parameters are named as the Class attributes.
For example, self.hour = hour
stores the hour
parameter value as the hour
attribute of self
.
Parameters are optional. Calling Time()
without arguments, we get the default values:
time = Time()
time.print_time()
00:00:00
Providing one argument overrides hour
:
time = Time (9)
time.print_time()
09:00:00
Providing two arguments override hour
and minute
:
time = Time(9, 45)
time.print_time()
09:45:00
Providing three arguments override all three default values:
__str__
method¶class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
def __str__(self):
return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
This is another special method that is supposed to return a string representation of an object.
When we print an object, Python
invokes the __str__
method:
time = Time(9, 45)
print(time)
09:45:00
When writing a new class, it's a good practice to start by writing:
__init__
to make it easier to instantiate objects__str__
useful for debuggingWith other special methods we can specify other operators behavior on programmer-defined types (Classes).
For example, to use the +
operator on Time
objects, let's define the special method __add__
:
class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
def __str__(self):
return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
def __add__(self, other):
seconds = self.time_to_int() + other.time_to_int()
return int_to_time(seconds)
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
Applying the +
operator to Time
objects, Python
invokes __add__
.
Printing the result, Python
invokes __str__
.
start = Time(9, 45)
duration = Time(1, 35)
print(start + duration)
11:20:00
There's a lot happening behind the scenes!
operator overloading: changing an operator behavior to work with programmer-defined types.
For every operator there's a corresponding special method.
So far we have added two Time
objects.
What about to add an integer
to a Time
object?
class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
def __str__(self):
return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
def __add__(self, other):
if isinstance(other, Time):
return self.add_time(other)
else:
return self.increment(other)
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def add_time(self, other):
seconds = self.time_to_int() + other.time_to_int()
return int_to_time(seconds)
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
This version of __add__
checks the type of other
and invokes:
add_time
increment
This operation (aka type-based dispatch) dispatches the computation to different methods, based on the type of the arguments.
other
is a Time
object, __add__
invokes add_time
.increment
.It checks the type with the built-in function isinstance
that takes:
and returns True
if the value is an instance of the class.
Here are examples that use the +
operator with different types:
start = Time(9, 45)
duration = Time(1, 35)
print(start + duration)
11:20:00
print(start + 1337)
10:07:17
Unfortunately, this addition implementation is not commutative.
Passing the integer as the first argument
print(1337 + start)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) <ipython-input-58-0f0a74261c37> in <module>() ----> 1 print(1337 + start) TypeError: unsupported operand type(s) for +: 'int' and 'Time'
Instead of asking the Time
object to add an integer, Python
is asking an integer to add a Time
object, and it doesn’t know how.
... but there's a clever solution for this!
With the special method __radd__
(for “right-side add”).
class Time:
def __init__(self, hour=0, minute=0, second=0):
self.hour = hour
self.minute = minute
self.second = second
def __str__(self):
return '%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second)
def __add__(self, other):
if isinstance(other, Time):
return self.add_time(other)
else:
return self.increment(other)
def __radd__(self, other):
return self.__add__(other)
def print_time(self):
print('%.2d:%.2d:%.2d' % (self.hour, self.minute, self.second))
def time_to_int(time):
minutes = time.hour * 60 + time.minute
seconds = minutes * 60 + time.second
return seconds
def add_time(self, other):
seconds = self.time_to_int() + other.time_to_int()
return int_to_time(seconds)
def increment(self, seconds):
seconds += self.time_to_int()
return int_to_time(seconds)
def is_after(self, other):
return self.time_to_int() > other.time_to_int()
.. that is invoked when a Time
object is on the right side of the +
operator. Here’s the definition:
print(1337 + start)
10:07:17
Type-based dispatch is useful when necessary, but fortunately that is not always the case!
One can avoid this with functions that work correctly for arguments with different types.
Many functions we wrote for strings also work for any other sequence types.
For example, the histogram
to count the number of times each letter appears in a word:
def histogram(s):
d = dict()
for c in s:
if c not in d:
d[c] = 1
else:
d[c] = d[c]+1
return d
... works as well for
t = ['spam', 'egg', 'spam', 'spam', 'bacon', 'spam']
histogram(t)
{'bacon': 1, 'egg': 1, 'spam': 4}
As long as s
elements are hashable, so they can be used as keys in d
.
A type that has a hash function (that takes any kind of value and returns an integer).
Immutable types are hashable:
Mutable types (like lists and dictionaries) are not.
Dictionaries use these integers, called hash values, to store and look up key-value pairs.
Functions that work with several types are called polymorphic.
Polymorphism can facilitate code reuse.
The built-in function sum
adds the elements of a sequence.
It works as long as the elements of the sequence support addition.
Since Time
objects provide an add method, they work with sum!
t1 = Time(7, 43)
t2 = Time(7, 41)
t3 = Time(7, 37)
total = sum([t1, t2, t3])
print(total)
23:01:00
If all of the operations inside a function work with a given type, the function works with that type.
The best kind of polymorphism is the unintentional kind!
One discover that a function can be applied to a type never planned for.
Among the goals of object-oriented design is to make software more maintainable:
Design Principle: to keep interfaces separate from implementations.
... methods should not depend on how the attributes are represented.
For example, the Time
class methods
time_to_int
is_after
add_time
can be implemented in several ways, but the implementation details depend on how we represent time!
Replacing Time
attributes with a single integer (seconds since midnight) would make some methods easier to write, but some others much harder.
Once a new class is deployed, a better implementation might be discovered.
When other components use this class, to change the interface is
Better to design the interface carefully: to be able to change (later on) the implementation, without changing the interface!
It is legal to add attributes to objects at any point, but it's easy to make mistakes!
For example, having objects with the same type, but different attributes.
Better to initialize them all in the __init__
method.
Not sure whether an object has a particular attribute?
... use the built-in function hasattr
hasattr(start, "hour")
True
hasattr(start, "day")
False
Or use the built-in function vars
, to return (given any object) a dictionary mapping all the attribute names and values:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(3, 4)
vars(p)
{'hour': 9, 'minute': 45, 'second': 0}
Finally, this function might be useful for debugging
def print_attributes(obj):
for attr in vars(obj):
print(attr, getattr(obj, attr))
it traverses the vars
dictionary to prints each attribute name and value.
print_attributes(p)
y 4 x 3
The built-in function getattr
takes an object and an attribute name to return the attribute’s value.