"点无关"风格在函数式编程中有哪些优缺点?

73

我知道在某些语言(比如 Haskell)中,追求的是点自由风格,或者从来不显式地引用函数参数名称。这对我来说是一个非常难以掌握的概念,但它可能有助于我理解该风格的优势(甚至缺点)。是否有人能够解释一下呢?

4个回答

79
一些作者认为,点无风格是终极的函数式编程风格。简单来说,类型为t1 -> t2的函数描述了从类型为t1的一个元素到类型为t2的另一个元素的转换。点满函数(使用变量编写)强调元素(当您编写\x -> ... x ...时,您正在描述发生在元素x上的事情),而无点函数(不使用变量表达)强调转换本身,作为更简单的转换的组合。点无风格的支持者认为,转换确实应该是中心概念,而点满符号虽然易于使用,但会使我们远离这个崇高的理想。
点无风格的函数式编程已经存在很长时间了。逻辑学家早已知道它,自从Moses Schönfinkel在1924年的开创性工作以来,已经成为第一项关于ML类型推断的研究基础,由Robert Feys和Haskell Curry在1950年代进行。
注:combinatory logic 从一组表达力强的基本组合子构建函数的想法非常吸引人,并已应用于各种领域,例如源自APL的数组操作语言或类似Haskell的Parsec的解析器组合库。点无编程的一个著名倡导者是John Backus。在他1978年的演讲“编程能否摆脱冯·诺依曼风格?”中,他写道:
lambda表达式(及其替换规则)能够定义所有可能的可计算函数,具有任何数量的参数和任何类型。这种自由和权力既有优点也有缺点。它类似于传统语言中不受限制的控制语句的权力:随着不受限制的自由,混乱就来了。如果像在lambda演算中那样不断发明新的组合形式以适应场合,就不会熟悉适用于所有目的的少量组合形式的风格或有用特性。正如结构化编程避免使用许多控制语句以获得具有更简单结构、更好属性和理解行为的统一方法的程序一样,函数式编程避免使用lambda表达式、替换和多个函数类型。它通过使用已知有用特性的熟悉的函数形式构建程序。这些程序结构化得如此之好,以至于它们的行为通常可以通过使用类似于解决高中代数问题所使用的代数技巧的机械方法来理解和证明。

所以这就是它们。点无编程的主要优势在于强制使用结构化组合风格,使等式推理自然。等式推理特别受到“Squiggol”运动的支持者(见[1] [2])的宣传,并确实使用了相当比例的点无组合子和计算/重写/推理规则。

最后,点无编程在 Haskellites 中广受欢迎的原因之一是它与范畴论的关系。在范畴论中,态射(可以看作是"对象之间的变换")是研究和计算的基本对象。虽然部分结果允许在特定类别中进行点有样式的推理,但通常建立、检查和操作箭头的方式仍然是点无样式,而其他语法,如字符串图表也展示了这种"点无风格"。在编程中,提倡“代数编程”方法的人和范畴论用户之间存在紧密的联系(例如,香蕉论文[2]的作者是/曾经是核心范畴论者)。

你可能会对Haskell维基上的Pointfree页面感兴趣。

点无风格的缺点非常明显:它很难读。尽管存在许多阴影、α-等价性等恐怖,我们仍然喜欢使用变量,因为它是一种自然而然的符号,易于阅读和思考。总体思路是,在一个引用透明的语言中,一个复杂的函数就像一个复杂的管道系统:输入是参数,它们进入一些管道,被应用于内部函数,重复(\x -> (x,x))或遗忘(\x -> (), 不导向任何地方的管道),等等。变量符号在所有这些机制上都有很好的隐含性:你给输入命名,输出(或辅助计算)命名,但你不必描述所有的管道计划,小管道将去哪里以不妨碍更大的管道等等。即使在像\(f,x,y) -> ((x,y), f x y)这样简短的内容中,所包含的管道数量也是惊人的。你可以单独跟踪每个变量,或者阅读每个中间的管道节点,但你永远不必看到整个管道系统。当你使用点无风格时,所有的管道都是显式的,你必须把所有的东西写下来,然后再看一遍,有时它就是很丑陋。

PS:这个管道视觉与堆栈编程语言密切相关,它们可能是使用最少的编程语言(勉强)。我建议尝试在其中进行一些编程,以便对其有所了解(就像我建议逻辑编程一样)。请参见FactorCat或可敬的Forth


当您使用点无风格时,一切都是显式的。这里难道不应该是“有点风格”吗?或者说是“隐式”的? - Magne
我认为原句是正确的。在点无风格中,您必须非常明确地说明函数中从输入到输出的值流动,而点有风格则依赖于名称来避免这种情况。例如,在右侧没有标记表明 xy 是重复的,它们只出现了两次。如果您尝试以点无风格的方式实现此函数,则会看到您需要更加明确。 - gasche
我仍然对整个段落感到有些困惑,因为您之前写道“点有意义的”函数(使用显式变量编写)的想法是什么。 - Magne
1
当你有变量时,变量是显式的,但数据流管道是隐式的。在点无样式中,没有变量,但管道必须明确。 (编辑:我删除了你引用的“显式”一词,以避免混淆,谢谢。) - gasche
感谢您澄清。理解是否正确,倒数第二段开始提到“无点”风格,但主要讨论“有点”风格,最后描述“无点”风格?如果是这样,那么我可能会使上下文切换更清晰(例如,“一般的想法...”是什么?),或者分割段落,以避免混淆。 - Magne
目前我更倾向于保留这段文字不变。 - gasche

59

我相信其目的是简洁明了地表达流水线计算,将函数组合视为管道计算,而不是考虑通过线程传递参数。下面是一个简单的例子(使用F#) - 给定:

let sum = List.sum
let sqr = List.map (fun x -> x * x)

使用方法如下:

> sum [3;4;5]
12
> sqr [3;4;5]
[9;16;25]
我们可以将“平方和”函数表示为:
let sumsqr x = sum (sqr x)

并且使用方法如下:

> sumsqr [3;4;5]
50

或者我们可以通过将 x 通过管道传递来定义它:

let sumsqr x = x |> sqr |> sum

这样写,很明显x仅被传递用于在一系列函数中"穿针引线"。直接合成看起来更加美观:

let sumsqr = sqr >> sum
这种方法更简洁,是一种不同的思考方式;组合函数而不是想象参数流动的过程。我们描述的不是“sumsqr”如何工作,而是描述它“是什么”。
PS:理解组合的一个有趣方式是尝试在连接语言(例如Forth、Joy、Factor等)中进行编程。 这些可以被视为仅仅由组合(Forth :sumsqr sqr sum; )构成,其中单词之间的空格是组合运算符
PPS:也许其他人可以评论性能差异。 在我看来,通过使编译器更明显地意识到无需生成中间值(在管道化中需要),从而减少GC压力,有助于使所谓的“砍林”问题更易处理。

12
有关编译性能提升的那部分完全不正确。在大多数编程语言中,点无风格实际上会降低性能。Haskell 严重依赖优化,因为这是使这些东西的成本可承受的唯一方法。最好的情况是,这些组合子被内联,并且你得到一个等价的有参数版本。 - gasche
2
我所说的“森林砍伐”减少GC压力是指编译器可以避免分配中间值(例如sqr中的列表),当明确它仅被传递给sum以构建结果时;将函数组合视为_提示_来执行此操作。List.sum实际上是List.fold (+) 0List.fold (fun s x -> s + x)。与map组合是:List.map (fun x -> x * x) >> List.fold (fun s x -> s + x),或者可以融合成一个:List.fold (fun s x -> s + x * x) 0,从而避免了分配。参见:https://link.springer.com/content/pdf/10.1007/3-540-19027-9_23.pdf - AshleyF

10

虽然我很喜欢点无的概念并在某些方面使用它,也同意之前说过的所有正面评价,但我发现以下一些问题是负面的(有些已经详细说明):

  1. 缩短符号表示可以减少冗余;在结构复杂的组合(例如ramda.js风格、Haskell中的点无关函数或其他连接语言),代码阅读比线性扫描一堆const绑定并使用符号高亮器查看哪个绑定进入哪个下游计算更加复杂。除了树形结构和线性结构之外,失去描述性符号名称也使得函数难以直观地理解。当然,树形结构和命名绑定的丧失也有很多积极的方面,例如,函数将感觉更加通用——不会通过选择的符号名称绑定到某个应用程序域——即使布局了绑定,树形结构也是语义上存在的,并且可以按顺序理解(lisp let/let*风格)。

  2. 仅在通过管道传递或组合一系列函数时,点无关函数最简单,因为这也会导致我们人类容易跟随的线性结构。但是,在多个接收者中穿过一些中间计算是乏味的。有各种包装成元组、镜头等费力的机制,只是为了使一些计算可访问,否则就是一些值绑定的多次使用。当然,重复部分可以作为单独的函数提取出来,也许这是个好主意,但也有一些非短函数的论点,即使它被提取了,其参数也必须通过两个应用程序以某种方式穿过,然后可能需要记忆函数来不实际重复计算。一个将使用很多convergelensmemoizeuseWidth等。

  3. JavaScript特定:更难调试。用线性流的let绑定,可以在任何地方添加断点。使用点无关风格,即使某个断点被添加,值流也很难读取,例如,您不能仅查询或悬停在开发控制台中的某个变量上。此外,由于点无关不是JS本身的功能,ramda.js或类似库函数会相当模糊堆栈,尤其是在强制柯里化时。

  4. 代码脆弱性,尤其是在非平凡大小系统和生产中。如果有新的要求,那么以上缺点就会发挥作用(例如,对于下一个维护者(可能是您自己几周后)来说,更难阅读代码,而且更难跟踪数据流以进行检查)。但最重要的是,即使是看似小而无辜的新要求也可能需要完全不同的代码结构。可以认为这是一件好事,因为它将是新事物的清晰表示,但重写大量的点无关代码非常耗时,然后我们还没有提到测试。因此,感觉松散、不太结构化、基于词汇分配的编码可以更快地重新定位。特别是如果编码是探索性的,并且在人类数据领域中具有奇怪的约定(时间等),很少能够100%准确地捕获,并且可能总会有一个请求来处理更准确或更符合客户需求的内容,那么哪种方法导致更快的转折就很


3
关于第三点,const tap = x => (console.log(x), x); 将会让你少受很多痛苦(虽然不是完全无痛苦)。 - Jared Smith
2
每个人都喜欢使用 tap,尤其是在处理 observables 时,但这是你需要添加和删除的东西,而在一系列的 const 绑定中,你只需在开发工具中单击该行 - 当然代价很大,就是它不是 point-free。 - Robert Monfera
然后将调用放在自己的一行上,并使用预处理器指令或其他构建步骤将其删除以进行非开发构建。这很笨拙,我不会称其为“已解决的问题”,但它并不是非常困难,我敢打赌我的JS代码库中到处都是对tap的注释调用。 - Jared Smith
1
这是一个真正伟大且信息量丰富的答案,其中提到了很少被谈论的要点。 - Magne

-2
对于点自由变体、串联式编程语言,我必须写下以下内容:
我有一些使用Joy的经验。Joy是一个非常简单和美丽的概念,它具有列表功能。当将问题转换为Joy函数时,您必须将大脑分成两部分:一部分用于堆栈管道工作,另一部分用于Joy语法中的解决方案。堆栈始终从后面处理。由于组合包含在Joy中,因此组合器不需要计算时间。

在SO上,您不应该将评论写成答案。这不是一个讨论论坛。请阅读准则。 - Bent Tranberg
我喜欢点无关的风格。这不是对 Joy 的风格有帮助吗? - fpstefan

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