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

Builder

Накратко: Builder сглобява сложен обект стъпка по стъпка, с име за всяко поле, проверява го в build() и връща готов неизменим обект.

Проблем

Девет параметъра и две разменени думи

Магазинът праща имейл, когато поръчката тръгне. Писмото има девет полета: до кого, на кой адрес да се отговаря, тема, текст, копие, скрито копие, прикачен файл, дали е важно и дали се следи отварянето.

class Email {
private final String to;
private final String replyTo;
private final String subject;
private final String body;
private final String cc;
private final String bcc;
private final String attachment;
private final boolean highPriority;
private final boolean trackOpens;
public Email(String to, String replyTo, String subject, String body, String cc,
String bcc, String attachment, boolean highPriority, boolean trackOpens) {
this.to = to;
this.replyTo = replyTo;
this.subject = subject;
this.body = body;
this.cc = cc;
this.bcc = bcc;
this.attachment = attachment;
this.highPriority = highPriority;
this.trackOpens = trackOpens;
}
public void send() {
System.out.println("До: " + to);
if (replyTo != null) {
System.out.println("Отговор до: " + replyTo);
}
System.out.println("Тема: " + subject);
if (attachment != null) {
System.out.println("Прикачено: " + attachment);
}
if (highPriority) {
System.out.println("Важно");
}
}
}

Ето как се праща потвърждението за поръчка 1042:

Email email = new Email("support@shop.example", "maria@example.com", "Поръчка 1042 е изпратена",
"Пратката е на път.", null, null, "invoice-1042.pdf", true, false);
email.send();
Изход
До: support@shop.example
Отговор до: maria@example.com
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
Важно

Писмото отиде до поддръжката на магазина, а Мария не получи нищо. Първите два аргумента са разменени. И двата са текст, затова компилаторът няма как да разбере. А кое е true и кое false в края? За да разбереш, броиш позиции в конструктора. Два null пазят местата на полета, които това писмо няма.

Всяко ново поле, например езикът на писмото, сменя конструктора и всяко място, което го вика.

Идеята

Builder: по име, на стъпки, с проверка

Обектът не се прави с едно извикване на девет позиции, а на стъпки, всяка с име: .to(…), .subject(…). Стъпките ги приема отделен обект, builder. Той събира стойностите, а накрая build():

  • проверява задължителните полета (тук: получател, тема и текст) и хвърля изключение, ако някое липсва;
  • прави неизменим обект: след това писмото не може да се промени.

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

  • продуктът: Email, неизменим, с частен конструктор, така че друг начин да го направиш няма;
  • builder-ът: Email.Builder, с метод за всяко поле. Методът връща самия builder, затова извикванията се нижат едно след друго във верига;
  • build(): единственото място, където продуктът се проверява и се прави.

Виж го

Веригата и build()

class Email {
private final String to;
private final String replyTo;
private final String subject;
private final String body;
private final String cc;
private final String bcc;
private final String attachment;
private final boolean highPriority;
private final boolean trackOpens;
private Email(Builder b) {
to = b.to;
replyTo = b.replyTo;
subject = b.subject;
body = b.body;
cc = b.cc;
bcc = b.bcc;
attachment = b.attachment;
highPriority = b.highPriority;
trackOpens = b.trackOpens;
}
public boolean highPriority() {
return highPriority;
}
public void send() {
System.out.println("До: " + to);
if (replyTo != null) {
System.out.println("Отговор до: " + replyTo);
}
System.out.println("Тема: " + subject);
if (attachment != null) {
System.out.println("Прикачено: " + attachment);
}
if (highPriority) {
System.out.println("Важно");
}
}
static class Builder {
private String to;
private String replyTo;
private String subject;
private String body;
private String cc;
private String bcc;
private String attachment;
private boolean highPriority;
private boolean trackOpens;
public Builder to(String value) { to = value; return this; }
public Builder replyTo(String value) { replyTo = value; return this; }
public Builder subject(String value) { subject = value; return this; }
public Builder body(String value) { body = value; return this; }
public Builder cc(String value) { cc = value; return this; }
public Builder bcc(String value) { bcc = value; return this; }
public Builder attachment(String value) { attachment = value; return this; }
public Builder highPriority() { highPriority = true; return this; }
public Builder trackOpens() { trackOpens = true; return this; }
public Email build() {
if (to == null) {
throw new IllegalStateException("Липсва получател");
}
if (subject == null) {
throw new IllegalStateException("Липсва тема");
}
if (body == null) {
throw new IllegalStateException("Липсва текст");
}
return new Email(this);
}
}
}

Builder-ът струва код: всяко поле е написано три пъти: в писмото, в builder-а и в неговия метод. Затова не се прави за всеки клас.

