PAYATHON 2026

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 支持。

备注:内容仅供参考。