29

我觉得这个词有点贬义。因此,我对维基百科中的两句话感到震惊:

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

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

4

13 回答 13

55

每隔一段时间,我就会看到一个关于 SO 的问题,这迫使我花半个小时来编辑一篇非常糟糕的 Wikipedia 文章。这篇文章现在只是中度糟糕。在与您的问题有关的部分中,我写道:

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

副作用对于使程序能够与外部世界(人、文件系统、网络上的其他计算机)交互至关重要。但是使用副作用的程度取决于编程范式。命令式编程以不受控制、混杂使用副作用而闻名。在函数式编程中,很少使用副作用。标准 ML 和 Scheme 等函数式语言不限制副作用,但程序员习惯于避免它们。函数式语言 Haskell 使用静态类型系统限制副作用;只有产生 IO 类型结果的函数才会有副作用。

于 2009-04-20T03:03:53.473 回答
27

副作用是一种必要的邪恶,人们应该设法将它们最小化/本地化。

线程上的其他评论说无效果编程有时不那么直观,但我认为人们认为“直观”很大程度上是他们先前经验的结果,并且大多数人的经验都带有严重的命令偏见。主流工具每天都变得越来越实用,因为人们发现无效果编程会导致更少的错误(尽管有时是新的/不同类别的错误),因为单独的组件通过效果进行交互的可能性较小。

几乎没有人提到性能,并且无效果编程的性能通常比有效编程更差,因为计算机是冯诺依曼机器,旨在很好地处理效果(而不是设计为很好地处理 lambda)。现在我们正处于多核革命之中,这可能会改变游戏规则,因为人们发现他们需要利用核心来获得性能,而并行化有时需要火箭科学家才能完全正确地发挥作用,当你没有效果时,它很容易得到正确的结果。

于 2009-04-18T20:30:10.527 回答
18

在冯诺依曼机器中,副作用是使机器工作的东西。本质上,无论您如何编写程序,它都需要产生副作用才能工作(在低级别视图中)。

没有副作用的编程意味着将副作用抽象出来,这样你就可以考虑一般的问题——而不用担心机器的当前状态——并减少程序不同模块之间的依赖关系(无论是过程、类还是其他任何东西)。通过这样做,您将使您的程序更具可重用性(因为模块不依赖于特定状态来工作)。

所以是的,无副作用的程序是一件好事,但副作用在某种程度上是不可避免的(所以它们不能被认为是“坏的”)。

于 2009-04-18T17:57:50.847 回答
9

临:

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

缺点:

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

例如 Haskell,一开始它看起来很优雅,但是你需要开始与外部世界一起玩,它不再那么有趣了。(Haskell 将状态作为函数参数移动并将其隐藏到称为 Monads 的东西中,这使您能够以命令式的外观风格编写。)

于 2009-04-18T17:57:46.167 回答
7

没有副作用,您根本无法做某些事情。一个例子是 I/O,因为根据定义,在屏幕上显示消息是一种副作用。这就是为什么函数式编程的目标是尽量减少副作用,而不是完全消除它们。

撇开这一点不谈,在某些情况下,尽量减少副作用与其他目标(如速度或内存效率)发生冲突。其他时候,您的问题已经有一个概念模型,与突变状态的想法非常吻合,而与现有模型作斗争可能会浪费精力和精力。

于 2009-04-18T17:59:45.397 回答
7

副作用就像任何其他武器一样。它们无疑是有用的,如果处理不当,可能会非常危险。

就像武器一样,你有各种不同程度的杀伤力的副作用。

在 C++ 中,副作用是完全不受限制的,这要归功于指针。如果变量被声明为“私有”,您仍然可以使用指针技巧访问或更改它。您甚至可以更改不在范围内的变量,例如调用函数的参数和局部变量。借助操作系统(mmap)的一点帮助,您甚至可以在运行时修改程序的机器代码!当你使用像 C++ 这样的语言编写代码时,你就被提升到了 Bit God 的级别,是你进程中所有内存的大师。编译器对您的代码所做的所有优化都是在您不滥用权力的假设下进行的。

在 Java 中,您的能力受到更多限制。范围内的所有变量都在您的控制之下,包括由不同线程共享的变量,但您必须始终遵守类型系统。尽管如此,由于您可以使用操作系统的一个子集以及静态字段的存在,您的代码可能会产生非本地影响。如果一个单独的线程以某种方式关闭了 System.out,它看起来就像魔术一样。这是魔法:具有副作用的魔法。

Haskell(尽管宣传是纯粹的)具有 IO monad,它要求您使用类型系统注册所有副作用。将您的代码包装在 IO monad 中就像是 3 天的手枪等待期:您仍然可以自暴自弃,但除非您与政府达成一致。还有 unsafePerformIO 和它的同类,它们是 Haskell IO 的黑市,给你“不问任何问题”的副作用。

Miranda 是 Haskell 的前身,是一种在 monad 流行之前创建的纯函数式语言。Miranda(据我所知......如果我错了,请替换 Lambda 演算)根本没有 IO 原语。唯一完成的 IO 是编译程序(输入)并运行程序并打印结果(输出)。在这里,你拥有完全的纯洁。执行顺序完全无关。所有“效果”对于声明它们的函数来说都是局部的,这意味着代码的两个不相交部分永远不会相互影响。这是一个乌托邦(数学家)。或者相当于一个 distpia。这很无聊。什么都没有发生。你不能为它写一个服务器。您不能在其中编写操作系统。你不能在里面写 SNAKE 或俄罗斯方块。每个人都只是坐在那里看着数学。

于 2010-04-26T14:57:44.237 回答
4

确实,正如这里的一些人所提到的,没有副作用就无法做出有用的应用程序。但由此并不能说以不受控制的方式使用副作用是一件好事。

考虑以下类比:具有没有分支指令的指令集的处理器将毫无价值。但是,这并不意味着程序员必须一直使用goto。相反,结构化编程和后来的 OOP 语言(如 Java)甚至可以在没有 goto 语句的情况下完成,而且没有人错过它。

(可以肯定的是,Java 中仍然存在 goto - 现在称为breakcontinuethrow。)

于 2009-04-20T08:46:01.170 回答
3

副作用对于大多数应用程序的重要部分是必不可少的。纯函数有很多优点。它们更容易考虑,因为您不必担心前置条件和后置条件。由于它们不改变状态,它们更容易并行化,随着处理器数量的增加,这将变得非常重要。

副作用是不可避免的。只要它们是比更复杂但更纯粹的解决方案更好的选择,就应该使用它们。纯函数也是如此。有时,使用功能解决方案可以更好地解决问题。

一切都很好 =) 您应该根据要解决的问题使用不同的范例。

于 2009-04-18T18:42:40.753 回答
2

没有副作用,就不能执行 I/O 操作;所以你不能做一个有用的应用程序。

于 2009-04-18T20:36:11.197 回答
2

这句话真的让我笑了。也就是说,我发现副作用的最小化可以真正转化为更容易推理和维护的代码。然而,我没有像我想要的那样去探索函数式编程的奢侈。

在使用围绕副作用的面向对象和过程语言工作时,我看待它的方式是包含隔离副作用。

作为一个基本示例,视频游戏具有将图形渲染到屏幕的必要副作用。然而,关于副作用,这里有两种不同的设计路径。

一种是通过使渲染器非常抽象并基本上告诉要渲染什么来寻求最小化和松散耦合。然后系统的其他部分告诉渲染器要绘制什么,这可能是一组图元,如三角形和带有投影和模型视图矩阵的点,或者可能是更高层次的东西,如抽象模型、相机、灯光和粒子。无论哪种方式,这样的设计都围绕着许多导致外部副作用的事情,因为代码库的许多部分可能会将更改推送到渲染器(无论多么抽象或间接,净效应仍然是这样的一大堆东西系统触发外部渲染副作用)。

另一种方法是控制/隔离这些副作用。渲染器不会被告知要渲染什么,而是与游戏世界耦合(尽管这可能只是一些基本的抽象,也可能是对场景图的访问)。现在它自己访问场景(只读访问)并查看场景并使用更多的拉式设计来确定要渲染的内容。这导致渲染器与游戏世界的更多耦合,但这也意味着与屏幕输出相关的副作用现在完全包含在渲染器中。

后一种设计包含隔离了副作用,我发现这种设计更容易维护和保持正确。它仍然会导致副作用,但与将图形输出到屏幕相关的所有副作用现在完全包含在渲染器中。如果那里有问题,您知道错误将出现在渲染器代码中,而不是外部滥用它并告诉它错误的事情要做的结果。

正因为如此,当谈到耦合时,我总是发现在引起外部副作用的事物中最大化传出(传出)耦合并最小化传入(传入)耦合更可取。无论抽象如何,这都适用。在副作用的上下文中,对于将要发生的副作用的通信,对IRenderer具体的依赖仍然是对具体的依赖。Renderer就将发生什么副作用而言,抽象没有任何区别。

渲染器应该依赖于世界的其他部分,以便它可以将这些副作用完全隔离到屏幕上;世界其他地方不应该依赖渲染器。文件保护程序的类比相同。文件保护程序不应该被外界告知要保存什么。它应该看看周围的世界,并找出自己要保存的东西。这就是寻求隔离和控制副作用的设计路径;它往往比基于推的更基于拉。如果你绘制出依赖关系,结果往往会引入更多的耦合(尽管它可能是松散的),因为保存器可能需要与它甚至不感兴趣保存的事物耦合,或者渲染器可能需要对事物进行只读访问它甚至对渲染没有兴趣去发现它对渲染感兴趣的东西。

然而,最终的结果是依赖流远离副作用而不是流向副作用。当我们有一个系统有许多依赖流向推动外部副作用时,我总是发现这些最难推理,因为系统的许多部分可能会改变外部状态,以至于很难弄清楚是什么将要发生还要何时。因此,纠正/预防该问题的最直接方法是寻求使依赖项远离副作用,而不是流向它们。

无论如何,我发现偏爱这些类型的设计是一种实用的方法,可以帮助避免错误,也有助于在它们存在时检测和隔离它们,使它们更容易复制和纠正。

我发现的另一个有用的策略是使系统的任何给定循环/阶段的副作用更加均匀。例如,我发现在这种情况下执行三个同质循环会更容易,而不是执行从某事物中删除关联数据、将其断开链接、然后将其删除的循环。第一个同构循环可以删除相关数据。第二个同构循环可以使节点脱链。第三个同质循环可以将其从系统的其余部分中移除。这是与实现而不是设计相关的较低级别的注释,但我经常发现结果更容易推理、维护甚至优化(更容易并行化,例如,

于 2018-01-03T08:13:21.257 回答
1

因为你的程序必须有副作用才能产生任何输出或有趣的效果(除了加热你的 CPU),问题是应该在你的程序中的哪里触发这些副作用。只有当它们隐藏在您意想不到的方法中时,它们才会变得有害。

根据经验:将纯方法和具有副作用的方法分开。将某些内容打印到控制台的方法应该只执行此操作,而不是计算您可能想在其他地方使用的一些有趣的值。

于 2015-06-17T10:05:18.227 回答
0

好吧,一方面,使用副作用进行编程要容易得多,也更直观。对很多人来说,函数式编程很难理解——找到一个在 Ocaml 中教授/TAed 课程的人,你可能会听到各种关于人们无法理解它的故事。如果没有人能够真正遵循它,那么拥有设计精美、无副作用的功能代码又有什么用呢?使雇用人员来完成您的软件变得相当困难。

至少这是争论的一方面。许多人必须学习所有关于函数式风格和无副作用代码的原因有很多。想到了多线程。

于 2009-04-18T17:54:44.237 回答
0

我认为平衡的答案是,人们应该寻找机会来最小化或避免副作用,或者考虑它们在哪里,并寻找机会将它们移动到其他地方以使代码更容易理解。

这里我给出一些代码的两个版本。当我开始寻找机会将看起来像第一个版本的代码更改为看起来像第二个版本的代码时,我的生活变得更好了:

它可能有一个具有属性first, second, third和方法的对象,first_method, second_method, third_method例如

def first_method(self):
    # calculate and set self.first

def second_method(self):
    # use self.first to calculate and set self.second

def third_method(self):
    # use self.first and self.second to calculate self.third

然后是更高级别的方法:

def method(self):
    self.first_method()
    self.second_method()
    self.third_method()

现在想象一下这些名称不是以“first”、“second”、“third”为前缀,而是描述了方法的作用,并且程序更复杂。这是我在探索滥用副作用的代码时经常遇到的问题。我经常需要查看函数的实现,以了解调用它们的效果是什么以及它们如何协同工作。

现在,无需对“副作用是邪恶的”东西发疯,我们仍然可以从避免副作用的愿望中受益:

def first_func():
    # calculate the first thing and return it

def second_func(first_thing):
    # use first_thing to calculate second_thing and return it

def third_func(first_thing, second_thing):
    # use first_thing and second_thing to calculate third_thing and return it

并且更高级别的方法可能看起来像

def method(self):
    self.first_thing = first_func()
    self.second_thing = second_func(self.first_thing)
    self.third_thing = third_func(self.first_thing, self.second_thing)

We still have side effects, method sets attributes of the object, but when we go to try to understand how the functions that make it up work together, it's crystal clear that you have to call them in this order and it is also crystal clear what each of them need to do their work.

Also, with the first version, when looking at the implementation of method, who knows what other attributes of the object get changed by each function. When looking at the second version, it's plain for all to see that calling method changes only three attributes without having to look at the implementation of the functions.

This example might seem simplistic because I crafted it out of thin air for this explanation. I tried my best to convey some real problems that I have when trying to understand code that was written without a certain slight disdain for side effects. Real world code that operates like the first version is harder to understand than real world code that operates like the second version.

My take on side effects, as others have said, is that it is worth while to ask yourself whether the side effects can be moved or avoided and that doing so often results in code that is easier to understand.

于 2020-07-09T01:16:43.377 回答