Voi siete qui: Inizio Programmare in Scala

Il sistema di tipi di Scala

Scala è un linguaggio staticamente tipato con un sistema di tipi annoverabile tra i più sofisticati, in parte grazie alla combinazione di concetti essenziali provenienti dalla programmazione funzionale e da quella orientata agli oggetti. Il sistema di tipi di Scala cerca di essere logicamente esaustivo, completo e coerente, superando le limitazioni di Java e allo stesso tempo presentando innovazioni mai apparse prima in un linguaggio di programmazione.

Tuttavia, il sistema di tipi può intimidire alla prima occhiata, in particolare chi è abituato a programmare in un linguaggio dinamicamente tipato come Ruby o Python. Per fortuna, l’inferenza di tipo nasconde la maggior parte della complessità in modo che non sia necessario conoscere i particolari, quindi è possibile usare Scala in maniera efficace anche senza padroneggiare il sistema di tipi. I lettori alla prima esperienza con Scala possono decidere di scorrere rapidamente questo capitolo, in modo da sapere almeno dove guardare nell’eventualità che in futuro incontrino problemi relativi ai tipi.

In ogni caso, maggiore è la conoscenza del sistema di tipi, maggiori saranno le capacità di sfruttarne le caratteristiche nella scrittura di un programma. Questa considerazione è valida soprattutto per gli sviluppatori di librerie, ai quali tornerà utile sapere quando usare i tipi parametrici anziché i tipi astratti, quali parametri di tipo dovrebbero essere covarianti, controvarianti o invarianti in caso di ereditarietà, e così via. In generale, una certa comprensione del sistema di tipi si rivelerà un aiuto prezioso nel capire e correggere gli occasionali errori di compilazione relativi ai tipi e nel dare un senso alle informazioni di tipo incluse nei sorgenti e nella documentazione Scaladoc delle librerie Scala.

Se ignorate il significato di alcuni dei termini usati nei paragrafi precedenti, non preoccupatevi: li spiegheremo, e spiegheremo perché sono utili. Anziché esaminare nei dettagli il sistema di tipi di Scala ci concentreremo sui suoi aspetti pratici, puntando a farvi acquisire la conoscenza delle caratteristiche disponibili e dello scopo per cui sono state progettate, nonché la capacità di leggere e capire le dichiarazioni di tipo.

Metteremo anche in evidenza le somiglianze con il sistema di tipi di Java, che potrebbe essere un punto di riferimento familiare per buona parte dei lettori, in modo che la comprensione delle differenze tra i due sistemi faciliti l’interazione con le librerie di quella piattaforma. Circoscriveremo la discussione evitando di parlare del sistema di tipi di .NET, tranne che per mettere in rilievo alcune discrepanze degne di nota che i programmatori .NET vorranno conoscere.

La riflessione sui tipi

Scala supporta le stesse funzionalità di riflessione di Java e .NET, sebbene in alcuni casi la sintassi sia differente.

Prima di tutto, è possibile usare gli stessi metodi a cui si ricorre nei programmi Java o .NET. Lo script seguente mostra alcuni metodi di riflessione disponibili sulla JVM, appartenenti a java.lang.Object e a java.lang.Class.

// esempi/cap-12/jvm-script.scala

trait T[A] {
  val vT: A
  def mT = vT
}

class C extends T[String] {
  val vT = "T"
  val vC = "C"
  def mC = vC

  class C2
  trait T2
}

val c = new C
val clazz = c.getClass              // metodo di java.lang.Object
val clazz2 = classOf[C]             // metodo Scala: classOf[C] ~ C.class
val methods = clazz.getMethods      // metodo di java.lang.Class<T>
val ctors = clazz.getConstructors   // ...
val fields = clazz.getFields
val annos = clazz.getAnnotations
val name  = clazz.getName
val parentInterfaces = clazz.getInterfaces
val superClass = clazz.getSuperclass
val typeParams = clazz.getTypeParameters

Si noti che questi metodi sono disponibili solo per i sottotipi di AnyRef.

Il metodo classOf[T] restituisce la rappresentazione di un tipo Scala a tempo di esecuzione, analogamente alla espressione Java T.class. L’uso di classOf[T] si rivela conveniente quando si desidera estrarre informazioni su un tipo a partire dal tipo stesso, mentre getClass è comodo per recuperare le stesse informazioni da un’istanza di quel tipo.

Tuttavia classOf[T] e getClass restituiscono valori leggermente differenti, a causa della cancellazione di tipo operata dalla JVM nel caso di getClass.

scala> classOf[C]
res0: java.lang.Class[C] = class C

scala> c.getClass
res1: java.lang.Class[_] = class C

Nonostante .NET supporti i tipi reificati evitando la cancellazione di tipo, attualmente la versione .NET di Scala segue il modello a cancellazione della JVM allo scopo di scongiurare incompatibilità che richiederebbero la separazione delle implementazioni.

Valuteremo una soluzione chiamata Manifest per aggirare la cancellazione di tipo dopo aver esaminato i tipi parametrici nella prossima sezione.

Scala fornisce anche metodi per verificare che un oggetto corrisponda a un certo tipo e anche per convertire un oggetto in un certo tipo.

x.isInstanceOf[T] restituirà true se l’istanza x è di tipo T. Tuttavia, questa verifica è soggetta alla cancellazione di tipo: per esempio, List(3.14159).isInstanceOf[List[String]] restituirà true perché il parametro di tipo di List non viene conservato a livello di bytecode; in questo caso, otterrete comunque un messaggio di avvertimento sull’operazione “non controllata” (in inglese, unchecked) da parte del compilatore.

x.asInstanceOf[T] convertirà x nel tipo T o lancerà una ClassCastException se T e il tipo di x sono incompatibili. Ancora una volta è necessario considerare la cancellazione di tipo per i tipi parametrici: infatti, la conversione List(3.14159).asInstanceOf[List[String]] avrà successo.

Si noti che queste due operazioni coinvolgono metodi anziché parole chiave del linguaggio, e i loro nomi sono deliberatamente piuttosto prolissi. Di solito, i controlli sui tipi e le conversioni come queste dovrebbero essere evitate: al posto dei primi è possibile usare il pattern matching; per quanto riguarda le seconde, è consigliabile considerare i motivi che le rendono necessarie e determinare se possono essere eliminate tramite una riorganizzazione del codice.

Al momento della scrittura, Scala contiene alcune funzionalità sperimentali nella versione 2.8 del package scala.reflect, progettate per semplificare l’ispezione e l’invocazione di codice tramite riflessione rispetto ai metodi Java corrispondenti.

Capire i tipi parametrici

Abbiamo presentato i tipi parametrici e i metodi parametrici nel capitolo 1 e ne abbiamo fornito una panoramica più approfondita nella sezione Tipi astratti e tipi parametrici del capitolo 2. Probabilmente li conoscete già se avete una certa esperienza di programmazione in Java o C#. In questa sezione esploreremo i dettagli del sofisticato supporto offerto da Scala per i tipi parametrici.

I tipi parametrici di Scala sono simili ai generici di Java e C# e ai template del C++. Offrono le stesse funzionalità dei generici Java, ma con alcune importanti differenze ed estensioni che rispecchiano la raffinatezza del sistema di tipi di Scala.

Ricapitolando quanto già visto, una dichiarazione come class List[+A] significa che List è parametrizzato da un singolo tipo, rappresentato da A. Il simbolo + viene chiamato annotazione di varianza. Ne riparleremo nella sezione Varianza in caso di ereditarietà più avanti.

Talvolta un tipo parametrico come List è chiamato costruttore di tipo perché viene usato per creare tipi specifici. Per esempio, List è il costruttore di tipo per List[String] e List[Int], che sono tipi differenti anche se vengono implementati allo stesso modo a causa della cancellazione di tipo. In realtà, è più accurato dire che tutti i tratti e le classi sono costruttori di tipo: quelli senza parametri sono effettivamente tipi parametrici a zero argomenti.

Nella dichiarazione class StringList[String] extends List[String] {…}, Scala interpreterà String come il nome del parametro di tipo invece di usarlo per la creazione di un tipo basato su stringhe. Per ottenere questo secondo effetto, basterà scrivere class StringList extends List[String] {…}.

Manifesti

A partire dalla versione 2.7.2, Scala è dotato di una funzione sperimentale chiamata Manifest che cattura le informazioni di tipo cancellate nel bytecode. Questa funzione non è documentata nelle pagine Scaladoc, ma potete esaminare il codice sorgente del tratto scala.reflect.Manifest per saperne di più. [Ortiz2008] analizza i manifesti e ne offre alcuni esempi d’uso.

Un Manifest va dichiarato come un argomento implicito di un metodo o di un tipo di cui si vogliono catturare le informazioni di tipo cancellate. A differenza della maggior parte degli argomenti impliciti, l’utente non ha bisogno di implementare un valore o un metodo Manifest che sia visibile nell’ambito in cui viene usato, perché il compilatore ne genera uno automaticamente. Ecco un esempio che illustra alcuni punti di forza e di debolezza dei manifesti.

// esempi/cap-12/manifest-script.scala

import scala.reflect.Manifest

object WhichList {
  def apply[B](value: List[B])(implicit m: Manifest[B]) = m.toString match {
    case "int"              => println("List[Int]")
    case "double"           => println("List[Double]")
    case "java.lang.String" => println("List[String]")
    case _                  => println("List[???]")
  }
}

WhichList(List(1, 2, 3))
WhichList(List(1.1, 2.2, 3.3))
WhichList(List("uno", "due", "tre"))

List(List(1, 2, 3), List(1.1, 2.2, 3.3), List("uno", "due", "tre")) foreach {
  WhichList(_)
}

WhichList cerca di determinare il tipo della lista passata come argomento sfruttando il valore restituito dal metodo toString del manifesto. Si noti che questo uso del manifesto funziona quando la lista viene costruita all’interno della invocazione di WhichList.apply, ma non funziona quando quel metodo riceve una lista precedentemente costruita. Nel primo caso, il compilatore sfrutta le informazioni di tipo che conosce per costruire il Manifest implicito con il tipo B corretto. Tuttavia, quando il metodo WhichList riceve liste precostruite, le informazioni di tipo cruciali sono già andate perse.

Perciò, i manifesti non possono “riesumare” le informazioni di tipo dal bytecode, ma possono essere usati per catturare e sfruttare le informazioni di tipo prima che vengano cancellate.

Metodi parametrici

Anche i singoli metodi possono essere parametrici. Un buon esempio di metodo parametrico è il metodo apply degli oggetti associati a classi parametriche. Se ricordate, gli oggetti associati sono oggetti singleton con una classe associata. Esiste una sola istanza di un oggetto singleton, come indica il nome, quindi i parametri sul tipo non avrebbero senso.

Consideriamo object List, l’oggetto associato a class List[+A]. Ecco la definizione del metodo apply di object List.

def apply[A](xs: A*): List[A] = xs.toList

Il metodo apply accetta una lista a lunghezza variabile di argomenti di tipo A (inferito a partire dagli argomenti stessi) e restituisce una lista che contiene gli oggetti passati. Ecco un esempio.

val languages = List("Scala", "Java", "Ruby", "C#", "C++", "Python", …)
val positiveInts = List(1, 2, 3, 4, 5, 6, 7, …)

Esamineremo altri metodi parametrici più avanti.

Varianza in caso di ereditarietà

Una differenza importante tra i generici Java e Scala è il modo in cui funziona la varianza in caso di ereditarietà, che determina, per esempio, se è possibile passare un argomento di tipo List[String] a un metodo che ha un parametro di tipo List[AnyRef], cioè se, in altre parole, List[String] deve essere considerato un sottotipo di List[AnyRef]. In caso affermativo, questo tipo di varianza viene chiamata covarianza, perché la relazione tra supertipo e sottotipo del contenitore (il tipo parametrico) “va nella stessa direzione” della relazione tra i parametri di tipo. In altri contesti, può essere desiderabile un comportamento controvariante o invariante, che descriveremo tra breve.

In Scala, il comportamento della varianza viene definito nel punto di dichiarazione usando le annotazioni di varianza + e -, oppure nessuna annotazione. Questo significa che è il progettista del tipo a decidere come deve variare il tipo in caso di ereditarietà.

Esaminiamo i tre tipi di varianza, riassunti nella tabella seguente, e cerchiamo di capire come usarli in maniera efficace. Supporremo che Tsup sia un supertipo di T e che Tsub sia un sottotipo di T.

Tabella 12.1. Annotazioni di varianza di tipo e loro significato.

AnnotazioneEquivalente JavaDescrizione

+

? extends T

Estensione covariante. Per esempio, List[Tsub] è un sottotipo di List[T].

-

? super T

Estensione controvariante. Per esempio, X[Tsup] è un sottotipo di X[T].

nessuna

T

Estensione invariante. Per esempio, non è possibile sostituire Y[Tsup] o Y[Tsub] al posto di Y[T].

La colonna “Equivalente Java” è leggermente fuorviante; spiegheremo il perché più avanti, approfondendo il confronto tra Java e Scala.

La classe List è dichiarata come List[+A], indicando che List[String] è una sottoclasse di List[AnyRef] e quindi che le liste sono covarianti nel parametro di tipo A. Quando un tipo come List ha un solo parametro di tipo covariante, si sente spesso usare l’espressione abbreviata “le liste sono covarianti”, così come per i tipi con un singolo parametro di tipo controvariante.

I tratti FunctionN per N che va da 0 a 22 vengono usati da Scala per implementare i valori funzione come veri oggetti. Consideriamo Function1 come esempio rappresentativo: è dichiarato come trait Function1[-T, +R].

+R è il tipo di ritorno e presenta l’annotazione covariante +. Il tipo del singolo argomento presenta l’annotazione controvariante -. Per le funzioni con più di un argomento, tutti i tipi degli argomenti presentano l’annotazione controvariante. Quindi, per esempio, usando i nostri tipi T, Tsup e Tsub, la definizione seguente sarebbe legale.

val f: Function1[T, T] = new Function1[Tsup, Tsub] { … }

Perciò, i tratti funzione sono covarianti nel parametro del tipo di ritorno R e controvarianti nei parametri degli argomenti T1, T2, …, TN.

Per capire il reale comportamento della varianza, esaminiamo un esempio completo. Se avete già esperienza nella progettazione per contratto [DesignByContract], di cui parleremo brevemente nella sezione Una progettazione migliore con la progettazione per contratto del capitolo 13, potrebbe esservi d’aiuto ricordarne i principi di funzionamento, che sono molto simili a quelli su cui si basa il meccanismo della varianza. Questo script illustra il comportamento della varianza in caso di ereditarietà.

// esempi/cap-12/func-script.scala
// Non verrà compilato!

class CSuper                { def msuper = println("CSuper") }
class C      extends CSuper { def m      = println("C") }
class CSub   extends C      { def msub   = println("CSub") }

var f: C => C = (c: C)      => new C       // #1
    f         = (c: CSuper) => new CSub    // #2
    f         = (c: CSuper) => new C       // #3
    f         = (c: C)      => new CSub    // #4
    f         = (c: CSub)   => new CSuper  // #5: ERRORE!

Lo script non produce alcuna uscita: se provate a eseguirlo, genererà un errore di compilazione relativo all’ultima riga.

Cominciamo definendo una gerarchia molto semplice di tre classi: C, la sua superclasse CSuper e il suo sottotipo CSub. Ognuna di esse definisce un metodo, che sfrutteremo tra breve.

Successivamente definiamo una variabile chiamata f sulla riga con il commento #1: è una funzione con la firma C => C, o più precisamente, è di tipo Function1(-C,+C). Per essere chiari, il valore assegnato a f si trova dopo il segno di uguale: (c: C) => new C, nel cui corpo ignoriamo il valore c in ingresso e ci limitiamo a creare una nuova istanza di C.

Ora assegniamo differenti valori funzione anonimi a f, usando gli spazi per sottolineare le somiglianze e le differenze nel confrontare la dichiarazione originale di f e i riassegnamenti successivi. Stiamo continuando a riassegnare valori a f perché desideriamo semplicemente verificare cosa verrà o non verrà compilato a questo punto. In particolare, desideriamo sapere quali valori funzione possiamo assegnare legalmente a f: (C) => C.

L’assegnamento sulla riga con il commento #2 usa (x:CSuper) => new CSub come valore funzione. Anche questo è accettabile, perché l’argomento di Function1 è controvariante, quindi possiamo sostituirlo con il supertipo, mentre il tipo di ritorno di Function1 è covariante, quindi il nostro valore funzione può restituire un’istanza del sottotipo.

Anche gli assegnamenti nelle due righe successive sono validi. Sulla terza riga, usiamo CSuper come argomento, che viene accettato come è accaduto nella seconda riga, e restituiamo un’istanza di C, che non crea problemi, proprio come ci aspettiamo. Similmente, sulla quarta riga usiamo C come argomento e CSub come tipo di ritorno, entrambi già convalidati dal loro impiego nelle righe precedenti.

L’ultima riga, con il commento #5, non viene compilata perché stiamo tentando di usare un argomento covariante in una posizione controvariante, e stiamo anche tentando di usare un valore di ritorno controvariante dove sono permessi solo valori covarianti.

Per capire qual è il comportamento corretto in questo caso, possiamo ricorrere a un ragionamento in termini di progettazione per contratto. Analizziamo il modo in cui un cliente potrebbe usare alcune di queste definizioni di f.

// esempi/cap-12/func2-script.scala
// Non verrà compilato!

class CSuper                { def msuper = println("CSuper") }
class C      extends CSuper { def m      = println("C") }
class CSub   extends C      { def msub   = println("CSub") }

def useF(f: C => C) = {
  val c1 = new C     // #1
  val c2: C = f(c1)  // #2
  c2.msuper          // #3
  c2.m               // #4
}

useF((c: C)      => new C)        // #5
useF((c: CSuper) => new CSub)     // #6
useF((c: CSub)   => {println(c.msub); new CSuper})   // #7: ERRORE!

Il metodo useF accetta una funzione C => C come argomento; ora stiamo semplicemente passando letterali funzione anziché assegnarli a f. Il metodo crea un’istanza di C (riga con il commento #1) e la passa alla funzione in ingresso per creare una nuova istanza di C (riga #2) su cui poi usa le operazioni di C, invocando i metodi msuper e m (righe con i commenti #3 e #4, rispettivamente).

Si potrebbe dire che il metodo useF specifica un contratto di comportamento: si aspetta che gli venga passata una funzione capace di accettare un’istanza di C come argomento e di restituire un’altra istanza di C. Perciò, il metodo invocherà la funzione che gli viene passata, passandole un’istanza di C, e si aspetterà di ricevere come valore di ritorno un’istanza di C.

Sulla riga con il commento #5, a useF viene passata una funzione che accetta un’istanza di C e restituisce un’istanza di C. L’istanza di C restituita non avrà problemi a funzionare con le righe #3 e #4, per definizione. Non ci sono intoppi.

Infine, arriviamo al punto cruciale di questo esempio. Sulla riga #6 viene passata una funzione che è “disposta” ad accettare un’istanza di CSuper e “promette” di restituire un’istanza di CSub, in altre parole una funzione il cui tipo inferito è Function1[CSuper,CSub]. In effetti, questa funzione allarga il ventaglio delle istanze permesse accettando un supertipo. Si noti che useF non le passerà mai un’istanza di CSuper, ma solo un’istanza di C; tuttavia, dato che la funzione accetta un insieme più ampio di valori, opera altrettanto bene anche se le vengono passate solo istanze di C.

Similmente, “promettendo” di restituire un’istanza di CSub, questa funzione anonima restringe i valori possibili restituiti a useF. Ma anche questo è un comportamento ancora valido, perché useF accetterà qualsiasi istanza di C come valore di ritorno e il suo contratto sarà soddisfatto pure ottenendo solo un’istanza di CSub. Le righe #3 e #4 continueranno a fare il loro dovere.

Seguendo la stessa linea di ragionamento, è possibile capire perché l’ultima riga dello script, indicata dal commento #7, genera un errore di compilazione. Ora la funzione anonima può solo accettare un’istanza di CSub, ma useF le passerà un’istanza di C: il corpo della funzione anonima invocherebbe il metodo c.msub che non esiste in C. Similmente, la restituzione di un’istanza di CSuper al posto di un’istanza di C provocherebbe un problema nella riga #4 in useF, perché CSuper non dispone del metodo m.

Argomentazioni identiche a queste vengono usate per spiegare come i contratti possono cambiare in caso di ereditarietà nella progettazione per contratto.

Si noti che le annotazioni di varianza hanno senso solamente sui parametri di tipo per i tipi parametrici ma non per i metodi parametrici, perché le annotazioni influenzano il comportamento dei sottotipi e i metodi non possono essere estesi, a differenza dei tipi che li contengono.

L’annotazione di varianza + significa che il tipo parametrico è covariante nel parametro di tipo. L’annotazione di varianza - significa che il tipo parametrico è controvariante nel parametro di tipo. L’assenza di qualsiasi annotazione di varianza significa che il tipo parametrico è invariante nel parametro di tipo.

Infine, il compilatore controlla l’uso effettuato delle annotazioni di varianza per evitare problemi come quello che abbiamo appena descritto nelle ultime righe degli esempi. Supponete di provare a definire un vostro tipo funzione in questo modo.

trait MyFunction2[+T1, +T2, -R] {
  def apply(v1:T1, v2:T2): R = { … }
  …
}

Il compilatore lancerà i seguenti errori per quanto riguarda il metodo apply.

… error: contravariant type R occurs in covariant position in type (T1,T2)R
     def apply(v1:T1, v2:T2):R
         ^
… error: covariant type T1 occurs in contravariant position in type T1 …
     def apply(v1:T1, v2:T2):R
               ^
… error: covariant type T2 occurs in contravariant position in type T2 …
     def apply(v1:T1, v2:T2):R
                      ^

La varianza dei tipi mutabili

Tutti i tipi parametrici visti finora sono tipi immutabili. Per quanto riguarda la varianza dei tipi mutabili, possiamo dire che è permesso solo il comportamento invariante. Considerate questo esempio.

// esempi/cap-12/mutable-type-variance-script.scala
// Non verrà compilato!
// I tipi parametrici mutabili non possono avere annotazioni di varianza

class ContainerPlus[+A](var value: A)      // ERRORE
class ContainerMinus[-A](var value: A)     // ERRORE

println(new ContainerPlus("Ciao mondo!"))
println(new ContainerMinus("Ciao mondo!"))

L’esecuzione di questo script provoca i seguenti errori.

… 4: error: covariant type A occurs in contravariant position in type A of parameter of setter value_=
class ContainerPlus[+A](var value: A)      // ERRORE
                             ^
… 5: error: contravariant type A occurs in covariant position in type => A of method value
class ContainerMinus[-A](var value: A)     // ERRORE
                              ^
two errors found

Questi errori si possono comprendere ricordando la discussione sulla varianza di tipo in caso di ereditarietà per i tratti FunctionN, dove i tipi degli argomenti della funzione sono controvarianti (cioè, -T1) e il tipo del valore di ritorno è covariante (cioè, +R).

Il problema con un tipo mutabile è che almeno uno dei suoi campi è dotato dell’equivalente delle operazioni di lettura e scrittura, tramite l’accesso diretto o attraverso metodi per impostare e restituire il valore.

Il primo errore è dovuto al tentativo di usare un tipo covariante come argomento di un metodo di scrittura, ma dalla discussione sui tipi funzione si è visto che i tipi degli argomenti di un metodo devono essere controvarianti. Un tipo covariante può essere utilizzato per un metodo di lettura.

Similmente, il secondo errore è dovuto al tentativo di usare un tipo controvariante come tipo di ritorno per un metodo di lettura, che deve essere covariante. Un tipo controvariante può essere utilizzato per un metodo di scrittura.

Perciò, il compilatore non ci permetterà di usare un’annotazione di varianza su un tipo usato per un campo mutabile. Per questo motivo, tutti i tipi parametrici mutabili nella libreria Scala sono invarianti nei loro parametri di tipo. Alcuni hanno un tipo immutabile corrispondente che possiede parametri covarianti o controvarianti.

Un confronto tra la varianza in Scala e in Java

Come è già stato detto, in Scala il comportamento di varianza è definito nel punto di dichiarazione. In Java, al contrario, è definito nel punto di invocazione: è il cliente di un tipo a definire il comportamento di varianza desiderato [Naftalin2006]. In altre parole, quando si usa un tipo generico Java e si specifica il parametro di tipo, si indica anche il comportamento di varianza (compresa l’invarianza, che è il comportamento predefinito). Non è possibile specificare il comportamento di varianza nel punto della dichiarazione in Java, sebbene sia consentito usare espressioni simili, che però definiscono i limiti del tipo, come vedremo più avanti.

Nelle specifiche di varianza in Java, compare sempre una wildcard ? prima delle parole chiave super o extend, come mostrato nella tabella precedente. Quando abbiamo affermato, dopo la tabella, che la colonna “Equivalente Java” era leggermente fuorviante, ci stavamo riferendo alla differenza tra le specifiche nel punto di dichiarazione o nel punto di invocazione. Esiste un’ulteriore differenza tra il comportamento di Scala e quello di Java che illustreremo più avanti nella sezione Tipi esistenziali.

Le specifiche di varianza nel punto di invocazione hanno lo svantaggio di obbligare chi usa i generici Java a una comprensione più completa del sistema di tipi rispetto a quanto è necessario per chi usa i tipi parametrici di Scala, che non ha bisogno di indicare questo comportamento nei punti in cui li impiega. I programmatori Scala vengono anche grandemente avvantaggiati dall’inferenza di tipo.

Esaminiamo un esempio Java di una versione semplificata dei tipi Scala Option, Some e None.

// esempi/cap-12/variances/Option.java

package variances;

abstract public class Option<T> {
  abstract public boolean isEmpty();

  abstract public T get();

  public T getOrElse(T t) {
    return isEmpty() ? t : get();
  }
}
// esempi/cap-12/variances/Some.java

package variances;

public class Some<T> extends Option<T> {

  public Some(T value) {
    this.value = value;
  }

  public boolean isEmpty() { return false; }

  private T value;

  public T get() { return value; }

  public String toString() {
    return "Option(" + value + ")";
  }
}
// esempi/cap-12/variances/None.java

package variances;

public class None<T> extends Option<T> {

  public boolean isEmpty() { return true; }

  public T get() { throw new java.util.NoSuchElementException(); }

  public String toString() {
    return "None";
  }
}

Ecco un breve programma che usa questa gerarchia di Option in Java.

// esempi/cap-12/variances/OptionExample.java

package variances;
import java.io.*;
import shapes.*;  // Dal capitolo "Una introduzione a Scala"

public class OptionExample {
  static String[] shapeNames = {"Rettangolo", "Cerchio", "Triangolo", "Sconosciuto"};
  static public void main(String[] args) {

    Option<? extends Shape> shapeOption =
      makeShape(shapeNames[0], new Point(0.,0.), 2., 5.);
    print(shapeNames[0], shapeOption);

    shapeOption = makeShape(shapeNames[1], new Point(0.,0.), 2.);
    print(shapeNames[1], shapeOption);

    shapeOption = makeShape(shapeNames[2],
      new Point(0.,0.), new Point(2.,0.), new Point(0.,2.));
    print(shapeNames[2], shapeOption);

    shapeOption = makeShape(shapeNames[3]);
    print(shapeNames[3], shapeOption);
  }

  static public Option<? extends Shape> makeShape(String shapeName,
      Object... args) {
    if (shapeName == shapeNames[0])
      return new Some<Rectangle>(new Rectangle((Point) args[0],
        (Double) args[1], (Double) args[2]));
    else if (shapeName == shapeNames[1])
      return new Some<Circle>(new Circle((Point) args[0], (Double) args[1]));
    else if (shapeName == shapeNames[2])
      return new Some<Triangle>(new Triangle((Point) args[0],
        (Point) args[1], (Point) args[2]));
    else
      return new None<Shape>();
  }

  static void print(String name, Option<? extends Shape> shapeOption) {
    System.out.println(name + "? " + shapeOption);
  }
}

Il metodo OptionExample.main usa la gerarchia di forme geometriche introdotta nel capitolo 1 e che qui di seguito abbiamo aggiornato per sfruttare nuove funzionalità imparate nel corso del libro, come le classi case.

// esempi/cap-12/shapes.scala

package shapes {
  case class Point(x: Double, y: Double) {
    override def toString() = "Point(" + x + "," + y + ")"
  }

  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)
  }
}

L’esecuzione di OptionExample tramite il comando scala -cp … variances.OptionExample produce il risultato seguente.

Rettangolo? Option(Rectangle(Point(0.0,0.0),2.0,5.0))
Cerchio? Option(Circle(Point(0.0,0.0),2.0))
Triangolo? Option(Triangle(Point(0.0,0.0),Point(2.0,0.0),Point(0.0,2.0)))
Sconosciuto? None

Tra le altre cose, questo esempio illustra anche l’interoperabilità tra Scala e Java, di cui riparleremo nella sezione Interoperabilità con Java del capitolo 14.

OptionExample.main invoca il metodo factory statico makeShape, i cui argomenti sono il nome di una forma geometrica e una lista a lunghezza variabile di parametri da passare ai costruttori di Shape.

Si noti che makeShape restituisce Option<? extends Shape> e che, quando Shape viene istanziata, makeShape restituisce un’istanza di Some parametrizzata con il sottotipo di Shape che racchiude. Se il metodo factory riceve un nome di forma geometrica sconosciuto, allora restituisce un’istanza di None<Shape>. Qui è necessario parametrizzare un’istanza di None con Shape, laddove Scala, che definisce un sottotipo di tutti i tipi, chiamato Nothing, può permettersi di definire None come case object None extends Option[Nothing].

Il sistema di tipi di Java non offre alcun idioma per implementare l’equivalente di None in modo simile. La possibilità di definire None come oggetto singleton ha un certo numero di vantaggi, compresa una maggior efficienza, perché evita la creazione di una moltitudine di piccoli oggetti, e un comportamento non ambiguo di equals, perché rende superfluo definire la semantica di uguaglianza tra diverse istanziazioni del tipo Java None<?>, per esempio per confrontare None<String> e None<Shape>.

Infine, si noti che OptionExample, in quanto cliente di Option, deve specificare la varianza di tipo Option<? extends Shape> in diversi punti. In Scala, invece, il cliente non deve assumersi questo onere.

Note di implementazione

L’implementazione dei tipi e dei metodi parametrici merita alcune considerazioni. Le diverse implementazioni sono generate quando il file sorgente che contiene le definizioni viene compilato. Per ogni parametro di tipo, l’implementazione presume che possano essere specificati i sottotipi di Any (nei generici Java viene usato Object). Questi aspetti hanno implicazioni sulle prestazioni di cui riparleremo esaminando l’annotazione @specialized nella sezione Annotazioni del capitolo 13.

Limiti sui tipi

Quando si definisce un tipo o un metodo parametrico potrebbe essere necessario specificare i limiti sul tipo. Per esempio, un tipo parametrico potrebbe richiedere che un particolare parametro di tipo contenga certi metodi.

Limiti superiori sui tipi

Considerate i metodi apply sovraccaricati di object.scala.Array usati per creare nuovi array. Ne esistono implementazioni ottimizzate per ogni tipo derivato da AnyVal, ma esiste un’altra implementazione di apply parametrizzata per qualsiasi sottotipo di AnyRef. Ecco come si presenta questa implementazione in Scala 2.7.5.

object Array {
  …
  def apply[A <: AnyRef](xs: A*): Array[A] = {
    val array = new Array[A](xs.length)
    var i = 0
    for (x <- xs.elements) { array(i) = x; i += 1 }
    array
  }
  …
}

Il parametro di tipo A <: AnyRef significa “qualsiasi tipo A che sia un sottotipo di AnyRef”. Si noti che un tipo è sempre sottotipo e supertipo di se stesso, quindi A potrebbe anche essere uguale ad AnyRef. L’operatore <: indica che il tipo alla sua sinistra deve essere derivato dal tipo alla sua destra, o che i due tipi devono essere uguali. Come abbiamo detto nella sezione Parole riservate del capitolo 2, in realtà questo operatore è una parola riservata del linguaggio.

Questi limiti sono chiamati limiti superiori sul tipo, seguendo la convenzione de facto per cui i diagrammi delle gerarchie di tipi collocano i sottotipi al di sotto dei loro supertipi. Abbiamo seguito questa convenzione nel diagramma mostrato nella sezione La gerarchia di tipi di Scala del capitolo 7.

In questo caso, se il limite non fosse presente, cioè se la firma fosse def apply[A](xs: A*): Array[A], la dichiarazione risulterebbe ambigua rispetto agli altri metodi apply definiti per ognuno dei sottotipi di AnyVal.

La firma di tipo A <: B significa che A deve essere un sottotipo di B. In Java, questa condizione si esprimerebbe come A extends B in una dichiarazione di tipo. Al contrario, per istanziare un tipo nel punto di invocazione la sintassi usata in Java sarebbe ? extends B e indicherebbe il comportamento della varianza.

È importante tenere a mente la distinzione tra varianza di tipo e limiti sul tipo. Per un tipo come List, il comportamento della varianza descrive il modo in cui i tipi reali istanziati dal tipo parametrico, come List[AnyRef] e List[String], sono in relazione tra loro. In questo caso, List[String] è un sottotipo di List[AnyRef], dato che String è un sottotipo di AnyRef.

Al contrario, i limiti superiore e inferiore di un tipo restringono i tipi permessi che possono essere usati come parametro di tipo quando si istanzia un tipo a partire da un tipo parametrico. Per esempio, def apply[A <: AnyRef]… indica che qualsiasi tipo usato per A deve essere un sottotipo di AnyRef.

Limiti inferiori sui tipi

Similmente, esistono circostanze in cui si desidera esprimere che solo i supertipi di un tipo particolare sono ammessi, senza dimenticare che un tipo è anche supertipo di se stesso. Chiamiamo questi limiti inferiori sul tipo, sempre a causa della disposizione grafica in un tipico diagramma di gerarchia di tipi, in questo caso perché il tipo permesso si troverebbe al di sopra del suo limite.

Un esempio particolarmente interessante è il metodo :: (chiamato “cons”) della classe List[+A]. Se ricordate, questo operatore viene usato per creare una nuova lista aggiungendo un elemento in testa a una lista già esistente.

class List[+A] {
  …
  def ::[B >: A](x : B) : List[B] = new scala.::(x, this)
  …
}

La nuova lista sarà di tipo List[B], nello specifico scala.::. La classe :: (distinta dal metodo ::) deriva da List. Ne riparleremo tra breve.

Il metodo :: può aggiungere in testa a una lista un oggetto di tipo diverso dal tipo A degli elementi contenuti nella lista originale. Il compilatore inferirà il supertipo comune più vicino di A e del parametro x e lo userà come valore di B. Ecco un esempio che aggiunge in testa a una lista di oggetti di un certo tipo un oggetto di tipo diverso.

// esempi/cap-12/list-ab-script.scala

val languages = List("Scala", "Java", "Ruby", "C#", "C++", "Python")
val list = 3.14 :: languages
println(list)

Questo script stampa il risultato seguente.

List(3.14, Scala, Java, Ruby, C#, C++, Python)

La nuova lista è di tipo List[Any], dato che Any è il supertipo comune più vicino di String e Double. La lista di partenza era una lista di stringhe, con A uguale a String; in seguito all’aggiunta di un numero di tipo Double in testa alla lista, il compilatore ha inferito Any come valore di B, cioè il supertipo comune più vicino (e, in realtà, l’unico).

La firma di tipo B >: A dice che B deve essere un supertipo di A. Non esiste caso analogo in Java: l’espressione B super A non è valida.

Uno sguardo più attento alle liste

L’implementazione della classe List nella libreria Scala merita di essere analizzata nel dettaglio, dato che combina tutte le caratteristiche appena viste e illustra diversi idiomi utili per realizzare strutture dati immutabili in stile funzionale pienamente type-safe e comunque flessibili. Non mostreremo l’implementazione completa, omettendo object List, numerosi metodi della classe List e i commenti usati per generare la documentazione Scaladoc, ma incoraggiamo il lettore a esaminarla per proprio conto, scaricando la distribuzione del codice sorgente dal sito web del linguaggio [Scala] oppure visitando la pagina Scaladoc di List. Per evitare confusione con scala.List, useremo nomi diversi per la classe (che chiameremo AbbrevList) e per il package.

// esempi/cap-12/abbrev-list.scala
// Adattato da scala/List.scala nella distribuzione di Scala 2.7.5

package bounds.abbrevlist

sealed abstract class AbbrevList[+A] {

  def isEmpty: Boolean
  def head: A
  def tail: AbbrevList[A]

  def ::[B >: A] (x: B): AbbrevList[B] = new bounds.abbrevlist.::(x, this)

  final def foreach(f: A => Unit) = {
    var these = this
    while (!these.isEmpty) {
      f(these.head)
      these = these.tail
    }
  }
}

// La AbbrevList vuota.

case object AbbrevNil extends AbbrevList[Nothing] {
  override def isEmpty = true

  def head: Nothing =
    throw new NoSuchElementException("testa della AbbrevList vuota")

  def tail: AbbrevList[Nothing] =
    throw new NoSuchElementException("coda della AbbrevList vuota")
}

// Una AbbrevList non vuota caratterizzata da una testa e una coda.

final case class ::[B](private var hd: B,
    private[abbrevlist] var tl: AbbrevList[B]) extends AbbrevList[B] {

  override def isEmpty: Boolean = false
  def head : B = hd
  def tail : AbbrevList[B] = tl
}

Si noti che, sebbene AbbrevList sia immutabile, l’implementazione interna usa variabili mutabili, per esempio nel metodo foreach.

Il package contiene le definizioni di tre tipi che formano una gerarchia sigillata. Il primo è AbbrevList (l’analogo di List), un tratto astratto che dichiara i tre metodi astratti isEmpty, head e tail e definisce anche l’operatore :: e un metodo foreach. Sarebbe possibile implementare tutti gli altri metodi di List a partire da questi metodi, sebbene alcuni (come List.length) siano realizzati in maniera differente per ragioni di efficienza.

AbbrevNil, l’analogo di Nil, è un object case che estende AbbrevList[Nothing] restituendo true da isEmpty e lanciando un’eccezione dai metodi head e tail. Dato che AbbrevNil (come Nil) essenzialmente non ha stato né comportamento, implementarlo sotto forma di un oggetto anziché di una classe elimina le copie non necessarie, rende equals veloce e semplice, &c.

La classe ::, dichiarata final, è l’analogo di scala.:: derivata da List. I suoi argomenti sono l’elemento che diventerà la testa della nuova lista e una lista esistente che diventerà la coda della nuova lista. Si noti che questi valori vengono memorizzati direttamente come campi. I metodi head e tail definiti in AbbrevList sono semplicemente metodi di lettura per questi campi. Non sono richieste altre strutture dati per rappresentare la lista.

Questo è il motivo per cui aggiungere in testa un nuovo elemento per creare una nuova lista è un’operazione di complessità temporale O(1). La classe List possiede anche un metodo + deprecato che crea una nuova lista aggiungendo un elemento in coda a una lista esistente: questa operazione è di complessità O(N), dove N è la lunghezza della lista.

Man mano che vengono costruite nuove liste aggiungendo elementi in testa ad altre liste, si forma una gerarchia annidata di istanze di ::. Dato che le liste sono immutabili, non è necessario premunirsi contro l’alterazione accidentale di questa catena nel caso in cui una delle istanze di :: venisse modificata in qualche modo.

È possibile vedere questa struttura annidata stampando una lista tramite il metodo toString che viene generato automaticamente a causa della parola chiave case. Ecco una sessione scala di esempio.

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

scala> import bounds.abbrevlist._
import bounds.abbrevlist._

scala> 1 :: 2 :: 3 :: AbbrevNil
res1: bounds.abbrevlist.AbbrevList[Int] = ::(1,::(2,::(3,AbbrevNil)))

Si noti il risultato sull’ultima riga, che mostra gli elementi (head,tail) annidati.

Si veda http://www.scala-lang.org/node/129 per un altro esempio che definisce in modo simile una struttura dati a pila.

Viste e limiti sulle viste

Abbiamo visto molti casi in cui un metodo implicito viene usato per una conversione di tipo, per esempio allo scopo di far sembrare che si siano aggiunti nuovi metodi a un tipo esistente, come nel pattern cosiddetto Pimp my library ampiamente sfruttato nel capitolo 11. Per effettuare una conversione si possono anche usare valori funzione accompagnati dalla parola chiave implicit. Vedremo un esempio di entrambe le soluzioni tra breve.

Una vista è un valore implicito di tipo funzione che converte un tipo A in un tipo B. La funzione è di tipo A => B oppure (=> A) => B (dove (=> A) è un parametro con nome). Si può usare come vista un metodo implicito con la stessa firma visibile nell’ambito corrente, cioè un metodo implicito importato da un object. Il termine vista esprime l’idea che da un tipo (A) sia possibile vedere un altro tipo (B).

Una vista viene applicata in due circostanze.

  1. Quando un tipo A viene usato in un contesto dove è richiesto un altro tipo B e l’ambito di visibilità contiene una vista in grado di convertire A in B.
  2. Quando si fa riferimento a un membro inesistente m di un tipo A ma l’ambito di visibilità contiene una vista in grado di convertire A in un tipo B dotato del membro m.

Un esempio comune della seconda circostanza è la sintassi di inizializzazione x -> y delle mappe, che innesca l’invocazione di Predef.anyToArrowAssoc(x) come abbiamo detto nella sezione L’oggetto Predef del capitolo 7.

Come esempio della prima circostanza, si possono considerare le viste definite da Predef per convertire i tipi AnyVal tra loro e per convertire un tipo AnyVal nel tipo corrispondente del package java.lang. Per esempio, double2Double converte un numero scala.Double in un’istanza di java.lang.Double.

Un limite di vista in una dichiarazione di tipo viene indicato con la parola chiave <%, per esempio A <% B. Il limite consente di usare come valore di A qualsiasi tipo che possa essere convertito in B usando una vista.

Un metodo o una classe che contiene un tale parametro di tipo viene trattato come equivalente a un metodo o a una classe corrispondente dotata di una lista di parametri aggiuntiva che contiene una vista come unico elemento. Per esempio, considerate la seguente definizione di metodo con un limite di vista.

def m [A <% B](arglist): R = …

La definizione è effettivamente identica a quella del metodo seguente, dove al parametro implicito viewAB il compilatore darebbe un nome unico.

def m [A](arglist)(implicit viewAB: A => B): R = …

Si noti che abbiamo una lista di parametri aggiuntiva anziché un parametro aggiuntivo nella lista di parametri esistente.

Questa trasformazione ha l’effetto voluto perché l’argomento implicito viewAB, posto che l’ambito di visibilità contenga una vista del tipo corretto che lo soddisfi, verrà invocato all’interno di m per convertire tutte le istanze di A in istanze di B ove sia necessario. È anche possibile passare esplicitamente una funzione con la firma corretta in una seconda lista di argomenti quando si invoca m, sebbene in alcune situazioni particolari, che descriveremo dopo il nostro prossimo esempio, questo modo di operare non funzioni.

Per i limiti di vista sui tipi, la lista di parametri contenente la vista implicita viene aggiunta al costruttore principale.

I tratti non possono avere limiti di vista per i loro parametri di tipo, poiché i loro costruttori non possono accettare argomenti.

Per concretizzare quanto è stato detto in un esempio pratico, useremo i limiti di vista per implementare una classe LinkedList dove la lista sia formata da nodi e ogni istanza di Node sia dotata di un campo payload che rappresenta il valore dell’elemento nella lista e di un riferimento all’istanza di Node successiva. Per prima cosa, ecco la gerarchia di nodi.

// esempi/cap-12/node.scala

package bounds

abstract trait Node[+A] {
  def payload: A
  def next: Node[A]
}

case class ::[+A](val payload: A, val next: Node[A]) extends Node[A] {
  override def toString =
    String.format("(%s :: %s)", payload.toString, next.toString)
}

object NilNode extends Node[Nothing] {
  def payload = throw new NoSuchElementException("Nessun valore in NilNode")
  def next    = throw new NoSuchElementException("Nessun nodo successivo in NilNode")

  override def toString = "*"
}

Questa gerarchia di tipi è modellata sulla falsariga di List e AbbrevList. Il tipo :: rappresenta i nodi intermedi e NilNode è l’analogo di Nil per List. Il metodo toString è stato ridefinito per fornire una rappresentazione conveniente dei nodi, che esamineremo tra breve.

Lo script seguente definisce un tipo LinkedList che usa istanze di Node.

// esempi/cap-12/view-bounds-script.scala

import bounds._

implicit def any2Node[A](x: A): Node[A] = bounds.::[A](x, NilNode)

case class LinkedList[A <% Node[A]](val head: Node[A]) {

  def ::[B >: A <% Node[B]](x: Node[B]) =
    LinkedList(bounds.::(x.payload, head))

  override def toString = head.toString
}

val list1 = LinkedList(1)
val list2 = 2 :: list1
val list3 = 3 :: list2
val list4 = "QUATTRO!" :: list3

println(list1)
println(list2)
println(list3)
println(list4)

Lo script comincia con la definizione di un metodo implicito parametrico any2Node capace di convertire A in Node[A], che verrà usato come vista e passato come argomento implicito quando lavoreremo con le istanze di LinkedList. Il metodo crea un nodo “foglia” usando un nodo bounds.:: con un riferiento a NilNode come elemento “successivo” nella lista.

In alternativa, si potrebbe definire un valore funzione che converte Any in Node[Any].

implicit val any2Node = (a: Any) => bounds.::[Any](a, NilNode)

In questo caso, lo script verrebbe eseguito allo stesso modo, a parte il fatto che alcune liste temporanee userebbero Node[Any] anziché Node[Int].

La dichiarazione di LinkedList definisce un limite di vista su A e accetta come singolo argomento l’istanza di Node che fa da testa alla lista (che potrebbe essere la testa di una catena di nodi).

case class LinkedList[A <% Node[A]](val head: Node[A]) { … }

Come si vede più avanti nello script, anche se il costruttore richiede un argomento di tipo Node[A], è possibile passargli un’istanza di A e aspettarsi l’invocazione della vista implicita any2Node. Il vantaggio di questo approccio è che il cliente non deve mai preoccuparsi di costruire opportunamente le istanze di Node perché il meccanismo gestisce questo processo in maniera automatica.

La classe è anche dotata di un operatore ::.

def ::[B >: A <% Node[B]](x: Node[B]) = …

Il parametro di tipo significa che “B è limitato inferiormente da (cioè è un supertipo di) A e ha un limite di vista uguale a B <% Node[B].” Come abbiamo visto per List e AbbrevList, il limite inferiore consente di aggiungere in testa alla lista elementi di tipi differenti rispetto al tipo A originale. Il compilatore aggiungerà a questo metodo un argomento implicito per la vista, ma il nostro metodo implicito parametrico any2Node verrà usato anche per quell’argomento.

In precedenza abbiamo affermato che, se l’ambito corrente non contiene una vista adatta, è possibile passare esplicitamente un convertitore “non implicito” in una seconda lista di argomenti. Nel nostro esempio, in realtà, questo espediente non funzionerebbe, perché il costruttore e il metodo :: di LinkedList accettano argomenti di tipo Node[A], ma noi li invochiamo con istanze di Int e String. Per poter passare manualmente il convertitore dovremmo invocare entrambi usando argomenti di tipo Node[Int] e Node[String], e l’invocazione di :: risulterebbe anche piuttosto inelegante, come in val list2 = list1.::(2)(convertitore).

Si noti la particolare sintassi usata per il parametro di tipo, che a prima vista potrebbe creare confusione. Leggendo B >: A <% Node[B] si potrebbe essere tentati di assumere che, in questa espressione, l’operatore <% vada applicato ad A, ma in realtà deve essere applicato a B. La grammatica per i parametri di tipo, compresi i limiti di vista, è la seguente [ScalaSpec2009].

TypeParam ::= (id | ‘_’) [TypeParamClause] [‘>:’ Type] [‘<:’ Type] [‘<%’ Type]
TypeParamClause ::= ‘[’ VariantTypeParam {‘,’ VariantTypeParam} ‘]’
VariantTypeParam ::= [‘+’ | ‘’] TypeParam

Come vedete, è possibile costruire espressioni per rappresentare tipi gerarchici molto complessi. Nel metodo :: definito per LinkedList, id è B, TypeParamClause è vuota e sulla destra abbiamo le espressioni >: A e <% Node[B]. Ancora una volta, tutte le espressioni dei limiti si applicano al primo id (cioè B) o alla variabile indicata dal trattino basso _ usato per i tipi esistenziali, di cui parleremo più avanti in una sezione a loro dedicata.

Infine, in fondo allo script creiamo un’istanza di LinkedList e vi aggiungiamo in testa alcuni valori per creare nuove liste che poi verranno stampate.

1 :: *
2 :: 1 :: *
3 :: 2 :: 1 :: *
QUATTRO! :: 3 :: 2 :: 1 :: *

Per ricapitolare, i limiti di vista ci permettono di lavorare con “valori utili” di tipo intero e stringa mentre l’implementazione gestisce le conversioni necessarie per ottenere le istanze di Node.

I limiti di vista non vengono usati con la stessa frequenza dei limiti inferiore e superiore sui tipi, ma offrono un meccanismo elegante da adoperare nelle situazioni in cui si desidera sfruttare la conversione automatica tra due tipi. Come sempre, gli impliciti devono essere usati con cautela: le conversioni implicite non risultano assolutamente chiare dalla lettura del codice e durante le attività di debug.

Nothing e Null

Nella sezione La gerarchia di tipi di Scala del capitolo 7 abbiamo detto che Null è un sottotipo di tutti i tipi AnyRef e che Nothing è un sottotipo di tutti i tipi, incluso Null.

Null è un tratto dichiarato come final (in modo che non possa essere esteso) di cui esiste una sola istanza, null. Dato che Null è un sottotipo di tutti i tipi AnyRef, è sempre possibile usare null come istanza di qualsiasi altro tipo. Java, al contrario, tratta semplicemente null come una parola chiave gestita in modo particolare dal compilatore. Tuttavia, il null di Java in realtà si comporta come se fosse un sottotipo di tutti i tipi riferimento, proprio come il Null di Scala.

D’altra parte, dato che Null non è un sottotipo di AnyVal, non è possibile usare null come istanza di Int, per esempio, coerentemente con la semantica dei tipi primitivi in Java.

Anche Nothing è un tratto dichiarato come final, ma dato che non ne esiste alcuna istanza viene utilizzato per definire altri tipi. Il miglior esempio è Nil, che rappresenta la lista vuota come un case object di tipo List[Nothing]. Dato che, come abbiamo visto, le liste in Scala sono covarianti, Nil è a tutti gli effetti un’istanza di List[T] per qualsiasi tipo T. Questa caratteristica è stata anche sfruttata nelle precedenti implementazioni di AbbrevList e LinkedList.

Capire i tipi astratti

In agginuta ai tipi parametrici, che di solito si trovano nei linguaggi orientati agli oggetti staticamente tipati, Scala supporta i tipi astratti, rintracciabili nella maggior parte dei linguaggi funzionali. Abbiamo presentato i tipi astratti nella sezione Tipi astratti e tipi parametrici del capitolo 2.

Questi due generi di tipo si sovrappongono tra loro in una certa misura. Tecnicamente, è possibile implementare quasi tutti gli idiomi supportati dai tipi parametrici usando i tipi astratti, e viceversa. Tuttavia, in pratica, ogni genere è adatto per natura a risolvere problemi di progettazione diversi.

Riprendiamo la nostra versione del pattern Observer che sfrutta i tipi astratti, vista nel capitolo 6.

// 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)
}

AbstractSubject dichiara un tipo Observer senza limiti di tipo che viene definito nei due tratti derivati: come tipo strutturale in SubjectForReceiveUpdate, come tipo funzione in SubjectForFunctionalObservers. Tratteremo questi due tipi in maniera più approfondita nelle prossime sezioni di questo capitolo.

I limiti di tipo possono essere usati anche quando si dichiara o si perfeziona la dichiarazione di un tipo astratto. Vedremo un semplice esempio nella sezione Proiezioni di tipo di questo capitolo, dove esamineremo una dichiarazione type t <: AnyRef in cui t ha un limite superiore di tipo (superclasse) uguale a AnyRef che esclude tutti i tipi AnyVal.

Nelle dichiarazioni di tipo astratto possono anche essere presenti limiti inferiori di tipo (sottoclassi) e nelle espressioni dei limiti si possono usare la maggior parte dei tipi valore (si veda la sezione Tipi valore più avanti). Ecco un esempio che illustra le opzioni più comuni.

// esempi/cap-12/abs-type-examples-script.scala

trait exampleTrait {
  type t1               // Senza vincoli
  type t2 >: t3 <: t1   // t2 deve essere un supertipo di t2 e un sottotipo di t1
  type t3 <: t1         // t3 deve essere un sottotipo di t1
  type t4               // Senza vincoli
  type t5 = List[t4]    // Lista di t4, qualunque sarà il tipo t4...

  val v1: t1            // Non può essere inizializzata fino a quando t1 non viene definito
  val v3: t3            // &c.
  val v2: t2            // ...
  val v4: t4            // ...
  val v5: t5            // ...
}

trait T1 { val name1: String }
trait T2 extends T1 { val name2: String }
class C(val name1: String, val name2: String) extends T2

object example extends exampleTrait {
  type t1 = T1
  type t2 = T2
  type t3 = C
  type t4 = Int

  val v1 = new T1 { val name1 = "T1"}
  val v3 = new C("C1", "C2")
  val v2 = new T2 { val name1 = "T1"; val name2 = "T2" }
  val v4 = 10
  val v5 = List(1,2,3,4,5)
}

I commenti spiegano la maggior parte dei dettagli. Le relazioni tra t1, t2 e t3 presentano alcuni aspetti interessanti. Per cominciare, t2 viene dichiarato in modo da trovarsi “tra” t1 e t3. Il tipo t1, qualsiasi esso sia, deve essere una superclasse di t2 (o uguale a t2) e t3 deve essere una sottoclasse di t2 (o uguale a t2).

Se ricordate quanto detto nella sezione Limiti sui tipi, qui viene fatta la dichiarazione del primo tipo dopo la parola chiave type, cioè t2, non del tipo nel mezzo, cioè t3. Il resto di questa espressione indica i limiti su t2.

Si consideri la riga successiva, che contiene la dichiarazione di t3 come sottotipo di t1. Se mancasse il limite di tipo, il compilatore genererebbe un errore, perché la relazione t3 <: t1 viene implicata dalla dichiarazione precedente di t2. Questo non significa che potete omettere la dichiarazione di t3, che deve essere presente, ma deve allo stesso tempo indicare un limite di tipo coerente con quello implicato dalla dichiarazione di t2.

Quando rivisiteremo il pattern Observer nella sezione Annotazioni self-type e membri tipo astratti del capitolo 13, esamineremo un altro esempio di limiti di tipo usati sui tipi astratti, vedremo i problemi che possono causare e un modo elegante per risolverli.

Infine, i tipi astratti non possono avere annotazioni di varianza.

// esempi/cap-12/abs-type-variances-wont-compile.scala
// Non verrà compilato!

trait T1 { val name1: String }
trait T2 extends T1 { val name2: String }
class C(val name1: String, val name2: String) extends T2

trait T {
  type t: +T1   // ERRORE, annotazioni di varianza +/- illegali
  val v
}

Ricordate che i tipi astratti sono membri del tipo che li racchiude anziché essere parametri di tipo come accade nei tipi parametrici. Il tipo che li racchiude può avere una relazione di ereditarietà con altri tipi, ma i membri tipo si comportano esattamente come i metodi e le variabili: non hanno influenza sulle relazioni di ereditarietà del tipo che li racchiude. Come gli altri membri, i tipi astratti possono essere dichiarati astratti o concreti, tuttavia la loro definizione può venire perfezionata nei sottotipi senza essere completata, a differenza di quanto accade per le variabili e i metodi. Naturalmente, si possono creare istanze solo quando ai tipi astratti è stata data una definizione concreta.

Un confronto tra tipi parametrici e tipi astratti

La scelta tra tipi parametrici e tipi astratti è una delle tipiche decisioni da prendere durante la progettazione. I tipi parametrici sono i più adatti per i tipi contenitore come List e Option. Si consideri la dichiarazione di Some, inclusa nella libreria standard.

case final class Some[+A](val x : A) { … }

Tentando di convertire questa dichiarazione per usare i tipi astratti, si potrebbe cominciare con quanto segue.

case final class Some(val x : ???) {
  type A
  …
}

Il tipo del campo x rimane indefinito. Non è possibile usare A perché non è visibile dai parametri del costruttore. Si potrebbe usare Any, perdendo però il valore aggiunto di una dichiarazione che usi i tipi nel modo più opportuno.

Se si corre il rischio di dichiarare i parametri di un costruttore usando un tipo “segnaposto” che non è ancora stato definito, allora i tipi parametrici sono l’unica soluzione valida (a meno di usare Any o AnyRef).

I tipi astratti si possono usare nei parametri di un metodo e come valori di ritorno all’interno di una funzione. Tuttavia, possono sorgere due problemi: primo, nel caso si usino tipi dipendenti dal percorso (esaminati più avanti nella sezione Tipi dipendenti dal percorso) il compilatore potrebbe confondersi, ritenendo incompatibile l’uso di un tipo in un particolare contesto quando in effetti il percorso conduce a un tipo compatibile; secondo, è insolito esprimere metodi come List.:: usando tipi astratti là dove possono avvenire cambiamenti di tipo (espansioni, in questo caso).

class List[+A] {
  …
  def ::[B >: A](x : B) : List[B] = new scala.::(x, this)
  …
}

Inoltre, se si desidera esprimere la varianza in caso di ereditarietà che è legata alle astrazioni di tipo, allora i tipi parametrici sono in grado di chiarire ed esplicitare questi comportamenti usando le annotazioni di varianza.

Queste limitazioni sui tipi astratti in realtà riflettono la tensione tra l’ereditarietà orientata agli oggetti e le origini funzionali dei tipi astratti, introdotti nei sistemi di tipi adottati nella programmazione funzionale pura, che non possiede il concetto di ereditarietà. I tipi parametrici sono più popolari nei linguaggi orientati agli oggetti perché gestiscono l’ereditarietà in maniera più naturale nella maggior parte delle situazioni.

D’altra parte, talvolta è utile fare riferimento a un’astrazione di tipo come a un membro di un altro tipo anziché come a un parametro usato per costruire nuovi tipi a partire da un tipo parametrico. Una dichiarazione di tipo astratto che viene perfezionata attraverso una serie di raffinamenti del tipo che la racchude può rivelarsi piuttosto elegante.

trait T1 {
  type t
  val v: t
}
trait T2 extends T1 {
  type t <: SomeType1
}
trait T3 extends T2 {
  type t <: SomeType2  // dove SomeType2 <: SomeType1
}
class C extends T3 {
  type t = Concrete    // dove Concrete <: SomeType2
  val v = new Concrete(…)
}
…

In più, questo esempio mostra che i tipi astratti vengono spesso usati per dichiarare variabili astratte dello stesso tipo. Vengono usati, meno frequentemente, anche per le dichiarazioni di metodo.

Per rendere finalmente concrete le variabili astratte è possibile definirle all’interno del corpo del tipo, in maniera molto simile a come sono state dichiarate in origine, oppure inizializzarle tramite gli argomenti del costruttore. Questa seconda tecnica permette a chi usa il tipo di scegliere l’effettivo valore, mentre la prima lascia questa decisione al progettista del tipo.

Abbiamo effettuato l’inizializzazione sfruttando gli argomenti del costruttore nella classe BulkReader, parte del breve esempio presentato nella sezione Tipi astratti e tipi parametrici del capitolo 2.

// esempi/cap-2/abstract-types-script.scala

import java.io._

abstract class BulkReader {
  type In
  val source: In
  def read: String
}

class StringBulkReader(val source: String) extends BulkReader {
  type In = String
  def read = source
}

class FileBulkReader(val source: File) extends BulkReader {
  type In = File
  def read = {
    val in = new BufferedInputStream(new FileInputStream(source))
    val numBytes = in.available()
    val bytes = new Array[Byte](numBytes)
    in.read(bytes, 0, numBytes)
    new String(bytes)
  }
}

println(new StringBulkReader("Ciao Scala!").read)
println(new FileBulkReader(new File("abstract-types-script.scala")).read)

Chi si è formato sulla programmazione orientata agli oggetti tenderà naturalmente a usare i tipi parametrici più spesso dei tipi astratti. Anche la libreria standard di Scala tende a enfatizzare l’uso dei tipi parametrici. In ogni caso, è consigliabile riconoscere i meriti dei tipi astratti e imparare a usarli quando ha senso farlo.

Tipi dipendenti dal percorso

I linguaggi che consentono di annidare i tipi mettono anche a disposizione alcuni modi di fare riferimento al percorso di quei tipi. Scala è dotato di una ricca sintassi per i tipi dipendenti dal percorso, di cui è utile comprendere i concetti principali anche se viene usata raramente, in quanto i messaggi di errore del compilatore contengono spesso percorsi di tipo.

Si consideri l’esempio seguente.

// esempi/cap-12/type-path-wont-compile.scala
// Non verrà compilato!

trait Service {
  trait Logger {
    def log(message: String): Unit
  }
  val logger: Logger

  def run = {
    logger.log("Avvio " + getClass.getSimpleName + ":")
    doRun
  }

  protected def doRun: Boolean
}

object MyService1 extends Service {
  class MyService1Logger extends Logger {
    def log(message: String) = println("1: " + message)
  }
  override val logger = new MyService1Logger
  def doRun = true  // fai qualcosa di concreto...
}

object MyService2 extends Service {
  override val logger = MyService1.logger  // ERRORE
  def doRun = true  // fai qualcosa di concreto...
}

Compilando questo file si ottiene l’errore seguente.

…:27: error: error overriding value logger in trait Service of type MyService2.Logger;
 value logger has incompatible type MyService1.MyService1Logger
  override val logger = MyService1.logger  // ERRORE
               ^
one error found

Il compilatore si lamenta del fatto che il valore logger di MyService2 sulla riga 27 è di tipo MyService2.Logger pur essendo stato dichiarato come Logger nel tratto genitore Service, e del tentativo di assegnargli un valore di tipo MyService1.MyService1Logger.

Questi tre tipi sono considerati diversi in Scala. Logger è annidato in Service, che è il genitore di MyService1 e MyService2. In Scala ciò significa che il tipo Logger annidato è unico per ognuno dei sottotipi. Il tipo reale è dipendente dal percorso.

In questo caso, la soluzione più semplice è spostare la dichiarazione di Logger al di fuori di Service, rimuovendo così la dipendenza dal percorso. In altri casi, è possibile qualificare il tipo in modo che corrisponda al tipo che desiderate.

Esistono diverse forme di percorsi di tipo.

Il percorso C.this

All’interno del corpo di una qualsiasi classe C potete usare C.this o this per fare riferimento all’istanza corrente.

class C1 {
  var x = "1"
  def setX1(x:String) = this.x = x
  def setX2(x:String) = C1.this.x = x
}

Sia setX1 sia setX2 hanno lo stesso effetto, perché C1.this è equivalente a this.

All’interno del corpo di un tipo e al di fuori della definizione di un metodo this fa riferimento al tipo stesso.

trait T1 {
  class C
  val c1 = new C
  val c2 = new this.C
}

I valori c1 e c2 sono dello stesso tipo. L’occorrenza di this nell’espressione this.C fa riferimento al tratto T1.

Il percorso C.super

Potete fare riferimento in modo specifico al genitore di un tipo usando super.

class C2 extends C1
class C3 extends C2 {
  def setX3(x:String) = super.setX1(x)
  def setX4(x:String) = C3.super.setX1(x)
  def setX5(x:String) = C3.super[C2].setX1(x)
}

C3.super è equivalente a super in questo esempio. Se volete fare riferimento a uno specifico genitore tra tutti quelli di un tipo, potete qualificare super con il tipo, come mostrato in setX5. Questo è particolarmente utile nel caso in cui un tipo mescoli diversi tratti, ognuno dei quali ridefinisca lo stesso metodo: se avete bisogno di accedere al metodo di un tratto specifico, potete qualificare super. Tuttavia, questa qualifica non può spingersi oltre il livello immediatamente superiore nella gerarchia, cioè non può fare riferimento ai tipi genitore del tipo genitore, e così via.

Se invocate super in una classe dotata di numerosi mixin e che estende un altro tipo, nel caso in cui il riferimento non sia qualificato sono le regole di linearizzazione a determinare l’obiettivo di super (si veda la sezione Linearizzare la gerarchia di un oggetto nel capitolo 7).

Proprio come accade per this, potete usare super per fare riferimento al tipo genitore nel corpo di un tipo al di fuori di un metodo.

class C4 {
  class C5
}
class C6 extends C4 {
  val c5a = new C5
  val c5b = new super.C5
}

Sia c5a sia c5b sono dello stesso tipo.

Il percorso path.x

Potete raggiungere un tipo annidato attraverso una espressione di percorso suddivisa da punti.

package P1 {
  object O1 {
    object O2 {
      val name = "nome"
    }
  }
}
class C7 {
  val name = P1.O1.O2.name
}

C7.name usa un percorso verso il valore name in O2. Gli elementi di un percorso di tipo devono essere stabili, il che a grandi linee comprende package e oggetti singleton, e dichiarazioni di tipo usate come alias di queste due entità. L’ultimo elemento di un percorso può essere una classe o un tratto. Si veda [ScalaSpec2009] per i dettagli.

object O3 {
  object O4 {
    type t = java.io.File
    class C
    trait T
  }
  class C2 {
    type t = Int
  }
}
class C8 {
  type t1 = O3.O4.t
  type t2 = O3.O4.C
  type t3 = O3.O4.T
//  type t4 = O3.C2.t   // ERRORE: C2 non è un "valore" in O3
}

Tipi valore

Dato che Scala è tipato in modo forte e statico, ogni valore è dotato di tipo. Il termine tipi valore si riferisce a tutte le forme differenti che questi tipi possono assumere, quindi comprende molte forme che ora ci sono familiari, più alcune nuove forme che finora non abbiamo incontrato.

Qui stiamo usando il termine tipi valore nello stesso modo in cui viene usato in [ScalaSpec2009]. Tuttavia, in altri punti del libro abbiamo anche seguito l’uso sovraccaricato del termine adottato dalla specifica per fare riferimento a tutti i sottotipi di AnyVal.

Designatori di tipo

Gli identificatori di tipo convenzionali che vengono comunemente usati sono chiamati designatori di tipo.

class Person              // "Person" è il designatore di tipo
object O { type t }       // "O" e "t" sono designatori di tipo
…

In realtà, i designatori non rappresentano altro che una sintassi abbreviata per le proiezioni di tipo, di cui parleremo più avanti.

Tuple

Un valore della forma (x1, … xN) è un tipo valore tupla.

Tipi parametrici

I tipi creati a partire da un tipo parametrico, come List[Int] e List[String], creati a partire da List[A], sono tipi valore perché vengono associati alla dichiarazione di valori, come nell’espressione val names = List[String]().

Tipi annotati

Quando si annota un tipo, come in @serializable @cloneable class C(val x:String), il tipo effettivo include le annotazioni.

Tipi composti

Una dichiarazione della forma T1 extends T2 with T3 { R }, dove R è il raffinamento (corpo), rappresenta un tipo composto. Qualsiasi dichiarazione nel raffinamento è parte della definizione del tipo composto. La nozione di tipi composti tiene in considerazione il fatto che non tutti i tipi hanno un nome, dato che possono esistere tipi anonimi, come mostra questa sessione di esempio di scala.

scala> val x = new T1 with T2 {
        type z = String
        val v: z = "Z"
}
x: java.lang.Object with T1 with T2{type z = String; def v: this.z} = $anon$1@9d9347d

Si noti il tipo dipendente dal percorso this.z nell’ultima riga.

Particolarmente interessante è il caso di una dichiarazione della forma val x = new { R }, priva di qualsiasi identificatore di tipo. Questa dichiarazione è equivalente a val x = new AnyRef { R }.

Tipi infissi

Alcuni tipi parametrici possono accettare due argomenti di tipo, per esempio scala.Either[+A,+B]. Scala vi consente di dichiarare istanze di questi tipi usando una notazione infissa, come in a Either b. Si consideri l’uso che viene fatto di Either nello script seguente.

// esempi/cap-12/infix-types-script.scala

def attempt(operation: => Boolean): Throwable Either Boolean = try {
  Right(operation)
} catch {
  case t: Throwable => Left(t)
}

println(attempt { throw new RuntimeException("Bu!") })
println(attempt { true })
println(attempt { false })

Il metodo attempt valuterà il parametro operation invocato per nome e restituirà il suo risultato di tipo Boolean, racchiuso in un’istanza di Right, oppure una qualsiasi istanza di Throwable che sia stata catturata, racchiusa in un’istanza di Left. Lo script stampa il testo seguente.

Left(java.lang.RuntimeException: Bu!)
Right(true)
Right(false)

Si noti la dichiarazione del valore di ritorno, Throwable Either Boolean, che è identica a Either[Throwable, Boolean]. Ricordatevi che, come abbiamo visto nella sezione La gerarchia di tipi di Scala del capitolo 7, quando si sfrutta questo idioma per gestire le eccezioni con Either la convenzione vuole che Left venga usato per l’eccezione e Right per il valore di ritorno ordinario.

Tipi funzione

Anche le funzioni che abbiamo scritto finora sono tipate. (T1, T2, … TN) => R è il tipo di tutte le funzioni che accettano N argomenti e restituiscono un valore di tipo R.

Quando c’è un solo argomento, è possibile tralasciare le parentesi, come in T => R. Una funzione che accetta un parametro invocato per nome (come discusso nel capitolo 8) è di tipo (=>T) => R. Abbiamo usato un argomento invocato per nome nell’esempio con la funzione attempt della sezione precedente.

Ricordatevi che in Scala ogni cosa è un oggetto, persino le funzioni. La libreria Scala definisce i tratti FunctionN, per N che va da 0 a 22, estremi inclusi. Ecco, per esempio, il codice sorgente di scala.Function3 incluso nella versione 2.7.5 di Scala, privo della maggior parte dei commenti e di alcuni ulteriori dettagli a cui per ora non siamo interessati.

// Da Scala 2.7.5: scala.Function3 (estratto).
package scala

trait Function3[-T1, -T2, -T3, +R] extends AnyRef {
  def apply(v1:T1, v2:T2, v3:T3): R
  override def toString() = "<function>"

  /** f(x1,x2,x3)  == (f.curry)(x1)(x2)(x3)
   */
  def curry: T1 => T2 => T3 => R = {
    (x1: T1) => (x2: T2) => (x3: T3) => apply(x1,x2,x3)
  }
}

Come abbiamo detto nella sezione Varianza in caso di ereditarietà, i tratti FunctionN sono controvarianti nei parametri di tipo per gli argomenti e covarianti nel parametro di tipo per il tipo di ritorno.

Se ricordate, quando si usa il riferimento a un oggetto qualsiasi seguito da una lista di argomenti, Scala invoca il metodo apply su quell’oggetto. In questo modo, anche qualsiasi oggetto dotato di un metodo apply può essere considerato una funzione, offrendo una gradevole visione simmetrica della doppia natura di Scala, funzionale e orientata agli oggetti.

Quando si definisce un valore funzione, il compilatore istanzia l’oggetto FunctionN appropriato e usa la definizione di funzione come corpo di apply.

// esempi/cap-12/function-types-script.scala

val capitalizer = (s: String) => s.toUpperCase

val capitalizer2 = new Function1[String,String] {
  def apply(s: String) = s.toUpperCase
}

println(List("Programmare", "in", "Scala") map capitalizer)
println(List("Programmare", "in", "Scala") map capitalizer2)

I valori funzione capitalizer e capitalizer2 sono in realtà identici: il secondo imita il risultato prodotto dalla compilazione del primo.

Abbiamo parlato in precedenza del metodo curry nella sezione Currying del capitolo 8. Tale metodo restituisce una nuova funzione con N liste di argomenti, ognuna delle quali contiene un singolo argomento preso dalla lista di argomenti originale contenente N elementi. Si noti che viene invocato lo stesso metodo apply.

// esempi/cap-12/curried-function-script.scala

val f  = (x: Double, y: Double, z: Double) => x * y / z
val fc = f.curry

val answer1 = f(2., 5., 4.)
val answer2 = fc(2.)(5.)(4.)
println(answer1 + " == " + answer2 + "? " + (answer1 == answer2))

val fc1 = fc(2.)
val fc2 = fc1(5.)
val answer3 = fc2(4.)
println(answer3 + " == " + answer2 + "? " + (answer3 == answer2))

Questo script stampa il testo seguente.

2.5 == 2.5? true
2.5 == 2.5? true

Nella prima parte dello script, definiamo un valore f di tipo Function3 che esegue operazioni aritmetiche sui numeri Double. Creiamo un nuovo valore funzione fc applicando il currying a f. Poi invochiamo entrambe le funzioni con gli stessi argomenti e stampiamo i risultati che, come previsto, sono uguali. Si noti che, in questo caso, non ci sono problemi per quanto riguarda eventuali errori di arrotondamento nei confronti di uguaglianza: entrambe le funzioni invocano lo stesso metodo apply, quindi devono restituire lo stesso valore.

Nella seconda parte dello script sfruttiamo la possibilità di applicare parzialmente gli argomenti delle funzioni curry, creando nuove funzioni fino a quando non li abbiamo applicati tutti. L’esempio ci aiuta anche a capire la dichiarazione di curry in Function3.

Le funzioni sono associative a destra, quindi un tipo T1 => T2 => T3 => R è equivalente a T1 => (T2 => (T3 => R)), come possiamo vedere nello script. Infatti l’istruzione val fc1 = fc(2.) invoca fc solo con la prima lista di argomenti (in questo caso, T1 corrisponde a Double) e restituisce una nuova funzione di tipo T2 => (T3 => R), nel nostro caso Double => (Double => Double). Successivamente, in val fc2 = fc1(5.), passiamo il secondo argomento (T2) e otteniamo una nuova funzione di tipo T3 => R, cioè Double => Double. Infine, in val answer3 = fc2(4.) passiamo l’ultimo argomento per calcolare il valore del tipo R, cioè Double.

Un tipo T1 => T2 => T3 => R è equivalente a T1 => (T2 => (T3 => R)). Quando si invoca una funzione di questo tipo con un valore per T1, essa restituisce una nuova funzione di tipo T2 => (T3 => R), e così via.

Per concludere, dato che le funzioni sono istanze di tratti, è possibile usare i tratti come genitori di altri tipi. Nella libreria Scala, Seq[+A] è una sottoclasse di PartialFunction[Int,A], che a sua volta è una sottoclasse di (Int) => A, cioè Function1[Int,A].

Proiezioni di tipo

Le proiezioni di tipo si usano per fare riferimento a una dichiarazione di tipo annidata in un altro tipo.

// esempi/cap-12/type-projection-script.scala

trait T {
  type t <: AnyRef
}
class C1 extends T {
  type t = String
}
class C2 extends C1

val ic1: C1#t = "C1"
val ic2: C2#t = "C2"
println(ic1)
println(ic2)

Sia C1#t sia C2#t sono uguali a String. È possibile anche fare riferimento al tipo astratto T#t, che però non può essere usato in una dichiarazione, proprio perché astratto.

Tipi singleton

Dato un valore v di un sottotipo di AnyRef, compreso null, è possibile ottenere il suo tipo singleton tramite l’espressione v.type, utilizzabile anche come tipo nelle dichiarazioni. Questa proprietà si rivela utile in rare occasioni per aggirare i problemi dovuti ai tipi dipendenti dal percorso visti nella sezione Tipi dipendenti dal percorso. In questi casi il tipo dipendente dal percorso di un oggetto potrebbe sembrare incompatibile con un altro tipo dipendente dal percorso, mentre in effetti i due tipi sono compatibili. Tramite l’espressione v.type si recupera il tipo singleton, un tipo “unico” che elimina la dipendenza dal percorso. I tipi dipendenti dal percorso di due valori v1 e v2 potrebbero essere diversi, ma il loro tipo singleton può essere lo stesso.

Questo esempio usa il tipo singleton di un valore nella dichiarazione di un altro valore.

class C {
  val x = "Cx"
}
val c = new C
val x: c.x.type = c.x

Annotazioni self-type

All’interno di un metodo è possibile usare this per fare riferimento al tipo che ne contiene la definizione, e attraverso il riferimento al tipo accedere a uno dei suoi membri. In quest’ultimo caso, di solito l’uso di this non è necessario, ma in alcune occasioni può rivelarsi utile per evitare riferimenti ambigui quando in un determinato ambito sono visibili più valori con lo stesso nome. Per default, il tipo di this è uguale al tipo all’interno del quale viene usato, ma questa è una condizione niente affatto necessaria.

Le annotazioni self-type permettono di specificare vincoli aggiuntivi sul tipo di this e possono essere usate per creare alias di this. Si consideri per primo quest’ultimo caso.

// esempi/cap-12/this-alias-script.scala

class C1 { self =>
  def talk(message: String) = println("C1.talk: " + message)
  class C2 {
    class C3 {
      def talk(message: String) = self.talk("C3.talk: " + message)
    }
    val c3 = new C3
  }
  val c2 = new C2
}
val c1 = new C1
c1.talk("Ciao")
c1.c2.c3.talk("Mondo")

Questo script stampa il testo seguente.

C1.talk: Ciao
C1.talk: C3.talk: Mondo

Al this dell’ambito più esterno (C1) viene dato l’alias self per potervi fare riferimento facilmente in C3. Sarebbe possibile usare self in qualsiasi metodo all’interno del corpo di C1 o nei suoi tipi annidati. Si noti che il nome self è arbitrario, ma piuttosto convenzionale; in effetti si potrebbe scrivere this =>, pur essendo assolutamente ridondante.

Se l’annotazione self-type contiene un tipo, i benefici che ne derivano sono molto diversi.

// esempi/cap-12/selftype-script.scala

trait Persistence {
  def startPersistence: Unit
}

trait Midtier {
  def startMidtier: Unit
}

trait UI {
  def startUI: Unit
}

trait Database extends Persistence {
  def startPersistence = println("Avvio il database")
}

trait ComputeCluster extends Midtier {
  def startMidtier = println("Avvio il cluster di elaborazione")
}

trait WebUI extends UI {
  def startUI = println("Avvio l'interfaccia web")
}

trait App {
  self: Persistence with Midtier with UI =>

  def run = {
    startPersistence
    startMidtier
    startUI
  }
}

object MyApp extends App with Database with ComputeCluster with WebUI

MyApp.run

Questo script illustra la struttura schematica di un’infrastruttura in grado di supportare diversi livelli applicativi sotto forma di componenti per la memorizzazione persistente, l’elaborazione dei dati e l’interfaccia utente. Esploreremo in maniera approfondita questa metodologia di progettazione dei componenti nel capitolo 13. Per ora, ci interessa semplicemente il ruolo delle annotazioni self-type.

Ogni tratto astratto dichiara un metodo di avvio che effettua l’inizializzazione del componente, ignorando tutte le possibili condizioni di errore. Ogni componente astratto è implementato da un tratto concreto corrispondente anziché da una classe, in modo da poterli usare come mixin. I tratti definiti nello script rappresentano un meccanismo per la persistenza in un database, una sorta di cluster di elaborazione per effettuare le operazioni di logica applicativa e una interfaccia web per l’utente.

Il tratto App collega i componenti tra loro, avviandoli uno per uno nel metodo run.

La particolare annotazione self-type self: Persistence with Midtier with UI => ha due effetti pratici.

  1. Consente al corpo del tratto di considerare se stesso come un’istanza di Persistence, Midtier e UI, e quindi di invocare i metodi definiti in quei tipi, proprio come succede nel metodo run, a prescindere dal fatto che, a questo punto, siano effettivamente legati a una definizione.
  2. Obbliga il tipo concreto che mescola questo tratto a mescolare quegli altri tre tratti o i loro discendenti.

In altre parole, il tipo self di App specifica le dipendenze dagli altri componenti, che vengono successivamente soddisfatte in MyApp mescolando i tratti concreti per i tre livelli applicativi.

Alternativamente, avremmo potuto definire App sfruttando l’ereditarietà.

trait App with Persistence with Midtier with UI {
  def run = { … }
}

In effetti, non ci sarebbero state differenze. Come abbiamo detto, l’annotazione self-type permette ad App di considerarsi come un tipo Persistence, &c., e questo è proprio ciò che succede anche quando mescolate un tratto.

Anche se sembrano equivalenti all’ereditarietà, esistono alcuni casi particolari in cui le annotazioni self-type, per ragioni teoriche, offrono vantaggi unici. In pratica sarebbe possibile usare l’ereditarietà in quasi tutti i casi, ma per convenzione la si usa quando si desidera indicare che un tipo si comporta come (eredita da) un altro tipo, e si usano le annotazioni self-type quando si vuole esprimere una dipendenza tra un tipo e altri tipi [McIver2009].

Nel nostro esempio, l’applicazione non viene considerata come se fosse un’interfaccia utente, un database, &c., bensì come una composizione di questi elementi. Si noti che nella maggior parte dei linguaggi orientati agli oggetti questa dipendenza tra componenti verrebbe espressa attraverso i campi, in particolare se il linguaggio non supporta la composizione dei mixin, come Java. Per esempio, si potrebbe scrivere App in Java nel modo che segue (dove, incidentalmente, le interfacce dei componenti sono annidate per evitare di dover creare file separati per ognuna).

// esempi/cap-12/selftype/JavaApp.java

package selftype;

public abstract class JavaApp {
  public interface Persistence {
    public void startPersistence();
  }

  public interface Midtier {
    public void startMidtier();
  }

  public interface UI {
    public void startUI();
  }

  private Persistence persistence;
  private Midtier midtier;
  private UI ui;

  public JavaApp(Persistence persistence, Midtier midtier, UI ui) {
    this.persistence = persistence;
    this.midtier = midtier;
    this.ui = ui;
  }

  public void run() {
    persistence.startPersistence();
    midtier.startMidtier();
    ui.startUI();
  }
}

È certamente possibile scrivere applicazioni in questo modo anche in Scala. Tuttavia, l’esplicitazione del tipo per la classe corrente trasforma la risoluzione imperativa delle dipendenze, che avviene a tempo di esecuzione tramite il passaggio di oggetti ai costruttori o ai metodi di scrittura dei valori, in una risoluzione dichiarativa che avviene a tempo di compilazione e cattura prima gli eventuali errori. Il paradigma dichiarativo, da cui deriva la programmazione funzionale, permette di esprimere codice più robusto, conciso e chiaro rispetto al paradigma imperativo.

Riprenderemo le annotazioni self-type come modello per la composizione di componenti nel capitolo 13, in particolare nelle sezioni Annotazioni self-type e membri tipo astratti e L’iniezione di dipendenza in Scala: il pattern Cake.

Tipi strutturali

I tipi strutturali si possono considerare come un meccanismo type-safe di duck typing, termine usato per indicare il modo in cui funziona la risoluzione dei metodi nei linguaggi dinamicamente tipati. L’interprete Ruby, per esempio, esegue l’espressione starFighter.shootWeapons cercando un metodo shootWeapons nell’oggetto a cui starFighter fa riferimento. Questo metodo, se trovato, potrebbe essere stato definito nella classe usata per istanziare starFighter o in una delle classi genitore o in uno dei moduli “inclusi”, oppure potrebbe anche essere stato aggiunto all’oggetto usando le funzionalità di metaprogrammazione di Ruby. In alternativa, l’oggetto potrebbe ridefinire il metodo method_missing ed eseguire una particolare operazione quando l’oggetto riceve il “messaggio” shootWeapons.

Scala non supporta questo genere di risoluzione dei metodi, ma vi consente di specificare che un oggetto deve aderire a una certa struttura, cioè contenere certi tipi, campi, o metodi, a prescindere dal suo tipo effettivo. Abbiamo incontrato i tipi strutturali per la prima volta all’inizio del capitolo 4, analizzando una variazione del pattern Observer come esempio.

// esempi/cap-4/observer/observer.scala

package observer

trait Subject {
  type Observer = { def receiveUpdate(subject: Any) }

  private var observers = List[Observer]()
  def addObserver(observer:Observer) = observers ::= observer
  def notifyObservers = observers foreach (_.receiveUpdate(this))
}

La dichiarazione type Observer = { def receiveUpdate(subject: Any) } richiede che qualsiasi osservatore valido sia dotato del metodo receiveUpdate. Il vero tipo del particolare osservatore non è importante.

I tipi strutturali hanno il pregio di minimizzare l’interfaccia tra due entità. Nel nostro esempio, l’accoppiamento consiste solo nella firma di un singolo metodo anziché in un tipo come nel caso di un tratto condiviso. Purtroppo i tipi strutturali non riescono a eliminare le dipendenze da nomi particolari. Se un nome è arbitrario, ci interessa solo l’intento che nasconde. Nel nostro esempio, è possibile evitare la dipendenza dal nome di un singolo metodo usando un oggetto funzione al posto del tipo strutturale, proprio come è stato fatto nella sezione Ridefinire i tipi astratti del capitolo 6.

D’altra parte, se il nome viene adottato come una specie di convenzione universale, la dipendenza ha maggior valore. Per esempio, foreach è un nome molto comune nella libreria Scala e ha un particolare significato, quindi definire un tipo strutturale sulla base di foreach anziché usare una funzione anonima di qualche genere potrebbe essere vantaggioso per comunicarne l’intento al programmatore.

Tipi esistenziali

I tipi esistenziali sono astrazioni usate per individuare un tipo disinteressandosi della sua reale identità: permettono di “indicare” la presenza di un tipo senza specificare estattamente quale sia, di solito perché non lo si conosce e non è necessario conoscerlo nel contesto in cui viene usata l’espressione che lo contiene.

I tipi esistenziali sono particolarmente utili per interfacciarsi al sistema di tipi di Java in tre casi.

  1. Quando si usano i generici, poiché i loro parametri di tipo vengono “cancellati” a livello di bytecode (tramite un procedimento chiamato cancellazione di tipo), come nel caso in cui si crea un’istanza di List[Int] e il tipo Int non è disponibile nel bytecode.
  2. Quando si incontra un tipo “grezzo” (in inglese, raw type), come nelle librerie precedenti a Java 5 dove le collezioni non avevano parametri di tipo. In questo caso, tutti i parametri di tipo sono effettivamente Object.
  3. Quando Java usa le wildcard per esprimere il comportamento di varianza nel punto in cui i generici vengono usati, poiché l’effettivo tipo rappresentato dalla wildcard è sconosciuto. (Ne abbiamo già parlato nella sezione Varianza in caso di ereditarietà di questo capitolo.)

Si consideri il caso del pattern matching su oggetti di tipo List[A]. L’idea di scrivere codice come quello riportato di seguito potrebbe essere seducente.

// esempi/cap-12/type-erasure-wont-work.scala
// Attenzione: non funziona come potreste aspettarvi

object ProcessList {
  def apply[B](list: List[B]) = list match {
    case lInt:    List[Int]    => // fai qualcosa
    case lDouble: List[Double] => // fai qualcosa
    case lString: List[String] => // fai qualcosa
    case _                     => // comportamento predefinito
  }
}

Se però provate a compilare lo script con l’opzione -unchecked sulla JVM otterrete alcuni messaggi di avvertimento sul mancato controllo di tipo per parametri come Int, a causa della cancellazione che impedisce al compilatore di distinguere tra loro i tipi di lista elencati. Nemmeno i manifest esaminati in precedenza possono essere d’aiuto in questo caso, perché non sono in grado di recuperare il tipo cancellato di B.

Abbiamo già visto che la soluzione migliore da adottare con il pattern matching consiste nel concentrarsi sul fatto di avere una lista ed evitare di provare a determinare il parametro di tipo “perduto” per l’istanza di lista. Allo scopo di salvaguardare la sicurezza dei tipi, è necessario specificare che la lista ha un parametro, il quale però, essendo ignoto, verrà indicato con il carattere _ usato come wildcard per il parametro di tipo, nel modo illustrato dall’esempio seguente.

case l: List[_] => // fai qualcosa di "generico" con la lista

Quando viene impiegato in un contesto tipato come questo, List[_] è in realtà una forma abbreviata per il tipo esistenziale List[T] forSome { type T }. Questo è il caso più generale: indica che il parametro di tipo della lista potrebbe essere qualsiasi tipo. Ecco alcuni altri esempi che mostrano come usare i limiti sui tipi.

Tabella 12.2. Esempi di tipi esistenziali

Sintassi abbreviataSintassi completaDescrizione

List[_]

List[T] forSome { type T }

T può essere qualunque sottotipo di Any.

List[_ <: scala.actors.AbstractActor]

List[T] forSome { type T <: scala.actors.AbstractActor }

T può essere qualunque sottotipo di AbstractActor.

List[_ >: MyFancyActor <: scala.actors.AbstractActor]

List[T] forSome { type T >: MyFancyActor <: scala.actors.AbstractActor }

T può essere qualunque sottotipo di AbstractActor fino al sottotipo MyFancyActor incluso.

Se si considerano le somiglianze tra Scala e Java nella sintassi usata per i generici, è possibile notare che la struttura di un’espressione come java.util.List[_ <: scala.actors.AbstractActor] corrisponde a quella dell’espressione di varianza Java java.util.List<? extends scala.actors.AbstractActor>. In effetti, le due dichiarazioni hanno lo stesso effetto. Anche se abbiamo detto che in Scala il comportamento di varianza viene definito nella dichiarazione, si possono usare le espressioni di tipo esistenziale per definire il comportamento di varianza nella invocazione. Questa pratica, tuttavia, viene sconsigliata per le ragioni discusse in precedenza.

La sintassi completa con forSome non viene usata molto spesso nel codice Scala, perché i tipi esistenziali esistono principalmente per supportare i generici Java preservando nel contempo la correttezza nel sistema di tipi di Scala. L’inferenza di tipo nasconde i dettagli nella maggior parte dei contesti. Quando si lavora con i tipi di Scala, è preferibile usare gli altri costrutti di tipo visti in questo capitolo anziché ricorrere ai tipi esistenziali.

Strutture dati infinite ed esecuzione ritardata

Abbiamo descritto i valori ritardati nel capitolo 8. I linguaggi funzionali la cui esecuzione è ritardata per default, come Haskell, sono maggiormente agevolati nel supportare strutture dati infinite.

Si consideri come esempio il metodo fib seguente che calcola il numero di Fibonacci per n in una sequenza di Fibonacci infinita.

def fib(n: Int): Int = n match {
  case 0 | 1 => n
  case _ => fib(n-1) + fib(n-2)
}

Se Scala fosse un linguaggio a esecuzione puramente ritardata, potremmo immaginare di scrivere una definizione della sequenza di Fibonacci come quella che segue evitando la creazione di un ciclo infinito.

fibonacci_sequence = for (i <- 0 to infinity) yield fib(i)

L’esecuzione di Scala non è ritardata per default (né esiste alcun valore o parola chiave infinity) ma la libreria contiene una classe Stream che supporta la valutazione ritardata e quindi può supportare strutture dati infinite; la sfrutteremo tra un momento per presentarvi una nuova implementazione della sequenza di Fibonacci. Prima, però, ecco un esempio più semplice che usa quella classe per rappresentare tutti gli interi positivi, tutti gli interi dispari positivi e tutti gli interi pari positivi.

// esempi/cap-12/lazy-ints-script.scala

def from(n: Int): Stream[Int] = Stream.cons(n, from(n+1))

lazy val ints = from(0)
lazy val odds = ints.filter(_ % 2 == 1)
lazy val evens = ints.filter(_ % 2 == 0)

odds.take(10).print
evens.take(10).print

Questo script produce il risultato seguente.

1, 3, 5, 7, 9, 11, 13, 15, 17, 19, Stream.empty
0, 2, 4, 6, 8, 10, 12, 14, 16, 18, Stream.empty

Si noti che il metodo from, utilizzato per definire ints, è ricorsivo e non termina mai. Stream.cons è un oggetto dotato di un metodo apply che è analogo al metodo :: (chiamato “cons”) di List: restituisce un nuovo flusso, cioè una nuova istanza di Stream, con il primo argomento come testa e con il secondo argomento, un’altra istanza di Stream, come coda. I valori ritardati odds e evens vengono calcolati filtrando ints.

Una volta definiti gli insiemi di numeri, usiamo il metodo take per recuperare una nuova istanza di Stream della dimensione fissa specificata, 10 in questo caso, e poi la stampiamo con il metodo print, generando i primi dieci elementi seguiti da Stream.empty quando si arriva al termine del flusso.

Tornando alla sequenza di Fibonacci, ne esiste una famosa definizione basata su sequenze infinite ritardate che sfruttano l’operazione zip (si veda per esempio [Abelson1996]). La nostra analisi di questa definizione in Scala è adattata da [Ortiz2007].

// esempi/cap-12/lazy-fibonacci-script.scala

lazy val fib: Stream[Int] =
  Stream.cons(0, Stream.cons(1, fib.zip(fib.tail).map(p => p._1 + p._2)))

fib.take(10).print

Lo script produce il risultato seguente.

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, Stream.empty

Come nella nostra definizione iterativa all’inizio di questa sezione, abbiamo esplicitamente specificato i primi due valori, 0 e 1. Il resto dei numeri viene calcolato usando zip e sfruttando il fatto che fib(n) = fib(n-1) + fib(n-2) per n > 1.

L’invocazione fib.zip(fib.tail) crea un nuovo flusso di tuple che contengono gli elementi di fib in prima posizione e gli elementi di fib.tail in seconda posizione. Per ottenere un flusso di singoli numeri interi, mappiamo il flusso di tuple in un flusso di Int sommando tra loro gli elementi di ogni tupla. Ecco le tuple calcolate.

(0,1), (1,1), (1,2), (2,3), (3,5), (5,8), (8,13), (13,21), (21,34), …

Si noti che ogni secondo elemento di una tupla è il numero che, nella sequenza di Fibonacci, segue il numero del corrispondente primo elemento. Sommando tra loro i numeri di ogni tupla, otteniamo la sequenza seguente.

1, 2, 3, 5, 8, 13, 21, 34, 55, …

Concatenando il flusso a 0 e 1, otteniamo la sequenza di Fibonacci.

0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, …

Un altro tipo di Scala a valutazione ritardata, anche se finito, è Range, usato tipicamente per rappresentare intervalli letterali come 1 to 1000, che pur essendo così ampio non consuma troppe risorse proprio grazie alla valutazione ritardata. Tuttavia, questa caratteristica può portare a un certo genere di problemi complicati a meno di non seguire le indicazioni di [Smith2009b], che riprendiamo in questa sede usando lo stesso esempio: una funzione che restituisce una sequenza di tre numeri interi casuali.

// esempi/cap-12/lazy-range-danger-script.scala

def mkRandomInts() = {
  val randInts = for {
    i <- 1 to 3
    val rand = i + (new scala.util.Random).nextInt
  } yield rand
  randInts
}
val ints1 = mkRandomInts

println("Invoco first sull'istanza di Seq:")
for (i <- 1 to 3) {
  println( ints1.first)
}

val ints2 = ints1.toList
println("Invoco first sulla Lista creata dall'istanza di Seq:")
for (i <- 1 to 3) {
  println( ints2.first)
}

Ecco il testo che viene stampato eseguendo lo script. I valori varieranno da esecuzione a esecuzione.

Invoco first sull'istanza di Seq:
-1532554511
-1532939260
-1532939260
Invoco first sulla lista creata dall'istanza di Seq:
-1537171498
-1537171498
-1537171498

Si noti che l’invocazione di first sulla sequenza non restituisce sempre lo stesso valore. Il motivo è che l’intervallo all’inizio dell’espressione for obbliga l’intera sequenza a essere ritardata, e quindi ne innesca una nuova valutazione a ogni invocazione di first: così, il primo valore nella sequenza effettivamente cambia, dato che Random restituisce un numero differente ogni volta (almeno lo farà se tra un’invocazione e l’altra trascorre un intervallo di tempo sufficiente).

Tuttavia, l’invocazione di toList sulla sequenza innesca la valutazione dell’intero intervallo e crea una lista rigorosa, in cui tutti gli elementi sono calcolati.

Evitate di usare gli intervalli in costrutti del tipo for (…) yield x, mentre potete usarli in quelli del tipo for (…) {…}.

Infine, la versione 2.8 di Scala doterà tutte le collezioni di un metodo force che ne farà diventare rigorose le istanze innescando la valutazione di tutti gli elementi.

Riepilogo, e poi?

Va ricordato che non è necessario padroneggiare le complessità del ricco sistema di tipi di Scala per usare efficacemente il linguaggio. Man mano che prenderete confidenza, comincerete a creare librerie potenti e sofisticate che incrementeranno la vostra produttività.

La specifica del linguaggio [ScalaSpec2009] descrive i dettagli formali del sistema di tipi. Come qualsiasi specifica, può risultare difficile da leggere, ma vale la pena farlo se desiderate conoscere il sistema di tipi in profondità. Esiste anche una lunga serie di articoli sul sistema di tipi di Scala, che potete rintracciare a partire dal sito ufficiale del linguaggio all’indirizzo http://scala-lang.org.

Nei prossimi due capitoli ci occuperemo di analizzare gli aspetti pratici della progettazione di software e gli strumenti e le librerie per sviluppare applicazioni in Scala.

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