16.5 — Powrót std::vector i wprowadzenie do przenoszenia semantyki

Kiedy musimy przekazać std::vector do funkcji, przekazujemy ją (const) referencję, abyśmy nie tworzyli kosztownej kopii danych tablicy.

Dlatego prawdopodobnie będziesz zaskoczony, gdy stwierdzisz, że można zwrócić std::vector według wartości.

Powiedz whaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaat?

Kopiuj semantykę

Rozważ następujący program:

#include <iostream>
#include <vector>

int main()
{
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    std::vector arr2 { arr1 };          // copies arr1 into arr2

    arr1[0] = 6; // We can continue to use arr1
    arr2[0] = 7; // and we can continue to use arr2

    std::cout << arr1[0] << arr2[0] << '\n';

    return 0;
}

Po wywołaniu arr2 inicjowania za pomocą arr1, wywoływany jest konstruktor kopiujący std::vector , który kopiuje arr1 do arr2.

Tworzenie kopia jest jedyną rozsądną rzeczą, jaką można zrobić w tym przypadku, ponieważ oboje potrzebujemy arr1 i arr2 by żyć niezależnie. Ten przykład kończy się utworzeniem dwóch kopii, po jednej na każdą inicjalizację.

Termin kopiuj semantykę odnosi się do reguł określających sposób tworzenia kopii obiektów. Kiedy mówimy, że typ obsługuje semantykę kopiowania, mamy na myśli, że obiekty tego typu można kopiować, ponieważ zdefiniowano zasady tworzenia takich kopii. Kiedy mówimy, że wywoływana jest semantyka kopiowania, oznacza to, że zrobiliśmy coś, co spowoduje utworzenie kopii obiektu.

W przypadku typów klas semantyka kopiowania jest zazwyczaj implementowana za pomocą konstruktora kopiującego (i operatora przypisania kopiowania), który definiuje sposób kopiowania obiektów tego typu. Zwykle powoduje to skopiowanie każdego elementu danych danego typu klasy. W poprzednim przykładzie instrukcja std::vector arr2 { arr1 }; wywołuje semantykę kopiowania, w wyniku czego następuje wywołanie konstruktora kopiującego std::vector, który następnie tworzy kopię każdego elementu danych arr1 do arr2. Efekt końcowy jest taki, że arr1 jest równoważna (ale niezależna od) arr2.

Kiedy semantyka kopiowania nie jest optymalna

Rozważmy teraz powiązany przykład:

#include <iostream>
#include <vector>

std::vector<int> generate() // return by value
{
    // We're intentionally using a named object here so mandatory copy elision doesn't apply
    std::vector arr1 { 1, 2, 3, 4, 5 }; // copies { 1, 2, 3, 4, 5 } into arr1
    return arr1;
}

int main()
{
    std::vector arr2 { generate() }; // the return value of generate() dies at the end of the expression

    // There is no way to use the return value of generate() here
    arr2[0] = 7; // we only have access to arr2

    std::cout << arr2[0] << '\n';

    return 0;
}

Po wywołaniu arr2 jest inicjalizowane to czasie jest inicjowany przy użyciu obiektu tymczasowego zwróconego przez funkcję generate(). W przeciwieństwie do poprzedniego przypadku, gdzie inicjatorem była wartość, która mogła zostać użyta w przyszłych instrukcjach, w tym przypadku obiekt tymczasowy będący wartością zostanie zniszczona na końcu wyrażenia inicjującego. Obiekt tymczasowy nie może być używany poza tym punktem. Ponieważ plik tymczasowy (wraz z jego danymi) zostanie zniszczony na końcu wyrażenia, potrzebujemy sposobu, aby przenieść dane z pliku tymczasowego do arr2.

Zwykle należy to zrobić tak samo, jak w poprzednim przykładzie: użyj semantyki kopiowania i utwórz potencjalnie kosztowną kopię. W ten sposób arr2 uzyskuje własną kopię danych, której można użyć nawet po zniszczeniu pliku tymczasowego (i jego danych).

Jednak tym, co odróżnia ten przypadek od poprzedniego przykładu, jest to, że plik tymczasowy i tak zostanie zniszczony. Po zakończeniu inicjalizacji plik tymczasowy nie potrzebuje już swoich danych (dlatego możemy go zniszczyć). Nie potrzebujemy dwóch zestawów danych, aby istnieć jednocześnie. W takich przypadkach wykonanie potencjalnie drogiej kopii, a następnie zniszczenie oryginalnych danych nie jest optymalne.

Wprowadzenie do przeniesienia semantyki

Zamiast tego, co by było, gdyby istniał sposób arr2 na „kradzież” danych tymczasowych zamiast ich kopiowania? arr2 byłby wtedy nowym właścicielem danych i nie trzeba byłoby wykonywać żadnej kopii danych. Kiedy własność danych zostaje przeniesiona z jednego obiektu na drugi, mówimy, że dane zostały wzruszony. Koszt takiego przeniesienia jest zazwyczaj trywialny (zwykle tylko dwa lub trzy przypisania wskaźników, czyli znacznie szybciej niż kopiowanie tablicy danych!).

Dodatkową korzyścią jest to, że gdy plik tymczasowy zostanie następnie zniszczony na końcu wyrażenia, nie będzie już miał żadnych danych do zniszczenia, więc też nie będziemy musieli ponosić tego kosztu.

Na tym polega istota przeniesienia semantyka, który odnosi się do reguł określających sposób przenoszenia danych z jednego obiektu do innego obiektu. Po wywołaniu semantyki przenoszenia każdy element danych, który można przenieść, zostanie przeniesiony, a każdy element danych, którego nie można przenieść, zostanie skopiowany. Możliwość przenoszenia danych zamiast ich kopiowania może sprawić, że semantyka przenoszenia będzie bardziej wydajna niż semantyka kopiowania, szczególnie gdy możemy zastąpić kosztowną kopię niedrogim przeniesieniem.

Kluczowa informacja

Semantyka przenoszenia to optymalizacja, która pozwala nam, w pewnych okolicznościach, niedrogo przenieść własność niektórych elementów danych z jednego obiektu na inny (zamiast tworzenia droższej kopii).

Zamiast tego kopiowane są elementy danych, których nie można przenieść.

Jak przenieść wywoływana jest semantyka

Zwykle, gdy obiekt jest inicjowany (lub przypisywany) obiektowi tego samego typu, zostanie użyta semantyka kopiowania (zakładając, że kopia nie zostanie usunięta).

Powiązana treść

Omówiliśmy eliminację kopiowania w lekcji 14.15 — Inicjalizacja klasy i elizja kopiowania.

Jeśli jednak wszystkie poniższe warunki są spełnione, zamiast tego zostanie wywołana semantyka przenoszenia:

  • Typ obiekt obsługuje semantykę przenoszenia.
  • Obiekt jest inicjowany (lub przypisywany) obiektowi o wartości (tymczasowej) tego samego typu.
  • Przeniesienie nie jest pomijane.

Oto smutna wiadomość: niewiele typów obsługuje semantykę przenoszenia. Jednakże std::vector i std::string oba tak!

Przyjrzymy się bardziej szczegółowo działaniu semantyki ruchu w rozdziale 22. Na razie wystarczy wiedzieć, czym jest semantyka przenoszenia i które typy umożliwiają przenoszenie.

Możemy zwrócić typy obsługujące przenoszenie, takie jak std::vector według wartości

Ponieważ funkcja zwrotu przez wartość zwraca wartość, jeśli zwrócony typ obsługuje semantykę przenoszenia, wówczas zwróconą wartość można przenieść zamiast kopiować do obiektu docelowego. To sprawia, że ​​zwracanie wartości jest w przypadku tego typu niezwykle niedrogie!

Kluczowa informacja

Możemy zwracać typy umożliwiające przenoszenie (np std::vector i std::string) według wartości. Takie typy niedrogo przeniosą swoje wartości, zamiast tworzyć kosztowną kopię.

Typy takie powinny być nadal przekazywane przez stałą referencję.

Czekaj, czekaj, czekaj. Typy, których kopiowanie jest drogie, nie powinny być przekazywane przez wartość, ale jeśli można je przenosić, można je zwrócić według wartości?

Zgadza się.

Poniższe omówienie jest opcjonalne, ale może pomóc Ci zrozumieć, dlaczego tak jest.

Jedną z najczęstszych rzeczy, które robimy w C++, jest przekazywanie wartości do jakiejś funkcji i uzyskiwanie z powrotem innej wartości. Gdy przekazywane wartości są typami klasowymi, proces ten składa się z 4 kroków:

  1. Konstruowanie wartości do przekazania.
  2. Właściwie przekaż wartość do funkcji.
  3. Konstruuj wartość, która ma zostać zwrócona.
  4. Właściwie przekaż zwracaną wartość z powrotem do obiektu wywołującego.

Oto przykład powyższego procesu using std::vector:

#include <iostream>
#include <vector>

std::vector<int> doSomething(std::vector<int> v2)
{
    std::vector v3 { v2[0] + v2[0] }; // 3 -- construct value to be returned to caller
    return v3; // 4 -- actually return value
}

int main()
{
    std::vector v1 { 5 }; // 1 -- construct value to be passed to function
    std::cout << doSomething(v1)[0] << '\n'; // 2 -- actually pass value

    std::cout << v1[0] << '\n';

    return 0;
}

Załóżmy, że std::vector nie można się poruszać. W takim przypadku powyższy program wykonuje 4 kopie:

  1. Konstruowanie wartości do przekazania kopiuje listę inicjatorów do v1
  2. Faktyczne przekazanie wartości do funkcji kopiuje argument v1 do parametru funkcji v2.
  3. Konstruowanie wartości do zwrócenia kopiuje inicjator do v3
  4. Faktycznie zwracając wartość do wywołującego kopiuje v3 z powrotem do osoby dzwoniącej.

Porozmawiajmy teraz o tym, jak możemy zoptymalizować powyższe. Mamy tu do dyspozycji wiele narzędzi: przekazywanie przez referencję lub adres, elizję, semantykę przenoszenia i parametry wyjściowe.

Nie możemy w ogóle zoptymalizować kopii 1 i 3. Potrzebujemy std::vector aby przejść do funkcji i potrzebujemy std::vector aby wrócić -- te obiekty muszą zostać skonstruowane. std::vector jest właścicielem swoich danych, więc koniecznie tworzy kopię swojego inicjatora.

Możemy wpływać na kopie 2 i 4.

Kopia 2 jest wykonywana, ponieważ przekazujemy wartość od osoby wywołującej do funkcji zwaną funkcją. Jakie mamy inne opcje?

  • Czy możemy przekazać referencje lub adres? Tak. Mamy gwarancję, że argument będzie istniał przez całe wywołanie funkcji — osoba wywołująca nie musi się martwić, że przekazany obiekt nieoczekiwanie wyjdzie poza zakres.
  • Czy można pominąć tę kopię? Nie. Elision działa tylko wtedy, gdy wykonujemy zbędną kopię lub przenosimy. Nie ma tutaj zbędnego kopiowania ani przenoszenia.
  • Czy możemy tutaj użyć parametru out? Nie. Przekazujemy wartość do funkcji, nie otrzymując wartości z powrotem.
  • Czy możemy tutaj zastosować semantykę przenoszenia? Nie. Argumentem jest wartość. Gdybyśmy przenieśli dane z v1 Do v2, v1 , stałyby się pustym wektorem, a późniejsze wydrukowanie v1[0] doprowadziłoby do niezdefiniowanego zachowania.

Wyraźne przekazywanie referencji do stałej jest tutaj najlepszą opcją, ponieważ pozwala uniknąć kopiowania, pozwala uniknąć problemów ze wskaźnikiem zerowym i działa zarówno z argumentami lvalue, jak i rvalue.

Kopia 4 jest wykonywana, ponieważ przekazujemy wartość z wywoływanej funkcji z powrotem do funkcji rozmówca. Jakie mamy inne opcje?

  • Czy możemy zwrócić na podstawie referencji lub adresu? Nie. Obiekt, który zwracamy, jest tworzony jako zmienna lokalna wewnątrz funkcji i zostanie zniszczony, gdy funkcja powróci. Zwrócenie referencji lub wskaźnika spowoduje, że osoba wywołująca otrzyma zawieszony wskaźnik lub referencję.
  • Czy można pominąć tę kopię? Tak, prawdopodobnie. Jeśli kompilator jest mądry, zorientuje się, że konstruujemy obiekt w zakresie wywoływanej funkcji i zwracamy go. Przepisując kod (zgodnie z zasadą as-if) tak, aby zamiast tego v3 był skonstruowany w zakresie wywołującego, możemy uniknąć kopiowania, które w przeciwnym razie zostałoby wykonane przy powrocie. Jednakże polegamy na tym, że kompilator będzie wiedział, że może to zrobić, więc nie możemy tego zagwarantować.
  • Czy możemy tutaj użyć parametru out? Tak. Zamiast konstruować v3 jako zmienną lokalną, możemy skonstruować pusty std::vector obiekt w zasięgu wywołującego i przekazać go do funkcji poprzez odwołanie inne niż stałe. Funkcja może następnie wypełnić ten parametr danymi. Kiedy funkcja powróci, obiekt ten nadal będzie istniał. Pozwala to uniknąć kopiowania, ale ma też pewne istotne wady i ograniczenia: brzydka semantyka wywoływania, nie działa z obiektami, które nie obsługują przypisania, a napisanie takich funkcji, które mogą działać zarówno z argumentami lvalue, jak i rvalue, jest trudne.
  • Czy możemy tutaj zastosować semantykę przenoszenia? Tak. v3 zostanie zniszczony, gdy funkcja powróci, więc zamiast kopiować v3 z powrotem do wywołującego, możemy użyć semantyki przenoszenia, aby przenieść jego dane do wywołującego, unikając kopiowania.

Elision jest tutaj najlepszą opcją, ale to, czy tak się stanie, jest poza naszą kontrolą. Następną najlepszą opcją dla typów z możliwością przenoszenia jest semantyka przenoszenia, której można użyć w przypadkach, gdy kompilator nie pominie kopii. W przypadku typów z możliwością przenoszenia semantyka przenoszenia jest wywoływana automatycznie przy zwracaniu przez wartość.

Podsumowując, w przypadku typów z możliwością przenoszenia wolimy przekazywać referencje do stałych i zwracać według wartości.

guest
Twój adres e-mail nie zostanie wyświetlony
Znalazłeś błąd? Zostaw komentarz powyżej!
Komentarze związane z poprawkami zostaną usunięte po przetworzeniu, aby pomóc zmniejszyć bałagan. Dziękujemy za pomoc w ulepszaniu witryny dla wszystkich!
Awatary z https://gravatar.com/ są połączone z podanym adresem e-mail.
Powiadamiaj mnie o odpowiedziach:  
77 Komentarze
Najnowsze
Najstarsze Najczęściej głosowane
Wbudowane opinie
Wyświetl wszystkie komentarze