Czym jest ten idiom i kiedy należy go używać? Jakie problemy rozwiązuje? Czy idiom zmienia się, gdy używany jest C ++ 11?
Chociaż w wielu miejscach wspomniano o tym, nie mieliśmy żadnego pojedynczego pytania i odpowiedzi „co to jest”, więc oto jest. Oto częściowa lista miejsc, w których wcześniej wspomniano:
Odpowiedzi:
Przegląd
Dlaczego potrzebujemy idiomu „kopiuj i zamień”?
Każda klasa zarządzająca zasobem ( opakowanie , takie jak inteligentny wskaźnik) musi zaimplementować Wielką Trójkę . Podczas gdy cele i implementacja konstruktora kopii i destruktora są proste, operator przypisywania kopii jest prawdopodobnie najbardziej dopracowany i najtrudniejszy. Jak należy to zrobić? Jakich pułapek należy unikać?
Idiom „ kopiuj i zamień” jest rozwiązaniem i elegancko pomaga operatorowi przypisania osiągnąć dwie rzeczy: uniknąć duplikacji kodu i zapewnić silną gwarancję wyjątku .
Jak to działa?
Pod względem koncepcyjnym działa przy użyciu funkcji konstruktora kopii, aby utworzyć lokalną kopię danych, a następnie pobiera skopiowane dane za pomocą
swap
funkcji, zamieniając stare dane na nowe. Tymczasowa kopia ulega zniszczeniu, zabierając ze sobą stare dane. Pozostaje nam kopia nowych danych.Aby użyć idiomu kopiowania i zamiany, potrzebujemy trzech rzeczy: działającego konstruktora kopii, działającego niszczyciela (oba są podstawą każdego opakowania, więc i tak powinny być kompletne) oraz
swap
funkcji.Funkcja zamiany to funkcja nie rzucająca, która zamienia dwa obiekty klasy, element członkowski na element członkowski. Możemy ulec pokusie użycia
std::swap
zamiast dostarczenia własnego, ale byłoby to niemożliwe;std::swap
korzysta z konstruktora kopiowania i operatora przypisania kopii w ramach swojej implementacji, a my ostatecznie staralibyśmy się zdefiniować operatora przypisania sam w sobie!(Nie tylko to, ale także niewykwalifikowane połączenia z
swap
naszym niestandardowym operatorem wymiany, pomijając niepotrzebną konstrukcję i zniszczenie naszej klasy, którestd::swap
to pociągałoby za sobą).Dogłębne wyjaśnienie
Cel
Rozważmy konkretny przypadek. Chcemy zarządzać, w skądinąd bezużytecznej klasie, tablicą dynamiczną. Zaczynamy od działającego konstruktora, konstruktora kopii i destruktora:
Ta klasa prawie skutecznie zarządza tablicą, ale musi
operator=
działać poprawnie.Nieudane rozwiązanie
Oto jak może wyglądać naiwna implementacja:
I mówimy, że jesteśmy skończeni; teraz zarządza tablicą, bez wycieków. Ma jednak trzy problemy, oznaczone kolejno w kodzie jako
(n)
.Pierwszym z nich jest test samodzielnego przypisania. Ta kontrola służy dwóm celom: jest to łatwy sposób, aby uniemożliwić nam uruchamianie niepotrzebnego kodu podczas samodzielnego przypisywania i chroni nas przed subtelnymi błędami (takimi jak usunięcie tablicy tylko w celu jej skopiowania). Ale we wszystkich innych przypadkach służy jedynie spowolnieniu programu i działa jak szum w kodzie; samodzielne przydzielanie rzadko występuje, więc przez większość czasu ta kontrola jest marnotrawstwem. Byłoby lepiej, gdyby operator mógł bez niego prawidłowo działać.
Po drugie, zapewnia jedynie podstawową gwarancję wyjątku. Jeśli się
new int[mSize]
nie powiedzie,*this
zostanie zmodyfikowany. (Mianowicie, rozmiar jest nieprawidłowy, a danych już nie ma!) Aby uzyskać silną gwarancję wyjątku, musiałoby to być coś w rodzaju:Kod się rozszerzył! Co prowadzi nas do trzeciego problemu: duplikacji kodu. Nasz operator przypisania skutecznie powiela cały kod, który już napisaliśmy w innym miejscu, i to jest okropne.
W naszym przypadku jego rdzeniem są tylko dwie linie (alokacja i kopia), ale przy bardziej złożonych zasobach ten rozdęty kod może być dość kłopotliwy. Powinniśmy starać się nigdy nie powtarzać.
(Można się zastanawiać: jeśli tak dużo kodu jest potrzebne do prawidłowego zarządzania jednym zasobem, co się stanie, jeśli moja klasa zarządza więcej niż jednym? Chociaż może to wydawać się uzasadnione i rzeczywiście wymaga nie trywialnych
try
/catch
klauzul, nie jest to -tak. To dlatego, że klasa powinna zarządzać tylko jednym zasobem !)Udane rozwiązanie
Jak wspomniano, idiom kopiowania i zamiany naprawi wszystkie te problemy. Ale teraz mamy wszystkie wymagania oprócz jednego:
swap
funkcji. Chociaż Reguła trzech z powodzeniem pociąga za sobą istnienie naszego konstruktora kopii, operatora przypisania i destruktora, tak naprawdę powinna ona nosić nazwę „Wielka Trójka i Pół”: za każdym razem, gdy klasa zarządza zasobem, sensowne jest również zapewnienieswap
funkcji .Musimy dodać funkcjonalność wymiany do naszej klasy i robimy to w następujący sposób †:
( Oto wyjaśnienie, dlaczego
public friend swap
). Teraz możemy nie tylko zamieniać naszedumb_array
, ale ogólnie swapy mogą być bardziej wydajne; po prostu zamienia wskaźniki i rozmiary, a nie alokuje i kopiuje całe tablice. Oprócz tego bonusu w funkcjonalności i wydajności, jesteśmy teraz gotowi do wdrożenia idiomu kopiowania i zamiany.Bez zbędnych ceregieli nasz operator przypisania to:
I to wszystko! Za jednym zamachem wszystkie trzy problemy są elegancko rozwiązywane jednocześnie.
Dlaczego to działa?
Najpierw zauważamy ważny wybór: argument parametru jest brany pod uwagę według wartości . Chociaż równie łatwo można wykonać następujące czynności (a nawet wiele naiwnych implementacji tego idiomu):
Tracimy ważną szansę optymalizacji . Nie tylko to, ale ten wybór jest krytyczny w C ++ 11, który zostanie omówiony później. (Ogólnie rzecz biorąc, niezwykle przydatna wskazówka jest następująca: jeśli masz zamiar zrobić kopię czegoś w funkcji, pozwól kompilatorowi zrobić to na liście parametrów. ‡)
Tak czy inaczej, ta metoda pozyskania naszego zasobu jest kluczem do wyeliminowania powielania kodu: możemy użyć kodu z konstruktora kopii do wykonania kopii i nigdy nie musimy go powtarzać. Po wykonaniu kopii jesteśmy gotowi do wymiany.
Zauważ, że po wejściu do funkcji wszystkie nowe dane są już przydzielone, skopiowane i gotowe do użycia. To daje nam silną gwarancję wyjątku za darmo: nawet nie wejdziemy w funkcję, jeśli konstrukcja kopii nie powiedzie się, a zatem nie można zmienić stanu
*this
. (To, co robiliśmy wcześniej ręcznie dla silnej gwarancji wyjątku, kompilator robi dla nas teraz; jak miło.)W tym momencie jesteśmy wolni od domu, ponieważ
swap
nie rzucamy. Zamieniamy nasze bieżące dane na skopiowane dane, bezpiecznie zmieniając nasz stan, a stare dane są umieszczane w tymczasowym. Stare dane są następnie zwalniane po powrocie funkcji. (Gdzie kończy się zakres parametru, a wywoływany jest jego destruktor.)Ponieważ idiom nie powtarza kodu, nie możemy wprowadzać błędów w operatorze. Zauważ, że oznacza to, że pozbyliśmy się potrzeby samodzielnego sprawdzania, pozwalającego na jednolitą implementację
operator=
. (Ponadto nie ponosimy już kary za wydajność w przypadku zadań innych niż samodzielne przydzielanie).I to jest idiom kopiowania i zamiany.
Co z C ++ 11?
Następna wersja C ++, C ++ 11, wprowadza jedną bardzo ważną zmianę w sposobie zarządzania zasobami: Reguła Trzech jest teraz Regułą Czterech (i pół). Dlaczego? Ponieważ nie tylko musimy być w stanie skopiować-skonstruować nasz zasób, musimy również go przenieść-skonstruować .
Na szczęście dla nas jest to łatwe:
Co tu się dzieje? Przypomnij sobie cel budowy ruchów: pobranie zasobów z innej instancji klasy, pozostawiając ją w stanie gwarantującym możliwość przypisania i zniszczenia.
To, co zrobiliśmy, jest proste: zainicjuj za pomocą domyślnego konstruktora (funkcja C ++ 11), a następnie zamień za pomocą
other
; wiemy, że domyślnie skonstruowana instancja naszej klasy może być bezpiecznie przypisana i zniszczona, więc wiemy, żeother
będziemy mogli zrobić to samo, po zamianie.(Należy pamiętać, że niektóre kompilatory nie obsługują delegowania konstruktorów; w tym przypadku musimy ręcznie ręcznie skonstruować klasę. To niefortunne, ale na szczęście trywialne zadanie).
Dlaczego to działa?
To jedyna zmiana, którą musimy wprowadzić w naszej klasie, więc dlaczego to działa? Pamiętaj o bardzo ważnej decyzji, którą podjęliśmy, aby parametr stał się wartością, a nie odniesieniem:
Teraz, jeśli
other
zostanie zainicjowany za pomocą wartości, zostanie on skonstruowany w ruchu . Doskonały. W ten sam sposób, w C ++ 03, ponownie wykorzystajmy naszą funkcję konstruktora kopii, biorąc argument za wartość, C ++ 11 automatycznie wybierze również konstruktor ruchu, gdy jest to właściwe. (I, oczywiście, jak wspomniano w poprzednio połączonym artykule, kopiowanie / przenoszenie wartości można po prostu całkowicie pominąć).I tak kończy się idiom kopiowania i zamiany.
Przypisy
* Dlaczego ustawiamy
mArray
na zero? Ponieważ jeśli jakikolwiek dalszy kod w operatorze wyrzuci,dumb_array
można wywołać destruktor ; a jeśli tak się stanie bez ustawienia wartości null, próbujemy usunąć pamięć, która została już usunięta! Unikamy tego, ustawiając go na null, ponieważ usunięcie null jest brakiem operacji.† Istnieją inne twierdzenia, że powinniśmy specjalizować się
std::swap
w naszym typie, zapewnić w swojej klasieswap
bezpłatną funkcjęswap
itp. Ale to wszystko jest niepotrzebne: każde prawidłowe użycieswap
będzie odbywać się za pośrednictwem niekwalifikowanego połączenia, a nasza funkcja będzie znalezione przez ADL . Jedna funkcja zadziała.‡ Powód jest prosty: gdy masz zasoby dla siebie, możesz je zamienić i / lub przenieść (C ++ 11) w dowolne miejsce. Wykonując kopię na liście parametrów, maksymalizujesz optymalizację.
†† Konstruktor ruchu powinien zasadniczo być
noexcept
, w przeciwnym razie część kodu (np.std::vector
Logika zmiany rozmiaru) użyje konstruktora kopiowania, nawet jeśli ruch miałby sens. Oczywiście zaznacz go tylko, jeśli kod w nim nie zgłasza wyjątków.źródło
swap
go znaleźć podczas ADL, jeśli chcesz, aby działał w najbardziej ogólnym kodzie, na który się natkniesz, jakboost::swap
i w innych różnych przykładach wymiany. Zamiana jest trudnym problemem w C ++ i ogólnie wszyscy zgodziliśmy się, że jeden punkt dostępu jest najlepszy (dla zachowania spójności), a jedynym sposobem na zrobienie tego w ogólności jest darmowa funkcja (int
nie może mieć elementu zamiany, na przykład). Zobacz moje pytanie, aby uzyskać trochę tła.U podstaw przypisania znajdują się dwa etapy: zburzenie starego stanu obiektu i zbudowanie nowego stanu jako kopii stanu innego obiektu.
Zasadniczo tak właśnie robią destruktor i konstruktor kopii , więc pierwszym pomysłem byłoby przekazanie im pracy. Ponieważ jednak zniszczenie nie może zawieść, podczas gdy konstrukcja może, chcemy to zrobić odwrotnie : najpierw wykonaj część konstruktywną, a jeśli to się powiedzie, a następnie część destrukcyjną . Idiom „kopiuj i zamień” to sposób, aby to zrobić: najpierw wywołuje konstruktora kopiowania klasy, aby utworzyć obiekt tymczasowy, następnie zamienia dane na dane tymczasowe, a następnie pozwala, aby destruktor tymczasowy zniszczył stary stan.
Od
swap()
ma nigdy nie zawieść, jedyną częścią, która może zawieść, jest konstrukcja kopii. Jest to wykonywane jako pierwsze, a jeśli się nie powiedzie, nic nie zostanie zmienione w docelowym obiekcie.W wyrafinowanej formie, kopiowanie i zamiana jest realizowane poprzez wykonanie kopii przez zainicjowanie (nie referencyjnego) parametru operatora przypisania:
źródło
std::swap(this_string, that)
nie zapewnia gwarancji braku rzutu. Zapewnia to wyjątkowe bezpieczeństwo, ale nie gwarantuje braku rzucania.std::string::swap
(które są wywoływane przezstd::swap
). W C ++ 0xstd::string::swap
jestnoexcept
i nie może zgłaszać wyjątków.std::array
...)Istnieje już kilka dobrych odpowiedzi. Skoncentruję się głównie na tym, co moim zdaniem brakuje - objaśnieniu „wad” za pomocą idiomu kopiowania i zamiany…
Sposób implementacji operatora przypisania pod względem funkcji zamiany:
Podstawową ideą jest to, że:
najbardziej podatną na błędy częścią przypisywania do obiektu jest zapewnienie zasobów potrzebnych do uzyskania nowego stanu (np. pamięć, deskryptory)
tego przejęcia można podjąć przed zmodyfikowaniem bieżącego stanu obiektu (tj.
*this
), jeśli wykonano kopię nowej wartości, dlategorhs
jest akceptowana przez wartość (tj. skopiowana), a nie przez odniesieniezamiana stanu lokalnej kopii
rhs
i*this
zwykle jest względnie łatwa do zrobienia bez potencjalnej awarii / wyjątków, ponieważ lokalna kopia nie potrzebuje później żadnego określonego stanu (po prostu potrzebuje stanu nadającego się do działania destruktora, podobnie jak w przypadku przemieszczanego obiektu od w> = C ++ 11)Gdy chcesz, aby przypisane do sprzeciwu nie miało wpływu zadanie przypisujące wyjątek, zakładając, że masz lub możesz napisać
swap
z silną gwarancją wyjątku, a najlepiej takie, które nie może zawieść /throw
.. †Gdy potrzebujesz czystego, łatwego do zrozumienia, solidnego sposobu zdefiniowania operatora przypisania w kategoriach (prostszego) konstruktora kopiowania
swap
i funkcji destruktora.†
swap
rzucanie: generalnie możliwe jest niezawodne zamiana elementów danych, które obiekty śledzą za pomocą wskaźnika, ale elementów danych niebędących wskaźnikami, które nie mają zamiany bez rzucania lub dla których zamiana musi zostać zaimplementowana jakoX tmp = lhs; lhs = rhs; rhs = tmp;
konstrukcja-kopia lub przypisanie może rzucać, nadal może się nie powieść, pozostawiając niektórych członków danych zamienionych, a innych nie. Ten potencjał dotyczy nawet C ++ 03std::string
, gdy James komentuje inną odpowiedź:‡ Implementacja operatora przypisania, która wydaje się rozsądna podczas przypisywania z odrębnego obiektu, może łatwo zawieść w przypadku samodzielnego przypisania. Chociaż może się wydawać niewyobrażalne, że kod klienta próbowałby nawet samodzielnie przypisać, może się to zdarzyć stosunkowo łatwo podczas operacji algo na kontenerach, przy czym
x = f(x);
kod, gdzief
jest (być może tylko dla niektórych#ifdef
gałęzi) makro ala#define f(x) x
lub funkcja zwracająca odniesieniex
, a nawet (prawdopodobnie nieefektywny, ale zwięzły) kod podobny dox = c1 ? x * 2 : c2 ? x / 2 : x;
). Na przykład:Przy samodzielnym przypisywaniu powyższy kod kasujący
x.p_;
wskazujep_
na nowo przydzielony region sterty, a następnie próbuje odczytać w nim niezainicjowane dane (Niezdefiniowane zachowanie), jeśli nie robi to nic dziwnego,copy
próbuje przydzielić się do każdego just- zniszczone „T”!Id Idiom kopiowania i zamiany może wprowadzać nieefektywności lub ograniczenia z powodu zastosowania dodatkowego tymczasowego (gdy parametr operatora jest kopiowany):
Tutaj odręcznie
Client::operator=
może sprawdzić, czy*this
jest już podłączone do tego samego serwera corhs
(być może wysyłanie kodu „resetującego”, jeśli jest to przydatne), podczas gdy metoda kopiowania i zamiany wywołałaby konstruktora kopiowania, który prawdopodobnie zostałby napisany do otwarcia wyraźne połączenie z gniazdem, a następnie zamknij oryginalne. Może to oznaczać nie tylko zdalną interakcję w sieci zamiast prostej kopii zmiennej procesowej, ale może również ograniczać limity klienta lub serwera dotyczące zasobów gniazd lub połączeń. (Oczywiście ta klasa ma dość okropny interfejs, ale to już inna sprawa ;-P).źródło
Client
polega na tym, że przypisanie nie jest zabronione.Ta odpowiedź jest raczej dodatkiem i niewielką modyfikacją powyższych odpowiedzi.
W niektórych wersjach programu Visual Studio (i ewentualnie w innych kompilatorach) występuje błąd, który jest naprawdę denerwujący i nie ma sensu. Więc jeśli zadeklarujesz / zdefiniujesz swoją
swap
funkcję w ten sposób:... kompilator będzie krzyczał na ciebie, gdy wywołasz
swap
funkcję:Ma to coś wspólnego z
friend
wywoływaną funkcją ithis
przekazywaniem obiektu jako parametru.Można to obejść bez używania
friend
słowa kluczowego i redefinicjiswap
funkcji:Tym razem możesz po prostu zadzwonić
swap
i przekazaćother
, dzięki czemu kompilator jest szczęśliwy:W końcu nie musisz używać
friend
funkcji do zamiany 2 obiektów. Równie sensowne jest utworzenieswap
funkcji składowej, która ma jedenother
obiekt jako parametr.Masz już dostęp do
this
obiektu, więc przekazanie go jako parametru jest technicznie zbędne.źródło
friend
funkcja jest wywoływana z*this
parametremChciałbym dodać słowo ostrzeżenia, gdy mamy do czynienia z kontenerami w stylu C ++ 11 obsługującymi alokatory. Zamiana i przypisanie mają subtelnie inną semantykę.
Dla konkretności rozważmy kontener
std::vector<T, A>
, w którymA
jest jakiś stanowy typ alokatora, i porównamy następujące funkcje:Celem obu funkcji
fs
ifm
jest daća
stan, któryb
miał początkowo. Istnieje jednak ukryte pytanie: co się stanie, jeślia.get_allocator() != b.get_allocator()
? Odpowiedź brzmi: to zależy. Write ChodźmyAT = std::allocator_traits<A>
.Jeśli
AT::propagate_on_container_move_assignment
takstd::true_type
, tofm
ponownie przypisuje alokatora
z wartościąb.get_allocator()
, w przeciwnym razie nie, ia
nadal używa swojego pierwotnego alokatora. W takim przypadku elementy danych muszą zostać zamienione indywidualnie, ponieważ przechowywaniea
ib
niezgodność.Jeśli
AT::propagate_on_container_swap
takstd::true_type
, tofs
zamienia zarówno dane, jak i alokatory w oczekiwany sposób.Jeśli
AT::propagate_on_container_swap
takstd::false_type
, to potrzebujemy dynamicznej kontroli.a.get_allocator() == b.get_allocator()
, to dwa pojemniki korzystają z kompatybilnego magazynu, a zamiana przebiega w zwykły sposób.a.get_allocator() != b.get_allocator()
program zachowuje się w sposób nieokreślony (por. [Container.requirements.general / 8].Konsekwencją jest to, że zamiana stała się nietrywialną operacją w C ++ 11, gdy tylko kontener zacznie obsługiwać stanowe alokatory. Jest to nieco „zaawansowany przypadek użycia”, ale nie jest to zupełnie mało prawdopodobne, ponieważ optymalizacje przenoszenia zwykle stają się interesujące dopiero, gdy klasa zarządza zasobem, a pamięć jest jednym z najpopularniejszych zasobów.
źródło