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

Factory Method и Simple Factory

Накратко: Factory събира на едно място решението кой клас да се създаде: Simple Factory държи избора на едно място, а Factory Method оставя подкласа да реши кой обект да направи.

Проблем

Три места знаят всеки вид доставка

Поръчката казва как стига до клиента: "address" (до адреса), "office" (в офис на куриера) или "locker" (в автомат). За всеки вид има клас със своя цена и свой етикет:

class AddressDelivery:
price = 5.90
def label(self, recipient):
return f"{recipient}, до адреса"
class OfficeDelivery:
price = 3.90
def label(self, recipient):
return f"{recipient}, в офис на куриера"
class LockerDelivery:
price = 2.90
def label(self, recipient):
return f"{recipient}, в автомат"

Три части от магазина имат нужда от доставката: касата, прегледът на цената и принтерът за етикети. Всяка сама решава кой клас да създаде:

def checkout(kind, items):
if kind == "address":
delivery = AddressDelivery()
elif kind == "office":
delivery = OfficeDelivery()
elif kind == "locker":
delivery = LockerDelivery()
else:
raise ValueError(kind)
return items + delivery.price
def preview_price(kind):
if kind == "address":
delivery = AddressDelivery()
elif kind == "office":
delivery = OfficeDelivery()
elif kind == "locker":
delivery = LockerDelivery()
else:
raise ValueError(kind)
return delivery.price
def print_label(kind, recipient):
if kind == "office":
delivery = OfficeDelivery()
else:
delivery = AddressDelivery()
return delivery.label(recipient)
print(f"Поръчка: {checkout("locker", 40):.2f} €")
print(f"Преглед: доставка {preview_price("locker"):.2f} €")
print("Етикет: " + print_label("locker", "Мария"))
Изход
Поръчка: 42.90 €
Преглед: доставка 2.90 €
Етикет: Мария, до адреса

Автоматите дойдоха миналия месец. Някой добави "locker" в касата и в прегледа. Принтерът за етикети е писан преди това и има else за адрес. Мария плати доставка до автомат, а куриерът тръгна към адреса ѝ.

Нов вид е три промени, и една се забравя. Изборът „кой клас за кой вид“ е копиран, а копията не знаят едно за друго.

Идеята

Simple Factory: new живее на едно място

Изборът „кой клас за кой вид“ е едно решение, затова е на едно място: фабрика. Тя получава вида и връща нов обект. Викащият не вика класа и не знае класа: получава обект, който има price и label.

Ролите:

  • продуктът: общата форма, price и label(recipient), които има всеки клас;
  • конкретните продукти: AddressDelivery, OfficeDelivery, LockerDelivery;
  • фабриката: create_delivery(kind), единственото място, което знае кой клас за кой вид;
  • клиентите: касата, прегледът, етикетът. Те само питат.

Това се казва Simple Factory (проста фабрика). Тя не е от книгата на „бандата на четиримата“, но е най-честата форма. Factory Method от книгата е малко друго нещо и идва по-нататък в урока.

Едно правило: непознат вид е грешка, а не тихо подразбиране. Фабрика, която не познава вида, хвърля изключение с ясно съобщение. Иначе грешката не изчезва, а тръгва с куриера към грешния адрес.

В Python фабриката е таблица вид → клас. Класът сам прави обекти: AddressDelivery() е нов обект, затова таблицата пази самите класове, а DELIVERIES[kind]() вика този, който трябва.

Таблицата „име → стратегия“ от урока Strategy е същото нещо: фабрика, която по текст дава обект.

Виж го

Една фабрика за трите места

DELIVERIES = {
"address": AddressDelivery,
"office": OfficeDelivery,
"locker": LockerDelivery,
}
def create_delivery(kind):
if kind not in DELIVERIES:
raise ValueError(f"Непознат вид доставка: {kind}")
return DELIVERIES[kind]()

Касата, прегледът и етикетът вече не избират, а питат:

def checkout(kind, items):
return items + create_delivery(kind).price
def preview_price(kind):
return create_delivery(kind).price
def print_label(kind, recipient):
return create_delivery(kind).label(recipient)
print(f"Поръчка: {checkout("locker", 40):.2f} €")
print(f"Преглед: доставка {preview_price("locker"):.2f} €")
print("Етикет: " + print_label("locker", "Мария"))
Изход
Поръчка: 42.90 €
Преглед: доставка 2.90 €
Етикет: Мария, в автомат

Етикетът е за автомат. Изборът е на едно място, затова не може да е верен в касата и грешен в етикета.

По-точно, за любопитните

Защо не DELIVERIES.get(kind)()? Ако вида го няма, get връща None, а None() дава TypeError: 'NoneType' object is not callable. Грешката не казва кой вид е непознат. Проверката if kind not in DELIVERIES с ясно съобщение струва два реда.

Виж го

Нов вид: един клас и един ред

Магазинът добавя вземане от магазина: вид "pickup", без такса. Новото е един клас:

class PickupDelivery:
price = 0.00
def label(self, recipient):
return f"{recipient}, от магазина"

и един ред във фабриката, маркиран тук:

DELIVERIES = {
"address": AddressDelivery,
"office": OfficeDelivery,
"locker": LockerDelivery,
"pickup": PickupDelivery,
}
def create_delivery(kind):
if kind not in DELIVERIES:
raise ValueError(f"Непознат вид доставка: {kind}")
return DELIVERIES[kind]()
home = create_delivery("address")
print(f"{home.label('Мария')}: {home.price:.2f} €")
locker = create_delivery("locker")
print(f"{locker.label('Мария')}: {locker.price:.2f} €")
pickup = create_delivery("pickup")
print(f"{pickup.label('Мария')}: {pickup.price:.2f} €")
Изход
Мария, до адреса: 5.90 €
Мария, в автомат: 2.90 €
Мария, от магазина: 0.00 €

home = create_delivery("address")
print(f"{home.label('Мария')}: {home.price:.2f} €")
locker = create_delivery("locker")
print(f"{locker.label('Мария')}: {locker.price:.2f} €")
pickup = create_delivery("pickup")
print(f"{pickup.label('Мария')}: {pickup.price:.2f} €")
Стъпка по стъпка, с думи
  1. create_delivery("address") получава само текста от поръчката. Фабриката търси калъпа за него.
  2. От калъпа излиза нов AddressDelivery. Викащият не пита кой клас е: ползва само price и label().
  3. Същата фабрика, друг текст: "locker" минава покрай другите калъпи и спира на LockerDelivery.
  4. Етикетът вече е за автомат: класът се избира на едно място, а касата, прегледът и етикетът питат все него.
  5. Нов вид: класът PickupDelivery и един ред повече във фабриката. Редовете, които викат create_delivery, не се пипат.
  6. "pickup" минава като всеки друг вид. Новият вид е добавен на едно място.

Касата, прегледът и етикетът не се пипат. Те разбират за новия вид, когато поръчка го поиска.

Виж го

Непознат вид

Видът идва отвън: от формуляр, от сайт на партньор, от стар файл. Рано или късно идва вид, който го няма:

try:
delivery = create_delivery("drone")
print(delivery.label("Мария"))
except ValueError as e:
print(e)
Изход
Непознат вид доставка: drone

Грешката казва кой вид е непознат. Хваща се там, където поръчката влиза в магазина, и клиентът вижда съобщение, вместо пратка на грешното място.

А без проверката? DELIVERIES[kind] дава KeyError с вида, но съобщението е за програмиста, а не за поръчката:
def create_delivery(kind):
return DELIVERIES[kind]()
Грешка
KeyError: 'drone'

Виж го

Factory Method: подкласът решава

Simple Factory избира по текст. Понякога изборът не е по текст, а по това кой обект пита.

Отчетът за продажбите се праща като CSV (за счетоводството) и като HTML таблица (в имейла). Двата правят едно и също: всеки продукт е ред, а накрая има ред „Общо“. Различен е само файлът, в който се пишат редовете:

class CsvFile:
def __init__(self):
self.lines = ["продукт;сума"]
def add(self, product, total):
self.lines.append(f"{product};{total:.2f}")
def text(self):
return "\n".join(self.lines)
class HtmlFile:
def __init__(self):
self.rows = []
def add(self, product, total):
self.rows.append(f"<tr><td>{product}</td><td>{total:.2f}</td></tr>")
def text(self):
return "<table>\n" + "\n".join(self.rows) + "\n</table>"
Общият алгоритъм е в базовия клас. Кой файл да направи, казва полето на класа file_class: класът е стойност, а подкласът слага друг. Така го правят и рамки като Django.
class ReportExporter:
file_class = CsvFile
def export(self, sales):
file = self.file_class()
overall = 0
for product, total in sales:
file.add(product, total)
overall += total
file.add("Общо", overall)
return file.text()
class HtmlExporter(ReportExporter):
file_class = HtmlFile
sales = [("Кафе", 12.50), ("Чай", 8.00)]
print(ReportExporter().export(sales))
print(HtmlExporter().export(sales))
Изход
продукт;сума
Кафе;12.50
Чай;8.00
Общо;20.50
<table>
<tr><td>Кафе</td><td>12.50</td></tr>
<tr><td>Чай</td><td>8.00</td></tr>
<tr><td>Общо</td><td>20.50</td></tr>
</table>

Това е Factory Method: базовият клас вика метод, който създава обекта (self.file_class()), а кой клас точно, решава подкласът.

Защо не се подава готов файл? Файлът събира редове. Всяко export трябва да започне с празен файл, затова отчетът не пази файл, а знае как да направи нов.

Кога Simple Factory стига? Factory Method си заслужава, когато около създаването има общ алгоритъм, който подкласовете не трябва да повтарят (тук: редовете и общата сума). Ако искаш само обект по вид, без алгоритъм около него, Simple Factory е по-ясна. А подклас, който съществува само за да върне нов обект, е церемония. Тогава подай самия клас: export(sales, HtmlFile).

Пробвай

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

Видовете идват от формуляр на сайта. Какво печата програмата?

for kind in ["office", "Locker", "locker"]:
try:
print(create_delivery(kind).label("Мария"))
except ValueError as e:
print(e)
Отговор

Пробвай

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

Друг програмист е написал фабриката така:

DELIVERIES = {
"office": OfficeDelivery,
"locker": LockerDelivery,
}
def create_delivery(kind):
return DELIVERIES.get(kind, AddressDelivery)()

Поръчката идва от сайт на партньор, който праща вида с главна буква:

delivery = create_delivery("Locker")
print("Етикет: " + delivery.label("Мария"))
print(f"Доставка: {delivery.price:.2f} €")
Изход
Етикет: Мария, до адреса
Доставка: 5.90 €

Мария избра автомат. Плати доставка 5.90 € вместо 2.90 €, а пратката тръгна към адреса ѝ. На кой ред е грешката?

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

Идеята

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

Фабриката е още един скок при четене: за да видиш кой клас се създава, отиваш другаде. Не си струва, когато:

  • на това място винаги трябва един и същ клас. OfficeDelivery() там, където доставката винаги е до офис, е по-ясно от фабрика;
  • видът е известен, докато пишеш кода, а не идва като данни (от поръчка, файл, формуляр);
  • пишеш клас DeliveryFactory само със статичен метод. Таблица и една функция стигат;
  • Factory Method има един подклас „за бъдещето“.

Струва си, когато видът идва като данни, а изборът на класа иначе би се копирал на няколко места. Или когато около създаването има общ алгоритъм, а различна е само стъпката „кой обект“.

Упражнение

Експресна доставка

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

Експресна доставка. Магазинът добавя експресна доставка до адреса: вид "express", 9.90 €, етикет „…, експресно до адреса“. Добави клас ExpressDelivery и го направи достъпен през фабриката. Касата, прегледът и етикетът не се пипат. Създай експресна доставка за Мария и я отпечатай като в картата „Нов вид“:

Изход
Мария, експресно до адреса: 9.90 €

Още задачи

  • Главни букви и интервали. Формулярът праща "Locker" и " locker ". Направи фабриката да ги приема, като промениш само нея. Непознатите видове пак трябва да дават грешка.
  • JSON отчет. Добави към отчета от картата „Factory Method“ формат JSON: [{"product": "Кафе", "total": 12.50}, …]. Кои класове пишеш и кои не пипаш?
  • Цена според теглото. Автоматите приемат пратки до 20 кг, а над 5 кг доставката до адрес струва повече. Къде отива теглото: във фабриката или в метода за цена? Помисли защо.

Тест

Провери се

1. Магазинът добавя доставка с дрон, вид "drone". Какво пишеш при Simple Factory?
2. Фабриката получава вид, който не познава. Какво трябва да направи?
3. Кога Factory Method е по-добър от Simple Factory?
4. За да пести памет, таблицата пази по един готов обект за всеки вид, а фабриката връща него. Обектът пази получателя в поле. Какво става с две поръчки за автомат една след друга?
5. Кога фабрика не ти трябва?

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