7 Punkte von GN⁺ 5 시간 전 | 5 Kommentare | Auf WhatsApp teilen
  • 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

 
dieafterwork 1 시간 전

Ich bin mir nicht sicher, ob dieses Thema eine so dichotome Auslegung erfordert.

 
hanje3765 2 시간 전

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.

 
jimmy2056 3 시간 전

Ponytail hat das gepostet, und genau so ein Beitrag ist es, haha

 
shakespeares 4 시간 전

Es ist immer ein Gegensatz.

 
GN⁺ 5 시간 전
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, ...) eher solve(f:double -> double, stopping_criteria: StoppingCriteriaClass) bauen.

    • Der Kern des Artikels ist, dass es um Fälle geht, in denen noch nicht klar ist, wie viele Wahrheitsquellen es überhaupt gibt.
      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.
    • Wenn Software durch extremen Druck in einen Zustand mit zwei Wahrheitsquellen geraten ist, kann es ziemlich nützlich sein, einen CI-Test hinzuzufügen, der einen Merge nach main verhindert, wenn die beiden Quellen nicht übereinstimmen.
      Ein typisches Beispiel ist die Synchronisierung von pyproject.toml und requirements.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.
    • Der Maßstab „wenn sie auseinanderlaufen, ist es ein Bug“ ist eine sehr gute Faustregel.
      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.
    • Theoretisch stimmt das, aber in der Praxis gibt es viele Menschen, die jede Form von Duplikation um jeden Preis vermeiden wollen.
      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.

    • Ich mag das Zitat: „Dinge sollten so einfach wie möglich sein, aber nicht einfacher.“
      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.
    • Die gemeinsame Abstraktion könnte bereits ausgelagert sein. Das wäre der Code, der die Pixel eines einzelnen Sprites lädt und anzeigt.
      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.
    • Dieser Ansatz ist richtig. Spieleentwicklung sollte schließlich Spaß machen.
      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.

    • Ich nutze funktionale Sprachen als Hobby, und für mich ist die wichtigste Sache, die man sich merken sollte, die Technik.
      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.
    • Damit man Codeduplikation erlebt, müssen Entwickler nicht unbedingt in Silos arbeiten.
      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.
    • Ich frage mich, was genau mit „eine Funktion von zwei Teilen aus aufrufen“ gemeint ist.
  • 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.

    • Kein Problem, also keine Sorge: Einfach noch ein paar vage Boolean-Parameter zur wiederverwendeten Funktion hinzufügen, damit sie den neuen Use Case unterstützt, und dann ausrollen.
    • Entscheidend ist, dass man es „versucht hat“. Man macht eine Weile so weiter und gelangt dann an einen Punkt, an dem man der Abstraktion nicht mehr treu folgen kann, weil sie schlicht falsch ist.
  • 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.

    1. https://www.youtube.com/watch?v=rX0ItVEVjHc
    2. https://www.youtube.com/watch?v=Cum5uN2634o
    • Es kam mir immer seltsam vor, dass selbst ziemlich kluge Engineers manchmal Metaphern der realen Welt über die tatsächlichen Anforderungen einer Codebasis stellen.
      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 drop automatisch 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.

    • Kubernetes, mehr Microservices als Engineers, komplexe Protokolle, um ein paar Byte Overhead zu sparen, alles in der Cloud und zahllose Klassen, die auch einfach Funktionen hätten sein können, sind genau solche Beispiele.
  • Ä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 + '://' + DOMAIN
    Ich 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.

    • In Code mag ich Nähe sehr. Ich bevorzuge es, Dinge so nah wie möglich an ihrer Verwendung zu definieren. Das ist wirklich eine lästige Gewohnheit.
      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 Konstanten oben stehen, lassen sie sich leichter anpassen. Besonders dann, wenn diese Datei kopiert wird.
      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 url an 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.
    • Das habe ich ebenfalls erlebt. Wenn ein Event einen Namen hat, kann ich im gesamten riesigen Monolithen oder über ein Bündel von Microservice-Repositories hinweg sofort per grep alle Dateien finden, die mit diesem Event zu tun haben.
      Wenn man das in eine Konstante auslagert, muss man wieder jedes Projekt einzeln öffnen und Verwendungen suchen.
  • Mit Microservices kann man beides haben.

    • Ich weiß, dass es als Witz gemeint ist, aber in den Microservices einer idealen Welt gibt es so etwas wie Codeduplizierung zwischen Services nicht.
      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.
    • Aber Moment, es gibt noch mehr!
      Für nur $19.95 verwandeln wir einen Single Point of Failure in mehrere Single Points of Failure!
    • In 9 von 10 Fällen werden Microservices so stark voneinander abhängig, dass ein verteilter Monolith entsteht.
      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