Motywacja i użycie konstruktorów ruchów w C ++

17

Niedawno czytałem o konstruktorach przenoszenia w C ++ (patrz np. Tutaj ) i staram się zrozumieć, jak one działają i kiedy powinienem ich używać.

O ile rozumiem, konstruktor ruchu służy do zmniejszenia problemów z wydajnością spowodowanych kopiowaniem dużych obiektów. Strona Wikipedii mówi: „Chroniczny problem z wydajnością w C ++ 03 to kosztowne i niepotrzebne głębokie kopie, które mogą się zdarzyć niejawnie, gdy obiekty są przekazywane przez wartość”.

Zwykle zajmuję się takimi sytuacjami

  • przekazując obiekty przez odniesienie, lub
  • za pomocą inteligentnych wskaźników (np. boost :: shared_ptr), aby ominąć obiekt (inteligentne wskaźniki zostaną skopiowane zamiast obiektu).

W jakich sytuacjach powyższe dwie techniki są niewystarczające, a korzystanie z konstruktora ruchu jest wygodniejsze?

Giorgio
źródło
1
Poza tym, że semantyka ruchu może osiągnąć znacznie więcej (jak powiedziano w odpowiedziach), nie należy pytać, w jakich sytuacjach przekazywanie przez referencję lub inteligentny wskaźnik nie jest wystarczające, ale czy te techniki są naprawdę najlepszym i najczystszym sposobem aby to zrobić (niech Bóg strzeże się shared_ptrtylko ze względu na szybkie kopiowanie) i jeśli semantyka ruchu może osiągnąć to samo bez prawie żadnego karania za kodowanie, semantykę i czystość.
Chris mówi Przywróć Monikę

Odpowiedzi:

16

Semantyka Move wprowadza cały wymiar do C ++ - nie chodzi tylko o to, by tanio zwracać wartości.

Na przykład bez semantyki move std::unique_ptrnie działa - spójrz na std::auto_ptr, która została przestarzała wraz z wprowadzeniem semantyki move i usunięta w C ++ 17. Przenoszenie zasobu znacznie różni się od kopiowania. Umożliwia przeniesienie własności unikatowego przedmiotu.

Na przykład, nie patrzmy std::unique_ptr, ponieważ jest dość dobrze omówione. Spójrzmy, powiedzmy, na obiekt bufora wierzchołków w OpenGL. Bufor wierzchołków reprezentuje pamięć na GPU - należy go przydzielić i zwolnić przy użyciu specjalnych funkcji, prawdopodobnie mających ścisłe ograniczenia dotyczące czasu jego życia. Ważne jest również, aby korzystał z niego tylko jeden właściciel.

class vertex_buffer_object
{
    vertex_buffer_object(size_t size)
    {
        this->vbo_handle = create_buffer(..., size);
    }

    ~vertex_buffer_object()
    {
        release_buffer(vbo_handle);
    }
};

void create_and_use()
{
    vertex_buffer_object vbo = vertex_buffer_object(SIZE);

    do_init(vbo); //send reference, do not transfer ownership

    renderer.add(std::move(vbo)); //transfer ownership to renderer
}

Teraz można to zrobić za pomocą std::shared_ptr- ale tego zasobu nie można udostępniać. To sprawia, że ​​używanie wskaźnika wspólnego jest mylące. Możesz użyć std::unique_ptr, ale wciąż wymaga to semantyki ruchu.

Oczywiście nie wdrożyłem konstruktora ruchów, ale masz pomysł.

Istotne jest tutaj to, że niektórych zasobów nie można skopiować . Możesz przesuwać wskaźniki zamiast się poruszać, ale o ile nie użyjesz unikalnego_ptr, istnieje problem własności. Warto wyjaśnić, jaki jest cel kodu, więc konstruktor ruchów jest prawdopodobnie najlepszym rozwiązaniem.

