[language] 函数的副作用及其他 (Version 2)

buaawhl 2008-03-29
原帖在这里
http://www.iteye.com/topic/177402

经过Lichray组长指点.修改版本如下.

函数的副作用及其他
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包装。
假设我们要调用Pure1, Pure2, Pure3三个Pure Function,中间还需要调用IO操作,我们可以写这样一个Monad。

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

看起来很简单。不是吗?
事实上,Monad没有这么简单。
在Haskell Monad里面,我们需要把一条条顺序执行IO操作、Pure Function变成一层层的函数调用。
至于为什么要这么做,后面再说。

Haskell的do语句(do notation)可能就是把看起来是平级顺序语句转化成层层嵌套调用的语法糖。这个过程也叫作bind(绑定)。
bind 做的事情, 有些类似于 Chain of Responsibility / Chain of Command.
bina 的主要目的把一串行为组装成一条链
>>, >>, >>= 等一系列Monad Bind动作,产生的不仅仅是一个Chain,还是一个State Machine.
一个一个环节之间的状态进行迁移.这个状态就是Continuation, 或者说Context.
产生这个Chained State Machine的目的也许不是为了线程安全. 而是为了保证在同一个函数范围内, Referential Transparent特性的有效性.
IO会引入状态, 变量或者表达式在函数内的全文替换,可能会不成立.因此要强制把任何产生状态的语句分隔到不同的函数体内.

bind的代码可能是这样. 被Bind的函数,都多了一个Continuation(或者Context)参数.用来传递当前的运行环境状态.
bind(action, nextAction) {
	return new Function(continuation) {
		nextContinuation = action(continuation)
		return nextAction(nextContinuation)
	}
}


我们可以看到, 新产生的Continuation(即当前状态,当前运行环境)总是向下传递的(有点像Lichray说的Reduce下降?).
新产生的Continuation (即新状态)不会影响到外层的函数, 只影响到相关内层的函数.
这似乎是为了保证状态变化不扩散

如果有 action1(c), action2(c), action3(c)三个函数需要bind在一起.
我们运行
f = bind (action1, bind(action2, action3))
f(continuation)

就可以顺序执行
C1 = action1(continuation)
C2 = action2(C1)
C3 = action3(C2)

do notation应该做的就是 bind 的工作.
do notation 是一种用顺序语句来代替 >>, >>. >>= 的语法糖. 其实还是会翻译成 >>, >>=.
Monad类型定义其实就是为了生成这样的嵌套函数调用链。
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]

为什么这么做呢?为什么需要这么一个函数嵌套调用链呢?为什么不直接用顺序语句呢?
我先前以为,是不是因为Transparent Referential特性不能保证同层语句的执行顺序。
经人指点(Thanks to Lichray组长),发现不是这个原因。
而是因为。
[quote=”lichray”]
问题就在这儿。在普通语言中,这样写看起来是没问题的,但一遇到并发就全是问题了。(想想看也容易理解:如果只需要这样的话,那还要 Monad 的原子延续做什么呢?)最关键的不是执行的顺序,而是执行的态度。有了延续这样的能够安全访问执行栈上任意一点的技术,才能安全地在 do 中的 IO 语句中进行并发。Haskell 不是为了没事找事儿采用这样“奇怪”的 IO 方式(也是一种程序组织方式)。


原来,形成这样一个函数调用链, 是为了强制产生运行栈 Frame 的分隔.
强制把每一个 IO 操作, 加在里面的 Pure Function, 都分隔到不同的 Stack Frame里面.

这是为了保证并发时候的线程安全性.
为什么,分隔到不同的Stack Frame就能够保证并发时的线程安全呢?

看来关键应该在 Lichray 提到的延续上面.
延续应该就是Continuation.
Continuation 可以看作是当前运行栈(或者执行树)环境 (Context )的一个快照. (是这样吗?)
(好像,支持Continuation的语言,一般都不是用线性运行栈,而是采用树形运行堆. 是这样吗?)

看来慢慢接触到问题的关键部分了.
(1) IO操作, Pure Function 分隔到不同的Stack Frame
(2) Atomic (Monad?) Continuation

这两点能够保证并发状态下的线程安全性.Right ?
满足了这两点, 程序的执行, 就可以产生无状态(Stateless)的效果 ?

经过一番思索. 我又想到了一些东西.
>>, >>, >>= 等一系列Monad Bind动作,产生的不仅仅是一个Chain,还是一个State Machine.
一个一个环节之间的状态进行迁移.这个状态就是Continuation, 或者说Context.
产生这个Chained State Machine的目的也许不是为了线程安全. 而是为了保证在同一个函数范围内, Referential Transparent特性的有效性.
IO会引入状态, 变量或者表达式在函数内的全文替换,可能会不成立.因此要强制把任何产生状态的语句分隔到不同的函数体内.

这些相关资料哪里能够获得? 不会是在 Haskell 编译器/解释器原理里面吧?
搜索了一下 Monad IO Thread Safe, 没有找到相关的资料.

搜索的时候, 发现一些不错的资料.

Archive for the '函数式编程' Category
http://www.nirvanastudio.org/category/functional-programming

chenge 2008-03-30

内容还不错,排版不太好啊,稍长。
shinyzhu 2008-12-22
讲得很清晰了。

函数式编程最接近数学,因此对数学基础的要求比其他应用语言高出很多。我的数学比较差,看来得回去翻翻高等数学书了。
pascal4123 2009-08-25
shinyzhu 写道
讲得很清晰了。

函数式编程最接近数学,因此对数学基础的要求比其他应用语言高出很多。我的数学比较差,看来得回去翻翻高等数学书了。

翻高等数学没有用, 该翻数理逻辑等.
Global site tag (gtag.js) - Google Analytics