假设我想编写一个处理向量和矩阵的库。是否可以将维度烘焙为类型,以使不兼容的维度的操作在编译时产生错误?
例如,我希望点积的签名类似于
dotprod :: Num a, VecDim d => Vector a d -> Vector a d -> a
其中d
类型包含单个整数值(代表这些Vector的维)。
我想可以通过为每个整数定义一个单独的类型并将其分组为一个名为的类型类来实现VecDim
。是否有某种机制可以“生成”此类类型?
还是一些更好/更简单的方法来实现同一目标?
假设我想编写一个处理向量和矩阵的库。是否可以将维度烘焙为类型,以使不兼容的维度的操作在编译时产生错误?
例如,我希望点积的签名类似于
dotprod :: Num a, VecDim d => Vector a d -> Vector a d -> a
其中d
类型包含单个整数值(代表这些Vector的维)。
我想可以通过为每个整数定义一个单独的类型并将其分组为一个名为的类型类来实现VecDim
。是否有某种机制可以“生成”此类类型?
还是一些更好/更简单的方法来实现同一目标?
Answers:
为了扩大对@ 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
-换句话说,我们有型级别的自然数。请注意,类型S
和Z
没有任何成员值-值仅占用类型的类型*
。
现在,我们介绍一种表示已知长度向量的GADT。注意种类签名:Vec
需要种类的类型Nat
(即a Z
或S
类型)来表示其长度。
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
。它仅在编译时用于跟踪长度,在编译器生成机器代码之前将被擦除。
我们定义了一个向量类型,它携带有关其长度的静态知识。我们来查询一下Vec
s 的类型,以了解它们的工作方式:
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)
输出向量的长度将是总和的两个输入向量的长度。我们需要教类型检查器如何将Nat
s 加在一起。为此,我们使用类型级别的函数:
type family (n :: Nat) :+: (m :: Nat) :: Nat where
Z :+: m = m
(S n) :+: m = S (n :+: m)
该type family
声明引入了一个称为类型的函数:+:
-换句话说,这是类型检查器计算两个自然数之和的诀窍。它是递归定义的-每当左操作数大于Z
ero时,我们在输出中加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
只给我们升级后的类型构造函数。换句话说,类型构造函数S
和Z
不会出现在值级别。我们必须为某个。* 的运行时表示形式确定单例值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)
对于dot
2 AVec
s,我们需要向GHC证明它们的长度相等。Data.Type.Equality
定义了仅在其类型参数相同时才能构造的GADT:
data (a :: k) :~: (b :: k) where
Refl :: a :~: a -- short for 'reflexivity'
当您进行模式匹配时Refl
,GHC会知道a ~ b
。还有一些函数可以帮助您使用此类型:我们将gcastWith
用于在等效类型之间进行转换,并TestEquality
确定两个Natty
s是否相等。
为了测试两个平等Natty
S,我们将需要利用的事实,如果两个数是相等的,那么他们的继任者也是相等的(:~:
是全等过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 Refl
;gcastWith
将使用该相等性证明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”的开发语言。
这就是所谓的依赖类型。一旦知道名称,就可以找到比您期望的更多的信息。还有一种有趣的类似Haskell的语言,称为Idris,可以在本地使用它们。它的作者就您可以在youtube上找到的主题做了一些非常好的演示。
newtype Vec2 a = V2 (a,a)
newtype Vec3 a = V3 (a,a,a)
Pi (x : A). B
哪个函数,从A
到B x
哪里x
是函数的参数。在此,函数的返回类型取决于作为参数提供的表达式。但是,所有这些都可以删除,仅在编译时有效