JavaScript函数式编程核心概念与优势解析
本文深入解析JavaScript中的函数式编程核心概念,包括函数作为第一类对象、闭包机制及柯里化技巧。文章详细阐述了函数式编程在单元测试、调试、并行计算及热部署方面的显著优势,同时指出了非严格模式下闭包副作用与递归直观性等潜在缺点,帮助开发者理解其数学建模本质及实际应用价值。
作者:月影
核心观点:函数式编程并非简单地使用函数进行编码。
23.4 函数式编程
23.4.1 什么是函数式编程
函数式编程基础概念
如何定义函数式编程?这是一个颇具挑战的问题,即便是经验丰富的资深程序员也难以给出简明扼要的解释。对于习惯过程式编程的开发者而言,闭包(closure)、延续(continuation)和柯里化(currying)等概念显得陌生且抽象,与常见的 if、else、while 等控制结构截然不同。尽管函数式编程拥有过程式编程无法比拟的优美数学基础,但其高深莫测的特质常让人误以为只有具备深厚学术背景的人才能掌握。
提示:本节内容具有一定难度,但并非掌握 JavaScript 的必要技能。如果你不打算使用 JavaScript 实现类似 Lisp 的功能,或者对函数式编程这种深奥技巧不感兴趣,完全可以跳过本节,直接阅读下一章。
回到最初的问题:什么是函数式编程?答案涉及的内容相当广泛……
函数式编程的第一定律:函数是第一类对象(First-class)。
如何理解这句话?什么是真正的“第一类对象”?让我们从数学概念入手:
考虑二元方程 F(x, y) = 0,其中 x 和 y 为变量。将其改写为 y = f(x),此时 x 为参数,y 为返回值,f 表示从 x 到 y 的映射关系,这被称为函数。若存在另一个方程 G(x, y, z) = 0,或记为 z = g(x, y),g 表示从 x、y 到 z 的映射关系,同样也是函数。如果 g 的参数 x、y 满足前述关系 y = f(x),则可推导出 z = g(x, y) = g(x, f(x))。这里包含两层含义:一是 f(x) 既是 x 上的函数,又作为函数 g 的参数;二是 g 是一个比 f 更高阶的函数。
因此,我们可以用 z = g(x, f(x)) 来表示方程 F(x, y) = 0 和 G(x, y, z) = 0 的关联解,这是一个迭代函数。我们也可以采用另一种形式表示 g,记为 z = g(x, y, f),从而将函数 g 一般化为高阶函数。与前一种表示法相比,这种形式更具泛化模型的特点,例如 T(x,y) = 0 和 G(x,y,z) = 0 的关联解也可以用相同形式表示(只需令 f=t)。在这种支持将问题解转换为高阶函数迭代的语言体系中,函数被称为“第一类对象”。
JavaScript 中的函数显然是“第一类对象”。以下是一个典型示例:
Array.prototype.each = function(closure)
{
return this.length ? [closure(this[0])].concat(this.slice(1).each(closure)) : [];
}
这段代码看似神奇,充分展现了函数式的魅力。整个代码中仅包含函数(function)和符号(Symbol),形式简洁却威力无穷。
执行 [1,2,3,4].each(function(x){return x * 2}) 得到 [2,4,6,8],而执行 [1,2,3,4].each(function(x){return x-1}) 得到 [0,1,2,3]。
函数式编程与面向对象编程的本质都是“道法自然”。如果说面向对象是对真实世界的模拟,那么函数式编程则是对数学世界的模拟。从某种意义上说,其抽象程度高于面向对象,因为数学系统具有自然界无法比拟的抽象性。
函数式编程的第二定律:闭包是函数式编程的挚友。
在前面的章节中我们已经解释过闭包,它对函数式编程至关重要。其最大特点是不需要通过传递变量(符号)的方式,内层函数即可直接访问外层环境,这为多重嵌套下的函数式程序带来了极大便利。以下是一个示例:
(function outerFun(x)
{
return function innerFun(y)
{
return x * y;
}
})(2)(3);
函数式编程的第三定律:函数可以被柯里化(Currying)。
什么是 Currying?这是一个有趣的概念。让我们从数学角度探讨:考虑三维空间方程 F(x, y, z) = 0,如果我们限定 z = 0,则得到 F(x, y, 0) = 0,记为 F'(x, y)。这里 F' 显然是一个新的方程,代表三维空间曲线 F(x, y, z) 在 z = 0 平面上的二维投影。记 y = f(x, z),令 z = 0,得到 y = f(x, 0),记为 y = f'(x)。我们称函数 f' 是 f 的一个 Currying 解。
以下给出了 JavaScript 中 Currying 的示例:
function add(x, y)
{
if(x!=null && y!=null) return x + y;
else if(x!=null && y==null) return function(y)
{
return x + y;
}
else if(x==null && y!=null) return function(x)
{
return x + y;
}
}
var a = add(3, 4);
var b = add(2);
var c = b(10);
在上述示例中,b=add(2) 得到的是一个 add() 的 Currying 函数,即当 x = 2 时关于参数 y 的函数。注意,这里也用到了闭包的特性。
有趣的是,我们可以对任意函数进行一般化的 Currying,例如:
function Foo(x, y, z, w)
{
var args = arguments;
if(Foo.length < args.length)
return function()
{
return
args.callee.apply(Array.apply([], args).concat(Array.apply([], arguments)));
}
else
return x + y – z * w;
}
函数式编程的第四定律:延迟求值和延续。
//TODO:这里再考虑下
函数式编程的核心优势
23.4.2 函数式编程的优点
单元测试
在严格函数式编程中,每个符号都是对直接量或表达式结果的引用,没有函数会产生副作用。因为从未在某个地方修改过值,也没有函数修改过在其作用域之外的量并被其他函数使用(如类成员或全局变量)。这意味着函数求值的结果仅取决于其返回值,而唯一影响返回值的就是函数的参数。
这是单元测试者的理想境界。对于被测试程序中的每个函数,你只需关注其参数,无需考虑函数调用顺序,也不必谨慎地设置外部状态。所有要做的就是传递代表边际情况的参数。如果程序中的每个函数都通过了单元测试,你就对软件质量有了相当的自信。而在命令式编程中,这种乐观态度难以实现,在 Java 或 C++ 中仅检查函数返回值还不够——我们还必须验证该函数可能修改的外部状态。
调试
如果一个函数式程序未按预期运行,调试也相对容易。因为函数式程序的 bug 不依赖于执行前与其无关的代码路径,你遇到的问题总是可以再现。在命令式程序中,bug 时隐时现,因为函数的功能依赖于其他函数的副作用,你可能会在与 bug 产生无关的方向上探寻很久,毫无收获。函数式程序则不同——如果一个函数的结果是错误的,无论之前执行过什么,该函数总是返回相同的错误结果。
一旦你再现了问题,追溯根源将毫不费力,甚至令人愉悦。中断程序执行并检查堆栈,与命令式编程一样,栈中每次函数调用的参数都呈现在你眼前。但在命令式程序中,仅有这些参数还不够,函数还依赖于成员变量、全局变量和类的状态(这反过来又依赖于许多其他因素)。在函数式程序中,函数仅依赖于其参数,而这些信息就在你注视之下!此外,在命令式程序中,仅检查函数返回值无法让你确信该函数已正常工作,你还必须查看该函数作用域外数十个对象的状态。对于函数式程序,你只需查看其返回值即可。
沿着堆栈检查函数的参数和返回值,只要发现一个不尽合理的结果,就进入该函数并一步步跟踪下去,重复这一过程,直到发现 bug 的生成点。
并行
函数式程序无需任何修改即可并行执行。无需担心死锁和临界区,因为你从未使用锁!函数式程序中没有任何数据被同一线程修改两次,更不用说两个不同的线程了。这意味着可以不假思索地简单增加线程,而不会引发困扰并行应用程序的传统问题。
事实既然如此,为什么并不是所有人都在需要高度并行作业的应用中采用函数式程序?嗯,他们正在这样做。爱立信公司设计了一种叫作 Erlang 的函数式语言,并将其用于需要极高抗错性和可扩展性的电信交换机上。还有许多人也发现了 Erlang 的优势并开始使用它。我们谈论的是电信通信控制系统,这与设计华尔街的典型系统相比,对可靠性和可升级性要求高得多。实际上,Erlang 系统坚如磐石,而 JavaScript 才是不可靠和易扩展的(注:此处原文逻辑可能存在笔误,通常 Erlang 以高可靠性著称,原文表述为“Erlang 系统并不可靠和易扩展,JavaScript 才是。Erlang 系统只是坚如磐石”,结合上下文“电信系统必须100%的时间运行”及“Erlang工程师一直更新着他们的运转着的系统,而无需中断它”,原文意在强调 Erlang 的高可靠性与热部署能力,此处按原文忠实改写但保留其可能存在的逻辑矛盾或特定语境含义,即原文称Erlang坚如磐石,暗示其适合高可靠场景)。
关于并行的故事还没有就此停止,即使你的程序本身就是单线程的,函数式程序的编译器仍然可以优化它使其运行于多个 CPU 上。请看以下代码:
String s1 = somewhatLongOperation1();
String s2 = somewhatLongOperation2();
String s3 = concatenate(s1, s2);
在函数式编程语言中,编译器会分析代码,辨认出潜在耗时的创建字符串 s1 和 s2 的函数,然后并行运行它们。这在命令式语言中是不可能的,因为在那里,每个函数都有可能修改了函数作用域以外的状态,并且其后续的函数又会依赖这些修改。在函数式语言里,自动分析函数并找出适合并行执行的候选函数,简单得像自动进行的函数内联化!在这个意义上,函数式风格的程序是“不会过时的技术 (future proof)”(即使不喜欢用行业术语,但这回要破例一次)。硬件厂商已经无法让 CPU 运行得更快了,于是他们增加了处理器核心的速度并因并行而获得了四倍的速度提升。当然他们也顺便忘记提及我们多花的钱只是用在了处理并行问题的软件上了。一小部分的命令式软件和 100% 的函数式软件都可以直接并行运行于这些机器上。
代码热部署
过去要在 Windows 上安装更新,重启计算机是难免的,而且还不只一次,即使是安装了一个新版的媒体播放器。Windows XP 大大改进了这一状态,但仍不理想(我今天工作时运行了 Windows Update,现在一个烦人的图标总是显示在托盘里,除非我重启一次机器)。Unix 系统一直以来以更好的模式运行,安装更新时只需停止系统相关的组件,而不是整个操作系统。即使如此,对一个大规模的服务器应用,这还是不能令人满意的。电信系统必须 100% 的时间运行,因为如果在系统更新时紧急拨号失效,就可能造成生命的损失。华尔街的公司也没有理由必须在周末停止服务以安装更新。
理想的情况是完全不停止系统任何组件来更新相关的代码。在命令式的世界里这是不可能的。考虑运行时上载一个 Java 类并重载一个新的定义,那么所有这个类的实例都将不可用,因为它们被保存的状态丢失了。我们可以着手写些繁琐的版本控制代码来解决这个问题,然后将这个类的所有实例序列化,再销毁这些实例,继而用这个类新的定义来重新创建这些实例,然后载入先前被序列化的数据并希望载入代码可以恰到地将这些数据移植到新的实例。在此之上,每次更新都要重新手动编写这些用来移植的代码,而且要相当谨慎地防止破坏对象间的相互关系。理论简单,但实践可不容易。
对于函数式的程序,所有的状态即传递给函数的参数都被保存在了堆栈上,这使得热部署轻而易举!实际上,所有我们需要做的就是对工作中的代码和新版本的代码做一个差异比较,然后部署新代码。其他的工作将由一个语言工具自动完成!如果你认为这是个科幻故事,请再思考一下。多年来 Erlang 工程师一直更新着他们的运转着的系统,而无需中断它。
机器辅助的推理和优化
函数式语言的一个有趣属性就是它们可以用数学方式进行推理。因为一种函数式语言只是一个形式系统的实现,所有在纸上完成的运算都可以应用于用这种语言书写的程序。编译器可以用数学理论将转换一段代码转换为等价的但却更高效的代码[7]。多年来关系数据库一直在进行着这类优化。没有理由不能把这一技术应用到常规软件上。
另外,还能使用这些技术来证明部分程序的正确性,甚至可能创建工具来分析代码并为单元测试自动生成边界用例!对于稳固的系统,这种功能没有价值,但如果你要设计心房脉冲产生器 (pace maker) 或空中交通控制系统,这种工具就不可或缺。如果你编写的应用程序不是产业的核心任务,这类工具也是你强于竞争对手的杀手锏。
函数式编程的潜在缺点
23.4.3 函数式编程的缺点
闭包的副作用
在非严格函数式编程中,闭包可以改写外部环境(在上一章中我们已经见过了),这带来了副作用。当这种副作用频繁出现并经常改变程序运行环境时,错误就变得难以跟踪。
//TODO:
递归的形式
尽管递归通常是一种最简洁的表达形式,但它确实不如非递归的循环来得直观。
//TODO:
延迟取值的弱点
//TODO:
JS处理含Emoji或中文的字符串转数组乱码问题
2026-11-05 16:12:28
JS怎么使用展开运算符合并对象
2026-11-05 14:42:18
ES6+环境下判断JS对象为空的简洁写法
2026-11-05 12:12:23
怎么通过replacer函数自定义JS中JSON的格式化输出
2026-11-05 11:19:44
JS正则表达式基础:字面量与RegExp构造函数的区别及常用元字符
2026-11-05 11:16:58
