C++ bez choletsterolu

Przeciążanie

Przeciążanie funkcji

Wspomniałem w poprzedniej części, w rozdziale o funkcjach, że nazwa funkcji nie stanowi jednoznacznej identyfikacji funkcji. Powód tego jest taki, że może istnieć wiele funkcji o takiej samej nazwie, różniące się tylko listą argumentów. Nic zatem nie stoi na przeszkodzie, aby zdefiniować sobie coś takiego:

float potega( float a, unsigned x ) {
  if ( x == 0 )
    return 1;
  float r = a;
  for ( int i = 2; i <= x; i++ )
    r *= a;
  return r;
}

float potega( float a, float x ) {
  return exp( x*log( a ) );
}

Co prawda to jest dla praktyki bezsensowny przykład (zwłaszcza, że w potęgowaniu całkowitym pominąłem liczby ujemne), ale mam nadzieję, że dobrze obrazuje przeciążanie. Można wywoływać je w następujący sposób:

cout << potega( 2, 10 ) << endl; // wywoła się pierwsza wersja
cout << potega( 2, 10.5 ) << endl; // wywoła się druga wersja

ale:

float x = 5;
cout << potega( 2, x ) << endl; // wywoła się druga wersja

Kompilator dobiera na podstawie postaci wywołania odpowiednią wersję funkcji.

Proszę pamiętać, że zasady rozstrzygania niejednoznaczności wywoływania funkcji są niekiedy dość ścisłe, ale często też zbyt luźne, więc zaleca się ostrożność. Proszę nie zapominać, że podczas wywoływania funkcji kompilator próbuje dopasować postać wywołania do konkretnej wersji na różne sposoby. Bywają zatem różne dość typowe sytuacje, m.in.:

  1. Wywołanie da się dopasować do dwóch różnych nagłówków z tym samym priorytetem. W takiej sytuacji kompilator odrzuci taki kod, jako niejednoznaczny.
  2. Wywołanie zostanie dopasowane do jednego z nagłówków z tego powodu, że dany argument ma dokładnie taki typ, jaki występuje w zapowiedzi (nawet jeśli można ten argument przekonwertować na typ, który występuje w innym nagłówku). Ponieważ takie sytuacje zdarzają się często, kompilator nie ostrzega (tzn. te nowsze wersje, dostosowane do standardu, bo bardziej anachroniczne np. Borland C++ 3.1 nie rozstrzygają tej niejednoznaczności, uważając ją za błędną).
  3. Wywołanie może zostać dopasowane do jednego z nagłówków z najwyższym priorytetem (tzn. żaden z nagłówków nie ma dokładnie takiego typu argumentu, ale jeden z nich do danego argumentu najlepiej "pasuje"). Kompilator zazwyczaj ostrzega, ale nie zawsze (tu również istnieją różne typowe przypadki, więc kompilator nie powinien sypać ostrzeżeniami). Zresztą, według standardu, nie ma takiego obowiązku. W tym punkcie ostrzegam przede wszystkim przed nagłówkami, które różnią się argumentem typu CAŁKOWITEGO i WSKAŹNIKOWEGO!!! Jeśli zamierzy się wywołać taką funkcję w wersji ze wskaźnikiem i poda się tam jako argument `0', można się bardzo zdziwić (dla ludzi stosujących NULL mam też kubeł zimnej wody na głowę: jedyną prawidłową w C++ definicją NULL jest po prostu 0; jedynie GNU C++ NULL deklaruje jako __null, co jest zerem wskaźnikowym, jednak finalnego kodu to nie zmienia - zyskuje się tylko dodatkowe ostrzeżenie). Należy tutaj - w przypadku konieczności takiego przeciążania BEZWZGLĘDNIE wprowadzać lokalne postacie stałej w rodzaju null, której będzie się tam używać (proponuję skorzystanie z mojej wersji `null', która jest przedstawiona w przy wzorcach).
  4. Jedna z postaci ma powiedzmy 3 argumenty, a druga 4, przy czym pierwsze 3 są identyczne jak w pierwszej, a czwarty jest domyślny. Jest to oczywiście niedostateczne zróżnicowanie i zostanie przez kompilator odrzucone.

Ktoś spyta pewnie, po co tyle strzępienia języka, przecież nikt nie każe nam przeciążać funkcji. Niestety szkopuł w tym, że istnieją takie sytuacje, choćby przeciążanie konstruktora albo operatorów (patrz niżej)...

Przeciążanie operatorów

Skomplikowane? Nic podobnego. Po prostu tylko troche inna nazwa funkcji. Mały przykładzik na początek (tak standardowy, że aż boli):

struct Complex {
  float re, im;
  Complex( float a=0, float b=0 ): re(a), im(b) {}
};

Complex operator+( const Complex& c1, const Complex& c2 ){
  return Complex( c1.re + c2.re, c1.im + c2.im );
}

i podobnie reszta operatorów. Oczywiście można operator zadeklarować jako metodę:

Complex Complex::operator+( const Complex& c ) const {
  return Complex( re + c.re, im + c.im );
}

Niezależnie jednak od sposobu zadeklarowania, można to wywoływać w ten sposób:

Complex z1( 5, 6 ), z2( 2, 3 );
Complex z3 = z1 + z2;

W rozdziale o operatorach jest dokładny opis nagłówków operatorów. Jedną z najważniejszych różnic w stosunku do funkcji jest to, że nie mogą mieć one argumentów domyślnych (nawet operator ()). Przeciążać można prawie wszystkie operatory, co niektóre są tylko w rozdziale o operatorach oznaczone, że nie można ich przeciążać. Argumenty operatorów muszą spełniać jednak jeden wymóg: muszą być typu zadeklarowanego zewnętrznie (przez użytkownika lub plik nagłówkowy), a więc któregoś z tych zadeklarowanych przy pomocy słowa struct, union, enum i class. Zresztą dla typów enum jest to często nieodzowne; nie posiadają one bowiem żadnych operatorów, więc żeby móc traktować typy wyliczeniowe jak typy porządkowe (bez konieczności niejawnego konwertowania ich na int i jawnego z powrotem), trzeba im poprzeciążać operatory.

