Introduction
Up until now, we've covered Creational, Structural, and Behavioral design patterns. These foundational pillars have offered insights into crafting elegant, maintainable, and scalable Python applications. Yet, as we delve deeper into the nuances of Python, there emerge some design patterns that are unique to the language itself — the Python-specific design patterns.
This is the third article in a short series dedicated to Design Patterns in Python.
Python's expressive syntax and dynamic nature have led to the birth of certain patterns that might not be as prevalent or even existent in other programming languages. These patterns tackle challenges specific to Python development, offering developers a more Pythonic way to solve problems.
In this final article of our design patterns series, we'll dive into the following patterns:
Global Object Pattern
When developing applications, especially those of considerable complexity, we often find ourselves in scenarios where we need to share an object's state across different parts of the system. While global variables can serve this purpose, they're generally frowned upon due to the complications and unpredictability they can introduce.
Instead, the Global Object Pattern presents a more controlled and elegant solution to this dilemma. At its core, this pattern aims to provide a singular shared instance of an object across the entire application, ensuring that the state remains consistent and synchronized.
Imagine you're designing a logging system for an application. It's crucial for the logger to maintain consistent configurations (like log levels or output formats) throughout various modules and components. Instead of creating new logger instances or passing the logger around, it would be beneficial to have a single, globally accessible logger instance that maintains the shared configurations.
The Global Object Pattern typically leverages the Singleton pattern (which we explained earlier in this lesson) to ensure a class has only one instance and provides a global point to access it. The main advantage of using this pattern is the control and predictability it offers. Changes made to the global object from one module will reflect in all others, ensuring synchronized behavior.
Let's create the global logger from our example using the Global Object pattern:
class GlobalLogger:
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(GlobalLogger, cls).__new__(cls, *args, **kwargs)
return cls._instance
def __init__(self):
self.log_level = "INFO"
def set_log_level(self, level):
self.log_level = level
def log(self, message):
print(f"[{self.log_level}] - {message}")
Here, GlobalLogger
will always return the same instance, ensuring that the configuration state is consistent throughout the application:
logger1 = GlobalLogger()
logger1.log("This is an info message.")
logger2 = GlobalLogger()
logger2.set_log_level("ERROR")
logger2.log("This is an error message.")
logger1.log("This message also shows as an error.") # The log level has been globally updated
This will give us:
[INFO] - This is an info message.
[ERROR] - This is an error message.
[ERROR] - This message also shows as an error.
Prebound Method Pattern
One of the alluring aspects of Python's dynamic nature is its ability to create and manipulate functions and methods at runtime. Often, we need methods that, when called, behave according to a specific context or data they were initially associated with.
This is where the Prebound Method Pattern comes into play. It allows us to bind a method to some data or context ahead of time, so when the method is eventually called, it inherently knows its context without explicitly being told.
Think of an event-driven system, like a GUI toolkit, where different UI components trigger specific actions when interacted with. Suppose you have a set of buttons, and each button, when clicked, should display its label.
Instead of crafting separate methods for each button, you can use a single method but prebind it to the respective button's data, allowing the method to inherently "know" which button triggered it and what label it should display.
The Prebound Method Pattern focuses on binding methods to specific data or context well in advance of the method's execution. The method, once bound, doesn't need explicit context passed in during invocation; instead, it operates on the prebound data, ensuring a seamless and elegant interaction.
Let's see how this works in action. We'll create the Button
class that contains the label and one method that handles clicks. When the button is clicked, its label gets printed out:
class Button:
def __init__(self, label):
self.label = label
# Prebinding the display_label method to the current instance
self.click_action = lambda: self.display_label(self)
def display_label(self, bound_button):
print(f"Button pressed: {bound_button.label}")
def click(self):
self.click_action()
To test this out, let's create two different buttons, and "click" each of them:
buttonA = Button("Submit")
buttonB = Button("Cancel")
buttonA.click()
buttonB.click()
As expected, clicking each button produced the appropriate output:
Button pressed: Submit
Button pressed: Cancel
By enabling methods to be intimately aware of their context before invocation, the Prebound Method Pattern streamlines method calls and offers an intuitive approach to context-specific actions.
Sentinel Object Pattern
In software development, sometimes we're faced with the challenge of distinguishing between the absence of a value and a value that's actually set to None
or some other default. Simply relying on typical default values might not suffice.
The Sentinel Object Pattern offers a solution to this dilemma. By creating a unique, unmistakable object that serves as a sentinel, we can differentiate between genuinely absent values and default ones.
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
Consider a caching system where users can store and retrieve values. There's a challenge: how do you differentiate between a key that's never been set, a key that's set with a value of None
, and a key that's been evicted from the cache? In such a scenario, merely returning None
for a missing key can be ambiguous. Is None
the actual value associated with the key, or does the key not exist in the cache at all? By leveraging the Sentinel Object Pattern, we can provide clarity in these situations.
The Sentinel Object Pattern revolves around creating a unique object that can't be confused with any legitimate data in your application. This object becomes the unmistakable sign that a particular condition, like a missing value, has been met:
# Our sentinel object
MISSING = object()
class Cache:
def __init__(self):
self._storage = {}
def set(self, key, value):
self._storage[key] = value
def get(self, key):
# Return the value if it exists, otherwise return the sentinel object
return self._storage.get(key, MISSING)
Now we differentiate the missing and None
values. When we add an object with None
as a value to a Cache
object, we'll be able to find it by searching for it using its key:
# Usage
cache = Cache()
cache.set("username", None)
# Fetching values
result = cache.get("username")
if result is MISSING:
print("Key not found in cache!")
else:
print(f"Found value: {result}")
This will output the value of the object whose key is username
:
Found value: None
On the other hand, we won't be able to find a non-existent object:
missing_result = cache.get("non_existent_key")
if missing_result is MISSING:
print("Key not found in cache!")
This will give us:
Key not found in cache!
The Sentinel Object Pattern provides a clear way to represent missing or special-case values, ensuring that your code remains unambiguous and easy to understand.
Conclusion
In this article, we unearthed three distinctive patterns - the Global Object Pattern, the Prebound Method Pattern, and the Sentinel Object Pattern. Each of these patterns addresses challenges and scenarios unique to Python programming.
The Global Object Pattern underscores Python's flexible module system and the power of singletons in state management. The Prebound Method Pattern elegantly solves challenges around binding methods to class or instance objects, highlighting Python's object-oriented capabilities. Meanwhile, the Sentinel Object Pattern showcases Python's dynamism, providing a powerful tool for signaling special cases or default behaviors.
Accompanying real-world examples not only help illustrate the real-life applications of these patterns but also make their implementation in Python more tangible. After reding this article, you should be able to bridge the gap between conceptual understanding and practical application of Python-specific design patterns.