برنامه‌نویسی پایتون فهرست مطالب
فصل ۳۳

الگوهای طراحی

بعضی مسئله‌ها در هر پروژه‌ای تکرار می‌شوند، و برنامه‌نویس‌ها در طول سال‌ها برای هر کدام یک راه حل جاافتاده پیدا کرده‌اند. به این راه‌حل‌ها الگوی طراحی می‌گویند. در این فصل هفت الگوی پرکاربرد را می‌بینیم و مهم‌تر از خود الگوها، یاد می‌گیریم هرکدام چه مسئله‌ای را حل می‌کنند.

در این فصل می‌خوانید
  1. الگوی طراحی چیست
  2. تک‌نمونه
  3. کارخانه
  4. سازنده
  5. مبدل
  6. دکوراتور
  7. استراتژی
  8. استراتژی در زمان اجرا
  9. ناظر
  10. هشدار: الگوی بی‌جا

۱. الگوی طراحی چیست

الگوی طراحی نه یک کتابخانه است و نه کدی که کپی کنید. الگو یک شکل راه حل است که بارها جواب داده است. شما آن را متناسب با مسئله خودتان پیاده می‌کنید.

فایده دوم الگوها زبان مشترک است. وقتی به همکارتان بگویید «اینجا از استراتژی استفاده کن»، یک جمله جای ده دقیقه توضیح می‌نشیند. الگوها معمولاً در سه خانواده دسته‌بندی می‌شوند:

الگوخانوادهبه چه سؤالی جواب می‌دهد
تک‌نمونهساختنیاز این شیء فقط باید یکی وجود داشته باشد
کارخانهساختنیدر زمان اجرا تصمیم بگیر کدام کلاس ساخته شود
سازندهساختنییک شیء پیچیده را قدم‌به‌قدم بساز
مبدلساختارییک کلاس قدیمی را با رابط جدید جور کن
دکوراتورساختاریرفتاری اضافه کن بدون دست زدن به کلاس
استراتژیرفتاریالگوریتم را در زمان اجرا عوض کن
ناظررفتاریوقتی چیزی عوض شد، به همه خبر بده
پیش‌نیاز این فصل

همه چیزهایی که در فصل‌های بیست و یکم تا بیست و ششم خواندید اینجا به کار می‌آیند: وراثت، کلاس انتزاعی ABC، تایپ اردکی، متدهای جادویی و دکوراتورها. اگر جایی گیر کردید، به آن فصل‌ها برگردید.

۲. تک‌نمونه

بعضی شیءها باید در کل برنامه فقط یکی باشند: یک فایل ثبت وقایع، یک شیء تنظیمات، یک استخر اتصال پایگاه داده. اگر دو تا ساخته شوند، دو نسخه از حقیقت خواهید داشت.

'''SINGLETON - creational pattern.

Problem:  some objects must exist only once in the whole program:
          a logger, a configuration, a connection pool.
Solution: the class itself makes sure that every call returns the SAME object.
'''


class SingletonLogger:
    # A class attribute holds the single instance.
    _instance = None

    def __new__(cls):
        '''__new__ runs BEFORE __init__ and is what really creates the object.

        By overriding it we can return an object that already exists instead of
        building a new one.
        '''
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance.logs = []      # initialised only the first time
        return cls._instance

    def log(self, message: str) -> None:
        self.logs.append(message)
        print(f'LOG: {message}')


if __name__ == '__main__':
    logger1 = SingletonLogger()
    logger2 = SingletonLogger()

    logger1.log('First message')
    logger2.log('Second message')

    print(logger1 is logger2)    # True - it is the very same object
    print(logger1.logs)          # ['First message', 'Second message']

تک‌نمونه: نمونه 01_singleton.py

LOG: First message
LOG: Second message
True
['First message', 'Second message']

شرح مثال

در پایتون راه ساده‌تری هست

هر ماژول پایتون خودش یک تک‌نمونه است: هر چند بار هم import شود، فقط یک بار اجرا می‌شود. کافی است در بالای یک ماژول بنویسید logger = Logger() و هر جا لازم شد همان را import کنید. همان تضمین، بدون هیچ جادویی. پیش از رفتن سراغ __new__، این راه را در نظر بگیرید.

۳. کارخانه

گاهی تا زمان اجرا نمی‌دانید کدام کلاس را باید بسازید: به انتخاب کاربر، به یک تنظیم، یا به محتوای یک فایل بستگی دارد. الگوی کارخانه این تصمیم را در یک نقطه جمع می‌کند.

from abc import ABC, abstractmethod


class Notifier(ABC):
    'The common interface. Every notifier can send a message.'

    @abstractmethod
    def send(self, message: str) -> None:
        ...


class EmailNotifier(Notifier):
    def send(self, message: str) -> None:
        print(f'Sending email: {message}')


class SMSNotifier(Notifier):
    def send(self, message: str) -> None:
        print(f'Sending SMS: {message}')


class BotNotifier(Notifier):
    def send(self, message: str) -> None:
        print(f'Sending bot message: {message}')


class NotifierFactory:
    'The only place in the program that knows the concrete classes.'

    # A dictionary is cleaner than a chain of elif: adding a channel is
    # one line, and no existing code has to be touched.
    _channels = {
        'email': EmailNotifier,
        'sms': SMSNotifier,
        'bot': BotNotifier,
    }

    @staticmethod
    def create_notifier(channel: str) -> Notifier:
        'Return the notifier matching the given channel name.'
        notifier_class = NotifierFactory._channels.get(channel)

        if notifier_class is None:
            raise ValueError(f'Unknown channel: {channel!r}')

        return notifier_class()


if __name__ == '__main__':
    notifier = NotifierFactory.create_notifier('email')
    notifier.send('Welcome to Kadoos!')

    # The rest of the program never mentions EmailNotifier or SMSNotifier.
    for channel in ('sms', 'bot'):
        NotifierFactory.create_notifier(channel).send('Your class starts soon')

    try:
        NotifierFactory.create_notifier('pigeon')
    except ValueError as e:
        print('Refused:', e)

کارخانه: نمونه 02_factory.py

Sending email: Welcome to Kadoos!
Sending SMS: Your class starts soon
Sending bot message: Your class starts soon
Refused: Unknown channel: 'pigeon'

شرح مثال

۴. سازنده

وقتی یک شیء ده جزء اختیاری دارد، سازنده‌ای با ده پارامتر غیرقابل خواندن می‌شود. الگوی سازنده شیء را قدم‌به‌قدم می‌سازد: هر جزء یک متد.

class Pizza:
    'The object we want to build.'

    def __init__(self) -> None:
        self.size: str | None = None
        self.cheese: bool = False
        self.pepperoni: bool = False
        self.mushrooms: bool = False

    def __str__(self) -> str:
        toppings = []
        if self.cheese:
            toppings.append('cheese')
        if self.pepperoni:
            toppings.append('pepperoni')
        if self.mushrooms:
            toppings.append('mushrooms')
        return f'Pizza(size={self.size}, toppings={toppings})'


class PizzaBuilder:
    'Builds a Pizza one step at a time.'

    def __init__(self) -> None:
        self.pizza = Pizza()

    def set_size(self, size: str) -> 'PizzaBuilder':
        self.pizza.size = size
        return self                # returning self is what allows chaining

    def add_cheese(self) -> 'PizzaBuilder':
        self.pizza.cheese = True
        return self

    def add_mushrooms(self) -> 'PizzaBuilder':
        self.pizza.mushrooms = True
        return self

    def build(self) -> Pizza:
        'Return the finished object.'
        if self.pizza.size is None:
            raise ValueError('a pizza needs a size')
        return self.pizza

سازنده: نمونه 03_builder.py

# Spread over several lines - the most readable form:
pizza = (
    PizzaBuilder()
    .set_size('large')
    .add_cheese()
    .add_pepperoni()
    .add_mushrooms()
    .build()
)
print(pizza)

try:
    PizzaBuilder().add_cheese().build()
except ValueError as e:
    print('Refused:', e)

استفاده زنجیره‌ای

Pizza(size=large, toppings=['cheese', 'pepperoni', 'mushrooms'])
Refused: a pizza needs a size

شرح مثال

۵. مبدل

تصویر واقعی این الگو، مبدل دوشاخه برق است: از نظر الکتریکی هیچ چیز را عوض نمی‌کند، فقط دو شکل را با هم جور می‌کند. در برنامه‌نویسی هم وقتی یک کلاس قدیمی کار درست را انجام می‌دهد ولی نام متدهایش با سیستم جدید نمی‌خواند و اجازه تغییرش را ندارید، یک مبدل می‌نویسید.

class LegacyPrinter:
    'The old class. We are not allowed to modify it.'

    def old_print(self, text: str) -> None:
        print(f'Legacy printer: {text}')


class PrinterInterface:
    'What the new system expects from a printer.'

    def print_message(self, text: str) -> None:
        raise NotImplementedError


class ModernPrinter(PrinterInterface):
    'A class written for the new interface.'

    def print_message(self, text: str) -> None:
        print(f'Modern printer: {text}')


class PrinterAdapter(PrinterInterface):
    'Makes a LegacyPrinter usable wherever a PrinterInterface is expected.'

    def __init__(self, legacy_printer: LegacyPrinter) -> None:
        self.legacy_printer = legacy_printer

    def print_message(self, text: str) -> None:
        # The only job of the adapter: translate the call.
        self.legacy_printer.old_print(text)


def print_report(printer: PrinterInterface) -> None:
    'This function only knows the new interface.'
    printer.print_message('Hello from the new system')


if __name__ == '__main__':
    print_report(ModernPrinter())

    legacy = LegacyPrinter()
    print_report(PrinterAdapter(legacy))    # the old class fits in

مبدل: نمونه 04_adapter.py

Modern printer: Hello from the new system
Legacy printer: Hello from the new system

شرح مثال

۶. دکوراتور

دکوراتورها را در فصل بیست و ششم به عنوان یک ابزار پایتون دیدید. اینجا همان را به عنوان یک الگو می‌بینیم: افزودن رفتار (زمان‌گیری، ثبت وقایع، بررسی دسترسی، حافظه نهان) بدون دست زدن به کد اصلی.

import time
from functools import wraps


def timing_decorator(func):
    'Measure how long the decorated function takes.'

    @wraps(func)          # keeps the name and the docstring of func
    def wrapper(*args, **kwargs):
        start = time.time()
        result = func(*args, **kwargs)      # the original behaviour
        end = time.time()
        print(f'{func.__name__} took {end - start:.4f} seconds')
        return result

    return wrapper


def audit(username: str):
    'A decorator with a parameter: who is allowed to run this?'
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            print(f'[audit] {username} called {func.__name__}')
            return func(*args, **kwargs)
        return wrapper
    return decorator


@timing_decorator
def slow_function():
    'A function that takes some time.'
    time.sleep(1)
    print('Function finished')


@audit('admin')
@timing_decorator          # decorators stack: the closest one wraps first
def compute(n: int) -> int:
    return sum(range(n))

دکوراتور: نمونه 05_decorator.py

Function finished
slow_function took 1.0012 seconds
[audit] admin called compute
compute took 0.0921 seconds
4499998500000

شرح مثال

۷. استراتژی

هر وقت چند الگوریتم قابل تعویض دارید و یک زنجیره if/elif دارید که با هر مورد تازه بلندتر می‌شود، الگوی استراتژی جواب شماست: هر الگوریتم یک کلاس، همه با یک رابط مشترک.

from abc import ABC, abstractmethod


class DiscountStrategy(ABC):
    'The common interface of every discount.'

    @abstractmethod
    def apply(self, price: float) -> float:
        ...


class NoDiscount(DiscountStrategy):
    def apply(self, price: float) -> float:
        return price


class TenPercentDiscount(DiscountStrategy):
    def apply(self, price: float) -> float:
        return price * 0.9


class FixedDiscount(DiscountStrategy):
    'Adding a new strategy changes NO existing class.'

    def __init__(self, amount: float) -> None:
        self.amount = amount

    def apply(self, price: float) -> float:
        return max(0.0, price - self.amount)


class ShoppingCart:
    'The client. It does not know which discount it is using.'

    def __init__(self, discount_strategy: DiscountStrategy) -> None:
        self.discount_strategy = discount_strategy

    def checkout(self, price: float) -> float:
        return self.discount_strategy.apply(price)


def main() -> None:
    print(ShoppingCart(NoDiscount()).checkout(1000))          # 1000
    print(ShoppingCart(TenPercentDiscount()).checkout(1000))  # 900.0
    print(ShoppingCart(FixedDiscount(250)).checkout(1000))    # 750.0

    # The strategy can even be changed while the program runs.
    cart = ShoppingCart(NoDiscount())
    print(cart.checkout(1000))
    cart.discount_strategy = TenPercentDiscount()
    print(cart.checkout(1000))

استراتژی: نمونه 06_strategy.py

1000
900.0
750.0
1000
900.0

شرح مثال

۸. استراتژی در زمان اجرا

جایی که این الگو واقعاً سود می‌دهد آنجاست که تصمیم (کدام استراتژی) از رفتار (آن استراتژی چه می‌کند) جدا می‌شود.

def choose_strategy(price: float) -> DiscountStrategy:
    'The business rule lives here, and ONLY here.'
    if price >= 10_000_000:
        return TwentyPercentDiscount()
    if price >= 5_000_000:
        return TenPercentDiscount()
    return NoDiscount()


def read_price() -> float:
    'Ask until the answer is a number.'
    while True:
        try:
            return float(input('Enter the price: '))
        except ValueError:
            print('That is not a number.')


def main() -> None:
    price = read_price()

    cart = ShoppingCart(choose_strategy(price))

    print(f'Strategy: {type(cart.discount_strategy).__name__}')
    print(f'To pay:   {cart.checkout(price):,.0f}')

انتخاب در زمان اجرا: نمونه 07_strategy_interactive.py

Enter the price: 12000000
Strategy: TwentyPercentDiscount
To pay:   9,600,000

شرح مثال

۹. ناظر

وقتی چیزی تغییر می‌کند، چند شیء دیگر باید واکنش نشان دهند، و شیئی که تغییر کرده نباید مجبور باشد آنها را بشناسد. الگوی ناظر این را با یک فهرست مشترک حل می‌کند.

class NewsPublisher:
    'The subject: it holds the state and notifies the subscribers.'

    def __init__(self) -> None:
        self.subscribers: list = []
        self.latest_news: str | None = None

    def subscribe(self, subscriber) -> None:
        'Add a subscriber (only once).'
        if subscriber not in self.subscribers:
            self.subscribers.append(subscriber)

    def unsubscribe(self, subscriber) -> None:
        'Remove a subscriber, quietly when it is not there.'
        if subscriber in self.subscribers:
            self.subscribers.remove(subscriber)

    def notify(self) -> None:
        'Tell every subscriber about the latest news.'
        # A copy of the list, so a subscriber may unsubscribe during notify().
        for subscriber in list(self.subscribers):
            subscriber.update(self.latest_news)

    def add_news(self, news: str) -> None:
        'Change the state and notify - the two always go together.'
        self.latest_news = news
        self.notify()


class NewsReader:
    'An observer. It only has to offer an update() method (duck typing).'

    def __init__(self, name: str) -> None:
        self.name = name

    def update(self, news: str) -> None:
        print(f'{self.name} received news: {news}')


class NewsArchive:
    'Another observer, doing something completely different.'

    def __init__(self) -> None:
        self.history: list[str] = []

    def update(self, news: str) -> None:
        self.history.append(news)
        print(f'[archive] {len(self.history)} news stored')

ناظر: نمونه 08_observer.py

Ali received news: Python 3.14 is released
Sara received news: Python 3.14 is released
[archive] 1 news stored
---
Ali received news: Kadoos opens a new Python class
[archive] 2 news stored
---
archive: ['Python 3.14 is released', 'Kadoos opens a new Python class']

شرح مثال

این الگو را قبلاً دیده‌اید

دکمه‌های رابط گرافیکی در فصل‌های سی و چهارم تا سی و ششم دقیقاً همین‌اند: command=my_function یعنی «وقتی کلیک شدم به این خبر بده». سیگنال و اسلات در Qt هم همین است. الگوی ناظر در هر رابط کاربری و هر سیستم رویدادمحوری زندگی می‌کند.

