泛函特性和闭包的浅显讨论(转)

时间:2021-07-23 22:40:53
为后续的框架级JavaScript工程设计作准备,先简单介绍下泛函编程(Functional Programming)和闭包(Closure)。如题所说,这里的讨论是非常浅显的。泛函涉及的方面很多,远不是一篇短短的博文所能讲解清楚的。 这里所说的泛函,也就是很多开发者提及的“函数式”。泛函编程和函数式编程的英文都是Functional Programming。之所以不使用函数式编程的说法,是避免误导新人,以为函数式编程就是用若干函数库来进行编程。用泛函编程做Functional Programming的翻译我记得是蔡学镛最先提的(他那篇文章似乎是发表在《程序员》上,网上我没有搜到)。理由是数学上的泛函就是指以函数为自变量的函数,符合计算机科学中Functional Programming中高阶函数的概念,故将其翻译为泛函。这里就借用这个翻译了。 对泛函概念不是很熟的同学,建议先Google一把或者阅读这篇《 函数式编程另类指南》,对泛函等概念有个预期的了解。该文作者文笔流畅,清晰易懂,是IT界到处术语黑话如麻中难得的精品。 JavaScript中的泛函 JavaScript不是严格意义上的泛函语言,她只是支持了部分泛函的特性,从而有了一定泛函语言的特征。有人说,JavaScript = C + Lisp,Lisp那部分就是指泛函特征。但是实际上,Lisp也不是严格的泛函语言。出于应用方面的考虑,部分“泛函”语言都多多少少加入了一些命令式语言的风格。Haskell是纯粹的泛函语言,但是目前似乎还停留在各种研究所、大学的应用上中,尚未有大型的商业应用使用。 高阶函数(High-Order Function) 所有以函数为参数或者以函数为返回值的函数叫做高阶函数。在Java世界,这种特性无疑是天方夜谭;而在泛函的世界,这只是最最基本的特性。 我们先从计算以下求和函数开始: 泛函特性和闭包的浅显讨论(转) 函数1 代码很简单,你愉快地完成了任务: [javascript] function sigma(x1, x2) { for(var x = x1, s = 0; x <= x2; x++) { s += x; } return s; } [/javascript] 有一天,老板说,我们还需要计算另一种求和函数: 泛函特性和闭包的浅显讨论(转) 函数2 代码也不难,拷贝sigma函数稍微改改就行了: [javascript] function sigma2(x1, x2) { for(var x = x1, s = 0; x <= x2; x++) { s += x * x; } return s; } [/javascript] 日子久了,需求难免会增加。第N次,老板还在让你不停地增加各种各样的求和函数: 泛函特性和闭包的浅显讨论(转) 函数3 还是拷贝粘贴代码,稍微修改下: [javascript] function sigmaN(x1, x2) { for(var x = x1, s = 0; x <= x2; x++) { s += 3 * x * x - 7 * x + 2; } return s; } [/javascript] 不知不觉中,你已经违反了DRY(Dont Repeat Yourself)原则。一旦需要求和函数改为求积函数,每一个sigma函数都需要修改;代码中包含了大量重复代码,给可维护性带来了问题。 为减少冗余代码,提高代码可维护性也为早点下班,怎样重构?JavaScript的高阶函数这时就派上用场了,将求和函数的内部再次函数化,sigma就成为一个高阶函数: 泛函特性和闭包的浅显讨论(转) 代码成了这样: [javascript] function sigma(x1, x2, f) { for(var x = x1, s = 0; x <= x2; x++) { s += f(x); } return s; } [/javascript] 如何使用? [javascript] var s1 = sigma(0, 10, function( x ) { return x; }); var s2 = sigma(1, 18, function( x ) { return x * x; }); var s3 = sigma(34, 119, function( x ) { return 3 * x * x - 7 * x + 2; }); [/javascript] 函数f被作为参数传递到sigma。f负责计算求和项的值,sigma只负责将f产生的项累加。要计算不同的求和函数,只需提供不同的f就行了。这就是高阶函数提供的抽象能力。 话外音:Java没有直接提供高阶函数这种抽象特性,但是由于有接口这个好东东,也能模拟出来: [javascript] // 先声明一个回调接口 interface Callback { int call(int x); } // 函数传入接口作为参数 public int sigma(int x1, int x2, Callback callee) { for(int x = x1, s = 0; x <= x2; x++) { s += callee.call(x); } return s; } ...... { // 匿名实现回调接口 int s1 = sigma(0, 10, new Callback() { public int call(int x) { return x; } }); int s2 = sigma(1, 18, new Callback() { public int call(int x) { return x * x; } }); int s3 = sigma(34, 119, new Callback() { public int call(int x) { return 3 * x * x - 7 * x + 2; } }); } [/javascript] 这里是用接口和匿名类来模拟的高阶函数。由于不是语法层面支持,代码写出来也是比较冗长的,没有JavaScript的清爽。OO语言普遍对高阶函数缺乏直接支持,一般把这种编程方式叫做“回调(Callback)”。 柯里化(Currying) 函数的柯里化是高阶函数的一个重要应用。可以表示为当函数的部分参数确定后所形成的新函数。例如,立方体体积计算函数V = V(a, b, c) = a * b * c,如果令a = 3,函数变为V' = V'(b, c) = 3 * b * c。于是我们说,函数V被柯里化为V'。实际上V'仍然是计算立方体体积的函数,只不过V经过特化为V'后,变成了只能计算其中一条棱长为3的立方体体积的函数。 由于编程语言中函数形参列表通常是线性的,像数学上一样任意柯里化在语法层面不好设计。因此,一般都只是使用左柯里化或者右柯里化,定义如下: 左柯里化: f(x1, x2, ..., xn) == curry(f)(x1)(x2)(x3)(...)(xn) == curry(f)(x1)(x2, x3, ..., xn) == curry(f)(x1, x2)(x3, x4, ..., xn) == ... == curry(f)(x1, x2, ..., xn-1)(xn) 右柯里化: f(x1, x2, ..., xn) == rcurry(f)(xn)(xn-1)(...)(x1) == rcurry(f)(xn)(xn-1, xn-2, ..., x1) == rcurry(f)(xn, xn-1)(xn-2, xn-3, ..., x1) == ... == rcurry(f)(xn, xn-1, ..., x2)(x1) 理论太抽象,还是用一个例子来演绎柯里化的使用。有一个函数用于计算x的y次方(科学型计算器就有这个功能),代码如下: [javascript] // 原始函数 function pow(x, y) { return Math.pow(x, y); } // 左柯里化pow函数,使之变成一个指数函数 var exp = curry(pow)(2); // exp变成2的指数函数 // 右柯里化pow函数,使之变成一个多项式函数 var poly = rcurry(pow)(3); // poly变成立方函数 alert(exp(5)); // 输出32 alert(poly(3)); // 输出27 [/javascript] 可以看出原先普通的pow函数可以根据需要,柯里化为需要的形式,满足不同的需要。在某些时候,为了满足代码的功能性,需要一个较为复杂的接口。但是对于普通开发人员可能又很少使用到这些复杂的功能,这时也可以采用柯里化的形式对复杂的接口进行包装,对外只暴露经过包装的简单接口。 上面例子中神奇的curry和rcurry是如何实现的,代码如下: [javascript] function __curry__(f) { var h; return h = function(g, n) { // 可以通过n来指定柯里化的长度。若忽略n,默认采取被柯里化函数的形参个数作为柯里化长度 n = n || g.length; return function() { var args = [].slice.call(arguments); if (args.length >= n) { return g.apply(this, args); } else { return h(function() { return g.apply(this, f(args, [].slice.call(arguments))); }, n - args.length); } }; }; } var curry = __curry__(function(a0, a1) { return aconcat(a1); }); var rcurry = __curry__(function(a0, a1) { return aconcat(areverse()); }); [/javascript] 关于柯里化最后再罗嗦一点。刚才提到柯里化主要用于将复杂函数简化,但是实际应用中过于复杂的函数却通常不采用柯里化的形式包装,而会采用更加简单有效的办法:用对象而不是参数列表来传递参数。详细信息可以参见JavaScript the Ultimate 1.1 JavaScriptic第6条。 闭包(Closure) 闭包由函数定义产生。简单地说,由于作用域的嵌套关系(由于函数定义可以嵌套)和作用域的封装特性,上级作用域的语句不能访问次级作用域中的变量,与之相反,次级作用域中的语句却可以访问上级作用域的变量。这样的一种特性被称为闭包。 闭包不算是泛函的特性。但是因为许多泛函语言都支持闭包,因此,闭包可说是泛函的朋友。 给一个最简单的闭包的例子: [javascript] var x = 5; function f() { alert(x); x = 6; } f(); // 输出5 alert(x); // 输出6 [/javascript] 在函数f中,并没有定义名为x的局部变量,为什么函数能够输出结果?因为定义x这个变量的作用域比函数f的作用域更高,程序执行到函数f内部时能够看见x已经被定义。而要是有语句在外部试图访问函数f内部定义的变量,那么一定会报“未定义标识符”这样的错误。 由于闭包有着可以引用上层作用域的特性,因此,编程时大大方便了开发者使用泛函编程。如果闭包不被支持,想访问上层作用域中的变量只能通过函数参数的形式,无疑是非常麻烦的。但是,闭包虽然给开发者提供了足够的灵活性,同时也给代码的可维护性以及可调试性带来了考验。可以想到,某一个函数中引用到的某一个变量可能在本函数体中找不到,甚至可能在整个js文件中都找不到——被定义在另一个文件中。但是,运行期却是可被JavaScript引擎所能找到的。 有关泛函编程的讨论先告一段落。泛函编程还有很多精彩的内容本文没有提及,如惰性求值(Lazy Evaluation)、延续(Continuation)、Monad、Arrow、SKI Combinator等等,大家如感兴趣可以继续查阅相关资料以深入了解,如果我将来能在这些方面学有所成也将继续分享。 原文出处: http://casey-lai.appspot.com/blog/31