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)