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

Composite

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

Проблем

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

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

record Product(String name, double price) {}
record Box(String name, double packaging, List<Object> items) {}

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

static double total(List<Object> order) {
double sum = 0;
for (Object item : order) {
if (item instanceof Product p) {
sum += p.price();
} else if (item instanceof Box box) {
sum += box.packaging();
for (Object inner : box.items()) {
if (inner instanceof Product q) {
sum += q.price();
}
}
}
}
return sum;
}
List<Object> order = List.of(
new Product("Свещ", 5.00),
new Box("Коледна кутия", 2.00, List.of(
new Product("Чай", 4.50),
new Product("Мед", 6.00),
new Box("Кутийка", 1.00, List.of(
new Product("Бонбони", 3.50),
new Product("Шоколад", 2.50))))));
System.out.printf("Общо: %.2f €%n", total(order));
Изход
Общо: 17.50 €

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

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

Идеята

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

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

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

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

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

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

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

Виж го

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

Общата форма:

interface Item {
double total();
}

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

record Product(String name, double price) implements Item {
@Override
public double total() {
return price;
}
}
class Box implements Item {
private final String name;
private final double packaging;
private final List<Item> items;
public Box(String name, double packaging, Item... items) {
this.name = name;
this.packaging = packaging;
this.items = List.of(items);
}
@Override
public double total() {
double sum = packaging;
for (Item item : items) {
sum += item.total();
}
return sum;
}
}

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

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

Item order = new Box("Поръчка", 0,
new Product("Свещ", 5.00),
new Box("Коледна кутия", 2.00,
new Product("Чай", 4.50),
new Product("Мед", 6.00),
new Box("Кутийка", 1.00,
new Product("Бонбони", 3.50),
new Product("Шоколад", 2.50))));
System.out.printf("Общо: %.2f €%n", order.total());
Стъпка по стъпка, с думи
  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 € за кило. Цената му е цена за кило по килограми. Това е нов клас със същата форма:

record WeighedProduct(String name, double perKg, double kg) implements Item {
@Override
public double total() {
return perKg * kg;
}
}
Item coffee = new Box("Кутия с кафе", 1.00,
new WeighedProduct("Кафе на зърна", 24.00, 0.25),
new Product("Чаша", 8.00));
System.out.printf("Кутия с кафе: %.2f €%n", coffee.total());
Изход
Кутия с кафе: 15.00 €

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

В проблема списъкът беше List<Object> и приемаше всичко. Сега кутията приема само Item, и текст на мястото на продукт не се компилира:

Item box = new Box("Кутия", 1.00, "Чай");
Грешка
error: constructor Box in class Box cannot be applied to given types;
required: String,double,Item[]
found: String,double,String
reason: varargs mismatch; String cannot be converted to Item
По-точно, за любопитните

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

Пробвай

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

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

Item box = new Box("Кутия", 1.00,
new Box("Празна", 0.50),
new Product("Свещ", 5.00));
System.out.printf("Кутия: %.2f €%n", box.total());
Отговор

Пробвай

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

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

class Box implements Item {
private final String name;
private final double packaging;
private final List<Item> items;
public Box(String name, double packaging, Item... items) {
this.name = name;
this.packaging = packaging;
this.items = List.of(items);
}
@Override
public double total() {
double sum = packaging;
for (Item item : items) {
if (item instanceof Product) {
sum += item.total();
}
}
return sum;
}
}
Item gift = new Box("Коледна кутия", 2.00,
new Product("Чай", 4.50),
new Product("Мед", 6.00),
new Box("Кутийка", 1.00,
new Product("Бонбони", 3.50),
new Product("Шоколад", 2.50)));
System.out.printf("Коледна кутия: %.2f €%n", gift.total());
Изход
Коледна кутия: 12.50 €

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

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

Идеята

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

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

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

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

Упражнение

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

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

Бележката като дърво. Добави метод print(String 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 за достъпно обучение по програмиране ·