[Kotlin Tip] Scope function a confronto

Le scope function sono una delle caratteristiche più interessanti di Kotlin, ma al tempo stesso possono destare qualche dubbio in chi si avvicina per la prima volta a questo linguaggio.

Benché la loro utilità risulti piuttosto evidente fin dai primi istanti d’utilizzo, distinguere le diverse funzioni e i relativi casi d’uso può non essere altrettanto semplice.

In questo post vorrei quindi provare ad esaminare le singole scope function mettendole a confronto. Evidenziare le loro similitudini e le loro differenze, così da conoscerle un po’ meglio.

Definizione

Traducendo liberamente la documentazione ufficiale, possiamo definire le scope function come:

Funzioni (definite all’interno della standard library di Kotlin) che permettono di eseguire un blocco di codice nel contesto di un oggetto.

Le funzioni in questione sono cinque: let, run, with, apply, also.

Sempre riprendendo quanto riportato nella documentazione ufficiale:

Fondamentalmente, queste funzioni si comportano allo stesso modo: eseguono un blocco di codice su un oggetto.

Ed è questo forse il fatto che genera più confusione: se fanno tutte la stessa cosa, a cosa servono cinque funzioni diverse?

Cerchiamo di rispondere a questa domanda.

La funzione let

Nei primi tempi in cui lavoravo con Kotlin, per me scope function era sinonimo di let.

Non avendo ben chiare le caratteristiche delle altre funzioni mi limitavo ad utilizzare let un po’ per tutto. E in effetti ancora oggi considero questa funzione la più versatile del gruppo.

Partiamo quindi con l’esaminare la funzione let, che, come forse avrete capito, utilizzerò in questo post come metro di paragone per valutare le altre scope function.

La funzione let presenta questa intestazione:

public inline fun <T, R> T.let(block: (T) -> R): R

Si tratta quindi di una extension function applicabile a qualunque tipo (T), che permette di eseguire una trasformazione del context object da T a R e di restituirne il risultato.

In questo senso, la funzione let appare simile ad un map applicato ad un singolo oggetto, piuttosto che ad una collection. Il corpo della lambda, che costituisce il parametro d’ingresso di let, implementa proprio questa logica di “trasformazione”.

Chiaramente nulla ci vieta di ritornare, come risultato della lambda, lo stesso context object, a cui potremmo aver applicato delle modifiche.

Ecco un esempio:

"this is a string".let {
  it.filterIndexed { i, _ -> i % 2 == 0 } 
} // => "ti sasrn"

La funzione let applica a "this is a string" una lambda che recupera i soli caratteri pari.

Come vedete, per come è dichiarato il parametro block, il context object all’interno della lambda expression è referenziabile tramite it.

La funzione run

La funzione run è disponibile in due “gusti”: extension function e funzione “normale”.

Partiamo esaminando la versione extension:

public inline fun <T, R> T.run(block: T.() -> R): R

Come noterete, l’intestazione di questa funzione è estremamente simile a quella della funzione let; la differenza sta nella lambda expression block: il tipo T, in questo caso, diventa il receiver della lambda. Di conseguenza block si comporterà come una funzione di estensione applicata a T.

All’atto pratico, questo si traduce nella possibilità, all’interno dell’espressione, di referenziare il context object tramite this, con la possibilità di ometterlo del tutto:

"this is a string".run {
  filterIndexed { i, _ -> i % 2 == 0 }
} // => "ti sasrn"

Al di là dell’utilizzo di this al posto di it, la funzione run, applicata come extension function, appare del tutto identica a let. E, in effetti, la scelta tra le due in questo caso è più legata ad una questione di gusti…

Differente è invece l’utilizzo di run nella sua versione non-extension:

public inline fun <R> run(block: () -> R): R

In questo caso, block torna ad essere una lambda senza receiver, ma, se ci fate caso, stavolta non presenta alcun parametro d’ingresso. Di fatto, in questa versione, run non lavora su un context object specifico.

La grande utilità di run, applicata come funzione “normale”, appare evidente nel caso in cui avessimo la necessità di eseguire un blocco di codice all’interno di un’espressione.

Un esempio classico è l’assegnazione di una variabile che richieda delle logiche d’inizializzazione complesse:

val millisecondsInDay = 1000 * run {
    val secondsInMinute = 60
    val minutesInHour = 60
    val hoursInDay = 24
    secondsInMinute * minutesInHour * hoursInDay
}

La funzione with

La funzione with è l’unica scope function a dichiarare due parametri:

public inline fun <T, R> with(receiver: T, block: T.() -> R): R

Trattandosi di una funzione “normale”, può ricordare run nella sua versione non-extension.

E in effetti potremmo descrivere with proprio come una terza versione di run in cui il context object, receiver, viene passato come parametro.

La lambda block coincide con quella della run extension, per cui, anche in questo caso, useremo this per far riferimento all’oggetto receiver.

L’utilizzo di with è assimilabile a quello di run, per cui il suo valore aggiunto principale sta nell’essere una funzione “parlante”.

Come riportato anche nella documentazione ufficiale, possiamo usare with in contesti in cui sia importante esplicitare il fatto che ci apprestiamo a lavorare con un certo oggetto; nella forma: with receiver, do something…

Al di là di questo apporto semantico al codice, gli esempi di applicazione di with ricalcano quelli citati per run non-extension:

val date: LocalDate = ...
val millisecondsFromDate = 1000 * with(date) {
    val today = LocalDate.now()

    require(this <= today) { "Date $this is in the future..." }

    val daysBetween = Period.between(this, today).days

    val secondsInMinute = 60
    val minutesInHour = 60
    val hoursInDay = 24

    daysBetween * secondsInMinute * minutesInHour * hoursInDay
}

La funzione also

Con also torniamo ad esaminare una funzione di estensione simile a let:

public inline fun <T> T.also(block: (T) -> Unit): T

La differenza sostanziale, come ormai avrete capito, sta nella lambda applicata e, in questo caso, anche nel valore di ritorno della scope function.

Se avete osservato bene l’intestazione di also, infatti, avrete notato che block non ritorna alcun risultato (o, per essere precisi, ritorna uno Unit). La funzione quindi non restituirà il risultato della lambda, ma lo stesso context object a cui verrà applicata.

Chiaramente, se il context object non è immutabile, abbiamo la possibilità di agire sullo stesso per applicare delle modifiche:

mutableListOf(1, 2, 3).also { it += 5 } // => [1, 2, 3, 5]

Personalmente, però, preferisco utilizzare also per applicare logiche che referenzino un oggetto senza mutarne lo stato:

// WARNING: lancerà un'eccezione...
val primesList = listOf(1, 2, 3, 5)
    .also { if (it.size > 3) error("List is too long!") }
    .also(::println)

La funzione apply

Concludiamo la carrellata con apply che, sicuramente, vi sembrerà indistinguibile dalla precedente:

public inline fun <T> T.apply(block: T.() -> Unit): T

Nuovamente abbiamo a che fare con una extension function che dichiara come unico parametro una lambda con receiver: T.() -> Unit.

Il parallelismo che possiamo fare tra also e apply è simile a quello già fatto tra let e run, quando quest’ultima viene usata come funzione di estensione.

Al di là delle preferenze tra le due funzioni, però, ritengo che apply e also presentino una differenza a livello di significato.

Benché le due funzioni siano pienamente intercambiabili — con le dovute sostituzioni di it con this e viceversa —, trovo apply più adatta ad operazioni di mutazione su oggetti precedentemente inizializzati:

mutableMapOf(
    1 to "one",
    2 to "two",
    3 to "tree",
    4 to "four"
).apply {
    remove(4)
    put(5, "five")
} // => {1=one, 2=two, 3=tree, 5=five}

Considerazioni generali

Dopo aver esaminato le singole funzioni, proviamo a trarre qualche conclusione.

  1. Possiamo classificare le scope function in base al tipo di ritorno:

    • let, run e with permettono di restituire un oggetto differente da quello che definisce il contesto (se presente), effettuando una trasformazione.
    • also e apply restituiscono lo stesso context object e — se il tipo lo consente — possono mutarne lo stato.
  2. Le funzioni di “trasformazione” possono essere applicate come funzioni di “mutazione”, ma non è vero il contrario.

  3. Possiamo distinguere le scope function anche in base al modo in cui facciamo riferimento al context object:

    • let e also permette di usare il nome implicito it, con la possibilità di dare un nome esplicito all’argomento passato alla lambda.
    • run, with e apply permettono di usare this, con la possibilità di non esplicitare il riferimento.
  4. In generale, dovremmo stabilire il significato che assegniamo ad ogni scope function — per lo meno a livello di progetto — e rimanere consistenti nell’applicazione della stesse a determinate situazioni.

In conclusione

In questo post ho voluto esporre il mio punto di vista sulle scope function di Kotlin e sul loro utilizzo.

Chiaramente, la sovrapposizione che esiste tra le definizioni di queste funzioni lascia spazio a differenti interpretazioni sul significato e l’applicazione di questi strumenti.

Mi piacerebbe quindi conoscere il punto di vista di chi le usa già o di chi si appresta ad applicarle ad un progetto.

Vi trovate in accordo con il mio pensiero o avete suggerimenti utili da darmi? Fatemelo sapere!

Alla prossima,

David

(Photo by Jason Leung on Unsplash)