JavaScript的函数式编程基础指南
引言
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实现这个相当简单:
varfirstTwoLetters=function(words){
return_.map(words,function(word){
return_.first(word,2);
});
};
看!看JavaScript巫术。我正在使用这些高级的函数式应用函数,像_.map和_.first。你还有什么要说的,利兰(译注:作者Leland)?
尽管underscore和像_.map这样的函数是非常有价值的函数式范式,但是像这个例子中所采用的组织代码的方法看起来…冗长而且对于我来说太难于理解。我们真的需要这样做吗?
如果开始思考的时候多一点“函数式”的思维,可能我们能够把上面的例子改成这样:
//...一点魔法 varfirstTwoLetters=map(first(2));
仔细想想,在1行代码中包含了和上面5行代码同样的信息。words和word仅仅是参数/占位符。这个方法的核心是用一种更明显的方式组合map函数,first函数,和常量2。
JavaScript是函数式编程语言吗?
没有神奇的公式能够判定一种语言是不是“函数式”语言。有些语言很明显就是函数式的,就像另外一些语言很明显不是函数式的,但是有大量语言的是模棱两可的中间派。
于是这里给出一些常用的、重要的函数式语言的“配料”(JavaScript能实现用粗体标志)
- 函数是“第一等公民”
- 函数能够返回函数
- 词法上支持闭包
- 函数要“纯粹”
- 可靠递归
- 没有变异状态
这决不是一个排它的列表,但是我们至少要逐个讨论Javascript中最重要的三个特性,它们支撑我们可以用函数式的方式来编写程序。
让我们逐个详细的了解下:
函数是“第一等公民”
这条可能是在所有的配料中最明显的,并且可能是在很多现代编程语言中最常见到的。
在JavaScript局部变量是通过var关键字来定义的。
varfoo="bar";
JavaScript中把函数以局部变量的方式定义是非常容易做到的。
varadd=function(a,b){returna+b;};
vareven=function(a){returna%2===0;};
这些都是事实,变量:变量add和变量even通过被赋值的方式,与函数定义建立引用关系,这种引用关系是在任何时候如果需要是可以被改变的。
//capturetheoldversionofthefunction
varold_even=even;
//assignvariable`even`toanew,differentfunction
even=function(a){returna&1===0;};
当然,这没有什么特别的。但是成为“第一等公民”这个重要的特性使得我们能够把函数以参数的方式传递给另一个函数。举个例子:
varbinaryCall=function(f,a,b){returnf(a,b);};
这是一个函数,他接受了一个二元函数f,和两个参数a,b,然后调用这个二元函数f,该二元函数f以a、b为输入参数。
add(1,2)===binaryCall(add,1,2); //true
这样做看起来有点笨拙,但是当把接下来的函数式编程“配料”合并考虑的时候,牛叉之处就显而易见了…
函数能返回函数(换个说法“高阶函数”)
事情开始变的酷起来。尽管开始比较简单。函数最终以新的函数作为返回值。举个例子:
varapplyFirst=function(f,a){
returnfunction(b){returnf(a,b);};
};
这个函数(applyFirst)接受一个二元函数作为其中一个参数,可以把第一个参数(即二元函数)看作是这个applyFirst函数的“部分操作”,然后返回一个一元(一个参数)函数,该一元函数被调用的时候返回外部函数的第一个参数(f)的二元函数f(a,b)。返回两个参数的二元函数。
让我们再谈谈一些函数,例如mult(乘法)函数:
varmult=function(a,b){returna*b;};
依循mult(乘法)函数的逻辑,我们可以写一个新的函数double(乘方):
vardouble=applyFirst(mult,2); double(32); //64 double(7.5); //15
这就是偏函数,在FP中经常会用到。(译注:FP全名为FunctionalProgramming函数式程序设计)
我们当然可以像applyFirst那样定义函数:
varcurry2=function(f){
returnfunction(a){
returnfunction(b){
returnf(a,b);
};
};
};
现在,我想要一个double(乘方)函数,我们换种方式做:
vardouble=curry2(mult)(2);
这种方式被称作“函数柯里化”。有点类似partialapplication(偏函数应用),但是更强大一点。
准确的说,函数式编程之所以强大,大部分因于此。简单和易理解的函数成为我们构筑软件的基础构件。当拥有高水平的组织能力、很少重用的逻辑的时候,函数能够被组合和混合在一起用来表达出更复杂的行为。
高阶函数可以得到的乐趣更多。让我们看两个例子:
1.翻转二元函数参数顺序
//fliptheargumentorderofafunction
varflip=function(f){
returnfunction(a,b){returnf(b,a);};
};
divide(10,5)===flip(divide)(5,10);
//true
2.创建一个组合了其他函数的函数
//returnafunctionthat'sthecompositionoftwofunctions...
//compose(f,g)(x)->f(g(x))
varcompose=function(f1,f2){
returnfunction(x){
returnf1(f2(x));
};
};
//abs(x)=Sqrt(x^2)
varabs=compose(sqrt,square);
abs(-2);
//2
这个例子创建了一个实用的函数,我们可以使用它来记录下每次函数调用。
varlogWrapper=function(f){
returnfunction(a){
console.log('calling"'+f.name+'"withargument"'+a);
returnf(a);
};
};
varapp_init=function(config){
/*...*/
};
if(DEBUG){
//logtheinitfunctionifindebugmode
app_init=logWrapper(app_init);
}
//logstotheconsoleifindebugmode
app_init({
/*...*/
});
词法闭包+作用域
我深信理解如何有效利用闭包和作用域是成为一个伟大JavaScript开发者的关键。
那么…什么是闭包?
简单的说,闭包就是内部函数一直拥有父函数作用域的访问权限,即使父函数已经返回。<译注4>
可能需要个例子。
varcreateCounter=function(){
varcount=0;
returnfunction(){
return++count;
};
};
varcounter1=createCounter();
counter1();
//1
counter1();
//2
varcounter2=createCounter();
counter2();
//1
counter1();
//3
一旦createCounter函数被调用,变量count就被分配一个新的内存区域。然后,返回一个函数,这个函数持有对变量count的引用,并且每次调用的时候执行count加1操作。
注意从createCounter函数的作用域之外,我们是没有办法直接操作count的值。Counter1和Counter2函数可以操作各自的count变量的副本,但是只有在这种非
常具体的方式操作count(自增1)才是被支持的。
在JavaScript,作用域的边界检查只在函数被声明的时候。逐个函数,并且仅仅逐个函数,拥有它们各自的作用域表。(注:在ECMAScript6中不再是这样,因为let的引入)
一些进一步的例子来证明这论点:
//globalscope
varscope="global";
varfoo=function(){
//innerscope1
varscope="inner";
varmyscope=function(){
//innerscope2
returnscope;
};
returnmyscope;
};
console.log(foo()());
//"inner"
console.log(scope);
//"global"
关于作用域还有一些重要的事情需要考虑。例如,我们需要创建一个函数,接受一个数字(0-9),返回该数字相应的英文名称。
简单点,有人会这样写:
//globalscope...
varnames=['zero','one','two','three','four','five','six','seven','eight','nine'];
vardigit_name1=function(n){
returnnames[n];
};
但是缺点是,names定义在了全局作用域,可能会意外的被修改,这样可能致使digit_name1函数所返回的结果不正确。
那么,这样写:
vardigit_name2=function(n){
varnames=['zero','one','two','three','four','five','six','seven','eight','nine'];
returnnames[n];
};
这次把names数组定义成函数digit_name2局部变量.这个函数远离了意外风险,但是带来了性能损失,由于每次digit_name2被调用的时候,都将重新为names数组定义和分配空间。换个例子如果names是个非常大的数组,或者可能digit_name2函数在一个循环中被调用多次,这时候性能影响将非常明显。
//"Aninnerfunctionenjoysthatcontextevenaftertheparentfunctionshavereturned."
vardigit_name3=(function(){
varnames=['zero','one','two','three','four','five','six','seven','eight','nine'];
returnfunction(n){
returnnames[n];
};
})();
这时候我们面临第三个选择。这里我们实现立即调用的函数表达式,仅仅实例化names变量一次,然后返回digit_name3函数,在IIFE(Immediately-Invoked-Function-Expression立即执行表达式)的闭包函数持有names变量的引用。
这个方案兼具前两个的优点,回避了缺点。搞定!这是一个常用的模式用来创建一个不可被外部环境修改“private”(私有)状态。