Open/Closed Principle
Overview
The `Open/Closed Principle` (`OCP`) states that software entities should be open for extension, but closed for modification. You should be able to add new functionality without changing existing code. In Python, this is typically achieved using: - `Inheritance`: Extending base classes. - `Composition`: Injecting different behaviors. - `Duck typing`: Determining object suitability by its methods rather than its type. For example, instead of a single function checking types: def process(item): if isinstance(item, Text): item.read() elif isinstance(item, Image): item.render() You rely on a common interface: def process(item): item.handle() Now, adding a `Video` class doesn't require modifying `process`. Think of a power strip: it is closed for modification (you don't rewire it to plug in a new device) but open for extension (you just plug a new adapter into an existing socket). class Video: pass # Forgot to implement handle() def process(item): item.handle() # Traps! Raises AttributeError process(Video()) Because Python uses duck typing, extending functionality without a strict interface risks an `AttributeError` at runtime if the new class forgets a required method. Using `abc.ABC` or `typing.Protocol` is the standard way to enforce these contracts (covered fully in protocols).
It prevents bugs from being introduced into tested, working code when requirements change. It also reduces coupling and makes codebases easier to maintain as they grow.
Where used: `FastAPI` dependencies, `Django` views, Plugin architectures
Why learn this
- Write systems that don't require rewriting core logic every time a new feature is requested
- Understand the underlying philosophy behind many design patterns like `Strategy` and `Factory`
Code walkthrough
class Logger:
def log(self, message):
print(f'LOG: {message}')
class App:
def __init__(self, logger: Logger):
self.logger = logger
def do_work(self):
self.logger.log('Work done')
app = App(Logger())
app.do_work()
Focus: self.logger = logger
Aha moment
class Formatter:
def format(self, text):
return text
class UpperFormatter(Formatter):
def format(self, text):
return text.upper()
def print_message(text, formatter=Formatter()):
print(formatter.format(text))
print_message('hello', UpperFormatter())
Prediction: What will `print_message` output?
Common guess: `'hello'`
Because we passed `UpperFormatter()`, the function delegates to it, resulting in `'HELLO'`. We changed the function's behavior without altering its source code.
Common mistakes
- Using endless `if-elif` chains: Adding a new condition means modifying the existing function. The fix is to extract the conditional logic into separate classes or functions that share an interface.
- Over-engineering prematurely: Applying `OCP` before the need for extension is proven can lead to unnecessary abstraction. Only abstract when you actually need to add a second or third variant.
Glossary
- coupling
- The degree to which different parts of a program rely on or are connected to each other; lower is usually better. Example: `process(MyClass())`.
- Strategy and Factory
- Common design patterns; `Strategy` lets you swap out behaviors on the fly, while `Factory` helps create new objects without exposing construction logic. Example: `EmailNotifier()`.
Recall questions
- What does it mean for code to be "closed for modification"?
- What is a common sign that a function violates the `Open/Closed Principle`?
- How does `OCP` improve the stability of a codebase?
- What is the primary risk of using duck typing to implement `OCP` in Python?
Understanding checks
In a notification system, what happens to the core `NotificationService` when a new `SlackNotification` class is added in an `OCP`-compliant design?
The core `NotificationService` does not need to be modified at all. Because new notification types implement the same shared interface, they plug in as extensions rather than requiring changes to existing tested code.
Because the system is "closed for modification" but "open for extension". The `NotificationService` relies on a shared interface. Adding a new `SlackNotification` simply extends the system by introducing a new adapter, without touching the existing, tested service code.
A developer created this code to process different file types. Why does it violate the `Open/Closed Principle`?
It uses an `if-elif` chain that checks `file_obj.type`. Adding any new file type requires editing the `process_file` function itself, which violates `OCP`'s rule that existing code should not need to change.
To support a new file type (like `xml`), you must modify the `process_file` function itself. This violates `OCP`, which states that code should be closed for modification. Instead, this should be refactored to use polymorphism, where each file object shares a common `parse()` interface.
Practice tasks
Refactor to OCP
You have a `DiscountCalculator` with an `if type == 'percent': ... elif type == 'fixed': ...` block. Refactor this to use OCP by creating a base `Discount` class and specific discount subclasses.
Challenge
Implement an OCP-compliant Notification System
Create a `Notifier` base class with a `send(message)` method. Then create `EmailNotifier` and `SMSNotifier` subclasses. Finally, write a `SystemAlert` class that accepts a list of notifiers and sends an alert message to all of them.
Continue learning
Previous: Class Creation and Instantiation
Previous: Single inheritance and super()