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 = 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:
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:
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é 0
né NULL
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
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.