Funzioni in linea in Kotlin

1. Panoramica

In Kotlin, le funzioni sono cittadini di prima classe, quindi possiamo passare le funzioni o restituirle proprio come altri tipi normali. Tuttavia, la rappresentazione di queste funzioni in fase di esecuzione a volte può causare alcune limitazioni o complicazioni nelle prestazioni.

In questo tutorial, prima enumereremo due problemi apparentemente non correlati su lambda e generici e poi, dopo aver introdotto le funzioni in linea , vedremo come possono affrontare entrambi questi problemi, quindi iniziamo!

2. Problemi in paradiso

2.1. Il sovraccarico di Lambdas a Kotlin

Uno dei vantaggi delle funzioni di essere cittadini di prima classe in Kotlin è che possiamo trasferire un pezzo di comportamento ad altre funzioni. Passare le funzioni come lambda ci consente di esprimere le nostre intenzioni in un modo più conciso ed elegante, ma questa è solo una parte della storia.

Per esplorare il lato oscuro dei lambda, reinventiamo la ruota dichiarando una funzione di estensione per filtrare le raccolte:

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

Ora, vediamo come la funzione sopra si compila in Java. Concentrarsi sulla funzione predicato che viene passata come parametro:

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

Notate come viene gestito il predicato utilizzando l' interfaccia Function1 ?

Ora, se lo chiamiamo in Kotlin:

sampleCollection.filter { it == 1 }

Qualcosa di simile al seguente verrà prodotto per racchiudere il codice lambda:

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

Ogni volta che dichiariamo una funzione di ordine superiore, verrà creata almeno un'istanza di quei tipi speciali di Funzione * .

Perché Kotlin lo fa invece di, diciamo, usare invokedynamic come fa Java 8 con lambda? In poche parole, Kotlin punta alla compatibilità con Java 6 e invokedynamic non è disponibile fino a Java 7.

Ma non è finita qui. Come possiamo intuire, la semplice creazione di un'istanza di un tipo non è sufficiente.

Per eseguire effettivamente l'operazione incapsulata in un lambda di Kotlin, la funzione di ordine superiore - filtro in questo caso - dovrà chiamare il metodo speciale chiamato invoke sulla nuova istanza. Il risultato è un sovraccarico maggiore a causa della chiamata extra.

Quindi, solo per ricapitolare, quando passiamo un lambda a una funzione, accade quanto segue sotto il cofano:

  1. Almeno un'istanza di un tipo speciale viene creata e archiviata nell'heap
  2. Accadrà sempre una chiamata di metodo extra

Un'altra allocazione di istanze e un'altra chiamata al metodo virtuale non sembrano così male, giusto?

2.2. Chiusure

Come abbiamo visto in precedenza, quando passiamo un lambda a una funzione, verrà creata un'istanza di un tipo di funzione, simile alle classi interne anonime in Java.

Proprio come con quest'ultimo, un'espressione lambda può accedere alla sua chiusura , cioè alle variabili dichiarate nell'ambito esterno. Quando un lambda acquisisce una variabile dalla sua chiusura, Kotlin archivia la variabile insieme al codice lambda di acquisizione.

Le allocazioni di memoria extra peggiorano quando un lambda acquisisce una variabile: la JVM crea un'istanza del tipo di funzione ad ogni chiamata . Per i lambda non di acquisizione, ci sarà solo un'istanza, un singleton , di quei tipi di funzione.

Come siamo così sicuri di questo? Reinventiamo un'altra ruota dichiarando una funzione per applicare una funzione su ogni elemento della raccolta:

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

Per quanto sciocco possa sembrare, qui moltiplicheremo ogni elemento della raccolta per un numero casuale:

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

E se diamo un'occhiata all'interno del bytecode usando 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

Quindi possiamo individuare dall'indice 51 che la JVM crea una nuova istanza della classe interna MainKt $ main $ 1 per ogni chiamata. Inoltre, l'indice 56 mostra come Kotlin cattura la variabile casuale. Ciò significa che ogni variabile catturata verrà passata come argomenti del costruttore, generando così un sovraccarico di memoria.

2.3. Digita Cancellazione

Quando si tratta di generici sulla JVM, non è mai stato un paradiso, tanto per cominciare! Ad ogni modo, Kotlin cancella le informazioni sul tipo generico in fase di esecuzione. Ovvero, un'istanza di una classe generica non conserva i parametri di tipo in fase di esecuzione .

Ad esempio, quando si dichiarano alcune raccolte come List o List, tutto ciò che abbiamo in fase di esecuzione sono solo elenchi non elaborati. Questo sembra estraneo ai numeri precedenti, come promesso, ma vedremo come le funzioni inline siano la soluzione comune per entrambi i problemi.

3. Funzioni inline

3.1. Rimozione dell'overhead di Lambdas

Quando si usano lambda, le allocazioni di memoria extra e la chiamata al metodo virtuale extra introducono un sovraccarico di runtime. Quindi, se eseguissimo lo stesso codice direttamente, invece di utilizzare lambda, la nostra implementazione sarebbe più efficiente.

Dobbiamo scegliere tra astrazione ed efficienza?

Come risulta, con le funzioni inline in Kotlin possiamo avere entrambe le cose! Possiamo scrivere i nostri lambda belli ed eleganti e il compilatore genera il codice inline e diretto per noi. Tutto quello che dobbiamo fare è inserire un inline su di esso:

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

Quando si utilizzano funzioni inline, il compilatore integra il corpo della funzione. Cioè, sostituisce il corpo direttamente nei punti in cui viene chiamata la funzione. Per impostazione predefinita, il compilatore integra il codice sia per la funzione stessa che per i lambda ad essa passati.

Ad esempio, il compilatore traduce:

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

A qualcosa come:

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

Quando si utilizzano funzioni inline, non vi è alcuna allocazione di oggetti extra e nessuna chiamata a metodi virtuali aggiuntivi .

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 questo articolo, ci siamo tuffati in problemi con le prestazioni lambda e la cancellazione del tipo in Kotlin. Quindi, dopo aver introdotto le funzioni inline, abbiamo visto come queste possono risolvere entrambi i problemi.

Tuttavia, dovremmo cercare di non abusare di questi tipi di funzioni, specialmente quando il corpo della funzione è troppo grande poiché la dimensione del bytecode generato potrebbe aumentare e potremmo anche perdere alcune ottimizzazioni JVM lungo il percorso.

Come al solito, tutti gli esempi sono disponibili su GitHub.