C++ bez cholesterolu

Funkcje

Wiadomości podstawowe

Funkcje są podstawowymi jednostkami wykonawczymi i są jednym z elementów pozwalających w ogóle na jakiekolwiek projektowanie. Realizują one coś, co określało się zazwyczaj jako podprogram (ang. subroutine), aczkolwiek w C++ mają one nieco większe możliwości. Te funkcje możemy tylko wywoływać, umożliwiają one zatem programowanie proceduralne, jednak jeszcze nie funkcjonalne.

Jak wiemy, funkcja przed wywołaniem powinna być zdefiniowana, lub co najmniej zadeklarowana (zapowiedziana). Zapowiedź (nagłówek, stąd nazwa "plik nagłówkowy", .h) ma następującą postać:

<modyfikator> <typ_zwracany> <nazwa>(
  <typ1> <arg1>, <typ2> <arg2> <...>
);

Definicje funkcji wyglądają podobnie, z tym że zamiast ; mamy {...} zawierające "ciało" funkcji. Mamy tutaj następujące elementy:

<modyfikator>
Opcjonalny modyfikator funkcji, zostanie omówiony w następnym punkcie.

<typ_zwracany>
Jest to typ danej, jaka będzie zwrócona przez funkcję, jeśli jej wywołanie zostało umieszczone w wyrażeniu. Jak wiemy, wynik zwracamy instrukcją return, a funkcja nie zwraca wyniku jeśli wynik ma być void. Jednak uwaga: nie oznacza to, że w funkcji, która ma typ zwracany `void', instrukcja return nie może posiadać argumentu. Reguła jest tylko taka, że jej argument musi być typu pasującego do zwracanego, więc można jej podać również wynik funkcji, której typ zwracany jest void (ale uwaga: jest to właściwość nowa w stosunku do C).

<nazwa>
Nazwa funkcji, przez którą będzie ona wywoływana. Wbrew pozorom, w C++ nie jest to jednoznaczna identyfikacja funkcji; o jednoznacznej identyfikacji stanowi poza nazwą również lista typów argumentów (będzie to szczegółowo omówione przy właściwościach dodatkowych). Jest to jeden z powodów wymagania deklaracji funkcji przed jej użyciem.

<typ> <arg>
Deklaracja zmiennej lokalnej, przez którą będzie przekazywany argument. Element <arg> jest opcjonalny zarówno w deklaracji jak i w definicji i nie musi się on zgadzać w jednym i drugim (w deklaracji jest on elementem podobnym do komentarza). W definicji funkcji jest on konieczny tylko w przypadku, gdy funkcja korzysta z tak przekazanego argumentu (jeśli komuś owo stwierdzenie wydało się bezsensowne, to przytoczę tylko, że w przypadku tablic asocjacyjnych możemy mieć grupę funkcji, gdzie nie każda funkcja musi używać każdego argumentu, ale i tak musi mieć taki sam nagłówek, bo wskaźnik może być nie do byle jakiej funkcji, ale do funkcji o ściśle zdefiniowanym nagłówku). Jeśli funkcja nie korzysta z jakiejkolwiek zmiennej lokalnej (również na liście argumentów), kompilaror powinien wystosować ostrzeżenie.

UWAGA: ta ostatnia właściwość jest nowa w C++, w C istnieje obowiązek nazywania każdej zmiennej na liście argumentów, nawet jeśli nie jest ona przez funkcję używana. W C dodatkowo istnieje tzw. składnia K&R (Kernighan & Ritche) deklarowania argumentów funkcji, niekiedy można jeszcze takie spotkać w starszych programach w C (lub pisanych przez staroświeckich programistów):

int funkcja( a, b, c )
int a, b, c;
{ ... }

Tu zwrócę uwagę na jeszcze jedną rzecz: proszę starać się NIE nazywać argumentów funkcji "parametrami". Jest to bardzo paskudne pomylenie pojęć. To pojęcie zostało (z tego co na razie zauważyłem) wprowadzone i rozpowszechnione przez kompilatory Borlanda i Microsoftu (używa się go w dokumentacji i komunikatach błędów kompilatora; nie wiem, kto pierwszy zaczął tą głupotę, podejrzewam, że -- jak zwykle -- Borland, a M$ tylko zmałpował). To pojęcie takoż często może wystąpić w MSDN-ie. Nie wiem, w jaki sposób się ono tam wzięło i "kto za tym wszystkim stoi". Parametr jest to z definicji coś, co dopełnia pewien "parametryzowany" twór (izotop?) tak, aby stał się on jakimś jednolitym tworem. W C++ podpadają pod to przede wszystkim wzorce, ale również jak najbardziej też makra preprocesora (w zależności od PARAMETRÓW mają określony deasygnat). Natomiast funkcje działają inaczej: są już same w sobie jednolitymi tworami, a my jedynie dajemy im coś na wejście (argumenty) i zbieramy coś na wyjściu (wyniki), zgodnie z definicją FUNKCJI. Jaka jest różnica? Ano przede wszystkim taka, że to, co jest parametrem MUSI być ściśle zdefiniowane i znane w momencie kompilacji (nawet jeśli zostanie uzupełnione dopiero przy wiązaniu). Natomiast to, co zostanie skądś odczytane podczas działania programu i w danym miejscu może to przybierać różne wartości, to jest zmienna. Zmienna nie może być zatem parametrem.

Nazewnictwo funkcji

Każdy "twór definicyjny", czy też inaczej "entka" (ang. entity) w C++ ma swój unikalny identyfikator w zasięgu (i kontekście), w którym został zdefiniowany. Pod to nie do końca podpadają funkcje (było tak w C, ale w C++ już tak nie jest). Dzieje się tak za sprawą właściwości zwanej "przeciążaniem" (ang. overloading). Powoduje to, że kilka funkcji może mieć tą samą nazwę. Przeciążaniem, jako że jest to już raczej zaawansowany temat, nie będziemy się tu zajmować. Mówię to tylko tak w kwestii uświadomienia, że coś takiego istnieje i występuje, również w funkcjach zdefiniowanych w bibliotece standardowej.

Ponieważ w C++ powinno się móc używać funkcji C, które posiadają nadal unikalne identyfikatory, musiano w C++ wprowadzić parę dodatkowych reguł. Przede wszystkim jeśli chodzi o łączenie zewnętrzne, to funkcje C, tak samo jak funkcje w fortranie, czy asemblerze posiadają identyfikator zewnętrzny składający się jedynie z nazwy funkcji. W C++ tak już nie jest.

W przypadku zatem zaimportowania funkcji zdefiniowanych w jednym z powyższych języków, należy użyć deklaracji z `extern "C"' (dla symetrii - jak wiemy - istnieje też `extern "C++"'). Obowiązuje on dla konkretnej funkcji również jeśli jest ona zdefiniowana/zadeklarowana w bloku zasygnowanym przez ten modyfikator. Jak już wspomniałem, oznacza on, że funkcja jest w zasięgu zewnętrznym dostępna pod symbolem składającym się z jej nazwy. Jeśli taki modyfikator poprzedza deklarację funkcji, oznacza że funkcja ma być zaimportowana spod symbolu również składającego się wyłącznie z jej nazwy. Funkcji takich oczywiście nie można przeciążać (tzn. tylko jedna z funkcji nazwanych tym identyfikatorem może być extern "C").

Funkcje C mogą być oczywiście deklarowane także w przestrzeni nazw. Jednak efekt jest tylko w C++. Identyfikator taki nadal może wystąpić tylko raz i nie może mieć on kilku deklaracji w różnych przestrzeniach nazw. Tzn. dokładnie to może; jeśli jednak zadeklarujemy funkcję C np.

extern "C" void raise( int )

w przestrzeniach nazw moja:: i twoja::, to wtedy moja::raise i twoja::raise odnosi się do tej samej funkcji; w przypadku funkcji C++ już tak nie jest.

Funkcja main

Każdy program -- jak wiemy -- zaczyna się od funkcji o zastrzeżonej nazwie `main', która ma następujący standardowy nagłówek:

int main( int argc, char** argv, char** envp );

Oczywiście kompilator nie nakazuje nam używać tej postaci nagłówka (najkrótszy program jaki widzialem to `long main = <wartość>', ale nie żartujmy sobie ;*). Funkcja powinna jednak zwracać int (w niektórych systemach, np. AmigaDOS, wartość zwrócona przez main() jest interpretowana przez powłokę wywołującą program i w przypadku wartości niezerowej wypisuje stosowny komunikat o błędzie!). W przypadku main wyjątkowo można zapomnieć o return i wtedy domyślnie zwracane jest 0. Jednak jeśli typ zwracany jest `void', kompilator nie trudzi się wówczas ze zwracaniem wartości i wtedy może się zdarzyć, że w miejscu, w które powinna być wpisana zwracana wartość, będzie wartość osobliwa. Funkcja main() może jednak nie mieć argumentów; argumenty są potrzebne tylko wtedy, jeśli chcemy przekazać jakieś argumenty dla programu (z argumentu envp rzadko się korzysta).

