Kotlin + Funkcje Rozszerzające. Czyli lekarstwo na klasy Utils

Oraz tajna broń umożliwiająca świetną współpracę z Javą

Rozszerzenia to jedna z tych rzeczy, które naprawdę zmieniają sposób w jaki kodujemy. I, jak to zwykle bywa, mają tyle samo zwolenników, co przeciwników. W tym artykule opowiem czym są, co nam daje ich stosowanie, oraz jak wygląda ich implementacja w Kotlinie.

Czym są rozszerzenia?

Rozszerzenia (ang. Extensions) to sposób na dodanie funkcjonalności (np. metody) do klasy, bez konieczności ingerencji w jej kod, dziedziczenia z niej, czy stosowania wzorców projektowych typu „dekorator”.

Rozszerzenia nie są niczym nowym. Istnieją od dawna w językach takich jak Smalltalk, Ruby, JavaScript, C#, czy nawet Objective-C (tutaj noszą nazwę „kategorii”). Ale oczywiście nie w Javie…

Poniżej przykład prostego rozszerzenia w Kotlinie, wraz z jego użyciem:

// MyExtensions.kt
package com.example.demo.extensions

fun String.toCamelCase(): String {
    return this.replace... //details not important
}

fun String.print(): Unit {
    println(this)
}

// SomeOtherFile.kt
import com.example.demo.extensions.*

"lorem ipsum".toCamelCase().print() //prints: loremIpsum

Jak widzisz, dodaliśmy do typu String dwie nowe metody, i wywołujemy je tak jakby były metodami instancji. To jest właśnie magia rozszerzeń! 😉 Więcej szczegółów znajdziesz w dalszej części artykułu.

Na czym polega problem?

Zastanów się chwilę, jak możnaby rozwiązać powyższy problem (dodanie funkcjonalności do klasy String) bez wykorzystania rozszerzeń?

Gdyby klasa String nie była finalna, możnaby z niej dziedziczyć, i dodać nowe metody. Wtedy jednak powstaje nowy typ (nazwijmy go MyString). Więc nowa funkcjonalność będzie dostępna tylko w miejscach, które oczekują typu MyString. Innymi słowy, nie będzie dostępna dla starego kodu. Poza tym, String jest final, więc temat i tak jest zamknięty 😉

Od Javy 8 moglibyśmy wykorzystać Interfejs z metodami domyślnymi. Problem z final’em byłby rozwiązany. Ale nadal byłby to zupełnie nowy typ (interfejs), więc nie działałby ze starym kodem.

Więc co robimy? Oczywiście tworzymy klasę StringUtils, a w niej metody statyczne. I wtedy już wiemy, że to nie będzie dobry dzień… 😉 Patrzymy na wywołanie naszego kodu, i aż chce się płakać:

// Java
StringUtils.print(StringUtils.toCamelCase("lorem ipsum"));

Nawet import static niewiele pomaga. Nadal trzeba to czytać od prawej do lewej, a IDE w żaden sposób nam nie podpowie, że na Stringu możemy wykonać operację toCamelCase…

Rozwiązanie

Rozwiązaniem wszystkich powyższych problemów są właśnie „rozszerzenia”, które magicznie „dodają” metody do istniejących typów, przez co wołamy je dokładnie tak samo, jak metody instancyjne. Mają wiele zalet:

  • Rozszerzają funkcjonalność istniejących typów, nie tworząc nowych
  • Są lekarstwem na klasy *Utils
  • Umożliwiają dodawanie funkcjonalności nawet klasom finalnym
  • Nie ingerują w kod rozszerzanej klasy
  • Sprzyjają tworzeniu tzw. fluent interface‚ów
  • Umożliwiają rozszerzanie o operatory (+, - , *, / itp.)

Mają też kilka ograniczeń:

  • Mogą się odwoływać jedynie do publicznego API rozszerzanego typu
  • Nie mogą przetrzymywać stanu (tak samo jak Interfejsy)

Jak tworzymy rozszerzenia w Kotlinie?

Kotlin daje nam do dyspozycji dwa rodzaje rozszerzeń: funkcje rozszerzające i właściwości rozszerzające. Skupmy się teraz na tych pierwszych.

Funkcje rozszerzające

Aby zadeklarować „funkcję rozszerzającą”, tworzymy zwykłą funkcję na poziomie pliku (nie wewnątrz żadnej klasy), i poprzedzamy jej nazwę rozszerzanym typem. Wtedy, wewnątrz tej funkcji słówko this oznacza konkretną instancję, na której tę funkcję wołamy:

// SomeFile.kt
package com.example.extensions

fun String.wordCount(): Int {
    return this.trim().split(Regex("\\s+")).size
}

// OtherFile.kt
import com.example.extensions.wordCount

"Winter is coming".wordCount()    // equals 3

W powyższym przykładzie ponownie rozszerzamy typ String (zauważ „String.” przed nazwą funkcji). Wewnątrz tej funkcji, this oznaczać będzie zawsze konkretną instancję, na której wołamy metodę wordCount() (w naszym wypadku jest to "Winter is coming".

I tutaj od razu uwaga: w Kotlinie rozszerzenia są zawsze jawnie importowane. Dzięki temu wszystkim jest łatwiej. My dokładnie widzimy, co skąd pochodzi. A kompilator też nie musi przeszukiwać całego kodu w poszukiwaniu „magicznej” funkcji.

Właściwości rozszerzające

Kotlin oferuje również „właściwości rozszerzające”. Skoro funkcja wordCount() zwraca jedynie ilość słów w stringu, idealnie nadaje się do tego, by zamienić ją na właściwość „tylko do odczytu”. I tak też zróbmy:

// SomeFile.kt
package com.example.extensions

val String.wordCount: Int
    get() {
        return this.trim().split(Regex("\\s+")).size
    }

// OtherFile.kt
import com.example.extensions.wordCount

"Winter is coming".wordCount    // equals 3

Jak widać, zasady są właściwie identyczne. Deklarujemy właściwość, poprzedzając jej nazwę rozszerzaym typem. Wówczas wewnątrz gettera i settera słówko this przyjmuje wartość konkretnej instancji rozszerzanego typu.

Poszerzanie o operatory +, -, *, /

Wiemy już, że „rozszerzenia” polegają na „dodawaniu” funkcji do istniejących typów. Wiemy również (z mojego poprzedniego wpisu), że w Kotlinie przeciążanie operatorów polega na utworzeniu funkcji o odpowiedniej sygnaturze. Czy w związku z tym możemy za pomocą rozszerzeń „dodawać” do typów konkretne operatory? Odpowiedź brzmi: Tak! 🙂

Oto przykłady nowych wspaniałych operatorów dla typu String:

operator fun String.minus(other: String): String {
    return this.replace(other, "")
}

operator fun String.times(n: Int): String {
    return this.repeat(n)
}

// Przykład użycia:
val wnter = "Winter is coming" - "i"    // "Wnter s comng"
val errors = "Error" * 3                // "ErrorErrorError"

Oczywiście operatory - i * dla Stringa nie mają zbyt wiele sensu. Ale już np. algebra dla typu BigDecimal jest bardzo sensowna. I została zaimplementowana właśnie za pomocą rozszerzeń (link).

Co się dzieje „pod spodem”?

Na czym polega „magia” rozszerzeń? Tak naprawdę na niczym szczególnym. Rozszerzenia są tylko ładniejszą formą zapisu (ang. „syntactic sugar”) dla statycznych metod, które jako jedyny parametr przyjmują rozszerzany typ. Widać to dokładnie, gdy spojrzymy, jak wygląda nasze rozszerzenie z poziomu kodu Javy:

// Java
import com.example.demo.extensions.MyExtensionsKt;

MyExtensionsKt.toCamelCase("lorem ipsum");
MyExtensionsKt.print("it works!");

Tak naprawdę jest tworzona klasa o nazwie podobnej do nazwy pliku Kotlina, w którym definiujemy rozszerzenie (MyExtensionsKt), a w niej metody statyczne z jednym parametrem – rozszerzanym typem. Czyli mamy starą, dobrą klasę typu *Utils.

Biblioteka standardowa

Ciekawym faktem jest, że biblioteka standardowa Kotlina na potęgę wykorzystuje rozszerzenia. Jest to jedna z „tajnych broni”, które umożliwiają Kotlinowi tak bezproblemową współpracę z Javą. Twórcy Kotlina w wielu przypadkach, zamiast tworzyć nowe typy, po prostu „rozszerzają” istniejące typy Javowe o nowe funkcjonalności. Świetnym przykładem są tutaj kolekcje. Kotlin korzysta ze zwykłych Javowych kolekcji (ListIterable, HashMap), ale rozszerza je o metody takie jak filter()foreach(), czy flatMap().

To tyle na dziś. Jeśli się podobało – zostaw komentarz, udostępnij znajomym itd. 😉 Pozdrawiam i do następnego wpisu!

Nie przegap kolejnych wpisów - subskrybuj!

10 komentarzy

  1. Bardzo miło się na to patrzy 🙂 Ostatnio zaproponowałem przejście na Kotlina w firmie i odzew jest pozytywny – będę robił proof of concept czy wszystko ładnie się zgrywa z naszym softem. Także na pewno będę tu wbijał by być na bieżąco 😉

    Make Java great again! #KotlinFTW 🙂

    1. Dzięki 🙂 I myślę że firma będzie Ci wdzięczna 😉 Język jest już dojrzały (w końcu ma już ponad 6 lat), a mimo to wciąż się rozwija. Przechodzą na niego coraz większe firmy (np. Allegro). „Ficzerów” ma od groma, jakichś większych problemów też nie ma. Pozostaje czysty „fun” z kodowania 😉

      A jakbyście potrzebowali jakiegoś wewnętrznego szkolenia czy coś, to wiesz do kogo uderzać 😀

  2. Mam pytanie odnośnie roszczerzenia właściwości. Czy dla właściwości wordCount, kod: „return this.trim().split(Regex(„\\s+”)).size” będzie wykonywany ponownie z każdym wywołaniem metody get(), czy tylko za pierwszym razem? Wydaje mi się, że ten kod mógłby się wykonać tylko raz, bo wordCount jest poprzedzone słowem val, czego odpowiednikiem w Javie jest final. A jak final, to przypisywanie wartości zmiennych tylko jeden raz.
    Poza tym, dla tego przypadku wystarczy tylko raz wykonać ten kod.

    1. Cześć Kamil 🙂 Kod gettera właściwości będzie wykonywany za każdym razem. Z dwóch powodów:
      1) Słówko „val” w przypadku właściwości nie oznacza „final”, tylko „read-only” (czyli właściwość, która ma getter, a nie ma settera)
      2) Pamiętaj że rozszerzenia są tylko ładniejszą formą zapisu dla metod statycznych, i nie mogą przetrzymywać żadnego stanu obiektu, na którym są wywoływane.
      BTW, ta właściwość jest również widoczna z poziomu Javy – jako metoda MyExtensionsKt.getWordCount()

    2. Zawsze do usług 😉 Dzięki za pytanie 🙂 Piszesz coś ciekawego w Kotlinie?

    3. Niestety, jeszcze nie 🙁 Dopiero zaczynam się uczyć tego języka. I to w sumie dzięki Tobie i Twojej prezentacji na JUG Bydgoszcz.

    4. Cieszę się, że moja „ewangelizacja” działa 😉 Powodzenia!

    1. Dzięki! Staram się 🙂 Mam nadzieję, że nie rzucasz słów na wiatr, i usłyszę niedługo o Twoich projektach w Kotlinie 😉

Dodaj komentarz