C++ bez cholesterolu: Programowanie hierarchiczne w C++: Rodzaje klas
4.2 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 uproszczeonego 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 conajmniej 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 zlekka 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". Klasa
rzeczywista jest ukryta przed zewnęrzem 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,
spowodowane tym, że jakiś idiota sobie kiedyś ubzdurał, że drzewo rośnie w dół
;) -- 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 dorobic kilka funkcji zamiat 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 obiektowich - 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 conajmniej 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).
Wyszukiwarka
Podobne podstrony:
classkeycode 1 1 form1structured classKB99B12więcej podobnych podstron