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 i
im 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.
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 Void
ist, 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 Double
nimmt 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 Double
s, 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 final
deklariert wurde. Obwohl sie ähnlich ist, ist es nicht dasselbe! Obwohl sie nicht neu zugewiesen werden kann, kann eine val
definitiv ihren Wert ändern! Eine val
Variable 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 DSL
s.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 vonPoint
deklariert 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 Segment
gezeigt 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:
>>> vals
=
Segment(
1
,2
,3
,4
)
Point starting at Point(
x
=
1
,y
=
2
)
with length2
.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, button
mit 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 Lightweight
aufrufen, 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 data
deklariert 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
durchAny
gibt 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 equals
Methode, 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 when
Anweisung 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 sealed
in 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 when
Ausdruck 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
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
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.
Zusammenfassung
Tabelle 1-1 zeigt einige der wichtigsten Unterschiede zwischen Java und Kotlin.
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 |
Verwende |
Typenzuordnung |
Datentypen sind erforderlich. |
Datentypen können gefolgert werden, wie |
Boxen und Unboxing-Typen |
In Java werden Datenprimitive wie |
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 |
Funktionen |
Alle Funktionen sind Methoden. |
Kotlin hat Funktionstypen. Funktionsdatentypen sehen zum Beispiel so aus: |
Aufhebbarkeit |
Jedes nicht-primitive Objekt kann null sein. |
Nur explizit nullbare Referenzen, die mit dem Suffix |
Statik versus Konstanten |
Mit dem Schlüsselwort |
Das Schlüsselwort |
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.