`if` nach oben, `for` nach unten verschieben
(matklad.github.io)- Wenn man bedingte Verzweigungen innerhalb von Funktionen zur Aufruferseite hochzieht und Arbeiten über wiederholte Ziele als Batch-Operationen statt als einzelne Aufrufe nach unten verlagert, lassen sich Codefluss und Performance zugleich vereinfachen
- Wenn man
ifnach oben zieht, lassen sich Vorbedingungen leichter über Typen oder Assertions garantieren, und doppelte Prüfungen sowie unnötige Verzweigungen innerhalb von Funktionen nehmen ab - Wenn Verzweigungslogik über mehrere Funktionen verstreut ist, sind tote Bedingungen oder wiederholte Entscheidungen schwer zu finden; auch eine Struktur, bei der man ein Enum erstellt und anschließend wieder per Match darauf verzweigt, kann dieselbe Verzweigung als Datenstruktur wiederholen
- Wenn man
fornach unten verlagert, kann man mehrere Objekte auf einmal verarbeiten, Startkosten amortisieren und Spielraum für Optimierungen wie Umordnung der Verarbeitungsreihenfolge oder Vektorisierung schaffen - Nutzt man beide Regeln zusammen, werden die erneute Auswertung von Bedingungen innerhalb von Schleifen und Verzweigungen in heißen Loops reduziert; die Struktur nähert sich damit einem Modell an, bei dem die Entscheidungskosten der Control Plane an die Batch-Verarbeitung der Data Plane übergeben werden
if zur Aufruferseite nach oben ziehen
- Wenn es innerhalb einer Funktion eine
if-Bedingung gibt, kann man zuerst prüfen, ob sich diese Bedingung zum Aufrufer verschieben lässt- Eine Funktion, die statt
Option<Walrus>direkt einWalrusentgegennimmt, ist einfacher als eine, die beiNoneeinfach zurückkehrt - Statt dass die Funktion die Vorbedingung prüft und „nichts tut“, kann der Aufrufer die Prüfung übernehmen und sie über Typen oder Assertions absichern
- Eine Funktion, die statt
- Das Hochziehen von Vorbedingungsprüfungen kann sich über den gesamten Aufrufpfad ausbreiten und motiviert letztlich dazu, die Anzahl der Prüfungen zu verringern
- Kontrollfluss und
iferhöhen leicht die Komplexität des Codes und die Wahrscheinlichkeit von Bugs- Eine Struktur, in der komplexe Verzweigungslogik an einer Stelle in einer übergeordneten Funktion gebündelt ist und die eigentliche Arbeit an lineare Unterfunktionen delegiert wird, ist leichter zu lesen
- Wenn komplexer Kontrollfluss in einer einzelnen Funktion auf einen Bildschirm passt statt über eine ganze Datei verstreut zu sein, lassen sich doppelte und tote Bedingungen leichter erkennen
- Das Refactoring „dissolving enum“ ist nützlich, wenn die Erzeugung eines Enums und das anschließende Match dieselbe Verzweigung wiederholen
- Wenn
f()je nach BedingungE::FoooderE::Barerzeugt undg()danach erneut per Matchfoo()oderbar()aufruft, erscheint dieselbe Verzweigung zweimal - Zieht man die Bedingung nach oben, erhält man eine Form, bei der
main()anhand derselben Bedingung direktfoo(x)oderbar(y)aufruft
- Wenn
for in Richtung Batch-Operationen nach unten verlagern
- In datenorientiertem Denken arbeitet ein Programm meist mit Gruppen mehrerer Objekte, und Hot Paths werden oft heiß, weil sie viele Entitäten verarbeiten
- Es ist nützlich, das Konzept eines Batches von Objekten einzuführen und die skalare Version als Spezialfall der Batch-Version zu behandeln
frobnicate_batch(walruses)ist besser alsfor walrus in walruses { frobnicate(walrus) }
- Der wichtigste Vorteil der Batch-Verarbeitung ist die Performance
- Wenn man mehrere Ziele auf einmal verarbeitet, können Startkosten amortisiert werden
- Die Verarbeitungsreihenfolge lässt sich flexibler ändern, und die Notwendigkeit sinkt, Entitäten in einer bestimmten Reihenfolge zu bearbeiten
- Man kann Vektorisierung oder Struct-of-Array-Techniken einsetzen, indem man zuerst ein Feld aller Entitäten verarbeitet und erst danach zum nächsten Feld übergeht
- Als extremes Performance-Beispiel wird der Vectorized Interpreters Talk verlinkt
- Ein interessantes Beispiel ist die FFT-basierte Polynom-Multiplikation
- Die gleichzeitige Auswertung eines Polynoms an mehreren Punkten kann schneller sein als die mehrfache Auswertung an einzelnen Punkten
- Die Regeln für
ifundforlassen sich gemeinsam anwenden- Wenn die Bedingung außerhalb der Schleife liegt, wird
conditionnicht wiederholt neu ausgewertet - Verzweigungen werden aus heißen Loops entfernt
- Das eröffnet Möglichkeiten zur Vektorisierung
- Wenn die Bedingung außerhalb der Schleife liegt, wird
- Dasselbe Muster funktioniert sowohl auf mikro- als auch auf makroskopischer Ebene
- Die Struktur von TigerBeetle verarbeitet Objekt-Batches gleichzeitig in der Data Plane und amortisiert so die Entscheidungskosten der Control Plane
- Die Hauptmotivation der
for-Regel ist zwar Performance, sie kann aber auch der Ausdruckskraft helfenjQueryarbeitete auf Element-Kollektionen und war in der Vergangenheit erfolgreich- Die Sprache abstrakter Vektorräume kann als Denkwerkzeug besser sein als eine Sammlung koordinatenweiser Gleichungen
1 Kommentare
Meinungen auf Hacker News
Mein seltsames Denkmodell ist, dass es einen Baum möglicher Zustände und Programmflüsse gibt und dass Bedingungen diesen Baum beschneiden.
Es ist besser, so früh wie möglich zu beschneiden, damit man nur noch auf weniger Ästen arbeitet.
Letztlich möchte ich, dass sich eine Funktion auf eines von beidem konzentriert: den Programmbaum zu durchlaufen oder die eigentliche Arbeit zu erledigen.
Ausdrücke werden gemäß Reduktionsregeln wiederholt „umgeschrieben“ und ausgewertet. Zum Beispiel wird
(1 + 2) + 4zu3 + 4und dann zu7.Dabei gibt es Kongruenzregeln, die festlegen, welcher Teilausdruck als Nächstes ausgewertet wird, und Berechnungsregeln, die tatsächlich Ausdrücke verändern und damit den Programmzustand ändern.
Strikte, also nicht-lazy Sprachen lassen meist alle Teilausdrücke vor ihrem übergeordneten Ausdruck auswerten, aber spezielle Konstrukte wie Bedingungen und Endlosschleifen sind Ausnahmen.
Bei Bedingungen wird die Berechnungsregel angewendet, bevor die Kongruenzregel die Auswertung aller Teilausdrücke anordnet; dadurch wird der Ausdrucksbaum buchstäblich beschnitten.
[1]: Empfehlung: Benjamin C. Pierce, Types and Programming Languages
Man muss sich Domain-Eigenschaften, Muster in der bestehenden Codebase, die jeweilige Stufe in der Datenpipeline, Performance-Eigenschaften usw. ansehen.
Früher habe ich versucht, solche Regeln und Heuristiken für Codestruktur aufzustellen, aber wenn man genug Code schreibt, akzeptiert man irgendwann, dass die Abstraktionsebene falsch ist und es sich nicht lohnt, lange daran festzuhalten.
Dass solche Diskussionen sich auf erfundene Funktionsnamen oder Ein-Buchstaben-Variablen stützen, ist ebenfalls aufschlussreich. Denn auf einer „Code-Insel“ ohne äußeren Kontext kann fast jede Regel plausibel wirken.
Nur wenn man die bequeme Annahme trifft, dass
gder einzige Aufrufer vonhist und das auch bleiben wird, kann man sagen, dass diese Regel einen toten Zweig sichtbar gemacht hat.In echten Codebases gibt es normalerweise einen Grund, warum
gundhüberhaupt nicht zusammengelegt wurden.Die allgemeinere Regel lautet,
ifmöglichst nahe an die Quelle der Eingaben zu setzen: https://gieseanw.wordpress.com/2024/06/24/dont-push-ifs-up-p...Der Kern ist, diesen Punkt zu finden, einschließlich Einstiegspunkten von außen ins Programm und Daten aus anderen Services, und ihn so zu verfeinern, dass möglichst viele Garantien entstehen, bevor die Kernlogik erreicht wird, besonders ressourcenintensive Teile.
Wenn möglich, sollte man diese Garantien in Typen kodieren.
Die lockere Vermutung „Wenn es innerhalb einer Funktion eine
if-Bedingung gibt, sollte man überlegen, ob sie zum Aufrufer verschoben werden kann“ hat viel zu viele Gegenbeispiele.Wenn eine Funktion an 37 Stellen aufgerufen wird, soll man dann überall die
if-Anweisung wiederholen?Wenn diese Funktion
getaddrinfooderEnterCriticalSectionist, sollte man die Nutzer der API dazu bringen, dasifnach außen zu verlagern?Ich denke, diese Umformung kommt nur bei internen Funktionen infrage, die höchstens an zwei Stellen aufgerufen werden, und wenn die Entscheidung außerhalb der Zuständigkeit der Funktion liegt.
Eine andere Möglichkeit ist, die Funktion nur das
ifausführen zu lassen und zwei Helper-Funktionen aufzurufen.Wenn der Aufrufer die Bedingung aus einer Schleife herausziehen soll, kann er einen Low-Level-„Helper zur Bedingungsinterpretation“ verwenden. Aufrufer, die kein
ifinnerhalb oder außerhalb einer Schleife brauchen, sondern nur ein einzigesif, können eine Convenience-Funktion verwenden, die dasifverbirgt.Allerdings dient das der Optimierung, und Optimierung kollidiert oft mit guter Programmstruktur.
In der Objektorientierung zeigt sich eine
if-Entscheidung im Aufgerufenen als Method Dispatch, also als Auswahl, welche Methode aufgerufen wird.Auch Techniken, Method Dispatch aus einer Schleife herauszuziehen, können dem Designfluss zuwiderlaufen.
Wenn man etwa ein Canvas-Objekt mit einem Rasterbild füllen will, möchte man nicht über die Bildpixel iterieren und
canvas.putpixel(x, y, color)aufrufen. Es sollte eine Methode geben, die das Bild auf das Canvas oder auf dessen rechteckigen Bereich blitted.consider.Der Artikel zielt auf ein ziemlich konkretes Designproblem ab, das besonders bei Dingen wie Tagged Unions auftritt.
DRY fühlt sich wie die richtige Antwort an, aber man muss konkrete Codebeispiele sehen, um das beurteilen zu können.
Bei einer Library-Funktion ist die Lage speziell. Sie liegt an einer Ownership-Grenze, Daten durchqueren Domains und überschreiten, in DDD-Begriffen, einen Bounded Context. Deshalb muss sie ihren eigenen Bereich selbst schützen.
EnterCriticalSectiondeutet auf einen Codepfad hin, bei dem beim Eintritt eine starke Prüfung, einschließlich derif-Bedingung, sinnvoll ist, und sollte als Domain-Grenze betrachtet werden.Wenn man dagegen eine Anwendung schreibt und in einer gewöhnlichen Anwendungsfunktion eine
if-Anweisung steht, kann man sie gefahrlos nach außen verlagern.Auch innerhalb von Libraries oder kritischen Codeabschnitten ist es sicher,
ifnicht tief im Inneren zu lassen, sondern an den Rand zu heben.Der Rat, die eigene Domain zu verwalten, keine Anforderungen an fremde Domains zu stellen und innerhalb dieser Domain den Kontrollfluss an den Rand zu verlagern, klingt vernünftig.
Natürlich ist eine Faustregel nur eine Faustregel, und in der realen Welt muss jemand, der den Kontext versteht und vernünftig urteilen kann, die Bewertung vornehmen.
Das Beispiel „Enum-Zerlegungs-Refactoring“ ist im Kern Polymorphie
matchlässt sich durch einen polymorphen Methodenaufruf auf dem Enum ersetzenZiel ist es, die Stelle zu trennen, an der die Fallunterscheidung entsteht, also das erste
if, von der Stelle, an der daraufhinfoooderbarausgeführt wirdDie Fallunterscheidung wird vom Objekt mitgeführt, hier also vom Enum-Wert oder von einer Closure, und muss am Aufrufpunkt nicht erneut wiederholt werden
Wenn sich die Fallunterscheidung ändert, muss also nur die Stelle geändert werden, an der diese Unterscheidung entsteht; die Stellen, die das fallabhängige Verhalten auslösen, müssen nicht geändert werden
Allerdings gibt es einen Trade-off. Es kann hilfreich sein, die einzelnen Fälle direkt an der Stelle zu sehen, an der das Verhalten ausgeführt wird; dafür entsteht aber eine Code-Level-Abhängigkeit von der Liste der einzelnen Fälle
Scanner für Code-Komplexität erzwingen letztlich, dass man
ifnach unten schiebt. Der Artikel empfiehlt das GegenteilWenn man
ifnach oben zieht, wird der Kontrollfluss oft in einer Funktion zentralisiert; diese Funktion hat zwar komplexe Verzweigungslogik, delegiert die eigentliche Arbeit aber an geradlinige Unterroutinen⁰ https://docs.sonarsource.com/sonarqube-server/latest/user-gu...
if (weShouldDoThis()) { doThis(); }Das ergänzt den Functional Core und die imperative Shell oder ist Teil davon
Wenn man die Prüfungen getrennt hält, sind sie leichter zu testen; und wenn die Komplexität stört, kann man jede Klausel innerhalb der Prüfung in eine eigene Funktion auslagern
Insbesondere Sonar meldet „Code Smells“ und keine echten Bugs
Wenn man solche „Nicht-Bugs“ behebt, steigt das Risiko, neue Fehler einzubauen, von 0 auf einen Wert größer als 0, und es kann Entwicklerzeit verschwenden, die eigentlich für reale Produktionsprobleme gebraucht würde
„Lokal“ bedeutet hier: nur so lange, bis sich Anforderungen ändern oder ein Ausnahmefall entdeckt wird, sodass auch außerhalb der Schleife eine Verzweigung nötig wird
Wenn es sowohl innerhalb als auch außerhalb der Schleife Verzweigungen gibt, wird das Nachvollziehen schwieriger
Wenn man ziemlich sicher ist, dass die Bedingung nur innerhalb der Schleife Auswirkungen haben wird, kann man sie dort belassen
Wenn es aber nicht schwer ist, sich eine Anforderung vorzustellen, bei der auch außerhalb der Schleife verzweigt werden muss, kann es besser sein, die Struktur von Anfang an dafür auszulegen
Der Code kann dadurch ausführlicher werden, ist aber leichter zu verfolgen und wird später mit geringerer Wahrscheinlichkeit zu Spaghetti
Das ist der Grund, warum ich aufgehört habe, Haskell zu verwenden. Haskell verleitet dazu, die knappste und „lokal optimale“ Logik zu schreiben, aber das konzentriert sich eher darauf, die Logik selbst auszudrücken, als auf die Absicht hinter der Logik. Schon kleine Anforderungsänderungen konnten dazu führen, dass man schrecklich viel wieder auseinanderziehen musste
Es ist viel lesbarer, die Logik an einer Stelle zu haben, und man sollte sie erst aufteilen, wenn man wegen der Details anfängt, das große Ganze zu verlieren
Manchmal gefällt es mir besser, die bedingte Logik im Aufgerufenen zu belassen. Denn so verhindert man, dass der Aufrufer Dinge versehentlich in der falschen Reihenfolge erledigt
Wenn man zum Beispiel eine idempotente Operation bauen möchte, kann man zuerst prüfen, ob diese Arbeit bereits erledigt ist, und sie andernfalls ausführen
Wenn man diese Bedingung aus dem Aufgerufenen heraus nach außen schiebt, muss jeder Aufrufer der Funktion selbst sicherstellen, dass er sie auf die richtige Weise aufruft, um die Idempotenzgarantie zu erhalten; diese Garantie lässt sich dann nicht abstrahieren
Es gibt auch Fälle, in denen vor einer Aktion innerhalb einer Datenbanktransaktion eine Reihe von Prüfungen ausgeführt werden muss. Wie soll man bei dieser Philosophie die Prüfungen innerhalb der Transaktionsgrenze halten?
Wenn man die Bedingung aus dem Aufgerufenen nach außen verschiebt, ist diese Funktion nicht mehr idempotent und kann die Garantie folglich natürlich nicht mehr bieten
Wenn aber einzelne Funktionen Zustandsverwaltung implementieren müssen, um Idempotenz bereitzustellen, ist das vermutlich eine ziemlich merkwürdige Struktur, und es scheint, als passiere in einer einzelnen Funktion zu viel Logik
Idempotenter Code fällt normalerweise in zwei Kategorien
Die eine ist, dass das Datenmodell und die ausgeführte Operation selbst von Natur aus idempotent sind. Das ist entweder eine zustandslose Operation oder eine Operation im Stil von
PUT, bei der die Eingabedaten allen Zustand enthalten, der geschrieben werden mussDie andere sind komplexere Geschäftsoperationen, bei denen eine Idempotenzabstraktion entsteht, indem Rollbacks durchgeführt werden oder eine atomare Anwendungsabstraktion bereitgestellt wird, die garantiert, dass partielle Fehler den Zustand nicht beschädigen
Im ersten Fall muss man die Reihenfolge der Arbeit nicht prüfen. Da sie von Natur aus idempotent ist, führt man sie einfach erneut aus
Im zweiten Fall lässt sich keine einfache Abstraktion anwenden. Man muss die gewünschte Arbeit aufzeichnen, sicherstellen, dass sie abgeschlossen wird oder fehlschlägt, und anschließend dafür sorgen, dass dieser Abschluss oder Fehler dauerhaft persistiert
Diese Art von Logik gehört nicht in eine einzelne Funktion, die man dann mit anderen Operationen komponiert
Diese Ratschläge sind sehr meinungsstark und sollten nicht wie Faustregeln behandelt werden
Ich sehe hier eigentlich gar keine Faustregel. Wenn ich unbedingt eine formulieren müsste, würde ich vermutlich das Gegenteil sagen
ifsollte man wegen DRY nach unten schiebenWenn die Performance es zulässt, sollte man erwägen,
fornach oben zu ziehen. Dann kann man mitfilter/map/reduceund Funktionskomposition auswählen, welche Aktion auf welches Objekt angewendet wird, und den Code faktisch vektorisierenWenn man
ifnach unten schiebt, verhindert das normalerweise VektorisierungDie im Artikel genannten Fälle sind solche, die nicht DRY sind, insbesondere wenn ein Typ intern getaggt ist und sich ähnliche Verzweigungen in vielen Funktionen weiter unten im Stack vermehren müssen
Ich bin nicht überzeugt, dass das eine „gute“ Regel ist, der man folgen sollte
Manchmal mag das zutreffen, aber es ist zu kontextabhängig, um daraus eine Schlussfolgerung zu ziehen
Es fühlt sich ähnlich an wie die Regel „i vor e, außer nach c“. Es gibt so viele Ausnahmen, dass es praktisch so ist, als gäbe es gar keine Regel
Eine ähnliche Version habe ich aus Sandi Metz’ 99 Bottles of OOP übernommen
Insgesamt ist es nicht ganz mein Stil, aber der Punkt, in einer Codebasis, in der viele Flags mehrere Ebenen tief nach unten gereicht werden, die Verzweigungen der Logik im Call Stack nach oben zu verschieben, war sehr überzeugend
https://sandimetz.com/99bottles
Eine Verzweigung in eine
for-Schleife zu stecken, ist eine Abstraktion, die sagt: „Diefor-Schleife ist die Regel, die Verzweigung ist das Verhalten“Neue Anforderungen durchbrechen diese Abstraktion aber sehr häufig
Dann muss man darum herumarbeiten, und der resultierende Code hat entweder eine Abstraktion, die in manchen Fällen gilt und in anderen nicht, oder man stopft haufenweise Parameter in die Abstraktion, damit sie überall anwendbar wird, wodurch sie schwer nachzuvollziehen ist
Hätte man diese Abstraktion von Anfang an nicht gebaut, wäre der resultierende Code womöglich leichter zu ändern und zu verstehen gewesen
https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction
Für die Lesbarkeit von Code ist es besser, alles nach unten zu schieben
printInvoice(invoice, options)ist viel besser alsif(printerReady){ if(printerHasInk){ if(printerHasPaper){ if(invoiceFormatIsPortrait){ ... }}}}Bei Schleifen gilt entsprechend:
printInvoices(invoices)ist besser alsfor(invoice of invoices){ printInvoice(invoice) }Am Ende ist Code-Lesbarkeit zwar sehr wichtig, aber Kapselung ist wichtiger, daher muss man beides sinnvoll mischen
printInvoicesoll eine Rechnung druckenWas passiert, wenn eine der im Namen genannten Bedingungen falsch ist und die Rechnung daher nicht gedruckt werden kann?
Man kann eine Exception werfen oder einen Sentinel-Wert bzw. Fehlertyp zurückgeben, aber dann ist nicht sofort klar, was damit zu tun ist
Besonders in Sprachen wie Java oder C++, in denen man Exceptions für normalen Kontrollfluss eher vermeidet und monadische Fehler nicht üblich sind, kann es besser sein, die Struktur eher wie im zweiten Stil aufzubauen
Das Hochformat sollte allerdings, sofern es keinen Fehler darstellt, vom Rechnungsdrucker behandelt werden
Kapselung scheint vor allem ein Werkzeug für langfristige Code-Lesbarkeit, lokale Refactorings und Änderbarkeit sowie die Fähigkeit zu sein, sich nur um lokale Objekte zu kümmern und daraus globales Verhalten abzuleiten
Deshalb fühlt es sich wie ein Kategorienfehler an, Lesbarkeit und Kapselung gegeneinander abzuwägen und eines davon für wichtiger zu halten
Stattdessen sollte man es so machen
if(!printerReady){ return; }if(!printerHasInk){ return; }if(!printerHasPaper){ return; }if(!invoiceFormatIsPortrait){ return; }Das ist viel leichter zu lesen als ein immer größer werdender Pfeil
Allerdings ist es gut, die Schleife in eine eigene Funktion zu legen und die übrigen Annahmen ebenfalls bereits behandelt zu haben
Wenn kein Papier da ist, darf der Drucker selbst auf keinen Fall die Räder leer durchdrehen lassen. Diese Prüfung würde ich in der Funktion belassen
maybe_print_invoicenennen, und das gefällt mir deutlich besser