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óre std::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:
#include <algorithm> // std::copy
#include <cstddef> // std::size_t
class dumb_array
{
public:
// (default) constructor
dumb_array(std::size_t size = 0)
: mSize(size),
mArray(mSize ? new int[mSize]() : nullptr)
{
}
// copy-constructor
dumb_array(const dumb_array& other)
: mSize(other.mSize),
mArray(mSize ? new int[mSize] : nullptr),
{
// note that this is non-throwing, because of the data
// types being used; more attention to detail with regards
// to exceptions must be given in a more general case, however
std::copy(other.mArray, other.mArray + mSize, mArray);
}
// destructor
~dumb_array()
{
delete [] mArray;
}
private:
std::size_t mSize;
int* mArray;
};
Ta klasa prawie skutecznie zarządza tablicą, ale musi operator=
działać poprawnie.
Nieudane rozwiązanie
Oto jak może wyglądać naiwna implementacja:
// the hard part
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get rid of the old data...
delete [] mArray; // (2)
mArray = nullptr; // (2) *(see footnote for rationale)
// ...and put in the new
mSize = other.mSize; // (3)
mArray = mSize ? new int[mSize] : nullptr; // (3)
std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
}
return *this;
}
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:
dumb_array& operator=(const dumb_array& other)
{
if (this != &other) // (1)
{
// get the new data ready before we replace the old
std::size_t newSize = other.mSize;
int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
// replace the old data (all are non-throwing)
delete [] mArray;
mSize = newSize;
mArray = newArray;
}
return *this;
}
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ż zapewnienie swap
funkcji .
Musimy dodać funkcjonalność wymiany do naszej klasy i robimy to w następujący sposób †:
class dumb_array
{
public:
// ...
friend void swap(dumb_array& first, dumb_array& second) // nothrow
{
// enable ADL (not necessary in our case, but good practice)
using std::swap;
// by swapping the members of two objects,
// the two objects are effectively swapped
swap(first.mSize, second.mSize);
swap(first.mArray, second.mArray);
}
// ...
};
( Oto wyjaśnienie, dlaczego public friend swap
). Teraz możemy nie tylko zamieniać nasze dumb_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:
dumb_array& operator=(dumb_array other) // (1)
{
swap(*this, other); // (2)
return *this;
}
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):
dumb_array& operator=(const dumb_array& other)
{
dumb_array temp(other);
swap(*this, temp);
return *this;
}
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:
class dumb_array
{
public:
// ...
// move constructor
dumb_array(dumb_array&& other) noexcept ††
: dumb_array() // initialize via default constructor, C++11 only
{
swap(*this, other);
}
// ...
};
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, że other
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:
dumb_array& operator=(dumb_array other); // (1)
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 klasie swap
bezpłatną funkcję swap
itp. Ale to wszystko jest niepotrzebne: każde prawidłowe użycie swap
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.