Est-il possible de "cuire la dimension dans un type" dans haskell?

20

Supposons que je veuille écrire une bibliothèque qui traite des vecteurs et des matrices. Est-il possible de faire cuire les dimensions dans les types, de sorte que les opérations de dimensions incompatibles génèrent une erreur au moment de la compilation?

Par exemple, je voudrais que la signature du produit scalaire soit quelque chose comme

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

où le dtype contient une seule valeur entière (représentant la dimension de ces vecteurs).

Je suppose que cela pourrait être fait en définissant (à la main) un type distinct pour chaque entier et en les regroupant dans une classe de type appelée VecDim. Existe-t-il un mécanisme pour «générer» de tels types?

Ou peut-être un moyen meilleur / plus simple de réaliser la même chose?

mitchus
la source
3
Oui, si je me souviens bien, il existe des bibliothèques pour fournir ce niveau de base de frappe dépendante dans Haskell. Je ne suis pas assez familier pour fournir une bonne réponse.
Telastyn
En regardant autour, il semble que la tensorbibliothèque y parvienne avec élégance en utilisant une datadéfinition récursive : noaxiom.org/tensor-documentation#ordinals
mitchus
Il s'agit de scala, pas de haskell, mais il contient certains concepts liés à l'utilisation de types dépendants pour empêcher les dimensions et les «types» de vecteurs non concordants. chrisstucchio.com/blog/2014/…
Daenyth

Réponses:

32

Pour développer la réponse de @ KarlBielefeldt, voici un exemple complet de la façon d'implémenter des vecteurs - des listes avec un nombre d'éléments statiquement connus - dans Haskell. Tenez votre chapeau ...

{-# 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

Comme vous pouvez le voir dans la longue liste de LANGUAGEdirectives, cela ne fonctionnera qu'avec une version récente de GHC.

Nous avons besoin d'un moyen de représenter les longueurs dans le système de types. Par définition, un nombre naturel est soit zéro ( Z), soit le successeur d'un autre nombre naturel ( S n). Ainsi, par exemple, le numéro 3 serait écrit S (S (S Z)).

data Nat = Z | S Nat

Avec l' extension DataKinds , cette datadéclaration introduit un type appelé Natet deux constructeurs de type appelés Set Z- en d'autres termes, nous avons des nombres naturels au niveau du type . Notez que les types Set Zn'ont pas de valeurs de membre - seuls les types de kind *sont habités par des valeurs.

Nous présentons maintenant un GADT représentant des vecteurs de longueur connue. Notez la signature type: Vecnécessite un type de typeNat (c'est-à-dire un Zou un Stype) pour représenter sa longueur.

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 définition des vecteurs est similaire à celle des listes chaînées, avec quelques informations supplémentaires au niveau du type sur sa longueur. Un vecteur est soit VNil, auquel cas il a une longueur de Z(ero), soit c'est une VConscellule qui ajoute un élément à un autre vecteur, auquel cas sa longueur est un de plus que l'autre vecteur ( S n). Notez qu'il n'y a pas d'argument constructeur de type n. Il est juste utilisé au moment de la compilation pour suivre les longueurs et sera effacé avant que le compilateur ne génère le code machine.

Nous avons défini un type de vecteur qui porte la connaissance statique de sa longueur. Examinons le type de quelques Vecs pour avoir une idée de leur fonctionnement:

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

Le produit scalaire procède comme pour une liste:

-- 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, qui applique «zippement» un vecteur de fonctions à un vecteur d 'arguments, est son Vecapplicatif <*>; Je ne l'ai pas mis dans un Applicativeexemple parce que ça devient salissant . Notez également que j'utilise le à foldrpartir de l'instance générée par le compilateur de Foldable.

Essayons-le:

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

Génial! Vous obtenez une erreur de compilation lorsque vous essayez de dotvecteurs dont les longueurs ne correspondent pas.


Voici une tentative de fonction pour concaténer des vecteurs ensemble:

-- 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 longueur du vecteur de sortie serait la somme des longueurs des deux vecteurs d'entrée. Nous devons apprendre au vérificateur de type comment additionner Nats ensemble. Pour cela, nous utilisons une fonction de niveau type :

type family (n :: Nat) :+: (m :: Nat) :: Nat where
    Z :+: m = m
    (S n) :+: m = S (n :+: m)

Cette type familydéclaration introduit une fonction sur les types appelés :+:- en d'autres termes, c'est une recette pour le vérificateur de type pour calculer la somme de deux nombres naturels. Il est défini de manière récursive - chaque fois que l'opérande gauche est supérieur à Zero, nous en ajoutons un à la sortie et le réduisons de un dans l'appel récursif. (C'est un bon exercice pour écrire une fonction de type qui multiplie deux Nats.) Maintenant, nous pouvons faire +++compiler:

infixr 5 +++
(+++) :: Vec n a -> Vec m a -> Vec (n :+: m) a
VNil +++ ys = ys
(VCons x xs) +++ ys = VCons x (concat xs ys)

Voici comment vous l'utilisez:

ghci> VCons 1 (VCons 2 VNil) +++ VCons 3 (VCons 4 VNil)
VCons 1 (VCons 2 (VCons 3 (VCons 4 VNil)))

Jusqu'ici tout simple. Qu'en est-il lorsque nous voulons faire le contraire de la concaténation et diviser un vecteur en deux? Les longueurs des vecteurs de sortie dépendent de la valeur d'exécution des arguments. Nous aimerions écrire quelque chose comme ceci:

-- 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)

mais malheureusement Haskell ne nous laisse pas faire ça. Permettre à la valeur de l' nargument d'apparaître dans le type de retour (c'est ce qu'on appelle communément une fonction dépendante ou type pi ) nécessiterait des types dépendants "à spectre complet", alors que cela DataKindsne nous donne que des constructeurs de type promus. Pour le dire autrement, les constructeurs de types Set Zn'apparaissent pas au niveau de la valeur. Nous devrons nous contenter des valeurs singleton pour une représentation d'exécution d'un certain 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)

Pour un type donné n(avec kind Nat), il existe précisément un terme de type Natty n. Nous pouvons utiliser la valeur singleton comme témoin d'exécution pour n: l'apprentissage d'un Nattynous apprend son net vice versa.

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)

Prenons-le pour un tour:

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)

