Inline-Funktionen in Kotlin

1. Übersicht

In Kotlin sind Funktionen erstklassige Bürger, sodass wir Funktionen wie andere normale Typen weitergeben oder zurückgeben können. Die Darstellung dieser Funktionen zur Laufzeit kann jedoch manchmal einige Einschränkungen oder Leistungskomplikationen verursachen.

In diesem Tutorial werden wir zunächst zwei scheinbar nicht zusammenhängende Probleme mit Lambdas und Generika aufzählen. Nach der Einführung der Inline-Funktionen werden wir sehen, wie sie diese beiden Probleme lösen können. Beginnen wir also!

2. Ärger im Paradies

2.1. Der Overhead von Lambdas in Kotlin

Ein Vorteil von Funktionen als erstklassige Bürger in Kotlin ist, dass wir ein Stück Verhalten an andere Funktionen weitergeben können. Wenn wir Funktionen als Lambdas übergeben, können wir unsere Absichten präziser und eleganter ausdrücken, aber das ist nur ein Teil der Geschichte.

Um die dunkle Seite von Lambdas zu erkunden, erfinden wir das Rad neu, indem wir eine Erweiterungsfunktion zum Filtern von Sammlungen deklarieren :

fun  Collection.filter(predicate: (T) -> Boolean): Collection = // Omitted

Nun wollen wir sehen, wie die obige Funktion in Java kompiliert wird. Konzentrieren Sie sich auf die Prädikatfunktion , die als Parameter übergeben wird:

public static final  Collection filter(Collection, kotlin.jvm.functions.Function1);

Beachten Sie, wie das Prädikat mithilfe der Function1- Schnittstelle behandelt wird.

Wenn wir das in Kotlin nennen:

sampleCollection.filter { it == 1 }

Zum Umschließen des Lambda-Codes wird etwas Ähnliches wie das Folgende erstellt:

filter(sampleCollection, new Function1() { @Override public Boolean invoke(Integer param) { return param == 1; } });

Jedes Mal, wenn wir eine Funktion höherer Ordnung deklarieren, wird mindestens eine Instanz dieser speziellen Funktionstypen * erstellt .

Warum macht Kotlin dies, anstatt beispielsweise invokedynamic zu verwenden, wie es Java 8 mit Lambdas macht? Einfach ausgedrückt, Kotlin setzt auf Java 6-Kompatibilität und invokedynamic ist erst mit Java 7 verfügbar.

Aber das ist noch nicht alles. Wie wir vielleicht erraten haben, reicht es nicht aus, nur eine Instanz eines Typs zu erstellen.

Um die in einem Kotlin-Lambda gekapselte Operation tatsächlich auszuführen, muss die Funktion höherer Ordnung - in diesem Fall Filter - die spezielle Methode namens invoke für die neue Instanz aufrufen . Das Ergebnis ist mehr Overhead aufgrund des zusätzlichen Anrufs.

Um es noch einmal zusammenzufassen: Wenn wir ein Lambda an eine Funktion übergeben, geschieht Folgendes unter der Haube:

  1. Mindestens eine Instanz eines speziellen Typs wird erstellt und im Heap gespeichert
  2. Ein zusätzlicher Methodenaufruf wird immer stattfinden

Eine weitere Instanzzuweisung und ein weiterer virtueller Methodenaufruf scheinen nicht so schlecht zu sein, oder?

2.2. Verschlüsse

Wie wir bereits gesehen haben, wird beim Übergeben eines Lambda an eine Funktion eine Instanz eines Funktionstyps erstellt, ähnlich wie bei anonymen inneren Klassen in Java.

Genau wie bei letzterem kann ein Lambda-Ausdruck auf seinen Abschluss zugreifen, dh auf Variablen, die im äußeren Bereich deklariert sind. Wenn ein Lambda eine Variable aus seinem Abschluss erfasst, speichert Kotlin die Variable zusammen mit dem erfassten Lambda-Code.

Die zusätzlichen Speicherzuordnungen werden noch schlimmer, wenn ein Lambda eine Variable erfasst: Die JVM erstellt bei jedem Aufruf eine Funktionstypinstanz . Für nicht erfassende Lambdas gibt es nur eine Instanz, einen Singleton , dieser Funktionstypen.

Wie sind wir uns da so sicher? Lassen Sie uns ein anderes Rad neu erfinden, indem wir eine Funktion deklarieren, die eine Funktion auf jedes Auflistungselement anwendet:

fun  Collection.each(block: (T) -> Unit) { for (e in this) block(e) }

So albern es auch klingen mag, hier multiplizieren wir jedes Sammlungselement mit einer Zufallszahl:

fun main() { val numbers = listOf(1, 2, 3, 4, 5) val random = random() numbers.each { println(random * it) } // capturing the random variable }

Und wenn wir mit javap einen Blick in den Bytecode werfen :

>> javap -c MainKt public final class MainKt { public static final void main(); Code: // Omitted 51: new #29 // class MainKt$main$1 54: dup 55: fload_1 56: invokespecial #33 // Method MainKt$main$1."":(F)V 59: checkcast #35 // class kotlin/jvm/functions/Function1 62: invokestatic #41 // Method CollectionsKt.each:(Ljava/util/Collection;Lkotlin/jvm/functions/Function1;)V 65: return

Dann können wir anhand des Index 51 erkennen, dass die JVM für jeden Aufruf eine neue Instanz der inneren Klasse MainKt $ main $ 1 erstellt. Außerdem zeigt Index 56, wie Kotlin die Zufallsvariable erfasst. Dies bedeutet, dass jede erfasste Variable als Konstruktorargumente übergeben wird, wodurch ein Speicheraufwand generiert wird.

2.3. Geben Sie Erasure ein

Wenn es um Generika auf der JVM geht, war es zunächst nie ein Paradies! Auf jeden Fall löscht Kotlin die allgemeinen Typinformationen zur Laufzeit. Das heißt, eine Instanz einer generischen Klasse behält ihre Typparameter zur Laufzeit nicht bei .

Wenn zum Beispiel ein paar Sammlungen wie erklärt Liste oder Liste, alles , was wir zur Laufzeit haben , ist nur rohe Liste s. Dies scheint, wie versprochen, nichts mit den vorherigen Problemen zu tun zu haben, aber wir werden sehen, wie Inline-Funktionen die gemeinsame Lösung für beide Probleme sind.

3. Inline-Funktionen

3.1. Entfernen des Overheads von Lambdas

Bei Verwendung von Lambdas führen die zusätzlichen Speicherzuweisungen und der zusätzliche Aufruf der virtuellen Methode zu einem gewissen Laufzeitaufwand. Wenn wir also denselben Code direkt ausführen würden, anstatt Lambdas zu verwenden, wäre unsere Implementierung effizienter.

Müssen wir uns zwischen Abstraktion und Effizienz entscheiden?

As is turns out, with inline functions in Kotlin we can have both! We can write our nice and elegant lambdas, and the compiler generates the inlined and direct code for us. All we have to do is to put an inline on it:

inline fun  Collection.each(block: (T) -> Unit) { for (e in this) block(e) }

When using inline functions, the compiler inlines the function body. That is, it substitutes the body directly into places where the function gets called. By default, the compiler inlines the code for both the function itself and the lambdas passed to it.

For example, The compiler translates:

val numbers = listOf(1, 2, 3, 4, 5) numbers.each { println(it) }

To something like:

val numbers = listOf(1, 2, 3, 4, 5) for (number in numbers) println(number)

When using inline functions, there is no extra object allocation and no extra virtual method calls.

However, we should not overuse the inline functions, especially for long functions since the inlining may cause the generated code to grow quite a bit.

3.2. No Inline

By default, all lambdas passed to an inline function would be inlined, too. However, we can mark some of the lambdas with the noinline keyword to exclude them from inlining:

inline fun foo(inlined: () -> Unit, noinline notInlined: () -> Unit) { ... }

3.3. Inline Reification

As we saw earlier, Kotlin erases the generic type information at runtime, but for inline functions, we can avoid this limitation. That is, the compiler can reify generic type information for inline functions.

All we have to do is to mark the type parameter with the reified keyword:

inline fun  Any.isA(): Boolean = this is T

Without inline and reified, the isA function wouldn't compile, as we thoroughly explain in our Kotlin Generics article.

3.4. Non-Local Returns

In Kotlin, we can use the return expression (also known as unqualified return) only to exit from a named function or an anonymous one:

fun namedFunction(): Int { return 42 } fun anonymous(): () -> Int { // anonymous function return fun(): Int { return 42 } }

In both examples, the return expression is valid because the functions are either named or anonymous.

However, we can't use unqualified return expressions to exit from a lambda expression. To better understand this, let's reinvent yet another wheel:

fun  List.eachIndexed(f: (Int, T) -> Unit) { for (i in indices) { f(i, this[i]) } }

This function performs the given block of code (function f) on each element, providing the sequential index with the element. Let's use this function to write another function:

fun  List.indexOf(x: T): Int { eachIndexed { index, value -> if (value == x) { return index } } return -1 }

This function is supposed to search the given element on the receiving list and return the index of the found element or -1. However, since we can't exit from a lambda with unqualified return expressions, the function won't even compile:

Kotlin: 'return' is not allowed here

As a workaround for this limitation, we can inline the eachIndexed function:

inline fun  List.eachIndexed(f: (Int, T) -> Unit) { for (i in indices) { f(i, this[i]) } }

Then we can actually use the indexOf function:

val found = numbers.indexOf(5)

Inline functions are merely artifacts of the source code and don't manifest themselves at runtime. Therefore, returning from an inlined lambda is equivalent to returning from the enclosing function.

4. Limitations

Generally, we can inline functions with lambda parameters only if the lambda is either called directly or passed to another inline function. Otherwise, the compiler prevents inlining with a compiler error.

For example, let's take a look at the replace function in Kotlin standard library:

inline fun CharSequence.replace(regex: Regex, noinline transform: (MatchResult) -> CharSequence): String = regex.replace(this, transform) // passing to a normal function

The snippet above passes the lambda, transform, to a normal function, replace, hence the noinline.

5. Conclusion

In diesem Artikel haben wir uns mit Problemen mit der Lambda-Leistung und der Typlöschung in Kotlin befasst. Nachdem wir Inline-Funktionen eingeführt hatten, haben wir gesehen, wie diese beide Probleme lösen können.

Wir sollten jedoch versuchen, diese Arten von Funktionen nicht zu überbeanspruchen, insbesondere wenn der Funktionskörper zu groß ist, da die generierte Bytecode-Größe möglicherweise zunimmt und wir dabei möglicherweise auch einige JVM-Optimierungen verlieren.

Wie üblich sind alle Beispiele auf GitHub verfügbar.