Python Full Stack Web Developer Course

Week 3: Object-Oriented Programming Advanced Concepts

Inheritance and Method Overriding

Understanding Inheritance in Python

Inheritance is one of the four fundamental pillars of object-oriented programming (along with encapsulation, abstraction, and polymorphism). It allows us to create new classes that are built upon existing classes, inheriting their attributes and methods while also allowing for customization and extension.

Think of inheritance like genetic inheritance in biology: just as children inherit traits from their parents but develop their own unique characteristics, a child class (subclass) inherits features from a parent class (superclass) while adding or modifying its own functionality.

Today's File Structure

For today's lesson, we'll create a new Python module in our project. Ensure you have the following directory structure:

project_root/
├── inheritance/
│   ├── __init__.py  (empty file to make the folder a package)
│   ├── basic_inheritance.py
│   ├── method_overriding.py
│   ├── multiple_inheritance.py
│   └── real_world_examples.py

All code examples will be saved in these files, allowing you to organize and revisit these concepts easily.

Basic Inheritance

Let's start with the fundamental syntax of inheritance in Python. Create a file named basic_inheritance.py with the following code:

# File: inheritance/basic_inheritance.py

class Vehicle:
    """Base class for all vehicles"""
    
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
        self.is_running = False
    
    def start_engine(self):
        self.is_running = True
        return f"The {self.year} {self.make} {self.model}'s engine is now running."
    
    def stop_engine(self):
        self.is_running = False
        return f"The {self.year} {self.make} {self.model}'s engine is now off."
    
    def describe(self):
        return f"This is a {self.year} {self.make} {self.model}."


class Car(Vehicle):
    """Car is a type of Vehicle"""
    
    def __init__(self, make, model, year, num_doors):
        # Call parent class's __init__ method
        super().__init__(make, model, year)
        self.num_doors = num_doors
        self.type = "car"
    
    def honk(self):
        return "Beep! Beep!"


# Create and use the classes
my_vehicle = Vehicle("Generic", "Vehicle", 2023)
print(my_vehicle.describe())
print(my_vehicle.start_engine())

my_car = Car("Toyota", "Corolla", 2022, 4)
print(my_car.describe())  # Inherited method
print(my_car.start_engine())  # Inherited method
print(my_car.honk())  # Car-specific method
print(f"This {my_car.type} has {my_car.num_doors} doors.")  # Car-specific attribute

Code Breakdown:

Real-world analogy: Think of a blueprint for a generic building (the parent class). This blueprint contains basic features like walls, a roof, doors, and windows. Now, you want to create a specific type of building, like a house (the child class). The house will inherit all the basic features from the generic building blueprint but will also add specific features like bedrooms, a kitchen, and a living room.

When we run this code, we see that my_car can use both methods defined in Vehicle (like start_engine) and methods defined in Car (like honk).

Method Overriding

Method overriding occurs when a subclass provides a specific implementation for a method that is already defined in its parent class. This allows the subclass to change the behavior of inherited methods to better suit its specific needs.

Let's create a file named method_overriding.py with the following code:

# File: inheritance/method_overriding.py

class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year
    
    def start_engine(self):
        return "The vehicle's engine is starting..."
    
    def describe(self):
        return f"This is a {self.year} {self.make} {self.model}."


class ElectricCar(Vehicle):
    def __init__(self, make, model, year, battery_capacity):
        super().__init__(make, model, year)
        self.battery_capacity = battery_capacity
    
    # Override the start_engine method
    def start_engine(self):
        return f"The {self.make} {self.model} silently comes to life with its electric motor."
    
    # Override the describe method
    def describe(self):
        basic_description = super().describe()  # Call parent's method
        return f"{basic_description} It has a {self.battery_capacity} kWh battery."


class Motorcycle(Vehicle):
    def __init__(self, make, model, year, has_sidecar):
        super().__init__(make, model, year)
        self.has_sidecar = has_sidecar
    
    # Override the start_engine method
    def start_engine(self):
        return f"The {self.make} {self.model}'s engine roars to life with a loud rumble!"


# Test the classes
regular_vehicle = Vehicle("Generic", "Vehicle", 2023)
tesla = ElectricCar("Tesla", "Model 3", 2023, 75)
harley = Motorcycle("Harley-Davidson", "Street Glide", 2022, False)

# Print descriptions
print(regular_vehicle.describe())
print(tesla.describe())
print(harley.describe())

# Start engines
print(regular_vehicle.start_engine())
print(tesla.start_engine())
print(harley.start_engine())

Code Breakdown:

