La move semantics e gli rvalue references

L

La move semantics e gli rvalue references

Il C++, se usato con giudizio, ha sempre prodotto programmi veloci. Sfortunatamente, prima del C++11, era presente una pecca che rallentava la maggior parte dei programmi in C++: la creazione di oggetti temporanei. A volte questi oggetti temporanei potevano essere ottimizzati dal compilatore (la Return Value Optimization, ad esempio). Ma troppo poco spesso si ricadeva in questi casi, avendo come risultato una costosa copia di oggetti.

Prendiamo come esempio il seguente listato:

#include <iostream>
 
using namespace std;
 
vector<int> doubleValues (const vector<int>& v)
{
    vector<int> new_values;
    new_values.reserve(v.size());
    for (auto itr = v.begin(), end_itr = v.end(); itr != end_itr; ++itr )
    {
        new_values.push_back( 2 * *itr );
    }
    return new_values;
}
 
int main()
{
    vector<int> v;
    for ( int i = 0; i < 100; i++ )
    {
        v.push_back( i );
    }
    v = doubleValues( v );
}

Vediamo perché questo è codice C++03 decisamente non efficiente. Il problema risiede ovviamente nelle copie. Quando doubleValues viene chiamata costruisce un vettore, new_values, e lo riempie. Potrebbe non essere la soluzione ideale, ma se vogliamo mantenere il nostro vettore originale invariato, abbiamo bisogno di una seconda copia. Ma cosa succede quando restituiamo questo vettore?

L’intero contenuto di new_values deve essere copiato! In teoria potrebbero avvenire due copie in questo caso: una nell’oggetto temporaneo da essere restituito, la seconda quando l’operatore di assegnamento del vettore agisce nella linea v = doubleValues(v);. La prima copia potrebbe essere automaticamente ottimizzata dal compilatore, ma non c’è modo di evitare che l’assegnamento a v consista in una nuova copia di tutti i valori, la quale richiede una nuova allocazione di memoria e un’altra iterazione su tutto il vettore.

Questo esempio potrebbe essere un po’ forzato. Ovviamente si può trovare il modo di evitare questo tipo di problema, ad esempio allocando dinamicamente il nuovo vettore e restituendolo tramite un puntatore. Concentriamoci però sulle problematiche derivanti da queste operazioni di copia.

La parte peggiore di tutta questa storia è che l’oggetto restituito da doubleValues ​​è semplicemente temporaneo: il risultato di doubleValues​​(v) viene gettato via dopo essere stato copiato. Sarebbe ottimo se fosse possibile saltare l’operazione di copia e “rubare” solamente il puntatore contenuto nel vettore temporaneo memorizzandolo in v. In C++03 purtroppo non c’era modo di determinare se un oggetto fosse temporaneo o meno, quindi il “furto” non era possibile. In C ++11 invece è possibile!

Ecco a cosa servono i riferimenti rvalue e la semantica di spostamento! La move semantics consente di evitare copie non necessarie quando si lavora con oggetti temporanei, le cui risorse possono quindi essere prelevate in modo sicuro.

La move semantics fa affidamento su una nuova funzionalità di C ++ 11, ovvero gli rvalue references, che sono da comprendere per apprezzare davvero cosa succede dietro le quinte. Innanzitutto vediamo cosa è un rvalue, e poi cosa è un riferimento a un rvalue. Infine, torneremo parlare della semantica di spostamento e come può essere implementata con riferimenti rvalue.

 

Rvalues e lvalues: rivali o migliori amici?

In C++ ci sono gli lvalue e gli rvalue. Un lvalue è un oggetto che ha una precisa locazione in memoria, un espressione della quale può essere preso l’indirizzo. Agli lvalue può venire assegnato un valore. Ad esempio:

int a;
a = 1; // qui a è un lvalue

Dato che un riferimento lvalue restituito da una funzione è esso stesso un lvalue, anche il seguente codice è perfettamente valido:

int x;

int& getRef ()
{
        return x;
}

int main() 
{
        getRef() = 4;
}

La funzione getRef() restituisce un riferimento a una variabile globale, la quale è memorizzata in una locazione permanente. È infatti possibile scrivere &getRef() per ottenere l’indirizzo di x.

Gli rvalue sono invece valori temporanei. Un’espressione viene valutata come rvalue se il suo risultato è un oggetto temporaneo. Ad esempio:

int x;

int getVal ()
{
    return x;
}

int main()
{
    getVal();
}

In questo caso getVal() è un rvalue perché il valore restituito non è un riferimento a x, ma semplicemente una sua copia temporanea.
Tutto ciò prende più significato quando utilizziamo degli oggetti:

std::string getName ()
{
     return "Alex";
}

int main() 
{
     getName();
}

Qua getName() restituisce una stringa che è costruita dentro la funzione. È possibile assegnare il valore restituito a una variabile:

std::string name = getName();

Però stiamo assegnando partendo da un oggetto temporaneo, non da un valore che ha una precisa locazione. Quindi getName() è un rvalue.

 

Rilevamento di oggetti temporanei tramite i riferimenti a rvalue

La cosa importante è che un rvalue è sinonimo di oggetto temporaneo, proprio come il valore restituito da doubleValue() nell’esempio mostrato all’inizio dell’articolo. Non sarebbe grandioso se potessimo riconoscere, senza ombra di dubbio, che un determinato valore restituito da una funzione è temporaneo, e in qualche modo scrivere del codice che è su misura per gestire questo caso? Si, sicuramente sarebbe ottimo. Ecco il motivo dei riferimenti a rvalue: sono riferimenti che vengono collegati solo ed esclusivamente ad oggetti temporanei.

Procediamo per gradi.

Prima del C++11 era possible collegare un oggetto temporaneo solo a un lvalue reference, a patto che esso fosse dichiarato costante.

const string& name = getName(); // ok
string& name = getName(); // non ok

Un lvalue reference costante è in grado di estendere la vita dell’oggetto temporaneo, ma l’impossibilità nel modificarlo garantisce una sicurezza: rimarrebbe pericoloso memorizzare in esso una qualche informazione, visto che è destinato a scomparire.

Dal C++11 è presente un nuovo tipo di riferimento, il riferimento a rvalue, il quale permette di collegare un riferimento non costante a un oggetto temporaneo, ma non a uno non temporaneo. In altre parole, gli rvalue references sono perfetti per rilevare la natura di un oggetto, ovvero determinare se esso è temporaneo o meno. Gli rvalue references vengono dichiarati tramite doppia e commerciale (&&), invece che una singola &, e possono essere costanti o non costanti, anche se raramente troveremo codice che utilizza un rvalue reference costante:

string&& name = getName(); // ok
const string&& name = getName(); // ok

In che modo può esserci di aiuto tutto ciò? Vediamo cosa succede quando scriviamo una funzione che prende un lvalue o un rvalue come argomento:

printReference (const String& str)
{
        cout << str;
}

printReference (String&& str)
{
        cout << str;
}

A questo punto il comportamento diventa interessante: la funzione printReference() che accetta un riferimento a un lvalue costante accetta qualsiasi argomento, sia esso un lvalue o un rvalue. Tuttavia, in presenza della seconda funzione in overload, quando printReference() verrà invocata sarà in grado di distinguere tra lvalue ed rvalue. In altre parole, scrivendo:

string me("alex");

// chiama la prima printReference(), poiché ha come argomento un lvalue
printReference(me);

// chiama la seconda printReference, poiché ha come argomento un valore temporaneo
printReference(getName());

Ora abbiamo un modo per determinare se una variabile reference si riferisce ad un oggetto temporaneo o ad un oggetto permanente. La versione della funzione con un rvalue reference è come un’entrata segreta in un club al quale si può aderire solo se si è un oggetto temporaneo. Adesso che siamo in grado di fare questa distinzione, in che modo possiamo utilizzarla?

 

Il costruttore di spostamento e l’operatore di assegnazione di spostamento

Utilizzando gli rvalue references capita spesso di dover creare un costruttore di spostamento e un operatore di assegnazione di spostamento, il quale segue gli stessi principi. Un costruttore di spostamento, come un costruttore di copia, ha come argomento l’istanza di un oggetto e ne crea una nuova basandosi sull’oggetto originale. Ad ogni modo il costruttore di spostamento ci permette di evitare la riallocazione di memoria poiché sappiamo con certezza che l’oggetto passatogli è temporaneo, perciò anziché copiare i vari campi dell’oggetto ci limiteremo a spostarli.

Che significa spostare un campo di un oggetto? Se il campo è di tipo primitivo, come un intero, ci limitiamo a copiarlo. Le cose si fanno più interessanti se il campo è un puntatore: in questo caso, anziché allocare e inizializzare una nuova porzione di memoria, possiamo semplicemente “rubare”, ovvero copiare, il valore contenuto in esso. Dopodiché sarà necessario impostare nullo il puntatore nell’oggetto temporaneo. Sappiamo che l’oggetto temporaneo non ci sarà più utile, perciò possiamo prendere tranquillamente i valori di tutti i suoi puntatori.

Prendiamo come esempio la classe ArrayWrapper:

class ArrayWrapper
{
public:

  ArrayWrapper(int n)
    : _p_vals(new int[ n ])
    , _size(n)
  {}

  // copy constructor
  ArrayWrapper(const ArrayWrapper& other)
    : _p_vals(new int[ other._size  ])
    , _size(other._size)
  {
    for(int i = 0; i < _size; ++i)
    {
      _p_vals[ i ] = other._p_vals[ i ];
    }
  }

  ~ArrayWrapper()
  {
    delete [] _p_vals;
  }

private:

  int *_p_vals;
  int _size;
};

Notiamo che il costruttore di copia deve sia allocare nuova memoria che copiare ogni singolo valore dall’array, uno alla volta. È una mole di lavoro non indifferente per una copia. Aggiungiamo un costruttore di spostamento e vediamo come si ottene un notevole incremento di efficienza:

class ArrayWrapper
{
public:

  // il costruttore di default crea un array dale dimensioni moderate
  ArrayWrapper()
    : _p_vals(new int[ 64 ])
    , _size(64)
  {}

  ArrayWrapper(int n)
    : _p_vals(new int[ n ])
    , _size(n)
  {}

  // copy constructor
  ArrayWrapper(const ArrayWrapper& other)
    : _p_vals(new int[ other._size  ])
    , _size(other._size)
  {
    for(int i = 0; i < _size; ++i)
    {
      _p_vals[ i ] = other._p_vals[ i ];
    }
  }
  
  // move constructor
  ArrayWrapper(ArrayWrapper&& other)
    : _p_vals(other._p_vals)
    , _size(other._size)
  {
    other._p_vals = nullptr;
    other._size = 0;
  }

  ~ArrayWrapper()
  {
    delete [] _p_vals;
  }

private:

  int *_p_vals;
  int _size;
};

A quanto pare il move constructor è addirittura più semplice! Le cose principali da notare sono due:

  1. Il parametro non è un rvalue reference costante
  2. other._p_vals è impostato a nullptr

La seconda osservazione spiega la prima: non potremmo modificare il valore contenuto in other._p_vals se prendessimo un rvalue reference costante come parametro. Ma per quale motivo è necessario impostare other._p_vals nullo? La ragione risiede nel distruttore. Quando un oggetto temporaneo esce dallo scope, come qualsiasi altro oggetto statico, ne verrà chiamato il distruttore. Tra le operazioni del distruttore della classe ArrayWrapper è presente la free su other._p_vals, lo stesso other._p_vals che è appena stato copiato! Se non lo impostassimo come nullo, lo spostamento appena effettuato si tradurrebbe in una copia che introdurrebbe un crash sicuro quando utilizzeremmo la memoria liberata. Inoltre l’oggetto che è appena stato costruito, anche se per qualche ragione non dovesse usare questa memoria, al termine della sua esistenza chiamerebbe la free su una zona di memoria precedentemente liberata, con conseguenze disastrose. Quindi il costruttore di spostamento evita l’operazione di copia a patto di modificare l’oggetto temporaneo.

Le regole di overload delle funzioni impongono che il costruttore di spostamento sia chiamato solo per un oggetto temporaneo che possa essere modificato. Ciò significa che, ad esempio, una funzione che restituisce un oggetto const farà sì che il costruttore di copia venga eseguito al posto del costruttore di spostamento. Evitiamo quindi di scrivere codice come questo:

const ArrayWrapper getArrayWrapper (); // il move constructor è inutile perché l’oggetto temporaneo è const

C’è ancora una situazione della quale non abbiamo discusso, ovvero quando un campo del nostro oggetto è un oggetto a sua volta. Vediamo il seguente esempio, dove creiamo una classe MetaData per sostituire il precedente campo size:

class MetaData
{
public:
  MetaData(int size, const std::string& name)
    : _name(name)
    , _size(size)
  {}

  // copy constructor
  MetaData(const MetaData& other)
    : _name(other._name)
    , _size(other._size)
  {}

  // move constructor
  MetaData(MetaData&& other)
    : _name(other._name)
    , _size(other._size)
  {}

  std::string getName() const
  {
    return _name;
  }
  int getSize() const
  {
    return _size;
  }

private:
  std::string _name;
  int _size;
};

Modifichiamo di conseguenza la classe ArrayWrapper:

class ArrayWrapper
{

public:
    ArrayWrapper ()
        : _p_vals( new int[ 64 ] )
        , _metadata( 64, "ArrayWrapper" )
    {}
 
    ArrayWrapper (int n)
        : _p_vals( new int[ n ] )
        , _metadata( n, "ArrayWrapper" )
    {}
 
    // move constructor
    ArrayWrapper (ArrayWrapper&& other)
        : _p_vals( other._p_vals  )
        , _metadata( other._metadata )
    {
        other._p_vals = NULL;
    }
 
    // copy constructor
    ArrayWrapper (const ArrayWrapper& other)
        : _p_vals( new int[ other._metadata.getSize() ] )
        , _metadata( other._metadata )
    {
        for ( int i = 0; i < _metadata.getSize(); ++i )
        {
            _p_vals[ i ] = other._p_vals[ i ];
        }
    }
    ~ArrayWrapper ()
    {
        delete [] _p_vals;
    }

private:
    int *_p_vals;
    MetaData _metadata;
};

Potrebbe essere naturale pensare che il costruttore di movimento della classe ArrayWrapper chiami in automatico il costruttore di movimento della classe MetaData. Questo però non avviene. Per quale motivo? Nel move constructor della classe ArrayWrapper, l’oggetto other non è un valore temporaneo. In altre parole, ogni rvalue reference è un lvalue quando ha un nome, al quale corrisponde una precisa locazione in memoria. Essendo other un lvalue, other._metadata è un lvalue, quindi l’istruzione _metadata( other._metadata ) chiama il copy constructor della classe MetaData anziché il move constructor.

La logica dietro questo comportamento può apparire confusionaria all’inizio. Proviamo a spiegare meglio cosa succede dietro le quinte. Un rvalue è un’espressione che crea un oggetto il quale è prossimo a sparire. Immediatamente passiamo questo oggetto temporaneo al move constructor, il quale da ad esso una nuova vita all’interno del suo scope. Nel contesto dove l’espressione rvalue è stata valutata, l’oggetto temporaneo ha terminato la sua esistenza. Ma nel move constructor esso ha un nome, perciò la sua vita viene estesa per l’intera durata della funzione costruttore. In altre parole, possiamo usare questo oggetto più volte all’interno di essa; l’oggetto temporaneo ha una locazione precisa che persiste per l’intera durata della funzione. È un lvalue perché l’oggetto ha un preciso indirizzo. Dato che è possibile riutilizzarlo dentro la funzione, se il costruttore di spostamento venisse chiamato in automatico per uno dei suoi campi interni ci potremmo ritrovare ad utilizzare erroneamente un oggetto già spostato.

// move constructor
ArrayWrapper (ArrayWrapper&& other)
    : _p_vals( other._p_vals  )
    , _metadata( other._metadata )
{
    // se _metadata( other._metadata ) chiamasse il move constructor, usare
    // other._metadata d’ora in poi sarebbe estremamente pericoloso
    other._p_vals = NULL;
}

 

STD::MOVE

In quale modo potremmo gestire questo caso? Avremmo bisogno di poter trasformare un lvalue in un rvalue, in modo che il move constructor venga chiamato. La funzione std::move esegue proprio questa operazione, non spostando nulla in realtà, ma semplicemente eseguendo un cast. Nello specifico essa restituisce un rvalue reference collegato al nostro lvalue da trasformare. Essendo senza nome, questo rvalue reference è temporaneo e “attiva” il move constructor. Dato che il C++ non permette la creazione di riferimenti a riferimenti, il riferimento a rvalue del move costructor verrà collegato direttamente al nostro lvalue.

#include <utility> // per la std::move

// move constructor
ArrayWrapper(ArrayWrapper&& other)
  : _p_vals(other._p_vals)
  , _metadata(std::move(other._metadata))
{
  other._p_vals = NULL;
}

 

Move assignment operator

La creazione di questo operatore non differisce molto da quella del move constructor. È doveroso tenere presente che l’operatore di assegnazione di spostamento agisce su un oggetto già costrutito, perciò per evitare di causare uno o più memory leak è necessario liberare la memoria precedentemente allocata dal medesimo:

ArrayWrapper& operator=(ArrayWrapper && other)
{
  // non eseguiamo alcuna operazione se se si tenta di assegnare l'oggetto a se stesso
  if(this != &other) 
  { 
    
    // liberiamo la memoria occupata dall’oggetto corrente
    delete [] _p_vals; 

    // preleviamo le risorse
    _p_vals = other._p_vals;
    _metadata = std::move(other._metadata);

    // impostiamo l’oggetto ricevuto come parametro in uno stato innocuo
    other._p_vals = NULL;

  }
  return *this;
}

Per completezza creiamo anche il move assignment operator della classe MetaData:

MetaData& operator=(MetaData && other)  
{
  // non eseguiamo alcuna operazione se se si tenta di assegnare l'oggetto a se stesso
    if (this != &other) 
    {	        
    // preleviamo le risorse
    _name = std::move(other._name);
    _size = other._size;
     } 
     return *this;
}

 

Generazione automatica dei costruttori

Come sicuramente saprai, in C++ dichiarando un qualsiasi costruttore si impedisce al compilatore di creare il costruttore di default. Perciò anche la dichiarazione del costruttore di spostamento inibirà la creazione automatica del costruttore di default. Ad ogni modo, dichiarare un costruttore di spostamento non inibisce la creazione automatica di un costruttore di copia, e dichiarare un move assignment operator non inibisce la creazione automatica di un copy assignment operator.
È quindi buona abitudine dotare le classi sia del copy constructor e del copy assignment operator sia del move constructor e del move assignment operator.

 

Funzioni che restituiscono un rvalue reference

Può succedere di dover creare una funzione di questo tipo? Che cosa significa restituire un rvalue reference? Non sono già le funzioni che restituiscono un oggetto per valore degli rvalues?

Chiariamo subito che restituire un riferimento rvalue è diverso dal restituire un oggetto per valore. Vediamo il seguente esempio:

int x;

int getInt()
{
  return x;
}

int && getRvalueInt()
{
  // notare che è possibile chiamare la std::move
  // anche sui tipi nativi poiché essa consiste in un cast
  return std::move(x);
}

Chiaramente nella prima funzione, nonostante getInt() sia un rvalue, una copia della variabile x viene creata. Possiamo notarlo utilizzando questa semplice funzione, la quale stampa a video l’indirizzo in memoria della variabile datale come parametro:

// ricordiamo che un riferimento lvalue costante
// può essere collegato anche agli rvalue
{
void printAddress (const int& v) 
    cout << reinterpret_cast<const void*>(&v) << endl;
}

Eseguendo le seguenti istruzioni otterremo due valori differenti:

printAddress( getInt() ); //getInt() restituisce una copia di x
printAddress( x );

Invece eseguendo le seguenti istruzioni otterremo il medesimo valore:

// getRvalueInt() restituisce un riferimento alla variabile x originale
printAddress( getRvalueInt() );
printAddress( x );

Perciò vi è una differenza non indifferente tra restituire un rvalue e restituire un rvalue reference, tenendo presente che restituire un rvalue reference a un oggetto creato direttamente nella funzione (a differenza del nostro esempio dove x è una variabile globale) è pericoloso, perché tale oggetto cessa di esistere al termine della funzione stessa e il riferimento restituito non è più valido. Perciò restituire un rvalue reference ha senso nel raro caso in cui si ha una funzione che deve restituire il risultato della chiamata alla std::move su un campo della classe che contiene la funzione stessa.

 

La move semantics e la standard library

La STL è stata ovviamente aggiornata; come abbiamo fatto per le stringhe è possibile utilizzare la move semantics con qualsiasi oggetto che essa ci fornisce. Inoltre se si abilita la move semantic nelle classi che creiamo noi stessi, quando memorizziamo tali oggetti in uno qualsiasi dei contenitori messi a disposizione nella STL, in automatico essa utilizzerà la std::move per eliminare la creazione di copie non efficienti.

 

BIBLIOGRAFIA

Articolo orginale: Move semantics and rvalue references in C++11 by Alex Allain.

A proposito di me

Andrea Simone Costa

Classe 1997, Toscano DOCP, Asimov oriented.
Si è diplomato come perito elettronico, ma ha ben presto tradito le origini per immergersi nell'universo JavaScript. Ama condividere ciò che ha imparato in modo semplice e pragmatico, senza mai ricadere nel banale, coltivando segretamente il sogno di insegnare.

Gli articoli più letti

Articoli recenti

Commenti recenti