Kapitel 1. Grundlagen von Kotlin

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

Kotlin wurde vom JetBrains-Team aus St. Petersburg, Russland, entwickelt. JetBrains ist vielleicht am bekanntesten für die IntelliJ Idea IDE, die Grundlage für Android Studio. Kotlin wird mittlerweile in einer Vielzahl von Umgebungen auf verschiedenen Betriebssystemen eingesetzt. Es ist fast fünf Jahre her, dass Google die Unterstützung für Kotlin auf Android angekündigt hat. Laut dem Android Developers Blog verwenden im Jahr 2021 über 1,2 Millionen Apps im Google Play Store Kotlin, darunter 80 % der Top 1.000 Apps.

Wenn du dieses Buch in die Hand nimmst, gehen wir davon aus, dass du bereits ein Android-Entwickler bist und dich mit Java auskennst.

Kotlin wurde entwickelt, um mit Java zu interagieren. Sogar sein Name, der von einer Insel in der Nähe von St. Petersburg stammt, ist eine Anspielung auf Java, eine Insel in Indonesien. Obwohl Kotlin auch andere Plattformen unterstützt (iOS, WebAssembly, Kotlin/JS usw.), ist die Unterstützung der Java Virtual Machine (JVM) der Schlüssel für die breite Nutzung von Kotlin. Da Kotlin zu Java-Bytecode kompiliert werden kann, kann es überall laufen, wo eine JVM läuft.

In diesem Kapitel wird ein Großteil der Diskussion Kotlin mit Java vergleichen. Es ist jedoch wichtig zu verstehen, dass Kotlin nicht einfach nur eine aufgewärmte Version von Java mit ein paar zusätzlichen Extras ist. Kotlin ist eine neue und andersartige Sprache, deren Verbindungen zu Scala, Swift und C# fast so stark sind wie die zu Java. Sie hat ihren eigenen Stil und ihre eigenen Idiome. Es ist zwar möglich, in Java zu denken und Kotlin zu schreiben, aber wenn du in der Idiomatik von Kotlin denkst, kannst du die ganze Kraft der Sprache entfalten.

Wir wissen, dass es einige Android-Entwickler gibt, die schon seit einiger Zeit mit Kotlin arbeiten und noch nie Java geschrieben haben. Wenn das auf dich zutrifft, kannst du dieses Kapitel und den Überblick über die Sprache Kotlin vielleicht überfliegen. Aber auch wenn du dich mit der Sprache gut auskennst, ist dies eine gute Gelegenheit, um dich an einige Details zu erinnern.

Dieses Kapitel soll keine vollständige Einführung in Kotlin sein. Wenn du also völlig neu in Kotlin bist, empfehlen wir dir das ausgezeichnete Kotlin in Action.1 Stattdessen ist dieses Kapitel ein Überblick über einige Grundlagen von Kotlin: das Typsystem, Variablen, Funktionen und Klassen. Auch wenn du kein Kotlin-Experte bist, sollte es dir genug Grundlagen bieten, um den Rest des Buches zu verstehen.

Wie bei allen statisch typisierten Sprachen ist das Typsystem von Kotlin die Metasprache, die Kotlin verwendet, um sich selbst zu beschreiben. Da es ein wesentlicher Aspekt für die Diskussion über Kotlin ist, fangen wir damit an, es zu besprechen.

Das Kotlin Typensystem

Wie Java ist auch Kotlin eine statisch typisierte Sprache. Der Kotlin-Compiler kennt den Typ jeder Entität, die ein Programm manipuliert. Er kann Rückschlüsse2 über diese Entitäten ziehen und anhand dieser Schlussfolgerungen Fehler erkennen, die auftreten, wenn der Code ihnen widerspricht. Die Typüberprüfung ermöglicht es einem Compiler, eine ganze Klasse von Programmierfehlern zu erkennen und zu markieren. In diesem Abschnitt werden einige der interessantesten Merkmale des Kotlin-Typensystems vorgestellt, darunter derUnit Typ, funktionale Typen, Nullsicherheit undGenerika.

Primitive Typen

Der offensichtlichste Unterschied zwischen Javas und Kotlins Typensystemen ist, dass Kotlin keinen Begriff für einen primitiven Typ hat.

Java hat die Typen int, float, boolean, usw. Diese Typen haben die Besonderheit, dass sie nicht von Javas Basistyp Object erben. Zum Beispiel ist die Anweisung int n = null; nicht legal in Java. Genauso wenig wie List<int> integers;. Um diese Inkonsistenz abzumildern, hat jeder primitive Typ in Java ein Äquivalent in Form einer Box. Integer So ist z.B. Boolean die Entsprechung von int für boolean und so weiter. Die Unterscheidung zwischen Primitiv- und Boxed-Typen ist fast verschwunden, denn seit Java 5 wandelt der Java-Compiler automatisch zwischen Boxed- und Unboxed-Typen um. Es ist jetzt legal,Integer i = 1 zu sagen.

In Kotlin gibt es keine primitiven Typen, die das Typensystem durcheinander bringen. Der einzige Basistyp, Any, ist analog zu Javas Object, die Wurzel der gesamten Kotlin-Typenhierarchie.

Hinweis

Die interne Darstellung von einfachen Typen in Kotlin ist nicht mit dem Typensystem verbunden. Der Kotlin-Compiler verfügt über genügend Informationen, um z. B. eine 32-Bit-Ganzzahl genauso effizient darzustellen wie jede andere Sprache. Wenn du also val i: Int = 1 schreibst, kann es sein, dass du einen Primitivtyp oder einen Boxed Type verwendest, je nachdem, wie die Variable iim Code verwendet wird. Wann immer möglich, verwendet der Kotlin-Compiler primitive Typen.

Null Sicherheit

Ein zweiter großer Unterschied zwischen Java und Kotlin ist, dassdie Nullbarkeit Teil des Typsystems von Kotlin ist. Ein löschbarer Typ wird von seinem nicht löschbaren Analogon durch das Fragezeichen am Ende seines Namens unterschieden; zum Beispiel: String und String?, Person undPerson?. Der Kotlin-Compiler erlaubt die Zuweisung von null an einen löschbaren Typ: var name: String? = null. Er lässt jedochvar name: String = null nicht zu (weil String kein löschbarer Typ ist).

Any ist die Wurzel des Kotlin-Typensystems, genau wie Object in Java. Es gibt jedoch einen wesentlichen Unterschied: Any Kotlin ist die Basisklasse für alle nicht-nullbaren Klassen, während Any? die Basisklasse für alle nullbaren Klassen ist. Dies ist die Grundlage für die Nullsicherheit. Mit anderen Worten: Es ist sinnvoll, sich das Typsystem von Kotlin als zwei identische Typbäume vorzustellen: Alle nicht löschbaren Typen sind Untertypen von Any und alle löschbaren Typen sind Untertypen von Any?.

Variablen müssen initialisiert werden. Es gibt keinen Standardwert für eine Variable. Dieser Code zum Beispiel führt zu einem Compilerfehler:

val name: String // error! Nonnullable types must be initialized!

Wie bereits beschrieben, zieht der Kotlin-Compiler Schlussfolgerungen aus den Typinformationen. Oft kann der Compiler den Typ eines Bezeichners aus den ihm bereits vorliegenden Informationen herausfinden. Dieser Prozess wird type inference genannt. Wenn der Compiler einen Typ ableiten kann, muss der Entwickler ihn nicht angeben. Zum Beispiel ist die Zuweisung var name = "Jerry" völlig legal, obwohl der Typ der Variablen name nicht angegeben wurde. Der Compiler kann daraus schließen, dass die Variable name eine String sein muss, weil ihr der Wert "Jerry" zugewiesen wird (der eine String ist).

Abgeleitete Typen können allerdings überraschend sein. Dieser Code erzeugt einen Compilerfehler:

var name = "Jerry"
name = null

