Comment rendre SFINAE élégant et fiable

Bonjour encore. Nous partageons avec vous un article intéressant, dont la traduction a été préparée spécialement pour les étudiants du cours "Développeur C ++" .





Aujourd'hui, nous avons un message d'invité de dám Balázs. Adam est ingénieur logiciel chez Verizon Smart Communities Hungary et développe des analyses vidéo pour les systèmes embarqués. L'une de ses passions est l'optimisation du temps de compilation, il a donc immédiatement accepté d'écrire un article invité sur ce sujet. Vous pouvez trouver Adam en ligne sur LinkedIn .

Dans une série d'articles sur la façon de rendre SFINAE élégant , nous avons vu comment rendre notre modèle SFINAE assez concis et expressif .

Jetez un œil à sa forme originale:

template<typename T> class MyClass { public: void MyClass(T const& x){} template<typename T_ = T> void f(T&& x, typename std::enable_if<!std::is_reference<T_>::value, std::nullptr_t>::type = nullptr){} }; 


Et comparez-le avec cette forme plus expressive:

 template<typename T> using IsNotReference = std::enable_if_t<!std::is_reference_v<T>>; template<typename T> class MyClass { public: void f(T const& x){} template<typename T_ = T, typename = IsNotReference <T_>> void f(T&& x){} }; 

Nous pouvons raisonnablement croire qu'il est déjà possible de se détendre et de commencer à l'utiliser en production. Nous pourrions, cela fonctionne dans la plupart des cas, mais - comme nous parlons d'interfaces - notre code devrait être sûr et fiable. En est-il ainsi? Essayons de le pirater!

Défaut n ° 1: SFINAE peut être contourné


En règle générale, SFINAE est utilisé pour désactiver une partie du code en fonction de la condition. Cela peut être très utile si nous devons implémenter, par exemple, la fonction définie par l'utilisateur abs pour une raison quelconque (classe arithmétique définie par l'utilisateur, optimisation pour un équipement spécifique, à des fins de formation, etc.):

 template< typename T > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } int main() { int a{ std::numeric_limits< int >::max() }; std::cout << "a: " << a << " myAbs( a ): " << myAbs( a ) << std::endl; } 

Ce programme affiche les éléments suivants, ce qui semble tout à fait normal:

 a: 2147483647 myAbs( a ): 2147483647 

Mais nous pouvons appeler notre fonction abs avec des arguments non signés T , et l'effet sera catastrophique:

 nt main() { unsigned int a{ std::numeric_limits< unsigned int >::max() }; std::cout << "a: " << a << " myAbs( a ): " << myAbs( a ) << std::endl; } 

En effet, maintenant le programme affiche:

a: 4294967295 myAbs( a ): 1

Notre fonction n'a pas été conçue pour fonctionner avec des arguments non signés, nous devons donc limiter l'ensemble possible de T avec SFINAE:

 template< typename T > using IsSigned = std::enable_if_t< std::is_signed_v< T > >; template< typename T, typename = IsSigned< T > > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } 

Le code fonctionne comme prévu: un appel à myAbs avec un type non signé provoque une erreur de compilation:

candidate template ignored: requirement 'std::is_signed_v< unsigned int>' was not satisfied [with T = unsigned int]

Hacking SFINAE State


Alors qu'est-ce qui ne va pas avec cette fonction? Pour répondre à cette question, nous devons vérifier comment myAbs implémente SFINAE.

 template< typename T, typename = IsSigned<T> > T myAbs( T val ); 

myAbs est un modèle de fonction avec deux types de paramètres de modèle d'entrée. Le premier est le type réel de l'argument de la fonction, le second est le type anonyme par défaut IsSigned < T > (sinon std::enable_if_t < std::is_signed_v < T > > ou bien std::enable_if < std::is_signed_v < T>, void>::type , qui est une substitution void ou ayant échoué).

Comment pouvons-nous appeler myAbs ? Il y a 3 façons:

 int a{ myAbs( -5 ) }; int b{ myAbs< int >( -5 ) }; int c{ myAbs< int, void >( -5 ) }; 

Les premier et deuxième appels sont simples, mais le troisième est intéressant: quel est l'argument du modèle void ?

Le deuxième paramètre de modèle est anonyme, a un type par défaut, mais il s'agit toujours d'un paramètre de modèle, vous pouvez donc le spécifier explicitement. Est-ce un problème? Dans ce cas, c'est vraiment un énorme problème. Nous pouvons utiliser le troisième formulaire pour contourner notre chèque SFINAE:

 unsigned int d{ myAbs< unsigned int, void >( 5u ) }; unsigned int e{ myAbs< unsigned int, void >( std::numeric_limits< unsigned int >::max() ) }; 

Ce code compile bien, mais conduit à des résultats désastreux, pour éviter que nous ayons utilisé SFINAE:

 a: 4294967295 myAbs( a ): 1 

Nous allons résoudre ce problème - mais d'abord: y a-t-il d'autres inconvénients? Et bien ...

Défaut n ° 2: nous ne pouvons pas avoir d'implémentations spécifiques


Une autre utilisation courante de SFINAE est de fournir des implémentations spécifiques pour des conditions de compilation spécifiques. Et si nous ne voulons pas interdire complètement l'appel de myAbs avec myAbs valeurs myAbs et fournir une implémentation triviale pour ces cas? Nous pouvons utiliser if constexpr en C ++ 17 (nous en discuterons plus tard), ou nous pouvons:

  template< typename T > using IsSigned = std::enable_if_t< std::is_signed_v< T > >; template< typename T > using IsUnsigned = std::enable_if_t< std::is_unsigned_v< T > >; template< typename T, typename = IsSigned< T > > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } template< typename T, typename = IsUnsigned< T > > T myAbs( T val ) { return val; } 

Mais c'est quoi?

 error: template parameter redefines default argument template< typename T, typename = IsUnsigned< T > > note: previous default template argument defined here template< typename T, typename = IsSigned< T > > 

Oh, la norme C ++ (C ++ 17; §17.1.16) stipule ce qui suit :

"Les arguments par défaut ne doivent pas être fournis au paramètre de modèle par deux déclarations différentes dans la même portée."

Oups, c'est exactement ce que nous avons fait ...

Pourquoi ne pas utiliser régulièrement si?


Nous pourrions simplement utiliser if lors de l'exécution à la place:

 template< typename T > T myAbs( T val ) { if( std::is_signed_v< T > ) { return ( ( val <= -1 ) ? -val : val ); } else { return val; } } 

Le compilateur optimiserait la condition car if (std::is_signed_v < T>) devient if (true) ou if (false) après avoir créé le modèle. Oui, avec notre implémentation actuelle de myAbs cela fonctionnera. Mais dans l'ensemble, cela impose une énorme limitation: les else if et else doivent être valides pour chaque T Et si nous modifions un peu notre implémentation:

 template< typename T > T myAbs( T val ) { if( std::is_signed_v< T > ) { return std::abs( val ); } else { return val; } } int main() { unsigned int a{ myAbs( 5u ) }; } 

Notre code plantera immédiatement:

 error: call of overloaded 'abs(unsigned int&)' is ambiguous 

Cette restriction est ce que SFINAE élimine: nous pouvons écrire du code qui n'est valide que pour un sous-ensemble de T (dans myAbs, il n'est valide que pour les types non signés ou valide uniquement pour les types signés).

Solution: un autre formulaire pour SFINAE


Que pouvons-nous faire pour surmonter ces lacunes? Pour le premier problème, nous devons forcer notre vérification SFINAE quelle que soit la façon dont les utilisateurs invoquent notre fonction. Actuellement, notre test peut être contourné lorsque le compilateur n'a pas besoin du type par défaut pour le deuxième paramètre de modèle.

