Lohnt sich der Aufwand? Die generische Programmierung hat Vorteile bei Lesbarkeit, Robustheit, Wiederverwendbarkeit, und der Parallelisierung von Code

Vorhang zu ... und alle Fragen offen?

Damit ist unser kleines Tutorial (vorerst!) zuende. Natürlich kann man sich fragen, ob eine solch einfache Funktion wie Summe oder Reduktion einen derartigen Aufwand rechtfertigt? Nun, in einem Tutorial geht es natürlich um einen einfachen Fall, an dem wesentliche Punkte und Techniken demonstriert werden können. Aber selbst hier spricht einiges für den Einsatz generischer Komponenten.

Der Aufwand erscheint in der Tat zunächst hoch zu sein (verglichen mit einer einzelnen einfachen Schleife, die es in diesem Fall getan hätte). Aber zum einen lassen sich viele Details mit etwas Erfahrung sowie Benutzung fertiger Lösungen wie Boost enable_if und den neuen C++11-Konstrukten deutlich kompakter lösen (das wäre eine weitere Übungsaufgabe für Fortgeschrittene ... oder ein TODO für dieses Tutorial), zum anderen läßt sich ein Großteil der Infrastruktur (wie Iterator-Typabbildungen) für andere Sequenz-Algorithmen wiederverwenden.

Vorteile des generischen Ansatzes

Bereits durch die Notwendigkeit, die Voraussetzungen für die generische Implementierung zu hinterfragen, werden einige Fragen aufgeworfen, die auch in einer nicht-generischen Implementierung gelöst werden müssen (aber durch die Einengung des Blickfeldes eben aus demselben geraten können ...). Stichworte sind zu enger Wertetyp und neutrales Element bei potentiell leeren Eingabe-Sequenzen. Wir haben hier nicht diskutiert, wie ein zu enger Wertetyp erkannt und abgefangen werden könnte, aber eine generische Funktion, die dies bietet, sollte genug Mehrwert haben, um anstelle einfacher Schleifen verwendet zu werden.

Die Lesbarkeit (und Robustheit) von Anwendungscode kann durch Benutzung generischer Komponenten deutlich erhöht werden. Generell ist ein Code in der Regel leichter zu verstehen (intentionaler), wenn statt expliziter Schleifen ein Funktionsaufruf verwendet wird, aus dessen Interface sein Zweck deutlich wird. So ist m.E.

float mx = reduce(c.begin(), c.end(), std::max<float>);
klarer als die entsprechende explizite Schleife, und kürzer noch dazu. Es ist außerdem weniger fehleranfällig, weil man z.B. sich nicht um die richtige Initialisierung kümmern muss (das ist ja beim Maximum nicht ganz trivial, wie wir gesehen haben). Und schließlich wird der Code auch robuster gegenüber Änderungen z.B. an Datenstrukturen, da die generische Komponente mit wesentlich weniger Annahmen zu Implementierungsdetails auskommt als eine herkömmliche direkte Umsetzung.

Der Vorteil der Klarheit und Einfachheit geht allerdings etwas verloren, wenn die entsprechenden Operatoren erst mühsam zusammengebaut werden müssen. Lambdas in C++11 bieten dafür einen allgemeinen Mechanismus. Für häufig verwandte Parameter bieten sich auch dünne Wrapper um die allergenerischsten Varianten an:

template<class It>
typename value_type<It>::result
max(It begin, It end) { ... }
wodurch der Anwendungs-Code noch kürzer und klarer wird:
float mx = max(c.begin(), c.end());
Die einzige noch vorhandene Redundanz ist die doppelte Benennung des Containers c. Das läßt sich beheben durch weitere Wrapper, die statt Iteratoren ein Container- bzw. Range-basiertes Interface unterstützen, wie es z.B. die Boost Range Bibliothek anbietet. Das ergibt dann
float mx = max(c);
Einige Experten argumentieren, dass Range-basierte Interfaces gegenüber Iteratoren erhebliche Vorteile haben (siehe z.B. A. Alexandrescu's Präsentation Iterators Must Go bei der BoostCon 2009).

Wo klemmt's?

Kein Licht ohne Schatten. Wie Sie sicher bemerkt haben, hat generische Programmierung eine gewisse Lernkurve (die ich hoffentlich mit diesem Tutorial etwas abflachen konnte). Generell ist ein etwas abstrakterer Standpunkt nötig als üblich — wir haben mit einer einfachen Summe angefangen und sind bei Typ-Abbildungen und neutralen Elementen gelandet. Wie so vieles im Leben, ist auch dies Übungssache.

Generische Komponenten bringen einen gewissen Mehraufwand an Implementierung mit sich. Sie müssen daher natürlich auch mehrfach verwendet werden, damit sich der Aufwand amortisiert. Generische Komponenten gehören daher in entsprechende Bibliotheken — die werden aber in vielen Fällen erst sinnvoll ermöglicht durch die mit generischen Konstrukten erzielbare Allgemeinheit! (Eine C-Bibliothek, die Summen-Funktionen für die gängigen Typen enthält, gibt es aus gutem Grund nicht.) Am Beispiel der parallelen Programmierung diskutieren wir das weiter.

Der am häufigsten vorgetragene Kritikpunkt aber betrifft eigentlich nicht die generische Programmierung an sich, sondern zielt letztlich auf mangelnde Sprachunterstützung:

  • Die C++-Syntax für Templates teils umständlich und schwer zugänglich.
  • Die typische Schachtelung von generischem Code führt zu kaum verständlichen Fehlermeldungen.
  • Der Kompilier-Aufwand ist bei mehrfacher Verwendung von Template-Funktionen oder Klassen oft höher als für nicht-parametrisierte Varianten.

All dies nervt und kann ein echter Hemmschuh sein, ist aber prinzipiell vorübergehender Natur (auch wenn das ein schwacher Trost ist, wenn man sich mit Template-Fehlermeldungen quält). Vergleicht man z. B. die Kompiliergeschwindigkeit und Fehlerbehandlung von template codes heute mit der von 10 Jahren, so sind deutliche Verbesserungen festzustellen. Spracherweiterungen wie Lambdas (in C++11) und concepts (in C++1y?) sind Schritte in die richtige Richtung. Die Programmiersprache D zeigt, dass eine entsprechende Syntax auch für imperative Sprachen einfach und gleichzeitig mächtig sein kann. Daher bin ich sehr optimistisch, was die weitere Entwicklung der Sprachunterstützung für die generische Programmierung angeht.

Und nun wenden wir uns einem äußerst wichtigen Anwendungsfall zu.

Parallele Programmierung und GP

Richtig interessant (und etwas weniger trivial) wird es, wenn wir etwa parallele und vektorisierte Versionen von reduce betrachten. Der Charme einer solchen Erweiterung ist es, dass der Benutzer gar nichts von der parallelen Implementierung sieht bzw. sehen muss. Das ist natürlich sehr attraktiv! So etwas wird auch nur durch generische Programmierung überhaupt sinnvoll möglich, denn sonst müsste man ja für jede kleine (oder auch nicht so kleine) Änderung der Eingabeparameter wieder eine neue parallele Implementierung schreiben. Und an dieser Stelle zahlt es sich spätestens aus, wenn auch Aufgaben wie Zählen (s.o.) auf reduce zurückgeführt worden sind — die parallele Variante bekommt man dann umsonst.

Damit kann man die Entscheidung über das parallele Framework weitgehend vom Anwendungscode trennen. Wir können die parallelen Teile separat testen, sogar bevor die eigentliche Anwendung geschrieben wird. Und oft können wir diese komplexen algorithmischen Komponenten auch einfach wiederverwenden — getestet sind sie dann schon! Das Design wird dadurch natürlich einfacher und klarer, weil man sich nur noch um die Anwendungslogik kümmern muss.

Zu guter Letzt ...

Viele Fragen bleiben in diesem Tutorial unbeantwortet oder werden nur angerissen. So gibt es einiges zu Performance-Aspekten zu sagen, und natürlich zu vielen weiteren Anwendungen der generischen Programmierung auf komplexere Algorithmen! Ein paar Ideen warten noch in meinem Hinterkopf ... also bleiben Sie dran. Und in der Zwischenzeit: