5

Riferimenti rvalue, semantica di spostamento e perfect-forward

Da principio, la semantica dello spostamento e il perfect-forward sembrano concetti piuttosto semplici.

•   La semantica di spostamento consente ai compilatori di sostituire delle costose operazioni di copia con meno costosi spostamenti. Nello stesso modo in cui i costruttori per copia e gli operatori di assegnamento per copia offrono il controllo sulla copia degli oggetti, i costruttori per spostamento e gli operatori d’assegnamento per spostamento offrono il controllo sulla semantica dello spostamento. La semantica della spostamento consente inoltre di creare tipi move-only, come std::unique_ptr, std::future e std::thread.

•   Il perfect-forward consente di scrivere modelli di funzioni (template) che accettano argomenti arbitrari e li inoltrano ad altre funzioni, in modo che le funzioni di destinazione ricevano esattamente gli stessi argomenti passati alle funzioni che eseguono l’inoltro.

I riferimenti rvalue sono il collante che unisce queste due funzionalità. Sono il meccanismo offerto dal linguaggio per rendere possibile sia la semantica dello spostamento sia il perfect-forward.

Ma più esperienza si acquisisce su queste funzionalità, più si comprende che l’impressione iniziale si basava solo sulla proverbiale “punta dell’iceberg”. Il mondo della semantica di spostamento, del perfect-forward e dei riferimenti rvalue è più intricato di quanto sembri. Per esempio, std::move non sposta nulla, e il perfect-forward è tutt’altro che “perfetto”. Le operazioni di spostamento non sempre sono più economiche rispetto a quelle di copia; quando lo sono, non sono tanto economiche quanto si potrebbe immaginare; e non sempre vengono richiamate in un contesto in cui lo spostamento è valido. Il costrutto “type&&” non sempre rappresenta un riferimento rvalue.

Indipendentemente da quanto si approfondiscano queste funzionalità, c’è sempre l’impressione che ci sia ancora qualcosa da scoprire. Fortunatamente, esiste un limite a questa profondità. Questo capitolo vi accompagnerà proprio fin sul fondo. Una volta che vi arriverete, questo aspetto del C++11 vi sarà molto più chiaro. Per esempio, conoscerete le convenzioni d’uso per std::move e std::forward. Vi troverete a vostro agio con la natura ambigua di “type&&”. Comprenderete i motivi, talvolta sorprendenti, degli strani comportamenti delle operazioni di spostamento. Tutti questi dettagli avranno una loro precisa collocazione. A quel punto, in modo paradossale, vi ritroverete dove siete partiti, poiché la semantica dello spostamento, il perfect-forward e i riferimenti rvalue torneranno a sembrarvi concetti semplici. Ma, a quel punto, li avrete davvero compresi appieno.

Negli Elementi di questo Capitolo, è particolarmente importante tenere in considerazione che un parametro è sempre un lvalue, anche se il suo tipo è un riferimento rvalue. Detto questo, dato

void f(Widget&& w);

il parametro w è un lvalue, anche se il suo tipo è un riferimento rvalue a Widget (se lo trovate sorprendente, rileggete la panoramica su lvalues e rvalues presente nell’Introduzione, all’interno del paragrafo Terminologia e convenzioni).

Elemento 23 – Parliamo di std::move e std::forward

È utile iniziare a parlare di std::move e std::forward in termini di ciò che non fanno. std::move non sposta niente. std::forward non inoltra niente. In fase di esecuzione del programma, nessuno dei due fa nulla. Non generano codice eseguibile, neppure un unico byte.

std::move e std::forward sono semplicemente funzioni (in realtà template di funzioni) che eseguono conversioni. std::move converte incondizionatamente il proprio argomento in un rvalue, mentre std::forward svolge questa conversione solo se è esaudita una determinata condizione. Questo è tutto. Naturalmente una descrizione così sintetica fa sorgere nuove domande, ma, in buona sostanza, questo è tutto.

Per rendere più concreto il tutto, ecco un esempio di implementazione di std::move in C++11. Non è pienamente conforme ai dettagli dello Standard, ma gli si avvicina molto.

template<typename T> // nel namespace std
typename remove_reference<T>::type&&  
move(T&& param)  
{  
using ReturnType = // dichiarazione alias;
typename remove_reference<T>::type&&; // vedi Elemento 9
   
return static_cast<ReturnType>(param);  
}  

Ho evidenziato due parti di codice. Un oè il nome della funzione, poiché la specifica del tipo restituito è piuttosto “rumorosa” e non voglio che in questo rumore possiate perdere dei dettagli. L’altra è la conversione che costituisce l’essenza della funzione. Come potete vedere, std::move prende un riferimento a un oggetto (un riferimento universale, per essere precisi, vedi Elemento 24) e restituisce un riferimento allo stesso oggetto.

La parte && del tipo restituito dalla funzione implica che std::move restituisce un riferimento rvalue, ma, come spiega l’Elemento 28, se il tipo T è un riferimento lvalue, T&& diverrebbe un riferimento lvalue. Per impedire ciò, a T viene applicato il tipe trait (vedi Elemento 9) std::remove_reference, garantendo così che && venga applicato a un tipo che non sia un riferimento. Ciò garantisce che std::move restituisca davvero un riferimento rvalue, e questo è importante, poiché i riferimenti rvalue restituiti dalle funzioni sono rvalue. Pertanto, std::move converte il proprio argomento in un rvalue, e questo è tutto ciò che fa.

A proposito, std::move può essere implementata con molta più facilità in C++14. Grazie alla deduzione del tipo restituito dalle funzioni (vedi Elemento 3) e al template alias std::remove_reference_t della Libreria Standard (vedi Elemento 9), std::move può essere scritta in questo modo:

   
template<typename T> // C++14; sempre nel
decltype(auto) move(T&& param) // namespace std
{  
using ReturnType = remove_reference_t<T>&&;  
return static_cast<ReturnType>(param);  
}  

Molto più leggibile, vero?

Poiché std::move non fa altro che convertire il proprio argomento in un rvalue, qualcuno ha suggerito che un nome migliore per questa funzione sarebbe stato qualcosa come rvalue_cast. Sia come sia, il nome utilizzato è std::move, pertanto è importante ricordare ciò che std::move fa e non fa. Esegue una conversione, non uno spostamento.

Naturalmente, gli rvalue sono candidati per gli spostamenti, dunque l’applicazione di std::move a un oggetto comunica al compilatore che l’oggetto può essere spostato. Questo è il motivo per cui std::move ha questo nome: per designare gli oggetti che possono essere spostati.

In verità, gli rvalue “solitamente” sono candidati per lo spostamento. Supponete di dover scrivere una classe che rappresenta generiche annotazioni. Il costruttore della classe prende un parametro std::string che costituisce l’annotazione e copia il parametro in un dato membro. Come descritto nell’Elemento 41, si dichiara un parametro per valore:

class Annotation {  
public:  
explicit Annotation(std::string text); // parametro da copiare,
// vedi Elemento 41,
}; // passaggio per valore

Ma il costruttore di Annotation ha bisogno solo di leggere il valore di text. Non deve modificarlo. Seguendo la lunga tradizione di utilizzare const quando possibile, si modifica la dichiarazione in modo che text sia const:

class Annotation {

public:

explicit Annotation(const std::string text)

};

Per evitare di pagare per un’operazione di copia quando si deve copiare text in un dato membro, ci atteniamo al consiglio fornito nel Punto 41 e applichiamo std::move a text, producendo pertanto un rvalue:

class Annotation {  
public:  
explicit Annotation(const std::string text)
: value(std::move(text)) // “move” di text in value; questo codice
{ ... } // non fa quello che sembra!
   
 
   
private:  
std::string value;  
};  

Questo codice passa la compilazione. Passa anche il linking. Viene anche seguito. Questo codice assegna al dato membro value il contenuto di text. L’unica cosa che separa questo codice da una realizzazione perfetta di questa idea è che text non viene spostato in value, ma viene copiato. Certamente, text viene convertito in un rvalue da std::move, ma text è dichiarato come una const std::string, pertanto prima della conversione, text è un lvalue const std::string e il risultato della conversione è un rvalue const std::string, ma, attraverso tutto questo, rimane il fatto che text è const.

Considerate l’effetto che si ha quando i compilatori devono determinare quale costruttore di std::string richiamare. Hanno due possibilità:

class string { // std::string è in effetti un
public: // typedef per std::basic string<char>
 
string(const string& rhs); // costr. per copia
string(string&& rhs); // costr. per spostamento
 
};  

Nell’elenco di inizializzazione dei membri del costruttore di Annotation, il risultato di std::move(text) è un rvalue di tipo const std::string. Tale rvalue non può essere passato al costruttore per spostamento di std::string, poiché il costruttore per spostamento prende un riferimento rvalue a una std::string non-const. L’r value può, tuttavia, essere passato al costruttore per copia, poiché a un rvalue const può associarsi a un riferimento lvalue a const. L’inizializzazione del membro, pertanto, richiama il costruttore per copia di std::string, anche se text è stato convertito in un rvalue! Tale comportamento è fondamentale per mantenere la correttezza in termini di const. Lo spostamento di un valore fuori da un oggetto, generalmente, è per modificare tale oggetto, e pertanto il linguaggio non dovrebbe mai permettere che gli oggetti const vengano passati a delle funzioni (come i costruttori per copia) che potrebbero modificarli.

Da questo esempio si possono trarre due lezioni. Innanzitutto, non si devono dichiarare oggetti const se poi si vuole avere la possibilità di spostarli. Le richieste di spostamento su oggetti const vengono, di fatto, trasformate in operazioni di copia. In secondo luogo, non solo std::move in realtà non sposta nulla, ma non garantisce neppure che l’oggetto che sta convertendo sia spostabile. L’unica cosa davvero certa sul risultato dell’applicazione di std::move a un oggetto è il fatto che si ottiene un rvalue.

A std::forward si applica un ragionamento simile a quello per std::move, ma mentre std::move converte incondizionatamente il proprio argomento in un rvalue, std ::forward lo fa solo in alcuni casi. std::forward è una conversione condizionale. Per capire quando esegue e quando non esegue la conversione, bisogna considerare il modo in cui std::forward viene normalmente utilizzata. La situazione più comune è un template di funzione che accetta un parametro riferimento universale che deve essere passato a un’altra funzione:

void process(const Widget& lvalArg); // elabora gli lvalue
void process(Widget&& rvalArg); // elabora gli rvalue
   
template<typename T> // template che passa
void logAndProcess(T&& param) // i parametri da elaborare
{  
auto now = // ora corrente
std::chrono::system clock::now();  
   
makeLogEntry(“Calling ‘process’”, now);
process(std::forward<T>(param));  
}  

Considerate due chiamate a logAndProcess: una con un lvalue e l’altra con un rvalue:

Widget w;  
   
logAndProcess(w); // chiamata con lvalue
logAndProcess(std::move(w)); // chiamata con rvalue

All’interno di logAndProcess, il parametro param viene passato alla funzione process. Quest’ultima ha un overloading per lvalue e un altro per rvalue. Quando si richiama logAndProcess con un lvalue, ci aspettiamo naturalmente che tale lvalue venga inoltrato a process come un lvalue e quando richiamiamo logAndProcess con un rvalue, ci aspettiamo che venga richiamato l’overloading di process per gli rvalue.

Ma param, come tutti i parametri di una funzione, è un lvalue. Ogni chiamata a process all’interno di logAndProcess vorrà pertanto richiamare l’overloading lvalue di process. Per evitare ciò, abbiamo bisogno di un meccanismo grazie al quale param possa essere convertito in un rvalue se (e solo se) l’argomento con cui è stato inizializzato param (l’argomento passato a logAndProcess) era un rvalue. Questo è esattamente ciò che fa std::forward. Questo è il motivo per cui std::forward è una conversione condizionale: converte in un rvalue solo se il suo argomento è stato inizializzato con un rvalue.

Potreste chiedervi come faccia std::forward a sapere se il suo argomento è stato inizializzato con un rvalue. Nel codice precedente, per esempio, come può std::forward sapere se param è stato inizializzato con un lvalue o con un rvalue? La risposta breve è che tale informazione viene codificata nel parametro T del template di logAndProcess. Il parametro viene passato a std::forward, che estrae l’informazione codificata. Per i dettagli dell’esatto funzionamento di questo meccanismo, consultate l’Elemento 28.

Dato che sia std::move sia std::forward, alla fine, non sono altro che conversioni, dove l’unica differenza consiste nel fatto che std::move esegue la conversione sempre, mentre std::forward la esegue solo qualche volta, potreste chiedervi se non si possa lasciar perdere std::move e utilizzare sempre std::forward, ovunque. Dal punto di vista puramente tecnico, la risposta è affermativa: std::forward può fare tutto e std ::move non è necessaria. Naturalmente nessuna delle due funzioni è davvero necessaria, poiché potremmo anche scrivere direttamente le conversioni, ma credo che siamo tutti d’accordo sul fatto che questo sarebbe... indesiderabile.

I punti di forza di std::move sono la comodità, il fatto che riduce l’insorgere di errori e la maggiore chiarezza. Considerate una classe in cui vogliamo determinare quante volte viene richiamato il costruttore per spostamento. Predisponiamo un semplice contatore static che viene incrementato durante la costruzione per spostamento. Supponendo che l’unico dato non statico della classe sia una std::string, ecco il modo convenzionale (ovvero utilizzando std::move) per implementare il costruttore per spostamento:

class Widget {

public:

Widget(Widget&& rhs)

: s(std::move(rhs.s))

{ ++moveCtorCalls; }

private:

static std::size_t moveCtorCalls;

std::string s;

};

Per implementare lo stesso comportamento con std::forward, il codice avrebbe il seguente aspetto:

class Widget {  
public:  
Widget(Widget&& rhs) // unconventional,
: s(std::forward<std::string>(rhs.s)) // indesiderabile
{ ++moveCtorCalls; } // implementation
 
   
};  

Notate innanzitutto che std::move richiede un solo argomento (rhs.s), mentre std ::forward richiede sia un argomento funzione (rhs.s) sia un argomento di tipo template (std::string). Poi notate che il tipo che passiamo a std::forward non dovrà essere un riferimento, poiché per convenzione la codifica dell’argomento passato deve essere un rvalue (vedi Elemento 28). Ciò significa che std::move richiede meno impegno di digitazione rispetto a std::forward e ci evita anche la necessità di passare un argomento di tipo che codifica il fatto che l’argomento che stiamo passando è un rvalue. Inoltre, elimina la possibilità del passaggio di un tipo errato (per esempio std ::string&, che risulterebbe dal fatto che il dato membro s venga costruito per copia anziché per spostamento).

Ancora più importante: l’uso di std::move veicola la richiesta di una conversione incondizionata in un rvalue, mentre l’uso di std::forward veicola la richiesta di una conversione in un rvalue solo per i riferimenti cui sono associati degli rvalue. Si tratta di due azioni molto differenti. La prima, tipicamente, prevede uno spostamento, mentre la seconda non fa altro che passare (inoltrare) un oggetto a un’altra funzione in un modo che mantiene le sue caratteristiche originali: lvalue o rvalue. Poiché si tratta di azioni così differenti, è giusto che esistano due funzioni differenti (con nomi differenti).

Argomenti da ricordare

•   std::move esegue una conversione incondizionata in una rvalue e di per se stessa non sposta nulla.

•   std::forward converte il proprio argomento in un rvalue solo se a l’argomento è associato a un rvalue.

•   Né std::movestd::forward svolgono alcuna operazione runtime.

Elemento 24 – Distinguere i riferimenti universali e riferimenti rvalue

Si dice che la verità renda liberi, ma, in alcuni casi, una bugia ben scelta può essere altrettanto liberatoria. Questo Elemento è una bugia di questo tipo. Ma, in termini software, al posto di “bugia” si usa un termine più elegante: “astrazione”.

Per dichiarare un riferimento rvalue a un determinato tipo T, si scrive T&&. Sembra pertanto ragionevole presumere che se si trova T&& nel codice sorgente, si tratti di un riferimento rvalue.

Purtroppo le cose non sono così semplici:

void f(Widget&& param); // riferimento rvalue
   
WidgetSS var1 = Widget(); // riferimento rvalue
   
autoSS var2 = var1; // non è riferimento rvalue
   
template<typename T>  
void f(std::vector<T>SS param); // riferimento rvalue
   
template<typename T>  
void f(TSS param); // non è riferimento rvalue

In realtà, T&& ha due significati differenti. Uno, naturalmente, è un riferimento rvalue. Tali riferimenti si comportano esattamente nel modo previsto: si associano solo a rvalue e il loro motivo di esistere è proprio quello di indentificare gli oggetti da cui possono essere eseguiti spostamenti.

L’altro significato di T&& è quello di riferimento rvalue oppure lvalue. Tali riferimenti, nel codice sorgente, hanno entrambi l’aspetto di riferimenti rvalue (ovvero T&&), ma possono comportarsi come se fossero riferimenti lvalue (ovvero T&). Questa doppia natura permette loro di associarsi a rvalue (come riferimenti rvalue) o anche a lvalue (come riferimenti lvalue). Inoltre, possono associarsi a oggetti const o non-const, a oggetti volatile o non-volatile e perfino a oggetti const e volatile. Praticamente si possono legare a qualsiasi cosa. Tali riferimenti assolutamente flessibili meritano un nome tutto loro. Possiamo chiamarli riferimenti universali.11

I riferimenti universali si hanno in due contesti. Il più comune è quello dei parametri di template di funzioni, come questo esempio basato sul codice precedente:

template<typename T>  
void f(TSS param); // param è un riferimento universale

Il secondo contesto è costituito dalle dichiarazioni auto, inclusa la seguente, sempre tratta dal codice precedente:

auto&& var2 = var1;         // var2 è un riferimento universale

Questi due contesti hanno in comune la presenza della deduzione del tipo. Nel template di f, il tipo di param viene dedotto e, nella dichiarazione di var2, il tipo di var2 viene dedotto. Confrontate questo con i seguenti esempi (sempre basati sul codice precedente), dove manca la deduzione del tipo. Se vedete T&& senza la deduzione del tipo, state osservando un riferimento rvalue:

void f(WidqetSS param); // nessuna deduzione del tipo;
  // param è un riferimento rvalue
   
WidgetSS vari = Widqet(); // nessuna deduzione del tipo;
  // vari è un riferimento rvalue

Poiché i riferimenti universali sono “riferimenti”, devono essere inizializzati. È proprio l’inizializzatore di un riferimento universale a determinare il fatto che esso rappresenti un riferimento rvalue o lvalue. Se l’inizializzatore è un rvalue, il riferimento universale corrisponde a un riferimento rvalue. Se l’inizializzatore è un lvalue, il riferimento universale corrisponde a un riferimento lvalue. Per i riferimenti universali che sono parametri di funzione, l’inizializzatore viene fornito al momento della chiamata:

template<typename T>  
void f(T&& param); // param è un riferimento universale
   
Widget w;  
f(w); // lvalue passato a f; il tipo di param
  // è Widqet& (ovvero un riferimento lvalue)
   
f(std::move(w) ); // rvalue passato a f; il tipo di param
  // è Widqet&& (ovvero un riferimento rvalue)

Perché un riferimento possa essere universale, la deduzione del tipo è necessaria, ma non sufficiente. Anche la forma della dichiarazione del riferimento deve essere corretta e tale forma deve sottostare a precisi vincoli. Deve essere esattamente T&&. Osservate nuovamente questo esempio basato sul codice che abbiamo visto in precedenza:

template<typename T>  
void f(std::vector<T>&& param); // param è un riferimento rvalue

Quando viene richiamata f, il tipo T viene dedotto (a meno che il chiamante non lo specifichi esplicitamente, un caso limite di cui non ci occuperemo). Ma la forma della dichiarazione del tipo di param non è T&&, bensì std::vector<T>&&. Ciò elimina la possibilità che param sia un riferimento universale. Pertanto param sarà un riferimento rvalue, qualcosa che il compilatore sarà felice di comunicarvi se tenterete di passare a f un lvalue:

std::vector<int> v;  
f(v); // errore! Non si può associare un lvalue
  // a un riferimento rvalue

Anche la semplice presenza del qualificatore const è sufficiente per impedire che un riferimento possa essere universale:

template<typename T>  
void f(const T&& param); // param è un riferimento rvalue

Se vi trovate in un template e vedete un parametro di funzione di tipo T&&, potete essere portati a supporre che si tratti di un riferimento universale. Ma non è così. Il fatto che sia un template non garantisce la deduzione del tipo. Considerate la seguente funzione membro push_back in std::vector:

template<class T, class Allocator = allocator<T>> // dal C++
class vector { // Standard
public:  
void push back(T&& x);  
 
};  

Il parametro di push_back ha certamente la forma corretta per un riferimento universale, ma in questo caso non vi è la deduzione del tipo. Questo perché push_back non può esistere senza una particolare istanziazione di vector di cui possa far parte e il tipo di tale istanziazione determina completamente la dichiarazione per push_back. Pertanto, dicendo

std::vector<Widget> v;

si fa in modo che il template std::vector venga istanziato nel seguente modo:

class vector<Widget, allocator<Widget>> {  
public:  
void push back(Widget&& x); // riferimento rvalue
 
};  

Ora si può vedere chiaramente che pushback non impiega la deduzione del tipo. Questa push_back per vector<T> (ve sono due, la funzione subisce overloading) dichiara sempre un parametro di tipo riferimento rvalue a T.

Al contrario, la funzione membro emplace_back, concettualmente simile, in std ::vector impiega la deduzione del tipo:

template<class T, class Allocator = allocator<T>> // sempre dal
class vector { // C++
public: // Standard
template <class... Args>  
void emplace back(ArgsSS... args);  
 
};  

Qui, il parametro di tipo Args è indipendente dal parametro del tipo, T, di vector e pertanto Args deve essere dedotto ogni volta che viene richiamata emplace_back (d’accordo, in realtà Args non è esattamente un parametro per il tipo, ma per gli scopi di questa discussione, possiamo trattarlo come tale).

Il fatto che il parametro per il tipo di emplace_back si chiami Args e che comunque sia un riferimento universale va a supporto del mio precedente commento: che è la forma di un riferimento universale che deve essere T&&. Non è obbligatorio utilizzare il nome T. Per esempio, il seguente template prende un riferimento universale, poiché la forma (type&&) è corretta e il tipo di param verrà dedotto (nuovamente, escludendo il caso limite in cui il chiamante specifichi esplicitamente il tipo):

template<typename MyTemplateType> // param è un
void someFunc(MyTemplateType&& param); // riferimento universale

Ho detto in precedenza che anche le variabili auto possono essere riferimenti universali. Per essere più precisi, le variabili dichiarate di tipo auto&& sono riferimenti universali, poiché a esse si applica la deduzione del tipo e hanno la forma corretta (T&&). I riferimenti universali auto non sono altrettanto comuni dei riferimenti universali utilizzati per i parametri dei template di funzione, ma ogni tanto vengono impiegati in C++11. Sono invece più presenti in C++14, perché le espressioni lambda del C++14 possono dichiarare parametri auto&&. Per esempio, se voleste scrivere una lambda C++14 per registrare il tempo impiegato nella chiamata di una funzione arbitraria, potreste fare nel seguente modo:

auto timeFuncInvocation =  
[](auto&& func, auto&&... params) // C++14
{  
start timer;  
std::forward<decltype(func)>(func)( // richiama func
std::forward<decltype(params)>(params)... // su params
);  
stop timer and record elapsed time;  
};  

Se la vostra reazione al codice std::forward<decltype(bla bla bla)> all’interno della lambda è “Ma che diamine...?!”, questo significa che probabilmente non avete ancora letto l’Elemento 33. Non preoccupatevi. L’importante in questo Elemento è costituito dai parametri auto&& dichiarati dalla lambda. func è un riferimento universale che può essere associato a ogni oggetto richiamabile, che sia lvalue o rvalue. args è costituito da zero o più riferimenti universali (ovvero un pack) che può essere associato a un qualsiasi numero di oggetti di tipo arbitrario. Il risultato, grazie ai riferimenti universali auto, è che timeFuncInvocation può controllare l’esecuzione di praticamente ogni funzione (per dettagli su questa differenza fra “ogni” e “praticamente ogni”, consultate l’Elemento 30).

Tenete in considerazione che questo intero Elemento (alla base dei riferimenti universali) è una bugia... ops, volevo dire una “astrazione”. La verità su cui essa si basa è chiamata collasso dei riferimenti (reference collapsing), un argomento al quale è dedicato l’Elemento 28. Ma la verità non rende meno utile l’astrazione. Il fatto di distinguere tra riferimenti rvalue e riferimenti universali vi aiuterà a leggere il codice sorgente con maggiore precisione (“Il T&& che ho davanti si associa solo a rvalue o a qualsiasi cosa?”) e vi eviterà di comunicare in modo ambiguo con i colleghi (“Sto utilizzando un riferimento universale, non un riferimento rvalue...”). Aiuterà inoltre a comprendere gli Elementi 25 e 26, che contano su questa distinzione. Accettate dunque questa astrazione. Come le leggi sulla gravitazione di Newton (tecnicamente errate) sono altrettanto utili e più facili da applicare rispetto alla teoria della relatività generale di Einstein (la “verità”), così il concetto di riferimento universale è normalmente preferibile a considerare i dettagli del “collasso”.

Argomenti da ricordare

•   Se un parametro di un template di funzione ha tipo T&& per un tipo dedotto T o se un oggetto è dichiarato utilizzando auto&&, il parametro o l’oggetto è un riferimento universale.

•   Se la forma della dichiarazione del tipo non è esattamente type&& o se la deduzione del tipo non si verifica, type&& identifica un riferimento rvalue.

•   I riferimenti universali corrispondono a riferimenti rvalue se vengono inizializzati tramite un rvalue. Corrispondono invece a riferimenti lvalue se vengono inizializzati con un lvalue.

Elemento 25 – Usare std::move sui riferimenti rvalue e std::forward sui riferimenti universali

I riferimenti rvalue si associano solo a oggetti che sono candidati per lo spostamento. Se avete un parametro riferimento rvalue, sapete che l’oggetto cui si associa può essere spostato:

class Widget {  
Widget(WidgetSS rhs); // rhs fa assolutamente riferimento
// a un oggetto spostabile
};  

Per questo motivo, vorrete passare tali oggetti ad altre funzioni in un modo che permetta a tali funzioni di sfruttare il fatto che l’oggetto è un rvalue. Il modo per farlo consiste nel convertire in rvalue i parametri associati a tali oggetti. Come descrive l’Elemento 23, questo non è solo ciò che std::move fa, è anche il motivo stesso per cui è stata creata:

class Widget {  
public:  
Widget(Widget&& rhs) // rhs è un riferimento rvalue
: name(std::move(rhs.name)),  
p(std::move(rhs.p))  
{ ... }  
 
private:  
std::string name;  
std::shared_ptr<SomeDataStructure> p;  
};  

Un riferimento universale, al contrario (vedi Elemento 24), può essere associato a un oggetto che può essere impiegato per uno spostamento. I riferimenti universali devono essere convertiti in rvalue solo se sono stati inizializzati tramite un rvalue. L’Elemento 23 spiega che questo è esattamente ciò che fa std::forward:

class Widget {  
public:  
template<typename T>  
void setName(T&& newName) // newName è un
{ name = std::forward<T>(newName); } // riferimento universale
   
 
};  

In breve, i riferimenti rvalue dovrebbero essere convertiti incondizionatamente in rvalue (tramite std::move) quando vengono inoltrati ad altre funzioni, poiché sono sempre associati a rvalue; i riferimenti universali dovrebbero essere convertiti condizionatamente in rvalue (tramite std::forward) al momento dell’inoltro, poiché solo talvolta sono associati a rvalue.

L’Elemento 23 spiega che l’uso di std::forward su riferimenti rvalue può anche esibire un comportamento corretto, ma il codice sorgente diviene prolisso, soggetto a errori e di difficile comprensione; pertanto si dovrebbe evitare di utilizzare std::forward con riferimenti rvalue. Ancora peggiore è l’idea di utilizzare std::move con i riferimenti universali, poiché possono avere l’effetto di modificare inaspettatamente gli lvalue (ovvero le variabili locali):

   
class Widget {  
public:  
template<typename T>  
void setName(T&& newName) // riferimento universale
{ name = std::move(newName); } // compilabile, ma
// decisamente cattiva programmazione!
   
private:  
std::string name;  
std::shared_ptr<SomeDataStructure> p;  
};  
   
std::string getWidgetName(); // funzione factory
   
Widget w;  
   
auto n = getWidgetName(); // n è una variabile locale
w.setName(n); // sposta n in w!
// il valore di n ora è ignoto

Qui, la variabile locale n viene passata a w.setName; il chiamante può erroneamente supporre che si tratti di un’operazione di sola lettura su n. Ma poiché setName usa internamente std::move per convertire incondizionatamente il suo parametro riferimento in un rvalue, il valore di n verrà spostato in w.name e dopo la chiamata a set-Name, n avrà un valore non specificato. Questo è un tipo di comportamento che genera collera nel chiamante.

Si potrebbe supporre che il parametro di setName non dovrebbe essere dichiarato come riferimento universale. Tali riferimenti non possono essere const (vedi Elemento 24), e certamente setName non dovrebbe certo modificare il proprio parametro. Potreste pensare che se setName avesse semplicemente subito overloading per lvalue const e per rvalue, tutto questo problema avrebbe potuto essere evitato. Nel seguente modo:

class Widget {  
public:  
void setName(const std::string& newName) // impostato da
{ name = newName; } // const lvalue
   
void setName(std::string&& newName) // impostato da
{ name = std::move(newName); } // rvalue
 
};  

Questo certamente potrebbe funzionare (in questo caso), ma presenta dei problemi. Innanzitutto, vi è ulteriore codice sorgente da scrivere e correggere (due funzioni invece di un unico template). In secondo luogo, può essere meno efficiente. Per esempio, considerato il seguente uso di setName:

w.setName(“Adela Novak”);

Con la versione di setName che accetta un riferimento universale, a setName verrebbe passata la stringa letterale “Adela Novak”, la quale verrebbe inviata all’operatore di assegnamento per la std::string all’interno di w. Il dato membro w di name verrebbe pertanto assegnato direttamente dal letterale stringa; non ne deriverebbero oggetti std::string temporanei. Con le versioni in overloading di setName, invece, verrebbe creato un oggetto setName temporaneo cui associare il parametro di setName, e questa std::string temporanea verrebbe poi spostata nel dato membro di w. Una chiamata a setName richiederebbe, pertanto, l’esecuzione di: un costruttore di std::string (per creare la stringa temporanea), un operatore di assegnamento per spostamento di std::string (per spostare newName in w.name) e un distruttore di std::string (per distruggere la stringa temporanea). Questa è quasi certamente una sequenza di esecuzione più costosa rispetto alla semplice chiamata dell’operatore di assegnamento di std::string, che accetta un puntatore const char*. Il costo aggiuntivo varia da implementazione a implementazione e quanto occorra preoccuparsi di questo costo varia da applicazione ad applicazione e da libreria a libreria, ma il fatto è che la sostituzione di un template che accetta un riferimento universale con una coppia di funzioni in overloading per riferimenti lvalue e riferimenti rvalue, molto probabilmente prevedrà dei costi runtime. Se poi generalizziamo l’esempio, in modo che il dato membro di Widget possa essere di un tipo arbitrario (ovvero non abbiamo la garanzia che si tratti di una std::string), il divario prestazionale può ampliarsi notevolmente, poiché non tutti i tipi sono “economici” da spostare quanto le std::string (vedi Elemento 29).

Il problema più serio con l’overloading per lvalue e rvalue, tuttavia, non riguarda la quantità o la comprensibilità del codice sorgente, e neppure le prestazioni runtime del codice. È la cattiva scalabilità del progetto. Widget::setName accetta un solo parametro e pertanto richiede due soli overload, ma per le funzioni che richiedono più parametri, ognuno dei quali potrebbe essere un lvalue o un rvalue, il numero di overloading cresce geometricamente: con n parametri saranno necessari 2n overload. Ma c’è di peggio. Alcune funzioni (in realtà template di funzioni) accettano un numero illimitato di parametri, ognuno dei quali potrebbe essere un lvalue o un rvalue. Il classico esempio è rappresentato da std::make_shared e, in C++14, da std::make_unique (vedi Elemento 21). Osservate le dichiarazioni degli overload utilizzati più comunemente:

template<class T, class... Args> // dal C++11
shared ptr<T> make shared(ArgsSS... args); // Standard
   
template<class T, class... Args> // dal C++14
unique_ptr<T> make unique(Args&&... args); // Standard

Per funzioni come queste, l’overloading per lvalue e rvalue non è possibile: i riferimenti universali sono l’unico modo di procedere. E all’interno di tali funzioni, ve lo garantisco, ai parametri riferimento universale passati ad altre funzioni viene applicata std::for-ward. Questo è esattamente ciò che dovreste fare.

Almeno di solito. Talvolta. Ma non necessariamente per principio. In alcuni casi, intendete utilizzare l’oggetto associato a un riferimento rvalue o a un riferimento universale più di una volta in un’unica funzione e volete assicurarvi che non venga spostato finché non avrete terminato di utilizzarlo. In tal caso, dovrete applicare std::move (per i riferimenti rvalue) o std::forward (per i riferimenti universali) solo all’ultimo utilizzo del riferimento. Per esempio:

template<typename T> // text è
void setSignText(T&& text) // rif. univ.
{  
sign.setText(text); // usa text, ma
  // senza modificarlo
auto now = // ottiene l’ora corrente
std::chrono::system clock::now();  
   
signHistory.add(now,  
std::forward<T>(text)); // conversione condizionale
} // di text in rvalue

Qui vogliamo assicurarci che il valore di text non venga cambiato da sign.setText, poiché vogliamo utilizzare tale valore quando richiamiamo signHistory.add. Da qui l’uso di std::forward solo sull’utilizzo finale del riferimento universale.

Per std::move si applica lo stesso ragionamento (ovvero applicare std::move a un riferimento rvalue solo l’ultima volta che viene utilizzato), ma è importante notare che in alcuni rari casi si vuole richiamare std::move_if_noexcept invece di std::move. Per capire quando e perché, consultate l’Elemento 14.

Se siete in una funzione che restituisce per valore e state restituendo un oggetto associato a un riferimento rvalue o a un riferimento universale, dovrete applicare std::move o std::forward al momento del return del riferimento. Per capire perché, considerate una funzione operator+ con la quale sommare due matrici, dove la matrice di sinistra si sa che è un rvalue (il suo spazio può pertanto essere riutilizzato per contenere la somma delle matrici):

Matrix // return per-valore
operator+(MatrixSS lhs, const MatrixS rhs)  
{  
lhs += rhs;  
return std::move(lhs); // sposta lhs nel
} // valore restituito

Convertendo lhs in un rvalue nell’istruzione return (tramite std::move), lhs verrà spostato nella posizione del valore restituito dalla funzione. Se la chiamata std::move venisse omessa,

Matrix // come sopra
operator+(Matrix&& lhs, const Matrix& rhs)  
{  
lhs += rhs;  
return lhs; // copia lhs nel
} // valore restituito

il fatto che lhs è un lvalue forzerebbe i compilatori a copiarlo, invece, nella posizione del valore restituito. Supponendo che il tipo Matrix supporti la costruzione per spostamento, più efficiente rispetto alla costruzione per copia, l’uso di std::move nell’istruzione return genera codice più efficiente.

Se Matrix non supporta lo spostamento, la sua conversione in un rvalue non darà problemi, poiché tale rvalue verrà semplicemente copiato dal costruttore per copia di Matrix (vedi Elemento 23). Se però Matrix viene successivamente modificato per supportare lo spostamento, operator+ se ne avvantaggerà automaticamente la prossima volta che verrà compilato. Per questo motivo, non si perderà nulla (anzi, si avrà un vantaggio) applicando std::move ai riferimenti rvalue restituiti dalle funzioni che restituiscono per valore.

La situazione è simile per i riferimenti universali e std::forward. Considerate un template di funzione reduceAndCopy che accetta un oggetto Fraction potenzialmente non ridotto, lo riduce e poi restituisce una copia del valore ridotto. Se l’oggetto originale è un rvalue, il suo valore dovrà essere spostato nel valore restituito (evitando così il costo dell’operazione di copia); ma se l’originale è un lvalue, deve essere eseguita una copia. Pertanto:

template<typename T>  
Fraction // restituisce per-valore
reduceAndCopy(T&& frac) // parametro riferimento universale
{  
frac.reduce();  
return std::forward<T>(frac); // sposta rvalue nel
} // valore restituito, copia lvalue

Se la chiamata a std::forward venisse omessa, frac verrebbe incondizionatamente copiato nel valore restituito da reduceAndCopy.

Alcuni programmatori usano questa informazione e tentano di estenderla anche alle situazioni in cui non si applica: “Se l’uso di std::move su un parametro riferimento rvalue da copiare nel valore restituito trasforma una costruzione per copia in una costruzione per spostamento”, pensano, “posso eseguire la stessa operazione anche sulle variabili locali che restituisco”. In altre parole, immaginano che, data una funzione che restituisce una variabile locale per valore, come il seguente esempio,

   
Widget makeWidget() // Cersione per “copia” di makeWidget
{  
Widget w; // variabile locale
   
// configura w
   
return w; // “copia” w nel valore restituito
}  
   

intenderebbero “ottimizzarla” trasformando la “copia” in uno spostamento:

Widget makeWidget() // versione per spostamento di makeWidget
{  
Widget w;  
 
return std::move(w); // sposta w nel valore restituito
} // (ASSOLUTAMENTE DA NON FARE!)

Tutte le virgolette e i commenti che ho inserito dovrebbero chiarire che questo ragionamento è errato. D’accordo, ma perché?

È errato poiché il Comitato di Standardizzazione è sempre un passo avanti ai programmatori quando si tratta di eseguire ottimizzazioni. È stato riconosciuto molto tempo fa che la versione per “copia” di makeWidget può evitare la necessità di copiare la variabile locale w costruendola nella memoria allocata per il valore che verrà restituito dalla funzione. Questa tecnica è chiamata Return Value Optimization (RVO) ed è espressamente suggerita dallo standard del C++ fin da quando esiste.

In realtà si vuole permettere tale elisione della copia solo nei punti in cui ciò non influenzi il comportamento osservabile del software. Per rendere più comprensibile il linguaggio impiegato dallo Standard, i compilatori possono elidere la copia (o lo spostamento) di un oggetto locale12 in una funzione che restituisce per valore se (1) il tipo dell’oggetto locale coincide con il tipo restituito dalla funzione e (2) l’oggetto locale e ciò che viene restituito.

Detto questo, osservate nuovamente la versione per “copia” di makeWidget:

   
Widget makeWidget() // versione per “copia” di makeWidget
{  
Widget w;  
 
return w; // “copia” w nel valore restituito
}  

Qui entrambe le condizioni sono soddisfatte e potete fidarvi se vi dico che per questo codice, ogni compilatore C++ degno di questo nome impiegherà l’ottimizzazione RVO per evitare di copiare w. Questo significa che la versione per “copia” di makeWidget, in realtà, non copia nulla.

La versione per spostamento di makeWidget fa esattamente ciò che dice il nome (supponendo che Widget offra un costruttore per spostamento): sposta il contenuto di w nella posizione del valore restituito da makeWidget. Ma perché i compilatori non usano l’ottimizzazione RVO per eliminare lo spostamento, costruendo nuovamente w nella memoria allocata per il valore restituito dalla funzione? La risposta è semplice: non possono. La condizione (2) stabilisce che l’ottimizzazione RVO possa essere eseguita solo se ciò che viene restituito è un oggetto locale, ma questo non è ciò che la versione per spostamento di makeWidget sta facendo. Osservate nuovamente la sua istruzione return:

return std::move(w);

Ciò che viene restituito qui non è l’oggetto locale w, ma un riferimento a w: il risultato di std::move(w). La restituzione di un riferimento a un oggetto locale non soddisfa le condizioni necessarie per l’ottimizzazione RVO, pertanto i compilatori devono spostare w nella posizione del valore restituito dalla funzione. Gli sviluppatori che cercano di aiutare i loro compilatori a eseguire un’ottimizzazione applicando std::move a una variabile locale che viene restituita, stanno in realtà limitando le opzioni di ottimizzazione già disponibili per i loro compilatori!

Tuttavia la RVO è un’ottimizzazione. I compilatori non sono obbligati a elidere le operazioni di copia e spostamento, anche quando lo possono fare. I più paranoici possono temere che i compilatori vi puniranno per le operazioni di copia, per il semplice fatto che possono. O forse avete conoscenze sufficienti per capire che vi sono casi in cui l’ottimizzazione RVO è troppo complessa da implementare per i compilatori, per esempio quando diversi percorsi di controllo di una funzione restituiscono variabili locali differenti (i compilatori dovrebbero generare codice per costruire la variabile locale appropriata nella memoria destinata al valore restituito dalla funzione, ma come potrebbero i compilatori determinare quale variabile locale sarebbe appropriata?). In tal caso, potreste voler pagare il prezzo di uno spostamento, considerandolo un’assicurazione rispetto al costo di una copia. Pertanto potreste comunque considerare che è ragionevole applicare std::move a un oggetto locale che state restituendo, semplicemente perché sapete che non pagherete mai per una copia.

In tal caso, l’applicazione di std::move a un oggetto locale sarebbe comunque una cattiva idea. La parte dello Standard che suggerisce l’ottimizzazione RVO dice anche che se le condizioni di tale ottimizzazione sono verificate, ma i compilatori scelgono di non eseguire l’elisione dell’operazione di copia, l’oggetto restituito deve essere trattato come un rvalue. In pratica, lo Standard richiede che quando l’ottimizzazione RVO è permessa, o si svolge l’elisione della copia, oppure agli oggetti locali restituiti viene implicitamente applicata std::move. Pertanto, nella versione per “copia” di makeWidget,

Widget makeWidget() // come prima
{  
Widget w;  
 
return w;  
}  

i compilatori devono elidere la copia di w o devono trattare la funzione come se fosse scritta nel seguente modo:

Widget makeWidget()  
{  
Widget w;  
 
return std::move(w); // tratta w come un rvalue, poiché
} // non è stata eseguita l’elisione della copia
   

La situazione è simile per i parametri di funzione passati per valore. In questo caso non è prevista l’elisione della copia per il valore restituito dalla funzione, ma, se vengono restituiti, i compilatori devono trattarli come rvalue. Come risultato, se il codice sorgente ha il seguente aspetto:

   
Widget makeWidget(Widget w) // parametro per valore dello stesso
{ // tipo del tipo restituito dalla funzione
 
return w;  
}  
   

i compilatori devono trattarlo come se fosse scritto nel seguente modo:

Widget makeWidget(Widget w)
{
...
return std::move(w); // tratta w come un rvalue
}  

Ciò significa che se si usa std::move sull’oggetto locale restituito da una funzione che sta restituendo per valore, non potete aiutare i vostri compilatori (devono trattare l’oggetto locale come un rvalue se non eseguono l’elisione della copia), ma certamente potete ostacolarli (impedendo l’ottimizzazione RVO). Vi sono situazioni in cui l’applicazione di std::move a una variabile locale può essere una scelta ragionevole (per esempio, quando la passate a una funzione e sapete che non utilizzerete più tale variabile), ma non per un’istruzione return che si qualificherebbe per un’ottimizzazione RVO o che restituisce un parametro per valore.

Argomenti da ricordare

•   Applicate std::move ai riferimenti rvalue e std::forward ai riferimenti universali solo l’ultima volta che vengono utilizzati.

•   Fate la stessa cosa per i riferimenti rvalue e per i riferimenti universali restituiti dalle funzioni che restituiscono per valore.

•   Non applicate mai std::move o std::forward agli oggetti locali ottimizzabili con RVO.

Elemento 26 – Evitare l’overloading sui riferimenti universali

Supponete di dover scrivere una funzione che accetta come parametro un nome, registra la data e l’ora, poi aggiunge il nome a una struttura dati globale. Il risultato potrebbe essere una funzione con il seguente aspetto:

std::multiset<std::string> names; // struttura dati globale
   
void logAndAdd(const std::string& name)  
{  
auto now = // ottiene l’ora corrente
std::chrono::system clock::now();  
   
log(now, “logAndAdd”); // crea la voce log
   
names.emplace(name); // aggiunge il nome alla struttura
} // dati globale; vedi Elemento 42
  // per info su emplace

Non è codice da “buttare via”, ma non è efficiente quanto dovrebbe. Considerate tre potenziali chiamate:

std::string petName(“Darla”);  
   
logAndAdd(petName); // passa std::string lvalue
   
logAndAdd(std::string(“Persephone”)); // passa std::string rvalue
   
logAndAdd(“Patty Dog”); // passa una stringa letterale

Nella prima chiamata, il parametro name di logAndAdd viene associato alla variabile petName. In logAndAdd, name viene poi passato a names.emplace. Poiché name è un lvalue, viene copiato in names. Non vi è alcun modo per evitare la copia, poiché a logAndAdd è stato passato un lvalue (petName).

Nella seconda chiamata, il parametro name viene associato a un rvalue (la std::string temporanea creata esplicitamente da “Persephone”). name stesso è un lvalue, pertanto viene copiato in names, ma riconosciamo che, in linea di principio, il suo valore potrebbe essere spostato in names. In questa chiamata, paghiamo per una copia, ma potremmo riuscire a ottenere il tutto utilizzando solo uno spostamento.

Nella terza chiamata, il parametro name viene nuovamente associato a un rvalue, ma questa volta si tratta di una std::string temporanea, che viene implicitamente creata da “Patty Dog”. Come nella seconda chiamata, tutti i name vengono copiati in names, ma in questo caso l’argomento originariamente passato a emplace è una stringa letterale. Se la stringa letterale fosse stata passata direttamente a emplace, non vi sarebbe stata alcuna necessità di creare una std::string temporanea. Al contrario, emplace avrebbe usato la stringa letterale per creare l’oggetto std::string direttamente all’interno di std::multiset. In questa terza chiamata, quindi, paghiamo per copiare una std::string, ma in realtà non vi è alcun motivo di pagare neppure per uno spostamento, tantomeno per una copia.

Possiamo eliminare le inefficienze nella seconda e nella terza chiamata riscrivendo logAndAdd in modo che accetti un riferimento universale (vedi Elemento 24) e, sulla base dell’Elemento 25, applicare std::forward per inviare questo riferimento a emplace. Il risultato parla da solo:

template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system clock::now();
log(now, “logAndAdd”);  
names.emplace( std::forward<T>( name) );  
}  
std::string petName(“Darla”); // come prima
   
logAndAdd( petName); // come prima, copia
  // lvalue in multiset
   
logAndAdd( std::string(“Persephone”) ); // sposta rvalue invece
  // di copiarlo
   
logAndAdd(“Patty Dog”); // crea std::string
  // in multiset invece
  // di copiare una std::string
  // temporanea
   

Efficienza perfetta!

Se il discorso si fermasse qui, potremmo fermarci e cambiare argomento, ma non abbiamo ancora parlato del fatto che i client non sempre hanno un accesso diretto ai nomi richiesti da logAndAdd. Alcuni client hanno solo un indice, utilizzato da logAndAdd per ricercare il nome corrispondente all’interno di una tabella. Per supportare tali client, logAndAdd subisce overloading:

std::string nameFromIdx(int idx); // restituisce il nome
  // corrispondente a idx
   
void logAndAdd(int idx) // nuovo overload
{  
auto now = std::chrono::system_clock::now();  
log(now, “logAndAdd”);  
names.emplace(nameFromIdx(idx));  
}  

La risoluzione delle chiamate ai due overloading funziona come previsto:

std::string petName(“Darla”); // come prima
   
logAndAdd(petName); // come prima, tutte
logAndAdd(std::string(“Persephone”)); // queste chiamate richiamano
logAndAdd(“Patty Dog”); // l’overload per T&&
   
logAndAdd(22); // richiama l’overload per int

In effetti, la risoluzione funziona come previsto solo se non ci si aspetta troppo. Supponete che un client abbia uno short che contiene un indice e che lo passi a logAndAdd:

short nameIdx;  
// dare un valore a nameIdx
   
logAndAdd(nameIdx); // errore!

Il commento sull’ultima riga non è particolarmente illuminante, ecco dunque che cosa succede.

Vi sono due overload per logAndAdd. Quello che accetta un riferimento universale può dedurre T come short, trovando pertanto una corrispondenza esatta. L’overloading per un parametro int può corrispondere all’argomento short solo dopo una promozione. In base alle classiche regole di risoluzione dell’overloading, una corrispondenza esatta batte una corrispondenza per promozione, pertanto viene richiamato l’overloading per il riferimento universale.

All’interno di tale overloading, il parametro name viene associato allo short che gli viene passato. name viene poi inoltrato con std::forward alla funzione membro emplace su names (una std::multiset<std::string>), che, a sua volta, lo inoltra diligentemente al costruttore di std::string. Ma non esiste alcun costruttore per std ::string che accetti uno short, pertanto la chiamata al costruttore di std::string all’interno della chiamata multiset::emplace all’interno della chiamata a logAndAdd non ha successo. Tutto perché l’overloading per il riferimento universale era una corrispondenza migliore per un argomento short rispetto a un int.

Le funzioni che accettano riferimenti universali sono fra le più “fameliche” del C++. Si istanziano per creare corrispondenze esatte per quasi ogni tipo di argomento (i pochi argomenti che le “sfuggono” sono descritti nell’Elemento 30). Questo è il motivo per cui combinare l’overloading e i riferimenti universali è quasi sempre una cattiva idea: l’overloading per riferimenti universali si aggiudica molti più tipi di argomenti rispetto a quanti lo sviluppatore generalmente si immagini.

Un modo semplice per schivare questa trappola consiste nello scrivere un costruttore perfect-forward. Una piccola modifica all’esempio logAndAdd illustra il problema. Invece di scrivere una funzione che può prendere una std::string o un indice che possa essere utilizzato per ricercare una std::string, immaginate una classe Person con dei costruttori che facciano la stessa cosa:

class Person {  
public:  
template<typename T>  
explicit Person(T&& n) // costr. per perfect-forward;
: name(std::forward<T>(n)) {} // inizializza il dato membro
   
explicit Person(int idx) // costr. per int
: name(nameFromIdx(idx)) {}  
 
private:  
std::string name;  
};  

Come nel caso di logAndAdd, il passaggio di un tipo intero diverso da int (ovvero std::size_t, short, long e così via) richiamerà l’overloading del costruttore per riferimenti universali invece che per int e ciò porterà a un errore di compilazione. Tuttavia, qui il problema è molto peggiore, poiché in Person vi sono molti più overloading rispetto a quanto si possa immaginare. L’Elemento 17 spiega che in condizioni appropriate, il C++ genererà i costruttori per copia e per spostamento e questo vale anche se la classe contiene un costruttore template-izzato che possa essere istanziato per produrre la signature del costruttore per copia o per spostamento. Se i costruttori per copia e per spostamento di Person vengono generati in questo modo, Person avrà il seguente aspetto:

class Person {
public:
template<typename T> // costr. per perfect-forward
explicit Person(T&& n)  
: name(std::forward<T>(n)) {}  
   
explicit Person(int idx); // costr. per int
   
Person(const Persons rhs); // costr. per copia
  // (generato dal compilatore)
Person(Person&& rhs); // costr. per spostamento
// (generato dal compilatore)
};  

Questo porta a un comportamento che può essere intuitivo solo per chi ha lavorato sui compilatori, tanto da aver dimenticato la sua essenza umana.

Person p(“Nancy”);  
   
auto cloneOfP(p); // crea una nuova Person da p;
  // incompilabile!

Qui stiamo tentando di creare una Person da un’altra Person, che sembra un caso davvero ovvio di costruzione per copie (p è un lvalue e così possiamo fugare tutti i dubbi che possiamo avere sul fatto che venga eseguita una “copia” attraverso un’operazione di spostamento). Ma questo codice non richiamerà il costruttore per copie. Richiamerà il costruttore perfect-forward. Tale funzione tenterà poi di inizializzare il dato membro std::string di Person con un oggetto di tipo Person (p). Poiché std::string non ha alcun costruttore che accetta un Person, il compilatore alzerà le mani esasperato, punendovi come sa fare: con lunghi e incomprensibili messaggi di errore per esprimere tutto il proprio disappunto.

“Perché”, potreste chiedervi, “viene richiamato il costruttore per il perfect-forward invece del costruttore per copia? Stiamo inizializzando una Person con un’altra Person!”. In effetti è così, ma i compilatori sono soliti seguire le regole del C++ e le regole impiegate qui si occupano proprio di governare la risoluzione delle chiamate alle funzioni in overloading.

Il compilatore ragiona nel seguente modo. cloneOfP viene inizializzato con un lvalue non-const (p) e ciò significa che il costruttore template-izzato può essere distanziato per prendere un lvalue non-const di tipo Person. Dopo tale istanziazione, la classe Person ha il seguente aspetto:

class Person {  
public:  
explicit Person(Person& n) // istanziata dal
: name(std::forward<Person&>(n)) {} // template
  // per perfect-forward
   
explicit Person(int idx); // come prima
Person(const Persons rhs); // costr. per copia
  // (generato dal compilatore)
};  
   

Nell’istruzione,

auto cloneOfP(p);

p potrebbe essere passato al costruttore per copia o al template istanziato. La chiamata del costruttore per copia richiederebbe l’aggiunta di const a p per coincidere con il tipo di parametri del costruttore per copia, mentre la chiamata del template istanziato non richiede questa aggiunta. L’overloading generato dal template è pertanto una corrispondenza migliore e così i compilatori faranno ciò che sono progettati per fare: generare una chiamata alla funzione che offre la migliore corrispondenza. La “copia” di lvalue non-const di tipo Person viene pertanto gestita dal costruttore per perfect-forward, non dal costruttore per copia.

Se cambiamo leggermente l’esempio, in modo che l’oggetto da copiare sia const, la situazione è completamente differente:

const Person cp(“Nancy”); // ora l’oggetto è const
   
auto cloneOfP(cp); // richiama il costr. per copia!

Poiché ora l’oggetto da copiare è const, si tratta di una corrispondenza esatta per il parametro del costruttore per copia. Il costruttore template-izzato può essere istanziato in modo da avere la stessa signature,

class Person {  
public:  
explicit Person(const Persons n); // istanziato dal
  // template
   
Person(const Person& rhs); // costr. per copia
  // (generato dal compilatore)
 
};  

ma questo non conta, poiché una delle regole di risoluzione degli overloading in C++ è che nelle situazioni in cui un’istanziazione a template e una funzione non-template (ovvero una normale funzione) rappresentano corrispondenze ugualmente buone per una chiamata a funzione, dev’essere preferita la funzione “normale”. Il costruttore per copia (una funzione normale) batte pertanto un template istanziato con la stessa signature.

Se vi state chiedendo perché i compilatori generino un costruttore per copia quando potrebbero istanziare un costruttore template-izzato per ottenere la signature che avrebbe il costruttore per copia, rileggete l’Elemento 17.

L’interazione fra i costruttori perfect-forward e le operazioni di copia e spostamento generate dal compilatore si complica ulteriormente quando entra in gioco l’ereditarietà. In particolare, le implementazioni convenzionali delle operazioni di copia e spostamento per le classi derivate hanno comportamenti piuttosto sorprendenti.

Ecco un esempio:

class SpecialPerson: public Person {  
public:  
SpecialPerson(const SpecialPerson& rhs) // costr. per copia; richiama
: Person(rhs) // il costr. perfect-forward
{ ... } // della classe base!
   
SpecialPerson(SpecialPerson&& rhs) // costr. per spostamento; richiama
: Person(std::move(rhs)) // il costr. perfect-forward
{ ... } // della classe base!
};  

Come indicato nel commento, i costruttori per copia e per spostamento della classe derivata non richiamano i costruttori per copia e per spostamento della classe base, bensì il costruttore perfect-forward della classe base! Per comprendere il perché, notate che le funzioni delle classi derivate stanno usando argomenti di tipo SpecialPerson, da passare alla loro classe base, quindi sono sensibili alle conseguenze dell’istanziazione template e della risoluzione dell’overloading dei costruttori della classe Person. Alla fine, il codice non verrà compilato, poiché non esiste un costruttore per std::string che accetta una SpecialPerson.

Spero, con questo, di avervi convinto che l’overloading per parametri che sono riferimenti universali dovrebbe essere evitato il più possibile. Ma se l’overloading per riferimenti universali è una cattiva idea, cosa fare quando occorre una funzione che inoltri la maggior parte dei tipi di argomenti, ma bisogna comunque trattare alcuni tipi di argomenti in modo particolare? Il problema può essere risolto in vari modi. Talmente tanti che gli abbiamo dedicato l’intero Elemento 27. Dunque vi basta continuare a leggere.

Argomenti da ricordare

•   L’overloading per riferimenti universali quasi sempre fa sì che questo venga richiamato più frequentemente del dovuto.

•   I costruttori perfect-forward sono particolarmente problematici, poiché, in genere, sono corrispondenze migliori rispetto ai costruttori per copia per gli lvalue non-const e possono attirare le chiamate dalla classe derivata ai costruttori per copia e spostamento della classe base.

Elemento 27 – Alternative all’overloading per riferimenti universali

L’Elemento 26 spiega che l’overloading per riferimenti universali può causare vari problemi, sia per le funzioni indipendenti sia per le funzioni membro (in particolare i costruttori). Inoltre, vi trovate esempi in cui tale overloading potrebbe essere utile. Se solo si comportasse nel modo che vorremmo! Questo Elemento esplora i modi per ottenere il comportamento desiderato, o tramite meccanismi che evitano di ricorrere all’overloading per riferimenti universali o impiegandoli in modi che vincolano i tipi di argomenti cui essi rispondono.

La discussione che segue sviluppa gli esempi introdotti nell’Elemento 26. Se non lo avete letto recentemente, può essere il caso di ripassarlo, prima di procedere.

Abbandonare l’overloading

Il primo esempio dell’Elemento 26, logAndAdd, è rappresentativo delle molte funzioni che aiutano ad aggirare i problemi dell’overloading per riferimenti universali utilizzando nomi differenti per quelli che dovrebbero essere overloading. I due overloading di logAndAdd, per esempio, possono essere suddivisi in logAndAddName e logAndAddNameIdx. Purtroppo, questo approccio non funziona per il secondo esempio che abbiamo considerato, il costruttore di Person, poiché il nome del costruttore viene corretto dal linguaggio. Ma, a parte questo, perché rinunciare completamente all’overloading?

Passaggio per const T&

Un’alternativa consiste nel tornare al C++98 e sostituire i passaggi per riferimento universale con altrettanti passaggi per riferimento lvalue a const. In realtà, questo è il primo approccio considerato nell’Elemento 26 (vedi a pagina 162). Il difetto di questo approccio è che il risultato non è efficiente come vorremmo. Sapendo ciò che sappiamo sull’interazione fra i riferimenti universali e l’overloading, rinunciare a un po’ di efficienza per guadagnare in termini di semplicità può essere un’alternativa meno disdicevole di quanto possa sembrare.

Passaggio per valore

Un approccio che spesso consente di migliorare le prestazioni senza aumentare la complessità consiste nel sostituire, in modo assolutamente non intuitivo, i parametri a passaggio per riferimento con altrettanti passaggi per valore. Questa tecnica recepisce il consiglio fornito nell’Elemento 41 di considerare il passaggio di oggetti per valore quando si sa che essi verranno copiati; dunque si rimanda a tale Elemento per una discussione dettagliata del funzionamento di questo meccanismo e della sua efficienza. Qui vedremo solo come utilizzare la tecnica nell’esempio di Person:

class Person {  
public:  
explicit Person(std::string n) // sostituisce il costr. di T&& ctor;
: name(std::move(n)) {} // vedi Elemento 41 per l’uso di std::move
   
explicit Person(int idx) // come prima
: name(nameFromIdx(idx)) {}  
 
private:  
std::string name;  
};  

Poiché non vi è alcun costruttore di std::string che accetta solo un intero, tutti gli argomenti int (o comunque di tipo simile a int) di un costruttore di Person (ovvero std::size_t, short, long) vengono incanalati nell’overloading per int. Analogamente, tutti gli argomenti di tipo std::string (e tutto ciò che da std::string può essere creato, come le stringhe letterali come “Ruth”) vengono passati al costruttore che accetta una std::string. Dunque nessuna sorpresa per i chiamanti. Potete però pensare, immagino, che qualcuno possa sorprendersi del fatto che utilizzando 0 o NULL per indicare un puntatore nullo si richiamerebbe l’overloading per int; ma, a questo punto, dovreste consultare l’Elemento 8 e continuare a leggerlo finché non fugherete ogni dubbio sull’uso di 0 o NULL come puntatori nulli.

L’approccio tag dispatch

Né il passaggio per riferimento lvalue a const né il passaggio per valore offrono il supporto per il perfect-forward. Se la motivazione per ricorrere a un riferimento universale è il perfect-forward, dobbiamo utilizzare un riferimento universale, non abbiamo altra scelta. Tuttavia non vogliamo abbandonare del tutto l’overloading. Pertanto, se non vogliamo rinunciare all’overloading e neppure ai riferimenti universali, come possiamo evitare l’overloading sui riferimenti universali?

In realtà non è così difficile. Le chiamate alle funzioni in overloading vengono risolte osservando tutti i parametri di tutti gli overloading, così come tutti gli argomenti nel punto della chiamata, scegliendo poi la funzione che rappresenta la corrispondenza migliore (tenendo in considerazione tutte le combinazioni parametro/argomento). Un parametro riferimento universale, generalmente, fornisce una corrispondenza esatta per tutto ciò che gli viene passato, ma se il riferimento universale fa parte di un elenco di parametri che contiene altri parametri che non sono riferimenti universali, alcune corrispondenze sufficientemente imprecise sui parametri che non sono riferimenti universali possono evitare che venga impiegato un overloading per riferimenti universali. Questo è ciò su cui si basa l’approccio tag dispatch. Un esempio aiuterà a comprendere la descrizione che segue.

Applicheremo la tecnica tag dispatch all’esempio logAndAdd presentato a pagina 162. Ecco il codice di tale esempio:

std::multiset<std::string> names; // struttura dati globale
   
template<typename T> // crea la voce log e aggiunge
void logAndAdd(T&& name) // name alla struttura dati
{  
auto now = std::chrono::system clock::now();
log(now, “logAndAdd”);  
names.emplace(std::forward<T>(name));  
}  
   

Di per se stessa, questa funzione si comporta bene, ma dovendo introdurre l’overload che accetta un int utilizzato per ricercare gli oggetti per indice, ci ritroviamo alla situazione problematica dell’Elemento 26. L’obiettivo di questo Elemento consiste proprio nell’evitarla. Invece di aggiungere l’overload, reimplementeremo logAndAdd per delegare il lavoro ad altre due funzioni: una per i valori interi e una per tutto il resto. logAndAdd stessa accetterà tutti i tipi di argomenti, sia interi sia di altro tipo.

Le due funzioni che svolgono il lavoro vero e proprio si chiameranno logAndAddImpl, ovvero utilizzeremo l’overloading. Una delle funzioni accetterà un riferimento universale. Pertanto, avremo sia l’overloading sia i riferimenti universali. Ma entrambe le funzioni accetteranno anche un secondo parametro, che indica se l’argomento passato è di tipo intero. Questo secondo parametro è ciò che ci impedirà di precipitare nel problema della “voracità” descritto nell’Elemento 26, poiché faremo in modo che il secondo parametro determini quale overload selezionare.

Mi sembra di sentirvi brontolare: “Bla, bla, bla... Smettila di blaterare e mostraci il codice!”. Nessun problema. Ecco una versione quasi-corretta di logAndAdd:

template<typename T>  
void logAndAdd(T&& name)  
{  
logAndAddImpl(std::forward<T>(name),  
std::is integral<T>()); // non correttissima
}  

Questa funzione inoltra il proprio parametro a logAndAddImpl, ma passa anche un argomento che indica se il tipo di tale parametro (T) è intero. Quanto meno, questo è ciò che dovrebbe accadere. Per gli argomenti interi che sono rvalue, è anche ciò che succede. Ma, come descritto nell’Elemento 28, se al riferimento universale name viene passato un argomento lvalue, il tipo dedotto per T sarà un riferimento lvalue. Pertanto, se a logAndAdd viene passato un lvalue di tipo int, T verrà dedotto come int&. Questo non è un tipo intero, poiché i riferimenti non sono interi. Ciò significa che std::is_integral<T> sarà false per ogni argomento lvalue, anche se, in effetti, l’argomento rappresenta un valore intero.

Il fatto di aver individuato il problema ci consente di trovare una soluzione, poiché la sempre disponibile Libreria Standard del C++ ha un type trait (vedi Elemento 9), std ::remove_reference, che fa ciò che suggerisce il nome, ossia quello di cui abbiamo bisogno: rimuovere da un tipo ogni qualificatore di riferimento. Il modo corretto per scrivere logAndAdd è pertanto:

template<typename T>

void logAndAdd(T&& name)

{

logAndAddImpl(

std::forward<T>(name),

std::is_integral<typename std::remove_reference<T>::type>()

);

}

Questa forma è finalmente corretta (il C++14, è più compatto: si puà usare std::remove_reference_t<T> al posto del testo evidenziato; per i dettagli, consultate l’Elemento 9).

Tenendo conto di tutto questo, possiamo rivolgere l’attenzione alla funzione chiamata, logAndAddImpl. Vi sono due overload e il primo è applicabile solo ai tipi non interi (ovvero ai tipi per i quali std::is_integral<typename std::remove_reference<T>::type>false):

template<typename T> // non-integral
void logAndAddImpl(T&& name, std::false_type) // argomento:
{ // lo aggiunge
auto now = std::chrono::system_clock::now(); // alla struttura dati
log(now, “logAndAdd”); // globale
names.emplace(std::forward<T>(name));  
}  

Si tratta di codice piuttosto semplice, una volta compresi i meccanismi su cui si basa il parametro evidenziato. Teoricamente, logAndAdd passa un valore booleano a logAndAddImpl, indicando se a logAndAdd, è stato passato il tipo intero, ma true e false sono valori runtime e noi dobbiamo utilizzare una risoluzione degli overload (un’attività di compilazione) per scegliere l’overload corretto di logAndAddImpl. Ciò significa che abbiamo bisogno di un tipo che corrisponda a true e di un tipo differente che corrisponda a false. Questa esigenza è talmente comune che la Libreria Standard fornisce ciò di cui c’è bisogno attraverso i nomi std::true_type e std::false_type. L’argomento passato a logAndAddImpl da logAndAdd è un oggetto di un tipo che eredita da std::true_type se T è intero e da std::false_type se T non è intero. Il risultato pratico è che questo overload di logAndAddImpl è un candidato utilizzabile per la chiamata in logAndAdd solo se T non è un tipo intero.

Il secondo overload copre il caso opposto: quando T è un tipo intero. In tal caso, logAndAddImpl trova semplicemente il nome corrispondente all’indice passato e poi passa il nome a logAndAdd:

std::string nameFromIdx(int idx); // vedi Elemento 26
void logAndAddImpl(int idx, std::true_type) // argomento
{ // intero: cerca
logAndAdd(nameFromIdx(idx)); // il nome e
} // richiamalogAndAdd
  // con tale nome

Facendo in modo che logAndAddImpl per un indice ricerchi il nome corrispondente e lo passi a logAndAdd (da cui verrà inoltrato con std::forward all’altro overload di logAndAddImpl), evitiamo la necessità di inserire il codice di log in entrambi gli overload di logAndAddImpl.

Utilizzando questa tecnica, i tipi std::true_type e std::false_type sono semplici “tag”, il cui unico scopo è quello di forzare la risoluzione dell’overloading in modo che proceda esattamente come desideriamo. Notate che non assegniamo neppure un nome a questi parametri. Non hanno alcuno scopo runtime e, in realtà, speriamo che i compilatori riconosceranno che i parametri tag non vengono utilizzati e li ottimizzeranno escludendoli dall’immagine d’esecuzione del programma (alcuni compilatori lo fanno, ma non sempre). La chiamata alle funzioni di implementazione dell’overloading all’interno di logAndAdd, distribuisce (dispatch) il lavoro all’overload corretto, facendo sì che venga creato l’oggetto tag corretto. Da qui il nome di questa tecnica: tag dispatch. Si tratta di un elemento costitutivo standard della metaprogrammazione a template e più osserverete il codice delle librerie C++ di oggi, più lo incontrerete.

Per i nostri scopi, ciò che è importante relativamente alla tecnica tag dispatch non è tanto come funziona, quanto come essa permetta di combinare riferimenti universali e overloading, eliminando il problema descritto nell’Elemento 26. La funzione di dispatch, logAndAdd, accetta un parametro riferimento universale non vincolato, ma questa funzione non subisce overloading. Le funzioni di implementazione, logAndAddImpl, subiscono invece overloading e una di esse accetta un parametro che è un riferimento universale, ma la risoluzione delle chiamate di queste funzioni dipende non solo dal parametro riferimento universale, ma anche dal parametro tag. E i valori del tag sono progettati in modo che un solo overload sia una corrispondenza utilizzabile. Il risultato è che è il tag a determinare quale overload richiamare. A questo punto, il fatto che il parametro riferimento universale generi sempre una corrispondenza esatta per il proprio argomento diviene ininfluente.