11.2 Deklaracje i definicje funkcji

Definicja lub deklaracja dostarcza kompilatorowi informacji o funkcji: jaki jest jej typ zwracany, jaka jest liczba parametrów, jaki jest typ parametrów itd. W ten sposób, za każdym razem, gdy w dalszej części tekstu programu pojawia się użycie tej funkcji, kompilator może sprawdzić

W razie niezgodności kompilacja zostanie przerwana, co jest lepsze niż utworzenie bezsensownego kodu wynikowego. Jest to cecha C++; w tradycyjnym C wymogu wcześniejszego deklarowania funkcji nie było, co powodowało moc kłopotów przy testowaniu i uruchamianiu programów.

Dlaczego definicja lub deklaracja, co to w ogóle jest deklaracja funkcji i do czego się przydaje?

Wyobraźmy sobie następującą sytuację: definiujemy kolejno dwie funkcje, fun1fun2. Funkcja fun1 wywołuje w swej treści funkcję fun2 i odwrotnie, funkcja fun2 wywołuje w swej treści funkcję fun1:

      1.      void fun1(int k) {
      2.          // ...
      3.          fun2(k)
      4.          // ...
      5.      }
      6.  
      7.      void fun2(int m) {
      8.          // ...
      9.          fun1(m)
     10.          // ...
     11.      }
W jakiej kolejności zdefiniować te funkcje? Jeśli zdefiniujemy je tak jak wyżej, to w linii 3 kompilacja zostanie przerwana, bo nieznana jest w niej jeszcze funkcja fun2; odwrócenie kolejności nie pomoże, bo wtedy instrukcja wywołania fun1 wewnątrz fun2 spowoduje te same kłopoty.

Na szczęście jest wyjście z tej sytuacji. Kompilatorowi nie jest potrzebna definicja funkcji, to znaczy nie musi wiedzieć, co funkcja robi: musi tylko wiedzieć, ile i jakie ma parametry i jaki jest jej typ zwracany. Do tego wystarczy prototyp funkcji podany w deklaracji. Deklaracja ma formę nagłówka funkcji, po którym następuje średnik zamiast ciała (treści) funkcji. Na przykład poprawnymi deklaracjami funkcji są

       string& fun1(char* c1, char* c2, bool b);
       void    fun2(int k, double d[]);
       Klasa*  fun3(Klasa* k1, Klasa* k2);
Nie mają one ciała (treści), a więc nie są definicjami. Ale zawierają informacje o nazwie, typie zwracanym, typie i liczbie parametrów (czyli właśnie prototyp). Jest to wszystko, czego potrzebuje kompilator, aby sprawdzić formalną prawidłowość ich użycia. Tak więc nasz pierwszy przykład skompiluje się gładko, jeśli przed definicją funkcji fun1 umieścimy deklarację funkcji fun2 (ze średnikiem na końcu):
      1.      void fun2(int);
      2.  
      3.      void fun1(int k) {
      4.          // ...
      5.          fun2(k)
      6.          // ...
      7.      }
      8.  
      9.      void fun2(int m) {
     10.          // ...
     11.          fun1(m)
     12.          // ...
     13.      }
Zauważmy, że w deklaracji (pierwsza linia) nie podaliśmy nazwy pierwszego i jedynego parametru formalnego funkcji fun2 — tylko jego typ (int). Jest to całkowicie dopuszczalne: do sprawdzenia poprawności wywołania kompilator potrzebuje informacji o liczbie i typie parametrów funkcji, ale ich nazwy nie są do niczego potrzebne i są wobec tego przez kompilator pomijane. Zatem można ich w ogóle nie pisać (choć warto, bo umiejętnie dobrane nazwy są znakomitą formą komentarza). Oczywiście w definicji nazwa zwykle jest konieczna, ale nie musi być taka sama jaka została podana w deklaracji.

Na przykład, podane poprzednio trzy deklaracje moglibyśmy równie dobrze zapisać tak:

       string& fun1(char*, char*, bool);
       void    fun2(int, double[]);
       Klasa*  fun3(Klasa*, Klasa*);
Funkcja zadeklarowana musi oczywiście być gdzieś również zdefiniowana (tylko raz). W przeciwnym razie powstanie błąd na etapie linkowania (łączenia, konsolidacji) programu. Zauważmy, że błędu nie będzie, jeśli zadeklarowana funkcja nie została w programie użyta — tak więc wolno deklarować funckje, które dopiero zamierzamy napisać, byle tylko nie próbować ich użycia. Definicja nie musi wystąpić w tym samym module (pliku). Wystarczy, że umieścimy ją w jakimś module składającym się na cały program. W innych modułach, w których funkcja ta jest używana, trzeba tylko zamieścić jej deklarację.

