OOP II: Inheritance and Method Resolution

When your dad is an object

Author

Karsten Naert

Published

October 20, 2026

Prerequisites

Before diving into this lecture, you should be comfortable with these concepts:

  • Object / class / instance terminology
  • Methods and __init__
  • Class variables
  • Basic class syntax: class MyClass: ...

Let’s also recall the relationship: “The object my_object is an instance of the class MyClass”

Basic Inheritance

Extending functionality

Sometimes you want to create multiple classes that behave similarly but with slight differences. Think about the streams we met in the lecture on bytes and files. A file handle returned by open(...), the stream sys.stdout, and an io.StringIO are three genuinely different things, and yet they all have:

  • A .write() method
  • A .flush() method
  • A .close() method

which means you can hand any of them to the same function and it just works:

import io
import sys

def write_report(stream):
    stream.write("Report\n")
    stream.write("======\n")
    stream.flush()

write_report(sys.stdout)          # goes to the terminal

buffer = io.StringIO()
write_report(buffer)              # goes into memory
print(repr(buffer.getvalue()))
Report
======
'Report\n======\n'

Rather than duplicating code, we can use inheritance. You create one base class with shared functionality and multiple derived classes (or subclasses) with specific behavior.

class OriginalClass:
    ...  # This is the base class

class NewClass(OriginalClass):
    ...  # This is the derived class

Here’s a simple example:

class TwoNumbers:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    def show_numbers(self):
        print(f'TwoNumbers object with a={self.a} and b={self.b}')
two_numbers = TwoNumbers(3, 4)
two_numbers.show_numbers()
TwoNumbers object with a=3 and b=4

Now let’s create specialized versions:

class SumTwoNumbers(TwoNumbers):
    def result(self):
        return self.a + self.b

class DifferenceTwoNumbers(TwoNumbers):
    def result(self):
        return self.a - self.b

These new classes inherit all the functionality from the parent class TwoNumbers, and in addition each defines a result method that did not exist in the base class. We use these classes like this:

sum_obj = SumTwoNumbers(3, 4)
sum_obj.show_numbers()  # Inherited method
print("Sum:", sum_obj.result())  # New method

diff_obj = DifferenceTwoNumbers(3, 4)  
diff_obj.show_numbers()  # Inherited method
print("Difference:", diff_obj.result())  # New method
TwoNumbers object with a=3 and b=4
Sum: 7
TwoNumbers object with a=3 and b=4
Difference: -1

Terminology

  • Base class (or parent class, superclass): The original class (TwoNumbers)
  • Derived class (or child class, subclass): The inheriting class (SumTwoNumbers)
  • Sibling classes: Classes with the same parent (SumTwoNumbers and DifferenceTwoNumbers)
Exercise
  1. Create a ProductTwoNumbers class that multiplies the two numbers.

  2. Create a base class TwoLists that stores two lists and provides .append1(x) and .append2(x) methods. Then create:

    • ShortestTwoLists: has an append(x) method that adds to the shorter list
    • LongestTwoLists: has an append(x) method that adds to the longer list

Testing relationships

issubclass and isinstance

Use issubclass() to test inheritance relationships:

print("DifferenceTwoNumbers is subclass of itself:", 
      issubclass(DifferenceTwoNumbers, DifferenceTwoNumbers))
print("DifferenceTwoNumbers is subclass of TwoNumbers:", 
      issubclass(DifferenceTwoNumbers, TwoNumbers))
print("TwoNumbers is subclass of DifferenceTwoNumbers:", 
      issubclass(TwoNumbers, DifferenceTwoNumbers))
DifferenceTwoNumbers is subclass of itself: True
DifferenceTwoNumbers is subclass of TwoNumbers: True
TwoNumbers is subclass of DifferenceTwoNumbers: False

Use isinstance() to test if an object belongs to a class:

