Déductions de types : auto, decltype(expr), declval<T>() et decltype(auto)

Merci à Gabriel Aubut-Lussier qui m'a rapporté quelques coquilles et a fait des suggestions permettant d'améliorer ce qui suit.

C++ offre plusieurs mécanismes de déduction de types. En particulier :

Pris ensembles, ces outils constituent des outils précieux dans la boîte à outils des programmeuses contemporaines et des programmeurs contemporains.

Le mot clé auto

Le mot clé auto existe en C (et en C++) depuis les années 1970, mais n'existait vraiment qu'à titre honoraire, signifiant réellement variable de durée automatique (et redondant dans tous les cas où il aurait pu être utilisé). Par contre, depuis C++ 11, ce mot clé essentiellement inutilisé a changé de sens et est devenu particulièrement utile.

Aujourd'hui, auto permet de déduire le type d'un objet du type de l'expression par laquelle cet objet est initialisé. Ceci permet entre autres des allègements syntaxiques importants. Par exemple :

Sans auto Avec auto

Par exemple, imaginons une fonction souhaitant afficher les éléments d'un const vector<T>& un à un, séparés entre eux par le symbole '|'.

Le type d'un itérateur pour réaliser un tel parcours est... laborieux (on parle en effet du type typename vector<T>::const_iterator, rien de moins). Il est toujours possible de l'écrire à la main, mais en pratique, plusieurs (dont votre humble serviteur) n'y verront que du bruit syntaxique.

Comparez à cet effet les versions manuelle (sans auto) et avec auto, tous deux à droite, en particulier l'initialisation de l'itérateur it dans la répétitive. Le code est identique... au bruit syntaxique près.

template <class T>
   void afficher(const vector<T> &v) {
      if (v.empty()) return;
      cout << v.front();
      for(typename vector<T>::const_iterator it = next(begin(v));
          it != end(v); ++it)
         cout << '|' << *it;
   }
template <class T>
   void afficher(const vector<T> &v) {
      if (v.empty()) return;
      cout << v.front();
      for(auto it = next(begin(v));
          it != end(v); ++it)
         cout << '|' << *it;
   }

Couplé aux répétitives for sur des intervalles, auto permet aussi un allègement syntaxique important dans les cas où une séquence entière doit être traversée.

Notez que dans la répétitive for(auto e : v) à droite, chaque élément e est pris par copie. Ceci peut être corrigé si la copie des éléments est une opération coûteuse. Les écritures suivantes (au choix) sont préférables :

for(const auto &e : v) // prendre chaque élément e par référence-vers-const
// ... ou encore ...
for(auto &&e : v) // prendre chaque élément e de la manière qui semble la 
                  // plus efficace (ici, probablement par référence car v
                  // n'est pas const; dans ce cas, pas de réel danger)
vector<int> v = remplir();
for(vector<int>::iterator i = v.begin();
    i != v.end(); ++i)
   cout << *i << endl;
auto v = remplir();
for(auto e : v)
   cout << e << endl;

Le mot clé auto peut aussi servir à titre introducteur pour fins de syntaxe unifiée de fonctions. Nous en verrons un exemple plus bas (section decltype(expr)).

Comme mentionné précédemment, donner à un objet le type auto n'est pas un geste magique, mais fait plutôt en sorte que le type soit celui de l'expression utilisée pour initialiser un objet. En ce sens, auto obj prend se comporte un peu comme dans le cas du mot obj dans un fonction de forme template <class T> f(T obj);, au sens où le type T y est déterminé par le type de ce qui est passé en paramètre.

Dans les exemples à droite :

  • L'objet a est double car le littéral 3.14159 est double
  • L'objet b est int car le littéral 0 est int. Dans un tel cas, convenez avec moi que auto est bien mal choisi
  • La ligne tentant de déclarer l'objet c ne compilera pas, faute d'une expression pour initialiser l'objet (quel serait son type?)
  • La ligne tentant de déclarer l'objet d ne compilerait pas, car le type de retour de f() est void et il se trouve que (du moins jusqu'à C++ 17) les variables de type void ne sont pas permises en C++
  • L'objet e est de type X* car tel est le type de retour de g(int)
void f();
X *g(int);
// ...
auto a = 3.14159; // a est double
auto b = 0; // b est int
auto c; // illégal
auto d = f(); // illégal
auto e = g(3); // e est X*

Pour bien comprendre auto, il est utile de se souvenir que, comme dans le cas de template <class T>, les qualifications const et volatile ne sont pas préservées, pas plus d'ailleurs que les références – informellement, auto mène à des copies.

const int n = 3;
int i = 3;
auto an = n; // an est int, pas const int
auto ai = i; // ai est int, pas int&
auto & r = i; // r est int&
const auto ci = i; // ci est const int

Les règles du Perfect Forwarding s'appliquent aussi avec auto, comme le monde l'exemple à droite.

int n = 3;
auto a0 = n; // copie
auto &a1 = n; // référence sur un lvalue
auto && a2 = n; // référence déduite sur un lvalue
auto a3 = 3; // int
auto &a4 = 3; // illégal (référence sur un littéral)
auto &&a5 = 3; // référence déduite sur un rvalue

Le mot clé auto est un outil, pas une panacée. Dans l'exemple à droite, déclarer i de type int pose problème car la comparaison i < v.size() compare un entier signé (i) et un entier non-signé (v.size()).

La tentation de déclarer i avec auto est une fausse bonne idée dans ce cas, car le littéral 0 est de type int.

Visiblement, auto n'est pas magique, et il demeure nécessaire de réfléchir en programmant.

//...
vector<int> v = remplir();
// oups! (i est signed, pas v.size())
for(int i = 0; i < v.size(); ++i)
   f(v[i]);
// oups! 0 est int!
for(auto i = 0; i < v.size(); ++i)
   f(v[i]);

Le mot clé auto peut servir à des fins qui peuvent être surprenantes. À droite :

  • Le type de p est double*
  • Le type de est_pair est celui d'une λ se comportant comme une fonction bool(int)
  • Le type de lst est initializer_list<int>
  • Le type de x jusqu'à C++ 14 était aussi initializer_list<int>, ce qui pouvait surprendre. Depuis C++ 17, le type de x est int
auto p = new auto(3.14159); // p est double*
auto est_pair = [](int n) {
   return n % 2 == 0;
};
auto lst { 2,3,5,7,11 };
auto x = { 3 }; // attention : changement entre ++14 et C++17

Utiliser auto peut aussi simplifier la refactorisation. En effet, supposons la fonction suivante :

template <class T>
   void f() {
      vector<T> v = fabrique<T>::obtenir_donnees();
      if (v.empty()) return;
      cout << v.front();
      for(typename vector<T>::iterator it = next(begin(v)); it != end(v); ++it)
         cout << '|' << *it;
   }

Si le type de retour de la fonction fabrique<T>::obtenir_donnees() change, passant par exemple de vector<T> à list<T>, cette fonction doit être modifiée à la fois pour le type de v et pour le type de it. Toutefois, si la même fonction est écrite avec auto :

template <class T>
   void f() {
      auto v = fabrique<T>::obtenir_donnees();
      if (v.empty()) return;
      cout << v.front();
      for(auto it = next(begin(v)); it != end(v); ++it)
         cout << '|' << *it;
   }

... changer le type de retour de la fonction fabrique<T>::obtenir_donnees() n'a plus le moindre impact sur l'écriture de la fonction f().

Depuis C++ 14, le type de retour d'une fonction peut être auto, ce qui peut alléger significativement certaines écritures. Par exemple :

Sans auto Avec auto
template <class C>
   typename C::iterator debut(C &conteneur) {
      return conteneur.begin();
   }
template <class C>
   typename C::const_iterator debut(const C &conteneur) {
      return conteneur.begin();
   }
template <class C>
   typename C::iterator fin(C &conteneur) {
      return conteneur.end();
   }
template <class C>
   typename C::const_iterator fin(const C &conteneur) {
      return conteneur.end();
   }
template <class C>
   auto debut(C &conteneur) {
      return conteneur.begin();
   }
template <class C>
   auto debut(const C &conteneur) {
      return conteneur.begin();
   }
template <class C>
   auto fin(C &conteneur) {
      return conteneur.end();
   }
template <class C>
   auto fin(const C &conteneur) {
      return conteneur.end();
   }
template <class T>
   constexpr T somme(T arg) {
      return arg;
   }
// decltype(expr) est présenté plus bas
template <class T, class ... Ts>
   constexpr auto somme(T arg, Ts ... args) -> decltype(somme(arg) + somme(args...)) {
      return somme(arg) + somme(args...);
   }
template <class T>
   constexpr T somme(T arg) {
      return arg;
   }
template <class T, class ... Ts>
   constexpr auto somme(T arg, Ts ... args) {
      return somme(arg) + somme(args...);
   }

Notez que dans la deuxième rangée, la fonction somme() peut, avec C++ 17, simplement s'écrire comme suit :

template <class ... Ts>
   constexpr auto somme(Ts &&... args) {
      return (args + ...);
   }

Le mot clé auto joue encore ici un rôle dans la simplification de l'écriture.

Truc : le mot clé auto peut nuire à la lisibilité du code et obscurcir le propos si le sens des variables n'est pas immédiatement évident aux yeux des programmeuses et des programmeurs. Utilisez des noms significatifs, et prenez l'habitude d'écrire des fonctions courtes.

L'opérateur decltype(expr)

L'opérateur decltype permet d'obtenir le type d'une expression. Évidemment, ceci se fait à la compilation. Par exemple, en C++ 11 ou avant, sans type de retour auto, il était bien embêtant d'écrire une fonction comme la suivante :

template <class T, class U>
   ??? mult(const T &x, const U &y) { // quel devrait être le type de retour de mult()?
      return x * y;
   }

On pouvait bien sûr s'en sortir avec des traits, mais c'était complexe et sujet à erreur huimaine. Avec C++ 11, il est possible d'utiliser la syntaxe unifiée de fonction et de reporter le type de retour à la fin de la signature, profitant du fait que les noms et les types des paramètres sont alors connus du compilateur et déduisant le type de retour du type de l'expression souhaitée :

template <class T, class U>
   auto mult(const T &x, const U &y) -> decltype(x * y) {
      return x * y;
   }

Notez que dans un tel cas, depuis C++ 14, auto seul suffit probablement. Ceci ne réduit pas l'intérêt de decltype cependant; en effet, là où auto suffit pour les cas les plus typiques, il arrive que la perte des qualifications const, volatile, &, etc. ne corresponde pas à l'intention des programmeuses ou des programmeurs. C'est dans ces moments que decltype devient utile : il s'agit d'un outil de précision.

L'exemple à droite montre la différence entre v et decltype. Supposons une fonction pass_thru(T&) retournant un T&, et supposons le programme de test qui l'accompagne :

  • La variable i0 sera une copie de j, puisque sont type est auto
  • La variable i1 sera une référence sur j, puisque son type est auto&, mais ceci demande au code client d'expliciter la référence et est sujet à erreurs
  • La variable i2 sera int&, puisqu'il s'agit du type exact de retour de pass_thru(j) avec j et de type int, ceci parce que son type est decltype(pass_thru(j)), donc « le type de retour de pass_thru(j) »
template <class T>
   T& pass_thru(T &val) {
      return val;
   }
int main() {
   int j = 3;
   auto i0 = pass_thru(j); // i0 est int
   auto &i1 = pass_thru(j); // i1 est int&
   // i2 est int&
   decltype(pass_thru(j)) i2 = pass_thru(j);
   i2 = 4;
   cout << j << endl; // 4
}

Ce seuil de précision dans le type choisi revient souvent dans du code générique. Supposons appliquer(f,p) ci-dessous, qui appliquera la fonction f à l'objet *p; si nous souhaitons que le type de retour de la fonction appliquer(f,obj) soit exactement le même que celui de f(*p), alors auto seul ne serait pas suffisamment précis (perdant les qualifications), même avec C++ 14 :

template <class F, class T>
    auto appliquer(F f, T *p) -> decltype(f(*p)){
      assert(p);
      return f(*p);
   }

 

Pour reprendre l'exemple d'itération avec indice sur un conteneur, vu précédemment, une solution simple pour choisir correctement le type du compteur est de l'exprimer en termes de decltype : en donnant à i le même type de v.size(), il est assuré que la condition i < v.size() évite les risque de comparaison signé / non-signé.

//...
vector<int> v = remplir();
// oups! (i est signed, pas v.size())
for(int i = 0; i < v.size(); ++i)
   f(v[i]);
// oups! 0 est int!
for(auto i = 0; i < v.size(); ++i)
   f(v[i])
// Ok!
for((decltypedecltype(v.size())) i = 0; i < v.size(); ++i)
   f(v[i]);

Une application amusante de decltype est la déduction des types de fonctions. Soit les fonctions f() et g() droite, dont les types sont pour le moins... déplaisants (notez que la convention d'appel __stdcall n'est pas du C++ standard, mais a été placé ici pour rendre la signature plus douloureuse encore), et supposant que nous souhaitions faire de ptrf_t et de ptrg_t les types de f et de g,  pour éventuellement déclarer des pointeurs de fonctions susceptibles de pointer vers f() ou g() ou vers d'autres fonctions de même signature :

  • La première définition de ces types suit la forme traditionnelle, pré-C++ 11, et repose sur typedef. Notez la position du nom du type (au centre!), que plusieurs trouverons difficile à saisir
  • Depuis C++ 11, il est possible d'utiliser using plutôt que typedef. La position du nom du type (avant le symbole =) est probablement plus raisonnable aux yeux de la majorité, mais nous avons tout de même dû exprimer la signature manuellement, parenthèses et tout
  • Enfin, il est possible d'utiliser decltype pour dire, tout simplement : ptrf_t est le type de l'adresse de f, ptrg_t est le type de l'adresse de g. C'est si simple en comparaison avec les alternatives...
// signature de la fonction
void __stdcall f(X**);
double g(void *, const std::string&);
// à l'ancienne (pré-C++11)
typedef void (__stdcall *ptrf_t)(X**); // C, C++03
typedef double (*ptrg_t)(void*, const std::string&); // C, C++03
// depuis C++11
using ptrf_t = void (__stdcall *)(X**); // C++11
using ptrg_t = double (*)(void*, const std::string&); // C++11
// depuis C++11 (avec decltype)
using ptrf_t = decltype(&f); // C++11
using ptrg_t = decltype(&g); // C++11

La « fonction » declval<T>()

Il arrive que l'on souhaite évaluer le type d'une expression hypothétique, sans aller jusqu'à créer les objets qui y interviennent. Par exemple, quel serait le nom du type que l'on obtiendrait si l'on calculait la distance entre deux itérateurs sur une list<T>? Obtenir cette information peut bien sûr se faire comme suit :

#include <list>
#include <iterator>
template <class T>
   void f() {
      using namespace std;
      list<T> lst;
      using type = decltype(distance(begin(lst), end(lst)));
      // ...
   }

... mais c'est inefficace, du fait que par cette technique nous avons instancié une list<T> avec pour seule motivation celle d'explorer des types découlant de son utilisation.

Notez toutefois que decltype opère à la compilation, un peu comme le font sizeof ou alignof. Il n'est donc pas nécessaire ici d'avoir une même list<T> pour les appels à begin() et à end(), puisque ces appels n'auront pas lieu – nous raisonnons ici strictement sur les types des objets, par sur leurs valeurs. Ici, nous pourrions raisonner sur les types de begin() et end() sur une hypothétique list<T> et nous atteindrions le même résultat.

C++ offre pour de tels cas la « fonction » declval<T>(). J'utilise les guillemets du fait que cette fonction n'existe pas vraiment : elle est déclarée, mais elle n'est pas définie, ce qui signifie que ne nous pouvons l'utiliser qu'à la compilation. Elle se présente comme suit :

template <class T>
   T && declval();

... ce qui signifie que son type de retour est celui d'un hypothétique T qui aurait été découvert par le compilateur (une Forwarding Reference). Ceci permet de réécrire le type que l'on obtiendrait si l'on calculait la distance entre deux itérateurs sur une list<T> comme suit :

#include <list>
#include <iterator>
template <class T>
   void f() {
      using namespace std;
      using type = decltype(distance(begin(declval<list<T>>()), end(declval<list<T>>())));
      // ...
   }

Dans cette version, tout se passe à la compilation, et le coût à l'exécution, que ce soit en mémoire ou en temps d'exécution, est nul.

La variante decltype(auto)

Enfin, depuis C++ 14, nous avons decltype(auto). Pour comprendre son rôle, réexaminons la fonction appliquer() vue précédemment. Elle avait la forme suivante :

template <class F, class T>
    auto appliquer(F f, T *p) -> decltype(f(*p)){
      assert(p);
      return f(*p);
   }

Un exemple de code client simpliste serait :

#include <cassert>
template <class F, class T>
    auto appliquer(F f, T *p) -> decltype(f(*p)) { // un peu verbeux...
      assert(p);
      return f(*p);
   }
int f0(int n) {
   return n * n;
}
int& f1(int &r) {
   return r;
}
#include <iostream>
int main() {
   using namespace std;
   int n = 3;
   cout << appliquer(f0, &n) << endl; // affichera 9
   ++appliquer(f1, &n);
   cout << n << endl; // affichera 4
}

Ici, le type de retour est quelque peu verbeux, répétant une partie de l'implémentation de la fonction. Il serait tentant de se limiter à auto comme type de retour, mais le type déduit ne serait pas nécessairement adéquat. Par exemple :

#include <cassert>
template <class F, class T>
    auto appliquer(F f, T *p) { // ICI : suppression du type de retour explicite
      assert(p);
      return f(*p);
   }
int f0(int n) {
   return n * n;
}
int& f1(int &r) {
   return r;
}
#include <iostream>
int main() {
   using namespace std;
   int n = 3;
   cout << appliquer(f0, &n) << endl; // affichera 9
   ++appliquer(f1, &n); // oups! On retourne une copie à cause du type de retour auto
   cout << n << endl; // affichera 3 (changement dans le sens du programme)
}

Dans de tels cas, decltype(auto) est utile. Le sens de decltype(auto) est précisément celui de l'expression utilisée, incluant les qualifications. Ainsi :

#include <cassert>
template <class F, class T>
    declrtype(auto) appliquer(F f, T *p) { // moins verbeux de la version originale, plus précis que auto
      assert(p);
      return f(*p);
   }
int f0(int n) {
   return n * n;
}
int& f1(int &r) {
   return r;
}
#include <iostream>
int main() {
   using namespace std;
   int n = 3;
   cout << appliquer(f0, &n) << endl; // affichera 9
   ++appliquer(f1, &n);
   cout << n << endl; // affichera 4
}

L'exemple à droite montre un cas d'utilisation (abstrait, mais bien réel) de decltype(auto) :

  • La variable i est un int, étant une copie de ce que retournera f()
  • La déclaration de variable r serait illégale, car elle tente de prendre une référence sur une lvalue menant vers une référence sur un rvalue (donc une référence avec une portée, mais menant sur un objet présumé détruit à la fin de l'expression)
  • Les variables j et k sont toutes deux des int&&. Dans le cas de j, le type est déduit du type de f() (ici, ce type est court, mais en général il peut être long et complexe), alors que dans le cas de k, il est déduit du contexte tout simplement
int && f();
// ...
auto i = f(); // i est int
auto & r = f(); // illégal
decltype(f()) j = f(); // j est int&&
decltype(auto) k = f(); // k est int&&

Revisitons un autre cas couvert précédemment : la fonction pass_thru() visible à droite.

Avec C++ 11, le type de la variable j dans ce cas aurait dû s'exprimer decltype(pass_thru(i)). Clairement, decltype(auto) est une simplification et un allègement syntaxique dans ce cas aussi, réduisant la quantité d'écriture redondante.

#include <iostream>
template <class T>
   T& pass_thru(T &val) {
      return val;
   }
int main() {
   using namespace std;
   int i = 3;
   // référence!
   decltype(auto) j = pass_thru(i);
   ++j;
   cout << i << endl; // 4
}

Autre cas mettant en valeur decltype(auto) : examinez la fonction appliquer_variadique() ci-dessous, fonction qui applique une fonction f à un nombre variadique de paramètres.

Sans decltype(auto) Avec decltype(auto)
template <class F, class ...Args>
   auto appliquer_variadique(F f, Args && ...args) -> decltype(f(std::forward<Args>(args)...)) {
      return f(std::forward<Args>(args)...);
   }
template <class F, class ...Args>
   decltype(auto) appliquer_variadique(F f, Args && ...args) {
      return f(std::forward<Args>(args)...);
   }

Dans ce cas, sans decltype(auto), le type de retour dans la signature de la fonction est plus long à écrire que ne l'est le corps de la fonction!

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !