C++ bez cholesterolu

Aspekty programowania obiektowego

Wstęp

Programowanie obiektowe jest wciąż modne, choć tak naprawde jest już trochę przestarzałe. Opisałem na wstępie tak mniej więcej z grubsza, jak wygląda programowanie obiektowe w ogólności oraz jak to wygląda zrealizowane w C++.

Jak wielu dobrze wie, o C++ krąży wciąż taka obiegowa opinia, że jest to język obiektowy (w szczególności, że C++ to jest C wzbogacony o możliwości obiektowe). Następnie (jak to wskazałem w Dywagacjach) uważa się, że można w nim tworzyć projekty wykorzystujące wyłącznie obiektowość. Okazuje się to potem dość niewygodne i toporne (co zarazem stanowi koronny argument przeciwko C++ w ogóle). Ci, którzy próbują, za jakiś czas przestawiają się na Javę, która jest pozbawiona wszystkich tych C++-owych niepotrzebnych narośli, można tam wygodnie programować w czystym stylu obiektowym (co jest akurat wierutną bzdurą, bo "czysty styl obiektowy" to jest to, co prezentują sobą języki takie jak Smalltalk, czy też Incr-Tcl, gdzie każdemu obiektowi można wywołać dowolną metodę, bez potrzeby specyfikowania tej metody w klasie, do której należy referencja, przez którą woła się metodę), jest odśmiecanie i wszystko jest prostsze. Wszystko to może byłoby prawdą, gdyby nie fakt, że C++... nie jest obiektowy. C++ posiada jedynie mechanizmy wspierające możliwości obiektowe, jako jedną ze wspieranych technik. Programowanie obiektowe jest tak wspaniałe, jak programy Microsoftu, to znaczy jest to najwspanialsza technika programowania, pod warunkiem że jest to jedyna zaawansowana technika programowania, jaką się zna.

Jeśli się chce na poważnie korzystać w C++ z programowania obiektowego, to nie "na pałę", ale wtedy, gdy rzeczywiście jest to właśnie to, o co nam chodzi. Zanim więc dokonamy wyboru paradygmatu programowania i rozważamy wybór programowania obiektowego, najpierw należy zastanowić się, czy wspiera on zachowania, jakie chcemy osiągnąć. W szczególności, o jaki aspekt programowania obiektowego nam chodzi. Jeśli bowiem żaden z tych, do których stosowanie programowania obiektowego ma sens, to szkoda wysiłku w przykrawaniu technik obiektowych do naszych wyobrażeń o działaniu naszej aplikacji i konstrukcji projektu.

Pierwszą rzeczą jest zastanawianie się, czy chodzi nam o programowanie obiektowe, czy tylko o hierarchiczne (czego wielu ludzi nie rozróżnia, ale też C++ jest jednym z niewielu języków, które to rozróżniają). Przykładowo, wielu ludzi pielęgnuje mit, że GUI najlepiej jest programować obiektowo, bo istnieje tam hierarchia obiektów i wzorce zachowań. W rzeczywistości programowanie obiektowe tylko odpowiednio dobrze odtwarza wymagania (i to wcale nie w najlepszy możliwy sposób), bo w GUI istotna jest hierarchia obiektów, a nie ich typów. Jakoś to się tak dzieje, że język skryptowy Tk (stowarzyszony z Tcl), przystosowany do programowania GUI, nie ma w sobie ani odrobiny, która mogłaby przypominać programowanie obiektowe (jest to dopiero w Incr-Tk), a jakoś stanowi najprostsze narzędzie do tworzenia GUI. Z niego zresztą zostały zapożyczone pomysły na bardziej nowoczesny "GUI development" w bibliotekach Qt i GTK+ (związek "TK" w GTK+ z nazwą tego języka jest niezamierzony, mimo że w obu rozwija się to jako "ToolKit"). W Tk hierarchia obiektów jest opisana odpowiednią składnią ( .okno.ramka.przycisk ), a modyfikację zachowań przeprowadza się za pomocą konstrukcji "trace variable", identyczną w działaniu co mechanizm sygnałów i slotów. Zatem rozważenie programowania obiektowego w C++ powinno być przeprowadzone ze znajomością jego aspektów (które to aspekty nie są wcale specyficzne dla C++).

Traktowanie zbiorcze obiektów (usuwanie typizacji)

Czasem istnieje potrzeba obsługi zbiorczej różnych obiektów i to nie tylko różniących się zawartością, ale również wewnętrzną konstrukcją i szczegółami różnych zachowań. Tak jak to napisałem we wstępie, chodzi tutaj o pozbycie się statycznej typizacji, która przeszkadza nam w tym przypadku w uogólnianiu zachowań, które chcemy zastosować dla wszystkich obiektów. Użycie np. jakiegoś zbiornika z STL jest na pewno dobre, ale jeśli chcemy w nim trzymać obiekty różnych typów, to nie można ich obsługiwać "po wartościach", bo to akurat wymaga, by wszystkie obiekty były tego samego typu.

Jest to akurat dość klasyczny przykład zastosowań programowania obiektowego. Składa się ono z klasy bazowej, klas pochodnych które mają zawierać odpowiednie implementacje, oraz metod wirtualnych w klasie bazowej, które mają określać zachowania wspólne dla wszystkich tych obiektów. Klasa podstawowa powinna zatem zawierać metody czysto (!) wirtualne, które zostaną następnie przedefiniowane w klasach pochodnych i tam zostaną zdefiniowane szczegóły tej operacji, zgodnie z tym, co reprezentuje owa klasa pochodna.

Jeden z powszechnie przytaczanych przykładów to może być np. klasa Ruchome z metodą ruszaj(), gdzie z Ruchome zostanie wyprowadzone następnie Samochód, Statek, Pociąg, Samolot itp. i każdy dostanie swoją definicję metody ruszaj(). Z bardziej realnych przykładów mogą być różnorakie obiekty z danymi, które dostają pewne "zbiorcze operacje" służące do zapisu i odczytu z pliku. Czy nawet nie pliku, tylko po prostu "strumienia wyjściowego", co byłoby dość ciekawym przykładem wzajemnej komunikacji dwóch systemów obiektowych. Każdy taki obiekt byłby obowiązany wpisać do strumienia odpowiedni nagłówek, który umożliwi jego identyfikację, oraz swoje dane w taki sposób, żeby umiał potem "się odczytać". Ponieważ każdy z nich zawiera inne dane, o innej strukturze, zatem każdy będzie się zapisywał inaczej. Ale po dostarczeniu takiej struktury jesteśmy w stanie zaprogramować zbiorcze zachowywanie i zbiorcze odczytywanie wszystkich danych wszystkich obiektów w jednym pliku (sorki, strumieniu wyjściowym). Również podobnie, po utworzeniu obiektów (i to w arbitralnej kolejności) będzie można ich stan wczytać z pliku. Procedura nadrzędna będzie rozpoznawać nagłówki i na ich podstawie wybierać obiekt, któremu odda się "sterowanie", żeby wczytał swoje dane (na zasadzie zapytywania ich "to twój nagłówek?"). Nota bene wszystkim zwolennikom używania C "żeby było szybciej" proponuję spróbować zorganizować taki projekt w C, a następnie zrobić benchmarki i sprawdzić, który z programów okaże się szybszy.

Nazwałem to "usuwanie typizacji", bo tak właśnie w istocie jest. Choć każda klasa pochodna ma statycznie inny typ, to jednak określenie zbiorcze zachowań, zwłaszcza gdy nie znamy ilości (a często i typów) obiektów, którymi się operuje, to nie możemy się uzależniać od ich typów. Jest to zatem jak najbardziej usuwanie typizacji, które znamy również z C, z tą tylko różnicą, że jest to usuwanie typizacji w pełni kontrolowane, nie wymaga od użytkownika żadnych sztuczek składniowych i jest w pełni kontrolowane przez kompilator.

Zwracam tu szczególną uwagę, że pod to mogą być podciągnięte również inne aspekty zbiorczości takie jak tworzenie obiektów zbiorczych (zbiorników), tworzenie generycznych algorytmów i w ogólności generyzm. Owszem, używanie do tego programowania obiektowego jest możliwe, z tym tylko że zupełnie niepotrzebnie używany jest tutaj dynamizm. Zwracam na to właśnie szczególną uwagę, bo programowanie obiektowe umożliwia operowanie zbiorcze obiektami różnych typów i w związku z tym posiada odpowiednie potrzebne do tego narzuty. Jeśli z tego nie korzystamy, to wtedy musimy się godzić na narzuty, z których nie mamy żadnych korzyści, czyli płacimy za to, czego nie używamy. Dobrze jest wtedy rozważyć, czy nie lepiej do tego zastosować wzorce, które raz że nie dostarczają żadnych narzutów podczas wykonania, a dwa że wymagają dużo mniejszej ilości definicji specjalizowanych, niż w przypadku programowania obiektowego. Wielu programistów, zwłaszcza starej daty, uważa dynamizm za coś podstawowego, najlepszego i w ogóle za nieodłączny składnik generyzmu. W różnych publikacjach nie szczędzą ostrych sformułowań pod adresem wzorców w C++ nazywając je "innym makrogeneratorem" i wyrokujących że tam, gdzie jest statyczny, monolityczny kompilat, nie może być mowy o generyźmie. W rzeczywistości zaś, dynamizm jest po prostu najprymitywniejszą i najłatwiejszą metodą realizowania implementacji generyzmu, bo nie wymaga specjalnie skomplikowanej konstrukcji kompilatora (co oczywiście program odbije sobie podczas wykonywania). Jest to jednocześnie metoda powodująca duże narzuty podczas wykonania oraz jest niewspółmiernie bardziej zawodna, gdyż w praktyce wszystkie błędy mogą zostać wykryte dopiero na etapie wykonania. Dobrze jest o tym pamiętać, zanim zacznie się w pośpiechu przygotowywać projekt oparty o obiektowość i rozważyć, czy w danym przypadku nie lepsze byłyby wzorce. Czasem nawet sposób mieszany (czyli obiektowość tylko tam, gdzie konieczny dynamizm, a cała reszta na wzorcach) może się tu okazać najlepszy. To nawet mogę potwierdzić z doświadczenia. Oczywiście są takoż aspekty, których wzorce nie załatwią, np. nie mogą być używane wtedy gdy biblioteka musi być dostępna jako biblioteka dynamiczna i to też winno być wziete pod uwagę.

Podpinanie własnych wywołań do zdarzeń

Jest to jeden z istotnych aspektów programowania obiektowego, z którego korzysta się... czasem. Dość sporo korzysta się z tego w Javie, natomiast w C++ jest z tym różnie. Korzysta z tego np. Inti, jako alternatywy. MFC realizuje to za pomocą ręcznie robionej (ze wspomaganiem preprocesora) mapy komunikatów dla notyfikacji. Chodzi tutaj o dość prostą rzecz, mianowicie o najczystsze "wywołania zwrotne" (ang. callback). Wywołania zwrotne można realizować w różny sposób. Najprostszym jest podanie gdzieś wskaźnika do funkcji. Bardzo podobna jest do tego wspomniana mapa komunikatów. Można to też dostarczać łącznie, mianowicie tworzy się własną klasę, w której przedefiniowuje się metody wirtualne, te które będą w interesujących nas momentach wywoływane.

Klasa bazowa ma też często jakąś "domyślną" definicję zachowania się obiektu takiej klasy (tzn. nie jest to metoda abstrakcyjna). My tą metodę wtedy podmieniamy i w związku z tym, jak podamy funkcji bibliotecznej nasz obiekt, to wywoła się to, co my zdefiniowaliśmy. Jak już się można mocno domyślać, chodzi o podpięcie swojej funkcji, która wywoła się, jak nadejdzie jakieś interesujące nas zdarzenie (nieważne czy synchronicznie - w momencie w którym się zdarzy, czy asynchronicznie - za pośrednictwem kolejki komunikatów), czyli jest to nic innego, jak synchronizacja stanu. Jest to technika bardzo popularna w Javie, gdzie dodaje się do odpowiedniego zbiornika obiekt będący tzw. "action listener", który jest obowiązany być jakiejś określonej klasy, gdzie przedefiniowano pewną metodę (Java ma również w tym celu dostarczoną specjalną składnię tworzenia anonimowej klasy, której nie ma w C++). Wedle mojego rozeznania zresztą, jest to w Javie jedyna istniejąca metoda realizacji wywołań zwrotnych, bo czegoś takiego jak C++-owe wskaźniki do funkcji (o funkcjonałach nie wspominając) tam po prostu nie ma.

Jest tutaj z tym wszystkim jeden problem. Po pierwsze, jeśli zajdzie akurat taka sytuacja że mimo wszystko oryginalna funkcja musi być wywołana (czasem przed tą naszą, czasem po), ale oczywiście tylko w niektórych przypadkach, to można co najwyżej powiedzieć użytkownikowi "pamiętaj, że musisz dostarczyć również wywołanie metody z klasy bazowej na końcu swojej własnej", co jest bardzo podobne do powiedzenia użytkownikowi programu: "drogi użytkowniku; pamiętaj, że w to pole musisz wpisać liczbę; nie waż się tam wpisywać żadnych liter, bo inaczej program się wysypie i stracisz ważne dane". Drugi problem z kolei polega na tym, że takie podpinanie wywołań do zdarzeń w praktyce niczym się nie różni od podawania wskaźnika do funkcji (no, powiedzmy obiektu ze wskaźnikiem do funkcji zaszytym w środku), czyli jest zwykłym mapowaniem 1:1 - na jedno zdarzenie odpowiada jedno wywołanie. Wszelkie dodatkowe wywołania trzeba już realizować wewnątrz. No i last but not least, nazwy funkcji podpinanych są określane w klasie zbiorczej, więc muszą mieć związek z tym, na jakie zdarzenie reagują. Tworzy się w ten sposób funkcję, której nazwa ma się nijak do tego, co robi - funkcja wykonuje jakieś określone czynności, a nazywana jest zgodnie z nazwą zdarzenia, na które reaguje. To tak, jakby funkcja otwierająca plik nazywała się "from_main" tylko dlatego, że jest wywoływana przez funkcję main.

W C++ ponieważ nie istnieje wspomniana składnia Javy, nie będzie to też zbyt wygodne. Jest to już wada specyficzna dla C++, poza wadami ogólnymi tego rozwiązania. Zalecam zatem absolutne wystrzeganie się stosowania tego rozwiązania. Wspomniałem tylko o tym aspekcie programowania obiektowego, bo takowe jest faktycznie w użyciu. Korzystanie z niego w C++ jest jednak bardzo złym pomysłem (poza oczywiście wspomnianymi wymogami obsługiwania obiektów różnych typów!), zwłaszcza że do wyboru mamy dużo lepszą metodę, specjalnie do tego dostosowaną: mechanizm sygnałów i slotów. Podpowiem dodatkowo, że C++ jest jedynym ze znanych mi języków, w których zrealizowano w sensowny sposób mechanizm sygnałów i slotów, w szczególności które zapewniają automatyczne rozłączanie połączeń z usuwanymi obiektami. W Javie mechanizm sygnałów i slotów w ogóle nie jest możliwy do zrealizowania, bo nie istnieje tam możliwość przechowania odniesienia do funkcji, a w C# dodano ten mechanizm na poziomie języka (vide delegaty), z tym tylko że posiadają one jedynie te możliwości co Qt (minus automatyczne rozłączanie, bo w tym akurat przeszkadza odśmiecacz).

Podpinanie własnych definicji

