Haskell 中为什么必须匹配 MkDoor 才能取得类型约束?
结论
匹配 MkDoor 会让参数携带的 SDoorStateI s 约束证据在分支中可用;_ 则直接忽略参数,不会取得这份证据。即使 Door s 只有一个构造器,两种模式的求值行为仍然不同。
因此,GHC 不能无条件地将 _ 替换为 MkDoor,并认为程序含义不变。要设计自动提取约束的语言扩展,还需要另行规定类型规则和求值规则;不能简单地把这里的限制归结为编译器不够聪明。
MkDoor 携带了什么
构造器签名中包含一个约束:
data Door (s :: DoorState) where
MkDoor :: SDoorStateI s => Door s
构造 MkDoor :: Door s 时,需要提供 SDoorStateI s 的证据。可以用类型类字典来理解:这个值在概念上包装了一份支持相应类型类操作的字典,虽然源码中没有显式的字段参数。
匹配 MkDoor 后,这份字典就在匹配分支中可用。这是构造器约束的既定规则,见 GHC 关于构造器签名及约束的说明。
因此,下面的函数体可以调用需要 SDoorStateI s 约束的 sDoorState:
doorState :: forall s. Door s -> DoorState
doorState MkDoor =
case sDoorState :: SDoorState s of
SOpened -> Opened
SClosed -> Closed
这里没有创建新的类型类实例。匹配提供的是已有实例的局部证据。
如果改用 _,函数签名本身没有要求 SDoorStateI s,函数体也没有通过匹配取得该约束,因此缺少调用 sDoorState 所需的字典。
唯一构造器为什么仍然不够
“只有一个构造器”表示,如果参数求值得到一个外层构造器,那就只能是 MkDoor。这并不保证参数一定能求值得到构造器。
Haskell 允许下面这样的表达式:
undefined :: Door s
它具有 Door s 类型,但不会求值得到 MkDoor。对它求值会触发异常;无限递归则可能一直无法得到结果。这些情况通常用底值 ⊥ 表示。
通配符 _ 无需求值参数就能匹配成功;匹配由 data 定义的 MkDoor 则要求参数求值到外层构造器,也就是弱头范式(WHNF)。Haskell Report 的模式匹配规则区分了这两类行为。
沿用原有的 Door 定义,可以直接看出区别:
ignoreDoor :: Door s -> ()
ignoreDoor _ = ()
inspectDoor :: Door s -> ()
inspectDoor MkDoor = ()
这两个函数对 undefined 的处理不同:
ignoreDoor undefined -- 返回 ()
inspectDoor undefined -- 求值参数,触发 undefined 异常
即使函数体完全不用类型类方法,把 _ 替换为 MkDoor 也可能让原本能返回结果的调用变成异常或不终止。
自动替换是否必然导致类型不安全
这里需要区分两件事:
- 插入
MkDoor匹配后,在分支内使用它提供的约束,可以符合类型规则。 - 将这种插入作为保持原程序行为的通用转换则不成立,因为它可能改变严格性和终止行为。
上面的反例说明了语义差异,并不意味着“任何自动提取字典的设计都会导致类型不安全”。对于原先无法通过类型检查的 doorState _,也不能直接拿它与某个既有运行结果比较。
按照当前规则,_ 不引入构造器约束。如果希望它在某些情况下自动提取证据,就需要增加明确的语言规则,不能只依赖“构造器唯一”这一判断。
应该怎样写
最直接的写法是保留 MkDoor。如果想先给参数命名,可以把匹配移到函数体中:
doorState :: forall s. Door s -> DoorState
doorState door =
case door of
MkDoor ->
case sDoorState :: SDoorState s of
SOpened -> Opened
SClosed -> Closed
如果设计上希望由调用方直接提供约束,也可以调整签名:
doorState :: forall s. SDoorStateI s => Door s -> DoorState
doorState _ =
case sDoorState :: SDoorState s of
SOpened -> Opened
SClosed -> Closed
这个版本中的字典来自调用方,函数无需从 Door s 参数中取出它。调用方必须能够满足 SDoorStateI s,而函数不再需要求值这个被忽略的参数。因此,它与原版本的接口要求和求值行为都有区别。
这些片段沿用问题中的类型、类型类和实例定义。要让显式 forall s 中的 s 作用于函数体内的类型注解,还需要相应的 ScopedTypeVariables 支持。
备注:内容仅供参考。