Email.Builder е вложен клас: той вижда частния конструктор на Email, а никой друг не го вижда. Полетата на Email са final.
Email email = new Email.Builder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
Email.Builder draft = new Email.Builder()
.to("peter@example.com")
.body("Пратката е на път.");
try {
draft.build();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
Изход
До: maria@example.com
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
Важно
Липсва тема

Email email = new Email.Builder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.")
.attachment("invoice-1042.pdf")
.highPriority()
.build();
email.send();
Email.Builder draft = new Email.Builder()
.to("peter@example.com")
.body("Пратката е на път.");
try {
draft.build();
} catch (IllegalStateException e) {
System.out.println(e.getMessage());
}
Стъпка по стъпка, с думи
  1. new Email.Builder(): празен чертеж с девет гнезда. Трите задължителни (to, subject, body) са отбелязани.
  2. .to(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
  3. Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
  4. .body(…): и трите задължителни гнезда са пълни.
  5. Незадължителните полета се пишат само ако трябват. highPriority() е без аргумент: вместо true на осма позиция.
  6. build() проверява задължителните гнезда и щампова Email. Полетата му са final: обектът вече не се променя.
  7. Писмото тръгва към Мария, с фактурата, като важно.
  8. Нов builder, нов празен чертеж. Тук някой е забравил темата.
  9. draft.build() стига до празното гнездо subject и спира с изключение. Писмо без тема изобщо не се появява.
  10. Грешката казва какво липсва. Нищо не е изпратено.

Писмото без тема не стига до пощата: build() спира още при сглобяването.

Виж го

Без Builder: какво има езикът

Java няма аргументи по име. record пише полетата, конструктора и методите за четене, и обектът е неизменим, но девет параметъра пак са девет позиции. Затова честите случаи получават фабричен метод с име (като в урока Factory), а проверката отива в компактния конструктор:
record Email(String to, String replyTo, String subject, String body, String cc,
String bcc, String attachment, boolean highPriority, boolean trackOpens) {
Email {
if (to == null || subject == null || body == null) {
throw new IllegalArgumentException("Липсва получател, тема или текст");
}
}
static Email orderShipped(String to, int orderId, String invoice) {
return new Email(to, "support@shop.example", "Поръчка " + orderId + " е изпратена",
"Пратката е на път.", null, null, invoice, false, false);
}
}
Email email = Email.orderShipped("maria@example.com", 1042, "invoice-1042.pdf");
System.out.println(email.to() + " / " + email.subject() + " / " + email.attachment());
Изход
maria@example.com / Поръчка 1042 е изпратена / invoice-1042.pdf
За всичко останало в Java се пише Builder. Самата стандартна библиотека го прави: HttpRequest.newBuilder().uri(…).header(…).build(). Библиотеката Lombok пише Builder вместо теб с анотацията @Builder.

Кога Builder още си струва:

  • писмото се сглобява на стъпки, в различни части на кода: прикаченият файл се добавя само ако има фактура, копието само за фирми;
  • има проверка между полетата: например скритото копие не може да е адресът на получателя;
  • полетата са много, повечето са незадължителни, а езикът няма аргументи по име.

Пробвай

Предвиди изхода

Един builder, две извиквания на build(). Какво печата програмата?

Email.Builder builder = new Email.Builder()
.to("maria@example.com")
.subject("Поръчка 1042 е изпратена")
.body("Пратката е на път.");
Email first = builder.build();
builder.highPriority();
Email second = builder.build();
System.out.println(first.highPriority() + " " + second.highPriority());
Отговор

Пробвай

Намери грешката

Магазинът праща писмата за изпратените поръчки в цикъл. Фактура има само първата поръчка:

List<Order> orders = List.of(
new Order("maria@example.com", 1042, "invoice-1042.pdf"),
new Order("peter@example.com", 1043, null));
Email.Builder builder = new Email.Builder().replyTo("support@shop.example");
for (Order order : orders) {
builder.to(order.email())
.subject("Поръчка " + order.id() + " е изпратена")
.body("Пратката е на път.");
if (order.invoice() != null) {
builder.attachment(order.invoice());
}
builder.build().send();
}
Изход
До: maria@example.com
Отговор до: support@shop.example
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
До: peter@example.com
Отговор до: support@shop.example
Тема: Поръчка 1043 е изпратена
Прикачено: invoice-1042.pdf

Петър получи фактурата на Мария. На кой ред е грешката?

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

Идеята

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

Builder удвоява кода на класа. Не си струва, когато:

  • полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
  • record с фабричен метод за честите случаи стига;
  • обектът няма какво да проверява и не се сглобява на стъпки.

Струва си, когато полетата са много и повечето са незадължителни, когато има проверка между тях, или когато обектът се сглобява на стъпки, в различни части на кода.

Упражнение

Пратка за куриер

Реши задачата в редактора си.

Builder за пратка. Куриерът иска пратка с получател, телефон, адрес и тегло (задължителни), а по желание: чупливо и наложен платеж. Напиши Shipment и builder за нея. build() проверява, че задължителните полета са зададени и че теглото е над 0 и до 30 кг. Направи пратка за Мария (2.5 кг, чупливо, наложен платеж 42.90 €), отпечатай я и опитай пратка от 45 кг:

Изход
Пратка за Мария, 0888 123 456
Адрес: ул. Шипка 5, Пловдив
Тегло: 2.5 кг, чупливо
Наложен платеж: 42.90 €
Теглото трябва да е над 0 и до 30 кг

Още задачи

  • Поправи цикъла. Поправи програмата от „Намери грешката“ така, че всяко писмо да носи само своята фактура. Адресът за отговор трябва да остане за всички писма.
  • Копие с една промяна. Добави метод, който от готово писмо прави builder със същите стойности (в Java често се казва toBuilder()). Изпрати същото писмо до друг получател.
  • Начинът на езика. Напиши Shipment като в картата „Без Builder“ и сравни двата варианта. Кой е по-кратък и какво губиш?

Тест

Провери се

1. new Email("support@…", "maria@…", …) се компилира, а писмото отива другаде. Защо компилаторът не помогна?
2. Какво прави build()?
3. Builder-ът е създаден преди цикъла и се ползва за всяко писмо. Второто писмо получи прикачения файл на първото. Защо?
4. Кога Builder още си струва?
5. Защо в Java Builder се среща по-често, отколкото в C# и Python?

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