Courseiva
PCAP-31-03Chapter 16 of 17Objective 5.4

Advanced OOP: Special Methods (Dunder) and Multiple Inheritance

Advanced OOP with special methods (dunder methods) and multiple inheritance gives you the power to make your classes behave like built-in Python objects, such as strings or numbers. For the PCAP-31-03 exam, you must understand how to implement __init__, __str__, __repr__, and __eq__ to customise object creation, display, and comparison, and how Python resolves method calls when a class inherits from more than one parent. This knowledge separates a basic script from a well-designed, reusable codebase that passes the exam.

12 min read
Advanced
Updated Jul 23, 2026
Editorial oversight: Johnson Ajibi· Security Systems Architect · MSc IT Security · IEEE Senior Member

A simple way to picture Advanced OOP: Special Methods (Dunder) and Multiple Inheritance

The Restaurant Inheritance Analogy

4 family members each bring a unique dish to a potluck dinner. Your task is to combine them all into one meal on one plate. But there's a catch: if two dishes have the same name, you must follow a specific rule to decide which one ends up on your plate.

You are the chef. The family members are Parent classes, their dishes are methods. Your plate is a Child class that inherits from multiple Parents. Multiple Inheritance in Python works exactly like this potluck. The rule for choosing which dish wins is called the Method Resolution Order (MRO). MRO is a left-to-right, depth-first search of the Parent classes, with a twist: Python uses the C3 linearisation algorithm to keep the order consistent even when classes share ancestors.

Now imagine each dish has a 'taste label' — a short description on a sticky note. In Python, that description is the __str__ dunder method. When someone asks 'What's in this dish?', Python looks for a __str__ method (the sticky note) before anything else. If you put your own sticky note on the plate, Python uses it. If not, Python asks each parent in MRO order until it finds one. This is how dunder methods like __init__ (the recipe), __repr__ (the chef's internal note), and __eq__ (the taste test) all work together in a family of classes.

How It Actually Works

Special methods, also called dunder methods (short for 'double underscore'), are methods with names that begin and end with two underscores, like __init__, __str__, __repr__, and __eq__. They allow your objects to respond to built-in Python operations. For example, when you write print(obj), Python looks for obj.__str__() to get a human-readable string. When you write obj1 == obj2, Python looks for obj1.__eq__(obj2) to compare them. Without these methods, Python uses default behaviours that often aren't useful.

Let's break down the four dunder methods you must master for PCAP-31-03:

__init__(self, ...): This is the constructor method. It is called automatically when you create a new instance of a class, like MyClass(). It initialises instance attributes (data stored with the object). For example, a Person class might use __init__ to set the person's name and age: def __init__(self, name, age): self.name = name; self.age = age. If you define __init__, it replaces the default no-argument initialiser. You can also call parent __init__ via super().__init__(...) to reuse initialisation code.

__str__(self): This method returns a user-friendly string that describes the object. It is called by the print() function and by str(obj). For example, a Book class might return f'{self.title} by {self.author}'. If you don't define __str__, Python falls back to __repr__.

__repr__(self): This method returns an 'official' string that, ideally, could recreate the object if fed back into Python code. It is called by the repr() function and when you inspect an object in the interactive interpreter (e.g., just typing obj and pressing Enter). It should be unambiguous and useful for debugging. For the Book class: f'Book("{self.title}", "{self.author}")'.

__eq__(self, other): This method defines equality comparison using the == operator. It returns True if self is considered equal to other, otherwise False (or it can return NotImplemented to let Python try other methods). By default, Python compares object identity (are they the same object in memory, like two twins who look identical but are different people). You override __eq__ to compare values instead, like two Book objects that have the same title and author.

Multiple Inheritance is when a class inherits from more than one parent class. In Python, you define it like class Child(Parent1, Parent2):. The child class has access to all methods and attributes from both parents. But what happens if both parents define a method with the same name? Python uses the Method Resolution Order (MRO). MRO is a linear order of classes that Python searches when looking up a method or attribute. You can inspect the MRO by calling ClassName.__mro__ (a tuple) or ClassName.mro() (a list). Python uses the C3 linearisation algorithm, which ensures that:

A child always comes before its parents.

If a class inherits from multiple parents, those parents appear in the MRO in the order they were listed in the class definition (leftmost first).

The algorithm respects monotonicity: if class A inherits from B and C, and B inherits from D, the MRO will not put D before A inconsistently.

A classic example of multiple inheritance is the 'Diamond Problem', where a class inherits from two classes that both inherit from a common grandparent. Python resolves this using MRO, so the method from the first listed parent is called first. You can also use super() in a child class to call the next method in the MRO chain, not just the direct parent.

Why do these concepts matter for the PCAP-31-03 exam? Because multiple inheritance and dunder methods are tested in specific, predictable ways. You will be asked to determine the output of code that uses __init__, __str__, __repr__, and __eq__, especially when overriding them or inheriting them. You will also need to solve MRO questions: given a diamond inheritance setup, which method gets called? The exam expects you to know that MRO follows a breadth-first depth-first rule (C3 linearisation) and that you can check MRO with the __mro__ attribute.

Let's see a concrete example. Suppose you define:

class A: def __init__(self): print('A init')

class B(A): def __init__(self): print('B init') super().__init__()

class C(A): def __init__(self): print('C init') super().__init__()

class D(B, C): def __init__(self): print('D init') super().__init__()

d = D()

The output will be: D init B init C init A init

This is because D's MRO is [D, B, C, A, object]. super() in D calls the next in the MRO (B), then super() in B calls the next (C), then super() in C calls the next (A). This cooperative multiple inheritance pattern ensures each class's initialiser runs exactly once, in the correct order.

For __eq__, a common exam trap is forgetting to return NotImplemented when comparing objects of different types. If you return False directly, Python won't try the other object's __eq__. Returning NotImplemented allows Python to attempt the reverse comparison (other.__eq__(self)), which is the correct pattern.

Diamond inheritance diagram showing class D inheriting from B and C, which both inherit from A, and the resulting MRO order [D, B, C, A, object].

Walk-Through

1

Define the base class with dunder methods

Start by writing a class, say Animal, with __init__ to set attributes like name and species. Then add __str__ to return a user-friendly string like f'{self.name} the {self.species}'. Optionally add __repr__ for an unambiguous representation. This establishes the default behaviour for all subclasses.

2

Override __init__ in a child class using super()

Create a subclass Dog that inherits from Animal. In its __init__, accept name, species, and breed. Call super().__init__(name, species) to initialise parent attributes, then set self.breed. Failing to call super() means the Dog object won't have name or species set, causing AttributeError when accessed.

3

Override __str__ in the subclass

In Dog, define __str__ to return f'{self.name} is a {self.breed} {self.species}'. When print(dog_object) is called, this custom version runs instead of the Animal version. This is how you customise display for specific types.

4

Define __eq__ for value-based comparison

Add __eq__ to the Dog class so that two Dog objects are equal if they have the same name and breed. The method signature is def __eq__(self, other):. First check if other is a Dog instance (using isinstance), if not, return NotImplemented. Then compare the attributes and return True or False. This ensures logical comparisons, not memory-address comparisons.

5

Create a second parent class (mixin) for multiple inheritance

Define a class Fluffy with a method clean_paws(). Then define a class FluffyDog(Dog, Fluffy) that inherits from both. Because Dog comes first in the parent list, MRO prioritises Dog's methods over Fluffy's. If Fluffy also defines __str__, the MRO ensures Dog's __str__ is called first. Use ClassName.__mro__ to verify the order.

6

Use super() in the multiple inheritance class

In FluffyDog, if you override __init__, call super().__init__(...) to let the MRO chain run both Dog's and Fluffy's initialisers in correct order. Do not hardcode parent names. This cooperative pattern allows each class in the chain to contribute to initialisation without conflicts.

What This Looks Like on the Job

As an IT professional, you will use special methods and multiple inheritance almost daily when building frameworks, libraries, or any non-trivial application. Imagine you are working for a logistics company, building a system to manage parcels, shipments, and tracking events.

First, you define a base class Parcel with an __init__ method that sets common attributes like weight, dimensions, and destination. You also define an __eq__ method so that two Parcel objects are considered equal if they have the same tracking number (not just the same memory location). Without __eq__, comparing parcels would always return False unless they are the exact same object, which is useless for matching shipments.

Next, you add a __str__ method that returns something like 'Parcel #TRACK123 (2.5kg to London)'. This makes debugging and logging much easier, because when you print a parcel to the console or log file, you get a readable summary instead of a cryptic object memory address.

Now, you need a special type of parcel: a FragileParcel. This class inherits from Parcel and overrides __str__ to add 'FRAGILE' to the output. It also adds a new attribute is_fragile and overrides __eq__ to include that attribute in the comparison. This is a real-world use of single inheritance with dunder methods.

But the scenario gets more complex: you have two different tracking systems — one for domestic shipments and one for international. Each system has its own base class with specific methods for handling customs or local regulations. You need a class CrossBorderParcel that inherits from both DomesticTracking and InternationalTracking. This is where multiple inheritance comes in. You must design the MRO carefully because both tracking classes might define a method called track_step(). Python's MRO will determine which track_step() gets called when you call it on a CrossBorderParcel instance. In practice, you would use super() in each class to ensure both parent methods run in the correct order.

In industry, multiple inheritance is used in frameworks like Django (class-based views) and SQLAlchemy (mixins for database features). A common pattern is to create mixin classes — small classes that provide a specific, reusable behaviour (like logging or timestamping) and then combine them via multiple inheritance.

For the PCAP-31-03 exam, you won't build a full system, but you must recognise when you need to override a dunder method and how MRO works. The exam writers love to test scenarios where a class inherits from two classes with the same method, and you must calculate which one gets called. They also love to test the effect of super() in a multi-inheritance chain.

In a job interview, you might be asked to 'explain the Liskov substitution principle in the context of multiple inheritance' or 'implement a class that can be compared by value using __eq__'. Being fluent in these topics shows you understand Python at a deeper level.

How PCAP-31-03 Actually Tests This

PCAP-31-03 dedicates a specific exam objective (5.4) to 'Implement __init__, __str__, __repr__, __eq__ and manage multiple inheritance and MRO.' This means the exam will contain multiple-choice and short-answer questions that directly test these five concepts.

What exactly does the exam test?

__init__: You must know that __init__ is not a constructor (the object already exists when __init__ is called) but an initialiser. The exam may ask what the default __init__ does or how to call the parent __init__ using super(). They love to test the order of __init__ calls in an inheritance chain. A common question: 'Given classes A, B, and C in a diamond hierarchy, what is the output of creating a C object?'

__str__ and __repr__: The exam tests the difference between them. A typical question: 'What does the following code print?' where one method is defined and the other is not. Remember: print() calls __str__; repr() calls __repr__; if __str__ is missing, Python falls back to __repr__. If both are missing, Python uses a default representation like '<__main__.MyClass object at 0x...>'.

__eq__: The exam expects you to know the default behaviour (identity comparison) and how to override it to compare values. They often include trap questions where __eq__ is defined but returns a value that is not a boolean (like a string), or where __eq__ returns False for two objects with identical attribute values because the developer forgot to compare all attributes.

Multiple Inheritance and MRO: This is where the exam gets tricky. They will present a class definition with multiple parents and ask: 'What is the MRO of class D?' or 'Which method is called when d.method() is invoked?' You must be able to compute the MRO manually or use the C3 linearisation rules. The exam also tests the 'diamond problem' and how super() resolves in such cases.

Common trap patterns to watch for:

Trap 1: Assuming that __init__ is called automatically in inheritance. It is not. If a child class defines its own __init__, the parent __init__ is not called unless you explicitly call super().__init__(). The exam might show a child class that fails to initialise a parent attribute and ask you why an AttributeError is raised.

Trap 2: Confusing __str__ and __repr__. The exam might ask: 'Which method is used by the print() function?' or 'Which method is used by the interactive interpreter when you type just the object name?' Know that print() uses __str__, and the interactive prompt uses __repr__.

Trap 3: Thinking that returning False from __eq__ means the objects are not equal. Actually, returning False tells Python they are not equal. But if you return NotImplemented, Python gives the other object a chance to compare. The exam tests this nuance.

Trap 4: Believing that MRO is simply depth-first from left to right. It is not; it's C3 linearisation, which can produce different orders when classes share ancestors. For example, if A inherits from B and C, and B and C both inherit from D, the MRO is [A, B, C, D, object], not [A, B, D, C, object].

