Projektowanie hierarchii klas
Zastanówmy się nad prostym problemem projektowym: jak opracować technikę umożliwiającą pobieranie przez program („aplikację”) wartości całkowitoliczbowych od użytkownika. Zadanie to można zrealizować na wiele sposobów. Aby nie przesadzić z różnorodnością, a jednocześnie mieć możliwość zbadania dostępnych opcji projektowych, zaczniemy od zdefiniowania modelu tej prostej operacji wejściowej.
Chodzi o to, by utworzyć klasę o nazwie Ival_box (ang. integer value input box) znającą zakres dozwolonych wartości wejściowych. Program może spytać obiekt klasy Ival_box, jaka jest jego wartość, oraz nakazać mu wyświetlenie prośby o podanie wartości użytkownikowi. Ponadto program może spytać Ival_box, czy użytkownik zmienił wartość od czasu ostatniego jej sprawdzenia:
Jako że sposobów implementacji tego jest wiele, musimy założyć, że będzie wiele rodzajów obiektów klasy Ival_box, takich jak suwaki, zwykłe pola, w których użytkownik może coś wpisać, pokrętła i interakcja głosowa.
Ogólne rozwiązanie polega na zbudowaniu „wirtualnego interfejsu użytkownika” do użytku przez aplikację. System ten udostępnia niektóre usługi dostarczane przez istniejące systemy interfejsowe. Można go zaimplementować w wielu różnych systemach, aby zapewnić przenośność kodu aplikacji. Naturalnie istnieją inne sposoby na oddzielenie aplikacji od systemu interfejsu użytkownika. Wybrałem ten, bo jest ogólny, umożliwia przedstawienie różnorodnych technik i kompromisów projektowych, techniki te są wykorzystywane także do budowy „prawdziwych” interfejsów użytkownika oraz — co najważniejsze — techniki te można zastosować także w wielu innych dziedzinach niż systemy interfejsowe.
Oprócz tematu wiązania działań użytkownika (zdarzeń) z wywołaniami bibliotecznymi pomijam też kwestię blokowania w wielowątkowych systemach GUI.
Dziedziczenie implementacji
Pierwszym rozwiązaniem, jakie opracujemy, będzie hierarchia klas z wykorzystaniem dziedziczenia implementacji (z tych, jakie powszechnie spotyka się w starszych programach). Klasa Ival_box definiuje podstawowy interfejs do wszystkich obiektów typu Ival_box oraz określa domyślną implementację, którą bardziej specyficzne klasy mogą przesłonić własnymi wersjami. Ponadto zadeklarujemy dane potrzebne do implementacji podstawowej koncepcji:
protected:
int val;
int low, high;
bool changed {false}; // zmieniane przez użytkownika za pomocą funkcji set_value()
public:
Ival_box(int ll, int hh) :val{ll}, low{ll}, high{hh} { }
virtual int get_value() { changed = false; return val; } // dla aplikacji
virtual void set_value(int i) { changed = true; val = i; } // dla użytkownika
virtual void reset_value(int i) { changed = false; val = i; } // dla aplikacji
virtual void prompt() { }
virtual bool was_changed() const { return changed; }
virtual ~Ival_box() {};
};
Domyślne implementacje tych funkcji są niezbyt udane, bo mają za zadanie tylko zasygnalizować planowaną semantykę. Realistyczna klasa zawierałaby na przykład mechanizm sprawdzania zakresu. Klas z tej hierarchii można używać następująco:
{
int old_val = pb−>get_value();
pb−>prompt(); // wyświetla prośbę do użytkownika
//...
int i = pb−>get_value();
if (i != old_val) {
//... nowa wartość; jakieś działania...
}
else {
//... jakieś inne działania...
}
}
void some_fct()
{
unique_ptr<Ival_box> p1 {new Ival_slider{0,5}}; // klasa Ival_slider pochodna od klasy Ival_box
interact(p1.get());
unique_ptr<Ival_box> p2 {new Ival_dial{1,12}};
interact(p2.get());
}
Większość kodu aplikacji działa w oparciu o (wskaźniki na) obiekty typu Ival_box, jak przedstawiona funkcja interact(). Dzięki temu aplikacja nie musi znać potencjalnie dużej różnorodności wariantów koncepcji Ival_box. Wiedza na temat tych specjalnych klas jest ograniczona do względnie niewielu funkcji tworzących obiekty. W ten sposób chronimy użytkowników przed wpływem zmian w implementacjach klas pochodnych. W większości kodu można ignorować fakt, że istnieją różne rodzaje koncepcji Ival_box.
Używam wskaźnika unique_ptr, aby uniknąć kłopotów, gdy zapomnę usunąć obiekty typu Lval_box.
Dla uproszczenia pomijam kwestie związane z oczekiwaniem na dane wejściowe. Program może na nie czekać w funkcji get_value() (np. przy użyciu funkcji get() na future) poprzez związanie Lval_box ze zdarzeniem i przygotowanie odpowiedzi albo poprzez utworzenie nowego wątku dla Ival_box i sprawdzenie później stanu tego wątku. Takie decyzje w procesie projektowania interfejsu użytkownika mają kluczowe znaczenie. Jednak opis ich w tym miejscu odciągnąłby naszą uwagę od prezentacji technik programowania i narzędzi językowych. Przedstawione tu techniki projektowania i służące do posługiwania się nimi narzędzia językowe nie są właściwe tylko interfejsom użytkownika — mają znacznie szersze zastosowanie. Różne rodzaje elementów do odbierania liczby całkowitej od użytkownika są zrealizowane jako klasy pochodne klasy Ival_box. Na przykład:
private:
//... graficzna reprezentacja suwaka itp.
public:
Ival_slider(int, int);
int get_value() override; // pobiera wartość od użytkownika i zapisuje ją w val
void prompt() override;
};
Dane składowe w klasie Ival_box zostały zadeklarowane jako chronione, by można było uzyskać do nich dostęp w klasach pochodnych. Dlatego funkcja Ival_slider::get_value() może zapisać wartość w składowej Ival_box::val. Składowa chroniona jest dostępna dla własnych składowych klasy i składowych klas pochodnych, ale nie jest dostępna dla ogólnych użytkowników.
Oprócz klasy Ival_slider możemy zdefiniować jeszcze inne warianty koncepcji Ival_box, takie jak Ival_dial służący do wybierania wartości za pomocą pokrętła, Flashing_ival_slider, który miga, gdy otrzyma nakaz wyświetlenia prośby do użytkownika, oraz Popup_ival_slider, który w reakcji na wywołanie prompt() pojawia się w jakimś dobrze widocznym miejscu, aby użytkownik go nie przeoczył.
Skąd można wziąć narzędzia dotyczące grafiki? Większość systemów interfejsów użytkownika zawiera klasę definiującą podstawowe właściwości obiektów wyświetlanych na ekranie. Gdybyśmy więc np. użyli systemu firmy Big Bucks, to każda z naszych klas, Ival_slider, Ival_dial itd., musiałaby być rodzajem klasy BBwidget. Najprościej byłoby to zrealizować poprzez
zmianę klasy Ival_box, aby była pochodną klasy BBwidget. Dzięki temu nasze klasy dziedziczyłyby po niej wszystkie własności. Przykładowo każdy obiekt rodzaju Ival_box można umieścić na ekranie, skalować, przeciągać oraz stylizować zgodnie ze standardem klasy BBwidget. Nasza hierarchia klas wyglądałaby tak:
class Ival_box : public BBwidget { /*...*/}; // zmieniona, by dziedziczyć po SGwidget
class Ival_slider : public Ival_box { /*...*/};
class Ival_dial : public Ival_box { /*...*/};
class Flashing_ival_slider : public Ival_slider { /*...*/};
class Popup_ival_slider : public Ival_slider { /*...*/};
Graficznie przedstawia się to następująco:
Krytyka
Taki projekt bardzo dobrze się sprawdza i w wielu przypadkach tego typu hierarchia jest idealnym rozwiązaniem. Jednak ma on też pewne niezgrabne cechy, które mogą nas skłonić do poszukiwania innych projektów. Klasę BBwidget uczyniliśmy bazą klasy Ival_box. Nie jest to całkiem dobre rozwiązanie, mimo że często stosuje się je w realnych systemach. Sposób użycia klasy BBwidget świadczy o tym, że nie jest ona dla nas częścią podstawowej koncepcji Ival_box, ale raczej szczegółem implementacyjnym. Jednak implementacja klasy Ival_box na bazie BBwidget wyniosła tę drugą klasę do roli podstawy projektu. To nie musi być złe. Na przykład kluczową decyzją dotyczącą sposobu prowadzenia interesów przez naszą organizację może być użycie środowiska zdefiniowanego przez Big Bucks. Ale co by było, gdybyśmy chcieli dodatkowo utworzyć implementacje Ival_box dla systemów firm Imperial Bananas, Liberated Software oraz Compiler Whizzes? Musielibyśmy utworzyć cztery różne wersje programu:
class Ival_box : public BBwidget { /*...*/}; // wersja BB
class Ival_box : public CWwidget { /*...*/}; // wersja CW
class Ival_box : public IBwidget { /*...*/}; // wersja IB
class Ival_box : public LSwindow { /*...*/}; // wersja LS
Utrzymywanie tak wielu wersji programu może być bardzo uciążliwe.
Ponadto raczej nie ma szans, że wszyscy producenci będą zgodnie stosować ten sam dwuliterowy przedrostek. Prędzej spotkamy się z sytuacją, że używane przez nas biblioteki będą należały do różnych przestrzeni nazw i różnie nazywały te same koncepcje, np. BigBucks::Widget, Wizzies::control oraz LS::Window. To jednak nie ma wpływu na projekt naszej hierarchii klas, więc pomijam kwestie przestrzeni nazw i nazewnictwa. Kolejnym problemem jest to, że każda klasa pochodna korzysta ze wspólnego podstawowego zestawu danych zadeklarowanych w klasie Ival_box. Dane te są oczywiście szczegółem implementacyjnym, który wkradł się do interfejsu. Ale przecież dane te w wielu przypadkach są niepoprawne, bo np. obiekt klasy Ival_slider nie potrzebuje zapisanej wartości. Jego wartość można łatwo obliczyć z pozycji suwaka po wywołaniu funkcji get_value(). Ogólnie rzecz biorąc, utrzymywanie dwóch powiązanych, ale różnych zbiorów danych jest proszeniem się o kłopoty. Wcześniej czy później ich drogi się rozejdą. Ponadto z doświadczenia wiem, że początkujący programiści lubią niepotrzebnie grzebać przy chronionych danych, powodując problemy z obsługą kodu. Dane składowe powinny być prywatne, aby twórcy klas pochodnych nie mieli do nich dostępu. A jeszcze lepiej, gdy dane znajdują się w klasach pochodnych, w których mogą być zdefiniowane w odpowiedni sposób i nie komplikują pracy z innymi klasami pochodnymi. Prawie zawsze chroniony interfejs powinien zawierać tylko funkcje, typy i stałe.
Zaletą derywacji od klasy BBwidget jest to, że wszystkie znajdujące się w niej narzędzia są dostępne użytkownikom klasy Ival_box. Niestety to również oznacza, że jeśli zmieni się coś w klasie BBwidget, to użytkownicy mogą być zmuszeni do ponownej kompilacji, a może nawet przepisania swojego kodu. Zwłaszcza sposób działania większości implementacji języka C++
sprawia, że zmiana rozmiaru klasy bazowej implikuje konieczność rekompilacji wszystkich klas pochodnych.
W końcu nasz program może być uruchamiany w mieszanych środowiskach zawierających okna różnych interfejsów użytkownika. Takie przypadki zdarzają się, gdy dwa systemy korzystają ze wspólnego ekranu albo program komunikuje się z użytkownikami używającymi różnych systemów. Nasz program z wbudowanymi systemami interfejsu użytkownika stanowiącymi jedyną bazę jedynej klasy Ival_box będzie w takich przypadkach za mało elastyczny.
Dziedziczenie interfejsu
Zaczniemy wszystko od początku i zbudujemy nową hierarchię klas, pozbawioną problemów przedstawionych w krytyce tradycyjnej hierarchii klas:
1. System interfejsu użytkownika powinien być szczegółem implementacyjnym ukrytym przed użytkownikami, których jego istnienie nie interesuje.
2. Klasa Ival_box nie powinna zawierać jakichkolwiek danych.
3. Zmiana w systemie interfejsu użytkownika nie powinna wymagać rekompilacji kodu, w którym wykorzystywana jest rodzina klas Ival_box.
4. W programie powinny móc współistnieć klasy Ival_box dla różnych systemów interfejsów użytkownika.
Postawione cele można osiągnąć na kilka sposobów. Poniżej przedstawiam jeden z nich, który można doskonale zrealizować w języku C++. Najpierw definiuję klasę Ival_box jako czysty interfejs:
public:
virtual int get_value() = 0;
virtual void set_value(int i) = 0;
virtual void reset_value(int i) = 0;
virtual void prompt() = 0;
virtual bool was_changed() const = 0;
virtual ~Ival_box() { }
};
Ten kod jest znacznie bardziej klarowny niż pierwotna deklaracja klasy Ival_box. Usunąłem dane i uproszczone implementacje funkcji składowych. Nie ma też konstruktora, ponieważ nie ma danych do zainicjowania. W zamian dodałem wirtualny destruktor, aby zapewnić poprawne kasowanie danych, które zostaną zdefiniowane w klasach pochodnych. Definicja klasy Ival_slider może wyglądać następująco:
public:
Ival_slider(int,int);
~Ival_slider() override;
int get_value() override;
void set_value(int i) override;
//...
protected:
//... funkcje przesłaniające funkcje wirtualne klasy BBwidget,
// np. BBwidget::draw(), BBwidget::mouse1hit() itd.
private:
//... dane potrzebne suwakowi...
};
Klasa pochodna Ival_slider dziedziczy po abstrakcyjnej klasie Ival_box i musi implementować jej funkcje czysto wirtualne. Dodatkowo dziedziczy też po klasie BBwidget, która zawiera narzędzia przydatne przy implementacji. Klasa Ival_box dostarcza interfejs klasie pochodnej, a więc jest bazą publiczną. Natomiast klasa BBwidget jest tylko pomocą implementacyjną, więc jest bazą chronioną. Oznacza to, że programista korzystający z klasy Ival_slider nie może bezpośrednio korzystać z narzędzi zdefiniowanych w klasie BBwieget. Interfejs dostarczany przez klasę Ival_slider jest odziedziczony po klasie Ival_box i wzbogacony o dodatki specyficzne dla suwaków. Zastosowałem derywację chronioną zamiast bardziej restrykcyjnej (i zwykle bezpieczniejszej) derywacji prywatnej, aby klasa BBwidget była dostępna klasom pochodnym klasy Ival_slider. Słowa kluczowego override używam, bo to, co budujemy, to typowa duża i skomplikowana hierarchia, w której łatwo o pomyłkę.
Bezpośrednie dziedziczenie po więcej niż jednej klasie nazywa się wielodziedziczeniem. Zauważ, że klasa Ival_slider musi przesłaniać funkcje z dwóch klas: Ival_box i BBwidget. Dlatego musi być derywowana pośrednio lub bezpośrednio z każdej z nich. Jak pokazałem wyżej pośrednia derywacja klasy Ival_slider z klasy BBwidget będącej bazą klasy Ival_box jest możliwa, ale ma niepożądane skutki uboczne. Także uczynienie „klasy implementacyjnej”
BBwidget składową klasy Ival_box nie jest dobrym rozwiązaniem, bo klasa nie może przesłaniać funkcji wirtualnych swojej składowej. Gdyby do reprezentacji okna użyto w klasie Ival_box składowej BBwidget*, otrzymano by kompletnie inny projekt obarczony innymi problemami do rozwiązania.
Niektórym programistom słowo „wielodziedziczenie” kojarzy się z czymś strasznie skomplikowanym. A przecież technika polegająca na użyciu jednej klasy bazowej do szczegółów implementacyjnych i innej do interfejsu (abstrakcyjnej) jest typowa dla wszystkich języków programowania obsługujących dziedziczenie i interfejsy kontrolowane w czasie kompilacji. Pokazany tu sposób użycia abstrakcyjnej klasy Ival_box jest prawie identyczny z używaniem
interfejsów w językach C# i Java. Co ciekawe, taka deklaracja klasy Ival_slider umożliwia pozostawienie kodu aplikacji w dokładnie takim samym stanie jak poprzednio. Tylko zmieniliśmy strukturę szczegółów implementacji na bardziej logiczną. Obiekty wielu klas zanim zostaną usunięte, wymagają wykonania pewnych czynności porządkowych. W abstrakcyjnej klasie Ival_box nie wiadomo, czy klasa pochodna będzie wymagała takiego porządkowania, a więc trzeba przyjąć założenie, że tak. O porządek w takiej sytuacji dbamy, definiując destruktor wirtualny Ival_box::~Ival_box() w bazie i odpowiednio go przesłaniając w klasie pochodnej. Na przykład:
{
//...
delete p;
}
Operator delete usuwa obiekt wskazywany przez p. Nie wiadomo, do jakiej klasy należy obiekt wskazywany przez p, ale dzięki wirtualnemu destruktorowi klasy Ival_box mamy pewność, że zostanie on usunięty poprawnie. Teraz definicja hierarchii Ival_box może wyglądać tak:
class Ival_slider
: public Ival_box, protected BBwidget { /*...*/};
class Ival_dial
: public Ival_box, protected BBwidget { /*...*/};
class Flashing_ival_slider
: public Ival_slider { /*...*/};
class Popup_ival_slider
: public Ival_slider { /*...*/};
Graficzna reprezentacja:
Przerywaną linią oznaczone jest dziedziczenie chronione. Zwykły użytkownik nie ma dostępu do chronionej bazy, bo jest ona (słusznie) uważana za część implementacji.
Język C++. Kompendium wiedzy. Wydanie IV, Autor: Bjarne Stroustrup, Wydawnictwo: Helion