Introduzione a Scala

III parte: Costrutti avanzatidi

Nei precedenti articoli abbiamo visto i concetti più elementari di Scala e i vantaggi che si traggono dall'utilizzare concetti di programmazione funzionale, per esempio nel trattare le collezioni. Stavolta approfondiamo alcuni concetti come il pattern matching e la ricorsione, molto importanti in Scala, ma vediamo anche le potenzialità dei trait, che permettono di incapsulare a grana molto fine le logiche di programma e diamo uno sguardo al supporto che Scala fornisce per XML, indicativo anche di quello che un DSL scritto in Scala può fare. Infine vedremo un concetto molto importante per la programmazione concorrente: gli Actor.

I concetti di cui ci occupiamo in questo terzo e ultimo articolo di introduzione a Scala rappresentano il livello "Intermediate application programmer", secondo la roadmap [1] pubblicata da Martin Odersky all'inizio di quest'anno. Si tratta pertanto di un piccolo passo avanti rispetto alle semplici basi di cui abbiamo parlato nei due articoli precedenti. Ci pare giusto però aggiungere qualche costrutto avanzato che magari risulterà un po' ostico a chi mastica poco o nulla di Scala, ma che potrà invece dare un'idea delle potenzialità del linguaggio ai lettori più interessati.

Pattern Matching

Il pattern matching, che abbiamo intravisto nei precedenti articoli, somiglia al costrutto switch di Java; la differenza è che molto più potente. Ad esempio, la funzione

def lengthOf(any : Any) = any match {
       case n:Int => n
       case l:String => l.length
       case (a:Int, _) => a + 1
       case _ => 0
}

costituisce un calcolo generico (bizzarro, per la verità, ma serve solo come esempio) della lunghezza di un oggetto qualsiasi (Any, corrispondente ad Object in Java): nel primo caso, se è un intero n, restituisce proprio quel valore; se è una stringa restituisce la lunghezza della stringa; se è una dupla in cui il primo elemento è un intero, restituisce il primo incrementato di uno; in qualsiasi altro caso restituisce zero. I segnaposto indicati con _ indicano che ciò che vi si trova non è importante ai fini del matching. Se la copiamo nell'interprete otteniamo il risultato visualizzato in figura 1.

 

 

Figura 1 - Interprete scala: pattern matching.

 

Ovviamente gli usi più interessanti sono altri, ma prima è necessaria una premessa. Per rendere più agevole il pattern matching, Scala ha il concetto di classi case, ossia normali classi che hanno già definita una serie di metodi (toString, equals, costruttore nel companion object, ...):

case class Person(name : String, surname : String)
 
assert( Person("Onofrio", "Panzarino") == Person("Onofrio", "Panzarino") )
assert( Person("Onofrio", "Panzarino").toString == "Person(Onofrio,Panzarino)" )

Un esempio classico in cui vengono usate classi case e pattern matching è il parser ricorsivo. Le classi:

abstract class Expr
case class Number(n : Double) extends Expr
case class Add(a : Expr, b : Expr) extends Expr
case class Mul(a : Expr, b : Expr) extends Expr
case class Log10(a : Expr) extends Expr

potrebbero rappresentare le possibili espressioni valutabili di un parser. In Java, per valutarle, scriveremmo un Visitor [2]. Con Scala, invece, abbiamo il pattern matching:

import scala.math._
 
def evaluate(expr : Expr) : Double = expr match {
       case Number(d) => d
       case Add(expr1, expr2) => evaluate(expr1) + evaluate(expr2)
       case Mul(expr1, expr2) => evaluate(expr1) * evaluate(expr2)
       case Log10(expr) => log10(evaluate(expr))
}

Questa scrittura, così compatta, rappresenta una funzione ricorsiva che per ogni tipo di espressione descrive come deve essere valutata. Quindi questa asserzione è valida:

assert( evaluate(Log10( Mul(Number(5), Add(Number(18), Number(2))))) == 2.0 )

Extractor Objects

Il meccanismo alla base del pattern matching è l'estrazione. Scrivendo un extractor object, è possibile guidare il pattern matching. Ad esempio, per completare il nostro parser, vogliamo estrarre l'espressione da una stringa inserita dall'utente. Per brevità riportiamo soltanto i casi del logaritmo e del numero (gli altri sono simili):

object Expr {
 
       def unapply(s : String) : Option[Expr] = {
             val z :Seq[Char] = s
             z match {
                    case Seq('L', 'o', 'g', '1', '0', '(', rest @ _*) =>
                           rest.take(rest.size-1).mkString match {
                                  case Expr(inner) => Some( Log10(inner) )
                                  case _ => None
                    }
                    case _ => try {
                                  Some( Number(s.toDouble) )
                    } catch {
                           case _ : java.lang.NumberFormatException => None
                    }
             }
       }
}

La funzione unapply definisce l'estrattore (deve chiamarsi così). Nella prima riga converte la stringa in sequenza di caratteri per poter applicare ancora il pattern matching e verificare se la sequenza comincia con "Log10(": se sì, ricorsivamente cerca un'espressione nei caratteri restanti. Se no, cerca di convertire la stringa in un numero. In questo modo, vediamo come funziona un estrattore: esso ritorna un'Option che è None se non è riuscito a estrarre quello che voleva dall'oggetto in ingresso (in questo caso una stringa).

L'estrattore, infatti, ci permette di scrivere:

def eval(s: String) = s match {
       case Expr(expr) => evaluate(expr)
       case _ => sys.error("Espressione non valida")
}
 
assert( eval("2.3") == 2.3 )     
assert( eval("Log10(100)") == 2.0 )
assert( eval("Log10(Log10(1e+10))") == 1.0 )

E il nostro parser è fatto: vediamo che la funzione eval tenta il pattern matching tra una stringa, s, e un'oggetto Expr, utilizzando l'estrattore sopra definito: se riesce lo valuta altrimenti causa un'eccezione. In conclusione, con un estrattore è possibile estendere il pattern matching con una nuova regola.

Tail Recursion

La ricorsione è uno degli approcci preferiti dai linguaggi funzionali, per il fatto che si sposa perfettamente con l'immutabilità degli oggetti. Ad esempio, consideriamo un ciclo che riempie una lista con numeri casuali:

def fillList(count : Int) = {
       var l = List[Double]()
       for(i              l = scala.math.random :: l
       l
}

Questa funzione inizia con una lista vuota, e per count volte genera un numero casuale che aggiunge alla lista. Usando una lista immutabile è necessario riassegnare l ad ogni passo (per questo c'è var). Quindi, o si usa una lista mutabile o una variabile riassegnabile: in ogni caso l'immutabilità di oggetti e variabili non può essere garantita.

In verità, tramite la parola chiave yield, che abbiamo visto nel precedente articolo, ciò è possibile:

def fillList2(count : Int) = ( for(i

 

ma per ora non lo consideriamo. Se trasformiamo fillList in ricorsivo, abbiamo due possibilità. La prima possibilità è la Head recursion: ad ogni chiamata la funzione ricorsiva effettua prima la ricorsione, poi genera un risultato:

def fillListRec(count : Int) : List[Double] =
       if(count == 0) Nil else scala.math.random :: fillListRec(count - 1)

Il codice lo dimostra bene: prima viene invocata fillListRec, poi viene calcolato il valore di ritorno come la concatenazione del nuovo numero casuale con la lista restituita dalla chiamata ricorsiva. Come si vede, non abbiamo bisogno ne' di variabili riassegnabili ne' di collezioni mutabili. Il problema, ben noto, è nelle performance e nell'occupazione di memoria, perche' lo stato della ricorsione, necessario ad ogni passo susseguente, viene lasciato nello stack e in memoria. L'ideale sarebbe se, ad ogni passo, lo stato della ricorsione non servisse più e fosse possibile eliminarlo (ovviamente con la collaborazione del compilatore):  soluzione perfetta, ma per ottenerla occorre fare in modo che la chiamata ricorsiva sia l'ultima operazione. Questo secondo tipo di ricorsione è detto Tail recursion:

def fillListTailRec(count : Int) : List[Double] = {
       def fillListTail(count : Int, l : List[Double]) : List[Double] =
             if (count == 0) l else fillListTail(count-1, scala.math.random :: l)
       fillListTail(count, Nil)
}

Questa funzione definisce al suo interno la funzione ricorsiva che effettua il lavoro, la quale prende come argomenti il numero di elementi da calcolare e la lista degli elementi già calcolati. Al primo passo la lista è vuota (Nil). Come si vede, la chiamata ricorsiva è l'ultima operazione effettuata dalla funzione, dunque non è necessario mantenere le informazioni sul passo corrente: si risparmia memoria, tempo e stack. Infatti, se inseriamo il tutto nell'interprete, otteniamo ciò che è mostrato in figura 2:

Figura 2 - Interprete Scala: Tail recursion.

 

È molto facile ottenere una StackOverflowException con la Head recursion. In conclusione, con la Tail recursion si azzerano (o quasi) gli svantaggi che tradizionalmente si imputano alla ricorsione, cioè l'overhead dello stack-frame; naturalmente il compilatore collabora, perche', individuando una tail recursion, azzera l'allocazione di ulteriori stack frame: possiamo quindi utilizzare sempre la ricorsione e salvare ancora l'immutabilità dei dati e delle variabili.

Trait composition

Nel precedente articolo abbiamo dato un accenno ai trait. Un trait, si è detto, è grosso modo equivalente a un'interfaccia, ma può contenere delle implementazioni. Non è una classe astratta: infatti Scala prevede anche le classi astratte che corrispondono esattamente alle classi astratte in Java.

trait Printable {
       def print() : Unit
      
       def printWithHead(head: String) = {
             println(head)
             print()
       }
}

Questo trait di esempio definisce un metodo astratto, print, e un metodo concreto che stampa un'intestazione passata come parametro prima di chiamare il metodo astratto, che ovviamente sarà definito dalle classe che la implementa. Ma il vantaggio di usare i trait è che è possibile comporli per arricchire gli oggetti. Consideriamo un trait che implementa il precedente, stampando la stringa restituita da toString.

trait StringPrintable extends Printable {
       def print() = println(toString)
}

Consideriamo anche un trait che arricchisce Printable stampando anche un footer:

trait Footer extends Printable {
       def printWithFooter(footer: String) = {
             print()
             println(footer)
       }
}

Allora, se volessimo una classe che è stampabile utilizzando toString ma con la possibilità di stampare anche un footer, sarebbe sufficiente:

class Person(name: String, surname: String)
              extends StringPrintable with Footer {
       override def toString = surname + " " + name
}

Come si vede, abbiamo un'ereditarietà multipla con la differenza che non c'è possibilità di ambiguità: stiamo estendendo StringPrintable con un Footer. Ma si può fare molto di più.

Stackable modification

Un modo con cui gli oggetti aggiungono funzionalità ad altri oggetti senza modificarli è il pattern Decorator [2], indubbiamente uno dei più potenti pattern della programmazione orientata agli oggetti. Un concetto simile sarebbe utile se fosse applicabile alle classi oltre che agli oggetti; in tal senso consideriamo il classico esempio degli input stream in Java:

new BufferedInputStream(new FileInputStream("file.txt"));

In questo caso, se volessimo incapsulare la creazione di questo oggetto, dovremmo usare una Factory o un Builder[2], semplicemente perche' il pattern Decorator funziona a livello di istanza. Le stackable modifications permettono di estendere questo concetto a livello di classi a compile-time. Come semplice esempio riportiamo un banale motore di reportistica che deve essere in grado di stampare delle stringhe, la cui classe di base potrebbe essere:

trait AWriter {
       def write(s : String) : Unit
}

e un'implementazione elementare:

class ConsoleWriter extends AWriter {
       def write(s : String) = println(s)
}

Ci piacerebbe poter aggiungere a piacere la possibilità di stampare la stringa tra due tag XML. Per fare ciò, potremmo definire un decorator di AWriter che stampa il tag prima e dopo la chiamata a write:

class TagWriterDecorator(inner: AWriter) extends AWriter {
       def write(s: String) = inner.write("

" + s + "

")
}

E funzionerebbe:

val writer = new TagWriterDecorator(new ConsoleWriter)
writer.write("test")

Ma la combinazione avverrebbe a livello d'istanza, a run-time. Con le stackable modification, invece avremmo una composizione a livello di classe:

trait TagWriter extends AWriter {
       abstract override def write(s : String) = super.write("

" + s + "

")
}
 
val writer = new ConsoleWriter with TagWriter
writer.write("test")
 
// oppure
class MyWriter extends ConsoleWriter with TagWriter
 
// oppure ancora (singleton)
object DefaultWriter extends ConsoleWriter with TagWriter
DefaultWriter.write("test")

Ciò che è immediatamente evidente è la concatenazione di abstract override. Questa è permessa solo in un trait e serve proprio per dare il supporto alle stackable modification.

Ancora, se volessimo un altro writer in grado, ad esempio, di enfatizzare il testo, potremmo combinarlo con i precedenti:

trait EmphasizedWriter extends AWriter {
       abstract override def write(s : String) {
             val text = s.toList.map(_ match {
                    case '!' => "!!"
                    case a   => "_" + a
             }).mkString
             super.write(text)
       }
}
 
val writer = new ConsoleWriter with TagWriter with EmphasizedWriter
writer.write("Hello world!")

Che eseguito nell'interprete darebbe il risultato mostrato in Figura 3.

Figura 3 - Interprete Scala: Stackable modification.

 

Questi meccanismi sono usati spesso nelle librerie perche' permettono ai client di comporre le funzionalità incapsulate in trait a grana molto fine per ottenere oggetti molto personalizzabili. Quindi si può dire che l'utilizzo dei trait non solo non viola i principi dell'OOP ma anzi rinforza i principi SOLID [3], ed in particolare il principio di Single Responsibility e Open/Closed.

XML

Il programmatore Java che lavora con XML deve usare o API molto verbose e come DOM e SAX oppure librerie come JAXB che richiedono l'uso di annotazioni oppure di configurazioni. Con Scala è più semplice e intuitivo (ciò non significa che quelle librerie non siano valide, tutt'altro: rimangono fondamentali in certi contesti).

Consideriamo un classico problema di serializzazione e de-serializzazione in XML, ossia vogliamo dare alla nostra classe Meeting (contenente descrizione e argomenti del meeting) la possibilità di serializzarsi e deserializzarsi:

class Meeting(val description : String, val subjects : List[String]) {
       def toXml = 
                    { subjects.map( s => {s} ) }
             
       // per l'output su console
       override def toString = "Il meeting " + description + " ha questi argomenti: " +
                           subjects.mkString(", ")
}
 
object Meeting {
       def fromXml(node : scala.xml.Node) : Meeting =
       new Meeting( (node  "@description").text, (node \ "subject").toList.map(s => s.text) )
}

È subito chiaro ciò che fa la funzione toXml: essa crea un XML appunto che contiene un tag meeting contente due attributi che vengono impostati con i valori ottenuti dentro le parentesi graffe. Inoltre, per ogni argomento del meeting viene creato un elemento subject. La bellezza di questo metodo è che i tag XML e gli attributi sono subito evidenti nel codice e possono essere manipolati con semplicità e soprattutto vengono validati a compile-time: se infatti dimenticassimo o sbagliassimo nello scrivere un tag di chiusura, il compilatore darebbe un errore. Sottolineiamo che non si tratta di stringhe ma di oggetti: ogni elemento è un oggetto di tipo scala.xml.Node.

Il metodo toString è stato scritto solo per mostrare l'output nella console. Il companion object Meeting contiene un metodo per deserializzare un nodo XML in un oggetto di tipo Meeting. L'estrazione delle informazioni avviene utilizzando XPath. Proviamo ad utilizzare queste funzioni:

val scalaMeeting = new Meeting( "Scala meeting", List("Pattern matching","XML") )
scalaMeeting.toXml
 
val restMeeting = 
                    MongoDB
                    Cassandra
                    Neo4j
             
val deserialized = Meeting.fromXml(restMeeting)

Un fatto degno di nota è che possiamo fare unit testing:

assert(scalaMeeting.toXml == 
             Pattern matchingXML
             )

Actors

Gli actor meritano una trattazione per la loro importanza nella programmazione concorrente. È noto infatti che l'approccio basato su memoria condivisa è tutt'altro che sicuro, inoltre porta a codice poco riutilizzabile e spesso poco scalabile. Scala, pur supportando anche questo metodo, incoraggia il modello alternativo basato su attori e messaggi. Fondamentalmente un attore è un'entità in grado di:

  • effettuare computazione in risposta alla ricezione di messaggi;
  • inviare messaggi ad altri attori;
  • creare altri attori.

Per mostrare come si usano gli actor, utilizziamo un classico problema accademico di gestione della concorrenza, il problema del barbiere, che esemplifica la gestione del tempo di elaborazione di un algoritmo pesante. Il problema è questo: se il barbiere non ha clienti, dorme; se un cliente entra dal barbiere ci sono tre possibilità: se il barbiere è libero, il cliente viene servito immediatamente, se il barbiere è occupato allora o ci sono posti a sedere quindi il cliente attende oppure tutti i posti di attesa sono occupati e il cliente se ne va. Ci sono diversi modi per risolvere questo problema con gli actor: qui ne mostriamo uno.

import scala.actors._
import scala.actors.Actor._
import scala.collection.mutable._
import scala.math._
 
def veryLongOp(seed : Int) = (for(i  
case class Ack
case class Client(name : String) {
       def go() = {
             val v = veryLongOp (4000000)
             println("Cliente " + name + " servito! ")
       }
}
 
val saletta = actor {
       val queue = new Queue[Client]
       var canSend = true
 
       val barbiere = actor {
             loop {
                    receive {
                           case c: Client => {
                                  c.go()
                                  reply(Ack)
                           }
                    }
             }
       }
      
       loop {
             receive {
                    case Ack => if(! queue.isEmpty)
                    barbiere ! queue.dequeue
                    else {
                           canSend = true
                           println("Barbiere libero")
                    }
                    case c:Client => if(canSend) {
                           println("Cliente " + c.name + " dal barbiere")
                           canSend = false
                           barbiere ! c
                    }
                    else if(queue.length < 4) {
                           queue.enqueue(c)
                           println("Cliente " + c.name + " accodato")
                    }
                    else
                    println("Cliente " + c.name + " rifiutato")
             }
       }
}
 
val tester = actor {
       for(i              val v = veryLongOp (1000000)
             println("Nuovo cliente ")
             saletta ! Client(i.toString)
       }
}

Abbiamo suddiviso il problema in due actor, il primo è il barbiere che esegue il lavoro sul cliente mentre il secondo è la saletta di attesa che tiene in coda i clienti e li invia al barbiere quando questi è libero.

La prima funzione (veryLongOp) rappresenta un'operazione computazionalmente importante, ci serve solo per rendere più dinamico l'esempio. La classe Ack è il messaggio di conferma che il barbiere invia quando ha finito di servire un cliente, rappresentato dalla classe Client. saletta è l'actor principale che coordina il tutto: contiene la coda dei clienti e il riferimento all'actor barbiere che serve il cliente; quando entra un cliente, dalla saletta passa direttamente dal barbiere se questi è libero, altrimenti viene accodato se la coda è più corta di 4 persone; se la coda è superiore alle 4 persone, il cliente viene rifiutato. Quando il barbiere invia un Ack, dalla saletta viene inviato un altro cliente se la coda non è vuota. L'ultimo actor, tester, invia dieci clienti in tempi casuali. Aggiungiamo che il modo con cui abbiamo creato un actor non è l'unico: è infatti possibile creare una actor come classe, estendendo la classe Actor.

Copiando il testo nell'interprete otteniamo quanto illustrato in figura 4.

Figura 4 - Interprete scala: esempio con Actor.

 

Dall'output si vede che il cliente n. 1 è stato mandato subito dal barbiere che non era occupato, mentre i clienti seguenti 2, 3, 4 e 5 sono stati messi in coda; nel frattempo il primo è stato servito dunque il cliente 6 è stato messo in coda mentre gli altri sono stati rifiutati. In seguito, uno alla volta i clienti in coda sono stati serviti. Eseguendo più volte questo listato si avranno diversi output.

Conclusioni

In questo ultimo articolo abbiamo trattato gli argomenti che Odersky indica come di competenza di un "intermediate application programmer" [1] più il concetto di Actor. A questo punto, se ha seguito con applicazione la serie, il lettore dovrebbe avere (ci auguriamo!) delle buone motivazioni per studiare approfonditamente Scala, magari con l'ausilio di un buon libro come quello di Odersky [4]. Per quanto riguarda gli Actor, segnaliamo che il progetto Akka [10] implementa una piattaforma avanzata di programmazione concorrente attraverso gli Actor (in Scala ma anche in Java), per alcuni versi migliore della libreria standard di Scala.

Nel primo articolo di questa serie avevo riportato alcuni blog interessanti su Scala: li riporto nuovamente tra i riferimenti in coda all'articolo e aggiungo il blog di Jim McBeath[8] e quello di Tony Morris [9]. Tutti questi trattano argomenti inerenti Scala o tecnologie affini in modo molto interessante e contengono contributi innovativi. Sul mio blog [11], in italiano, si parla anche di tematiche inerenti lo sviluppo con Scala (soprattutto) ma anche di Java e altri linguaggi, nonche' di esempi e progetti inerenti il mondo Scala:. A conclusione di questa serie, desidero infine ringraziare sia i lettori che hanno accolto con entusiasmo gli articoli sia la redazione che ha seguito costantemente questo lavoro e ha mostrato il massimo interesse per le tematiche trattate.

 

Riferimenti

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

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

 

[2] Erich Gamma, Richard Helm, Ralph Johnson e John Vlissides, "Design Patterns: Elements of Reusable Object-Oriented Software", ISBN 88-7192-150-X.

 

[3] SOLID (object-oriented design)

http://en.wikipedia.org/wiki/SOLID_(object-oriented_design)

 

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

 

[5] Code Commit

http://www.codecommit.com

 

[6] James Iry Blog

http://james-iry.blogspot.com

 

[7] Ruminations of a Programmer

http://debasishg.blogspot.com

 

[8] Jim McBeath Blog

http://jim-mcbeath.blogspot.com/

 

[9] Tony Morris Blog

http://blog.tmorris.net/

 

[10] Akka

http://akka.io/

 

[11] onof blog

http://onof80.blogspot.com/

Condividi

Pubblicato nel numero
165 settembre 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