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:
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.
DunderTriggered byPurpose
__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 objMembership test
__iter__for x in objReturn an iterator
__eq__obj == otherEquality comparison
__lt__obj < otherLess-than (enables sorting)
__hash__hash(obj)Dict key / set member support
__call__obj()Make instance callable like a function
__enter__with objContext manager entry
__exit__leaving withContext manager cleanup
__del__garbage collectedDestructor (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:
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
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.
Built with data precision and created through information authentication. By Glenn Junsay Pansensoy ยท domain: code-sense.pansensoyglenn.workers.dev ยท file: python-oop-programming.html ยท 2026 edition ยท 12 comprehensive sections