Voi siete qui: Inizio Programmare in Scala

La progettazione di applicazioni

In questo capitolo esamineremo gli aspetti pratici dello sviluppo di applicazioni in Scala. Analizzeremo alcune caratteristiche del linguaggio e della API di cui finora non abbiamo parlato, discuteremo di pattern di progettazione e di idiomi comuni, infine rivisiteremo i tratti nell’ottica della strutturazione efficace del codice.

Annotazioni

Al pari di Java e .NET, Scala supporta le annotazioni come meccanismo per aggiungere metadati alle dichiarazioni. Le annotazioni vengono largamente impiegate dagli strumenti adottati per realizzare le tipiche applicazioni aziendali e di rete: per esempio, alcuni framework ORM1 usano le annotazioni sui tipi e sui loro membri per fornire al compilatore informazioni riguardanti la persistenza degli oggetti. Anche se Scala dispone di meccanismi alternativi per gli usi più comuni delle annotazioni in Java e .NET, esse possono rivelarsi essenziali per interagire con le librerie che, su queste piattaforme, ne sfruttano tutte le proprietà. Per fortuna, è possibile usare le annotazioni Java e .NET nel codice Scala.

Il modo in cui le annotazioni Scala vengono interpretate dipende dall’ambiente a tempo di esecuzione. In questa sezione ci concentreremo sull’ambiente offerto dal JDK.

In Java le annotazioni si dichiarano usando convenzioni particolari, per esempio adoperando la parola chiave @interface al posto di class o interface. Ecco la dichiarazione di un’annotazione estratta dalla libreria Contract4J [Contract4J] che usa le annotazioni per supportare la progettazione per contratto in Java (si veda anche la sezione Una progettazione migliore con la progettazione per contratto più avanti). Alcuni commenti sono stati rimossi dal codice e, per chiarezza, i rimanenti sono stati tradotti.

// esempi/cap-13/Pre.java

package org.contract4j5.contract;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.PARAMETER, ElementType.METHOD, ElementType.CONSTRUCTOR})
public @interface Pre {
  /**
   * Il "valore" è l'espressione di test, che deve essere valutata a true o false.
   * Deve essere un'espressione valida nel linguaggio di scripting che state usando.
   */
  String value() default "";

  /**
   * Un messaggio opzionale da stampare insieme al messaggio standard stampato
   * quando il contratto non viene rispettato.
   */
  String message() default "";
}

L’annotazione @Pre viene usata per specificare le “precondizioni” che devono essere soddisfatte quando si entra in un metodo o in un costruttore, o prima di usare un parametro passato a un metodo o a un costruttore. Le condizioni vengono specificate sotto forma di una stringa che in realtà è una porzione di codice sorgente, scritta in un linguaggio di scripting come Groovy o JRuby, la cui valutazione deve dare come risultato true oppure false. Il nome della variabile che contiene questa stringa, value, è quello adottato come convenzione per indicare il campo più importante di un’annotazione. L’altro campo, message, rappresenta un messaggio opzionale da usare quando viene presentato un fallimento.

Alla dichiarazione vengono applicate altre annotazioni: per esempio, l’annotazione @Retention con il valore RetentionPolicy.RUNTIME significa che le informazioni relative a ogni occorrenza di @Pre saranno conservate nel file di classe per essere usate a tempo di esecuzione.

Ecco un esempio di come usare @Pre in Scala, specificando i parametri value e message in modi diversi.

// esempi/cap-13/pre-example.scala

import org.contract4j5.contract._

class Person(
  @Pre( "name != null && name.length() > 0" )
  val name: String,
  @Pre{ val value = "age > 0", val message = "Sei troppo giovane!" }
  val age: Int,
  @Pre( "ssn != null" )
  val ssn: SSN)

class SSN(
  @Pre( "valid(ssn)" ) { val message = "Il formato deve essere NNN-NN-NNNN." }
  val ssn: String) {

  private def valid(value: String) =
    value.matches("""^\s*\d{3}-\d{2}-\d{4}\s*$""")
}

Nella classe Person, l’annotazione @Pre accetta come argomento una semplice stringa, contenente la “precondizione” che un nome passato da un utente deve soddisfare: questo valore non può essere null e non può essere di lunghezza zero. Come in Java, se all’annotazione viene passato un singolo argomento, questo viene assegnato al campo value.

Una simile annotazione @Pre viene usata per il terzo argomento ssn, il numero di previdenza sociale. In entrambi i casi, al campo message viene assegnata una stringa vuota, specificata come valore predefinito nella definizione di Pre.

L’annotazione @Pre usata per l’età mostra come specificare valori per più di un campo usando le parentesi graffe anziché le parentesi tonde. La sintassi dei campi somiglia a quella di una dichiarazione val priva delle informazioni di tipo, in quanto i tipi possono essere sempre inferiti! Di conseguenza, è possibile usare la sintassi abbreviata per value e specificare comunque i valori per gli altri campi.

Se Person fosse una classe Java, questa annotazione sarebbe identica, eccetto che per l’assenza della parola chiave val e l’uso delle parentesi tonde.

L’annotazione @Pre sul parametro del costruttore della classe SSN mostra la sintassi alternativa per assegnare valori a più di un campo. Il campo value viene specificato come prima, tramite una lista di parametri contenente un elemento; il campo message viene inizializzato nel blocco successivo, racchiuso tra parentesi graffe.

Per collaudare questo script sarebbe necessario installare e configurare Conctract4J. Fate riferimento a [Contract4J] per scoprire come effettuare queste operazioni.

Anziché usare una sintassi speciale, le annotazioni Scala vengono dichiarate come normali classi. In questo modo si evita un “caso particolare” nel linguaggio, ma come vedremo si perdono anche alcune delle caratteristiche offerte dalle annotazioni Java. Ecco un’annotazione di esempio, SerialVersionUID, tratta dalla libreria Scala (ancora una volta con i commenti rimossi, per chiarezza).

package scala

class SerialVersionUID(uid: Long) extends StaticAnnotation

L’annotazione @SerialVersionUID viene applicata a una classe per definire un identificatore unico globale sotto forma di numero Long. Quando si usa questa annotazione, l’identificatore viene specificato come un argomento del costruttore; in una classe Java, quello stesso numero verrebbe assegnato a un campo static chiamato serialVersionUID. Questo è un esempio di un’annotazione Scala che corrisponde a un costrutto Java diverso da un’annotazione.

SerialVersionUID estende il tratto scala.StaticAnnotation, usato come genitore per tutte le annotazioni che dovrebbero essere visibili durante i controlli di tipo, anche attraverso i confini delle unità di compilazione. A sua volta, scala.StaticAnnotation estende scala.Annotation, che è il genitore di tutte le annotazioni Scala.

Nella dichiarazione del parametro uid non compare alcuna parola chiave val: uid non viene dichiarato come campo perché il dato dell’annotazione non è destinato a essere usato dal programma, ma solo da strumenti esterni come scalac. Questo significa anche che non c’è alcun modo di definire valori nelle annotazioni di Scala 2.7.X, in quanto il supporto per gli argomenti impliciti è assente. Tuttavia, si potrebbe ricorrere agli argomenti predefiniti introdotti in Scala 2.8.0 (non sono ancora stati implementati al momento della scrittura, quindi non ci è stato possibile verificare).

Come per le annotazioni Java, la clausola che contiene un’annotazione Scala viene applicata alla definizione che precede. Potete usare più di una clausola per una singola definizione, e l’ordine in cui compaiono le clausole non è significativo. La sintassi da usare per scrivere queste clausole è @Annotazione, se il costruttore dell’annotazione non accetta argomenti, oppure @Annotazione(arg1, … argN), se il costruttore accetta un numero N di argomenti. L’annotazione deve essere una sottoclasse di scala.Annotation.

Tutti i parametri del costruttore devono essere espressioni costanti, comprese stringhe, letterali classe, enumerazioni Java, espressioni numeriche, e array monodimensionali di questi tipi. Tuttavia, il compilatore permette di usare anche clausole di annotazione con altri argomenti, come valori booleani e mappe, in modo simile a quanto presentato in questo esempio.

// esempi/cap-13/anno-example.scala

import scala.StaticAnnotation

class Persist(tableName: String, params: Map[String,Any])
  extends StaticAnnotation

// Non viene compilato:
// @Persist("ACCOUNTS", Map("dbms" -> "MySql", "writeAutomatically" -> true))
@Persist("ACCOUNTS", Map(("dbms", "MySql"), ("writeAutomatically", true)))
class Account(val balance: Double)

Stranamente, se tentate di usare la normale sintassi per i letterali Map mostrata nel commento, il compilatore genera un errore lamentando la mancanza del metodo -> nella classe String. La conversione implicita verso ArrowAssoc che abbiamo esaminato nella sezione L’oggetto Predef del capitolo 7 non viene invocata, perciò dovete usare una lista di oggetti Tuple, che è il tipo effettivo dell’argomento atteso da Map.apply.

Il tratto scala.ClassfileAnnotation è un altro figlio di scala.Annotation, progettato per essere esteso da annotazioni che dovrebbero venire conservate nel file di classe in modo da rimanere disponibili a tempo di esecuzione. Tuttavia, se usate questo tratto con la versione di Scala per il JDK, il compilatore genererà errori simili a quello riportato di seguito.

…: warning: implementation restriction: subclassing Classfile does not
            make your annotation visible at runtime.  If that is what
            you want, you must write the annotation class in Java.
…

Perciò se desiderate la visibilità a tempo di esecuzione dovete implementare l’annotazione in Java, potendola poi usare tranquillamente nel codice Scala al pari di qualsiasi altra annotazione Java. Al momento, forse per ovvie ragioni, la libreria Scala non definisce annotazioni derivate da ClassfileAnnotation.

Evitate ClassfileAnnotation e implementate in Java le annotazioni che richiedono di essere conservate a tempo di esecuzione.

Per le versioni 2.7.X di Scala è necessario tenere presente un’altra importante limitazione: le annotazioni non possono essere annidate. Questo diventa un problema quando usate annotazioni JPA nel codice Scala, per esempio, come si vede in [JPAScala]. Tuttavia, la versione 2.8 di Scala rimuove questa limitazione.

Le annotazioni si possono annidare solo in Scala 2.8.

La tabella seguente (adattata ed estesa da http://www.scala-lang.org/node/106) descrive tutte le annotazioni definite nella libreria Scala, partendo dai figli diretti di Annotation e proseguendo con i figli di StaticAnnotation.

Tabella 13.1. Le annotazioni Scala derivate da Annotation.

NomeEquivalente JavaDescrizione

ClassfileAnnotation

Annotazione con @Retention(RetentionPolicy.RUNTIME)

Il tratto genitore per le annotazioni che dovrebbero essere conservate nel file di classe per l’accesso a tempo di esecuzione — ma in realtà non funziona con il JDK!

BeanDescription

BeanDescriptor (classe)

Un’annotazione per i componenti JavaBeans che associa a un tipo o a un membro una breve descrizione (fornita all’annotazione come argomento) che verrà inclusa tra le informazioni generate per il componente.

BeanDisplayName

BeanDescriptor (classe)

Un’annotazione per i componenti JavaBeans che associa a un tipo o a un membro un nome (fornito all’annotazione come argomento) che verrà incluso tra le informazioni generate per il componente.

BeanInfo

BeanInfo (classe)

Un marcatore usato per indicare che una classe BeanInfo dovrebbe essere generata per la classe Scala annotata. Le dichiarazioni val diventano proprietà a sola lettura; le dichiarazioni var diventano proprietà a lettura e scrittura; le dichiarazioni def diventano metodi.

BeanInfoSkip

nessuno

Un marcatore usato per indicare che le informazioni del componente non dovrebbero essere generate per il membro annotato.

StaticAnnotation

Campi statici, @Target(ElementType.TYPE)

Il tratto genitore per le annotazioni che dovrebbero definire metadati “statici” ed essere visibili attraverso i confini delle unità di compilazione.

TypeConstraint

nessuno

Un tratto applicabile come annotazione ad altre annotazioni che definiscono vincoli su un tipo, basandosi solo sulle informazioni definite all’interno del tipo stesso anziché su informazioni esterne relative al contesto in cui il tipo viene definito o usato. Il compilatore può sfruttare questa restrizione per riscrivere il vincolo. Attualmente la libreria Scala non contiene annotazioni che usano questo tratto.

unchecked

nessuno

Un marcatore da applicare al selettore in una istruzione di pattern matching (per esempio, la x in x match {…}) per omettere un messaggio di avvertimento del compilatore nel caso in cui le clausole case non siano “esaustive”. Durante l’esecuzione può ancora avvenire un errore di tipo MatchError se non esiste alcuna corrispondenza per un valore di x nelle clausole case. Si veda l’esempio più avanti.

unsealed

nessuno

Deprecata, usate @unchecked al suo posto.

 

Tabella 13.2. Le annotazioni Scala derivate da StaticAnnotation.

NomeEquivalente JavaDescrizione

BeanProperty

Convenzioni JavaBeans

Un marcatore applicabile ai campi (compresi i parametri del costruttore dichiarati con una parola chiave val o var) che induce il compilatore a generare metodi di “lettura” e “scrittura” nello stile JavaBeans. Il metodo di scrittura viene generato solo per le dichiarazioni var. Si veda a questo proposito la sezione Proprietà JavaBeans nel capitolo 14.

cloneable

java.lang.Cloneable (interfaccia)

Un marcatore per indicare che una classe può essere clonata.

cps

nessuno

Genera il bytecode usando un modello di esecuzione basato sul passaggio di continuazioni. Disponibile solo a partire da Scala 2.8.

deprecated

java.lang.Deprecated

Un marcatore applicabile a qualsiasi definizione per indicare che l’elemento definito è obsoleto. Il compilatore genererà un messaggio di avvertimento quando l’elemento viene usato.

inline

nessuno

Un marcatore applicabile ai metodi per chiedere al compilatore di impegnarsi “molto duramente” a espandere il metodo nei punti in cui viene invocato.

native

native (parola chiave)

Un marcatore per indicare che il metodo è implementato sotto forma di codice “nativo”. Il compilatore non genererà il corpo del metodo, ma il controllo di tipo sulle invocazioni del metodo verrà comunque effettuato.

noinline

nessuno

Un marcatore applicabile ai metodi usato per impedire al compilatore di espandere il metodo anche quando questa operazione sembra innocua.

remote

java.rmi.Remote (interfaccia)

Un marcatore usato per indicare che la classe può essere invocata da una JVM remota.

serializable

java.io.Serializable (interfaccia)

Un marcatore usato per indicare che la classe può essere serializzata.

SerialVersionUID

Un campo statico serialVersionUID in una classe

Definisce un identificatore unico globale ai fini della serializzazione. Il costruttore dell’annotazione accetta un numero Long come argomento per rappresentare l’identificatore unico.

switch

nessuno

Un’annotazione da applicare a un’espressione di pattern matching, come per esempio (x: @switch) match {…}. Quando è presente, il compilatore verificherà che il costrutto sia stato compilato sotto forma di una istruzione switch basata su tabella o su ricerca, generando un errore se il costrutto è stato compilato sotto forma di una serie di espressioni condizionali, che sono meno efficienti. Disponibile solo a partire da Scala 2.8.

specialized

nessuno

Un’annotazione da applicare ai parametri di tipo nei tipi e nei metodi parametrici. Induce il compilatore a generare versioni ottimizzate del tipo o del metodo per i tipi AnyVal corrispondenti ai tipi primitivi della piattaforma. Opzionalmente, è possibile limitare i tipi AnyVal per i quali verranno generate le implementazioni specializzate (si veda più avanti per una discussione in merito). Disponibile solo a partire da Scala 2.8.

tailrec

nessuno

Un’annotazione applicabile ai metodi per indurre il compilatore a verificare che il metodo sia stato ottimizzato per la ricorsione in coda. Quando è presente, il compilatore genererà un errore se il metodo non può essere ottimizzato in un ciclo. Questo può succedere, per esempio, quando il metodo non è qualificato come private o final, quando può essere ridefinito, e quando l’invocazione ricorsiva non è realmente l’ultima istruzione del metodo. Disponibile solo a partire da Scala 2.8.

throws

throws (parola chiave)

Indica quali eccezioni vengono lanciate dal metodo annotato (si veda la discussione più avanti).

transient

transient (parola chiave)

Contrassegna un metodo come “transiente”.

uncheckedStable

nessuno

Un marcatore applicabile a un valore che si suppone stabile anche se il suo tipo è volatile (cioè annotato con @volatile).

uncheckedVariance

nessuno

Un marcatore applicato a un argomento di tipo che è volatile, quando è usato in un tipo parametrico, per omettere il controllo della varianza.

volatile

volatile (parola chiave, solo per i campi)

Un marcatore applicabile a un singolo campo (o a un intero tipo, nel qual caso ha effetto su tutti i suoi campi) per indicare che il campo può essere modificato da un altro thread.

Tra le annotazioni disponibili solo a partire dalla versione 2.8 di Scala considerate @tailrec, usata nell’esempio seguente.

import scala.annotation.tailrec

@tailrec
def fib(i: Int): Int = i match {
  case _ if i <= 1 => i
  case _ => fib(i-1) + fib(i-2)
}
println(fib(5))

Notate che fib effettua il calcolo dei numeri di Fibonacci in maniera ricorsiva, ma la funzione non è ricorsiva in coda perché l’invocazione a se stessa non è l’ultima operazione eseguita nella seconda clausola case. Infatti, dopo essersi richiamata due volte, la funzione esegue una somma, perciò non è possibile effettuare l’ottimizzazione per la ricorsione in coda su questo metodo, e quando il compilatore vede l’annotazione @tailrec genera un errore informandoci del problema, come potete verificare se tentate di eseguire lo script.

… 4: error: could not optimize @tailrec annotated method
def fib(i: Int): Int = i match {
     ^
one error found

Possiamo usare lo stesso metodo per illustrare la nuova annotazione @switch disponibile in Scala 2.8.

import scala.annotation.switch

def fib(i: Int): Int = (i: @switch) match {
  case _ if i <= 1 => i
  case _ => fib(i-1) + fib(i-2)
}
println(fib(5))

Questa volta annotiamo la variabile i nell’istruzione match, inducendo il compilatore a generare un errore nel caso non sia in grado di generare un costrutto switch nel bytecode a partire dai casi dell’istruzione match. In genere, i costrutti switch sono più efficienti rispetto alle espressioni condizionali. L’esecuzione di questo script produce l’uscita seguente.

… 3: error: could not emit switch for @switch annotated match
def fib(i: Int): Int = (i: @switch) match {
                                     ^
one error found

Evidentemente è necessario generare blocchi condizionali anziché un costrutto switch, a causa della guardia if i <= 1 che abbiamo inserito nella prima clausola case.

Diamo ora un’occhiata a un esempio di @unchecked in azione, adattato dalla pagina Scaladoc relativa a questa annotazione. Considerate il seguente frammento di codice.

…
def process(x: Option[int]) = x match {
  case Some(value) => …
}
…

Se provate a compilarlo, otterrete il seguente messaggio di avvertimento.

…: warning: does not cover case {object None}
  def f(x: Option[int]) = x match {
                          ^
one warning found

Normalmente vorreste aggiungere una clausola case per None, ma se desiderate che il compilatore ometta i messaggi di avvertimento in situazioni come queste dovete modificare il metodo nel modo seguente.

…
def process(x: Option[int]) = (x: @unchecked) match {
  case Some(value) => …
}
…

Grazie all’annotazione @unchecked applicata a x nel modo illustrato, il messaggio di avvertimento non viene emesso. Tuttavia, se x dovesse mai valere None, allora verrà lanciata una eccezione di tipo MatchError.

L’annotazione @specialized è un’altra delle annotazioni relative all’ottimizzazione aggiunte nella versione 2.8 di Scala e rappresenta una pratica soluzione di compromesso tra l’efficienza in termini di spazio e le prestazioni. In Java e in Scala l’implementazione di un tipo o di un metodo parametrico viene generata nel punto in cui è stato dichiarato (come abbiamo detto nella sezione Capire i tipi parametrici del capitolo 12), a differenza di quanto accade in C++, dove si sfrutta un template per generare l’implementazione dei parametri di tipo reali nei punti in cui il template viene usato. L’approccio del C++ ha il vantaggio di permettere la generazione di implementazioni ottimizzate per i tipi primitivi, ma ha lo svantaggio di produrre codice ipertrofico a causa di tutte le istanziazioni dei template.

La generazione di implementazioni “a richiesta” non è adatta ai linguaggi per la JVM, principalmente a causa della mancanza del passo di “collegamento” compiuto dal linker nei linguaggi compilati, durante il quale possono essere determinate le istanziazioni richieste di un template. Di solito, un tipo o un metodo parametrico Scala verrà tradotto in una singola implementazione usando Any per i parametri di tipo (in parte a causa della cancellazione di tipo a livello di bytecode); i generici Java funzionano allo stesso modo. Tuttavia, se il tipo o il metodo vengono usati con uno dei tipi AnyVal, per esempio Int, l’implementazione sarà penalizzata da inefficienti operazioni di conversione tra i tipi primitivi della piattaforma e i corrispondenti tipi oggetto avvolgenti.

Come alternativa, si potrebbe generare un’implementazione separata per ogni AnyVal corrispondente a un tipo primitivo, provocando però la già citata ipertrofia del codice se si considera, in particolare, quanto sarebbe raro per un’applicazione usare tutte quelle implementazioni. I creatori di Scala hanno dovuto affrontare questo dilemma, riuscendo infine a trovare un accomodamento.

L’annotazione @specialized rappresenta un pratico compromesso: l’utente può usarla per segnalare al compilatore che l’efficienza a tempo di esecuzione è più importante dell’efficienza in termini di spazio, inducendolo quindi a generare le implementazioni separate per ogni tipo primitivo corrispondente a AnyVal. Qui di seguito riportiamo un esempio di come usare l’annotazione.

class SpecialCollection[@specialized +T](…) {
  …
}

Al momento della scrittura, l’implementazione degli assemblaggi “notturni” di Scala 2.8 supporta solo la generazione di implementazioni specializzate per Int e Double, ma per il rilascio finale della versione 2.8 si prevede di supportare anche gli altri tipi derivati da AnyVal, oltre a offrire all’utente la possibilità di specificare i tipi per i quali richiedere la generazione delle implementazioni ottimizzate, evitandogli così di ritrovarsi con implementazioni inutilizzate per qualche sottoclasse di AnyVal. Si veda la documentazione Scaladoc della versione 2.8 definitiva per i dettagli sull’insieme di caratteristiche effettivamente supportate.

Un’altra annotazione prevista per la versione 2.8 è @cps, il cui nome è l’acronimo dell’inglese continuation passing style (stile a passaggio di continuazioni). Sarà una direttiva interpretata da un plug-in del compilatore che attiverà la generazione di bytecode basato sulle continuazioni per le invocazioni dei metodi al posto del bytecode predefinito basato su un modello a stack. L’annotazione non avrà effetto a meno che non venga usato il corrispondente plug-in di scalac. Non appena sarà disponibile, consultate la documentazione della versione definitiva di Scala 2.8 per ottenere maggiori informazioni su questa funzione.

Per comprendere l’annotazione @throws è importante ricordare che Scala, a differenza di Java, non dispone di eccezioni controllate, né di una clausola throws per le dichiarazioni di metodo. Questo non costituisce un problema nel caso in cui un metodo Scala invochi un metodo Java che dichiara di lanciare un’eccezione controllata, in quanto Scala tratterà questa eccezione come non controllata. Tuttavia, supponete che il metodo Scala in questione non catturi l’eccezione ma la lasci passare: cosa succede se questo metodo Scala viene invocato da altro codice Java?

Diamo un’occhiata a un esempio in cui si verifica questa situazione, relativa all’eccezione controllata java.io.IOException. La classe Scala riportata di seguito stampa il contenuto di un java.io.File.

// esempi/cap-13/file-printer.scala

import java.io._

class FilePrinter(val file: File) {

  @throws(classOf[IOException])
  def print() = {
    var reader: LineNumberReader = null
    try {
      reader = new LineNumberReader(new FileReader(file))
      loop(reader)
    } finally {
      if (reader != null)
        reader.close
    }
  }

  private def loop(reader: LineNumberReader): Unit = {
    val line = reader.readLine()
    if (line != null) {
      format("%3d: %s\n", reader.getLineNumber, line)
      loop(reader)
    }
  }
}

L’annotazione @throws viene applicata al metodo print e l’argomento del suo costruttore è un singolo oggetto java.lang.Class[Any], in questo caso classOf[IOException] poiché i metodi della API Java di I/O usati da print e il metodo privato loop potrebbero lanciare questa eccezione.

Notate che loop usa una ricorsione in coda in stile funzionale anziché un ciclo. Nessuna variabile è stata modificata durante la produzione del testo in uscita! (A dire il vero, non sappiamo cosa succeda realmente all’interno delle classi di I/O di Java…)

Ecco una classe Java che usa FilePrinter in un metodo main.

// esempi/cap-13/FilePrinterMain.java

import java.io.*;

public class FilePrinterMain {
  public static void main(String[] args) {
    for (String fileName: args) {
      try {
        File file = new File(fileName);
        new FilePrinter(file).print();
      } catch (IOException ioe) {
        System.err.println("IOException per il file " + fileName);
        System.err.println(ioe.getMessage());
      }
    }
  }
}

Queste classi vengono compilate senza errori. Potete provare a eseguirle tramite il comando seguente (supponendo che FilePrinterMain.java si trovi nella directory annotations, come accade nella distribuzione degli esempi di codice).

scala -cp build FilePrinterMain annotations/FilePrinterMain.java

Dovreste ottenere l’uscita seguente.

 1: import java.io.*;
 2:
 3: public class FilePrinterMain {
 4:   public static void main(String[] args) {
 5:     for (String fileName: args) {
 6:       try {
 7:         File file = new File(fileName);
 8:         new FilePrinter(file).print();
 9:       } catch (IOException ioe) {
10:         System.err.println("IOException per il file " + fileName);
11:         System.err.println(ioe.getMessage());
12:       }
13:     }
14:   }
15: }

Ora, tornando alla classe FilePrinter, supponete di nascondere l’annotazione @throws in un commento. Il file che contiene quella classe verrà ancora compilato, ma tentando di compilare FilePrinterMain.java otterrete l’errore seguente.

annotations/FilePrinterMain.java:9: exception java.io.IOException is never
thrown in body of corresponding try statement
      } catch (IOException ioe) {
        ^
1 error

Nonostante sia ancora possibile che FilePrinter lanci una java.io.IOException, il bytecode generato da scalac non contiene questa informazione, perciò javac conclude la propria analisi deducendone erroneamente che IOException non viene mai lanciata.

Lo scopo di @throws, quindi, è quello di inserire nel bytecode che verrà letto da javac l’informazione sulle eccezioni controllate che possono essere lanciate.

In un ambiente misto Java/Scala considerate l’ipotesi di aggiungere l’annotazione @throws a tutti i vostri metodi Scala che possono lanciare eccezioni controllate Java. Prima o poi qualche porzione di codice Java finirà probabilmente per invocare uno di quei metodi.

Un confronto tra enumerazioni e pattern matching

Le enumerazioni consentono di definire un insieme finito di valori costanti e rappresentano un’alternativa leggera alle classi case. Usando un’enumerazione potete fare riferimento direttamente ai valori, attraversarli in un’iterazione, accedervi tramite un indice intero, &c.

Proprio come per le annotazioni, la forma delle enumerazioni di Scala è basata sulle classi e su un particolare insieme di idiomi anziché su parole chiave speciali da applicare alla definizione, come invece accade per le enumerazioni in Java e .NET. Tuttavia, in Scala potete anche usare le enumerazioni definite su quelle due piattaforme.

Le enumerazioni di Scala si definiscono estendendo la classe astratta scala.Enumeration e si possono costruire in diversi modi. Vi mostreremo l’idioma che ricalca più da vicino le forme usate in Java e .NET che potreste già conoscere.

A questo scopo riprenderemo gli script per i metodi HTTP che abbiamo realizzato nella sezione Gerarchie di classi sigillate del capitolo 7. Se ricordate, avevamo definito l’insieme dei metodi di HTTP 1.1 usando una gerarchia sigillata di classi case.

// esempi/cap-7/sealed/http-script.scala

sealed abstract class HttpMethod()
case class Connect(body: String) extends HttpMethod
case class Delete (body: String) extends HttpMethod
case class Get    (body: String) extends HttpMethod
case class Head   (body: String) extends HttpMethod
case class Options(body: String) extends HttpMethod
case class Post   (body: String) extends HttpMethod
case class Put    (body: String) extends HttpMethod
case class Trace  (body: String) extends HttpMethod

def handle (method: HttpMethod) = method match {
  case Connect (body) => println("connect: " + body)
  case Delete  (body) => println("delete: "  + body)
  case Get     (body) => println("get: "     + body)
  case Head    (body) => println("head: "    + body)
  case Options (body) => println("options: " + body)
  case Post    (body) => println("post: "    + body)
  case Put     (body) => println("put: "     + body)
  case Trace   (body) => println("trace: "   + body)
}

val methods = List(
  Connect("corpo di connect..."),
  Delete ("corpo di delete..."),
  Get    ("corpo di get..."),
  Head   ("corpo di head..."),
  Options("corpo di options..."),
  Post   ("corpo di post..."),
  Put    ("corpo di put..."),
  Trace  ("corpo di trace..."))

methods.foreach { method => handle(method) }

In quell’esempio ogni metodo aveva un attributo body per il corpo del messaggio. Qui ipotizzeremo che il corpo venga gestito in altri modi: ci interessa solo identificare il tipo di metodo HTTP. Quindi, ecco una classe Enumeration per i metodi di HTTP 1.1.

// esempi/cap-13/http-enum-script.scala

object HttpMethod extends Enumeration {
  type Method = Value
  val Connect, Delete, Get, Head, Options, Post, Put, Trace = Value
}

import HttpMethod._

def handle (method: HttpMethod.Method) = method match {
  case Connect => println("Connect: " + method.id)
  case Delete  => println("Delete: "  + method.id)
  case Get     => println("Get: "     + method.id)
  case Head    => println("Head: "    + method.id)
  case Options => println("Options: " + method.id)
  case Post    => println("Post: "    + method.id)
  case Put     => println("Put: "     + method.id)
  case Trace   => println("Trace: "   + method.id)
}

HttpMethod foreach { method => handle(method) }
println(HttpMethod)

Questo script produce l’uscita seguente. (Abbiamo suddiviso il testo tra parentesi graffe su più righe, per adeguarlo meglio alla larghezza della pagina.)

Connect: 0
Delete: 1
Get: 2
Head: 3
Options: 4
Post: 5
Put: 6
Trace: 7
{Main$$anon$1$HttpMethod(0), Main$$anon$1$HttpMethod(1),
Main$$anon$1$HttpMethod(2), Main$$anon$1$HttpMethod(3),
Main$$anon$1$HttpMethod(4), Main$$anon$1$HttpMethod(5),
Main$$anon$1$HttpMethod(6), Main$$anon$1$HttpMethod(7)}

Nella definizione di HttpMethod, Value viene usato in due modi. La prima occorrenza è un riferimento alla classe astratta Enumeration.Value che incapsula alcune operazioni utili per i “valori” nelle enumerazioni, usata per definire un nuovo type chiamato Method che funziona come un alias per Value. Per il lettore, HttpMethod.Method è un nome più significativo di HttpMethod.Value: potete verificarlo osservando il tipo dell’argomento passato al metodo handle, che mostra HttpMethod in azione. Notate che, nel metodo handle, abbiamo usato anche il campo id di Enumeration.Value.

La seconda occorrenza di Value in realtà è l’invocazione di un metodo. Non c’è alcun conflitto tra questi due nomi. La riga val Connect, Delete, Get, Head, Options, Post, Put, Trace = Value definisce l’insieme di valori per l’enumerazione, invocando per ognuno di essi il metodo Value, che di volta in volta crea una nuova istanza di Enumeration.Value e la aggiunge all’insieme di valori gestito dalla enumerazione.

Nella parte successiva del codice, importiamo le definizioni contenute in HttpMethod e implementiamo un metodo handle che effettua il pattern matching sugli oggetti HttpMethod.Method, stampando semplicemente un messaggio e il campo id di ogni valore. Notate che l’esempio non contiene una clausola case “predefinita” (per esempio case _ ⇒ …) perché in questo caso non è richiesta. Tuttavia, in realtà il compilatore non sa che tutti i possibili valori sono trattati, a differenza di quanto accade con una gerarchia sigillata di classi case. Infatti, se nascondete in un commento una delle istruzioni case del metodo handle non otterrete messaggi di avvertimento da parte del compilatore, bensì un’eccezione di tipo MatchError durante l’esecuzione.

Quando si effettua il pattern matching sui valori di un’enumerazione, il compilatore non è in grado di capire se la corrispondenza è “esaustiva”.

Se vi state chiedendo perché abbiamo cablato le stringhe come "Connect" nelle istruzioni println contenute nelle clausole case invece di recuperare i nomi dagli oggetti HttpMethod.Method, e perché il risultato di println(HttpMethod) non include quei nomi al posto dei nomi illeggibili usati internamente dagli oggetti, siete probabilmente abituati alle enumerazioni Java o .NET. Sfortunatamente, non possiamo recuperare quei nomi dai valori della enumerazione Scala, almeno dato il modo in cui abbiamo dichiarato HttpMethod. Tuttavia, è possibile modificare l’implementazione in due modi diversi per ottenere le stringhe con i nomi. Nel primo approccio, il nome viene passato a Value durante la creazione dei campi.

// esempi/cap-13/http-enum2-script.scala

object HttpMethod extends Enumeration {
  type Method = Value
  val Connect = Value("Connect")
  val Delete  = Value("Delete")
  val Get     = Value("Get")
  val Head    = Value("Head")
  val Options = Value("Options")
  val Post    = Value("Post")
  val Put     = Value("Put")
  val Trace   = Value("Trace")
}

import HttpMethod._

def handle (method: HttpMethod.Method) = method match {
  case Connect => println(method + ": " + method.id)
  case Delete  => println(method + ": " + method.id)
  case Get     => println(method + ": " + method.id)
  case Head    => println(method + ": " + method.id)
  case Options => println(method + ": " + method.id)
  case Post    => println(method + ": " + method.id)
  case Put     => println(method + ": " + method.id)
  case Trace   => println(method + ": " + method.id)
}

HttpMethod foreach { method => handle(method) }
println(HttpMethod)

L’uso ripetuto della stessa parola nelle dichiarazioni come val Connect = Value("Connect") è ridondante.

L’esecuzione di questo script produce un’uscita più gradevole.

Connect: 0
Delete: 1
Get: 2
Head: 3
Options: 4
Post: 5
Put: 6
Trace: 7
{Connect, Delete, Get, Head, Options, Post, Put, Trace}

Nel secondo approccio, i nomi vengono passati al costruttore Enumeration.

// esempi/cap-13/http-enum3-script.scala

object HttpMethod extends Enumeration(
    "Connect", "Delete", "Get", "Head", "Options", "Post", "Put", "Trace") {
  type Method = Value
  val Connect, Delete, Get, Head, Options, Post, Put, Trace = Value
}

import HttpMethod._

def handle (method: HttpMethod.Method) = method match {
  case Connect => println(method + ": " + method.id)
  case Delete  => println(method + ": " + method.id)
  case Get     => println(method + ": " + method.id)
  case Head    => println(method + ": " + method.id)
  case Options => println(method + ": " + method.id)
  case Post    => println(method + ": " + method.id)
  case Put     => println(method + ": " + method.id)
  case Trace   => println(method + ": " + method.id)
}

HttpMethod foreach { method => handle(method) }
println(HttpMethod)

Questo script produce la stessa identica uscita del precedente. Notate che in questo caso la ridondanza coinvolge due elenchi, quello di stringhe di nomi e quello di nomi di valori. Sta a voi mantenere gli argomenti del costruttore in un ordine coerente con i valori dichiarati! Questa versione usa meno caratteri ma è maggiormente soggetta a errori. Internamente, Enumeration accoppia le stringhe con le corrispondenti istanze di Value nel momento in cui queste vengono create.

Il risultato ottenuto dalla stampa dell’intero oggetto HttpMethod è migliore in entrambe le implementazioni alternative. Quando i valori hanno un nome, il loro metodo toString restituisce quel nome. In effetti, i nostri due ultimi esempi sono diventati piuttosto artificiosi perché ora tutte le clausole case contengono istruzioni identiche! Naturalmente, in un’implementazione reale, dovreste gestire i metodi HTTP in maniera differente tra loro.

Considerazioni su annotazioni ed enumerazioni

Sia per le annotazioni sia per le enumerazioni, l’approccio di Scala, che prevede di usare gli ordinari meccanismi basati sulle classi anziché inventare parole chiave e sintassi personalizzata, presenta vantaggi e svantaggi. I vantaggi includono un minor numero di casi particolari nel linguaggio: il modo in cui si usano classi e tratti è più o meno lo stesso rispetto al codice “normale”. Gli svantaggi includono la necessità di comprendere e seguire convenzioni ad hoc che non sempre sono tanto convenienti quanto i meccanismi sintattici particolari richiesti da Java e .NET. Inoltre, le implementazioni di Scala non sono altrettanto complete.

Ma forse la comunità Scala dovrebbe proseguire per la propria strada anziché desistere e implementare meccanismi ad hoc ma più completi per le annotazioni e le enumerazioni. Scala è un linguaggio più flessibile rispetto alla maggior parte degli altri linguaggi, e in Scala molte funzionalità offerte dalle annotazioni e dalle enumerazioni di Java e .NET possono essere implementate in altri modi.

Alcuni “casi d’uso” per le funzionalità più avanzate delle annotazioni Java possono essere implementati più elegantemente con codice Scala “normale”, come vedremo nella sezione Pattern di progettazione più avanti. Per quanto riguarda le enumerazioni, le classi case sigillate e il pattern matching offrono in molti casi una soluzione più flessibile.

Le enumerazioni a confronto con le classi case e il pattern matching

Rivisitiamo lo script per i metodi HTTP che fa uso di una gerarchia sigillata di classi case, confrontandolo con la versione scritta in precedenza che sfrutta un’istanza di Enumeration. Dato che questa seconda versione non gestisce il corpo del messaggio, modifichiamo la versione con la gerarchia sigillata di classi case in modo da renderla simile: eliminiamo la gestione del corpo del messaggio e aggiungiamo i metodi name e id.

// esempi/cap-13/http-case-script.scala

sealed abstract class HttpMethod(val id: Int) {
  def name = getClass getSimpleName
  override def toString = name
}

case object Connect extends HttpMethod(0)
case object Delete  extends HttpMethod(1)
case object Get     extends HttpMethod(2)
case object Head    extends HttpMethod(3)
case object Options extends HttpMethod(4)
case object Post    extends HttpMethod(5)
case object Put     extends HttpMethod(6)
case object Trace   extends HttpMethod(7)

def handle (method: HttpMethod) = method match {
  case Connect => println(method + ": " + method.id)
  case Delete  => println(method + ": " + method.id)
  case Get     => println(method + ": " + method.id)
  case Head    => println(method + ": " + method.id)
  case Options => println(method + ": " + method.id)
  case Post    => println(method + ": " + method.id)
  case Put     => println(method + ": " + method.id)
  case Trace   => println(method + ": " + method.id)
}

List(Connect, Delete, Get, Head, Options, Post, Put, Trace) foreach {
  method => handle(method)
}

Notate che abbiamo usato un case object per ognuna delle sottoclassi concrete, in modo da avere un vero e proprio insieme di costanti. Per imitare il campo id della enumerazione abbiamo aggiunto esplicitamente un campo, ma qui è compito nostro passargli valori unici e validi. I metodi handle nelle due implementazioni sono quasi identici.

Lo script produce l’uscita seguente.

Main$$anon$1$Connect$: 0
Main$$anon$1$Delete$: 1
Main$$anon$1$Get$: 2
Main$$anon$1$Head$: 3
Main$$anon$1$Options$: 4
Main$$anon$1$Post$: 5
Main$$anon$1$Put$: 6
Main$$anon$1$Trace$: 7

I nomi degli oggetti non sono molto leggibili, ma potremmo manipolare le stringhe per estrarre la parte che ci interessa realmente.

Entrambi gli approcci supportano il concetto di un insieme finito e costante di valori, purché la gerarchia di classi case rimanga sigillata. Un ulteriore vantaggio della gerarchia sigillata di classi case è il fatto che il compilatore vi avvertirà se le istruzioni del pattern matching non esauriscono tutti i casi possibili, come potete verificare provando a rimuovere una delle clausole case. Abbiamo visto che, invece, il compilatore non è in grado di effettuare questo controllo per le enumerazioni.

Il formato delle enumerazioni è più conciso, nonostante la duplicazione dei nomi, e vi consente anche di iterare sull’insieme dei valori, operazione che abbiamo dovuto eseguire manualmente nella implementazione con le classi case. Queste ultime possono contenere altri campi, come per esempio body nell’implementazione originale, mentre le enumerazioni possono solo contenere oggetti Value costanti insieme ai nomi e agli identificatori associati.

Per i casi in cui vi occorre solo una semplice lista di costanti con un nome o un identificatore numerico usate le enumerazioni, facendo attenzione a seguire gli idiomi propri di questo costrutto. Per insiemi invariabili di oggetti costanti più complessi usate una gerarchia sigillata di case object.

Un confronto tra null e Option

Quando abbiamo presentato Option nella sezione Option, Some e None: evitare i valori nulli del capitolo 2, abbiamo esaminato brevemente il modo in cui vi incoraggia a evitare i riferimenti nulli nel vostro codice, ricordando anche che Tony Hoare, l’inventore del concetto di null nel 1965, li ha definiti come il suo “errore da un miliardo di dollari”. [Hoare2009].

Scala deve supportare null perché null è supportato sia dalla JVM sia da .NET e le librerie esistenti su queste piattaforme lo usano. Di fatto, null viene usato anche da alcune librerie Scala. Ma proviamo a ipotizzare che null non sia disponibile, e cerchiamo di capire come cambierebbe il nostro modo di scrivere codice. La API di Map ci offre alcuni esempi interessanti, a partire da questi due metodi.

trait Map[A,+B] {
  …
  def get(key: A) : Option[B]
  def getOrElse [B2 >: B](key : A, default : => B2) : B2 = …
  …
}

Nel caso in cui una mappa non contenga un valore per una particolare chiave, entrambi questi metodi evitano di restituire null: le implementazioni concrete di get nelle sottoclassi di Map restituiscono None se nessun valore è associato alla chiave, altrimenti restituiscono un’istanza di Some che racchiude il valore. La firma del metodo vi dice che un valore potrebbe non esistere e vi obbliga a gestire con eleganza quella situazione.

val stateCapitals = Map("Alabama" -> "Montgomery", …)
…

stateCapitals.get("Nord Hinterlandia") match {
  case None => println ("Questo stato non esiste!")
  case Some(x) => println(x)
}

Similmente, getOrElse vi obbliga a scrivere codice in maniera difensiva: dovete specificare un valore predefinito da usare quando la chiave non è contenuta nella mappa. Notate che il valore predefinito può effettivamente essere un’istanza di un supertipo relativo al tipo dei valori nella mappa.

println(stateCapitals.getOrElse("Nord Hinterlandia", "Questo stato non esiste!"))

Molte API di Java e .NET possono restituire valori nulli e consentono di usare null come argomento per i metodi, ma è possibile scrivere uno strato di codice Scala che racchiuda queste API e implementi una strategia appropriata per gestire i valori nulli. Come dimostrazione, rivisitiamo l’esempio relativo alla stampa di un file realizzato nella sezione Annotazioni. Riorganizzeremo la classe FilePrinter e il main Java combinandoli in un unico script che risolva due problemi: avvolgeremo LineNumberReader.readLine in un metodo che restituisce Option anziché null e racchiuderemo l’eccezione controllata IOException in una nostra eccezione non controllata che chiameremo ScalaIOException.

// esempi/cap-13/file-printer-refactored-script.scala

import java.io._

class ScalaIOException(cause: Throwable) extends RuntimeException(cause)

class ScalaLineNumberReader(in: Reader) extends LineNumberReader(in) {
  def inputLine() = readLine() match {
    case null => None
    case line => Some(line)
  }
}

object ScalaLineNumberReader {
  def apply(file: File) = try {
     new ScalaLineNumberReader(new FileReader(file))
  } catch {
    case ex: IOException => throw new ScalaIOException(ex)
  }
}

class FilePrinter(val file: File) {
  def print() = {
    val reader = ScalaLineNumberReader(file)
    try {
      loop(reader)
    } finally {
      if (reader != null)
        reader.close
    }
  }

  private def loop(reader: ScalaLineNumberReader): Unit = {
    reader.inputLine() match {
      case None =>
      case Some(line) => {
        format("%3d: %s\n", reader.getLineNumber, line)
        loop(reader)
      }
    }
  }
}

// Elabora gli argomenti a riga di comando (i nomi dei file):
args.foreach { fileName =>
  new FilePrinter(new File(fileName)).print();
}

La classe ScalaLineNumberReader definisce un nuovo metodo inputLine che invoca LineNumber.readLine ed esegue il pattern matching sul risultato: se è null, allora viene restituito None, altrimenti viene restituita la riga racchiusa in un’istanza di Some[String].

ScalaIOException è un’eccezione non controllata, in quanto sottoclasse di RuntimeException, che usiamo per racchiudere qualsiasi IOException venga lanciata in ScalaLineNumberReader.apply.

La classe FilePrinter viene riorganizzata in modo da usare ScalaLineNumberReader.apply nel proprio metodo print e ScalaLineNumberReader.inputLine nel proprio metodo loop. Mentre la versione originale gestiva in maniera appropriata il caso in cui LineNumberReader.readLine restituisse null, ora l’utente di ScalaLineNumberReader non ha altra scelta se non quella di gestire il valore di ritorno None.

Lo script termina con un ciclo sugli argomenti in ingresso, che vengono memorizzati automaticamente nella variabile args. Ogni argomento è trattato come un nome di file da stampare. Lo script stamperà se stesso quando viene eseguito con il seguente comando.

scala file-printer-refactored-script.scala file-printer-refactored-script.scala

Option e le espressioni for

Quando Option viene usata in combinazione con le espressioni for possiamo godere di un altro vantaggio: la rimozione automatica degli elementi None dalle espressioni nella maggior parte dei casi [Pollak2007], [Spiewak2009c]. Considerate questa prima versione di uno script che usa Option in una espressione for.

// esempi/cap-13/option-for-comp-v1-script.scala

case class User(userName: String, name: String, email: String, bio: String)

val newUserProfiles = List(
  Map("userName" -> "twitspam", "name" -> "Twit Spam"),
  Map("userName" -> "bucktrends", "name" -> "Buck Trends",
      "email" -> "[email protected]", "bio" -> "L'oratore più pomposo del mondo"),
  Map("userName" -> "lonelygurl", "name" -> "Lonely Gurl",
      "bio" -> "Chiaramente falso..."),
  Map("userName" -> "deanwampler", "name" -> "Dean Wampler",
      "email" -> "[email protected]", "bio" -> "Passionista di Scala"),
  Map("userName" -> "al3x", "name" -> "Alex Payne",
      "email" -> "[email protected]", "bio" -> "Genio della API di Twitter"))

// Versione n°1

var validUsers = for {
  user     <- newUserProfiles
  if (user.contains("userName") && user.contains("name") &&   // #1
      user.contains("email") && user.contains("bio"))         // #1
  userName <- user get "userName"
  name     <- user get "name"
  email    <- user get "email"
  bio      <- user get "bio" }
    yield User(userName, name, email, bio)

validUsers.foreach (user => println(user))

Immaginate che questo codice venga usato per il sito di qualche social network. I nuovi utenti inseriscono i dati del proprio profilo, che vengono passati in blocco a questo servizio per essere elaborati. Per fare un esempio, abbiamo cablato una lista di profili in cui i dati sono inseriti in una mappa, che potrebbe essere stata copiata da una sessione HTTP.

Il servizio filtra i profili incompleti a cui manca qualche campo tramite le righe di codice commentate con #1, e crea nuovi oggetti utente a partire dai profili completi.

Se eseguite lo script, verranno stampati tre nuovi utenti a partire dai cinque profili elaborati.

User(bucktrends,Buck Trends,[email protected],L'oratore più pomposo del mondo)
User(deanwampler,Dean Wampler,[email protected],Passionista di Scala)
User(al3x,Alex Payne,[email protected],Genio della API di Twitter)

Ora, cancellate le due righe con i commenti #1.

…
var validUsers = for {
  user     <- newUserProfiles
  userName <- user get "userName"
  name     <- user get "name"
  email    <- user get "email"
  bio      <- user get "bio" }
    yield User(userName, name, email, bio)

validUsers.foreach (user => println(user))

Prima di rieseguire lo script potreste aspettarvi di vedere stampate cinque righe con alcuni campi vuoti o contenenti altri tipi di valori. E invece, il risultato stampato è identico al precedente.

Pur essendo privo delle espressioni condizionali esplicite, lo script è riuscito a eseguire il filtraggio che volevamo grazie al modo in cui sono implementate le espressioni for. Ecco un paio di semplici espressioni for seguite dalla loro traduzione secondo la specifica del linguaggio [ScalaSpec2009]. Per prima cosa, esamineremo un semplice generatore con un’istruzione yield.

for (p1 <- e1) yield e2          // espressione for

e1 map ( case p1 => e2 )         // traduzione

Ecco la traduzione di un singolo generatore seguito da un’espressione arbitraria (che potrebbe essere un insieme di più espressioni racchiuse tra parentesi, &c.).

for (p1 <- e1) e2                // espressione for

e1 foreach ( case p1 => e2 )     // traduzione

Con più di un generatore, map viene sostituito da flatMap nelle espressioni yield, ma foreach rimane inalterato.

for (p1 <- e1; p2 <- e2 …) yield eN       // espressione for

e1 flatMap ( case p1 => for (p2 <- e2 …) yield eN )  // traduzione

for (p1 <- e1; p2 <- e2 …) eN             // espressione for

e1 foreach ( case p1 => for (p2 <- e2 …) eN )       // traduzione

Notate che il secondo degli N generatori diventa una espressione for annidata che necessita di essere tradotta.

Esistono traduzioni simili per le istruzioni for condizionali (che diventano invocazioni di filter e assegnamenti a valori val). Non le mostreremo in questa sede, dato che il nostro scopo principale è quello di descrivere un numero di dettagli di implementazione sufficiente a farvi comprendere come lavorano insieme Option e le espressioni for. Ulteriori informazioni si possono trovare in [ScalaSpec2009], corredate di esempi.

Se applicate questo procedimento di traduzione sul nostro esempio, ottenete l’espansione seguente.

var validUsers = newUserProfiles flatMap {
  case user => user.get("userName") flatMap {
    case userName => user.get("name") flatMap {
      case name => user.get("email") flatMap {
        case email => user.get("bio") map {
          case bio => User(name, userName, email, bio)    // #1
        }
      }
    }
  }
}

Notate che flatMap viene invocato fino al caso più interno, dove invece viene usato map (ma in questo caso flatMap e map si comportano allo stesso modo).

Ora possiamo capire perché le espressioni condizionali erano superflue. Ricordatevi che user è un’istanza di Map e che user.get(…) restituisce un’istanza di Option, cioè None oppure Some(valore). La chiave è il comportamento di flatMap definito su Option, che ci permette di trattare i valori di Option come altre collezioni. Ecco la definizione di flatMap.

def flatMap[B](f: A => Option[B]): Option[B] =
  if (isEmpty) None else f(this.get)

Se user.get(…) restituisce None, allora flatMap restituisce semplicemente None e non valuta mai il letterale funzione. Quindi, le iterazioni annidate si fermano e l’esecuzione non arriva mai alla riga segnata con il commento #1, dove avviene la creazione vera e propria di User.

L’invocazione più esterna di flatMap viene effettuata sull’istanza di List in ingresso, newUserProfiles. Su una collezione come questa, che contiene più elementi, flatMap si comporta in modo simile a map, ma “appiattisce” la nuova collezione e quindi, a differenza di map, non deve necessariamente restituire una mappa con lo stesso numero di elementi della collezione originale.

Infine, ricordando quanto detto nella sezione Funzioni parziali del capitolo 8, le istruzioni case user => …, per esempio, inducono il compilatore a generare un’istanza di PartialFunction da passare a flatMap e map, quindi non è necessario racchiuderle all’interno di un blocco nello stile di foo match {…}.

L’uso di Option con le espressioni for elimina la necessità della maggior parte dei controlli effettuati per individuare le collezioni vuote o i valori nulli.

Le eccezioni e le alternative

Se null è l’errore “da un miliardo di dollari”, come abbiamo detto nella sezione Option, Some e None: evitare i valori nulli del capitolo 2, cosa dovremmo pensare delle eccezioni? Si può sostenere che i valori nulli non dovrebbero mai capitare e si possono progettare linguaggi e librerie che non li usano mai. Tuttavia, le eccezioni occupano un posto legittimo nella programmazione, perché separano gli interessi del flusso di esecuzione normale da quelli del flusso di esecuzione “eccezionale”. Questa divisione non è sempre netta: per esempio, se un utente sbaglia a digitare il proprio nome, questo avvenimento è normale o eccezionale?

Un altro problema dalla soluzione non sempre chiara riguarda il punto appropriato in cui le eccezioni dovrebbero essere catturate e gestite. Le eccezioni controllate di Java sono state progettate per indicare all’utente di una API quali eccezioni potrebbero essere lanciate da un metodo, ma questo ha incoraggiato i programmatori a gestire le eccezioni in maniera spesso subottimale. Se un metodo invoca un altro metodo che potrebbe lanciare un’eccezione controllata, il metodo chiamante è obbligato a gestire l’eccezione oppure a dichiarare di lanciare a sua volta quella eccezione. Di solito, il metodo chiamante è il posto sbagliato per gestire l’eccezione: accade comunemente che i metodi si “mangino” un’eccezione che dovrebbe essere rilanciata e gestita in un contesto più appropriato. Per farlo, sarebbe necessario aggiungere dichiarazioni throws ai metodi che compongono una catena di invocazioni, un’operazione che non solo è fastidiosa, ma inquina i contesti intermedi con nomi di eccezioni che spesso non hanno nulla a che vedere con il contesto locale.

Come abbiamo visto, Scala non dispone di eccezioni controllate: qualsiasi eccezione può propagarsi fino al punto in cui è più opportuno gestirla. Tuttavia, è necessaria una certa disciplina nella progettazione per implementare questa gestione nei punti appropriati per tutte le eccezioni dalle quali è possibile riprendere l’esecuzione!

Ogni tanto gli sviluppatori appartenenti alla comunità di un particolare linguaggio partecipano ad accesi dibattiti sui meriti delle eccezioni impiegate come meccanismo di controllo del flusso di esecuzione per la normale elaborazione. A volte questo uso delle eccezioni viene visto come un meccanismo simile all’istruzione longjump o a un goto non locale, utile per uscire da un ambito di visibilità profondamente annidato. Una delle ragioni per cui questo dibattito si riapre è che questo uso delle eccezioni si rivela talvolta più efficiente rispetto a un’implementazione più “convenzionale”.

Per esempio, sarebbe possibile implementare Iterable.foreach in modo da attraversare una collezione alla cieca e fermarsi quando viene catturata una qualsiasi eccezione che indica il superamento della fine della collezione.

Quando si tratta di progettare applicazioni comunicare l’intento è molto importante, ma l’uso delle eccezioni come meccanismo di goto rappresenta una violazione del principio di minima sorpresa: accadrà raramente che il guadagno sulle prestazioni possa giustificare la perdita di chiarezza, quindi vi incoraggiamo a usare le eccezioni solo per i casi davvero “eccezionali”. Notate che Ruby in effetti fornisce un meccanismo simile a un goto non locale: le parole chiave throw e catch sono riservate a questo scopo, mentre per sollevare un’eccezione e gestirla vengono usate raise e rescue.

Qualunque sia la vostra opinione sull’uso appropriato delle eccezioni, agevolerete gli utenti delle vostre API se durante la progettazione minimizzate la possibilità di sollevare un’eccezione. In una strategia per la gestione delle eccezioni, questo è il rovescio della medaglia: come prima regola, evitate di lanciarle. In questo caso, Option può esservi d’aiuto.

Considerate i due metodi di Seq chiamati first e firstOption.

trait Seq[+A] {
  …
  def first : A = …
  def firstOption : Option[A] = …
  …
}

Il metodo first lancia una Predef.UnsupportedOperationException se la sequenza è vuota. Restituire null in questo caso non è possibile, perché la sequenza potrebbe contenere elementi uguali a null! Al contrario, il metodo firstOption restituisce un’istanza di Option, quindi nel caso in cui la sequenza sia vuota restituisce il valore None, che non ha problemi di ambiguità.

Si può sostenere che la API di Seq sarebbe più robusta se solo avesse un metodo first che restituisce un’istanza di Option. Durante la progettazione è utile cercare di evitare all’utente qualsiasi tipo di errore, ma quando gli “errori” non possono essere prevenuti è consigliabile usare Option o un costrutto simile per indicare all’utente la possibilità di ritrovarsi in una condizione di errore. Ragionando in termini di trasformazioni di stato valide, il metodo first, pur conveniente, non rappresenta una transizione valida per una sequenza che si trova nello stato in cui è vuota. La decisione di eliminare il metodo first per questa ragione sarebbe probabilmente troppo drastica, ma restituendo Option dal metodo firstOption la API comunica all’utente che esistono circostanze nelle quali il metodo non può soddisfare la richiesta ed è compito dell’utente riprendere il flusso di esecuzione normale. In questo senso, firstOption considera l’assenza di elementi in una sequenza come una situazione non eccezionale.

Se ricordate, abbiamo visto un compromesso di questo tipo in un altro esempio della sezione Option, Some e None: evitare i valori nulli nel capitolo 2, esaminando due metodi di Option che servono a recuperare il valore racchiuso in un’istanza di Some. Il metodo get lancia un’eccezione se non è presente alcun valore, cioè se l’istanza di Option è un’istanza di None; l’altro metodo, getOrElse, accetta come secondo argomento un valore predefinito da restituire se l’istanza di Option è un’istanza di None, e in questo caso non viene lanciata nessuna eccezione.

Naturalmente, è impossibile evitare tutte le eccezioni. In parte, l’intento originale nella separazione tra eccezioni controllate e non controllate era quello di distinguere i problemi potenzialmente risolvibili da quelli catastrofici come gli errori dovuti alla mancanza di memoria. Tuttavia, i metodi alternativi di Seq e Option mostrano come “incoraggiare” l’utente di una API a considerare le conseguenze di un possibile errore, per esempio nel tentativo di accedere al primo elemento di una sequenza vuota, permettendogli di specificare le azioni contingenti da intraprendere nel caso in cui l’errore si verifichi. Minimizzare la possibilità di generare eccezioni renderà più robuste le vostre librerie Scala e le applicazioni che ne fanno uso.

Astrazioni scalabili

Per qualche tempo uno degli scopi della nostra industria è stato quello di creare componenti riusabili. Sfortunatamente, non c’è mai stato molto accordo sul significato del termine componente né su quello del termine correlato modulo (che qualcuno considera sinonimo di componente). Di solito le definizioni proposte partono da assunzioni diverse relativamente alla piattaforma, alla granularità, agli scenari di configurazione e di produzione, alle questioni che riguardano l’assegnamento dei numeri di versione, &c. [Szyperski1998].

In questa sede eviteremo tali discussioni e useremo il termine componente in maniera informale per fare riferimento a un gruppo di tipi e di package che espone astrazioni coerenti (preferibilmente una sola) per i servizi offerti, ha un insieme minimo di dipendenze nei confronti di altri componenti ed è internamente coesivo.

Tutti i linguaggi offrono meccanismi per definire componenti, almeno in qualche misura. Gli oggetti sono il meccanismo di incapsulamento principale nei linguaggi orientati agli oggetti, ma da soli non sono sufficienti a causa della loro naturale tendenza a raggrupparsi in aggregati di dimensioni maggiori, soprattutto man mano che le applicazioni si evolvono. Parlando per sommi capi, un oggetto non è necessariamente un componente e un componente può contenere molti oggetti. Scala e Java usano i package come costrutto per aggregare i tipi; i moduli di Ruby servono a uno scopo simile, al pari degli spazi di nomi in C# e C++.

In ogni caso, questi meccanismi di aggregazione soffrono ancora di alcuni limiti. Un problema comune è la mancanza di una definizione chiara di cosa sia pubblicamente visibile al di fuori dei confini del componente e cosa sia invece interno al componente. Per esempio, in Java qualsiasi tipo pubblico o metodo pubblico in un tipo pubblico è visibile a tutti gli altri componenti esterni al package di definizione. I tipi e i metodi si possono rendere “privati per il package”, ma così diventerebbero invisibili agli altri package incapsulati nel componente. Java non possiede una nozione chiara di confine di un componente.

Scala dispone di un certo numero di meccanismi che migliorano questa situazione, molti dei quali sono già stati esaminati nel corso di questo libro.

Regole di visibilità a grana fine

Nella sezione Regole di visibilità del capitolo 5 abbiamo visto che Scala offre regole di visibilità a grana più fine rispetto alla maggior parte degli altri linguaggi, tramite le quali potete controllare la visibilità dei tipi e dei metodi al di fuori dei confini del tipo e del package.

Considerate l’esempio di un componente definito nel package encodestring, come mostrato di seguito.

// esempi/cap-13/encoded-string.scala

package encodedstring {

  trait EncodedString {
    protected[encodedstring] val string: String
    val separator: EncodedString.Separator.Delimiter

    override def toString = string

    def toTokens = string.split(separator.toString).toList
  }

  object EncodedString {
    object Separator extends Enumeration {
      type Delimiter = Value
      val COMMA = Value(",")
      val TAB   = Value("\t")
    }

    def apply(s: String, sep: Separator.Delimiter) = sep match {
      case Separator.COMMA => impl.CSV(s)
      case Separator.TAB   => impl.TSV(s)
    }

    def unapply(es: EncodedString) = Some(Pair(es.string, es.separator))
  }

  package impl {
    private[encodedstring] case class CSV(override val string: String)
        extends EncodedString {
      override val separator = EncodedString.Separator.COMMA
    }

    private[encodedstring] case class TSV(override val string: String)
        extends EncodedString {
      override val separator = EncodedString.Separator.TAB
    }
  }
}

Il componente in questo esempio incapsula la gestione delle stringhe che codificano valori separati da virgole (CSV, dall’inglese comma-separated values) o da tabulazioni (TSV). Il package encodestring espone un tratto EncodedString visibile ai clienti, mentre le classi concrete che implementano i CSV e i TSV sono dichiarate come private[encodestring] nel package encodestring.impl. Il tratto definisce due campi val astratti: string, per memorizzare la stringa codificata, protetta dall’accesso da parte dei clienti, e separator, per mememorizzare il separatore (per esempio una virgola). Se ricordate quanto è stato detto nel capitolo 6, i campi astratti, al pari dei metodi e dei tipi astratti, devono essere inizializzati nelle istanze concrete. In questo caso, il valore di string verrà definito tramite un costruttore concreto e il valore di separator viene impostato esplicitamente nelle classi concrete CSV e TSV.

Il metodo toString di EncodedString stampa la stringa nella sua forma originale. Nascondendo il valore di string e le classi concrete, siamo completamente liberi di scegliere come memorizzare effettivamente la stringa. Per esempio, potremmo voler suddividere le stringhe di grandi dimensioni sulla base del delimitatore e memorizzare le sottostringhe in una struttura dati. Questo ci permetterebbe di risparmiare spazio se le stringhe fossero abbastanza lunghe e potessimo condividere le sottostringhe contenute in più di una stringa. Inoltre, potremmo scoprire che questa strategia di memorizzazione è utile per varie operazioni di ricerca, ordinamento, e altri tipi di manipolazione. Tutte queste questioni di implementazione sono trasparenti per il cliente.

Il package espone anche un oggetto che contiene un’enumerazione dei separatori noti, un metodo di costruzione apply per creare nuove stringhe codificate e un metodo di decomposizione unapply per estrarre dagli oggetti EncodedString la stringa che racchiudono e il delimitatore. In questo caso, l’implementazione del metodo unapply sembra banale, ma se memorizzassimo le stringhe in modo diverso questo metodo potrebbe ricostruire la stringa originale in maniera trasparente.

Così, i clienti di questo componente conoscono solo l’astrazione EncodedString e l’enumerazione che rappresenta i tipi supportati di stringhe codificate, mentre tutti i tipi e i dettagli dell’implementazione reale sono privati per il package encodedstring.2 Perciò, il confine tra le astrazioni esposte e i dettagli dell’implementazione interna è chiaro.

Lo script riportato di seguito mostra il componente in azione.

// esempi/cap-13/encoded-string-script.scala

import encodedstring._
import encodedstring.EncodedString._

def p(s: EncodedString) = {
  println("EncodedString: " + s)
  s.toTokens foreach (x => println("parola: " + x))
}

val csv = EncodedString("Scala,è,fantastico!", Separator.COMMA)
val tsv = EncodedString("Scala\tè\tfantastico!", Separator.TAB)

p(csv)
p(tsv)

println("\nEstrazione:")
List(csv, "ProgrammareInScala", tsv, 3.14159) foreach {
  case EncodedString(str, delim) =>
    println("EncodedString: \"" + str + "\", delimitatore: \"" + delim + "\"")
  case s: String => println "String: " + s)
  case x => println("Valore sconosciuto: " + x)
}

L’esecuzione di questo script produce l’uscita seguente.

EncodedString: Scala,è,fantastico!
parola: Scala
parola: è
parola: fantastico!
EncodedString: Scala    è      fantastico!
parola: Scala
parola: è
parola: fantastico!

Estrazione:
EncodedString: "Scala,è,fantastico!", delimitatore: ","
String: ProgrammareInScala
EncodedString: "Scala   è      fantastico!", delimitatore: "   "
Valore sconosciuto: 3.14159

Tuttavia, se per esempio proviamo a usare la classe CSV direttamente, otteniamo l’errore seguente.

scala> import encodedstring._
import encodedstring._

scala> val csv = impl.CSV("valori,separati,da,virgole")
<console>:6: error: object CSV cannot be accessed in package encodedstring.impl
       val csv = impl.CSV("valori,separati,da,virgole")
                      ^

scala>

In questo semplice esempio non era essenziale rendere i tipi concreti privati per il componente, ma così facendo abbiamo reso davvero minimale l’interfaccia esposta ai clienti, e ci siamo garantiti la libertà di modificare l’implementazione nel modo più opportuno senza quasi correre il rischio di costringere il cliente ad alterare il proprio codice. Una causa comune della paralisi nella manutenzione di applicazioni mature è la presenza di un numero eccessivo di dipendenze tra i tipi concreti, che diventano difficili da modificare poiché le dipendenze obbligano il cliente a modificare il proprio codice. Quindi, per componenti più grandi e sofisticati, questa chiara separazione tra astrazione e implementazione può conservare a lungo la manutenibilità e la riusabilità del codice.

La composizione dei mixin

Nel capitolo 4 abbiamo visto il modo in cui i tratti favoriscono la composizione dei mixin. Una classe può concentrarsi sul proprio dominio principale e le altre responsabilità possono essere implementate separatamente nei tratti. Poi, al momento di costruire le istanze, è possibile combinare classi e tratti per comporre l’intero insieme di comportamenti richiesti.

Per esempio, nella sezione Ridefinire i tipi astratti del capitolo 6 abbiamo esaminato la nostra seconda versione del pattern Observer.

// esempi/cap-6/observer/observer2.scala

package observer

trait AbstractSubject {
  type Observer

  private var observers = List[Observer]()
  def addObserver(observer:Observer) = observers ::= observer
  def notifyObservers = observers foreach (notify(_))

  def notify(observer: Observer): Unit
}

trait SubjectForReceiveUpdateObservers extends AbstractSubject {
  type Observer = { def receiveUpdate(subject: Any) }

  def notify(observer: Observer): Unit = observer.receiveUpdate(this)
}

trait SubjectForFunctionalObservers extends AbstractSubject {
  type Observer = (AbstractSubject) => Unit

  def notify(observer: Observer): Unit = observer(this)
}

Abbiamo usato questa versione per osservare i “clic” sui pulsanti in una interfaccia grafica. Rivisitiamo questa implementazione e oltrepassiamone alcuni limiti usando il nostro prossimo strumento per creare astrazioni scalabili, le annotazioni self-type combinate con i membri tipo astratti.

Annotazioni self-type e membri tipo astratti

L’implementazione di AbstractSubject nella nostra seconda versione del pattern Observer ci lascia insoddisfatti solo sotto pochi aspetti. Il primo problema si trova in SubjectForReceiveUpdateObservers, dove il tipo Observer è definito come il tipo strutturale { def receiveUpdate(subject: Any) }: qui sarebbe meglio restringere il tipo di subject a un valore più specifico rispetto ad Any.

Il secondo problema, che in realtà è uguale al primo ma si presenta in una forma differente, si trova in SubjectForFunctionalObservers, dove il tipo Observer è definito come il tipo (AbstractSubject) => Unit: qui ci piacerebbe che il tipo dell’argomento della funzione fosse più specifico di AbstractSubject. In precedenza questo difetto non risultava così evidente, forse perché nel nostro semplice esempio l’osservatore non ha mai avuto bisogno di accedere ai metodi o allo stato di Button.

In effetti, ci aspettiamo che i tipi effettivi del soggetto e dell’osservatore vengano specializzati in maniera covariante. Per esempio, quando stiamo osservando un pulsante, ci aspettiamo che i nostri osservatori siano specializzati per Button, in modo da poter accedere allo stato e ai metodi delle istanze di Button. Questa specializzazione covariante viene talvolta chiamata polimorfismo familiare [Odersky2005]. Ora correggeremo il nostro codice per supportare questa covarianza.

Per semplificare l’esempio, concentriamoci solo sulla forma receiveUpdate dell’osservatore, che in precedenza avevamo implementato con SubjectForReceiveUpdateObservers. Ecco una rielaborazione del nostro pattern che segue in maniera approssimativa un esempio contenuto in [Odersky2005]. Notate che, dal momento in cui l’articolo è stato scritto, la sintassi di Scala è stata modificata.

// esempi/cap-13/observer3-wont-compile.scala
// Non verrà compilato!

package observer

abstract class SubjectObserver {
  type S <: Subject
  type O <: Observer

  trait Subject {
    private var observers = List[O]()
    def addObserver(observer: O) = observers ::= observer
    def notifyObservers = observers foreach (_.receiveUpdate(this)) // ERRORE
  }

  trait Observer {
    def receiveUpdate(subject: S)
  }
}

Spiegheremo l’errore fra un minuto, ma per ora notate la dichiarazione dei tipi S e O. Come abbiamo visto nella sezione Capire i tipi parametrici del capitolo 12, l’espressione type S <: Subject definisce un tipo astratto S i cui tipi concreti permessi saranno solo i sottotipi di Subject. La dichiarazione di O è simile. Per essere chiari, S e O sono “segnaposti” a questo punto, mentre Subject e Observer sono tratti astratti definiti in SubjectObserver.

Tra l’altro, la scelta di dichiarare SubjectObserver come una classe astratta o come un tratto astratto è piuttosto arbitraria. Fra breve ne deriveremo oggetti concreti. SubjectObserver ci occorre principalmente per avere un tipo che “conservi” i nostri membri tipo astratti S e O.

In ogni caso, se provate a compilare questo codice nel modo in cui è scritto, ottenete l’errore seguente.

… 10: error: type mismatch;
 found   : SubjectObserver.this.Subject
 required: SubjectObserver.this.S
      def notifyObservers = observers foreach (_.receiveUpdate(this))
                                                               ^
one error found

Nel tratto Observer annidato, receiveUpdate si aspetta un’istanza di tipo S, ma qui gli viene passato this, che è di tipo Subject; in altre parole, gli viene passata un’istanza di un tipo genitore del tipo atteso. Si potrebbe risolvere il problema modificando la firma per fare in modo che il tipo atteso sia il tipo genitore Subject, ma questo non è ciò che desideriamo. Come abbiamo appena detto, i nostri osservatori concreti hanno bisogno del tipo più specifico, cioè l’effettivo tipo concreto che infine definiremo per S, in modo che possano invocare metodi su di esso: per esempio, gli osservatori che sorvegliano le caselle di controllo in un’interfaccia grafica vorranno sapere se la casella è stata spuntata o meno, senza essere obbligati a usare conversioni di tipo poco sicure.

Abbiamo esaminato la composizione tramite annotazioni self-type nel capitolo 12, e ora useremo quella funzionalità per risolvere il nostro attuale problema di compilazione. Ecco ancora lo stesso codice, ma questa volta con una annotazione self-type.

// esempi/cap-13/observer3.scala

package observer

abstract class SubjectObserver {
  type S <: Subject
  type O <: Observer

  trait Subject {
    self: S =>  // #1
    private var observers = List[O]()
    def addObserver(observer: O) = observers ::= observer
    def notifyObservers = observers foreach (_.receiveUpdate(self))  // #2
  }

  trait Observer {
    def receiveUpdate(subject: S)
  }
}

Il commento #1 mostra l’annotazione self-type self: S =>. Ora possiamo usare self come alias di this, ma ogni volta che appare si presumerà che il suo tipo sia S anziché Subject. È come se stessimo dicendo a Subject di impersonare un altro tipo, ma in modo type-safe, come vedremo.

In effetti, avremmo potuto scrivere this anziché self nell’annotazione, ma l’uso di self è una convenzione abbastanza radicata. Un nome differente ci ricorda anche che stiamo lavorando con un tipo differente.

Le annotazioni self-type sono una funzionalità sicura da usare? Nell’atto di definire un sottotipo concreto di SubjectObserver, S e O verranno specificati e verrà effettuato un controllo di tipo per garantire che i tipi concreti di S e O siano compatibili con Subject e Observer. In questo caso, avendo anche definito S come sottotipo di Subject e O come sottotipo di Observer, una qualsiasi coppia di tipi derivati rispettivamente da Subject e Observer andrà bene.

Il commento #2 mostra il passaggio di self a receiveUpdate al posto di this.

Ora che abbiamo una implementazione generica del pattern, possiamo specializzarla per osservare i clic sui pulsanti.

// esempi/cap-13/button-observer3.scala

package ui
import observer._

object ButtonSubjectObserver extends SubjectObserver {
  type S = ObservableButton
  type O = ButtonObserver

  class ObservableButton(name: String) extends Button(name) with Subject {
    override def click() = {
      super.click()
      notifyObservers
    }
  }

  trait ButtonObserver extends Observer {
    def receiveUpdate(button: ObservableButton)
  }
}

Nella dichiarazione dell’oggetto ButtonSubjectObserver, a S e O vengono rispettivamente assegnati ObservableButton e ButtonObserver, entrambi definiti nell’oggetto. Stiamo usando un object ora, in modo da poter fare riferimento facilmente ai tipi annidati, come vedremo fra breve.

ObservableButton è una classe concreta che ridefinisce click per avvisare gli osservatori, in maniera simile a quanto accadeva nella precedente implementazione vista nel capitolo 4. Tuttavia, ButtonObserver è ancora un tratto astratto, perché receiveUpdate non è definito. Notate che ora il parametro di receiveUpdate è di tipo ObservableButton, che è il valore assegnato a S.

L’ultimo pezzo del puzzle è la definizione di un osservatore concreto, che useremo, come prima, per contare i clic sul pulsante. Tuttavia, per sottolineare quanto sia preziosa la possibilità di passare all’osservatore un tipo specifico di istanza (ObservableButton in questo caso), perfezioneremo l’osservatore in modo che tenga traccia dei clic su più pulsanti usando una mappa le cui chiavi siano le etichette dei pulsanti. Non ci sarà bisogno di ricorrere ad alcuna conversione di tipo.

// esempi/cap-13/button-click-observer3.scala

package ui
import observer._

class ButtonClickObserver extends ButtonSubjectObserver.ButtonObserver {
  val clicks = new scala.collection.mutable.HashMap[String,Int]()

  def receiveUpdate(button: ButtonSubjectObserver.ObservableButton) = {
    val count = clicks.getOrElse(button.label, 0) + 1
    clicks.update(button.label, count)
  }
}

Ogni volta che ButtonClickObserver.receiveUpdate viene invocato, preleva il conto attuale per il pulsante, se esiste, e aggiorna la mappa incrementandolo. Notate che ora è impossibile invocare receiveUpdate con una normale istanza di Button: dobbiamo usare un’istanza di ObservableButton. Questa restrizione elimina quei potenziali errori che impediscono la ricezione delle notifiche attese. Inoltre, viene garantito l’accesso a tutte le funzioni “migliorate” di cui ObservableButton potrebbe disporre.

Infine, ecco una specifica per collaudare il codice.

// esempi/cap-13/button-observer3-spec.scala

package ui
import org.specs._
import observer._

object ButtonObserver3Spec extends Specification {
  "Un osservatore che conta i clic su un pulsante" should {
    "vedere tutti i clic" in {
      val button1 = new ButtonSubjectObserver.ObservableButton("pulsante1")
      val button2 = new ButtonSubjectObserver.ObservableButton("pulsante2")
      val button3 = new ButtonSubjectObserver.ObservableButton("pulsante3")
      val buttonObserver = new ButtonClickObserver
      button1.addObserver(buttonObserver)
      button2.addObserver(buttonObserver)
      button3.addObserver(buttonObserver)
      clickButton(button1, 1)
      clickButton(button2, 2)
      clickButton(button3, 3)
      buttonObserver.clicks("pulsante1") mustEqual 1
      buttonObserver.clicks("pulsante2") mustEqual 2
      buttonObserver.clicks("pulsante3") mustEqual 3
    }
  }

  def clickButton(button: Button, nClicks: Int) =
    for (i <- 1 to nClicks)
      button.click()
}

Abbiamo creato tre pulsanti e un osservatore per tutti e tre. Poi effettuiamo un numero differente di clic su ogni pulsante e infine verifichiamo che i clic per ognuno siano stati correttamente conteggiati.

Ancora una volta, possiamo constatare che i tipi astratti, combinati con le annotazioni self-type, offrono astrazioni riusabili facili da estendere in modo type-safe per necessità particolari. Anche se abbiamo definito un protocollo generale per osservare un “evento” dopo che è accaduto, siamo stati in grado di definire sottotipi specifici di Button senza ricorrere a conversioni di tipo poco sicure a partire dalle astrazioni Subject.

Lo stesso compilatore Scala è implementato usando questo meccanismo [Odersky2005] per renderlo modulare in modo che, per esempio, sia relativamente facile implementare i plug-in.

Rivisiteremo questi idiomi nella sezione L’iniezione di dipendenza in Scala: il pattern Cake più avanti.

La progettazione efficace dei tratti

I problemi causati dall’ereditarietà multipla in C++ costituiscono uno dei motivi per cui questa caratteristica è stata esclusa da molti linguaggi, tra cui Java. Uno di questi problemi è rappresentato dal cosiddetto diamante della morte, visibile nella figura 13.1.

Figura 13.1. Il diamante della morte nei linguaggi con ereditarietà multipla.

In C++, ogni costruttore di C invocherà un costruttore di B1 e un costruttore di B2 (in maniera esplicita o implicita). Ogni costruttore di B1, al pari di ogni costruttore di B2, invocherà un costruttore di A. Quindi, in un’implementazione ingenua dell’ereditarietà multipla, i campi a1 e a2 di A potrebbero venire inizializzati due volte e perfino in modo incoerente, oppure l’istanza di C potrebbe contenere due “pezzi” A differenti, uno per B1 e uno per B2. Il C++ è dotato di meccanismi che chiarificano cosa dovrebbe succedere, ma tocca allo sviluppatore capirne i dettagli e usarli nella maniera corretta.

L’ereditarietà singola di Scala e il suo supporto per i tratti evitano questi problemi, pur conservando il beneficio più importante dell’ereditarietà multipla, cioè la composizione dei mixin. L’ordine di costruzione non è ambiguo (si veda la sezione Linearizzare la gerarchia di un oggetto nel capitolo 7) e i tratti non possono avere liste di argomenti per il costruttore, ma Scala si assicura che i loro campi siano inizializzati in maniera appropriata quando le istanze vengono create, come abbiamo visto nella sezione Costruire i tratti del capitolo 4 e nella sezione Ridefinire i campi astratti e concreti nei tratti del capitolo 6. Abbiamo visto un altro esempio di inizializzazione dei valori val in un tratto nella sezione Regole di visibilità a grana fine più indietro, dove alcune classi concrete ridefinivano i due campi astratti del tratto EncodedString.

Quindi, Scala è in grado di gestire molti problemi che possono sorgere quando si mescolano i contributi di tratti diversi nell’insieme dei possibili stati di un’istanza. Rimane comunque importante esaminare il modo in cui questi contributi interagiscono tra loro.

Nel considerarne lo stato, è utile immaginare un’istanza come una macchina a stati in cui un evento (per esempio, l’invocazione di un metodo e la scrittura di un campo) causa la transizione da uno stato a un altro. L’insieme di tutti gli stati possibili forma uno spazio. Potete pensare che ogni campo rappresenti una dimensione di questo spazio.

Prendiamo come esempio il tratto VetoableClicks usato nella sezione Tratti impilabili del capitolo 4, dove i clic su un pulsante venivano rifiutati dopo che il loro numero aveva raggiunto una certa soglia. La nostra semplice classe Button contribuiva allo spazio degli stati solo con una dimensione label, mentre VetoableClicks contribuiva con una dimensione count e una costante maxAllowed. Ecco uno script che raccoglie questi tipi ed effettua una breve prova del codice.

// esempi/cap-13/vetoable-clicks1-script.scala

trait Clickable {
  def click()
}

class Widget
class Button(val label: String) extends Widget with Clickable {
  def click() = println("clic!")
}

trait VetoableClicks extends Clickable {
  val maxAllowed = 1
  private var count = 0
  abstract override def click() = {
    if (count < maxAllowed) {
      count += 1
      super.click()
    }
  }
}

val button1 = new Button("cliccami!")
println("new Button(...)")
for (i <- 1 to 3 ) button1.click()

val button2 = new Button("cliccami!") with VetoableClicks
println("new Button(...) with VetoableClicks")
for (i <- 1 to 3 ) button2.click()

Questo script stampa il testo seguente.

new Button(...)
clic!
clic!
clic!
new Button(...) with VetoableClicks
clic!

Notate che maxAllowed è una costante, ma può essere ridefinita nell’atto di creare una qualsiasi istanza. Quindi, due istanze possono differire solo nel valore di maxAllowed, perciò anche maxAllowed rappresenta una dimensione dello stato, ma con un solo valore per ogni istanza.

Quindi, per un pulsante etichettato “Invia”, con maxAllowed impostato a 3, e su cui sono stati effettuati due clic (in modo che count sia uguale a 2), lo stato può essere rappresentato dalla tupla ("Invia", 2, 3).

In generale, un singolo tratto può essere privo di stato, cioè senza dimensioni da aggiungere allo stato dell’istanza, o può portare come contributo dimensioni ortogonali che sono indipendenti da quelle che provengono da altri tratti e dalla classe genitore. Nello script, Clickable è chiaramente privo di stato (se ignoriamo l’etichetta del pulsante) mentre VetoableClicks contribuisce con maxAllowed e count. I tratti con stato ortogonale spesso contengono anche metodi ortogonali: per esempio, i tratti del pattern Observer che abbiamo usato nel capitolo 4 disponevano di metodi per gestire la propria lista di osservatori.

Indipendentemente dal fatto di aggiungere o meno dimensioni allo stato, un tratto può anche modificare i valori possibili per una dimensione proveniente da un altro tratto o dalla classe genitore. Per vedere un esempio, riorganizziamo lo script in modo da spostare count nel tratto Clickable.

// esempi/cap-13/vetoable-clicks2-script.scala

trait Clickable {
  private var clicks = 0
  def count = clicks

  def click() = { clicks += 1 }
}

class Widget
class Button(val label: String) extends Widget with Clickable {
  override def click() = {
    super.click()
    println("clic!")
  }
}

trait VetoableClicks extends Clickable {
  val maxAllowed = 1
  abstract override def click() = {
    if (count < maxAllowed)
      super.click()
  }
}

val button1 = new Button("cliccami!")
println("new Button(...)")
for (i <- 1 to 3 ) button1.click()

val button2 = new Button("cliccami!") with VetoableClicks
println("new Button(...) with VetoableClicks")
for (i <- 1 to 3 ) button2.click()

Questo script stampa la stesso testo del precedente, ma ora Clickable aggiunge una dimensione per count (che qui è un metodo usato per restituire il valore del campo privato clicks). VetoableClicks modifica questa dimensione riducendo l’intervallo infinito di possibili valori positivi per count ai soli 0 e 1. Possiamo dire che un tratto è invasivo quando influenza il comportamento di un altro tratto, come nel caso di VetoableClicks, che modifica il comportamento di altri mixin.

Questi ragionamenti sono importanti perché, sebbene i problemi derivanti dall’ereditarietà multipla siano assenti nel modello a ereditarietà singola e tratti di Scala, è necessario fare attenzione quando si mescolano i contributi in termini di stato e comportamento se si vogliono creare applicazioni prive di malfunzionamenti. Per esempio, un’istanza di Button with VetoableClicks non passerà la stessa serie di test che Button passa con successo se i test presumono che voi possiate cliccare sui pulsanti tutte le volte che volete, in quanto questi due tipi di pulsanti hanno “specifiche” differenti. Questa differenza viene espressa dal principio di sostituzione di Liskov [Martin2003]: un’istanza di Button with VetoableClicks non sarà sostituibile in tutte le situazioni in cui viene usata una semplice istanza di Button. Questa è una conseguenza della natura invasiva di VetoableClicks.

I tratti che aggiungono solo elementi ortogonali senza influenzare il resto dello stato e del comportamento dell’istanza rendono molto più semplici il riuso e la composizione e riducono le possibilità di errore. Le implementazioni del pattern Observer che abbiamo visto sono piuttosto riusabili: l’unico requisito per poterle riutilizzare consiste nel fornire un po’ di “colla” per adattare i tratti generici del soggetto e dell’osservatore alle particolari circostanze.

Questo non significa che i mixin invasivi siano da evitare, ma solo che dovrebbero essere usati a ragion veduta. Il pattern degli “eventi vietabili” può rivelarsi molto utile.

I pattern di progettazione

Ultimamente i pattern di progettazione sono stati criticati da chi vorrebbe accantonarli perché li considera un surrogato delle caratteristiche mancanti di un linguaggio. In effetti, alcuni dei pattern catalogati dalla Gang of Four [GOF1995] non sono davvero necessari in Scala, dato che le caratteristiche native offrono soluzioni migliori, e altri pattern sono parte integrante del linguaggio, quindi non è necessario organizzare il codice in un modo particolare. Naturalmente, non è colpa dei pattern se di loro si fa spesso un cattivo uso.

Riteniamo che le critiche abbiano spesso trascurato un punto importante: la distinzione tra un’idea e il modo in cui viene implementata e usata in una certa situazione. I pattern di progettazione descrivono idee ricorrenti e assai utili che fanno parte del vocabolario usato dagli sviluppatori di software per illustrare i loro progetti.

In Scala alcuni pattern comuni corrispondono a caratteristiche native del linguaggio, come accade per gli oggetti singleton, che eliminano il bisogno di implementare il pattern Singleton [GOF1995] nello stile a cui fanno spesso ricorso gli sviluppatori Java.

Il pattern Iterator [GOF1995] è talmente diffuso che la maggior parte dei linguaggi di programmazione include un meccanismo di iterazione per qualsiasi tipo possa essere trattato come una collezione. Per esempio, in Scala potete scorrere i caratteri di una stringa usando il metodo foreach di String.

"Programmare in Scala" foreach {c => println(c)}

In effetti, in questo caso viene invocata una conversione implicita per convertire l’istanza di java.lang.String in un’istanza di RichString dotata del metodo foreach. Questo è un esempio del pattern chiamato Pimp my library che abbiamo menzionato nella sezione Conversioni implicite del capitolo 8.

Scala dispone di alternative migliori ad altri pattern comuni. Ne esamineremo una per il pattern Visitor [GOF1995] fra un momento.

Infine, altri pattern ancora possono essere implementati in Scala e la loro utilità resta indiscutibile. Per esempio, il pattern Observer che abbiamo esaminato in precedenza sia in questo capitolo sia nel capitolo 4 è un pattern molto comodo per risolvere numerosi problemi di progettazione e può essere implementato in maniera molto elegante usando la composizione dei mixin.

Eviteremo di esaminare tutti i pattern più noti, come quelli catalogati in [GOF1995], una parte dei quali vengono analizzati in [ScalaWiki:Patterns] insieme ad altri pattern piuttosto specifici per Scala. Invece esamineremo alcuni esempi degni di nota, cominciando da un’alternativa al pattern Visitor che sfrutta idiomi funzionali e conversioni implicite, e concludendo con l’analisi di una potente tecnica per implementare la iniezione di dipendenza in Scala attraverso il pattern Cake.

Il pattern Visitor: un’alternativa migliore

Il pattern Visitor [GOF1995] risolve il problema di aggiungere una nuova operazione a una gerarchia di classi senza modificarne il codice sorgente nel caso in cui, per un certo numero di ragioni pratiche, queste modifiche non siano possibili o desiderabili.

Esaminiamo un esempio di questo pattern usando la gerarchia di classi Shape già vista in precedenza. Cominceremo con la versione a classi case illustrata nella sezione Classi case del capitolo 6.

// esempi/cap-6/shapes/shapes-case.scala

package shapes {
  case class Point(x: Double, y: Double)

  abstract class Shape() {
    def draw(): Unit
  }

  case class Circle(center: Point, radius: Double) extends Shape() {
    def draw() = println("Circle.draw: " + this)
  }

  case class Rectangle(lowerLeft: Point, height: Double, width: Double)
      extends Shape() {
    def draw() = println("Rectangle.draw: " + this)
  }

  case class Triangle(point1: Point, point2: Point, point3: Point)
      extends Shape() {
    def draw() = println("Triangle.draw: " + this)
  }
}

Potreste desiderare che il metodo draw non sia presente nelle classi: questa è una decisione di progetto ragionevole, poiché il metodo di disegno dipenderà notevolmente dal particolare contesto d’uso, che per esempio comprende i dettagli delle librerie grafiche della piattaforma sulla quale l’applicazione verrà eseguita. Per garantire un’elevata riusabilità, vorremmo che il disegno fosse un’operazione disaccoppiata dalle forme geometriche.

Prima di tutto, riorganizziamo la gerarchia di Shape per supportare il pattern Visitor, seguendo l’esempio contenuto in [GOF1995].

// esempi/cap-13/shapes-visitor.scala

package shapes {
  trait ShapeVisitor {
    def visit(circle: Circle): Unit
    def visit(rect: Rectangle): Unit
    def visit(tri: Triangle): Unit
  }

  case class Point(x: Double, y: Double)

  sealed abstract class Shape() {
    def accept(visitor: ShapeVisitor): Unit
  }

  case class Circle(center: Point, radius: Double) extends Shape() {
    def accept(visitor: ShapeVisitor) = visitor.visit(this)
  }

  case class Rectangle(lowerLeft: Point, height: Double, width: Double)
        extends Shape() {
    def accept(visitor: ShapeVisitor) = visitor.visit(this)
  }

  case class Triangle(point1: Point, point2: Point, point3: Point)
        extends Shape() {
    def accept(visitor: ShapeVisitor) = visitor.visit(this)
  }
}

Abbiamo definito un tratto ShapeVisitor dotato di un metodo per ogni classe concreta nella gerarchia, per esempio visit(circle: Circle), che accetta un parametro del corrispondente tipo da visitare. Le classi concrete derivate implementeranno ognuno di questi metodi per effettuare l’operazione appropriata sull’oggetto di quel particolare tipo passato come argomento.

Il pattern richiede una singola modifica alla gerarchia delle classi: è necessario aggiungere un metodo chiamato accept, che prende un Visitor come parametro. Questo metodo deve essere ridefinito in ogni classe in modo da invocare il metodo corrispondente definito nell’istanza del visitatore, passandogli this come argomento.

Infine, notate che abbiamo dichiarato Shape come sealed. Questa scelta non ci aiuterà a evitare alcuni errori nella implementazione del pattern, ma si dimostrerà utile fra breve.

Ecco un visitatore concreto che supporta la nostra operazione draw originale.

// esempi/cap-13/shapes-drawing-visitor.scala

package shapes {
  class ShapeDrawingVisitor extends ShapeVisitor {
    def visit(circle: Circle): Unit =
      println("Circle.draw: " + circle)

    def visit(rect: Rectangle): Unit =
      println("Rectangle.draw: " + rect)

    def visit(tri: Triangle): Unit =
      println("Triangle.draw: " + tri)
  }
}

Per ogni metodo visit, la classe “disegna” l’istanza di Shape in maniera appropriata. Infine, ecco uno script che mette in azione il codice.

// esempi/cap-13/shapes-drawing-visitor-script.scala

import shapes._

val p00 = Point(0.0, 0.0)
val p10 = Point(1.0, 0.0)
val p01 = Point(0.0, 1.0)

val list = List(Circle(p00, 5.0),
                Rectangle(p00, 2.0, 3.0),
                Triangle(p00, p10, p01))

val shapesDrawer = new ShapeDrawingVisitor
list foreach { _.accept(shapesDrawer) }

L’esecuzione di questo script produce il risultato seguente.

draw: Circle(Point(0.0,0.0),5.0)
Rectangle.draw: Rectangle(Point(0.0,0.0),2.0,3.0)
Triangle.draw: Triangle(Point(0.0,0.0),Point(1.0,0.0),Point(0.0,1.0))

Il pattern Visitor è stato criticato per la sua scarsa eleganza e per la violazione del principio aperto-chiuso (anche noto come OCP, dall’inglese Open-Closed Principle) [Martin2003] determinata dalla necessità di modificare (nonché collaudare e rendere nuovamente disponibili) tutti i visitatori di una gerarchia qualora quella gerarchia venga cambiata. Si noti infatti che tutti i tratti ShapeVisitor contengono metodi in cui sono cablate informazioni su ogni tipo derivato da Shape. Va anche rilevato come l’applicazione di questo genere di modifiche sia spesso soggetta a errori.

In linguaggi dotati di “tipi aperti” come Ruby l’alternativa al pattern Visitor consiste nel creare un nuovo file sorgente che riapre le definizioni di tutti i tipi della gerarchia e inserisce in ognuna l’implementazione di un metodo appropriato.

Scala non supporta i tipi aperti, naturalmente, ma offre alcune alternative. Il primo approccio che esamineremo combina il pattern matching con le conversioni implicite. Cominciamo col riorganizzare il codice di ShapeVisitor per rimuovere la logica del pattern Visitor.

// esempi/cap-13/shapes.scala

package shapes2 {
  case class Point(x: Double, y: Double)

  sealed abstract class Shape()

  case class Circle(center: Point, radius: Double) extends Shape()

  case class Rectangle(lowerLeft: Point, height: Double, width: Double)
      extends Shape()

  case class Triangle(point1: Point, point2: Point, point3: Point)
      extends Shape()
}

Se volessimo invocare draw come metodo su qualsiasi istanza di Shape, allora dovremmo usare una conversione implicita verso una classe avvolgente che dispone di quel metodo.

// esempi/cap-13/shapes-drawing-implicit.scala

package shapes2 {
  class ShapeDrawer(val shape: Shape) {
    def draw = shape match {
      case c: Circle    => println("Circle.draw: " + c)
      case r: Rectangle => println("Rectangle.draw: " + r)
      case t: Triangle  => println("Triangle.draw: " + t)
    }
  }

  object ShapeDrawer {
    implicit def shape2ShapeDrawer(shape: Shape) = new ShapeDrawer(shape)
  }
}

Le istanze di ShapeDrawer racchiudono un oggetto Shape. Quando draw viene invocato, il pattern matching determina il modo appropriato di disegnare la forma geometrica sulla base del tipo della forma.

Un oggetto associato dichiara una conversione implcita che avvolge un’istanza di Shape in un’istanza di ShapeDrawer.

Lo script seguente mette in azione il codice.

// esempi/cap-13/shapes-drawing-implicit-script.scala

import shapes2._

val p00 = Point(0.0, 0.0)
val p10 = Point(1.0, 0.0)
val p01 = Point(0.0, 1.0)

val list = List(Circle(p00, 5.0),
                Rectangle(p00, 2.0, 3.0),
                Triangle(p00, p10, p01))

import shapes2.ShapeDrawer._

list foreach { _.draw }

Questo script produce lo stesso risultato dell’esempio che usava il pattern Visitor.

Questa implementazione di ShapeDrawer ha alcune analogie con il pattern Visitor, ma è più concisa ed elegante e non richiede modifiche al codice originale della gerarchia di Shape.

Tecnicamente, questa implementazione ha gli stessi problemi di OCP del pattern Visitor: una modifica alla gerarchia di Shape comporta una modifica all’espressione di pattern matching. Tuttavia, i cambiamenti richiesti sono isolati in un unico punto e sono meno estesi. In effetti, tutta la logica per il disegno ora è concentrata in un unico luogo anziché essere distribuita nei metodi draw di ogni classe derivata da Shape e potenzialmente suddivisa in file differenti. Notate che, avendo sigillato la gerarchia, otterremo un errore di compilazione in draw se ci dimentichiamo di modificarlo quando la gerarchia cambia.

Se si preferisce evitare di usare il pattern matching nel metodo di disegno, è possibile implementare una classe “disegnatrice” e una conversione implicita separata per ogni classe derivata da Shape, mantenendo così ogni operazione di disegno in un file separato in modo da favorire la modularità a discapito della quantità di codice e del numero di file da gestire.

Se, d’altra parte, l’uso della sintassi orientata agli oggetti nella invocazione di shape.draw è accettabile, si può eliminare la conversione implicita ed eseguire lo stesso pattern matching impiegato nel metodo ShapeDrawer.draw. Questo approccio può risultare più semplice, in particolare quando è possibile isolare il comportamento aggiuntivo in un unico punto, e in effetti ricalca le convenzioni di programmazione adottate nello stile funzionale, come illustrato dallo script seguente.

// esempi/cap-13/shapes-drawing-pattern-script.scala

import shapes2._

val p00 = Point(0.0, 0.0)
val p10 = Point(1.0, 0.0)
val p01 = Point(0.0, 1.0)

val list = List(Circle(p00, 5.0),
                Rectangle(p00, 2.0, 3.0),
                Triangle(p00, p10, p01))

val drawText = (shape:Shape) => shape match {
  case circle: Circle =>  println("Circle.draw: " + circle)
  case rect: Rectangle => println("Rectangle.draw: " + rect)
  case tri: Triangle =>   println("Triangle.draw: " + tri)
}

def pointToXML(point: Point) =
  "<point><x>%.1f</x><y>%.1f</y></point>".format(point.x, point.y)

val drawXML = (shape:Shape) => shape match {
  case circle: Circle =>  {
    println("<circle>")
    println("  <center>" + pointToXML(circle.center) + "</center>")
    println("  <radius>" + circle.radius + "</radius>")
    println("</circle>")
  }
  case rect: Rectangle => {
    println("<rectangle>")
    println("  <lower-left>" + pointToXML(rect.lowerLeft) + "</lower-left>")
    println("  <height>" + rect.height + "</height>")
    println("  <width>" + rect.width + "</width>")
    println("</rectangle>")
  }
  case tri: Triangle => {
    println("<triangle>")
    println("  <point1>" + pointToXML(tri.point1) + "</point1>")
    println("  <point2>" + pointToXML(tri.point2) + "</point2>")
    println("  <point3>" + pointToXML(tri.point3) + "</point3>")
    println("</triangle>")
  }
}

list foreach (drawText)
println("")
list foreach (drawXML)

Abbiamo definito due valori funzione, assegnandoli rispettivamente alle variabili drawText e drawXML. Ogni funzione drawX accetta in ingresso un’istanza di Shape, ne esegue il pattern matching sul tipo e la “disegna” correttamente. Abbiamo anche definito un metodo di utilità per convertire un’istanza di Point nel formato XML desiderato.

Infine, scorriamo due volte la lista delle forme geometriche, passando come argomento a foreach prima drawText e poi drawXML. L’esecuzione di questo script riproduce il risultato precedente per il disegno “testuale”, seguito dal nuovo risultato in formato XML.

Circle.draw: Circle(Point(0.0,0.0),5.0)
Rectangle.draw: Rectangle(Point(0.0,0.0),2.0,3.0)
Triangle.draw: Triangle(Point(0.0,0.0),Point(1.0,0.0),Point(0.0,1.0))

<circle>
  <center><point><x>0.0</x><y>0.0</y></point></center>
  <radius>5.0</radius>
</circle>
<rectangle>
  <lower-left><point><x>0.0</x><y>0.0</y></point></lower-left>
  <height>2.0</height>
  <width>3.0</width>
</rectangle>
<triangle>
  <point1><point><x>0.0</x><y>0.0</y></point></point1>
  <point2><point><x>1.0</x><y>0.0</y></point></point2>
  <point3><point><x>0.0</x><y>1.0</y></point></point3>
</triangle>

Ognuno di questi idiomi offre una tecnica potente per aggiungere ulteriori funzionalità particolari che potrebbero non essere indispensabili “ovunque” nell’applicazione, molto efficace per rimuovere metodi la cui presenza negli oggetti non è assolutamente necessaria.

Un’applicazione per il disegno dovrebbe codificare in un unico punto i procedimenti di lettura e scrittura delle forme geometriche, sia che le serializzi in un formato di testo per memorizzarle su disco sia che le rappresenti come figure su uno schermo. È possibile separare l’“interesse” del disegno dalle restanti funzioni sulle forme geometriche e isolarne la logica, il tutto senza modificare la gerarchia di Shape o i punti in cui viene usata all’interno dell’applicazione. Il pattern Visitor soddisfa parzialmente questa necessità di separazione e isolamento, ma richiede di aggiungere codice alle implementazioni dei visitatori per ogni sottotipo di Shape.

Per concludere, esaminiamo un’altra opzione di progettazione da adottare quando si possiede il controllo completo sul processo di costruzione delle forme geometriche. Supponendo che una singola factory si occupi di creare le forme, è possibile modificarla per mescolare tratti che aggiungono nuovi comportamenti a seconda delle esigenze.

// esempi/cap-13/shapes-drawing-factory.scala

package shapes2 {
  trait Drawing {
    def draw: Unit
  }

  trait CircleDrawing extends Drawing {
    def draw = println("Circle.draw " + this)
  }
  trait RectangleDrawing extends Drawing {
    def draw = println("Rectangle.draw: " + this)
  }
  trait TriangleDrawing extends Drawing {
    def draw = println("Triangle.draw: " + this)
  }

  object ShapeFactory {
    def makeShape(args: Any*) = args(0) match {
      case "cerchio" => {
        val center = args(1).asInstanceOf[Point]
        val radius = args(2).asInstanceOf[Double]
        new Circle(center, radius) with CircleDrawing
      }
      case "rettangolo" => {
        val lowerLeft = args(1).asInstanceOf[Point]
        val height    = args(2).asInstanceOf[Double]
        val width     = args(3).asInstanceOf[Double]
        new Rectangle(lowerLeft, height, width) with RectangleDrawing
      }
      case "triangolo" => {
        val p1 = args(1).asInstanceOf[Point]
        val p2 = args(2).asInstanceOf[Point]
        val p3 = args(3).asInstanceOf[Point]
        new Triangle(p1, p2, p3) with TriangleDrawing
      }
      case x => throw new IllegalArgumentException("sconosciuto: " + x)
    }
  }
}

Abbiamo definito un tratto Drawing, derivandone un tratto concreto per ognuna delle classi nella gerarchia di Shape, e un oggetto ShapeFactory con un metodo factory makeShape che accetta una lista di argomenti a lunghezza variabile. Questo metodo esegue il pattern matching sul primo argomento per determinare quale forma geometrica creare, poi effettua una conversione di tipo sugli altri argomenti, appropriata alla costruzione della forma, mescolando il corrispondente tratto per il disegno. Una factory simile potrebbe essere usata per aggiungere metodi di disegno che generano codice XML. Si noti che la lista di valori Any a lunghezza variabile, le numerose conversioni di tipo e il ridotto controllo degli errori sono stati impiegati per convenienza, mentre un’implementazione reale potrebbe moderare il ricorso a questi espedienti.

Lo script seguente mette in azione la factory.

// esempi/cap-13/shapes-drawing-factory-script.scala

import shapes2._

val p00 = Point(0.0, 0.0)
val p10 = Point(1.0, 0.0)
val p01 = Point(0.0, 1.0)

val list = List(
    ShapeFactory.makeShape("cerchio", p00, 5.0),
    ShapeFactory.makeShape("rettangolo", p00, 2.0, 3.0),
    ShapeFactory.makeShape("triangolo", p00, p10, p01))

list foreach { _.draw }

Rispetto agli script precedenti, ora la lista di forme geometriche viene costruita usando la factory e per disegnare le forme nell’istruzione foreach si invoca semplicemente draw su ognuna. Il risultato ottenuto è identico a quello già visto.

Circle.draw Circle(Point(0.0,0.0),5.0)
Rectangle.draw: Rectangle(Point(0.0,0.0),2.0,3.0)
Triangle.draw: Triangle(Point(0.0,0.0),Point(1.0,0.0),Point(0.0,1.0))

La sottigliezza di questo script consiste nell’evitare di assegnare il risultato della invocazione di ShapeFactory.makeShape a una variabile di tipo Shape. Se questo assegnamento avesse luogo, non sarebbe possibile invocare il metodo draw sull’istanza!

Infatti, in questo script, Scala ha inferito un supertipo comune per la lista parametrica leggermente diverso da quello che potreste aspettarvi. Questo tipo è visibile dall’interno dell’interprete interattivo scala, se usate il comando :load per caricare lo script come nella sessione seguente.

$ scala -cp …
Welcome to Scala version 2.8.0.final (Java …).
Type in expressions to have them evaluated.
Type :help for more information.

scala> :load shapes-drawing-factory-script.scala
Loading shapes-drawing-factory-script.scala...
import shapes2._
p00: shapes2.Point = Point(0.0,0.0)
p10: shapes2.Point = Point(1.0,0.0)
p01: shapes2.Point = Point(0.0,1.0)
list: List[Product with shapes2.Shape with shapes2.Drawing] = List(…)
Circle.draw Circle(Point(0.0,0.0),5.0)
Rectangle.draw: Rectangle(Point(0.0,0.0),2.0,3.0)
Triangle.draw: Triangle(Point(0.0,0.0),Point(1.0,0.0),Point(0.0,1.0))

scala>

Fate attenzione alla riga che comincia con list: List[Product with shapes2.Shape with shapes2.Drawing], stampata dopo che la lista di forme geometriche è stata riconosciuta: da qui si nota che il supertipo comune inferito è Product with shapes2.Shape with shapes2.Drawing. Product è un tratto mescolato in tutte le classi case, quindi anche nelle nostre sottoclassi concrete di Shape. Non dimenticate che, per evitare i problemi con l’ereditarietà tra le classi case, la classe Shape alla radice della gerarchia non è una classe case (si veda la sezione Classi case del capitolo 6 per una motivazione dettagliata sulla necessità di evitare l’ereditarietà tra classi case). Quindi, il nostro supertipo comune è una classe anonima che incorpora Shape, Product e il tratto Drawing.

Se si vuole assegnare una di queste forme geometriche disegnabili a una varibile e allo stesso tempo essere in grado di invocare draw, è possibile usare una dichiarazione come quella riportata di seguito (mostrata nel contesto della stessa sessione interattiva di scala).

scala> val s: Shape with Drawing = ShapeFactory.makeShape("cerchio", p00, 5.0)
s: shapes2.Shape with shapes2.Drawing = Circle(Point(0.0,0.0),5.0)

scala> s.draw
Circle.draw Circle(Point(0.0,0.0),5.0)

scala>

L’iniezione di dipendenza in Scala: il pattern Cake

La iniezione di dipendenza (anche nota come DI, dall’inglese Dependency Injection) è una potente tecnica di inversione del controllo (IoC) usata per risolvere le dipendenze tra i “componenti” di applicazioni di grandi dimensioni. Uno dei benefici della DI è la minimizzazione delle dipendenze tra questi componenti, in modo che sia relativamente facile sostituirli a seconda delle particolari circostanze.

Di solito, un oggetto cliente che ha bisogno di un oggetto per accedere a un database ne crea una propria istanza. Pur conveniente, questo approccio rende il collaudo di unità molto difficile, perché vi obbliga a effettuare i test su un vero database, e compromette la riusabilità nelle situazioni in cui è necessario un altro meccanismo di persistenza (o in cui la persistenza non è richiesta). L’inversione del controllo risolve questo problema ribaltando la responsabilità di soddisfare le dipendenze tra l’oggetto e la connessione al database.

JNDI è un esempio di inversione del controllo: anziché istanziare un oggetto per l’accesso, l’oggetto cliente chiede a JNDI di fornirgliene uno. Al cliente non interessa qual è il tipo dell’oggetto che gli viene fornito, perciò non è più legato a una implementazione concreta della dipendenza, ma dipende solo da un’opportuna astrazione per l’accesso alla persistenza, come una interfaccia Java o un tratto Scala.

L’iniezione di dipendenza porta l’inversione del controllo alla sua logica conclusione. Ora il cliente non fa nulla per risolvere la dipendenza, ma è un meccanismo esterno che conosce l’intero sistema a “iniettare” l’oggetto appropriato usando un argomento del costruttore o un metodo di scrittura di un valore. Questo procedimento avviene durante la creazione del cliente. La DI elimina le dipendenze dai meccanismi di IoC nel codice (per esempio, non ci sono più le invocazioni a JNDI) e mantiene gli oggetti relativamente semplici, con legami minimali nei confronti degli altri oggetti.

Tornando ai test di unità, è preferibile usare una controfigura per sostituire le dipendenze più pesanti, in modo da minimizzare il costo aggiuntivo e le ulteriori complicazioni del collaudo. Il caso del nostro oggetto cliente che dipende da un oggetto per accedere al database è un esempio classico. Nel collaudare il cliente, il costo aggiuntivo e gli inconvenienti dovuti all’uso di un vero database sono proibitivi. La sostituzione del database con una controfigura leggera in cui sono stati cablati alcuni dati campione vi permette di semplificare i metodi di preparazione e ripulitura eseguiti all’inizio e alla fine di ogni test, oltre a garantirvi un’esecuzione più veloce e un comportamento predicibile da parte dell’oggetto per l’accesso ai dati da cui dipende il cliente.

In Java, di solito la DI viene realizzata usando un contenitore per l’inversione del controllo, come il framework Spring [SpringFramework], o una API Java equivalente, come la API Guice di Google [Guice]. In entrambi i casi è possibile usare il software insieme al codice Scala, in particolare quando si introduce Scala in un ambiente di produzione Java già maturo.

Tuttavia, Scala vi offre alcune soluzioni uniche per implementare la DI nel codice Scala, esaminate in [Bonér2008b]. Tra queste, ci concentreremo sul pattern Cake, che può sostituire o integrare questi altri meccanismi di iniezione di dipendenza, e che è simile, come vedremo, all’implementazione del pattern Observer esaminata verso l’inizio di questo capitolo, nella sezione Annotazioni self-type e membri tipo astratti. Il pattern Cake è stato descritto in [Odersky2005], anche se gli è stato dato questo nome dopo la pubblicazione dell’articolo. [Bonér2008b] ne analizza anche alcune alternative.

Per esemplificare il pattern costruiremo un elementare modello a componenti per un client Twitter estremamente semplificato, che comprenda una interfaccia utente, una memoria locale per i messaggi inviati e una connessione al servizio offerto da Twitter. Ognuno di questi “componenti” sarà configurabile e verrà specificato separatamente, insieme a un client che funzionerà come “middleware” per legare insieme le parti dell’applicazione. Il client sarà a sua volta un componente che dipenderà dagli altri componenti: nel crearne un’istanza concreta, la configureremo tramite i componenti di cui avrà bisogno.

// esempi/cap-13/twitter-client.scala

package twitterclient
import java.util.Date
import java.text.DateFormat

class TwitterUserProfile(val userName: String) {
  override def toString = "@" + userName
}

case class Tweet(
  val tweeter: TwitterUserProfile,
  val message: String,
  val time: Date) {

  override def toString = "(" +
    DateFormat.getDateInstance(DateFormat.FULL).format(time) + ") " +
    tweeter + ": " + message
}

trait Tweeter {
  def tweet(message: String)
}

trait TwitterClientUIComponent {
  val ui: TwitterClientUI

  abstract class TwitterClientUI(val client: Tweeter) {
    def sendTweet(message: String) = client.tweet(message)
    def showTweet(tweet: Tweet): Unit
  }
}

trait TwitterLocalCacheComponent {
  val localCache: TwitterLocalCache

  trait TwitterLocalCache {
    def saveTweet(tweet: Tweet): Unit
    def history: List[Tweet]
  }
}

trait TwitterServiceComponent {
  val service: TwitterService

  trait TwitterService {
    def sendTweet(tweet: Tweet): Boolean
    def history: List[Tweet]
  }
}

trait TwitterClientComponent {
  self: TwitterClientUIComponent with
        TwitterLocalCacheComponent with
        TwitterServiceComponent =>

  val client: TwitterClient

  class TwitterClient(val user: TwitterUserProfile) extends Tweeter {
    def tweet(msg: String) = {
      val twt = new Tweet(user, msg, new Date)
      if (service.sendTweet(twt)) {
        localCache.saveTweet(twt)
        ui.showTweet(twt)
      }
    }
  }
}

La prima classe, chiamata TwitterUserProfile, incapsula il profilo di un utente, limitato al solo nome. La seconda classe, chiamata Tweet, è una classe case che incapsula un singolo “cinguettìo” (un messaggio di Twitter, che il servizio limita a 140 caratteri), l’utente che lo ha inviato e la data e l’ora di invio. Abbiamo scelto di definire questa classe come una classe case per la facilità con cui le classi case permettono di creare oggetti ed eseguire su di loro il pattern matching. La classe che rappresenta il profilo non è stata definita in questo modo perché è più probabile che venga usata come genitore di classi di profilo più dettagliate.

Subito dopo troviamo il tratto Tweeter, che dichiara un singolo metodo chiamato tweet. Questo tratto è stato definito unicamente per eliminare una possibile dipendenza circolare tra i due componenti TwitterClientComponent e TwitterClientUIComponent, definiti insieme agli altri componenti nella parte rimanente del file.

In totale, i “componenti” sono quattro. Si noti che sono implementati come tratti.

Tutti i componenti hanno una struttura simile: ognuno dichiara un tratto o una classe annidata che incapsula il comportamento del componente, e un valore val a cui è assegnata un’istanza del tipo annidato.

In Java i package vengono spesso associati informalmente con i componenti, come capita in altri linguaggi con il loro equivalente per i package, per esempio i moduli o gli spazi di nomi. Qui stiamo definendo una nozione più precisa di componente e i tratti sono il miglior mezzo per veicolarla perché sono progettati per essere composti tra loro.

TwitterClientUIComponent dichiara un campo val chiamato ui che contiene un’istanza del tipo annidato TwitterClientUI. Questa classe dispone di un campo client che deve essere inizializzato con un’istanza di Tweeter. In effetti, questa sarà un’istanza di TwitterClient (definita in TwitterClientComponent), che estende Tweeter.

TwitterClientUI definisce due metodi: il primo è sendTweet, che l’interfaccia userà per invocare l’oggetto client quando l’utente invia un nuovo messaggio; il secondo è showTweet, speculare al primo metodo e invocato ogni volta che un nuovo messaggio (inviato da un altro utente, per esempio) deve essere mostrato. Questo secondo metodo è astratto, in attesa della “decisione” sul tipo di interfaccia da usare.

Similmente, TwitterLocalCacheComponent contiene la dichiarazione di TwitterLocalCache e una sua istanza. Le istanze con questo tratto salvano i messaggi nella memoria persistente locale quando saveTweet viene invocato. I messaggi salvati si possono recuperare invocando history.

Anche TwitterServiceComponent è molto simile. Il suo tipo annidato dispone di un metodo sendTweet per inviare un messaggio a Twitter e di un metodo history per recuperare tutti i messaggi dell’utente corrente.

Infine, TwitterClientComponent contiene una classe concreta, chiamata TwitterClient, che integra i componenti tra loro. Il suo metodo tweet invia un nuovo messaggio al servizio di Twitter e, se la spedizione ha successo, lo inoltra anche all’interfaccia e alla memoria persistente locale.

TwitterClientComponent contiene anche la seguente annotazione self-type.

self: TwitterClientUIComponent with
      TwitterLocalCacheComponent with
      TwitterServiceComponent =>

Questa dichiarazione ha l’effetto di indicare che ogni TwitterClientComponent concreto deve assumere anche il comportamento di quegli altri tre componenti, combinando quindi tutti i componenti in una singola istanza dell’applicazione. Questa composizione verrà realizzata mescolando i componenti, che sono tratti, al momento di creare i client concreti, come vedremo fra breve.

L’annotazione self-type significa anche che possiamo fare riferimento ai campi val dichiarati in quei componenti. Si noti che TwitterClient.tweet usa service, localCache e ui come se fossero variabili visibili nel proprio ambito. E, in effetti, sono visibili, grazie all’annotazione self-type.

Si noti anche che tutti i metodi che invocano altri componenti sono concreti: quelle relazioni di invocazione tra i componenti sono completamente specificate. Le astrazioni sono dirette “all’esterno”, verso l’interfaccia utente, il meccanismo di persistenza, &c.

Ora definiamo un client Twitter concreto che usa un’interfaccia testuale a riga di comando e una cache locale in memoria, e che finge di interagire con il servizio Twitter.

// esempi/cap-13/twitter-text-client.scala

package twitterclient

class TextClient(userProfile: TwitterUserProfile)
    extends TwitterClientComponent
    with TwitterClientUIComponent
    with TwitterLocalCacheComponent
    with TwitterServiceComponent {

  // Da TwitterClientComponent:

  val client = new TwitterClient(userProfile)

  // Da TwitterClientUIComponent:

  val ui = new TwitterClientUI(client) {
    def showTweet(tweet: Tweet) = println(tweet)
  }

  // Da TwitterLocalCacheComponent:

  val localCache = new TwitterLocalCache {
    private var tweets: List[Tweet] = Nil

    def saveTweet(tweet: Tweet) = tweets ::= tweet

    def history = tweets
  }

  // Da TwitterServiceComponent

  val service = new TwitterService() {
    def sendTweet(tweet: Tweet) = {
      println("Invio il messaggio al quartier generale di Twitter")
      true
    }
    def history = List[Tweet]()
  }
}

La nostra classe concreta TextClient estende TwitterClientComponent e mescola gli altri tre componenti, soddisfando in questo modo le annotazioni self-type di TwitterClientComponent. In altre parole, TextClient è anche un TwitterClientUIComponent, un TwitterLocalCacheComponent e un TwitterServiceComponent, oltre a essere un TwitterClientComponent.

Il costruttore di TextClient accetta come argomento un profilo utente, che verrà passato alla classe client annidata.

TextClient deve definire quattro campi val, uno proveniente da TwitterClientComponent e tre dagli altri mixin. Per quanto riguarda client, si limita a creare una nuova istanza di TwitterClient, passandole il profilo utente contenuto in userProfile. Per quanto riguarda ui, istanzia una classe anonima derivata da TwitterClientUI che definisce showTweet in modo da stampare il messaggio. Per quanto riguarda localCache, istanzia una classe anonima derivata da TwitterLocalCache che mantiene la cronologia dei messaggi in una lista. Infine, per quanto riguarda service, istanzia una classe anonima derivata da TwitterService; questo oggetto “finto” definisce sendTweet in modo da stampare un messaggio e restituire una lista vuota come cronologia.

Mettiamo alla prova il nostro client usando lo script seguente.

// esempi/cap-13/twitter-text-client-script.scala

import twitterclient._

val client = new TextClient(new TwitterUserProfile("BuckTrends"))
client.ui.sendTweet("Il mio primo messaggio. Come funziona questo aggeggio?")
client.ui.sendTweet("Il servizio è attivo?")
client.ui.sendTweet("Vado in bagno...")
println("Cronologia dei messaggi:")
client.localCache.history.foreach {t => println(t)}

Abbiamo creato un’istanza di TextClient per l’utente "BuckTrends". Il vecchio Buck invia tre messaggi perspicaci tramite l’interfaccia. Concludiamo stampando in ordine inverso la cronologia dei messaggi memorizzati localmente. L’esecuzione di questo script produce in uscita il testo seguente.

Invio il messaggio al quartier generale di Twitter
(Sunday, May 3, 2009) @BuckTrends: Il mio primo messaggio. Come funziona questo aggeggio?
Invio il messaggio al quartier generale di Twitter
(Sunday, May 3, 2009) @BuckTrends: Il servizio è attivo?
Invio il messaggio al quartier generale di Twitter
(Sunday, May 3, 2009) @BuckTrends: Vado in bagno...
Cronologia dei messaggi:
(Sunday, May 3, 2009) @BuckTrends: Vado in bagno...
(Sunday, May 3, 2009) @BuckTrends: Il servizio è attivo?
(Sunday, May 3, 2009) @BuckTrends: Il mio primo messaggio. Come funziona questo aggeggio?

Le date varieranno, naturalmente. Ricordatevi che la riga Invio il messaggio al quartier generale di Twitter viene stampata dal servizio finto.

Per riassumere, ogni componente principale del client Twitter è stato dichiarato nel proprio tratto, con un tipo annidato che contiene le dichiarazioni dei campi e dei metodi del componente. Il componente client ha dichiarato le proprie dipendenze nei confronti degli altri componenti usando un’annotazione self-type. La classe client concreta ha mescolato questi componenti e definito il campo val di ogni componente come un sottotipo appropriato delle classi astratte e dei tratti corrispondenti che erano stati dichiarati nel componente.

Abbiamo ottenuto un “collegamento” type-safe tra i componenti e un modello a componenti flessibile, realizzando tutto quanto in codice Scala! Esistono alcune alternative al pattern Cake per implementare l’iniezione di dipendenza in Scala: si veda [Bonér2008b] per ulteriori esempi.

Una progettazione migliore con la progettazione per contratto

Concluderemo questo capitolo con una breve disamina di una metodologia di programmazione chiamata progettazione per contratto [DesignByContract], sviluppata da Bertrand Meyer per il linguaggio Eiffel [Eiffel]. (Si veda anche il capitolo 4 di [Hunt2000].) La progettazione per contratto ha almeno vent’anni di vita, e anche se ora è caduta piuttosto in disgrazia è ancora molto utile per ragionare sulla progettazione del software.

Quando considerate il “contratto” di un modulo, è possibile specificare tre tipi di condizioni. Per iniziare, si possono specificare gli ingressi richiesti affinché un modulo esegua un servizio con successo, come nel caso della invocazione di un metodo. Questi vincoli vengono chiamati precondizioni, e possono anche includere requisiti di sistema, per esempio su variabili globali (che però normalmente dovreste evitare).

Poi, è possibile anche specificare i risultati che il modulo garantisce di restituire, cioè le postcondizioni, se le precondizioni sono state soddisfatte.

Infine, si possono specificare le invarianti che devono essere vere prima e dopo l’invocazione di un servizio.

Il particolare contributo portato dalla progettazione per contratto è l’idea che questi vincoli contrattuali dovrebbero essere specificati come codice eseguibile, in modo da poter essere fatti rispettare automaticamente a tempo di esecuzione, anche se, di solito, solo durante la fase di collaudo. La violazione di un vincolo dovrebbe portare alla terminazione immediata dell’esecuzione, obbligandovi a correggere il problema anziché ignorarlo.

Scala non supporta esplicitamente la progettazione per contratto, ma Predef dispone di diversi metodi che possono essere usati a questo scopo. L’esempio seguente mostra come sfruttare require e assume per far rispettare un contratto.

// esempi/cap-13/bank-account.scala

class BankAccount(val balance: Double) {
  require(balance >= 0.0)
  def debit(amount: Double) = {
    require(amount > 0.0, "L'addebito deve essere maggiore di 0.0")
    assume(balance - amount > 0.0, "I conti scoperti non sono ammessi")
    new BankAccount(balance - amount)
  }
  def credit(amount: Double) = {
    require(amount > 0.0, "L'accredito deve essere maggiore di 0.0")
    new BankAccount(balance + amount)
  }
}

La classe BankAccount usa require per garantire che un saldo non negativo venga specificato come argomento del costruttore. Similmente, i metodi debit e credit usano require per garantire che l’argomento amount specificato sia positivo.

La specifica seguente conferma che il “contratto” viene rispettato.

// esempi/cap-13/bank-account-spec.scala

import org.specs._

object BankAccountSpec extends Specification {
  "La creazione di un conto con un saldo negativo" should {
    "fallire perché il saldo iniziale deve essere positivo." in {
      new BankAccount(-100.0) must throwAn[IllegalArgumentException]
    }
  }

  "L'addebito di una somma su un conto" should {
    "fallire se l'addebito è minore di 0" in {
      val account = new BankAccount(100.0)
      (account.debit(-10.0)) must throwAn[IllegalArgumentException]
    }
  }

  "L'addebito di una somma su un conto" should {
    "fallire se l'addebito è maggiore del saldo" in {
      val account = new BankAccount(100.0)
      (account.debit(110.0)) must throwAn[AssertionError]
    }
  }
}

Se si tenta di creare un’istanza di BankAccount con un saldo negativo, verrà lanciata un’eccezione di tipo IllegalArgumentException, come nel caso in cui l’addebito sia inferiore a zero. Entrambe le condizioni vengono imposte usando require, che lancia una IllegalArgumentException quando la condizione specificata è falsa.

Il metodo assume, usato per garantire che non siano ammessi conti scoperti, funziona in maniera quasi identica a require, ma lancia un AssertionError anziché una IllegalArgumentException.

Sia require sia assume sono disponibili in due forme: una che accetta solo una condizione booleana e una che accetta anche una stringa contenente un messaggio di errore.

Esiste anche una coppia di metodi assert che si comporta in maniera identica ad assume, tranne per un leggero cambiamento nel messaggio di errore generato. La scelta tra assert o assume dipende da quali di questi due “nomi” è più adatto a un certo contesto dal punto di vista concettuale.

L’oggetto Predef definisce anche una classe Ensuring che può essere usata per generalizzare le funzionalità di questi metodi. Essa dispone solo di un metodo ensure sovraccaricato in diverse versioni, alcune delle quali accettano un letterale funzione come “predicato”.

Ensuring e i suoi metodi hanno lo svantaggio di non poter essere disabilitati nel codice di produzione. Potrebbe non essere accettabile terminare bruscamente l’esecuzione se una condizione non viene rispettata, anche se, nel caso in cui gli venga permesso di procedere “zoppicando”, il sistema potrebbe bloccarsi più tardi rendendo più difficile scoprire e correggere il problema. Il costo aggiuntivo imposto alle prestazioni potrebbe costituire un ulteriore motivo per disabilitare i controlli sul contratto durante l’esecuzione.

Di questi tempi, gli obiettivi della progettazione per contratto sono ampiamente raggiunti dallo sviluppo guidato dal collaudo (noto come Test-Driven Development, o TDD). Tuttavia, la forma mentis necessaria a ragionare in termini di progettazione per contratto può essere adottata come utile complemento dei benefici offerti dal TDD. Se decidete di usare la progettazione per contratto per le vostre applicazioni, considerate la possibilità di creare un modulo personalizzato che vi permetta di disabilitare i controlli nel codice di produzione.

Riepilogo, e poi?

Abbiamo imparato un certo numero di tecniche pratiche, pattern e idiomi per sviluppare applicazioni in maniera efficace con Scala. Ma per costruire applicazioni in qualsiasi linguaggio è importante usare strumenti e librerie valide. Perciò, nel prossimo capitolo vi forniremo numerosi dettagli sugli strumenti di Scala a riga di comando, descriveremo lo stato del supporto per Scala offerto da diversi IDE e vi presenteremo alcune delle librerie Scala più importanti.


  1. [NdT] ORM è l’acronimo dell’inglese Object-Relational Mapping, una tecnica di programmazione che favorisce l’integrazione di sistemi software orientati agli oggetti con sistemi di database relazionali.
  2. Qui abbiamo inserito tutto in un unico file per motivi di convenienza, ma di solito dovreste tenere separate queste due parti.

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