Python course Β· Module 3: Object-Oriented Programming

Magic methods - hidden animal behaviors

13 min read
In this lesson11

Welcome back! Darwin here with a fascinating lesson about Python's hidden powers.

In nature, animals have hidden behaviors - a lion roars, an elephant trumpets, a chameleon camouflages. In Python, classes can also have hidden behaviors through magic methods - special methods with double underscores!

1lion = Animal("Lion")
2print(lion)  # How does Python know what to display? __str__!
3len(lion)    # How does Python know what to measure? __len__!
4lion + elephant  # How does Python know what to add? __add__!

What are magic methods?

Magic methods (also called dunder methods from "double underscore") are special methods with names in the format __method__ that Python automatically calls in specific situations.

Safari Analogy: They're like natural instincts of animals - you don't have to teach them, they're built into their nature!

1class Animal:
2    def __init__(self, name):
3        """Called when CREATING an object"""
4        self.name = name
5
6    def __str__(self):
7        """Called by print() and str()"""
8        return f"Animal: {self.name}"
9
10    def __len__(self):
11        """Called by len()"""
12        return len(self.name)
13
14lion = Animal("Lion")  # Calls __init__
15print(lion)           # Calls __str__
16print(len(lion))      # Calls __len__

Why "magic"?

  • Python calls them automatically
  • They make your classes behave like built-in types
  • They allow using standard Python syntax

Basic magic methods

1. __init__ - Constructor

You already know this one! Called when creating an object.

1class Species:
2    def __init__(self, name, population):
3        """Initialization - you already know this!"""
4        self.name = name
5        self.population = population
6
7lion = Species("Lion", 500)  # Calls __init__

2. __str__ - Human-readable representation

Called by print() and str() - returns a user-friendly string. When you write print(lion), print() first converts the argument to text with str(lion), and str() calls the method lion.__str__().

1class Animal:
2    def __init__(self, name, age):
3        self.name = name
4        self.age = age
5
6    def __str__(self):
7        """For humans - readable"""
8        return f"{self.name} (age: {self.age} years)"
9
10lion = Animal("Simba", 5)
11print(lion)  # "Simba (age: 5 years)" - calls __str__
12print(str(lion))  # Same thing

3. __repr__ - Programmer representation

Called by repr() and in the console - returns an unambiguous object representation.

1class Animal:
2    def __init__(self, name, age):
3        self.name = name
4        self.age = age
5
6    def __repr__(self):
7        """For programmers - precise"""
8        return f"Animal(name='{self.name}', age={self.age})"
9
10    def __str__(self):
11        """For users - readable"""
12        return f"{self.name} ({self.age} years)"
13
14lion = Animal("Simba", 5)
15
16# In Python console
17>>> lion
18Animal(name='Simba', age=5)  # Calls __repr__
19
20# In print
21print(lion)  # "Simba (5 years)" - calls __str__
22
23# repr() explicitly
24print(repr(lion))  # "Animal(name='Simba', age=5)"

Rule: __repr__ should return code that can recreate the object!

1class Point:
2    def __init__(self, x, y):
3        self.x = x
4        self.y = y
5
6    def __repr__(self):
7        return f"Point({self.x}, {self.y})"
8
9p = Point(3, 4)
10print(repr(p))  # "Point(3, 4)" - you can copy and paste!

Comparison methods

Allow comparing objects with ==, <, >, etc.

1class Animal:
2    def __init__(self, name, weight):
3        self.name = name
4        self.weight = weight  # kg
5
6    def __eq__(self, other):
7        """Equality: =="""
8        if not isinstance(other, Animal):
9            return False
10        return self.weight == other.weight
11
12    def __lt__(self, other):
13        """Less than: <"""
14        if not isinstance(other, Animal):
15            return NotImplemented
16        return self.weight < other.weight
17
18    def __le__(self, other):
19        """Less than or equal: <="""
20        return self.weight <= other.weight
21
22    def __gt__(self, other):
23        """Greater than: >"""
24        return self.weight > other.weight
25
26    def __ge__(self, other):
27        """Greater than or equal: >="""
28        return self.weight >= other.weight
29
30    def __ne__(self, other):
31        """Not equal: !="""
32        return not self.__eq__(other)
33
34lion = Animal("Lion", 190)
35elephant = Animal("Elephant", 5000)
36rhino = Animal("Rhino", 2300)
37
38print(lion < elephant)  # True - calls __lt__
39print(elephant > rhino)  # True - calls __gt__
40print(lion == Animal("Tiger", 190))  # True - same weight

Tip: You can use the @functools.total_ordering decorator - define only __eq__ and one of __lt__, __le__, __gt__, __ge__, and the rest will be automatically generated!

1from functools import total_ordering
2
3@total_ordering
4class Animal:
5    def __init__(self, name, weight):
6        self.name = name
7        self.weight = weight
8
9    def __eq__(self, other):
10        """Only this and __lt__ - the rest automatically!"""
11        if not isinstance(other, Animal):
12            return False
13        return self.weight == other.weight
14
15    def __lt__(self, other):
16        """Only this and __eq__ - the rest automatically!"""
17        if not isinstance(other, Animal):
18            return NotImplemented
19        return self.weight < other.weight
20
21    # __le__, __gt__, __ge__, __ne__ - automatically generated!

Arithmetic methods

Allow using mathematical operators with objects!

1class Population:
2    """Represents a species population"""
3
4    def __init__(self, species, count):
5        self.species = species
6        self.count = count
7
8    def __add__(self, other):
9        """Addition: +"""
10        if isinstance(other, Population):
11            if self.species != other.species:
12                raise ValueError("Different species!")
13            return Population(self.species, self.count + other.count)
14        elif isinstance(other, int):
15            return Population(self.species, self.count + other)
16        return NotImplemented
17
18    def __sub__(self, other):
19        """Subtraction: -"""
20        if isinstance(other, int):
21            return Population(self.species, max(0, self.count - other))
22        return NotImplemented
23
24    def __mul__(self, other):
25        """Multiplication: *"""
26        if isinstance(other, (int, float)):
27            return Population(self.species, int(self.count * other))
28        return NotImplemented
29
30    def __str__(self):
31        return f"{self.species}: {self.count} individuals"
32
33# Using operators!
34lions_north = Population("Lion", 120)
35lions_south = Population("Lion", 85)
36
37# Adding populations
38total_lions = lions_north + lions_south
39print(total_lions)  # "Lion: 205 individuals"
40
41# Adding a number
42more_lions = lions_north + 30
43print(more_lions)  # "Lion: 150 individuals"
44
45# Subtraction (population decrease)
46fewer_lions = lions_north - 20
47print(fewer_lions)  # "Lion: 100 individuals"
48
49# Multiplication (estimating growth)
50projected = lions_north * 1.5  # 50% growth
51print(projected)  # "Lion: 180 individuals"

Container methods

Make your objects behave like lists, dictionaries!

__len__ - Length

When a class has a __len__ method, the len() function works on its objects just like on a list. The pack returns the number of its members:

1class Pack:
2    """Animal pack"""
3
4    def __init__(self, species):
5        self.species = species
6        self.members = []
7
8    def add(self, name):
9        self.members.append(name)
10
11    def __len__(self):
12        """Called by len()"""
13        return len(self.members)
14
15pack = Pack("Lions")
16pack.add("Simba")
17pack.add("Nala")
18pack.add("Mufasa")
19
20print(len(pack))  # 3 - calls __len__

__getitem__ and __setitem__ - Index access

These two methods let you read and change an object's elements with square brackets, as in a list. Along the way the habitat also gets __contains__, which handles the in operator:

1class Habitat:
2    """Habitat with animals"""
3
4    def __init__(self, name):
5        self.name = name
6        self.animals = []
7
8    def __getitem__(self, index):
9        """Read access: habitat[0]"""
10        return self.animals[index]
11
12    def __setitem__(self, index, value):
13        """Write access: habitat[0] = "Lion" """
14        self.animals[index] = value
15
16    def __len__(self):
17        return len(self.animals)
18
19    def __contains__(self, item):
20        """Operator 'in': "Lion" in habitat"""
21        return item in self.animals
22
23    def append(self, animal):
24        self.animals.append(animal)
25
26savanna = Habitat("Savanna")
27savanna.append("Lion")
28savanna.append("Elephant")
29savanna.append("Giraffe")
30
31# Access like a list!
32print(savanna[0])  # "Lion" - calls __getitem__
33savanna[1] = "Rhino"  # Calls __setitem__
34
35# Operator in
36print("Lion" in savanna)  # True - calls __contains__
37
38# len()
39print(len(savanna))  # 3 - calls __len__
40
41# Iteration (automatic through __getitem__)
42for animal in savanna:
43    print(animal)

__iter__ and __next__ - Iteration

A for loop first calls __iter__ and then __next__ on every step until it gets a StopIteration exception. That is how we will build a calendar of expedition days:

1class Expedition:
2    """Iterator over expedition days"""
3
4    def __init__(self, days):
5        self.days = days
6        self.current_day = 0
7
8    def __iter__(self):
9        """Return iterator (self)"""
10        self.current_day = 0
11        return self
12
13    def __next__(self):
14        """Return next element"""
15        if self.current_day >= self.days:
16            raise StopIteration  # End of iteration
17
18        self.current_day += 1
19        return f"Day {self.current_day}"
20
21expedition = Expedition(5)
22
23# Iteration with for
24for day in expedition:
25    print(day)
26# Day 1
27# Day 2
28# Day 3
29# Day 4
30# Day 5
31
32# Or manually
33exp2 = Expedition(3)
34print(next(exp2))  # "Day 1"
35print(next(exp2))  # "Day 2"
36print(next(exp2))  # "Day 3"

A fourth call to next(exp2) would raise StopIteration. It is the same signal that makes a for loop stop on its own.

__call__ - Callable objects

Makes an object callable like a function!

1class AnimalSound:
2    """Callable - object like a function"""
3
4    def __init__(self, species, sound):
5        self.species = species
6        self.sound = sound
7
8    def __call__(self, times=1):
9        """Call object like a function"""
10        return " ".join([self.sound] * times)
11
12lion_roar = AnimalSound("Lion", "ROAR")
13elephant_trumpet = AnimalSound("Elephant", "TRUUU")
14
15# Call like a function!
16print(lion_roar())  # "ROAR" - calls __call__
17print(lion_roar(3))  # "ROAR ROAR ROAR"
18print(elephant_trumpet(2))  # "TRUUU TRUUU"

Context managers - __enter__ and __exit__

Allow using the with statement!

1class SafariCamera:
2    """Context manager for taking photos"""
3
4    def __init__(self, location):
5        self.location = location
6        self.photos = []
7
8    def __enter__(self):
9        """Called when entering the 'with' block"""
10        print(f"Turning on camera in {self.location}")
11        return self  # Return object for use
12
13    def __exit__(self, exc_type, exc_val, exc_tb):
14        """Called when exiting the 'with' block"""
15        print(f"Photos saved: {len(self.photos)}")
16        print(f"Turning off camera")
17        return False  # Don't suppress exceptions
18
19    def take_photo(self, subject):
20        """Take a photo"""
21        self.photos.append(subject)
22        print(f"  Photo: {subject}")
23
24# Usage with 'with' - automatic __enter__ and __exit__!
25with SafariCamera("Serengeti") as camera:
26    camera.take_photo("Lion hunting")
27    camera.take_photo("Elephant herd")
28    camera.take_photo("Giraffe by a tree")

When you run it, the console shows:

1Turning on camera in Serengeti
2  Photo: Lion hunting
3  Photo: Elephant herd
4  Photo: Giraffe by a tree
5Photos saved: 3
6Turning off camera

We never called __enter__ or __exit__ by hand. with ran the first method at the start of the block and the second one automatically at its end, and it would do so even if an error happened inside.

Safari example - complete cataloging system

Finally, a species class that uses almost every magic method from this lesson: its objects have a readable description, compare by population, support + and -, behave like a container and can be called like a function.

