Voi siete qui: Inizio Programmare in Scala

Programmazione orientata agli oggetti in Scala: elementi avanzati

Abbiamo appreso i concetti di base della OOP in Scala, ma ci sono molte altre cose da imparare.

Ridefinire i membri di classi e tratti

Le classi e i tratti possono dichiarare campi, metodi e tipi come membri astratti. Questi membri devono essere definiti da una classe derivata o da un tratto derivato prima che sia possibile creare un’istanza. La maggior parte dei linguaggi orientati agli oggetti supporta i metodi astratti e alcuni supportano anche campi e tipi astratti.

Per ridefinire un membro concreto, Scala richiede l’uso della parola chiave override. Questa parola chiave è opzionale quando un sottotipo definisce (“ridefinisce”, in inglese, appunto, override) un membro astratto. Al contrario, dovete evitare di usare override a meno che non stiate effettivamente ridefinendo un membro.

L’obbligo di usare la parola chiave override ha diversi vantaggi.

  1. Cattura gli errori di ortografia nel nome dei membri indicati come ridefinizioni. Il compilatore segnalerà che il membro non ridefinisce alcunché.
  2. Cattura un errore potenzialmente sottile che può avvenire se si aggiunge un nuovo membro a una classe base in cui il nome del membro collide con un membro di una classe derivata più vecchia che lo sviluppatore della classe base non conosce. Cioè, il membro della classe derivata non era stato concepito per ridefinire un membro della classe base. Dato che il membro della classe derivata sarà privo della parola chiave override, il compilatore lancerà un errore quando il nuovo membro della classe base viene introdotto.
  3. Vi rammenta di considerare quali membri dovrebbero o non dovrebbero essere ridefiniti.

Java è dotato di un’annotazione @Override opzionale per i metodi che vi aiuta a catturare gli errori del primo tipo (ortografia sbagliata) ma che, essendo appunto opzionale, non può aiutarvi con gli errori del secondo tipo.

Tentare di ridefinire le dichiarazioni final

In ogni caso, è proibito ridefinire una dichiarazione se essa include la parola chiave final. Nell’esempio seguente, fixedMethod è dichiarato come final nella classe genitore. Il tentativo di compilare l’esempio risulterà in un errore di compilazione.

// esempi/cap-6/overrides/final-member-wont-compile.scala
// NON verrà compilato!

class NotFixed {
  final def fixedMethod = "fisso"
}

class Changeable2 extends NotFixed {
  override def fixedMethod = "non fisso" // ERRORE
}

Questo vincolo si applica alle classi e ai tratti così come ai membri. Nel prossimo esempio, la classe Fixed viene dichiarata come final, quindi anche il tentativo di estenderla genererà un errore di compilazione.

// esempi/cap-6/overrides/final-class-wont-compile.scala
// NON verrà compilato!

final class Fixed {
  def doSomething = "Fixed ha fatto qualcosa!"
}

class Changeable1 extends Fixed // ERRORE

Alcuni tipi nella libreria Scala sono final, comprese certe classi del JDK come String e tutti i tipi valore derivati da AnyVal (si veda la sezione La gerarchia di tipi di Scala nel capitolo 7).

Esaminiamo le regole per ridefinire le dichiarazioni che non sono final e i loro comportamenti, cominciando dai metodi.

Ridefinire i metodi astratti e concreti

Estendiamo la nostra classe base Widget con un metodo astratto draw, per supportare il “rendering” degli elementi grafici su uno schermo, una pagina web, &c. Ridefiniremo anche il metodo concreto toString(), noto a tutti i programmatori Java, usando un formato ad hoc. Come prima, introdurremo un nuovo package, chiamato ui3.

Il disegno in realtà è un interesse trasversale. Lo stato di un elemento grafico è una cosa; il modo in cui viene rappresentato su piattaforme differenti, applicazioni rich-client, pagine web, dispositivi mobili, &c. è un’altra cosa, completamente separata. Quindi, il disegno è un buon candidato per un tratto, soprattutto se volete che le vostre astrazioni di interfaccia grafica siano portabili. Tuttavia, per semplificare le cose, gestiremo il disegno nella gerarchia di Widget.

Ecco la classe Widget revisionata con i metodi draw e toString.

// esempi/cap-6/ui3/widget.scala

package ui3

abstract class Widget {
  def draw(): Unit
  override def toString() = "(elemento)"
}

Il metodo draw è astratto perché non ha un corpo; cioè, il metodo non è seguito da un segno di uguale (=) e da altro testo. Di conseguenza, Widget deve essere dichiarato abstract (la dichiarazione è opzionale per il metodo). Ogni sottoclasse concreta di Widget dovrà implementare draw o fare affidamento su una classe genitore che lo implementa. Non dobbiamo restituire nulla da draw, quindi il suo valore di ritorno è Unit.

Il metodo toString è banale. Dato che AnyRef definisce toString, è obbligatorio usare la parola chiave override per Widget.toString.

Ecco la classe Button revisionata con i metodi draw e toString.

// esempi/cap-6/ui3/button.scala

package ui3

class Button(val label: String) extends Widget with Clickable {

  def click() = {
    // Logica per mostrare visivamente il clic sul pulsante...
  }

  def draw() = {
    // Logica per disegnare il pulsante su uno schermo, una pagina web, &c.
  }

  override def toString() =
    "(pulsante: etichetta=" + label + ", " + super.toString() + ")"
}

Button implementa il metodo astratto draw. La parola chiave override non è obbligatoria. Button ridefinisce anche toString, e in questo caso la parola chiave override è obbligatoria. Notate che questo metodo invoca super.toString.

La parola chiave super è analoga a this, ma è legata al tipo genitore, che è l’aggregazione della classe genitore e di tutti i tratti mescolati. La ricerca di super.toString troverà il metodo toString del tipo genitore “più vicino”, come determinato dal processo di linearizzazione (si veda la sezione Linearizzare la gerarchia di un oggetto nel capitolo 7). In questo caso, dato che Clickable non definisce toString, il metodo invocato sarà Widget.toString.

La ridefinizione di un membro concreto dovrebbe essere fatta raramente, perché è un’operazione soggetta a errori. Dovreste invocare il metodo genitore? Se è così, quando? Lo invocate prima di fare tutto il resto o dopo? Sebbene lo sviluppatore del metodo genitore possa descrivere i vincoli di ridefinizione del metodo, è difficile assicurarsi che lo sviluppatore di una classe derivata rispetti questi vincoli. Il pattern template method [GOF1995] è un approccio molto più robusto.

Ridefinire i campi astratti e concreti

La maggior parte dei linguaggi orientati agli oggetti vi consente di ridefinire i campi mutabili (var). Un numero inferiore di linguaggi orientati agli oggetti vi consente di definire campi astratti o di ridefinire i campi concreti immutabili (val). Per esempio, è pratica comune usare il costruttore di una classe base per inizializzare un campo mutabile e usare il costruttore di una classe derivata per cambiare il valore di quel campo.

Discuteremo la ridefinizione dei campi nei tratti separatamente dalla ridefinizione dei campi nelle classi, in quanto i tratti presentano alcuni problemi particolari.

Ridefinire i campi astratti e concreti nei tratti

Ricordate il nostro tratto VetoableClicks nella sezione Tratti impilabili del capitolo 4? Il tratto definisce un campo val chiamato maxAllowed e lo inizializza a 1. Ci piacerebbe essere in grado di ridefinire il valore in una classe che mescola questo tratto.

Sfortunatamente, nelle versioni 2.7.X di Scala non è possibile ridefinire un campo val definito in un tratto. Tuttavia, è possibile ridefinire un campo val definito in una classe genitore. La versione 2.8 di Scala supporta anche la ridefinizione dei campi val in un tratto.

Dato che il comportamento di ridefinizione per i campi val in un tratto sta cambiando, dovreste evitare di fare affidamento sulla possibilità di ridefinirli, se state attualmente usando le versioni 2.7.X di Scala. Invece, adottate un altro approccio.

Sfortunatamente, il compilatore della versione 2.7 accetta il codice che tenta di ridefinire un campo val definito in un tratto, ma la ridefinizione in realtà non avviene, come illustrato da questo esempio.

// esempi/cap-6/overrides/trait-val-script.scala
// PERICOLO! Fallimento silenzioso della ridefinizione di un campo
//           in un tratto (solo per la versione 2.7.5)
// Ha il funzionamento atteso nella versione 2.8.0

trait T1 {
  val name = "T1"
}

class Base

class ClassWithT1 extends Base with T1 {
  override val name = "ClassWithT1"
}

val c = new ClassWithT1()
println(c.name)

class ClassExtendsT1 extends T1 {
  override val name = "ClassExtendsT1"
}

val c2 = new ClassExtendsT1()
println(c2.name)

Se eseguite questo script con la versione 2.7.5 di scala, ottenete l’uscita seguente.

T1
T1

Leggendo lo script, ci saremmo aspettati che le due stringhe T1 fossero ClassWithT1 e ClassExtendsT1 rispettivamente.

Tuttavia, se eseguite questo script con la versione 2.8 di scala, ottenete questa uscita.

ClassWithT1
ClassExtendsT1

Nelle versioni 2.7.X di Scala, il tentativo di ridefinire un campo val definito in un tratto verrà accettato dal compilatore, ma non avrà alcun effetto.

Ci sono tre espedienti a cui potete ricorrere con la versione 2.7 di Scala. Il primo consiste nell’usare alcune opzioni avanzate di scala e scalac. L’opzione -Xfuture attiverà il comportamento di ridefinizione supportato nella versione 2.8. L’opzione -Xcheckinit analizzerà il vostro codice e vi dirà se il cambiamento del comportamento lo danneggerà. L’opzione -Xexperimental, che attiva molte modifiche sperimentali, vi avvertirà che il comportamento di ridefinizione per i campi val è differente.

Il secondo espediente consiste nel rendere il campo val astratto nel tratto. Questo obbliga un’istanza che usa il tratto ad assegnare un valore al campo. Dichiarare un campo val astratto in un tratto è un approccio di progettazione perfetto da usare in entrambe le versioni di Scala. In effetti, questa è la scelta di progettazione migliore quando non esiste alcun valore predefinito appropriato da assegnare al campo val nel tratto.

// esempi/cap-6/overrides/trait-abs-val-script.scala

trait AbstractT1 {
  val name: String
}

class Base

class ClassWithAbstractT1 extends Base with AbstractT1 {
  val name = "ClassWithAbstractT1"
}

val c = new ClassWithAbstractT1()
println(c.name)

class ClassExtendsAbstractT1 extends AbstractT1 {
  val name = "ClassExtendsAbstractT1"
}

val c2 = new ClassExtendsAbstractT1()
println(c2.name)

Questo script produce l’uscita che ci aspettiamo.

ClassWithAbstractT1
ClassExtendsAbstractT1

Quindi un campo val astratto funziona bene, a meno che nel corpo del tratto il campo non sia usato in modo tale da provocare problemi se non viene opportunamente inizializzato. Sfortunatamente, l’inizializzazione non avverrà fino a quando il corpo del tratto non viene eseguito. Considerate l’esempio seguente.

// esempi/cap-6/overrides/trait-invalid-init-val-script.scala
// ERRORE: "value" viene letto prima di essere inizializzato.

trait AbstractT2 {
  println("In AbstractT2:")
  val value: Int
  val inverse = 1.0/value // ???
  println("AbstractT2: value = " + value + ", inverse = " + inverse)
}

val c2b = new AbstractT2 {
  println("In c2b:")
  val value = 10
}
println("c2b.value = " + c2b.value + ", inverse = " + c2b.inverse)

Nonostante new AbstractT2 {…} somigli alla creazione di un’istanza del tratto, in realtà stiamo usando una classe anonima che estende implicitamente il tratto. Questo script mostra cosa succede quando inverse viene calcolato.

In AbstractT2:
AbstractT2: value = 0, inverse = Infinity
In c2b:
c2b.value = 10, inverse = Infinity

Come vi potreste aspettare, inverse viene calcolato troppo presto. Notate che non viene lanciata nessuna eccezione di divisione per zero; il compilatore riconosce che il valore è infinito, ma in realtà non ha ancora “provato” la divisione!

Il comportamento dello script in effetti è piuttosto sottile. Come esercizio, provate a rimuovere (o a racchiudere in un commento) selettivamente le diverse istruzioni println, una alla volta. Osservate ciò che accade ai risultati. A volte inverse viene inizializzato propriamente! (Suggerimento: rimuovete l’istruzione println("In c2b:"). Poi provate a reinserirla, ma dopo la riga val value = 10.)

In realtà, questo esperimento mostra che gli effetti collaterali (come quelli causati dalle istruzioni println) possono essere sottili e inaspettati, specialmente durante l’inizializzazione. È meglio evitarli.

Scala offre due soluzioni per questo problema: i valori ritardati, che discuteremo nella sezione Valori ritardati del capitolo 8, e i campi pre-inizializzati, mostrati nel codice seguente che perfeziona l’esempio precedente.

// esempi/cap-6/overrides/trait-pre-init-val-script.scala

trait AbstractT2 {
  println("In AbstractT2:")
  val value: Int
  val inverse = 1.0/value
  println("AbstractT2: value = " + value + ", inverse = " + inverse)
}

val c2c = new {
  // Nei blocchi di pre-inizializzazione sono permesse
  // solo le inizializzazioni.
  // println("In c2c:")
  val value = 10
} with AbstractT2

println("c2c.value = " + c2c.value + ", inverse = " + c2c.inverse)

Qui abbiamo istanziato una classe annidata anonima, inizializzando il campo value nel blocco che precede la clausola with AbstractT2. Questo garantisce che value venga inizializzato prima di eseguire il corpo di AbstractT2, come mostrato dalla esecuzione dello script.

In AbstractT2:
AbstractT2: value = 10, inverse = 0.1
c2c.value = 10, inverse = 0.1

In più, se rimuovete selettivamente qualsiasi istruzione println, ottenete gli stessi risultati attesi e predicibili.

Il terzo espediente consiste nel cambiare la dichiarazione val in var. Questa soluzione è più adatta se esiste un buon valore predefinito per il campo e non volete obbligare le istanze che usano il tratto a impostare sempre il valore. In questo caso, potete cambiare la dichiarazione, sia che sia pubblica, sia che sia privata e quindi nascosta dietro una coppia di metodi di lettura e scrittura. In ogni caso, possiamo semplicemente riassegnare il valore in un tratto derivato o in una classe derivata.

Tornando al nostro esempio di VetoableClicks, ecco il tratto modificato che usa un campo var pubblico per maxAllowed.

// esempi/cap-6/ui3/vetoable-clicks.scala

package ui3
import observer._

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

Ecco un nuovo oggetto “di specifica” ButtonClickableObserverVetoableSpec2 che sperimenta la modifica del valore di maxAllowed.

// esempi/cap-6/ui3/button-clickable-observer-vetoable2-spec.scala
package ui3

import org.specs._
import observer._
import ui.ButtonCountObserver

object ButtonClickableObserverVetoableSpec2 extends Specification {
  "Un osservatore di un pulsante con clic vietabili" should {
    "osservare solo i primi 'maxAllowed' clic" in {
      val observableButton =
        new Button("Okay") with ObservableClicks with VetoableClicks {
          maxAllowed = 2
      }
      observableButton.maxAllowed mustEqual 2
      val buttonClickCountObserver = new ButtonCountObserver
      observableButton.addObserver(buttonClickCountObserver)
      for (i <- 1 to 3) observableButton.click()
      buttonClickCountObserver.count mustEqual 2
    }
  }
}

Nessuna dichiarazione override var è necessaria. Assegniamo semplicemente un nuovo valore. Dato che il corpo del tratto viene eseguito prima del corpo della classe che lo usa, il riassegnamento del valore del campo avviene dopo l’assegnamento iniziale nel corpo del tratto. Tuttavia, come abbiamo visto prima, questo riassegnamento potrebbe avvenire troppo tardi se, nel corpo del tratto, il campo viene usato in qualche calcolo che non sarà più valido dopo il riassegnamento. Potete evitare questo problema rendendo il campo privato e definendo un metodo di scrittura pubblico che riesegua tutti i calcoli dipendenti dal valore del campo.

La dichiarazione var ha un altro svantaggio: maxAllowed non era stato concepito per essere modificabile. Come vedremo nel capitolo 8, i valori a sola lettura portano benefici importanti. Preferiremmo che maxAllowed fosse a sola lettura, almeno dopo il completamento del processo di costruzione.

Possiamo vedere che la semplice modifica della dichiarazione val in var causa potenziali problemi al manutentore di VetoableClicks. Ora si è perso il controllo del campo. Il manutentore deve considerare con attenzione l’eventualità che il valore cambi e le conseguenze di un tale cambiamento sulla validità dello stato dell’istanza. Questo problema è particolarmente pernicioso nei sistemi multithread (si veda la sezione I problemi dello stato condiviso e sincronizzato nel capitolo 9).

Evitate i campi var quando è possibile (sia nelle classi sia nei tratti). Considerate particolarmente rischiosi i campi var pubblici.

Ridefinire i campi astratti e concreti nelle classi

Al contrario di quanto accade nei tratti, la ridefinizione di un campo val dichiarato in una classe funziona come ci si aspetta. Ecco un esempio che contiene sia la ridefinizione di un campo val, sia un riassegnamento di un campo var in una classe derivata.

// esempi/cap-6/overrides/class-field-script.scala

class C1 {
  val name = "C1"
  var count = 0
}

class ClassWithC1 extends C1 {
  override val name = "ClassWithC1"
  count = 1
}

val c = new ClassWithC1()
println(c.name)
println(c.count)

La parola chiave override è obbligatoria per il campo val concreto name, ma non per il campo var count, perché stiamo modificando l’inizializzazione di una costante (val), che è un’operazione “speciale”.

Se eseguite questo script, ottenete l’uscita seguente.

ClassWithC1
1

Nella classe derivata, entrambi i campi vengono ridefiniti come previsto. Ecco lo stesso esempio modificato in modo che i campi val e var della classe base siano astratti.

// esempi/cap-6/overrides/class-abs-field-script.scala

abstract class AbstractC1 {
  val name: String
  var count: Int
}

class ClassWithAbstractC1 extends AbstractC1 {
  val name = "ClassWithAbstractC1"
  var count = 1
}

val c = new ClassWithAbstractC1()
println(c.name)
println(c.count)

La parola chiave override non è obbligatoria per name in ClassWithAbstractC1, dato che la dichiarazione originale è astratta. L’uscita di questo script è la seguente.

ClassWithAbstractC1
1

È importante sottolineare che name e count sono campi astratti, non campi concreti con valori predefiniti. Una dichiarazione simile di name in una classe Java, String name;, rappresenterebbe un campo concreto con un valore predefinito (null in questo caso). Java non supporta campi o tipi astratti (come diremo più avanti), solo metodi.

Ridefinire i tipi astratti

Abbiamo introdotto le dichiarazioni di tipo astratto 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)

I tipi astratti sono un’alternativa ai tipi parametrici, che esploreremo nella sezione Capire i tipi parametrici del capitolo 12. Come i tipi parametrici, essi forniscono un meccanismo di astrazione a livello di tipo.

L’esempio mostra come dichiarare un tipo astratto e come definire un valore concreto per il tipo nelle classi derivate. BulkReader dichiara type In senza inizializzarlo. La classe concreta derivata StringBulkReader fornisce al tipo un valore concreto usando type In = String.

A differenza dei campi e dei metodi, non è possibile ridefinire una definizione type concreta. Tuttavia, la dichiarazione astratta può vincolare i valori concreti consentiti per il tipo; vedremo come nel capitolo 12.

Infine, avrete probabilmente notato che questo esempio mostra anche come definire un campo astratto, tramite un parametro del costruttore, e un metodo astratto.

Per fare un altro esempio, riprendiamo il nostro tratto Subject dalla sezione I tratti come mixin nel capitolo 4. Il tipo Observer viene definito come un tipo strutturale con un metodo chiamato receiveUpdate. Gli osservatori devono avere questa “struttura”. Ora generalizziamo l’implementazione usando un tipo astratto.

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

Ora AbstractSubject dichiara type Observer come astratto (implicitamente, in quanto non c’è nessuna definizione). Dato che il tipo strutturale originale è scomparso, non sappiamo esattamente come informare un osservatore. Quindi, abbiamo aggiunto anche un metodo astratto notify, che un tratto o una classe concreta definiranno in maniera appropriata.

Il tratto derivato SubjectForReceiveUpdateObservers definisce Observer con lo stesso tipo strutturale che abbiamo usato nell’esempio originale e notify si limita a chiamare receiveUpdate, come prima.

Il tratto derivato SubjectForFunctionalObservers definisce Observer come una funzione che prende un’istanza di AbstractSubject e restituisce Unit. Tutto quello che notify deve fare è invocare la funzione osservatore, passando il soggetto come unico argomento. Notate che questa implementazione è simile all’approccio che abbiamo usato nella nostra implementazione originale del pulsante ButtonWithCallbacks, dove le “callback” erano funzioni fornite dall’utente. (Si vedano la sezione Una introduzione ai tratti nel capitolo 4 e una versione riveduta nella sezione I costruttori in Scala del capitolo 5.)

Ecco una specifica che esercita queste due variazioni, osservando i clic sul pulsante come prima.

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

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

object ButtonObserver2Spec extends Specification {
  "Un osservatore che controlla un pulsante SubjectForReceiveUpdateObservers" should {
    "osservare i clic sul pulsante" in {
      val observableButton =
        new Button(name) with SubjectForReceiveUpdateObservers {
        override def click() = {
          super.click()
          notifyObservers
        }
      }
      val buttonObserver = new ButtonCountObserver
      observableButton.addObserver(buttonObserver)
      for (i <- 1 to 3) observableButton.click()
      buttonObserver.count mustEqual 3
    }
  }
  "Un osservatore che controlla un pulsante SubjectForFunctionalObservers" should {
    "osservare i clic sul pulsante" in {
      val observableButton =
        new Button(name) with SubjectForFunctionalObservers {
        override def click() = {
          super.click()
          notifyObservers
        }
      }
      var count = 0
      observableButton.addObserver((button) => count += 1)
      for (i <- 1 to 3) observableButton.click()
      count mustEqual 3
    }
  }
}

Prima esercitiamo SubjectForReceiveUpdateObservers, che sembra molto simile ai nostri esempi precedenti. Poi esercitiamo SubjectForFunctionalObservers: in questo caso non abbiamo bisogno di istanziare un “osservatore” vero e proprio, ma ci basta mantenere una variabile count e passare al metodo addObserver un letterale funzione che incrementa il conto (e ignora il pulsante).

La qualità principale di SubjectForFunctionalObservers è il suo minimalismo. Non richiede istanze particolari, né tratti che definiscono astrazioni, &c. In molti casi, è un approccio ideale.

AbstractSubject è maggiormente riusabile rispetto alla definizione originale di Subject, perché impone meno vincoli sui possibili osservatori.

AbstractSubject mostra che un’astrazione con un numero inferiore di dettagli concreti di solito è maggiormente riusabile.

Ma aspettate, c’è di più! Rivisiteremo l’uso dei tipi astratti e il pattern observer nella sezione Astrazioni scalabili del capitolo 13.

Quando i metodi di accesso e i campi sono indistinguibili: il principio di accesso uniforme

Supponete che un utente della classe ButtonCountObserver, vista nella sezione I tratti come mixin del capitolo 4, acceda al membro count.

// esempi/cap-4/ui/button-count-observer-script.scala

val bco = new ui.ButtonCountObserver
val oldCount = bco.count
bco.count = 5
val newCount = bco.count
println(newCount + " == 5 e " + oldCount + " == 0?")

Quando il campo count viene letto o scritto, come in questo esempio, si invocano metodi o si accede direttamente al campo? Per come è fatta la dichiarazione originale di ButtonCountObserver, si accede direttamente al campo. Tuttavia, questo non ha importanza per l’utente. Infatti, dal punto di vista dell’utente, le due definizioni seguenti sono funzionalmente equivalenti.

class ButtonCountObserver {
  var count = 0  // campo ad accesso pubblico (definizione originale)
  // …
}
class ButtonCountObserver {
  private var cnt = 0  // campo privato
  def count = cnt      // metodo di lettura
  def count_=(newCount: Int) = cnt = newCount  // metodo di scrittura
  // …
}

Questa equivalenza è un esempio del principio di accesso uniforme. I clienti leggono e scrivono il valore di un campo come se il campo fosse pubblicamente accessibile, anche se in alcuni casi stanno effettivamente invocando metodi. Chi mantiene ButtonCountObserver è libero di cambiare l’implementazione senza obbligare gli utenti a modificare il loro codice.

Nella seconda versione della classe, il metodo di lettura non ha parentesi. Ricordatevi che è obbligatorio usare le parentesi in maniera consistente se la definizione di un metodo le omette. L’omissione è consentita solo se il metodo non accetta argomenti. Per fare in modo che il principio di accesso uniforme funzioni, è necessario definire i metodi di lettura senza le parentesi. (Confrontate questo approccio con Ruby, dove le parentesi dei metodi sono sempre opzionali, purché il codice non sia ambiguo.)

Il metodo di scrittura ha la forma count_=(…). Come zucchero sintattico, il compilatore consente di scrivere le invocazioni dei metodi che hanno questa forma nei modi seguenti.

oggetto.campo_=(nuovoValore)
// oppure
oggetto.campo = nuovoValore

Nella definizione alternativa abbiamo chiamato cnt la variabile privata. Scala tiene i nomi dei campi e dei metodi nello stesso spazio di nomi; questo significa che non possiamo dare al campo il nome count se esiste già un metodo chiamato count. Molti linguaggi, come Java, non hanno questa restrizione, perché tengono i nomi dei campi in uno spazio di nomi separato rispetto ai nomi dei metodi. Tuttavia, come risultato, questi linguaggi non possono supportare il principio di accesso uniforme, a meno che non modifichino ad hoc la loro sintassi e il loro compilatore.

Dato che le definizioni dei membri object si comportano in modo simile ai campi dal punto di vista del chiamante, anche queste si trovano nello stesso spazio di nomi dei metodi e dei campi. Quindi, la classe seguente non verrebbe compilata.

// esempi/cap-6/overrides/member-namespace-wont-compile.scala
// NON verrà compilato!

class IllegalMemberNameUse {
  def member(i: Int) = 2 * i
  val member = 2         // ERRORE
  object member {        // ERRORE
    def apply() = 2
  }
}
Questa “unificazione” di spazi di nomi ha un altro vantaggio. Se una classe genitore dichiara un metodo senza parametri, allora una sottoclasse può ridefinire quel metodo con un campo val. Se il metodo del genitore è concreto, allora la parola chiave override è obbligatoria.
// esempi/cap-6/overrides/method-field-class-script.scala

class Parent {
  def name = "Parent"
}

class Child extends Parent {
  override val name = "Child"
}

println(new Child().name)   // => "Child"

Se il metodo del genitore è astratto, allora la parola chiave override è opzionale.

// esempi/cap-6/overrides/abs-method-field-class-script.scala

abstract class AbstractParent {
  def name: String
}

class ConcreteChild extends AbstractParent {
  val name = "ConcreteChild"
}

println(new ConcreteChild().name)   // => "ConcreteChild"

Questo funziona anche con i tratti. Se il metodo del tratto è concreto, abbiamo la situazione seguente.

// esempi/cap-6/overrides/method-field-trait-script.scala

trait NameTrait {
  def name = "NameTrait"
}

class ConcreteNameClass extends NameTrait {
  override val name = "ConcreteNameClass"
}

println(new ConcreteNameClass().name)   // => "ConcreteNameClass"

Se il metodo del tratto è astratto, allora abbiamo la situazione seguente.

// esempi/cap-6/overrides/abs-method-field-trait-script.scala

trait AbstractNameTrait {
  def name: String
}

class ConcreteNameClass extends AbstractNameTrait {
  val name = "ConcreteNameClass"
}

println(new ConcreteNameClass().name)   // => "ConcreteNameClass"

Questa caratteristica è utile perché consente alle classi derivate e ai tratti derivati di usare solo l’accesso diretto al campo quando questo è sufficiente, oppure un’invocazione di metodo quando sono necessarie ulteriori elaborazioni, come nel caso della inizializzazione ritardata. In generale, la stessa considerazione vale anche per il principio di accesso uniforme.

Ridefinire un metodo def con un campo val in una sottoclasse può anche essere comodo per interoperare con il codice Java. Potete trasformare un metodo di accesso in un campo val mettendolo nel costruttore, come mostra l’esempio seguente, in cui la nostra classe Scala Person implementa un’ipotetica PersonInterface che proviene da codice Java esistente.

class Person(val getName: String) extends PersonInterface

Se i metodi di accesso presenti nel codice Java da integrare sono pochi, questa tecnica se ne prenderà cura velocemente.

Cosa succede se ridefiniamo un metodo senza parametri con un campo var, o se ridefiniamo un campo val o var con un metodo? Queste ridefinizioni non sono permesse, perché il loro comportamento non può corrispondere a quello delle entità che ridefiniscono.

Se tentate di usare un campo var per ridefinire un metodo senza parametri otterrete un errore, perché il metodo di scrittura override name_= non ridefinisce nulla. In più, questa ridefinizione non sarebbe coerente con uno degli obiettivi filosofici della programmazione funzionale, per la quale un metodo che non accetta parametri dovrebbe restituire sempre lo stesso risultato. Un’implementazione differente richiederebbe la presenza di effetti collaterali, che la programmazione funzionale cerca di evitare per ragioni che esamineremo nel capitolo 8. Dato che un campo var è modificabile, il “metodo” senza parametri definito nel tipo genitore non restituirebbe più lo stesso risultato in maniera consistente.

Se poteste ridefinire un campo val con un metodo, Scala non sarebbe in grado di garantire che il metodo restituisca sempre lo stesso valore in accordo con la semantica della dichiarazione val. Un campo var non ha questo problema, naturalmente, ma dovreste ridefinire questo campo con due metodi, uno per la lettura e uno per la scrittura. Il compilatore di Scala non supporta questa sostituzione.

Oggetti associati

Ricordatevi che i campi e i metodi definiti negli object giocano lo stesso ruolo dei campi e dei metodi “statici” nei linguaggi simili a Java. I campi e i metodi di un object che sono intimamente associati con una classe particolare vengono normalmente definiti in un oggetto associato.

Abbiamo menzionato velocemente gli oggetti associati nel primo capitolo e abbiamo parlato della classe Pair, proveniente dalla libreria Scala, nel secondo capitolo e nel capitolo precedente. Ora è venuto il momento di completare il quadro con i dettagli rimanenti.

Prima di tutto, ricordatevi che se una classe (o una dichiarazione type che fa riferimento a una classe) e un object vengono dichiarati nello stesso file, nello stesso package e con lo stesso nome, vengono chiamati classe associata (o tipo associato) e oggetto associato, rispettivamente.

Quando il nome viene riutilizzato in questo modo non c’è nessun conflitto di nomi, perché Scala memorizza il nome della classe nello spazio di nomi dei tipi e memorizza il nome dell’oggetto nello spazio di nomi dei termini [ScalaSpec2009].

I due metodi più interessanti che vengono frequentemente definiti in un oggetto associato sono apply e unapply.

Il metodo apply

Il metodo apply è una forma di zucchero sintattico fornita da Scala. Quando un’istanza di una classe è seguita da una coppia di parentesi che racchiude una lista di zero o più argomenti, il compilatore invoca il metodo apply per quella istanza. Questo vale sia per un object che definisce un metodo apply (come un oggetto associato) sia per un’istanza di una classe che definisce un metodo apply.

Nel caso di un object, il metodo apply viene convenzionalmente usato come metodo factory che restituisce una nuova istanza. Questo è ciò che fa Pair.apply nella libreria Scala. Ecco la definizione di Pair dalla libreria standard.

type Pair[+A, +B] = Tuple2[A, B]
object Pair {
  def apply[A, B](x: A, y: B) = Tuple2(x, y)
  def unapply[A, B](x: Tuple2[A, B]): Option[Tuple2[A, B]] = Some(x)
}

Quindi, potete creare una nuova istanza di Pair nel modo seguente.

val p = Pair(1, "uno")

Sembra che, in qualche modo, stiamo creando un’istanza di Pair senza usare new. Anziché invocare un costruttore di Pair direttamente, stiamo in realtà invocando Pair.apply (usando l’oggetto associato Pair) che a sua volta invoca Tuple2.apply sull’oggetto associato Tuple2!

Se una classe è dotata di diversi costruttori alternativi e possiede anche un oggetto associato, considerate la possibilità di rimuovere alcuni costruttori dalla classe e di definire diversi metodi apply sovraccaricati nell’oggetto associato per gestire le variazioni.

Tuttavia, apply non deve limitarsi a istanziare la classe associata. Invece, potrebbe restituire un’istanza di una sottoclasse della classe associata. Nel prossimo esempio, definiremo un oggetto associato Widget che usa le espressioni regolari per riconoscere una stringa che rappresenta una sottoclasse di Widget. Quando viene trovata una corrispondenza, la sottoclasse viene istanziata e la nuova istanza viene restituita.

// esempi/cap-6/objects/widget.scala

package objects

abstract class Widget {
  def draw(): Unit
  override def toString() = "(elemento)"
}

object Widget {
  val ButtonExtractorRE = """\(pulsante: etichetta=([^,]+),\s+\(elemento\)\)""".r
  val TextFieldExtractorRE = """\(campoditesto: testo=([^,]+),\s+\(elemento\)\)""".r

  def apply(specification: String): Option[Widget] = specification match {
    case ButtonExtractorRE(label)   => new Some(new Button(label))
    case TextFieldExtractorRE(text) => new Some(new TextField(text))
    case _ => None
  }
}

Widget.apply riceve una stringa “di specifica” che definisce la classe da istanziare. La stringa potrebbe provenire da un file di configurazione contenente gli elementi grafici da creare all’avvio, per esempio. Il formato della stringa è lo stesso usato da toString(). Sono definite espressioni regolari per ogni tipo. (Come alternativa si potrebbero usare gli operatori di riconoscimento, discussi nella sezione DSL esterni con la combinazione di riconoscitori del capitolo 11.)

L’espressione match applica ogni espressione regolare alla stringa. In un’espressione come:

case ButtonExtractorRE(label) => new Some(new Button(label))

si effettua la ricerca di una corrispondenza tra la stringa e l’espressione regolare ButtonExtractorRE. Se la corrispondenza viene trovata, si estrae la sottostringa del primo gruppo di cattura nell’espressione regolare e la si assegna alla variabile label. Infine, si crea un nuovo pulsante con questa etichetta, racchiuso in un’istanza di Some. Impareremo il funzionamento di questo processo di estrazione nella prossima sezione.

La creazione di istanze di TextField viene gestita con un caso simile. (Qui TextField non viene mostrato. Si vedano gli esempi di codice sul sito di O’Reilly.) Infine, se apply non trova una corrispondenza con la stringa, restituisce None.

Ecco un object “di specifica” che esercita Widget.apply.

// esempi/cap-6/objects/widget-apply-spec.scala

package objects
import org.specs._

object WidgetApplySpec extends Specification {
  "Widget.apply con una stringa di specifica valida per un elemento" should {
    "restituire un'istanza con i campi corretti impostati" in {
      Widget("(pulsante: etichetta=cliccami, (elemento))") match {
        case Some(w) => w match {
          case b:Button => b.label mustEqual "cliccami"
          case x => fail(x.toString())
        }
        case None => fail("Restituito None.")
      }
      Widget("(campoditesto: testo=Questo è testo, (elemento))") match {
        case Some(w) => w match {
          case tf:TextField => tf.text mustEqual "Questo è testo"
          case x => fail(x.toString())
        }
        case None => fail("Restituito None.")
      }
    }
  }
  "Widget.apply con una stringa di specifica non valida" should {
    "restituire None" in {
      Widget("(pulsante: , (elemento)") mustEqual None
    }
  }
}

La prima istruzione match invoca implicitamente Widget.apply con la stringa "(pulsante: etichetta=cliccami, (elemento))". Se non viene restituito un pulsante con l’etichetta "cliccami" racchiuso in un’istanza di Some, questo test fallirà. Poi viene eseguito un test simile per un elemento grafico TextField. L’ultimo test usa una stringa non valida e conferma la restituzione di None.

Uno svantaggio di questa particolare implementazione è che abbiamo dovuto cablare una dipendenza da ogni classe derivata da Widget nella classe Widget stessa, violando così il principio aperto-chiuso (si vedano [Meyer1997] e [Martin2003]). Un’implementazione migliore userebbe un pattern di progettazione factory [GOF1995]. Nondimeno, l’esempio mostra che il metodo apply può essere usato come una factory reale.

Non siete obbligati a usare il metodo apply di un object come una factory, e non ci sono restrizioni sulla lista di argomenti o sul valore di ritorno di apply. Tuttavia, essendo così comune l’uso di apply come factory in un object, fate attenzione quando usate apply per altri scopi, in quanto potreste confondere i vostri utenti. In ogni caso, esistono validi controesempi, come l’uso di apply nei linguaggi domain-specific (si veda il capitolo 11).

Il metodo apply definito nelle classi segue più raramente questa convenzione. Per esempio, nella libreria standard di Scala, Array.apply(i: int) restituisce l’elemento di indice i nell’array. Molte altre collezioni usano apply in modo simile. Quindi, gli utenti possono scrivere codice come quello che segue.

val a = Array(1,2,3,4)
println(a(2))  // => 3

Infine, come promemoria, apply non è diverso dagli altri metodi, nonostante venga gestito in modo particolare dal compilatore. Potete sovraccaricarlo, invocarlo direttamente, &c.

Il metodo unapply

Il nome unapply suggerisce che questo metodo esegua l’operazione “opposta” rispetto a quella eseguita da apply. In effetti, unapply viene usato per estrarre le parti costituenti di un’istanza. Il pattern matching fa ampio uso di questa funzione. Quindi, unapply viene spesso definito negli oggetti associati ed è usato per estrarre i valori dei campi dalle istanze dei tipi associati corrispondenti. Per questo motivo, i metodi unapply vengono chiamati estrattori.

Ecco una versione ampliata di button.scala con un object Button che definisce un metodo estrattore unapply.

// esempi/cap-6/objects/button.scala

package objects
import ui3.Clickable

class Button(val label: String) extends Widget with Clickable {

  def click() = {
    // Logica per mostrare visivamente il clic sul pulsante...
  }

  def draw() = {
    // Logica per disegnare il pulsante su uno schermo, una pagina web, &c.
  }

  override def toString() = "(pulsante: etichetta=" + label + ", " + super.toString() + ")"
}

object Button {
  def unapply(button: Button) = Some(button.label)
}

Button.unapply accetta un singolo argomento di tipo Button e restituisce il valore di label racchiudendolo in un’istanza di Some. Questo esempio mostra il protocollo dei metodi unapply: essi restituiscono un’istanza di Some che racchiude i campi estratti. (Vedremo come gestire più di un campo fra un momento.)

Ecco un oggetto “di specifica” che esercita Button.unapply.

// esempi/cap-6/objects/button-unapply-spec.scala

package objects
import org.specs._

object ButtonUnapplySpec extends Specification {
  "Button.unapply" should {
    "trovare una corrispondenza con un oggetto Button" in {
      val b = new Button("cliccami")
      b match {
        case Button(label) => label mustEqual "cliccami"
        case _ => fail()
      }
    }
    "trovare una corrispondenza con un oggetto RadioButton" in {
      val b = new RadioButton(false, "cliccami")
      b match {
        case Button(label) => label mustEqual "cliccami"
        case _ => fail()
      }
    }
    "non trovare una corrispondenza con un oggetto diverso da Button" in {
      val tf = new TextField("ciao mondo!")
      tf match {
        case Button(label) => fail()
        case x => x must notBeNull // hack per evitare che Specs ignori questo test
      }
    }
    "estrarre l'etichetta di un pulsante" in {
      val b = new Button("cliccami")
      b match {
        case Button(label) => label mustEqual "cliccami"
        case _ => fail()
      }
    }
    "estrarre l'etichetta di un pulsante radio" in {
      val rb = new RadioButton(false, "clicca anche me")
      rb match {
        case Button(label) => label mustEqual "clicca anche me"
        case _ => fail()
      }
    }
  }
}

I primi tre esempi (cioè le prime tre clausole in) confermano che Button.unapply viene effettivamente chiamato solo per le istanze di Button o per le istanze delle classi derivate come RadioButton.

Dato che unapply accetta un argomento di tipo Button (in questo caso), l’interprete Scala controlla il tipo dell’istanza coinvolta nella corrispondenza, poi cerca un oggetto associato con un metodo unapply e invoca quel metodo passandogli l’istanza. La clausola jolly case _ viene invocata per le istanze che risultano incompatibili dopo il controllo di tipo. Il processo di pattern matching è completamente type-safe.

Gli esempi rimanenti (cioè le altre clausole in) confermano l’estrazione dei valori corretti di label. L’interprete Scala estrae automaticamente l’elemento contenuto nell’istanza di Some.

Come si fa per estrarre più di un campo? Per un insieme fisso di campi noti, si restituisce un’istanza di Some che racchiude un’istanza di Tuple, come mostrato in questa versione aggiornata di RadioButton.

// esempi/cap-6/objects/radio-button.scala

package objects

/**
 * Pulsante con due stati, attivo (on) o disattivo (off), come
 * il pulsante di selezione del canale sulle radio vecchio stile.
 */
class RadioButton(val on: Boolean, label: String) extends Button(label)

object RadioButton {
  def unapply(button: RadioButton) = Some((button.on, button.label))
                 // equivalente a: = Some(Pair(button.on, button.label))
}

Qui viene restituita un’istanza di Some che racchiude Pair(button.on, button.label). Come diremo nella sezione L’oggetto Predef del capitolo 7, Pair è un tipo definito per essere uguale a Tuple2. Ecco il corrispondente oggetto “di specifica” che collauda RadioButton.

// esempi/cap-6/objects/radio-button-unapply-spec.scala

package objects
import org.specs._

object RadioButtonUnapplySpec extends Specification {
  "RadioButton.unapply" should {
    "trovare una corrispondenza con un oggetto RadioButton" in {
      val b = new RadioButton(true, "cliccami")
      b match {
        case RadioButton(on, label) => label mustEqual "cliccami"
        case _ => fail()
      }
    }
    "non trovare una corrispondenza con un oggetto Button (classe genitore)" in {
      val b = new Button("cliccami")
      b match {
        case RadioButton(on, label) => fail()
        case x => x must notBeNull
      }
    }
    "non trovare una corrispondenza con un oggetto diverso da RadioButton" in {
      val tf = new TextField("ciao mondo!")
      tf match {
        case RadioButton(on, label) => fail()
        case x => x must notBeNull
      }
    }
    "estrarre lo stato on/off e l'etichetta di un pulsante radio" in {
      val b = new RadioButton(true, "cliccami")
      b match {
        case RadioButton(on, label) => {
          label mustEqual "cliccami"
          on    mustEqual true
        }
        case _ => fail()
      }
    }
  }
}

apply e unapply per le collezioni

E se volessimo costruire una collezione a partire da una lista variabile di argomenti passata ad apply? E se volessimo estrarre i primi elementi di una collezione senza curarci dei rimanenti?

In questo caso, dovete definire i metodi apply e unapplySeq (un metodo unapply per le sequenze). Ecco come sono definiti questi metodi per la classe List di Scala.

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

def unapplySeq[A](x: List[A]): Some[List[A]] = Some(x)

La parametrizzazione di tipo [A] su questi metodi consente all’oggetto associato List, che non è parametrico, di costruire una nuova istanza di List[A]. (Si veda la sezione Capire i tipi parametrici nel capitolo 12 per maggiori dettagli.) Molto spesso, il parametro di tipo verrà inferito sulla base del contesto.

La lista di parametri xs: A* è una lista variabile di argomenti. Chi invoca apply può passare tutte le istanze di A che vuole, anche nessuna. Internamente, le liste variabili di argomenti vengono memorizzate in un’istanza di Array[A], che eredita da Iterable il metodo toList qui utilizzato.

Chi scrive API troverà questo idioma molto comodo. Accettare un numero variabile di argomenti per una funzione può essere conveniente per gli utenti, e convertire gli argomenti in una lista è spesso la soluzione ideale per la loro gestione interna.

Ecco uno script di esempio che usa List.apply implicitamente.

// esempi/cap-6/objects/list-apply-example-script.scala

val list1 = List()
val list2 = List(1, 2.2, "tre", 'quattro)
val list3 = List("1", "2.2", "tre", "quattro")
println("1: " + list1)
println("2: " + list2)
println("3: " + list3)

Qui 'quattro è un simbolo, essenzialmente una stringa internata. I simboli vengono usati più comunemente in Ruby, per esempio, dove questo simbolo sarebbe scritto come :quattro. I simboli sono utili per rappresentare le identità in maniera consistente.

Lo script produce questa uscita.

1: List()
2: List(1, 2.2, tre, 'quattro)
3: List(1, 2.2, tre, quattro)

Il metodo unapplySeq è banale: restituisce la lista in ingresso racchiusa in un’istanza di Some. Tuttavia, questo è sufficiente per il pattern matching, come mostrato nell’esempio seguente.

// esempi/cap-6/objects/list-unapply-example-script.scala

val list = List(1, 2.2, "tre", 'quattro)
list match {
  case List(x, y, _*) => println("x = " + x + ", y = " + y)
  case _ => throw new Exception("Nessuna corrispondenza! " + list)
}

La sintassi List(x, y, _*) indica che vogliamo cercare una corrispondenza solo su una lista con almeno due elementi e che i primi due elementi verranno assegnati a x e y. Il resto della lista non ci interessa. _* corrisponde agli zero o più elementi rimanenti.

L’uscita è la seguente.

x = 1, y = 2.2

Avremo molte più cose da dire sull’uso delle liste con il pattern matching nella sezione Le liste nella programmazione funzionale del capitolo 8.

Gli oggetti associati e i metodi statici di Java

C’è ancora una cosa da sapere sugli oggetti associati. Ogni volta che definite un metodo main come punto d’ingresso di un’applicazione, Scala vi obbliga a collocarlo in un oggetto. Tuttavia, al momento in cui scriviamo, i metodi main non possono essere definiti in un oggetto associato. A causa dei dettagli di implementazione del codice generato, la JVM non troverà il metodo main. Questo problema potrebbe essere risolto in una futura versione di Scala. Per ora, dovete definire qualsiasi metodo main in un oggetto singleton (cioè, un oggetto “non associato”) [ScalaTips]. Considerate l’esempio seguente: una classe Person e il suo oggetto associato che tenta di definire un metodo main.
// esempi/cap-6/objects/person.scala

package objects

class Person(val name: String, val age: Int) {
  override def toString = "nome: " + name + ", età: " + age
}

object Person {
  def apply(name: String, age: Int) = new Person(name, age)
  def unapply(person: Person) = Some((person.name, person.age))

  def main(args: Array[String]) = {
    // Collaudiamo il costruttore...
    val person = new Person("Buck Trends", 18)
    assert(person.name == "Buck Trends")
    assert(person.age  == 21)
  }
}

object PersonTest {
  def main(args: Array[String]) = Person.main(args)
}

Questo codice viene compilato senza problemi, ma se provate a invocare Person.main usando scala -cp … objects.Person, ottenete l’errore seguente.

java.lang.NoSuchMethodException: objects.Person.main([Ljava.lang.String;)

Se decompilate il file objects/Person.class con javap -classpath … objects.Person (si veda la sezione Gli strumenti scalap, javap e jad a riga di comando nel capitolo 14), potete vedere che non contiene un metodo main. Se decompilate objects/Person$.class, che contiene il bytecode dell’oggetto associato, trovate un metodo main che però non è stato dichiarato static. Quindi, anche il tentativo di invocare scala -cp … objects.Person$ non riuscirà a trovare il metodo main “statico”.

java.lang.NoSuchMethodException: objects.Person$.main is not static

Dovete usare l’oggetto singleton separato PersonTest che è definito nell’esempio. La decompilazione con javap -classpath … objects.PersonTest mostra che l’oggetto possiede un metodo static main. Se invocate scala -cp … objects.PersonTest, viene invocato il metodo PersonTest.main, che a sua volta invoca Person.main. Nella seconda invocazione di assert otterrete un errore di asserzione intenzionale.

java.lang.AssertionError: assertion failed
    at scala.Predef$.assert(Predef.scala:87)
    at objects.Person$.test(person.scala:15)
    at objects.PersonTest$.main(person.scala:20)
    at objects.PersonTest.main(person.scala)
    …

In effetti, questo è un problema generale: i metodi definiti negli oggetti associati non sono statici nel bytecode; se avete bisogno di metodi che devono essere visibili al codice Java sotto forma di metodi statici, li dovete definire negli oggetti singleton. Considerate la classe Java seguente, che tenta di creare un utente con Person.apply.

// esempi/cap-6/objects/PersonUserWontCompile.java
// NON verrà compilato!

package objects;

public class PersonUserWontCompile {
  public static void main(String[] args) {
    Person buck = Person.apply("Buck Trends", 100);  // ERRORE
    System.out.println(buck);
  }
}

Se la compiliamo (dopo aver compilato Person.scala), otteniamo l’errore seguente.

$ javac -classpath … objects/PersonUserWontCompile.java
objects/PersonUserWontCompile.java:5: cannot find symbol
symbol  : method apply(java.lang.String,int)
location: class objects.Person
        Person buck = Person.apply("Buck Trends", 100);
                            ^
1 error

Tuttavia, possiamo usare l’oggetto singleton seguente.

// esempi/cap-6/objects/person-factory.scala

package objects

object PersonFactory {
  def make(name: String, age: Int) = new Person(name, age)
}

Ora la classe Java seguente verrà compilata.

// esempi/cap-6/objects/PersonUser.java

package objects;

public class PersonUser {
  public static void main(String[] args) {
    // La riga seguente non viene compilata.
    // Person buck = Person.apply("Buck Trends", 100);
    Person buck = PersonFactory.make("Buck Trends", 100);
    System.out.println(buck);
  }
}

Evitate di definire in un oggetto associato il metodo main o altri metodi che devono essere visibili al codice Java sotto forma di metodi static. Invece, definiteli in un oggetto singleton.

Se non avete altra scelta tranne quella di invocare da Java un metodo in un oggetto associato, potete esplicitamente creare un’istanza dell’oggetto con new, dato che nel bytecode l’oggetto è una classe Java “ordinaria”, e invocare il metodo su quella istanza.

Classi case

Nella sezione Corrispondenze sulle classi case del capitolo 3, vi abbiamo brevemente presentato le classi case. Le classi case posseggono diverse caratteristiche utili, ma presentano anche alcuni inconvenienti.

Riscriviamo l’esempio di Shape che abbiamo creato nella sezione Un assaggio di concorrenza del capitolo 1 in modo da usare le classi case. Ecco l’implementazione originale.

// esempi/cap-1/shapes.scala

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

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

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

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

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

Ecco l’esempio riscritto usando la parola chiave case.

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

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

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

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

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

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

L’aggiunta della parola chiave case induce il compilatore ad aggiungere automaticamente un certo numero di caratteristiche utili. La parola chiave suggerisce un’associazione con le espressioni case nel pattern matching. In effetti, queste classi sono particolarmente adatte per essere usate in quel contesto, come vedremo.

Per prima cosa, il compilatore converte automaticamente i parametri del costruttore in campi immutabili (cioè campi val). La parola chiave val è opzionale. Se volete campi mutabili, usate la parola chiave var. Quindi, le liste di parametri dei nostri costruttori ora sono più corte.

Come seconda cosa, il compilatore implementa automaticamente i metodi equals, hashCode e toString della classe usando i campi specificati come parametri del costruttore. Quindi, non abbiamo più bisogno dei nostri metodi toString. In effetti, i metodi toString generati producono la stessa uscita di quelli che avevamo implementato noi. In più, il corpo di Point è sparito perché non abbiamo bisogno di definire alcun metodo!

Lo script seguente usa questi metodi, che ora si trovano nelle forme geometriche.

// esempi/cap-6/shapes/shapes-usage-example1-script.scala

import shapes._

val shapesList = List(
  Circle(Point(0.0, 0.0), 1.0),
  Circle(Point(5.0, 2.0), 3.0),
  Rectangle(Point(0.0, 0.0), 2, 5),
  Rectangle(Point(-2.0, -1.0), 4, 3),
  Triangle(Point(0.0, 0.0), Point(1.0, 0.0), Point(0.0, 1.0)))

val shape1 = shapesList.head  // prende la prima
println("shape1: " + shape1 + ". hash = " + shape1.hashCode)
for (shape2 <- shapesList) {
  println("shape2: " + shape2 + ". 1 == 2 ? " + (shape1 == shape2))
}

Lo script produce il risultato seguente.

shape1: Circle(Point(0.0,0.0),1.0). hash = 2061963534
shape2: Circle(Point(0.0,0.0),1.0). 1 == 2 ? true
shape2: Circle(Point(5.0,2.0),3.0). 1 == 2 ? false
shape2: Rectangle(Point(0.0,0.0),2.0,5.0). 1 == 2 ? false
shape2: Rectangle(Point(-2.0,-1.0),4.0,3.0). 1 == 2 ? false
shape2: Triangle(Point(0.0,0.0),Point(1.0,0.0),Point(0.0,1.0)). 1 == 2 ? false

Come vedremo più avanti nella sezione L’uguaglianza tra oggetti, il metodo == in realtà invoca il metodo equals.

Anche al di fuori delle espressioni case, la generazione automatica di questi tre metodi è molto conveniente per classi “strutturali” semplici, cioè classi che contengono campi e comportamenti relativamente semplici.

Come terza cosa, quando viene usata la parola chiave case, il compilatore crea automaticamente un oggetto associato con un metodo factory apply che accetta gli stessi argomenti del costruttore principale. L’esempio appena visto usa i metodi apply appropriati per creare le istanze di Point, le diverse istanze di Shape e persino l’istanza di List. Questo è il motivo per cui non ci serve new; stiamo effettivamente invocando apply(x, y) sull’oggetto associato Point, per esempio.

Le classi case possono avere costruttori secondari, ma il compilatore non genererà nessun metodo apply sovraccaricato con le stesse liste di parametri. Dovrete usare new per creare istanze con quei costruttori.

L’oggetto associato viene dotato anche un metodo estrattore unapply, che estrae tutti i campi di un’istanza in modo elegante. Lo script seguente mostra gli estrattori in azione nelle istruzioni case del pattern matching.

// esempi/cap-6/shapes/shapes-usage-example2-script.scala

import shapes._

val shapesList = List(
  Circle(Point(0.0, 0.0), 1.0),
  Circle(Point(5.0, 2.0), 3.0),
  Rectangle(Point(0.0, 0.0), 2, 5),
  Rectangle(Point(-2.0, -1.0), 4, 3),
  Triangle(Point(0.0, 0.0), Point(1.0, 0.0), Point(0.0, 1.0)))

def matchOn(shape: Shape) = shape match {
  case Circle(center, radius) =>
    println("Circle: center = " + center + ", radius = " + radius)
  case Rectangle(ll, h, w) =>
    println("Rectangle: lowerLeft = " + ll + ", height = " + h + ", width = " + w)
  case Triangle(p1, p2, p3) =>
    println("Triangle: point1 = " + p1 + ", point2 = " + p2 + ", point3 = " + p3)
  case _ =>
    println("Forma geometrica sconosciuta!" + shape)
}

shapesList.foreach { shape => matchOn(shape) }

Questo script produce il risultato seguente.

Circle: center = Point(0.0,0.0), radius = 1.0
Circle: center = Point(5.0,2.0), radius = 3.0
Rectangle: lowerLeft = Point(0.0,0.0), height = 2.0, width = 5.0
Rectangle: lowerLeft = Point(-2.0,-1.0), height = 4.0, width = 3.0
Triangle: point1 = Point(0.0,0.0), point2 = Point(1.0,0.0), point3 = Point(0.0,1.0)

Zucchero sintattico per le operazioni binarie

A proposito, ricordate quando abbiamo discusso le corrispondenze sulle liste nella sezione Corrispondenze sulle sequenze del capitolo 3? Abbiamo scritto questa espressione case.

def processList(l: List[Any]): Unit = l match {
  case head :: tail => …
  …
}

A quanto pare, le seguenti espressioni sono identiche.

  case head :: tail => …
  case ::(head, tail) => …

Stiamo usando l’oggetto associato con la classe case chiamata ::, che è usata per le liste non vuote. Quando questa classe viene usata nelle espressioni case, il compilatore supporta questa particolare notazione operazionale infissa per invocare unapply.

Questa notazione non funziona solo per i metodi unapply con due argomenti, ma anche per quelli con uno o più argomenti. Potremmo riscrivere il nostro metodo matchOn visto in precedenza in questo modo.

def matchOn(shape: Shape) = shape match {
  case center Circle radius => …
  case ll Rectangle (h, w) => …
  case p1 Triangle (p2, p3) => …
  case _ => …
}

Per invocare un metodo unapply che accetta un solo argomento, è necessario inserire una coppia di parentesi vuota per evitare ambiguità nella sintassi.

  case arg Foo () => …

Dal punto di vista della chiarezza, questa sintassi si rivela elegante in alcuni casi che coinvolgono due argomenti. Per esempio, head :: tail corrisponde all’espressione che costruisce una lista, quindi si ottiene una meravigliosa simmetria quando il processo di estrazione usa la stessa sintassi. Tuttavia, i meriti di questa sintassi sono meno evidenti per gli altri esempi, specialmente quando gli argomenti sono più di due.

Il metodo copy in Scala 2.8

In Scala 2.8, il compilatore genera automaticamente un altro metodo di istanza chiamato copy. Questo metodo è utile se volete costruire una nuova istanza di una classe case che sia identica a un’altra istanza con alcuni campi modificati. Considerate il seguente script di esempio.

// esempi/cap-6/shapes/shapes-usage-example3-v28-script.scala
// Solo per la versione 2.8 di Scala.

import shapes._

val circle1 = Circle(Point(0.0, 0.0), 2.0)
val circle2 = circle1 copy (radius = 4.0)

println(circle1)
println(circle2)

Il secondo cerchio viene creato copiando il primo e specificando un nuovo raggio. L’implementazione del metodo copy generata dal compilatore sfrutta i nuovi parametri predefiniti e con nome, introdotti da Scala 2.8, che abbiamo discusso nella sezione Argomenti con nome e argomenti predefiniti per i metodi (Scala 2.8) del capitolo 2. L’implementazione generata di Circle.copy somiglia più o meno al codice seguente.

case class Circle(center: Point, radius: Double) extends Shape() {
  …
  def copy(center: Point = this.center, radius: Double = this.radius) =
    new Circle(center, radius)
}

Quindi, tutti i parametri del metodo (solo due in questo caso) vengono dotati di un valore predefinito. Quando usa il metodo copy, l’utente specifica per nome solo i campi che stanno cambiando. I valori degli altri campi vengono usati senza dovervi fare riferimento esplicitamente.

L’ereditarietà nelle classi case

Avete notato che il nuovo codice della gerarchia di Shape nella sezione Classi case non usa la parola chiave case per la classe astratta Shape? Il compilatore lo consente, ma ci sono ragioni per evitare che una classe case ne estenda un’altra. Prima di tutto, questo può complicare l’inizializzazione dei campi. Supponete di trasformare Shape in una classe case. Supponete di voler aggiungere a tutte le forme geometriche un campo stringa che rappresenti un identificatore impostabile dall’utente; avrebbe senso definire questo campo in Shape. Apportiamo questi due cambiamenti alla classe Shape.

abstract case class Shape(id: String) {
  def draw(): Unit
}

Ora le forme derivate devono passare id al costruttore Shape. Per esempio, Circle diventerebbe così.

case class Circle(id: String, center: Point, radius: Double) extends Shape(id) {
  def draw(): Unit
}

Tuttavia, se compilate questo codice, otterrete errori come il seguente.

… error: error overriding value id in class Shape of type String;
   value id needs `override' modifier
    case class Circle(id: String, center: Point, radius: Double) extends Shape(id) {
                      ^

Ricordatevi che entrambe le definizioni di id, sia quella in Shape sia quella in Circle, sono considerate definizioni di campi val! Il messaggio di errore ci fornisce la soluzione: usate la parola chiave override come abbiamo discusso nella sezione Ridefinire i membri di classi e tratti. Quindi, l’insieme completo delle modifiche richieste è il seguente.

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

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

  abstract case class Shape(id: String) {
    def draw(): Unit
  }

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

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

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

Notate che dobbiamo anche aggiungere le parole chiave val. Questo codice funziona, ma è piuttosto brutto.

I metodi equals generati automaticamente sono fonte di un problema più pericoloso: in caso di ereditarietà, questi metodi non obbediscono alle regole standard di robustezza per il confronto di uguaglianza tra oggetti. Discuteremo queste regole più avanti nella sezione L’uguaglianza tra oggetti. Per ora, considerate l’esempio seguente.

// esempi/cap-6/shapes/shapes-case-equals-ambiguity-script.scala

import shapesid._

case class FancyCircle(name: String, override val id: String,
    override val center: Point, override val radius: Double)
      extends Circle(id, center, radius) {
  override def draw() = println("FancyCircle.draw: " + this)
}

val fc = FancyCircle("io", "cerchio", Point(0.0,0.0), 10.0)
val c  = Circle("cerchio", Point(0.0,0.0), 10.0)
format("FancyCircle == Circle? %b\n", (fc == c))
format("Circle == FancyCircle? %b\n", (c  == fc))

Se eseguite questo script, ottenete l’uscita seguente.

FancyCircle == Circle? false
Circle == FancyCircle? true

Quindi, Circle.equals viene valutato come vero quando gli viene passata un’istanza di FancyCircle con gli stessi valori dei campi di un’istanza di Circle. Il caso inverso è falso. Sebbene possiate argomentare che, per quanto riguarda Circle, le due istanze sono effettivamente uguali, la maggior parte delle persone sosterrebbe che questa è un’interpretazione di uguaglianza “rilassata” e rischiosa. Una futura versione di Scala potrebbe generare metodi equals per le classi case che eseguono controlli rigorosi sulla uguaglianza di tipo.

Quindi, le comodità offerte dalle classi case a volte si rivelano problematiche. È meglio evitare l’ereditarietà tra classi case. Notate che non ci sono problemi se una classe case estende una classe non case o un tratto, né se una classe non case o un tratto estendono una classe case.

A causa di questi problemi, è possibile che l’ereditarietà tra classi case venga deprecata e rimossa nelle future versioni di Scala.

Evitate di estendere una classe case con un’altra classe case.

L’uguaglianza tra oggetti

L’implementazione di un confronto di uguaglianza affidabile per le istanze è difficile da realizzare correttamente. Effective Java [Bloch2008] e la pagina Scaladoc per AnyRef.equals descrivono i requisiti per un valido confronto di uguaglianza. Una descrizione molto buona delle tecniche per scrivere metodi equals e hashCode corretti può essere trovata in [Odersky2009], che usa la sintassi Java ma è adattato dal ventottesimo capitolo di Programming in Scala [Odersky2008]. Consultate questo materiale quando avete bisogno di implementare i vostri metodi equals e hashCode. Ricordate che questi metodi vengono creati automaticamente per le classi case.

Qui ci concentreremo sui diversi metodi di uguaglianza disponibili in Scala e sul loro significato. Ci sono alcune leggere inconsistenze tra la specifica del linguaggio [ScalaSpec2009] e le pagine Scaladoc per i metodi di uguaglianza appartenenti alle classi Any e AnyRef, ma il comportamento generale è chiaro.

Alcuni metodi di uguaglianza hanno nomi uguali ai metodi di uguaglianza di altri linguaggi, ma a volte la loro semantica è differente!

Il metodo equals

Il metodo equals verifica l’uguaglianza tra valori. Cioè, o1 equals o2 è vero se o1 e o2 hanno lo stesso valore. Non è necessario che facciano riferimento alla stessa istanza.

Dunque, equals si comporta come il metodo equals in Java e come il metodo eql? in Ruby.

I metodi == e !=

Sebbene == sia un operatore in molti linguaggi, in Scala è un metodo final definito in Any. Questo metodo verifica l’uguaglianza tra valori, come equals. Cioè, o1 == o2 è vero se o1 e o2 hanno lo stesso valore. In effetti, == delega la propria funzione a equals, come descritto in questa parte della voce Scaladoc per Any.== (qui tradotta):

o == arg0 è uguale a o.equals(arg0).

Ecco la parte corrispondente della voce Scaladoc per AnyRef.== (qui tradotta):

o == arg0 è uguale a if (o eq null) arg0 eq null else o.equals(arg0).

Come vi aspettereste, != è la negazione, cioè è equivalente a !(o1 == o2).

Dato che == e != sono dichiarati come final in Any non potete ridefinirli, ma non ne avete bisogno dato che essi delegano le proprie funzioni a equals.

In Java, C++ e C# l’operatore == verifica l’uguaglianza per riferimento, non per valore. Al contrario, l’operatore == di Ruby verifica l’uguaglianza per valore. Qualunque sia il linguaggio a cui siete abituati, ricordatevi che in Scala == verifica l’uguaglianza per valore.

I metodi ne ed eq

Il metodo eq verifica l’uguaglianza per riferimento. Cioè, o1 eq o2 è vero se o1 e o2 puntano alla stessa locazione in memoria. Questi metodi sono definiti solo in AnyRef.

Dunque, eq si comporta come l’operatore == in Java, C++ e C#, ma diversamente dall’operatore == in Ruby.

Il metodo ne è la negazione di eq, cioè è equivalente a !(o1 eq o2).

L’uguaglianza tra array e il metodo sameElements

Il confronto tra i contenuti di due array non ha un risultato ovvio in Scala.

scala> Array(1, 2) == Array(1, 2)
res0: Boolean = false

Questa è una sorpresa! Per fortuna, esiste una semplice soluzione nella forma del metodo sameElements.

scala> Array(1, 2).sameElements(Array(1, 2))
res1: Boolean = true

Così va molto meglio. Ricordate di usare sameElements quando volete verificare che due array contengano gli stessi elementi.

Sebbene questa possa sembrare un’inconsistenza, i progettisti del linguaggio hanno preferito adottare un approccio conservativo, incoraggiando l’uso di un test esplicito per il confronto di uguaglianza tra due strutture dati mutabili. Nel lungo termine, questo dovrebbe risparmiarvi una serie di risultati inattesi nelle vostre condizioni.

Riepilogo, e poi?

Abbiamo esplorato i dettagli più raffinati della ridefinizione dei metodi nelle classi derivate. Abbiamo imparato a usare il confronto di uguaglianza tra oggetti, le classi case e le classi e gli oggetti associati.

Nel prossimo capitolo studieremo la gerarchia di tipi di Scala, in particolare l’oggetto Predef che include molte definizioni utili. Vedremo anche l’alternativa di Scala ai membri di classe static di Java e le regole di linearizzazione per la ricerca dei metodi.

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