Open Source im professionellen Einsatz
Linux-Magazin 04/2015

Modernes C++ in der Praxis – Folge 21

Für vorsichtige Raser

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.

956

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.

Abbildung 1: Entscheidungslogik des Kopieralgorithmus.

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 }
Abbildung 2: Eindeutig ausfallender Performance-Vergleich mit der Funktion my::fill().

Ü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).

Diesen Artikel als PDF kaufen

Express-Kauf als PDF

Umfang: 4 Heftseiten

Preis € 0,99
(inkl. 19% MwSt.)

Linux-Magazin kaufen

Einzelne Ausgabe
 
Abonnements
 
TABLET & SMARTPHONE APPS
Bald erhältlich
Get it on Google Play

Deutschland

Ähnliche Artikel

  • C++11

    C++11 kennt zwei neue Literale: Raw-String- und benutzerdefinierte Literale. Während Raw-String-Literale den Interpreter zum Beispiel davon abbringen, Backslashes in Zeichenketten zu interpretieren, schützen benutzerdefinierte Literale dereinst womöglich Nasa-Sonden vor dem Verglühen.

  • C++

    Verantwortung abgeben fällt C++-Entwicklern nicht immer leicht. Dabei trifft der Compiler fast immer die besseren Entscheidungen, wie der vorliegende Artikel zeigt.

  • C++11

    Die neuen Smart Pointer in C++11 machen den Zugriff auf Ressourcen transparent und räumen hinter sich auf. Dieser Artikel stellt als ersten Vertreter den Unique Pointer vor.

  • C++

    Die wichtigsten Neuerungen im neuen C++17-Standard finden in den Bibliotheken statt: der leichtgewichtige String-Wrapper "string_view", die parallelisierten Algorithmen der STL, die Dateisystem-Bibliothek oder die praktischen Datentypen "std::optional" und "std::any".

  • C++11

    "Performance matters": Was sich wie ein Glaubensbekenntnis anhört, bleibt ein wichtiges Designkriterium in C++. Mit den neuen Hashtabellen kann der Programmierer richtig Tempo machen.

comments powered by Disqus

Ausgabe 10/2017

Digitale Ausgabe: Preis € 6,40
(inkl. 19% MwSt.)

Artikelserien und interessante Workshops aus dem Magazin können Sie hier als Bundle erwerben.