Week 4 Lecture: Inheritance and Composition
Introduction to Object-Oriented Relationships
In object-oriented programming (OOP), objects in a system frequently relate to one another, much like entities in the real world. These relationships form the foundation of software design. Two primary types of relationships exist:
- “Is-a” relationship: Denotes inheritance (e.g., a Cat is an Animal, a Student is a Person).
- “Has-a” relationship: Denotes composition (e.g., a Laptop has a CPU, a Car has an Engine).
While creating isolated classes is useful for introductory concepts, enterprise applications consist of numerous interacting classes. Understanding how to structure these interactions through inheritance and composition is crucial for robust software design.
Inheritance
Inheritance is a mechanism wherein a new class, known as a child class (or subclass/derived class), is derived from an existing class, known as a parent class (or base class/superclass). The child class inherits the attributes and methods of the parent class, promoting code reuse, and can also define its own unique attributes and methods.
Consider a fundamental Person class:
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def greet(self):
print(f'hi, i am {self.name}')
To create a Student class that shares the characteristics of a Person but includes additional student-specific information, inheritance is employed. The syntax in Python involves passing the parent class as an argument to the child class definition:
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age)
self.student_id = student_id
def study(self):
print(f"{self.name} is studying")
The super() Function
Within the Student class’s initialization method (__init__), the super() function is utilized. super() returns a proxy object that delegates method calls to a parent or sibling class. In this context, super().__init__(name, age) invokes the __init__ method of the Person class, allowing the parent class to handle the initialization of name and age.
While it is technically possible to explicitly call the parent class’s method (e.g., Person.__init__(self, name, age)), utilizing super() is the standard and recommended practice. Hardcoding the parent class name tightly couples the classes and can lead to complications, particularly in scenarios involving multiple inheritance, as super() dynamically resolves the method resolution order (MRO).
When an instance of the Student class is created, it inherently possesses the methods defined in the Person class:
s = Student('sevara', 20, '2512345')
s.study()
s.greet()
Method Overriding
A child class can modify the behavior of an inherited method by redefining it. This process is known as method overriding. When a method is called on an object, Python first searches for the method within the object’s class. If found, it executes that version; otherwise, it proceeds to search the parent class.
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age)
self.student_id = student_id
def greet(self):
print(f"hi, i am {self.name}, my student id: {self.student_id}")
p = Person("jasur", 20)
s = Student("sevara", 22, "2512345")
p.greet() # hi, i am jasur
s.greet() # hi, i am sevara, my student id: 2512345
Furthermore, an overridden method can extend the functionality of the parent’s method by utilizing super() to invoke the parent’s implementation prior to or following its own logic:
class Student(Person):
def __init__(self, name, age, student_id):
super().__init__(name, age)
self.student_id = student_id
def greet(self):
super().greet()
print(f"my id is {self.student_id}")
Multilevel Inheritance
Inheritance is not limited to a single level. Classes can form an inheritance chain, where a class acts as a child to one class and a parent to another.
class GradStudent(Student):
def __init__(self, name, age, student_id, thesis_topic):
super().__init__(name, age, student_id)
self.thesis_topic = thesis_topic
def greet(self):
print(f'hi, i am {self.name}. i am researching {self.thesis_topic}')
g = GradStudent("nodira", 24, "2512345", "machine learning")
g.greet()
print(g.student_id)
In this hierarchy, GradStudent inherits from Student, which in turn inherits from Person. Consequently, a GradStudent instance possesses all the attributes and methods defined across the entire inheritance chain.
Type Checking Functions
Python provides built-in functions to verify class types and inheritance relationships:
isinstance(object, classinfo): ReturnsTrueif the object is an instance of theclassinfoargument, or an instance of any subclass thereof.type(object): Returns the exact class type of the object. It does not account for inheritance.issubclass(class, classinfo): ReturnsTrueifclassis a subclass ofclassinfo.
print(isinstance(g, GradStudent)) # True
print(isinstance(g, Student)) # True
print(isinstance(g, Person)) # True
print(issubclass(GradStudent, Student)) # True
print(issubclass(GradStudent, Person)) # True
print(issubclass(Student, Person)) # True
Multiple Inheritance and Method Resolution Order (MRO)
Python supports multiple inheritance, allowing a class to inherit from more than one parent class.
class A:
def hello(self):
print("Hello from A")
class B(A):
def hello(self):
print("Hello from B")
class C(A):
def hello(self):
print("Hello from C")
class D(B, C):
pass
When an object of class D invokes a method that exists in multiple ancestor classes (e.g., d = D(); d.hello()), Python determines which method to execute based on the Method Resolution Order (MRO). The MRO is the sequential order in which Python searches for base classes during method resolution.
The MRO can be inspected using the __mro__ attribute or the mro() method:
print(D.__mro__)
# (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)
The MRO is calculated using the C3 linearization algorithm, which adheres to the following general principles:
- The child class is evaluated first.
- Parent classes are evaluated in the order they are specified in the class definition (left-to-right).
- A superclass is only evaluated after all its subclasses have been evaluated.
- The built-in
objectclass is always evaluated last.
Therefore, when d.hello() is called, Python searches D, then B, finding the method in B and executing it.
The True Nature of super()
The super() function relies inherently on the MRO. It does not simply refer to the “parent class”; rather, it delegates the method call to the next class in the MRO sequence.
class A:
def hello(self):
print("Hello from A")
class B(A):
def hello(self):
print("Hello from B")
super().hello()
class C(A):
def hello(self):
print("Hello from C")
super().hello()
class D(B, C):
def hello(self):
print("Hello from D")
super().hello()
d = D()
d.hello()
Output:
Hello from D
Hello from B
Hello from C
Hello from A
In this scenario, when B.hello() executes super().hello(), the MRO dictates that the next class in the sequence is C, not A. This illustrates why super() is essential for cooperative multiple inheritance, ensuring that every method in the hierarchy is executed in the correct, predictable order.
The Universal Base Class: object
In Python, all classes implicitly inherit from the built-in object base class.
class MyClass:
pass
print(MyClass.__bases__) # (<class 'object'>,)
print(isinstance(MyClass(), object)) # True
This fundamental inheritance structure means that every object in Python—including integers, strings, and even functions—is an instance of object and inherits default implementations of special methods such as __init__, __str__, and __repr__.
print(isinstance(5, object)) # True
print(isinstance(3.14, object)) # True
print(isinstance("hello", object)) # True
print(isinstance([1, 2, 3], object)) # True
print(isinstance({"a": 1}, object)) # True
print(isinstance(True, object)) # True
print(isinstance(None, object)) # True
print(isinstance(print, object)) # True
Composition
Composition is an architectural principle where complex objects are constructed by assembling smaller, simpler objects. It establishes a “has-a” relationship, contrasting with the “is-a” relationship of inheritance.
Consider Car and Engine objects. A Car is not an Engine, but it has an Engine.
class Engine:
def __init__(self, horsepower):
self.horsepower = horsepower
def start(self):
print('engine started')
class GPS:
def navigate(self, destination):
print(f'navigating to {destination}')
class Car:
def __init__(self, brand, horsepower):
self.brand = brand
self.engine = Engine(horsepower) # Has-A Engine
self.gps = GPS() # Has-A GPS
def drive(self, destination):
self.engine.start()
self.gps.navigate(destination)
print('driving...')
car = Car("tesla", 75)
car.drive("bazaar")
In this design, the Car class encapsulates instances of Engine and GPS. It orchestrates these components without inheriting from them. This modular approach allows for independent modification and substitution of components, yielding more flexible and maintainable code.
Inheritance vs. Composition
When designing object-oriented systems, determining whether to utilize inheritance or composition can be guided by the conceptual relationship:
- Use inheritance when an “is-a” relationship is logically sound (e.g., a
Rectangleis aShape). - Use composition when a “has-a” relationship is logically sound (e.g., a
Universityhas aDepartment).
A general principle in software engineering is to favor composition over inheritance. Inheritance establishes a rigid, tightly coupled hierarchy where changes to a parent class can inadvertently affect numerous subclasses. Composition offers greater flexibility, permitting the dynamic assembly and interchange of behaviors at runtime.
Combining Inheritance and Composition
Real-world applications frequently employ both inheritance and composition in tandem to accurately model complex domains.
class Person:
def __init__(self, name, age):
self.name = name
self.age = age
def __str__(self):
return f"{self.name} (age {self.age})"
class Address:
def __init__(self, city, street):
self.city = city
self.street = street
def __str__(self):
return f"{self.street}, {self.city}"
class Student(Person): # Is-A Person
def __init__(self, name, age, student_id, city, street):
super().__init__(name, age)
self.student_id = student_id
self.address = Address(city, street) # Has-A Address
def info(self):
return f"Student {self.name} (ID: {self.student_id}), lives at {self.address}"
s = Student("Jasur", 20, "AKU-2024-042", "Tashkent", "Amir Temur 15")
print(s.info())
# Student Jasur (ID: AKU-2024-042), lives at Amir Temur 15, Tashkent
Here, a Student represents an entity that is a Person (inheritance) and inherently possesses an Address (composition).
Advanced Examples
Example: Multiple Initialization
When dealing with multiple inheritance, careful coordination of __init__ methods is required to ensure all parent classes are correctly initialized.
class Father:
def __init__(self):
self.name = "from Father"
class Mother:
def __init__(self):
self.name = "from Mother"
class Child(Father, Mother):
def __init__(self):
super().__init__() # only calls Father.__init__ (next in MRO)
c = Child()
print(c.name) # "from Father"
To execute the initialization logic of all ancestors, cooperative super() calls are necessary:
class Father2:
def __init__(self):
super().__init__()
self.name = "from Father" # runs second, overwrites Mother's value
class Mother2:
def __init__(self):
super().__init__()
self.name = "from Mother" # runs first (MRO: Child → Father2 → Mother2 → object)
class Child2(Father2, Mother2):
def __init__(self):
super().__init__()
c2 = Child2()
print(c2.name) # "from Father" — Father2 runs last, so its assignment wins
Example: Inheriting from Built-in Types
Python permits subclassing of built-in data types, such as list, dict, or str, enabling the extension of their native functionality.
class MyList(list):
def first(self):
return self[0] if self else None
def last(self):
return self[-1] if self else None
m = MyList([10, 20, 30])
print(m.first()) # 10
print(m.last()) # 30
m.append(40) # inherited from list
print(m) # [10, 20, 30, 40]
print(type(m)) # <class '__main__.MyList'>
Example: Name Mangling and Inheritance
In Python, attributes prefixed with a double underscore (__) undergo name mangling, which alters their internal representation to include the class name. This mechanism simulates private attributes and helps prevent accidental overriding in subclasses.
class Parent:
def __init__(self):
self.__secret = "hidden" # becomes self._Parent__secret
self._protected = "visible" # single underscore — just a convention
class Child(Parent):
def reveal(self):
# print(self.__secret) # AttributeError! Python looks for _Child__secret
print(self._Parent__secret) # works, but ugly
print(self._protected) # works fine
c = Child()
c.reveal()
# hidden
# visible
This content will be available starting March 02, 2026.