3 Punkte von GN⁺ 2025-05-18 | 1 Kommentare | Auf WhatsApp teilen
  • Wenn man if-Anweisungen innerhalb einer Funktion an die Aufrufstelle hochzieht, hilft das, die Komplexität des Codes zu verringern
  • Wenn Bedingungsprüfungen und Verzweigungslogik an einer Stelle gebündelt werden, lassen sich Duplikate und unnötige Verzweigungsprüfungen leichter erkennen
  • Mit dem Refactoring zum Auflösen von Enums lässt sich verhindern, dass dieselbe Bedingung an vielen Stellen im Code verstreut ist
  • Auf Batch-Verarbeitung basierende for-Schleifen sind wirksam für Leistungssteigerungen und die Optimierung wiederholter Arbeit
  • Durch die Kombination des Musters ifs nach oben, fors nach unten lassen sich Lesbarkeit und Effizienz des Codes zugleich steigern

Kurze Notiz zu zwei zusammenhängenden Regeln

  • Wenn es innerhalb einer Funktion eine if-Bedingung gibt, empfiehlt es sich zu prüfen, ob sie an die Aufrufstelle verschoben werden kann
  • Wie im Beispiel ist es wünschenswerter, statt Vorbedingungen innerhalb der Funktion zu prüfen, die entsprechende Prüfung an die Aufrufstelle zu verlagern oder die Vorbedingung per Typ (oder assert) zu garantieren
  • Das Hochziehen von Vorbedingungen (Push up) wirkt sich auf den gesamten Code aus und reduziert insgesamt die Anzahl unnötiger Bedingungsprüfungen

Kontrollfluss und Bündelung von Bedingungen

  • Kontrollfluss und if-Anweisungen sind wesentliche Ursachen für Komplexität und Bugs im Code
  • Es ist nützlich, Bedingungen auf höhere Ebenen wie die Aufrufstelle zu konzentrieren, die Verzweigungslogik in einer Funktion zu bündeln und die eigentliche Arbeit geradlinigen Subroutinen zu überlassen
  • Wenn Verzweigungen und Kontrollfluss an einer Stelle zusammenkommen, lassen sich duplizierte Verzweigungen und unnötige Bedingungen leicht erkennen

Beispiele:

  • Wenn in Funktion f verschachtelte if-Anweisungen stehen, ist toter Code (Dead Branch) leichter zu erkennen
  • Wenn Verzweigungen über mehrere Funktionen (g, h) verteilt sind, wird das schwieriger

Refactoring zum Auflösen von Enums (Dissolving enum Refactor)

  • Wenn der Code dieselbe Verzweigungslogik etwa in einem Enum verkapselt, kann man die Bedingung nach oben ziehen und so Verzweigung und Arbeit klarer voneinander trennen
  • Mit diesem Ansatz lässt sich verhindern, dass dieselbe Bedingung mehrfach im Code wiederholt auftaucht

Beispiel:

  • Eine Situation, in der dieselbe Verzweigungsbedingung jeweils in den Funktionen f, g und im Enum E ausgedrückt wird,
  • lässt sich durch eine einzige übergeordnete Verzweigung für den gesamten Code vereinfachen

Datenorientiertes Denken (Data Oriented Thinking) und Batch-Verarbeitung

  • Die meisten Programme arbeiten mit vielen Objekten (Entitäten). Auf dem kritischen Pfad (Hot Path) wird die Performance durch die Verarbeitung vieler Objekte bestimmt
  • Es ist sinnvoll, das Konzept von Batches einzuführen und Operationen auf Mengen von Objekten zum Standard zu machen, während Operationen auf Einzelobjekten als Sonderfall behandelt werden

Beispiele:

  • Mit einer Funktion wie frobnicate_batch(walruses) dient Batch-Verarbeitung als Standard,

  • und einzelne Objekte können als Sonderfall über eine for-Schleife verarbeitet werden

  • Dieser Ansatz spielt bei der Performance-Optimierung eine wichtige Rolle, weil er bei großen Arbeitsmengen Startkosten senkt und mehr Flexibilität bei der Reihenfolge ermöglicht

  • Auch der Einsatz von SIMD-Operationen (etwa struct-of-array) ist möglich, sodass zunächst nur bestimmte Felder gesammelt verarbeitet und danach die Gesamtarbeit ausgeführt werden kann

