Outils d'utilisateurs

Outils du Site


inference_de_type

Ceci est une ancienne révision du document !


C++14 : decltype(auto)

que choisir, auto ou type explicite ? Si écrire le type explicite apporte une information supplémenataire, l'écrire. Sinon auto (AAA Style (Almost Always Auto))

L'inférence de type

Conversion implicite de types

Lorsque l'on initialise une variable avec une littérale, on fournit deux fois l'information sur le type. Par exemple, dans le code suivant :

int const i { 123 }; 

Dans ce code, le type de la variable i et la littérale 123 sont des entiers de type int.

Si le type de la variable et de la littérale n'est pas parfaitement identique, le compilateur effectue une conversion implicite du type de la littérale vers le type de la variable. Lorsque cette conversion implicite ne pose pas de problème et que la littérale peut être convertie vers le type de la variable sans perte d'information, le compilateur ne va pas signaler cette conversion implicite.

main.cpp
#include <iostream>
 
int main() {
    int const i { 123 };
    float const c { 123 };
    std::cout << i << std::endl;
    std::cout << c << std::endl;
}

affiche :

123
123

En revanche, si la littérale ne peut pas être convertie vers le type de la variable sans problème ou sans perte d'information, le compilateur signalera cette conversion implicite (narrowing). Une perte d'information peut avoir lieu par exemple dans les conversions suivantes :

  1. Une littérale de type réel dans une variable de type entier.
  2. Une littérale négative dans une variable non signée.
  3. Une littérale trop grande dans une variable de type ne pouvant contenir cette valeur.
int i { 123.000 };      // 1.
unsigned int ui { -1 }; // 2.
char c { 123456789 };   // 3.

Le compilateur signalera une erreur ou un avertissement (selon les options de compilation) pour signaler la conversion implicite. Par exemple, dans le cas d'une conversion d'un double vers un int :

main.cpp
#include <iostream>
 
int main() {
    int const i { 123.456 };
    std::cout << i << std::endl;
}

affiche :

main.cpp:4:13: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
    int i { 123.456 };
            ^~~~~~~
main.cpp:4:13: note: override this message by inserting an explicit cast
    int i { 123.456 };
            ^~~~~~~
            static_cast<int>( )
main.cpp:4:13: warning: implicit conversion from 'double' to 'int' changes value 
from 123.456 to 123 [-Wliteral-conversion]
    int i { 123.456 };
          ~ ^~~~~~~
1 warning and 1 error generated.

Le premier message donne le message d'erreur de conversion (“type 'double' cannot be narrowed to 'int'” signifie “le type 'double' ne peut pas être réduit dans 'int'”).

À la fin du message d'erreur, le compilateur indique l'option de compilation qui active la vérification des erreurs de conversion -Wc++11-narrowing. Vous avez déjà vu quelques options de compilation -Wall -Wextra -pedantic. Vous n'avez pas utilisé cette nouvelle option, mais pourtant le compilateur a signalé le problème. La raison est que les options de compilation que vous avez utilisé -Wall -Wextra -pedantic activent d'autres options de compilation, en particulier, -Wc++11-narrowing.

Le deuxième message propose de réaliser une conversion explicite au lieu d'une conversion implicite. La raison est qu'autant le compilateur peut supposer qu'une conversion implicite puisse être une erreur, autant une conversion explicite est forcément un choix volontaire du développeur. Vous verrez dans un prochain chapitre l'utilisation de static_cast.

Le dernier message d'avertissement prévient que du fait de la conversion implicite, la littérale 123.456 est changée en la littérale 123.

Inférence de type

On peut légitimement se poser la question de fournir deux fois la même information (le type que l'on souhaite utiliser), surtout que si on n'utilise pas le même type, il y a un risque de conversion implicite et d'erreur.

L'inférence de type consiste à laisser le compilateur définir le type d'une variable, en fonction de l'expression qu'on lui donne pour initialiser une variable. Cette expression peut être une littérale, une autre variable, une expression mathématique, une fonction, etc. La syntaxe pour utiliser l'inférence de type est la suivante :

auto variable = expression;

Le mot-clé auto remplace le type de la variable et sert à indiquer au compilateur d'utiliser l'inférence de type.

Il n'est pas possible d'utiliser les syntaxes de déclaration des variables avec auto et l'initialisation avec des crochets.

Avec l'initialisation par défaut auto i {};, il n'a pas d'expression donnée et donc le compilateur n'a pas de moyen de déduire le type qu'il doit utiliser.

Avec l'initialisation avec une valeur auto i { 123 };, le type déduit n'est pas un entier, mais un tableau d'entier contenant une valeur (ce type de tableau s'appelle “initializer list”). Ainsi, si vous essayez par exemple de réaliser une addition, cela produira une erreur (l'addition d'un tableau “initializer list” et d'un entier n'est pas définie).

main.cpp
#include <iostream>
 
int main() {
    auto i { 123 };
    std::cout << i + 1 << std::endl;
}

affichera l'erreur suivante :

main.cpp:5:20: error: invalid operands to binary expression 
('std::initializer_list<int>' and 'int')
    std::cout << i + 1 << std::endl;
                 ~ ^ ~          

Vous verrez dans les prochains chapitres comment utiliser les tableaux.

La déduction du type est relativement simple dans le cas d'initialisation par des littérales ou une expression :

auto i = 123;     // int
auto j = 12 + 45; // int
auto k = i;       // int

auto d = 123.456; // double
auto b = true;    // bool

Cette écriture présente deux avantages par rapport à la déclaration de variables en explicitant le type. Premièrement, cela évite de devoir écrire le type. Avec des types simples, l'avantage est réduit, mais certains types complexes sont long à écrire et source potentielle d'erreur d'écriture. Vous verrez en particulier par la suite l'utilisation des itérateurs pour parcourir les conteneurs, dont l'écriture est assez lourde.

Le second avantage est d'avoir un code plus évolutif. En effet, imaginons que l'on réalise plusieurs calculs numériques complexes. Si on écrit explicitement le type et que, par la suite, on souhaite en utiliser un autre; il faudra alors modifier plusieurs lignes de code, ce qui diminue la maintenabilité du code.

Par exemple, la formule permet de calculer la fonction de Gauss (loi normale centrée réduite) :

$$ f(x) = \frac 1 { \sqrt {2 \pi} } e ^ { - \frac {x^2} 2 } $$

Le code correspondant est relativement simple :

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto value = 1.0f; // float
    auto result = std::exp(-value*value/2);
    std::cout << sizeof(result) << std::endl;
}

Dans ce code, le type de la variable value est déduite de la littérale, le type est donc un float. De même pour la variable result. Maintenant, imaginons que l'on réalise que le calcul n'est pas assez précis avec un float et que l'on souhaite utiliser un type double. Dans le cas précédent, il suffit simplement de modifier la littérale pour modifier le type des variables value et result. Selon le type de la littérale, le code affichera 4 (taille d'un float) ou 8 (taille d'un double).

Vous avez déjà vu l'utilisation de using pour éviter en partie ce problème. L'utilisation de auto peut être une autre solution. Vous verrez également par la suite la programmation générique, ou autre technique permettant au compilateur de déduire les types.

Selon le contexte et les contraintes, l'une des approches sera peut-être préférable à une autre. Le principal pour vous est d'avoir le choix, mais cela implique de se poser la question du design de son code et de bien comprendre les implications de la décision effectuée.

Obtenir le type d'une expression

Dans le code précédent, le calcul est en fait faux. En effet, dans la formule donnée, il faut multiplier la valeur retournée par la fonction exponentielle par une constante $\frac 1 { \sqrt {2 \pi} }$ pour obtenir la valeur exacte de la fonction gaussienne. Si on modifie le code pour ajouter cette constante, on ajoute une seconde littérale dans le calcul.

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto value = 1.0f; // float
    auto result = std::exp(-value * value/2) * 0.398942;
    std::cout << sizeof(result) << std::endl;
}

Le type de la variable result n'est pas alors déduit uniquement de la première littérale (utilisée pour initialiser la variable value), mais de deux littérales. Ici, comme on utilise des float et des double dans un calcul, le compilateur va utiliser le type le plus large, pour éviter une perte d'information (donc double). Pour modifier le type déduit pour la variable result, il faut donc modifier les deux littérales. On en revient donc au problème de devoir modifier le code à plusieurs endroit, ce qui augmente le risque d'erreur (si on oublie par exemple de modifier une des deux littérales).

