Chi mi conosce sa che ultimamente ho programmato molto in C++. Di conseguenza, non potevo non interessarmi alle novità che verranno introdotte nel linguaggio dalla nuova versione dello standard internazionale ANSI/ISO, che verrà ratificato a breve, ormai ribattezzato da tutti C++0x nonostante si parli di un rilascio nel 2011.

Nonostante lo standard internazionale attuale risalga al 1998 (con qualche correzione nel 2003), il linguaggio ha visto un notevole sviluppo negli ultimi anni, grazie principalmente all’evoluzione dei vari compilatori presenti sul mercato, che oltre ad essere ormai completi e per la maggior parte aderenti allo standard, sono anche più performanti di quanto non si potesse sognare qualche anno fa (vedi ad esempio Clang). Il nuovo standard poggia su queste fondamenta e cerca di “modernizzare” il linguaggio, aggiungendo alcune feature veramente interessanti che, pur essendo a prima vista delle modifiche minori, rendono il C++ un linguaggio in alcuni sensi molto diverso.

La buona notizia è che, a differenza dello standard del 1998, i vari produttori di compilatori sembrano intenzionati a fare le cose per bene fin da subito. Il compilatore g++ di GNU, che è praticamente lo standard de facto su tutte le piattaforme diverse da Microsoft Windows, implementa già buona parte delle nuove caratteristiche del linguaggio, mentre Visual C++ 10 di Microsoft ne ha introdotto alcune tra le più interessanti.

In questo post cercherò di illustrare la novità più importante dal punto di vista delle performance del C++, ovvero l’introduzione dei cosiddetti r-value references.

Il problema

Il comitato di standardizzazione, nello scegliere le funzionalità da aggiungere al linguaggio, ha pensato a come risolvere i principali problemi incontrati dagli utenti del linguaggio attuale. Gli r-value references risolvono un problema molto sentito nella classica programmazione C++: le copie ridondanti di oggetti.

Il C++ è l’unico linguaggio object-oriented attualmente utilizzato in cui gli oggetti hanno una copy semantics. Gli oggetti automatici (quelli allocati sullo stack per capirci), quando vengono passati come parametri o restituiti da una funzione vengono copiati. Il chiaro vantaggio è la coerenza del comportamento degli oggetti rispetto ai tipi base.

Tutti i programmatori C++ sanno come gestire questa caratteristica in modo da evitare prestazioni penose: si passano gli argomenti per riferimento, o si usano soluzioni più sofisticate come l’implicit-sharing con copy-on-write (vedi i container di Qt). Per quanto ci si possa impegnare, però, rimarrà sempre un overhead dovuto a copie superflue.

Vediamo ad esempio la classica implementazione di una funzione swap:

template <typename T>
void swap(T &a, T &b)
{
   T tmp = a;
   a = b;
   b = tmp;
}

Passare i parametri per riferimento in questo caso non evita la copia dell’oggetto a nell’oggetto temporaneo tmp. Allo stesso modo, dopo la seconda istruzione ci saranno due copie dell’oggetto b. Queste copie sono inutili perchè noi non volevamo copiare niente, ma spostare il contenuto di un’oggetto nell’altro e viceversa. Con gli r-value references e una piccola funzione ausiliaria della libreria standard, la funzione swap in C++0x può essere scritta come segue:

template <typename T>
void swap(T &a, T &b)
{
   T tmp = std::move(a);
   a = std::move(b);
   b = std::move(tmp);
}

La funzione std::move() abilita la move semantics del proprio argomento, consentendoci di spostare, invece che copiare, il contenuto di a in tmp, spostare b in a, e poi spostare nuovamente tmp in b, senza eseguire alcuna copia inutile.

Il trucco non sta nel corpo della funzione std::move(), che come vedremo è una funzione veramente semplicissima. Il trucco sta nell’introduzione di un nuovo tipo di reference, detto r-value reference, che nelle prossime sezioni cercherò di spiegare in modo relativamente comprensibile.

r-values e l-values

Prima di vedere in dettaglio il funzionamento di questa nuova caratteristica è bene fare un po’ di chiarezza su un concetto chiave della grammatica del C++: r-values vs l-values. Ogni espressione, quando viene valutata produce un valore. Ogni valore, in C++ come in tutti gli altri linguaggi imperativi, può essere un l-value, o un r-value (o entrambi).

Un l-value (che sta per left-value) è un valore che può stare a sinistra dell’operatore di assegnamento. Un r-value (che sta per right-value) è un valore che può stare solo a destra. È chiaro che un l-value è anche un r-value. In pratica tutto ciò che ha una precisa posizione in memoria ed è identificabile con un nome è un l-value. Ad esempio, i valori delle seguenti espressioni sono l-value:

n     // valore di una variabile
*p    // valore puntato da un puntatore
v[0]  // valore di un elemento di un'array

Mentre i seguenti sono r-value:

4          // una costante
func()     // il valore di ritorno di un metodo (se non ritorna un reference)
func() + 4 // un'espressione qualunque (se non ricade tra gli l-value)

È facile capire come mai non possiamo assegnare un valore ad una costante o all’oggetto temporaneo restituito da una funzione. La differenza tra l-values e r-values però non si nota solo in presenza di assegnamenti.
Prendiamo una funzione qualsiasi:

void func(int a, int &b, int const& c)
{
// ...
}

Cosa possiamo passare come parametri di questa funzione? Il primo parametro viene passato per valore e quindi copiato. Possiamo ovviamente passare di tutto ad un parametro passato per valore, sia r-value che l-value, senza problemi.

Il secondo parametro è invece passato per riferimento, e potrebbe quindi essere modificato dal corpo della funzione. Per questo motivo, ai parametri by reference è possibile passare solo degli l-value, e quindi non è possibile passare, ad esempio, un’espressione come 4 + 5 ad un parametro di questo tipo. In altre parole, i reference si associano (bind in inglese) solo ad l-values. È bene notare a questo punto che tra gli r-values ricadono anche tutti gli oggetti temporanei che risultano dalla valutazione delle espressioni. Vietare di modificare un oggetto temporaneo è un buon modo di evitare una serie spiacevole di subdoli bug.

Il terzo parametro è passato per riferimento costante. Cosa si può passare ad un parametro di questo tipo? Di nuovo, la risposta è “qualsiasi cosa”. In questo caso infatti, non c’è nessun pericolo a passare un r-value, perchè il parametro non verrà comunque modificato, e possiamo quindi passare un valore temporaneo senza problemi.

Abbiamo visto quindi che i classici reference si associano solo ad l-value, mentre i const-reference si associano sia ad l-value che r-value. Gli r-value reference, introdotti in C++0x, sono un tipo di reference non costante che può associarsi solo ad un r-value. Dove un classico reference si indicava con il simbolo &, gli r-value reference si indicano con &&. In altre parole:

int   a     = 0;
int&  lref  = a;   // Ok. l-value reference associato ad un l-value.
int&& rref  = a;   // Errore! r-value reference associato ad un l-value.
int&  lref2 = 5;   // Errore! l-value reference associato ad un r-value.
int&& rref2 = 5;   // Ok. r-value reference associato ad un r-value.

La domanda è: perchè vorremmo poter fare tutto ciò? Non abbiamo appena detto che modificare oggetti temporanei può essere pericoloso? In effetti, l’oggetto temporaneo creato nell’ultima riga viene distrutto subito, e quindi usando il reference rref2 si incorre sicuramente in un crash.
In realtà, combinando l-value ed r-value reference, si può implementare la move semantics di cui parlavamo sopra, permettendo di evitare moltissime copie semanticamente inutili.

Move semantics

Il trucco sta in come vengono risolte le chiamate in presenza di overloading di metodi. Prendiamo le seguenti due funzioni:

void func(int &i) { ... }
void func(int &&i) { ... }

Questo overloading è perfettamente valido e viene risolto con la seguente regola. Se alla funzione viene passato un l-value, viene chiamata la versione che prende il parametro con l-value reference. Se viene passato un r-value, la prima forma non può essere utilizzata e quindi si usa quella che prende il parametro con r-value reference, quindi:

int a = 0;
func(a);   // chiama func(int &)
func(5);   // chiama func(int &&)

Detto ciò entriamo finalmente nel vivo del discorso. Consideriamo una classe string, dichiarata in linea di massima così:

class string
{
   char *data;
public:
   string();
   string(char *data);

   string(string const& s) // copy constructor
   {
      copy(data, s.data);
   }

   string(string &&s) // move constructor
   {
      data = s.data;
      s.data = 0;
   }

   string operator=(string const& s); // copy assignment
   string operator=(string && s); // move assignment
};

Fino al costruttore di copie non c’è nulla di nuovo. La novità arriva nel costruttore che ho commentato come “move constructor”. Questo costruttore viene chiamato quando l’argomento è un r-value, e come si può vedere dal codice l’effetto è di spostare i dati dal parametro, lasciandolo essenzialmente vuoto, evitando il costo dell’operazione di copia. Questo si può fare senza problemi perchè sappiamo che l’argomento è un oggetto temporaneo e quindi nessuno andrà più ad utilizzarlo. Lo stesso discorso vale per gli operatori di “copy assignment”, che è il classico operatore di assegnamento, e di “move assignment”, che invece sposta i dati dal parametro senza il costo di una copia.

Ora abbiamo quindi la possibilità di capire il funzionamento e l’utilità della funzione std::move() che accennavo prima. La sua definizione è (a meno di dettagli) la seguente:

template <typename T>
T&& move(T&& a)
{
    return a;
}

In pratica std::move() non fa altro che prendere un parametro di qualsiasi tipo e ritornarlo come r-value, senza causare nessuna copia (al contrario di un ritorno per valore che avrebbe sempre prodotto un r-value ma causando una copia).
Nella versione di swap() definita prima quindi, è ora chiaro cosa accade. Il valore di std::move(a) è un r-value che viene passato al move constructor del tipo T (se esiste). Lo stesso discorso vale per i due assegnamenti successivi che andranno ad usare l’operatore di move assignment. In questo modo si eliminano tutte le copie inutili. Se il tipo in questione non supporta alcuna move semantics viene chiamato comunque il copy constructor e tutto continua a funzionare come prima.

Questo tipo di vantaggi non si applicano ovviamente solo a swap, ma a tutti quei casi in cui la copia è inutile, come ad esempio questo pezzo di codice:

std::string s1 = "ciao ", s2 = "come ", s3 = "va?";
std::string r = s1 + s2 + s3;

In un’espressione del genere abbiamo molte copie inutili. Prima viene copiato s1 nell’oggetto temporaneo che sarà il risultato di s1 + s2, per poi appendere il contenuto di s2. Fatto ciò, questo oggetto temporaneo viene copiato nell’oggetto temporaneo che sarà il risultato generale, che poi verrà copiato di nuovo dentro ad r. Ogni copia ha un costo proporzionale alla lunghezza della stringa copiata, e richiede un’allocazione di memoria dinamica. Utilizzando opportunamente la move semantics è possibile implementare un operator+(std::string &&s), che sposta i dati dei vari risultati intermedi invece di copiarli, richiedendo a tutti gli effetti una sola copia.

Utilizzando la move semantics, le prestazioni della maggior parte dei container e degli algoritmi standard aumenta drasticamente. Ad esempio, quando l’algoritmo sort() deve scambiare gli elementi di un vettore per ordinarlo, usando std::swap() sposta gli elementi invece di copiarli.
Stesso discorso per tutti quei casi in cui un container deve spostare elementi durante la riallocazione della memoria.

Gli r-value reference sono una feature rivolta principalmente agli autori di librerie e componenti riutilizzabili.
La cosa da notare è che il drastico aumento di performance che deriva da questa nuova funzionalità non richiede modifiche del codice che utilizza le nuove librerie. Alla fine, molto codice che usa la libreria standard o altre librerie che verranno portate al C++0x, potrà godere di un incremento di performance anche solo venendo ricompilato.

Perfect forwarding

Un altro problema risolto dagli r-value references ha a che fare con la programmazione generica.
Supponiamo per esempio di voler scrivere una funzione factory che alloca oggetti di un certo tipo e li ritorna all’interno di uno smart pointer come ad esempio shared_ptr (che da Boost ora è incluso anche nel nuovo standard).
In C++ classico potremmo scriverla così:

template <typename T, typename U>
std::shared_ptr<T>
factory(const U& arg)
{
    return std::shared_ptr<T>(new T(arg));
}

Questa funzione è molto semplice e non fa altro che restituire lo shared_ptr costruito con un oggetto di tipo T allocato per l’occasione. Idealmente vorremmo poter usare questa funzione in tutti i posti in cui useremmo l’espressione new T(…). In realtà però non è sempre possibile.
Ad esempio, cosa succede se il costruttore di A prende un parametro passato per riferimento non costante? Otteniamo un errore di compilazione nel punto in cui la funzione factory cerca di passare un const-reference ad un reference non costante.
Per risolvere il problema si potrebbe pensare di togliere il qualificatore const dal parametro di factory. In questo caso il codice funzionerebbe anche con parametri reference, ma sarebbe impossibile scrivere codice di questo tipo:

shared_ptr<A> ptr = factory<A>(5);

In questo caso, infatti la costante 5 non potrebbe essere passata by reference alla funzione factory.

Da questo esempio si vede che attualmente è impossibile creare una funzione generica che ripassi i propri argomenti ad un’altra funzione preservando esattamente lo stesso tipo con cui li riceve. Questo problema è noto come “perfect forwarding”. Con gli r-value references si risolve in modo molto semplice:

template <typename T, typename U>
std::shared_ptr<T>
factory(U&& arg)
{
   return std::shared_ptr<T>(new T(std::forward<U>(arg)));
}

La funzione std::forward() usata qui ha praticamente la stessa definizione di std::move(), ma grazie al nome diverso esprime chiaramente il suo intento, ovvero restituire direttamente il proprio argomento preservandone esattamente il tipo, compresa la sua natura di l-value o r-value. Così facendo, anche la chiamata factory<A>(5) sarà legale e la funzione factory funzionerà come voluto.

Conclusioni

Gli r-value references sono una delle feature di C++0x più difficili da capire ed utilizzare correttamente. Sono rivolti agli autori di librerie e componenti riutilizzabili, ma avvantaggiano la totalità degli utenti in modo trasparente.
Come per i vararg templates (template con lista di argomenti di lunghezza variabile), l’aggiunta di questa feature aumenta le performance e la generalità delle librerie C++ mantenendo leggibile e di alto livello il codice che le usa.
Volendo provare a sperimentare, i compilatori che già supportano questa novità sono G++ dalla 4.3 in poi, Microsoft Visual C++ dalla versione 10, e il branch di sviluppo di Clang ormai da parecchi mesi.
Ho cercato di spiegare l’argomento nel modo più comprensibile possibile. Qualsiasi commento a riguardo è benvenuto.

  4 Responses to “Le novità di C++0x: r-value references”

  1. Non mi è chiaro perché fai un delete data nel move constructor di string.

 Leave a Reply

(required)

(required)

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre lang="" line="" escaped="" highlight="">

 
© 2011 Nicola Gigante Hosted by NetMDM Suffusion theme by Sayontan Sinha