27.3 Multiple Inheritance: Diamond Problem and Cooperative super()
Multiple inheritance introduces a powerful but complex mechanism for class composition. When a class inherits from more than one base class, Python must determine the order in which to search these base classes when resolving a method or attribute. This is managed by the Method Resolution Order (MRO), which becomes critically important in the classic “diamond problem” inheritance pattern.
The Diamond Problem Explained
The diamond problem occurs in an inheritance hierarchy where a subclass inherits from two classes that both inherit from a common superclass. This forms a diamond shape in the inheritance graph. The central question is: how many times is the common ancestor’s method called, and through which path?
Consider this classic diamond structure:
class A:
def method(self):
print("A.method")
class B(A):
def method(self):
print("B.method (before super)")
super().method()
print("B.method (after super)")
class C(A):
def method(self):
print("C.method (before super)")
super().method()
print("C.method (after super)")
class D(B, C):
def method(self):
print("D.method (before super)")
super().method()
print("D.method (after super)")
Without a well-defined MRO, a call to D().method() could result in A.method being called twice—once via B and once via C. This is typically undesirable. Python’s MRO, using the C3 linearization algorithm, ensures each class in the hierarchy is called exactly once.
Understanding the MRO with C3 Linearization
Python’s MRO is not simply depth-first or breadth-first. The C3 algorithm creates a linear ordering that preserves the order of base classes as declared and ensures that a class always appears before its parents. You can inspect the MRO for any class using the .__mro__ attribute or the mro() method.
# Inspecting the MRO for class D
print(D.__mro__)
# Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
d_instance = D()
d_instance.method()
The output of the method call demonstrates the cooperative nature of super():
D.method (before super)
B.method (before super)
C.method (before super)
A.method
C.method (after super)
B.method (after super)
D.method (after super)
The super() call in each class does not directly call the parent class. Instead, it delegates to the next class in the MRO. In B, super().method() delegates to C.method, not directly to A.method, because C is the next class in the MRO after B.
The Role of Cooperative super()
For multiple inheritance to work predictably, all methods in the inheritance chain must cooperatively use super(). This is not just a best practice; it is often a requirement. If one class in the hierarchy uses a direct call to a parent class (e.g., A.method(self)), it breaks the chain and prevents other classes in the MRO from being called.
Pitfall: Non-Cooperative Methods
class BadB(A):
def method(self): # This breaks the diamond
print("BadB.method")
A.method(self) # Direct call, bypassing super()
class BadD(BadB, C):
pass
bad_d = BadD()
bad_d.method() # C.method will never be called!
Output:
BadB.method
A.method
The call to C.method is completely skipped because BadB did not use super(), halting the MRO traversal.
Best Practices and Design Considerations
- Always Use
super(): In classes designed for inheritance, especially in multiple inheritance scenarios, always usesuper()to call parent methods. The exact recipient of the call is determined by the MRO, making your class a cooperative participant in the hierarchy. - Design for Mixins: When using multiple inheritance, it is often effective to use “mixin” classes. Mixins are classes that are not meant to stand alone but are designed to add specific functionality to other classes through inheritance. They should always call
super()in their methods, as they cannot know what will come next in the MRO. - Be Mindful of Signature Compatibility: Since a method may call
super()and pass arguments to a method in a class that is unknown at the time of writing, all methods in the chain should have compatible signatures. Using keyword arguments and accepting**kwargscan help manage this. - Inspect the MRO: When building complex inheritance hierarchies, always check the
.__mro__to understand the method resolution order. This can help debug unexpected behavior. - Favor Composition Over Complex Inheritance: If the MRO becomes too complex and difficult to reason about, it may be a sign that the design could be simplified. Often, using composition (e.g., having a class contain an instance of another class rather than inherit from it) can lead to a cleaner and more maintainable design.