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 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. |
|
|
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 :
|
|
|
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 :
|
|
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. |
|
Les règles du Perfect Forwarding s'appliquent aussi avec auto, comme le monde l'exemple à droite. |
|
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. |
|
Le mot clé auto peut servir à des fins qui peuvent être surprenantes. À droite :
|
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 |
---|---|
|
|
|
|
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 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 :
|
|
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é. |
|
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 :
|
|
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.
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) :
|
|
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. |
|
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) |
---|---|
|
|
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!
Quelques liens pour enrichir le propos.