tel: +48 728 438 076
email: piotr.hyzy@eviden.com
Początek¶
Kilka zasad¶
- W razie problemów => chat, potem SMS i telefon, NIE mail
- Materiały szkoleniowe
- Wszystkie pytania są ok
- Reguła Vegas
- Słuchawki
- Kamerki
- Chat
- Zgłaszamy wyjścia na początku danego dnia, także pożary, wszystko na chacie
- By default mute podczas wykład
- Przerwy (praca 08:30 - 16:30)
- blok 08:30 - 10:00
- kawowa 10:00 - 10:15 (15')
- blok 10:15 - 11:45
- kawowa 11:45 - 12:00
- blok 12:00 - 13:30
- obiad 13:30 - 14:00
- blok 14:00 - 15:15
- kawowa 15:15 - 15:30
- blok 15:30 - 16:30
- wszystkie czasy są plus/minus 10'
- Jak zadawać pytanie? 1) przerwanie 2) pytanie na chacie 3) podniesienie wirtualnej ręki
- IDE => dowolne
- Każde ćwiczenie w osobnym pliku/Notebooku
- Nie zapraszamy innych osób
- Zaczynamy punktualnie
- Ćwiczenia w dwójkach, rotacje, ask for help
Rundka - sprawdzenie mikrofonów¶
Advanced OOP¶
Class & Object Attributes¶
Example 1 (Primitives)¶
from typing import Any
class Person:
phone = None
def __init__(self, name: str) -> None:
self.name = name
self.name
def foo(self):
return int(5)
p1 = Person('John')
p2 = Person('Alice')
p1.phone = 98765
print('p2.phone =', p2.phone)
print('p1.phone =', p1.phone)
print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)
print('Person.__dict__ =', Person.__dict__)
p2.phone = None p1.phone = 98765 p1.__dict__ = {'name': 'John', 'phone': 98765} p2.__dict__ = {'name': 'Alice'} Person.__dict__ = {'__module__': '__main__', 'phone': None, '__init__': <function Person.__init__ at 0x1044e6de0>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
Example 2 (List)¶
class Person:
phones = []
db = dict()
def __init__(self, name):
self.name = name
p1 = Person('Jan')
p2 = Person('Anna')
p1.phones.append(98765)
print('p1.phones =', p1.phones)
print('p2.phones =', p2.phones)
print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)
print('Person.__dict__ =', Person.__dict__)
p1.phones = [98765, 98765] p2.phones = [98765, 98765] p1.__dict__ = {'name': 'Jan'} p2.__dict__ = {'name': 'Anna'} Person.__dict__ = {'__module__': '__main__', 'phones': [98765, 98765], '__init__': <function Person.__init__ at 0x10aae9440>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
Example 3 (Replace List)¶
class Person:
phones = []
def __init__(self, name):
self.name = name
p1 = Person('Jan')
p2 = Person('Anna')
p1.phones = [98765]
print('p2.phones =', p2.phones)
print('p1.phones =', p1.phones)
print('p1.__dict__ =', p1.__dict__)
print('p2.__dict__ =', p2.__dict__)
print('Person.__dict__ =', Person.__dict__)
p2.phones = [] p1.phones = [98765] p1.__dict__ = {'name': 'Jan', 'phones': [98765]} p2.__dict__ = {'name': 'Anna'} Person.__dict__ = {'__module__': '__main__', 'phones': [], '__init__': <function Person.__init__ at 0x10aae9580>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
@staticmethod
Decorator¶
The @staticmethod
decorator in Python is used to define a method that belongs to a class but does not require access to the instance (self
) or class (cls
) itself. This means that a static method can be called on the class or instance, but it cannot modify the instance or class state.
- No
self
orcls
argument: A static method does not takeself
(instance reference) orcls
(class reference) as the first parameter. - Called on class or instance: You can call a static method using either the class or an instance of the class.
Static methods are useful when you need a method that logically belongs to a class but does not need to modify or access class-specific data.
class Person:
phone = None
def __init__(self, name):
self.name = name
def foo(self):
print(self.__dict__)
@classmethod
def boo(cls):
print(cls.__dict__)
p1 = Person('John')
p1.phone=123
p1.foo()
p1.boo()
{'name': 'John', 'phone': 123} {'__module__': '__main__', 'phone': None, '__init__': <function Person.__init__ at 0x10ab5d4e0>, 'foo': <function Person.foo at 0x10ab5d940>, 'boo': <classmethod(<function Person.boo at 0x10ab5da80>)>, '__dict__': <attribute '__dict__' of 'Person' objects>, '__weakref__': <attribute '__weakref__' of 'Person' objects>, '__doc__': None}
class MyClass:
@staticmethod
def foo():
print('metoda statyczna')
a = MyClass()
a.foo()
MyClass.foo()
metoda statyczna metoda statyczna
class MyClass:
@staticmethod
def greet(name):
return f"Hello, {name}!"
# Calling on the class
print(MyClass.greet("Alice")) # Outputs: Hello, Alice!
Hello, Alice!
# Calling on an instance
obj = MyClass()
print(obj.greet("Bob")) # Outputs: Hello, Bob!
Hello, Bob!
Exercise: Implement Point.get_distance
¶
Create a class Point
that represents a point in a 2D space with x
and y
coordinates. Implement a static method get_distance
that takes two Point
objects and calculates the Euclidean distance between them.
Example:
p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2)) # Outputs: 5.0
The formula for Euclidean distance is:
$$ \text{distance} = \sqrt{(x_2 - x_1)^2 + (y_2 - y_1)^2} $$#### Solution 1
from math import sqrt
class Point:
def __init__(self, x: float, y: float):
self.x = x
self.y = y
@staticmethod
def get_distance(a: 'Point', b: 'Point'):
return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)
p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2)) # Outputs: 5.0
5.0
### Solution 2
from dataclasses import dataclass # pip install dataclasses on Python 3.6 or ealier
from math import sqrt
@dataclass
class Point:
x: float
y: float
@staticmethod
def get_distance(a, b):
return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)
p1 = Point(0, 0)
p2 = Point(3, 4)
print(Point.get_distance(p1, p2)) # Outputs: 5.0
5.0
### Solution 3
from pydantic import BaseModel # pip install dataclasses on Python 3.6 or ealier
from math import sqrt
class Point(BaseModel):
x: float
y: float
@staticmethod
def get_distance(a, b):
return sqrt((a.x-b.x)**2 + (a.y-b.y)**2)
p1 = Point(x=5, y=7)
p2 = Point(x=1, y=4)
print(Point.get_distance(p1, p2)) # Outputs: 5.0
5.0
Special Methods¶
class Foo:
def __init__(self):
print('Constructor')
def __str__(self):
return 'Foo'
def __len__(self):
return 5
def __getitem__(self, key):
if key >= 5:
raise IndexError
return key + 2
f = Foo()
g = Foo()
print(f)
print(len(f))
print(len(g))
print(f[2])
print(f[6])
Constructor Constructor Foo 5 5 4
--------------------------------------------------------------------------- IndexError Traceback (most recent call last) Cell In[60], line 11 8 print(len(g)) 10 print(f[2]) ---> 11 print(f[6]) Cell In[58], line 13, in Foo.__getitem__(self, key) 11 def __getitem__(self, key): 12 if key >= 5: ---> 13 raise IndexError 14 return key + 2 IndexError:
__init__(self)
:- Called when an instance of the class is created. It's the constructor method.
- Example:
f = Foo()
will call__init__
and print'Constructor'
.
__str__(self)
:- Called by
str()
orprint()
to provide a readable, string representation of an object. - Example:
print(str(f))
will print'Foo!!!'
.
- Called by
__len__(self)
:- Called by
len()
to return the length of an object. - Example:
len(f)
will return5
.
- Called by
__getitem__(self, key)
:- Called when accessing elements using square brackets (e.g.,
f[key]
). - Example:
f[3]
will return5
because the method adds 2 to the key. If the key is5
or greater, it raises anIndexError
.
- Called when accessing elements using square brackets (e.g.,
getattr¶
The __getattr__
method in Python is invoked as a "last resort" when an attribute is not found in an instance or class namespace. If you try to access an attribute that doesn't exist, Python will first look in the instance's __dict__
, then the class's __dict__
, and if the attribute is still not found, it will call the __getattr__
method (if it is defined).
class Bar:
class_attr = "This is a class attribute"
def __init__(self):
self.instance_attr = 42 # This is an instance attribute
def __getattr__(self, name):
# This is only called if the attribute isn't found in the instance or class
return f"'{name}' attribute not found, returning from __getattr__"
# Creating an instance
b = Bar()
# Accessing instance attribute
print(b.instance_attr)
42
# accesing instance method
print(b.__init__)
<bound method Bar.__init__ of <__main__.Bar object at 0x104797800>>
# Accessing class attribute
print(b.class_attr)
This is a class attribute
# Accessing a non-existent attribute
print(b.non_existent_attr) # Outputs "'non_existent_attr' attribute not found, returning from __getattr__"
'non_existent_attr' attribute not found, returning from __getattr__
Breakdown of the attribute search:¶
obj.instance_attr
: Found in the instance, so it returns the value42
.obj.class_attr
: Not found in the instance, but found in the class, so it returns"This is a class attribute"
.obj.non_existent_attr
: Not found in either the instance or the class, so Python calls__getattr__
, which returns the custom message.
This demonstrates how Python looks for an attribute first in the instance, then in the class, and finally resorts to __getattr__
if it isn't found.
setattr¶
The __setattr__
method is another special method in Python that is called every time an attribute is set on an object. Unlike __getattr__
or __getattribute__
, which deal with retrieving attributes, __setattr__
is triggered whenever you attempt to assign a value to an attribute, whether the attribute exists or not.
Here's an example demonstrating how __setattr__
works:
class MyClass:
def __init__(self):
# Setting an instance attribute in the normal way
# Since __setattr__ is used for any assignment, even in __init__ it is called
self.instance_attr = 42
def __setattr__(self, name, value):
# This will be called for every attribute assignment
print(f"__setattr__ called for setting '{name}' to {value}")
# Using the default behavior by calling the parent class's __setattr__
super().__setattr__(name, value)
# Creating an instance of MyClass
obj = MyClass()
__setattr__ called for setting 'instance_attr' to 42
# Setting an existing attribute
obj.instance_attr = 100 # This will trigger __setattr__
__setattr__ called for setting 'instance_attr' to 100
# Setting a new attribute
obj.new_attr = "Hello" # This will also trigger __setattr__
__setattr__ called for setting 'new_attr' to Hello
Example with Custom Logic in setattr¶
You can customize the behavior of setattr to implement checks, logging, or other special behaviors when setting attributes:
class MyClass:
def __init__(self):
self.instance_attr = 42
def __setattr__(self, name, value):
if name == "instance_attr" and value < 0:
raise ValueError(f"Cannot set '{name}' to a negative value!")
print(f"Setting attribute '{name}' to {value}")
super().__setattr__(name, value)
# Creating an instance of MyClass
obj = MyClass()
# Setting an attribute with a valid value
obj.instance_attr = 100 # Outputs: Setting attribute 'instance_attr' to 100
Setting attribute 'instance_attr' to 42 Setting attribute 'instance_attr' to 100
# Attempting to set a negative value (triggers a custom check)
try:
obj.instance_attr = -1 # This will raise a ValueError
except ValueError as e:
print(e)
Cannot set 'instance_attr' to a negative value!
Conclusion:¶
__setattr__
is triggered for every attribute assignment.- You can customize
__setattr__
to handle specific logic, such as validation or logging, when setting attributes. - Always use
super().__setattr__
to perform the actual assignment inside the method to avoid infinite recursion.
Exercise: Implement the Record
Class¶
Your task is to implement a class Record
that allows access to its internal data through both attribute-style access (using .
notation) and dictionary-style access (using []
notation).
Requirements:¶
Attribute Access:
- You should be able to access data using attribute-style syntax, e.g.,
obj.key
. - You should be able to assign values using the same attribute-style syntax, e.g.,
obj.key = value
.
- You should be able to access data using attribute-style syntax, e.g.,
Dictionary Access:
- You should be able to access data using dictionary-style syntax, e.g.,
obj['key']
. - You should be able to assign values using dictionary-style syntax, e.g.,
obj['key'] = value
.
- You should be able to access data using dictionary-style syntax, e.g.,
Data Storage:
- The class should store all data internally in a way that supports both attribute and dictionary-style access seamlessly.
Ensure:
- Both attribute-style and dictionary-style access refer to the same underlying data.
- Attribute access for internal mechanisms (like the storage of data itself) should not cause recursive behavior.
Example Usage:¶
r = Record()
# Dictionary-style access
r['name'] = "Alice"
print(r['name']) # Outputs: Alice
# Attribute-style access
r.age = 30
print(r.age) # Outputs: 30
# Both access styles should refer to the same underlying data
print(r['age']) # Outputs: 30
r['city'] = "New York"
print(r.city) # Outputs: New York
Solution¶
### Solution 1a (With Custom _data Dictionary):
class Record:
def __init__(self):
self._data = {}
def __getattr__(self, attr):
return self._data[attr]
def __setattr__(self, attr, value):
if attr == '_data':
super().__setattr__(attr, value)
else:
self._data[attr] = value
def __getitem__(self, item):
return self._data[item]
def __setitem__(self, item, value):
self._data[item] = value
r = Record()
# Dictionary-style access
r['name'] = "Alice"
print(r['name']) # Outputs: Alice
# Attribute-style access
r.age = 30
print(r.age) # Outputs: 30
# Both access styles should refer to the same underlying data
print(r['age']) # Outputs: 30
r['city'] = "New York"
print(r.city) # Outputs: New York
Alice 30 30 New York
# Solution 1b (With Cleaner Initialization):
class Record:
def __init__(self):
super().__setattr__('_data', {}) # Directly setting _data during initialization
def __getattr__(self, attr):
return self._data[attr]
def __setattr__(self, attr, value):
self._data[attr] = value
def __getitem__(self, item):
return self._data[item]
def __setitem__(self, item, value):
self._data[item] = value
r = Record()
# Dictionary-style access
r['name'] = "Alice"
print(r['name']) # Outputs: Alice
# Attribute-style access
r.age = 30
print(r.age) # Outputs: 30
# Both access styles should refer to the same underlying data
print(r['age']) # Outputs: 30
r['city'] = "New York"
print(r.city) # Outputs: New York
Alice 30 30 New York
# Solution 2 (Preferred - Inheriting from dict):
class Record(dict):
def __getattr__(self, attr):
return self[attr]
def __setattr__(self, attr, value):
self[attr] = value
r = Record()
# Dictionary-style access
r['name'] = "Alice"
print(r['name']) # Outputs: Alice
# Attribute-style access
r.age = 30
print(r.age) # Outputs: 30
# Both access styles should refer to the same underlying data
print(r['age']) # Outputs: 30
r['city'] = "New York"
print(r.city) # Outputs: New York
Alice 30 30 New York
### Solution 3 (Pseudo dict):
class Record:
def __getitem__(self, item):
return self.__dict__[item]
def __setitem__(self, item, value):
self.__dict__[item] = value
r = Record()
# Dictionary-style access
r['name'] = "Alice"
print(r['name']) # Outputs: Alice
# Attribute-style access
r.age = 30
print(r.age) # Outputs: 30
# Both access styles should refer to the same underlying data
print(r['age']) # Outputs: 30
r['city'] = "New York"
print(r.city) # Outputs: New York
Alice 30 30 New York
getattribute¶
The __getattribute__
method in Python is similar to __getattr__
in that it deals with attribute access, but it has an important difference:
__getattribute__
is called immediately whenever any attribute is accessed, regardless of whether it exists or not. It is the first method that Python checks when an attribute is accessed.__getattr__
, on the other hand, is only called after Python fails to find the attribute through normal means (i.e., it isn't found in the instance or class namespace).
Here's an example using __getattribute__
:
class Foo:
class_attr = "This is a class attribute"
def __init__(self):
self.instance_attr = 42
def __getattribute__(self, name):
# This will be called for every attribute access, including existing ones
print(f"__getattribute__ called for: {name}")
return super().__getattribute__(name) # Default behavior to avoid infinite recursion
# Creating an instance of Foo
f = Foo()
# Accessing instance attribute
print(f.instance_attr) # This will trigger __getattribute__ before returning 42
__getattribute__ called for: instance_attr 42
print(f.class_attr)
__getattribute__ called for: class_attr This is a class attribute
print(f.non_existent_attr) # This triggers __getattribute__ and then raises AttributeError
__getattribute__ called for: non_existent_attr
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[86], line 1 ----> 1 print(f.non_existent_attr) # This triggers __getattribute__ and then raises AttributeError Cell In[81], line 9, in Foo.__getattribute__(self, name) 7 def __getattribute__(self, name): 8 print(f"__getattribute__ called for: {name}") ----> 9 return super().__getattribute__(name) AttributeError: 'Foo' object has no attribute 'non_existent_attr'
Key points:¶
__getattribute__
is always called first, even if the attribute exists in the instance or class. You can think of it as the "first-glance" method for attribute access.- If you override
__getattribute__
, you need to be cautious and make sure to call the base class’s__getattribute__
method (viasuper()
) to avoid infinite recursion. - It gives you fine-grained control over attribute access but can be tricky to use if not handled properly.
In contrast, __getattr__
is only called as a fallback when an attribute cannot be found.
Callable Classes¶
In Python, callable classes are classes that behave like functions, meaning you can "call" an instance of the class as if it were a function. This is done by defining the __call__
method in the class.
__call__
Method:¶
- The
__call__
method allows an instance of a class to be called like a regular function. - When you write
obj()
, Python internally callsobj.__call__()
.
class MyCallableClass:
def __init__(self, *args, **kwargs):
print(f"Init: Called with arguments: {args} and keyword arguments: {kwargs}")
def __call__(self, *args, **kwargs):
print(f"Call: Called with arguments: {args} and keyword arguments: {kwargs}")
# Creating an instance
obj = MyCallableClass('a', 'b', x=4)
Init: Called with arguments: ('a', 'b') and keyword arguments: {'x': 4}
# Calling the instance as if it were a function
obj(1, 2, 3, name="Alice")
Call: Called with arguments: (1, 2, 3) and keyword arguments: {'name': 'Alice'}
Decorator Definition¶
import functools
def shouter(func):
print('wywolanie dekorowania funkcji')
@functools.wraps(func) # takes care of func.__doc__ and func.__name__
def wrapper(*args, **kwargs):
print('Before foo')
result = func(*args, **kwargs)
print('After foo')
return result
return wrapper
class Shouter:
def __init__(self, func):
print('Shouter.__init__')
self.func = func
def __call__(self, *args, **kwargs):
@shouter # => shouter(foo) => wrapper
def foo(a):
'''Docstring'''
print("Message:", a)
return 42
@Shouter # => Shouter(foo) => self = Shouter(foo)
def foo(a):
'''Docstring'''
print("Message:", a)
return 42
wywolanie dekorowania funkcji Shouter.__init__
result = foo(a="Inside") # delegates to wrapper()
Before foo Message: Inside After foo
### Supress exc eptionw ith use classic function decorator
def suppress_exceptions(func):
@functools.wraps(func) # takes care of func.__doc__ and func.__name__
# wrapper definition starts
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception:
return None
# wrapper definiton ends
return wrapper
Exercise: Implement SuppressExceptions
Class¶
Your task is to implement a class SuppressExceptions
that can be used as a decorator to wrap any function or method. The purpose of this decorator is to suppress all exceptions that may occur during the execution of the wrapped function and return None
in case an exception occurs. If no exception is raised, the function should behave normally and return its result.
Requirements:¶
Class Structure:
- Implement a class
SuppressExceptions
with an__init__
and__call__
method. - The
__init__
method should accept the function to be decorated. - The
__call__
method should execute the function with the provided arguments, suppress any exceptions, and returnNone
if an exception is raised.
- Implement a class
Functionality:
- The class should allow the decorated function to be called with any number of positional (
*args
) and keyword arguments (**kwargs
). - If an exception occurs while calling the decorated function, it should catch the exception and return
None
without raising the exception.
- The class should allow the decorated function to be called with any number of positional (
Exception Handling:
- Suppress all types of exceptions, not just specific ones.
Expected Behavior:
- When applied as a decorator to a function, it should print the
args
andkwargs
passed to the function, execute the function, and handle exceptions accordingly.
- When applied as a decorator to a function, it should print the
Example Usage:¶
@SuppressExceptions
def foo(a, b):
return a / b
# Testing the decorated function
print(foo(4, 2)) # Outputs: 2.0
print(foo(4, 0)) # Outputs: None (due to ZeroDivisionError)
print(foo(4, b=0)) # Outputs: None (due to ZeroDivisionError)
Hints:¶
- Use a
try-except
block inside the__call__
method to catch any exceptions and returnNone
. - Ensure the function arguments (
*args
and**kwargs
) are passed correctly to the decorated function.
### Supress exc eptionw ith use classic function decorator
def suppress_exceptions(func):
@functools.wraps(func) # takes care of func.__doc__ and func.__name__
# wrapper definition starts
def wrapper(*args, **kwargs):
try:
return func(*args, **kwargs)
except Exception:
return None
# wrapper definiton ends
return wrapper
class SuppressExceptions:
def __init__(self, func):
functools.wraps(func)(self)
self.func = func
def __call__(self, *args, **kwargs):
try:
# print('args =', args)
# print('kwargs =', kwargs)
return self.func(*args, **kwargs)
except Exception:
return None
@SuppressExceptions # foo = SupressException(foo)
def foo(a, b):
'''
doc string
'''
return a / b
# Testing the decorated function
print(foo(4, 2)) # Outputs: 2.0
print(foo(4, 0)) # Outputs: None (due to ZeroDivisionError)
print(foo(4, b=0)) # Outputs: None (due to ZeroDivisionError)
print(foo.__doc__)
2.0 None None doc string
Exercise: Implement SuppressExceptionsEx
Class¶
Your task is to implement a class-based parameterized decorator named SuppressExceptionsEx
that allows you to:
- Specify the type of exception you want to catch (defaults to catching all exceptions).
- Specify the return value in case the specified exception occurs (defaults to
None
if not specified).
The decorator should be able to wrap any function, suppress the specified exception, and return the specified value when that exception is raised.
Requirements:¶
Class Structure:
- Implement a class
SuppressExceptionsEx
that accepts the following parameters:exception
: The type of exception to catch (e.g.,ZeroDivisionError
). If not provided, it should catch all exceptions.value_on_exception
: The value to return if the specified exception occurs (defaults toNone
if not provided).
- The
__init__
method should store these parameters. - The
__call__
method should decorate a function and handle exceptions based on the provided parameters.
- Implement a class
Decorator Functionality:
- When applying the decorator, if the specified exception occurs, it should return the
value_on_exception
. - If another type of exception occurs, it should propagate that exception as usual.
- The function should accept and work with any number of positional (
*args
) and keyword arguments (**kwargs
).
- When applying the decorator, if the specified exception occurs, it should return the
Example Usage:¶
@SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0)
def egg(a, b):
return a / b
# Test cases
print(egg(4, 2)) # Outputs: 2.0
print(egg(4, 0)) # Outputs: 0.0 (ZeroDivisionError is caught)
print(egg("a", "b")) # Raises TypeError (since we're not catching TypeError)
Hints:¶
- Use a
try-except
block inside the__call__
method to handle the specified exception and return the desired value. - Ensure that the decorator works with multiple types of exceptions if no specific exception is provided.
- Be mindful of how you pass arguments (
*args
,**kwargs
) to the wrapped function.
class SuppressExceptionsEx:
def __init__(self, exception=Exception, value_on_exception=None):
self.exception = exception
self.value_on_exception = value_on_exception
def __call__(self, func):
def wrapper(*args, **kwargs):
try:
# print('args =', args)
# print('kwargs =', kwargs)
return func(*args, **kwargs)
except self.exception:
return self.value_on_exception
return wrapper
@SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0) # => SuppressExceptionsEx(exception=ZeroDivisionError, value_on_exception=0.0)(egg)
def egg(a, b):
return a / b
print(egg(4, 2)) # Outputs: 2.0
print(egg(4, 0)) # Outputs: 0.0 (ZeroDivisionError is caught)
2.0 0.0
@SuppressExceptionsEx()
def egg(a, b):
return a / b
print(egg(4, 2)) # Outputs: 2.0
print(egg(4, 0)) # Outputs: None (ZeroDivisionError is caught)
print(egg("a", "b")) # Outputs: None (TypeError is caught)
2.0 None None
@property
¶
The @property
decorator in Python provides a way to manage attributes with getter and setter methods in a clean, readable way, allowing you to treat method calls like attribute access. It helps to encapsulate logic for getting and setting attributes while keeping a user-friendly interface.
Example: Vector
Class¶
Consider a Vector
class representing a vector in a 2D space. Initially, we store the x
and y
components as well as the length of the vector. However, this leads to a broken Single Source of Truth issue — modifying the vector's coordinates (x
or y
) doesn’t update the length
automatically.
from math import sqrt
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
self.length = sqrt(self.x**2 + self.y**2) # Problem: length doesn't update when x or y changes
v = Vector(3, 4)
print(v.x, v.y, v.length)
3 4 5.0
# example of monkey patching
v.length = 10
print(v.x, v.y, v.length)
3 4 10
In this example, v.length = 10
sets a new length, but x
and y
don't adjust accordingly. This violates the Single Source of Truth, where the length should always be derived from the x
and y
values.
Fixing with Getter and Setter Methods¶
A better approach is to calculate the length dynamically based on the current x
and y
values, and update x
and y
when the length is modified. Here's a version using explicit getter and setter methods:
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
def get_length(self):
return sqrt(self.x**2 + self.y**2)
def set_length(self, new_length):
factor = new_length / self.get_length()
self.x *= factor
self.y *= factor
v = Vector(3, 4)
print(v.x, v.y, v.get_length())
3 4 5.0
v.set_length(10)
print(v.x, v.y, v.get_length())
6.0 8.0 10.0
Using @property
for a Cleaner Interface¶
We can make the class interface cleaner by using the @property
decorator. It allows us to treat the length as a regular attribute while still having control over how it is calculated and updated.
With @property
, you can access length
as if it were a simple attribute, but behind the scenes, it is computed dynamically. The setter allows you to update x
and y
when the length is changed.
class Vector:
def __init__(self, x, y):
self.x = x
self.y = y
@property
def length(self):
return sqrt(self.x**2 + self.y**2)
@length.setter
def length(self, new_length):
factor = new_length / self.length
self.x *= factor
self.y *= factor
v = Vector(3, 4)
print(v.x, v.y, v.length)
3 4 5.0
v.length = 10
print(v.x, v.y, v.length)
6.0 8.0 10.0
Benefits of @property
:¶
- Encapsulation: Encapsulates the logic of getting and setting attributes, keeping the interface clean.
- Single Source of Truth: Ensures the vector's length is always consistent with
x
andy
. - Intuitive Usage: Users of the class can interact with the object as if
length
were a simple attribute.
Inheriting from Immutable Types¶
In Python, immutable types like tuple
, int
, str
, etc., cannot be modified once created. However, it’s possible to inherit from these types and extend their functionality. This chapter explores how to create custom classes that inherit from immutable types and the considerations for memory efficiency and initialization.
Memory-Consuming Point
¶
When defining a class in Python, each instance has a __dict__
attribute that stores all the instance’s attributes. This can consume a significant amount of memory, especially when creating many objects with only a few attributes.
Consider the following Point
class:
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(2, 3)
print(p.__dict__)
print(p.__dict__.__sizeof__())
print(p.__sizeof__())
{'x': 2, 'y': 3} 280 16
Reducing Memory Overhead with __slots__
¶
To save memory, you can define __slots__
in the class. This tells Python to allocate space for a fixed set of attributes, avoiding the overhead of a __dict__
for each instance.
class Point:
__slots__ = ['x', 'y'] # No __dict__ will be created, saving memory
def __init__(self, x, y):
self.x = x
self.y = y
p = Point(2, 3)
# print(p.__dict__)
# print(p.__dict__.__sizeof__())
print(p.__sizeof__())
32
Inheriting from tuple
¶
Inheriting from immutable types like tuple
allows you to extend the behavior of these types, but it comes with limitations. Since tuple
is immutable, you cannot modify its elements after creation.
Consider this attempt to create a Point
class by inheriting from tuple
:
class Point(tuple):
def __init__(self, x, y):
self[0] = x # Attempting to modify a tuple element
self[1] = y
p = Point(2, 3)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[137], line 6 3 self[0] = x # Attempting to modify a tuple element 4 self[1] = y ----> 6 p = Point(2, 3) TypeError: tuple expected at most 1 argument, got 2
This will raise a TypeError
because tuples are immutable and cannot be modified
Creating Immutable Objects with __new__
¶
When inheriting from immutable types, you need to override the __new__
method rather than __init__
. The __new__
method is responsible for creating new instances of immutable types before they are initialized.
class Point(tuple):
def __new__(cls, x, y):
return super().__new__(cls, (x,y))
@property
def x(self):
return self[0]
@property
def y(self):
return self[1]
p = Point(2, 3)
print(type(p))
p[0]
p.x
print(p.__sizeof__())
<class '__main__.Point'> 40
Inheriting from int
¶
Inheriting from other immutable types, like int
, follows a similar approach. You can use the __new__
method to create new instances and add extra attributes. Let’s look at an example where we create an integer class with additional metadata
class IntegerWithMeta(int):
def __new__(cls, value, meta):
self = super().__new__(cls, value) # Create an integer object
self.meta = meta # Attach metadata to the object
return self
two = IntegerWithMeta(2, 'kg')
print(two)
print(type(two))
print(two.__dict__)
2 <class '__main__.IntegerWithMeta'> {'meta': 'kg'}
three = IntegerWithMeta(3, 'g')
print(two < three)
True
In this example:
IntegerWithMeta
inherits fromint
.- The
__new__
method creates the integer object and attaches additional metadata (e.g.,'kg'
for kilograms) to it. - You can still use this class like a regular integer, but with the added ability to store and access extra data (
meta
).
Conclusion¶
Inheriting from immutable types in Python requires careful use of the __new__
method to create objects. You cannot modify these objects after creation, but you can extend their functionality and add additional attributes like metadata. By using immutability effectively, you can ensure that certain objects remain unchanged throughout their lifetime, while still making them more useful and feature-rich.
Exercise: Implement a Point3D
¶
Your task is to create a class Point3D
that represents a point in 3D space. The point should be immutable, and you should define x
, y
, and z
as properties to access the coordinates. Additionally, implement a method distance_from_origin
that returns the Euclidean distance of the point from the origin (0, 0, 0)
.
Requirements:¶
- Inherit from
tuple
to make the point immutable. - Create
x
,y
, andz
as properties to access the coordinates. - Implement a method
distance_from_origin
that computes the Euclidean distance from the origin. - Ensure that the class is immutable and inherits from
tuple
.
Example Usage:¶
p = Point3D(3, 4, 5)
# Access the x, y, z coordinates
print(p.x) # Outputs: 3
print(p.y) # Outputs: 4
print(p.z) # Outputs: 5
# Calculate the distance from the origin
print(p.distance_from_origin()) # Outputs: 7.071 (approximately)
# Check immutability (should raise a TypeError if modification is attempted)
try:
p.x = 10 # This should raise an error because the object is immutable
except AttributeError as e:
print(e) # Outputs: "can't set attribute"
# Check inheritance
print(isinstance(p, tuple)) # Outputs: True (Point3D should inherit from tuple)
Hint:¶
- Use
sqrt(x**2 + y**2 + z**2)
to calculate the Euclidean distance in 3D. - Try accessing
p.x
,p.y
, andp.z
as properties and test if modifying these values raises an error to confirm immutability.
Solution¶
from math import sqrt
class Point3D(tuple):
def __new__(cls, x, y, z):
# We call the parent class tuple constructor with the values x, y, z as a tuple
return super().__new__(cls, (x, y, z))
@property
def x(self):
return self[0]
@property
def y(self):
return self[1]
@property
def z(self):
return self[2]
def distance_from_origin(self):
return sqrt(self.x**2 + self.y**2 + self.z**2)
# Testing the implementation
p = Point3D(3, 4, 5)
# Access the x, y, z coordinates
print(p.x) # Outputs: 3
print(p.y) # Outputs: 4
print(p.z) # Outputs: 5
# Calculate the distance from the origin
print(p.distance_from_origin()) # Outputs: 7.0710678118654755
# Check immutability (should raise a TypeError if modification is attempted)
try:
p.x = 10 # This should raise an error because the object is immutable
except AttributeError as e:
print(e) # Outputs: "can't set attribute"
# Check inheritance
print(isinstance(p, tuple)) # Outputs: True (Point3D should inherit from tuple)
3 4 5 7.0710678118654755 property 'x' of 'Point3D' object has no setter True
Descriptors¶
In Python, descriptors provide a powerful way to manage attribute access and can help solve certain problems related to attribute management that standard properties (@property
) might not address fully. Descriptors allow you to define custom behavior for getting, setting, and deleting attributes, making them more flexible than properties.
Point with @property
¶
Let's start with a common scenario using @property
. We want to create a Point
class that acts as a proxy for a JSON object. The JSON represents coordinates u
and v
, but we want the user to access them via the more intuitive x
and y
properties. We also want to ensure that:
- The
x
andy
values are always returned as floats. - Any update to
x
ory
should modify the underlying JSON. - Invalid data types (like strings) should raise an error, while integers should be converted to floats.
class Point:
def __init__(self, json: dict):
self.json = json
@property
def x(self):
return float(self.json['u'])
@property
def y(self):
return float(self.json['v'])
@x.setter
def x(self, value):
if not isinstance(value, (int, float)):
raise TypeError('Must be int or float')
self.json['u'] = float(value)
@y.setter
def y(self, value):
if not isinstance(value, (int, float)):
raise TypeError('Must be int or float')
self.json['v'] = float(value)
Expected Behavior:¶
p = Point({'u': 42, 'v': 18.0})
assert p.x == 42.0
p.y = 15
assert p.y == 15.0
assert type(p.x) is float # Even though 'u' was an int, x is always a float
assert p.json == {'u': 42, 'v': 15.0}
assert type(p.json['v']) is float
assert 'x' not in p.__dict__
assert 'u' not in p.__dict__
Dict Key Order¶
Before introducing descriptors, it's essential to understand how dictionaries behave in different versions of Python, as they play a role when handling JSON-like data:
- Python 3.5 and earlier: Dictionaries do not remember the order of keys.
- Python 3.6: Dictionaries remember the order of keys, but it was not guaranteed in the language specification.
- Python 3.7 and later: Key order is officially part of the language specification, meaning dictionaries maintain the insertion order.
Introduction¶
A descriptor is a Python object that implements one or more of the following special methods:
__get__(self, instance, owner)
: Handles attribute access.__set__(self, instance, value)
: Handles setting attribute values.__delete__(self, instance)
: Handles deleting attributes.
Descriptors allow you to manage attribute access in a class in a fine-grained way. Here is an example of a descriptor:
class Descriptor:
def __get__(self, instance, owner):
print('self =', self)
print('instance =', instance)
print('owner =', owner)
return 42
def __set__(self, instance, value):
print('self =', self)
print('instance =', instance)
print('value =', value)
def __delete__(self, instance):
print('self =', self)
print('instance =', instance)
class Owner:
my_int = 84
attribute = Descriptor()
owner = Owner()
print(owner.my_int)
84
Access through Instance¶
print(owner.attribute) # Access via Descriptor (calls Descriptor.__get__)
self = <__main__.Descriptor object at 0x10af084d0> instance = <__main__.Owner object at 0x10af0bd10> owner = <class '__main__.Owner'> 42
When you access owner.attribute
, it calls the __get__
method of the Descriptor
class, and prints the instance and class before returning 42
.
Let's assign a value to owner.attribute
:
owner.attribute = 100 # Calls Descriptor.__set__
self = <__main__.Descriptor object at 0x10af084d0> instance = <__main__.Owner object at 0x10af0bd10> value = 100
Finally, deleting the attribute:
del owner.attribute # Calls Descriptor.__delete__
self = <__main__.Descriptor object at 0x10af084d0> instance = <__main__.Owner object at 0x10af0bd10>
Behavior in Initializers¶
What happens when you try to override the descriptor's value in the initializer?
class Owner:
attribute = Descriptor()
def __init__(self, k):
self.attribute = k
self.casual = k
owner_2 = Owner(10)
print(owner_2.__dict__)
self = <__main__.Descriptor object at 0x10af0be60> instance = <__main__.Owner object at 0x10af0ad50> value = 10 {'casual': 10}
The descriptor behavior is bypassed in __init__
, but the dictionary shows only casual
, not attribute
.
Accessing attribute
again still invokes the descriptor's __get__
method:The descriptor behavior is bypassed in __init__
, but the dictionary shows only casual
, not attribute
.
Accessing attribute
again still invokes the descriptor's __get__
method:
print(owner_2.attribute)
self = <__main__.Descriptor object at 0x10af0be60> instance = <__main__.Owner object at 0x10af0ad50> owner = <class '__main__.Owner'> 42
Access through Class¶
Accessing the descriptor through the class (rather than an instance) behaves slightly differently:
print(Owner.attribute)
self = <__main__.Descriptor object at 0x10af0be60> instance = None owner = <class '__main__.Owner'> 42
Here, instance
is None
because we are accessing the attribute directly from the class, not an instance.
You can override the descriptor on the class level:
Owner.attribute = 44 # Bypasses the descriptor
print(Owner.attribute)
44
del Owner.attribute # Deletes the descriptor
print(Owner.attribute)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[176], line 1 ----> 1 del Owner.attribute # Deletes the descriptor 2 print(Owner.attribute) AttributeError: type object 'Owner' has no attribute 'attribute'
Example Use Case¶
Let's look at a more practical example: a power class that can represent power in different units—decibels-milliwatts (dBm
), milliwatts (mW
), and watts (W
). Descriptors are used to convert between these units automatically.
import math
class Unit_dBm:
'''
decibel-milliwatts
'''
def __get__(self, instance, owner):
return instance._dBm
def __set__(self, instance, value):
instance._dBm = float(value)
class Unit_mW:
def __get__(self, instance, owner):
return int(10 ** (instance._dBm / 10))
def __set__(self, instance, value):
instance._dBm = 10 * math.log10(float(value))
class Unit_W:
def __get__(self, instance, owner):
return (10 ** (instance._dBm / 10)) / 1000
def __set__(self, instance, value):
instance._dBm = 10 * math.log10(float(value) * 1000)
class Power:
dBm = Unit_dBm()
mw = Unit_mW()
w = Unit_W()
_dBm: float
def __init__(self, dBm=None, mw=None, w=None):
if dBm is not None:
self.dBm = dBm
elif mw is not None:
self.mw = mw
elif w is not None:
self.w = w
else:
raise TypeError("Must provide a value for one of the units")
print('self._dBm =', self._dBm)
power = Power(w=0.2)
self._dBm = 23.010299956639813
print(power.mw)
200
power.mw = 10
print(power.dBm)
print(power.mw)
print(power.w)
10.0 10 0.01
print(power.__dict__)
{'_dBm': 10.0}
Exercise: Implementing a Field
Descriptor for Data Validation¶
Problem Description:¶
You are tasked with creating a system for managing attributes in a class that uses data from a JSON structure. Instead of using @property
, which results in repetitive code for validation and type conversion, you will use descriptors to generalize this behavior and eliminate code duplication.
The goal is to implement a generic Field
class that manages data validation and type conversion for different types of fields. The Field
class should be reusable for different types like float
, string
, or any custom type. Your task is to:
Create a base class
Field
, which will be responsible for handling:- Getting values from the underlying JSON.
- Validating and setting values in the JSON.
- Performing type conversion.
Implement a
FloatField
class that inherits fromField
, specifically for handling floating-point values.Extend the functionality with additional types of fields, such as
StringField
for strings andTypeField
for general type conversion.Finally, implement a
Model
class that will serve as a base class for other models (likePoint
). The model will store a reference to a JSON structure, and its attributes will be handled by theField
descriptors.
Requirements:¶
FloatField Class:
- Inherits from
Field
. - Converts the values in the JSON to
float
.
- Inherits from
StringField Class:
- Inherits from
Field
. - Converts the values in the JSON to
str
.
- Inherits from
TypeField Class:
- A more flexible version of
Field
that can handle any type conversion based on a provided type (likeint
,float
,str
, etc.).
- A more flexible version of
Field Class:
Field
should take a key (representing a JSON key) in the constructor and use this key to access the underlying JSON.- It should handle type conversion through a
convert
method. - It should raise
NotImplementedError
if theconvert
method is not implemented in a subclass.
Model Class:
- The
Model
class should take a JSON dictionary in the constructor, which will serve as the underlying data store. - It will use descriptors (
Field
,FloatField
, etc.) to manage access to its attributes.
- The
Expected Behavior:¶
class Point(Model):
x = FloatField('u')
y = TypeField('v', float)
name = StringField('n')
p = Point({'u': 42, 'v': 18.0, 'n': "A"})
# Test getting values
assert p.x == 42.0
assert p.y == 18.0
assert p.name == "A"
# Test setting values
p.y = 15
assert p.y == 15.0
p.name = "qwer"
assert p.json == {'u': 42, 'v': 15.0, 'n': 'qwer'}
# Test type conversions
assert type(p.x) is float
assert type(p.y) is float
# Test setting values with type conversion
p.name = 123
assert p.json == {'u': 42, 'v': 15.0, 'n': '123'}
Hints:¶
- Use the
__get__
and__set__
methods to manage access and assignment of values. - Implement the
convert
method in each subclass to handle type conversion. - Raise an error in
Field
if the conversion method is not implemented.
Solution:¶
class Model:
def __init__(self, json):
self.json = json
class Field:
def __init__(self, key):
self.key = key
def __get__(self, instance, owner):
return self.convert(instance.json[self.key])
def __set__(self, instance, value):
instance.json[self.key] = self.convert(value)
def convert(self, value):
raise NotImplementedError("The 'convert' method should be implemented in subclasses.")
class FloatField(Field):
def convert(self, value):
return float(value)
class StringField(Field):
def convert(self, value):
return str(value)
class TypeField(Field):
def __init__(self, key, type_):
super().__init__(key)
# self.type_ = type_
self.convert = type_
# def convert(self, value):
# return self.type_(value)
# Define the Point class inheriting from Model
class Point(Model):
x = FloatField('u')
y = TypeField('v', float)
name = StringField('n')
# Example Usage
p = Point({'u': 42, 'v': 18.0, 'n': "A"})
# Access attributes
assert p.x == 42.0
assert p.y == 18.0
assert p.name == "A"
# Modify attributes
p.y = 15
assert p.json == {'u': 42, 'v': 15.0, 'n': 'A'}
p.name = "qwer"
assert p.json == {'u': 42, 'v': 15.0, 'n': 'qwer'}
# Test type conversion
p.name = 123
assert p.json == {'u': 42, 'v': 15.0, 'n': '123'}
Explanation of the Solution:¶
Field Class:
- This class is the base descriptor responsible for handling getting and setting values in the JSON.
- It requires each subclass to implement a
convert
method to handle type conversion. This method ensures the value in the JSON is always stored as the correct type.
FloatField Class:
- This subclass converts the value to a
float
. When the attribute is accessed, the value is fetched from the JSON and converted tofloat
. When the value is set, it converts it tofloat
before storing it.
- This subclass converts the value to a
StringField Class:
- This subclass converts the value to a
string
using the same pattern asFloatField
.
- This subclass converts the value to a
TypeField Class:
- This more generic subclass allows any type conversion to be passed as a parameter. You can specify the desired type (e.g.,
float
,int
, etc.) and it will convert the value accordingly.
- This more generic subclass allows any type conversion to be passed as a parameter. You can specify the desired type (e.g.,
Model Class:
- The
Model
class provides the base structure that interacts with the JSON data. The descriptors (fields) manage individual attributes, handling both data validation and conversion.
- The
Point Class:
- This is the final class that inherits from
Model
. It defines specific attributes (x
,y
, andname
) using the descriptors (FloatField
,TypeField
, andStringField
).
- This is the final class that inherits from
Exercise: Implementing Your Own @property
Descriptor¶
In this exercise, you will implement your own custom descriptor called Property
, which mimics the behavior of the built-in @property
decorator in Python. This custom descriptor should handle both getter and setter methods for attributes, allowing for read-only
Requirements:¶
Property
class:- The
Property
class should be able to accept getter function via its constructor. - If only the getter is defined, the property should act as a read-only property.
- The
__get__
method should return the result of the getter function.
- The
Expected Behavior:¶
You will define a class MyClass
that uses the Property
descriptor to handle a read-only attribute (readonly_value
).
Example Code for Testing:¶
class MyClass:
def __init__(self, readonly_value):
self._readonly_value = readonly_value
@Property
def readonly_value(self):
return self._readonly_value
# Test cases
d = MyClass("readonly value")
# Test accessing read-only property
assert d.readonly_value == 'readonly value'
# Test setting read-only property (should raise an AttributeError)
try:
d.readonly_value = 3
except AttributeError as e:
print(e) # Outputs: Cannot set read-only property.
# Test accessing the property directly from the class
print(MyClass.readonly_value)
Solution:¶
class Property:
def __init__(self, fget=None):
self.fget = fget
def __get__(self, instance, owner):
if instance is None:
return self
if self.fget is None:
raise AttributeError("Unreadable property")
return self.fget(instance)
class MyClass:
def __init__(self, readonly_value):
self._readonly_value = readonly_value
@Property
def readonly_value(self):
return self._readonly_value
# Test cases
d = MyClass("readonly value")
# Test accessing read-only property
assert d.readonly_value == 'readonly value'
# Test setting read-only property (should raise an AttributeError)
try:
d.readonly_value = 3
except AttributeError as e:
print(e) # Outputs: Cannot set read-only property.
# Test accessing the property directly from the class
print(MyClass.readonly_value)
<__main__.Property object at 0x10b64dc10>
Bounded Methods in Python¶
class Foo:
def __init__(self, f):
self.f = f
def method(self, arg):
print(f'method({arg})')
def function(arg):
print(f'function({arg})')
foo = Foo(function)
Bound Methods vs. Functions¶
Let's investigate the behavior when we call these methods and functions:
foo.method(42)
method(42)
Foo.method(foo, 42)
method(42)
foo.method(42)
automatically passesfoo
as the first argument (self
) to the method.Foo.method(foo, 42)
explicitly passesfoo
as the first argument.
Accessing the Function Stored in an Instance¶
You can also call the function stored in the instance directly:
foo.f(42) # => function(42)
function(42)
However, trying to access f
from the class itself will result in an error:
Foo.f(42)
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[203], line 1 ----> 1 Foo.f(42) AttributeError: type object 'Foo' has no attribute 'f'
This is because f
is an instance attribute and not part of the class itself.
Bound Method vs Unbound Method¶
When you assign a method to a variable, it becomes a bound method, meaning it carries with it the instance it was bound to:
m = foo.method
m(42)
method(42)
If you check foo.method
, it is a bound method, as it is tied to the instance foo
:
print(foo.method) # Foo.method.__get__(foo, Foo)
print(Foo.method)
<bound method Foo.method of <__main__.Foo object at 0x10adea2d0>> <function Foo.method at 0x10ba86700>
foo.method
is a bound method, meaning it’s tied to the instancefoo
.Foo.method
is a function (unbound method), meaning it can be called, but it expects an instance as its first argument.
Checking Bound Instance¶
You can also check the instance to which a bound method is attached using the __self__
attribute:
m = foo.method
print(m.__self__)
<__main__.Foo object at 0x10adea2d0>
Bound Methods Behind the Scenes¶
When you call foo.method
, it is equivalent to calling Foo.method.__get__(foo, Foo)
. This is how Python implements method binding:
print(foo.method)
print(Foo.method.__get__(foo, Foo))
<bound method Foo.method of <__main__.Foo object at 0x1044a6150>> <bound method Foo.method of <__main__.Foo object at 0x1044a6150>>
This shows that Python binds methods using the __get__
method of the descriptor protocol.
This mechanics allows a developer to inject a custom code between an call to a method of an class's instance and an actuall call to bound function at the class definition.
class BoundedMethod:
def __init__(self, method):
self.method = method
def __get__(self, instance, class_definition):
return self.method(instance)
class Foo:
def method(self, arg):
print(f'method({arg})')
foo = Foo() # create instance of the Foo class
# python creates empty foo without method
# foo.method = BoundedMethod(Foo.method) => foo.method => Foo.method.__get__(foo, Foo)
print(Foo.method)
print(foo.method)
<function Foo.method at 0x10bba44a0> <bound method Foo.method of <__main__.Foo object at 0x1044a6150>>
Summary of Key Concepts¶
- Bound Method: A function that is bound to an instance. It automatically passes the instance as the first argument.
- Unbound Method: A function that is not bound to an instance. It needs an instance to be explicitly passed.
__get__
method: Python binds methods using the__get__
method of descriptors, which ties a function to an instance of a class.
Conclusion¶
Bound methods allow object-oriented programming in Python to work seamlessly, ensuring that methods are always called with the correct instance. This automatic binding is a core part of how Python handles object methods, making method calls simpler and more intuitive.
Multiple Inheritance in Python¶
class Base1:
def base_method(self):
print('base_method')
class Base2:
def another_method(self):
print('another_method')
class Child(Base2, Base1):
def child_method(self):
print('child_method')
# Creating an instance of Child and calling methods
child = Child()
child.base_method()
child.another_method() # Inherited from Base2
child.child_method() # Defined in Child
base_method another_method child_method
In this example:
Child
inherits methods from bothBase1
andBase2
.- You can access methods from both base classes using an instance of
Child
.
However, the parent classes (Base1
, Base2
) do not have access to each other's methods:
b1 = Base1()
b1.base_method() # Works fine
base_method
b1.child_method() # Raises an AttributeError
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[219], line 1 ----> 1 b1.child_method() # Raises an AttributeError AttributeError: 'Base1' object has no attribute 'child_method'
b1.another_method() # Raises an AttributeError
--------------------------------------------------------------------------- AttributeError Traceback (most recent call last) Cell In[220], line 1 ----> 1 b1.another_method() # Raises an AttributeError AttributeError: 'Base1' object has no attribute 'another_method'
Method Resolution Order (MRO)¶
When a class inherits from multiple parents, Python needs a way to decide the order in which it looks for methods and attributes. This is called the Method Resolution Order (MRO). Python uses the C3 linearization algorithm to determine the order in which classes are searched for methods and attributes.
Let's explore this with a more complex example:
class Foo:
def __init__(self):
print('Foo.__init__')
class Boo(Foo):
pass
class Zoo(Foo):
def __init__(self):
print('Zoo.__init__')
class Doo(Foo):
def __init__(self):
super().__init__()
print('Zoo.__init__')
d = Foo()
b = Boo()
z = Zoo()
Foo.__init__ Foo.__init__ Zoo.__init__
e = Doo()
Foo.__init__ Zoo.__init__
class Person:
def __init__(self, name):
self.name = name
class PersonExt(Person):
def __init__(self, name, surname):
super().__init__(name)
self.surname = surname
class External:
def method1(self):
pass
def method2(self):
pass
class Internal(External):
pass
class Internal2:
def __init__(self, ext: External):
self._ext = ext
def method1(self):
return self._ext.method1()
# A => base Interface (no functionality)
# B => read functionality
# C => write functionality
# D => module using write and read
class A: # Implicitly inherits from object => A(object):
def __init__(self):
super().__init__()
print("A")
class B:
def __init__(self):
super().__init__()
print("B")
class C:
def __init__(self):
super().__init__()
print("C")
class D(B, C):
def __init__(self):
super().__init__()
print("D")
In this case, D
inherits from both B
and C
, which in turn inherit from A
. When we create an instance of D
, Python will follow the MRO to determine which __init__
methods to call:
print(D.__mro__)
(<class '__main__.D'>, <class '__main__.B'>, <class '__main__.C'>, <class 'object'>)
d = D()
C B D
The MRO determines that:
D
looks in its own class.- Then it looks in
B
. - Then in
C
. - Then in
A
. - Finally, it looks in
object
, the root of all classes in Python.
As a result, the __init__
methods are called in the following order: A
, C
, B
, and finally D
.
Custom MRO Traversal¶
You can manually control how the methods are called by using the __class__.__mro__
attribute, which stores the MRO of a class. Here's an example of manually traversing the MRO and calling the next class in the MRO:
class A:
def __init__(self):
mro = self.__class__.__mro__
next_class = mro[mro.index(A) + 1]
next_class.__init__(self)
print("A")
class B(A):
def __init__(self):
mro = self.__class__.__mro__
next_class = mro[mro.index(B) + 1]
next_class.__init__(self)
print("B")
class C(A):
def __init__(self):
mro = self.__class__.__mro__
next_class = mro[mro.index(C) + 1]
next_class.__init__(self)
print("C")
class D(B, C):
def __init__(self):
mro = self.__class__.__mro__
next_class = mro[mro.index(D) + 1]
next_class.__init__(self)
print("D")
d = D()
A C B D
In this version, each class manually retrieves the next class in the MRO and explicitly calls its __init__
method. This approach provides more control, but it’s rarely necessary in normal Python programming since Python’s super()
handles most cases.
Excercise¶
Implement ComparableByKeyMixin
. Classes inheriting from the mixin should implement get_key()
method that returns the value we should compare objects by (i.e., for vectors, it should return their length).
Hint: Six special methods are used to compare objects: __lt__
, __le__
, __eq__
, __ne__
, __gt__
, and __ge__
.
DatabaseModel
class already implements the logic of storing an object in a database, so you can simply inherit from it.
Implement the following classes:
- Vector: Should be comparable by length and NOT stored in a database.
- Person: Should be comparable by
id
and stored in a database. - TodoItem: Should NOT be comparable and should be stored in a database.
Implement LogCreationMixin
:
- This mixin logs (prints to the output) when you create an instance of any class using this mixin.
- This is very useful for debugging.
- Use it for both the
Person
andTodoItem
classes.
Optional tasks (★):
JSONSerializableMixin: Implements
.to_json()
method and.from_json(json)
classmethod that allow you to convert an object to/from a dictionary.- This should work for any class, so you need to dynamically get all object attributes from
self.__dict__
. - Make
Vector
andPerson
serializable.
- This should work for any class, so you need to dynamically get all object attributes from
DateCreatedMixin: Automatically adds
created_at
attribute and sets it to the current time by default (unless an explicitcreated_at
keyword argument is provided in__init__
).- Reuse this logic for both
Vector
andTodoItem
.
Implementation:
- Reuse this logic for both
- First person implements
ComparableByKeyMixin
, second person implementsLogCreationMixin
, then the first person implementsJSONSerializableMixin
, and the second person implementsDateCreatedMixin
.
Initial Code¶
class DatabaseModel:
# Imagine this class implements complex logic of adding
# transparent persistence in a database. Don't modify the code,
# however, make sure that both Person and TodoItem inherit
# from this class.
def save(self):
print("Automagically saved to the database.")
class ComparableByKeyMixin:
def __lt__(self, other): # triggered when you compare self < other
pass
# Implement __le__, __eq__, __ne__, __gt__, __ge__ as well
class LogCreationMixin:
# Implement logging functionality when a new instance is created
...
class Vector(...):
def __init__(self, x, y):
self.x = x
self.y = y
# Vectors should be comparable by length and NOT stored in a database
...
class Person(...):
def __init__(self, id, name):
self.id = id
self.name = name
# Persons should be comparable by id and stored in a database
...
class TodoItem(...):
def __init__(self, id, title):
self.id = id
self.title = title
# TodoItems should be NOT comparable and should be stored in a database
...
Expected Behaviour¶
v = Vector(3, 4) # length 5
u = Vector(0, 6) # length 6
assert v < u # compare by length
# v.save() # Vectors do NOT support database persistence
p1 = Person(id=1234, name='John')
p2 = Person(id=2345, name='Alice')
assert p1 < p2 # compare by id
p1.save() # Persons support database persistence
# Output: Created an instance of Person
# Output: Automagically saved to the database.
t1 = TodoItem(1, 'Clean dishes')
t1.save() # TodoItems support database persistence
# Output: Created an instance of TodoItem
# Output: Automagically saved to the database.
Additional Tasks (Optional)¶
# Optional: DateCreatedMixin
print(v.created_at)
# print(p1.created_at) # Persons should not have created_at field
print(t1.created_at)
# Output: 2020-11-03 12:46:51.985752
t2 = TodoItem(1, 'Clean dishes', created_at=datetime(2020, 1, 1))
print(t2.created_at)
# Output: Created an instance of TodoItem
# Output: 2020-01-01 00:00:00
# Optional: JSONSerializableMixin
print(v.to_json())
# Output: {'x': 3, 'y': 4}
w = Vector.from_json({'x': 2, 'y': 3})
print(w.__dict__)
# Output: {'created_at': datetime.datetime(2020, 11, 3, 12, 46, 51, 985752), 'x': 2, 'y': 3}
class A:
def __init__(self):
super().__init__()
print('A.__init__')
class B:
def __init__(self):
super().__init__()
print('B.__init__')
class C:
def __init__(self):
super().__init__()
print('C.__init__')
class D(C, B, A):
def __init__(self):
super().__init__()
print('D.__init__')
# pass
d = D() # d.__init__()
A.__init__ B.__init__ C.__init__ D.__init__
D.__mro__
(__main__.D, __main__.C, __main__.B, __main__.A, object)
#### Solution
from datetime import datetime
from functools import total_ordering
from math import sqrt
class DatabaseModel:
# Imagine this class implements complex logic of adding
# transparent persistence in a database. Don't modify the code,
# however make sure, that both Vector and Person inherit
# from this class.
def save(self):
print("Automagically saved to the database.")
@total_ordering
class ComparableByKeyMixin:
def __lt__(self, other: 'ComparableByKeyMixin'): # self < other
return self.get_key() < other.get_key()
def __eq__(self, other: 'ComparableByKeyMixin'): # self == other
return self.get_key() == other.get_key()
def get_key(self):
raise NotImplementedError
class LogCreationMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
print(f"Created an instance of {self.__class__.__name__}")
class DateCreatedMixin:
def __init__(self, *args, created_at=None, **kwargs):
super().__init__(*args, **kwargs)
if created_at is None:
created_at = datetime.now()
self.created_at = created_at
class JSONSerializableMixin:
def to_json(self):
return self.__dict__.copy()
@classmethod
def from_json(cls, json: dict):
return cls(**json) # __class__
class Vector(ComparableByKeyMixin, DateCreatedMixin, JSONSerializableMixin):
def __init__(self, x, y, *args, **kwargs):
super().__init__(*args, **kwargs)
self.x = x
self.y = y
def get_key(self):
return sqrt(self.x**2 + self.y**2)
class Person(ComparableByKeyMixin, LogCreationMixin, JSONSerializableMixin, DatabaseModel):
def __init__(self, id, name):
super().__init__()
self.id = id
self.name = name
def get_key(self):
return self.id
class TodoItem(LogCreationMixin, DateCreatedMixin, DatabaseModel):
def __init__(self, id, title, *args, **kwargs):
super().__init__(*args, **kwargs)
self.id = id
self.title = title
v = Vector(3, 4) # length 5
u = Vector(0, 6) # length 6
assert v < u # compare by length
# v.save() # Vectors do NOT support database persistence
p1 = Person(id=1234, name='John')
p2 = Person(id=2345, name='Alice')
assert p1 < p2 # compare by id
p1.save() # Persons support database persistence
# Output: Created an instance of Person
# Output: Automagically saved to the database.
t1 = TodoItem(1, 'Clean dishes')
t1.save() # TodoItems support database persistence
# Output: Created an instance of TodoItem
# Output: Automagically saved to the database.
Created an instance of Person Created an instance of Person Automagically saved to the database. Created an instance of TodoItem Automagically saved to the database.
# Optional: DateCreatedMixin
print(v.created_at)
# print(p1.created_at) # Persons should not have created_at field
print(t1.created_at)
# Output: 2020-11-03 12:46:51.985752
t2 = TodoItem(1, 'Clean dishes', created_at=datetime(2020, 1, 1))
print(t2.created_at)
# Output: Created an instance of TodoItem
# Output: 2020-01-01 00:00:00
# Optional: JSONSerializableMixin
print(v.to_json())
# Output: {'x': 3, 'y': 4}
w = Vector.from_json({'x': 2, 'y': 3})
print(w.__dict__)
# Output: {'created_at': datetime.datetime(2020, 11, 3, 12, 46, 51, 985752), 'x': 2, 'y': 3}
2024-09-27 11:45:35.724468 2024-09-27 11:45:35.725278 Created an instance of TodoItem 2020-01-01 00:00:00 {'created_at': datetime.datetime(2024, 9, 27, 11, 45, 35, 724468), 'x': 3, 'y': 4} {'created_at': datetime.datetime(2024, 9, 27, 11, 45, 47, 679946), 'x': 2, 'y': 3}
Metaclasses¶
Metaclasses allow you to control how classes behave. Just as classes control the behavior of their instances, metaclasses control the behavior of classes themselves. This can be useful for tasks such as:
- Automatically adding methods or attributes to classes.
- Enforcing class-level constraints or properties.
- Modifying class behavior dynamically.
In essence, metaclasses allow you to hook into and customize class creation in Python.
Introduction¶
In Python, everything is an object, even classes themselves. This means that classes are instances of something too, and that something is called a metaclass. Metaclasses allow you to control how classes are created.
Let's explore this by starting with a simple integer variable s
.
s = 2
print(s)
print(type(s)) # => class int
print(s.__class__) # => class int
2 <class 'int'> <class 'int'>
Here, the variable s
is an instance of the class int
. The type()
function tells us that s
is of type int
, and s.__class__
gives the same result.
Now, what about the class itself? Let's check the type of the int
class:
k = s.__class__ # return int class form int instance
print(k.__class__) # return ?
print(type(k))
print(type(int))
<class 'type'> <class 'type'> <class 'type'>
The int
class itself is an instance of the class type
. In Python, classes are created using a metaclass, and the default metaclass is type
.
Now, let's define a simple class:
class MyClass:
pass
inst = MyClass()
print(type(inst)) # What is the type of inst (instance)?
print(type(MyClass)) # What is the type of MyClass (class)?
<class '__main__.MyClass'> <class 'type'>
Using type()
to Create Classes¶
Normally, we define classes using the class
keyword, but Python also provides a way to create classes dynamically using the built-in type()
function.
class BaseClass:
def base_method(self):
print("Base method called")
class MyClass(BaseClass):
def bar(self):
print("Bar method called")
foo = 42
inst = MyClass()
This can be rewritten using the type()
function. The type()
function can be used to dynamically create classes at runtime by passing three arguments:
- Class name: The name of the class as a string.
- Bases: A tuple of base classes.
- Namespace: A dictionary representing the class body (attributes and methods).
Here’s how the class MyClass
would look if we used type()
to define it:
def base_method(self):
print("Base method called")
name = 'BaseClass'
bases = ()
namespace = {
'base_method': base_method,
}
BaseClass = type(name, bases, namespace) # Creating BaseClass dynamically
def bar(self):
print("Bar method called")
name = 'MyClass'
bases = (BaseClass,) # Inheriting from BaseClass
namespace = {
'bar': bar,
'foo': 42,
}
MyClass = type(name, bases, namespace) # Creating MyClass with inheritance from BaseClass
inst = MyClass()
inst.bar() # Calls MyClass's method
inst.base_method() # Calls BaseClass's method
Bar method called Base method called
BaseClass
: We dynamically createBaseClass
usingtype()
with a single methodbase_method
.name
: The string'MyClass'
is the name of the class we are creating.bases
: We pass(BaseClass,)
as the base class from whichMyClass
inherits.namespace
: This dictionary includes thebar
method andfoo
attribute forMyClass
.
The type()
function creates a class named MyClass
that inherits from BaseClass
, and has its own methods and attributes.
Exercise: Implement a Metaclass to Uppercase All Attributes¶
Description:¶
In this exercise, you will implement a metaclass called upper_attr
. This metaclass will modify the class creation process so that all the attributes (both fields and methods) of the class are converted to uppercase. The idea is to intercept the class creation process and transform all attribute names to uppercase in the resulting class.
Initial Code:¶
def upper_attr(name, bases, namespace):
# Your code here
...
return type(name, bases, namespace)
Expected Behaviour:¶
You should be able to define a class using upper_attr
as its metaclass, and all attributes should be converted to uppercase.
class Foo(metaclass=upper_attr):
bar = 'foo'
def function(self):
print(42)
# Check the class dictionary
print(Foo.__dict__)
# Expected Output:
# {
# '__MODULE__': '__main__',
# '__QUALNAME__': 'Foo',
# 'BAR': 'foo',
# 'FUNCTION': <function Foo.function at 0x7fe925bf44c0>,
# '__module__': '__main__',
# '__dict__': <attribute '__dict__' of 'Foo' objects>,
# '__weakref__': <attribute '__weakref__' of 'Foo' objects>,
# '__doc__': None
# }
f = Foo()
# Ensure the attributes are accessible in uppercase
assert f.BAR == 'foo'
f.FUNCTION() # Expected Output: 42
Solution¶
# meta function
def upper_attr(name, bases, namespace):
# Convert all attributes to uppercase
uppered = {key.upper(): value for key, value in namespace.items()}
return type(name, bases, uppered)
# Testing the metaclass
class Foo(metaclass=upper_attr): # Foo: => Foo(metaclass=type):
bar = 'foo'
def function(self):
print(42)
# Check the class dictionary
print(Foo.__dict__)
# Test instance attributes and methods
f = Foo()
assert f.BAR == 'foo'
f.FUNCTION()
{'__MODULE__': '__main__', '__QUALNAME__': 'Foo', 'BAR': 'foo', 'FUNCTION': <function Foo.function at 0x1097074c0>, '__module__': '__main__', '__dict__': <attribute '__dict__' of 'Foo' objects>, '__weakref__': <attribute '__weakref__' of 'Foo' objects>, '__doc__': None} 42
# Meta class definition
class HelloMeta(type):
def __new__(cls, name, bases, namespace):
# Tworzymy nową metodę 'hello'
def hello(self):
print(f"Hello from {self.__class__.__name__}!")
# Dodajemy metodę 'hello' do klasy
namespace['hello'] = hello
# Wywołujemy oryginalną metodę __new__ z metaklasy 'type'
return super().__new__(cls, name, bases, namespace)
class MyClass(metaclass=HelloMeta):
pass
obj = MyClass()
obj.hello()
Hello from MyClass!
Explanation:¶
upper_attr
Function:- The
upper_attr
function is a custom metaclass that intercepts the class creation process. - It takes three arguments:
name
: The name of the class.bases
: A tuple of base classes.namespace
: A dictionary containing the class attributes and methods.
- The function loops over all items in the
namespace
and converts each key (attribute name) to uppercase.
- The
Class Creation:
- When
Foo
is defined withmetaclass=upper_attr
, theupper_attr
function is called to create the class. - The returned class has all its attributes (fields and methods) in uppercase.
- When
Testing:
- We check the
__dict__
of theFoo
class to confirm that all attributes have been converted to uppercase. - We create an instance of
Foo
and verify that we can access the attributes using uppercase names.
- We check the
SOLID Design Principles: A Simple Introduction¶
The SOLID principles are a set of five guidelines used in object-oriented programming to help developers write more maintainable, flexible, and understandable code. These principles were introduced by Robert C. Martin (also known as Uncle Bob) and are designed to encourage better software design, making the code easier to extend, modify, and test.
SOLID stands for:
- Single Responsibility Principle (SRP): Each class or module should have only one reason to change. In simpler terms, a class should do only one thing and be responsible for one part of the software's functionality.
- Open/Closed Principle (OCP): Classes should be open for extension but closed for modification. You should be able to add new features to a class without changing its existing code.
- Liskov Substitution Principle (LSP): Subclasses should be substitutable for their base classes. In other words, objects of a subclass should behave correctly when used in place of an object of the base class.
- Interface Segregation Principle (ISP): Clients should not be forced to depend on interfaces they do not use. Instead of one large interface, create smaller, more specific ones.
- Dependency Inversion Principle (DIP): High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces), making the system more modular and flexible.
The SOLID principles gained popularity in the early 2000s, but their roots go back further. In 1994, four authors—Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides—published "Design Patterns: Elements of Reusable Object-Oriented Software". This book, commonly referred to as the Gang of Four (GoF) book, introduced design patterns to a wider audience and influenced how we think about software design. SOLID principles extend and refine the ideas from the GoF patterns, emphasizing maintainability and scalability in complex systems.
Single Responsibility Principle (SRP)¶
The Single Responsibility Principle states that a class or function should have only one responsibility or reason to change. In practical terms, it means that every class should do only one job, and if a class has more than one responsibility, it’s a candidate for refactoring.
A classic quote from Robert C. Martin is: "A class should have only one reason to change." This "reason" can be understood as a responsibility or actor that affects the class. The aim is to prevent classes from becoming overly complex, harder to understand, and more prone to bugs.
Example: Simple Demonstration of SRP Violation and Fix¶
Here’s an example of a class that violates the SRP by mixing business logic (calculating pay) with database persistence (saving the object):
class Employee:
def calculate_pay(self):
print("Calculating pay")
def save(self):
print("Saving to the database")
This class has two responsibilities: calculating the employee's pay and saving the employee to the database. To apply SRP, we should separate these concerns into different classes.
Refactored Solution:
class Employee:
def calculate_pay(self):
print("Calculating pay")
class EmployeeRepository:
def save(self, employee: Employee):
print(f"Saving {employee} to the database")
Now, the Employee
class only handles the business logic, and EmployeeRepository
is responsible for saving the employee.
Example: Report Generation and Logging¶
In this example, we have a Report
class that is responsible for both generating a report and handling logging. This violates the SRP because the class is handling two distinct concerns: generating the report and logging messages.
import logging
class Report:
def __init__(self, title: str, content: str):
self.title = title
self.content = content
self.logger = logging.getLogger('ReportLogger')
def generate(self):
self.logger.info(f"Generating report: {self.title}")
print(f"Report Title: {self.title}\nContent: {self.content}")
def log_error(self, message: str):
self.logger.error(message)
Here, the Report
class is handling both report generation and logging. If the logging requirements change (e.g., changing the logging mechanism), you’d have to modify the Report
class, which should only focus on generating reports.
To fix this violation, we’ll split the logging responsibility into a separate Logger
class. The Report
class will now only be responsible for report generation, and logging will be handled independently.
import logging
class Logger:
def __init__(self):
self.logger = logging.getLogger('ReportLogger')
def log_info(self, message: str):
self.logger.info(message)
def log_error(self, message: str):
self.logger.error(message)
class Report:
def __init__(self, title: str, content: str, logger: Logger):
self.title = title
self.content = content
self.logger = logger
def generate(self):
self.logger.log_info(f"Generating report: {self.title}")
print(f"Report Title: {self.title}\nContent: {self.content}")
Example: File Handling and Parsing¶
Let’s consider a scenario where a class handles both file reading and data parsing. This is another common violation of SRP, as reading a file and parsing data are two distinct responsibilities.
class DataProcessor:
def read_file(self, file_path: str) -> str:
with open(file_path, 'r') as file:
return file.read()
def parse_data(self, data: str) -> list:
return data.splitlines()
In this example, DataProcessor
is doing two jobs: reading a file and parsing its content. These are separate concerns and should be handled by different classes.
To adhere to SRP, we’ll split the DataProcessor
into two classes: one for reading files and one for parsing data.
class FileReader:
def read_file(self, file_path: str) -> str:
with open(file_path, 'r') as file:
return file.read()
class DataParser:
def parse_data(self, data: str) -> list:
return data.splitlines()
def test_file_processing():
file_reader = FileReader()
data_parser = DataParser()
data = file_reader.read_file('data.txt')
parsed_data = data_parser.parse_data(data)
print(parsed_data)
test_file_processing()
['line1', 'line2', 'line3']
SRP Refactoring Process: From Bad to Good¶
Now, let's walk through refactoring an example step-by-step to better apply the SRP using a Person
class.
Before Refactoring (Bad Example)¶
The Person
class below is responsible for both storing personal information and validating the email:
# %%writefile srp_before.py
import re
import pytest
EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')
class Person:
def __init__(self, first_name: str, last_name: str, email: str):
self.first_name = first_name
self.last_name = last_name
if self.validate_email(email):
self.email = email
else:
raise ValueError('Invalid email')
def validate_email(self, email: str) -> bool:
return EMAIL_PATTERN.match(email) is not None
class BillingAddress:
def __init__(self, address: str, email: str):
pass
def test_valid_person():
Person('Jan', 'Kowalski', 'jan@kowalski.pl')
def test_invalid_email():
with pytest.raises(ValueError):
Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ============================== platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/design_patterns plugins: anyio-4.4.0 collected 2 items srp_before.py .. [100%] ============================== 2 passed in 0.02s ===============================
Iteration 1: Move Email Validation Out¶
# %%writefile srp_before.py
import re
import unittest
import pytest
EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')
def validate_email(email: str) -> str:
if EMAIL_PATTERN.match(email) is None:
raise ValueError('Invalid email')
else:
return email
class Person:
def __init__(self, first_name: str, last_name: str, email: str):
self.first_name = first_name
self.last_name = last_name
self.email = validate_email(email)
class BillingAddress:
def __init__(self, address: str, email: str):
self.email = validate_email(email)
def test_valid_person():
Person('Jan', 'Kowalski', 'jan@kowalski.pl')
def test_invalid_email():
with pytest.raises(ValueError):
Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ============================== platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/design_patterns plugins: anyio-4.4.0 collected 2 items srp_before.py .. [100%] ============================== 2 passed in 0.02s ===============================
Iteration 1: Bad Solution¶
# %%writefile srp_before.py
import re
import pytest
EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')
class Validator:
def validate_email(self, email: str) -> bool:
return EMAIL_PATTERN.match(email) is not None
class Person(Validator):
def __init__(self, first_name: str, last_name: str, email:str):
# def __init__(self, first_name: str, last_name: str, email_working: str, email_home: str):
self.first_name = first_name
self.last_name = last_name
if self.validate_email(email):
self.email = email
else:
raise ValueError('Invalid email')
class BillingAddress(Validator):
def __init__(self, address: str, email: str):
if self.validate_email(email):
self.email = email
else:
raise ValueError('Invalid email')
def test_valid_person():
Person('Jan', 'Kowalski', 'jan@kowalski.pl')
def test_invalid_email():
with pytest.raises(ValueError):
Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ============================== platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/design_patterns plugins: anyio-4.4.0 collected 2 items srp_before.py .. [100%] ============================== 2 passed in 0.02s ===============================
Iteration 2: Create an Email Class¶
# %%writefile srp_before.py
import re
import pytest
EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')
class Email:
def __init__(self, email):
if EMAIL_PATTERN.match(email) is None:
raise ValueError('Invalid email')
self.email = email
def __str__(self):
return self.email
class Person:
def __init__(self, first_name: str, last_name: str, email: str):
self.first_name = first_name
self.last_name = last_name
self._email = Email(email)
@property
def email(self):
return self._email.email
def test_valid_person():
Person('Jan', 'Kowalski', 'jan@kowalski.pl')
def test_invalid_email():
with pytest.raises(ValueError):
Person('Jan', 'Kowalski', 'invalid email')
Overwriting srp_before.py
! pytest srp_before.py
============================= test session starts ============================== platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/design_patterns plugins: anyio-4.4.0 collected 2 items srp_before.py .. [100%] ============================== 2 passed in 0.02s ===============================
Iteration 3: Final Solution with dataclass
¶
# %%writefile srp_after.py
from dataclasses import dataclass
import re
import pytest
EMAIL_PATTERN = re.compile(r'^([\w-]+(?:\.[\w-]+)*)@((?:[\w-]+\.)*\w[\w-]{0,66})\.([a-z]{2,6}(?:\.[a-z]{2})?)$')
@dataclass(frozen=True)
class Email: # Value Object
email: str
def __post_init__(self):
if EMAIL_PATTERN.match(self.email) is None:
raise ValueError('Invalid email')
class Person:
def __init__(self, first_name: str, last_name: str, email: str):
self.first_name = first_name
self.last_name = last_name
self._email = Email(email)
@property
def email(self):
return self._email.email
def test_valid_person():
Person('Jan', 'Kowalski', 'jan@kowalski.pl')
def test_invalid_email():
with pytest.raises(ValueError):
Person('Jan', 'Kowalski', 'invalid email')
In this final version, each class has a single responsibility: Email
manages validation, and Person
holds personal information.
With these refactorings, developers can see the value of SRP through incremental improvements, leading to more modular and maintainable code.
Open/Closed Principle (OCP)¶
The Open/Closed Principle is the second principle in the SOLID design principles and states that:
"Software entities (such as classes, modules, and functions) should be open for extension but closed for modification."
This means that we should be able to extend the behavior of a class without modifying its existing source code. In simpler terms, you should be able to add new functionality by writing new code, but you shouldn't have to change existing code to accommodate those changes. This principle encourages using abstraction and polymorphism to make code more flexible and resistant to changes.
The goal of OCP is to avoid frequent modifications to tested and stable code when new requirements arise. By adhering to this principle, you minimize the risk of introducing bugs while adding new features.
How to Achieve OCP?¶
To achieve the Open/Closed Principle, we often rely on:
- Abstraction (e.g., abstract classes or interfaces) to define behaviors.
- Polymorphism to allow different implementations of the same behavior.
By leveraging these techniques, you can extend the functionality of your system without altering existing code.
What is an Abstract Base Class?¶
An Abstract Base Class is a class that cannot be instantiated on its own and typically contains one or more abstract methods. Abstract methods are methods that are declared but contain no implementation; they must be overridden by subclasses.
Python provides the abc
module to create ABCs. To define an abstract base class, you inherit from ABC
and decorate abstract methods with @abstractmethod
.
Why Use ABCs?¶
- Enforces a Contract: ABCs ensure that all subclasses implement specific methods. This prevents subclasses from being incomplete or misused.
- Facilitates OCP: You can create a flexible system where adding new functionality only involves extending the abstract class without changing existing, tested code.
- Encourages Polymorphism: ABCs enable the creation of polymorphic systems where objects can be used interchangeably as long as they share the same interface.
Example: ABC in Python¶
Let’s revisit the payment processing example, but this time, we will use ABC to define a common interface for different payment methods.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def process(self):
pass
In this example, PaymentMethod
is an abstract base class that declares the process()
method as abstract. Any subclass of PaymentMethod
must implement the process()
method.
Defining Subclasses with ABC¶
Now, let’s implement different payment methods that inherit from the PaymentMethod
ABC.
class CreditCardPayment(PaymentMethod):
def process(self):
print("Processing credit card payment")
class PayPalPayment(PaymentMethod):
def process(self):
print("Processing PayPal payment")
Benefits of ABC in OCP¶
- Maintainable Code: ABCs allow you to define core behavior without locking the system into specific implementations, making it easier to maintain and extend.
- Less Risk: Since abstract base classes and the core system are closed for modification, you avoid the risk of introducing bugs when adding new functionality.
- Polymorphic Flexibility: ABCs allow you to treat different subclasses interchangeably as long as they follow the same abstract interface. This makes the code more flexible and reusable.
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def process(self):
pass
class CreditCardPayment(PaymentMethod):
def process_payment(self):
print("Processing credit card payment")
payment = CreditCardPayment()
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[29], line 13 9 def process_payment(self): 10 print("Processing credit card payment") ---> 13 payment = CreditCardPayment() TypeError: Can't instantiate abstract class CreditCardPayment without an implementation for abstract method 'process'
Example 1: Simple Payment Processing System¶
In this example, we have a payment processing system that processes different types of payments, like credit card and PayPal payments. However, the class violates OCP because every time we want to add a new payment method (e.g., bank transfer), we need to modify the existing PaymentProcessor
class.
#### Bad Example: Violating OCP
class PaymentProcessor:
def process_payment(self, payment_type: str):
if payment_type == "credit_card":
self.process_credit_card_payment()
elif payment_type == "paypal":
self.process_paypal_payment()
else:
raise ValueError("Unknown payment type")
def process_credit_card_payment(self):
print("Processing credit card payment")
def process_paypal_payment(self):
print("Processing PayPal payment")
In this example, every time a new payment method is added, such as "bank_transfer," the existing class has to be modified to handle that new type. This violates the OCP because the PaymentProcessor
is not closed for modification.
We can refactor this example by introducing an abstract PaymentMethod
class and using polymorphism. This way, the PaymentProcessor
can handle new payment methods without requiring changes to its code.
#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod
class PaymentMethod(ABC):
@abstractmethod
def process(self):
pass
class CreditCardPayment(PaymentMethod):
def process(self):
print("Processing credit card payment")
class PayPalPayment(PaymentMethod):
def process(self):
print("Processing PayPal payment")
class PaymentProcessor:
def process_payment(self, payment_method: PaymentMethod):
payment_method.process()
Now, if we want to add a new payment method, such as "bank transfer," we can simply extend the PaymentMethod
class without modifying the PaymentProcessor
:
class BankTransferPayment(PaymentMethod):
def process(self):
print("Processing bank transfer payment")
Example 2: Discount System¶
Consider a discount system where we have different types of discounts (e.g., seasonal discounts, loyalty discounts). Initially, the system calculates only seasonal discounts. When a new loyalty discount is introduced, we modify the existing DiscountCalculator
class. This violates the Open/Closed Principle because each new discount type requires changes to the class.
#### Bad Example: Violating OCP
class DiscountCalculator:
def calculate(self, order_amount: float, discount_type: str) -> float:
if discount_type == "seasonal":
return order_amount * 0.9 # 10% off for seasonal discount
elif discount_type == "loyalty":
return order_amount * 0.85 # 15% off for loyalty discount
else:
raise ValueError("Unknown discount type")
#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod
class DiscountStrategy(ABC):
@abstractmethod
def apply_discount(self, order_amount: float) -> float:
pass
class SeasonalDiscount(DiscountStrategy):
def apply_discount(self, order_amount: float) -> float:
return order_amount * 0.9 # 10% off
class LoyaltyDiscount(DiscountStrategy):
def apply_discount(self, order_amount: float) -> float:
return order_amount * 0.85 # 15% off
class DiscountCalculator:
def calculate(self, order_amount: float, discount_strategy: DiscountStrategy) -> float:
return discount_strategy.apply_discount(order_amount)
class HolidayDiscount(DiscountStrategy):
def apply_discount(self, order_amount: float) -> float:
return order_amount * 0.8 # 20% off
Example 3: Reporting System¶
Let’s imagine a reporting system that generates reports in different formats, such as PDF and Excel. The initial implementation might look like this:
#### Bad Example: Violating OCP
class ReportGenerator:
def generate(self, report_type: str):
if report_type == "pdf":
self.generate_pdf_report()
elif report_type == "excel":
self.generate_excel_report()
else:
raise ValueError("Unknown report type")
def generate_pdf_report(self):
print("Generating PDF report")
def generate_excel_report(self):
print("Generating Excel report")
#### Refactored Example: Applying OCP
from abc import ABC, abstractmethod
class ReportFormat(ABC):
@abstractmethod
def generate(self):
pass
class PDFReport(ReportFormat):
def generate(self):
print("Generating PDF report")
class ExcelReport(ReportFormat):
def generate(self):
print("Generating Excel report")
class ReportGenerator:
def generate(self, report_format: ReportFormat):
report_format.generate()
class HTMLReport(ReportFormat):
def generate(self):
print("Generating HTML report")
Excercise: OCP Discount¶
Task: Implement a new discount strategy without modifying the old one
Someone has already implemented a strategy to compute discounts for customers in the
calculate_discount_percentage
function.Now, you need to reuse the logic from this function to implement a new strategy.
- The only difference is that loyal customers who have been with us for 10 years should get a 15% discount, instead of the 20% discount they currently receive.
Your goal is to implement this new strategy in the
calculate_discount_percentage_new
function.
Key Requirements:¶
Keep the existing discount strategy intact for old customers, as it is still in use.
- You are not allowed to change the rules you have previously agreed upon with your old customers.
Implement the new rules for new customers without modifying the original function.
Do not copy and paste the existing code from the old function.
- Why? Because there may be more changes in the future, and copying and pasting the code would make it difficult to manage.
The tests provided only test the old strategy.
- Do not modify the tests or any existing logic for the old strategy.
Your task is to refactor the code so that both strategies can coexist without duplicating the logic, keeping the code clean and maintainable.
Intial Code¶
# %%writefile ocp_before.py
#### Initial Code
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
@dataclass
class Customer:
first_purchase_date: Optional[datetime] # datetime or None
birth_date: datetime
is_veteran: bool
def calculate_discount_percentage(customer):
discount = 0
now = datetime.now()
year = timedelta(days=365)
if customer.birth_date <= now - 65*year:
# senior discount
discount = 5
if customer.first_purchase_date is not None:
if customer.first_purchase_date <= now - year:
# after one year, loyal customers get 10%
discount = 10
if customer.first_purchase_date <= now - 5*year:
# after five years, 12%
discount = 12
if customer.first_purchase_date <= now - 10*year:
# after ten years, 20%
discount = 20
else:
# first time purchase ==> 15% discount
discount = 15
if customer.is_veteran:
discount = max(discount, 10)
return discount
Overwriting ocp_before.py
Tests¶
# %%writefile ocp-tests.py
from datetime import datetime, timedelta
import pytest
### Update import: ocp_before or ocp_after
from ocp_after import Customer, calculate_discount_percentage
YEAR = timedelta(days=365)
@pytest.fixture
def now():
return datetime.now()
def test_should_return_zero_for_casual_customer(now):
customer = Customer(
first_purchase_date=now,
birth_date=now-20*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 0
assert got == expected
def test_should_return_15_for_new_client(now):
customer = Customer(
first_purchase_date=None,
birth_date=now-20*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 15
assert got == expected
def test_should_return_10_for_veteran(now):
customer = Customer(
first_purchase_date=now,
birth_date=now-20*YEAR,
is_veteran=True,
)
got = calculate_discount_percentage(customer)
expected = 10
assert got == expected
def test_should_return_5_for_a_senior(now):
customer = Customer(
first_purchase_date=now,
birth_date=now-65*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 5
assert got == expected
def test_should_return_10_for_a_loyal_customer(now):
customer = Customer(
first_purchase_date=now-1*YEAR,
birth_date=now-20*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 10
assert got == expected
def test_should_return_12_for_a_more_loyal_customer(now):
customer = Customer(
first_purchase_date=now-5*YEAR,
birth_date=now-20*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 12
assert got == expected
def test_should_return_20_for_a_most_loyal_customer(now):
customer = Customer(
first_purchase_date=now-10*YEAR,
birth_date=now-20*YEAR,
is_veteran=False,
)
got = calculate_discount_percentage(customer)
expected = 20
assert got == expected
def test_should_return_maximum_discount(now):
customer = Customer(
first_purchase_date=None,
birth_date=now-20*YEAR,
is_veteran=True,
)
# eligible for 15% discount as a new client and 10% as a veteran
got = calculate_discount_percentage(customer)
expected = 15
assert got == expected
Overwriting ocp-tests.py
! pytest ocp-tests.py
============================= test session starts ============================== platform darwin -- Python 3.12.1, pytest-8.2.2, pluggy-1.5.0 rootdir: /Users/a563420/python_training/design_patterns plugins: anyio-4.4.0 collected 8 items ocp-tests.py ........ [100%] ============================== 8 passed in 0.06s ===============================
Hint¶
def first_purchase_rule(customer):
if customer.first_purchase_date is None:
return 15
else:
return 0
def senior_rule(customer):
...
# Loyalty Rule?
# 1 rok => 10%
# 5 lat => 12%
# 10 lat => 20%
class LoyalCustomerRule:
def __init__(self, years, discount):
self.years = years
self.discount = discount
def __call__(self, customer):
if ...:
return self.discount
else:
return 0
def veteran_rule(customer):
...
def calculate_discount_percentage(customer):
strategy = [
first_purchase_rule,
senior_rule,
LoyalCustomerRule(1, 10),
...
]
# compute maximum discount and return it
def calculate_discount_percentage_new(customer):
strategy = [
first_purchase_rule,
senior_rule,
LoyalCustomerRule(1, 10),
...
]
# compute maximum discount and return it
Solution 1¶
# %%writefile ocp_after.py
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR
@dataclass
class Customer:
first_purchase_date: Optional[datetime] # datetime or None
birth_date: datetime
is_veteran: bool
# @property
# def age(self):
# now = datetime.now()
# return now - customer.birth_date
def senior_rule(customer):
now = datetime.now()
if customer.birth_date <= now - SENIOR_AGE:
return 5
else:
return 0
def first_purchase_rule(customer):
if customer.first_purchase_date is None:
return 15
else:
return 0
@dataclass
class LoyalCustomerRule:
years: int
discount: float
def __call__(self, customer):
now = datetime.now()
if (customer.first_purchase_date is not None and
customer.first_purchase_date <= now - self.years * YEAR):
return self.discount
else:
return 0
def veteran_rule(customer):
#int x = cond ? value_if_true : value_if_false
# x = value_if_true if cond else value_if_else
return 10 if customer.is_veteran else 0
def calculate_discount_percentage(customer):
rules = [
senior_rule,
first_purchase_rule,
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=20),
veteran_rule,
]
discounts = [rule(customer) for rule in rules]
return max(discounts, default=0)
def calculate_discount_percentage_new(customer):
rules = [
senior_rule,
first_purchase_rule,
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=15),
veteran_rule,
]
discounts = [rule(customer) for rule in rules]
return max(discounts, default=0)
Solution 2¶
# %%writefile ocp_after.py
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
import pytest
YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR
@dataclass
class Customer:
first_purchase_date: Optional[datetime] # datetime or None
birth_date: datetime
is_veteran: bool
# @property
# def age(self):
# now = datetime.now()
# return now - customer.birth_date
class Rule:
def __call__(self, customer: Customer) -> float:
raise NotImplementedError
@dataclass
class SeniorRule(Rule):
discount: float
def __call__(self, customer):
now = datetime.now()
if customer.birth_date <= now - SENIOR_AGE:
return self.discount
else:
return 0
@dataclass
class FirstPurchaseRule(Rule):
discount: float
def __call__(self, customer):
if customer.first_purchase_date is None:
return self.discount
else:
return 0
@dataclass
class LoyalCustomerRule(Rule):
years: int
discount: float
def __call__(self, customer):
now = datetime.now()
if (customer.first_purchase_date is not None and
customer.first_purchase_date <= now - self.years * YEAR):
return self.discount
else:
return 0
@dataclass
class VeteranRule(Rule):
discount: float
def __call__(self, customer):
#int x = cond ? value_if_true : value_if_false
# x = value_if_true if cond else value_if_else
return self.discount if customer.is_veteran else 0
class DiscountCalculator:
def __init__(self, rules):
self.rules = rules
def __call__(self, customer):
discounts = [rule(customer) for rule in self.rules]
return max(discounts, default=0)
calculate_discount_percentage = DiscountCalculator([
SeniorRule(discount=5),
FirstPurchaseRule(discount=15),
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=20),
VeteranRule(discount=10),
])
calculate_discount_percentage_new = DiscountCalculator([
SeniorRule(discount=5),
FirstPurchaseRule(discount=15),
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=15),
VeteranRule(discount=10),
])
class PaymentMethod:
def process_payment(self, amount: float):
raise NotImplementedError
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PayPalPayment(PaymentMethod):
def process_payment(self, email: str, amount: float):
print(f"Processing PayPal payment of ${amount} from {email}")
Solution 3¶
# %%writefile ocp_after.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Optional
import pytest
YEAR = timedelta(days=365)
SENIOR_AGE = 65 * YEAR
@dataclass
class Customer:
first_purchase_date: Optional[datetime] # datetime or None
birth_date: datetime
is_veteran: bool
# @property
# def age(self) -> int:
# now = datetime.now()
# return now - customer.birth_date
class Rule(ABC):
@abstractmethod
def __call__(self, customer: 'Customer') -> float:
raise NotImplementedError
@abstractmethod
def is_eligible(self, customer: 'Customer') -> bool:
raise NotImplementedError
@dataclass
class SimpleRule(Rule):
discount: float
def is_eligible(self, customer: Customer) -> float:
raise NotImplementedError # child implement please
def __call__(self, customer: Customer) -> float: # child please don't chnage this logic
if self.is_eligible(customer):
return self.discount
else:
return 0
@dataclass
class SeniorRule(SimpleRule):
def is_eligible(self, customer):
return customer.birth_date <= datetime.now() - SENIOR_AGE
@dataclass
class FirstPurchaseRule(SimpleRule):
def is_eligible(self, customer):
return customer.first_purchase_date is None
@dataclass
class LoyalCustomerRule(SimpleRule):
# implicit discount: float
years: int
def is_eligible(self, customer):
now = datetime.now()
return (customer.first_purchase_date is not None and
customer.first_purchase_date <= now - self.years * YEAR)
@dataclass
class VeteranRule(SimpleRule):
def is_eligible(self, customer):
return customer.is_veteran
class DiscountCalculator:
def __init__(self, rules):
self.rules = rules
def __call__(self, customer):
discounts = [rule(customer) for rule in self.rules]
return max(discounts, default=0)
calculate_discount_percentage = DiscountCalculator([
SeniorRule(discount=5),
FirstPurchaseRule(discount=15),
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=20),
VeteranRule(discount=10),
])
calculate_discount_percentage_new = DiscountCalculator([
SeniorRule(discount=5),
FirstPurchaseRule(discount=15),
LoyalCustomerRule(years=1, discount=10),
LoyalCustomerRule(years=5, discount=12),
LoyalCustomerRule(years=10, discount=15),
VeteranRule(discount=10),
])
Liskov Substitution Principle (LSP)¶
The Liskov Substitution Principle (LSP) is one of the five SOLID principles and helps guide how we structure the relationship between parent classes and their subclasses. The basic idea behind LSP is:
"You should be able to replace an object of a parent class with an object of its subclass without breaking the program."
In simpler terms, subclasses should behave in a way that doesn't contradict or change the expected behavior of the parent class. When you replace a parent class with a subclass, the program should still function as intended without any surprises or errors.
Why is LSP Important?¶
LSP is important because it ensures that your code is flexible, modular, and reusable. If subclasses behave in unexpected ways or don’t fully respect the behavior of their parent classes, you could introduce bugs into the system. By following LSP, your code will be easier to understand, maintain, and extend.
When subclasses follow LSP, you can build systems where components are interchangeable. This makes your program more flexible and reduces the risk of errors when extending your code.
Key Rules of LSP (In Simple Terms)¶
To follow the Liskov Substitution Principle, there are three important rules to keep in mind when creating subclasses:
You can't make the rules stricter in a subclass.
- This means that if the parent class allows something, the subclass should allow it too. You shouldn’t make it harder for the subclass to do the same job as the parent class.
Example: If the parent class accepts a wide range of inputs, the subclass should not narrow it down by only accepting a more specific set of inputs.
You can’t relax the promises made by the parent class.
- If the parent class promises to give a certain output (called the “postcondition”), the subclass can’t lower that expectation. It can provide more detailed or specific results, but it should always meet the base expectations set by the parent.
Example: If a parent class promises that a method returns a number, a subclass can’t suddenly return a text string. It can return more specific types of numbers, but it can’t break the original promise.
You must preserve the overall behavior of the parent class.
- The subclass should maintain the core rules and logic of the parent class. It shouldn’t introduce new behaviors that contradict how the parent class works. This is called preserving the “invariants,” or the things that are always true for the parent class.
Example: If a parent class guarantees that certain properties of an object won’t change during its lifetime, the subclass should maintain those guarantees as well.
Example: Payment System with Credit Card and PayPal¶
Suppose you have a payment system with a base PaymentMethod
class that processes payments. You want to add different payment methods, such as credit cards and PayPal, but they should behave in a way that follows the Liskov Substitution Principle.
Bad Example: Violating LSP¶
In this example, the PayPalPayment
class violates LSP by introducing different parameters for processing a payment, making it incompatible with the PaymentMethod
interface.
class PaymentMethod:
def process_payment(self, amount: float):
raise NotImplementedError
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PayPalPayment(PaymentMethod):
def process_payment(self, email: str, amount: float):
print(f"Processing PayPal payment of ${amount} from {email}")
Here, if we try to substitute PayPalPayment
for a PaymentMethod
, the code will break because the parameters for process_payment
do not match.
Refactored Example: Following LSP¶
To follow LSP, we should make sure that all payment methods adhere to the same contract (i.e., the method signatures should be consistent).
class PaymentMethod:
def process_payment(self, amount: float):
raise NotImplementedError
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PayPalPayment(PaymentMethod):
def __init__(self, email: str):
self.email = email
def process_payment(self, amount: float):
print(f"Processing PayPal payment of ${amount} from {self.email}")
class PaymentMethod:
def __init__(self, customer: object):
self.customer = customer
def process_payment(self, amount: float):
raise NotImplementedError
class CreditCardPayment(PaymentMethod):
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PayPalPayment(PaymentMethod):
def process_payment(self, amount: float):
print(f"Processing PayPal payment of ${amount} from {self.customer.email}")
Example: Rectangle and Square Problem¶
A classic example used to demonstrate LSP is the Rectangle and Square problem. At first glance, it might seem reasonable to make Square
a subclass of Rectangle
, since a square is a type of rectangle. But this leads to issues when applying the LSP.
Bad Example: Violating LSP¶
In this example, we create a Square
class as a subclass of Rectangle
. The Rectangle
class has two methods, set_width
and set_height
, but when substituting a Square
object in place of a Rectangle
, unexpected behavior occurs.
class Rectangle:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def set_width(self, width: int):
self.width = width
def set_height(self, height: int):
self.height = height
def get_area(self):
return self.width * self.height
class Square(Rectangle):
def set_width(self, width: int):
self.width = width
self.height = width # Both sides are equal
def set_height(self, height: int):
self.height = height
self.width = height # Both sides are equal
In this case, if we substitute a Square
for a Rectangle
, the set_width
or set_height
method will modify both dimensions, which breaks the expected behavior of a Rectangle
.
Refactored Example: Following LSP¶
To properly follow LSP, the Square
class should not be a subclass of Rectangle
since they behave differently. Instead, we can refactor the design by separating them into two distinct classes.
class Rectangle:
def __init__(self, width: int, height: int):
self.width = width
self.height = height
def get_area(self):
return self.width * self.height
class Square:
def __init__(self, side_length: int):
self.side_length = side_length
def get_area(self):
return self.side_length * self.side_length
Example: Bird and Penguin Problem¶
Let’s consider a scenario where we have a Bird
class that can fly, and we create a subclass Penguin
. Since penguins can't fly, this is a violation of LSP because a Penguin
object will not behave correctly when substituted for a Bird
object.
Bad Example: Violating LSP¶
class Bird:
def fly(self):
print("Flying in the sky!")
class Penguin(Bird):
def fly(self):
raise Exception("Penguins can't fly!")
n this example, if we treat a Penguin
as a Bird
and call the fly
method, it will raise an exception, which breaks the expected behavior.
Refactored Example: Following LSP¶
To fix this, we can refactor the design so that not all birds are expected to fly. We can introduce an abstract base class Bird
, and separate flying birds and non-flying birds.
from abc import ABC, abstractmethod
class Bird(ABC):
@abstractmethod
def make_sound(self):
pass
class FlyingBird(Bird):
def fly(self):
print("Flying in the sky!")
class NonFlyingBird(Bird):
def walk(self):
print("Walking on the ground!")
class Penguin(NonFlyingBird):
def make_sound(self):
print("Penguin sound!")
class Sparrow(FlyingBird):
def make_sound(self):
print("Chirping sound!")
Interface Segregation Principle (ISP)¶
The Interface Segregation Principle (ISP) is the fourth principle in the SOLID design principles and deals with how interfaces should be designed and used. Simply put:
"No client should be forced to depend on methods it does not use."
In other words, large interfaces should be broken down into smaller, more specific ones. Classes should only implement the methods they need and use, and not be forced to implement unnecessary functionality.
Why is ISP Important?¶
The Interface Segregation Principle is important because it helps keep your code more modular, flexible, and easy to maintain. By having smaller, focused interfaces, you avoid situations where classes are burdened with methods they don’t need, which can lead to bloated code, difficulty in maintaining, and potential bugs.
ISP helps prevent what’s known as “fat interfaces” or “God interfaces”, which are interfaces that have too many responsibilities or methods. Such interfaces force classes to implement methods they don’t need, which violates the principle of “separation of concerns.” By following ISP, you ensure that each class and interface has a clear and focused responsibility.
Example: Printer Interface Problem¶
Consider an interface for a multifunction printer that includes various operations like printing, scanning, and faxing. Not all printers support all of these operations, so it doesn’t make sense for every printer class to implement every method.
Bad Example: Violating ISP¶
In this example, a single interface Printer
has too many methods, forcing classes to implement methods they don’t use:
class Printer:
def print_document(self, document):
raise NotImplementedError
def scan_document(self, document):
raise NotImplementedError
def fax_document(self, document):
raise NotImplementedError
class BasicPrinter(Printer):
def print_document(self, document):
print("Printing document")
def scan_document(self, document):
pass # Basic printer cannot scan, but still needs to implement this method
def fax_document(self, document):
pass # Basic printer cannot fax either
Here, the BasicPrinter
class is forced to implement scan_document
and fax_document
even though it doesn’t support these features. This violates ISP because it’s making the class depend on methods it does not need.
Refactored Example: Following ISP¶
To follow ISP, we can split the Printer
interface into smaller, more focused interfaces that group related functionality. Each class will only implement the interfaces it needs:
class Printer:
def print_document(self, document):
raise NotImplementedError
class Scanner:
def scan_document(self, document):
raise NotImplementedError
class FaxMachine:
def fax_document(self, document):
raise NotImplementedError
class BasicPrinter(Printer):
def print_document(self, document):
print("Printing document")
class AdvancedPrinter(Printer, Scanner, FaxMachine):
def print_document(self, document):
print("Printing document")
def scan_document(self, document):
print("Scanning document")
def fax_document(self, document):
print("Faxing document")
Example: Media Player Problem¶
Let’s consider a media player that can play different types of media like audio and video. Not all media players support both, so it doesn’t make sense for them to implement methods for formats they can’t play.
Bad Example: Violating ISP¶
In this bad example, the MediaPlayer
interface includes methods for playing both audio and video, forcing a class that only supports audio to implement the video method as well:
class MediaPlayer:
def play_audio(self, audio_file):
raise NotImplementedError
def play_video(self, video_file):
raise NotImplementedError
class AudioPlayer(MediaPlayer):
def play_audio(self, audio_file):
print(f"Playing audio: {audio_file}")
def play_video(self, video_file):
pass # Audio players cannot play video, but still need to implement this method
The AudioPlayer
class is forced to implement the play_video
method even though it doesn’t support video playback, violating ISP.
Refactored Example: Following ISP¶
To adhere to ISP, we can break down the MediaPlayer
interface into smaller, more focused interfaces for audio and video:
class AudioPlayerInterface:
def play_audio(self, audio_file):
raise NotImplementedError
class VideoPlayerInterface:
def play_video(self, video_file):
raise NotImplementedError
class AudioPlayer(AudioPlayerInterface):
def play_audio(self, audio_file):
print(f"Playing audio: {audio_file}")
class VideoPlayer(AudioPlayerInterface, VideoPlayerInterface):
def play_audio(self, audio_file):
print(f"Playing audio: {audio_file}")
def play_video(self, video_file):
print(f"Playing video: {video_file}")
Dependency Inversion Principle (DIP)¶
The Dependency Inversion Principle (DIP) is the fifth and final principle of the SOLID design principles. It helps guide how we structure dependencies in our code. In simple terms, the Dependency Inversion Principle states:
"High-level modules should not depend on low-level modules. Both should depend on abstractions."
Additionally:
"Abstractions should not depend on details. Details should depend on abstractions."
In essence, instead of having high-level components directly rely on low-level components, both should depend on an abstraction, such as an interface or abstract class. This makes your code more flexible, easier to test, and allows you to switch out implementations without affecting the overall system.
Why is DIP Important?¶
DIP is crucial for making code easier to maintain and modify. By ensuring that high-level modules (which contain your core business logic) don't depend on the nitty-gritty details of low-level modules (e.g., specific database interactions or APIs), you can change, extend, or replace parts of your system without breaking the whole program. This leads to better modularity, and a more flexible, scalable architecture.
Example: Notification System¶
Let’s consider a notification system where you need to send messages via different services such as email and SMS.
Bad Example: Violating DIP¶
In this example, the Notification
class directly depends on the concrete classes EmailService
and SMSService
. This violates DIP because the high-level module (Notification
) is tightly coupled to the low-level modules (specific services).
class EmailService:
def send_email(self, message: str):
print(f"Sending email: {message}")
class SMSService:
def send_sms(self, message: str):
print(f"Sending SMS: {message}")
class Notification:
def __init__(self):
self.email_service = EmailService()
self.sms_service = SMSService()
def notify(self, message: str):
self.email_service.send_email(message)
self.sms_service.send_sms(message)
Here, if you want to change the way messages are sent (e.g., adding a PushNotification service), you would need to modify the Notification
class. This tightly couples the high-level Notification
class to low-level implementations, making it harder to extend or maintain.
Refactored Example: Following DIP¶
To follow the Dependency Inversion Principle, we can create an abstraction (like an interface or abstract class) for message sending and make the high-level Notification
class depend on this abstraction, not on the concrete implementations.
from abc import ABC, abstractmethod
class MessageService(ABC):
@abstractmethod
def send(self, message: str):
pass
class EmailService(MessageService):
def send(self, message: str):
print(f"Sending email: {message}")
class SMSService(MessageService):
def send(self, message: str):
print(f"Sending SMS: {message}")
class Notification:
def __init__(self, service: MessageService):
self.service = service
def notify(self, message: str):
self.service.send(message)
Example 2: Payment Processing¶
Imagine you have a payment system where different payment methods like PayPal, credit cards, and bank transfers are used.
Bad Example: Violating DIP¶
Here, the PaymentProcessor
class directly depends on the low-level classes PayPalService
and CreditCardService
. This makes the PaymentProcessor
difficult to extend when new payment methods are added.
class PayPalService:
def process_payment(self, amount: float):
print(f"Processing PayPal payment of ${amount}")
class CreditCardService:
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PaymentProcessor:
def __init__(self):
self.paypal_service = PayPalService()
self.credit_card_service = CreditCardService()
def process(self, method: str, amount: float):
if method == "paypal":
self.paypal_service.process_payment(amount)
elif method == "credit_card":
self.credit_card_service.process_payment(amount)
In this setup, the PaymentProcessor
is tightly coupled to specific payment services. Adding new methods like BankTransferService
would require modifying the PaymentProcessor
class.
Refactored Example: Following DIP¶
To follow DIP, we abstract the payment services into an interface and make the PaymentProcessor
class depend on the abstraction, not the concrete implementations.
from abc import ABC, abstractmethod
class PaymentService(ABC):
@abstractmethod
def process_payment(self, amount: float):
pass
class PayPalService(PaymentService):
def process_payment(self, amount: float):
print(f"Processing PayPal payment of ${amount}")
class CreditCardService(PaymentService):
def process_payment(self, amount: float):
print(f"Processing credit card payment of ${amount}")
class PaymentProcessor:
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
def process(self, amount: float):
self.payment_service.process_payment(amount)
Example: Data Persistence Layer¶
Consider a system where you need to save data to different storage systems, such as a database or a file system.
from abc import ABC, abstractmethod
class StorageService(ABC):
@abstractmethod
def save_data(self, data):
pass
class DatabaseStorage(StorageService):
def save_data(self, data):
print(f"Saving data to the database: {data}")
class FileStorage(StorageService):
def save_data(self, data):
print(f"Saving data to a file: {data}")
class DataManager:
def __init__(self, storage_service: StorageService):
self.storage_service = storage_service
def save(self, data):
self.storage_service.save_data(data)
database_storage = DatabaseStorage()
file_storage = FileStorage()
data_manager = DataManager(database_storage)
data_manager.save("Some important data")
Saving data to the database: Some important data
Example: Controlling a Light with a Button¶
This example demonstrates how tightly coupling a ToggleButton
to a specific LEDLight
implementation can violate the Dependency Inversion Principle (DIP) and how to refactor the design to follow the principle.
Bad Example: Violating DIP¶
In the following example, the ToggleButton
class is directly dependent on the LEDLight
class from an external library. The button directly controls the light by setting its RGB values, tightly coupling the button to this specific light implementation.
class LEDLight: # External library
def setRGB(self, r: float, g: float, b: float) -> None:
print(f"Set color to {r}, {g}, {b}")
class ToggleButton:
def __init__(self, light: LEDLight):
self.light = light
def click(self):
# Directly controls the light, violating DIP
self.light.setRGB(1, 1, 1) # Turns the light on with a specific color
In this example, ToggleButton
is tightly coupled to LEDLight
, which means:
- You can't reuse
ToggleButton
with other types of lights. - If the
LEDLight
class changes, theToggleButton
class also has to change, violating DIP.
Refactored Example: Following DIP¶
To follow the Dependency Inversion Principle, we can introduce an abstraction, like a Switch
interface, that both the button and light depend on. The ToggleButton
will depend on the abstraction, allowing the light's implementation details to change without affecting the button.
class LEDLight: # External library
def setRGB(self, r: float, g: float, b: float) -> None:
print(f"Set color to {r}, {g}, {b}")
from abc import ABC, abstractmethod
class Switch(ABC): # Abstraction for controlling lights
@abstractmethod
def on(self):
pass
class LEDSwitch(Switch): # Implementation of Switch for LED lights
def __init__(self):
self.led_light = LEDLight()
def on(self):
# Turns on the LED light with white color
self.led_light.setRGB(1, 1, 1)
class ToggleButton:
def __init__(self, switch: Switch):
self.switch = switch # Depends on abstraction, not specific light
def click(self):
self.switch.on() # Triggers the on method without knowing the details
Now, ToggleButton
is dependent on the Switch
interface, which can have different implementations like LEDSwitch
or any other type of switch. This follows the Dependency Inversion Principle:
- The high-level
ToggleButton
no longer depends directly on low-level details likeLEDLight
. - You can easily swap out
LEDSwitch
with another implementation (e.g., a switch for a different type of light), without modifying theToggleButton
class.
Creational Patterns¶
Creational patterns are design patterns that focus on how objects are created. They help make systems more flexible by allowing objects to be created in different ways depending on the situation. Instead of directly instantiating objects (using new
in some languages, or calling a class constructor), creational patterns allow us to delegate this responsibility to another part of the system. This makes the system more adaptable and easier to extend or modify when the requirements change.
The main goal of creational patterns is to manage and control the process of object creation, ensuring that the system remains decoupled and flexible. These patterns also often provide solutions to deal with complex object creation, especially when the exact type or configuration of the object isn't known until runtime.
Common Creational Patterns:
- Factory Method: Creates objects without specifying the exact class.
- Abstract Factory: Allows you to create families of related objects without specifying their concrete classes.
- Singleton: Ensures a class has only one instance and provides a global point of access to it.
- Builder: Separates the construction of a complex object from its representation, allowing you to create different types of objects with the same construction process.
- Prototype: Creates new objects by copying existing objects, allowing for flexible and efficient object creation.
Prototype Pattern¶
The Prototype Pattern is a creational design pattern that allows you to create new objects by cloning existing objects. Instead of instantiating a new object directly, you clone an existing object, which can then be customized as needed. This pattern is useful when creating new objects is expensive or when we need many similar objects.
In simpler terms, the Prototype Pattern lets you make a copy of an object to create a new one. This is particularly handy when an object’s setup is complex or resource-intensive. By cloning an already configured object, you can skip the process of building everything from scratch.
When to Use the Prototype Pattern:¶
- When object creation is costly: If the setup of an object requires a lot of time or resources, it’s easier to clone an existing instance instead of building a new one.
- When you need to avoid subclasses: If you want to create new instances of a class without going through the inheritance or factory method approach, cloning a prototype is a simpler solution.
- When objects are similar: If you need several objects that share the same structure or properties but have slight differences, the Prototype Pattern is a perfect fit.
How It Works:¶
- You create a prototype object (an existing instance).
- Instead of creating new objects directly, you make clones (copies) of this prototype.
- Each clone can be modified if needed, but the core structure remains the same as the prototype.
By using the Prototype Pattern, you avoid the complexity of recreating new objects from scratch, and you gain flexibility in how you customize each clone.
Use Cases¶
- Clone a record in a DB: If you're versioning a record and each version needs to be stored as a separate record, you can clone the original record, modify the necessary fields, and save the clone.
- Copy data to prevent accidental modification: When working on data that might be shared between different parts of the system, you can create a copy of it to ensure that changes don't affect the original.
- Test data preparation: In testing, you may need to prepare slightly different test data for multiple tests. Instead of creating it from scratch each time, you can create a prototype, clone it for each test, and then modify the clone as needed.
- Copy-pasting in editors: Any editor (such as a document editor) that allows you to copy and paste elements is using a form of the Prototype Pattern. You copy the original element and then make modifications to the clone.
xample:
class PersonModel(models.Model):
first_name = models.CharField()
last_name = models.CharField()
def clone(self):
...
Example: Cloning a Database Record¶
Suppose we are dealing with a system that stores versions of records in a database. Each version is a new record. Instead of creating a new record from scratch, we clone an existing one.
import copy
class PersonModel:
def __init__(self, first_name, last_name):
self.first_name = first_name
self.last_name = last_name
def clone(self):
return copy.deepcopy(self)
# Usage
record = PersonModel("John", "Doe")
new_version = record.clone()
new_version.last_name = "Smith" # Versioned record with slight modification
Example: Creating Test Data for Unit Tests¶
In unit testing, test data preparation is often repetitive. With the Prototype Pattern, you can create a prototype of the test data and clone it for each test.
import copy
class TestData:
def __init__(self, data):
self.data = data
def clone(self):
return copy.deepcopy(self)
# Usage in tests
prototype_data = TestData({"name": "John", "age": 30})
# Test 1
test_data_1 = prototype_data.clone()
test_data_1.data["age"] = 25
# Test 2
test_data_2 = prototype_data.clone()
test_data_2.data["name"] = "Jane"
print(test_data_1.data) # Output: {'name': 'John', 'age': 25}
print(test_data_2.data) # Output: {'name': 'Jane', 'age': 30}
{'name': 'John', 'age': 25} {'name': 'Jane', 'age': 30}
Shallow vs Deep Copy¶
When cloning objects in Python, it’s important to understand the difference between shallow copy and deep copy:
- Shallow Copy: Creates a new object, but references the original elements. If the object contains mutable elements, changes to them will reflect in the original object.
- Deep Copy: Creates a completely independent copy of the object and all of its nested elements.
import copy
original = [1,2, ['one', 'two']]
deep_clone = copy.deepcopy(original)
shallow_clone = copy.copy(original)
original[2].append('three') # add "three to inner list"
original.append(4) # add 4 to outer list
print(original)
[1, 2, ['one', 'two', 'three'], 4]
print(deep_clone)
[1, 2, ['one', 'two']]
print(shallow_clone)
[1, 2, ['one', 'two', 'three']]
As you can see, the shallow copy (shallow_clone
) references the same inner list, so changes to the inner list in the original are reflected in the shallow copy. The deep copy (deep_clone
), on the other hand, is completely independent.
Point.clone()¶
What to Do¶
Your task is to implement the clone()
method for the Point
class, so that it correctly returns a copy of the Point
object. Additionally, ensure that the method works with inheritance: if a subclass of Point
calls .clone()
, the method should return an instance of the subclass, not the base Point
class.
Initial Code¶
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
z: float
def clone(self):
...
Expected Behaviour¶
p1 = Point(3, 4, 5)
assert (p1.x, p1.y, p1.z) == (3, 4, 5)
p2 = p1.clone()
p2.z = 8
assert p2.z == 8, p2
assert p1.z == 5, p1
#### Expected Behaviour -- inheritance
class ExtendedPoint(Point):
def distance_to(self, other):
return ((self.x-other.x)**2 + (self.y-other.y)**2 + (self.z-other.z)**2) ** 0.5
ex_p = ExtendedPoint(3, 4, 0)
ex_p2 = ExtendedPoint(0, 0, 0)
assert ex_p.distance_to(ex_p2) == 5.0
assert type(ex_p) is ExtendedPoint
assert type(ex_p.clone()) is not Point
assert type(ex_p.clone()) is ExtendedPoint
Solution¶
from dataclasses import dataclass
import copy
@dataclass
class Point:
x: float
y: float
z: float
def clone(self):
return copy.copy(self)
# return Point(x=self.x, y=self.y, z=self.z) # zle !!!
# Extended example with inheritance
class ExtendedPoint(Point):
def distance_to(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2 + (self.z - other.z) ** 2) ** 0.5
ex_p = ExtendedPoint(3, 4, 0)
ex_p2 = ExtendedPoint(0, 0, 0)
assert ex_p.distance_to(ex_p2) == 5.0
assert type(ex_p) is ExtendedPoint
assert type(ex_p.clone()) is ExtendedPoint
Copy Interface¶
In some cases, you may need to customize the way an object is copied. Python’s copy
module allows you to define how an object should be deep-copied by implementing the __deepcopy__
method.
import copy
class Logger:
def __init__(self, name):
self.name = name
def log(self, message):
print(f'Logging: {message}')
def __deepcopy__(self, memo):
# Custom deepcopy behavior: append an underscore to the name
return Logger(self.name + '_')
logger = Logger('main')
copied_logger = copy.deepcopy(logger)
print(copied_logger.name) # Output: main_
copied_copied_logger = copy.deepcopy(copied_logger)
print(copied_copied_logger.name) # Output: main_
main_ main__
# Copying a list of loggers
loggers = [Logger('first'), Logger('second')]
loggers_copy = copy.deepcopy(loggers)
print(loggers_copy[1].name) # Output: second_
second_
In this example, we customize the deep-copy behavior for the Logger
class. When a Logger
is copied, its name
attribute is modified by appending an underscore (_
).
Singleton Pattern¶
The Singleton Pattern is a creational design pattern that ensures a class has only one instance and provides a global point of access to that instance. This means that every time you request an instance of that class, you’ll get the same one, and no other instance will ever be created.
In simpler terms, the Singleton Pattern ensures that only one object of a particular class exists in the system, regardless of how many times you try to create it.
When to Use the Singleton Pattern:¶
- When a single instance is sufficient for all uses: For example, when you're dealing with things like logging services, database connections, or configuration settings, it's wasteful and risky to have multiple instances. One instance should be shared across the application.
- When you need a single point of control: This pattern is useful when one centralized object should manage some resource or provide services to other parts of your application.
- When multiple instances would cause issues: If multiple instances would break functionality (e.g., conflicting data or resource usage), Singleton ensures that this never happens.
None is Singleton¶
In Python, some built-in objects like None
already behave as singletons. When you assign variables to None
, they all point to the same memory location. Let’s see how None
behaves like a Singleton.
a = None
b = None
print(a is b)
print(id(a)) # Output: <same memory address>
print(id(b)) # Output: <same memory address>
print(id(copy.copy(a)))
True 4511203840 4511203840 4511203840
# Even if you create an instance of NoneType, it still behaves like a singleton.
NoneType = type(None)
my_none = NoneType()
print(id(my_none)) # Output: <same memory address>
4511203840
True and False are Singletons¶
Similarly, in Python, True
and False
also behave as singletons. Regardless of how many times you assign variables to True
or False
, they all refer to the same memory location.
a = False
b = False
print(a is b) # Output: True
print(id(a)) # Output: <same memory address>
print(id(b)) # Output: <same memory address>
True 4511116552 4511116552
Ellipsis (...
) is Singleton¶
Another interesting built-in singleton in Python is Ellipsis
, represented by the ...
symbol. It's often used as a placeholder in Python and, like None
, there is only one instance of Ellipsis
.
a = ...
b = Ellipsis
print(a is b) # Output: True
print(id(a)) # Output: <same memory address>
print(id(b)) # Output: <same memory address>
True 4511212192 4511212192
# Even if you create an instance of the ellipsis class, it behaves like a singleton.
ellipsis_cls = type(...)
new_ellipsis = ellipsis_cls()
print(id(new_ellipsis)) # Output: <same memory address>
4511212192
Non-Singleton¶
Now, let’s see a non-singleton example. When you create instances of a normal class, each object is distinct, and they don’t share the same memory location.
class MyClass:
pass
a = MyClass()
b = MyClass()
print(id(a)) # Output: <unique memory address>
print(id(b)) # Output: <different memory address>
4547322288 4547316672
Java-like Singleton Implementation¶
In Python, you can implement the Singleton pattern in a way that mimics Java's typical approach. The idea is to provide a method to access the instance of the class, but ensure that the constructor is called only once. If the instance already exists, it should return the existing one.
What to Do¶
- Implement a class
CounterSingleton
with a class-level attributeinstance
that holds the singleton instance. - The constructor should only be called once. For further access to the object, a class method
get_instance()
should be used.
Initial Code¶
class CounterSingleton:
def __init__(self):
self._next_id = 0
def get_instance(cls):
...
def get_next_id(self):
self._next_id += 1
return self._next_id
# Expected Behavior
a = CounterSingleton.get_instance()
b = CounterSingleton.get_instance()
print(id(a)) # Output: same memory address for both a and b
print(id(b)) # Output: same memory address for both a and b
print(a.get_next_id()) # Output: 1
print(a.get_next_id()) # Output: 2
print(b.get_next_id()) # Output: 3
Solution¶
class CounterSingleton:
instance = None
def __init__(self):
self._next_id = 0
@classmethod
def get_instance(cls):
if cls.instance is None:
cls.instance = cls() # Create only one instance
return cls.instance
def get_next_id(self):
self._next_id += 1
return self._next_id
# Expected Behavior
a = CounterSingleton.get_instance()
b = CounterSingleton.get_instance()
print(id(a)) # Both instances have the same memory address
print(id(b)) # Both instances have the same memory address
print(a.get_next_id()) # Output: 1
print(a.get_next_id()) # Output: 2
print(b.get_next_id()) # Output: 3
# Accessing it from different files/modules:
# one_file.py
counter = CounterSingleton.get_instance()
print(counter.get_next_id()) # Output: 4
# another_module.py
counter = CounterSingleton.get_instance()
print(counter.get_next_id()) # Output: 5
4547321280 4547321280 1 2 3 4 5
c = CounterSingleton()
print(id(a))
print(id(b))
print(id(c))
4547321280 4547321280 4549132992
Singleton with Modules¶
In Python, modules naturally behave as singletons. When a module is imported, it's loaded and initialized once. Every subsequent import of that module returns the same instance. This makes Python modules behave like singletons without extra code.
# %%writefile counter_singleton.py
_next_id = 0
def get_next_id():
global _next_id
_next_id += 1
return _next_id
def reset_counter():
global _next_id
_next_id = 0
Writing counter_singleton.py
Once this module is imported, you can call get_next_id()
and it will behave like a singleton.
from counter_singleton import get_next_id, reset_counter
print(get_next_id()) # Output: 1
print(get_next_id()) # Output: 2
print(get_next_id()) # Output: 3
reset_counter()
print(get_next_id()) # Output: 1
1 2 3 1
Singleton with __new__
Method in Python¶
In Python, another way to implement the Singleton pattern is by overriding the __new__
method. The __new__
method is responsible for creating a new instance of a class. By controlling this method, we can ensure that only one instance of the class is ever created, regardless of how many times we try to instantiate it.
Here’s how you can implement a Singleton using the __new__
method:
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
# Testing the Singleton
singleton1 = Singleton()
singleton2 = Singleton()
print(singleton1 is singleton2) # Output: True
True
Parametrized Singleton¶
In some cases, you may need a Parametrized Singleton. This is a variation of the Singleton Pattern where different parameters result in different singleton instances. This means that a singleton instance is created per unique parameter, and requests with the same parameter will return the same instance.
class ParametrizedSingleton:
_instances = {}
def __new__(cls, param1, param2):
key = (param1, param2)
if key not in cls._instances:
cls._instances[key] = super().__new__(cls)
return cls._instances[key]
def __init__(self, param1, param2):
self.param1 = param1
self.param2 = param2
# Testing the Parametrized Singleton
singleton1 = ParametrizedSingleton(10, 20)
singleton2 = ParametrizedSingleton(10, 20)
singleton3 = ParametrizedSingleton(30, 40)
# All instances with the same parameters should point to the same object
print(singleton1 is singleton2) # Output: True
# Instances with different parameters should be different
print(singleton1 is singleton3) # Output: False
# Checking the values stored in each instance
print(singleton1.param1, singleton1.param2) # Output: 10 20
print(singleton3.param1, singleton3.param2) # Output: 30 40
True False 10 20 30 40
One common example of a parametrized singleton in Python is the logging
module. The getLogger()
method returns a singleton logger for a given name. If you request a logger with the same name multiple times, it will return the same logger instance. If you request a logger with a different name, a new singleton instance is created for that name.
Let’s look at an example of how the logging
module works as a parametrized singleton:
import logging
# Get logger with the name 'A'
logger_a = logging.getLogger('A')
print(logger_a) # Output: <Logger A (WARNING)>
# Check that repeated calls to getLogger('A') return the same instance
print(id(logging.getLogger('A'))) # Output: Same memory address as logger_a
print(id(logging.getLogger('A'))) # Output: Same memory address as logger_a
# Get a different logger with the name 'B'
logger_b = logging.getLogger('B')
print(id(logger_b)) # Output: Different memory address from logger_a
<Logger A (WARNING)> 4549138320 4549138320 4547608256
Builder Pattern¶
The Builder Pattern is a creational design pattern that simplifies the construction of complex objects. It allows you to create different representations of an object by breaking down the creation process into steps. The key idea behind the Builder Pattern is to separate the object construction process from its representation, meaning that the same process can be used to create different types of objects or variations of the same object.
Why Use the Builder Pattern?¶
The Builder Pattern solves several challenges related to object creation:
- Creating complex objects: When an object is made up of multiple parts or requires multiple configuration steps, a traditional constructor may become cumbersome. The Builder Pattern makes this more manageable by splitting the creation into logical steps.
- Improving flexibility: The Builder Pattern allows you to create different versions of the same object using the same process. This is useful when dealing with objects that can have various configurations or optional parameters.
- Encapsulation of construction: The Builder Pattern encapsulates the code responsible for creating the object, ensuring the class itself doesn't break the Single Responsibility Principle (SRP). It also keeps the construction logic separate from the object’s internal logic.
Use Cases for the Builder Pattern:¶
- Creating complex hierarchies of objects: When building composite objects like trees or object graphs, a builder can make the construction easier and cleaner.
- Working with immutable objects that need to be temporarily mutable during their creation: The Builder Pattern allows you to mutate the object during the building phase, and then deliver it as immutable.
- Building complex SQL queries: Instead of manually concatenating query strings, a builder can generate SQL queries step by step.
- Form creation in UI systems: Builders are often used in user interfaces to create complex forms with multiple fields and configurations.
- Command-line argument parsers: Libraries like Python’s
argparse
use the Builder Pattern to construct parsers with multiple optional parameters and configurations. - Document generation: PDF builders are a common use case where text, images, and formatting are added step by step before generating the final document.
Database Example¶
Here’s an example of how a builder can simplify the creation of a complex database schema. Without a builder, setting up tables and filling them with data might require writing a lot of procedural code. With a builder, you can streamline this process by chaining methods.
class MSSQLDatabaseBuilder:
def __init__(self):
self.commands = []
def create_table(self, table_name):
self.commands.append(f"CREATE TABLE {table_name};")
return self
def create_string_column(self, column_name):
self.commands.append(f"ALTER TABLE ADD COLUMN {column_name} VARCHAR(255);")
return self
def fill_with_random_usernames(self):
self.commands.append("INSERT INTO user (username) VALUES ('random_user');")
return self
def fill_with_password_hashes(self, algorithm='bcrypt2'):
self.commands.append(f"INSERT INTO user (password) VALUES ('hashed_password_with_{algorithm}');")
return self
def build(self):
return "\n".join(self.commands)
# Example usage:
db = (MSSQLDatabaseBuilder()
.create_table('user')
.create_string_column('username')
.fill_with_random_usernames()
.create_string_column('password')
.fill_with_password_hashes(algorithm='bcrypt2')
.create_table('items')
.build())
print(db)
CREATE TABLE user; ALTER TABLE ADD COLUMN username VARCHAR(255); INSERT INTO user (username) VALUES ('random_user'); ALTER TABLE ADD COLUMN password VARCHAR(255); INSERT INTO user (password) VALUES ('hashed_password_with_bcrypt2'); CREATE TABLE items;
PDF Example¶
Generating documents, like PDFs, often involves adding various elements such as text, images, and formatting. Using a builder makes this process more intuitive and less error-prone.
class PDFBuilder:
def __init__(self):
self.content = []
def add_text(self, text, page=1, loc=(0, 0)):
self.content.append(f"Text '{text}' added at {loc} on page {page}")
return self
def add_image(self, image_path, page=1):
self.content.append(f"Image '{image_path}' added on page {page}")
return self
def generate(self, file_name):
# Simulate PDF generation by printing the commands
print(f"Generating PDF '{file_name}' with the following content:")
for item in self.content:
print(item)
return self
# Example usage:
doc = PDFBuilder()
doc.add_text('Hello, world!', page=1, loc=(0, 0)) \
.add_image('path/to/image.png', page=1) \
.add_image('path/to/image.png', page=2) \
.generate('output.pdf')
Generating PDF 'output.pdf' with the following content: Text 'Hello, world!' added at (0, 0) on page 1 Image 'path/to/image.png' added on page 1 Image 'path/to/image.png' added on page 2
<__main__.PDFBuilder at 0x10f0e6a50>
Matplotlib Example¶
Matplotlib is a powerful plotting library in Python, and it also uses a builder-like interface. You can build plots step by step, adding data series, titles, labels, and so on.
from matplotlib import pyplot as plt
# Builder-like process for creating a plot
plt.figure(figsize=(10, 5)) # Set up the figure size
plt.plot([1, 3, 2]) # Add the first plot line
plt.plot([3, 4, 2]) # Add another plot line
plt.grid() # Add grid lines
plt.show() # Render and display the plot
Complex Builder Example: Building a Car¶
Let’s consider a more complex example of the Builder Pattern by building a Car
object step by step. A car can have multiple components, such as an engine, wheels, color, and seats. These components might vary based on the model or customer preferences. The Builder Pattern allows us to construct different cars using the same process, but with variations in the components.
In this example, we’ll create a CarBuilder
that helps build a Car
object with multiple parts. The builder will allow us to specify different configurations for the car, and at the end, we’ll call build()
to get the final Car
object.
Car Components:¶
- Engine: The type of engine (e.g., electric, gasoline).
- Wheels: The number of wheels.
- Color: The car’s exterior color.
- Seats: The number of seats.
class Car:
def __init__(self):
self.engine = None
self.wheels = None
self.color = None
self.seats = None
def __str__(self):
return (f"Car with {self.engine} engine, {self.wheels} wheels, "
f"painted {self.color}, and {self.seats} seats.")
class CarBuilder:
def __init__(self):
self.car = Car()
def add_engine(self, engine_type):
self.car.engine = engine_type
return self
def add_wheels(self, wheel_count):
self.car.wheels = wheel_count
return self
def paint(self, color):
self.car.color = color
return self
def add_seats(self, seat_count):
self.car.seats = seat_count
return self
def build(self):
return self.car # Return the final car object
# Example usage
car_builder = CarBuilder()
# Build a sports car
sports_car = (car_builder
.add_engine("V8")
.add_wheels(4)
.paint("Red")
.add_seats(2)
.build())
print(sports_car) # Output: Car with V8 engine, 4 wheels, painted Red, and 2 seats.
# Build a family car
family_car = (car_builder
.add_engine("Electric")
.add_wheels(4)
.paint("Blue")
.add_seats(5)
.build())
print(family_car) # Output: Car with Electric engine, 4 wheels, painted Blue, and 5 seats.
Car with V8 engine, 4 wheels, painted Red, and 2 seats. Car with Electric engine, 4 wheels, painted Blue, and 5 seats.
How It Works:¶
- CarBuilder Class: This class is responsible for adding various parts to the
Car
object. Each method returns the builder itself (self
), allowing for method chaining. - Method Chaining: The pattern allows you to add each component step by step, resulting in clear and readable code.
- Flexibility: You can create different types of cars (sports cars, family cars, etc.) using the same
CarBuilder
class, just by chaining different methods together. - Car Class: The
Car
class represents the final object that we are building. It contains the properties (engine, wheels, color, seats) that can be configured through the builder.
Advantages of This Approach:¶
- Separation of Concerns: The
CarBuilder
focuses only on constructing the car, while theCar
class handles the details of what a car is. - Easy to Extend: If you need to add more components to the car (e.g., a sunroof, GPS system), you can easily extend the
CarBuilder
with new methods without modifying existing logic. - Flexibility: Different configurations of the car can be created using the same builder, which makes the process flexible and reusable.
Exercise: MongoDB Aggregation Query Builder¶
In this exercise, you will implement a MongoDB aggregation query builder using the Builder Pattern, following the Single Responsibility and Open-Closed principles.
MongoDB aggregation pipelines are used to process data in stages, such as filtering documents, grouping results, and sorting. Your task is to build a query builder that constructs these aggregation stages step by step.
To maintain immutability and allow for method chaining in your query builder, you'll need to create a copy of the current state of the query at each step. This allows you to add new stages to the query without modifying the original query. By using new = self.copy()
, you can ensure that the original object remains unchanged while returning a new object with the updated query stages.
This approach adheres to the Builder Pattern by allowing incremental, step-by-step construction of the query while preserving immutability, ensuring that previous versions of the query remain intact. Each method (match()
, group()
, sort()
, etc.) will return a new query object, allowing for flexible combinations of query stages without side effects.
Objectives:¶
Build a Query in Stages: Implement methods like
match()
,group()
,sort()
, andlimit()
to add stages to the query. These methods should usenew = self.copy()
to create a new version of the query object with the added stage, maintaining immutability.Use Method Chaining: Each method should return a new instance of the query builder with the added stage, allowing for method chaining (e.g.,
query.match(...).group(...).sort(...)
).Adhere to SOLID Principles:
- Single Responsibility Principle: Each class (e.g.,
MatchStage
,GroupStage
) should handle only one specific task (building that stage of the query). - Open-Closed Principle: Your builder should be easily extendable with new stages (e.g., adding
$unwind
or$project
stages) without modifying the existing code.
- Single Responsibility Principle: Each class (e.g.,
The use of new = self.copy()
ensures that each operation returns a new, updated query object, making the builder flexible and supporting method chaining while avoiding unintended modifications to previous query states.
Initial Code¶
from typing import List, Dict
import copy
# MongoDB Aggregation Stages
class MongoAggregationStage:
"""Represents an aggregation stage in MongoDB."""
def to_stage(self) -> Dict:
"""Convert this stage to its MongoDB representation."""
raise NotImplementedError
class MatchStage(MongoAggregationStage):
def __init__(self, criteria: Dict):
...
...
class GroupStage(MongoAggregationStage):
def __init__(self, group_by: str, accumulator: Dict):
...
...
class SortStage(MongoAggregationStage):
def __init__(self, sort_by: str, order: int = 1):
...
...
# TODO: Add the LimitStage class
# class LimitStage ...
# MongoDB Aggregation Query Builder
class MongoAggregationQueryBuilder:
def __init__(self):
self.stages: List[MongoAggregationStage] = []
def match(self, criteria: Dict):
"""Add a match stage to the aggregation pipeline."""
new = self.copy()
# TODO: Implement the match method using MatchStage
pass
# TODO: Implement the group, sort, limit methods
def copy(self):
return copy.deepcopy(self)
def build(self) -> List[Dict]:
"""Return the aggregation pipeline as a list of stages."""
# TODO: Implement the build method to return the stages
pass
Hints for Implementation¶
- MongoDB Aggregation Basics:
$match
: Filters documents based on the criteria provided.{ "$match": { "age": { "$gte": 18 } } }
$group
: Groups documents by a specified field and applies an accumulator function like$sum
.{ "$group": { "_id": "$city", "total": { "$sum": 1 } } }
$sort
: Orders documents based on the specified field in ascending (1
) or descending (-1
) order.{ "$sort": { "age": 1 } }
$limit
: Restricts the number of documents returned.{ "$limit": 5 }
Testing¶
query = MongoAggregationQueryBuilder()
# Test 1: Match only
q1 = query.match({"username": "admin"})
expected_q1 = [{ "$match": { "username": "admin" } }]
assert q1.build() == expected_q1, f"Expected: {expected_q1}, but got: {q1.build()}"
# Test 2: Match and Group
q2 = query.match({"username": "admin"}).group("role", {"count": {"$sum": 1}})
expected_q2 = [
{ "$match": { "username": "admin" } },
{ "$group": { "_id": "$role", "count": { "$sum": 1 } } }
]
assert q2.build() == expected_q2, f"Expected: {expected_q2}, but got: {q2.build()}"
# Test 3: Sort and Limit
q3 = query.sort("created_at", -1).limit(5)
expected_q3 = [
{ "$sort": { "created_at": -1 } },
{ "$limit": 5 }
]
assert q3.build() == expected_q3, f"Expected: {expected_q3}, but got: {q3.build()}"
# Multi-stage test: Match, Group, Sort, Limit
multi_stage_query = (
query
.match({"age": {"$gte": 18}}) # Match stage: Filter documents where age >= 18
.group("city", {"total": {"$sum": 1}}) # Group stage: Group by city and count total
.sort("total", -1) # Sort stage: Sort by total in descending order
.limit(10) # Limit stage: Limit results to 10 documents
)
expected_multi_stage_query = [
{ "$match": { "age": { "$gte": 18 } } },
{ "$group": { "_id": "$city", "total": { "$sum": 1 } } },
{ "$sort": { "total": -1 } },
{ "$limit": 10 }
]
assert multi_stage_query.build() == expected_multi_stage_query, \
f"Expected: {expected_multi_stage_query}, but got: {multi_stage_query.build()}"
Solution¶
from typing import List, Dict
import copy
# MongoDB Aggregation Stages
class MongoAggregationStage:
"""Represents an aggregation stage in MongoDB."""
def to_stage(self) -> Dict:
"""Convert this stage to its MongoDB representation."""
raise NotImplementedError
class MatchStage(MongoAggregationStage):
def __init__(self, criteria: Dict):
self.criteria = criteria
def to_stage(self) -> Dict:
return {"$match": self.criteria}
class GroupStage(MongoAggregationStage):
def __init__(self, group_by: str, accumulator: Dict):
self.group_by = group_by
self.accumulator = accumulator
def to_stage(self) -> Dict:
return {"$group": {"_id": f"${self.group_by}", **self.accumulator}}
class SortStage(MongoAggregationStage):
def __init__(self, sort_by: str, order: int = 1):
self.sort_by = sort_by
self.order = order
def to_stage(self) -> Dict:
return {"$sort": {self.sort_by: self.order}}
class LimitStage(MongoAggregationStage):
def __init__(self, limit: int):
self.limit = limit
def to_stage(self) -> Dict:
return {"$limit": self.limit}
# MongoDB Aggregation Query Builder
class MongoAggregationQueryBuilder:
def __init__(self):
self.stages: List[MongoAggregationStage] = []
def copy(self):
return copy.deepcopy(self)
def match(self, criteria: Dict):
new = self.copy()
new.stages.append(MatchStage(criteria))
return new
def group(self, group_by: str, accumulator: Dict):
new = self.copy()
new.stages.append(GroupStage(group_by, accumulator))
return new
def sort(self, sort_by: str, order: int = 1):
new = self.copy()
new.stages.append(SortStage(sort_by, order))
return new
def limit(self, count: int):
new = self.copy()
new.stages.append(LimitStage(count))
return new
def build(self) -> List[Dict]:
return [stage.to_stage() for stage in self.stages]
# Testing the solution
query = MongoAggregationQueryBuilder()
q1 = query.match({"username": "admin"})
q2 = query.match({"username": "admin"}).group("role", {"count": {"$sum": 1}})
q3 = query.sort("created_at", -1).limit(5)
print(q1.build()) # [{ "$match": { "username": "admin" } }]
print(q2.build()) # [{ "$match": { "username": "admin" } }, { "$group": { "_id": "$role", "count": { "$sum": 1 } } }]
print(q3.build()) # [{ "$sort": { "created_at": -1 } }, { "$limit": 5 }]
[{'$match': {'username': 'admin'}}] [{'$match': {'username': 'admin'}}, {'$group': {'_id': '$role', 'count': {'$sum': 1}}}] [{'$sort': {'created_at': -1}}, {'$limit': 5}]
# Example of a multi-stage MongoDB aggregation query
# Initialize the query builder
query = MongoAggregationQueryBuilder()
# Build a query with multiple stages
multi_stage_query = (
query
.match({"age": {"$gte": 18}}) # Match stage: Filter documents where age >= 18
.group("city", {"total": {"$sum": 1}}) # Group stage: Group by city and count total
.sort("total", -1) # Sort stage: Sort by total in descending order
.limit(10) # Limit stage: Limit results to 10 documents
)
# Print the resulting query pipeline
print(multi_stage_query.build())
[{'$match': {'age': {'$gte': 18}}}, {'$group': {'_id': '$city', 'total': {'$sum': 1}}}, {'$sort': {'total': -1}}, {'$limit': 10}]
Exercise: Builder in Tests¶
Write tests for Order class. You can find example data below:
shipping_address = Address(
name='John Doe',
address='123 Street',
city='Springfield',
state='MO',
zipcode=65807,
)
billing_information = BillingInformation(
billing_address=shipping_address,
credit_card_number='1234567890123456',
expiration_month=12,
expiration_year=2022,
card_holder_name='John P. Doe',
)
card = [
Item(
guid=1234,
quantity=2,
price=10.0,
name='water',
description='spilled water',
category='drinking',
)
]
Check:
- If Order.total() returns valid result (20)?
- If the order can be processed without any error?
- If you change the credit card number to empty, if you get an error (ValueError) when you try to proceed?
- If Order.total() returns valid result, when the order is exactly as above, except that it contains an additional Item (make sure that guid is unique)?
- If Order.total() returns valid result, when the order is exactly as above, except that it contains two additional Items (make sure that guid is unique)?
- ★ If Order.total() returns valid result, when the order is exactly as above, except that it contains items from the file below.
%%writefile items.csv
guid,price,quantity
1,5,15
5,8,20
5,8,20
Writing items.csv
Initial Code¶
# %%writefile builder_example.py
from dataclasses import dataclass
from typing import List
@dataclass
class Item:
guid: str
name: str
description: str
category: str
quantity: float
price: float
@dataclass
class Address:
name: str
address: str
city: str
state: str
zipcode: str
@dataclass
class BillingInformation:
billing_address: Address
credit_card_number: str
expiration_month: int
expiration_year: int
card_holder_name: str
@dataclass
class Order:
cart: List[Item]
billing_information: BillingInformation
shipping_address: Address
def total(self):
return sum(item.quantity * item.price for item in self.cart)
def proceed(self):
if len(self.billing_information.credit_card_number) != 16:
raise ValueError('Invalid credit card number')
print("Proceeding")
Writing builder_example.py
Factory Method Pattern¶
The Factory Method Pattern is a creational design pattern that defines an interface for creating objects but allows subclasses to determine which class to instantiate. The primary goal of this pattern is to encapsulate the logic for object creation in a method, giving flexibility to handle complex object creation while decoupling the client from specific implementations.
When Should You Use the Factory Method Pattern?¶
The Factory Method is particularly useful when:
- Object creation involves complex decision-making or configuration.
- Different types of objects need to be created depending on context (e.g., environment, user preferences, configurations).
- You want to decouple the creation process from the object usage, allowing flexibility in changing or extending object types without modifying existing code.
Simple Factory Pattern¶
Purpose: The Simple Factory Pattern provides a way to encapsulate object creation logic. The goal is to create objects without having to specify the exact class of object that will be created.
How It Works:
- It defines an interface for creating an object, but lets subclasses or other components decide which class to instantiate.
- This pattern typically uses a single method (often
create()
orgetInstance()
) that returns different types of objects depending on the input parameters.
Example: If you have a
VehicleFactory
that creates different types of vehicles (Car
,Bike
, etc.) based on the input, it decides which specific class to instantiate but returns a generalVehicle
interface or abstract class.class VehicleFactory: def create_vehicle(self, vehicle_type): if vehicle_type == 'car': return Car() elif vehicle_type == 'bike': return Bike() # Client Code factory = VehicleFactory() vehicle = factory.create_vehicle('car') # Creates a Car object
Use Case: Factory Pattern is used when you need to create instances of a class from a family of derived classes without knowing the exact subclass that will be instantiated.
Abstract Factory Pattern¶
Purpose: The Abstract Factory Pattern provides an interface to create families of related or dependent objects without specifying their concrete classes. It is more complex and powerful than the Factory Pattern.
How It Works:
- Instead of a single method to create objects, the abstract factory has multiple methods to create various related objects (a family of objects).
- The goal is to ensure that related products (objects) are created together in a consistent way.
- Each factory class (concrete factory) is responsible for creating objects from a particular product family.
Example: If you’re building an application that deals with multiple UI themes (e.g., MacOS vs. Windows UI), an abstract factory might produce related components like
Button
,Window
, andCheckbox
specific to each theme.class AbstractUIFactory: def create_button(self): pass def create_window(self): pass class MacOSFactory(AbstractUIFactory): def create_button(self): return MacButton() def create_window(self): return MacWindow() class WindowsFactory(AbstractUIFactory): def create_button(self): return WindowsButton() def create_window(self): return WindowsWindow() # Client Code def create_ui(factory: AbstractUIFactory): button = factory.create_button() window = factory.create_window() factory = MacOSFactory() create_ui(factory) # Creates MacOS-style button and window
Use Case: Abstract Factory Pattern is used when you need to create families of related or dependent objects that should be used together and ensure that the objects created are from the same family. For example, when creating multiple types of objects (like buttons, windows, scrollbars) that are related and need to conform to a theme or style (e.g., MacOS, Windows).
Key Differences between Simple and Abstract Factories¶
Complexity: The Factory Pattern is simpler, typically dealing with the creation of one type of object at a time, while the Abstract Factory Pattern handles the creation of multiple related objects (families of objects).
Object Scope:
- Simple Factory Pattern: Focuses on creating one object at a time.
- Abstract Factory Pattern: Deals with families of objects and ensures consistency across them.
Usage:
- Use Factory Pattern when you need to decouple the object creation process but are only creating a single product.
- Use Abstract Factory Pattern when creating groups of related objects that need to maintain consistency.
Both patterns promote loose coupling by abstracting the process of object creation, but the Abstract Factory Pattern is more versatile when managing complex scenarios involving multiple types of related objects.
Example Scenario: A Cloud Resource Manager¶
Let’s consider a cloud resource manager that provisions different types of virtual machines (VMs) on AWS, Google Cloud, and Azure. The process of provisioning a VM is complex, as it depends on:
- The cloud provider (AWS, Google Cloud, Azure).
- Different configuration parameters (CPU, memory, disk space).
- Credentials (each provider has its own method of authentication).
- The availability of certain resources on each platform.
In this case, the Factory Method pattern is ideal because it encapsulates all the complexity of provisioning a VM, which is different depending on the cloud provider.
Without Factory Method¶
The code below shows how the process of provisioning VMs might look without the Factory Method. Each time we need to provision a VM, we have to handle the specific logic for each cloud provider, which leads to repetition and tightly coupled code.
class AWSVM:
def provision(self, cpu, memory, disk_size):
print(f"Provisioning AWS VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
class GoogleCloudVM:
def provision(self, cpu, memory, disk_size):
print(f"Provisioning Google Cloud VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
class AzureVM:
def provision(self, cpu, memory, disk_size):
print(f"Provisioning Azure VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
def provision_vm(provider, cpu, memory, disk_size):
if provider == "aws":
vm = AWSVM()
elif provider == "google_cloud":
vm = GoogleCloudVM()
elif provider == "azure":
vm = AzureVM()
else:
raise ValueError(f"Unknown provider: {provider}")
vm.provision(cpu, memory, disk_size)
# Usage
provision_vm("aws", 4, 16, 100) # Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk
provision_vm("google_cloud", 2, 8, 50) # Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk
Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk
Issues:¶
- Tight Coupling: The client is tightly coupled with the logic of determining which VM to create.
- Limited Extensibility: Adding a new provider requires modifying the client logic, which violates the Open-Closed Principle.
- Scattered Logic: The logic of selecting the correct VM type is repeated every time the
provision_vm
function is called.
With Factory Method Pattern¶
Now, let’s apply the Factory Method Pattern to handle this situation. The factory method will encapsulate all the decision-making logic for provisioning VMs, allowing for better flexibility and future extensibility.
# Abstract VM class (Product)
class VM:
def provision(self, cpu: int, memory: int, disk_size: int):
raise NotImplementedError
# AWS VM class (Concrete Product)
class AWSVM(VM):
def provision(self, cpu: int, memory: int, disk_size: int):
print(f"Provisioning AWS VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
# Google Cloud VM class (Concrete Product)
class GoogleCloudVM(VM):
def provision(self, cpu: int, memory: int, disk_size: int):
print(f"Provisioning Google Cloud VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
# Azure VM class (Concrete Product)
class AzureVM(VM):
def provision(self, cpu: int, memory: int, disk_size: int):
print(f"Provisioning Azure VM with {cpu} CPUs, {memory} GB RAM, {disk_size} GB Disk")
# Abstract Factory (Creator)
class CloudProviderFactory:
def create_vm(self) -> VM:
raise NotImplementedError
def authenticate(self):
raise NotImplementedError
def provision_vm(self, cpu: int, memory: int, disk_size: int):
self.authenticate() # Authenticate to cloud provider
vm = self.create_vm() # Factory Method: Create VM based on cloud provider
vm.provision(cpu, memory, disk_size) # Provision VM with specific config
# AWS Factory (Concrete Creator)
class AWSFactory(CloudProviderFactory):
def create_vm(self) -> VM:
return AWSVM()
def authenticate(self):
print("Authenticating to AWS using IAM credentials...")
# Google Cloud Factory (Concrete Creator)
class GoogleCloudFactory(CloudProviderFactory):
def create_vm(self) -> VM:
return GoogleCloudVM()
def authenticate(self):
print("Authenticating to Google Cloud using OAuth tokens...")
# Azure Factory (Concrete Creator)
class AzureFactory(CloudProviderFactory):
def create_vm(self) -> VM:
return AzureVM()
def authenticate(self):
print("Authenticating to Azure using Active Directory...")
# Usage
def deploy_vm(factory: CloudProviderFactory, cpu: int, memory: int, disk_size: int):
factory.provision_vm(cpu, memory, disk_size)
# Test the Factory Method with different providers
deploy_vm(AWSFactory(), 4, 16, 100) # Authenticates and provisions an AWS VM
deploy_vm(GoogleCloudFactory(), 2, 8, 50) # Authenticates and provisions a Google Cloud VM
deploy_vm(AzureFactory(), 8, 32, 200) # Authenticates and provisions an Azure VM
Authenticating to AWS using IAM credentials... Provisioning AWS VM with 4 CPUs, 16 GB RAM, 100 GB Disk Authenticating to Google Cloud using OAuth tokens... Provisioning Google Cloud VM with 2 CPUs, 8 GB RAM, 50 GB Disk Authenticating to Azure using Active Directory... Provisioning Azure VM with 8 CPUs, 32 GB RAM, 200 GB Disk
Key Benefits of the Factory Method Pattern Here:¶
Encapsulation of Complex Logic: The factories (
AWSFactory
,GoogleCloudFactory
,AzureFactory
) encapsulate the complex logic of authentication and VM creation. The client (deploy_vm
) doesn't need to worry about which cloud provider it’s dealing with.Decoupling from Specific Implementations: The
deploy_vm
function relies on theCloudProviderFactory
interface and doesn’t care about the specific type of VM being created. This makes the code more flexible and easier to maintain.Extensibility: If a new cloud provider (e.g., DigitalOcean) needs to be supported, you can simply create a new
DigitalOceanFactory
class without modifying any existing code. This adheres to the Open-Closed Principle.Real-World Relevance: This example mimics real-world systems where complex setup and authentication processes are handled differently for different cloud providers. Each cloud provider requires specific credentials, configurations, and logic for creating virtual machines.
Example: Document Generator Factory (Different File Formats)¶
# Document class (Product)
class Document:
def export(self, content: str):
raise NotImplementedError
# PDF Document class (Concrete Product)
class PDFDocument(Document):
def export(self, content: str):
print(f"Exporting content to PDF: {content}")
# Word Document class (Concrete Product)
class WordDocument(Document):
def export(self, content: str):
print(f"Exporting content to Word: {content}")
# HTML Document class (Concrete Product)
class HTMLDocument(Document):
def export(self, content: str):
print(f"Exporting content to HTML: {content}")
# Abstract Document Factory (Creator)
class DocumentFactory:
def create_document(self) -> Document:
raise NotImplementedError
def prepare_document(self, content: str):
document = self.create_document()
document.export(content)
# PDF Document Factory (Concrete Creator)
class PDFDocumentFactory(DocumentFactory):
def create_document(self) -> Document:
return PDFDocument()
# Word Document Factory (Concrete Creator)
class WordDocumentFactory(DocumentFactory):
def create_document(self) -> Document:
return WordDocument()
# HTML Document Factory (Concrete Creator)
class HTMLDocumentFactory(DocumentFactory):
def create_document(self) -> Document:
return HTMLDocument()
# Usage
def generate_report(factory: DocumentFactory, content: str):
factory.prepare_document(content)
# Test the Factory Method
generate_report(PDFDocumentFactory(), "This is the PDF content") # Exports to PDF
generate_report(WordDocumentFactory(), "This is the Word content") # Exports to Word
generate_report(HTMLDocumentFactory(), "This is the HTML content") # Exports to HTML
Exporting content to PDF: This is the PDF content Exporting content to Word: This is the Word content Exporting content to HTML: This is the HTML content
Excercise: Shapes¶
Implement Drawing.process_line, so that you can load different shapes from a file.
Initial Code¶
from dataclasses import dataclass
@dataclass
class Circle:
x: float
y: float
r: float
@dataclass
class Rectangle:
x: float
y: float
w: float
h: float
@dataclass
class Drawing:
_shapes: list
@classmethod
def from_string(cls, string):
shapes = []
for line in string.split('\n'):
line = line.strip()
if not line:
continue
shape = cls.process_line(line)
shapes.append(shape)
return cls(shapes)
@classmethod
def process_line(cls, line):
raise NotImplementedError
Test¶
raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
Circle(15, 10, 14),
Rectangle(30, 30, 100, 150),
Circle(40, 20, 5),
Rectangle(30, 100, 20, 20),
]
drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing
Hint¶
shape_factory = {
'Circle': Circle,
# pozostałe kształty
}
Bad Solution¶
### Solution without Factory Method
from dataclasses import dataclass
@dataclass
class Circle:
x: float
y: float
r: float
@dataclass
class Rectangle:
x: float
y: float
w: float
h: float
@dataclass
class Drawing:
_shapes: list
@classmethod
def from_string(cls, string):
shapes = []
for line in string.split('\n'):
line = line.strip()
if not line:
continue
shape = cls.process_line(line)
shapes.append(shape)
return cls(shapes)
@classmethod
def process_line(cls, line):
tokens = line.split()
shape_name = tokens[0]
parameters = tokens[1:]
# shape_name, *parameters = line.split() # Python 3
parameters = map(int, parameters)
if shape_name == 'Circle':
shape = Circle(*parameters)
elif shape_name == 'Rectangle':
shape = Rectangle(*parameters)
elif shape_name == 'Square':
x, y, a = parameters
shape = Rectangle(x, y, a, a)
else:
raise TypeError
return shape
raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
Circle(15, 10, 14),
Rectangle(30, 30, 100, 150),
Circle(40, 20, 5),
Rectangle(30, 100, 20, 20),
]
drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing
Better Solution¶
from dataclasses import dataclass
@dataclass
class Circle:
x: float
y: float
r: float
@dataclass
class Rectangle:
x: float
y: float
w: float
h: float
def create_rectangle_from_square(x, y, a):
return Rectangle(x, y, a, a)
shape_factory = {
'Circle': Circle,
'Rectangle': Rectangle,
'Square': create_rectangle_from_square,
}
@dataclass
class Drawing:
_shapes: list
@classmethod
def from_string(cls, string):
shapes = []
for line in string.split('\n'):
line = line.strip()
if not line:
continue
shape = cls.process_line(line)
shapes.append(shape)
return cls(shapes)
@classmethod
def process_line(cls, line):
tokens = line.split()
shape_name = tokens[0]
parameters = tokens[1:]
# shape_name, *parameters = line.split() # Python 3
parameters = map(int, parameters)
try:
shape_creator = shape_factory[shape_name]
except KeyError:
raise ValueError
else:
shape = shape_creator(*parameters)
return shape
raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
Circle(15, 10, 14),
Rectangle(30, 30, 100, 150),
Circle(40, 20, 5),
Rectangle(30, 100, 20, 20),
]
drawing = Drawing.from_string(raw_shapes)
assert drawing == Drawing(expected_shapes), drawing
Good Solution¶
from abc import ABC, abstractmethod
from dataclasses import dataclass
@dataclass
class Circle:
x: float
y: float
r: float
@dataclass
class Rectangle:
x: float
y: float
w: float
h: float
# Abstract Factory Interface
class ShapeFactory(ABC):
@abstractmethod
def create_shape(self, *args):
pass
# Concrete Factory for Circle
class CircleFactory(ShapeFactory):
def create_shape(self, x, y, r):
return Circle(x, y, r)
# Concrete Factory for Rectangle
class RectangleFactory(ShapeFactory):
def create_shape(self, x, y, w, h):
return Rectangle(x, y, w, h)
# Concrete Factory for Square (using Rectangle)
class SquareFactory(ShapeFactory):
def create_shape(self, x, y, a):
return Rectangle(x, y, a, a)
# Shape Factory Mapping
shape_factory = {
'Circle': CircleFactory(),
'Rectangle': RectangleFactory(),
'Square': SquareFactory(),
}
@dataclass
class Drawing:
_shapes: list
@classmethod
def from_string(cls, string):
shapes = []
for line in string.split('\n'):
line = line.strip()
if not line:
continue
shape = cls.process_line(line)
shapes.append(shape)
return cls(shapes)
@classmethod
def process_line(cls, line):
tokens = line.split()
shape_name = tokens[0]
parameters = tokens[1:]
parameters = map(int, parameters)
try:
shape_creator = shape_factory[shape_name]
except KeyError:
raise ValueError(f"Unknown shape: {shape_name}")
else:
shape = shape_creator.create_shape(*parameters)
return shape
# Test with raw shape data
raw_shapes = '''
Circle 15 10 14
Rectangle 30 30 100 150
Circle 40 20 5
Square 30 100 20
'''
expected_shapes = [
Circle(15, 10, 14),
Rectangle(30, 30, 100, 150),
Circle(40, 20, 5),
Rectangle(30, 100, 20, 20), # Square is represented as a Rectangle with equal sides
]
# Create a Drawing from the raw string
drawing = Drawing.from_string(raw_shapes)
# Verify the result
assert drawing == Drawing(expected_shapes), drawing
Structural Patterns¶
Structural design patterns are patterns that focus on how to compose objects and classes into larger structures while keeping them flexible and efficient. These patterns are particularly useful when dealing with complex systems that involve many interacting objects or when adapting and modifying existing systems. They help define clear, maintainable relationships between classes and objects.
Why Use Structural Patterns?¶
- Simplify complex structures: Structural patterns help you build efficient and scalable object structures.
- Promote reusability: These patterns allow you to reuse components in a flexible manner, often adapting them to work in new contexts.
- Decouple components: They often promote loose coupling between components, making the system easier to maintain and extend.
Some of the key structural patterns include:
- Adapter
- Bridge
- Composite
- Decorator
- Facade
- Flyweight
- Proxy
Adapter Pattern¶
The Adapter Pattern is a structural design pattern that allows objects with incompatible interfaces to work together. In simple terms, it acts as a bridge between two incompatible interfaces, enabling existing classes to interact without modifying their source code.
What Problem Does the Adapter Pattern Solve?¶
Imagine you have a system that uses one interface, but you're given a class that implements a completely different interface. You can't or don't want to change either interface, but you still need them to work together. The Adapter Pattern allows you to adapt the incompatible class to work within your system by creating a new class (the adapter) that converts the interface of the class into one that your system can use.
Use Cases for the Adapter Pattern:¶
Hardware Adapters:
- A real-world analogy is a VGA-to-HDMI adapter. You have a monitor that only accepts HDMI input, but your computer has a VGA port. The adapter allows you to connect these incompatible devices.
Data Format Conversion:
- Suppose you’re working with two systems that use different data formats (e.g., CSV vs. JSON). An adapter can convert one format to the other, allowing the systems to communicate.
SQL Database Backends:
- If you need to support a new database backend (e.g., PostgreSQL instead of MySQL), you can create an adapter that translates the SQL commands used by the old backend into commands supported by the new one.
Legacy Code Integration:
- When integrating old, legacy systems into modern architectures, the Adapter Pattern is perfect for wrapping old interfaces and adapting them to work with newer APIs.
Example: Using the Adapter Pattern with io.StringIO
¶
In Python, the io.StringIO
class is a great example of an adapter. It adapts a string to behave like a file object, allowing you to use file-like operations such as read()
, write()
, and seek()
on a string. This is particularly useful when you have functions that expect file-like objects, but you want to provide them with a string.
In this example, we will adapt a string to be used as if it were a file using io.StringIO
, which acts as an adapter.
Problem¶
You have a function that works with file objects, but you want to provide it with a string instead. Instead of rewriting the function, you can use io.StringIO
to adapt the string so that it behaves like a file.
import io
# A function that expects a file object
def process_file(file_obj):
print("Reading first 4 characters:", file_obj.read(4))
print("Reading next 4 characters:", file_obj.read(4))
file_obj.seek(0) # Go back to the beginning of the file
print("Reading first 4 characters again:", file_obj.read(4))
# You have a string, but the function expects a file-like object
string_data = "This is a string that behaves like a file"
# Using io.StringIO as an adapter to make the string behave like a file object
file_like_string = io.StringIO(string_data)
# Process the string as if it were a file
process_file(file_like_string)
Reading first 4 characters: This Reading next 4 characters: is Reading first 4 characters again: This
Example: Hardware Adapter¶
In the real world, a VGA-to-HDMI adapter allows you to connect an old VGA monitor to a modern HDMI port. The adapter converts VGA signals into HDMI, making them compatible.
Let’s represent this concept in code:
# Old VGA interface (incompatible)
class VGAConnector:
def connect_vga(self):
return "VGA connected"
# New HDMI interface (desired)
class HDMIConnector:
def connect_hdmi(self):
return "HDMI connected"
# Adapter to make VGA work with HDMI
class HDMIToVGAAdapter(HDMIConnector):
def __init__(self, vga_device: VGAConnector):
self.vga_device = vga_device
def connect_hdmi(self):
# Adapts the VGA output to HDMI
return self.vga_device.connect_vga()
# Usage
vga_monitor = VGAConnector()
adapter = HDMIToVGAAdapter(vga_monitor)
# Now, we can use the VGA monitor with an HDMI connection
print(adapter.connect_hdmi()) # Output: VGA connected
VGA connected
Example: Adapting Data Formats (CSV to JSON)¶
In this example, you have a system that processes data in CSV format, but you now need to support an external system that works with JSON. Instead of rewriting the entire data processing pipeline, you can create an adapter to convert CSV to JSON.
import json
import csv
from io import StringIO
# The existing system that expects data in JSON format
class JSONProcessor:
def process(self, data):
return f"Processing JSON data: {data}"
# The new data source that provides data in CSV format
class CSVSource:
def get_csv(self):
return "name,age\nAlice,30\nBob,25"
# Adapter to convert CSV to JSON
class CSVToJSONAdapter:
def __init__(self, csv_source: CSVSource):
self.csv_source = csv_source
def convert_to_json(self):
csv_data = self.csv_source.get_csv()
f = StringIO(csv_data)
reader = csv.DictReader(f)
json_data = json.dumps([row for row in reader])
return json_data
# Usage
csv_source = CSVSource()
adapter = CSVToJSONAdapter(csv_source)
json_processor = JSONProcessor()
json_data = adapter.convert_to_json()
print(json_processor.process(json_data)) # Output: Processing JSON data: [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
Processing JSON data: [{"name": "Alice", "age": "30"}, {"name": "Bob", "age": "25"}]
from google.compute import instance as gcp_instance
class VM:
vm_model: dict
def provision(self):
...
class GCPVM(VM):
def provision(self):
self.gcp_instance.create(vm_model)
Exercise: Implement Stream2String Class¶
Implement the Stream2String
class that accepts an opened stream and has the interface of a string. For simplicity, it's enough if you implement the __getitem__
, __len__
, and __repr__
methods. The catch is that you should read everything from the stream and cache it on the first demand, that is first time that any of the above methods are used. Don't read from the stream in __init__
!
Initial Code¶
class Stream2String:
...
Testing¶
from io import StringIO
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert stream.tell() == 0 # Make sure that Stream2String.__init__ doesn't read from the stream
assert stringish[0] == 'a' # string.__getitem__(0)
assert stream.tell() == 9
print(repr(stringish))
assert repr(stringish) == 'asdf qwer' # string.__repr__()
assert len(stringish) == 9 # string.__len__()
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert repr(stringish) == 'asdf qwer'
Solution¶
class Stream2String:
def __init__(self, stream):
self._stream = stream
self._content = None
@property
def content(self):
if self._content is None:
self._content = self._stream.read()
return self._content
def __getitem__(self, index):
return self.content[index]
def __len__(self):
return len(self.content)
def __repr__(self):
return self.content
### Expected Behaviour
from io import StringIO
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert stream.tell() == 0 # Make sure that Stream2String.__init__ doesn't read from the stream
assert stringish[0] == 'a' # string.__getitem__(0)
assert stream.tell() == 9
print(repr(stringish))
assert repr(stringish) == 'asdf qwer' # string.__repr__()
assert len(stringish) == 9 # string.__len__()
stream = StringIO('asdf qwer')
stringish = Stream2String(stream)
assert repr(stringish) == 'asdf qwer'
asdf qwer
Proxy Pattern¶
The Proxy Pattern is a structural design pattern that provides a surrogate or placeholder for another object. In simple terms, a proxy is an object that controls access to another object, usually by intercepting method calls or requests before passing them on to the real object.
Why Use the Proxy Pattern?¶
There are several reasons to use the Proxy Pattern:
- Control Access: A proxy can control access to an object, preventing direct access to sensitive resources.
- Lazy Initialization: A proxy can delay the creation of expensive objects until they are actually needed.
- Logging or Monitoring: A proxy can be used to log interactions with the object, helping to track usage or debug issues.
- Security: A proxy can ensure that only authorized users have access to certain operations.
- Remote Access: A proxy can act as a local representative for an object that resides in a different location (e.g., a remote server).
Use Cases:¶
- Virtual Proxy: To delay the creation of resource-heavy objects until they are actually needed.
- Remote Proxy: To act as a local object that represents a resource located in a different system (e.g., a web service or a database).
- Access Proxy: To control access to sensitive resources, ensuring security or limiting who can perform certain actions.
- Logging/Monitoring Proxy: To intercept method calls and log or monitor them for debugging or audit purposes.
Example: Lazy Initialization (Virtual Proxy)¶
Imagine you have a resource-heavy object like a large image file. You don’t want to load the image until it's actually needed (e.g., when the user requests to view it). The proxy delays the creation of this object until it’s really necessary.
import time
# The Real Object (expensive to load)
class RealImage:
def __init__(self, filename):
self.filename = filename
self.load_image()
def load_image(self):
print(f"Loading image from {self.filename}...")
time.sleep(2) # Simulate time-consuming image loading
self.data = "Image data"
def display(self):
print(f"Displaying {self.filename}: {self.data}")
# The Proxy Object
class ImageProxy:
def __init__(self, filename):
self.filename = filename
self._real_image = None
def display(self):
if self._real_image is None:
self._real_image = RealImage(self.filename) # Load only when needed
self._real_image.display()
# Usage
print("Creating the proxy...")
image = ImageProxy("large_image.jpg")
print("Image not loaded yet.")
print("Displaying the image for the first time:")
image.display() # Image is loaded here for the first time
print("\nDisplaying the image again:")
image.display() # Image is already loaded, no delay this time
Example: Protection Proxy (Access Control)¶
In this example, we’ll use a proxy to control access to a sensitive resource, such as a bank account. Only authorized users can perform transactions, and the proxy ensures that users without sufficient privileges are denied access.
# Real Bank Account (Protected Resource)
class BankAccount:
def __init__(self, owner):
self.owner = owner
self.balance = 0
def deposit(self, amount):
self.balance += amount
print(f"{amount} deposited. New balance: {self.balance}")
def withdraw(self, amount):
if self.balance >= amount:
self.balance -= amount
print(f"{amount} withdrawn. New balance: {self.balance}")
else:
print(f"Insufficient funds. Current balance: {self.balance}")
# Proxy for controlling access to the BankAccount
class BankAccountProxy:
def __init__(self, bank_account, user):
self.bank_account = bank_account
self.user = user
def deposit(self, amount):
if self._is_authorized():
self.bank_account.deposit(amount)
else:
print(f"Access denied for {self.user}")
def withdraw(self, amount):
if self._is_authorized():
self.bank_account.withdraw(amount)
else:
print(f"Access denied for {self.user}")
def _is_authorized(self):
return self.user == self.bank_account.owner
# Usage
account = BankAccount("Alice")
# Authorized user
authorized_user = BankAccountProxy(account, "Alice")
authorized_user.deposit(100) # Output: 100 deposited. New balance: 100
authorized_user.withdraw(50) # Output: 50 withdrawn. New balance: 50
# Unauthorized user
unauthorized_user = BankAccountProxy(account, "Bob")
unauthorized_user.deposit(100) # Output: Access denied for Bob
unauthorized_user.withdraw(50) # Output: Access denied for Bob
100 deposited. New balance: 100 50 withdrawn. New balance: 50 Access denied for Bob Access denied for Bob
Exercise: Implement LazyStream
Class¶
Implement the LazyStream
class that behaves like a file stream, except that it creates the stream as late as possible, that is, on the first call to read
(or any other method). Don't create the stream in __init__
! For simplicity, implement only the read
, tell
, and seek
methods.
★ Use __getattr__
to delegate all method calls to the stream. This way you don't have to repeat read
, tell
, and seek
methods.
Initial Code¶
class LazyStream:
def __init__(self, file_name):
...
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self.close()
...
Tests¶
s = LazyStream('test.txt')
s.read()
# is equivalent to:
m = s.read # s.__getattr__('read')
m()
### Expected Behaviour
from pytest import raises
with raises(FileNotFoundError):
s = open('non-existent')
s = LazyStream('non-existent') # no error
with raises(FileNotFoundError):
s.read()
s = LazyStream('test.txt')
print(s.read())
s.seek(0)
print(s.read())
s.close()
with LazyStream('test.txt') as s: # s = LazyStream('test.txt').__enter__()
print(s.read())
s.seek(0)
print(s.read())
Solution¶
class LazyStream:
def __init__(self, file_name):
self._file_name = file_name
self._stream = None
@property
def stream(self):
if self._stream is None:
self._stream = open(self._file_name)
return self._stream
def tell(self, *args, **kwargs):
return self.stream.tell(*args, **kwargs)
def read(self, *args, **kwargs):
return self.stream.read(*args, **kwargs)
def seek(self, *args, **kwargs):
return self.stream.seek(*args, **kwargs)
def __getattr__(self, attr):
return getattr(self.stream, attr)
def close(self):
if self._stream is not None:
self._stream.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_value, exc_traceback):
self.close()
from pytest import raises
with raises(FileNotFoundError):
s = open('non-existent')
s = LazyStream('non-existent') # no error
with raises(FileNotFoundError):
s.read()
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) Cell In[76], line 1 ----> 1 from pytest import raises 3 with raises(FileNotFoundError): 4 s = open('non-existent') File ~/python_training/design_patterns/.venv/lib/python3.12/site-packages/pytest/__init__.py:21 19 from _pytest.config.argparsing import OptionGroup 20 from _pytest.config.argparsing import Parser ---> 21 from _pytest.debugging import pytestPDB as __pytestPDB 22 from _pytest.doctest import DoctestItem 23 from _pytest.fixtures import fixture File ~/python_training/design_patterns/.venv/lib/python3.12/site-packages/_pytest/debugging.py:17 15 from typing import TYPE_CHECKING 16 from typing import Union ---> 17 import unittest 19 from _pytest import outcomes 20 from _pytest._code import ExceptionInfo ModuleNotFoundError: No module named 'unittest'
Decorator Pattern¶
The Decorator Pattern is a design pattern used to add new functionality to an existing object without altering its structure. This is achieved by wrapping the original object with a new "decorator" object that can enhance or modify its behavior. The decorator pattern is particularly useful when you want to add responsibilities to individual objects dynamically and transparently, without affecting other objects.
Example Use Cases¶
- Extending Functionality: Suppose you have a basic logging system, and you want to add additional features such as timestamping and log level filtering without modifying the existing logging code.
- User Interface Components: In a graphical user interface, you might have a basic window object. Using the decorator pattern, you can add features like scrollbars, borders, or shadows to the window without changing its core functionality.
- Data Processing Pipelines: When processing data, you might want to add various processing steps (e.g., filtering, transforming) to the data stream. The decorator pattern allows you to chain these processing steps together in a flexible and reusable way.
Example: Basic Logging with Decorators¶
Let's start with a simple example of a logging system where we want to add a timestamp to each log message.
import datetime
class Logger:
def log(self, message):
print(message)
class TimestampLoggerDecorator:
def __init__(self, logger):
self._logger = logger
def log(self, message):
timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
self._logger.log(f"{timestamp} - {message}")
basic_logger = Logger()
timestamped_logger = TimestampLoggerDecorator(basic_logger)
basic_logger.log("This is a basic log message.")
timestamped_logger.log("This is a timestamped log message.")
This is a basic log message. 2024-10-08 10:42:18 - This is a timestamped log message.
Example: User Interface Components¶
class Window:
def render(self):
return "Rendering window"
class BorderDecorator:
def __init__(self, window):
self._window = window
def render(self):
return f"{self._window.render()} with border"
class ScrollbarDecorator:
def __init__(self, window):
self._window = window
def render(self):
return f"{self._window.render()} with scrollbar"
# Usage
basic_window = Window()
bordered_window = BorderDecorator(basic_window)
scrollbar_bordered_window = ScrollbarDecorator(bordered_window)
print(basic_window.render()) # Output: Rendering window
print(bordered_window.render()) # Output: Rendering window with border
print(scrollbar_bordered_window.render()) # Output: Rendering window with border with scrollbar
Rendering window Rendering window with border Rendering window with border with scrollbar
Exercise✏️ Starbucks¶
There are some basic drinks: Espresso
and Cappuccino
classes. Every drink has its own algorithm to be prepared (prepare
method). For simplicity, it prints "Making a perfect espresso" or "Making cappuccino". Every drink has methods to get the total price (get_total_price
) and the description of the drink (get_description
).
You want to let people add a whipped cream to both Espresso
and Cappuccino
. To prepare such a drink with whipped cream you need to prepare the original drink, and then, add the whipped cream, which is to print "Adding a whipped cream" after preparing the original drink. The price of such a drink is the price of the original drink plus an extra 2.5. The description should be "Espresso + Whipped Cream" or "Cappuccino + Whipped Cream".
You want to add more additions, i.e., Whisky for an extra 10.0. When preparing, print "Pouring 50cl of whisky". The description should be "original_drink + Whisky".
Initial Code¶
class Drink:
def prepare(self) -> None:
raise NotImplementedError
def get_total_price(self) -> float:
raise NotImplementedError
def get_description(self) -> str:
raise NotImplementedError
class BaseDrink(Drink):
def __init__(self):
super().__init__()
class Espresso(BaseDrink):
def get_total_price(self):
return 4.0
def get_description(self):
return "Espresso"
def prepare(self):
print("Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar")
class Cappuccino(BaseDrink):
def get_total_price(self):
return 5.0
def get_description(self):
return "Cappuccino"
def prepare(self):
print("Making cappuccino (an espresso combined with a perfect milk foam)")
drink = Espresso()
drink.prepare()
print(f"Price: {drink.get_total_price()}")
print(f"Description: {drink.get_description()}")
# How are you going to let people create an espresso with whipped cream?
Test¶
coffee = Whipped(Whipped(Espresso()))
assert coffee.get_total_price() == 9.0 # 4.0 + 2.5 + 2.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whipped Cream"
coffee.prepare()
# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Adding whipped cream
coffee = Whisky(Whipped(Espresso()))
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Pouring 50cl of whisky
Solution¶
class Drink:
def prepare(self) -> None:
raise NotImplementedError
def get_total_price(self) -> float:
raise NotImplementedError
def get_description(self) -> str:
raise NotImplementedError
class BaseDrink(Drink):
def __init__(self):
super().__init__()
class Espresso(BaseDrink):
def get_total_price(self):
return 4.0
def get_description(self):
return "Espresso"
def prepare(self):
print("Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar")
class Cappuccino(BaseDrink):
def get_total_price(self):
return 5.0
def get_description(self):
return "Cappuccino"
def prepare(self):
print("Making cappuccino (an espresso combined with a perfect milk foam)")
class Addition(Drink): # keep Drink interface
price: float
name: str
def __init__(self, drink: Drink):
self._drink = drink
def get_total_price(self):
return self._drink.get_total_price() + self.price
def get_description(self):
return f"{self._drink.get_description()} + {self.name}"
class Whipped(Addition):
price = 2.5
name = 'Whipped Cream'
def prepare(self):
self._drink.prepare()
print("Adding whipped cream")
class Whisky(Addition):
price = 10.0
name = 'Whisky'
def prepare(self):
self._drink.prepare()
print("Pouring 50cl of whisky")
coffee = Whipped(Whipped(Espresso()))
assert coffee.get_total_price() == 9.0 # 4.0 + 2.5 + 2.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whipped Cream"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar Adding whipped cream Adding whipped cream
coffee = Whisky(Whipped(Espresso()))
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar Adding whipped cream Pouring 50cl of whisky
Exercise✏️ Builder For Starbucks¶
Implement a Builder for a Starbucks.
Initial Code¶
from typing import Type
class CoffeeBuilder:
...
Test¶
coffee = (CoffeeBuilder()
.add(Whipped)
.set_base(Espresso)
.add(Whisky)
.get_coffee())
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
# Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar
# Adding whipped cream
# Pouring 50cl of whisky
from typing import Type
class CoffeeBuilder:
def __init__(self):
self._base = None
self._additions = []
def add(self, addition: Type[Addition]): # pass a definition of a class not an instance
self._additions.append(addition)
return self
def set_base(self, base: Type[BaseDrink]): # pass a definition of a class not an instance
self._base = base
return self
def get_coffee(self) -> Drink:
if self._base is None:
raise ValueError("Base is not defined")
coffee = self._base()
for addition in self._additions:
coffee = addition(coffee)
return coffee
### Expected Behaviour
coffee = (CoffeeBuilder()
.add(Whipped)
.set_base(Espresso)
.add(Whisky)
.get_coffee())
assert coffee.get_total_price() == 16.5
assert coffee.get_description() == "Espresso + Whipped Cream + Whisky"
coffee.prepare()
Making a perfect espresso: 8g of coffee, 96 Celsius, 16 bar Adding whipped cream Pouring 50cl of whisky
### Example how the builder is useful in event-driven application
def on_init():
global builder
builder = CoffeeBuilder()
def on_set_espresso():
builder.set_base(Espresso)
def on_set_cappuccino():
builder.set_base(Cappuccino)
def on_add_whipped_cream():
builder.add(Whipped)
def on_add_whisky():
builder.add(Whisky)
def on_make_drink():
global builder
coffee = builder.get_coffee()
coffee.prepare()
builder = CoffeeBuilder()
Facade Pattern¶
The Facade Pattern is a structural design pattern that provides a simplified interface to a complex subsystem. It aims to make the subsystem easier to use by hiding its complexity and exposing only the necessary functionalities. The Facade Pattern is particularly useful when dealing with large systems that have many interdependent classes, as it allows clients to interact with the system through a single, unified interface.
Example: Library Management System¶
class Book:
def __init__(self, title):
self.title = title
class Member:
def __init__(self, name):
self.name = name
class Transaction:
def __init__(self, book, member):
self.book = book
self.member = member
class LibraryFacade:
def __init__(self):
self.books = []
self.members = []
self.transactions = []
def add_book(self, title):
book = Book(title)
self.books.append(book)
print(f"Book '{title}' added to the library.")
def add_member(self, name):
member = Member(name)
self.members.append(member)
print(f"Member '{name}' added to the library.")
def borrow_book(self, title, member_name):
book = next((b for b in self.books if b.title == title), None)
member = next((m for m in self.members if m.name == member_name), None)
if book and member:
transaction = Transaction(book, member)
self.transactions.append(transaction)
print(f"Book '{title}' borrowed by '{member_name}'.")
else:
print("Book or member not found.")
# Usage
library = LibraryFacade()
library.add_book("1984")
library.add_member("Alice")
library.borrow_book("1984", "Alice")
Book '1984' added to the library. Member 'Alice' added to the library. Book '1984' borrowed by 'Alice'.
Example: Media Conversion¶
class AudioConverter:
def convert_to_mp3(self, file):
print(f"Converting {file} to MP3 format.")
class VideoConverter:
def convert_to_mp4(self, file):
print(f"Converting {file} to MP4 format.")
class MediaConverterFacade:
def __init__(self):
self.audio_converter = AudioConverter()
self.video_converter = VideoConverter()
def convert_audio(self, file, format):
if format == "mp3":
self.audio_converter.convert_to_mp3(file)
else:
print("Unsupported audio format.")
def convert_video(self, file, format):
if format == "mp4":
self.video_converter.convert_to_mp4(file)
else:
print("Unsupported video format.")
# Usage
media_converter = MediaConverterFacade()
media_converter.convert_audio("song.wav", "mp3")
media_converter.convert_video("movie.avi", "mp4")
Converting song.wav to MP3 format. Converting movie.avi to MP4 format.
Composite Pattern in Structural Patterns¶
The Composite Pattern is a structural design pattern that allows you to compose objects into tree structures to represent part-whole hierarchies. This pattern lets clients treat individual objects and compositions of objects uniformly. It is particularly useful when you need to work with tree-like structures, such as file systems, organizational hierarchies, or graphical user interfaces.
Example Use Cases¶
- File System: A file system where directories can contain files and other directories. The Composite Pattern allows you to treat files and directories uniformly.
- Organization Hierarchy: An organization where employees can be managers or regular employees. Managers can have subordinates, who can be either other managers or regular employees.
- Macros: Both single instructions and entire macros should have a
.run()
method. - JSON, DOM (HTML/XML): Any recursive tree-like structure where both leaves and nodes have the same interface.
- Graphical User Interface: Components like buttons, panels, and windows can be composed into complex UI elements.
Example: File System¶
In this example, we'll create a file system where directories can contain files and other directories. We'll add more functions to demonstrate the capabilities of the Composite Pattern.
from abc import ABC, abstractmethod
class FileSystemComponent(ABC):
@abstractmethod
def show_details(self):
pass
@abstractmethod
def rename(self, new_name):
pass
@abstractmethod
def move(self, new_location):
pass
class File(FileSystemComponent):
def __init__(self, name, size):
self.name = name
self.size = size
def show_details(self):
print(f"File: {self.name}, Size: {self.size}KB")
def rename(self, new_name):
self.name = new_name
print(f"File renamed to: {self.name}")
def move(self, new_location):
print(f"File {self.name} moved to {new_location}")
class Directory(FileSystemComponent):
def __init__(self, name):
self.name = name
self.children = []
def add(self, component):
self.children.append(component)
def remove(self, component):
self.children.remove(component)
def show_details(self):
print(f"Directory: {self.name}")
for child in self.children:
child.show_details()
def rename(self, new_name):
self.name = new_name
print(f"Directory renamed to: {self.name}")
def move(self, new_location):
print(f"Directory {self.name} moved to {new_location}")
for child in self.children:
child.move(new_location)
# Usage
root = Directory("root")
home = Directory("home")
user = Directory("user")
file1 = File("file1.txt", 100)
file2 = File("file2.txt", 200)
file3 = File("file3.txt", 300)
root.add(home)
home.add(user)
user.add(file1)
user.add(file2)
root.add(file3)
root.show_details()
root.rename("new_root")
root.move("/new_location")
Directory: root Directory: home Directory: user File: file1.txt, Size: 100KB File: file2.txt, Size: 200KB File: file3.txt, Size: 300KB Directory renamed to: new_root Directory new_root moved to /new_location Directory home moved to /new_location Directory user moved to /new_location File file1.txt moved to /new_location File file2.txt moved to /new_location File file3.txt moved to /new_location
Example: Graphical User Interface¶
class GUIComponent(ABC):
@abstractmethod
def render(self):
pass
class ComposableComponent(ABC):
@abstractmethod
def add(self, component):
pass
@abstractmethod
def remove(self, component):
pass
class Button(GUIComponent):
def __init__(self, name):
self.name = name
def render(self):
print(f"Rendering Button: {self.name}")
class Panel(GUIComponent, ComposableComponent):
def __init__(self, name):
self.name = name
self.children = []
def render(self):
print(f"Rendering Panel: {self.name}")
for child in self.children:
child.render()
def add(self, component):
self.children.append(component)
def remove(self, component):
self.children.remove(component)
# Usage
main_window = Panel("Main Window")
sidebar = Panel("Sidebar")
button1 = Button("Button 1")
button2 = Button("Button 2")
button3 = Button("Button 3")
main_window.add(sidebar)
sidebar.add(button1)
sidebar.add(button2)
main_window.add(button3)
main_window.render()
Rendering Panel: Main Window Rendering Panel: Sidebar Rendering Button: Button 1 Rendering Button: Button 2 Rendering Button: Button 3
class GUIComponent(ABC):
@abstractmethod
def render(self):
pass
class ComposableComponent(ABC):
children = []
@abstractmethod
def add(self, component):
pass
@abstractmethod
def remove(self, component):
pass
class Button(GUIComponent):
def __init__(self, name):
self.name = name
def render(self):
print(f"Rendering Button: {self.name}")
class Panel(GUIComponent):
def __init__(self, name):
self.name = name
def render(self):
print(f"Rendering Panel: {self.name}")
class ComposablePanel(ComposableComponent):
def __init__(self, panel: Panel):
self._panel = panel
def add(self, component):
self.children.append(component)
def remove(self, component):
self.children.remove(component)
def render(self):
for child in self.children:
child.render()
Flyweight Pattern in Structural Patterns¶
The Flyweight Pattern is a structural design pattern that allows you to minimize memory usage or computational expenses by sharing as much data as possible with similar objects. It is particularly useful when you need to create a large number of similar objects, and you want to reduce the overhead associated with creating and maintaining these objects. The Flyweight Pattern achieves this by storing shared states in a central location and only storing unique states within the individual objects.
Example Use Cases¶
- Text Editors: In a text editor, characters can be represented using flyweights to avoid storing duplicate font and style information for each character.
- Game Development: In a game, you might have many instances of similar objects, such as trees, rocks, or enemies. Using flyweights can reduce memory usage by sharing common data among these objects.
- GUI Elements: In a graphical user interface, elements like buttons or icons that appear multiple times can use flyweights to share common properties like color, shape, or size.
- Network Connections: When managing a large number of network connections, you can use flyweights to share common configuration settings among connections.
Example: Text Editor¶
class CharacterFlyweight:
def __init__(self, font, size, color):
print('Create new flyweight')
self.font = font
self.size = size
self.color = color
def render(self, character, position):
print(f"Rendering '{character}' at {position} with font={self.font}, size={self.size}, color={self.color}")
class CharacterFlyweightFactory:
def __init__(self):
self.flyweights = {}
def get_flyweight(self, font, size, color):
key = (font, size, color)
if key not in self.flyweights:
self.flyweights[key] = CharacterFlyweight(font, size, color)
return self.flyweights[key]
# Usage
factory = CharacterFlyweightFactory()
position = 0
text = "Hello Flyweight"
for char in text:
flyweight = factory.get_flyweight("Arial", 12, "black")
flyweight.render(char, position)
position += 1
create new flyweight Rendering 'H' at 0 with font=Arial, size=12, color=black Rendering 'e' at 1 with font=Arial, size=12, color=black Rendering 'l' at 2 with font=Arial, size=12, color=black Rendering 'l' at 3 with font=Arial, size=12, color=black Rendering 'o' at 4 with font=Arial, size=12, color=black Rendering ' ' at 5 with font=Arial, size=12, color=black Rendering 'F' at 6 with font=Arial, size=12, color=black Rendering 'l' at 7 with font=Arial, size=12, color=black Rendering 'y' at 8 with font=Arial, size=12, color=black Rendering 'w' at 9 with font=Arial, size=12, color=black Rendering 'e' at 10 with font=Arial, size=12, color=black Rendering 'i' at 11 with font=Arial, size=12, color=black Rendering 'g' at 12 with font=Arial, size=12, color=black Rendering 'h' at 13 with font=Arial, size=12, color=black Rendering 't' at 14 with font=Arial, size=12, color=black
Example: Game Development¶
In this example, we'll create a game where multiple trees are rendered using the Flyweight Pattern to share common data among tree objects.
class TreeType:
def __init__(self, name, color, texture):
self.name = name
self.color = color
self.texture = texture
def render(self, position):
print(f"Rendering tree '{self.name}' at {position} with color={self.color} and texture={self.texture}")
class TreeFactory:
def __init__(self):
self.tree_types = {}
def get_tree_type(self, name, color, texture):
key = (name, color, texture)
if key not in self.tree_types:
self.tree_types[key] = TreeType(name, color, texture)
return self.tree_types[key]
class Tree:
def __init__(self, x, y, tree_type):
self.x = x
self.y = y
self.tree_type = tree_type
def render(self):
self.tree_type.render((self.x, self.y))
# Usage
factory = TreeFactory()
forest = []
forest.append(Tree(1, 2, factory.get_tree_type("Oak", "Green", "Rough")))
forest.append(Tree(3, 4, factory.get_tree_type("Pine", "Green", "Smooth")))
forest.append(Tree(5, 6, factory.get_tree_type("Oak", "Green", "Rough")))
forest.append(Tree(7, 8, factory.get_tree_type("Cherry Blossom", "Pink", "Smooth")))
for tree in forest:
tree.render()
Rendering tree 'Oak' at (1, 2) with color=Green and texture=Rough Rendering tree 'Pine' at (3, 4) with color=Green and texture=Smooth Rendering tree 'Oak' at (5, 6) with color=Green and texture=Rough Rendering tree 'Cherry Blossom' at (7, 8) with color=Pink and texture=Smooth
Example: Python¶
a = 256
b = 256
print(id(a))
print(id(b))
print(a is b)
print(id(0))
print(id(0))
4512078256 4512078256 True 4512070064 4512070064
a = 'asdfqwer'
b = 'asdfqwer'
print(id(a))
print(id(b))
print(a is b)
4735574256 4735574256 True
a = 'asdfqwer'*512
b = 'asdfqwer'*512
print(id(a))
print(id(b))
print(a is b)
140181105731584 140181105731584 True
c = 'asdf!@#$'*512
d = 'asdf!@#$'*512
print(id(c))
print(id(d))
print(c is d)
140181098507264 140181098486784 False
import sys
e = sys.intern('asdf!@#$'*512)
f = sys.intern('asdf!@#$'*512)
print(id(e))
print(id(f))
print(e is f)
140181349154304 140181349154304 True
%timeit a == b
%timeit c == d
%timeit e == f
24.7 ns ± 2.97 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each) 242 ns ± 5.16 ns per loop (mean ± std. dev. of 7 runs, 1,000,000 loops each) 24.4 ns ± 1.09 ns per loop (mean ± std. dev. of 7 runs, 10,000,000 loops each)
Bridge Pattern in Structural Patterns¶
The Bridge Pattern is a structural design pattern that decouples an abstraction from its implementation, allowing the two to vary independently. This pattern is particularly useful when you want to avoid a permanent binding between an abstraction and its implementation, enabling you to change the implementation without affecting the client code.
Example Use Cases¶
- Graphic Rendering: You might have different rendering engines (e.g., OpenGL, DirectX) and different shapes (e.g., circle, rectangle). The Bridge Pattern allows you to change the rendering engine without changing the shape classes.
- Remote Control Devices: In a home automation system, you might have different devices (e.g., TV, lights) and different remote controls (e.g., basic remote, advanced remote). The Bridge Pattern allows you to mix and match devices and remote controls.
- Database Abstraction: You might have different databases (e.g., MySQL, PostgreSQL) and different data access methods. The Bridge Pattern allows you to switch databases without changing the data access code.
- Cross-Platform UI: You might have different UI components (e.g., buttons, checkboxes) and different platforms (e.g., Windows, macOS). The Bridge Pattern allows you to vary the UI components and platforms independently.
Example: Graphic Rendering¶
In this example, we'll create a graphic rendering system where different shapes can be rendered using different rendering engines.
from abc import ABC, abstractmethod
# Implementor
class Renderer(ABC):
@abstractmethod
def render_circle(self, radius):
pass
# Concrete Implementor 1
class OpenGLRenderer(Renderer):
def render_circle(self, radius):
print(f"Rendering circle with radius {radius} using OpenGL")
# Concrete Implementor 2
class DirectXRenderer(Renderer):
def render_circle(self, radius):
print(f"Rendering circle with radius {radius} using DirectX")
# Abstraction
class Shape(ABC):
def __init__(self, renderer: Renderer):
self.renderer = renderer
@abstractmethod
def draw(self):
pass
# Refined Abstraction
class Circle(Shape):
def __init__(self, renderer: Renderer, radius):
super().__init__(renderer)
self.radius = radius
def draw(self):
self.renderer.render_circle(self.radius)
# Usage
opengl_renderer = OpenGLRenderer()
directx_renderer = DirectXRenderer()
circle1 = Circle(opengl_renderer, 5)
circle2 = Circle(directx_renderer, 10)
circle1.draw()
circle2.draw()
Rendering circle with radius 5 using OpenGL Rendering circle with radius 10 using DirectX
Example: Remote Control Devices¶
In this example, we'll create a home automation system where different devices can be controlled using different remote controls.
from abc import ABC, abstractmethod
# Implementor
class Device(ABC):
@abstractmethod
def turn_on(self):
pass
@abstractmethod
def turn_off(self):
pass
# Concrete Implementor 1
class TV(Device):
def turn_on(self):
print("TV is turned on")
def turn_off(self):
print("TV is turned off")
# Concrete Implementor 2
class Light(Device):
def turn_on(self):
print("Light is turned on")
def turn_off(self):
print("Light is turned off")
# Abstraction
class RemoteControl(ABC):
def __init__(self, device):
self.device = device
@abstractmethod
def turn_on(self):
pass
@abstractmethod
def turn_off(self):
pass
# Refined Abstraction
class BasicRemoteControl(RemoteControl):
def turn_on(self):
self.device.turn_on()
def turn_off(self):
self.device.turn_off()
# Refined Abstraction
class AdvancedRemoteControl(RemoteControl):
def turn_on(self):
print("Advanced Remote: Turning on device")
self.device.turn_on()
def turn_off(self):
print("Advanced Remote: Turning off device")
self.device.turn_off()
Exercise: Shapes¶
- We want to be able to draw drawings (
Drawing
class). - Every drawing is a set of Shapes:
Line
andRectangle
. This is the first hierarchy - the hierarchy of shapes. - We want to use one of two backends: either
turtle
orTKinter
. This is the second hierarchy - the hierarchy of backends. Implementation of drawing a line in
turtle
is completely different than drawing a line intkinter
. Similarly, drawing a rectangle inturtle
is completely different than drawing a rectangle intkinter
. We have two backends and two shapes, so there are (2 \times 2 = 4) different implementations. If we had three backends and three shapes, there would be (3 \times 3 = 9) different implementations.Design the code.
- How easy will it be to add a new shape (i.e., an ellipse)?
- How easy will it be to add a new backend (i.e., Qt)?
Initial Code¶
### Tkinter backend
import tkinter
def draw_tkinter():
root = tkinter.Tk()
frame = tkinter.Frame(master=root)
canvas = tkinter.Canvas(frame)
canvas.pack()
frame.pack()
root.geometry('400x400')
# for each line call draw_line_tkinter(..., canvas)
draw_line_tkinter(x1=10, y1=10, x2=30, y2=30, canvas=canvas)
# for each rectangle call draw_rectangle_tkinter(..., canvas)
draw_rectangle_tkinter(x=10, y=10, w=50, h=50, canvas=canvas)
root.mainloop()
def draw_line_tkinter(x1, y1, x2, y2, canvas):
canvas.create_line(x1, y1, x2, y2)
def draw_rectangle_tkinter(x, y, w, h, canvas):
canvas.create_line(x, y, x+w, y)
canvas.create_line(x, y+h, x+w, y+h)
canvas.create_line(x, y, x, y+h)
canvas.create_line(x+w, y, x+w, y+h)
draw_tkinter()
### Turtle backend
import turtle
def draw_turtle():
turtle.resetscreen()
turtle.penup()
# for each line call draw_line_turtle(...)
draw_line_turtle(x1=10, y1=10, x2=30, y2=30)
# for each rectangle call draw_rectangle_turtle(...)
draw_rectangle_turtle(x=10, y=10, w=50, h=50)
turtle.done()
def draw_line_turtle(x1, y1, x2, y2):
turtle.goto(x1, -y1)
turtle.pendown()
turtle.goto(x2, -y2)
turtle.penup()
def draw_rectangle_turtle(x, y, w, h):
turtle.goto(x, -y)
turtle.pendown()
turtle.goto(x+w, -y)
turtle.goto(x+w, -y-h)
turtle.goto(x, -y-h)
turtle.goto(x, -y)
turtle.penup()
draw_turtle()
Initial Solution¶
@dataclass
class Shape:
def draw(self, drawing):
raise NotImplementedError
# use backend.draw_line
@dataclass
class Line(Shape):
x1: int
y1: int
x2: int
y2: int
def draw(self, backend: Backend):
pass
# use backend.draw_line
@dataclass
class Rectangle(Shape):
x: int
y: int
w: int
h: int
def draw(self, backend: Backend):
pass
# use backend.draw_line
@dataclass
class Drawing:
shapes: List[Shape]
class Backend:
def draw(self, drawing: Drawing): raise NotImplementedError # iterate over all shapes in the drawing and call their .draw() method
def draw_line(self, x1, y1, x2, y2): raise NotImplementedError
class TKinterBackend(Backend): pass # implement draw and draw_line methods
class TurtleBackend(Backend): pass # implement draw and draw_line methods
# TKinterBackend.draw and TurtleBackend.draw should delegate to Line.draw and Rectangle.draw.
# Line.draw and Rectangle.draw should delegate to backend.draw_line.
Test¶
shapes = [
Line(10, 10, 50, 10),
Line(10, 10, 10, 50),
Rectangle(50, 50, 100, 150),
]
drawing = Drawing(shapes)
backend = TKinterBackend()
backend.draw(drawing)
backend = TurtleBackend()
backend.draw(drawing)
Solution¶
from __future__ import annotations
from dataclasses import dataclass
import tkinter
import turtle
@dataclass
class Shape:
def draw(self, drawing):
raise NotImplementedError
@dataclass
class Line(Shape):
x1: int
y1: int
x2: int
y2: int
def draw(self, backend: Backend):
backend.draw_line(self.x1, self.y1, self.x2, self.y2)
@dataclass
class Rectangle(Shape):
x: int
y: int
w: int
h: int
def draw(self, backend: Backend):
backend.draw_line(self.x, self.y, self.x+self.w, self.y)
backend.draw_line(self.x, self.y+self.h, self.x+self.w, self.y+self.h)
backend.draw_line(self.x, self.y, self.x, self.y+self.h)
backend.draw_line(self.x+self.w, self.y, self.x+self.w, self.y+self.h)
@dataclass
class Drawing:
shapes: List[Shape]
class Backend:
def draw(self, drawing: Drawing): raise NotImplementedError # iterate over all shapes in the drawing and call their .draw() method
def draw_line(self, x1, y1, x2, y2): raise NotImplementedError
class TKinterBackend(Backend): # implement draw and draw_line methods
def draw(self, drawing: Drawing):
root = tkinter.Tk()
frame = tkinter.Frame(master=root)
self.canvas = tkinter.Canvas(frame)
self.canvas.pack()
frame.pack()
root.geometry('400x400')
for shape in drawing.shapes:
shape.draw(self)
root.mainloop()
def draw_line(self, x1, y1, x2, y2):
self.canvas.create_line(x1, y1, x2, y2)
class TurtleBackend(Backend): # implement draw and draw_line methods
def draw(self, drawing: Drawing):
turtle.resetscreen()
turtle.penup()
for shape in drawing.shapes:
shape.draw(self)
turtle.done()
def draw_line(self, x1, y1, x2, y2):
turtle.goto(x1, -y1)
turtle.pendown()
turtle.goto(x2, -y2)
turtle.penup()
# TKinterBackend.draw and TurtleBackend.draw should delegate to Line.draw and Rectangle.draw.
# Line.draw and Rectangle.draw should delegate to backend.draw_line.
Iterators & Generators¶
Iterators and generators are powerful features in Python that allow for efficient looping and data streaming.
Iterators are objects that can be iterated over, meaning you can traverse through all the values. They implement two methods: __iter__()
and __next__()
. The __iter__()
method returns the iterator object itself and is implicitly called at the start of loops. The __next__()
method returns the next value and is implicitly called at each loop iteration. When there are no more items to return, it raises a StopIteration
exception, signaling the end of the iteration.
Generators are a special type of iterator, defined using a function rather than a class. They use the yield
keyword to return values one at a time, suspending the function’s state between each yield and resuming from where it left off when called again. This makes generators memory-efficient and well-suited for large data streams or sequences.
Example Use Cases¶
- Reading Large Files: Iterators and generators can be used to read large files line by line without loading the entire file into memory.
- Streaming Data: In applications where data is received in streams, such as network packets or sensor data, generators can be used to process data on-the-fly.
- Infinite Sequences: Generators can be used to create infinite sequences, such as an endless sequence of prime numbers, without running out of memory.
- Pipeline Processing: Generators can be chained together to create data processing pipelines, allowing for modular and reusable code.
Iteration Protocol¶
class NonIterableObject:
def __init__(self):
pass
a = NonIterableObject()
for x in a:
pass
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[131], line 8 3 pass 6 a = NonIterableObject() ----> 8 for x in a: 9 pass TypeError: 'NonIterableObject' object is not iterable
iter(a)
--------------------------------------------------------------------------- TypeError Traceback (most recent call last) Cell In[132], line 1 ----> 1 iter(a) TypeError: 'NonIterableObject' object is not iterable
class MyRange: # iterable object
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self): # iterator factory
return self
def __next__(self): # iterator
if self.current >= self.end:
raise StopIteration
current = self.current
self.current += 1
return current
# Usage
for num in MyRange(1, 5):
print(num)
1 2 3 4
x = iter(MyRange(1, 5))
print(next(x))
print(next(x))
print(next(x))
print(next(x))
print(next(x))
1 2 3 4
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[135], line 6 4 print(next(x)) 5 print(next(x)) ----> 6 print(next(x)) Cell In[133], line 11, in MyRange.__next__(self) 9 def __next__(self): # iterator 10 if self.current >= self.end: ---> 11 raise StopIteration 12 current = self.current 13 self.current += 1 StopIteration:
items = [1, 4] # obiekt iterowalny (iterable)
iterator = iter(items) # delegates to items.__iter__()
print(next(iterator)) # delegates to iterator.__next__()
print(next(iterator))
print(next(iterator))
1 4
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[136], line 5 3 print(next(iterator)) # delegates to iterator.__next__() 4 print(next(iterator)) ----> 5 print(next(iterator)) StopIteration:
items = [1, 4]
iterator = items.__iter__()
print(iterator.__next__())
print(iterator.__next__())
print(iterator.__next__())
1 4
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[137], line 5 3 print(iterator.__next__()) 4 print(iterator.__next__()) ----> 5 print(iterator.__next__()) StopIteration:
Exercise: One-time Iterable¶
- Implement countdown class that is an iterable and iterator at the same time.
- It lets you count down from a number passed to the constructor.
- This is one-time iterable, that is you can iterate over it only once.
Initial Code¶
# Avoid range(5) or hardcoding [5, 4, 3, 2, 1] list.
class countdown:
...
Test¶
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []
for i in countdown(5):
print(i)
Solution¶
class countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
# reset conter if I need
return self
def __next__(self):
if self.start <= 0:
raise StopIteration
else:
previous = self.start
self.start -= 1
return previous
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []
for i in countdown(5):
print(i)
5 4 3 2 1
Exercise: Multiple-time Iterable¶
- Implement countdown and CountdownIterator classes. The first one is an interable, the second one is an iterator.
- It should be possible to iterate over the same countdown many times. It should work even when there are two parallel for loops over the same countdown in two execution threads.
Initial Code¶
class countdown: # iterable
...
class CountdownIterator: # iterator
...
Test¶
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]
c = countdown(5)
for i in c:
print(i)
for i in c:
print(i)
Solution¶
class countdown: # iterable
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start)
class CountdownIterator: # iterator
def __init__(self, start):
self.counter = start
def __next__(self):
if self.counter <= 0:
raise StopIteration
else:
previous = self.counter
self.counter -= 1
return previous
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]
c = countdown(5)
for i in c:
print(i)
for i in c:
print(i)
5 4 3 2 1 5 4 3 2 1
Generators¶
A generator is a special type of iterator that is defined using a function with one or more yield
statements.
You create a generator by defining a function with yield
statements. When the function is called, it returns a generator object.
Generators automatically manage their state and resume execution where they left off when yield
was last called.
Generators are more memory efficient than iterators because they generate values on the fly and do not store the entire sequence in memory.
Essentials¶
def foo():
print('asdf')
return 42
print('finish')
print(foo())
asdf 42
def gen():
d = 5
print('start')
a = yield 1
print('a =', a)
print('d =', d)
b = yield 2
print('b =', b)
print('d =', d)
g = gen() # it = countdown.__iter__()
print(next(g)) # next(it)
start 1
print(next(g)) # allows generator to continue
a = None d = 5 2
print(next(g))
b = None d = 5
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[147], line 1 ----> 1 print(next(g)) StopIteration:
g = gen()
for i in g:
print(i)
start 1 a = None d = 5 2 b = None d = 5
Generators are Iterables¶
g = gen()
print(g.send)
print(g.throw)
print(g.close)
print(g.__iter__)
print(g.__next__)
<built-in method send of generator object at 0x11a3482e0> <built-in method throw of generator object at 0x11a3482e0> <built-in method close of generator object at 0x11a3482e0> <method-wrapper '__iter__' of generator object at 0x11a3482e0> <method-wrapper '__next__' of generator object at 0x11a3482e0>
it = iter(g)
print(g)
print(it)
print(it is g)
<generator object gen at 0x11a3482e0> <generator object gen at 0x11a3482e0> True
send()¶
The send
method is used to send a value to the generator. This value becomes the result of the current yield
expression.
g = gen()
print(g.__next__())
start 1
print(g.send(12)) # send value and implicitly call __next__
a = 12 d = 5 2
print(g.send(23))
b = 23 d = 5
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[153], line 1 ----> 1 print(g.send(23)) StopIteration:
throw() and close()¶
The throw
method is used to raise an exception inside the generator, while the close
method is used to stop the generator.
def gen():
try:
yield 1 # stop, g,throw(Excception,...)
yield 2
except Exception as e:
print(f"Exception: {e}")
finally:
print("Generator closed")
g = gen()
print(next(g))
g.throw(Exception, "An error occurred")
1 Exception: An error occurred Generator closed
/var/folders/w9/9hmtpfzj64v0x0j841ny9y280000gn/T/ipykernel_47413/3039691535.py:12: DeprecationWarning: the (type, exc, tb) signature of throw() is deprecated, use the single-arg signature instead. g.throw(Exception, "An error occurred")
--------------------------------------------------------------------------- StopIteration Traceback (most recent call last) Cell In[156], line 12 10 g = gen() 11 print(next(g)) ---> 12 g.throw(Exception, "An error occurred") StopIteration:
Example: Simple Generator¶
Here, we define a generator that yields the first n
Fibonacci numbers.
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
yield a
a, b = b, a + b
# Usage
for num in fibonacci(10):
print(num)
0 1 1 2 3 5 8 13 21 34
Excercise: One-time generator¶
Implement countdown as a generator function.
Initial Code¶
def countdown(start):
...
Expected Behaviour:¶
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []
for i in countdown(5):
print(i)
5 4 3 2 1
Solution¶
def countdown(start):
while start > 0:
yield start
start -= 1
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == []
for i in countdown(5):
print(i)
5 4 3 2 1
Excercise: Multiple-time generator¶
Implement countdown as a class where iter method is a generator function. This time it should be possible to iterate over the same countdown many times. It should work even when there are two parallel for loops over the same countdown in two execution threads.
Initial Code¶
class countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
...
def generator(self, counter): # iterator
...
Expected Behaviour:¶
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]
c = countdown(5)
for i in c:
print(i)
for i in c:
print(i)
5 4 3 2 1 5 4 3 2 1
Solution¶
class countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
return self.generator(self.start)
def generator(self, counter): # iterator
while counter > 0:
yield counter
counter -= 1
# # albo w jednej metodzie:
# def __iter__(self):
# counter = self.start
# while counter > 0:
# yield counter
# counter -= 1
assert list(countdown(5)) == [5, 4, 3, 2, 1]
c = countdown(5)
assert list(c) == [5, 4, 3, 2, 1]
assert list(c) == [5, 4, 3, 2, 1]
c = countdown(5)
for i in c:
print(i)
for i in c:
print(i)
5 4 3 2 1 5 4 3 2 1
Use Case¶
%%writefile numbers.txt
123
543 -
987 678 234 -
# a comment
123.0 # another comment
543
Overwriting numbers.txt
with open('numbers.txt', 'r') as stream:
total = 0
for line in stream:
index = line.find('#')
if index != -1:
line = line[:index]
for number in line.split():
if number != '-':
number = float(number)
total += number
print(total)
3231.0
def remove_comments(stream):
for line in stream:
index = line.find('#')
if index != -1:
yield line[:index]
else:
yield line
def split_by_whitespaces(stream):
for line in stream:
# for number in line.split():
# yield number
# # is equivalent to:
yield from line.split() # from attach iter to output of the line.split()
def ignore_dashes(stream):
for number in stream:
if number != '-':
yield number
def convert_to_float(stream):
for number in stream:
yield float(number)
with open('numbers.txt', 'r') as stream:
stream = remove_comments(stream)
stream = split_by_whitespaces(stream)
stream = ignore_dashes(stream)
# stream = convert_to_float(stream)
stream = map(float, stream)
summed = sum(stream)
print(summed)
3231.0
Behavioral Patterns¶
Behavioral patterns are design patterns that focus on how objects interact and communicate with each other. These patterns help define the responsibilities between objects and the way they collaborate to achieve a specific task. They are particularly useful for managing complex interactions.
Observer Pattern¶
The Observer Pattern is a behavioral design pattern that allows an object, known as the subject, to maintain a list of its dependents, called observers, and automatically notify them of any state changes. This pattern is particularly useful for implementing distributed event-handling systems.
Examples and Use Cases¶
- Newsletter: Subscribers get notified when a new edition of the newsletter is published.
- News Feed: Users get updates when new articles or posts are published.
- Push Notifications: Mobile apps send push notifications to users when certain events occur.
- Store with New Products: Customers are notified when a new product they are interested in becomes available.
- Publisher-Subscriber Pattern: Any system where you need a publisher-subscriber model, such as messaging systems.
- Push Model: Any scenario where you need to push updates to subscribers instead of them pulling the information.
- Event-Driven Systems: Especially useful in graphical user interfaces (GUIs) where various components need to react to user actions.
- Queues: Systems like RabbitMQ that handle message queues and notify subscribers when new messages arrive.
- Logging: Logging systems that notify different logging handlers about new log entries.
Two Kinds of Objects¶
- Observer = Receiver = Subscriber = Listener = Handler: These are the objects that receive updates from the subject.
- Subject = Sender = Publisher = Topic: These are the objects that send updates to observers.
Example: Simple Weather Station¶
class WeatherStationStatus:
temperature: float
class WeatherStationStatusV2:
temperature: int
class Subject:
def __init__(self):
self._observers = []
def register_observer(self, observer):
self._observers.append(observer)
def remove_observer(self, observer):
self._observers.remove(observer)
def notify_observers(self, message):
for observer in self._observers:
observer.update(message)
class WeatherStation(Subject):
def __init__(self):
super().__init__()
self._temperature = 0
def set_temperature(self, temperature):
self._temperature = temperature
self.notify_observers(f"Temperature updated to {temperature}°C")
# self.notify_observers(WeatherStationStatus(temperature=temperature))
# self.notify_observers(WeatherStationStatusV2(temperature=temperature))
class Observer:
def update(self, message):
pass
class TemperatureDisplay(Observer):
def update(self, message):
print(f"Temperature Display: {message}")
class TemperatureLogger(Observer):
def update(self, message):
print(f"Temperature Logger: {message}")
# Usage
weather_station = WeatherStation()
display = TemperatureDisplay()
logger = TemperatureLogger()
weather_station.register_observer(display)
weather_station.register_observer(logger)
weather_station.set_temperature(25)
Temperature Display: Temperature updated to 25°C Temperature Logger: Temperature updated to 25°C
weather_station.set_temperature(30)
Temperature Display: Temperature updated to 30°C Temperature Logger: Temperature updated to 30°C
Example: Event Logging System¶
In this example, we'll use the Logger
class to create an event logging system where different handlers can be registered to process log messages in various ways, such as printing to the console, writing to a file, and sending alerts.
class Logger:
def __init__(self):
self._handlers = []
def register_handler(self, handler):
self._handlers.append(handler)
def log(self, msg):
for handler in self._handlers:
handler(msg)
# Usage
logger = Logger()
# Define some handlers
def print_log(msg):
print("LOG: " + msg)
def file_log(msg):
with open('log.txt', 'a') as f:
f.write(msg + '\n')
def alert_log(msg):
print("ALERT: " + msg)
# Register handlers
logger.register_handler(print_log)
logger.register_handler(file_log)
logger.register_handler(alert_log)
# Log a message
logger.log('System started')
LOG: System started ALERT: System started
# Log another message
logger.log('An error occurred')
LOG: An error occurred ALERT: An error occurred
Excercise: Observing a Directory¶
- Observe a directory. When a new file is created in the directory, print its name.
- Use watchdog library. You can install it with pip install watchdog.
- Watchdog Documentation
Initial Code¶
import time
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
class MyEventHandler(FileSystemEventHandler):
...
Test¶
### Expected Behaviour (below, the current working directory will be observed for 5 seconds).
# Your Code should observe it infinitely long.
event_handler = MyEventHandler()
observer = Observer()
observer.schedule(event_handler, '.', recursive=True)
observer.start()
time.sleep(5)
observer.stop()
observer.join()
import time
import logging
from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler
### Solution
class MyEventHandler(FileSystemEventHandler):
def on_created(self, event):
if not event.is_directory:
print(event.src_path)
### Expected Behaviour (below, the current working directory will be observed for 5 seconds).
# Your Code should observe it infinitely long.
event_handler = MyEventHandler()
observer = Observer()
observer.schedule(event_handler, '.', recursive=True)
observer.start()
time.sleep(5)
observer.stop()
observer.join()
Mediator Pattern¶
The Mediator Pattern is a behavioral design pattern that provides a way to encapsulate how objects interact with each other. Instead of objects communicating directly, they communicate through a mediator object. This helps reduce the dependencies between communicating objects, promoting loose coupling.
Example: Message Handling System with Differentiated Handlers Using Message Classes¶
class Message:
pass
class InfoMessage(Message):
def __init__(self, content):
self.content = content
class ErrorMessage(Message):
def __init__(self, content):
self.content = content
class AlertMessage(Message):
def __init__(self, content):
self.content = content
class CriticalMessage(Message):
def __init__(self, content):
self.content = content
class MessageMediator:
def __init__(self):
self.handlers = {}
def register_handler(self, message_type, handler):
if message_type not in self.handlers:
self.handlers[message_type] = []
self.handlers[message_type].append(handler)
def dispatch_message(self, message):
message_type = type(message)
if message_type in self.handlers:
for handler in self.handlers[message_type]:
handler.handle(message)
class MessageHandler:
def handle(self, message):
pass
class PrintHandler(MessageHandler):
def handle(self, message):
print(f"PrintHandler: {message.content}")
class LogHandler(MessageHandler):
def handle(self, message):
with open('messages.log', 'a') as file:
file.write(message.content + '\n')
print(f"LogHandler: {message.content} logged")
class AlertHandler(MessageHandler):
def handle(self, message):
print(f"AlertHandler: ALERT! {message.content}")
# Usage
mediator = MessageMediator()
print_handler = PrintHandler()
log_handler = LogHandler()
alert_handler = AlertHandler()
# Register handlers for specific message types
mediator.register_handler(InfoMessage, print_handler)
mediator.register_handler(ErrorMessage, log_handler)
mediator.register_handler(AlertMessage, alert_handler)
# Register multiple handlers for a single message type
mediator.register_handler(CriticalMessage, log_handler)
mediator.register_handler(CriticalMessage, alert_handler)
# Dispatch messages to the appropriate handlers
mediator.dispatch_message(InfoMessage("System started"))
mediator.dispatch_message(ErrorMessage("An error occurred"))
mediator.dispatch_message(AlertMessage("High CPU usage detected"))
mediator.dispatch_message(CriticalMessage("System failure imminent"))
PrintHandler: System started LogHandler: An error occurred logged AlertHandler: ALERT! High CPU usage detected LogHandler: System failure imminent logged AlertHandler: ALERT! System failure imminent
Template Method Pattern¶
The Template Method Pattern is a behavioral design pattern that defines the skeleton of an algorithm in a method, deferring some steps to subclasses. This pattern allows subclasses to redefine certain steps of an algorithm without changing its structure. The main idea is to outline the algorithm's structure in a base class and let subclasses implement the specific details.
Subfunctions¶
### Before Refactorization
def foo():
print('1')
print('2')
print('foo')
def bar():
print('1')
print('2')
print('bar')
### After Refactorization
def print_1_2():
print('1')
print('2')
def foo():
print_1_2()
print('foo')
def bar():
print_1_2()
print('bar')
DRY¶
%%writefile file.txt
content
more content
end
Overwriting file.txt
### Before Refactorization
import logging
from time import time
def divide_numbers(a, b):
start = time()
try:
return a / b
finally:
elapsed = time() - start
print(f'Elapsed {elapsed:.3f} sec in divide_numbers')
def print_file(filename):
start = time()
try:
with open(filename) as stream:
content = stream.read()
print(content)
finally:
elapsed = time() - start
print(f'Elapsed {elapsed:.3f} sec in print file')
print(divide_numbers(2, 3))
Elapsed 0.000 sec in divide_numbers 0.6666666666666666
print_file('file.txt')
content more content end Elapsed 0.001 sec in print file
Bad Approach¶
def timeit(try_, finally_):
start = time()
try:
return try_()
finally:
elapsed = time() - start
finally_(elapsed)
def divide_numbers(a, b):
def try_():
return a / b
def finally_(elapsed):
print(f'Elapsed {elapsed:.3f} sec in divide_numbers')
return timeit(try_, finally_)
def print_file(filename):
def try_():
with open(filename) as stream:
content = stream.read()
print(content)
def finally_(elapsed):
logging.warning(f'Elapsed {elapsed:.3f} sec in divide_numbers')
return timeit(try_, finally_)
Good Approach¶
class TimeIt:
def run(self, *args, **kwargs):
start = time()
try:
return self.try_(*args, **kwargs)
finally:
elapsed = time() - start
self.finally_(elapsed)
def try_(self):
raise NotImplementedError
def finally_(self, elapsed: float):
raise NotImplementedError
class DivideNumbers(TimeIt):
def try_(self, a, b):
return a / b
def finally_(self, elapsed):
print(f'Elapsed {elapsed:.3f} sec in divide_numbers')
class PrintFile(TimeIt):
def try_(self, filename):
with open(filename) as stream:
content = stream.read()
print(content)
def finally_(self, elapsed):
logging.warning(f'Elapsed {elapsed:.3f} sec in divide_numbers')
dn = DivideNumbers()
print(dn.run(2, 3))
Elapsed 0.000 sec in divide_numbers 0.6666666666666666
Decorator Approach¶
import functools
def timeit(f):
@functools.wraps(f)
def wrapper(*args, **kwargs):
start = time()
try:
return f(*args, **kwargs)
finally:
elapsed = time() - start
print(f'Elapsed {elapsed:.3f} sec in {f.__name__}')
return wrapper
@timeit
def divide_numbers(a, b):
return a / b
@timeit
def print_file(filename):
with open(filename) as stream:
content = stream.read()
print(content)
Example: Document Generation¶
from abc import ABC, abstractmethod
class DocumentGenerator(ABC):
def generate_document(self):
self.add_header()
self.add_content()
self.add_footer()
@abstractmethod
def add_header(self):
pass
@abstractmethod
def add_content(self):
pass
@abstractmethod
def add_footer(self):
pass
class ReportGenerator(DocumentGenerator):
def add_header(self):
print("Report Header")
def add_content(self):
print("Report Content")
def add_footer(self):
print("Report Footer")
class InvoiceGenerator(DocumentGenerator):
def add_header(self):
print("Invoice Header")
def add_content(self):
print("Invoice Content")
def add_footer(self):
print("Invoice Footer")
# Usage
report = ReportGenerator()
report.generate_document()
invoice = InvoiceGenerator()
invoice.generate_document()
Report Header Report Content Report Footer Invoice Header Invoice Content Invoice Footer
Example: Data Processing Pipeline¶
from abc import ABC, abstractmethod
class DataProcessor(ABC):
def process_data(self, data):
data = self.load_data(data)
data = self.transform_data(data)
self.save_data(data)
@abstractmethod
def load_data(self, data):
pass
@abstractmethod
def transform_data(self, data):
pass
@abstractmethod
def save_data(self, data):
pass
class CSVDataProcessor(DataProcessor):
def load_data(self, data):
print("Loading CSV data")
return data
def transform_data(self, data):
print("Transforming CSV data")
return data
def save_data(self, data):
print("Saving CSV data")
class JSONDataProcessor(DataProcessor):
def load_data(self, data):
print("Loading JSON data")
return data
def transform_data(self, data):
print("Transforming JSON data")
return data
def save_data(self, data):
print("Saving JSON data")
Example: HTML parsing¶
from html.parser import HTMLParser
class MyHTMLParser(HTMLParser):
def handle_starttag(self, tag, attrs):
print("Encountered a start tag:", tag, attrs)
def handle_endtag(self, tag):
print("Encountered an end tag :", tag)
def handle_data(self, data):
print("Encountered some data :", data)
parser = MyHTMLParser()
parser.feed('<html><head><title attr="value" attr2="value2">Test</title></head>'
'<body><h1>Parse me!</h1></body></html>')
--------------------------------------------------------------------------- ModuleNotFoundError Traceback (most recent call last) Cell In[196], line 1 ----> 1 from html.parser import HTMLParser 3 class MyHTMLParser(HTMLParser): 4 def handle_starttag(self, tag, attrs): ModuleNotFoundError: No module named 'html.parser'
Exercise: CLI do turtle¶
Installing TKinter Implement a command line tool that works in question-answer way. Use builtin cmd framework. Documentation The tool should let you control the turtle. Implement commands like right, left, home, circle, reset. ★ Implement commands like position and heading.
Initial Code¶
import cmd, sys
import turtle
class TurtleShell(cmd.Cmd):
intro = 'Welcome to the turtle shell. Type help or ? to list commands.\n'
prompt = '(turtle) '
# ----- basic turtle commands -----
def do_forward(self, arg):
'Move the turtle forward by the specified distance: FORWARD 10'
turtle.forward(int(arg))
def do_bye(self, arg):
'Close the turtle window, and exit: BYE'
print('Thank you for using Turtle')
turtle.bye()
return True
if __name__ == '__main__':
TurtleShell().cmdloop()
Welcome to the turtle shell. Type help or ? to list commands.
(turtle) forward 50 (turtle) exit *** Unknown syntax: exit (turtle) bye Thank you for using Turtle
Solution¶
import cmd, sys
import turtle
class TurtleShell(cmd.Cmd):
intro = 'Welcome to the turtle shell. Type help or ? to list commands.\n'
prompt = '(turtle) '
# ----- basic turtle commands -----
def do_forward(self, arg):
'Move the turtle forward by the specified distance: FORWARD 10'
turtle.forward(int(arg))
def do_right(self, arg):
'Turn turtle right by given number of degrees: RIGHT 20'
turtle.right(int(arg))
def do_left(self, arg):
'Turn turtle left by given number of degrees: LEFT 90'
turtle.left(int(arg))
def do_home(self, arg):
'Return turtle to the home position: HOME'
turtle.home()
def do_circle(self, arg):
'Draw circle with given radius an options extent and steps: CIRCLE 50'
turtle.circle(int(arg))
def do_position(self, arg):
'Print the current turtle position: POSITION'
print('Current position is {} {}\n'.format(*turtle.position()))
def do_heading(self, arg):
'Print the current turtle heading in degrees: HEADING'
print(f'Current heading is {turtle.heading()}\n')
def do_reset(self, arg):
'Clear the screen and return turtle to center: RESET'
turtle.reset()
def do_bye(self, arg):
'Close the turtle window, and exit: BYE'
print('Thank you for using Turtle')
turtle.bye()
return True
if __name__ == '__main__':
TurtleShell().cmdloop()
Chain of Responsibility Pattern¶
The Chain of Responsibility Pattern is a behavioral design pattern that allows a request to be passed along a chain of handlers. Each handler in the chain either processes the request or passes it to the next handler. This pattern is useful when multiple objects can handle a request, but the handler isn't determined until runtime.
How Does the Chain of Responsibility Work?¶
Imagine you have a sequence of objects, each with the ability to process a request. The request starts at the beginning of the chain and moves along until it's handled by one of the objects, or it reaches the end of the chain. This pattern promotes loose coupling because the sender of a request doesn't need to know which object will handle it.
There are two main variations of the Chain of Responsibility:
- Pass Until Handled: The request is passed along the chain until one handler processes it.
- Pass Until Failure: The request is passed along the chain, but the process stops if one handler fails, either passing or failing the entire chain.
Example: Pass Until Handled¶
class HelpDeskHandler:
def __init__(self, next_handler=None):
self.next_handler = next_handler
def handle(self, request):
if self.next_handler:
return self.next_handler.handle(request)
return f"Request {request} could not be handled"
# Level 1 Handler
class Level1Support(HelpDeskHandler):
def handle(self, request):
if request == "password_reset":
return "Level 1 Support: Handling password reset"
return super().handle(request)
# Level 2 Handler
class Level2Support(HelpDeskHandler):
def handle(self, request):
if request == "software_issue":
return "Level 2 Support: Handling software issue"
return super().handle(request)
# Level 3 Handler
class Level3Support(HelpDeskHandler):
def handle(self, request):
if request == "hardware_issue":
return "Level 3 Support: Handling hardware issue"
return super().handle(request)
# Create the chain of handlers
chain = Level1Support(Level2Support(Level3Support()))
# Test the chain
print(chain.handle("password_reset")) # Output: Level 1 Support: Handling password reset
print('--------')
print(chain.handle("software_issue")) # Output: Level 2 Support: Handling software issue
print('--------')
print(chain.handle("hardware_issue")) # Output: Level 3 Support: Handling hardware issue
print('--------')
print(chain.handle("unknown_request")) # Output: Request unknown_request could not be handled
Level 1 Support: Handling password reset -------- Level 2 Support: Handling software issue -------- Level 3 Support: Handling hardware issue -------- Request unknown_request could not be handled
Example: Pass Until Failure¶
In this variation, the request is passed along the chain, but the process stops if one handler fails to process it successfully. In this example, a request goes through a series of validation handlers for a credit card payment. If any validation fails, the process stops and returns an error.
class PaymentValidationHandler:
def __init__(self, next_handler=None):
self.next_handler = next_handler
def handle(self, request):
if self.next_handler:
return self.next_handler.handle(request)
return "All validations passed."
# Check if card number is valid
class CardNumberValidation(PaymentValidationHandler):
def handle(self, request):
if len(request["card_number"]) == 16:
print("Card number validation passed")
return super().handle(request)
return "Invalid card number."
# Check if expiry date is valid
class ExpiryDateValidation(PaymentValidationHandler):
def handle(self, request):
if request["expiry_year"] >= 2024:
print("Expiry date validation passed")
return super().handle(request)
return "Card has expired."
# Check if CVV is valid
class CVVValidation(PaymentValidationHandler):
def handle(self, request):
if len(request["cvv"]) == 3:
print("CVV validation passed")
return super().handle(request)
return "Invalid CVV."
# Create the chain of validation handlers
validation_chain = CardNumberValidation(ExpiryDateValidation(CVVValidation()))
# Test data
valid_request = {
"card_number": "1234567812345678",
"expiry_year": 2025,
"cvv": "123"
}
invalid_card_request = {
"card_number": "1234",
"expiry_year": 2025,
"cvv": "123"
}
expired_card_request = {
"card_number": "1234567812345678",
"expiry_year": 2023,
"cvv": "123"
}
invalid_cvv_request = {
"card_number": "1234567812345678",
"expiry_year": 2025,
"cvv": "12"
}
# Test the chain with valid and invalid requests
print(validation_chain.handle(valid_request)) # Output: All validations passed.
print('------')
print(validation_chain.handle(invalid_card_request)) # Output: Invalid card number.
print('------')
print(validation_chain.handle(expired_card_request)) # Output: Card has expired.
print('------')
print(validation_chain.handle(invalid_cvv_request)) # Output: Invalid CVV.
Card number validation passed Expiry date validation passed CVV validation passed All validations passed. ------ Invalid card number. ------ Card number validation passed Card has expired. ------ Card number validation passed Expiry date validation passed Invalid CVV.
Exercise: Build a Chain of Responsibility¶
In this exercise, you will create a Chain of Responsibility Pattern to process different types of requests. The request goes through multiple handlers, and each handler either processes the request or passes it to the next handler.
You need to:
- Implement a base handler class that can pass the request to the next handler in the chain.
- Implement three concrete handlers:
- LogHandler: Logs the incoming request.
- AuthorizationHandler: Checks if the request is authorized.
- DataProcessingHandler: Processes the request if it passes the previous handlers.
- Create the chain of handlers and ensure that requests flow through the chain correctly.
You will practice structuring the chain and defining how handlers pass the request along until it is processed.
Initial Code:¶
# 1. Base Handler class: Should pass the request to the next handler if it exists
class Handler:
def __init__(self, next_handler=None):
# Exercise: Implement the base handler constructor and store the next handler
pass
def handle(self, request):
# Exercise: Implement the handle method to pass the request to the next handler if present
pass
# 2. LogHandler: Logs the incoming request
class LogHandler(Handler):
def handle(self, request):
# Exercise: Implement logging and call the next handler
pass
# 3. AuthorizationHandler: Checks if the request is authorized
class AuthorizationHandler(Handler):
def handle(self, request):
# Exercise: Implement authorization check and call the next handler or stop the chain
pass
# 4. DataProcessingHandler: Processes the request data
class DataProcessingHandler(Handler):
def handle(self, request):
# Exercise: Implement data processing and call the next handler if needed
pass
# Create the chain of handlers
# Exercise: Construct the chain of handlers here
chain_of_handlers = None
Test Code:¶
# Test requests
authorized_request = {"user": "admin", "data": "important data"}
unauthorized_request = {"user": "guest", "data": "important data"}
# Test the chain with authorized and unauthorized requests
print(chain_of_handlers.handle(authorized_request)) # Expected: Request processed.
print(chain_of_handlers.handle(unauthorized_request)) # Expected: Unauthorized request.
Solution:¶
# 1. Base Handler class: Should pass the request to the next handler if it exists
class Handler:
def __init__(self, next_handler=None):
self.next_handler = next_handler
def handle(self, request):
if self.next_handler:
return self.next_handler.handle(request)
return "Request processed."
# 2. LogHandler: Logs the incoming request
class LogHandler(Handler):
def handle(self, request):
print(f"Logging request: {request}")
return super().handle(request)
# 3. AuthorizationHandler: Checks if the request is authorized
class AuthorizationHandler(Handler):
def handle(self, request):
if request.get("user") == "admin":
print("Authorization successful")
return super().handle(request)
print("Authorization failed")
return "Unauthorized request."
# 4. DataProcessingHandler: Processes the request data
class DataProcessingHandler(Handler):
def handle(self, request):
print(f"Processing data: {request['data']}")
return super().handle(request)
# Create the chain of handlers
chain_of_handlers = LogHandler(AuthorizationHandler(DataProcessingHandler()))
# Test requests
authorized_request = {"user": "admin", "data": "important data"}
unauthorized_request = {"user": "guest", "data": "important data"}
# Test the chain with authorized and unauthorized requests
print(chain_of_handlers.handle(authorized_request)) # Expected: Request processed.
print(chain_of_handlers.handle(unauthorized_request)) # Expected: Unauthorized request.
Logging request: {'user': 'admin', 'data': 'important data'} Authorization successful Processing data: important data Request processed. Logging request: {'user': 'guest', 'data': 'important data'} Authorization failed Unauthorized request.
Exercise: GUI Event Propagation¶
- Implement Widget.handle method.
- This method should let the widget process the event. If this is not supported, it should pass the event to all its children.
- The widget supports an event if it implements on_XXX method specified in the event.handler_name.
Initial Code¶
### Events
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Event:
handler_name: ClassVar[str]
handled: bool = field(default=False, init=False)
@dataclass
class MouseClickEvent(Event):
handler_name = 'on_mouse_click'
x: int
y: int
@dataclass
class KeyPressedEvent(Event):
handler_name = 'on_key_pressed'
key: str
@dataclass
class CloseEvent(Event):
handler_name = 'on_close'
class Widget:
def __init__(self):
self.children = []
def handle(self, event: Event):
...
class MainWindow(Widget):
def on_close(self, event):
print('Closing MainWindow...')
class Frame(Widget):
pass
class TextBox(Widget):
def on_key_pressed(self, event):
print('TextBox handles keypressed event - {}'.format(event.key))
def on_close(self, event):
print('TextBox handles close event.')
class Button(Widget):
def on_mouse_click(self, event):
print('Button handles mouse clicked at ({}, {})'.format(event.x, event.y))
Tests¶
txt_box = TextBox()
btn_ok = Button()
frame = Frame()
frame.children = [txt_box, btn_ok]
wnd = MainWindow()
wnd.children = [frame]
wnd.handle(MouseClickEvent(1, 2))
# Button handles mouse clicked at (1, 2)
wnd.handle(KeyPressedEvent('K'))
# TextBox handles keypressed event - K
wnd.handle(CloseEvent())
Closing MainWindow...
Solution¶
### Events
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass
class Event:
handler_name: ClassVar[str]
handled: bool = field(default=False, init=False)
@dataclass
class MouseClickEvent(Event):
handler_name = 'on_mouse_click'
x: int
y: int
@dataclass
class KeyPressedEvent(Event):
handler_name = 'on_key_pressed'
key: str
@dataclass
class CloseEvent(Event):
handler_name = 'on_close'
class Widget:
def __init__(self):
self.children = []
### Solutions
# Version 1
# Ask for forgiveness
def handle(self, event: Event):
try:
handler = getattr(self, event.handler_name)
except AttributeError:
for child in self.children:
child.handle(event)
if event.handled:
break
else:
handler(event)
event.handled = True
# Version 2
def handle(self, event) -> bool:
try:
handler = getattr(self, event.handler_name)
except AttributeError:
for child in self.children:
handled = child.handle(event)
if handled:
break
else:
handler(event)
return True
# Version 3
def handle(self, event):
if event.handled:
return
try:
handler = getattr(self, event.handler_name)
except AttributeError:
for child in self.children:
child.handle(event)
else:
handler(event)
# Version 4
def handle(self, event):
# Ask for permission
if hasattr(self, event.handler_name):
handler = getattr(self, event.handler_name)
handler(event)
event.handled = True
else:
for child in self.children:
child.handle(event)
if event.handled:
break
class MainWindow(Widget):
def on_close(self, event):
print('Closing MainWindow...')
class Frame(Widget):
pass
class TextBox(Widget):
def on_key_pressed(self, event):
print('TextBox handles keypressed event - {}'.format(event.key))
def on_close(self, event):
print('TextBox handles close event.')
class Button(Widget):
def on_mouse_click(self, event):
print('Button handles mouse clicked at ({}, {})'.format(event.x, event.y))
Strategy Pattern¶
The Strategy Pattern is a behavioral design pattern that defines a family of algorithms, encapsulates each one, and makes them interchangeable. The key idea behind the pattern is to enable selecting an algorithm's behavior at runtime. Instead of hardcoding multiple behaviors, the Strategy Pattern lets you choose from different strategies that can be swapped in and out as needed.
How Does the Strategy Pattern Work?¶
The Strategy Pattern allows a class to delegate its behavior to one of several interchangeable objects (strategies). Each strategy implements a specific behavior or algorithm, and the client class chooses which strategy to use at runtime. This makes the code more flexible and open to extension, as new strategies can be added without modifying the core logic.
Example: Payment Strategy¶
In this example, an e-commerce platform allows customers to pay using different payment methods. Instead of hardcoding the logic for each payment method, we use the Strategy Pattern to allow the system to switch between strategies dynamically (e.g., PayPal, Credit Card, Bank Transfer).
# Base Payment Strategy
class PaymentStrategy:
def pay(self, amount):
raise NotImplementedError
# PayPal Payment Strategy
class PayPalPayment(PaymentStrategy):
def pay(self, amount):
return f"Paying {amount} using PayPal."
# Credit Card Payment Strategy
class CreditCardPayment(PaymentStrategy):
def pay(self, amount):
return f"Paying {amount} using Credit Card."
# Bank Transfer Payment Strategy
class BankTransferPayment(PaymentStrategy):
def pay(self, amount):
return f"Paying {amount} using Bank Transfer."
# Context class that uses a Payment Strategy
class PaymentProcessor:
def __init__(self, strategy: PaymentStrategy):
self.strategy = strategy
def process_payment(self, amount):
return self.strategy.pay(amount)
# Usage
paypal = PayPalPayment()
credit_card = CreditCardPayment()
bank_transfer = BankTransferPayment()
# Choose a payment method at runtime
payment_processor = PaymentProcessor(paypal)
print(payment_processor.process_payment(100)) # Output: Paying 100 using PayPal.
payment_processor = PaymentProcessor(credit_card)
print(payment_processor.process_payment(200)) # Output: Paying 200 using Credit Card.
payment_processor = PaymentProcessor(bank_transfer)
print(payment_processor.process_payment(300)) # Output: Paying 300 using Bank Transfer.
Paying 100 using PayPal. Paying 200 using Credit Card. Paying 300 using Bank Transfer.
Exercise: 🏠 Discounts¶
- We have an online shop and there are different Orders.
- Each Order belongs to a Customer.
- On top of that, each Order has a list of LineItems.
- You can compute Order.total which sums up prices of all products in the order.
- So far, there were no discounts. Now, you want to implement Order.due property that returns the price after applying a discount.
- One day you want to give 5% discounts for customers that have at least 1000 fidelity points. However, old Orders should have no discount. 7.Another day you think it'll be better to change the strategy and give 7% discounts for orders that have 10 or more different products. This applies only to new orders. Old orders should use 5%-rule or no discount in case of the oldest orders.
- Implement this logic, so that later on you can add more strategies.
Initial Code¶
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import List
@dataclass
class Customer:
name: str
fidelity: int
@dataclass
class LineItem:
product: str
price: float
class Promotion:
def compute_discount(self, order: Order):
"""REturn discount or zero"""
raise NotImplementedError
class NullPromo(Promotion):
""" No discount at all."""
def compute_discount(self, order):
...
class FidelityPromo(Promotion):
""" 5% discount for customers that have at least 1000 fidelity points."""
def compute_discount(self, order):
...
class LargeOrderPromo(Promotion):
""" 7% discount for orders that have 10 or more different products. """
def compute_discount(self, order):
...
@dataclass
class Order:
customer: Customer
cart: List[LineItem]
promotion: Promotion = NullPromo()
@property
def total(self):
return sum(item.price for item in self.cart)
def due(self):
...
def __repr__(self):
return f'<Order total: {self.total:.2f} due: {self.due:.2f}>'
Tests¶
### Expected Behaviour -- NullPromo
cart = [LineItem('banana', 2.00)]
john = Customer('John Doe', 0)
order = Order(john, cart, NullPromo())
assert order.total == 2.00
assert order.due == 2.00
### Expected Behaviour -- FidelityPromo
cart = [
LineItem('banana', 2.00),
LineItem('apple', 15.00),
LineItem('watermellon', 25.00),
]
john = Customer('John Doe', 0)
order = Order(john, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 42.00
ann = Customer('Ann Smith', 1100)
order = Order(ann, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 39.90
### Expected Behaviour -- LargeOrderPromo
order = Order(john, cart, LargeOrderPromo())
assert order.total == 42.00
assert order.due == 42.00
long_order = [LineItem(str(item_code), 1.00)
for item_code in range(10)]
order = Order(john, long_order, LargeOrderPromo())
assert order.total == 10.00
assert order.due == 9.30
Solution¶
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
from typing import List
@dataclass
class Customer:
name: str
fidelity: int
@dataclass
class LineItem:
product: str
price: float
class Promotion:
def compute_discount(self, order: Order):
"""REturn discount or zero"""
raise NotImplementedError
class NullPromo(Promotion):
""" No discount at all."""
def compute_discount(self, order):
return 0
class FidelityPromo(Promotion):
""" 5% discount for customers that have at least 1000 fidelity points."""
def compute_discount(self, order):
if order.customer.fidelity >= 1000:
return 0.05 * order.total
else:
return 0
class LargeOrderPromo(Promotion):
""" 7% discount for orders that have 10 or more different products. """
def compute_discount(self, order):
distinct_items = {item.product for item in order.cart}
if len(distinct_items) >= 10:
return order.total * 0.07
else:
return 0
@dataclass
class Order:
customer: Customer
cart: List[LineItem]
promotion: Promotion = NullPromo()
@property
def total(self):
return sum(item.price for item in self.cart)
@property
def due(self):
discount = self.promotion.compute_discount(self)
return self.total - discount
def __repr__(self):
return f'<Order total: {self.total:.2f} due: {self.due:.2f}>'
### Expected Behaviour -- NullPromo
cart = [LineItem('banana', 2.00)]
john = Customer('John Doe', 0)
order = Order(john, cart, NullPromo())
assert order.total == 2.00
assert order.due == 2.00
### Expected Behaviour -- FidelityPromo
cart = [
LineItem('banana', 2.00),
LineItem('apple', 15.00),
LineItem('watermellon', 25.00),
]
john = Customer('John Doe', 0)
order = Order(john, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 42.00
ann = Customer('Ann Smith', 1100)
order = Order(ann, cart, FidelityPromo())
assert order.total == 42.00
assert order.due == 39.90
### Expected Behaviour -- LargeOrderPromo
order = Order(john, cart, LargeOrderPromo())
assert order.total == 42.00
assert order.due == 42.00
long_order = [LineItem(str(item_code), 1.00)
for item_code in range(10)]
order = Order(john, long_order, LargeOrderPromo())
assert order.total == 10.00
assert order.due == 9.30
Exercise: 🏠 BiggestPromo¶
- Implement a new promotion strategy (BiggestPromo) that let you combine multiple simple promotions and applies the biggest discount.
- This promotion strategy is different from the other. It accepts a list of other promotions in the constructor.
- Make BiggestPromo iterable.
- Use Builder and Prototype to simplify BiggestPromo creation.
Tests¶
### Expected Behaviour -- composite
promo = BiggestPromo([FidelityPromo(), LargeOrderPromo()])
order = Order(ann, long_order, promo)
assert order.total == 10.00
assert order.due == 9.30
### Expected Behaviour -- iterator
promos = [FidelityPromo(), LargeOrderPromo()]
promo = BiggestPromo(promos)
assert list(promo) == promos
for subpromo in promo:
print(subpromo)
print(promo[1])
### Expected Behaviour -- builder + prototype
promo = (BiggestPromo()
.added(FidelityPromo())
.added(LargeOrderPromo()))
order = Order(ann, long_order, promo)
assert order.total == 10.00
assert order.due == 9.30
Command Pattern¶
The Command Pattern is a behavioral design pattern that turns requests or simple operations into objects. This allows you to parametrize methods with different requests, delay or queue a request’s execution, and support undoable operations. The Command Pattern encapsulates a request as an object, thereby allowing users to issue requests without knowing the details of the operations being requested.
Key Components of the Command Pattern:¶
- Command: An interface or abstract class that defines the execution method.
- Concrete Command: Implements the Command interface and performs the actual work by calling methods on the receiver.
- Receiver: The object that performs the actual action when the command is executed.
- Invoker: The object that initiates the command.
- Client: The class that creates and configures the command and the invoker.
When to Use the Command Pattern:¶
- When you want to parameterize objects with operations (e.g., as in a queue of commands).
- When you want to support undoable operations.
- When you need to decouple the object that sends the request from the object that knows how to perform the operation.
- When you need to log or queue requests and execute them later.
Example: Home Automation System¶
Imagine a home automation system where you can control different devices (lights, fans, etc.) with a remote control. The remote control can issue commands such as turning devices on or off, and you can undo the last command if needed.
Components:¶
- Command Interface: Defines the
execute
method. - Concrete Commands: Implement the command interface to turn devices on or off.
- Receiver: The devices (light, fan) that will perform the actual action.
- Invoker: The remote control that triggers the commands.
# Command Interface
class Command:
def execute(self):
pass
# Concrete Commands
class LightOnCommand(Command):
def __init__(self, light):
self.light = light
def execute(self):
self.light.turn_on()
class LightOffCommand(Command):
def __init__(self, light):
self.light = light
def execute(self):
self.light.turn_off()
class FanOnCommand(Command):
def __init__(self, fan):
self.fan = fan
def execute(self):
self.fan.turn_on()
class FanOffCommand(Command):
def __init__(self, fan):
self.fan = fan
def execute(self):
self.fan.turn_off()
# Receiver: The devices
class Light:
def turn_on(self):
print("The light is on")
def turn_off(self):
print("The light is off")
class Fan:
def turn_on(self):
print("The fan is on")
def turn_off(self):
print("The fan is off")
# Invoker: Remote Control
class RemoteControl:
def __init__(self):
self.history = []
def execute_command(self, command):
command.execute()
self.history.append(command)
# Client
light = Light()
fan = Fan()
light_on = LightOnCommand(light)
light_off = LightOffCommand(light)
fan_on = FanOnCommand(fan)
fan_off = FanOffCommand(fan)
remote = RemoteControl()
# Turning the devices on and off using the remote control
remote.execute_command(light_on) # Output: The light is on
remote.execute_command(fan_on) # Output: The fan is on
remote.execute_command(light_off) # Output: The light is off
remote.execute_command(fan_off) # Output: The fan is off
Exercise: Text Editor¶
- You have a TextEditor that let you edit text. It has a clipboard and supports cut and paste operations. All of that is already implemented in TextEditor class.
- There is a new requirement: we need an infinite undo. Implement it.
Initial Code¶
class TextEditor:
def __init__(self, text=''):
self._text = text
self._clipboard = ''
def cut(self, start=0, end=0):
self._clipboard = self._text[start:end]
self._text = self._text[:start] + self._text[end:]
def paste(self, offset=0):
self._text = self._text[:offset] + self._clipboard + self._text[offset:]
def clear_clipboard(self):
self.clipboard = ''
def set_text(self, text):
self._text = text
def __len__(self):
return len(self.text)
def __repr__(self):
return f'Screen({self._text})'
class ScreenCommand:
...
class CutCommand(ScreenCommand):
...
class PasteCommand(ScreenCommand):
...
Tests¶
editor = TextEditor('hello world')
editor.cut(start=5, end=11)
assert editor._text == 'hello'
editor.paste(offset=0)
assert editor._text == ' worldhello'
editor.undo()
assert editor._text == 'hello'
editor.undo()
assert editor._text == 'hello world'
Solution¶
class TextEditor:
def __init__(self, text=''):
self._text = text
self._clipboard = ''
self._history = [] # złamane SRP
def cut(self, start=0, end=0):
cmd = CutCommand(self, start, end)
self._execute_and_store(cmd)
def paste(self, offset=0):
cmd = PasteCommand(self, offset)
self._execute_and_store(cmd)
def _execute_and_store(self, cmd):
cmd.execute()
self._history.append(cmd)
def clear_clipboard(self):
self.clipboard = ''
def set_text(self, text):
self._text = text
def undo(self):
self._history.pop().undo()
def __len__(self):
return len(self.text)
def __repr__(self):
return f'Screen({self._text})'
class ScreenCommand:
def __init__(self, editor):
self._editor = editor
self._executed = False
def execute(self):
if self._executed:
raise Exception('Cannot execute multiple times')
self._executed = True
def undo(self):
if not self._executed:
raise Exception('Cannot undo a command that was not executed')
class CutCommand(ScreenCommand):
def __init__(self, editor, start, end):
super().__init__(editor)
self._start = start
self._end = end
def execute(self):
super().execute()
self._previous_state = self._editor._text
self._editor._clipboard = self._editor._text[self._start:self._end]
self._editor._text = self._editor._text[:self._start] + self._editor._text[self._end:]
def undo(self):
super().undo()
# self._editor.clear_clipboard()
self._editor._text = self._previous_state
class PasteCommand(ScreenCommand):
def __init__(self, editor, offset):
super().__init__(editor)
self._offset = offset
def execute(self):
super().execute()
self._previous_state = self._editor._text
self._editor._text = self._editor._text[:self._offset] + self._editor._clipboard + self._editor._text[self._offset:]
def undo(self):
super().undo()
self._editor._text = self._previous_state
Exercise: Add Redo function to the editor¶
State Pattern¶
The State Pattern is a behavioral design pattern that allows an object to alter its behavior when its internal state changes. The pattern suggests that the object should change its class to reflect the current state, encapsulating state-specific behavior in separate state classes.
In simpler terms, the State Pattern allows an object to change its behavior at runtime depending on its internal state, without needing large if-else
or switch-case
statements to manage state transitions.
Key Components of the State Pattern:¶
- Context: The object that maintains a reference to the current state object. It delegates state-specific behavior to this state object.
- State Interface: Defines the interface that each state must implement.
- Concrete State: Implements the behavior associated with a particular state of the
Context
.
When to Use the State Pattern?¶
- When an object’s behavior needs to change based on its internal state, and you want to avoid complex conditional logic (
if-else
,switch-case
). - When an object can be in multiple states, and these states are subject to change at runtime.
- When you want to make the code more maintainable and scalable by separating state-specific behavior into different classes.
Use Cases:¶
- A Turnstile: A turnstile's behavior changes based on whether it is locked or unlocked. Passing through it or inserting a coin causes state transitions.
- Blog Posts: Blog posts can move between states like draft, review, and published, with different behaviors for each state.
- Purchase in an Online Shop: An online purchase can move through states such as cart, checkout, paid, shipped, and delivered, with actions changing based on the current state.
- Phone Buttons: The behavior of phone buttons changes depending on whether the phone is locked or unlocked.
- TV Remote: A TV remote's on/off button changes the behavior of the TV depending on its current state (on or off).
- Audio Player: An audio player has different states (locked, playing, paused, stopped) that affect the behavior of buttons like play, next, and previous.
- Finite-State Machines: Any system that can be modeled as a finite-state machine (e.g., elevators, ATMs, vending machines) benefits from the State Pattern, where actions depend on the current state.
Example: TurnStile¶
Without state¶
class Turnstile:
def __init__(self):
self._state = 'locked'
def coin(self):
if self._state == 'locked':
self.unlock()
self._state = 'unlocked'
else:
assert self._state == 'unlocked'
self.display('Thank you')
def pass_(self):
if self._state == 'locked':
self.alarm()
else:
assert self._state == 'unlocked'
self.lock()
self._state = 'locked'
def unlock(self):
print('unlocking turnstile')
def lock(self):
print('locking turnstile')
def display(self, text):
print(f'>>> {text} <<<')
def alarm(self):
print('alarm!')
t = Turnstile()
t.coin()
t.pass_()
t.pass_()
t.coin()
t.pass_()
t.coin()
t.coin()
unlocking turnstile locking turnstile alarm! unlocking turnstile locking turnstile unlocking turnstile >>> Thank you <<<
With State Pattern¶
class State:
def __init__(self, turnstile):
self.turnstile = turnstile
def coin(self):
raise NotImplementedError
def pass_(self):
raise NotImplementedError
class LockedState(State):
def coin(self):
self.turnstile.unlock()
self.turnstile.set_state(UnlockedState)
def pass_(self):
self.turnstile.alarm()
class UnlockedState(State):
def coin(self):
self.turnstile.display("Thank you")
def pass_(self):
self.turnstile.lock()
self.turnstile.set_state(LockedState)
class Turnstile:
def __init__(self):
self._state = LockedState(self)
def coin(self):
self._state.coin()
def pass_(self):
self._state.pass_()
def unlock(self):
print('unlocking turnstile')
def lock(self):
print('locking turnstile')
def display(self, text):
print(f'>>> {text} <<<')
def alarm(self):
print('alarm!')
def set_state(self, state_cls):
self._state = state_cls(self)
t = Turnstile()
t.coin()
t.pass_()
t.pass_()
t.coin()
t.pass_()
t.coin()
t.coin()
t.coin()
unlocking turnstile locking turnstile alarm! unlocking turnstile locking turnstile unlocking turnstile >>> Thank you <<< >>> Thank you <<<
Example: Traffic Light System¶
Imagine a traffic light system where the behavior changes based on whether the light is Green, Yellow, or Red. The system transitions between these states cyclically, and each state has its own behavior when transitioning to the next.
Components:¶
- State Interface: Defines the behavior for traffic light transitions.
- Concrete States: Green, Yellow, and Red light states.
- Context: The traffic light object that holds the current state and delegates behavior to the state classes.
# State Interface
class TrafficLightState:
def next_state(self, traffic_light):
pass
# Concrete States
class GreenLightState(TrafficLightState):
def next_state(self, traffic_light):
print("Green Light - Turning to Yellow")
traffic_light.set_state(YellowLightState())
class YellowLightState(TrafficLightState):
def next_state(self, traffic_light):
print("Yellow Light - Turning to Red")
traffic_light.set_state(RedLightState())
class RedLightState(TrafficLightState):
def next_state(self, traffic_light):
print("Red Light - Turning to Green")
traffic_light.set_state(GreenLightState())
# Context: Traffic Light
class TrafficLight:
def __init__(self):
self.state = RedLightState() # Initial state is Red
def set_state(self, state: TrafficLightState):
self.state = state
def change(self):
self.state.next_state(self)
# Client
traffic_light = TrafficLight()
# Simulate the traffic light changing states
for _ in range(6): # Loop through the state changes
traffic_light.change()
Red Light - Turning to Green Green Light - Turning to Yellow Yellow Light - Turning to Red Red Light - Turning to Green Green Light - Turning to Yellow Yellow Light - Turning to Red
Memento Pattern¶
The Memento Pattern is a behavioral design pattern that allows you to capture and store the current state of an object so that it can be restored later, without exposing the details of its implementation. The key idea is to provide a way to roll back to a previous state when needed, making it useful for implementing undo/redo functionality in applications.
Key Components of the Memento Pattern:¶
- Memento: This is an object that stores the state of another object. It doesn't allow direct access to its internal state to prevent unintended modifications.
- Originator: The object whose state needs to be saved and restored. It creates a Memento containing its current state and uses the Memento to restore its state.
- Caretaker: This is the object responsible for keeping track of the Memento(s). It does not modify or inspect the contents of the Memento, but it knows when to store or retrieve the Memento.
When to Use the Memento Pattern:¶
- When you need to save and restore an object’s state (e.g., implementing undo/redo functionality).
- When you want to store snapshots of an object’s state without exposing its internal structure.
- When you want to allow rolling back to a previous state without violating encapsulation.
Example: Text Editor with Undo/Redo¶
In this example, we’ll simulate a simple text editor where the user can write text, and the system can undo or redo the last changes.
# Memento: Stores the state of the text editor
class TextEditorMemento:
def __init__(self, content):
self._content = content
def get_saved_content(self):
return self._content
# Originator: The text editor that creates and restores mementos
class TextEditor:
def __init__(self):
self._content = ""
def write(self, text):
self._content += text
def save(self):
return TextEditorMemento(self._content) # Save the current state
def restore(self, memento):
self._content = memento.get_saved_content() # Restore the state
def show_content(self):
print(self._content)
# Caretaker: Manages the history of mementos
class History:
def __init__(self):
self._mementos = []
def push(self, memento):
self._mementos.append(memento)
def pop(self):
if self._mementos:
return self._mementos.pop()
return None
# Client code: Test undo functionality in the text editor
editor = TextEditor()
history = History()
# Writing to the text editor
editor.write("Hello, ")
history.push(editor.save()) # Save state
editor.write("world!")
history.push(editor.save()) # Save state
editor.show_content() # Output: Hello, world!
# Undo last change
editor.restore(history.pop())
editor.show_content() # Output: Hello,
# Undo again
editor.restore(history.pop())
editor.show_content() # Output: (empty)
Example: Game Checkpoints¶
In this example, we’ll simulate a game where players can save their progress and load a checkpoint to restore a previous state.
# Memento: Stores the state of the game (player's score and level)
class GameMemento:
def __init__(self, level, score):
self._level = level
self._score = score
def get_saved_state(self):
return (self._level, self._score)
# Originator: The game that creates and restores mementos
class Game:
def __init__(self):
self._level = 1
self._score = 0
def play(self, level, score):
self._level = level
self._score = score
def save(self):
return GameMemento(self._level, self._score)
def restore(self, memento):
self._level, self._score = memento.get_saved_state()
def show_state(self):
print(f"Level: {self._level}, Score: {self._score}")
# Caretaker: Manages the saved checkpoints
class CheckpointManager:
def __init__(self):
self._checkpoints = []
def save_checkpoint(self, memento):
self._checkpoints.append(memento)
def load_checkpoint(self):
if self._checkpoints:
return self._checkpoints.pop()
return None
# Client code: Test checkpoint functionality in the game
game = Game()
manager = CheckpointManager()
# Playing the game
game.play(2, 500)
manager.save_checkpoint(game.save()) # Save checkpoint
game.play(3, 900)
manager.save_checkpoint(game.save()) # Save checkpoint
game.show_state() # Output: Level: 3, Score: 900
# Restore to the last checkpoint
game.restore(manager.load_checkpoint())
game.restore(manager.load_checkpoint())
game.show_state() # Output: Level: 2, Score: 500
Level: 3, Score: 900 Level: 2, Score: 500
Exercise: 🏠 Screen¶
- Implement create_memento() and set_memento(memento) methods in Screen class.
- The state of a Screen is a pair of self._text and self._clipboard.
- Memento should be a bytes array, so that later on you can store it in a file.
- Use pickle.dumps and pickle.loads methods to create/load mementos.
- ★ The above implementation depends on the fields of your class. Implement it in a generic way. You can get object state from self.dict. Make sure your code is reusable, that is you can use it for other classes.
Initial Code (without state)¶
class Screen:
def __init__(self, text=''):
self._text = text
self._clipboard = ''
def cut(self, start=0, end=0):
self._clipboard = self._text[start:end]
self._text = self._text[:start] + self._text[end:]
def paste(self, offset=0):
self._text = self._text[:offset] + self._clipboard + self._text[offset:]
def clear_clipboard(self):
self.clipboard = ''
def __len__(self):
return len(self.text)
def __repr__(self):
return 'Screen({})'.format(repr(self._text))
def create_memento(self):
pass
# Your Code Here
def set_memento(self, memento):
pass
# Your Code Here
Test¶
### Expected Behaviour
screen = Screen('hello world')
assert screen._text == 'hello world'
screen.cut(start=6, end=11)
assert screen._text == 'hello '
memento = screen.create_memento()
screen.paste(offset=0)
assert screen._text == 'worldhello '
screen.set_memento(memento)
assert screen._text == 'hello '
Solution¶
Exercise: 🏠 Screen with Undo¶
- Implement UndoableMixin class and @undoable decorator.
- Any class inheriting from the mixin should store state history and should provide undo() method.
- You create a new snapchot in the history only for methods decorated with @undoable. That is, cut and paste create a new snapchot. However, clear_clipboard should not create an entry in the history.
- Implement redo() functionality.
Initial Code¶
class TextEditor(UndoableMixin):
def __init__(self, text=''):
super().__init__()
self._text = text
self._clipboard = ''
@undoable
def cut(self, start=0, end=0):
self._clipboard = self._text[start:end]
self._text = self._text[:start] + self._text[end:]
@undoable
def paste(self, offset=0):
self._text = self._text[:offset] + self._clipboard + self._text[offset:]
def clear_clipboard(self):
self.clipboard = ''
@undoable
def set_text(self, text):
self._text = text
def __len__(self):
return len(self.text)
def __repr__(self):
return f'Screen({self._text})'
Tests¶
editor = TextEditor('hello world')
editor.cut(start=5, end=11)
assert editor._text == 'hello', editor._text
editor.paste(offset=0)
assert editor._text == ' worldhello', editor._text
editor.undo()
assert editor._text == 'hello', editor._text
editor.clear_clipboard()
editor.undo()
assert editor._text == 'hello world', editor._text
editor.redo()
assert editor._text == 'hello', editor._text
editor.redo()
assert editor._text == ' worldhello', editor._text
editor.undo()
assert editor._text == 'hello', editor._text
editor.cut(0, 1)
assert editor._text == 'ello', editor._text
import pytest
with pytest.raises(IndexError):
editor.redo() # ==> raises IndexError
Solution¶
Visitor Pattern¶
The Visitor Pattern is a behavioral design pattern that allows you to separate algorithms from the objects on which they operate. With the Visitor Pattern, you can define new operations without changing the classes of the elements on which it operates. This pattern is particularly useful when you have a structure of objects (such as a tree or a collection) and you need to perform different operations on these objects based on their concrete types.
Key Components of the Visitor Pattern:¶
- Visitor Interface: Declares methods for visiting each type of element in the structure.
- Concrete Visitor: Implements the operations defined in the visitor interface for each type of element.
- Element Interface: Declares an
accept
method that takes a visitor object as an argument. - Concrete Element: Implements the
accept
method by calling the appropriate visitor method. - Object Structure: A collection of elements that may be visited by the visitor.
When to Use the Visitor Pattern?¶
- When you need to perform many unrelated operations on objects in a structure, but want to avoid cluttering the classes with these operations.
- When the operations on a structure of objects change more frequently than the objects themselves.
- When you want to add new operations to an existing class hierarchy without modifying it.
Example: File System Visitor¶
In this example, we'll simulate a file system where different operations (like calculating the size or counting files) can be performed on files and directories using the Visitor Pattern.
class FileSystemVisitor:
def visit_file(self, file):
pass
def visit_directory(self, directory):
pass
# Concrete Visitor: Size Calculator
class SizeCalculatorVisitor(FileSystemVisitor):
def __init__(self):
self.total_size = 0
def visit_file(self, file):
self.total_size += file.size
def visit_directory(self, directory):
# Size of the directory itself can be included if needed
pass
# Concrete Visitor: File Counter
class FileCounterVisitor(FileSystemVisitor):
def __init__(self):
self.file_count = 0
def visit_file(self, file):
self.file_count += 1
def visit_directory(self, directory):
# No specific counting for directories
pass
# Element Interface
class FileSystemElement:
def accept(self, visitor: FileSystemVisitor):
pass
# Concrete Element: File
class File(FileSystemElement):
def __init__(self, name, size):
self.name = name
self.size = size
def accept(self, visitor: FileSystemVisitor):
visitor.visit_file(self)
# Concrete Element: Directory
class Directory(FileSystemElement):
def __init__(self, name):
self.name = name
self.children = []
def add(self, element: FileSystemElement):
self.children.append(element)
def accept(self, visitor: FileSystemVisitor):
visitor.visit_directory(self)
for child in self.children:
child.accept(visitor)
# Client code: Simulate a file system
root = Directory("root")
file1 = File("file1.txt", 100)
file2 = File("file2.txt", 200)
subdir = Directory("subdir")
file3 = File("file3.txt", 300)
# Build the file system hierarchy
root.add(file1)
root.add(file2)
subdir.add(file3)
root.add(subdir)
# Use the SizeCalculatorVisitor to calculate total size
size_visitor = SizeCalculatorVisitor()
root.accept(size_visitor)
print(f"Total size: {size_visitor.total_size}") # Output: Total size: 600
# Use the FileCounterVisitor to count files
file_counter = FileCounterVisitor()
root.accept(file_counter)
print(f"Total files: {file_counter.file_count}") # Output: Total files: 3
Total size: 600 Total files: 3