Dependent Haskell如何改进行业项目

222 阅读17分钟

依赖型Haskell如何改善行业项目

依赖类型是Haskell社区的一个热门话题。许多声音主张在Haskell中加入依赖类型,并且为此投入了大量的精力。同时,怀疑论者也提出了各种担忧,其中之一是认为依赖类型更像是一个研究项目,而不是适用于工业软件开发的工具。

然而,这是个错误的二分法。虽然依赖类型仍然是一个活跃的研究课题,但我们已经对其有了足够的了解,可以看到如何在现实世界中应用它们并获得直接的好处。

在这篇文章中,我们展示了依赖型Haskell可以用来简化和改进一个大型生产代码库中的代码。我们的案例研究的主题是MorleyTezos区块链的智能合约语言的实现。

开始使用Dependent Haskell

依赖类型是一个广泛的概念。如果你仔细观察一下现有的依赖类型语言,如AgdaCoqIdris,你会发现它们的类型系统都有某些独特的特征。在这方面,Haskell也即将得到它自己的依赖类型的味道,其设计是为了尽可能地与语言的其他部分相适应,而不是盲目地模仿先前的工作。

大部分的理论已经在Adam Gundry的"类型推理、Haskell和依赖类型 "以及Richard Eisenberg的"Haskell的依赖类型。理论与实践"。我们鼓励读者熟悉这些著作,尤其是后者(因为它更近一些)。

作为一种更随意的阅读,还有GHC GitLab维基页面上的主题和GHC提案#378。虽然没有那么广泛,但这些资源为学习Dependent Haskell的拟议设计提供了一个坚实的起点。

最后,为了完整起见,我们现在将回顾一下这里最重要的概念:量词、依赖性、可见性和擦除。

量词和它们的属性

当我们说 "量词 "时,我们指的是类型的一部分,它对应于术语中的一个地方,如果你想的话,你可以在那里引入一个变量(来源:评论775422563)。像往常一样,光是这个定义就很难理解,所以让我们用几个例子来具体说明一下。

考虑一下lambda抽象\a -> a == 0 。相应的类型是Int -> Bool 。我们可以将这两者拆分如下。

粘合剂
类型Int ->Bool
术语\a ->a == 0

与术语级绑定器相对应的类型部分是Int -> ... ,所以这就是量词。

请注意,lambda抽象可能会被其他语言特性所掩盖,比如模式匹配。比如说:

f :: Int -> Bool
f 0 = True
f _ = False

尽管这里没有明确的lambda抽象或变量,我们仍然把类型的Int -> ... 部分称为量化器。在这次讨论中,我们通过对术语进行解构的角度来看待它们。上面的代码片断实际上等同于。

f :: Int -> Bool
f = \a -> case a of
  0 -> True
  _ -> False

而现在lambda抽象是显式的。阅读我们关于Haskell去ugaring的文章,了解更多关于这种转换的信息。

量词的另一个例子是ctx => ... ,它引入了一个类约束。如果你不熟悉**字典传递**,那么你可能不清楚约束与变量和量化的关系。而让人感到棘手的是,我们无法在表面的Haskell中做一个转换来使其明显化,我们需要看一下GHC的内部语言,称为Core。幸运的是,前面提到的关于去ugaring的文章也涵盖了这一点。

简而言之,一个类型为Num a => a -> a 的约束函数等同于一个类型为NumDict a -> a -> a 的函数,其中NumDict 是一个包含数字方法实现的记录,如(+)(*)negate ,等等。

这也是可见性概念发挥作用的地方。我们称a ->可见量词,因为在术语层面,抽象和应用都是明确的。另一方面,ctx => ... 是一个不可见的量词,因为术语级的类方法字典对程序员是隐藏的。

在Haskell中,正如Haskell 2010报告所规定的,这是仅有的两个量词。然而,随着ExplicitForAll 的扩展,我们又得到了一个:forall a. ... 。就像双箭头一样,forall 是一个不可见的量词,所以可能很难理解为什么它是一个量词(而且它也在关于去ugaring的文章中涉及)。然而,通过TypeApplications 扩展,你可以在使用地点覆盖可见性。人们可以不写map (>0) ,而写map @Int @Bool (>0) 。这三个输入对应于地图类型中的前三个量词:

map ::
  forall a.     -- e.g. @Int
  forall b.     -- e.g. @Bool
  (a -> b) ->   -- e.g. (>0)
  ([a] -> [b])

这样,在今天的Haskell中,我们就有了三个量词,这些量词要么是可见的,要么是不可见的:

a -> ...可见
ctx => ...不可见
forall a. ...不可见(但可以用可见的方式使用TypeApplications)

然而,可见性并不是我们关心的唯一属性。另一个是擦除。如果一个量词可以在它所引入的变量上进行模式匹配,我们称之为保留(而不是擦除)。

例如,下面的代码是无效的:

evil_id :: forall a. a -> a
evil_id x =
  case a of   -- Nope!
    Int -> 42
    _ -> x

在上面的代码片段中,我们的想法是:evil_id 将主要表现为id ,但当应用于一个Int 时返回42 。不过这是不可能的,因为类型变量a 不能用于案例分析。因此,我们把forall 称为擦除量词。由于被擦除的参数不能进行案例分析,所以它们永远不会影响到哪个代码分支被采纳。

擦除的参数在运行时不被传递。

另外,由于类方法字典是在运行时传递的,我们说ctx => ... 是一个保留的量词。自然,字典中包含的数据可用于案例分析。

例如,evil_id 可以通过利用Typeable 类来实现:

import Type.Reflection
import Data.Type.Equality

evil_id :: forall a. Typeable a => a -> a
evil_id x =
  case testEquality (typeRep @a) (typeRep @Int) of
    Just Refl -> 42
    Nothing -> x

最后,让我们讨论一下依赖性。如果一个量词引入的变量可以在类型的其余部分中被提及,那么这个量词就被认为是依赖性的。

例如,普通函数就不是依赖性的:

f :: Bool -> ...  -- x cannot be used here
f = \x  -> ...

另一方面,forall 是一个依赖性量词:

f :: forall x. ... -- x can be used here
f = ...

这意味着从属变量所取的值可以影响类型的其他部分。(+) @Int 的类型是Int -> Int -> Int ,而(+) @Double 的类型是Double -> Double -> Double

在本小节的最后,让我们总结一下目前可用的量词和它们的属性:

量词可见的抹去的依赖性
a -> ...✔️
ctx => ...
forall a. ...✔️✔️

Dependent Haskell的新量词

你可能注意到,量词表有不少缺失的行。可见的擦除依存量化,或不可见的保留依存量化,等等,怎么办?

Dependent Haskell的主要重点是增加最强大的量化形式,它将同时保留和依赖。我们将把这个新的量化器称为foreach 。可见性并不那么重要,所以计划是同时提供可见和不可见的变化。既然如此,我们不妨把可见的擦除依赖性量化扔进这个组合。

量词可见的擦除的从属
forall a -> ...✔️✔️✔️
foreach a. ...✔️
foreach a -> ...✔️✔️

新的量词将为目前的一些做法提供更有原则的替代,包括Proxy,Typeable,TypeRep,Sing, 和SingI 。这正是我们将要探索的,因为Tezos Morley恰好利用了这些定义。

具体来说,我们将进行以下(主要是机械的)转换。

之前之后
forall a. Sing a -> bforeach a -> b
forall a. SingI a => bforeach a. b
forall a. Proxy a -> bforall a -> b

因此,代码将变得更加简洁,更容易维护。我们将不再需要定义SingSingIsingletons 包,因为我们使用所有这些新的量词,而不是来自singletons 的复杂机械。

从属类型如何帮助工业

在这一节中,我们将讨论几个例子,说明如何通过依赖型Haskell来简化使用高级类型的工业代码。程序员们已经在他们的项目中为各种目的模拟了依赖类型(例如,使用单子)。我们展示了如果我们能够摆脱这些模拟而使用真正的依赖类型,这些项目可能会是什么样子。

我们的案例研究是Morley,它是Tezos的一部分。

Tezos是一个拥有股权证明共识算法的区块链系统。Michelson是Tezos区块链的一种功能性智能合约语言。它是一种基于堆栈的语言,具有强类型,它受到ML和Scheme等功能语言的启发。在tezos.gitlab.io/alpha/miche…,也有关于Michelson操作语义的正式描述。

Morley是一套用于编写Michelson智能合约的工具。Morley "这个词有点重了,因为它指的是Haskell包、扩展Michelson的智能合约语言以及同名的框架。该包由Morley解释器的实现和类型检查器组成。

摆脱了singletons

singletons 是一个模拟Haskell中依赖类型的库。你可以从它的README和介绍该库的论文中了解更多信息。Richard Eisenberg和Stephanie Weirich的"Dependently Typed Programming with Singletons"

我们假设读者已经知道诸如Sing 类型族和SingI 类的结构。否则,可以看一下文档

例1:TgetWTP

让我们来看看来自morley数据类型T

data T =
    TKey
  | TUnit | TSignature | TChainId | TOption T | TList T | TSet T | TOperation
  | TContract T | TPair T T | TOr T T | TLambda T T | TMap T T | TBigMap T T
  | TInt | TNat | TString | TBytes | TMutez | TBool | TKeyHash | TBls12381Fr
  | TBls12381G1 | TBls12381G2 | TTimestamp | TAddress | TNever

这是一个普通的ADT,它描述了迈克尔逊值的类型。如果我们想验证一个类型是否符合格式,我们可以实现一个谓词。

isWellFormed :: T -> Bool

然而,仅仅使用一个Bool ,意味着我们没有任何证据表明验证成功了。相反,morley 定义了以下类型的getWTP

getWTP :: forall (t :: T). (SingI t) => Either NotWellTyped (Dict (WellTyped t))

如果输入类型t :: T 是格式良好的,则该函数产生带有证据的Right 。否则,它就会失败并返回Left 。值得注意的是,证据的类型WellTyped t 是指t 的值。这就是为什么我们不得不采用精心设计的结构forall t. SingI t => ,而不是添加一个简单的函数参数T ->

由此产生了相当多的复杂问题。首先,我们需要为T 生成单数。

$(let singPrefix, sPrefix :: Name -> Name
      singPrefix nm = mkName ("Sing" ++ nameBase nm)
      sPrefix nm = mkName ("S" ++ nameBase nm) in

  withOptions defaultOptions{singledDataConName = sPrefix, singledDataTypeName = singPrefix} $
  concat <$> sequence [genSingletons [''T], singDecideInstance ''T]

Template Haskell的这个片段生成了SingT ,它是T 的单子类型,也是SingISDecide 的实例:

data SingT t where
  STUnit :: SingT TUnit
  STSignature :: SingT TSignature
  ...
  STPair :: SingT t1 -> SingT t2 -> SingT (TPair t1 t2)
  ...
  -- and so on for each constructor of T.

其次,getWTP 的实现现在必须使用SingT 的值,而不是普通的T 值。

让我们看一下getWTP 的一个分支,它处理STPair

getWTP :: forall t. (SingI t) => Either NotWellTyped (Dict (WellTyped t))
getWTP = case sing @t of
...
 STPair s1 s2 ->
    withSingI s1 $
    withSingI s2 $
    fromEDict (getWTP_ s1) $
    fromEDict (getWTP_ s2) $ Right Dict
…

getWTP_ :: forall t. Sing t -> Either NotWellTyped (Dict (WellTyped t))
getWTP_ s = withSingI s $ getWTP @t

TPair 是 的构造函数,对应于Michelson tuple数据类型,而 是对应的单子。这个函数有 的约束,这里我们对 进行模式匹配,其中 是一个类型变量,其类型为 。T STPair a b SingI sing @t t T

通过Dependent Haskell,我们得到了foreach 量词,可以用它来简化上述所有的内容。getWTP 的类型将变成:

getWTP :: foreach (t :: T). Either NotWellTyped (Dict (WellTyped t))

getWTP 的实现可以对常规的T 值进行模式匹配,而不是SingT

getWTP @(TPair s1 s2) =
    fromEDict (getWTP @s1) $
    fromEDict (getWTP @s2) $ Right Dict

此外,我们不再需要用于递归调用的getWTP_ 辅助函数。相反,我们只需使用一个可见性覆盖@

例2:PeanoUpdateN

下面是另一个例子。在morley 中,我们需要术语级和类型级的自然数来索引堆栈机栈上的元素。

目前,我们有经典的数据类型,按照 Peano 的方式归纳定义自然数:

data Peano = Z | S Peano

而通过DataKinds 语言扩展,我们可以在类型级使用它,就像我们在堆栈机的指令类型中一样:

data Instr (inp :: [T]) (out :: [T]) where
…
  UPDATEN
    :: forall (ix :: Peano) (val :: T) (pair :: T) (s :: [T]).
       ConstraintUpdateN ix pair
    => PeanoNatural ix
    -> Instr (val : pair : s) (UpdateN ix val pair ': s)
...

但除了类型级的变量ix :: Peano ,我们还需要在术语级对其进行镜像,以便对其进行模式匹配。这就是PeanoNatural ix 字段的目的。

通常情况下,我们会使用一个单子类型来达到这个目的:

data SingNat (n :: Nat) where
  SZ :: SingNat 'Z
  SS :: !(SingNat n) -> SingNat ('S n)

但我们更进一步。有很多情况下,我们需要将这个单子值转换为一个自然数,以非归纳Natural

直接的解决方案是利用像下面这样的转换函数:

toPeano :: SingNat n -> Natural
toPeano SZ = 0
toPeano (SS n) = 1 + (toPeano n)

这样的转换在运行时是O(n)O(n)O(n),重复调用它将是低效的。取而代之的是,我们定义了PeanoNatural 数据类型,将这种转换的结果缓存在单子的旁边:

data PeanoNatural (n :: Peano) = PN !(SingNat n) !Natural

当然,我们不想从任意的一对SingNat nNatural 中制作一个PeanoNatural 的元素。我们希望有一个不变式,可以表述为PN s k :: PeanoNatural n iffk = toPeano s 。我们通过引入模式的同义词ZeroSucc 来正式确定这一想法:

data MatchPS n where
  PS_Match :: PeanoNatural n -> MatchPS ('S n)
  PS_Mismatch :: MatchPS n

matchPS :: PeanoNatural n -> MatchPS n
matchPS (PN (SS m) k) = PS_Match (PN m (k - 1))
matchPS _ = PS_Mismatch

pattern Zero :: () => (n ~ 'Z) => PeanoNatural n
pattern Zero = PN SZ 0

pattern Succ :: () => (n ~ 'S m) => PeanoNatural m -> PeanoNatural n
pattern Succ s <- (matchPS -> PS_Match s) where
  Succ (PN n k) = PN (SS n) (k+1)
{-# COMPLETE Zero, Succ #-}

这些模式涵盖了所有可能的情况,但是GHC不能自己计算出来,所以我们必须使用COMPLETE pragma来避免incomplete-patterns 的警告。

使用Dependent Haskell,我们可以重写PeanoNatural ,以避免使用单子:

data PeanoNatural (n :: Peano) where
  PN :: foreach !(n :: Peano) -> !Natural -> PeanoNatural n

有趣的是,这揭示了对严格的foreach 的需求--这个话题之前在文献中没有讨论过,所以值得单独研究。

回到InstrUPDATEN 构造函数:

  UPDATEN
    :: forall (ix :: Peano) (val :: T) (pair :: T) (s :: [T]).
       ConstraintUpdateN ix pair
    => PeanoNatural ix
    -> Instr (val : pair : s) (UpdateN ix val pair ': s)

UPDATEN 是堆栈机更新堆栈顶部给定的右组合对的第n个节点的指令。

这里,UpdateN 是一个类型级的列表操作:

type family UpdateN (ix :: Peano) (val :: T) (pair :: T) :: T where
  UpdateN 'Z           val _                   = val
  UpdateN ('S 'Z)      val ('TPair _  right)   = 'TPair val right
  UpdateN ('S ('S n))  val ('TPair left right) = 'TPair left (UpdateN n val right)

在Dependent Haskell中,我们可以将UpdateN 重新定义为一个术语级函数:

updateN :: Peano -> T -> T -> T
updateN Z         val  _                 = val
updateN (S Z)     val (TPair _  right)   = TPair val right
updateN (S (S n)) val (TPair left right) = TPair left (updateN n val right)

摆脱Proxy

在Morley中,我们使用幻象标签来识别算术和其他代数操作:

data Add           -- addition
data Sub           -- subtraction
data Mul           -- multiplication
data And           -- conjunction
data Or            -- disjunction
data Xor           -- exclusive disjunction        
...

那么为了实现这些运算,我们有一个叫做ArithOp 的类:

class (Typeable n, Typeable m) =>
      ArithOp aop (n :: T) (m :: T) where
  type ArithRes aop n m :: T
  evalOp
    :: proxy aop
    -> Value' instr n
    -> Value' instr m
    -> Either (ArithError (Value' instr n) (Value' instr m)) (Value' instr (ArithRes aop n m))

aop 类型变量代表上述的一个运算。nm 类型变量代表操作的输入类型,一个操作可以被重载,以对各种输入进行操作:

instance ArithOp 'Add TInt TInt   -- addition of Ints
instance ArithOp 'Add TNat TNat   -- addition of Nats
...

instance ArithOp 'And TBool TBool   -- logical `and`
instance ArithOp 'And TNat TNat     -- bitwise `and`

ArithRes 类型族指定了结果的类型:

instance ArithOp 'Add TInt TInt where
  type ArithRes 'Add TInt TInt = TInt
  ...

-- offsetting a timestamp by a given number of seconds
instance ArithOp 'Add TInt TTimestamp where
  type ArithRes 'Add TInt TTimestamp = TTimestamp
  ...

最后,我们有evalOp 方法,它实际上是在术语级别上实现操作。

instance ArithOp Or 'TNat 'TNat where
  type ArithRes Or 'TNat 'TNat = 'TNat
  evalOp _ (VNat i) (VNat j) = Right $ VNat (i .|. j)

你会注意到,evalOp 忽略了它的第一个参数,它是一个代理值。它的唯一作用是在使用地点指定操作。

let k = evalOp (Proxy :: Proxy Add) n m

Proxy 的问题是,它是一个在运行时传递的值,所以它产生了一定的开销。优化器不可能总是摆脱它。另一个问题是,在使用地点构造它,会引入语法噪音,使API不那么方便。我们宁愿写evalOp Add ,而不是evalOp (Proxy :: Proxy Add)

如果我们简单地删除它呢?像这样。

class ... => ArithOp aop (n :: T) (m :: T) where
  ...
  evalOp   -- removed the (proxy aop ->) parameter
    :: Value' instr n
    -> Value' instr m
    -> Either (ArithError (Value' instr n) (Value' instr m))
              (Value' instr (ArithRes aop n m))

那么我们就可以解决这两个问题:在运行时没有输入要传递,而在使用站点我们可以简单地写evalOp @Add 。但代价是我们会引入一个新的问题:aop 类型变量会变得模糊不清。如果启用AllowAmbiguousTypes 扩展,这是被允许的,但如果忘记在使用站点指定模糊的类型变量,就会导致错误信息的严重恶化。

Dependent Haskell的一个量词提供了一个更好的解决方案。可见的forall a -> 量词大多等同于普通的forall a. ,但是类型变量必须总是在使用地点指定,而且永远不会含糊。

对于evalOp ,我们希望的类型是。

evalOp
    :: forall instr n m. forall aop ->
       ArithOp aop n m
    => Value' instr n
    -> Value' instr m
    -> Either (ArithError (Value' instr n) (Value' instr m))
              (Value' instr (ArithRes aop n m))

对于我们希望编译器在使用地点推断的变量,我们使用普通的量词forall instr n m. ;但是对于aop ,必须在使用地点明确指定,我们使用forall aop ->

我们可以将同样的技巧应用于其他涉及该变量的函数。例如,在今天的Morley中,有一个围绕evalOp 的包装器,它对来自堆栈机的值进行操作。

runArithOp
  :: (ArithOp aop n m, EvalM monad)
  => proxy aop
  -> StkEl n
  -> StkEl m
  -> monad (StkEl (ArithRes aop n m))
runArithOp op l r = case evalOp op (seValue l) (seValue r) of
  Left  err -> throwError (MichelsonArithError err)
  Right res -> pure $ starNotesStkEl res

有了可见的forall,我们会把它改写成如下。

runArithOp
  :: forall aop ->
     (ArithOp aop n m, EvalM monad)
  => StkEl n
  -> StkEl m
  -> monad (StkEl (ArithRes aop n m))
runArithOp op l r = case evalOp op (seValue l) (seValue r) of
  Left  err -> throwError (MichelsonArithError err)
  Right res -> pure $ starNotesStkEl res

runArithOp 函数评估算术操作,要么成功,要么失败。第一个参数是proxy aop ,它指定了算术运算本身。该函数使用evalOp ,这是一个类型类ArithOp 的方法,其相关的类型是ArithRes ,表示运算结果的类型。

这个变化在下游连带到其他使用runArithOp 的函数。例如,runInstrImpl 函数有以下公式。

type InstrRunner m = forall inp out. Instr inp out -> Rec StkEl inp -> m (Rec StkEl out)

runInstrImpl :: EvalM m => InstrRunner m -> InstrRunner m
…
runInstrImpl _ OR (l :& r :& rest)     = (:& rest) <$> runArithOp (Proxy @Or) l r

取代Proxy @Or ,我们在这里会简单地写成Or

runInstrImpl _ OR (l :& r :& rest)     = (:& rest) <$> runArithOp Or l r

术语级函数而不是类型族

例1:DropTake

在Dependent Haskell中,我们将能够在类型级别使用函数。特别是,我们可以摆脱类型族而使用通常的术语级函数。

例如,我们可以用列表上的相应函数来替换下面的类型族。

type family Drop (n :: Peano) (s :: [k]) :: [k] where
  Drop  'Z s = s
  Drop ('S _) '[] = '[]
  Drop ('S n) (_ ': s) = Drop n s

type family Take (n :: Peano) (s :: [k]) :: [k] where
  Take  'Z _ = '[]
  Take _ '[] = '[]
  Take ('S n) (a ': s) = a ': Take n s

这些类型族也是来自morley 。我们把DropTake 替换成它们通常的术语级对应函数。

drop :: Peano -> [a] -> [a]
drop Z l = l
drop _ [] = []
drop (S n) (_ : s) = drop n s

take :: Peano -> [a] -> [a]
take Z _ = []
take _ [] = []
take (S n) (x : xs) = x : take n s

除了对列表的操作之外,我们还使用类型族来强制执行不变性。

type family IsLongerThan (l :: [k]) (a :: Peano) :: Bool where
  IsLongerThan (_ ': _) 'Z = 'True
  IsLongerThan (_ ': xs) ('S a) = IsLongerThan xs a
  IsLongerThan '[] _ = 'False

type LongerThan l a = IsLongerThan l a ~ 'True

IsLongerThan 是一个二元谓词,如果一个列表的长度大于一个给定的自然数,则为真。

我们可以在 Dependent Haskell 中重新表述这段代码,如下所示。

isLongerThan :: [a] -> Peano -> Bool
isLongerThan xs n = length xs > n

-- longerThan :: [a] -> Peano -> Constraint
longerThan l n = isLongerThan l n ~ True

例2:IsoValueToT

现在我们来看一下IsoValue 类型类。这个类型类使用关联类型定义了从Haskell类型到Michelson类型的映射。

class (WellTypedToT a) => IsoValue a where
  -- | Type function that converts a regular Haskell type into a @T@ type.
  type ToT a :: T
  type ToT a = GValueType (G.Rep a)

在Dependent Haskell中,我们可以用方法代替关联类型(假设#267来控制可见性)。

class (WellTypedToT a) => IsoValue a where
  toT a :: T

我们可以进一步翻译利用ToT 的类型族。例如,目前我们有ToTs ,将ToT 应用于一个类型的列表。

type family ToTs (ts :: [Type]) :: [T] where
  ToTs '[] = '[]
  ToTs (x ': xs) = ToT x ': ToTs xs

有了DH,我们可以简单地写成map toT

例3:DUPN

现在让我们考虑一个与莫利堆栈机直接相关的例子。

回顾一下,Instr 是代表堆栈机指令的数据类型,比如UPDATENDUPN

data Instr (inp :: [T]) (out :: [T]) where
...
  DUPN
    :: forall (n :: Peano) inp out a. (ConstraintDUPN n inp out a)
    => PeanoNatural n -> Instr inp out
...

如同前一节讨论的UPDATEN ,我们重写DUPN ,用foreach ,而不是PeanoNatural

data Instr (inp :: [T]) (out :: [T]) where
...
  DUPN
    :: foreach  (n :: Peano) 
    -> forall inp out a. (ConstraintDUPN n inp out a)
    => Instr inp out
...

我们已经讨论了这种转变,现在我们对其他的东西感兴趣:ConstraintDUPN 的约束:

type ConstraintDUPN n inp out a = ConstraintDUPN' T n inp out a

type ConstraintDUPN' kind (n :: Peano) (inp :: [kind]) (out :: [kind]) (a :: kind) =
  ( RequireLongerOrSameLength inp n
  , n > 'Z ~ 'True
  , inp ~ (Take (Decrement n) inp ++ (a ': Drop n inp))
  , out ~ (a ': inp)
  )

让我们特别关注一下这一行:

 inp ~ (Take (Decrement n) inp ++ (a ': Drop n inp))

这里涉及四个类型家族。Take,Drop,Decrement, 和++ 。我们已经讨论了TakeDrop

Decrement 被定义如下:

type family Decrement (a :: Peano) :: Peano where
  Decrement 'Z = TypeError ('Text "Expected n > 0")
  Decrement ('S n) = n

再一次,在Dependent Haskell中,我们不需要在类型层面上将算术运算复制为类型族。所以我们用Decrement 中的小写字母 "D "来代替大写字母 "D":

decrement :: Peano -> Peano
decrement Z = error "Expected n > 0"
decrement (S n) = n

TypeError 的调用可以翻译成我们熟悉的术语级error 。至于TakeDrop ,我们已经在上面演示了它们的翻译。至于++ ,目前我们使用的是vinyl 库中的一个:

type family (as :: [k]) ++ (bs :: [k]) :: [k] where
  '[] ++ bs = bs
  (a ': as) ++ bs = a ': (as ++ bs)

在Dependent Haskell中,我们可以使用来自base 的术语级的。因此,我们以如下方式重新定义了ConstraintDUPN'

type ConstraintDUPN' kind (n :: Peano) (inp :: [kind]) (out :: [kind]) (a :: kind) =
  ( RequireLongerOrSameLength inp n
  , n > 'Z ~ 'True
  , inp ~ (take (decrement n) inp ++ (a ': drop n inp))
  , out ~ (a ': inp)
  )

Dependent Haskell的未来

我们希望这篇文章解释了语言中的这些变化将如何让我们写出更透明的Haskell代码。特别是,当我们想在类型级别上保证安全时,这些变化是有意义的。

让我们快速讨论一下Dependent Haskell的进一步步骤。在写这篇文章的时候,为依赖类型设计的提案最近已经被接受。这是一个相当了不起的成就,因为这个话题在Haskell社区中是相当有争议的。但是我们仍然有很多工作要做,以 "实现 "这些看起来相当推测的增强功能,因为在Haskell中成熟的依赖类型还没有准备好。

现在,我们正在努力解决诸如启用可见的foralls在函数中绑定类型变量等问题。这些都是问题的例子,这些问题的解决使得Haskell中的从属类型变得更加接近。然而,引入foreach量词需要广泛的研究,因为我们目前只有一个设计草图。

如何参与?

GHC开发者社区总是对新的爱好者开放的,所以这篇文章的一些读者可能想参与到Dependent Haskell的开发中。

如果是这样,你可以看一下ghc.dev。这个页面包含了构建和调试GHC的基本命令。也请看Simon Marlow和Simon Peyton Jones在《开源应用程序的架构》中的GHC章节。我们也建议新来者浏览一下GHC的问题列表