为什么要检查()类型的值?

5
在Haskell中,()类型有两个值,分别为()和底部。如果您有一个表达式e :: (),实际上没有检查它的必要,因为它要么是e = (),要么通过检查它,您会崩溃一个本来不会崩溃的程序。
因此,我认为对于()类型的值的操作不会检查该值,并且不会区分()和底部之间的差异。
然而,这是完全不正确的:
▎λ ghci
GHCi, version 9.0.2: https://www.haskell.org/ghc/  :? for help
ghci> u = (undefined :: ())
ghci> show u
"*** Exception: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:75:14 in base:GHC.Err
  undefined, called at <interactive>:1:6 in interactive:Ghci1
ghci> () == u
*** Exception: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:75:14 in base:GHC.Err
  undefined, called at <interactive>:1:6 in interactive:Ghci1
ghci> f () = "ok"
ghci> f u
"*** Exception: Prelude.undefined
CallStack (from HasCallStack):
  error, called at libraries/base/GHC/Err.hs:75:14 in base:GHC.Err
  undefined, called at <interactive>:1:6 in interactive:Ghci1

这是什么原因呢?以下是一些猜测:
  1. 出于某种我想不到的原因,在 () 上不懒惰可能是有用的。有时我们想要让底部传播。

  2. Haskell 的语义是以这样的方式编写的:解构任何 ADT,甚至是微不足道的 ADT,都会检查它们。这意味着,使用 case (undefined :: ()) of { () -> ... } 不会抛出异常,这将违反语言语义

  3. () 是一种极其特殊的情况,在像 Haskell 这样的大型语言中,没有必要关注如何挤出这个微小的额外的安全性

  4. 还有一个可能的组合解释,即 Haskell 可能已经有了规定表达式 case e of 在检查除类型为 () 之外的 e 时的语义,但这可能会污染相对较低的收益的语言规范


3
我会称之为“奇怪的不一致性”,而不是“额外的安全措施”。改变程序中的变量数量,即使看起来应该保持原有行为,也可能会改变程序是否终止或产生错误。 - Ry-
我称之为“额外的安全性”,因为在(几乎?)每种情况下,避免可选底部是可取的。有时额外的底部意味着更好的性能(比如BangPatterns),但据我所知,在这种情况下并非如此。 - Quelklef
7
通过使其不可能来避免错误是很好的,但通过忽视导致错误条件的错误条件来避免错误则与安全相反。 - Ry-
即,类型为 () 的底部几乎总是程序员错误的指示,因此不检查它将违反“快速失败”的原则。这是一个有趣的观点。 - Quelklef
是的。"或者通过检查它,你会让一个本来不会崩溃的程序崩溃。" 如果有原因导致程序崩溃,我更希望我的程序崩溃。 - Louis Wasserman
3个回答

6
我将处理这一部分:

出于我无法想出的某种原因,对 () 进行非惰性计算是有用的。有时我们希望底部能够传播。

让我们来看看 Control.Parallel.Strategies(一个旧版本的第 1 版)。这是一个并行评估模块。为了简单起见,让我们专注于其一个函数:
parMap :: Strategy b -> (a -> b) -> [a] -> [b]
parMap strat f xs 的结果和 map f xs 相同,只是列表是并行计算的。什么是 strat 参数呢?
strat :: Strategy b

means

strat :: b -> ()

strat只有两种用法:

  • 调用它并忽略结果,这等于根本没有调用它;
  • 调用它并强制得到结果,即使你知道它是()或一个底部值。

parMap在并行处理中实现了后一种用法。这使得调用者可以指定一个strat参数,按需评估类型为b的列表值。例如:

parMap (\(x,y) -> ()) f xs
parMap (\(x,y) -> x `seq` ()) f xs
parMap (\(x,y) -> x `seq` y `seq` ()) f xs

以下两个调用是有效的,将导致parMap仅评估新的成对列表以公开成对构造函数、第一个组件和第二个组件。

因此,在这种情况下强制执行strat()结果允许用户控制在parMap期间执行多少评估,即在并行计算中强制执行结果的程度以及因此应该留下哪些部分未被评估。(相比之下,map f xs将完全保持未评估状态 -- 它完全是惰性的。否则parMap不能这样做,否则就不再是并行的了。)


小插曲:注意GADT

data a :~: b where
    Refl :: t :~: t

有一个构造函数,如()。在这里,强制使用此类值是必需的,例如:

foo :: Int :~: String -> Int -> String
foo Refl x = x ++ " hello"

这里的第一个参数必须是一个底部值。通过强制要求,我们使该函数在出现异常时报错。如果我们没有强制要求,就会出现类似于C和C++中那样的非常严重的未定义行为,完全破坏类型安全。Haskell将正确地拒绝任何试图规避此限制的尝试:

foo :: Int :~: String -> Int -> String
foo _ x = x ++ " hello"

在编译时会触发类型错误。


1
换句话说,我认为“类型t〜()越懒惰越好”的假设是完全错误的。感谢您提供的精彩答案! - Quelklef

5

我不确定,但我怀疑它与你所说的事情都无关。相反,这是为了使语言更加可预测和一致。

基本上,你观察到了两件事情,我认为它们是不同的。第一件事是使用 case 语句检查 x 是否确实为 () 强制评估 x;第二件事是使用 case 语句编写 ShowEq 的实例。

  • Pattern matching: the predictable, consistent rule here is that if you write case <e0> of <pat> -> <e1>, then e0 is evaluated far enough to check whether the constructors in pat are in fact in the given places. Well, okay, there's some wrinkles here to do with irrefutable patterns; let's say instead that e0 is evaluated far enough to check whether pat actually does match! For the () type, that means that the pattern () causes full evaluation -- because you've specified the full value that you expect it to be -- while the pattern x or _ can match without further evaluation.

  • Class instances: the natural inductive way to specify what the various class instances do is to always have an outermost case that matches against each available constructor with simple variable patterns for the fields, then does something (presumably recursive calls) on each of the fields in turn. That is, simplifying a bit, the show implementation goes like:

    show x = case x of
        <Con0> field00 field01 field02 <...> -> "<Con0>"
            ++ " " ++ show field00
            ++ " " ++ show field01
            ++ " " ++ show field02
            ++ <...>
        <Con1> field10 field11 field12 <...> -> "<Con1>"
            ++ " " ++ show field10
            ++ " " ++ show field11
            ++ " " ++ show field12
            ++ <...>
        <...>  
    

    It is very natural for the specialization of this scheme to the single-constructor, zero-field type () to go:

    show x = case x of
        () -> "()"
    

    (Additionally, the Report specifies that (==) is is always strict in both arguments; but that property would also arise naturally from the obvious way of writing a generic Eq instance derivation algorithm.) Therefore the path of least surprise is for class instances to pattern match on their argument(s).


这与#2不同吗? - FrownyFrog
1
@FrownyFrog 我认为我的第一个要点与#2有关,但它更多地探讨了为什么这是一个好选择,而不是语言如何工作的平面陈述。(我认为我的第二个要点与#2无关。) - Daniel Wagner
我选择接受其他答案,因为它给出了具体的例子,说明这种规律在现实世界中确实非常有用。然而,我想承认这是一个非常好的答案,两个答案真的互相补充! - Quelklef

1

#2 绝对是正确的。

() 类型只是一个带有特殊类型/数据构造函数语法的零元 data 类型:

 data () = ()

作为结果,尽管只提供非正式的语义,在 Haskell 2010 报告中在“3.17.2 模式匹配的非正式语义”一节中,明确指出以下表达式:

代码段


case undefined of () -> "ack!"

根据规则 #5,将按如下方式对其进行评估:

匹配模式 con pat1 … patn 与值的相符,其中 con 是由 data 定义的构造函数,这取决于该值:

  • 如果该值为 con v1… vn 的形式,则子模式从左到右匹配数据值的组件; 如果所有匹配成功,则整体匹配成功;第一个失败或发散导致整体匹配失败或发散。
  • 如果该值为 con′ v1… vm 的形式,其中 con 是与 con′ 不同的构造函数,则匹配失败。
  • 如果该值为 ⊥,则匹配分歧。

在这里,undefined 的值为 ⊥,因此应用第三个要点,匹配分歧。如果匹配分歧,则程序分歧,如果程序分歧,必须以错误终止或者最坏的情况是永久循环。它无法像什么都没发生一样继续执行。诚然,尽管没有明确说明,但对于表达式发散评估的语义来说,这是唯一合理的解释。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接