bigonotes
CS ConceptsOOP

Dunder Methods and @property

Which dunder methods Python calls for printing, comparing, adding and len(), and how @property gives computed attributes.

4 min readLast updated OOPListen · Watch on NotebookLM

Dunder ("double underscore") methods are special methods Python calls automatically in response to operations like +, print(), len() or ==. You rarely call them directly; you define them so your own classes behave like built-in types.

v1 + v2        # v1.__add__(v2)
print(obj)     # obj.__str__()
len(obj)       # obj.__len__()
obj == other   # obj.__eq__(other)

Every class inherits defaults from object. For built-ins like int and list these are already sensible; for your own classes they are usually not, so you override them.

Object creation: __new__ and __init__#

Creating an object takes two dunder calls:

  • __new__(cls, ...) creates and returns the empty object. Called first. Override rarely (e.g. Singleton).
  • __init__(self, ...) fills self.__dict__ with instance variables. Called second, always returns None. Override almost always.

See Classes and Objects for the full walkthrough.

String representations: __str__ and __repr__#

Without these, printing your object shows something like <Dog object at 0x7f...>.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):    # for end users
        return f"{self.name} ({self.age} years)"

    def __repr__(self):   # for developers: unambiguous, ideally valid code
        return f"Dog(name={self.name!r}, age={self.age!r})"

d = Dog("Bruno", 3)
print(d)   # Bruno (3 years)
repr(d)    # Dog(name='Bruno', age=3)
__str__ __repr__
Purpose Human-readable Developer/debug-readable
Triggered by print(obj), str(obj), f-strings repr(obj), the REPL, inside containers
Fallback Uses __repr__ if not defined Default <... object at 0x...>

Define __repr__ at minimum: str() falls back to it, and lists of your objects print with it.

Comparisons: __eq__ and __lt__#

By default == compares identity (same object in memory), so two equal-looking objects are not equal.

class Dog:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __eq__(self, other):
        if not isinstance(other, Dog):
            return NotImplemented          # let Python try the other side
        return (self.name, self.age) == (other.name, other.age)

    def __lt__(self, other):
        return self.age < other.age        # order dogs by age

print(Dog("Bruno", 3) == Dog("Bruno", 3))  # True

dogs = [Dog("Max", 5), Dog("Bruno", 3), Dog("Charlie", 1)]
dogs.sort()                                # sort() uses __lt__
print([d.name for d in dogs])              # ['Charlie', 'Bruno', 'Max']
  • self is the left operand, other the right.
  • Without __lt__, d1 < d2 on custom objects raises TypeError.
  • Defining __eq__ sets __hash__ to None, so the objects can no longer go in sets or be dict keys unless you also define __hash__.

Arithmetic: __add__#

Define __add__ and the + operator works on your objects:

class Vector:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __add__(self, other):
        # Return the same type, so chaining works
        return Vector(self.x + other.x, self.y + other.y)

v = Vector(1, 2) + Vector(3, 4) + Vector(1, 1)
print(v.x, v.y)  # 5 7

Without __add__, v1 + v2 raises TypeError.

Containers: __len__ and __contains__#

These two make your class behave like a built-in collection:

class Library:
    def __init__(self):
        self.books = []

    def __len__(self):
        return len(self.books)

    def __contains__(self, book):
        return book in self.books

lib = Library()
lib.books.append("Harry Potter")
print(len(lib))                    # 1
print("Harry Potter" in lib)       # True
print("Game of Thrones" in lib)    # False

Without __len__, len() raises TypeError. Without __contains__, in falls back to iterating (__iter__ or __getitem__) and raises TypeError if neither exists.

Cheat sheet#

The dunders you'll use most often:

Dunder Triggered by Default for your class
__new__ Dog() Creates the object
__init__ Dog() Does nothing
__str__ print, str() Falls back to __repr__
__repr__ repr(), REPL <Dog object at 0x...>
__eq__ == Identity comparison
__lt__ <, sort() TypeError
__add__ + TypeError
__len__ len() TypeError
__contains__ in Iterates if possible, else TypeError

@property: computed attributes#

@property turns a method into something you read like an attribute. The value is computed on every access, not stored in the instance __dict__, so it stays in sync with the data it depends on.

import math

class Circle:
    def __init__(self, radius):
        self.radius = radius

    @property
    def area(self):
        return math.pi * self.radius ** 2

    @property
    def diameter(self):
        return self.radius * 2

c = Circle(5)
print(round(c.area, 1))  # 78.5, no () needed
c.radius = 10
print(round(c.area, 1))  # 314.2, updated automatically

For validated setters, see Encapsulation.

Where things live#

d = Dog("Bruno", 3)
print(d.__dict__)    # {'name': 'Bruno', 'age': 3}: instance variables only
print(Dog.__dict__)  # methods, class variables, properties
What Stored in
Instance variables obj.__dict__
Methods and class variables Class.__dict__
@property Class.__dict__, as a descriptor (not a value)
Instance variable @property
In instance __dict__? Yes No
Value Stored Computed on access
Updates when inputs change? No Yes
Use when The value is independent The value derives from other attributes

Key takeaways#

  • Python calls dunder methods for you when you use operators and built-ins.
  • Define __repr__ at minimum; __str__ is for friendly output.
  • Default == is identity; define __eq__ (and __hash__ if needed) for value equality.
  • Return your own type from arithmetic dunders so expressions chain.
  • @property values are computed on access and live on the class as descriptors.

Found a mistake? Tell me on LinkedIn ↗

Further reading#