Skip to content
Educora
Advanced25 min19 / 42

Advanced OOP: special methods, properties and the MRO

Make your classes behave like built-in types: `__repr__`/`__str__`, `__eq__`/`__lt__`/`__hash__`, operator overloading, the container protocol, `@property`, class and static methods, the MRO, `super()` and `abc.ABC`.

Check yourself
In this lesson you will learn
  • Implement __repr__, __str__, __eq__, __lt__, __hash__ and operator methods correctly
  • Add validation with @property and 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.

Python
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__:

Python
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():

Python
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
ExpressionMethod 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:

Python
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:

Python
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:

Python
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 False

Inheritance, 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”**:

Python
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:

Python
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'
Exercise

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.

Exercise · Python
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
Exercise

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.

Exercise · Python
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 return NotImplemented for foreign types; defining __eq__ removes __hash__ unless you add it back.
  • @property hides validation behind attribute syntax; @classmethod gets cls (alternative constructors), @staticmethod gets neither.
  • super() follows the MRO of the actual object; the abstract methods of an ABC must be implemented before it can be instantiated.

Check yourself

10 questions. Every correct answer earns XP.

1 / 10
Which method does print([obj]) use to show the object inside the list?