Kotlin + Lambda z Odbiorcą. Czyli ostatni składnik Idealnej Bezkosztowej Enkapsulacji

W poprzednich wpisach omówiliśmy sobie rozszerzenia i funkcje inline. Wiemy również, że Kotlin ma bardzo przyjemnie zaimplementowane wyrażenia lambda. Czas połączyć te wszystkie rzeczy, dodać do nich jeszcze jedną, i zobaczyć co z tego wyjdzie… 😉

Zapraszam do lektury.

Nie-zwykła Lambda

Naszym brakującym elementem jest tzw. „lambda z odbiorcą” (ang. „lambda with receiver”). Brzmi to groźnie, ale w rzeczywistości jest równie proste, co genialnie. Jest to po prostu możliwość wywołania lambdy na konkretnym obiekcie. Taki obiekt nazywamy wtedy „odbiorcą” (ang. receiver). Kiedy to zrobimy, wewnątrz takiej lambdy mamy bezpośredni dostęp do składowych tego obiektu.

Działa to bardzo podobnie do funkcji rozszerzających, w których wnętrzu mamy dostęp do metod rozszerzanego typu. Korzystając z analogii, można powiedzieć, że lambdy z odbiorcą to takie „anonimowe funkcje rozszerzające”.

Notacja jest również podobna. Poniżej zamieściłem dwa typy funkcyjne. Pierwszy jest typową funkcją bezparametrową, zwracającą Unit. Drugi posiada dodatkowo zdefiniowany typ odbiorcy:

() -> Unit          // zwykła funkcja
String.() -> Unit   // funkcja z odbiorcą typu String

Wewnątrz takiej funkcji mamy bezpośredni dostęp do metod typu String, np.:

val printStats: String.() -> Unit = {
    println(length)
    println(toUpperCase())
    println(toLongOrNull() ?: -1)
    println(startsWith("ABC"))
}

"DEF".printStats()

Dość teorii. Zobaczmy jak to wygląda w praktyce.

Biblioteka standardowa

Podobnie jak to było w przypadku rozszerzeń, lambdy z odbiorcą również są często wykorzystywane przez samych twórców Kotlina – w bibliotece standardowej.

Przeanalizujmy jeden taki przykład – funkcję apply(). Jej definicja wygląda następująco:

public inline fun <T> T.apply(block: T.() -> Unit): T {
    this.block()
    return this
}

Co tu mamy? Jest to funkcja rozszerzająca dowolny typ T. Jej jedynym parametrem block jest funkcja. Jednak nie jest to zwykła funkcja, ale funkcja ze zdefiniowanym odbiorcą typu T.

Skutek jest taki, że wewnątrz naszej funkcji rozszerzającej T.apply() możemy wywołać naszą lambdę block na instancji this, tak jakby była jego elementem składowym. Taka incepcja – rozszerzenie w rozszerzeniu 😉

Ale co to nam tak naprawdę daje? Jak pisałem wcześniej, wewnątrz lambdy możemy teraz wołać metody typu T. Zobaczmy więc, jak wygląda wywołanie funkcji apply().

Poniżej typowy przykład z moich apek Androidowych:

recyclerView?.apply {
    setHasFixedSize(true)
    layoutManager = LinearLayoutManager(context)
    adapter = ProductListAdapter(dataItems, this@MyActivity)
    addItemDecoration(DividerItemDecoration(context, LinearLayoutManager.VERTICAL))
}

Działa to tak, że jeśli recyclerView nie jest nullem, to wołamy kilka z jego metod i właściwości. Zaletą jest fakt, że żadnej z tych metod i właściwości nie musimy poprzedzać przedrostkiem recyclerView..

Dodatkowo, fakt iż funkcja apply() jest inline sprawia, że to rozwiązanie ma zerowy wpływ na wydajność. Po dekompilacji do Javy, będzie to wyglądać po prostu tak:

// Java
if (recyclerView != null) {
   recyclerView.setHasFixedSize(true);
   recyclerView.setLayoutManager((LayoutManager)(new LinearLayoutManager(recyclerView.getContext())));
   recyclerView.setAdapter((Adapter)(new ProductListAdapter((List)this.getDataItems(), (Context)this)));
   recyclerView.addItemDecoration((ItemDecoration)(new DividerItemDecoration(recyclerView.getContext(), 1)));
}

Kolejny przykład:

db.apply {
    execSQL(createTableSongSQL)
    execSQL(createTableAuthorSQL)
    execSQL(createTableLabelSQL)
    execSQL(loadFixturesSQL)
}

Takich przykładów można by mnożyć w nieskończoność. A ich głównym celem jest zwiększenie czytelności kodu po stronie wywołania.

Ukrywanie „ceremonii”

Ale to jeszcze nie wszystko. Zwróć uwagę, że funkcja apply() nie robi nic prócz wywołania lambdy block. A przecież rozszerzenia to pełnoprawne funkcje, które mogą wykonywać dowolną logikę! I tutaj zaczyna się prawdziwa zabawa… 😉

Posłużę się tutaj świetnym przykładem, jaki przedstawił kiedyś Jake Wharton w swojej prezentacji „Android Development with Kotlin”. Pamiętam, że gdy zobaczyłem to rozwiązanie po raz pierwszy, zrobiło na mnie ogromne wrażenie. W tamtym momencie doszło do mnie, jak potężnym językiem jest Kotlin!

Załóżmy że chcemy wykonać operacje na bazie danych, które wymagają transakcji. Standardowo w Androidzie taki kod wygląda następująco:

db.beginTransaction()
try {
    db.delete("songs", "author = ?", arrayOf("Ed Sheeran")) // 1
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

Każdorazowo musimy tu dochować całej „ceremonii” – rozpocząć transakcję, stworzyć blok try-finally, zatwierdzić lub anulować transakcję… I gdzieś między tym wszystkim wykonujemy operacje na bazie, które wymagają transakcji (linia 1). Po pierwsze, ten kod jest nieczytelny. Po drugie, łatwo możemy zapomnieć np. o oznaczeniu transakcji jako „successful”. I żadna statyczna analiza kodu nam tego nie wykryje…

W Kotlinie, korzystając z kombinacji rozszerzeń, funkcji inline, i wyrażeń lambda z odbiorcą, możemy każdą taką „ceremonię” z łatwością zamknąć (ang. encapsulate) wewnątrz jakiejś funkcji.

Oto jak to wygląda w praktyce:

inline fun SQLiteDatabase.inTransaction(block: SQLiteDatabase.() -> Unit) {
    beginTransaction()
    try {
        block()    // 1
        setTransactionSuccessful()
    } finally {
        endTransaction()
    }
}

Stworzyliśmy tu rozszerzenie typu SQLiteDatabase. Jedyny parametr jest funkcją z odbiorcą, również typu SQLiteDatabase. Całość jest oznaczona jako inline, więc zawartość funkcji inTransaction() zostanie „wklejona” w każdym miejscu wywołania, po uprzednim „wklejeniu” zawartości lambdy block w miejscu wywołania tej lambdy (linia 1).

Jak to wygląda po stronie wywołania?

db.inTransaction {
    delete("songs", "author = ?", arrayOf("Ed Sheeran"))
}

Czyli udało się nam ukryć całą powtarzalną logikę (aka „ceremonię”) wewnątrz funkcji inTransaction(). Po stronie wywołania mamy tylko lambdę, w której umieszczamy jedynie te operacje na bazie, które powinny być zawarte w transakcji. Szczegóły implementacji transakcji są ukryte. Czyli idealnie 😉

Anko Layouts

Oczywiście transakcje w bazie to tylko jedna z możliwości. Zastosowań tej strategii jest bez liku. Moim ulubionym przykładem jak dotąd jest DSL do tworzenia layout’ów Androidowych w bibliotece Anko. Oto mała próbka:

verticalLayout {
    val name = editText()
    button("Say Hello") {
        onClick { toast("Hello, ${name.text}!") }
    }
}

Wygląda trochę jak nie-Kotlin, prawda? Stąd właśnie modne ostatnio określenie DSL 😉 Ale, jak się bliżej przyjrzeć, to jest to najzwyklejszy Kotlin.

Najpierw mamy funkcję verticalLayout() z jednym parametrem – lambdą. Wewnątrz lambdy, kolejna funkcja – button() – tym razem dwuparametrowa: pierwszy typu String, drugi – lambda. Itd…

Kto choć raz próbował tworzyć layout dla Androida w kodzie, wie jaka to męczarnia. Natomiast tutaj mamy do bólu czytelny pseudo-kod, naśladujący strukturą XML’a.

Podsumowanie

Moim zdaniem, możliwości jakie daje kombinacja trzech elementów – rozszerzeń, funkcji inline, i wyrażeń lambda z odbiorcą – jest jednym z najlepszych przykładów geniuszu twórców Kotlina (brawo Andrey Breslav!). Wszystkie te elementy idealnie tutaj współgrają, dając nam developerom naprawdę niesamowite możliwości.

Jestem ciekaw, jakie Ty masz pomysły na zastosowanie tej wspaniałej trójcy? Na pewno nie raz natrafiłeś na API, które wymagało od Ciebie żmudnej „ceremonii”, a którą najchętniej byś „ukrył”, żeby jej więcej nie oglądać 😉

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

 

Nie przegap kolejnych wpisów - subskrybuj!

Dodaj komentarz