Operator przypisania ruchu i `if (this! = & Rhs)`

126

W operatorze przypisania klasy zwykle musisz sprawdzić, czy przypisywany obiekt jest obiektem wywołującym, aby nie zepsuć rzeczy:

Class& Class::operator=(const Class& rhs) {
    if (this != &rhs) {
        // do the assignment
    }

    return *this;
}

Czy potrzebujesz tego samego dla operatora przypisania ruchu? Czy jest kiedykolwiek sytuacja, w której this == &rhsbyłoby to prawdą?

? Class::operator=(Class&& rhs) {
    ?
}
Seth Carnegie
źródło
12
Nie ma związku z pytaniem o Q i tylko po to, aby nowi użytkownicy, którzy czytają to Q na osi czasu (bo wiem, że Seth już to wie), nie dostają złych pomysłów, Kopiuj i zamień to właściwy sposób na zaimplementowanie operatora kopiowania przypisania, w którym Ty nie trzeba sprawdzać, czy istnieje przypisanie do siebie.
Alok Zapisz
5
@VaughnCato: A a; a = std::move(a);.
Xeo,
11
@VaughnCato Używanie std::movejest normalne. Następnie weź pod uwagę aliasing, a kiedy jesteś głęboko w stosie wywołań i masz jedno odniesienie T, a drugie odniesienie do T... czy zamierzasz sprawdzić tożsamość tutaj? Czy chcesz znaleźć pierwsze wywołanie (lub wywołania), w których udokumentowanie, że nie możesz dwukrotnie przekazać tego samego argumentu, statycznie udowodni, że te dwa odwołania nie będą aliasami? A może sprawisz, że zadanie do siebie po prostu zadziała?
Luc Danton,
2
@LucDanton Wolałbym asercję w operatorze przypisania. Gdyby std :: move zostało użyte w taki sposób, że można było otrzymać samo przypisanie rvalue, uznałbym to za błąd, który powinien zostać naprawiony.
Vaughn Cato,
4
@VaughnCato Jedynym miejscem, w którym samo-zamiana jest normalna, jest wewnątrz albo std::sortlub std::shuffle- za każdym razem, gdy zamieniasz ith i jth element tablicy bez uprzedniego sprawdzenia i != j. ( std::swapjest zaimplementowany pod względem przydziału ruchu.)
Quuxplusone,

Odpowiedzi:

143

Wow, jest tu tyle do posprzątania ...

Po pierwsze, kopiowanie i zamiana nie zawsze jest właściwym sposobem implementacji przypisania kopii. Niemal na pewno w przypadku dumb_arraytego rozwiązania jest to nieoptymalne rozwiązanie.

Użycie funkcji Kopiuj i zamień jest dumb_arrayklasycznym przykładem umieszczenia najdroższej operacji z najpełniejszymi funkcjami w dolnej warstwie. Jest idealny dla klientów, którzy chcą mieć pełną funkcjonalność i są gotowi zapłacić karę za wydajność. Dostają dokładnie to, czego chcą.

Ale jest to katastrofalne dla klientów, którzy nie potrzebują pełnej funkcjonalności i zamiast tego szukają najwyższej wydajności. Dla nich dumb_arrayto tylko kolejne oprogramowanie, które muszą przepisać, ponieważ jest zbyt wolne. Miał dumb_arrayzostały zaprojektowane inaczej, to mogło usatysfakcjonowany zarówno klientów bez kompromisów po obu klienta.

Kluczem do satysfakcji obu klientów jest zbudowanie najszybszych operacji na najniższym poziomie, a następnie dodanie do tego API dla pełniejszych funkcji kosztem. Oznacza to, że potrzebujesz silnej gwarancji wyjątków, w porządku, za to płacisz. Nie potrzebujesz tego? Oto szybsze rozwiązanie.

Spójrzmy konkretnie: oto szybki, podstawowy operator gwarancji wyjątku Copy Assignment dla dumb_array:

dumb_array& operator=(const dumb_array& other)
{
    if (this != &other)
    {
        if (mSize != other.mSize)
        {
            delete [] mArray;
            mArray = nullptr;
            mArray = other.mSize ? new int[other.mSize] : nullptr;
            mSize = other.mSize;
        }
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }
    return *this;
}

