C++ bez cholesterolu

Rodzaje klas

Ponieważ poznaliśmy już wszelkie sposoby definiowania klas i mechanizmów, jakie udostępnia nam do tego język C++, przedstawię teraz kilka typowych sposobów używania klas. Język C++ – o czy przecie mowiłem na samym początku – udostępnia nam różne mechanizmy, ale żeby je umieć wykorzystać, dobrze jet wiedzieć o różnych typowych ich zastosowaniach. Rodzaje klas, jakie tutaj występują, nie wyczerpują oczywiście wszystkich możliwości i tutaj właśnie wszelka inwencja twórcza jest jak najbardziej pożądana ;*).

Ogólne sposoby definiowania prymitywów

Niech Ci się oczywiście słowo "prymityw" nie wyda pejoratywnym ;). Oznacza to mniej więcej coś takiego, jak pewien twór, który może służyć do tworzenia innych, bardziej złożonych tworów. Jak wiemy, do tworzenia prymitywów najlepiej nadaje się słowo struct (lub class, jeśli ktoś lubi). Z takiego tworu można z kolei wyprowadzać następne poprzez dziedziczenie (lub zawieranie). Można oczywiście stworzyć też wzorzec struktury, który potem utworzy strukturę na konkretny użytek. Wydaje się, że już wszystko wiadomo, ale to tylko pozór. Dlaczego bowiem zyskują popularność języki takie, jak Java? Dlatego, że używa uproszczonego sposobu definiowania prymitywów oraz już na poziomie języka wprowadzono tam pewien podział klas (czyli wprowadzono więcej "podpowiedzi" i maksymalnie ograniczono możliwości). W C++ nie ma żadnego podziału, a możliwości mamy dużo większe (nie są one nieograniczone, bo i tak wielu rzeczy jeszcze w nim brakuje). Zajmijmy się zatem zagadnieniem rodzajów klas i sposobów posługiwania się nimi w projektowaniu obiektowym i hierarchicznym w C++.

Przede wszystkim najważniejszą rzeczą, jaką należy zauważyć, jest sposób egzystencji klas w C++. Programowanie obiektowe jest już dość starą technologią i C++ nie jest pod tym względem czymś szczególnym. Zaś to, co C++ wyróżnia wśród języków obiektowych (choć też nie jest on jedyny pod tym względem) to fakt, że klasa nie jest "bazą" dla innych klas, lecz zbiorem cech, które są do jakiejś klasy (z niej wyprowadzonej) DOŁĄCZONE. Klasa bazowa jednocześnie też jest klasą podstawową, tzn. nad-obiekt może być traktowany jakby był obiektem tej klasy bazowej. Jeśli z niej wyprowadzono więcej klas, to jest ona klasą "grupującą" wszystkie te klasy, które z niej wyprowadzono. Jak widać już funkcjonalność takiej klasy jest dość spora i dlatego postanowiłem się zająć tym tematem. Klasy bowiem można tworzyć takie, które będą miały jeszcze większą, choć ograniczoną funkcjonalność. A niektóre też takie, które mają mniejszą funkcjonalność w porównaniu z tymi zwykłymi klasami, a funkcjonalność owa jest ograniczona celowo.

Można więc powiedzieć, że te rodzaje są jakby "meta-klasami" w C++ (to określenie nie ma oczywiście nic wspólnego z określeniem "metaklasa" z języków obiektowych; jest to określenie bardziej ogólne), gdyż można je dowolnie łączyć – opisuję tutaj jedynie właściwości klas. Należy jednak pamiętać o podstawowych rodzajach klas, a więc o klasie monomorficznej i polimorficznej. Klasa polimorficzna zawiera co najmniej jedną metodę wirtualną i zawiera informacje pozwalające na stwierdzenie rzeczywistego typu obiektu. Klasy polimorficzne zostaną opisane w rozdziale o programowaniu obiektowym, tu wspomnę o nich jedynie "przy okazji".

Interfejs

Ogólnie rzecz biorąc, interfejs to klasa, która zawiera metody, ale ich wywołanie jest zlecane zawsze gdzieś dalej – nigdy obiekt tej klasy nie wykonuje w pełni żadnej z czynności opisanej metodami (o ile w ogóle ta klasa jest zdolna utworzyć obiekt). W C++ mamy do dyspozycji kilka rodzajów interfejsów: interfejs komponentowy, interfejs nakładkowy oraz interfejs przelotowy ("opaque").

Interfejs komponentowy

Jest to klasa, która zawiera jedynie metody czysto-wirtualne. Jest to niemal identyczny twór z interfejsami w Javie. Bywa użyteczny, aczkolwiek tylko jeśli rzeczywiście pożądane jest rozdzielenie dwóch rzeczy, a w szczególności obsługa COM-a (stąd nazwa). Jeśli nam nie zależy na uniwersalizacji interfejsów, to lepiej jest jednak zawrzeć w klasie pola i zdefiniować metody, które do tego wystarczą. Ogólnie, tworzenie interfejsu umożliwia zmniejszenie zależności między różnymi częściami źródeł - aczkolwiek jest to takoż klasa abstrakcyjna (patrz niżej), a tworzenie takiej dobrze żeby miało jakiekolwiek logiczne uzasadnienie :). Konkretnie, mechanizm ten jest bardzo ogólny i bardzo unika konkretnych właściwości C++, jest też zatem często dość niewygodny, zwłaszcza wersje używane w COM-ie.

W technologii "korbowatej" (czyli COM i inne mutacje CORBY) do konkretnych klas obiektów definiuje się interfejsy. W przypadku modułu w Javie, interfejs jest właśnie interfejsem :). W przypadku C++ jest to właśnie taka klasa. Jak więc widać jest to klasa robiona pod jeden klasyczny schemat. Wszystkie te postacie interfejsów są generowane z IDL-a.

Interfejs nakładkowy

Interfejs nakładkowy jest dość podobny do interfejsu komponentowego, z tym tylko że nie wszystkie metody muszą być abstrakcyjne. Interfejs komponentowy wymusza na użytkowniku, który chce "zaimplementować interfejs", aby dostarczył definicje wszystkich metod. W przypadku interfejsu nakładkowego można rzecz z lekka uprościć w ten sposób, że dostarcza się już w klasie interfejsu definicje pewnych metod (nawet wszystkich). "Implementator" danego interfejsu w klasie dostarcza wtedy definicje tylko tych metod, których zachowanie ma być inne, niż "domyślne" (często zachowanie domyślne może nie potrzebować dostępu do jakichkolwiek pól - np. ma tylko zwrócić zero).

Interfejs przelotowy

Jest to jedna z metod programowania opisana w "Design patterns", zwana "Opaque pointer". Klasa rzeczywista jest ukryta przed zewnętrzem na tyle, że jej definicji nawet nie ma w pliku nagłówkowym. Istnieje tylko jej deklaracja typu niekompletnego (i to zazwyczaj w sekcji prywatnej interfejsu), a w klasie, która jest tym właśnie interfejsem przelotowym, istnieje tylko wskaźnik lub referencja do niej. Interfejs przelotowy posiada tylko deklaracje metod (definicje tylko wtedy, gdy metoda jest skrótem do wywołań innych metod tej samej klasy), a definicje są w plikach źródłowych. Taka klasa dobrze izoluje interfejs i implementację właściwej klasy i pomaga zmniejszyć zależności między źródłami i wszelkie konieczności ponownej kompilacji.

Ponieważ klasyczna deklaracja interfejsu przelotowego może powodować fragmentację pamięci, można kombinować też trochę inaczej - np. robić pewne oszustwa w stylu "typ o zmiennej wielkości" (tzn. jedynym polem jest jednoelementowa tablica typu char, ale w rzeczywistości obiekt trzymany za wskaźnik do tej klasy jest znacznie większy). Tylko ostrożnie, bo oszukuje się w ten sposób również operator sizeof. Taka sztuczka jest zresztą stosowana w Unixowej strukturze "dirent".

Nakładka

Podobnie, jest to klasa zawierająca tylko metody. Jest to jednak klasa o diametralnie odmiennym przeznaczeniu - jest pochodną, a nie bazową i jest przeznaczona do bycia typem obiektów. Zazwyczaj chodzi tutaj o "dodanie" do klasy jakichś dodatkowych właściwości - czyli np. mamy klasę A i robimy sobie klasę K, która dziedziczy po A i zawiera tylko dodatkowe metody. Typowe programowanie obiektowe wymaga, aby klasa taka była dziedziczona (tu: A) prywatnie, a klasa nakładkowa (tu: K) definiowała własny interfejs do niej. Zazwyczaj jednak jest to za dużo zabawy, więc dziedziczy się to publicznie (w razie czego poszczególne elementy można "sprywatyzować").

Mała dygresja: zamiast "klasa nakładkowa" chętnie użyłbym określenia "nadklasa", gdyby nie to, że spowoduje to natychmiastowe protesty specjalistów od programowania obiektowego, że sieję dezinformację, bo zgodnie z terminologią obiektową powinienem użyć terminu "podklasa" – ja jednak odżegnuję się od tych pojęć, bo to one właśnie powodują dodatkowe zamieszanie; w terminologii obiektowej jakoś tak dziwnie się dzieje, że drzewa hierarchii rosną zielonym do dołu :). Oczywiście wiem, że chodzi o "stanie wyżej w hierarchii", ale jednocześnie "nadklasa" to to samo co "klasa podstawowa" i jak to ma nie wprowadzać zamieszania?

Wróćmy jednak do tematu. Ten rodzaj klasy nie wymaga takich wybiegów, jakich wymaga programowanie ściśle obiektowe. Klasa będąca nakładką ma w tym wypadku dostęp do pól chronionych. Nie to jest jednak istotne. Oczywiście, że można dorobić kilka funkcji zamiast wyprowadzać klasę. Ale wyprowadzając klasę po pierwsze możemy dorzucić funkcję jako metodę, co może umożliwić (choć nie musi) czytelniejszą postać wywołania (funkcję możemy sobie zawsze zdefiniować, jeśli uznamy to za czytelniejsze), a także udostępni operatory dostępne tylko wewnątrz klasy (np. (), czy [], a także operator przypisania, który i tak trzeba zdefiniować).

Jeśli chodzi o reguły, to należy pamiętać, że należy dla takiej klasy zdefiniować co najmniej konstruktory (te, co są dostępne dla klasy podstawowej) i operatory przypisania. No i oczywiście operator konwersji jest też często potrzebny (jakieś funkcje mogą np. przyjmować ten typ jako argument).

W połączeniu z klasą polimorficzną oczywiście nie jest to już to samo, bowiem klasa polimorficzna dostaje dodatkowe fizyczne dane. Jeśli jednak wyprowadzamy tą klasę z innej polimorficznej, to owe dane nie ulegną oczywiście powieleniu (chyba, że dziedziczymy wielokrotnie!).

Co można by podać jako przykład? No np. można sobie zrobić własną klasę String. Ponieważ nikt normalny nie będzie wyważał otwartych drzwi i pisał własnej klasy string, lepiej jest to zrobić na bazie std::string. Na "dokładkę" robi się parę dodatkowych metod po to, aby nasza klasa była elastyczniejsza w użyciu (np. ktoś chciałby, żeby fragmenty stringa można było uzyskać przez operator () a nie metodę substr). Inny przykład, z którym się zetknąłem - brakowało mi w std::map metody at(), która jest – podobnie jak w przypadku vector – podobna do []. Metoda ta – w odróżnieniu od [] – w przypadku braku istnienia podanego klucza nie tworzy tego klucza z przypisaniem domyślnej wartości, tylko zwraca taką domyślną wartość. To samo można zrobić z wieloma innymi typami standardowymi, gdyż jak na razie jeszcze nikt nie wymyślił metody na uniemożliwienie dziedziczenia w C++ (w większości języków obiektowych - w Javie również - istnieje taka możliwość). W efekcie uzyskujemy nowy typ, który zachowuje się identycznie jak stary, z tym tylko że ma pare "ulepszeń". Pożądane jest również robienie takich nakładek do klas z jakiejś komercyjnej/fundacyjnej biblioteki, która powoduje dostosowanie (konformację?) do jakiegoś konceptu/standardu.

Oczywiście należy bardzo uważać z takimi typami, a zwłaszcza gdy dostawca klasy sam pisze, że nie należy z niej niczego wyprowadzać :). Wymaga to bardzo dobrej znajomości typu podstawowego na tyle, aby być pewnym, że się nie naruszy pewnych założeń, z których owa klasa korzysta - w razie wątpliwości oczywiście pozostaje wyprowadzanie przez zawieranie :).

Klasa abstrakcyjna

Tą klasę już kiedyś omawiałem. Najważniejsza zasada, jakiej taka klasa musi być poddana to niemożliwość utworzenia obiektu takiej klasy. "Normalnym" sposobem jej utworzenia jest wstawienie co najmniej jednej metody czysto-wirtualnej. Jednak wtedy będzie ona już polimorficzna. Nie jest to konieczne jednak do utworzenia klasy abstrakcyjnej. Klasę abstrakcyjną można też uczynić deklarując jej konstruktor w sekcji chronionej. Oczywiście abstrakcyjność takiej klasy – z oczywistych przyczyn – nie podlega dziedziczeniu (w odróżnieniu od polimorficznej).

Klasa abstrakcyjna wprowadza małe zamieszanie pojęciowe. Np. mówiłem o pod-obiektach. Czy zatem część obiektu dostarczona przez definicję klasy abstrakcyjnej może być pod-obiektem (przecież nie można mówić o istnieniu "obiektów" takiej klasy, skoro jest ona abstrakcyjna!) ? Ależ oczywiście, że może. Pod-obiekt (zwłaszcza w przypadku dziedziczenia) to nie jest zwykły obiekt. Niezbyt zresztą pasuje do ogólnego określenia "obiekt". Zawsze może to być jakiś fragment obiektu, nawet jeśli może on istnieć wyłącznie jako część innego obiektu. Pod-obiekt jest zawsze określany przez klasę bazową, nawet jeśli taka klasa nie kwalifikuje się być typem. Oczywiście tak jest tylko w przypadku pod-obiektów "wprowadzanych przez dziedziczenie" - obiekty "wprowadzane przez zawieranie" są zawsze obiektami w pełnym tego słowa znaczeniu.

Klasa prywatna

Klasa taka jest przeznaczona tylko na prywatny użytek innej klasy, w której jest to specjalnie oznaczone. Nie mówię oczywiście o klasie zagnieżdżonej. Obiektów klasy prywatnej można spokojnie używać, ale nie można ich tworzyć. Może za to je tworzyć inna klasa (konkretnie jej metody). W tym celu konstruktor takiej klasy powinien być utworzony w sekcji prywatnej, a dostęp do nich powinna mieć odpowiednia metoda klasy właścicielskiej, która te obiekty tworzy (przez friend).