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

Builder

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

Проблем

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

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

class Email
{
public string To { get; }
public string ReplyTo { get; }
public string Subject { get; }
public string Body { get; }
public string Cc { get; }
public string Bcc { get; }
public string Attachment { get; }
public bool HighPriority { get; }
public bool TrackOpens { get; }
public Email(string to, string replyTo, string subject, string body, string cc,
string bcc, string attachment, bool highPriority, bool trackOpens)
{
To = to;
ReplyTo = replyTo;
Subject = subject;
Body = body;
Cc = cc;
Bcc = bcc;
Attachment = attachment;
HighPriority = highPriority;
TrackOpens = trackOpens;
}
public void Send()
{
Console.WriteLine($"До: {To}");
if (ReplyTo != null)
{
Console.WriteLine($"Отговор до: {ReplyTo}");
}
Console.WriteLine($"Тема: {Subject}");
if (Attachment != null)
{
Console.WriteLine($"Прикачено: {Attachment}");
}
if (HighPriority)
{
Console.WriteLine("Важно");
}
}
}

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

var 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
{
public string To { get; private init; }
public string ReplyTo { get; private init; }
public string Subject { get; private init; }
public string Body { get; private init; }
public string Cc { get; private init; }
public string Bcc { get; private init; }
public string Attachment { get; private init; }
public bool HighPriority { get; private init; }
public bool TrackOpens { get; private init; }
private Email()
{
}
public void Send()
{
Console.WriteLine($"До: {To}");
if (ReplyTo != null)
{
Console.WriteLine($"Отговор до: {ReplyTo}");
}
Console.WriteLine($"Тема: {Subject}");
if (Attachment != null)
{
Console.WriteLine($"Прикачено: {Attachment}");
}
if (HighPriority)
{
Console.WriteLine("Важно");
}
}
public class Builder
{
private string to;
private string replyTo;
private string subject;
private string body;
private string cc;
private string bcc;
private string attachment;
private bool highPriority;
private bool 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 InvalidOperationException("Липсва получател");
}
if (subject == null)
{
throw new InvalidOperationException("Липсва тема");
}
if (body == null)
{
throw new InvalidOperationException("Липсва текст");
}
return new Email
{
To = to,
ReplyTo = replyTo,
Subject = subject,
Body = body,
Cc = cc,
Bcc = bcc,
Attachment = attachment,
HighPriority = highPriority,
TrackOpens = trackOpens,
};
}
}
}

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

Email.Builder е вложен клас: той може да ползва частните init на свойствата и частния конструктор, а никой друг не може.
var email = new Email.Builder()
.To("maria@example.com")
.Subject("Поръчка 1042 е изпратена")
.Body("Пратката е на път.")
.Attachment("invoice-1042.pdf")
.HighPriority()
.Build();
email.Send();
var draft = new Email.Builder()
.To("peter@example.com")
.Body("Пратката е на път.");
try
{
draft.Build();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Изход
До: maria@example.com
Тема: Поръчка 1042 е изпратена
Прикачено: invoice-1042.pdf
Важно
Липсва тема

var email = new Email.Builder()
.To("maria@example.com")
.Subject("Поръчка 1042 е изпратена")
.Body("Пратката е на път.")
.Attachment("invoice-1042.pdf")
.HighPriority()
.Build();
email.Send();
var draft = new Email.Builder()
.To("peter@example.com")
.Body("Пратката е на път.");
try
{
draft.Build();
}
catch (InvalidOperationException e)
{
Console.WriteLine(e.Message);
}
Стъпка по стъпка, с думи
  1. new Email.Builder(): празен чертеж с девет гнезда. Трите задължителни (To, Subject, Body) са отбелязани.
  2. .To(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
  3. Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
  4. .Body(…): и трите задължителни гнезда са пълни.
  5. Незадължителните полета се пишат само ако трябват. HighPriority() е без аргумент: вместо true на осма позиция.
  6. Build() проверява задължителните гнезда и щампова Email. Полетата му са private init: обектът вече не се променя.
  7. Писмото тръгва към Мария, с фактурата, като важно.
  8. Нов builder, нов празен чертеж. Тук някой е забравил темата.
  9. draft.Build() стига до празното гнездо Subject и спира с изключение. Писмо без тема изобщо не се появява.
  10. Грешката казва какво липсва. Нищо не е изпратено.

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

Виж го

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

C# има това, което Builder прави, в самия език. init позволява свойството да се зададе само при създаването, required кара компилатора да провери задължителните, а инициализаторът на обект пише всяко по име:
class Email
{
public required string To { get; init; }
public string ReplyTo { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public string Cc { get; init; }
public string Bcc { get; init; }
public string Attachment { get; init; }
public bool HighPriority { get; init; }
public bool TrackOpens { get; init; }
}
var email = new Email
{
To = "maria@example.com",
Subject = "Поръчка 1042 е изпратена",
Body = "Пратката е на път.",
Attachment = "invoice-1042.pdf",
HighPriority = true,
};
Console.WriteLine($"{email.To} / {email.Subject} / {email.HighPriority}");
Изход
maria@example.com / Поръчка 1042 е изпратена / True
Забравената тема и промяната след създаването са грешки при компилиране:
var email = new Email { To = "maria@example.com", Body = "Пратката е на път." };
email.To = "peter@example.com";
Грешка
error CS9035: Required member 'Email.Subject' must be set in the object initializer or attribute constructor.
error CS8852: Init-only property or indexer 'Email.To' can only be assigned in an object initializer, or on 'this' or 'base' in an instance constructor or an 'init' accessor.
Ако класът има конструктор, C# го вика и с аргументи по име: new Email(to: "…", replyTo: "…"). Тогава размяната от първата карта също не минава незабелязано.

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

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

Пробвай

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

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

var builder = new Email.Builder()
.To("maria@example.com")
.Subject("Поръчка 1042 е изпратена")
.Body("Пратката е на път.");
Email first = builder.Build();
builder.HighPriority();
Email second = builder.Build();
Console.WriteLine($"{first.HighPriority} {second.HighPriority}");
Отговор

Пробвай

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

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

var orders = new List<Order>
{
new("maria@example.com", 1042, "invoice-1042.pdf"),
new("peter@example.com", 1043, null),
};
var builder = new Email.Builder().ReplyTo("support@shop.example");
foreach (var order in 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 удвоява кода на класа. Не си струва, когато:

  • полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
  • required и init с инициализатор на обект вършат същото без втори клас;
  • обектът няма какво да проверява и не се сглобява на стъпки.

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

Упражнение

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

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

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. Кое в C# прави почти същото като Builder, без втори клас?

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