Kategorien
C++Programmierung

Requires clause für Funktionsüberladung – Anwendungsbeispiel

Intro

Dies ist der dritte (und voraussichtlich letzte) Artikel aus meiner kleinen Mini-Serie über vielleicht nicht so bekannte C++ Features / Pattern / Programmiertechniken für welche ich praktische Anwendungsbeispiele während der Arbeiten an meinen Projekten gefunden habe.

Heute dreht es sich um die requires clause (Anweisung) eingesetzt für Funktionsüberladung (und deren Auflösung).

Die Requires Clause

“The keyword requires is used to introduce a requires-clause, which specifies constraints on template arguments or on a function declaration.”

Von cppreference.com

Ein einfaches Beispiel könnte so aussehen:

template< typename T >
requires std::is_floating_point_v<T>
void calc( T x )  // all floating point types go here...
{
}

C++20 hat auch Concepts, welche für die Spezifizierung von Template-Typen verwendet werden sollten. Es ist möglich Concepts und die requires clause zu kombinieren:

// T must fulfill the concept of integral, but ...
template< std::integral T >
requires( !std::is_same_v<T, bool> && !std::is_same_v<T, wchar_t>
      && !std::is_same_v<T, char16_t> && !std::is_same_v<T, char32_t> 
      && !std::is_same_v<T, char8_t> )
void calc( T x )  // ... we don't want bool and character types!
{
}
 
// NOTE: The requires clause is a little bit simplified.
//       For match all possibilities std::remove_cvref_t<T> 
//       must be used for compare in std::is_same_v<>.

Requires clause für Funktionsüberladungsauflösung

Wie oben veranschaulicht, kann die requires clause verwendet werden, um die möglichen Typen zu beschränken und/oder die Gewünschten auszuwählen.

Aber sie kann auch eingesetzt werden, um die korrekte/gewünschte Funktionsüberladung zu wählen.

Manchmal wählt der Compiler nicht die gleiche Funktionsüberladung wie es der Programmierer erwartet. Und manchmal kann der Compiler sogar keine Überladung auswählen, da es eine Mehrdeutigkeit gibt.

Für den ersten Fall habe ich ein Beispiel auf godbolt erstellt, welches ich hier erläutern werde.
Für den letzteren zeige ich im Anschluss mein Anwendungsbeispiel aus meinem privatem Projekt.

Sag dem Compiler welche Funktion er nehmen soll

Die nächsten Code Beispiele können auf godbolt ausprobiert werden. Hier ist der Link: https://godbolt.org/z/1d6EWj5P7

// Imagine you have these 2 functions:
 
void test( int x )
{
    std::cout << "test( int ) "  << x << std::endl;
}
 
void test( short x )
{
    std::cout << "test( short ) "  << x << std::endl;
}
 
// now you want call it with a variable of type char.
 
char c = 123;
 
// call it:
test( c );
 
// which overload will be called ???

Wenn wir nur die Größe der Typen betrachten, dann sollte die short Überladung aufgerufen werden. Des weiteren, wenn es nur diese beiden Überladungen gibt, dann war die Intention des Programmierers höchstwahrscheinlich genau das. Aber die Regeln von C++ sind anders. Für integrale Typen kleiner als int findet immer eine Promotion zu int statt. Deshalb wird in diesem Fall die int Überladung aufgerufen.

Das können wir lösen, indem wir den Aufruf explizit machen: test( static_cast<short>(c) );

Aber wir müssen das jedes mal machen und es kann leicht vergessen werden.

Mit templates und der requires clause können wir dies jedoch perfekt lösen:

template< std::integral T>
void test3( T x )
{
    std::cout << "test3(std::integral): "  << x << std::endl;
}
 
template< std::integral T>
requires ( sizeof(T) < 4 )
void test3( T x )
{
    std::cout << "test3(sizeof < 4): "  << x << std::endl;
}
 
// now you want call it with a variable of type char.
 
char c = 123;
 
// call it:
test3( c );

Wie man auf godbolt nachprüfen kann, wird jetzt die korrekte Überladung (mit den besser passenden Typgrößen) aufgerufen. Alle integralen Typen, welche kleiner als 4 Bytes sind, nehmen die zweite Überladung, wohingegen alle integralen Typen, die genau 4 Bytes oder größer sind, die erste Überladung nehmen.

Auch im nächsten Beispiel, wenn wir eine Überladung mit unsigned char haben, wird die (für uns) erwünschte Überladung zunächst nicht aufgerufen:

void test2( int x )
{
    std::cout << "test2( int ) "  << x << std::endl;
}
 
void test2( unsigned char x )
{
    std::cout << "test2( unsigned char ) "  << x << std::endl;
}
 
// now you want call it with a variable of type char.
 
char c = 123;
 
// call it:
test2( c );
 
// Do you believe the second overload will be called ??

Die zweite Überladung wird nicht aufgerufen, obwohl die Typen sich nur in der Vorzeicheneigenschaft unterscheiden.

Und wieder, templates und die requires clause helfen uns hier weiter:

template< std::integral T>
void test4( T x )
{
    std::cout << "test4(std::integral): "  << x << std::endl;
}
 
template< std::integral T>
requires ( sizeof(T) == 1 && std::is_unsigned_v<T> )
void test4( T x )
{
    std::cout << "test4(sizeof == 1 && unsigned): "  << x << std::endl;
}
 
template< std::integral T>
requires ( sizeof(T) == 1 && std::is_signed_v<T> )
void test4( T x )
{
    // (could do some checks here first.)
    std::cout << "test4(sizeof == 1 && signed): now calling unsigned overload." << std::endl;
    test4( static_cast<std::make_unsigned_t<T>>(x) );
}
 
 
// now you want call it with a variable of type char.
 
char c = 123;
 
// call it:
test4( c );

Jetzt können wir sogar noch Extras für Typen mit Vorzeichen erledigen, falls dies erforderlich ist.

Mehrdeutigkeit auflösen

Manchmal kommt es zu einer Mehrdeutigkeit um eine Funktionsüberladung auszuwählen und der Compiler kann es nicht mehr automatisch.

Dies passierte mir während der frühen Entwicklung von TeaScript.

In TeaScript gibt es die Content Klasse, welche ein kleinen wenig wie ein std::string_view ist, jedoch noch eine veränderbare aktuelle Position sowie Zeilen und Spalten Zähler des Contents besitzt. Die Klasse wird vom Parser verwendet um Skriptcode zu parsen.

Den Quellcode kann man auf Github ansehen: class Content.

Mein Ziel war es die Klasse mit jedem string content (8bit/utf-8) einfach verwendbar zu machen. Deswegen gibt es natürlich einen Konstruktor mit std::string sowie einen mit std::string_view. Aber ich habe auch einen Konstruktor hinzugefügt, der ein C-Array mit chars nimmt.

template< size_t N>
Content( char const (&content)[N] )
    : Content( &content[0], N )
{
}

Dies ist sehr nützlich und anwenderfreundlich, wenn man es mit (Raw-)String-Literalen aufrufen will:

// No string object constructed here.
// Also, the compiler puts the correct string length at compile time.
auto const c = Content( "const dice_roll := rolldice( 20 )" );

Dann habe ich, nur zur Komplettierung und um älteren Code zu unterstützen, einen weiteren Konstruktor nur mit char const * eingefügt.
Es gab bereits einen Konstruktor mit 2 Parametern, char const * und die size_t für die Länge.
Aber dieser war für mich nicht ausreichend. Wieso? Weil der Parameter für die Länge vom Anwender bereitgestellt werden muss. Das ist fehleranfällig. Es kann schnell aus Versehen zu einem Fehler kommen. Zudem ist es unhandlich. Der Anwender muss strlen selbst aufrufen und er muss die Doku lesen, welche Länge genau erwartet wird (z.B. mit oder ohne abschließender Null, usw.).
Deshalb wollte ich einen Konstruktor mit nur genau einem einfachen char const * um die Arbeit für den Anwender aus Komfortgründen zu übernehmen.

Aber ich hatte Pech. Der Compiler hat den Konstruktor nicht akzeptiert, da eine Mehrdeutigkeit mit dem C-Array Konstruktor von oben existiert.
Dies ist wegen dem Zeigerverfall (array to pointer decay). Jedes C-Array wird automatisch in einen einfachen Zeiger des selben Elements zerfallen. Dies erzeugt die Mehrdeutigkeit für den Compiler.

Doch zum Glück konnte ich dieses mit Hilfe von einem template und einer requires clause lösen:

template< typename CharPtr = char const * >
Content( CharPtr pStr ) requires std::is_pointer_v<CharPtr>
    : Content( pStr, ::strlen(pStr) + 1 )
{
}

Damit war es dem Compiler wieder möglich die richtige Überladung auszuwählen. 🙂

Fazit

Persönlich denke ich, dass die requires clause einen großen Vorteil im modernen C++ liefert und es viele sinnvolle Anwendungsmöglichkeiten gibt.

Was denkst du über diese Technik und/oder dem konkreten Anwendungsbeispiel?
Ich hoffe, der Artikel hat dir gefallen. Danke fürs Lesen! 🙂

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert