So machen Sie SFINAE schlank und zuverlässig

Hallo nochmal. Wir teilen Ihnen einen interessanten Artikel mit, dessen Übersetzung speziell für Studenten des Kurses "C ++ Developer" erstellt wurde .





Heute haben wir einen Gastbeitrag von dám Balázs. Adam ist Softwareentwickler bei Verizon Smart Communities Hungary und entwickelt Videoanalysen für eingebettete Systeme. Eine seiner Leidenschaften ist die Optimierung der Kompilierungszeit, daher stimmte er sofort zu, einen Gastbeitrag zu diesem Thema zu schreiben. Sie finden Adam online auf LinkedIn .

In einer Reihe von Artikeln darüber, wie man SFINAE elegant macht , haben wir gesehen, wie man unsere SFINAE-Vorlage ziemlich präzise und ausdrucksstark macht .

Schauen Sie sich einfach die ursprüngliche Form an:

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){} }; 


Und vergleichen Sie es mit dieser ausdrucksstärkeren Form:

 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){} }; 

Wir können davon ausgehen, dass es bereits möglich ist, sich zu entspannen und es in der Produktion einzusetzen. Wir könnten, es funktioniert in den meisten Fällen, aber - wenn wir über Schnittstellen sprechen - sollte unser Code sicher und zuverlässig sein. Ist es so? Versuchen wir es zu hacken!

Fehler Nr. 1: SFINAE kann umgangen werden


In der Regel wird SFINAE verwendet, um einen Teil des Codes abhängig von der Bedingung zu deaktivieren. Dies kann sehr nützlich sein, wenn wir beispielsweise die benutzerdefinierte Funktion abs aus irgendeinem Grund implementieren müssen (benutzerdefinierte Rechenklasse, Optimierung für eine bestimmte Ausrüstung, zu Schulungszwecken usw.):

 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; } 

Dieses Programm zeigt Folgendes an, was ganz normal aussieht:

 a: 2147483647 myAbs( a ): 2147483647 

Aber wir können unsere abs Funktion mit vorzeichenlosen Argumenten T aufrufen, und der Effekt wird katastrophal sein:

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

In der Tat zeigt das Programm jetzt Folgendes an:

a: 4294967295 myAbs( a ): 1

Unsere Funktion war nicht dafür ausgelegt, mit vorzeichenlosen Argumenten zu arbeiten, daher müssen wir die mögliche Menge von T mit SFINAE begrenzen:

 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 ); } 

Der Code funktioniert wie erwartet: Ein Aufruf von myAbs mit einem vorzeichenlosen Typ verursacht einen Fehler bei der Kompilierung:

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

SFINAE-Staat hacken


Was ist dann falsch an dieser Funktion? Um diese Frage zu beantworten, müssen wir überprüfen, wie myAbs SFINAE implementiert.

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

myAbs ist eine Funktionsvorlage mit zwei Arten von Eingabeschablonenparametern. Der erste ist der tatsächliche Typ des Funktionsarguments, der zweite ist der Standardtyp IsSigned < T > (andernfalls std::enable_if_t < std::is_signed_v < T > > oder std::enable_if < std::is_signed_v < T>, void>::type , der void oder fehlgeschlagen ist).

Wie können wir myAbs ? Es gibt drei Möglichkeiten:

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

Der erste und der zweite Aufruf sind unkompliziert, aber der dritte ist interessant: Was ist das Argument der void Vorlage?

Der zweite Vorlagenparameter ist anonym, hat einen Standardtyp, ist jedoch weiterhin ein Vorlagenparameter, sodass Sie ihn explizit angeben können. Ist das ein Problem? In diesem Fall ist dies wirklich ein großes Problem. Wir können das dritte Formular verwenden, um unseren SFINAE-Scheck zu umgehen:

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

Dieser Code lässt sich gut kompilieren, führt jedoch zu katastrophalen Ergebnissen, um zu vermeiden, dass wir SFINAE verwendet haben:

 a: 4294967295 myAbs( a ): 1 

Wir werden dieses Problem lösen - aber zuerst: Gibt es noch andere Nachteile? Nun ...

Fehler Nr. 2: Wir können keine spezifischen Implementierungen haben


Eine weitere häufige Verwendung von SFINAE besteht darin, bestimmte Implementierungen für bestimmte Bedingungen zur Kompilierungszeit bereitzustellen. Was ist, wenn wir den Aufruf von myAbs mit myAbs Werten nicht vollständig verbieten und eine triviale Implementierung für diese Fälle bereitstellen möchten? Wir können if constexpr in C ++ 17 verwenden (wir werden dies später diskutieren), oder wir können:

  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; } 

Aber was ist das?

 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, der C ++ - Standard (C ++ 17; §17.1.16) besagt Folgendes :

"Die Standardargumente sollten dem Vorlagenparameter nicht durch zwei verschiedene Deklarationen im selben Bereich bereitgestellt werden."

Ups, genau das haben wir getan ...

Warum nicht regelmäßig verwenden, wenn?


Wir könnten stattdessen nur zur Laufzeit verwenden:

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

Der Compiler würde die Bedingung optimieren, da if (std::is_signed_v < T>) if (false) nach dem Erstellen der Vorlage zu if (true) oder if (false) . Ja, mit unserer aktuellen Implementierung von myAbs dies funktionieren. Insgesamt bedeutet dies jedoch eine enorme Einschränkung: Die if und else müssen für jedes T gültig sein T Was ist, wenn wir unsere Implementierung ein wenig ändern:

 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 ) }; } 

Unser Code stürzt sofort ab:

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

Diese Einschränkung beseitigt SFINAE: Wir können Code schreiben, der nur für eine Teilmenge von T gültig ist (in myAbs ist er nur für vorzeichenlose Typen oder nur für vorzeichenbehaftete Typen gültig).

Lösung: ein anderes Formular für SFINAE


Was können wir tun, um diese Mängel zu beheben? Für das erste Problem müssen wir unsere SFINAE-Prüfung erzwingen, unabhängig davon, wie Benutzer unsere Funktion aufrufen. Derzeit kann unser Test umgangen werden, wenn der Compiler nicht den Standardtyp für den zweiten Vorlagenparameter benötigt.

Was ist, wenn wir unseren SFINAE-Code verwenden, um einen Vorlagenparametertyp zu deklarieren, anstatt einen Standardtyp anzugeben? Versuchen wir mal:

 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 ) }; } 

In gültigen Fällen muss IsSigned ein anderer Typ als void sein, da wir einen Standardwert für diesen Typ angeben möchten. Es gibt keinen Wert für den void-Typ, daher sollten wir etwas anderes verwenden: bool, int, enum, nullptr_t usw. Normalerweise verwende ich bool - in diesem Fall sehen die Ausdrücke aussagekräftig aus:

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

Es funktioniert! Für myAbs (5u) Compiler wie zuvor einen Fehler aus:

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

Der zweite Aufruf, myAbs < int> (5u) ist noch gültig. Wir teilen dem Compilertyp T explizit mit, sodass er 5u in int konvertiert.

Schließlich können wir myAbs nicht mehr um den Finger myAbs < unsigned int, true> (5u) verfolgen: myAbs < unsigned int, true> (5u) einen Fehler aus. Es spielt keine Rolle, ob wir im Aufruf einen Standardwert angeben oder nicht, ein Teil des SFINAE-Ausdrucks wird trotzdem ausgewertet, da der Compiler einen Argumenttyp eines anonymen Vorlagenwerts benötigt.

Wir können mit dem nächsten Problem fortfahren - aber warten Sie eine Minute! Ich denke, wir überschreiben nicht mehr das Standardargument für denselben Vorlagenparameter. Wie war die ursprüngliche Situation?

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

Aber jetzt mit dem aktuellen Code:

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

Es sieht dem vorherigen Code sehr ähnlich, so dass wir vielleicht denken, dass dies auch nicht funktioniert, aber tatsächlich hat dieser Code nicht das gleiche Problem. Was ist IsUnsigned < T> ? Bool oder fehlgeschlagene Suche. Und was ist IsSigned < T> ? Das Gleiche, aber wenn einer von ihnen Bool ist, ist der andere eine fehlgeschlagene Suche.

Dies bedeutet, dass wir die Standardargumente nicht überschreiben, da es nur eine Funktion mit dem Vorlagenargument bool gibt, die andere eine fehlgeschlagene Ersetzung ist und daher nicht vorhanden ist.

Syntaktischer Zucker


UPD Dieser Absatz wurde vom Autor aufgrund von darin gefundenen Fehlern gelöscht.

Alte Versionen von C ++


Alle oben genannten Funktionen funktionieren mit C ++ 11, der einzige Unterschied besteht in der Ausführlichkeit der Definitionen von Einschränkungen zwischen Standardversionen:

 //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 >; 

Die Vorlage bleibt jedoch gleich:

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

Im guten alten C ++ 98 gibt es keine Vorlagen-Aliase. Außerdem können Funktionsvorlagen keine Typen oder Standardwerte haben. Wir können unseren SFINAE-Code in den Ergebnistyp oder nur in die Liste der Funktionsparameter einfügen. Die zweite Option wird empfohlen, da die Konstruktoren keine Ergebnistypen haben. Das Beste, was wir tun können, ist ungefähr so:

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

Nur zum Vergleich - die moderne Version von C ++:

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

Die C ++ 98-Version ist hässlich, führt einen bedeutungslosen Parameter ein, funktioniert aber - Sie können ihn verwenden, wenn dies unbedingt erforderlich ist. Und ja: my_enable_if und my_is_signed sollten implementiert werden ( std :: enable_if std :: is_signed waren neu in C ++ 11).

Aktueller Zustand


C ++ 17 wurde eingeführt, if constexpr , eine Methode zum Verwerfen von Code basierend auf Bedingungen zur Kompilierungszeit. Sowohl if- als auch else-Anweisungen müssen syntaktisch korrekt sein, die Bedingung wird jedoch zur Kompilierungszeit ausgewertet.

 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." ); }*/ } } 

Wie wir sehen können, ist unsere Bauchmuskelfunktion kompakter und leichter zu lesen. Der Umgang mit nicht konformen Typen ist jedoch nicht einfach. Der static_assert bedingungslose static_assert macht diese Aussage schlecht konsistent, was vom Standard verboten ist, unabhängig davon, ob sie verworfen wird oder nicht.

Glücklicherweise gibt es eine Lücke: In Vorlagenobjekten werden abgelegte Operatoren nicht erstellt, wenn die Bedingung vom Wert unabhängig ist. Großartig!

Das einzige Problem mit unserem Code ist, dass er während der Vorlagendefinition abstürzt. Wenn wir die Auswertung von static_assert bis zum Zeitpunkt der static_assert der Vorlage verschieben könnten, wäre das Problem gelöst: Sie würde nur dann erstellt, wenn alle unsere Bedingungen falsch sind. Aber wie können wir static_assert verschieben, bis die Vorlage erstellt wird? Machen Sie seinen Zustand abhängig vom Typ!

 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" ); } } } 

Über die Zukunft


Wir sind uns schon sehr nahe, aber wir müssen eine Weile warten, bis C ++ 20 die endgültige Lösung bringt: Konzepte! Dadurch wird die Art und Weise, wie Vorlagen (und SFINAE) verwendet werden, vollständig geändert.

Kurz gesagt: Konzepte können verwendet werden, um die Anzahl der Argumente zu begrenzen, die für Vorlagenparameter akzeptiert werden. Für unsere Bauchmuskelfunktion könnten wir das folgende Konzept verwenden:

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

Und wie können wir Konzepte verwenden? Es gibt drei Möglichkeiten:

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

Beachten Sie, dass das dritte Formular immer noch eine Vorlagenfunktion deklariert! Hier ist die vollständige Implementierung von myAbs in 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" ) }; } 

Ein auskommentierter Aufruf gibt den folgenden Fehler aus:

 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 

Ich fordere alle auf, diese Methoden mutig im Produktionscode zu verwenden. Die Kompilierungszeit ist billiger als die Laufzeit. Viel Spaß beim SFINAEing!

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


All Articles