C++ bez cholesterolu

Ogólnie o programowaniu obiektowym w C++

Teoria programowania obiektowego

Najważniejszymi jednostkami w programowaniu obiektowym jest obiekt i klasa. Dzięki konsekwentnemu używaniu tu pojęcia "obiekt" mam nadzieje, że nie muszę tłumaczyć, czym on jest. Klasa jest to z kolei – ogólnie mówiąc - podstawa do klasyfikacji obiektów.

W ogólności, programowanie obiektowe polega na tym, że w programie posługujemy się obiektami, którym zleca się "wykonanie" jakiejś czynności (obiekt jest zatem wykonawcą czynności; odmiennie niż w zwykłym programowaniu proceduralnym, gdzie wszystko robi główny program). Programowanie obiektowe najlepiej – jak się uważa – implementuje język Smalltalk, w którym zawsze za wykonanie czynności odpowiada jakiś obiekt (jest tam nawet taki obiekt "Smalltalk" :). Istnieje jeszcze kwestia, w jaki sposób tworzy się obiekty i jak specyfikuje się owe operacje, bo właśnie tu języki wspierające właściwości obiektowe najbardziej się różnią.

Jedną z technik, którą się tu stosuje jest używanie tzw. klas. Klasa jest to zbiór definicji określających zachowanie obiektu. Definiuje ona przede wszystkim interfejs, czyli zbiór definicji na jakich zasadzie danym obiektem można operować. Posiada również swoją implementację, która w ogólności zawiera – najprościej mówiąc – wszystko to, co w klasie jest, a na zewnątrz nie widać. Technika ta nie jest oczywiście konieczna do stosowania; np. taki język self (dialekt Smalltalka wymyślony przez Sun-a) w ogóle nie posiada klas i tam obiekty tworzy się przez klonowanie. Nie będę się tutaj jednak nad tymi rzeczami rozwodzić; programowanie obiektowe w C++ akurat opiera się na klasach.

Klasa jest dość wygodną techniką implementacji właściwości obiektowych. Ogólnie w programowaniu obiektowym używa się jej do stworzenia obiektu. Jednak dana klasa może być stworzona na podstawie innej klasy; nazywa się to "subclassing". Jeśli zatem klasa B powstała na bazie klasy A, to wtedy obiekt stworzony przez klasę B wykonuje czynności określone w klasie A z dodatkiem tego, co określono w klasie B; należy więc również do klasy A. Nie ma jednak co się rozwodzić nad pięknem programowania obiektowego w Smalltalku, więc przejdę od razu do rzeczy, czyli jak to jest zorganizowane w C++.

