第13章 函数和抽象思考的力量
13.1 函数作为子程序
子程序将一些重复的功能进行简单的封装,并赋予它一个名字。
通常,子程序用来封装某个算法,该算法只是一个可被理解的执行单元,用来执行给定任务。
创建一个 判断闰年 的可复用子程序(函数):
function printLeapYearStatus(){
const year = new Date().getFullYear();
if(year % 4 != 0) console.log(`${year} is not a leap year.`);
else if(year % 100 != 0) console.log(`${year} is a leap year.`);
else if(year % 400 != 0) console.log(`${year} is not a leap pear.`);
else console.log(`${year} is a leap year.`);
}
printLeapYearStatus(); //2018 is not a leap year.
13.2 函数作为有返回值的子程序
重新定义pringLeapYearStataus函数,让它变成一个有返回值的子程序:
function isCurrentYearLeapYear(){
const year = new Date().getFullYear();
if(year %4 !== 0) return false;
else if(year % 100 != 0) return true;
else if(year % 400 != 0) return false;
else return true;
}
//使用返回值
const daysInMonth = [31,isCurrentYearLeapYear() ? 29 : 28,31,30,31,30,31,31,30,31,30,31];
console.log(daysInMonth); //
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if(isCurrentYearLeapYear()) console.log("It is a leap year.")
else console.log("it is not a leap year.") //
it is not a leap year.
13.3 函数即...函数
遵守数学定义的函数会被开发人员称之为纯函数。
纯函数必须做到,对同一组输入,始终返回相同的结果。其次,纯函数不能有副作用,也就是说,调用该函数不能改变程序的状态。
将 判断闰年 的例子变成一个纯函数:
function isLeapYear(year){
if(year %4 !== 0) return false;
else if(year % 100 != 0) return true;
else if(year % 400 != 0) return false;
else return true;
}
isLeapYear(2018); //false
修改后,针对相同的输入,函数的返回值始终一样,并且没有副作用,这样它就变成了一个纯函数。
下面是 彩虹颜色 的例子:
const colors = ['red','orange','yellow','green','blue','indigo','violet'];
let colorIndex = -1;
function getNextRainbowColor(){
if(++colorIndex >= colors.length) colorIndex = 0;
return colors[colorIndex];
}
getNextRainbowColor(); //"red"
getNextRainbowColor(); //"orange"
//...将一直循环...
这个函数不符合纯函数的两大规则:相同的输入(没有参数,所以没有输入)每次返回的结果不同;并且产生副作用(改变了colorIndex)。
可以通过将外部变量放入闭包从而消除副作用:
const getNextRainbowColor=(function(){
const colors = ['red','orange','yellow','green','blue','indigo','violet'];
let colorIndex = -1;
return function(){
if(++colorIndex >= colors.length) colorIndex = 0;
return colors[colorIndex];
}; //注意这里还要加分号
})();
getNextRainbowColor(); //"red"
getNextRainbowColor(); //"orange"
//在浏览器中循环调用该函数的代码
setInterval(function(){
document.querySelector(".rainbow").syle["background-color"] = getNextRainbowColor();
},500);
这样得到了一个没有副作用的函数,但这仍不是一个纯函数,因为对于相同的输入,它的返回值可能不一样。
在这个例子中,使用迭代器:
function getRainbowIterator(){
const colors = ['red','orange','yellow','green','blue','indigo','violet'];
let colorIndex = -1;
return {
next(){
if(++colorIndex >= colors.length) colorIndex = 0;
return {value:colors[colorIndex], done:false};
}
};
}
const rainbowIterator = getRainbowIterator();
rainbowIterator.next(); //{value: "red", done: false}
rainbowIterator.next();
//{value: "orange", done: false}
//setInterval改为:
setInterval(function(){
document.querySelector(".rainbow").syle["background-color"] = rainbowIterator.next().value;
},500);
这样,虽然使用next() 返回的值都不一样,但它是一个迭代器,它运行于自己所属对象的上下文中,行为受控于该对象。其他地方调用 getRainbowIterator ,就会生成不同的迭代器,并且各自独立,互不影响。
13.4 那又如何
至此,已经见识了函数的三种不同形式(子程序、有返回值的子程序、以及纯函数)。
用函数来定义子程序的目的:避免重复代码。通过封装代码来避免重复。
纯函数会让代码更易于测试,更轻量、可读性更高。
当一个函数在不同情况下返回不同的结果或者会产生副作用,那么称之为
上下文相关。
举个例子,某个函数会产生副作用,在一段程序下有用,当把它移到另一短程序后,他就可能失效了。更糟糕的是,在99%的时间里他能正常工作,但在剩下的时间里造成严重的漏洞。
这种间歇性漏洞可以长时间潜伏在系统中,很难解决。
应始终优先使用纯函数。
第9章,面向对象编程提供了一种范式,通过严格控制副作用的作用域,从而以一种受控和合理的方式使用副作用。
13.5 IIFEs 和异步代码
IIFEs 的一个重要用途是在一个全新的作用域中创建新变量,从而让异步代码正确执行。
倒计时代码:
var i; //这里使用了var
for(i=5; i>=0; i--){
setTimeout(function(){
console.log(i===0 ? "go!" : i);
},(5-i)*1000);
}
//按秒打印了6次-1
会看到-1 被打印了6。这是因为传给 setTimeout 的函数没有在循环中被调用,它们会在未来的某个时间点被调用。当函数被调用时,i 的值已经变成了 -1.
在块作用域变量(使用 let)出现之前,要解决这个问题需要借助一个额外函数。使用这个额外的函数创建一个新的作用域,就可以在每一步执行中“捕获”(在闭包中)i 的值。
先考虑一个具名函数:
function loopBody(i){
setTimeout(function(){
console.log(i===0 ? "go!" : i);
},(5-i)*1000);
}
var i;
for(i=5; i>=0; i--){
loopBody(i);
}
//将从5打印到1,最后“go”
函数实际上创建了6个不同的作用域,以及6个独立的变量(一个给外部作用域,其他5个在调用loopBody 时使用)。
使用IIFE的版本(创建一个等价的匿名函数,这个函数会被立即执行):
var i;
for(i=5; i>=0; i--){
(function(i){
setTimeout(function(){
console.log(i === 0 ? "go!" : i);
},(5-i)*1000);
})(i);
}
//将从5打印到1,最后“go”
它创建了一个只有一个参数的函数,然后在每一次的循环中调用该函数。
使用块作用域变量可以简化这个例子,省去了函数创建新作用域的麻烦:
for(
let i=5; i>0; i--){
setTimeout(function(){
console.log( i === 0 ? "go!" : i );
},(5-i)*1000);
}
//将从5打印到1,最后“go”
for 循环里使用了 let 关键字,如果把它放到循环外面,之前的问题又会出现。
这里的 let关键字,它告诉JavaScript ,在每一次循环中,为变量 i 生成一个新的、独立的拷贝。
所以当setTime 中的函数在未来的某个时刻执行时,它们接收的值都是来自自身作用域的变量。
13.6 函数变量
凡是可以使用变量的地方,都可以使用函数。
意味着除了变量的普通用法外,还可以:
-
通过创建一个指向函数的变量来给函数起一个别名
-
将函数放入数组中
-
将函数当做对象的属性
-
从函数传入到另一个函数中
-
从一个函数中返回一个函数
-
从一个把函数当做参数的函数中返回一个函数
13.6.1 数组中的函数
使用数组的好处是可以随时修改它。
图形转换就是一个这样的例子。如果开发一个可视化软件,通常会有一个在很多点上都会用到的转换“管道”。常见的二位转换实例:
const sin = Math.sin;
const cos = Math.cos;
const theta = Math.PI/4;
const zoom = 2;
const offset = [1,-3];
const pipeline = [
function rotate(p){
return{
x: p.x * cos(theta) - p.y * sin(theta),
y: p.x * sin(theta) + p.y * cos(theta)
};
},
function scale(p){
return{ x: p.x * zoom,y: p.y * zoom };
},
function translate(p){
return{ x: p.x + offset[0],y: p.y + offset[1] };
}
];
//此时pipeline 是一个包含了特殊 2D 转换的函数数组
//我们可以转换一个点:
const p = {x:1, y:1};
let p2 = p;
for(let i=0; i
p2 = pipeline[i](p2);
} //
{x: 1.0000000000000002, y: -0.17157287525381015}
//此时p2 是基于开始位置旋转45度( PI/4弧度 )
//
然后向前移动2 个单位,向右1 个,向下3个单位,所的出的点
p2通过for 循环,经过了数组中三个函数的处理,最后的值是它们累计的结果。这样一个过程被称为管道处理。
任何时候当需要按照指定顺序执行一系列函数时,管道就是一个有用的抽象原则。
13.6.2 将函数传给函数
前面接触过,将函数传给函数的例子:把函数传给setTimeout 或 forEach。这样做的是为了管理异步编程。
实现异步执行的常见方法:将一个函数( 叫回调函数,cb )传给另一个函数。 该函数在闭包函数执行完成时被调用(回调)。
除了用来回调,还能用来“注入”功能。看一个例子:sum函数可以用来统计一个数组的所有数字之和,还能返回数字平方和、立方和。
function sum(arr,f){
if(typeof f != "function") f = x =>x;
//没有传入函数,则转成“空函数”
return arr.reduce((a,x) => a += f(x),0);
//回调函数在执行完成时被调用
}
sum([1,2,3]); //6
sum([1,2,3],x => x*x); //14
sum([1,2,3],x => Math.pow(x,3)); //36
通过给sum 函数传入一个函数,就可以完成任何想做的事情。如果不传入函数,f 的值是 undefined,这时,调用它就会出错。为了防止这种错误,把非函数的参数转成一个“空函数”,其实就是什么都不做。如果传入5,它就返回5。
13.6.3 在函数中返回函数
可以把从函数中返回函数,看成3D 打印机:它自己制造一些东西,然后这些东西还可以继续制造东西。
我们定制返回函数,类似于定制3D 打印机打印出的东西。
把接收多个参数的函数转成接收单个参数的函数,这种技术叫做柯里化(currying)。
一个函数的参数中又有数组,又有函数是不太好的,因此将其柯里化。其中一种实现方式是创建一个新函数,让该函数调用原来的函数。
function sum(arr,f){
if(typeof f != "function") f = x =>x;
return arr.reduce((a,x) => a += f(x),0);
}
//创建一个新函数(计算平方和),让它调用原来的函数
function sumOfSquares(arr){
return sum(arr,x => x*x);
}
sum([1,2,3],x => x*x); //14
//转化为
sumOfSquares([1,2,3]); //14
如果需要不断这种重复模式,创建一个返回特定函数的函数:
function sum(arr,f){
if(typeof f != "function") f = x =>x;
return arr.reduce((a,x) => a += f(x),0);
}
//创建一个返回特定函数的函数:
function newSummer(f){
return arr => sum(arr,f);
}
const sumOfSquares = newSummer(x => x*x);
const sumOfCubes = newSummer(x => Math.pow(x,3));
sumOfSquares([1,2,3]); //14
sumOfCubes([1,2,3]); //36
13.7 递归
递归是指那些调用自身的函数。当递归函数的输入集合不断缩小的时候,这项技术就会变得异常强大。
干草寻针 的例子:
function findNeedle(haystack){
if(haystack.length === 0) return "no haystack here";
if(haystack.shift() === "needle") return "found it";
//shift方法删除数组首个值并返回删掉的值
return findNeedle(haystack); //前两个条件不满足时,继续调用自身
}
findNeedle(["hay","hay","hay","needle","hay"]); //
"found it"
findNeedle([]); //
"no haystack here"
其基本原理是不断地削减草堆,直到找到针为止。而这本质上是递归。
递归函数一定要有一个结束条件,不然它会一直递归下去。直到JavaScript解释器认为调用栈太深了。
例子,计算一个数的阶乘:某个数阶乘等于这个数和小于它的所有正整数相乘,阶乘的表示方式是在数字后加感叹号,如 4! 指的是 4x3x2x1 = 24。递归的实现方式:
function fact(n) {
if(n === 1) return 1;
return n * fact(n-1);
}