论坛首页 综合技术版 数据结构和算法

函数的副作用及其他

浏览 5089 次
该帖已经被评为良好帖
作者 正文
时间:2008-03-28
函数的副作用及其他
Pure Function、Impure Function、副作用、Referential Transparent

纯函数(Pure Function)是这样一种函数——输入输出数据流全是显式(Explicit)的。
显式(Explicit)的意思是,函数与外界交换数据只有一个唯一渠道——参数和返回值;函数从函数外部接受的所有输入信息都通过参数传递到该函数内部;函数输出到函数外部的所有信息都通过返回值传递到该函数外部。
如果一个函数通过隐式(Implicit)方式,从外界获取数据,或者向外部输出数据,那么,该函数就不是Pure Function,叫作Impure Function。
隐式(Implicit)的意思是,函数通过参数和返回值以外的渠道,和外界进行数据交换。比如,读取全局变量,修改全局变量,都叫作以隐式的方式和外界进行数据交换;比如,利用I/O API(输入输出系统函数库)读取配置文件,或者输出到文件,打印到屏幕,都叫做隐式的方式和外界进行数据交换。
如果用社会现象来比喻,显式(Explicit)就是显规则,隐式(Implicit)就是潜规则。
Pure Function就是那种一根筋的理想主义者,绝不搞歪门邪道,没有什么灰色收入,数据入口和出口只有一条唯一途径——参数和返回值。只要卡住参数、返回值的出入口,就可以监控所有的数据流向。这对征税很有好处。比如一般的工薪阶层,只有工资一条收入渠道,扣税是银行直接代缴的。
Impure Function就不一样了,为了行事方便,大开后门,各种暗地手段无所不用其极,路子很宽很野。从函数签名(函数名,参数列表,返回值)定义上,你不知道Impure Function内部实现中有多少潜在的条数据交换的通路。监控Impure Function的数据流向是比较困难的。对Impure Function征税,也是比较困难的。只能期望灰色收入的人群自己申报。
下面举几个例子。

f(x) { return x + 1 }
f(x)函数就是Pure Function。

a = 0
q(x) {
b = a
}
q(x) 访问了函数外部的变量。q(x)是Impure Function。

p(x){
print “hello”
}
p(x)通过I/O API输出了一个字符串。p(x)是Impure Function。

c(x) {
data = readConfig() // 读取配置文件
}
c(x)通过I/O API读取了配置文件。c(x)是Impure Function。

Pure Function内部有隐式(Implicit)的数据流,这种情况叫做副作用(Side Effect)。我们可以把副作用想象为潜规则。上述的I/O,外部变量等,都可以归为副作用。因此,Pure Function的定义也可以写为,没有副作用的函数,叫做Pure Function。
I/O API可以看作是一种特殊的全局变量。文件、屏幕、数据库等输入输出结构可以看作是独立于运行环境之外的系统外全局变量,而不是应用程序自己定义的全局变量。
上述只讨论了一般的情况,还有一种特殊的情况,我们没有讨论。有些函数的参数是一种In/Out作用的参数,即函数可能改变参数里面的内容,把一些信息通过输入参数,夹带到外界。这种情况,严格来说,也是副作用。也是Impure Function。
比如下面的函数。
process(context) {
a = context.getInfo()
result = calculate( a )
context.setResult( result )
}
这种情况下,context参数同时作为输入和输出渠道。严格意义上,这也叫作副作用。参数只作为输入,返回值只作为输出,这才符合严格的Pure Function定义。
一般情况下,Pure Function的参数应该是只读的。函数不应该改变参数内部的任何状态。
除此之外,还有一种特殊情况。比如,函数调用了获取系统时间的API。这种API是有状态的API,也可以看作是特殊的I/O API。这也是Impure Function。

Pure Function有这么多限制,那么Pure Function到底有什么好处呢?难道就是监控数据流方便?还是征税方便?
我能想到的,Pure Function的好处主要有几点:
(1)无状态,Stateless。线程安全。不需要线程同步。
(2)Pure Function相互调用组装起来的函数,还是Pure Function。
(3)应用程序或者运行环境(Runtime)可以对Pure Function的运算结果进行缓存,运算加快速度。

上述的好处(3)需要说明一下。Pure Function的输入、输出都是固定的。输入是同样的参数,输出结果一定是同样的结果。而Impure Function的副作用是无法用(参数、结果)缓存的。参见前面的例子。
没有副作用,也可以叫做Referential Transparent(引用透明)。
Referential Transparent的意思好像这样的。在一个范围内,一个变量或者表达式出现在多个地方,那么这些地方的值都是一样的,可以进行值替换。
比如,
f(x) {
a = f(x)
b = f(x)
}

编译器或者运行环境发现程序里面出现了两次f(x),就可以放心地用第一个f(x)的结果,代替另一个f(x)。(这是我的个人理解,不能确保就是如此。)

---------------------------------------
副作用、状态、I/O、AOP、Monad

Pure Function是无状态的。很好,很干净。清净琉璃体,玲珑剔透心。
Haskell就是这样一种理想主义的Pure Functional Language(纯函数式编程语言)。
但是,世界是复杂的,有很多潜规则,华丽的外表下有很多暗流在涌动。无状态的Pure Function不足以表达充满了状态和副作用的现实需求。
我们来看一看,哪些副作用是可以避免的,那些副作用是无法避免的。
首先,全局变量的副作用是很容易避免的。全局变量的读写,可以用参数和返回值代替。我们可以比较容易地消除代码中的全局变量。同样,参数里面放置返回值的情况,也可以很容易用返回值避免。
比较难以处理的情况是I/O的情况。一个应用系统总是需要输入输出的。如果一个应用系统只是在启动的时候,需要输入,在结束的时候,进行输出,那么还好处理一点。我们可以把I/O集中在系统的最外层处理,系统内部不需要处理任何I/O。但这只是一种良好的愿望。常见的应用系统都是交互式的,而且系统经常需要吐出一堆堆的log,经常需要重新接受用户的配置选项更改。
I/O又是一种非常特殊的全局变量。I/O设备(或者结构)独立于应用系统之外(比如,文件,网络,数据库系统)。应用系统很难在程序设计的层次上,用参数、返回值代替I/O API。
输出部分还是比较容易收集。我们可以把所有的输出都收集到一个巨大的List结构中,作为返回值,一层层返回到最外层。最外层统一把List中的数据全部输出到对应设备。
输入部分,就难办了。应用程序可能随时需要访问一下配置文件、数据库、系统时钟等设备。我们无法预料到程序内部什么时候需要读取什么样的设备和信息,我们无法提前准备这些输入信息作为参数。
怎么办呢?我们联想一下。AOP(Aspect Oriented Programming)最著名的例子就是Log(系统日志)。
在AOP中,函数出入口的Log等脏活累活都可以统一集中地交给几个Advice类处理。Advice类就是那种Interceptor、Filter、Proxy之类的东西。通常会有intercept、around等方法。
主体程序本身是高贵的,不需要处理Log。编译过程或者运行过程中,AOP系统负责把Advice类里面的Log处理代码夹杂到主体程序中,工作过程非常类似于计算机病毒感染的过程。
于是,进到AOP系统这个大染缸之前,主体程序还是冰清玉洁的;主体程序进入AOP系统,并从AOP系统出来之后,主题程序就已经被感染了,具有了Log等功能。
正如马克.吐温在<竞选州长>中描述的。
“你忠实的朋友,过去是正派人,现在却成了伪证犯、小偷、拐尸犯、酒疯子、贿赂犯和讹诈犯的马克•吐温。”

同样的思路,能否应用到Pure Function中呢?
比如,我们可以保持Pure Function的纯洁性,把IO这样的操作,移动到Advice(or Proxy)类里面。然后通过某种类似于AOP的机制,把两者绑定起来。
正如表面看上去,凯撒的归凯撒,上帝的归上帝,世俗王权和宗教神权互不干涉。但实际上,对于权力、金钱的渴望,通常会导致王权神权两种权力的冲突和勾结。
Haskell是Pure Functional Language。Haskell处理IO的方法之一叫作Monad。
Monad是一种臭名昭著的难以理解的东西。
Monad不是我所能理解的东西。我只能对Monad进行猜想。
我猜想,Monad是一种类似于Advice、Proxy的类型定义。
Monad是一种类型定义。可能和C++ Template有些相似。
Monad类型就是专门做IO杂事的特殊类型。除了Monad类型,其他的函数或者类型都是Pure。
如果一个Pure Function,需要输入输出,就必须被Monad包装。
我们可以想象几个IO Monad Proxy的例子。
InputProxy ( function ) { // function 就是被截获的Pure Function
a = readSomething // 读取输入设备
f( a )
}

OutputProxy( function) { // function 就是被截获的Pure Function
b = function()
print b
}

在Monad Proxy中,(我猜想)应该只能存在一条输入输出语句。如上例所示。
如果要同时输入输出。那么就必须把上述的Monad Proxy串起来。
比如,先输入,再输出。应该这么写。OutputProxy( InputProxy ( function ) )
如果用嵌套函数的写法,应该写成这样。
OutputProxy() {
b = InputProxy() {
a = readSomething()
function( a ) {
// process a
}
}

print b
}

为什么一定要保证一个函数中的输入输出语句只有一条?为什么一定要写成这样?
我猜想,这可能和Haskell的Referential Transparent的特性有关。Haskell支持Referential Transparent,支持同名变量或者同字符串表达式的任意替换。在一定程度上,Haskell程序的代码是顺序无关的。如果是Pure Function,编译器处理起来比较容易。
如果引入了和外界交换状态的输入输出语句,就必须强制代码的顺序性了。必须保证代码顺序执行。在Haskell中,要强制顺序,只能通过函数的一层层调用来保证。
正如前面所说的。Haskell中,
a = f(x)
b = g(x)
这种同一层的两次f(x)调用,不一定能够保证这两条语句的执行顺序。
要保证f在g之前执行,我们只能写成 g ( f (.. ) ) 的形式,才能保证 f 在 g 之前执行。

Haskell的do语句可能就是把看起来是平级顺序语句转化成层层嵌套调用的语法糖。
比如,
do
readSomething
print something

实际上会被翻译成函数调用的嵌套形式。
g() {
readSomething
f() {
print something
}
}

Monad类型定义其实就是为了生成这样的函数调用嵌套结构。这个生成过程好像也叫作bind(绑定)。
bind就有点类似于AOP系统的那种夹杂绑定过程,把干净的东西和副作用揉合到一起。
注,上述只是猜想。
那些城里人动不动就搞些Category之类的名词吓唬我们乡下人。不讲人话,总是讲神话。
我希望,咱老百姓能够讲述自己的故事。不整那些神鬼莫测的名词,也照样能把话说清楚。
显然,这是一种奢望。至少我还说不清楚。话语权还是握在城里人的手里。

参见
农民对城里人的抱怨
http://www.javaeye.com/topic/165962
   
时间:2008-03-29
引用
正如前面所说的。Haskell中,
a = f(x)
b = g(x)
这种同一层的两次f(x)调用,不一定能够保证这两条语句的执行顺序。
要保证f在g之前执行,我们只能写成 g ( f (.. ) ) 的形式,才能保证 f 在 g 之前执行。

??
   
0 请登录后投票
时间:2008-03-29
Trustno1 写道
引用
正如前面所说的。Haskell中,
a = f(x)
b = g(x)
这种同一层的两次f(x)调用,不一定能够保证这两条语句的执行顺序。
要保证f在g之前执行,我们只能写成 g ( f (.. ) ) 的形式,才能保证 f 在 g 之前执行。

??


这个地方, 我一直不明白.
Referential Transparent 到底是什么意思?
难道就是 Final Variable(变量是Final的,只能赋值一次)的意思?
忘了从哪里看到的. 里面说, Referential Transparent 的意思是,
在一定范围内, 只要表达式的字符串一样, 那么值必定一样.

比如, f(x + 2), 在另一个地方也出现了 f(x + 2).
由于 x 是final的, f() 函数是 pure funciton. 那么两个 f (x + 2) 必然是一样的.
因此, 编译器可能进行优化. 执行顺序不一定和代码顺序相同.
不知道是否如此?
   
0 请登录后投票
时间:2008-03-29
引用透明性,《为什么函数式编程至关重要?》一文中是这样描述的:
引用
因为没有副作用能够改变表达式的值,因此可以在任何时刻对它求值。这一特性将程序员从规定控制流的重担之下拯救出来。由于表达式可以在任何时刻被求值,程序员便可以随心所欲地使用自己要的值来代替变量,反之亦然——也就是说,程序是“引用透明”的。这一自由使得函数式程序与它们传统的对应物相比,更容易数学化地控制。

事实上它是等式推理的一部分:支持 α-替换 全部并支持 β-替换的一个子集。α-替换表现为在词法作用域范围内,对任意一个变量的所有次出现进行全体换名,程序执行效果完全不变。β-替换表现为将表达式作为函数参数代入,等效于对函数体中该表达式的所有次出现替换为该表达式,程序执行效果完全不变。但 β-替换 因为可能导致“不可到达的计算”不被执行的问题,在非惰性的函数式编程语言中不能总是成立。

附:建议把这篇文章发到 函数式编程の道 圈子。
   
15 请登录后投票
时间:2008-03-29
lichray 写道
引用透明性,《为什么函数式编程至关重要?》一文中是这样描述的:
引用
因为没有副作用能够改变表达式的值,因此可以在任何时刻对它求值。这一特性将程序员从规定控制流的重担之下拯救出来。由于表达式可以在任何时刻被求值,程序员便可以随心所欲地使用自己要的值来代替变量,反之亦然——也就是说,程序是“引用透明”的。这一自由使得函数式程序与它们传统的对应物相比,更容易数学化地控制。

事实上它是等式推理的一部分:支持 α-替换 全部并支持 β-替换的一个子集。α-替换表现为在词法作用域范围内,对任意一个变量的所有次出现进行全体换名,程序执行效果完全不变。β-替换表现为将表达式作为函数参数代入,等效于对函数体中该表达式的所有次出现替换为该表达式,程序执行效果完全不变。但 β-替换 因为可能导致“不可到达的计算”不被执行的问题,在非惰性的函数式编程语言中不能总是成立。

附:建议把这篇文章发到 函数式编程の道 圈子。


Thanks.
lichray的内容, 我大致看懂了.解除了很多疑惑.
关于β替换.我的猜想如下.
β替换是替换表达式.Haskell是Lazy语言,替换表达式的时候,可能会忽略掉“不可到达的计算”里面的表达式. 因此替换结果可能和 Eager语言(Not Lazy)不同.

---------------------

引用
因为没有副作用能够改变表达式的值,因此可以在任何时刻对它求值。这一特性将程序员从规定控制流的重担之下拯救出来。由于表达式可以在任何时刻被求值,


这是不是说明, 程序执行的顺序, 有可能和代码书写顺序不一致呢?

---------------------

函数式编程の道 圈子, 不错.
原来很多内容藏在这里.

这个帖子的后半部分主要是征求解惑篇,充满了自相矛盾的猜想,漏洞百出.
尤其是关于保证调用顺序的部分, 几段代码表达的思路都是不一致的.
还不够进入 函数式编程の道 圈子.
   
0 请登录后投票
时间:2008-03-29
引用
Haskell是Lazy语言,替换表达式的时候,可能会忽略掉“不可到达的计算”里面的表达式.

Exactly Right.
引用
这是不是说明, 程序执行的顺序, 有可能和代码书写顺序不一致呢?
这个并不完全是。代码书写的顺序是从左到右,但有些编程语言就是规定了参数代入顺序是从右到左。对于惰性语言,优势在于这只是一个实现问题,无关程序执行效果。当然,还有一些执行优化上的优势。例如可以放手进行循环不变量外提优化而不需考虑对逻辑结构的影响。

PS: 这是在谦虚还是在婉拒呢...你应该能感觉到,你的水平已经远超圈子里80%的人了,我是组长我怎么不知道。。。
   
0 请登录后投票
时间:2008-03-29
:D
不是谦虚, 也不是婉拒. 只是这贴还是个半成品.
以后, 经过整理, 可能还会重新发一份综合了大家意见修正了谬误的贴.

已经申请加入.有机会好好阅读圈子里面现有的资料.
网上现有的很多资料的学术性/数学性太强,以至于我们民科(对科学有兴趣但是却苦于文化不够的农民科学家)很难真正理解.如果有更多的通俗版本(下里巴人看的)就好了.或者,函数式的一些思想注定是阳春白雪.

------------------------

另外一个问题是,
如果不是因为执行顺序的问题,
Haskell Monad 为什么要强制把一条条的 IO 语句, 分在不同层次的函数调用中. 比如, do notaion 做的那样?

引用

Haskell的do语句可能就是把看起来是平级顺序语句转化成层层嵌套调用的语法糖。
比如,
do
readSomething
print something

实际上会被翻译成函数调用的嵌套形式。
g() {
readSomething
f() {
print something
}
}


不知道我对 do notation 的理解是否正确?
   
0 请登录后投票
时间:2008-03-29
有一点偏差。do notation 是 Monad 传递“世界变量”进行延续调用的一个简化语法,展开后应该是一个以 >> 算符连接、以 >>= 算符结尾的一个 Monad 调用序列,并非你想的那样。这样做的意义不在于对齐函数调用的顺序,而在于使所有的 IO 都使用一个“世界”,然后把函数调用做成一个类似 reduce 下降的执行链,避免 IO 的副作用污染到程序的其它部分。

PS: 啊,那个“申请”过程是 JavaEye 的一个小 Bug,关闭了申请之后这提示还会出现。申请了就已经是成员了。
   
0 请登录后投票
时间:2008-03-29
lichray 写道

do notation 是 Monad 传递“世界变量”进行延续调用的一个简化语法,展开后应该是一个以 >> 算符连接、以 >>= 算符结尾的一个 Monad 调用序列,


>>, >>=
这就是传说中的 Monad Bind 吗?

ajoo有一个JParsec项目, Haskell Parsec (一个parser combinator)的 Java 版本.
http://jparsec.codehaus.org/

里面的Parsers类的bind方法, 好像也是做这件事情的.

参照 JParsec Parser.bind() 方法的实现.
我发现 (或者说, 我猜想)

bind 做的事情, 有些类似于 Chain of Responsibility / Chain of Command.
bina 的主要目的把一串行为组装成一条链.
bind 的代码可能如下

bind ( action, nextAction) {
  return new Function() {
    action();
    nextAction();
  }
}


如果一串序列调用, action1, action2, action3
就是这样组合
f = bind( action1, bind(action2, action3))

当 f 执行的时候,
即调用 f()
就会顺序执行
action1(),
action2(),
action3().

do notaion 应该做的就是 bind 的工作.
利用 >>, >>= 等运算符组装一个 Chain of Command / Responsibility

是这样吗?
通过以上种种迹象,我总感觉到 Monad do notaion 对顺序执行的一种强制保证.

--------------------------

lichray 写道

而在于使所有的 IO 都使用一个“世界”,然后把函数调用做成一个类似 reduce 下降的执行链,避免 IO 的副作用污染到程序的其它部分。


我的感觉上, Monad类型定义本身就是用来包装并隔离副作用的.
其他的Pure Function还是Pure Function.
Monad是把 IO部分强加到 Pure Function的外部, 产生一个 Chain.
Pure Function 只是 Chain 上的一个环节.
每一条 IO 操作, 也只是 Chain 上的一个环节.
比如,Monad可能产生这样的一种 Chain,
我把它猜想为一个 List 形态的东西
f = [IO, Pure1, IO, Pure2, IO, IO, Pure3]

具体的产生过程可能是这样,
IOMonad = Monad { IO, _, IO, _,IO, IO, _ }
// 产生一个模板. 里面的 _ 表示空位, 用来放入 Pure Function 的.

f = IOMonad (Pure1, Pure2, Pure3)
结果就产生了一个 chain, 内容是 [IO, Pure1, IO, Pure2, IO, IO, Pure3]

不知道对不对?

因此我猜想, 最外层的Monad定义,已经起到了隔离包装的作用.
do notation 应该是主要用来生成 action chain 的.

假设 do notaion 是用来生成 action chain 的.
那么, 为什么一定要产生这种 chain 结构.

下面的方法

IOMonad ( Pure1, Pure2, Pure3) {
   return new Funciton() {
      someIO();
      Pure1();
      anotherIO();
      Pure2();
      againIO();
      onceMoreIO();
      Pure3();
   }
}

f = Monad(Pure1, Pure2, Pure3);

f();



不是更加清楚, 更加方便吗?
我想不出来, 为什么一定要产生这个 Chain 结构.
所以, 我猜想, 可能是因为同一层次的代码, 可能不能保证顺序执行.
   
0 请登录后投票
时间:2008-03-29
个人认为,差不多了。关于 bind 方法上的理解还有一些问题,但说起来很麻烦,就等 Trustno1 大人来仲裁吧。他的这个帖子:回albertLee:关于Category Theory 和Monad中说的比较详细,但还不够浅显。AlbertLee 的一些翻译结果可以去网上搜一下,如果里你很关心 Monad 的话。
   
0 请登录后投票
论坛首页 综合技术版 数据结构和算法

跳转论坛:
JavaEye推荐