Wyjaśnienie:

Jedną z droższych rzeczy, które można zrobić na nowoczesnym sprzęcie, jest podróż na stertę. Wszystko, co możesz zrobić, aby uniknąć wyprawy na stos, to dobrze wykorzystany czas i wysiłek. Klienci dumb_arraymogą chcieć często przypisywać tablice tego samego rozmiaru. A kiedy to zrobią, wszystko, co musisz zrobić, to memcpy(ukryty pod std::copy). Nie chcesz przydzielać nowej tablicy o tym samym rozmiarze, a następnie cofać przydział starej tablicy o tym samym rozmiarze!

Teraz dla Twoich klientów, którzy naprawdę chcą silnego bezpieczeństwa wyjątków:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    swap(lhs, rhs);
    return lhs;
}

A może, jeśli chcesz skorzystać z przypisania przeniesienia w C ++ 11, powinno to być:

template <class C>
C&
strong_assign(C& lhs, C rhs)
{
    lhs = std::move(rhs);
    return lhs;
}

Jeśli dumb_arrayklienci cenią sobie szybkość, powinni zadzwonić do operator=. Jeśli potrzebują silnego zabezpieczenia wyjątków, istnieją ogólne algorytmy, które mogą wywołać, które będą działać na wielu różnych obiektach i wystarczy je zaimplementować tylko raz.

Wróćmy teraz do pierwotnego pytania (które w tym momencie ma typ o):

Class&
Class::operator=(Class&& rhs)
{
    if (this == &rhs)  // is this check needed?
    {
       // ...
    }
    return *this;
}

To jest właściwie kontrowersyjne pytanie. Niektórzy powiedzą tak, absolutnie, inni powiedzą nie.

Osobiście uważam, że nie, nie potrzebujesz tego czeku.

Racjonalne uzasadnienie:

Kiedy obiekt wiąże się z odwołaniem do wartości r, jest to jedna z dwóch rzeczy:

  1. Tymczasowy.
  2. Obiekt, który dzwoniący chce, abyś uwierzył, jest tymczasowy.

Jeśli masz odniesienie do obiektu, który jest rzeczywisty tymczasowy, to z definicji masz unikalne odniesienie do tego obiektu. Nigdzie indziej w całym programie nie może się do niego odwoływać. To this == &temporary znaczy nie jest możliwe .

Teraz, jeśli twój klient cię okłamał i obiecał ci, że dostajesz tymczasowy, gdy tak nie jest, to klient jest odpowiedzialny za upewnienie się, że nie musisz się tym przejmować. Jeśli chcesz być naprawdę ostrożny, uważam, że byłaby to lepsza implementacja:

Class&
Class::operator=(Class&& other)
{
    assert(this != &other);
    // ...
    return *this;
}

Tj Jeśli przekazywane odniesienie siebie, jest to błąd ze strony klienta, która powinna być stała.

Aby uzyskać kompletność, oto operator przypisania ruchu dla dumb_array:

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

W typowym przypadku użycia przeniesienia, *thisbędzie to obiekt przeniesiony, a więc nie delete [] mArray;powinno być operacją. Bardzo ważne jest, aby implementacje jak najszybciej usuwały dane o wartości nullptr.

Ostrzeżenie:

Niektórzy będą twierdzić, że swap(x, x)to dobry pomysł lub po prostu zło konieczne. A to, jeśli zamiana przejdzie do domyślnej zamiany, może spowodować przypisanie samodzielnego ruchu.

Nie zgadzam się, że swap(x, x)jest zawsze dobrym pomysłem. Jeśli zostanie znaleziony w moim własnym kodzie, uznam to za błąd wydajności i naprawię. Ale jeśli chcesz na to zezwolić, zdaj sobie sprawę, że swap(x, x)samoczynne przeniesienie przypisuje się tylko do wartości przeniesionej. W naszym dumb_arrayprzykładzie będzie to całkowicie nieszkodliwe, jeśli po prostu pominiemy potwierdzenie lub ograniczymy je do przypadku przeniesionego z:

