linguaggio scala
scala

Scala: gli impliciti

Tempo fa su questo blog sono stati pubblicatialcuni post su Scala, con l’obiettivo di offrire una prima panoramica sul linguaggio. In questo articolo illustreremo un’altra importante caratteristica del linguaggio, per certi versi un po’ fuori dal comune rispetto ai classici costrutti cui siamo abituati: gli impliciti.

Da un punto di vista sintattico non c’è molto da imparare: l’utilizzo degli impliciti in Scala si riduce ad una sola keyword: implicit, da anteporre alla definizione di altri costrutti. Ad esempio:

    
implicit val nazione = "Italia"
implicit val campionatiDelMondoVinti = 4

oppure

def hello(who: String)(implicit greeting: String) = s"$greeting, $who!"

Allora perché questo post? Nonostante la facilità nell’introdurre un implicit, gli effetti del loro utilizzo all’interno di una codebase Scala possono essere molteplici. Proveremo ad analizzarli per comprendere meglio il loro valore aggiunto.

Cominciamo a chiarire da dove nasce l’esigenza di introdurre gli impliciti nel linguaggio. Sono stati introdotti per favorire l’estensibilità del codice di terze parti: di solito gli sviluppatori possono modificare a piacimento il proprio codice all’interno di una codebase, ma ovviamente non hanno le stesse possibilità quando si tratta di librerie di terze parti. In Scala riusciremo a farlo grazie agli implicit!

Le conversioni implicite

Spesso, durante lo sviluppo del software, ci si trova a dover integrare parti di codice sorgente che sono state originariamente sviluppate senza tenere conto dell’eventuale esigenza futura di farle interagire insieme; librerie diverse utilizzano approcci differenti per fornire lo stesso comportamento o le stesse strutture. Le implicit conversions sono utili proprio in queste occasioni: servono a ridurre il numero di conversioni esplicite che sono di solito necessarie per poter trasformare un tipo in un altro.

Applicando la keyword implicit in modo opportuno aiuteremo il compilatore ad inserire delle definizioni che consentono di sistemare quelli che altrimenti sarebbero errori di compilazione. Chiariamo il concetto con un esempio: un convertitore di Bitcoin.

Il convertitore di Bitcoin

Supponiamo di star lavorando ad un progetto per la gestione delle finanze e che nella nostra codebase tutte le operazioni finanziarie siano implementate utilizzando gli Euro, che modelliamo con la seguente case class:

case class Euro(euro: BigDecimal)

Per rendere il nostro prodotto più al passo con i tempi, il management ci chiede di introdurre anche la gestione delle criptovalute, nello specifico i Bitcoin. Anziché scrivere una nostra classe, decidiamo di importare una libreria esterna per gestire la criptovaluta dei Bitcoin. La libreria è implementata come segue:

package cryptocurrency;

//bitcoin currency
case class Bitcoin(value: BigDecimal)
//payment gateway
object BitcoinEngine {
   def paymentOperation(amount: Bitcoin): Bitcoin = /* very long method here */
}

Una delle funzioni più comode della nuova libreria è l’introduzione di BitcoinEngine, un gateway per i pagamenti in Bitcoin. Decidiamo di usarlo, sebbene la nostra attuale codebase preveda la sola currency Euro. Accidenti, dobbiamo convertire tutti i nostri oggetti Euro in Bitcoin? Non potrebbe aiutarci il compilatore Scala? Sì, per uno scenario del genere possiamo far ricorso ad una semplice implicit conversion. Per trasformare tutti i nostri Euro in Bitcoin, anziché intervenire manualmente possiamo introdurre una conversione implicita:

object EuroConverter { 
  import cryptocurrency._
  implicit def euroToBitcoin(eur: Euro): Bitcoin = Bitcoin(eur.euro + 10000)
}

ed importare l’oggetto EuroConverter in tutti i punti in cui dobbiamo convertire Euro in Bitcoin. Ad esempio, senza l’import dell’oggetto EuroConverter, il seguente codice non compilerà:

import cryptocurrency._

object Main extends App {
  val euro = Euro(120)
  println(BitcoinEngine.paymentOperation(euro))    //il tipo della variabile euro 
                                                   //non è ammissibile qui
}

Invece, aggiungendo allo scope il solo import della conversione implicita:

import cryptocurrency._
import EuroConverter.euroToBitcoin  //importazione della conversione implicita

object Main extends App {
  val euro = Euro(120)
  println(BitcoinEngine.paymentOperation(euro))    //adesso non ci sarà 
                                                  //più l'errore poiché 
                                                  //la conversione viene 
                                                  //fatta implicitamente
}

il compilatore individuerà la conversione implicita applicandola dove necessario. La conversione introdotta consente di sistemare eventuali errori sui tipi. Come lavora il compilatore dietro le quinte? Prima prova a compilare il codice come se non ci fosse nessuna conversione, riscontrando però un errore dovuto ad un’inconsistenza dei tipi coinvolti. Al posto di fermarsi segnalando l’errore, il compilatore Scala cerca eventuali conversioni implicite che possano risolvere il problema. Nell’esempio precedente individua euroToBitcoin, prova ad applicarla, verifica che essa risolve il problema e prosegue oltre. Comodo, no?

Quando usare le conversioni implicite

I contesti del linguaggio in cui conviene utilizzare le conversioni implicite sono tre:

  • conversioni ad un tipo atteso, come nel precedente esempio;
  • conversioni del destinatario di una selezione;
  • parametri impliciti.
Conversioni implicite ad un tipo atteso

Le conversioni di questo tipo consentono di poter utilizzare un tipo specifico in un contesto dove originariamente ne era atteso un altro, come nell’esempio precedente.

Conversioni sui destinatari di una selezione

Questo tipo è simile al precedente e consente di adattare le istanze su cui invochiamo un metodo, se il metodo che stiamo invocando non è applicabile sul tipo originale. Supponiamo di avere una classe per modellare i numeri razionali:

class Rational(val numer: Int, val denom: Int) {
  def + (that: Int): Rational = new Rational(numer + that * denom, denom)
  def + (that: Rational): Rational = new Rational(
                       numer * that.denom + that.numer * denom, 
                       denom * that.denom)
}

in cui abbiamo definito il metodo + per poter aggiungere ad un numero Rational sia un Int sia un’altra istanza di Rational. Ora non ci resta che testarla in un semplice Main:

object Main extends App {
  val r = new Rational(1, 2)
  val s = new Rational(3, 4)
  r + s
  s + r
  r + 1
  1 + r  //errore di compilazione poiché Int non ha il metodo +(r: Rational)
}

Ma come? Per le addizioni non dovrebbe esistere la proprietà commutativa? Perché questo Main non compila? L’errore di compilazione è dovuto alla mancanza del metodo +(r: Rational) per il tipo Int. Come possiamo fare ad aggiungerlo? Siccome la classe Int è una classe della libreria standard di Scala, non possiamo intervenire direttamente su di essa, ma attraverso una conversione implicita riusciamo a risolvere l’errore. Definiamo un oggetto:

package math
object IntRational {
  implicit def intToRational(x: Int): Rational = new Rational(x, 1)
}

ed importiamolo nello scope del nostro Main:

import math.IntRational._
object Main extends App {
  val r = new Rational(1, 2)
  …
  1 + r  //stavolta compila!
}

Questa volta il compilatore prima prova a compilare l’espressione 1 + r, ma senza riuscirci (Int ha tanti metodi +, ma nessuno che accetta un Rational come parametro). Piuttosto che arrendersi, però, prova a cercare una conversione implicita da Int ad un altro tipo che abbia un metodo + da poter applicare al parametro di tipo Rational. Siccome abbiamo importato una conversione adatta allo scopo (i.e.:  import math.IntRational._) il compilatore riesce a risolvere il codice e a terminare senza errori.

Questa tipologia di conversione fornisce un’integrazione migliore di nuove classi all’interno di una gerarchia di classi già esistente: potremo usare istanze di tipi già esistenti come se fossero istanze di un altro tipo!

Parametri impliciti

Le conversioni implicite di parametri, anche dette implicit parameters, sono generalmente utilizzate per fornire maggiori informazioni alla funzione chiamata su cosa si aspetta il chiamante. In questo caso quindi, il compilatore inserirà le conversioni implicite all’interno della lista degli argomenti di un metodo. Questo approccio risulta molto utile con le funzioni o i metodi generici, laddove la funzione chiamata potrebbe non conoscere niente sui tipi di uno o più dei propri argomenti (e.g.: dependency injection). Un classico esempio di utilizzo degli implicit parameters è quello per l’implementazione delle type classes in Scala, che vedremo in un prossimo post.

Come funzionano, in dettaglio, le conversioni di parametri impliciti? Supponiamo di voler eseguire delle query su un database relazionale. Modelliamo innanzitutto una configurazione per la connessione al DB:

case class DBConfiguration(driver: String, 
                           url: String, 
                           username: String, 
                           password: String)

scriviamo poi la nostra logica applicativa utilizzando un parametro implicito per gestire la configurazione:

import java.sql.{Connection, DriverManager}
object JDBCEngine {
  def selectData(query: String)(implicit config: DBConfiguration) = {
   // è solo un esempio: ci sono modi migliori per interagire con un DB!
    var connection: Connection = null
    try {
      // istanzia la connessione usando il parametro implicito
      Class.forName(config.driver)
      connection = DriverManager.getConnection(
                                 config.url, 
                                 config.username, 
                                 config.password)
       // esegui query SQL...
       //  ...
     } catch {
      case e => e.printStackTrace
     }
    connection.close()
   }
}

come avrete notato, il secondo parametro del metodo selectData è stato marcato come implicit, quindi esso può essere passato al metodo in maniera implicita mediante un semplice import. Per cui, definite diverse configurazioni JDBC in un oggetto, ad esempio:

object DatabaseConfigurations {
   implicit val mySQL =    DBConfiguration("com.mysql.jdbc.Driver", 
                                 "mysql://localhost:3306/test", 
                                 "test", 
                                 "test")
   implicit val postgres = DBConfiguration("org.postgresql.Driver", 
                                 "postgresql://localhost:5432/mydb", 
                                 "user", 
                                 "password")
}

possiamo importare solo quella che desideriamo usare nello scope dell’oggetto JDBCEngine ed invocare il metodo selectData come segue:

import DatabaseConfigurations.mySQL
import JDBCEngine._ //il secondo parametro verrà importato implicitamente

selectData("Mario")

In questo modo potremo usare le conversioni implicite anche per disaccoppiare le dipendenze iniettandole in modo più conciso con un solo import.

Le regole di conversione

Con gli esempi delle sezioni precedenti abbiamo visto a cosa servono le conversioni implicite e dove è possibile utilizzarle. Oltre a questo, però, è importante evidenziare una serie di aspetti e regole che rendono l’utilizzo di queste conversioni abbastanza delicato. Le regole sono:

  • Regola di marcatura: solo le definizioni marcate come implicit possono essere riconosciute ed utilizzate per provare a risolvere errori di conversione di tipi. In questo modo si evitano eventuali problematiche che potrebbero insorgere laddove il compilatore, invece, prendesse una qualsiasi funzione in scope e inserendola come “conversione”. Secondo questa regola  una funzione di conversione implicita è selezionata solo tra le definizioni del codice sorgente che sono state esplicitamente marcate come implicit;

  • Regola dello scope: una conversione implicit deve essere in scope come un singolo identificativo oppure dev’essere associata con la sorgente o la destinazione della conversione. Il compilatore considera solo le conversioni implicite che sono in scope. Affinché una conversione implicita sia presente nello scope, essa dovrà essere introdotta mediante un import.
    Un’alternativa all’import della conversione, è quella di introdurre la conversione implicita all’interno del companion object della classe sorgente o destinazione della conversione. Nell’esempio precedente sui Bitcoin quindi, potremmo introdurre la conversione euroToBitcoin direttamente nell’object Euro. In questo caso, in ogni scope in cui l’oggetto Euro viene importato, abbiamo anche la conversione implicita da Euro a Bitcoin. Quest’alternativa all’import può risultare più comoda perché non richiede di importare la conversione separatamente dal companion object.
    Il beneficio principale di questa regola è quello di aiutare gli sviluppatori nel ragionare in maniera più organizzata: se tutte le conversioni implicite avessero uno scope globale, infatti, sarebbe complesso individuare all’interno della intera codebase se e quale conversione sia in atto.

  • Una conversione alla volta: il compilatore non permette di inserire più di una conversione alla volta per gli stessi tipi. Questa scelta nasce dall’esigenza di ridurre le differenze tra ciò che ha scritto il programmatore e ciò che il programma effettivamente eseguirà. Nello specifico, il compilatore non inserirà ulteriori conversioni implicite quando esso è già nel mezzo dell’applicazione di un altro implicit.

  • Priorità al codice esplicito: fin tanto che non ci sono errori di type checking, il compilatore non tenta di risolverli mediante la risoluzioni di conversioni implicite.

Conclusioni

Questo articolo fornisce una panoramica introduttiva sulle conversioni implicite di Scala. Si tratta di un costrutto molto potente, che consente sia di scrivere codice più conciso sia di favorire una migliore integrazione tra istanze e metodi originariamente pensati per tipi di dati diversi. Abbiamo rapidamente illustrato i contesti in cui è ammissibile introdurre conversioni implicite cercando di evidenziare i benefici del loro utilizzo.
Resta comunque importante sottolineare che, come buona parte dei costrutti avanzati di qualsiasi linguaggio di programmazione, non vale la pena di abusarne; solo così si potrà evitare un effetto controproducente dal loro utilizzo. Quindi, prima di introdurre qualsiasi conversione implicita, vale la pena di verificare se è possibile ottenere lo stesso effetto attraverso altri costrutti, ad esempio l’ereditarietà o l’overloading dei metodi.
Infine, oltre a tutte le regole e casistiche citate in questo breve post, esistono alcune eccezioni che possono renderne più complesso l’utilizzo. Per un approfondimento, il consiglio è quello di consultare il capitolo 21 del libro “Programming in Scala”.

1 Posts

Ingegnere del software e bug fixer dal 2008. Spendo le mie giornate lavorative chiuso dentro una JVM. Ho visto più variabili globali che film di fantascienza. "An investment in knowledge pays the best interest." B. Franklin