Ponieważ na razie nasze programy i tak mieszczą się w jednym pliku, szczegóły odłożymy do rozdziału o modułach programu .

Ta sama funkcja może być deklarowana wielokrotnie, nawet w tym samym pliku. Definicja natomiast powinna wystąpić tylko raz (ODR – ang. One Definition Rule); wyjątkiem są funkcje rozwijane, o których powiemy dalej.

Oczywiście wszystkie deklaracje, jeśli jest ich kilka, muszą być ze sobą zgodne, czyli definiować ten sam prototyp. Definicja również musi być zgodna z deklaracjami — to znaczy nagłówek funkcji musi być zgodny z prototypem. Jak mówiliśmy, ta zgodność nie musi dotyczyć nazw parametrów formalnych funkcji, które w ogóle nie mają znaczenia w deklaracjach i do prototypu nie należą.

Nagłówek określa prototyp, czyli zewnętrzne własności funkcji. W najprostszej postaci wygląda on tak:

        Typ Nazwa ListaParam
gdzie Typ określa typ wartości zwracanej (przed nią mogą wystąpić modyfikatory, o których powiemy w dalszej części rozdziału), Nazwa oznacza nazwę funkcji, a ListaParam listę parametrów formalnych.
Typ wartości zwracanej.

Musi to być typ wbudowany (jak int, char, ...), typ zdefiniowany przez użytkownika, typ pochodny (int*, Osoba& itd.), albo wreszcie typ void. Jeśli typem zwracanym nie jest void, to funcję nazywamy funkcją rezultatową. Jeśli jest to void, to funkcja w ogóle nie zwraca żadnej wartości; taką funkcję nazywamy funkcją bezrezultatową. Typ wartości zwracanej zwany jest też po prostu typem funkcji.
Typem funkcji nie może być typ tablicowy i nie może nim być typ funkcyjny; może to natomiast być typ wskaźnikowy — w szczególności wskaźnik może wskazywać na tablicę lub funkcję — lub typ referencyjny.
Nazwa.

Nazwa może być dowolna, byle nie kolidowała z którymś ze słów kluczowych, składała się tylko z liter, cyfr i znaków podkreślenia. Nie może jednak zaczynać się od cyfry.
Lista parametrów formalnych.

Lista ta jest ujętą w okrągłe nawiasy listą oddzielonych przecinkami deklaracji pojedynczych parametrów funkcji w postaci (typ1 nazwa1, typ2 nazwa2). Nie wolno stosować deklaracji zbiorczych: (typ nazwa1, nazwa2) — nie byłoby wtedy wiadomo, czy nazwa2 jest drugim parametrem typu typ, czy typem następnego, anonimowego (co jest dopuszczalne), parametru. Nazwy wszystkich parametrów muszą być różne.
Nazwy parametru można w ogóle nie podawać w deklaracjach; w definicjach również można nazwę pominąć, jeśli z argumentu wywołania skojarzonego z tym parametrem funkcja w ogóle nie korzysta, jak to często bywa w czasie tworzenia funkcji w jej wstępnych wersjach.
Lista parametrów może być pusta; obejmujących ją nawiasów pominąć jednak nie można. Jeśli lista parametrów jest pusta, to można zaznaczyć to przez wpisanie wewnątrz nawiasów słowa kluczowego void. Nie jest to jednak konieczne.
Pamiętajmy też, że poprzedzenie nazwy typu słowem kluczowym const odpowiada zmianie typu: typ int* i typ const int* to dwa różne typy. Jeśli typem parametru jest na przykład const int*, to kompilator nie zgodzi się na zmianę wartości zmiennej wskazywanej poprzez nazwę wskaźnika przekazanego jako argument (co funkcja normalnie może zrobić; przekazana jej została, co prawda, kopia adresu, ale sam adres odpowiada oryginalnej zmiennej z funkcji wywołującej).

Część nagłówka funkcji składająca się z nazwy funkcji i listy typów jej parametrów (bez nazw tych parametrów) nazywa się czasem jej sygnaturą. Typu zwracanego zwykle w sygnaturze nie uwzględnia się. Tak więc na przykład sygnaturą funkcji o prototypie

       double fun(double x, char* nap);
jest
       fun(double, char*)

Definicja funkcji składa się z takiego samego nagłówka, tyle że teraz nie kończymy go średnikiem, tylko umieszczamy zaraz za nim, ujętą w nawiasy klamrowe, treść (ciało) funkcji, czyli sekwencję instrukcji do wykonania. Jeśli w ciele funkcji chcemy korzystać z argumentu przekazanego poprzez parametr funkcji, to oczywiście ten parametr musi mieć nazwę (którą w deklaracji mogliśmy pominąć). W programie może występować tylko jedna definicja funkcji, choć wiele deklaracji. Wyjątkiem są funkcje rozwijane, które mogą być definiowane wielokrotnie (patrz podrozdział o funkcjach rozwijanych ).

Ciało funkcji może być traktowane jak wnętrze instrukcji grupującej (złożonej). Do zakresu tej instrukcji grupującej należą również deklaracje zmiennych lokalnych opisane przez specyfikacje parametrów funkcji. Zmienne definiowane w ciele funkcji będą w czasie jej wykonywania lokalne — po wykonaniu funkcji są one usuwane. Zmiennymi lokalnymi są również zmienne wyspecyfikowane jako parametry formalne funkcji: będą one zainicjowane wartościami argumentów wywołania. Po zakończeniu wykonywania funkcji zmienne lokalne (z wyjątkiem tych zadeklarowanych jako static) będą usunięte.

Zmienne lokalne funkcji (w tym te deklarowane przez specyfikacje parametrów w nagłówku funkcji) w żaden sposób nie kolidują ze zmiennymi o tej samej nazwie w innych funkcjach. Mogą natomiast przesłaniać nazwy zmiennych globalnych, zadeklarowanych poza funkcjami i klasami. Jeśli tak jest, to niekwalifikowana nazwa występująca w ciele funkcji odnosi się zawsze do zmiennej lokalnej, natomiast dostęp do zmiennej globalnej o tej nazwie mamy poprzez operator zasięgu — czterokropek (patrz rozdział o zasięgu i widzialności zmiennych).

Nie wolno definicji funkcji zagnieżdżać, to znaczy nie można w ciele jednej funkcji definiować innej funkcji (co jest dozwolone w innych językach, jak Pascal czy Fortran 90/95). W nowym standardzie C++11 można jednak wewnątrz funkcji definiować tak zwane funkcje lambda, o których za chwilę.

Definicja (i deklaracja) funkcji może w nowym standardzie C++11 mieć inną, alternatywną, postać, a mianowicie

       auto f_nazwa(parametry) -> typ_zwracany { ciało }
Zamiast specyfikować typ zwracany przed nazwą funkcji, stawiamy tam słowo kluczowe auto, natomiast typ funkcji określamy zaraz za listą parametrów, poprzedzając go „strzałką” (' ->'). Nie musimy zresztą tego typu określać jawnie, możemy użyć tu wyrażenia decltype (zob. rozdział o typach danych ). Zauważmy, że jeśli tak zrobimy, to możemy w  decltype użyć nazw parametrów funkcji, bo w tym miejscu jesteśmy już w zakresie funkcji. Pokażmy to na przykładzie


P65: fundefnew.cpp     Alternatywna postać deklaracji funkcji

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  auto D(int a, double b) -> decltype(a*b);   
      5.  
      6.  double A(int a, double b) {                 
      7.      return a*b;
      8.  }
      9.  
     10.  auto B(int a, double b) -> double {         
     11.      return a*b;
     12.  }
     13.  
     14.  auto C(int a, double b) -> decltype(a*b) {  
     15.      return a*b;
     16.  }
     17.  
     18.  double D(int a, double b) {                 
     19.      return a*b;
     20.  }
     21.  
     22.  int main() {
     23.      cout << A(4,2.5) << " " << B(4,2.5) << " "
     24.           << C(4,2.5) << " " << D(4,2.5) << endl;
     25.  }

Definiujemy tu szereg funkcji (A, B, C, D), które są właściwie identyczne — wszystkie po prostu zwracają iloczyn swoich argumentów. Definicja funkcji A () jest „normalna”. W linii  użyliśmy natomiast nowej składni (auto przed nazwą, typ za listą parametrów i poprzedzony strzałką). W definicji funkcji C () zamiast podawać jawnie typ „poprosiliśmy” kompilator, aby sam określił, jaki powinien być typ iloczynu argumentów (oczywiście będzie to double). Widać też, że nowej składni możemy też użyć do deklarowania funkcji () — sama definicja () może być zapisana zarówno w nowej jak i starej składni (wtedy oczywiście typ zwracany musi się zgadzać z tym wydedukowanym przez kompilator z deklaracji).

Forma użyta w tym przykładzie w definicji funkcji C () okaże się niesłychanie użyteczna przy definiowaniu szablonów funkcji.

Powiedzmy na koniec, że deklaracje i definicje funkcji nie są instrukcjami wykonywalnymi (jak na przykład w Pythonie). Zatem kolejność, w jakiej je piszemy, nie ma znaczenia, dopóki spełniony jest warunek, że co najmniej jedna deklaracja poprzedza leksykalnie instrukcje, w których funkcja jest wykorzystywana.

T.R. Werner, 23 lutego 2019; 23:59