Dipendentemente digitato Haskell, ora?
Haskell è, in piccola parte, un linguaggio tipicamente dipendente. Esiste una nozione di dati a livello di tipo, ora più sensibilmente digitati grazie a DataKinds
, e vi sono alcuni mezzi ( GADTs
) per fornire una rappresentazione di runtime ai dati a livello di tipo. Quindi, i valori delle cose di runtime vengono effettivamente visualizzati in tipi , che è ciò che significa che una lingua deve essere tipizzata in modo dipendente.
I tipi di dati semplici vengono promossi a livello di tipo, in modo che i valori in essi contenuti possano essere utilizzati nei tipi. Da qui l'esempio archetipico
data Nat = Z | S Nat
data Vec :: Nat -> * -> * where
VNil :: Vec Z x
VCons :: x -> Vec n x -> Vec (S n) x
diventa possibile e con esso definizioni come
vApply :: Vec n (s -> t) -> Vec n s -> Vec n t
vApply VNil VNil = VNil
vApply (VCons f fs) (VCons s ss) = VCons (f s) (vApply fs ss)
che è bello. Nota che la lunghezza n
è una cosa puramente statica in quella funzione, assicurando che i vettori di input e output abbiano la stessa lunghezza, anche se quella lunghezza non ha alcun ruolo nell'esecuzione di
vApply
. Al contrario, è molto più complicato (vale a dire, impossibile) per implementare la funzione che fa n
copie di un dato x
(che sarebbe pure
a vApply
's <*>
)
vReplicate :: x -> Vec n x
perché è fondamentale sapere quante copie eseguire in fase di esecuzione. Inserisci i singoli.
data Natty :: Nat -> * where
Zy :: Natty Z
Sy :: Natty n -> Natty (S n)
Per qualsiasi tipo promotabile, possiamo costruire la famiglia singleton, indicizzata sul tipo promosso, abitata da duplicati di runtime dei suoi valori. Natty n
è il tipo di copie runtime del tipo-livello n
:: Nat
. Ora possiamo scrivere
vReplicate :: Natty n -> x -> Vec n x
vReplicate Zy x = VNil
vReplicate (Sy n) x = VCons x (vReplicate n x)
Quindi lì hai un valore a livello di tipo associato a un valore di runtime: l'ispezione della copia di runtime raffina la conoscenza statica del valore a livello di tipo. Anche se termini e tipi sono separati, possiamo lavorare in modo dipendente dal tipo usando la costruzione singleton come una specie di resina epossidica, creando legami tra le fasi. È molto lontano dal consentire espressioni di runtime arbitrarie in tipi, ma non è niente.
Cosa c'è di cattivo? Cosa manca?
Facciamo un po 'di pressione su questa tecnologia e vediamo cosa inizia a vacillare. Potremmo avere l'idea che i singoli dovrebbero essere gestibili in modo un po 'più implicito
class Nattily (n :: Nat) where
natty :: Natty n
instance Nattily Z where
natty = Zy
instance Nattily n => Nattily (S n) where
natty = Sy natty
permettendoci di scrivere, diciamo,
instance Nattily n => Applicative (Vec n) where
pure = vReplicate natty
(<*>) = vApply
Funziona, ma ora significa che il nostro Nat
tipo originale ha generato tre copie: una specie, una famiglia singleton e una classe singleton. Abbiamo un processo piuttosto ingombrante per lo scambio di Natty n
valori e Nattily n
dizionari espliciti . Inoltre, Natty
non lo è Nat
: abbiamo una sorta di dipendenza dai valori di runtime, ma non dal tipo a cui abbiamo pensato per la prima volta. Nessun linguaggio tipicamente dipendente rende i tipi dipendenti così complicati!
Nel frattempo, sebbene Nat
possa essere promosso, Vec
non può. Non è possibile indicizzare per tipo indicizzato. Completamente su linguaggi tipicamente dipendenti non impone tale restrizione, e nella mia carriera di show-off tipicamente dipendente, ho imparato a includere esempi di indicizzazione a due livelli nei miei discorsi, solo per insegnare alle persone che hanno fatto l'indicizzazione a uno strato difficile ma possibile non aspettarsi che mi pieghi come un castello di carte. Qual è il problema? Uguaglianza. I GADT funzionano traducendo i vincoli che si ottengono implicitamente quando si assegna a un costruttore un tipo di ritorno specifico in esplicite richieste equazionali. Come questo.
data Vec (n :: Nat) (x :: *)
= n ~ Z => VNil
| forall m. n ~ S m => VCons x (Vec m x)
In ciascuna delle nostre due equazioni, entrambe le parti hanno gentile Nat
.
Ora prova la stessa traduzione per qualcosa di indicizzato sui vettori.
data InVec :: x -> Vec n x -> * where
Here :: InVec z (VCons z zs)
After :: InVec z ys -> InVec z (VCons y ys)
diventa
data InVec (a :: x) (as :: Vec n x)
= forall m z (zs :: Vec x m). (n ~ S m, as ~ VCons z zs) => Here
| forall m y z (ys :: Vec x m). (n ~ S m, as ~ VCons y ys) => After (InVec z ys)
e ora formiamo vincoli equazionali tra as :: Vec n x
e
VCons z zs :: Vec (S m) x
dove le due parti hanno tipi sintatticamente distinti (ma dimostrabilmente uguali). Il core GHC non è attualmente attrezzato per un simile concetto!
Cos'altro manca? Bene, la maggior parte di Haskell manca dal livello del tipo. Il linguaggio dei termini che puoi promuovere ha solo variabili e costruttori non GADT, davvero. Una volta che li hai, il type family
macchinario ti consente di scrivere programmi a livello di tipo: alcuni di questi potrebbero essere abbastanza simili a funzioni che considereresti di scrivere a livello di termine (ad esempio, equipaggiare Nat
con l'aggiunta, in modo da poter dare un buon tipo da aggiungere Vec
) , ma è solo una coincidenza!
Un'altra cosa che manca, in pratica, è una libreria che si avvale delle nostre nuove abilità per indicizzare i tipi in base ai valori. Cosa fanno Functor
e Monad
diventano in questo nuovo mondo coraggioso? Ci sto pensando, ma c'è ancora molto da fare.
Esecuzione di programmi a livello di tipo
Haskell, come la maggior parte dei linguaggi di programmazione tipicamente dipendenti, ha due
semantiche operative. C'è il modo in cui il sistema di runtime esegue i programmi (solo espressioni chiuse, dopo la cancellazione del tipo, altamente ottimizzato) e poi c'è il modo in cui il typechecker esegue i programmi (le tue famiglie di tipi, il tuo "tipo di classe Prolog", con espressioni aperte). Per Haskell, normalmente non si mescolano i due, perché i programmi in esecuzione sono in lingue diverse. Le lingue tipizzate in modo dipendente hanno modelli separati di runtime ed esecuzione statica per la stessa lingua di programmi, ma non preoccuparti, il modello di runtime ti consente ancora di digitare la cancellazione e, in effetti, la prova di cancellazione: questo è ciò che l' estrazione di Coqil meccanismo ti dà; questo è almeno ciò che fa il compilatore di Edwin Brady (anche se Edwin cancella valori duplicati inutilmente, nonché tipi e prove). La distinzione di fase potrebbe non essere più una distinzione della categoria sintattica
, ma è viva e vegeta.
Le lingue tipicamente dipendenti, essendo totali, consentono al typechecker di eseguire programmi liberi dalla paura di qualcosa di peggio di una lunga attesa. Mentre Haskell viene tipizzato in modo più dipendente, ci troviamo di fronte alla domanda su quale dovrebbe essere il suo modello di esecuzione statica? Un approccio potrebbe essere quello di limitare l'esecuzione statica alle funzioni totali, il che ci consentirebbe la stessa libertà di esecuzione, ma potrebbe costringerci a fare distinzioni (almeno per il codice a livello di tipo) tra dati e codata, in modo da poter dire se imporre la risoluzione o la produttività. Ma questo non è l'unico approccio. Siamo liberi di scegliere un modello di esecuzione molto più debole che è riluttante a eseguire programmi, al costo di far uscire meno equazioni solo dal calcolo. E in effetti, è quello che fa GHC. Le regole di digitazione per GHC core non menzionano la corsa
programmi, ma solo per verificare l'evidenza delle equazioni. Quando si traduce in core, il risolutore di vincoli di GHC cerca di eseguire i programmi a livello di tipo, generando una piccola scia argentata di prove che una determinata espressione è uguale alla sua forma normale. Questo metodo di generazione delle prove è un po 'imprevedibile e inevitabilmente incompleto: combatte ad esempio con una ricorsione dall'aspetto spaventoso, e probabilmente è saggio. Una cosa di cui non dobbiamo preoccuparci è l'esecuzione dei IO
calcoli nel typechecker: ricorda che il typechecker non deve dare
launchMissiles
lo stesso significato del sistema runtime!
Cultura Hindley-Milner
Il sistema di tipo Hindley-Milner raggiunge la straordinaria coincidenza di quattro distinzioni distinte, con lo sfortunato effetto collaterale culturale che molte persone non riescono a vedere la distinzione tra le distinzioni e supporre che la coincidenza sia inevitabile! Di cosa sto parlando?
- termini vs tipi
- cose scritte in modo esplicito contro cose scritte in modo implicito
- presenza in fase di esecuzione vs cancellazione prima della fase di esecuzione
- astrazione non dipendente vs quantificazione dipendente
Siamo abituati a scrivere termini e lasciare inferire i tipi ... e poi cancellati. Siamo abituati a quantificare le variabili di tipo con l'astrazione del tipo corrispondente e l'applicazione che avviene silenziosamente e staticamente.
Non devi allontanarti troppo dalla vaniglia Hindley-Milner prima che queste distinzioni vengano fuori allineamento, e non è male . Per cominciare, possiamo avere tipi più interessanti se siamo disposti a scriverli in alcuni punti. Nel frattempo, non dobbiamo scrivere dizionari di classe di tipo quando utilizziamo funzioni sovraccaricate, ma quei dizionari sono certamente presenti (o incorporati) in fase di esecuzione. In linguaggi tipicamente dipendenti, ci aspettiamo di cancellare più dei semplici tipi in fase di esecuzione, ma (come con le classi di tipi) che alcuni valori implicitamente dedotti non verranno cancellati. Ad esempio, vReplicate
l'argomento numerico è spesso inferibile dal tipo del vettore desiderato, ma dobbiamo ancora conoscerlo in fase di esecuzione.
Quali scelte di design linguistico dovremmo rivedere perché queste coincidenze non valgono più? Ad esempio, è giusto che Haskell non fornisca alcun modo per creare un'istanza forall x. t
esplicita di un quantificatore? Se il typechecker non può indovinare x
unificando t
, non abbiamo altro modo di dire cosa x
deve essere.
Più in generale, non possiamo trattare l '"inferenza di tipo" come un concetto monolitico di cui abbiamo tutto o niente. Per cominciare, dobbiamo separare l'aspetto di "generalizzazione" (regola "let" di Milner), che si basa fortemente sulla limitazione di quali tipi esistono per garantire che una macchina stupida possa indovinarne uno, dall'aspetto di "specializzazione" (var di Milner "regola) che è efficace quanto il tuo risolutore di vincoli. Possiamo aspettarci che i tipi di livello superiore diventeranno più difficili da dedurre, ma che le informazioni sui tipi interni rimarranno abbastanza facili da propagare.
I prossimi passi per Haskell
Stiamo vedendo che i livelli di tipo e tipo diventano molto simili (e condividono già una rappresentazione interna in GHC). Potremmo anche unirli. Sarebbe divertente * :: *
se potessimo: abbiamo perso
la solidità logica molto tempo fa, quando consentivamo il fondo, ma la
solidità del tipo è di solito un requisito più debole. Dobbiamo controllare. Se dobbiamo avere livelli distinti di tipo, tipo, ecc., Possiamo almeno assicurarci che ogni cosa a livello di tipo e superiore possa sempre essere promossa. Sarebbe bello solo riutilizzare il polimorfismo che già abbiamo per i tipi, piuttosto che reinventare il polimorfismo a livello gentile.
Dovremmo semplificare e generalizzare l'attuale sistema di vincoli consentendo equazioni eterogenee ina ~ b
cui i tipi di a
e
b
non sono sintatticamente identici (ma possono essere dimostrati uguali). È una vecchia tecnica (nella mia tesi, del secolo scorso) che rende la dipendenza molto più facile da affrontare. Saremmo in grado di esprimere vincoli sulle espressioni nei GADT, e quindi allentare le restrizioni su ciò che può essere promosso.
Dobbiamo eliminare la necessità per la costruzione singleton introducendo un tipo di funzione dipendente pi x :: s -> t
. Una funzione con un tale tipo potrebbe essere applicata esplicitamente a qualsiasi espressione di tipo s
che vive nell'intersezione del tipo e dei linguaggi dei termini (quindi, variabili, costruttori, con altri a venire in seguito). La lambda e l'applicazione corrispondenti non verrebbero cancellate in fase di esecuzione, quindi saremmo in grado di scrivere
vReplicate :: pi n :: Nat -> x -> Vec n x
vReplicate Z x = VNil
vReplicate (S n) x = VCons x (vReplicate n x)
senza sostituire Nat
con Natty
. Il dominio di pi
può essere di qualsiasi tipo promuovibile, quindi se i GADT possono essere promossi, possiamo scrivere sequenze di quantificatori dipendenti (o "telescopi" come li chiamavano de Briuijn)
pi n :: Nat -> pi xs :: Vec n x -> ...
di qualunque lunghezza abbiamo bisogno.
Il punto di questi passaggi è eliminare la complessità lavorando direttamente con strumenti più generali, invece di accontentarsi di strumenti deboli e codifiche goffe. L'attuale buy-in parziale rende i vantaggi dei tipi dipendenti di Haskell più costosi di quanto debbano essere.
Troppo difficile?
I tipi dipendenti rendono molte persone nervose. Mi rendono nervoso, ma mi piace essere nervoso, o almeno trovo difficile non essere nervoso comunque. Ma non aiuta che ci sia una tale nebbia di ignoranza attorno all'argomento. In parte ciò è dovuto al fatto che tutti abbiamo ancora molto da imparare. Ma è noto che i sostenitori di approcci meno radicali alimentano la paura dei tipi dipendenti senza assicurarsi sempre che i fatti siano interamente con loro. Non nominerò i nomi. Questi "indecidibili controlli dei caratteri", "Turing incompleti", "nessuna distinzione di fase", "nessuna cancellazione di tipo", "prove ovunque", ecc., Persistono miti, anche se sono spazzatura.
Non è certo il caso che i programmi tipicamente dipendenti debbano sempre essere dimostrati corretti. Si può migliorare l'igiene di base dei propri programmi, imponendo ulteriori invarianti in tipi senza andare fino in fondo a una specifica completa. Piccoli passi in questa direzione spesso portano a garanzie molto più forti con pochi o nessun obbligo di prova aggiuntivo. Non è vero che i programmi scritti in modo dipendente siano inevitabilmente pieni di prove, anzi di solito prendo la presenza di eventuali prove nel mio codice come spunto per mettere in discussione le mie definizioni .
Perché, come con ogni aumento di articolazione, diventiamo liberi di dire cose nuove, oltre che giuste. Ad esempio, ci sono molti modi scadenti per definire alberi di ricerca binari, ma ciò non significa che non ci sia un buon modo . È importante non presumere che le brutte esperienze non possano essere migliorate, anche se ammette l'ego di ammetterlo. La progettazione di definizioni dipendenti è una nuova abilità che richiede apprendimento, ed essere un programmatore Haskell non ti rende automaticamente un esperto! E anche se alcuni programmi sono fallo, perché negheresti ad altri la libertà di essere onesti?
Perché preoccuparsi ancora di Haskell?
Mi piacciono molto i tipi dipendenti, ma la maggior parte dei miei progetti di hacking sono ancora in Haskell. Perché? Haskell ha classi di tipi. Haskell ha utili librerie. Haskell ha un trattamento praticabile (sebbene tutt'altro che ideale) di programmazione con effetti. Haskell ha un compilatore di forza industriale. I linguaggi tipicamente dipendenti sono in una fase molto più precoce nella crescita della comunità e delle infrastrutture, ma ci arriveremo, con un vero passaggio generazionale in ciò che è possibile, ad esempio, attraverso metaprogrammazione e generici di tipi di dati. Ma devi solo guardarti intorno cosa fanno le persone a seguito dei passi di Haskell verso i tipi dipendenti per vedere che ci sono molti vantaggi da ottenere spingendo avanti anche l'attuale generazione di lingue.