Tutaj sprawa polega na podobnej rzeczy, co w pierwszym wspomnianym aspekcie, ale sposób jego używania jest całkiem odmienny. Jest to wręcz cała istota programowania obiektowego, blisko związana programowaniem hierarchicznym. Polega to na tym, że definiujemy swoją klasę bazując na innej, dostarczając jakieś własne metody. Używać będziemy jednak obiektów naszej klasy. Cały myk będzie polegał na tym, że jak wywołamy jej jakieś metody (zdefiniowane w klasie bazowej), a tamta wywoływała jakieś metody wirtualne, to w efekcie wywoła nasze metody wirtualne. Co zyskujemy? Zyskujemy to, że nie musimy pisać jakichś długich i skomplikowanych metod, a dostarczamy tylko definicje pewnych bardzo drobnych, szczegółowych operacji.

Tu zwracam również uwagę na to samo, co w pierwszym aspekcie. Po pierwsze, jest to – znów! – dynamizm, którego użycie nie zawsze jest konieczne. Po drugie, tu szczególnie zwrócę uwagę na to (bo natknąłem się na to niestety podczas mojej pracy), że jest to również jeden z najprostszych sposobów na to, żeby programowanie obiektowe stało się koszmarem. Jeszcze pół biedy, gdy dysponujemy źródłami biblioteki (ja tak dobrze miałem, bo robiłem wtedy albo w MFC, albo w jakiejś "prywatnej" bibliotece). Ale tu też jest wystarczająco dużo koszmarów, bo jest to często związane z otrzymywaniem obiektów o ograniczonej ilości statycznej informacji, co uniemożliwia podglądanie zawartości obiektu. A jeśli nie dysponujemy źródłami takiej biblioteki (a pragnę zauważyć, że jest to cecha, którą często teoretycy programowania obiektowego nadmiernie gloryfikują), to możemy się tylko domyślać, co się tam w środku dzieje, co jeszcze skuteczniej utrudnia śledzenie programu.

Co najgorsze, podobieństwu do pierwszego aspektu dodaje fakt, że technika ta również jest do zrealizowania za pomocą wzorców. Kiedy dostarczamy własną specjalizację wzorca (a do tego celu można np. dostarczyć własną klasę, która go będzie specjalizować), możemy w niej przedefiniować jakąś metodę, która wtedy zostanie zamiast oryginalnej wywołana. Co więcej, przedefiniowywać można w ten sposób nie tylko metody, ale niemal każdą definicję statyczną. Przykład można znaleźć na stronie o WTL.

Owszem, wzorce będą tu na pewno walczyć o lepsze z programowaniem obiektowym. Stawką tu jest nie tylko wydajność, ale i łatwość pisania. W tej drugiej dziedzinie programowanie obiektowe wydaje się wygrywać, zatem decyzja nie jest prosta. Zwłaszcza że strata wydajnościowa w przypadku programowania obiektowego jest często dość niewielka, dlatego jest tutaj wiele rzeczy, które należy wziąć pod uwagę.

Konkludując...

... programowanie obiektowe wcale nie ma tak szerokiej gamy zastosowań, jak to się nadal powszechnie głosi. Owszem, jest metodą na zrealizowanie wielu rzeczy. Wiele z nich można też zrealizować innymi, często lepszymi metodami. Dlatego C++ posiada właśnie szeroki wybór. Programowanie obiektowe posiada również tę zaletę, że jest zdecydowanie łatwiejsze do nauczenia się, niż programowanie generyczne za pomocą wzorców, zwłaszcza tym bardziej ta różnica jest widoczna, im większe możliwości chcemy z danego paradygmatu wydusić. Jeśli zadowala cię zatem realizowanie wszystkiego za pomocą programowania obiektowego, ucz się Javy. Jeśli jednak chcesz realizować wszystko w najlepszy dla danego zastosowania sposób, proponuję zapoznać się nie tylko z programowaniem obiektowym i hierarchicznym w C++, ale również z programowaniem generycznym. Aha, no i oczywiście mechanizmem sygnałów i slotów (no wiem, wielu sie ostatnio śmieje, że to mój konik :).