是否可以在Haskell中“将维度转换为类型”?


20

假设我想编写一个处理向量和矩阵的库。是否可以将维度烘焙为类型,以使不兼容的维度的操作在编译时产生错误?

例如,我希望点积的签名类似于

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

其中d类型包含单个整数值(代表这些Vector的维)。

我想可以通过为每个整数定义一个单独的类型并将其分组为一个名为的类型类来实现VecDim。是否有某种机制可以“生成”此类类型?

还是一些更好/更简单的方法来实现同一目标?


3
是的,如果我没记错的话,Haskell中有一些库可以提供这种基本的依赖类型输入。虽然我不太熟悉,但是不能提供一个很好的答案。
Telastyn

环顾四周,似乎tensor图书馆使用递归data定义非常优雅地实现了这一目标: noaxiom.org/tensor-documentation#ordinals
mitchus 2015年

这是scala,不是haskell,但是它具有一些有关使用依赖类型来防止维数不匹配以及向量的“类型”不匹配的概念。chrisstucchio.com/blog/2014/...
Daenyth

Answers:


32

为了扩大对@ KarlBielefeldt的答案,这里是如何实现一个完整的例子载体 -列出了静态已知数量的元素-在Haskell。戴上帽子...

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

从一长串LANGUAGE指令中可以看出,这仅适用于GHC的最新版本。

我们需要一种表示类型系统中长度的方法。根据定义,自然数可以为零(Z)或其他自然数(S n)的后继。因此,例如,将写入数字3 S (S (S Z))

data Nat = Z | S Nat

随着DataKinds扩展,这个data声明引入了一种Nat和两个类型的构造方法调用,S以及Z-换句话说,我们有型级别的自然数。请注意,类型SZ没有任何成员值-值仅占用类型的类型*

现在,我们介​​绍一种表示已知长度向量的GADT。注意种类签名:Vec需要种类的类型Nat(即a ZS类型)来表示其长度。

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)

向量的定义与链表的定义类似,但还有一些有关其长度的类型附加信息。向量是VNil,在这种情况下,其长度为Z(ero),或者是一个VCons单元格向另一个向量添加项,在这种情况下,其长度比另一个向量(S n)大。请注意,没有类型的构造函数参数n。它仅在编译时用于跟踪长度,在编译器生成机器代码之前将被擦除。

我们定义了一个向量类型,它携带有关其长度的静态知识。我们来查询一下Vecs 的类型,以了解它们的工作方式:

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

点积与列表一样进行:

-- 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,这“zippily”的功能的载体适用于参数矢量,是Vec的应用性<*>; 我没有把它放在一个Applicative实例中,因为它变得凌乱。另请注意,我使用的foldr是编译器生成的实例中的Foldable

让我们尝试一下:

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

大!尝试对dot长度不匹配的向量进行编译时错误。


这是尝试将向量连接在一起的函数:

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

输出向量的长度将是总和的两个输入向量的长度。我们需要教类型检查器如何将Nats 加在一起。为此,我们使用类型级别的函数

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

type family声明引入了一个称为类型函数:+: -换句话说,这是类型检查器计算两个自然数之和的诀窍。它是递归定义的-每当左操作数大于Zero时,我们在输出中加1并在递归调用中将其减少1。(写一个乘以2 Nat的类型函数是一个好习惯。)现在我们可以进行+++编译了:

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

使用方法如下:

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

到目前为止很简单。当我们想进行串联的相反操作并将向量分成两部分时,该怎么办?输出向量的长度取决于参数的运行时值。我们想写这样的东西:

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

但不幸的是,Haskell不允许我们这样做。允许的的n参数出现在返回类型(这通常被称为相依函数PI型)需要“全谱”相关的类型,而DataKinds只给我们升级后的类型构造函数。换句话说,类型构造函数SZ不会出现在值级别。我们必须为某个。* 的运行时表示形式确定单例值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)

对于给定的类型n(具有kind Nat),恰好有一个type项Natty n。我们可以将singleton值用作运行时的见证人n:了解某门知识可以Natty教会我们有关它的信息n,反之亦然。

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)

让我们旋转一下:

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)

在第一个示例中,我们成功地在位置2分割了一个三元素向量;然后,当我们尝试在结束点之后的位置拆分向量时,出现类型错误。单例是使类型取决于Haskell中的值的标准技术。

*该singletons包含一些模板Haskell助手,可以Natty为您生成单例值。


最后一个例子。当您不知道向量的维数时该怎么办?例如,如果我们试图从运行时数据以列表的形式构建向量,该怎么办?您需要向量的类型取决于输入列表的长度。换句话说,我们不能使用foldr VCons VNil构建向量,因为输出向量的类型随折痕的每次迭代而变化。我们需要使向量的长度对编译器保密。

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存在的类型:类型变量n不会出现在AVec数据构造函数的返回类型中。我们正在使用它来模拟一个依赖对fromList不能静态地告诉您向量的长度,但是它可以返回一些您可以进行模式匹配以了解向量的长度的信息- Natty n在元组的第一个元素中。正如Conor McBride在一个相关的答案中所说的那样:“您看一眼,然后了解另一件事”。

这是存在量化类型的常用技术。由于您实际上无法使用不知道其类型的数据执行任何操作-尝试编写函数data Something = forall a. Sth a-存在物通常与GADT证据捆绑在一起,从而可以通过执行模式匹配测试来恢复原始类型。存在的其他常见模式包括打包函数以处理您的类型(data AWayToGetTo b = forall a. HeresHow a (a -> b)),这是制作一流模块的一种好方法,或者内置类型类字典(data AnOrd = forall a. Ord a => AnOrd a),可以帮助模拟子类型多态性。

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

只要数据的静态属性依赖于编译时不可用的动态信息,相关对就很有用。这里是filter矢量:

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) 

对于dot2 AVecs,我们需要向GHC证明它们的长度相等。Data.Type.Equality定义了仅在其类型参数相同时才能构造的GADT:

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

当您进行模式匹配时Refl,GHC会知道a ~ b。还有一些函数可以帮助您使用此类型:我们将gcastWith用于在等效类型之间进行转换,并TestEquality确定两个Nattys是否相等。

为了测试两个平等NattyS,我们将需要利用的事实,如果两个数是相等的,那么他们的继任者也是相等的(:~:全等S):

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

Refl左侧的模式匹配使GHC知道n ~ m。有了这些知识,这是微不足道的S n ~ S m,因此GHC让我们立即返回新的Refl

现在我们可以TestEquality通过直接递归来编写一个实例。如果两个数字均为零,则它们相等。如果两个数字都具有前任,则当前任相等时它们相等。(如果它们不相等,请返回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

现在我们可以将片段拼成dot一对AVec长度未知的。

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)

首先,在AVec构造函数上进行模式匹配,以提取向量长度的运行时表示形式。现在用于testEquality确定这些长度是否相等。如果他们是,我们将拥有Just ReflgcastWith将使用该相等性证明dot u v通过履行其隐含n ~ m假设来确保类型正确。

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

请注意,由于不知道其长度的向量基本上是一个列表,因此我们有效地重新实现了的列表版本dot :: Num a => [a] -> [a] -> Maybe a。不同之处在于此版本是根据vectors实现的dot。重点是:在类型检查器允许您调用之前dot必须使用来测试输入列表的长度是否相同testEquality。我很容易以if错误的方式获取- 语句,但不是在依赖类型的环境中!

在处理运行时数据时,您无法避免在系统边缘使用存在性包装器,但是在执行输入验证时,可以在系统内部的任何地方使用依赖类型,并在边缘使用存在性包装器。

由于Nothing信息不是很丰富,因此您可以进一步完善的类型,dot'以返回在失败情况下长度不相等的证明(以长度差不为0的证据的形式)。这与标准的Haskell技术(Either String a可能用来返回错误消息)非常相似,尽管证明项在计算上远比字符串有用!


这样就结束了对依赖类型的Haskell编程中常见的一些技术的全面介绍。在Haskell中使用此类编程非常酷,但同时又很尴尬。尽管存在代码生成器来帮助样板,但将所有相关数据分解为许多表示相同意思的表示形式(Nat类型,Nat类型,Natty n单例)确实非常麻烦。目前,对于可以提升为类型级别的内容也有限制。真是令人着迷!人们对可能性感到困惑-在Haskell中的文献中有强类型printf,数据库接口,UI布局引擎的示例...

如果您想进一步阅读,关于依赖类型的Haskell的文献越来越多,无论是已出版的还是在Stack Overflow之类的网站上都有。一个很好的起点是Hasochism -纸经过这个非常例子(等等),在一些细节讨论痛苦的部分。单身演示单值的技术(如Natty)。有关一般的依赖类型的更多信息,Agda教程是一个不错的起点。同样,Idris是(大致上)被设计为“具有依赖类型的Haskell”的开发语言。


@Benjamin仅供参考,末尾的Idris链接似乎已断开。
Erik Eidt 2015年

@ErikEidt糟糕,感谢您指出!我会更新。
本杰明·霍奇森

14

这就是所谓的依赖类型。一旦知道名称,就可以找到比您期望的更多的信息。还有一种有趣的类似Haskell的语言,称为Idris,可以在本地使用它们。它的作者就您可以在youtube上找到的主题做了一些非常好的演示。


这根本不是依赖类型。依赖类型在运行时讨论类型,但是将维数烘焙到类型中可以在编译时轻松完成。
DeadMG

4
@DeadMG相反,依赖类型在编译时谈论类型运行时是反射,不依赖打字。从我的回答中可以看出,将维数烘焙到类型中对于一般维数而言并非易事。(您可以定义,依此类推,但这不是问题要问的。)newtype Vec2 a = V2 (a,a)newtype Vec3 a = V3 (a,a,a)
本杰明·霍奇森

好吧,值仅在运行时出现,因此除非您想解决暂停问题,否则您不能在编译时真正谈论值。我要说的是,即使在C ++中,您也可以在维数上进行模板化,并且效果很好。在Haskell中没有对应的功能吗?
DeadMG

4
@DeadMG“全谱”依赖类型的语言(例如Agda)实际上允许在类型语言中进行任意术语级计算。正如您指出的那样,这使您面临尝试解决停止问题的风险。最依赖类型的系统afaik通过不完善图灵来解决这个问题。我不是C ++专家,但是您可以使用模板模拟依赖类型,这并不令我感到惊讶。模板可以各种创造性方式滥用。
本杰明·霍奇森

4
@BenjaminHodgson您无法使用模板执行依赖类型,因为您无法模拟pi类型。必须声明您需要的“规范”从属类型是Pi (x : A). B哪个函数,从AB x哪里x是函数的参数。在此,函数的返回类型取决于作为参数提供的表达式。但是,所有这些都可以删除,仅在编译时有效
Daniel Gratzer
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.