La raison est simple : en utilisant auto, on ne dit pas au compilateur “pour la variable result, utilise le même type que pour la variable value”, mais “déduit le type de la variable result en fonction de l'expression”. Si l'expression ne donne pas exactement le type que l'on avait prévu, on n'aura pas forcement le type attendu.

Heureusement, il existe la fonction decltype (declare type, déclarer le type en français) pour récupérer le type d'une expression et l'utiliser pour définir une variable. L'expression est donnée entre parenthèses :

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto value = 1.0f; // float
    decltype(value) result = std::exp(-value * value/2) * 0.398942;
    std::cout << sizeof(result) << std::endl;
}

Dans ce code, on a simplement remplacé auto par decltype(value), ce qui permet de dire explicitement au compilateur : “pour la variable result, tu récupères le type de la variable value”.

Cela commence à faire beaucoup de méthodes pour déclarer une variable (et il reste les fonctions génériques, que vous verrez dans les prochains chapitres). Il peut être compliqué de choisir quelle est la meilleure approche à utiliser dans ces conditions. Lorsque vous verrez les fonctions génériques, cette question sera détaillée.

Retenez simplement, pour le moment, qu'un type explicitement écrit dans le code est une ligne de code qu'il faudra modifier manuellement si vous changez d'avis. Un type déduit ne nécessitera pas de correction de votre part (sauf erreur d'écriture).

auto, decltype et const

Le mot-clé auto permet de déduire le type d'une expression, mais pas les modificateurs comme la constance ou les références (que vous verrez par la suite). Ainsi, dans le code précédent,

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto value = 1.0f; // float
    decltype(value) result = std::exp(-value*value/2) * 0.398942;
    std::cout << sizeof(result) << std::endl;
}

la variable value n'est pas une constante pour le compilateur et vous pouvez tout à fait modifier cette variable. Si vous avez décidé qu'une variable devait être constante, il faudra donc utiliser le mot-clé const, comme vous l'avez fait auparavant.

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto const value = 1.0f; // float
    decltype(value) result = std::exp(-value*value/2) * 0.398942;
    std::cout << sizeof(result) << std::endl;
}

De son côté, la fonction decltype permet de récupérer le type complet d'une expression, constance incluse. Dans le code précédent, la variable value étant déclarée comme constante, la variable result l'est aussi.

auto i = 1;
decltype(i) j = i;
i += 1; // ok, non const
j += 1; // ok, non const
 
auto const k = 1;
decltype(k) l = k;
k += 1; // erreur, k est const
l += 1; // erreur, l est const

Bien sûr, selon le contexte, vous souhaiterez peut-être utiliser decltype avec un type constant, mais pour déclarer un type non constant. Heureusement, cette situation a été prévue et il est possible de supprimer la constance d'un type déduit avec decltype. Pour cela, vous devez utiliser la fonction générique std::remove_const (“supprimer const” en français) du fichier d'en-tête <type_traits> (pour rappel, une fonction générique utilise des chevrons) :

std::remove_const<T>::type

“T” est le type de la variable. Ainsi, le code précédent peut alors s'écrire :

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto const value = 1.0f; // float
    std::remove_const<decltype(value)>::type result = std::exp(-value * value/2) * 0.398942;
    result += 1.0; // ok, result n'est pas constant
    std::cout << sizeof(result) << std::endl;
}

Attention à l'utilisation de l'espace de noms std : les mots-clés auto et decltype sont des fonctionnalités du langage C++, il ne faut pas utiliser std::. Au contraire, remove_const est une fonctionnalité de la bibliothèque standard et nécessite donc l'utilisation de std::.

En cas de doute sur l'utilisation de l'espace de noms, il ne faut pas hésiter à vérifier dans la documentation. Ou vous pouvez lire les messages du compilateur.

La déclaration de la variable result peut sembler un peu compliqué, mais elle respecte la syntaxe type identifiant = expression. Le code std::remove_const<decltype(value)>::type peut être lu comme “on prend la variable value, on récupère son type avec decltype puis on retire const de ce type”.

Donc, pour résumer, avec auto il faut ajouter const si on veut que la variable soit constante. Avec decltype, il faut retirer const si on ne veut pas que la variable soit constante. Le tableau suivant résume la situation :

Type Déclaration Type déduit
T auto T
T const auto T
T auto const T const
T const auto const T const
T decltype T
T const decltype T const
T remove_const<decltype> T
T const remove_const<decltype> T

En complément, il existe une fonction permettant de faire l'inverse de std::remove_const<T>::type, c'est-à-dire pour ajouter const à un type : std::add_const<T>::type.

Vérifier les types déduits

La déduction de type est une technique puissante pour avoir un code plus évolutif, mais cela peut compliquer la situation si on utilise n'importe quel type. Sur le code précédent, imaginons que le programme fasse plusieurs milliers de lignes de code et que le calcul de la fonction de Gauss et l'initialisation de la variable value soient éloignés. Il sera possible dans cette situation d'oublier qu'il faut obligatoirement utiliser un type numérique, ce qui peut provoquer des erreurs de compilation pas forcément très explicites.

Par exemple, si on remplace la littérale de value par une littérale chaîne de caractères.

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto const value = "hello";
    decltype(value) result = std::exp(-value * value/2) * 0.398942;
    std::cout << result << std::endl;
}

Bien sûr, le calcul n'a aucun sens et le compilateur signale qu'il y a un problème dans le code :

main.cpp:6:39: error: invalid argument type 'const char *' to unary expression
    decltype(value) result = std::exp(-value * value/2) * 0.398942;
                                      ^~~~~~
1 error generated.

Le message d'erreur n'est pas forcement clair (et un compilateur C++ peut être très doué pour donner des messages d'erreur incompréhensibles…). Et la situation peut être pire, si par exemple on utilise une littérale qui ne sera pas correctement interprétée. Par exemple, en utilisant un booléen :

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto const value = true;
    decltype(value) result = std::exp(-value * value/2) * 0.398942;
    std::cout << result << std::endl;
}

Dans ce cas, le programme compile sans erreur et affiche 1, ce qui n'a aucun sens. Ce type d'erreur est très compliqué à corriger, puisqu'il n'y a aucune erreur générée et si on ne connait pas le résultat attendu, il sera très difficile de réaliser que le code ne donne pas le résultat correct. En terme de qualité logiciel, cela correspond au critère de fiabilité. Il est possible d'écrire des tests unitaires pour vérifier en partie la fiabilité d'un code (vous verrez cela dans un prochain chapitre), mais cette approche ne permet pas d'apporter 100 % de garantie.

Dans le code précédent, nous savons, au moment où l'on écrit le calcul, qu'il faut obligatoirement utiliser un type réel. L'idéal serait donc que celui qui écrit le code du calcul ajoute une vérification des types utilisés et affiche systématiquement un message d'erreur si le type ne convient pas.

En fait, la solution à ce problème a été vue (en partie) dans les chapitres précédents. En effet, dans le chapitre Obtenir des informations sur les types, vous avez vu les classes de traits pour vérifier les types, en particulier std::is_floating_point<T>::value. Pour rappel, cette fonctionnalité permet de vérifier qu'un type représente un nombre réel.

Vous pouvez alors utiliser cette fonctionnalité avec la fonction static_assert qui permet de tester une condition (une valeur booléenne) à la compilation et afficher un message d'erreur si la condition n'est pas respectée. La syntaxe de static_assert est la suivante :

static_assert(expression booléenne, message);

Nous pouvons donc modifier le code précédent pour ajouter cette condition :

main.cpp
#include <iostream>
#include <cmath>
 
int main() {
    auto const value = 1.0f;
 
    static_assert(std::is_floating_point<decltype(value)>::value, "value n'est pas un nombre réel");
 
    std::remove_const<decltype(value)>::type result = std::exp(-value * value/2) * 0.398942;
    std::cout << result << std::endl;
}

Ce code compilera sans problème. Par contre, si on remplace la littérale par une littérale booléenne (ou n'importe quelle autre littérale non réelle), le compilateur va retourner le message d'erreur suivant :

main.cpp:7:5: error: static_assert failed "value n'est pas un nombre réel"
    static_assert(std::is_floating_point<decltype(value)>::value, "value n'est pas un nombre réel");
    ^             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.

Ce type d'approche est assez généraliste et est basée sur une idée simple : celui qui écrit un code est celui qui sait le mieux comment ce code doit être utilisé. C'est donc lui le mieux placé pour vérifier que celui qui utilise son code l'utilise de façon correcte. On appelle ce concept une précondition, une condition qui doit être vérifiée par le code utilisateur.

Vous verrez une généralisation de cette approche avec la programmation par contrat, dans la partie sur la programmation objet.

Pour vous aider à écrire des conditions sur les types, vous pouvez utiliser l'ensemble des classes de traits (dans la documentation), combiner plusieurs vérifications ou créer vos propres classes de traits.

Limites de l'inférence de type

Pour terminer ce chapitre sur l'inférence de type, il faut examiner un peu en détail le cas des chaînes de caractères. Vous avez vu dans les chapitres précédents qu'il était possible de connaître le nombre de caractères d'une chaîne en utilisant la fonction membre size :

main.cpp
#include <iostream>
#include <string>
 
int main() {
    std::string s { "hello, world!" };
    std::cout << s.size() << std::endl;
}

affiche :

13

Essayez maintenant avec auto :

main.cpp
#include <iostream>
#include <string>
 
int main() {
    auto s = "hello, world!" ;
    std::cout << s.size() << std::endl;
}

Ce code affiche l'erreur de compilation suivante :

main.cpp:6:19: error: member reference base type 'const char *' is not a structure or union
    std::cout << s.size() << std::endl;
                 ~^~~~~
1 error generated.

En effet, pour le compilateur, la fonction size n'existe pas dans ce cas. Pour lui, la variable s est de type const char * (comme indiqué dans le message d'erreur) et non du type string. C'est une particularité du C++ hérité du C : en C++, il faut utiliser string pour manipuler une chaîne, mais une littérale chaîne est une chaîne de type C.

Lorsque l'on initialise une variable string, le compilateur s'occupe de faire la conversion :

std::string const s { "hello, world!" };

Mais avec auto, le compilateur utilise le type déduit, c'est-à-dire la chaîne style C. Pour autant, il est possible d'utiliser quand même auto avec string. Il faut simplement dans ce cas écrire explicitement le type que l'on souhaite utiliser :

auto const s = std::string { "hello, world!" };
std::cout << s.size() << std::endl; // ok, s est bien un string

Avec ce code, l'expression permet de créer explicitement un objet de type string et celui-ci est utilisé par le compilateur pour déduire correctement le type de la variable s.

Le type déduit par auto dépend du type de la littérale ou de l'expression utilisée pour initialiser la variable. Mais il n'est parfois pas possible d'écrire une littérale ou une expression avec certains types (par exemple, on peut créer des littérales long int et long long int en utilisant les suffixes L et LL, mais on ne peut pas créer une littérale de type short int, int8_t, int16_t, etc.). Dans ce cas, on peut réaliser un cast explicite de la littérale ou expression dans le type que l'on souhaite utiliser et on utilise auto pour la déduction de type (qui est donc forcement dans ce cas là le même type que le cast).

auto const s = std::string { "hello, world!" };
 
std::string const s { "hello, world!" };

Dans les deux cas, il n'y a pas redondance inutile d'information.

Il existe une règle, A.A.A. (Almost Always Auto), qui préconise d'utiliser le plus souvent auto, en utilisant un cast si l'on souhaite un type en particulier. L'intérêt en termes de qualité de code est limité. Cela est intéressant pour indiquer explicitement que l'on souhaite un type en particulier, mais en C++11.

// en C++03
 
short int i = foo();
// ancienne façon, on peut avoir un cast implicite si
// foo ne retourne pas un short int
 
// en C++11
 
auto i = foo();
// nouvelle façon, si on veut que i soit toujours du
// type retourné par foo()
 
short int i = foo();
// type explicite sans auto, cela veut dire que l'on
// souhaite un short int ou que l'on a oublié de mettre
// un jour un ancien code C++03 ?
 
auto i = static_cast<short int>(foo());
// type explicite avec auto, on sait que l'on
// veut toujours transformer le type retourné par foo
// en short int
inference_de_type.1438365232.txt.gz · Dernière modification: 2015/07/31 19:53 par 77.148.2.219