CodeGym /Kurse /Swift SELF /Pruefungsrichtlinie: assert, precondition, fatalError

Pruefungsrichtlinie: assert, precondition, fatalError

Swift SELF
Level 31, Lektion 4
Verfügbar

1. Richtlinie fuer harte Pruefungen und Verantwortungsgrenzen

assert, precondition und fatalError ersetzen nicht die Behandlung erwarteter Fehler. Sie kennzeichnen etwas anderes: Situationen, die laut Vertrag nicht auftreten duerfen. Wenn eine solche Situation dennoch eintritt, gibt es in der Software einen logischen Fehler, und es ist besser, davon so frueh wie moeglich zu erfahren — bevor der Datenzustand noch inkorrekter wird.

Die Kernidee: Diese Werkzeuge helfen dabei, Verantwortung zu trennen. Die aeussere Welt (Eingabe, Netzwerk, Dateien) darf immer „falsch hereinkommen“. Ihr interner Code dagegen nicht, wenn Sie einen Vertrag vereinbart haben.

Zwei Problemklassen: erwartete Fehler und logische Ausfaelle

In einem Programm gibt es zwei grundlegend verschiedene Arten von Problemen.

Der erste Typ sind erwartete Fehler: Der Benutzer hat etwas anderes eingegeben, Daten kamen in einem falschen Format an, eine Datei wurde nicht gefunden, eine Zahl laesst sich nicht parsen, ein Befehl ist unbekannt. Das ist der „normale Alltag“, und solche Situationen werden gewoehnlich ueber Result, throws, manchmal Optional behandelt (je nach gewaehlter Richtlinie).

Der zweite Typ sind logische Ausfaelle (Programmierfehler): „Diese Funktion darf nicht mit einem leeren Array aufgerufen werden“, „der Index wurde oben bereits geprueft“, „im switch haben wir alle moeglichen Faelle aufgelistet“. Genau hier sind assert, precondition und fatalError passend: Sie „heilen“ die Situation nicht, sondern machen einen Vertragsbruch sichtbar, also einen Bug.

Eine kleine Skizze, um die Verantwortungsgrenzen im Kopf zu behalten:

flowchart TD
    A[Aeusere Welt: Eingabe, Dateien, Netzwerk, Parameter] --> B{Kann das falsch sein?}
    B -->|Ja, das ist erwartet| C["Result / throws / Optional (kontrollierter Fehler)"]
    B -->|Nein, laut Vertrag darf das nicht passieren| D["assert / precondition / fatalError (logischer Ausfall)"]

2. assert: ein sanftes „So habe ich es gemeint“

assert ist eine Pruefung der Annahme des Entwicklers. Sinngemaess heisst das: „Ich behaupte, dass diese Bedingung hier immer wahr ist. Wenn nicht, habe ich irgendwo einen Fehler gemacht und moechte das so frueh wie moeglich erfahren.“

assert ist deshalb gut, weil es einen versteckten Bug in ein sichtbares Signal verwandelt. Es ist in Stellen nuetzlich, an denen der Code ohne Pruefung irgendwie weiterlaufen wuerde und spaeter ploetzlich merkwuerdige Ergebnisse liefert — und Sie wuerden dann Zeit mit der Fehlersuche verlieren.

Wichtig ist die Philosophie: assert ist kein Schutzschild gegen den Benutzer, sondern eine Taschenlampe fuer den Entwickler.

assert in einer Mini-Anwendung: „unsichtbare“ Regeln pruefen

Stellen wir uns eine kleine Konsolenanwendung „Buchbibliothek“ vor. Nehmen wir an, wir haben vereinbart: Buch-IDs sind eindeutig. Wenn wir den Code korrekt schreiben, darf es keine Duplikate geben. Wenn aber irgendwo versehentlich dasselbe Buch zweimal hinzugefuegt wird, soll assert das sichtbar machen.

import Foundation

struct Book {
    let id: String
    let title: String
}

struct Library {
    private var booksByID: [String: Book] = [:]

    mutating func add(_ book: Book) {
        assert(booksByID[book.id] == nil, "Duplicate id: \(book.id)")
        booksByID[book.id] = book
    }
}

Hier ist assert keine „Fehlerbehandlung beim Hinzufuegen“. Es bedeutet: „Wenn das passiert ist, haben wir unsere eigenen Nutzungsregeln fuer Library.add verletzt.“

Die Meldung in assert: nicht „oops“, sondern „was genau kaputt ist“

Man kann leicht assert(x > 0) schreiben und sich beruhigen. Wenn es dann ausgeloest wird, sehen Sie etwas wie „Assertion failed“ und betrachten es wie den Satz „irgendetwas ist schiefgelaufen“.

Die Meldung sollte praktisch sein: Was wurde erwartet, was wurde erhalten und — wenn moeglich — welcher Parameter ist schuld.

import Foundation

func average(_ values: [Int]) -> Double {
    assert(!values.isEmpty, "average() expects non-empty array")

    let sum = values.reduce(0, +)
    return Double(sum) / Double(values.count)
}

print(average([2, 4, 6])) // 4.0

Hier versuchen wir nicht, ein leeres Array als Benutzerfehler „zu behandeln“. Wir sagen: „Rufen Sie average nicht mit einem leeren Array auf.“ Das ist der Vertrag der Funktion.

3. precondition: harter API-Vertrag

Wenn assert wie „ich bin fast sicher“ klingt, dann klingt precondition wie „es muss so sein, sonst hat der Rest keinen Sinn“.

precondition schuetzt den Vertrag: Sie definieren, welche Eingabedaten oder Zustande fuer eine korrekte Ausfuehrung zulaessig sind. Wird die Bedingung verletzt, wird die Ausfuehrung beendet, denn mit falschen Voraussetzungen weiterzumachen bedeutet entweder Muell als Ergebnis oder einen noch staerkeren Schaden am Datenzustand.

Wichtiger Gedanke: precondition ist nicht fuer „der Benutzer hat etwas Falsches eingegeben“. Benutzereingaben koennen naturgemaess falsch sein. Der Vertrag ist etwas, das Sie und Ihr Code einhalten muessen.

precondition als Schutz vor falscher Verwendung einer Funktion

Betrachten wir eine Funktion, die ein Array-Element ueber einen Index holt. Man kann Optional zurueckgeben, man kann einen Fehler werfen, aber manchmal sagt das API-Design: „Wenn der Index ungueltig ist, ist das ein Bug im aufrufenden Code.“

import Foundation

func element(at index: Int, in array: [String]) -> String {
    precondition(index >= 0 && index < array.count, "Index out of bounds: \(index)")
    return array[index]
}

print(element(at: 1, in: ["a", "b", "c"])) // b

Wir versuchen hier nicht, „die Welt zu retten“ und einen Standardstring "?" zurueckzugeben. Wenn der Index falsch ist, dann hat die aufrufende Seite den Vertrag verletzt.

precondition und Zustandsinvarianten: „Buecher duerfen nicht ins Nichts verschwinden“

Zurueck zu Library. Nehmen wir an, es gibt eine Methode zum Loeschen eines Buches nach ID. Wenn die ID garantiert existiert (weil wir oben im Code bereits geprueft haben), dann kann der Vertrag ueber precondition formuliert werden.

import Foundation

struct Book {
    let id: String
    let title: String
}

struct Library {
    private var booksByID: [String: Book] = [:]

    mutating func removeExisting(id: String) {
        precondition(booksByID[id] != nil, "Expected existing id: \(id)")
        booksByID[id] = nil
    }
}

In einer echten Anwendung wuerden Sie fuer das Loeschen durch den Benutzer wahrscheinlich eine andere Methode schreiben — eine, die Result zurueckgibt oder einen Fehler wirft, denn der Benutzer kann durchaus verlangen, ein nicht existentes Buch zu loeschen. Diese Methode ist „intern“, fuer Szenarien, in denen das Fehlen einer ID einen Bug bedeutet.

4. fatalError: unerreichbare Zweige und bewusstes Abbrechen

fatalError ist eine direkte Art zu sagen: „Dieser Zustand ist unmoeglich, und wenn er auftritt, koennen wir nicht fortfahren.“ Im Unterschied zu precondition, das eine Bedingung prueft, wird fatalError meist in einem Zweig verwendet, der nach der Logik des Programms unerreichbar sein sollte.

Das begegnet haeufig zusammen mit switch oder nach Pruefungen, die garantieren, dass „wir hier nicht mehr landen“.

Stilistisch wichtig: fatalError ist kein „Schnellfix, damit es kompiliert“. Es ist ein roter Knopf, der selten und sehr bewusst eingesetzt werden sollte.

fatalError in einem switch: den unmoeglichen Zweig markieren

Wenn ein switch vollstaendig ist, braucht man fatalError nicht: Der Compiler zwingt Sie dann selbst dazu, alle Faelle zu behandeln.

import Foundation

enum SortMode {
    case byTitle
    case byID
}

func sortDescription(_ mode: SortMode) -> String {
    switch mode {
    case .byTitle: return "Sort by title"
    case .byID: return "Sort by id"
    }
}

Hier ist ein Beispiel, in dem wir nach einer harten Pruefung „die Tuere schliessen“:

import Foundation

func weekdayName(_ number: Int) -> String {
    precondition((1...7).contains(number), "weekdayName expects 1...7, got \(number)")

    switch number {
    case 1: return "Mon"
    case 2: return "Tue"
    case 3: return "Wed"
    case 4: return "Thu"
    case 5: return "Fri"
    case 6: return "Sat"
    case 7: return "Sun"
    default:
        fatalError("Unreachable: precondition should have filtered this out")
    }
}

print(weekdayName(5)) // Fri

default ist hier genau der „unmoegliche Zweig“, und fatalError macht dieses Unmoegliche sichtbar, falls es doch einmal passiert.

guard ... else { fatalError(...) }: bewusstes „Sterben“ statt stummem !

Es gibt ein verbreitetes Problem: Man sieht x! (force unwrap) und versteht nicht, warum das sicher sein soll. Wenn nil Ihrer Logik nach wirklich unmoeglich ist, machen Sie es lieber explizit: mit guard und fatalError mit einer sauberen Erklaerung.

import Foundation

func lastNonEmpty(_ values: [Int]) -> Int {
    guard let last = values.last else {
        fatalError("lastNonEmpty() expects a non-empty array")
    }
    return last
}

print(lastNonEmpty([10, 20])) // 20

Der Sinn ist nicht, dass fatalError „besser“ ist. Der Sinn ist, dass der Vertrag lesbar wird und Sie beim Absturz eine Meldung erhalten, die wirklich hilft.

5. Praktische Richtlinie: Werkzeugwahl und Praxisbeispiel

Wenn es mehrere Werkzeuge gibt, waehlt ein Anfaenger oft zufaellig: „Hier setze ich fatalError, weil das schnell geht.“ Damit der Code nicht zu einem Minenfeld wird, ist eine einfache interne Richtlinie fuer die Wahl nuetzlich — wie eine Ampel.

Mini-Tabelle zur Auswahl: womit pruefen und wo die Verantwortungsgrenze liegt

Situation Kann das im normalen Leben passieren? Was verwenden Idee
Der Benutzer hat etwas Falsches eingegeben, der String laesst sich nicht parsen, Daten fehlen Ja Result / throws / manchmal Optional Der Fehler ist Teil des Szenarios
Ein interner Algorithmus stuetzt sich auf eine Annahme Eher nein assert Bug fruehzeitig finden
Der Vertrag einer Funktion/Methode wird verletzt (API wird falsch verwendet) Nein precondition So geht es nicht weiter
Ein Logikzweig ist nach den Pruefungen „unmoeglich“ Nein fatalError Das Unerreichbare markieren

Beachten Sie: Hier gibt es nirgends den Punkt „Benutzer hat etwas Falsches eingegeben → fatalError“. Denn genau in diesem Fall schieben Sie Verantwortung vom Level „Fehler behandeln“ auf das Level „Programm abstuerzen lassen“.

Beispiel: saubere Buchbibliothek ohne „stille“ Bugs

Stellen wir einen kleinen, in sich geschlossenen Codeblock zusammen, in dem die Trennung sichtbar wird: Benutzerfehler ueber Result, interne Vertrage ueber precondition und feine Annahmen ueber assert.

import Foundation

enum LibraryError: Error {
    case duplicateID(String)
    case notFound(String)
}

struct Book {
    let id: String
    let title: String
}

struct Library {
    private var booksByID: [String: Book] = [:]

    mutating func addBook(_ book: Book) -> Result<Void, LibraryError> {
        guard booksByID[book.id] == nil else {
            return .failure(.duplicateID(book.id))
        }
        booksByID[book.id] = book
        assert(booksByID[book.id] != nil, "Internal invariant broken: book not stored")
        return .success(())
    }

    func requireBook(id: String) -> Book {
        precondition(booksByID[id] != nil, "Expected existing book id: \(id)")
        return booksByID[id]! // safe because of precondition
    }

    mutating func removeBook(id: String) -> Result<Book, LibraryError> {
        guard let removed = booksByID.removeValue(forKey: id) else {
            return .failure(.notFound(id))
        }
        return .success(removed)
    }
}

Achten Sie auf die Verantwortungsgrenzen direkt im Code. Die Methode addBook gibt Result zurueck, weil ein doppelter ID-Wert im Anwendungsszenario durchaus erwartet werden kann. Die Methode removeBook gibt ebenfalls Result zurueck: Der Benutzer kann ein Buch loeschen wollen, das nicht existiert. requireBook dagegen ist absichtlich hart: Sie ist fuer interne Szenarien gedacht, in denen das Fehlen eines Buches einen Bug in der Programmlogik bedeutet.

Und hier eine kleine Demo zur Verwendung:

import Foundation

var library = Library()

let r1 = library.addBook(Book(id: "b1", title: "Swift for Humans"))
print(r1) // success()

let r2 = library.addBook(Book(id: "b1", title: "Duplicate"))
print(r2) // failure(duplicateID("b1"))

let removed = library.removeBook(id: "b2")
print(removed) // failure(notFound("b2"))

Hier gibt es keine Abstuerze, weil wir keine Vertrage verletzen. Wir behandeln erwartete Fehler als Daten. Und die „harten“ Pruefungen bleiben als Absicherung gegen unsere eigenen zukuenftigen Fehler im Code bestehen.

6. Typische Fehler

Fehler Nr. 1: Benutzereingaben mit fatalError behandeln.
Wenn Sie „eine Zahl konnte nicht geparst werden“ sehen, ist die Versuchung manchmal gross, fatalError("Invalid number") zu schreiben, weil das einfacher ist: keine Signaturen aendern, nicht ueber Result nachdenken muessen. Doch genau hier vermischen Sie Problemklassen. Ungueltige Eingaben sind eine erwartete Realitaet, also muessen sie in einen kontrollierten Fehler (Result/throws) umgewandelt werden und nicht in einen Prozessabsturz.

Fehler Nr. 2: assert als „Torwächter am Eingang“ missbrauchen.
assert sieht wie eine Pruefung aus, deshalb beginnen Anfaenger, damit alles moegliche zu validieren: Bereiche, leere Zeichenfolgen, Eingabeformate. Das Problem ist, dass assert seinem Sinn nach fuer Annahmen des Entwicklers und zum Debuggen gedacht ist, nicht fuer „wir schuetzen das Programm vor der Welt“. Wenn Sie externe Daten ueber assert validieren, riskieren Sie ein Verhalten, das von der Umgebung abhaengt, und vor allem vermischen Sie die Verantwortungsebenen.

Fehler Nr. 3: ein „stummes“ ! statt eines erklaerten Vertrags.
Code wie array.last! kann korrekt sein, wirkt aber ohne Kontext wie ein Roulettespiel. Wenn nil Ihrer Logik nach wirklich unmoeglich ist, machen Sie es lieber explizit: precondition(!array.isEmpty, "...") oder guard let ... else { fatalError("...") }. Dann wird der Vertrag lesbar, und im Fehlerfall erhalten Sie eine verstaendliche Meldung statt eines geheimnisvollen „unexpectedly found nil“.

Fehler Nr. 4: fatalError als Pflaster „damit es kompiliert“.
Manchmal wird in einem switch einfach default: fatalError() hinzugefuegt, nur weil der Compiler vollstaendige Faelle verlangt und „ich keine Lust habe, mehr zu schreiben“. Das ist ein gefaehrlicher Stil: Sie unterschreiben quasi „bei neuen Faellen wird die Anwendung abstuerzen“, und spaeter vergessen Sie selbst, dass Sie das unterschrieben haben. Besser ist es, entweder den switch wirklich vollstaendig zu machen oder klar zu erklaeren, warum der Zweig unerreichbar ist (meist nach precondition).

Fehler Nr. 5: zu allgemeine Meldungen in Pruefungen.
Meldungen wie "Error" oder "Something went wrong" in precondition/fatalError helfen nicht weiter. Der Sinn solcher Pruefungen ist es, die Ursache des logischen Fehlers schnell zu verstehen. Eine gute Meldung enthaelt fast immer „was erwartet wurde“ und „was erhalten wurde“: Bereich, ID, Index, Funktionsname. Das spart Stunden bei der Fehlersuche und schont Ihre Nerven — und die Psyche eines Programmierers ist eine begrenzte Ressource.

1
Aufgabe
Swift SELF, Level 31, Lektion 4
Gesperrt
Durchschnittlicher Betrag
Durchschnittlicher Betrag
1
Aufgabe
Swift SELF, Level 31, Lektion 4
Gesperrt
Titel nach Nummer
Titel nach Nummer
1
Aufgabe
Swift SELF, Level 31, Lektion 4
Gesperrt
Bücherregal
Bücherregal
1
Aufgabe
Swift SELF, Level 31, Lektion 4
Gesperrt
Wochentag
Wochentag
1
Umfrage/Quiz
Result und Fehlerbehandlung, Level 31, Lektion 4
Nicht verfügbar
Result und Fehlerbehandlung
Praxis mit Result, map, defer
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION