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 class C contiene un metodo 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 get al momento dell’utilizzo:

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 f() per riferimento?

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.

CasoRisultatoNote
1Non disponibilenon valido per definizione
2Funziona 
3Funziona 
4FunzionaTLE su c
5FunzionaTLE su s (which extends the lifetime of c)
6Non disponibilenon 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; }
};
CasoRisultatoNote
1FunzionaCopia aggiuntiva del risultato di get()
2FunzionaCopia aggiuntiva del risultato di get()
3FunzionaCopia aggiuntiva del risultato di get(), TLE su s
4FunzionaCopia aggiuntiva del risultato di get(), TLE su c
5FunzionaCopia aggiuntiva del risultato di get(), TLE su both s e c
6FunzionaCopia 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).

CasoRisultatoNote
1Non disponibile (Funziona)Non valido per definizione. Copia aggiuntiva del risultato di get()
2FunzionaCopia aggiuntiva del risultato di get()
3FunzionaCopia aggiuntiva del risultato di get(), TLE on s
4FunzionaCopia aggiuntiva del risultato di get(), TLE on c
5FunzionaCopia aggiuntiva del risultato di get(), TLE on both s and c
6Non 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…

CasoRisultatoNote
1Errore di compilazioneImpedisce di compilare a del codice che non creava problemi 🙁
2Funziona 
3Funziona 
4FunzionaTLE su c
5FunzionaTLE su c
6Errore 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:

CasoRisultatoNote
1Funzionamove aggiuntiva sul risultato di get()
2Funziona 
3Funziona 
4FunzionaTLE su c
5FunzionaTLE su c
6Funzionamove 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.

3 Comments


  1. Thank you, very interesting answer on SO!

    Reply

  2. Thank you, I didn’t know they added move semantics to QT5 already, my experience with QT is a bit outdated now.

    Reply

Leave a Reply

Your email address will not be published.