Erhalten Sie Zugang zu diesem und mehr als 300000 Büchern ab EUR 5,99 monatlich.
Legacy Code steht für Software ohne Tests und einen großen Haufen chaotischer Code, der irgendwie funktioniert, aber keiner weiß wieso. Fast jede Firma arbeitet mit veraltetem Code, der nicht mehr gut läuft oder Performance-Probleme mit sich bringt. Michael Feathers zeigt Software-Entwicklern in diesem Buch, wie sich aus altem Code mehr Performance und Zuverlässigkeit herausholen lässt und wie dieser besser handhabbar wird. Der Leser lernt, wie Software so verändert und Features hinzugefügt werden, dass sie dadurch nicht schlechter wird und wie man Tests schreibt, die vor neuen Problemen schützen. Die Techniken sind für jede Programmiersprache anwendbar, die Beispiele im Buch sind in Java, C++, C und C#.
Sie lesen das E-Book in den Legimi-Apps auf:
Seitenzahl: 473
Veröffentlichungsjahr: 2018
Das E-Book (TTS) können Sie hören im Abo „Legimi Premium” in Legimi-Apps auf:
Bibliografische Information der Deutschen Nationalbibliothek
Die Deutsche Nationalbibliothek verzeichnet diese Publikation in der Deutschen Nationalbibliografie; detaillierte bibliografische Daten sind im Internet über <http://dnb.d-nb.de> abrufbar.
ISBN 978-3-95845-903-8
1. Auflage 2011
www.mitp.de
E-Mail: [email protected]
Telefon: +49 7953 / 7189 - 079
Telefax: +49 7953 / 7189 - 082
© 2011 mitp-Verlags GmbH & Co. KG, Frechen
Dieses Werk, einschließlich aller seiner Teile, ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlages unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Übersetzungen, Mikroverfilmungen und die Einspeicherung und Verarbeitung in elektronischen Systemen.
Die Wiedergabe von Gebrauchsnamen, Handelsnamen, Warenbezeichnungen usw. in diesem Werk berechtigt auch ohne besondere Kennzeichnung nicht zu der Annahme, dass solche Namen im Sinne der Warenzeichen- und Markenschutz-Gesetzgebung als frei zu betrachten wären und daher von jedermann benutzt werden dürften.
Übersetzung: Reinhard Engel
Lektorat: Sabine Schulz
Sprachkorrektorat: Petra Heubach-Erdmann
Coverbild: © Lux2008 – fotolia.de
Datenkonvertierung: CPI books GmbH, Leck
Dieses Ebook verwendet das EPUB-Format und ist optimiert für die Nutzung mit dem iBooks-Reader auf dem iPad von Apple. Bei der Verwendung von anderen Readern kann es zu Darstellungsproblemen kommen.
Der Verlag räumt Ihnen mit dem Kauf des E-Books das Recht ein, die Inhalte im Rahmen des geltenden Urheberrechts zu nutzen. Dieses Werk einschließlich seiner Teile ist urheberrechtlich geschützt. Jede Verwertung außerhalb der engen Grenzen des Urheberrechtsgesetzes ist ohne Zustimmung des Verlags unzulässig und strafbar. Dies gilt insbesondere für Vervielfältigungen, Mikroverfilmungen und Einspeicherung und Verarbeitung in elektronischen Systemen.
Der Verlag schützt seine E-Books vor Missbrauch des Urheberrechts durch ein digitales Rechtemanagement. Bei Kauf im Webshop des Verlages werden die E-Books mit einem nicht sichtbaren digitalen Wasserzeichen individuell pro Nutzer signiert. Bei Kauf in anderen Ebook-Webshops erfolgt die Signatur durch die Shopbetreiber. Angaben zu diesem DRM finden Sie auf den Seiten der jeweiligen Anbieter.
Für Ann, Deborah und Ryan,die strahlenden Zentren meines Lebens.– Michael
Vorwort
Geleitwort
Danksagungen
Einführung – Wie man dieses Buch lesen sollte
Teil I Wie Wandel funktioniert
1 Software ändern
1.1 Vier Gründe, Software zu ändern
1.2 Riskante Änderungen
2 Mit Feedback arbeiten
2.1 Was sind Unit-Tests?
2.2 Higher-Level-Tests
2.3 Testabdeckung
2.4 Der Algorithmus zur Änderung von Legacy Code
3 Überwachung und Trennung
3.1 Kollaborateure simulieren
4 Das Seam-Modell
4.1 Ein riesiges Blatt mit Text
4.2 Seams
4.3 Seam-Arten
5 Tools
5.1 Automatisierte Refactoring-Tools
5.2 Mock-Objekte
5.3 Unit-Test-Harnische
5.4 Allgemeine Test-Harnische
Teil II Software ändern
6 Ich habe nicht viel Zeit und ich muss den Code ändern
6.1 Sprout Method
6.2 Sprout Class
6.3 Wrap Method
6.4 Wrap Class
6.5 Zusammenfassung
7 Änderungen brauchen eine Ewigkeit
7.1 Verständlichkeit
7.2 Verzögerungszeit
7.3 Dependencies aufheben
7.4 Zusammenfassung
8 Wie füge ich eine Funktion hinzu?
8.1 Test-Driven Development (TDD)
8.2 Programming by Difference
8.3 Zusammenfassung
9 Ich kann diese Klasse nicht in einen Test-Harnisch einfügen
9.1 Der Fall des irritierenden Parameters
9.2 Der Fall der verborgenen Dependency
9.3 Der Fall der verketteten Konstruktionen
9.4 Der Fall der irritierenden globalen Dependency
9.5 Der Fall der schrecklichen Include-Dependencies
9.6 Der Fall der Zwiebel-Parameter
9.7 Der Fall des Alias-Parameters
10 Ich kann diese Methode nicht in einem Test-Harnisch ausführen
10.1 Der Fall der verborgenen Methode
10.2 Der Fall der »hilfreichen« Sprachfunktion
10.3 Der Fall des nicht erkennbaren Nebeneffekts
11 Ich muss eine Änderung vornehmen. Welche Methoden sollte ich testen?
11.1 Effekte analysieren
11.2 Vorwärtsanalyse (Reasoning Forward)
11.3 Effektfortpflanzung (Effect Propagation)
11.4 Tools für Effektanalysen
11.5 Von der Effektanalyse lernen
11.6 Effektskizzen vereinfachen
12 Ich muss in einem Bereich vieles ändern. Muss ich die Dependencies für alle beteiligten Klassen aufheben?
12.1 Abfangpunkte
12.2 Ein Design mit Einschnürpunkten beurteilen
12.3 Fallen bei Einschnürpunkten
13 Ich muss etwas ändern, weiß aber nicht, welche Tests ich schreiben soll
13.1 Charakterisierungs-Tests
13.2 Klassen charakterisieren
13.3 Gezielt testen
13.4 Eine Heuristik für das Schreiben von Charakterisierungs-Tests
14 Dependencies von Bibliotheken bringen mich um
15 Meine Anwendung besteht nur aus API-Aufrufen
16 Ich verstehe den Code nicht gut genug, um ihn zu ändern
16.1 Notizen/Skizzen
16.2 Listing Markup
16.3 Scratch Refactoring
16.4 Ungenutzten Code löschen
17 Meine Anwendung hat keine Struktur
17.1 Die Geschichte des Systems erzählen
17.2 Naked CRC
17.3 Conversation Scrutiny
18 Der Test-Code ist im Weg
18.1 Konventionen für Klassennamen
18.2 Der Speicherort für Tests
19 Mein Projekt ist nicht objektorientiert. Wie kann ich es sicher ändern?
19.1 Ein einfacher Fall
19.2 Ein schwieriger Fall
19.3 Neues Verhalten hinzufügen
19.4 Die Objektorientierung nutzen
19.5 Es ist alles objektorientiert
20 Diese Klasse ist zu groß und soll nicht noch größer werden
20.1 Aufgaben erkennen
20.2 Andere Techniken
20.3 Die nächsten Schritte
20.4 Nach dem Extrahieren von Klassen
21 Ich ändere im ganzen System denselben Code
21.1 Erste Schritte
22 Ich muss eine Monster-Methode ändern und kann keine Tests dafür schreiben
22.1 Spielarten von Monstern
22.2 Monster mit automatischer Refactoring-Unterstützung zähmen
22.3 Die Herausforderung des manuellen Refactorings
22.4 Strategie
23 Wie erkenne ich, dass ich nichts kaputtmache?
23.1 Hyperaware Editing
23.2 Single-Goal Editing
23.3 Preserve Signatures
23.4 Lean on the Compiler
24 Wir fühlen uns überwältigt. Es wird nicht besser
Teil III Techniken zur Aufhebung von Dependencies
25 Techniken zur Aufhebung von Dependencies
25.1 Adapt Parameter
25.2 Break Out Method Object
25.3 Definition Completion
25.4 Encapsulate Global References
25.5 Expose Static Method
25.6 Extract and Override Call
25.7 Extract and Override Factory Method
25.8 Extract and Override Getter
25.9 Extract Implementer
25.10 Extract Interface
25.11 Introduce Instance Delegator
25.12 Introduce Static Setter
25.13 Link Substitution
25.14 Parameterize Constructor
25.15 Parameterize Method
25.16 Primitivize Parameter
25.17 Pull Up Feature
25.18 Push Down Dependency
25.19 Replace Function with Function Pointer
25.20 Replace Global Reference with Getter
25.21 Subclass and Override Method
25.22 Supersede Instance Variable
25.23 Template Redefinition
25.24 Text Redefinition
A Refactoring
A.1 Extract Method
B Glossar
»… damit fing es an …«
In der Einführung zu diesem Buch verwendet Michael Feathers diesen Ausdruck, um den Beginn seiner Leidenschaft für Software zu beschreiben.
»… damit fing es an …«
Kennen Sie dieses Gefühl? Erinnern Sie sich an den einen Moment in Ihrem Leben, über den Sie sagen könnten: »… damit fing es an …«? Gab es einen einzigen Moment, der den Lauf Ihres Lebens änderte und schließlich dazu führte, dass Sie zu diesem Buch gegriffen haben und begannen, dieses Vorwort zu lesen?
Ich war in der sechsten Klasse, als ich einen solchen Moment erlebte. Ich interessierte mich für Naturwissenschaften, den Weltraum und alles Technische. Meine Mutter hatte in einem Katalog einen Plastikcomputer entdeckt und für mich bestellt. Er hieß Digi-Comp I. Vierzig Jahre später hat dieser kleine Plastikcomputer auf meinem Bücherregal einen Ehrenplatz. Er war der Katalysator, an dem sich meine lebenslange Leidenschaft für Software entzündete. Er vermittelte mir eine erste Ahnung davon, welche Freude das Schreiben von Programmen machen kann, die für Menschen Probleme lösen. Er bestand nur aus drei S-R-Flip-Flops und sechs Und-Gates aus Plastik, aber das reichte aus – er erfüllte seinen Zweck. Damit begann es … – für mich.
Aber meine Freude wurde bald getrübt, als ich erkannte, dass Softwaresysteme fast immer in einem Chaos enden. Was als kristallklares Design im Geist der Programmierer entstanden war, verrottete im Laufe der Zeit wie ein Stück verdorbenes Fleisch. Das hübsche kleine System, das wir im letzten Jahr erstellt haben, entwickelte sich im nächsten Jahr in einen schrecklichen Morast aus verschlungenen Funktionen und Variablen.
Warum passiert das? Warum verrotten Systeme? Warum können sie nicht sauber bleiben? Manchmal schieben wir die Schuld auf unsere Kunden. Manchmal beschuldigen wir sie, die Anforderungen zu ändern. Wir trösten uns mit dem Glauben, das Design wäre schon in Ordnung gewesen, wären die Kunden nur mit dem zufrieden gewesen, was sie ihrer Aussage nach brauchten. Der Kunde ist selber schuld, wenn er seine Anforderungen an uns ändert.
Na ja, falls Sie es noch nicht wissen: Anforderungen ändern sich. Designs, die nicht flexibel auf Änderungen der Anforderungen reagieren können, sind per se schlechte Designs. Kompetente Software-Entwickler wollen Designs erstellen, die Änderungen tolerieren.
Dieses Problem scheint unlösbar schwierig zu sein. Tatsächlich ist es so schwierig, dass fast jedes jemals produzierte System langsam und kräftezehrend verrottet. Diese Verrottung ist so weit verbreitet, dass wir für verrottete Programme einen besonderen Begriff geprägt haben: Legacy Code.
Legacy Code – ein Begriff, der Programmierer abstößt. Er ruft Bilder von einem unergründlichen Sumpf mit verschlungenem Wurzelwerk, Blutegeln in trüben Gewässern und sirrenden Stechmücken hervor. Es riecht nach Verfall, Moder, Schleim und Verwesung. Auch wenn unsere erste Freude am Programmieren überschäumend gewesen sein mag, reicht das Elend beim Umgang mit Legacy Code oft aus, um diese Begeisterung zu ersticken.
Viele haben versucht, Methoden zu entwickeln, um zu verhindern, dass aus Code überhaupt Legacy Code werden kann. Wir haben Bücher über Prinzipien, Patterns und Verfahren geschrieben, die Programmierern helfen können, ihre Systeme sauber zu halten. Aber Michael Feathers hat etwas erkannt, das vielen anderen entgangen ist. Vorbeugung ist nicht perfekt. Selbst das disziplinierteste Entwicklungsteam, das die besten Prinzipien und Patterns beherrscht und die besten Verfahren anwendet, produziert immer wieder einmal chaotische Systeme. Der Morast wächst immer noch. Es reicht nicht aus, Verrottung zu verhindern – Sie müssen diesen Prozess umkehren können.
Darum geht es in diesem Buch: die Umkehrung der Verrottung. Wie kann man aus einem verschlungenen, undurchschaubaren, verworrenen System langsam und allmählich Schritt für Schritt ein einfaches, sauber strukturiertes System mit einem makellosen Design machen? Wie kann man die Entropie umkehren?
Bevor Sie sich von Ihrer Begeisterung fortreißen lassen, möchte ich Sie warnen: Verrottung umzukehren, ist nicht leicht und braucht Zeit. Die Techniken, Patterns und Tools, die Feathers in diesem Buch präsentiert, sind wirksam, aber sie erfordern Arbeit, Zeit, Ausdauer und Sorgfalt. Dieses Buch ist keine Silberkugel. Es sagt Ihnen nicht, wie Sie den ganzen Mist, der sich in Ihren Systemen angesammelt hat, über Nacht beseitigen können, sondern beschreibt einen Satz von Disziplinen, Konzepten und Einstellungen, den Sie für den Rest Ihrer Karriere mit sich tragen werden und der Ihnen helfen wird, aus Systemen, die sich im Laufe der Zeit verschlechtern, Systeme zu machen, die sich im Laufe der Zeit verbessern.
Robert C. Martin29. Juni 2004
Erinnern Sie sich noch an das erste Programm, das Sie geschrieben haben? Ich erinnere mich an meins. Es war ein kleines Grafikprogramm, das ich auf einem frühen PC geschrieben habe. Ich begann später als die meisten meiner Freunde mit dem Programmieren. Sicher, Computer waren mir von klein auf bekannt; und ich erinnere mich daran, wie nachhaltig mich ein Minicomputer beeindruckt hat, den ich in einem Büro sah. Aber jahrelang hatte ich keine Gelegenheit, mich an einen Computer zu setzen. Später in meiner Teenagerzeit kaufte einer meiner Freunde einige der ersten TRS-80s. Ich war interessiert, aber auch ein wenig besorgt. Ich wusste, dass ich dem Computer verfallen würde, sollte ich anfangen, mit ihm zu spielen. Er sah einfach zu verlockend aus. Ich weiß nicht, woher ich mich so gut kannte, aber ich hielt mich zurück. Später im College hatte ein Zimmergenosse einen Computer; und ich kaufte mir einen C-Compiler, um mir das Programmieren beizubringen. Damit fing es an. Ich blieb jede Nacht auf, um dieses und jenes auszuprobieren und den Quellcode des Emacs-Editors zu studieren, der dem Compiler beilag. Es machte süchtig, es war anspruchsvoll, und ich liebte es.
Ich hoffe, Sie haben ähnliche Erfahrungen gemacht und haben die reine Freude erlebt, Dinge auf einem Computer zum Laufen zu bringen. Fast jeder Programmierer, den ich frage, kennt dieses Gefühl. Diese Freude gehört zu den Motiven, die uns diese Arbeit haben wählen lassen; aber wohin ist sie im Alltag verschwunden?
Vor einigen Jahren rief ich abends nach erledigter Arbeit meinen Freund Erik Meade an. Ich wusste, dass Erik gerade einen Beratungsauftrag mit einem neuen Team angenommen hatte, und fragte ihn deshalb: »Wie läuft’s?« Er sagte: »Nicht zu glauben, die schreiben Legacy Code.« Dies war einer der wenigen Male in meinem Leben, bei denen mich eine Äußerung eines Kollegen wie ein unerwarteter Schlag erwischte. Ich fühlte ihn direkt in der Magengrube. Erik hatte das Gefühl punktgenau ausgedrückt, das mich oft beschleicht, wenn ich zum ersten Mal mit einem fremden Team zu tun habe. Seine Mitglieder bemühen sich redlich, aber letztlich schreiben viele Menschen einfach nur Legacy Code. Die Gründe? Vielleicht ist der Termindruck zu stark. Vielleicht wiegt die Last des überkommenden Codes zu schwer. Vielleicht ist einfach nur kein besserer Code vorhanden, mit dem sie ihre Anstrengungen vergleichen könnten.
Was ist Legacy Code? Ich habe diesen Terminus bis jetzt undefiniert verwendet. Betrachten wir die strenge Definition: Legacy Code ist Code, den wir von jemand anderem übernommen haben. Vielleicht hat unser Unternehmen Code von einem anderen Unternehmen übernommen; vielleicht sind die Mitarbeiter des ursprünglichen Teams zu anderen Projekten abgewandert. Legacy Code ist Code eines anderen. Aber im Programmiererjargon bedeutet der Terminus viel mehr als das. Der Terminus Legacy Code hat im Laufe der Zeit zusätzliche Bedeutungen und mehr Gewicht angenommen.
Was denken Sie, wenn Sie den Terminus Legacy Code hören? Denken Sie wie ich an eine verworrene, unverständliche Struktur, an Code, den Sie ändern müssen, den Sie aber nicht wirklich verstehen? Denken Sie an schlaflose Nächte, in denen Sie versuchen, Funktionen hinzuzufügen, die leicht hinzuzufügen sein sollten? Fühlen Sie sich entmutigt? Haben Sie den Eindruck, der Code sei den Mitgliedern Ihres Teams so über, dass ihnen alles egal ist und sie den Code am liebsten sterben sähen. Ein Teil von Ihnen fühlt sich sogar schlecht bei dem Gedanken, den Code zu verbessern. Er scheint Ihre Anstrengungen nicht zu verdienen. Diese Definition von Legacy Code hat nichts damit zu tun, wer ihn geschrieben hat. Die Qualität von Code kann durch viele Faktoren verschlechtert werden; und viele haben nichts damit zu tun, ob der Code von einem anderen Team geschrieben wurde.
In der Branche wird Legacy Code oft salopp zur Bezeichnung von Code verwendet, den man nicht versteht und der schwer zu ändern ist. Aber im Laufe der Jahre, in denen ich verschiedenen Teams geholfen habe, ernste Code-Probleme zu beseitigen, habe ich eine andere Definition entwickelt.
Für mich ist Legacy Code ganz einfach Code ohne Tests. Mit dieser Definition habe ich mir einigen Kummer eingehandelt. Was haben Tests damit zu tun, ob Code schlecht ist? Darauf hab ich eine unkomplizierte Antwort, die ich in diesem Buch immer wieder aus verschiedenen Blickwinkeln darstelle:
Code ohne Tests ist schlechter Code. Es spielt keine Rolle, wie gut er geschrieben ist; es spielt keine Rolle, wie schön oder objektorientiert oder gut eingekapselt er ist. Mit Tests können wir das Verhalten unseres Codes schnell und verifizierbar ändern. Ohne Tests wissen wir nicht wirklich, ob unser Code besser oder schlechter wird.
Vielleicht halten Sie das für streng. Was ist mit sauberem Code? Reicht es nicht aus, wenn eine Code-Basis sehr sauber und gut strukturiert ist? Bitte verstehen Sie mich nicht falsch. Ich liebe sauberen Code. Ich liebe ihn mehr als die meisten Menschen, die ich kenne; doch sauberer Code ist zwar gut, aber allein nicht gut genug. Teams gehen erhebliche Risiken ein, wenn sie große Änderungen ohne Tests durchführen wollen. Es ähnelt der Hochseilartistik ohne Sicherheitsnetz. Es erfordert unglaubliches Können und ein klares Verständnis, was bei jedem Schritt passieren wird. Die Auswirkung der Änderung einiger Variablen genau zu überschauen, ist oft mit der Gewissheit vergleichbar, dass Sie nach einem Salto von einem anderen Artisten an den Armen aufgefangen werden. Wenn Sie in einem Team an Code arbeiten, der derartig übersichtlich ist, sind Sie in einer besseren Position als die meisten Programmierer. Bei meiner Arbeit sind mir Teams mit einem solchen Code selten begegnet. Sie scheinen eine statistische Anomalie zu sein. Und wissen Sie was? Wenn sie nicht mit Testunterstützung arbeiten, brauchen sie für Code-Änderungen immer noch länger als Teams, die systematisch testen.
Es stimmt: Teams werden besser und schreiben von Anfang an klareren Code; aber es dauert sehr lange, bis älterer Code klarer wird. In vielen Fällen wird dieses Ziel nie ganz erreicht. Deshalb habe ich kein Problem damit, Legacy Code als Code ohne Tests zu definieren. Es ist eine brauchbare Arbeitsdefinition, die auf eine Lösung verweist.
Obwohl ich bis jetzt ausführlich auf Tests eingegangen bin, handelt dieses Buch nicht vom Testen. In diesem Buch geht es darum, eine beliebige Code-Basis erfolgssicher zu ändern. In den folgenden Kapiteln beschreibe ich Techniken, mit denen Sie Code verstehen, in eine Testumgebung integrieren, refaktorisieren und funktional erweitern können.
Sie werden beim Lesen dieses Buches bemerken, dass es hier nicht um »schönen« Code geht. Die Beispiele in diesem Buch sind konstruiert, weil ich mit Kunden unter einer Geheimhaltungsvereinbarung arbeite. Aber in vielen Beispielen bemühe ich mich, den »Geist« des Codes wiederzugeben, der mir im Feld begegnet ist. Ich will nicht behaupten, alle Beispiele wären repräsentativ. Sicher gibt es im Feld Oasen mit großartigem Code, aber, ganz ehrlich, es gibt dort auch Code-Basen, die viel schlechter als alles sind, was ich in diesem Buch als Beispiel verwenden kann. Abgesehen von der Vertraulichkeit konnte ich einfach keinen derartigen Code in dieses Buch einfügen, ohne Sie zu Tode zu langweilen und wichtige Punkte in einem Morast von Details zu versenken. Folglich sind viele Beispiele relativ kurz. Wenn Sie ein solches Beispiel sehen und denken: »Der hat ja keine Ahnung – meine Methoden sind viel länger und viel schlechter«, nehmen Sie bitte meinen zugehörigen Ratschlag für bare Münze und prüfen Sie seine Anwendbarkeit, auch wenn das Beispiel einfacher zu sein scheint.
Die hier vorgestellten Techniken sind mit erheblich umfangreicherem Code getestet worden. Die Beispiele sind nur wegen des Buchformats kürzer. Insbesondere können Sie Ellipsen (…) in einem Code-Fragment wie folgt interpretieren: »Fügen Sie hier 500 Zeilen mit hässlichem Code ein.« Ein Beispiel:
In diesem Buch geht es nicht nur nicht um »schönen« Code, sondern noch weniger um »schönes« Design. Gutes Design sollte zu den Zielen jedes Programmierers gehören; aber bei Legacy Code nähern wir uns diesem Ziel schrittweise. In einigen Kapiteln beschreibe ich Methoden, wie man eine vorhandene Code-Basis mit neuem Code erweitern und dabei gute Designprinzipien berücksichtigen kann. Sie können in eine Legacy-Code-Basis Bereiche mit qualitativ hochwertigem Code einführen, sollten aber nicht überrascht sein, wenn bei einigen Änderungen andere Teile des Codes etwas hässlicher werden. Diese Arbeit gleicht einem chirurgischen Eingriff. Wir müssen Einschnitte vornehmen, und wir müssen durch die Eingeweide gehen und gewisse ästhetische Überlegungen beiseitelassen. Könnten die Hauptorgane und Eingeweide dieses Patienten in besserer Verfassung sein? Ja. Vergessen wir deshalb das anstehende Problem, nähen ihn wieder zu und raten ihm zu einer besseren Ernährung und regelmäßigem Training? Dies könnten wir tun; doch hier und jetzt müssen wir den Patienten nehmen, wie er ist, die Mängel beseitigen und ihn gesünder machen. Vielleicht wird er nie um olympische Medaillen kämpfen, aber wir dürfen das »Beste« nicht zum Feind des »Besseren« machen. Die Code-Basis kann gesünder und leichter handhabbar werden. Wenn sich ein Patient ein wenig besser fühlt, ist oft der geeignete Zeitpunkt, ihn zu einem gesünderen Lebensstil zu führen. Genau dies möchten wir mit Legacy Code erreichen. Wir versuchen, die dringenden Probleme zu beheben und dann den Code schrittweise zu verbessern, indem wir Änderungen erleichtern. Wenn es uns gelingt, diese Vorgehensweise fest in einem Team zu etablieren, wird auch das Design besser.
Die hier beschriebenen Techniken habe ich im Laufe der Jahre entdeckt oder von Kollegen gelernt, als ich bei meiner Arbeit mit Kunden versuchte, die Kontrolle über widerspenstige Code-Basen zu gewinnen. Dieser Legacy-Code-Schwerpunkt bildete sich zufällig heraus. Meine Arbeit bei Object Mentor bestand anfangs hauptsächlich darin, Teams mit ernsten Problemen bei der Entwicklung ihrer Fähigkeiten und der Verbesserung ihrer Interaktionen so weit zu unterstützen, dass sie regelmäßig qualitativ hochwertigen Code abliefern konnten. Wir verwendeten oft Extreme-Programming-Verfahren, um den Teams zu helfen, ihre Arbeit zu kontrollieren, intensiv zusammenzuarbeiten und Ergebnisse zu liefern. Oft glaube ich, Extreme Programming (XP) ist weniger eine Methode der Software-Entwicklung, sondern eher eine Methode zur Bildung funktionierender Teams, die nebenbei auch noch im Abstand von zwei Wochen großartige Software abliefern.
Doch von Anfang an gab es ein Problem. Viele der ersten XP-Projekte waren »Greenfield«-Projekte. Meine Kunden verfügten über umfangreiche Code-Basen, und sie hatten Probleme. Sie brauchten eine Methode, um ihre Arbeit in den Griff zu bekommen und termingerecht abzuliefern. Im Laufe der Zeit stellte ich fest, dass ich mit meinen Kunden immer wieder dieselben Probleme behandelte. Dieser Eindruck verdichtete sich bei einer Arbeit mit einem Team der Finanzbranche.
Bevor ich dazukam, hatte man erkannt, dass Unit-Testing eine beeindruckende Sache war, aber die Tests, die man ausführte, testeten das komplette Szenarium, griffen wiederholt auf eine Datenbank zu und führten umfangreiche Code-Fragmente aus. Die Tests waren schwer zu schreiben, und das Team führte sie nicht oft aus, weil sie so lange liefen. Als ich mich mit dem Team zusammensetzte, um die Dependencies (Abhängigkeiten) aufzulösen und den Code in kleineren Einheiten zu testen, hatte ich ein schreckliches Déjà-vu-Gefühl. Es schien, dass ich diese Art von Arbeit mit jedem Team, das ich traf, erneut leisten musste, und es war eine Art von Arbeit, über die niemand wirklich gerne nachdenkt. Es handelte sich um eine Drecksarbeit, die man erledigt, wenn man die Kontrolle über seinen Code gewinnen will und weiß, was man tun muss. Damals beschloss ich, dass es sich wirklich lohnen würde, über die Methoden zur Lösung dieser Probleme nachzudenken und sie aufzuschreiben, um Teams bei der Verbesserung ihrer Code-Basis zu helfen.
Eine Anmerkung zu den Beispielen: Ich habe Beispiele in mehreren verschiedenen Programmiersprachen verwendet. Die meisten Beispiele sind in Java, C++ und C geschrieben. Ich habe Java ausgewählt, weil diese Sprache weit verbreitet ist, und ich habe C++ eingeschlossen, weil diese Sprache in einer Legacy-Umgebung einige besondere Herausforderungen präsentiert. Ich habe C ausgewählt, weil es viele Probleme in prozeduralem Legacy Code hervorhebt. Zusammen decken diese Sprachen einen großen Teil des Spektrums der Legacy-Code-Probleme ab. Doch auch wenn Sie mit anderen Sprachen arbeiten, sollten Sie sich die Beispiele anschauen. Viele der behandelten Techniken können auch in anderen Sprachen, wie etwa Delphi, Visual Basic, COBOL oder FORTRAN, verwendet werden.
Ich hoffe, dass Ihnen die Techniken in diesem Buch bei Ihrer Arbeit helfen und dazu beitragen, die Freude am Programmieren wiederzufinden. Programmieren kann eine sehr lohnenswerte und erfreuliche Arbeit sein. Wenn Sie dieses Gefühl bei Ihrer Alltagsarbeit nicht haben, hoffe ich, dass Ihnen die Techniken in diesem Buch helfen werden, dieses Gefühl zu entdecken und in Ihrem Team zu kultivieren.
Vor allem schulde ich meiner Frau, Ann, und meinen Kindern, Deborah und Ryan, einen tief empfundenen Dank. Ihre Liebe und ihre Unterstützung machten dieses Buch und die vorhergehende Zeit des Lernens möglich. Außerdem möchte ich »Uncle Bob« Martin, dem Chef und Gründer von Object Mentor, danken. Sein strenger pragmatischer Ansatz zu Entwicklung und Design und seine Trennung des Kritischen vom Belanglosen gaben mir vor etwa einem Jahrzehnt Halt, als ich in einer Woge unrealistischer Ratschläge zu ertrinken schien. Und danke, Bob, dass du mir die Gelegenheit verschafft hast, in den vergangenen fünf Jahren mehr Code zu sehen und mit mehr Menschen zu arbeiten, als ich jemals für möglich gehalten hätte.
Ich muss auch Kent Beck, Martin Fowler, Ron Jeffries und Ward Cunningham für ihre gelegentlichen Ratschläge und ihre Lehren über Teamarbeit, Design und Programmieren danken. Mein besonderer Dank richtet sich an alle Menschen, die die Entwürfe lasen. Die offiziellen Gutachter waren Sven Gorts, Robert C. Martin, Erik Meade und Bill Wake; die inoffiziellen Gutachter waren Dr. Robert Koss, James Grenning, Lowell Lindstrom, Micah Martin, Russ Rufer und die Silicon Valley Patterns Group sowie James Newkirk.
Dank auch an die Gutachter der allerersten Entwürfe, die ich ins Internet stellte. Ihr Feedback hat die Richtung dieses Buches erheblich beeinflusst, nachdem ich sein Format umstrukturiert hatte. Ich entschuldige mich im Voraus bei allen, die ich vielleicht ausgelassen haben. Die ersten Gutachter waren: Darren Hobbs, Martin Lippert, Keith Nicholas, Phlip Plumlee, C. Keith Ray, Robert Blum, Bill Burris, William Caputo, Brian Marick, Steve Freeman, David Putman, Emily Bache, Dave Astels, Russel Hill, Christian Sepulveda und Brian Christopher Robinson.
Dank auch an Joshua Kerievsky, der wesentliche Anmerkungen zu einem der ersten Entwürfe beitrug, und Jeff Langr, der meine ganzen Schreibprozesse mit seinen Ratschlägen und zeitnahen Kritiken begleitete.
Die Gutachter halfen mir, meinen Entwurf erheblich zu glätten; doch sollte das Buch noch Fehler enthalten, bin ich dafür verantwortlich.
Dank an Martin Fowler, Ralph Johnson, Bill Opdyke, Don Roberts und John Brant für ihre Arbeit über das Refactoring. Sie war mir eine Inspiration.
Besonderen Dank schulde ich auch Jay Packlick, Jacques Morel und Kelly Mower von Sabre Holdings und Graham Wright von Workshare Technology für Unterstützung und Feedback.
Besonderen Dank schulde ich auch Paul Petralia, Michelle Vincenti, Lori Lyons, Krista Hansing und dem Rest des Teams bei Prentice-Hall. Danke, Paul, für die Hilfe und Ermutigung, die dieser Erstautor brauchte.
Mein besonderer Dank gilt auch Gary und Joan Feathers, April Roberts, Dr. Raimund Ege, David Lopez de Quintana, Carlos Perez, Carlos M. Rodriguez und dem verstorbenen Dr. John C. Comfort für ihre Hilfe und Ermutigung im Laufe der vergangenen Jahre. Ich muss auch Brian Button für das Beispiel in Kapitel 21, Ich ändere im ganzen System denselben Code, danken. Er schrieb diesen Code in etwa einer Stunde, als wir zusammen einen Refactoring-Kursus entwickelten. Dieser Code ist heute eines meiner Lieblingsbeispiele in meinen Programmierkursen.
Besondere danke ich auch Jannick Top, dessen Instrumentalstück De Futura mich als Soundtrack während meiner letzten Wochen bei der Arbeit an diesem Buch begleitete.
Schließlich möchte ich allen danken, mit denen ich im Laufe der letzten Jahre zusammengearbeitet habe und deren Einsichten und Herausforderungen das Material in diesem Buch verbessert haben.
Michael Feathers
mfeathers@objectmentor.comwww.objectmentor.comwww.michaelfeathers.com
In Software ändern habe ich versucht, häufig gestellte Fragen zu beantworten, die bei der Legacy-Code-Arbeit auftauchen. Jedes Kapitel ist nach einem besonderen Problem benannt. Dadurch werden die Kapitelüberschriften ziemlich lang; aber hoffentlich können Sie so schnell einen Abschnitt finden, der Ihnen hilft, Ihr besonderes Problem zu lösen.
Software ändern wird von einem Satz einführender Kapitel (Teil I, Wie Wandel funktioniert) und einem Katalog von Refactorings eingerahmt, die bei Legacy-Code-Arbeit sehr nützlich sind (Teil III, Techniken zur Aufhebung von Dependencies). Bitte lesen Sie das einführende Kapitel, insbesondere Kapitel 4, Das Seam-Model. Diese Kapitel liefern den Kontext und die Nomenklatur für alle folgenden Techniken. Zusätzlich sollten Sie Termini, die nicht im Kontext beschrieben werden, im Glossar nachschlagen.
Die Refactorings in Techniken zur Aufhebung von Dependencies sind etwas Besonderes, da sie ohne Tests angewendet werden sollen. Sie dienen der Einrichtung von Tests. Ich rate Ihnen, jede einzelne Technik durchzulesen, damit Sie mehr Möglichkeiten kennen lernen, um Ihren Legacy Code zu zähmen.
In diesem Teil:
Kapitel 1Software ändern
Kapitel 2Mit Feedback arbeiten
Kapitel 3Überwachung und Trennung
Kapitel 4Das Seam-Modell
Kapitel 5Tools
Code zu ändern, ist etwas Großartiges. Wir verdienen damit unseren Lebensunterhalt. Aber es gibt Methoden, Code zu ändern, die das Leben erschweren, und es gibt Methoden, die es erheblich erleichtern. In der Branche wurde nicht viel darüber geredet. Der Sache am nächsten kommt noch die Literatur über Refactoring. Ich glaube, wir sollten die Diskussion etwas breiter anlegen und überlegen, wie wir in den schlimmsten Situationen mit Code umgehen sollten. Zu diesem Zweck müssen wir uns zunächst näher mit der Mechanik von Änderungen befassen.
Der Einfachheit halber möchte ich vier Hauptgründe unterscheiden, Software zu ändern:
1. Eine Funktion hinzufügen
2. Einen Fehler beseitigen
3. Das Design verbessern
4. Die Nutzung von Ressourcen optimieren
Eine Funktion hinzuzufügen, scheint mir die unkomplizierteste Art von Änderungen zu sein. Die Software zeigt ein Verhalten, und der Anwender erwartet von dem System auch noch ein anderes Verhalten.
Angenommen, wir arbeiteten an einer Webanwendung und ein Manager teilte uns mit, das Unternehmenslogo solle nicht auf der linken, sondern auf der rechten Seite stehen. Wir sprechen mit ihm darüber und stellen fest, dass dies nicht ganz so einfach ist. Er möchte das Logo verschieben, aber erwartet auch andere Änderungen. Es soll beim nächsten Release animiert werden. Gehört dies in die Kategorie »Fehler beseitigen« oder »Neue Funktion hinzufügen«? Das hängt von Ihrem Standpunkt ab. Aus der Sicht des Kunden handelt es sich definitiv um die Beseitigung eines Problems. Vielleicht hat er die Webseite gesehen und ein Meeting mit Mitarbeitern seiner Abteilung veranstaltet; und sie haben beschlossen, das Logo an eine andere Stelle zu setzen und ein wenig mehr Funktionalität zu fordern. Auf der Sicht eines Entwicklers kann die Änderung als vollkommen neue Funktion eingestuft werden. »Wenn die Abteilung einfach aufhören würde, ständig ihre Meinung zu ändern, wären wir jetzt fertig.« Aber in einigen Unternehmen wird eine Verschiebung eines Logos einfach als Beseitigung eines Fehlers gesehen, ungeachtet der Tatsache, dass das Team dafür umfangreiche neue Arbeit leisten muss.
Man könnte dies leicht als subjektive Einschätzung abtun. Für Sie ist dies ein zu beseitigender Fehler, für mich eine neue Funktion, also was? Leider müssen in vielen Unternehmen Korrekturen von Fehlern und Erweiterungen um neue Funktionen unterschiedlich überwacht und abgerechnet werden, weil es auch noch Verträge, Garantien oder Qualitätsinitiativen gibt. Zwar können wir endlos darüber diskutieren, ob wir Funktionen hinzufügen oder Fehler beheben, aber letztlich müssen Code und andere Artefakte geändert werden. Dieser Streit auf semantischer Ebene, was die Tätigkeit denn nun letztlich sei, maskiert etwas für uns technisch viel Wichtigeres: die Verhaltensänderung eines Systems. Und dabei bedeutet es einen großen Unterschied, ob neues Verhalten hinzugefügt oder altes geändert wird.
Verhalten ist der wichtigste Aspekt von Software. Es ist der Grund, warum Anwender Software verwenden. Anwender lieben es, wenn wir Verhalten hinzufügen (vorausgesetzt, es leistet, was sie wirklich wollten), aber wenn wir Verhalten ändern oder entfernen, das sie benötigen (Fehler einführen), verlieren wir ihr Vertrauen.
Fügen wir in unserem Unternehmenslogo-Beispiel Verhalten hinzu? Ja; denn nach der Änderung wird das System ein Logo auf der rechten Seite anzeigen. Beseitigen wir Verhalten? Ja; denn es gibt kein Logo mehr auf der linken Seite.
Betrachten wir einen schwierigeren Fall. Angenommen, ein Kunde wolle ein Logo rechts auf einer Webseite anzeigen, aber es gäbe kein Logo auf der linken Seite, mit dem wir anfangen könnten. Ja; wir fügen Verhalten hinzu; aber entfernen wir auch Verhalten? Wurde an der Stelle, an der das Logo erscheinen soll, irgendetwas anderes angezeigt?
Ändern wir Verhalten, fügen wir Verhalten hinzu, oder beides?
Wir können eine Unterscheidung treffen, die für uns als Programmierer nützlicher ist. Wenn wir Code modifizieren müssen (und HTML zählt in diesem Fall als Code), könnten wir Verhalten ändern. Wenn wir nur Code hinzufügen und ihn aufrufen, fügen wir oft Verhalten hinzu. Betrachten wir ein anderes Beispiel, eine Methode einer Java-Klasse:
Die Klasse enthält eine Methode, mit der wir Track-Listings (etwa mit den Songs einer CD) hinzufügen können. Fügen wir eine Methode hinzu, mit der wir Track-Listings ersetzen können:
Haben wir mit dieser Methode neues Verhalten zu unserer Anwendung hinzugefügt oder haben wir Verhalten geändert? Weder noch. Eine Methode hinzuzufügen, ändert Verhalten erst, wenn die Methode irgendwie aufgerufen wird.
Ändern wir den Code erneut. Wir wollen einen neuen Button in die Benutzerschnittstelle des CD-Players einfügen und ihn mit der replaceTrackListing-Methode verknüpfen. Damit fügen wir das Verhalten hinzu, das wir in der replaceTrackListing-Methode spezifiziert haben; aber wir ändern auch Verhalten auf subtile Weise; denn die Benutzerschnittstelle wird mit diesem neuen Button etwas anders dargestellt. Möglicherweise dauert es eine Mikrosekunde länger, bis es komplett angezeigt wird. Es scheint fast unmöglich zu sein, Verhalten hinzuzufügen, ohne zugleich vorhandenes Verhalten bis zu einem gewissen Grad zu ändern.
Das Design zu verbessern, ist eine weitere Art von Software-Änderung. Wir wollen die Software umstrukturieren, etwa damit sie wartungsfreundlicher wird, wobei ihr Verhalten im Allgemeinen bewahrt werden soll. Wird dabei Verhalten, vielleicht aus Versehen, entfernt, bezeichnen wir dies oft als Bug (Fehler). Einer der Hauptgründe, warum viele Programmierer nicht versuchen, das Design zu verbessern, liegt darin, dass dabei leicht Verhalten verloren gehen, beschädigt oder unerwünscht verändert werden kann.
Der Prozess, Design zu verbessern, ohne Verhalten zu ändern, wird als Refactoring bezeichnet. Es basiert auf der Idee, dass wir Software wartungsfreundlicher machen können, ohne ihr Verhalten zu ändern, wenn wir Tests schreiben, mit denen wir kontrollieren, dass das vorhandene Verhalten nicht geändert wird, und in kleinen Schritten vorgehen, um dies nach jedem Schritt zu verifizieren. Entwickler säubern schon seit Jahren den Code vorhandener Systeme; aber erst in den letzten Jahren hat sich das Refactoring verbreitet. Es unterscheidet sich von allgemeinen Säuberungen darin, dass wir nicht einfach risikoarme Änderungen wie etwa eine Umformatierung von Quellcode oder invasive und riskante Dinge wie etwa das Umschreiben ganzer Code-Fragmente vornehmen, sondern dass wir eine Reihe kleiner struktureller Änderungen vornehmen und dabei von Tests unterstützt werden, die das Ändern des Codes erleichtern. Die Essenz des Refactorings besteht darin, Verhalten zu bewahren, während funktionale Änderungen Verhalten modifizieren.
Optimierung ähnelt dem Refactoring, verfolgt aber ein anderes Ziel. Bei beiden wird die Funktionalität nicht geändert, aber beim Refactoring wird die Programm-Struktur geändert, während bei der Optimierung die Nutzung von Ressourcen (Zeit, Speicherplatz usw.) verbessert wird.
Doch ähnelt das Refactoring der Optimierung tatsächlich viel stärker als dem Hinzufügen von Funktionen oder der Beseitigung von Fehlern? Refactoring und Optimierung haben gemeinsam, dass die Funktionalität invariant bleibt, während etwas anderes geändert wird.
Im Allgemeinen können wir bei der Arbeit an einem System drei verschiedene Aspekte ändern: Struktur, Funktionalität und Ressourcenverbrauch.
Was ändert sich normalerweise und was bleibt im Wesentlichen konstant, wenn wir vier unserer verschiedenen Arten von Änderungen vornehmen (ja, oft ändern sich alle drei Aspekte, aber wir wollen das Typische betrachten):
Oberflächlich sehen sich Refactoring und Optimierung sehr ähnlich. Sie halten die Funktionalität invariant. Aber was passiert, wenn wir neue Funktionalität separat betrachten? Wenn wir eine Funktion hinzufügen, führen wir eine neue Funktionalität ein, aber ohne vorhandene Funktionalität zu ändern.
Beim Hinzufügen von Funktionen, beim Refactoring und bei der Optimierung bleibt die vorhandene Funktionalität konstant. Und wenn wir das Beseitigen von Fehlern genauer betrachten, stellen wir zwar fest, dass wir damit Funktionalität ändern; aber die Änderungen sind oft sehr klein, verglichen mit der insgesamt vorhandenen Funktionalität, die nicht geändert wird.
Das Hinzufügen von Funktionen und das Beseitigen von Fehlern ähneln stark dem Refactoring und der Optimierung. In allen vier Fällen wollen wir Funktionalität oder Verhalten ändern, aber gleichzeitig viel mehr bewahren (siehe Abbildung 1.1).
Abb. 1.1: Verhalten bewahren
Was bedeutet diese detaillierte Analyse der möglichen Änderungen für unsere praktische Arbeit? Positiv betrachtet scheint sie uns zu sagen, worauf wir uns konzentrieren müssen. Wir müssen dafür sorgen, dass die kleine Anzahl der Dinge, die wir ändern, korrekt geändert werden. Negativ betrachtet lernen wir, dass dies nicht das Einzige ist, auf das wir uns konzentrieren müssen. Wir müssen herausfinden, wie wir den Rest des Verhaltens bewahren können. Dazu gehört leider mehr, als einfach den Code in Ruhe zu lassen. Wir müssen Gewissheit haben, dass sich das Verhalten nicht ändert, und das kann sehr schwierig sein. Das Verhalten, das wir bewahren müssen, ist normalerweise sehr umfangreich, aber das ist nicht das Problem. Das Problem ist, dass wir oft nicht wissen, in welchem Umfang Verhalten durch unsere Änderungen gefährdet ist. Andernfalls könnten wir uns auf dieses Verhalten konzentrieren und den Rest ignorieren.
Um Risiken zu verändern, müssen wir drei Fragen stellen:
1. Welche Änderungen müssen wir vornehmen?
2. Wie erfahren wir, dass wir sie korrekt vorgenommen haben?
3. Wie können wir sicher sein, dass wir nichts beschädigt haben?
Wie viele Änderungen können Sie sich leisten, wenn Änderungen riskant sind?
Die meisten Teams, mit denen ich gearbeitet habe, arbeiten mit einem sehr konservativen Risikomanagement. Sie minimierten die Anzahl der Änderungen ihrer Code-Basis. Manchmal handeln sie nach der Maxime: »Wenn es nicht kaputt ist, fass es nicht an.« In anderen Teams werden Änderungen »kleingeredet«. Die Entwickler sind einfach sehr vorsichtig, wenn sie Änderungen vornehmen: »Was? Sie erstellen dafür eine andere Methode?« Antwort: »Nein, ich füge nur die Code-Zeilen direkt hier in die Methode ein, wo ich gleichzeitig den Rest des Codes sehen kann. Ich muss weniger editieren, und es ist sicherer.«
Es ist verlockend zu denken, wir können Software-Probleme minimieren, indem wir sie ignorieren; aber leider holt uns die Wirklichkeit immer ein. Wenn wir vermeiden, neue Klassen und Methoden zu erstellen, werden die vorhandenen immer größer und unübersichtlicher. Wenn Sie ein umfangreiches System ändern, müssen Sie damit rechnen, dass es eine Weile dauert, mit dem Arbeitskontext vertraut zu werden. Gute und schlechte Systeme unterscheiden sich auch dadurch, dass Sie bei guten nach dieser Lernphase ein Gefühl der Sicherheit haben und sich zutrauen, die Änderungen erfolgreich vorzunehmen. Wenn Sie dagegen schlecht strukturierten Code nach der Lernphase ändern wollen, haben Sie eher das Gefühl, von einer Klippe zu springen, um einem Tiger zu entkommen. Sie zögern diesen Schritt immer weiter hinaus: »Bin ich bereit dafür? Nun, mir bleibt wohl nichts anderes übrig.«
Änderungen zu vermeiden, hat auch andere negative Konsequenzen. Wer Code nicht ändert, verliert oft die Fähigkeit dafür. Eine große Klasse in Teile zu zerlegen, kann ziemlich anstrengend sein, wenn Sie es nicht mehrfach pro Woche tun. Andernfalls wird es zur Routine. Sie erkennen immer besser, was kaputtgehen kann und was nicht, und die Arbeit geht viel leichter von der Hand.
Die letzte Konsequenz, Änderungen zu vermeiden, ist Angst. Leider haben viele Teams eine unglaubliche Angst vor Änderungen; und jeden Tag wird es schlimmer. Oft merken die Mitglieder gar nicht, wie viel Angst sie haben, bis sie bessere Techniken kennen lernen und die Angst langsam nachlässt.
Jetzt haben Sie erfahren, dass es schlecht ist, Änderungen zu vermeiden; aber welche Alternativen gibt es? Eine Alternative besteht einfach darin, sich mehr anzustrengen. Vielleicht können wir mehr Entwickler einstellen, damit alle genügend Zeit für Studium und Analyse des Codes haben und die Änderungen »richtig« durchgeführt werden. Sicher, mehr Zeit und bessere Analysen machen Änderungen sicherer. Oder etwa nicht? Wie kann ein Team nach allen Analysen sicher sein, ob es alles richtig verstanden hat?
Es gibt zwei grundsätzliche Methoden, ein System zu ändern: Edit and Pray (Bearbeiten und Beten) und Cover and Modify (Abdecken und Modifizieren). Leider ist Edit and Pray wohl eher der Branchenstandard. Bei Edit and Pray studieren Sie den Code gründlich, den Sie ändern wollen, planen die Änderungen sorgfältig und fangen dann an, den Code zu ändern. Wenn Sie fertig sind, führen Sie das System aus, um zu prüfen, ob die Änderungen wirksam waren, und probieren dann dieses und jenes aus, um festzustellen, ob Sie nichts beschädigt haben. Dieses Herumprobieren ist wichtig. Wenn Sie Ihre Änderungen vornehmen, hoffen und beten Sie, nichts falsch zu machen; danach nehmen Sie sich zusätzlich Zeit, um dies zu überprüfen.
Oberflächlich scheint Edit and Pray dasselbe wie »sorgfältig arbeiten« zu sein, sehr professionelles Verhalten also. Ihre »Sorgfalt« ist geradezu greifbar; und bei sehr invasiven Änderungen gehen Sie besonders sorgfältig vor, weil viel mehr schiefgehen kann. Aber Sicherheit hängt nicht nur von der Sorgfalt ab. Ich glaube, niemand würde zu einem Chirurgen gehen, der mit einem Buttermesser operiert, nur weil er sorgfältig arbeitet. Eine wirksame Änderung von Software erfordert ähnlich wie ein wirksamer chirurgischer Eingriff umfassendere Fähigkeiten. Sorgfältig zu arbeiten, bringt nicht viel, wenn man nicht die richtigen Tools und Techniken anwendet.
Cover and Modify ist eine andere Methode, Systeme zu ändern. Sie basiert auf der Idee, dass wir mit einem Sicherheitsnetz arbeiten können, wenn wir Software ändern. Natürlich handelt es sich nicht um ein normales Sicherheitsnetz, mit dem wir uns beim Stürzen vor Schaden bewahren, sondern um eine Art Schutzmantel, in den wir unseren Code einhüllen, um schädliche Änderungen einzudämmen und den Rest unserer Software nicht zu infizieren. Software zu bedecken bedeutet, sie mit Tests abzudecken. Wenn wir ein Code-Fragment mit einem brauchbaren Satz von Tests umgeben haben, können wir Änderungen vornehmen und sehr schnell herausfinden, ob sie positive oder negative Auswirkungen haben. Wir gehen immer noch mit derselben Sorgfalt vor; doch mit dem Feedback, das wir bekommen, können wir den Code präziser ändern.
Wenn Sie mit dieser Anwendung von Tests nicht vertraut sind, hört sich all dies wahrscheinlich etwas seltsam an. Traditionell werden Tests nach der Entwicklung geschrieben und ausgeführt. Eine Gruppe von Programmierern schreibt Code und ein Team von Testern prüft dann mit diversen Tests, ob der Code die Spezifikationen erfüllt. In einigen sehr traditionellen IT-Abteilungen wird Software so und nicht anders entwickelt. Das Team kann Feedback bekommen, aber die Feedback-Schleife ist lang. Das Team arbeitet einige Wochen oder Monate, und dann sagen Tester in einer anderen Gruppe, ob alles richtig ist oder nicht.
Solche Tests versuchen eigentlich, die »Korrektheit zu demonstrieren«. Obwohl dies ein erstrebenswertes Ziel ist, können Tests auch ganz anders eingesetzt werden, nämlich »um Änderungen aufzudecken«.
Solche Tests werden üblicherweise als Regressionstests bezeichnet. Wir führen periodisch Tests aus, die bekanntermaßen richtiges Verhalten prüfen, um festzustellen, ob unsere Software immer noch so funktioniert wie vor den Änderungen.
Tests, die Code-Fragmente einschließen, die Sie ändern wollen, sind eine Art Software-Zwinge. Sie können das meiste Verhalten konstant halten und sicher sein, nur das zu ändern, was Sie ändern wollen.
Software-Zwinge
Eine Zwinge (Schraubstock, engl. vise) ist ein Gerät, in das man ein Werkstück einspannen kann, um es für die Dauer der Bearbeitung in einer bestimmten Position zu fixieren.
Tests, die Änderungen entdecken, verhalten sich wie eine Zwinge, in die unser Code eingespannt ist. Sie fixieren das Verhalten des Codes. Bei Änderungen können wir sicher sein, dass wir immer nur einen Teil des Verhaltens gleichzeitig ändern. Kurz gesagt: Wir kontrollieren unsere Arbeit.
Regressionstests sind eine hervorragende Errungenschaft. Warum werden sie von Entwicklern nicht öfter eingesetzt? Bei Regressionstests gibt es ein kleines Problem: Sie werden häufig auf die Anwendungsschnittstelle angewendet, egal ob es sich um eine Webanwendung, eine Befehlszeilenanwendung oder eine GUI-basierte Anwendung handelt. Regressionstests wurden traditionell der Anwendungsebene zugeordnet. Leider! Denn sie können ein sehr nützliches Feedback liefern. Deshalb lohnt es sich, sie auf einer feinkörnigeren Ebene einzusetzen.
Angenommen, wir analysierten eine umfangreiche Funktion mit einer komplizierten Logik. Wir denken nach, wir reden mit anderen Entwicklern, die das Code-Fragment besser kennen als wir, und dann ändern wir es. Wir wollen gewährleisten, dass die Änderung nichts beschädigt hat; doch wie können wir das tun? Glücklicherweise haben wir ein Qualitätssicherungsteam, das über einen Satz von Regressionstests verfügt, die wir über Nacht ausführen können. Wir rufen das Team an und bitten es, einen Testlauf einzuplanen. Das Team sagt zu, dass es die Tests über Nacht ausführen kann, aber es wäre gut, dass wir früh angerufen hätten. Andere Gruppen versuchen normalerweise, Regressionstests in der Mitte der Woche durchzuführen, und wenn wir länger gewartet hätten, wäre möglicherweise keine Zeit und kein Rechner für uns verfügbar gewesen. Wir atmen erleichtert durch und gehen zurück an die Arbeit. Wir müssen noch etwa fünf weitere Änderungen vornehmen, die ähnlich kompliziert wie die letzte sind. Und wir sind nicht allein. Wir wissen, dass mehrere andere Entwickler ebenfalls Änderungen vornehmen.
Am nächsten Morgen bekommen wir einen Anruf. Daiva aus dem Test-Team teilt uns mit, die Tests AE1021 und AE1029 wären über Nacht gescheitert. Sie sei nicht sicher, ob dies unsere Änderungen wären, rufe aber uns an, weil sie wisse, dass wir uns für sie darum kümmern würden. Wir debuggen den Code und prüfen, ob die Fehler auf unsere Änderungen oder die eines anderen Entwicklers zurückzuführen sind.
Leider kommt diese Situation allzu häufig vor. Betrachten wir ein anderes Szenarium.
Wir müssen eine ziemlich lange, komplizierte Funktion ändern. Glücklicherweise finden wir einen Satz von Unit-Tests dafür vor. Die Entwickler, die zuletzt mit dem Code gearbeitet haben, haben einen Satz von über 20 Unit-Tests geschrieben, die die Funktion gründlich durchleuchten. Wir führen die Tests aus und stellen fest, dass alle bestanden werden. Als Nächstes schauen wir uns die Tests an, um zu verstehen, wie sich der Code verhält.
Als wir den Code ändern wollen, wird uns klar, dass wir nicht genau wissen, wie wir vorgehen sollen. Der Code ist unklar, und wir würden ihn wirklich gerne besser verstehen, bevor wir ihn ändern. Da die Tests nicht alles erfassen, wollen wir möglichst klaren Code schreiben, damit wir mehr auf unsere Änderungen vertrauen können. Abgesehen davon sollte niemand noch einmal dieselbe Arbeit leisten müssen, um den Code zu verstehen. Was für eine Zeitverschwendung!
Wir beginnen, den Code zu refaktorisieren. Wir extrahieren einige Methoden und verschieben einige Bedingungen. Nach jeder kleinen Änderung führen wir die Suite von Unit-Tests aus. Sie werden fast bei jeder Ausführung bestanden. Vor einigen Minuten haben wir einen Fehler gemacht und die Logik einer Bedingung umgekehrt. Doch da ein Test scheiterte, konnten wir die Situation in einer Minute bereinigen. Nach dem Refactoring ist der Code viel übersichtlicher. Wir führen unsere geplante Änderung durch und wir sind sicher, dass sie korrekt ist. Wir fügen einige Tests hinzu, um das neue Verhalten zu verifizieren. Die nächsten Programmierer, die dieses Code-Fragment bearbeiten, werden es leichter haben und auf Tests zugreifen können, die seine Funktionalität abdecken.
Wollen Sie Ihr Feedback in einer Minute oder über Nacht bekommen? Welches Szenarium ist effizienter?
Unit-Tests zählen zu den wichtigsten Instrumenten beim Arbeiten mit Legacy Code. Regressionstests auf Systemebene sind großartig, aber kleine, lokalisierte Tests sind unschätzbar. Sie können Ihnen bei der Entwicklung Feedback liefern und helfen, Code viel sicherer zu refaktorisieren.
Der Terminus Unit-Test hat bei der Software-Entwicklung eine lange Geschichte. Den meisten Definitionen ist die Idee gemeinsam, dass es sich um Tests handelt, bei denen einzelne Software-Komponenten isoliert getestet werden. Was sind Komponenten? Es gibt verschiedene Definitionen; doch bei Unit-Tests untersuchen wir normalerweise die kleinsten Verhaltenseinheiten eines Systems. In prozeduralem Code sind die Units oft Funktionen, bei objektorientiertem Code sind sie Klassen.
Test-Harnisch
In diesem Buch benutze ich den Terminus Test-Harnisch (engl. test harness) als generische Bezeichnung für den Test-Code, den wir schreiben, um ein SoftwareFragment zu prüfen, und den Code, den wir benötigen, um die Tests auszuführen. Beim Arbeiten mit unserem Code können wir viele verschiedene Arten von Test-Harnischen verwenden. In Kapitel 5, Tools, beschreibe ich das xUnit-TestFramework und das FIT-Framework. Beide können für die Tests benutzt werden, die ich in diesem Buch beschreibe.
Kann man überhaupt nur eine Funktion oder eine Klasse testen? In prozeduralen Systemen ist es oft schwierig, Funktionen isoliert zu testen. Funktionen auf oberster Ebene rufen andere Funktionen auf, die wiederum andere Funktionen aktivieren usw. bis hinunter auf die Maschinenebene. In objektorientierten Systemen ist es ein wenig einfacher, Klassen isoliert zu testen; doch Tatsache ist, dass Klassen im Allgemeinen nicht isoliert existieren. Wie viele Klassen haben Sie geschrieben, die nicht auf andere Klassen zugreifen? Ziemlich wenige, nicht wahr? Normalerweise handelt es sich um kleine Daten-Klassen oder Datenstruktur-Klassen wie etwa Stacks oder Queues (und selbst diese nutzen vielleicht andere Klassen).
Isoliertes Testen ist ein wichtiger Aspekt der Definition eines Unit-Tests, aber warum ist er wichtig? Schließlich können viele Fehler auftreten, wenn Software-Komponenten integriert werden. Sollten umfangreiche Tests, die breite funktionale Code-Fragmente abdecken, nicht wichtiger sein? Nun ja, sie sind zweifellos wichtig; aber es gibt bei umfangreichen Tests einige Probleme:
■ Fehlerlokalisierung – Je weiter Tests von ihrem Testgegenstand entfernt sind, desto schwieriger wird es, ein Scheitern eines Tests richtig zu deuten. Oft ist es schwierig, die Ursache für das Scheitern zu lokalisieren. Man muss die Test-Inputs, seine Outputs und den Fehler analysieren, um genau die Stelle zu finden, an der der Fehler aufgetreten ist. Natürlich ist dies auch bei Unit-Tests erforderlich; dort ist es aber oft trivial, weil die Tests so klein sind.
■ Ausführungsdauer – Die Ausführung größerer Tests dauert normalerweise länger. Deshalb sind Testläufe oft ziemlich lästig. Tests, deren Ausführung zu lange dauert, werden letztlich ganz ausgelassen.
■ Abdeckung – Der Zusammenhang zwischen einem Code-Fragment und den Werten, mit denen es ausgeführt wird, kann schwer zu erkennen sein. Mit Coverage-Tools (Abdeckungs-Tools) können wir normalerweise feststellen, ob ein Code-Fragment von einem Test geprüft wird; doch wenn wir neuen Code hinzufügen, müssen wir möglicherweise eine aufwendige Mehrarbeit leisten, um High-Level-Tests zu erstellen, die den neuen Code prüfen.
Einer der frustrierendsten Aspekte umfangreicherer Tests liegt darin, dass sie auf Grund ihres Umfangs das Lokalisieren von Fehlern erschweren. Das scheint nicht offensichtlich zu sein. Denn wenn wir unsere Tests ausführen und sie bestanden werden und wir dann eine kleine Änderung machen und die Tests danach scheitern, wissen wir doch genau, wo das Problem ausgelöst wird. Es muss an unserer letzten kleinen Änderung liegen. Wir können die Änderung rückgängig machen und es noch einmal versuchen. Doch bei umfangreichen Tests kann die Ausführung zu lange dauern; deshalb sind wir oft versucht, die Tests nicht oft genug auszuführen, um Fehler wirklich zu lokalisieren.
Unit-Tests schließen Lücken, die von größeren Tests nicht abgedeckt werden können. Wir können Code-Fragmente unabhängig testen; wir können Tests so gruppieren, dass wir einige unter bestimmten Bedingungen und andere unter anderen Bedingungen ausführen. So können wir Fehler schnell lokalisieren. Wenn wir den Ort eines Fehlers in einem bestimmten Code-Fragment vermuten, können wir in einem Test-Harnisch normalerweise schnell einen Test schreiben, um zu prüfen, ob der Fehler wirklich an der vermuteten Stelle ausgelöst wird.
Gute Unit-Tests haben folgende Eigenschaften:
1. Sie werden schnell ausgeführt.
2. Sie helfen uns, Probleme zu lokalisieren.
Entwickler diskutieren oft, ob bestimmte Tests Unit-Tests sind. Ist ein Test wirklich ein Unit-Test, wenn er mehr als eine Produktionsklasse verwendet? Mit reichen die beiden genannten Eigenschaften. Natürlich gibt es Übergänge. Einige Tests sind umfangreicher und verwenden mehrere Klassen gleichzeitig. Vielleicht ähneln sie kleinen Integrationstests. Allein laufen sie vielleicht schnell, aber was passiert, wenn sie alle zusammen ausgeführt werden? Wenn ein Test eine Klasse zusammen mit mehreren ihrer Kollaborateure prüft, wird er tendenziell immer größer. Wenn Sie sich nicht die Zeit genommen haben, eine Klasse in einem Test-Harnisch separat instanziierbar zu machen, wie leicht wird es sein, wenn Sie mehr Code hinzufügen? Einfacher wird es nie. Man schiebt es vor sich her. Mit der Zeit braucht der Test dann 1/10 Sekunde zur Ausführung.
Ein Unit-Test, der 1/10 Sekunde zur Ausführung braucht, ist ein langsamer Unit-Test.
Das meine ich ernst. Als ich dies schrieb, war 1/10 Sekunde zur Ausführung eines Unit-Tests unglaublich lang. Rechnen wir nach: Ein Projekt mit 3.000 Klassen und zehn Tests pro Klasse enthält 30.000 Tests. Wie lange dauert die Ausführung aller Tests, wenn jeder 1/10 Sekunde braucht? Fast eine Stunde! Dies ist eine lange Zeit, wenn man auf Feedback wartet. Ihr Projekt umfasst keine 3.000 Klassen? Halbieren Sie die Anzahl. Dann warten Sie immer noch eine halbe Stunde. Bräuchten die Tests dagegen jeweils nur 1/100 Sekunde, sprächen wir über fünf bis zehn Minuten. Wenn es so lange dauert, arbeite ich möglichst nur mit einer Teilmenge der Tests; aber ich habe nichts dagegen, alle paar Stunden alle Tests laufen zu lassen.
Mit Hilfe von Moores Gesetz hoffe ich, in meinem Leben noch Test-Feedback zu erleben, das selbst für die größten Systeme fast in Echtzeit erfolgt. Ich vermute, das Arbeiten mit solchen Systemen wird dem Arbeiten mit Code ähneln, der zurückbeißen kann. Er wird uns mitteilen können, wenn er durch Änderungen beschädigt wird.
Unit-Tests laufen schnell. Wenn sie nicht schnell laufen, sind sie keine Unit-Tests.
Andere Arten von Tests verkleiden sich oft als Unit-Tests. Ein Test ist kein Unit-Test, …
1. … wenn er mit einer Datenbank kommuniziert;
2. … wenn er über ein Netzwerk kommuniziert;
3. … wenn er auf das Dateisystem zugreift.
4. … wenn Sie Ihre Umgebung verändern müssen (etwa indem Sie Konfigurationsdateien bearbeiten), um ihn auszuführen.
Solche Tests sind nicht schlecht. Oft lohnt es sich, sie zu schreiben; und im Allgemeinen werden sie in Unit-Test-Harnischen geschrieben. Doch es ist wichtig, sie von echten Unit-Tests zu unterscheiden, damit Sie mit einem Satz von Tests arbeiten können, die schnell ausgeführt werden, wenn Sie Änderungen vornehmen.
Unit-Tests sind hervorragende Instrumente, aber auch höher angesiedelte Tests, die Szenarien und Interaktionen in einer Anwendung abdecken, haben eine Existenzberechtigung. Sie können damit das Verhalten eines Satzes von Klassen gleichzeitig fixieren. Oft können Sie dann Tests für einzelne Klassen leichter schreiben.
Wie geht man die Änderung eines Legacy-Projekts am besten an? Zunächst müssen Sie akzeptieren, dass es im Zweifelsfall immer sicherer ist, Änderungen mit Tests zu ummanteln. Wenn wir Code ändern, können wir Fehler machen; schließlich sind wir alle nur Menschen. Doch wenn wir unseren Code vorher mit Tests abdecken, wächst unsere Chance, Fehler abzufangen.
Abbildung 2.1 zeigt einen kleinen Satz von Klassen. Wir wollen die getResponse-Text-Methode von InvoiceUpdateResponder und die getValue-Methode von Invoice ändern. Sie sind unsere Änderungspunkte, die wir mit Tests für ihre Klassen abdecken wollen.
Abb. 2.1: Invoice-Update-Klassen
Um Tests schreiben und ausführen zu können, müssen wir Instanzen von InvoiceUpdateResponder und Invoice in einem Test-Harnisch erstellen können. Ist dies möglich? Nun, eine Invoice zu erstellen, scheint einfach genug zu sein; die Klasse hat einen Konstruktor, der keine Argumente übernimmt. Doch die Klasse InvoiceUpdateResponder scheint etwas komplizierter zu sein. Sie akzeptiert eine DBConnection, eine echte Verbindung zu einer aktiven Datenbank. Wie können wir dies in einem Test handhaben? Müssen wir eine Datenbank mit Daten für unsere Tests einrichten? Das wäre viel Arbeit. Wären die Tests mit der Datenbank nicht langsam? Außerdem interessieren wir uns im Moment nicht besonders für die Datenbank, sondern wollen einfach unsere Änderungen in InvoiceUpdateResponder und Invoice abdecken. Wir haben auch ein größeres Problem. Der Konstruktor von InvoiceUpdateResponder braucht ein InvoiceUpdateServlet als Argument. Wie leicht können wir dieses erstellen? Wir könnten den Code so ändern, dass er kein Servlet mehr entgegennimmt. Wenn der InvoiceUpdateResponder nur wenige Daten aus InvoiceUpdateServlet benötigt, könnten wir nur diese Daten anstelle des ganzen Servlets übergeben; aber sollten wir nicht einen Test eingerichtet haben, um zu prüfen, ob wir diese Änderung korrekt vorgenommen haben?
Alle diese Probleme sind Dependency-Probleme (Abhängigkeitsprobleme). Hängen Klassen direkt von Dingen ab, die in einem Test schwer zu handhaben sind, ist es schwierig, sie zu ändern und damit zu arbeiten.
Dependency ist eines der gravierendsten Probleme bei der Software-Entwicklung. Ein großer Teil der Arbeit mit Legacy Code besteht darin, Dependencies so aufzuheben, dass der Code leichter geändert werden kann.
Also, wie gehen wir am besten vor? Wie können wir Tests einrichten, ohne den Code zu ändern? Die traurige Wahrheit ist, dass dies in vielen Fällen nicht gut machbar ist. In einigen Fällen kann es sogar unmöglich sein. In diesem Beispiel könnten wir versuchen, das DBConnection-Problem mit einer echten Datenbank zu umgehen; aber was ist mit dem Servlet-Problem? Müssen wir ein komplettes Servlet erstellen und an den Konstruktor von InvoiceUpdateResponder übergeben? Können wir es in den richtigen Zustand versetzen? Es könnte möglich sein. Was würden wir bei einer GUI-Desktop-Anwendung tun? Vielleicht hätten wir dann gar keine Programm-Schnittstelle. Die Logik könnte direkt in die GUI-Klassen eingebunden sein. Was tun wir dann?
Das Legacy-Code-Dilemma
Wollen wir Code ändern, sollten wir vorher Tests einrichten. Um Tests einzurichten, müssen wir oft Code ändern.
In dem Invoice-Beispiel können wir versuchen, auf einer höheren Ebene zu testen. Wenn es schwierig ist, Tests zu schreiben, ohne eine bestimmte Klasse zu ändern, ist es manchmal einfacher, eine Klasse zu testen, die diese bestimmte Klasse verwendet; davon abgesehen müssen wir normalerweise Dependencies zwischen Klassen an irgendeiner Stelle aufheben. In diesem Fall können wir die Dependency von InvoiceUpdateServlet aufheben, indem wir genau die Daten übergeben, die InvoiceUpdateResponder wirklich braucht: eine Collection von Invoice-IDs (Rechnungsnummern), die in dem InvoiceUpdateServlet gespeichert sind. Wir können auch die InvoiceUpdateResponder-Dependency von DBConnection aufheben, indem wir eine Schnittstelle (IDBConnection) einführen und InvoiceUpdateResponder so ändern, dass er stattdessen die Schnittstelle verwendet. Abbildung 2.2 zeigt den Zustand dieser Klassen nach den Änderungen.
Abb. 2.2: Invoice-Update-Klassen nach Aufhebung der Dependencies
Sind diese Refactorings ohne Tests sicher? Vielleicht. Diese Refactorings werden als Primitivize Parameter (25.16) bzw. Extract Interface (25.10) bezeichnet. Sie werden in dem Katalog der Techniken zur Aufhebung von Dependencies am Ende des Buches beschrieben. Wenn wir Dependencies aufheben, können wir oft Tests schreiben, die invasive Änderungen sicherer machen. Der Trick ist, diese anfänglichen Refactorings sehr konservativ vorzunehmen.
Konservativ vorzugehen, ist immer richtig, wenn wir Fehler einführen könnten. Doch um Code abzudecken, lassen sich Dependencies manchmal nicht so sauber aufheben wie in dem vorhergehenden Beispiel. Vielleicht führen wir in Methoden Parameter ein, die im Produktions-Code eigentlich überflüssig sind, oder wir zerlegen Klassen auf obskure Weise, nur um Tests einzurichten. In diesem Fall sieht der Code hinterher an der jeweiligen Stelle schlechter aus als vorher. Wären wir weniger konservativ, würden wir dies sofort korrigieren. Wir können dies tun, aber das hängt davon ab, welches Risiko damit verbunden ist. Wenn Fehler ein großes Problem sind, und normalerweise sind sie das, lohnt es sich, konservativ zu sein.
