Outils d'utilisateurs

Outils du Site


inference_de_type

Ceci est une ancienne révision du document !


L'inférence de type

Conversion implicite de types

Lorsque vous définissez une variable, vous pouvez l'initialiser avec une littérale de même type que le type de la variable ou avec un type différent. Si le type de la variable et de la littérale n'est pas parfaitement identiques, 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 perte d'information, le compilateur signalera cette conversion implicite (narrowing). Une perte d'information peut avoir lieu par exemple dans les conversions suivantes :

  • une littérale de type réel dans une variable de type entier ;
  • une littérale signée (qui peut être positive ou négative) dans une variable non signée (qui peut être que positive) ;
  • une littérale trop grande dans une variable de type ne pouvant contenir cette valeur.
int i { 123.0 };        // double dans un int
unsigned int ui { -1 }; // signed dans un unsigned 
char c { 123456789 };   // int dans un char

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() {
    const int i { 123.456 };
    std::cout << i << std::endl;
}

affiche :

main.cpp:4:19: error: type 'double' cannot be narrowed to 'int' in initializer list [-Wc++11-narrowing]
    const int i { 123.456 };
                  ^~~~~~~
main.cpp:4:19: note: insert an explicit cast to silence this issue
    const int i { 123.456 };
                  ^~~~~~~
                  static_cast<int>( )
main.cpp:4:19: warning: implicit conversion from 'double' to 'int' changes value from 123.456 to 
123 [-Wliteral-conversion]
    const 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ées -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 que le compilateur ne peut deviner si la conversion est une erreur ou un choix volontaire du développeur. Pour lever cette ambiguïté, il demande donc au développeur d'écrire explicitement cette conversion. Vous verrez dans un prochain chapitre l'utilisation de static_cast pour ce cas d'utilisation.

Le dernier message d'avertissement prévient que du fait de la conversion implicite, la littérale 123.456 est arrondie et la variable est initialisée avec la valeur 123.

Inférence de type

Utiliser des types différents pour une variable et une littérale lors de l’initialisation peut poser des problèmes. Il est donc logique d'appliquer (sauf cas particuliers) la bonne pratique qui consiste à utiliser le même type pour les deux. Mais dans ce cas, vous pouvez légitimement vous poser la question suivante : pourquoi devoir définir deux fois le type, au risque que cela pose problème ? Ne serait-il pas possible de le définir une seule fois ?

Cela est possible en C++, grâce à l'inférence de type, qui permet au compilateur de déduire automatiquement le type d'une variable à partir d'une expression. Il existe trois formes possibles pour définir une variable en C++ utilisant l'inférence de type, basées sur une même syntaxe :

<EXPRESSION> IDENTIFIANT = VALEUR;

Le type de la variable est remplacé par <EXPRESSION>, qui peut correspondre à l'un des trois mots-clés suivants :

  • auto : déduit le type à partir de VALEUR, sans conserver les modificateurs de types (const, etc.) ;
  • decltype(auto) : déduit le type à partir de VALEUR, en conservant les modificateurs de types;
  • decltyp(expression) : déduit le type d'une expression.

Cette écriture présente de nombreux avantages par rapport à la définition d'une variable sans inférence. Premièrement, cela évite de devoir écrire le type. Avec des types simples, l'avantage est réduit, mais certains types complexes sont longs à écrire et source potentielle d'erreur lors de leur écriture. Vous verrez en particulier par la suite l'utilisation des itérateurs pour parcourir les collections, dont l'écriture est assez lourde.

Un deuxième avantage est d'avoir un code plus évolutif. Imaginez que vous réalisez plusieurs calculs numériques complexes. Si vous écrivez explicitement le type de toutes vos variables et que vous souhaitez ensuite utiliser un autre type, vous devrez modifier l'ensemble de votre code pour utiliser le nouveau type. En utilisant l'inférence, vous laissez le compilateur s'occuper de cette tâche.

Cependant, l'inférence de type n'est pas toujours utilisable, c'est pour cela qu'il faut connaître les deux syntaxes pour définir une variable.

Le mot-clé auto

Commençons par détailler le mot-clé auto. Ce mot-clé indique au compilateur qu'il doit utiliser le type de VALEUR pour le type de la variable. Par exemple :

main.cpp
int main() {
    const auto i = 123;    // 123 est une littérale de type 
                           // int, donc auto déduit le type int.
                           // Ce dernier est précédé de const
                           // donc i est de type const int.
 
    const auto j = i;      // i est une variable de type const int,
                           // auto déduit me type int
                           // Ce dernier est précédé de const
                           // donc j est de type const int.
 
    const auto x = 12.34;  // 12.34 est une littérale de type
                           // double, auto déduit le type double.
                           // il est précédé de const
                           // donc x est de type const double
 
          auto c = 'a';    // 'a' est de type char, 
                           // auto déduit le type char.
                           // donc c est de type char.
}

Dans tous les cas, le compilateur applique le même processus :

  • évaluer le type de VALEUR ;
  • l'utiliser pour la variable.

Par exemple, la formule permettant 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.

Le mot-clé decltype

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 endroits, 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).

Différence entre auto et decltype

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ée, 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.

Le mot-clé decltype(auto)

syntaxe. Différence avec auto et decltype.

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é 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 forcément 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.1444639284.txt.gz · Dernière modification: 2015/10/12 10:41 par 193.49.27.199