Que faire si nous utilisons notre code SFINAE pour déclarer un type de paramètre de modèle au lieu de fournir un type par défaut? Essayons:

 template< typename T > using IsSigned = std::enable_if_t< std::is_signed_v< T >, bool >; template< typename T, IsSigned< T > = true > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } int main() { //int a{ myAbs( 5u ) }; int b{ myAbs< int >( 5u ) }; //int c{ myAbs< unsigned int, true >( 5u ) }; } 

Nous avons besoin que IsSigned soit un type autre que void dans les cas valides, car nous voulons fournir une valeur par défaut pour ce type. Il n'y a pas de valeur pour le type void, nous devons donc utiliser autre chose: bool, int, enum, nullptr_t, etc. Habituellement, j'utilise bool - dans ce cas, les expressions ont un sens:

 template< typename T, IsSigned< T > = true > 

Ça marche! Pour myAbs (5u) compilateur renvoie une erreur, comme précédemment:

 candidate template ignored: requirement 'std::is_signed_v<unsigned int>' was not satisfied [with T = unsigned int 

Le deuxième appel, myAbs < int> (5u) est toujours valide, nous indiquons explicitement le type de compilateur T , donc il convertit 5u en int .

Enfin, nous ne pouvons plus tracer myAbs autour du doigt: myAbs < unsigned int, true> (5u) renvoie une erreur. Peu importe que nous fournissions une valeur par défaut dans l'appel ou non, une partie de l'expression SFINAE est quand même évaluée, car le compilateur a besoin d'un type d'argument d'une valeur de modèle anonyme.

Nous pouvons passer au problème suivant - mais attendez une minute! Je pense que nous ne remplaçons plus l'argument par défaut pour le même paramètre de modèle. Quelle était la situation d'origine?

 template< typename T, typename = IsUnsigned< T > > T myAbs( T val ); template< typename T, typename = IsSigned< T > > T myAbs( T val ); 

Mais maintenant avec le code actuel:

 template< typename T, IsUnsigned< T > = true > T myAbs( T val ); template< typename T, IsSigned< T > = true > T myAbs( T val ); 

Il ressemble beaucoup au code précédent, nous pouvons donc penser que cela ne fonctionnera pas non plus, mais en fait ce code n'a pas le même problème. Qu'est-ce que IsUnsigned < T> ? Recherche booléenne ou échouée. Et qu'est-ce que IsSigned < T> ? Même chose, mais si l'un d'eux est Bool, l'autre est une recherche échouée.

Cela signifie que nous ne remplaçons pas les arguments par défaut, car il n'y a qu'une fonction avec l'argument de modèle bool, l'autre est une substitution ayant échoué, elle n'existe donc pas.

Sucre syntaxique


UPD Ce paragraphe a été supprimé par l'auteur en raison d'erreurs qui s'y trouvaient.

Anciennes versions de C ++


Tout ce qui précède fonctionne avec C ++ 11, la seule différence est la verbosité des définitions des restrictions entre les versions standard:

 //C++11 template< typename T > using IsSigned = typename std::enable_if< std::is_signed< T >::value, bool >::type; //C++14 - std::enable_if_t template< typename T > using IsSigned = std::enable_if_t< std::is_signed< T >::value, bool >; //C++17 - std::is_signed_v template< typename T > using IsSigned = std::enable_if_t< std::is_signed_v< T >, bool >; 

Mais le modèle reste le même:

 template< typename T, IsSigned< T > = true > 

Dans le bon vieux C ++ 98, il n'y a pas d'alias de modèle, en outre, les modèles de fonction ne peuvent pas avoir de types ou de valeurs par défaut. Nous pouvons insérer notre code SFINAE dans le type de résultat ou uniquement dans la liste des paramètres de fonction. La deuxième option est recommandée car les constructeurs n'ont pas de type de résultat. Le mieux que nous puissions faire est quelque chose comme ceci:

 template< typename T > T myAbs( T val, typename my_enable_if< my_is_signed< T >::value, bool >::type = true ) { return( ( val <= -1 ) ? -val : val ); } 

Juste pour comparaison - la version moderne de C ++:

 template< typename T, IsSigned< T > = true > T myAbs( T val ) { return( ( val <= -1 ) ? -val : val ); } 

La version C ++ 98 est laide, introduit un paramètre vide de sens, mais cela fonctionne - vous pouvez l'utiliser si c'est absolument nécessaire. Et oui: my_enable_if et my_is_signed doivent être implémentés ( std :: enable_if std :: is_signed étaient nouveaux en C ++ 11).

État actuel


C ++ 17 a introduit if constexpr , une méthode pour éliminer le code basé sur les conditions au moment de la compilation. Les instructions if et else doivent être syntaxiquement correctes, mais la condition sera évaluée au moment de la compilation.

 template< typename T > T myAbs( T val ) { if constexpr( std::is_signed_v< T > ) { return( ( val <= -1 ) ? -val : val ); } else { if constexpr( std::is_unsigned_v< T > ) { return val; } /*else { static_assert( false, "T must be signed or unsigned arithmetic type." ); }*/ } } 

Comme nous pouvons le voir, notre fonction abs est devenue plus compacte et plus facile à lire. Cependant, la gestion des types non conformes n'est pas simple. L' static_assert inconditionnelle static_assert rend cette instruction peu cohérente, ce qui est interdit par la norme, qu'elle soit ignorée ou non.

Heureusement, il existe une faille: dans les objets de modèle, les opérateurs supprimés ne sont pas créés si la condition est indépendante de la valeur. Super!

Donc, le seul problème avec notre code est qu'il se bloque lors de la définition du modèle. Si nous pouvions différer l'évaluation de static_assert jusqu'à la création du modèle, le problème serait résolu: il serait créé si et seulement si toutes nos conditions étaient fausses. Mais comment pouvons-nous différer static_assert jusqu'à ce que le modèle soit créé? Faites dépendre son état du type!

 template< typename > inline constexpr bool dependent_false_v{ false }; template< typename T > T myAbs( T val ) { if constexpr( std::is_signed_v< T > ) { return( ( val <= -1 ) ? -val : val ); } else { if constexpr( std::is_unsigned_v< T > ) { return val; } else { static_assert( dependent_false_v< T >, "Unsupported type" ); } } } 

À propos de l'avenir


Nous sommes déjà très proches, mais nous devons attendre un peu jusqu'à ce que C ++ 20 apporte la solution finale: les concepts! Cela changera complètement la façon dont les modèles (et SFINAE) sont utilisés.

En résumé: les concepts peuvent être utilisés pour limiter l'ensemble d'arguments acceptés pour les paramètres de modèle. Pour notre fonction abs, nous pourrions utiliser le concept suivant:

 template< typename T > concept bool Arithmetic() { return std::is_arithmetic_v< T >; } 

Et comment utiliser les concepts? Il existe trois façons:

 //   template< typename T > requires Arithmetic< T >() T myAbs( T val ); //   template< Arithmetic T > T myAbs( T val ); //  Arithmetic myAbs( Arithmetic val ); 

Notez que le troisième formulaire déclare toujours une fonction de modèle! Voici l'implémentation complète de myAbs en C ++ 20:

 template< typename T > concept bool Arithmetic() { return std::is_arithmetic_v< T >; } Arithmetic myAbs( Arithmetic val ) { if constexpr( std::is_signed_v< decltype( val ) > ) { return( ( val <= -1 ) ? -val : val ); } else { return val; } } int main() { unsigned int a{ myAbs( 5u ) }; int b{ myAbs< int >( 5u ) }; //std::string c{ myAbs( "d" ) }; } 

Un appel commenté donne l'erreur suivante:

 error: cannot call function 'auto myAbs(auto:1) [with auto:1 = const char*]' constraints not satisfied within 'template<class T> concept bool Arithmetic() [with T = const char*]' concept bool Arithmetic(){ ^~~~~~~~~~ 'std::is_arithmetic_v' evaluated to false 

J'exhorte tout le monde à utiliser hardiment ces méthodes dans le code de production; le temps de compilation est moins cher que le runtime. Bonne SFINAEing!

Source: https://habr.com/ru/post/fr466915/


All Articles