SFINAE (Substitution Failure is not an Error)

Pour bien saisir les subtilités dans ce qui suit, mieux vaut vous familiariser tout d'abord avec les templates, la programmation générique et les traits. Il est aussi probable que vous soyez intéressé(e) par enable_if.

La programmation générique en C++ repose en partie sur une mécanique charmante nommée SFINAE, pour Substitution Failure is not an Error.

Le nom SFINAE aurait été trouvé par Daveed Vandevoorde, du moins si je me fie sur la mémoire de Walter E. Brown dans sa présentation sur la programmation générique à CppCon 2014

Informellement, lorsque le compilateur voit que le code a recours à une fonction ou à un type générique, il examinera les diverses substitutions possibles pour construire ce qu'on nomme son Overload Set, et conservera la meilleure (la plus proche de l'intention manifeste du code).

Ce que signifie SFINAE est le fait que certaines substitutions potentielles ne soient pas adéquates n'est pas une erreur. Les cas en question sont omis, donc exclues de l'Overload Set, tout simplement, parce qu'ils ne sont pas utilisés.

Exemple simple

L'illustration suivante montre l'impact de SFINAE :

#include <iostream>
using namespace std;
class X {};
struct Y
{
   using type = X;
};
template <class T>
   void f(T) // version générale
   {
      cout << "f(T)" << endl;
   }
template <class T>
   void f(typename T::type) // version plus « pointue »
   {
      cout << "f(T::type)" << endl;
   }
int main()
{
   f<X>(X{});
   f<Y>(X{});
}

À l'exécution, ce programme affiche :

f(T)
f(T::type)

Notez que le programme compile sans heurts. Le premier appel privilégie la version générale parce que le type T (qui y est X) n'a pas de type interne type. Le second appel privilégie la version plus « pointue » parce que le type T (qui y est Y) a un type interne type qui peut (manifestement) être construit à partir d'un X.

Dans le premier cas, SFINAE s'applique du fait que la version spécialisée de f() pour un T::type n'est pas appropriée pour un type T équivalent à X. La version spécialisée n'est donc pas considérée pour la résolution de cet appel de f(), mais cela ne constitue pas une erreur.

Pour un exemple plus pointu (qui vient d'un dénommé Xeo), il est possible de détecter si une fonction est constexpr ou non à l'aide de SFINAE. Par exemple, avec ce qui suit, la constante has_constexpr_f<X>::value sera true seulement si X a une méthode f sans paramètres qui est constexpr :

#include <type_traits>

template<int>
   struct sfinae_true : std::true_type
   {
   };
template<class T>
   sfinae_true<(T::f(), 0)> check(int);
template<class>
   std::false_type check(...);

template<class T>
   struct has_constexpr_f
      : decltype(check<T>(0))
   {
   };

On lit cet exemple comme suit :

Il faut comprendre ici que sfinae_true<int>, étant générique, peut être exclu de l'Overload Set s'il est considéré mal formé. C'est la clé du succès de cette manoeuvre :

La version de check(int) qui retourne un sfinae_true<0> retourne en fait un sfinae_true<(T::f(), 0)>. Ici, l'opérateur , évalue ce qui se trouve à gauche (l'appel à T::f()) puis ce qui se trouve à droite (le littéral 0); la valeur de cette expression composite est celle de l'opérande de droite, donc un int de valeur zéro.

La beauté de cette expression est qu'elle n'est bien formée que si T::f() est constexpr, puisqu'on utilise cet appel dans un contexte statique (les paramètres d'un template doivent être connus à la compilation).

Ainsi, si T::f() n'est pas constexpr, alors la version retournant un sfinae_true<int> est exclue de l'Overload Set et la version de check<T>() qui est retenue est celle qui retourne false_type.

Si les deux versions de check<T>() sont conservées, alors la version acceptant une ellipse (le ...) sera considérée la moins importante, celle retournant un sfinae_true<int> sera retenue, et nous constaterons à la compilation que T::f() est constexpr.

SFINAE et contexte immédiat

Pour que SFINAE s'applique, il faut que le contexte immédiat s'y prête. Ce qui suit est basé sur une explication du brillant et sympathique Jonathan Wakely; pour suivre son argumentaire, il faut distinguer les paramètres d'une template de ceux de la fonction template – dans template <class T> T f(T arg); on parle de T dans le premier cas, et d'arg dans le second.

Considérons d'abord tous les templates et toutes les fonctions implicitement définies requises pour déterminer le résultat de la substitution des paramètres aux appels de fonctions templates, et supposons qu'elles sont générées avant même le début des substitutions. Toute erreur dans cette première étape, si elle n'est pas dans le contexte immédiat (dans les paramètres génériques du template, pas de la fonction), est une erreur, simplement.

Si ces instanciations et ces définitions implicites, incluant la « définition » de fonctions =delete, se complètent sans erreurs, alors les « erreurs » qui suivront et découleront de la substitution des paramètres du template seront des échecs de substitution, pas des erreurs en tant que telles.

L'exemple de Jonathan utilise les deux fonctions templates et le type générique suivants :

template <class T>
   void func(typename T::type* arg); // cas de base
template<class>
   void func(...); // plan «B» si le cas «de base» est un échec de substitution
template<class T>
   struct A
   {
      using type = T*;
   };

Étant donné ces déclarations, un appel à func<A<int&>>(nullptr) substituera A<int&> pour le type T. Ceci demandera d'instancier A<int&> pour savoir si T::type existe.

Supposons alors que l'on ait inséré une instanciation explicite avant l'appel à func<A<int&>(nullptr), et que cette instanciation soit de la forme :

template class A<int&>;

...alors, nous aurions un échec pour cette instanciation, car elle chercherait à créer le type int&* alors qu'il est illégal en C++ de tenir un pointeur sur une référence. Dans ce cas, nous ne nous rendrions même pas au point de tenter une substitution, étant placés face à une erreur pure et dure avec l'instanciation de A<int&>.

Maintenant, supposons une spécialisation explicite de A, de la forme suivante :

template <>
   struct A<char>
   {
   };

Alors, un appel à func<A<char>>(nullptr) demandera d'instancier le type A<char>. Maintenant, supposons une instanciation explicite de la forme suivante mais placée ici encore avant l'appel :

template class A<char>;

...ou template struct A<char>; si votre compilateur n'est pas à jour. Nous avons cette fois une instanciation correcte, ce qui nous permet de procéder à la substitution des paramètres du template. Ici, bien que A puisse être instancié, il se trouve que A::type n'existe pas. Cela ne brise pas le programme pour autant : ce type mène le compilateur à exclure le cas de base du Overload Set et le « plan B » est sélectionné.

Expression SFINAE

Les expression SFINAE sont une caractéristique de C++ 14 qui permet d'alléger considérablement certaines manoeuvres de métaprogrammation, en particulier dans le recours à enable_if. C++ offre SFINAE pour les types et les constantes depuis longtemps, mais SFINAE sur les expressions est né d'un besoin récent dans des bibliothèques sophistiquées (merci à Eric Niebler, en particulier, pour avoir signalé ce besoin).

Pour les compilateurs suffisamment à jour, l'expression SFINAE permet à un programme comme le suivant de compiler (code tiré de http://en.cppreference.com/w/cpp/language/sfinae) :

struct X {};
struct Y { Y(X){} };
template <class T>
   auto f(T t1, T t2) -> decltype(t1 + t2); // cas 0
X f(Y, Y);                                  // cas 1
int main()
{
   X x0, x1;
   X res = f(x0, x1);
}

Cet exemple se lit comme suit :

Lectures complémentaires

Quelques liens pour enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !