C++-Code sicherer machen und zugleich an der Performance-Schraube drehen, das sind die beiden Domänen der neuen Type-Traits-Bibliothek. Sie beschleunigt Code, indem sie Typen zur Kompilierzeit analysiert und verändert, wenn der Entwickler sie geschickt einsetzt.
Die Standard Template Library (STL) von C++ besteht aus generischen Algorithmen, die auf generischen Containern agieren, die mehrere Objekte gleichen Datentyps in einer Struktur sammeln. Iteratoren verbinden die Algorithmen mit den Containern. Sie sind Abstraktionen von Zeigern und beschreiben den Bereich eines Containers, auf den die Bibliothek einen Algorithmus anwendet.
Fortsetzungsroman
Behandelte der vorige Artikel [1], wie die Type-Traits-Bibliothek den Code sicherer macht, hat dieser die Performance im Visier.
So viel Abstraktion hat auf den ersten Blick einen großen Nachteil: Weil der auf einen Container angewandte Algorithmus nicht von den Elementen des Containers abhängt, ist er auch nicht auf diese hin optimiert. Das bedeutet insbesondere, dass ein Algorithmus seine Elemente stets auf demselben Weg prozessiert, unabhängig davon, ob es sich um einen einfachen Built-in-Datentyp wie »char« oder einen selbst definierten, komplizierten Klassentyp handelt. Da aber Performance eines der wichtigsten Prinzipien von C++ ist, stellt sich die Frage, wie das zusammenpasst.
Schnelle Kopien
Ein forschender Blick auf die Implementierung der STL-Algorithmen bringt Aufklärung. Augenscheinlich nimmt der Kopieralgorithmus »std::copy(InputIt first, InputIt last, OutputIt d_first)« [2] jeden Containerbereich auf die gleiche generische Art an. Unter der Oberfläche aber verwendet er eine auf den jeweiligen Datentyp hin optimierte Implementierung. Dank dieser kopiert er Elemente performant von den Bereichen »first« und »last« nach »d_first« .
In diesem Prozess prüft der Kopieralgorithmus, ob seine Elemente hinreichend einfach sind. Konkret heißt das: Alle Iteratoren müssen Zeiger sein, auf den gleichen Typ verweisen und einen vom Compiler erzeugten Zuweisungsoperator besitzen. Sind diese drei Bedingungen erfüllt, kopiert die Funktion »std::copy()« ganze Speicherbereiche hochperformant und in einem Rutsch. Ihr zur Seite stehen die C-Funktionen »std::memcpy()« und »std::memmove()« .
Sind die Elemente aber nicht hinreichend einfach, muss der Algorithmus sie umständlich einzeln kopieren. Abbildung 1 stellt die Entscheidungslogik nochmals exemplarisch dar.
Wonach entscheidet aber der Compiler, ob er zum optimierten Kopieralgorithmus greift? Die Entscheidung treffen die Template-Spezialisierungen und die neue Type-Traits-Bibliothek. Besonders Neugierige lesen die Details zum Kopieralgorithmus online in einem frei zugänglichen Linux-Magazin-Artikel [3] nach.
Fillmaterial
Es gibt weitere Rätsel, so etwa die Frage, wie es der STL-Algorithmus »std::fill( ForwardIt first, ForwardIt last, const T& value)« [4] schafft, seinen Bereich von »first« bis »last« performant mit dem Wert »value« zu setzen.
Das Funktionstemplate »my::fill()« in Listing 1 folgt der aktuellen GCC-Implementierung von »std::fill()« . Diese deutlich aufgehübschte Form (der Beweis erfolgt weiter unten) kommt in der »main()« -Funktion zum Einsatz. Abbildung 2 zeigt das Ergebnis.
Listing 1
my::fill()
01 #include <cstring>
02 #include <chrono>
03 #include <iostream>
04 #include <type_traits>
05
06 namespace my{
07
08 template <typename I, typename T, bool b>
09 void fill_impl(I first, I last, const T& val, const std::integral_constant<bool, b>&){
10 while(first != last){
11 *first = val;
12 ++first;
13 }
14 }
15
16 template <typename T>
17 void fill_impl(T* first, T* last, const T& val, const std::true_type&){
18 std::memset(first, val, last-first);
19 }
20
21 template <class I, class T>
22 inline void fill(I first, I last, const T& val){
23 typedef std::integral_constant<bool,std::has_trivial_copy_assign<T> ::value && (sizeof(T) == 1)> boolType;
24 fill_impl(first, last, val, boolType());
25 }
26 }
27
28 const int arraySize = 100000000;
29 char charArray1[arraySize]= {0,};
30 char charArray2[arraySize]= {0,};
31
32 int main(){
33
34 std::cout << std::endl;
35
36 auto begin= std::chrono::system_clock::now();
37 my::fill(charArray1, charArray1 + arraySize,1);
38 auto last= std::chrono::system_clock::now() - begin;
39 std::cout << "charArray1: " << std::chrono::duration<double>(last).count() << " seconds" << std::endl;
40
41 begin= std::chrono::system_clock::now();
42 my::fill(charArray2, charArray2 + arraySize, static_cast<char>(1));
43 last= std::chrono::system_clock::now() - begin;
44 std::cout << "charArray2: " << std::chrono::duration<double>(last).count() << " seconds" << std::endl;
45
46 std::cout << std::endl;
47
48 }
Übersetzt hat das Programm ein aktueller GCC-Compiler in Version 4.8 ohne Optimierung. Obwohl in den Zeilen 29 und 30 die gleichen »char« -Arrays auftreten, braucht das Füllen der Arrays in Zeile 37 das Zwanzigfache der Zeit von Zeile 42. Der Unterschied zwischen den »my::fill()« -Aufrufen liegt darin, dass der erste den Wert »1« , der zweite »static_cast<char>(1)« verwendet. Letzterer verwandelt ein »int« – explizit in ein »char« -Literal. Das Funktionstemplate »fill_impl()« (Zeilen 9 oder 17) reagiert auf die Länge des Füllwerts.
Entscheidend ist, dass ein »char« -Literal die Länge 1 und einen trivialen Zuweisungsoperator »std::has_trivial_copy_assign<T>::value« besitzt. Das lässt den »boolType« der Zeile 23 in Zeile 24 wahr werden (»true_type« ). Der Compiler versieht den Datentyp »char« dann automatisch mit »std::has_trivial_copy_assign<T>::value« und der Länge 1 (»sizeof(T) == 1« ). »T« symbolisiert die Länge des Füllwerts.
Handelt es sich hingegen um eine natürliche Zahl 1 wie im »charArray1« (Zeile 37), beträgt die Länge auf 32-Bit-Architekturen 4 Byte, auf 64-Bit-Architekturen dagegen 8 Byte.
Auch sei erwähnt, dass im optimierten Fall die C-Funktion »std::memset()« (Zeile 18) einspringt. Sie füllt ganze Speicherbereiche in einem Rutsch, während das erste Template (Zeile 9) jedes Element einzeln auf seinen Füllwert setzt. Der Unterschied spiegelt sich in der kürzeren Ausführungszeit wider (Abbildung 2).
Nicht so “__simple”
Wie anfangs angekündigt folgt nun noch der Originalcode des GCC 4.8 – und der sieht nicht hübsch aus (Listing 2). Das Funktionstemplate »__equal_aux()« verkörpert die Entscheidungslogik, nach welcher der Vergleichsalgorithmus »std::equal()« [6] zwei Bereiche auf ihre Identität überprüft.
Listing 2
std::equal() (Auszug)
01 template<typename _II1, typename _II2>
02 inline bool __equal_aux(_II1 __first1, _II1 __last1, _II2 __first2){
03 typedef typename iterator_traits<_II1>::value_type _ValueType1;
04 typedef typename iterator_traits<_II2>::value_type _ValueType2;
05 const bool __simple = ((__is_integer<_ValueType1>::__value
06 || __is_pointer<_ValueType1>::__value)
07 && __is_pointer<_II1>::__value
08 && __is_pointer<_II2>::__value
09 && __are_same<_ValueType1, _ValueType2>::__value);
10 return std::__equal<__simple>::equal(__first1, __last1, __first2);
11 }
Soll die »bool« -Variable wahr werden, müssen die Container-Elemente einerseits natürliche Zahlen oder Zeiger sein. Andererseits müssen die Iteratoren Zeiger und die Container-Elemente und ihre Objekte vom gleichen Typ sein.
Den Ausdruck als »__simple« (Listing 2, Zeile 5) zu bezeichnen, zeugt von speziellem Humor. Ein wenig Aufmerksamkeit verdient hingegen, dass Funktionen der Type-Traits-Bibliothek die in »__simple« enthaltenen Ausdrücke »__is_pointer« und »__is_integer« sowie den Typvergleich »__are_same« abfragen.
Typen
Daneben kann die Type-Traits-Bibliothek zur Übersetzungszeit Typen modifizieren. Sie kennt primäre und zusammengesetzte Typkategorien. Die 14 primären Typkategorien sind vollständig und schließen sich gegenseitig aus, sodass jeder Datentyp nur einer Typkategorie angehört. Das Beispiel aus Listing 3 sagt mehr als tausend Worte, die Ausgabe des Programms zeigt Abbildung 3.
Listing 3
Typkategorien
01 #include <iostream>
02 #include <type_traits>
03
04 using namespace std;
05
06 int main(){
07
08 cout << endl;
09 cout << boolalpha;
10
11 cout << "is_void<void>::value: " << is_void<void>::value << endl;
12 cout << "is_integral<short>::value: " << is_integral<short>::value << endl;
13 cout << "is_floating_point<double>::value: " << is_floating_point<double>::value << endl;
14 cout << "is_array<int []>::value: " << is_array<int [] >::value << endl;
15 cout << "is_pointer<int*>::value: " << is_pointer<int*>::value << endl;
16 cout << "is_reference<int&>::value: " << is_reference<int&>::value << endl;
17 struct A{
18 int a;
19 int f(double){return 2011;}
20 };
21 cout << "is_member_object_pointer<int A::*>::value: " << is_member_object_pointer<int A::*>::value << endl;
22 cout << "is_member_function_pointer<int (A::*)(double)>::value: " << is_member_function_pointer<int (A::*)(double)>::value << endl;
23 enum E{
24 e= 1,
25 };
26 cout << "is_enum<E>::value: " << is_enum<E>::value << endl;
27 union U{
28 int u;
29 };
30 cout << "is_union<U>::value: " << is_union<U>::value << endl;
31 cout << "is_class<string>::value: " << is_class<string>::value << endl;
32 cout << "is_function<int * (double)>::value: " << is_function<int * (double)>::value << endl;
33 cout << "is_lvalue_reference<int&>::value: " << is_lvalue_reference <int&>::value << endl;
34 cout << "is_rvalue_reference<int&&>::value: " << is_rvalue_reference <int&&>::value << endl;
35
36 cout << endl;
37
38 }
Die Abfrage der primären Typkategorien hängt nicht davon ab, ob der Entwickler den Datentyp als »const« oder »volatile« deklariert hat. Vielmehr entstehen die zusammengesetzten Typkategorien durch Kombination der primären. So wird aus »std::is_integral<T>::value« und »std::is_floating_point<T>« die zusammengesetzte Typkategorie »std::is_arithmetic« .
C++ kennt noch viele weitere zusammengesetzte Typ-Kategorien und -Eigenschaften, die weit komplexere Typabfragen zur Kompilierzeit erlauben. So lassen sich die Eigenschaften spezieller Methoden wie Konstruktoren, Destruktoren und Zuweisungsoperatoren abfragen. Alle Details zur Introspektionsfähigkeit von C++ stellt [7] schön dar.
Ebenfalls gut zu wissen: Die Type-Traits-Funktionen »std::is_same()« , »std::is_base_of()« , »std::is_convertible()« und »std::is_explicitly_convertible()« können zwei Typen direkt miteinander vergleichen [7].
Wechselhaft
Typmodifikationen zur Übersetzungszeit gibt es in mehreren Varianten (Abbildung 4, [7]). Eine kann die »const« -, »volatile« – oder auch Vorzeichen-Eigenschaften eines Typs verändern. Eine andere transformiert einen Zeiger in einen Nicht-Zeiger-Typ, eine Referenz in einen Nicht-Referenz-Typ. Wie das Ganze in Code gegossen aussieht, demonstriert Listing 4.
Listing 4
Typmodifikation beim Übersetzen
01 #include <iostream>
02 #include <type_traits>
03
04 using namespace std;
05
06 int main(){
07
08 cout << boolalpha << endl;
09
10 cout << "is_const<add_const<int>::type>::value: " << is_const<add_const<int>::type>::value << endl;
11 cout << "is_const<remove_const<const int>::type>::value: " << is_const<remove_const<const int>::type>::value << endl;
12
13 cout << endl;
14
15 typedef add_const<int>::type myConstInt;
16 cout << "is_const<myConstInt>::value: " << is_const<myConstInt>::value << endl;
17 typedef const int myConstInt2;
18 cout << "is_same<myConstInt,myConstInt2>::value: " << is_same<myConstInt,myConstInt2>::value << endl;
19
20 cout << endl;
21
22 int fir= 1;
23 int& refFir1= fir;
24 using refToIntType= typename add_lvalue_reference<int>::type;
25 refToIntType refFir2= fir;
26
27 cout << "(fir,refFi1r,refFir2): " << "(" << fir << "," << refFir1 << "," << refFir2 << ")" << endl;
28
29 fir= 2;
30
31 cout << "(fir,refFir,refFir2): " << "(" << fir << "," << refFir1 << "," << refFir2 << ")" << endl;
32
33 cout << endl;
34
35 }
Zunächst fügt Zeile 10 die »const« -Eigenschaften des »int« -Typs hinzu und entfernt diese in Zeile 11 gleich wieder. Das Typ-Alias »myConstInt2« (Zeile 17) verhält sich wie ein konstanter »int« -Typ. Interessant ist außerdem, dass es sich sowohl bei der Variablen »refFir1« als auch bei »refFir2« um Referenzen handelt. Das erklärt nämlich den Effekt, dass die Zuweisung »fir= 2« in Zeile 29 alle drei Variablen auf einen Schlag verändert.
Wie geht’s weiter?
Als konstante Ausdrücke bezeichnen Entwickler in der Regel solche Ausdrücke, die C++ zur Übersetzungszeit auswertet. Das ermöglicht es dem Compiler, sie im nicht-flüchtigen Bereich des Programms zu speichern. Auf diesem Wege lässt sich die große Performanceschraube noch ein Stückchen weiter anziehen.
Ein schöner Nebeneffekt besteht noch darin, dass konstante Ausdrücke implizit Thread-safe sind. C++ bietet sie in Form von Variablen, Funktionen und benutzerdefinierten Typen an. Die Details zu diesem Thema folgen – wie gewohnt – in der nächsten Ausgabe dieser Serie über modernes C++.
Infos
- Rainer Grimm, “Statisch geprüft”: Linux-Magazin 02/15, S. 106
- Referenz zur C++-Funktion »std::copy()« : http://en.cppreference.com/w/cpp/algorithm/copy
- Rainer Grimm, “Magischer Mechanismus”: Linux-Magazin 01/11, S. 108
- »std::fill()« -Referenz: http://en.cppreference.com/w/cpp/algorithm/fill
- Sämtliche Listings zum Artikel: https://www.linux-magazin.de/static/listings/magazin/2015/04/cpp/
- »std::equal()« -Referenz: http://en.cppreference.com/w/cpp/algorithm/equal
- Type-Traits-Bibliothek: http://en.cppreference.com/w/cpp/header/type_traits










