Lernreise 2 — Refactoring ohne Zeitbomben

Was du nach dieser Reise kannst

Du erkennst eine Schnittstelle und ihre vollständige Vertragsoberfläche — nicht nur den Methodennamen. Du führst Refactorings durch, ohne tote Aufrufstellen zurückzulassen. Und du baust Robustheit an den Grenzen deines Systems ein, nicht im Inneren.

Konkret nach Tier 3: Du hast eine eigene Refactor-Sweep-Checkliste, du verstehst, warum Typannotationen Verträge sind, und du setzt Boundary-Coercion bewusst dort ein, wo eine fremde Eingabe deinem System Schaden zufügen könnte.

Anker — drei Bugs aus einer einzigen Session

In einer iMenu-Refactoring-Session wurden drei Schnittstellen geändert. Jede ließ tote Aufrufstellen zurück. Drei verschiedene Ausprägungen einer Disziplin.

Bug 1 — SessionBridge bekam einen neuen Konstruktor

PYTHON
# Vorher
class SessionBridge:
    def __init__(self, config: BridgeConfig): ...

# Nachher (vereinfacht)
class SessionBridge:
    def __init__(self): ...

In cli.py blieben fünf Click-Subkommandos zurück, die noch SessionBridge(config) aufriefen. Beim ersten Aufruf von i menu jump schlug das CLI mit TypeError: __init__() takes 1 positional argument but 2 were given fehl.

Bug 2 — Project-Datenklasse umbenannte ein Attribut

PYTHON
# Vorher
@dataclass
class Project:
    type: str   # "library", "service", ...

# Nachher
@dataclass
class Project:
    project_type: str

Die Search-Funktion griff weiter auf project.type zu — AttributeError, sobald jemand suchte.

Bug 3 — description durfte plötzlich ein Dict sein

Eine metadata.yaml-Datei änderte sich:

YAML
# Vorher
description: "A small CLI tool for X"

# Nachher
description:
    en: "A small CLI tool for X"
    de: "Ein kleines CLI-Werkzeug für X"

Die Datenklasse versprach description: Optional[str]. Die echte Eingabe war jetzt manchmal dict. Downstream rief eine Filter-Funktion description.lower() auf — AttributeError.

Drei verschiedene Bugs, eine Disziplin: Verträge ändern bedeutet, jede Konsumstelle mitzuziehen — und für unkontrollierbare Eingaben Grenzen zu setzen.


Tier 1 · Erkennen — Was ist eine Schnittstelle?

Eine Schnittstelle ist mehr als „der Methodenname". Sie ist die ganze Vertragsoberfläche, die deine Aufrufer sehen:

Element Beispiel
Konstruktor-Signatur SessionBridge(config: BridgeConfig)
Methoden-Signaturen def jump(name: str) -> bool
Rückgabe-Typ und -Form Optional[str], list[Project]
Öffentliche Attribute project.project_type, bridge.session_id
Typannotationen sind ein Vertrag, keine Höflichkeit
Ausnahme-Verträge „wirft KeyError, wenn …"

Wenn sich auch nur eines davon ändert, wird jede Aufrufstelle zur Hausaufgabe. Die Sprache verlangt vom Aufrufer, dass er sich anpasst — du musst die Aufrufer finden, sonst tut die Laufzeit es für dich, später, vor einem Anwender.

Was eine „Aufrufstelle" alles sein kann

  • Direkter Aufruf: bridge.jump(name)
  • Indirekter Aufruf via Callback: app.set_interval(2.0, self._render)_render ist hier eine Aufrufstelle, auch ohne ()
  • Test-Aufruf: test_jump() ruft bridge.jump()
  • Doku-Aufruf: README-Beispiel, das veraltet — Doku ist auch Aufrufer
  • Skript-Aufruf: ein tools/-Skript, das das Modul nutzt

Faustregel: Wenn ich es umbenennen kann, ohne dass irgendwer es bemerkt, war es nie öffentlich.


Tier 2 · Üben — Die Refactor-Sweep-Checkliste

Wir refactorieren bewusst, mit Disziplin. Diese Schritte sind in dieser Reihenfolge wichtig.

Schritt 0 — Vor dem Refactor: was ändert sich?

Schreib auf, bevor du eine Zeile änderst, was sich an der Schnittstelle ändert:

TXT
GEÄNDERT WIRD:
- Konstruktor:     SessionBridge.__init__: (config) → ()
- Methodenname:    rename_session() → rename()
- Rückgabe-Typ:    list[str] → list[Session]
- Neue Annotation: name: str → name: str | None
- Attribut:        Project.type → Project.project_type

Wenn du das nicht aufschreiben kannst, weißt du noch nicht, was du tust — und dein zukünftiges Ich hat keine Chance, die Aufrufstellen vollständig zu finden.

Schritt 1 — Den Sweep durchführen

Pro Element der Änderungsliste:

BASH
# Methoden-Umbenennung — \b stellt sicher, dass _render nicht in _rendering trifft
grep -rn "\._render\b" --include="*.py" .

# Konstruktor-Signatur einer Klasse
grep -rn "SessionBridge(" --include="*.py" .

# Attribut-Zugriff (Achtung: `.type` ist zu generisch — qualifiziere mit Kontext)
grep -rn "project\.type\b\|self\.type\b" --include="*.py" .

Drei Regeln für den Sweep:

  • Word-Boundaries (\b) sind Pflicht. Sonst trifft \._render auch \._rendering.
  • Vergiss Tests nicht. Tests sind Aufrufer. tests/ mit durchsuchen.
  • Vergiss Doku und Skripte nicht. README.md, docs/, tools/ ebenfalls scannen.

Schritt 2 — Statisch absichern

Lernreise 1 hat dich gelehrt, AST-Wächter zu bauen. Genau hier ist die Anwendung: ein Wächter gegen undefiniertes self.method() fängt alles, was der grep übersehen hat — Callbacks, dynamisch konstruierte Aufrufe, falsch escapte Treffer.

Faustregel: Refactor + AST-Wächter + Test-Run = Vertrauen.

Schritt 3 — Den Sweep abschließen

Vor dem Commit:

  • [ ] Jeder Treffer aus Schritt 1 ist angefasst oder bewusst übersprungen?
  • [ ] AST-Wächter aus Schritt 2 grün?
  • [ ] Tests grün?
  • [ ] Doku-Beispiele aktualisiert?
  • [ ] Commit-Nachricht nennt die geänderte Schnittstelle (damit git blame in einem Jahr verständlich bleibt)?

Erst dann committen. Atomar — eine Schnittstellen-Änderung pro Commit.


Tier 3 · Übertragen — Verträge als Grenzen

Refactor-Disziplin verhindert deine eigenen Bugs. Die Welt schickt dir aber auch Eingaben, die du nicht kontrollierst — YAML-Dateien, JSON-APIs, User-Eingaben.

Die Regel: Vertrauen innen, validieren am Rand.

Bug 3 noch einmal — drei mögliche Fixe, nur einer ist richtig

Erinnerung: description durfte plötzlich dict sein, der Vertrag versprach Optional[str].

Möglichkeit 1 — Schaden umschließen:

PYTHON
try:
    return description.lower()
except AttributeError:
    return ""

❌ Das Problem wandert, statt sich aufzulösen. Jede zukünftige Konsumstelle muss denselben try/except schreiben — oder vergessen, und der Bug taucht woanders wieder auf.

Möglichkeit 2 — Vertrag aufweichen:

PYTHON
@dataclass
class Project:
    description: Union[str, dict, None]

❌ Jede Konsumstelle muss jetzt drei Fälle behandeln. Der Code im Innern wird komplexer — wegen einer Eigenheit am Rand. Das ist die Komplexität an der falschen Stelle.

Möglichkeit 3 — Am Rand zwingen:

PYTHON
def _create_project(self, metadata: dict) -> Project:
    description = metadata.get("description")
    if isinstance(description, dict):
        # Sprachvarianten → eine kanonische Form
        description = description.get("en") or next(iter(description.values()), None)
    return Project(description=description, ...)

Der Vertrag bleibt unverletztdescription: Optional[str] — und alle Konsumstellen bleiben einfach.

Das Prinzip

Genau eine Stelle pro Grenze zwingt die Eingabe in die kanonische Form. Alle anderen Stellen dürfen vertrauen.

  • Eine Datei: ein Konstruktor / Loader.
  • Eine API: ein Adapter / Deserialisierer.
  • Ein User-Form: ein Parser / Validator.

Wenn du dieselbe Coercion-Logik an zwei Stellen hast, ist eine der beiden Stellen falsch oder du hast eine Grenze übersehen.

Deine Übung

Such in deinem Code eine Stelle, an der eine Datenklasse Eingaben aus einer Datei oder API erhält. Frag dich:

  1. Was ist der Vertrag (Typannotation)?
  2. Was kann die echte Quelle wirklich schicken?
  3. Wo ist die eine Grenze, an der ich zwingen sollte?
  4. Welche Stellen im Innern dürfen ab jetzt vertrauen?

Schreib einen Coercion-Block am Rand. Ergänze einen Test mit einer „dreckigen" Eingabe. Beobachte, wie der Test grün bleibt, obwohl sich am Innern nichts geändert hat.


Was du jetzt weißt

  • Eine Schnittstelle hat mehr Oberfläche als ihre Methodennamen — Signaturen, Typen, Rückgaben, Attribute, Ausnahmen.
  • Refactoring ist eine Such-Aufgabe. Wenn du nicht weißt, was du suchst, weißt du nicht, was du refactorierst.
  • Typannotationen sind Verträge. Wenn du sie verletzt, brichst du Code, den du nie gesehen hast.
  • Grenzen sind die richtige Stelle, um zu zwingen. Im Innern darfst du vertrauen — und das ist die Belohnung der Disziplin.

Weiterführende Reisen

Quellen & Anker

  • iMenu-Commits der Refactor-Session (SessionBridge-Rewrite, Project-Datenklasse, description-Coercion in _create_project)
  • Python-Dokumentation: dataclasses, typing.Optional