Template Method и Iterator
Накратко: Template Method пази общия скелет на алгоритъм в базов клас, а подкласовете попълват стъпките; Iterator дава елементите един по един, без да показва как се пазят.
Проблем
Два отчета с един и същ скелет
Счетоводството иска поръчките в CSV, а сайтът иска същите в HTML таблица. Отказаните поръчки не влизат в отчета. Някой написа CSV, а после копира функцията за HTML:
def export_csv(orders): lines = ["поръчка,сума"] total = 0 for order in orders: if order.cancelled: continue lines.append(f"{order.id},{order.total:.2f}") total += order.total lines.append(f"общо,{total:.2f}") return "\n".join(lines)
def export_html(orders): lines = ["<table>", "<tr><th>Поръчка</th><th>Сума</th></tr>"] total = 0 for order in orders: lines.append(f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>") total += order.total lines.append(f"<tr><td>Общо</td><td>{total:.2f}</td></tr>") lines.append("</table>") return "\n".join(lines)orders = [ Order(1001, 80.00), Order(1002, 25.50), Order(1003, 40.00, cancelled=True),]print(export_csv(orders))print(export_html(orders))поръчка,сума1001,80.001002,25.50общо,105.50<table><tr><th>Поръчка</th><th>Сума</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>1003</td><td>40.00</td></tr><tr><td>Общо</td><td>145.50</td></tr></table>Двата отчета показват различна сума: CSV е 105.50 €, а HTML е 145.50 €. Правилото „отказаните не влизат“ го има само в първото копие. Двете копия правят едно и също: отварят, пишат заглавие, обхождат поръчките, пишат сбора и затварят. Различни са само редовете, които пишат. Третият формат ще е трето копие, и правилото пак трябва да се помни на още едно място.
Идеята
Template Method: скелетът е един, стъпките са различни
Каквото не се сменя, е редът на стъпките и правилата между тях: отваряне, заглавие, по ред за всяка поръчка без отказаните, сбор, затваряне. Каквото се сменя, е как изглежда всяка стъпка.
Скелетът отива в базовия клас Report, в метода export. На всяко място, където форматите се различават, той вика стъпка: header, row, footer. Подкласът пише само стъпките. Стъпка, която не е задължителна (open и close, които CSV не ползва), има празно тяло в базовия клас и се казва кука (hook).
Това е шаблонът Template Method (шаблонен метод). Ролите са две:
- базовият клас със скелета: той решава реда и вика стъпките;
- подкласовете:
CsvReportиHtmlReportпишат стъпките и не викат нищо сами.
Виж го
Скелетът и двата отчета
Report наследява ABC, а задължителните стъпки са с @abstractmethod. Тялото им е ...: тук няма какво да се прави, подкласът го пише. Двете куки имат тяло pass:class Report(ABC): def export(self, orders): lines = [] self.open(lines) self.header(lines) total = 0 for order in orders: if order.cancelled: continue self.row(lines, order) total += order.total self.footer(lines, total) self.close(lines) return "\n".join(lines)
def open(self, lines): pass
@abstractmethod def header(self, lines): ...
@abstractmethod def row(self, lines, order): ...
@abstractmethod def footer(self, lines, total): ...
def close(self, lines): passДвата отчета пишат само редовете си. CsvReport не пише куките, затова ползва празните от базовия клас:
class CsvReport(Report): def header(self, lines): lines.append("поръчка,сума")
def row(self, lines, order): lines.append(f"{order.id},{order.total:.2f}")
def footer(self, lines, total): lines.append(f"общо,{total:.2f}")
class HtmlReport(Report): def open(self, lines): lines.append("<table>")
def header(self, lines): lines.append("<tr><th>Поръчка</th><th>Сума</th></tr>")
def row(self, lines, order): lines.append(f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>")
def footer(self, lines, total): lines.append(f"<tr><td>Общо</td><td>{total:.2f}</td></tr>")
def close(self, lines): lines.append("</table>")orders = [ Order(1001, 80.00), Order(1002, 25.50), Order(1003, 40.00, cancelled=True),]print(CsvReport().export(orders))print(HtmlReport().export(orders))поръчка,сума1001,80.001002,25.50общо,105.50<table><tr><th>Поръчка</th><th>Сума</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Общо</td><td>105.50</td></tr></table>class Report(ABC): def export(self, orders): lines = [] self.open(lines) self.header(lines) total = 0 for order in orders: if order.cancelled: continue self.row(lines, order) total += order.total self.footer(lines, total) self.close(lines) return "\n".join(lines)
def open(self, lines): pass
@abstractmethod def header(self, lines): ...
@abstractmethod def row(self, lines, order): ...
@abstractmethod def footer(self, lines, total): ...
def close(self, lines): passСтъпка по стъпка, с думи
- new CsvReport().export(orders): скелетът е в Report. Той решава реда на стъпките.
- open: CsvReport не пише тази стъпка, затова върви празното тяло от Report. Не се добавя нищо.
- header: скелетът вика стъпката, а CsvReport пише своя ред.
- row за поръчка 1001: скелетът вика стъпката, а CsvReport пише своя ред.
- row за поръчка 1002: скелетът вика стъпката, а CsvReport пише своя ред.
- Поръчка 1003 е отказана: самият скелет я прескача с continue. Нито един отчет не трябва да помни това правило.
- footer: скелетът вика стъпката, а CsvReport пише своя ред.
- close: CsvReport не пише тази стъпка, затова върви празното тяло от Report. Не се добавя нищо.
- Сега HtmlReport: същият скелет, други стъпки.
- open: скелетът вика стъпката, а HtmlReport пише своя ред.
- header: скелетът вика стъпката, а HtmlReport пише своя ред.
- row за поръчка 1001: скелетът вика стъпката, а HtmlReport пише своя ред.
- row за поръчка 1002: скелетът вика стъпката, а HtmlReport пише своя ред.
- Поръчка 1003 е отказана: самият скелет я прескача с continue. Нито един отчет не трябва да помни това правило.
- footer: скелетът вика стъпката, а HtmlReport пише своя ред.
- close: скелетът вика стъпката, а HtmlReport пише своя ред.
- Два отчета, един скелет. И двата сбора са 105.50 €, защото правилото за отказаните е на едно място.
Сборът е 105.50 € и в двата отчета: правилото за отказаните е в скелета, на едно място.
Виж го
Скелетът не се пипа
В Python final няма. Подклас може да напише свой export, и нищо не го спира (@typing.final е само подсказка за програми като mypy, не за Python). Пази ли го, е въпрос на уговорка. Липсваща стъпка обаче Python хваща, още когато създаваш обекта:
class TextReport(Report): def header(self, lines): lines.append("поръчка сума")
def row(self, lines, order): lines.append(f"{order.id} {order.total:.2f}")
TextReport()TypeError: Can't instantiate abstract class TextReport without an implementation for abstract method 'footer'Виж го
Без наследяване: стъпките като функции
Стъпките не е задължително да са методи на подклас. Скелетът може да ги приеме като параметри: заглавието е текст, а редът и сборът са функции.
def export(orders, header, row, footer): lines = [header] total = 0 for order in orders: if order.cancelled: continue lines.append(row(order)) total += order.total lines.append(footer(total)) return "\n".join(lines)print(export( orders, "поръчка,сума", lambda order: f"{order.id},{order.total:.2f}", lambda total: f"общо,{total:.2f}",))print(export( orders, "<table>\n<tr><th>Поръчка</th><th>Сума</th></tr>", lambda order: f"<tr><td>{order.id}</td><td>{order.total:.2f}</td></tr>", lambda total: f"<tr><td>Общо</td><td>{total:.2f}</td></tr>\n</table>",))поръчка,сума1001,80.001002,25.50общо,105.50<table><tr><th>Поръчка</th><th>Сума</th></tr><tr><td>1001</td><td>80.00</td></tr><tr><td>1002</td><td>25.50</td></tr><tr><td>Общо</td><td>105.50</td></tr></table>Скелетът пак е на едно място, а отказаните пак не влизат.
В Python това често е по-естественият начин: функцията е стойност, и наследяване не трябва. Подкласът си заслужава, когато стъпките са много или делят общи данни (например текущия номер на реда), които стоят по-добре в полета на един обект.Проблем
Всеки, който иска поръчките, върти страниците
Поръчките са в система, която ги дава на страници: по две на заявка, защото заявката е бавна. Тук източникът е списък в паметта, а всяко зареждане печата ред, за да виждаш кога става:
class OrderSource: def __init__(self, orders): self.orders = orders
def fetch_page(self, number): print(f"зареждам страница {number}") start = (number - 1) * 2 return self.orders[start:start + 2]Отчетът, имейлът за клиентите и експортът искат всичките поръчки, и всеки върти страниците сам:
number = 1page = source.fetch_page(number)while page: for order in page: print(order.id) number += 1 page = source.fetch_page(number)зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4Кодът, който иска само „всяка поръчка поред“, знае, че има страници, как се броят и че краят е празна страница. Същите редове са в три файла. Когато системата мине от номер на страница към „ключ за следващата страница“, сменяш и трите.
Идеята
Iterator: обхождането зад курсор
Извикващият иска едно: следващата поръчка, докато има. Как се стига до нея (списък, страници, файл, мрежа) е работа на някой друг.
Този друг е курсорът: обект, който помни докъде е стигнал и на въпроса „има ли още?“ отговаря сам, като зарежда следваща страница, само когато предишната свърши. Извикващият пише обикновен for и не знае за страниците.
Това е шаблонът Iterator (итератор). Ролите са две:
- колекцията: това, което се обхожда. Тя дава курсор;
- курсорът: помни мястото и дава следващия елемент.
Този шаблон е вграден във всеки от четирите езика: for работи с всеки обект, който дава курсор. В Python курсорът е обект с __next__, а най-лесно се прави с генератор: функция с yield.
Виж го
Курсорът, който зарежда страниците
yield е генератор: при извикване не прави нищо, а връща курсор. Всеки път, когато for поиска следваща поръчка, функцията върви до следващото yield, дава поръчката и спира точно там. Затова следващата страница се зарежда, едва когато текущата свърши:def all_orders(source): number = 1 while True: page = source.fetch_page(number) if not page: return for order in page: yield order number += 1Извикващият не знае за страниците:
for order in all_orders(source): print(order.id)зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4def all_orders(source): number = 1 while True: page = source.fetch_page(number) if not page: return for order in page: yield order number += 1
for order in all_orders(source): print(order.id)Стъпка по стъпка, с думи
- Цикълът иска първата поръчка. Още нищо не е заредено.
- Генераторът още няма страница, затова зарежда страница 1.
- 1001: първата поръчка от страницата отива в цикъла, а той я печата.
- 1002: от същата страница. Нищо не се зарежда.
- Страницата свърши. Едва сега курсорът зарежда страница 2.
- 1003 и 1004 от страница 2.
- Пак границата: страница 3.
- 1005: на страница 3 има само една поръчка.
- Страница 4 е празна: поръчки няма повече.
- Курсорът казва „няма повече“, и цикълът свършва. Цикълът така и не видя страница.
Изходът е същият, а броенето на страници е на едно място. Когато системата смени начина, по който дава страниците, сменяш генератора, а трите места, които обхождат, не се пипат.
Виж го
Какво прави for-each отвътре
for е кратък запис на този цикъл с курсора:
it = iter(all_orders(source))while True: try: order = next(it) except StopIteration: break print(order.id)зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4for вика iter() веднъж, а после next() на всяка стъпка, докато курсорът не хвърли StopIteration. Генераторът е свой курсор: iter() връща самия него.Ако искаш нещо, което се обхожда наново всеки път, както списъкът, направи клас, който при всяко обхождане дава нов генератор:
class AllOrders: def __init__(self, source): self.source = source
def __iter__(self): return all_orders(self.source)Сега for order in AllOrders(source) тръгва отначало при всяко обхождане, точно както Iterable в Java.
Пробвай
Предвиди изхода
Цикълът спира след третата поръчка. Какво печата програмата?
shown = 0for order in all_orders(source): print(order.id) shown += 1 if shown == 3: breakПробвай
Намери грешката
Имейлът до клиентите първо казва колко са поръчките, а после ги изрежда:
orders = all_orders(OrderSource(all))count = 0for order in orders: count += 1print(f"Поръчки: {count}")for order in orders: print(order.id)зареждам страница 1зареждам страница 2зареждам страница 3зареждам страница 4Поръчки: 5Избери реда с грешката.
for го изчерпва до края, и вторият няма какво да вземе: не печата нищо и не дава грешка. Ако поръчките ти трябват два пъти, събери ги в списък веднъж: orders = list(all_orders(...)). Или ползвай класа AllOrders, който дава нов генератор при всяко обхождане, ако е добре страниците да се заредят наново.Идеята
Кога не ти трябват
Template Method има цена: базов клас и наследяване, а наследяването е връзка, която трудно се развързва. Не си струва, когато:
- форматът е един. Един функция без стъпки е по-ясен;
- стъпките са една-две. Подай ги като функции, както в картата „Без наследяване“;
- подкласовете искат да сменят и реда на стъпките. Тогава скелетът не е общ, и насилването му ражда стъпки с
ifвътре.
Свой Iterator не ти трябва, когато данните вече са в списък: той вече се обхожда. Пишеш свой, когато отзад има нещо, което не е колекция в паметта: страници, файл, който се чете ред по ред, дърво, или безкрайна поредица (номерата на фактурите).
Упражнение
Отчет в Markdown
Реши задачата в редактора си.
Markdown. Сайтът с документацията иска отчета като Markdown таблица. Напиши MarkdownReport, който пише само стъпките. Заглавието е два реда. Report и другите отчети не се пипат. С поръчките от урока:
| Поръчка | Сума ||---|---|| 1001 | 80.00 || 1002 | 25.50 || **Общо** | 105.50 |Още задачи
- Номер на реда. HTML отчетът номерира редовете (
<td>1</td>,<td>2</td>), а отказаните не броят. Къде пазиш брояча: в скелета или вHtmlReport? - Първите N. Напиши генератор, който дава само първите N поръчки от друг курсор. Колко страници се зареждат за първите три?
- Само големите. Обходи поръчките от източника и дай само тези над 30 €, пак без извикващият да знае за страниците.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · dataclass, match и шаблони · Шаблони за дизайн · Strategy
- Задачи по темата: Шаблони за дизайн