11.6 Argumenty referencyjne

Zarówno w roli argumentów, jak i wartości zwracanej mogą występować referencje, o których mówiliśmy w rozdziale o referencjach . Co to znaczy, jeśli parametr funkcji jest zadeklarowany jako referencja?

Niech funkcja będzie zdefiniowana tak:

       void fun(int& k) {
           k = k + 2;
       }
Taki zapis oznacza, że argument typu int zostanie przesłany do funkcji przez referencję (referencję). Jeśli wywołamy tę funkcję podając jako argument pewną zmienną typu int, to w czasie wykonania funkcji nazwa parametru k będzie inną nazwą dokładnie tej samej zmiennej. A więc nie będzie kopiowania wartości na stos: zmienna lokalna w funkcji w ogóle nie będzie tworzona, nazwa k będzie odnosić się do oryginału zmiennej będącej argumentem wywołania. Jeśli powyższą funkcję wywołamy tak:
       int m = 1;
       fun(m);
       cout << "m = " << m << endl;
to wewnątrz funkcji nazwa k będzie inną nazwą tej zmiennej, która w programie wywołującym nazywa się m. Ta właśnie zmienna zostanie przez funkcję powiększona o dwa. Zatem po wykonaniu funkcji zmienna m w funkcji wywołującej będzie zmieniona — jej wartość wynosić będzie teraz trzy!

Zauważmy, że samo wywołanie funkcji wygląda tak jak normalne wywołanie przez wartość. Przy wywołaniu przez wartość jednak to kopia wartości m byłaby przypisana do lokalnej dla funkcji zmiennej k. Modyfikacje tej lokalnej zmiennej nie odbiłyby się w żaden sposób na wartości oryginału, czyli zmiennej m z funkcji wywołującej.

Fakt, że patrząc na wywołanie funkcji nie jesteśmy w stanie stwierdzić, czy jest to wywołanie przez wartość czy przez referencję, jest dość nieszczęśliwy: może prowadzić do błędnej interpretacji kodu, jeśli nie sprawdzi się deklaracji lub definicji funkcji. Dlatego nie należy stosować takiej formy wywołania bez potrzeby. Z drugiej strony, w niektórych sytuacjach jest to sposób najwygodniejszy, a czasem wręcz konieczny.

Parametr zadeklarowany jako referencyjny jest inną nazwą zmiennej przekazanej jako argument. Zatem ta zmienna musi istnieć — jak podkreślaliśmy, w funkcji nie jest tworzona zmienna lokalna.

Co jednak będzie, jeśli jako argumentu użyjemy literału lub wyrażenia o określonej, co prawda, wartości, ale nie będącego l-wartością istniejącej zmiennej? Albo, co będzie, jeśli przekażemy jako argument l-wartość istniejącej zmiennej, ale innego typu niż ten zadeklarowany na liście parametrów funkcji? Zauważmy bowiem, że nie może tu być mowy o niejawnych konwersjach: identyfikator zadeklarowany jako referencja skojarzona ze zmienną typu double nie może przecież być „inną nazwą” zmiennej typu int. Doprowadziłoby to szybko do kompletnego chaosu.

Zatem w obu przypadkach kompilator wykryje błąd. Jest jednak odstępstwo od tej reguły. Jeśli parametr referencyjny jest zadeklarowany z modyfikatorem const, takie wywołanie jest dopuszczalne. Ponieważ jednak zmienna lokalna nie jest dla parametrów referencyjnych tworzona, zostanie utworzona zmienna tymczasowa odpowiedniego typu i nazwa parametru w funkcji będzie inną nazwą tej zmiennej tymczasowej. Zauważmy, że to nie doprowadzi do chaosu: skoro parametr był const, czyli 'read-only', to i tak nie była możliwa zmiana wartości argumentu, zatem żadnych nieprzewidzianych skutków ubocznych taka konstrukcja nie powinna spowodować.

Parametry referencyjne to wygodny sposób przekazywania do funkcji obiektów o znacznych rozmiarach. Oczywiście zmienne typów wbudowanych są małe, ale zmienne typów (klas) definiowanych przez autora programu mogą być i bywają bardzo duże. Przekazywanie przez wartość powodowałoby konieczność kopiowania ich na stos, tworzenia zmiennych lokalnych itd. Użycie referencji eliminuje te niedogodności. Z drugiej strony, często nie jest naszą intencją, aby funkcja zmodyfikowała zmienną przesłaną jej przez referencję jako argument. Pamiętamy bowiem, że funkcja ma wtedy dostęp do oryginału, a nie do kopii wartości. Przed taką niezamierzoną zmianą możemy się uchronić deklarując parametr referencyjny z modyfikatorem const; zmienna będąca argumentem wywołania nie musi wtedy wcale być ustalona —  skojarzenie argumentu typu Typ z parametrem typu const Typ& jest dopuszczalne (jest to prawidłowa, trywialna konwersja niejawna: patrz rozdział o konwersjach ).

Inna sytuacja, gdy argumenty referencyjne przydają się, zachodzi wtedy, gdy właśnie zależy nam na tym, aby zmienna przekazywana do funkcji zmieniła swoją wartość. Na przykład chcielibyśmy, aby funkcja obliczyła i zwróciła jakieś dwie (albo więcej) wartości: poprzez normalny mechanizm zwracania wartości za pomocą return możemy otrzymać jedną z nich, ale jak dostać drugą? Można to łatwo rozwiązać dzięki parametrom referencyjnym. Oczywiście, inną możliwością jest użycie wskaźników: posyłamy do funkcji poprzez argument adres zmiennej, którą chcemy w funkcji zmienić. Ten adres zostanie przesłany przez wartość, a więc przesłana zostanie jego kopia, ale sam adres będzie wskazywał na istniejącą zmienną z funkcji wywołującej, a więc funkcja wołana będzie miała do niej dostęp. Zatem będzie mogła wartość tej zmiennej zmodyfikować.

