Aus Linux-Magazin 04/2018

Modernes C++ in der Praxis – Folge 39

Weiß ein Software-Entwickler die Intelligenz des Compilers zu nutzen, spart er sich das aufwändige und fehlerträchtige Prüfen der Software. Das übernehmen “static_assert” und die Type-Traits-Bibliothek.

Zehn Regeln sollen helfen, besseren Code in modernem C++ zu schreiben, mittlerweile erreicht diese Artikelreihe bereits den achten Halt (Abbildung 1). Der rät: “Lasse dir helfen.” Tatsächlich gibt es viele Komponenten im C++-Ökosystem, dank denen Entwickler deutlich besseren Code schreiben.

Das beginnt bei IDEs wie Eclipse [1] oder Visual Studio [2], setzt sich über dynamische und statische Code-Analysewerkzeuge wie Thread Sanitzer [3] oder Cpp Mem [4] fort, geht weiter mit Smart Pointern [5] oder Type Traits [6] und endet mit der C++-Kernsprache. Letztere kennt auch ein Feature wie die automatische Typ-Bestimmung mit »auto« [7] oder »static_assert« [8].

Abbildung 1: Der achte Tipp zu besserem C++ dreht sich um Hilfsmittel für Entwickler.

Abbildung 1: Der achte Tipp zu besserem C++ dreht sich um Hilfsmittel für Entwickler.

Der Artikel konzentriert sich auf das Traumpaar Type Traits und »static_assert«. Während »static_assert« es genehmigt, an den Quellcode gestellte Bedingungen während des Kompilierens zu überprüfen, erlaubt es die Type-Traits-Bibliothek, diese Bedingungen zu formulieren. Genau die richtigen Bausteine also, um das aktuelle Motto umzusetzen. Sie sollen einen Algorithmus, der den größten gemeinsamen Teiler (ggT) zweier Zahlen ermittelt, typsicher machen.

Der Schnelldurchlauf

Zunächst kann ein kurzer Blick auf »static_assert« und die Type-Traits-Bibliothek das Gedächtnis auffrischen. Der einfache Fall ist »static_assert«, das lediglich nach einem Ausdruck und einem Text verlangt. Der Ausdruck muss zur Übersetzungszeit einen Wahrheitswert zurückgeben. Liefert er ein »false«, zeigt der Compiler den vorgefertigten Text als Fehlermeldung und erzeugt als Konsequenz keine ausführbare Datei.

Auch erwähnenswert ist, dass der Compiler »static_assert« zur Übersetzungszeit auswertet. Daher entsteht keine Laufzeiteinschränkung. Zudem lässt sich »static_assert« in allen Bereichen des Programms anwenden. Der Nutzen von »static_assert« steht und fällt aber mit den Bedingungen, die ein Entwickler beim Kompilieren an den Quellcode stellen kann. Hier kommt die Type-Traits-Bibliothek ins Spiel. Sie erlaubt es, zur Übersetzungszeit Typ-Eigenschaften zu prüfen, Typ-Vergleiche und sogar -Änderungen vorzunehmen.

Wem dieser Schnelldurchlauf nicht genügt, der kann sich in der Online-Dokumentation auf http://cppreference.com tiefgehend über die »static_assert«-Funktion [8] und die Type-Traits-Bibliothek [6] informieren.

Ein erster, naiver Versuch

Der generische Algorithmus in Listing 1 ist nur eine Fingerübung. Er heißt »gcd« und ermittelt den größten gemeinsamen Teiler zweier Zahlen. Das Funktions-Template »gcd()« in den Zeilen 3 bis 9 setzt den bekannten euklidischen Algorithmus [9] ein, um den ggT zweier Zahlen zu berechnen. Das Programm ist schnell übersetzt und ausgeführt und liefert die erwartete Ausgabe (Abbildung 2).

Listing 1

Größter gemeinsamer Teiler (naiv)

01 #include <iostream>
02
03 template<typename T>
04 T gcd(T a, T b){
05   if( b == 0 ){ return a; }
06   else{
07     return gcd(b, a % b);
08   }
09 }
10
11 int main(){
12
13   std::cout << std::endl;
14
15   std::cout << "gcd(100, 10)= " <<  gcd(100, 10)  << std::endl;
16   std::cout << "gcd(100, 33)= " << gcd(100, 33) << std::endl;
17   std::cout << "gcd(100, 0)= " << gcd(100, 0)  << std::endl;
18   // std::cout << "gcd(3.5, 4.0)= " << gcd(3.5, 4.0) << std::endl;
19   // std::cout << "gcd(100, 10L)= " << gcd(100, 10L) << std::endl;
20
21   std::cout << std::endl;
22
23 }

Wer nun aber die auskommentierten Zeilen aktiviert, erkennt schnell die Schwächen der Implementierung des ggT-Algorithmus. Er lässt sich nicht nur für jeden beliebigen Datentyp und damit auch für »double«-Werte (Zeile 18), sondern auch für zwei verschiedene Datentypen aufrufen (Zeile 19). Der Compiler quittiert dies mit der geschwätzigen Fehlermeldung in Abbildung 3.

Abbildung 2: Die erste, naive Implementierung des ggT-Algorithmus scheint zun&auml;chst zu funktionieren.

Abbildung 2: Die erste, naive Implementierung des ggT-Algorithmus scheint zunächst zu funktionieren.

Die Meldung liefert immerhin einige Anhaltspunkte. Die erste Zeile lautet »no matching function for call to ‘gcd(int, long int)’«. Das bedeutet, dass der Compiler für das erste Argument des Aufrufs »gcd(100, 10L)« den Typ »int« bestimmt, für das zweite den Typ »long int«. Das kann nicht gut gehen, da das Klassen-Template »gcd« nur einen Typ-Parameter »<T>« besitzt. Der kann nicht zugleich »int« und »long int« sein.

Abbildung 3: Der Versuch, den ggT-Algorithmus mit unpassenden Datentypen zu verwenden, scheitert.

Abbildung 3: Der Versuch, den ggT-Algorithmus mit unpassenden Datentypen zu verwenden, scheitert.

In eine ganz andere Richtung geht die letzte Zeile der Fehlermeldung. Der Compiler moniert, dass er den Modulo-Operator »%« nicht auf »double«-Werte anwenden kann. Recht hat er. Während der Entwickler dieses Problem mit Hilfe der Type-Traits-Bibliothek zügig behebt, verlangt ihm die Instanzierung des Funktions-Template mit den ähnlichen Ganzzahlen »gcd(10, 100L)« schon mehr Aufwand ab. Sie soll als richtiges Ergebnis »10« liefern.

Du darfst hier nicht rein

Die Type-Traits-Bibliothek enthält unter anderem die Funktion »std::is_integral <type>::value« [10]. Sie prüft zur Übersetzungszeit, ob »type« ein integraler Typ ist. Das ist genau das fehlende Puzzleteil, um den ggT-Algorithmus in Listing 2 typsicherer zu implementieren. Das Übersetzen des Programms scheitert mit einer einfachen Fehlermeldung (Abbildung 4), besser kann es nicht laufen.

Listing 2

Ist type ein integraler Typ?

01 #include <iostream>
02 #include <type_traits>
03
04 template<typename T>
05 T gcd(T a, T b){
06   static_assert(std::is_integral<T>::value, "T should be an integral type!");
07   if( b == 0 ){ return a; }
08   else{
09     return gcd(b, a % b);
10   }
11 }
12
13 int main(){
14
15   std::cout << std::endl;
16
17   std::cout << gcd(3.5, 4.0)<< std::endl;
18
19   std::cout << std::endl;
20
21 }

Das war schon vielversprechend, doch in der nächsten Optimierungsstufe lernt der ggT-Algorithmus, mit zwei verschiedenen integralen Typen umzugehen. Um das zu erreichen, ist ein Umbau des ggT-Algorithmus nötig, der auch das klassische Problem von Funktions-Templates berücksichtigt, die mehr als einen Typ annehmen.

Abbildung 4: Die statische Pr&uuml;fung des ggT-Algorithmus schl&auml;gt zu Recht Alarm.

Abbildung 4: Die statische Prüfung des ggT-Algorithmus schlägt zu Recht Alarm.

Stellt sich die Frage nach dem Rückgabetyp für den ggT-Algorithmus, die Listing 3 mit drei Fragezeichen andeutet. Soll der Rückgabetyp »<T>R« aus dem größeren der beiden Template-Parameter »T1« und »T2«, dem kleineren oder einem neuen Typ bestehen, der sich aus beiden Template-Parametern zusammensetzt? Die Frage lässt sich leider nicht allgemein beantworten. Aber die Lösung des Problems liefert einmal mehr die Type-Traits-Bibliothek. Listing 4 stellt gleich zwei Lösungen vor.

Listing 3

Welcher Rückgabetyp für den ggT-Algorithmus?

01 template<typename T1, typename T2>
02 ??? gcdConditional(T1 a, T2 b){
03   static_assert(std::is_integral<T1>::value, "T1 should be an integral type!");
04   static_assert(std::is_integral<T2>::value, "T2 should be an integral type!");
05   if( b == 0 ){ return a; }
06   else{
07     return gcdConditional(b, a % b);
08   }
09 }

Listing 4

Rückgabetyp bestimmen

01 #include <iostream>
02 #include <type_traits>
03 #include <typeinfo>
04
05 template<typename T1, typename T2,
06          typename R = typename std::conditional <(sizeof(T1) < sizeof(T2)), T1, T2>::type>
07 R gcdConditional(T1 a, T2 b){
08   static_assert(std::is_integral<T1>::value, "T1 should be an integral type!");
09   static_assert(std::is_integral<T2>::value, "T2 should be an integral type!");
10   if( b == 0 ){ return a; }
11   else{
12     return gcdConditional(b, a % b);
13   }
14 }
15
16
17 template<typename T1, typename T2,
18          typename R = typename std::common_type<T1, T2>::type>
19 R gcdCommon(T1 a, T2 b){
20   static_assert(std::is_integral<T1>::value, "T1 should be an integral type!");
21   static_assert(std::is_integral<T2>::value, "T2 should be an integral type!");
22   if( b == 0 ){ return a; }
23   else{
24     return gcdCommon(b, a % b);
25   }
26 }
27
28 int main(){
29
30
31   std::cout << std::endl;
32
33   std::cout << "gcdConditional(100, 10LL) = " << gcdConditional(100, 10LL) << std::endl;
34   std::cout << "gcdCommon(100, 10LL) = " << gcdCommon(100, 10LL) << std::endl;
35
36   std::conditional <(sizeof(int) < sizeof(long long)), int, long long>::type gcd1 = gcdConditional(100, 10LL);
37   auto gcd2 = gcdCommon(100, 10LL);
38
39   std::cout << std::endl;
40
41   std::cout << "typeid(gcd1).name() = " << typeid(gcd1).name() << std::endl;
42   std::cout << "typeid(gcd2).name() = " << typeid(gcd2).name() << std::endl;
43
44   std::cout << std::endl;
45
46 }

Der Einfachheit halber heißen die Algorithmen in dem Beispiel »gcdConditional()« (Zeilen 5 bis 14) und »gcdCommon()« (Zeilen 17 bis 26). Beide Funktions-Templates besitzen einen Standardtyp für die Rückgabe. Im Falle des Funktions-Template »gcdConditional()« heißt dieser:

std::conditional <(sizeof(T1) < sizeof(T2)), T1, T2>::type

Im Falle des Funktions-Template »gcdCommon()« lautet der Typ:

std::common_type<T1, T2>::type

Während das erste Beispiel eine Art ternären Operator zeigt, der zur Übersetzungszeit den kleineren der beiden Typen »(sizeof(T1) < sizeof(T2))« zurückgibt, ermittelt der zweite Ausdruck den Typ, in den sich die Typen »T1« und »T2« konvertieren lassen. Recht erhellend ist die Programmausgabe (Abbildung 5).

Abbildung 5: Der ggT-Algorithmus unterst&uuml;tzt zwei verschiedene Typen.

Abbildung 5: Der ggT-Algorithmus unterstützt zwei verschiedene Typen.

Besonders hässlich fallen die Type Traits aus, wenn der Entwickler an Stelle der automatischen Typ-Ableitung mit »auto« (Zeile 37) den expliziten Datentyp angibt (Zeile 36). Dann muss er die komplette Entscheidungslogik zum Ermitteln des Rückgabetyps wiederholen.

Was die Ausgabe des Programms auch schön zeigt: Im Fall des »gcdConditional()«-Algorithmus gibt sie den kleineren (Zeile 41), im Fall des »gcdCommon«-Algorithmus den größeren beider Datentypen (Zeile 42) zurück. Details zu beiden Funktionen warten einmal mehr in der Online-Dokumentation [6]. Dies betrifft auch die Funktion »std::enable_if()« im nächsten Abschnitt.

Wo ist die Funktion?

Konzeptionell besitzen alle bisher vorgestellten Variationen mit »std::is_integral<T1>::value« einen Schönheitsfehler: Ob ein Datentyp integral ist, testen sie erst im Körper der Funktions-Templates. Das ist eindeutig zu spät, dieser Test sollte bereits auf dem Interface der Funktion stattfinden.

Genau dies ermöglicht »std::enable_if()«. Die Funktion wendet einen Trick an, der in der C++-Community als Sfinae bekannt ist, einer Abkürzung für “Substitution Failure Is Not An Error” [11]. Das bedeutet: Wenn der Code ein Template wie »gcd(100, 100L)« instanziert, dann interpretiert der Compiler eine fehlerhafte Substituierung nicht als Fehler, sondern entfernt sie aus dem Pool möglicher Instanzierungen.

Listing 5

Sfinae mit std::enable_if() in der Anwendung

01 #include <iostream>
02 #include <type_traits>
03
04 template<typename T1, typename T2,
05          typename std::enable_if<std::is_integral<T1>::value, T1>::type = 0,
06          typename std::enable_if<std::is_integral<T2>::value, T2>::type = 0,
07          typename R = typename std::conditional <(sizeof(T1) < sizeof(T2)), T1, T2>::type>
08 R gcd(T1 a, T2 b){
09   if( b == 0 ){ return a; }
10   else{
11     return gcd(b, a % b);
12   }
13 }
14
15 int main(){
16
17   std::cout << "gcd(100, 10LL) = " << gcd(100, 10LL) << std::endl;
18   std::cout << "gcd(3.5, 4.0) = " << gcd(3.5, 4.0) << std::endl;
19
20 }

Häufig kennt der Compiler noch weitere Optionen, um das Template erfolgreich zu instanzieren. Hier kommt die Stärke der teilweisen oder vollständigen Template-Spezialisierung ins Spiel. Die Funktion »std::enable_if()« verpackt diese sehr hilfreiche Technik und wendet sie in Listing 5 an.

Abbildung 6: Sfinae in Aktion: Der Compiler zeigt eine Fehlermeldung.

Abbildung 6: Sfinae in Aktion: Der Compiler zeigt eine Fehlermeldung.

Die Fehlermeldung in Abbildung 6 bringt es ans Licht: “no matching function for call to ‘gcd(double, double)'”. Schlägt also das Instanzieren für »double« fehl, entfernt der Compiler diese konkrete Substitution aus der Menge aller möglichen Instanzierungen. In diesem Fall kann er aber auf keine weitere Option zurückgreifen. Die Instanzierung scheitert, da

std::enable_if<std::is_integral<T1>:: value, T1>::type

in Zeile 5 fehlschlägt. Dies gilt natürlich auch für die Zeile 6. Beide Zeilen sind nur Hilfsstrukturen, um Sfinae anwenden zu können.

Wie geht’s weiter?

Auf Tipp 8 folgt wenig überraschend Tipp 9, der da lautet: “Kenne deine Bibliotheken!” Diese Weisheit trifft insbesondere auf modernes C++ zu, weil jeder neue C++-Standard auch einige neue Bibliotheken im Gepäck hat.

Der Autor

Rainer Grimm ist Trainer für C++ und Python. Seine zahlreichen C++-Bücher, zuletzt “The C++ Standard Library” und “Concurrency with modern C++”, sind bei O’Reilly und Leanpub erschienen.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 4 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Nach oben