Trap 5: Forgetting that super() in a class with multiple inheritance does not just call the immediate parent; it calls the next class in the MRO. The exam might ask what happens when you call super() from the last class in the chain.

Key definitions to memorise:

__init__: initialiser, called after object creation. Syntax: def __init__(self, ...)

__str__: informal string representation, for end users. Called by print() and str().

__repr__: official string representation, for developers. Called by repr() and the interactive prompt.

__eq__: equality comparison. Signature: def __eq__(self, other).

super(): returns a proxy object that delegates method calls to the next class in the MRO.

__mro__: tuple of classes in method resolution order, attribute of a class.

C3 linearisation: algorithm that computes MRO, ensuring consistency and monotonicity.

Diamond problem: ambiguity that arises when a class inherits from two classes that share a common ancestor.

Mixin: a small class designed to be combined via multiple inheritance to add a specific behaviour.

Exam question types:

Multiple-choice: 'What is the output of the following code?' (involving dunder methods or MRO)

Drag-and-drop: 'Arrange the classes in the correct MRO order.'

True/False: 'The __repr__ method is used by the print() function.' (False)

Single-line coding: 'Write a __str__ method for a class.' (in a coding environment)

To pass, practise writing small scripts that test each dunder method and inheritance scenario. Use the Python interactive shell to check MRO with ClassName.__mro__.

Key Takeaways

Special methods (dunders) like __init__, __str__, __repr__, and __eq__ let your objects behave like built-in Python types, overriding default behaviours for initialisation, display, and comparison.

__init__ is an initialiser, not a constructor — the object already exists when it runs, and you must call super().__init__() to initialise parent attributes in inheritance chains.

__str__ is for end-user readability (used by print()), and __repr__ is for developer clarity (used by repr() and the interpreter); if __str__ is missing, Python falls back to __repr__.

__eq__ should return NotImplemented when comparing objects of different types to allow Python to try the other object's __eq__, rather than returning False directly.

Multiple inheritance uses C3 linearisation to compute the Method Resolution Order (MRO), which you can inspect via ClassName.__mro__.

In a diamond hierarchy, super() calls classes in MRO order, ensuring that each initialiser runs exactly once when cooperative multiple inheritance is used.

If a child class does not define __init__, __str__, __repr__, or __eq__, it inherits the parent's version according to the MRO.

The PCAP-31-03 exam tests your ability to calculate MRO manually and predict method call order in multi-inheritance scenarios, including diamond problems.

Easy to Mix Up

These come up on the exam all the time. Here's how to tell them apart.

__str__

Called by print() and str()

Intended for end users (human-readable)

If missing, falls back to __repr__

__repr__

Called by repr() and the interactive interpreter

Intended for developers (unambiguous)

If missing, falls back to default <...> representation

Single Inheritance

Class inherits from exactly one parent class

MRO is a simple linear chain from child to parent to grandparent to object

super() always calls the one direct parent

Multiple Inheritance

Class inherits from two or more parent classes

MRO is computed by C3 linearisation, may be non-intuitive with shared ancestors

super() calls the next class in the MRO, which may not be a direct parent

__init__ in Child (without super())

Parent __init__ is not called automatically

Child must set all attributes itself

Risk of missing parent attributes leading to AttributeError

__init__ in Child (with super())

Parent __init__ is called via MRO chain

Cooperative initialisation ensures all classes contribute

Standard pattern for reliable multi-inheritance code

__eq__ returning False directly

Python stops comparison and returns False

Other object's __eq__ is ignored

Can cause asymmetry: a == b is False, but b == a might be True if b's __eq__ is different

__eq__ returning NotImplemented

Python then tries other.__eq__(self)

If other also returns NotImplemented, Python falls back to identity comparison (is)

Promotes symmetric and predictable equality logic

Watch Out for These

Mistake

__init__ is the constructor that creates the object in memory.

Correct

__init__ is the initialiser, not the constructor. The object is already created by __new__ before __init__ runs. __init__ only sets initial attribute values.

This mistake comes from other languages like Java where the constructor does both allocation and initialisation. Python separates these.

Mistake

