Inheritance in OOP: Types & Interview Guide
TL;DR
Inheritance allows you to create new classes based on existing ones, inheriting their attributes and methods. This promotes code reuse and establishes “is-a” relationships between classes. It’s a fundamental pillar of OOP that enables polymorphism and helps organize code into logical hierarchies.
Core Concept
What is Inheritance?
Inheritance is a mechanism where a new class (called the child class, subclass, or derived class) is created from an existing class (called the parent class, superclass, or base class). The child class automatically receives all attributes and methods from the parent class and can add new ones or modify existing behavior.
Why Inheritance Matters
Inheritance solves the problem of code duplication. Instead of copying and pasting similar code across multiple classes, you define common functionality once in a parent class and let child classes inherit it. This makes your code:
- DRY (Don’t Repeat Yourself): Common logic lives in one place
- Maintainable: Changes to shared behavior happen in one location
- Extensible: New variations can be added without modifying existing code
- Organized: Related classes form logical hierarchies
The “Is-A” Relationship
Inheritance models an “is-a” relationship. A Dog “is-a” Animal. A SavingsAccount “is-a” BankAccount. If you can’t say “Child is-a Parent” naturally, inheritance might not be the right choice.
Key Inheritance Concepts
Method Overriding: Child classes can replace parent methods with their own implementation. The child’s version takes precedence.
The super() Function: Allows child classes to call parent class methods, especially useful in constructors to initialize inherited attributes before adding child-specific ones.
Method Resolution Order (MRO): Python determines which method to call when there are multiple inheritance levels. It searches the child class first, then moves up the hierarchy.
Single vs. Multiple Inheritance
Single inheritance means a class has one parent. Multiple inheritance allows a class to inherit from multiple parents simultaneously (supported in Python and C++, but not Java). Multiple inheritance can create complexity and ambiguity, so use it sparingly.
Visual Guide
Basic Inheritance Hierarchy
classDiagram
Animal <|-- Dog
Animal <|-- Cat
Animal <|-- Bird
class Animal {
+name: str
+age: int
+eat()
+sleep()
}
class Dog {
+breed: str
+bark()
+fetch()
}
class Cat {
+indoor: bool
+meow()
+scratch()
}
class Bird {
+can_fly: bool
+chirp()
+fly()
}
Inheritance creates an “is-a” relationship. Dog, Cat, and Bird all inherit from Animal, gaining its attributes and methods while adding their own specific behaviors.
Method Resolution Order (MRO)
graph TD
A[Child Class] -->|1. Search here first| B[Method found?]
B -->|No| C[Parent Class]
C -->|2. Search parent| D[Method found?]
D -->|No| E[Grandparent Class]
E -->|3. Continue up hierarchy| F[Method found?]
F -->|No| G[object base class]
B -->|Yes| H[Execute method]
D -->|Yes| H
F -->|Yes| H
G -->|4. AttributeError if not found| I[Raise Error]
Python searches for methods starting at the child class and moving up the inheritance chain until it finds the method or reaches the top (object class).
Inheritance vs. Composition Decision Tree
graph TD
A[Need to share functionality?] -->|Yes| B{Is there an 'is-a' relationship?}
A -->|No| C[No relationship needed]
B -->|Yes| D[Use Inheritance]
B -->|No| E{Is there a 'has-a' relationship?}
E -->|Yes| F[Use Composition]
E -->|No| G[Consider other patterns]
D -->|Example| H[Dog is-a Animal]
F -->|Example| I[Car has-a Engine]
Choose inheritance when there’s a true “is-a” relationship. Use composition (one class contains another) for “has-a” relationships.
Examples
Example 1: Basic Inheritance with Method Overriding
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
def speak(self):
return "Some generic animal sound"
def info(self):
return f"{self.name} is {self.age} years old"
class Dog(Animal): # Dog inherits from Animal
def __init__(self, name, age, breed):
super().__init__(name, age) # Call parent constructor
self.breed = breed
def speak(self): # Override parent method
return "Woof! Woof!"
def fetch(self):
return f"{self.name} is fetching the ball!"
class Cat(Animal):
def speak(self): # Override parent method
return "Meow!"
def scratch(self):
return f"{self.name} is scratching the furniture"
# Usage
dog = Dog("Buddy", 3, "Golden Retriever")
cat = Cat("Whiskers", 2)
print(dog.info()) # Inherited from Animal
print(dog.speak()) # Overridden in Dog
print(dog.fetch()) # Dog-specific method
print(dog.breed) # Dog-specific attribute
print(cat.info()) # Inherited from Animal
print(cat.speak()) # Overridden in Cat
print(cat.scratch()) # Cat-specific method
Expected Output:
Buddy is 3 years old
Woof! Woof!
Buddy is fetching the ball!
Golden Retriever
Whiskers is 2 years old
Meow!
Whiskers is scratching the furniture
Key Points:
Dog(Animal)syntax means Dog inherits from Animalsuper().__init__(name, age)calls the parent constructor to initialize inherited attributes- Both Dog and Cat override
speak()with their own implementations info()is inherited and works without modification- Child classes add their own unique methods (
fetch(),scratch())
Java/C++ Note: In Java, use extends keyword: class Dog extends Animal. In C++, use : public Animal syntax: class Dog : public Animal.
Try it yourself: Create a Bird class that inherits from Animal, overrides speak() to return “Chirp!”, and adds a fly() method. Test with a bird instance.
Example 2: Using super() to Extend Parent Methods
class BankAccount:
def __init__(self, account_number, balance=0):
self.account_number = account_number
self.balance = balance
def deposit(self, amount):
if amount > 0:
self.balance += amount
return f"Deposited ${amount}. New balance: ${self.balance}"
return "Invalid deposit amount"
def withdraw(self, amount):
if 0 < amount <= self.balance:
self.balance -= amount
return f"Withdrew ${amount}. New balance: ${self.balance}"
return "Insufficient funds or invalid amount"
class SavingsAccount(BankAccount):
def __init__(self, account_number, balance=0, interest_rate=0.02):
super().__init__(account_number, balance)
self.interest_rate = interest_rate
def apply_interest(self):
interest = self.balance * self.interest_rate
self.balance += interest
return f"Interest applied: ${interest:.2f}. New balance: ${self.balance:.2f}"
def withdraw(self, amount):
# Extend parent method with additional logic
if amount > 500:
return "Savings account: Cannot withdraw more than $500 at once"
return super().withdraw(amount) # Call parent's withdraw
class CheckingAccount(BankAccount):
def __init__(self, account_number, balance=0, overdraft_limit=100):
super().__init__(account_number, balance)
self.overdraft_limit = overdraft_limit
def withdraw(self, amount):
# Override with different logic
if 0 < amount <= self.balance + self.overdraft_limit:
self.balance -= amount
return f"Withdrew ${amount}. New balance: ${self.balance}"
return "Exceeds overdraft limit"
# Usage
savings = SavingsAccount("SAV001", 1000, 0.03)
checking = CheckingAccount("CHK001", 200, 150)
print(savings.deposit(500)) # Inherited method
print(savings.apply_interest()) # SavingsAccount-specific
print(savings.withdraw(600)) # Overridden with restriction
print(savings.withdraw(400)) # Calls parent's withdraw via super()
print(checking.deposit(100)) # Inherited method
print(checking.withdraw(250)) # Overridden with overdraft
print(checking.withdraw(200)) # Would exceed overdraft
Expected Output:
Deposited $500. New balance: $1500
Interest applied: $45.00. New balance: $1545.00
Savings account: Cannot withdraw more than $500 at once
Withdrew $400. New balance: $1145.0
Deposited $100. New balance: $300
Withdrew $250. New balance: $50
Exceeds overdraft limit
Key Points:
super().withdraw(amount)in SavingsAccount calls the parent’s withdraw method after checking the $500 limit- CheckingAccount completely overrides withdraw with different logic (no super() call)
- Both child classes extend the parent constructor with additional attributes
- Inherited methods like
deposit()work unchanged in both child classes
Try it yourself: Add a transfer(amount, target_account) method to BankAccount that withdraws from one account and deposits to another. Test it with both SavingsAccount and CheckingAccount instances.
Example 3: Multi-Level Inheritance
class Vehicle:
def __init__(self, brand, year):
self.brand = brand
self.year = year
def start(self):
return f"{self.brand} vehicle starting..."
def stop(self):
return f"{self.brand} vehicle stopping..."
class Car(Vehicle):
def __init__(self, brand, year, num_doors):
super().__init__(brand, year)
self.num_doors = num_doors
def honk(self):
return "Beep beep!"
class ElectricCar(Car):
def __init__(self, brand, year, num_doors, battery_capacity):
super().__init__(brand, year, num_doors)
self.battery_capacity = battery_capacity
self.charge_level = 100
def start(self):
# Override grandparent's method
if self.charge_level > 0:
return f"{self.brand} electric motor starting silently..."
return "Battery dead! Cannot start."
def charge(self, hours):
charge_added = min(hours * 10, 100 - self.charge_level)
self.charge_level += charge_added
return f"Charged for {hours} hours. Battery: {self.charge_level}%"
# Usage
tesla = ElectricCar("Tesla", 2023, 4, 75)
print(tesla.start()) # Overridden in ElectricCar
print(tesla.honk()) # Inherited from Car
print(tesla.stop()) # Inherited from Vehicle (grandparent)
print(tesla.charge(3)) # ElectricCar-specific
print(f"Doors: {tesla.num_doors}") # From Car
print(f"Year: {tesla.year}") # From Vehicle
# Check the Method Resolution Order
print(ElectricCar.__mro__)
Expected Output:
Tesla electric motor starting silently...
Beep beep!
Tesla vehicle stopping...
Charged for 3 hours. Battery: 100%
Doors: 4
Year: 2023
(<class '__main__.ElectricCar'>, <class '__main__.Car'>, <class '__main__.Vehicle'>, <class 'object'>)
Key Points:
- ElectricCar inherits from Car, which inherits from Vehicle (three-level hierarchy)
- ElectricCar can access methods from both Car and Vehicle
__mro__shows the Method Resolution Order: ElectricCar → Car → Vehicle → object- Each
super().__init__()call passes control up one level in the hierarchy
Try it yourself: Add a HybridCar class that inherits from Car and has both a battery_capacity and fuel_tank_size. Override start() to check both fuel and battery levels.
Example 4: Checking Inheritance Relationships
class Animal:
pass
class Dog(Animal):
pass
class Cat(Animal):
pass
class GoldenRetriever(Dog):
pass
# Create instances
buddy = GoldenRetriever()
whiskers = Cat()
# isinstance() checks if an object is an instance of a class or its parents
print(isinstance(buddy, GoldenRetriever)) # True
print(isinstance(buddy, Dog)) # True
print(isinstance(buddy, Animal)) # True
print(isinstance(buddy, Cat)) # False
# issubclass() checks if a class inherits from another
print(issubclass(GoldenRetriever, Dog)) # True
print(issubclass(GoldenRetriever, Animal)) # True
print(issubclass(Dog, Animal)) # True
print(issubclass(Dog, Cat)) # False
# type() returns the exact class (not parents)
print(type(buddy)) # <class '__main__.GoldenRetriever'>
print(type(buddy) == GoldenRetriever) # True
print(type(buddy) == Dog) # False (even though buddy is-a Dog)
Expected Output:
True
True
True
False
True
True
True
False
<class '__main__.GoldenRetriever'>
True
False
Key Points:
- Use
isinstance(obj, Class)to check if an object is an instance of a class or any of its parent classes - Use
issubclass(ChildClass, ParentClass)to check inheritance relationships between classes type()returns the exact class, not considering inheritance- In interviews, prefer
isinstance()overtype()for type checking because it respects inheritance
Try it yourself: Create a function describe_animal(animal) that uses isinstance() to print different messages for Dog, Cat, or generic Animal instances.
Common Mistakes
1. Forgetting to Call super().init()
# WRONG
class Dog(Animal):
def __init__(self, name, breed):
# Forgot to call super().__init__()
self.breed = breed
dog = Dog("Buddy", "Labrador")
print(dog.name) # AttributeError: 'Dog' object has no attribute 'name'
Why it’s wrong: The parent class’s __init__ never runs, so inherited attributes like name and age are never created.
Fix: Always call super().__init__() with the appropriate arguments before initializing child-specific attributes.
# CORRECT
class Dog(Animal):
def __init__(self, name, age, breed):
super().__init__(name, age) # Initialize parent attributes first
self.breed = breed
2. Overusing Inheritance Instead of Composition
# WRONG - Car is NOT an Engine
class Car(Engine):
def __init__(self, horsepower, cylinders):
super().__init__(horsepower, cylinders)
Why it’s wrong: A Car “has-a” Engine, not “is-a” Engine. This violates the “is-a” principle and creates confusing hierarchies.
Fix: Use composition when there’s a “has-a” relationship.
# CORRECT - Composition
class Engine:
def __init__(self, horsepower, cylinders):
self.horsepower = horsepower
self.cylinders = cylinders
class Car:
def __init__(self, engine):
self.engine = engine # Car HAS-A Engine
engine = Engine(300, 6)
car = Car(engine)
Rule of thumb: If you can’t naturally say “Child is-a Parent,” use composition instead.
3. Creating Deep Inheritance Hierarchies
# PROBLEMATIC - Too many levels
class LivingThing:
pass
class Animal(LivingThing):
pass
class Mammal(Animal):
pass
class Carnivore(Mammal):
pass
class Canine(Carnivore):
pass
class Dog(Canine):
pass
class GoldenRetriever(Dog):
pass
Why it’s problematic: Deep hierarchies (more than 3-4 levels) become hard to understand, maintain, and debug. Changes at the top ripple down unpredictably.
Fix: Keep hierarchies shallow. Favor composition and interfaces/protocols for flexibility.
# BETTER - Flatter hierarchy
class Animal:
pass
class Dog(Animal):
def __init__(self, breed):
self.breed = breed
dog = Dog("Golden Retriever") # Breed is data, not a class
4. Shadowing Parent Attributes Unintentionally
class Parent:
def __init__(self):
self.value = 10
class Child(Parent):
def __init__(self):
super().__init__()
self.value = 20 # Shadows parent's value
def get_parent_value(self):
# No way to access parent's original value!
return self.value # Returns 20, not 10
Why it’s problematic: Once you reassign self.value, the parent’s original value is lost. This can cause confusion.
Fix: Use different attribute names or be intentional about overriding.
class Child(Parent):
def __init__(self):
super().__init__()
self.child_value = 20 # Different name
# self.value still holds parent's 10
5. Using type() Instead of isinstance() for Type Checking
class Animal:
pass
class Dog(Animal):
pass
dog = Dog()
# WRONG - Doesn't respect inheritance
if type(dog) == Animal:
print("It's an animal") # This won't print!
# CORRECT - Respects inheritance
if isinstance(dog, Animal):
print("It's an animal") # This prints!
Why it’s wrong: type() checks for exact class match, ignoring inheritance. A Dog is an Animal, but type(dog) == Animal returns False.
Fix: Use isinstance() for type checking in polymorphic code.
Interview note: Interviewers often test whether you understand the difference between type() and isinstance(). Always prefer isinstance() unless you specifically need the exact class.
Interview Tips
1. Explain the “Is-A” vs. “Has-A” Distinction Clearly
Interviewers frequently ask when to use inheritance versus composition. Have a crisp answer ready:
Your response: “I use inheritance for ‘is-a’ relationships where the child class is a specialized version of the parent. For example, a Dog is-a Animal. I use composition for ‘has-a’ relationships where one class contains another. For example, a Car has-a Engine. The Liskov Substitution Principle guides this: if I can’t substitute a child instance wherever a parent instance is expected, inheritance is wrong.”
Follow-up they might ask: “Why prefer composition over inheritance?” Answer: “Composition is more flexible. It avoids tight coupling and deep hierarchies. I can change behavior at runtime by swapping components, which inheritance doesn’t allow.”
2. Be Ready to Code Inheritance On the Spot
Common interview question: “Design a class hierarchy for [vehicles/employees/shapes/accounts].”
Your approach:
- Identify the base class with common attributes/methods
- Create 2-3 child classes that specialize the base
- Show method overriding in at least one child
- Use
super()correctly in constructors - Demonstrate polymorphism by treating different children as the parent type
Example prompt: “Design classes for different types of employees.”
class Employee:
def __init__(self, name, id, base_salary):
self.name = name
self.id = id
self.base_salary = base_salary
def calculate_pay(self):
return self.base_salary
class Manager(Employee):
def __init__(self, name, id, base_salary, bonus):
super().__init__(name, id, base_salary)
self.bonus = bonus
def calculate_pay(self):
return self.base_salary + self.bonus
class SalesRep(Employee):
def __init__(self, name, id, base_salary, commission_rate):
super().__init__(name, id, base_salary)
self.commission_rate = commission_rate
self.sales = 0
def calculate_pay(self):
return self.base_salary + (self.sales * self.commission_rate)
# Polymorphism in action
employees = [
Manager("Alice", 1, 80000, 20000),
SalesRep("Bob", 2, 50000, 0.1)
]
for emp in employees:
print(f"{emp.name}: ${emp.calculate_pay()}") # Same interface, different behavior
This demonstrates you understand inheritance, overriding, and polymorphism.
3. Know When NOT to Use Inheritance
Interviewers test your judgment. Be ready to critique inheritance:
Red flags for inheritance:
- The relationship isn’t truly “is-a” (use composition)
- You need multiple unrelated behaviors (use interfaces/mixins)
- The hierarchy would be more than 3-4 levels deep (flatten it)
- Child classes would need to override most parent methods (wrong abstraction)
Example answer: “If I’m designing a Duck class that can swim and fly, I wouldn’t create a hierarchy like Bird → FlyingBird → SwimmingBird → Duck. That’s fragile because not all birds fly or swim. Instead, I’d use composition with Flyable and Swimmable components, or use interfaces/protocols.”
4. Discuss the Liskov Substitution Principle (LSP)
This is a favorite interview topic. LSP states: “Objects of a subclass should be replaceable with objects of the superclass without breaking the program.”
Classic violation example:
class Rectangle:
def __init__(self, width, height):
self.width = width
self.height = height
def area(self):
return self.width * self.height
class Square(Rectangle): # Violates LSP!
def __init__(self, side):
super().__init__(side, side)
def set_width(self, width):
self.width = width
self.height = width # Breaks Rectangle's assumption
Why it violates LSP: A Square can’t truly substitute for a Rectangle because changing width also changes height, which Rectangle users don’t expect.
Your answer: “Square shouldn’t inherit from Rectangle because their behaviors differ fundamentally. A better design uses a common Shape interface or keeps them separate.”
5. Explain Method Resolution Order (MRO)
For Python interviews, know how MRO works, especially with multiple inheritance:
Question: “What happens when a class inherits from multiple parents with the same method?”
Your answer: “Python uses C3 linearization to determine MRO. It searches left-to-right, depth-first, but ensures each class appears before its parents. You can check the order with ClassName.__mro__ or ClassName.mro(). In practice, I avoid complex multiple inheritance and use mixins carefully to prevent ambiguity.”
Example:
class A:
def method(self):
return "A"
class B(A):
def method(self):
return "B"
class C(A):
def method(self):
return "C"
class D(B, C): # Multiple inheritance
pass
d = D()
print(d.method()) # Prints "B" (B comes before C in MRO)
print(D.__mro__) # Shows: D → B → C → A → object
6. Practice Refactoring Code to Use Inheritance
Interviewers may give you duplicate code and ask you to refactor:
Before:
class Circle:
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
def perimeter(self):
return 2 * 3.14 * self.radius
class Square:
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
After (with inheritance):
class Shape:
def describe(self):
return f"Area: {self.area()}, Perimeter: {self.perimeter()}"
def area(self):
raise NotImplementedError("Subclass must implement area()")
def perimeter(self):
raise NotImplementedError("Subclass must implement perimeter()")
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14 * self.radius ** 2
def perimeter(self):
return 2 * 3.14 * self.radius
class Square(Shape):
def __init__(self, side):
self.side = side
def area(self):
return self.side ** 2
def perimeter(self):
return 4 * self.side
This shows you can identify common patterns and extract them into a base class.
7. Know the Syntax Differences Across Languages
If interviewing for Java or C++ roles, know the syntax:
Python: class Dog(Animal):
Java: class Dog extends Animal {}
C++: class Dog : public Animal {}
Python: super().__init__(args)
Java: super(args); (in constructor)
C++: : Animal(args) (initializer list)
Being fluent in multiple syntaxes shows versatility.
Key Takeaways
- Inheritance creates “is-a” relationships where child classes inherit attributes and methods from parent classes, promoting code reuse and establishing logical hierarchies.
- Use super() to call parent methods, especially in constructors, to properly initialize inherited attributes before adding child-specific ones.
- Method overriding allows child classes to replace parent behavior while still accessing the original via super() if needed.
- Prefer composition over inheritance for “has-a” relationships and keep hierarchies shallow (2-3 levels max) to maintain flexibility and avoid tight coupling.
- Use isinstance() for type checking to respect inheritance relationships, and understand that the Liskov Substitution Principle requires child objects to be substitutable for parent objects without breaking functionality.