dumb_array& operator=(dumb_array&& other)
{
    assert(this != &other || mSize == 0);
    delete [] mArray;
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

Jeśli samodzielnie przypiszesz dwa „przeniesione z” (puste) dumb_array, nie zrobisz nic złego poza wstawieniem niepotrzebnych instrukcji do swojego programu. Ta sama obserwacja może dotyczyć większości obiektów.

<Aktualizacja>

Poświęciłem więcej uwagi tej sprawie i nieco zmieniłem swoje stanowisko. Teraz uważam, że przypisanie powinno być tolerancyjne dla samodzielnego przypisywania, ale warunki postu dotyczące przypisania kopii i przeniesienia są różne:

W przypadku przypisania kopii:

x = y;

należy mieć warunek końcowy, yaby nie zmieniać wartości. Kiedy &x == &ywtedy ten warunek końcowy przekłada się na: przypisanie do samodzielnego kopiowania nie powinno mieć wpływu na wartość x.

Przydział ruchu:

x = std::move(y);

należy mieć warunek końcowy, który yma ważny, ale nieokreślony stan. Kiedy &x == &ywtedy ten warunek końcowy przekłada się na: xma ważny, ale nieokreślony stan. Tzn. Samodzielne przydzielanie ruchu nie musi być nieopuszczalne. Ale to nie powinno się zawiesić. Ten warunek końcowy jest zgodny z pozwoleniem swap(x, x)na zwykłą pracę:

template <class T>
void
swap(T& x, T& y)
{
    // assume &x == &y
    T tmp(std::move(x));
    // x and y now have a valid but unspecified state
    x = std::move(y);
    // x and y still have a valid but unspecified state
    y = std::move(tmp);
    // x and y have the value of tmp, which is the value they had on entry
}

Powyższe działa, o ile x = std::move(x)nie ulega awarii. Może pozostawić xw dowolnym ważnym, ale nieokreślonym stanie.

Widzę trzy sposoby zaprogramowania operatora przypisania przenoszenia, aby dumb_arrayto osiągnąć:

dumb_array& operator=(dumb_array&& other)
{
    delete [] mArray;
    // set *this to a valid state before continuing
    mSize = 0;
    mArray = nullptr;
    // *this is now in a valid state, continue with move assignment
    mSize = other.mSize;
    mArray = other.mArray;
    other.mSize = 0;
    other.mArray = nullptr;
    return *this;
}

Powyższa realizacja toleruje zadanie samodzielne, ale *thisi otherw końcu jest zero wielkości tablicy po cesji własnym ruchem, bez względu na to, co oryginalna wartość *thisjest. Jest okej.

dumb_array& operator=(dumb_array&& other)
{
    if (this != &other)
    {
        delete [] mArray;
        mSize = other.mSize;
        mArray = other.mArray;
        other.mSize = 0;
        other.mArray = nullptr;
    }
    return *this;
}

Powyższa implementacja toleruje przypisanie własne w taki sam sposób, jak operator przypisania kopii, czyniąc z niej brak działania. To też jest w porządku.

dumb_array& operator=(dumb_array&& other)
{
    swap(other);
    return *this;
}

Powyższe jest w porządku tylko wtedy, gdy dumb_arraynie zawiera zasobów, które powinny zostać zniszczone "natychmiast". Na przykład, jeśli jedynym zasobem jest pamięć, powyższe jest w porządku. Gdyby dumb_arraymożliwe było utrzymywanie blokad mutex lub stanu otwartego plików, klient mógłby rozsądnie oczekiwać, że zasoby po lewej stronie przydziału przeniesienia zostaną natychmiast zwolnione, a zatem ta implementacja może być problematyczna.

Koszt pierwszego to dwa dodatkowe sklepy. Koszt drugiego to test i oddział. Obie działają. Obydwa spełniają wszystkie wymagania z tabeli 22, wymagania MoveAssignable w standardzie C ++ 11. Trzeci działa również modulo problem z zasobami niezwiązanymi z pamięcią.

Wszystkie trzy wdrożenia mogą mieć różne koszty w zależności od sprzętu: Ile kosztuje oddział? Czy rejestrów jest dużo czy bardzo mało?

Wniosek jest taki, że przypisanie do samodzielnego przeniesienia, w przeciwieństwie do przypisania do samodzielnego kopiowania, nie musi zachowywać bieżącej wartości.

</Aktualizacja>

Jedna ostatnia (miejmy nadzieję) edycja zainspirowana komentarzem Luca Dantona:

Jeśli piszesz klasę wysokiego poziomu, która nie zarządza bezpośrednio pamięcią (ale może mieć bazy lub członków, którzy to robią), wówczas najlepszą implementacją przypisania ruchu jest często:

Class& operator=(Class&&) = default;

Spowoduje to przeniesienie przypisania każdej bazy i każdemu członkowi po kolei i nie będzie obejmować this != &otherczeku. Zapewni to najwyższą wydajność i podstawowe bezpieczeństwo wyjątków, przy założeniu, że nie ma potrzeby utrzymywania niezmienników wśród baz i członków. Dla swoich klientów wymagających silnego bezpieczeństwa wyjątków, wskaż im kierunek strong_assign.

Howard Hinnant
źródło
6
Nie wiem, co sądzić o tej odpowiedzi. Wygląda na to, że implementowanie takich klas (które bardzo wyraźnie zarządzają swoją pamięcią) jest powszechną czynnością. To prawda, że kiedy zrobić zapis taki jeden klasa musi być bardzo bardzo ostrożnym wyjątku gwarancje bezpieczeństwa i znalezienie sweet spot dla interfejsu być zwięzły, ale wygodne, ale pytanie wydaje się być pytanie o ogólną poradę.
Luc Danton,
Tak, zdecydowanie nigdy nie używam kopiowania i zamiany, ponieważ jest to strata czasu dla zajęć, które zarządzają zasobami i rzeczami (po co robić kolejną całą kopię wszystkich danych?). Dzięki, to odpowiada na moje pytanie.
Seth Carnegie
5
Głosowano w dół za sugestię, że przeniesienie-przypisanie-od-siebie powinno kiedykolwiek potwierdzić-zawieść lub przynieść „nieokreślony” rezultat. Przypisanie od siebie jest dosłownie najłatwiejszym przypadkiem do wykonania. Jeśli twoja klasa się zawiesza std::swap(x,x), to dlaczego miałbym ufać, że poprawnie obsługuje bardziej skomplikowane operacje?
Quuxplusone
1
@Quuxplusone: Zgodziłem się z tobą co do asertu-porażki, jak wspomniano w aktualizacji mojej odpowiedzi. Jeśli chodzi o std::swap(x,x)to, działa po prostu nawet wtedy, gdy x = std::move(x)daje nieokreślony wynik. Spróbuj! Nie musisz mi wierzyć.
Howard Hinnant,
@HowardHinnant dobra uwaga, swapdziała tak długo, jak długo x = move(x)pozostawia xw stanie, w którym można się przenieść. A algorytmy std::copy/ std::movesą zdefiniowane w taki sposób, aby dawały niezdefiniowane zachowanie na kopiach bez operacji (ach, 20-latek memmovema rację, ale std::movenie ma!). Więc chyba nie pomyślałem jeszcze o „wsadzie” do samodzielnego zadania. Ale oczywiście samokontrola jest czymś, co zdarza się często w prawdziwym kodzie, niezależnie od tego, czy Standard jest błogosławieństwem, czy nie.
Quuxplusone
11

Po pierwsze, masz nieprawidłowy podpis operatora przypisania ruchu. Ponieważ ruchy kradną zasoby z obiektu źródłowego, źródło musi być odniesieniem innym niż constr-wartość.

Class &Class::operator=( Class &&rhs ) {
    //...
    return *this;
}

Należy zauważyć, że nadal powrócić za pośrednictwem (nie const) l -wartość odniesienia.

W przypadku każdego typu bezpośredniego przypisania standard nie sprawdza, czy przypisanie własne, ale upewnienie się, że przypisanie własne nie powoduje awarii i spalenia. Na ogół nikt nie robi tego x = xani nie y = std::move(y)wywołuje, ale aliasowanie, szczególnie za pomocą wielu funkcji, może prowadzić a = blub prowadzić c = std::move(d)do samodzielnego przypisywania. Wyraźne sprawdzenie samo-przypisania, tj. this == &rhsPomijanie treści funkcji, gdy prawda, jest jednym ze sposobów zapewnienia bezpieczeństwa samo-przypisania. Ale jest to jeden z najgorszych sposobów, ponieważ optymalizuje (miejmy nadzieję) rzadki przypadek, podczas gdy jest anty-optymalizacją dla bardziej powszechnego przypadku (ze względu na rozgałęzienia i możliwe błędy pamięci podręcznej).

Teraz, gdy (przynajmniej) jeden z operandów jest obiektem bezpośrednio tymczasowym, nigdy nie możesz mieć scenariusza samo-przypisania. Niektórzy opowiadają się za przyjęciem takiego przypadku i zoptymalizowaniem kodu do tego stopnia, że ​​kod staje się samobójczo głupi, gdy założenie jest błędne. Mówię, że porzucanie sprawdzania tego samego obiektu na użytkownikach jest nieodpowiedzialne. Nie tworzymy tego argumentu przy przypisywaniu kopii; po co odwrócić stanowisko w przypadku przypisania ruchu?

Zróbmy przykład, odmieniony od innego respondenta:

dumb_array& dumb_array::operator=(const dumb_array& other)
{
    if (mSize != other.mSize)
    {
        delete [] mArray;
        mArray = nullptr;  // clear this...
        mSize = 0u;        // ...and this in case the next line throws
        mArray = other.mSize ? new int[other.mSize] : nullptr;
        mSize = other.mSize;
    }
    std::copy(other.mArray, other.mArray + mSize, mArray);
    return *this;
}

To przypisanie do kopiowania z wdziękiem obsługuje przypisanie własne bez jawnego sprawdzenia. Jeśli rozmiary źródłowe i docelowe różnią się, cofnięcie przydziału i ponowna alokacja poprzedzają kopiowanie. W przeciwnym razie zostanie wykonane tylko kopiowanie. Samodzielne przypisanie nie zapewnia zoptymalizowanej ścieżki, zostaje zrzucone na tę samą ścieżkę, co wtedy, gdy rozmiary źródła i miejsca docelowego są równe. Kopiowanie jest technicznie niepotrzebne, gdy dwa obiekty są równoważne (w tym, gdy są tym samym obiektem), ale jest to cena, gdy nie wykonuje się kontroli równości (pod względem wartości lub adresu), ponieważ samo sprawdzenie byłoby marnotrawstwem czasu. Zwróć uwagę, że samo przypisanie obiektu w tym miejscu spowoduje serię przypisań własnych na poziomie elementu; typ elementu musi być do tego bezpieczny.

Podobnie jak jego przykład źródłowy, to przypisanie kopii zapewnia podstawową gwarancję bezpieczeństwa wyjątków. Jeśli potrzebujesz silnej gwarancji, użyj ujednoliconego operatora przypisania z oryginalnego zapytania Kopiuj i Zamień , który obsługuje zarówno przypisanie kopiowania , jak i przenoszenia. Ale celem tego przykładu jest zmniejszenie bezpieczeństwa o jeden stopień, aby uzyskać prędkość. (Nawiasem mówiąc, zakładamy, że wartości poszczególnych elementów są niezależne; że nie ma niezmiennego ograniczenia ograniczającego niektóre wartości w porównaniu z innymi).

Spójrzmy na przypisanie ruchu dla tego samego typu:

class dumb_array
{
    //...
    void swap(dumb_array& other) noexcept
    {
        // Just in case we add UDT members later
        using std::swap;

        // both members are built-in types -> never throw
        swap( this->mArray, other.mArray );
        swap( this->mSize, other.mSize );
    }

    dumb_array& operator=(dumb_array&& other) noexcept
    {
        this->swap( other );
        return *this;
    }
    //...
};

void  swap( dumb_array &l, dumb_array &r ) noexcept  { l.swap( r ); }

Typ wymienny, który wymaga dostosowania, powinien mieć dwuparametrową funkcję wolną o nazwie swapw tej samej przestrzeni nazw, co typ. (Ograniczenie przestrzeni nazw umożliwia zamianę wywołań niekwalifikowanych). Typ kontenera powinien również dodawać publiczną swapfunkcję składową, aby pasowała do standardowych kontenerów. Jeśli element członkowski swapnie zostanie podany, funkcja wolna swapprawdopodobnie musi zostać oznaczona jako przyjaciel typu wymiennego. Jeśli dostosujesz ruchy do użycia swap, musisz podać własny kod wymiany; standardowy kod wywołuje kod przeniesienia typu, co spowodowałoby nieskończoną wzajemną rekursję dla typów dostosowanych do przenoszenia.

Podobnie jak destruktory, funkcje swap i operacje przenoszenia nie powinny być nigdy rzucane, jeśli to w ogóle możliwe, i prawdopodobnie oznaczone jako takie (w C ++ 11). Standardowe typy bibliotek i procedury mają optymalizacje dla typów ruchomych, których nie można rzucać.

Ta pierwsza wersja przydziału przeniesienia spełnia podstawową umowę. Znaczniki zasobów źródła są przenoszone do obiektu docelowego. Stare zasoby nie zostaną ujawnione, ponieważ zarządza nimi teraz obiekt źródłowy. A obiekt źródłowy pozostaje w stanie użytecznym, dzięki czemu można na nim zastosować dalsze operacje, w tym przypisanie i zniszczenie.

Zauważ, że to przypisanie ruchu jest automatycznie bezpieczne do samodzielnego przypisania, ponieważ swapwywołanie jest. Jest również zdecydowanie bezpieczny dla wyjątków. Problem polega na niepotrzebnym zatrzymywaniu zasobów. Stare zasoby dla miejsca docelowego nie są już koncepcyjnie potrzebne, ale tutaj są nadal dostępne tylko po to, aby obiekt źródłowy mógł pozostać ważny. Jeśli zaplanowane zniszczenie obiektu źródłowego jest bardzo odległe, marnujemy miejsce w zasobach lub, co gorsza, jeśli łączna ilość zasobów jest ograniczona, a inne prośby o zasoby pojawią się, zanim (nowy) obiekt źródłowy oficjalnie umrze.

Ta kwestia spowodowała kontrowersyjne porady obecnych guru dotyczące samodzielnego kierowania się podczas przydziału ruchu. Sposób pisania przypisania do przeniesienia bez zalegających zasobów jest podobny do:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        delete [] this->mArray;  // kill old resources
        this->mArray = other.mArray;
        this->mSize = other.mSize;
        other.mArray = nullptr;  // reset source
        other.mSize = 0u;
        return *this;
    }
    //...
};

Źródło jest resetowane do warunków domyślnych, podczas gdy stare zasoby docelowe są niszczone. W przypadku samooceny twój obecny obiekt kończy się samobójstwem. Głównym sposobem na obejście tego jest otoczenie kodu akcji if(this != &other)blokiem lub wkręcenie go i umożliwienie klientom zjedzenia assert(this != &other)początkowej linii (jeśli czujesz się dobrze).

Alternatywą jest zbadanie, jak sprawić, aby przypisanie kopii było silnie bezpieczne dla wyjątków, bez przypisania ujednoliconego i zastosować je do przypisania przeniesienia:

class dumb_array
{
    //...
    dumb_array& operator=(dumb_array&& other) noexcept
    {
        dumb_array  temp{ std::move(other) };

        this->swap( temp );
        return *this;
    }
    //...
};

Kiedy otheri thissą odrębne, othersą opróżniane przez ruch tempi pozostają w ten sposób. Następnie thistraci swoje stare zasoby temp, uzyskując zasoby pierwotnie posiadane other. Wtedy stare zasoby thisginą, kiedy tak się tempdzieje.

Kiedy dochodzi otherdo tempsamozadania, opróżnianie thisrównież się opróżnia . Następnie obiekt docelowy odzyskuje swoje zasoby tempi thiszamienia się. Śmierć tempżąda pustego przedmiotu, który powinien być praktycznie nieopuszczalny. Obiekt this/ otherzachowuje swoje zasoby.

Przypisanie ruchu nie powinno być nigdy rzucane, o ile konstrukcja ruchu i zamiana również są. Koszt bycia bezpiecznym podczas samodzielnego przydzielania to kilka dodatkowych instrukcji dla typów niskiego poziomu, które powinny zostać zniwelowane przez wezwanie do cofnięcia przydziału.

CTMacUser
źródło
Czy musisz sprawdzić, czy jakakolwiek pamięć została przydzielona przed wywołaniem deletedrugiego bloku kodu?
user3728501
3
Twój drugi przykładowy kod, operator przypisania kopii bez sprawdzania przypisania własnego, jest nieprawidłowy. std::copypowoduje niezdefiniowane zachowanie, jeśli zakresy źródłowy i docelowy nakładają się (w tym przypadek, gdy pokrywają się). Zobacz C ++ 14 [alg.copy] / 3.
MM
6

Jestem w obozie tych, którzy chcą bezpiecznych operatorów do samodzielnego przypisywania, ale nie chcą pisać kontroli przypisania do siebie w implementacjach operator=. I faktycznie, w ogóle nie chcę wdrażać operator=, chcę, aby domyślne zachowanie działało „od razu po wyjęciu z pudełka”. Najlepsi członkowie specjalni to ci, którzy przychodzą za darmo.

Biorąc to pod uwagę, wymagania MoveAssignable obecne w standardzie są opisane w następujący sposób (z 17.6.3.1 Wymagania dotyczące argumentów szablonu [utility.arg.requirements], n3290):

Wyrażenie Zwracany typ Wartość zwracana Warunek końcowy
t = rv T & tt jest równoważne wartości rv przed przypisaniem

gdzie symbole zastępcze są opisane jako: „ t[jest] modyfikowalną lwartością typu T;” i „ rvjest wartością r typu T;”. Zauważ, że są to wymagania nałożone na typy używane jako argumenty w szablonach biblioteki Standard, ale patrząc gdzie indziej w standardzie zauważam, że każde wymaganie przy przypisaniu przeniesienia jest podobne do tego.

Oznacza to, że a = std::move(a)musi być „bezpieczny”. Jeśli potrzebujesz testu tożsamości (np. this != &other), Zrób to, bo inaczej nie będziesz w stanie nawet umieścić swoich obiektów std::vector! (Chyba, że nie korzystają z tych członków / operacje, które wymagają MoveAssignable, ale pomijając to.) Zauważ, że z poprzedniego przykładu a = std::move(a), wtedy this == &otherrzeczywiście trzymać.

Luc Danton
źródło
Czy możesz wyjaśnić, dlaczego a = std::move(a)brak pracy może spowodować, że klasa nie będzie działać std::vector? Przykład?
Paul J. Lucas
@ PaulJ.Lucas Wywołanie std::vector<T>::erasenie jest dozwolone, chyba że Tjest to MoveAssignable. (Na marginesie IIRC niektóre wymagania MoveAssignable zostały złagodzone do MoveInsertable zamiast w C ++ 14.)
Luc Danton
OK, więc Tmusi być MoveAssignable, ale dlaczego miałoby erase()polegać na przenoszeniu elementu do siebie ?
Paul J. Lucas
@ PaulJ.Lucas Nie ma satysfakcjonującej odpowiedzi na to pytanie. Wszystko sprowadza się do „nie zrywania umów”.
Luc Danton
2

Ponieważ twoja obecna operator=funkcja jest zapisywana, ponieważ utworzyłeś argument rvalue-reference const, nie ma możliwości, abyś mógł "ukraść" wskaźniki i zmienić wartości przychodzącej referencji rvalue ... po prostu nie możesz tego zmienić, mógł tylko czytać. Widziałbym tylko problem, gdybyś zaczął wywoływać deletewskaźniki itp. W swoim thisobiekcie, tak jak w normalnej metodzie referencyjnej lvaue operator=, ale ten rodzaj pokonuje punkt wersji r-wartości ... tj. wydaje się zbędne używanie wersji rvalue do wykonywania tych samych operacji, które normalnie pozostawiono metodzie const-lvalue operator=.

Teraz, jeśli zdefiniowałeś swój jako odniesienie operator=do wartości innej niż constr, jedynym sposobem, w jaki mogłem zobaczyć, że wymagane jest sprawdzenie, było przekazanie thisobiektu do funkcji, która celowo zwróciła odwołanie do wartości r, a nie tymczasowe.

Na przykład, przypuśćmy, że ktoś próbował napisać operator+funkcję i użyć mieszanki odwołań do r-wartości i l-wartości, aby „zapobiec” utworzeniu dodatkowych elementów tymczasowych podczas wykonywania operacji dodawania stosu na typie obiektu:

struct A; //defines operator=(A&& rhs) where it will "steal" the pointers
          //of rhs and set the original pointers of rhs to NULL

A&& operator+(A& rhs, A&& lhs)
{
    //...code

    return std::move(rhs);
}

A&& operator+(A&& rhs, A&&lhs)
{
    //...code

    return std::move(rhs);
}

int main()
{
    A a;

    a = (a + A()) + A(); //calls operator=(A&&) with reference bound to a

    //...rest of code
}

Teraz, z tego, co rozumiem o referencjach rvalue, robienie powyższego jest odradzane (tj. Powinieneś po prostu zwrócić tymczasowe odniesienie, a nie rvalue), ale gdyby ktoś nadal to robił, to chciałbyś sprawdzić, czy czy przychodzące odwołanie do wartości r nie odnosiło się do tego samego obiektu co thiswskaźnik.

Jason
źródło
Zauważ, że "a = std :: move (a)" to trywialny sposób na uzyskanie takiej sytuacji. Twoja odpowiedź jest jednak ważna.
Vaughn Cato,
1
Całkowicie się zgadzam, że to najprostszy sposób, chociaż myślę, że większość ludzi nie zrobi tego celowo :-) ... Pamiętaj jednak, że jeśli odniesienie do wartości r to const, możesz tylko z niego czytać, więc jedyną potrzebą Sprawdzenie byłoby, gdybyś zdecydował się operator=(const T&&)wykonać tę samą ponowną inicjalizację this, jaką wykonałbyś w typowej operator=(const T&)metodzie, zamiast operacji w stylu zamiany (tj. kradzieży wskaźników itp. zamiast wykonywania głębokich kopii).
Jason
1

Moja odpowiedź jest nadal taka, że ​​przypisanie ruchu nie musi być chronione przed samodzielnym przypisaniem, ale ma inne wyjaśnienie. Rozważmy std :: unique_ptr. Gdybym miał zaimplementować jeden, zrobiłbym coś takiego:

unique_ptr& operator=(unique_ptr&& x) {
  delete ptr_;
  ptr_ = x.ptr_;
  x.ptr_ = nullptr;
  return *this;
}

Jeśli spojrzysz na wyjaśnienia Scotta Meyersa , robi coś podobnego. (Jeśli wędrujesz, dlaczego nie zrobić zamiany - ma jeden dodatkowy zapis). A to nie jest bezpieczne dla samodzielnego zadania.

Czasami jest to niefortunne. Rozważ wyprowadzenie z wektora wszystkich liczb parzystych:

src.erase(
  std::partition_copy(src.begin(), src.end(),
                      src.begin(),
                      std::back_inserter(even),
                      [](int num) { return num % 2; }
                      ).first,
  src.end());

Jest to w porządku dla liczb całkowitych, ale nie sądzę, aby coś takiego działało z semantyką przenoszenia.

Podsumowując: przeniesienie przypisania do samego obiektu nie jest w porządku i trzeba na to uważać.

Mała aktualizacja.

  1. Nie zgadzam się z Howardem, co jest złym pomysłem, ale mimo to - uważam, że przypisywanie obiektów „przeniesionych” do siebie powinno działać, ponieważ swap(x, x)powinno działać. Algorytmy uwielbiają te rzeczy! Zawsze miło jest, gdy narożnik po prostu działa. (I jeszcze nie widziałem przypadku, w którym nie jest to darmowe. Nie oznacza to jednak, że nie istnieje).
  2. Oto jak przypisywanie unique_ptrs jest zaimplementowane w libc ++: unique_ptr& operator=(unique_ptr&& u) noexcept { reset(u.release()); ...} jest bezpieczne dla samodzielnego przypisywania ruchu.
  3. Podstawowe Wytyczne uważają, że samodzielne przydzielanie ruchu powinno być w porządku.
Denis Yaroshevskiy
źródło
0

Jest sytuacja, o której (this == rhs) przychodzi mi do głowy. W przypadku tej instrukcji: Myclass obj; std :: move (obj) = std :: move (obj)

mały potwór
źródło
Myclass obj; std :: move (obj) = std :: move (obj);
little_monster