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 objectmy_object is an instance of the classMyClass”
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 ioimport sysdef write_report(stream): stream.write("Report\n") stream.write("======\n") stream.flush()write_report(sys.stdout) # goes to the terminalbuffer= io.StringIO()write_report(buffer) # goes into memoryprint(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 classclass NewClass(OriginalClass): ... # This is the derived class
Here’s a simple example:
class TwoNumbers:def__init__(self, a, b):self.a = aself.b = bdef show_numbers(self):print(f'TwoNumbers object with a={self.a} and b={self.b}')
class SumTwoNumbers(TwoNumbers):def result(self):returnself.a +self.bclass DifferenceTwoNumbers(TwoNumbers):def result(self):returnself.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:
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
Create a ProductTwoNumbers class that multiplies the two numbers.
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:passclass MyClass(object): # Explicit, but unnecessarypass
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 = text1self.text2 = text2defformat(self):returnf'StringContainer containing "{self.text1}" and "{self.text2}"'
class SingleQuotedStringContainer(StringContainer):defformat(self): # Override the parent's format methodreturnf"StringContainer containing '{self.text1}' and '{self.text2}'"
Create an AngularStringContainer that formats text with <> brackets instead of quotes.
Create a TripleStringContainer that:
Inherits from StringContainer
Overrides __init__ to accept three texts
Uses the original format() method (what happens?)
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 1verbose_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 = namedef say_hello(self):print(f'Hi {self.name}')class Friend(Person):def say_hello(self):print(f'Hello {self.name}, my good friend!')
class Friend(Person):def say_hello(self):super().say_hello() # Call parent methodprint('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 parentself.age = age # Add our own initializationdef describe(self):print(f'A {self.age}-year-old person named {self.name}')
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):returnself.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 inheritancedef__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 LineFormatterreturn formatted
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 chaindef format_line(self, items):returnself.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 chaindef 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 chaindef format_bytes(self, byte_list): decoded =self.decode_bytes(byte_list) formatted =self.format_line(decoded)return formatted
print("New MRO:")for i, cls inenumerate(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:
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 MROself.separator = separatordef format_line(self, items):returnself.separator.join(items)class ByteDecoder:def__init__(self, encoding='utf-8', **kwargs):super().__init__(**kwargs)self.encoding = encodingdef decode_bytes(self, byte_list):return [b.decode(self.encoding) for b in byte_list]class ByteFormatter(LineFormatter, ByteDecoder):pass# nothing left to write!
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)
CellIn[32], line 1----> 1ByteFormatter(separator=' - ',encodign='utf-8')# note the typoCellIn[30], line 3, in LineFormatter.__init__(self, separator, **kwargs) 2def__init__(self, separator=' | ', **kwargs):
----> 3super().__init__(**kwargs)# pass the rest along the MRO 4self.separator = separator
CellIn[30], line 11, in ByteDecoder.__init__(self, encoding, **kwargs) 10def__init__(self, encoding='utf-8', **kwargs):
---> 11super().__init__(**kwargs) 12self.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:
Keep inheritance hierarchies simple
Use composition instead of complex inheritance
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, ByteFormatteris aLineFormatter and is aByteDecoder:
class ByteFormatterInherited(LineFormatter, ByteDecoder):def format_bytes(self, byte_list):returnself.format_line(self.decode_bytes(byte_list))
With composition, it has a formatter and has a decoder, stored as ordinary attributes:
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 elseprint(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 ByteFormatteris aByteDecoder” is actually true. If it sounds forced, you want composition. Inheritance is for genuine specialisation (SumTwoNumbers really is aTwoNumbers); composition is for assembling a thing out of parts.
Exercise
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.
Examine the MRO of your AmphibiousCar class and predict the output before running identify().
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().
---title: "OOP II: Inheritance and Method Resolution"subtitle: "When your dad is an object"author: "Karsten Naert"date: todaytoc: trueexecute: echo: true output: true---# PrerequisitesBefore 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 functionalitySometimes you want to create multiple classes that behave similarly but with slightdifferences. Think about the streams we met in the lecture on[bytes and files](02-bytes-files.qmd). A file handle returned by `open(...)`, the stream`sys.stdout`, and an `io.StringIO` are three genuinely different things, and yet they allhave:- A `.write()` method- A `.flush()` method- A `.close()` methodwhich means you can hand any of them to the same function and it just works:```{python}import ioimport sysdef write_report(stream): stream.write("Report\n") stream.write("======\n") stream.flush()write_report(sys.stdout) # goes to the terminalbuffer= io.StringIO()write_report(buffer) # goes into memoryprint(repr(buffer.getvalue()))```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.```{python}class OriginalClass: ... # This is the base classclass NewClass(OriginalClass): ... # This is the derived class```Here's a simple example:```{python}class TwoNumbers:def__init__(self, a, b):self.a = aself.b = bdef show_numbers(self):print(f'TwoNumbers object with a={self.a} and b={self.b}')``````{python}two_numbers = TwoNumbers(3, 4)two_numbers.show_numbers()```Now let's create specialized versions:```{python}class SumTwoNumbers(TwoNumbers):def result(self):returnself.a +self.bclass DifferenceTwoNumbers(TwoNumbers):def result(self):returnself.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:```{python}sum_obj = SumTwoNumbers(3, 4)sum_obj.show_numbers() # Inherited methodprint("Sum:", sum_obj.result()) # New methoddiff_obj = DifferenceTwoNumbers(3, 4) diff_obj.show_numbers() # Inherited methodprint("Difference:", diff_obj.result()) # New method```## 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`)::: {.callout-note icon=false}## Exercise1. 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:```{python}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))```Use `isinstance()` to test if an object belongs to a class:```{python}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))```## The `object` hierarchyIn Python, **every class** inherits from `object` by default:```{python}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))```These are equivalent:```pythonclass MyClass:passclass MyClass(object): # Explicit, but unnecessarypass```::: {.callout-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 overridingYou can _override_ (replace) methods from the parent class:```{python}class StringContainer:def__init__(self, text1, text2):self.text1 = text1self.text2 = text2defformat(self):returnf'StringContainer containing "{self.text1}" and "{self.text2}"'``````{python}container = StringContainer("Alice", "Bob")print(container.format())``````{python}class SingleQuotedStringContainer(StringContainer):defformat(self): # Override the parent's format methodreturnf"StringContainer containing '{self.text1}' and '{self.text2}'"``````{python}quoted_container = SingleQuotedStringContainer("Alice", "Bob")print(quoted_container.format())```You can override any method, including `__init__`, properties, class methods, and static methods:```{python}class Operation:def__init__(self, a, b):self.a = aself.b = b@propertydef result(self):returnself.a +self.bclass MultiplyOperation(Operation):@propertydef result(self): # Override the propertyreturnself.a *self.b``````{python}add_op = Operation(3, 4)mult_op = MultiplyOperation(3, 4)print(f"Addition: {add_op.result}")print(f"Multiplication: {mult_op.result}")```::: {.callout-note icon=false}## Exercise1. 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:```python 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:```{python}class Person:def__init__(self, name):self.name = namedef 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:```{python}friend = Friend('Alice')friend.say_hello() # Child methodPerson.say_hello(friend) # Parent method explicitly```But `super()` is cleaner and more flexible:```{python}class Friend(Person):def say_hello(self):super().say_hello() # Call parent methodprint('Welcome, my good friend!')``````{python}friend = Friend('Alice')friend.say_hello()```This pattern is especially common with `__init__`:```{python}class PersonWithAge(Person):def__init__(self, name, age):super().__init__(name) # Initialize parentself.age = age # Add our own initializationdef describe(self):print(f'A {self.age}-year-old person named {self.name}')``````{python}person_with_age = PersonWithAge('Bob', 25)person_with_age.say_hello()person_with_age.describe()```::: {.callout-warning icon=true}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 parentsA 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` ismeant to be instantiated on its own and used as a thing in the world; each exists purely tocontribute a capability to some other class. A class written for that purpose has a name: itis called a **mixin**. It is by far the most common and the most defensible use of multipleinheritance, and we come back to mixins in [OOP III](08-oop-advanced.qmd).```{python}class LineFormatter:def__init__(self):self.separator =' | 'def identify(self):print('Hi, I am LineFormatter')def format_line(self, items):returnself.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]``````{python}class ByteFormatter(LineFormatter, ByteDecoder): # Multiple inheritancedef__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 LineFormatterreturn formatted``````{python}formatter = ByteFormatter()formatter.identify()result = formatter.format_bytes([b'\xce\xb1', b'\xce\xb2', b'\xce\xb3'])print(result)```## Method Resolution Order (MRO)With multiple inheritance, which parent's method gets called? Python uses the **Method Resolution Order**:```{python}print("ByteFormatter MRO:")for i, cls inenumerate(ByteFormatter.__mro__):print(f" {i}: {cls.__name__}")```When you call `super()`, Python looks for the **next class in the MRO**, not necessarily the immediate parent:```{python}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 chaindef format_line(self, items):returnself.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 chaindef 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 chaindef format_bytes(self, byte_list): decoded =self.decode_bytes(byte_list) formatted =self.format_line(decoded)return formatted``````{python}print("New MRO:")for i, cls inenumerate(ByteFormatter.__mro__):print(f" {i}: {cls.__name__}")print("\nCalling identify():")formatter = ByteFormatter()formatter.identify()```## Handling `__init__` with multiple inheritanceWhen using `super().__init__()` with multiple inheritance, be careful. Sometimes you need to call parent constructors explicitly:```{python}class LineFormatter:def__init__(self, separator=' | '):self.separator = separatorclass ByteDecoder:def__init__(self, encoding='utf-8'):self.encoding = encodingclass ByteFormatter(LineFormatter, ByteDecoder):def__init__(self, separator=' | ', encoding='utf-8'):# Explicitly initialize both parents LineFormatter.__init__(self, separator) ByteDecoder.__init__(self, encoding)``````{python}formatter = ByteFormatter(separator=' <-> ', encoding='latin1')print(f"Separator: '{formatter.separator}'")print(f"Encoding: '{formatter.encoding}'")```This works, but notice that we have abandoned `super()` and gone back to naming the parentsexplicitly. That undoes everything the MRO does for us: if somebody later inserts a classinto 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)`:```{python}class LineFormatter:def__init__(self, separator=' | ', **kwargs):super().__init__(**kwargs) # pass the rest along the MROself.separator = separatordef format_line(self, items):returnself.separator.join(items)class ByteDecoder:def__init__(self, encoding='utf-8', **kwargs):super().__init__(**kwargs)self.encoding = encodingdef decode_bytes(self, byte_list):return [b.decode(self.encoding) for b in byte_list]class ByteFormatter(LineFormatter, ByteDecoder):pass# nothing left to write!``````{python}formatter = ByteFormatter(separator=' <-> ', encoding='latin1')print(f"Separator: '{formatter.separator}'")print(f"Encoding: '{formatter.encoding}'")```Follow the arguments as they travel. `ByteFormatter` has no `__init__` of its own, so thenext 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 passeson an empty `kwargs`, so `object.__init__()` is finally called with nothing at all. Eachclass 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:```{python}#| error: trueByteFormatter(separator=' - ', encodign='utf-8') # note the typo```The message is not exactly friendly — it complains about `object.__init__()` rather thanabout your typo — but the important thing is that it complains at all. Without thismechanism the misspelled `encodign` would have been silently swallowed, and you would bedebugging why your encoding setting had no effect.::: {.callout-tip icon=true}Multiple inheritance can be tricky. When in doubt:1. Keep inheritance hierarchies simple2. Use composition instead of complex inheritance3. Always check the MRO when debugging:::## Composition: the alternative to inheritancePoint 2 above deserves an example, because "use composition" is advice you will readeverywhere and it is rarely spelled out._Composition_ means: instead of *being* a thing, your object *has* a thing. Compare the twoversions of our formatter. With inheritance, `ByteFormatter` **is a** `LineFormatter` and**is a** `ByteDecoder`:```{python}class ByteFormatterInherited(LineFormatter, ByteDecoder):def format_bytes(self, byte_list):returnself.format_line(self.decode_bytes(byte_list))```With composition, it **has a** formatter and **has a** decoder, stored as ordinaryattributes:```{python}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):returnself.formatter.format_line(self.decoder.decode_bytes(byte_list))```Both do the same job:```{python}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.```{python}f = ByteFormatterComposed()f.decoder = ByteDecoder(encoding='latin1') # change the decoder, keep everything elseprint(f.format_bytes(data))```The cost is that you lose the automatic forwarding. If you want `ByteFormatterComposed` toalso 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 inheritanceanyway.::: {.callout-tip}## The usual rule of thumbAsk yourself whether the sentence "a `ByteFormatter` **is a** `ByteDecoder`" is actuallytrue. If it sounds forced, you want composition. Inheritance is for genuine specialisation(`SumTwoNumbers` really *is a* `TwoNumbers`); composition is for assembling a thing out ofparts.:::::: {.callout-note icon=false}## Exercise1. 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 argumentsMost 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:```{python}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``````{python}d = D()print("MRO:", [cls.__name__for cls in D.__mro__])d.greet()```The two-argument form becomes useful when you want to skip certain classes in the MRO:```{python}class D(B, C):def greet(self):print("Hi from D")super(B, self).greet() # Skip B, start from C in the MRO``````{python}d = D()d.greet()```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](https://peps.python.org/pep-3135/).::: {.callout-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- [Python Official Documentation - Classes](https://docs.python.org/3/tutorial/classes.html)- [Python's Method Resolution Order](https://docs.python.org/3/howto/mro.html)- [Inheritance and composition in Python](https://realpython.com/inheritance-composition-python/)