Max
źródło
Dziękuję za odpowiedź. Co by się stało, gdyby ktoś użył tutaj wspólnego wskaźnika?
Giorgio
Próbuję odpowiedzieć sobie: użycie wspólnego wskaźnika nie pozwoliłoby kontrolować czasu życia obiektu, a warunkiem jest, aby obiekt mógł żyć tylko przez określony czas.
Giorgio
3
@Giorgio Ty mógł korzystać z udostępnionego wskaźnik, ale byłoby semantycznie niewłaściwy. Udostępnianie bufora nie jest możliwe. Ponadto zasadniczo spowodowałoby to przekazanie wskaźnika do wskaźnika (ponieważ vbo jest w zasadzie unikalnym wskaźnikiem do pamięci GPU). Ktoś przeglądający Twój kod może później zastanawiać się: „Dlaczego jest tu wspólny wskaźnik? Czy to jest udostępniony zasób? To może być błąd! ”. Lepiej jest jak najdokładniej określić pierwotne zamiary.
Maks
@Giorgio Tak, to także część wymogu. Gdy „mechanizm renderujący” w tym przypadku chce zwolnić część zasobów (być może za mało pamięci dla nowych obiektów na GPU), nie może istnieć żaden inny uchwyt pamięci. Użycie shared_ptr, które wykracza poza zakres, zadziałałoby, gdybyś nie trzymał go nigdzie indziej, ale dlaczego nie uczynić tego całkowicie oczywistym, kiedy możesz?
Maks
@Giorgio Zobacz moją edycję, aby uzyskać kolejną próbę wyjaśnienia.
Maks
5

Semantyka przesuwania niekoniecznie jest tak wielką poprawą, gdy shared_ptrzwracasz wartość - i kiedy / jeśli używasz (lub czegoś podobnego) prawdopodobnie przedwcześnie pesymujesz. W rzeczywistości prawie wszystkie racjonalnie nowoczesne kompilatory wykonują tak zwaną Optymalizację Wartości Zwrotnej (RVO) i Optymalizację Nazwanej Zwrotu Wartości (NRVO). Oznacza to, że kiedy wracasz wartość, zamiast faktycznie kopiowanie wartości w ogóle, po prostu przekazują ukryty wskaźnik / odniesienie do miejsca, w którym wartość zostanie przypisana po zwrocie, a funkcja używa tego do utworzenia wartości, w której ma się ona skończyć. Standard C ++ zawiera specjalne postanowienia, aby to umożliwić, więc nawet jeśli (na przykład) twój konstruktor kopiowania ma widoczne skutki uboczne, nie jest wymagane użycie konstruktora kopii do zwrócenia wartości. Na przykład:

#include <vector>
#include <numeric>
#include <iostream>
#include <stdlib.h>
#include <algorithm>
#include <iterator>

class X {
    std::vector<int> a;
public:
    X() {
        std::generate_n(std::back_inserter(a), 32767, ::rand);
    }

    X(X const &x) {
        a = x.a;
        std::cout << "Copy ctor invoked\n";
    }

    int sum() { return std::accumulate(a.begin(), a.end(), 0); }
};

X func() {
    return X();
}

int main() {
    X x = func();

    std::cout << "sum = " << x.sum();
    return 0;
};

Podstawowa idea tutaj jest dość prosta: stwórz klasę z wystarczającą ilością treści, wolelibyśmy unikać kopiowania, jeśli to możliwe ( std::vectorwypełnimy 32767 losowymi liczbami całkowitymi). Mamy wyraźny ctor kopiowania, który pokaże nam kiedy / jeśli zostanie skopiowany. Mamy też trochę więcej kodu do zrobienia czegoś z losowymi wartościami w obiekcie, więc optymalizator nie (przynajmniej łatwo) wyeliminuje wszystko w klasie tylko dlatego, że nic nie robi.

Następnie mamy kod, aby zwrócić jeden z tych obiektów z funkcji, a następnie użyć sumowania, aby upewnić się, że obiekt został naprawdę utworzony, a nie tylko całkowicie zignorowany. Kiedy go uruchamiamy, przynajmniej z najnowszymi / nowoczesnymi kompilatorami, okazuje się, że napisany przez nas konstruktor kopiowania nigdy nie działa - i tak, jestem prawie pewien, że nawet szybka kopia z poleceniem shared_ptrjest nadal wolniejsza niż brak kopiowania w ogóle.

Przenoszenie pozwala ci robić wiele rzeczy, których po prostu nie możesz (bez nich) zrobić. Rozważ część „scalania” zewnętrznego typu scalania - masz, powiedzmy, 8 plików, które chcesz scalić razem. Idealnie chciałbyś umieścić wszystkie 8 tych plików w pliku vector- ale ponieważ vector(od C ++ 03) musi być w stanie kopiować elementy, a ifstreams nie może być kopiowane, utkniesz z niektórymi unique_ptr/shared_ptr , lub coś w tej kolejności, aby móc umieścić je w wektorze. Należy pamiętać, że nawet jeśli (przykładowo) my reservemiejsca w vectortak jesteśmy pewni nasi ifstreams będzie naprawdę nigdy nie mogą być kopiowane, kompilator nie będzie wiedział, że, więc kod nie będzie kompilować choć my wiemy, że konstruktor kopia nigdy nie będzie i tak używane.

Mimo że nadal nie można go skopiować, w C ++ 11 ifstream można go przenieść. W tym przypadku obiekty prawdopodobnie nigdy nie zostaną przeniesione, ale fakt, że mogą być w razie potrzeby, sprawia, że ​​kompilator jest szczęśliwy, dzięki czemu możemy umieścić nasze ifstreamobiekty vectorbezpośrednio, bez żadnych inteligentnych hacków wskaźnika.

Wektor, który się rozwija, to całkiem niezły przykład czasu, w którym semantyka ruchu naprawdę może być / jest użyteczna. W takim przypadku RVO / NRVO nie pomoże, ponieważ nie mamy do czynienia z wartością zwracaną z funkcji (lub czegokolwiek bardzo podobnego). Mamy jeden wektor zawierający niektóre obiekty i chcemy przenieść te obiekty do nowej, większej części pamięci.

W C ++ 03 dokonano tego, tworząc kopie obiektów w nowej pamięci, a następnie niszcząc stare obiekty w starej pamięci. Wykonywanie tych wszystkich kopii tylko po to, by wyrzucić stare, było jednak stratą czasu. W C ++ 11 można oczekiwać, że zostaną przeniesione. Zazwyczaj pozwala to nam zasadniczo wykonać płytką kopię zamiast (zwykle o wiele wolniejszej) głębokiej kopii. Innymi słowy, za pomocą łańcucha lub wektora (tylko dla kilku przykładów) po prostu kopiujemy wskaźnik (i) w obiektach, zamiast tworzyć kopie wszystkich danych, do których odnoszą się te wskaźniki.

Jerry Coffin
źródło
Dzięki za bardzo szczegółowe wyjaśnienie. Jeśli dobrze rozumiem, wszystkie sytuacje, w których ruch wchodzi w grę, mogą być obsługiwane przez normalne wskaźniki, ale zaprogramowanie całego żonglowania wskaźnikiem za każdym razem byłoby niebezpieczne (złożone i podatne na błędy). Zamiast tego pod maską znajduje się unikatowy mechanizm (lub podobny mechanizm), a semantyka ruchu gwarantuje, że pod koniec dnia będzie tylko kopiowanie wskaźnika i nie będzie kopiowania obiektów.
Giorgio
@Giorgio: Tak, to całkiem poprawne. Język tak naprawdę nie dodaje semantyki ruchu; dodaje referencje wartości. Odwołanie do wartości (oczywiście wystarczające) może powiązać się z wartością, w którym to przypadku wiesz, że bezpiecznie jest „ukraść” wewnętrzną reprezentację danych i po prostu skopiować wskaźniki zamiast robić głęboką kopię.
Jerry Coffin
4

Rozważać:

vector<string> v;

Dodając ciągi do v, rozszerzy się ono w razie potrzeby i przy każdej realokacji ciągi będą musiały zostać skopiowane. W przypadku konstruktorów ruchów jest to w zasadzie problem.

Oczywiście możesz także zrobić coś takiego:

vector<unique_ptr<string>> v;

Ale to zadziała dobrze tylko dlatego, że std::unique_ptr implementuje konstruktor ruchu.

Używanie std::shared_ptrma sens tylko w (rzadkich) sytuacjach, gdy faktycznie masz współwłasność.

Nemanja Trifunovic
źródło
ale co, jeśli zamiast tego stringmamy instancję, w Fooktórej ma 30 członków danych? unique_ptrWersja nie będzie bardziej efektywny?
Vassilis,
2

Zwracane wartości są tam, gdzie najczęściej chciałbym przekazać wartość zamiast jakiegoś odniesienia. Byłoby miło móc szybko zwrócić obiekt „na stos” bez ogromnej kary za wydajność. Z drugiej strony nie jest szczególnie trudno obejść ten problem (wspólne wskaźniki są tak łatwe w użyciu ...), więc nie jestem pewien, czy naprawdę warto wykonywać dodatkową pracę na moich obiektach, aby móc to zrobić.

Michael Kohne
źródło
Zwykle używam również inteligentnych wskaźników do zawijania obiektów zwracanych z funkcji / metody.
Giorgio
1
@Giorgio: To zdecydowanie zarówno zaciemniające, jak i powolne.
DeadMG
Nowoczesne kompilatory powinny wykonać automatyczny ruch, jeśli zwrócisz prosty obiekt na stosie, więc nie ma potrzeby udostępniania wspólnych plików ptr itp.
Christian Severin