← Computer Programming II

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:

  1. Problem — a recurring situation in software design.
  2. Solution — a general arrangement of classes and objects that solves it.
  3. 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:

  1. __new__(cls)creates the object. It allocates memory, builds a new empty object, and returns it.
  2. __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:

  1. A class variable _instance starts as None.
  2. __new__ checks whether _instance is None. If so, it calls super().__new__(cls) (the default object-creation logic from the built-in object class) and stores the result.
  3. The logs list is initialized inside __new__, not __init__, because __init__ runs every time Logger() is called — it would reset the logs each time. In general, if you define __init__ on a Singleton, guard it with a flag or hasattr check to prevent re-initialization.
  4. 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.py executes main().
  • Writing from calculator import add in another file imports the function without running main().

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-safeStatus.PENDING is a Status object, 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 source command (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)