Advanced OOP

Object-Oriented Programming (OOP) is a powerful paradigm that helps us model real-world problems using classes and objects. While the basics of OOP — such as creating classes, instantiating objects, and using methods — are essential skills, the true power of OOP in Python shines when you master its advanced concepts. This lesson will guide you through some of the most important advanced OOP techniques in Python, enabling you to design cleaner, more flexible, and maintainable codebases.

We'll explore key topics including:

  • Class and static methods
  • Property decorators for controlled attribute access
  • Magic (dunder) methods to customize behavior
  • Multiple inheritance and the method resolution order (MRO)
  • Abstract base classes for interface enforcement
  • Composition vs inheritance

By the end of this lesson, you'll be able to harness these tools to create sophisticated Python classes and architectures.

Class Methods and Static Methods: Managing Behavior at the Class Level

Sometimes, you want methods that are not tied to a specific instance, but rather to the class itself. Python provides two special decorators for this: @classmethod and @staticmethod.

  • Class Methods take the class itself as the first argument (conventionally named cls). They can modify class state that applies across all instances.
  • Static Methods don’t take either self or cls as the first argument. They behave like regular functions but belong to the class’s namespace.

📌 Deep Dive: Using Class and Static Methods

PYTHON
class Employee:
    raise_amount = 1.05  # 5% raise for all employees

    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def apply_raise(self):
        self.salary = int(self.salary * self.raise_amount)

    @classmethod
    def set_raise_amount(cls, amount):
        cls.raise_amount = amount

    @staticmethod
    def is_workday(day):
        # Monday is 0 and Sunday is 6
        return day.weekday() < 5

# Usage
import datetime

emp1 = Employee('John Doe', 50000)
print(emp1.salary)  # 50000

Employee.set_raise_amount(1.10)  # Change raise amount for all employees
emp1.apply_raise()
print(emp1.salary)  # 55000

my_date = datetime.date(2024, 6, 15)  # This is a Saturday
print(Employee.is_workday(my_date))  # False
Output
50000
55000
False

Notice how set_raise_amount modifies the class variable raise_amount for all instances, while is_workday is a utility function logically grouped inside the Employee class.

Property Decorators: Encapsulating Attribute Access Elegantly

Directly exposing attributes sometimes leads to issues when validation or computed values are needed. Python’s @property decorator allows you to expose methods like attributes, providing a clean interface to get, set, or delete attributes without changing the class’s external API.

📌 Deep Dive: Controlling Attributes Using @property

PYTHON
class Celsius:
    def __init__(self, temperature=0):
        self._temperature = temperature

    @property
    def temperature(self):
        print("Getting value...")
        return self._temperature

    @temperature.setter
    def temperature(self, value):
        if value < -273.15:
            raise ValueError("Temperature below -273.15 is not possible")
        print("Setting value...")
        self._temperature = value

# Usage
c = Celsius()
c.temperature = 37  # Calls setter
print(c.temperature)  # Calls getter

try:
    c.temperature = -300  # Invalid, raises exception
except ValueError as e:
    print(e)
Output
Setting value...
Getting value...
37
Temperature below -273.15 is not possible

This approach lets you change internal implementation without affecting the users of your class. It’s a crucial tool for maintaining backward compatibility in large projects.

Magic Methods: Customizing Object Behavior

Magic methods (or “dunder” methods) are special methods surrounded by double underscores, like __init__, __str__, or __add__. They allow you to define how objects behave with built-in operations such as printing, addition, equality comparison, and more.

Implementing these methods lets your classes integrate smoothly with Python’s syntax and built-in functions, making your objects behave like built-in types.

📌 Deep Dive: Implementing Magic Methods

PYTHON
class Vector2D:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __repr__(self):
        return f"Vector2D({self.x}, {self.y})"

    def __add__(self, other):
        if not isinstance(other, Vector2D):
            return NotImplemented
        return Vector2D(self.x + other.x, self.y + other.y)

    def __eq__(self, other):
        if not isinstance(other, Vector2D):
            return False
        return self.x == other.x and self.y == other.y

# Usage
v1 = Vector2D(2, 4)
v2 = Vector2D(5, -2)
print(v1 + v2)  # Vector2D(7, 2)
print(v1 == v2)  # False
print(v1 == Vector2D(2, 4))  # True
Output
Vector2D(7, 2)
False
True

By overriding __add__, the + operator works intuitively with vectors. The __repr__ method returns a clear, unambiguous string representation, useful for debugging.

Multiple Inheritance and Method Resolution Order (MRO)

Python supports multiple inheritance, allowing a class to inherit from more than one parent class. This enables powerful abstractions but can introduce complexity, especially with method conflicts. Python uses the C3 linearization algorithm to determine the Method Resolution Order (MRO), which is the order in which base classes are searched when a method is called.

💡 Why MRO Matters

When multiple parents define the same method, Python uses the MRO to decide which method to call, ensuring consistent and predictable behavior.

📌 Deep Dive: Multiple Inheritance and MRO

PYTHON
class A:
    def greet(self):
        print("Hello from A")

class B(A):
    def greet(self):
        print("Hello from B")

class C(A):
    def greet(self):
        print("Hello from C")

class D(B, C):
    pass

d = D()
d.greet()  # Which greet is called?

print(D.__mro__)
Output
Hello from B
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>)

The D class inherits from B and C. The greet method from B is called because B appears before C in the MRO. You can check the MRO by inspecting the __mro__ attribute.

Abstract Base Classes: Defining Interfaces and Contracts

