par , 06/01/2016 à 17h03 (3021 Affichages)
Dans le billet précédent, nous avons utilisé une assertion statique. Ces assertions sont faites lors de la compilation : si elles échouent, le programme ne compile pas et – et c'est là l'intérêt principal – le compilateur affiche un message clair, que vous avez défini, plutôt qu'une longue suite d'erreurs template illisibles. La STL C++11 définit un certain nombre de conditions qui peuvent être utilisées dans une expression static_assert. std::is_same<T, U> est l'exemple que nous avons utilisé pour vérifier que l'itérateur fourni à la fonction scanpattern était bien un std::random_access_iterator. Il arrive un point, cependant, où l'on doit définir soi-même la condition de l'assertion statique.
Is it a good processor ?
Prenons une fonction mineTree, par exemple, qui prend pour argument un Processor qui doit posséder un opérateur de fonction applicable à un std::pair<std::vector<Item>, int> (c'est la forme des frequent patterns). Il n'existe pas de condition standard qui permette de vérifier cela et il va falloir créer la nôtre.
La signature de notre condition sera : template <class Type, class Arg> has_func_operator.
Nous pourrons l'utiliser de la façon suivante :
1 2
| static_assert(has_func_operator<Processor, std::pair<std::vector<Item>, int>>::value,
"Bad Processor Error: Processor must implement func operator with signature operator()(Arg)"); |
au début de la fonction mineTree.
Qu'est-ce qu'une condition statique ?
static_assert exige une condition statique, c'est-à-dire une condition qu'il est possible de vérifier à la compilation. Il est donc impossible d'écrire, par exemple :
1 2 3
| char c;
std::cin >> c;
static_assert(c == 'a', "erreur: c != a"); // c'est déterminé à l'exécution ! |
Donc toute la difficulté de l'exercice est d'obtenir l'information sans entrer dans un contexte d'exécution, appelé aussi contexte d'évaluation. Prenons l'exemple de la condition std::is_same, comment peut-elle être implémentée ? Assez simplement, en fait, même avec les versions plus anciennes du standard : on utilise la possibilité de spécialisation partielle des templates, processus qui se déroule entièrement à la compilation :
1 2 3 4 5 6 7 8
| template <class T, class U>
struct is_same {
static const bool value = false; // T et U sont des types différents
};
template <class T>
struct is_same<T, T> { //, mais là T et U sont le même type!
static const bool value = true; // donc is_same::value = true
}; |
Hélas, tout n'est pas si simple
Certaines conditions sont plus difficiles à vérifier que d'autres. Celle que nous recherchons, has_func_operator, ne peut pas être implémentée uniquement avec les spécialisations partielles. On peut de cette façon vérifier le type d'une fonction, mais pas son existence : pour que la spécialisation fonctionne, il faut qu'au moins une des spécialisations soit valide. Il faut trouver une façon d'utiliser le contexte de compilation d'une façon que l'erreur devienne constructive – et c'est exactement le rôle de cette technique nommée SFINAE.
Substitution failure is not an error
L'échec d'une substitution n'est pas une erreur. Décortiquons cela :
l'échec d'une substitution : pour instancier une fonction template surchargée (avec plusieurs signatures), le compilateur regarde les différentes signatures possibles et choisit celle qui est la plus adaptée. C'est le principe de la substitution : on substitue à une signature générique une signature déterminée.
n'est pas une erreur : vous me direz que c'est la même chose pour une fonction normale, sans template : certes, mais avec une différence importante : si une des fonctions normales qui peut être choisie est mal formée, le compilateur refusera de compiler. Ce n'est pas le cas lorsqu'il s'agit d'une fonction template. Pourquoi ? Parce qu'une fonction template qui n'est pas appelée n'est pas instanciée. Pour le compilateur, elle n'existe pas. Donc si elle est mal formée, peu importe -> l'échec d'une substitution n'est pas une erreur.
Concrètement, comment ça marche ?
Comme une fonction template qui n'est pas retenue lors de l'étape de substitution n'est pas instanciée, deux fonctions de même nom peuvent être surchargées aussi bien du côté des arguments que du côté du type de retour. En examinant le type de retour, on peut donc savoir quelle surcharge a été appelée. C'est ainsi qu'on utilisait SFINAE dans les versions du standard antérieures à C++11. Par exemple, voici une astuce pour déterminer si un type est une classe. Elle repose sur le fait qu'une signature comportant un pointeur sur un membre non statique d'un type provoquera un échec de substitution si le type n'est pas une classe :
1 2 3 4
| typedef char is_a_class[2]; // on différencie les types de retour par leur taille
typedef char is_not_a_class; // on est au moins sûr que sizeof(char) == 1
template <class T> is_a_class func(int T::*); // pointeur sur un membre int - cette signature sera utilisée si T est une classe, sinon elle échouera...
template <class T> is_not_a_class func(...); // ...et c'est cette signature qui sera utilisée. |
La moitié du chemin
Nous avons fait la moitié du chemin, reste la deuxième. Comme vous pouvez le constater, les signatures de func ci-dessus ne sont pas définies. Ce n'est pas gênant, car nous devons rester en dehors du contexte d'évaluation ou d'instanciation. Avant C++11, le moyen de rester dans ce contexte était offert par l'opérateur sizeof. C'est la raison pour laquelle j'ai pris deux types de retours dont on peut être certain qu'ils sont de tailles différentes. Nous allons pouvoir encapsuler notre résolution de substitution et résoudre la question de la surcharge retenue :
1 2 3 4 5 6 7 8 9 10
| template <class T>
struct is_class {
// comme avant
typedef char is_a_class[2];
typedef char is_not_a_class;
template <class T> is_a_class func(int T::*);
template <class T> is_not_a_class func(T);
// et on rajoute
static const int value = sizeof(func<T>(0)) == sizeof(is_a_class); // static const: on reste dans le contexte de compilation tant qu'on ne prend pas l'adresse de value (légère simplification)
}; |
De retour au processeur et à C++11
L'implémentation de SFINAE qu'on a vue est très astucieuse, mais c'est de l'histoire ancienne. C++11 offre des ressources plus puissantes pour la métaprogrammation. C'est avec ces ressources nouvelles que nous résoudrons la question initiale, l'écriture de has_func_operator. En voici le code, l'explication vient :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
|
// 1
template <class T, class Arg>
auto constexpr has_func_operator_intern(int) -> decltype(std::declval<T>()(Arg()), bool()) {
return true;
}
// 2
template <class , class>
bool constexpr has_func_operator_intern(...) {
return false;
}
// 3
template <class T, class Arg>
struct has_func_operator {
static const bool value = has_func_operator_intern<T, Arg>(0);
}; |
Nous commençons par la deuxième fonction :
- c'est une fonction constexpr : c'est-à-dire que, sous réserve que son contenu le permette, elle peut-être appelée à la compilation, donc en dehors d'un contexte d'exécution ;
- elle a pour signature l'ellipse (...) : au moment de la substitution, c'est la signature qui a la priorité la plus faible ; elle ne sera utilisée que si toutes les autres substitutions ont échoué.
La première fonction est plus compliquée :
- son type de retour est indiqué après la flèche -> . C'est une nouvelle syntaxe de C++11: le mot-clé auto est utilisé à la place du type de retour et précisé après la flèche ;
- son type de retour est le résultat de l'expression decltype ; comme sizeof, decltype reste dans le contexte de compilation. Elle retourne le type de l'expression donnée en argument ;
- l'argument de decltype est composé autour de l'opérateur virgule : ses deux opérandes sont évalués, mais c'est celui de droite qui est renvoyé ;
- l'opérande de droite initialise un bool ; decltype retournera donc le type bool de même que la fonction has_func_operator_intern ;
- l'opérande de gauche est complexe. Plus simplement on aurait pu l'écrire T()(Arg()), mais cela aurait posé une difficulté: si T n'a pas de constructeur accessible, la substitution échouera. Si T est une lambda, qui pourrait pourtant avoir un opérateur de fonction comme on le recherche, la substitution échouerait. C'est le rôle de std::declval de résoudre ce problème.
- std::declval<T>() retourne une référence sur l'objet T, ce qui permet de l'utiliser – en dehors du contexte d'exécution évidemment, uniquement dans celui de la déduction des types –pour appeler une fonction membre d'une classe sans avoir à invoquer son constructeur. Donc nous avons une référence sur un objet T inexistant qui nous permet d'appeler son opérateur de fonction.
La troisième fonction est toute simple : c'est seulement une enveloppe autour des deux premières qui évite d'utiliser directement SFINAE en écrivant :
has_func_operator_intern<T, Arg>(0);
dans le corps du programme. De plus, elle harmonise l'interface de notre condition statique avec l'interface des conditions proposées par la STL.
En conclusion
Dans notre contexte, SFINAE n'a permis qu'une seule chose : générer un message d'erreur plus lisible si le Processor fourni n'a pas les fonctionnalités suffisantes. Mais ses possibilités sont nombreuses. À vous, maintenant que vous avez l'idée en tête, de faire preuve d'imagination ! Au fur et à mesure que vous entrerez dans les subtilités de SFINAE, vous découvrirez aussi les subtilités du C++ : savoir ce qui appartient au contexte d'évaluation (où tout doit être défini) et au contexte de compilation (où les définitions partielles sont permises) est une question byzantine. Vous pouvez jeter un œil sur cppreference pour débroussailler le terrain. Vous verrez que tant qu'on reste en dehors de l'usage « odr » (comprendre one definition rule,) on reste dans les limites de ce qui peut être réalisé à la compilation.