Object-Oriented Programming¶
Version: 0.1 Year: 2026
Copyright Notice¶
Copyright (c) 2025-2026 Ryan Thomas Robson / Robworks Software LLC. Licensed under CC BY-NC-ND 4.0. You may share this material for non-commercial purposes with attribution, but you may not distribute modified versions.
Most Python you have written so far has been procedural - functions calling functions, data passed as dictionaries or tuples. That works well for scripts, but as your tools grow beyond a few hundred lines, you start running into problems: related data and functions scatter across modules, configuration dictionaries grow unwieldy keys, and refactoring means hunting through every caller. Object-oriented programming (OOP) gives you a way to bundle related data and behavior into a single unit - a class - and to define clear contracts between the parts of your system.
classDiagram
direction LR
class YourClass {
+attributes
+methods()
+__init__()
+__repr__()
}
YourClass <|-- Inheritance
YourClass *-- Composition
YourClass <|.. ABC
YourClass <|.. Protocol
YourClass <|-- Dataclass
class Inheritance {
is-a relationship
super()
}
class Composition {
has-a relationship
delegation
}
class ABC {
@abstractmethod
enforced interface
}
class Protocol {
structural typing
duck typing
}
class Dataclass {
@dataclass
auto __init__ / __repr__
}
This guide covers the full OOP toolkit Python offers - from basic classes through inheritance, composition, and the modern alternatives (dataclasses and protocols) that often replace traditional patterns.
Classes and Objects¶
A class is a blueprint. An object (or instance) is a thing built from that blueprint. You define a class with the class keyword and create instances by calling it like a function.
class Server:
def __init__(self, hostname, port=22):
self.hostname = hostname
self.port = port
self.is_healthy = True
def address(self):
return f"{self.hostname}:{self.port}"
web = Server("web-01.prod", 443)
print(web.address()) # web-01.prod:443
The __init__ method runs when you create an instance. The first parameter, self, is the instance being created - Python passes it automatically.
Instance attributes (like self.hostname) belong to a specific object. Class attributes are shared across all instances:
class Server:
default_timeout = 30 # class attribute - shared by all instances
def __init__(self, hostname):
self.hostname = hostname # instance attribute - unique per object
Class vs instance attributes
Class attributes are useful for defaults and constants. But be careful with mutable class attributes like lists or dicts - all instances share the same object. Define mutable defaults in __init__ instead.
Dunder Methods¶
Dunder methods (short for "double underscore") let your classes hook into Python's built-in operations. You have already seen __init__ and __repr__. Here are the ones you will use most often.
String Representations¶
__repr__ returns an unambiguous string for developers (shown in the REPL and debugger). __str__ returns a human-readable string (used by print() and str()).
class Server:
def __init__(self, hostname, port=22):
self.hostname = hostname
self.port = port
def __repr__(self):
return f"Server({self.hostname!r}, port={self.port})"
def __str__(self):
return f"{self.hostname}:{self.port}"
If you only define one, define __repr__. When __str__ is not defined, Python falls back to __repr__.
Equality and Hashing¶
By default, == compares object identity (memory address). Override __eq__ to compare by value:
class Server:
def __init__(self, hostname, port=22):
self.hostname = hostname
self.port = port
def __eq__(self, other):
if not isinstance(other, Server):
return NotImplemented
return self.hostname == other.hostname and self.port == other.port
def __hash__(self):
return hash((self.hostname, self.port))
Returning NotImplemented (not NotImplementedError) tells Python to try the other operand's __eq__ instead. Defining __hash__ alongside __eq__ lets you use instances as dictionary keys and in sets.
The eq / hash contract
If you define __eq__ without __hash__, Python sets __hash__ to None, making your objects unhashable. If two objects are equal, they must have the same hash. The reverse is not required - different objects can share a hash.
Collection-Like Behavior¶
__len__ and __contains__ let your objects work with len() and the in operator:
class Cluster:
def __init__(self, name, servers=None):
self.name = name
self.servers = servers or []
def __len__(self):
return len(self.servers)
def __contains__(self, server):
return server in self.servers
Context Managers¶
__enter__ and __exit__ let your objects work with with statements. This is the same pattern you used in the Files and APIs guide when opening files:
class DatabaseConnection:
def __init__(self, dsn):
self.dsn = dsn
self.connection = None
def __enter__(self):
self.connection = connect(self.dsn) # acquire resource
return self.connection
def __exit__(self, exc_type, exc_val, exc_tb):
if self.connection:
self.connection.close() # always release
return False # don't suppress exceptions
Properties¶
A property lets you add logic to attribute access without changing the caller's syntax. Use the @property decorator to create a getter and @name.setter to add validation:
class Server:
def __init__(self, hostname, port=22):
self.hostname = hostname
self.port = port # goes through the setter
@property
def port(self):
return self._port
@port.setter
def port(self, value):
if not 1 <= value <= 65535:
raise ValueError(f"Port must be 1-65535, got {value}")
self._port = value
The backing attribute uses a leading underscore (self._port) by convention. Callers still write server.port = 8080 - the validation is invisible to them. This is why Python does not need Java-style getters and setters by default: start with a plain attribute, and switch to @property later if you need validation. The calling code never changes.
Inheritance¶
Inheritance lets you create a new class that reuses and extends an existing one. The new class (the subclass or child) inherits all attributes and methods from the parent (the base class or superclass).
class NetworkDevice:
def __init__(self, hostname, ip_address):
self.hostname = hostname
self.ip_address = ip_address
def ping(self):
return f"Pinging {self.ip_address}..."
def __repr__(self):
return f"{type(self).__name__}({self.hostname!r})"
class Router(NetworkDevice):
def __init__(self, hostname, ip_address, routing_table=None):
super().__init__(hostname, ip_address)
self.routing_table = routing_table or {}
def add_route(self, network, gateway):
self.routing_table[network] = gateway
class Switch(NetworkDevice):
def __init__(self, hostname, ip_address, vlan_count=1):
super().__init__(hostname, ip_address)
self.vlan_count = vlan_count
super() calls the parent class's method. Always use it instead of hardcoding the parent name - it handles multiple inheritance correctly.
classDiagram
NetworkDevice <|-- Router
NetworkDevice <|-- Switch
NetworkDevice <|-- Firewall
class NetworkDevice {
+hostname
+ip_address
+ping()
}
class Router {
+routing_table
+add_route()
}
class Switch {
+vlan_count
}
class Firewall {
+rules
+add_rule()
}
Use isinstance() to check types. It respects inheritance - a Router is also a NetworkDevice:
r = Router("core-rtr-01", "10.0.0.1")
isinstance(r, Router) # True
isinstance(r, NetworkDevice) # True
Multiple Inheritance and MRO¶
Python supports multiple inheritance, but it creates complexity. Python uses the Method Resolution Order (MRO) - a linearization algorithm called C3 - to decide which parent's method wins:
class Loggable:
def log(self, message):
print(f"[{type(self).__name__}] {message}")
class Monitorable:
def health_check(self):
return True
class ManagedServer(Loggable, Monitorable):
pass
Use multiple inheritance sparingly
Multiple inheritance works well for small mixin classes that add a single capability (like Loggable above). Avoid deep multiple inheritance hierarchies - they create confusing method resolution chains. If you find yourself building diamond-shaped class trees, switch to composition.
Composition vs Inheritance¶
Inheritance models an "is-a" relationship: a Router is a NetworkDevice. Composition models a "has-a" relationship: a Deployment has a Server and a Config. The difference matters because inheritance locks you into a rigid hierarchy, while composition lets you swap parts independently.
# Inheritance approach - rigid coupling
class WebDeployment(Server):
def __init__(self, hostname, config_path):
super().__init__(hostname, port=443)
self.config_path = config_path
The problem: what if a deployment spans multiple servers? Or needs a different kind of server? Inheritance forces a single parent.
# Composition approach - flexible parts
class Deployment:
def __init__(self, name, servers, config):
self.name = name
self.servers = servers
self.config = config
def healthy_servers(self):
return [s for s in self.servers if s.check()]
def deploy(self):
targets = self.healthy_servers()
for server in targets:
print(f"Deploying {self.name} to {server.address()}")
A useful heuristic: if you can describe the relationship with "has-a" or "uses-a", prefer composition. If the relationship is genuinely "is-a" and you need polymorphism (treating different types uniformly), inheritance is appropriate.
Abstract Base Classes¶
An abstract base class (ABC) defines a contract: subclasses must implement certain methods. Any attempt to instantiate a class with missing abstract methods raises TypeError.
from abc import ABC, abstractmethod
from pathlib import Path
class StorageBackend(ABC):
@abstractmethod
def read(self, key):
"""Return the value for key, or raise KeyError."""
@abstractmethod
def write(self, key, value):
"""Store value under key."""
def exists(self, key):
"""Check if key exists. Concrete method - subclasses inherit this."""
try:
self.read(key)
return True
except KeyError:
return False
class FileStorage(StorageBackend):
def __init__(self, base_dir: Path):
self.base_dir = base_dir
def read(self, key):
path = self.base_dir / key
if not path.exists():
raise KeyError(key)
return path.read_text()
def write(self, key, value):
path = self.base_dir / key
path.write_text(value)
ABCs are useful when you need to guarantee an interface - for example, ensuring every storage backend implements read and write. But they add ceremony. For lightweight contracts, Python offers a more flexible alternative: protocols.
Dataclasses¶
The @dataclass decorator (from the dataclasses module, Python 3.7+) auto-generates __init__, __repr__, and __eq__ based on class attributes. This eliminates the boilerplate for classes that are primarily data containers.
from dataclasses import dataclass, field
@dataclass
class HealthResult:
server: str
status: str
response_ms: float
checks: list[str] = field(default_factory=list)
That single decorator generates an __init__ with parameters matching the fields, a __repr__ that shows all values, and an __eq__ that compares field-by-field. You would need roughly 20 lines to write all of that by hand.
Defaults and Post-Init¶
Use field(default_factory=...) for mutable defaults (never use a bare [] or {} as a default). The __post_init__ method runs after the generated __init__:
from dataclasses import dataclass, field
from datetime import datetime
@dataclass
class DeployRecord:
service: str
version: str
servers: list[str] = field(default_factory=list)
timestamp: str = field(default="", repr=False)
def __post_init__(self):
if not self.timestamp:
self.timestamp = datetime.now().isoformat()
Frozen Dataclasses¶
Set frozen=True to make instances immutable. Any attempt to set an attribute after creation raises FrozenInstanceError:
Frozen dataclasses are automatically hashable, so you can use them as dictionary keys or in sets.
When to use a dataclass vs a regular class
Use @dataclass when your class is primarily a data container - configuration records, API responses, results. Use a regular class when behavior is more important than data, or when you need fine-grained control over initialization and attribute access.
Protocols (Structural Typing)¶
Protocols (from typing, Python 3.8+) define interfaces through structure rather than inheritance. A class satisfies a protocol if it has the right methods - it does not need to explicitly inherit from the protocol class. This is structural typing, also known as "duck typing with type checker support."
from typing import Protocol
class Readable(Protocol):
def read(self) -> str: ...
def load_config(source: Readable) -> dict:
import json
return json.loads(source.read())
Any object with a read() -> str method satisfies Readable - files, io.StringIO, your custom classes - without any of them knowing Readable exists.
Protocols vs ABCs¶
| ABC | Protocol | |
|---|---|---|
| Relationship | Nominal ("inherits from") | Structural ("has the methods") |
| Subclass declaration | Required (class Foo(MyABC)) |
Not required |
| Runtime enforcement | TypeError on instantiation |
No runtime enforcement |
| Type checker support | Yes | Yes |
| Best for | Enforcing contracts in your own codebase | Defining interfaces for code you don't control |
from typing import Protocol
class Notifier(Protocol):
def send(self, message: str) -> bool: ...
class EmailNotifier:
def __init__(self, smtp_host):
self.smtp_host = smtp_host
def send(self, message: str) -> bool:
print(f"Email via {self.smtp_host}: {message}")
return True
class SlackNotifier:
def __init__(self, webhook_url):
self.webhook_url = webhook_url
def send(self, message: str) -> bool:
print(f"Slack webhook: {message}")
return True
def alert(notifiers: list[Notifier], message: str):
for n in notifiers:
n.send(message)
Neither EmailNotifier nor SlackNotifier inherits from Notifier. They just happen to have a send(message: str) -> bool method. A type checker like mypy or pyright verifies compatibility at check time without any runtime cost.
Practical OOP Patterns¶
Strategy Pattern¶
The strategy pattern lets you swap an algorithm at runtime by passing in an object that implements the behavior. This is composition in action:
from typing import Protocol
class RetryStrategy(Protocol):
def should_retry(self, attempt: int, error: Exception) -> bool: ...
def delay(self, attempt: int) -> float: ...
class ExponentialBackoff:
def __init__(self, max_retries=3, base_delay=1.0):
self.max_retries = max_retries
self.base_delay = base_delay
def should_retry(self, attempt, error):
return attempt < self.max_retries
def delay(self, attempt):
return self.base_delay * (2 ** attempt)
class NoRetry:
def should_retry(self, attempt, error):
return False
def delay(self, attempt):
return 0
class APIClient:
def __init__(self, base_url, retry_strategy=None):
self.base_url = base_url
self.retry = retry_strategy or NoRetry()
def request(self, endpoint):
attempt = 0
while True:
try:
print(f"GET {self.base_url}/{endpoint} (attempt {attempt + 1})")
# In real code: response = requests.get(...)
return {"status": "ok"}
except Exception as e:
if not self.retry.should_retry(attempt, e):
raise
delay = self.retry.delay(attempt)
print(f" Retrying in {delay}s...")
attempt += 1
The caller picks the strategy:
# Production - retry with backoff
client = APIClient("https://api.example.com", ExponentialBackoff(max_retries=5))
# Tests - fail fast
client = APIClient("https://api.example.com", NoRetry())
Repository Pattern¶
The repository pattern abstracts data access behind a consistent interface. Your business logic calls repo.get(id) without knowing whether the data comes from a file, a database, or memory:
from dataclasses import dataclass, field
@dataclass
class User:
username: str
role: str = "viewer"
class UserRepository:
"""In-memory repository for demonstration. Swap in a database-backed
version by implementing the same get/save/list_all interface."""
def __init__(self):
self._store: dict[str, User] = {}
def save(self, user: User):
self._store[user.username] = user
def get(self, username: str) -> User:
if username not in self._store:
raise KeyError(f"No user: {username}")
return self._store[username]
def list_all(self) -> list[User]:
return list(self._store.values())
In production, you would create a PostgresUserRepository with the same get, save, and list_all methods. The calling code never changes. This is composition and protocols working together - define a Repository protocol, and any backend that implements the methods satisfies the contract.
Singletons are usually a code smell
The singleton pattern (ensuring a class has only one instance) is frequently misused in Python. Module-level variables already provide singletons naturally - just put the instance in a module. If you find yourself writing a __new__ override to prevent multiple instances, step back and ask whether a module-level object or dependency injection would be simpler.
Further Reading¶
- Python Data Model - the official reference for all dunder methods and special attributes
- dataclasses Module - full documentation for
@dataclass,field(), and related functions - typing.Protocol - structural subtyping and protocol classes
- Real Python: OOP in Python - practical OOP tutorial with additional examples
- Brandon Rhodes: The Composition Over Inheritance Principle - in-depth exploration of when to choose composition
Previous: Testing and Tooling | Next: Functions and Modules | Back to Index