Inheritance and Type Safety

Enhance your Python OOP skills with key decorators: @abstractmethod for subclass method enforcement, @final to prevent method overrides, and @override for clear refactoring. Ideal for creating robust, maintainable code. Subscribe for the latest in Python programming expertise.

Inheritance and Type Safety
The DNA of OOP in Python

Overview

Inheritance, a cornerstone of object-oriented programming (OOP), enables code reuse by allowing us to declare a parent class when writing new classes. The child class will be a subtype with all the capabilities of the parent and any new methods we define on it.

However, this introduces potential pitfalls, like how to ensure a method that has to be specialized is specialized in all the subclasses. Or the opposite, how to ensure a subclass never overrides a function. Or how to ensure that the overriding relationships are maintained when code evolves.

In this article, we’ll discuss three essential decorators that will help us write more robust class hierarchies and facilitate refactoring efforts, pointing out code that could break if we remove or modify methods in a base class.


Understanding Abstract Methods

When designing a base class, there are often methods that provide a blueprint for functionality but are meant to be implemented outside the base class itself. Instead, they serve as placeholders, outlining the requirements for subclasses.

In OOP, these are called abstract methods; they are a powerful tool for defining a contract that subclasses must adhere to. In Python, we can use the @abstractmethod decorator to mark methods as abstract and ensure any subclass meant to be used to implement objects is forced to provide an implementation for them.

Let's do a quick review of the pitfalls we had to deal with before the introduction of abstract methods with PEP-3119 in Python 3.0:

Python

# Define a base class with a method (not using @abstractmethod)
class Shape:
    def area(self):
        pass

# Create a subclass without implementing the method
class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

# Instantiate the subclass
circle = Circle(5)

# Calling the method from the subclass
result = circle.area()

# No error is raised, and result is set to None
print(result)  # Output: None
        

In this example, we define a base class Shape with a method area() that is not marked as an abstract method. We then create a subclass Circle that inherits from Shape but does not provide an implementation for the area() method.

When we instantiate the Circle subclass and call the area() method, Python does not raise any errors. Instead, it treats the area() method as a regular method with a default implementation that returns None. This can be a source of hard-to-track bugs in an application since, at runtime, the error might occur in code that is not immediate to the actual cause, or even worse, an error might never happen, and we could be working with wrong results without knowing it.

To avoid this issue, we can use the @abstractmethod decorator:

Python ≥ 3.0

from abc import abstractmethod

class Shape:
    @abstractmethod          
    def area(self):
        pass
        

With this change, the issue will be reported as an error by mypy:

mypy reporting that an abstract class can't be instantiated.
mypy reporting that an abstract class can't be instantiated.

This is an improvement, but the safety improvement is only available if we use a type checker. To get this check in Python, we need to extend the Abstract Base Class:

Python ≥ 3.0

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod          
    def area(self):
        pass
        
python reporting that an abstract class can't be instantiated.
python reporting that an abstract class can't be instantiated.

To complete the implementation, we have to add an implementation of the area() method:

Python ≥ 3.0

from abc import ABC, abstractmethod
from math import pi

class Shape(ABC):
    @abstractmethod
    def area(self):
        pass

# Create a subclass without implementing the method
class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return pi * self.radius**2

# Instantiate the subclass
circle = Circle(5)

# Calling the method from the subclass
result = circle.area()

print(result)
        

In summary, abstract methods and the @abstractmethod decorator provide a practical means to define essential method requirements in base classes. By mandating their implementation in subclasses, Python ensures that our code follows a structured and predictable pattern.


Enhancing Code Stability with @final

Imagine you're developing a financial software application for a banking institution. One of the critical components of this system is a class called LoanCalculator, which calculates the monthly loan payments for various types of loans, including mortgages, personal loans, and car loans.

Within the LoanCalculator class, there's a method called calculate_monthly_payment(). This method is carefully crafted to handle complex financial calculations, considering interest rates, loan terms, and other variables. The accuracy and reliability of this method are of utmost importance, as even a small error could have significant financial consequences for the bank and its customers.

