CodeGym /Kurse /Kotlin SELF /Nullable als Vertrag: wo null gerechtfertigt ist und wo e...

Nullable als Vertrag: wo null gerechtfertigt ist und wo es stört

Kotlin SELF
Level 41, Lektion 0
Verfügbar

1. Eintauchen in null

Wenn Sie String? sehen, liest das Anfängerhirn das oft so: „da kann null sein, also setze ich ?. und mache weiter“. Für das Programmdesign ist jedoch eine andere Lesart hilfreicher: String? ist ein Versprechen des Autors der Funktion/des Feldes, dass das Fehlen eines Werts ein normaler, erwarteter Ausgang ist. Und genau hier beginnt das Erwachsenenleben: Wenn null ein normaler Ausgang ist, muss seine Bedeutung genauso verständlich sein wie die Bedeutung von true/false oder 0/1.

Festigen wir den Gedanken an einem einfachen Beispiel. In Kotlin zeigt man in Lernbeispielen oft einen Parser: Wenn ein String keine Zahl ist, geben wir null zurück. Das ist ein sehr ehrlicher Vertrag: „konnte nicht parsen — hier ist das Signal, ohne Exceptions und ohne Mystik“.

fun parsePort(raw: String): Int? {
    val trimmed = raw.trim()
    return trimmed.toIntOrNull()
}

fun main() {
    println(parsePort("8080"))   // 8080
    println(parsePort("oops"))   // null
}

Hier bedeutet null etwas Konkretes: „der String enthält keine korrekte ganze Zahl“. Das ist kein Systemfehler, kein Bug, kein „irgendwas ist schiefgelaufen“ — das ist eine gewöhnliche Situation an der Eingabegrenze.

Stellen Sie sich nun vor, Sie hätten Folgendes gemacht:

fun loadConfigText(): String? {
    // ... was auch immer wir lesen
    return null
}

Und das war’s. Aber was bedeutet null? Konfig nicht gefunden? Keine Rechte? Datei leer? Haben Sie vergessen, die Funktion fertig zu schreiben? Das ist bereits ein schlechter Vertrag: Er zwingt den Aufrufer zum Raten.

Das Schlüsselkriterium: Wenn Sie ein ? setzen, müssen Sie die Frage „warum kann der Wert fehlen?“ so beantworten, dass es nicht wie „weil ich zu faul bin“ wirkt.

2. Wo null normalerweise gerechtfertigt ist: Programmgrenzen

In der Regel ist null ein hervorragendes Werkzeug an den Systemgrenzen. Dort, wo Sie auf die unvorhersehbare Welt treffen: Benutzereingaben, Kommandozeilenargumente, Daten aus dem Netz, Dateilesen, Suche nach Schlüssel (wenn ein Element wirklich fehlen kann). An der Grenze darf die Welt Ihnen Leere bringen, einen seltsamen String oder „ich habe überhaupt nichts eingegeben“. Und genau dort ist null passend.

In Kotlin deutet sogar die Basal-API von Collections diesen Ansatz an: Zum Beispiel liefert das Lesen nach Schlüssel aus einer Map V? zurück, weil der Schlüssel nicht existieren kann, und das ist ein normales Szenario. An so einer Stelle liest sich null als „nicht gefunden“ und nicht als „kaputt“.

Nehmen wir ein Beispiel näher an unserer praktischen Konsolenanwendung (nennen wir sie der Einfachheit halber ExpenseTracker: eine kleine Ausgabenverwaltung in der CLI). Angenommen, wir haben eine Liste von Ausgaben und suchen eine Ausgabe nach id. Es ist völlig normal, Expense? zurückzugeben: Die Ausgabe könnte gelöscht worden sein, die id könnte falsch sein oder der Benutzer könnte sich vertippt haben.

data class Expense(
    val id: Int,
    val title: String,
    val amount: Int
)

fun findExpenseById(expenses: List<Expense>, id: Int): Expense? {
    return expenses.find { it.id == id }
}

fun main() {
    val expenses = listOf(
        Expense(id = 1, title = "Coffee", amount = 300),
        Expense(id = 2, title = "Book", amount = 1200),
    )

    val e = findExpenseById(expenses, 2)
    println(e?.title) // Book
}

Beachten Sie: Der Vertrag ist perfekt lesbar. Expense? bedeutet hier „kann nicht gefunden werden“. Und das ist kein „Fehler“: Es ist Teil der Domäne.

3. Wo null anfängt zu stören: innerhalb von Modell und Business-Logik

Innerhalb des Programms (nachdem die Daten bereits erkannt und geprüft wurden) wird null oft zu einem „Hauch von Chaos“. Der Grund ist einfach: null beginnt, durch die Code-Schichten durchzusickern. Ein Nullable-Feld zieht das zweite nach sich, dann das dritte; dann tauchen ?. beim Formatieren auf, ?: in Berechnungen, if (x != null) an drei verschiedenen Stellen, und am Ende steht die philosophische Frage: „Was gilt überhaupt als korrektes Objekt?“

Stellen wir uns eine schlechte Version unseres Ausgabenmodells vor:

data class ExpenseDraft(
    val title: String?,
    val amount: Int?
)

fun main() {
    val draft = ExpenseDraft(title = null, amount = null)
    println("title=${draft.title}, amount=${draft.amount}") // title=null, amount=null
}

Von außen wirkt das bequem: „man kann wenigstens irgendetwas erstellen“. Aber als Nächstes wollen Sie:

  • die Ausgabe dem Benutzer anzeigen,
  • die Summe berechnen,
  • sortieren,
  • gruppieren.

Und überall werden Sie denken: „kann hier null sein?“. Das ist, als würde man zu Hause in der Küche „zur Sicherheit“ einen Regenschirm tragen: Formal geht das, aber Kochen wird unbequem.

Ein wichtiger Gedanke der heutigen Vorlesung: Nullable-Felder innerhalb eines „normalen“ Modells bedeuten oft, dass Sie verschiedene Datenstadien vermischt haben. Es gibt „rohe Eingabe“ und es gibt „valide Entität“. Wenn Sie beides in einem Typ vermischen, wird sich null schnell ausbreiten.

4. Zwei Bedeutungen von null: „keine Daten“ und „Fehler“

Dieser Teil ist besonders wichtig, denn genau hier entstehen die fiesesten Bugs — nicht die, die sofort abstürzen, sondern die, die still die Logik verderben.

null wird typischerweise in zwei unterschiedlichen Situationen verwendet:

Situation Was inhaltlich passiert Ist es okay, null zurückzugeben? Beispiel
Fehlender Wert Der Wert kann nicht existieren und das ist erwartbar Ja „nicht nach id gefunden“, „leere Eingabe“, „Zahl ließ sich nicht parsen“
Fehler/Unmöglichkeit Wir wollten den Wert, haben ihn aber wegen eines Problems nicht bekommen Gefährlich „Datei konnte nicht gelesen werden“, „Netzwerkfehler“, „Logik ist kaputt“

Das Problem ist, dass beide Situationen technisch gleich aussehen: „null zurückgegeben“. Inhaltlich sind sie aber so verschieden wie „im Laden gibt es kein Brot“ und „der Laden ist abgebrannt“.

Erstellen wir absichtlich ein schlechtes Beispiel und schauen wir, warum es schlecht ist:

fun loadUserName(userId: Int): String? {
    // Stellen wir uns vor, dass es hier einen Datenbankfehler geben könnte,
    // aber wir haben einfach null zurückgegeben
    return null
}

Der aufrufende Code kann nun nicht unterscheiden:

  • einen Benutzer mit so einer id gibt es wirklich nicht (normal),
  • den Benutzer gibt es, aber die DB ist abgestürzt (nicht normal),
  • der Entwickler hat vergessen, die Funktion zu implementieren (auch nicht normal).

Und dann schreibt in der Regel jemand:

val name = loadUserName(10) ?: "UNKNOWN"
println("Hello, $name") // Hello, UNKNOWN

Und da ist er — der „stille Bug“: Das Programm lebt weiter, aber die Bedeutung der Daten ist verdorben. Das ist gefährlicher als ein Absturz, denn ein Absturz schreit wenigstens ehrlich: „mir geht’s schlecht“. Aber "UNKNOWN" kann in einen Report, in einen Export, in Endsummen und weiter in der Kette geraten.

Das Ziel heute ist, zu lernen, solche Stellen zu erkennen und null nicht als universellen „Stopfen für alles“ zu benutzen.

5. Praxis: wie man null unter Kontrolle hält

Kriterium: „Kann ich null mit einem Satz erklären?“

Damit Design nicht zur Philosophie wird, ist ein sehr praktischer Test hilfreich.

Wenn eine Funktion/ein Feld den Typ T? hat, versuchen Sie, dazu ohne Scham einen Kommentar in einer Zeile zu formulieren. Kein Absatz, kein „na ja, grundsätzlich…“, sondern einen Satz.

Zum Beispiel gute Varianten:

  • findExpenseById(...) : Expense? — „gibt die Ausgabe zurück oder null, wenn sie nicht gefunden wurde“.
  • parseAmount(...) : Int? — „gibt den Betrag zurück oder null, wenn der String keine ganze Zahl ist“.
  • normalizeTitle(...) : String? — „gibt den String ohne Leerzeichen zurück oder null, wenn der String nach trim leer ist“.

Und hier sind schlechte Varianten, die Sie alarmieren sollten:

  • „gibt null zurück, wenn etwas schiefgelaufen ist“
  • „gibt null im Fehlerfall zurück“
  • „kann null zurückgeben, ich bin mir nicht sicher“

Wenn Sie sich bei solchen Formulierungen ertappen, versuchen Sie mit hoher Wahrscheinlichkeit, in null das zu verstecken, was inhaltlich ein Fehler oder ein eigener Zustand ist.

ExpenseTracker: Verträge lesbar machen

Wir werden jetzt keine „fortgeschrittenen“ Konstrukte und keine neuen Ergebnis-Typen hinzufügen (das ist ein eigenes Thema in den nächsten Tagesvorlesungen). Stattdessen üben wir, korrekt T oder T? zu wählen und den Vertrag verständlich zu machen.

Normalisierung von Benutzereingaben: String? ist passend

Der Benutzer kann einen leeren String eingeben, einen String aus Leerzeichen oder sogar „Enter drücken und nichts sagen“. Das ist die klassische Grenze. Hier ist null ehrlich.

fun normalizeNonEmptyText(raw: String): String? {
    val s = raw.trim()
    return if (s.isEmpty()) null else s
}

fun main() {
    println(normalizeNonEmptyText("  hello  ")) // hello
    println(normalizeNonEmptyText("    "))      // null
}

null bedeutet hier „nach der Normalisierung ist kein Text vorhanden“. Das ist Datenabwesenheit, kein Fehler.

Zahl parsen: Int? ist passend

Kotlin schubst einen direkt in diesen Stil: toIntOrNull() liefert Nullable zurück, weil Parsen nicht immer möglich ist. Das ist ein Beispiel für ein „erwartetes Scheitern“, und das ist gut.

fun parseAmount(raw: String): Int? {
    val s = raw.trim()
    return s.toIntOrNull()
}

fun main() {
    println(parseAmount("1200"))  // 1200
    println(parseAmount("12o0"))  // null
}

Suche in einer Collection: Expense? ist passend

Das ist „nicht gefunden“. Ganz normale Geschichte.

fun removeExpenseById(expenses: MutableList<Expense>, id: Int): Boolean {
    val found = expenses.find { it.id == id } ?: return false
    expenses.remove(found)
    return true
}

fun main() {
    val expenses = mutableListOf(
        Expense(1, "Coffee", 300),
        Expense(2, "Book", 1200),
    )

    println(removeExpenseById(expenses, 2)) // true
    println(removeExpenseById(expenses, 99)) // false
}

Hier habe ich es absichtlich so gemacht, dass nach außen kein Expense? ausläuft. Die „löschen“-Funktion nutzt die Nullable-Suche intern, gibt nach außen aber ein einfaches, verständliches Ergebnis zurück: Boolean („gelöscht / nicht gelöscht“). Das ist ebenfalls Teil der Kultur: Nullable halten wir möglichst nah an der Stelle, wo es entstanden ist, und nach außen geben wir, wenn möglich, einen bequemeren Vertrag zurück.

Anti-Pattern: überall nullable „damit der Compiler weniger meckert“

Sehr verführerisch: Der Compiler meckert, dass Sie auf Nullable zugreifen — und Sie so: „okay, dann mache ich eben alles nullable, und die Welt soll brennen“. Kurzfristig wirkt es leichter. Langfristig wird der Code zu einem Quest „finde die Stelle, wo ?. vergessen wurde“.

Um den Unterschied zu fühlen, vergleichen Sie zwei Formatierungsansätze:

fun formatExpenseBad(title: String?, amount: Int?): String {
    val safeTitle = title ?: "UNKNOWN"
    val safeAmount = amount ?: 0
    return "$safeTitle: $safeAmount"
}

fun main() {
    println(formatExpenseBad(null, null)) // UNKNOWN: 0
}

Einerseits stürzt das Programm nicht ab. Andererseits sind die Daten bereits ersetzt. 0 ist nicht „kein Betrag“, sondern „Betrag null“, und der Report kann jetzt falsch werden.

Wenn in Ihrer Anwendung 0 eine zulässige Ausgabe ist (z. B. Promo-Code, Rabatt, Rückerstattung), können Sie „leer“ nicht mehr von „null“ unterscheiden. Und das ist genau ein Beispiel dafür, warum null ein Bedeutungsvertrag ist und nicht „einfach nur das Fehlen eines Werts“.

Sentinel-Werte statt null: warum sie gefährlich sind

Manchmal sagen Entwickler: „Ich mag null nicht, nehmen wir stattdessen -1“. Einen solchen Wert nennt man Sentinel: eine spezielle Zahl/ein spezieller String, der „da ist nichts“ bedeutet.

Das kann funktionieren, aber es gibt ein Risiko: Sie fügen dem Code eine versteckte Vereinbarung hinzu. Wenn Sie in einer Stelle vergessen, -1 zu prüfen, bricht die Logik, aber der Compiler hilft Ihnen nicht, weil der Typ immer noch Int ist.

Ein kleines Beispiel, bei dem der Fehler „anständig“ aussieht, aber die Bedeutung bereits falsch ist:

fun parseAmountOrMinusOne(raw: String): Int {
    return raw.trim().toIntOrNull() ?: -1
}

fun main() {
    val amount = parseAmountOrMinusOne("oops")
    println(amount + 100) // 99  (und das sieht so aus, als wäre alles normal)
}

Deshalb fördert Kotlin Nullable: Int? zwingt Sie, den Fall „kein Wert“ explizit zu behandeln, während -1 es erlaubt, ihn aus Versehen zu vergessen.

Das heißt nicht, dass Sentinel-Werte immer böse sind. Es heißt, dass wenn Sie sie verwenden, müssen Sie es zu einem Vertrag machen, nicht zu einem „stillen Trick“.

Ein einziges „Gateway“ zur Verarbeitung statt null zu verschmieren

In gutem Code sieht man oft den Stil: null lebt an einer Stelle, nahe an Eingabe oder Suche, und wird danach entweder in eine konkrete Aktion verwandelt oder abgeschnitten. In schlechtem Code sickert null durch alle Ebenen: Domänenmodell, Reports, Formatierung, Sortierungen.

Schematisch sieht das so aus:

flowchart LR
    A["External world (input, search, data)"] -->|can be null| B["Gateway: normalization/validation"]
    B -->|non-null| C["Internal logic (model, calculations)"]
    B -->|no value| D["Clear response (message/branch/exit)"]

Die Idee ist, dass Sie im Code weniger Stellen haben, an denen Sie jedes Mal neu erfinden „was tun mit null?“. Lassen Sie das an einer klaren Stelle lösen.

Zum Beispiel beim Parsen eines Kommandos:

data class AddExpenseCommand(val title: String, val amount: Int)

fun parseAddCommand(line: String): AddExpenseCommand? {
    val parts = line.trim().split(" ")
    if (parts.size < 3) return null

    val title = parts[1].trim().ifEmpty { return null }
    val amount = parts[2].trim().toIntOrNull() ?: return null

    return AddExpenseCommand(title = title, amount = amount)
}

fun main() {
    println(parseAddCommand("add coffee 300")) // AddExpenseCommand(title=coffee, amount=300)
    println(parseAddCommand("add coffee oops")) // null
}

Hier bedeutet null etwas Konkretes: „der Befehl ist ungültig“. Und wichtig ist, dass null nicht weiter durchsickert. Wir haben nicht AddExpenseCommand(title: String?, amount: Int?) gebaut. Entweder geben wir einen normalen Command zurück oder null als Signal „nicht erkannt“.

Nullable-Receiver in Extension-Funktionen: Prüfungen lokalisieren

Manchmal möchte man eine kleine Utility schreiben, die sogar dann funktioniert, wenn der Wert null ist. Kotlin erlaubt Extension-Funktionen auf einem Nullable-Typ (Any?, String? usw.). Das ist nützlich, wenn Sie eine wiederholte Prüfung an einer Stelle verstecken möchten, statt sie bei jedem Aufruf zu schreiben. In der offiziellen Kotlin-Dokumentation wird das ebenfalls gezeigt: Eine Extension auf Any? kann den String "null" zurückgeben, statt abzustürzen, und innerhalb des Bodys versteht der Compiler nach der Prüfung this == null bereits, dass this nicht null ist.

Für unsere Anwendung kann man etwas ganz Einfaches bauen — eine sichere Textdarstellung:

fun String?.toUiText(): String {
    return this?.trim()?.takeIf { it.isNotEmpty() } ?: "<empty>"
}

fun main() {
    val a: String? = "  hello "
    val b: String? = "   "
    val c: String? = null

    println(a.toUiText()) // hello
    println(b.toUiText()) // <empty>
    println(c.toUiText()) // <empty>
}

Beachten Sie die feine Grenze: Das ist im UI/Output passend, aber nicht immer passend in Berechnungen und Datenspeicherung. Innerhalb des Modells wäre "<empty>" ein merkwürdiger „Sentinel“, aber für die Ausgabe in der Konsole ist es eine normale, bewusste Wahl.

6. Typische Fehler bei der Arbeit mit null

Fehler Nr. 1: Modellfelder „zur Sicherheit“ nullable machen.
Meist passiert das aus dem Wunsch heraus, „schneller ein Objekt zu erstellen“. Am Ende wird das Objekt leicht zu erstellen, aber schwer zu verwenden: An jeder Stelle muss man an ?. denken und sich ausdenken, was mit dem Fehlen zu tun ist. Das ist in der Regel ein Zeichen dafür, dass Sie in einem Typ „rohe Eingabe“ und „valide Entität“ vermischt haben.

Fehler Nr. 2: null sowohl für „nicht gefunden“ als auch für „Fehler“ zurückgeben.
Ein solcher Vertrag zwingt den aufrufenden Code zum Raten, d. h. er wird entweder alles „still reparieren“ über ?:, oder er wird mit !! abstürzen, oder er wird einfach "UNKNOWN" ausgeben und die Daten verderben. Wenn Sie sich bei dem Satz ertappen „geben wir null zurück, wenn etwas schiefgelaufen ist“, ist das fast immer ein Signal, dass der Vertrag nebulös ist.

Fehler Nr. 3: Null-Vertragsdesign mit dem Operator !! heilen.
!! ist manchmal nötig, aber in echten Projekten wird es oft zu „ich bin müde zu denken“. Das Problem ist, dass Sie die Bedeutungsfrage nicht gelöst haben: Sie haben den Absturz nur ins Runtime verschoben. Im Kontext „nullable als Vertrag“ sieht das so aus: „Der Vertrag sagt, dass es den Wert möglicherweise nicht gibt, aber ich tue so, als gäbe es ihn“.

Fehler Nr. 4: null ohne strikte Vereinbarung durch Sentinel-Werte ersetzen (0, -1, "UNKNOWN").
Wenn Sie einen „Default“-Wert einsetzen, ändern Sie die Bedeutung der Daten. Manchmal ist das passend (insbesondere für UI), aber wenn das ohne einheitliche Policy und ohne Verständnis der Domäne passiert, bekommen Sie stille Logikfehler: Summen sind „fast korrekt“, Reports sehen „fast normal“ aus, und der Benutzer fragt später, warum er eine Ausgabe "UNKNOWN: 0" hat.

Fehler Nr. 5: null über die Pipeline verschmieren statt eines einzigen Gateways zur Verarbeitung.
Wenn null durch 5–10 Funktionen geht, verlieren Sie die Kontrolle: Wo genau ist es entstanden und was bedeutet es? Viel robuster ist es, wenn null nahe an der Quelle (Eingabe/Suche) lebt und schnell in eine verständliche Entscheidung verwandelt wird: „nochmal fragen“, „nicht gefunden“, „Befehl ungültig“.

1
Aufgabe
Kotlin SELF, Level 41, Lektion 0
Gesperrt
Sauberer Kommentar
Sauberer Kommentar
1
Aufgabe
Kotlin SELF, Level 41, Lektion 0
Gesperrt
Parsen einer positiven Ganzzahl ohne “Fehler als null”
Parsen einer positiven Ganzzahl ohne “Fehler als null”
1
Aufgabe
Kotlin SELF, Level 41, Lektion 0
Gesperrt
AUFGABE 03: Suche in einer Map als „kann fehlen“
AUFGABE 03: Suche in einer Map als „kann fehlen“
1
Aufgabe
Kotlin SELF, Level 41, Lektion 0
Gesperrt
TASK 04: Extension auf nullable‑Receiver für „Text in der UI“
TASK 04: Extension auf nullable‑Receiver für „Text in der UI“
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION