- Implement
__repr__,__str__,__eq__,__lt__,__hash__and operator methods correctly - Add validation with
@propertyand use class and static methods appropriately - Explain the MRO and how
super()works with multiple inheritance - Create an abstract base class with
abc.ABC
Why does + add numbers, join strings and concatenate lists? Why do len(), in, sorted() and print() work with objects of completely different types? Because Python turns every such operation into a call of a special method: a + b becomes a.__add__(b), and len(x) becomes x.__len__(). These are also called dunder methods (from “double underscore”). Implement them in your own class, and its objects will behave like built-in types. This set of rules is called the Python data model.
__repr__ and __str__: how an object introduces itself
Every class inherits a __repr__ that prints something like <__main__.Money object at 0x7f…> — useless for debugging. __repr__ should be unambiguous and written for developers; ideally it looks like the code that creates the object. __str__ is the readable version for users. print() and f-strings use __str__, while the interactive console, lists and the !r conversion use __repr__. If only __repr__ is defined, str() falls back to it.
class Money:
def __init__(self, amount, currency='AZN'):
self.amount = amount
self.currency = currency
def __repr__(self):
return f'Money({self.amount!r}, {self.currency!r})'
def __str__(self):
return f'{self.amount:.2f} {self.currency}'
price = Money(12.5)
print(price)
print(repr(price))
print([price, Money(3, 'USD')])
print(f'{price} | {price!r}')▸ Expected output
12.50 AZN Money(12.5, 'AZN') [Money(12.5, 'AZN'), Money(3, 'USD')] 12.50 AZN | Money(12.5, 'AZN')
Equality, ordering and hashing
Without __eq__, == compares identity: two objects with the same data are not equal. When the other operand has an unsupported type, __eq__ should not raise an error but return the special value **NotImplemented: Python then tries the reflected operation on the other object and, if that fails too, falls back to identity. The decorator functools.total_ordering** derives <=, > and >= from __eq__ and __lt__:
from functools import total_ordering
@total_ordering
class Money:
def __init__(self, amount, currency='AZN'):
self.amount, self.currency = amount, currency
def __eq__(self, other):
if not isinstance(other, Money):
return NotImplemented
return (self.amount, self.currency) == (other.amount, other.currency)
def __lt__(self, other):
if not isinstance(other, Money) or other.currency != self.currency:
return NotImplemented
return self.amount < other.amount
def __hash__(self):
return hash((self.amount, self.currency))
prices = [Money(5), Money(2.5), Money(10)]
print([m.amount for m in sorted(prices)])
print(Money(5) == Money(5), Money(5) >= Money(2.5), Money(5) == 5)
print(len({Money(1), Money(1)}))▸ Expected output
[2.5, 5, 10] True True False 1
Operator overloading and the container protocol
Arithmetic works the same way. For a + b Python first calls a.__add__(b); if that returns NotImplemented, it tries b.__radd__(a) — the reflected method. That is why 2 * v below needs __rmul__: int knows nothing about vectors. __bool__ decides what if v: means, and __abs__ serves the built-in abs():
class Vector:
def __init__(self, x, y):
self.x, self.y = x, y
def __repr__(self):
return f'Vector({self.x}, {self.y})'
def __add__(self, other):
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, k):
return Vector(self.x * k, self.y * k)
__rmul__ = __mul__
def __abs__(self):
return (self.x ** 2 + self.y ** 2) ** 0.5
def __bool__(self):
return bool(self.x or self.y)
v = Vector(3, 4)
print(v + Vector(1, 1), v * 2, 2 * v)
print(abs(v), bool(Vector(0, 0)))▸ Expected output
Vector(4, 5) Vector(6, 8) Vector(6, 8) 5.0 False
| Expression | Method called |
|---|---|
a + b, a - b, a * b | __add__, __sub__, __mul__ (__radd__, …) |
a == b, a < b | __eq__, __lt__ |
len(a), a[i], x in a | __len__, __getitem__, __contains__ |
for x in a | __iter__ (__getitem__) |
abs(a), bool(a), hash(a) | __abs__, __bool__, __hash__ |
str(a), repr(a), a() | __str__, __repr__, __call__ |
For a += b Python first looks for __iadd__ (an in-place version that may modify the object) and falls back to __add__. That is why += changes a list in place but creates a new object for tuples and strings. The method __call__ makes an object callable like a function: obj().
Your class can also behave like a collection. __len__ serves len(), __getitem__ serves indexing and slicing (the slice object is passed straight through to the list), and __contains__ serves the operator in. Even for works: when a class has no __iter__, Python falls back to calling __getitem__ with 0, 1, 2… until IndexError appears:
class Playlist:
def __init__(self, *songs):
self._songs = list(songs)
def __len__(self):
return len(self._songs)
def __getitem__(self, index):
return self._songs[index]
def __contains__(self, song):
return song.lower() in (s.lower() for s in self._songs)
p = Playlist('Morning', 'Rain', 'Finale')
print(len(p), p[0], p[-1], p[1:])
print('rain' in p)
for song in p:
print('>', song)▸ Expected output
3 Morning Finale ['Rain', 'Finale'] True > Morning > Rain > Finale
@property, @classmethod and @staticmethod
A property looks like an ordinary attribute from outside, but reading or assigning it runs a method. This lets you add validation later without changing the code that uses the class — t.celsius = -300 is still a normal assignment. Note that __init__ also goes through the setter, so even a new object cannot start in an invalid state. A property without a setter, like fahrenheit, is read-only and computed on the fly:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError('below absolute zero')
self._celsius = value
@property
def fahrenheit(self):
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius, t.fahrenheit)
try:
t.celsius = -300
except ValueError as e:
print('error:', e)▸ Expected output
25 77.0 error: below absolute zero
A class method (@classmethod) receives the class itself as cls instead of an instance. Its main use is alternative constructors such as from_csv: because it calls cls(...), a subclass automatically gets objects of its own type. A static method (@staticmethod) receives neither self nor cls — it is an ordinary function that lives in the class namespace because it logically belongs there. Also note the class attribute count, shared by all instances:
class Student:
count = 0
def __init__(self, name, grade):
self.name, self.grade = name, grade
Student.count += 1
@classmethod
def from_csv(cls, line):
name, grade = line.split(',')
return cls(name, int(grade))
@staticmethod
def is_valid_grade(grade):
return 1 <= grade <= 11
def __repr__(self):
return f'{type(self).__name__}({self.name!r}, {self.grade})'
class Graduate(Student):
pass
print(Student.from_csv('Aysel,9'), Graduate.from_csv('Murad,11'))
print(Student.count, Student.is_valid_grade(12))▸ Expected output
Student('Aysel', 9) Graduate('Murad', 11)
2 FalseInheritance, the MRO and super()
With multiple inheritance Python needs a rule for looking up methods. It computes the MRO (method resolution order) with the C3 algorithm: a child comes before its parents, the parents keep the order in which they are listed, and every class appears only once. You can see it in __mro__. The key point: **super() does not mean “my parent”, but “the next class in the MRO of the actual object”**:
class Base:
def save(self):
print('Base.save')
class Logged(Base):
def save(self):
print('Logged: before')
super().save()
class Validated(Base):
def save(self):
print('Validated: checking')
super().save()
class Order(Logged, Validated):
pass
print([c.__name__ for c in Order.__mro__])
Order().save()▸ Expected output
['Order', 'Logged', 'Validated', 'Base', 'object'] Logged: before Validated: checking Base.save
Logged inherits directly from Base, but its super().save() called Validated.save(), because that is the next class in the MRO of Order. Thanks to this, Base.save() ran exactly once. Such cooperating classes, called mixins, only work if every method calls super() and none of them calls a parent directly as Base.save(self).
An abstract base class defines a contract that subclasses must fulfil. A class that inherits from abc.ABC and has methods marked with @abstractmethod cannot be instantiated until a subclass implements all of them — the error appears when the object is created, not in the middle of the work:
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self): ...
def describe(self):
return f'{type(self).__name__} with area {self.area():.2f}'
class Rect(Shape):
def __init__(self, w, h):
self.w, self.h = w, h
def area(self):
return self.w * self.h
print(Rect(3, 4.5).describe())
try:
Shape()
except TypeError as e:
print(e)▸ Expected output
Rect with area 13.50 Can't instantiate abstract class Shape without an implementation for abstract method 'area'
Add __add__, __radd__ and __eq__ to the Money class so that two amounts can be added, a list can be totalled with sum() and the result can be compared. For foreign types __add__ must return NotImplemented.
class Money:
def __init__(self, amount):
self.amount = amount
def __repr__(self):
return f'Money({self.amount})'
# add __add__, __radd__ and __eq__ here
wallet = [Money(5), Money(10), Money(2)]
print(Money(1) + Money(2))
print(sum(wallet))
print(sum(wallet) == Money(17))▸ Expected output
Money(3) Money(17) True
Add __len__ and __repr__ to the Stack class. Do not write a separate method for bool(s) — an empty stack should count as False on its own.
class Stack:
def __init__(self):
self._items = []
def push(self, item):
self._items.append(item)
def pop(self):
return self._items.pop()
# add __len__ and __repr__ here
s = Stack()
print(bool(s))
s.push('a')
s.push('b')
print(s, len(s), bool(s))
print(s.pop(), s)▸ Expected output
False Stack(['a', 'b']) 2 True b Stack(['a'])
Key points
- Operators and built-ins call special methods:
a + b→__add__,len(x)→__len__,x in c→__contains__. __repr__is for developers,__str__for users; containers show__repr__.__eq__should returnNotImplementedfor foreign types; defining__eq__removes__hash__unless you add it back.@propertyhides validation behind attribute syntax;@classmethodgetscls(alternative constructors),@staticmethodgets neither.super()follows the MRO of the actual object; the abstract methods of anABCmust be implemented before it can be instantiated.
Check yourself
10 questions. Every correct answer earns XP.
print([obj]) use to show the object inside the list?