我从去年年初开始接触函数式编程,看了很多和函数式编程相关的书和博客,如
JS函数式编程
、
Haskell趣学指南
、
Real World Haskell
,接触到很多函数式语言,如 Haskell, Elixir, Lisp系列等等。随着函数式代码写得越来越多,我越来越喜欢函数式的编程方式,渐渐成为了一名函数式编程的爱好者。本文是我学习函数式编程以来的一些总结。
函数式编程是一种编程范式,主要是利用函数把运算过程封装起来,通过组合各种函数来计算结果。举个简单的例子,假设我们要把字符串
functional programming is great
变成每个单词首字母大写,我们可以这样实现:
var string = 'functional programming is great';
var result = string
.split(' ')
.map(v => v.slice(0, 1).toUpperCase() + v.slice(1))
.join(' ');
或许对很多人来说这并没有什么特别之处,但这里已经是利用了函数式编程的核心思想: 通过函数对数据进行转换。
上面的例子先用 split
把字符串转换数组,然后再通过 map
把各元素的首字母转换成大写,最后通过 join
把数组转换成字符串。 整个过程就是 join(map(split(str)))
,我们只是利用面向对象编程的接口来把这个转换过程写成上面那样。
细心的同学可以发现,其实这个过程和 Unix 的管道操作非常相似,管道操作通过 |
来把上一个程序的输出重定向到下一个程序的输入,如:cat log.txt | grep mystring
。这种方式非常符合我们对计算机的理解:给定特定输入,返回计算结果。函数式编程正好是这种方式的一种「实现」。
函数式编程与面向对象编程
我们知道,函数式编程是一种编程范式,它经常与面向对象编程作比较。
经常编写面向对象程序的人们会发现,面向对象是让操作围绕数据,我们会定义一个类的属性,然后定义一些方法,这些方法会围绕这些属性来进行操作。如:
class ObjectManager {
constructor() {
this._objects = [];
add(object) {
this._objects.push(obj);
return this;
get(index) {
return this._objects[index];
var objects = new ObjectManager();
objects.add({ key: 1 }).add({ key: 2 }).add({ key: 3 });
objects.get(2); // { key: 3 }
可以看见,上面代码中的 add
、get
方法其实是围绕 this._objects
这个数据来进行操作。如果是函数式编程的话,更多的是让数据围绕操作。我们会定义一系列函数,然后让数据「流」过这些函数,最后输出结果。
// basic implementation
function add(array, object) {
return array.concat(object);
function get(array, index) {
return array[index];
var objects = add(add(add([], { key: 1 }), { key: 2 }), { key: 3});
get(objects, 2); // { key: 3 }
这就是用函数式编程改写过的代码,尽管非常难以阅读,一点也不符合函数式编程简洁的特点,但它确实代表了函数式编程的基本思想:通过函数对数据进行转换。我们可以用以下方式来改写:
// improvement
Array.prototype.get = function(index) { return this[index]; };
[].concat({ key: 1 }) // [{ key: 1 }]
.concat({ key: 2 }) // [{ key: 1 }, { key: 2 }]
.concat({ key: 3 }) // [{ key: 1 }, { key: 2 }, { key: 3 }]
.get(2); // { key: 3 }
这个版本才像函数式的写法,它有两个要点:
用 get
代替直接通过 [index]
来访问数据,它和面向对象版本的 get
非常相似,是为了避免直接访问数组。
利用 concat
代替 push
拼接数组,这和面向对象版本的 add
有本质的区别。尽管大家看起来都是链式调用,但面向对象版本是通过返回 this
来实现链式调用,而函数式版本是通过返回一个新数组的方式来实现链式调用的。这点可以很好的体现出 操作围绕数据 和 数据围绕操作 的区别。
函数式编程的基本特点有两个:
通过函数来对数据进行转换
通过串联多个函数来求结果
我们来看看下面这个例子:假设有三个函数,f(x)
可以把 x 转换成 a,g(a)
可以把 a 转换成 b,h(b)
可以把 b 转换成 c,即:
f(x) -> a
g(a) -> b
h(b) -> c
那么怎么把 x 转换成 c ?相信这难不倒大家,我们只需要通过如下式子就可以实现:
h( g( f(x) ) )
上面的式子可以解读成把 f(x)
的结果传给 g 函数(即 g(f(x))
), 再把结果传给 h 函数,即是上式子的意思。
由于串联函数在函数式编程中实在太常见了,各个函数式语言都有自己的表达方式。下面我们来看看各个函数式语言是怎么表达这个式子的:
Lisp 家族
(h (g (f x)))
Haskell
let result = h . g . f x
Elixir:
result = x |> f |> g |> h
函数式编程除了提倡串联函数之外,还有很多有趣的特性,接下来我们来看看函数式编程中一些常见的特性。
很多时候你去查阅函数式编程的相关资料,你都会看到下面这些术语:
指调用函数时不会修改外部状态,即一个函数调用 n 次后依然返回同样的结果。
var a = 1;
// 含有副作用,它修改了外部变量 a
// 多次调用结果不一样
function test1() {
return a;
// 无副作用,没有修改外部状态
// 多次调用结果一样
function test2(a) {
return a + 1;
指一个函数只会用到传递给它的变量以及自己内部创建的变量,不会使用到其他变量。
var a = 1;
var b = 2;
// 函数内部使用的变量并不属于它的作用域
function test1() {
return a + b;
// 函数内部使用的变量是显式传递进去的
function test2(a, b) {
return a + b;
不可变变量
指的是一个变量一旦创建后,就不能再进行修改,任何修改都会生成一个新的变量。使用不可变变量最大的好处是线程安全。多个线程可以同时访问同一个不可变变量,让并行变得更容易实现。
由于 JavaScript 原生不支持不可变变量,需要通过第三方库来实现。 (如 Immutable.js,Mori 等等)
var obj = Immutable({ a: 1 });
var obj2 = obj.set('a', 2);
console.log(obj); // Immutable({ a: 1 })
console.log(obj2); // Immutable({ a: 2 })
函数是一等公民
指的是函数和其他数据类型一样,可以赋值给变量,可以作为参数传递,可以作为返回值,可以作为数组中的函数,可以是对象中的一个值等等。下面这些术语都是围绕这一特性的应用:
高阶函数 (Higher order function)
如果一个函数接受函数作为参数,或者返回值为函数,那么该函数就是高阶函数。
// 接受函数的函数
function test1(callback) {
callback()
// 返回函数的函数
function test2() {
return function() {
console.log(1);
闭包 (Closure)
如果一个函数引用了自由变量,那么该函数就是一个闭包。何谓自由变量?自由变量是指不属于该函数作用域的变量(所有全局变量都是自由变量,严格来说引用了全局变量的函数都是闭包,但这种闭包并没有什么用,通常情况下我们说的闭包是指函数内部的函数)。
// test1 是普通函数
function test1() {
var a = 1;
// test2 是内部函数
// 它引用了 test1 作用域中的变量 a
// 因此它是一个闭包
return function test2() {
return a + 1;
函数组合 (Composition)
前面提到过,函数式编程的一个特点是通过串联函数来求值。然而,随着串联函数数量的增多,代码的可读性就会不断下降。函数组合就是用来解决这个问题的方法。假设有一个 compose
函数,它可以接受多个函数作为参数,然后返回一个新的函数。当我们为这个新函数传递参数时,该参数就会「流」过其中的函数,最后返回结果。
// 组合 f, g 函数, 从左到右求值
var composed = compose(f,g);
composed(x) = g(f(x));
// 组合 f, g, h 函数, 从右到左求职
var composed = composeRight(h, g, f);
composed(x) = h(g(f(x)));
柯里化 (Currying)
柯里化是对函数的封装,当调用函数时传递参数数量不足时,会返回一个新的函数,直到参数数量足够时才进行求值。
// 假设函数 f 接受三个参数
f = curry(f);
f(x,y,z) = f(x,y)(z) = f(x)(y,z) = f(x)(y)(z);
模式匹配 (Pattern matching)
模式匹配是指可以为一个函数定义多个版本,通过传入不同参数来调用对应的函数。形式上有点像「方法重载」,但方法重载是通过传入*参数类型*不同来区分的,模式匹配没有这个限制。利用模式匹配,我们可以去掉函数中的「分支」(最常见的是 if
),写出非常简洁的代码。
// 普通版本,需要在函数内部对参数进行判断
function fib(x) {
if (x === 0) return 0;
if (x === 1) return 1;
return fib(x-1) + fib(x-2);
// 模式匹配版本。
// 由于 JavaScript 不支持模式匹配,
// 下面代码只是作演示用途
var fib = (0) => 0;
var fib = (1) => 1;
var fib = (x) => fib(x-1) + fib(x-2);
// 调用
fib(10);
可以看到,如果能在语言层面支持模式匹配,那么在函数定义中就可以省略很多「分支」。
上面的例子可能不够明显,请想象一下在编写 Restful API 时,我们需要解析请求中的参数,然后返回对应内容。传统方式下,我们需要写很多 if
语句来进行判断,如果该 API 支持非常多参数,那么该函数就会有非常多的 if
语句,这种函数在维护的时候会是一个噩梦。
如果支持模式匹配,那么我们只需要定义多个函数即可。
接下来来看看几个常见的函数。
映射函数,通过定义函数来实现数据的映射,简单来说就是令一个数组变换成另一个数组,如要将 [1,2,3]
变换成 [4,5,6]
,我们只需要定义一个 x+3
的变换即可,即:
[1,2,3].map(x => x+3); // 返回 [4, 5, 6]
function map(array, fn) {
var result = [];
for (var i = 0; i < array.length; i++) {
var transformed = fn(array[i], i);
result.push(transformed);
return result;
reduce
(有些语言里面叫 fold
)
归纳函数,给定一个初始值,再定义一个函数来归纳数组,最后返回一个值,简单来说就是令一个数组变成一个值。如对 [1,2,3]
求和,相当于给定初始值 0,然后用它去「流」过数组,最后生成结果,翻译成 JavaScript 代码就是:
[1, 2, 3].reduce((prev, next) => prev + next, 0); // 返回 6
function reduce(array, acc, fn) {
for (var i = 0; i < array.length; i++) {
acc = fn(acc, array[i]);
return acc;
function filter(array, fn) {
var result = [];
for (var i = 0; i < array.length; i++) {
if (fn(array[i], i)) {
result.push(array[i]);
return result;
最后我们来看看一个混合使用这些函数的例子:
假设我要从 http://example.com?type=1&keyword=hello&test=
中提取查询字符串,过滤掉无效键值对,最后返回一个 Object
形式的结果,大概会有以下步骤:
http://example.com?type=1&keyword=hello&test
->
type=1&keyword=hello&test
->
[ 'type=1', 'keyword=hello', 'test' ]
->
[ [ 'type', '1' ], [ 'keyword', 'hello' ], ['test'] ]
->
[ [ 'type', '1' ], [ 'keyword', 'hello' ] ]
->
{ type: 1, keyword: 'hello' }
这些步骤翻译成函数式代码即是:
.split('?')[1]
.split('&')
.map(v => v.split('='))
.filter(v => v.length === 2)
.reduce((prev, next) =>
Object.assign(prev, { [next[0]]: next[1] }),
从上面的代码中我们可以发现,列表类的数据结构是非常常用的,函数式语言通常都会对这类数据结构进行优化。列表在函数式语言里面常常以下面这种形式存在:
[ head | tail ]
即一个列表可以看成是由头元素和尾部来组成,如:
// 普通列表
[1,2,3]
// [1, 2, 3] 可以看成是 1 和 [2, 3] 的组合
[1 | [2,3]]
// [2, 3] 可以看成是 2 和 [3] 的组合
[1 | [2 | [3]]]
// [3] 可以看成是 3 和 [] 的组合
[1 | [2 | [3 | []]]]
我们可以定义函数来获取列表的头部和尾部:
head([1,2,3]) -> 1
tail([1,2,3]) -> [2,3]
那么把列表定义成这样的形式到底有什么好处呢?答案就是:方便递归。
递归在函数式编程中是非常重要的概念,它可以通过调用函数的形式来模拟循环。如我们要对 [1,2,3,4,5]
求和,最常见的做法是写 for
循环,然后一个一个加起来。而用递归的话则是定义一个相加函数,然后不断对其进行调用。
// JavaScript
function sum(list) {
if (list.length === 0) return 0;
var h = head(list);
var t = tail(list);
return h + sum(t);
上面的代码虽然是递归,但由于 JavaScript 不支持模式匹配,因此写起来非常啰嗦,我们来看看支持模式匹配的代码:
-- Haskell
sum [] = 0
sum (x:xs) = x + sum(xs)
Haskell 的实现只需要两行,它做的事情和上面的 JavaScript 一样,都是递归求和。上面这种形式的递归计算过程如下:
1 + sum([2,3,4,5])
1 + 2 + sum([3,4,5])
1 + 2 + 3 + sum([4,5])
1 + 2 + 3 + 4 + sum([5])
1 + 2 + 3 + 4 + 5 + sum([])
1 + 2 + 3 + 4 + 5 + 0
这种形式计算小列表没有什么问题,但对于大列表来说问题就非常大了,因为上述代码并不是尾递归(不知道尾递归的同学可以去看阮一峰老师的 尾调用优化),在大列表的情况下很容易会爆内存。因此,更好的做法是把上述代码改写成尾递归的版本:
// JavaScript
function sum(list, acc) {
if (list.length === 0) return acc;
var h = head(list);
var t = tail(list);
return sum(t, acc + h);
由于函数式代码是声明式的,因此它非常容易阅读。
在不可变变量的帮助下,函数式代码不须考虑死锁,非常适合并行执行。
函数式编程强调纯函数,无副作用,不涉及状态改变,因此测试和调试非常方便。
性能比命令式编程差。
函数式编程用类似管道的方式来处理数据,因此不适合处理可变状态。
函数式编程不适合做 IO 操作,也不适合写 GUI。
什么是函数式编程思维?
函数式编程初探
声明式编程和命令式编程的比较