Object-Oriented Programming#

Why? Modularity, reusability, scalability.

Abstraction#

Implementation hiding. In Python, this can be implemented through abstract base classes (ABC).

Abstract classes cannot be instantiated, and any subclass of the abstract class must implement the abstract methods.

from abc import ABC, abstractmethod

class MachineLearningModel(ABC):
    @abstractmethod
    def predict(self):
        pass

class NaiveModel(MachineLearningModel):
    def predict(self, input):
        return input.mean()

Encapsulation#

Information hiding. Only the necessary parts are exposed.

Protected and Private Variables#

  • Protected: used within the class and its subclasses.

  • Private: strictly internal to the class.

class Dog:
    def __init__(self, name, age):
        self._name = name  # Protected variable
        self.__age = age  # Private variable

Decorators#

Decorators allow you to modify the behavior of functions or methods.

Properties (Getter / Setter / Deleter Methods)#

The @property decorator turns a method into a read-only attribute. It can then be paired with .setter and .deleter.

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

    def __repr__(self):
        return f'Circle(radius={self.radius})'

    @property
    def radius(self):
        return self._radius

    @radius.setter
    def radius(self, value):
        if value < 0:
            raise ValueError("Radius cannot be negative")
        self._radius = value

    @radius.deleter
    def radius(self):
        del self._radius

circle = Circle(5)

print(circle)
print(circle.radius)  # Accessing the method

circle.radius = 10  # Invokes the setter
del circle.radius  # Invokes the deleter
Circle(radius=5)
5

Dataclass#

The @dataclass decorator automatically generates:

  • __init__: initialize the class with the attributes name, age, and country.

  • __repr__: readable string representation of the object.

  • __eq__: allows comparison of two objects.

Data classes require type hints, but types aren’t actually enforced.

The frozen argument can be used to protect class attributes so they can be modified only in a way we want.

from dataclasses import dataclass

@dataclass
class Person:
    name: str
    age: int = 25
    country: str = "United States"

@dataclass(frozen=True)
class ImmutablePerson:
   birth_name: str
   country_of_origin: str = "United States"

person = Person(name="Alice") # Person(name='Alice', age=25, country='Unknown')
print(person)

Static and Class Methods#

  • Static Method (@staticmethod): A method that belongs to the class rather than an instance of the class. It doesn’t require access to self or cls.

  • Class Method (@classmethod): A method that operates on the class itself and is passed the class (cls) as its first argument.

class MyClass:
    @staticmethod
    def static_method():
        return "Static Method"

    @classmethod
    def class_method(cls, params):
        return cls(params["lr"], params["steps"])

Custom decorators#

def log_decorator(func):
    def wrapper(*args, **kwargs):
        print(f"Calling function '{func.__name__}' with arguments {args} and {kwargs}")
        result = func(*args, **kwargs)
        print(f"Function '{func.__name__}' returned {result}")
        return result
    return wrapper

@log_decorator
def add(a, b):
    return a + b

sum_result = add(3, 5)
Calling function 'add' with arguments (3, 5) and {}
Function 'add' returned 8

Inheritance#

class Base:
    def foo(self):
        return 'foo'

class Inheritor(Base):
    def bar(self):
        return f"bar"
    
assert hasattr(Base, 'foo'), "Base class should have 'foo' method."

Single and Multiple Inheritance#

class A:
    pass

class B:
    pass

class C(A, B):  # Multiple inheritance
    pass

super()#

Allows you to call methods from a parent class. super() must be used inside a method of the class

class Person:
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        return f"My name is {self.name}."

class Employee(Person):
    def __init__(self, name, position):
        super().__init__(name)
        self.position = position
    
    def speak(self):
        # Extend the parent class's method, keeping its functionality
        base_speak = super().speak()
        return f"{base_speak} I work as a {self.position}."

# Create an instance of Employee and call the speak method
employee = Employee('Alice', 'Data Scientist')
print(employee.speak())
My name is Alice. I work as a Data Scientist.

Method Resolution Order (MRO)#

MRO determines the order in which base classes are searched when executing a method. Python uses the C3 linearization algorithm to compute the MRO, ensuring a consistent and predictable order.

class A:
    def method(self):
        print("Method in A")

class B(A):
    def method(self):
        print("Method in B")
        super().method()

class C(A):
    def method(self):
        print("Method in C")
        super().method()

class D(B, C):
    def method(self):
        print("Method in D")
        super().method()

d = D()
d.method()
print(D.mro())
Method in D
Method in B
Method in C
Method in A
[<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class '__main__.A'>, <class 'object'>]

Mixin#

Add functionality to other classes, without being intended for independent use.

