Beliebte Suchanfragen

Cloud Native

DevOps

IT-Security

Agile Methoden

Java

//

Kotlin, mehr als nur „das bessere Java"

3.2.2023 | 12 Minuten Lesezeit

Spiderman hängt von der Decke

Die Sprache Kotlin erfreut sich zunehmender Beliebtheit. Laut dem 2021 Stackoverflow Survey nutzen knapp 10 % aller professionellen Entwickler Kotlin. Viele kennen die Sprache als „das bessere Java", was Kotlin wohl auch ist. In diesem Artikel möchte ich zeigen, wieso Kotlin noch mehr ist als das. Kotlin ist vielmehr so etwas wie „der freundliche Superheld aus der Nachbarschaft". (Foto: https://unsplash.com/photos/9XAnXWHu9_4)

Das Team bei JetBrains, das Kotlin baut, hat eine pragmatische Sprache zum Ziel. Kotlin ist weniger auf theoretische Konstruktionen und Vollständigkeit optimiert, sondern auf leichten Einstieg und einfache Benutzbarkeit (DX = Developer Experience).

  • Du möchtest weniger Boilerplate Code schreiben als in Java? – Probier Kotlin aus!
  • Du möchtest unveränderbare Datenstrukturen und Sicherheit vor null haben? – Probier Kotlin aus!
  • Du möchtest mehr in funktionalem Stil schrieben? – Probier Kotlin aus!

Kotlin ist nicht furchterregend, Kotlin ist (oder erscheint) ganz leicht und einfach. Die Sprache und auch die Dokumentation sind auf eine niedrige Einstiegshürde hin ausgerichtet.

Im Folgenden werden ich davon ausgehen, dass du diese „Basics" von Kotlin bereits kennst:

  • Kein Schreiben von Gettern und Settern
  • data class, die zusätzlich automatisch equals, hashCode und toString erstellt
  • Unveränderbarkeit (val vs. var, List vs. MutableList usw.)
  • Null-Sicherheit (String vs. String?)
  • Type inference (val name = "String", ohne name explizit zu typisieren)
  • Funktionen und Lambdas als Parameter von anderen Funktionen (list.map { i -> i+1 }, oder list.map(::adder))
  • Lambdas mit nur einem Parameter: der Parameter ist implizit als it verfügbar
  • Listenoperationen (listOf(1, 2, 3).map{it + 10})
  • if, when und viele andere Schlüsselwörter sind Ausdrücke/Expressions und keine Anweisungen/Statements

Sollte diese Liste Fragen und Kopfschütteln hervorrufen, schlage ich vor, du schaust dir die grundlegende Kotlin-Syntaxdokumentation einmal an.

Also, los geht's mit Kotlin-Funktionen, die du vielleicht noch nicht kennst ...

Scope-Funktionen: nette kleine Helfer

Kotlin bietet in der Standardbibliothek eine Reihe von nützlichen Helfer-Funktionen. Drei davon, die zu den „Scope-Functions" gehören, möchte ich hier einmal vorstellen:

  • let
  • also
  • apply

(Es gibt noch mehr Scope-Functions, aber bisher habe ich nur diese drei verwendet.)

Alle drei Funktionen kann man als Methode von jedem beliebigen Objekt aufrufen, ja sogar als Methode von Literalen (wie 3 oder "Hallo").

let

Mit let kann ich etwas mit einem Objekt tun, ohne dauerhaft eine Variable dafür anlegen zu müssen. Hier zunächst erst einmal ein etwas überflüssiges Beispiel, das aber die grundsätzliche Nutzung zeigt.

// irgendeine Funktion zum Lesen von Daten
fun readSomeData(): <SomeType> { ... }

val result = readSomeData()
val displayString = result.let {
    if (it.isValid())
        it.data.toString()
    else
        it.error.toString()
}

Wir nehmen result, das von irgendeinem Typ SomeType ist, und rufen die Methode let auf. Das geht wie gesagt bei jedem Objekt. let nimmt ein Lambda als Parameter und übergibt seinerseits das Objekt diesem Lambda als Parameter. Das Ergebnis der Methode let ist das Ergebnis des Lambdas, hier also eine String-Version der Daten oder der Fehlermeldung.

Natürlich könnte man dieses Beispiel auch leicht mit einem einfachen if lösen, klar. Aber die Scope Function erlaubt es mir, auf die Variable result zu verzichten, wenn ich sie nicht woanders brauche:

// irgendeine Funktion zum Lesen von Daten
fun readSomeData(): <SomeType> { ... }

val displayString = readSomeData().let {
    if (it.isValid())
        it.data.toString()
    else
        it.error.toString()
}

let dient also dazu, Variablen zu vermeiden und damit den Scope einer Funktion sauber zu halten.

Ein weiteres Einsatzgebiet ist ein impliziter null-Check:

readData()?.let {
    println(it.status)
}

Durch das ? vor dem .let werden die Funktion und damit das Lambda nur ausgeführt, wenn das Objekt davor nicht null ist.

also

also funktioniert sehr ähnlich wie let, nur dass das Ergebnis der Funktion das Objekt ist, auf dem man die Funktion aufgerufen hat. also dient also dazu, mit einem Objekt etwas zu tun, trotzdem aber genau diesen Wert unverändert zurückzugeben oder weiterzuverwenden.

Das Einsatzgebiet sind vor allem Nebenwirkungen, wie Logging oder Zwischenspeichern in der Datenbank.

val result =
    aList
    .filter { /* filter the list */ }
    .also { log("Length: ${it.size}") }
    .map { /* do something more with the list */ }

Hier, mitten in der Listenverarbeitung wird mittels also zwischendurch die Länge der aktuellen Liste ausgegeben.

Hier noch ein Beispiel:

val folder = File(basePath, someFolder)
             .also { it.mkdirs() }

Ich möchte in der Variablen folder das File-Objekt eines bestimmten Verzeichnisses speichern. Gleichzeitig möchte ich sichergehen, dass dieses Verzeichnis und alle darüber angelegt werden, sollte das noch nicht der Fall sein.

apply

apply funktioniert so ähnlich wie also. Der Unterschied besteht darin, dass das Objekt dem Lambda nicht als Parameter übergeben wird, sondern als „Receiver": innerhalb des Lambdas ist das Objekt, auf dem ich apply aufrufe, als this erreichbar.

Damit ist apply wunderbar dafür geeignet, klassische Java-Objekte leeren Konstruktoren zu erstellen und direkt nach der Erstellung mit Werten zu versehen:

val header = HttpHeaders().apply {
    set("api_key", /* some api key */ )
    set("Content-Type", "application/json")
}

In header wird ein HttpHeader-Objekt hinterlegt, das direkt nach dem Erstellen mit ein paar Werten gefüllt wird. Das ist sozusagen ein Ersatz für die in klassischen Java-Bibliotheken oftmals fehlenden sinnvollen Konstruktoren und auch eine Alternative zum Builder-Pattern.

Top-Level Funktionen: Wofür brauche ich Klassen?

In Java müssen alle Funktionen Teil einer class sein, entweder als static oder als Member-Function. In Kotlin braucht man keine class, um eine Funktion zu erstellen. Man kann sie einfach direkt in die Datei einfügen:

package myPackage

fun adder(a: Int, b: Int): Int {
    return a + b
}

Diese Funktion kann von überall in diesem Package aufgerufen werden oder in ein anderes Package importiert werden:

Paket myOtherPackage

importiere myPackage.adder

...
    val result = adder(10, 20)
...

Wenn man Kotlin-Code schreibt, sollte man wirklich einen guten Grund haben, Funktionen in einer Klasse anzusiedeln. Das könnten zum Beispiel sein:

  • Ich will eine data class mit zusätzlichen Member Functions ausstatten
  • Das Framework benötigt Klassen (wie z. B. bei Spring)

Bevor du aber eine Klasse erstellst, nur um ein paar Funktionen zu gruppieren, versuch es doch mal mit Top-Level-Funktionen. Das Gruppieren kann man durch separate Dateien immer noch erreichen. Eine mit private markierte Top-Level-Funktion ist nur innerhalb derselben Datei erreichbar. Kapselung funktioniert also immer noch.

Single Expression Body: Wofür brauche ich die geschweiften Klammern?

Es gibt eine sehr nette alternative Schreibweise für Funktionen, deren Body nur aus einer einzigen Expression besteht, dessen Ergebnis auch gleich der Rückgabewert der gesamten Funktion ist. In diesem Fall kann ich folgendes weglassen:

  • Geschweifte Klammern
  • Deklaration des Return Types
  • Das Schlüsselwort return

Ich muss stattdessen nur ein Gleichheitszeichen (=) setzen.

Hier zum Vergleich:

// "java-esque" style
fun add (a: Int, b: Int): Int {
    return a + b
}

ist genau dasselbe wie

// functional style
fun add (a: Int, b: Int) = a + b

Hier noch ein weiteres Bespiel:

fun giveType(o: Any) =
    when (o) {
        is Int -> "Sorry, just an integer"
        is Double -> "Hey, it has decimal numbers"
        is String -> "It's a String"
        else -> "I have no idea"
    }

println(giveType(10))       // Sorry, just an integer
println(giveType(10.5))     // Hey, it has decimal numbers
println(giveType("Hello"))  // It's a String
println(giveType(Date()))   // I have no idea

(Hinweis: Normalerweise sollte man Any meiden, wie der Teufel das Weihwasser. Das hier dient nur zur Verdeutlichung.)

Gerade bei Funktionen, deren Body nur aus einer when-Expression besteht, ergibt diese Schreibweise sehr viel Sinn.

Extension Functions: Auf die „fließende" Tour

Eine „Extension Function" ist eine Funktion, bei der der Funktionsname nach einem Typ steht (mit . getrennt).

fun String.duplicate(): String { return this + this }

Beim Aufruf dieser Beispielfunktion wird die Zeichenkette zweimal hintereinander gesetzt:

"Test".duplicate() // ergibt: "TestTest"

Es sieht so aus, als hätten wir die aktuelle Klassendefinition erweitert und ihr eine neue Member Function gegeben. Fragen tun sich auf: Welche Nebenwirkungen hat das? Wann und warum sollte ich das tun?

Keine Angst!

Das ist nicht das, was hier passiert. Betrachte eine Extension Function als ganz normale Funktion mit einer anderen Art der Parameterübergabe. Anstatt einen der Werte nach dem Funktionsnamen in Klammern zu übergeben, wird er vor den Funktionsnamen geschrieben. Innerhalb der Funktion kann dieser Wert mit this erreicht werden.

Wenn man sich Extension Functions so vorstellt, verlieren sie ihren Schrecken und werden einfach nur zu ... Funktionen.

Das Schreiben von Extension Functions gibt uns die Möglichkeit, Algorithmen auf eine „fließende Weise" (fluent) zu schreiben.

Wenn wir zum Beispiel für eine Autovermietung arbeiten, könnten wir Funktionen wie diese erstellen:

val cars: List<Car> = // eine Datenstruktur,
                      // die die Autos der
                      // Autovermietung enthält

fun List<Car>.compactClass() = ...

fun List<Car> isAvailable(day: LocalDate) = ...

Später in unserer Hauptfunktionen können wir schreiben:

val result = cars
              .compactClass()
              .isAvailable(today)

Einfach, nicht? Man nimmt eine Datenstruktur (in diesem Fall eine Liste von Autos) und ruft eine Funktion darauf auf. Mit dem zurückgegebenen Wert (wieder eine Liste von Autos) ruft man die nächste Funktion auf. Und so weiter ...

„Fluent Code" ist häufig lesbarer und verständlicher.

Algebraic Data Types: Wie modelliere ich meine Domäne?

Viele funktionale Programmiersprachen unterstützen sogenannte algebraische Datentypen. Dies ist ein komplizierter Name für eine eigentlich sehr einfache Sache. Es bedeutet nur, dass mir zwei verschiedene Möglichkeiten zur Verfügung stehen, Datenstrukturen zu erstellen.

Eine Möglichkeit besteht darin, mehrere Werte zu kombinieren, um eine neue Datenstruktur zu bilden. In Kotlin nennen wir diese Dinge ... class oder besser data class:

data class Adresse(
    val street: String,
    val number: String,
    val zipCode: String,
    val Stadt: String
)

Ich nehme an, dass du damit vertraut bist. Fast jede Programmiersprache kann so etwas (struct, class, record ...). Eine der beiden Modellierungsmöglichkeiten ist damit abgehakt.

Die zweite Art, Daten zu strukturieren, ist eine, die von etlichen Programmiersprachen nicht unterstützt wird. Die Idee besteht darin, nicht mehrere Werte zu kombinieren, sondern eine Struktur zu definieren, die genau eine Ausprägung aus einer bestimmten Menge von Werten zulässt.

Nehmen wir zum Beispiel eine abstrakte Datenstruktur contact information, die eine E-Mail-Adresse oder eine Telefonnummer enthalten könnte. Eine contact information kann nur einen dieser Werte enthalten, nicht beide zur gleichen Zeit.

Der erste Gedanke könnte eine enum class mit den möglichen Werten EMAIL_ADDRESS und PHONE_NUMBER sein. Aber das ist nicht genug, weil ich für beide Fälle zusätzliche Daten speichern will, eben die E-Mail-Adresse oder die Telefonnummer. Natürlich kann eine enum class auch Daten enthalten, aber das reicht uns nicht, denn bei einer enum müssen die Datenstrukturen in jeder Ausprägung vom gleichen Typ sein. Das ist in unserem Beispiel nicht der Fall.

Wir nehmen stattdessen eine sealed class oder besser noch ein sealed interface mit speziellen Ableitungen:

sealed interface ContactInformation

class EmailAddress(
  val email: String
): ContactInformation

class PhoneNumber(
  val countryCode: Int,
  val areaCode: Int,
  val number: Int
): ContactInformation

Das sealed führt dazu, dass alle Ableitungen dieser Klasse oder dieses Interface dem Compiler bekannt sein, also in einem Compile-Vorgang durchlaufen werden müssen. Einmal compiliert kann von einer solchen Klasse oder Interface später woanders nicht mehr abgeleitet werden. Auf diese Weise kann der Kotlin-Compiler sicher sein, dass er alle möglichen Ausprägungen für ContactInformation kennt.

Man benutzt ein solches Konstrukt meisten mit einer when-Expression:

fun getPrintString(ci: ContactInformation) =
  when (ci) {
    is EmailAdresse -> ci.email
    is PhoneNumber ->
        "+${ci.countryCode}-${ci.areaCode}-${ci.number}"
  }

Man braucht hier keinen else-Zweig, da der Compiler prüfen kann, dass die Fälle vollständig sind.

Mit ArrowKt gibt es übrigens eine Bibliothek, die häufig verwendete funktionale Datentypen enthält, um Funktoren, Monaden und andere funktionale Patterns zu nutzen.

Functiontypes with Receiver: Ist das ein „Extension-Lambda"?

Ich habe noch eine Sache, die ich zeigen möchte: Functiontypes with receiver.

Wir haben oben die Extension Functions gesehen, Funktionen, in deren Implementierung einer der „Parameter" als this zur Verfügung steht. Diese Funktionen besitzen natürlich auch eine Typsignatur und können auch als Parameter einer anderen Funktionen übergeben werden.

Klingt kompliziert? Ist es gar nicht. Schauen wir uns ein Beispiel an:

fun ifFileExists(file: File, action: File.()->Unit) {
    if (file.exists() && !file.isDirectory)
        file.action()
}

Diese Funktion nimmt zwei Parameter: ein File und eine andere Funktion, die eine Extension Function von File ist, selbst keinen Parameter nimmt und auch nichts zurückgibt: File.()->Unit.

Was tut ifFileExists? Wenn der übergebene Pfad existiert und kein Verzeichnis ist, dann wird die Funktion mit File als this-Wert aufgerufen.

Normalerweise werden solche Konstrukte mit Lambdas verwendet, die dadurch zu „Extension-Lambdas" werden (Ich schreibe „Extension Lambda" in Anführungszeichen, weil das keine offizielle Bezeichnung ist. Aber ich finde, sie beschreibt gut, worum es geht):

