Kapitel 4. Optional zu löschbar

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Tony Hoare mag die Erfindung von Null-Referenzen als seinen Milliarden-Dollar-Fehler betrachten ,1 aber wir müssen immer noch die Abwesenheit von Dingen in unseren Softwaresystemen darstellen. Wie können wir Kotlin nutzen, um Null zu nutzen und trotzdem sichere Software zu haben?

Abwesenheit repräsentieren

Die vielleicht attraktivste Eigenschaft von Kotlin für Java-Programmierer ist die Darstellung der Nullbarkeit im Typsystem. Dies ist ein weiterer Bereich, in dem sich die Körner von Java und Kotlin unterscheiden.

Vor Java 8 verließ sich Java auf Konventionen, Dokumentation und Intuition, um zwischen Referenzen zu unterscheiden, die null sein können oder nicht. Wir können ableiten, dass Methoden, die ein Element aus einer Sammlung zurückgeben, in der Lage sein müssen, null zurückzugeben, aber kann addressLine3 null sein, oder verwenden wir eine leere Zeichenkette, wenn es keine Informationen gibt?

Im Laufe der Jahre haben sich deine Autoren und ihre Kollegen auf eine Konvention geeinigt, bei der davon ausgegangen wird, dass Java-Referenzen nicht null sind, es sei denn, sie sind anders gekennzeichnet. Wir könnten also ein Feld addressLine3OrNull oder eine Methode previousAddressOrNull nennen. Innerhalb einer Codebasis funktioniert das gut genug (auch wenn es ein wenig umständlich ist und ewige Wachsamkeit erfordert, um die Geißel NullPointerExceptionzu vermeiden).

Einige Codebasen entschieden sich stattdessen für die Verwendung von @Nullable und @NotNullable Annotationen, die oft von Tools unterstützt wurden, die die Korrektheit überprüfen. Java 8, das 2014 veröffentlicht wurde, verbesserte die Unterstützung für Annotationen in einem Maße, dass Tools wie das Checker Framework viel mehr als nur die Nullsicherheit statisch überprüfen können. Noch wichtiger ist jedoch, dass Java 8 auch einen Standardtyp Optional einführte.

Bis hatten sich viele JVM-Entwickler mit Scala beschäftigt. Sie erkannten die Vorteile der Verwendung eines Optional-Typs (in der Scala-Standardbibliothek Option genannt), wenn die Abwesenheit möglich war, und einfacher Referenzen, wenn dies nicht möglich war. Oracle verwirrte das Wasser, indem es die Entwickler aufforderte, Optional nicht für Feld- oder Parameterwerte zu verwenden, aber wie bei vielen Funktionen, die in Java 8 eingeführt wurden, war es gut genug und wurde in den Mainstream von Java übernommen.

Je nach Alter wird dein Java-Code einige oder alle dieser Strategien für den Umgang mit Abwesenheit verwenden. Es ist sicherlich möglich, eine Codebasis zu haben, in der Null⁠Pointer​Excep⁠tionpraktisch nie zu sehen ist, aber die Realität ist, dass dies harte Arbeit ist. Java wird durch null belastet und durch seinen halbherzigen Optional Typ beschämt.

Im Gegensatz dazu setzt Kotlin auf Null. Dadurch, dass die Optionalität Teil des Typsystems und nicht der Standardbibliothek ist, ist der Umgang mit fehlenden Werten in Kotlin-Codebasen erfrischend einheitlich. Es ist nicht alles perfekt: Map<K, V>.get(key) gibt null zurück, wenn es keinen Wert für key gibt, aber List<T>.get(index) wirft IndexOutOfBoundsException, wenn es keinen Wert für index gibt. Ebenso wirft Iterable<T>.first() No⁠Such​Ele⁠ment⁠Exception , anstatt null zurückzugeben. Solche Unzulänglichkeiten sind in der Regel auf den Wunsch nach Abwärtskompatibilität mit Java zurückzuführen.

Kotlin hat eigene APIs, die in der Regel gute Beispiele für die sichere Verwendung von Null zur Darstellung von optionalen Eigenschaften, Parametern und Rückgabewerten sind und von denen wir eine Menge lernen können. Wenn du einmal die Erfahrung mit der erstklassigen Nullbarkeit gemacht hast, fühlt sich die Rückkehr zu Sprachen ohne diese Unterstützung unsicher an; du bist dir bewusst, dass du immer nur eine Dereferenzierung von NullPointerException entfernt bist und dass du dich auf Konventionen verlässt, um den sicheren Weg durch das Minenfeld zu finden.

Funktionale Programmierer von raten dir vielleicht, in Kotlin einen optionalen Typ (auch Maybe genannt) anstelle der Nullbarkeit zu verwenden. Wir raten dir davon ab, auch wenn du damit die gleichen (monadischen - da, wir haben es gesagt) Werkzeuge zur Darstellung von potenzieller Abwesenheit, Fehlern, Asynchronität usw. nutzen kannst. Ein Grund, warum du Optional in Kotlin nicht verwenden solltest, ist, dass du dadurch den Zugang zu den Sprachfunktionen verlierst, die speziell für die Unterstützung der Nullbarkeit entwickelt wurden; in diesem Bereich unterscheidet sich Kotlin von Scala.

Ein weiterer Grund, keinen Wrapper-Typ zu verwenden, um Optionalität darzustellen, ist subtil, aber wichtig. Im Kotlin-Typsystem ist T ein Untertyp von T?. Wenn du einen String hast, der nicht null sein kann, kannst du ihn immer dort verwenden, wo ein nullable String benötigt wird. T ist dagegen kein Untertyp von Optional<T>. Wenn du einen String hast und ihn einer optionalen Variablen zuweisen willst, musst du ihn zuerst in einen Optional einpacken. Schlimmer noch: Wenn du eine Funktion hast, die ein Optional<String> zurückgibt, und später eine Möglichkeit entdeckst, immer ein Ergebnis zurückzugeben, wird das Ändern des Rückgabetyps in String alle deine Clients kaputt machen. Wäre dein Rückgabetyp das löschbare String? gewesen, hättest du ihn unter Beibehaltung der Kompatibilität in String umwandeln können. Dasselbe gilt für Eigenschaften von Datenstrukturen: Du kannst leicht von optional zu nicht-optional mit Löschbarkeit wechseln - aber ironischerweise nicht mit Optional.

Deine Autoren lieben Kotlins Unterstützung für Nullen und haben gelernt, sich darauf zu stützen, um viele Probleme zu lösen. Es dauert eine Weile, bis man sich das Vermeiden von Nullen abgewöhnt hat, aber wenn man es geschafft hat, gibt es buchstäblich eine ganz neue Dimension von Ausdrucksmöglichkeiten, die man erforschen und ausnutzen kann.

Es ist schade, dass Travelator diese Möglichkeit nicht bietet, also schauen wir uns an, wie wir von Java-Code mit Optional zu Kotlin und nullable migrieren können.

Refactoring von Optional zu Nullable

Die Reisen sind in Legs unterteilt, wobei jede Leg eine ununterbrochene Reise ist. Hier ist eine der Hilfsfunktionen, die wir im Code gefunden haben:

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        Leg result = null;
        for (Leg leg : legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.getPlannedDuration())
                ) {
                    result = leg;
                }
        }
        return Optional.ofNullable(result);
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

Die Tests überprüfen, ob der Code wie vorgesehen funktioniert, und ermöglichen es uns, sein Verhalten auf einen Blick zu sehen:

public class LongestLegOverTests {

    private final List<Leg> legs = List.of(
        leg("one hour", Duration.ofHours(1)),
        leg("one day", Duration.ofDays(1)),
        leg("two hours", Duration.ofHours(2))
    );
    private final Duration oneDay = Duration.ofDays(1);

    @Test
    public void is_absent_when_no_legs() {
        assertEquals(
            Optional.empty(),
            findLongestLegOver(emptyList(), Duration.ZERO)
        );
    }

    @Test
    public void is_absent_when_no_legs_long_enough() {
        assertEquals(
            Optional.empty(),
            findLongestLegOver(legs, oneDay)
        );
    }

    @Test
    public void is_longest_leg_when_one_match() {
        assertEquals(
            "one day",
            findLongestLegOver(legs, oneDay.minusMillis(1))
                .orElseThrow().getDescription()
        );
    }

    @Test
    public void is_longest_leg_when_more_than_one_match() {
        assertEquals(
            "one day",
            findLongestLegOver(legs, Duration.ofMinutes(59))
                .orElseThrow().getDescription()
        );
    }

    ...
}

Mal sehen, was wir tun können, um die Dinge in Kotlin besser zu machen. Wenn wir Legs.java in Kotlin umwandeln, erhalten wir (nach einer kleinen Umformatierung) Folgendes:

object Legs {
    @JvmStatic
    fun findLongestLegOver(
        legs: List<Leg>,
        duration: Duration
    ): Optional<Leg> {
        var result: Leg? = null
        for (leg in legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.plannedDuration))
                    result = leg
        }
        return Optional.ofNullable(result)
    }

    private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
        return leg.plannedDuration.compareTo(duration) > 0
    }
}

Die Methodenparameter sind so, wie man es erwarten würde, wobei Kotlin List<Leg> transparent ein java.util.List akzeptiert. (Wir werden Java- und Kotlin-Sammlungen in Kapitel 6 genauer untersuchen.) Es ist erwähnenswert, dass der Compiler eine Nullprüfung vor dem Funktionskörper einfügt, wenn eine Kotlin-Funktion einen nicht-nullbaren Parameter deklariert (hierlegs und duration ). Auf diese Weise wissen wir sofort, wenn Java-Aufrufer eine null einschleusen. Aufgrund dieser defensiven Prüfungen erkennt Kotlin unerwartete Nullen so nah wie möglich an der Quelle, im Gegensatz zu Java, wo eine Referenz auf null gesetzt werden kann, die zeitlich und räumlich weit von dem Ort entfernt ist, an dem sie schließlich explodiert.

Um auf das Beispiel zurückzukommen: Die Schleife in Kotlin for ist der in Java sehr ähnlich, mit der Ausnahme, dass das Schlüsselwort in anstelle von : verwendet wird, und gilt für jeden Typ, der Iterable erweitert.

Der umgewandelte Code von findLongestLegOver ist nicht sehr idiomatisch für Kotlin. (Seit der Einführung von Streams ist er wohl auch nicht sehr idiomatisch für Java.) Statt einer for Schleife sollten wir nach etwas suchen, das mehr Absicht verrät, aber lassen wir das erst einmal, denn unsere Hauptaufgabe ist es, von Optional auf nullable zu migrieren. Wir werden das veranschaulichen, indem wir unsere Tests einen nach dem anderen umwandeln, so dass wir eine Mischung haben, wie in einerCodebasis, die wir migrieren. Um die Nullability in unseren Clients zu nutzen, müssen sie in Kotlin sein, also konvertieren wir die Tests:

class LongestLegOverTests {
    ...
    @Test
    fun is_absent_when_no_legs() {
        Assertions.assertEquals(
            Optional.empty<Any>(),
            findLongestLegOver(emptyList(), Duration.ZERO)
        )
    }

    @Test
    fun is_absent_when_no_legs_long_enough() {
        Assertions.assertEquals(
            Optional.empty<Any>(),
            findLongestLegOver(legs, oneDay)
        )
    }

    @Test
    fun is_longest_leg_when_one_match() {
        Assertions.assertEquals(
            "one day",
            findLongestLegOver(legs, oneDay.minusMillis(1))
                .orElseThrow().description
        )
    }

    @Test
    fun is_longest_leg_when_more_than_one_match() {
        Assertions.assertEquals(
            "one day",
            findLongestLegOver(legs, Duration.ofMinutes(59))
                .orElseThrow().description
        )
    }

    ...
}

Um nun schrittweise zu migrieren, brauchen wir zwei Versionen von findLongestLegOver: die bestehende, die Optional<Leg> zurückgibt, und eine neue, die Leg? zurückgibt. Das können wir erreichen, indem wir die Eingeweide der aktuellen Implementierung extrahieren. Das ist derzeit:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return Optional.ofNullable(result)
}

Wir "extrahieren die Funktion" an allen Stellen außer der Return-Anweisung dieser findLongestLegOver. Wir können ihr nicht denselben Namen geben, also verwenden wir longestLegOver; wir machen sie öffentlich, weil dies unsere neue Schnittstelle ist:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    var result: Leg? = longestLegOver(legs, duration)
    return Optional.ofNullable(result)
}

fun longestLegOver(legs: List<Leg>, duration: Duration): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return result
}

Das Refactoring hat eine rudimentäre result Variable in findLongestLegOver hinterlassen. Wir können sie auswählen und "Inline" geben:

@JvmStatic
fun findLongestLegOver(
    legs: List<Leg>,
    duration: Duration
): Optional<Leg> {
    return Optional.ofNullable(longestLegOver(legs, duration))
}

Jetzt haben wir zwei Versionen unserer Schnittstelle, von denen eine in Bezug auf die andere definiert ist. Wir können unsere Java-Clients die Optional von findLongestLegOver konsumieren lassen und unsere Kotlin-Clients so konvertieren, dass sie die nullable-returning longestLegOver aufrufen. Zeigen wir die Konvertierung mit unseren Tests.

Wir nehmen uns zuerst die Abwesenden vor. Sie rufen derzeit assert⁠Equals​(Optional.empty<Any>(), findLongestLegOver…) auf:

@Test
fun is_absent_when_no_legs() {
    assertEquals(
        Optional.empty<Any>(),
        findLongestLegOver(emptyList(), Duration.ZERO)
    )
}

@Test
fun is_absent_when_no_legs_long_enough() {
    assertEquals(
        Optional.empty<Any>(),
        findLongestLegOver(legs, oneDay)
    )
}

Also ändern wir sie in assertNull(longestLegOver(...):

@Test
fun `is absent when no legs`() {
    assertNull(longestLegOver(emptyList(), Duration.ZERO))
}

@Test
fun `is absent when no legs long enough`() {
    assertNull(longestLegOver(legs, oneDay))
}

Beachte, dass wir die Testnamen so geändert haben, dass sie Bezeichner in Backtick-Anführungszeichen verwenden. IntelliJ macht das für uns, wenn wir Alt-Enter auf function_names with_underscores_in_tests drücken.

Nun zu den Aufrufen, die nicht leer zurückkommen:

@Test
fun is_longest_leg_when_one_match() {
    assertEquals(
        "one day",
        findLongestLegOver(legs, oneDay.minusMillis(1))
            .orElseThrow().description
    )
}

@Test
fun is_longest_leg_when_more_than_one_match() {
    assertEquals(
        "one day",
        findLongestLegOver(legs, Duration.ofMinutes(59))
            .orElseThrow().description
    )
}

Das Kotlin-Äquivalent zu Optional.orElseThrow() (auch bekannt als get() vor Java 10) istder !! (bang-bang oder dammit) Operator. Sowohl der Java orElseThrow als auch der Kotlin !! geben den Wert zurück oder werfen eine Ausnahme, wenn es keine gibt. Kotlin wirft logischerweise eine NullPointerException. Java wirft ebenso logischerweise eine NoSuchElementExecption; sie denken nur anders über Abwesenheit! Vorausgesetzt, wir haben uns nicht auf den Typ der Ausnahme verlassen, können wir findLongestLegOver(...).orElseThrow() durch longestLegOver(...)!! ersetzen:

@Test
fun `is longest leg when one match`() {
    assertEquals(
        "one day",
        longestLegOver(legs, oneDay.minusMillis(1))
            !!.description
    )
}

@Test
fun `is longest leg when more than one match`() {
    assertEquals(
        "one day",
        longestLegOver(legs, Duration.ofMinutes(59))
            ?.description
    )
}

Wir haben den ersten der Tests ohne Nullrückgabe (is longest leg when one match) mit dem !! Operator konvertiert. Wenn er fehlschlagen würde (was er nicht tut, aber wir planen diese Dinge gerne ein), würde er mit einem geworfenen NullPointerException statt mit einer schönen Diagnose fehlschlagen. Im zweiten Fall haben wir dieses Problem mit dem sicheren Aufrufoperator ?. gelöst, der die Auswertung nur dann fortsetzt, wenn sein Empfänger nicht null ist. Das bedeutet, dass die Fehlermeldung folgendermaßen lautet, wenn das Bein nullist, was viel schöner ist:

Expected :one day
Actual   :null

Prüfungen sind einer der wenigen Orte, an denen wir !! in der Praxis verwenden, und selbst hier gibt es meist eine bessere Alternative.

Wir können dieses Refactoring durch unsere Clients durchführen, indem wir sie in Kotlin konvertieren und dann longestLegOver verwenden. Sobald wir alle konvertiert haben, können wir Optional löschen und findLongestLegOver zurückgeben.

Refactoring zu idiomatischem Kotlin

der gesamte Code in diesem Beispiel ist Kotlin und wir haben gesehen, wie man von optional zu nullable migriert. Wir könnten an dieser Stelle aufhören, aber gemäß unserem Grundsatz, die Extrameile beim Refactoring zu gehen, machen wir weiter, um zu sehen, was uns dieser Code noch lehrt.

Hier ist die aktuelle Version von Legs:

object Legs {
    fun longestLegOver(
        legs: List<Leg>,
        duration: Duration
    ): Leg? {
        var result: Leg? = null
        for (leg in legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.plannedDuration))
                    result = leg
        }
        return result
    }

    private fun isLongerThan(leg: Leg, duration: Duration): Boolean {
        return leg.plannedDuration.compareTo(duration) > 0
    }
}

