Nessun risultato. Prova con un altro termine.
Guide
Notizie
Software
Tutorial

Scala in ascesa: che Opzioni ci restano?

Quali sono le caratteristiche più interessanti ed originali del linguaggio Scala? Che utilità può avere per i nostri progetti? Ecco alcuni esempi di base.
Quali sono le caratteristiche più interessanti ed originali del linguaggio Scala? Che utilità può avere per i nostri progetti? Ecco alcuni esempi di base.
Link copiato negli appunti

Chiariamo subito le cose: non parliamo di grandi mercati azionari, ma di argomenti a noi cari: la programmazione, e in particolare il linguaggio Scala, che ha recentemente invaso il mondo dello sviluppo con le sue sedicenti caratteristiche di espressività, potenza, flessibilità e sicurezza.

Ma per capire se queste promesse saranno rispettate è lecito farsi qualche domanda riguardo agli strumenti che Scala offre, per capire se vale veramente la pena adottarlo per i nostri progetti.

Esiste un sintetica scheda di riferimento da scaricare per la sintassi del linguaggio, preparata da Alvin Alexander

Option, che tipo!

Partiamo con una struttura semplice ma di pratica utilità, come potrete constatare facilmente: il tipo Option.

Il problema

Questo tipo di dato viene utilizzato per rappresentare un valore che non è necessariamente definito. Per capire di cosa parlo immaginiamo una semplice rubrica elettronica come quella del nostro client di e-mail. Ogni voce può essere rappresentata con oggetto di tipo Contact, che all'interno avrà gli attributi che ci aspettiamo:

Attributo Descrizione
name il nome del nostro contatto
phone il telefono fisso
mobile il cellulare
email l'indirizzo di posta elettronica
... ...

Se guardiamo questi campi ci rendiamo subito conto che non tutti saranno necessariamente popolati con un valore; non tutti i nostri contatti avranno numero fisso, cellulare e email.

In diversi linguaggi di larga diffusione si utilizza un riferimento vuoto per rappresentare questa situazione: ad esempio null in java. Ma tale soluzione ci obbliga costantemente a tenere traccia di quali sono i campi "non obbligatori" all'interno del nostro programma, ad esempio per evitare di mostrarli all'utente (magari convertendo un valore null in una stringa vuota), o peggio ancora per evitare di invocare una funzione su un riferimento vuoto generando la tristemente famosa (sempre in java) NullPointerException, piuttosto frequente nella nostra esperienza quotidiana.

Bisogna quindi attrezzarsi per gestire il caso di campi nulli, ad esempio con un controllo preventivo

if (contact.email != null) mail.recipients.add(contact.email)

Questo tipo di gestione comporta fra l'altro una difficoltà che alle volte sfugge anche alla nostra consapevolezza, per quanto siamo ormai abituati a farne uso.

non c'è nel codice stesso nessuna indicazione esplicita di quali siano i valori che possono essere nulli

La soluzione

I nuovi linguaggi basati sulla JVM propongono diverse soluzioni alternative al problema; Scala usa il tipo Option, per segnalare appunto, un valore "opzionale".

In particolare le Option sono ulteriormente parametrizzate dal tipo di dato che possono rappresentare; nel nostro esempio avremo tre valori di tipo Option[String].

In generale una Option[A] (valore opzionale di un tipo non specificato A) è una classe astratta che ammette solo 2 possibili istanze:

  • Some[A]che contiene effettivamente un qualche valore
  • None che indica un valore assente

Per fare un'esempio potremmo assegnare i campi del nostro contatto:

contact.phone = Some("+39 000545110")
    contact.mobile = None
    contact.email = Some("miaemail@miodominio.it")

Oltre ai costruttori dell'esempio, abbiamo alcune alternative per costruire una Option. Vediamo un esempio lanciato da riga di comando:

scala> val someNumber = Some("+39 0005451101")
    someNumber: Some[String] = Some(+39 0005451101)
    scala> val noMail = None
    noMail: None.type = None
    scala> val someMobile = Option("+39 3332221110")
    someMobile: Option[String] = Some(+39 3332221110)
    scala> val noThing = Option(null)
    noThing: Option[Null] = None

Osserviamo come sia semplice in questo modo convertire un valore che potrebbe essere null, magari per creare uno strato di interfaccia con delle librerie Java che permetta di ridurre al minimo il rischio di NullPointerException nella nostra applicazione. Questa è una garanzia di stabilità per il nostro codice.

Analogamente al caso dei campi opzionali, l'Option è spesso usata per indicare una funzione il cui risultato non è garantito. Come esempio immaginiamo di avere una funzione della rubrica che permette di trovare un contatto per nome:

def findByName(name: String): Option[Contact] = ...

Già dalla signature del metodo ci rendiamo conto che potremmo non avere un risultato. Inoltre il type-system stesso ci obbliga a trattare tale dato in modo distinto da altri tipi, in quanto ad esempio non posso usarlo dove ci si aspetta un Contact:

//Questo metodo aggiunge un contatto tra i preferiti
    def addToFavorites(contact: Contact) = ...
    //Se proviamo a passare un tipo opzionale, il metodo non funziona!
    addToFavorites(findByName("Roberto"))
    :14: error: type mismatch;
      found   : Option[Contact]
      required: Contact
              addToFavorites(findByName("Roberto"))

Infine possiamo usare dei tipi opzionali come parametri di una funzione, caso che viene semplificato ulteriormente grazie al supporto di Scala per i parametri di default, come si vede dall'esempio:

//aggiunge un contatto ai preferiti, con una categoria opzionale, che vale None se non specificata
    def addToFavorites(contact: Contact, category: Option[String] = None) = ...
    //aggiungiamo ai preferiti in modo semplice
    //la categoria non viene specificata e quindi vale None
    addToFavorites(myGoodFriend)
    //aggiungiamo ad una categoria specifica passando il parametro in modo esplicito
    addToFavorites(personalTrainer, "fitness")

Cosa ci faccio adesso? (Come estrarre i valori dalle Option)

Molto bene... adesso siamo diventati delle persone accorte e diligenti e abbiamo imparato che conviene esplicitare quando un valore è opzionale, ma se ci serve un Contact e abbiamo fra le mani solo un Option[Contact], cosa ci dovremmo fare? Ovvero, come lo tiro fuori il coniglio dal cilindro?

Gli strumenti ci vengono forniti dai metodi presenti sulla Option; diamo un'occhiata ai più immediati:

class Option[A] {
	def get: A //estrae il valore se presente
	def isDefined: Boolean //indica se il valore e' definito
}

Ma ci accorgiamo presto che chiamare get su un valore "assente", non dà grandi soddisfazioni...

scala> noMail.get
java.util.NoSuchElementException: None.get
at scala.None$.get(Option.scala:313)
// ...

e quindi sarebbe saggio verificare che il valore esista prima di usarlo

if (contact.email.isDefined) {
	val mail = contact.email.get
	//... usiamo qui il valore della mail
}

ma quale vantaggio ne abbiamo ottenuto? Abbiamo scambiato un x != null con x.isDefined e NullPointerException con NoSuchElementException!
Qui Scala non ci aiuta per niente, anzi! Complica solo le cose... beh, siccome non siete degli sprovveduti avrete già capito che sto per mostrarvi qualche "trucchetto".

Pattern Matching dappertutto!

Il modo forse più intuitivo di utilizzare un valore nella nostra Option, senza rischi per la salute dell'applicazione, è di sfruttare le capacità di pattern matching messe a disposizione da Scala.

Analogamente a come le espressioni regolari permettono di verificare se un testo corrisponde ad uno specifico pattern, lo stesso si può fare in molti linguaggi funzionali verificando se un "oggetto" o "dato" è conforme ad un "pattern strutturale".

La sintassi in Scala è simile ad uno "switch", e utilizza le istruzioni match e case; un esempio solitamente è sufficiente a chiarire le idee

//costruiamo una Option con una stringa
val optionalValue: Option[String] = Some("content")
//facciamo il pattern matching per ottenere un risultato distinto per i due casi
val safeValue = optionalValue match {
	case Some(value) => value
	case None => "missing"
}

safeValue viene determinato in base al corrispondente ramo del match, ossia

  1. se optionalValue corrisponde a Some(value) allora restituisce quello che c'è in value
  2. se optionalValue corrisponde a None allora restituisce un valore predefinito, in questo caso "missing"

Nel nostro esempio optionalValue corrisponde a Some("content"), pertanto si ricade nel primo caso, dove value corrisponde a "content" per cui viene restituito il valore "content", appunto.

In generale le conseguenze di ciascun case (ossia il codice definito a destra del =>) possono essere qualsiasi, e restituire qualunque valore, purché tutte siano consistenti rispetto al tipo di valore restituito. Difatti è necessario che tutto il pattern match venga convertito in un risultato ben definito.

Tanto per chiarire possiamo definire un blocco di codice che non restituisce alcun risultato ma esegue un'operazione

//stampa a schermo il valore nella Option, se esiste
    optionalValue match {
      case Some(value) => println(value)
      case None =>
    }

I "trucchi" del programmatore funzionale

Per concludere in bellezza, passiamo ad un altro paio di utili funzioni definite sul tipo Option[A]. In particolare vogliamo gestire 3 situazioni molto frequenti nell'esperienza comune

  1. estrarre il valore opzionale, garantendo al contempo la sicurezza del type-system, tramite un "valore di default"
  2. trasformare il valore opzionale, tramite una funzione, ma solo se esiste, altrimenti lasciare invariato il tutto
  3. combinare due operazioni che restituiscono un valore opzionale, dove il risultato della prima (se esiste) serve come input alla seconda

Trucco 1: getOrElse

Il primo caso corrisponde ad una semplificazione dell'esempio che abbiamo fatto con il pattern matching:

val safeValue = optionalValue match {
      case Some(value) => value
      case None => "missing"
    }

che si può esprimere con una chiamata diretta al metodo getOrElse(defaultValue: A)

val safeValue = optionalValue.getOrElse("missing")

In quasi ogni possibile situazione che incontreremo, questa rappresenta la soluzione più immediata e pratica per usare il valore, generalmente preferibile all'uso del meno sicuro get.
Come già accennato, spesso potrebbe essere sufficiente dare come valore di default una stringa vuota, oppure un valore preconfigurato, ad esempio

  • i campi di una form nella GUI: form.setEmail(contact.email.getOrElse(""))
  • la codifica di un file di testo: file.setEncoding(userEncoding.getOrElse(Encoding.UTF_8))

Trucco 2: map

Supponiamo che la nostra rubrica abbia fra i dati del contatto anche l'indirizzo, inserito come semplice stringa

class Contact {
  // ...
  var address: Option[String] = None
  // ...
}

mettiamo di avere scritto una sofisticatissima funzione di parsing che converte la stringa in un oggetto complesso Address, con i singoli elementi dell'indirizzo definiti come campi dell'oggetto

def parseAddress(stringAddr: String): Address = // ...

Pur essendo la stringa con l'indirizzo "inglobata" in un campo opzionale, possiamo applicare la nostra funzione in modo diretto attraverso il metodo map.
Tale metodo "rimappa", appunto, il valore contenuto nella Option, attraverso la trasformazione da noi fornita, preservando il fatto che l'indirizzo fosse disponibile o meno.
In altri termini, applicando map ad un valore esistente (Some) viene restituita una Option dove è stata applicata la funzione al contenuto, in caso contrario otteniamo un valore assente (None)

class Option[A] {
// ...
def map[B](f: A => B): Option[B] //applica f all'eventuale valore presente nella option
// ...
}
//applica la funzione sul valore opzionale, restituendo un risultato opzionale,
//coerente con quello originale
val completeAddress: Option[Address] =
contact.address.map(addr => parseAddress(addr))
//in forma semplificata
val completeAddress: Option[Address] = contact.address.map(parseAddress)

Questa operazione ci consente di lavorare con un eventuale valore senza starci a preoccupare se è presente o meno, attraverso una o più trasformazioni. Alla fine otterremo un valore opzionale corrispondente al risultato di tutte le operazioni.

Per chiarire meglio le idee, supponiamo che parseAddress si aspetti una stringa senza spazi terminali e in lettere maiuscole. É immediato applicare una serie di map che ci portano al risultato voluto.

//in forma estesa
val completeAddress: Option[Address] = contact.address
	.map(addr => addr.trim)
	.map(addr => addr.toUpperCase)
	.map(parseAddress)
//usando il segnaposto "_" per semplificare le funzioni anonime e omettendo il tipo
val completeAddress = contact.address
	.map(_.trim)
	.map(_.toUpperCase)
	.map(parseAddress)

Semplice ma intenso, no?

Trucco 3: flatMap

Vediamo infine il caso in cui dobbiamo concatenare più operazioni con un risultato opzionale.

Abbiamo già implementato una sorta di estrattore di indirizzi concatenando più chiamate a map

def extractAddress(contact: Contact): Option[Address] = ...
//vedi esempio precedente

Potremmo aver migliorato la nostra rubrica con dei calcoli geografici sui nostri contatti, magari scrivendo una funzione che a partire dall'indirizzo recupera le coordinate di geolocazione (in questo caso rappresentate dalla coppia longitudine + latitudine, di tipo (Double, Double)).
Tale funzione dovrebbe restituire le coordinate, ma potrebbe non trovarle! Quindi ancora Option alla riscossa

def findGeoLocation(address: Address): Option[(Double, Double)]

É probabile che vorremo comporre queste operazioni; vediamo cosa succede se estraggo l'indirizzo da un contatto trovato per nome, usando il metodo map

val addressSearched = findByName("Gigi").map(extractAddress)

Una rapida analisi ci convincerà che addressSearched è di tipo Option[Option[Address]], visto che ciascuna operazione introduce un valore opzionale!
Decisamente non una situazione comoda, come potremmo constatare cercando di estrarre il valore contenuto in queste scatole cinesi.
Peggio ancora, se volessimo "mappare" nuovamente il risultato per ottenere le coordinate di geolocazione, otterremmo una Option[Option[Option[(Double, Double)]]]!!

Per esercizio vi invito a scrivere due funzioni che realizzino quanto appena spiegato, probabilmente usando i metodi finora esposti e il pattern matching.

Ovviamente non siamo i primi a imbatterci nel problema per cui esistono dei metodi per gestire questa situazione

  • flatten che "appiattisce" due Option innestate in un unica Option che ha un valore solo se ce lo hanno entrambe
  • flatMap[B](f: A => Option[B]): Option[B] che, come il nome suggerisce, applica f al contenuto, ed eventualmente esegue flatten sul risultato

Possiamo quindi farne buon uso per ottenere

//appiattisce il risultato
val flatAddressSearched: Option[Address] = addressSearched.flatten
//esegue entrambe le operazioni direttamente!
val addressSearched: Option[Address] = findByName("Gigi").flatMap(extractAddress)
//concatena direttamente i metodi per estrarre le coordinate dal nome!
val coords: Option[(Double, Double)] = findByName("Gigi")
	.flatMap(extractAddress)
	.flatMap(findGeoLocation)

Questa operazione è talmente frequente e utile che Scala prevede una sintassi espressiva per concatenare flatMap e map, chiamata for-comprehension (forse vedremo in futuro come questa sia legata al ciclo for sulle collection).

val coords = for {
	contact <- findByName("Gigi")
	address <- extractAddress(contact)
	geolocation <- findGeoLocation(address)
} yield (geolocation)

In questa forma, si può immaginare di avere accesso a tutti i valori intermedi delle operazioni, e se uno di essi non esiste, ovvero vale None, ne consegue che tutta l'operazione restituisce un valore None.

Tanto per chiarire il risultato raggiunto, confrontiamolo con un esempio di quanto viene fatto comunemente in java

var coords = null
val contact = findByName("Gigi")
if (contact != null) {
	val address = extractAddress(contact)
	if (address != null) {
		coords = findGeoLocation(address)
	}
}

A voi lascio trarre le dovute...

Conclusioni

In questo articolo abbiamo conosciuto un elemento semplice e pratico di Scala, preso in prestito dalla tradizione dei linguaggi funzionali, che ci aiuterà a scrivere applicazioni più stabili e codice più espressivo. Spero di avervi un po' convinti di questo, ma anche se così non fosse, sarete costretti ad ammettere di avere aggiunto un'opzione alla scelta dei vostri strumenti.


Ti consigliamo anche