È possibile "inserire la dimensione in un tipo" in haskell?


20

Supponiamo che io voglia scrivere una biblioteca che si occupa di vettori e matrici. È possibile inserire le dimensioni nei tipi, in modo che le operazioni di dimensioni incompatibili generino un errore al momento della compilazione?

Ad esempio, vorrei che la firma del prodotto dot fosse qualcosa di simile

dotprod :: Num a, VecDim d => Vector a d -> Vector a d -> a

dove il dtipo contiene un singolo valore intero (che rappresenta la dimensione di questi vettori).

Suppongo che ciò possa essere fatto definendo (a mano) un tipo separato per ogni numero intero e raggruppandoli in una classe di tipo chiamata VecDim. Esiste un meccanismo per "generare" tali tipi?

O forse un modo migliore / più semplice per ottenere la stessa cosa?


3
Sì, se ricordo bene, ci sono librerie per fornire questo livello base di digitazione dipendente in Haskell. Non ho abbastanza familiarità per fornire una buona risposta.
Telastyn,

Guardandosi intorno, sembra che la tensorbiblioteca stia ottenendo questo risultato abbastanza elegantemente usando una datadefinizione ricorsiva : noaxiom.org/tensor-documentation#ordinals
mitchus

Questo è scala, non haskell, ma ha alcuni concetti correlati sull'uso di tipi dipendenti per prevenire dimensioni non corrispondenti e "tipi" di vettori non corrispondenti. chrisstucchio.com/blog/2014/…
Daenyth,

Risposte:


32

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 LANGUAGEdirettive, 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 datadichiarazione introduce un tipo chiamato Nate due costruttori di tipo chiamati Se Z- in altre parole abbiamo numeri naturali a livello di tipo . Nota che i tipi Se Znon 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: Vecrichiede un tipo di tipoNat (ovvero un Zo un Stipo) 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 VConscella 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 Vecsecondi 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, è Vecapplicativo <*>; Non l'ho messo in Applicativeun'istanza perché diventa disordinato . Nota anche che sto usando l' foldristanza 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 dotvettori 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 Nats 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 familydichiarazione 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 Zero, ne aggiungiamo uno all'output e lo riduciamo di uno nella chiamata ricorsiva. (È un buon esercizio scrivere una funzione di tipo che moltiplica due Nats.) 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 nragionamento appaia nel tipo di ritorno (questo è comunemente chiamato una funzione di dipendente o di tipo PI ) richiederebbe "full-spectrum" tipi dipendenti, mentre DataKindssolo ci dà promosso tipo costruttori. Per dirla in altro modo, digitare i costruttori Se Znon 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 Nattyci insegna sul suo ne 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 singletonslibreria contiene alcuni helper Template Haskell per generare valori singleton come Nattyper 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 VNilper 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 nnon appare nel tipo restituito del AVeccostruttore di dati. Lo stiamo usando per simulare una coppia dipendente : fromListnon 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 nnel 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 filterper 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 dotdue AVecsecondi, dobbiamo dimostrare a GHC che le loro lunghezze sono uguali. Data.Type.Equalitydefinisce 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 gcastWithper convertire tra tipi equivalenti e TestEqualityper determinare se due Nattys sono uguali.

Per verificare l'uguaglianza di due Nattys, 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 Refllato 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 TestEqualitysemplice 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 dotuna coppia di AVecs 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 AVeccostruttore per estrarre una rappresentazione runtime delle lunghezze dei vettori. Ora usa testEqualityper determinare se quelle lunghezze sono uguali. Se lo sono, avremo Just Refl; gcastWithutilizzerà tale prova di uguaglianza per garantire che dot u vsia ben tipizzata scaricando la sua n ~ massunzione 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 ifdichiarazioni 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é Nothingnon è 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 aper 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 - Natil tipo, Natil tipo, Natty nil 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 printfinterfacce 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".


@Benjamin FYI, il link Idris alla fine sembra essere rotto.
Erik Eidt,

@ErikEidt oops, grazie per averlo sottolineato! Lo aggiornerò.
Benjamin Hodgson,

14

Si chiama tipizzazione dipendente . Una volta che conosci il nome, puoi trovare più informazioni di quanto tu possa mai desiderare di desiderare. C'è anche un interessante linguaggio simile a un haskell chiamato Idris che li usa in modo nativo. Il suo autore ha fatto alcune ottime presentazioni sull'argomento che puoi trovare su YouTube.


Non è affatto una digitazione dipendente. La digitazione dipendente parla dei tipi in fase di esecuzione, ma è possibile eseguire facilmente l'inserimento dimensionale nel tipo in fase di compilazione.
DeadMG

4
@DeadMG Al contrario, la digitazione dipendente parla di valori in fase di compilazione . I tipi in fase di esecuzione sono riflessione, non tipizzazione dipendente. Come puoi vedere dalla mia risposta, inserire la dimensionalità nel tipo è tutt'altro che facile per una dimensione generale. (Potresti definire newtype Vec2 a = V2 (a,a), newtype Vec3 a = V3 (a,a,a)e così via, ma non è quello che si pone la domanda.)
Benjamin Hodgson,

Bene, i valori appaiono solo in fase di esecuzione, quindi non puoi davvero parlare di valori in fase di compilazione a meno che tu non voglia risolvere il problema Halting. Tutto quello che sto dicendo è che anche in C ++ puoi semplicemente creare un template sulla dimensionalità e funziona bene. Non ha un equivalente in Haskell?
DeadMG

4
@DeadMG I linguaggi tipicamente dipendenti a spettro completo (come Agda) consentono infatti calcoli arbitrari a livello di termine nella lingua del tipo. Come fai notare, questo ti mette a rischio di tentare di risolvere il problema di interruzione. I sistemi tipicamente più dipendenti, afaik, puntano su questo problema non essendo Turing completo . Non sono un tipo C ++ ma non mi sorprende che tu possa simulare tipi dipendenti usando template; i modelli possono essere abusati in tutti i modi creativi.
Benjamin Hodgson,

4
@BenjaminHodgson Non è possibile eseguire tipi dipendenti con modelli perché non è possibile simulare un tipo pi. Il tipo di dipendente "canonica" deve sarebbe rivendicare ciò che serve è Pi (x : A). Bche è una funzione da Aa B xdove xè l'argomento della funzione. Qui il tipo restituito della funzione dipende dall'espressione fornita come argomento. Tuttavia, tutto questo può essere cancellato, è solo tempo di compilazione
Daniel Gratzer,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.