W przeciwieństwie do odniesienia do non-const (które może wiązać się tylko z modyfikowalnymi lwartościami), odwołanie do const może wiązać się z modyfikowalnymi lwartościami, niemodyfikowalne lwartości i rwartości. Dlatego też, jeśli utworzymy parametr odniesienia const, będzie on mógł powiązać się z dowolnym typem argumentu:
#include <iostream>
void printRef(const int& y) // y is a const reference
{
std::cout << y << '\n';
}
int main()
{
int x { 5 };
printRef(x); // ok: x is a modifiable lvalue, y binds to x
const int z { 5 };
printRef(z); // ok: z is a non-modifiable lvalue, y binds to z
printRef(5); // ok: 5 is rvalue literal, y binds to temporary int object
return 0;
}Przekazywanie przez odwołanie do const oferuje tę samą podstawową korzyść, co przekazanie przez odwołanie inne niż const (unikanie tworzenia kopii argumentu), gwarantując jednocześnie, że funkcja może nie zmienić wartość, do której się odwołuje.
Na przykład poniższe jest niedozwolone, ponieważ ref jest const:
void addOne(const int& ref)
{
++ref; // not allowed: ref is const
}W większości przypadków nie chcemy, aby nasze funkcje modyfikowały wartość argumentów.
Najlepsza praktyka
Preferujemy przekazywanie referencji const zamiast przekazywania referencji innych niż const, chyba że masz konkretny powód, aby postąpić inaczej (np. funkcja musi zmienić wartość argumentu).
Teraz rozumiemy motywację zezwalania referencjom const lvalue na wiązanie się z rwartościami: bez tej możliwości nie byłoby mowy do przekazywania literałów (lub innych wartości) do funkcji, które korzystały z przekazywania przez referencję!
Przekazując argumenty innego typu do parametru referencyjnego const lvalue
W lekcji 12.4 -- Odniesienia do wartości do const, zauważyliśmy, że referencja const lvalue może wiązać się z wartością innego typu, o ile wartość ta jest konwertowalna na typ odwołania. Konwersja tworzy tymczasowy obiekt, z którym może następnie zostać powiązany parametr referencyjny.
Główną motywacją zezwolenia na to jest to, że możemy przekazać wartość jako argument do parametru value lub const parametru referencyjnego dokładnie w ten sam sposób:
#include <iostream>
void printVal(double d)
{
std::cout << d << '\n';
}
void printRef(const double& d)
{
std::cout << d << '\n';
}
int main()
{
printVal(5); // 5 converted to temporary double, copied to parameter d
printRef(5); // 5 converted to temporary double, bound to parameter d
return 0;
}W przypadku przekazywania wartości oczekujemy, że zostanie wykonana kopia, więc jeśli konwersja nastąpi pierwsza (skutkująca dodatkową kopią), rzadko stanowi to problem (a kompilator prawdopodobnie zoptymalizuje jedną z dwóch kopii dalej).
Jednakże często używamy przekazywania przez odniesienie, gdy nie chcemy, aby została wykonana kopia. Jeśli najpierw nastąpi konwersja, zazwyczaj spowoduje to utworzenie (prawdopodobnie kosztownej) kopii, która może nie być optymalna.
Ostrzeżenie
W przypadku przekazywania przez referencję należy upewnić się, że typ argumentu odpowiada typowi referencji, w przeciwnym razie spowoduje to nieoczekiwaną (i prawdopodobnie kosztowną) konwersję.
Mieszanie przekazywania przez wartość i przekazywania przez referencję
Funkcja z wieloma parametrami może określić, czy każdy parametr jest przekazywany przez wartość, czy przekazywany przez referencję indywidualnie.
Na przykład:
#include <string>
void foo(int a, int& b, const std::string& c)
{
}
int main()
{
int x { 5 };
const std::string s { "Hello, world!" };
foo(5, x, s);
return 0;
}W powyższym przykładzie pierwszy argument jest przekazywany przez wartość, drugi przez referencję, a trzeci przez stałą referencję.
Kiedy używać przekazywania przez wartość, a kiedy przez referencję
Dla większości początkujących użytkowników C++ wybór, czy używać przekazywania przez wartość, czy przez referencję, nie jest zbyt oczywisty. Na szczęście istnieje prosta zasada, która sprawdzi się w większości przypadków.
- Typy podstawowe i typy wyliczeniowe są tanie w kopiowaniu, dlatego zazwyczaj przekazuje się je przez wartość.
- Kopiowanie typów klas może być kosztowne (czasami znacząco), dlatego zazwyczaj przekazuje się je przez odwołanie do stałej.
Najlepsza praktyka
Ogólną zasadą jest przekazywanie typów podstawowych według wartości, a typów klas przez const referencja.
Jeśli nie masz pewności, co zrobić, pomiń odwołanie do stałej, ponieważ jest mniej prawdopodobne, że napotkasz nieoczekiwane zachowanie.
Wskazówka
Oto częściowa lista innych interesujących przypadków:
Następujące elementy są często przekazywane przez wartość (ponieważ jest to bardziej wydajne):
- Typy wyliczeniowe (wyliczenia o ograniczonym i ograniczonym zakresie).
- Widoki i spans (np.
std::string_view,std::span). - Typy naśladujące referencje lub (niebędące właścicielami) wskaźniki (np. iteratory,
std::reference_wrapper). - Typy klas tanie do skopiowania, które mają semantykę wartości (np.
std::pairz elementami typów podstawowych,std::optional,std::expected).
Przekazywanie przez referencje powinno być stosowane w przypadku:
- Argumenty, które muszą zostać zmodyfikowane przez funkcję.
- Typy, których nie można kopiować (takie jak
std::ostream). - Typy, których kopiowanie ma konsekwencje związane z własnością, których chcemy uniknąć (np.
std::unique_ptr,std::shared_ptr). - Typy, które mają funkcje wirtualne lub od których prawdopodobnie zostaną odziedziczone (ze względu na obiekt problemy związane z dzieleniem, omówione w lekcji 25.9 -- Dzielenie obiektów).
Koszt przekazywania przez wartość a przekazywanie przez referencję Zaawansowane
Nie wszystkie typy klas muszą być przekazywane przez referencję (np. std::string_view, które zwykle jest przekazywane przez wartość). Możesz się zastanawiać, dlaczego nie przekazujemy wszystkiego po prostu przez referencję. W tej sekcji (która jest lekturą opcjonalną) omawiamy koszt przekazywania przez wartość w porównaniu z przekazywaniem przez referencję i doprecyzowujemy nasze najlepsze praktyki, kiedy powinniśmy to zrobić. każdy.
Po pierwsze, musimy wziąć pod uwagę koszt inicjalizacji parametru funkcji. W przypadku przekazywania wartości, inicjalizacja oznacza utworzenie kopii. Koszt kopiowania obiektu jest zazwyczaj proporcjonalny do dwóch rzeczy:
- Rozmiar obiektu, który zajmuje więcej pamięci, kopiowanie zajmuje więcej czasu.
- Wszelkie dodatkowe koszty konfiguracji niektórych typów klas wymagają dodatkowej konfiguracji podczas tworzenia instancji (np. otwarcia pliku lub bazy danych lub przydzielenia określonej ilości pamięci dynamicznej do przechowywania obiektu o zmiennym rozmiarze). Te koszty konfiguracji muszą być poniesione za każdym razem, gdy obiekt jest kopiowany.
Z drugiej strony, powiązanie referencji z obiektem jest zawsze szybkie (mniej więcej z tą samą szybkością, co kopiowanie typu podstawowego).
Po drugie, musimy wziąć pod uwagę koszt użycia parametru funkcji. Podczas konfigurowania wywołania funkcji kompilator może przeprowadzić optymalizację poprzez umieszczenie referencji lub kopii argument przekazywany przez wartość (jeśli jest mały) do rejestru procesora (do którego dostęp jest szybki), a nie do pamięci RAM (do której dostęp jest wolniejszy).
Za każdym razem, gdy używany jest parametr wartości, działający program może bezpośrednio uzyskać dostęp do lokalizacji przechowywania (rejestr procesora lub pamięć RAM) skopiowanego argumentu. Jednak w przypadku użycia parametru referencyjnego zwykle wykonywany jest dodatkowy krok, aby określić Tylko wtedy może uzyskać dostęp do miejsca przechowywania odwoływanego obiektu (w pamięci RAM).
Dlatego każde użycie parametru wartości oznacza dostęp do pojedynczego rejestru procesora lub pamięci RAM, podczas gdy każde użycie parametru odniesienia oznacza dostęp do pojedynczego rejestru procesora lub pamięci RAM plus drugi dostęp do pamięci RAM.
Po trzecie, kompilator może czasami zoptymalizować kod wykorzystujący przekazywanie wartości skuteczniej niż kod wykorzystujący przekazywanie przez referencję. W szczególności optymalizatory muszą być konserwatywne, gdy istnieje ryzyko aliasingu (kiedy dwa lub więcej wskaźników lub referencji może uzyskać dostęp do tego samego obiektu). Ponieważ przekazanie wartości skutkuje kopią wartości argumentów, nie ma szans na wystąpienie aliasingu, co pozwala na bardziej agresywne działanie optymalizatorów.
Możemy teraz odpowiedzieć na pytanie, dlaczego nie przekazujemy wszystkiego przez referencję:
- W przypadku obiektów, które są tanie w użyciu kopii, koszt kopiowania jest podobny do kosztu wiązania, ale dostęp do obiektów jest szybszy, a kompilator prawdopodobnie będzie w stanie lepiej zoptymalizować.
- W przypadku obiektów, których kopiowanie jest drogie, koszt kopiowania dominuje nad innymi czynnikami związanymi z wydajnością.
Ostatnie pytanie brzmi zatem, jak zdefiniować „tanie kopiowanie” Nie ma tutaj jednoznacznej odpowiedzi, ponieważ różni się to w zależności od kompilatora, przypadku użycia i architektury dobra praktyczna zasada: kopiowanie obiektu jest tanie, jeśli wykorzystuje 2 lub mniej „słów” w pamięci (gdzie „słowo” jest przybliżane przez rozmiar adresu pamięci) i nie wiąże się z kosztami konfiguracji.
Następujący program definiuje makro funkcyjne, którego można użyć do określenia, czy kopiowanie typu (lub obiektu) jest tanie:
#include <iostream>
// Function-like macro that evaluates to true if the type (or object) is equal to or smaller than
// the size of two memory addresses
#define isSmall(T) (sizeof(T) <= 2 * sizeof(void*))
struct S
{
double a;
double b;
double c;
};
int main()
{
std::cout << std::boolalpha; // print true or false rather than 1 or 0
std::cout << isSmall(int) << '\n'; // true
double d {};
std::cout << isSmall(d) << '\n'; // true
std::cout << isSmall(S) << '\n'; // false
return 0;
}Na marginesie…
Używamy tutaj makra funkcyjnego preprocesora, dzięki czemu możemy podać obiekt LUB nazwę typu jako parametr (ponieważ funkcje C++ nie pozwalają na przekazywanie typów jako parametru).
Jednak może być trudno stwierdzić, czy obiekt typu klasy ma koszty konfiguracji, czy nie. Najlepiej założyć, że większość standardowych klas bibliotek ma koszty konfiguracji, chyba że wiesz inaczej, że tak nie jest.
Wskazówka
Obiekt typu T jest tani w kopiowaniu, jeśli sizeof(T) <= 2 * sizeof(void*) i nie wiąże się z dodatkowymi kosztami konfiguracji.
W przypadku parametrów funkcji preferuj std::string_view za const std::string& w większości przypadków
Jedno pytanie, które często pojawia się we współczesnym C++: podczas pisania funkcji, która ma parametr łańcuchowy, czy typ parametr be const std::string& lub std::string_view?
W większości przypadków std::string_view jest lepszym wyborem, ponieważ może efektywnie obsługiwać szerszy zakres typów argumentów. Parametr std::string_view pozwala również wywołującemu przekazać podciąg bez konieczności kopiowania go najpierw do własnego ciągu.
void doSomething(const std::string&);
void doSomething(std::string_view); // prefer this in most casesIstnieje kilka przypadków, w których użycie parametru const std::string& może być bardziej odpowiednie:
- Jeśli używasz C++ 14 lub starszego,
std::string_viewnie jest dostępne. - Jeśli Twój funkcja musi wywołać inną funkcję, która pobiera ciąg znaków w stylu C lub
std::stringparametr, wtedyconst std::string&może być lepszym wyborem, ponieważ nie ma gwarancji, żestd::string_viewbędzie zakończone znakiem null (coś, czego oczekują funkcje ciągów w stylu C) i nie jest efektywnie konwertowana z powrotem dostd::string.
Najlepsza praktyka
Preferuj przekazywanie ciągów używając zamiast tego std::string_view (według wartości) of const std::string&, chyba że twoja funkcja wywołuje inne funkcje, które wymagają ciągów w stylu C lub std::string parametrów.
Dlaczego std::string_view parametry są bardziej wydajne niż const std::string& Zaawansowane
W C++ argumentem ciągu będzie zazwyczaj std::string, a std::string_view lub literał/łańcuch w stylu C.
Jak przypomnienia:
- Jeśli typ argumentu nie jest zgodny z typem odpowiedniego parametru, kompilator spróbuje niejawnie przekonwertować argument tak, aby pasował do typu parametru.
- Konwersja wartości powoduje utworzenie obiektu tymczasowego skonwertowanego typu.
- Tworzenie (lub kopiowanie)
std::string_viewjest niedrogie, ponieważstd::string_viewnie tworzy kopii ciąg, który przegląda. - Tworzenie (lub kopiowanie)
std::stringmoże być kosztowne, ponieważ każdystd::stringobiekt tworzy kopię ciągu.
Oto tabela pokazująca, co się dzieje, gdy próbujemy przekazać każdy typ:
| Typ argumentu | std::string_view parametr | const std::string& parametr |
|---|---|---|
| std::string | Niedrogie konwersja | Niedrogie wiązanie referencji |
| std::string_view | Niedroga kopia | Kosztowna jawna konwersja na std::string |
| Ciąg / literał w stylu C | Niedrogie konwersja | Kosztowna konwersja |
Dzięki std::string_view parametrem wartości:
- Jeśli przekażemy
std::stringargument, kompilator przekonwertujestd::stringdostd::string_view, co jest niedrogie, więc jest w porządku. - Jeśli przekażemy
std::string_view, kompilator skopiuje argument do parametru, co jest niedrogie, więc nie ma w tym nic złego. - Jeśli przekażemy ciąg znaków lub literał w stylu C, kompilator skonwertuje je na
std::string_view, co jest niedrogie, więc jest w porządku.
Jak widać, std::string_view obsługuje wszystkie trzy przypadki niedrogo.
Dzięki const std::string& parametr referencyjny:
- Jeśli przekażemy
std::stringargument, parametr będzie odwoływał się do powiązania z argumentem, co jest niedrogie, więc nie ma w tym nic złego. - Jeśli przekażemy
std::string_viewargument, kompilator odmówi wykonania niejawnej konwersji i wygeneruje błąd kompilacji. Możemy użyćstatic_castdo wykonania jawnej konwersji (dostd::string), ale ta konwersja jest kosztowna (ponieważstd::stringutworzy kopię przeglądanego ciągu znaków). Po zakończeniu konwersji parametr będzie odwoływał się do powiązania z wynikiem, co jest niedrogie. Ale do konwersji wykonaliśmy kosztowną kopię, więc nie jest to zbyt dobre rozwiązanie. - Jeśli przekażemy ciąg znaków lub literał ciągu w stylu C, kompilator niejawnie przekonwertuje to na
std::string, co jest kosztowne. Więc to też nie jest świetne.
Zatem a const std::string& Parametr obsługuje tylko std::string argumenty niedrogo.
To samo w formie kodu:
#include <iostream>
#include <string>
#include <string_view>
void printSV(std::string_view sv)
{
std::cout << sv << '\n';
}
void printS(const std::string& s)
{
std::cout << s << '\n';
}
int main()
{
std::string s{ "Hello, world" };
std::string_view sv { s };
// Pass to `std::string_view` parameter
printSV(s); // ok: inexpensive conversion from std::string to std::string_view
printSV(sv); // ok: inexpensive copy of std::string_view
printSV("Hello, world"); // ok: inexpensive conversion of C-style string literal to std::string_view
// pass to `const std::string&` parameter
printS(s); // ok: inexpensive bind to std::string argument
printS(sv); // compile error: cannot implicit convert std::string_view to std::string
printS(static_cast<std::string>(sv)); // bad: expensive creation of std::string temporary
printS("Hello, world"); // bad: expensive creation of std::string temporary
return 0;
}Dodatkowo musimy wziąć pod uwagę koszt dostępu do parametru wewnątrz funkcji. Ponieważ parametr std::string_view jest normalnym obiektem, dostęp do przeglądanego ciągu można uzyskać bezpośrednio. Dostęp do std::string& parametru wymaga dodatkowego kroku, aby dostać się do obiektu, do którego się odnosi, zanim będzie można uzyskać dostęp do łańcucha.
Na koniec, jeśli chcemy przekazać podciąg istniejącego ciągu (dowolnego typu), stosunkowo tanie jest utworzenie std::string_view podciągu, który można następnie tanio przekazać do parametru std::string_view . Dla porównania, przekazanie podciągu do const std::string& jest droższe, ponieważ podciąg musi w pewnym momencie zostać skopiowany do std::string , z którym powiązany jest parametr odniesienia.