Dans le premier exemple, nous avons réussi à diviser un vecteur à trois éléments à la position 2; puis nous avons eu une erreur de type lorsque nous avons essayé de diviser un vecteur à une position après la fin. Les singletons sont la technique standard pour faire dépendre un type d'une valeur en Haskell.

* La singletonsbibliothèque contient des assistants Template Haskell pour générer des valeurs singleton comme Nattypour vous.


Dernier exemple. Qu'en est-il lorsque vous ne connaissez pas statiquement la dimensionnalité de votre vecteur? Par exemple, que se passe-t-il si nous essayons de créer un vecteur à partir de données d'exécution sous la forme d'une liste? Vous avez besoin que le type du vecteur dépende de la longueur de la liste d'entrée. En d'autres termes, nous ne pouvons pas utiliser foldr VCons VNilpour construire un vecteur car le type du vecteur de sortie change à chaque itération du pli. Nous devons garder la longueur du vecteur secrète du compilateur.

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

AVecest un type existentiel : la variable type nn'apparaît pas dans le type de retour du AVecconstructeur de données. Nous l'utilisons pour simuler une paire dépendante : fromListne peut pas vous dire statiquement la longueur du vecteur, mais il peut renvoyer quelque chose sur lequel vous pouvez faire correspondre le motif pour apprendre la longueur du vecteur - le Natty ndans le premier élément du tuple . Comme Conor McBride le dit dans une réponse connexe : «Vous regardez une chose et, ce faisant, vous en apprenez une autre».

Il s'agit d'une technique courante pour les types quantifiés existentiellement. Parce que vous ne pouvez rien faire avec des données dont vous ne connaissez pas le type - essayez d'écrire une fonction de data Something = forall a. Sth a- les existentielles sont souvent fournies avec des preuves GADT qui vous permettent de récupérer le type d'origine en effectuant des tests de correspondance de modèles. D'autres modèles courants d'existentiels incluent l'empaquetage de fonctions pour traiter votre type ( data AWayToGetTo b = forall a. HeresHow a (a -> b)), ce qui est une bonne façon de faire des modules de première classe, ou la création d'un dictionnaire de classes de type ( data AnOrd = forall a. Ord a => AnOrd a) qui peut aider à émuler le polymorphisme de sous-type.

ghci> fromList [1,2,3]
AVec (Sy (Sy (Sy Zy))) (VCons 1 (VCons 2 (VCons 3 Nil)))

Les paires dépendantes sont utiles lorsque les propriétés statiques des données dépendent d'informations dynamiques non disponibles au moment de la compilation. Voici filterpour les vecteurs:

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) 

A dotdeux AVecs, il faut prouver au GHC que leurs longueurs sont égales. Data.Type.Equalitydéfinit un GADT qui ne peut être construit que lorsque ses arguments de type sont identiques:

data (a :: k) :~: (b :: k) where
    Refl :: a :~: a  -- short for 'reflexivity'

Lorsque vous faites correspondre des motifs Refl, GHC le sait a ~ b. Il existe également quelques fonctions pour vous aider à travailler avec ce type: nous utiliserons gcastWithpour convertir entre des types équivalents et TestEqualitypour déterminer si deux Nattys sont égaux.

Pour tester l'égalité de deux Nattys, nous allons devoir utiliser le fait que si deux nombres sont égaux, alors leurs successeurs sont également égaux ( :~:est congruent sur S):

congSuc :: (n :~: m) -> (S n :~: S m)
congSuc Refl = Refl

La correspondance des motifs sur Reflle côté gauche permet au GHC de le savoir n ~ m. Avec cette connaissance, c'est trivial S n ~ S m, alors GHC nous permet de retourner un nouveau Refltout de suite.

Maintenant, nous pouvons écrire une instance de TestEqualitypar récursivité simple. Si les deux nombres sont nuls, ils sont égaux. Si les deux nombres ont des prédécesseurs, ils sont égaux si les prédécesseurs sont égaux. (S'ils ne sont pas égaux, revenez simplement 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

Maintenant, nous pouvons assembler les pièces en dotune paire de AVecs de longueur inconnue.

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)

Tout d'abord, le modèle correspond au AVecconstructeur pour extraire une représentation d'exécution des longueurs des vecteurs. Utilisez maintenant testEqualitypour déterminer si ces longueurs sont égales. S'ils le sont, nous aurons Just Refl; gcastWithutilisera cette preuve d'égalité pour s'assurer qu'elle dot u vest bien typée en libérant son n ~ mhypothèse implicite .

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

Notez que, comme un vecteur sans connaissance statique de sa longueur est essentiellement une liste, nous avons effectivement ré-implémenté la version de liste de dot :: Num a => [a] -> [a] -> Maybe a. La différence est que cette version est implémentée en termes de vecteurs » dot. Voici le point: avant que le vérificateur de type ne vous permette d'appeler dot, vous devez avoir testé si les listes d'entrée ont la même longueur en utilisant testEquality. Je suis enclin à obtenir des ifdéclarations dans le mauvais sens, mais pas dans un cadre typé de manière dépendante!

Vous ne pouvez pas éviter d'utiliser des wrappers existentiels sur les bords de votre système, lorsque vous traitez avec des données d'exécution, mais vous pouvez utiliser des types dépendants partout dans votre système et conserver les wrappers existentiels sur les bords, lorsque vous effectuez une validation d'entrée.

Étant donné que ce Nothingn'est pas très informatif, vous pouvez affiner davantage le type de dot'pour renvoyer une preuve que les longueurs ne sont pas égales (sous la forme de preuves que leur différence n'est pas 0) dans le cas d'échec. Ceci est assez similaire à la technique standard utilisée par Haskell Either String apour renvoyer éventuellement un message d'erreur, bien qu'un terme de preuve soit beaucoup plus utile en termes de calcul qu'une chaîne!


Ainsi se termine ce tour de passe-passe de certaines des techniques qui sont courantes dans la programmation Haskell typée de façon dépendante. La programmation avec des types comme celui-ci dans Haskell est vraiment cool, mais vraiment maladroite en même temps. Décomposer toutes vos données dépendantes en de nombreuses représentations qui signifient la même chose - Natle type, Natle type, Natty nle singleton - est vraiment assez lourd, malgré l'existence de générateurs de code pour aider avec le passe-partout. Il existe également actuellement des limitations sur ce qui peut être promu au niveau du type. C'est pourtant alléchant! L'esprit s'embarrasse des possibilités - dans la littérature, il existe des exemples dans Haskell d' printfinterfaces de base de données fortement typées , de moteurs de mise en page de l'interface utilisateur ...

Si vous souhaitez en savoir plus, il existe de plus en plus de documentation sur Haskell typé de manière dépendante, à la fois publié et sur des sites comme Stack Overflow. Un bon point de départ est le Hasochism papier - le papier passe par ce même exemple (entre autres), discuter des parties douloureuses en détail. Le document Singletons démontre la technique des valeurs singleton (telles que Natty). Pour plus d'informations sur la saisie dépendante en général, le didacticiel Agda est un bon point de départ; aussi, Idris est un langage en développement qui est (à peu près) conçu pour être "Haskell avec des types dépendants".

Benjamin Hodgson
la source
@Benjamin FYI, le lien Idris à la fin semble rompu.
Erik Eidt du
@ErikEidt oups, merci de l'avoir signalé! Je vais le mettre à jour.
Benjamin Hodgson
14

C'est ce qu'on appelle la saisie dépendante . Une fois que vous connaissez le nom, vous pouvez trouver plus d'informations à ce sujet que vous ne pourriez jamais souhaiter. Il y a aussi un langage intéressant de type haskell appelé Idris qui les utilise nativement. Son auteur a fait quelques très bonnes présentations sur le sujet que vous pouvez trouver sur YouTube.

Karl Bielefeldt
la source
Ce n'est pas du tout une saisie dépendante. La saisie dépendante parle des types lors de l'exécution, mais la cuisson de la dimensionnalité dans le type peut facilement être effectuée au moment de la compilation.
DeadMG
4
@DeadMG Au contraire, la saisie dépendante parle de valeurs au moment de la compilation . Les types à l' exécution sont une réflexion et non une saisie dépendante. Comme vous pouvez le voir dans ma réponse, la cuisson de la dimensionnalité dans le type est loin d'être facile pour une dimension générale. (Vous pouvez définir newtype Vec2 a = V2 (a,a), newtype Vec3 a = V3 (a,a,a)et ainsi de suite, mais ce n'est pas ce que la question pose.)
Benjamin Hodgson
Eh bien, les valeurs n'apparaissent qu'au moment de l'exécution, vous ne pouvez donc pas vraiment parler de valeurs au moment de la compilation, sauf si vous voulez résoudre le problème d'arrêt. Tout ce que je dis, c'est que même en C ++, vous pouvez simplement modèle sur la dimensionnalité et cela fonctionne très bien. Cela n'a-t-il pas d'équivalent à Haskell?
DeadMG
4
@DeadMG Les langages typés dépendants "à spectre complet" (comme Agda) autorisent en fait des calculs arbitraires au niveau des termes dans le langage de typage. Comme vous le faites remarquer, cela vous expose au risque de tenter de résoudre le problème de l'arrêt. Les systèmes typés les plus dépendants, afaik, tentent de résoudre ce problème en n'étant pas Turing complet . Je ne suis pas un gars en C ++ mais cela ne m'étonne pas que vous puissiez simuler des types dépendants à l'aide de modèles; les modèles peuvent être utilisés de toutes sortes de façons créatives.
Benjamin Hodgson
4
@BenjaminHodgson Vous ne pouvez pas faire de types dépendants avec des modèles car vous ne pouvez pas simuler un type pi. Le type dépendant « canonique » doit te réclamer besoin est -ce Pi (x : A). Bqui est une fonction de Aà B xxest l'argument de la fonction. Ici, le type de retour de la fonction dépend de l' expression fournie en argument. Cependant, tout cela peut être effacé, c'est seulement le temps de compilation
Daniel Gratzer