Introduzione a Scala

II parte: Primi passi con i costrutti funzionalidi

Nel precedente articolo abbiamo visto alcuni concetti fondamentali di Scala e quelli che a nostro avviso rappresentano i motivi più importanti per cui un programmatore Java dovrebbe conoscere e lavorare con Scala. Stavolta vedremo alcuni dei costrutti fondamentali della programmazione funzionale, tralasciando tutti quegli elementi in comune con Java e i linguaggi più imperativi. Tutti gli esempi che daremo potranno essere eseguiti con successo sia nell‘interprete Scala, sia uno dei vari ambienti di sviluppo per Scala (praticamente ogni IDE Java ha un proprio plug-in per Scala).

All'inizio di quest'anno, Martin Odersky ha pubblicato [1] una roadmap per chi vuole imparare Scala, suddivisa in cinque livelli, da "Beginning application programmer" a "Expert library designer". In questo articolo faremo una veloce panoramica delle conoscenze di un "Beginning application programmer".

Scala e le collezioni

Scala [2], rispetto a Java, consente una grande flessibilità con le collezioni. Innanzitutto, le tradizionali Collection di Java sono supportate. Ma ciò solo per compatibilità con altre librerie, perche' lo sviluppatore Scala non dovrebbe usarle:  infatti, se sono necessarie collezioni mutabili, Scala ha la sua gerarchia di contenitori molto più eleganti e semplici da usare. Per cominciare, diamo uno sguardo ai package che la libreria standard di Scala offre riguardo alle collection [3].

scala.collection

Tutte le collezioni, si trovano qui o in uno dei suoi sub package.

scala.collection.mutable

Qui si trovano le collezioni mutabili, ossia che possono essere modificate in place, proprio come le collezioni Java. Eccone alcune:

  • ListBuffer
  • LinkedList
  • DoubleLinkedList
  • Queue
  • StringBuilder
  • Stack
  • HashSet
  • HashMap

scala.collection.immutable

Contiene le collezioni immutabili: ogni operazione di scrittura si traduce nella creazione di una nuova collezione. Le più importanti:

  • List
  • Queue
  • Stack
  • HashMap
  • TreeSet
  • TreeMap

Array

L'array è a parte perche' non è definito all'interno di scala.collection, bensì nel package scala e corrisponde esattamente a un array Java, con una differenza: attraverso le conversioni implicite è possibile utilizzare con esso gli stessi metodi tipici delle collezioni in Scala.

Lavorare con le collezioni

Un programmatore Scala difficilmente si preoccupa quali implementazioni utilizzare, almeno all'inizio, perche' è sempre possibile passare da una implementazione ad un'altra (chi ha dovuto riscrivere una libreria per utilizzare liste al posto di array sa quale sia il vantaggio). Ma andiamo con ordine.

scala.collection.Traversable costituisce la radice della gerarchia delle collezioni, essendo un trait (grossomodo l'equivalente di un'interfaccia di Java, ma che può contenere anche metodi concreti, come vedremo più avanti) avente un metodo astratto:

def foreach[U](f: A ⇒ U): Unit

che permette di eseguire una funzione (l'argomento f) su un insieme di elementi. Ad esempio, data una qualsiasi collezione, è possibile stampare a video tutti i suoi elementi in questo modo:

myTraversable.foreach( println(_) )

Oltre a questo metodo astratto, ne contiene molti concreti estremamente interessanti e ampiamente utilizzati, ad esempio map, che dà la possibilità di generare una nuova collezione applicando una certa funzione a ogni elemento. Alcuni esempi:

List(1, 2, 2, 3).map( i => i.toString )
Set("Scala", "Java").map( "I like programming in " + _)

Il metodo filter genera una nuova collezione applicando un filtro sugli elementi, ad esempio:

List(-1, 30, 40).filter(number => number > 0)

// che si può scrivere anche:

List(-1, 30, 40).filter(_ > 0)

Il metodo find cerca un elemento che soddisfa un predicato e restituisce un Option (tipo che abbiamo presentato nell'articolo precedente):

// restituisce Some(30)
List(-1, 30, 40).find(_ > 0)
 
// restituisce None
List(-1, -30).find(_ > 0)

Il metodo mkString, crea una stringa unendo gli elementi, utilizzando un prefisso, un separatore e un postfisso:

List("Scala", "Java").mkString("I like programming in ", " and ", ".")
// restituisce: "I like programming in Scala and Java."

Il trait Iterable, analogamente all'omonima interfaccia Java, contiene il metodo astratto:

xs.iterator

che restituisce un Iterator. Con gli oggetti che implementano Iterable è possibile utilizzare l'istruzione for:

for( item    println(item)

Collezioni immutabili

Le collezioni immutabili più importanti vengono importate di default da Scala, per suggerire allo sviluppatore di utilizzarle per ogni scopo, riservando l'uso delle collezioni mutabili solo quando sono veramente indispensabili. Quindi non è necessario importare il package e possiamo scrivere, direttamente nell'interprete:

List(1, 2, 3)
Map(1 -> 1, 2 -> 4, 3 -> 8, 4 -> 32)
Set(1, 2, 3, 2)

per creare, nell'ordine, una lista, una mappa e un set. In questo articolo vediamo più da vicino la lista e la mappa immutabili. Basti dire che derivando dagli stessi trait, modificare un algoritmo per lavorare con una collezione mutabile sostanzialmente significa semplicemente modificare il costruttore, ma ovviamente bisognerà prestare attenzione perche' a quel punto cesseranno di essere thread-safe.

List

La lista immutabile è una collezione generica molto potente in Scala. Un metodo di creazione di una lista l'abbiamo già visto nel listato precedente ma, essendo definito un operatore "::" si può creare anche così:

val myList = 1 :: 2 :: 3 :: Nil

Questa espressione crea una lista partendo dalla fine: prima prende la lista vuota Nil, poi aggiunge in testa 3, quindi 2, infine 1. Questo tipo di scrittura è molto comodo quando si lavora con il pattern matching, infatti è possibile scrivere espressioni di questo tipo:

myList match {
   case a :: b :: c :: Nil => a + b + c
   case _ => 0
}

Essa restituisce la somma dei tre elementi se la lista è composta appunto da tre elementi, altrimenti restituisce zero.

Una lista si può creare anche concatenando due liste, in questo modo:

val myUnionList = List(4, 5, 6) ::: myList
assert( myUnionList == List(4, 5, 6, 1, 2, 3) )

La funzione assert lancia un'eccezione se la condizione non è vera. La useremo per mostrare i risultati delle operazioni. Ovviamente ognuna di queste operazioni si traduce nella creazione di una nuova lista immutabile, e ciò vale anche per l'eliminazione di uno o più elementi, l'inversione della lista e l'ordinamento:

val cappedTo5 = myUnionList.remove(_ > 5)
assert( cappedTo5 == List(4, 5, 1, 2, 3) )
 
val reversed = myUnionList.reverse
assert( reversed == List(3, 2, 1, 6, 5, 4) )
 
val sorted = myUnionList.sort( _ < _)
assert( sorted == List(1, 2, 3, 4, 5, 6) )

Quest'ultima espressione ordina la lista utilizzando l'ordinamento classico degli interi (indicato con la funzione anonima tra parentesi); se volessimo ordinare la lista in un altro modo è sufficiente utilizzare un'altra funzione di ordinamento:

val exoticSorted = myUnionList.sort( scala.math.pow(2, _) < _)

Per ottenere l'n-esimo elemento della lista è sufficiente specificare l'indice tra parentesi:

val third = myUnionList(2)
assert(third == 6)

Questo perche' è stata introdotta, nella classe List, una funzione apply(Int). Quando il compilatore trova che una classe o un oggetto hanno una funzione con questo nome, permette di utilizzare l'oggetto come se fosse una funzione. Ovviamente, se l'indice passato supera il numero di elementi nella lista, verrà generato un IndexOutOfBoundsException.

Una mappa può anche essere mappata in un'altra, anche di un altro tipo:

val mappedList = myList map(n => n*n)
assert( mappedList == List(1, 4, 9 ) )
 
val convertedList = myList map(_.toString)
assert( convertedList == List("1", "2", "3" ) )

Un aspetto interessante è che si può lavorare anche con liste di funzioni, ad esempio:

val functions = myList map(n => n + (_:Int))

Ora sumList contiene una lista di funzioni (chiusure), ognuna delle quali aggiunge un certo valore al valore d'ingresso, e possono essere applicate; ad esempio:

val appliedTo10 = functions map(f => f(10))
assert(appliedTo10 == List(11, 12, 13))

applica ognuna delle funzioni al valore 10 e restituisce una lista con i risultati. Ma le funzioni si possono anche comporre, per ottenere un'altra lista di funzioni:

val composedFunctions = functions map(f => f(_:Int) * 2)

Ora composedFunctions contiene una lista di funzioni ottenute componendo le funzioni presenti in functions con la moltiplicazione per 2.

Tuple

A questo punto è necessario introdurre questo tipo di dato, molto utilizzato nei linguaggi funzionali. Una tupla è un tipo composto da un insieme ordinato e tipizzato di elementi. In Scala, si possono creare tuple nel seguente modo:

val duple = (4, "quattro")
val triple = (1, "quattro", "IV")
val tuple4 = (1, "quattro", "IV", '4')

e così via. Una tupla di due elementi può essere anche creata così:

val duple = 4 -> "quattro"

Ovviamente è possibile referenziare qualsiasi elemento della tupla:

val number4 = duple._1
val stringQuattro = triple._2
val stringQuatuor = tuple4._3
val char4 = tuple4._4

Una lista può lavorare con le tuple in vari modi, ad esempio, con il metodo map:

val myTupledList = myList map (n => (n, n*n))
assert( myTupledList == List((1, 1), (2, 4), (3, 9)) )

Esso genera una lista di tuple di due elementi: il primo corrisponde all'elemento della lista originaria, il secondo il suo quadrato. Ma si può anche creare a partire da due liste, che devono avere la stessa lunghezza:

val zippedLists = List(1, 2, 3) zip List(1, 4, 9)
assert( myTupledList == zippedLists )

Una funzione molto usata è zipWithIndex che crea una lista di tuple in cui il secondo elemento è l'indice della lista:

val zippedWithIndex = List("uno", "due", "tre") zipWithIndex
assert( zippedWithIndex == List(("uno",0), ("due",1), ("tre",2)) )

Molto comodo per formattare delle stringhe aggregando una lista, ad esempio:

List("uno", "due", "tre").zipWithIndex map(e => "Elemento " + e._2 + " = " + e._1) mkString(", ")

Questa espressione può essere resa più leggibile utilizzando l'espressione for  (che vediamo più avanti):

val strings = for(
       (n, i)        ) yield "Elemento n. " + i + " = "  + n
strings mkString ", "

Essa, dapprima crea una lista di stringhe, ottenuta iterando su ogni tupla (valore, indice), quindi, con il metodo mkString, le concatena con il separatore dato.

Map

Una mappa è una collezione di tuple con una chiave unica. Lavorare con le mappe in Scala, oltre ad essere molto semplice, porta a codice molto espressivo. Ad esempio, per creare una mappa:

val numbers = Map(1 -> "uno", 2 -> "due", 3 -> "tre", 4 -> "quattro")

Crediamo che non sia necessario spiegare cosa fa questo codice. Anche qui, ogni aggiunta, rimozione, trasformazione si traduce nella creazione di una nuova mappa, quindi le seguenti sono espressioni senza side-effect, ossia non modificano la mappa di partenza:

numbers + (5 -> "cinque")
Map(5 -> "cinque", 6 -> "sei") ++ numbers
numbers - 3

La lettura della mappa può essere fatta in vari modi. La ricerca di una singola chiave, ad esempio:

numbers(3)

Ma questo metodo lancia un'eccezione se non trova la chiave. In modo più sicuro si può utilizzare il metodo get che ritorna un Option:

numbers get 3
numbers get 9

Il primo restituisce Some(3), mentre il secondo None, perche' 9 non è presente nella mappa. Altrimenti, se vogliamo un valore di default, in caso la chiave non esista:

numbers getOrElse (9, "molti")

Si noti che questa funzione ci risparmia un'espressione di pattern-matching:

numbers get 9 match {
   case Some(a) => a
   case None => "molti"
}

Si può lavorare con le mappe anche effettuando filtraggi:

numbers filter ( _._1 > 2)
numbers map (t => t._1 -> t._2.length)

ricordandoci che la mappa è una collezione di tuple. Per lavorare in modo più efficiente, ed espressivo, si possono utilizzare le funzioni ad hoc:

numbers filterKeys ( _ > 2 )
numbers mapValues ( _.length )

Conversione a collezioni Java

Se utilizziamo Scala all'interno di un progetto Java (nel precedente articolo abbiamo visto che è possibilissimo) o se stiamo utilizzando con Scala un libreria Java esistente che accetta come input o restituisce delle collezioni Java, potremmo chiederci come lavorare con esse. La libreria di Scala ci viene incontro con un object contenente un numero di conversioni implicite molto utili. Per utilizzarle, è necessario importarle:

import collection.JavaConversions._

Questo codice importa non un package ma il contenuto di un oggetto.Con Scala, infatti, si può importare il contenuto di un oggetto e richiamare le funzioni lì contenute senza dover specificare lo spazio dei nomi. Importare un oggetto, inoltre, è l'unico modo per attivare le conversioni implicite lì contenute: questo meccanismo permette di controllare quali conversioni implicite si stanno usando altrimenti si potrebbe avere una degenerazione incontrollabile (chi ha lavorato in C++ ha sicuramente un'idea delle conseguenze). A questo punto, basta utilizzare le conversioni:

var jList : java.util.List[Int] = myList
var jMap : java.util.Map[Int, String] = numbers

E sono pronte per essere usate in Java.

Espressioni con for

L'espressione for è molto potenziata in Scala, rispetto agli altri linguaggi. L'uso più semplice è ovviamente quello di iterare su un insieme di elementi:

for (i

 

La classe Range rappresenta un intervallo di elementi. L'espressione precedente itera sull'intervallo da 1 a 3, inclusivo, stampando gli elementi. Grazie alle conversioni implicite, ci viene fornito il metodo to che ci permette di abbreviare:

for (i

Naturalmente, con il codice seguente avremmo ottenuto lo stesso risultato:

for (i

Una caratteristica interessante di for è che è possibile combinare i cicli annidati. Ad esempio:

for (i        for(j              println(i * j)

è del tutto identico a:

for( i

Ora, questo listato è in stile ancora molto imperativo: produce un side-effect una volta e non è più riutilizzabile. Un modo migliore di conservare la sequenza dei prodotti e stamparla sarebbe:

val prods = for( i prods foreach (println(_))

Ora prods contiene una sequenza su cui si può iterare quando serve. All'interno dell'espressione si può anche filtrare:

for(
       i        if i.length > 2
) yield i

crea una sequenza ottenuta filtrando gli elementi con lunghezza maggiore di due.

Ma la maggiore potenza di for si evidenzia quando si usa il pattern-matching  al suo interno. Come esempio riprendiamo una mappa, numbers, già usata in precedenza:

val strNumber2 = for( (2, strNumber) assert( strNumber2 == List("due") )

Questo codice itera su ogni tupla della mappa e restituisce solo gli elementi che corrispondo al pattern (2, strNumber), ossia quelle tuple che hanno 2 come chiave. Quindi, se non ne trova, restituisce una lista vuota, altrimenti una lista di un solo elemento. Filtro e pattern-matching si possono combinare:

val oddNumbers = for( (i, strNumber) assert( oddNumbers == List("uno", "tre") )

che restituisce le rappresentazioni stringa dei numeri dispari contenuti nella mappa.

Vediamo un ultimo esempio; supponiamo di voler filtrare una collezione di Option[Int] (che potrebbe provenire da un'elaborazione precedente, per esempio una ricerca):

val someNumbers = List( Some(4), None, Some(5), Some(220), None, Some(510))
 
// Numeri minori di 100
val littleNumbers = for( Some(i) assert( littleNumbers == List(4, 5) )

flatten e flatMap

Ora, l'ultima espressione qui sopra poteva essere scritta in modo molto più compatto:

assert( littleNumbers == (someNumbers.flatten filter (_ < 100)) )

Questo perche' la funzione flatten delle collezioni di Scala "appiattisce" una collezione di collezioni. E Option è un container anch'esso: infatti è una collezione vuota o una collezione di un solo elemento. Altri esempi:

assert( someNumbers.flatten == List(4, 5, 220, 510) )
assert( List( List(1, 2), List(3, 4) ).flatten == List(1, 2, 3, 4) )
assert( List("uno", "due").flatten == List('u', 'n', 'o', 'd', 'u', 'e') )

L'ultimo si spiega considerando una stringa come collezione di caratteri.

La funzione flatten può essere vista come una specializzazione della funzione flatMap. Questa, infatti, prende come argomento una funzione con cui mappa la collezione, quindi concatena i risultati. Ad esempio:

val flatMapExample = List(1, 2, 3) flatMap (1 to _)
assert( flatMapExample == List(1, 1, 2, 1, 2, 3) )

La funzione flatMap è imparentata con l'espressione for per via del concetto di monade, un'entità appartenente alla teoria delle categorie, molto importante nei linguaggi puramente funzionali. Per ora ci basti sapere che l'espressione precedente poteva essere scritta in modo equivalente:

val forExample = for( i assert( flatMapExample == forExample )

Ossia, combinando due indici in un for, si ottiene lo stesso effetto di invocare flatMap.

Conclusioni

A questo punto il lettore pratico di Java, ma che non conosceva Scala, dovrebbe essere al livello "A1 - Beginning application programmer", il che è indicativo delle potenzialità di questo linguaggio. Per fare pratica consigliamo fortemente l'utilizzo dell'interprete, perche' consente di vedere immediatamente i risultati dei propri algoritmi. Tutti gli esempi che abbiamo visto possono essere eseguiti con successo sia nell'interprete Scala, sia uno dei vari ambienti di sviluppo per Scala. Ma consigliamo di partire dall'interprete proprio perche' passare a un IDE, a quel punto, è molto semplice (praticamente ogni IDE Java ha un proprio plug-in per Scala).

Come riferimento per le collezioni rimandiamo di nuovo alla documentazione raggiungibile dal sito ufficiale [2], che contiene anche un'ottima introduzione alle API [3]. Per approfondire, consigliamo nuovamente il libro[4] di Odersky e Spoon, perche' vengono spiegati anche i motivi e i meccanismi che sono dietro alla gestione delle collezioni immutabili. Nel prossimo articolo vedremo alcuni costrutti e funzioni ancora più potenti e il supporto per XML in Scala.

Riferimenti

[1] Scala levels: beginner to expert, application programmer to library designer

http://www.scala-lang.org/node/8610

 

[2] The Scala Programming Language

http://www.scala-lang.org/

 

[3] Martin Odersky, Lex Spoon, The Scala 2.8 Collections API, Settembre 2010

http://www.scala-lang.org/docu/files/collections-api/collections.html

 

[4] Martin Odersky, Lex Spoon, Bill Venners, "Programming in Scala, Second Edition. A comprehensive step-by-step guide", Artima

Condividi

Pubblicato nel numero
164 luglio 2011
Onofrio Panzarino, ingegnere elettronico, lavora ad Ancona come software architect, per Wolters Kluwer Italia. Sviluppatore con esperienza in vari linguaggi e piattaforme, soprattutto web-oriented, è molto interessato a soluzioni scalabili e a linguaggi di programmazione funzionali. È speaker in JUG Marche su argomenti correlati a Scala e database NoSQL.
Articoli nella stessa serie
Ti potrebbe interessare anche