W takim wypadku [to znaczy, gdy chcemy przekazać jakieś argumenty do programu - przyp.AP.] przydadzą się nam zmienne `argc' i `argv'. Są to odpowiednio ilość przekazanych argumentów i tablica argumentów. Zmienna `argv' jest tablicą, której elementy są typu char* (choć bezpieczniej jest używać const char*). Argument `argc' jest równy zawsze co najmniej 1, gdyż co najmniej jeden argument jest zawsze do programu dostarczany. Jest to mianowicie nazwa programu i jest umieszczona pod argv[0] (pod DOS-em jest to razem z pełną ścieżką, ale pod UNIXem już nie). W systemach unixowych ten argument jest często używany, np. jeśli jakiś plik z programem jest zapisany pod kilkoma nazwami (tzn. jako jeden plik, ale zajmujący kilka pozycji w katalogu), rozpoznaje on jako `co' został wywołany właśnie sprawdzając ten argument. Kolejne argumenty znajdziemy pod argv[1], argv[2] itd. Następny element tej tablicy za ostatnim argumentem jest równy zero. Istnieje wręcz dokładnie taka reguła, że argv[argc] == 0.

Trzeci argument, envp (jest oczywiście nieobowiązkowy) jest tablicą (podobną do argv) zawierającą zmienne środowiskowe w postaci "NAZWA=WARTOŚĆ". Nie jest jednak konieczne korzystanie z tej tablicy, bowiem ułatwiają to specjalne funkcje getenv i setenv (opisane w rozdziale na temat signal.h).

Modyfikatory funkcji

Poznaliśmy już modyfikatory `static' i `extern'. Przypomnę więc, że dla funkcji modyfikator `static' oznacza, że funkcja ma zasięg wewnętrzny i nie jest dostępna w innych jednostkach kompilacji. `extern' natomiast nie oznacza nic, bowiem sygnowaną przez niego właściwość funkcja posiada domyślnie (również w deklaracji).

Bardzo ważnym modyfikatorem funkcji jest `inline'. Oznacza on, że funkcja powinna zostać rozwinięta w miejscu wywołania (o tym, czy tak faktycznie będzie zadecyduje kompilator, więc nie można być tego pewnym). Używaj go jednak zawsze, jeśli funkcja jest niewielkich rozmiarów i zależy Ci na tym, żeby wykonanie zawartych w niej operacji nie opóźniało się przez proces wywołania i powrotu. Funkcje inline dodatkowo dają większe pole do popisu kompilatorowi w dziedzinie optymalizacji.

Zapamiętaj, że jeśli modyfikator `inline' został umieszczony w definicji funkcji, to funkcja taka jest z założenia statyczna (może być zatem umieszczana w plikach nagłówkowych). Jeśli zaś w deklaracji - funkcja ma normalnie łączność zewnętrzną. Niestety jeśli kompilator nie współpracuje z programem łączącym, który potrafi rozwijać funkcje, modyfikator inline w takich sytuacjach psu na buty się zda (taka sytuacja występuje najczęściej na systemach, na których kompilator jest częścią systemu, np. na uniksach; gwoli ścisłości zresztą nie słyszałem o tym, żeby JAKIKOLWIEK kompilator wspierał tą właściwość). Z kolei jeśli gdzieś jest pobierany wskaźnik do danej funkcji, modyfikator inline jest także ignorowany (zaznaczam jednak, że kompilator, mimo rozwijania, może wygenerować wesję "outline" takiej funkcji; podlega ona wtedy tzw. słabemu wiązaniu - ang. vague linkage).

Tu jeszcze muszę zwrócić uwagę, na właśnie powiedzianą nieścisłość: funkcja inline wcale nie jest statyczna (ale oczywiście można ją taką uczynić). Standard wspomina jedynie, że funkcja inline powinna być ZDEFINIOWANA w każdej jednostce kompilacji osobno (czyli ściśle rzecz biorąc, należy je deklarować TYLKO w plikach nagłówkowych albo tylko na terenie jednej jednostki kompilacji). Oczywiście spokojnie można tą funkcję traktować jako statyczną z tym tylko że nie wolno w różnych jednostkach kompilacji definiować funkcji o tym samym nagłówku i innym ciele, bo wtedy wyjdzie mały efekt uboczny. Ogólnie więc funkcje inline należy definiować albo w plikach nagłówkowych, albo na terenie jednej jednostki kompilacji jako statyczne. Jakie tu mogą nas czekać efekty uboczne? Na przykład: M$ Visual C++ dla funkcji inline nie dostarcza wersji normalnej danej funkcji. To w praktyce oznacza, że jeśli mamy część plików skompilowanych z rozwijaniem i część bez rozwijania, to Visual Studio nie rozpozna, że trzeba wszystko przekompilować; przekona się o tym dopiero użytkownik jak zgłupieje na widok błędów wiązania. Natomiast gcc do każdej funkcji inline (zewnętrznej) dostarcza również wersję zewnętrzną, dzięki czemu wszelkie zewnętrzne użycia tej funkcji będą działać. Jeśli jednak zrobimy tak, że w dwóch różnych jednostkach kompilacji zdefiniujemy dwie funkcje inline jako zewnętrzne, to kompilator może zrobić różne rzeczy: może wywołać w każdym pliku to, co trzeba, ale równie dobrze może wywołać w obu jednostkach kompilacji funkcję z pierwszej lub drugiej jednostki kompilacji (w zależności od kolejności plików w poleceniu wiązania, a czasem nawet w zależności od opcji optymalizacji). Gdyby nie inline, to byłby błąd podczas wiązania (typu dwa symbole o tej samej nazwie; to się właśnie nazywa "silne wiązanie"), ale dla inline ta restrykcja jest akurat złagodzona (te bowiem podlegają właśnie "słabemu wiązaniu"). Proszę więc pamiętać, że funkcje inline są albo w plikach nagłówkowych, albo statyczne.

Przekazywanie argumentów do funkcji

Znamy już sposób przekazywania do funkcji argumentów przez wartość. Jest to jednak sposób dobry pod warunkiem, że jest to typ o niewielkim rozmiarze (za taki możemy przyjąć typ int i każdy typ wskaźnikowy) oraz że wartość reprezentowana przez przekazany argument będzie używana wyłącznie jako p-wartość.

Jak bowiem odbywa się przekazywanie argumentów typu całkowitego lub wskaźnikowego? Jest to oczywiście kwestia implementacji, ale nawet tu możemy zawęzić te możliwości. Może być ona przekazana na stosie, jeśli platforma sprzętowa posiada coś takiego jak stos. W zarezerwowanej dla niego pamięci zatem tworzy się kopię przekazanej wartości. Może być przekazana przez rejestry procesora, jak to ma miejsce w przypadku procesorów Sparc i często też MC68k. To drugie jednak nie jest możliwe, jeśli wartość była takiego typu, który nie mieści się w rejestrze. Na stosie oczywiście jest to możliwe, ale jeśli chcemy przekazać obiekt o dużym rozmiarze, zajmuje on sporo miejsca na stosie; mogłoby to być niebezpieczne, bowiem stos jest ograniczonym zasobem i program może się nam całkiem nieoczekiwanie wysypać (dobre systemy operacyjne starają się być pod tym względem przewidujące, no ale bez przesady...). Dodatkowo zaś wykonywanie po kilkanaście tymczasowych kopii dużego obiektu ze względu na swój czas trwania zakrawa na śmieszność. Z tego względu dla obiektów większych niż obiekty typów ścisłych należy przekazywać przez odpowiednie odniesienia.

Inna właściwość, która się co prawda z poprzednią nie wiąże w sposób logiczny, ale której brak jest również mankamentem przekazywania przez wartość, to fakt, że wewnątrz funkcji nie mamy żadnego wpływu na zmienne, jakie jej zostały przekazane. Klasycznym przykładem, który to obrazuje, jest funkcja o nazwie `swap', której zadaniem jest zamienić miejscami wartości w dwóch zmiennych np. typu int. Ponieważ jestem z natury złośliwy, dodam od siebie, że takiej funkcji NIE DA się jakimkolwiek sposobem zrobić w języku Java (i prawdopodobnie też w innych, podobnych jej językach):

void swap( int a, int b ) {
  int temp = a;
  a = b;
  b = temp;
  // i co?
}

Wywołujemy funkcję:

int main() {
  int a = 0, b = 5;
  swap( a, b );
  cout << "a = " << a << ", b = " << b << endl;
  return 0;
}

... i okazuje się że nic się nie zmieniło. Funkcja bowiem nie otrzymała zmiennych, lecz kopie ich wartości, a operuje wyłącznie na własnych zmiennych lokalnych.

Jak to upolować? W języku C rozwiązywało się to przekazaniem przez wskaźnik. Przekazywana jest co prawda nadal wartość, ale tą wartością jest wskaźnik do zmiennej, wystarczy więc posłużyć się wyłuskiwaniem:

