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

Composite

Накратко: Composite дава на единичния елемент и на групата една и съща форма, затова дърво от продукти и кутии се обработва с едно извикване, на колкото и нива да е.

Проблем

Кутия в кутия в кутия

Магазинът продава подаръчни кутии. В коледната кутия има чай, мед и по-малка кутийка с бонбони и шоколад. Всяка кутия има и цена на опаковката. В поръчката има свещ и коледната кутия. Данните са такива:

from dataclasses import dataclass
@dataclass
class Product:
name: str
price: float
@dataclass
class Box:
name: str
packaging: float
items: list

Сумата се смята с проверка какво е всеки предмет: продукт или кутия. За кутия има втори цикъл:

def total(order):
result = 0
for item in order:
if isinstance(item, Product):
result += item.price
elif isinstance(item, Box):
result += item.packaging
for inner in item.items:
if isinstance(inner, Product):
result += inner.price
return result
order = [
Product("Свещ", 5.00),
Box("Коледна кутия", 2.00, [
Product("Чай", 4.50),
Product("Мед", 6.00),
Box("Кутийка", 1.00, [
Product("Бонбони", 3.50),
Product("Шоколад", 2.50)])])]
print(f"Общо: {total(order):.2f} €")
Изход
Общо: 17.50 €

Поръчката струва 24,50 €, а програмата казва 17,50 €. Кутийката е на третото ниво. Вторият цикъл я вижда, но тя не е продукт, и тя изчезва от сумата заедно с бонбоните и шоколада. Грешка няма.

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

Идеята

Composite: частта и цялото с една форма

Продуктът и кутията се питат за едно и също: колко струваш? Продуктът отговаря с цената си. Кутията отговаря с опаковката плюс отговорите на всичко вътре.

Затова двете имат един и същ метод total(). Кутията пази списък от предмети и не пита какви са: вика total() на всеки. Интерфейс не трябва, стига всеки предмет да има този метод.

Това е шаблонът Composite (композиция). Има три роли:

  • общата форма: методът total();
  • листото: продуктът, който смята сам;
  • контейнерът: кутията, която пази деца със същата форма и пита всяко от тях.

Кутия в кутията е просто дете, което също пита децата си. Така извикванията слизат до продуктите, колкото и нива да има: това е рекурсия през обекти. Кодът, който пита за сумата, не знае дали държи продукт, кутия или цяло дърво.

Същата форма има и папката с файлове: размерът на файла е неговият, а размерът на папката е сборът на всичко в нея, включително на папките вътре.

Виж го

Сумата слиза до дъното и се връща

Продуктът и кутията. Продуктът е dataclass, а кутията приема произволен брой предмети (*items):

@dataclass
class Product:
name: str
price: float
def total(self):
return self.price
class Box:
def __init__(self, name, packaging, *items):
self.name = name
self.packaging = packaging
self.items = list(items)
def total(self):
return self.packaging + sum(item.total() for item in self.items)

Дървото се пише като вложени конструктори, по един предмет на ред:

order = Box("Поръчка", 0,
Product("Свещ", 5.00),
Box("Коледна кутия", 2.00,
Product("Чай", 4.50),
Product("Мед", 6.00),
Box("Кутийка", 1.00,
Product("Бонбони", 3.50),
Product("Шоколад", 2.50))))
print(f"Общо: {order.total():.2f} €")
Изход
Общо: 24.50 €

order = Box("Поръчка", 0,
Product("Свещ", 5.00),
Box("Коледна кутия", 2.00,
Product("Чай", 4.50),
Product("Мед", 6.00),
Box("Кутийка", 1.00,
Product("Бонбони", 3.50),
Product("Шоколад", 2.50))))
print(f"Общо: {order.total():.2f} €")
Стъпка по стъпка, с думи
  1. Дървото се сглобява: всеки предмет е на свой ред, а всяка кутия държи децата си.
  2. total() пита корена. Поръчката не знае какво има вътре: започва от опаковката си и вика total() на всяко дете.
  3. Свещ е продукт: отговаря с цената си, 5.00, и отговорът се качва обратно.
  4. Коледна кутия е кутия: прави точно същото, първо опаковката, после дете по дете.
  5. Кутийка е на третото ниво. Тук нищо не изчезва: тя пита своите деца.
  6. Кутийка: 1.00 + 3.50 + 2.50 = 7.00. Сумата ѝ се качва при родителя.
  7. Коледна кутия: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
  8. Поръчка: 0.00 + 5.00 + 19.50 = 24.50. Това печата програмата.

