Refactoring von C++

Programmiersprachen spielen keine wesentliche Rolle habe ich einst gelernt, es kommt vor allem auf die Qualität der Entwickler an. Diese Aussage stammt aus den frühen 80er Jahren, als Barry Boehm sein „Software Engineering Economics“ veröffentlicht hat. Und tatsächlich kann die beste Programmiersprache der Welt wenig ausrichten, wenn das Team inkompetent ist – und ein kompetentes Team wird auch mit altertümlichen Programmiersprachen noch etwas ausrichten können. Also alles in Butter? Gebt mir eine turingvollständige Sprache und ich wuppe Euch das Projekt? Wohl kaum.

Nur in wenigen Bereichen hat sich so viel in den letzten 25 Jahren verändert, wie bei Programmiersprachen und ihren Entwicklungsumgebungen. In den frühen 80ern hatte man im Wesentlichen noch die Auswahl zwischen C, COBOL, Pascal, PL/I und Assembler und bei den Entwicklungsumgebungen die Wahl zwischen vi, emacs und dem Host-Editor. Heute führen die Unterschiede zwischen den verschiedenen Sprachen und Umgebungen durchaus zu Produktivitätsunterschieden von einer Größenordnung, also um den Faktor 10; beim gleichen Team, wohlgemerkt.

Besonders fallen mir diese Unterschiede auf, wenn ich – wie heute einmal wieder – einem Kunden beim Umbauen einer C++ Anwendung helfe. Zur Erinnerung: C++ war der Versuch, dem guten alten C einige Konzepte überzustülpen, die bei flüchtiger Betrachtung als objektorientiert verkauft werden konnten. „Die C-Programmierer müssen dann nicht umlernen“ war die häufigste Begründung für diese Sprache. Ohne das belegen zu können, vermute ich, dass genau dieser Umstand – unreflektierter Einsatz objektorientierter Techniken von Programmierern, die nur in prozeduraler Programmierung ausgebildet sind – zu den häufigsten technischen Gründen für gescheiterte Projekte zählt.

C++ sollte alles können und so kommt es, dass alleine die Grammatik der Sprache mehr als dreißig Seiten lang ist. Man stelle dies in Relation zur Grammatik von Smalltalk, die bequem auf einer DIN-A4-Seite Platz hat. Dazu kommt ein hochkomplexes Typsystem, dass alleine für einen Aufruf mit drei Parameter 2048 (in Worten: zweitausendachtundvierzig!) Varianten zulässt, wie man die Daten zwischen Aufrufer und der Methode hin- und herreichen kann. Dazu kommt noch das Ärgernis, dass dem Programmierer die Speicherverwaltung auf das Auge gedrückt wird, was eine nie versiegende Quelle komplexester Fehler darstellt.

Jedes einzelne Konstrukt klingt isoliert gar nicht so schlecht, allerdings hat man gelegentlich das Gefühl, dass die Schöpfer von C++ nicht die jeweils einfachste Lösung für ein Problem gesucht haben, sondern einfach alle denkbaren Lösungen eingebaut haben. Mit der Folge, dass keine von ihnen wirklich funktioniert. So gibt es neben dem – für manche Programmierer ohnehin schwer verständlichen – Unterschieden zwischen call-by-reference und call-by-value auch noch ein call-by-pointer und keine der drei Möglichkeiten ist so durchgängig implementiert, dass man diese Vielfalt ignorieren könnte. Insbesondere wenn man es wagt, Polymorphie einzusetzen, also ein Grundkonstrukt objektorientierten Designs, wird man mit einer Vielzahl von Hürden und Fallstricken bestraft: Hat die Superklasse wirklich alle Methoden mit virtual deklariert? Verwende ich ausschließlich Referenzen und Pointer, weil alles andere nicht polymorph wäre? (Siehe dazu auch Johannes Links Blogeintrag zu C#)

Die Höchststrafe aber erwartet einen, wenn man es wagt, nach lauffähiger Software zu fragen: Ein hinreichend komplexes System zu compilieren und zu linken kann leicht mehrere Stunden dauern – willkommen in der Welt der Batchcompiler von vor 50 Jahren! Dagegen hilft nur, das System in kleinste Einheiten zu zerlegen, wodurch man sich aber wiederum Versions- und Installationskomplexität einhandelt. Ein wenig Erleichterung verschafft auch Grid-Computing, bei dem das Compilieren auf möglichst viele Rechner verteilt wird – eine Technik, die sonst für Wettervorhersagen, Klimamodellierung und virtuelle Crashtests angewendet wird. Wer moderne Just-in-time-Compiler gewohnt ist, die schon während der Eingabe Fehler finden, kann sich nur schwer wieder in so einen altertümlichen Arbeitsrhythmus zurück finden.

„Alles halb so wild,“ sagen erfahrene (und überzeugte) C++-Entwickler oft, „man muss es nur richtig machen“ – eine ziemliche Binsenweisheit. Die Realität sieht anders aus: Zwar gibt es durchaus Teams, die mit einem enormen Aufwand an Werkzeugen und Disziplin das Chaos beherrschbar halten. Viele Häuser stehen aber mit umfangreichem Altcode da, der weder sauber entkoppelt ist, noch den mindestens Anforderungen an eine stabile Architektur entspricht. Das System einfach wegwerfen und neu schreiben, kommt aus wirtschaftlichen Gründen oft nicht in Frage; man kann sich nicht für drei oder vier Jahre vom Markt verabschieden, ohne den bestehenden Code zu warten, Fehler zu beheben und neue Funktionalität einzubauen.

In dieser Situation wird oft versucht, das System durch Refaktorisieren in den Griff zu bekommen: Durch kleine und kleinste Schritte wird der Code zunächst so entflochten, dass man einzelne Komponenten austauschen kann und zum Beispiel neu durch geschriebenen Java oder C# Code ersetzen kann.

In der Theorie klingt das erst einmal gut, zumindest wenn man unendlich viel Geduld besitzt und Programmieren eher als meditative Übung begreift, denn als produktive Tätigkeit. Buildzeiten von 20 oder 30 Minuten verbieten schon fast kleinste Schritte, wie man sie von modernen Umgebungen gewohnt ist. Zudem lassen sich entsprechend verflochtene Anwendungen in der Regel nicht mit echten Unit-Tests absichern. Die von außen aufgesetzten Akzeptanztests laufen in günstigen Fällen fünf bis zehn Minuten, so dass die Zykluszeit zwischen 20 und 40 Minuten liegt. Man vergleiche das mit Java-Umgebungen, wo ein solcher Zyklus dank moderner Umgebungen und Compilertechniken unter einer Minute durchlaufen werden kann!

Die Konsequenz ist klar: Statt kleinster Schritte werden größere Schritte gemacht: zehn oder zwanzig Änderungen, bei denen „nichts schief gehen kann“. Dann wird der Zyklus gestartet, es folgen 30 Minuten Meditation über hoffnungslos veraltete Compilertechnik, um dann einen scheiternden Test zu sehen. Nun erhält man zum Beispiel im Microsoft Visual Studio (die am meisten verbreitete Umgebung für C++) das Testergebnis nicht in einer Form, die einem direkte Rückschlüsse auf den Ursprung des Fehlers geben würde. Stattdessen bekommt man wirre Meldungen aus den Tiefen der Micosoft Foundation Classes serviert, bei denen die eigene Anwendung nicht einmal im Stacktrace enthalten ist. Hier darf man sich immerhin der Debuggertechnik der 80er Jahre erfreuen und im Einzelschritt durch die Anwendung gehen, um den Fehler zu finden; zumindest 15 Minuten intellektuell anspruchsvoller Rätselarbeit.

Ein Umbau, wie das Einziehen eines Interfaces kann in Java auf Eclipse in maximal 10 Minuten abgeschlossen werden. Der Anlass für meinen heutigen Blog war ein vergleichbarer Umbau, für den wir geschlagene vier Stunden gebraucht haben, das entspricht 240 Minuten, oder der 24-fachen Zeit. Neben der absolut mangelhaften Werkzeugunterstützung (der „beste“ Refactoringbrowser war zwar installiert, bietet eine solche Operation aber nicht mal in Einzelschritten an) waren die abenteuerlichen Zykluszeiten ausschlaggebend für diese nervenaufreibende Geduldsübung.

Was kann man daraus lernen: Wenn man irgendwie die Möglichkeit hat, sollte man Sprachen wie C++ meiden. Moderne Sprachen wie Java, C# oder dem Vernehmen nach auch Ruby ermöglichen kurze Zykluszeiten und damit Arbeiten in sinnvollen, kleinsten Schritten. Wer um den alten C++ Code nicht drum herum kommt, mache sich auf eine äußerst unangenehme Umbauzeit gefasst. Die folgende Strategie ist dabei oft sinnvoll:

  • Absichern des Codes von außen mit Akzeptanztests, z.B. mit Hilfe des FIT Frameworks
  • Herausschneiden fachlich zusammengehöriger Codeteile mit Hilfe neu eingezogener interfaces (ja die gibt es auch in C++!). Dafür müssen evtl. Zuständigkeiten verschoben werden und Felder eingekapselt werden. Es wird aber nur so viel umgebaut, wie nötig, um die Schnittstellen einzuziehen
  • Die neu entstandene Komponente über Unit Tests absichern. Ziel sind kürzere Zykluszeiten, also darauf achten, dass die Unit-Tests schnell laufen. Dazu kann es unter Umständen auch nötig werden, Datenbankzugriffe abzukapseln und durch Mocks zu ersetzen
  • Den vorigen Schritt bei Bedarf rekursiv wiederholen, bis sich handhabbare Komponenten mit erträglichen Buildzeiten ergeben
  • Auf Basis der dabei entstandenen Schnittstellen und Komponenten können dann besonders fehlerträchtige oder wartungsunfreundliche Komponenten herausgelöst werden und durch neue, testgetrieben entwickelte Komponenten in modernen Sprachen ausgetauscht werden. Auch dieser Schritt ist weder trivial noch risikolos, ist aber notwendig, um das Problem an der Wurzel zu packen

Solche Umbauten sind sehr zeitaufwändig und mit einem hohen Planungsrisiko behaftet. Eine gangbare Strategie besteht darin, nur einen Teil des Teams mit dem Refaktorisieren einer Komponente zu betrauen, während der Rest des Teams daran arbeitet, Mehrwert für die Kunden zu erwirtschaften.

Ein komplettes System so umzubauen kann viele Jahre benötigen. Sprachen, die solche Umbauten provozieren, sollten nur noch für Spezialfälle eingesetzt werden. Schließlich würden Sie ja auch keinen Neuwagen kaufen, dessen Motor in den frühen Achtzigern entwickelt wurde und noch viele Konzepte seines Vorgängers aus den späten sechziger Jahren enthält.

PS: Standardwerk zu diesem Thema ist Michael Feathers „Working Effektively with Legacy Code„, Prentice Hall, 2005

3 Gedanken zu „Refactoring von C++

  1. Hmmmm,

    Also mal zu c++: c++ ist meiner meinung nach die einzige portable high-level sprache di ohne vom auskommt. c# ist microsoft spezifisch, objective-c apple, java ist eigentlich schon eine eigene plattform (es gibt ja sogar schon den java desktop). ausserdem kann man in c++ highlevel konstrukte (objekte, reference counting, abstrahierte io, funktionale paradigmen, operator overloading etc.) fast ohne runtime penalty machen.

    zur kompilation: ja, du musst natürlich kleine bausteine bilden, aber nicht unbedingt um bilbiothejen zu bauen, sondern um das kompilat (*.o) zu cachen, und parallel zu kompilieren. komplett rekompilieren sollte man eh selten, und ausserdem sollte irgendwo im team ein schneller kompilationsrechner stehen, den man per distcc ansteuern kann. wenn das alles gegeben ist glaube ich kaum, dass du oft kompilationszeiten von mehr als ein paar minuten hast.

    und: ja, es gibt viele wege etwas zu machen. man muss sich schon selbst überlegen, welches tool das beste für ein bestimmtes problem ist, aber es gibt auch ne menge bücher die man lesen kann. ausserdem gibt es die stl und boost, die viele standardlösungen vorschlagen.

    was refactoring angeht: da fehlt sicher was (gibt es da nix kommerzielles? strange…). aber: man kann sowas ähnliches heute schohn mit gccxml machen, und bald auch mit clang (clang.llvm.org).

    Gruss

  2. Hallo Daniel,

    natürlich hast Du Recht, dass man auch in C++ gute Systeme bauen kann. Das ist ja auch geschehen, schließlich gibt es sehr viele erfolgreiche Systemem mit C++ vor allem im technischen Umfeld.

    Meine Erfahrung ist aber auch, dass die meisten C++ Systeme im C/S- oder Webumfeld, die ich bisher gesehen habe, genau nicht klein modularisiert und entkoppelt waren. Und dass dann zu zerlegen ist ein (technischer und wirtschaftlicher) Albtraum. Und die Werkzeugunterstützung ist für Systeme >500 Klassen eine schlichte Katastrophe.

    Ich glaube aber auch, dass es dafür viele sprach-inherente Gründe gibt, wie Redundanz in den Headerfiles, das Typsystem und viele goldene Henkel, die eine Automatisierung derart erschweren, dass Refactoring extrem schwierig wird. Dazu kommt noch, dass das verbreitetste Werkzeug Visual Studio keinen vollständigen AST aller Klassen eines Workspaces hält, sondern eher eine klassische Compiler-Linker-Toolkette ist mit ein paar GUI-Gimmicks vorne dran (Visual Studio für .NET ist da deutlich besser implementiert!).

    Alles in allem eine sehr frustrierende Angelegenheit, insbesondere wenn man sich anschaut, was Werkzeuge wie Eclipse oder TogetherJ da seit fast zehn Jahren vormachen. Und noch dazu bei der Produktivität der Entwickler ein Desaster (für die Manager unter den Bloglesern 🙂

Kommentare sind geschlossen.