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);}Стъпка по стъпка, с думи
- new Email.Builder(): празен чертеж с девет гнезда. Трите задължителни (To, Subject, Body) са отбелязани.
- .To(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
- Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
- .Body(…): и трите задължителни гнезда са пълни.
- Незадължителните полета се пишат само ако трябват. HighPriority() е без аргумент: вместо true на осма позиция.
- Build() проверява задължителните гнезда и щампова Email. Полетата му са private init: обектът вече не се променя.
- Писмото тръгва към Мария, с фактурата, като важно.
- Нов builder, нов празен чертеж. Тук някой е забравил темата.
- draft.Build() стига до празното гнездо Subject и спира с изключение. Писмо без тема изобщо не се появява.
- Грешката казва какво липсва. Нищо не е изпратено.
Писмото без тема не стига до пощата: Build() спира още при сглобяването.
Виж го
Без 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 е изпратена / Truevar 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.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-ът е направен веднъж, преди цикъла. Той помни всичко, което му е казано, и прикаченият файл от първото писмо остава за второто, защото никой не го е изчистил. Builder-ът е за едно сглобяване: направи нов в цикъла, при всяко писмо. Тук грешката изтича лични данни: чужда фактура с име и адрес.
Идеята
Кога не ти трябва
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“ и сравни двата варианта. Кой е по-кратък и какво губиш?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове · record, switch и шаблони · Шаблони за дизайн · Factory Method и Simple Factory
- Продължава в: Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн