Kotlin i Funkcje Inline. Czyli lambdy i typy generyczne na sterydach

Funkcje inline (funkcje „w linii”, funkcje otwarte) są konceptem znanym już z C++, potem C (w standardzie C99). Jednak potem zaskakująco rzadko o nich słychać. Aż tu nagle, powracają w wielkim stylu w Kotlinie. Okazuje się bowiem, że – po małym tuningu – są one świetnym narzędziem zwiększającym wydajność JVM’owej aplikacji. Ich nadużywanie ma jednak swoją cenę. Ale zacznijmy od początku…

Czym są Funkcje Inline?

Funkcja inline różni się od zwykłej funkcji tym, że w miejscu jej wywołania, kompilator nie umieszcza do niej wskaźnika (jak to zwykle bywa), lecz „wkleja” całą jej zawartość. Mówiąc kolokwialnie: kompilator robi copy-paste całej zawartości funkcji we wszystkich miejscach, gdzie jest ona wywoływana.

Plusem takiego rozwiązania jest nieznaczne przyspieszenie działania programu. Minusem jest wzrost rozmiaru pliku wykonywalnego. Bo jeśli funkcja jest wywoływana w pięciu miejscach, to jej zawartość zostanie skopiowana pięć razy.

W Kotlinie, taką funkcję tworzymy przez poprzedzenie definicji funkcji modyfikatorem inline, np.:

// Definicja
inline fun add(a: Int, b: Int): Int {
    return a+b
}

// Użycie
val sum = add(10, 20)

Co się dzieje „pod spodem”?

Żeby zobaczyć jak to naprawdę działa, zrobimy mały eksperyment: podejrzymy sobie byte-kod Kotlina, a potem zdekompilujemy go do Javy. Użyjemy poniższej klasy testowej:

class InlineTest {
    inline fun add(a: Int, b: Int): Int {
        return a+b
    }
    fun test() {
        val sum = add(10, 20)  //(1)
    }
}

Żeby mieć punkt odniesienia, najpierw usuwamy z powyższego przykładu słówko inline. Następnie otwieramy IntelliJ, i wchodzimy w: Tools -> Kotlin -> Show Kotlin Bytecode. Gdy zaznaczymy linię (1), naszym oczom ukażą się magiczne runy:

ALOAD 0
BIPUSH 10
BIPUSH 20
INVOKEVIRTUAL com/geekydevs/myapplication/InlineTest.add (II)I
ISTORE 1

…które po dekompilacji do Javy wyglądają tak:

// Java
int sum = this.add(10, 20);

Gdy jednak z powrotem wstawimy modyfikator inline,  byte-kod będzie wyglądał tak:

ALOAD 0
ASTORE 2
BIPUSH 10
ISTORE 3
BIPUSH 20
ISTORE 4

…co w Javie wygląda tak:

// Java
byte a = 10;
byte b = 20;
int sum = a+b;

Czyli widać, że to działa 🙂

Jakie nam dają korzyści?

No właśnie. Tak szczerze, jakie to nam daje korzyści? Przecież, przy dzisiejszej mocy obliczeniowej, koszt wywołania funkcji jest właściwie żaden.

Podręcznikowa definicja mówi, aby używać inline dla małych, często wywoływanych funkcji, i jako przykład pokazywana jest zawsze prosta funkcja wywoływana miliony razy w pętli – wtedy faktycznie widać jakieś przyspieszenie. Ale jak to się ma do rzeczywistości? Potrzebujemy bardziej praktycznych przykładów.

Na szczęście Kotlin ma w zanadrzu kilka trików 😉

Funkcje Inline vs Lambdy

Sytuacja nabiera kolorów, gdy parametrem Twojej funkcji jest lambda.

Pamiętajmy, że Kotlina obowiązują standardowe ograniczenia JVM. Czyli każda lambda jest obiektem – najczęściej klasą anonimową. Dodatkowo, lambdy mają dostęp do wszystkich zmiennych, które są w ich zasięgu –  czyli tzw. „domknięcie” (ang. closure). Te wszystkie rzeczy trzeba przecież zaalokować. A to już może mieć spory wpływ na wydajność.

I tutaj znów z pomocą przychodzą funkcje inline. Bowiem wszystkie lambdy będące parametrami takiej funkcji, będą również umieszczone „w linii” (ang. inlined).

Rozpatrzmy to na przykładzie funkcji let() zawartej w bibliotece standardowej Kotlina. Wygląda ona tak:

// Definicja
inline fun <T, R> T.let(block: (T) -> R): R = block(this)

// Przykładowe użycie
user?.let { 
    println("User is not null")
    println("He's name is ${it.name}")
}

Gdyby nie była ona oznaczona jako „inline”, to lambda zostałaby skompilowana do klasy anonimowej (lub zagnieżdżonej), i ogólnie byłoby sporo nadmiarowych obiektów – czyli negatywny wpływ na wydajność. Jednak jako że jest inline, to zdekompilowany byte-kod wygląda tak:

// Java
String var6 = "User is not null";
System.out.println(var6);
var6 = "He\'s name is " + user.getName();
System.out.println(var6);

Genialny wynalazek, prawda? 😀

noinline

Jeśli, z jakichś powodów, nie chcemy aby nasza lambda była „w linii” (np. chcemy jej użyć asynchronicznie), zawsze możemy ją oznaczyć modyfikatorem noinline.

Funkcje Inline vs Typy Generyczne

Druga ciekawa implikacja inline w Kotlinie jest związana z typami generycznymi.

Mianowicie, funkcje inline mogą tutaj korzystać z tzw. reified generics (w wolnym tłumaczeniu: „skonkretyzowane typy generyczne”). Dają nam one możliwość wyłuskania Klasy wewnątrz funkcji, bez konieczności przekazywania obiektu Class w parametrze (co zwykle robimy w Javie).

Tutaj fajnym przykładem jest np. rejestracja bean’ów w Springu 5.0:

// Java
context.registerBean(Foo.class);
context.registerBean(Bar.class, () ->
        new Bar(context.getBean(Foo.class))
);

// Kotlin
context.registerBean<Foo>()
context.registerBean {
    Bar(it.getBean<Foo>())
}

Albo startowanie Aktywności w Androidzie w bibliotece Anko:

// Java
startActivity(new Intent(this, SecondActivity.class))

// Kotlin + Anko
startActivity<SecondActivity>()

Nie wnikając za bardzo w szczegóły samego Anko, definicja funkcji startActivity() wygląda tak:

inline fun <reified T: Activity> Context.startActivity(vararg params: Pair<String, Any>) {
    AnkoInternals.internalStartActivity(this, T::class.java, params)
}

Pomińmy nieistotne w tej chwili rzeczy: parametry vararg, oraz fakt że funkcja ta jest rozszerzeniem. Ważne jest, że typ generyczny poprzedzony jest słówkiem reified, co z kolei pozwala nam na użycie T::class.java, i uwalnia nas od konieczności przekazywania clazz: Class<T> w parametrze funkcji.

Podsumowanie

W mojej opinii, funkcje inline są nadspodziewanie przydatnym narzędziem na JVM. Z początku wydają się niepozorne, wręcz nieprzydatne, jednak pozwalają nam pisać bardziej czytelny kod, bez negatywnych skutków dla wydajności aplikacji.

A co Ty o tym sądzisz? Może znasz jakieś ciekawe zastosowania inline, których tutaj nie poruszyłem? Podziel się swoją opinią w komentarzu 😉

 

Nie przegap kolejnych wpisów - subskrybuj!

Dodaj komentarz