Panoramica
La programmazione a livello di tipo presenta molte somiglianze con la programmazione a livello di valore tradizionale. Tuttavia, a differenza della programmazione a livello di valore, in cui il calcolo avviene in fase di esecuzione, nella programmazione a livello di tipo, il calcolo avviene in fase di compilazione. Cercherò di tracciare parallelismi tra la programmazione a livello di valore e la programmazione a livello di tipo.
paradigmi
Esistono due paradigmi principali nella programmazione a livello di tipo: "orientato agli oggetti" e "funzionale". La maggior parte degli esempi collegati da qui seguono il paradigma orientato agli oggetti.
Un buon esempio abbastanza semplice di programmazione a livello di tipo nel paradigma orientato agli oggetti può essere trovato nell'implementazione di apocalisp del lambda calcolo , replicata qui:
// Abstract trait
trait Lambda {
type subst[U <: Lambda] <: Lambda
type apply[U <: Lambda] <: Lambda
type eval <: Lambda
}
// Implementations
trait App[S <: Lambda, T <: Lambda] extends Lambda {
type subst[U <: Lambda] = App[S#subst[U], T#subst[U]]
type apply[U] = Nothing
type eval = S#eval#apply[T]
}
trait Lam[T <: Lambda] extends Lambda {
type subst[U <: Lambda] = Lam[T]
type apply[U <: Lambda] = T#subst[U]#eval
type eval = Lam[T]
}
trait X extends Lambda {
type subst[U <: Lambda] = U
type apply[U] = Lambda
type eval = X
}
Come si può vedere nell'esempio, il paradigma orientato agli oggetti per la programmazione a livello di tipo procede come segue:
- Primo: definisci un tratto astratto con vari campi di tipo astratto (vedi sotto per cos'è un campo astratto). Questo è un modello per garantire che alcuni tipi di campi esistano in tutte le implementazioni senza forzare un'implementazione. Nell'esempio lambda calcolo, ciò corrisponde a
trait Lambda
che i seguenti tipi esistano garanzie: subst
, apply
e eval
.
- Avanti: definire i sottotratti che estendono il tratto astratto e implementano i vari campi di tipo astratto
- Spesso, questi sottotratti saranno parametrizzati con argomenti. Nell'esempio del lambda calcolo, i sottotipi sono
trait App extends Lambda
parametrizzati con due tipi ( S
e T
, entrambi devono essere sottotipi di Lambda
), trait Lam extends Lambda
parametrizzati con un tipo ( T
) e trait X extends Lambda
(che non è parametrizzato).
- i campi di tipo sono spesso implementati facendo riferimento ai parametri di tipo del sottoritratto e talvolta facendo riferimento ai loro campi di tipo tramite l'operatore hash:
#
(che è molto simile all'operatore punto: .
per i valori). Nel tratto App
dell'esempio lambda calcolo, il tipo eval
viene realizzato nel modo seguente: type eval = S#eval#apply[T]
. Si tratta essenzialmente di chiamare il eval
tipo di parametro del tratto S
e di chiamare apply
con parametro T
sul risultato. Nota, S
è garantito che abbia un eval
tipo perché il parametro specifica che è un sottotipo di Lambda
. Allo stesso modo, il risultato di eval
deve avere un apply
tipo, poiché è specificato che è un sottotipo di Lambda
, come specificato nel tratto astratto Lambda
.
Il paradigma funzionale consiste nel definire molti costruttori di tipi parametrizzati che non sono raggruppati insieme in tratti.
Confronto tra programmazione a livello di valore e programmazione a livello di tipo
- classe astratta
- il valore di livello:
abstract class C { val x }
- tipo di livello:
trait C { type X }
- tipi dipendenti dal percorso
C.x
(riferendosi al valore del campo / funzione x nell'oggetto C)
C#x
(facendo riferimento al tipo di campo x nel tratto C)
- firma della funzione (nessuna implementazione)
- il valore di livello:
def f(x:X) : Y
- livello di tipo:
type f[x <: X] <: Y
(questo è chiamato "costruttore di tipo" e di solito si verifica nel tratto astratto)
- implementazione della funzione
- il valore di livello:
def f(x:X) : Y = x
- tipo di livello:
type f[x <: X] = x
- condizionali
- controllo dell'uguaglianza
- il valore di livello:
a:A == b:B
- tipo di livello:
implicitly[A =:= B]
- a livello di valore: si verifica nella JVM tramite uno unit test in fase di esecuzione (ovvero senza errori di runtime):
- in essense è un'affermazione:
assert(a == b)
- livello di tipo: si verifica nel compilatore tramite un controllo del tipo (cioè nessun errore del compilatore):
- in sostanza è un confronto di tipo: es
implicitly[A =:= B]
A <:< B
, viene compilato solo se A
è un sottotipo diB
A =:= B
, viene compilato solo se A
è un sottotipo di B
ed B
è un sottotipo diA
A <%< B
, ("visualizzabile come") viene compilato solo se A
è visualizzabile come B
(cioè c'è una conversione implicita da A
a un sottotipo di B
)
- un esempio
- più operatori di confronto
Conversione tra tipi e valori
In molti degli esempi, i tipi definiti tramite i tratti sono spesso sia astratti che sigillati, e quindi non possono essere istanziati direttamente né tramite sottoclasse anonime. Quindi è comune utilizzare null
come valore segnaposto quando si esegue un calcolo a livello di valore utilizzando un tipo di interesse:
- ad esempio
val x:A = null
, dov'è A
il tipo a cui tieni
A causa della cancellazione del tipo, i tipi parametrizzati hanno tutti lo stesso aspetto. Inoltre, (come accennato in precedenza) i valori con cui stai lavorando tendono a essere tutti null
, e quindi il condizionamento sul tipo di oggetto (ad esempio tramite una dichiarazione di corrispondenza) è inefficace.
Il trucco sta nell'usare funzioni e valori impliciti. Il caso base è solitamente un valore implicito e il caso ricorsivo è solitamente una funzione implicita. In effetti, la programmazione a livello di tipo fa un uso massiccio di impliciti.
Considera questo esempio ( tratto da metascala e apocalisp ):
sealed trait Nat
sealed trait _0 extends Nat
sealed trait Succ[N <: Nat] extends Nat
Qui hai una codifica peano dei numeri naturali. Cioè, hai un tipo per ogni numero intero non negativo: un tipo speciale per 0, vale a dire _0
; e ogni numero intero maggiore di zero ha un tipo della forma Succ[A]
, dove A
è il tipo che rappresenta un numero intero più piccolo. Ad esempio, il tipo che rappresenta 2 sarebbe: Succ[Succ[_0]]
(successore applicato due volte al tipo che rappresenta zero).
Possiamo alias vari numeri naturali per un riferimento più conveniente. Esempio:
type _3 = Succ[Succ[Succ[_0]]]
(Questo è un po 'come definire val
a come risultato di una funzione.)
Ora, supponiamo di voler definire una funzione a livello di valore def toInt[T <: Nat](v : T)
che accetta un valore di argomento v
,, che è conforme a Nat
e restituisce un numero intero che rappresenta il numero naturale codificato nel v
tipo di. Ad esempio, se abbiamo il valore val x:_3 = null
( null
di tipo Succ[Succ[Succ[_0]]]
), lo vorremmotoInt(x)
tornare 3
.
Per l'implementazione toInt
, utilizzeremo la seguente classe:
class TypeToValue[T, VT](value : VT) { def getValue() = value }
Come vedremo di seguito, ci sarà un oggetto costruito dalla classe TypeToValue
per ciascuno Nat
dal _0
fino al (es.) _3
, E ognuno memorizzerà la rappresentazione del valore del tipo corrispondente (cioè TypeToValue[_0, Int]
memorizzerà il valore 0
, TypeToValue[Succ[_0], Int]
memorizzerà il valore 1
, ecc.). Nota, TypeToValue
è parametrizzato da due tipi: T
e VT
. T
corrisponde al tipo a cui stiamo cercando di assegnare valori (nel nostro esempio, Nat
) e VT
corrisponde al tipo di valore che gli stiamo assegnando (nel nostro esempio,Int
).
Ora facciamo le seguenti due definizioni implicite:
implicit val _0ToInt = new TypeToValue[_0, Int](0)
implicit def succToInt[P <: Nat](implicit v : TypeToValue[P, Int]) =
new TypeToValue[Succ[P], Int](1 + v.getValue())
E implementiamo toInt
come segue:
def toInt[T <: Nat](v : T)(implicit ttv : TypeToValue[T, Int]) : Int = ttv.getValue()
Per capire come toInt
funziona, consideriamo cosa fa su un paio di input:
val z:_0 = null
val y:Succ[_0] = null
Quando chiamiamo toInt(z)
, il compilatore cerca un argomento implicito ttv
di tipo TypeToValue[_0, Int]
(poiché z
è di tipo _0
). Trova l'oggetto _0ToInt
, chiama il getValue
metodo di questo oggetto e torna indietro0
. Il punto importante da notare è che non abbiamo specificato al programma quale oggetto utilizzare, il compilatore lo ha trovato implicitamente.
Ora consideriamo toInt(y)
. Questa volta, il compilatore cerca un argomento implicito ttv
di tipo TypeToValue[Succ[_0], Int]
(poiché y
è di tipo Succ[_0]
). Trova la funzione succToInt
, che può restituire un oggetto del tipo appropriato ( TypeToValue[Succ[_0], Int]
) e la valuta. Questa funzione stessa accetta un argomento implicito ( v
) di tipo TypeToValue[_0, Int]
(ovvero, a TypeToValue
dove il primo parametro di tipo è ne ha uno in meno Succ[_]
). Il compilatore fornisce _0ToInt
(come è stato fatto nella valutazione di cui toInt(z)
sopra) e succToInt
costruisce un nuovo TypeToValue
oggetto con valore1
. Ancora una volta, è importante notare che il compilatore fornisce tutti questi valori in modo implicito, poiché non abbiamo accesso ad essi esplicitamente.
Controllo del tuo lavoro
Esistono diversi modi per verificare che i calcoli a livello di tipo stiano facendo ciò che ti aspetti. Ecco alcuni approcci. Crea due tipi A
e B
che vuoi verificare siano uguali. Quindi controlla che la seguente compilazione:
Equal[A, B]
implicitly[A =:= B]
In alternativa, puoi convertire il tipo in un valore (come mostrato sopra) ed eseguire un controllo di runtime dei valori. Ad esempio assert(toInt(a) == toInt(b))
, dove a
è di tipo A
ed b
è di tipo B
.
Risorse addizionali
L'insieme completo dei costrutti disponibili può essere trovato nella sezione tipi del manuale di riferimento scala (pdf) .
Adriaan Moors ha diversi documenti accademici sui costruttori di tipi e argomenti correlati con esempi da scala:
Apocalisp è un blog con molti esempi di programmazione a livello di tipo in scala.
ScalaZ è un progetto molto attivo che fornisce funzionalità che estendono l'API Scala utilizzando varie funzionalità di programmazione a livello di tipo. È un progetto molto interessante che ha un grande seguito.
MetaScala è una libreria a livello di tipo per Scala, inclusi meta tipi per numeri naturali, booleani, unità, HList, ecc. È un progetto di Jesper Nordenberg (il suo blog) .
Il Michid (blog) ha alcuni fantastici esempi di programmazione a livello di tipo in Scala (da un'altra risposta):
Debasish Ghosh (blog) ha anche alcuni post rilevanti:
(Ho fatto delle ricerche su questo argomento ed ecco cosa ho imparato. Sono ancora nuovo, quindi per favore indica eventuali inesattezze in questa risposta.)