引言
JavaScript是一種強大的,卻被誤解的程式語言。有些人喜歡說它是一個物件導向的程式語言,或者它是一個函數式程式語言。另外一些人喜歡說,它不是一個物件導向的程式語言,或者它不是一個函數式程式語言。還有人認為它兼具物件導向語言和函數式語言的特點,或者,認為它既不是物件導向的也不是函數式的,好吧,讓我們先擱置那些爭論。
讓我們假設我們共有這樣的一個使命:在JavaScript語言所允許的範圍內,盡可能多的使用函數式程式設計的原則來編寫程式。
首先,我們要清理下腦中那些關於函數式程式設計的錯誤觀念。
在JS界被(重度)誤解的函數式程式設計
顯然有相當一批開發者一天到晚的以函數式範式的方式使用JavaScript。我還是要說有更大量的JavaScript開發者,並不真正理解那佯做的真正意義。
我確信,導致這種局面是因為許多用於服務端的web開發語言都源自C語言,而C語言,很顯然不是一種函數式程式語言。
似乎有兩個層級的混亂,第一個層級的混亂我們用下面這個在jQuery中常會用到的例子來說明:
$(".signup").click(function(event){ $("#signupModal").show(); event.preventDefault(); });
嘿,仔細看。我傳遞一個匿名函數作為參數,這在JavaScript世界裡被稱為眾所周知「CallBack」(回呼)函數。
真有人會認為這就是函數式程式設計嗎?根本不是!
這個例子展示了一個函數式語言的關鍵特性:函數作為參數。另一方面,這個3行程式碼的例子也違背了幾乎所有其他的函數式程式設計範式。
第二層級的混亂有點微妙。讀到這裡,一些追求潮流的JS開發者在暗自思考。
好吧,廢話!但是我已經知道了所有關於函數式程式設計的知識與技能。我在我所有的項目上使用Underscore.js。
Underscore.js 是一個廣受歡迎的JavaScript函式庫,到處都在使用。舉個例子,我有一組單詞,我需要得到一個集合,集合裡的每個元素是各個單字的頭兩個字母。用Underscore.js實作這個相當簡單:
var firstTwoLetters = function(words){ return _.map(words,function(word){ return _.first(word,2); }); };
看!看JavaScript巫術。我正在使用這些高級的函數式應用函數,像是_.map 和 _.first。你還有什麼要說的,利蘭(譯註:作者Leland)?
儘管underscore 和 像_.map這樣的函數是非常有價值的函數式範式,但是像這個例子中所採用的組織程式碼的方法看起來…冗長而且對我來說太難於理解。我們真的需要這樣做嗎?
如果開始思考的時候多一點「函數式」的思維,可能我們能夠把上面的例子改成這樣:
// ...一点魔法 var firstTwoLetters = map(first(2));
仔細想想,在1行程式碼中包含了和上面5行程式碼相同的資訊。 words 和word 只是參數/佔位符。這個方法的核心是用一種更明顯的方式來組合map函數,first函數,和常數2。
JavaScript是函數式程式語言嗎?
沒有神奇的公式能夠判定一種語言是不是「函數式」語言。有些語言很明顯就是函數式的,就像其他語言很明顯不是函數式的,但是有大量語言的是模稜兩可的中間派。
於是這裡給出一些常用的、重要的函數式語言的「配料」(JavaScript能實作用粗體標誌)
這絕對不是一個排它的列表,但是我們至少要逐個討論Javascript中最重要的三個特性,它們支撐我們可以用函數式的方式來編寫程式。
讓我們逐一詳細的了解:
函數是「第一等公民」
這條可能是在所有的配料中最明顯的,並且可能是在許多現代程式語言中最常見到的。
在JavaScript局部變數是透過var關鍵字來定義的。
var foo = "bar";
JavaScript中把函數以局部變數的方式定義是非常容易做到的。
var add = function (a, b) { return a + b; }; var even = function (a) { return a % 2 === 0; };
這些都是事實,變數:變數add和變數even透過被賦值的方式,與函數定義建立引用關係,這種引用關係是在任何時候如果需要是可以被改變的。
// capture the old version of the function var old_even = even; // assign variable `even` to a new, different function even = function (a) { return a & 1 === 0; };
當然,這沒有什麼特別的。但是成為「第一等公民」這個重要的特性使得我們能夠把函數以參數的方式傳遞給另一個函數。舉例:
var binaryCall = function (f, a, b) { return f(a, b); };
这是一个函数,他接受了一个二元函数f,和两个参数a,b,然后调用这个二元函数f,该二元函数f以a、b为输入参数。
add(1,2) === binaryCall(add, 1, 2); // true
这样做看起来有点笨拙,但是当把接下来的函数式编程“配料”合并考虑的时候,牛叉之处就显而易见了…
函数能返回函数(换个说法“高阶函数”)
事情开始变的酷起来。尽管开始比较简单。函数最终以新的函数作为返回值。举个例子:
var applyFirst = function (f, a) { return function (b) { return f(a, b); }; };
这个函数(applyFirst)接受一个二元函数作为其中一个参数,可以把第一个参数(即二元函数)看作是这个applyFirst函数的“部分操作”,然后返回一个一元(一个参数)函数,该一元函数被调用的时候返回外部函数的第一个参数(f)的二元函数f(a, b)。返回两个参数的二元函数。
让我们再谈谈一些函数,例如mult(乘法)函数:
var mult = function(a, b) { return a * b; };
依循mult(乘法)函数的逻辑,我们可以写一个新的函数double(乘方):
var double = applyFirst(mult, 2); double(32); // 64 double(7.5); // 15
这就是偏函数,在FP中经常会用到。(译注:FP全名为 Functional Programming 函数式程序设计 )
我们当然可以像applyFirst那样定义函数:
var curry2 = function (f) { return function (a) { return function (b) { return f(a, b); }; }; };
现在,我想要一个double(乘方)函数,我们换种方式做:
var double = curry2(mult)(2);
这种方式被称作“函数柯里化”。有点类似partial application(偏函数应用),但是更强大一点。
准确的说,函数式编程之所以强大,大部分因于此。简单和易理解的函数成为我们构筑软件的基础构件。当拥有高水平的组织能力、很少重用的逻辑的时候,函数能够被组合和混合在一起用来表达出更复杂的行为。
高阶函数可以得到的乐趣更多。让我们看两个例子:
1.翻转二元函数参数顺序
// flip the argument order of a function var flip = function (f) { return function (a, b) { return f(b, a); }; }; divide(10, 5) === flip(divide)(5, 10); // true
2.创建一个组合了其他函数的函数
// return a function that's the composition of two functions... // compose (f, g)(x) -> f(g(x)) var compose = function (f1, f2) { return function (x) { return f1(f2(x)); }; }; // abs(x) = Sqrt(x^2) var abs = compose(sqrt, square); abs(-2); // 2
这个例子创建了一个实用的函数,我们可以使用它来记录下每次函数调用。
var logWrapper = function (f) { return function (a) { console.log('calling "' + f.name + '" with argument "' + a); return f(a); }; }; var app_init = function(config) { /* ... */ }; if(DEBUG) { // log the init function if in debug mode app_init = logWrapper(app_init); } // logs to the console if in debug mode app_init({ /* ... */ });
词法闭包+作用域
我深信理解如何有效利用闭包和作用域是成为一个伟大JavaScript开发者的关键。
那么…什么是闭包?
简单的说,闭包就是内部函数一直拥有父函数作用域的访问权限,即使父函数已经返回。<译注4>
可能需要个例子。
var createCounter = function () { var count = 0; return function () { return ++count; }; }; var counter1 = createCounter(); counter1(); // 1 counter1(); // 2 var counter2 = createCounter(); counter2(); // 1 counter1(); // 3
一旦createCounter函数被调用,变量count就被分配一个新的内存区域。然后,返回一个函数,这个函数持有对变量count的引用,并且每次调用的时候执行count加1操作。
注意从createCounter函数的作用域之外,我们是没有办法直接操作count的值。Counter1和Counter2函数可以操作各自的count变量的副本,但是只有在这种非
常具体的方式操作count(自增1)才是被支持的。
在JavaScript,作用域的边界检查只在函数被声明的时候。逐个函数,并且仅仅逐个函数,拥有它们各自的作用域表。(注:在ECMAScript 6中不再是这样,因为let的引入)
一些进一步的例子来证明这论点:
// global scope var scope = "global"; var foo = function(){ // inner scope 1 var scope = "inner"; var myscope = function(){ // inner scope 2 return scope; }; return myscope; }; console.log(foo()()); // "inner" console.log(scope); // "global"
关于作用域还有一些重要的事情需要考虑。例如,我们需要创建一个函数,接受一个数字(0-9),返回该数字相应的英文名称。
简单点,有人会这样写:
// global scope... var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; var digit_name1 = function(n){ return names[n]; };
但是缺点是,names定义在了全局作用域,可能会意外的被修改,这样可能致使digit_name1函数所返回的结果不正确。
那么,这样写:
var digit_name2 = function(n){ var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; return names[n]; };
这次把names数组定义成函数digit_name2局部变量.这个函数远离了意外风险,但是带来了性能损失,由于每次digit_name2被调用的时候,都将重新为names数组定义和分配空间。换个例子如果names是个非常大的数组,或者可能digit_name2函数在一个循环中被调用多次,这时候性能影响将非常明显。
// "An inner function enjoys that context even after the parent functions have returned." var digit_name3 = (function(){ var names = ['zero','one','two','three','four','five','six','seven','eight','nine']; return function(n){ return names[n]; }; })();
这时候我们面临第三个选择。这里我们实现立即调用的函数表达式,仅仅实例化names变量一次,然后返回digit_name3函数,在 IIFE (Immediately-Invoked-Function-Expression 立即执行表达式)的闭包函数持有names变量的引用。
这个方案兼具前两个的优点,回避了缺点。搞定!这是一个常用的模式用来创建一个不可被外部环境修改“private”(私有)状态。