Praktische Beispiele und empfohlene Muster

  • Wie bei FFT-basierter Polynom-Multiplikation kann man die Performance maximieren, indem Berechnungen an mehreren Punkten gleichzeitig ausgeführt werden
  • Die Regel, Bedingungen nach oben und Schleifen nach unten zu verlagern, lässt sich parallel anwenden

Beispiele:

  • Statt innerhalb einer Schleife immer wieder dieselbe Bedingung zu prüfen, kann man die Bedingung aus der Schleife herausziehen; das reduziert Verzweigungen im Loop und erleichtert Optimierung und Vektorisierung
  • Dieser Ansatz gewährleistet auch in Datenebenen großer Systeme wie dem Design von TigerBeetle hohe Effizienz

Fazit

  • Durch die Kombination des Musters, if-Anweisungen (Bedingungen) nach oben (Aufrufstelle, Steuerungsebene) und for-Schleifen (Wiederholungen) nach unten (Ausführungsebene, Datenverarbeitung) zu verlagern, lassen sich Lesbarkeit, Effizienz und Performance des Codes verbessern
  • Das Denken aus der Perspektive abstrakter Vektorräume, also Operationen auf Mengenebene, ist ein besseres Werkzeug zur Problemlösung als wiederholte Verzweigungsverarbeitung
  • Kurz gesagt: if nach oben, for nach unten!

