什么是函数式编程?
-
Functional Programming, FP
-
函数式编程是一种编程范式,主要是利用函数把运算过程封装起来,通过组合各种函数来处理数据
-
也就是不关心运算过程,把运算过程抽象成函数,再去组合使用
-
数学意义上y=f(x),把数据通过函数映射成想要的结果,可以让多个函数组合起来使用,组合高于集成继承
函数式编程特性
纯函数(Pure Functions)
纯函数是函数式编程中的核心概念。一个函数被称为纯函数如果它满足以下条件:
- 确定性:对于相同的输入,总是产生相同的输出。
- 无副作用:函数执行过程中不会改变外部状态(如全局变量、输入变量、数据库记录等)。
这使得纯函数更易于理解和测试,因为它们不依赖于外部环境。
不可变性(Immutability)
- 在函数式编程中,数据被视为不可变的。一旦数据被创建,它就不能被改变。如果需要修改数据,你应该创建一个新的数据副本并应用更改。这种方式有助于避免由于数据共享引起的副作用和状态变化,使得程序行为更可预测,更易于管理。
- 用不可变变量最大的好处是线程安全。多个线程可以同时访问同一个不可变变量,让并行变得更容易实现。由于 JavaScript 原生不支持不可变变量,需要通过第三方库来实现。(如 Immutable.js,Mori 等等)
var obj = Immutable({ a: 1 });
var obj2 = obj.set('a', 2);
console.log(obj); // Immutable({ a: 1 })
console.log(obj2); // Immutable({ a: 2 })
透明引用
- 指一个函数只会用到传递给它的变量以及自己内部创建的变量,不会使用到其他变量。
- 函数的运行不依赖于外部变量或"状态",只依赖于输入的参数,任何时候只要参数相同,引用函数所得到的返回值总是相同的
var a = 1;
var b = 2;
// 函数内部使用的变量并不属于它的作用域
function test1() {
return a + b;
}
// 函数内部使用的变量是显式传递进去的
function test2(a, b) {
return a + b;
}
函数是一等公民(First-Class Functions)
- JavaScript中的函数被视为“一等公民”,这意味着函数可以被赋值给变量、作为参数传递给其他函数、作为其他函数的返回结果,以及拥有与任何其他数据类型相同的行为。这种特性支持了高阶函数的使用。
用"表达式",不用"语句"
- "表达式"(expression)是一个单纯的运算过程,总是有返回值;
- "语句"(statement)是执行某种操作,没有返回值。
函数组合(Function Composition)
函数组合是将多个函数结合成一个函数的过程,每个函数的输出自动成为下一个函数的输入。这在JavaScript中可以通过各种库实现,如 Lodash 的 _.flow。函数组合有助于构建简洁且模块化的代码。
惰性求值(Lazy Evaluation)
- 惰性求值是指表达式不立即求值,而是在需要其结果时才求值。这可以提高应用程序的效率和性能,特别是处理大数据集或无限数据流时。虽然JavaScript本身不直接支持惰性求值,但可以通过各种技术和库(例如使用生成器)模拟。
函数式编程好处
- 代码简洁,开发快速,更复用
- 接近自然语言,易于理解
- 更方便的代码管理、方便维护、方便测试
- 易于"并发编程
- 代码的热升级
- 可以组合(方便扩展)
vue3响应式API,不是函数式编程
- vue3的响应式api,不是函数式编程,因为没有输入,而且函数多次调用结果不同
- 只是源码内部实现的功能是基于函数式编程
高阶函数
英文叫Higher-order function。JavaScript的函数其实都指向某个变量。既然变量可以指向函数,函数的参数能接收变量,那么一个函数就可以接收另一个函数作为参数,这种函数就称之为高阶函数
-
满足以下任何一个条件即可(或者的关系)
- 接收一个或多个函数作为参数
- 一个函数返回一个函数作为结果
-
常见的高阶函数包括:Promise、setTimeout、setInterval、节流防抖函数以及数组方法中的 map、reduce、filter 等等。
应用场景
实现扩展
-
扩展原来的函数,不破坏原来的功能,可以采用高阶函数
预制参数
-
给某些函数预制一些参数,分批传参,可以采用高阶函数
解决异步并发问题
纯函数
- 纯函数定义
- 对于相同的输入,永远得到相同的输出(函数的返回结果只依赖于它的参数)
- 没有任何可观察到的副作用(函数执行过程中没有副作用)
副作用概念
函数副作用,指当调用函数时,除了返回函数值之外,还对主调用函数产生附加的影响。例如修改全局变量(函数外的变量)或修改参数。
-
常见的副作用
- 对外部的资源访问
ajax - 访问数据库
- 改变全局变量
- 对公共内存进行管理
- dom访问等
- 对外部的资源访问
-
副作用会对方发的通用性减低,产生静态问题(多个参数,操作同一个资源,用谁的,谁快谁慢都会有问题)
纯函数优点
- 输入一定、输出一定,可以缓存,方便测试
- 可以并发执行
- 状态管理都会采用纯函数的方式
- 可复用性/可移植性
- 纯函数仅依赖于传入的参数,这意味着你可以随意将这个函数移植到别的代码中,只需要提供踏需要的参数即可。如果是非纯函数,有可能你需要一根香蕉,却需要将整个香蕉树搬过去
函数柯里化
- 是指把接收多个参数的函数变换成接收单一参数的函数,嵌套返回直到所有参数都被使用并返回最终结果。
- 更简单地说,柯里化是一个函数变换的过程,是将函数从调用方式:
f(a,b,c)变换成调用方式:f(a)(b)(c)的过程。柯里化不会调用函数,它只是对函数进行转换。 - 把一个具有较多参数数量的函数转换为具有较少参数数量函数的过程
- 强调的是转化后的参数是一个个的
- 如果是分批传入,则是
偏函数
// 普通函数
function add(x,y){
return x + y;
}
add(1,2); // 3
// 函数柯里化
var add = function(x) {
return function(y) {
return x + y;
};
};
var increment = add(1);
increment(2);// 3
-
简易的柯里化函数
function curryIt(fn) { // 参数fn函数的参数个数 var n = fn.length; var args = []; return function(arg) { args.push(arg); if (args.length < n) { return arguments.callee; // 返回这个函数的引用 } else { return fn.apply(this, args); } }; } function add(a, b, c) { return [a, b, c]; } var c = curryIt(add); var c1 = c(1); var c2 = c1(2); var c3 = c2(3); console.log(c3); //[1, 2, 3] -
柯里化是一种“预加载”函数的方法,通过传递较少的参数,得到一个已经记住了这些参数的新函数,某种意义上讲,这是一种对参数的“缓存”,
闭包
如果一个函数引用了自由变量,那么该函数就是一个闭包。何谓自由变量?自由变量是指不属于该函数作用域的变量(所有全局变量都是自由变量,严格来说引用了全局变量的函数都是闭包,但这种闭包并没有什么用,通常情况下我们说的闭包是指函数内部的函数)。
-
闭包的形成条件:
-
存在内、外两层函数
-
内层函数对外层函数的局部变量进行了引用
-
-
闭包的用途:可以定义一些作用域局限的持久化变量,这些变量可以用来做缓存或者计算的中间量等。
-
函数可以记住和访问当前所在的词法作用域(声明决定)函数是在当前词法作用域之外执行,就会产生闭包
- 声明和执行不在一个地方
// 简单的缓存工具
// 匿名函数创造了一个闭包
const cache = (function() {
const store = {};
return {
get(key) {
return store[key];
},
set(key, val) {
store[key] = val;
}
}
}());
console.log(cache) //{get: ƒ, set: ƒ}
cache.set('a', 1);
cache.get('a'); // 1
- 匿名函数创造了一个闭包,使得 store 对象 ,一直可以被引用,不会被回收。
- 闭包的弊端:持久化变量不会被正常释放,持续占用内存空间,很容易造成内存浪费,所以一般需要一些额外手动的清理机制。
记忆函数
- 记忆化(memoization)是一种构建函数的处理过程,能够记住上次计算结果的函数。在实现中,可以这样进行处理:当函数计算得到结果时,就将该结果按照参数存储起来。采取这种方式时,如果另外一个调用也使用相同的参数,我们则可以直接返回上次存储的结果而不是再计算一遍
- 显而易见,像这样避免既重复又复杂的计算可以显著提高性能。对于动画中的计算、搜索不经常变化的数据或任何耗时的数学计算来说,记忆化这种方式是十分有用的
/ 自记忆素数检测函数
function isPrime (value) {
// 创建缓存
if (!isPrime.answers) {
isPrime.answers = {};
}
// 检查缓存的值
if (isPrime.answers[value] !== undefined) {
return isPrime.answers[value];
}
// 0和1不是素数
var prime = value !== 0 && value !== 1;
// 检查是否为素数
for (var i = 2; i < value; i++) {
if (value % i === 0) {
prime = false;
break;
}
}
// 存储计算值
return isPrime.answers[value] = prime
}
isPrime函数是一个自记忆素数检测函数,每当它被调用时
- 首先,检查它的answers属性来确认是否已经有自记忆的缓存,如果没有,创建一个
- 接下来,检查参数之前是否已经被缓存过,如果在缓存中找到该值,直接返回缓存的结果
- 如果参数是一个全新的值,进行正常的素数检测
- 最后,存储并返回计算值
优点
- 由于函数调用时会寻找之前调用所得到的值,所以用户最终会乐于看到所获得的性能收益
- 它不需要执行任何特殊请求,也不需要做任何额外初始化,就能顺利进行工作
缺点
- 任何类型的缓存都必然会为性能牺牲内存
- 缓存逻辑不应该和业务逻辑混合,一个方法只需要把一件事情做好
- 对记忆函数很难做负载测试或估算算法复杂度,因为结果依赖于函数之前的输入
偏函数
参数分批传入,先固定一部分参数,返回一个函数,再传入剩下的一部分
- 偏函数属于函数式编程的一部分,使用偏函数可以通过有效地“冻结”那些预先确定的参数,来缓存函数参数,然后在运行时,当获得需要的剩余参数后,可以将他们解冻,传递到最终的参数中,从而使用最终确定的所有参数去调用函数
- 简而言之,偏函数是 JS 函数柯里化运算的一种特定应用场景,就是把一个函数的某些参数先固化,也就是设置默认值,返回一个新的函数,在新函数中继续接收剩余参数,这样调用这个新函数会更简单
将一些函数组合封装到一个函数中,调用时可以按顺序实现全部功能
function toUpperCase(str) {
return str.toUpperCase() // 将字符串变成大写
}
function add(str) {
return str + '!!!' // 将字符串拼接
}
function split(str) {
return str.split('') // 将字符串拆分为数组
}
function reverse(arr) {
return arr.reverse() // 将数组逆序
}
function join(arr) {
return arr.join('-') // 将数组按'-'拼接成字符串
}
function compose() {
const args = Array.prototype.slice.call(arguments) // 类数组转换为数组
const len = args.length - 1 // 最后一个参数的索引
return function(x) {
let result = args[len](x) // 执行最后一个函数的结果
while(len--) {
result = args[len](result) // 执行每个函数的结果
}
return result
}
}
const f = compose(add, join, reverse, split, toUpperCase)
console.log( f('cba') ) // A-B-C!!!
在组合函数 compose 中,依次执行 toUpperCase、split、reverse、join、add 实现全部功能
当然有更优雅的写法,通过数组自带的方法实现
const compose2 = (...args) => x => args.reduceRight((result, cb) => cb(res), x)
const f = compose2(add, join, reverse, split, toUpperCase)
console.log( f('cba') ) // A-B-C!!!