今天看了一篇黑 Haskell 的帖子,转过来供参考,根据本人的理解,加入点评。原帖地址如下:
黑 Haskell 全集
1、对函数式语言的误解
很早的时候,“函数式语言”对于我来说就是 Lisp,因为 Lisp 可以在程序的几乎任何位置定义函数,并且把它们作为值来传递(这叫做 first-class function)。后来有人告诉我,Lisp 其实不算是“函数式语言”,因为 Lisp 的函数并不“纯”(pure)。所谓“纯函数”的意思,就是像数学的函数一样,如果你给它同样的输入,它就给你同样的输出。然后你就发现在这种定义下,几乎所有程序语言里面常见的随机数函数(random),其实都不是“纯函数”。因为每一次调用 random(),你都会得到不同的随机数。
批注1:关于函数式语言的理解
事实上,“纯函数”是函数式语言特征之一,并非函数式语言的目的。
…
传统编程语言本质上是面向过程的,通过赋值、分支、循环等机械化步骤求解问题。这种求解问题的方法和传统数学解题方法有很大的不同,大家刚学编程的时候一定能感受到用编程语言求解问题和我们从小时候就熟悉的那套数学解题的方法是有很大不同的。
…
函数式编程的根本目的是想提供一个途径,让我们用传统数学的思维模式来定义问题的求解过程。遇到最明显的一个冲突就是传统编程语言中函数的概念和数学中函数概念的差别。当然,Haskell 选择数学中的函数定义。
…
批注2: 关于 first-classs object 的理解
文中提到 first-class function,这个说法源自第一 类对象(first- class object), 指可在运行期创建, 可用作函数参数或返回 值, 可存入变量的实体。 Haskell中,函数是第一类对象,最常见的用法就是匿名函数。
…
在 C 语言中,变量 f 和 函数 f 的定义区别是很明显的,也有着本质的不同:
int f = 123; // 变量定义 int f() { return 123; } // 函数定义
在 Haskell 中,二者没有任何区别:
f = 123 // 变量定义 f = 123 // 函数定义
带参数的函数是不带参数的函数(或者说变量)的自然推广。因此,在 Haskell 中,函数和普通变量地位没有任何区别,我们可以把普通变量理解成无输入变量返回常亮的函数,或者我们可以干脆认为,Haskell 里面只有函数,没有变量。如果这两来理解,大概就能明白函数式语言打算如何来进行“程序”设计了。
…
其他先不评价,函数和变量语法的一致性,应该是Haskell 一个可圈可点的语法设计。
在这种害怕自己所用的语言“不纯”的恐慌之下,我开始接触 Haskell,一种号称“纯函数式”的语言。Haskell 的社区喜欢在他们的概念里省掉“纯”这个字,把 Haskell 叫做“函数式语言”。他们喜欢“纠正”别人的概念。他们告诉人们,“不纯”的函数式语言,其实都不配叫做“函数式语言”。在他们的这种定义下,Lisp 这么老牌的函数式语言,居然都不能叫“函数式语言”了。但是看完这篇文章你就会发现,其实他们的这种定义是狭隘和错误的。
在 Haskell 里面,你不能使用通常语言里面都有的赋值语句,比如 Pascal 里的 x:=1,C 和 Java 里的 x=1,或者 Scheme 里的 (set! x 1),Common Lisp 里的 (setq x 1)。这样一来,你就不可能保留“状态”(state)。所谓“状态”,就是指“随机数种子”那样的东西,其实本质上就是“全局变量”。比如,在 C 语言里定义 random() 函数,你可以这么做:
int random()
{ static int seed = 0; seed = next_random(seed); return seed;
}
这里的 seed 是一个“static 变量”,其本质就是一个全局变量,只不过这个全局变量只能被 random 这一个函数访问。每次调用 random(),它都会使用 next_random(seed) 生成下一个随机数,并且把 seed 的值更新为这个新的随机数。在 random() 的执行结束之后,seed 会一直保存这个值。下一次调用 random(),它就会根据 seed 保存的值,算出下一个随机数,然后再次更新 seed,如此继续。这就是为什么每一次调用 random(),你都会得到不同的随机数。
可是在 Haskell 里面情况就很不一样了。由于 Haskell 不能保留状态,所以同一个“变量”在它作用域的任何位置都具有相同的值。每一个函数只要输入相同,就会输出同样的结果。所以在 Haskell 里面,你不能轻松的表达 random 这样的“不纯函数”。为了让 random 在每次调用得到不同的输出,你必须给它“不同的输入”。那怎么才能给它不同的输入呢?Haskell 采用的办法,就是把“种子”作为输入,然后返回两个值:新的随机数和新的种子,然后想办法把这个新的种子传递给下一次的 random 调用。所以 Haskell 的 random 的“线路”看起来像这个样子:
(旧种子)---> (新随机数,新种子)
现在问题来了。得到的这个新种子,必须被准确无误的传递到下一个使用 random 的地方,否则你就没法生成下一个随机数。因为没有地方可以让你“暂存”这个种子,所以为了把种子传递到下一个使用它的地方,你经常需要让种子“穿过”一系列的函数,才能到达目的地。种子经过的“路径”上的所有函数,必须增加一个参数(旧种子),并且增加一个返回值(新种子)。这就像是用一根吸管扎穿这个函数,两头通风,这样种子就可以不受干扰的通过。
所以你看到了,为了达到“纯函数”的目标,我们需要做很多“管道工”的工作,这增加了程序的复杂性和工作量。如果我们可以把种子存放在一个全局变量里,到需要的时候才去取,那就根本不需要把它传来传去的。除 random() 之外的代码,都不需要知道种子的存在。
批注3:如何实现状态编程
如何实现随机函数,是我学习 Haskell 思考的第一个问题。借助函数机制,只能实现无状态系统。随机函数需要简单的状态机模型才能实现,Haskell 的函数定义无法实现。和状态有关的内容,Haskell 全部封装到 io 子系统,基本机制依赖 monad。
…
说实话,学到这一部分我对 Haskell 很失望,因为这一部分等于独立搞了一套支持“变量”的编程语言。这样干的话,还不如直接用 C 语言编程。我这一部分刚接触,理解不深,等用 Haskell 做过实际开发再来讨论这个话题。
为了减轻视觉负担和维护这些进进出出的“状态”,Haskell 引入了一种叫 monad 的概念。它的本质是使用类型系统的“重载”(overloading),把这些多出来的参数和返回值,掩盖在类型里面。这就像把乱七八糟的电线塞进了接线盒似的,虽然表面上看起来清爽了一些,底下的复杂性却是不可能消除的。有时候我很纳闷,在其它语言里易如反掌的事情,为什么到 Haskell 里面就变成了“研究性问题”,很多时候就是 monad 这东西在捣鬼。特别是当你有多个“状态”的时候,你就需要使用像 monad transformer 这样的东西。而 monad transformer 在本质上其实是一个丑陋的 hack,它并不能从根本上解决问题,却可以让你伤透脑筋也写不出来。有些人以为会用 monad 和 monad transformer 就说明他水平高,其实这根本就是自己跟自己过不去而已。
当谈到 monad 的时候,我喜欢打这样一个比方: 使用含有 monad 的“纯函数式语言”,就像生活在一个没有电磁波的世界。 在这个世界里面没有收音机,没有手机,没有卫星电视,没有无线网,甚至没有光!这个世界里的所有东西都是“有线”的。你需要绞尽脑汁,把这些电线准确无误的通过特殊的“接线器”(monad)连接起来,才能让你的各种信息处理设备能够正常工作,才能让你自己能够看见东西。如果你想生活在这样的世界里的话,那就请继续使用 Haskell。
其实要达到纯函数式语言的这种“纯”的效果,你根本不需要使用像 Haskell 这样完全排斥“赋值语句”的语言。你甚至不需要使用 Lisp 这样的“非纯”函数式语言。你完全可以用 C 语言,甚至汇编语言,达到同样的效果。
我只举一个非常简单的例子,在 C 语言里面定义如下的函数。虽然函数体里面含有赋值语句,它却是一个真正意义上的“纯函数”:
int f(int x)
{ int y = 0; int z = 0; y = 2 * x; z = y + 1; return z / 3;
}
这是为什么呢?因为它计算的是数学函数 f(x) = (2x+1)/3 。你给它同样的输入,肯定会得到同样的输出。函数里虽然对 y 和 z 进行了赋值,但这种赋值都是“局部”的,它们不会留下“状态”。所以这个函数虽然使用了被“纯函数程序员”们唾弃的赋值语句,却仍然完全的符合“纯函数”的定义。
如果你研究过编译器,就会理解其中的道理。因为这个函数里的 y 和 z,不过是函数的“数据流”里的一些“中间节点”,它们的用途是用来暂存一些“中间结果”。这些局部的赋值操作,跟函数调用时的“参数传递”没有本质的区别,它们不过都是把信息传送到指定的节点而已。如果你不相信的话,我现在就可以把这些赋值语句全都改写成函数调用:
int f(int x) { return g(2 * x); }
int g(int y) { return h(y + 1); }
int h(int z) { return z/3; }
很显然,这两个 f 的定义是完全等价的,然而第二个定义却没有任何赋值语句。第一个函数里对 y 和 z 的“赋值语句”,被转换成了等价的“参数传递”。这两个程序如果经过我写的编译器,会生成一模一样的机器代码。所以如果你说赋值语句是错误的话,那么函数调用也应该是错误的了。那我们还要不要写程序了?
> 批注4:用 C 语言实现函数编程
这里我也有过同样的想法,如果只是实现无状态编程,不声明全局变量就行了。
…
当然,无状态编程和函数编程还是有差距的。在 Haskell 看来,变量和函数等价才能算得上函数式编程吧。
…
我觉得函数式编程至少给我们提个醒,在程序设计的时候,如果需要用到全局变量,一定要慎重做一个规划,且不可滥用全局变量。
盲目的排斥赋值语句,来自于对“纯函数”这个概念的片面理解。很多研究像 Haskell,ML 一类语言的专家,其实并不明白我上面讲的道理。他们仿佛觉得如果使用了赋值,函数就肯定不“纯”了似的。CMU 的教授 Robert Harper 就是这样一个极端。他在一篇博文里指出,人们不应该把程序里的“变量”叫做“变量”,因为它跟数学和逻辑学里所谓的“变量”不是一回事,它可以被赋值。然而,其果真如他所说的那样吗?如果你理解了我对上面的例子的分析,你就会发现其实程序里的“变量”,跟数学和逻辑学里面的“变量”相比,其实并没有本质的不同。
批注5:Haskell 排斥赋值语句吗?
Haskell 并非盲目排斥赋值语句,事实上, 如前面分析,Haskell 本质上根本没有变量的概念,一切皆为函数。
程序里的变量甚至更加严格一些。如果你把数学看作一种程序语言的话,恐怕没有一本数学书可以编译通过。因为它们里面充满了变量名冲突,未定义变量,类型错误等程序设计的低级错误。你只需要注意概率论里表示随机数的大写变量(比如 X),就会发现数学所谓的“变量”其实是多么的不严谨。这变量 X 根本不需要被赋值,它自己身上就带“副作用”!实际上,90%以上的数学家都写不出像样的程序来。所以拿数学的“变量”来衡量程序语言的“变量”,其实是颠倒了。我们应该用程序的“变量”来衡量数学的“变量”,这样数学的语言才会有所改善。
批注6:程序员比数学家更严谨
哈哈,这个我绝对同意。大学读的数学系,第一次学习编程的时候,大吃一惊,哇,编程的逻辑严谨性比大学教材里的推理过程高很多啊!
对数学和逻辑学的盲目崇拜,导致了 Harper 做出盲目的判断。这也许不能完全怪他。如果你知道他是 Alonzo Church 的第三代“学术后裔”(Alozon Church –> Stephen Kleene –> Robert Constable –> Robert Harper)并且以此为豪,你就会发现根深蒂固的逻辑学传统,阻碍了他从另外一个角度看问题。
逻辑学家虽然有他们的价值,但他们并不是先知,并不总是对的。由于沉迷于对符号的热爱,他们经常看不到事物的本质。虽然他们理解很多符号公式和推理规则,但他们却经常不明白这些符号和推理规则,到底代表着自然界中的什么物体,所以有时候他们连最基本的问题都会搞错(比如他们有时候会混淆“全称量词”?的作用域)。逻辑学家们的教条主义和崇古作风,也许就是图灵当年在 Church 手下做学生那么孤立,那么痛苦的原因。也就是这个图灵,在某种程度上超越了 Church,把一部分人从逻辑学的死板思维模式下解放了出来,变成了“计算机科学家”。当然其中某些计算机科学家堕入了另外一种极端,他们对逻辑学已有的精华一无所知,所以搞出一些完全没有原则的设计,然而这不是这篇文章的主题。
所以综上所述,我们完全没有必要追求什么“纯函数式语言”,因为我们可以在不引起混淆的前提下使用赋值语句,而写出真正的“纯函数”来。可以自由的对变量进行赋值的语言,其实超越了通常的数理逻辑的表达能力。如果你不相信这一点,就请想一想,数理逻辑的公式有没有能力推断出明天的天气?为什么天气预报都是用程序算出来的,而不是用逻辑公式推出来的?所以我认为,程序其实在某种程度上已经成为比数理逻辑更加强大的逻辑。完全用数理逻辑的思维方式来对程序语言做出评价,其实是很片面的。
批注7: 逻辑只是工具,逻辑自身无法解决实际问题
罗素是著名的逻辑主义数学学派代表人物,坚持认为数学真理可以用纯逻辑方法“凭空”推导出来。最终结果证明这个想法行不通,除了逻辑的外衣,数学还有自己非逻辑型的本质。作者的论断是符合数学界这一结论的,离开变量,单纯的逻辑推理解决不了实际问题。
说了这么多,对于“函数式语言”这一概念的误解,应该消除得差不多了。其实“函数式语言”唯一的要求,应该是能够在任意位置定义函数,并且能够把函数作为值传递,不管这函数是“纯”的还是“不纯”的。所以像 Lisp 和 ML 这样的语言,其实完全符合“函数式语言”这一称号。
2、 Hindley-Milner 类型系统的根本性错误
之前的一个时间,我曾经公开过这样一段幻灯片,它是2012年10月的时候,我在 Indiana 大学做的最后一次演讲。由于当时的委婉,我并没有直接说出这些结论的重要性。其实它揭示了 ML 和 Haskell 这类基于 Hindley-Milner 类型系统的语言的一个根本性的错误。
说明,我找到一段被删除的原文《Hindley-Milner 类型系统的根本性错误》,我觉得作者的感慨应该被保留:
“然而现在我发现,委婉其实是一种错误的态度。对待错误的东西,就是应该直截了当,一针见血。否则错误就得以机会蔓延。”
这个错误来源于对一阶逻辑的“全称量词”(universal quantifier,通常写作?)与程序函数之间关系的误解。在 HM 系统里面,多态(polymorphic)的函数能够被推导为含有全称量词的类型,比如 \x->x 的类型被推导为 ?a.a->a,但 HM 系统决定这个全称量词的位置的方式,却是没有原则的。这就导致了类型变量(type variable)的作用域(scope)的偏差。
我的研究显示,这个错误来源于 HM 系统最初的一项重要的设计,叫做 let-polymorphism。如果右边的函数是一个多态的函数,比如:
let f = \x->x in ...
let-polymorphism 总是会把全称量词的位置确定在 let 的“=”右边。然而这是一个非常错误的做法,它的错误程度近似于早期的 Lisp 所采用的 dynamic scoping。这样做的结果是,全称量词的位置会随着程序的“格式”,而不是程序的“结构”,而变化。至于什么是 dynamic scoping,你可以参考我的这篇博文。
为了弥补这个错误,30多年来,许多的人发表了许多的论文,提出了很多的“改进措施”,比如 value restriction,MLF,等等。但是我的研究却显示,所有这些“改进措施”都是丑陋的 hack。因为他们没有看到问题的根源,所以他们的方案只对一些特殊情况起作用,而不能通用。为此,我可以轻而易举的写出正确的程序,而让它不能通过这些类型系统的检查,比如像我这篇英文博文所示。如果你看到了问题的根源,就会发现 HM 系统的这个错误是无法弥补的,因为它触及了 HM 系统的根基。为了根治这个问题,let-polymorphism 必须被去除掉。
我为此提出了自己的解决方案:在 lambda 的位置进行“generalization”,也就是说把 ? 放在 lambda 的位置,而不是 let。这样一来 let-polymorphism 就不存在了。但是这样一来,HM 系统就不再是 HM 系统,因为它的“模块化类型推导”的性质,就会名存实亡。由于类型里面含有程序的“控制结构”,这个类型系统表面上看起来是在进行“模块化类型检查”,而本质上是在做一个“跨过程静态检查”(interprocedual static analysis)。也就是说,模块化的类型推导,在 HM 这样的没有“类型标记”的体系下,其实是不可能实现的。
为了达到完全通用的模块化类型检查,却又允许多态函数的存在,我们终究会需要在函数的参数位置手工写上类型,这样我们就完全的丧失了 HM 系统设计的初衷。
作者原文最后又发一通感概:
“这就是我半个学期的研究所得到的结果。很可惜的是,当我对我的导师以及 Indiana 大学的某些顶尖的逻辑学家们指出这个问题的时候,他们仿佛茫然不知我在说什么,他们甚至匆忙的试图否认这个结论的价值。我惊讶的看见了这些人头脑里的愚昧,官僚和恐惧。我不明白为什么这么明显的事情,他们却可以视而不见。这就是为什么我决定抛弃我的 PhD,因为我决定取消这些人对我的工作做出评价的资格。我也不再在乎任何其他大学的 PhD,管它是 CMU,Berkeley,Stanford 还是 Cambridge。”
…
批注8:我晕
汗,这一部分没看懂,看明白再点评。不过从这里大家可以看出,Haskell 是一个用数学语言解决问题的工具,其中运用到数理逻辑的表达方式描述变量关系,应该是很正常的事情。
3、谈惰性求值
从之前的几篇博文里面你也许已经看到了,Haskell 其实是问题相当严重的语言,然而这些问题却没有引起足够的重视。我能看到的 Haskell 的问题在于:
复杂的基于缩进的语法,使得任何编辑器都不能高效的编辑 Haskell 程序,并且使得语法分析难度加倍。对这个观点,请参考我的博文《谈语法》以及我的英文博文《Layout Syntax Considered Harmful》。
“纯函数式”的语义以及 monad 其实不是好东西。对此请参考博文《对函数式语言的误解》。
Haskell 所用的 Hindley-Milner 类型系统,其实含有一个根本性的错误。对此请参考《Hindley-Milner 类型系统的根本性错误》。
Haskell 所用的 type class,其实跟一般语言(比如 Java)里面的重载(overloading)并没有本质区别。你看到的区别都是因为 Hindley-Milner 系统和重载混合在一起产生的效果。type class 并不能比其它语言里的重载做更多的事。
这样一来,好像 Haskell 的“特征”,要么是错误的,要么就不是自己的。可是现在我再给它加上一棵稻草:Haskell 的惰性求值(lazy evaluation)方式,其实大大的限制了它的运行效率,并且使得它跟并行计算的目标相矛盾。
这是一个对我已经非常明显的问题,所以我只简要的说明一下。惰性求值的方式,使得我们在“需要”一个变量的值的时候,总是有两种可能性:1)这个变量在这之前已经被求值,所以可以直接取值 2)这个变量还没有被求值,也就是说它还是一个 thunk,我们必须启动对它的求值。
可能你已经发现了,这其实带来了类型系统的混乱。任何类型,不管是 Int, Bool, List, … 或者自定义数据类型,都多出了这么一个东西:thunk。它表示的是“还没有求值的计算”。Haskell 程序员一般把它叫做“bottom”,写作 |。它的意思是:死循环。因为任何 thunk 都有可能 1)返回一个预定的类型的值,或者 2)导致死循环。
这有点像 C++ 和 Java 里的 null 指针,因为 null 可以被作为任何其他类型使用,却又不具有那种类型的特征,所以会产生意想不到的问题。| 给 Haskell 带来的问题没那么严重,但却一样的不可预料,难以分析和调试。对于 Haskell 来说,有可能出现这样的事情:明明写了一个很小的函数,觉得应该不会花很多时间。结果呢,因为它对某个变量取值,间接的触发了一段很耗时间的代码,所以等了老半天还没返回。想知道是哪里出了问题,却难以发现线索,因为这函数并没有直接或者间接的调用那段耗时间的代码,而是这个变量的 thunk 启动了那段代码。这就导致了程序的效率难以分析:被“惰性”搁在那里的计算,有可能在出乎你意料的地方爆发。这就是所谓“平时不烧香,临时抱佛脚。”
这种不确定性,并没有带来总体计算开销的增加。然而“惰性”却在另外一方面带来了巨大的开销,这就是“问问题”的开销。每当看到一个变量,Haskell 都会问它一个问题:“你被求值了没有?”即使这变量已经被求值,而且已经被取值一百万次,Haskell 仍然会问这个问题:“你被求值了没有?”问一个变量这问题可能不要紧,可是 Haskell 会问几乎所有的变量这个问题,反复的问这个问题。这就累积成了巨大的开销。跟我在另一篇博文里谈到的“解释开销”差不多,这种问题是“运行时”的,所以没法被编译器“优化”掉。
具有讽刺意味的是,Haskell 这种“纯函数式语言”的惰性求值所需要的 thunk,全都需要“副作用”才可以更新,所以它们必须被放在内存里面,而不是寄存器里面。如果你理解了我写的《对函数式语言的误解》,你就会发现连 C 程序里面的“副作用”也没有 Haskell 这么多。这样一来,处理器的寄存器其实得不到有效的利用,从而大大增加了内存的访问。我为什么可以很确信的告诉你这个呢?因为我曾经设计了一个寄存器分配算法,于是开会的时候我问 GHC 的实现者们,你们会不会对一个新的寄存器分配算法感兴趣,我可以帮你们加到 GHC 里面。结果他们说,我们不需要,因为 Haskell 到处都是 thunk,根本就没什么机会用寄存器。
所以,问太多问题,没法充分利用寄存器,这使得 Haskell 在效率上大打折扣。
然后我们来看看,为什么惰性求值会跟并行计算的目标相冲突。这其实很明显,它的原因就在于“惰性求值”的定义。惰性求值说:“到需要我的时候再来计算我。”而并行计算说:“到需要你的时候,你最好已经被某个处理器算出来了。”所以你看到了,并行计算要求你“勤奋”,要求你事先做好准备。而惰性求值本来就是很“懒”,怎么可能没事找事,先把自己算出来呢?由于这个问题来自于“惰性求值”的定义,所以这是不可调和的矛盾。
所以,惰性求值不管是在串行处理还是在并行处理的时候,都会带来效率上的大打折扣。它是一个很鸡肋的语言特征。
批注9:惰性求值是个好主意,目前还不成熟
前些日子写了个简单的应用,但是编译以后界面输出和解释方式不一致,造成运行结果混乱,不知道是否因为惰性求值。
…
惰性求值是一个很好的想法,但是技术实现方法有待进一步研究。记得学物理的时候老师说过,不要急着把数值代入公式计算,一定要等最后结果的表达式求出来并简化后,再进行计算,这样可以极大地减少计算量。惰性求值也是同样的思路,希望能有系统的理论来解决这个问题。
4、谈谈 Currying
很多基于 lambda calculus 的程序语言,比如 ML 和 Haskell,都习惯用一种叫做 currying 的手法来表示函数。比如,如果你在 Haskell 里面这样写一个函数:
f x y = x + y
然后你就可以这样把链表里的每个元素加上 2:
map (f 2) [1, 2, 3]
它会输出 [3, 4, 5]。
注意本来 f 需要两个参数才能算出结果,可是这里的 (f 2) 只给了 f 一个参数。这是因为 Haskell 的函数定义的缺省方式是“currying”。Currying 其实就是用“单参数”的函数,来模拟多参数的函数。比如,上面的 f 的定义在 Scheme 里面相当于:
(define f (lambda (x) (lambda (y) (+ x y))))
它是说,函数 f,接受一个参数 x,返回另一个函数(没有名字)。这个匿名函数,如果再接受一个参数 y,就会返回 x + y。所以上面的例子里面,(f 2) 返回的是一个匿名函数,它会把 2 加到自己的参数上面返回。所以把它 map 到 [1, 2, 3],我们就得到了 [3, 4, 5]。
在这个例子里面,currying 貌似一个挺有用的东西,它让程序变得“简短”。如果不用 currying,你就需要制造另一个函数,写成这个样子:
map (\y->f 2 y) [1, 2, 3]
这就是为什么 Haskell 和 ML 的程序员那么喜欢 currying。这个做法其实来源于最早的 lambda calculus 的设计。因为 lambda calculus 的函数都只有一个参数,所以为了能够表示多参数的函数,有一个叫 Haskell Curry 的数学家和逻辑学家,发明了这个方法。
当然,Haskell Curry 是我很尊敬的人。不过我今天想指出的是,currying 在程序设计的实践中,其实并不是想象中的那么好。大量使用 currying,其实会带来程序难以理解,复杂性增加,并且还可能因此引起意想不到的错误。
不用 currying 的写法(\y->f 2 y)虽然比起 currying 的写法(f 2)长了那么一点,但是它有一点好。那就是你作为一个人(而不是机器),可以很清楚的从“\y->f 2 y”这个表达式,看到它的“用意”是什么。你会很清楚的看到:
“f 本来是一个需要两个参数的函数。我们只给了它第一个参数 2。我们想要把 [1, 2, 3] 这个链表里的每一个元素,放进 f 的第二个参数 y,然后把 f 返回的结果一个一个的放进返回值的链表里。”
仔细看看上面这段话说了什么吧,再来看看 (f 2) 是否表达了同样的意思?注意,我们现在的“重点”在于你,一个人,而不在于计算机。你仔细想,不要让思维的定势来影响你的判断。
你发现了吗?(f 2) 并不完全的含有 \y->f 2 y 所表达的内容。因为单从 (f 2) 这个表达式(不看它的定义),你看不到“f 总共需要几个参数”这一信息,你也看不到 (f 2) 会返回什么东西。f 有可能需要2个参数,也有可能需要3个,4个,5个…… 比如,如果它需要3个参数的话,map (f 2) [1, 2, 3] 就不会返回一个整数的链表,而会返回一个函数的链表,它看起来是这样:[(\z->f 2 1 z), (\z->f 2 2 z), (\z->f 2 3 z)]。这三个函数分别还需要一个参数,才会输出结果。
这样一来,表达式 (f 2) 含有的对“人”有用的信息,就比较少了。你不能很可靠地知道这个函数接受了一个参数之后会变成什么样子。当然,你可以去看 f 的定义,然后再回来,但是这里有一种“直觉”上的开销。如果你不能同时看见这些信息,你的脑子就需要多转一道弯,你就会缺少一些重要的直觉。这种直觉能帮助你写出更好的程序。
然而,currying 的问题不止在于这种“认知”的方面,有时候使用 curry 会直接带来代码复杂性的增加。比如,如果你的 f 定义不是加法,而是除法:
f x y = x / y
然后,我们现在需要把链表 [1, 2, 3] 里的每一个数都除以 2。你会怎么做呢?
map (f 2) [1, 2, 3] 肯定不行,因为 2 是除数,而不是被除数。熟悉 Haskell 的人都知道,可以这样做:
map (flip f 2) [1, 2, 3]
flip 的作用是“交换”两个参数的位置。它可以被定义为:
flip f x y = f y x
但是,如果 f 有 3 个参数,而我们需要把它的第 2 个参数 map 到一个链表,怎么办呢?比如,如果 f 被定义为:
f x y z = (x - y) / z
稍微动一下脑筋,你可能会想出这样的代码:
map (flip (f 1) 2) [1, 2, 3]
能想出这段代码说明你挺聪明,可是如果你这样写代码,那就是缺乏一些“智慧”。有时候,好的程序其实不在于显示你有多“聪明”,而在于显示你有多“笨”。现在我们就来看看笨一点的代码:
map (\y -> f 1 y 2) [1, 2, 3]
现在比较一下,你仍然觉得之前那段代码很聪明吗?如果你注意观察,就会发现 (flip (f 1) 2) 这个表达式,是多么的晦涩,多么的复杂。
从 (flip (f 1) 2) 里面,你几乎看不到自己想要干什么。而 \y-> f 1 y 2 却很明确的显示出,你想用 1 和 2 填充掉 f 的第一,三号参数,把第二个参数留下来,然后把得到的函数 map 到链表 [1, 2, 3]。仔细看看,是不是这样的?
所以你花费了挺多的脑力才把那使用 currying 的代码写出来,然后你每次看到它,还需要耗费同样多的脑力,才能明白你当时写它来干嘛。你是不是吃饱了没事干呢?
练习题:如果你还不相信,就请你用 currying 的方法(加上 flip)表达下面这个语句,也就是把 f 的第一个参数 map 到链表 [1, 2, 3]:
map (\y -> f y 1 2) [1, 2, 3]
得到结果之后再跟上面这个语句对比,看谁更加简单?
到现在你也许注意到了,以上的“笨办法”对于我们想要 map 的每一个参数,都是差不多的形式;而使用 currying 的代码,对于每个参数,形式有很大的差别。所以我们的“笨办法”其实才是以不变应万变的良策。
才三个参数,currying 就显示出了它的弱点,如果超过三个参数,那就更麻烦了。所以很多人为了写 currying 的函数,特意把参数调整到方便 currying 的顺序。可是程序的设计总是有意想不到的变化。有时候你需要增加一个参数,有时候你又想减少一个参数,有时候你又会有别的用法,导致你需要调整参数的顺序…… 事先安排好的那些参数顺序,很有可能不能满足你后来的需要。即使它能满足你后来的需要,你的函数也会因为 currying 而难以看懂。
这就是为什么我从来不在我的 ML 和 Haskell 程序里使用 currying 的原因。古老而美丽的理论,也许能够给我带来思想的启迪,可是未必就能带来工程中理想的效果。
批注10:美丽不能当饭吃
…
古老而美丽的理论,也许能够给我带来思想的启迪,可是未必就能带来工程中理想的效果!
…
最短的代码未必是最好的代码,易读性永远都是一个很重要的因素。Haskell 在工程学上是否有价值,等我用它完成一个项目再来评价。