Pozwól, że spróbuję podać różne możliwe tryby przekazywania wskaźników do obiektów, których pamięcią zarządza instancja std::unique_ptr
szablonu klasy; dotyczy to również starszego std::auto_ptr
szablonu klasy (który, jak wierzę, zezwala na wszystkie zastosowania tego unikalnego wskaźnika, ale dla których dodatkowo modyfikowalne wartości będą akceptowane tam, gdzie oczekiwane są wartości bez konieczności wywoływania std::move
), i do pewnego stopnia również std::shared_ptr
.
Jako konkretny przykład do dyskusji rozważę następujący prosty typ listy
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Wystąpienia takiej listy (której nie można udostępniać części innym wystąpieniom lub być okrągłe) są w całości własnością tego, kto posiada list
wskaźnik początkowy . Jeśli kod klienta wie, że lista, którą przechowuje, nigdy nie będzie pusta, może również zapisać pierwszą node
bezpośrednio zamiast list
. Brak destruktora dlanode
trzeba definiować : ponieważ destruktory dla jego pól są wywoływane automatycznie, cała lista zostanie rekursywnie usunięta przez inteligentny wskaźnik destruktor wskaźnika, gdy upłynie okres użytkowania początkowego wskaźnika lub węzła.
Ten typ rekurencyjny daje okazję do omówienia niektórych przypadków, które są mniej widoczne w przypadku inteligentnego wskaźnika do zwykłych danych. Również same funkcje czasami dostarczają (rekurencyjnie) przykład kodu klienta. Typedef dlalist
jest oczywiście stronniczy unique_ptr
, ale definicję można zmienić na use auto_ptr
lubshared_ptr
zamiast tego bez potrzeby zmiany tego, co powiedziano poniżej (w szczególności w odniesieniu do zapewnienia bezpieczeństwa wyjątkowego bez konieczności pisania niszczycieli).
Tryby przekazywania inteligentnych wskaźników
Tryb 0: przekaż wskaźnik lub argument referencyjny zamiast inteligentnego wskaźnika
Jeśli twoja funkcja nie dotyczy własności, jest to preferowana metoda: nie zmuszaj jej wcale do inteligentnego wskaźnika. W takim przypadku twoja funkcja nie musi się martwić, kto jest właścicielem wskazanego obiektu lub w jaki sposób zarządzanie własnością jest zarządzane, więc przekazanie surowego wskaźnika jest zarówno całkowicie bezpieczne, jak i najbardziej elastyczną formą, ponieważ niezależnie od własności klient zawsze może tworzy surowy wskaźnik (przez wywołanie get
metody lub z adresu operatora &
).
Na przykład funkcja obliczająca długość takiej listy nie powinna być list
argumentem, lecz surowym wskaźnikiem:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Klient, który posiada zmienną, list head
może wywołać tę funkcję jako length(head.get())
, podczas gdy klient, który zamiast tego wybrał przechowywanie node n
reprezentującej niepustej listy, może zadzwonić length(&n)
.
Jeśli gwarantujemy, że wskaźnik ma wartość inną niż null (co nie ma tu miejsca, ponieważ listy mogą być puste), można raczej przekazać referencję niż wskaźnik. Może to być wskaźnik / odniesienie do, const
jeśli funkcja nie musi aktualizować zawartości węzła (-ów), bez dodawania lub usuwania któregokolwiek z nich (ten ostatni wymagałby własności).
Ciekawym przypadkiem należącym do kategorii trybu 0 jest (głęboka) kopia listy; chociaż funkcja, która to robi, musi oczywiście przenieść własność tworzonej przez siebie kopii, nie dotyczy to własności listy, którą kopiuje. Można go zatem zdefiniować w następujący sposób:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Ten kod zasługuje na dokładne przyjrzenie się, zarówno dla pytania, dlaczego w ogóle się kompiluje (wynik wywołania rekurencyjnego copy
na liście inicjalizacyjnej wiąże się z argumentem referencyjnym wartości w konstruktorze przenoszenia unique_ptr<node>
, czyli list
podczas inicjowania next
pola pola wygenerowane node
) i na pytanie, dlaczego jest bezpieczny w wyjątkach (jeśli podczas procesu alokacji rekurencyjnej skończy się pamięć i jakieś wywołanie new
rzutówstd::bad_alloc
, wówczas wskaźnik do częściowo skonstruowanej listy jest trzymany anonimowo w tymczasowym typie list
utworzony dla listy inicjalizującej, a jej destruktor wyczyści tę listę częściową). Nawiasem mówiąc, należy oprzeć się pokusie zastąpienia (jak na początku) drugiego nullptr
przezp
, który przecież w tym momencie wiadomo, że jest pusty: nie można zbudować inteligentnego wskaźnika ze (surowego) wskaźnika na stały , nawet jeśli wiadomo, że jest pusty.
Tryb 1: przekaż inteligentny wskaźnik wartości
Funkcja, która przyjmuje wartość inteligentnego wskaźnika jako argument, przejmuje w posiadanie obiekt wskazany od razu: inteligentny wskaźnik trzymany przez wywołującego (niezależnie od tego, czy jest to zmienna o nazwie, czy anonimowy tymczasowy) jest kopiowany do wartości argumentu przy wejściu do funkcji i wywołującego wskaźnik stał się zerowy (w przypadku tymczasowej kopia mogła zostać pominięta, ale w każdym przypadku dzwoniący utracił dostęp do wskazanego obiektu). Chciałbym wywołać ten tryb wywołania gotówką, : dzwoniący płaci z góry za usługę, o której mowa, i nie może mieć złudzeń co do własności po zakończeniu połączenia. Aby to wyjaśnić, reguły językowe wymagają od osoby dzwoniącej zawarcia argumentustd::move
jeśli inteligentny wskaźnik jest przechowywany w zmiennej (technicznie, jeśli argument jest wartością); w tym przypadku (ale nie dla trybu 3 poniżej) ta funkcja robi to, co sugeruje jej nazwa, mianowicie przenosi wartość ze zmiennej na tymczasową, pozostawiając zmienną zerową.
W przypadkach, gdy wywoływana funkcja bezwarunkowo przejmuje na własność (przechwytuje) wskazany obiekt, ten tryb jest używany z std::unique_ptr
lub std::auto_ptr
jest dobrym sposobem przekazywania wskaźnika wraz z jego własnością, co pozwala uniknąć ryzyka wycieku pamięci. Niemniej jednak uważam, że jest bardzo niewiele sytuacji, w których tryb 3 poniżej nie powinien być preferowany (choćby nieznacznie) w stosunku do trybu 1. Z tego powodu nie podam żadnych przykładów użycia tego trybu. (Ale zobacz reversed
przykład trybu 3 poniżej, w którym zauważono, że tryb 1 zrobiłby co najmniej równie dobrze.) Jeśli funkcja przyjmuje więcej argumentów niż tylko ten wskaźnik, może się zdarzyć, że istnieje dodatkowy powód techniczny, aby unikać trybu 1 (z std::unique_ptr
lub std::auto_ptr
): ponieważ rzeczywista operacja przesuwania ma miejsce podczas przekazywania zmiennej wskaźnikowejp
przez wyrażenie std::move(p)
nie można założyć, że p
ma on użyteczną wartość podczas oceny innych argumentów (kolejność oceny jest nieokreślona), co może prowadzić do subtelnych błędów; przeciwnie, użycie trybu 3 zapewnia, że żadne przeniesienie nie p
nastąpi przed wywołaniem funkcji, dzięki czemu inne argumenty mogą bezpiecznie uzyskać dostęp do wartości p
.
W przypadku użycia z std::shared_ptr
tym trybem jest interesujący, ponieważ z definicją jednej funkcji pozwala wywołującemu wybrać, czy zachować dla siebie kopię udostępniania wskaźnika podczas tworzenia nowej kopii udostępniania do użycia przez funkcję (dzieje się tak, gdy wartość jest ważna dostarczony jest argument; konstruktor kopii dla wskaźników wspólnych używanych podczas wywołania zwiększa liczbę referencji) lub po prostu daje funkcji kopię wskaźnika bez zachowania jednego lub dotykania liczby referencji (dzieje się tak, gdy zapewniony jest argument wartości wartość zawinięta w call std::move
). Na przykład
void f(std::shared_ptr<X> x) // call by shared cash
{ container.insert(std::move(x)); } // store shared pointer in container
void client()
{ std::shared_ptr<X> p = std::make_shared<X>(args);
f(p); // lvalue argument; store pointer in container but keep a copy
f(std::make_shared<X>(args)); // prvalue argument; fresh pointer is just stored away
f(std::move(p)); // xvalue argument; p is transferred to container and left null
}
To samo można osiągnąć poprzez osobne zdefiniowanie void f(const std::shared_ptr<X>& x)
(dla przypadku void f(std::shared_ptr<X>&& x)
lvalue ) i (dla przypadku rvalue), przy czym ciała funkcji różnią się tylko tym, że pierwsza wersja wywołuje semantykę kopiowania (przy użyciu konstrukcji / przypisania kopii przy użyciu x
), ale druga wersja przenosi semantykę ( std::move(x)
zamiast tego pisze , jak w przykładowym kodzie). W przypadku wskaźników wspólnych tryb 1 może być przydatny, aby uniknąć powielania kodu.
Tryb 2: przekaż inteligentny wskaźnik przez (modyfikowalne) odwołanie do wartości
W tym przypadku funkcja wymaga jedynie modyfikowalnego odniesienia do inteligentnego wskaźnika, ale nie daje żadnych wskazówek, co z nią zrobi. Chciałbym nazwać tę metodę call by card : osoba dzwoniąca zapewnia płatność, podając numer karty kredytowej. Odwołanie może być użyte do przejęcia własności wskazanego obiektu, ale nie musi. Ten tryb wymaga podania modyfikowalnego argumentu wartości, odpowiadającego faktowi, że pożądany efekt funkcji może obejmować pozostawienie użytecznej wartości w zmiennej argumentu. Osoba wywołująca z wyrażeniem rvalue, które chce przekazać do takiej funkcji, byłaby zmuszona do przechowywania jej w nazwie zmiennej, aby móc wykonać wywołanie, ponieważ język zapewnia jedynie domyślną konwersję do stałejlvalue referencja (odnosząca się do wartości tymczasowej) z wartości. (W przeciwieństwie do odwrotnej sytuacji obsługiwanej przez std::move
rzutowanie z Y&&
na Y&
, przy Y
użyciu inteligentnego typu wskaźnika, nie jest możliwe; mimo to tę konwersję można uzyskać za pomocą prostej funkcji szablonu, jeśli jest to naprawdę pożądane; patrz https://stackoverflow.com/a/24868376 / 1436796 ). W przypadku, gdy wywoływana funkcja zamierza bezwarunkowo przejąć na własność obiekt, kradnąc z argumentu, obowiązek podania argumentu wartości daje zły sygnał: zmienna nie będzie miała żadnej użytecznej wartości po wywołaniu. Dlatego do takiego użycia należy preferować tryb 3, który daje identyczne możliwości w ramach naszej funkcji, ale prosi osoby dzwoniące o podanie wartości.
Jednak istnieje uzasadniony przypadek użycia dla trybu 2, a mianowicie funkcje, które mogą modyfikować wskaźnik lub obiekt wskazany w sposób obejmujący własność . Na przykład funkcja, która poprzedza węzeł węzłem, list
stanowi przykład takiego użycia:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Najwyraźniej byłoby tutaj niepożądane wymuszenie użycia dzwoniących std::move
, ponieważ ich inteligentny wskaźnik nadal posiada dobrze zdefiniowaną i niepustą listę po połączeniu, choć inną niż wcześniej.
Znów interesujące jest obserwowanie, co się stanie, jeśli prepend
połączenie nie powiedzie się z powodu braku wolnej pamięci. Wtedy new
połączenie rzuci std::bad_alloc
; w tym momencie, ponieważ nie node
można było przypisać żadnego , pewne jest, że przekazane odwołanie do wartości (tryb 3) z std::move(l)
nie mogło być jeszcze sfałszowane, ponieważ byłoby to zrobione w celu skonstruowania next
pola tego, node
który nie został przydzielony. Tak więc oryginalny inteligentny wskaźnik l
nadal zawiera oryginalną listę, gdy zostanie zgłoszony błąd; ta lista albo zostanie odpowiednio zniszczona przez inteligentny wskaźnik niszczący wskaźnik, albo w przypadku, l
gdyby przetrwała dzięki odpowiednio wczesnej catch
klauzuli, nadal będzie zawierać oryginalną listę.
To był konstruktywny przykład; mrugając do tego pytania można również podać bardziej niszczycielski przykład usunięcia pierwszego węzła zawierającego daną wartość, jeśli taka istnieje:
void remove_first(int x, list& l)
{ list* p = &l;
while ((*p).get()!=nullptr and (*p)->entry!=x)
p = &(*p)->next;
if ((*p).get()!=nullptr)
(*p).reset((*p)->next.release()); // or equivalent: *p = std::move((*p)->next);
}
Ponownie poprawność jest tutaj dość subtelna. W szczególności, w końcowej instrukcji wskaźnik (*p)->next
trzymany w węźle, który ma zostać usunięty, jest odłączony (przez release
, co zwraca wskaźnik, ale powoduje, że pierwotny jest pusty), zanim reset
(domyślnie) zniszczy ten węzeł (gdy niszczy starą przechowywaną wartość p
), zapewniając, że jeden i tylko jeden węzeł jest zniszczone w tym czasie. (W alternatywnej formie, o której mowa w komentarzu, ten czas byłby pozostawiony wewnętrznym aspektom implementacji operatora przeniesienia przypisania std::unique_ptr
instancji list
; standard mówi 20.7.1.2.3; 2, że operator ten powinien postępować „tak, jakby wzywając reset(u.release())
”, dlatego też tutaj czas powinien być bezpieczny.)
Należy pamiętać, że prepend
i remove_first
nie może być wywołana przez klientów, którzy przechowują lokalną node
zmienną na zawsze zakaz pustą listę, i słusznie, ponieważ podane implementacje nie może pracować w takich przypadkach.
Tryb 3: przekaż inteligentny wskaźnik przez (modyfikowalną) wartość referencyjną wartości
Jest to preferowany tryb do użycia, gdy po prostu przejmujesz na własność wskaźnik. Chciałbym wywołać tę metodę call by check : osoba dzwoniąca musi zaakceptować zrzeczenie się własności, jak gdyby zapewniała gotówkę, podpisując czek, ale faktyczna wypłata jest odroczona do momentu, aż wywołana funkcja faktycznie przesunie wskaźnik (dokładnie tak, jak w przypadku trybu 2 ). „Podpisanie czeku” konkretnie oznacza, że dzwoniący muszą owinąć argument std::move
(jak w trybie 1), jeśli jest to wartość (jeśli jest to wartość, część „rezygnacja z własności” jest oczywista i nie wymaga osobnego kodu).
Zauważ, że technicznie tryb 3 zachowuje się dokładnie tak samo jak tryb 2, więc wywoływana funkcja nie musi przejmować własności; Chciałbym jednak twierdzą, że jeśli istnieje jakakolwiek niepewność co do przeniesienia własności (w normalnych warunkach użytkowania), tryb 2 powinny być preferowane do trybu 3, tak, że przy użyciu trybu 3 jest niejawnie sygnał do rozmówców, że są dające się własności. Można powiedzieć, że przekazanie tylko argumentu trybu 1 naprawdę oznacza wymuszoną utratę własności przez osoby dzwoniące. Ale jeśli klient ma jakiekolwiek wątpliwości co do zamiarów wywoływanej funkcji, powinna znać specyfikację wywoływanej funkcji, co powinno usunąć wszelkie wątpliwości.
Zaskakująco trudno jest znaleźć typowy przykład dotyczący naszego list
typu, który wykorzystuje przekazywanie argumentów w trybie 3. Przenoszenie listy b
na koniec innej listy a
jest typowym przykładem; jednak a
(który przetrwa i zatrzyma wynik operacji) lepiej jest przejść za pomocą trybu 2:
void append (list& a, list&& b)
{ list* p=&a;
while ((*p).get()!=nullptr) // find end of list a
p=&(*p)->next;
*p = std::move(b); // attach b; the variable b relinquishes ownership here
}
Czysty przykład przekazywania argumentów trybu 3 jest następujący, który pobiera listę (i jej własność) i zwraca listę zawierającą identyczne węzły w odwrotnej kolejności.
list reversed (list&& l) noexcept // pilfering reversal of list
{ list p(l.release()); // move list into temporary for traversal
list result(nullptr);
while (p.get()!=nullptr)
{ // permute: result --> p->next --> p --> (cycle to result)
result.swap(p->next);
result.swap(p);
}
return result;
}
Tę funkcję można wywołać jako in, l = reversed(std::move(l));
aby odwrócić listę do siebie, ale odwróconej listy można również używać w inny sposób.
Tutaj argument jest natychmiast przenoszony do zmiennej lokalnej dla wydajności (można było użyć parametru l
bezpośrednio zamiast p
, ale dostęp do niego za każdym razem wymagałby dodatkowego poziomu pośredniego); stąd różnica w przekazywaniu argumentów w trybie 1 jest minimalna. W rzeczywistości przy użyciu tego trybu argument mógł służyć bezpośrednio jako zmienna lokalna, unikając w ten sposób początkowego przesunięcia; jest to tylko przykład ogólnej zasady, że jeśli argument przekazany przez referencję służy tylko do zainicjowania zmiennej lokalnej, równie dobrze można przekazać ją zamiast wartości i użyć parametru jako zmiennej lokalnej.
Używanie trybu 3 wydaje się być zalecane przez standard, o czym świadczy fakt, że wszystkie dostarczone funkcje biblioteczne, które przenoszą własność inteligentnych wskaźników za pomocą trybu 3. Szczególnym przekonującym przykładem jest konstruktor std::shared_ptr<T>(auto_ptr<T>&& p)
. Że konstruktor stosowany (w std::tr1
) do podjęcia modyfikowalna lwartości odniesienia (podobnie jak w auto_ptr<T>&
konstruktorze kopii), a zatem można nazwać z auto_ptr<T>
lwartości p
jak w std::shared_ptr<T> q(p)
, po czym p
został zresetowany do wartości null. Ze względu na zmianę z trybu 2 na 3 w przekazywaniu argumentów, stary kod należy teraz przepisać na std::shared_ptr<T> q(std::move(p))
i będzie on nadal działał. Rozumiem, że komitetowi nie spodobał się tutaj tryb 2, ale mieli możliwość przejścia do trybu 1, poprzez zdefiniowaniestd::shared_ptr<T>(auto_ptr<T> p)
zamiast tego mogliby zapewnić, że stary kod działa bez modyfikacji, ponieważ (w przeciwieństwie do wskaźników unikatowych) auto-wskaźniki można dyskretnie wyrejestrować na wartość (sam obiekt wskaźnika jest zresetowany do wartości zerowej w tym procesie). Najwyraźniej komitet tak bardzo wolał opowiadać się za trybem 3 niż za trybem 1, że postanowił aktywnie złamać istniejący kod zamiast używać trybu 1, nawet w przypadku przestarzałego użycia.
Kiedy preferować tryb 3 niż tryb 1
Tryb 1 jest doskonale użyteczny w wielu przypadkach i może być preferowany nad trybem 3 w przypadkach, w których przyjęcie własności przybierałoby formę przeniesienia inteligentnego wskaźnika do zmiennej lokalnej, jak w reversed
powyższym przykładzie. Widzę jednak dwa powody, dla których wolę tryb 3 w bardziej ogólnym przypadku:
Nieco skuteczniej jest przekazać referencję niż utworzyć tymczasowy i nix stary wskaźnik (obsługa gotówki jest nieco pracochłonna); w niektórych scenariuszach wskaźnik może zostać kilkakrotnie przekazany w niezmienionej postaci do innej funkcji, zanim zostanie w rzeczywistości sfałszowany. Takie przekazywanie zwykle wymaga pisania std::move
(chyba że używany jest tryb 2), ale należy pamiętać, że jest to po prostu obsada, która w rzeczywistości nic nie robi (w szczególności bez dereferencji), więc ma zerowy koszt.
Czy można sobie wyobrazić, że cokolwiek zgłasza wyjątek między początkiem wywołania funkcji a punktem, w którym on (lub niektóre wywołanie zawarte) faktycznie przenosi wskazany obiekt do innej struktury danych (a ten wyjątek nie został jeszcze wychwycony w samej funkcji ), a następnie w trybie 1 obiekt, do którego odnosi się inteligentny wskaźnik, zostanie zniszczony, zanim catch
klauzula będzie w stanie obsłużyć wyjątek (ponieważ parametr funkcji został zniszczony podczas rozwijania stosu), ale nie w przypadku użycia trybu 3. Ten ostatni daje właściwość w takich przypadkach osoba wywołująca ma możliwość odzyskania danych obiektu (poprzez wychwycenie wyjątku). Zauważ, że tutaj tryb 1 nie powoduje wycieku pamięci , ale może prowadzić do nieodwracalnej utraty danych dla programu, co również może być niepożądane.
Zwracanie inteligentnego wskaźnika: zawsze według wartości
Kończąc słowo o zwróceniu inteligentnego wskaźnika, prawdopodobnie wskazuje na obiekt stworzony do użycia przez osobę dzwoniącą. Nie jest to tak naprawdę przypadek porównywalny z przekazywaniem wskaźników do funkcji, ale dla kompletności chciałbym nalegać, aby w takich przypadkach zawsze zwracać wartość (i nie używać std::move
w return
instrukcji). Nikt nie chce uzyskać odniesienia do wskaźnika, który prawdopodobnie został właśnie usunięty.