Template Method и Iterator
Накратко: Template Method пази общия скелет на алгоритъм в базов клас, а подкласовете попълват стъпките; Iterator дава елементите един по един, без да показва как се пазят.
Проблем
Два отчета с един и същ скелет
Счетоводството иска поръчките в CSV, а сайтът иска същите в HTML таблица. Отказаните поръчки не влизат в отчета. Някой написа CSV, а после копира метода за HTML:
static String exportCsv(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("поръчка,сума"); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(order.id() + "," + String.format("%.2f", order.total())); sum += order.total(); } lines.add("общо," + String.format("%.2f", sum)); return String.join("\n", lines);}
static String exportHtml(List<Order> orders) { List<String> lines = new ArrayList<>(); lines.add("<table>"); lines.add("<tr><th>Поръчка</th><th>Сума</th></tr>"); double sum = 0; for (Order order : orders) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); sum += order.total(); } lines.add("<tr><td>Общо</td><td>" + String.format("%.2f", sum) + "</td></tr>"); lines.add("</table>"); return String.join("\n", lines);}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(exportCsv(orders));System.out.println(exportHtml(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пишат стъпките и не викат нищо сами.
Виж го
Скелетът и двата отчета
final: подкласът не може да го смени. Стъпките са protected и abstract, освен двете куки с празно тяло:abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Двата отчета пишат само редовете си. CsvReport не пише куките, затова ползва празните от базовия клас:
class CsvReport extends Report { @Override protected void header(List<String> lines) { lines.add("поръчка,сума"); }
@Override protected void row(List<String> lines, Order order) { lines.add(order.id() + "," + String.format("%.2f", order.total())); }
@Override protected void footer(List<String> lines, double sum) { lines.add("общо," + String.format("%.2f", sum)); }}
class HtmlReport extends Report { @Override protected void open(List<String> lines) { lines.add("<table>"); }
@Override protected void header(List<String> lines) { lines.add("<tr><th>Поръчка</th><th>Сума</th></tr>"); }
@Override protected void row(List<String> lines, Order order) { lines.add("<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>"); }
@Override protected void footer(List<String> lines, double sum) { lines.add("<tr><td>Общо</td><td>" + String.format("%.2f", sum) + "</td></tr>"); }
@Override protected void close(List<String> lines) { lines.add("</table>"); }}List<Order> orders = List.of( new Order(1001, 80.00, false), new Order(1002, 25.50, false), new Order(1003, 40.00, true));System.out.println(new CsvReport().export(orders));System.out.println(new 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>abstract class Report { public final String export(List<Order> orders) { List<String> lines = new ArrayList<>(); open(lines); header(lines); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } row(lines, order); sum += order.total(); } footer(lines, sum); close(lines); return String.join("\n", lines); }
protected void open(List<String> lines) { }
protected abstract void header(List<String> lines);
protected abstract void row(List<String> lines, Order order);
protected abstract void footer(List<String> lines, double sum);
protected void close(List<String> lines) { }}Стъпка по стъпка, с думи
- 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 € и в двата отчета: правилото за отказаните е в скелета, на едно място.
Виж го
Скелетът не се пипа
Подклас, който се опита да напише свой export, не се компилира:
class QuickCsvReport extends CsvReport { @Override public String export(List<Order> orders) { return "поръчка,сума"; }}error: export(List<Order>) in QuickCsvReport cannot override export(List<Order>) in Report public String export(List<Order> orders) { ^ overridden method is finalfinal. Без него подкласът тихо сменя скелета и правилото за отказаните изчезва само за този отчет.Виж го
Без наследяване: стъпките като функции
Стъпките не е задължително да са методи на подклас. Скелетът може да ги приеме като параметри: заглавието е текст, а редът и сборът са функции.
static String export(List<Order> orders, String header, Function<Order, String> row, Function<Double, String> footer) { List<String> lines = new ArrayList<>(); lines.add(header); double sum = 0; for (Order order : orders) { if (order.cancelled()) { continue; } lines.add(row.apply(order)); sum += order.total(); } lines.add(footer.apply(sum)); return String.join("\n", lines);}System.out.println(export(orders, "поръчка,сума", order -> order.id() + "," + String.format("%.2f", order.total()), sum -> "общо," + String.format("%.2f", sum)));System.out.println(export(orders, "<table>\n<tr><th>Поръчка</th><th>Сума</th></tr>", order -> "<tr><td>" + order.id() + "</td><td>" + String.format("%.2f", order.total()) + "</td></tr>", sum -> "<tr><td>Общо</td><td>" + String.format("%.2f", sum) + "</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>Скелетът пак е на едно място, а отказаните пак не влизат.
Това е Strategy за всяка стъпка:Function<Order, String> вместо абстрактен метод. Стига за две-три кратки стъпки. Подкласът си заслужава, когато стъпките са много, имат общи полета или някои са по желание, както куките open и close.Проблем
Всеки, който иска поръчките, върти страниците
Поръчките са в система, която ги дава на страници: по две на заявка, защото заявката е бавна. Тук източникът е списък в паметта, а всяко зареждане печата ред, за да виждаш кога става:
class OrderSource { private final List<Order> orders;
OrderSource(List<Order> orders) { this.orders = orders; }
List<Order> fetchPage(int number) { System.out.println("зареждам страница " + number); int from = Math.min((number - 1) * 2, orders.size()); int to = Math.min(from + 2, orders.size()); return orders.subList(from, to); }}Отчетът, имейлът за клиентите и експортът искат всичките поръчки, и всеки върти страниците сам:
int number = 1;List<Order> page = source.fetchPage(number);while (!page.isEmpty()) { for (Order order : page) { System.out.println(order.id()); } number++; page = source.fetchPage(number);}зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4Кодът, който иска само „всяка поръчка поред“, знае, че има страници, как се броят и че краят е празна страница. Същите редове са в три файла. Когато системата мине от номер на страница към „ключ за следващата страница“, сменяш и трите.
Идеята
Iterator: обхождането зад курсор
Извикващият иска едно: следващата поръчка, докато има. Как се стига до нея (списък, страници, файл, мрежа) е работа на някой друг.
Този друг е курсорът: обект, който помни докъде е стигнал и на въпроса „има ли още?“ отговаря сам, като зарежда следваща страница, само когато предишната свърши. Извикващият пише обикновен for-each и не знае за страниците.
Това е шаблонът Iterator (итератор). Ролите са две:
- колекцията: това, което се обхожда. Тя дава курсор;
- курсорът: помни мястото и дава следващия елемент.
Този шаблон е вграден във всеки от четирите езика: for-each работи с всеки обект, който дава курсор. В Java колекцията е Iterable, а курсорът Iterator с hasNext() и next().
Виж го
Курсорът, който зарежда страниците
yield, затова курсорът се пише на ръка. hasNext зарежда следващата страница, само когато текущата е изчерпана:class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}Iterable е колекцията: всеки for-each иска от нея нов курсор.
class AllOrders implements Iterable<Order> { private final OrderSource source;
AllOrders(OrderSource source) { this.source = source; }
@Override public Iterator<Order> iterator() { return new PageIterator(source); }}Извикващият не знае за страниците:
for (Order order : new AllOrders(source)) { System.out.println(order.id());}зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4class PageIterator implements Iterator<Order> { private final OrderSource source; private List<Order> page = List.of(); private int number = 0; private int index = 0; private boolean done = false;
PageIterator(OrderSource source) { this.source = source; }
@Override public boolean hasNext() { if (index == page.size() && !done) { number++; page = source.fetchPage(number); index = 0; done = page.isEmpty(); } return index < page.size(); }
@Override public Order next() { if (!hasNext()) { throw new NoSuchElementException(); } return page.get(index++); }}
for (Order order : new AllOrders(source)) { System.out.println(order.id());}Стъпка по стъпка, с думи
- Цикълът иска първата поръчка. Още нищо не е заредено.
- Курсорът (PageIterator) още няма страница, затова зарежда страница 1.
- 1001: първата поръчка от страницата отива в цикъла, а той я печата.
- 1002: от същата страница. Нищо не се зарежда.
- Страницата свърши. Едва сега курсорът зарежда страница 2.
- 1003 и 1004 от страница 2.
- Пак границата: страница 3.
- 1005: на страница 3 има само една поръчка.
- Страница 4 е празна: поръчки няма повече.
- Курсорът казва „няма повече“, и цикълът свършва. Цикълът така и не видя страница.
Изходът е същият, а броенето на страници е на едно място. Когато системата смени начина, по който дава страниците, сменяш курсора, а трите места, които обхождат, не се пипат.
Виж го
Какво прави for-each отвътре
for-each е кратък запис на този цикъл с курсора:
Iterator<Order> it = new AllOrders(source).iterator();while (it.hasNext()) { Order order = it.next(); System.out.println(order.id());}зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4for (Order order : orders) вика orders.iterator() веднъж, а после hasNext() и next() на всяка стъпка. Затова работи и със списък, и с AllOrders, и с всичко, което е Iterable.Пробвай
Предвиди изхода
Цикълът спира след третата поръчка. Какво печата програмата?
int shown = 0;for (Order order : new AllOrders(source)) { System.out.println(order.id()); shown++; if (shown == 3) { break; }}Пробвай
Намери грешката
Имейлът до клиентите първо казва колко са поръчките, а после ги изрежда:
AllOrders orders = new AllOrders(new OrderSource(all));int count = 0;for (Order order : orders) { count++;}System.out.println("Поръчки: " + count);for (Order order : orders) { System.out.println(order.id());}зареждам страница 1зареждам страница 2зареждам страница 3зареждам страница 4Поръчки: 5зареждам страница 110011002зареждам страница 210031004зареждам страница 31005зареждам страница 4Избери реда с грешката.
AllOrders е Iterable: всеки for-each взима нов курсор и тръгва отначало, със зареждане на всички страници. Ако поръчките ти трябват два пъти, събери ги в списък веднъж: List<Order> orders = new ArrayList<>(); new AllOrders(...).forEach(orders::add);. Тогава страниците се зареждат само веднъж.Идеята
Кога не ти трябват
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 €, пак без извикващият да знае за страниците.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Ламбди · record, switch и шаблони · Шаблони за дизайн · Strategy
- Задачи по темата: Шаблони за дизайн