不可变对象为什么能够实现函数式编程?

44

我正在尝试学习Scala,但无法理解这个概念。为什么将对象设置为不可变的有助于防止函数中的副作用?有人能像我五岁一样解释一下吗?


2
观看此Rich Hickey的主题演讲,关于值得价值: http://www.infoq.com/presentations/Value-Values - Jed Wesley-Smith
8个回答

41

这是一个有趣的问题,但难以回答。

函数式编程很大程度上使用数学来推理程序。为此,需要一种形式化描述程序及其属性证明方法的形式体系。

有许多计算模型提供这样的形式体系,例如λ演算和图灵机。它们之间存在某种程度的等同性(参见这个问题进行讨论)。

从非函数式编程到函数式编程有一种直接映射关系,例如以下例子:

a = 0
b = 1
a = a + b

以下是两种将其映射到函数式程序的方法。第一种方法是,ab都是“状态”的一部分,每一行都是从一个状态到另一个新状态的函数:

state1 = (a = 0, b = ?)
state2 = (a = state1.a, b = 1)
state3 = (a = state2.a + state2.b, b = state2.b)

这里是另一个例子,每个变量与特定时间相关联:

(a, t0) = 0
(b, t1) = 1
(a, t2) = (a, t0) + (b, t1)

那么,考虑到上述情况,为什么不使用可变性呢?

其实,数学有一个非常有趣的特点:形式越简单的公式,就越容易进行证明。换句话说,对于包含可变性的程序,我们很难进行推理。

因此,在编程中涉及可变性的概念相对较少。著名的设计模式并不是通过研究得出的,也没有任何数学支持。它们是多年试错的结果,其中一些已被证明是误导性的。其他许多“设计模式”是否可靠还无从得知。

与此同时,Haskell程序员提出了Functor、Monad、Co-monad、Zipper、Applicative、Lenses等数十个概念,这些概念都有数学支持,并且最重要的是,它们实际上构成了编程中代码组合的模式。这些概念可以用来推理程序、提高可重用性和正确性。你可以在Typeclassopedia网站上看到一些例子。

毫不奇怪,对于不熟悉函数式编程的人来说,这些东西可能有点吓人......相比之下,其他编程领域仍然在使用几十年前的概念。新概念的提出对他们来说是陌生而奇怪的。

不幸的是,所有这些模式、概念只适用于没有可变性(或其他副作用)的代码,如果存在可变性,它们的特性就不再有效,你将不得不回到猜测、测试和调试的过程中。


那么,为了定义可变性,我应该从时间还是状态的角度来看呢? - BobLoblaw
2
@VeganRhino,实际上是为了限定作用域。我不是专家,但在我看来,可变性是指标识符在其作用域中具有多个可能值。时间技巧通过向其添加时间戳来更改标识符的“名称”。状态技巧更改标识符的作用域,使其成为仅限于一个语句。 - Daniel C. Sobral