In large projects or libraries, it's essential to enforce that certain classes implement specific methods. Python’s abc module lets you create Abstract Base Classes (ABCs) that define abstract methods. Subclasses must implement these methods, or they cannot be instantiated.

📌 Deep Dive: Using Abstract Base Classes

PYTHON
from abc import ABC, abstractmethod

class Shape(ABC):

    @abstractmethod
    def area(self):
        pass

    @abstractmethod
    def perimeter(self):
        pass

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

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

    def perimeter(self):
        return 2 * (self.width + self.height)

# Usage
rect = Rectangle(4, 5)
print(rect.area())       # 20
print(rect.perimeter())  # 18

# The following will raise an error:
# shape = Shape()  # TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter
Output
20
18

ABCs are powerful tools for API design, forcing subclasses to implement critical methods and preventing incomplete implementations.

Composition Over Inheritance: Building Flexible Systems

While inheritance is a core feature of OOP, relying on it excessively can lead to fragile and tightly coupled code. Composition — where objects contain other objects — is often preferred for building flexible systems. It allows you to assemble behaviors dynamically and avoid the pitfalls of complex inheritance hierarchies.

💡 Composition vs Inheritance

Composition means "has-a" relationship (e.g., a Car has an Engine), while inheritance means "is-a" relationship (e.g., a Car is-a Vehicle).

Comparison: Inheritance vs Composition
InheritanceComposition
Creates a tight coupling between parent and childMore flexible; components can be swapped or changed
Good for “is-a” relationshipsGood for “has-a” relationships
Can lead to deep, complex hierarchiesFlatter, easier to maintain structures
Behavior is inherited automaticallyBehavior is delegated to contained objects

📌 Deep Dive: Using Composition to Model a Car

PYTHON
class Engine:
    def start(self):
        print("Engine starting...")

    def stop(self):
        print("Engine stopping...")

class Car:
    def __init__(self):
        self.engine = Engine()  # Car has an Engine

    def start(self):
        self.engine.start()
        print("Car is now moving")

    def stop(self):
        self.engine.stop()
        print("Car has stopped")

# Usage
my_car = Car()
my_car.start()
my_car.stop()
Output
Engine starting...
Car is now moving
Engine stopping...
Car has stopped

By composing a Car with an Engine, we keep responsibilities separated and code modular. This approach scales well for complex systems.

Architecture of Advanced OOP
Architecture of Advanced OOP

Putting It All Together: Designing a Realistic Class Hierarchy

Let’s design a simplified employee management system that combines many of the concepts we covered.

📌 Deep Dive: Employee Management with Advanced OOP

PYTHON
from abc import ABC, abstractmethod
from datetime import date

class Employee(ABC):
    raise_factor = 1.05

    def __init__(self, name, salary):
        self.name = name
        self._salary = salary

    @property
    def salary(self):
        return self._salary

    @salary.setter
    def salary(self, amount):
        if amount < 0:
            raise ValueError("Salary cannot be negative.")
        self._salary = amount

    def apply_raise(self):
        self.salary = int(self.salary * self.raise_factor)

    @classmethod
    def set_raise_factor(cls, factor):
        cls.raise_factor = factor

    @staticmethod
    def is_workday(check_date):
        return check_date.weekday() < 5

    @abstractmethod
    def work(self):
        pass

class Developer(Employee):
    def __init__(self, name, salary, prog_lang):
        super().__init__(name, salary)
        self.prog_lang = prog_lang

    def work(self):
        print(f"{self.name} writes {self.prog_lang} code.")

class Manager(Employee):
    def __init__(self, name, salary, employees=None):
        super().__init__(name, salary)
        self.employees = employees if employees else []

    def add_employee(self, emp):
        self.employees.append(emp)

    def work(self):
        print(f"{self.name} manages {len(self.employees)} employees.")

# Usage example
dev = Developer("Alice", 70000, "Python")
mgr = Manager("Bob", 90000, [dev])

dev.work()  # Alice writes Python code.
mgr.work()  # Bob manages 1 employees.

print(f"Is 2024-06-22 a workday? {Employee.is_workday(date(2024,6,22))}")

mgr.apply_raise()
print(f"{mgr.name}'s new salary after raise: {mgr.salary}")
Output
Alice writes Python code.
Bob manages 1 employees.
Is 2024-06-22 a workday? True
Bob's new salary after raise: 94500

This example demonstrates:

  • Abstract base class Employee forces derived classes to implement work().
  • Class method to adjust raise factor globally.
  • Static method to check if a date is a workday.
  • Encapsulated salary attribute with property decorators.
  • Inheritance for different employee roles.

Such design improves code clarity, enforces rules, and keeps your system extensible.

⚠️ Common Pitfall: Overusing Inheritance

Be cautious not to create unnecessarily deep or broad inheritance trees. Over-inheritance can make your code rigid and hard to maintain. Prefer composition and interface abstractions where appropriate.

Summary

Advanced OOP in Python equips you with tools to write more structured, reusable, and readable code. Remember to:

  • Use @classmethod and @staticmethod to organize methods logically at the class level.
  • Leverage @property decorators to control attribute access with ease.
  • Implement magic methods to make your objects behave like built-in types.
  • Understand multiple inheritance and how Python’s MRO works to avoid surprises.
  • Use abstract base classes to define essential interfaces.
  • Favor composition over inheritance to build flexible systems.

Practice these concepts by refactoring your existing code and experimenting with new designs. Mastery of advanced OOP will elevate your Python skills to a professional level.