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

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 пазят местата на полета, които това писмо няма.

Python има аргументи по име (reply_to="…"), но нищо не те кара да ги ползваш: позиционното извикване минава без грешка.

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

Идеята

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

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

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

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

  • продуктът: Email, неизменим;
  • builder-ът: EmailBuilder, с метод за всяко поле. Методът връща самия builder, затова извикванията се нижат едно след друго във верига;
  • build(): единственото място, където продуктът се проверява и се прави.
В Python проблемът от първата карта има и по-кратък отговор: аргументите по име. Той е в картата „Без Builder“. Builder-ът тук е за случая, когато писмото се сглобява на стъпки, на различни места в кода.

Виж го

Веригата и 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)
Стъпка по стъпка, с думи
  1. EmailBuilder(): празен чертеж с девет гнезда. Трите задължителни (to, subject, body) са отбелязани.
  2. .to(…) пълни едно гнездо и връща същия builder, затова веригата продължава.
  3. Всяко извикване има име: стойността не може да отиде в чуждо гнездо, както ставаше с позициите в конструктора.
  4. .body(…): и трите задължителни гнезда са пълни.
  5. Незадължителните полета се пишат само ако трябват. high_priority() е без аргумент: вместо true на осма позиция.
  6. build() проверява задължителните гнезда и щампова Email. Полетата му са frozen=True: обектът вече не се променя.
  7. Писмото тръгва към Мария, с фактурата, като важно.
  8. Нов builder, нов празен чертеж. Тук някой е забравил темата.
  9. draft.build() стига до празното гнездо subject и спира с изключение. Писмо без тема изобщо не се появява.
  10. Грешката казва какво липсва. Нищо не е изпратено.

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

Виж го

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

Python има това в самия език. Полетата със стойност по подразбиране са незадължителни, а 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 е изпратена / True
Позиционното извикване от първата карта вече не минава:
email = 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 удвоява кода на класа. Не си струва, когато:

  • полетата са две или три и всички са задължителни: обикновеният конструктор е по-ясен;
  • аргументите по име и 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“ и сравни двата варианта. Кой е по-кратък и какво губиш?

Тест

Провери се

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

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