基本概念
- 函数式编程
(Functional programming)
与面向对象编程(Object-oriented programming)
和过程式编程(Procedural programming)
并列的编程范式。
过程式编程毫无边界,只关心完成目标的具体操作步骤,这个很接近机器的指令式思维。
面向对象编程,开始有边界了。第一层边界是对象,有隔离,有封装;第二层边界是环境;
函数式编程的边界进一步缩小。第一层边界是函数,独立的,纯的函数,不依赖外界的状态。第二层边界是容器(集合),从一个集合变换到另外一集合。这两个集合是互相独立的,只是有映射关系,而且这种映射关系是单向的,一对一或者是多对一的。 - 最主要的特征是,函数是第一等公民。所谓“一等公民”,其实就是“普通公民”。函数可以是参数,可以是返回值,可以是数组的成员等等。
- 值的集合组成一个容器,或者叫范畴
category
;值的变换关系叫函数,可以一对一,多对一,但是不能一对多。(对于给定的输入,有确定的输出)。函数式编程的本质是从一个容器变换到另外一个容器:变换的函数是容器的方法,变换的成员是容器中的成员,容器中的成员个数保持不变,或者越来越少,甚至最后变成一个(reduce
)。为了简单起见,函数的输入参数是一个(curry
),输出也是一个(可能是函数),直到所有的参数都处理完,最后形成一条串行管道(pipe
)。
容器和成员变换关系,是两大基本元素。思路要转变到这两个焦点上面。 - 函数式编程有两个最基本的运算:合成和柯里化。
- 函数式编程要求是纯函数,但是现实的异步编程基本上都是不纯的函数。所以重点是想办法将不纯的函数变成纯的函数。
纯函数
纯函数的定义是,对于相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用,也不依赖外部环境的状态。
函数f
的概念就是,对于输入x
产生一个输出y = f(x)
。
- 纯的容器(集合)的方法(函数)不能改变自己所属容器(集合)的成员,应该返回一个新的容器(集合),其成员是变换(映射)后的结果。
比如数组的splice
方法操作自身成员,是不纯的;slice
方法返回一个映射结果的新数组,是纯的
var input = [1,2,3,4,5];
var output = [];
// Array.slice是纯函数,因为它没有副作用,对于固定的输入,输出总是固定的
// 可以,这很函数式
output = input.slice(0,3);
//=> [1,2,3]
output = input.slice(0,3);
//=> [1,2,3]
// Array.splice是不纯的,它有副作用,对于固定的输入,输出不是固定的
// 这不函数式
output = input.splice(0,3);
//=> [1,2,3]
output = input.splice(0,3);
//=> [4,5]
output = input.splice(0,3);
//=> []
- 纯的函数不能依赖函数外部的变量(状态),函数跟外部的接口只能通过参数,并且参数要求是值传递,不能是引用传递(有共享的内存,函数就依赖外部的状态了)。
// 不纯的
var minimum = 21;
var checkAge = function(age) {
return age >= minimum;
};
// 纯的
var checkAge = function(age) {
var minimum = 21;
return age >= minimum;
};
函数组合
- 如果一个值要经过多个函数,才能变成另外一个值,就可以把所有中间步骤合并成一个函数,这叫做"函数的合成"
(compose)
。
var compose = function(g,f) {
return function(x) {
return g(f(x));
};
};
var toUpperCase = function(x) { return x.toUpperCase(); }; // f
var exclaim = function(x) { return x + '!'; }; // g
var shout = compose(exclaim, toUpperCase); // 先f后g
console.log(shout("send in the clowns"));
//=> "SEND IN THE CLOWNS!"
- 函数的合成还必须满足结合律。但是一般不满足交换律。实际使用中这条链可能很长,结合的个数也不限于2个,可以有多个,具体怎么结合,可以根据具体的业务来。并且链路也可能有多条,交叉,但是要求单向流动,一般不能有回路。
compose(h, compose(g, f))
// 等同于
compose(compose(h, g), f)
// 等同于
compose(h, g, f)
- 图上的箭头和顺序“从左向右”,但是书写和执行的顺序“从右向左”,刚好相反,这点要注意。如果非要搞成一致,那么建议画图的时候,箭头和顺序也“从右向左”。没有为什么,约定俗成罢了,习惯了就好了。
-
ABCD
看成是容器(或者集合),fgh
看成函数(或者映射),思维转换过来,习惯了就好了。函数(属性,比如map
)是容器(类,比如Array
)的方法,作用的对象是容器的成员。只关注输入输出的映射关系,不关心具体的循环遍历方式以及中间变量(比如循环序号i
) -
fgh
等函数只允许有一个参数,简化处理;多参数的情况就把链路拉长一点 - 从起点到终点,容器内的成员只能保持不变或者越来越少。原因是函数的映射关系只能是一对一或者多对一,不能一对多。
Point Free
不要命名转瞬即逝的中间变量
//这不Piont free
var f = str => str.toUpperCase().split(' ');
这个函数中,我们使用了 str
作为我们的中间变量,但这个中间变量除了让代码变得长了一点以外是毫无意义的。
var compose = (f, g) => (x => f(g(x)));
var toUpperCase = word => word.toUpperCase();
var split = x => (str => str.split(x));
var f = compose(split(' '), toUpperCase);
var result = f("abcd efgh");
console.log(result);
// [ 'ABCD', 'EFGH' ]
柯里化(curry)
- 所谓"柯里化",就是把一个多参数的函数,转化为单参数函数。
- 只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。形成一个调用链,直到处理完所有的参数。
- 也是从左向右书写,但是从右到左执行,将最外层的参数写在最左边,最后处理。最内层的参数写在最右边,最先处理。
var add = function(x) {
return function(y) {
return x + y;
};
};
var increment = add(1);
var addTen = add(10);
increment(2);
// 3
addTen(2);
// 12
函子(Functor)
- 函子是函数式编程里面最重要的数据类型,也是基本的运算单位和功能单位。
- 它首先是一种范畴,也就是说,是一个容器,包含了值和变形关系。比较特殊的是,它的变形关系可以依次作用于每一个值,将当前容器变形成另一个容器。
- 任何具有
map
方法的数据结构,都可以当作函子的实现。 - 一般约定,函子的标志就是容器具有
map
方法。该方法将容器里面的每一个值,映射到另一个容器。 - 函数式编程里面的运算,都是通过函子完成,即运算不直接针对值,而是针对这个值的容器----函子。
- 函子本身具有对外接口(
map
方法),各种函数就是运算符,通过接口接入容器,引发容器里面的值的变形。 - 学习函数式编程,实际上就是学习函子的各种运算。函数式编程就变成了运用不同的函子,解决实际问题。
- 函数式编程一般约定,函子有一个
of
方法,用来生成新的容器。
class Functor {
constructor(val) {
this.val = val;
}
static of(val) {
return new Functor(val);
}
map(f) {
return new Functor(f(this.val));
}
}
console.log(Functor.of(2).map(function (two) {
return two + 2;
}));
// Functor { val: 4 }
console.log(Functor.of('flamethrowers').map(function(s) {
return s.toUpperCase();
}));
// Functor { val: 'FLAMETHROWERS' }
console.log(Functor.of('bombs').map(function(s){
return s.concat(' away');
}).map(function(s) {
return s.length;
}));
// Functor { val: 10 }
- 这个
of
方法这里是一个静态方法,用类名来访问,替代构造方法,将new
关键字隐藏起来
- 这里的函数执行顺序是“从左到右”的,和示意图流的方向一致。这个和前面函数的合成和柯里化那部分是相反的,要注意区别。
Maybe 函子
Maybe
函子是为了处理空值而设计的。简单说,它的map
方法里面设置了空值检查。
class Maybe extends Functor {
static of(val) {
return new Maybe(val);
}
map(f) {
return this.val ? Maybe.of(f(this.val)) : Maybe.of(null);
}
}
try {
console.log(Functor.of(null).map(function (s) {
return s.toUpperCase(); // Functor没有空值检查,当输入null时抛出异常
}));
} catch (error) {
console.log(error.message);
// Cannot read property 'toUpperCase' of null
}
console.log(Maybe.of(null).map(function (s) {
return s.toUpperCase();
}));
// Maybe { val: null }
Either 函子
- 条件运算
if...else
是最常见的运算之一,函数式编程里面,使用Either
函子表达。 -
Either
函子内部有两个值:左值(Left)
和右值(Right)
。右值是正常情况下使用的值,左值是右值不存在时使用的默认值。
class Either {
constructor(left, right) {
this.left = left;
this.right = right;
}
static of(left, right) {
return new Either(left, right);
}
map(f) {
return this.right ?
Either.of(this.left, f(this.right)) :
Either.of(f(this.left), this.right);
}
}
// Either用来提供默认值
var addOne = function (x) {
return x + 1;
};
console.log(Either.of(5, 6).map(addOne));
// Either { left: 5, right: 7 }
console.log(Either.of(1, null).map(addOne));
// Either { left: 2, right: null }
-
Either
函子的另一个用途是代替try...catch
,使用左值表示错误。一般来说,所有可能出错的运算,都可以返回一个Either
函子。
function parseJSON(json) {
try {
return Either.of(null, JSON.parse(json));
} catch (e: Error) {
return Either.of(e, null);
}
}
ap 函子
- 容器中的成员是函数
- 这些函数是
curry
函数 - 包含
ap
方法 -
ap
方法的参数不是函数,而是另一个函子 - ap 函子的意义在于,对于那些多参数的函数,就可以从多个容器之中取值,实现函子的链式操作。
- 取ap函子中的函数,参数从其他的函子中取,最后得到一个结果的集合。这个集合不是ap函子,也不是参数函子,而是结果的集合。
class Ap {
constructor(val) {
this.val = val;
}
static of(val) {
return new Ap(val);
}
map(f) {
return new Ap(f(this.val));
}
ap(F) {
return Ap.of(this.val(F.val));
}
}
class Maybe {
constructor(val) {
this.val = val;
}
static of(val) {
return new Maybe(val);
}
map(f) {
return this.val ? Maybe.of(f(this.val)) : Maybe.of(null);
}
}
function add(x) {
return function (y) {
return x + y;
};
}
var reslut = Ap.of(add).ap(Maybe.of(2)).ap(Maybe.of(3));
console.log(reslut);
// Ap { val: 5 }
Monad 函子
- Monad 函子的作用是,总是返回一个单层的函子。
- 它有一个
flatMap
方法,与map
方法作用相同,唯一的区别是如果生成了一个嵌套函子,它会取出后者内部的值,保证返回的永远是一个单层的容器,不会出现嵌套的情况。 -
flatMap
方法也叫chain
方法,就是比普通的map
多一个取值操作 - Monad 函子的重要应用,就是实现 I/O (输入输出)操作。
class Monad extends Functor {
join() {
return this.val;
}
flatMap(f) {
return this.map(f).join();
}
}
-
Monad
,英文单词翻译成中文是“单子”。作用是实现输入输出都是“类”的函数调用,最后形成一个链式调用。直观的感觉就是“类”(或者叫容器)中的方法,全部返回“类”本身,那么就可以一直点点点下去,将很多函数调用写在一行,成为“一条链” - 从本质上讲,输入输出都是“类”的函数是没有意义的。值到值的变换才是函数的本质。借助
Monad
这个概念,实现了函数链式调用。首先取出传过来的“类”参数中的值,经过函数运算,得到结果,然后把这个值再“封装”到“类”中,返回这个类。
图解 Monad这篇文章很好地描述了这个过程,好好看看。
IO 操作
- I/O 是不纯的操作,普通的函数式编程没法做,这时就需要把 IO 操作写成Monad函子,通过它来完成。
- 读取文件和打印本身都是不纯的操作,但是
readFile
和print
却是纯函数,因为它们总是返回 IO 函子。
var fs = require('fs');
var readFile = function(filename) {
return new IO(function() {
return fs.readFileSync(filename, 'utf-8');
});
};
var print = function(x) {
return new IO(function() {
console.log(x);
return x;
});
}
参考文章
函数式编程入门教程
这篇博客写的很好,对于函数式编程概念的理解很有帮助,强烈推荐
JS函数式编程指南
这个都写成书了,应该好好看看,写得很好,强烈推荐
JavaScript函数式编程(一)
JavaScript函数式编程(二)
JavaScript函数式编程(三)
很用心写的一系列文章,值得看看