JavaScript函数式编程:从误区到核心特性解析

作者:小括号时间:2026-10-29 23:36:17浏览:412

本文深入解析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”(私有)状态。


作者最新文章