Vorrei provare a indicare le diverse modalità praticabili per passare i puntatori agli oggetti la cui memoria è gestita da un'istanza del std::unique_ptr
modello di classe; si applica anche al std::auto_ptr
modello di classe precedente (che credo consenta a tutti gli usi che fa quel puntatore univoco, ma per il quale saranno accettati anche valori modificabili dove sono previsti i valori, senza dover invocare std::move
), e in una certa misura anche a std::shared_ptr
.
Come esempio concreto per la discussione, prenderò in considerazione il seguente tipo di elenco semplice
struct node;
typedef std::unique_ptr<node> list;
struct node { int entry; list next; }
Le istanze di tale elenco (a cui non è consentito condividere parti con altre istanze o essere circolari) sono interamente di proprietà di chiunque detenga il list
puntatore iniziale . Se il codice client sa che l'elenco che memorizza non sarà mai vuoto, può anche scegliere di memorizzare il primo node
direttamente anziché un list
. Non è node
necessario definire alcun distruttore : poiché i distruttori per i suoi campi vengono chiamati automaticamente, l'intero elenco verrà eliminato in modo ricorsivo dal distruttore di puntatori intelligenti una volta terminata la durata del puntatore o nodo iniziale.
Questo tipo ricorsivo offre l'occasione per discutere alcuni casi che sono meno visibili nel caso di un puntatore intelligente a dati semplici. Anche le funzioni stesse forniscono occasionalmente (ricorsivamente) anche un esempio di codice client. Il typedef per list
è ovviamente distorto unique_ptr
, ma la definizione potrebbe essere cambiata per usare auto_ptr
o shared_ptr
invece senza molto bisogno di cambiare a quanto detto di seguito (in particolare per quanto riguarda la sicurezza delle eccezioni garantita senza la necessità di scrivere distruttori).
Modalità di passaggio dei puntatori intelligenti
Modalità 0: passa un puntatore o un argomento di riferimento anziché un puntatore intelligente
Se la tua funzione non è interessata alla proprietà, questo è il metodo preferito: non fargli assumere un puntatore intelligente. In questo caso la tua funzione non ha bisogno di preoccuparti di chi possiede l'oggetto puntato, o con quale mezzo la proprietà è gestita, quindi passare un puntatore non elaborato è perfettamente sicuro e la forma più flessibile, dal momento che indipendentemente dalla proprietà un cliente può sempre produce un puntatore non elaborato (chiamando il get
metodo o dall'operatore dell'indirizzo &
).
Ad esempio la funzione per calcolare la lunghezza di tale elenco, non dovrebbe essere un list
argomento, ma un puntatore non elaborato:
size_t length(const node* p)
{ size_t l=0; for ( ; p!=nullptr; p=p->next.get()) ++l; return l; }
Un client che contiene una variabile list head
può chiamare questa funzione come length(head.get())
, mentre un client che ha scelto invece di memorizzare un node n
elenco che rappresenta un elenco non vuoto può chiamare length(&n)
.
Se il puntatore è garantito come non nullo (il che non è il caso qui poiché gli elenchi potrebbero essere vuoti) si potrebbe preferire passare un riferimento anziché un puntatore. Potrebbe essere un puntatore / riferimento a non- const
se la funzione deve aggiornare i contenuti dei nodi, senza aggiungerli o rimuoverli (quest'ultimo implicherebbe la proprietà).
Un caso interessante che rientra nella categoria modalità 0 è la creazione di una (profonda) copia dell'elenco; mentre una funzione che fa ciò deve ovviamente trasferire la proprietà della copia che crea, non si preoccupa della proprietà dell'elenco che sta copiando. Quindi potrebbe essere definito come segue:
list copy(const node* p)
{ return list( p==nullptr ? nullptr : new node{p->entry,copy(p->next.get())} ); }
Questo codice merita uno sguardo ravvicinato, sia per la domanda sul perché si compili del tutto (il risultato della chiamata ricorsiva copy
nell'elenco di inizializzatori si lega all'argomento di riferimento del valore nel costruttore di spostamento di unique_ptr<node>
, aka list
, quando si inizializza il next
campo del generato node
), e per la domanda sul perché è sicura rispetto alle eccezioni (se durante la memoria ricorsiva processo di allocazione si esaurisce e che alcuni chiamano di new
tiri std::bad_alloc
, poi in quel momento un puntatore alla lista in parte costruito si svolge in forma anonima in una temporanea di tipo list
creato per l'elenco di inizializzatori e il relativo distruttore pulirà tale elenco parziale). Tra l'altro si dovrebbe resistere alla tentazione di sostituire (come ho inizialmente fatto) il secondo nullptr
dap
, che dopo tutto è noto per essere nullo in quel punto: non è possibile costruire un puntatore intelligente da un puntatore (grezzo) a costante , anche quando è noto che è nullo.
Modalità 1: passa un puntatore intelligente per valore
Una funzione che accetta un valore di puntatore intelligente come argomento prende possesso dell'oggetto immediatamente indicato: il puntatore intelligente che il chiamante deteneva (sia in una variabile nominata o un temporaneo anonimo) viene copiato nel valore dell'argomento all'ingresso della funzione e il chiamante il puntatore è diventato nullo (nel caso di un temporaneo la copia potrebbe essere stata elusa, ma in ogni caso il chiamante ha perso l'accesso all'oggetto puntato). Vorrei chiamare questa modalità chiamata in contanti : il chiamante paga in anticipo per il servizio chiamato e non può avere illusioni sulla proprietà dopo la chiamata. Per chiarire questo, le regole del linguaggio richiedono che il chiamante includa l'argomentostd::move
se il puntatore intelligente è tenuto in una variabile (tecnicamente, se l'argomento è un valore); in questo caso (ma non per la modalità 3 di seguito) questa funzione fa ciò che suggerisce il suo nome, ovvero sposta il valore dalla variabile a una temporanea, lasciando la variabile nulla.
Per i casi in cui la funzione chiamata assume incondizionatamente la proprietà di (pilfers) l'oggetto puntato, questa modalità utilizzata con std::unique_ptr
o std::auto_ptr
è un buon modo per passare un puntatore insieme alla sua proprietà, evitando così qualsiasi rischio di perdite di memoria. Tuttavia, penso che ci siano solo pochissime situazioni in cui la modalità 3 di seguito non deve essere preferita (mai così leggermente) rispetto alla modalità 1. Per questo motivo non fornirò esempi di utilizzo di questa modalità. (Ma vedi l' reversed
esempio della modalità 3 di seguito, in cui si osserva che anche la modalità 1 farebbe almeno altrettanto.) Se la funzione accetta più argomenti di questo semplice puntatore, può accadere che ci sia anche un motivo tecnico per evitare la modalità 1 (con std::unique_ptr
o std::auto_ptr
): poiché viene eseguita un'operazione di spostamento effettiva mentre si passa una variabile puntatorep
dall'espressione std::move(p)
, non si può presumere che abbia p
un valore utile durante la valutazione degli altri argomenti (l'ordine di valutazione non è specificato), il che potrebbe portare a sottili errori; al contrario, l'utilizzo della modalità 3 assicura che non si verifichino spostamenti p
prima della chiamata alla funzione, in modo che altri argomenti possano accedere in modo sicuro a un valore tramite p
.
Se utilizzata con std::shared_ptr
, questa modalità è interessante in quanto con una singola definizione di funzione consente al chiamante di scegliere se mantenere una copia condivisa del puntatore per sé mentre crea una nuova copia condivisa che deve essere utilizzata dalla funzione (ciò accade quando un valore viene fornito l'argomento; il costruttore della copia per i puntatori condivisi utilizzati durante la chiamata aumenta il conteggio dei riferimenti) o per assegnare alla funzione una copia del puntatore senza trattenerne uno o toccare il conteggio dei riferimenti (ciò accade quando viene fornito un argomento rvalue, possibilmente un valore racchiuso in una chiamata di std::move
). Per esempio
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
}
Lo stesso si può ottenere definendo separatamente void f(const std::shared_ptr<X>& x)
(per il caso lvalue) e void f(std::shared_ptr<X>&& x)
(per il caso rvalue), con corpi funzione diversi solo per il fatto che la prima versione invoca la semantica della copia (usando la costruzione / assegnazione della copia quando si usa x
) ma la seconda versione sposta la semantica (scrivendo std::move(x)
invece, come nel codice di esempio). Pertanto, per i puntatori condivisi, la modalità 1 può essere utile per evitare la duplicazione del codice.
Modalità 2: passa un puntatore intelligente tramite riferimento al valore (modificabile)
Qui la funzione richiede solo di avere un riferimento modificabile al puntatore intelligente, ma non fornisce indicazioni su cosa ne farà. Vorrei chiamare questo metodo chiamata per carta : il chiamante assicura il pagamento fornendo un numero di carta di credito. Il riferimento può essere utilizzato per diventare proprietario dell'oggetto puntato, ma non è necessario. Questa modalità richiede di fornire un argomento lvalue modificabile, corrispondente al fatto che l'effetto desiderato della funzione può includere lasciare un valore utile nella variabile argomento. Un chiamante con un'espressione rvalore che desidera passare a tale funzione sarebbe costretto a memorizzarlo in una variabile denominata per poter effettuare la chiamata, poiché il linguaggio fornisce solo la conversione implicita in una costanteriferimento al valore (riferito a un temporaneo) da un valore. (A differenza della situazione opposta gestita da std::move
, un cast da Y&&
a Y&
, con Y
il tipo di puntatore intelligente, non è possibile; tuttavia questa conversione potrebbe essere ottenuta da una semplice funzione modello se lo si desidera davvero; vedere https://stackoverflow.com/a/24868376 / 1436796 ). Nel caso in cui la funzione chiamata intende assumere incondizionatamente la proprietà dell'oggetto, rubando dall'argomento, l'obbligo di fornire un argomento lvalue sta dando il segnale sbagliato: la variabile non avrà alcun valore utile dopo la chiamata. Pertanto la modalità 3, che offre identiche possibilità all'interno della nostra funzione ma chiede ai chiamanti di fornire un valore, dovrebbe essere preferita per tale utilizzo.
Tuttavia, esiste un caso d'uso valido per la modalità 2, vale a dire le funzioni che possono modificare il puntatore o l'oggetto indicato in un modo che implica la proprietà . Ad esempio, una funzione che antepone un nodo a list
fornisce un esempio di tale utilizzo:
void prepend (int x, list& l) { l = list( new node{ x, std::move(l)} ); }
Chiaramente sarebbe indesiderabile qui forzare l'uso dei chiamanti std::move
, poiché il loro puntatore intelligente possiede ancora un elenco ben definito e non vuoto dopo la chiamata, sebbene diverso da prima.
Ancora una volta è interessante osservare cosa succede se la prepend
chiamata fallisce per mancanza di memoria libera. Quindi la new
chiamata verrà lanciata std::bad_alloc
; in questo momento, poiché non è node
stato possibile assegnare alcun valore , è certo che il riferimento al valore passato (modalità 3) da std::move(l)
non può essere stato ancora rubato, in quanto ciò verrebbe fatto per costruire il next
campo di quello node
che non è stato assegnato. Quindi il puntatore intelligente originale l
contiene ancora l'elenco originale quando viene generato l'errore; tale elenco verrà o correttamente distrutto dal distruttore di puntatori intelligenti o, nel caso in cui l
dovesse sopravvivere grazie a una catch
clausola sufficientemente precoce , conterrà comunque l'elenco originale.
Questo è stato un esempio costruttivo; con un occhiolino a questa domanda si può anche dare l'esempio più distruttivo di rimuovere il primo nodo contenente un dato valore, se presente:
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);
}
Anche in questo caso la correttezza è abbastanza sottile. In particolare, nell'istruzione finale il puntatore (*p)->next
tenuto all'interno del nodo da rimuovere è scollegato (da release
, che restituisce il puntatore ma rende nullo l'originale) prima reset
(implicitamente) distrugge quel nodo (quando distrugge il vecchio valore detenuto da p
), assicurando che un solo nodo viene distrutto in quel momento. (Nella forma alternativa menzionata nel commento, questa tempistica sarebbe lasciata agli interni dell'implementazione dell'operatore di assegnazione dei movimenti std::unique_ptr
dell'istanza list
; lo standard dice 20.7.1.2.3; 2 che questo operatore dovrebbe agire "come se chiamando reset(u.release())
", da qui anche i tempi dovrebbero essere al sicuro.)
Si noti che prepend
e remove_first
non può essere chiamato dai client che memorizzano una node
variabile locale per un elenco sempre non vuoto, e giustamente poiché le implementazioni fornite non possono funzionare in questi casi.
Modalità 3: passa un puntatore intelligente tramite riferimento al valore (modificabile)
Questa è la modalità preferita da usare quando si diventa semplicemente proprietari del puntatore. Vorrei chiamare questo metodo chiamata tramite assegno : il chiamante deve accettare la rinuncia alla proprietà, come se fornisse denaro, firmando l'assegno, ma l'effettivo prelievo viene posticipato fino a quando la funzione chiamata non impegna effettivamente il puntatore (esattamente come farebbe quando si utilizza la modalità 2 ). La "firma del controllo" significa concretamente che i chiamanti devono avvolgere un argomento std::move
(come nella modalità 1) se è un valore (se è un valore, la parte "rinunciare alla proprietà" è ovvia e non richiede un codice separato).
Si noti che tecnicamente la modalità 3 si comporta esattamente come la modalità 2, quindi la funzione chiamata non deve assumere la proprietà; tuttavia vorrei insistere sul fatto che in caso di incertezza sul trasferimento della proprietà (nell'uso normale), la modalità 2 dovrebbe essere preferita alla modalità 3, in modo che l'uso della modalità 3 sia implicitamente un segnale per i chiamanti che stanno cedendo la proprietà. Si potrebbe replicare che solo l'argomento della modalità 1 che passa in realtà segnala la perdita forzata della proprietà ai chiamanti. Ma se un cliente ha dei dubbi sulle intenzioni della funzione chiamata, dovrebbe conoscere le specifiche della funzione chiamata, che dovrebbe rimuovere ogni dubbio.
È sorprendentemente difficile trovare un esempio tipico che coinvolga il nostro list
tipo che utilizza il passaggio dell'argomento in modalità 3. Lo spostamento di un elenco b
alla fine di un altro elenco a
è un tipico esempio; tuttavia a
(che sopravvive e detiene il risultato dell'operazione) è meglio passare usando la modalità 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
}
Un puro esempio di passaggio dell'argomento in modalità 3 è il seguente che prende un elenco (e la sua proprietà) e restituisce un elenco contenente i nodi identici in ordine inverso.
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;
}
Questa funzione può essere chiamata come in l = reversed(std::move(l));
per invertire l'elenco in se stesso, ma l'elenco invertito può anche essere utilizzato in modo diverso.
Qui l'argomento viene immediatamente spostato in una variabile locale per efficienza (si potrebbe usare il parametro l
direttamente al posto di p
, ma accedervi ogni volta comporterebbe un ulteriore livello di riferimento indiretto); quindi la differenza con il passaggio dell'argomento in modalità 1 è minima. In effetti usando quella modalità, l'argomento avrebbe potuto servire direttamente come variabile locale, evitando così quella mossa iniziale; questa è solo un'istanza del principio generale secondo cui se un argomento passato per riferimento serve solo a inizializzare una variabile locale, si potrebbe anche passarlo per valore e usare il parametro come variabile locale.
L'uso della modalità 3 sembra essere sostenuto dallo standard, come testimonia il fatto che tutte le funzioni di libreria fornite che trasferiscono la proprietà dei puntatori intelligenti utilizzando la modalità 3. Un caso particolare convincente è il costruttore std::shared_ptr<T>(auto_ptr<T>&& p)
. Quel costruttore ha usato (in std::tr1
) per prendere un riferimento al valore modificabile (proprio come il auto_ptr<T>&
costruttore della copia), e potrebbe quindi essere chiamato con un auto_ptr<T>
valore p
come in std::shared_ptr<T> q(p)
, dopo di che p
è stato resettato a null. A causa del passaggio dalla modalità 2 alla 3 nel passaggio degli argomenti, questo vecchio codice deve ora essere riscritto std::shared_ptr<T> q(std::move(p))
e continuerà a funzionare. Capisco che al comitato non piaceva la modalità 2 qui, ma avevano la possibilità di passare alla modalità 1, definendostd::shared_ptr<T>(auto_ptr<T> p)
invece, avrebbero potuto assicurarsi che il vecchio codice funzioni senza modifiche, poiché (diversamente dai puntatori univoci) i puntatori automatici possono essere silenziosamente riferiti a un valore (l'oggetto puntatore stesso viene reimpostato su null nel processo). Apparentemente, il comitato ha preferito così tanto sostenere la modalità 3 rispetto alla modalità 1, che hanno scelto di rompere attivamente il codice esistente piuttosto che utilizzare la modalità 1 anche per un utilizzo già deprecato.
Quando preferire la modalità 3 rispetto alla modalità 1
La modalità 1 è perfettamente utilizzabile in molti casi e potrebbe essere preferita alla modalità 3 nei casi in cui assumere la proprietà assumerebbe altrimenti la forma di spostare il puntatore intelligente su una variabile locale come reversed
nell'esempio sopra. Tuttavia, posso vedere due motivi per preferire la modalità 3 nel caso più generale:
È leggermente più efficiente passare un riferimento che creare un temporaneo e annullare il vecchio puntatore (la gestione del denaro è alquanto laboriosa); in alcuni scenari il puntatore può essere passato più volte invariato a un'altra funzione prima di essere effettivamente rubato. Tale passaggio richiederà generalmente la scrittura std::move
(a meno che non venga utilizzata la modalità 2), ma si noti che questo è solo un cast che in realtà non fa nulla (in particolare senza dereferenziazione), quindi ha costi zero associati.
Dovrebbe essere ipotizzabile che qualcosa generi un'eccezione tra l'inizio della chiamata di funzione e il punto in cui (o qualche chiamata contenuta) sposta effettivamente l'oggetto puntato in un'altra struttura di dati (e questa eccezione non è già rilevata all'interno della funzione stessa ), quindi quando si utilizza la modalità 1, l'oggetto a cui fa riferimento il puntatore intelligente verrà distrutto prima che una catch
clausola possa gestire l'eccezione (perché il parametro della funzione è stato distrutto durante lo svolgimento dello stack), ma non così quando si utilizza la modalità 3. Quest'ultimo fornisce il chiamante ha la possibilità di recuperare i dati dell'oggetto in tali casi (catturando l'eccezione). Si noti che la modalità 1 qui non causa una perdita di memoria , ma può portare a una perdita irreversibile di dati per il programma, che potrebbe anche essere indesiderabile.
Restituzione di un puntatore intelligente: sempre in base al valore
Per concludere una parola sulla restituzione di un puntatore intelligente, presumibilmente indicando un oggetto creato per essere utilizzato dal chiamante. Questo non è davvero un caso analogo con il passare puntatori in funzioni, ma per completezza vorrei insistere sul fatto che in questi casi tornare sempre per valore (e non utilizzare std::move
nella return
dichiarazione). Nessuno vuole ottenere un riferimento a un puntatore che probabilmente è stato appena annullato.