Now, let's say you have a team of developers, each responsible for a specific type of loan calculation (e.g., mortgages, personal loans). These developers must create specialized subclasses of LoanCalculator to accommodate the unique rules and parameters associated with each loan type. However, you want to ensure that the core calculate_monthly_payment() method remains untouched and cannot be overridden, as any deviation from the established calculation logic could introduce critical bugs:

Python ≥ 3.0

# Define the base class LoanCalculator without @final
class LoanCalculator:
    def __init__(self):
        self.loan_type = "Generic Loan"
    
    def set_loan_type(self, loan_type):
        self.loan_type = loan_type
    
    def get_loan_type(self):
        return self.loan_type

    def calculate_monthly_payment(self, principal, interest_rate, loan_term):
        # Complex calculation logic here
        monthly_payment = (principal * interest_rate()) / (1 - (1 + interest_rate()) ** (-loan_term))
        return monthly_payment

    def get_interest_rate(self):
        return 0.05  # Default interest rate

# Subclass PersonalLoanCalculator legitimately overrides interest_rate
class PersonalLoanCalculator(LoanCalculator):
    def __init__(self):
        super().__init__()
        self.loan_type = "Personal Loan"

    def set_loan_type(self, loan_type):
        # Legitimate override to customize behavior
        if loan_type == "Personal Loan":
            self.loan_type = loan_type
        else:
            print("Invalid loan type for PersonalLoanCalculator")
    
    def get_interest_rate(self):
        # Custom interest rate for personal loans
        return super.get_interest_rate() + 0.03

    def calculate_monthly_payment(self, principal, interest_rate, loan_term):
        # Bug: Incorrect calculation logic for personal loans
        # This could lead to significant financial errors
        # Incorrect formula
        monthly_payment = principal / loan_term  
        return monthly_payment
        

Here's where the @final decorator comes into play; it was added to Python 3.8 with the PEP 591. By marking the calculate_monthly_payment() method as final, we signal to our team that this method is off-limits for modification or override.

Subclasses can extend the functionality by adding new methods or attributes, but they are prohibited from altering the core calculation method. This safeguards the accuracy of loan calculations across different loan types, preventing the introduction of subtle and potentially costly errors:

Python ≥ 3.0

from typing import final

class LoanCalculator:
    def __init__(self):
        self.loan_type = "Generic Loan"
    
    def set_loan_type(self, loan_type):
        self.loan_type = loan_type
    
    def get_loan_type(self):
        return self.loan_type

    # Mark the method as final to prevent overriding
    @final 
    def calculate_monthly_payment(self, principal: float, interest_rate: float, loan_term: float) -> float:
        # Complex calculation logic here
        monthly_payment = (principal * interest_rate) / (1 - (1 + interest_rate) ** (-loan_term))
        return monthly_payment

# Subclass PersonalLoanCalculator mistakenly overrides the core method
class PersonalLoanCalculator(LoanCalculator):
    def __init__(self):
        super().__init__()
        self.loan_type = "Personal Loan"

    def set_loan_type(self, loan_type):
        # Legitimate override to customize behavior
        if loan_type == "Personal Loan":
            self.loan_type = loan_type
        else:
            print("Invalid loan type for PersonalLoanCalculator")
    
    def calculate_monthly_payment(self, principal, interest_rate, loan_term) -> float:
        # Bug: Incorrect calculation logic for personal loans
        # This could lead to significant financial errors
        monthly_payment = principal / loan_term  # Incorrect formula
        return monthly_payment
        

In this scenario, the @final decorator maintains method integrity, reducing the risk of bugs arising from unintended method overrides. It shows how @final can be a crucial tool in code design, especially in scenarios where precision and reliability are paramount.

An important caveat is that this decorator is only for Type Checkers; we will only get the benefit if we use one. Also, some type checkers, like mypy, might skip the method if it does not use Type Annotations. Hence, we have to at least add a return type annotation to the methods we are marking as final:

Python ≥ 3.0

from typing import final

class LoanCalculator:
    def __init__(self):
        self.loan_type: str = "Generic Loan"
    
    def set_loan_type(self, loan_type):
        self.loan_type = loan_type
    
    def get_loan_type(self):
        return self.loan_type

    # Mark the method as final to prevent overriding
    @final 
    def calculate_monthly_payment(self, principal, interest_rate, loan_term) -> float:
        # Complex calculation logic here
        monthly_payment = (principal * interest_rate) / (1 - (1 + interest_rate) ** (-loan_term))
        return monthly_payment