sum_obj = SumTwoNumbers(1, 2)
print("sum_obj is instance of SumTwoNumbers:", isinstance(sum_obj, SumTwoNumbers))
print("sum_obj is instance of TwoNumbers:", isinstance(sum_obj, TwoNumbers))
print("sum_obj is instance of DifferenceTwoNumbers:", isinstance(sum_obj, DifferenceTwoNumbers))
sum_obj is instance of SumTwoNumbers: True
sum_obj is instance of TwoNumbers: True
sum_obj is instance of DifferenceTwoNumbers: False

The object hierarchy

In Python, every class inherits from object by default:

print("TwoNumbers inherits from object:", issubclass(TwoNumbers, object))
print("int inherits from object:", issubclass(int, object))
print("Everything is an object:", isinstance(42, object), isinstance(sum_obj, object))
TwoNumbers inherits from object: True
int inherits from object: True
Everything is an object: True True

These are equivalent:

class MyClass:
    pass

class MyClass(object):  # Explicit, but unnecessary
    pass
Tip

In modern Python, it is never necessary to inherit from object, so don’t do it unless you have a very good reason to.

It is related to the distinction between “old style” and “new style” classes which existed in Python 2.x.

Method overriding

You can override (replace) methods from the parent class:

class StringContainer:
    def __init__(self, text1, text2):
        self.text1 = text1
        self.text2 = text2

    def format(self):
        return f'StringContainer containing "{self.text1}" and "{self.text2}"'
container = StringContainer("Alice", "Bob")
print(container.format())
StringContainer containing "Alice" and "Bob"
class SingleQuotedStringContainer(StringContainer):
    def format(self):  # Override the parent's format method
        return f"StringContainer containing '{self.text1}' and '{self.text2}'"
quoted_container = SingleQuotedStringContainer("Alice", "Bob")
print(quoted_container.format())
StringContainer containing 'Alice' and 'Bob'

You can override any method, including __init__, properties, class methods, and static methods:

class Operation:
    def __init__(self, a, b):
        self.a = a
        self.b = b

    @property
    def result(self):
        return self.a + self.b

class MultiplyOperation(Operation):
    @property  
    def result(self):  # Override the property
        return self.a * self.b
add_op = Operation(3, 4)
mult_op = MultiplyOperation(3, 4)
print(f"Addition: {add_op.result}")
print(f"Multiplication: {mult_op.result}")
Addition: 7
Multiplication: 12
Exercise
  1. Create an AngularStringContainer that formats text with <> brackets instead of quotes.

  2. Create a TripleStringContainer that:

    • Inherits from StringContainer
    • Overrides __init__ to accept three texts
    • Uses the original format() method (what happens?)
  3. Create a class VerboseDict inheriting from dict that prints messages when items are accessed or modified:

    verbose_dict = VerboseDict(a=1, b=2)
    print(verbose_dict['a'])  # Should print "Requesting a" then 1
    verbose_dict['b'] = 10    # Should print "Setting b to 10"

Using parent methods with super()

Sometimes you want to extend rather than completely replace a parent’s method:

class Person:
    def __init__(self, name):
        self.name = name

    def say_hello(self):
        print(f'Hi {self.name}')

class Friend(Person):
    def say_hello(self):
        print(f'Hello {self.name}, my good friend!')

You can explicitly call the parent’s method:

friend = Friend('Alice')
friend.say_hello()  # Child method
Person.say_hello(friend)  # Parent method explicitly
Hello Alice, my good friend!
Hi Alice

But super() is cleaner and more flexible:

class Friend(Person):
    def say_hello(self):
        super().say_hello()  # Call parent method
        print('Welcome, my good friend!')
friend = Friend('Alice')
friend.say_hello()
Hi Alice
Welcome, my good friend!

This pattern is especially common with __init__:

class PersonWithAge(Person):
    def __init__(self, name, age):
        super().__init__(name)  # Initialize parent
        self.age = age          # Add our own initialization

    def describe(self):
        print(f'A {self.age}-year-old person named {self.name}')
person_with_age = PersonWithAge('Bob', 25)
person_with_age.say_hello()
person_with_age.describe()
Hi Bob
A 25-year-old person named Bob
Warning

Order matters: usually you want to call super().__init__() first in your child’s __init__ method, then add your own initialization code.

Multiple inheritance and MRO

Multiple parents

A class can inherit from multiple classes simultaneously. Each class can deliver some functionality (methods, properties, …) that end up in the subclass.

Notice what the two classes below are for. Neither LineFormatter nor ByteDecoder is meant to be instantiated on its own and used as a thing in the world; each exists purely to contribute a capability to some other class. A class written for that purpose has a name: it is called a mixin. It is by far the most common and the most defensible use of multiple inheritance, and we come back to mixins in OOP III.

class LineFormatter:
    def __init__(self):
        self.separator = ' | '

    def identify(self):
        print('Hi, I am LineFormatter')
        
    def format_line(self, items):
        return self.separator.join(items)

class ByteDecoder:
    def __init__(self):
        self.encoding = 'utf-8'

    def identify(self):
        print('Hi, I am ByteDecoder')

    def decode_bytes(self, byte_list):
        return [b.decode(self.encoding) for b in byte_list]
class ByteFormatter(LineFormatter, ByteDecoder):  # Multiple inheritance
    def __init__(self):
        self.encoding = 'utf-8'
        self.separator = ' | '

    def identify(self):
        print('Hi, I am ByteFormatter')

    def format_bytes(self, byte_list):
        decoded = self.decode_bytes(byte_list)  # From ByteDecoder
        formatted = self.format_line(decoded)   # From LineFormatter
        return formatted
formatter = ByteFormatter()
formatter.identify()
result = formatter.format_bytes([b'\xce\xb1', b'\xce\xb2', b'\xce\xb3'])
print(result)
Hi, I am ByteFormatter
α | β | γ

Method Resolution Order (MRO)

With multiple inheritance, which parent’s method gets called? Python uses the Method Resolution Order:

print("ByteFormatter MRO:")
for i, cls in enumerate(ByteFormatter.__mro__):
    print(f"  {i}: {cls.__name__}")
ByteFormatter MRO:
  0: ByteFormatter
  1: LineFormatter
  2: ByteDecoder
  3: object

When you call super(), Python looks for the next class in the MRO, not necessarily the immediate parent:

class Identifiable:
    def identify(self):
        print('Hi, I am Identifiable')

class LineFormatter(Identifiable):
    def __init__(self):
        super().__init__()
        self.separator = ' | '

    def identify(self):
        print('Hi, I am LineFormatter')
        super().identify()  # Continue the chain
        
    def format_line(self, items):
        return self.separator.join(items)

class ByteDecoder(Identifiable):
    def __init__(self):
        super().__init__()
        self.encoding = 'utf-8'

    def identify(self):
        print('Hi, I am ByteDecoder')
        super().identify()  # Continue the chain

    def decode_bytes(self, byte_list):
        return [b.decode(self.encoding) for b in byte_list]

class ByteFormatter(LineFormatter, ByteDecoder):
    def __init__(self):
        super().__init__()

    def identify(self):
        print('Hi, I am ByteFormatter')
        super().identify()  # Start the chain

    def format_bytes(self, byte_list):
        decoded = self.decode_bytes(byte_list)
        formatted = self.format_line(decoded)
        return formatted
print("New MRO:")
for i, cls in enumerate(ByteFormatter.__mro__):
    print(f"  {i}: {cls.__name__}")

print("\nCalling identify():")
formatter = ByteFormatter()
formatter.identify()
New MRO:
  0: ByteFormatter
  1: LineFormatter
  2: ByteDecoder
  3: Identifiable
  4: object

Calling identify():
Hi, I am ByteFormatter
Hi, I am LineFormatter
Hi, I am ByteDecoder
Hi, I am Identifiable

Handling __init__ with multiple inheritance

When using super().__init__() with multiple inheritance, be careful. Sometimes you need to call parent constructors explicitly:

class LineFormatter:
    def __init__(self, separator=' | '):
        self.separator = separator

class ByteDecoder:
    def __init__(self, encoding='utf-8'):
        self.encoding = encoding

class ByteFormatter(LineFormatter, ByteDecoder):
    def __init__(self, separator=' | ', encoding='utf-8'):
        # Explicitly initialize both parents
        LineFormatter.__init__(self, separator)
        ByteDecoder.__init__(self, encoding)
formatter = ByteFormatter(separator=' <-> ', encoding='latin1')
print(f"Separator: '{formatter.separator}'")
print(f"Encoding: '{formatter.encoding}'")
Separator: ' <-> '
Encoding: 'latin1'

This works, but notice that we have abandoned super() and gone back to naming the parents explicitly. That undoes everything the MRO does for us: if somebody later inserts a class into the hierarchy, our ByteFormatter will walk straight past it.

The alternative is the cooperative pattern. Every class in the hierarchy accepts **kwargs, takes out the arguments it recognises, and passes the rest up the MRO with super().__init__(**kwargs):

class LineFormatter:
    def __init__(self, separator=' | ', **kwargs):
        super().__init__(**kwargs)      # pass the rest along the MRO
        self.separator = separator

    def format_line(self, items):
        return self.separator.join(items)

class ByteDecoder:
    def __init__(self, encoding='utf-8', **kwargs):
        super().__init__(**kwargs)
        self.encoding = encoding

    def decode_bytes(self, byte_list):
        return [b.decode(self.encoding) for b in byte_list]

class ByteFormatter(LineFormatter, ByteDecoder):
    pass                                 # nothing left to write!
formatter = ByteFormatter(separator=' <-> ', encoding='latin1')
print(f"Separator: '{formatter.separator}'")
print(f"Encoding: '{formatter.encoding}'")
Separator: ' <-> '
Encoding: 'latin1'

Follow the arguments as they travel. ByteFormatter has no __init__ of its own, so the next class in the MRO receives both of them. LineFormatter.__init__ takes the separator it knows about and passes the leftover {'encoding': 'latin1'} up with super().__init__(**kwargs). That reaches ByteDecoder, which takes encoding and passes on an empty kwargs, so object.__init__() is finally called with nothing at all. Each class grabs its own argument and forwards the rest without needing to know who comes next.

Two rules make this work:

  • Every class in the hierarchy must play along. One class that forgets to call super().__init__() silently breaks the chain for everything after it.
  • The chain has to end cleanly at object, which accepts no arguments. So by the time you reach the top, kwargs must be empty — and if it isn’t, you get a TypeError telling you somebody passed an argument that nobody claimed:
ByteFormatter(separator=' - ', encodign='utf-8')   # note the typo
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
Cell In[32], line 1
----> 1 ByteFormatter(separator=' - ', encodign='utf-8')   # note the typo

Cell In[30], line 3, in LineFormatter.__init__(self, separator, **kwargs)
      2 def __init__(self, separator=' | ', **kwargs):
----> 3     super().__init__(**kwargs)      # pass the rest along the MRO
      4     self.separator = separator

Cell In[30], line 11, in ByteDecoder.__init__(self, encoding, **kwargs)
     10 def __init__(self, encoding='utf-8', **kwargs):
---> 11     super().__init__(**kwargs)
     12     self.encoding = encoding

TypeError: object.__init__() takes exactly one argument (the instance to initialize)

The message is not exactly friendly — it complains about object.__init__() rather than about your typo — but the important thing is that it complains at all. Without this mechanism the misspelled encodign would have been silently swallowed, and you would be debugging why your encoding setting had no effect.

Tip

Multiple inheritance can be tricky. When in doubt:

  1. Keep inheritance hierarchies simple
  2. Use composition instead of complex inheritance
  3. Always check the MRO when debugging

Composition: the alternative to inheritance

Point 2 above deserves an example, because “use composition” is advice you will read everywhere and it is rarely spelled out.

Composition means: instead of being a thing, your object has a thing. Compare the two versions of our formatter. With inheritance, ByteFormatter is a LineFormatter and is a ByteDecoder:

class ByteFormatterInherited(LineFormatter, ByteDecoder):
    def format_bytes(self, byte_list):
        return self.format_line(self.decode_bytes(byte_list))

With composition, it has a formatter and has a decoder, stored as ordinary attributes:

class ByteFormatterComposed:
    def __init__(self, separator=' | ', encoding='utf-8'):
        self.formatter = LineFormatter(separator=separator)
        self.decoder = ByteDecoder(encoding=encoding)

    def format_bytes(self, byte_list):
        return self.formatter.format_line(self.decoder.decode_bytes(byte_list))

Both do the same job:

data = [b'\xce\xb1', b'\xce\xb2', b'\xce\xb3']
print(ByteFormatterInherited(separator=' + ').format_bytes(data))
print(ByteFormatterComposed(separator=' + ').format_bytes(data))
α + β + γ
α + β + γ

The composed version is a few lines longer, and in exchange:

  • There is no MRO to reason about. self.decoder.decode_bytes(...) says exactly which object does the work. With inheritance, self.decode_bytes(...) might come from anywhere in the hierarchy, and inserting a class can silently change the answer.
  • No name collisions. Earlier in this lecture both LineFormatter and ByteDecoder defined an identify method, and only one of them could win. Composed, they keep their own methods and you choose which to call.
  • You expose only what you want. The inherited version drags every public method of both parents into its own interface, whether or not that makes sense. The composed version exposes just format_bytes.
  • You can swap parts at runtime, which inheritance cannot do: the base classes are fixed when the class is defined.
f = ByteFormatterComposed()
f.decoder = ByteDecoder(encoding='latin1')   # change the decoder, keep everything else
print(f.format_bytes(data))
α | β | γ

The cost is that you lose the automatic forwarding. If you want ByteFormatterComposed to also offer format_line, you have to write a one-line method that delegates to self.formatter. That chore is real, and it is the main reason people reach for inheritance anyway.

The usual rule of thumb

Ask yourself whether the sentence “a ByteFormatter is a ByteDecoder” is actually true. If it sounds forced, you want composition. Inheritance is for genuine specialisation (SumTwoNumbers really is a TwoNumbers); composition is for assembling a thing out of parts.

Exercise
  1. Create a diamond inheritance pattern with a base class Vehicle, two middle classes Car and Boat, and a final class AmphibiousCar. Add an identify() method to each class and use super() to chain them properly.

  2. Examine the MRO of your AmphibiousCar class and predict the output before running identify().

  3. Experiment with changing the order of inheritance (e.g., class AmphibiousCar(Boat, Car) vs class AmphibiousCar(Car, Boat)) and observe how it affects the MRO.

Super with two arguments

Most of the time you’ll use super() with no arguments, but there’s also super(ClassName, self). This does the following: look up the MRO of the object self, and get the class right after the class ClassName. This explicit form lets you control exactly where Python starts looking in the MRO:

class A:
    def greet(self):
        print("Hi from A")

class B(A):
    def greet(self):
        print("Hi from B")
        super().greet()

class C(A):
    def greet(self):
        print("Hi from C") 
        super().greet()

class D(B, C):
    def greet(self):
        print("Hi from D")
        # These are equivalent:
        super().greet()                    # Start from next in MRO  
        # super(D, self).greet()           # Same thing, explicit
d = D()
print("MRO:", [cls.__name__ for cls in D.__mro__])
d.greet()
MRO: ['D', 'B', 'C', 'A', 'object']
Hi from D
Hi from B
Hi from C
Hi from A

The two-argument form becomes useful when you want to skip certain classes in the MRO:

class D(B, C):
    def greet(self):
        print("Hi from D")
        super(B, self).greet()  # Skip B, start from C in the MRO
d = D()
d.greet()
Hi from D
Hi from C
Hi from A

This explicitly says: “Start looking for greet() from class B’s position in the MRO, but use self as the instance.” Since B comes before C in the MRO, calling super(B, self) jumps to C.

This helps you understand what super() really does: it is a shortcut for super(__class__, self), where __class__ is the class we are currently in and self is the first argument of the function we are programming. You can read more about this in PEP 3135 - New Super.

Tip

The two-argument form is rarely needed in practice. It was used in Python 2.x because the form super() didn’t exist yet. You should only use it if you have a very good reason to; otherwise stick with super().

Additional resources