Object-Oriented Programming: Inheritance#
As a quick recap, we’ve discovered that in Python, classes serve as blueprints, outlining both the structure and behavior of objects. Objects, in turn, are instances of these classes, embodying real-world entities.
Inheritance: superclass and subclass#
In this episode, our focus shifts to a powerful OOP concept—inheritance. Imagine our familiar Car
class; today, we’ll delve into how we can extend and refine its features.
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
def __str__(self):
return f"Brand: {self.brand}\nModel: {self.model}"
camry = Car(brand="Toyota", model="Camry")
Inheritance allows us to build on existing classes. We’ll explore how a new class (a subclass) can inherit attributes and methods from an existing class (a superclass). This elegant mechanism not only fosters code reuse but also establishes a hierarchy that mirrors real-world relationships.
class ElectricCar(Car):
def __init__(self, brand, model, battery_level=100):
Car.__init__(self, brand, model)
self.battery_level = battery_level
tesla_s = ElectricCar(brand="Tesla", model="Model S")
print(tesla_s)
Brand: Tesla
Model: Model S
In the example above, notice that we didn’t explicitly define the __str__
method in the subclass. Instead, we inherited it from the superclass. This illustrates a fundamental aspect of inheritance, where the subclass can acquire and use methods defined in the superclass without having to redefine them.
If our superclass included additional specialized methods, the subclass would automatically have access to them as well. This underscores the efficiency of inheritance, allowing us to leverage a set of common functionalities from a superclass while maintaining the flexibility to extend or override them in the subclass.
‘d like to highlight a crucial concept: the ability to leverage the code specified in a superclass. To illustrate this, let’s enhance the complexity of our superclass:
class Car:
def __init__(self, brand, model, year, color, odometer=0, doors_locked=True):
self.brand = brand
self.model = model
self.year = year
self.color = color
self.odometer = odometer
self.doors_locked = doors_locked
def vehicle_status(self):
"""Prints the status of the car including all attributes."""
print(f"Car Status:")
print(f"-- Brand: {self.brand}")
print(f"-- Model: {self.model}")
print(f"-- Year: {self.year}")
print(f"-- Color: {self.color}")
print(f"-- Odometer: {self.odometer} miles")
print(f"-- Doors: {'Locked' if self.doors_locked else 'Unlocked'}")
def unlock(self):
"""Unlocks the doors of the car."""
if self.doors_locked:
print("Doors are now unlocked.")
self.doors_locked = False
else:
print("Doors are already unlocked.")
def lock(self):
"""Locks the doors of the car."""
if not self.doors_locked:
print("Doors are now locked.")
self.doors_locked = True
else:
print("Doors are already locked.")
In our previous episode, we implemented the Car
class. Now, let’s revisit that and extend our exploration by creating an ElectricCar
class, inheriting from the existing Car
class:
class ElectricCar(Car):
def __init__(self, brand, model, year, color, battery_level=100, odometer=0, doors_locked=True):
Car.__init__(self, brand, model, year, color, odometer, doors_locked)
self.battery_level = battery_level
tesla_s = ElectricCar("Tesla", "Model S", 2023, "Black")
Now, we can make use of all the methods that we’ve defined in the Car
class:
tesla_s.vehicle_status()
tesla_s.unlock()
tesla_s.vehicle_status()
Car Status:
-- Brand: Tesla
-- Model: Model S
-- Year: 2023
-- Color: Black
-- Odometer: 0 miles
-- Doors: Locked
Doors are now unlocked.
Car Status:
-- Brand: Tesla
-- Model: Model S
-- Year: 2023
-- Color: Black
-- Odometer: 0 miles
-- Doors: Unlocked
Now, we have the opportunity to define specific methods exclusive to the ElectricCar
class. These methods will be accessible only for instances of the ElectricCar
class:
class ElectricCar(Car):
def __init__(self, brand, model, year, color, battery_level=100, odometer=0, doors_locked=True):
Car.__init__(self, brand, model, year, color, odometer, doors_locked)
self.battery_level = battery_level
def battery_status(self):
print(f"Battery level: {self.battery_level}")
tesla_s = ElectricCar("Tesla", "Model S", 2023, "Black")
tesla_s.battery_status()
Battery level: 100
Default inheritance#
In Python, everything is treated as an object. Let’s explore this concept by creating an empty class, avoiding any manually specified methods or attributes:
class EmptyClass:
pass
In the snippet above, the pass
keyword allows us to create a minimal class without implementing any specific functionality.
Now, let’s inspect what kind of attributes and methods are associated with an instance of our EmptyClass
:
obj = EmptyClass()
print(dir(obj))
['__class__', '__delattr__', '__dict__', '__dir__', '__doc__', '__eq__', '__format__', '__ge__', '__getattribute__', '__getstate__', '__gt__', '__hash__', '__init__', '__init_subclass__', '__le__', '__lt__', '__module__', '__ne__', '__new__', '__reduce__', '__reduce_ex__', '__repr__', '__setattr__', '__sizeof__', '__str__', '__subclasshook__', '__weakref__']
Upon running this, you’ll observe a myriad of names associated with our object.
This abundance is due to the fact that, by default, any class in Python is implicitly a subclass of a superclass known as the object
class.
This inheritance from the object
class provides a set of default attributes and methods to every class, contributing to the comprehensive list we see when querying the instance.
Multiple inheritance#
In Python, our flexibility extends beyond inheriting from just one class. We have the capability to utilize multiple classes as superclasses for a single subclass. Let’s examine the following example:
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
class Convertible(Car):
def __init__(self, brand, model, roof_open=False):
Car.__init__(self, brand, model)
self.roof = roof_open
def open_roof(self):
if not self.roof:
print("Roof now is open")
self.roof = True
else:
print("Roof is already open")
def close_roof(self):
if self.roof:
print("Roof now is closed")
self.roof = False
else:
print("Roof is already closed")
class HybridCar(Car):
def __init__(self, brand, model, battery_level=100):
Car.__init__(self, brand, model)
self.battery_level = battery_level
def charge_battery(self):
print("Battery was charged")
self.battery_level = 100
class HybridConvertible(Convertible, HybridCar):
def __init__(self, brand, model, roof_open=False, battery_level=100):
# Call constructors of both parent classes
Convertible.__init__(self, brand, model, roof_open)
HybridCar.__init__(self, brand, model, battery_level)
# Creating an instance of HybridConvertible
hybrid_convertible = HybridConvertible(brand="Toyota", model="Prius Convertible", battery_level=50)
print(f"Battery Level: {hybrid_convertible.battery_level}")
print(f"Current roof status: {'Open' if hybrid_convertible.roof else 'Closed'}")
# Accessing methods from both parent classes
hybrid_convertible.open_roof() # From Convertible class
hybrid_convertible.charge_battery() # From HybridCar class
print(f"Battery Level: {hybrid_convertible.battery_level}")
print(f"Current roof status: {'Open' if hybrid_convertible.roof else 'Closed'}")
Battery Level: 50
Current roof status: Closed
Roof now is open
Battery was charged
Battery Level: 100
Current roof status: Open
In this example:
The
Car
class represents base class.The
Convertible
class inherits fromCar
and adds specific methodsopen_roof
andclose_roof
.The
HybridCar
class also inherits fromCar
and adds a specific methodcharge_battery
.The
HybridConvertible
class inherits from bothConvertible
andHybridCar
, showcasing multiple inheritance.
Instances of HybridConvertible
can access methods from all the parent classes, demonstrating the flexibility and power of multiple inheritance.
Method Resolution Order (MRO)#
In Python, when we encounter multiple inheritance, it’s not uncommon to have the same method names defined in different parent classes. This can pose a challenge: which method should be called when we invoke it on an instance of a subclass? To address this, Python uses the Method Resolution Order (MRO) to establish a systematic sequence for searching and determining the order in which methods are resolved.
Here are the key points about the Method Resolution Order:
1. Depth-First, Left-to-Right (DRLR):
By default, Python follows a depth-first, left-to-right approach for method resolution. This means that it first looks in the current class, then in its superclass (from left to right), and continues this pattern until it finds the desired method or attribute.
2. __mro__
attribute:
You can access the MRO of a class using the __mro__
attribute or the mro()
method.
This provides a tuple representing the order in which Python searches for methods.
class A:
pass
class B(A):
pass
class C(A):
pass
class D(B, C):
pass
print(D.__mro__)
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
3. C3 Linearization Algorithm:
Python employs the C3 Linearization Algorithm to determine the MRO. This algorithm ensures consistency and avoids ambiguity in the order of method resolution. If you want to look at some details then click here.
4. Diamond Inheritance Issue:
In scenarios where there’s a diamond-shaped inheritance (e.g., class D inherits from both B and C, which both inherit from A), the MRO prevents ambiguity by following the DRLR approach.
class A:
def method(self):
print("A method")
class B(A):
def method(self):
print("B method")
class C(A):
def method(self):
print("C method")
class D(B, C):
pass
instance_d = D()
instance_d.method() # Outputs "B method" due to the MRO
B method
In this example, the MRO ensures that the method from class B takes precedence over the method from class C.
Note
Understanding the Method Resolution Order is crucial, especially in complex class hierarchies involving multiple inheritance, as it helps predict which method or attribute will be accessed when called on an instance of a class.
super()
method#
The super()
method in Python is used to call a method from a parent or superclass. It is often employed within the context of inheritance, where a subclass wants to invoke a method that is defined in its superclass. Here’s a breakdown of the super()
method:
Usage in the __init__
Method:
A common use case is within the __init__
method of a subclass. This allows the subclass to initialize its own attributes and then call the __init__
method of the superclass to handle its specific initialization.
class Parent:
def __init__(self, name):
self.name = name
class Child(Parent):
def __init__(self, name, additional_info):
super().__init__(name)
self.additional_info = additional_info
In this example, super().__init__(name)
calls the __init__
method of the Parent
class, allowing the Child
class to inherit the name attribute.
Passing Arguments:
You use super()
without arguments when you’re working within a class and Python dynamically figures out the appropriate superclass to call.
If you need to be more explicit, you can pass the class and instance explicitly to super()
.
class Parent:
def some_method(self):
print("Parent method")
class Child(Parent):
def some_method(self):
super().some_method()
print("Child method")
In this example, super().some_method()
calls the some_method
of the Parent
class, allowing the Child
class to extend or override the behavior.
Multiple Inheritance:
super()
is particularly useful in multiple inheritance scenarios, where a class inherits from more than one superclass.
It ensures that methods are called in the correct order in the inheritance chain.
class A:
def some_method(self):
print("A method")
class B(A):
def some_method(self):
print("B method")
super().some_method()
class C(A):
def some_method(self):
print("C method")
super().some_method()
class D(B, C):
pass
instance_d = D()
instance_d.some_method()
B method
C method
A method
Here, super().some_method()
ensures that the method is called from the next class in the method resolution order (MRO).
Note
The super()
method is a powerful tool for maintaining a clean and consistent structure in code that involves inheritance. It fosters code reuse and supports the principles of modularity and extensibility in object-oriented programming.
isinstance
function#
The isinstance() method in Python is used to check whether an object is an instance of a particular class or a tuple of classes. It returns True
if the object is an instance of any of the specified classes; otherwise, it returns False
. Let’s use our Car
class as an example:
class Car:
def __init__(self, brand, model):
self.brand = brand
self.model = model
class ElectricCar(Car):
def __init__(self, brand, model, battery_capacity):
super().__init__(brand, model)
self.battery_capacity = battery_capacity
# Creating instances of Car and ElectricCar
regular_car = Car(brand="Toyota", model="Camry")
electric_car = ElectricCar(brand="Tesla", model="Model S", battery_capacity=100)
# Using isinstance to check object types
print(isinstance(regular_car, Car)) # True
print(isinstance(regular_car, ElectricCar)) # False
print(isinstance(electric_car, Car)) # True
print(isinstance(electric_car, ElectricCar)) # True
True
False
True
True
In this example:
isinstance(regular_car, Car)
checks ifregular_car
is an instance of theCar
class, which is true.isinstance(regular_car, ElectricCar)
checks ifregular_car
is an instance of theElectricCar
class, which is false.isinstance(electric_car, Car)
checks ifelectric_car
is an instance of theCar
class, which is true becauseElectricCar
is a subclass ofCar
.isinstance(electric_car, ElectricCar)
checks ifelectric_car
is an instance of theElectricCar
class, which is true.
This method is useful for checking the type of an object before performing certain operations, especially in scenarios involving inheritance and polymorphism.
type
vs isinstance
#
The type()
and isinstance()
functions in Python are used to determine the type or class of an object, but they differ in their use cases.
The type()
function is used to get the type of an object. It returns the type as a class object.
print(type(electric_car))
<class '__main__.ElectricCar'>
It can also be used to compare the type of an object with a specific type.
print(type(electric_car) == ElectricCar) # True
print(type(electric_car) == Car) # False
True
False
However, using type()
for checking types in the context of inheritance can be less flexible, especially when dealing with subclasses.
In summary, while both type()
and isinstance()
can be used to check the type of an object, type()
is more straightforward and is mainly used for direct type checking, whereas isinstance()
is more versatile, considering inheritance relationships and often used in scenarios involving polymorphism and class hierarchies.
issubclass
function#
The issubclass()
function in Python is used to check if a class is a subclass of another class.
It returns True
if the first class is a subclass of the second class, and False
otherwise. Here’s an explanation:
class Vehicle:
pass
class Car(Vehicle):
pass
class ElectricCar(Car):
pass
# Using issubclass to check class relationships
print(issubclass(Car, Vehicle)) # True
print(issubclass(ElectricCar, Car)) # True
print(issubclass(ElectricCar, Vehicle)) # True
print(issubclass(Vehicle, Car)) # False
True
True
True
False
In this example:
issubclass(Car, Vehicle)
checks ifCar
is a subclass ofVehicle
, which is true.issubclass(ElectricCar, Car)
checks ifElectricCar
is a subclass ofCar
, which is true.issubclass(ElectricCar, Vehicle)
checks ifElectricCar
is a subclass ofVehicle
, which is true because it indirectly inherits fromVehicle
throughCar
.issubclass(Vehicle, Car)
checks ifVehicle
is a subclass ofCar
, which is false.
Note
The issubclass()
function is particularly useful when dealing with class hierarchies and inheritance. It allows you to check the relationship between two classes and determine whether one is derived from another. This function is often used in scenarios where you need to ensure the compatibility or hierarchy of classes in your code.