void swap( int* a, int* b ) {
  int temp = *a;
  *a = *b;
  *b = temp;
}

Niestety, konieczna jest zmiana sposobu wywołania funkcji:

swap(&a, &b);

Brzydko to wygląda, nieprawdaż? W C++ coś takiego byłoby nie do pomyślenia. Z ludzi programujących w C kogo szlag nie trafiał, kiedy trafiło mu się popełnić taki błąd w programie:

  int a;
  ...
  scanf( "%i", a );

Funkcja scanf wczytuje znaki ze strumienia wejściowego i interpretuje je według podanego jako pierwszy argument oznaczenia typu (będzie ona jeszcze dokładniej opisana przy opisie całej biblioteki). Funkcja scanf jest jednak funkcją uniwersalną i może rozkładać wczytane znaki na czynniki pierwsze podle podanego jako łańcuch tekstowy schematu, może więc wyciągać z niego więcej, niż jeden argument; z tego względu jest funkcją o zmiennej liście argumentów. Dlatego też kompilator nie sprawdza, co też zostało jej przekazane i typ `int' jest równie dobry jak każdy inny. Problem w tym, że ten argument jest przez scanf interpretowany jako adres, pod który należy odczytaną wartość wpisać. Prawidłowo powinno się więc tą funkcję wywołać:

scanf( "%i", &a );

Jak wiemy, nie ma tego problemu ze strumieniami z biblioteki C++; tam podajemy zmienną bez żadnych oznaczeń i wszystko działa, argument jest bowiem przekazany przez referencję. Nagłówek tej funkcji dla typu int bowiem wygląda (tzn. jest to jedna z możliwości) tak:

istream& operator>>( istream& strin, int& to );

Zatem możemy już zadeklarować naszą funkcję swap zmienając tylko drobny szczegół w jej nagłówku:

void swap( int& a, int& b ) {
  int temp = a;
  a = b;
  b = temp;
  // i już
}

Wywołujemy funkcję:

  int a, b;
  ...
  swap( a, b );

I otrzymujemy to, co chcieliśmy.

Pamiętamy oczywiście, że dla typów referencyjnych nie ma operatora przypisania. Mogą jednak wystąpić na liście przekazywanych argumentów i tymi argumentami są wtedy inicjalizowane. Jeśli z kolei taki typ jest przez funkcję zwracany... ale o tym w następnym punkcie ;*).

Zwracanie argumentów przez funkcję

Funkcja zwraca swój argument na zawołanie `return <wartość>', a typ i jego sposób przekazania jest określony w deklaracji funkcji, jako `typ zwracany'. Przypomnę jeszcze, że jeśli funkcja nic nie zwraca (void), to używamy `return' bez podania wartości. Przykłady użycia instrukcji `return' już widzieliśmy. Tu skupię się na szczegółach zwracania wyniku przez funkcję.

Otóż wynik funkcji możemy zwrócić tak, jak się go przekazuje. To nie jest problem. Zwracanie obiektów typów ścisłych to też nie problem. Nawet zwracanie dużych obiektów przez wartość, choć nie jest zalecane, nie stanowi problemu (zostanie na stosie jego kopia, a obsługa obiektów tymczasowych, jaką nam dostarcza język, zaopiekuje się nim). Problem to się zaczyna dopiero wtedy, jeśli nie potrafimy określić, kto (funkcja? obiekt?) ma zarządzać obiektem, który został zwrócony. Czy ten obiekt jest obiektem który już dawno istnieje i nie zostanie zniszczony? A może trzeba samemu zadbać o jego usunięcie, gdy już nie będzie potrzebny? A czy na pewno będzie ten obiekt trwał do chwili, w której zechce mi się go użyć? Jeśli nie stawiasz sobie tych pytań podczas pisania programu w C++ (a używasz obiektów dynamicznych), to na poważnie lepiej znajdź sobie inne zajęcie. Ewentualnie inny język, np. Java, Smalltalk albo C#, ewentualnie Haskell lub OCaml - tam się takimi rzeczami nie musisz martwić; system operacyjny języka wszystko zrobi za Ciebie - nie pytam tylko jakim kosztem, bo nie chcę być bezczelny (ale niektórzy twierdzą, że żadnym, bo ręczna gospodarka pamięci też swoje kosztuje).

Jeśli typ zwracany jest typem ścisłym (lub typem niewielkich rozmiarów, zawierającym powiedzmy dwa pola typów ścisłych, np. complex<int>), to zwróć go po prostu przez wartość i nie zawracaj sobie głowy szczegółami. Jeśli jest to obiekt trochę większych rozmiarów, to masz następujące możliwości, obarczone odpowiednimi konsekwencjami:

Często istnieje potrzeba, aby obiekt dynamiczny posiadał "chwilowo" właściwości zmiennej lokalnej. Tu bardzo przyda się jedna sprytna rzecz, z biblioteki standardowej, mianowicie auto_ptr (jeśli masz starą wersję kompilatora, możesz nie mieć tej zabawki, jest ona jednak bajecznie prosta do napisania; dodatkowo nie wymaga żadnych bibliotek, a jedynie odpowiedniego pliku nagłówkowego).

#include <memory>
#include <iostream>
using namespace std;

int main() {
  auto_ptr<Klocek> k = new Klocek;
  // tu rób coś z klockiem
  // `k' możesz używać normalnie, jakby była typu Klocek*
} // kto zwolni?

Otóż zwolni destruktor zmiennej k. Właśnie auto_ptr jest tym, któremu zlecamy opiekę nad obiektem. Po co? A np. po to, żeby nie trzeba było obiektu ręcznie zwalniać za każdym razem, kiedy kończy się funkcja. Typ auto_ptr przejmuje kontrolę nad każdym obiektem, który mu został przypisany, przez co obiekt dynamiczny uzyskuje właściwości zmiennej lokalnej. Niekoniecznie jednak na zawsze; można mu nakazać zrzeczenia się tej kontroli (nie zapomnij wcześniej przypisać jego wartości innemu wskaźnikowi!):

k.release();

Po takim wywołaniu, wskaźnik `k' nie odpowiada za obiekt.

Niestety, auto_ptr posiada w aktualnej implementacji poważny błąd (nie twierdzę oczywiście, że niezamierzony). Mianowicie nie da się podać stałego auto_ptr do operatora przypisania. A nawet dokładnie to obiekty auto_ptr, które są stałe, są po prostu nieużywalne.

Argumenty domyślne funkcji

Argumenty domyślne są to wartości, które są używane w sytuacji, gdy określony argument podczas wywołania nie został podany. Na przykład:

int Zwieksz( int a, int v = 1 ) {
  return a + v;
}

Możemy wtedy wywołać tą funkcję jako

Zwieksz( 2, 5 );

jak i

Zwieksz( 2 ); // odpowiada Zwieksz( 2, 1 );

Argumenty domyślne muszą znajdować się zawsze w deklaracji funkcji, więc jeśli korzysta się z deklaracji funkcji, to argument domyślny musi być wpisany w deklaracji, a w definicji już nie. Oczywiście nadal nie ma obowiązku wpisywania nazwy argumentu w deklaracji:

int Zwieksz( int, int = 1 );

Niektórzy podają też przykłady podawania podatku VAT 22% (domyślnie) i 7%, ja jednak nie chcę się - przynajmniej tu - mieszać w sprawy polityczne :*).

Operatory

Jak obiecałem, przedstawiam teraz listę wszystkich operatorów. Operatrory mają swoje właściwości, które postaram się zobrazować w tabeli:

  1. priorytet (n); oznacza, które operatory są wiązane wcześniej, a które później, np. zgodnie z tym, jak jest w matematyce, operatory * i / mają wyższy priorytet, niż + i -
  2. wersja; określa, ile argumentów przyjmuje operator. Istnieją operatory określane tym samym symbolem, ale przyjmujące różną liczbę argumentów; np. operator `-' może być dwuargumentowy (odejmowanie) lub jednoargumentowy (negacja arytmetyczna). Jedynie operator () nie ma określonej liczby argumentów. Dla operatorów ++ i -- ten atrybut przybiera wyjątkowe tylko dla nich postacie: przedrostkowa i przyrostkowa. W tabeli nie jest to specjalnie oznaczone
  3. wiązanie; określa kierunek wykonywania kolejnych operacji, jeśli taki operator jest w wyrażeniu używany wielokrotnie (właściwość tą posiadają tylko te operatory, które przyjmują dwa argumenty). Większość operatorów (np. arytmetyczne +, * itd.) ma wiązanie lewostronne, tzn. są one wywoływane w kolejności od lewej do prawej (ale uwaga: oznacza kolejność pobierania podwyrażeń, a NIE wartościowania ich; kolejność wartościowania NIGDY nie jest możliwa do określenia z wyjątkiem operatorów && i || !), z kolei operatory przypisania mają wiązanie prawostronne: np. z=a+b+c wykonuje po kolei: t1=a+b; t2=t1+c; z=t2; Ale już operacja: a=b=c wykona najpierw b=c, a potem a=b. Poznany już operator << ma również wiązanie lewostronne. W tabeli oznaczone są tylko operatory, które mają wiązanie prawostronne; operatory o wiązaniu lewostronnym nie są wyróżniane.
  4. przeciążalność; oznacza, czy operator można przeciążać, czyli nadać jego nazwę swojej funkcji, czy nie można. Funkcja taka musi spełniać jeden warunek: co najmniej jeden jej argument musi być typu zdefiniowanego przez użytkownika (bibliotekę) i nie może być to typ zdefiniowany przez typedef. W tabeli wyróżniono tylko operatory nieprzeciążalne oznaczeniem <!>
  5. domyślność; oznacza, że dla podanego typu argumentu jeśli nie zdefiniowano znaczenia operatora, podejmowana jest akcja domyślna. Jeśli tak nie jest to kompilator nie dopuści takiego użycia nie zdefiniowanego operatora. W tabeli oznaczono to przez <default>
  6. nagłówek; oznacza jakie typy argumentów operator przyjmuje, a jakie zwraca. Standardowo wszystkie operatory mają swoje nagłówki, lecz nie każdy rodzaj argumentu przyjmują. Można sobie zdefiniować własny nagłówek dla danego operatora, ale tylko jeśli jest on przeciążalny (d) i nagłówek pasuje do jego wersji (b).

W przedstawionej poniżej liście operatorów, użyłem kilku nieistniejących oznaczeń: __any, który oznacza całkowicie dowolny typ, dla wyrażeń arytmetycznych użyłem też symbolu __num, który oznacza dowolny typ liczbowy (int, float, double wraz z ew. modyfikatorami). Aby nie wprowadzać niejednoznaczności, użyłem też typu __int, któremu może odpowiadać int lub unsigned int (wyróżniłem, bo jest to istotne). Oznaczenia te mogą też mieć dopisane cyfry. Oznacza to, że np. __num1 może, ale nie musi być tym samym typem, co __num2, natomiast jeśli jest bez cyfr, wtedy oznacza to, że podany typ tylko jednego rodzaju obowiązuje w całym nagłówku. Niektóre operatory posiadają takie ograniczenie, że może być zdefiniowany tylko jako metoda (patrz niżej, właściwości dodatkowe) i jest on oznaczony wtedy jako __any::operator <op>(...). Atrybut `wersja' nie jest tu specjalnie wyróżniony, jest podane jedynie jak się dla konkretnej wersji taki operator powinno definiować (jedynie dla operatora () argumenty są określone jako ...). Niektóre z operatorów zdefiniowałem jak funkcję, która obrazuje jego znaczenie.

+
__num1 operator+( __num2, __num3 );
dodawanie
+
__num operator+( __num a ) { return a; }
-
__num1 operator-( __num2, __num3 );
odejmowanie
-
__num operator-( __num );
negacja arytmetyczna
*
__num1 operator*( __num2, __num3 );
mnożenie
/
double operator/( double, double );
dzielenie rzeczywiste
/
__int operator/( __int, __int );
dzielenie całkowite
%
__int operator%( __int, __int );
reszta z dzielenia
++
__num& __num::operator++() {
  return *this = *this + 1;
}
++
__num& __num::operator++(int) {
  __num t = *this;
  ++*this;
  return t;
}
--
__num& __num::operator--(); // podobnie jak powyżej
--
__num& __num::operator--(int); // więc się nie będę powtarzać
<<
__int& operator<<( __int, unsigned );
przesuń bity w lewo
>>
__int& operator>>( __int, unsigned );
przesuń bity w prawo
&
__int operator&( __int, __int );
bitowe AND
|
__int operator|( __int, __int );
bitowe OR
^
__int operator^( __int, __int );
bitowe EXOR
~
__int operator~( __int );
negacja bitowa
=
__any& __any::operator=( const __any& ); <default>
przypisanie
+=
__any& __any::operator+=( __any );
dodawanie z przypisaniem wyniku
Uwaga: podobne istnieją dla: -,*,/,%,<<,>>,&,|,^
==
bool operator==( const __any&, const __any& );
Uwaga: __any oznacza tutaj wyłacznie ten typ, dla którego znaczenie danych operatorów zostało zdefiniowane; domyślnie istnieje on tylko dla typów wbudowanych
!=
bool operator!=( const __any&, const __any& );
<
bool operator<( const __any&, const __any& );
>
bool operator>( const __any&, const __any& );
<=
bool operator<=( const __any&, const __any& );
>=
bool operator>=( const __any&, const __any& );
&&
bool operator&&( bool, bool );
||
bool operator||( bool, bool );
!
bool operator!( bool );
&
__any* __any::operator&() { return this; }
*
__any& __any*::operator*();
wyłuskanie
()
__any1 __any2::operator()( ... );
wywołanie funkcji
__any& __any*::operator( size_t );
indeksowanie tablicy
.
<!>
dereferencja pola obiektu podanego wprost lub referencji
.*
<!>
wyłuskanie wskaźnika na składową na rzecz obiektu... (j.w.)
->
__any1& __any2*::operator->();
dereferencja pola ob. spod wskaźnika
->* jak wyżej z odpowiednią poprawką; nagłówek też ten sam
?:
<!> __any& operator?:( bool, __any&, __any& );
,
__any1 operator,( __any2&, __any1& );
przetwórz pierwsze wyrażenie

Jeśli chodzi o operatory -> i ->* to oczywiście sposób ich użycia odpowiada operarowi `.'. Ich definicja ma na celu trochę co innego - pozwala danemu typowi "udawać" wskaźnik, a ten operator ma na celu wyłącznie zwrócenie wskaźnika, spod którego będzie wyłuskiwane podane pole (tzn. tego wskaźnika, który dany typ próbuje udawać).