Real-world analogy: Imagine a company-wide communication protocol (base method) where everyone sends reports via email. However, the design team (subclass) might override this protocol to better suit their needs, sending their reports with visual attachments and links to design files, while still adhering to the basic reporting structure.

Partial Overriding vs. Complete Overriding

In the example above, we saw two different approaches to method overriding:

  1. Complete Overriding: In the Motorcycle class, we completely replaced the parent's start_engine method with our own implementation.
  2. Partial Overriding: In the ElectricCar class's describe method, we called the parent's method using super().describe() and then extended it with additional functionality.

Both approaches are valid, and the choice depends on whether you want to completely replace the parent's behavior or build upon it.

Best Practices for Inheritance and Method Overriding

1. Follow the "is-a" Relationship

Use inheritance only when there's a true "is-a" relationship between the subclass and the superclass. For example, a car "is a" vehicle, so inheritance makes sense.

Wrong use: Making a Driver class inherit from Car because a driver uses a car. This is a "has-a" or "uses-a" relationship, not an "is-a" relationship.

2. Favor Composition Over Inheritance When Appropriate

For "has-a" relationships, use composition (where one class contains an instance of another class) instead of inheritance.

# Instead of inheritance for a "has-a" relationship:
class Engine:
    def start(self):
        return "Engine started"

class Car:
    def __init__(self):
        self.engine = Engine()  # Composition
    
    def start_car(self):
        return self.engine.start()

3. Use Consistent Method Signatures

When overriding methods, maintain the same parameters unless you're using method overloading (which is limited in Python).

4. Don't Forget to Call super() When Needed

Especially in __init__ methods, call the parent class's method to ensure proper initialization.

5. Keep the Liskov Substitution Principle in Mind

This principle states that objects of a superclass should be replaceable with objects of its subclasses without affecting the correctness of the program.

Multiple Inheritance

Python, unlike some other languages like Java, supports multiple inheritance, allowing a class to inherit from more than one parent class. This can be powerful but also introduces complexity.

Let's create a file named multiple_inheritance.py with the following code:

# File: inheritance/multiple_inheritance.py

class Engine:
    def start(self):
        return "Engine started"
    
    def stop(self):
        return "Engine stopped"


class ElectricPower:
    def charge(self):
        return "Charging battery"
    
    def use_power(self):
        return "Using electric power"


class HybridCar(Engine, ElectricPower):
    def __init__(self, make, model):
        self.make = make
        self.model = model
    
    def drive(self):
        engine_status = self.start()
        power_status = self.use_power()
        return f"{engine_status}. {power_status}. The {self.make} {self.model} is now driving."


# Create and use a hybrid car
prius = HybridCar("Toyota", "Prius")
print(prius.drive())
print(prius.charge())
print(prius.stop())

Code Breakdown:

Method Resolution Order (MRO): When a class inherits from multiple parents, Python follows a specific order to look for methods, known as the Method Resolution Order. You can view a class's MRO using the __mro__ attribute:

print(HybridCar.__mro__)
# Output: (, , , )

This shows that Python will first look for methods in HybridCar, then in Engine, then in ElectricPower, and finally in object (the base class for all Python classes).

Diamond Problem

Multiple inheritance can lead to the "diamond problem" when a class inherits from two classes that both inherit from a common base class. Python resolves this using the C3 linearization algorithm, which is reflected in the MRO.

class A:
    def method(self):
        return "Method from A"

class B(A):
    def method(self):
        return "Method from B"

class C(A):
    def method(self):
        return "Method from C"

class D(B, C):
    pass

d = D()
print(d.method())  # Output: "Method from B"
print(D.__mro__)  # Shows the method resolution order

In this example, D inherits from both B and C, which both inherit from A. When we call d.method(), Python follows the MRO to determine which method to use.

Real-World Examples of Inheritance and Method Overriding in Python Web Development

Let's create a file called real_world_examples.py that demonstrates how inheritance and method overriding are used in actual web development contexts:

# File: inheritance/real_world_examples.py

# Example 1: Django Class-Based Views
class ExampleView:
    """Simulating Django's base View class"""
    
    def get(self, request):
        return "Base get method - typically returns a HttpResponse"
    
    def post(self, request):
        return "Base post method - typically returns a HttpResponse"
    
    def dispatch(self, request, method):
        """Dispatch the request to the appropriate method"""
        if method.lower() == 'get':
            return self.get(request)
        elif method.lower() == 'post':
            return self.post(request)
        return "Method not allowed"


class ProductListView(ExampleView):
    """A view for listing products, overriding the get method"""
    
    def get(self, request):
        # In a real Django app, this would query the database for products
        return "Rendering a list of products"