25
简而言之,如果一个函数会改变一个对象,那么它就有副作用。改变是一种副作用。这是根据定义来说的。
事实上,在一个纯粹的函数式语言中,对象是否可变在理论上并不重要,因为语言永远不会“尝试”改变对象。纯函数式语言不提供任何方法来执行副作用。
然而,Scala不是一个纯函数式语言,并且它运行在Java环境中,其中副作用非常流行。在这个环境中,使用不可变的对象鼓励你使用纯函数式风格,因为它使得基于副作用的风格不可能。你正在使用数据类型来强制保证纯度,因为语言本身不会做到这一点。
现在我会说一些其他的东西,希望能帮助你理解这个概念。
在函数式语言中,变量的基本概念是引用透明性。
引用透明性意味着值和对该值的引用之间没有区别。在这种情况下,程序的思考过程就更加简单了,因为你永远不必停下来问自己,“这是一个值还是一个值的引用?”任何曾经编写过C语言的人都知道,学习这种编程范式的重要挑战之一就是始终知道它们之间的区别。
为了实现引用透明性,一个引用所指向的值不能改变。
(警告:我即将做一个类比。)
可以这样想:在你的手机中,你保存了一些其他人手机的电话号码。你认为每当你打那个电话号码时,你都会联系到你想要谈话的人。如果其他人想要和你的朋友说话,你会给他们电话号码,他们也会找到同一个人。如果有人更换了他们的手机号码,这个系统就会崩溃。如果你想联系他们,突然需要获得他们的新电话号码。也许六个月后你拨打同一个号码时却联系到另一个人。当函数执行副作用时,你会遇到这样的情况:看起来是同一个东西,但当你试图使用它时,它现在变成了不同的东西。即使你预料到这种情况,那你之前给这个号码的所有人怎么办?你要给他们都打电话告诉他们旧的号码不能再联系到同一个人了吗?
你依赖的电话号码对应于某个人,但事实上并不总是如此。电话号码系统缺乏引用透明性:号码并不总是与人相同。
函数式语言避免了这个问题。你可以公开你的电话号码,人们将始终能够联系到你,在你余生中,永远不会联系到任何其他人。
然而,在Java平台中,事情可能会改变。你认为的一件事,一分钟后可能变成另一件事。如果是这样,你该如何停止它呢?
Scala利用类型的力量来防止这种情况,通过创建具有引用透明性的类。因此,尽管整个语言不是引用透明的,只要使用不可变类型,你的代码就会是引用透明的。
实际上,使用不可变类型编写代码的优点包括:
  1. 当读者不必查找令人惊讶的副作用时,您的代码更容易阅读。
  2. 如果您使用多个线程,则无需担心锁定,因为共享对象永远不会更改。当存在副作用时,您必须仔细思考代码,并找出两个线程可能尝试同时更改同一对象的所有地方,并保护可能导致的问题。
  3. 从理论上讲,如果编译器只使用不可变类型,那么它可以更好地对一些代码进行优化。但由于Java允许副作用,我不知道它能否有效地实现这一点。无论如何,这至少还是一个值得权衡的问题,因为有些问题使用副作用可以更加高效地解决。

这段文本一直出现问题,我不知道发生了什么。 - Nate C-K
谢谢你的回复。这意味着我应该能够使用final或static在Java/C++中模拟相同的操作。如果是这样,除了语法简洁性之外,它的优点是什么? - BobLoblaw
是的,您可以在Java或C++中模拟这个。通常,您需要将所有数据设置为私有,并仅创建公共方法来读取数据而不是更改数据。其优点是:1)使您的代码更易于阅读(您知道没有意外的副作用),2)多个线程始终看到相同的数据。 - Nate C-K
3
你对"referential transparency"的定义不完全正确。如果一个表达式用于计算某个值时,可以被该值替换而不改变程序的含义,则该表达式就是"referentially transparent"。这确实意味着任何对引用(*p)进行解引用操作的表达式只有在所引用的值(以及引用本身)是不可变的情况下才是"referentially transparent",这与你所说的相关。但它也包括在没有引用涉及的情况下将(3+4)*2替换为14的情况。 - Ben
@puchu 你可能想要添加一个答案并更详细地解释一下。 - aderchox
显示剩余2条评论

6

我采用这个五岁孩子也能理解的解释:

class Account(var myMoney:List[Int] = List(10, 10, 1, 1, 1, 5)) {
  def getBalance = println(myMoney.sum + " dollars available")
  def myMoneyWithInterest = {
    myMoney = myMoney.map(_ * 2)
    println(myMoney.sum + " dollars will accru in 1 year")
  }
}

假设我们在自动取款机前,该机使用此代码向我们提供账户信息。你需要按照以下步骤操作:
scala> val myAccount = new Account()
myAccount: Account = Account@7f4a6c40

scala> myAccount.getBalance
28 dollars available

scala> myAccount.myMoneyWithInterest
56 dollars will accru in 1 year

scala> myAccount.getBalance
56 dollars available

当我们想要查看当前余额加上一年的利息时,我们使用“mutated”更改了账户余额。现在,我们的账户余额是错误的。对于银行来说,这是个坏消息!

如果在类定义中使用“val”而不是“var”来跟踪“myMoney”,我们将无法“mutate”美元并增加我们的余额。

在REPL中定义类时,使用“val”:

error: reassignment to val
             myMoney = myMoney.map(_ * 2

Scala告诉我们,我们想要一个不可变的值,但试图改变它!

由于有了Scala,我们可以更换为val,重新编写我们的myMoneyWithInterest方法,并放心地确保我们的Account类永远不会改变余额。


谢谢你的提问。在下面的语句中 'for (arg <args) println(arg)' 中,'arg' 是一个 val 而不是 var 的原因是否相同? - BobLoblaw
1
是的,如果 arg 是一个 var,你可以在需要时对其进行 mutate],而你只是想打印它!你必须明确告诉 Scala 你想要一个可变的集合/变量。 - Kyle
如果我指定类似于 "var arg" 而不是 "val arg",那么循环会在同一个 'arg' 上工作还是每次都创建一个新的 'arg'。我希望我表述清楚了。 - BobLoblaw
1
for(var arg <- List(1,2,3,4,5)) 无法编译。 - Kyle
这是关于“不可变性”而不是关于“为什么不可变性使FP成为可能”的问题。 - aderchox

4

函数式编程的一个重要特性是:如果我使用相同的参数两次调用相同的函数,我将得到相同的结果。在许多情况下,这使得对代码的推理更加容易。

现在想象一下一个返回某个对象属性content的函数。如果该content可以更改,则该函数可能在使用相同的参数进行不同调用时返回不同的结果。=>没有更多的函数式编程。


3

首先是一些定义:

  • 副作用是状态的更改--也称为突变。
  • 不可变对象是不支持突变(副作用)的对象。

一个被传递可变对象(作为参数或在全局环境中)的函数可能会产生副作用,也可能不会。这取决于实现。

然而,如果一个函数只传递不可变对象(作为参数或在全局环境中),那么它就不可能产生副作用。因此,完全使用不可变对象将排除副作用的可能性。


1
我想你是指“然而,如果一个函数只传递不可变对象,则不可能”。 - Nate C-K
可能会产生副作用,你是完全正确的。不可变范式只是一种解决方法,它并不完整。 - puchu

1
Nate的回答很好,这里有一些例子。
在函数式编程中,有一个重要的特性,即当您使用相同的参数调用函数时,您总是会得到相同的返回值。
对于不可变对象来说,这总是成立的,因为您创建后无法修改它们:
class MyValue(val value: Int)

def plus(x: MyValue) = x.value + 10

val x = new MyValue(10)
val y = plus(x) // y is 20
val z = plus(x) // z is still 20, plus(x) will always yield 20

但是如果您有可变对象,就不能保证plus(x)对于MyValue的同一实例始终返回相同的值。

class MyValue(var value: Int)

def plus(x: MyValue) = x.value + 10

val x = new MyValue(10)
val y = plus(x) // y is 20
x.value = 30
val z = plus(x) // z is 40, you can't for sure what value will plus(x) return because MyValue.value may be changed at any point.

1
为什么不可变对象能够实现函数式编程?
它们并不能。
我们采用“函数”、“过程”、“例程”或“方法”的一种定义,这适用于许多编程语言:“通常命名的代码段,接受参数和/或返回值。”
采用“函数式编程”的一种定义:“使用函数进行编程。” 使用函数进行编程的能力与是否修改状态无关。
例如,Scheme被认为是一种函数式编程语言。它具有尾部调用、高阶函数和使用函数的聚合操作。它也具有可变对象。虽然可变性破坏了某些美好的数学特性,但并不一定会阻止“函数式编程”。

0

我已经阅读了所有的答案,但它们并不能令我满意,因为它们大多谈论的是“不可变性”,而不是它与FP的关系。

主要问题是:

为什么不可变对象能够实现函数式编程?

所以我又搜索了一下,我相信这个问题的简单答案是:“因为函数式编程基本上是建立在易于推理的函数基础之上定义的”。以下是函数式编程的定义:

通过组合纯函数构建软件的过程。

如果一个函数不是纯函数——这意味着接收相同的输入,不能保证始终产生相同的输出(例如,如果函数依赖于全局对象、日期和时间或随机数来计算输出)——那么该函数就是不可预测的!现在关于“不可变性”的故事也是一样的,如果对象不是不可变的,那么具有相同对象作为其输入的函数每次使用时可能会产生不同的结果(即副作用),这将使得对程序进行推理变得困难。

我最初尝试将这个放在评论中,但它超过了限制,我绝不是专业人士,所以请谨慎对待我的回答。


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