Haskell声明的依赖性分析
大多数语言要求程序员在使用函数和数据类型之前声明它们。例如,下面的代码就不是一个有效的C或C++程序。
int main() {
f(); // error: use of undeclared identifier 'f'
}
void f(); // ... even though it’s declared down here.
有这种限制的语言包括Python、JavaScript、Rust,甚至还有一些函数式语言,例如OCaml。
Haskell是为数不多的可以让你按照自己的想法来构造程序的语言之一。函数和数据类型可以以任意的顺序进行声明和使用。
main = putStrLn str
str = "Hello, World"
不过,不按依赖顺序的声明对编译器是一个挑战。在不知道str 的类型的情况下,GHC如何知道main 中的putStrLn str 是否具有良好的类型?它需要首先检查str ,尽管它排在第二位。
这就是依赖性分析发挥作用的地方。在对一个模块进行类型检查之前,GHC重新安排了它的声明,使函数和数据类型在使用之前就被声明。然后它可以自上而下地处理它们,就像其他语言的编译器那样。
依赖性分析可以处理相当复杂的程序。
data Nat = Z | S Nat
parity :: Nat -> Parity
parity Z = Even
parity (S n) = flipP (parity n)
data Parity = Even | Odd
flipP :: Parity -> Parity
flipP Even = Odd
flipP Odd = Even
在这个例子中,Parity 和flipP 都是在声明之前使用的。但依赖性分析决定了处理的顺序应该是:Nat,Parity,flipP,parity 。
类型检查环境
为什么类型检查器期望声明是以任何特定的顺序进行的?这不仅仅是一个工程问题,它的根源在于理论。具体来说,类型理论家们经常将类型环境定义如下。
Γ::=∅∣Γ,x:τ\Gamma::=\varnothing \mid \Gamma, x : \tauΓ::= ∅∣ Γ,x:_COPYτ
如果你不喜欢数学符号和希腊字母,不用担心:这基本上意味着我们有一个有序的配对列表[(Name, Type)] 。每当我们说一个表达式是有类型的,我们就意味着它在给定的上下文中是有类型的Γ\GammaΓ。
例如,你能说x + 1 是否是类型良好的吗?如果不知道x 的类型就不能说,因此,Γ\GammaΓ的上下文必须有一个条目,如x : Int 或x : String 。换句话说,类型系统的正式定义认为,当我们对一个表达式进行类型检查时,我们已经在类型化的上下文中拥有它的所有依赖关系。
这也是GHC的运作方式:有一个类型检查环境的概念,与类型化上下文的概念相当相似。当我们对一个定义进行类型检查时,我们将它添加到环境中,所有后续的定义都可以引用它。
依赖性分析:一个鸟瞰图
依赖性分析的第一步是将类型与术语分开。目前,类型(几乎)不会依赖术语,所以类型总是在术语之前被检查。当然,如果我们想实现依赖类型,这一点在将来必须改变,但这不在本文的讨论范围之内。
- 类型级声明。
Nat(Z, S),Parity(Even, Odd) - 术语级的声明。
parity,flipP
因此,甚至在我们开始看依赖关系之前,我们就知道Nat 和Parity 将在flipP 和parity 之前被检查。
在这篇文章中,我们将主要关注类型级声明,因为这是我们有一些开放性问题和正在进行的开发的地方。但首先要对模板Haskell做一个小小的迂回,事实证明,它以一种意想不到的方式模糊了术语和类型之间的界限。
模板Haskell的拼接
上面我提到,类型永远不能依赖于术语,但有一个例外,那就是模板哈士奇的拼接。让我们看一下这个例子。
boodDesc =
[("id", [t| UUID |])
,("title", [t| Text |])
,("author", [t| Text |])
$(generateRecord "Book" 'bookDesc)
-- data Book = Book { bookId :: UUID, bookTitle :: Text, bookAuthor :: Text }
type Library = [Book]
这里我们有一个Template Haskell函数generateRecord ,它接收一个术语级的字段列表,并使用它来生成一个记录数据类型。生成的类型取决于一个术语:如果bookDesc 被改变,Book 也会改变。那么,如果术语总是在类型之后被检查,GHC是如何做到这一点的呢?
在GHC对一个模块进行任何类型检查或名称解析之前,它将该模块分割成若干组(称为HsGroup )。每个组都被Template Haskell拼接所分隔。
_(1).png)
然后这些组按照它们在源代码中出现的顺序被单独分析和进行类型检查。在一个组被类型检查后,它的所有声明被添加到类型检查环境中,然后Template Haskell代码被执行,然后GHC进入下一个组。
这种分割行为的一个副作用是,你不能引用Template Haskell拼接后的声明。
boodDesc =
[("id", [t| UUID |])
,("title", [t| Text |])
,("format", [t| FileFormat |]) -- FileFormat is not in scope
$(generateRecord "Book" 'bookDesc)
-- data Book = Book { bookId :: UUID, bookTitle :: Text, bookAuthor :: Text }
type Library = [Book]
data FileFormat = EPUB | PDF
这是因为带有bookDesc 的组将首先被完全处理,然后GHC才会转到带有FileFormat 的组。
在没有Template Haskell拼接的情况下,整个模块构成一个单一的HsGroup 。
类型层面的依赖性分析
那么在一个给定的HsGroup ,在分离了类型和术语之后,GHC是如何计算出在类型检查之前将声明放在哪个位置的顺序的?
让我们看一下这个例子,看看一步步发生了什么。
data L =
Result ListCB | Operation Op
data Op =
Reverse L | Sort L
data ListCB =
NilCB | ConsCB Char ListBC
data ListBC =
NilBC | ConsBC Bool ListCB
GHC做的第一件事是浏览每个声明,并列出声明所提到的名字。
| 声明 | 提到的名字 |
|---|---|
L | ListCB,Op |
Op | L |
ListCB | Char,ListBC |
ListBC | Bool,ListCB |
然后,GHC以声明为顶点,以提及的名字为边,建立了一个有向图。例如,我们可以看到L 提到了ListCB 和Op ,所以它与图中的这些顶点有边。
_(1).png)
在建立图之后,GHC会找到强连接的组件。一个强连接组件(简称SCC)是一个有向图的一部分,其中每个顶点都可以从其他每个顶点到达。或者,在我们的例子中,它是一个相互递归的组。你可以把寻找SCC看作是建立一个 "超级图",其中每个顶点现在都是一个相互递归的组,而边是组之间的依赖关系。因此,在上面的例子中,我们有两个相互递归的组:ListCB 与ListBC ,L 与Op 。而{L, Op} 组依赖于{ListCB, ListBC} 组。
最后,在找到SCC之后,GHC将它们按拓扑顺序排列。
-- Group 1
data ListCB = NilCB | ConsCB Char ListBC
data ListBC = NilBC | ConsBC Bool ListCB
-- Group 2
data L = Result ListCB | Operation Op
data Op = Reverse L | Sort L
类型族实例
类型族实例,与其他类型的声明不同,没有名字,也没有什么可以直接引用它们。因此它们不是图的一部分,处理方式也不同。让我们看一看:
type family PropType a
data WTitle
data WResizable
type MainWindow :: forall prop -> PropType prop
type family MainWindow prop where
MainWindow WTitle = "Text Editor"
MainWindow WResizable = True
type instance PropType WTitle = Symbol
type instance PropType WResizable = Bool
在所有的图和SCC业务完成后,我们会得到以下的组。
-- Group 1
type family PropType a
-- Group 2
data WTitle
-- Group 3
data WResizable
-- Group 4
type MainWindow :: forall prop -> PropType prop
type family MainWindow prop where
MainWindow WTitle = "Text Editor"
MainWindow WResizable = True
但是类型实例去哪里了?
对于每个类型实例,GHC也会建立它所提到的名字列表,就像它对其他类型的声明所做的那样。但是实例本身没有名字,所以它不参与拓扑排序。相反,它被添加到最早的组中,在那里它的所有依赖关系都被满足。
目标组的确定是通过逐个浏览每个组,并从实例的剩余依赖列表中删除其声明。一旦列表为空,就意味着所有的实例依赖关系都已经在当前和之前的组中被定义,所以实例可以被插入到当前组中。
| 实例 | 依赖关系 |
|---|---|
PropType WTitle | PropType,WTitle 。Symbol |
PropType WResizable | PropType,WResizable 。Bool |
所以在我们的例子中,首先Symbol 和Bool 将被从列表中删除,因为它们是导入的。这就给我们留下了以下内容。
| 实例 | 依赖性(剩余) |
|---|---|
PropType WTitle | PropType,WTitle |
PropType WResizable | PropType,WResizable |
然后GHC将处理第一组,即定义PropType 的那一组,并将PropType 从两个实例的依赖关系中删除。因此,我们有
| 实例 | 依赖关系(剩余) |
|---|---|
PropType WTitle | WTitle |
PropType WResizable | WResizable |
接下来,它将到达有WTitle 的组。WTitle 将被从依赖列表中移除,由于它是PropType WTitle 的最后一个依赖,所以该实例被插入到该组中。
-- Group 2
data WTitle
type instance PropType WTitle = Symbol -- inserted
然后同样的事情发生在下一个有PropType WResizable 的组,使我们的最终组看起来像这样。
type family PropType a
data WTitle
type instance PropType WTitle = Symbol
data WResizable
type instance PropType WResizable = Bool
type MainWindow :: forall prop -> PropType prop
type family MainWindow prop where
MainWindow WTitle = "Text Editor"
MainWindow WResizable = True
这个算法效果很好。然而,它并不是万无一失的。让我们来看看它的不足之处吧!
当前算法的不足之处
例子一:Open
type family Open a
type instance Open Bool = Nat
type instance Open Char = F Float
type instance Open Float = Type
type F :: forall a -> Open a
type family F a
type instance F Bool = 42 -- :: Open Bool (~ Nat)
type instance F Char = '[0, 1] -- :: Open Char (~ F Float ~ [Nat])
type instance F Float = [Nat] -- :: Open Float (~ Type)
这里有很多东西需要解读,我们先从Open 类型族开始。 就其核心而言,这是一个非常简单的类型族:它只是把一些Type 映射到另一些Type ,比如Open Bool 等于Nat 。Open Char 更有趣,因为它等于F Float ,所以让我们看看F 。
所以这段代码是正确的,一切都应该在这里进行类型检查。然而,如果我们试图在当前的GHC中编译这段代码,我们会得到这个错误。
• Expected kind ‘Open Char’,
but ‘'[0, 1]’ has kind ‘[Nat]’
所以GHC抱怨说Open Char 不是[Nat] ,但是我们知道它是。为什么GHC不知道呢?这是因为在当前的算法中,实例是如何分组的。
type family Open a
type instance Open Bool = Nat
type instance Open Float = Type
type F :: forall a -> Open a
type family F a
type instance Open Char = F Float
type instance F Bool = 42
type instance F Char = '[0, 1]
type instance F Float = [Nat]
请注意,所有F的实例以及Open Char 最终都在同一个组中。这是一个问题,因为要对F Char 进行类型检查,我们需要知道Open Char 和F Float 减少到什么,或者换句话说,这些实例应该已经在类型检查环境中了。但是由于它们是同一个相互递归组的一部分,它们同时被检查,而它们都还没有进入类型检查环境。
例子二:IxKind
为了更好地展示这个问题,我们来看看另一个表现出同样问题的例子。
type family IxKind (m :: Type) :: Type
type family Value (m :: Type) :: IxKind m -> Type
data T (k :: Type) (f :: k -> Type) = MkT
type instance IxKind (T k f) = k
type instance Value (T k f) = f
这里我们有两个类型族。
IxKind是相当简单的,只是把一个Type映射到另一个Type上。Value接受某个类型的m,并返回一个从IxKind m到其他类型的函数。
然后有一个数据类型T ,它的第二个参数的种类取决于第一个参数。
现在为了暴露这个问题,让我们在Value (T k f) 实例中明确写出种类。
type instance Value (T k f) = f -- :: k -> Type, from the signature of T
-- Value (T k f) :: IxKind (T k f) -> Type, from the signature of Value
所以我们可以看到,我们应该返回IxKind (T k f) -> Type ,但是我们返回的是k -> Type 。所以为了检查这个类型,我们需要知道k ~ IxKind (T k f) 。如果我们看一下IxKind (T k f) 实例,我们会发现确实是这样的。但是为了利用这些信息,IxKind 实例必须在 Value 实例之前被添加到类型检查环境中。不幸的是,它们最终被放在了同一组中。
type family IxKind (m :: Type) :: Type
type family Value (m :: Type) :: IxKind m -> Type
data T (k :: Type) (f :: k -> Type) = MkT
type instance IxKind (T k f) = k
type instance Value (T k f) = f
目前,还没有一种机制可以让依赖性分析发现Value 实例依赖于IxKind 实例。
其他语言是如何解决这个问题的?
在这一点上,人们可能会想:我们有这些先进的语言,如Idris或Agda,它们已经是依赖类型的。他们是如何解决这个问题的呢?好吧,事实是他们根本就没有这些问题,因为他们只是像其他语言一样按照书面顺序检查声明。
这在GHC中可以做到吗?嗯,可以,但实际上不可以。按照声明的书写顺序进行检查会破坏很多现有的代码,而且会偏离标准。但是,也许我们可以有一个扩展,让我们有 "有序 "的代码块(有点像Idris的mutual 关键字的反义词)?我们可以,但这种方法被认为是非Haskelly的。
TH拼接的变通方法
如果你还记得前面的内容,Template Haskell拼接将代码分成几组,严格按照顺序检查。我们可以利用这个优势来实现今天的非Haskelly方法。
type family Open a
type instance Open Bool = Nat
type instance Open Float = Type
$(return [])
type F :: forall a -> Open a
type family F a
type instance Open Char = F Float
type instance F Bool = 42
type instance F Float = [Nat]
$(return [])
type instance F Char = '[0, 1]
在这里,通过正确地分割组,我们迫使Open Char 和F Float 在GHC对F Char 进行类型检查之前被检查,从而使这段代码被编译。
但这是很笨拙的。那么,我们怎样才能改进当前的算法,使我们不必这样做呢?
:sig 和:def 符号
很多时候,依赖整个声明是一种太强的依赖。例如,为了对data T = MkT Bool 进行类型检查,我们需要知道Bool的种类(也就是Type ),但其他的就不需要了。我们并不关心Bool 有哪些构造函数或其他关于它的东西。所以实际上,这个声明应该只依赖于Bool 的签名,而不应该依赖于它的定义。
介绍一下:sig 和:def 符号,分别是签名和定义的缩写。你可以把签名看作是声明的左手,把定义看作是声明的右手。
data Bool = True | False
-- ^^^^ ^^^^^^^^^^^^
-- Bool:sig Bool:def
type family F a; type instance F Int = Bool;
type instance F Bool = Int;
-- ^^^^^ ^^^^^^^^^^^^
-- F:sig F:def
对于开放的类型家族,所有的实例都被认为是同一个:def 块的一部分,并被归为一组。我们的有向图将有两种类型的顶点::sigs和:defs,而不是将声明作为顶点,现在我们有不同类型的顶点,我们需要新的规则来推断边(依赖关系):
- 提到一个类型级事物的名字,就会给该事物的
:sig,增加一个依赖关系。例如,上面的F:def依赖于Bool:sig - 每个
:def都依赖于其相应的:sig。 - 提到一个被提升的数据构造函数的名字,就会增加对该数据构造函数的父级
:def的依赖性。例如,提到'False,就会增加对Bool:def的依赖关系。 - 如果一个数据类型没有明确的种类,就从它的
:sig,添加一个依赖关系到它的 。:def
为什么我们需要最后一条规则?让我们看一下这个简单的数据类型。
data T a = MkT a
现在,如果我们在这里将:sig 与:def 分开,可能会发生GHC在对:def 进行类型检查之前对:sig 进行类型检查。让我们看看那时会发生什么。
type T a -- GHC will infer the most general type here:
-- T a :: forall k. k -> Type
然后它就会对:def 进行类型检查。
data T a = MkT a
这就是不匹配发生的地方,定义希望a 有种类Type ,因为数据构造器中只能有种类Type 的东西,但是GHC之前推断出a 是种类多态的。
因此,除非用户提供一个明确的种类签名,否则我们不能将:sig 与:def 分开,因为这可能会干扰种类推理。为了解决这个问题,我们在这种情况下从:sig 到:def 添加一条额外的边,使它们成为一个相互递归的组,这保证了它们会被一起检查。
修复IxKind 的例子
现在让我们看看我们前面的例子中的IxKind 的新图是什么样子的。
type family IxKind (m :: Type) :: Type
-- ^^^^^^^^^^^^^^^^^^^^^^^^^^
-- IxKind:sig
type family Value (m :: Type) :: IxKind m -> Type
-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
-- Value:sig
data T (k :: Type) (f :: k -> Type) = MkT
-- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ^^^^^
-- T:sig T:def
type instance IxKind (T k f) = k
-- ^^^^^^^^^^^^^^^^^^
-- IxKind:def
type instance Value (T k f) = f
-- ^^^^^^^^^^^^^^^^^
-- Value:def
_(1).png)
看起来很酷但如果你仔细观察,这并没有解决我们原来的问题。我们原来的问题是,我们真的想在键入检查Value (T k f) 之前先键入检查IxKind (T k f) ,但在这个图上它们之间没有任何依赖关系!这就是我们的问题。
但是与我们之前的算法相比,我们现在至少可以在图上指出这种依赖关系。我们现在需要做的就是想出一个规则,为我们推断出这种依赖关系。
最早提出的规则之一是 "对于从一个:sig 到另一个:sig 的每一个依赖关系,也要在相应的:defs 之间添加一个依赖关系"。这在这种情况下是可行的,但有时这条规则会推断出不必要的依赖关系,有时它又不能推断出足够的依赖关系。
在这一点上,使用哪一套确切的规则在某种程度上仍然是一个开放的研究问题。还提出了一些其他的规则,但我们还没有尝试,因为手头有更紧迫的问题。
哎呀!都慌了!
这个新算法的本质是将签名与定义分开。让我们来看看这段代码。
type T :: Type
data E = MkE T
data T
在建立图、寻找SCC等之后,我们会得到以下的组。
type T :: Type
data E = MkE T
data T
请注意我们在输入检查了T的签名之后但在其定义之前在E 中使用了T 。理论上,这应该是足够的,然而,类型检查器对环境中哪些是可用的,哪些是不可用的做了很多假设,它不能处理这样的情况,导致崩溃。
依赖性分析的工作目前被类型检查器的崩溃问题所阻挡,事实证明,解决这个问题需要对类型检查器的代码库进行一些重大的重构,目前这项工作正在进行中。