Python Classes and Inheritance

Introduction

The mountains fade into the plains, and the river flows into the wilderness.

Overview

  • Basics of Classes
  • Inheritance
  • Polymorphism
  • Encapsulation
  • Multiple Inheritance
  • Using the super() Function
  • Class Methods and Static Methods
  • <span>Property Decorators</span>
  • <span>Abstract Base Classes</span>

Basics of Classes

What is a Class?

  • A class is a template that describes the behavior and state of a type of object. An object is an instance of a class. A class contains attributes and methods.
  • For example, we can create a class called “Dog” that has attributes like name, age, and gender, and methods like bark, run, and bite.

How to Define a Class?

  • Use the <span>class</span> keyword to define a class.
class Dog:
    # Class attribute (shared by all dogs)
    species = "Canis familiaris"

    # Initialization method (constructor)
    def __init__(self, name, age):
        # Instance attributes (unique to each instance)
        self.name = name
        self.age = age

    # Instance method
    def bark(self):
        return f"{self.name} says woof!"

    def run(self, speed):
        return f"{self.name} runs at {speed} km/h."

Creating Objects (Instantiation)

  • Use the class name followed by parentheses to create an object, passing the required parameters for the initialization method (excluding <span>self</span>).
my_dog = Dog("Buddy", 3)
print(my_dog.name)  # Output: Buddy
print(my_dog.age)   # Output: 3
print(my_dog.bark()) # Output: Buddy says woof!

Inheritance

What is Inheritance?

  • Inheritance is a feature of object-oriented programming that allows us to define a class (subclass) that inherits attributes and methods from another class (superclass).
  • The subclass can override or extend the functionality of the superclass.
# Superclass (Base class)
class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def speak(self):
        raise NotImplementedError("Subclass must implement this method")

# Subclass (Derived class)
class Cat(Animal):  # Inherits from Animal
    def __init__(self, name, age, color):
        super().__init__(name, age)  # Call the superclass's initialization method
        self.color = color

    # Override superclass method
    def speak(self):
        return f"{self.name} says meow!"

    # Method unique to the subclass
    def describe(self):
        return f"{self.name} is {self.age} years old and has {self.color} fur."

Using Subclasses

my_cat = Cat("Whiskers", 2, "black")
print(my_cat.speak())  # Output: Whiskers says meow!
print(my_cat.describe()) # Output: Whiskers is 2 years old and has black fur.

Polymorphism

  • Polymorphism refers to the ability of different class objects to respond differently to the same message (method).
  • For example, we can have a function that accepts any animal object and calls its <span>speak</span> method.
def animal_sound(animal):
    return animal.speak()

# Create different animal objects
my_dog = Dog("Buddy", 3)
my_cat = Cat("Whiskers", 2, "black")

print(animal_sound(my_dog))  # Output: Buddy says woof!
print(animal_sound(my_cat))  # Output: Whiskers says meow!

Encapsulation

  • Encapsulation is the bundling of data (attributes) and methods that operate on that data, while hiding the internal implementation details. In Python, we use naming conventions to indicate the access level of attributes and methods.
  • Public: No leading underscore, e.g., <span>name</span>.
  • Protected: One leading underscore, e.g., <span>_age</span> (indicates that it should not be accessed directly from outside, but it can still be accessed).
  • Private: Two leading underscores, e.g., <span>__secret</span> (name mangled, cannot be accessed directly).
class BankAccount:
    def __init__(self, account_holder, balance):
        self.account_holder = account_holder  # Public attribute
        self._balance = balance              # Protected attribute (indicates not to access directly)
        self.__account_number = 123456       # Private attribute

    # Public method
    def get_balance(self):
        return self._balance

    def deposit(self, amount):
        self._balance += amount
        return self._balance

    def __update_database(self):  # Private method
        print("Database updated")

account = BankAccount("Alice", 1000)
print(account.account_holder)  # Can access
print(account.get_balance())   # Access protected attribute through public method
# print(account.__account_number)  # Will raise an error, as private attribute cannot be accessed directly
# account.__update_database()     # Similarly, private method cannot be accessed directly

Method Overriding

  • Method overriding allows a subclass to redefine a method from its superclass to change its behavior.
class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

    def start_engine(self):
        return "Engine started"

    def info(self):
        return f"{self.brand} {self.model}"