Różne sposoby przesyłania argumentów do funkcji ilustruje poniższy program:


P70: vrp.cpp     Przekazywanie argumentu przez wartość, referencję i wskaźnik

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  void fun(int, int&, int*);
      5.  
      6.  int main() {
      7.      int a = 1, b = 2, c = 3;
      8.  
      9.      cout << "Przed: a = " << a << " b = " << b
     10.                            << " c = " << c << endl;
     11.      fun(a, b, &c);
     12.  
     13.      cout << "Po   : a = " << a << " b = " << b
     14.                            << " c = " << c << endl;
     15.  }
     16.  
     17.  void fun(int x, int& y, int* z) {
     18.      x = 2*x;
     19.      y = 3*y;
     20.     *z = 4*(*z);
     21.  }

Pierwszy argument przesłany jest przez wartość, a więc zmiana wartości zmiennej lokalnej x wewnątrz funkcji nie odbije się na wartości zmiennej a w funkcji main. Drugi argument przesyłany jest przez odniesienie (referencję), a więc w funkcji identyfikator y oznacza dokładnie tę samą zmienną, która w funkcji main nazywa się b. Ponieważ funkcja modyfikuje wartość y, więc automatycznie zmieniona jest i wartość zmiennej b w funkcji main. Trzeci argument przekazywany jest przez wskaźnik. Odpowiedni parametr funkcji fun zadeklarowany jest jako typu int*. Jest to wskaźnik do int, a więc funkcja spodziewa się otrzymania (przez wartość) adresu zmiennej typu int. Dlatego trzecim argumentem wywołania jest adres zmiennej c (czyli wartość wyrażenia &c). Wewnątrz funkcji wyrażenie *z oznacza zmienną wskazywaną przez z, ale z zawiera przekazany jej adres zmiennej c z programu głównego; zatem *z w funkcji jest tym samym co c w programie głównym — w ten sposób modyfikacja *z modyfikuje oryginał, czyli zmienną c w programie głównym:

    Przed: a = 1 b = 2 c = 3
    Po   : a = 1 b = 6 c = 12

Jeśli nazwa parametru typu referencyjnego oznacza w funkcji tę samą zmienną, która została użyta jako odpowiedni argument podczas jej wywołania, to co będzie, jeśli parametrem tym jest referencja do tablicy? Czy wewnątrz funkcji znany na przykład będzie wymiar tej tablicy, tak jak jest znany w tej funkcji, gdzie tablica była deklarowana? Odpowiedź jest twierdząca. Spójrzmy na następujący przykład:


P71: tabref.cpp     Referencja do tablicy

      1.  #include <iostream>
      2.  using namespace std;
      3.  
      4.  void funtab(double[]);                      
      5.  void funref(double (&)[6]);                 
      6.  
      7.  int main() {
      8.      double tab[] = {1,2,3,4,5,6};
      9.      cout << "Wymiar double : " << sizeof(double)  << endl;
     10.      cout << "Wymiar double*: " << sizeof(double*) << endl;
     11.      cout << "Wymiar tab w main: " << sizeof(tab)  << endl;
     12.      funtab(tab);
     13.      funref(tab);                            
     14.  }
     15.  
     16.  void funtab(double t[]) {
     17.      cout << "Wymiar t w funtab: " << sizeof(t) << endl;
     18.  }
     19.  
     20.  void funref(double (&t)[6]) {               
     21.      cout << "Wymiar t w funref: " << sizeof(t) << endl;
     22.  }

Parametrem funkcji funtab zadeklarowanej w linii  jest tablica, czyli tak naprawdę wskaźnik do początku tej tablicy. Wewnątrz funkcji wymiar tablicy jest nieznany, zmienna t jest typu double* i jej rozmiar wynosi 4 (lub 8). Aby wymiar stał się znany, musielibyśmy przesłać go jako osobny argument. Parametrem funkcji funref (zadeklarowanej w linii ) jest referencja do tablicy. Zauważmy tu deklarację: ' double (&)[6]' jest tu nazwą typu referencja do sześcioelementowej tablicy elementów typu double. Nawias jest tu konieczny; gdyby go nie było, to typem byłaby sześcioelementowa tablica odniesień (referencji) do zmiennych typu double — coś takiego w ogóle nie istnieje!

Nie istnieje typ tablica odniesień.

Podobnie w linii  ' double (&t)[6]' oznacza t jest referencją do sześcioelementowej tablicy elementów typu double, czyli inaczej: identyfikator t jest inną nazwą sześcioelementowej tablicy elementów typu double, która istnieje w funkcji wołającej i została tam użyta jako argument wywołania. Zauważmy, że wymiar musi być podany: tablica czteroelementowa to nie to samo, co tablica pięcioelementowa. Dlatego w tym przypadku funkcja tabref zna wymiar tablicy:

    Wymiar double : 8
    Wymiar double*: 8
    Wymiar tab w main: 48
    Wymiar t w funtab: 8
    Wymiar t w funref: 48
Nie oznacza to jednak, że rozwiązaliśmy problem przekazywania rozmiaru tablicy do funkcji „żeby było tak jak w Javie”: zauważmy bowiem, że argumentem wywołania funkcji funref może być wyłącznie tablica sześcioelementowa! Spróbujmy zmienić wymiar tablicy tab przez dodanie na przykład jednego elementu: funkcji funtab to nie przeszkadza, ale wywołanie z linii  w ogóle się nie skompiluje!

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