Die Funktionen sind in object enthalten, weil unsere Java-Methoden statisch waren, so dass die Konvertierung einen Platz für sie brauchte. Wie wir in Kapitel 8 sehen werden, braucht Kotlin diese zusätzliche Ebene des Namensraums nicht, so dass wir "Move to top level" auf longestLegOver wählen können. Zum Zeitpunkt der Erstellung dieses Artikels funktioniert dies nicht sehr gut, weil IntelliJ fehlschlägt, um die FunktionisLongerThan mit der aufrufenden Funktion zu verbinden, so dass sie in Legs verbleibt. Dieser Fehler lässt sich jedoch leicht beheben, so dass wir eine Funktion der obersten Ebene und korrigierte Referenzen im bestehenden Code haben:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (isLongerThan(leg, duration))
            if (result == null ||
                isLongerThan(leg, result.plannedDuration))
                result = leg
    }
    return result
}

private fun isLongerThan(leg: Leg, duration: Duration) =
    leg.plannedDuration.compareTo(duration) > 0

Du hast vielleicht bemerkt, dass isLongerThan seine geschweiften Klammern und die Return-Anweisung verloren hat. Wir werden in Kapitel 9 über die Vor- und Nachteile von Funktionen mit einem Ausdruck sprechen.

Wo wir schon mal hier sind, der Ausdruck isLongerThan(leg, ...) hat etwas Seltsames an sich. Zweifellos wird dich unsere Verliebtheit in Erweiterungsfunktionen langweilen (spätestens am Ende von Kapitel 10), aber solange wir noch dein Wohlwollen haben, drücken wir die Alt-Eingabe für den Parameter leg und "Convert parameter to receiver", damit wir leg.isLongerThan(...) schreiben können:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    var result: Leg? = null
    for (leg in legs) {
        if (leg.isLongerThan(duration))
            if (result == null ||
                leg.isLongerThan(result.plannedDuration))
                result = leg
    }
    return result
}

private fun Leg.isLongerThan(duration: Duration) =
    plannedDuration.compareTo(duration) > 0

Bisher waren alle unsere Änderungen strukturell, d.h. wir haben geändert, wo der Code definiert ist und wie wir ihn aufrufen. Strukturelle Refactors sind von Natur aus ziemlich (d.h. meistens, nicht vollständig) sicher. Sie können das Verhalten von Code ändern, der auf Polymorphismus (entweder durch Methoden oder Funktionen) oder Reflexion beruht, aber ansonsten, wenn der Code weiterhin kompiliert werden kann, verhält er sich wahrscheinlich.

Jetzt wenden wir uns dem Algorithmus in longestLegOver zu. Das Refactoring von Algorithmen ist gefährlicher, vor allem von solchen, die auf Mutation beruhen, weil die Tools sie nicht gut unterstützen. Wir haben aber gute Tests, und es ist schwer herauszufinden, was der Algorithmus macht, wenn wir ihn lesen, also schauen wir mal, was wir tun können.

Der einzige Vorschlag, den IntelliJ macht, ist, compareTo durch > zu ersetzen. An diesem Punkt hat zumindest Duncan kein Talent mehr zum Refactoring (wenn wir tatsächlich ein Paar wären, hättest du vielleicht einen Vorschlag?) und beschließt, die Funktion von Grund auf neu zu schreiben.

Um die Funktionalität neu zu implementieren, fragen wir uns: "Was will der Code erreichen?" Die Antwort liegt hilfreicherweise im Namen der Funktion: longestLegOver. Um diese Berechnung zu implementieren, können wir den längsten Schenkel finden und, wenn er länger als die Dauer ist, zurückgeben, andernfalls null. Nachdem wir legs. am Anfang der Funktion eingegeben haben, schauen wir uns die Vorschläge an und finden maxByOrNull. Unsere längste Strecke wird legs.max⁠By​Or⁠Null(Leg::plannedDuration) sein. Die API gibt Leg? zurück (und fügt den Ausdruck orNull hinzu), um uns daran zu erinnern, dass sie kein Ergebnis liefern kann, wenn legs leer ist. Wenn wir unseren Algorithmus "Finde die längste Strecke, und wenn sie länger als die Dauer ist, gib sie zurück, andernfalls null" direkt in Code umwandeln, erhalten wir:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
    if (longestLeg != null && longestLeg.plannedDuration > duration)
        return longestLeg
    else
        return null
}

Das besteht die Tests, aber die mehrfachen Rückgaben sind hässlich. IntelliJ bietet hilfreicherweise an, die return aus der if zu entfernen:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg: Leg? = legs.maxByOrNull(Leg::plannedDuration)
    return if (longestLeg != null && longestLeg.plannedDuration > duration)
        longestLeg
    else
        null
}

Kotlins Unterstützung für Nullbarkeit bietet verschiedene Möglichkeiten, dies zu ändern, je nachdem, was du willst.

Wir können den Elvis-Operator ?: verwenden, der die linke Seite auswertet, es sei denn, es handelt sich um null. In diesem Fall wird die rechte Seite ausgewertet. So können wir vorzeitig zurückkehren, wenn wir keine längste Strecke haben:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg = legs.maxByOrNull(Leg::plannedDuration) ?:
        return null
    return if (longestLeg.plannedDuration > duration)
        longestLeg
    else
        null
}

Wir könnten mit einem einzigen ?.let Ausdruck gehen. Der ?. wertet null aus, wenn er mit einem null gefüttert wird; andernfalls leitet er die längste Strecke für uns in den let Block:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? =
    legs.maxByOrNull(Leg::plannedDuration)?.let { longestLeg ->
        if (longestLeg.plannedDuration > duration)
            longestLeg
        else
            null
    }

Innerhalb der let kann longestLeg also nicht null sein. Das ist kurz und bündig, aber auf den ersten Blick schwer zu verstehen. Die Optionen mit when zu buchstabieren, ist klarer:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? {
    val longestLeg = legs.maxByOrNull(Leg::plannedDuration)
    return when {
        longestLeg == null -> null
        longestLeg.plannedDuration > duration -> longestLeg
        else -> null
    }
}

Um das Ganze weiter zu vereinfachen, brauchen wir einen Trick, den Duncan (der das hier schreibt) bisher noch nicht verinnerlicht hat:takeIf gibt seinen Empfänger zurück, wenn ein Prädikat true ist; andernfalls gibt er null zurück. Das ist genau die Logik unseres vorherigen let -Blocks. Wir können also schreiben:

fun longestLegOver(
    legs: List<Leg>,
    duration: Duration
): Leg? =
    legs.maxByOrNull(Leg::plannedDuration)?.takeIf { longestLeg ->
        longestLeg.plannedDuration > duration
    }

Je nachdem, wie viel Erfahrung unser Team mit Kotlin hat, könnte das zu subtil sein. Nat meint, dass es in Ordnung ist, aber wir werden uns auf die Seite der Eindeutigkeit schlagen, also bleibt die Version von when bestehen, zumindest bis zum nächsten Refactoring hier.

Zum Schluss wandeln wir den Parameter legs in den Empfänger in einer Erweiterungsfunktion um. So können wir die Funktion in etwas weniger Fragwürdiges umbenennen:

fun List<Leg>.longestOver(duration: Duration): Leg? {
    val longestLeg = maxByOrNull(Leg::plannedDuration)
    return when {
        longestLeg == null -> null
        longestLeg.plannedDuration > duration -> longestLeg
        else -> null
    }
}

Bevor wir dieses Kapitel beenden, nimm dir die Zeit, diese Version mit dem Original zu vergleichen. Gibt es irgendwelche Vorteile der alten Version?

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        Leg result = null;
        for (Leg leg : legs) {
            if (isLongerThan(leg, duration))
                if (result == null ||
                    isLongerThan(leg, result.getPlannedDuration())
                ) {
                    result = leg;
                }
        }
        return Optional.ofNullable(result);
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

Normalerweise würden wir sagen "es kommt darauf an", aber in diesem Fall sind wir der Meinung, dass die neue Version in fast jeder Hinsicht besser ist. Sie ist kürzer und einfacher; es ist leichter zu erkennen, wie sie funktioniert; und in den meisten Fällen führt sie zu weniger Aufrufen von getPlannedDuration(), was eine relativ teure Operation ist. Was wäre, wenn wir den gleichen Ansatz in Java gewählt hätten? Eine direkte Übersetzung ist:

public class Legs {

    public static Optional<Leg> findLongestLegOver(
        List<Leg> legs,
        Duration duration
    ) {
        var longestLeg = legs.stream()
            .max(Comparator.comparing(Leg::getPlannedDuration));
        if (longestLeg.isEmpty()) {
            return Optional.empty();
        } else if (isLongerThan(longestLeg.get(), duration)) {
            return longestLeg;
        } else {
            return Optional.empty();
        }
    }

    private static boolean isLongerThan(Leg leg, Duration duration) {
        return leg.getPlannedDuration().compareTo(duration) > 0;
    }
}

Eigentlich ist das nicht schlecht, aber im Vergleich zur Kotlin-Version kannst du sehen, wie Optional so ziemlich jeder Zeile der Methode Rauschen hinzufügt. Deshalb ist eine Version, die Optional.filter verwendet, wahrscheinlich vorzuziehen, auch wenn sie unter denselben Verständnisproblemen leidet wie die Kotlin-Version takeIf. Das heißt, Duncan kann nicht sagen, dass sie funktioniert, ohne die Tests auszuführen, aber Nat bevorzugt sie.

public static Optional<Leg> findLongestLegOver(
    List<Leg> legs,
    Duration duration
) {
    return legs.stream()
        .max(Comparator.comparing(Leg::getPlannedDuration))
        .filter(leg -> isLongerThan(leg, duration));
}

Weiterziehen

Die Abwesenheit oder Anwesenheit von Informationen ist in unserem Code unausweichlich. Indem Kotlin sie in den Status der ersten Klasse erhebt, stellt es sicher, dass wir die Abwesenheit berücksichtigen, wenn wir es müssen, und nicht davon überwältigt werden, wenn wir es nicht müssen. Im Vergleich dazu fühlt sich Javas Optional Typ unbeholfen an. Glücklicherweise können wir leicht von Optional zu nullable migrieren und beides gleichzeitig unterstützen, wenn wir noch nicht bereit sind, unseren gesamten Code auf Kotlin umzustellen.

In Kapitel 10, Funktionen zu Erweiterungsfunktionen, werden wir sehen, wie nullable Typen mit anderen Kotlin-Sprachmerkmalen - den sicheren Aufruf- und Elvis-Operatoren und Erweiterungsfunktionen - kombiniert werden, um ein Design zu bilden, das sich von dem in Java unterscheidet.

Im nächsten Kapitel schauen wir uns eine typische Java-Klasse an und übersetzen sie in eine typische Kotlin-Klasse. Die Übersetzung von Java nach Kotlin ist mehr als nur syntaktisch: Die beiden Sprachen unterscheiden sich in ihrer Akzeptanz von veränderbaren Zuständen.

1 "Null-Referenzen: The Billion Dollar Mistake" auf YouTube.

Get Von Java zu Kotlin now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.