副作用是一件好事吗?

30
我认为这个术语有点贬义。因此,我对维基百科上的两句话感到惊讶:

命令式编程以使用副作用来使程序运行而闻名。相反,函数式编程以最小化副作用而闻名。[1]

由于我有点偏向数学,后者听起来很棒。那么副作用的论据是什么?它们是否意味着失去控制或接受不确定性?它们是一件好事吗?

13个回答

56

偶尔我会在Stack Overflow上看到某些问题,这迫使我花费半小时编辑一个非常糟糕的维基百科文章。现在这篇文章只是适度糟糕。在与您的问题相关的部分中,我写道:

在计算机科学中,如果一个函数或表达式除了产生一个值外,还修改了一些状态或与调用函数或外部世界有可观察交互,那么它被称为具有副作用。例如,函数可能会修改全局变量或静态变量,修改其参数之一,引发异常,向显示器或文件写入数据,读取数据,调用其他具有副作用的函数或发射导弹。在存在副作用的情况下,程序的行为取决于过去的历史记录;也就是说,评估的顺序很重要。由于理解具有效果的程序需要考虑所有可能的历史记录,因此副作用经常使程序更难以理解。

副作用是使程序能够与外部世界(人类、文件系统、网络上的其他计算机)交互的重要手段。但是副作用的使用程度取决于编程范型。命令式编程以无序、放纵的副作用使用而闻名。在函数式编程中,副作用很少使用。像Standard ML和Scheme这样的函数式语言不限制副作用,但程序员通常避免使用它们。函数式语言Haskell通过静态类型系统限制副作用;只有产生IO类型结果的函数才能具有副作用。


29

副作用是必要的,但应该尽量减少或局限其影响。

其他评论中提到,无副作用编程有时不太直观,但我认为人们所谓的“直观”很大程度上取决于他们的先前经验,而大多数人的经验具有强烈的命令式偏见。由于副作用较少,导致组件之间进行交互的可能性降低,因此人们发现无副作用编程可以减少错误(虽然有时会出现新的/不同类别的错误)。每天都有越来越多的主流工具变得更加函数化,这是因为人们在发现无副作用编程的优势。

几乎没有人提到性能问题,无副作用编程通常比有副作用的编程性能更差,因为计算机是冯·诺伊曼机器,设计用于处理效果(而不是使用 lambda 函数)。现在我们正处于多核革命的中心,这可能会改变游戏规则,因为人们发现他们需要利用内核来提高性能,当你没有副作用时并行化有时很容易实现,而有副作用时则需要像火箭科学家一样去实现。


4
无副作用编程可以和有副作用编程一样高效 (参见 mlton.org 或 caml.inria.fr),但编译器的开发者需要更努力地去实现这个目标。 - Norman Ramsey

18
在冯·诺伊曼机中,副作用是使机器工作的东西。基本上,无论你如何编写程序,在低层视图下它都需要执行副作用才能工作。
编写没有副作用的程序意味着抽象出副作用,这样你可以思考问题,并减少程序不同模块(无论是过程、类还是其他什么)之间的依赖关系,从而使程序更具可重用性(因为模块不依赖于特定的状态才能工作)。
所以说,没有副作用的程序很好,但是在某个层面上副作用是不可避免的(因此不能被认为是“坏”的)。

没有副作用的程序并不是一件好事。所有没有副作用的程序都可以完全优化掉。 - David Conrad

10

优点:

  • 最终想要实现的是副作用。
  • 对于与外部世界交互的代码来说,副作用是自然而然的。
  • 它们使许多算法变得简单。
  • 为了避免使用副作用,你需要通过递归来实现循环,因此你的语言实现需要有尾调用优化。

缺点:

  • 纯代码易于并行化。
  • 副作用会使代码变得复杂。
  • 纯代码更容易证明正确性。

例如Haskell,一开始看起来非常优雅,但是当你需要开始与外部世界进行交互时,就不那么有趣了。(Haskell使用函数参数将状态移动,并将其隐藏在称为Monad的东西中,这使得你可以以类似命令式的方式编写代码。)


2
副作用使算法变得简单,因为它们非常强大,强大的工具应该谨慎使用。 - Anton Tykhyy
1
对于循环,编译器只需要优化尾递归,而不是一般的尾调用,这太容易了。 - Ingo
2
我认为,相对于“复杂”,更少模块化和更多模块化更清晰、更具体。 - Kzqai

8
副作用就像任何其他武器一样。它们无疑是有用的,但在处理不当时可能非常危险。
就像武器一样,你会遇到各种不同类型和不同程度致命性的副作用。
在C ++中,由于指针的存在,副作用是完全不受限制的。如果一个变量被声明为“private”,你仍然可以使用指针技巧访问或更改它。你甚至可以更改不在范围内的变量,例如调用函数的参数和局部变量。通过操作系统的一点帮助(mmap),你甚至可以在运行时修改程序的机器代码!当你使用类似C ++的语言编写代码时,你被提升为位之神的等级,成为进程中所有内存的主人。编译器对你的代码所做的所有优化都是基于你不滥用你的权力的假设。
在Java中,你的能力受到更多限制。所有在范围内的变量都在你的控制之下,包括由不同线程共享的变量,但你必须始终遵守类型系统。尽管如此,由于操作系统的子集可供你使用以及静态字段的存在,你的代码可能具有非本地效果。如果一个独立的线程关闭了System.out,它将看起来像魔术。而且这确实是一种副作用的魔法。
Haskell(尽管有关其纯洁性的宣传)具有IO monad,它要求你在类型系统中注册所有副作用。将代码包装在IO monad中就像手枪的三天等待期一样:你仍然可以炸掉自己的脚,但必须经过政府批准。还有unsafePerformIO及其类似物,它们是Haskell IO的黑市,让你无需问任何问题就能获得副作用。
Miranda是Haskell的前身,是一种纯函数式语言,在monad变得流行之前创建。Miranda(据我所学...如果我错了,请替换Lambda Calculus)根本没有IO原语。唯一进行的IO是编译程序(输入)并运行程序并打印结果(输出)。在这里,你拥有完全的纯度。执行顺序是完全无关紧要的。所有“效果”都局限于声明它们的函数,意味着两个不相交的代码部分永远不会互相影响。这是一个乌托邦(对于数学家来说)。或者等价于一个反乌托邦。这很无聊。什么也没发生。你不能用它写服务器。你不能在它上面写操作系统。你不能用它写SNAKE或Tetris。每个人都在数学上坐着。

抱歉挖掘了一个10年前的答案,但是你是不是指的是反乌托邦? - ARI FISHER

7

没有副作用,有些事情就无法实现。其中一个例子是I/O,因为在屏幕上显示消息本质上就是一个副作用。这就是为什么函数式编程的目标是最小化副作用而不是完全消除它们。

抛开这一点,通常情况下,最小化副作用与其他目标(如速度或内存效率)相冲突。有时候,已经存在一个概念模型与状态变化的想法很好地契合,与这个现有模型对抗可能会浪费能量和精力。


4
有些人在这里提到,没有副作用就不能制作有用的应用程序,这是正确的。但是从这个观点出发,并不意味着无节制地使用副作用是一件好事。
考虑以下类比:如果处理器的指令集没有分支指令,那么它将毫无价值。然而,并不意味着程序员必须一直使用goto。相反,结构化编程和后来的OOP语言(如Java)证明可以不使用goto语句,而且没有人会错过它。(当然,在Java中仍然有goto - 现在称为break、continue和throw。)

如果、否则、当和为循环同样是跳转语句,而break、continue和throw也是。 - hasen

3
副作用在大多数应用程序中都是必不可少的。纯函数有很多优点。它们更容易理解,因为您不必担心前置和后置条件。由于它们不改变状态,因此更容易并行化,随着处理器数量的增加,这将变得非常重要。
副作用是不可避免的。只有在比更复杂但纯的解决方案更好的情况下才应使用它们。对于纯函数也是如此。有时,使用函数式解决方案可以更好地解决问题。
这样做是正确的 =) 您应根据所解决的问题使用不同的范例。

3

如果没有副作用,就无法执行I/O操作;因此,您无法创建有用的应用程序。


2
那句话确实让我笑了。不过,我发现最小化副作用确实可以转化为更易于理解和维护的代码。然而,我没有太多时间去探索函数式编程。
在面向对象和过程化语言中工作时,我认为应该“包含”和“隔离”副作用。
举个简单的例子,视频游戏必须有将图形呈现到屏幕上的副作用。然而,在处理副作用方面,这里有两种不同的设计路径。
一种是通过使渲染器非常抽象并告诉它要呈现什么来最小化和松散地耦合。然后,系统的其他部分告诉渲染器要绘制什么,可能是一批基本图元(如三角形和点),带有投影和模型视图矩阵,或者可能是更高级别的抽象模型、相机、光线和粒子。无论哪种方式,这种设计都涉及许多引起外部副作用的事物,因为潜在地,代码库的许多部分都会向渲染器推送更改(无论多么抽象或间接,净效应仍然是这种系统中的许多东西触发外部渲染副作用)。
另一种方法是“包含/隔离”这些副作用。渲染器不再被告知要呈现什么,而是与游戏世界耦合在一起(虽然这可能只是一些基本的抽象和可能访问场景图)。现在,它自己访问场景(只读访问),并通过更多的拉式设计查找要呈现的内容。这导致从渲染器到游戏世界的耦合更多,但也意味着与屏幕输出相关的副作用现在完全包含在渲染器内部。
后一种设计“包含”或“隔离”副作用,我发现这种类型的设计更容易维护和保持正确性。它仍会引起副作用,但与输出图形到屏幕有关的所有副作用现在完全包含在渲染器中。如果出现问题,你知道bug将在渲染器代码中而不是由于外部错误使用它并告诉它做错事情。
因此,在耦合方面,无论是哪种抽象,我始终发现最大化引出耦合(传出)对于那些引起外部副作用的东西更为可取,最小化引入耦合(传入)。在副作用的上下文中,对IRenderer的依赖仍然是对具体Renderer的依赖,就通信而言,这涉及到哪些副作用将发生。抽象不会影响将发生哪些副作用。
渲染器应该依赖于其他部分,以便它可以将这些副作用完全隔离到屏幕上;其他部分不应该依赖于渲染器。文件保存器也是同样的道理。外部世界不应该告诉文件保存器要保存什么。它应该查看周围的世界并自行确定要保存什么。这种设计路径旨在隔离和包含副作用。它往往更倾向于拉取而不是推送。结果往往会引入一些耦合(尽管它可能是松散的),如果您绘制出依赖关系,则保存器可能需要与其甚至不感兴趣的东西耦合在一起,或者渲染器可能需要只读访问其不感兴趣的东西,以发现它感兴趣的内容。
然而,最终结果是依赖关系流向副作用而不是从副作用流向依赖关系。当我们有一个系统,其中许多依赖关系流向推动外部副作用时,我总是觉得这些最难理解,因为系统的许多部分都可能改变外部状态,以至于不仅难以弄清楚会发生什么,而且还难以知道何时和在哪里发生。因此,纠正/防止这个问题最直接的方法是寻求使依赖关系远离副作用而不是朝向它们。
无论如何,我发现倾向于这些类型的设计是帮助避免错误并在存在时帮助检测和隔离它们以使它们更容易重现和纠正的实用方法。
我发现另一个有用的策略是使任何给定循环/系统阶段的副作用更加均匀。例如,在某些情况下,与其执行删除相关数据、取消链接,然后将其删除的循环,不如分三个相同类型的循环来处理。第一个相同类型的循环可以删除相关数据。第二个相同类型的循环可以取消链接节点。第三个相同类型的循环可以将其从系统中删除。这是一个更低级别的注释,更多地涉及实现而不是设计,但我经常发现结果更容易理解、维护,甚至优化(例如更容易并行化,并具有改进的引用局部性)——您将那些触发多种不同类型副作用的非同质循环分解成多个同质循环,每个循环仅触发一种统一类型的副作用。

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