9.5 — Przekaż przez odniesienie do lwartości

9.5 — Przekaż przez odniesienie do lwartości

W poprzednich lekcjach wprowadziliśmy odniesienia do lwartości (9,3 — odniesienia do Lwartości) i odniesienia do lwartości do const (9,4 — odniesienia do Lwartości do const). W odosobnieniu może się to wydawać niezbyt przydatne — po co tworzyć alias do zmiennej, skoro można po prostu użyć samej zmiennej?

W tej lekcji w końcu przedstawimy wgląd w to, co sprawia, że ​​referencje są przydatne. A potem, zaczynając w dalszej części tego rozdziału, zobaczysz odnośniki używane regularnie.

Najpierw trochę kontekstu. Wracając do lekcji 2.4 -- Wprowadzenie do parametrów i argumentów funkcji omówiliśmy pass by value , gdzie argument przekazany do funkcji jest kopiowany do parametru funkcji:

#include <iostream>

void printValue(int y)
{
    std::cout << y << '\n';
} // y is destroyed here

int main()
{
    int x { 2 };

    printValue(x); // x is passed by value (copied) into parameter y (inexpensive)

    return 0;
}

W powyższym programie, gdy printValue(x) jest wywoływana, wartość x (2 ) jest kopiowany do parametru y . Następnie na końcu funkcji obiekt y jest zniszczony.

Oznacza to, że kiedy wywołaliśmy funkcję, zrobiliśmy kopię wartości naszego argumentu tylko po to, by użyć jej krótko, a następnie ją zniszczyć! Na szczęście, ponieważ podstawowe typy są tanie w kopiowaniu, nie stanowi to problemu.

Kopiowanie niektórych obiektów jest drogie

Większość typów dostarczanych przez standardową bibliotekę (takich jak std::string ) to class types . Typy klas są zwykle drogie do kopiowania. Jeśli to możliwe, chcemy uniknąć tworzenia niepotrzebnych kopii obiektów, których kopiowanie jest kosztowne, zwłaszcza gdy zniszczymy te kopie niemal natychmiast.

Rozważ następujący program ilustrujący ten punkt:

#include <iostream>
#include <string>

void printValue(std::string y)
{
    std::cout << y << '\n';
} // y is destroyed here

int main()
{
    std::string x { "Hello, world!" }; // x is a std::string

    printValue(x); // x is passed by value (copied) into parameter y (expensive)

    return 0;
}

To drukuje

Hello, world!

Chociaż ten program zachowuje się tak, jak się spodziewamy, jest również nieefektywny. Identycznie jak w poprzednim przykładzie, gdy printValue() wywoływany jest argument x jest kopiowany do printValue() parametr y . Jednak w tym przykładzie argumentem jest std::string zamiast int i std::string jest typem klasy, którego kopiowanie jest drogie. A ta droga kopia jest tworzona za każdym razem printValue() nazywa się!

Możemy zrobić lepiej.

Przekaż przez odniesienie

Jednym ze sposobów uniknięcia tworzenia kosztownej kopii argumentu podczas wywoływania funkcji jest użycie pass by reference zamiast pass by value . Używając przekazywania przez odwołanie, deklarujemy parametr funkcji jako typ referencyjny (lub stały typ referencyjny), a nie jako typ normalny. Gdy funkcja jest wywoływana, każdy parametr referencyjny jest powiązany z odpowiednim argumentem. Ponieważ odwołanie działa jako alias argumentu, nie jest tworzona kopia argumentu.

Oto ten sam przykład, co powyżej, przy użyciu przekazywania przez referencję zamiast przekazywania przez wartość:

#include <iostream>
#include <string>

void printValue(std::string& y) // type changed to std::string&
{
    std::cout << y << '\n';
} // y is destroyed here

int main()
{
    std::string x { "Hello, world!" };

    printValue(x); // x is now passed by reference into reference parameter y (inexpensive)

    return 0;
}

