Week 14 Lecture: Design Patterns
In the previous week, we studied SOLID principles — rules for writing clean and flexible code. Design patterns take this a step further: they are proven, reusable solutions (recipes) for problems that come up again and again in software development.
The classic reference is the book “Design Patterns: Elements of Reusable Object-Oriented Software” (1994), which catalogues 23 patterns. This lecture covers four of the most widely used ones: Singleton, Factory, Observer, and Strategy.
What Is a Design Pattern?
Every design pattern has three parts:
- Problem — a recurring situation in software design.
- Solution — a general arrangement of classes and objects that solves it.
- Name — a shared vocabulary so developers can communicate concisely (e.g., “use a Singleton here” is instantly understood).
1. Singleton
The word singleton comes from “single.” The Singleton pattern ensures that only one instance of a class can exist in the entire program.
When Is It Needed?
- Application configuration — a single config object shared everywhere.
- Database connection — one connection pool for the whole app.
- Logger — one shared log destination.
The Problem
Consider a Logger class that stores messages in a list. Two services — AuthService and PaymentService — each create their own Logger:
class Logger:
def __init__(self):
self.logs = []
def log(self, message):
self.logs.append(message)
print(f"[LOG] {message}")
class AuthService:
def __init__(self):
self.logger = Logger()
def login(self, user):
self.logger.log(f"{user} logged in")
class PaymentService:
def __init__(self):
self.logger = Logger()
def pay(self, amount):
self.logger.log(f"Payment of {amount} received")
auth = AuthService()
auth.login("Alisher")
payments = PaymentService()
payments.pay(50_000)
# Now the boss says: "Show me ALL logs for today"
print(auth.logger.logs) # ['Alisher logged in']
print(payments.logger.logs) # ['Payment of 50000 received']
# There is no single place with ALL logs!
Each service creates its own Logger object, so the logs are scattered. There is no single place that holds all log entries. You could manually create one logger and pass it to every class, but in a large project that quickly becomes unwieldy.
__new__ vs __init__
To understand Singleton, we first need to understand how Python creates objects. When you write Logger(), two magic methods run in sequence:
__new__(cls)— creates the object. It allocates memory, builds a new empty object, and returns it.__init__(self)— initializes the object. It takes the already-created object and fills it with attributes.
logger = Logger()
# What actually happens:
logger = Logger.__new__(Logger) # step 1: create
Logger.__init__(logger) # step 2: initialize
Normally you never override __new__ — Python’s default implementation simply creates a fresh object every time. But by overriding __new__, you can control object creation: instead of making a new object, you can return an existing one. This is the key to building a Singleton.
The Solution
class Logger:
_instance = None # class variable, shared by all
def __new__(cls):
if cls._instance is None:
print("Creating the one and only Logger")
cls._instance = super().__new__(cls)
cls._instance.logs = []
return cls._instance
def log(self, message):
self.logs.append(message)
print(f"[LOG] {message}")
logger_a = Logger() # Creating the one and only Logger
logger_b = Logger() # nothing prints, returns same object
logger_a.log("Hello")
logger_b.log("World")
print(logger_a is logger_b) # True
print(logger_a.logs) # ['Hello', 'World']
How it works:
- A class variable
_instancestarts asNone. __new__checks whether_instanceisNone. If so, it callssuper().__new__(cls)(the default object-creation logic from the built-inobjectclass) and stores the result.- The
logslist is initialized inside__new__, not__init__, because__init__runs every timeLogger()is called — it would reset the logs each time. In general, if you define__init__on a Singleton, guard it with a flag orhasattrcheck to prevent re-initialization. - Every subsequent call to
Logger()returns the same stored instance.
Pythonic Alternative: Module-Level Singleton
In Python, every module (file) is loaded only once. This means module-level variables are natural singletons:
# logger.py
logs = []
def log(message):
logs.append(message)
print(f"[LOG] {message}")
Anywhere you import logger, you get the same logs list:
import logger
import logger as logger2
logger.log("asd")
logger2.log("zxc")
print(logger.logs) # ['asd', 'zxc']
print(logger2.logs) # ['asd', 'zxc']
This approach is simpler and often preferred in Python when a full class is not necessary.
2. Factory
A factory in real life makes things — a car factory makes cars, a bread factory makes bread. In code, the Factory pattern is a function or class whose job is to create objects on your behalf, hiding the decision of which class to instantiate.
The Problem
Suppose you have several notification classes:
class EmailNotification:
def send(self, message):
print(f"📧 Email: {message}")
class SMSNotification:
def send(self, message):
print(f"📱 SMS: {message}")
class TelegramNotification:
def send(self, message):
print(f"✈️ Telegram: {message}")
When the user picks a notification method, you end up with an if/elif chain:
user_choice = "telegram"
if user_choice == "email":
notif = EmailNotification()
elif user_choice == "sms":
notif = SMSNotification()
elif user_choice == "telegram":
notif = TelegramNotification()
notif.send("Your order is ready, Sevara!")
This violates the Open/Closed Principle (OCP): every time a new notification type is added, you must modify this code.
Solution: Dict-Based Factory
Replace the if/elif chain with a dictionary lookup:
class NotificationFactory:
_types = {
"email": EmailNotification,
"sms": SMSNotification,
"telegram": TelegramNotification,
}
@staticmethod
def create(kind: str):
cls = NotificationFactory._types.get(kind)
if cls is None:
raise ValueError(f"Unknown type: {kind}")
return cls()
This is cleaner, but still requires editing the _types dictionary when adding new types — still an OCP violation.
Improved Solution: Registration-Based Factory
A better approach is to register notification types from the outside, so the factory itself never needs to be modified. (In tutorials we will learn how to make classes register themselves automatically using decorators.)
class NotificationFactory:
_types = {}
@classmethod
def register(cls, kind, notification_cls):
cls._types[kind] = notification_cls
@staticmethod
def create(kind):
cls = NotificationFactory._types.get(kind)
if cls is None:
raise ValueError(f"Unknown type: {kind}")
return cls()
NotificationFactory.register("email", EmailNotification)
NotificationFactory.register("sms", SMSNotification)
NotificationFactory.register("telegram", TelegramNotification)
Now each notification type is registered with the factory. Adding a new type means creating the class and calling register() — the factory code itself stays untouched.
When to Use Factory
Use the Factory pattern when object creation logic is complex or when you need to decouple the code that uses objects from the code that decides which class to instantiate.
3. Observer
Real-world analogy: Think of a Telegram channel. You subscribe to it, and whenever a new post is published, you get notified automatically. You do not need to keep checking — the channel pushes updates to you.
The Observer pattern works the same way: there is one subject (the channel) and many observers (subscribers). When the subject’s state changes, all observers are notified automatically.
Example: Weather Station
A weather station publishes temperature updates. Multiple displays react to changes:
class WeatherStation:
def __init__(self):
self._observers = []
self._temperature = 0
def subscribe(self, observer):
self._observers.append(observer)
def unsubscribe(self, observer):
self._observers.remove(observer)
def _notify(self):
for obs in self._observers:
obs.update(self._temperature)
def set_temperature(self, value):
print(f"\n🌡️ Station: temperature is now {value}°C")
self._temperature = value
self._notify()
The observers each implement an update() method:
class PhoneDisplay:
def update(self, temp):
print(f"📱 Phone shows: {temp}°C")
class WebDisplay:
def update(self, temp):
print(f"💻 Website shows: {temp}°C")
class AlarmSystem:
def update(self, temp):
if temp > 40:
print("🚨 ALARM! It's too hot!")
Putting it together:
station = WeatherStation()
phone = PhoneDisplay()
web = WebDisplay()
alarm = AlarmSystem()
station.subscribe(phone)
station.subscribe(web)
station.subscribe(alarm)
station.set_temperature(25)
# 📱 Phone shows: 25°C
# 💻 Website shows: 25°C
station.set_temperature(45)
# 📱 Phone shows: 45°C
# 💻 Website shows: 45°C
# 🚨 ALARM! It's too hot!
The station does not know or care what kind of observer is listening — it simply calls update() on each one. To enforce this interface, you can define an abstract base class:
from abc import ABC, abstractmethod
class Observer(ABC):
@abstractmethod
def update(self, value): ...
When to Use Observer
Use the Observer pattern when one thing changes and many things need to react — event systems, UI updates, notification services, etc.
4. Strategy
Analogy: You want to get from your house to the university. You can walk, take a bus, take a taxi, or ride a bike. The goal is the same — reach the university — but the strategy (how you get there) changes.
The Strategy pattern says: do not hard-code the algorithm. Pass it as an object so you can swap it at any time.
The Problem
class Character:
def __init__(self, name):
self.name = name
def move(self, place):
if place == "road":
print(f"{self.name} walks on the road")
elif place == "river":
print(f"{self.name} swims across the river")
elif place == "mountain":
print(f"{self.name} climbs the mountain")
elif place == "sky":
print(f"{self.name} flies through the sky")
Again, this is an OCP violation — adding a new movement type requires modifying the Character class.
The Solution
Define a strategy interface and separate implementations:
from abc import ABC, abstractmethod
class MovementStrategy(ABC):
@abstractmethod
def move(self, name): ...
class WalkStrategy(MovementStrategy):
def move(self, name):
print(f"{name} walks")
class SwimStrategy(MovementStrategy):
def move(self, name):
print(f"{name} swims")
class FlyStrategy(MovementStrategy):
def move(self, name):
print(f"{name} flies")
class TeleportStrategy(MovementStrategy):
def move(self, name):
print(f"{name} teleports in a flash")
Now Character accepts any strategy:
class Character:
def __init__(self, name, strategy):
self.name = name
self.strategy = strategy
def move(self):
self.strategy.move(self.name)
Usage:
hero = Character("Timur", WalkStrategy())
hero.move() # Timur walks
mage = Character("Sevara", FlyStrategy())
mage.move() # Sevara flies
wizard = Character("Alisher", TeleportStrategy())
wizard.move() # Alisher teleports in a flash
Pythonic Alternative: Functions as Strategies
In Python, functions are first-class objects, so a strategy can simply be a function:
def walk(name): print(f"{name} walks")
def swim(name): print(f"{name} swims")
def fly(name): print(f"{name} flies")
class Character:
def __init__(self, name, move_fn):
self.name = name
self.move_fn = move_fn
def move(self):
self.move_fn(self.name)
hero = Character("Timur", walk)
hero.move() # Timur walks
When to Use Strategy
Use the Strategy pattern when you have many interchangeable ways to do the same thing and want to avoid hard-coding the choice with if/elif chains.
Design Patterns Summary
| Pattern | Problem | Solution |
|---|---|---|
| Singleton | Multiple objects when you need only one | Override __new__ to always return the same instance |
| Factory | Messy if/elif chains to create objects | A separate class/method that decides which object to create |
| Observer | One thing changes, many things need to react | Subject keeps a list of observers and calls update() on each |
| Strategy | Many ways to do the same thing, hard-coded with if/elif | Pass the algorithm as an object (or function) |
Cheatsheet — Minimal Code
Singleton
class Singleton:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
Factory
class Factory:
_types = {}
@classmethod
def register(cls, kind, klass):
cls._types[kind] = klass
@staticmethod
def create(kind):
return Factory._types[kind]()
Observer
class Subject:
def __init__(self):
self._observers = []
def subscribe(self, obs):
self._observers.append(obs)
def notify(self, data):
for obs in self._observers:
obs.update(data)
Strategy
class Context:
def __init__(self, strategy):
self.strategy = strategy
def execute(self):
self.strategy.do()
The if __name__ == "__main__" Idiom
Every Python file has a built-in variable called __name__:
- When you run a file directly (
python calculator.py), Python sets__name__to the string"__main__". - When you import the file from another module,
__name__is set to the file’s module name (e.g.,"calculator").
This is useful when a file serves a dual purpose — both as a reusable library and as a standalone script:
# file: calculator.py
def add(a, b):
return a + b
def main():
print("Welcome to calculator")
print(add(2, 3))
if __name__ == "__main__":
main()
- Running
python calculator.pyexecutesmain(). - Writing
from calculator import addin another file imports the function without runningmain().
Enums
When working with a fixed set of states — like traffic light colors (RED, YELLOW, GREEN) or order statuses (PENDING, SHIPPED, DELIVERED) — using plain strings is error-prone:
order_status = "shipped"
if order_status == "shiped": # typo! silent bug!
print("On the way")
Python will not catch the typo — the condition silently evaluates to False.
Using Enum
Import Enum from the enum module and define your states as a subclass:
from enum import Enum
class OrderStatus(Enum):
PENDING = 1
SHIPPED = 2
DELIVERED = 3
CANCELLED = 4
order_status = OrderStatus.SHIPPED
if order_status == OrderStatus.SHIPED: # AttributeError immediately!
print("On the way")
A typo now raises an AttributeError instead of silently failing.
You can iterate over enum members:
for status in OrderStatus:
print(status.name, status.value)
# PENDING 1
# SHIPPED 2
# DELIVERED 3
# CANCELLED 4
Use auto() to let Python assign values automatically:
from enum import Enum, auto
class Color(Enum):
RED = auto()
GREEN = auto()
BLUE = auto()
Why Not Just Use Class Variables?
A plain class with constants offers no protection:
class Status:
PENDING = 1
SHIPPED = 2
Status.PENDING = 99 # anyone can change it!
Status.SHIPPED == 2 # True — it's just an int, no type safety
Status.OOPS = 5 # anyone can add new values
An Enum prevents all of these:
class Status(Enum):
PENDING = 1
SHIPPED = 2
Status.PENDING = 99 # AttributeError — cannot reassign!
Status.PENDING == 2 # False — it's a Status, not an int
Status.OOPS = 5 # TypeError — cannot add new members!
Enums provide three guarantees that plain classes do not:
- Immutable — values cannot be changed after creation.
- Type-safe —
Status.PENDINGis aStatusobject, not just an integer, preventing accidental mix-ups. - Closed set — new members cannot be added after definition.
Extended Unpacking
Basic unpacking assigns each element of an iterable to a variable:
a, b, c = [1, 2, 3]
print(a, b, c) # 1 2 3
Star (*) Unpacking
The * operator collects remaining elements into a list. It can appear in any position:
first, *middle, last = [1, 2, 3, 4, 5]
print(first) # 1
print(middle) # [2, 3, 4]
print(last) # 5
*head, tail = [10, 20, 30, 40]
print(head) # [10, 20, 30]
print(tail) # 40
Ignoring Values with _
By convention, _ is used as a throwaway variable name to indicate that a value is intentionally ignored:
name, _, age = ("Alisher", "Tashkent", 21) # ignore city
print(name, age)
You can combine * and _ to skip multiple values:
first, *_, last = [1, 2, 3, 4, 5]
print(first, last) # 1 5
for/else and while/else
Python allows an else clause on for and while loops. The else block runs only if the loop completes without hitting a break:
for i in range(5):
print(i)
else:
print("Loop finished cleanly")
If the loop is interrupted by break, the else block is skipped. This is particularly useful for search patterns:
numbers = [1, 3, 5, 7, 9]
target = 4
for n in numbers:
if n == target:
print("Found!")
break
else:
print("Not in the list")
Virtual Environments
When working on multiple projects, dependency conflicts can arise — one project may need version 3 of a library while another needs version 5. Since only one version can be installed globally at a time, this creates a problem.
Virtual environments solve this by giving each project its own isolated Python installation with its own set of libraries.
Creating a Virtual Environment
python -m venv myenv
This creates a myenv/ folder containing a private copy of the Python interpreter.
Activating the Environment
On macOS / Linux:
source myenv/bin/activate
On Windows:
myenv\Scripts\activate
The
sourcecommand (on Unix systems) runs the script’s commands in the current terminal session. Without it, the script would execute in a separate subprocess and the activation would not persist.
Once activated, your terminal prompt shows (myenv) at the beginning. Any pip install commands now only affect this environment.
Example: Isolating Flask Versions
Step 1 — Create two project folders:
mkdir project_a project_b
Step 2 — Project A: install Flask 2.x
cd project_a
python -m venv venv
source venv/bin/activate # Mac/Linux; on Windows: venv\Scripts\activate
pip install Flask==2.3.3
pip show Flask
# Name: Flask
# Version: 2.3.3
Step 3 — Project B: install Flask 3.x
cd ../project_b
python -m venv venv
source venv/bin/activate # Mac/Linux; on Windows: venv\Scripts\activate
pip install Flask==3.1.0
pip show Flask
# Name: Flask
# Version: 3.1.0
Each project now has its own Flask version, completely isolated from the other.
Progress Bars with tqdm
Long-running loops with no output leave you staring at a frozen screen. The tqdm library (from the Arabic taqaddum, meaning “progress”) provides a simple progress bar. It is widely used in machine learning and data processing.
Install it with:
pip install tqdm
Wrap any iterable with tqdm():
import time
from tqdm import tqdm
for i in tqdm(range(1_000)):
time.sleep(0.05)
The progress bar displays:
- Percentage complete
- Item count
- Time elapsed
- Estimated time remaining
- Speed (iterations per second)
You can add a description label:
for i in tqdm(range(1_000), desc="Processing"):
time.sleep(0.05)