Design patterns
Problem
The function that grows with every promotion
The price in the shop depends on the promotion:
def final_price(price, promo): if promo == "student": return price * 0.9 elif promo == "black friday": return max(price - 20, 0) elif promo == "loyal customer": return price * 0.95 return priceprint(f"€{final_price(120, 'student'):.2f}")print(f"€{final_price(120, 'black friday'):.2f}")print(f"€{final_price(120, 'none'):.2f}")€108.00€100.00€120.00elif in this function, and often in two more: the one for the invoice and the one for the confirmation email. The function knows about all the promotions at once, and every change touches it. This problem is so common that its solution has a name.The idea
Design patterns
Patterns fall into three groups:
- Creational: how objects are made. For example Singleton (only one object of the class) and Factory Method (something else decides which class to create).
- Structural: how classes and objects are put together into bigger ones. For example Adapter (connects incompatible interfaces) and Composite (a tree of parts that you work with as one whole).
- Behavioral: how objects split the work and talk to each other. For example Strategy (a replaceable rule) and Observer (subscribers to an event).
Why they are worth knowing:
- you do not reinvent a solution that others have already tested;
- the code changes with less risk, because the part that changes is kept separate;
- the names are a shared language. "Put an adapter here" tells a colleague a whole structure in one word.
More precisely, if you are curious
if does the job, you do not need a pattern.See it
Strategy: a replaceable rule
class Checkout: def __init__(self, discount): self.discount = discount
def total(self, price): return max(self.discount(price), 0)checkout = Checkout(lambda price: price)print(f"No discount: €{checkout.total(120):.2f}")
checkout.discount = lambda price: price * 0.9print(f"Student: €{checkout.total(120):.2f}")
checkout.discount = lambda price: price - 20print(f"Black Friday: €{checkout.total(120):.2f}")No discount: €120.00Student: €108.00Black Friday: €100.00Checkout does not change. The rule can also be changed while the program is running: you store a new function in checkout.discount.This is Strategy: a family of rules, each with the same parameters and result, so they are interchangeable. The key you pass to sorted is also a strategy: sorted knows how to sort, and you say what to sort by.More precisely, if you are curious
In the books, the class that uses the strategy is called the "context" (here Checkout). If the strategy has its own fields, it can be a class. Checkout calls self.discount(price), so the object has to be callable like a function: the method __call__ gives it that:
class PercentDiscount: def __init__(self, percent): self.percent = percent
def __call__(self, price): return price * (1 - self.percent / 100)checkout = Checkout(PercentDiscount(10))print(f"Student: €{checkout.total(120):.2f}")Student: €108.00A class is the better choice when the strategy has its own fields or is long. A lambda, when it is one line.
See it
Observer: subscribers to an event
When the stock of a product changes, the website has to show the new count, and the warehouse has to reorder if there are only a few left. Tomorrow it may be an email to customers too. The product should not know about any of them.
class Product: def __init__(self, name, quantity): self.name = name self.quantity = quantity self.listeners = []
def subscribe(self, listener): self.listeners.append(listener)
def set_quantity(self, quantity): if quantity == self.quantity: return self.quantity = quantity for listener in self.listeners: listener(self.name, quantity)phone = Product("Phone", 0)phone.subscribe(lambda name, qty: print(f"Website: {name}, {qty} in stock"))
def store(name, qty): if qty < 3: print(f"Warehouse: reorder {name}")
phone.subscribe(store)
phone.set_quantity(10)phone.set_quantity(2)Website: Phone, 10 in stockWebsite: Phone, 2 in stockWarehouse: reorder Phone- Whoever wants to know subscribes with
subscribeand passes a function. - On a change, the product goes through the list and calls each function with the name and the count. Who the subscribers are and what they do is none of its business.
- If the count has not changed, nobody is bothered.
- A lambda in Python is only one expression, and
ifis not an expression. That is why the warehouse is a regular function withdef, andsubscribe(store)passes the function itself, without parentheses.
phone = Product("Phone", 0)phone.subscribe(lambda name, qty: print(f"Website: {name}, {qty} in stock"))
def store(name, qty): if qty < 3: print(f"Warehouse: reorder {name}")
phone.subscribe(store)
phone.set_quantity(10)phone.set_quantity(2)Step by step, in words
- Phone with 0 in stock and an empty listeners list.
- The website subscribes: the lambda goes into the list.
- So does the warehouse, with the function store. The product does not know who they are, only that it can call them with a name and a count.
- The stock becomes 10. The product goes through the list and calls each subscriber in the order they subscribed. The website shows the new count.
- The warehouse checks 10 < 3: no, so it does nothing.
- The stock becomes 2. The product goes through the list and calls each subscriber in the order they subscribed. The website shows the new count.
- The warehouse checks 2 < 3: yes, so it reorders.
This is Observer. The product is the observed object (the subject), and the listeners are the observers. Buttons in graphical programs work exactly like this: "when clicked, call this".
More precisely, if you are curious
- You also need an
unsubscribethat removes the listener from the list. You remove the same function you subscribed, so you keep it in a variable. A subscriber that nobody removes stays in memory as long as the product does.
phone = Product("Phone", 5)site = lambda name, qty: print(f"Website: {qty} in stock")phone.subscribe(site)phone.set_quantity(4)phone.listeners.remove(site)phone.set_quantity(3)print("The website is no longer listening")Website: 4 in stockThe website is no longer listening- In the old lecture the observed class is called
Subject, with the methodsattachandnotifyAllObservers, and the observer is an abstract class with a methodupdate(). The idea is the same, and with functions you do not need a class for each observer. - The listeners are called one after another, inside the method that changed the count. If one of them is slow, all of them wait.
Try it
Who will hear it?
Product is the one from the card before this one.
What will the program print?
coffee = Product("Coffee", 5)coffee.subscribe(lambda name, qty: print(f"A: {qty}"))coffee.set_quantity(4)coffee.set_quantity(4)coffee.subscribe(lambda name, qty: print(f"B: {qty}"))coffee.set_quantity(0)set_quantity(4) notifies A. The second one is not a change, and nobody hears anything. B subscribes after that and does not learn about past changes. At 0 both of them hear it, in the order they subscribed.See it
Factory Method: which class to create
EmailNotification() in ten places, each with its own if. The "notification" contract is an abstract class, as in the "Inheritance" module:from abc import ABC, abstractmethod
class Notification(ABC): @abstractmethod def send(self, to, text): pass
class EmailNotification(Notification): def send(self, to, text): print(f"Email to {to}: {text}")
class SmsNotification(Notification): def send(self, to, text): print(f"SMS to {to}: {text}")def create_notification(channel): match channel: case "email": return EmailNotification() case "sms": return SmsNotification() case _: raise ValueError(f"Unknown channel: {channel}")customers = [ ("email", "maria@example.com"), ("sms", "+359 88 123 4567"), ("pigeon", "5 Shipka St."),]for channel, to in customers: try: n = create_notification(channel) n.send(to, "Your order has been shipped.") except ValueError as e: print(e)Email to maria@example.com: Your order has been shipped.SMS to +359 88 123 4567: Your order has been shipped.Unknown channel: pigeonNotification. Only the factory knows exactly which class is behind it. A new channel, for example push, is a new class and a new case in the match. The sending is not touched.More precisely, if you are curious
- This is the most common form in practice. Its more precise name is "simple factory". The Factory Method from the GoF book is something else: the parent has an abstract method that creates the object, and the subclasses decide what kind:
class Shop(ABC): @abstractmethod def create_notification(self): pass
def confirm_order(self, to): self.create_notification().send(to, "The order has been received.")
class OnlineShop(Shop): def create_notification(self): return EmailNotification()
class PhoneShop(Shop): def create_notification(self): return SmsNotification()OnlineShop().confirm_order("maria@example.com")PhoneShop().confirm_order("+359 88 123 4567")Email to maria@example.com: The order has been received.SMS to +359 88 123 4567: The order has been received.confirm_order is written once, in Shop, and does not know what notification it sends. And Shop() cannot be created: ABC stops it with a TypeError, because create_notification is abstract.
- If the factory does not reach a
return, it returnsNone. Then the error comes only atsend, far from the real cause:
def create_notification(channel): if channel == "email": return EmailNotification() if channel == "sms": return SmsNotification()
n = create_notification("pigeon")try: n.send("5 Shipka St.", "Your order has been shipped.")except AttributeError as e: print("AttributeError:", e)AttributeError: 'NoneType' object has no attribute 'send'An exception with a clear message in the factory is better.
See it
Singleton: only one object
The application's settings, like the currency, are read once. If every part of the program makes its own settings object, they are read many times and can drift apart.
class AppConfig: _instance = None
def __new__(cls): if cls._instance is None: print("Reading the settings...") cls._instance = super().__new__(cls) cls._instance.currency = "EUR" return cls._instancea = AppConfig()b = AppConfig()print(a is b)print(b.currency)Reading the settings...TrueEUR- Python has no
privateconstructor: anyone can writeAppConfig(). So the guard is in__new__. Python calls__new__to create the object, even before__init__. - The only object is in the class field
_instance.__new__creates it on the first call and then always returns that one. The underscore in the name says "for internal use". - The currency is also set in
__new__, only the first time. Why not in__init__is in the card after this one. - "Reading the settings..." is printed once, and
a is bisTrue: there is one object.
This is Singleton.
More precisely, if you are curious
- If two threads call
AppConfig()at the same moment, two objects may be created. Threads are in the module "Real-world programs". - A safe Singleton in Python is an
Enumwith one value (theenummodule). AnEnumis a type with objects listed in advance. Python creates each one exactly once, andSettings("EUR")does not make a new object but finds the listed one:
from enum import Enum
class Settings(Enum): INSTANCE = "EUR"
def currency(self): return self.valueprint(Settings.INSTANCE.currency())print(Settings("EUR") is Settings.INSTANCE)try: Settings("USD")except ValueError as e: print("ValueError:", e)EURTrueValueError: 'USD' is not a valid Settings- In Python something else is more common: the object is created once, at the top level of a module, and the other parts bring it in with
import. - A Singleton is a global variable for the whole program with a nice name. Anyone can use it from anywhere, so it is hard to test and hard to replace. It is often better to create the object once at the start of the program and pass it as a parameter to the ones that use it.
Try it
Find the bug
Order numbers have to be unique, so the counter is a Singleton. But the program gave out the number 1001 twice:
numbers = OrderNumbers()print(numbers.next_number())print(numbers.next_number())print(OrderNumbers().next_number())100110021001Which line in the class is the bug on?
Pick the line with the bug.
__new__ always returns it. But after every OrderNumbers() Python also calls __init__, and it resets the counter to 1001. The third call does not make a new counter, it resets the only one. The fix is to put the starting value in __new__, next to super().__new__(cls), where it runs only once, and to remove __init__.See it
Adapter: connects incompatible interfaces
The shop pays through an interface with an amount in euros and a "yes or no" answer. The bank gives you an old library that wants cents and returns an error code. The library is not yours, and you cannot change it.
# The bank's old library. You cannot change it.class LegacyBankClient: # The amount is in cents. Returns 0 on success and an error code otherwise. def charge(self, cents, reference): print(f"Bank: {cents} cents for {reference}") return 0 if cents <= 50000 else 51You write a class that looks like what the shop expects, and inside it calls the bank:
from abc import ABC, abstractmethod
# This is what the shop expects.class PaymentGateway(ABC): @abstractmethod def pay(self, order_id, amount_eur): pass
class BankAdapter(PaymentGateway): def __init__(self, bank): self.bank = bank
def pay(self, order_id, amount_eur): cents = round(amount_eur * 100) return self.bank.charge(cents, f"ORDER-{order_id}") == 0gateway = BankAdapter(LegacyBankClient())print(gateway.pay("1001", 49.90))print(gateway.pay("1002", 1299.00))Bank: 4990 cents for ORDER-1001TrueBank: 129900 cents for ORDER-1002Falsetrue on the way back. The shop does not know there is an old library behind it. If tomorrow you change banks, you write a new adapter, and the shop is not touched.This is Adapter: like a plug adapter that changes neither the plug nor the wall.
More precisely, if you are curious
The classic example in the books, and in the old lecture, is a media player. AudioPlayer can play only mp3, and an existing class AdvancedMediaPlayer has the methods playVlc and playMp4. MediaAdapter implements the player's interface and forwards to the right method. The structure is the same as here.
Exercise
Patterns in the shop
Solve the tasks in your editor.
1. Discounts as strategies. Three functions that return a lambda: none(), percent(p) and fixed(amount) (never below €0). A class Cart with a list of prices and a replaceable discount in the field discount. Add coffee beans (€18.50), honey (€12) and tea (€4.20), and print the total with no discount, with −10%, with −€5 and with −€50.
No discount: €34.70-10%: €31.23-€5: €29.70-€50: €0.002. "Notify me when it is in stock". The product keeps a list of customers who are waiting. When the stock goes from 0 to more than 0, it notifies all of them and clears the list, because the notification is one-time. The console has 0 in stock, Maria and Georgi subscribe, then the stock becomes 5, 0 and 3.
Email to Maria: Console is in stock (5 units)SMS to Georgi: Console is in stockEnd3. A notification factory. Add PushNotification and a factory for the three channels that ignores uppercase and lowercase. Read a number of lines and then lines "channel;address". Send "Your reservation is confirmed." to each address, and for an unknown channel print the exception's message.
4Email;maria@example.comsms;+359 88 123 4567push;Elena's phonefax;02 123 456Email to maria@example.com: Your reservation is confirmed.SMS to +359 88 123 4567: Your reservation is confirmed.Push to Elena's phone: Your reservation is confirmed.Unknown channel: faxOldThermometer returns the temperature in Fahrenheit with read_fahrenheit(). The new system works with an abstract class TemperatureSensor with a method celsius(). Write ThermometerAdapter (°C = (°F − 32) × 5 / 9). In a list of sensors, put two old thermometers (71.6 °F and 39.2 °F) through the adapter, and one new sensor DigitalSensor that returns 21.5 °C.Office: 22.0 °CWarehouse: 4.0 °CServer room: 21.5 °CMore tasks
- Shipping as a strategy. Make functions for the shipping price to an office, to an address and from the store, and put the chosen one in the checkout together with the discount.
- A log as a Singleton. A class
Loggerwith a__new__that keeps the only object, and a methodlog(text)that numbers the lines. Check that the numbers continue when you call it from two different functions. - A price observer. Add subscribers for price changes to the product, and send a notification only when the price drops by at least 10%.
Test