۱۰. هشدار: الگوی بی‌جا

این مهم‌ترین بخش فصل است. الگویی که جای لازم به کار نرود، کد را بدتر می‌کند. تازه‌کارها بعد از یاد گرفتن الگوها معمولاً همه جا از آنها استفاده می‌کنند و برنامه ساده را به هزارتو تبدیل می‌کنند.

قاعده درست: با ساده‌ترین چیزی که کار می‌کند شروع کنید؛ وقتی مسئله‌ای که یک الگو حل می‌کند واقعاً پیدا شد، آن وقت سراغش بروید.

الگوکی سراغش نروید
تک‌نمونهوقتی یک ماژول ساده کافی است. تک‌نمونه حالت سراسری می‌سازد و آزمودن کد را سخت می‌کند
کارخانهوقتی فقط یک یا دو کلاس دارید و انتخاب ثابت است
سازندهوقتی شیء دو یا سه پارامتر دارد. آرگومان کلیدواژه‌ای پایتون کافی است
مبدلوقتی می‌توانید خود کلاس را اصلاح کنید
دکوراتوروقتی رفتار اضافه فقط در یک تابع لازم است
استراتژیوقتی فقط دو حالت دارید و هرگز زیاد نمی‌شوند
ناظروقتی فقط یک شنونده دارید. صدا زدن مستقیم خواناتر است
فایل‌های تمرینی این فصل

پوشه S33-design-patterns شامل هشت فایل 01_singleton.py تا 08_observer.py. هر فایل مستقل است و بدون نصب چیزی اجرا می‌شود. توصیه می‌شود هر فایل را اجرا کنید و بعد سعی کنید یک کلاس تازه به آن اضافه کنید: تجربه کردن اینکه «هیچ کد موجودی دست نخورد» کل درس این فصل است.

خلاصه فصل

الگوهسته پیاده‌سازی
تک‌نمونهبازنویسی __new__ و نگه داشتن _instance، یا فقط یک ماژول
کارخانهدیکشنری از نام به کلاس، به‌علاوه یک تابع سازنده
سازندهیک متد برای هر جزء، همه با return self، و یک build()
مبدلکلاسی که شیء قدیمی را نگه می‌دارد و نام متدها را ترجمه می‌کند
دکوراتورتابعی که تابع می‌گیرد و تابع پیچیده برمی‌گرداند، با @wraps
استراتژییک ABC و چند پیاده‌سازی، که به مشتری تزریق می‌شوند
ناظرفهرست مشترکان، متدهای subscribe و notify
قاعده طلاییساده شروع کنید. الگو را وقتی مسئله‌اش ظاهر شد به کار ببرید

تمرین‌های فصل سی و سه

  1. یک تک‌نمونه Settings بنویسید که فایل پیکربندی را فقط یک بار بخواند.
  2. یک ShapeFactory بنویسید که از روی نام، شیء Circle یا Square یا Triangle بسازد.
  3. کلاس Pasta و PastaBuilder را طبق الگوی سازنده بنویسید.
  4. کلاس قدیمی OldDatabase با متد run_query() را در نظر بگیرید و مبدلی بنویسید که به جایش execute() ارائه دهد.
  5. دکوراتور @log_to_file را بنویسید که هر فراخوانی را در calls.log بنویسد.
  6. سه استراتژی مرتب‌سازی (بر اساس نام، نمره و سن) و یک کلاس Ranking بنویسید که از یکی‌شان استفاده کند.
  7. یک ناظر EmailAlert به 08_observer.py اضافه کنید و بررسی کنید که کلاس ناشر اصلاً تغییر نکرده باشد.
  8. یکی از برنامه‌های قبلی خودتان را که زنجیره بلند if/elif دارد، با الگوی استراتژی بازنویسی کنید.
  9. برای هر الگوی این فصل، یک جمله بنویسید که بگوید کی نباید به وظیفه آن برد.
فصل قبل: ORM و SQLAlchemy فصل بعد: رابط گرافیکی با tkinter