Vincolare i template che accettano riferimenti universali

Un elemento tipico della tecnica tag dispatch è l’esistenza di un’unica funzione (senza overloading) come API client. Quest’unica funzione distribuisce il lavoro alle funzioni di implementazione. La creazione di una funzione dispatch senza overload è normalmente semplice, ma il secondo problema considerato nell’Elemento 26, quello del costruttore perfect-forward della classe Person (vedi a pagina 165), è un’eccezione. I compilatori possono generare specifici costruttori per copia e per spostamento e pertanto anche scrivendo un solo costruttore e utilizzandovi la tecnica tag dispatch, alcune chiamate al costruttore potrebbero essere gestite da funzioni generate dal compilatore, che aggirano il nostro sistema tag dispatch.

In verità, il vero problema non è che le funzioni generate dal compilatore possano aggirare la struttura del tag dispatch, è che questo non capita sempre. Si desidera (praticamente sempre) che il costruttore per copia di una classe gestisca le richieste di copiare lvalue di tale tipo; ma, come illustra l’Elemento 26, fornendo un costruttore che accetta un riferimento universale accade che nella copia di lvalue non-const venga richiamato il costruttore per riferimenti universali e non il costruttore per copia. Tale Elemento spiega anche che quando una classe base dichiara un costruttore perfect-forward, tale costruttore verrà tipicamente richiamato quando le classi derivate implementano i propri costruttori per copia e per spostamento in modo convenzionale, anche se il comportamento corretto stabilirebbe che venissero richiamati i costruttori per copia e per spostamento della classe base.

Per situazioni di questo tipo, in cui una funzione in overloading che accetta un riferimento universale è più “vorace” di quanto dovrebbe, ma non abbastanza “aggressiva” da agire come unica funzione di dispatch, la tecnica tag dispatch non è più la soluzione più appropriata. Occorre impiegare una tecnica differente, che consenta di determinare le condizioni in cui possa essere impiegato il template di funzione di cui fa parte il riferimento universale. C’è bisogno di std::enable_if.

std::enable_if fornisce un modo per costringere i compilatori a comportarsi come se un determinato template non esistesse. Tali template si dicono disattivati. Per default, tutti i template sono attivi, ma un template che utilizza std::enable_if è attivo solo se è soddisfatta la condizione specificata da std::enable_if. Nel nostro caso, vorremmo attivare il costruttore perfect-forward di Person solo se il tipo passato non è Person. Se, invece, il tipo passato è Person, vogliamo disattivare il costruttore perfect-forward (ovvero far sì che i compilatori lo ignorino), per far sì che la chiamata venga gestita dal costruttore per copia o per spostamento, che è ciò che vogliamo quando un oggetto Person viene inizializzato con un altro Person.

Il modo per esprimere questo concetto non è particolarmente difficoltoso, ma la sintassi non è proprio esaltante, specialmente le prime volte; pertanto cercheremo di semplificarla. Partiremo dalla condizione di std::enable_if. Ecco la dichiarazione per il costruttore perfect-forward di Person, che mostra solo quanto è necessario per poter utilizzare std::enable_if. Mostrerò solo la dichiarazione di questo costruttore, poiché l’uso di std::enable_if non ha alcun effetto sull’implementazione della funzione. L’implementazione rimane la stessa descritta nell’Elemento 26.

class Person {

public:

template<typename T,

typename = typename std::enable_if<condition>::type>

explicit Person(T&& n);

};

Per comprendere esattamente ciò che accade nel testo evidenziato, purtroppo devo rimandarvi ad altre fonti, poiché la descrizione dei dettagli richiederebbe troppo tempo. Nelle ricerche, utilizzate i termini “SFINAE” e “std::enable_if”, poiché SFINAE è la tecnologia che consente il funzionamento di std::enable_if. Qui voglio concentrarmi sul modo in cui esprimere la condizione che controllerà se questo costruttore è attivo.

La condizione che vogliamo specificare è che T non sia Person, ovvero che il costruttore template-izzato debba essere attivato solo se T è un tipo diverso da Person. Grazie a un type trait che determina se due tipi coincidono (std::is_same), sembrerebbe che la condizione che vogliamo sia !std::is_same<Person, T>::value (notate il “!all’inizio dell’espressione, perché vogliamo che Person e T non siano la stessa cosa). Questo è più o meno ciò che vogliamo, ma non è del tutto corretto, poiché, come descritto nell’Elemento 28, il tipo dedotto per un riferimento universale inizializzato con un lvalue è sempre un riferimento lvalue. Ciò significa che per codice come il seguente,

Person p(“Nancy”);  
auto cloneOfP(p); // inizializza da lvalue

il tipo T nel costruttore universale verrà dedotto come Person&. I tipi Person e Person& non sono la stessa cosa e il risultato di std::is_same rifletterà questo fatto: std ::is_same<Person, Person&>::value sarà quindi false.

Se riflettiamo bene su ciò che intendiamo quando diciamo che il costruttore template-izzato in Person debba essere attivato solo se T non è Person, ci accorgiamo che quando consideriamo T vogliamo ignorare i seguenti fatti.

•   Che sia un riferimento. Per lo scopo di determinare se il costruttore per riferimenti universali debba essere attivato, i tipi Person, Person& e Person&& sono la stessa cosa di Person.

•   Che sia const o volatile. Per quel che ci riguarda, una const Person, una volatile Person e una const volatile Person sono sempre una Person.

Ciò significa che abbiamo bisogno di un modo per eliminare da T tutti i riferimenti, i const e i volatile prima di controllare se il suo tipo è lo stesso di Person. Ancora una volta, la Libreria Standard ci dà ciò di cui abbiamo bisogno sotto forma di un type trait. Tale trait è std::decay. In pratica, std::decay<T>::type è la stessa cosa di T, tranne il fatto che sono stati rimossi i riferimenti e i qualificatori const e volatile (qualificatori cv). In realtà le cose sono più complesse, poiché std::decay, come suggerisce il nome, trasforma anche gli array e i tipi funzione in puntatori (vedi Elemento 1), ma per gli scopi di questa discussione, std::decay si comporta come abbiamo descritto. La condizione che vogliamo impiegare per controllare se il nostro costruttore è attivato, pertanto, è

!std::is_same<Person, typename std::decay<T>::type>::value

ovvero che Person non sia lo stesso tipo di T, ignorando i riferimenti e i qualificatori const e volatile (come descritto nell’Elemento 9, il typename davanti a std::decay è obbligatorio, poiché il tipo std::decay<T>::type dipende dal parametro del template, T).

Inserendo questa condizione in std::enable_if e formattando il risultato per renderlo più comprensibile, si ottiene questa dichiarazione del costruttore perfect-forward di Person:

class Person {

public:

template<

typename T,

typename = typename std::enable_if<

!std::is_same<Person,

                       typename std::decay<T>::type

                >::value

         >::type

>

explicit Person(T&& n);

};

Se non avete mai visto niente di simile prima d’ora, siete davvero fortunati. C’è un motivo per cui ho presentato questa tecnica per ultima. Quando potete utilizzare uno degli altri meccanismi per evitare di impiegare insieme riferimenti universali e overloading (e quasi sempre è possibile), dovreste farlo. In ogni caso, una volta fatta l’abitudine alla sintassi funzionale e alla proliferazione di parentesi angolari, le cose non sono poi così complesse. Inoltre, questo è proprio il comportamento desiderato. Data la dichiarazione precedente, la costruzione di una Person da un’altra Person (che sia lvalue o rvalue, const o non-const, volatile o non-volatile) non richiamerà mai il costruttore che accetta un riferimento universale.

È davvero così? Davvero siamo a cavallo?

Veramente no. C’è una situazione evidenziata nell’Elemento 26 che continua a sfuggirci e dobbiamo occuparcene.

Supponete che una classe derivata da Person implementi le operazioni di copia e spostamento in modo convenzionale:

   
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!
   
   
};  

Questo è lo stesso codice mostrato nell’Elemento 26 (a pagina 168), compresi i commenti, che, purtroppo, rimangono validi. Quando copiamo o spostiamo un oggetto SpecialPerson, ci aspettiamo di copiare o spostare le parti della sua classe base utilizzando i costruttori per copia e per spostamento della classe base. Ma, in queste funzioni, passiamo gli oggetti SpecialPerson ai costruttori della classe base, e poiché SpecialPerson non è la stessa cosa di Person (neppure dopo l’applicazione di std:: decay), il costruttore per riferimenti universali nella classe base è attivo e, se tutto va bene, si istanzia per trovare una corrispondenza esatta per un argomento SpecialPerson.

Questa corrispondenza esatta è migliore rispetto alle conversioni da derivata a base che sarebbero necessarie per associare gli oggetti SpecialPerson ai parametri Person nei costruttori per copia e per spostamento di Person. Pertanto, con il codice che abbiamo a questo punto, la copia e lo spostamento di oggetti SpecialPerson utilizzerebbero il costruttore perfect-forward di Person per copiare o spostare parti della classe base! Ci sembra di tornare all’Elemento 26.

La classe derivata sta semplicemente seguendo le normali regole per l’implementazione dei costruttori per copia e spostamento della classe derivata; pertanto la correzione di questo problema riguarda la classe base e, in particolare, la condizione che controlla se è attivo il costruttore per riferimenti universali di Person. A questo punto comprendiamo che non vogliamo attivare il costruttore a template solo per un qualsiasi tipo di argomento diverso da Person, ma anche per ogni tipo di argomento diverso da Person o anche da un tipo derivato da Person. Dannata ereditarietà!

Non vi sorprenderete di sapere che fra i type trait standard ve ne è uno che determina se un tipo è derivato da un altro. Si chiama std::is_base_of. Dunque: std::is_base_of<T1, T2>::value è true se T2 è un tipo derivato da T1. I tipi sono considerati derivati da se stessi e, pertanto, std::is_base_of<T, T>::value è true. Questo è comodo, poiché vogliamo modificare la condizione che controlla l’attivazione del costruttore perfect-forward di Person in modo che tale costruttore sia attivo solo se il tipo T, dopo aver eliminato tutti i riferimenti dei qualificatori const e volatile, non è né Person né una classe derivata da Person. Utilizzando std::is_base_of invece di std::is_same, otteniamo ciò di cui abbiamo bisogno:

class Person {

public:

template<

typename T,

typename = typename std::enable_if<

       !std::is_base_of<Person,

                        typename std::decay<T>::type

                >::value

                >::type

>

explicit Person(T&& n);

};

Finalmente abbiamo concluso. Ammettendo di scrivere codice in C++11, le cose stanno così. Se invece scriviamo in C++14, questo codice funzionerà, ma possiamo impiegare template alias per std::enable_if e std::decay, per sbarazzarci del typename e del ::type, ottenendo codice più lineare:

class Person { // C++14
public:  
template<  
typename T,  
typename = std::enable if t< // meno codice qui
           !std::is_base_of<Person,  
                        std::decay t<T> // e qui
                       >::value  
       > // e qui
>  
explicit Person(T&& n);  
 
};  

Devo ammetterlo: vi ho mentito. Il discorso non è concluso, ma quasi ci siamo.

Abbiamo visto come utilizzare std::enable_if per disabilitare selettivamente il costruttore per riferimenti universali di Person per quei tipi di argomenti che vogliamo siano gestiti dai costruttori per copia e per spostamento della classe, ma non abbiamo ancora visto come distinguere gli argomenti interi e non-interi. Dopotutto, questo era il nostro primo obiettivo; il problema dell’ambiguità del costruttore è stato semplicemente qualcosa che abbiamo incontrato lungo il percorso.

Tutto ciò che dobbiamo fare (e con questo abbiamo davvero concluso) è (1) aggiungere un costruttore in overloading di Person per gestire gli argomenti interi e (2) vincolare ulteriormente il costruttore template-izzato, in modo che sia disabilitato per questi argomenti. Aggiungiamo questi ultimi “ingredienti” al “piatto” che abbiamo cucinato finora, cuociamo il tutto a fuoco lento e godiamoci il profumo del successo:

class Person {

public:

template<

   typename T,

   typename = std::enable_if_t<

   !std::is_base_of<Person, std::decay_t<T>>::value

    &&

       !std::is_integral<std::remove_reference_t<T»::value

     >

>

explicit Person(T&& n) // costr. per std::strings e
: name(std::forward<T>(n)) // argomenti convertibili
{ ... } // in std::strings
   
explicit Person(int idx) // costr. per argomenti interi
: name(nameFromIdx(idx))  
{ ??? }  
   
// costr. per copia e spostamento ecc.
   
private:  
std::string name;
};  

Voilà! Una vera bellezza! D’accordo... una bellezza per chi ha una passione per la metaprogrammazione a template, ma rimane il fatto che questo approccio non solo risolve tutti i problemi, ma ha anche una certa eleganza. Poiché utilizza il perfect forward, offre la massima efficienza e poiché controlla la combinazione riferimenti universali/overloading piuttosto che proibirla, questa tecnica può essere applicata in tutti quei casi in cui l’overloading è inevitabile, come nel caso dei costruttori.

Compromessi

Le prime tre tecniche considerate in questo Elemento (abbandono dell’overloading, passaggio per const T& e passaggio per valore) specificano un tipo per ciascun parametro nella funzione (o nelle funzioni) che verrà richiamata. Le ultime due tecniche, tag dispatch e vincolo dei template, usano il perfect-forward, pertanto non specificano il tipo dei parametri. Questa decisione fondamentale (specificare o non specificare un tipo) ha delle conseguenze.

Come regola generale, il perfect-forward è più efficiente, poiché evita di creare oggetti temporanei con il solo scopo di conformarsi al tipo della dichiarazione del parametro. Nel caso del costruttore di Person, il perfect-forward permette a una stringa letterale come “Nancy” di essere inoltrata al costruttore di std::string all’interno di Person, mentre le tecniche che non utilizzano il perfect-forward devono creare un oggetto temporaneo std::string a partire dalla stringa letterale e soddisfare la specifica dei parametri per il costruttore di Person.

Ma il perfect-forward ha dei difetti. Uno è che alcuni tipi di argomenti non possono essere inoltrati perfettamente, anche se possono essere passati a funzioni che accettano tipi specifici. L’Elemento 30 esplora questi casi di fallimento del perfect-forward.

Un secondo problema è la comprensibilità dei messaggi d’errore che compaiono quando i client passano argomenti non validi. Supponete, per esempio, che un client che crea un oggetto Person passi una stringa letterale costituita da “caratteri” char16_t (un tipo introdotto in C++11 per rappresentare caratteri a 16 bit) invece di char (ovvero ciò di cui è costituita una std::string):

Person p(u”Konrad Zuse”); // “Konrad Zuse” è costituita da
  // caratteri di tipo const char16_t

Con i primi tre approcci esaminati in questo Elemento, i compilatori vedranno che i costruttori disponibili accettano int o std::string e produrranno un messaggio d’errore più o meno chiaro, che spiega che non esiste alcuna conversione da char16_t[12] a int o std::string.

Con un approccio basato sul perfect-forward, invece, l’array di const char16_t viene associato al parametro del costruttore senza alcun problema. Da qui viene inoltrato al costruttore del dato membro std::string di Person ed è solo a questo punto che viene scoperto il problema di corrispondenza fra il chiamante passato (un array di const char16_t) e ciò che è richiesto (ogni tipo accettabile per il costruttore di std::string). Il messaggio d’errore risultante è, quanto meno, impressionante. Uno dei compilatori che utilizzo abitualmente produce un messaggio d’errore di ben 160 righe.

In questo esempio, il riferimento universale viene inoltrato una sola volta (dal costruttore di Person al costruttore di std::string), ma più complesso è il sistema, più è probabile che un riferimento universale venga inoltrato attraverso vari strati di chiamate a funzione prima di arrivare finalmente in un punto che determina se il tipo degli argomenti è accettabile. Più volte il riferimento universale viene inoltrato, più complesso sarà il messaggio d’errore che dice che qualcosa non va. Molti sviluppatori trovano che questo problema, da solo, basta a riservare i parametri riferimento universale alle interfacce quando le prestazioni sono un fattore importante.

Nel caso di Person, sappiamo che il parametro riferimento universale della funzione che esegue l’inoltro si suppone che sia un inizializzatore per una std::string; pertanto possiamo utilizzare static_assert per verificare che possa giocare tale ruolo. Il type trait std::is_constructible svolge un test in fase di compilazione per determinare se un oggetto di un tipo può essere costruito a partire da un oggetto (o da un insieme di oggetti) di un tipo (o di un insieme di tipi) differente, pertanto l’asserzione è facile da scrivere:

class Person {

public:

   template<         // come prima

      typename T,

      typename = std::enable_if_t<

        !std::is base of<Person, std::decay t<T>>::value

        &&

        !std::is_integral<std::remove_reference_t<T>>::value

    >

  >

  explicit Person(T&& n)

  : name(std::forward<T>(n))

  {

     // asserisce che una std::string possa essere creata da un oggetto T

     static_assert(

       std::is_constructible<std::string, T>::value,

       “Parameter n can’t be used to construct a std::string”

  );

                              // qui va il solito costruttore

}

…                 // resto della classe Person (come prima)

};

Questo fa sì che venga prodotto il messaggio d’errore specificato se il codice client tenta di creare una Person da un tipo che non può essere utilizzato per costruire una std ::string. Sfortunatamente, in questo esempio static_assert è nel corpo del costruttore, ma il codice di inoltro, facendo parte dell’elenco di inizializzazione del membro, la precede. Con i compilatori utilizzati da me, il risultato è che il messaggio, questa volta comprensibile, derivante da static_assert appare solo dopo che i normali messaggi d’errore (oltre 160 righe di messaggi) sono stati emessi.

Argomenti da ricordare

•   Fra le alternative alla combinazione riferimenti universali/overloading vi sono l’uso di nomi distinti per le funzioni, il passaggio di parametri per riferimento lvalue a const, il passaggio di parametri per valore e la tecnica tag dispatch.

•   Il vincolo dei template tramite std::enable_if consente l’uso congiunto di riferimenti universali e overloading, ma controlla le condizioni in cui i compilatori possono utilizzare gli overloading dei riferimenti universali.

•   I parametri riferimento universale spesso offrono dei vantaggi in termini di efficienza, ma in genere portano degli svantaggi in termini di usabilità.

Elemento 28 – Il collasso dei riferimenti

Nell’Elemento 23 abbiamo visto che quando a una funzione template viene passato un argomento, il tipo dedotto per il parametro template codifica il fatto che l’argomento sia un lvalue o un rvalue. In tale Elemento non menzioniamo però il fatto che ciò si verifica solo quando l’argomento viene utilizzato per inizializzare un parametro che è un riferimento universale, ma tale omissione ha un buon motivo: i riferimenti universali vengono introdotti solo nell’Elemento 24. Insieme, queste due osservazioni relative ai riferimenti universali e alla codifica lvalue/rvalue significano che per questo template,

template<typename T>
void func(T&& param);

il parametro template dedotto T codifica se l’argomento passato a param è un lvalue oppure un rvalue.

Il meccanismo di codifica è semplice. Quando come argomento viene passato un lvalue, T viene dedotto come un riferimento lvalue. Quando viene passato un rvalue, T viene dedotto come un non-riferimento (notate l’asimmetria: gli lvalue vengono codificati come riferimenti lvalue, mentre gli rvalue vengono codificati come non-riferimenti). Pertanto:

Widget widgetFactory(); // funzione che restituisce un rvalue
   
Widget w; // una variabile (un lvalue)
   
func(w); // richiama func con un lvalue; T dedotto
  // come Widget&
   
func(widgetFactory()); // richiama func con un rvalue; T dedotto
  // come Widget

In entrambe le chiamate a func, viene passato un Widget, ma poiché un Widget è un lvalue e un altro è un rvalue, per il parametro template T vengono dedotti tipi differenti. Questo, come vedremo presto, è ciò che determina se i riferimenti universali divengono riferimenti rvalue oppure riferimenti lvalue e anche il meccanismo mediante il quale std::forward svolge proprio lavoro.

Prima di poter osservare più da vicino std::forward e i riferimenti universali, occorre notare che i riferimenti di riferimenti sono illegali in C++. Se doveste dichiararne uno, il compilatore non ve la farà passare liscia:

int x;  
 
auto& & rx = x; // errore! Non si può dichiarare un riferimento di un riferimento

Ma considerate ciò che accade quando a un template di funzione che accetta un riferimento universale viene passato un lvalue:

template<typename T>  
void func(T&& param); // come prima
   
func(w); // richiama func con un lvalue;
  // T dedotto come Widget&

Se prendiamo il tipo dedotto per T (per esempio Widget&) e lo utilizziamo per istanziare il template, otteniamo:

void func(Widget& && param);

ovvero un riferimento a un riferimento. Tuttavia il compilatore non se ne lamenta. Sappiamo dall’Elemento 24 che poiché il riferimento universale param è stato inizializzato con un lvalue, il tipo di param si suppone che sia un riferimento a un lvalue, ma come arriva il compilatore al risultato di prendere il tipo dedotto per T e sostituirlo nel template come il seguente, che risulta essere la signature finale della funzione?

void func(Widget& param);

La risposta è legata al collasso dei riferimenti. In pratica, noi non possiamo dichiarare riferimenti a riferimenti, mentre i compilatori possono produrli, in particolari contesti; fra questi, vi è l’istanziazione di template. Quando i compilatori generano riferimenti a riferimenti, ciò che accade successivamente è stabilito dal collasso dei riferimenti.

Vi sono due tipi di riferimenti (lvalue e rvalue), pertanto vi sono quattro possibili combinazioni fra riferimenti (lvalue a lvalue, lvalue a rvalue, rvalue a lvalue e rvalue a rvalue). Se sorge un riferimento di un riferimento in un contesto in cui questo è permesso (ovvero durante l’istanziazione di un template), i riferimenti collassano in un unico riferimento in base alla seguente regola:

Se almeno uno dei due riferimenti è un riferimento lvalue, il risultato è un riferimento lvalue. Altrimenti (ovvero se entrambi sono riferimenti rvalue), il risultato è un riferimento rvalue.

Nell’esempio precedente, la sostituzione del tipo dedotto Widget& nel template func fornisce un riferimento rvalue a un riferimento lvalue e la regola di collasso dei riferimenti ci dice che il risultato è un riferimento lvalue.

Il collasso dei riferimenti è un elemento fondamentale per il funzionamento di std ::forward. Come descritto nell’Elemento 25, std::forward si applica ai parametri riferimento universale e dunque un caso d’uso comune ha il seguente aspetto:

template<typename T>  
void f(T&& fParam)  
{  
// fa la stessa cosa
   
someFunc(std::forward<T>(fParam)); // inoltra fParam
} // a someFunc
   

Poiché fParam è un riferimento universale, sappiamo che il parametro tipo T codifica il fatto che l’argomento passato a f (ovvero l’espressione utilizzata per inizializzare fParam) sia un lvalue o un rvalue. Il compito di std::forward è quello di convertire fParam (un lvalue) in un rvalue se (e solo se) T codifica il fatto che l’argomento passato f era un rvalue, ovvero se T è di un tipo non-riferimento.

Ecco come std::forward può essere implementata per fare proprio questo:

   
template<typename T> // nel
T&& forward(typename // namespace
remove_reference<T>::type& param) // std
{  
return static_cast<T&&>(param);  
}  
   

Non è esattamente conforme allo standard (ho omesso alcuni dettagli dell’interfaccia), ma le differenze sono irrilevanti per gli scopi della discussione, ovvero di comprendere come si comporta std::forward.

Supponete che l’argomento passato a f sia un lvalue di tipo Widget. T verrà dedotto come Widget& e la chiamata a std::forward lo istanzierà come std::forward<Widget&>. Inserendo Widget& nell’implementazione di std::forward si ottiene:

WidgetS && forward(typename

remove_reference<Widget&>::type& param) {

return static_cast<Widget& &&>(param); }

Il type trait std::remove_reference<Widget&>::type fornisce Widget (vedi Elemento 9), pertanto std::forward diviene:

Widget& && forward(Widget& param)

{ return static_cast<Widget& &&>(param); }

Il collasso dei riferimenti viene applicato anche al tipo restituito e alla conversione, e il risultato è la versione finale di std::forward per la chiamata:

Widget& forward(Widget& param) // sempre nel
{ return static_cast<WidgetS>(param); } // namespace std

Come potete vedere, quando a un template di funzione f viene passato un argomento lvalue, std::forward viene istanziato per accettare e restituire un riferimento lvalue. La conversione all’interno di std::forward non fa nulla, poiché il tipo di param è già Widget&, pertanto la sua conversione in Widget& non ha alcun effetto. Un argomento lvalue passato a std::forward restituirà pertanto un riferimento lvalue. Per definizione, i riferimenti lvalue sono lvalue e pertanto il passaggio di un lvalue a std::forward fa sì che venga restituito un rvalue, proprio come deve essere.

Ora supponete che l’argomento passato a f sia un rvalue di tipo Widget. In questo caso, il tipo dedotto per T, il parametro del tipo di f, sarà semplicemente Widget. La chiamata all’interno di f a std::forward sarà pertanto std::forward<Widget>. Sostituendo Widget a T nell’implementazione di std::forward si ottiene:

Widget&& forward(typename

remove_reference<Widget>::type& param)

{ return static_cast<Widget&&>(param); }

Applicando std::remove_reference al tipo non-riferimento Widget si ottiene lo stesso tipo di partenza (widget), pertanto std::forward diviene:

Widget&& forward(Widget& param)

{ return static_cast<Widget&&>(param); }

Qui non vi sono riferimenti a riferimenti, quindi non si verifica alcun collasso fra riferimenti e questa è la versione istanziata finale di std::forward per la chiamata.

I riferimenti rvalue restituiti dalle funzioni sono definiti per essere rvalue, pertanto, in questo caso, std::forward trasformerà il parametro fParam di f (un lvalue) in un rvalue. Il risultato finale è che un argomento rvalue passato a f verrà inoltrato a some-Func come un rvalue, che è esattamente ciò che deve accadere.

In C++14, l’esistenza di std::remove_reference_t consente di implementare std ::forward in modo un po’ più compatto:

   
template<typename T> // C++14; sempre nel
T&& forward(remove_reference_t<T>& param) // namespace std
{  
return static_cast<T&&>(param);  
}  

Il collasso dei riferimenti si verifica in quattro contesti. Il primo, più comune, è l’istanziazione di template. Il secondo è la generazione del tipo per le variabili auto. I dettagli sono sostanzialmente gli stessi per i template, poiché la deduzione del tipo per le variabili auto coincide essenzialmente con la deduzione del tipo per i template (vedi Elemento 2). Considerate ancora una volta quest’esempio presentato in precedenza nel nell’Elemento:

template<typename T>  
void func(T&& param);  
   
Widget widgetFactory(); // funzione che restituisce un rvalue
   
Widget w; // una variabile (un lvalue)
   
func(w); // richiama func con un lvalue; T dedotto
  // come Widget&
   
func(widgetFactory()); // richiama func con un rvalue; T dedotto
  // come Widget

Questo può essere simulato con un auto. La dichiarazione

auto&& w1 = w;

inizializza w1 con un lvalue, deducendo pertanto il tipo Widget& per auto. Inserendo Widget& per auto nella dichiarazione per w1 si ottiene questo codice, che rappresenta un riferimento di riferimento,

WidgetS SS w1 = w;

che, dopo il collasso dei riferimenti, diviene

WidgetS w1 = w;

Come risultato, w1 è un riferimento lvalue.

Al contrario, questa dichiarazione,

auto&& w2 = widgetFactory();

inizializza w2 con un rvalue, facendo sì che il tipo non-riferimento Widget venga dedotto per auto. Sostituendo Widget ad auto si ottiene:

WidgetSS w2 = widgetFactory();

Qui non vi sono riferimenti a riferimenti e dunque siamo cavallo: w2 è un riferimento rvalue.

Ora abbiamo finalmente la possibilità di comprendere appieno i riferimenti universali introdotti nell’Elemento 24. Un riferimento universale non è un nuovo tipo di riferimento, in realtà è un riferimento rvalue in un contesto in cui sono soddisfatte le seguenti due condizioni.

•   La deduzione del tipo distingue lvalue e rvalue. Gli lvalue di tipo T vengono dedotti in modo che abbiano tipo T&, mentre gli rvalue di tipo T forniscono T come tipo dedotto;

•   Si verifica il collasso dei riferimenti.

Il concetto dei riferimenti universali è utile, poiché ci evita di dover riconoscere l’esistenza dei contesti di collasso dei riferimenti, di dedurre mentalmente tipi differenti per lvalue e rvalue e di applicare la regola di collasso dei riferimenti dopo aver sostituito mentalmente i tipi dedotti nei contesti in cui si presentano.

Abbiamo detto che vi sono quattro contesti di questo tipo, ma ne abbiamo introdotti solo due: l’istanziazione di template e la generazione del tipo con auto. Il terzo è la generazione e l’uso di typedef e delle dichiarazioni alias (vedi Elemento 9). Se, durante la creazione o valutazione di un typedef, sorgono dei riferimenti a riferimenti, per eliminarli interviene il collasso dei riferimenti. Per esempio, supponete di avere una classe template Widget contenente un typedef per un tipo riferimento rvalue,

template<typename T>

class Widget {

public:

typedef T&& RvalueRefToT;

};

e supponete di istanziare Widget con un tipo riferimento lvalue:

Widget<int&> w;

Sostituendo int& per T nel template di Widget, si ottiene il seguente typedef:

typedef int& && RvalueRefToT;

Il collasso dei riferimenti lo riduce a questo,

typedef int& RvalueRefToT;

che chiarisce il fatto che il nome scelto per il typedef forse non è descrittivo quanto avremmo sperato: RvalueRefToT è un typedef per un riferimento lvalue quando Widget viene istanziato con un tipo riferimento lvalue.

L’ultimo contesto in cui si svolge il collasso dei riferimenti è legato agli usi di decltype. Se, durante l’analisi di un tipo che richiama decltype, sorge un riferimento di riferimento, interverrà il collasso dei riferimenti per eliminarlo (per informazioni su decltype, consultate l’Elemento 3).

Argomenti da ricordare

•   Il collasso dei riferimenti si verifica in quattro contesti: istanziazione di template, generazione del tipo con auto, creazione e uso di typedef e dichiarazioni alias e decltype.

•   Quando i compilatori generano un riferimento a un riferimento in un contesto di collasso dei riferimenti, il risultato diviene un singolo riferimento. Se almeno uno dei riferimenti originali è un lvalue, il risultato è un riferimento lvalue. Altrimenti sarà un riferimento rvalue.

•   I riferimenti universali sono riferimenti lvalue nei contesti in cui la deduzione del tipo distingue gli lvalue dagli rvalue e dove si verifica il collasso dei riferimenti.

Elemento 29 – Operazioni di spostamento: se non sono disponibili, economiche o utilizzate

Le semantiche di spostamento sono senza dubbio la funzionalità principale del C++11. Si dice che “Lo spostamento di container, oggi, è altrettanto economico della copia di puntatori!” e “La copia di oggetti temporanei è oggi così efficiente che programmare per evitarla diventa una sorta di ostacolo all’ottimizzazione!”. Tali affermazioni sono facili da comprendere. Le semantiche di spostamento rappresentano davvero una funzionalità importante. Non solo consentono ai compilatori di sostituire le costose operazioni di copia con i, molto più economici, spostamenti, ma, in realtà richiedono che sia (quasi) sempre così. Diciamo “quasi”, perché devono essere soddisfatte le condizioni corrette. Prendete il vostro codice C++98, ricompilatelo con un compilatore C++11 e la Libreria Standard e, d’un tratto, il vostro software sarà più veloce.

Le semantiche di spostamento possono davvero fare la differenza e ciò conferisce a questa funzionalità un’aura di leggenda. Tuttavia, le leggende, di solito, sono il risultato di un’esagerazione. Scopo di questo Elemento è quello di aiutarci a rimettere i piedi per terra.

Iniziamo osservando che molti tipi non supportano la semantica di spostamento. L’intera Libreria Standard C++98 è stata rielaborata per il C++11, in modo da aggiungere le operazioni di spostamento per i tipi in cui lo spostamento potesse essere implementato in modo più rapido rispetto alla copia; anche l’implementazione dei componenti della libreria è stata riveduta per sfruttare queste operazioni. Tuttavia, è probabile che abbiate a disposizione una base di codice che non è stata completamente aggiornata per sfruttare le nuove funzionalità del C++11. Per i tipi (dell’applicazione o delle librerie che utilizzate) per i quali non sono state eseguite modifiche da parte del C++11, il nuovo supporto per gli spostamenti nei compilatori non farà una grande differenza. Certamente il C++11 intenderà generare delle operazioni di spostamento per le classi che non le offrono, ma ciò solo per le classi che non dichiarano operazioni di copia, di spostamento o distruttori (vedi Elemento 17). I dati membro o le classi base dei tipi per i quali è disabilitato lo spostamento (per esempio tramite una cancellazione, vedi Elemento 11) sopprimeranno sempre le operazioni di spostamento generate dal compilatore. Per i tipi che non offrono un supporto esplicito per lo spostamento e che non si qualificano per le operazioni di spostamento generate dal compilatore, non vi è alcun motivo di aspettarsi che il C++11 fornisca un qualche miglioramento prestazionale rispetto al C++98.

Perfino i tipi che offrono un supporto esplicito per le operazioni di spostamento potrebbero non conseguire i vantaggi desiderati. Per esempio, tutti i container nella Libreria Standard C++11 supportano lo spostamento, ma sarebbe un errore presumere che lo spostamento di tutti i container sia necessariamente economico. Per alcuni container, il motivo è che non esiste un modo davvero economico per spostarne il contenuto. Per altri, le operazioni di spostamento davvero economiche dei container hanno dei dettagli che gli elementi del container non riescono a soddisfare.

Considerate std::array, un nuovo container C++11. std::array è sostanzialmente un array standard con un’interfaccia STL. Si tratta di una struttura fondamentalmente differente dagli altri container standard, ognuno dei quali memorizza il proprio contenuto nello heap. Gli oggetti di questi tipi di container contengono (come dati membro), teoricamente, solo un puntatore alla memoria dello heap che conserva il contenuto del container (la realtà è più complessa, ma per gli scopi di questa analisi, tali differenze sono ininfluenti). L’esistenza di questo puntatore consente di spostare il contenuto di un intero container in un tempo costante: basta copiare il puntatore dal container di origine a quello di destinazione e impostare il puntatore di origine a null:

std::vector<Widget> vw1;

// inserisce i dati in vw1

// sposta vw1 in vw2. Opera in

// un tempo costante. Solo i puntatori

// in vw1 e vw2 vengono modificati

auto vw2 = std::move(vw1);

Gli oggetti std::array non hanno questo puntatore, poiché i dati del loro contenuto sono conservati direttamente nell’oggetto std::array:

std::array<Widget, 10000> aw1;

// inserisce i dati in aw1

// sposta aw1 in aw2. Opera in

// un tempo lineare. Tutti gli elementi

// di aw1 vengono spostati in aw2

auto aw2 = std::move(aw1);

Notate che gli elementi in aw1 vengono spostati in aw2. Supponendo che Widget sia un tipo in cui lo spostamento è più veloce rispetto alla copia, lo spostamento di uno std::array di Widget sarà più veloce rispetto alla copia. Pertanto, std::array offre certamente il supporto dello spostamento. Tuttavia, sia lo spostamento sia la copia di uno std::array hanno una complessità computazionale di tipo lineare, poiché deve essere copiato o spostato ciascun elemento del container. Questo non va d’accordo con l’affermazione “Oggi lo spostamento di un container è economico quanto assegnare un paio di puntatori” che si sente dire talvolta.

D’altra parte, std::string offre anche delle operazioni di spostamento in tempo costante e di copia in tempo lineare. Ciò fa pensare che lo spostamento sia più veloce rispetto alla copia, ma non sempre le cose stanno così. Molte implementazioni di stringhe impiegano l’ottimizzazione SSO (Small String Optimization), in base alla quale, le stringhe “piccole” (per esempio quelle con una capacità non superiore a 15 caratteri) vengono conservate in un buffer interno dell’oggetto std::string; non viene utilizzata memoria tratta dallo heap. Lo spostamento di piccole stringhe utilizzando un’implementazione basata su SSO non è più veloce rispetto alla copia, poiché non è applicabile il trucco di copiare solo un puntatore, su cui si basa generalmente il vantaggio prestazionale degli spostamenti rispetto alle copie.

La motivazione per l’ottimizzazione SSO è il fatto che le stringhe brevi sono la norma per molte applicazioni. L’utilizzo di un buffer interno per memorizzare il contenuto di tali stringhe elimina la necessità di allocare dinamicamente della memoria per loro, e questo, in genere, si traduce in un vantaggio in termini di efficienza. Un’implicazione di questo vantaggio, tuttavia, è il fatto che gli spostamenti non sono più veloci delle copie, anche se si può adottare un approccio a “bicchiere mezzo pieno” e dire che per queste stringhe, la copia non è più lenta rispetto allo spostamento.

Anche per i tipi che supportano le operazioni di spostamento accelerate, alcune situazioni di spostamento apparentemente “sicure” possono trasformarsi in altrettante copie. L’Elemento 14 spiega che alcune operazioni per container nella Libreria Standard offrono la garanzia forte per la sicurezza delle eccezioni; e per garantire che il codice C++98 preesistente che dipende da tale garanzia continui a funzionare nel passaggio al C++11, le operazioni di copia sottostanti possono essere sostituite da altrettante operazioni di spostamento solo se si sa che non vengono lanciate le operazioni di spostamento. Una conseguenza è che anche se un tipo offre delle operazioni di spostamento che sono più efficienti rispetto alle corrispondenti operazioni di copia e anche se, in un determinato punto del codice, un’operazione di spostamento sarebbe generalmente appropriata (per esempio se l’oggetto di origine è un rvalue), i compilatori possono comunque essere costretti a richiamare un’operazione di copia, poiché la corrispondente operazione di spostamento non è stata dichiarata noexcept.

Vi sono pertanto varie situazioni in cui la semantica di spostamento del C++11 non offre particolari vantaggi.

•   Nessuna operazione di spostamento: l’oggetto da spostare non offre operazioni di spostamento. La richiesta di spostamento diviene pertanto una richiesta di copia.

•   Spostamento non veloce: l’oggetto da spostare ha delle operazioni di spostamento, ma non sono più veloci rispetto alle operazioni di copia.

•   Spostamento non utilizzabile: il contesto in cui si svolgerebbe lo spostamento richiede un’operazione di spostamento che non ammette eccezioni, ma tale operazione non è dichiarata noexcept.

Vale anche la pena di menzionare un’altra situazione in cui le semantiche di spostamento non offrono alcun vantaggio in termini di efficienza.

•   L’oggetto di origine è un lvalue: con pochissime eccezioni (vedi, per esempio, l’Elemento 25) solo gli rvalue possono essere utilizzati come origine di un’operazione di spostamento.

Ma il titolo di questo Elemento è di supporre che le operazioni di spostamento non siano presenti, non siano economiche e non vengano utilizzate. Questo è tipicamente il caso del codice generico, ovvero dei template, poiché non si conoscono tutti i tipi con cui si sta lavorando. In tali circostanze, in C++98 (prima che esistesse la semantica di spostamento) occorreva essere conservativi sulla copia degli oggetti. È anche il caso del codice “instabile”, ovvero codice in cui le caratteristiche dei tipi utilizzati sono soggette a modifiche relativamente frequenti.

Spesso, tuttavia, si conoscono i tipi utilizzati dal codice e si può contare sul fatto che le loro caratteristiche non cambino (in termini di supporto o meno di operazioni di spostamento economiche). In questo caso, basta cercare dettagli sul supporto dello spostamento per i tipi utilizzati. Se tali tipi offrono operazioni di spostamento economiche e se si utilizzano questi oggetti nei contesti in cui tali operazioni di spostamento verranno richiamate, si può contare con sicurezza sul fatto che le semantiche di spostamento sostituiscano le operazioni di copia, con i relativi vantaggi prestazionali.

Argomenti da ricordare

•   Supporre che le operazioni di spostamento non siano presenti, non siano economiche e non vengano utilizzate.

•   Nel codice con tipi noti o con il supporto delle semantiche di spostamento, non è necessario fare supposizioni.

Elemento 30 – Casi problematici di perfect-forward

Una delle funzionalità più blasonate del C++11 e il perfect-forward. Se si chiama “perfetto”, sarà, per forza di cose... perfetto! Purtroppo, mettendoci mano, si scopre che c’è una certa differenza fra un perfetto ideale e un perfetto reale. Il perfect-forward del C++11 è un’ottima funzionalità, ma raggiunge la vera perfezione solo se si tengono in considerazione alcuni piccoli dettagli. Questo Elemento è dedicato proprio a tali dettagli.

Prima di affrontare la nostra esplorazione, vale la pena di ripassare che cosa si intende con “perfect-forward”. Con “forward”, inoltro, si intende semplicemente che una funzione passa, inoltra, i propri parametri a un’altra funzione. L’obiettivo è che la seconda funzione (quella che riceve l’inoltro) riceva gli stessi oggetti che la prima funzione (quella che esegue l’inoltro) ha ricevuto. Questo taglia fuori i parametri passati per valore, poiché sono solo copie di ciò che ha passato il chiamante originale. Vogliamo che la funzione che riceve l’inoltro sia in grado di operare sugli oggetti originariamente passati. Si devono scartare anche i parametri puntatore, poiché non vogliamo costringere i chiamanti a passare dei puntatori. Parlando di inoltro, in generale, consideriamo parametri che sono riferimenti.

Il perfect-forward significa che non inoltriamo semplicemente degli oggetti, inoltriamo anche le loro caratteristiche salienti: il loro tipo, il fatto che si tratti di valori lvalue o rvalue, e il fatto che siano const o volatile. Unendo l’osservazione precedente (che abbiamo a che fare con parametri riferimento), ciò significa che stiamo utilizzando riferimenti universali (Elemento 24), poiché solo i riferimenti universali codificano informazioni relative al fatto che gli argomenti passati siano lvalue o rvalue.

Supponiamo di avere una funzione f e di voler scrivere una funzione (in realtà un template di funzione) che esegua l’inoltro a tale funzione. Sostanzialmente il tutto ha il seguente aspetto:

template<typename T>  
void fwd(T&& param) // accetta qualsiasi argomento
{  
f(std::forward<T>(param)); // lo inoltra a f
}  

Le funzioni che eseguono l’inoltro sono, per definizione, generiche. Il template fwd, per esempio, accetta ogni tipo di argomento e inoltra ciò che riceve. Un’estensione logica di questa genericità è che le funzioni che eseguono l’inoltro non siano semplici template, ma template variadic, che dunque accettino qualsiasi numero di argomenti. La forma variadic di fwd ha il seguente aspetto:

template<typename... Ts>  
void fwd(Ts&&... params) // accetta qualsiasi numero di argomenti
{  
f(std::forward<Ts>(params)...); // li inoltra tutti a f
}  

Questa è la forma che vedrete, fra l’altro, nelle funzioni di emplacement dei container standard (Elemento 42) e che avete visto nelle funzioni factory dei puntatori smart, std::make_shared e std::make_unique (Elemento 21).

Data la nostra funzione obiettivo f e la nostra funzione di inoltro fwd, il perfect-forward non ha successo se accade che chiamando f con un determinato argomento si ottiene una cosa, mentre chiamando fwd con lo stesso argomento si ottiene qualcosa di differente:

f( expression ); // se qui si ottiene una cosa,
fwd( expression ); // e qui si ottiene qualcosa di differente,
  // fwd non inoltra perfettamente expression a f

Vari tipi di argomenti provocano questo tipo di problema. Sapere quali sono e come aggirare il problema è importante; pertanto, vediamo innanzitutto quali argomenti non sono adatti al perfect-forward.

Inizializzatori a graffe

Supponete che f sia dichiarata nel seguente modo:

void f(const std::vector<int>& v);

In tal caso, richiamando f con un inizializzatore a graffe, il codice viene compilato,

f({ 1, 2, 3 }); // OK, “{1, 2, 3}” implicitamente
  // convertito in std::vector<int>
 

mentre passando lo stesso inizializzatore a graffe a fwd il codice non viene compilato:

fwd({ 1, 2, 3 }); // errore! Non viene compilato

Questo perché l’uso di un inizializzatore a graffe è uno dei casi in cui il perfect-forward non si può applicare.

Tutti i casi di fallimento di questo tipo hanno la stessa causa. In una chiamata diretta a f (come f({ 1, 2, 3 })), i compilatori vedono gli argomenti passati nel punto di chiamata e vedono anche i tipi dei parametri dichiarati da f. Confrontano gli argomenti nel punto di chiamata con le dichiarazioni dei parametri, ne controllano la compatibilità e, se necessario, svolgono le opportune conversioni implicite, per far sì che la chiamata abbia successo. Nell’esempio precedente, generano da { 1, 2, 3 } un oggetto std::vector<int> temporaneo, in modo che il parametro v di f abbia un oggetto std::vector<int> cui associarsi.

Chiamando f indirettamente tramite il template della funzione di inoltro fwd, i compilatori non potranno più confrontare gli argomenti passati nel punto di chiamata di fwd con le dichiarazioni dei parametri in f. Al contrario, dedurranno i tipi degli argomenti passati a fwd e confronteranno i tipi dedotti con le dichiarazioni dei parametri di f. Il perfect-forward non funziona quando si verifica uno dei due casi seguenti.

•   I compilatori non sono in grado di dedurre un tipo da uno o più dei parametri di fwd. In tal caso, il codice non verrà compilato.

•   I compilatori deducono il tipo “errato” per uno o più dei parametri di fwd. Qui “errato” può significare che l’istanziazione di fwd non può essere compilata con i tipi che sono stati dedotti, ma può anche significare che la chiamata a f che utilizza i tipi dedotti per fwd si comporta in modo differente rispetto a una chiamata diretta a f con gli argomenti effettivamente passati a fwd. Un’origine di questo comportamento divergente può essere il caso di una f che è una funzione in overloading: a causa di una deduzione “errata” dei tipi, l’overload di f richiamata all’interno di fwd può essere differente dall’overload che verrebbe richiamata se f fosse stata richiamata direttamente.

Nella chiamata fwd({ 1, 2, 3 }) precedente, il problema è che passando un inizializzatore a graffe al parametro template di una funzione che non è dichiarata come std::initializer_list, si dichiara, come dice lo standard, un “contesto non-dedotto”. Ciò significa che i compilatori non devono poter dedurre un tipo per l’espressione { 1, 2, 3 } nella chiamata fwd, poiché il parametro di fwd non è dichiarato come una std::initializer_list. Non potendo dedurre un tipo per il parametro di fwd, i compilatori devono, comprensibilmente, rifiutare la chiamata.

Un aspetto interessante è che l’Elemento 2 spiega che la deduzione del tipo ha successo per le variabili auto inizializzate con un inizializzatore a graffe. Tali variabili sono considerate oggetti std::initializer_list e questo ci dà una semplice soluzione per i casi in cui il tipo che la funzione di inoltro dovrebbe dedurre è una std::initializer_list: basta dichiarare una variabile locale utilizzando auto e poi passare la variabile locale alla funzione che esegue l’inoltro.

auto il = { 1, 2, 3 }; // il tipo di il è dedotto come
  // std::initializer list<int>
   
fwd(il); // PK, perfect-forward di il a f

0 o Null come puntatori nulli

L’Elemento 8 spiega che quando si tenta di passare 0 o NULL come puntatori nulli a un template, la deduzione del tipo è disorientata, deducendo per l’argomento passato un tipo intero (tipicamente int) invece di un tipo puntatore. Il risultato è che né 0NULL possono essere inoltrati “perfettamente” come puntatori null. La soluzione è semplice: passare nullptr invece di 0 o NULL. Per i dettagli, consultate l’Elemento 8.

Dati membro static const interi solo nella dichiarazione

Come regola generale, non vi è alcuna necessità di definire i dati membro static const interi all’interno delle classi; bastano le dichiarazioni. Questo perché i compilatori eseguono la propagazione const sul valore di tali membri, eliminando la necessità di riservare ulteriore memoria. Per esempio, considerate il seguente codice:

class Widget {  
public:  
static const std::size_t MinVals = 28; // dichiarazione di MinVals
 
};  
... // nessuna definizione per MinVals
   
std::vector<int> widgetData;  
widgetData.reserve(Widget::MinVals); // uso di MinVals

Qui utilizziamo Widget::MinVals (di conseguenza, semplicemente MinVals) per specificare la capacità iniziale di widgetData, anche se MinVals non ha una propria definizione. I compilatori aggirano la definizione mancante (e sono obbligati a farlo) inserendo il valore 28 in tutti punti in cui è menzionato MinVals. Il fatto che non sia stata riservata memoria per il valore MinVals non è un problema. Se servisse l’indirizzo di MinVals (per esempio se qualcuno creasse un puntatore a MinVals), allora MinVals richiederebbe della memoria (il puntatore deve avere qualcosa a cui puntare) e il codice precedente, anche se venisse compilato, non passerebbe la fase di linking finché non fosse fornita una definizione per MinVals.

Detto questo, immaginate che f (la funzione cui fwd inoltra i propri argomenti) sia dichiarata nel seguente modo:

void f(std::size_t val);

Richiamare f con MinVals va bene, poiché i compilatori non faranno altro che sostituire MinVals con il suo valore:

f(Widget::MinVals); // OK, trattato come “f(28)”
   

Purtroppo, le cose non vanno altrettanto bene se tentiamo di richiamare f attraverso fwd:

fwd(Widget::MinVals); // errore! Non passa il linking

Questo codice verrà compilato, ma non dovrebbe riuscire a passare la fase di linking. Se questo vi ricorda ciò che accade se scriviamo del codice che prende l’indirizzo di MinVals, è proprio così, poiché il problema è sostanzialmente lo stesso.

Anche se nulla nel codice sorgente prende l’indirizzo di MinVals, il parametro di fwd è un riferimento universale; e i riferimenti, nel codice generato dai compilatori, vengono solitamente trattati come puntatori. Nel codice binario prodotto per il programma (e nell’hardware), i puntatori e i riferimenti sono sostanzialmente la stessa cosa. A questo livello, è proprio vero che i riferimenti non sono altro che puntatori, deindirizzati automaticamente. Poiché le cose stanno così, il passaggio di MinVals per riferimento è sostanzialmente la stessa cosa del passaggio per puntatore e, per questo motivo, vi deve essere della memoria cui il puntatore può puntare. Il passaggio di dati membri static const interi per riferimento, quindi, generalmente richiede che essi siano definiti e questo requisito può far sì che il codice che utilizza il perfect-forward non funzioni laddove invece funziona il codice equivalente senza perfect-forward.

Forse avrete notato le tante “sfumature” che ho impiegato nelle pagine precedenti. Il codice “non dovrebbe riuscire a passare la fase di linking”. I riferimenti “vengono solitamente trattati come puntatori”. “Il passaggio di dati membri static const interi per riferimento, quindi, generalmente richiede che essi siano definiti”. È come se io sapessi qualcosa che, in realtà, non voglio dire...

Le cose stanno proprio così. Secondo lo Standard, il passaggio di MinVals per riferimento richiede che questo sia definito. Ma non tutte le implementazioni impongono questo requisito. Pertanto, a seconda del compilatore e dei linker impiegati, potreste scoprire di poter inoltrare perfettamente i dati membro static const interi anche se non sono stati definiti. Se potete farlo, congratulazioni, ma non aspettatevi che tale codice sia portabile. Per renderlo portabile, fornite semplicemente una definizione per il dato membro static const intero in questione. Per MinVals, avrà il seguente aspetto:

const std::size_t Widget::MinVals;     // nel file cpp di Widget

Notate che la definizione non ripete l’inizializzatore (28, nel caso di MinVals). Non trascurate questo dettaglio. Se lo dimenticate e fornite l’inizializzatore in entrambe le posizioni, il compilatore se ne lamenterà, ricordandovi di specificarlo una sola volta.

Nomi di funzioni overloaded e nomi template

Supponete che la nostra funzione f (quella cui vogliamo inoltrare gli argomenti tramite fwd) possa offrire un comportamento personalizzato, ricevendo una funzione che svolga una parte del suo lavoro. Supponendo che questa funzione accetti e restituisca valori int, f potrebbe essere dichiarata nel seguente modo:

void f(int (*pf)(int));     // pf = “processing function”

Vale la pena notare che f potrebbe anche essere dichiarata utilizzando una sintassi più semplice, senza puntatori. Tale dichiarazione avrebbe il seguente aspetto, e avrebbe lo stesso significato della dichiarazione precedente:

void f( int pf(int) );    // dichiara f come sopra

In ogni caso, supponete ora di avere una funzione overloaded, processVal:

int processVal(int value);

int processVal(int value, int priority);

Possiamo passare processVal a f,

f(processVal);       // OK

