Kotlin i Delegowanie Właściwości. Czyli Lazy, Observable i wiele więcej…

Właściwości klas (ang. class properties) możemy umownie podzielić na dwie grupy. Jedne po prostu zapisują i odczytują wartość swojego prywatnego pola. A drugie – zupełnie przeciwnie – posiadają dowolnie skomplikowaną logikę w getterach i setterach.

W ramach tych drugich, coraz częściej widzimy powtarzające się schematy (wzorce?) takie jak: lazy initialization, czy nasłuchiwanie zmian danej wartości.

Aby nie pisać tej standardowej logiki za każdym razem od nowa, Kotlin umożliwia zamknięcie jej wewnątrz klasy, i wielokrotne wykorzystanie za pomocą tzw. „właściwości delegowanych”.

Delegowanie Właściwości

Właściwości delegowane (ang. delegated properties) to specjalna konstrukcja języka, dzięki której możemy przekazać (czyt. delegować) wywołania metod get() i set() do innego obiektu (tzw. „delegata”).  Służy do tego słowo kluczowe by użyte przy deklaracji właściwości:

class Example {
    var prop: String by MyDelegate()
}

Oficjalna dokumentacja mówi, że taki delegat nie musi implementować żadnego interfejsu, a jedynie spełniać poniższe warunki:

  • Musi posiadać metodę getValue()
  • Jeśli właściwość jest zmienna (var) to musi również posiadać metodę setValue()

Przykład:

class MyDelegate {
    operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
        println("Odczyt wartości '${property.name}' obiektu ${thisRef}")
        return "Geeky Devs"
    }
    operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
        println("Zmiana wartości '${property.name}' obiektu ${thisRef}")
        println("Nowa wartość: $value")
    }
}

W parametrze thisRef otrzymujemy obiekt, który zawiera delegowaną właściwość (w naszym wypadku instancja klasy Example). Parametr property (typu KProperty) zawiera opis samej właściwości, m.in. jej nazwę. value jest natomiast wartością przekazaną do settera:

val e = Example()
println(e.prop)
e.prop = "Geeky Devs Blog"

Wynik:

Odczyt wartości 'prop' obiektu Example@41b25632
Geeky Devs
Zmiana wartości 'prop' obiektu Example@41b25632
Nowa wartość: Geeky Devs Blog

Podejście „bez interfejsu” jest czasem wygodne. Umożliwia np. dodanie metod getValue/setValue za pomocą rozszerzeń. Ale na co dzień ma sporą wadę: sami musimy zgadywać sygnaturę tych metod 😉 Dlatego jednak polecam implementację jednego z dwóch wbudowanych interfejsów:

interface ReadOnlyProperty<in R, out T> {
    operator fun getValue(thisRef: R, property: KProperty<*>): T
}

interface ReadWriteProperty<in R, T> {
    operator fun getValue(thisRef: R, property: KProperty<*>): T
    operator fun setValue(thisRef: R, property: KProperty<*>, value: T)
}

Co się dzieje „pod spodem”?

Tak naprawdę, mechanizm delegacji jest równie prosty, co błyskotliwy. Jak większość rzeczy w Kotlinie 😉

Dla każdej delegowanej właściwości jest tworzony dodatkowy prywatny obiekt – delegat. Następnie metody get() i set() wywołują odpowiednio metody getValue() i setValue() delegata:

// Kod napisany przez nas:
class Example {
    var prop: String by MyDelegate()
}

// Kod wygenerowany przez kompilator:
class Example {
    private val prop$delegate = MyDelegate()
    var prop: String
        get() = prop$delegate.getValue(this, this::prop)
        set(value: String) = prop$delegate.setValue(this, this::prop, value)
}

Proste i czyste rozwiązanie.

Standardowe Delegaty

Delegaty, w zależności od potrzeby, możemy napisać sami, opakować w klasę czy bibliotekę, i w łatwy wykorzystywać w dowolnym miejscu.

Natomiast w przypadku najczęściej wykorzystywanych wzorców, nie musimy ich nawet pisać, bo są zawarte w bibliotece standardowej Kotlina.

Poniżej kilka przykładów.

Lazy

Funkcja lazy() umożliwia nam wygodną implementację wzorca „lazy initialization” – czyli odroczenia inicjalizacji właściwości do momentu jej pierwszego użycia. Jest to funkcja przyjmująca jeden parametr – lambdę, która zostanie wywołana tylko raz, przy pierwszym wywołaniu get(). Potem będzie już tylko zwracany jej wynik:

val lazyProp: String by lazy {
    println("Some long initialization...")
    "Done!"
}

println(lazyProp)
println(lazyProp)

Wynik:

Some long initialization...
Done!
Done!

Observable

Delegates.observable() umożliwia obserwację zmian danej wartości. Przyjmuje dwa parametry: wartość początkową, i lambdę – handler, który zostanie odpalony po każdej zmianie wartości:

var name: String by Delegates.observable("<empty>") {
    prop, oldValue, newValue ->
    println("$oldValue -> $newValue")
}

name = "John"
name = "James"

Wynik:

<empty> -> John
John -> James

Vetoable

Delegates.vetoable() jest bardzo podobne do observable(). Różnica polega na tym, że handler jest wołany przed zmianą wartości, oraz że zyskuje on „prawo weta”. Zwracany Boolean określa, czy pozwalamy na zmianę wartości właściwości, czy też nie. false oznacza weto:

var name: String by Delegates.vetoable("<empty>") {
    prop, oldValue, newValue ->
    newValue == "James"
}

println(name)
name = "John"
println(name)
name = "James"
println(name)

Wynik:

<empty>
<empty>
James

Zapisywanie wartości w Mapie

Najciekawszym przykładem z tej listy jest zapisywanie wartości właściwości wewnątrz Mapy. Przydaje się on, kiedy chcemy robić coś „dynamicznego”, jak np. parsowanie JSONa do obiektu DAO, i z powrotem:

class User(val map: Map<String, Any?>) {
    val name: String by map
    val age: Int     by map
}

val sampleMap = mapOf("name" to "John", "age" to 33)
val user = User(sampleMap)
println(user.name)  //prints "John"
println(user.age)   //prints 33

Powyższy kod działa, ponieważ w Kotlinie typ Map jest rozszerzony o metody getValue() i setValue().

Jeśli dziwi Cię słówko to, to tłumaczę, że jest to zwykła funkcja rozszerzająca Any.to(), tylko zapisana w notacji infix.

Podsumowanie

Właściwości delegowane są kolejnym przykładem przyszłościowego myślenia twórców Kotlina. Zamiast dodawać do języka kolejne modyfikatory typu lazy czy observable (jak w innych językach), stworzyli mechanizm umożliwiający tworzenie dowolnych „modyfikatorów” w postaci „delegatów”.

Ja osobiście bardzo często korzystam z lazy(). Chyba nawet zbyt często 😉 A Tobie jak się podoba ten cały koncept? Korzystałeś już z delegacji? Podziel się swoimi przemyśleniami 😀

To wszystko na dziś. Do następnego wpisu!

 

Nie przegap kolejnych wpisów - subskrybuj!

4 komentarzy

  1. Bardzo ciekawe. Lazy pozwoliłoby pewnie zrobić Hibernate’a bez dynamicznych proxy w Javie albo czarów z bytecodem.

    1. Ciekawe. O tym nie pomyślałem. Choć wydaje mi się, że Kotlinowe lazy() nie ma aż takiej mocy sprawczej 😉
      Ale dzięki Tobie dopisałem rozdział „Co się dzieje „pod spodem”?”, który trochę bardziej to objaśnia.
      Dzięki! 😉

Dodaj komentarz