1 Kommentare

 
GN⁺ 2025-05-18
Hacker-News-Kommentare
  • Mein etwas eigenwilliges mentales Modell ist, dass verschiedene Zustände oder Programmflüsse eine Baumstruktur bilden. Bedingungen beschneiden die Äste dieses Baums. Ich möchte möglichst früh beschneiden, damit sich die Zahl der Äste reduziert, die später noch verarbeitet werden müssen. Ich will vermeiden, jeden einzelnen Ast erst auszuwerten und wegzuräumen, nur um am Ende doch den ganzen Ast auf einmal abzuschneiden. Aus einer etwas ungewöhnlichen Perspektive sind Bedingungen ein „Prozess des Entdeckens unnötiger Arbeit“, und Schleifen sind die „eigentliche Arbeit“. Im Idealfall konzentriert sich eine Funktion entweder darauf, den Programmbaum zu durchsuchen, oder darauf, die eigentliche Arbeit zu erledigen
    • Ich möchte mein benachbartes Modell vorstellen. Klassen sind Nomen, Funktionen sind Verben
    • Mein mentales Modell passe ich an die Welt an, in der der konkrete Code existiert, den ich schreibe. Das hängt von Domäneneigenschaften, bestehenden Code-Mustern, den Stufen einer Datenpipeline, Leistungsprofilen usw. ab. Anfangs wollte ich solche Regeln oder Heuristiken aufstellen, aber nachdem ich viel Code geschrieben habe, wurde mir klar, dass solche abstrakten Regeln in der Praxis kaum viel bedeuten. Oft legt man willkürlich nur einen Funktionsnamen oder einen einzelnen Buchstaben fest, und nur innerhalb dieses „inselartigen Codes“ gilt die Regel; in echten Codebasen gibt es meist einen Grund, warum diese Funktionen nicht absichtlich zusammengelegt wurden. Als Beispiel wird von „Duplikaten und toten Bedingungen“ gesprochen, aber das ist eine Regel, die nur unter der bequemen Annahme gilt, dass die betreffende Funktion nur an genau einer Stelle aufgerufen wird. In Wirklichkeit sind Dinge oft aus anderen Gründen getrennt
    • Ich halte das für ein ziemlich gutes Modell
  • Eine allgemeinere Regel ist, Bedingungen möglichst nah an die Eingabequelle zu setzen. Entscheidend ist, Eintrittspunkte in das Programm früh zu identifizieren, also auch Daten, die aus anderen Services kommen, und vor dem Erreichen der Kernlogik möglichst viele Garantien herzustellen, insbesondere bevor ressourcenintensive Teile erreicht werden. Es ist auch sehr gut, das explizit in Typen auszudrücken
    • Macht es das beim Verständnis der Kernlogik nicht schwerer zu erkennen, von welchen Annahmen sie ausgeht? Muss man dann nicht die gesamte Aufrufkette im Code nachverfolgen?
  • Für den Rat „Wenn eine if-Bedingung innerhalb einer Funktion steht, prüfe, ob du sie zum Aufrufer verschieben kannst“ gibt es zu viele Gegenbeispiele. Wenn eine Funktion an 37 Stellen aufgerufen wird, soll man dann an jedem Aufruf dieselbe if-Anweisung wiederholen? Kann man so etwas etwa für Funktionen wie getaddrinfo oder EnterCriticalSection verlangen? Ich denke, so eine Umformung kommt nur dann infrage, wenn etwas vielleicht an nur zwei Stellen aufgerufen wird und die Entscheidung außerhalb des Zuständigkeitsbereichs der Funktion liegt. Ein Ansatz ist, eine Funktion, die nur Bedingungen ausführt, die eigentliche Arbeit an eine Helper-Funktion delegieren zu lassen. Und wenn man eine Bedingung aus einer Schleife herausziehen muss, kann man den Aufrufer direkt den niedrigeren Condition-Helper verwenden lassen. Der Kern dieser Überlegung ist aber „Optimierung“. Optimierung steht oft im Konflikt mit besserem Programmdesign. Es kann das bessere Design sein, wenn der Aufrufer die Bedingung gar nicht kennen muss. Dieses Dilemma taucht in OOP häufig auf. Die Entscheidung, die durch „if“ repräsentiert wird, erfolgt in Wirklichkeit oft über Method Dispatch. Auch dieses Dispatching aus der Schleife herauszuziehen, kann mit Designprinzipien kollidieren. Ein Beispiel wäre, beim Zeichnen eines Bildes auf ein Canvas statt wiederholt putpixel aufzurufen lieber eine Methode wie blit zu verwenden
    • Wenn eine Funktion an 37 Stellen aufgerufen wird, braucht der Code wohl tatsächlich Refactoring. Um die Frage zu beantworten: Es kommt darauf an. DRY fühlt sich zwar wie die richtige Antwort an, aber man müsste den konkreten Beispielcode sehen, um zu entscheiden. Bei einer Library liegt man an einer Ownership-Grenze, daher muss jede Seite ihre eigenen Daten und Verantwortlichkeiten verwalten. Bei einer Funktion wie EnterCriticalSection ist es richtig, am Eintrittspunkt starke Validierung durchzuführen, einschließlich Bedingungen. In Anwendungscode kann man das if aber durchaus zum Aufrufer verschieben. In Libraries oder Kerncode ist es passend, den Kontrollfluss an den Rand zu verlagern. Innerhalb der eigenen Domäne ist es gut, den Kontrollfluss an den Rändern zu halten. Aber solche Regeln sind immer nur idiomatisch; jemand, der vernünftig urteilen kann, muss sie dem Kontext entsprechend anwenden
  • Das Refactoring-Beispiel „dissolving enum“ ist im Grunde ein Polymorphismus-Muster. Eine match-Anweisung kann durch polymorphe Methodenaufrufe ersetzt werden. Ziel dieses Ansatzes ist es, den Zeitpunkt der anfänglichen Fallunterscheidung vom Zeitpunkt der eigentlichen Ausführung des Verhaltens zu trennen. Die Fallunterscheidung steckt im Objekt, hier also im Enum-Wert, oder in der Closure, daher muss man sie nicht bei jedem Aufruf wiederholen. Wenn sich die Fallunterscheidung ändert, muss man nur den Verzweigungspunkt anpassen; die Stellen, an denen das tatsächliche Verhalten stattfindet, müssen nicht verändert werden. Der Nachteil ist der Trade-off zwischen dem Komfort, die Verhaltensverzweigungen pro Fall direkt prüfen zu können, und der Tatsache, dass auf Codeebene eine Abhängigkeit von der Fallliste entsteht
  • Manchmal mag ich es, Bedingungen innerhalb einer Funktion zu haben. So kann man absichtlich verhindern, dass der Aufrufer bei der Aufrufreihenfolge Fehler macht. Wenn man zum Beispiel Idempotenz garantieren muss, prüft man erst, ob ein Zustand bereits verarbeitet wurde, und führt die Aktion andernfalls aus. Wenn man diese Bedingung an die Aufrufstelle verlagert, müssen alle Aufrufer das Verfahren korrekt einhalten, damit Idempotenz gewährleistet bleibt, und die Abstraktion kann diese Garantie nicht mehr bieten. Ich frage mich, wie sich diese Philosophie in solchen Situationen anwenden lässt. Ein anderes Beispiel wäre, wenn man innerhalb einer Datenbanktransaktion eine Reihe von Prüfungen durchführen und danach die Arbeit erledigen will; dann stellt sich die Frage, wo diese Prüfungen hingehören
    • Ich glaube, du hast dir die Frage schon selbst beantwortet. Wenn du die Bedingung an die Aufrufstelle verschiebst, ist die Funktion nicht mehr idempotent und kann das natürlich auch nicht mehr garantieren. Wenn du in jede Funktion Zustandsverwaltungslogik packst, um Idempotenz zu gewährleisten, schreibst du möglicherweise ziemlich seltsamen Code und stopfst zu viel Business-Logik in eine einzelne Funktion. Idempotenter Code lässt sich grob in zwei Kategorien einteilen. Erstens Code, bei dem das Datenmodell oder die Operation selbst idempotent ist. Dann muss man sich um die Verarbeitungsreihenfolge gar nicht groß kümmern. Zweitens die Schaffung idempotenter Abstraktionen für komplexere Geschäftsoperationen. Dann braucht man komplexe Logik wie Rollback oder eine Abstraktion für atomisches Anwenden, und so etwas lässt sich nicht einfach in eine einzelne Funktion packen
    • Man könnte auch eine interne Funktion ohne Prüfungen erstellen und das über eine Wrapper-Funktion steuern, die außen die Prüfungen macht und dann die interne Funktion aufruft
  • Ein Code-Komplexitäts-Scanner ist letztlich ein Werkzeug, das if-Anweisungen eher nach unten drängt. In diesem Artikel wird dagegen empfohlen, if-Anweisungen nach oben zu ziehen, also in höher gelegene Funktionen. So kann man komplexe Verzweigungslogik zentral in einer einzelnen Funktion behandeln und die eigentliche konkrete Arbeit an Subroutinen delegieren
    • Die Lösung ist, „Entscheidung“ und „Ausführung“ zu trennen. Eine Idee, die ich von Bertrand Meyer gelernt habe. Zum Beispiel if (weShouldDoThis()) { doThis(); } in dieser Art; wenn man jede Prüfung in eine eigene Funktion auslagert, werden Tests und Komplexitätsmanagement einfacher
    • Berichte von Code-Scannern sollte man mit ernsthaftem Misstrauen betrachten. sonarqube und Ähnliches melden wahllos auch „code smells“, die gar keine echten Bugs sind. Wenn man dann anfängt, sogar „problemfreien Code“ umzubauen, steigt nur das Risiko, dabei neue Bugs einzuführen, und man verschwendet Zeit, die man für wirklich wichtige Probleme bräuchte
    • Solche Optimierungen sind meist nur „lokale Optima“. Sobald also neue Anforderungen oder Ausnahmefälle auftauchen, braucht man die Verzweigungslogik plötzlich auch außerhalb der Schleife. Wenn dann sowohl innerhalb als auch außerhalb der Schleife Verzweigungen vermischt sind, wird es schwer verständlich. Wenn man sicher ist, dass eine Bedingung nur innerhalb der Schleife gebraucht wird, sollte man sie dort lassen; andernfalls halte ich es für besser, lieber etwas länger im Voraus zu designen und den Code notfalls wortreicher zu machen, solange er leichter verständlich bleibt. Ich hatte diese Erfahrung beim Einsatz von Haskell. Wenn man Logik in ihrer kompaktesten und optimiertesten Form als lokales Optimum verfolgt, bleibt bei auch nur leicht geänderten Anforderungen nicht mehr die Absicht des Designs sichtbar, sondern nur noch die Logik selbst, und schon kleine Änderungen führen zu starkem Code-Unrolling
    • Code-Komplexitäts-Scanner waren für mich schon immer ein Ärgernis. Sie beschweren sich sogar über große Funktionen, die sich eigentlich gut lesen lassen. Wenn man Logik an einer Stelle hält, ist der Gesamtkontext oft leichter zu verstehen; beim Aufteilen in Funktionen muss man wirklich aufpassen, den eigentlichen Kontext nicht zu verlieren
    • Gestern gab es in einem Thread über LLMs die Frage nach „unzuverlässigen Tools, die Entwickler trotzdem alle akzeptieren“. Jetzt kenne ich die Antwort …
  • Je nach Fall sollte man vielleicht sogar genau umgekehrt vorgehen und SIMD einsetzen. Bei AVX-512 und Ähnlichem kann man Code mit Verzweigungen in branchlosen Code überführen, indem man Vektor-Maskenregister verwendet. Zum Beispiel ist ein if innerhalb einer for-Schleife leichter zu handhaben und speichereffizienter als ein if außerhalb der for-Schleife. Ein konkretes Beispiel: Wenn bei ungeraden Werten +1 und bei geraden -2 gerechnet werden soll, müsste man ursprünglich in jeder Schleifeniteration verzweigen, aber mit SIMD kann man alle 16 int gleichzeitig verarbeiten, ganz ohne Branch. Wenn der Compiler korrekt vektorisiert, kann er den ursprünglichen Code in eine branchlose optimierte Version umwandeln
    • Ich glaube, der gezeigte before-Code passt nicht ganz zum eigentlichen Punkt des Artikels, und eher die optimierte SIMD-Version entspricht seiner Aussage. Im Beispiel ist das if innerhalb der for-Schleife eine datenabhängige Verzweigung und lässt sich nicht einfach nach oben ziehen. Wenn der Algorithmus stattdessen eine Struktur wie if (length % 2 == 1) { ... } else { ... } mit einer Bedingung außerhalb der Schleife hätte, dann wäre es natürlich richtig, diese Bedingung vor die for-Schleife zu ziehen. In der SIMD-Version verschwindet das if ganz, und genau so ein Code-Muster ist ideal; vermutlich würde auch der Autor des Artikels das mögen
    • Mir kam auch sofort Code in den Sinn, der je nach Elementwert in einer for-Schleife verzweigt. Weiß jemand, wie schwierig es für Compiler ist, solchen Code automatisch zu vektorisieren? Mich interessiert, wo da die Grenze liegt
  • Persönlich halte ich das nicht für eine „gute“ Regel. Es gibt Fälle, in denen sie anwendbar ist, aber das hängt so stark vom Kontext ab, dass man kaum eine harte Schlussfolgerung ziehen kann. Es fühlt sich wie Regeln zur englischen Rechtschreibung an: so viele Ausnahmen, dass es praktisch schwer ist, das als Regel zu behandeln
  • Link zur damaligen Diskussion (2023) (662 Punkte, 295 Kommentare) https://news.ycombinator.com/item?id=38282950
  • Ich bin in Sandi Metz’ 99 Bottles of OOP auf etwas Ähnliches gestoßen. Es ist nicht mein Stil, aber ich stimme zu, dass es nützlich sein kann, Verzweigungslogik ganz nach oben im Call Stack zu ziehen. Besonders deutlich habe ich das in Codebasen gemerkt, in denen Flags durch mehrere Layer weitergereicht wurden. https://sandimetz.com/99bottles
    • Ich musste sofort an den Text desselben Autors denken: „The Wrong Abstraction“. Verzweigungen innerhalb einer for-Schleife erzeugen die Abstraktion „for ist die Regel, Verzweigung ist das Verhalten“. Wenn dann neue Anforderungen auftauchen, bricht diese Abstraktion auseinander, und man stopft gewaltsam Parameter hinein oder fügt immer mehr Ausnahmebehandlung hinzu, wodurch der Code schwer verständlich wird. Hätte man von Anfang an ohne diese Abstraktion geschrieben, wäre das Ergebnis wahrscheinlich klarer und leichter wartbar gewesen. https://sandimetz.com/blog/2016/1/20/the-wrong-abstraction