破坏参考透明度的副作用


11

Scala中的函数式编程说明了副作用对破坏引用透明性的影响:

副作用,这意味着违反了参照透明性。

我已经阅读了SICP的一部分,其中讨论了使用“替代模型”评估程序。

当我大致了解具有引用透明性(RT)的替换模型时,可以将函数分解为最简单的部分。如果表达式是RT,则可以分解表达式并始终获得相同的结果。

但是,正如以上引用所述,使用副作用可能/将破坏替代模型。

例:

val x = foo(50) + bar(10)

如果foobar 没有副作用,则执行任一函数将始终将相同结果返回x。但是,如果它们确实有副作用,它们将更改一个变量,该变量会破坏/投入替代模型。

我对这个解释感到满意,但是我并没有完全理解它。

请纠正我,并填写有关破坏RT的副作用的所有漏洞,并讨论对替代模型的影响。

Answers:


20

让我们从引用透明性的定义开始:

如果可以在不更改程序行为的情况下将其替换为其值(换句话说,产生一个具有相同效果并在相同输入上输出的程序),则该表达式被称为参照透明的。

这意味着(例如)您可以在程序的任何部分中将2 + 5替换为7,并且该程序仍然可以运行。此过程称为替换。 当且仅当2 + 5可以用7代替而不影响程序的任何其他部分时,替换才有效。

假设我有一个名为的类Baz,其中包含函数Foo和函数Bar。为简单起见,我们只说,FooBar都返回了在。所以传递的值Foo(2) + Bar(5) == 7,如你所愿。引用透明性保证您可以在程序中的任何位置Foo(2) + Bar(5)用表达式替换表达式7,并且程序仍将以相同的方式运行。

但是,如果Foo返回传入的值,但又Bar返回传入的值加上提供给的最后一个值,该Foo怎么办? 如果您将的值存储FooBaz类中的局部变量中,那么这样做很容易。好吧,如果该局部变量的初始值为0,则该表达式Foo(2) + Bar(5)将在7您首次调用它时返回期望值,但是它将9在您第二次调用它时返回。

这有两种方式违反了参照透明性。首先,不能依靠Bar每次调用它来返回相同的表达式。其次,发生了副作用,即调用Foo影响Bar的返回值。由于您无法再保证Foo(2) + Bar(5)等于7,因此无法再替代。

这就是引用透明在实践中的含义。参照透明函数接受一些值,并返回一些相应的值,而不会影响程序中其他位置的其他代码,并且始终在给定相同输入的情况下返回相同的输出。


5
所以,打破RT禁止你使用substitution model.的一个重要问题能够使用的substitution model是用它来推理程序的权力?
Kevin Meredith 2014年

没错
罗伯特·哈维

1
+1非常清晰易懂的答案。谢谢。
Racheet 2014年

2
另外,如果这些函数是透明的或“纯净的”,它们实际运行的顺序并不重要,那么我们就不在乎foo()或bar()是否先运行,在某些情况下,它们可能永远不会评估是否不需要它们
Zachary K

1
RT的另一个优点是可以缓存昂贵的参照透明表达式(因为对它们进行一次或两次评估应产生完全相同的结果)。
dcastro

3

想象一下,您正在尝试建造一堵墙,并且得到了各种大小和形状不同的盒子。您需要在墙上填充一个特定的L形孔;您应该寻找L形的盒子,还是可以替换两个尺寸合适的直形盒子?

在功能世界中,答案是任何一种解决方案都将起作用。建立功能世界时,您无需打开盒子就能看到里面的东西。

在世界势在必行,这是危险的建立你的墙不检查每一个框的内容,并比较它们每隔框的内容:

  • 有些不牢固,如果对齐不当,则会将其他磁性盒从墙上推出。
  • 有些温度过高或过低,如果放置在相邻的空间中,会产生不良反应。

我想我会停下来,然后再花一些不太可能的隐喻来浪费您的时间,但我希望这是正确的。功能积木没有任何隐藏的惊喜,并且完全可以预测。因为您始终可以使用尺寸和形状合适的较小块来代替较大的块,并且相同尺寸和形状的两个框之间没有区别,所以具有参照透明性。对于命令式砖块,仅拥有合适的尺寸和形状是不够的-您必须知道砖块是如何构造的。不是参照透明的。

在纯函数式语言中,您需要查看的只是函数的签名以了解其功能。当然,你可能想看看里面看到它的执行方式很好,但你不具备的样子。

用命令式语言,您永远都不知道里面可能会隐藏什么惊喜。


“在纯函数式语言中,您需要查看的只是函数的签名以了解其功能。” –通常情况并非如此。是的,在参数多态性的假设下,我们可以得出以下结论:类型(a, b) -> afst函数只能是函数,类型a -> aidentity函数只能是函数,但是您不必说任何有关类型的函数(a, a) -> a
约尔格W¯¯米塔格

2

当我大致了解替换模型(具有参考透明性(RT))时,可以将函数分解为最简单的部分。如果表达式是RT,则可以分解表达式并始终获得相同的结果。

是的,直觉是正确的。以下是一些更精确的指示:

就像您说的那样,任何RT表达式都应具有single“结果”。也就是说,给定factorial(5)程序中的表达式,它应始终产生相同的“结果”。因此,如果程序中存在某个确定factorial(5)值并且产生120,则无论扩展/计算哪个“步骤顺序”(与时间无关),它都应始终产生120 。

示例:factorial功能。

def factorial(n):
    if n == 1:
        return 1
    return n * factorial(n - 1)

此说明有一些注意事项。

首先,请记住,对于相同的RT表达,不同的评估模型(请参见适用顺序与正常顺序)可能会产生不同的“结果”。

def first(y, z):
  return y

def second(x):
  return second(x)

first(2, second(3)) # result depends on eval. model

在上面的代码中,first并且second是引用透明的,但是,如果以正常顺序和应用顺序(在后者下,该表达不停止)进行评估,则最后的表达式将产生不同的“结果”。

....这导致在引号中使用“结果”。由于不需要暂停表达式,因此它可能不会产生值。因此,使用“结果”有点模糊。可以说RT表达式computations在评估模型下总是产生相同的结果。

第三,可能需要看到两个foo(50)以不同的表达式出现在程序中的不同位置,每个表达式产生的结果可能彼此不同。例如,如果语言允许动态范围,则两个表达式尽管在词法上相同,但都是不同的。在perl中:

sub foo {
    my $x = shift;
    return $x + $y; # y is dynamic scope var
}

sub a {
    local $y = 10;
    return &foo(50); # expanded to 60
}

sub b {
    local $y = 20;
    return &foo(50); # expanded to 70
}

动态范围会产生误导,因为它使人们容易想到它x是的唯一输入foo,而实际上它是xy。看到差异的一种方法是将程序转换为没有动态范围的等效程序-即显式传递参数,因此foo(x),我们定义而不是定义,foo(x, y)y在调用程序中显式传递。

关键是,我们始终处于一种function思维定势:给表达式一个特定的输入,就给我们一个相应的“结果”。如果我们提供相同的输入,则我们应该始终期待相同的“结果”。

现在,下面的代码呢?

def foo():
   global y
   y = y + 1
   return y

y = 10
foo() # yields 11
foo() # yields 12

foo过程中断RT,因为存在重新定义。也就是说,我们y在一点上进行了定义,然后再重新定义了同一点 y。在上面的perl示例中,y尽管s共享相同的字母名称“ y”,但它们是不同的绑定。这里的ys实际上是相同的。这就是为什么我们说(重新分配)是一个操作:实际上,您正在更改程序的定义。

大致来说,人们通常将差异描述如下:在无副作用的环境中,您有的映射input -> output。在“命令式”设置中,您input -> ouput所处的环境state会随时间而变化。

现在,不仅state要用表达式替换其对应的值,还必须在需要它的每个操作上对进行转换(当然,表达式可以参考相同的表达式state来执行计算)。

因此,如果在无副作用程序中,计算表达式所需的全部是其单个输入,则在命令式程序中,对于每个计算步骤,我们都需要知道输入和整个状态。推理是第一个遭受重大打击的人(现在,要调试有问题的过程,您需要输入核心转储)。某些技巧不实用,例如记忆。而且,并发和并行性也变得更具挑战性。


1
很高兴您提到记忆。这可以用作内部状态在外部不可见的示例:使用备注的函数即使在内部使用状态和变异,也仍然是参照透明的。
Giorgio 2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.