Special methods
Overview
Teaching: 25 min
Exercises: 15 minQuestions
How can classes allow their instances to work with standard Python operators?
How can classes allow their instances to behave like iterables or collections?
How can classes allow their instances to be called like functions?
How can I avoid writing repetitive
__init__,__repr__, and__eq__methods for simple data-holding classes?Objectives
Be able to implement methods like
__add__,__eq__, and__gt__.Be able to implement methods like
__len__,__iter__, and__reversed__.Be able to implement the
__call__method.Be able to use the
@dataclassdecorator to reduce boilerplate in data-holding classes.
In the previous episodes, we built a Triangle class that could
represent a triangle by storing the lengths of its sides. Now,
mathematically speaking, two triangles with identical sides are the
same triangle. Let’s see if Python agrees with this.
a_triangle = Triangle([3, 4, 5])
the_same_triangle = Triangle([3, 4, 5])
if a_triangle == the_same_triangle:
print("Python thinks that these triangles are the same.")
else:
print("Python thinks that these are different triangles.")
Python thinks that these are different triangles.
So, despite these triangles having been constructed with exactly the same side lengths, Python distinguishes between them. By default, Python will only consider two objects to be the same if they are identical:
a_triangle = Triangle([3, 4, 5])
duplicate_triangle = a_triangle
if a_triangle == duplicate_triangle:
print("Python thinks that these triangles are the same.")
else:
print("Python thinks that these are different triangles.")
Python thinks that these triangles are the same.
This isn’t great for our triangle example—we would much prefer
if we could compare equality of triangles without having to compare
the side_lengths property by hand. Fortunately, Python gives us a
way of doing this. If we implement the __eq__ method of Triangle,
then Python learns how to compare triangles.
class Triangle(Polygon):
def __init__(self, side_lengths):
# Triangles have three sides
super().__init__(side_lengths)
assert len(self.side_lengths) == 3
@classmethod
def equilateral(cls, side_length):
return cls([side_length] * 3)
def area(self):
"""Returns the area of the triangle."""
a, b, c = self.side_lengths
p = (a + b + c) / 2
return (p * (p - a) * (p - b) * (p - c)) ** 0.5
def __eq__(self, other):
"""Returns True if the triangle self and the triangle other
are the same triangle"""
if not isinstance(other, Triangle):
return False
else:
# Check all permutations
if self.side_lengths == other.side_lengths:
return True
elif (
self.side_lengths[1:] + [self.side_lengths[0]] ==
other.side_lengths
):
return True
elif (
[self.side_lengths[2]] + self.side_lengths[:2] ==
other.side_lengths
):
return True
return False
a_triangle = Triangle([3, 4, 5])
the_same_triangle = Triangle([3, 4, 5])
if a_triangle == the_same_triangle:
print("Python thinks that these triangles are the same.")
else:
print("Python thinks that these are different triangles.")
Python thinks that these triangles are the same.
Great! We can compare equality. __eq__ is the second example we’ve
seen of a so-called “special”, “magic”, or “dunder” (short for “double
underscore”) method. These are methods that Python ascribes a special
meaning to; it guards the names of these with a double underscore __
on each side, so it is unlikely to collide with a name you might want
to use for a method of your own. These methods allow us to enable
instances of our classes to behave more like Python objects you’re
used to dealing with, using the typical set of operators, rather than
needing to use method calls for everything.
Let’s look at some more examples of these. Firstly, wouldn’t it be
nice if we got something more descriptive when Python referred to our
Triangles?
a_triangle
<__main__.Triangle at 0x1235e7b00>
We can do this by implementing the __repr__ method (short for
“representation”). This is designed to be something that looks like
Python code—ideally, something that if you pasted it back in, you’d
get the same (or at least a similar) object. For the Triangle, this
could look like:
def __repr__(self):
return f"Triangle({self.side_lengths})"
Testing this now gives:
a_triangle = Triangle([3, 4, 5])
a_triangle
Triangle([3, 4, 5])
Simplifying data-holding classes with dataclasses
Writing __init__, __repr__, and __eq__ by hand every time we create a
class that mainly just holds a fixed set of values is repetitive. Python’s
dataclasses module (part of the standard library since Python 3.7) can
generate all three of these for us, from a list of type-annotated fields.
Written by hand, a simple 2D point class looks like:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def __repr__(self):
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other):
if not isinstance(other, Point):
return NotImplemented
return (self.x, self.y) == (other.x, other.y)
Using @dataclass, this becomes:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
p1 = Point(1, 2)
p2 = Point(1, 2)
print(p1)
print(p1 == p2)
Point(x=1, y=2)
True
The x: float and y: float lines are type-annotated field declarations,
not assignments—@dataclass reads them to work out what __init__
should accept and store, and what __repr__ and __eq__ should use.
@dataclassdoesn’t replace all your methods
@dataclassonly ever generates__init__,__repr__,__eq__, and (withorder=True) the ordering dunders__lt__,__le__,__gt__, and__ge__. Anything else—area(),perimeter(), arithmetic operators, custom validation—you still write yourself, exactly as before. It removes repetitive constructor/representation/equality boilerplate; it doesn’t write your class’s actual behaviour for you.
When the default
__eq__isn’t enough
@dataclass’s generated__eq__compares fields in order, as if they were a tuple. That’s not always what we want: ourTriangleclass needs to treat[3, 4, 5]and[4, 5, 3]as the same triangle, which a plain field-by-field comparison can’t express. If you define__eq__yourself in the class body, it overrides the one@dataclasswould otherwise have generated, so you can mix and match—let@dataclasswrite__init__and__repr__, and keep a hand-written__eq__where the default logic isn’t right.
Other comparisons
What about if we want to know how two objects compare to each other?
We can do this by implementing __lt__, __gt__, __le__, __ge__,
and __ne__, representing <, >, <=, >=, and !=
respectively. For example, an implementation of __lt__ might look
like:
def sides_with_max_first(self):
max_index = self.side_lengths.index(max(self.side_lengths))
if max_index == 0:
return self.side_lengths
elif max_index == 1:
return self.side_lengths[1:] + [self.side_lengths[0]]
else:
return [self.side_lengths[2]] + [self.side_lengths[:2]]
def __lt__(self, other):
if self.area() != other.area():
return self.area() < other.area()
elif self.perimeter() != other.perimeter():
return self.perimeter() < other.perimeter()
elif self == other:
return False
else:
return self.sides_with_max_first() < other.sides_with_max_first()
Testing this:
a_triangle = Triangle([3, 4, 5])
b_triangle = Triangle([5, 12, 13])
a_triangle < b_triangle
True
Python then does two very nice things for us: firstly, since we have
defined __lt__, it can now sort lists of Triangles for us. And
while we could leave only the < operator defined, this could be
confusing for those using the class; fortunately, given
implementations of __eq__ and __lt__, Python can automatically
generate the other relational operators using the
functools.total_ordering decorator.
from functools import total_ordering
@total_ordering
class Triangle(Polygon):
...
Ordering with
@dataclassIf a class uses
@dataclass, you get the same result by passingorder=Trueto the decorator (@dataclass(order=True)) instead of writing__lt__and applying@total_ordering—dataclasswill generate all of__lt__,__le__,__gt__, and__ge__for you, again comparing fields in field-declaration order. This only works for the default, tuple-like comparison;Triangle’s “same triangle in any rotation” logic still needs to be written by hand, as above.
Sorting random triangles
Add a class method that generates a triangle with three random edge lengths (for example, using
random.random()). Use this to construct and sort a list of 10 random triangles.Solution
Add an import at the top of the file:
from random import randomAlso add new class method:
@classmethod def random(cls): """Returns a triangle with three random length sides in the range [0, 1). If the sum of the two short sides isn't longer than the long side (and so the triangle doesn't close), then try again. There is an infinitesimal probability that this method will never return, as randomness keeps delivering invalid triangles.""" random_triangle = cls([random(), random(), random()]) while isinstance(random_triangle.area(), complex): random_triangle = cls([random(), random(), random()]) return random_triangleTesting this:
random_triangles = [Triangle.random() for _ in range(10)] [triangle.area() for triangle in sorted(random_triangles)]
Arithmetic
In the same way that
__lt__and friends correspond to relational operators, arithmetic operations like+,-,*, etc. can be defined with methods like__add__,__sub__, and__mul__.Define a new class
ErrorBarto represent a number with an associated error in Gaussian statistics, with fieldscentreanderror. Use@dataclassso you don’t have to write__init__by hand, and add__repr__,__add__,__sub__,__mul__, and__truediv__methods, making the (very unreasonable) assumption that all errors are uncorrelated.Solution
@dataclassgenerates__init__(and__eq__) for us from thecentreanderrorfield declarations. We still write__repr__ourselves, since we want the"centre ± error"format rather than the defaultErrorBar(centre=..., error=...)one that@dataclasswould otherwise generate—defining it in the class body overrides the auto-generated version. The arithmetic methods aren’t something@dataclasscan generate at all, so those are hand-written as before.from dataclasses import dataclass @dataclass class ErrorBar: centre: float error: float def __repr__(self): return f"{self.centre} ± {self.error}" def __add__(self, other): centre = self.centre + other.centre error = (self.error ** 2 + other.error ** 2) ** 0.5 return ErrorBar(centre, error) def __sub__(self, other): centre = self.centre - other.centre error = (self.error ** 2 + other.error ** 2) ** 0.5 return ErrorBar(centre, error) def __mul__(self, other): centre = self.centre * other.centre error = centre * ((self.error / self.centre) ** 2 + (other.error / other.centre) ** 2) ** 0.5 return ErrorBar(centre, error) def __truediv__(self, other): centre = self.centre / other.centre error = centre * ((self.error / self.centre) ** 2 + (other.error / other.centre) ** 2) ** 0.5 return ErrorBar(centre, error)Testing this, note that we also get
__eq__for free from@dataclass:a = ErrorBar(1.0, 0.1) b = ErrorBar(1.0, 0.1) print(a) print(a == b) print(a + ErrorBar(2.0, 0.2))1.0 ± 0.1 True 3.0 ± 0.223606797749979
Callable objects
By implementing the __call__ method, we can allow instances of a
class to be called like functions. For example, returning to the
FunctionPlotter example:
from numpy import linspace, sin
from matplotlib.colors import is_color_like
from matplotlib.pyplot import show, subplots
class FunctionPlotter:
def __init__(self, color="red", linewidth=1, x_min=-10, x_max=10):
self.color = color
self.linewidth = linewidth
self.x_min = x_min
self.x_max = x_max
@property
def color(self):
return self._color
@color.setter
def color(self, color):
assert is_color_like(color)
self._color = color
def plot(self, function):
"""Plot a function of a single argument.
The line is plotted in the colour specified by color, and with width
linewidth."""
fig, ax = subplots()
x = linspace(self.x_min, self.x_max, 1000)
ax.plot(x, function(x), color=self._color, linewidth=self.linewidth)
def __call__(self, *args, **kwargs):
return self.plot(*args, **kwargs)
plotter = FunctionPlotter()
plotter(sin)
show()
Subclassing with
__call__Do we need to redefine
__call__on each subclass ofFunctionPlotterto get the correct version of theplot()function? Why/why not?Solution
No;
selfreturns the current instance, so the call toself.plot()will pick up the correct version ofplot()for whichever class the instance is.
Collections and iterables
Python also gives us the power to make our objects behave like
iterable or collection types (for example tuples, lists, dicts, and
generators). For example, to let instances of the class behave with
the len() function, we implement __len__(). For example, adding
this to the Polygon class:
def __len__(self):
return len(self.side_lengths)
will define the length of the object as the number of edges that the
Polygon has. (Note that we shouldn’t make this the
perimeter—Python expects len() to return a non-negative
integer.) Testing this,
a_polygon = Polygon([1, 2, 3, 4, 5])
print(len(a_polygon))
5
We can also let our code loop over elements of our objects by
implementing the __iter__() method, which should return an
iterator; this is a particular type of object in Python that makes
things like for loops work. We can get one of these from any
iterable via the iter() function.
def __iter__(self):
return iter(self.side_lengths)
We can now iterate through the sides of our Polygons without having
to get the side_lengths property each time.
a_polygon = Polygon([1, 2, 3, 4, 5])
for side_length in a_polygon:
print(side_length)
1
2
3
4
5
In reverse
The
reversed()function returns an iterator over the elements of an iterable or collection going backwards. This is implemented for classes via the__reversed__method. Implement this for thePolygonclass, and test your implementation.Solution
Method:
def __reversed__(self): return reversed(self.side_lengths)Test:
a_polygon = Polygon([1, 2, 3, 4, 5]) for side_length in reversed(a_polygon): print(side_length)5 4 3 2 1
Getting specific elements
You can also allow your code to access elements via square brackets, just like with lists. The
__getitem__()method does this, taking the index (or key) being sought as its argument.For a non-dict-like collection,
__getitem__()can work for both integer indices and for slices.Implement
__getitem__()for thePolygonclass. Since in our current implementation ofPolygon, it doesn’t make sense to take a subset of the sides, requesting a slice should raiseIndexError; only requesting a single element with an integer index should work.Solution
Implementation:
def __getitem__(self, key): if type(key) is int: return self.side_lengths[key] else: raise IndexErrorTest:
a_polygon = Polygon([1, 2, 3, 4, 5]) print(a_polygon[2])3
forloops with__getitem__()Once a class has
__getitem__()defined, then Python will automatically work out how to loop over it, even in the absence of__iter__()(although adding this does make it more efficient). Even better, when__len__()is also implemented, then Python automatically knows how toreversed()the class as well.Test this by removing the implementations of
__iter__()and__reversed__()fromPolygonand testing the loops forwards and backwards again.
More dunder methods
Python offers many more dunder methods than could possibly be covered in this episode. A full listing, categorised by the functions that they serve, can be found in the Python documentation
Key Points
Implement methods like
__eq__,__add__, and__gt__to allow operations such as arithmetic and comparisons.Implement
__repr__to get more meaningful printouts when you output an object.Implement methods like
__len__,__iter__, and__reversed__to make instances of a class behave like a collection or iterable.Implement the
__call__method to make instances of a class callable like functions.Use
@dataclass(from thedataclassesmodule) to auto-generate__init__,__repr__, and__eq__for classes that mainly hold data; you can still override any of them yourself.