Programmazione 8 commenti

Il linguaggio Scala

di Franco Lombardo

Da tempo si sente l'esigenza di superare Java per rendere la programmazione più flessibile, agile e, se possibile, vicina al linguaggio naturale. Molti hanno individuato la ragione della rigidità di Java nella sua tipizzazione statica e si sono pertanto rivolti a linguaggi dinamici. Tali strumenti di programmazione, però, suscitano numerosi dubbi e perplessità, in chi, come me, proviene dal mondo della tipizzazione statica.

È per questo che il nuovo linguaggio Scala, tipizzato staticamente e libero dai tanti problemi di Java, non manca di affascinarmi. Realizzato a partire dal 2001 dal Politecnico di Losanna sotto la guida di Martin Odersky, uno degli sviluppatori del compilatore Java, è stato rilasciato pubblicamente per la prima volta nel 2004 in due versioni, una per la piattaforma Java ed un'altra per .NET, ed ha subito un sostanziale miglioramento nel corso del 2006. Vediamone alcune delle caratteristiche principali.

Interoperabilità con Java

La sintassi di base del linguaggio è piuttosto simile a quella di Java. È vero che Scala è sintatticamente molto ricco (per alcuni troppo) ed è possibile creare con esso delle espressioni alquanto criptiche, ma un programmatore disciplinato ha tutti gli strumenti per scrivere codice leggibile ed espressivo.

Compilare un programma Scala significa generare dei normalissimi file .class che contengono bytecode interpretabile da una qualsiasi JVM 1.4 o superiore. Questi file possono essere eseguiti tramite il comando scala, che altro non è se non un file batch per lanciare l'ambiente di esecuzione Scala all'interno della JVM.

Le librerie Java possono essere importate in Scala, anzi il package java.lang viene importato di default. Inoltre da Scala si possono estendere classi o implementare interfacce Java, il che ne consente l'utilizzo all'interno dei più comuni framework. Sebbene in alcuni casi siano stati creati dei meccanismi per l'integrazione di librerie Java all'interno di linguaggi dinamici e viceversa, le soluzioni adottate non mi sembrano così lineari come quelle di Scala, che è stato disegnato sin dal principio con questo obiettivo. Ma qui, ovviamente, si tratta di sensibilità personale. Per illustrare meglio quanto appena detto, è giunta l'ora di dare spazio al protagonista inevitabile di ogni introduzione ad un linguaggio: Hello World!

import java.util.Date

object hello extends Application { 
  println("Yet Another Hello World Program")
  println("running on " + new Date())  //Classe java visibile tramite import
  println("JVM version " + System.getProperty("java.version"))  //Classe di java.lang
}

Il codice può essere compreso immediatamente da un qualsiasi programmatore Java. Notiamo solamente che defininendo un object creiamo un Singleton, ovvero una classe con una singola istanza.

Linguaggio OO e Funzionale

Scala vuole fondere in uno stesso linguaggio i paradigmi della programmazione Object Oriented e di quella funzionale. Ogni elemento del linguaggio è un oggetto, inclusi numeri e funzioni. Queste ultime, pertanto, possono venire memorizzate in variabili, essere passate come parametri, rappresentare il risultato di una chiamata di metodo, oppure essere estese tramite ereditarietà. D'altro canto, se ogni valore è un oggetto, ogni operazione è l'invocazione di un metodo. L'espressione a(i) = 5 + 4 per assegnare il risultato di una somma ad un elemento di un array viene infatti tradotta in successivi invii di messaggi: a.update(i, 5.+(4)) Dai linguaggi funzionali Scala acquisisce "closures", funzioni di ordine superiore (higher order functions), valutazione lazy dei parametri e la predilezione per le strutture dati "immutabili" o tipi di dati algebrici, come le liste. Tali strutture sono manipolabili tramite filtri, mappature e folding. Per dare un assaggio di questi concetti, vediamo il codice per risolvere il primo dei quesiti del Progetto Eulero: trovare la somma di tutti i multipli di 3 o di 5 inferiori a 1000.

object euler01 extends Application {
  println((1 until 1000)
                   .filter(n => n % 3 == 0 || n % 5 == 0)
                               .foldLeft(0)((accum, item) => accum + item))  
}

Qui la diversità da Java diviene più marcata. Spiegiamo il programma nel dettaglio. Con l'espressione 1 until 1000 si applica il metodo until all'oggetto 1 di classe Int passando come argomento 1000. Ciò genera una sequenza (qualcosa di simile ad una Collection Java) di interi. In realtà il metodo untilnon è definito nella classe Int, ma in RichInt; viene qui utilizzata una conversione impicita, che illustreremo più avanti. Dalla sequenza vengono estratti i valori multipli di 3 o 5 passando al metodo filter una funzione anonima che ottiene un booleano partendo da un'intero. La sottosequenza cosi' ottenuta viene ridotta ad un singolo valore tramite il metodo foldLeft, il quale applica la funzione passata come secondo argomento, nell'esempio la somma, in maniera cumulativa a tutti gli elementi da sinistra a destra. Il primo argomento di foldLeft è invece il valore iniziale dell'accumulatore, nel nostro caso 0.

Inferenza dei tipi

L'aspetto di Scala che maggiormente mi affascina è quello di saper declinare i costrutti più efficaci della programmazione all'interno di un linguaggio tipizzato staticamente. Si rimprovera spesso ai più diffusi linguaggi a tipizzazione statica, in particolare C++, Java e C#, di essere prolissi, percorsi da un fortissimo "rumore sintattico". Scala, prendendo esempio da linguaggi quali Haskell e ML, cerca di sciogliere questo nodo introducendo un meccanismo di inferenza dei tipi molto sofisticato. Per garantire la corretta tipizzazione non è necessario imboccare il compilatore, ma è il compilatore stesso a dedurre, nella maggior parte dei casi, le informazioni necessarie dal nostro codice. Per un esempio di tale meccanismo, si noti che nel programma per il progetto Eulero appena visto i tipi delle funzioni anonime non vengono dichiarati, ma dedotti dal compilatore. Scala dimostra anche così che espressività, concisione e tipizzazione statica non sono concetti antitetici.

Pattern matching

Il pattern matching è senza dubbio l'aspetto più controverso del linguaggio. Da un lato è sicuramente uno strumento potentissimo, sfruttabile ad esempio nella definizione di funzioni per induzione o per manipolare di documenti XML, i quali, per inciso, sono tipi "primitivi" del linguaggio:

val magazzino01 = 
  <magazzino>
    <articolo codice="nxt01" decrizione="Robot Lego NXT" esistenza="1" />  
    <articolo codice="nabas02" decrizione="Nabagstag Tag" esistenza="4" />  
  </magazzino>;

def add(magazzinoDaAggiornare: Node, nuovoArticolo:Node) = 
  magazzinoDaAggiornare match {
    case <magazzino>{ articoli @ _* }</magazzino> => 
            <magazzino>{ articoli }{ nuovoArticolo }</magazzino>
  }

val magazzinoAggiornato = 
  add(magazzino01, 
      <articolo codice="chu01" descrizione="Radio Chumby" esistenza="2" />)

D'altra parte, la stessa definizione che di esso ne dà Odersky lascia alquanto perplessi: Il pattern matching è una generalizzazione del costrutto switch del C o di Java applicato alla gerarchia delle classi. In altre parole un super switch effettuabile sul risultato di una instanceof. Qualcosa che sembrerebbe poco in linea con i principi della programmazione ad oggetti (che, ad ogni modo, non è un dogma di fede). Prima di rimandarvi a questo interessante articolo dello stesso Odersky sull'argomento, vediamo un esempio di pattern matching su una gerarchia di classi. Il pattern matching su gerarchie di classi si sposa in modo particolare con i tipi di dati algebrici, quali liste alberi e stack. Supponiamo di definire con le seguenti tre classi un albero binario di interi:

abstract class Tree
case object Empty extends Tree
case class Binary(element: int, left: Tree, right: Tree) extends Tree

Il modificatore case indica che sulla struttura della classe in via di definizione sarà possibile effettuare il pattern matching. Tramite pattern matching portremo allora definire un metodo per trovare la somma degli interi contenuti nell'albero:

def sum(t: Tree): int = t match {
  case Empty => 0
  case Binary(element, left, right) => element + sum(left) + sum(right)
}

Tipi strutturali e conversioni implicite

Ecco una delle caratteristiche pù interessanti del linguaggio. Supponiamo di avere un metodo che cancella i record di una tabella di database:

def deleteAllRows(statement: java.sql.Statement) = 
  statement.execute("DELETE FROM MYTABLE")

Dovendo scrivere un test per questo metodo, si potrebbe pensare di utilzzare come parametro non un vero oggetto JDBC, ma un mock object, per poi verificare la correttezza dell'SQL che si manda in esecuzione. A tale scopo, in Java, dovrei creare un mock che implementa i circa quaranta metodi dell'interfaccia java.sql.Statement, quando sarei interessato al solo execute. È vero che potrei utilizzare una classe proxy, ma la cosa sarebbe comunque macchinosa. Se anche volessi passare a deleteAllRows un'interfaccia contenente il solo metodo di mio interesse, essa non potrebbe venire implementata da java.sql.Statementche risiede una libreria. Mi occorrebbe una classe all'interno della quale incorporare il vero statement. Scala ha una soluzione più elegante al problema. Possiamo riscrivere deleteAllRows così:

def deleteAllRows(statement: {def execute(sql: String): Boolean}) = 
  statement.execute("DELETE FROM MYTABLE")

Il che significa che il parametro statement deve avere una struttura che comprenda il metodo execute. Si noti che questa dichiarazione di tipo, oltre a limitare un uso scorretto del metodo, costituisce anche una forma di documentazione per chi lo dovrà utilizzare. La documentazione automatica del codice è uno dei punti di forza dei linguaggi a tipizzazione statica rispetto a quelli tipizzati a tempo di esecuzione. Il codice di test potrebbe essere allora di questo tipo:

def testDeleteAllRows() {
  val mockStatement = new {
    def execute(sql: String) = {
      println(sql)   //Oppure qualsiasi cosa per testare l'oggetto
      true          //Valore di ritorno di execute
    }
  }

  deleteAllRows(mockStatement)
}

Rimanendo nel nostro esempio, supponiamo ora di aver trovato una libreria che consenta l'esecuzione di chiamate SQL su un database remoto accessibile non via JDBC, ma tramite web services. Ecco come potrebbe essere definito uno statement remoto:

class RemoteStatement {
  def remoteExecute(sql: String) = {
    println(sql + " executed remotely :-)")
    true
  }
}

Se l'autore della libreria avesse utilizzato la stessa nomenclatura dell'interfaccia java.sql.Statement non avremmo dovuto fare nulla. Siccome il metodo che ci interessa è stato invece chiamato remoteExecute, dobbiamo modificare il nostro codice:

def deleteAllRows[T <% {def execute(sql: String): Boolean}](statement: T) = 
  statement.execute("DELETE FROM MYTABLE")

La notazione tra parentesi quadre indica, analogamente ai generics di Java, che il metodo è parametrico nel tipo T. Su tale tipo si introduce però un vincolo, detto view bound e denotato da <%, che richiede che esso sia convertibile in un tipo contenente il metodo execute. Si noti che, dopo questa modifica, tutti i client del metodo, sia quelli che utilizzano java.sql.Statement che quelli che ricorrono al mock continuano a funzionare. Per utilizzare la libreria occorre creare una funzione che converta RemoteStatement nel tipo voluto:

implicit def normalizeStatement(remote: RemoteStatement) = new {
  def execute(sql: String) = remote.remoteExecute(sql)
}

Siamo quindi ora in grado di effettuare la chiamata al nostro metodo utilizzando la nuova classe:

deleteAllRows(new RemoteStatement)

Per approfondire l'argomento consiglio di dare un'occhiata al post di David Pollak che, tralasciando le note eccessivamente polemiche, è senza dubbio illuminante.

Gli "Attori" della programmazione concorrente

L'importanza della programmazione concorrente nell'ultimo periodo e' aumentata, da un lato per l'uscita dei processori multi-core, dall'altro per l'affermarsi di paradigmi di elaborazione distribuita quali i web-services ed il grid computing.

La concorrenza in Java è gestita generalmente tramite threads con memoria condivisa sottoposta a lock. Questo metodo da un lato ha problemi di prestazioni, in quanto l'inizializzazione e il passaggio di contesto fra thread sono operazioni dispendiose per elaborazione e memoria; d'altro canto è difficilmente estendibile ad un ambienti distribuiti. Inoltre questo stile di programmazione risulta difficile da comprendere e testare.

Scala ha importato dal linguaggio Erlang la tecnica di gestione della concorrenza basata su Actor, processi concorrenti che comunicano inviandosi messaggi sia in modo sincrono che asincrono. Ciò è vantaggioso sia perché gli Actor possono essere implementati sfruttando un meccanismo di thread-pooling che ne aumenta la scalabilità, sia perché il modello potrebbe essere facilmente esteso all'elaborazione distribuita. Il codice risulta per giunta più lineare, deterministico e meno soggetto ad "effetti collaterali". Quale esempio, vediamo un semplice programma che effettua la ricerca parallela di un file.

import scala.actors.Actor
import java.io.File

//I 2 tipi di messaggi che i processi si inviano
case class Found(location: String)
case object Finished

case class ParallelSearch(toFind: String) extends Actor {   
  def act() = {  //Analogo al metodo run() di Runnable in Java
    Searcher(toFind, "c:\\", this).start  //Avvio il primo processo di ricerca...
    Searcher(toFind, "e:\\", this).start  //...ed il secondo
    var activeWorkers = 2
    while(activeWorkers > 0) {
      receive {  //Pattern matching sul tipo di messaggio ricevuto
        case Found(location) => println("Found " + location)
        case Finished  => activeWorkers -= 1
      }
    }
    println("Search completed")
  }  
}

case class Searcher (what: String, where: String, search: ParallelSearch) extends Actor {
  def act() = {
    visitAllDirsAndFiles(new File(where))
    search ! Finished   //Segnalo alla ricerca di avere terminato
  }

  def visitAllDirsAndFiles(fileSystemEntry: File): Unit = {
    if (fileSystemEntry.getName().equalsIgnoreCase(what))
      search ! Found(fileSystemEntry.getAbsolutePath()) //Invio il percorso trovato
    if (fileSystemEntry.isDirectory()) {
      val children = fileSystemEntry.list()
      for (child <- children) {
        visitAllDirsAndFiles(new File(fileSystemEntry.getAbsolutePath(), child))
      }
    }
  }
}

object parallelSearchRunner extends Application {
  ParallelSearch("parallelSearchRunner.scala").start
}

Per inviare ad un Actor un messaggio asincrono, costituito da un oggetto, si utilizza il metodo actorObject ! msgObject, mentre all'interno di un Actor posso ricevere messaggi utilizzando il metodo receive. Si noti che entrambi non sono costrutti insiti nel linguaggio, bensì due metodi della classe di libreria Actor. Questa scelta comporta alcuni benefici. In primo luogo il cuore del linguaggio può mantenersi snello ed adattarsi agevolmente al sistema di threading della virtual machine che lo ospita. Secondariamente risulta possibile estendere la libreria con implementazioni differenti da quella di base, che, ad esempio, realizzino la comunicazione con attori remoti utilizzando diversi protocolli di trasporto.

La nostra banale ricerca parallela potrebbe trasformarsi facilmente, disponendo di un'opportuna libreria per la comunicazione con processi remoti, in un'applicazione di ricerca distribuita. Un supporto agli Actor distribuiti è presente nella libreria base di Scala e parecchio lavoro si sta svolgendo per l'integrazione con Terracotta. Non sono molti i linguaggi che si propongono come alternative a Java a possedere un modello di programmazione concorrente così evoluto.

Conclusioni

Vi sarebbero numerose altre caratteristiche interessanti del linguaggio, ma è giunto il momento di qualche riflessione. Scala è forse il migliore dei linguaggi possibili? Certamente molti aspetti di Scala non sono ottimali. Ho già citato i dubbi sul pattern matching. La grande ricchezza sintattica inoltre, se male utilizzata, può portare a del codice intricato e difficilmente leggibile. Ad esempio qualche tempo fa è nata una polemica sulla leggibilità del seguente frammento:

def sum(l: List[int]): int = (0/:l){_+_}

È il frutto di una manata casuale sulla tastiera, o il metodo per sommare una lista di interi? Alcune sottigliezze, poi, come la distinzione tra funzioni senza argomenti e funzioni con zero argomenti, pur ineccepibili da un punto di vista teorico, possono apparire barocchismi a chi proviene dal mondo, forse un po' grezzo, ma concreto di Java. La gerarchia dei tipi, con la distinzione tra Any, Anyref ed Anyval e la conseguente suddivisione tra Null e Nothing, probabilmente ha subito la cattiva influenza di Java, che distingue tra valori primitivi ed oggetti.

Il compilatore non è esente da bachi ed il plug-in per Eclipse, pur utile, è sicuramente da raffinare; ma questi, si sa, sono peccati di gioventù ai quali sicuramente si porrà rimedio. Vanno però considerati i punti di forza della creatura di Odersky e soci. In primo luogo Scala è pienamente integrato nell'ambiente di esecuzione Java. Ciò consente il suo utilizzo in una grande quantità di ambienti eterogenei (ad esempio i sistemi Mainframe e iSeries IBM) oltre a mettere immediatamente a disposizione l'enorme numero di librerie e framework esistenti per il linguaggio di Sun. Si noti poi che le prestazioni di Scala sono paragonabili a quelle di Java, cosa non sempre vera per molti suoi concorrenti. Inoltre il linguaggio, pur potentissimo, è a tipizzazione statica. Questo può facilitarne l'adozione da parte di chi, come me, non ha una grande affinità con i linguaggi dinamici. Anche la sua sintassi di base, simile a quella di Java, può contribuire alla sua diffusione. Secondo la mia modestissima opinione, quindi, Scala non è un linguaggio perfetto, ma, al momento, sembra comunque uno dei migliori candidati alla successione di Java nel medio periodo.

Pubblicato il
6 Mar 2008
Tag

Commenti

  • Massimiliano il 6 Mar 2008

    Premetto di non amare particolarmente Java, quindi non commento il lato prettamente tecnico dell'articolo, ma non posso non congratularmi con voi per la tipologia delle notizie, l'impostazione dell'esposizione e l'elevato livello degli argomenti trattati: veramente bravi! Sinceramente, penso che questo blog sia veramente uno dei migliori attualmente presenti sulla scena italiana. Mai banale, molto interessante e dettagliato, ma non per questo complicato da leggere. Bravi.

  • Michele Simionato il 7 Mar 2008

    Io aspetto un articoletto sui pro e contro del pattern matching!

  • Lorenzo Bolognini il 7 Mar 2008

    A sentire un mio collega che usa erlang il pattern matching è il Sacro Graal ;-)

  • Antonio Cangiano il 8 Mar 2008

    Franco, ottima panoramica del linguaggio e benvenuto nel team. :)

  • riffraff il 9 Mar 2008

    io voglio sapere la differenza tra funzioni con zero argomenti e funzioni senza argomenti! Ormai ho un tarlo nel cervello, ci scappa un'articoletto ? :)

    Tra l'altro, esiste qualcuno che sa se e quali differenze ci sono tra il pattern matching in Scala e quello in F# ?

    Ah e dimenticavo: bel'articolo!

  • lloyd27 il 11 Mar 2008

    un altro linguaggio java-based carino è Groovy, con qualche infusione di python.. ma anche questo scala (che onestamente non conoscevo) non sembra da buttare..

  • Paolo Sacconier il 13 Mar 2008

    Considerando il livello tecnico medio dei programmatori Java con cui ho avuto a che fare sinceramente li vedrei in difficoltà a gestire un linguaggio così barocco. Ne vedo la diffusione difficile, sicuramente sarà precursore delle future evoluzioni di Java. Ottimo articolo.

  • ZeD il 15 Mar 2008
    def sum(l: List[int]): int = (0/:l){_+_}

    O_o

    è la più brutta foldr / reduce che abbia mai visto

    Boh, magari sono io, ma più vedo questo linguaggio meno m'attira (ottimo articolo, comunque)

Screencast e videocorsi di programmazione
Stacktrace RSS Feed Stacktrace via E-mail
Hai idee per un articolo? Faccelo sapere!