Per espandere la risposta di @ KarlBielefeldt, ecco un esempio completo di come implementare i vettori - elenchi con un numero staticamente noto di elementi - in Haskell. Tieni il tuo cappello ...
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE ExistentialQuantification #-}
{-# LANGUAGE DeriveFoldable #-}
{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE TypeFamilies #-}
import Prelude hiding (foldr, zipWith)
import qualified Prelude
import Data.Type.Equality
import Data.Foldable
import Data.Traversable
Come puoi vedere dalla lunga lista di LANGUAGE
direttive, funzionerà solo con una versione recente di GHC.
Abbiamo bisogno di un modo per rappresentare le lunghezze nel sistema dei tipi. Per definizione, un numero naturale è zero ( Z
) o è il successore di qualche altro numero naturale ( S n
). Quindi, per esempio, il numero 3 verrebbe scritto S (S (S Z))
.
data Nat = Z | S Nat
Con l' estensione DataKinds , questa data
dichiarazione introduce un tipo chiamato Nat
e due costruttori di tipo chiamati S
e Z
- in altre parole abbiamo numeri naturali a livello di tipo . Nota che i tipi S
e Z
non hanno alcun valore membro - solo i tipi di tipo *
sono abitati da valori.
Ora introduciamo un GADT che rappresenta i vettori con una lunghezza nota. Nota la firma del tipo: Vec
richiede un tipo di tipoNat
(ovvero un Z
o un S
tipo) per rappresentare la sua lunghezza.
data Vec :: Nat -> * -> * where
VNil :: Vec Z a
VCons :: a -> Vec n a -> Vec (S n) a
deriving instance (Show a) => Show (Vec n a)
deriving instance Functor (Vec n)
deriving instance Foldable (Vec n)
deriving instance Traversable (Vec n)
La definizione di vettori è simile a quella degli elenchi collegati, con alcune informazioni aggiuntive a livello di tipo sulla sua lunghezza. Un vettore è VNil
, nel qual caso ha una lunghezza di Z
(ero), oppure è una VCons
cella che aggiunge un elemento a un altro vettore, nel qual caso la sua lunghezza è uno in più rispetto all'altro vettore ( S n
). Si noti che non esiste alcun argomento di tipo costruttivo n
. Viene usato al momento della compilazione per tenere traccia delle lunghezze e verrà cancellato prima che il compilatore generi il codice macchina.
Abbiamo definito un tipo di vettore che porta con sé conoscenza statica della sua lunghezza. Esaminiamo il tipo di alcuni Vec
secondi per avere un'idea di come funzionano:
ghci> :t (VCons 'a' (VCons 'b' VNil))
(VCons 'a' (VCons 'b' VNil)) :: Vec ('S ('S 'Z)) Char -- (S (S Z)) means 2
ghci> :t (VCons 13 (VCons 11 (VCons 3 VNil)))
(VCons 13 (VCons 11 (VCons 3 VNil))) :: Num a => Vec ('S ('S ('S 'Z))) a -- (S (S (S Z))) means 3
Il prodotto punto procede esattamente come per un elenco:
-- note that the two Vec arguments are declared to have the same length
vap :: Vec n (a -> b) -> Vec n a -> Vec n b
vap VNil VNil = VNil
vap (VCons f fs) (VCons x xs) = VCons (f x) (vap fs xs)
zipWith :: (a -> b -> c) -> Vec n a -> Vec n b -> Vec n c
zipWith f xs ys = fmap f xs `vap` ys
dot :: Num a => Vec n a -> Vec n a -> a
dot xs ys = foldr (+) 0 $ zipWith (*) xs ys
vap
, che "zippily" applica un vettore di funzioni a un vettore di argomenti, è Vec
applicativo <*>
; Non l'ho messo in Applicative
un'istanza perché diventa disordinato . Nota anche che sto usando l' foldr
istanza generata dal compilatore di Foldable
.
Proviamolo:
ghci> let v1 = VCons 2 (VCons 1 VNil)
ghci> let v2 = VCons 4 (VCons 5 VNil)
ghci> v1 `dot` v2
13
ghci> let v3 = VCons 8 (VCons 6 (VCons 1 VNil))
ghci> v1 `dot` v3
<interactive>:20:10:
Couldn't match type ‘'S 'Z’ with ‘'Z’
Expected type: Vec ('S ('S 'Z)) a
Actual type: Vec ('S ('S ('S 'Z))) a
In the second argument of ‘dot’, namely ‘v3’
In the expression: v1 `dot` v3
Grande! Si ottiene un errore di compilazione quando si tenta di dot
vettori le cui lunghezze non corrispondono.
Ecco un tentativo di una funzione per concatenare i vettori insieme:
-- This won't compile because the type checker can't deduce the length of the returned vector
-- VNil +++ ys = ys
-- (VCons x xs) +++ ys = VCons x (concat xs ys)
La lunghezza del vettore di output sarebbe la somma delle lunghezze dei due vettori di input. Dobbiamo insegnare al controllo del tipo come aggiungere Nat
s insieme. Per questo usiamo una funzione a livello di tipo :
type family (n :: Nat) :+: (m :: Nat) :: Nat where
Z :+: m = m
(S n) :+: m = S (n :+: m)
Questa type family
dichiarazione introduce una funzione sui tipi chiamati :+:
- in altre parole, è una ricetta per il controllo del tipo per calcolare la somma di due numeri naturali. È definito in modo ricorsivo: ogni volta che l'operando di sinistra è maggiore di Z
ero, ne aggiungiamo uno all'output e lo riduciamo di uno nella chiamata ricorsiva. (È un buon esercizio scrivere una funzione di tipo che moltiplica due Nat
s.) Ora possiamo +++
compilare:
infixr 5 +++
(+++) :: Vec n a -> Vec m a -> Vec (n :+: m) a
VNil +++ ys = ys
(VCons x xs) +++ ys = VCons x (concat xs ys)
Ecco come lo usi:
ghci> VCons 1 (VCons 2 VNil) +++ VCons 3 (VCons 4 VNil)
VCons 1 (VCons 2 (VCons 3 (VCons 4 VNil)))
Fin qui tutto semplice. Che dire di quando vogliamo fare il contrario della concatenazione e dividere un vettore in due? Le lunghezze dei vettori di output dipendono dal valore di runtime degli argomenti. Vorremmo scrivere qualcosa del genere:
-- this won't work because there aren't any values of type `S` and `Z`
-- split :: (n :: Nat) -> Vec (n :+: m) a -> (Vec n a, Vec m a)
ma sfortunatamente Haskell non ci lascerà fare questo. Permettere il valore del n
ragionamento appaia nel tipo di ritorno (questo è comunemente chiamato una funzione di dipendente o di tipo PI ) richiederebbe "full-spectrum" tipi dipendenti, mentre DataKinds
solo ci dà promosso tipo costruttori. Per dirla in altro modo, digitare i costruttori S
e Z
non apparire a livello di valore. Dovremo accontentarci di valori singleton per una rappresentazione runtime di un determinato Nat
. *
data Natty (n :: Nat) where
Zy :: Natty Z -- pronounced 'zed-y'
Sy :: Natty n -> Natty (S n) -- pronounced 'ess-y'
deriving instance Show (Natty n)
Per un determinato tipo n
(con kind Nat
), esiste esattamente un termine di tipo Natty n
. Possiamo usare il valore singleton come testimone di runtime per n
: conoscere un a Natty
ci insegna sul suo n
e viceversa.
split :: Natty n ->
Vec (n :+: m) a -> -- the input Vec has to be at least as long as the input Natty
(Vec n a, Vec m a)
split Zy xs = (Nil, xs)
split (Sy n) (Cons x xs) = let (ys, zs) = split n xs
in (Cons x ys, zs)
Facciamo un giro:
ghci> split (Sy (Sy Zy)) (VCons 1 (VCons 2 (VCons 3 VNil)))
(VCons 1 (VCons 2 VNil), VCons 3 VNil)
ghci> split (Sy (Sy Zy)) (VCons 3 VNil)
<interactive>:116:21:
Couldn't match type ‘'S ('Z :+: m)’ with ‘'Z’
Expected type: Vec ('S ('S 'Z) :+: m) a
Actual type: Vec ('S 'Z) a
Relevant bindings include
it :: (Vec ('S ('S 'Z)) a, Vec m a) (bound at <interactive>:116:1)
In the second argument of ‘split’, namely ‘(VCons 3 VNil)’
In the expression: split (Sy (Sy Zy)) (VCons 3 VNil)
Nel primo esempio, abbiamo diviso con successo un vettore a tre elementi in posizione 2; quindi abbiamo riscontrato un errore di tipo quando abbiamo provato a dividere un vettore in una posizione oltre la fine. I singleton sono la tecnica standard per far dipendere un tipo da un valore in Haskell.
* La singletons
libreria contiene alcuni helper Template Haskell per generare valori singleton come Natty
per te.
Ultimo esempio. Che dire quando non conosci staticamente la dimensionalità del tuo vettore? Ad esempio, cosa succede se stiamo cercando di creare un vettore dai dati di runtime sotto forma di un elenco? È necessario che il tipo di vettore dipenda dalla lunghezza dell'elenco di input. Per dirla in un altro modo, non possiamo usare foldr VCons VNil
per costruire un vettore perché il tipo del vettore di output cambia con ogni iterazione della piega. Dobbiamo mantenere segreta la lunghezza del vettore dal compilatore.
data AVec a = forall n. AVec (Natty n) (Vec n a)
deriving instance (Show a) => Show (AVec a)
fromList :: [a] -> AVec a
fromList = Prelude.foldr cons nil
where cons x (AVec n xs) = AVec (Sy n) (VCons x xs)
nil = AVec Zy VNil
AVec
è un tipo esistenziale : la variabile type n
non appare nel tipo restituito del AVec
costruttore di dati. Lo stiamo usando per simulare una coppia dipendente : fromList
non possiamo dirti staticamente la lunghezza del vettore, ma può restituire qualcosa su cui puoi abbinare il modello per imparare la lunghezza del vettore - il Natty n
nel primo elemento della tupla . Come dice Conor McBride in una risposta correlata , "Guardi una cosa e, nel farlo, ne impari un'altra".
Questa è una tecnica comune per tipi quantificati esistenzialmente. Poiché in realtà non puoi fare nulla con i dati per i quali non conosci il tipo - prova a scrivere una funzione di data Something = forall a. Sth a
- gli esistenziali spesso vengono raggruppati con prove GADT che ti consentono di recuperare il tipo originale eseguendo test di corrispondenza dei modelli. Altri schemi comuni per esistenziali includono il confezionamento di funzioni per elaborare il tuo tipo ( data AWayToGetTo b = forall a. HeresHow a (a -> b)
) che è un modo pulito di fare moduli di prima classe, o costruire un dizionario di classe di tipo ( data AnOrd = forall a. Ord a => AnOrd a
) che può aiutare ad emulare il polimorfismo dei sottotipi.
ghci> fromList [1,2,3]
AVec (Sy (Sy (Sy Zy))) (VCons 1 (VCons 2 (VCons 3 Nil)))
Le coppie dipendenti sono utili ogni volta che le proprietà statiche dei dati dipendono da informazioni dinamiche non disponibili al momento della compilazione. Ecco filter
per i vettori:
filter :: (a -> Bool) -> Vec n a -> AVec a
filter f = foldr (\x (AVec n xs) -> if f x
then AVec (Sy n) (VCons x xs)
else AVec n xs) (AVec Zy VNil)
Per dot
due AVec
secondi, dobbiamo dimostrare a GHC che le loro lunghezze sono uguali. Data.Type.Equality
definisce un GADT che può essere costruito solo quando i suoi argomenti di tipo sono gli stessi:
data (a :: k) :~: (b :: k) where
Refl :: a :~: a -- short for 'reflexivity'
Quando lo schema corrisponde Refl
, GHC lo sa a ~ b
. Ci sono anche alcune funzioni che ti aiutano a lavorare con questo tipo: useremo gcastWith
per convertire tra tipi equivalenti e TestEquality
per determinare se due Natty
s sono uguali.
Per verificare l'uguaglianza di due Natty
s, stiamo andando a necessità di fare uso di fatto che se due numeri sono uguali, poi i loro successori sono uguali ( :~:
è congruente sopra S
):
congSuc :: (n :~: m) -> (S n :~: S m)
congSuc Refl = Refl
La corrispondenza dei motivi sul Refl
lato sinistro consente a GHC di saperlo n ~ m
. Con questa conoscenza, è banale S n ~ S m
, quindi GHC ci consente di restituire subito una nuova Refl
.
Ora possiamo scrivere un'istanza di TestEquality
semplice ricorsione. Se entrambi i numeri sono zero, sono uguali. Se entrambi i numeri hanno predecessori, sono uguali se i predecessori sono uguali. (Se non sono uguali, basta tornare Nothing
.)
instance TestEquality Natty where
-- testEquality :: Natty n -> Natty m -> Maybe (n :~: m)
testEquality Zy Zy = Just Refl
testEquality (Sy n) (Sy m) = fmap congSuc (testEquality n m) -- check whether the predecessors are equal, then make use of congruence
testEquality Zy _ = Nothing
testEquality _ Zy = Nothing
Ora possiamo unire i pezzi a dot
una coppia di AVec
s di lunghezza sconosciuta.
dot' :: Num a => AVec a -> AVec a -> Maybe a
dot' (AVec n u) (AVec m v) = fmap (\proof -> gcastWith proof (dot u v)) (testEquality n m)
Innanzitutto, corrispondere al modello sul AVec
costruttore per estrarre una rappresentazione runtime delle lunghezze dei vettori. Ora usa testEquality
per determinare se quelle lunghezze sono uguali. Se lo sono, avremo Just Refl
; gcastWith
utilizzerà tale prova di uguaglianza per garantire che dot u v
sia ben tipizzata scaricando la sua n ~ m
assunzione implicita .
ghci> let v1 = fromList [1,2,3]
ghci> let v2 = fromList [4,5,6]
ghci> let v3 = fromList [7,8]
ghci> dot' v1 v2
Just 32
ghci> dot' v1 v3
Nothing -- they weren't the same length
Si noti che, poiché un vettore senza conoscenza statica della sua lunghezza è fondamentalmente un elenco, abbiamo effettivamente implementato nuovamente la versione dell'elenco di dot :: Num a => [a] -> [a] -> Maybe a
. La differenza è che questa versione è implementata in termini di vettori dot
. Ecco il punto: prima che il controllo del tipo ti consenta di chiamare dot
, devi aver verificato se gli elenchi di input hanno la stessa lunghezza usando testEquality
. Sono incline a ottenere if
dichiarazioni nel modo sbagliato, ma non in un ambiente tipicamente dipendente!
Non puoi evitare di usare wrapper esistenziali ai bordi del tuo sistema, quando hai a che fare con dati di runtime, ma puoi usare tipi dipendenti ovunque nel tuo sistema e mantenere i wrapper esistenziali ai bordi, quando esegui la validazione dell'input.
Poiché Nothing
non è molto informativo, è possibile perfezionare ulteriormente il tipo di dot'
restituire una prova che le lunghezze non sono uguali (sotto forma di prova che la loro differenza non è 0) nel caso del fallimento. Questo è abbastanza simile alla tecnica standard di Haskell che utilizza Either String a
per restituire eventualmente un messaggio di errore, sebbene un termine di prova sia molto più computazionalmente utile di una stringa!
Termina così questo tour di whistle-stop di alcune delle tecniche che sono comuni nella programmazione di Haskell tipicamente dipendente. Programmare con tipi come questo in Haskell è davvero bello, ma allo stesso tempo molto imbarazzante. Suddividere tutti i tuoi dati dipendenti in molte rappresentazioni che significano la stessa cosa - Nat
il tipo, Nat
il tipo, Natty n
il singleton - è davvero piuttosto ingombrante, nonostante l'esistenza di generatori di codice per aiutare con il boilerplate. Attualmente ci sono anche limitazioni su ciò che può essere promosso a livello di tipo. È allettante però! La mente confonde sulle possibilità: in letteratura ci sono esempi in Haskell di printf
interfacce di database fortemente tipizzate , motori di layout dell'interfaccia utente ...
Se vuoi ulteriori letture, c'è un corpus crescente di letteratura su Haskell tipicamente dipendente, sia pubblicato che su siti come Stack Overflow. Un buon punto di partenza è il documento di Hasochism - il documento passa attraverso questo esempio (tra gli altri), discutendo le parti dolorose in alcuni dettagli. Il documento Singletons dimostra la tecnica dei valori singleton (come Natty
). Per ulteriori informazioni sulla tipizzazione dipendente in generale, il tutorial di Agda è un buon punto di partenza; inoltre, Idris è un linguaggio in fase di sviluppo (approssimativamente) progettato per essere "Haskell con tipi dipendenti".