post

Programming Styles als Architekturvergleich

Dieselbe kleine Aufgabe in Dutzenden Programmierstilen gelöst ist der kompakteste Architekturvergleich, den ich kenne – weil jeder Stil eine bewusst gewählte Constraint-Menge ist.

≈ 8 Min. Lesezeit

Diesen Beitrag anhören (11 Min.)

MP3 herunterladen

Ein Programmierstil ist keine Geschmacksfrage. Zumindest nicht in der Bedeutung, die Cristina Videira Lopes dem Wort in ihrem Buch „Exercises in Programming Style" (2014) gibt: Ein Stil ist dort eine Menge von Regeln, die festlegen, welche Mittel erlaubt und welche verboten sind. Keine globalen Variablen. Nur Funktionen, die ihre Eingabe unverändert lassen. Kein direkter Aufruf zwischen Komponenten. Wer in einem Stil schreibt, unterwirft sich einer Constraint-Menge – und genau diese Constraints erzeugen die Eigenschaften des Programms, nicht die persönliche Vorliebe für Einrückung oder Namensgebung.

Das Buch treibt diese Idee mit einem Kunstgriff auf die Spitze, den Lopes von Raymond Queneau geliehen hat. Queneau erzählt in „Exercices de style" (1947) dieselbe belanglose Alltagsszene auf 99 verschiedene Arten. Lopes macht dasselbe mit einem Programm: Eine einzige, bewusst banale Aufgabe wird in der ersten Auflage in 33 Stilen gelöst. Die Aufgabe ist Wortfrequenzzählung – Term Frequency:

  • eine Textdatei lesen (im Repo: „Pride and Prejudice")
  • den Text normalisieren, also Kleinschreibung und Satzzeichen entfernen
  • Stopwörter wie „the" und „of" herausfiltern
  • die Häufigkeit jedes Wortes zählen
  • die 25 häufigsten Wörter ausgeben

Jede Lösung ist ein kurzes Python-Skript, alle liefern dieselbe Ausgabe, alle laufen mit demselben Aufruf. Ich habe das begleitende Repository Anfang Mai geforkt und mich Stil für Stil durchgearbeitet – nicht gelesen, sondern getippt, verändert, kaputt gemacht und repariert. Dabei ist mir etwas aufgefallen, das über das Buch hinausgeht: Das ist kein Kuriositätenkabinett. Es ist der kompakteste Architekturvergleich, der mir je begegnet ist. Weil die Aufgabe trivial und konstant ist, bleibt als einzige Variable die Struktur. Man sieht Architekturentscheidungen in Reinform, auf jeweils einer Bildschirmseite.

Ein paar der Stile will ich herausgreifen und gegeneinanderstellen.

Good Old Times: die Abwesenheit von Struktur

Der erste Stil im Buch simuliert die Bedingungen früher Rechner: kaum Speicher, keine benannten Abstraktionen, keine Funktionen – das Programm ist eine einzige Folge von Anweisungen, die sich Zwischenstände auf einer simulierten „Festplatte" merken muss. Das Ergebnis ist mühsam zu lesen und noch mühsamer zu ändern. Der verwandte monolithische Stil lockert die Speichergrenze, verbietet aber weiterhin jede Abstraktion: eine lange Prozedur, geschachtelte Schleifen, Flags.

Interessant ist daran nicht die Nostalgie, sondern die Ehrlichkeit. Der Monolith ist der Nullpunkt: So sieht Code aus, wenn keine Constraint ihn formt. Und er ist keineswegs ausgestorben – ich habe 2014 in einem Projekt ein gewachsenes Import-Skript übernommen, das exakt diesem Stil folgte, nur eben mit 1800 statt 80 Zeilen. Es funktionierte. Es war nur unmöglich zu testen, weil es keine Stelle gab, an der man es hätte anfassen können, ohne alles anzufassen.

Pipeline: Datenfluss statt Ablauf

Der Pipeline-Stil erlaubt ausschließlich Funktionen, die ihre Eingabe entgegennehmen und ein Ergebnis zurückgeben. Kein geteilter Zustand, keine Seiteneffekte zwischen den Schritten. Mein Nachbau sieht so aus (Python 2.7, wie im Buch):

import re
import operator

def read_text(path):
    with open(path) as f:
        return f.read()

def normalize(text):
    return re.sub('[^a-z]', ' ', text.lower())

def tokenize(text):
    return text.split()

def remove_stop_words(words):
    with open('stop_words.txt') as f:
        stop_words = set(f.read().split(','))
    return [w for w in words if w not in stop_words and len(w) > 1]

def count_frequencies(words):
    counts = {}
    for w in words:
        counts[w] = counts.get(w, 0) + 1
    return counts

def top_25(counts):
    ordered = sorted(counts.items(), key=operator.itemgetter(1), reverse=True)
    return ordered[:25]

for word, count in top_25(count_frequencies(remove_stop_words(
        tokenize(normalize(read_text('pride-and-prejudice.txt')))))):
    print word, '-', count

Die letzte Zeile liest sich wie eine mathematische Formel: Das Programm ist die Komposition seiner Schritte. Jede Funktion ist für sich testbar, jede lässt sich austauschen, solange die Datenform stimmt. Das ist die Architektur jeder Batch- und ETL-Strecke und jeder Unix-Pipe – nur dass dort die Funktionen Prozesse heißen und die Rückgabewerte Dateien oder Streams sind. Die Constraint „kein geteilter Zustand" kauft Testbarkeit und Parallelisierbarkeit und bezahlt mit dem Verbot, sich zwischendurch etwas zu merken.

Things: Zustand hinter Türen

Der Stil „Things" ist die objektorientierte Antwort: Das Problem wird in Dinge zerlegt – ein DataStorageManager, ein StopWordManager, ein WordFrequencyManager – und jedes Ding kapselt seinen Zustand. Von außen sind nur Methoden sichtbar; niemand greift in die Daten eines anderen. Die Constraint lautet: Zustand existiert, aber nur hinter einer Tür, und die Tür hat einen Namen.

Auf 60 Zeilen wirkt das fast übertrieben feierlich. Aber die Analogie auf Systemebene ist offensichtlich: Es ist die Grundidee modularer Systeme und sauberer Serviceschnitte. Die Frage „wem gehört dieser Zustand, und wer darf ihn ändern?" ist in jedem Architektur-Review, das ich erlebt habe, die ergiebigste Frage überhaupt – und dieser Stil zwingt einen, sie für jedes Datum zu beantworten.

Hollywood: Don't call us, we'll call you

Ab hier dreht sich die Kontrollrichtung um. Im Hollywood-Stil ruft nicht mehr das Programm seine Bausteine – ein Rahmen ruft zurück, was sich vorher registriert hat:

class WordFrequencyFramework:
    def __init__(self):
        self._load_handlers = []
        self._work_handlers = []
        self._end_handlers = []

    def register_for_load(self, handler):
        self._load_handlers.append(handler)

    def register_for_work(self, handler):
        self._work_handlers.append(handler)

    def register_for_end(self, handler):
        self._end_handlers.append(handler)

    def run(self, path):
        for handler in self._load_handlers:
            handler(path)
        for handler in self._work_handlers:
            handler()
        for handler in self._end_handlers:
            handler()

Die fachlichen Teile – Einlesen, Filtern, Zählen – melden sich beim Framework an und warten. Der Ablauf gehört dem Rahmen, nicht mehr der Anwendung. Das ist Inversion of Control in ihrer nacktesten Form, und es ist das Bauprinzip praktisch jedes Web-Frameworks und jeder Plugin-Architektur: Man schreibt keine Hauptschleife mehr, man füllt Lücken, die ein anderer vorgesehen hat.

Wer viel JavaScript schreibt, kennt dieses Gefühl aus jedem Node-Programm – dort ist der Callback nicht Stilübung, sondern Alltag, mit allen Folgen für die Lesbarkeit des Kontrollflusses. Ich habe dazu schon einmal aufgeschrieben, wie sich Kontrollfluss vor async/await bändigen lässt. Die Stilübung macht sichtbar, was man dabei eintauscht: Flexibilität für den Rahmen, Nachvollziehbarkeit für den Leser. Wer wissen will, wann count_frequencies läuft, muss den Rahmen verstehen, nicht die Fachlogik.

Bulletin Board: das schwarze Brett

Der Bulletin-Board-Stil entkoppelt noch eine Stufe weiter. Komponenten rufen einander gar nicht mehr, auch nicht über Registrierung beim Rahmen – sie veröffentlichen Ereignisse an ein schwarzes Brett, und wer sich für einen Ereignistyp eingetragen hat, wird benachrichtigt:

class EventManager:
    def __init__(self):
        self._subscriptions = {}

    def subscribe(self, event_type, handler):
        self._subscriptions.setdefault(event_type, []).append(handler)

    def publish(self, event):
        event_type = event[0]
        for handler in self._subscriptions.get(event_type, []):
            handler(event)

Der Zähler abonniert word, der Filter abonniert valid_word, die Ausgabe abonniert print – und niemand kennt niemanden. Das ist Publish/Subscribe auf 15 Zeilen, das Muster hinter jedem Message-Broker und jeder ereignisgetriebenen Integration. Die Constraint „keine direkten Aufrufe" kauft maximale Entkopplung: Man kann Teilnehmer hinzufügen, ohne bestehende zu ändern. Bezahlt wird mit dem Verlust des sichtbaren Ablaufs. Beim Durcharbeiten habe ich für diesen Stil zum ersten Mal den Debugger gebraucht – bei 80 Zeilen. Genau dieselbe Erfahrung macht man später im Großen, wenn man in einem ereignisgetriebenen System einer Nachricht hinterherläuft.

The One: der Wert im Gefäß

Der Stil mit dem schönsten Namen ist eine Monade light: Ein Wert wird in ein Gefäß gelegt, und das Gefäß weiß, wie es Funktionen der Reihe nach auf seinen Inhalt anwendet.

class TheOne:
    def __init__(self, value):
        self._value = value

    def bind(self, func):
        self._value = func(self._value)
        return self

TheOne('pride-and-prejudice.txt') \
    .bind(read_text) \
    .bind(normalize) \
    .bind(tokenize) \
    .bind(remove_stop_words) \
    .bind(count_frequencies) \
    .bind(top_25) \
    .bind(print_all)

Inhaltlich ist das dieselbe Kette wie im Pipeline-Stil – aber die Verkettung selbst ist jetzt ein Baustein, den man erweitern kann: um Protokollierung bei jedem Schritt, um Fehlerbehandlung, um verzögerte Ausführung. Wer in ES5-JavaScript schon mit Promise-Bibliotheken gearbeitet hat, erkennt das Muster sofort wieder: then ist ein bind, das nebenbei Asynchronität wegkapselt. Die Constraint „alles fließt durch ein Gefäß" kauft einen Ort, an dem Querschnittsbelange wohnen dürfen.

Sechs Stile, sechs Architekturen

Nebeneinandergelegt ergibt sich aus den Übungen eine Zuordnung, die ich seitdem im Kopf behalte:

flowchart LR
    A[Monolith<br/>keine Abstraktion erlaubt] --> A2[gewachsene Skripte,<br/>Legacy-Prozeduren]
    B[Pipeline<br/>nur reine Funktionen] --> B2[Batch, ETL,<br/>Unix-Pipes]
    C[Things<br/>Zustand nur hinter Methoden] --> C2[Module,<br/>Serviceschnitte]
    D[Hollywood<br/>Rahmen ruft zurück] --> D2[Frameworks,<br/>Plugin-Systeme]
    E[Bulletin Board<br/>nur Ereignisse, keine Aufrufe] --> E2[Message-Broker,<br/>Integration]
    F[The One<br/>alles durch ein Gefäß] --> F2[Fluent APIs,<br/>Promise-Ketten]

Jede dieser Architekturen im Großen ist ein Programmierstil im Kleinen – dieselbe Constraint-Menge, nur auf Prozess- statt Funktionsebene. Und in beiden Größenordnungen gilt: Die Eigenschaften, die man sich wünscht, kommen aus dem, was man sich verbietet. Testbarkeit kommt aus dem Verbot geteilten Zustands. Erweiterbarkeit kommt aus dem Verbot direkter Aufrufe. Nachvollziehbarkeit kommt aus dem Verbot versteckter Kontrollflüsse. Nichts davon gibt es geschenkt; jede Constraint hat ihren Preis, und die Übungen machen den Preis auf einer Bildschirmseite fühlbar, statt ihn erst nach zwei Jahren Betrieb zu präsentieren.

Der unbewusste Stilmix

Die für mich wichtigste Erkenntnis kam allerdings nicht aus einem der Stile, sondern aus dem Kontrast zu meinem eigenen Alltagscode. Der ist – wie vermutlich der meiste Code da draußen – ein Stilmix: ein bisschen objektorientiert, weil die Sprache es nahelegt; ein paar reine Funktionen, wo es sich anbot; ein globaler Cache, weil es schnell gehen musste; Callbacks, weil das Framework es verlangt. Nichts davon ist für sich falsch. Das Problem ist, dass der Mix unbewusst entsteht. Niemand hat entschieden, dass dieses Modul dem Pipeline-Gedanken folgt und jenes dem schwarzen Brett – es ist einfach passiert, Commit für Commit.

Genau da liegt für mich der Unterschied zwischen Code, der Struktur hat, und Code, der nur strukturiert aussieht. Architektur ist die Kunst, Constraints bewusst zu wählen – und sie dann auch dort durchzuhalten, wo sie gerade unbequem sind. Eine Constraint, die man aufgibt, sobald sie zwickt, war nie eine; sie war eine Gewohnheit. Seit ich die Übungen durchgearbeitet habe, stelle ich in Code-Reviews gelegentlich eine neue Frage: In welchem Stil sind wir hier eigentlich? Nicht als Fangfrage, sondern weil die Antwort sofort zeigt, ob eine Regel verletzt wurde, die sich das Team gegeben hat – oder ob es die Regel nie gab und wir sie jetzt nachholen sollten.

Hilfreich ist der Blick auch in der Gegenrichtung. Wenn ein Modul schwer testbar ist, lautet die Frage nicht „welches Test-Werkzeug fehlt?", sondern „welche Constraint fehlt?". Meist ist es dieselbe, die der Pipeline-Stil erzwingt und der gewachsene Code ignoriert.

Was ich daraus mitnehme

Wortfrequenzen zählen ist die langweiligste Aufgabe der Welt, und genau das macht sie zum idealen Prüfstand: Wenn die Aufgabe konstant bleibt, ist alles, was sich zwischen zwei Lösungen unterscheidet, Struktur – und damit vergleichbar. Man kann Architekturstile diskutieren, indem man Systeme mit jahrelanger Geschichte seziert. Oder man vergleicht 33 Skripte à 80 Zeilen, die alle dasselbe tun, und sieht dieselben Kräfte am Werk, nur ohne Nebel.

Ein paar Dinge nehme ich konkret mit:

  • Stile nicht nach Sympathie beurteilen, sondern nach der Frage, welche Eigenschaft ihre Constraints kaufen und was sie kosten.
  • Beim Lesen fremden Codes zuerst den Stil identifizieren – danach erklärt sich vieles, was vorher wie Willkür aussah.
  • Den eigenen Stilmix gelegentlich explizit machen: Welche Regeln gelten in diesem Modul, und stehen sie irgendwo?

Wer das Buch nicht zur Hand hat: Das Repository allein trägt schon weit. Forken, einen Stil pro Abend nachbauen, dabei die Constraints absichtlich verletzen und beobachten, was kaputtgeht. Ich habe aus diesen Abenden mehr über Architektur gelernt als aus manchem dicken Entwurfsmuster-Wälzer – gerade weil kein einziges UML-Diagramm vorkam, sondern nur die Frage: Was verbietest du dir, und was bekommst du dafür?

Weiterführende Quellen

Kommentare