ma la cosa è piuttosto sorprendente. f richiede come argomento un puntatore a una funzione, ma processVal non è un puntatore a funzione e neppure una funzione; è il nome di due diverse funzioni. Tuttavia, i compilatori sanno di quale processVal hanno bisogno: quella che corrisponde al tipo di parametro di f. Pertanto, scelgono il processVal che prende un int e passano a f l’indirizzo di tale funzione.

Ciò che fa funzionare il tutto è che la dichiarazione di f consente ai compilatori di immaginare quale versione di processVal è richiesta. Al contrario, fwd, essendo un template di funzione, non ha alcuna informazione sul tipo di cui ha bisogno e ciò rende impossibile per i compilatori determinare quale overload dovrebbe essere scelto e passato:

fwd(processVal);        // errore! Quale processVal?

processVal non ha alcun tipo. Senza un tipo, non vi può essere alcuna deduzione del tipo e senza deduzione del tipo, abbiamo un altro caso di fallimento del perfect-forward.

Lo stesso problema sorge se tentiamo di utilizzare un template di funzione invece di (o in aggiunta a) un nome di funzione overloaded. Un template di funzione non rappresenta una funzione, ma molteplici funzioni:

template<typename T>  
T workOnVal(T param) // template per l’elaborazione dei valori
{ ... }  
   
fwd(workOnVal); // errore! Quale istanziazione
  // di workOnVal?

Il modo per far sì che una funzione perfect-forward come fwd accetti un nome di funzione overloaded o un nome di template consiste nello specificare manualmente l’overload o l’istanziazione che si desidera venga inoltrata. Per esempio, potete creare un puntatore a funzione dello stesso tipo del parametro di f, inizializzare tale puntatore con processVal o workOnVal (facendo sì che venga selezionata la versione corretta di processVal o che venga generata l’istanziazione corretta di workOnVal) e passare il puntatore a fwd:

using ProcessFuncType = // crea il typedef;
int (*)(int); // vedi Elemento 9
   
ProcessFuncType processValPtr = processVal; // specifica la
  // signature per
  // processVal
   
fwd(processValPtr); // OK
   
fwd(static_cast<ProcessFuncType>(workOnVal)); // OK

Naturalmente, questo richiede che sappiate il tipo del puntatore a funzione cui fwd sta eseguendo l’inoltro. Non è irragionevole presumere che una funzione perfect-forward lo specificherà. Dopotutto, le funzioni perfect-forward sono progettate per accettare qualsiasi cosa, pertanto se non vi è alcuna documentazione che specifica cosa passare, in quale modo è possibile saperlo?

Campi bit

L’ultimo caso di fallimento del perfect-forward si ha quando come argomento di una funzione viene utilizzato un bitfield, un campo di bit. Per vedere che cosa significhi questo in pratica, osservate una struttura IPv4 realizzata nel seguente modo:13

struct IPv4Header {

std::uint32_t version:4,

IHL:4,

DSCP:6,

ECN:2,

totalLength:16;

...

};

Se la nostra solita funzione f (il costante obiettivo della nostra funzione di inoltro fwd) è dichiarata in modo da accettare un parametro std::size_t, richiamando, per esempio, il campo totalLength di un oggetto IPv4Header, verrà compilata senza problemi:

   
void f(std::size t sz); // funzione da richiamare
   
IPv4Header h;  
 
f(h.totalLength); // OK
   

Tentando invece di inoltrare h.totalLength a f tramite fwd, si ottiene tutta un’altra situazione:

fwd(h.totalLength); // errore!

Il problema è che il parametro di fwd è un riferimento e h.totalLength è un bitfield non-const. La cosa può non sembrare così grave, ma lo Standard C++ condanna la combinazione con una descrizione insolitamente chiara: “Un riferimento non-const non può essere associato a un bitfield”. Esiste un ottimo motivo per questa proibizione. I campi di bit possono essere costituiti da parti arbitrarie di word-macchina (per esempio i bit da 3 a 5 di un int a 32 bit), ma non vi è alcun modo per accedere direttamente a questi elementi. Ho detto in precedenza che i riferimenti e i puntatori sono la stessa cosa, a livello hardware, e così come non vi è modo di creare un puntatore arbitrario (il C++ stabilisce che la più piccola “cosa” cui si può puntare è un char), non vi è alcun modo per associare un riferimento a un singolo bit.

Aggirare l’impossibilità di un perfect-forward di un campo bit è semplice, una volta che si comprende che ogni funzione che accetta un campo bit come argomento riceverà una copia del valore del campo bit. Dopotutto, nessuna funzione può legare un riferimento a un campo bit, né una funzione può accettare puntatori a campi bit, poiché i puntatori a campi bit non esistono. L’unico tipo di parametro cui un campo bit può essere passato è costituito dai parametri per valore e, cosa molto interessante, i riferimenti a const. Nel caso dei parametri passati per valore, la funzione richiamata riceve ovviamente una copia del valore del campo bit e ne consegue che, nel caso di un parametro riferimento a const, lo Standard richieda che il riferimento si associ effettivamente a una copia del valore del campo bit conservata in un oggetto di un tipo intero standard (per esempio int). I riferimenti a const non si associano ai campi bit, ma a oggetti “normali” all’interno dei quali siano stati copiati i campi bit.

La chiave per passare un campo bit a una funzione perfect-forward, quindi, è quella di sfruttare il fatto che la funzione che riceve l’inoltro riceverà sempre una copia del valore del campo bit. Si può pertanto eseguire una copia del campo e richiamare la funzione che esegue l’inoltro utilizzando tale copia. Nel caso del nostro esempio con lPv4Header, ecco il codice che esegue l’operazione:

// copia il valore del bitfield; vedi Elemento 6
auto length = static_cast<std::uint16 t>(h.totalLength);
   
fwd(length); // inoltra la copia
 

Conclusioni

Nella maggior parte dei casi, il perfect-forward funziona esattamente come previsto. Raramente ci si deve preoccupare troppo. Ma quando non funziona (quando del codice apparentemente ragionevole non viene compilato o, peggio, viene sì compilato, ma non si comporta nel modo previsto), è importante conoscere le “imperfezioni” del perfect-forward. Altrettanto importante è sapere come aggirarle. Nella maggior parte dei casi, non è difficile.

Argomenti da ricordare

•   Il perfect-forward non funziona quando non può essere applicata la deduzione del tipo del template o quando viene dedotto il tipo sbagliato.

•   Gli argomenti che conducono al fallimento del perfect-forward sono gli inizializzatori a graffe, i puntatori nulli espressi come 0 o NULL, i dati in membro const static interi solamente dichiarati, i nomi di funzione template e overloaded e i campi bit.

 

11 L’ Elemento 25 spiega che ai riferimenti universali dovrebbe quasi sempre essere applicata std::forward e, al momento della stampa di questo volume, alcuni membri della comunità C++ hanno iniziato a chiamare i riferimenti universali con il nome di riferimenti forward.

12 Fra gli oggetti locali considerabili per questa ottimizzazione, vi è la maggior parte delle variabili locali (come w all’interno di makeWidget), ma anche gli oggetti temporanei creati nell’ambito di un’istruzione return. I parametri di funzione, invece, no. Alcuni distinguono fra l’applicazione dell’ottimizzazione RVO a oggetti locali con nome e senza nome (ovvero temporanei), limitando il termine RVO agli oggetti senza nome e chiamando invece l’ottimizzazione degli oggetti con nome con il termine NRVO (NamedReturn Value Optimization).

13 Si suppone che i bitfield siano disposti dal lsb (il bit meno significativo) al msb (il bit più significativo). Il C++ non lo garantisce, ma spesso i compilatori forniscono un meccanismo che consente ai programmatori di controllare la disposizione dei bit nel campo.