introduzione alla typeclasses in Scala

  • Parte 1: Questo post
  • Parte 2: Type-Safe serializzazione in Scala
  • Parte 3: Typeful gestione degli errori con più variegata tipi

che Cosa è un tipo di classe

Uno dei principali ostacoli per la comprensione e l’utilizzo di classi di tipo nel proprio codice, è il nome stesso. Contrariamente a quanto evoca il nome, una classe di tipo non è una classe di un linguaggio orientato agli oggetti. Secondo wikipedia:

In informatica, una classe di tipo è un costrutto di sistema di tipo che supporta il polimorfismo ad hoc. Ciò si ottiene aggiungendo vincoli alle variabili di tipo in tipi parametricamente polimorfici.

Abbiamo un po ‘di disimballaggio da fare:

  • Che cos’è il polimorfismo “ad hoc”? Qualunque cosa sia, questo è ciò che le classi di tipo ci aiuteranno a raggiungere.
  • Che cos’è un tipo’ parametricamente polimorfico’? Qualunque cosa sia, è così che creeremo le nostre classi di tipo.

Le classi di tipo sono apparse per la prima volta in Haskell e sono un modo fondamentale per costruire astrazioni in Haskell. Sono così vitali che vengono introdotti nella prima pagina del libro Learn You a Haskell:

Una classe type è una sorta di interfaccia che definisce alcuni comportamenti. Se un tipo fa parte di una classe di tipo, significa che supporta e implementa il comportamento descritto dalla classe di tipo.

Ok, quindi sembra che stiamo cercando di definire un comportamento per i nostri tipi e implementare quel comportamento in modo diverso a seconda del tipo. L’ultima parte, “implementa il comportamento in modo diverso a seconda del tipo” è il bit “polimorfismo” di prima. Quindi il termine sta iniziando ad avere senso: è una classe per i tipi. Una classe che implementa il comportamento designato dalla classe di tipo si dice che sia un membro di quella classe di tipi – ‘type class’.

A questo punto sarebbe opportuno dire che questo suona come ereditarietà. Entrambi ci permettono di costruire astrazioni e fornire polimorfismo.

Quindi perché esplorare le classi di tipo? In molti casi sono un po ‘ più potenti ed estensibili di una gerarchia di ereditarietà. Possono essere molto più semplici e si adattano molto meglio quando la tua base di codice si appoggia già a FP su OO. Sebbene anche in un progetto fortemente OO, le classi di tipo possono ancora salvare la giornata.

Definizione di un typeclass

Questo è abbastanza introduzione — per il resto dell’articolo esploreremo un pezzo di funzionalità molto familiare, che è effettivamente implementato in Java tramite ereditarietà e implementandolo usando una classe di tipo: stringification.

Abbiamo tutti familiarità con il metodo che esiste su tutti gli oggetti in Java (e quindi Scala): .toString. Il suo scopo è quello di produrre una rappresentazione stringa dell’oggetto in questione. Diamo un’occhiata a come useremmo le classi di tipo per raggiungere questo obiettivo. La versione di Haskell di toString si chiama Show , quindi ci atteniamo approssimativamente a quel nome:

trait Showable {
def show(a: A): String
}

Questo è tutto: c’è la nostra classe di tipo Showable. Perché prende un parametro di tipo A? Se ricordiamo la nostra prima definizione di classi di tipi, leggiamo che ” ottenuto aggiungendo vincoli alle variabili di tipo in tipi parametricamente polimorfici.”Showable è il nostro tipo parametricamente polimorfico, e A è la nostra variabile di tipo, il che significa semplicemente che prende un tipo (A) come parametro di tipo. (Nota che non abbiamo posto alcun vincolo su A, come da definizione, ma va bene. Questo è qualcosa che facciamo quando componiamo classi di tipo insieme, o facendo uso di loro, che verrà più tardi.)

Implementazione di un typeclass

Ora dobbiamo fornire implementazioni del tratto Showable per tutti i tipi che vogliamo essere in grado di mostrare. Queste implementazioni sono chiamate “istanze” della nostra classe di tipo, poiché saranno letteralmente istanze del tratto Showable. Queste saranno più spesso classi anonime che sovrascrivono .show, e dal momento che ci sono alcuni boilerplate coinvolti nella loro definizione (ricorda “oggetti funzione” nella programmazione Java pre-lambda?), definiremo un helper chiamato make. Iniziamo con un’implementazione per un tipo di nome utente:

object Showable {
def make(showFn: A => String): Showable = new Showable {
override def show(a: A): String = showFn(a)
}
}final case class UserName(first: String, last: String)implicit val defaultUserNameShowable = Showable.make(userName => s"${userName.first} ${userName.last}")val janeName = UserName("Jane", "Doe")

Quindi, come potremmo invocare questo? Potremmo chiamare direttamente il metodo dall’istanza showable, in questo modo:

defaultUserNameShowable.show(janeName) // "Jane Doe"

Sintassi migliorata

Siamo riusciti a richiamare la nostra implementazione Showable di UserName, ma non è stato molto bello. Dovevamo avere un riferimento alla nostra istanza di classe di tipo in giro (defaultUserNameShowable) e conoscere il suo nome. Questo è qualcosa che vogliamo astratto via. Se la storia finisse qui, digitare le classi in Scala non sarebbe poi così bello da usare. Fortunatamente, possiamo migliorare l’ergonomia drasticamente utilizzando impliciti. Aumenteremo l’oggetto companion di Showable con un helper di invocazione:

implicit class ShowSyntax(a: A) extends AnyVal {
def show(implicit showable: Showable): String = showable.show(a)
}
...
janeName.show

