Bevorzuge Duplikation gegenüber der falschen Abstraktion (2016)
(sandimetz.com)- Codeduplizierung ist viel günstiger als eine falsche Abstraktion, und vorschnelle Vereinheitlichung erhöht die langfristigen Wartungskosten
- Selbst anfangs sinnvolle Extraktionen verlieren mit leicht abweichenden Anforderungen durch Parameter und bedingte Verzweigungen ihre ursprüngliche Absicht
- Wenn eine gemeinsame Abstraktion anfängt, mehrere Ideen zugleich zu tragen, verwandelt sich der Code in eine bedingungsgetriebene Prozedur und wird mit jeder neuen Funktion anfälliger
- Man sollte sich vor dem Sunk-Cost-Fehlschluss hüten, bei dem man den bereits investierten Aufwand schützen will, und bei Bedarf die Abstraktion wieder an den Aufrufstellen inline setzen, sodass nur der tatsächlich benötigte Code übrig bleibt
- Wenn sich eine falsche Abstraktion gezeigt hat, ist es schneller, Duplikation wieder einzuführen, die Gemeinsamkeiten der aktuellen Anforderungen neu zu beobachten und erst danach erneut zu extrahieren
Wie falsche Abstraktionen entstehen
- Der Satz „duplication is far cheaper than the wrong abstraction“ war Teil eines RailsConf-2014-Vortrags und wird bis heute häufig zitiert
- Ein typischer Pfad zum Scheitern sieht so aus
- Entwickler A entdeckt Duplikation
- Die Duplikation wird in eine Methode oder Klasse extrahiert und mit einem Namen versehen; so entsteht eine neue Abstraktion
- Der wiederholte Code an den Aufrufstellen wird durch Aufrufe dieser neuen Abstraktion ersetzt
- Mit der Zeit tauchen neue Anforderungen auf, die fast passen, aber nicht vollständig identisch sind
- Entwickler B versucht, die bestehende Abstraktion beizubehalten, ergänzt Parameter und fügt bedingte Verzweigungen ein, die je nach Wert unterschiedliche Pfade nehmen
- Mit jeder weiteren Anforderung wachsen Parameter und Bedingungen, und der Code wird immer schwerer zu verstehen
- Einmal geschriebener Code wirkt leicht wie eine Investition, die bewahrt werden muss
- Die Psychologie bereits investierten Aufwands setzt ein
- Je komplexer und schwerer verständlich der Code ist, desto eher wirkt er wichtig und zeitaufwendig entstanden, wodurch es schwerfällt, ihn zu verwerfen
- Das hängt mit dem Sunk-Cost-Fehlschluss zusammen
Zu Duplikation zurückkehren und neu extrahieren
- Wenn man auf einer falschen Abstraktion weiter neue Anforderungen umsetzt, wird gemeinsamer Code zunehmend von Bedingungen dominiert und mit jeder zusätzlichen Funktion instabiler
- Der schnellere Weg ist dann nicht, noch weiter darauf aufzubauen, sondern einen Schritt zurückzugehen
- Den abstrahierten Code wieder an jeder Aufrufstelle inline einsetzen und so Duplikation erneut einführen
- Anhand der Parameter, die an jeder Aufrufstelle übergeben wurden, prüfen, welcher Code tatsächlich ausgeführt wird
- Code entfernen, der an der jeweiligen Aufrufstelle nicht benötigt wird
- Durch dieses Inline-Setzen werden Abstraktion und bedingte Verzweigungen gemeinsam entfernt, und jede Aufrufstelle wird auf genau den Code reduziert, den sie braucht
- Selbst Code, der scheinbar dieselbe Abstraktion aufruft, kann in Wirklichkeit an jeder Aufrufstelle ziemlich eigene Codepfade ausgeführt haben
- Erst nachdem die frühere Abstraktion vollständig entfernt wurde, kann man die Duplikation erneut beobachten und eine neue Abstraktion extrahieren, die zu den aktuellen Anforderungen passt
- Wenn zu gemeinsamem Code fortlaufend Parameter und bedingte Pfade hinzugefügt werden, passt diese Abstraktion wahrscheinlich nicht mehr
- Anfangs kann sie die richtige Abstraktion gewesen sein
- Durch geänderte Anforderungen lässt sie sich womöglich nicht mehr in derselben Form aufrechterhalten
- Bei einer falschen Abstraktion ist die Wiedereinführung von Duplikation kein Rückschritt, sondern ein besserer Schritt nach vorn
5 Kommentare
Ich bin mir nicht sicher, ob dieses Thema eine so dichotome Auslegung erfordert.
Oh, dem stimme ich zu.
Was nicht aufgeräumt ist, kann man aufräumen, aber
bei etwas, das bereits aufgeräumt ist, scheint das Umkrempeln deutlich mehr Aufwand zu kosten.
Ponytail hat das gepostet, und genau so ein Beitrag ist es, haha
Es ist immer ein Gegensatz.
Hacker-News-Kommentare
Ich finde, das Prinzip einer Single Source of Truth sollte immer eingehalten werden.
Wenn duplizierter Code zu Bugs führt, sobald er auseinanderläuft, sollte er refaktoriert werden. Andernfalls entsteht eine Fernkopplung, die künftige Entwickler womöglich erst bemerken, wenn der Bug bereits aufgetreten ist.
Solange dieses Prinzip jedoch nicht verletzt wird, ist Abstraktion nur eine Bequemlichkeit. Wenn sie anfängt, unhandlich zu werden, erfüllt sie ihren Zweck nicht mehr und es gibt keinen Grund, sie zu verwenden. Wenn eine Funktion mehrere Flags für maßgeschneidertes Verhalten braucht, ist das sehr wahrscheinlich eine falsche Abstraktion oder ein Verstoß gegen das Single-Responsibility-Prinzip.
Wenn wirklich viel Anpassung nötig ist, ist es oft besser, Funktionen/Funktoren als Argumente zu übergeben. Zum Beispiel könnte man statt
solve(f:double -> double, max_iters = 99, x_abs_tol = 1e-15, x_rel_tol = 1e-15, ...)ehersolve(f:double -> double, stopping_criteria: StoppingCriteriaClass)bauen.Es ist unklar, ob zwei Stellen im Code denselben Algorithmus verwenden oder nur leicht unterschiedliche Versionen, und noch wichtiger, ob sie sich aus demselben Grund ändern werden.
Die Maxime im Titel sagt, dass es schmerzhafter ist, verschiedene Dinge gewaltsam gleichzumachen, als dieselbe Sache zu duplizieren und später unterschiedlich zu machen, und ich halte das für richtig. Im zweiten Fall muss man dieselbe Änderung zweimal durchführen oder per Refactoring eine Abstraktion einführen; im ersten Fall muss man die Abstraktion immer weiter anflicken oder zurückbauen.
Besonders problematisch ist, dass dabei die Lokalität zerstört wird, und das ist beim Ändern eigentlich die wichtigste Eigenschaft überhaupt. Ich will einfach nur diese Änderung machen, ohne mir Sorgen zu machen, ob sie Nebenwirkungen in irrelevanten Teilen des Systems auslöst.
mainverhindert, wenn die beiden Quellen nicht übereinstimmen.Ein typisches Beispiel ist die Synchronisierung von
pyproject.tomlundrequirements.txt, und vermutlich lässt sich das auch allgemeiner anwenden. Die Voraussetzung ist, dass die Lage bereits so schief ist, dass eine Single Source of Truth nicht mehr möglich ist; das ist eher Schadensbegrenzung als Heilung.Ich habe oft erlebt, dass zwei Codestellen zu einem Zeitpunkt ähnlich aussehen und dann übermäßig abstrahiert werden, nur damit sie sich später wieder auseinanderentwickeln.
Vor allem Junior-Entwickler behandeln Duplikation manchmal so, als sei sie die Wurzel allen Übels.
Ich denke gelegentlich über dieses Problem nach. Ich bin ihm kürzlich in einem privaten Projekt begegnet, in dem ich mit 2D-Sprites für RTS-Einheiten gearbeitet habe. Die Unit-Sprites lagen auf dem Sprite-Sheet in konsistenter Form vor: 5 Sprites für 8 Richtungen, davon 3 gespiegelt, in der Reihenfolge stand, move, attack, die.
Also habe ich einen Loader gebaut, der action + direction annimmt und das abzuspielende Sprite-Array zurückgibt.
Dann gab es aber Explosions-Sprites ohne Richtungsabhängigkeit, Leichen-Sprites mit nur 4 Richtungen und 2 Spiegelungen, und außerdem Fälle, in denen Orks und Menschen abgesehen von den ersten vier Sprites größtenteils identisch waren.
Ich habe kurz darüber nachgedacht, was die gemeinsame Abstraktion für all das überhaupt sein soll, aber am Ende nur einen Teil des Ladecodes extrahiert und dann UnitLoader, CorpseLoader und EffectLoader gebaut. Vielleicht gibt es eine bessere Abstraktion, weil die drei Loader teilweise dieselben Dinge behandeln, aber die kann ich auch später noch finden. Es ist einfacher, die Duplikation später dann zu entfernen, als jetzt einen komplexen EverythingLoader zu bauen, der alle Fälle abdecken soll.
Beim Programmieren gibt es den Instinkt, Code durch Verallgemeinerung zu vereinfachen, aber die Realität ist chaotisch, deshalb vereinfacht man oft zu stark. Wie im Artikel beschrieben zeigt sich mit der Zeit und neuen Anforderungen dann, dass es eine zu frühe Vereinfachung war.
„Verfrühte Abstraktion ist die Wurzel vieler Hässlichkeiten“ wäre eine passende Maxime.
Auf der darüberliegenden Ebene — also bei der Interpretation des Sprite-Sheet-Layouts und der Wiedergabemodi — gibt es verschiedene Varianten, und möglicherweise existiert keine gemeinsame Abstraktion, die zu allen Fällen passt.
Statt eine unsichtbare Abstraktion gewaltsam zu konstruieren oder alles in eine unvollständige Abstraktion zu pressen, bevorzuge ich den aktuellen Ansatz. Es ist gut, zu warten, bis die Abstraktion vollständig klar ist und ihr Bedarf eindeutig geworden ist.
Als Gegenmittel zur DRY-Idee gibt es WET. Das bedeutet, alles zwei- oder dreimal zu schreiben. Noch wichtiger ist aber die Haltung, nur für tatsächlich belegte Anwendungsfälle zu abstrahieren, also meist erst dann, wenn sie sich als Duplikation gezeigt haben. Code für zukünftige, noch gar nicht existierende Anwendungsfälle steht der Abstraktion dessen, was man real hat, oft im Weg, und jedes Mal, wenn das passiert, ist es irgendwie komisch.
Die schwierigen und langweiligen Dinge kann man auch erledigen, wenn das Projekt bei den letzten 10 % angekommen ist.
Außerdem werden „Bugs“, die durch Duplikation entstanden sind, manchmal zu lustigen Features, die Spieler sogar mögen.
Als ich noch OOP verwendet habe, hatte ich wegen Abstraktionen oft Schwierigkeiten, aber seit ich zu einem fast rein funktionalen Ansatz gewechselt bin, kommt Codeduplikation nur noch selten vor.
Man erstellt einfach eine Funktion und ruft sie an zwei Stellen auf. Das Hauptproblem bei Abstraktion betrifft Datenstrukturen, aber TypeScript-Interfaces sind im Wesentlichen Duck Typing, daher ist selbst das oft kein großes Problem.
Deshalb ist Codeduplikation, die durch Abstraktionsprobleme entsteht, selten. Viel häufiger ist Codeduplikation, die daraus resultiert, dass Entwickler in Silos arbeiten.
Die meisten modernen Sprachen lassen sich problemlos auf funktionaler Programmier-Theorie aufbauen, und man muss Haskell nicht unbedingt kennen. Köpfe funktionieren bei jedem anders, aber mir liegt die Vorstellung, dass das Ganze aus kleinen, einfachen und manchmal flexiblen Bausteinen besteht.
Das ist das Gegenteil einer großen, komplexen Formwandel-Maschine, die alles erledigt.
Sobald ein Team eine gewisse Größe überschreitet und nicht mehr jeder wissen kann, woran alle anderen arbeiten, ist Codeduplikation ziemlich unvermeidlich. Das gilt auch dann, wenn alle im funktionalen Stil schreiben.
Genau das ist letzten Monat bei uns im Unternehmen passiert. Ich habe eine neue reine Helper-Funktion geschrieben und sie oben in die Datei gesetzt, und eine Woche später hat ein Kollege mich darauf hingewiesen, dass am Ende derselben Datei bereits eine sehr ähnliche Helper-Funktion mit im Wesentlichen derselben Funktionalität, aber anderer Signatur existierte.
Im selben Kontext wie der Haupttext: Wer beides erlebt hat, wird zustimmen. Eine unterentwickelte Codebasis ist sehr viel leichter zu handhaben als eine überentwickelte.
Der schlimmste Code, den ich jemals warten musste, war Code, der DRY folgen wollte. Allerdings hat man dabei nicht versucht, die ursprüngliche Absicht dieses Prinzips zu verstehen.
Der einzige Weg, aus diesem Chaos herauszukommen, war, wieder weitreichende Codeduplizierung einzuführen.
Dabei muss ich an zwei Vorträge denken: Mike Actons Data-Oriented Design and C++ [1] und Brian Cantrills The Complexity of Simplicity [2].
Mikes Vortrag sagt, dass eine Code-Lösung nicht die reale Welt modellieren muss, dass unterschiedliche Daten unterschiedliche Probleme erzeugen und daher unterschiedliche Lösungen brauchen. Es ist schwer, den Vortrag gut genug wiederzugeben, aber er hat mich stark beeinflusst.
Brians Vortrag behandelt Abstraktion im Allgemeinen und wie schwierig es ist, die „richtige“ Abstraktion zu finden.
Als ich vor ein paar Jahren, kurz nach dem Studium, in Rust einen Connection Pool implementierte, war die vernünftigste Umsetzung, dass das Connection-Objekt eine schwache Referenz auf den Pool hält und bei
dropautomatisch zurückgegeben wird.Mein Manager, ein sehr erfahrener Vorgesetzter, mochte diese Idee nicht, weil „eine Bibliothek Bücher hält, aber ein Buch keine Bibliothek hält“. Ich fand das nicht überzeugend genug, um das Design zu ändern, aber er wollte das Problem nur durch die Linse dieser Metapher betrachten.
Am Ende löste ein anderer Manager die Blockade mit dem Vorschlag auf: „Ein Bibliotheksbuch enthält zwar keine Bibliothek, aber hinten ist ein Bibliotheksstempel drauf, der auf die Rückgabestelle verweist.“ Diesen Ausbau der Analogie hielt der Manager offenbar für plausibel.
Wäre ich erfahrener gewesen, hätte ich vielleicht einen Weg gefunden, innerhalb dieser Metapher zu argumentieren, ohne den eigentlichen Punkt preiszugeben. Aber ich finde es bis heute vollkommen bizarr, dass man auf dieser Metapher als Standardrahmen bestanden hat, statt die Folgen der Code-Abstraktion und die tatsächliche Erfahrung bei der Nutzung der Bibliothek abzuwägen.
Niemand will zuhören. Wirklich niemand. In 90 % der Firmen gibt es sogenannte Senior-Entwickler, die ganz entzückt sind, wenn sie eine neue Abstraktion bauen.
Overengineering, Abstraktion und verfrühte Optimierung sind die drei großen Katastrophen des Engineerings.
Andererseits freue ich mich auch darüber, weil es dadurch immer Arbeit geben wird.
Ähnlich scheinen manche Entwickler zu glauben, dass Inline-Strings oder numerische Konstanten grundsätzlich böse sind. Ich habe in einem PR so etwas gesehen:
HTTPS_SCHEME = 'https'DOMAIN = 'www.example.com'url = HTTPS_SCHEME + '://' + DOMAINIch sehe nicht, was das bringt, außer dass man „keine Konstanten hartkodieren“ cargo-kultartig befolgt. Außerdem standen die Konstantendefinitionen ganz oben in der Datei, und der Code zum Erzeugen der URL war Hunderte Zeilen weiter unten.
Auch Regexe muss man nicht an den Anfang der Datei stellen, sondern kann sie dort platzieren, wo sie verwendet werden. Die Sprache ist schlau genug, vermutlich selbst zu erkennen, dass es sich um Konstanten handelt.
Wenn es nur eine winzige Funktion ist, kann man einfach ein Lambda verwenden. Ich wünschte, man würde keine Einzeiler-Funktionen, die nur ein- oder zweimal benutzt werden, irgendwo weit entfernt definieren.
Wenn man in Test- oder Staging-Umgebungen statt https lieber http verwenden muss, kann es sinnvoll sein, Schema und Domain zu trennen und die Konstanten weiter oben oder in eine eigene Datei zu legen. Wichtig ist auch, ob
urlan mehreren Stellen zusammengesetzt wird oder nur an einer.Benannte Konstanten am Anfang einer Datei zu haben, ist ein sehr verbreiteter Stil und manchmal auch Teil des Coding-Standards eines Teams.
Es kann noch andere Gründe geben, deshalb sollte man an Chesterton’s Fence denken. Jedenfalls ist es keine gute Idee, vorschnell von Cargo-Kult zu sprechen. Genauso könnte jemand sagen, dass Inline-Literale zu verwenden derselbe Cargo-Kult sei. Wenn etwas seltsam aussieht, kann man nachfragen; vielleicht gibt es einen guten Grund, oder es hat einfach niemanden interessiert und alle freuen sich, wenn man refaktoriert und die Konstanten inline setzt.
Wenn man das in eine Konstante auslagert, muss man wieder jedes Projekt einzeln öffnen und Verwendungen suchen.
Mit Microservices kann man beides haben.
Wenn man einen Service wartet, gibt es keinen Grund, sich um Code in einem anderen Service zu kümmern. Warum sollte mich der Code eines anderen Teams interessieren? Man muss nicht einmal wissen, dass dieses Team existiert. In großen Systemen ist es oft ohnehin unrealistisch, die Existenz aller Anwendungen zu kennen.
Für nur $19.95 verwandeln wir einen Single Point of Failure in mehrere Single Points of Failure!
Es ist besser, eine serviceorientierte Architektur zu verwenden und trotzdem einfach den Monolithen auszurollen. Das ist leichter zu testen und vermeidet zusätzlich die Schicht aus Serialisierung und Deserialisierung.
Die meisten Seniors wissen vermutlich, dass man DRY nicht blind befolgen sollte. Trotzdem fühlen sich viele von uns unwohl bei dem Gedanken, mehrere duplizierte Codequellen pflegen zu müssen
Um damit umzugehen, muss man das einfache Modell genau betrachten, in dem zwei Aufrufer von gemeinsamem Code abhängen. Wenn gemeinsamer Code wegen der Anforderungen nur eines Aufrufers geändert werden muss, dann gehört dieser Code nicht zum Gemeinsamen
Das falsche Ziel von DRY versucht man durch Kapselung zu lösen. Kapselung verlagert die Refactoring-Arbeit von den Aufrufern in den gemeinsamen Code. Das ist aber nicht die gewünschte Richtung, weil die Auswirkungen einer Aktualisierung des gemeinsamen Codes viel größer sind als bei den Aufrufern
Man kann DRY auch einhalten, ohne Kapselung zu erzwingen. Besser ist es, mehrere dünne Abstraktionen zu haben, die die Aufrufer kennen müssen. In der OOP lernt man dafür SRP und IoC, und in der prozeduralen Programmierung zeigt sich das ganz natürlich als eine Reihe von Aufrufen von Helper-Funktionen