val file = Datei("/tmp/hello")

ifFileExists(aFile) {
    val Inhalt = readText()
    println(content)
}

Wenn es eine Datei unter /tmp/hello gibt und diese kein Verzeichnis ist, wird das Lambda ausgeführt, das den Inhalt liest und ausgibt. Spannend ist, dass man einfach readText() schreiben kann (ohne this. davor), denn this. kann man bekanntlich weglassen, wenn der Ausdruck eindeutig ist.

Wozu brauche ich ein solches Konstrukt? Mit „Extension Lambdas" kannst du Funktionen schreiben, die eine eigene DLS (=domain specific language) aufbauen.

Hier ist ein Beispiel aus der Kotlin-Dokumentation über type-safe builder:

fun result() =
    html {
        head {
            title {+"XML encoding with Kotlin"}
        }
        body {
            h1 {+"XML encoding with Kotlin"}
            p  {+"this format can be used as an alternative markup to XML"}

            // an element with attributes and text content
            a(href = "https://kotlinlang.org") {+"Kotlin"}

            // mixed content
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "https://kotlinlang.org") {+"Kotlin"}
                +"project"
            }
            p {+"some text"}

            // content generated by
            p {
                for (arg in args)
                    +arg
            }
        }
    }

html ist eine Funktion, die ein „Extension Lambda" als Parameter nimmt und es mit einem bestimmten Builder-Objekt versorgt, das seinerseits die Funktionen head und body anbietet. head macht dasselbe mit einem anderen Builder, der unter anderem eine title -Funktion anbietet. Und so weiter.

In der oben erwähnten Dokumentation findest du den Quellcode zum Erstellen dieser DSL. Es ist wirklich erstaunlich wenig Code.

Extension Functions und „Extension Lambdas" sind übrigens alles, was man braucht, um die weiter oben erwähnten Scope Functions zu bauen. Schau dir mal den Sourcecode dafür an, du wirst staunen, wie kurz er ist.

Zusammenfassung

Kotlin als Sprache kann deutlich mehr, als nur durch „syntactic sugar" umständliche Java-Sprachkonstrukte zu vereinfachen. Top-Level-Functions und Extension Functions ermöglichen funktionale, fließende Programmierung, sealed class und sealed interface ermöglichen algebraische Datentypen und das alles zusammen mit „Extension Lambdas" erlauben es, sehr lesbaren Code zu schreiben und auf „optical noise" zu verzichten.

Vielleicht hast du Lust bekommen, das bei deinem nächsten Projekt einmal auszuprobieren.

Tatsächlich ist selbst hier bei Kotlin nicht Schluss: Die meisten Entwicklerinnen und Entwickler setzen Kotlin ein, um Bytecode für die JVM (Java Virtual Machine) oder für Android zu erzeugen. Es gibt aber noch mehr Ziele für Kotlin-Code:

  • Es gibt einen nativen Compiler (Kotlin/native), der plattformspezifische Binaries erzeugt, wodurch man z. B. iOS-Apps in Kotlin schreiben kann.
  • Mit Kotlin/JS kann man Kotlin auch verwenden, um JavaScript zu erzeugen, als Alternative zu TypeScript, aber mit „algebraic data types".
  • Mit Kotlin/multiplatform kann man das alles zusammen in einem gemeinsamen Projekt mit einem zentralen gradle-Build nutzen und dabei, wenn sinnvoll, Code zwischen den Modulen teilen.

Ich hoffe, ich konnte etwas mehr über eine Sprache erzählen, die du vielleicht schon benutzt, von der du aber möglicherweise das gesamte Potenzial noch nicht kanntest.

Beitrag teilen

Gefällt mir

4

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.