Composite
Накратко: Composite дава на единичния елемент и на групата една и съща форма, затова дърво от продукти и кутии се обработва с едно извикване, на колкото и нива да е.
Проблем
Кутия в кутия в кутия
Магазинът продава подаръчни кутии. В коледната кутия има чай, мед и по-малка кутийка с бонбони и шоколад. Всяка кутия има и цена на опаковката. В поръчката има свещ и коледната кутия. Данните са такива:
from dataclasses import dataclass
@dataclassclass Product: name: str price: float
@dataclassclass 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 resultorder = [ 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):
@dataclassclass 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} €")Стъпка по стъпка, с думи
- Дървото се сглобява: всеки предмет е на свой ред, а всяка кутия държи децата си.
- total() пита корена. Поръчката не знае какво има вътре: започва от опаковката си и вика total() на всяко дете.
- Свещ е продукт: отговаря с цената си, 5.00, и отговорът се качва обратно.
- Коледна кутия е кутия: прави точно същото, първо опаковката, после дете по дете.
- Кутийка е на третото ниво. Тук нищо не изчезва: тя пита своите деца.
- Кутийка: 1.00 + 3.50 + 2.50 = 7.00. Сумата ѝ се качва при родителя.
- Коледна кутия: 2.00 + 4.50 + 6.00 + 7.00 = 19.50.
- Поръчка: 0.00 + 5.00 + 19.50 = 24.50. Това печата програмата.
Никъде няма проверка на типа и никъде няма брой нива. Кутията прави едно и също на всяко ниво: опаковката плюс сумата на децата.
Виж го
Нов вид предмет, без да пипаш кутията
Магазинът започва да продава кафе на зърна по 24 € за кило. Цената му е цена за кило по килограми. Това е нов клас със същата форма:
@dataclassclass WeighedProduct: name: str per_kg: float kg: float
def total(self): return self.per_kg * self.kgcoffee = 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 €. На кой ред е грешката?
Избери реда с грешката.
Проверката пуска само продуктите. Кутийката вътре не е продукт, затова тя, бонбоните и шоколадът (7 €) изчезват от сумата. Това е същата грешка като в проблема, само че скрита в кутията. Контейнерът в Composite не пита какво е детето: вика total() на всяко. Махни проверката и сумата е 19,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 и папка с файлове и папки. Отпечатай размера на всяка папка. Какво е общото с кутиите?
- Най-скъпият продукт. Намери цената на най-скъпия продукт в цялото дърво. Помисли какво връща празна кутия.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изключения · Списъци: append, remove и sort · dataclass, match и шаблони · Шаблони за дизайн
- Продължава в: Command
- Задачи по темата: Шаблони за дизайн