一、函数式编程核心概念
面向对象编程通过封装变化使得代码更易理解。
函数式编程通过最小化变化使得代码更易理解。
编写代码需要思考一下原则:
- 可扩展性一一我是否需要不断地重构代码来支持额外的功能?
- 易模块化一一一如果我更改了一个文件,另一个文件会不会受到影响?
- 可重用性一一是否有很多重复的代码?
- 易推理性一一我写的代码是否非结构化严重并难以推理?
- 可测性一一给这些函数添加单元测试是否让我纠结?
什么是函数式编程?
函数式编程是一种强调以函数使用为主的软件开发风格。
为了充分理解函数式编程,首先必须知道它所基于的一些基本概念:
声明式编程
先看命令式代码与声明式的对比:
// 命令式
let array = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9];
for (let i = 0; i < array.length ; i++){
array[i] = Math.pow (array[i], 2) ;
}
// - > [O , 1 , 4, 9 , 16 , 25 , 36 , 49 , 64 , 81]
// 声明式
[0 , 1, 2, 3, 4 , 5 , 6, 7 , 8 , 9] .map(num => Math.pow(num , 2)) ;
// - > [O , 1 , 4, 9 , 16, 25 , 36 , 49 , 64 , 81]
命令式编程主张告诉编译器“如何”做。(如上例,获取数组长度、循环数组,根据索引获取元素...)
而声明式编程中,我们告诉编译器做“什么”,而不是“如何”做。“如何”做的部分被抽象到普通函数中。(箭头函数)
可以看到,命令式编程中的循环是很难被重用的东西。 而这正是函数式编程要去做的。我们将使用如 map 、 reduce 和 filter 这样的高阶函数来从代码中去除循环。
纯函数
纯函数具有以下性质:
- 仅取决于提供的输入,并且对于给定的输入返回相同的输出
- 不会造成超出其作用域的变化,例如修改全局对象或引用传递的参数
直观地看 ,任何不符合以上条件的函数都是“不纯的”。考虑以下函数:
let counter = O;
function increment () {
return ++counter ;
}
这个函数是不纯的,因为它具有副作用,修改了一个外部变量 。(即函数作用域外的counter )
纯函数不应改变任何外部环境的变量。换句话说就是,纯函数不依赖任何外部变量。
另一种常见的副作用发生在通过 this 关键字访问实例数据时。 this 在 JavaScript 中的行为与其他编程语言中的不同,因为它决定了一个函数在运行时的上下文。而这往往就导致很难去推理代码,这就是为什么要尽可能地避免。
纯函数的好处
-
合理的代码
因为在代码库中包含具有副作用的函数对团队其他开发者来说是难以阅读的。
-
可缓存
既然纯函数总是为给定的输入返回相同的输出,那么我们就能够缓存函数的输出来代替函数调用。
假设任何程序可以被定义为一组的函数,对于一个给定的输入, 会产生一个输出,则可表示为:
Program = [Input] + [funcl, func2, func3, ...] - > Output如果函数
[funcl, func2, func3, ...]都是纯的,则可以轻易地将由其产生的值来重写这个程序 一一[val1, val2, val3, ...]一一而不改变结果 。
引用透明
函数对于相同的输入都将返回相同的结果。这是纯函数的一个特质,称为引用透明 。
再次对比:
// 命令式版本
increment {) ;
increment {) ;
print(counter) ;
// -> ?
// 该值依赖于 counter 的初始状态,且如果调用期间该值发生变化,则是不可预测的
// 函数式版本
const plus2 = run(increment , increment);
print (run(O)) ;
// -> 2
// 该值总为初始值加2
不可变数据
虽然可以通过定义函数形参的方式来避免在大多数情况下的副作用,但是在用引用来传递对象时,一定要谨慎, 不要在不经意间改变它们。
不可变数据是指那些被创建后不能更改的数据。 与许多其他语言一样, JavaScript中的所有基本类型 ( String 、 Number 等 )从本质上是不可变的。但是对于对象而言,例如数组,都是可变的。如下:
const a = [{ val: 1 }]
const b = a.map(item => item.val = 2)
// 期望:b 的每一个元素的 val 值变为 2,但最终 a 里面每个元素的 val 也变为了 2
console.log(a[0].val) // 2
这是语言的一个缺陷,我们将在后续的章节中克服它。
二、函数式与面向对象的程序设计
自然语言是没有主导范式的 , JavaScript 也同样没有。 开发者可以从过程式、 函数式和面向对象的“大杂烩” 中选择自己需要的,再适时地把它们融为一休。
面向对象
考虑一个简单的涉及 Student 对象的学习管理系统模型。从类或类型层次的角度来看,我们能够很自然地想到 Student 应该作为 Person 的一个子类型,其中包括像姓、名、地址等基本属性。
面向对象的 JavaScript:当说到一个对象与另一个对象之间具有子类型或派生类型的关系时,指的是它们之间存在的原型关系。
面向对象的核心,就是将创建派生对象作为程序中代码重用的主要手段。但这使得在原对象中添加更多功能变得很棘手,因为它的后代并不一定会适用于这些新功能。(如通过 Student 派生出诸如 CollegeStudent 的对象)
面向对象的应用程序大多是命令式的,因此在很大程度上依赖于使用基于对象的封装来保护其自身和继承的可变状态的完整性,再通过实例方法来暴露或修改这些状态。这也正解释了为什么对象是抽象的核心。
面向函数
再看函数式编程,它不需要对调用者隐藏数据,通常使用一些更小且非常简单的数据类型。由于一切都是不可变的,对象都是可以直接拿来使用的,而且是通过定义在对象作用域外的函数来使用的。在这种范式中 ,函数成为抽象的主要形式。
面向对象的程序设计通过特定的行为将很多数据类型逻辑地连接在一起, 函数式编程则关注如何在这些数据类型之上通过组合来连接各种操作。 因此存在一个两种编程范式都可以被有效利用的平衡点。
如图所示,两种范式的差别随着横竖坐标的增长逐渐显现。在实践中,一些极好的面向对象代码均使用了两种编程范式一一正是在这个相交的平衡点上。要做到这一点,你需要把对象视为不可变的实体或值,并将它们的功能拆分成可应用在该对象上的函数。
两种编程范式结合:
// 函数中 this 可以替换为传入的参数对象
const fullname = person => [person.firstname, person.lastname].join (' ');
此时,fullname() 可以适用于任何派生自 Person 的对象。
面向对象的关键是创建继承层次结构( 如继承 Person 的 Student 对象 ),并将方法与数据紧密的绑定在一起。函数式编程则更倾向于通过广义的多态函数,交叉应用于不同的数据类型 , 同时避免使用 this。
将 fullname() 分离至独立的函数,可以避免使用 this 引用来访问对象数据。使用 this 的缺点是它给予了超出方法作用域的实例层级的数据访问能力,从而可能导致副作用。使用函数式编程,对象数据不再与代码的特定部分紧密耦合,从而更具重用性和可维护性。
可以通过将其他函数作为参数的形式(而不是通过创建一堆的派生类型)来扩展当前函数的行为。
面向对象的设计着重于数据及数据之间的关系,函数式编程则关注于操作如何执行,即行为。
| 函数式 | 面向对象 | |
|---|---|---|
| 组合单元 | 函数 | 对象(类) |
| 编程风格 | 声明式 | 命令式 |
| 数据和行为 | 独立且松耦合的纯函数 | 与方法紧耦合的类 |
| 状态管理 | 将对象视为不可变的值 | 主张通过实例方法改变对象 |
| 程序流控制 | 函数与递归 | 循环与条件 |
| 线程安全 | 可并发编程 | 难以实现 |
| 封装性 | 因为一切都是不可变的 , 所以没有必要 | 需要保护数据的完整性 |
管理JavaScript对象的状态
程序的状态可以定义为在任一时刻存储在所有对象之中的数据快照。可惜的是,JavaScript 是在对象状态安全方面做得最差的语言之一。 JavaScript 的对象是高度动态的,其属性可以在任何时间被修改、增加或删除。
值对象模式
在传统意义上,原始类型本身就是不可变的。在函数式编程中,我们将具有此种行为的类型称为数值。在前面,我们知道了要做到不可变,就需要将任何对象视为数值。而这样做可以让函数将对象传来传去,而不用担心它们被篡改。
尽管 JavaScript 的原始类型是不能改变的,但引用原始类型的变量状态是可以被更改的。因此,提供或者至少模拟对数据的不可变引用,才能使得自定义对象具有近似不可变的行为。
封装是一个防止篡改的不错策略。对于一些简单的对象结构, 一个好的方法是采用值对象模式。比如以下是一个邮编的实现代码:
function zipcode(code, location) {
let _code = code;
let _location =location || '';
return {
code: function() {
return _code;
},
location: function() {
return _location;
},
fromString: function (str) {
let parts = str.split ('-');
return zipCode(parts[0], parts[1]);
},
toString: function() {
return _code + '-' + _location;
}
};
}
const princetonZip = zipCode('08544', '3345');
princetonZip.toString(); // -> '08544 3345'
在 JavaScript 中,可以使用函数来保障 ZIP code 的内部状态访问权限,通过返回一个对象字面接口来公开一小部分方法给调用者,这样就可以将 code 和 location 视为伪私有变量。 这些变量只能通过闭包的方式由对象的字面定义中访问。
返回的对象可以表现出像原始类型一样没有可变方法的行为。再看一个例子:
function coordinate(lat, long) {
let _lat = lat;
let _long = long;
return {
latitude: function() {
return _lat;
},
longitude: function() {
return _long;
},
translate: function(dx, dy) {
return coordinate(_lat + dx, _long + dy);
},
toString: function() {
return '(' + _lat + ',' + _long + ')';
}
};
}
const greenwich = coordinate(51.4778 , 0.0015) ;
greenwich.toString (); // -> '(51.4778, 0.0015)'
让方法返回一个新的副本(例如 translate )是另一种实现不可变性的方式。 在该对象上应用 一次平移操作,将产生一个新的 coordinate 对象:
greenwich.translate(10, 10).toString(); // -> '( 61.4778, 10.0015 )'
值对象是一个由函数式编程启发而来的面向对象设计模式。 在实践中,代码有可能需要处理层次化数据(例如之前的 Person 和 Student )。JavaScript 可以使用 Object.freeze() 机制来模拟这些问题。
深冻结可变部分
尽管 JavaScript 新的类定义语法中不存在能够将字段标记为不可变量的关键字,但它拥有一种内部机制,通过控制一些如 writable 的隐藏对象属性来实现。JavaScript 的 Object.freeze() 函数可以通过将该属性设置为 false 来阻止对象状态的改变。
const person = Object.freeze(new Person ('Haskellt', 'Curry', '444-44-4444'));
但是,它不能被用于冻结嵌套对象属性。
尽管 Person 己被冻结,但其内部对象属性(如 address )并不会被冻结,因此 person.address.country 可以随时改变。这是由于只有顶层变量会被冻结,也就是说,该机制是浅冻结。
要解决该问题,需要手动冻结对象的嵌套结构。
// 使用递归函数来深冻结对象
const isObject = (val) => val && typeof val === "Object";
function deepFreeze(obj) {
if (isObject(obj) && !Object.isFrozen(obj)) {
Object.keys(obj).forEach((name) => deepFreeze(obj[name]));
Object.freeze(obj);
}
return obj;
}
上述的一些技巧可以用来增强代码中的不可变性水平。
三、关注操作本身
函数式编程更关注于操作本身而不是数据结构。
命令式代码的缺点是限定于高效地解决某个特定的问题。因此,比起函数式代码,其抽象水平要低得多。抽象层次越低,代码的重用的概率就会越低,出现错误的复杂性和可能性就会越大。
用高阶函数代替循环
如 map 、reduce 、 filter 等函数。
用递归代替循环
递归是一种旨在通过将问题分解成较小的自相似问题来解决问题本身的技术,如果问题可以分解成较小的问题,就可以逐个解决,再将这些结论组合起来构建出整个问题的解决方案。递归函数包含以下两个主要部分:
- 终止条件
- 递归主体
终止条件是能够令递归函数计算出具体结果的一组输入,而不必再重复下去。递归主体则处理函数调用自身的一组输入(必须小于原始值)。如果输入不变小,那么递归就会无限期地运行,直至程序崩溃。随着函数的递归,输入会无条件地变小,最终到达触发终止条件,以一个值作为递归过程的终止。
学会递归地思考
递归地思考需要考虑递归自身以及自身的一个修改版本。以对数组中的所有数求和为例:
// 命令式实现
let ace = O;
for (let i = O; i < nums.length; i++) {
ace += nums[i] ;
}
// 高阶函数代替循环
nums.reduce((acc, current) => acc + current, 0);
// 递归实现
function sum(arr) {
if (!_.isEmpty(arr)) {
return 0;
}
// 递归主体:使用更小的输入集调用自身。这里通过 _.first 和 _.rest 缩减输入集
return _.first(arr) + sum(_.rest(arr));
}
从底层来看,递归调用会在栈中不断堆叠。当算法满足终止条件时,运行时就会展开调用栈并执行加操作,因此所有返回语句都将被执行。递归就是通过语言运行时这种机制代替了循环。
递归过程:
1 + sum[2, 3, 4, 5, 6, 7, 8, 9]
1 + 2 + sum[3, 4, 5, 6, 7, 8, 9]
1 + 2 + 3 + sum[4, 5, 6, 7, 8, 9]
1 + 2 + 3 + 4 + sum[5, 6, 7, 8, 9]
1 + 2 + 3 + 4 + 5 + sum[6, 7, 8, 9]
1 + 2 + 3 + 4 + 5 + 6 + sum[7, 8, 9]
1 + 2 + 3 + 4 + 5 + 6 + 7 + sum[8, 9]
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + sum[9]
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + sum[]
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 0 -> 满足终止条件时,展开调用栈并执行加操作
1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9
1 + 2 + 3 + 4 + 5 + 6 + 7 + 17
1 + 2 + 3 + 4 + 5 + 6 + 24
l + 2 + 3 + 4 + 5 + 30
1 + 2 + 3 + 4 + 35
1 + 2 + 3 + 39
1 + 2 + 42
1 + 44
45
递归的缺点
函数调用自己时会创建新的函数上下文。所以效率低下或者不正确的递归调用,例如永远无法满足结束条件,或者用效率低下的递归来遍历巨大的数组,很容易导致栈溢出。如何解决呢?可采用记忆化递归或者尾递归优化。
记忆化递归避免重复计算
递归是将任务分解成更小版本的自己的机制。通常情况下,每次递归调用都在一个更小的子集解决“同样的问题”,直至达到递归的终止条件,然后释放堆栈返回结果。如果每一个子任务的结果都能缓存, 就可以减少重复同样的计算, 从而提高性能。如下:
const memoized = (fn) => {
const lookupTable = {};
return (arg) => lookupTable[arg] ?? (lookupTable[arg] = fn(arg));
}
使用:
const fastFactorial = memoized((n) => {
if (n === 0) {
return 1;
}
// 递归
return n * fastFactorial(n - 1);
})
fastFactorial(5)
// => lookupTable 将为: Object {0: 1, 1: 1, 2: 2, 3: 6, 4: 24, 5: 120}
fastFactorial(6)
现在再次调用 fastFactorial(6) 时,通过记忆化能够重复使用 fastFactorial(5)的结果,所以只会创建 2 个栈帧。由此减轻了堆栈失控的问题。
记忆化不是优化递归调用的唯一方法,还存在其他的方法,比如编译器级别的优化——尾递归。
尾递归优化
当使用尾递归时,编译器有可能帮助你做尾部调用优化(TCO)。TCO 是 ES6 添加的编译器增强功能。
这为什么算是一种优化?函数的最后一件事情如果是递归的函数调用,那么运行时会认为不必要保持当前的栈帧,因为所有工作已经完成,完全可以抛弃当前帧。
在大多数情况下,只有将函数的上下文状态作为参数传递给下一个函数调用,(如在递归阶乘函数处看到的),才能使递归调用不需要依赖当前帧。通过这种方式,递归每次都会创建一个新的帧,回收旧的帧,而不是将新的帧叠在旧的上。
如何实现?
// 经典实现
const factorial = (n) =>
(n === 1) ? 1
: (n * factorial(n - 1));
但上面的递归调用并没有发生在尾部,因为最后返回的表达式是n * factorial(n - 1)。改成尾递归只需要两步:
- 将当前乘法结果当作参数传人递归函数
- 使用 ES6 的默认参数给定一个默认值(也可以部分地应用它们,但默认参数会让代码更整洁)
const factorial = (n, current = 1) =>
(n === 1) ? current
: factorial(n - 1, n * current);
通过尾递归的形式,factorial(4)的调用会从典型的递归金字塔变为如下所示的扁平结构。
factorial(4)
factorial(3)
factorial(2)
factorial(1)
factorial(0)
return 24
四、函数式编程
真正的高阶函数
tap函数
const tap = (value) => (fn) => ( // 定义一个tap函数,并返回另一个函数
typeof of === "function" && fn(value),
console.log(value)
);
此处tap函数接受一个value并返回一个包含value的闭包函数,该函数将被执行。
tap("fun")((it) => console.log("value is ", it));
// => value is fun
// => fun
可以看到先是打印了 ”value is fun“,然后是 ”fun“。
unary函数
它的任务是接受一个给定的多参数函数,并把它转换为一个只接受一个参数的函数。
const unary = (fn) =>
fn.length === 1 // 检查传入的fn是否有一个长度为1的参数列表
? fn
: (arg) => fn(arg) // 如果没有就返回一个新函数,它只接受一个参数 arg,并用该参数调用fn
once函数
once 函数允许我们只运行一次给定的函数。
const once = (fn) => {
let done = false;
return function() {
return done
? undefined
: ((done = true), fn.apply(this, arguments));
// 通过apply来调用,并将done设置为true阻止下一次执行
}
}
使用once函数:
const doPayment = once(() => {
console.log("Payment is done");
})
doPayment();
// => Payment is done
// 我们不小心执行了第二次!
doPayment();
// => undefined
柯里化
通过引人函数柯里化不仅可以降低函数参数,还可以增强代码的模块化和重用性。
柯里化函数在所有参数提供完毕之后才会真正运行,当使用部分参数调用时,它会返回一个新的函数等待外部提供其余的参数。
compose函数
compose函数把一个函数的输出作为输入传递给另一个函数的方式把两个函数组合。
// compose 的定义
const compose = (...fns) =>
(value) =>
fns.reverse().reduce((acc, fn) => fn(acc), value);
// value作为函数的第一个输入
可以发现compose的数据流是从右至左的,最右侧的函数首先执行,将数据传递给前一个函数,以此类推,最左侧的函数最后执行。
管道函数
管道函数其实就是compose函数的复制品,唯一的修改就是数据流从左至右。
// 管道函数的定义
const pipe = (...fns) =>
(value) =>
reduce(fns, (acc, fn) => fn(acc), value);
惰性求值避免不必要的计算
在函数式编程中,模拟惰性求值来应用纯函数的好处。比如通过只传递函数引用(或函数名称),然后有条件的选择调用或不调用。
true || callback();