如何在没有冗余方法的情况下在 Purescript 中定义类型类实例

How to define an typeclass instance in Purescript without redundant methods

我正在努力自学 Purescript。我目前正在学习 Purescript book 和第 6 章。由于我对 Haskell 非常熟悉,所以到目前为止练习都非常简单。但我目前停留在一个上,我当然知道“如何”去做,但似乎被关于定义类型类实例的 Purescript 特定行为打败了。这让我很好奇这在实践中是如何完成的。

我想做的具体事情是为 NonEmpty 类型定义一个 Foldable 实例,定义如下:

data NonEmpty a = NonEmpty a (Array a)

来自 Haskell 背景,我知道 foldMap 往往是定义 Foldable 实例的最简单方法,所以我没有花时间写:

instance foldableNonEmpty :: Foldable NonEmpty where
    foldMap f (NonEmpty a as) = f a <> foldMap f as

这在 Haskell 中就足够了,因为根据 foldMap.

所有其他 Foldable 方法都有默认值

我想它在 PureScript 中也足够了,但我的代码没有编译,给我错误:

  The following type class members have not been implemented:
  foldr :: forall a b. (a -> b -> b) -> b -> ... -> b
  foldl :: forall a b. (b -> a -> b) -> b -> ... -> b

in type class instance

  Data.Foldable.Foldable NonEmpty

这让我很吃惊,充其量看起来很不方便。但是我检查了 documentation 并很快发现确实有预定义的方法可以从 foldMap 中获取 foldlfoldr,形式为 foldlDefaultfoldrDefault。所以我的下一次尝试是:

instance foldableNonEmpty :: Foldable NonEmpty where
    foldMap f (NonEmpty a as) = f a <> foldMap f as
    foldr = foldrDefault
    foldl = foldlDefault

但这也无法编译。这次的错误是:

  The value of foldableNonEmpty is undefined here, so this reference is not allowed.

这对我来说有点神秘,但我认为这意味着我还不能访问 foldrDefaultfoldlDefault,因为(根据 Pursuit 文档)它们需要类型构造函数已经有一个 Foldable 实例,因此不能用作定义实例的一部分。

但这一切当然引出了一个问题:我如何在 Purescript 中定义一个 Foldable 实例,而不必为 3 种方法中的 2 种方法手动写出冗余定义?与根据彼此定义方法的其他类型类类似。或者如果可能的话(我希望如此!),我错过了什么?

实际上,在更努力地搜索 Google 之后,我确实找到了一个 Foldable 实例的示例,并且发现如果我这样做,该实例确实可以编译:

instance foldableNonEmpty :: Foldable NonEmpty where
    foldMap f (NonEmpty a as) = f a <> foldMap f as
    foldr f = foldrDefault f
    foldl f = foldlDefault f

但这引出了一个新问题:据我所知,由于 PureScript 使用柯里化的方式与 Haskell 完全相同,为什么在上面的“eta-reduced”版本是不是?关于值未定义的错误消息与什么有什么关系?

是的,PureScript 确实以与 Haskell 完全相同的方式使用柯里化,但柯里化不是这里的问题。

问题是求值顺序。 Haskell 使用正常的求值顺序,而 PureScript 使用应用程序(通俗地说 - PureScript 不是懒惰的)。

这意味着 Eta-reduction 在 PureScript 和 Haskell 之间的工作方式不同(在极端情况下)。考虑以下示例:

f g x = if x > 0 then g x else 0
h x y = f (h x) y

如果我调用 h 5 0,结果是 0,而 h 实际上从未被递归调用,因为在 felse 分支得到评价。

在Haskell中这可以是安全的Eta-reduced:

h x = f (h x)

但是如果我在 PureScript 中这样写,这意味着每次调用 h x 都必须立即递归调用 h x 以便将其结果传递给 f,从而导致无限递归。

在 Haskell 中,这是有效的,因为 h x 在 被传递到 f 之前未被评估 - 也就是“正常评估顺序”。


这与您的 class 实例类似,如果您还记得实例只是编译器计算出来并透明地传递给您的额外参数,并且实例声明可以看作是一个函数构造一个字典(这确实是它被编译成 JavaScript 的方式)。

为了调用foldrDefault,必须将实例传递给它,但这意味着递归调用dictionary-constructing函数,这将导致无限递归。

但是如果你Eta-expand,实例字典在它自己的构造过程中是不需要的,只有当foldr实际调用时才需要参数。


但为什么 PureScript 会使用应用程序评估顺序?! - 你可能会愤怒地问。

好吧,我不是 PureScript 的设计者,所以我无法明确回答这个问题,但考虑到它被编译为 JavaScript,这对我来说确实有意义,并且使语义惰性化意义重大在编译后的代码中跳过额外的环节,这会使在浏览器中检查和调试变得很头疼。


回复您的评论:

I'm having trouble applying it to the instance definition, in particular why is it that "if you Eta-expand, the instance dictionary is not required during its own construction"? Is that dictionary still not required in order to figure out what foldrDefault actually is?

也许通过查看已编译的 JavaScript 来说明会更容易。

对于 Eta-reduced 的情况,JavaScript 看起来像这样:

var dictionary = {
    ...
    foldr: foldrDefault(dictionary)
    ...
}

对于 Eta-expanded 情况,JavaScript 将如下所示:

var dictionary = {
    ...
    foldr: function(y) { return foldrDefault(dictionary)(y) }
    ...
}

可以看出,在前一种情况下,dictionary 的值在 dictionary 的初始化完成之前传递给了 foldrDefault。 JavaScript 实际上允许这样做,运行时行为是 foldrDefault 的参数最终会变成 undefined,这当然会导致运行时崩溃。

这实际上是编译器中的一个错误(我现在找不到 GitHub 问题,抱歉),补丁只是简单地禁止这种模式,只允许 Eta-expanded 的情况,如您所见,dictionary 的值仅在 foldr 被调用时才传递给 foldrDefault,但在 dictionary 的初始化期间不会.