Wspomaganie organizacji kodu
Metody, czyli funkcje składowe
Określenie `metoda' pochodzi już z terminologii obiektowej, dlatego tu bezpieczniej będzie używać określenia `funkcja składowa'. Termin `składowa' został w C++ wprowadzony z powodu konieczności zunifikowania znaczenia funkcji i obiektów zdefiniowanych wewnątrz struktury. Zatem wszystko, co zostało zdefiniowane wewnątrz struktury nazywa się `składowymi', z kolei te składowe dzielą się na `pola' (obiekty składowe) i `metody' (funkcje składowe).
No to mały przykład.
#include <iostream> using namespace std; struct Skladniki { int a; int b; int Dodaj() { return a + b; } int Odejmij() { return a - b; } int Pomnoz() { return a*b; } int Podziel() { return a/b; } }; int main() { Skladniki s; s.a = 10; s.b = 2; cout << "Dodawanie: " << s.Dodaj() << endl; // ... itd. nie chce mi się pisać }
Żeby wywołać funkcję składową używa się składni podobnej jak dla pól, czyli "z kropką". Oznacza to "wywołanie metody na rzecz obiektu", czyli składnia s.Dodaj() nazywa się "wywołaniem `Dodaj' na rzecz `s'". W przedstawionych jednak tutaj metodach (bo jest kilka ich rodzajów, te są tzw. "zwykłe") jednak nie ma nic szczególnego, poza tym, że nazwy pól nie muszą być poprzedzone dereferencją z obiektu (wyglądają jak zmienne globalne). Różnice są jedynie składniowe i nic poza tym; ten rodzaj metod to tylko inaczej używane funkcje.
Następna rzecz, którą należy zapamiętać, to to, że pełne definiowanie funkcji wewnątrz definicji klasy jest dla C++ wśród innych języków obiektowych czymś wyjątkowym. "Normalnie" bowiem powinno się je wewnątrz klasy jedynie zapowiadać, a definiować już poza klasą. C++ dopuszcza definiowanie ich wewnątrz klasy, ale w takim wypadku zyskują one pewną dodatkową właściwość, sygnowaną normalnie przez modyfikator inline.
Nie oznacza to oczywiście, że tylko w ten sposób metody mogą być inline. Będą takie również po zdefiniowaniu ich poza klasą po dodaniu im modyfikatora inline.
Zatem nasza struktura mogła być zdefiniowana następująco:
struct Skladniki { int a; int b; int Dodaj(); int Odejmij(); int Pomnoz(); int Podziel(); }; inline int Skladniki::Dodaj() { return a + b; } inline int Skladniki::Odejmij() { return a - b; } inline int Skladniki::Pomnoz() { return a*b; } inline int Skladniki::Podziel() { return a/b; }
W tym konkretnym przypadku akurat lepiej było zdefiniować metody wewnątrz struktury, gdyż są one bardzo krótkie. W bardzo wielu przypadkach jednak definiowanie metod wewnątrz klasy jest złym pomysłem, gdyż powoduje, że kod klasy się nadmiernie rozrasta i staje się nieczytelny.
Zauważ, że ponieważ metoda jest składową, to posiada dostęp do składowych swojej klasy bezpośrednio. Gdyby np. metoda Dodaj() była zdefiniowana jako funkcja, wówczas jej odpowiednikiem byłoby:
inline int Dodaj( Skladniki& s ) { return s.a + s.b; }
A zewnętrznie różnica polegałaby na tym jedynie, że wywoływałoby się to Dodaj(s) zamiast s.Dodaj().
Czasem jednak oczywiście istnieje taka potrzeba, żeby uzyskać dostęp do obiektu, na rzecz którego została wywołana metoda. Zatem w każdej metodzie (z wyjątkiem statycznych, o których za chwilę) dostępna jest stała, która ma następującą pseudodeklarację:
Klasa* const this;
Jest to - zwracam uwagę - wskaźnik. Dlaczego nie zdecydowano się na referencję - prawdopodobnie znów zaważyły względy historyczne (kiedyś używano wskaźnika this do przydzielenia pamięci na obiekt). Z drugiej strony jednak jest to słuszne - znakomita większość typów definiowanych przez użytkownika tworzy obiekty duże, raczej dynamiczne, niż automatyczne, a do takich używa się raczej wskaźników, niż referencji (w Javie zresztą jest on referencją tylko dlatego, że tam referencje przejęły rolę wskaźników).
Tak naprawdę więc metoda o nagłówku:
int Klasa::Metoda( int a, const char* t );
Podczas wywołania ma jakby nagłówek
int Metoda( Klasa* const this, int a, const char* t );
Pewnie też komuś ciśnie się na usta pytanie, jak uzyskać tutaj `const Klasa* const this'. Metody zatem mogą mieć również taki modyfikator, który umieszcza się między zamkniętym nawiasem a otwieraną klamrą (lub średnikiem, jeśli to jest zapowiedź):
int Klasa::Metoda( int a, const char* t ) const;
Metody zadeklarowane w klasie Skladniki nie modyfikują pól, zatem mogą być zadeklarowane jako stałe:
inline int Skladniki::Dodaj() const { return a + b; }
i tak dalej. Oczywiście w podobnej roli może tu wystąpić volatile.
Pewnie ktoś zapyta, po co komu te metody. O ich użyteczności będzie można głównie przekonać się przy enkapsulacji, niemniej ich użyteczność jest większa, niż się wydaje. Kiedyś mi się to przypadkiem udało, kiedy pisałem program na pracę dyplomową. Ponieważ ten program musiał obliczać wyrażenia arytmetyczne, a mnie się nie chciało tego pisać, postanowiłem dołączyć czyjś inny kod, który był prostym programem kalkulatora. Niestety; korzystał on ze zmiennych globalnych i był napisany tak, że dostosowanie go do mojego programu było wręcz zajęciem nieopłacalnym. I wtedy pomyślałem: a gdyby umieścić cały jego kod w klasie, zmienne globalne zrobić jej polami, funkcje - metodami, a inicjalizację zmiennych globalnych umieścić w konstruktorze? Okazało się wtedy, że po paru drobnych, niewielkich modyfikacjach wstawiłem kod do mojego programu bez najmniejszych problemów. Całe wykonanie polegało na zadeklarowaniu obiektu tej klasy i wywołaniu na jej rzecz metody Evaluate(). Klasę, do której wstawiłem kod tego programu (o nazwie Evaluator) nazwałem "modułem" kodu. Nie wiem, czy właściwie, ale to określenie bardzo tutaj pasowało.
Automatyczna inicjalizacja obiektów - konstruktory
W podrozdziale o obiektach dynamicznych wspomniałem o etapach zarządzania życiem obiektu. Jak wiemy, aby obiekt był możliwy do użycia, to po przydzieleniu pamięci powinien on zostać zainicjalizowany. Przykładowo jeśli deklarujemy sobie zmienną:
int i;
to po przydzieleniu pamięci, następuje wywołanie konstruktora. W tym wypadku nie robi on nic (słowo "wywołanie" nie musi oznaczać faktycznego wykonania skoku do podprogramu i powrotu; proszę nie zapominać o modyfikatorze inline!). Ale już w tym przypadku:
int i( 10 ); // lub int i = 10;
konstruktor spowoduje zainicjalizowanie tej zmiennej wartością 10.
Na pewno wiele osób zdecyduje mi się zarzucić, że zamierzam sugerować, jakoby konstruktory (jak również referencje!) istniały już w C. Niestety to jest akurat wszystko prawda, z tym tylko, że o ile w C były to jedynie możliwości typów wbudowanych, niedostępne dla użytkownika, o tyle C++ umożliwia użytkownikowi ich nie tylko używanie, ale i implementację w zdefiniowanych typach. W C++ po prostu dodano pare właściwości, które czynią ten język logiczniejszym i bardziej elastycznym.
Konstruktor jest to metoda o następującej deklaracji:
Klasa::Klasa( <argumenty> );
tzn. metoda ma tą samą nazwę co klasa i NIE DEKLARUJE ŻADNYCH WARTOŚCI ZWRACANYCH (nawet void!).
Przyjrzyjmy się jeszcze raz naszemu przykładowi.
#include <iostream> using namespace std; struct Skladniki { int a; int b; Skladniki( int x, int y ) // konstruktor { a = x; b = y; } int Dodaj() { return a + b; } int Odejmij() { return a - b; } int Pomnoz() { return a*b; } int Podziel() { return a/b; } }; int main() { Skladniki s( 10, 2 ); // wywołanie konstruktora cout << "Dodawanie: " << s.Dodaj() << endl; // ... itd. }
Konstruktor oczywiście nie musi mieć argumentów i podczas deklarowania zmiennej, której konstruktor nie ma argumentów, nie trzeba używać nawiasów, a jeśli konstruktor ma tylko jeden argument (uwaga! NIE jeśli posiada kilka argumentów, gdzie od drugiego wszystkie mogą być domyślne!) można go podać po znaku `=' zamiast w nawiasach (jest to wymóg kompatybliności z C). Z jednym wyjątkiem – w C++ istnieje słowo kluczowe "explicit", które oznacza, że argument konstruktora NIE MOŻE być podany po znaku `=' (słowo to jest w tym przypadku efektywne tylko dla konstruktorów przyjmujących jeden argument). Słowo explicit ma oczywiście też inne znaczenie – powstrzymuje przed użyciem konstruktora z jednym argumentem do dokonania niejawnej konwersji (bo normalnie to tak się właśnie dzieje!). Jednym z typów, których konstruktor jest tak oznaczony, jest auto_ptr. Konstruktor jednak – przypominam – istnieje i tak i tak, jeśli go nie zdefiniujemy, to domyślnie mamy dostępny konstruktor bez argumentów, który nie robi niczego (jest to również wymóg kompatybilności z C). Jednak jeśli zdefiniujemy jakikolwiek własny konstruktor, to ten konstruktor już nie jest dostępny (ale można go oczywiście "dołożyć").
Konstruktor jednak ma znaczenie większe, niż sam fakt, że inicjalizuje obiekt. Dodanie definicji konstruktora tworzy typ klasowy. Bez tej definicji struktura jest typem, który standard określa jako "agregat". Agregatem jest każda struktura bez konstruktora, unia i tablica (może lepiej byłoby to nazwać "zwieraczem", ale to się będzie ludziom źle kojarzyć - myślę, że skojarzenie z lodówkami, choć błędne, nie będzie przynajmniej "nieprzyzwoite" :). Agregaty rządzą się trochę innymi prawami, niż klasy. Mianowicie, mają wiele wspólnego z typami POD. W szczególności np. można je kopiować przez kopiowanie reprezentacji (tzn. C++ traktuje sprawę trochę inaczej - w ogólności mówi się o domyślnej implementacji kopiowania obiektów, o czym za chwilę). Również ich utworzenie powoduje utworzenie obiektu o wartości osobliwej. Właściwie to nawet można powiedzieć, że od typów POD różni ich tylko jedna rzecz: są złożone. Ma to różne konsekwencje, np. takie że nie można ich inicjalizować pojedynczymi wartościami, lecz inicjalizatorem agregacyjnym, czyli elementami w klamrach. Nie mogą być też przekazywane do funkcji o zmiennej liście argumentów. Nie mają też zdefiniowanych operatorów związanych z typem (z wyjątkiem operatora przypisania). Czy coś jeszcze - nie wiem; przed ANSI C miały one więcej ograniczeń, np. niemożność kopiowania przez wartość, ale ANSI C to ograniczenie usunął.
Z tymi konstruktorami domyślnymi w agregatach sprawa jest troszkę bardziej skomplikowana, gdyż jest tu drobny konflikt pomiędzy właściwościami zgodnymi z C, a właściwościami wymaganymi przez C++ (no niestety, C++ jest okraszony różnymi tego typu kompromisami). Otóż, załóżmy, że mamy takową strukturę:
struct S { int a, b; string s; };
I teraz podamy trzy możliwości zadeklarowania zmiennej typu S:
S s; S s = S(); S s = { 12, 15 };
Pierwszy i drugi sposób to konstruktor domyślny. W drugim przypadku teoretycznie następuje taka akcja: konstruktor domyślny (S()) tworzy obiekt tymczasowy, a ten jest następnie konstruktorem kopiującym kopiowany do s. Teoretycznie, bo tak naprawde zostanie wywołany tylko konstruktor domyślny - jest to optymalizacja, dopuszczona przez standard (to jednak mimo wszystko oznacza, że obiekt musi mieć zdefiniowaną operację kopiowania). Ostatni z kolei to inicjalizator w stylu C. Jak widać, nie wszystkie pola są użyte. Jest to owszem również dozwolone (nawet w C) na zasadzie, że można po prostu podać mniej elementów inicjalizatora, niż jest to konieczne - pola, którym nie podano wartości, będą inicjalizowane "konstruktorem domyślnym". Gdybyśmy więc napisali:
Jest tutaj parę drobnostek, na które dobrze jest zwrócić uwagę. Jak widzisz, struktura S zawiera jeszcze pole typu string. Typ string niestety nie jest typem ścisłym - jest typem strukturalnym, zawierającym własny konstruktor (a więc, klasą). I to wprowadza tutaj małe zamieszanie. W pierwszych dwóch deklaracjach, pola a i b mają wartości osobliwe, natomiast pole s jest pustym stringiem (jak każdy string inicjalizowany konstruktorem domyślnym). W trzeciej - pola a i b mają wartości tak, jak podano, a s tak, jak poprzednio. Gdybyśmy podali wartość, ona zostałaby posłana do konstruktora (na zasadzie domyślnej konwersji przez konstruktor). To jest mniej więcej tak, jak możnaby się domyślić, ale... no właśnie. Co by było, gdyby trzecie pole też było typu int (zauważ, że wtedy wszystkie pola są typami POD, a co za tym idzie, owa struktura jest wtedy agregatem)? Otóż wtedy pierwsza i druga deklaracja różnią się. Mianowicie w pierwszej, zmienna s jest "domyślnie inicjalizowana" (czyli pola pozostają przy wartościach osobliwych), a w drugiej - pola są zapisywane wartościami konstruktorów domyślnych (czyli zerami). Skąd się to bierze? Otóż struktury, których wszystkie pola są POD (lub innej struktury, która również spełnia ten sam warunek), mają konstruktor domyślny działający na tej samej zasadzie co dla typów POD: S() wywołuje konstruktory domyślne dla pól, a S s; jest "domyślnie inicjalizowane", czyli nie wywołuje niczego. Określenie "konstruktor domyślny" z poprzedniego akapitu oznacza właśnie to pierwsze i czasem różni się niestety od "domyślnej inicjalizacji".
Ale proszę pamiętać też o zmiennych statycznych! O ile pamiętasz, pisałem że zmienne statyczne są inicjalizowane konstruktorem domyślnym. I tak właśnie jest - deklaracja "static int i;" jest identyczna z "static int i = int();". To samo takoż dotyczy struktury S - i to niezależnie od tego, czy składa się z samych POD-ów, czy nie tylko.
Uprzedzę jednak wszystkie pytania w stylu "po cholere wprowadzono takie zamieszanie". Otóż jak mogłaby wyglądać jednolitość tych reguł. Załóżmy np., że uniemożliwiamy powstanie osobliwości przez niezainicjalizowanie wartości typu POD - czyli wszystkie wartości typu POD są na starcie zapisywane zerami. Jeśli tak, to wyobraź sobie np. tablicę z wartościami typu POD, czy jakąś bardziej skomplikowaną strukturę nie posiadającą konstruktora, sporych rozmiarów, której na starcie inicjalizowanoby wszystkie elementy zerami. Natychmiast C++ stałby się bardzo widocznie wolniejszy od C. Natomiast gdyby z kolei wprowadzić regułę, że inicjalizujemy wszystkie zmienne zerami, chyba że przypisano im inne wartości (tzn.: inicjalizujemy zerem wszystko co bez tego pozostałoby osobliwe), być może byłby to jakiś kompromis. Niestety też nie bardzo i nie zawsze udałoby się uniknąć kosztów dodatkowych; niewiele jest tak naprawde sytuacji, w których kompilator by to wykrył. Z kolei, gdyby przyjąć odwrotnie - że domyślna konstrukcja typu POD daje wartość osobliwą to już w ogóle nie wchodzi w rachubę: wartość można stworzyć przez TYP(). Gdyby takie coś zwracało wartość osobliwą, to ten język już by się do niczego nie nadawał. Właśnie dlatego zapewnia się (też co prawda nie do końca - ale to już dłuższy temat), że wywołanie TYP() tworzy stałą wartość - a taka wartość nigdy nie może być osobliwa.
Aha, no i jeszcze jedno. Pisałem o tym, że konstruktor typu
S s = S();
może być użyty tylko, gdy istnieje operacja kopiowania dla typu S. Operacja kopiowania jest definiowana (tzn. może być, bo istnieje jej wersja domyślna) poprzez coś, co się nazywa konstruktorem kopiującym. Jest to funkcja o następującej definicji (np. dla klasy `Składniki'):
Skladniki::Skladniki( const Skladniki& );
Konstruktor taki jest dostępny w strukturze zawsze, nawet jeśli zdefiniuje się jakiś inny konstruktor (jak uniemożliwić użytkownikowi jego użycie, dowiesz się w części o programowaniu hierarchicznym).
Tu jednak bardzo ważna uwaga! Konstruktor kopiujący (jak i każdy inny konstruktor z jednym argumentem) NIE korzysta z operatora przypisania! Właśnie dlatego warto czasem stosować słowo explicit. Użycie operatora przypisania przy inicjalizacji sugeruje, że zostanie użyta funkcja "operator=" (za chwile będzie omówione przeciążanie operatorów), co niestety nie jest prawdą; zostanie użyty odpowiedni konstruktor. Właśnie dlatego używanie nawiasów zamiast operatora = do inicjalizacji (oraz słowa kluczowego explicit) sugeruję jako surowo wskazane!
Konstruktor jest oczywiście funkcją, która może robić co tylko się użytkownikowi zachce. Pamiętajmy jednak, że zadaniem konstruktora jest przede wszystkim zainicjalizowanie pól. Tymczasem zastosowana w tym konstruktorze składnia nie jest inicjalizacją tylko przypisaniem. Gdybyśmy mieli np. zmienne referencyjne jako pola, to wymagałyby one inicjalizacji, gdyż przypisanie nie jest dla nich dostępne. Inicjalizację pod-obiektów (tzn. w tym wypadku mam na myśli pola, ale to pojęcie jest w istocie szersze; mówię to tylko na późniejszy użytek) wykonujemy przez wywołanie konstruktora na LIŚCIE INICJALIZACYJNEJ, tzn.:
struct Skladniki { int a, b; Skladniki( int x, int y ): a( x ), b( y ) { } ... };
I w ten sposób przypisanie wartości a i b nastąpiło już w ich konstruktorach, a nie dopiero po ich skonstruowaniu. Ciało konstruktora jest oczywiście puste, gdyż w tym akurat przypadku poza wywołaniem konstruktorów dla a i b nic więcej robić nie trzeba (w kwestii informacyjnej przypominam oczywiście, że listę inicjalizacyjną umieszcza się tam, gdzie jest definicja funkcji).
Konstruktor jest - wbrew pozorom - bardzo często używany wprost. Między innymi podaje się go dla operatora new przy tworzeniu obiektów dynamicznych:
Skladniki* s = new Skladniki( 20, 50 );
A dodatkowo służą też do tworzenia obiektów tymczasowych; zwykle tworzy się je jako argumenty zwracane (przykład jest w rozdziale 5).
Destruktor
Jak pamiętamy z opisu zarządzania życiem obiektu, kiedy nie będzie on już używany i przeznacza się go na usunięcie, przed zwolnieniem zajmowanej przez niego pamięci należy obiekt zniszczyć. Typy ścisłe akurat nie posiadają destruktorów (tzn. przyjmijmy dla bezpieczeństwa, że destruktory typów ścisłych są puste). Jednak typy ścisłe mają prostą budowę i obsługę, a typy użytkownika nie zawsze. Jeśli np. obiekt zawiera wskaźnik, na który pamięć przydziela się dynamicznie (w konstruktorze), to zanim obiekt zostanie zwolniony, należy najpierw zwolnić pamięć, którą się w konstruktorze przydzieliło. Oczywiście nie chodzi tutaj tylko o zwalnianie pamięci; obiekt np. może być gdzieś "uwiązany" i przed destrukcją należy go "odwiązać"; obiekt w konstruktorze powiększył licznik obiektów i przed zniszczeniem trzeba ten licznik z powrotem zmniejszyć; może trzeba np. zamknąć plik, z którego obiekt korzysta – i tak dalej. Jak widzimy więc, nie jest on niczym koniecznym, ale dla uogólnienia należy przyjąć jego istnienie, gdyż jest on często bardzo użyteczny.
Destruktor ma dość prostą składnię:
Klasa::~Klasa();
i NIGDY nie przyjmuje argumentów. Oczywiście istnieje też coś takiego, jak int::~int(), ale jest to po prostu tylko i wyłącznie taki "marker", dostarczony tylko z uwagi na wzorce; niczego to nie robi.
Pola statyczne
Jak obiecałem, wyjaśniam teraz, jakie znaczenie ma modyfikator static dla pól (jeśli nie używa się właściwości hierarchizacji typów w C++, stosowanie ich jest praktycznie pozbawione sensu, ale wolę wyjaśnić to teraz). Wyobraźmy sobie strukturę:
struct Klocek { int a; static int b; }; Klocek k1, k2;
W ten sposób, k1.a i k2.a to są dwie różne zmienne, podczas gdy k1.b i k2.b to dokładnie ta sama zmienna. Jakby tego było jeszcze mało, to słowo static dla pola struktury ma znaczenie trochę podobne do... extern! Zatem program taki nie będzie chodził, dopóki nie umieści się (już poza klasą) deklaracji:
int Klocek::b;
Jak zresztą widać, do pola statycznego `b' w strukturze `Klocek' można się odwoływać jako k1.b, k2.b i oczywiście Klocek::b. Jest to więc pole de facto dzielone przez wszystkie obiekty tej struktury (czy muszę jeszcze dodawać, że fizycznie nie znajduje się w tej strukturze? :*).
Zewnętrzna deklaracja jest oczywiście obowiązkowa i jest deklaracją FIZYCZNĄ, a nie ULOTNĄ (jak to jest w przypadku typów i ich "zwykłej" zawartości); zatem jej deklaracja nie może być w pliku nagłówkowym (czyli w tym samym, co struktura). Jej deklaracja jest identyczna, jak deklaracja każdej zmiennej globalnej. Nie może być zmienną lokalną w funkcji (to taki kubeł zimnej wody na głowę :*).
Mimo wszystko jednak, można by zapytać, po co wprowadzono takie zamieszanie, że jeszcze dodatkowo trzeba deklarować to pole statyczne. Problem niestety polega na tym, że powstałby w ten sposób dodatkowy obiekt tworzony na rzecz klasy i musiałby w ten sposób podlegać "słabemu wiązaniu" (opisałem to w rozdziałach "Niekompatybilności z językiem C" oraz "Moduły"). Teoretycznie oczywiście nie stanowiłoby to pewnie problemu, żeby takiego obiektu nie tworzyć, tylko byłby on tworzony przez kompilator i podlegał wtedy "słabemu wiązaniu". Jednak szkopuł w tym, że o ile w przypadku obiektów charakterystycznych klas (są tworzone dla niektórych klas, będzie jeszcze o tym mowa przy właściwościach obiektowych) i funkcji inline nie było innej możliwości, niż wprowadzić "słabe wiązanie" (Bjarne Stroustrup wspominał, że w pierwszej implementacji C++, kompilatorze Cfront, który tłumaczył kod C++ na C, obiekty charakterystyczne klas były "doklejane" do deklaracji pierwszej metody wirtualnej, tak żeby wybrać sobie po prostu "pierwszy lepszy" plik .o, w którym to będzie siedziało), o tyle w przypadku pól statycznych było. Ale nie to jest najważniejsze. Drugim powodem jest fakt, że o ile funkcje mogą być wiązane bezpiecznie, o tyle obiekty już nie (i dlatego właśnie wszystkie obiekty podlegają silnemu wiązaniu). To jeszcze w ostateczności dałoby się przecierpieć, ale istnieje jeszcze trzeci powód. Otóż obiekt musi być niestety konstruowany, a każdemu obiektowi należy wskazać zarówno miejsce, jak i sposób konstruowania (może jako argument trzeba by mu podać wskaźnik do innego globalnego obiektu, który został utworzony wcześniej?). Gdyby się decydować na takie niejawne deklarowanie obiektu, to dodanie "tradycyjnej" metody tworzenia takiego obiektu nie byłoby już możliwe (implementacja obiektów fizycznych tworzonych na rzecz definicji ulotnych polega na tym, że tworzy się tyle tych obiektów, ile jednostek kompilacji wciąga plik nagłówkowy zawierający te deklaracje, a słabe wiązanie tych obiektów polega na tym, że wybiera się z nich "pierwszy lepszy" - gdzie wtedy należy umieścić wywołanie konstruktora dla takiego obiektu, skoro tylko jeden z takich obiektów miałby określone wywołanie konstruktora?).
Metody statyczne
Pola i metody statyczne mają jedną wspólną cechę: są to po prostu normalne obiekty i funkcje, z tym tylko featuresem, że ich "tzw. przestrzenią nazw" (ang. namespace!) jest dana struktura (to oczywiście nie jest wszystko, ale reszta będzie przy enkapsulacji). Zatem aby uzyskać dostęp do metody statycznej `Make' ze struktury `Klocek', należy napisać `Klocek::Make', aczkolwiek można tą metodę wywołać również na rzecz dowolnego obiektu klasy `Klocek', choć oczywiście nie ma to za grosz sensu (nie zapominajmy jednak, że metody wywołuje się też spod innych metod tej samej klasy, a wtedy to się przydaje).
Metody statyczne - jak to już wspominałem - nie mają dostępu do `this' (i w efekcie też do zwykłych pól), ale mają dostęp do pól statycznych. Dla niektórych to stwierdzenie może się wydać "retoryczne", ale później okaże się, że nie powiedziałem tego na próżno (patrz sekcje dostępu).
Zagnieżdżanie struktur
Deklaracje struktur można zagnieżdżać, np.:
struct Klocek { struct Scianka { int a; }; int a; };
W takiej sytuacji struktura Scianka należy tylko do przestrzeni nazw struktury Klocek i nic poza tym. Struktura Klocek zawiera w efekcie tylko jedno pole `a'. Jedyne, co wynika z tej deklaracji to to, że dostęp do struktury Scianka jest za pośrednictwem Klocek, tzn. jeśli chcielibyśmy zadeklarować zmienną typu Scianka, trzeba napisać:
Klocek::Scianka s;
Zagnieżdżone struktury przydają się jeśli potrzebujemy jakiegoś "lokalnego" typu, a nie chcemy ryzykować pomieszania nazw. Zwłaszcza może się to nam przydać później, w połączeniu z enkapsulacją.
Uzmiennianie stałych
Tak, jak normalne obiekty typów ścisłych, tak i struktury mogą być stałe. Stałość struktury jednocześnie oznacza, że stałe są jej wszystkie pola. Modyfikator const jest niejako "dziedziczony" przez pola takiego obiektu. Oczywiście, modyfikator const można nadać tak samo pojedynczym polom i takie pole będzie ZAWSZE stałe. Natomiast jeśli nie określi się wariancji, to wtedy uzyskana REFERENCJA do tego pola posiada taką samą wariancję, jak obiekt nadrzędny.
Czasem jednak istnieje potrzeba, żeby nawet w stałych strukturach istniało pole, które można zmienić (jakieś specjalne kontrolne). Początkowo tego nie było w C++ i należało się ratować konwersjami na przeciwwariancję (rzutowaniem obleśnym!). Te są jednak o tyle niebezpieczne, że kompilator często próbuje (w celach optymalizacyjnych) umieścić globalne obiekty stałe w przestrzeni kodu, a nie danych, przez co one są wręcz fizycznie stałe (tzn. próba ich zapisania zakończy się wysypaniem programu). Dlatego w C++ wprowadzono specjalny modyfikator dla pól struktur, zwany `mutable'.
struct Complex { mutable int nop; float real, imag; Complex(): real( 0 ), imag( 0 ) {} Complex operator+( const Complex& c ) const { nop++; c.nop++; return Complex( real + c.real, imag + c.imag ); } }; ... const Complex x; Complex z = x + x;
Jak widać, mimo że `x' jest stałe (i tylko dzięki `const' przy operatorze dodawania może się on w takiej sytuacji wykonać), można zmieniać wartość `nop'. Modyfikator `mutable' oznacza, że dane pole w strukturze będzie zawsze zmienne.
Jedna drobna sprawa - przy okazji omawiania referencji, wspomniałem o tym, że nie ma czegoś takiego, jak "const T"; jest tylko "const T&". No i że jest też od tego wyjątek. Otóż niestety jest właśnie coś takiego:
Klocek GetKlocek2( int );
Funkcja zwraca nam wartość typu 'Klocek'. Ale możemy też napisać "const Klocek". Pomiędzy nimi jest niestety różnica, a wygląda ona dokładnie w ten sposób (ja to uważam za defekt, ale mimo zgłoszenia do C++ kilka ładnych setek defektów, tego nie ujęto): wewnątrz metody, wywołanej na rzecz tej wartości zwracanej, wskaźnik 'this' ma wariancję taką, jak w tym typie zwracanym podano. Jest to jeden JEDYNY przypadek, gdzie tak się decyduje o wariancji obiektu. Gdybyśmy napisali "GetKlocek2( 10 ).Make()", to wewnątrz metody Make() wskaźnik "this" miałby typ "Klocek*". Gdyby typ zwracany podać jako "const Klocek", to this byłby typu "const Klocek*". Jest to dość paskudna niespójność; gdybyśmy np. zamiast wywoływać metodę wywołali funkcję z argumentem "Klocek&", kompilator by to odrzucił. Gdybyśmy nawet chcieli, żeby było identycznie jak w metodach, czyli wskaźnik Klocek* i próbowali napisać "&GetKlocek2(10)", kompilator to jeszcze szybciej odrzuci: operator & można stosować do referencji, a nie do wartości (a zakładając konwersje do referencji - może się to konwertować tylko do stałej, a nie do zmiennej referencji). Niespójność polega tutaj tylko na jednej rzeczy - pozwala się de facto konwertować "Klocek" na "Klocek&" przez wywołanie metody. Jest to dość paskudne nie tylko przez niespójność; utrudnia również kompilatorowi stosowanie optymalizacji, bo np. kompilator musi założyć, że jak user nie napisał "const Klocek" to ma tak zaimplementować obiekt, żeby mógł zawsze do niego uzyskać wskaźnik (czyli zawsze umieścić go w pamięci, nawet gdy nie ma takiej potrzeby).
Przy okazji jeszcze jedna uwaga: dlaczego mogliśmy zadeklarować stałą x nie podając jej argumentów? A skoro mogliśmy, to kiedy nie możemy?
Niestety jest to kolejna niespójność. Bo jeśli chodzi o konstruktory typów ścisłych, to tam wszystko jest w porządku - konstruktor z argumentem jest dozwolony zawsze, a bez argumentów tylko dla obiektów zmiennych. Niestety, w C++ nie istnieje możliwość oznaczania konstruktorów modyfikatorem const. Nawet jeśli deklaruje się obiekt stały, to na jego rzecz zawsze wywołuje się konstruktor i można tam wywołać dosłownie każdy konstruktor. I na czas działania konstruktora szlag trafia wszelkie atrybuty const, które teoretycznie powinny być nadane poszczególnym polom tej struktury i które są nadawane im przecież zaraz, gdy tylko konstruktor się zakończy.
Ogólnie zresztą ma się to tak: kompilator nie sprawdzi, czy obiekt utworzony z atrybutem const na pewno wywoła konstruktory dla typów ścisłych (i to te, które się pozwala wywołać!). To znaczy – pozwoli na wywołanie konstruktora bezargumentowego dla typu int, którego jest pole struktury, której obiekt ma być stały. Czego przecież nie powinien dozwolić i przecie nie dozwoli np. dla zmiennych lokalnych. Wystarczy zatem opakować zmienną typu int w klasę i w tym momencie możemy utworzyć stałą typu tej struktury, gdzie ta zapakowana "stała" (a przynajmniej taką powinna być po nadaniu tego atrybutu obiektowi tej struktury) będzie miała wartość osobliwą!
Wskaźnik na składową
Wskaźniki można tworzyć również do składowych (w tym do pól, co jest nowością w stosunku do języka C). W języku C jest dostępne makro o nazwie offsetof (w stdlib.h, przez to dostępne również w C++), które pozwalało przesuwać się od wskaźnika na początek obiektu do wskaźnika na pole wewnątrz obiektu. Wskaźnik na pole jest czymś podobnym, ale nie dokładnie tym samym; używa się go nie jako przesuwacza, ale jako faktyczny wskaźnik, który się naprawdę wyłuskuje.
Wskaźnik na składową jest, przyznaję, jedną z najgorszych rzeczy pod względem składniowym w C++ - sposób wywołania jest dość nieczytelny, a zrozumieć sposób jego używania jest dość trudno, mimo że teoretycznie jest to bardzo proste. Załóżmy sobie taką strukturę:
struct A { int in; int out; void Set( int x ) { in = x; } void Inc( int x ) { in += x; } };
Do tego możemy utworzyć wskaźniki do pola 'in' i metody 'Set':
int A::*pf; void (A::*pm)( int );
Można im przypisywać różne pola i różne metody:
pf = &A::in; pm = &A::Inc;
Natomiast żeby się odwołać poprzez wskaźnik pf do pola, trzeba napisać:
A a; a.*pf = 3;
Natomiast można wywołać również metodę na rzecz takiego obiektu:
(a.*pm)( 3 );
Jeśli obiekt jest trzymany za wskaźnik, to używamy, jak się można domyślić, operatora
Do czego to można wykorzystać? No cóż, w sumie nie ma tutaj innego pola wykorzystania, niż w przypadku zwykłych wskaźników do danych i funkcji. Jeśli np. jakaś struktura ma kilka metod o tej samej sygnaturze, można by zrobić tablicę asocjacyjną ze wskaźnikami do metody. Podobnie możemy np. ugenerycznić sobie dostęp do odpowiednich fragmentów obiektu za pomocą odwoływania się do jego pól nie bezpośrednio, lecz przez wskaźnik.
Przypominam jednocześnie, że wskaźnik na metodę - tak jak na funkcję - nie konwertuje się na void*. Co prawda w przypadku funkcji to można by jeszcze powiedzieć, że no... i tak większość systemów ma teraz wskaźniki do funkcji tej samej wielkości, co void*. Można się na tym kiedyś przejechać, ale to jeszcze nie jest groźne (i tak np. uniksowa dlsym korzysta z tego założenia). W przypadku metod jest jeszcze gorzej. Wyjaśnię może to na podstawie budowy wskaźnika do metody według implementacji gcc.
Wskaźnik do metody musi bowiem działać prawidłowo niezależnie od tego, jak bardzo skomplikowana jest definicja metody (mam na myśli komplikacje wprowadzane przez dziedziczenia i wirtualność). To oznacza, że wskaźnik na metodę nie będzie absolutnie czymś tak banalnie prostym, jak wskaźnik do funkcji. Wskaźnik do funkcji to tylko jeden z elementów wskaźnika na metodę (należałoby tu mówić również o funkcji, która jest tzw. implementacją metody, a wskaźnik na tą funkcję to jeden z elementów wskaźnika na metodę). Istnieją tutaj dwie dość istotne sprawy, o których należy wspomnieć. Związane zresztą z wielorakim dziedziczeniem. W sumie odwoływanie się teraz do tego nie jest najlepszym pomysłem, kiedy akurat dziedziczenie i wirtualność opisuję nieco dalej, ale wydaje mi się, że dzielenie tego na dwa kawałki nie jest lepsze (zatem polecam zapoznanie się z dziedziczeniem wielorakim, żeby było wiadomo, o czym mowa).
Pierwsza to kwestia rozmieszczenia obiektów przy dziedziczeniu wielorakim. Tam, jeśli są dwa obiekty bazowe (lub więcej, ale skupmy się na najprostszym przypadku), to pierwszy z nich będzie miał wskaźnik do siebie identyczny liczbowo ze wskaźnikiem na cały obiekt. A drugi już niestety nie; będzie przesunięty względem "początku całego obiektu" o rozmiar pierwszego (plus wyrównanie). Różnica ta nazywana jest 'delta' (przynajmniej przeze mnie :). I z czym jest zatem problem? Otóż jeśli w strukturze pochodnej nie przedefiniowano metody z klasy bazowej (tej drugiej), to wywołanie tej metody na rzecz obiektu klasy pochodnej wywoła tą metodę z klasy bazowej. Jeśli więc mamy wskaźnik do takiej metody jako wskaźnik do metody klasy pochodnej, to wiadomo, że jej wywołanie będzie potrzebowało jako "this" wskaźnika na podobiekt tej klasy bazowej, z której ta metoda pochodzi. Czyli inaczej:
struct A { int x; }; struct B { int y; void Miau() { y = 0; } } struct C: A, B { int a; }; ... void (C::*pm)() = &C::Miau; ... C c; (c.*pm)();
Co się tutaj stanie? Wywołujemy na rzecz obiektu typu C metodę, która teoretycznie jest metodą struktury C. Teoretycznie, bo tak naprawdę zostanie wywołana metoda z B. Gdyby była to metoda z C, to wtedy w jej wywołaniu 'this' (typu C* const) wskazywałoby cały obiekt typu C. Ponieważ jednak Miau jest zdefiniowane w klasie B, więc wewnątrz niej 'this' jest typu B* const. Co by się stało, gdyby wskaźnik na cały obiekt typu C został "zamieniony" na wskaźnik do typu B, łatwo przewidzieć - początek obiektu typu C (czyli ten fragment, w którym siedzi podobiekt typu A) zostałby potraktowany jak obiekt typu B. Na szczęście nic się takiego nie dzieje. No dobrze, ale jakoś trzeba sobie z tym radzić. Przecież 'pm' jest wciąż ewidentnie wskaźnikiem na metodę z C i to obiekt tego typu zostanie tam "podany" jako 'this', a Miau potrzebuje nadal obiektu typu B.
Właśnie do radzenia sobie z tym służy delta. Delta odpowiada za skorygowanie wskaźnika this właśnie specjalnie dla takich przypadków. Nietrudno się domyślić, że gdyby była to metoda ze struktury A, to delta wynosiłaby zero (gdyby była przedefiniowana w klasie C, to też, zresztą). Zatem zanim się wywoła tzw. implementację metody (czyli funkcję, która za nią odpowiada), najpierw czyta się deltę i sumuje z wartością wskaźnika, który podano tam jako 'this' (czyli w tym wypadku wskaźnik typu C* zostanie przekazany jako B* do Miau, ale po zsumowaniu z wartością 'delta'). A wspomniana delta jest jednym z elementów wskaźnika na metodę. Czyli - mówiąc w skrócie - jeśli żądano pobrania wskaźnika na Miau ale tak jakby było metodą klasy C, a faktycznie jest to tylko metoda przejęta przez C z klasy B, to wtedy wskaźnik na metodę zawiera oczywiście ten sam wskaźnik na funkcję, co &B::Miau, ale delta zawiera wartość będącą różnicą pomiędzy początkiem całego obiektu, a początkiem podobiektu klasy B. Gdyby natomiast była faktycznie metoda zdefiniowana jako C::Miau, to w tym przypadku delta wynosiłaby zero.
Drugi problem ze wskaźnikami na metody jest nieco podobny, ale równie skomplikowany (cokolwiek by to nie znaczyło :). Otóż tu sprawa rozbija się o metody wirtualne. Metody wirtualne najczęściej są implementowane za pomocą tablicy wskaźników do funkcji (dokładnie to wskaźników na ich implementacje, zgodnie z tym, co mówiłem wyżej). Wskaźnik do tej tablicy zawiera się w każdym obiekcie takiej klasy. Zatem wołanie konkretnej metody wirtualnej polega na wyciągnięciu wskaźnika z tej tablicy po zaindeksowaniu jej wartością charakterystyczną dla nazwy metody. Każda z metod ma zatem swój unikalny, dokładnie określony indeks.
Ale niestety nie do końca. Co się stanie, jeśli - jak w powyższej sytuacji - dojdzie do dziedziczenia wielorakiego, a obie struktury bazowe mają swoje metody wirtualne? Załóżmy, że klasa A ma ich 3, a B - 2. Ile będzie ich miała klasa C? To oczywiste, 5. No dobrze, ale jaki indeks będzie miała w klasie C metoda, która w klasie B ma indeks 1? To chyba niestety nadal oczywiste - nie będzie miała 1, tylko 4. Jak zatem widać, problem jest dość podobny. Zatem drugi dodatkowy składnik naszego wskaźnika na metodę to indeks, który decyduje o tym, gdzie zaczyna się fragment tablicy metod wirtualnych odziedziczonych z danej klasy bazowej - i podobnie jak w przypadku delty, dla pierwszej w kolejności dziedziczenia klasy jest on równy zero.
No dobrze, ale do czego on może być potrzebny? Wyobraźmy sobie zatem taką sytuację: Mamy jak powyżej hierarchię, struktury A i B zawierają metody wirtualne. Wołamy teraz przez wskaźnik na metodę jakąś metodę z klasy B, ale nie przedefiniowaną w strukturze C (nie jest istotne, czy ona jest wirtualna, to jeszcze nie ta scena :). Jak wspomniałem zatem, ta metoda, choć została wywołana na rzecz obiektu typu C, musi być niezbicie przekonana, że pracuje na obiekcie typu B. Dlatego właśnie ten cały this zostaje skorygowany o deltę, żeby wyciągnąć z niego poprawną część. I teraz wyobraźmy sobie, że wspomniana metoda wywoła inną metodę na rzecz obiektu typu B, która jest wirtualna. Ma ona jakiś indeks wewnątrz tablicy metod wirtualnych typu B. Nasza naiwna metoda zatem sięga do tego obiektu typu B i wyciąga z niej wskaźnik na tablicę metod wirtualnych. Gdyby teraz był to wskaźnik taki, jak normalnie na tablicę metod wirtualnych dla typu C, to byłaby kiszka; wywołałoby się coś zupełnie innego. Do tego właśnie potrzebny jest indeks, żeby przesunąć ten iterator do tablicy metod wirtualnych o jego wartość, żeby dotrzeć do tego fragmentu, który interesuje metodę z klasy B. Jak więc widać, obie te rzeczy są ukierunkowane na udawanie przed metodą klasy bazowej, że obiekt klasy pochodnej jest niby obiektem klasy bazowej.
No i tak, owszem; napisałem to głównie z uwagi na to, jak ja to sobie wyobrażam; nie wiem, czy jest możliwe zrobić to lepiej. Co do delty bowiem istnieją alternatywne rozwiązania - zamiast delty stosuje się alternatywny wskaźnik do funkcji. Po prostu wskaźnik nie jest wskaźnikiem do implementacji metody, lecz do pewnego kawałka kodu, który dokona korekcji this o deltę i dopiero wywoła właściwą implementację. Taki kawałek kodu jest zwany łącznikiem (ang. thunk - określenie pochodzi z terminologii Algola). Kompilator gcc pozwala korzystać z tej implementacji jako alternatywy. Jeśli nie stosuje się dziedziczenia wielorakiego, to nawet warto, bo zmniejsza to obciążenie dla wszystkich wywołań metod z klasy bazowej (aczkolwiek niewielkie). Jeśli się je stosuje - nie warto, bo obciążenie przez łącznik jest już większe, niż przez samą deltę (bo korekcja o deltę jest wykonywana i tak). Jeśli zaś chodzi o alternatywne metody radzenia sobie z tym drugim problemem - nic o tym nie wiem.
W każdym razie, nie radzę próbować z konwertowaniem wskaźnika na metodę na cokolwiek innego. Również jeśli chodzi o dlsym, radzę tego jednak używać z funkcjami (bo do tego on właśnie służy), a nie z metodami (w gcc pola delta i index są na początku, a nie na końcu struktury wskaźnika do metody).