Posizionando questo “metodo di estensione” da qualche parte nell’ambito, possiamo semplicemente fingere che tutti gli oggetti abbiano un metodo .show.

Che cosa ha raggiunto

Quindi perché non sovrascrivere toString su UserName invece? Bene, diciamo che UserName vive in una libreria da cui dipendiamo ed è già compilato nel momento in cui lo stiamo consumando. Non possiamo sottoclassarlo in quanto è una classe final. Tuttavia, utilizzando le classi di tipo, siamo liberi di “allegare” essenzialmente un nuovo comportamento ad esso in modo ad hoc. Questo è il pezzo “ad hoc” della definizione sopra: possiamo aggiungere questo polimorfismo separatamente dal luogo in cui è definito il tipo stesso.

Inoltre, a differenza dell’ereditarietà, quando definiamo le classi di tipo in Scala, non devono essere “coerenti”, il che significa che possiamo definire più implementazioni per Showable e scegliere tra di esse. Per alcune classi di tipo questa è una buona cosa, per altri non tanto.

In Haskell, le classi di tipo sono coerenti. In Scala 3, secondo Martin Odersky, avremo la possibilità di scegliere, per ogni classe di tipo, se dovrebbe essere coerente o meno.

Ma per ora, forniamo un significato alternativo di show per UserName:

implicit val secretUserNameShowable = Showable.make(_ => "<secret person>")

Tutto quello che dobbiamo fare ora è essere sicuri che solo una delle nostre istanze Showable sia nell’ambito alla volta. E se no, non è un grosso problema, otterremo un errore di “valori impliciti ambigui” in fase di compilazione:

janeName.show
> ambiguous implicit values:
both value secretUserNameShowable in object typeclasses of type => Showable
and value defaultUserNameShowable in object typeclasses of type => Showable
match expected type Showable

Una nota importante è che non abbiamo accoppiato UserName e Showable. Tutto ciò che abbiamo fatto è definire un significato di Showable per UserName, mantenendoli completamente indipendenti l’uno dall’altro. Questo disaccoppiamento ha enormi implicazioni di manutenibilità e aiuta a prevenire relazioni di dipendenza aggrovigliate in tutto il codice.

Sicurezza in fase di compilazione

Cosa succede se tentiamo di chiamare .show su un oggetto per il quale non abbiamo fornito un significato di show? In altre parole, quando non esiste un’istanza di Showable nell’ambito del nostro obiettivo? Otteniamo semplicemente un errore del compilatore:

java.util.UUID.randomUUID().show
> could not find implicit value for parameter showable: Showable

Possiamo effettivamente personalizzare questo messaggio di errore quando definiamo la nostra classe type:

@scala.annotation.implicitNotFound("No Showable in scope for A = ${A}. Try importing or defining one.")
trait Showable {
...
}
...
janeName.show
> No Showable in scope for A = java.util.UUID. Try importing or defining one.

Key Takeaways

  • Le classi di tipi ci consentono di definire un insieme di comportamenti completamente separatamente dagli oggetti e dai tipi che implementeranno tali comportamenti.
  • Le classi di tipo sono espresse in Scala pura con tratti che prendono parametri di tipo e implicano la pulizia della sintassi.
  • Le classi di tipi ci consentono di estendere o implementare il comportamento per tipi e oggetti la cui origine non possiamo modificare. (Forniscono il polimorfismo ad hoc necessario per risolvere il problema dell’espressione).

Ciò che viene dopo

Concesso, Showable è una classe di tipo banale, anche se non senza i suoi usi. Possiamo facilmente immaginare quelli molto più utili, tuttavia:

trait SafeSerializable {
def serialize(a: A): Array
def deserialize(bytes: Array): A
}
trait Convertible {
def to(a: A): B
def from(b: B): A
}
trait Functor] {
def map(a: F, f: A => B): F
}

SafeSerializable potrebbe essere un’ottima alternativa a qualcosa come la serializzazione di Akka in cui aspettiamo fino al runtime per vedere se un serializzatore è registrato o meno per il nostro tipo. È molto più sicuro passare implicitamente l’implementazione della serializzazione in fase di compilazione!

Convertible potrebbe essere un ottimo modo riutilizzabile per convertire i dati tra i tipi. C’è una libreria di Twitter con lo stesso obiettivo in mente chiamato Bijection.

Functor è un elemento costitutivo per alcune astrazioni estremamente potenti in un’area di programmazione funzionale chiamata “programmazione categoriale”. Librerie come ScalaZ e Cats sono costruite su classi di tipo come Functor. Una nozione importante che non abbiamo discusso questa volta è che le classi di tipo possono essere facilmente composte per costruire comportamenti sempre più complessi da semplici blocchi di costruzione come Functor.

Inoltre, molte librerie essenziali nell’ecosistema Scala (Play JSON, Slick, et. al) fare uso di classi di tipo ed esporle come driver principale della loro API in molti casi (e.g. Gioca la classe di tipo Format di JSON). Comprendere le classi di tipo da soli è un ottimo modo per diventare più efficaci nell’utilizzare ciò che è là fuori nell’ecosistema Scala, anche se non scrivi mai nessuno dei tuoi.

In futuro, guarderò alcune classi di tipo immediatamente pratiche come SafeSerializable. Per un’introduzione alle classi di tipi categoriali più astratte come Functor] e Monad], non guardare oltre la documentazione della libreria cats. Per una guida più approfondita alla programmazione in Scala utilizzando le classi di tipo, il underscore.io ha un libro fantastico sull’argomento: Scala with Cats

Parte 2: Serializzazione sicura del tipo in Scala

GIST con codice

Lascia un commento

Il tuo indirizzo email non sarà pubblicato.