Inheritance & Polymorphism

Acadestine

Learning Objectives
    • Create parent and child classes to reuse common attributes and methods across related objects.
    • Use the super() function within child class constructors to initialize inherited base attributes.
    • Implement method overriding to allow child classes to customize or extend parent class behavior.

Stop Copy-Pasting Your Classes

Imagine you are building an application that starts with a basic user account. Copy-pasting existing class code to build a slightly different class feels fast at first, but it quickly becomes a maintenance nightmare.

Let's take a look at what happens when you build separate User and Admin classes without sharing any code:

class User:
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def login(self):
        print(f"{self.username} has logged in.")

class Admin:
    def __init__(self, username, email):
        self.username = username
        self.email = email

    def login(self):
        print(f"{self.username} has logged in.")

    def delete_user(self, user):
        print(f"Deleting user: {user}")

Take a close look at the User and Admin definitions above. Notice how both classes contain the exact same __init__() method and login() logic?

This pattern of code duplication creates major maintenance issues:

  • Repeated Effort: Every time you add a shared feature, you have to write and test the exact same code in multiple places.
  • Bug Vulnerabilities: If you discover a bug in User.login(), you must remember to fix it inside Admin.login() as well. If you forget one place, your app breaks.
  • Scalability Bottlenecks: Adding new roles like Moderator or Guest multiplies the amount of redundant code you need to manage.

As a developer, your goal is to keep your code DRY (Don't Repeat Yourself). Instead of duplicating logic across separate classes, object-oriented programming gives us tools to share attributes and actions cleanly.

The Power of Class Lineage

Now that you've seen how messy copy-pasting code across classes can get, it's time to unlock a core superpower of Object-Oriented Programming: class lineage. By establishing parent-child relationships between your classes, you eliminate repetitive code and build software that scales effortlessly.

The DRY Principle in Action

In software engineering, DRY stands for "Don't Repeat Yourself." When you apply the DRY principle to your class design, you place shared attributes and behaviors inside a single parent class instead of duplicating them across multiple separate classes.

Here is a quick look at how specialized classes build upon a shared foundation:

# Parent class: Houses shared logic in one place
class User:
    def login(self):
        print("User logged in successfully.")

# Child class: Inherits login() automatically and adds specialized logic
class Admin(User):
    def purge_logs(self):
        print("System logs purged.")

By defining login() once inside the User parent class, Admin gets that functionality for free without writing a single duplicate line of code. If you ever need to change how logging in works, you only update the code in one single location.

Extensibility Through Specialization

As your applications grow, you will constantly need to introduce new features and object types. Class lineage allows for seamless growth through specialization building distinct, feature-rich child classes on top of simple, generalized parent classes.

Adopting this approach gives your codebase three massive advantages:

  • Single source of truth: Core functionality lives in one parent class, preventing bugs caused by out-of-sync copy-pasted logic.
  • Safe expansion: You can add new specialized features in child classes without modifying or accidentally breaking working parent code.
  • Intuitive organization: Your codebase naturally models real-world hierarchy, making it much easier for you and your team to navigate.

Smartphones and Upgraded Models

Think about how tech companies release new smartphones every year. When designing the high-end "Pro" model, engineers don't re-invent the wheel or rebuild the device from scratch they start with the foundational hardware of the standard model and build upon it.

Imagine a standard Base Smartphone. It comes equipped with the core capabilities that every modern device requires to function:

  • Making phone calls
  • Sending text messages
  • Connecting to Wi-Fi networks
  • Displaying content on a standard 60Hz screen

Now, imagine the company wants to release a "Pro" Smartphone. Rather than redesigning the entire motherboard, cellular chip, and operating system, the Pro model inherits the base model's blueprint.

From that starting foundation, the Pro model modifies the base blueprint in two specific ways:

  • Specialized Additions: It introduces brand-new hardware that the standard model doesn't have at all, such as a dedicated telephoto camera lens or a built-in stylus.
  • Replacing Defaults: It replaces standard default features with customized upgrades such as swapping out the standard 60Hz screen for a high-refresh 120Hz display.

In object-oriented programming, this exact relationship exists between a base class and a derived class.

By establishing a clear parent-child relationship between classes, your derived class gets all the foundational attributes and behaviors of the base class for free. You can then focus your effort purely on adding specialized features or replacing default behaviors to fit a more specific need.

Let's look at how this real-world smartphone design strategy maps directly to technical software concepts:

Real-World Smartphone Analogy OOP Concept Description
Base Smartphone Model Base Class (Parent) Defines the foundational attributes and core capabilities shared by all models.
"Pro" Smartphone Variant Derived Class (Child) Inherits all standard features while introducing unique, specialized capabilities.
Adding a Telephoto Lens Specialized Additions Brand-new capabilities added to the derived class that do not exist in the base class.
Upgrading to a 120Hz Display Replacing Defaults Overriding an inherited behavior to substitute a custom implementation in the derived class.

By organizing your software this way, you ensure that common features stay centralized in one place, while specialized variations remain clean, modular, and easy to maintain.

Building Hierarchy with Parent Classes and super()

Writing the same initialization code across multiple related classes gets repetitive quickly, leading to messy and hard-to-maintain code. By defining a parent class and having child classes inherit from it, you can write your core setup logic once and reuse it across your entire application.

To establish this relationship in Python, you pass the name of the parent class inside parentheses right after the child class name. When initializing the child class, you use the built-in super() function to delegate the setup of shared attributes back to the parent class.

A common beginner mistake is forgetting to pass the required arguments into super().__init__(). Remember that super().__init__() is executing the parent class's constructor, so you must supply all arguments that the parent expects!

Here is how single inheritance and super() work in practice. Run this code in your editor to see how the child class seamlessly inherits features from its parent:

Console

        
Output:
Brand: TechCorp
Model: V100
Storage: 128 GB
Powered On: False

The Code Breakdown

Let me walk you through how the mechanics of this code work step-by-step:

  • Child Class Declaration (class SmartPhone(Phone):): Putting Phone inside the parentheses tells Python that SmartPhone is a child class that inherits all attributes and methods from the Phone parent class.
  • Parent Initialization (super().__init__(brand, model)): The super() function returns a temporary object of the parent class. By calling super().__init__(brand, model), you delegate setting self.brand, self.model, and self.is_powered_on to the parent class, avoiding duplicate code.
  • Child-Specific Attributes (self.storage_gb = storage_gb): After super() finishes setting up the base attributes, you can assign new attributes that belong exclusively to the SmartPhone class.
  • Attribute Inheritance: Notice how my_phone.is_powered_on works seamlessly. Even though is_powered_on was never explicitly defined inside SmartPhone.__init__(), the child object automatically inherits all base attributes from its parent.

Customizing Behavior with Method Overriding

Inheriting methods from a parent class gives you instant access to shared functionality, but generic parent logic doesn't always fit every specialized child class. Method overriding allows a child class to re-define a parent method with the exact same name, customizing or replacing its behavior on the fly.

The "Show, Don't Tell" Loop

Let's look at how you can either completely replace or extend a parent class's behavior using method overriding, and how Python handles these calls dynamically.

Console

        

The Output

Copy the code above into your environment and run it. You will see the following output:

User 'alex99' has permissions: ['read_content']
User 'boss_lady' has permissions: ['read_content', 'write_content', 'delete_content', 'manage_users']
User 'dev_sam' has permissions: ['read_content', 'write_content']

The Breakdown

Let's break down how Python executes this code step-by-step:

  1. Defining the Base Behavior: The User class defines a standard get_permissions() method returning a simple list: ["read_content"].
  2. Replacing Method Behavior: Inside AdminUser, we re-define def get_permissions(self):. Because this method has the exact same name as the parent method, Python ignores the User implementation entirely when called on an AdminUser instance.
  3. Extending Method Behavior: Inside PowerUser, we want to keep the base permissions and add a new permission. We call super().get_permissions() to run the parent logic first, store its list return value, and then use .append("write_content") to add to it.
  4. Polymorphic Function Calls: The display_user_access() function takes any user object and calls user.get_permissions(). Polymorphism allows Python to execute the correct method implementation based on the specific instance passed in, even though the function calls the exact same method name.

A common beginner mistake when overriding methods is altering the function signature by changing parameter names or adding required arguments in the child class. Unless you have a specific reason to modify parameters, keep your overridden method's signature identical to the parent class's signature to ensure polymorphic calls work smoothly.

Replacing vs. Extending Parent Methods

When designing child classes, you have two approaches for customizing method behavior:

Approach How to Implement When to Use
Completely Replacing Define the method in the child class without calling super(). When the parent class logic is entirely irrelevant to the child class.
Extending Call super().method_name() inside the child method and build on top of its result. When you want to preserve common base setup while adding specialized child logic.

By mastering both techniques, you can build flexible class hierarchies where each object responds intelligently through a single shared interface.

The Upward Search Cascade

Ever wonder what Python actually does under the hood when you access a variable or call a method on a child object? It doesn't just guess where things are; it follows a strict, predictable path moving upwards called the Upward Search Cascade.

Think of your object hierarchy like a multi-story building:

  • Ground Floor (The Instance): Python checks if the attribute lives directly on your specific object instance.
  • First Floor (The Child Class): If it isn't on the instance, Python moves up to check the child class definition.
  • Second Floor (The Parent Class): If it still hasn't found it, Python delegates the search up to the parent class.

If Python finds what it's looking for on any floor, it stops searching immediately and uses that version. If it reaches the top floor without finding the attribute, it raises an AttributeError.

Seeing the Search Cascade in Action

Let's write a script to watch this delegation path work in real time. Run this code in your environment to see how Python resolves attributes across different levels:

Console

        

Output

Nickname: Sparky
Battery: 75 kWh
Wheels: 4
Engine: Vroom! Parent engine started.
Description: I am an eco-friendly electric car.

The Line-by-Line Breakdown

Let's trace how Python resolves each line during execution:

  1. my_car.nickname: Python checks my_car (the instance) first. It finds nickname right away on the ground floor and returns "Sparky".
  2. my_car.battery_capacity: Python checks the instance first (not there), then moves up to ElectricCar (the child class). It finds battery_capacity on the first floor and returns "75 kWh".
  3. my_car.wheels: Python checks the instance (not there), checks ElectricCar (not there), and moves up to Vehicle (the parent class). It finds wheels on the second floor and returns 4.
  4. my_car.start_engine(): Python delegates this method call up to Vehicle, finding the method on the parent class and executing it.
  5. my_car.describe(): Python checks the instance (not there), then checks ElectricCar. It finds describe() defined directly on ElectricCar and stops searching immediately, completely ignoring the parent class version in Vehicle.

Here is a quick reference table showing how delegation resolves each lookup:

Attribute / Method Where Python Finds It Action Taken
nickname Instance Used immediately from the individual object
battery_capacity Child Class Found in ElectricCar definition
wheels Parent Class Delegated up to Vehicle definition
start_engine() Parent Class Delegated up to Vehicle definition
describe() Child Class Found in ElectricCar (Search stops; Parent ignored)

Understanding this cascade means you can predict exactly which method or variable your code will execute, keeping your inheritance hierarchies clean and bug-free!

Inheritance and Polymorphism in Action

You've just unlocked three of the core building blocks of Object-Oriented Programming in Python! By mastering parent and child classes, super(), and method overriding, you now know how to write clean, reusable code without repeating yourself.

Let's do a quick recap of how these pieces fit together to help you structure your programs effectively.

Summary of Core Concepts

Concept What It Does Example Syntax
Parent/Child Syntax Connects a specialized child class to a general parent class. class Dog(Animal):
super() Function Calls the parent class's methods to avoid duplicating initialization code. super().__init__(name)
Method Overriding Replaces or extends a parent class method inside a child class. def speak(self):

Key Takeaways

  • Parent/Child Class Syntax: You create a child class by passing the parent class name in parentheses when defining the class. The child automatically inherits all attributes and methods from the parent.
  • Initialization with super(): Inside the child class __init__() method, calling super().__init__() delegates attribute setup to the parent class, keeping your code DRY (Don't Repeat Yourself).
  • Method Overriding: If a child class needs unique behavior, you simply define a method with the exact same name as the one in the parent class. Python will execute the child's customized version instead of the parent's generic version.

Quick Refresher Example

Here is how all three concepts look when working side-by-side in code:

# 1. Parent Class Definition
class Vehicle:
    def __init__(self, brand):
        self.brand = brand

    def start(self):
        print("The vehicle powers on.")

# 2. Child Class using super() and Method Overriding
class Car(Vehicle):
    def __init__(self, brand, model):
        # Delegate brand initialization to Vehicle parent class
        super().__init__(brand)
        self.model = model

    # Override the parent start() method with customized behavior
    def start(self):
        print(f"The {self.brand} {self.model} purrs to life!")

Whenever you design related classes, ask yourself if a common base behavior exists. If it does, extract that common logic into a parent class, leverage super() to handle base setup, and override methods only where unique behavior is required. You are now ready to build clean, maintainable object hierarchies in your own Python projects!