الگوهای طراحی
بعضی مسئلهها در هر پروژهای تکرار میشوند، و برنامهنویسها در طول سالها برای هر کدام یک راه حل جاافتاده پیدا کردهاند. به این راهحلها الگوی طراحی میگویند. در این فصل هفت الگوی پرکاربرد را میبینیم و مهمتر از خود الگوها، یاد میگیریم هرکدام چه مسئلهای را حل میکنند.
۱. الگوی طراحی چیست
الگوی طراحی نه یک کتابخانه است و نه کدی که کپی کنید. الگو یک شکل راه حل است که بارها جواب داده است. شما آن را متناسب با مسئله خودتان پیاده میکنید.
فایده دوم الگوها زبان مشترک است. وقتی به همکارتان بگویید «اینجا از استراتژی استفاده کن»، یک جمله جای ده دقیقه توضیح مینشیند. الگوها معمولاً در سه خانواده دستهبندی میشوند:
| الگو | خانواده | به چه سؤالی جواب میدهد |
|---|---|---|
| تکنمونه | ساختنی | از این شیء فقط باید یکی وجود داشته باشد |
| کارخانه | ساختنی | در زمان اجرا تصمیم بگیر کدام کلاس ساخته شود |
| سازنده | ساختنی | یک شیء پیچیده را قدمبهقدم بساز |
| مبدل | ساختاری | یک کلاس قدیمی را با رابط جدید جور کن |
| دکوراتور | ساختاری | رفتاری اضافه کن بدون دست زدن به کلاس |
| استراتژی | رفتاری | الگوریتم را در زمان اجرا عوض کن |
| ناظر | رفتاری | وقتی چیزی عوض شد، به همه خبر بده |
همه چیزهایی که در فصلهای بیست و یکم تا بیست و ششم خواندید اینجا
به کار میآیند: وراثت، کلاس انتزاعی 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']
شرح مثال
- متد
__new__برخلاف__init__شیء را میسازد و پیش از آن اجرا میشود. با بازنویسیاش میتوانیم شیء موجود را برگردانیم. - صفت کلاسی
_instanceتنها نمونه را نگه میدارد. خط زیرش فقط بار اول اجرا میشود، پس فهرست گزارشها هر بار خالی نمیشود. - عملگر
isمیگوید دو نام به یک شیء اشاره میکنند، نه اینکه محتوایشان برابر است. تفاوتisبا==را در فصل نهم دیدید.
هر ماژول پایتون خودش یک تکنمونه است: هر چند بار
هم 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'
شرح مثال
- کلاس انتزاعی
Notifierقرارداد مشترک را تعیین میکند: هر کانالی بایدsendداشته باشد. - نکته کلیدی در دیکشنری
_channelsاست. مقدارهای این دیکشنری خود کلاسها هستند، نه شیء. در پایتون کلاس هم یک شیء است و میشود در متغیر و لیست و دیکشنری نگهش داشت. notifier_class()یعنی همان کلاس را صدا بزن و یک نمونه بساز.- دیکشنری جای زنجیره
if/elifنشسته است. افزودن کانال تازه یک سطر است و هیچ کد موجودی دست نمیخورد. این همان «اصل باز و بسته» است: باز برای گسترش، بسته برای تغییر. - کانال ناشناس با
ValueErrorرد میشود، نه با یکNoneبیسروصدا.
۴. سازنده
وقتی یک شیء ده جزء اختیاری دارد، سازندهای با ده پارامتر غیرقابل خواندن میشود. الگوی سازنده شیء را قدمبهقدم میسازد: هر جزء یک متد.
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
شرح مثال
- ترفندی که کل الگو را دلپذیر میکند یک خط است:
return self. چون هر متد خودِ سازنده را برمیگرداند، میشود متدها را پشت سر هم زنجیر کرد. - پرانتز دور کل عبارت اجازه میدهد زنجیره چندخطی شود و هر قدم در یک سطر بنشیند. این خواناترین شکل نوشتن است.
- متد
buildفقط شیء را برنمیگرداند، بلکه اعتبارسنجی هم میکند. پیتزای بدون اندازه ساخته نمیشود. - مقایسه کنید با
Pizza('large', True, False, True). شش ماه بعد هیچکس نمیفهمد آن سه بولی یعنی چه.
۵. مبدل
تصویر واقعی این الگو، مبدل دوشاخه برق است: از نظر الکتریکی هیچ چیز را عوض نمیکند، فقط دو شکل را با هم جور میکند. در برنامهنویسی هم وقتی یک کلاس قدیمی کار درست را انجام میدهد ولی نام متدهایش با سیستم جدید نمیخواند و اجازه تغییرش را ندارید، یک مبدل مینویسید.
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
شرح مثال
- تابع
print_reportفقط رابط جدید را میشناسد و اصلاً خبر ندارد کلاس قدیمی وجود دارد. - مبدل کلاس قدیمی را در خود نگه میدارد (ترکیب) و از آن ارث نمیبرد. این همان بحث «ترکیب در برابر وراثت» فصل بیست و سوم است.
- کل بدنه متد مبدل یک خط است: ترجمه نام. مبدل نباید منطق اضافه داشته باشد؛ اگر دارد، دیگر مبدل نیست.
- این الگو در کار واقعی بسیار پرکاربرد است: وقتی کتابخانهای را عوض میکنید، یک مبدل مینویسید تا بقیه برنامه دست نخورد.
۶. دکوراتور
دکوراتورها را در فصل بیست و ششم به عنوان یک ابزار پایتون دیدید. اینجا همان را به عنوان یک الگو میبینیم: افزودن رفتار (زمانگیری، ثبت وقایع، بررسی دسترسی، حافظه نهان) بدون دست زدن به کد اصلی.
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
شرح مثال
@wraps(func)نام و مستندات تابع اصلی را حفظ میکند. بدون آن،compute.__name__میشود'wrapper'و اشکالزدایی سخت میشود.- دکوراتور پارامتردار یک لایه بیشتر دارد: تابعی که دکوراتور میسازد. سه سطح تودرتو عجیب به نظر میرسد ولی همیشه همین شکل است.
- دکوراتورها روی هم انباشته میشوند و نزدیکترین به تابع، اول میپیچد. اینجا اول زمانگیری و بعد ثبت وقایع.
- تفاوت با الگوی کلاسیک: در کتابهای قدیمی دکوراتور شیء را میپیچد، در پایتون معمولاً تابع را با
@. ایده یکی است: افزودن رفتار با پیچیدن، نه با ارثبری.
۷. استراتژی
هر وقت چند الگوریتم قابل تعویض دارید و یک زنجیره 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
شرح مثال
ShoppingCartهیچ نمیداند کدام تخفیف را به کار میبرد. فقط میداند شیئی دارد که متدapplyدارد.- افزودن
FixedDiscountهیچ کلاس موجودی را تغییر نداد. اگر زنجیرهelifبود، باید تابع اصلی را دست میزدیم و ریسک شکستن بقیه را میپذیرفتیم. - استراتژی میتواند در میانه اجرا عوض شود: کافی است صفت را بازنویسی کنید.
- استراتژیای که خودش پارامتر دارد (مثل مبلغ ثابت) نشان میدهد چرا کلاس بهتر از تابع ساده است: کلاس میتواند حالت داشته باشد.
۸. استراتژی در زمان اجرا
جایی که این الگو واقعاً سود میدهد آنجاست که تصمیم (کدام استراتژی) از رفتار (آن استراتژی چه میکند) جدا میشود.
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
شرح مثال
- تنها جایی که قانون کسبوکار نوشته شده، تابع
choose_strategyاست. اگر فردا مدیر بگوید آستانهها عوض شوند، فقط همین تابع تغییر میکند. type(obj).__name__نام کلاس یک شیء را میدهد؛ برای پیام و گزارش مفید است.read_priceهمان حلقه اعتبارسنجی ورودی فصل هشتم است. هیچ وقتfloat(input(...))را بدون محافظ ننویسید.- قالب
{price:,.0f}عددها را سهرقمسهرقم جدا میکند. برای نمایش مبلغ عالی است.
۹. ناظر
وقتی چیزی تغییر میکند، چند شیء دیگر باید واکنش نشان دهند، و شیئی که تغییر کرده نباید مجبور باشد آنها را بشناسد. الگوی ناظر این را با یک فهرست مشترک حل میکند.
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']
شرح مثال
- ناشر فقط میداند مشترکانش متد
updateدارند. این تایپ اردکی فصل بیست و سوم است: هیچ کلاس پایه مشترکی ضرورتی ندارد. - دو ناظر کارهای کاملاً متفاوتی میکنند: یکی چاپ میکند، دیگری بایگانی. ناشر تفاوتشان را نمیبیند.
- در
notifyروی یک کپی از فهرست حلقه میزنیم. اگر ناظری در میانه اطلاعرسانی خودش را لغو اشتراک کند، بدون این کپی برنامه دچار خطا میشود. این یک باگ کلاسیک و سختیاب است. add_newsتغییر حالت و اطلاعرسانی را با هم انجام میدهد. جدا کردنشان یعنی روزی یکی فراموش میشود.
دکمههای رابط گرافیکی در فصلهای سی و چهارم تا سی و ششم دقیقاً
همیناند: command=my_function یعنی «وقتی کلیک شدم به
این خبر بده». سیگنال و اسلات در Qt هم همین است. الگوی ناظر در هر
رابط کاربری و هر سیستم رویدادمحوری زندگی میکند.
۱۰. هشدار: الگوی بیجا
این مهمترین بخش فصل است. الگویی که جای لازم به کار نرود، کد را بدتر میکند. تازهکارها بعد از یاد گرفتن الگوها معمولاً همه جا از آنها استفاده میکنند و برنامه ساده را به هزارتو تبدیل میکنند.
قاعده درست: با سادهترین چیزی که کار میکند شروع کنید؛ وقتی مسئلهای که یک الگو حل میکند واقعاً پیدا شد، آن وقت سراغش بروید.
| الگو | کی سراغش نروید |
|---|---|
| تکنمونه | وقتی یک ماژول ساده کافی است. تکنمونه حالت سراسری میسازد و آزمودن کد را سخت میکند |
| کارخانه | وقتی فقط یک یا دو کلاس دارید و انتخاب ثابت است |
| سازنده | وقتی شیء دو یا سه پارامتر دارد. آرگومان کلیدواژهای پایتون کافی است |
| مبدل | وقتی میتوانید خود کلاس را اصلاح کنید |
| دکوراتور | وقتی رفتار اضافه فقط در یک تابع لازم است |
| استراتژی | وقتی فقط دو حالت دارید و هرگز زیاد نمیشوند |
| ناظر | وقتی فقط یک شنونده دارید. صدا زدن مستقیم خواناتر است |
پوشه S33-design-patterns شامل هشت فایل
01_singleton.py تا 08_observer.py. هر
فایل مستقل است و بدون نصب چیزی اجرا میشود. توصیه میشود هر فایل
را اجرا کنید و بعد سعی کنید یک کلاس تازه به آن اضافه کنید: تجربه
کردن اینکه «هیچ کد موجودی دست نخورد» کل درس این فصل است.
خلاصه فصل
| الگو | هسته پیادهسازی |
|---|---|
| تکنمونه | بازنویسی __new__ و نگه داشتن _instance، یا فقط یک ماژول |
| کارخانه | دیکشنری از نام به کلاس، بهعلاوه یک تابع سازنده |
| سازنده | یک متد برای هر جزء، همه با return self، و یک build() |
| مبدل | کلاسی که شیء قدیمی را نگه میدارد و نام متدها را ترجمه میکند |
| دکوراتور | تابعی که تابع میگیرد و تابع پیچیده برمیگرداند، با @wraps |
| استراتژی | یک ABC و چند پیادهسازی، که به مشتری تزریق میشوند |
| ناظر | فهرست مشترکان، متدهای subscribe و notify |
| قاعده طلایی | ساده شروع کنید. الگو را وقتی مسئلهاش ظاهر شد به کار ببرید |
تمرینهای فصل سی و سه
- یک تکنمونه
Settingsبنویسید که فایل پیکربندی را فقط یک بار بخواند. - یک
ShapeFactoryبنویسید که از روی نام، شیءCircleیاSquareیاTriangleبسازد. - کلاس
PastaوPastaBuilderرا طبق الگوی سازنده بنویسید. - کلاس قدیمی
OldDatabaseبا متدrun_query()را در نظر بگیرید و مبدلی بنویسید که به جایشexecute()ارائه دهد. - دکوراتور
@log_to_fileرا بنویسید که هر فراخوانی را درcalls.logبنویسد. - سه استراتژی مرتبسازی (بر اساس نام، نمره و سن) و یک کلاس
Rankingبنویسید که از یکیشان استفاده کند. - یک ناظر
EmailAlertبه08_observer.pyاضافه کنید و بررسی کنید که کلاس ناشر اصلاً تغییر نکرده باشد. - یکی از برنامههای قبلی خودتان را که زنجیره بلند
if/elifدارد، با الگوی استراتژی بازنویسی کنید. - برای هر الگوی این فصل، یک جمله بنویسید که بگوید کی نباید به وظیفه آن برد.