W poprzednich lekcjach omówiliśmy dwa różne sposoby przekazywania argumentu do funkcji: przekazywanie przez wartość (2.4 -- Wprowadzenie do parametrów i argumentów funkcji) i przekazywanie przez referencję (12.5 -- Przekazywanie przez lvalue reference).
Oto przykładowy program, który pokazuje std::string obiekt przekazywany przez wartość i referencję:
#include <iostream>
#include <string>
void printByValue(std::string val) // Parametr funkcji jest kopią str
{
std::cout << val << '\n'; // wydrukuj wartość przez kopię
}
void printByReference(const std::string& ref) // Parametr funkcji to referencja, która wiąże się z str
{
std::cout << ref << '\n'; // wydrukuj wartość przez odwołanie
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // przekazuje str przez wartość, tworzy kopię str
printByReference(str); // przekazuje str przez referencję, nie tworzy kopii str
return 0;
}Kiedy przekazujemy argument str przez wartość, parametr funkcji val otrzymuje kopię argumentu. Ponieważ parametr jest kopią argumentu, wszelkie zmiany w val dokonują kopii, a nie oryginału argument.
Kiedy przekazujemy argument str przez referencję, parametr referencyjny ref jest powiązany z rzeczywistym argumentem. Pozwala to uniknąć tworzenia kopii argumentu. Ponieważ nasz parametr referencyjny jest stały, nie wolno nam zmieniać ref. Gdyby jednak ref nie był stały, wszelkie zmiany, które wprowadziliśmy w ref , zostałyby wykonane. zmiana str.
W obu przypadkach wywołujący podaje rzeczywisty obiekt (str), który ma być przekazany jako argument do wywołania funkcji.
Przekazywanie przez adres
C++ zapewnia trzeci sposób przekazywania wartości do funkcji, zwany przekazywaniem przez adres Za pomocą przekazaniu adresu, zamiast podawać obiekt jako argument, osoba wywołująca udostępnia obiektu adres (poprzez wskaźnik). Ten wskaźnik (przetrzymujący adres obiektu) jest kopiowany do parametru wskaźnika wywoływanej funkcji (który teraz przechowuje także adres obiektu). Funkcja może następnie wyłuskać ten wskaźnik, aby uzyskać dostęp do obiektu, którego adres został przekazany.
Oto wersja powyższego programu, która dodaje wariant adresu przejścia:
#include <iostream>
#include <string>
void printByValue(std::string val) // Parametr funkcji jest kopią str
{
std::cout << val << '\n'; // wydrukuj wartość przez kopię
}
void printByReference(const std::string& ref) // Parametr funkcji to referencja, która wiąże się z str
{
std::cout << ref << '\n'; // wydrukuj wartość przez odwołanie
}
void printByAddress(const std::string* ptr) // Parametr funkcji to wskaźnik przechowujący adres str
{
std::cout << *ptr << '\n'; // wydrukuj wartość za pomocą wyłuskanego wskaźnika
}
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // przekazuje str przez wartość, tworzy kopię str
printByReference(str); // przekazuje str przez referencję, nie tworzy kopii str
printByAddress(&str); // przekazuje str przez adres, nie tworzy kopii str
return 0;
}Zauważ, jak podobne są wszystkie trzy wersje. Przyjrzyjmy się bardziej szczegółowo wersji z przekazywaniem adresu.
Po pierwsze, ponieważ chcemy, aby nasza printByAddress() funkcja korzystała z przekazywania przez adres, stworzyliśmy parametr naszej funkcji jako wskaźnik o nazwie ptr. Ponieważ printByAddress() , który będzie używany ptr w trybie tylko do odczytu, ptr jest wskaźnikiem do wartości stałej.
void printByAddress(const std::string* ptr)
{
std::cout << *ptr << '\n'; // wydrukuj wartość za pomocą wyłuskanego wskaźnika
}Wewnątrz projektu printByAddress() funkcją, my dereference ptr aby uzyskać dostęp do wartości wskazywanego obiektu.
Po drugie, gdy funkcja jest wywoływana, nie możemy po prostu przekazać str obiektu - musimy przekazać adres str. Najłatwiej to zrobić, używając operatora adresu (&), aby uzyskać wskaźnik przechowujący adres str:
printByAddress(&str); // użyj operatora adresu (&), aby uzyskać wskaźnik przechowujący adres strWhen. to wywołanie zostanie wykonane, &str utworzy wskaźnik przechowujący adres str. Adres ten jest następnie kopiowany do parametru funkcji ptr w ramach wywołania funkcji. Ponieważ ptr przechowuje teraz adres str, gdy funkcja wyłuska ptr, otrzyma wartość str, którą funkcja wypisuje na konsoli.
To wszystko.
Chociaż w powyższym przykładzie używamy operatora adresu w celu uzyskania adresu str, jeśli mamy już zmienną wskaźnikową zawierającą adres str, moglibyśmy go zamiast tego użyć:
int main()
{
std::string str{ "Hello, world!" };
printByValue(str); // przekazuje str przez wartość, tworzy kopię str
printByReference(str); // przekazuje str przez referencję, nie tworzy kopii str
printByAddress(&str); // przekazuje str przez adres, nie tworzy kopii str
std::string* ptr { &str }; // zdefiniuj zmienną wskaźnikową przechowującą adres str
printByAddress(ptr); // przekazuje str przez adres, nie tworzy kopii str
return 0;
}Nomenklatura
Gdy przekazujemy adres zmiennej jako argument za pomocą operator&, powiedzmy, że zmienna jest przekazywana przez adres.
Gdy mamy zmienną wskaźnikową przechowującą adres obiektu i przekazujemy wskaźnik jako argument do parametru tego samego typu, mówimy, że obiekt jest przekazywany przez adres, a wskaźnik przez wartość.
Przekazywanie adresu nie tworzy kopii wskazywanego obiektu
Rozważ następujące stwierdzenia:
std::string str{ "Hello, world!" };
printByAddress(&str); // użyj operatora adresu (&), aby uzyskać wskaźnik przechowujący adres strAs zauważyliśmy w 12.5 -- Przekazywanie przez lvalue reference, kopiowanie std::string jest kosztowne, więc tego chcemy uniknąć. Kiedy przekazujemy std::string przez adres, nie kopiujemy rzeczywistego std::string obiektu - kopiujemy jedynie wskaźnik (przechowujący adres obiektu) od obiektu wywołującego do wywoływanej funkcji. Ponieważ adres ma zazwyczaj tylko 4 lub 8 bajtów, jest to wskaźnik ma tylko 4 lub 8 bajtów, więc kopiowanie wskaźnika jest zawsze szybkie.
Tak więc, podobnie jak przekazywanie przez referencję, przekazywanie przez adres jest szybkie i pozwala uniknąć tworzenia kopii obiektu argumentu.
Przekazywanie adresu pozwala funkcji modyfikować wartość argumentu.
Kiedy przekazujemy obiekt przez adres, funkcja otrzymuje adres przekazanego obiektu, do którego może uzyskać dostęp poprzez dereferencję. Ponieważ jest to adres aktualnie przekazywanego obiektu argumentu (a nie kopia obiektu), jeśli parametr funkcji jest wskaźnikiem do wartości innej niż stała, funkcja może zmodyfikować argument za pomocą parametru wskaźnika:
#include <iostream>
void changeValue(int* ptr) // uwaga: ptr jest w tym przypadku wskaźnikiem do wartości innej niż stała przykład
{
*ptr = 6; // zmień wartość na 6
}
int main()
{
int x{ 5 };
std::cout << "x = " << x << '\n';
changeValue(&x); // przekazujemy adres x do funkcji
std::cout << "x = " << x << '\n';
return 0;
}Wypisuje:
x = 5 x = 6
Jak widać, argument jest modyfikowany i modyfikacja ta utrzymuje się nawet po changeValue() zakończeniu działania.
Jeśli funkcja nie ma modyfikować przekazywanego obiektu, parametr funkcji powinien zostać zmieniony pointer-to-const:
void changeValue(const int* ptr) // uwaga: ptr jest teraz wskaźnikiem do const
{
*ptr = 6; // błąd: nie można zmienić wartości stałej
}Z wielu tych samych powodów zazwyczaj nie const zwykłych (niewskaźnikowych, nieodnoszących się) parametrów funkcji (omówionych w 5.1 -- Zmienne stałe (zwane stałymi)), zazwyczaj nie const parametrów funkcji wskaźnikowych. Zróbmy dwa stwierdzenia:
- słowo kluczowe
constużyte do utworzenia parametru funkcji wskaźnika wskaźnik const zapewnia niewielką wartość (ponieważ nie ma wpływu na osobę wywołującą i służy głównie jako dokumentacja, że wskaźnik się nie zmieni). - słowo kluczowe
constużyte do odróżnienia wskaźnika do stałej od wskaźnika do innej niż stała, który może modyfikować przekazywany obiekt, jest istotne (ponieważ wywołujący musi wiedzieć, czy funkcja może zmień wartość argumentu).
Jeśli użyjemy tylko parametrów funkcji wskaźnikowej innych niż stałe, wówczas wszystkie użycia const są istotne. Gdy tylko zaczniemy używać const dla parametrów funkcji wskaźnika stałego, trudniej będzie określić, czy dane użycie const jest znaczące czy nie. Co ważniejsze, utrudnia to także dostrzeżenie parametrów wskaźnika do wartości innych niż stałe. Na przykład:
void foo(const char* source, char* dest, int count); // Używając wskaźników innych niż stałe, wszystkie stałe są znaczące.
void foo(const char* const source, char* const dest, int count); // Używając wskaźników const, `dest` będący wskaźnikiem do nie-const może pozostać niezauważony w morzu fałszywych stałych.W pierwszym przypadku łatwo zauważyć, że source jest wskaźnikiem do stałej i dest jest wskaźnikiem do nie-stałej. W tym drugim przypadku znacznie trudniej jest zauważyć, że dest jest wskaźnikiem const-to-non-const, którego wskazany obiekt może być modyfikowany przez funkcję!
Najlepsza praktyka
Preferuj parametry funkcji wskaźnik do const zamiast parametrów funkcji wskaźnik do non-const, chyba że funkcja musi zmodyfikować przekazywany obiekt.
Nie twórz parametrów funkcji const wskaźników, chyba że istnieje jakiś konkretny powód aby to zrobić.
Sprawdzanie wartości zerowej
Rozważmy teraz całkiem niewinnie wyglądający program:
#include <iostream>
void print(int* ptr)
{
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
int* myPtr {};
print(myPtr);
return 0;
}Gdy ten program zostanie uruchomiony, wydrukuje wartość 5 , a następnie najprawdopodobniej ulegnie awarii.
W wywołaniu print(myPtr), myPtr jest wskaźnikiem zerowym, więc parametr funkcji ptr będzie również wskaźnikiem zerowym. Kiedy w treści funkcji następuje wyłuskiwanie tego wskaźnika zerowego, następuje niezdefiniowane zachowanie.
Przekazując parametr przez adres, przed wyłuskaniem wartości należy upewnić się, że wskaźnik nie jest wskaźnikiem zerowym. Jednym ze sposobów osiągnięcia tego jest użycie instrukcji warunkowej:
#include <iostream>
void print(int* ptr)
{
if (ptr) // jeśli ptr nie jest wskaźnikiem zerowym
{
std::cout << *ptr << '\n';
}
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}W powyższym programie testujemy ptr aby upewnić się, że nie ma ona wartości null, zanim ją usuniemy. Chociaż jest to w porządku w przypadku tak prostej funkcji, w bardziej skomplikowanych funkcjach może to skutkować nadmiarową logiką (wielokrotne sprawdzanie, czy ptr ma wartość null) lub zagnieżdżeniem podstawowej logiki funkcji (jeśli jest zawarta w bloku).
W większości przypadków skuteczniejsze jest postępowanie odwrotne: sprawdź, czy parametr funkcji ma wartość null jako warunek wstępny (9.6 — Assert i static_assert) i natychmiast zajmij się przypadkiem ujemnym:
#include <iostream>
void print(int* ptr)
{
if (!ptr) // jeśli ptr jest wskaźnikiem zerowym, wcześniej wróć z powrotem do wywołującego
return;
// jeśli doszliśmy do tego punktu, możemy założyć, że ptr jest poprawny
// więc nie trzeba już testować ani wymagane zagnieżdżanie
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}Jeśli a Do funkcji nigdy nie należy przekazywać wskaźnika zerowego, zamiast tego można użyć assert (o którym mówiliśmy w lekcji 9.6 — Assert i static_assert) (ponieważ asercje mają na celu dokumentowanie rzeczy, które nigdy nie powinny się wydarzyć):
#include <iostream>
#include <cassert>
void print(const int* ptr) // teraz wskaźnik do const int
{
assert(ptr); // niepowodzenie programu w trybie debugowania, jeśli zostanie przekazany wskaźnik zerowy (ponieważ to nigdy nie powinno się zdarzyć)
// (opcjonalnie) potraktuj to jako przypadek błędu w trybie produkcyjnym, abyśmy nie zawiesili się, jeśli tak się stanie
if (!ptr)
return;
std::cout << *ptr << '\n';
}
int main()
{
int x{ 5 };
print(&x);
print(nullptr);
return 0;
}Preferuj przekazywanie (const) referencji
Zauważ, że funkcja print() w powyższym przykładzie nie radzi sobie zbyt dobrze z wartościami null - w rzeczywistości po prostu przerywa funkcję. Biorąc to pod uwagę, po co w ogóle pozwalać użytkownikowi na przekazywanie wartości null? Przekazywanie przez referencję ma te same zalety, co przekazywanie przez adres, bez ryzyka przypadkowego wyłuskania wskaźnika zerowego.
Przekazywanie przez referencję stałą ma kilka innych zalet w porównaniu z przekazywaniem przez adres.
>Po pierwsze, ponieważ obiekt przekazywany przez adres musi mieć adres, przez adres można przekazywać tylko lwartości (ponieważ wartości nie mają adresów). Przekazywanie przez odwołanie do stałej jest bardziej elastyczne, ponieważ może akceptować lwartości i rwartości:
#include <iostream>
void printByValue(int val) // Funkcja parametru do kopiowania argumentu
{
std::cout << val << '\n'; // wydrukuj wartość przez kopię
}
void printByReference(const int& ref) // Parametr funkcji to referencja, która wiąże się z argumentem
{
std::cout << ref << '\n'; // wydrukuj wartość przez odwołanie
}
void printByAddress(const int* ptr) // Parametr funkcji to wskaźnik przechowujący adres argumentu
{
std::cout << *ptr << '\n'; // wydrukuj wartość za pomocą wyłuskanego wskaźnika
}
int main()
{
printByValue(5); // poprawna (ale tworzy kopię)
printByReference(5); // poprawna (ponieważ parametr jest odniesieniem do stałej)
printByAddress(&5); // błąd: nie można przyjąć adresu wartość r
return 0;
}Po drugie, składnia przekazywania przez referencję jest naturalna, ponieważ możemy po prostu przekazywać literały lub obiekty. W przypadku przekazywania adresu nasz kod jest zaśmiecony ampersandami (&) i gwiazdkami (*).
W nowoczesnym C++ większość rzeczy, które można wykonać za pomocą przekazywania adresu, można lepiej wykonać innymi metodami. Postępuj zgodnie z tą powszechną maksymą: „Przekazuj przez referencję, kiedy możesz, podawaj przez adres, kiedy musisz”.
Najlepsza praktyka
Preferuj podawanie przez referencję zamiast adresu, chyba że masz konkretny powód, aby użyć przekazywania przez adres.

