When a class inherits from another, it often needs to provide its own specific implementation for a method defined in its parent class. This is called method overriding. The child class’s method replaces the parent’s method for instances of the child class. However, it is frequently necessary to extend the parent’s behavior rather than replace it entirely. This is achieved by calling the parent class’s implementation from within the child class’s overridden method. The mechanism for this call varies depending on the type of inheritance (single or multiple) and is handled by the Method Resolution Order (MRO).

Using super() for Cooperative Inheritance

The modern and recommended way to call a parent class’s method is using the built-in super() function. super() returns a proxy object that delegates method calls to the appropriate class in the inheritance hierarchy, as defined by the MRO. Its primary strength is that it enables cooperative multiple inheritance by ensuring that all classes in the hierarchy have a chance to run their code.

class AudioFile:
    def __init__(self, filename):
        self.filename = filename
        self._load_metadata()  # Common initialization

    def _load_metadata(self):
        print(f"Loading basic metadata for {self.filename}")

class MP3File(AudioFile):
    def __init__(self, filename, bitrate=128):
        super().__init__(filename)  # Calls AudioFile.__init__
        self.bitrate = bitrate
        self._load_mp3_specific_metadata()

    def _load_mp3_specific_metadata(self):
        print(f"Loading ID3 tags for {self.filename} with bitrate {self.bitrate}kbps")

# Create an instance
file = MP3File("song.mp3")
# Output:
# Loading basic metadata for song.mp3
# Loading ID3 tags for song.mp3 with bitrate 128kbps

In this example, MP3File overrides the __init__ method but uses super().__init__(filename) to ensure the initialization code in AudioFile is also executed. This is crucial for maintaining the integrity of the base class’s state.

The Old Way: Explicit Parent Class Invocation

Before super() became the standard, it was common to call the parent method directly by referencing the parent class. This approach is still functional but is generally discouraged because it breaks down in multiple inheritance scenarios.

class OldSchoolMP3File(AudioFile):
    def __init__(self, filename, bitrate=128):
        AudioFile.__init__(self, filename)  # Explicit call using the class name
        self.bitrate = bitrate

file = OldSchoolMP3File("old_song.mp3")

The critical pitfall here is that if you have a complex inheritance diamond, AudioFile.__init__ might be called multiple times because the explicit call bypasses the MRO. super() is designed to prevent this by calling each parent’s method exactly once.

How super() Works with the MRO

The power of super() is fully realized in multiple inheritance. It doesn’t simply call the “parent” class; it calls the “next” class in the MRO. This allows a chain of calls to flow through all the classes in a predictable, non-repetitive way.

class A:
    def run(self):
        print("A")

class B(A):
    def run(self):
        print("B (before super)")
        super().run()
        print("B (after super)")

class C(A):
    def run(self):
        print("C (before super)")
        super().run()
        print("C (after super)")

class D(B, C):
    def run(self):
        print("D (before super)")
        super().run()
        print("D (after super)")

d = D()
d.run()
print(D.__mro__)  # View the Method Resolution Order

Output:

D (before super)
B (before super)
C (before super)
A
C (after super)
B (after super)
D (after super)
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

The MRO for class D is D -> B -> C -> A -> object. When super().run() is called in D, the MRO is consulted, and the next class after D is B, so B’s run method is invoked. B’s method also uses super(), which calls the next class in the MRO (C, not the parent A), and so on. This cooperative design ensures every class’s method is called in the correct sequence.

Common Pitfalls and Best Practices

  1. Mismatched Signatures: When overriding a method, ensure its signature (the number and names of parameters) is compatible with the parent method. If using super(), the call must pass the correct arguments. Using *args and **kwargs can help manage this in complex hierarchies.

    class Parent:
        def method(self, a, b):
            pass
    
    class Child(Parent):
        def method(self, *args, **kwargs):  # Accepts any arguments
            # Do something specific to Child
            super().method(*args, **kwargs)  # Pass them all to the next in MRO
    
  2. Forgetting to Call super(): This is a common error that can break the entire initialization chain. If a child class’s __init__ overrides the parent’s and does not call super().__init__(), the parent’s initialization code will never run, potentially leaving the object in an incomplete state.

  3. Using super() in a Class with No Suitable Parent: While super() will still work (it delegates to object), it can be confusing. Its use is most meaningful within an inheritance hierarchy.

  4. Mixing super() and Explicit Calls: Avoid mixing super() and explicit parent class calls (Parent.method(self)). This often leads to inconsistent method invocation order and, in diamond inheritance, can cause a parent class’s method to be called twice, breaking the intended flow of the program. Consistently using super() throughout your class hierarchy is the best practice.