Risparmia 100€ con il codice BIGADATA100

Big Data Executive | Milano | sabato 30 novembre

Iscriviti
Home
Kotlin: definire funzioni al volo

04 Settembre 2019

Kotlin: definire funzioni al volo

di

Google lo ha scelto come successore ideale di Java per lo sviluppo software su Android e più in generale in campo mobile. Programmatori in erba oppure veterani, Kotlin segna un passaggio generazionale che per tutti merita un approfondimento.

Kotlin è un linguaggio apprezzato per la sua semplicità nel definire in modo immediato costrutti che in altri linguaggi sarebbero molto prolissi. Uno dei meccanismi più apprezzati va sotto il nome di funzioni letterali, ovvero funzioni che si possono creare al volo e quindi passare come parametri ad altre funzioni, o assegnare semplicemente a delle variabili. Esse vengono definite secondo le regole descritte dal linguaggio formale nella figura qui sotto.

Funzioni letterali in Kotlin

Funzioni letterali in Kotlin.

Esistono due tipi diversi di funzioni letterali, che vogliamo esaminare in modo più approfondito.

Espressioni lambda

Una espressione lambda è un modo per definire una funzione on the fly attraverso una sintassi descritta nella figura qui sopra, ma che possiamo descrivere attraverso qualche semplice esempio. La funzione square() che abbiamo definito in precedenza può essere espressa come espressione lambda nel seguente modo:

{ x: Int -> x * x }

A sinistra del simbolo -> abbiamo l’elenco dei parametri di input; in questo caso solamente il parametro x, di tipo Int. Di seguito abbiamo il corpo vero e proprio della lambda, che calcola il quadrato di x. Possiamo fare due importanti osservazioni. Innanzitutto abbiamo avuto la necessità di definire il tipo della variabile x, in quanto il compilatore non sarebbe stato in grado di dedurne il tipo nemmeno nel caso in cui avessimo assegnato la lambda a una variabile, come nel seguente codice:

val lamdaSquare = { x: Int -> x * x }

Questo sarebbe stato invece possibile nel caso in cui avessimo esplicitato il tipo della variabile nel seguente modo:

typealias IntFun = (Int) -> Int
    val lamdaSquare: IntFun = { x -> x * x }

La seconda osservazione riguarda il non utilizzo della parola chiave return, che non è appunto permesso all’interno di un’espressione lambda.

Nel caso di espressioni con un numero maggiore di parametri, essi dovranno essere elencati separati da una virgola, come nel seguente caso:

{ x: Int, y: Int -> x * y }

Che cosa fare nel caso di lambda senza parametri di input? In questo caso è possibile scrivere semplicemente:

{ <expr> }

Qui <expr> è un’espressione qualsiasi. È interessante notare come anche la seguente sarebbe lecita pur se il simbolo -> è ridondante, tanto che IntelliJ ne suggerirebbe l’eliminazione:

{ -> <expr> }

Un esempio potrebbe quindi essere il seguente:

val noInput = { 10 }

È interessante notare come anche il codice seguente sarebbe perfettamente lecito:

val empty = { }

Il tipo di empty sarebbe:

() -> Unit

Concludiamo sottolineando come il tipo restituito dalla lambda è quello dell’ultima espressione in esso contenuta e come queste due espressioni siano molto diverse tra loro:

val noInput = { 10 }
    val ten = 10

Potete notarlo dai relativi tipi, che evidenziamo di seguito:

val noInput: () -> Int = { 10 }
    val ten: Int = 10

Passare lamba come parametro di una funzione high order

La notazione lambda ci permette quindi di definire delle funzioni al volo. Sono l’ideale nel caso di funzioni high order. Consideriamo per esempio il codice seguente:

typealias IntOp = (Int, Int) -> Int
    
    fun calculate(x: Int, y: Int, f: IntOp) = f(x, y)
    
    val add: IntOp = { x, y -> x + y }
    val sub: IntOp = { x, y -> x - y }
    val mul: IntOp = { x, y -> x * y }
    val div: IntOp = { x, y -> x / y }

Inizialmente definiamo l’alias IntOp come tipo di tutte le funzioni che hanno due parametri di tipo Int e producono un Int; in pratica un’operazione tra Int. Definiamo poi la funzione calculate() che accetta in input due Int e la funzione che rappresenta, appunto, l’operazione da eseguire su di essi. Notiamo come il suo corpo sia molto breve, ovvero consista nella semplice invocazione della funzione sugli altri parametri. Di seguito abbiamo definito le funzioni corrispondenti alle quattro operazioni. Questo significa che è possibile eseguire una somma tra due valori semplicemente attraverso la seguente espressione:

calculate(2, 3, add)

Potremmo eseguire il prodotto semplicemente cambiando la funzione passata come terzo parametro:

calculate(2, 3, mul)

In questo caso abbiamo definito quattro costanti per le quattro operazioni, ma avremmo potuto anche passare l’espressione lambda direttamente nell’invocazione, come nel seguente esempio:

calculate(2, 3, { x, y -> x / y })

Questa rappresenta, in realtà, la vera utilità di questa notazione, ma possiamo fare ancora meglio. Infatti, quando il parametro di tipo funzione è l’ultimo, è possibile scrivere la lambda direttamente fuori dall’invocazione principale, ovvero è possibile scrivere:

calculate(2, 3) { x, y -> x / y }

Il tutto è sicuramente più leggibile.

Per comprendere l’utilità di questa notazione, supponiamo di voler definire una funzione che permette di misurare il tempo impiegato per l’esecuzione di un’altra funzione. Alla luce di quanto visto, possiamo definire la funzione measure nel seguente modo:

fun measure(f: () -> Unit): Long {
    val start = System.currentTimeMillis()
    f()
    return System.currentTimeMillis() - start
    }

Abbiamo evidenziato in grassetto la funzione come unico parametro e la sua invocazione all’interno delle istruzioni che permettono di avere una stima della sua durata. Per quanto visto in precedenza possiamo quindi invocare la funzione measure() nel seguente modo:

val timeElapsed = measure { <codice> }

Quello tra parentesi { } è una lambda il cui tipo è () -> Unit. Come esempio invitiamo a eseguire il codice seguente:

val timeElapsed = measure { Thread.sleep(1000) }

Il risultato sarà un valore superiore a 1000, per la semantica del metodo sleep().

Ritorniamo ora alla funzione calculate() definita in precedenza e supponiamo di voler creare un’operazione che restituisca sempre il valore della variabile y. In questo caso potremmo invocare la funzione calculate() nel seguente modo:

calculate(2, 3) { x, y -> y }

In questo caso la variabile x non viene utilizzata. In casi come questo è possibile utilizzare il simbolo _ (underscore) per indicare che il valore del corrispondente parametro non è utilizzato e quindi non ci interessa. Ecco che la precedente invocazione può essere scritta come:

calculate(2, 3) { _, y -> y }

Notiamo come la seguente notazione non sia invece corretta:

calculate(2, 3) { y -> y }  // ERRORE

Questo perché la lambda passata come parametro non sarebbe del tipo richiesto dalla funzione calculate(), la quale prevede due parametri di input e non uno solo.

A proposito di funzioni con un solo parametro, Kotlin mette a disposizione anche una costante implicita che si chiama it. Si tratta di una variabile implicita che vedremo molte volte nel Capitolo 4. Come esempio possiamo dire che la seguente funzione execSingle()

typealias IntUnaryOp = (Int) -> Int
    fun execSingle(x: Int, f: IntUnaryOp) = f(x)

…può essere invocata come

execSingle(3) { x -> x * x }

ma anche come

execSingle(3) { it * it }

Abbiamo indicato in grassetto la costante implicita it. Parliamo di costante, in quanto si tratta di un riferimento che non possiamo cambiare all’interno della lambda. Il codice seguente darebbe quindi un errore in fase di compilazione.

execSingle(3) { it = 3; it * it }  // ERRORE

Funzioni anonime

La seconda categoria di funzioni letterali è quella delle funzioni anonime che, come è facile intuire, non hanno nome. Le espressioni lambda che abbiamo descritto nel dettaglio sono molto comode, ma hanno una limitazione: non permettono di esplicitare il tipo del valore restituito. Esso infatti nella maggior parte dei casi viene dedotto per inferenza dal tipo dell’ultima espressione. La sintassi di queste funzioni è molto semplice ed è praticamente quella di una normale funzione, senza però il nome. Un tipico esempio è quello della funzione che esegue il prodotto tra due Int, ovvero:

fun(x: Int, y: Int): Int = x * y

Queste funzioni anonime possono avere un corpo molto più complesso e quindi racchiuso in un blocco di codice come nel seguente caso:

fun(x: Int, y: Int): Int {
    return x * y
    }

Le precedenti definizioni, se venissero scritte così come sono, darebbero un errore di compilazione dovuto alla mancanza di nome. Vanno infatti utilizzate come se fossero espressioni lambda. Riprendendo la funzione execSingle() vista poco sopra, possiamo scrivere:

typealias IntUnaryOp = (Int) -> Int
    fun execSingle(x: Int, f: IntUnaryOp) = f(x)
    fun main() {
    execSingle(3, fun(x: Int): Int = x * x)
    }

Attenzione: la funzione anonima deve essere passata all’interno dell’elenco dei parametri e non può essere utilizzata con la sintassi da ultimo parametro come per le lambda, ovvero:

fun main() {
    execSingle(3) fun(x: Int): Int = x * x // ERRORE
    }

Come per le funzioni con nome, il tipo restituito viene determinato per inferenza nel caso di utilizzo diretto di un’espressione, mentre deve essere esplicitato nel caso di un blocco.

Funzioni letterali con receiver

Una funzione definita all’interno di una classe viene chiamata metodo. Un esempio potrebbe essere la funzione toLong() della classe Int, che abbiamo visto nel precedente capitolo e che potremmo usare nel seguente modo:

val asLong = 10.toLong()

Nell’esempio, la funzione toLong() è applicata all’oggetto di tipo Int definito attraverso il letterale 10; essa è una funzione membro di Int. In questo caso, 10 si dice il receiver della funzione toLong(). Un altro modo per invocare la stessa funzione è il seguente:

val asLong2 = Int::toLong.invoke(10)

Qui notiamo come il receiver venga passato come parametro del metodo invoke() della funzione stessa. Si tratta di una sintassi leggermente diversa, alla base di una delle principali funzionalità di Kotlin che va sotto il nome di extension function.

Il concetto di receiver è molto importante, in quanto può essere utilizzato come parte della definizione di un tipo di funzione. In precedenza abbiamo infatti utilizzato la seguente definizione:

typealias IntFun = (Int) -> Int

Questa indica una funzione che riceve in input un Int e restituisce in output un Int. Essa in realtà non dice nulla su quello che è il receiver, a differenza di questa:

typealias IntFunRec = Int.(Int) -> Int

Qui abbiamo indicato in grassetto la sintassi (attenzione al punto!) ovvero il tipo seguito dal punto (.) prima dell’elenco dei parametri:

<tipo>.

Se esaminiamo il linguaggio formale relativamente alla definizione del tipo di una funzione, notiamo quanto evidenziato nella prossima figura.

Funzioni letterali in Kotlin: definizione del receiver

Definizione del receiver.

Ma che cosa si vuole rappresentare, quindi, con questa definizione di tipo? E quali sono i vantaggi?

Il primo beneficio è una migliore definizione del tipo stesso. Se consideriamo il tipo IntFun, questo codice è perfettamente lecito:

fun square(x: Int): Int = x * x
    
    val f1: IntFun = Int::toInt // OK
    val f2: IntFun = ::square // OK

In entrambi i casi abbiamo due costanti che fanno riferimento a una funzione che mappa Int in altri Int. La prima è una funzione membro della classe Int, mentre la seconda è una nostra funzione globale.

Se ora andiamo a scrivere il codice seguente, noteremo degli errori di compilazione:

val f3: IntFunRec = Int::toInt // ERRORE
    val f4: IntFunRec = ::square // ERRORE

Il tipo IntFunRec, infatti, non è lo stesso del tipo IntFun. Come possiamo, allora, definire una funzione del tipo IntFunRec? Un modo consiste nell’utilizzare un’espressione lambda come la seguente:

val f5: IntFunRec = { x: Int -> x * x }  // OK

L’espressione lambda però sembra del tipo IntFun, in quanto mappa un Int in un altro Int come quelle che invece ci avevano dato un errore in compilazione. In realtà la definizione del receiver come tipo di una funzione è un modo per renderlo visibile all’interno della funzione stessa, attraverso un riferimento this. Per questo motivo possiamo quindi scrivere l’ultima definizione come la seguente:

val f5: IntFunRec = { x: Int -> this * this }  // OK

Oppure, meglio ancora, come:

val f5: IntFunRec = { this * this }  // OK

Come prova di quanto detto notiamo come la seguente definizione, che utilizza this e il tipo IntFun, dia invece errore di compilazione:

val f5: IntFun = { this * this }  // ERRORE!

Questa funzionalità è molto utile, specialmente quando viene utilizzata nella definizione del tipo di un parametro di una funzione high order. Nel corpo di una funzione come questa:

fun execute(x: Int, f: IntFun) = f(x)

possiamo solamente invocare la funzione f passata come parametro. Se invece utilizziamo la seguente definizione:

fun execute(x: Int, f: IntFunRec) = f(x)  // ERRORE

si ha un errore di compilazione, in quanto l’invocazione della funzione f è intesa come funzione con un Int come receiver. In questo caso potremmo quindi scrivere:

fun execute(x: Int, f: IntFunRec) = 10.f(x)

o addirittura:

fun execute(x: Int, f: IntFunRec) = x.f(x)

in quanto f dovrà essere una funzione che ha Int come receiver. Ecco che possiamo invocare la funzione execute() nel seguente modo:

execute(3) { val l = toLong(); 10 }

Qui, se non esplicitato diversamente, i metodi sono riferiti a un receiver di tipo Int.

Le closure

Un aspetto interessante di un’espressione lambda o funzione, è la relazione con le variabili esterne. Prendiamo per esempio il codice seguente:

var mul = 10
    fun genMulti(): (Int) -> Int = { i -> i * mul }

Si tratta di una funzione che genera una funzione che permette di moltiplicare un intero per un valore definito da una variabile esterna mul. Il codice seguente:

fun main() {
    println(genMulti()(3))
    }

produce il seguente output:

30

Questo perché la funzione getMulti() restituisce una funzione che permette di moltiplicare un valore per 10. Facciamo ora un esperimento con il codice seguente:

fun main() {
    val multiFun = genMulti()
    println(multiFun(3))
    mul = 27
    println(multiFun(3))
    }

Qui abbiamo modificato il valore della variabile mul tra un’esecuzione e la successiva. In questo caso l’output è:

30
81

Quindi la lambda generata dalla funzione genMulti() si è portata con sé il riferimento alla variabile esterna mul, accorgendosi anche della sua modifica. In questi casi si parla di closure.

Questo articolo richiama contenuti dal capitolo 2 di Kotlin – Guida al nuovo linguaggio di Android e dello sviluppo mobile.

L'autore

  • Massimo Carli
    Massimo Carli, dopo essersi occupato per più di dieci anni di applicazioni enterprise in ambiente Java, nel 2003 ha iniziato a interessarsi alle applicazioni mobile, sviluppando per dispositivi Blackberry, iOS e Android. Ha lavorato come Software Engineer per Yahoo! e Facebook ed è attualmente Lead Mobile Engineer per Lloyds Banking Group.

Vuoi rimanere aggiornato?
Iscriviti alla nostra newletter

Novità, promozioni e approfondimenti per imparare sempre qualcosa di nuovo

Gli argomenti che mi interessano:
Iscrivendomi dichiaro di aver preso visione dell’Informativa fornita ai sensi dell'art. 13 e 14 del Regolamento Europeo EU 679/2016.

Libri che potrebbero interessarti

Tutti i libri

Kotlin

Guida al nuovo linguaggio di Android e dello sviluppo mobile

33,90

49,89€ -32%

25,42

29,90€ -15%

19,99

di Massimo Carli

Android 9

Guida completa per lo sviluppo di applicazioni mobile

56,90

84,89€ -33%

42,42

49,90€ -15%

34,99

di Massimo Carli

Java 11

Guida allo sviluppo in ambienti Windows, macOS e GNU/Linux

56,90

84,89€ -33%

42,42

49,90€ -15%

34,99

di Pellegrino Principe


Articoli che potrebbero interessarti

Tutti gli articoli