Voi siete qui: Inizio Programmare in Scala

La programmazione funzionale in Scala

Ogni decade o due, un’idea importante nel campo dell’informatica viene adottata dal mainstream. Queste idee possono essere rimaste nascoste nelle retrovie della ricerca accademica, o forse in qualche settore industriale poco conosciuto. L’adozione nel mainstream arriva in risposta alla percezione di un problema per il quale l’idea rappresenta una soluzione particolarmente adatta. La programmazione orientata agli oggetti, che è stata inventata negli anni ’60, è diventata popolare negli anni ’80, presumibilmente in risposta alla diffusione delle interfacce grafiche, con le quali il paradigma OOP ha una naturale affinità.

Sembra che la programmazione funzionale stia vivendo una simile esplosione. A lungo argomento di ricerca nel campo dell’informatica, e persino anteriore alla programmazione orientata agli oggetti, il paradigma funzionale offre tecniche efficaci per la programmazione concorrente, che sta diventando sempre più importante.

Dato che la programmazione funzionale è meno conosciuta della programmazione orientata agli oggetti, eviteremo di presumere che ne abbiate già avuto esperienza e cominceremo questo capitolo con un’ampia introduzione. Come vedrete, il paradigma funzionale non è solo un modo molto efficace di affrontare la programmazione concorrente, che esploreremo in profondità nel capitolo 9, ma può anche migliorare i vostri oggetti.

Naturalmente, non possiamo offrirvi una trattazione esaustiva della programmazione funzionale. Per saperne di più, [O’Sullivan2009] contiene un’introduzione particolareggiata nel contesto del linguaggio Haskell. [Abelson1996], [VanRoy2004] e [Turbak2008] trattano diversi approcci generali di programmazione in maniera completa. Infine, [Okasaki1998] e [Rabhi1999] discutono i dettagli di strutture dati e algoritmi funzionali.

Che cos’è la programmazione funzionale?

Tutti i linguaggi di programmazione non posseggono già un qualche concetto di funzione? Che siano chiamate metodi, procedure, o GOTO, i programmatori hanno sempre avuto a che fare con le funzioni. La programmazione funzionale è basata sul comportamento delle funzioni in senso matematico, con tutte le implicazioni che questo punto di partenza comporta.

Le funzioni in matematica

In matematica, le funzioni non hanno effetti collaterali. Considerate la classica funzione sin(x).

y = sin(x)

A prescindere dal numero di operazioni compiute da sin(x), tutti i risultati vengono restituiti e assegnati a y. Internamente, sin(x) non modifica alcuna informazione di stato globale. Quindi, diciamo che una tale funzione è priva di effetti collaterali, cioè pura.

Questa proprietà semplifica enormemente il compito di analizzare, collaudare e correggere una funzione. Potete svolgere queste attività senza sapere nulla del contesto in cui la funzione viene invocata, a parte le altre funzioni che essa potrebbe invocare. Tuttavia, potete analizzare queste altre funzioni nello stesso modo, lavorando dal basso verso l’alto per verificare l’intera “pila” di invocazioni.

Questa possibilità di trascurare il contesto circostante è nota come trasparenza referenziale. Potete invocare ovunque una di queste funzioni ed essere sicuri che si comporterà sempre nello stesso modo. Se lo stato globale non viene modificato, l’invocazione concorrente di una funzione è semplice e affidabile.

Nella programmazione funzionale potete creare nuove funzioni componendole a partire da altre funzioni. Per esempio, tan(x) = sin(x) / cos(x). Dalla componibilità consegue che le funzioni possono essere trattate come valori. In altre parole, le funzioni sono entità di prima classe, esattamente come i dati: potete assegnare funzioni alle variabili; potete passare funzioni ad altre funzioni; potete restituire funzioni come valori di ritorno da altre funzioni. Nel paradigma funzionale, le funzioni diventano un tipo primitivo, un componente che è altrettanto essenziale per il lavoro del programmatore quanto i numeri interi o le stringhe.

Quando una funzione accetta altre funzioni come argomenti o restituisce una funzione, essa viene chiamata funzione di ordine superiore. In matematica, due esempi di funzioni di ordine superiore sono la derivazione e l’integrazione.

Variabili che non sono tali

La parola “variabile” assume un nuovo significato nella programmazione funzionale. Se provenite da una formazione orientata agli oggetti o procedurale, siete abituati a variabili che sono mutabili. Nella programmazione funzionale le variabili sono immutabili.

Questa è un’altra conseguenza dell’orientamento matematico. Nell’espressione y = sin(x), una volta che scegliete x, y è fissa. Per fare un altro esempio, se incrementate di 1 l’intero 3, non “modificate l’oggetto 3”, ma create un nuovo valore per rappresentare 4.

Per essere più precisi, sono i valori a essere immutabili. I linguaggi di programmazione funzionale vi impediscono di assegnare un nuovo valore a una variabile che possiede già un valore.

Lavorare con l’immutabilità è difficile se non ci siete abituati. Non potete modificare una variabile, quindi non potete usare contatori in un ciclo, per esempio. Siamo abituati a oggetti che cambiano il loro stato quando invochiamo metodi su di essi. Imparare a pensare in termini di immutabilità richiede un certo sforzo.

Tuttavia, l’immutabilità ha benefici enormi per la concorrenza. Quasi tutte le difficoltà della programmazione multithread consistono nel sincronizzare l’accesso a uno stato condiviso e mutabile. Se rimuovete la mutabilità, allora i problemi essenzialmente spariscono. È la combinazione delle funzioni dotate di trasparenza referenziale con i valori immutabili che rende la programmazione funzionale irresistibile come “un modo migliore” per scrivere software concorrente.

Queste qualità possono giovare ai programmi anche in altri modi. Quasi tutti i costrutti inventati in più di sessant’anni di informatica non sono stati altro che tentativi di gestire la complessità. Le funzioni di ordine superiore e la trasparenza referenziale offrono una grande flessibilità nella composizione dei programmi.

L’immutabilità riduce enormemente gli errori di regressione, molti dei quali sono causati da modifiche accidentali dello stato in una parte del programma a causa di modifiche intenzionali in un’altra parte. Altri elementi contribuiscono a questi effetti non locali, ma la mutabilità è uno dei più importanti.

Nella progettazione orientata agli oggetti è pratica comune incapsulare l’accesso alle strutture dati negli oggetti. Se queste strutture sono mutabili, non possiamo semplicemente condividerle con i clienti, ma dobbiamo aggiungere metodi speciali di accesso per fare in modo che i clienti non possano modificarle al di fuori del nostro controllo. Queste aggiunte incrementano la dimensione del codice appesantendo il collaudo e la manutenzione, e quindi richiedono ai clienti uno sforzo maggiore per capire le caratteristiche ad hoc delle nostre API.

Al contrario, se le strutture dati sono immutabili, molti di questi problemi semplicemente scompaiono. Possiamo offrire accesso alle collezioni senza timore di perdere o rovinare i dati. Naturalmente, il principio generale di riduzione delle dipendenze è sempre valido: i clienti non dovrebbero preoccuparsi se viene usata un’istanza di Set o di List, purché un metodo foreach sia disponibile.

L’immutabilità dei dati implica anche che ne verranno fatte molte copie, cosa che potrebbe essere costosa. Le strutture dati funzionali vengono ottimizzate per risolvere questo problema [Okasaki1998] e molti tipi predefiniti di Scala sono efficienti nel creare nuove copie da copie esistenti.

È venuto il momento di immergersi nelle pratiche della programmazione funzionale in Scala. Discuteremo altri aspetti e benefici di questo approccio man mano che procediamo.

La programmazione funzionale in Scala

In quanto linguaggio ibrido a oggetti e funzionale, Scala non esige che le funzioni siano pure né che le variabili siano immutabili. Tuttavia, vi incoraggia a scrivere codice in questo modo ogni volta che potete. Avete la libertà di usare tecniche orientate agli oggetti o procedurali quando e dove vi sembrano le più appropriate.

Sebbene l'obiettivo principale dei linguaggi funzionali sia quello di eliminare gli effetti collaterali, un linguaggio che non ammette mai effetti collaterali sarebbe inutile. Lo scambio di dati con le periferiche (detto I/O) è intrinsecamente legato agli effetti collaterali ed è essenziale per tutti i tipi di programmi. Per questo motivo, tutti i linguaggi funzionali offrono meccanismi per generare effetti collaterali in modo controllato.

Invece di vincolarvi, Scala vi incoraggia a usare valori immutabili e funzioni e metodi puri ovunque sia possibile. Quando la mutabilità e gli effetti collaterali sono necessari, perseguiteli secondo certi “principi”, cioè isolandoli in moduli ben definiti e concentrandoli su singole attività.