Никъде няма проверка на типа и никъде няма брой нива. Кутията прави едно и също на всяко ниво: опаковката плюс сумата на децата.

Виж го

Нов вид предмет, без да пипаш кутията

Магазинът започва да продава кафе на зърна по 24 € за кило. Цената му е цена за кило по килограми. Това е нов клас със същата форма:

@dataclass
class WeighedProduct:
name: str
per_kg: float
kg: float
def total(self):
return self.per_kg * self.kg
coffee = Box("Кутия с кафе", 1.00,
WeighedProduct("Кафе на зърна", 24.00, 0.25),
Product("Чаша", 8.00))
print(f"Кутия с кафе: {coffee.total():.2f} €")
Изход
Кутия с кафе: 15.00 €

В Box не е сменен нито ред. Кутията вика total() на кафето, както на всеки друг предмет.

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

box = Box("Кутия", 1.00, "Чай")
print(box.total())
Грешка
AttributeError: 'str' object has no attribute 'total'

Това все пак е по-добре от проблема, където такъв предмет тихо се пропускаше: тук грешката излиза веднага щом някой поиска сумата.

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

Нов вид предмет е лесен: един клас. Нова операция е по-скъпа: ако освен сумата трябва и тегло за куриера, методът weight() се добавя във всеки клас. Composite е силен там, където видовете предмети растат, а операциите са малко.

Ако искаш проверка на формата без наследяване, typing.Protocol описва „нещо с метод total()“, и инструменти като mypy проверяват кутията, преди програмата да тръгне. Самият Python не го проверява при изпълнение.

Пробвай

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

В кутията има празна кутия и свещ. Какво печата програмата?

box = Box("Кутия", 1.00,
Box("Празна", 0.50),
Product("Свещ", 5.00))
print(f"Кутия: {box.total():.2f} €")
Отговор

Пробвай

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

Някой преписва кутията, за да е „по-сигурна“:

class Box:
def __init__(self, name, packaging, *items):
self.name = name
self.packaging = packaging
self.items = list(items)
def total(self):
return self.packaging + sum(
item.total() for item in self.items if isinstance(item, Product)
)
gift = Box("Коледна кутия", 2.00,
Product("Чай", 4.50),
Product("Мед", 6.00),
Box("Кутийка", 1.00,
Product("Бонбони", 3.50),
Product("Шоколад", 2.50)))
print(f"Коледна кутия: {gift.total():.2f} €")
Изход
Коледна кутия: 12.50 €

Коледната кутия струва 19,50 €, а програмата казва 12,50 €. На кой ред е грешката?

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

Идеята

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

Composite има цена: обща форма и рекурсия, които трябва да прочетеш, за да разбереш една сума. Не си струва, когато:

  • нещата са в плосък списък. Количка без кутии е списък, а сумата е един цикъл;
  • нивата са точно две и такива остават: поръчка с редове. Ред в поръчката не съдържа поръчка;
  • операциите стават много, а видовете предмети са два. Всяка нова операция се пише във всеки клас. Тогава понякога е по-ясно една функция да обходи дървото с проверка на вида, но на едно място.

Струва си, когато части и цели се влагат едни в други на колкото нива дойде: кутии, папки, категории в каталог, менюта с подменюта, елементи на страница.

Упражнение

Касовата бележка

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

Бележката като дърво. Добави метод print(self, indent="") в продукта и в кутията. Продуктът печата името и цената си. Кутията печата името и общата си сума, а после вика print на всяко дете с два интервала повече. Отпечатай поръчката от картата „Сумата слиза до дъното и се връща“:

Изход
Поръчка: 24.50 €
Свещ: 5.00 €
Коледна кутия: 19.50 €
Чай: 4.50 €
Мед: 6.00 €
Кутийка: 7.00 €
Бонбони: 3.50 €
Шоколад: 2.50 €

Още задачи

  • Колко продукта. Добави count(): продуктът е 1, а кутията е сборът на децата си. Опаковката не е продукт. В поръчката са 5.
  • Папки и файлове. Направи файл с име и размер в KB и папка с файлове и папки. Отпечатай размера на всяка папка. Какво е общото с кутиите?
  • Най-скъпият продукт. Намери цената на най-скъпия продукт в цялото дърво. Помисли какво връща празна кутия.

Тест

Провери се

1. Магазинът започва да продава кафе на кило. Какво пишеш при Composite?
2. Колко нива може да има дървото, за да е сумата вярна?
3. Кутия сумира опаковката си и цените само на продуктите, които са директно в нея. Какво става с кутия в кутията?
4. Кога Composite не ти трябва?

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