Fonctions en ligne dans Kotlin

1. Vue d'ensemble

Dans Kotlin, les fonctions sont des citoyens de première classe, nous pouvons donc les transmettre ou les renvoyer comme les autres types normaux. Cependant, la représentation de ces fonctions au moment de l'exécution peut parfois entraîner quelques limitations ou complications de performances.

Dans ce didacticiel, nous allons d'abord énumérer deux problèmes apparemment sans rapport avec les lambdas et les génériques, puis, après avoir présenté les fonctions en ligne , nous verrons comment ils peuvent résoudre ces deux problèmes, alors commençons!

2. Problèmes au paradis

2.1. Les frais généraux des lambdas à Kotlin

L'un des avantages des fonctions d'être des citoyens de première classe à Kotlin est que nous pouvons transmettre un comportement à d'autres fonctions. Passer des fonctions comme des lambdas nous permet d'exprimer nos intentions de manière plus concise et élégante, mais ce n'est qu'une partie de l'histoire.

Pour explorer le côté obscur des lambdas, réinventons la roue en déclarant une fonction d'extension pour filtrer les collections:

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

Voyons maintenant comment la fonction ci-dessus se compile en Java. Focus sur la fonction de prédicat qui est passée en paramètre:

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

Remarquez comment le prédicat est géré à l'aide de l' interface Function1 ?

Maintenant, si nous appelons cela dans Kotlin:

sampleCollection.filter { it == 1 }

Quelque chose de similaire à ce qui suit sera produit pour envelopper le code lambda:

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

Chaque fois que nous déclarons une fonction d'ordre supérieur, au moins une instance de ces types spéciaux de fonction * sera créée .

Pourquoi Kotlin fait-il cela au lieu, par exemple, d'utiliser invokedynamic comme le fait Java 8 avec les lambdas? En termes simples, Kotlin opte pour la compatibilité Java 6, et invokedynamic n'est pas disponible avant Java 7.

Mais ce n’est pas la fin. Comme on peut le deviner, il ne suffit pas de créer une instance d'un type.

Afin d'effectuer réellement l'opération encapsulée dans un lambda Kotlin, la fonction d'ordre supérieur - filtre dans ce cas - devra appeler la méthode spéciale nommée invoke sur la nouvelle instance. Le résultat est plus de frais généraux en raison de l'appel supplémentaire.

Donc, juste pour récapituler, lorsque nous passons un lambda à une fonction, ce qui suit se passe sous le capot:

  1. Au moins une instance d'un type spécial est créée et stockée dans le tas
  2. Un appel de méthode supplémentaire se produira toujours

Une allocation d'instance de plus et un autre appel de méthode virtuelle ne semblent pas si mauvais, non?

2.2. Fermetures

Comme nous l'avons vu précédemment, lorsque nous transmettons un lambda à une fonction, une instance d'un type de fonction sera créée, similaire aux classes internes anonymes en Java.

Tout comme avec ce dernier, une expression lambda peut accéder à sa fermeture , c'est-à-dire aux variables déclarées dans la portée externe. Lorsqu'un lambda capture une variable à partir de sa fermeture, Kotlin stocke la variable avec le code lambda de capture.

Les allocations de mémoire supplémentaires s'aggravent encore lorsqu'un lambda capture une variable: la JVM crée une instance de type de fonction à chaque appel . Pour les lambdas non capturants, il n'y aura qu'une seule instance, un singleton , de ces types de fonction.

Comment en sommes-nous si sûrs? Réinventons une autre roue en déclarant une fonction pour appliquer une fonction sur chaque élément de collection:

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

Aussi ridicule que cela puisse paraître, ici nous allons multiplier chaque élément de collection par un nombre aléatoire:

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

Et si nous jetons un coup d'œil à l'intérieur du bytecode en utilisant javap :

>> 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

Ensuite, nous pouvons repérer à partir de l'index 51 que la JVM crée une nouvelle instance de la classe interne MainKt $ main $ 1 pour chaque appel. De plus, l'index 56 montre comment Kotlin capture la variable aléatoire. Cela signifie que chaque variable capturée sera passée en tant qu'arguments du constructeur, générant ainsi une surcharge de mémoire.

2.3. Effacement de type

Quand il s'agit de génériques sur la JVM, cela n'a jamais été un paradis, pour commencer! Quoi qu'il en soit, Kotlin efface les informations de type générique au moment de l'exécution. Autrement dit, une instance d'une classe générique ne conserve pas ses paramètres de type au moment de l'exécution .

Par exemple, lors de la déclaration de quelques collections comme List ou List, tout ce que nous avons au moment de l'exécution sont simplement des List bruts . Cela ne semble pas lié aux problèmes précédents, comme promis, mais nous verrons comment les fonctions en ligne sont la solution commune aux deux problèmes.

3. Fonctions en ligne

3.1. Suppression des frais généraux des lambdas

Lors de l'utilisation de lambdas, les allocations de mémoire supplémentaires et l'appel de méthode virtuelle supplémentaire introduisent une surcharge d'exécution. Donc, si nous exécutions le même code directement, au lieu d'utiliser des lambdas, notre implémentation serait plus efficace.

Faut-il choisir entre abstraction et efficacité?

En fait, avec les fonctions en ligne de Kotlin, nous pouvons avoir les deux! Nous pouvons écrire nos jolies et élégantes lambdas, et le compilateur génère le code intégré et direct pour nous. Tout ce que nous avons à faire est de mettre un inline dessus:

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

Lors de l'utilisation de fonctions en ligne, le compilateur insère le corps de la fonction. Autrement dit, il remplace le corps directement dans les endroits où la fonction est appelée. Par défaut, le compilateur intègre le code à la fois pour la fonction elle-même et les lambdas qui lui sont passés.

Par exemple, le compilateur traduit:

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

À quelque chose comme:

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

Lors de l'utilisation de fonctions en ligne, il n'y a pas d'allocation d'objets supplémentaires ni d'appels de méthodes virtuelles supplémentaires .

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

Dans cet article, nous avons plongé dans les problèmes de performances lambda et d'effacement de type dans Kotlin. Ensuite, après avoir introduit les fonctions en ligne, nous avons vu comment celles-ci peuvent résoudre les deux problèmes.

Cependant, nous devrions essayer de ne pas abuser de ces types de fonctions, en particulier lorsque le corps de la fonction est trop grand car la taille du bytecode généré peut augmenter et que nous pouvons également perdre quelques optimisations JVM en cours de route.

Comme d'habitude, tous les exemples sont disponibles sur over sur GitHub.