为什么编译器不能在Haskell中为我们处理newtype?

33

我理解newtype在编译时会擦除类型构造器,作为一种优化方式,因此newtype Foo = Foo Int最终会得到一个Int。换句话说,我的问题不是这个问题。我的问题是关于为什么编译器不能在看到单值data构造器时自动应用这种优化。当我使用hlint时,它很聪明地告诉我单值data构造器应该是newtype。(我从不犯这种错误,但试了一下想看看会发生什么。我的怀疑得到了证实。)

一个反对意见可能是,如果没有newtype,我们就不能使用GeneralizedNewTypeDeriving和其他类似的扩展。但这很容易解决。如果我们说...

data Foo m a b = Foo a (m b) deriving (Functor, Applicative, Monad)

编译器可以直接报错告诉我们我们的愚蠢。

当编译器总是可以自己找出类型时,为什么还需要newtype呢?


1
我个人的猜测是newtype具有语义价值。它告诉我们开发者的意图:“我想要包装这种类型,也许隐藏一些功能,并以某种其他方式增强它。” - Gregory Higley
1
newtype 稍微减少了惰性,这有时是一种期望的效果,因为它没有真正的数据构造函数。 - Willem Van Onsem
好的。就像我说的,我理解它的作用。我不明白的是为什么编译器不能自己判断何时应用这种优化。 - Gregory Higley
换句话说,如果我说 data Foo a = Foo a,编译器就知道它可以将其转换为 newtype Foo a = Foo a。对于单值数据构造函数,它始终可以这样做,那么为什么不将其隐式化并摆脱 newtype 呢? - Gregory Higley
9
降低懒惰本身并不是一个期望的副作用,这完全取决于具体应用场景。 - Willem Van Onsem
3个回答

32

看起来 newtype 最初主要是程序员提供的注释,用于执行编译器无法自行解决的优化,有点像 C 语言中的 register 关键字。

然而,在 Haskell 中,newtype 不仅仅是编译器的建议注释;它实际上具有语义后果。这些类型:

newtype Foo = Foo Int
data Bar = Bar Int

声明两个非同构类型。具体来说,Foo undefinedundefined :: Foo是等效的,而Bar undefinedundefined :: Bar不是,因此:

Foo undefined `seq` "not okay"    -- is an exception
Bar undefined `seq` "okay"        -- is "okay"

case undefined of Foo n -> "okay"       -- is okay
case undefined of Bar n -> "not okay"   -- is an exception

正如其他人所指出的,如果你将data字段设置为严格模式:

data Baz = Baz !Int

并且要确保只使用不可辩驳的模式匹配,那么Baznewtype Foo表现一致:

Baz undefined `seq` "not okay"         -- exception, like Foo
case undefined of ~(Baz n) -> "okay"   -- is "okay", like Foo
换句话说,如果我奶奶有轮子,她就会成为一辆自行车!所以,为什么编译器不能在看到单值数据构造函数时自动应用这种优化呢?嗯,它不能在不改变程序语义的情况下普遍地执行此优化,因此需要首先证明在将特定的任意一个构造函数、一个字段的“data”类型强制为其字段,并对其进行无可辩驳的匹配而不是严格匹配时,语义未发生变化。由于这取决于实际使用类型的值的方式,因此对于由模块导出的数据类型(尤其是在函数调用边界处),这可能很难做到,但现有的优化机制(如专门化、内联、严格性分析和取消装箱)通常会在自包含代码块中执行等效优化,因此即使您意外使用了“data”类型,也可能获得“newtype”的好处。尽管如此,总的来说,似乎这对编译器来说是太困难的问题,因此记住要使用“newtype”的负担留给了程序员。 这带来了一个显而易见的问题——为什么我们不能更改语义,使它们等效;为什么首先“newtype”和“data”的语义不同呢?嗯,“newtype”语义的原因似乎很明显。由于“newtype”优化(在编译时擦除类型和构造函数)的性质,它变得不可能——或至少极其困难——在编译时分别表示“Foo undefined”和“undefined :: Foo”,这解释了这两个值的等价性。因此,在只有一个可能的构造函数并且没有可能出现该构造函数不存在的情况下(或者至少不能区分构造函数的存在和不存在的情况,因为唯一可能发生这种情况的情况是区分“Foo undefined”和“undefined :: Foo”,而我们已经说过编译代码中无法区分它们),无可辩驳的匹配是一个显然的进一步优化。 对于一个构造函数、一个字段的“data”类型的语义(在没有严格性注释和无可辩驳匹配的情况下),原因可能不太明显。但是,这些语义与构造函数和/或字段计数其他于1的数据类型是完全一致的,而“newtype”语义则会在一种特殊情况下引入任意的不一致性,即一个“data”类型和所有其他类型之间。由于“data”和“newtype”类型之间的这种历史差异,许多后续扩展都对它们进行了不同的处理,进一步巩固了不同的语义。您提到的“GeneralizedNewTypeDeriving”适用于“newtype”但不适用于一个构造函数、一个字段的“data”类型。在用于安全强制转换(即“Data.Coerce”)和“DerivingVia”计算表示上也有进一步的差异,存在量化或更一般的GADT的使用,“UNPACK”命令等。通用的表示类型在泛型中的表示方式也存在一些差异,虽然现在我认真看它们时,它们似乎相当表面。 即使“newtype”是一个不必要的历史错误,本质上可以通过特殊处理某个“data”类型来替代它,现在也有点晚了。此外,“newtype”并没有让

5
我认为这并不像你所说的那么难。data Bar a = Bar !a 可以翻译成 newtype Bar a = Bar_ a,使用 pattern Bar :: a -> Bar a; pattern Bar a <- Bar_ !a where Bar = Bar_。显然,模式同义词是新出现的,但编译器一直在对工作包装器执行类似的技巧。 - dfeuer

19

这是一个非常好的问题。从语义角度来看,

newtype Foo = Foo Int

等同于

data Foo' = Foo !Int

除了前者的模式匹配是惰性的,后者的模式匹配是严格的之外,它们在编译器中可以被编译成相同的形式,并调整模式匹配的编译以保持语义正确。

对于像您描述的类型来说,在实践中,这种优化并不是非常关键,因为用户可以使用newtype,并根据需要添加seq或bang patterns。它会变得更加有用的地方是存在量化类型和GADTs。也就是说,我们希望获得更紧凑的类型表示:

data Baz a b where
  Baz :: !a -> Baz a Bool

data Quux where
  Quux :: !a -> Quux

不过GHC目前并未提供此类优化,而在这些情况下进行此类优化会更加棘手。


10
当编译器可以自己解决问题时,为什么我们需要使用newtype呢?
答:编译器不能自己解决这个问题。data和newtype具有不同的语义:data会增加额外的间接层,而newtype则与其封装类型具有完全相同的表示,并始终使用惰性模式匹配;而你可以选择使用严格性标注(!)或像StrictData这样的pragma来使data成为严格的。同样,编译器并不能总是确切知道何时可以用newtype替换data。严格度分析(Strictness analysis)允许它谨慎地确定何时可能消除一些将总是被评估的无用惰性,在这种情况下,它可以有效地在本地删除data包装。 GHC在移除类似于Int这样的装箱数字类型中的额外装箱和拆箱操作链时也会采取类似的方法,以便可以在更高效的非装箱Int#上执行大多数计算。但一般来说(也就是没有全局优化的情况下),它无法知道某些代码是否依赖于那个thunk的存在。因此,HLint提供这个建议,因为通常情况下你不需要运行时的“额外”包装,但有时它是必需的。这个建议只是一个建议。

4
有没有 intrinsically 防止一个只有单个严格字段的 data 被编译时不经过间接引用的情况?假设我们的语言中已知的 newtype(带有其 Coercible 实例)只是 data 的一种特殊情况,那么这种语言似乎是完全可行的,并且还能减少一个关键字。因此,更有趣的问题,也就是 OP 所问的,是为什么没有选择这种替代设计? - Li-yao Xia
特别是,Int#的例子似乎不相关。 newtype 包装已经被盒化的类型,或者更一般地说,它与不改变表示有关。 - Li-yao Xia
鉴于运行时包装器有时是必不可少的,您能否提供一个玩具演示示例? - Enlico
1
@Li-yaoXia,假设有这样的定义:data D = D !Int; newtype N = N Int。那么 case D undefined of D _ -> () 的结果是 undefined,但是 case N undefined of N _ -> () 的结果是 () - Daniel Wagner
2
我知道它们是不同的,但是从“带有newtypes的Haskell”到“不带有newtypes的Haskell”存在一种语义保持的翻译(除了更改Coercible的目标)。每当你写 \(N x) -> f x 时,你可以等价地写成 \ ~(D x) -> f x - Li-yao Xia
@Li-yaoXia,“...为什么没有选择那种替代设计?”这正是我想知道的。正如K.A. Buhr在他的回答中指出的那样,它可能是早期编译器无法理解的一种优化而产生的,并且随着差异的出现而变得根深蒂固。然而,从头开始设计一个类似Haskell的语言的人可以选择将其作为隐式优化。 (有趣的是,Purescript的设计者决定保留它。) - Gregory Higley

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