Die Assertion-Angst
Die meisten Production-Codebases gehören zu einem von zwei Lagern. Lager A behandelt assert als dekoratives Gewürz, streut es in jede zweite Zeile, bis die Funktion wie ein rechtlicher Vertrag eines paranoischen Anwalts liest. Lager B behandelt Assertions als Trainingsräder nur für die Entwicklung, entfernt sie alle zum Build-Zeitpunkt und hofft, dass der Code im Production-Code funktioniert, weil die Tests einmal bestanden haben.
Beide Lager liegen falsch. Die Frage ist nicht, ob man asserten soll. Die Frage ist, was eine Assertion eigentlich bedeutet.
Eine Assertion ist keine Fehlerbehandlung. Sie ist keine Input-Validierung. Sie ist keine höfliche Empfehlung. Eine Assertion ist die Behauptung, dass etwas unmöglich ist. Wenn die Assertion auslöst, ist dein mentales Modell des Programms kaputt. Diese Unterscheidung bestimmt alles darüber, wo Assertions hingehören und wie viele du schreiben solltest.
Assertions sind für Invariants, nicht für Fehler
Wenn ein User ein negatives Alter an deine API übergibt, ist das ein Fehler. Fehler sind erwartbar. Fehler verdienen echte Behandlung, Logging und User-facing Messages. Wenn deine interne Berechnung nach einer angeblich erfolgreichen Query eine negative Anzahl an Datenbank-Zeilen produziert, ist das eine Invariant-Violation. Das sollte niemals passieren. Dafür existieren Assertions.
Das klingt offensichtlich, bis man Production-Code liest. Ich habe Funktionen gesehen, die asserten, dass ein String nicht leer ist, und drei Zeilen später if (!str) prüfen und eine formatierte Exception werfen. Der Entwickler hat beide Tools für dieselbe Condition verwendet, weil er nie entschieden hat, welcher der echte Contract war.
Hier ist die Regel. Wenn die Condition durch External Input ausgelöst werden kann, ist sie keine Assertion. Wenn sie nur durch einen Bug in deinem eigenen Code ausgelöst werden kann, ist sie eine.
def process_payment(user_id: str, amount_cents: int) -> Receipt:
# NOT an assertion. Users or upstream services can send bad data.
if amount_cents <= 0:
raise ValueError("amount_cents must be positive")
# NOT an assertion. The user_id comes from the outside world.
if not user_id:
raise ValueError("user_id is required")
receipt = _charge_card(user_id, amount_cents)
# THIS is an assertion. If charge_card returned None after
# succeeding, our understanding of the universe is wrong.
assert receipt is not None, "charge_card succeeded but returned None"
# THIS is an assertion. A receipt with zero items after a
# successful charge means our internal logic is broken.
assert len(receipt.items) > 0, "receipt has no items after successful charge"
return receipt
Die ersten beiden Checks bewachen die Boundary. Die letzten beiden bewachen die interne Konsistenz des Systems. Sie zu mischen erzeugt Verwirrung darüber, wer für was verantwortlich ist.
Das Drei-Assertions-Limit
Wenn du merkst, dass du mehr als drei Assertions in einer einzigen Funktion schreibst, hast du eines von zwei Problemen. Entweder macht deine Funktion zu viele Dinge, oder deine Invariants sind zu vage, um durchzusetzen zu werden.
Eine Funktion mit zwölf Assertions ist nicht defensiv. Sie ist unsicher. Der Autor vertraut dem Code, der sie aufruft, dem Code, den sie aufruft, oder den Daten, die dazwischen fließen, nicht. Diese Unsicherheit sollte durch Refactoring gelöst werden, nicht durch das Hinzufügen weiterer assert-Statements.
Das praktische Limit kommt von dem, was ein Entwickler im Kopf halten kann. Eine Funktion sollte einen klaren Contract haben. Dieser Contract impliziert eine kleine Anzahl an Invariants. Wenn du ein Dutzend Assertions brauchst, um dich sicher zu fühlen, hat deine Funktion wahrscheinlich Verantwortlichkeiten aufgesaugt, die woanders hingehören.
Splitte die Funktion. Extrahiere den Teil, der Daten transformiert. Extrahiere den Teil, der externe Services aufruft. Gib jeder extrahierten Funktion ihren eigenen kleinen Satz an Invariants. Drei Assertions pro Funktion sind eine Warnleuchte. Fünf sind ein Plattfuß.
Assertions im Production-Code: An oder Aus?
Verschiedene Sprachen treffen verschiedene Entscheidungen. Python entfernt assert-Statements, wenn man mit dem -O-Flag ausführt. C- und C++-Compiler entfernen Assertions routinemäßig in Release-Builds. JavaScript hat überhaupt kein eingebautes assert. Man polyfilled es entweder oder nutzt eine Library, die im Production-Code aktiv bleibt.
Das erzeugt ein echtes Dilemma. Wenn du Assertions entfernst, verlierst du genau dann das Sicherheitsnetz, wenn du es am meisten brauchst. Bugs, die nur im Production-Code auftauchen, korrumpieren Daten stillschweigend, anstatt schnell zu failen. Wenn du sie behältst, riskierst du, einen Production-Prozess wegen einer Condition zu crashen, die zwar theoretisch unmöglich ist, aber nicht wirklich fatal.
Die Antwort hängt von den Kosten des Weitermachens ab. Wenn das Verletzen der Invariant bedeutet, dass die nächste Operation die Datenbank korrumpiert oder sensitive Daten leaked, sollte die Assertion den Prozess crashen. Ein harter Stop ist besser als ein stillschweigender Breach. Wenn das Verletzen der Invariant nur einen leicht falschen Log-Eintrag oder einen kleinen UI-Glitch bedeutet, logge es und mache weiter.
// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");
// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
logger.warn("cache timestamp is in the future, ignoring");
}
Nicht jede Invariant verdient dieselbe Haltung. Lerne, den Unterschied zwischen „das muss stoppen“ und „das ist komisch, aber überlebbar“ zu erkennen.
Was wir ausprobiert haben und nicht funktioniert hat
Früh in einem Projekt haben wir versucht, jede Function Precondition zu asserten. Jedes Argument wurde auf null, Typ, Range und Format geprüft. Das Ergebnis war vorhersehbar. Tests haben wunderbar bestanden. Der Production-Code ist beim ersten Mal gecrasht, als eine Third-Party-API ein Feld als String statt als Number zurückgegeben hat.
Das Problem war nicht die Assertion. Das Problem war, dass wir Daten von außerhalb unserer Kontrolle assertet haben und dann mit aktivierten Assertions im Production-Code kompiliert haben. Eine malformed External Response hat unseren Prozess gekillt, anstatt sanitized und gehandhabt zu werden. Wir hatten ein System gebaut, das intern konsistent und extern fragil war.
Wir haben gelernt, die Boundary vom Inneren zu trennen. An der Boundary parsen und validieren wir aggressiv. Konvertiere externes Chaos in interne Gewissheit. Innerhalb der Boundary asserte die Invariants, die diese Gewissheit definieren. Die Assertions blieben. Die Input-Validierung zog in explizite Parsing-Funktionen um, die Result-Types zurückgeben, anstatt zu throwen.
Eine praktische Checkliste
Bevor du eine Assertion hinzufügst, gehe diese Liste durch:
- Kann External Input das auslösen? Wenn ja, nutze Validierung, keine Assertion.
- Wenn das im Production-Code auslöst, sollte der Prozess stoppen? Wenn nein, logge stattdessen eine Warnung.
- Hat diese Funktion bereits drei oder mehr Assertions? Wenn ja, erwäge Refactoring, bevor du eine weitere hinzufügst.
- Wird diese Assertion für jemanden, der den Code in sechs Monaten liest, immer noch Sinn ergeben? Obskure Assertions werden beim Refactoring gelöscht. Klare überleben.
Assertions sind ebenso ein Kommunikations-Tool wie ein Sicherheits-Tool. Sie sagen dem nächsten Entwickler: „Diese Condition ist by design unmöglich.“ Wenn die Condition nicht wirklich by design unmöglich ist, lügt die Assertion. Und Lügen im Production-Code sind teuer.
FAQ
Sollte ich auf Function Arguments asserten?
Nur wenn der Caller ebenfalls dein Code ist und das Argument ein Produkt interner Logik ist, nicht External Input. Public-API-Funktionen sollten validieren. Private Helper-Funktionen können Invariants über die Werte, die sie erhalten, asserten.
Was ist mit TypeScript? Es fängt nulls bereits zur Compile Time ab.
TypeScripts Type-System ist eine mächtige Assertion-Layer, verschwindet aber zur Runtime. Nutze es für alles, was der Compiler beweisen kann. Füge Runtime-Assertions für die Lücken hinzu: API-Responses, deserialisierte Daten und jedes as-Cast, das den Type Checker umgeht.
Beeinträchtigen Assertions die Performance?
In den meisten Sprachen kostet eine gut platzierte Assertion Mikrosekunden. Wenn du innerhalb einer tight loop assertest, die Millionen von Items verarbeitet, verschiebe die Assertion außerhalb der Loop. Prüfe die Invariant auf dem Batch, nicht auf jedem Element.
Sollte ich Custom-Assert-Funktionen schreiben?
Nur wenn die eingebaute Assertion-Message nicht hilfreich wäre. Eine Custom-assertNonEmpty, die die tatsächliche Array-Length ausgibt, ist nützlicher als ein generisches assert len(items) > 0, das ohne Kontext crasht. Halte sie klein. Baue kein Assertion-Framework.