Chcę dzisiaj zająć się pewną sprawą natury projektowej. Mam tu mianowicie na myśli sytuację, gdy mamy do czynienia ze zbiorem różnych elementów, wywodzących się z tej samej klasy bazowej. Najlepiej widać to będzie na odpowiednim diagramie:
Jest tu klasa kontenera (Container), która posiada m.in. zestaw różnych Elementów. Ich dokładne typy nie są i nie muszą być znane – ważne, że wywodzą się od klasy bazowej Element. W szczególności klasy Container i Element mogą być jednym i tym samym – wtedy będziemy mieli do czynienia ze strukturą hierarchiczną, reprezentującą np. drzewo węzłów dokumentu XML czy kontrolki systemu GUI. Zauważmy jeszcze, że każdy element wie o swoim właścicielu (pole Owner), czyli o pojemniku, który go zawiera (albo o elemencie nadrzędnym w przypadku drzewa).
Z obsługą już dodanych elementów nie ma zbytniego problemu, jako że wyjątkowo naturalne jest tu wykorzystanie metod wirtualnych i polimorfizmu. Kłopot polega właśnie na tworzeniu i dodawaniu nowych elementów – a ściślej na interfejsie (funkcjach i parametrach), które mają do tego służyć. I tutaj właśnie pojawiają się te trzy rozwiązania.
Rozwiązanie pierwsze polega na wyposażeniu klasy Container w garnitur metod zajmujących się tworzeniem każdego typu elementów i dodawaniem ich do zbioru. W naszym przypadku byłyby to więc funkcje AddElementA, AddElementB i AddElementC:
Jak widać w tym przypadku dobrze jest wyposażyć konstruktory klasy Element i pochodnych w parametr, przez który będziemy podawali wskaźnik do nadrzędnego kontenera lub elementu.
Drugie rozwiązanie zakłada, że pojemnik dysponuje tylko jedną ogólną metodą AddElement. Przekazujemy jej już utworzone obiekty, a jej zadaniem jest tylko dodać je do już posiadanych:
Zauważmy, że tutaj konstruktory elementów już nie przyjmują wskaźników na swoich właścicieli. Dzięki temu możemy uchronić się przed omyłkowym dodaniem elementu do innego pojemnika niż ten, który podaliśmy podczas jego tworzenia.
Skąd jednak element wie, do kogo należy?… Za to odpowiada już metoda dodająca, która modyfikuje pole określające właściciela:
Oczywiście w tym celu klasa Container musi być zaprzyjaźniona z klasą bazową Element, co na szczęście w C++ nie stanowi problemu :) (W innych językach, jak np. C# czy Java, można osiągnąć bardzo podobny efekt przy pomocy pakietów.)
W końcu trzecie wyjście przenosi cały ciężar pracy na same elementy. W tej koncepcji to elementy same się dodają do podanego pojemnika:
Żeby miało to sens, muszą być one aczkolwiek tworzone dynamicznie poprzez operator new.
Czas na nieodparcie nasuwające się pytanie, czyli… które rozwiązanie jest najlepsze? No cóż, sprawa nie jest taka prosta (inaczej bym o niej nie pisał ;D), więc może przyjrzyjmy się kilku argumentom:
Jest więc new, którego rezultat ignorujemy i tylko po dłuższym przyjrzeniu się można zauważyć, że to jednak nie jest wyciek pamięci.
Cóż więc wybrać? Ostatnio skłaniam się ku drugiemu rozwiązaniu. Według mnie jest ono z tych trzech najbardziej przejrzyste – tworzenie obiektu to jedno (operator new), a jego dodawanie drugie (metoda Add). Ponadto dzieli ono pracę w miarę równo między element, jak i pojemnik – w przeciwieństwie do pozostałych modeli. I w końcu, nic w tym rozwiązaniu nie jest robione za plecami programisty, co oczywiście może być zaletą, ale i wadą oznaczającą, że przecież wszystko musimy zrobić samemu.
A zatem… kwadratura koła? W takich sytuacjach warto chyba iść na kompromis, czyli poprzeć prostokąt z zaokrąglonymi rogami ;)
Ciekawa notka! Mnie też nurtuje ten problem i też dochodzę do podobnych wniosków. Dodałbym tutaj:
– Jeśli jest wiele kontenerów, model 3 nadal może działać – tworzonemu elementowi trzeba wtedy do konstruktora podać kontener, do którego on ma należeć.
– Problem zwalniania – czy użytkownik elementów może ich nie zwolnić przez zwolnieniem kontenera i kontener sam je sobie zwolni?
– Inne jeszcze modele tworzenia elementów, np. taki jaki nadal pokutuje w mojej (starej już :) szóstej wersji silnika:
struct MATERIAL { … };
class MaterialCollection
{
public:
// Tworzy lub uaktualnia
void SetMaterial(string Name, MATERIAL Params);
// Usuwa
void DeleteMaterial(string Name);
};
– No tak, to nie jest żadne odkrycie – patrz ostatni sampel kodu :)
– Rzeczywiście nie napisałem tego, chociaż to pośrednio wynikało z użycia określenia “właściciel”. Tak, kontener zwalnia dodane do niego obiekty – m.in. po to je grupujemy :]
– Ciekawe :) Ale to wariacja na temat sposobu 1. Poza tym taka kolekcja obejmuje obiekty tylko jednego typu więc nie ma tu problemu rozrastającego się interfejsu.