If a child class doesn't define __init__, it will have no initialiser and the object will fail to be created.

Correct

If a child class doesn't define __init__, Python automatically calls the parent class's __init__ using the MRO. The object will still be created, but only with parent attributes.

Beginners assume they must always write __init__, but Python inherits it. They also forget that missing __init__ can lead to missing child-specific attributes.

Mistake

__str__ and __repr__ are the same thing, just with different names.

Correct

__str__ is for informal human-readable output (used by print()), while __repr__ is for official unambiguous representation intended for developers (used by repr()). They can return different strings.

Many beginners never see a scenario where both differ, and the default behaviour (print falls back to __repr__) makes them seem identical.

Mistake

Multiple inheritance means the child class gets an exact copy of all methods from all parents, and if two parents have the same method, Python randomly picks one.

Correct

The child class does not get copies; it inherits references. When two parents have the same method, Python uses the Method Resolution Order (MRO) to deterministically decide which one is called, not randomly.

The word 'random' is often used loosely by beginners who don't understand MRO. They assume Python's decision is arbitrary because they haven't studied the C3 linearisation algorithm.

Mistake

You can only use super() to call the immediate parent class's method.

Correct

super() returns a proxy that follows the MRO, so it can call methods from any class in the inheritance chain, not just the immediate parent. In multiple inheritance, super() can skip the direct parent and call the next class in the MRO.

This misconception originates from single inheritance, where super() always refers to the one parent. In multiple inheritance, the behaviour is more complex but consistent with MRO.

Mistake

If you define __eq__, you must also define __hash__ to avoid errors, otherwise Python will throw an error.

Correct

If you define __eq__ but do not define __hash__, Python sets __hash__ to None for mutable objects, making them unhashable. This only raises an error when you try to use the object in a set or as a dictionary key. For simple equality checks without hashing, it is fine.

Many tutorials warn about this but do not explain that the error only occurs when hashing is needed. Beginners think any __eq__ definition causes a crash.

Do You Actually Know This?

Reveal each answer, then mark whether you got it right. Score 60%+ to unlock the next chapter.

Frequently Asked Questions

What does __init__ do in Python?

__init__ is a special method that initialises a newly created object. It is called automatically after the object is created by __new__. You use it to set initial attribute values, like name or age, using parameters passed when creating the object (e.g., obj = MyClass(arg1, arg2)).

What is the difference between __str__ and __repr__?

__str__ returns a human-readable string meant for end users, used by print() and str(). __repr__ returns a more precise or official representation meant for developers, used by repr() and the interactive interpreter. If __str__ is missing, Python falls back to __repr__ when you call print().

How does Python decide which method to call in multiple inheritance?

Python uses the Method Resolution Order (MRO), computed by the C3 linearisation algorithm. The MRO is a linear ordering of all ancestor classes. When you call a method, Python searches this order from left to right, top to bottom, and uses the first method found. You can view the MRO with ClassName.__mro__.

Do I need to call super().__init__ in a subclass?

You should call super().__init__() if the parent class's __init__ sets up important attributes. If the child does not call super(), the parent's initialisation is skipped, and the object may lack those attributes, causing AttributeError. However, if the child's __init__ is not defined, Python automatically inherits the parent's __init__ via MRO.

What happens if I override __eq__ but not __hash__?

Python automatically sets __hash__ to None for that class, making instances unhashable. If you try to use them in a set or as a dictionary key, you get a TypeError: unhashable type. For simple equality comparisons (like if a == b), this is fine and no error occurs.

Can I use multiple inheritance with built-in types like list or dict?

Yes, you can inherit from built-in types and custom classes simultaneously. For example, class CustomList(list, MyMixin):. MRO will include list and MyMixin in the defined order. However, be cautious because built-in types may have strict internal structures; misuse can lead to unexpected behaviour.

Is there a way to change MRO dynamically at runtime?

No, MRO is computed at class creation time and is immutable. You cannot change it after the class is defined. You can influence it by reordering base classes in the class definition or by using metaclasses, but that is beyond PCAP level.

Terms Worth Knowing

Keep going

You've finished Advanced OOP: Special Methods (Dunder) and Multiple Inheritance. Continue through the PCAP-31-03 study guide to build a complete picture of the exam.

Done with this chapter?