本文深入解析JavaScript中的函数式编程实践,澄清将回调或Underscore.js简单等同于函数式的误区。重点阐述函数作为第一公民、高阶函数(偏应用与柯里化)、词法闭包及纯函数等核心特性,并通过代码示例展示如何利用这些特性构建更简洁、可组合且无副作用的程序逻辑。
JavaScript函数式编程:从误区到核心特性解析
引言
JavaScript是一门强大但常被误解的语言。关于它的范式归属,观点纷纭:有人视其为面向对象语言,有人认为是函数式语言,也有人坚持它既非面向对象也非函数式,或者兼具两者特点。暂且搁置这些争论,我们聚焦于实际开发中的选择。
我们的目标是:在JavaScript语言能力的范围内,尽可能多地应用函数式编程的原则来编写代码。
首先,需要清除对函数式编程的常见误解。
澄清函数式编程的常见误区
JavaScript社区中对函数式编程存在严重误解
确实有大量开发者以函数式范式使用JavaScript,但许多人并未真正理解其核心意义。
这种误解可能源于服务端Web开发语言多源自C语言,而C语言并非函数式语言。
这种混乱通常分为两个层级,首先通过jQuery中常见的例子来说明第一层误解:
$(".signup").click(function(event){
$("#signupModal").show();
event.preventDefault();
});
注意,这里传递了一个匿名函数作为参数,这在JavaScript中被称为“回调”(Callback)函数。
但这绝不等于函数式编程。
此例演示函数式语言核心特性:函数作为参数。同时,该3行代码示例亦违背几乎所有其他函数式编程范式。
第二层误解更为微妙。读到这里的部分开发者可能暗自得意。
“我已经掌握了所有函数式编程的知识,并在项目中广泛使用Underscore.js。”
Underscore.js是一个广受欢迎的JavaScript库。例如,若有一组单词,需要提取每个单词的前两个字母组成集合,使用Underscore.js实现如下:
var firstTwoLetters = function(words){
return _.map(words,function(word){
return _.first(word,2);
});
};
看起来使用了_.map和_.first等高级函数式应用函数。但这是否就足够了呢?
尽管Underscore及_.map等函数体现了有价值的函数式范式,但这种代码组织方式显得冗长且难以理解。我们真的需要这样写吗?
如果采用更“函数式”的思维,上述例子可以重构为:
// ...一点魔法
var firstTwoLetters = map(first(2));
这一行代码蕴含了与上面五行代码相同的信息。words和word仅是参数占位符。核心在于以更直观的方式组合map函数、first函数和常量2。
JavaScript是函数式编程语言吗?
没有公式能判定一种语言是否为“函数式”语言。有些语言明显是,有些明显不是,而许多语言处于模棱两可的中间地带。
以下是一些常用且重要的函数式语言“配料”(JavaScript能实现的部分以粗体标示)
- 函数是“第一等公民”
- 函数能够返回函数
- 词法上支持闭包
- 函数要“纯粹”
- 可靠递归
- 没有变异状态
这并非 exhaustive 列表,但我们至少需要逐个讨论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,以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
这就是偏函数(Partial Application),在FP(Functional Programming,函数式程序设计)中经常使用。
我们当然可以像applyFirst那样定义函数:
var curry2 = function (f) {
return function (a) {
return function (b) {
return f(a, b);
};
};
};
现在,我想要一个double函数,我们换种方式做:
var double = curry2(mult)(2);
这种方式被称为“函数柯里化”。它类似于偏函数应用,但更强大。
准确地说,函数式编程的强大之处大部分源于此。简单且易于理解的函数成为构建软件的基础构件。当具备高水平的组织能力且逻辑重复较少时,函数可以被组合和混合,以表达更复杂的行为。
高阶函数还能带来更多乐趣。让我们看两个例子:
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”(私有)状态。