Se per voi la programmazione funzionale è una novità, tenete presente che è facile ricadere nelle vecchie abitudini. Vi incoraggiamo a padroneggiare il lato funzionale di Scala e a imparare a usarlo in modo efficace.

Una funzione che restituisce Unit è una funzione con effetti collaterali puri, nel senso che, se esegue operazioni utili, quelle operazioni devono essere composte esclusivamente da effetti collaterali, dato che la funzione non restituisce nulla.

Abbiamo visto molti esempi di funzioni di ordine superiore e di componibilità in Scala. Per esempio, List.map prende una funzione per trasformare ogni elemento della lista in qualcos’altro.

// esempi/cap-8/basics/list-map-example-script.scala

List(1, 2, 3, 4, 5) map { _ * 2 }

Ricordatevi che _ * 2 è un letterale funzione che rappresenta una forma abbreviata di i => i * 2. Per ogni argomento della funzione, potete usare _ se l’argomento è usato una volta sola. Possiamo anche usare la notazione operazionale infissa per invocare map. Ecco un esempio che “riduce” la stessa lista moltiplicando tra loro tutti gli elementi.

// esempi/cap-8/basics/list-reduceLeft-example-script.scala

List(1, 2, 3, 4, 5) reduceLeft { _ * _ }

Il primo _ rappresenta l’argomento che accumula il valore della riduzione e il secondo _ rappresenta l’elemento corrente nella lista.

Entrambi gli esempi hanno “attraversato” con successo la lista senza usare un contatore mutabile per tenere traccia delle iterazioni. La maggior parte dei contenitori nella libreria Scala offre metodi di iterazione funzionalmente puri. In altri casi, la ricorsione è il modo migliore per attraversare una struttura dati o eseguire un algoritmo. Riprenderemo questo argomento nella sezione Ricorsione più avanti in questo capitolo.

Letterali funzione e chiusure

Estendiamo un poco il nostro precedente esempio di map.

// esempi/cap-8/basics/list-map-closure-example-script.scala

var factor = 3
val multiplier = (i:Int) => i * factor

val l1 = List(1, 2, 3, 4, 5) map multiplier

factor = 5
val l2 = List(1, 2, 3, 4, 5) map multiplier

println(l1)
println(l2)

Abbiamo definito una variabile factor da usare come fattore di moltiplicazione e abbiamo estratto la precedente funzione anonima in un valore chiamato multiplier che ora usa factor. Poi abbiamo usato map su una lista di interi, come abbiamo fatto in precedenza. Dopo la prima invocazione di map, modifichiamo factor e invochiamo nuovamente map. Questo è il risultato.

List(3, 6, 9, 12, 15)
List(5, 10, 15, 20, 25)

Nonostante multiplier fosse un valore funzione immutabile, il suo comportamento è cambiato quando factor è stata modificata.

Ci sono due variabili libere in multiplier: i e factor. Una di loro, i, è un parametro formale della funzione. Quindi, viene legato a un nuovo valore ogni volta che multiplier viene invocata.

Tuttavia, factor non è un parametro formale, ma un riferimento a una variabile nell’ambito esterno. Quindi, il compilatore crea una chiusura che racchiude (o si “chiude sopra”) multiplier e il contesto esterno delle variabili non legate a cui multiplier fa riferimento, legando in questo modo anche quelle variabili.

Questo è il motivo per cui il comportamento di multiplier è cambiato dopo la modifica di factor. La funzione fa riferimento a factor e legge il suo valore corrente ogni volta. Se una funzione non ha riferimenti esterni, allora è banalmente chiusa su se stessa e non richiede nessun contesto esterno.

Purezza interna ed esterna

Se avessimo invocato sin(x) migliaia di volte con lo stesso valore di x, calcolare ogni singola volta lo stesso risultato sarebbe stato uno spreco. Persino nelle librerie funzionali “pure” sono comuni le ottimizzazioni interne come la registrazione in una cache dei risultati precedentemente calcolati (una pratica chiamata memoizzazione, in inglese memoization). L’uso della cache introduce effetti collaterali, in quanto lo stato della cache viene modificato.

Tuttavia, questa mancanza di purezza dovrebbe essere invisibile all’utente (eccetto forse in termini di un impatto sulle prestazioni). Se state progettando librerie funzionali, assicuratevi che preservino la purezza delle loro astrazioni, incluso il comportamento di trasparenza referenziale e le sue implicazioni per la concorrenza.

La libreria Scala contiene alcuni esempi di librerie funzionali con entità interne mutabili. I metodi di List usano spesso varibili locali mutabili per attraversare la struttura in maniera efficiente. Le variabili locali sono thread-safe, così come gli attraversamenti, dato che le istanze di List sono immutabili di per sé.

Ricorsione

La ricorsione gioca un ruolo più ampio nella programmazione funzionale pura rispetto alla programmazione imperativa, in parte a causa della restrizione per cui le variabili sono immutabili. Per esempio, in un ciclo non potete usare contatori che verrebbero modificati a ogni passo di esecuzione. La ricorsione offre un modo per implementare i cicli in maniera puramente funzionale.

Il calcolo del fattoriale ci fornisce un buon esempio. Eccone un’implementazione imperativa che usa un ciclo.

// esempi/cap-8/recursion/factorial-loop-script.scala

def factorial_loop(i: BigInt): BigInt = {
  var result = BigInt(1)
  for (j <- 2 to i.intValue)
    result *= j
  result
}

for (i <- 1 to 10)
  format("%s: %s\n", i, factorial_loop(i))

Sia il contatore del ciclo j sia result sono variabili mutabili. (Per semplicità, stiamo ignorando i numeri in ingresso che sono minori o uguali a zero.) L’uscita di questo script è la seguente.

1: 1
2: 2
3: 6
4: 24
5: 120
6: 720
7: 5040
8: 40320
9: 362880
10: 3628800

Ecco un primo passo verso un’implementazione ricorsiva.

// esempi/cap-8/recursion/factorial-recur1-script.scala

def factorial(i: BigInt): BigInt = i match {
  case _ if i == 1 => i
  case _ => i * factorial(i - 1)
}

for (i <- 1 to 10)
  format("%s: %s\n", i, factorial(i))

L’uscita è la stessa, ma ora non ci sono variabili mutabili. La ricorsione non solo ci aiuta a evitare le variabili mutabili, ma è anche la forma più naturale con cui esprimere alcune funzioni, in particolare le funzioni matematiche. La definizione ricorsiva della nostra seconda funzione factorial è strutturalmente simile alla definizione di fattoriale che potreste vedere in un libro di matematica.

Tuttavia, ci sono due potenziali problemi con la ricorsione: la riduzione di prestazioni dovuta alle ripetute invocazioni di una funzione e il rischio di stack overflow.

A volte i problemi di prestazione in uno scenario ricorsivo possono essere affrontati con la memoizzazione, ma bisognerebbe stare attenti a evitare che i requisiti di spazio per la cache diventino più rilevanti rispetto ai benefici sulle prestazioni.

Lo stack overflow può essere evitato convertendo l’invocazione ricorsiva in un ciclo di qualche tipo. In effetti, il compilatore Scala può effettuare per voi questa conversione su alcuni tipi di invocazioni ricorsive che descriveremo nella prossima sezione.

La ricorsione in coda e la sua ottimizzazione

La ricorsione in coda è un tipo particolare di ricorsione che avviene quando la funzione richiama se stessa come sua ultima operazione. La ricorsione in coda è molto importante, perché è il tipo di ricorsione più facile da ottimizzare con la conversione in un ciclo. I cicli eliminano il potenziale stack overflow e migliorano le prestazioni eliminando il costo aggiuntivo della chiamata ricorsiva di funzione. Nonostante l’ottimizzazione della ricorsione in coda non sia ancora supportata nativamente dalla JVM, scalac può effettuarla.

Tuttavia, il nostro esempio del fattoriale non è una ricorsione in coda, perché factorial si richiama e poi opera una moltiplicazione tra i risultati. È possibile implementare factorial in modo da usare la ricorsione in coda. In effetti, ne abbiamo vista un’implementazione nella sezione Annidare le definizioni di metodo del capitolo 2. Tuttavia, quell’esempio non usava alcuni costrutti che abbiamo imparato nel frattempo, come le espressioni for e il pattern matching. Quindi, ecco una nuova implementazione di factorial che effettua i calcoli con la ricorsione in coda.

// esempi/cap-8/recursion/factorial-recur2-script.scala

def factorial(i: BigInt): BigInt = {
  def fact(i: BigInt, accumulator: BigInt): BigInt = i match {
    case _ if i == 1 => accumulator
    case _ => fact(i - 1, i * accumulator)
  }
  fact(i, 1)
}

for (i <- 1 to 10)
  format("%s: %s\n", i, factorial(i))

Questo script produce la stessa uscita di prima. Ora, factorial fa tutto il lavoro in un metodo annidato fact che è ricorsivo in coda perché passa un argomento accumulator per memorizzare il risultato del calcolo in corso. Questo argomento viene calcolato con una moltiplicazione prima della chiamata ricorsiva a fact, che ora è l’ultima operazione effettuata. Nella nostra implementazione precedente, questa moltiplicazione veniva fatta dopo l’invocazione di fact. Quando invochiamo fact(1), restituiamo semplicemente il valore accumulato.

Se invochiamo la nostra implementazione originale di factorial, che non è ricorsiva in coda, con un numero grande, per esempio 10000, causeremo uno stack overflow su un tipico computer da scrivania. L’implementazione ricorsiva in coda funziona perfettamente, restituendo un numero molto grande.

L’annidamento di una funzione ricorsiva in coda che usa un accumulatore è una tecnica idiomatica molto utile per convertire molti algoritmi ricorsivi in ricorsioni in coda che possono essere ottimizzate da scalac sotto forma di cicli.

L’ottimizzazione della ricorsione in coda non verrà applicata quando un metodo che chiama se stesso può essere ridefinito in un tipo derivato. Il metodo deve essere dichiarato private o final, definito in un object, o annidato in un altro metodo (come fact nel nostro esempio). La nuova annotazione @tailrec introdotta nella versione 2.8 genererà un errore se il compilatore non riesce a ottimizzare il metodo annotato. (Si veda la sezione Annotazioni nel capitolo 13.)

Trampolino per le ricorsioni in coda

Un trampolino è un ciclo che attraversa una lista di funzioni invocandole una per volta. Il nome deriva dalla metafora di lanciare le funzioni da un trampolino.

Considerate un tipo di ricorsione in cui una funzione A non si invochi ricorsivamente, ma invece invochi un’altra funzione B, che invoca A, che invoca B, &c. Anche questo tipo di ricorsione avanti-e-indietro può essere convertito in un ciclo, usando un trampolino. Notate che il trampolino impone una penalizzazione sulle prestazioni, ma è ideale per le ricorsioni funzionali pure (in contrasto con un equivalente imperativo) che altrimenti esaurirebbero lo stack.

Il supporto per questa ottimizzazione è pianificato per la versione 2.8 di Scala, sebbene non sia ancora stato implementato al momento della scrittura.

Strutture dati funzionali

Esistono diverse strutture dati comunemente usate nella programmazione funzionale, molte delle quali sono contenitori, come le collezioni. I linguaggi come Erlang fanno affidamento su pochi tipi, mentre altri linguaggi funzionali offrono un sistema di tipi più ricco.

Queste strutture dati supportano tutte lo stesso sottoinsieme di funzioni di ordine superiore per l’attraversamento e l’accesso in sola lettura ai loro elementi. Queste caratteristiche le rendono adatte come “protocolli” per minimizzare le dipendenze tra i componenti pur supportando lo scambio di dati.

In effetti, queste strutture dati e le loro operazioni sono così utili da essere incluse in molti linguaggi, compresi quelli che non sono considerati linguaggi funzionali, come Java e Ruby. Java non supporta direttamente le funzioni di ordine superiore; invece, i valori funzione devono essere racchiusi in oggetti. Ruby usa le proc e le lambda come valori funzione.

Le liste nella programmazione funzionale

Le liste sono la struttura dati più comune nella programmazione funzionale. Sono il cuore del primo linguaggio di programmazione funzionale, Lisp.

Per favorire l’immutabilità, quando aggiungete un nuovo elemento a una lista viene creata una nuova lista. La convenzione prevede di aggiungere il nuovo elemento in testa alla lista, come abbiamo visto prima.

// esempi/cap-8/datastructs/list-script.scala

val list1 = List("Programmare", "in", "Scala")
val list2 = "Chinuque" :: "dovrebbe" :: "leggere" :: list1
println(list2)

Dato che l’operatore :: lega gli operandi alla sua destra, la definizione di list2 è equivalente a entrambe le variazioni seguenti.

val list2 = ("Chiunque" :: ("dovrebbe" :: ("leggere" :: list1)))
val list2 = list1.::("leggere").::("dovrebbe").::("Chiunque")

In termini di prestazioni, l’aggiunta in testa è O(1). Vedremo perché quando esamineremo da vicino l’implementazione di List nella sezione Uno sguardo più attento alle liste del capitolo 12, dopo che avremo imparato qualcosa in più sui tipi parametrici in Scala.

A differenza di alcune altre collezioni, Scala definisce List solo come tipo immutabile. Tuttavia, definisce anche alcuni tipi di lista mutabili, come ListBuffer e LinkedList.

Le mappe nella programmazione funzionale

Forse la seconda struttura dati più comune è la mappa, chiamata hash o dizionario in altri linguaggi, che non deve essere confusa con la funzione map che abbiamo visto prima. Le mappe sono usate per memorizzare coppie di chiavi e valori.

Come scelta minimalista, le mappe potrebbero essere implementate sotto forma di liste. Ogni elemento di posto pari nella lista (contando da zero) potrebbe essere una chiave, seguita dal valore nella posizione dispari successiva. In pratica, di solito le mappe vengono implementate in altri modi per motivi di efficienza.

Scala supporta la speciale sintassi di inizializzazione che abbiamo visto in precedenza.

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  // …
  "Wyoming" -> "Cheyenne")

Il tratto scala.collection.Map[A, +B] definisce solo i metodi per leggere i dati contenuti nelle istanze di Map. Da esso derivano i tratti per le mappe immutabili e mutabili, scala.collection.immutable.Map[A, +B] e scala.collection.mutable.Map[A, B], rispettivamente. Questi tratti definiscono gli operatori + e - per aggiungere e rimuovere elementi, e gli operatori ++ e -- per aggiungere e rimuovere elementi definiti in istanze di Iterator che contengono istanze di Pair, dove ogni istanza di Pair è una coppia chiave-valore.

Potreste aver notato che non c’è il segno + davanti al parametro di tipo B per scala.collection.mutable.Map. Vedrete perché nella sezione La varianza dei tipi mutabili del capitolo 12.

Gli insiemi nella programmazione funzionale

Gli insiemi sono come le liste, ma richiedono che ogni elemento sia unico. Anche gli insiemi potrebbero essere implementati usando le liste, purché l’equivalente dell’operatore “cons” (::) controlli prima che l’elemento non sia già presente nella lista. Dalla proprietà di unicità consegue che l’inserimento di un elemento sarebbe O(N) se fossero usate le liste, e che l’ordine degli elementi nell’insieme non corrisponderebbe necessariamente all’ordine delle operazioni di “inserimento”. In pratica, di solito gli insiemi vengono implementati con strutture dati più efficienti.

Proprio come per Map, il tratto scala.collection.Set[A] definisce solo i metodi per leggere i dati contenuti nelle istanze di Set. Da esso derivano i tratti per gli insiemi immutabili e mutabili, scala.collection.immutable.Set[A] e scala.collection.mutable.Set[A], rispettivamente. Questi tratti definiscono gli operatori + e - per aggiungere e rimuovere elementi, e gli operatori ++ e -- per aggiungere e rimuovere elementi definiti in istanze di Iterator (che potrebbero essere altri insiemi, liste, &c.)

Altre strutture dati nella programmazione funzionale

Nei linguaggi funzionali compariranno anche altre strutture dati familiari, come Tuple e Array, che vengono tipicamente usate per offrire alcune caratteristiche convenienti non supportate dai tipi funzionali più comuni. Nella maggior parte dei casi, però, queste strutture possono essere sostituite dalle liste.

Operazioni comuni sulle strutture dati funzionali

Le collezioni funzionali che abbiamo appena discusso, cioè liste, mappe, insiemi, e anche tuple e array, supportano tutte diverse operazioni comuni basate sugli attraversamenti a sola lettura. In effetti, questa uniformità può essere sfruttata se tutti i tipi di “contenitore” supportano anche queste operazioni. Per esempio, un’istanza di Option contiene zero elementi o un elemento, a seconda che sia un’istanza di None o Some, rispettivamente.

Attraversamento

Il metodo standard di attraversamento per i contenitori in Scala è foreach, che è definito nel tratto Iterable mescolato nei contenitori. È O(N) nel numero degli elementi. Ecco un esempio di come usarlo con liste e mappe.

// esempi/cap-8/datastructs/foreach-script.scala

List(1, 2, 3, 4, 5) foreach { i => println("Int: " + i) }

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  "Wyoming" -> "Cheyenne")

stateCapitals foreach { kv => println(kv._1 + ": " + kv._2) }

La firma di foreach è la seguente.

trait Iterable[+A] {
  …
  def foreach(f : (A) => Unit) : Unit = …
  …
}

foreach è una funzione di ordine superiore che prende come argomento una funzione che rappresenta l’operazione da eseguire su ciascun elemento. Notate che, per una mappa, A in realtà è una tupla, come mostrato nell’esempio. In più, foreach restituisce Unit, perché non è concepita per creare nuove collezioni; vedremo esempi di operazioni che creano collezioni tra breve.

Una volta che avete foreach, potete implementare tutte le ulteriori operazioni di attraversamento che discuteremo successivamente, e anche molte altre. Se date un’occhiata a Iterable, vedrete che supporta metodi per filtrare le collezioni, per trovare elementi che corrispondono a criteri specifici, per calcolare il numero di elementi, e così via.

I metodi che discuteremo nelle prossime sezioni sono il marchio di fabbrica della programmazione funzionale: mappatura, filtraggio, ripiegamento e riduzione.

Mappatura

Abbiamo già incontrato il metodo map in precedenza. Questo metodo restituisce una nuova collezione della stessa dimensione dell’originale. Anche map è un membro di Iterable e la sua firma è la seguente.

trait Iterable[+A] {
  …
  def map[B](f : (A) => B) : Iterable[B] = …
  …
}

La funzione che viene passata (f) può trasformare un elemento originale di tipo A in un nuovo elemento di tipo B. Ecco un esempio.

// esempi/cap-8/datastructs/map-script.scala

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  "Wyoming" -> "Cheyenne")

val lengths = stateCapitals map { kv => (kv._1, kv._2.length) }
println(lengths)

Questo script produce l’uscita seguente.

ArrayBuffer((Alabama,10), (Alaska,6), (Wyoming,8))

Abbiamo convertito gli elementi Pair[String, String] in un ArrayBuffer di elementi Pair[String, Int]. Da dove è arrivato ArrayBuffer? A quanto pare, Iterable.map crea e restituisce un’istanza di ArrayBuffer come nuova collezione iterabile.

Questo solleva un conflitto generale tra i tipi immutabili e le gerarchie di tipo orientate agli oggetti. Se un tipo base crea una nuova istanza durante un’operazione di modifica, come fa a sapere qual è il tipo dell’istanza che deve creare?

Potete risolvere questo problema in due modi. Ogni tipo nella gerarchia potrebbe ridefinire il metodo map per restituire un’istanza di quello stesso tipo. Questo approccio è soggetto a errori, però, in quanto sarebbe facile dimenticare di ridefinire tutti questi metodi quando viene aggiunto un nuovo tipo.

Anche se vi ricordaste sempre di ridefinire tutti i metodi, rimarreste con il problema di come implementare la ridefinizione. Invocate il metodo super per riutilizzare l’algoritmo, poi iterate attraverso l’istanza restituita per creare una nuova istanza del tipo corretto? Questo sarebbe inefficiente. Potreste copiare l’algoritmo e incollarlo in ogni ridefinizione, ma questo creerebbe problemi di mantenibilità, disallineamento e spreco di risorse.

Esiste un approccio alternativo: non provateci nemmeno. Come viene usata l’istanza effettivamente restituita? Ci interessa veramente che sia del tipo “sbagliato”? Tenete presente che tutto quello che di solito ci interessa sono le astrazioni di più basso livello come liste, mappe e insiemi. Nel caso di strutture dati funzionali, i tipi derivati che potremmo implementare usando l’ereditarietà orientata agli oggetti sono molto spesso implementazioni ottimizzate. La gerarchia dei contenitori di Scala ha alcuni livelli di astrazione verso l'alto: per esempio, Collection estende Iterable che estende AnyRef; ma sotto Collection ci sono Seq (genitore di List), Map, Set, &c.

Detto questo, se davvero avete bisogno di una mappa, potete crearne una abbastanza facilmente.

// esempi/cap-8/datastructs/map2-script.scala

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  "Wyoming" -> "Cheyenne")

val map2 = stateCapitals map { kv => (kv._1, kv._2.length) }

// val lengths = Map(map2)  // ERRORE: non funzionerà
val lengths = Map[String, Int]() ++ map2

println(lengths)

La riga di codice nascosta in un commento suggerisce che sarebbe bello se poteste semplicemente passare la nuova istanza di Iterable a Map.apply, ma questo non funziona. Ecco la firma di Map.apply.

object Map {
  …
  def apply[A, B](elems : (A, B)*) : Map[A, B] = …
  …
}

Questo metodo si aspetta una lista variabile di argomenti, non un’istanza di Iterable. Tuttavia, potete creare una mappa vuota del tipo corretto e poi aggiungervi la nuova istanza di Iterable usando il metodo ++ che restituisce una nuova istanza di Map.

Quindi, possiamo ottenere la mappa che vogliamo quando ci è necessario averne una. Sarebbe bello se i metodi come map restituissero una collezione dello stesso tipo di quella in ingresso, ma abbiamo visto che non c’è un modo facile per farlo. Invece, dobbiamo accettare che map e metodi simili restituiscano un’astrazione come Iterable e poi dobbiamo fare affidamento sul sottotipo specifico per popolare la collezione passando un’istanza di Iterable come argomento.

Un’operazione correlata di Map è flatMap, che può essere usata per “appiattire” una struttura dati gerarchica, rimuovere elementi “vuoti”, &c. Quindi, a differenza di map, potrebbe restituire una nuova collezione di dimensione diversa rispetto alla collezione originale.

// esempi/cap-8/datastructs/flatmap-script.scala

val graph = List(
  "a", List("b1", "b2", "b3"), List("c1", List("c21", Nil, "c22"), Nil, "e")
)

def flatten(list: List[_]): List[_] = list flatMap {
  case head :: tail => head :: flatten(tail)
  case Nil => Nil
  case x => List(x)
}

println(flatten(graph))

Questo script riduce il grafo gerarchico contenuto in graph alla lista List(a, b1, b2, b3, c1, c21, c22, e). Notate che gli elementi Nil sono stati rimossi. Abbiamo usato List[_] perché, a causa della cancellazione di tipo, non sapevamo quali fossero i parametri di tipo delle liste annidate quando abbiamo attraversato la lista esterna.

Ecco la firma di flatMap, insieme a quella di map, per un confronto.

trait Iterable[+A] {
  …
  def map[B]    (f : (A) => B) : Iterable[B] = …
  def flatMap[B](f : (A) => Iterable[B]) : Iterable[B] = …
  …
}

Ogni passo deve restituire Iterable[B], non B. Dopo aver attraversato la collezione, flatMap “appiattirà” tutte quelle istanze di Iterable in una collezione. Notate che flatMap non appiattirà elementi oltre al primo livello di profondità. Se il nostro letterale funzione lascia intatte le liste annidate, esse non verranno appiattite per noi.

Filtraggio

Attraversare una collezione ed estrarne una nuova collezione contenente elementi che corrispondono a certi criteri è un’operazione molto comune.

// esempi/cap-8/datastructs/filter-script.scala

val stateCapitals = Map(
  "Alabama" -> "Montgomery",
  "Alaska"  -> "Juneau",
  "Wyoming" -> "Cheyenne")

val map2 = stateCapitals filter { kv => kv._1 startsWith "A" }

println(map2)

Iterable definisce diversi tipi di metodi per filtrare o altrimenti restituire parte della collezione originale. (Commenti adattati dalla documentazione Scaladoc.)

trait Iterable[+A] {
  …
  // Restituisce questo iterabile senza i suoi primi n elementi. Se
  // l'iterabile ha meno di n elementi, viene restituito l'iterabile vuoto.
  def drop (n : Int) : Collection[A] = …

  // Restituisce il suffisso più lungo di questo iterabile il cui primo
  // elemento non soddisfa il predicato p.
  def dropWhile (p : (A) => Boolean) : Collection[A] = …

  // Applica un predicato p a tutti gli elementi di questo iterabile e 
  // restituisce true se e solo se p restituisce true per almeno un elemento.
  def exists (p : (A) => Boolean) : Boolean = …

  // Restituisce tutti gli elementi di questo iterabile che soddisfano il
  // predicato p. L'ordine degli elementi viene preservato.
  def filter (p : (A) => Boolean) : Iterable[A] = …

  // Trova e restituisce il primo elemento dell'iterabile che soddisfa il
  // predicato, se questo elemento esiste.
  def find (p : (A) => Boolean) : Option[A] = …

  // Restituisce l'indice del primo elemento che soddisfa il predicato, o -1.
  def findIndexOf (p : (A) => Boolean) : Int = …

  // Applica un predicato p a tutti gli elementi di questo iterabile e
  // restituisce true se e solo se p restituisce true per tutti gli elementi.
  def forall (p : (A) => Boolean) : Boolean = …

  // Restituisce l'indice della prima occorrenza dell'oggetto specificato
  // in questo iterabile.
  def indexOf [B >: A](elem : B) : Int = …

  // Partiziona questo iterabile in due iterabili in accordo col predicato.
  def partition (p : (A) => Boolean) : (Iterable[A], Iterable[A]) = …

  // Controlla se l'altro iterabile contiene gli stessi elementi.
  def sameElements [B >: A](that : Iterable[B]) : Boolean = …

  // Restituisce un iterabile che consiste solo dei primi n elementi di
  // questo iterabile, o l'intero iterabile se ha meno di n elementi.
  def take (n : Int) : Collection[A] = …

  // Restituisce il prefisso più lungo di questo iterabile i cui elementi
  // soddisfano il predicato p.
  def takeWhile (p : (A) => Boolean) : Iterable[A] = …
}

Tipi come Map e Set sono dotati di ulteriori metodi.

Ripiegamento e riduzione

Discuteremo il ripiegamento e la riduzione nella stessa sezione, dato che sono simili. Entrambe sono operazioni per “restringere” una collezione a una collezione più piccola o a un singolo valore.

Il ripiegamento comincia con un valore di “origine” (anche detto “seme”) ed elabora ogni elemento nel contesto di quel valore. Al contrario, la riduzione non comincia con un valore iniziale fornito dall’utente. Piuttosto, usa il primo elemento come valore iniziale.

// esempi/cap-8/datastructs/foldreduce-script.scala

List(1,2,3,4,5,6) reduceLeft(_ + _)

List(1,2,3,4,5,6).foldLeft(10)(_ * _)

Questo script riduce la lista di interi sommandoli tra loro, restituendo 21. Poi ripiega la stessa lista usando la moltiplicazione con un seme uguale a 10, restituendo 7200.

La riduzione non funziona su una collezione vuota, dato che non ci sarebbe nulla da restituire. In questo caso, viene lanciata un’eccezione. Il ripiegamento su una collezione vuota restituirà semplicemente il valore di origine.

Il ripiegamento offre anche più opzioni per generare il risultato finale. Ecco un’operazione di “ripiegamento” che in realtà è un’operazione di mappatura.

// esempi/cap-8/datastructs/foldleft-map-script.scala

List(1, 2, 3, 4, 5, 6).foldLeft(List[String]()) {
  (list, x) => ("<" + x + ">") :: list
}.reverse

Questa operazione restituisce List(<1>, <2>, <3>, <4>, <5>, <6>). Notate che abbiamo dovuto invocare reverse sul risultato per ottenere una lista ordinata allo stesso modo di quella in ingresso.

Ecco le firme delle varie operazioni di ripiegamento e riduzione in Iterable.

trait Iterable[+A] {
  …
  // Combina insieme gli elementi di questo iterabile usando la funzione
  // binaria op, da sinistra a destra, cominciando dal valore z.
  def foldLeft [B](z : B)(op : (B, A) => B) : B

  // Combina insieme gli elementi di questo iterabile usando la funzione
  // binaria op, da destra a sinistra, cominciando dal valore z.
  def foldRight [B](z : B)(op : (A, B) => B) : B

  // Simile a foldLeft ma può essere usata come un operatore con l'ordine
  // inverso degli argomenti. Cioè, z /: xs è uguale a xs foldLeft z.
  def /: [B](z : B)(op : (B, A) => B) : B

  // Un alias di foldRight. Cioè, xs :\ z è uguale a xs foldRight z.
  def :\ [B](z : B)(op : (A, B) => B) : B

  // Combina insieme gli elementi di questo iterabile usando l'operatore
  // binario op, da sinistra a destra.
  def reduceLeft [B >: A](op : (B, A) => B) : B

  // Combina insieme gli elementi di questo iterabile usando l'operatore
  // binario op, da destra a sinistra.
  def reduceRight [B >: A](op : (A, B) => B) : B
}

Molte persone considerano le forme operatore, :\ per foldRight e /: per foldLeft, un po’ più oscure e difficili da ricordare. Quando scrivete codice, non dimenticate quanto è importante comunicare con chi lo leggerà.

Perché esistono forme di ripiegamento e riduzione a sinistra e a destra? Per i primi esempi che vi abbiamo mostrato, la somma e la moltiplicazione di una lista di interi, le due forme restituirebbero lo stesso risultato. Considerate una versione di foldRight dal nostro ultimo esempio che usi il ripiegamento per mappare gli interi sulle stringhe.

// esempi/cap-8/datastructs/foldright-map-script.scala

List(1, 2, 3, 4, 5, 6).foldRight(List[String]()) {
  (x, list) => ("<" + x + ">") :: list
}

Questo script produce List(<1>, <2>, <3>, <4>, <5>, <6>) senza dover invocare reverse, come invece abbiamo fatto prima. Notate anche che gli argomenti della funzione letterale sono in ordine inverso rispetto agli argomenti usati con foldLeft, come richiesto dalla definizione di foldRight.

Sia foldLeft sia reduceLeft elaborano gli elementi da sinistra a destra. Ecco la sequenza di foldLeft per List(1, 2, 3, 4, 5, 6).foldLeft(10)(_ * _).

((((((10 * 1) * 2) * 3) * 4) * 5) * 6)
((((((10) * 2) * 3) * 4) * 5) * 6)
(((((20) * 3) * 4) * 5) * 6)
((((60) * 4) * 5) * 6)
(((240) * 5) * 6)
((1200) * 6)
(7200)

Ecco la sequenza di foldRight.

(1 * (2 * (3 * (4 * (5 * (6 * 10))))))
(1 * (2 * (3 * (4 * (5 * (60))))))
(1 * (2 * (3 * (4 * (300)))))
(1 * (2 * (3 * (1200))))
(1 * (2 * (3600)))
(1 * (7200))
(7200)

A quanto pare, foldLeft e reduceLeft hanno un vantaggio molto importante rispetto ai loro fratelli “destri”: sono ricorsivi in coda, e come tali possono beneficiare dell’ottimizzazione per la ricorsione in coda.

Se osservate le precedenti scomposizioni per la moltiplicazione di interi, potete probabilmente vedere perché queste operazioni sono ricorsive in coda. Ricordatevi che la chiamata ricorsiva deve essere l’ultima operazione in un’iterazione. Per ogni riga nella sequenza di foldRight, la moltiplicazione più esterna non può essere eseguita fino a quando le moltiplicazioni più interne non sono state tutte completate, quindi l’operazione non è ricorsiva in coda.

Nello script seguente, la prima riga stampa 1784293664, mentre la seconda causa uno stack overflow.

// esempi/cap-8/datastructs/reduceleftright-script.scala

println((1 to 1000000) reduceLeft(_ + _))
println((1 to 1000000) reduceRight(_ + _))

Ma allora perché avere entrambi i tipi di ricorsione? Se lo stack overflow non vi preoccupa, una ricorsione a destra potrebbe essere l’abbinamento più naturale per l’operazione che state eseguendo. Ricordatevi che quando abbiamo usato foldLeft per mappare gli interi sulle stringhe abbiamo dovuto invertire il risultato. Questo è stato abbastanza facile da fare in quel caso, ma in generale il risultato di una ricorsione a sinistra potrebbe non essere sempre facile da convertire nella forma a destra.

Option funzionali

Le operazioni funzionali che abbiamo analizzato si trovano ovunque nella libreria Scala, non solo nelle collezioni. Il comodo contenitore Option supporta filter, map, flatMap e altri metodi orientati alle funzioni che vengono applicati solo se l’istanza di Option non è vuota (cioè se è un’istanza di Some e non un’istanza di None).

Vediamo questa caratteristica in azione.

// esempi/cap-8/datastructs/option-script.scala

val someNumber = Some(5)
val noneNumber = None

for (option <- List(noneNumber, someNumber)) {
  option.map(n => println(n * 5))
}

In questo esempio, tentiamo di moltiplicare i contenuti di due Option per cinque. Normalmente, il tentativo di moltiplicare un valore null provocherebbe un errore. Ma dato che l’implementazione di map in Option applica la funzione passata come argomento solo quando l’istanza non è vuota, non dobbiamo preoccuparci di verificare la presenza di un valore o di gestire un’eccezione quando effettuiamo una mappatura su None.

Le operazioni funzionali di Option ci risparmiano l’uso di espressioni condizionali aggiuntive o del pattern matching. Il pattern matching, comunque, è uno strumento potente nel contesto della programmazione funzionale, come vedremo nella prossima sezione.

Pattern matching

Abbiamo visto molti esempi di pattern matching nel corso del libro. Ne abbiamo avuto il nostro primo assaggio nella sezione Un assaggio di concorrenza del capitolo 1, quando abbiamo usato il pattern matching nel nostro attore che disegnava forme geometriche. Abbiamo discusso approfonditamente il pattern matching nella sezione Pattern matching del capitolo 3.

Il pattern matching è uno strumento fondamentale nella programmazione funzionale. È importante tanto quanto il polimorfismo nella programmazione orientata agli oggetti, sebbene gli scopi delle due tecniche siano molto differenti.

Il pattern matching è un modo elegante di decomporre gli oggetti nelle loro parti costituenti per elaborarle. A prima vista, l’uso del pattern matching per questo scopo sembra violare il principio di incapsulamento seguito dagli oggetti. L’immutabilità, però, stempera notevolmente questo conflitto. Il rischio che le parti di un oggetto possano cambiare al di fuori del controllo dell’oggetto che le racchiude viene evitato.

Per esempio, supponendo di avere una classe Person che contiene un lista di indirizzi, non è un problema esporre quella lista ai clienti se la lista è immutabile, perché così i clienti non possono modificare inaspettatamente la lista.

Tuttavia, esporre le parti costituenti genera una potenziale dipendenza tra i clienti e i tipi di quelle parti. Non possiamo cambiare il modo in cui le parti sono implementate senza danneggiare i clienti. Un modo per minimizzare questo rischio è quello di esporre le astrazioni di livello più alto possibile. Quando i clienti accedono agli indirizzi di una persona, devono davvero sapere che i dati sono memorizzati in un’istanza di List o è sufficiente che sappiano che i dati si trovano in un’istanza di Iterable o di Seq? Se è così, allora possiamo modificare l’implementazione degli indirizzi purché supporti quelle astrazioni. Naturalmente, nella programmazione orientata agli oggetti sappiamo da tempo di dover ammettere solo le dipendenze dalle astrazioni, non dai dettagli concreti (si veda per esempio [Martin2003]).

Il pattern matching funzionale e il polimorfismo orientato agli oggetti sono potenti complementi l’uno dell’altro. Lo abbiamo visto nell’esempio degli attori proveniente dalla sezione Un assaggio di concorrenza del capitolo 1, dove abbiamo usato il pattern matching sulle astrazioni Shape ma abbiamo invocato l’operazione polimorfica draw.

Funzioni parziali

Avete già visto le funzioni applicate parzialmente, o funzioni parziali, nel corso di questo libro. Quando avete visto un trattino basso passato a un metodo, avete probabilmente visto l’applicazione parziale al lavoro.

Le funzioni parziali sono espressioni in cui una funzione riceve argomenti solo per alcuni dei parametri che definisce. In Scala, le funzioni parziali vengono usate per impacchettare una funzione insieme ai suoi argomenti e al suo valore di ritorno in modo da assegnarla a una variabile o passarla come argomento a un’altra funzione.

Questa spiegazione rimane oscura fino a quando non vedete una funzione parziale in azione.

// esempi/cap-8/partial/partial-script.scala

def concatUpper(s1: String, s2: String): String = (s1 + " " + s2).toUpperCase

val c = concatUpper _
println(c("pantaloni", "corti"))

val c2 = concatUpper("pantaloni", _: String)
println(c2("corti"))

Invocare concatUpper con un trattino basso (_) trasforma il metodo in un valore funzione. Nella prima parte di questo esempio, abbiamo assegnato una versione parzialmente applicata di concatUpper al valore c. Poi l’abbiamo applicata, invocando implicitamente il metodo apply su c passandogli direttamente gli argomenti. Quindi abbiamo stampato il valore di ritorno.

Nella seconda parte, abbiamo specificato il primo argomento di concatUpper ma non il secondo, anche se del secondo argomento abbiamo specificato il tipo. Abbiamo assegnato questa variante a un secondo valore c2. Per produrre la stessa uscita già vista, è necessario passare solo un singolo valore quando applichiamo c2. Abbiamo applicato parte della funzione nell’assegnamento a c2 e abbiamo “riempito gli spazi vuoti” quando abbiamo invocato c2 nella riga successiva.

Abbiamo anche visto funzioni parzialmente applicate senza la sintassi del trattino basso.

List("pantaloni", "corti").map(println)

In questo esempio, println è la funzione parzialmente applicata. Viene applicata a ogni elemento nella lista nel momento in cui è invocata dalla mappatura. Dato che l’operazione di mappatura si aspetta una funzione come argomento, non abbiamo bisogno di scrivere map(println, _). In questo contesto, il trattino basso finale che trasforma println in un valore funzione è sottinteso.

Potete anche pensare alle funzioni parziali come a funzioni che vi informeranno quando fornite loro argomenti che sono al di fuori del loro dominio. Ogni funzione parziale è, come potreste indovinare, di tipo PartialFunction. Questo tratto definisce un metodo orElse che accetta un’altra istanza di PartialFunciton; se la prima funzione parziale non si applicasse, verrà invocata la seconda.

Ancora una volta, la spiegazione è più facile da capire vedendo un esempio pratico.

// esempi/cap-8/partial/orelse-script.scala

val truthier: PartialFunction[Boolean, String] = { case true => "veritiero" }
val fallback: PartialFunction[Boolean, String] = { case x => "approssimativo" }
val tester = truthier orElse fallback

println(tester(1 == 1))
println(tester(2 + 2 == 5))

In questo esempio, tester è una funzione parziale composta da altre due funzioni parziali, truthier e fallback. Nella prima istruzione println viene eseguita truthier perché la clausola case interna alla funzione parziale trova una corrispondenza. Nella seconda istruzione viene eseguita fallback perché il valore dell’espressione è al di fuori del dominio di truthier.

Le istruzioni case che abbiamo visto durante la nostra esplorazione di Scala vengono espanse internamente in funzioni parzialmente applicate. Le funzioni offrono il metodo astratto isDefinedAt, una caratteristica del tratto PartialFunction, usato per specificare i limiti del dominio di una funzione parziale.

// esempi/cap-8/partial/isdefinedat-script.scala

val pantsTest: PartialFunction[String, String] = {
  case "pantaloni" => "sì, indossiamo i pantaloni!"
}

println(pantsTest.isDefinedAt("pantaloni"))
println(pantsTest.isDefinedAt("gonna"))

Qui, la nostra funzione parziale è un test per la stringa "pantaloni". Quando controlliamo se la stringa "pantaloni" è definita per questa funzione, il risultato è true. Ma per la stringa "gonna" il risultato è false. Se avessimo definito una nostra funzione parziale, avremmo potuto fornire un’implementazione di isDefinedAt che verifica in modo arbitrario il rispetto dei limiti della nostra funzione.

Currying

Così come avete incontrato le funzioni parzialmente applicate prima che le definissimo, avete anche visto le funzioni curry. Dal nome del matematico Haskell Curry (che ha dato il nome anche al linguaggio Haskell), il currying trasforma una funzione che accetta più argomenti in una catena di funzioni ognuna delle quali accetta un singolo argomento.

In Scala, le funzioni curry vengono definite con molteplici liste di parametri, come nell’esempio seguente.

def cat(s1: String)(s2: String) = s1 + s2

Naturalmente, possiamo definire più di due parametri per una funzione curry, se vogliamo.

Possiamo anche usare la seguente sintassi per definire una funzione curry.

def cat(s1: String) = (s2: String) => s1 + s2

Sebbene a nostro parere la prima sintassi sia più leggibile, l’uso di questa sintassi elimina il requisito di un trattino basso finale quando trattiamo la funzione curry come una funzione parzialmente applicata.

Nel REPL di Scala, l’invocazione della nostra funzione curry per concatenare due stringhe si effettua in questo modo.

scala> cat("foo")("bar")
res1: java.lang.String = foobar

Possiamo anche convertire metodi che accettano più argomenti in una forma curry con il metodo Function.curried.

scala> def cat(s1: String, s2: String) = s1 + s2
cat: (String,String)java.lang.String

scala> val curryCat = Function.curried(cat _)
curryCat: (String) => (String) => java.lang.String = <function>

scala> cat("foo", "bar") == curryCat("foo")("bar")
res2: Boolean = true

In questo esempio trasformiamo una funzione cat definita con due parametri nel suo equivalente curry che accetta molteplici liste di argomenti. Se cat fosse stata definita con tre parametri, il suo equivalente curry avrebbe accettato tre liste di argomenti, e così via. Le due forme sono funzionalmente equivalenti, come dimostrato dal confronto di uguaglianza, ma curryCat ora può anche essere usata come base per una funzione parzialmente applicata.

scala> val partialCurryCat = curryCat("foo")(_)
partialCurryCat: (String) => java.lang.String = <function>

scala> partialCurryCat("bar")
res3: java.lang.String = foobar

In pratica, il currying viene principalmente usato allo scopo di specializzare le funzioni per particolari tipi di dato. Potete cominciare con un caso estremamente generale e usare la forma curry di una funzione per ridurla a casi particolari.

Per fare un semplice esempio di questo approccio, il codice seguente fornisce forme specializzate di una funzione base che gestisce la moltiplicazione.

def multiplier(i: Int)(factor: Int) = i * factor
val byFive = multiplier(5) _
val byTen = multiplier(10) _

Abbiamo cominciato con multiplier, che accetta due argomenti: un intero, e un altro intero per il quale moltiplicare il primo. Dopodiché usiamo il currying per creare due casi speciali di multiplier sotto forma di valori funzione. Notate i trattini bassi finali, che inducono il compilatore ad applicare il currying all’espressione precedente. In particolare, il trattino basso indica che gli argomenti rimanenti (in questo esempio, un solo argomento) non sono specificati.

Quando invochiamo le nostre funzioni curry nella console di Scala, otteniamo il risultato previsto.

scala> byFive(2)
res4: Int = 10

scala> byTen(2)
res5: Int = 20

Rivisiteremo il metodo curry nella sezione Tipi funzione del capitolo 12.

Come potete vedere, il currying e le funzioni parzialmente applicate sono concetti intimamente correlati. Potete vederli riferiti in maniera intercambiabile, ma quello che è importante è la loro applicazione (nessun gioco di parole intenzionale).

Impliciti

A volte avete bisogno di usare un’istanza di un certo tipo in un contesto che richiede un tipo diverso ma in qualche modo simile. Per il caso “singolo” potreste creare un’istanza del tipo richiesto usando lo stato dell’istanza che avete già. Tuttavia, per il caso generale, se questo problema si incontra di frequente, vorreste piuttosto avere un meccanismo di conversione automatica.

Una situazione simile si verifica quando invocate ripetutamente una o più funzioni e dovete passare gli stessi valori a tutte le invocazioni. Potreste gradire un modo di specificare un valore predefinito per un certo parametro in modo che non sia necessario specificarlo esplicitamente ogni volta.

La parola chiave implicit di Scala può essere usata per soddisfare entrambe le necessità.

Conversioni implicite

Considerate il seguente frammento di codice.

val name: String = "scala"
println(name.capitalize.reverse)

Quando viene eseguito, stampa il testo seguente.

alacS

Nella sezione L’oggetto Predef del capitolo 7 abbiamo visto che Predef definisce il tipo String in modo che sia uguale a java.lang.String, ma java.lang.String non definisce i metodi capitalize e reverse. Come funziona questo codice?

La libreria Scala definisce una classe “avvolgente” chiamata scala.runtime.RichString che è dotata di questi metodi, e il compilatore converte implicitamente la stringa name in un’istanza di RichString, usando un metodo speciale definito in Predef e chiamato stringWrapper.

implicit def stringWrapper(x: String) = new runtime.RichString(x)

La parola chiave implicit dice al compilatore che può usare questo metodo per una conversione “implicita” da String a RichString ogni volta che quest’ultima è necessaria. Il compilatore ha notato un tentativo di invocare il metodo capitalize e ha determinato che RichString è dotata di questo metodo; poi ha esaminato l’ambito di visibilità corrente per cercare un metodo implicit che convertisse String in RichString, trovando stringWrapper.

Come vedremo nella sezione Viste e limiti sulle viste del capitolo 12, a volte questi metodi di conversione vengono chiamati viste, nel senso che il nostro metodo stringWrapper offre una vista da String a RichString.

Predef definisce molti altri metodi di conversione implicita, la maggior parte dei quali segue la convenzione nominale vecchio2Nuovo, dove vecchio è il tipo di oggetto disponibile e Nuovo è il tipo desiderato. Tuttavia, non c’è alcuna restrizione sui nomi dei metodi di conversione. Un certo numero di altre classi avvolgenti “ricche” viene definito nel package scala.runtime.

Ecco un riepilogo delle regole di ricerca usate dal compilatore per trovare e applicare i metodi di conversione. Per maggiori dettagli, si veda [ScalaSpec2009].

  1. Nessuna conversione verrà tentata se l’oggetto è di tipo compatibile con il metodo.
  2. Vengono considerati solo i metodi con la parola chiave implicit.
  3. Vengono considerati solo i metodi impliciti nell’ambito di visibilità corrente, così come i metodi impliciti definiti nell’oggetto associato del tipo destinazione.
  4. I metodi impliciti non vengono concatenati per arrivare dal tipo disponibile al tipo destinazione attraverso tipi intermedi. Verrà considerato solo un metodo che prende una singola istanza del tipo disponibile e restituisce un’istanza del tipo destinazione.
  5. Nessuna conversione viene tentata se esiste più di un metodo di conversione che potrebbe essere applicato. Ci deve essere una e una sola possibilità.

E se non potete definire un metodo di conversione in un oggetto associato, per soddisfare la terza regola, forse perché non potete modificare o creare l’oggetto associato? In questo caso, definite il metodo da qualche altra parte e importatelo. Normalmente, definireste un object con i soli metodi di conversione necessari. Ecco un esempio.

// esempi/cap-8/implicits/implicit-conversion-script.scala
import scala.runtime.RichString

class FancyString(val str: String)

object FancyString2RichString {
    implicit def fancyString2RichString(fs: FancyString) =
        new RichString(fs.str)
}

import FancyString2RichString._

val fs = new FancyString("scala")
println(fs.capitalize.reverse)

Non possiamo modificare RichString o Predef per aggiungere un metodo di conversione implicita per la nostra classe personalizzata FancyString. Invece, definiamo un object chiamato FancyString2String e vi definiamo il metodo di conversione. Poi importiamo il contenuto di questo oggetto e il convertitore viene invocato implicitamente nell’ultima riga. L’uscita di questo script è la seguente.

alacS

Questo schema per aggiungere efficacemente nuovi metodi alle classi è stato chiamato Pimp my library (letteralmente, Decora la mia libreria) [Odersky2006].

Parametri di funzione impliciti

Nel capitolo 2 abbiamo visto che Scala 2.8 aggiunge il supporto per i valori predefiniti degli argomenti, che potete trovare in altri linguaggi come Ruby e C++. Ci sono altri modi di ottenere lo stesso effetto in tutte le versioni di Scala. Il primo consiste nell’usare il currying per le funzioni, come abbiamo già visto. Il secondo consiste nel definire valori impliciti tramite la parola chiave implicit.

Esaminiamo come funzionano i valori impliciti.

// esempi/cap-8/implicits/implicit-parameter-script.scala
import scala.runtime.RichString

def multiplier(i: Int)(implicit factor: Int) {
  println(i * factor)
}

implicit val factor = 2

multiplier(2)
multiplier(2)(3)

Il nostro moltiplicatore è definito con due liste di parametri. L’ultima include un valore intero factor indicato come implicit. Questa parola chiave induce il compilatore a cercare il valore di factor nell’ambito circostante, se è disponibile, oppure a usare qualunque argomento sia stato esplicitamente fornito alla funzione.

Abbiamo definito il nostro valore factor nell’ambito di visibilità, e quel valore viene usato nella prima invocazione di multiplier. Nella seconda invocazione stiamo esplicitamente passando un valore per factor, in modo che venga usato al posto del valore definito nell’ambito circostante.

Essenzialmente, i parametri di funzione impliciti si comportano come parametri con un valore predefinito, con la differenza fondamentale che il valore proviene dall’ambito circostante. Se il nostro valore factor si fosse trovato in una classe o in un oggetto, avremmo dovuto importarlo nell’ambito locale. Se il compilatore non può determinare il valore da usare per un parametro implicito, genererà un errore.

Considerazioni conclusive sugli impliciti

Gli impliciti possono essere pericolosamente vicini alla “magia”; se vengono usati in maniera eccessiva, offuscano il comportamento del codice per il lettore. In più, state attenti all’implementazione dei metodi di conversione, in particolare se il tipo di ritorno non è esplicitamente dichiarato. Se una futura modifica al metodo provocasse anche un sottile cambiamento del tipo di ritorno, la conversione potrebbe improvvisamente smettere di funzionare. In generale, gli impliciti possono causare comportamenti misteriosi che sono difficili da trovare e correggere.

Quando decidete come implementare i valori “predefiniti” per gli argomenti dei metodi, uno dei vantaggi principali dei valori predefiniti degli argomenti (in Scala 2.8) è che la decisione su quale valore usare come valore predefinito viene presa dal manutentore del metodo. L’implementazione è più chiara e voi evitate la “magia” dei metodi impliciti. Tuttavia, uno svantaggio dei valori predefiniti degli argomenti potrebbe essere la relativa difficoltà di usare un valore “predefinito” differente sulla base del contesto in cui il metodo viene invocato. Scala 2.8 vi offre una certa flessibilità, in quanto potete usare un’espressione per un argomento invece di un semplice valore costante. Ma questa flessibilità potrebbe non essere sufficiente; in questo caso, gli impliciti costituiscono un’alternativa molto flessibile e potente.

Usate gli impliciti con parsimonia e attenzione. Considerate la possibilità di aggiungere un tipo di ritorno esplicito ai metodi di conversione “non banali”.

Invocazione per nome o per valore

Tipicamente, i parametri vengono passati per valore alle funzioni, cioè il valore del parametro viene determinato prima di essere passato alla funzione. Nella maggior parte delle circostanze, questo è il comportamento desiderato e atteso.

Ma come possiamo fare se vogliamo scrivere una funzione che accetta come parametro un’espressione che vogliamo evitare di valutare fino a quando non è utilizzata all’interno della nostra funzione? Per questa circostanza Scala vi offre i parametri per nome.

Un parametro per nome viene specificato omettendo le parentesi che normalmente accompagnano un parametro funzione, come nel seguente esempio.

def myCallByNameFunction(callByNameParameter: => ReturnType)

Senza questa abbreviazione sintattica, questa definizione di metodo si presenterebbe nel modo seguente.

def myCallByNameFunction(callByNameParameter: () => ReturnType)

E in più dovremmo includere quelle sgradevoli parentesi vuote in ogni invocazione di quel meotdo. L’uso dei parametri per nome rimuove questo vincolo.

I parametri per nome possono essere usati per implementare potenti costrutti di ciclo, tra le altre cose. Facciamo i pazzi e implementiamo il nostro ciclo while, gettando anche il currying nella mischia.

// esempi/cap-8/overrides/call-by-name-script.scala

def whileAwesome(conditional: => Boolean)(f: => Unit) {
  if (conditional) {
    f
    whileAwesome(conditional)(f)
  }
}

var count = 0

whileAwesome(count < 5) {
  println("ancora fantastico")
  count += 1
}

Cosa succederebbe se rimuovessimo la freccia tra conditional: e Boolean? L’espressione count < 5 verrebbe valutata a true prima di essere passata al nostro ciclo while personalizzato, e il messaggio "ancora fantastico" verrebbe stampato indefinitamente. Ritardando la valutazione fino a quando conditional viene invocato all’interno della nostra funzione con un parametro per nome, otteniamo il comportamento atteso.

Valori ritardati

Nella sezione Ridefinire i campi astratti e concreti nei tratti del capitolo 6 abbiamo mostrato diversi scenari in cui l’ordine della inizializzazione dei campi può essere problematico in caso di ridefinizione, e li abbiamo risolti utilizzando i campi pre-inizializzati. Ora discutiamo l’altra soluzione che avevamo menzionato in precedenza, i valori ritardati.

Ecco uno di quegli esempi, riscritto usando un valore ritardato lazy val.

// esempi/cap-8/overrides/trait-lazy-init-val-script.scala

trait AbstractT2 {
  println("In AbstractT2:")
  val value: Int
  lazy val inverse = { println("inizializzo inverse:"); 1.0/value }
  //println("AbstractT2: value = " + value + ", inverse = " + inverse)
}

val c2d = new AbstractT2 {
  println("In c2d:")
  val value = 10
}

println("Uso c2d:")
println("c2d.value = " + c2d.value + ", inverse = " + c2d.inverse)

Questa è l’uscita dello script.

In AbstractT2:
In c2d:
Uso c2d:
inizializzo inverse:
c2d.value = 10, inverse = 0.1

Come prima, stiamo usando una classe anonima annidata che estende implicitamente il tratto. Il corpo della classe, che inizializza value, viene valutato dopo il corpo del tratto. Tuttavia, notate che inverse è dichiarato come lazy; questo significa che il lato destro verrà valutato solo quando inverse viene effettivamente usato. In questo caso, ciò accade nell’ultima istruzione println. Solo in quel momento inverse viene inizializzato, usando value, che a quel punto è correttamente inizializzato.

Provate a estrarre dal commento l’istruzione println al termine del corpo di AbstractT2. Cosa succede ora?

In AbstractT2:
inizializzo inverse:
AbstractT2: value = 0, inverse = Infinity
In c2d:
Uso c2d:
c2d.value = 10, inverse = Infinity

Questa println impone la valutazione di inverse dentro al corpo di AbstractT2, prima che value venga inizializzato dal corpo della classe, riproducendo così il problema che avevamo prima.

Questo esempio sottolinea un punto importante: se altri valori val usano il valore lazy val nel corpo della stessa classe o dello stesso tratto, quei valori dovrebbero essere dichiarati lazy a loro volta. In più, fate attenzione alle invocazioni di funzione che si trovano nel corpo e usano i valori ritardati.

Se un valore è ritardato, assicuratevi che tutti gli usi di quel valore siano ritardati.

Quindi, in che modo un valore ritardato è differente da un’invocazione di metodo? In un’invocazione di metodo il corpo viene eseguito ogni volta che il metodo è invocato. Per un valore ritardato, il “corpo” di inizializzazione viene valutato una volta sola, nel momento in cui la variabile viene usata per la prima volta. Questa valutazione unica non ha molto senso per un campo mutabile. Di conseguenza, la parola chiave lazy non è consentita sui campi var. (Non possono effettivamente farne uso comunque.)

Potete usare i valori ritardati anche per evitare inizializzazioni costose di cui potreste non avere effettivamente bisogno e per differire le inizializzazioni che rallentano l’avvio dell’applicazione. I valori ritardati funzionano bene nei costruttori, dove è chiaro agli altri programmatori che tutte le operazioni uniche di supporto per inizializzare un’istanza vengono eseguite in un solo posto.

Il “ritardo” si può anche sfruttare per gestire strutture dati potenzialmente infinite dove solo un sottoinsieme ridotto dei dati verrà effettivamente usato. In effetti, la notazione matematica è intrinsecamente ritardata. Quando scriviamo la sequenza di Fibonacci, per esempio, potremmo scriverla come una sequenza infinita in questo modo.

Fib = 1, 1, 2, 3, 5, 8, …

Alcuni linguaggi funzionali puri sono ritardati per default, quindi imitano questo comportamento nella maniera più fedele possibile. Questo può funzionare senza esaurire le risorse della macchina se l’utente non cerca mai di utilizzare più di un sottoinsieme finito di questi valori. Scala non è un linguaggio ritardato per default, ma fornisce un supporto per lavorare con strutture dati infinite. Affronteremo questo argomento nella sezione Strutture dati infinite ed esecuzione ritardata del capitolo 12.

Riepilogo: le astrazioni per i componenti funzionali

Quando la programmazione orientata agli oggetti divenne popolare alla fine degli anni ’80 e all’inizio degli anni ’90, la speranza fu quella di veder nascere un’era di componenti software riusabili. Le cose non sono andate proprio così, a parte in alcuni rari casi, come le API dei sistemi a finestre sulle varie piattaforme.

Perché questo non è accaduto? Ci sono sicuramente molte ragioni, ma una possibile causa è il fatto che i semplici protocolli di interoperabilità a livello di codice sorgente o di codice eseguibile che avrebbero dovuto unire tra loro questi componenti non si sono mai materializzati. La ricchezza delle API orientate agli oggetti è stato proprio il fattore che ha minato la componentizzazione.

I modelli a componenti che hanno avuto successo sono tutti basati su fondamenta molto semplici. In elettronica, i circuiti integrati si agganciano ai bus di comunicazione con 2n morsetti che trasmettono segnali booleani, cioè accesi o spenti. Da quel protocollo molto semplice è nata la crescita industriale più esplosiva nella storia dell’umanità.

HTTP è un altro buon esempio. Con una manciata di tipi di messaggio e uno standard molto semplice per il contenuto del messaggio, questo protocollo ha posto le basi per la rivoluzione di Internet. I servizi web di tipo REST costruiti su HTTP si stanno rivelando riusciti come componenti, ma la loro complessità è già sufficiente da rendere necessaria una certa cura per assicurarne il corretto funzionamento.

Quindi c’è speranza per la nascita di un modello a componenti a livello di codice sorgente o di codice eseguibile? Probabilmente un tale modello non sarà orientato agli oggetti, come abbiamo visto; piuttosto, potrebbe essere più funzionale.

I componenti dovrebbero interoperare scambiandosi alcune strutture dati immutabili, come per esempio liste e mappe, che contengano sia dati sia “comandi”. Un modello a componenti di questo tipo avrebbe la semplicità necessaria per diffondersi e la ricchezza richiesta per funzionare nel mondo reale. Notate come questo suoni molto simile ad HTTP e REST.

In effetti, il modello degli attori possiede molte di queste qualità, come vedremo nel prossimo capitolo.

© 2008–9 O’Reilly Media
© 2009–10 Giulio Piancastelli per la traduzione italiana