Delegieren von Schnittstellen und Eigenschaften

Kotlin bietet zwei native Funktionalitäten zur Implementierung des Delegate Patterns. Die erste ist die Schnittstellen-Delegation (z. B. Strategy-Muster). Die andere ist die Eigenschaftsdelegation, die sich auf Klassenmitglieder/Eigenschaften konzentriert (z.B. Lazy Loading, Observable, …). Zusammen bieten sie eine umfangreiche und prägnante Reihe von Funktionalitäten. Nach diesem Tutorial werden Sie verstehen, in welchen Situationen Sie dieses Muster verwenden können. Die Beispiele werden Ihnen die Vorteile, aber auch die bekannten Probleme aufzeigen.

Komposition statt Vererbung

Es ist bekannt, dass in objektorientierten Sprachen die Vererbung eines der wichtigsten Merkmale ist. Das bedeutet, dass man eine gewisse Funktionalität und Abstraktion in einer Basisklasse unterbringen kann. Andere Klassen können von der Basisklasse erben und erhalten somit die geerbte Funktionalität. Dies nennt man eine “Is-a”-Beziehung. Als Lehrbuchbeispiel können wir uns eine Shape-Klasse (Basis) und Rechtecke, Kreise usw. als abgeleitete Klassen vorstellen.

In der Vergangenheit wurde diese Art der Modellierung missbraucht, was zu schlechtem Design führte. So wurden z. B. lange Vererbungsketten verwendet, um den Objekten inkrementelle Funktionalität hinzuzufügen. Als überspitztes Beispiel betrachten wir den folgenden Klassenaufbau. Wir wollen “Tiere” erstellen und implementieren, wie sie sich bewegen und essen. Eine naive Art, dies zu tun, wäre, es wie im linken Bild gezeigt zu implementieren. Es ist offensichtlich, dass dies nicht skalierbar ist.

In der rechten Abbildung hingegen wird die Komposition der Vererbung vorgezogen. Die Klasse Animal implementiert die Schnittstellen IMoving und IEating. Sie hat jedoch keinen eigentlichen Code für die Implementierung, sondern delegiert die Aufrufe an die konkreten Implementierungen von Walking und MeatEating. Wie wir sehen können, ist dieses Design flexibler, da neue Implementierungen von Moving und Eating erstellt werden können, ohne die Klasse Animal zu beeinflussen.

Delegation statt Vererbung ist daher eine Möglichkeit, das Open-Closed-Prinzip der SOLID-Entwurfsmuster zu verwirklichen.

schlechtes Vererbungsbeispiel in Kotlin
Vererbungskette
Kotlins Schnittstellen Delegation
Komposition

Schnittstellen Delegation

Wie erstellt man einen Delegate in Kotlin?

Kotlin provides native capabilities to implement the delegation pattern without the need to write any boilerplate code. This is only working on interfaces (in other words abstract classes). The interface implementation of IWalking is done as shown in the following code.

interface IMoving {
    fun move()
}

class Walking : IMoving {
    override fun move() {
        println("Walking")
    }
}

class Animal(private val movable: IMoving) : IMoving {

    override fun move() {
        movable.move()
    }
}

fun main() {
    var movable = Walking()
    var animal = Animal(movable)
    animal.move()
}
interface IMoving {
    fun move()
}

class Walking : IMoving {
    override fun move() {
        println("Walking")
    }
}

class Animal(movable : IMoving) : IMoving by movable {

}

fun main() {
    var walking = Walking()
    var animal = Animal(walking)
    animal.move()
}

Wie wir sehen können, muss der linke Code die Methoden der Schnittstelle implementieren. In der rechten Version übernimmt der Compiler diese Aufgabe für Sie. Der Effekt ist noch größer, wenn die Schnittstelle mehrere Funktionen hat. Die By-Klausel in der Supertype-Liste für die abgeleitete Klasse zeigt an, dass movable intern in Objekten von IMoving gespeichert wird und der Compiler alle Methoden der Schnittstelle generiert, die an IMoving weitergeleitet werden.

Reimplementierung von Funktionen der Schnittstelle

Falls Sie eine bestimmte Funktion einer Schnittstelle überschreiben müssen, können Sie dies tun, indem Sie die Funktion in die abgeleitete Klasse schreiben und das Schlüsselwort override hinzufügen. Der Compiler wird die neue Spezifikation der überschriebenen Methode verwenden. Im folgenden Beispiel haben wir eine neue Version der move-Methode erstellt.

class Animal(movable : IMoving) : IMoving by movable {

    override fun move() {
        println("Something else")
    }
}

fun main() {
    var walking = Walking()
    var animal = Animal(walking)
    animal.move()
}

Mehrfache Schnittstellen und Vererbung

Um das obige Beispiel zu vervollständigen, müssen wir alle Schnittstellen implementieren und alle Funktionsaufrufe an die Mitgliedsvariablen delegieren. Wir können dies tun, indem wir das Gleiche wie im vorherigen Abschnitt auf alle geerbten Schnittstellen anwenden.


interface IMoving {
    fun move()
}

class Walking : IMoving {
    override fun move() {
        println("Walking")
    }
}

interface IEating {
    fun eat()
}

class MeatEater : IEating {
    override fun eat() {
        println("Eat meat")
    }

}
class Animal(movable : IMoving, eating : IEating) : IMoving by movable, IEating by eating {

}

fun main() {
    var walking = Walking()
    var eating = MeatEater()
    var animal = Animal(walking, eating)
    animal.move()
    animal.eat()
}

Gleiche Funktionssignaturen

Ein Sonderfall tritt ein, wenn Sie mehrere Schnittstellen implementieren müssen, für die dieselben Methoden deklariert sind. In diesem Fall ist die Delegation zweideutig und der Compiler gibt einen Fehler aus wie: “Class ‘xxx’ must override public open fun ‘yyy’ because it inherits many implementations of it“. Sie müssen diese Funktion explizit implementieren (oder außer Kraft setzen) und sie manuell delegieren.

Kotlin compiler error

Delegate zur Laufzeit ersetzen

Oft ist es notwendig, einen Delegate zur Laufzeit zu ändern. Dies wird oft im Strategie- oder Zustandsmuster verwendet. (Derzeit) bietet Kotlin nicht die Möglichkeit, einen Delegaten zu ändern, sobald er nativ mit dem Schlüsselwort “by” injiziert wurde.

Der folgende Code ist irreführend, da er das Folgende ausgibt:

Walking
Walking
Running@xxx


interface IMoving {
    fun move()
}

class Walking : IMoving {
    override fun move() {
        println("Walking")
    }
}

class Running : IMoving {
    override fun move() {
        println("Running")
    }
}


class Animal(var movable : IMoving) : IMoving by movable {

}

fun main() {
    var walking = Walking()
    var animal = Animal(walking)
    animal.move()

    var running = Running()
    animal.movable = running
    animal.move()

    println(animal.movable)
}

Um einen Delegate zur Laufzeit zu ändern, müssen Sie die Standardimplementierung verwenden. Mit anderen Worten, Sie müssen alle Schnittstellenmethoden selbst implementieren und die Funktionsaufrufe manuell an die abgeleitete Klasse delegieren. Es ist wahrscheinlich, dass sich dies in Zukunft ändern wird, da es bereits einen Feature Request gibt.

Delegation zu Klassenproperties

Was ist eine delegierte Eigenschaft?

Kotlin bietet einige nette Funktionen zur Implementierung delegierter Eigenschaften. Es fügt den Klasseneigenschaften einige allgemeine Funktionen hinzu. Sie können einige Eigenschaften (z.B. Lazy) in einer Bibliothek erstellen und Ihre Klassenmitglieder mit dieser Funktionalität erweitern.

Um zu verstehen, wie das funktioniert, müssen Sie wissen, dass jede Klassenvariable hinter den Kulissen einen getValue und setValue bereitstellt. Der Delegate muss keine Schnittstelle implementieren, aber er muss diese Funktionen für jeden Typ bereitstellen. Wie Sie sehen können, sind Delegates generische Objekte/Funktionen.

Im folgenden Beispiel stellen wir einen Delegaten bereit, der bei jedem Zugriff Nachrichten ausgibt.

Delegate mit Lesezugriff

Jeder Delegate muss mindestens die folgende Funktion für den Werttyp implementieren, der delegiert wird. Der Typ T hängt von der Klasse ab, die erweitert wird.

    operator fun getValue(example: Any, property: KProperty<*>): T {
        return // value of T
    }

Als Beispiel verwenden wir einen etwas anderen Code aus der Kotlin-Referenzseite. Unser Delegat kann nur für einen Stringtyp verwendet werden und gibt nur leere Strings zurück. Dieser Code ist ziemlich nutzlos, aber er zeigt die Verwendung einer schreibgeschützten Eigenschaft.

class Example {
    val p: String by Delegate()
}

class Delegate {

    operator fun getValue(example: Any, property: KProperty<*>): String {
        return ""
    }

}

Delegate mit Lese- und Schreibrechten

Um den obigen Delegaten für eine Lese- und Schreibimplementierung zu verwenden, müssen wir auch die folgende Funktion implementieren. Beachten Sie, dass wir einen String-Wert-Typ haben. Deshalb schreiben wir s: String. Sie müssen dies an den Typ anpassen, den Sie verwenden möchten.

    operator fun setValue(example: Any, property: KProperty<*>, s: String) {
        
    }

Unser obiges Beispiel kann nun die erforderliche Funktion implementieren. Wir werden eine Klassenvariable cached einführen, die den aktuellen Wert enthalten wird.

class Example {
    var p: String by Delegate()
}

class Delegate {
    var cached = ""

    operator fun getValue(example: Any, property: KProperty<*>): String {
        return cached
    }

    operator fun setValue(example: Any, property: KProperty<*>, s: String) {
        cached = s
    }
}

Schnittstelle als Erweiterung (extension) implementieren

Ein anderer Ansatz besteht darin, die Funktionen getValue() und setValue() als Erweiterungsfunktion für die Klasse Delegate zu implementieren. Dies ist nützlich, wenn die Klasse nicht in Ihrer Quellcodekontrolle enthalten ist.

class Example {
    val string : String by Delegate()
}

class Delegate {
}

operator fun Delegate.getValue(example: Any, property: KProperty<*>): String {
    return ""
}

Allgemeine Delegatendefinition

Der offensichtliche Nachteil ist, dass dieser Code nur mit Objekten des Typs string funktioniert. Wenn wir ihn für andere Typen verwenden wollen, müssen wir eine generische Klasse verwenden. Wir zeigen Ihnen, wie das obige Beispiel so geändert werden kann, dass es mit allen Typen konform ist.

class Example {
    var string: String by Delegate("hello")
    var int : Int by Delegate(3)
}

class Delegate<T>(var cached: T) {
    operator fun getValue(example: Any, property: KProperty<*>): T {
        return cached
    }

    operator fun setValue(example: Any, property: KProperty<*>, s: T) {
        cached = s
    }
}

Anonymes Objekt-Delegate

Kotlin bietet auch eine Möglichkeit, anonyme Objekt-Delegates zu erstellen, ohne neue Klassen zu kreeieren. Dies funktioniert aufgrund der Schnittstellen ReadOnlyProperty und ReadWritePropery der Standardbibliothek. Diese Interfaces stellen den getValue() für ReadOnlyProperty zur Verfügung. ReadWritePropery erweitert ReadOnlyProperty durch Hinzufügen der Funktion setValue().

Im obigen Beispiel wird die delegierte Eigenschaft als anonymes Objekt durch einen Funktionsaufruf erstellt.

class Example {
    val string: String by delegate()
}

fun delegate(): ReadWriteProperty<Any?, String> =
    object : ReadWriteProperty<Any?, String> {
        var curValue = ""
        override fun getValue(thisRef: Any?, property: KProperty<*>): String = curValue
        override fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
            curValue = value
        }
    }

An eine andere Eigenschaft delegieren

Delegating from one property to another can be a quite useful trick to provide backward compatibility. Imagine in version 1.0 of a class you had some public member functions. But during their lifetime the name changed. To not break clients we can mark the old member functions deprecated (via annotation) and forward their calls to the new implementation.

Das Delegieren von einer Eigenschaft an eine andere kann ein recht nützlicher Trick sein, um Abwärtskompatibilität zu gewährleisten. Stellen Sie sich vor, in Version 1.0 einer Klasse hätten Sie einige öffentliche Mitgliedsfunktionen. Aber während ihrer Lebensdauer wurde der Name geändert. Um Benutzer der Klasse nicht zu beeiträchtigen, können wir die alten Mitgliedsfunktionen als veraltet markieren (per Annotation) und ihre Aufrufe an die neue Implementierung weiterleiten.

class Example {
    var newName: String = ""

    @Deprecated("Use 'newName' instead", ReplaceWith("newName"))
    var oldName: String by this::newName
}

fun main() {
    val example = Example()
    example.oldName = "hello"

    println(example.newName)

}

Wir haben die Erfahrung gemacht, dass dieser Code in einigen Fällen nicht kompiliert werden kann und einen Fehlercode aufweist:

Type 'KMutableProperty0' has no method 'getValue(MyClass, KProperty<*>)' and thus it cannot serve as a delegate.

Da wir wissen, dass es ein offenes Bug-Ticket (LINK) gibt, hoffen wir, dass das Problem bald behoben wird!

Delegationsbeispiele und Anwendungsfälle

Kotlin bietet einige vorimplementierte Lösungen für häufig auftretende Probleme. Eine vollständige Liste finden Sie auf der Seite KotlinLang. Für die aktuellen Implementierungen zeigen wir Ihnen ein paar Beispiele.

By observable

