[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:
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:
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:
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:
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:
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:
La funzione with
La funzione with
è l’unica scope function a dichiarare due parametri:
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:
La funzione also
Con also
torniamo ad esaminare una funzione di estensione simile a let
:
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:
Personalmente, però, preferisco utilizzare also
per applicare logiche che referenzino un oggetto senza mutarne lo stato:
La funzione apply
Concludiamo la carrellata con apply
che, sicuramente, vi sembrerà indistinguibile dalla precedente:
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:
Considerazioni generali
Dopo aver esaminato le singole funzioni, proviamo a trarre qualche conclusione.
-
Possiamo classificare le scope function in base al tipo di ritorno:
let
,run
ewith
permettono di restituire un oggetto differente da quello che definisce il contesto (se presente), effettuando una trasformazione.also
eapply
restituiscono lo stesso context object e — se il tipo lo consente — possono mutarne lo stato.
-
Le funzioni di “trasformazione” possono essere applicate come funzioni di “mutazione”, ma non è vero il contrario.
-
Possiamo distinguere le scope function anche in base al modo in cui facciamo riferimento al context object:
let
ealso
permette di usare il nome implicitoit
, con la possibilità di dare un nome esplicito all’argomento passato alla lambda.run
,with
eapply
permettono di usarethis
, con la possibilità di non esplicitare il riferimento.
-
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