众所周知,在函数式语言中,函数是一等公民,就像是对象一样,函数也可以是参数和返回值,从严格意义上来说函数式编程也意味着不使用可变的变量,赋值,循环和其他命令式控制结构进行编程,而是以表达式的方式编程,通过各种组合子进行数据的操作
函数式的最主要的好处主要是不可变性带来的:
- 没有副作用,不容易出错,测试调试也更方便
- 不共享状态,不造成资源竞争,更好的并行处理能力
-
什么是编程范式?
函数式编程是一种编程范式,我们常见的编程范式还有
命令式编程 (Imperative programming),
- 逻辑式编程
常见的面向对象编程也是一种命令式编程。命令式编程是面向计算机硬件的抽象:
- 变量:对应着存储单元
- 赋值语句:对应获取存储指令
- 表达式:对应内存引用和算术运算
- 控制语句:跳转指令
函数式的一些特性
不可变性(Immutabillity)
对于值的操作并不是修改原来的值,而是修改新产生的值,原有的值保持不变
let item = {
id: 0,
name: 'karim',
number: 10
}
let newItem = Object.keys(item).reduce((previousValue, currentValue) => {
if (currentValue !== 'name') {
return {
...previousValue,
[currentValue]: item[currentValue]
}
}
return previousValue;
}, {})
console.log(newItem);
// { id: 0, number: 10 }
console.log(item);
// { id: 0, name: 'karim', number: 10 }
函数式编程无法实现循环,因为 for 循环使用可变的状态作为计数器,而 while 循环需要可变的状态作为跳出循环的条件,因此在函数式语言里就只能使用递归来解决迭代问题。
在JavaScript中,我们通常使用for循环,语句中包含一些可变变量
let values = [1, 2, 3, 4, 5];
let sumOfValues = 0;
for (let i = 0; i < values.length; i++) {
sumOfValues += values[i];
}
console.log(sumOfValues) // 15
对于每一次迭代,我们都在改变i和sumOfValue状态。但是我们如何处理迭代中的可变性呢?这个时候就需要用到递归
let list = [1, 2, 3, 4, 5]
let accumulator = 0;
function sum(list, accumulator) {
if (list.length === 0) {
return accumulator
}
return sum(list.slice(1), accumulator + list[0])
}
console.log(sum(list, accumulator))
// 15
console.log(list)
// [ 1, 2, 3, 4, 5 ]
console.log(accumulator)
// 0
slice()方法返回一个新的数组对象,这一对象是一个由begin和end决定的原数组的浅拷贝(包括begin,不包括end)。原始数组不会被改变
这里我们有一个和函数它接收一个数值向量。函数调用自身,直到列表为空(递归基本情况)。对于每个“迭代”,我们将把值加到总累加器中。
使用递归,我们保持变量不变。列表和累加器变量不变。它保持不变的值。
注意:递归方式关注的是计算表达定义
纯函数(Pure function)
遵守下面两条规定的函数,可以称之为纯函数:
- 一致的输入可以获得一致的输出,不受调用次数和外界因素的影响;
- 没有副作用(side effect)
第一条也可以理解为纯函数的返回值只依赖其参数
假设我们想要实现一个计算圆面积的函数。一个非纯函数将接收作为参数,然后计算 radius :
let PI = 3.14;
const calculateArea = (radius) => radius * radius * PI;
calculateArea(10); // returns 314.0
为什么这不是一个纯函数?原因很简单,因为它使用了一个全局对象,该对象没有作为参数传递给函数。
现在假设一些数学家认为PI值实际上是42,并改变了全局对象的值。
我们的非纯函数现在的结果是10 10 42 = 4200。对于相同的参数(半径= 10),我们有不同的结果。
让我们解决它:
let PI = 3.14;
const calculateArea = (radius, pi) => radius * radius * pi;
calculateArea(10, PI); // returns 314.0
现在我们总是把PI的值作为参数传递给函数。现在我们只是在访问传递给函数的参数。没有外部对象。
- 对于参数radius = 10和pi = 3.14,我们将始终得到相同的结果:314.0
- 对于参数radius = 10和pi = 42,我们将始终得到相同的结果:4200
第二条,它不会产生任何明显的副作用
可见副作用包括修改全局对象或通过引用传递的参数。
现在我们想实现一个函数来接收一个整数值并返回增加1的值。
let counter = 1;
function increaseCounter(value) {
counter = value + 1;
}
increaseCounter(counter);
console.log(counter); // 2
函数接收value参数。我们的非纯函数接收到这个参数的值,并将该值加1重新赋值给计数器。
let counter = 1;
const increaseCounter = (value) => value + 1;
increaseCounter(counter); // 2
console.log(counter); // 1
我们正在修改全局对象。但我们如何使它纯净呢?只要返回值增加1即可。
我们的纯函数increecounter返回2,但计数器值仍然相同。函数返回递增的值,而不改变变量的值。
纯函数是稳定的、一致的和可预测的。给定相同的参数,纯函数总是返回相同的结果。我们不需要考虑相同参数产生不同结果的情况,因为这种情况永远不会发生。
高阶函数(Higher-order function)
通俗的讲,我们将参数为函数,或返回值为函数的函数,称为高阶函数。
function add(a, b, fn) {
console.log(fn(a)) // 4
console.log(fn(b)) // 9
return fn(a) + fn(b);
}
let fn = function(a) {
return a * a;
}
console.log(add(2, 3, fn)) //13
在同等复杂度的代码,高阶函数能让实现更加简单,高阶函数还能够非常方便的拆分逻辑
柯里化(Currying)
只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。
// 柯里化之前
function add(x, y) {
return x + y;
}
console.log(add(1, 2)) // 3
// 柯里化之后
function addX(y) {
return function (x) {
return x + y;
};
}
console.log(addX(2)(1)) // 3
闭包(Closure)
闭包是由两部分组成:函数+一个自由访问的变量
闭包(closure):一个普通函数function,如果它可以访问外层作用域中的自由变量,那么这个函数就是闭包
闭包的主要作用:延伸了变量的作用范围 当函数中有一个局部变量时通常调用完毕之后就会销毁但是在闭包中不会销毁
- 实现简单的闭包
外部函数所有局部变量对内部函数都是可见的,内部函数的局部变量对外部函数是不可见的,这就是JavaScript语言特有的“链式作用域”结构(chain scope),子对象会一级一级的寻找父对象的变量。
正常的情况下函数外部是不能访问函数内部的局部变量的
function b() {
var n = 888;
}
console.log(n); // n is not defined
由于种种原因需要得到函数内的局部变量。需要在函数的内部,再定义一个函数
function funParent() {
let n = 777;
function funChild() {
console.log('拿到父函数中的局部变量:', n);
}
return funChild; // 返回子函数,在外部就可以调用到它的内部变量了
}
// 定义一个变量接收函数值
let res = funParent();
res(); // 777
闭包的最大用处有两个:
- 表达式化
就是去掉变量,去掉循环,通过表达式处理逻辑。 - 数据与行为分离,无副作用
由于严格作用域,必须没有副作用。 - 高阶思维
能用 map 解决的,就不要 for 循环 - 组合思维
函数式和面向对象是相反的,面向对象是自顶向下的设计,函数式是自底向上的设计,也就是先定义基本操作,然后不断组合。比如 sql 定义了 select, from, where … 这些组合子来满足查询需求。