# Subclass PersonalLoanCalculator mistakenly overrides the core method
class PersonalLoanCalculator(LoanCalculator):
    def __init__(self):
        super().__init__()
        self.loan_type = "Personal Loan"

    def set_loan_type(self, loan_type):
        # Legitimate override to customize behavior
        if loan_type == "Personal Loan":
            self.loan_type = loan_type
        else:
            print("Invalid loan type for PersonalLoanCalculator")
    
    def calculate_monthly_payment(self, principal, interest_rate, loan_term) -> float:
        # Bug: Incorrect calculation logic for personal loans
        # This could lead to significant financial errors
        monthly_payment = principal / loan_term  # Incorrect formula
        return monthly_payment
        

This will result in a type-checking error:

Screenshot of mypy reporting an error when overriding methods decorated with @final
mypy reports an error when overriding methods decorated with @final
⚠️
As of Python 3.12.0 and mypy, It is easy to break the semantics of @final, by not using any type annotations in the child class. We consider that an implementation Bug. We recommend using the annotation anyway since we can get the full benefit once this Bug is corrected.

We can also mark a whole class as @final, preventing the creation of any subtypes. This is important if we want to ensure a class is never changed and that any object we recieve as parameters are that exact type since no sub type can be created of a final class.


Renaming With Confidence: The @Override Decorator

The last decorator we are going to review in this article is @override, added in Python 3.12 with the PEP-698.

In some statically typed languages, like Scala, Kotlin or C#, override is a a keyword and it is required wehn we would like to redifine a method originally declared in a superclass. The class of bugs this prevents might look trivial when writing new software, but it becomes a lot more useful as the code base expands and we need to refactor base classes, maybe renaming some methods.

Let's understand its utility with a simple example:

Python ≥ 3.0

from abc import abstractmethod
from typing import override

class Shape:
    @abstractmethod
    def shape_area(self) -> float:
        pass

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    # Indicate that we intend to override the 'shape_area' method
    @override  
    def shape_area(self) -> float:
        return 3.14 * self.radius * self.radius

class Rectangle(Shape):
    def __init__(self, length, width):
        self.length = length
        self.width = width

    @override  
    def shape_area(self) -> float:
        return self.length * self.width
        

Using the @override decorator enhances code clarity and helps prevent bugs by explicitly stating the developer's intent to override a method. It's a valuable tool for maintaining clean and maintainable OOP code in Python.

Now imaginge we do not like the name of the method, and we will like to rename shape_area to simply area. Without the decorator we could change only the base class and not the subclasses, letting the refactoring incomplete and leading to Runtime errors.

But if we use the decorator and a type checker, we will get a helpful error earlier in the development process:

Python ≥ 3.0

class Shape:
    @abstractmethod
    def area(self) -> float:
        pass
        
mypy reporting an error when the method is renamed in the base class but not the subclasses.
mypy reporting an error when the method is renamed in the base class but not the subclasses

The example highlights how the @override decorator helps maintain code correctness and prevent unintended bugs when methods in base classes undergo changes.


Conclusion

In summary, this article highlighted key decorators in Python's OOP: @abstractmethod, @final, and @override. @abstractmethod ensures subclasses implement necessary methods, preventing runtime errors. @final safeguards critical methods from being overridden, essential in precision-critical applications like financial computations. @override clarifies intent in method redefinitions, aiding in error-free refactoring.

These tools are vital for robust, maintainable Python coding. They tackle inheritance challenges, promoting stability and predictability in class hierarchies. As Python evolves, embracing these features is crucial for efficient, error-resistant coding

For more insights and updates on Python programming, don't forget to subscribe to our newsletter. Stay ahead in your coding journey with our latest articles and resources!


Addendum: A Special Note for Our Readers

I decided to delay the introduction of subscriptions, you can read the full story here.

In the meantime, I decided to accept donations.

If you can afford it, please consider donating:

Every donation helps me offset the running costs of the site and an unexpected tax bill. Any amount is greatly appreciated.

Also, if you are looking to buy some Swag, please visit I invite you to visit the TuringTacoTales Store on Redbubble.

Take a look, maybe you can find something you like: