为什么依赖性Haskell是软件开发的未来

507 阅读19分钟

你好,我是Vladislav Zavialov。在过去的几个月里,我在Serokell的工作是领导我们努力改进我们的主要工具--Glasgow Haskell编译器(GHC)。我专注于使类型系统更加强大,这是受Richard Eisenberg介绍Dependent Haskell的论文的启发。

目前的状况

Dependent types 依赖类型是我最想在Haskell中看到的一个功能。让我们来谈谈为什么。在编写性能良好的代码、可维护和易于理解的代码以及构造正确的代码之间存在着一种矛盾。在现有的技术中,我们处于 "二选一 "的情况,但在Haskell中对依赖类型的直接支持,我们可能会得到我们的蛋糕,并把它吃掉。

标准Haskell:人机工程学+性能

Haskell的核心是简单的:它只是一个多态的lambda calculus,具有懒惰的评估和代数数据类型和类型类。这恰好是各种功能的正确组合,使我们能够写出干净、可维护的代码,而且运行速度也很快。我将简要地把它与更多的主流语言进行比较,以证实这一说法。

内存不安全的语言,如C,会导致最糟糕的错误和安全漏洞(缓冲区溢出和内存泄漏)。在一些特定的情况下,人们会想使用这样的语言,但大多数情况下这是一个可怕的想法。

内存安全语言有两类:一类是依靠垃圾收集器的语言,另一类是Rust。Rust似乎是独一无二的,因为它在没有GC的情况下提供了内存安全(在这一组中还有现在没有维护的Cyclone和其他研究语言,但与它们不同的是,Rust正在向主流使用铺平道路)。缺点是,虽然安全,但Rust的内存管理仍然是手动的和非琐碎的,在能够负担得起使用垃圾收集器的应用程序中,开发人员的时间最好花在其他问题上。

这就给我们留下了垃圾收集语言,我们将根据它们的类型系统把它们进一步分为两类。

动态类型(或者说,单元类型)语言,如JavaScript或Clojure,在设计上不提供静态分析,因此不能对代码的正确性提供相同程度的信心(不,测试不能取代类型--我们需要两者!)。

静态类型语言,如Java或Go,往往带有严重限制的类型系统,迫使程序员编写模板或退回到不安全的编程技术。一个明显的例子是,Go中缺乏泛型,因此必须使用 interface{}运行时转换。有作用的(IO)和纯计算之间也没有分离。

最后,在具有强大类型系统的内存安全的垃圾收集语言中,Haskell以其懒惰而脱颖而出。懒惰是编写可组合、模块化代码的重要资产,使得表达式的任何部分,包括控制结构都有可能被分解。

它似乎是一种完美的语言,直到你意识到与Agda等证明助手相比,它在静态验证方面的潜力还远远没有发挥出来。

作为Haskell类型系统不足的一个简单例子,考虑一下Prelude列表索引操作符(或primitive 包的数组索引)。

(!!) :: [a] -> Int -> a
indexArray :: Array a -> Int -> a

这些类型签名中没有任何内容可以断言索引是非负的,但小于集合的长度。对于高安全性的软件来说,这立刻就是一个不可能的事情。

Agda:人机工程学+正确性

证明助手(例如Coq)是一种软件工具,可以在计算机辅助下开发数学定理的正式证明。对数学家来说,使用证明助手很像写纸笔证明,但计算机需要前所未有的严谨性来断言这种证明的正确性。

然而,对程序员来说,证明助手与一种深奥的编程语言的编译器没有什么不同,这种语言有一个令人难以置信的类型系统(也许还有一个IDE,那么它被称为交互式定理检验器),而其他一切都很平凡(甚至缺乏)。如果你把所有的时间都花在为你的语言开发类型检查器上,而忘记了程序也需要运行,那么证明助手就会发生。

验证软件开发的圣杯是一个证明助手,它也是一个具有工业强度的代码生成器和运行时系统的良好编程语言。在这个方向上有一些实验,例如Idris,但Idris是一种具有急切(严格)评估的语言,而且它的实现目前还不是特别稳定。

最接近Haskeller心脏的证明助手是Agda,因为在很多方面它感觉很像Haskell,有一个更强大的类型系统。我们用它来证明我们软件的各种属性,我的同事Danya Rogozin写了一个关于它的系列帖子

这里的类型是 lookup函数,类似于Haskell的(!!)

lookup : ∀ (xs : List A) → Fin (length xs) → A

这里的第一个参数的类型是List A ,对应于Haskell中的[a] 。然而,请注意,我们也给了它一个名字,xs ,以便在后面的类型签名中引用它。在Haskell中,我们只能在术语级别的函数体中引用函数参数:

(!!) :: [a] -> Int -> a -- can't refer to xs here
(!!) = \xs i -> ...     -- can refer to xs here

然而在Agda中,我们也可以在类型层次上引用这个xs 值,就像我们在lookup 的第二个参数Fin (length xs) 那样。一个在类型层次上引用其参数的函数被称为从属函数,这也是从属类型的一个例子。

lookup 的第二个参数的类型是Fin n ,用于n ~ length xs 。类型为Fin n 的值对应于[0, n) 范围内的一个数字,所以Fin (length xs) 是一个小于输入列表长度的非负数,这正是我们需要的,以表示进入列表的有效索引。粗略的说,这意味着lookup ["x","y","z"] 2 是良好的类型,但是lookup ["x","y","z"] 42 被类型检查器拒绝。

当涉及到运行Agda时,我们可以使用MAlonzo后端将其编译为Haskell,但产生的代码不能提供有竞争力的性能。然而,这并不是MAlonzo的错--它别无选择,只能插入大量的unsafeCoerce ,这样GHC就会接受之前被Agda类型检查器检查过的代码,但正是这个unsafeCoerce破坏了性能

这使我们陷入了一个困难的境地,即我们必须使用Agda进行建模和形式化验证,然后在Haskell中重新实现相同的功能。在这种情况下,我们的Agda代码就像机器检查过的规范一样,虽然比自然语言规范有进步,但还没有达到 "如果它能编译,它就能按规定工作 "的最终目标。

Haskell扩展:正确性+性能

We still don't have proper dependent types in Haskell

为了实现依赖类型语言的静态保证,GHC长期以来一直在增加扩展,以提高类型系统的表达能力。我开始使用Haskell时,GHC 7.4是最新、最闪亮的Haskell编译器,那时它已经有了高级类型级编程的主要扩展。RankNTypes,GADTs,TypeFamilies,DataKinds, 和PolyKinds

然而,我们在Haskell中仍然没有适当的依赖类型:没有依赖函数(Π-类型)或依赖对(Σ-类型)。不过,我们确实有一个用于它们的编码。

目前的技术状况是在类型级别上将函数编码为封闭的类型族,使用去功能化来允许不饱和的函数应用,并使用单子类型来弥补术语和类型之间的差距。这导致了大量的模板,但有一个 singletons库来生成模板Haskell。

Singletone generation of succ zero

这意味着对于非常勇敢和坚定的人来说,在今天的Haskell中存在着依赖类型的编码。作为一个概念的证明,这里有一个类似于Agda版本的lookup 函数的实现:

{-# OPTIONS -Wall -Wno-unticked-promoted-constructors -Wno-missing-signatures #-}
{-# LANGUAGE LambdaCase, DataKinds, PolyKinds, TypeFamilies, GADTs,
             ScopedTypeVariables, EmptyCase, UndecidableInstances,
             TypeSynonymInstances, FlexibleInstances, TypeApplications,
             TemplateHaskell #-}

module ListLookup where

import Data.Singletons.TH
import Data.Singletons.Prelude

singletons
  [d|
    data N = Z | S N
    len :: [a] -> N
    len [] = Z
    len (_:xs) = S (len xs)
  |]

data Fin n where
  FZ :: Fin (S n)
  FS :: Fin n -> Fin (S n)

lookupS :: SingKind a => SList (xs :: [a]) -> Fin (Len xs) -> Demote a
lookupS SNil = \case{}
lookupS (SCons x xs) =
  \case
    FZ -> fromSing x
    FS i' -> lookupS xs i'

这里有一个GHCi会话,证明lookupS 确实拒绝了过大的索引:

GHCi, version 8.6.2: http://www.haskell.org/ghc/  :? for help
[1 of 1] Compiling ListLookup       ( ListLookup.hs, interpreted )
Ok, one module loaded.
*ListLookup> :set -XTypeApplications -XDataKinds 
*ListLookup> lookupS (sing @["x", "y", "z"]) FZ
"x"
*ListLookup> lookupS (sing @["x", "y", "z"]) (FS FZ)
"y"
*ListLookup> lookupS (sing @["x", "y", "z"]) (FS (FS FZ))
"z"
*ListLookup> lookupS (sing @["x", "y", "z"]) (FS (FS (FS FZ)))

<interactive>:5:34: error:
    • Couldn't match type 'S n0’ with 'Z’
      Expected type: Fin (Len '["x", "y", "z"])
        Actual type: Fin ('S ('S ('S ('S n0))))
    • In the second argument of ‘lookupS’, namely ‘(FS (FS (FS FZ)))’
      In the expression:
        lookupS (sing @["x", "y", "z"]) (FS (FS (FS FZ)))
      In an equation for ‘it’:
          it = lookupS (sing @["x", "y", "z"]) (FS (FS (FS FZ)))

这个例子证明了 "可能的 "并不意味着 "可行的"。我很高兴Haskell拥有实现lookupS 所需的功能,但我也因为它带来的意外的复杂性而感到苦恼。我绝不建议在非研究性项目中使用这种风格的代码。

在这个特殊的例子中,我们本可以使用长度索引的向量来实现类似的结果,而且复杂度更低。然而,Agda代码的直接翻译更有助于揭示我们在其他情况下必须处理的问题。下面是其中的一些:

  • 类型关系a :: t 和种类关系t :: k 是不同的。5 :: Integer 在术语中成立,但在类型中不成立。"hi" :: Symbol 在类型中成立,但在术语中不成立。这导致了对Demote 类型家族的需求,以将类型级的编码映射到它们的术语级的对应物。

  • 标准库使用Int 来表示列表索引(而singletons 在推广的定义中使用Nat )。IntNat 是非归纳类型,虽然比自然数的单数编码更有效,但它们与归纳定义(如FinlookupS )不兼容。因此我们不得不将length 重新定义为len

  • singletons 将它们编码为封闭的类型族,使用去功能化来解决类型族不能被部分应用的限制。这是一个复杂的编码,我们必须把我们对len 的定义放到Template Haskell引文中,这样singletons 就会生成它的类型级对应物,Len

  • 由于缺乏内置的依赖函数,我们必须使用单子类型来弥补术语和类型之间的差距。这意味着我们使用SList 作为lookupS 的输入,而不是一个普通的列表。这不仅导致了有多个列表定义的心理开销,还导致了来回转换(toSing,fromSing )和为转换过程携带证据(SingKind constraint)的运行时间开销。

不好的人机工程学只是问题的一部分。另一部分是这些都不能可靠地工作。例如,我早在2016年就报告了Trac #12564,还有大约同一时期的Trac #12088--当涉及到比教科书上的例子更高级的东西时,如列表查找,这两个问题都带来了重大麻烦。这些GHC的bug仍然没有被修复,我认为原因是GHC的开发者根本没有足够的时间,忙于其他问题。积极从事GHC工作的人出乎意料的少,有些领域注定是无人问津的。

总结

我之前说过,我们正处于一个 "二选一 "的局面,所以这里有一个表格来说明我的意思。

StandardHaskellAgdaHaskellExtended
运行时
性能
✔️✔️

构建的
正确性
✔️✔️
人体工程学和
可维护性
✔️✔️

光明的未来

Haskell bright future 在我们的三个选项中,每个选项都有一个缺陷。然而,我们可以修复它们。

  • 我们可以采用Standard Haskell并直接用依赖类型来扩展它,而不是通过singletons 来进行尴尬的编码。(说起来容易做起来难)。
  • 我们可以采用Agda,并为它实现一个高效的代码生成器和RTS。(说起来容易做起来难)。
  • 我们可以采用Haskell扩展,修复它的错误,并继续增加新的扩展,使常见的编程模式更符合人体工程学。(说起来容易做起来难)。

好消息是,所有这三种选择似乎都汇聚在一个点上(类似于)。想象一下对标准哈斯克尔的最整洁的扩展,它增加了依赖类型,因此有能力通过构造来编写正确的代码。Agda可以被编译(transpiled)为这种语言,而不需要unsafeCoerce 。从某种意义上说,Haskell扩展是这种语言的一个未完成的原型--我们需要加强一些东西,摒弃一些东西,但最终我们会到达理想的目的地。

拆除singletons

进展的一个很好的迹象是,从singletons 。一点一点地,我们将使这个包成为多余的。例如,在2016年,我利用-XTypeInType 的扩展,从SingKindSomeSing删除了KProxy。这一改变是由于一个重要的里程碑--类型和种类的统一而实现的。比较新旧定义。

class (kparam ~ 'KProxy) => SingKind (kparam :: KProxy k) where
  type DemoteRep kparam :: *
  fromSing :: SingKind (a :: k) -> DemoteRep kparam
  toSing :: DemoteRep kparam -> SomeSing kparam

type Demote (a :: k) = DemoteRep ('KProxy :: KProxy k)

data SomeSing (kproxy :: KProxy k) where
  SomeSing :: Sing (a :: k) -> SomeSing ('KProxy :: KProxy k)

在旧的定义中,k 只出现在种类的位置上,在种类注释的右侧t :: k 。我们用kparam :: KProxy k 来携带类型中的k

class SingKind k where
  type DemoteRep k :: *
  fromSing :: SingKind (a :: k) -> DemoteRep k
  toSing :: DemoteRep k -> SomeSing k

type Demote (a :: k) = DemoteRep k

data SomeSing k where
  SomeSing :: Sing (a :: k) -> SomeSing k

在新的定义中,k 在种类和类型位置之间自由穿梭,所以我们不再需要种类代理。原因是,从GHC 8.0开始,类型和种类是同一个语法类别。

在标准Haskell中,我们有三个完全独立的世界:术语、类型和种类。如果我们看一下GHC 7.10的源代码,我们可以看到一个专门的类型解析器,和一个专门的检查器。在GHC 8.0中,这些都消失了:类型和种类共享同一个解析器检查器

在Haskell扩展中,作为一个种类只是一个类型所履行的角色。

f :: T z -> ...               -- 'z' is a type
g :: T (a :: z) -> ...        -- 'z' is a kind
h :: T z -> T (a :: z) -> ... -- 'z' is both

不幸的是,这只有97%的正确性。在GHC 8.0-8.4中,在类型和种类位置上的类型的重命名和范围规则仍然存在差异。不过,这些过去的遗留物正在被清除。在GHC 8.6中,我通过添加StarIsType 扩展和将TypeInType 的功能合并到PolyKinds统一了类型和种类的重命名器。有一个被接受的建议是统一范围规则,但它的实施计划在GHC 8.10中进行,以符合3发布政策。我很期待这个变化,我在GHC 8.6中加入了一个警告。 -Wimplicit-kind-vars,今天你可以用它来编写向前兼容的代码。

再往前走几个GHC版本,到那时,类型就是类型,没有任何附加条件。下一步是什么呢?让我们简单看一下SingKind ,在最新版本的singletons

class SingKind k where
  type Demote k = (r :: Type) | r -> k
  fromSing :: Sing (a :: k) -> Demote k
  toSing   :: Demote k -> SomeSing k

Demote 类型族是用来解释类型关系a :: t 和种类关系t :: k 之间的差异的。大多数时候(对于ADTs),Demote 是身份函数。

  • type Demote Bool = Bool
  • type Demote [a] = [Demote a]
  • type Demote (Either a b) = Either (Demote a) (Demote b)

因此,Demote (Either [Bool] Bool) = Either [Bool] Bool 。这一观察促使我们做出如下简化。

class SingKind k where
  fromSing :: Sing (a :: k) -> k
  toSing   :: k -> SomeSing k

看,妈,没有Demote!而且,事实上,这对于Either [Bool] Bool ,以及其他ADT来说都是可行的。但在实践中,除了ADTs,还有其他类型。Integer,Natural,Char,Text, 等等。当作为类型使用时,它们并不是居住的:1 :: Natural 在术语中是真的,但在类型中不是。这就是为什么我们有这些不幸的线条。

type Demote Nat = Natural
type Demote Symbol = Text

解决这个问题的方法是推广所有的原始类型。例如,Text 被定义为。

-- | A space efficient, packed, unboxed Unicode text type.
data Text = Text
    {-# UNPACK #-} !Array -- payload (Word16 elements)
    {-# UNPACK #-} !Int   -- offset (units of Word16, not Char)
    {-# UNPACK #-} !Int   -- length (units of Word16, not Char)

data Array = Array ByteArray#
data Int = I# Int#

如果我们适当地提升ByteArray#Int# ,那么我们就可以用Text 代替Symbol 。对Natural 和也许其他几个类型做同样的事情,我们就可以摆脱Demote ,--对吗?

并非如此。我一直在忽略房间里的大象:函数。它们也有一个特殊的Demote 实例。

type Demote (k1 ~> k2) = Demote k1 -> Demote k2

type a ~> b = TyFun a b -> Type
data TyFun :: Type -> Type -> Type

~> 类型是singletons 用于类型级函数的表示,基于封闭的类型族和去功能化

最初的直觉可能是将~>-> 统一起来,因为两者都代表函数的类型(种类)。这里的问题是,在类型位置的(->) 和在种类位置的(->) 意味着不同的事情。在术语中,从ab 的所有函数都有类型a -> b 。另一方面,在类型中,只有从ab构造函数具有类型a -> b ,而不是类型同义词或类型族。由于类型推理的原因,GHC假定f a ~ g b 意味着f ~ ga ~ b ,这对构造函数来说是成立的,但对函数来说不成立,因此有这个限制。

因此,为了提升函数但保持类型推理,我们将不得不把构造函数移到它们自己的类型中,我们称之为a :-> b ,它确实具有这样的属性:f a ~ g b 隐含f ~ ga ~ b ,而所有其他函数将继续属于a -> b 。例如,Just :: a :-> Maybe a ,但isJust :: Maybe a -> Bool

一旦处理了Demote ,最后一英里就是消除对Sing 本身的需求。为此,我们将需要一个新的量词,一个介于forall-> 之间的混合量词。让我们仔细看看isJust 函数。

isJust :: forall a. Maybe a -> Bool
isJust =
  \x ->
    case x of
      Nothing -> False
      Just _  -> True

isJust 函数是由一个类型a ,然后是一个值x :: Maybe a 。这两个参数表现出不同的属性:

  • 可见性:当我们调用isJust (Just "hello") ,我们明显地传递了x = Just "hello" ,但a = String 是由编译器推断出来的。在现代的Haskell中,我们也可以使用可见性覆盖,并以可见的方式传递两个参数:isJust @String (Just "hello")

  • 相关性:我们作为输入传递给isJust 的值在运行时确实会被传递,因为我们需要用case 来匹配它,看它是Nothing 还是Just 。因此它被认为是相关的。另一方面,类型本身被抹去,不能被匹配:函数将统一处理Maybe Int,Maybe String,Maybe Bool, 等等--因此,它被认为是无关的。这个属性也被称为参数性。

  • 依赖性:在forall a. t ,类型t 可能会提到a ,因此,依赖于被传递的具体a 。例如,isJust @String 有类型Maybe String -> Bool ,但isJust @Int 有类型Maybe Int -> Bool 。这意味着,forall 是一个依赖性量词。与价值参数形成对比:不管我们是调用isJust Nothing 还是isJust (Just ...) ,结果总是Bool 。因此,-> 不是一个从属量词。

为了归入Sing ,我们需要一个可见和相关的量词,就像a -> b ,但又是依赖性的,就像forall (a :: k). t 。我们将把它写成foreach (a :: k) -> t 。为了归入SingI ,我们还将引入一个不可见的相关从属量词,即foreach (a :: k). t 。在这一点上,我们不再需要singletons ,因为我们刚刚在语言中引入了从属函数。

从属Haskell的一瞥

通过对函数和foreach 量词的推广,我们将能够重写lookupS 如下:

data N = Z | S N

len :: [a] -> N
len [] = Z
len (_:xs) = S (len xs)

data Fin n where
  FZ :: Fin (S n)
  FS :: Fin n -> Fin (S n)

lookupS :: foreach (xs :: [a]) -> Fin (len xs) -> a
lookupS [] = \case{}
lookupS (x:xs) =
  \case
    FZ -> x
    FS i' -> lookupS xs i'

这段代码并不短--毕竟,singletons 在隐藏模板方面做得很好。然而,它要干净得多:没有Demote,SingKind,SList,SNil,SCons,fromSing 。没有使用TemplateHaskell ,因为我们现在可以直接使用len 函数而不是生成type family Len 。运行时的性能也会更好,因为我们不再需要fromSing 的转换。

我们仍然不得不将length 重新定义为len ,以返回归纳定义的N ,而不是Int 。也许最好将这个问题声明在Dependent Haskell的范围之外,因为Agda的lookup 函数也是使用归纳定义的N

Dependent Haskell在某些方面比Standard Haskell更简单。毕竟,它将术语、类型和种类的世界合并到一个单一的、统一的语言中。我可以很容易地想象在生产代码库中使用这种风格的代码来认证应用程序的关键组件。许多库将能够提供更安全的API,而没有singletons 的复杂性成本。

要达到这个目标并不容易。在所有的GHC组件中,有许多工程上的挑战摆在我们面前:解析器、重命名器、类型检查器,甚至是核心语言,都需要进行非微不足道的修改,甚至是重新设计。最近,我的重点是解析,我将在未来几周内发布一个相当大的差异。

请继续关注未来的文章,我将深入研究我正在解决的问题的技术细节,这是我在Serokell的GHC工作的一部分,以使依赖类型的编程实用化。