Understanding The Gang of Four (GOF) design patterns using Python — Part 4
This is the 13th post in a series of learning the Python programming language.
Proxy Design Pattern
The proxy pattern is a structural design pattern that provides a substitute or placeholder for another object to control access to it. In other words, it acts as an interface to the real object and protects access to the real object from unauthorized access.
For example, consider a scenario where a user wants to access a remote server to get some data, but you don’t want to allow direct access to the remote server because it is slow or not reliable. In this case, you can use a proxy server to act as an intermediary between the client and the remote server. The proxy server can cache the data and return it to the client, reducing the load on the remote server and improving the performance of the system.
The following code shows a simple implementation of the proxy pattern in Python:
class RemoteServer:
def get_data(self):
return "Data from the remote server"
class ProxyServer:
def __init__(self):
self.remote_server = RemoteServer()
self.cached_data = None
def get_data(self):
if self.cached_data is None:
self.cached_data = self.remote_server.get_data()
return self.cached_data
proxy = ProxyServer()
print(proxy.get_data())
In the above code, the RemoteServer
class represents the real object that provides the data. The ProxyServer
class acts as a proxy for the RemoteServer
object and provides a cached version of the data. The get_data
method of the ProxyServer
class first checks if the cached data is available. If the cached data is not available, it retrieves the data from the remote server and caches it for future use.
The advantages of using the Proxy design pattern are:
- Control access: The proxy provides a layer of abstraction that can be used to control access to the original object. This can be useful for security, for example, to ensure that only authorized clients are able to access the original object.
- Performance optimization: The proxy can be used to optimize the performance of an application. For example, it can cache the results of expensive operations, so that they don’t have to be repeated every time they are needed.
- Remote access: The proxy can be used to provide remote access to an object. This can be useful, for example, to provide access to a service or resource that is located on a different network or in a different location.
The disadvantages of using the Proxy design pattern are:
- Increased complexity: The use of a proxy can increase the complexity of an application, as it introduces an extra layer of abstraction.
- Maintaining consistency: It can be challenging to maintain consistency between the proxy and the original object, especially if the original object is updated frequently.
- Performance overhead: The use of a proxy can introduce performance overhead, as it requires an extra layer of processing.
The proxy pattern can be used in many different scenarios, such as to control access to an expensive resource, to provide security and authorization, and to optimize resource usage.
Chain of Responsibility Design Pattern
The Chain of Responsibility pattern is a behavioral design pattern that allows multiple objects to handle a request without having to know the specifics of who will handle it. It creates a chain of objects, where each object has the opportunity to handle the request or pass it on to the next object in the chain.
For example, consider a scenario where a user wants to withdraw money from an ATM machine. The request first goes to the highest denomination dispenser, and if it cannot handle the request completely, it passes the request on to the next lower denomination dispenser. If no dispenser can handle the request, the withdrawal is declined.
The following code shows a simple implementation of the Chain of Responsibility pattern in Python:
class Dispenser:
def __init__(self, denomination, limit, next=None):
self.denomination = denomination
self.limit = limit
self.next = next
def handle_request(self, request):
if request >= self.denomination and self.limit > 0:
num = min(request // self.denomination, self.limit)
remainder = request - num * self.denomination
self.limit -= num
print(f"Dispensing {num} x {self.denomination} notes")
if remainder == 0:
return
elif self.next is not None:
self.next.handle_request(remainder)
return
if self.next is not None:
self.next.handle_request(request)
else:
print("Request cannot be handled")
dispenser_10 = Dispenser(10, 10)
dispenser_20 = Dispenser(20, 5, dispenser_10)
dispenser_50 = Dispenser(50, 2, dispenser_20)
dispenser_100 = Dispenser(100, 6, dispenser_50)
dispenser_100.handle_request(490)
Output:
Dispensing 4 x 100 notes
Dispensing 1 x 50 notes
Dispensing 2 x 20 notes
In the above code, the Dispenser
class represents a dispenser that can dispense banknotes of a certain denomination, with a limit on the number of notes it can dispense. Each dispenser can pass on the request to the next dispenser in the chain until the request can be fully handled or there are no more dispensers in the chain.
Advantages of the Chain of Responsibility design pattern:
- Loose Coupling: The Chain of Responsibility pattern provides loose coupling between the sender and the receiver of a request. The sender does not need to know the details of the receiver’s implementation.
- Flexibility: The Chain of Responsibility pattern provides great flexibility, as the receiver of a request can be changed at runtime, or multiple receivers can handle the same request.
- Single Responsibility Principle: The Chain of Responsibility pattern follows the Single Responsibility Principle as it separates the responsibilities of creating a request, managing the request queue, and handling a request.
- Improved Reusability: The Chain of Responsibility pattern can improve reusability, as it allows you to reuse handlers in multiple chains, or to use them for different types of requests.
- Better Support for Undo/Redo: The Chain of Responsibility pattern provides better support for undo/redo behavior, as each handler can store information about the request it handles, making it easier to implement undo/redo functionality.
Disadvantages of the Chain of Responsibility design pattern:
- Increased Complexity: The Chain of Responsibility pattern can add complexity to an application, as it requires creating multiple objects for each request and managing the request queue.
- Overhead: The Chain of Responsibility pattern can add overhead to an application, especially if the application has a large number of requests or complex chains.
- Debugging: Debugging a chain of responsibility can be challenging, as it requires understanding the relationships between multiple handlers and the request queue.
The Chain of Responsibility pattern provides several benefits, including reducing coupling between the sender and the receiver of a request, making it easy to add new handlers without affecting existing code, and allowing multiple handlers to handle the same request.
Command Design Pattern
The command design pattern is a behavioral design pattern that converts requests or simple operations into objects. The main idea behind this pattern is to encapsulate the request as an object, and pass it around, which can result in decoupling the requester of the operation from the object that actually performs the operation.
In Python, the Command design pattern can be implemented using the following steps:
- Create a Command interface with a method
execute()
.
class Command:
def execute(self):
pass
- Create concrete commands that implement the
execute()
method, and perform specific operations.
class ConcreteCommandA(Command):
def __init__(self, receiver):
self._receiver = receiver
def execute(self):
self._receiver.actionA()
class ConcreteCommandB(Command):
def __init__(self, receiver):
self._receiver = receiver
def execute(self):
self._receiver.actionB()
- Create a Receiver class with the actions that the commands will trigger.
class Receiver:
def actionA(self):
print("Receiver performing action A")
def actionB(self):
print("Receiver performing action B")
- Create an Invoker class that will hold the command and execute it.
class Invoker:
def set_command(self, command):
self._command = command
def execute_command(self):
self._command.execute()
- Use the Client to tie everything together by creating the receiver, commands, and invoker, and then setting the command on the invoker and executing it.
def main():
receiver = Receiver()
commandA = ConcreteCommandA(receiver)
commandB = ConcreteCommandB(receiver)
invoker = Invoker()
invoker.set_command(commandA)
invoker.execute_command()
invoker.set_command(commandB)
invoker.execute_command()
if __name__ == "__main__":
main()
This example would output:
Receiver performing action A
Receiver performing action B
Advantages of the Command design pattern:
- Encapsulation of Request: The Command design pattern encapsulates the request as an object, making it easy to pass requests as method arguments, queue or store them for later execution, and manage the undo/redo behavior.
- Loose Coupling: The Command pattern provides loose coupling between the invoker and the receiver of a request. The invoker does not need to know the details of the receiver’s implementation.
- Single Responsibility Principle: The Command pattern follows the Single Responsibility Principle as it separates the responsibilities of creating a request, managing the request queue, and executing a request.
- Flexibility: The Command pattern provides great flexibility as it allows you to change the receiver of a request at runtime, or to change the request itself.
- Adding New Commands: The Command pattern makes it easy to add new commands to an application, as the invoker and the receiver do not depend on each other, and can be extended independently.
Disadvantages of the Command design pattern:
- Increased Complexity: The Command pattern can add complexity to an application, as it requires creating multiple objects for each request and maintaining a command queue.
- Overhead: The Command pattern can add overhead to an application, especially if the application has a large number of requests or complex undo/redo behavior.
- Memory Management: The Command pattern requires proper memory management, as commands that have been executed need to be managed, stored, and disposed of properly.
The Command design pattern is useful for implementing undo/redo operations, implementing deferred execution of operations, and for breaking down complex operations into smaller, simpler pieces.
Interpreter Design Pattern
The Interpreter design pattern is a behavioral design pattern that defines a way to evaluate sentences in a language. The pattern provides a way to define the grammar of the language, parse input sentences into a syntax tree, and evaluate the parse tree to produce the result. This pattern is used to implement languages for specific domains, such as mathematical expressions, SQL statements, and programming languages.
In Python, the Interpreter design pattern can be implemented by defining a set of classes that represent the grammar of the language. These classes can include abstract syntax tree nodes, non-terminal expressions, terminal expressions, and a parse tree evaluator.
Here’s an example implementation of the Interpreter design pattern in Python for a simple arithmetic expression language:
class Expression:
def interpret(self, context):
pass
class NumberExpression(Expression):
def __init__(self, number):
self.number = number
def interpret(self, context):
context.push(self.number)
return self.number
class AddExpression(Expression):
def __init__(self, left, right):
self.left = left
self.right = right
def interpret(self, context):
left = self.left.interpret(context)
right = self.right.interpret(context)
result = left + right
context.push(result)
class SubtractExpression(Expression):
def __init__(self, left, right):
self.left = left
self.right = right
def interpret(self, context):
left = self.left.interpret(context)
right = self.right.interpret(context)
result = left - right
context.push(result)
class Context:
def __init__(self):
self.stack = []
def push(self, value):
self.stack.append(value)
def pop(self):
return self.stack.pop()
def main():
context = Context()
expressions = [NumberExpression(7),
NumberExpression(5),
AddExpression(NumberExpression(3), NumberExpression(2)),
SubtractExpression(NumberExpression(7), NumberExpression(5))
]
for expression in expressions:
expression.interpret(context)
result = context.pop()
print("Result:", result)
if __name__ == '__main__':
main()
Output:
Result: 2
In this example, the expression language consists of numbers and two operations: addition and subtraction. The Expression
class is the abstract syntax tree node and the base class for all expressions in the language. The NumberExpression
class represents a terminal expression that evaluates to a number. The AddExpression
and SubtractExpression
classes represent non-terminal expressions that evaluate the sum or difference of their operands, respectively. The Context
class is a stack-based data structure that holds the values of the intermediate results during the evaluation of the parse tree.
The main
function creates a Context
object defines a list of expressions to evaluate, and interprets each expression in the list by calling its interpret
method. The result of the evaluation is stored in the Context
object and the final result is popped from the Context
stack and printed to the screen.
The example demonstrates the basic concepts of the Interpreter design pattern, including the use of classes to define the grammar of the language, the use of the interpret
method to evaluate the parse tree, and the use of a context object to store the intermediate results.
In real-world applications, the Interpreter design pattern can be used to implement complex domain-specific languages, such as scripting languages, query languages, or rule-based systems. Defining a grammar and an evaluation mechanism, allows you to create a powerful and flexible language that can be used to perform complex operations in a concise and expressive way.
Advantages of Interpreter Design Pattern:
- Abstraction: Interpreter Design Pattern provides a high level of abstraction and encapsulation for the language implementation, making it easier for developers to maintain the code and make changes as needed.
- Separation of Concerns: Interpreter Design Pattern separates the implementation of the language from the evaluation of expressions, making it easier to understand the code and debug issues.
- Flexibility: The interpreter Design Pattern is highly flexible, as it can be used to implement any language, regardless of its syntax and structure.
- Reusability: The Interpreter Design Pattern can be used in multiple projects, making it easier to reuse the code and reduce development time.
Disadvantages of Interpreter Design Pattern:
- Performance: The interpreter Design Pattern can be slow compared to other design patterns, as it requires evaluating expressions one by one.
- Complexity: The Interpreter Design Pattern can be complex to implement, especially for languages with complex syntax and structure.
- Maintenance: The Interpreter Design Pattern can be difficult to maintain, as any changes made to the language or syntax will require changes in the code.
- Debugging: Debugging issues with the Interpreter Design Pattern can be challenging, as the code is divided into multiple components, making it harder to identify the source of the problem.
The Interpreter design pattern is a powerful tool for creating and interpreting domain-specific languages. It provides a way to define the grammar of a language, parse input sentences, and evaluate expressions in a clear and concise way. Although it can be complex to implement, it provides a flexible and reusable solution for interpreting languages in a variety of domains.
If you like the post, don’t forget to clap. If you’d like to connect, you can find me on LinkedIn.
References:
Book “Design Patterns: Elements of Reusable Object-Oriented Software”