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]
  • None

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 NullPointerException 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 parametri di default

//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 Some(value) value
  2. se optionalValue None "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.

//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
  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
  4. 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 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

    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 Option[Option[Address]]
    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 Option Option
    • flatMap[B](f: A => Option[B]): Option[B] f flatten

    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