JavaScript-可维护代码编写,函数式编程与纯函数
JavaScript是函数式编程与面向对象编程的混合编程语言,加上本身一些可扩展性(比如:函数参数个数及类型的不确定),使得JavaScript非常灵活,当然也可以说非常不可控。正是这个特点,使得一个团队维护一个共同的前端项目时,JavaScript代码可能非常难以读懂。试想,你新加入一个团队,让你去读别人的代码本身就不太容易,如果团队的注释习惯及编程风格不太好,真的很难读懂每个js文件到底在干什么。因此,编写可维护的高质量代码真的非常重要。
1. 问题来源
阅读代码比阅读文章困难是因为,一段代码可能会依赖很多函数,你为了了解每个函数的作用不得不跳到其他代码块,这不符合我们的阅读习惯。 偶尔在一本书中看到这样一句话:“面向对象语言的问题是,它们永远都要随身携带那些隐式的环境。你只需要一个香蕉,但却得到一个拿着香蕉的大猩猩…以及整个丛林”。所以,尝试在我们的代码中更巧妙利用JavaScript的特点,使用函数式编程,可以使得我们的JS代码可维护性更高,可读性更强。
2. 使用纯函数来提高JS代码可维护性
首先我们来了解一下什么是函数式编程。
在函数式编程语言中,函数是第一类的对象,也就是说,函数 不依赖于任何其他的对象而可以独立存在,而在面向对象的语言中,函数 ( 方法 ) 是依附于对象的,属于对象的一部分。这一点就 决定了函数在函数式语言中的一些特别的性质,比如作为传出 / 传入参数,作为一个普通的变量等。
我们常听到这样一种说法,在JavaScript中函数是“一等公民”。当我们说函数是“一等公民”的时候,我们实际上说的是它们和其他对象都一样,你可以像对待任何其他数据类型一样对待它们——把它们存在数组里,当作参数传递,赋值给变量…等等。而函数式编程的一个基础就是纯函数。
纯函数是这样一种函数,即相同的输入,永远会得到相同的输出,而且没有任何可观察的副作用。
2.1 理解纯函数
实际上我们可以这样理解,纯函数就是这样一种函数,它不依赖于外部环境(例如:全局变量、DOM)、不改变外部环境(例如:发送请求、改变DOM结构),函数的输出完全由函数的输入决定。
比如 slice 和 splice,这两个函数的作用并无二致——但是注意,它们各自的方式却大不同,但不管怎么说作用还是一样的。我们说 slice 符合纯函数的定义是因为对相同的输入它保证能返回相同的输出。而 splice 却会嚼烂调用它的那个数组,然后再吐出来;这就会产生可观察到的副作用,即这个数组永久地改变了。
相关代码请查看github
var array1 = [0,1,2,3,4,5,6];
var array2 = [0,1,2,3,4,5,6];
var spliceArray = array1.splice(0,2);
var sliceArray = array2.slice(0,2);
console.log('array1: ' + array1);
console.log('spliceArray: ' + spliceArray);
console.log('array2: ' + array2);
console.log('sliceArray: ' + sliceArray);
运行结果
array1: 2,3,4,5,6
spliceArray: 0,1
array2: 0,1,2,3,4,5,6
sliceArray: 0,1
可以看到,splice改变了原始数组,而slice没有。我们认为,slice不改变原来数组的方式更加“安全”。改变原始组数,是一种“副作用”。
2.2 非纯函数可能带来的“副作用”
让我们来仔细研究一下“副作用”以便加深理解。那么,我们在纯函数定义中提到的万分邪恶的副作用到底是什么?
副作用是在计算结果的过程中,系统状态的一种变化,或者与外部世界进行的可观察的交互。
副作用可能包含,但不限于:
- 列表内容
- 更改文件系统
- 往数据库插入记录
- 发送一个 http 请求
- 可变数据
- 打印/log
获取用户输入 - DOM 查询
- 访问系统状态
这个列表还可以继续写下去。概括来讲,只要是跟函数外部环境发生的交互就都是副作用——这一点可能会让你怀疑无副作用编程的可行性。函数式编程的哲学就是假定副作用是造成不正当行为的主要原因。
function getName(obj){
return obj.name;
}
function getAge(obj){
return obj.age;
}
function selfIntroduction(people){
console.log(getName(people));
console.log(getAge(people));
}
var Lee = {
name: 'LYY',
age: 25
};
selfIntroduction(Lee);
运行结果
LYY
25
显然selfIntroduction
这个函数不是纯函数,它依赖于getName
、getAge
两个函数,如果我不小心改变了其中某个函数的功能,这将使得selfIntroduction
这个函数出现错误。你现在可能感觉自己不会犯这样的错误,但当网页变得复杂,且由多人维护的时候,这将是个很难发现的bug。
2.3 纯函数编程的优点
- 可缓存性(Cacheable)
纯函数总能够根据输入来做缓存。实现缓存的一种典型方式是 memoize 技术:
var memoize = function(f) {
var cache = {};
return function() {
var arg_str = JSON.stringify(arguments);
cache[arg_str] = cache[arg_str] ? cache[arg_str] + '(from cache)' : f.apply(f, arguments);
return cache[arg_str];
};
};
var squareNumber = memoize(function(x){ return x*x; });
console.log(squareNumber(4));
console.log(squareNumber(4));
console.log(squareNumber(5));
console.log(squareNumber(5));
执行结果:
16
16(from cache)
25
25(from cache)
- 可移植(Portable)
纯函数是完全自给自足的,它需要的所有东西都能轻易获得。仔细思考思考这一点…这种自给自足的好处是什么呢?首先,纯函数的依赖很明确,因此更易于观察和理解——没有偷偷摸摸的小动作。这使得你在阅读这种代码的时候更容易,一个函数完成一个功能,不再依赖其他函数或者变量。 - 可测试(Testable)
第三点,纯函数让测试更加容易。因为只要每次输入相同,纯函数将输出相同的结果,不需要多次测试同一个输入。 - 合理性(Reasonable)
很多人相信使用纯函数最大的好处是引用透明性(referential transparency)。如果一段代码可以替换成它执行所得的结果,而且是在不改变整个程序行为的前提下替换的,那么我们就说这段代码是引用透明的。
由于纯函数总是能够根据相同的输入返回相同的输出,所以它们就能够保证总是返回同一个结果,这也就保证了引用透明性。 - 并行代码(Parallel)
最后一点,也是决定性的一点:我们可以并行运行任意纯函数。因为纯函数根本不需要访问共享的内存,而且根据其定义,纯函数也不会因副作用而进入竞争态(race condition)。
并行代码在服务端 js 环境以及使用了 web workers 的浏览器那里是非常容易实现的,因为它们使用了线程(thread)。不过出于对非纯函数复杂度的考虑,当前主流观点还是避免使用这种并行。
3. 不要滥用函数式编程或者纯函数
以上分析提到了纯函数的很多优点,但是,这并不是要求我们编写的每一个函数都是纯函数。函数越“纯”,对环境依赖越少,往往意味着要输入更多参数。
3.1 可以提纯的情况举例
var pureHttpCall = memoize(function(url, params){
return function() { return $.getJSON(url, params); }
});
这里有趣的地方在于我们并没有真正发送 http 请求——只是返回了一个函数,当调用它的时候才会发请求。这个函数之所以有资格成为纯函数,是因为它总是会根据相同的输入返回相同的输出:给定了 url 和 params 之后,它就只会返回同一个发送 http 请求的函数。这种技巧结合 柯里化 及 代码组合会使我们的JS代码清晰、可维护。
柯里化 我写过一篇博客,你可以点击阅读。 代码组合其实比较简单,现在举例如下。
var compose = function(f, g) {
return function(x) {
return f(g(x));
};
};
function getPeople(){
return {};
}
function namePeople(p){
p.name = 'Lee';
return p;
}
var definePeople = compose(namePeople, getPeople);
var people = definePeople();
console.log(people);
执行结果:
Object {name: “Lee”}
看,代码组合就是字面意思。
3.2 滥用函数式编程的举例
var getServerStuff = function(callback){
return ajaxCall(function(json){
return callback(json);
});
};
//如果你仔细分析这段代码,它就等价于
var getServerStuff = ajaxCall;
分析过程:
// 这行
return ajaxCall(function(json){
return callback(json);
});
// 等价于这行
return ajaxCall(callback);
// 那么,重构下 getServerStuff
var getServerStuff = function(callback){
return ajaxCall(callback);
};
// ...就等于
var getServerStuff = ajaxCall;
还有这种控制器:
var BlogController = (function() {
var index = function(posts) {
return Views.index(posts);
};
var show = function(post) {
return Views.show(post);
};
var create = function(attrs) {
return Db.create(attrs);
};
var update = function(post, attrs) {
return Db.update(post, attrs);
};
var destroy = function(post) {
return Db.destroy(post);
};
return {index: index, show: show, create: create, update: update, destroy: destroy};
})();
我们可以直接把它重写成:
var BlogController = {index: Views.index, show: Views.show, create: Db.create, update: Db.update, destroy: Db.destroy};
当你滥用函数式编程时,很可能使得你的代码很难懂。所以,我的建议是,代码保持最直接简洁的状态,尽量使用纯函数(容易维护)、适当情况下使用函数式编程(容易看懂)。