class LoggingMixin:
    def log(self, message):
        print(f"Log: {message}")

class MyClass(LoggingMixin):
    def do_something(self):
        self.log("Doing something...")

obj = MyClass()
obj.do_something()
Log: Doing something...

Polymorphism#

Polymorphism allows objects of different classes to be treated as objects of a common parent class. It provides the ability to define methods in a way that they can operate on objects of different types.

Method Overriding is when a subclass provides a specific implementation of a method that is already defined in its superclass.

class Animal:
    @abstractmethod
    def speak(self):
        raise NotImplementedError("Subclass must implement this method")

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

class Cat(Animal):
    def speak(self):
        return f"Meow"

animals = [Dog(), Cat()]
for animal in animals:
    print(animal.speak())  # Calls speak() from respective class
Woof!
Meow
class Base:
    def foo(self):
        return 'foo'

class Overrider(Base):
    def foo(self): # Overrides the parent method
        return f"foo from BaseOverrider"

Dunder (Magic) Methods#

Magic methods allow you to define special behaviors for built-in operations.

They are called dunder methods because they begin and end with double underscores, e.g., __init__, __str__

class Book:
    def __init__(self, title, author):
        self.title = title
        self.author = author

    def __str__(self):
        return f"Book: {self.title} by {self.author}"
    
str(Book("Dune", "Frank Herbert"))
'Book: Dune by Frank Herbert'

Full list of methods is nicely summarized here: https://www.pythonmorsels.com/every-dunder-method/

Composition#

Composition is a design principle where one class contains an object of another class to reuse code and functionality. It represents a “has-a” relationship between objects.

class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower

class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine  # Composition: Car "has an" Engine

Class vs. Instance Variables#

  • Instance Variables: Variables that are unique to each instance of a class.

  • Class Variables: Variables that are shared across all instances of a class

class Employee:
    company_name = "TechCorp"  # Class variable

    def __init__(self, name):
        self.name = name  # Instance variable

SOLID Principles#

Single Responsibility#

A class should have only one job or responsibility.

class UserAuth:
    def login(self, username, password):
        pass # Handle user login

class UserData:
    def store_user_data(self, user):
        pass # Handle storing user data

Open/Closed#

Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.

class Shape:
    def area(self):
        raise NotImplementedError

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

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

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

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

Liskov Substitution#

Subtypes must be substitutable for their base types without affecting the correctness of the program.

If you have a base class Bird with a method fly, any subclass of Bird should be able to fly as well. Otherwise, it would violate LSP.

The Penguin class violates LSP because it cannot fly, even though it’s a subclass of Bird. A better design would be to rethink the hierarchy or separate the flying behavior into another class.

class Bird:
    def fly(self):
        print("Flying")

class Sparrow(Bird):
    pass

class Penguin(Bird):
    def fly(self):
        raise Exception("Penguins cannot fly")

Interface Segregation#

Clients should not be forced to depend on interfaces they do not use.

Example: If you have an interface Worker with methods work() and eat(), classes that don’t need both methods will be forced to implement them, even if it doesn’t make sense.

class WorkerInterface:
    def work(self):
        pass

    def eat(self):
        pass

class Robot(WorkerInterface):
    def work(self):
        print("Robot is working")

    def eat(self):
        raise NotImplementedError("Robots do not eat")  # Violates ISP

Instead, split the interface into two smaller ones:

class Workable:
    def work(self):
        pass

class Eatable:
    def eat(self):
        pass

class Robot(Workable):
    def work(self):
        print("Robot is working")

Dependency Inversion#

High-level modules should not depend on low-level modules. Both should depend on abstractions. Also, abstractions should not depend on details; details should depend on abstractions.

Example: Without DIP, a high-level class like OrderProcessing might directly depend on a specific payment processor implementation:

class PayPalPaymentProcessor:
    def process_payment(self, amount):
        print(f"Processing payment of {amount} through PayPal")

class OrderProcessing:
    def __init__(self):
        self.payment_processor = PayPalPaymentProcessor()  # Tight coupling

    def process_order(self, amount):
        self.payment_processor.process_payment(amount)

This tightly couples OrderProcessing with PayPalPaymentProcessor, making it difficult to change the payment processor. Instead, apply DIP by introducing an abstraction:

class PaymentProcessor:
    def process_payment(self, amount):
        pass

class PayPalPaymentProcessor(PaymentProcessor):
    def process_payment(self, amount):
        print(f"Processing payment of {amount} through PayPal")

class OrderProcessing:
    def __init__(self, payment_processor: PaymentProcessor):
        self.payment_processor = payment_processor  # Dependency Injection

    def process_order(self, amount):
        self.payment_processor.process_payment(amount)