1. 纯函数
1.1 什么是纯函数?
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
1.2 副作用内容
“副作用“的关键部分在于”副“。就像一潭死水中的”水“本身并不是幼虫的培养器,“死”才是生成虫群的原因。同理,副作用中的“副”是滋生bug的温床。 副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。
副作用可能包含,但不限于:
- 更改文件系统;
- 往数据库插入记录
- 发送一个http请求;
- 可变数据
- 打印/log
- 获取用户输入
- DOM查询
- 访问系统状态
概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
这并不是说,要禁止使用一切副作用,而是说,要让他们在可控的范围内发生。
副作用让一个函数变得不纯是有道理的:从定义上来说,纯函数必须要能够根据相同的收入返回相同的输出;如果函数需要跟外部事物打交道,那么就无法保证这一点了。
1.3 追求纯函数的原因
1.3.1 可缓存性(Cacheable)
首先,纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是 memorize 技术:
var squareNumber = memorize(function(x){return x * x})
squareNumber(4)
// => 16
squareNumber(4)
// => 16 从缓存中读取输入值 4 的结果
var memorize = function(f){
var cache = {}
return function(){
var arg_str = JSON.stringify(arguments)
cache[arg_str] = cahce[arg_str] || f.apply(f,arguments)
return cache[arg_str]
}
}
值得注意的一点是,可以通过延迟执行的方式把不纯的函数转换为纯函数:
var pureHttpCall = memorize(function(url,params){
return function() { return $.getJSOn(url,params)}
})
这里并没有真正发送http请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格称为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送http请求的函数。
memorize 函数工作起来没有任何问题,虽然他缓存的并不是 http 请求所返回的结果,而是生成的函数。
1.3.2 可移植性/自文档化(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) {
...
};
上述例子表明,纯函数对于其他以来必须要明确,这样我们就能知道它的目的。
仅从纯函数版本的 signUp 的签名就可以看出,它将要用到Db,Email和 attrs,这在最小程度上给了我们足够多的信息。其次,通过强迫“注入”依赖,或者把他们当作参数传递,我们的应用也更加灵活。
命令式编程中“典型”的方法和过程都深深地根植于他们所在的环境中,通过状态、依赖和有效作用(available effects)达成。纯函数与此相反,它与环境无关,只要我们愿意,可以在任何地方运行它。
1.3.3 可测试性
纯函数让测试更加容易。我们不需要伪造一个“真实“支付网关,或者每一次测试之前都要配置、之后都要断言状态(assert the state)。只需简单地给函数一个输入,然后断言出就好了。
1.3.4 合理性
很多人相信使用纯函数最大的好处是引用透明性(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"})
decrementHP,isSameTeam 和 punch 都是纯函数,所以引用透明的。可以使用一种叫做“等式推导“(equational reasoning)的方法来分析代码。所谓“等式推导”就是一对一替换,有点像在不考虑程序性执行的怪异行为(quirks of programmatic evaluation)的情况下,手动执行相关代码。我们借助引用透明性来剖析一下这段代码。
首先内联 isSameTeam函数:
var punch = function(player, target) {
if(player.team === target.team) {
return target;
} else {
return decrementHP(target);
}
};
因为是不可变数据,我们可以直接把 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的调用:
var punch = function(player, target) {
return target.set("hp", target.hp-1);
};
等式推导带来的分析代码的能力对重构和理解代码非常重要。事实上,我们重构海鸥程序使用的正是这项技术:利用加和乘的特性。
1.3.5 并行代码
最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存。而且根据定义,纯函数也不会因副作用而进入竞争态(race codition)。
并行代码在服务端 js 环境以及使用了 web worker 的浏览器那里非常容易实现的,因为它们使用了线程(thread)。不过处于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。
2. 柯里化
2.1 什么是柯里化
概念:只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
可以一次性地调用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
上述代码中定义了一个 add 函数,它接受一个参数并返回一个新的函数。调用 add 之后,返回的函数就通过必报的方式记住了 add的 第一个参数。一次性地调用它实在是有点繁琐,好在可以使用一个特殊的 curry 帮助函数(helper function)使这类函数的定义和调用更加容易。
var curry = require('lodash').curry;
var match = curry(function(what, str) {
return str.match(what);
});
var replace = curry(function(what, replacement, str) {
return str.replace(what, replacement);
});
var filter = curry(function(f, ary) {
return ary.filter(f);
});
var map = curry(function(f, ary) {
return ary.map(f);
});
上述代码中遵循的是一种简单,同时也非常重要的模式。即策略性地把要操作的数据(String,Array)放到最后一个参数里。在使用它们的时候就明白这样做的原因是什么了。
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
filter(hasSpaces, ["tori_spelling", "tori amos"]);
// ["tori amos"]
var findSpaces = filter(hasSpaces);
// function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }
findSpaces(["tori_spelling", "tori amos"]);
// ["tori amos"]
var noVowels = replace(/[aeiou]/ig);
// function(replacement, x) { return x.replace(/[aeiou]/ig, replacement) }
var censored = noVowels("*");
// function(x) { return x.replace(/[aeiou]/ig, "*") }
censored("Chocolate Rain");
// 'Ch*c*l*t* R**n'
这里表明的是一种“”预加载“函数的能力,通过传递一到两个参数调用函数,就能得到一个记住了这些参数的新函数。
2.2 使用用途
curry 的用处非常广泛,就像在 hasSpaces,findSpaces 和 censored看到的那样,只需传给函数一些参数,就能得到一个新函数。
用 map 简单地把参数是单个元素的函数包裹一下,就能把它转换成参数为数组的函数。
var getChildren = function(x) {
return x.childNodes;
};
var allTheChildren = map(getChildren);
只传给函数一部分参数通常也叫做局部调用(partial application),能够大量减少样板文件代码(boilerplate code)。考虑上面的 allTheChildren 函数,如果用 lodash 的普通 map 来写会是什么样的(注意参数的顺序变了):
var allTheChildren = function(elements) {
return _.map(elements, getChildren);
};
通常我们不定义直接操作数组的函数,因为只需要内联调用 map(getChildren) 就能达到目的。这一点同样适用于sort、filter以及其他的高阶函数(higher order function)(高阶函数:参数或返回值为函数的函数)。
当谈论到纯函数时,都说他们接受一个输入返回一个输出。curry 函数所做的正是这样:每传递一个参数调用的函数,就返回一个新函数处理剩余的参数。这就是一个输入对应一个输出。