Nie ująłem tutaj operatorów ::, sizeof, typeid i *_cast, gdyż po pierwsze i tak są nieprzeciążalne, a po drugie ich definicja nie byłaby taka prosta. Opiszę więc to słownie.

Operatory sizeof i typeid przyjmują jako argument typ (podany w nawiasach) lub obiekt; jednak ten argument jest tylko wyrażeniem, z którego te operatory interesuje wyłacznie typ (w przypadku typeid nie do końca, ale to zostanie opisane w III części). Operator `sizeof' zwraca rozmiar obiektu podanego typu (interesuje go oczywiście typ, operator sizeof nie "mierzy" obiektu, jeśli ktoś to sobie tak wyobrażał :*). Z kolei operator `typeid' zwraca referencję do struktury identyfikującej typ. Ponieważ to jest jednak trochę dłuższy temat, opiszę go osobno, we właściwościach dodatkowych, w rozdziale "Operatory RTTI".

Operator :: jest operatorem zakresu i jest operatorem nie dla typów C++, lecz w ogólności dla identyfikatorów. Składnia jego używania jest następująca:

<id_zakresu>::<identyfikator>

Wyrażenie takie zastępuje <identyfikator>, jeśli ów identyfikator w bieżącym zakresie znaczy co innego lub w ogóle nie istnieje. Oczywiście <id_zakresu> może również być postaci takiego wyrażenia. Jest on też elementem opcjonalnym; jeśli nie wystąpi, wówczas przyjmuje się najbardziej ogólny zakres (tzn. zakres "globalny", aczkolwiek pojęcie to zostanie uściślone przy omawianiu przestrzeni nazw).

Nie omówiłem jeszcze znaczenia operatora ?:, dlatego zrobię to teraz, bo później mogę nie mieć okazji. Jest on oczywiście nieprzeciążalny, ale warto pamiętać, że przy postaci <war> ? <w1> : <w2> wyrażenia w1 i w2 muszą być tego samego typu. Nie bardzo mogą nawet być konwertowalne, bo typ wyniku musi być wyznaczony na podstawie typu właśnie tych wyrażeń. Wyjaśnię jego znaczenie na przykładzie:

x = a == 5 ? 0 : 2;

jest znaczeniowo identyczne z:

if ( a == 5 )
  x = 0;
else
  x = 2;

Fakt, że może wydawać się mało zrozumiały (wyrażenie warunkowe polecam brać w nawiasy), ale za to jego możliwości są o wiele większe, niż if'a. Zauważ, że operacja przypisania jest tutaj jednoznaczna, a warunkowe jest tylko uzyskanie wartości. W if-ie jest inaczej - operacja przypisania występuje w obu miejscach. Nie chodzi mi już nawet o większe możliwości optymizacji. Chodzi mi głównie o to, że np. całe takie wyrażenie może zwracać wartość, którym zainicjalizuje się referencję. Przy zwykłym `if' nie da się tego zrobić na żaden sposób.

Oczywiście nie wspomniałem tutaj o operatorach new i delete. Jednak ich używanie a przeciążanie to - w odróżnieniu od pozostałych operatorów - zupełnie różne sprawy. Składnię wywoływania podałem już wcześniej.