Haskell 中无点函数的先决条件是什么

What are the prerequisites for a point-free function in Haskell

我一直认为pointfree函数的先决条件是让函数参数到达定义的末尾。例如

-- This can be made pointfree quite easily:    
let lengths x = map length x    
let lengths' = map length

-- However this cannot:
let lengthz x = length `map` x
-- let lengthz' = length `map` (parse error)

我最初看到这篇文章 this question。我们有这个例子:

agreeLen :: (Eq a) => [a] -> [a] -> Int
agreeLen x y = length $ takeWhile id $ zipWith (==) x y
-- This may look like it can easily be made pointfree, however it cannot
-- agreeLen' :: (Eq a) => [a] -> [a] -> Int
-- agreeLen' = length $ takeWhile id $ zipWith (==) (zipWith is applied to too few arguments)

那为什么我的第一个例子可以变成点自由的,而其他两个不能呢?

你的第一个可以写成无点风格,但你必须调整它以适应你map作为中缀函数的使用。

let lengthz = (length `map`) -- write the partial application as a section

agreeLen 的问题在于 zipWith 不是一个参数的函数,而是两个参数的函数。在将结果传递给 takeWhile id 之前,zipWith 需要应用于两个参数。以无点样式编写它的不容易的方法是

-- via http://pointfree.io
agreeLen = ((length . takeWhile id) .) . zipWith (==)

简而言之,zipWith (==) 应用于第一个参数 xagreeLen 以生成一个新函数(一个接受列表和 returns 压缩列表的函数).这个新函数作为 (length . takeWhile id) . 的参数给出,它生成一个新的组合函数,该函数接受 agreeLen 的第二个参数并产生所需的 Int 值。

(@duplode 可能 比我将要尝试的更干净。)

当初始函数接受超过 2 个参数时,一个很快就会失控的技巧是显式取消柯里化它,进行组合,然后重新柯里化结果。

agreeLen = curry $ length . takeWhile id . (uncurry $ zipWith (==))
-- However this cannot:
let lengthz x = length `map` x
-- let lengthz' = length `map` (parse error)

\x -> length `map` x写点免费简直就是map length。中缀反引号只是语法糖。 (正如 chepner 指出的那样,如果你真的想要它,你可以使用一个部分,即 (length `map`)。)

agreeLen :: (Eq a) => [a] -> [a] -> Int
agreeLen x y = length $ takeWhile id $ zipWith (==) x y
-- This may look like it can easily be made pointfree, however it cannot

这里的关键词是"easily"。如果你足够努力,几乎任何事情都可以变得毫无意义。在这种情况下,如果我们根据 (.) 而不是 ($) 编写 agreeLen,那么省略 y 参数很容易:

agreeLen x y = (length . takeWhile id . zipWith (==) x) y
agreeLen x = length . takeWhile id . zipWith (==) x

至于x,我们可以通过将(.)的使用与其他函数组合zipWith (==) x作为另一种用函数修改值的情况来处理它:

agreeLen x = (.) (length . takeWhile id) (zipWith (==) x)
agreeLen x = ((length . takeWhile id) .) (zipWith (==) x) -- cosmetical change
agreeLen x = (((length . takeWhile id) .) . zipWith (==)) x
agreeLen = ((length . takeWhile id) .) . zipWith (==)

这不是你在实践中真正想要做的事情,但它确实是可能的。

您始终可以仅使用多态 S (<*>) 和 K (const) 组合子,将类型化的 lambda 表达式转换为类型化的组合子表达式。 A Note on Typed Combinators and Typed Lambda Terms 显示了一个证明,它也是一个 抽象算法 将 lambda 项转换为(无点)组合项:

  1. |xα| xα = Iα → α
  2. |xα| Xα = Kβ → (α → β)Xβ if xα ∉ Xβ
  3. |xα| Xα → β = Xα → β if xα ∉ Xα → β
  4. |xα| Xβ → γYβ = Sα → (β → γ) → α → β → (α → γ)(|xα| Xβ → γ)(|xα| Yβ) otherwise

这可以很容易地转换成Haskell表示法:

(\(x :: a) -> (x :: a)) = id

-- If X does not mention x
(\(x :: a) -> (X :: a)) = const X

-- If X does not mention x
(\(x :: a) -> (X :: a -> b)) = X

(\(x :: a) -> (X :: b -> c) (Y :: b))
  = (\(x :: a) -> X) <*> (\(x :: a) -> Y)

因此你总是可以写一个无点定义,但如果没有更高级别的组合器,它可能会更大更丑陋,你可能需要引入新类型包装和解包或 RankNTypes 来处理非谓语传递多态函数引起的多态性。

然而,在实际的 Haskell 代码中,当您可以“将参数获取到定义的末尾”时,您主要希望使用无点样式,也就是说,当您可以 eta-reduce f x = g x 变成 f = g。如果您有一个简单的函数“管道”,您可以在其中传递一个中间值而不复制或删除它,那么这种方法效果最好。例如,这是一个直方图函数:

-- import Control.Arrow ((&&&))
(f &&& g) x = (f x, g x)

-- import Control.Category ((>>>))
(f >>> g) = g . f

histogram :: String -> [(Char, Int)]
histogram
  = sort                             -- [Char]
  >>> group                          -- [[Char]]
  >>> map (head &&& length)          -- [(Char, Int)]
  >>> sortBy (flip (comparing snd))

如果我们调用这种形式的函数定义point-free

f = e

其中 f 只是一个函数名, e 是一个表达式, 我们至少可以使

的那些函数成为无点函数
  1. 右手边只是一个函数应用程序。
  2. 不需要对其参数进行模式匹配并且
  3. 参数未在 "complex" 子表达式中提及,例如列表理解、case、let 或 lambda 抽象。

也就是说,如果参数只是简单的名称,如果其他两个条件也满足,我们可以让它成为无点。

对于需要一些模式匹配的参数,如果我们可以用一些函数消除构造函数(比如maybeeitherfstsnd)我们可以经常重写函数,以便我们得到一个可以写成无点的函数:

 f (a,b) = (b,a)
 f' ab   = (snd ab, fst ab)
 f'' = (pure (,) <*> snd) <*> fst

此外,右侧的if表达式可以重写为bool

的应用

这是一个从表达式 e 中消除一些变量 v 的算法,这样得到的表达式 x 不包含 v 并且当应用于 v 时等同于 e

  • 如果e不包含v,则结果为pure e
  • 如果ev,结果是id
  • 如果 e 是一些表达式 f 应用于 v f 没有提到 v 结果是 f
  • 否则,e 必须是一个应用程序 g h,其中至少有一个 gh提到v。结果将是 g' <*> h' 其中 g' 是消除 v[=97 时产生的表达式=] from g and h' 是从 中消除 v 时得到的表达式]h

下面是如何使函数定义无点以满足上述要求

 f v1 v2 v3 ... vn = e
  • e'e
  • 中消除vn
  • 写下新函数

    f' v1 v2 v3 ... v(n-1) = e'

  • 如果没有留下参数,你就完成了

  • 否则,使f'无点

请注意,有多种方法可以使用 (.) flip 等函数从表达式中消除变量,这通常会导致代码更短,但是,以上是最基本的方法,因为它只需要 S、K 和 I 组合子(在 Haskell 中写为 <*> pureid)。

I always thought that the prerequisites for a pointfree function were to get the function arguments to the end of the definition

好吧,正如其他答案所示,这不是必要条件;几乎任何东西都可以免费使用。所以它本身并不是先决条件,而只是 一个 重写函数定义的简单规则。这个特殊的规则甚至有一个名字:eta reduction(如希腊字母 η,英文拼写为 "eta")。但这不是唯一的此类规则。

应用 eta 减少的条件也不完全是 "get the arguments to the end"。这是对 "looks like" 什么时候可以应用 eta 减少的粗略描述,但实际情况并不是你可以不考虑表达式的结构而盲目地应用于源代码文本的情况。 1

真正的规则是寻找像这样的东西:

func x = expr x

不是的意思是"some text (that I'm calling expr) followed by x",而是"some well-formed expression (that I'm calling expr) applied to x"。区别在于我在谈论表达式的结构,而不是该表达式的源代码表示中的字符顺序。这对于理解让您感到困惑的示例之间的差异至关重要。

你总是需要函数体的 "top level expression" 是 完全 直接应用于函数最后一个参数的东西。您还需要不在其他任何地方引用该参数,但 "something" 是什么并不重要;它可能是一个包含许多子部分的复杂表达式。

现在让我们从这个角度考虑您的示例。

-- This can be made pointfree quite easily:
let lengths x = map length x

好的,x 是最后的文字。但它是顶级应用程序的参数吗?这个表达式有三个部分,而不仅仅是两个,所以我们的规则不一定立即适用。但是请记住,函数应用程序是左关联的(f x y 意味着 f 应用于 x,而 f x 的结果应用于 y),这是 map length 应用于 x。所以我们可以应用 eta 减少来消除 x,只留下应用于它的表达式:map length.

-- However this cannot:
let lengthz x = length `map` x

同样,x 在文本上是最后的,但我们显然不能仅在文本上将其删除,因为这甚至不会产生格式正确的代码。中缀运算符需要一个左右参数(用反引号写的名称,如 `map` 将其变成中缀运算符)。

但请记住中缀运算符语法 的意思是 length `map` xmap 应用于 lengthmap length 的结果应用于 x。所以 eta 减少 确实 适用,我们可以消除 x。但不仅仅是通过盲目删除源代码文本"x"。剩下的应该是应用于x的表达式,又是map length,而不是无意义的源代码文本length `map`.

我们确实有另一个中缀运算符选项。你可以可以 "leave out"一个来自中缀运算符的参数,如果你把它转换成一个section通过把东西括在括号里。所以我们可以写 map 应用于 length 而不是通过写 (length `map`) 将结果应用于第二个参数,如果你想保持结果 looking 类似于您开始的内容。这也提供了另一种消除参数以获取指向自由代码的方法:我们可以像省略第二个一样容易地省略中缀运算符的 first 参数。例如,如果我们有:

f x = x / 3

这是 (/) 应用到 x,然后应用到 3 的结果。所以最外层应用的参数是3,而不是x,我们不能应用eta reduction。但是我们可以应用非常相似的过程,因为Haskell的运算符部分语法2,并消除x到离开(/ 3)。再次注意,要考虑的重要事项是表达式 x / 3 结构 ,而不是我们用来写下它的源代码文本位的顺序。

题外话到此结束。你的最后一个例子有点复杂:

agreeLen :: (Eq a) => [a] -> [a] -> Int
agreeLen x y = length $ takeWhile id $ zipWith (==) x y

y(然后 x 如果我们可以这样做两次)在源代码文本的末尾。但是不是这个表达式中顶层函数应用的参数

Haskell 语法的一个怪癖是,如果表达式中涉及运算符(除非它们在带括号的子表达式中),其中之一是 always顶级应用。这是因为普通函数应用程序的优先级高于任何可能的运算符,并且 lowest 优先级最终成为顶级表达式。因此,仅从 $ 运算符的存在来看,很明显其中之一必须是 "top" 函数,并且它们中的任何一个都不可能仅应用于其上的 y自己的。所以 eta 减少不会立即应用。

更详细地说,这是 $ 应用于 length,然后应用于右侧的所有内容。因此,顶级应用程序 ($) length 被应用到 takeWhile id $ zipWith (==) x yy 深入参数表达式,我们需要顶层应用程序的参数只是简单的 y.

但请记住 f $ x 只是 f x 的另一种写法,通常只用于我们不必在 fx 周围加上括号,如果这些是复杂的表达。如果我们把括号放回去,我们会更接近于 y 在顶层。

agreeLen x y = length (takeWhile id (zipWith (==) x y))

我们还没有到那儿。但这是“length applied to (takeWhile id applied to (zipWith (==) x applied to y)). 这种 "chaining" 的模式应用于其他结果applications 正是函数 composition with the . operator is for. 我们可以在心理上将其重构为“lengthtakeWhile id 之后应用在 zipWith (==) x,整个链条应用于 y”。用代码编写为:

agreeLen x y = (length . takeWhile id . zipWith (==) x) y

现在 我们已经将 <something> 应用于 y!所以我们可以减少eta:

agreeLen x = length . takeWhile id . zipWith (==) x

请注意 x,虽然现在在文本上是最后一个,但 不符合 与上面 y 相同的模式。在应用于 x 之前,我们没有将整个 "function-pipeline" 括起来,而是 x 部分 管道的最后阶段。我们可以洗牌最终得到:

agreeLen x = (((length . takeWhile id) .) . zipWith (==)) x

然后 eta reduce 以消除 x,如其他答案所示。但是步骤比较复杂;我什至不知道它们是什么,我只是从@chepner 的回答中复制了那个表达式(谁从 http://pointfree.io 复制了它)。这显示了 xy 在原始 agreeLen 中的重要区别,尽管它们都以正确的顺序出现在源代码的末尾。

这只是再次强调了我的观点:要理解为什么有时可以缩减 eta 而有时不能缩减 eta,您不需要将缩减 eta 视为一个简单的文本规则;您确实实际上必须阅读并理解您要重写的表达式的结构。


1 实际上,在纯 lambda 演算中,您几乎可以将其作为盲目文本规则应用; Haskell 添加了更复杂的语法,例如通过 precedence/associative 带有隐式括号的中缀运算符、大小写、if/then/else 等结构

2 纯 lambda 演算中不存在中缀运算符语法,更不用说运算符部分了。所以这个规则没有奇特的希腊字母名称,但这只是因为命名这些规则的理论家最初没有研究 Haskell.