- Jens Coldeweys Blog - http://blog.coldewey.com -

Refactoring von C++

Dieser Eintrag stammt von Jens Coldewey Am 27.11.2007 @ 21:49 In it-agile-blog-planet, Refactoring, Buchtipp, Agilität | 4 Kommentare

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 “[1] 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 [2] 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 “[3] Working Effektively with Legacy Code“, Prentice Hall, 2005


Dieser Artikel wurde ausgedruckt ab Jens Coldeweys Blog: http://blog.coldewey.com

URL zum Artikel: http://blog.coldewey.com/agile/2007/11/27/refactoring-von-c/

URLs in this post:
[1] Software Engineering Economics: http://www.amazon.de/dp/0138221227/ref=nosim?tag=coldewconsul-21
[2] Blogeintrag zu C#: http://jlink.blogger.de/stories/977302/
[3] Working Effektively with Legacy Code: http://www.amazon.de/dp/0131177052/ref=nosim?tag=coldewconsul-21

Klicken hier zum Drucken.