函数式编程在做什么
一个简单的例子,海鸥程序
var Flock = function(n) {
this.seagulls = n;
};
Flock.prototype.conjoin = function(other) {
this.seagulls += other.seagulls;
return this;
};
Flock.prototype.breed = function(other) {
this.seagulls = this.seagulls * other.seagulls;
return this;
};
var flock_a = new Flock(4);
var flock_b = new Flock(2);
var flock_c = new Flock(0);
var result = flock_a.conjoin(flock_c).breed(flock_b).conjoin(flock_a.breed(flock_b)).seagulls;
//=> 32
var result = flock_a.conjoin(flock_c).breed(flock_b).conjoin(flock_a.breed(flock_b)).seagulls;
这行代码,我们来解析一下是干了什么操作(不看也行,知道比较难理解且数据可能错误就可以),
- flock_a.conjoin(flock_c).breed(flock_b).conjoin(
flock_a.breed(flock_b)
).seagulls;执行了flock_a.breed(flock_b),假设返回的结果是结果1(此时flock_a的seagulls值被改变了,从4变成了8) - flock_a.conjoin(flock_c).breed(flock_b).
conjoin
(结果1).seagulls,此时要执行conjoin,在此之前要先得到flock_a.conjoin(flock_c).breed(flock_b)的值 - flock_a.conjoin(flock_c).
breed
(flock_b),要执行breed,在此之前要得到flock_a.conjoin(flock_c)的值。 flock_a.conjoin(flock_c)
,执行flock_a.conjoin(flock_c),得到结果2=flock_a.conjoin(flock_c) (此时的flock_a已经在第一步被改变了)- 反推回第三步,
结果2.breed(flock_b)
,得到结果3=flock_a.conjoin(flock_c).breed(flock_b) - 反推回第二步,结果3.conjoin(结果1).seagulls
- 最后的result=结果3.conjoin(结果1).seagulls
- 最后的result=32,不是我们使用这个函数期望得到的值
很复杂,我们用函数重新写一下
var conjoin = function(flock_x, flock_y) { return flock_x + flock_y };
var breed = function(flock_x, flock_y) { return flock_x * flock_y };
var flock_a = 4;
var flock_b = 2;
var flock_c = 0;
var result = conjoin(breed(flock_b, conjoin(flock_a, flock_c)), breed(flock_a, flock_b));
//=>16
这次我们得到了正确的答案,而且少写了很多代码。不过函数嵌套有点让人费解…(我们会在代码组合解决这个问题)
代码中的两个函数除了函数名有些特殊,其他没有任何难以理解的地方。我们把它们重命名一下。
var add = function(a, b) { return a + b };
var multiply = function(a, b) { return a * b };
var x = 4;
var y = 2;
var z = 0;
var result = add(multiply(y, add(x, z)), multiply(x, y));
//实际上执行y*(x+z)+x*y=>(2x+z)*y=>multiply(add(2*x,z),y)
//=>16
比原来清晰非常多,也不会因为改变对象内部的原本值而造成语义错误,这就是函数化编程的优点。
纯函数
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用(不会影响别人)。
比如:slice 和 splice
slice
不会改变原数组,splice
会改变原数组
var xs = [1,2,3,4,5];
// 纯的
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
xs.slice(0,3);
//=> [1,2,3]
// 不纯的
xs.splice(0,3);
//=> [1,2,3]
xs.splice(0,3);
//=> [4,5]
xs.splice(0,3);
//=> []
在函数式编程中,我们讨厌这种会改变数据的笨函数。我们追求的是那种可靠的,每次都能返回同样结果的函数,而不是像 splice 这样每次调用后都把数据弄得一团糟的函数,这不是我们想要的。
副作用可能包含,但不限于:
- 更改文件系统;
- 往数据库插入记录;
- 发送一个 http 请求;
- 可变数据;
- 打印/log;
- 获取用户输入;
- DOM 查询;
- 访问系统状态…
从定义上来说,纯函数必须要能够根据相同的输入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。
追求纯函数的原因
- 可缓存性(Cacheable)
- 可移植性/自文档化(Portable / Self-Documenting)
- 可测试性(Testable)
- 合理性(Reasonable)
- 并行代码
可缓存性(Cacheable):
首先,纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是 memoize 技术:
var squareNumber = memoize(function(x){ return x*x; });
squareNumber(4);
//=> 16
squareNumber(4); // 从缓存中读取输入值为 4 的结果
//=> 16
squareNumber(5);
//=> 25
squareNumber(5); // 从缓存中读取输入值为 5 的结果
//=> 25
下面的代码是一个简单的实现,尽管它不太健壮。
var memoize = function(f) {
var cache = {};
return function() {
var arg_str = JSON.stringify(arguments);
cache[arg_str] = cache[arg_str] || f.apply(f, arguments);
return cache[arg_str];
};
};
可移植性/自文档化(Portable / Self-Documenting):
纯函数是完全自给自足
的,它需要的所有东西都能轻易获得。仔细思考思考这一点…这种自给自足的好处是什么呢?
首先,纯函数的依赖很明确
,因此更易于观察和理解。
// 不纯的
var signUp = function(attrs) {
var user = saveUser(attrs);
welcomeUser(user);
};
var saveUser = function(attrs) {
var user = Db.save(attrs);
...
};
var welcomeUser = function(user) {
Email(user, ...);
...
};
// 纯的
var signUp = function(Db, Email, attrs) {
return function() {
var user = saveUser(Db, attrs);
welcomeUser(Email, user);
};
};
var saveUser = function(Db, attrs) {
...
};
var welcomeUser = function(Email, user) {
...
};
这个例子表明,纯函数对于其依赖必须要明确,这样我们就能知道它的目的
可测试性(Testable):
纯函数让测试更加容易。只需简单地给函数一个输入,然后断言输出就好了
合理性(Reasonable):
使用纯函数最大的好处是引用透明性
,如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。
var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};
var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return target;
}
};
var jobe ={name:"Jobe", hp:20, team: "red"};
var michael = {name:"Michael", hp:20, team: "green"};
punch(jobe, michael);
如上图案例:isSameTeam
和punch
函数都是纯函数
首先punch代入isSameTeam
//isSameTeam(player, target)=> player.team === target.team
var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return target;
}
};
因为是不可变数据,我们可以直接把 team 替换为实际值:
var punch = function(player, target) {
if("red" === "green") {
return target;
} else {
return target;
}
};
if 语句执行结果为 false,所以可以把整个 if 语句都删掉:
var punch = function(player, target) {
return target;
};
等式推导带来的分析代码的能力对重构和理解代码非常重要。事实上,我们重构海鸥程序使用的正是这项技术:利用加和乘的特性。
并行代码:
最后一点,也是决定性的一点:我们可以并行运行任意纯函数
。因为纯函数根本不需要访问共享的内存
,而且根据其定义,纯函数也不会
因副作用
而进入竞争态
柯里化
柯里化是一种函数的转换,它是指将一个函数从可调用的 f(a, b, c) 转换为可调用的 f(a)(b)©。
curry 的概念很简单:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
你可以一次性地调用 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
柯里化?目的是什么?
使用:
//match已经柯里化
match(/\s+/g, "hello world");
// [ ' ' ]
match(/\s+/g)("hello world");
// [ ' ' ]
var hasSpaces = match(/\s+/g);
// function(x) { return x.match(/\s+/g) }
hasSpaces("hello world");
// [ ' ' ]
hasSpaces("spaceless");
// null
可以看出,只需传给函数一些参数,就能得到一个新函数。
例如,我们有一个用于格式化和输出信息的日志(logging)函数 log(date, importance, message)。在实际项目中,此类函数具有很多有用的功能,例如通过网络发送日志(log),在这儿我们仅使用 alert:
function log(date, importance, message) {
alert(`[${date.getHours()}:${date.getMinutes()}] [${importance}] ${message}`);
}
让我们将它柯里化!
log = _.curry(log);
柯里化之后,log 仍正常运行:
log(new Date(), "DEBUG", "some debug"); // log(a, b, c)
……但是也可以以柯里化形式运行:
log(new Date())("DEBUG")("some debug"); // log(a)(b)(c)
现在,我们可以轻松地为当前日志创建便捷函数:
// logNow 会是带有固定第一个参数的日志的偏函数
let logNow = log(new Date());
// 使用它
logNow("INFO", "message"); // [HH:mm] INFO message
柯里化实现
function curry(func) {
// func 是要转换的函数
return function curried(...args) {
if (args.length >= func.length) {//如果传进来的参数大于等于func函数参数,则执行函数。
return func.apply(this, args);
} else {
return function(...args2) {//否则进行返回一个函数,返回的函数会带上新传入的参数和原来的参数合并,执行curried 进入新一轮的参数长度判断(if判断)
return curried.apply(this, args.concat(args2));
}
}
};
}
柯里化 是一种转换,将 f(a,b,c) 转换为可以被以 f(a)(b)© 的形式进行调用
我们以f(a,b,c,d) =>f(a)(b,c)(d)为例,理解一下上述的柯里化实现
1、 f(a)执行,传入a,一个参数,小于f(a,b,c)的参数个数4
2、进入else分支,返回一个函数Fn,Fn的函数体是一个执行curried(a,新参数)
,f(a)执行完毕。返回Fn。
3、执行Fn(b,c),传入b,c,执行Fn的函数体,curried(a,b,c)
4、curried的if分支判断,参数个数3,小于4,继续返回一个函数Fn2(函数名字不重要,没有名字也可以),Fn2的函数体是一个执行curried(a,b,c,新参数)
5、执行Fn2(d), 执行Fn2的函数体,curried(a,b,c,d)
6、curried的if分支判断,参数个数4,等于4,执行f(a,b,c,d)
只允许确定参数长度的函数
柯里化要求函数具有固定数量的参数。
使用 rest 参数的函数,例如 f(…args),不能以这种方式进行柯里化。
代码组合
compose:从右到左依次执行函数
pipe:从左到右依次执行函数
使用:
实现功能:先将数据进行去除空格,然后转大写,最后转数组
const name = " linlin ";
// 去空格
const trim = (str) => str.trim();
// 转大写
const toUpper = (str) => str.toUpperCase();
// 转数组
const toArray = (str) => str.split("");
没使用事件组合时:result=toArray(toUpper(trim(name)))
三层函数嵌套
使用compose:result=compose(toArray, toUpper, trim)(name)
从右到左,执行trim(name), 将结果1返回给toUpper,执行toUpper(结果1),将结果2返回toArray,执行toArray(结果2),最后结果返回result
使用pipe:result=pipe(trim, toUpper, toArray)(name)
与compose只有顺序不同
实现:
compose:
function compose(...fns) {
// 动态个数,因为我们不需要访问 fns 参数长度
// 数组的遍历方法
// map
// reduce
// reduceRight
// forEach
return function (x) {
return fns.reduceRight((value, fn) => {
return fn(value);
}, x);
};
}
pipe:
function pipe(...fns) {
// 动态个数,因为我们不需要访问 fns 参数长度
// 数组的遍历方法
// map
// reduce
// reduceRight
// forEach
return function (x) {
return fns.reduce((value, fn) => {
return fn(value);
}, x);
};
}
reduce、reduceRight是数组的方法,第一个参数是前一个数据的执行结果,后续会有专门章节介绍数组方法。