Inheritance is a powerful feature that allows us to use functionality from another class or from multiple classes.
Let’s define a simple class that we can initialize by passing any keyword arguments that we want and it will add those arguments to the class’s dictionary attribute, allowsing them to be dot-accessible:
class LooseInit:
def __init__(self, **kwargs) -> None:
self.__dict__.update(kwargs)If we want to use this functionality in another class, we have to inherit from this class:
class Child(LooseInit):
passWe can create an instance of this class to use this functionality:
sister = Child(name="Kiara", age=13)
brother = Child(name="Hunter", age=15)
print(f"{sister.name} is {sister.age} years old.")
print(f"{brother.name} is {brother.age} years old.")Output:
Kiara is 13 years old.
Hunter is 15 years old.
Inheritance becomes more powerful when you inherit from multiple classes. So let’s define another class:
class Utils:
@classmethod
def from_dict(cls, arg_dict):
return cls(**arg_dict)
def to_dict(self):
return {
key: value for key, value in self.__dict__.items() if not key.startswith("_")
}from_dictmethod allows us to create an instance of a class by passing in a dictionary of key to value argumentsto_dictmethod allows us to create a dictionary with all non-private attributes of a class.
Now, let our Child class to inherit from this new class too:
class Child(LooseInit, Utils):
passNow let’s use both these two new methods:
cousin = Child.from_dict({"name": "Amanda", "age": 16})
sister = Child(name="Kiara", age=13)
print(f"{cousin.name} is {cousin.age} years old.")
print(f"sister dict: {sister.to_dict()}")Output:
Amanda is 16 years old.
sister dict: {'name': 'Kiara', 'age': 13}
Say we want to control the input that’s going into the class during the instantiation, without touching the LooseInit class. To do so, we’ll define a new class before the Child class:
class StrictInit(LooseInit):
def __init__(self, **kwargs) -> None:
sanitized_args = {
key: value for key, value in kwargs.items() if not key.startswith("bad")
}What we have done here is to store unwanted arguments that were passed into our class and store into a new variable called sanitized_args. However, we lost the funcionality of LooseInit, that is add all keyword arguments to the class’s dictionary attribute. Instead of we-writing that functionality, Python provides the super() method:
class StrictInit(LooseInit):
def __init__(self, **kwargs) -> None:
sanitized_args = {
key: value for key, value in kwargs.items() if not key.startswith("bad")
}
super().__init__(**sanitized_args)
class Child(StrictInit, Utils):
pass
sister = Child(name="Kiara", age=13, bad_args="asdf")
print(f"sister dict: {sister.to_dict()}")Outpu:
sister dict: {'name': 'Kiara', 'age': 13}
As we can see the keyword argument bad_args was not stored into the class’s dictionary attribute.
Let’s say now we don’t like all these print statements, so we’ll write a new class that allows us to print things a bit better by overriding the __repr__ dunder method, which is part of every class in Python:
class BetterRepr(Utils):
def __repr__(self) -> str:
output = ''
for key, value in self.to_dict().items():
output += f"{key}: {value}, "
return f"<{output[:-2]}>"What __repr__ does is it controls what’s returned when you call for a string representation of an object. Notice that we’re using to_dict() method of Utils class because we’re inheriting from class and so we can use all methods of that class.
Now let’s let the Child class inherit from BetterRepr as well and then we’ll create some instances of that class:
class Child(StrictInit, BetterRepr):
pass
sister = Child(name="Kiara", age=13, bad_arg="asdf")
brother = Child(name="Hunter", age=15)
cousin = Child.from_dict({"name": "Amanda", "age": 17})
print(f"sister - {sister}")
print(f"brother - {brother}")
print(f"cousin - {cousin}")Output:
sister - <name: Kiara, age: 13>
brother - <name: Hunter, age: 15>
cousin - <name: Amanda, age: 17>
We can also add one of these instances as attribute of any other instances:
sister.cousin = cousin
brother.cousin = cousin
print(f"sister - {sister}")
print(f"brother - {brother}")Output:
sister - <name: Kiara, age: 13, cousin: <name: Amanda, age: 17>>
brother - <name: Hunter, age: 15, cousin: <name: Amanda, age: 17>>
Now let’s add a new method to StrictInit as well as BetterRepr classes:
class StrictInit(LooseInit):
def __init__(self, **kwargs) -> None:
sanitized_args = {
key: value for key, value in kwargs.items() if not key.startswith("bad")
}
super().__init__(**sanitized_args)
def print_cls(self):
print("StrictInit")
class BetterRepr(Utils):
def __repr__(self) -> str:
output = ''
for key, value in self.to_dict().items():
output += f"{key}: {value}, "
return f"<{output[:-2]}>"
def print_cls(self):
print("BetterRepr")
class Child(StrictInit, BetterRepr):
pass
sister = Child(name="Kiara", age=13, bad_arg="asdf")
print("Calling print_cls method")
sister.print_cls()Output:
Calling print_cls method
StrictInit
We can look that Child inherits from both StrictInit and BetterRepr classes, but it return StrictInit string when print_cls() method is called. That’s because Python resolves StrictInit before it does BetterRepr. If we swap the twos in the Child class definition, the print_cls method will return BetterRepr string:
class Child(BetterRepr, StrictInit):
pass
sister = Child(name="Kiara", age=13, bad_arg="asdf")
print("Calling print_cls method")
sister.print_cls()Output:
Calling print_cls method
BetterRepr
Let’s understand why this happens. Let’s paste all the classes we wrote so far and then let’s print the MRO of the Child class, that tells us which order Python looks to find the funcionality in the class:
class LooseInit:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class StrictInit(LooseInit):
def __init__(self, **kwargs):
sanitized_args = {
key: value for key, value in kwargs.items()
if not key.startswith("bad")
}
super().__init__(**sanitized_args)
def print_cls(self):
print("StrictInit")
class Utils:
@classmethod
def from_dict(cls, arg_dict):
return cls(**arg_dict)
def to_dict(self):
return {
key: value
for key, value in self.__dict__.items()
if not key.startswith("_")
}
class BetterRepr(Utils):
def __repr__(self):
output = ""
for key, value in self.to_dict().items():
output += f"{key}: {value}, "
return f"<{output[:-2]}>"
def print_cls(self):
print("BetterRepr")
class Child(BetterRepr, StrictInit):
pass
Child.mro()Output:
[__main__.Child
__main__.BetterRepr,
__main__.Utils,
__main__.StrictInit,
__main__.LooseInit,
object]
We can see that:
- it starts from the
Child - then it goes to
BetterRepr, becauseChildinherits fromBetterRepr - then it goes to
Utils, becauseBetterReprinherits fromUtils
So far, it’s going to the left-side of the inheritance. Now it will go to the right-side:
- it goes to
StrictInit - then it goes to
LooseInit, becauseStrictInitinherits fromLooseInit - then all classes within Python eventually inherit from the
objectclass, which is a built-in class in Python and it provides a lot of base funcionalities.
Let’s represent it graphically:
Utils LooseInit
| |
BetterRepr StrictInit
\ /
Child
There’s another thing that can trip you up when it comes to the MRO order. We’ll make a new Nothing class and lets the BetterRepr inherits from it:
class Nothing:
pass
class BetterRepr(Nothing, Utils):
def __repr__(self):
output = ""
for key, value in self.to_dict().items():
output += f"{key}: {value}, "
return f"<{output[:-2]}>"
def print_cls(self):
print("BetterRepr")
class Child(BetterRepr, StrictInit):
pass
Child.mro()Output:
[__main__.Child,
__main__.BetterRepr,
__main__.Nothing,
__main__.Utils,
__main__.StrictInit,
__main__.LooseInit,
object]
This result is what we expected and we can see graphically as well:
Nothing Utils LooseInit
\ / |
BetterRepr StrictInit
\ /
Child
But, what happens if StrictInit inherits from Nothing as well?
class Nothing:
pass
class LooseInit:
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
class StrictInit(LooseInit, Nothing):
def __init__(self, **kwargs):
sanitized_args = {
key: value for key, value in kwargs.items()
if not key.startswith("bad")
}
super().__init__(**sanitized_args)
def print_cls(self):
print("StrictInit")
class Utils:
@classmethod
def from_dict(cls, arg_dict):
return cls(**arg_dict)
def to_dict(self):
return {
key: value
for key, value in self.__dict__.items()
if not key.startswith("_")
}
class BetterRepr(Nothing, Utils):
def __repr__(self):
output = ""
for key, value in self.to_dict().items():
output += f"{key}: {value}, "
return f"<{output[:-2]}>"
def print_cls(self):
print("BetterRepr")
class Child(BetterRepr, StrictInit):
pass
Child.mro()Output:
[__main__.Child,
__main__.BetterRepr,
__main__.StrictInit,
__main__.LooseInit,
__main__.Nothing,
__main__.Utils,
object]
Before giving explanation, let’s represent it graphically:
Nothing Utils LooseInit Nothing
\ / \ /
BetterRepr StrictInit
\ /
Child
The result now is different from what we expected. Since MRO goes from the left-side to the right-side we expected to see Nothing after BetterRepr. What happens is that adding the Nothing to StrictInit brought the Nothing class higher in the MRO; actually it will put as high as possible. So, Python will wait to resolve Nothing until all other classes (StrictInit and its ancestors) are also considered.
As a rule you have to remember that when it comes to the MRO, when you have common parents, those parents will be moved above any other parents on the same level.