class Car(Vehicle):
    def __init__(self, brand, model, doors):
        super().__init__(brand, model)
        self.doors = doors

    # Override superclass method
    def start_engine(self):
        return f"Car engine of {self.brand} {self.model} started with a key"

    # Extend superclass method
    def info(self):
        return f"{super().info()} with {self.doors} doors"

class ElectricCar(Car):
    def __init__(self, brand, model, doors, battery_capacity):
        super().__init__(brand, model, doors)
        self.battery_capacity = battery_capacity

    # Completely override superclass method
    def start_engine(self):
        return f"Electric car {self.brand} {self.model} powered on silently"

# Testing
car = Car("Toyota", "Camry", 4)
electric_car = ElectricCar("Tesla", "Model S", 4, "100kWh")

print(car.start_engine())        # Output: Car engine of Toyota Camry started with a key
print(electric_car.start_engine()) # Output: Electric car Tesla Model S powered on silently
print(car.info())                # Output: Toyota Camry with 4 doors

Multiple Inheritance

  • A class can inherit from multiple parent classes.
# Example 1
class A:
    def method_a(self):
        return "Method A"

class B:
    def method_b(self):
        return "Method B"

class C(A, B):  # Multiple inheritance
    def method_c(self):
        return "Method C"

obj = C()
print(obj.method_a())  # Output: Method A
print(obj.method_b())  # Output: Method B

# Example 2
class Flyable:
    def fly(self):
        return "I can fly!"

class Swimmable:
    def swim(self):
        return "I can swim!"

class Duck(Flyable, Swimmable):    # Multiple inheritance
    def __init__(self, name):
        self.name = name

    def quack(self):
        return f"{self.name} says quack!"

donald = Duck("Donald")
print(donald.quack())  # Output: Donald says quack!
print(donald.fly())    # Output: I can fly!
print(donald.swim())   # Output: I can swim!

Method Resolution Order

  • When using multiple inheritance, Python uses the C3 algorithm to determine the method resolution order.
class A:
    def method(self):
        return "Method A"

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

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

class D(B, C):
    pass

# Check method resolution order
print(D.__mro__)
# Output: (<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

d = D()
print(d.method())  # Output: Method B (because B comes before C)

Using the super() Function

  • <span>super()</span> function is used to call methods from the superclass, especially when overriding methods, we often need to call the superclass’s implementation first.
class Rectangle:
    def __init__(self, length, width):
        self.length = length
        self.width = width

    def area(self):
        return self.length * self.width

class Square(Rectangle):
    def __init__(self, side):
        super().__init__(side, side)  # Call superclass's __init__, setting both length and width to side

# A square is a type of rectangle, so we can inherit this way
square = Square(5)
print(square.area())  # Output: 25

Why Call the Superclass’s __init__ Method?

  • When creating a subclass, we want the subclass to have both the attributes of the superclass and its own unique attributes.
class Cat(Animal):
    def __init__(self, name, age, color):
        # ❌ Repeating code already present in the superclass
        self.name = name    # Repetition!
        self.age = age      # Repetition!
        self.color = color  # New attribute

my_cat = Cat("Whiskers", 2, "black")
print(my_cat.name)  # Output: Whiskers

class Cat(Animal):
    def __init__(self, name, age, color):
        # ✅ Correct approach: let the superclass handle what it does best
        super().__init__(name, age)  # Superclass sets name and age
        self.color = color           # Subclass sets its unique attribute

What is super()?

  • <span>super()</span> is a built-in function that returns a proxy object representing the superclass. Through <span>super()</span>, we can call methods from the superclass.
  • In the subclass’s <span>__init__</span> method, we typically use <span>super().init(...)</span> to call the superclass’s initialization method, so we don’t have to repeat the initialization code already present in the superclass.

Other Uses of super()

  • <span>super()</span> can be used not only for <span>__init__</span>, but also to call other methods from the superclass:
class Animal:
    def make_sound(self):
        return "Some generic animal sound"

    def describe(self):
        return "I am an animal"

class Dog(Animal):
    def make_sound(self):
        return "Woof! Woof!"

    def describe(self):
        # First call the superclass's describe method, then add a dog-specific description
        parent_description = super().describe()
        return f"{parent_description}, specifically a dog"

class LoudDog(Dog):
    def make_sound(self):
        # First call the superclass's make_sound, then make it louder
        parent_sound = super().make_sound()
        return parent_sound.upper() + "!!!"

    def describe(self):
        parent_description = super().describe()
        return f"{parent_description} that is very LOUD"

# Testing
animal = Animal()
dog = Dog()
loud_dog = LoudDog()

print("Animal:", animal.describe())    # Output: I am an animal
print("Dog:", dog.describe())          # Output: I am an animal, specifically a dog
print("LoudDog:", loud_dog.describe()) # Output: I am an animal, specifically a dog that is very LOUD

print("\nSounds:")
print("Animal:", animal.make_sound())  # Output: Some generic animal sound
print("Dog:", dog.make_sound())        # Output: Woof! Woof!
print("LoudDog:", loud_dog.make_sound()) # Output: WOOF! WOOF!!!

Class Methods and Static Methods

  • Instance methods: The first parameter is <span>self</span>, which refers to the instance itself.
  • Class methods: Use the <span>@classmethod</span> decorator, the first parameter is <span>cls</span>, which refers to the class itself. Class methods can access class attributes but cannot access instance attributes.
  • Static methods: Use the <span>@staticmethod</span> decorator, which does not require a default parameter. It cannot access instance attributes or class attributes, acting like a regular function but logically belonging to the class.

Python Classes and Inheritance

class Student:
    school_name = "Python High School"  # Class attribute
    student_count = 0  # Class attribute to count students

    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
        Student.student_count += 1  # Modify class attribute

    # Instance method - can access instance and class attributes
    def info(self):
        return f"{self.name} is in grade {self.grade} at {self.school_name}"

    # Class method - can access class attributes, cannot access instance attributes
    @classmethod
    def get_school_info(cls):
        return f"School: {cls.school_name}, Total students: {cls.student_count}"

    # Static method - cannot access class or instance attributes
    @staticmethod
    def is_passing_grade(grade):
        return grade >= 60

    # Class method as an alternative constructor
    @classmethod
    def from_string(cls, student_str):
        name, grade = student_str.split(",")
        return cls(name, int(grade))

# Usage
student1 = Student("Alice", 85)
student2 = Student("Bob", 92)

print(student1.info())  # Output: Alice is in grade 85 at Python High School
print(Student.get_school_info())  # Output: School: Python High School, Total students: 2
print(Student.is_passing_grade(75))  # Output: True

# Using alternative constructor
student3 = Student.from_string("Charlie,88")
print(student3.info())  # Output: Charlie is in grade 88 at Python High School 

Property Decorators

  • Property decorators (<span>@property</span>) can turn methods into properties, allowing us to hide implementation details and add logic when getting or setting properties.

Why Use @property?

  • Background: Encapsulation and data validation.
  • Without <span>@property</span>, if we want to add validation logic when setting a property, we would need to do it like this:
class PersonWithoutProperty:
    def __init__(self, first_name, last_name):
        self._first_name = first_name
        self._last_name = last_name

    def get_full_name(self):
        return f"{self._first_name} {self._last_name}"

    def set_full_name(self, name):
        if not isinstance(name, str) or len(name.split()) < 2:
            raise ValueError("Full name must contain at least first and last name")
        self._first_name, self._last_name = name.split(" ", 1)

    # Using getter and setter methods
    person = PersonWithoutProperty("John", "Doe")
    print(person.get_full_name())  # Output: John Doe
    person.set_full_name("Jane Smith")
  • Problem: The syntax is not intuitive, requiring method calls instead of direct property access.

Basic Principle of @property

  • <span>@property</span> turns a method into a “read-only property”.
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        """Turn full_name method into a read-only property"""
        return f"{self.first_name} {self.last_name}"

# Usage
person = Person("John", "Doe")

# Now we can call full_name like accessing a property
print(person.full_name)  # Output: John Doe
# Note: No parentheses! It looks like a property, but it's actually a method call

# person.full_name = "Jane Smith"  # This will raise an error, as it is now a read-only property

Details of the Setter Method

  • Add setting functionality to the property.
class Person:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @property
    def full_name(self):
        """Getter method - called when accessing the property"""
        return f"{self.first_name} {self.last_name}"

    @full_name.setter
    def full_name(self, name):
        """Setter method - called when setting the property"""
        # Add validation logic
        if not isinstance(name, str):
            raise ValueError("Name must be a string")

        parts = name.split()
        if len(parts) < 2:
            raise ValueError("Full name must contain at least first and last name")

        self.first_name = parts[0]
        self.last_name = " ".join(parts[1:])  # Handle middle names
        print(f"Name updated to: {self.first_name} {self.last_name}")

