Scala: le case class

Scala: le case class

Dopo aver visto come creare una classe in Scala e come utilizzare i costruttori ausiliari vediamo oggi le case class. C’è da dire subito che le case class non sono delle classi speciali ma sono un’abbreviazione sintattica tanto semplice quanto molto, molto comoda e diffusa ovunque nel codice scritto in Scala.

Nel post precedente eravamo arrivati a definire una classe HardDisk in questo modo.

class HardDisk(var size: Int, var usedSpace: Int) {
  override def toString = s"HardDisk[Size $size, Used space $usedSpace]"
}

Da quanto avevamo visto, sappiamo che Scala definisce per noi un costruttore, le member variable, i getter e, in base al modificatore var/val, decide se aggiungere o meno i setter. Se i setter non sono aggiunti (caso val), la classe risulterà immutabile e quindi una volta istanziata non se ne potranno modificare i valori.

Una case class corrisponde proprio a quest’ultimo caso. A cosa possa mai servire un oggetto che non modifichiamo mai, da bravi Javisti, proprio non riusciamo ad immaginarcelo, ma questo lo chiariremo nella seconda parte del post. Vediamo allora come si dichiara e come si usa una case class. La sintassi è estremamente semplice.

case class HardDisk(size: Int, usedSpace: Int)

Finito, non ci sono altre righe da scrivere :-). Cosa abbiamo dichiarato allora? Un bel po’ di cose che elenco qui sotto:

  • la classe HardDisk (ovviamente),
  • le member variable size e usedSpace entrambe immutabili,
  • i due getter corrispondenti,

e in più rispetto alla dichiarazione standard:

  • il metodo toString
  • il metodo hashCode
  • il metodo copy
  • il metodo equals

e inoltre un companion object dotato di metodo apply e unapply.

Facciamo ordine, il companion object

Abbiamo messo molta carne al fuoco, vediamo di spiegare i concetti uno alla volta partendo dal fondo dove forse le cose sono più oscure. Un companion object è un semplicemente un oggetto singleton che ha lo stesso nome della classe e che per convenzione in Scala può accedere alle sue parti private. In genere si utilizzano i companion per raggruppare i metodi di utilità, nel caso della case class ne abbiamo due e vengono usati per istanziare la classe (apply) e per gestire il pattern matching (unapply). C’è molto da dire sul pattern matching e quindi lo affronteremo in un prossimo post. Per il momento ci limitiamo a dire che una case class può essere istanziata così come abbiamo già visto:

val hd1 = new HardDisk(250, 10)

oppure usando il metodo apply del companion object che fa esattamente la stessa cosa

val hd1 = HardDisk.apply(250, 10)  // def apply(size: Int, usedSpace: Int) = new HardDisk(size, usedSpace)

In Scala esiste una convenzione sul metodo apply: questo metodo può essere invocato omettendone il nome e usando solo le parentesi, quindi nel caso della case class possiamo scrivere anche:

val hd1 = HardDisk(250, 10)

Quest’ultima sintassi può sembrare un po’ strana: sembra che ci siamo dimenticati il new. Tuttavia è quella che più si avvicina agli Algebraic Data Type (ADT) che tanto sono utilizzati nella programmazione funzionale.

I metodi toString e hashCode

Se c’è un articolo su CoseNonJaviste che è sempre in cima alla lista dei post letti è sicuramente quello sul metodo hashCode. Il post spiega molto bene perché hashCode debba essere definito in modo preciso per far funzionare correttamente le mappe. Le case class di Scala fanno questo lavoro per noi liberandoci da un compito noioso, quanto ripetitivo e potenzialmente fonte di errori.

Anche avere a disposizione in automatico il metodo toString con tutte le member variable della classe è una bella comodità, se un giorno volessimo aggiungere un altro parametro non dobbiamo preoccuparci di aggiornare questo metodo. Abbiamo insomma gli stessi vantaggi che avremmo in Java se stessimo usando Lombok.
Per completezza di spiegazione, quando stampiamo in console HardDisk

println("Hd1: " + hd1)

andiamo a ottenere questo risultato.

Hd1: HardDisk(250,10)

Il metodo equals

Così come per metodo hashCode, anche il metodo equals viene generato per noi componendo il risultato degli equals di ogni singolo campo. Una notazione sintattica importante soprattutto arrivando da Java: in Scala il metodo equals si può scrivere anche usando il simbolo ==, anzi di solito si preferisce usare questa notazione, mentre per confrontare i reference dell’oggetto si usa il metodo eq, quindi se:

val hd1 = HardDisk(250, 10)
val hd2 = HardDisk(250, 10)

println(hd1 == hd2) // true
println(hd1 eq hd2) // false

allora la prima stampa su console restituirà true perché i due oggetti hanno member variable uguali (nel senso di equals), mentre la seconda restituirà false perché abbiamo due reference diversi.

e il metodo copy?

Fin qui tutto bene, queste case class sembrano proprio funzionare bene: ci risparmiano veramente un sacco di lavoro, peccato però che una volta che il nostro hard disk sia stato istanziato, di fatto non possiamo più modificarlo. Qui ci viene in aiuto il metodo copy. Supponiamo di voler consumare ancora un po’ di disco, quindi di voler occupare altri 50 giga di spazio, come possiamo modificare il contenuto dell’oggetto? La risposta è semplice, non possiamo! Possiamo però creare un nuovo oggetto dello stesso tipo a partire da quello che abbiamo, cambiando solamente il parametro che ci interessa con i named parameter.

var hardDisk = HardDisk(250, 10)
hardDisk = hardDisk.copy(usedSpace = 60)

Non saremo un po’ “spreconi” a far così? La risposta giusta è . Nel paradigma funzionale, che è una delle due anime di Scala, non esistono oggetti mutabili, ogni cambiamento viene realizzato costruendo un nuovo oggetto immutabile. Il vantaggio è che un oggetto immutabile può essere condiviso da più thread contemporaneamente senza alcun accorgimento particolare (cioè senza usare metodi sincronizzati o fare copie difensive). Questo da un lato semplifica la programmazione e dall’altro consente di aumentare il parallelismo di esecuzione. Un oggetto immutabile può inoltre essere inserito in più strutture dati contemporaneamente, quindi un’istanza di case class può essere condivisa, ad esempio, da più liste. Impieghiamo quindi più memoria da una parte, ma possiamo recuperarla dall’altra.

Tuttavia, siccome Scala è anche un linguaggio object-oriented, dove vogliamo garantire le prestazioni possiamo mettere da parte le case class e istanziare oggetti mutabili come abbiamo già visto nei post precedenti. In questo senso Scala ci lascia molta libertà, possiamo adottare il paradigma di programmazione che preferiamo a seconda dei casi

Alcuni limiti

Ci sono alcuni limiti da tenere presente quando si lavora con le case class. Il primo è legato al numero dei parametri: una classe di questo tipo deve avere almeno uno parametro (almeno a partire dalla versione 2.11) ma non ne può avere più di 22. Un’altra restrizione è legata all’ereditarietà: una case class può essere derivata da un’altra classe purché questa non sia a sua volta una case class.

Concludendo

Abbiamo visto come le case class siano un costrutto molto compatto quanto potente. Sebbene negli esempi che abbiamo fatto ci siamo limitati a casi molto semplici, nulla vieta che la case class sia estensione di un’altra classe o che definisca altri metodi. Proprio per la loro immediatezza d’uso e per le garanzie di immutabilità queste classi sono presenti in pratica in ogni progetto scritto in Scala, in particolar modo quelli che usano il pattern matching, che vedremo prossimamente.

Giampaolo Trapasso

Sono laureato in Informatica e attualmente lavoro come Software Engineer in Radicalbit. Mi diverto a programmare usando Java e Scala, Akka, RxJava e Cassandra. Qui mio modesto contributo su StackOverflow e il mio account su GitHub