Tu jest akurat pewien problem. W Smalltalku nie istnieje typizacja; istnieją tylko obiekty. W związku z tym obiektom po prostu wywołuje się metody. W C++ istnieje typizacja, zatem tej typizacji należy się w pewnych ściśle określonych regułach po prostu pozbyć. Na tej zasadzie, że definiujemy klasę bazową, kilka klas pochodnych i posługujemy się wskaźnikiem na tą klasę bazową. Wskaźnik taki, jak wiemy, może wskazywać na dowolny obiekt klasy pochodnej. Ta klasa bazowa definiuje nam zestaw metod, które można takim obiektom wywołać, a ich implementacja (czyli definicja owych metod) zawiera się już w definicjach klas pochodnych. Zatem taka prosta instrukcja "wywołania metody" może w różnych momentach programu wykonywać zupełnie odmienne akcje – zależy to od RZECZYWISTEGO typu obiektu, który jest trzymany za wskaźnik. Ta właściwość nazywa się POLIMORFIZMEM (gr. `polys' – liczny + `morfee' – postać), a taki rodzaj wpływu typu obiektu na działanie programu nazywa się DYNAMICZNĄ TYPIZACJĄ.

Dla osób znających inne języki obiektowe od razu wyjaśnienie: polimorfizm zastosowany w C++ jest mocno ograniczony do tego, co zostało pierwotnie zdefiniowane jako programowanie obiektowe. Oznacza to, że nie wszystkie metody można wywołać na rzecz danego obiektu, lecz tylko te, które dozwala się wywołać (odmiennie, niż w Smalltalku, gdzie można wywołać każdą metodę na rzecz każdego obiektu; najwyżej będzie błąd czasu wykonania). Lista tych metod musi być zawarta w klasie, do której należy typ wskaźnika (lub referencji), za który trzymany jest dany obiekt. Konkretnie: Jeśli mamy A* a, który trzyma jakiś obiekt typu wyprowadzonego z `A', to aby kompilator przepuścił instrukcję a->zapodaj(), musi w tym celu istnieć deklaracja A::zapodaj(). To nic, że `a' trzyma wskaźnik na obiekt typu B, a B ma zdefiniowane zapodaj(). Jeśli nie istnieje A::zapodaj(), to a->zapodaj() jest błędem i kompilator tego nie przepuści. Zwracam na to uwagę, gdyż jest to podstawowa różnica pomiędzy C++ a innymi językami obiektowymi! (Tzn. mówię o językach tzw. "rdzennie obiektowych", do których Java np. nie należy i akurat tam jest tak samo jak w C++). Często też w przypadku konieczności należy konwertować wskaźnik/referencję na inny typ (nb. w Javie można to zauważyć dość często), jednak wymaga to pewności co do tego, że wskazanie jest rzeczywiście na ten typ, co trzeba. Jest to też zresztą charakterystyczne i bardzo często niestety jest to konieczne w przypadku bibliotek, które są pisane "Smalltalkiem w C++", jak np. MFC.

C++ to oczywiście nie jest język obiektowy. Obiektowym językiem jest Smalltalk i tam wszystko się robi obiektowo. C++ udostepnia nam jedynie (nieco okrojone w stosunku do Smalltalka) właściwości obiektowe, zatem obiektowo programuje się jedynie pewne fragmenty projektu, te które tego wymagają.

Implementacja programowania obiektowego w C++

Każdy obiekt, jak wiemy, posiada swój typ. W pierwszej części omawialiśmy typy obiektów jako zupełnie niezwiązane pomiędzy sobą definicje (tak, jak to jest w języku C). Mając grupę obiektów, z których każdy jest tego samego typu, można powiedzieć, że są tej samej klasy. Niestety w takiej sytuacji każdy obiekt może być tylko jednej klasy, a więc to jest żadna klasyfikacja.

W rozdziale 4. zostało opisane, w jaki sposób jeden typ można wyprowadzić z drugiego. Wyprowadzenie takie wprowadza jednak już pomiędzy tymi typami pewne powiązania i oba typy są "klasami". Klasa w C++ to po prostu... struktura.

Tzn. nie dokładnie tak. Muszę się tutaj odwołać zlekka do historii C++. Początkowo, konstrukcje `struct' i `union' zostały odziedziczone z C i miały dokładnie takie jak tam możliwości (tzn. nie było niczego z "właściwości dodatkowych", opisanych w części II). Postanowiono bowiem nie naruszać niczego związanego z C, wprowadzono zatem słowo kluczowe `class', które miało znaczenie identyczne jak `struct', ale mogło dodatkowo zawierać metody oraz podział na sekcje dostępu, przy czym początkową domyślną sekcją była sekcja prywatna oraz można było te typy hierarchizować. Później jednak wprowadzono te właściwości również dla `struct' i `union' (z wyjątkiem hierarchizacji dla `union'), z tym tylko, że dla nich pierwszą domyślną sekcją jest publiczna. W efekcie słowa `struct' i `class' w C++ róznią się tylko tym, że domyślna sekcja dla `class' jest prywatna, podczas gdy dla `struct' – publiczna (co nie ma praktycznie żadnego znaczenia, bo sekcje można sobie dowolnie zmieniać; równie dobrze słowo `class' mogłoby zniknąć z C++). Jednak ze względów tradycyjnych w programowaniu obiektowym używa się powszechnie `class'.

Oczywiście mówiłem teraz o klasie w sensie języka C++ a nie w sensie terminologii obiektowej C++. Klasą bowiem w C++ jest praktycznie każdy typ. Jednak określenie klasy użyte w stosunku do typu, który nie może uczestniczyć w hierarchii (jak np. wszystkie typy ścisłe) jest pozbawione sensu (do niczego bowiem nie służy). Co prawda pewna drobna hierarchia w typach ścisłych istnieje, mianowicie to, że każdy typ w C++ jest pochodnym typu void, ale ten szczegół w praktyce nie ma żadnego znaczenia (poza kwestią wskaźników).

Dywagacje :*)

Na koniec jednak pozwolę sobie na odrobinę dywagacji nt. programowania obiektowego w C++. Przede wszystkim więc zaznaczam, żeby starać się ostrożnie podchodzić do programowania obiektowego w C++ i nie dać się zwariować. Daje ono spore możliwości, to fakt. Ale – jak wiadomo – programowanie obiektowe nie jest (jak i żadna technika programowania) lekarstwem na wszystkie problemy, ani jakąś super-hiper techniką; posiada jedynie swój zakres zastosowań. Na samym początku, gdy języki obiektowe zaczynały zdobywać popularność (a m.in. i C++) wszyscy się tym zachwycali i zachłystywali. Powstało wiele bibliotek i programów ostro korzystających z programowania obiektowego. Wiele z nich okazało się jednak wielką klapą, gdyż przez nieumiejętne i "w nieodpowiednim czasie i miejscu" zastosowanie programowania obiektowego nie tylko nie polepszyło, ale wręcz pogorszyło i skomplikowało wiele projektów, doprowadzając je do ruiny, o jakich się autorom nigdy nie śniło. Nb. z tego właśnie powodu nt. języka C++ wyrosło w powszechnej świadomości mnóstwo mitów, a bzdury takie jak to, że programy w C++ są bardziej pamięciożerne, większe i wolniejsze niż w C, są nadal (ale bez żadnych sensownych przykładów) powtarzane przez różnych ludzi, również próbujących się kreować na autorytety. Nie brakuje też takich, jak np. Eric S. Raymond, którzy twierdzą, że C++ nie jest lepszym wyborem od C, bo programowanie obiektowe nie jest "srebrną kulą" na wszystkie problemy inżynierii oprogramowania (tak jakby programowanie obiektowe było w C++ jedyną właściwością tego języka, które jest ulepszeniem w stosunku do C - tak się zresztą wypowiada każdy "intelektualista", który w życiu nie splamił się najmniejszym programem w C++).

Z kolei w przypadku niektórych bibliotek w C++ (jak np. MFC) zbyt późno jednak zorientowano się, że C++ do programowania ściśle-obiektowego się tak naprawdę nie nadaje – podczas gdy wywoływanie metod "poza statyczną typizacją" było w programowaniu obiektowym normalne (bo w programowaniu obiektowym nigdy nie było czegoś takiego, jak statyczna typizacja), w C++ było niemożliwe do uzyskania inaczej, niż przez rzutowanie (i to obleśne; operatory rzutowania *_cast dodano dużo później). A dlaczego takie sytuacje się zdarzały? Oto sztandarowy przykład: W MFC operuje się klasą widoku i dokumentu (tzw. architektura dokument widok; niezorientowanym wyjaśniam, że chodzi po prostu o architekturę łączącą część wizualną – widok – i część z danymi wewnętrznymi – dokument). Powiedzmy, że są to odpowiednio klasy CView i CDocument. Klasa CView ma metodę GetDocument, która zwraca CDocument*. Wyprowadzamy sobie swoje klasy np. HView i HDoc. Nadal jednak HView::GetDocument zwraca CDocument*. Owszem, możemy sobie skorzystać z GetDocument, ale gdybyśmy na jej rezultacie chcieli zrobić doc->Rotate, gdzie Rotate jest zdefiniowane w HDoc, niestety nie da się, bo CDocument nic nie wie o Rotate. No i co? Nie ma innej metody, jak zrzutować ten wskaźnik na wskaźnik do właściwego typu i dopiero mu to wywołać: ((HDoc*)view->GetDocument())->Rotate(). Można też przedefiniować GetDocument, ale to i tak nic nie da, bo funkcje korzystające z CView i tak wywołają to, co jest w CView – a przynajmniej potraktują zwracany obiekt nadal przez CDocument*. I nawet żadne metody wirtualne tu by nie pomogły, bo klasa bazowa nie ma (bo i ma nie mieć) zielonego pojęcia o klasach pochodnych, a już zwłaszcza w dziedzinie statycznej typizacji. Problemy takie niestety w MFC są na porządku dziennym, ale niestety tak to bywa, jak ktoś doświadczony w pisaniu w Smalltalku bierze się za pisanie biblioteki w C++ i nabiera się na programowanie obiektowe. Np. takie również, jakie też są na porządku dziennym w Smalltalku, że biblioteka nam dostarcza klasy, a naszym zadaniem jest WYPROWADZIĆ z niej własną klasę (czyli nie można tam "nie programować" obiektowo). Problem jednak w tym, że za pisanie MFC wzieli się ludzie doświadczeni w Smalltalku i C (zresztą; przecie cały windows był przez takich właśnie ludzi pisany!) i nie znający za grosz C++. Przez co stosowali techniki, które może były dobre w Smalltalku, ale do C++ pasowały jak pięść do nosa.

Całkowicie odmiennie sprawa wyglądała z kolei z biblioteką Qt. Ta biblioteka jest właśnie przykładem, że choć C++ nie nadaje się do "ścisłej" obiektówki, to jednak można napisać w C++ bibliotekę, która będzie jednocześnie obiektowa i naturalna w C++. Przyglądając się jej konstrukcji, (a także programom pod nią pisanym) można wyciągnąć parę istotnych wniosków. Np. w odróżnieniu od MFC, klasy w Qt przeznaczone są zazwyczaj do tego, żeby TRZYMAĆ w nich definicje obiektów, a nie żeby wyprowadzać z nich inne klasy. Wyprowadzanie klas jest konieczne tylko w przypadku, gdy chcemy rozszerzyć właściwości danej klasy – klasa jest pod to oczywiście odpowiednio przygotowana. Przede wszystkim zaś biblioteka ta jest PISANA obiektowo, a nie przeznaczona do ściśle obiektowego używania. Dzięki zaś dostarczonemu mechanizmowi sygnałów i slotów, nie istnieje potrzeba wyprowadzania swojej klasy w celu zdefiniowania reakcji na zdarzenia, czy też często nawet w ogóle definiowania klasy – tam klasę definiuje się zazwyczaj tylko po to, żeby była jakąś zwartą definicją logicznie powiązaną z konkretnym oknem.

Ktoś pewnie powie, że Qt do tego celu wspomaga się moc-ą, zatem właściwości obiektowe C++ nie są na tyle dobre, żeby wystarczały do napisania dobrej biblioteki. Guzik prawda. Qt używa moc-y z kilku innych powodów. Po pierwsze – to fakt – Qt powstała w czasach, gdy C++ jeszcze był dość ubogi (1996 rok), a w każdym razie od tego momentu do powstania sensownych zgodnych ze standardem kompilatorów upłynęło sporo lat. Nie zmienia to faktu, że wiele w tej bibliotece można było ulepszyć od tego czasu – ale też jest to wynik takiej polityki firmy TrollTech. Firma ta cokolwiek osobliwie podchodzi "bardzo ostrożnie" do kompilatorów i stara się używać niejako minimalnie tylko najpewniejszych właściwości C++ (bardziej absurdalne restrykcje obowiązują chyba tylko w Netscape Communications). Nie są jednak te dodatki jakieś do przesady rozrośnięte (przynajmniej nie aż tak, jak w MFC) i dotyczą właściwie tylko trzech spraw: mechanizmu sygnałów i slotów, internacjonalizacji i tłumaczenia tekstów, oraz właściwości (properties) – w tym ostatnim chodzi o to, żeby można było zmienić jakąś właściwość (tzn. stan cząstkowy) obiektu nie mając dostępu do jego klasy wyjściowej – robi się to za pośrednictwem QObject. Nie jest również prawdą, że mechanizmu sygnałów i slotów nie dałoby się zorganizować bez moc-y. Istnieją bowiem biblioteki, które mają zrobiony mechanizm sygnałów i slotów na bazie wzorców i w ogóle nie korzystają z żadnych makrogeneratorów (o tym, że mają w dodatku większe możliwości i mniej ograniczeń to już nie wspomnę). Przykładem są libsigc++ (stowarzyszona z GTKMM), podobny do tego element biblioteki Inti (konkurencja GTKMM) oraz najbardziej wypasiona boost::signals.

Ktoś pewnie zarzuciłby też, że w Qt też zdarzają się opisane wyżej problemy, z którymi boryka się MFC. Niestety, mówiąc ogólnie i teoretycznie, nie jest to do końca prawda, a mówiąc bardziej zgodnie z praktyką jest to z dużym przybliżeniem również guzik prawda. W MFC, jak wiemy, własne klasy KONIECZNIE TRZEBA wyprowadzać z klas bibliotecznych, bo inaczej się tej biblioteki używać po prostu nie da. To raz. Dwa, że w MFC istnieje coś takiego jak obiekty równoległe, które narzuca architektura dokument-widok. Istnieją tam zatem w co najmniej jednym projekcie obiekty dokumentu, widoku i ramki scalone przez obiekt wzorca dokumentu. Obiekty te istnieją właśnie równolegle i wzajemnie siebie udostępniają. Kwestia ta jest jednak całkowicie prostopadła do kwestii konieczności wyprowadzania własnych klas. W Qt sprawa wygląda inaczej: rzadziej tam się korzysta z dziedziczenia po klasach bibliotecznych, jak również prawie nie ma tam żadnych obiektów równoległych; zatem połączenie tych dwóch czynników, powodujących wszelkie opisane problemy w MFC, efektywnie stanowi najwyżej promil linii kodu w projektach opartych na Qt.

Może być to też dla niektórych ludzi dziwne, że Qt mimo wszystko jednak JEST wzorowane na językach ściśle obiektowych. Jednak po pierwsze, jest pisana naprawdę ze znawstwem realiów C++, a po drugie – niebagatelną rolę w kwestii "odciążenia" właściwości obiektowych odegrał mechanizm sygnałów i slotów. Zatem, jak widać, programowanie obiektowe w C++ nie jest co prawda specjalnie trudne, ale nie można do programowania obiektowego w C++ przykładać tej teorii projektowej, którą stosuje się np. w Smalltalku. Dobrze jest też wyciągnąć z tego kilka istotnych wniosków. Zwracam przede wszystkim uwagę na to, że w Smalltalku pisze się TYLKO obiektowo, co prowadzi do tego, że tam programowanie obiektowe musi być wyposażone we "wszystko co możliwe", po to żeby można było więcej rzeczy łatwo zrobić w sposób obiektowy. W C++ zrobiono inaczej. Programowanie obiektowe w C++ zostało celowo okrojone, gdyż bez tego wprowadzałoby to dodatkowe koszty, na które twórcy C++ nie przystali z uwagi na ogólne założenie "zerowych kosztów dodatkowych". W zamian, udostępniono wiele innych, alternatywnych paradygmatów programowania, pozwalających wykonać to samo metodami prostszymi, bardziej niezawodnymi i – co najważniejsze – tańszymi. Język C++ ma zresztą mnóstwo "swojej specyfiki", która powoduje, że problem dobry do rozwiązywania metodami obiektowymi w językach ściśle obiektowych być może w C++ lepiej będzie rozwiązać inną metodą – a wybór jest szeroki.