Opis
Wzorzec projektowy Composite (Kompozyt) należy do grupy wzorców strukturalnych. Umożliwia łączenie obiektów w struktury drzewiaste w taki sposób, by zarówno pojedynczy obiekt jak i ich kolekcja mogły być traktowane jednakowo.
Występuje kilka nazewnictw dla relacji obiektów rodzic-potomek. Spotkać można takie jak, odpowiednio: kompozyt-liść, komponent-składnik. Nierzadko używane są na przemian, a nawet pomieszane.
Problem i rozwiązanie
Reprezentacje drzewiaste w programowaniu można spotkać niezwykle często. Mówiąc o OOP, na myśl przychodzi oczywiście hierarchia obiektów. Obsłużyć ją można, co najmniej, na kilka sposobów, a niektóre z nich prawie zawsze prowadzą do tych samych problemów. Szczególnie, w przypadkach gdzie klasa potomka jest bardzo podobna do rodzica, a implementacja zakłada, że są to dwa różne byty.
Zacznie się ciągłe sprawdzanie, z obiektem jakiego typu ma się do czynienia w konkretnej akcji albo na jakie operacje pozwala. A co, gdyby traktować je w ten sam sposób? Uregulować je wspólnym interfejsem, dzięki czemu klient realizujący akcję nie musi się martwić, czy operacje wykonuje na obiekcie potomka, czy rodzica. Właśnie takie podejście, dzięki rekurencji, może usprawnić obsługę hierarchicznych modeli. Oczywiście rekurencja dla bardzo złożonych struktur bywa kosztowna, ale odpowiednim buforowaniem da się pewne operacje zoptymalizować.
Plusy i minusy
Wzorzec projektowy Kompozyt ma wiele zalet. Przede wszystkim bardzo łatwo rozszerza się jego strukturę o kolejne klasy należące do hierarchii. Wystarczy odnieść się do właściwego interfejsu (bądź klasy abstrakcyjnej) i zapewnić odpowiednie implementacje. Co ważne, takie zmiany nie wiążą się też z modyfikacjami istniejącego już kodu, co przekłada się na pełne wsparcie dla reguły otwarte/zamknięte (open-closed).
Klient realizujący daną akcję nie ma pojęcia jaką pozycję przyjmuje obiekt w strukturze (enkapsulacja). Takie rozwiązanie gwarantuje sporą elastyczność. Obiekty mające relację do innych obiektów to właściwie codzienność, dlatego warto w swojej skrzynce narzędziowej mieć Composite.
W miarę jak model klas się rozrasta, interfejs staje się coraz trudniejszy do utrzymania – to niewątpliwie spora wada. Struktura drzewiasta sama w sobie jest już dość skomplikowana, więc nie ma się co dziwić. Wszystko komplikuje się jednak w momencie, w którym obiekty zaczynają się coraz bardziej różnić. W takiej sytuacji wspólny interfejs okazuje się tylko dlatego „wspólny”, że wszystkie klasy go implementują. Nie ma to jednak przełożenia na ich faktyczne zachowania.
Przykładowa implementacja w PHP
W poniższej implementacji występują dwa obiekty złączone przez wspólny interfejs i jedna klasa testująca podstawowe funkcjonalności. Przykład ten ilustruje strukturę drzewiastą jaką jest relacja obiektów katalog i plik. Folder może posiadać wewnątrz inne foldery, bądź pliki. Oba obiekty nie są jednak bardzo różne w założeniach i operacje, które można na nich wykonać najprawdopodobniej będą tożsame (czasem realizowane w inny sposób). Jest to świetna okazja do użycia wzorca Kompozyt.
Warto dodać, że nierzadko obiektów tych będzie więcej niż dwa, a ich wzajemne relacje mogą być na tyle skomplikowane, że każdy może posiadać każdego. W takich sytuacjach to narzędzie jeszcze bardziej pozwala uprościć kod. Żeby niepotrzebnie nie zaciemniać, przykład poniżej ma najprostszą z możliwych form.
Każdy z elementów systemu plików ma swój unikalny identyfikator (normalnie możliwe, że byłaby to nazwa, która musi być unikalna w ramach konkretnej przestrzeni) i metodę pozwalająca odczytać rozmiar. Oczywiście, na rozmiar składają się wszystkie elementy zawarte w danym komponencie. Analogicznie jak waga pudełka zawierającego w sobie inne pudełka. I tak dla liścia (plik) jest to po prostu jego rozmiar, a dla kompozytu (katalog) jest to rozmiar wszystkich elementów podrzędnych. Wartość dla pustego folderu wynosi zero, chociaż niekoniecznie pusty folder może istnieć – zależy od implementacji.
Jako, że oba obiekty traktowane są w ten sam sposób, to dla kompozytu wyliczenie rozmiaru kończy się na jednej pętli. Tak naprawdę to występuje tutaj mechanizm rekurencji, więc dla dużych struktur wewnątrz wykona się wiele pętli. Operacja ta może okazać się kosztowna. Jeśli istnieje taka obawa to warto pomyśleć o buforowaniu. Wówczas nie tylko klasa File
będzie miała pole $size
. Przy każdym dodaniu elementu za pomocą metody addElement()
jej wartość, dla wszystkich elementów mieszczących się wyżej w hierarchii, będzie rosła. Implementacja nie zakłada usuwania elementów, ale oczywiście należałoby to uwzględnić w przypadku utrwalania rozmiaru. Przy wyliczaniu dynamicznym nie trzeba się tym martwić, chociaż operacja może być bardziej zasobochłonna.
<?php
declare(strict_types=1);
namespace DesignPatterns\Structural\Composite;
interface FileSystemElementInterface
{
public function getId(): string;
public function getSize(): int;
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Structural\Composite;
final class File implements FileSystemElementInterface
{
private string $id;
public function __construct(
private int $size
) {
$this->id = uniqid();
}
public function getId(): string
{
return $this->id;
}
public function getSize(): int
{
return $this->size;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Structural\Composite;
final class Directory implements FileSystemElementInterface
{
private string $id;
private array $elements;
public function __construct()
{
$this->id = uniqid();
$this->elements = [];
}
public function getId(): string
{
return $this->id;
}
public function getSize(): int
{
$size = 0;
/** @var FileSystemElementInterface $element */
foreach ($this->elements as $element) {
$size += $element->getSize();
}
return $size;
}
public function addElement(FileSystemElementInterface $element): void
{
$this->elements[$element->getId()] = $element;
}
}
<?php
declare(strict_types=1);
namespace DesignPatterns\Structural\Composite\Test;
use DesignPatterns\Structural\Composite\Directory;
use DesignPatterns\Structural\Composite\File;
use PHPUnit\Framework\TestCase;
final class DirectoryTest extends TestCase
{
public function testCanGetSizeOfEmptyDirectory(): void
{
self::assertSame(0, (new Directory())->getSize());
}
public function testCanGetSizeOfDirectoryThatContainsOnlyFiles(): void
{
$directory = new Directory();
$directory->addElement(new File(12));
$directory->addElement(new File(8));
self::assertSame(20, $directory->getSize());
}
public function testCanGetSizeOfDirectoryThatContainsDirectoriesAndFiles(): void
{
$firstChildDirectory = new Directory();
$secondChildDirectory = new Directory();
$parentDirectory = new Directory();
$firstChildDirectory->addElement(new File(12));
$secondChildDirectory->addElement(new File(12));
$secondChildDirectory->addElement(new File(24));
$secondChildDirectory->addElement(new File(12));
$parentDirectory->addElement(new File(16));
$parentDirectory->addElement(new File(24));
$parentDirectory->addElement($firstChildDirectory);
$parentDirectory->addElement($secondChildDirectory);
self::assertSame(100, $parentDirectory->getSize());
}
}
Composite – podsumowanie
Zastosowanie wzorca projektowego Composite na pewno wymaga głębszego przemyślenia. Kiedy powstaje model, bardzo często ma w sobie kilka pól i metod. Na tym etapie widać więcej podobieństw, niż różnic między klasami. W przyszłości może jednak okazać się, że klasa rodzica jest całkowicie różna od potomka. Wówczas wspólny interfejs staje się uciążliwy.
Nie zawsze dla relacji rodzic-potomek rozwiązaniem okaże się wzorzec Composite. Tam gdzie się nada, z pewnością ułatwi zarządzanie skomplikowaną strukturę drzewiastą. Kompozyt użyty we właściwym miejscu bezapelacyjnie upraszcza nawet skomplikowane struktury drzewiaste. Warto korzystać z niego w aplikacjach, ale należy rozważnie podchodzić do jego użycia.
Dzięki, bardzo klarownie wytłumaczone i pomocne!