Grundlegende Funktionen können durch die Verwendung des observable delegate erreicht werden. Er bietet einen Callback, wenn sich der Wert ändert. Das Gute daran ist, dass Sie Zugriff auf die neuen und vorherigen Werte haben. Um den observable delegate zu verwenden, müssen Sie den Anfangswert angeben. Weitere Informationen finden Sie in unserem Observer-Musterbeispiel.

class Book {
    var content : String by observable("") { property, oldValue, newValue ->
        println("Book changed")
    }
}

fun main() {
    val book = Book()
    book.content = "New content"
}

By vetoable

Der Vetoable ist ähnlich wie der Observer-Delegate. Die Callback-Funktion kann jedoch verwendet werden, um die Änderung rückgängig zu machen. Beachten Sie, dass der Rückruf einen booleschen Wert zurückgeben muss. Im Erfolgsfall ist es true und im Fehlerfall (Veto) false. In unserem Buchbeispiel prüfen wir, ob der neue Inhalt des Buches null oder leer ist. In diesem Fall wird er als falsch angesehen und wir können ein Veto gegen die Änderung einlegen.

class Book {
    var content : String by vetoable("") { property, oldValue, newValue ->
        !newValue.isNullOrEmpty()
    }
}

fun main() {
    val book = Book()
    book.content = "New content"
    println(book.content)

    book.content = ""
    println(book.content)
}

By Lazy

Kotlin bietet eine bestehende Implementierung für die verzögerte Berechnung von Variablen. Ein häufiger Anwendungsfall sind Membervariablen, deren Initialisierung viel Zeit in Anspruch nimmt, die aber zu Beginn nicht direkt verwendet werden. Nehmen wir zum Beispiel eine Book-Klasse, die einen String erhält, der den gesamten Text repräsentiert. Die Klasse Book enthält eine andere Klasse, die eine teure Nachbearbeitung des Buches vornimmt. Die Entwickler haben beschlossen, diese Instanz lazy zu machen, da sie nicht immer aufgerufen wird.

Die Lazy-Eigenschaft ist an die Analyser-Klasse angebunden. Wenn der Code ausgeführt wird, wird zuerst ausgegeben, dass das Buch erstellt wurde und dann, dass die Analyser Member-Variable instanziiert wurde. Wenn Sie mehr darüber erfahren möchten, wie Kotlin mit Konstruktoren und Init-Blöcken umgeht, lesen Sie unser Konstruktor-Tutorial.

class Book(private val rawText: String) {

    private val analyser: Analyser by lazy { Analyser() }

    fun analyse() {
        analyser.doSomething()
    }
}

class Analyser {
    init {
        println("Init Analyser class")
    }

    fun doSomething() {
        println("DoSomething")
    }
}

fun main() {
    val rawText = "MyBook"
    val book = Book(rawText)
    println("Book is created")
    book.analyse()
}

By Not Null

Gibt einen Eigenschaftsdelegaten für eine Lese-/Schreibeigenschaft mit einem Nicht-Null-Wert zurück, der nicht während der Objekterstellung, sondern zu einem späteren Zeitpunkt initialisiert wird. Da er zu einem späteren Zeitpunkt erstellt wird, ist er mit dem “Lazy Initializing” verwandt. Ein großer Nachteil ist jedoch, dass es keinen nativen Weg gibt, um zu wissen, ob der Wert initialisiert ist. Es ist ein gewisser Boilerplate-Code erforderlich, um ihn zu speichern. Unser Book Beispiel würde wie der folgende Code aussehen. Wir bevorzugen die By-Lazy Variante.

class Analyser {
    init {
        println("Init Analyser class")
    }

    fun doSomething() {
        println("DoSomething")
    }
}

class Book(private val rawText: String) {

    var analyser: Analyser by Delegates.notNull()

    fun analyse() {
        analyser = Analyser()
        analyser.doSomething()
    }
}

fun main() {
    val rawText = "MyBook"
    val book = Book(rawText)
    println("Book is created")
    book.analyse()
}

Logging

You can provide access to the application logging library with a delegate. In function with lazy (see below), it is the most idiomatic way to provide access to the logger to every class needed.

Sie können den Zugriff auf die Logging Bibliothek der Applikatiom mit einem Delegaten bereitstellen. In der Funktion mit Lazy (siehe unten) ist dies der idiomatischste Weg, um jeder benötigten Klasse den Zugriff auf den Logger zu ermöglichen.

fun <R : Any> R.logger(): Lazy<Logger> {
    return lazy { Logger.getLogger(unwrapCompanionClass(this.javaClass).name) }
}

class Something {
    val LOG by logger()

    fun foo() {
        LOG.info("Hello from Something")
    }
}