Към съдържанието
wannacode

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.00
1002,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.00
1002,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) {
}
}
Стъпка по стъпка, с думи
  1. new CsvReport().export(orders): скелетът е в Report. Той решава реда на стъпките.
  2. open: CsvReport не пише тази стъпка, затова върви празното тяло от Report. Не се добавя нищо.
  3. header: скелетът вика стъпката, а CsvReport пише своя ред.
  4. row за поръчка 1001: скелетът вика стъпката, а CsvReport пише своя ред.
  5. row за поръчка 1002: скелетът вика стъпката, а CsvReport пише своя ред.
  6. Поръчка 1003 е отказана: самият скелет я прескача с continue. Нито един отчет не трябва да помни това правило.
  7. footer: скелетът вика стъпката, а CsvReport пише своя ред.
  8. close: CsvReport не пише тази стъпка, затова върви празното тяло от Report. Не се добавя нищо.
  9. Сега HtmlReport: същият скелет, други стъпки.
  10. open: скелетът вика стъпката, а HtmlReport пише своя ред.
  11. header: скелетът вика стъпката, а HtmlReport пише своя ред.
  12. row за поръчка 1001: скелетът вика стъпката, а HtmlReport пише своя ред.
  13. row за поръчка 1002: скелетът вика стъпката, а HtmlReport пише своя ред.
  14. Поръчка 1003 е отказана: самият скелет я прескача с continue. Нито един отчет не трябва да помни това правило.
  15. footer: скелетът вика стъпката, а HtmlReport пише своя ред.
  16. close: скелетът вика стъпката, а HtmlReport пише своя ред.
  17. Два отчета, един скелет. И двата сбора са 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 final
Това прави final. Без него подкласът тихо сменя скелета и правилото за отказаните изчезва само за този отчет.

Виж го

Без наследяване: стъпките като функции

Стъпките не е задължително да са методи на подклас. Скелетът може да ги приеме като параметри: заглавието е текст, а редът и сборът са функции.

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.00
1002,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);
}
Изход
зареждам страница 1
1001
1002
зареждам страница 2
1003
1004
зареждам страница 3
1005
зареждам страница 4

Кодът, който иска само „всяка поръчка поред“, знае, че има страници, как се броят и че краят е празна страница. Същите редове са в три файла. Когато системата мине от номер на страница към „ключ за следващата страница“, сменяш и трите.

Идеята

Iterator: обхождането зад курсор

Извикващият иска едно: следващата поръчка, докато има. Как се стига до нея (списък, страници, файл, мрежа) е работа на някой друг.

Този друг е курсорът: обект, който помни докъде е стигнал и на въпроса „има ли още?“ отговаря сам, като зарежда следваща страница, само когато предишната свърши. Извикващият пише обикновен for-each и не знае за страниците.

Това е шаблонът Iterator (итератор). Ролите са две:

  • колекцията: това, което се обхожда. Тя дава курсор;
  • курсорът: помни мястото и дава следващия елемент.

Този шаблон е вграден във всеки от четирите езика: for-each работи с всеки обект, който дава курсор. В Java колекцията е Iterable, а курсорът Iterator с hasNext() и next().

Виж го

Курсорът, който зарежда страниците

Java няма 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());
}
Изход
зареждам страница 1
1001
1002
зареждам страница 2
1003
1004
зареждам страница 3
1005
зареждам страница 4

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++);
}
}
for (Order order : new AllOrders(source)) {
System.out.println(order.id());
}
Стъпка по стъпка, с думи
  1. Цикълът иска първата поръчка. Още нищо не е заредено.
  2. Курсорът (PageIterator) още няма страница, затова зарежда страница 1.
  3. 1001: първата поръчка от страницата отива в цикъла, а той я печата.
  4. 1002: от същата страница. Нищо не се зарежда.
  5. Страницата свърши. Едва сега курсорът зарежда страница 2.
  6. 1003 и 1004 от страница 2.
  7. Пак границата: страница 3.
  8. 1005: на страница 3 има само една поръчка.
  9. Страница 4 е празна: поръчки няма повече.
  10. Курсорът казва „няма повече“, и цикълът свършва. Цикълът така и не видя страница.

Изходът е същият, а броенето на страници е на едно място. Когато системата смени начина, по който дава страниците, сменяш курсора, а трите места, които обхождат, не се пипат.

Виж го

Какво прави for-each отвътре

for-each е кратък запис на този цикъл с курсора:

Iterator<Order> it = new AllOrders(source).iterator();
while (it.hasNext()) {
Order order = it.next();
System.out.println(order.id());
}
Изход
зареждам страница 1
1001
1002
зареждам страница 2
1003
1004
зареждам страница 3
1005
зареждам страница 4
for (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
зареждам страница 1
1001
1002
зареждам страница 2
1003
1004
зареждам страница 3
1005
зареждам страница 4
Номерата излизат, но всяка страница е заредена два пъти, а източникът е бавен. Кой ред трябва да смениш?

Избери реда с грешката.

Идеята

Кога не ти трябват

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 €, пак без извикващият да знае за страниците.

Тест

Провери се

1. Магазинът иска отчет и в JSON. Какво пишеш при Template Method?
2. Защо стъпката open има празно тяло в базовия клас, а header е абстрактна?
3. Цикъл обхожда поръчките от бавния източник и спира с break на втората. Колко страници са заредени?
4. Какво вика for-each, за да обходи нещо?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·