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

Composite

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

Проблем

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

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

record Product(string Name, decimal Price);
record Box(string Name, decimal Packaging, List<object> Items);

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

static decimal Total(List<object> order)
{
decimal sum = 0;
foreach (object item in order)
{
if (item is Product p)
{
sum += p.Price;
}
else if (item is Box box)
{
sum += box.Packaging;
foreach (object inner in box.Items)
{
if (inner is Product q)
{
sum += q.Price;
}
}
}
}
return sum;
}
List<object> order = [
new Product("Свещ", 5.00m),
new Box("Коледна кутия", 2.00m, [
new Product("Чай", 4.50m),
new Product("Мед", 6.00m),
new Box("Кутийка", 1.00m, [
new Product("Бонбони", 3.50m),
new Product("Шоколад", 2.50m)])])];
Console.WriteLine($"Общо: {Total(order):F2} €");
Изход
Общо: 17.50 €

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

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

Идеята

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

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

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

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

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

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

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

Виж го

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

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

interface IItem
{
decimal Total();
}

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

record Product(string Name, decimal Price) : IItem
{
public decimal Total() => Price;
}
class Box : IItem
{
private readonly List<IItem> items;
public string Name { get; }
public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items)
{
Name = name;
Packaging = packaging;
this.items = new List<IItem>(items);
}
public decimal Total()
{
decimal sum = Packaging;
foreach (IItem item in items)
{
sum += item.Total();
}
return sum;
}
}

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

IItem order = new Box("Поръчка", 0,
new Product("Свещ", 5.00m),
new Box("Коледна кутия", 2.00m,
new Product("Чай", 4.50m),
new Product("Мед", 6.00m),
new Box("Кутийка", 1.00m,
new Product("Бонбони", 3.50m),
new Product("Шоколад", 2.50m))));
Console.WriteLine($"Общо: {order.Total():F2} €");
Изход
Общо: 24.50 €

IItem order = new Box("Поръчка", 0,
new Product("Свещ", 5.00m),
new Box("Коледна кутия", 2.00m,
new Product("Чай", 4.50m),
new Product("Мед", 6.00m),
new Box("Кутийка", 1.00m,
new Product("Бонбони", 3.50m),
new Product("Шоколад", 2.50m))));
Console.WriteLine($"Общо: {order.Total():F2} €");
Стъпка по стъпка, с думи
  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, decimal PerKg, decimal Kg) : IItem
{
public decimal Total() => PerKg * Kg;
}
IItem coffee = new Box("Кутия с кафе", 1.00m,
new WeighedProduct("Кафе на зърна", 24.00m, 0.25m),
new Product("Чаша", 8.00m));
Console.WriteLine($"Кутия с кафе: {coffee.Total():F2} €");
Изход
Кутия с кафе: 15.00 €

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

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

IItem box = new Box("Кутия", 1.00m, "Чай");
Грешка
error CS1503: Argument 3: cannot convert from 'string' to 'IItem'
По-точно, за любопитните

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

Пробвай

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

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

IItem box = new Box("Кутия", 1.00m,
new Box("Празна", 0.50m),
new Product("Свещ", 5.00m));
Console.WriteLine($"Кутия: {box.Total():F2} €");
Отговор

Пробвай

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

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

class Box : IItem
{
private readonly List<IItem> items;
public string Name { get; }
public decimal Packaging { get; }
public Box(string name, decimal packaging, params IItem[] items)
{
Name = name;
Packaging = packaging;
this.items = new List<IItem>(items);
}
public decimal Total()
{
decimal sum = Packaging;
foreach (IItem item in items)
{
if (item is Product)
{
sum += item.Total();
}
}
return sum;
}
}
IItem gift = new Box("Коледна кутия", 2.00m,
new Product("Чай", 4.50m),
new Product("Мед", 6.00m),
new Box("Кутийка", 1.00m,
new Product("Бонбони", 3.50m),
new Product("Шоколад", 2.50m)));
Console.WriteLine($"Коледна кутия: {gift.Total():F2} €");
Изход
Коледна кутия: 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 за достъпно обучение по програмиране ·