Builder
Накратко: Builder сглобява сложен обект стъпка по стъпка, с име за всяко поле, и го проверява в build(); в Python същото често решават аргументите по име и dataclass.
Проблем
Девет параметъра и две разменени думи
Магазинът праща имейл, когато поръчката тръгне. Писмото има девет полета: до кого, на кой адрес да се отговаря, тема, текст, копие, скрито копие, прикачен файл, дали е важно и дали се следи отварянето.
class Email: def __init__(self, to, reply_to, subject, body, cc, bcc, attachment, high_priority, track_opens): self.to = to self.reply_to = reply_to self.subject = subject self.body = body self.cc = cc self.bcc = bcc self.attachment = attachment self.high_priority = high_priority self.track_opens = track_opens
def send(self): print(f"До: {self.to}") if self.reply_to: print(f"Отговор до: {self.reply_to}") print(f"Тема: {self.subject}") if self.attachment: print(f"Прикачено: {self.attachment}") if self.high_priority: print("Важно")Ето как се праща потвърждението за поръчка 1042:
email = Email("support@shop.example", "maria@example.com", "Поръчка 1042 е изпратена", "Пратката е на път.", None, None, "invoice-1042.pdf", True, False)email.send()До: support@shop.exampleОтговор до: maria@example.comТема: Поръчка 1042 е изпратенаПрикачено: invoice-1042.pdfВажноПисмото отиде до поддръжката на магазина, а Мария не получи нищо. Първите два аргумента са разменени. И двата са текст, затова езикът няма как да разбере. А кое е true и кое false в края? За да разбереш, броиш позиции в конструктора. Два None пазят местата на полета, които това писмо няма.
reply_to="…"), но нищо не те кара да ги ползваш: позиционното извикване минава без грешка.Всяко ново поле, например езикът на писмото, сменя конструктора и всяко място, което го вика.
Идеята
Builder: по име, на стъпки, с проверка
Обектът не се прави с едно извикване на девет позиции, а на стъпки, всяка с име: .to(…), .subject(…). Стъпките ги приема отделен обект, builder. Той събира стойностите, а накрая build():
- проверява задължителните полета (тук: получател, тема и текст) и хвърля изключение, ако някое липсва;
- прави неизменим обект: след това писмото не може да се промени.
Това е шаблонът Builder (строител). Ролите:
- продуктът:
Email, неизменим; - builder-ът:
EmailBuilder, с метод за всяко поле. Методът връща самия builder, затова извикванията се нижат едно след друго във верига; build(): единственото място, където продуктът се проверява и се прави.
Виж го
Веригата и build()
@dataclass(frozen=True, kw_only=True)class Email: to: str reply_to: str | None = None subject: str body: str cc: str | None = None bcc: str | None = None attachment: str | None = None high_priority: bool = False track_opens: bool = False
def send(self): print(f"До: {self.to}") if self.reply_to: print(f"Отговор до: {self.reply_to}") print(f"Тема: {self.subject}") if self.attachment: print(f"Прикачено: {self.attachment}") if self.high_priority: print("Важно")
class EmailBuilder: def __init__(self): self.fields = {}
def to(self, value): self.fields["to"] = value return self
def reply_to(self, value): self.fields["reply_to"] = value return self
def subject(self, value): self.fields["subject"] = value return self
def body(self, value): self.fields["body"] = value return self
def cc(self, value): self.fields["cc"] = value return self
def bcc(self, value): self.fields["bcc"] = value return self
def attachment(self, value): self.fields["attachment"] = value return self
def high_priority(self): self.fields["high_priority"] = True return self
def track_opens(self): self.fields["track_opens"] = True return self
def build(self): if "to" not in self.fields: raise ValueError("Липсва получател") if "subject" not in self.fields: raise ValueError("Липсва тема") if "body" not in self.fields: raise ValueError("Липсва текст") return Email(**self.fields)Builder-ът струва код: всяко поле е написано и в писмото, и в метода на builder-а. Затова не се прави за всеки клас.
@dataclass(frozen=True, kw_only=True) пише __init__ вместо теб: frozen=True забранява промяната след създаването, а kw_only=True приема полетата само по име. **self.fields подава речника като аргументи по име.email = ( EmailBuilder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път.") .attachment("invoice-1042.pdf") .high_priority() .build())email.send()
draft = ( EmailBuilder() .to("peter@example.com") .body("Пратката е на път."))try: draft.build()except ValueError as e: print(e)До: maria@example.comТема: Поръчка 1042 е изпратенаПрикачено: invoice-1042.pdfВажноЛипсва темаemail = ( EmailBuilder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път.") .attachment("invoice-1042.pdf") .high_priority() .build())email.send()
draft = ( EmailBuilder() .to("peter@example.com") .body("Пратката е на път."))try: draft.build()except ValueError as e: print(e)Стъпка по стъпка, с думи
- EmailBuilder(): празен чертеж с девет гнезда. Трите задължителни (to, subject, body) са отбелязани.
- .to(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
- Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
- .body(…): и трите задължителни гнезда са пълни.
- Незадължителните полета се пишат само ако трябват. high_priority() е без аргумент: вместо true на осма позиция.
- build() проверява задължителните гнезда и щампова Email. Полетата му са frozen=True: обектът вече не се променя.
- Писмото тръгва към Мария, с фактурата, като важно.
- Нов builder, нов празен чертеж. Тук някой е забравил темата.
- draft.build() стига до празното гнездо subject и спира с изключение. Писмо без тема изобщо не се появява.
- Грешката казва какво липсва. Нищо не е изпратено.
Писмото без тема не стига до пощата: build() спира още при сглобяването.
Виж го
Без Builder: какво има езикът
kw_only=True позволява само извикване по име. Проверка, която dataclass не прави сам, отива в __post_init__:@dataclass(frozen=True, kw_only=True)class Email: to: str reply_to: str | None = None subject: str body: str cc: str | None = None bcc: str | None = None attachment: str | None = None high_priority: bool = False track_opens: bool = False
def __post_init__(self): if "@" not in self.to: raise ValueError(f"Грешен адрес: {self.to}")email = Email( to="maria@example.com", subject="Поръчка 1042 е изпратена", body="Пратката е на път.", attachment="invoice-1042.pdf", high_priority=True,)print(email.to, "/", email.subject, "/", email.high_priority)maria@example.com / Поръчка 1042 е изпратена / Trueemail = Email("support@shop.example", "maria@example.com", "Поръчка 1042 е изпратена")TypeError: Email.__init__() takes 1 positional argument but 4 were givenКога Builder още си струва:
- писмото се сглобява на стъпки, в различни части на кода: прикаченият файл се добавя само ако има фактура, копието само за фирми;
- има проверка между полетата: например скритото копие не може да е адресът на получателя;
- полетата са много, повечето са незадължителни, а сглобяването трябва да се подаде от едно място на друго, преди да е готово.
Пробвай
Предвиди изхода
Един builder, две извиквания на build(). Какво печата програмата?
builder = ( EmailBuilder() .to("maria@example.com") .subject("Поръчка 1042 е изпратена") .body("Пратката е на път."))first = builder.build()builder.high_priority()second = builder.build()print(first.high_priority, second.high_priority)Пробвай
Намери грешката
Магазинът праща писмата за изпратените поръчки в цикъл. Фактура има само първата поръчка:
orders = [ ("maria@example.com", 1042, "invoice-1042.pdf"), ("peter@example.com", 1043, None),]builder = EmailBuilder().reply_to("support@shop.example")for email, order_id, invoice in orders: builder.to(email).subject(f"Поръчка {order_id} е изпратена") builder.body("Пратката е на път.") if invoice: builder.attachment(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 удвоява кода на класа. Не си струва, когато:
- полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
- аргументите по име и dataclass вършат същото без втори клас;
- обектът няма какво да проверява и не се сглобява на стъпки.
Струва си, когато полетата са много и повечето са незадължителни, когато има проверка между тях, или когато обектът се сглобява на стъпки, в различни части на кода.
Упражнение
Пратка за куриер
Реши задачата в редактора си.
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“ и сравни двата варианта. Кой е по-кратък и какво губиш?
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Вложени класове и данни · dataclass, match и шаблони · Шаблони за дизайн · Factory Method и Simple Factory
- Продължава в: Магазинът: кой шаблон къде
- Задачи по темата: Шаблони за дизайн