纯函数
引言
- 对于函数,大家都不陌生,但是对于纯函数可能有些生疏。我们在写函数的时候,会肆无忌惮,充分的利用js的一些特性,比如使用作用域去修改外部变量,对象属性,数据结构等。这样写如果稍不注意,可能会出现隐藏的bug,使我们的函数,不再是一个可信赖的函数。而纯函数正是一个可以保证数据信赖的函数方式,他也是一个函数,只不过有他特有的要求规范,下面就让我们从函数的副作用开始,来学习纯函数的优缺点
什么是纯函数?
- 纯函数是这样一种函数,相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用
比如常用的slice
和splice
,这两个函数的作用别二致。但是slice
符合纯函数的定义,因为对于相同的输入,他能保证相同的输出。而splice却会嚼烂调用他的那个数组,然后吐出来
;这样就会产生观察到的副作用
let arr = [1,2,3]
// 纯的
arr.slice(0, 3) // (3) [1, 2, 3]
arr.slice(0, 3) // (3) [1, 2, 3]
arr.slice(0, 3) // (3) [1, 2, 3]
// 不纯的
arr.splice(0, 3) // (3) [1, 2, 3]
arr.splice(0, 3) // []
在函数式编程中,我们讨厌这种会改变数据的笨函数。我们追求的是那种可靠的,每次都能返回同样结果的函数,而不是像 splice
这样每次调用后都把数据弄得一团糟的函数,这不是我们想要的。
来看另外一个例子
// 不纯的
let immutableState = 21
const checkAge = (age) => {
return age >=immutableState
}
// 纯的
const checkAge = (age) => {
let immutableState = 21
return age >=immutableState
}
在不纯的版本中,checkAge的结果取决于immutableState这个变量是不是一个可变的值
。 如果是可变的,那么对于immutableState的追踪,将会是一件棘手的事。我们可以通过参数的形式传递immutableState,也可以通过作用域的方式,限制变量的访问范围,当然我们也可以通过一些手段,使immutableState成为一个不可变(immutable) 对象.要实现这个效果,必须得创建一个对象,然后调用 Object.freeze
方法:
let immutableState = Object.freeze({
immutableState: 21
})
什么是函数的副作用
如果函数与外部可变状态进行交互,则他是有副作用的
- 任何修改外部的变量,对象属性,数据结构
- 控制台(屏幕)输入输出的交互
- 文件操作,网络操作
- 抛出异常或错误终止
- …不止于此
站在纯函数的角度来说,以上均是非纯函数的副作用。我们也不必太刻意使用纯函数,因为有很多功能,还是得依赖非纯函数来实现,毕竟你要和这个世界打交道,而不是只活在自己的世界里。
函数副作用demo - 输出最大数
let arrScore = [1, 3, 2, 5, 4];
const getMaxScore = () => {
arrScore = arrScore.sort((a, b) => {
return b - a;
});
return arrScore[0];
};
console.log('getMaxScore', getMaxScore()); // 5
console.log('arrScore', arrScore); // [ 5, 4, 3, 2, 1 ]
问题分析
- 在函数内,直接使用了外部变量,并且sort会改变源数据。下次再使用源数据,数据处于不可知状态
问题解决
- 通过参数的形式,将数据传入
- 参数如果是引用类型,提前进行clone
- 避免使用修改源数据的api,使用其他方式实现
let arrScore = [1, 3, 2, 5, 4];
const getMaxScore = (arrScore) => {
let temp = arrScore.slice();
temp = temp.sort((a, b) => {
return b - a;
});
return temp[0];
};
console.log('getMaxScore', getMaxScore(arrScore)); // 5
console.log('arrScore', arrScore); // [ 1, 3, 2, 5, 4 ]
let arrScore = [1, 3, 2, 5, 4];
const getMaxScore = (arrScore) => {
return Math.max(...arrScore);
};
console.log('getMaxScore', getMaxScore(arrScore)); // 5
console.log('arrScore', arrScore); // [ 1, 3, 2, 5, 4 ]
追求“纯”的理由
可缓存性 (Cacheable)
-
由于纯函数的输入就决定了输出,就和数学中的函数一样(所以我们也可以像数学公式一样推导),一个确定的输入对应一个确定的输出,
-
因此我们可以根据传入的参数把结果缓存起来,这样后续以同样的参数调用的时候就可以直接返回结果,而不是重新执行一遍算法.
-
实现缓存的一种典型方式是memoize技术,
vue 源码
/**
* Create a cached version of a pure function.
*/
function cached(fn) {
var cache = Object.create(null);
return function cachedFn(str) {
var hit = cache[str];
return hit || (cache[str] = fn(str));
};
}
/**
* Capitalize a string.
*/
var capitalize = cached(function (str) {
return str.charAt(0).toUpperCase() + str.slice(1);
});
可移植性,自文档化(Portable / Self-Documenting)
-
纯函数的依赖很明确,因此更易于观察和理解
-
因为他们与环境无关,所以可以拷贝到任何地方运行,提高了代码的复用性。
-
对比面向对象,你从类中拷贝一个方法,就要麻烦得多。
可测试性(Testable)
-
纯函数让测试更加容易。
-
只需要给定输入,断言输出就可以了。
-
甚至有专门的测试工具帮我们自动生成输入,并断言输出。 比如Quickcheck
合理性,引用透明性(Reasonable)
- 很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
var Immutable = require('immutable');
var decrementHP = function(player) {
return player.set("hp", player.hp-1);
};
var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};
var punch = function(player, target) {
if(isSameTeam(player, target)) {
return target;
} else {
return decrementHP(target);
}
};
var jobe = Immutable.Map({name:"Jobe", hp:20, team: "red"});
var michael = Immutable.Map({name:"Michael", hp:20, team: "green"});
punch(jobe, michael);
//=> Immutable.Map({name:"Michael", hp:19, team: "green"})
在上面的demo中,decrementHP
和isSameTeam
和punch
都是纯函数,所以是透明引用。我们可以使用一种叫做“等式推到”
的技术来分析代码
首先内联isSameTeam
var isSameTeam = function(player1, player2) {
return player1.team === player2.team;
};
// 因为数据是不可变的,所以我们直接把team 替换为实际的值
var punch = function(player, target) {
if("red" === "green") {
return target;
} else {
return decrementHP(target);
}
};
// if 执行的结果是false,所以可以把整个if 语句删掉
var punch = function(player, target) {
return decrementHP(target);
};
// 然后再内联decrementHP,punch就编程一个让hp的值减1的调用
并行运算
-
我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
-
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。