PYTHON OOP PROGRAMMING
The four pillars of Object-Oriented Programming โ from classes to advanced design patterns (2026 edition)
Master Object-Oriented Programming in Python โ This comprehensive guide covers everything from fundamental classes and objects to advanced topics like metaclasses, descriptors, mixins, and SOLID principles. Whether you're preparing for interviews, building large applications, or deepening your Python expertise, these concepts are essential for writing clean, maintainable, and scalable code.
01 ยท What Is OOP?
object-oriented programming overview
Object-Oriented Programming (OOP) is a programming paradigm that organises code around objects โ bundles of data (attributes) and behaviour (methods) โ rather than procedures or functions alone. Python is a multi-paradigm language that fully supports OOP. Every value in Python is an object, including integers, strings, functions, and even classes themselves.
the four pillars
OOP rests on four core principles:
- Encapsulation โ bundling data and the methods that operate on it into a single unit (class), hiding internal details.
- Abstraction โ exposing only what is necessary; hiding complexity behind clean, simple interfaces.
- Inheritance โ a child class acquiring properties and behaviour from a parent class, enabling code reuse.
- Polymorphism โ different classes responding to the same interface in their own way, enabling flexible, interchangeable code.
02 ยท Classes & Objects
defining a class
A class is the blueprint; an object (instance) is the concrete thing built from it. Define a class with the class keyword. The __init__ method is the constructor โ it runs automatically when a new object is created. self is the first parameter of every instance method and refers to the current object.
class Person:
species = "Homo sapiens"
def __init__(self, name: str, age: int):
self.name = name
self.age = age
def greet(self) -> str:
return f"Hi, I am {self.name}, age {self.age}."
def __repr__(self) -> str:
return f"Person('{self.name}', {self.age})"
alice = Person("Alice", 30)
print(alice.greet())
print(alice)
class vs instance attributes
Class attributes are shared across all instances (defined directly in the class body). Instance attributes are unique to each object (defined in __init__ via self). If you assign to a class attribute on an instance, Python creates a new instance attribute that shadows the class one โ the class attribute remains unchanged.
class Counter:
total = 0
def __init__(self, name: str):
self.name = name
Counter.total += 1
@classmethod
def get_total(cls) -> int:
return cls.total
Counter("a")
Counter("b")
Counter("c")
print(Counter.get_total())
instance, class & static methods
Python classes support three kinds of methods. Instance methods receive self and can access or modify instance state. Class methods (decorated with @classmethod) receive cls and are used for factory constructors or operations affecting the class itself. Static methods (decorated with @staticmethod) receive neither โ they are utility functions logically grouped inside a class.
class Temperature:
def __init__(self, celsius: float):
self.celsius = celsius
def to_fahrenheit(self) -> float:
return self.celsius * 9 / 5 + 32
@classmethod
def from_fahrenheit(cls, f: float) -> "Temperature":
return cls((f - 32) * 5 / 9)
@staticmethod
def is_freezing(celsius: float) -> bool:
return celsius <= 0
t = Temperature.from_fahrenheit(212)
print(t.celsius)
print(Temperature.is_freezing(-5))
03 ยท Encapsulation
access control conventions
Python does not enforce strict access modifiers like Java, but uses naming conventions that every professional developer respects. A single underscore prefix (_attr) signals "protected โ internal use only." A double underscore prefix (__attr) triggers name mangling, making the attribute harder to access from outside the class. This is Python's version of "private."
class Wallet:
def __init__(self, owner: str, balance: float):
self.owner = owner
self._log = []
self.__balance = balance
def deposit(self, amount: float) -> None:
if amount <= 0:
raise ValueError("Deposit must be positive")
self.__balance += amount
self._log.append(f"+{amount}")
def withdraw(self, amount: float) -> None:
if amount > self.__balance:
raise ValueError("Insufficient funds")
self.__balance -= amount
self._log.append(f"-{amount}")
properties โ pythonic getters & setters
The @property decorator exposes a method as a read-only attribute. Add a @attr.setter to allow validated writes. This is the Pythonic replacement for explicit get_x() and set_x() methods โ the interface feels like a plain attribute but runs validation logic transparently.
class BankAccount:
def __init__(self, balance: float = 0):
self.__balance = balance
@property
def balance(self) -> float:
return self.__balance
@balance.setter
def balance(self, value: float) -> None:
if value < 0:
raise ValueError("Balance cannot be negative")
self.__balance = value
acc = BankAccount(500)
acc.balance = 750
print(acc.balance)
Use @property when you want an attribute that feels simple to the caller but runs logic internally โ validation, lazy computation, or logging on access.
04 ยท Abstraction
abstract base classes (ABC)
Abstraction hides complex implementation behind a clean interface. Python's abc module provides ABC and @abstractmethod. Any class inheriting from an ABC that does not implement every abstract method cannot be instantiated โ Python raises a TypeError. This enforces a contract: all subclasses must provide the required behaviour.
from abc import ABC, abstractmethod
class Shape(ABC):
@abstractmethod
def area(self) -> float: ...
@abstractmethod
def perimeter(self) -> float: ...
def describe(self) -> str:
return f"Area={self.area():.2f}, Perimeter={self.perimeter():.2f}"
class Circle(Shape):
def __init__(self, radius: float):
self.radius = radius
def area(self) -> float: return 3.14159 * self.radius ** 2
def perimeter(self) -> float: return 2 * 3.14159 * self.radius
c = Circle(5)
print(c.describe())
abstract properties
You can combine @property and @abstractmethod to require that subclasses expose a specific attribute via a property. Stack the decorators: @property on top, @abstractmethod below. This pattern is common in framework design where every subclass must declare, for example, a name or endpoint string.
from abc import ABC, abstractmethod
class DataSource(ABC):
@property
@abstractmethod
def name(self) -> str: ...
@abstractmethod
def load(self) -> list: ...
class CSVSource(DataSource):
@property
def name(self) -> str: return "CSV"
def load(self) -> list: return ["row1", "row2"]
src = CSVSource()
print(src.name, src.load())
05 ยท Inheritance
single inheritance
A child class inherits all attributes and methods from its parent. Call the parent's constructor with super().__init__(). Override methods in the child to specialise behaviour. The child class can also call the parent's version of an overridden method using super().method(), composing rather than replacing the parent logic.
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return f"{self.name} makes a sound."
def __repr__(self) -> str:
return f"{self.__class__.__name__}('{self.name}')"
class Dog(Animal):
def speak(self) -> str:
return f"{self.name} says: Woof!"
class Cat(Animal):
def speak(self) -> str:
return f"{self.name} says: Meow!"
print(Dog("Rex").speak())
print(Cat("Luna").speak())
super() & extending parents
super() returns a proxy to the parent class, enabling cooperative calls up the inheritance chain. Use it to extend โ not replace โ parent behaviour. This is especially important when the parent's __init__ sets up state your child class depends on. Always pass any parent-required arguments through when calling super().__init__().
class Vehicle:
def __init__(self, make: str, speed: int):
self.make = make
self.speed = speed
def describe(self) -> str:
return f"{self.make} at {self.speed} km/h"
class ElectricCar(Vehicle):
def __init__(self, make: str, speed: int, range_km: int):
super().__init__(make, speed)
self.range_km = range_km
def describe(self) -> str:
base = super().describe()
return f"{base}, range {self.range_km} km"
ev = ElectricCar("Tesla", 250, 600)
print(ev.describe())
multiple inheritance & MRO
Python allows a class to inherit from multiple parents simultaneously. The Method Resolution Order (MRO) โ computed by the C3 linearisation algorithm โ defines the order Python searches classes for a method. Inspect it with ClassName.__mro__. MRO ensures consistent, predictable resolution even in complex diamond hierarchies. Use super() cooperatively for this to work correctly.
class Flyable:
def fly(self) -> str: return "I can fly!"
class Swimmable:
def swim(self) -> str: return "I can swim!"
class Duck(Flyable, Swimmable):
def quack(self) -> str: return "Quack!"
donald = Duck()
print(donald.fly())
print(donald.swim())
print(Duck.__mro__)
Prefer composition over deep inheritance chains. Deep hierarchies create tight coupling โ a change in the parent ripples unexpectedly through all children. If the relationship is "has-a" rather than "is-a", use composition instead.
06 ยท Polymorphism
duck typing
Python's approach to polymorphism is duck typing: if an object has the expected methods, it works โ regardless of its class. Named after the principle "if it walks like a duck and quacks like a duck, it's a duck." This makes Python code flexible and compositional without requiring explicit interfaces or type declarations.
class HTMLRenderer:
def render(self, content: str) -> str:
return f"<p>{content}</p>"
class PDFRenderer:
def render(self, content: str) -> str:
return f"[PDF] {content}"
class TerminalRenderer:
def render(self, content: str) -> str:
return f">> {content}"
def publish(renderer, content: str) -> None:
print(renderer.render(content))
for r in [HTMLRenderer(), PDFRenderer(), TerminalRenderer()]:
publish(r, "Python OOP is elegant")
method overriding & operator overloading
Method overriding allows a subclass to replace a parent method with its own version. Operator overloading uses dunder methods to give custom objects behaviour with Python's built-in operators (+, ==, <, len(), etc.). This makes your classes feel like native Python types.
class Vector:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __eq__(self, other: "Vector") -> bool:
return self.x == other.x and self.y == other.y
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
v1 = Vector(1, 2)
v2 = Vector(3, 4)
print(v1 + v2)
print(v1 == Vector(1, 2))
07 ยท Dunder / Magic Methods
essential dunder methods
Dunder (double-underscore) methods let your objects integrate with Python's built-in machinery. They are called implicitly โ you never call
obj.__len__() directly; you call
len(obj) and Python does the rest.
| Dunder | Triggered by | Purpose |
__init__ | MyClass() | Constructor โ initialise object state |
__repr__ | repr(obj) | Developer-facing string, ideally eval-able |
__str__ | print(obj) | User-facing readable string |
__len__ | len(obj) | Return the size of the container |
__getitem__ | obj[key] | Index or key access |
__contains__ | x in obj | Membership test |
__iter__ | for x in obj | Return an iterator |
__eq__ | obj == other | Equality comparison |
__lt__ | obj < other | Less-than (enables sorting) |
__hash__ | hash(obj) | Dict key / set member support |
__call__ | obj() | Make instance callable like a function |
__enter__ | with obj | Context manager entry |
__exit__ | leaving with | Context manager cleanup |
__del__ | garbage collected | Destructor (use sparingly) |
container & callable dunders in practice
Once you implement __len__ and __getitem__, your object works with for loops, list(), in checks, and reversed() automatically. Implementing __call__ makes an instance behave like a function โ the pattern used by PyTorch's nn.Module and stateful transforms.
class Pipeline:
def __init__(self, steps: list):
self._steps = steps
def __len__(self) -> int:
return len(self._steps)
def __getitem__(self, idx):
return self._steps[idx]
def __contains__(self, item) -> bool:
return item in self._steps
def __call__(self, data):
for step in self._steps:
data = step(data)
return data
p = Pipeline([str.strip, str.lower])
print(p(" HELLO "))
print(len(p), "steps")
08 ยท Dataclasses & Protocols
dataclasses 3.7+
The @dataclass decorator auto-generates __init__, __repr__, and __eq__ from annotated fields. Add frozen=True to make instances immutable and hashable. Add order=True to auto-generate comparison methods. Add slots=True (Python 3.10+) for memory-efficient slot-based storage. Dataclasses are ideal for data containers โ they are not a replacement for full OOP classes with rich behaviour.
from dataclasses import dataclass, field
@dataclass
class Student:
name: str
grade: int
scores: list[int] = field(default_factory=list)
def average(self) -> float:
return sum(self.scores) / len(self.scores) if self.scores else 0.0
s = Student("Glenn", 10, [90, 85, 92])
print(s)
print(s.average())
protocols โ structural typing 3.8+
A Protocol defines a structural interface: any class that has the required methods automatically satisfies it โ no explicit inheritance needed. This is Python's formal version of duck typing, enabling static type checkers (mypy) to verify interface compliance without coupling classes together. Import from typing.
from typing import Protocol
class Drawable(Protocol):
def draw(self) -> str: ...
class Circle:
def draw(self) -> str: return "Drawing a circle"
class Square:
def draw(self) -> str: return "Drawing a square"
def render(shape: Drawable) -> None:
print(shape.draw())
render(Circle())
render(Square())
09 ยท Advanced OOP new
metaclasses โ classes that create classes
A metaclass is the class of a class. Just as a class defines how instances behave, a metaclass defines how classes behave. Metaclasses are powerful but should be used sparingly. Common use cases include: registering classes automatically, enforcing coding standards, or modifying class attributes before creation. The default metaclass is type.
class SingletonMeta(type):
_instances = {}
def __call__(cls, *args, **kwargs):
if cls not in cls._instances:
cls._instances[cls] = super().__call__(*args, **kwargs)
return cls._instances[cls]
class Database(metaclass=SingletonMeta):
def __init__(self):
self.connection = "Connected"
db1 = Database()
db2 = Database()
print(db1 is db2) # True
descriptors โ controlling attribute access
Descriptors let you define reusable attribute behaviour. Any class implementing __get__, __set__, or __delete__ is a descriptor. They power @property, @classmethod, and @staticmethod. Descriptors are ideal for validation, lazy loading, or type checking across multiple classes.
class PositiveNumber:
def __set_name__(self, owner, name):
self.name = name
def __get__(self, obj, objtype=None):
return obj.__dict__.get(self.name, 0)
def __set__(self, obj, value):
if value <= 0:
raise ValueError("Must be positive")
obj.__dict__[self.name] = value
class Order:
quantity = PositiveNumber()
def __init__(self, quantity):
self.quantity = quantity
order = Order(5)
print(order.quantity)
mixins โ reusable behaviour through composition
A mixin is a class that provides method implementations for reuse by multiple unrelated classes. Mixins are not meant to stand alone โ they are combined via multiple inheritance to add specific features. This is a powerful alternative to deep inheritance hierarchies. Follow the naming convention with a Mixin suffix.
class JSONMixin:
def to_json(self) -> str:
import json
return json.dumps(self.__dict__)
class XMLMixin:
def to_xml(self) -> str:
return f"<data>{self.__dict__}</data>"
class User(JSONMixin, XMLMixin):
def __init__(self, name, age):
self.name = name
self.age = age
user = User("Alice", 30)
print(user.to_json())
context managers โ with statement magic
Context managers simplify resource management using the with statement. Implement __enter__ and __exit__ or use the @contextmanager decorator. They're essential for files, database connections, locks, and any setup/cleanup pairs.
class ManagedFile:
def __init__(self, filename):
self.filename = filename
def __enter__(self):
self.file = open(self.filename, 'w')
return self.file
def __exit__(self, exc_type, exc_val, exc_tb):
if self.file:
self.file.close()
with ManagedFile('test.txt') as f:
f.write('Hello, world!')
10 ยท Design Patterns
singleton pattern
The Singleton pattern ensures only one instance of a class can ever exist. It is used for shared resources like configuration objects, database connection pools, or logging singletons. Override __new__ โ the method Python calls before __init__ to actually create the object.
class Config:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.debug = False
cls._instance.version = "1.0"
return cls._instance
a = Config()
b = Config()
a.debug = True
print(b.debug)
print(a is b)
factory pattern
A Factory creates objects without the caller needing to know the concrete class. Use a class method or standalone function that takes a type string and returns the correct subclass. This pattern enables extensible, configurable object creation and is used throughout Django, SQLAlchemy, and Pillow.
class Animal:
def speak(self) -> str: ...
class Dog(Animal):
def speak(self) -> str: return "Woof!"
class Cat(Animal):
def speak(self) -> str: return "Meow!"
class Bird(Animal):
def speak(self) -> str: return "Tweet!"
def animal_factory(kind: str) -> Animal:
registry = {"dog": Dog, "cat": Cat, "bird": Bird}
if kind not in registry:
raise ValueError(f"Unknown animal: {kind}")
return registry[kind]()
print(animal_factory("dog").speak())
print(animal_factory("bird").speak())
observer pattern
The Observer pattern (also called pub/sub) allows objects to subscribe to events emitted by another object. When the subject's state changes, it notifies all registered observers automatically. This pattern is the backbone of event systems, UI frameworks, and reactive data pipelines.
class EventEmitter:
def __init__(self):
self._listeners: dict[str, list] = {}
def on(self, event: str, callback) -> None:
self._listeners.setdefault(event, []).append(callback)
def emit(self, event: str, *args) -> None:
for cb in self._listeners.get(event, []):
cb(*args)
emitter = EventEmitter()
emitter.on("login", lambda u: print(f"User logged in: {u}"))
emitter.on("login", lambda u: print(f"Sending welcome email to {u}"))
emitter.emit("login", "Glenn")
strategy pattern
The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. It lets the algorithm vary independently from clients that use it. In Python, strategies are often just functions or classes with a common interface.
class ShippingCost:
def __init__(self, strategy):
self.strategy = strategy
def calculate(self, order):
return self.strategy(order)
def fedex_strategy(order):
return 10.0 * order.weight
def ups_strategy(order):
return 8.0 * order.weight
class Order:
def __init__(self, weight):
self.weight = weight
order = Order(5)
cost = ShippingCost(fedex_strategy)
print(cost.calculate(order))
11 ยท OOP Best Practices
SOLID principles in Python
The
SOLID principles guide writing maintainable, extensible OOP code:
- S โ Single Responsibility: Each class has one reason to change. A
UserService handles user logic; a separate EmailService sends email.
- O โ Open/Closed: Open for extension, closed for modification. Add behaviour via new subclasses, not by editing existing code.
- L โ Liskov Substitution: Subclasses must be usable wherever the parent is expected without breaking correctness.
- I โ Interface Segregation: Prefer small, focused interfaces. A class should not be forced to implement methods it does not need.
- D โ Dependency Inversion: Depend on abstractions, not concretions. Inject dependencies rather than hard-coding them inside classes.
composition over inheritance
Inheritance expresses "is-a" relationships. Composition expresses "has-a" โ building objects by combining smaller, focused collaborators instead of extending deep class trees. Composition produces looser coupling, easier testing, and more flexible designs. As a rule: if you find yourself building hierarchies deeper than two or three levels, prefer composition.
class Logger:
def log(self, msg: str) -> None:
print(f"[LOG] {msg}")
class Validator:
def validate(self, data: dict) -> bool:
return bool(data.get("name"))
class UserService:
def __init__(self):
self.logger = Logger()
self.validator = Validator()
def create(self, data: dict) -> str:
if not self.validator.validate(data):
raise ValueError("Invalid data")
self.logger.log(f"Creating user: {data['name']}")
return f"User {data['name']} created."
dependency injection
Dependency Injection (DI) is a technique where objects receive their dependencies from external sources rather than creating them internally. This promotes loose coupling, testability, and flexibility. DI can be implemented via constructor injection, setter injection, or frameworks like dependency-injector.
class EmailService:
def send(self, to, msg):
print(f"Sending email to {to}: {msg}")
class UserService:
def __init__(self, email_service: EmailService):
self.email_service = email_service
def register(self, user_email):
self.email_service.send(user_email, "Welcome!")
email_svc = EmailService()
user_svc = UserService(email_svc)
user_svc.register("alice@example.com")
OOP anti-patterns to avoid
- God Class โ one class that does everything. Split it into focused collaborators.
- Mutable default arguments โ
def __init__(self, items=[]) shares the list across all instances. Use None as sentinel instead.
- Missing
__repr__ โ classes without it print useless <MyClass at 0x7f...>. Always implement it.
- Breaking LSP โ a
Square that inherits from Rectangle but breaks area semantics violates the Liskov Substitution Principle.
- Deep inheritance chains โ hierarchies deeper than 3 levels become fragile. Favour composition.
- Class as namespace โ a class with only
@staticmethods and no state is better written as a module.
- Concrete dependencies โ hard-coding dependencies makes testing and changing behaviour difficult. Use dependency injection.
Use dataclasses for data containers, ABC for enforced interfaces, @property instead of getters/setters, and Protocol for structural duck-typing with static analysis support. For advanced cases, consider descriptors, mixins, and dependency injection.