Der Compiler hat den Typ String für die Variable name hergeleitet, nicht den Typ String?. Da String kein nullbarer Typ ist, ist der Versuch, null zuzuweisen, illegal.

Es ist wichtig zu wissen, dass ein löschbarer Typ nicht dasselbe ist wie seinnicht löschbares Gegenstück. Wie sinnvoll, verhält sich ein löschbarer Typ wie der Supertyp des entsprechenden nicht löschbaren Typs. Dieser Code lässt sich zum Beispiel problemlos kompilieren, weil String ein String? ist:

val name = Jerry
fun showNameLength(name: String?) { // Function accepts a nullable parameter
     // ...
}

showNameLength(name)

Andererseits lässt sich der folgende Code überhaupt nicht kompilieren, weil String? kein String ist:

val name: String? = null
fun showNameLength(name: String) { // This function only accepts non-nulls
    println(name.length)
}

showNameLength(name)               // error! Won't compile because "name"
                                   // can be null

Einfach den Typ des Parameters zu ändern, wird das Problem nicht vollständig beheben:

val name: String? = null
fun showNameLength(name: String?) { // This function now accepts nulls
    println(name.length)            // error!
}

showNameLength(name)                // Compiles

Dieses Snippet schlägt mit der Fehlermeldung Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String? fehl.

Kotlin verlangt, dass nullbare Variablen sicher gehandhabt werden - und zwar so, dass keine Null-Pointer-Ausnahme erzeugt wird. Damit der Code kompiliert werden kann, muss er den Fall korrekt behandeln, in demname null ist:

val name: String? = null
fun showNameLength(name: String?) {
    println(if (name == null) 0 else name.length)
    // we will use an even nicer syntax shortly
}

Kotlin hat spezielle Operatoren, ?. und ?:, die die Arbeit mit nullbaren Entitäten vereinfachen:

val name: String? = null
fun showNameLength(name: String?) {
    println(name?.length ?: 0)
}

Wenn name nicht null ist, ist der Wert von name?.length derselbe wie der Wert von name.length. Wenn name jedoch null ist, ist der Wert von name?.length null . Der Ausdruck löst keine Nullzeiger-Ausnahme aus. Der erste Operator im vorherigen Beispiel, der sichere Operator ?., ist also syntaktisch äquivalent zu:

if (name == null) null else name.length

Der zweite Operator, der elvis-Operator ?:, gibt den linken Ausdruck zurück, wenn er nicht null ist, und andernfalls den rechten Ausdruck. Beachte, dass der Ausdruck auf der rechten Seite nur ausgewertet wird, wenn der linke Ausdruck null ist.

Es ist gleichbedeutend mit:

if (name?.length == null) 0 else name.length

Der Einheitstyp

In Kotlin hat alles einen Wert. Immer. Wenn du das verstanden hast, ist es nicht schwer, dir vorzustellen, dass auch eine Methode, die nichts zurückgibt, einen Standardwert hat. Dieser Standardwert heißt Unit.Unit ist der Name von genau einem Objekt, dem Wert, den Dinge haben, wenn sie keinen anderen Wert haben. Der Typ des Unit Objekts heißt praktischerweise Unit.

Das ganze Konzept von Unit kann Java-Entwicklern seltsam vorkommen, da sie an die Unterscheidung zwischen Ausdrücken - Dingen, die einen Wert haben - und Anweisungen - Dingen, die keinen Wert haben - gewöhnt sind.

Die Java-Bedingung ist ein gutes Beispiel für den Unterschied zwischen einerAnweisung und einem Ausdruck, denn sie hat von beidem etwas! In Java kannst du sagen:

if (maybe) doThis() else doThat();

Du kannst jedoch nicht sagen:

int n = if (maybe) doThis() else doThat();

Anweisungen, wie die if Anweisung, geben keinen Wert zurück. Du kannst den Wert einer if Anweisung nicht einer Variablen zuweisen, denn if Anweisungen geben nichts zurück. Das Gleiche gilt für Schleifenanweisungen, Case-Anweisungen usw.

Die Java-Anweisung if hat jedoch eine Entsprechung, den ternären Ausdruck. Da es sich um einen Ausdruck handelt, gibt er einen Wert zurück und dieser Wert kann zugewiesen werden. Das ist in Java legal (vorausgesetzt, sowohl doThis als auchdoThat geben ganze Zahlen zurück):

int n = (maybe) ? doThis() : doThat();

In Kotlin gibt es keine Notwendigkeit für zwei Konditionale, da if ein Ausdruck ist und einen Wert zurückgibt. Das ist zum Beispiel völlig legal:

val n = if (maybe) doThis() else doThat()

In Java ist eine Methode mit void als Rückgabetyp wie eine Anweisung. Eigentlich ist das eine falsche Bezeichnung, denn void ist kein Typ. Es ist ein reserviertes Wort in der Sprache Java, das anzeigt, dass die Methode keinen Wert zurückgibt. Als Java die Generika einführte, wurde der Typ Void eingeführt, um die Lücke zu füllen (beabsichtigt!). Die beiden Darstellungen von "nichts", das Schlüsselwort und der Typ, sind jedoch verwirrend und inkonsistent: Eine Funktion, deren Rückgabetyp Voidist, muss explizit null zurückgeben.

Kotlin ist viel konsequenter: Alle Funktionen geben einen Wert zurück und haben einen Typ. Wenn der Code für eine Funktion nicht explizit einen Wert zurückgibt, hat die Funktion den Wert Unit.

Funktionstypen

Das Typsystem von Kotlin unterstützt Funktionstypen. Der folgende Code definiert zum Beispiel eine Variable, func, deren Wert eine Funktion ist, das Lambda { x -> x.pow(2.0) }:

val func: (Double) -> Double = { x -> x.pow(2.0) }

Da func eine Funktion ist, die ein Argument vom Typ Double annimmt und ein Double zurückgibt, ist sie vom Typ (Double) -> Double.

Im vorherigen Beispiel haben wir den Typ von func explizit angegeben. Der Kotlin-Compiler kann jedoch aus dem zugewiesenen Wert viel über den Typ der Variablen func ableiten. Er kennt den Rückgabetyp, weil er den Typ von pow kennt. Er hat jedoch nicht genug Informationen, um den Typ des Parameters x zu erraten. Wenn wir diesen aber liefern, können wir die Typangabe für die Variable weglassen:

val func = { x: Double -> x.pow(2.0)}
Hinweis

Javas Typsystem kann keinen Funktionstyp beschreiben - es gibt keine Möglichkeit, über Funktionen außerhalb des Kontexts der Klassen zu sprechen, die sie enthalten. In Java würden wir, um etwas Ähnliches wie das vorherige Beispiel zu tun, den Funktionstyp Function verwenden, etwa so:

Function<Double, Double> func
    = x -> Math.pow(x, 2.0);

func.apply(256.0);

Der Variablen func wurde eine anonyme Instanz vom Typ Function zugewiesen, deren Methode apply das angegebene Lambda ist.

Dank der Funktionstypen können Funktionen andere Funktionen als Parameter empfangen oder als Werte zurückgeben. Wir nennen diese Funktionen höherer Ordnung. Betrachten wir eine Vorlage für einen Kotlin-Typ: (A, B) -> C. Sie beschreibt eine Funktion, die zwei Parameter annimmt, einen vom Typ A und einen vom Typ B(welche Typen das auch immer sein mögen), und einen Wert vom Typ C zurückgibt. Da die Typensprache von Kotlin Funktionen beschreiben kann, können A, B und C alle selbst Funktionen sein.

Wenn sich das ziemlich meta anhört, dann ist das so. Lass es uns konkreter machen. Für A in der Vorlage ersetzen wir (Double, Double) -> Int . Das ist eine Funktion, die zwei Doublenimmt und einen Int zurückgibt. FürB ersetzen wir einfach Double. Bis jetzt haben wir ((Double, Double) -> Int, Double) -> C .

Nehmen wir an, unser neuer Funktionstyp liefert (Double) -> Int , eine Funktion, die einen Parameter, Double, annimmt und einen Int zurückgibt. Der folgende Code zeigt die vollständige Signatur für unsere hypothetische Funktion:

fun getCurve(
    surface: (Double, Double) -> Int,
    x: Double
): (Double) -> Int {
    return { y -> surface(x, y) }
}

Wir haben gerade einen Funktionstyp beschrieben, der zwei Argumente benötigt. Die erste ist eine Funktion (surface) mit zwei Parametern, beide Doubles, die ein Int zurückgibt. Die zweite ist ein Double (x). Unsere Funktion getCurve gibt eine Funktion zurück, die einen Parameter, einDouble (y) nimmt und ein Int zurückgibt.

Die Möglichkeit, Funktionen als Argumente an andere Funktionen zu übergeben, ist ein Grundpfeiler funktionaler Sprachen. Durch die Verwendung von Funktionen höherer Ordnung kannst du die Redundanz im Code reduzieren, ohne neue Klassen erstellen zu müssen, wie du es in Java tun würdest (Unterklassen Runnable oder Function Schnittstellen). Wenn sie klug eingesetzt werden, verbessern Funktionen höherer Ordnung den Codedie Lesbarkeit.

Generika

Wie Java unterstützt auch das Typsystem von Kotlin Typvariablen. Zum Beispiel:

fun <T> simplePair(x: T, y: T) = Pair(x, y)

Diese Funktion erstellt ein Kotlin Pair Objekt, bei dem beide Elemente vom gleichen Typ sein müssen. Mit dieser Definition sindsimplePair("Hello", "Goodbye") und simplePair(4, 5) beide zulässig, simplePair("Hello", 5) jedoch nicht.

Der generische Typ, der in der Definition von simplePair als T bezeichnet wird, ist eine Typvariable: Die Werte, die sie annehmen kann, sind Kotlin-Typen (in diesem Beispiel String oderInt). Eine Funktion (oder eine Klasse), die eine Typvariable verwendet, wird alsgenerisch bezeichnet.

Variablen und Funktionen

Jetzt, wo wir die Typensprache von Kotlin zur Hand haben, können wir damit beginnen, die Syntax von Kotlin selbst zu diskutieren.

In Java ist die oberste syntaktische Einheit die Klasse. Alle Variablen und Methoden sind Mitglieder einer Klasse, und die Klasse ist das Hauptelement in einer gleichnamigen Datei.

Kotlin hat keine solchen Beschränkungen. Du kannst dein gesamtes Programm in eine Datei packen, wenn du willst (bitte nicht). Du kannst auch Variablen und Funktionen außerhalb jeder Klasse definieren.

Variablen

Es gibt zwei Möglichkeiten, eine Variable zu deklarieren: mit den Schlüsselwörtern val undvar. Das Schlüsselwort ist erforderlich, steht als erstes in der Zeile und leitet die Deklaration ein:

val ronDeeLay = "the night time"

Das Schlüsselwort val erzeugt eine Variable, die schreibgeschützt ist: Sie kann nicht neu zugewiesen werden. Sei aber vorsichtig! Du könntest denken, dass val wie eine Java-Variable ist, die mit demSchlüsselwort finaldeklariert wurde. Obwohl sie ähnlich ist, ist es nicht dasselbe! Obwohl sie nicht neu zugewiesen werden kann, kann eine val definitiv ihren Wert ändern! Eine valVariable in Kotlin ist eher wie ein Feld einer Java-Klasse, das einen Getter, aber keinen Setter hat, wie im folgenden Code gezeigt:

val surprising: Double
    get() = Math.random()

Jedes Mal, wenn surprising aufgerufen wird, wird ein anderer Zufallswert zurückgegeben. Dies ist ein Beispiel für eine Eigenschaft, die kein Hintergrundfeld hat. Wir werden uns später in diesem Kapitel mit Eigenschaften beschäftigen. Hätten wir dagegen val rand = Random() geschrieben, würde sich der Wert von rand nicht ändern und wäre eher wie eine Variable final in Java.

Das zweite Schlüsselwort, var, erzeugt eine vertraute veränderbare Variable: wie ein kleines Kästchen, das die letzte Sache enthält, die hineingelegt wurde.

Im nächsten Abschnitt widmen wir uns einer der Funktionen von Kotlin als funktionale Sprache: Lambdas.

Lambdas

Kotlin unterstützt Funktionsliterale: Lambdas. In Kotlin sind Lambdas immer von geschweiften Klammern umgeben. Innerhalb der geschweiften Klammern steht die Argumentliste links von einem Pfeil, ->, und der Ausdruck, der der Wert der Ausführung des Lambdas ist, steht rechts, wie im folgenden Code gezeigt:

{ x: Int, y: Int -> x * y }

Konventionell ist der zurückgegebene Wert der Wert des letzten Ausdrucks im Körper des Lambdas. Die im folgenden Code gezeigte Funktion ist zum Beispiel vom Typ(Int, Int) -> String:

{ x: Int, y: Int -> x * y; "down on the corner" }

Kotlin hat eine sehr interessante Funktion, mit der die Sprache tatsächlich erweitert werden kann. Wenn das letzte Argument einer Funktion eine andere Funktion ist (die Funktion ist höherwertig), kannst du den als Parameter übergebenen Lambda-Ausdruck aus den Klammern, die normalerweise die eigentliche Parameterliste begrenzen, herausnehmen, wie im folgenden Code gezeigt:

// The last argument, "callback", is a function
fun apiCall(param: Int, callback: () -> Unit)

Diese Funktion wird normalerweise wie folgt verwendet:

apiCall(1, { println("I'm called back!")})

Aber dank der erwähnten Sprachfunktion kann sie auch so verwendet werden:

apiCall(1) {
   println("I'm called back!")
}

Das ist doch viel schöner, oder? Dank dieser Funktion kann dein Code besser gelesen werden. Eine fortgeschrittene Anwendung dieser Funktion sind DSLs.3

Erweiterungsfunktionen

Was tust du, wenn du eine neue Methode zu einer bestehenden Klasse hinzufügen musst und diese Klasse aus einer Abhängigkeit stammt, deren Quellcode du nicht besitzt?

Wenn die Klasse in Java nicht final ist, kannst du sie unterklassifizieren. Manchmal ist das nicht ideal, denn dann muss ein weiterer Typ verwaltet werden, was die Komplexität des Projekts erhöht. Wenn die Klasse final ist, kannst du eine statische Methode in einer eigenen Hilfsklasse definieren, wie im folgenden Code gezeigt:

class FileUtils {
    public static String getWordAtIndex(File file, int index) {
        /* Implementation hidden for brevity */
    }
}

Im vorherigen Beispiel haben wir eine Funktion definiert, die ein Wort in einer Textdatei mit einem bestimmten Index abfragt. Auf der Verwendungsseite würdest du String word =schreiben.getWordAtIndex(file, 3) schreiben, wenn du den statischen Import vonFileUtils.getWordAtIndex vornimmst.Das ist in Ordnung, das machen wir in Java schon seit Jahren und es funktioniert.

In Kotlin kannst du noch eine weitere Sache tun. Du hast die Möglichkeit, eine neue Methode für eine Klasse zu definieren, auch wenn es sich nicht um eine echte Mitgliedsfunktion der Klasse handelt. Du erweiterst also nicht wirklich die Klasse, aber auf der Benutzerseite sieht es so aus, als hättest du der Klasse eine Methode hinzugefügt. Wie ist das möglich? Indem du eineErweiterungsfunktion definierst, wie im folgenden Code gezeigt:

// declared inside FileUtils.kt
fun File.getWordAtIndex(index: Int): String {
    val context = this.readText()  // 'this' corresponds to the file
    return context.split(' ').getOrElse(index) { "" }
}

In der Deklaration der Erweiterungsfunktion verweist this auf die Instanz des empfangenden Typs (hier File). Du hast nur Zugriff auf öffentliche und interne Attribute und Methoden, daher sind die Felder private und protected nicht zugänglich - du wirst gleich verstehen, warum.

Auf der Verwendungsseite würdest du val word = file.getWordAtIndex(3) schreiben. Wie du siehst, rufen wir die Funktion getWordAtIndex()auf einer Instanz von File auf, als ob die Klasse File die Memberfunktion getWordAtIndex() hätte. Das macht die Verwendungsseite aussagekräftiger und lesbarer. Wir mussten uns keinen Namen für eine neue Hilfsklasse einfallen lassen: Wir können Erweiterungsfunktionen direkt am Anfang einer Quelldatei deklarieren.

Hinweis

Werfen wir einen Blick auf die dekompilierte Version von getWordAtIndex:

public class FileUtilsKt {
    public static String getWordAtIndex(
            File file, int index
    ) {
        /* Implementation hidden for brevity */
    }
}

Beim Kompilieren ist der generierte Bytecode unserer Erweiterungsfunktion das Äquivalent einer statischen Methode, die ein File als erstes Argument annimmt. Die umschließende Klasse, FileUtilsKt, wird nach dem Namen der Quelldatei(FileUtils.kt) mit dem Suffix "kt" benannt.

Das erklärt, warum wir in einer Erweiterungsfunktion nicht auf private Felder zugreifen können: Wir fügen einfach eine statische Methode hinzu, die den empfangenden Typ als Parameter annimmt.

Das ist noch nicht alles! Für Klassenattribute kannst du Erweiterungseigenschaften deklarieren. Die Idee ist genau dieselbe: Du erweiterst eine Klasse nicht wirklich, aber du kannst neue Attribute mit der Punktnotation zugänglich machen, wie im folgenden Code gezeigt:

// The Rectangle class has width and height properties
val Rectangle.area: Double
    get() = width * height

Beachte, dass wir dieses Mal val (statt fun) verwendet haben, um die Erweiterungseigenschaft zu deklarieren. Du würdest sie wie folgt verwenden:val area = rectangle.area.

Mithilfe von Erweiterungsfunktionen und -eigenschaften kannst du die Fähigkeiten von Klassen erweitern und dabei die Punktnotation verwenden, ohne die Trennung der Bereiche zu vernachlässigen. Du überhäufst bestehende Klassen nicht mit spezifischem Code für besondere Anforderungen.

Klassen

Auf den ersten Blick sehen Klassen in Kotlin ähnlich aus wie in Java: das Schlüsselwortclass, gefolgt von dem Block, der die Klasse definiert. Der Clou von Kotlin ist jedoch die Syntax für den Konstruktor und die Möglichkeit, darin Eigenschaften zu deklarieren. Der folgende Code zeigt die Definition einer einfachen Klasse Point und einige Anwendungsfälle:

class Point(val x: Int, var y: Int? = 3)

fun demo() {
    val pt1 = Point(4)
    assertEquals(3, pt1.y)
    pt1.y = 7
    val pt2 = Point(7, 7)
    assertEquals(pt2.y, pt1.y)
}

Klasse Initialisierung

Beachte, dass im vorangegangenen Code der Konstruktor von Point in die Deklaration der Klasse eingebettet ist. Er wird Primärkonstruktor genannt. Der Primärkonstruktor vonPointdeklariert zwei Klasseneigenschaften, x und y, die beide ganze Zahlen sind. Die erste,x, ist schreibgeschützt. Die zweite, y, ist veränderbar und löschbar und hat einen Standardwert von 3.

Beachte, dass die Schlüsselwörter var und val sehr wichtig sind! Die Deklaration class Point(x: Int, y: Int) unterscheidet sich stark von der vorangegangenen Deklaration, da sie keine Mitgliedseigenschaften deklariert. Ohne die Schlüsselwörter sind die Bezeichner x und y einfach Argumente für den Konstruktor. Der folgende Code erzeugt zum Beispiel einen Fehler:

class Point(x: Int, y: Int?)

fun demo() {
    val pt = Point(4)
    pt.y = 7 // error!  Variable expected
}

Die Klasse Point in diesem Beispiel hat nur einen Konstruktor, nämlich den, der in ihrer Deklaration definiert ist. Klassen sind jedoch nicht auf diesen einen Konstruktor beschränkt. In Kotlin kannst du auch sowohl sekundäre Konstruktoren als auch Initialisierungsblöcke definieren, wie in der folgenden Definition der Klasse Segmentgezeigt wird:

class Segment(val start: Point, val end: Point) {
    val length: Double = sqrt(
            (end.x - start.x).toDouble().pow(2.0)
                    + (end.y - start.y).toDouble().pow(2.0))

    init {
        println("Point starting at $start with length $length")
    }

    constructor(x1: Int, y1: Int, x2: Int, y2: Int) :
            this(Point(x1, y1), Point(x2, y2)) {
        println("Secondary constructor")
    }
}

Es gibt noch einige andere Dinge, die in diesem Beispiel von Interesse sind. Zunächst einmal ist zu beachten, dass ein sekundärer Konstruktor in seiner Deklaration an den primären Konstruktor, den : this(...), delegieren muss. Der Konstruktor kann einen Codeblock haben, muss aber explizit an den primären Konstruktor delegieren.

Interessanter ist vielleicht die Reihenfolge der Ausführung des Codes in der vorangehenden Erklärung. Nehmen wir an, man würde eine neue Segment erstellen, indem man den sekundären Konstruktor verwendet. In welcher Reihenfolge würden die Druckanweisungen erscheinen?

Na also! Probieren wir es aus und sehen wir nach:

>>> val s = Segment(1, 2, 3, 4)

Point starting at Point(x=1, y=2) with length 2.8284271247461903
Secondary constructor

Das ist ziemlich interessant. Der Block init wird vor dem Codeblock ausgeführt, der mit dem sekundären Konstruktor verbunden ist! Andererseits wurden die Eigenschaften length und start mit den vom Konstruktor gelieferten Werten initialisiert. Das bedeutet, dass der primäre Konstruktor noch vor dem init Block ausgeführt worden sein muss.

Tatsächlich garantiert Kotlin diese Reihenfolge: Der primäre Konstruktor (falls es einen gibt) wird zuerst ausgeführt. Danach werden die Blöcke von init in der Reihenfolge der Deklaration ausgeführt (von oben nach unten). Wenn die neue Instanz mithilfe eines sekundären Konstruktors erstellt wird, wird der Codeblock, der mit diesem Konstruktor verbunden ist, als letztes ausgeführt.

Eigenschaften

Kotlin-Variablen, die mit val oder var in einem Konstruktor oder auf der obersten Ebene einer Klasse deklariert werden, definieren eigentlich eine Eigenschaft. Eine Eigenschaft ist in Kotlin wie eine Kombination aus einem Java-Feld und seinem Getter (wenn die Eigenschaft schreibgeschützt ist und mit val definiert wurde) oder seinem Getter und Setter (wenn sie mit var definiert wurde).

Kotlin unterstützt das Anpassen des Accessors und Mutators für eine Eigenschaft und hat dafür eine spezielle Syntax, wie hier in der Definition der Klasse Rectangle gezeigt:

class Rectangle(val l: Int, val w: Int) {
    val area: Int
        get() = l * w
}

Die Eigenschaft area ist synthetisch: Sie wird aus den Werten für Länge und Breite berechnet. Da es keinen Sinn machen würde, sie area zuzuweisen, ist sie eine val, schreibgeschützt und hat keine set() Methode.

Verwende die Standard-"Punkt"-Notation, um auf den Wert einer Eigenschaft zuzugreifen:

val rect = Rectangle(3, 4)
assertEquals(12, rect.area)

Um die benutzerdefinierten Getter und Setter von Eigenschaften näher zu betrachten, betrachte eine Klasse, die einen Hash-Code hat, der häufig verwendet wird (vielleicht werden Instanzen in einer Map gespeichert) und dessen Berechnung ziemlich teuer ist. Du entscheidest dich dafür, den Hash-Code zwischenzuspeichern und ihn zu setzen, wenn sich der Wert einer Klasseneigenschaft ändert. Ein erster Versuch könnte etwa so aussehen:

// This code doesn't work (we'll see why)
class ExpensiveToHash(_summary: String) {

    var summary: String = _summary
        set(value) {
            summary = value    // unbounded recursion!!
            hashCode = computeHash()
        }

    //  other declarations here...
    var hashCode: Long = computeHash()

    private fun computeHash(): Long = ...
}

Der vorangehende Code schlägt aufgrund einer unbegrenzten Rekursion fehl: Die Zuweisung an summary ist ein Aufruf an summary.set()! Der Versuch, den Wert der Eigenschaft innerhalb ihres eigenen Setters zu setzen, wird nicht funktionieren. Kotlin verwendet den speziellen Bezeichner field, um dieses Problem zu lösen. Im Folgenden siehst du die korrigierte Version des Codes:

class ExpensiveToHash(_summary: String) {

    var summary: String = _summary
        set(value) {
            field = value
            hashCode = computeHash()
        }

    //  other declarations here...
    var hashCode: Long = computeHash()

    private fun computeHash(): Long = ...
}

Der Bezeichner field hat nur innerhalb der benutzerdefinierten Getter und Setter eine besondere Bedeutung, da er sich auf das Hintergrundfeld bezieht, das den Zustand der Eigenschaft enthält.

Beachte auch, dass der vorangegangene Code das Idiom für die Initialisierung einer Eigenschaft demonstriert, die einen benutzerdefinierten Getter/Setter mit einem Wert hat, der dem Klassenkonstruktor übergeben wird. Die Definition von Eigenschaften in einer Konstruktorparameterliste ist eine wirklich praktische Kurzform. Wenn jedoch einige Eigenschaftsdefinitionen in einem Konstruktor über benutzerdefinierte Getter und Setter verfügen, kann der Konstruktor dadurch sehr unübersichtlich werden.

Wenn eine Eigenschaft mit einem benutzerdefinierten Getter und Setter vom Konstruktor aus initialisiert werden muss, wird die Eigenschaft zusammen mit ihrem benutzerdefinierten Getter und Setter im Körper der Klasse definiert. Die Eigenschaft wird mit einem Parameter aus dem Konstruktor initialisiert (in diesem Fall_summary). Hier wird noch einmal deutlich, wie wichtig die Schlüsselwörterval und var in der Parameterliste eines Konstruktors sind. Der Parameter_summary ist nur ein Parameter und keine Klasseneigenschaft, da er ohne eines der beiden Schlüsselwörter deklariert wird.

lateinit Eigenschaften

Es kommt vor, dass der Wert einer Variablen nicht an der Stelle verfügbar ist, an der sie deklariert wird. Ein offensichtliches Beispiel für Android-Entwickler ist ein UI-Widget, das in einer Activity oder Fragment verwendet wird. Erst wenn die Methode onCreate oder onCreateView ausgeführt wird, kann die Variable, die während der gesamten Aktivität verwendet wird, um auf das Widget zu verweisen, initialisiert werden. Die button in diesem Beispiel zum Beispiel:

class MyFragment: Fragment() {
    private var button: Button? = null // will provide actual value later
}

Die Variable muss initialisiert werden. Eine Standardtechnik, da wir den Wert noch nicht kennen können, ist, die Variable nullable zu machen und sie mit null zu initialisieren.

Die erste Frage, die du dir in dieser Situation stellen solltest, ist, ob es wirklich notwendig ist, diese Variable zu diesem Zeitpunkt und an dieser Stelle zu definieren. Wird die Referenz button wirklich in mehreren Methoden verwendet oder wird sie nur an ein oder zwei bestimmten Stellen gebraucht? Wenn letzteres der Fall ist, kannst du die Klasse global ganz abschaffen.

Das Problem bei der Verwendung eines nullbaren Typs ist jedoch, dass du jedes Mal, wenn dubutton in deinem Code verwendest, auf Nullbarkeit prüfen musst. Zum Beispiel: button?.setOnClickListener { .. }. Ein paar Variablen wie diese und du hast am Ende eine Menge lästiger Fragezeichen! Das kann besonders unübersichtlich aussehen, wenn du an Java und seine einfache Punktnotation gewöhnt bist.

Warum, so magst du dich fragen, hindert mich Kotlin daran, buttonmit einem Nicht-Null-Typ zu deklarieren, wenn du sicher bist, dass du es initialisieren wirst, bevor irgendetwas versucht, darauf zuzugreifen? Gibt es nicht eine Möglichkeit, die Initialisierungsregel des Compilers nur für diese button zu lockern?

Es ist möglich. Du kannst genau das mit dem lateinit Modifikator tun, wie im folgenden Code gezeigt:

class MyFragment: Fragment() {
    private lateinit var button: Button // will initialize later
}

Da die Variable lateinit deklariert ist, kannst du sie in Kotlin deklarieren, ohne ihr einen Wert zuzuweisen. Die Variable muss veränderbar sein, var, denn per Definition wirst du ihr später einen Wert zuweisen. Toll - Problem gelöst, oder?

Wir, die Autoren, dachten genau das, als wir anfingen, Kotlin zu benutzen. Heute tendieren wir dazu, lateinit nur dann zu verwenden, wenn es unbedingt notwendig ist, und stattdessen nullbare Werte zu verwenden. Und warum?

Wenn du lateinit verwendest, sagst du dem Compiler: "Ich kann dir jetzt noch keinen Wert geben. Aber ich werde dir später einen Wert geben, versprochen." Wenn der Kotlin-Compiler sprechen könnte, würde er antworten: "Gut! Du sagst, du weißt, was du tust. Wenn etwas schief geht, bist du schuld." Wenn du den lateinit Modifikator verwendest, deaktivierst du die Nullsicherheit von Kotlin für deine Variable. Wenn du vergisst, die Variable zu initialisieren, oder versuchst, eine Methode aufzurufen, bevor sie initialisiert ist, bekommst du eine UninitializedPropertyAccessException, was im Grunde dasselbe ist wie NullPointerException in Java.

Jedes Mal, wenn wir lateinit in unserem Code verwendet haben, haben wir uns irgendwann verbrannt. Unser Code könnte in allen Fällen funktionieren, die wir vorhergesehen haben. Wir waren uns sicher, dass wir nichts übersehen haben ... und haben uns geirrt.

Wenn du eine Variable lateinit deklarierst, machst du Annahmen, die der Compiler nicht überprüfen kann. Wenn du oder andere Entwickler den Code später überarbeiten, kann dein sorgfältiger Entwurf kaputtgehen. Die Tests könnten den Fehler aufdecken. Oder auch nicht.4 Nach unserer Erfahrung führte die Verwendung von lateinit immer zu Laufzeitabstürzen. Wie haben wir das gelöst? Indem wir einen nullbaren Typ verwenden.

Wenn du einen nullbaren Typ anstelle von lateinit verwendest, zwingt dich der Kotlin-Compiler, deinen Code auf Nullbarkeit zu prüfen, und zwar genau an den Stellen, an denen er null sein könnte. Das Hinzufügen von ein paar Fragezeichen ist den Kompromiss für robusteren Code auf jeden Fall wert.

Faule Eigenschaften

In der Softwareentwicklung ist es üblich, die Erstellung und Initialisierung eines Objekts aufzuschieben, bis es tatsächlich benötigt wird. Dieses Muster wird als lazy initialization bezeichnet und ist besonders unter Android verbreitet, da die Zuweisung vieler Objekte beim Start der App zu einer längeren Startzeit führen kann. Beispiel 1-1 ist ein typisches Beispiel für eine faule Initialisierung in Java.

Beispiel 1-1. Faule Initialisierung in Java
class Lightweight {
    private Heavyweight heavy;

    public Heavyweight getHeavy() {
        if (heavy == null) {
            heavy = new Heavyweight();
        }
        return heavy;
    }
}

Das Feld heavy wird nur dann mit einer neuen Instanz der Klasse Heavyweight initialisiert (deren Erstellung vermutlich teuer ist), wenn sein Wert zum ersten Mal angefordert wird, z. B. durch einen Aufruf von lightweight.getHeavy(). Nachfolgende Aufrufe von getHeavy() geben die zwischengespeicherte Instanz zurück.

In Kotlin ist die faule Initialisierung ein Teil der Sprache. Wenn du die Direktive by lazy verwendest und einen Initialisierungsblock bereitstellst, ist der Rest der lazy Instanziierung implizit, wie inBeispiel 1-2 gezeigt.

Beispiel 1-2. Faule Initialisierung in Kotlin
class Lightweight {
    val heavy by lazy { // Initialization block
        Heavyweight()
    }
}

Wir werden diese Syntax im nächsten Abschnitt genauer erklären.

Hinweis

Beachte, dass der Code in Beispiel 1-1 nicht thread-sicher ist. Mehrere Threads, die gleichzeitig die Methode getHeavy() von Lightweightaufrufen, könnten zu unterschiedlichen Instanzen von Heavyweight führen.

Der Code in Beispiel 1-2 ist standardmäßig thread-sicher. Die Aufrufe vonLightweight::getHeavy() werden synchronisiert, so dass sich jeweils nur ein Thread im Initialisierungsblock befindet.

Die feinkörnige Kontrolle des gleichzeitigen Zugriffs auf einen Lazy-Initialisierungsblock kann mit LazyThreadSafetyMode verwaltet werden.

Ein fauler Kotlin-Wert wird erst dann initialisiert, wenn ein Aufruf zur Laufzeit erfolgt. Wenn die Eigenschaft heavy zum ersten Mal referenziert wird, wird der Initialisierungsblock ausgeführt.

Delegierte

Lazy Properties sind ein Beispiel für eine allgemeinere Kotlin-Funktion, dieDelegation genannt wird. Eine Deklaration verwendet das Schlüsselwort by, um einen Delegaten zu definieren, der dafür verantwortlich ist, den Wert der Eigenschaft zu erhalten und zu setzen. In Java könnte man etwas Ähnliches z. B. mit einem Setter erreichen, der sein Argument als Parameter an einen Methodenaufruf für ein anderes Objekt, den Delegaten, weitergibt.

Weil Kotlins Lazy Initialization ein hervorragendes Beispiel für die Leistungsfähigkeit von idiomatischem Kotlin ist, nehmen wir uns eine Minute Zeit, um es auszupacken.

Der erste Teil der Deklaration in Beispiel 1-2 lautet val heavy. Dies ist, wie wir wissen, die Deklaration einer schreibgeschützten Variablen,heavy. Als nächstes kommt das Schlüsselwort by, das einen Delegaten einführt. Das Schlüsselwort by besagt, dass der nächste Bezeichner in der Deklaration ein Ausdruck ist, der zu dem Objekt ausgewertet wird, das für den Wert von heavy verantwortlich sein wird.

Der nächste Punkt in der Deklaration ist der Bezeichner lazy. Kotlin erwartet einen Ausdruck. Es stellt sich heraus, dass lazy einfach eine Funktion ist! Es ist eine Funktion, die ein einzelnes Argument, ein Lambda, entgegennimmt und ein Objekt zurückgibt. Das Objekt, das sie zurückgibt, ist Lazy<T>, wobei T der Typ ist, den das Lambda zurückgibt.

Die Implementierung von Lazy<T> ist ganz einfach: Beim ersten Aufruf wird das Lambda ausgeführt und der Wert zwischengespeichert. Bei weiteren Aufrufen wird der zwischengespeicherte Wert zurückgegeben.

Lazy Delegation ist nur eine von vielen Varianten der Eigenschaftsdelegation. Mit dem Schlüsselwort by kannst du auch beobachtbare Eigenschaften definieren (siehe die Kotlin-Dokumentation für delegierte Eigenschaften). Lazy Delegation ist jedoch die am häufigsten verwendete Eigenschaftsdelegation in Android-Code.

Verbundene Objekte

Vielleicht fragst du dich, was Kotlin mit statischen Variablen macht. Keine Angst, Kotlin verwendet Companion-Objekte. Ein Companion-Objekt ist ein Singleton-Objekt, das immer mit einer Kotlin-Klasse verbunden ist. Obwohl es nicht zwingend erforderlich ist, wird die Definition eines Begleitobjekts meistens am Ende der zugehörigen Klasse platziert, wie hier gezeigt:

class TimeExtensions {
    //  other code

    companion object {
        const val TAG = "TIME_EXTENSIONS"
    }
}

Begleitobjekte können Namen haben, Klassen erweitern und Schnittstellen erben. In diesem Beispiel heißt das Begleitobjekt von TimeExtension StdTimeExtension und erbt die Schnittstelle Formatter:

interface Formatter {
    val yearMonthDate: String
}

class TimeExtensions {
    //  other code

    companion object StdTimeExtension : Formatter {
        const val TAG = "TIME_EXTENSIONS"
        override val yearMonthDate = "yyyy-MM-d"
    }
}

Wenn du ein Mitglied eines Companion-Objekts von außerhalb einer Klasse referenzierst, die es enthält, musst du die Referenz mit dem Namen der Klasse, die es enthält, qualifizieren:

val timeExtensionsTag = TimeExtensions.StdTimeExtension.TAG

Ein Begleitobjekt wird initialisiert, wenn Kotlin die zugehörige Klasse lädt.

Daten-Klassen

Es gibt eine Kategorie von Klassen, die so häufig vorkommt, dass sie in Java einen Namen haben: Sie werden POJOs genannt, oder plain old Java objects. Die Idee dahinter ist, dass sie einfache Darstellungen von strukturierten Daten sind. Sie sind eine Sammlung von Datenelementen (Feldern), von denen die meisten über Getter und Setter und nur wenige andere Methoden verfügen: equals, hashCode, undtoString. Diese Art von Klassen ist so verbreitet, dass Kotlin sie zum Bestandteil der Sprache gemacht hat. Sie werden Datenklassen genannt.

Wir können unsere Definition der Klasse Point verbessern, indem wir sie zu einer Datenklasse machen:

data class Point(var x: Int, var y: Int? = 3)

Was ist der Unterschied zwischen dieser Klasse, die mit dem Modifikator datadeklariert wurde, und der Originalklasse, die ohne ihn deklariert wurde? Machen wir ein einfaches Experiment, indem wir zuerst die ursprüngliche Definition von Point (ohne den data Modifikator) verwenden:

class Point(var x: Int, var y: Int? = 3)

fun main() {
    val p1 = Point(1)
    val p2 = Point(1)
    println("Points are equals: ${p1 == p2}")
}

Die Ausgabe dieses kleinen Programms wird "Points are equals: false" sein. Der Grund für dieses vielleicht unerwartete Ergebnis ist, dass Kotlin p1 == p2 als p1.equals(p2) kompiliert. Da unsere erste Definition der Klasse Point die Methode equals nicht überschreibt, wird daraus ein Aufruf der Methode equals in der Basisklasse von Point, Any. Die Implementierung von equals durchAnygibt true nur dann zurück, wenn ein Objekt mit sich selbst verglichen wird.

Wenn wir dasselbe mit der neuen Definition von Point als Datenklasse versuchen, wird das Programm "Points are equals: true" ausgeben. Die neue Definition verhält sich wie beabsichtigt, weil eine Datenklasse automatisch Überschreibungen für die Methoden equals ,hashCode und toString enthält. Jede dieser automatisch erzeugten Methoden hängt von allen Eigenschaften der Klasse ab.

Zum Beispiel enthält die data class Version von Point eine equalsMethode, die dem hier entspricht:

override fun equals(o: Any?): Boolean {
    // If it's not a Point, return false
    // Note that null is not a Point
    if (o !is Point) return false

    // If it's a Point, x and y should be the same
    return x == o.x && y == o.y
}

Zusätzlich zu den Standardimplementierungen von equals undhashCode bietet data class auch die Methode copy. Hier ein Beispiel für ihre Verwendung:

data class Point(var x: Int, var y: Int? = 3)
val p = Point(1)          // x = 1, y = 3
val copy = p.copy(y = 2)  // x = 1, y = 2

Die Datenklassen von Kotlin sind ein perfekter Komfort für ein häufig verwendetes Idiom.

Im nächsten Abschnitt untersuchen wir eine weitere besondere Art von Klasse: Enum-Klassen.

Enum-Klassen

Erinnerst du dich noch an die Zeiten, in denen Entwicklern geraten wurde, dass Enums für Android zu teuer seien? Zum Glück behauptet das heute niemand mehr: Verwende Enum-Klassen nach Herzenslust!

Die Enum-Klassen in Kotlin sind den Enums in Java sehr ähnlich. Sie schaffen eine Klasse, die nicht unterklassifiziert werden kann und eine feste Anzahl von Instanzen hat. Wie in Java können Enums keine anderen Typen unterklassifizieren, aber sie können Schnittstellen implementieren und Konstruktoren, Eigenschaften und Methoden haben. Hier sind ein paar einfache Beispiele:

enum class GymActivity {
    BARRE, PILATES, YOGA, FLOOR, SPIN, WEIGHTS
}

enum class LENGTH(val value: Int) {
    TEN(10), TWENTY(20), THIRTY(30), SIXTY(60);
}

Enums funktionieren sehr gut mit dem when Ausdruck von Kotlin. Zum Beispiel:

fun requiresEquipment(activity: GymActivity) = when (activity) {
    GymActivity.BARRE -> true
    GymActivity.PILATES -> true
    GymActivity.YOGA -> false
    GymActivity.FLOOR -> false
    GymActivity.SPIN -> true
    GymActivity.WEIGHTS -> true
}

Wenn der Ausdruck when verwendet wird, um eine Variable zuzuweisen, oder als Ausdruckskörper einer Funktion, wie im vorherigen Beispiel, muss er erschöpfend sein. Ein erschöpfender when Ausdruck ist ein Ausdruck, der alle möglichen Werte seines Arguments (in diesem Fallactivity) abdeckt. Eine Standardmethode, um sicherzustellen, dass ein when Ausdruck erschöpfend ist, besteht darin, eine else Klausel einzufügen. Die else Klausel passt auf jeden Wert des Arguments, der nicht explizit in der Fallliste aufgeführt ist.

Im vorangegangenen Beispiel muss der Ausdruck when jeden möglichen Wert des Funktionsparameters activity enthalten, um erschöpfend zu sein. Der Parameter ist vom Typ GymActivity und muss daher eine der Instanzen dieser Enum sein. Da eine Enum eine bekannte Menge von Instanzen hat, kann Kotlin feststellen, dass alle möglichen Werte als explizit aufgelistete Fälle abgedeckt sind und den Wegfall der else Klausel erlauben.

Das Weglassen der else Klausel hat einen wirklich netten Vorteil: Wenn wir der GymActivity Aufzählung einen neuen Wert hinzufügen, lässt sich unser Code plötzlich nicht mehr kompilieren. Der Kotlin-Compiler erkennt, dass der when Ausdruck nicht mehr erschöpfend ist. Wenn du einen neuen Fall zu einer Aufzählung hinzufügst, möchtest du mit Sicherheit alle Stellen in deinem Code kennen, die sich an den neuen Wert anpassen müssen. Ein erschöpfender when Ausdruck, der keinen else Fall enthält, tut genau das.

Hinweis

Was passiert, wenn eine when Anweisung keinen Wert zurückgeben muss (z. B. bei einer Funktion, bei der der Wert der when Anweisung nicht der Wert der Funktion ist)?

Wenn die Anweisung when nicht als Ausdruck verwendet wird, erzwingt der Kotlin-Compiler nicht, dass sie erschöpfend ist. Du erhältst jedoch eine Lint-Warnung (eine gelbe Flagge in Android Studio), die dich darauf hinweist, dass es empfohlen wird, dass ein when Ausdruck auf enum erschöpfend ist.

Es gibt einen Trick, der Kotlin dazu zwingt, jede whenAnweisung als Ausdruck zu interpretieren (und damit erschöpfend zu sein). Die inBeispiel 1-3 definierte Erweiterungsfunktion zwingt die Anweisung when, einen Wert zurückzugeben, wie wir in Beispiel 1-4 sehen. Da sie einen Wert haben muss, besteht Kotlin darauf, dass sie erschöpfend ist.

Beispiel 1-3. Erzwingen, dass when erschöpfend ist
val <T> T.exhaustive: T
    get() = this
Beispiel 1-4. Prüfen auf eine erschöpfende when
when (activity) {
    GymActivity.BARRE -> true
    GymActivity.PILATES -> true
}.exhaustive // error!  when expression is not exhaustive.

Enums sind eine Möglichkeit, eine Klasse zu erstellen, die eine bestimmte, statische Menge von Instanzen hat. Kotlin bietet eine interessante Verallgemeinerung dieser Fähigkeit: die versiegelte Klasse.

Versiegelte Klassen

Betrachte den folgenden Code. Er definiert einen einzigen Typ, Result, mit genau zwei Subtypen. Success enthält einen Wert; Failure enthält einen Exception:

interface Result
data class Success(val data: List<Int>) : Result
data class Failure(val error: Throwable?) : Result

Beachte, dass es keine Möglichkeit gibt, dies mit einem enum zu tun. Alle Werte eines Enums müssen Instanzen desselben Typs sein. Hier gibt es jedoch zwei verschiedene Typen, die Untertypen von Result sind.

Wir können eine neue Instanz von einem der beiden Typen erstellen:

fun getResult(): Result = try {
    Success(getDataOrExplode())
} catch (e: Exception) {
    Failure(e)
}

Und auch hier ist ein when Ausdruck eine praktische Möglichkeit, eine Result zu verwalten:

fun processResult(result: Result): List<Int> = when (result) {
    is Success -> result.data
    is Failure -> listOf()
    else -> throw IllegalArgumentException("unknown result type")
}

Wir mussten wieder einen else -Zweig hinzufügen, weil der Kotlin-Compiler nicht weiß, dass Success und Failure die einzigen Result-Unterklassen sind. Irgendwo in deinem Programm könntest du eine weitere Unterklasse des Ergebnisses Result erstellen und einen weiteren möglichen Fall hinzufügen. Daher ist die Verzweigungelse für den Compiler erforderlich.

Versiegelte Klassen tun für Typen das, was Enums für Instanzen tun. Sie ermöglichen es dir, dem Compiler mitzuteilen, dass es für einen bestimmten Basistyp (hier:Result) eine feste, bekannte Menge von Untertypen (hier:Success und Failure ) gibt. Um diese Erklärung abzugeben, verwendest du das Schlüsselwort sealedin der Deklaration, wie im folgenden Code gezeigt:

sealed class Result
data class Success(val data: List<Int>) : Result()
data class Failure(val error: Throwable?) : Result()

Da Result versiegelt ist, weiß der Kotlin-Compiler, dass Success und Failure die einzigen möglichen Unterklassen sind. Auch hier können wir das else aus einem whenAusdruck entfernen:

fun processResult(result: Result): List<Int> = when (result) {
    is Success -> result.data
    is Failure -> listOf()
}

Sichtbarkeitsmodifikatoren

Sowohl in Java als auch in Kotlin bestimmen Sichtbarkeitsmodifikatoren den Geltungsbereich einer Variablen, Klasse oder Methode. In Java gibt es drei Sichtbarkeitsmodifikatoren:

private

Referenzen sind nur für die Klasse sichtbar, in der sie definiert sind, und für die äußere Klasse, wenn sie in einer inneren Klasse definiert sind.

protected

Referenzen sind für die Klasse sichtbar, in der sie definiert sind, sowie für alle Unterklassen dieser Klasse. Außerdem sind sie auch von Klassen im selben Paket sichtbar.

public

Referenzen sind überall sichtbar.

Auch Kotlin hat diese drei Sichtbarkeitsmodifikatoren. Allerdings gibt es einige feine Unterschiede. Während du sie in Java nur in Verbindung mit Class-Member-Deklarationen verwenden kannst, kannst du sie in Kotlin in Verbindung mit Class-Member- und Top-Level-Deklarationen verwenden:

private

Die Sichtbarkeit der Deklaration hängt davon ab, wo sie definiert ist:

  • Ein Klassenmitglied, das als private deklariert ist, ist nur in der Klasse sichtbar, in der es definiert ist.

  • Eine Top-Level-Deklaration private ist nur in der Datei sichtbar, in der sie definiert ist.

protected

Geschützte Deklarationen sind nur in der Klasse, in der sie definiert sind, und in deren Unterklassen sichtbar.

public

Referenzen sind überall sichtbar, genau wie in Java.

Zusätzlich zu diesen drei verschiedenen Sichtbarkeiten gibt es in Java noch eine vierte: package-private, bei der Referenzen nur von Klassen sichtbar sind, die sich im selben Paket befinden. Eine Deklaration ist package-private, wenn sie keine Sichtbarkeitsmodifikatoren hat. Mit anderen Worten: Dies ist die Standard-Sichtbarkeit in Java.

Kotlin hat kein solches Konzept.5 Das mag überraschen, denn Java-Entwickler verlassen sich oft auf die paketprivate Sichtbarkeit, um Implementierungsdetails vor anderen Paketen innerhalb desselben Moduls zu verbergen. In Kotlin werden Pakete überhaupt nicht für die Sichtbarkeit verwendet - sie sind lediglich Namensräume. Daher ist die Standardsichtbarkeit in Kotlin anders - sie ist öffentlich.

Die Tatsache, dass Kotlin keine Paket-Privat-Sichtbarkeit hat, wirkt sich erheblich darauf aus, wie wir unseren Code gestalten und strukturieren. Um eine vollständige Kapselung der Deklarationen (Klassen, Methoden, Top-Level-Felder usw.) zu gewährleisten, kannst du alle diese Deklarationen als private in derselben Datei haben.

Manchmal ist es akzeptabel, wenn mehrere eng verwandte Klassen in verschiedene Dateien aufgeteilt sind. Allerdings können diese Klassen nicht auf Geschwister aus demselben Paket zugreifen, es sei denn, sie sind public oder internal. Was ist internal? Es ist der vierte Sichtbarkeitsmodifikator, der von Kotlin unterstützt wird und der die Referenz überall innerhalb des enthaltenenModuls sichtbar macht.6 Aus der Sicht eines Moduls ist internal identisch mit public. internal ist jedoch interessant, wenn dieses Modul als Bibliothek gedacht ist - zum Beispiel, wenn es eine Abhängigkeit für andere Module ist. Tatsächlich sind internal Deklarationen für Module, die deine Bibliothek importieren, nicht sichtbar. internal ist daher nützlich, um Deklarationen vor der Außenwelt zu verbergen.

Hinweis

Der internal Modifikator ist nicht dafür gedacht, die Sichtbarkeit innerhalb des Moduls einzuschränken, wie es in Java mit package-private möglich ist. Das ist in Kotlin nicht möglich. Es ist möglich, die Sichtbarkeit mit demprivate Modifikator etwas stärker einzuschränken.

Zusammenfassung

Tabelle 1-1 zeigt einige der wichtigsten Unterschiede zwischen Java und Kotlin.

Tabelle 1-1. Unterschiede zwischen Java- und Kotlin-Funktionen
Feature Java Kotlin

Inhalt der Datei

Eine einzelne Datei enthält eine einzige Top-Level-Klasse.

Eine einzelne Datei kann eine beliebige Anzahl von Klassen, Variablen oder Funktionen enthalten.

Variablen

Verwende final, um eine Variable unveränderlich zu machen; standardmäßig sind Variablen veränderbar. Definiert auf Klassenebene.

Verwende val, um eine Variable schreibgeschützt zu machen, oder var für Lese-/Schreibwerte. Wird auf Klassenebene definiert, kann aber auch unabhängig von einer Klasse existieren.

Typenzuordnung

Datentypen sind erforderlich. Date date = new Date();

Datentypen können gefolgert werden, wie val date = Date(), oder explizit definiert werden, wie val date: Date = Date().

Boxen und Unboxing-Typen

In Java werden Datenprimitive wie int für teurere Operationen empfohlen, da sie preiswerter sind als Boxed-Typen wie Integer. Allerdings haben Boxed-Typen viele nützliche Methoden in den Wrapper-Klassen von Java.

Kotlin hat von Haus aus keine primitiven Typen. Alles ist ein Objekt. Wenn er für die JVM kompiliert wird, führt der generierte Bytecode automatisch ein Unboxing durch, sofern möglich.

Zugriffsmodifikatoren

Öffentliche und geschützte Klassen, Funktionen und Variablen können erweitert und außer Kraft gesetzt werden.

Als funktionale Sprache fördert Kotlin, wann immer möglich, die Unveränderbarkeit. Klassen und Funktionen sind standardmäßig endgültig.

Zugriffsmodifikatoren in Multimodulprojekten

Der Standardzugang ist paketprivat.

Es gibt kein Paket-privat, und der Standardzugang ist öffentlich. Der neue internal Zugang bietet Sichtbarkeit im selben Modul.

Funktionen

Alle Funktionen sind Methoden.

Kotlin hat Funktionstypen. Funktionsdatentypen sehen zum Beispiel so aus: (param: String) -> Boolean.

Aufhebbarkeit

Jedes nicht-primitive Objekt kann null sein.

Nur explizit nullbare Referenzen, die mit dem Suffix ? am Typ deklariert sind, können auf null gesetzt werden: val date: Date? = new Date().

Statik versus Konstanten

Mit dem Schlüsselwort static wird eine Variable an eine Klassendefinition angehängt, nicht an eine Instanz.

Das Schlüsselwort static gibt es nicht. Verwende ein privates const oder ein companion Objekt.

Herzlichen Glückwunsch, du hast soeben ein Kapitel über die Grundlagen von Kotlin abgeschlossen. Bevor wir über die Anwendung von Kotlin auf Android sprechen, müssen wir die in Kotlin eingebaute Bibliothek besprechen: Sammlungen und Datentransformationen. Wenn du die grundlegenden Funktionen von Datentransformationen in Kotlin verstehst, hast du die notwendige Grundlage, um Kotlin als funktionale Sprache zu verstehen.

1 Dmitry Jemerov und Svetlana Isakova. Kotlin in Aktion. Manning, 2017.

2 Kotlin nennt dies offiziell Type Inferencing. Dabei wird eine Teilphase des Compilers (die Frontend-Komponente) verwendet, um den geschriebenen Code zu prüfen, während du in der IDE schreibst. Es ist ein Plug-in für IntelliJ! Spaßfakt: IntelliJ und Kotlin bestehen vollständig aus Compiler-Plug-ins.

3 DSL steht für domänenspezifische Sprache. Ein Beispiel für eine in Kotlin entwickelte DSL ist die Kotlin Gradle DSL.

4 Du kannst mit this::button.isInitialized überprüfen, ob die Eigenschaft latenit button initialisiert ist. Wenn du dich darauf verlässt, dass die Entwickler diese Prüfung an den richtigen Stellen einbauen, löst das nicht das eigentliche Problem.

5 Zumindest seit Kotlin 1.5.20. Während wir diese Zeilen schreiben, denkt Jetbrains darüber nach, der Sprache einen paketprivaten Sichtbarkeitsmodifikator hinzuzufügen.

6 Ein Modul ist ein Satz von Kotlin-Dateien, die zusammen kompiliert werden.

Get Android mit Kotlin programmieren 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.