1from functools import total_ordering
2from datetime import datetime
3
4@total_ordering
5class Species:
6    """
7    Species class with a full set of magic methods
8
9    Behaves like a built-in Python type!
10    """
11
12    all_species = []  # Registry of all species
13
14    def __init__(self, scientific_name, common_name, population, habitat):
15        """Constructor"""
16        self.scientific_name = scientific_name
17        self.common_name = common_name
18        self.population = population
19        self.habitat = habitat
20        self.observations = []
21
22        Species.all_species.append(self)
23
24    # === REPRESENTATION ===
25
26    def __str__(self):
27        """For print() - human-friendly"""
28        return f"{self.common_name} ({self.population} individuals)"
29
30    def __repr__(self):
31        """For repr() - unambiguous"""
32        return (f"Species(scientific_name='{self.scientific_name}', "
33                f"common_name='{self.common_name}', "
34                f"population={self.population}, "
35                f"habitat='{self.habitat}')")
36
37    # === COMPARISONS (only __eq__ and __lt__, rest via @total_ordering) ===
38
39    def __eq__(self, other):
40        """Equality: =="""
41        if not isinstance(other, Species):
42            return False
43        return self.population == other.population
44
45    def __lt__(self, other):
46        """Less than: < (comparison by population)"""
47        if not isinstance(other, Species):
48            return NotImplemented
49        return self.population < other.population
50
51    def __hash__(self):
52        """Hash from the same field as __eq__ - equal objects must have equal hashes"""
53        return hash(self.population)
54
55    # === ARITHMETIC ===
56
57    def __add__(self, other):
58        """Addition: species + 50"""
59        if isinstance(other, int):
60            return Species(
61                self.scientific_name,
62                self.common_name,
63                self.population + other,
64                self.habitat
65            )
66        return NotImplemented
67
68    def __sub__(self, other):
69        """Subtraction: species - 20"""
70        if isinstance(other, int):
71            return Species(
72                self.scientific_name,
73                self.common_name,
74                max(0, self.population - other),
75                self.habitat
76            )
77        return NotImplemented
78
79    # === CONTAINER ===
80
81    def __len__(self):
82        """len(species) - number of observations"""
83        return len(self.observations)
84
85    def __getitem__(self, index):
86        """species[0] - access to observations"""
87        return self.observations[index]
88
89    def __contains__(self, location):
90        """'Serengeti' in species - check location"""
91        return any(obs["location"] == location for obs in self.observations)
92
93    # === CALLABLE ===
94
95    def __call__(self, location, count):
96        """Call like a function - add observation"""
97        observation = {
98            "date": datetime.now().strftime("%Y-%m-%d"),
99            "location": location,
100            "count": count
101        }
102        self.observations.append(observation)
103        return f"Added: {count}x {self.common_name} in {location}"
104
105    # === BOOL ===
106
107    def __bool__(self):
108        """bool(species) - does the species have any population?"""
109        return self.population > 0
110
111# === DEMONSTRATION OF ALL MAGIC METHODS ===
112
113print("=== CREATING SPECIES ===\n")
114
115lion = Species("Panthera leo", "Lion", 120, "savanna")
116elephant = Species("Loxodonta africana", "Elephant", 450, "savanna")
117rhino = Species("Diceros bicornis", "Rhino", 45, "savanna")
118extinct = Species("Dodo", "Dodo", 0, "Mauritius")
119
120# __str__ and __repr__
121print("__str__ (print):")
122print(lion)  # "Lion (120 individuals)"
123
124print("\n__repr__ (repr):")
125print(repr(lion))
126# Species(scientific_name='Panthera leo', common_name='Lion', population=120, habitat='savanna')
127
128# Comparisons (__eq__, __lt__, etc.)
129print("\n=== COMPARISONS ===")
130print(f"lion == elephant? {lion == elephant}")  # False
131print(f"rhino < lion? {rhino < lion}")  # True (45 < 120)
132print(f"elephant > lion? {elephant > lion}")  # True (450 > 120)
133
134# Sorting (works through __lt__)
135species_list = [lion, rhino, elephant]
136species_list.sort()
137print(f"\nSorted (ascending): {[str(s) for s in species_list]}")
138# ['Rhino (45 individuals)', 'Lion (120 individuals)', 'Elephant (450 individuals)']
139
140# Arithmetic (__add__, __sub__)
141print("\n=== ARITHMETIC ===")
142more_lions = lion + 30  # Increase population
143print(f"lion + 30 = {more_lions}")  # "Lion (150 individuals)"
144
145fewer_rhinos = rhino - 10
146print(f"rhino - 10 = {fewer_rhinos}")  # "Rhino (35 individuals)"
147
148# Callable (__call__)
149print("\n=== CALLABLE - adding observations ===")
150print(lion("Serengeti", 12))  # Call like a function!
151print(lion("Masai Mara", 8))
152print(elephant("Amboseli", 35))
153
154# Container (__len__, __getitem__, __contains__)
155print("\n=== CONTAINER ===")
156print(f"Number of lion observations: {len(lion)}")  # __len__
157print(f"First observation: {lion[0]}")  # __getitem__
158print(f"'Serengeti' in lion? {'Serengeti' in lion}")  # __contains__ - True
159
160# Iteration (through __getitem__)
161print("\nAll lion observations:")
162for obs in lion:
163    print(f"  - {obs['date']} in {obs['location']}: {obs['count']} individuals")
164
165# Bool (__bool__)
166print("\n=== BOOL ===")
167print(f"bool(lion)? {bool(lion)}")  # True - has population
168print(f"bool(extinct)? {bool(extinct)}")  # False - population 0
169
170if lion:
171    print("Lion has a population!")
172
173if not extinct:
174    print("Dodo is extinct...")
175
176# Hash (__hash__) - works in set and dict
177print("\n=== HASH - usage in set/dict ===")
178species_set = {lion, elephant, rhino}
179print(f"Species set: {len(species_set)} elements")
180
181species_dict = {
182    lion: "Predator",
183    elephant: "Herbivore",
184    rhino: "Herbivore"
185}
186print(f"Lion type: {species_dict[lion]}")

List of key magic methods

Initialization and representation

  • __init__(self, ...) - constructor
  • __new__(cls, ...) - creates the object; Python calls it before __init__ (rarely overridden)
  • __str__(self) - string for the user (print)
  • __repr__(self) - string for the programmer (repr)
  • __format__(self, format_spec) - formatting (f"{obj:spec}")

Comparisons

  • __eq__(self, other) - equality ==
  • __ne__(self, other) - inequality !=
  • __lt__(self, other) - less than <
  • __le__(self, other) - less than or equal <=
  • __gt__(self, other) - greater than >
  • __ge__(self, other) - greater than or equal >=

Arithmetic

  • __add__(self, other) - addition +
  • __sub__(self, other) - subtraction -
  • __mul__(self, other) - multiplication *
  • __truediv__(self, other) - division /
  • __floordiv__(self, other) - floor division //
  • __mod__(self, other) - modulo %
  • __pow__(self, other) - exponentiation **

Container

  • __len__(self) - length len()
  • __getitem__(self, key) - read obj[key]
  • __setitem__(self, key, value) - write obj[key] = value
  • __delitem__(self, key) - delete del obj[key]
  • __contains__(self, item) - membership item in obj
  • __iter__(self) - iterator for x in obj
  • __next__(self) - next element

Callable and Context Manager

  • __call__(self, ...) - call obj()
  • __enter__(self) - enter with
  • __exit__(self, ...) - exit with

Other

  • __bool__(self) - conversion to bool
  • __hash__(self) - hash for set/dict
  • __del__(self) - destructor (rarely used)

Summary

In this lesson you learned:

  • What magic methods (dunder methods) are
  • How to make classes behave like built-in types
  • Implementing __str__, __repr__, __eq__, __lt__
  • Arithmetic operators (__add__, __sub__, __mul__)
  • Container behaviors (__len__, __getitem__, __contains__)
  • Callable objects with __call__
  • Context managers with __enter__ and __exit__
  • Practical example with a complete cataloging system

Checkpoint

Before moving on:

  • You understand the difference between __str__ and __repr__
  • You can implement comparisons
  • You know how to add arithmetic operators
  • You understand __len__, __getitem__, __iter__
  • You can create a callable object with __call__

Safari Analogy: Magic methods are animal instincts - a lion doesn't learn to roar, it just does it! Your classes can have natural behaviors too!

In the next lesson Darwin will teach you type hints - how to precisely describe data types so your code is more readable and safe!

Spotted a mistake in this lesson?

Check yourself

Answer the questions from this lesson. Pick an answer to see right away whether it is correct.

  1. 1. What is the difference between __str__ and __repr__?

  2. 2. When is the __len__ method called?

These are 2 of 3 questions for this lesson. Solve the rest in the game.

Hands-on tasks in the game

  • Vertical ordering

    Arrange the object lifecycle:

  • Code editor

    Add a __str__ method to the Animal class that returns 'Animal: {name}'

  • Code editor

    In the Animal(name, species) class, add an __eq__ method that compares animals by species (same species = equal)

  • Click in order

    Click elements in the correct order:

  • Click in order

    Click the parts of the header of the method that print(object) calls:

Useful articles