class ProductCreateView(ExampleView):
    """A view for creating products, overriding both get and post methods"""
    
    def get(self, request):
        return "Rendering a form to create a new product"
    
    def post(self, request):
        return "Processing the form submission and creating a new product"


# Example 2: Flask Extension of Base Classes
class BaseModel:
    """Simulate a base model class, similar to SQLAlchemy's declarative_base"""
    
    def __init__(self, id=None):
        self.id = id
    
    def save(self):
        return f"Saving {self.__class__.__name__} to the database"
    
    def delete(self):
        return f"Deleting {self.__class__.__name__} from the database"
    
    def validate(self):
        return True


class User(BaseModel):
    """User model extending BaseModel"""
    
    def __init__(self, id=None, username=None, email=None):
        super().__init__(id)
        self.username = username
        self.email = email
    
    def validate(self):
        """Override validation to include username and email checks"""
        if not self.username or len(self.username) < 3:
            return False
        if not self.email or '@' not in self.email:
            return False
        return True


class Product(BaseModel):
    """Product model extending BaseModel"""
    
    def __init__(self, id=None, name=None, price=None):
        super().__init__(id)
        self.name = name
        self.price = price
    
    def validate(self):
        """Override validation for product-specific rules"""
        if not self.name:
            return False
        if self.price is None or self.price < 0:
            return False
        return True


# Example 3: Form Validation in Web Applications
class BaseForm:
    """Base form class with validation capabilities"""
    
    def __init__(self, data=None):
        self.data = data or {}
        self.errors = {}
    
    def validate(self):
        """Base validation method"""
        return True
    
    def clean_data(self):
        """Remove unwanted characters from data"""
        for key, value in self.data.items():
            if isinstance(value, str):
                self.data[key] = value.strip()


class ContactForm(BaseForm):
    """Contact form with specific validation rules"""
    
    def validate(self):
        """Override to implement contact form validation"""
        self.clean_data()
        
        # Validate name
        if 'name' not in self.data or not self.data['name']:
            self.errors['name'] = "Name is required"
        
        # Validate email
        if 'email' not in self.data or '@' not in self.data.get('email', ''):
            self.errors['email'] = "Valid email is required"
        
        # Validate message
        if 'message' not in self.data or len(self.data.get('message', '')) < 10:
            self.errors['message'] = "Message must be at least 10 characters"
        
        return len(self.errors) == 0


# Test the examples
# Django-like views
product_list = ProductListView()
print(product_list.dispatch("request object", "GET"))

product_create = ProductCreateView()
print(product_create.dispatch("request object", "POST"))

# Database models
user = User(username="joh", email="invalid-email")
print(f"User valid: {user.validate()}")

product = Product(name="Laptop", price=999.99)
print(f"Product valid: {product.validate()}")
print(product.save())

# Form validation
contact_form = ContactForm({
    'name': 'John Doe',
    'email': 'john@example.com',
    'message': 'This is a test message that is long enough.'
})
print(f"Form valid: {contact_form.validate()}")

Code Breakdown:

These examples show how inheritance and method overriding are fundamental concepts in web development frameworks and libraries. Understanding these concepts helps you leverage these frameworks more effectively and extend them to meet your specific needs.

Practical Exercise: Building a Web API Response Framework

Let's apply what we've learned to build a simple framework for standardizing API responses in a web application. This is a common use case for inheritance and method overriding in real-world web development.

# Exercise: Creating a flexible API response system using inheritance

class ApiResponse:
    """Base class for all API responses"""
    
    def __init__(self, data=None, message=None):
        self.data = data
        self.message = message
        self.status_code = 200  # Default status code
    
    def to_dict(self):
        """Convert the response to a dictionary that can be serialized to JSON"""
        return {
            'status': 'success' if self.is_success() else 'error',
            'message': self.message,
            'data': self.data
        }
    
    def is_success(self):
        """Determine if this response represents a successful operation"""
        return 200 <= self.status_code < 300


class SuccessResponse(ApiResponse):
    """Represents a successful API operation"""
    
    def __init__(self, data=None, message="Operation successful"):
        super().__init__(data, message)
        self.status_code = 200


class CreatedResponse(SuccessResponse):
    """Represents a successful resource creation"""
    
    def __init__(self, data=None, message="Resource created successfully"):
        super().__init__(data, message)
        self.status_code = 201
    
    def to_dict(self):
        """Override to include the resource ID in a standard location"""
        response_dict = super().to_dict()
        if self.data and 'id' in self.data:
            response_dict['resource_id'] = self.data['id']
        return response_dict


class ErrorResponse(ApiResponse):
    """Base class for error responses"""
    
    def __init__(self, message="An error occurred", data=None, error_code=None):
        super().__init__(data, message)
        self.status_code = 400  # Default error status
        self.error_code = error_code
    
    def to_dict(self):
        """Override to include error details"""
        response_dict = super().to_dict()
        if self.error_code:
            response_dict['error_code'] = self.error_code
        return response_dict


class NotFoundResponse(ErrorResponse):
    """Resource not found response"""
    
    def __init__(self, resource_type, resource_id, message=None):
        if not message:
            message = f"{resource_type} with ID {resource_id} was not found"
        super().__init__(message=message, error_code="NOT_FOUND")
        self.status_code = 404


class ValidationErrorResponse(ErrorResponse):
    """Validation error response with field-specific errors"""
    
    def __init__(self, validation_errors, message="Validation failed"):
        super().__init__(message=message, data=validation_errors, error_code="VALIDATION_ERROR")
        self.status_code = 422  # Unprocessable Entity
    
    def to_dict(self):
        """Override to format validation errors in a standard way"""
        response_dict = super().to_dict()
        # Rename 'data' to 'errors' for clarity in validation error responses
        response_dict['errors'] = response_dict.pop('data')
        return response_dict


# Example usage in a web application context

def get_user(user_id):
    """Simulate a user lookup API endpoint"""
    # In a real app, this would query a database
    if user_id == 123:
        return SuccessResponse(data={
            'id': 123,
            'username': 'johndoe',
            'email': 'john@example.com'
        })
    else:
        return NotFoundResponse('User', user_id)


def create_user(user_data):
    """Simulate a user creation API endpoint"""
    # In a real app, this would validate and save to a database
    errors = {}
    
    if not user_data.get('username'):
        errors['username'] = "Username is required"
    
    if not user_data.get('email') or '@' not in user_data.get('email', ''):
        errors['email'] = "Valid email is required"
    
    if errors:
        return ValidationErrorResponse(errors)
    
    # Simulate successful creation with an ID
    return CreatedResponse(data={
        'id': 456,
        'username': user_data.get('username'),
        'email': user_data.get('email')
    })


# Test the API response framework
print("\nGetting existing user:")
response = get_user(123)
print(response.to_dict())

print("\nGetting non-existent user:")
response = get_user(999)
print(response.to_dict())

print("\nCreating user with valid data:")
response = create_user({
    'username': 'janedoe',
    'email': 'jane@example.com'
})
print(response.to_dict())

print("\nCreating user with invalid data:")
response = create_user({
    'username': '',
    'email': 'invalid-email'
})
print(response.to_dict())

Exercise Explanation:

In this exercise, we've created a hierarchy of API response classes:

  1. ApiResponse: The base class with common functionality for all responses
  2. SuccessResponse and ErrorResponse: Two main categories of responses
  3. More specific response types like CreatedResponse, NotFoundResponse, and ValidationErrorResponse that inherit from these categories

Each subclass overrides the to_dict method when necessary to customize the response structure while maintaining a consistent format. This demonstrates how inheritance and method overriding can create a flexible, extensible system that ensures consistency across an application.

In a real-world web application using Flask or Django, these response classes would be integrated with the framework's response mechanisms. For example, in Flask:

from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/users/')
def get_user_api(user_id):
    response = get_user(user_id)
    return jsonify(response.to_dict()), response.status_code

@app.route('/api/users', methods=['POST'])
def create_user_api():
    user_data = request.json
    response = create_user(user_data)
    return jsonify(response.to_dict()), response.status_code

Key Takeaways

Assignment: Extend Your Previous Class System with Inheritance and Advanced Features

For today's assignment, you'll extend the class-based system you created in yesterday's lesson by implementing inheritance and method overriding.

Requirements:

  1. Create a base class that defines common attributes and methods for your system.
  2. Create at least two subclasses that inherit from this base class.
  3. Override at least one method in each subclass to provide specialized behavior.
  4. Use super() in at least one overridden method to call the parent's implementation.
  5. Implement at least one example of multiple inheritance to combine functionality from different classes.
  6. Include docstrings and comments to explain your code's structure and design decisions.
  7. Create a demonstration script that shows how your classes interact and how overridden methods change behavior in different contexts.

Bonus Challenges:

  1. Implement a "mixin" class that provides additional functionality that can be added to multiple different classes.
  2. Create a class hierarchy that models a real-world web development scenario, such as different types of database models or HTTP handlers.
  3. Experiment with the __mro__ attribute and explain how it affects method resolution in your multiple inheritance example.

Submit your work as a Python file or module with clear structure and organization. Be prepared to explain your design choices and how inheritance enhances your system's flexibility and maintainability.

Further Reading and Resources