Object-Oriented Programming in Python: Classes and dataclasses
I modeled a small automation domain with Python classes, abstract base classes, and dataclasses for value objects. An explicit object model with clear interfaces made the system easy to extend and unit-test without touching existing code.
Objective & Context
OOP organizes state and behaviour behind interfaces. This lab covers instance vs class attributes, inheritance, polymorphism via abstract base classes, and boilerplate-free value types with @dataclass, applying the open/closed principle to automation components.
Learning Objectives
- Define interfaces with abc.ABC and @abstractmethod.
- Model value objects with @dataclass.
- Use inheritance and polymorphism for extensible dispatch.
- Separate data (dataclasses) from behaviour (services).
- Verify polymorphic behaviour with pytest.
Environment & Prerequisites
Prerequisites
Python 3.11withdataclassesandabc(standard library)- A domain with multiple related entity types to model
pytestfor behaviour verification- Assumed skill: functions and modules
Step-by-Step Execution
1. Define an interface with abc
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, msg: str) -> bool: ...
Expected output:
(no output; Notifier ABC defined)
What this teaches: An abstract base class turns an informal contract into an enforced one: subclasses must implement send() or they cannot be instantiated.
2. A dataclass value object
cat > alert.py <<'EOF'
from dataclasses import dataclass
@dataclass
class Alert:
ip: str
sev: int
if __name__ == "__main__":
print(Alert("1.2.3.4", 2))
EOF
python alert.pyExpected output:
Alert(ip='1.2.3.4', sev=2)
What this teaches: The dataclass generated __init__, __repr__, and __eq__ from two field declarations; the readable repr is free and invaluable in logs. Writing real files instead of one-liners keeps the examples runnable and reviewable.
3. Polymorphic dispatch
cat > notify.py <<'EOF'
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, msg: str) -> bool: ...
class SlackNotifier(Notifier):
def send(self, msg: str) -> bool:
print(f"slack: {msg}")
return True
EOF
python -c "from notify import SlackNotifier; print(SlackNotifier().send('up'))"Expected output:
slack: up
True
What this teaches: Polymorphic dispatch means the caller works against the Notifier interface while each subclass decides how sending works; adding a new channel needs no caller changes. The abstract method blocks instantiating a half-built notifier, so missing implementations fail at construction time, not at 3am.
How It Works
How It Works
Abstract base classes formalize the dependency inversion idea: high-level code depends on the Notifier abstraction, not on Slack or email details. @abstractmethod makes the contract compiler-enforced at instantiation time rather than documentation-only.
Dataclasses remove the boilerplate tax on value objects. A class that only carries data gets __init__, __repr__, and __eq__ generated from field declarations, and frozen=True additionally makes instances immutable and hashable.
Polymorphism pays off when the set of variants grows. Each new notifier is a new class implementing send(); the dispatching code never changes, which is the open/closed principle in its most practical form.
send(msg) abstract"] --> SL["SlackNotifier"] ABC --> EM["EmailNotifier"] ABC --> PG["PagerNotifier"] DC["Alert dataclass
ip, sev"] --> USE["dispatch(Alert)"] SL --> USE EM --> USE
Validation & Testing
Add a new Notifier subclass without modifying the dispatcher and confirm it works via the shared interface. Pass criteria: subclasses are interchangeable, dataclasses compare by value, and abstract methods enforce the contract.
Advanced: Troubleshooting
- Cannot instantiate ABC: implement all abstract methods in the subclass.
- Mutable dataclass default: use
field(default_factory=list). - Shared class attribute mutated: initialize per-instance state in
__init__.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| TypeError: Can't instantiate abstract class | a subclass missed one @abstractmethod | implement every abstract method or mark the subclass abstract too |
| dataclass equality surprises | eq defaults to True and compares all fields | set eq=False or compare selected fields when identity semantics are needed |
| mutable dataclass field shares state | field default like [] is shared across instances | use field(default_factory=list) instead |
| isinstance checks everywhere | dispatch logic leaked into callers | push the variation into polymorphic methods instead of branching on type |
| super().__init__() missed in subclass | parent initialization never ran | call super().__init__() first in every overriding __init__ |
Key Results
- Modeled the domain behind a single abstract interface.
- Added new behaviour via subclassing with zero changes to callers.
- Eliminated boilerplate with dataclass value objects.
- Verified polymorphism and value equality with unit tests.
Key Takeaways
- ABCs turn informal contracts into enforced interfaces.
- Dataclasses generate boilerplate (__init__, __repr__, __eq__) from field declarations.
- Polymorphism lets callers depend on abstractions while variants multiply.
- Separate data carriers (dataclasses) from behaviour (services).
- New variants should require new classes, not edits to dispatch code.
Practice Challenges
Challenge 1: Frozen alert
Goal: Make Alert frozen and hashable, then use alerts as dict keys in a deduplication pass.
Hint: frozen=True gives immutability and a hash; verify duplicates collapse.
Challenge 2: Third channel
Goal: Add a PagerNotifier subclass and prove the existing dispatch code needs zero changes.
Hint: Inherit Notifier, implement send(), and pass it to the same caller.