Definicja operatora nie musi mieć nic wspólnego z jego pierwotnym znaczeniem. Poznane już operatory << i >> przeciążone dla klas ostream i istream nie mają nic wspólnego z ich znaczeniem jako przesunięć bitowych. Oczywiście nie wszystkich operatorów to dotyczy; pragnę przede wszystkim zwrócić uwagę na następujące:

  1. operator przypisania `='; należy do operatorów, które mogą być zdefiniowane tylko jako metoda. Tutaj raczej nie wolno robić sobie z tym operatorem "co się zechce", gdyż prawa, jakim podlega w C++ raczej wykluczają go z innych zastosowań, niż te, do których jest on przeznaczony
  2. operatory wyłuskania spod wskaźnika `->' i `->*'; mają ograniczenia co do argumentów zwracanych (mogą zwracać tylko i wyłącznie "rzeczywisty wskaźnik", spod którego uzyskuje się pole) i mają zastosowanie tylko w klasach będących "inteligentnymi wskaźnikami" (np. wspomniany `auto_ptr')
  3. operator adresu `&'; należy do operatorów używanych domyślnie w razie niezdefiniowania. Definiując ten operator w "dowolny" sposób można łatwo uniemożliwić pobranie adresu obiektu (tzn. dokładnie to uniemożliwić tym, którzy robią to operatorem &, bo uniemożliwić całkiem to się akurat nie da; polecam obejrzeć boost::addressof)

Funkcja zadeklarowana jako operator jest oczywiście dostępna pod nazwą `operator <op>()' (ewentualnie z odpowiednim nagłówkiem zasięgu, jeśli jest metodą klasy). Może być nawet tak wywoływana.

Podam jeszcze parę dodatkowych rzeczy nt. operatora przypisania. Jest on dość wyjątkowym operatorem i jego sposób definiowania jest "niepisanym standardem".

struct Complex {
  float real, imag;
  Complex& operator=( float nreal ) {
    real = nreal;
    imag = 0;
    return *this;
  }
};

Teraz mamy możliwość przypisania wartości typu `float' do zmiennej typu `Complex'. Nie zmienia to jednak faktu, że nadal jest dostępny operator przypisania, który przyjmuje argument `const Complex&' i powoduje przepisanie wartości pól. Zwróćmy jednak uwagę na ostatnią instrukcję (oraz oczywiście typ zwracany). Do czego to służy? Oczywiście, do umożliwienia wielokrotnych przypisań. Zatem dla `Complex x' wartością wyrażenia x = 2 będzie referencja do `x'.

Teraz pytanko: czy operator przypisania może działać dla obiektów stałych? Pytanie jest może głupie, ale - ku zaskoczeniu - odpowiedź brzmi: tak (tzn. konkretnie kompilator nie może tego zabronić). Jednak - jak widzieliśmy - operator przypisania zwrócił tutaj `Complex&', zatem gdyby operator = był const (a co za tym idzie, obiekt wskazywany przez `this' jest wtedy const) musiałby on również zwrócić `const Complex&', jeśli nie chciałby naruszać przeciwwariancji. Ale to jeszcze nic. Dowcip polega na tym, że wewnątrz takiego operatora nie byłoby możliwe przypisywanie do czegokolwiek, bo - po uczynieniu stałym *this - wszystkie pola struktury są stałe. Należy zatem pamiętać, że mimo konkretnych przeznaczeń niektórych operatorów, tylko od użytkownika zależy, czy zaprogramuje jego znaczenie w sensowny, czy w bezsensowny sposób.

W definiowaniu operatorów zaleca się maksymalny umiar i rozsądek; w szczególności nie należy w żadnym wypadku nadawać operatorom takiego znaczenia, które w danym kontekście mogłoby nie być oczywiste. Wiele problemów w projektach często wiązało się z tym, że nie dało się zgadnąć, "co autor miał na myśli" definiując jakiś operator.

Wśród operatorów pominąłem jeszcze rzutowanie. Można bowiem zdefiniować dla danej struktury operator rzutowania. Będzie on używany wtedy, gdy będzie konieczne wykonanie konwersji (również niejawnej!). Deklaracja jest prosta:

struct Int {
  int in;
  ...
  operator int() { return in; }
};

W ten sposób możemy deklarować operator konwersji na absolutnie dowolny typ. Wbrew pozorom, jest to jedna z rzeczy, która daje C++ ogromne możliwości. Nic nie stoi bowiem na przeszkodzie, żeby obiekt takiej klasy został użyty w bardzo nietypowy sposób, tzn. jakby był funkcją. Przykładowo następująca klasa zachowuje się jakby była funkcją:

struct A2I {
  const char* str;
  A2I( const char* n ) : str( n ) {}
  operator int() { return atoi( str ); }
};

Może i bezsensowne, ale to tylko przykład. Wywołanie A2I("112") można przypisać do l-wartości typu int& lub przekazać jako argument, a zwróci ono liczbę 112. Jeszcze większe możliwości operatory konwersji mają z użyciem wzorców, co będzie pokazane przy deklaracji null.

Zanim zakończę ten temat, omówię jeszcze przeciążanie operatorów new i delete. Operatory te można przeciążać zarówno globalnie, jak i lokalnie na daną strukturę (oczywiście nie zmienia to ich postaci wywołania). Jednak ich postać i znaczenie nieco odbiega od "normalnych" operatorów.

Przede wszystkim zaś, ich postać ze słowem operator, o której wspomniałem to funkcje o następujących nagłówkach:

void* operator new( size_t );
void operator delete( void* );

I nie są to normalne funkcje zastępujące operatory new i delete! To są tylko ich części odpowiedzialne za przydział pamięci. Tak też wywołanie operatora new normalnie będzie zupełnie czym innym, niż wywołanie funkcji `operator new'; pierwsze bowiem wywoła konstruktor typu, drugie zaś nie (bo niby skąd miałby coś wiedzieć o typie?). Na dodatek zresztą funkcja `operator new' zwraca typ `void*', który normalnie w C++ trzeba przecież dopiero przekonwertować (jak więc widać, sam operator dokonuje jeszcze statycznej konwersji na wymagany typ). Dokładnie nawet możnaby jego działanie określić tak:

Klocek* k = static_cast<Klocek*>(
  operator new( sizeof (Klocek) )
);
construct( *k );

Funkcja `construct' (jak i `destroy') jest zdefiniowana w bibliotece STL. Oczywiście podałem to `construct' w małym uproszczeniu, bo przecież argumenty konstruktora bywają różne.

Do tych operatorów dochodzi jeszcze wiele różnych wariantów. Pierwsza postać, którą przedstawiłem, jest właściwie najprostsza i zdefiniowanie tych funkcji przeciąża operatory new i delete globalnie, tzn. ustala sposób przydziału (i zwalaniania) pamięci dla wszystkich typów (jeśli mówiłem kiedyś, że C++ jest językiem rozszerzalnym, a nie zmienialnym, to zaznaczam od razu, że to jest właśnie w tym języku mały wyjątek).

Teraz następna bajka. Dla tablic przydzielanie pamięci będzie się odbywało tak, jak powyższymi operatorami, dopóki nie przeciążymy operatorów:

void* operator new[]( size_t );
void operator delete[]( void* );

Dodatkowo oczywiście, te operatory można przeciążyć wewnątrz struktury. Będzie się je wywoływać tak samo, ale w przypadku próby przydzielenia pamięci dla obiektu określonego typu, użyje się tak właśnie zadeklarowanego operatora new. Podobnie delete, który zostanie rozpoznany przez typ podanego mu wskaźnika.

Oczywiście to, co napisałem o operatorach z [] jest słuszne również i tu. Proszę jednak zwrócić uwagę, że funkcja operator new otrzymuje jako argument wielkość pamięci do przydzielenia. Jeśli zadeklarujemy to wewnątrz struktury, to łatwo się domyślić, że dla typu `Klocek' wywołanie `new Klocek' przekaże funkcji `Klocek::operator new' wartość `sizeof (Klocek)'. Jednak dla operatorów tablicowych będzie to ta wartość pomnożona przez wielkość żądanej tablicy. Poza tym nie zapominajmy o tym, że to jest normalny operator i podlega dziedziczeniu - co więc za tym idzie, jeśli wywoła się go na rzecz obiektu klasy pochodnej od tej, w której zadeklarowano operator new, to wywoła się dokładnie ta sama metoda, co wywołałaby się na rzecz obieku klasy pochodnej, z tym tylko, że z właściwym argumentem typu size_t. Odradzam zatem ignorowanie tego argumentu i posługiwanie się `sizeof (Klasa)'. Chyba, że nie zamierzasz dziedziczyć po klasie, w której ten operator zadeklarujesz.

Ale to jeszcze nie wszystko. Operatory te mogą mieć jeszcze drugi argument (a nawet trzeci itd.) i to całkiem dowolnego typu (pozwala to m.in. na uniemożliwienie wywołania tego operatora dla odpowiednich klas, jak też wybranie odpowiedniego wariantu przydzielania czy zwalniania pamięci). Istnieją jednak standardowe postacie takich operatorów (używanie ich wymaga nagłówka <new> lub <new.h>).

Jedna z nich to ta o drugim argumencie typu `void*', który nazywa się składnią umieszczania (ang. placement; oczywiście tylko operatory new i new[]):

Klocek* k = new(x) Klocek;

Ich normalny efekt działania (tzn. dokładnie to ich części odpowiedzialnych za przydział pamięci) jest dokładnie żaden; po prostu podany "drugi" argument (tutaj x) jest zwracany przez operator new. Ta wersja operatora new jest jedynie niejako "wymuszeniem wywołania konstruktora". Korzysta z niej funkcja z biblioteki STL `construct'.

Inną standardową wersją tego operatora z drugim argumentem jest podanie takiego wyłącznie "markującego" argumentu "nothrow":

Klocek* k = new(nothrow) Klocek;

Normalnie, gdyby przydział pamięci się nie udał, następuje wyjątek (wyjątki będą opisane w następnym rozdziale) typu bad_alloc. Ta przedstawiona wersja jednak nie rzuca wyjątkiem, lecz normalnie - podobnie jak malloc - zwrcaca pusty wskaźnik.

Przeciążanie a wskaźnik do funkcji

Przede wszystkim, czym jest obiekt oznaczony nazwą funkcji? Jest to obiekt funkcyjny, który dodatkowo może się niejawnie konwertować na wskaźnik do funkcji. Zapytajmy jednak dalej - czym jest obiekt oznaczony nazwą przeciążonej funkcji? Cóż, to już nie jest takie proste. Obiekt taki, owszem, nadal może się konwertować na wskaźnik do funkcji... no ale której? Jeśli funkcja jest przeciążona, to istnieją dwie funkcje, a oznacza się je jednym identyfikatorem obiektu.

Sytuacja ma się w tej kwestii mniej więcej podobnie do opisanego wcześniej zdefiniowanego wewnątrz struktury operatora konwersji. Wewnątrz struktury możemy sobie bowiem zdefiniować kilka różnych operatorów konwersji. Tylko który z nich zostanie wywołany? Tutaj akurat sprawa jest prosta, pod warunkiem, że... znów nie przekazujemy do funkcji ewentualnej wartości po przekonwertowaniu, która to funkcja jest przeciążona na więcej, niż jeden z tych typów, na który ta struktura ma się konwertować. Przykładowo:

struct Number {
  double d;
  int i;
  operator int() { return i; }
  operator double() { return d; }
};

int f( int );
int f( double );

...
Number n;
...
f( n ); // czyli co?

To jest już niejednoznaczność. Kompilator nie może rozstrzygnąć, czy powinien użyć f(int), czy f(double), bo obie możliwości są równie dobre.

Pójdźmy jednak dalej i utwórzmy sobie wskaźniki do funkcji. Tu jeszcze nie ma problemu, bo wskaźnik i tak musi uwzględniać typy argumentów i zwracany:

int f( int );
int f( double );

int (*p1)( int );
int (*p2)( double );

...
p1 = f; // czyli f( int )
p2 = f; // czyli f( double )

Tylko że jeśli zrobimy sobie funkcję, której będzie się przekazywać wskaźnik i przeciążymy ją na oba wskaźniki do funkcji, sytuacja robi się podobna:

int f( int );
int f( double );

int (*p1)( int );
int (*p2)( double );

int p( int (*)( int ) );
int p( int (*)( double ) );

...
p( f ); // czyli które?

Sytuacja ta dotyczy nie tylko przeciążania, ale również wzorców, o których będzie dalej. Tam jest jeszcze gorzej: następuje tam domniemanie typu przekazanego obiektu, więc w przypadku takiego 'f', jak wyżej, żadnego typu domniemać się nie da (obiekt 'f' jako taki nie ma żadnego jednoznacznego typu).

Problem jest możliwy do rozwiązania tylko poprzez odpowiednie rzutowanie, czyli np.:

p( static_cast<int(*)(int)>( f ) ); // czyli f(int)
p( static_cast<int(*)(double)>( f ) ); // czyli f(double)

Najczęstsze problemy jednak, jakie to powoduje, to właśnie wzorce. Wzorce takoż dostarczają również metodę radzenia sobie z tym problemem, z tym tylko, że wcale nie wyglądają one lepiej od static_cast (rzekłbym, różnią się od static_cast najwyżej długością nazwy).