# Usage
person = Person("John", "Doe")
print("Original name:", person.full_name)  # Output: Original name: John Doe

# Now we can set full_name like a property
person.full_name = "Jane Marie Smith"  # This will call the setter method
print("New name:", person.full_name)     # Output: New name: Jane Marie Smith

# Test validation logic
try:
    person.full_name = "SingleName"  # This will trigger an error
except ValueError as e:
    print(f"Error: {e}")

The Magic Behind @property: Descriptor Protocol

  • <span>@property</span> is implemented based on Python’s descriptor protocol. Here’s how to manually implement similar functionality:
# Manually implement property functionality (simplified version)
class ManualProperty:
    def __init__(self, fget=None, fset=None):
        self.fget = fget  # Getter function
        self.fset = fset  # Setter function

    def __get__(self, obj, objtype=None):
        if obj is None:
            return self
        if self.fget is None:
            raise AttributeError("unreadable attribute")
        return self.fget(obj)

    def __set__(self, obj, value):
        if self.fset is None:
            raise AttributeError("can't set attribute")
        self.fset(obj, value)

    def setter(self, fset):
        self.fset = fset
        return self

class PersonWithManualProperty:
    def __init__(self, first_name, last_name):
        self.first_name = first_name
        self.last_name = last_name

    @ManualProperty
    def full_name(self):
        return f"{self.first_name} {self.last_name}"

    @full_name.setter
    def full_name(self, name):
        self.first_name, self.last_name = name.split(" ", 1)
  • Usage example:
class BankAccount:
    def __init__(self, account_holder, initial_balance=0):
        self._account_holder = account_holder  # Protected attribute
        self.__balance = initial_balance  # Private attribute
        self._transaction_history = []

    # Property decorator - call method like accessing a property
    @property
    def balance(self):
        return self.__balance

    @property
    def account_holder(self):
        return self._account_holder

    @account_holder.setter
    def account_holder(self, new_name):
        if len(new_name) > 0:
            self._account_holder = new_name
            self._transaction_history.append(f"Account holder changed to {new_name}")

    def deposit(self, amount):
        if amount > 0:
            self.__balance += amount
            self._transaction_history.append(f"Deposited: ${amount}")

    def withdraw(self, amount):
        if 0 < amount <= self.__balance:
            self.__balance -= amount
            self._transaction_history.append(f"Withdrew: ${amount}")
            return True
        return False

    def get_transaction_history(self):
        return self._transaction_history.copy()  # Return a copy to protect original data

# Usage
account = BankAccount("John Doe", 1000)
print(account.balance)        # Output: 1000
print(account.account_holder)  # Output: John Doe

account.account_holder = "Jane Doe"  # Using setter
account.deposit(500)
account.withdraw(200)

print(f"New balance: ${account.balance}")  # Output: New balance: $1300
print("Transaction history:", account.get_transaction_history())

The Complete @property Ecosystem

  • In addition to <span>getter</span> and <span>setter</span>, there is also <span>deleter</span>:
class Config:
    def __init__(self):
        self._settings = {"theme": "dark", "language": "en"}

    @property
    def theme(self):
        return self._settings.get("theme")

    @theme.setter
    def theme(self, value):
        if value not in ["light", "dark"]:
            raise ValueError("Theme must be 'light' or 'dark'")
        self._settings["theme"] = value

    @theme.deleter
    def theme(self):
        print("Deleting theme setting, restoring default")
        self._settings["theme"] = "light"

config = Config()
print(f"Current theme: {config.theme}")  # Output: Current theme: dark

config.theme = "light"
print(f"New theme: {config.theme}")    # Output: New theme: light

del config.theme                   # Output: Deleting theme setting, restoring default
print(f"Default theme: {config.theme}")  # Output: Default theme: light
  • Syntax pattern:
class MyClass:
    def __init__(self, value):
        self._value = value

    @property
    def value(self):
        # Getter logic
        return self._value

    @value.setter
    def value(self, new_value):
        # Setter logic (including validation)
        if validation_condition:
            self._value = new_value
        else:
            raise ValueError("Error message")

    @value.deleter
    def value(self):
        # Deleter logic
        del self._value

Abstract Base Classes

  • Abstract Base Classes (ABCs) are used to define interfaces, specifying methods that subclasses must implement. We cannot directly instantiate an abstract base class.

Why Do We Need Abstract Base Classes?

  • Standardizing Interfaces: When multiple subclasses need to implement the same interface, an abstract base class ensures that each subclass implements the necessary methods.
    • For example, in a graphics library, each shape should have methods for calculating area and perimeter, so we can define an abstract base class Shape that requires each subclass to implement area and perimeter methods.
  • Avoiding Omissions: If a subclass does not implement all abstract methods in the abstract base class, the subclass cannot be instantiated, which helps catch errors during development rather than at runtime.
  • Polymorphism: Abstract base classes allow us to handle different subclass objects in a uniform way.
    • For example, we can have a list of Shape types containing various shapes (circles, rectangles, etc.), and then iterate through the list to call each shape’s area method without worrying about the specific type of shape.
  • Documentation Purpose: Abstract base classes clearly specify the methods that subclasses should have, providing a clear explanation for readers of the code.

The Logic Behind Abstract Base Classes

  • Abstract base classes are implemented through the <span>abc</span> module, where <span>ABC</span> is a helper class for abstract base classes, and <span>abstractmethod</span> is a decorator used to declare abstract methods.
  • An abstract base class cannot be instantiated because its abstract methods are not implemented. Attempting to instantiate an abstract base class will raise an error.
  • Subclasses must implement all abstract methods to be instantiated; otherwise, the subclass will also be considered an abstract class.
from abc import ABC, abstractmethod

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

    @abstractmethod
    def perimeter(self):
        pass

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

# Cannot instantiate Shape class, must instantiate its subclass, and the subclass must implement abstract methods
circle = Circle(5)
print(circle.area())  # Output: 78.5
  • If we create a subclass but do not implement all abstract methods, that subclass also cannot be instantiated:
class IncompleteCircle(Shape):
    def __init__(self, radius):
        self.radius = radius

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

    # Missing perimeter method

# incomplete_circle = IncompleteCircle(5)  # TypeError: Can't instantiate abstract class IncompleteCircle with abstract method

Underlying Implementation Principles

  • The implementation of abstract base classes relies on Python’s metaclass mechanism. When we use <span>ABC</span> as a base class, it is actually managed by the metaclass <span>ABCMeta</span>.
  • Registering abstract methods: When using the <span>@abstractmethod</span> decorator, that method is added to the class’s <span>__abstractmethods__</span> set.
  • Instantiation check: When we attempt to instantiate a class, Python checks whether the class’s <span>__abstractmethods__</span> set is empty. If it is not empty, it raises a <span>TypeError</span>, indicating that there are still abstract methods that have not been implemented.
# Manually implement the functionality of abstract base classes (simplified version)
class ManualABC:
    def __init__(self):
        # Collect all abstract methods
        self._abstract_methods = set()

        # Iterate through the class's methods to find those marked as abstract
        for attr_name in dir(self):
            attr = getattr(self, attr_name)
            if hasattr(attr, '_is_abstract') and attr._is_abstract:
                self._abstract_methods.add(attr_name)

    def __new__(cls, *args, **kwargs):
        # Check if there are any unimplemented abstract methods
        instance = super().__new__(cls)
        if hasattr(instance, '_abstract_methods') and instance._abstract_methods:
            raise TypeError(f"Cannot instantiate abstract class {cls.__name__},"
                          f" unimplemented methods: {instance._abstract_methods}")
        return instance

def manual_abstractmethod(func):
    """Manually implemented @abstractmethod decorator"""
    func._is_abstract = True
    return func

# Using manually implemented abstract base class
class ManualShape(ManualABC):
    @manual_abstractmethod
    def area(self):
        pass

    @manual_abstractmethod
    def perimeter(self):
        pass

class ManualCircle(ManualShape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):  # Must implement
        return 3.14 * self.radius ** 2

    # Intentionally not implementing perimeter method

# Testing
try:
    # circle = ManualCircle(5)  # This will raise an error because perimeter method is not implemented
    pass
except TypeError as e:
    print(f"Error: {e}")

Conclusion

In the maze-like city of Chang’an, seeking the happiness each deems fit.

Leave a Comment