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 valoreNone
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
- se
optionalValue
corrisponde aSome(value)
allora restituisce quello che c'è invalue
- se
optionalValue
corrisponde aNone
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
- estrarre il valore opzionale, garantendo al contempo la sicurezza del
type-system
, tramite un "valore di default" - trasformare il valore opzionale, tramite una funzione, ma solo se esiste, altrimenti lasciare invariato il tutto
- 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" dueOption
innestate in un unicaOption
che ha un valore solo se ce lo hanno entrambeflatMap[B](f: A => Option[B]): Option[B]
che, come il nome suggerisce, applicaf
al contenuto, ed eventualmente esegueflatten
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.