class OriginalClass:
... # This is the base class
class NewClass(OriginalClass):
... # This is the derived classOOP II: Inheritance and Method Resolution
When your dad is an object
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 file streams: sys.stdout and file objects both have:
- An
open()method - A
close()method
- A
write()method
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.
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.bThese new classes will inherit all the functionality from the parent class TwoNumber, but in addition the method result method is a new 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 methodTwoNumbers 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 (
SumTwoNumbersandDifferenceTwoNumbers)
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
passIn 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.badd_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
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 explicitlyHello 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
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.
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 formattedformatter = 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 formattedprint("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'
Multiple inheritance can be tricky. When in doubt:
- Keep inheritance hierarchies simple
- Use composition instead of complex inheritance
- Always check the MRO when debugging
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, explicitd = 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 MROd = 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 it 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.
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().