Questo articolo è la traduzione di un mio post del 2016 intitolato Optimizing return values.
Il problema
Abbiamo una vecchia classe, scritta in un dialetto C++ pre-C++11:
class C { string _s; public: C(const string &s) : _s(s) {} const string &get() const { return _s; } };
La
contiene un metodo class C
get()
che ritorna un riferimento ad uno stato interno. Nel nostro codice dobbiamo fare attenzione a non usare questo riferimento dopo che la nostra classe sia stata distrutta. Questo era un evento improbabile prima di C++11 dal momento che non era comune ritornare oggetti complessi per valore (come un’istanza della nostra classe C
).
In C++11, grazie alla move semantics, questa classe riceve un aggiornamento automatico, i move constructor, quindi nel nostro codice diventerà efficiente scrivere qualcosa del tipo:
C f() { return C("test"); }
In quanti modi diversi possiamo catturare il valore di ritorno di questa funzione?
1. Utilizzo diretto
Possiamo utilizzare il valore di ritorno immediatamente:
cout << f().get();
Questo non crea problemi: il valore ritornato da f()
e un’istanza temporanea di C
, che viene mantenuta in vita fino alla fine del “full statement”, ovvero fino al punto e virgola.
2. Salvare l’oggetto in una variabile locale
Creiamo l’oggetto, lo salviamo in una nuova variabile, ed eseguiamo il metodo
al momento dell’utilizzo:get
C c = f(); cout << c.get();
Anche questo non crea problemi: la variabile c
ha lo stesso ruolo della variabile temporanea nell’esempio precedente, ma ora rimane in vita per l’intero scope.
3. Salvare l’oggetto in una variabile locale, e catturare il risultato per riferimento
In aggiunta al caso precedente, possiamo salvare il risultato di get in un riferimento:
C c = f(); const string &s = c.get(); cout << s;
Il risultato è identico al precedente, ma la visibilità della variabile s
è estesa all’intero scope. Ancora una volta, questo non crea problemi, perché la variabile c
è attiva nello stesso scope.
4. Salvare un riferimento all’oggetto
Cosa accadrebbe se catturassimo l’oggetto ritornato da
per riferimento?f()
const C &c = f(); cout << c.get();
Funzionerebbe?
Certo! È una feature di C++ nota come “estensione della vita dei temporanei” (temporary lifetime extension, o in breve TLE): la vita della variabile temporanea ritornata da f
è fatta combaciare con la vita del riferimento in cui viene salvata c
. (lo scope corrente). In pratica questo caso è equivalente al numero 2.
5. Salvare un riferimento all’oggetto, e catturare il risultato per riferimento
const C &c = f(); const string &s = c.get(); cout << s;
Questo è equivalente al caso 3, con l’aggiunta della TLE sulla variabile c come nel caso 4.
6. Creare al volo, chiamare il metodo, e catturare il risultato per riferimento
const string &s = f().get(); cout << s;
Eeeee, questo è un riferimento ad un oggetto non più esistente (dangling reference).
Perché?
Se non ci sono reference, la vita dei temporanei non viene estesa. Come nel primo caso, la vita del temporaneo ritornato da f()
è lo statement corrente (fino al punto e virgola), ma il riferimento ritornato da get
è salvato in s
, quindi rimarrà accessibile anche oltre. Il suo utilizzo nella linea successiva è quindi un “comportamento non definito” (Undefined Behavior, o UB).
Questo è terribile: dobbiamo assolutamente spiegare ai nostri utenti del nostro oggetto devono evitare questo pattern, o impedir loro di farlo.
Soluzioni possibili
It’s pretty clear that the problem is that we’re using a reference for return, isn’t it? Let’s see some possible solutions.
Salvare il valore di ritorno in una variabile
L’idea è documentare la libreria, chiedendo cortesemente ai nostri utenti di non usare la funzione f
in un espressione, ma di salvare sempre il risultato in una variabile per copia o per riferimento.
Il codice rimane com’è, ma il caso 6 diventa “non valido per definizione”. Purtroppo anche il caso (1) risulterà invalidato allo stesso modo.
Caso | Risultato | Note |
1 | Non disponibile | non valido per definizione |
2 | Funziona | |
3 | Funziona | |
4 | Funziona | TLE su c |
5 | Funziona | TLE su s (which extends the lifetime of c ) |
6 | Non disponibile | non valido per definizione |
Funziona, ma chi legge i manuali dopotutto? La probabilità che questa non-soluzione fallisca sono piuttosto alte.
Ritornare sempre per valore (ovvero “il goldone”)
Sembra la via più sicura: paghiamo una “piccola” penalità (copia), ma ritornando sempre per valore i problemi spariscono, giusto?
class C { string _s; public: C(const string &s) : _s(s) {} string get() const { return _s; } };
Caso | Risultato | Note |
1 | Funziona | Copia aggiuntiva del risultato di get() |
2 | Funziona | Copia aggiuntiva del risultato di get() |
3 | Funziona | Copia aggiuntiva del risultato di get() , TLE su s |
4 | Funziona | Copia aggiuntiva del risultato di get() , TLE su c |
5 | Funziona | Copia aggiuntiva del risultato di get() , TLE su both s e c |
6 | Funziona | Copia del risultato di get() , TLE su s |
It solves 6, ma aggiunge una copia dappertutto (e il suo costo non è poi così piccolo).
Ritornare sempre per valore e salvare il valore di ritorno in una variabile (ovvero “il doppio goldone”)
Unisce il peggio dei due casi precedenti, il codice è come qui sopra (copie dappertutto), ma invalidiamo i casi 6 (e 1) per definizione. È la versione “fault-tolerant” del caso precedente (nel caso l’utente non legga i manuali).
Caso | Risultato | Note |
1 | Non disponibile (Funziona) | Non valido per definizione. Copia aggiuntiva del risultato di get() |
2 | Funziona | Copia aggiuntiva del risultato di get() |
3 | Funziona | Copia aggiuntiva del risultato di get() , TLE on s |
4 | Funziona | Copia aggiuntiva del risultato di get() , TLE on c |
5 | Funziona | Copia aggiuntiva del risultato di get() , TLE on both s and c |
6 | Non disponibile (Funziona) | Non valido per definizione. Se invocato, copia del risultato di get() , TLE su s |
In pratica l’unico scopo di questa opzione è massimizzare il numero di copie dei casi normali, e rendere del codice perfettamente funzionante “non valido per definizione”.
Rimozione selettiva dei casi indesiderati
Il problema è il mantenimento di riferimenti a valori temporanei. Possiamo impedire che questo accada a tempo di compilazione, e quindi smettere di richiedere all’utente di leggere il manuale? Certo!
class C { string _s; public: C(const string &s) : _s(s) {} const string &get() const & { return _s; } const string &get() && = delete; };
In questa soluzione abbiamo differenziato i due usi della classe C
, impedendo l’invocazione di get()
quando la classe è un r-value (un temporaneo).
Elegante, ma…
Caso | Risultato | Note |
1 | Errore di compilazione | Impedisce di compilare a del codice che non creava problemi 🙁 |
2 | Funziona | |
3 | Funziona | |
4 | Funziona | TLE su c |
5 | Funziona | TLE su c |
6 | Errore di compilazione | 🙂
|
La compilazione del caso 6 viene bloccata. Sfortunatamente, anche il caso 1 non è più valido (e potremmo rompere del codice valido).
Usare std::move per ritornare degli utilizzi temporanei
Questa è la soluzione più elegante che ho trovato: fale un overload per gli r-value del metodo get() e fargli muovere nel ritorno il contenuto dell’oggetto temporaneo.
class C { string _s; public: C(const std::string &s) : _s(s) {} const string &get() const & { return _s; } string get() && { return move(_s); } };
Non rischia di rompere il codice esistente, ma modifica leggermente il suo comportamento:
Caso | Risultato | Note |
1 | Funziona | move aggiuntiva sul risultato di get() |
2 | Funziona | |
3 | Funziona | |
4 | Funziona | TLE su c |
5 | Funziona | TLE su c |
6 | Funziona | move sul risultato di get() , TLE su s |
In questo caso, paghiamo una move
nel caso 1 (che si spera venga elisa, e comunque in generale costa molto meno di una copia), ma è un piccolo prezzo rispetto al vantaggio di far funzionare il caso 6.
#
Thank you, very interesting answer on SO!
#
Thank you, I didn’t know they added move semantics to QT5 already, my experience with QT is a bit outdated now.
#