究竟什么是副作用

对于副作用的定义,各个语言的标准文档各有不同。如 C++定义一条语句有无可能产生副作用的条件是“是否修改对象,使用库 IO 操作,调用函数”。然而无论哪个语言的副作用定 义,“对外部环境的修改”都是其中必不可少的一条。然而许多人并没有理解这条定义,私自扩展“外部环境”的外延,甚至认为“向屏幕输出”都会必然地产生副作用。事实上, 这种认识是错误的。下面对于“副作用”的概念,我将从两方面进行说明。


“输出到屏幕”的归谬

在开始探索之前,先明确一个概念,一段代码是否产生副作用是固定的,而与运行环境无关。 那么观察下面的代码:

a=input
f(x)={x+1} 
output f(a)

这是一段极为简单的代码,其中定义了一个数学函数“f(x)=x+1”按照一些人的说法,由于 存在对屏幕的 IO 操作,该段代码具有副作用。不过即使如此,其中也存在着明显的“副作 用边界”——函数 f(x)必然是不存在副作用的。

那么现在想象一下,加入我要给这个语言开发一个简单的“调试”功能,输出其内部计算的所有表达式的值,相信很快就可以做到,达到这种效果:

>>3 
4 
5
不错,不过如果我没有办法通过修改语言的解释器来实现“调试”功能,那就只能将其内化到程序中:

a=input 
f(x)={ 
         b=x+1 
         output b 
         return b 
} 
output f(a)
程序就变成了这样,就算测试工程师穷尽世界上所有的输入,程序的表现也会和之前的调试版完全一样。而且执行路径、计算语义都没有改变。 

一个程序的计算语义没有改变,依照定义却出现了“有副作用”和“无副作用”两种截然不 同的判断。究竟是哪里出了问题?而且,这只是冰山一角


傻瓜版变量实现 

想象一下,我继续改动这个语言的实现,现在它不能直接访问内存,而是通过库 IO 函数— —读写配置文件,来实现值的保存操作,不仅仅是变量,“x+1”这种右值的中间储存 也要通过配置文件来完成。简单来说,代码 a=1 反映到的是 WriteINI(“a”,1), 而 x=a 则反映到的是 WriteINI(“x”,ReadINI(“a”))。 

可能许多人已经蒙了,认为这个语言已经带上了原罪,即“天然产生副作用”。那么,我不提供给用户任何的对磁盘文件读取操作,使用了这个新的实现后,用户编写的程序表现上会有什么区别?运行我们刚刚编写的程序,同样的,执行路径,计算语义,不会有任何改变。 你是否发现了直接用“是否使用库 IO 函数”来区分是否产生副作用的荒谬?


程序等价与语义等价

这是大概有人会说我是个诡辩家,并轻松构造这样的程序:

a=input 
x=a 
f()={x=x+1} 
output x
使用全局变量传递参数和返回值,这是极为典型的副作用操作。 可能你会认为,按照前面的说法,这个程序相比原来‘计算语义’也没有改变,所以上文的评判方法不成立。甚至如果你更聪明些,还可以学我之前的方法,把那个“傻瓜版变量实现”内化到程序中, 写出这样的代码:

WriteINI(“a”,input) 
WriteINI(“x”,ReadINI(“a”)) 
f()={WriteINI(“x”,ReadINI(“x”)+1)} 
output ReadINI(“x”)
使用配置文件传递参数和返回值,几乎符合含副作用程序的所有特性。 

那么这种说法是否正确呢?

确实,这几个程序是等价的,允许存在副作用和强行禁止副作用的语言都可以图灵完备,具有图灵机等价的计算能力。也就是说,任何一段无副作用的代码都可以转化为无穷个有副作用的等价代码。但这并非是计算语义不变。比如常见的情况,两个算法完全可以输入输出完全相同,但却具有不同的复杂度,这就是因为它们的计算语义并不相同。

下面展开来说,上面的两段代码和我最开始构造的代码有什么区别,为什么它们不能证明观点的错误。


数据流语义的简单入门 

变量 a 的值由变量 b、c 决定,我们称之为变量 a 依赖于(rely on)b、c。其中,通过 b、c 得到 a 的过程称之为“操作”。那么对于我们的第一个程序,显然可以绘制出如下的“变量依赖链”:


通过依赖链(复杂时近于数据流图)表达程序语义是程序分析中“数据流分析”的基本思想之一。

而后来构造的程序则图下图所示:


通过依赖链可以清晰的看出,后构造的有副作用版本,函数附近是非线状的,未使用参数和返回值传递量。而是选择从外部读取。从函数的角度来看,f()本身无变元,如果是纯函数, 应当是一个常值函数,但因其可以读取外部变量 x,造成 x 的状态不同时其返回值不同。也就是说外部变量 x 改变了 f()的“计算语义”。使其“不纯”。即 f()对 a 产生了隐性依赖(implicit dependence)。跨域隐性依赖表现出的就是这种非线状的依赖链。 

下面是重点,如果改动构造的程序,强制调用 f()前必须改变 x 的值,那么可以将整个调用过程视作下面的函数:

callf(y)={
          x=y 
          f()={x=x+1} 
          return x 
}
此时,x 虽然相对 f()为外部变量,且在其中还进行了重赋值,但 x 绝不会影响 callf 的计算语 义,因为 x 仅显式依赖于callf 的唯一参数y。 

所有,一旦遵守“调用 f()前必须改变 x 的值”的条件,这个函数将立即退化为最开始的纯函数形式,即无函数副作用。因为将数据流图经过合理变换之后,将会产生同样的依赖链, 也就是说它们具有相同的计算语义。如果这个变换过程你感觉不好理解,那么可以思考这个问题——在比较智能的 IDE 中,如果你写如下代码:

if a>0 
   b=1 
b=-1
IDE 会立刻在第二句给你提示,这是为什么?它是怎么做到的呢?b 的最终值和 if 无关,那么上面 callf(y)的返回值与 f()跨域访问变量有关吗?

如果你还死死盯着副作用的所谓“定义”,一定会怀疑我这个“计算语义与无副作用函数相同的函数也无副作用”的概念,但要清楚,可以被证明计算等价的程序形式可以互相转化,而这些转化正是编译器优化的材料。也就是说,这段代码在编译时,编译器完全可以将它转换成 最初代码的模样生成,那么编译前后二者是否具有相同的性质?如果不是,难道程序的性质和对其进行等价转换的编译器和运行环境有关?这是我们最开始说明的概念。


完全理解了以上概念之后,你或许就能发现有无副作用的真正边界—— 

如果你所进行的“改变外部环境”的操作,不会成为以后任何代码的“隐性依赖”,即你所改变的环境与程序可访问(尤其是读取)到的环境完全隔离,它就不会对程序的执行路径和计算语义造成任何影响。那么这次改变外部环境的操作就不会产生所谓“副作用”。只要仔细想想仅能知道,在程序运行的过程中一直在读写内存,进行寄存器操作,这些操作不能与 对变元的运算和定义一一对应,是否算作“改变外部环境”?如果肆意扩展“改变外部环境” 的外延,所有的操作都会产生天然的副作用,这显然是十分荒谬的。


无副作用的“原子操作” 

虽然某些有副作用的代码添加限定之后具有和无副作用代码等价的计算语义,但如果一门程序语言要禁止副作用,仅靠这种开发者自身所给的约束是行不通的。例如前面构造的代码: “调用 f()前必须给 x 赋值”,如果不在外面套一个函数,这一点谁来保证?所以许多函数式语言变量不可变。

仅仅这样还不够,我们之前列举了许多情况(如 IO 操作)某些时候有副作用,某些时候就没有。这种操作无穷无尽,只给标准库做减法肯定不行。所有要强行定义一些原子操作, 这些操作保证无副作用(可以把这些操作看成是无函数副作用的函数)。要清楚,这种保证是强行定义出来的,比如变元的计算、定义操作,语言层面保证无副作用,也就是开发者后面的代码不会、也不能对与原子操作有关的量产生“隐式依赖”。至于如何实现操作本身,则是语言实现者来做选择——他与开发者之间只需要遵守无依赖的协议即可。如果拒绝定义原子操作,就会陷入“外界环境”外延定义的无穷漩涡当中。 

提供原子操作的意义是说明有无副作用的清晰边界,如通过原子操作组合而成的操作也一定无副作用。比如一个(纯)函数。而在这个大操作之外的单元,若不使用原子操作,就不保证无副作用。如 Haskell,将 IO 操作归为一个大类排除于原子操作之外。这是因为你可以通过自行进行的文件读写保存状态,建立“隐式依赖”。如果你做的好,可以封装这些操作为 对上层代码无副作用的新接口(比如使用 Haskell 自举)。但语言无法提供你正确使用它们的保证。所以统一禁止与大的无副作用操作混合。这种禁令也使得一些本来纯洁的操作(如 不能访问 API 读取控制台内容时的输出)也受到牵连。而且由于可以进行重定向,输入和输出不止是针对被“隔离”的控制台,也有可能被定义到不安全的文本。如果混合,这种安全性是系统无法保证的。


对于维基百科的解释

大的讨论已经结束了,如果你认真阅读,我想你已经对副作用这个概念有了超乎常人的认识。不过有些人还存在着一些对概念上的疑惑。我特以维基百科所的“side effect(computerscience)”词条为例,说一说对“副作用”这个概念本身定义常见的误解。 首先说明,维基只是一个相对专业的平台,本质依然是众包编辑,并不比教科书专业,适当 看看就可以了,不要迷信。


在维基词条第一段中有这样的描述 :

“Side effects are the most common way that a program interacts with outside world.”

“a function or expression is said to have a side effect if it …… write data to a display or file.”

看了这样的描述,你可能会在第一时间把“输出”操作化为副作用操作的真子集。不过这种 说法显然和本词条下面的说法矛盾。

“Side effects caused by a operation to execute are usually ignored when discussing side effects.” (子条目Temporal side effects)

“在讨论副作用时,执行操作时(产生的必要)副作用通常忽略不计。”

显然,这里提到了两个指代不同副作用概念,前者是词条本身所描述的,狭义副作用。而后者则是很多人所混淆的广义副作用概念。事实上我反对这种分类方法,因为由于语言应用环境的不同,每个语言所指代的副作用概念是一定的。在程序设计中不存在一个所有语言公用的广义副作用。如使用汇编语言编写底层代码,显然在屏幕上输出内容会改变语言能访问到 的“变量”状态,而且可能对接下来的代码执行有影响,显然产生了副作用。而使用高级语言,尤其是 MATLAB、Coq 之类的用作计算和证明之类的语言,屏幕环境与其工作完全隔离, 不会对其产生影响,所有它们所指的“副作用”,就不包含输出内容。 

另外,“执行操作时的必要副作用”也缺乏对外延的明确说明。如前文所述,调试工作是否算作某些时候“执行操作的必要”?内化调试工作呢?显然是算的,按照这么说,调试工作 造成的“广义副作用”不算在语言的“狭义副作用”中。那么为什么在前文又明晃晃的给副 作用举例为“write data to a display”?


另外需要特别说明的是引用透明(referential transparency)的概念,这个概念在该词条中也被 提到,并出现了和开头的矛盾。引用透明指,在一个语言可以编写出的任何代码中,任意两处参数相同的函数调用相互替换,而不影响程序的动作。则这个语言为“完全引用透明”。 相应的,一段程序也可以使用这个定义。这个定义和副作用相关。同时该词条中明确写到:

“ Absence of side effects is a necessary, but not sufficient, condition for referential transparency.” 

“(程序中)不存在副作用是(该程序)引用透明的必要条件。”

按照这个说法,如果一个程序引用透明,也就可以推出其“Absence of side effects”。参照 前面的第二个调试代码,如果我多写几个 f(x)的调用,并任意互相调换位置,显然值不互相影响,也就是说,前面的调试程序确实引用透明。显然,它不存在副作用。

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值