Ten program jest identyczny z poprzednim, z wyjątkiem typu parametru y został zmieniony z std::string do std::string& (odwołanie do lwartości). Teraz, gdy printValue(x) jest wywoływana, parametr referencyjny lvalue y jest powiązany z argumentem x . Powiązanie referencji jest zawsze niedrogie i nie ma kopii x trzeba zrobić. Ponieważ odwołanie działa jak alias dla obiektu, do którego się odwołuje, gdy printValue() używa referencji y , uzyskuje dostęp do rzeczywistego argumentu x (zamiast kopii x ).

Kluczowe spostrzeżenia

Przekazywanie przez referencję pozwala nam przekazać argumenty do funkcji bez tworzenia kopii tych argumentów za każdym razem, gdy funkcja jest wywoływana.

Przekazywanie przez referencję pozwala nam zmienić wartość argumentu

Gdy obiekt jest przekazywany przez wartość, parametr funkcji otrzymuje kopię argumentu. Oznacza to, że wszelkie zmiany wartości parametru są dokonywane w kopii argumentu, a nie w samym argumencie:

#include <iostream>

void addOne(int y) // y is a copy of x
{
    ++y; // this modifies the copy of x, not the actual object x
}

int main()
{
    int x { 5 };

    std::cout << "value = " << x << '\n';

    addOne(x);

    std::cout << "value = " << x << '\n'; // x has not been modified

    return 0;
}

W powyższym programie, ponieważ wartość parametru y jest kopią x , gdy zwiększamy y , dotyczy to tylko y . Ten program wyprowadza:

value = 5
value = 5

Ponieważ jednak referencja działa identycznie jak obiekt, do którego się odwołujemy, podczas korzystania z funkcji przekazywania przez referencję wszelkie zmiany wprowadzone w parametrze referencji spowodują wpływają na argument:

#include <iostream>

void addOne(int& y) // y is bound to the actual object x
{
    ++y; // this modifies the actual object x
}

int main()
{
    int x { 5 };

    std::cout << "value = " << x << '\n';

    addOne(x);

    std::cout << "value = " << x << '\n'; // x has been modified

    return 0;
}

Ten program wyprowadza:

value = 5
value = 6

W powyższym przykładzie x początkowo ma wartość 5 . Kiedy addOne(x) jest wywoływany, parametr referencyjny y jest powiązany z argumentem x . Kiedy addOne() funkcja zwiększa referencję y , to faktycznie zwiększa argument x z 5 do 6 (nie kopia x ). Ta zmieniona wartość utrzymuje się nawet po addOne() zakończył wykonywanie.

Kluczowe spostrzeżenia

Przekazywanie wartości przez odniesienie do niestałych pozwala nam pisać funkcje, które modyfikują wartość przekazywanych argumentów.

Przydatna może być możliwość modyfikowania przez funkcje wartości przekazanych argumentów. Wyobraź sobie, że napisałeś funkcję, która określa, czy potwór skutecznie zaatakował gracza. Jeśli tak, potwór powinien zadać pewną ilość obrażeń zdrowiu gracza. Jeśli przekażesz swój obiekt odtwarzacza przez odwołanie, funkcja może bezpośrednio zmodyfikować stan faktycznego obiektu odtwarzacza, który został przekazany. Jeśli przekażesz obiekt odtwarzacza według wartości, możesz zmodyfikować tylko stan kopii obiektu odtwarzacza, co nie jest tak przydatne.

Przekazywanie przez referencję do non-const może akceptować tylko modyfikowalne argumenty l-wartości

Ponieważ odwołanie do wartości niestałej może wiązać się tylko z modyfikowalną lwartością (zasadniczo zmienną niestałą), oznacza to, że przekazywanie przez referencję działa tylko z argumentami, które są modyfikowalnymi lwartościami. W praktyce znacznie ogranicza to użyteczność pass przez odniesienie do non-const, ponieważ oznacza to, że nie możemy przekazać const zmiennych ani literałów. Na przykład:

#include <iostream>
#include <string>

void printValue(int& y) // y only accepts modifiable lvalues
{
    std::cout << y << '\n';
}

int main()
{
    int x { 5 };
    printValue(x); // ok: x is a modifiable lvalue

    const int z { 5 };
    printValue(z); // error: z is a non-modifiable lvalue

    printValue(5); // error: 5 is an rvalue

    return 0;
}

Na szczęście istnieje prosty sposób na obejście tego.

Przekaż przez stałe odniesienie

W przeciwieństwie do referencji do non-const (która może wiązać się tylko z modyfikowalnymi l-wartościami), referencja do const może wiązać się z modyfikowalnymi l-wartościami, niemodyfikowalnymi l-wartościami i r-wartościami. Dlatego, jeśli ustawimy nasz parametr referencyjny const, będzie on w stanie powiązać się z dowolnym typem argumentu:

#include <iostream>
#include <string>

void printValue(const int& y) // y is now a const reference
{
    std::cout << y << '\n';
}

int main()
{
    int x { 5 };
    printValue(x); // ok: x is a modifiable lvalue

    const int z { 5 };
    printValue(z); // ok: z is a non-modifiable lvalue

    printValue(5); // ok: 5 is a literal rvalue

    return 0;
}

Przekazywanie przez const reference oferuje tę samą podstawową korzyść, co przekazywanie przez referencję (unikając tworzenia kopii argumentu), jednocześnie gwarantując, że funkcja nie zmienić wartość, do której się odwołujemy.

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 zmieniały wartości argumentów.

Najlepsza praktyka

Preferuj przekazywanie przez stałą referencję zamiast przez niestałą referencję, chyba że masz konkretny powód, aby zrobić inaczej (np. funkcja musi zmienić wartość argumentu).

Teraz możemy zrozumieć motywację pozwalającą, aby referencje do stałej lwartości były wiązane z rwartościami:bez tej możliwości nie byłoby możliwości przekazywania literałów (lub innych rwartości) do funkcji, które używały przekazywania przez referencję!

Mieszanie przekaż według wartości i przekaż przez odniesienie

Funkcja z wieloma parametrami może określić, czy każdy parametr jest przekazywany przez wartość, czy przez odwołanie 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 odwołanie, a trzeci przez stałe odwołanie.

Kiedy przekazać przez odniesienie

Ponieważ kopiowanie typów klas może być kosztowne (czasami znacznie), typy klas są zwykle przekazywane przez const reference zamiast value, aby uniknąć tworzenia kosztownej kopii argumentu. Typy podstawowe są tanie w kopiowaniu, więc zazwyczaj są przekazywane według wartości.

Najlepsza praktyka

Przekaż typy podstawowe według wartości, a typy klasy (lub struktury) według odniesienia do const.

Koszt przekazania przez wartość w porównaniu z przekazaniem przez odniesienie (zaawansowane)

Nie wszystkie typy klas muszą być przekazywane przez referencję. I możesz się zastanawiać, dlaczego nie przekazujemy wszystkiego przez odniesienie. W tej sekcji (która jest lekturą opcjonalną) omawiamy koszt przekazywania przez wartość w porównaniu z przekazywaniem przez odniesienie i udoskonalamy nasze najlepsze praktyki dotyczące tego, kiedy powinniśmy używać każdego z nich.

Istnieją dwa kluczowe punkty, które pomogą nam zrozumieć, kiedy powinniśmy przekazywać wartość, a kiedy przekazywać przez odniesienie:

Po pierwsze, koszt kopiowania obiektu jest generalnie proporcjonalny do dwóch rzeczy:

  • Rozmiar obiektu. Kopiowanie obiektów, które zużywają więcej pamięci, zajmuje więcej czasu.
  • Wszelkie dodatkowe koszty instalacji. Niektóre typy klas dokonują dodatkowej konfiguracji podczas tworzenia instancji (np. otwierają plik lub bazę danych lub przydzielają pewną ilość pamięci dynamicznej do przechowywania obiektu o zmiennej wielkości). Te koszty instalacji muszą być opłacone za każdym razem, gdy obiekt jest kopiowany.

Z drugiej strony wiązanie odniesienia do obiektu jest zawsze szybkie (mniej więcej z taką samą szybkością, jak kopiowanie podstawowego typu).

Po drugie, dostęp do obiektu poprzez odwołanie jest nieco droższy niż dostęp do obiektu poprzez normalny identyfikator zmiennej. Z identyfikatorem zmiennej kompilator może po prostu przejść do adresu pamięci przypisanego do tej zmiennej i uzyskać dostęp do wartości. Z referencją zwykle jest dodatkowy krok:kompilator musi najpierw określić, do którego obiektu się odwołuje, a dopiero potem może przejść do tego adresu pamięci dla tego obiektu i uzyskać dostęp do wartości. Kompilator może również czasami optymalizować kod przy użyciu obiektów przekazanych przez wartość w większym stopniu niż kod przy użyciu obiektów przekazanych przez odwołanie. Oznacza to, że kod generowany dla obiektów przekazywanych przez referencję jest zazwyczaj wolniejszy niż kod generowany dla obiektów przekazywanych przez wartość.

Możemy teraz odpowiedzieć na pytanie, dlaczego nie przekazujemy wszystkiego przez odniesienie:

  • W przypadku obiektów, które są tanie w kopiowaniu, koszt kopiowania jest podobny do kosztu oprawy, dlatego preferujemy przekazywanie wartości, aby wygenerowany kod był szybszy.
  • W przypadku obiektów, które są drogie w kopiowaniu, dominuje koszt kopii, dlatego preferujemy przekazywanie (const) referencji, aby uniknąć tworzenia kopii.

Najlepsza praktyka

Preferuj przekazywanie według wartości dla obiektów, które są tanie w kopiowaniu, i przekaż przez stałe odwołanie dla obiektów, które są drogie w kopiowaniu. Jeśli nie masz pewności, czy kopiowanie obiektu jest tanie, czy drogie, łaska przekaż przez stałe odniesienie.

Ostatnie pytanie brzmi zatem, jak definiujemy „tanie do kopiowania”? Nie ma tu absolutnej odpowiedzi, ponieważ zależy to od kompilatora, przypadku użycia i architektury. Możemy jednak sformułować dobrą praktyczną zasadę:obiekt jest tani do skopiowania, jeśli używa 2 lub mniej „słów” pamięci (gdzie „słowo” jest aproksymowane przez rozmiar adresu pamięci) i nie ma kosztów konfiguracji .

Poniższy program definiuje makro, którego można użyć do określenia, czy typ (lub obiekt) używa 2 lub mniej adresów pamięci o wartości pamięci:

#include <iostream>

// Evaluates to true if the type (or object) uses 2 or fewer memory addresses worth of memory
#define isSmall(T) (sizeof(T) <= 2 * sizeof(void*))

struct S
{
    double a, b, c;
};

int main()
{
    std::cout << std::boolalpha; // print true or false rather than 1 or 0
    std::cout << isSmall(int) << '\n'; // true
    std::cout << isSmall(double) << '\n'; // true
    std::cout << isSmall(S) << '\n'; // false

    return 0;
}

Na marginesie…

Używamy tutaj makra preprocesora, dzięki czemu możemy zastąpić w typie (normalne funkcje na to nie pozwalają).

Jednak może być trudno stwierdzić, czy obiekt typu klasy ma koszty konfiguracji, czy nie. Najlepiej założyć, że większość standardowych klas bibliotecznych ma koszty konfiguracji, chyba że wiesz inaczej, że tak nie jest.

Wskazówka

Obiekt typu T jest tani do skopiowania, jeśli sizeof(T) <= 2 * sizeof(void*) i nie ma dodatkowych kosztów konfiguracji.

Typowe typy, które są tanie w kopiowaniu, obejmują wszystkie typy podstawowe, typy wyliczeniowe i std::string_view.
Typowe typy, których kopiowanie jest kosztowne, obejmują std::array, std::string, std::vector, i std::ostream.