对函数式编程的一些理解,怎么进行学习?
PS:我本人是比较喜欢通过代码来进行对知识的学习的!所以我会比较喜欢通过记录代码来唤起我当时的记忆!
老是听说函数式编程函数式编程?那什么是函数式编程?当面试官问函数式编程?怎么回答能提高面评让面试官满意?
是什么?
函数式编程是一种编程范式,它将计算视为函数执行,并避免了使用共享状态和可变数据。
简言之,它是对过程的一种抽象封装,我们无需关心函数内部的的实现,也不用担心任何副作用。
函数式编程强调以下几个特点:
- 函数是第一等公民:函数可以像变量一样作为参数传递、返回值返回,可以被赋值给变量或属性,也可以被存储在数据结构中。
- 纯函数:函数的执行结果只依赖于它的输入参数,而且没有副作用,具有不可变性,这样的函数被称为纯函数(不改变输入参数和全局变量)。纯函数可以更容易地理解和测试,也更容易被复用。
- 组合:函数式编程中的函数可以像积木一样组合起来,形成更复杂的函数。这种组合方式可以使得代码更加简洁、清晰,并且更容易进行复用和测试。
单纯看特点切实有点懵逼,这都啥啥啥?
下面我解释一下
纯函数
纯函数是指输入相同的参数,总是返回相同的结果,并且不会有副作用。即纯函数的执行过程不依赖于程序的上下文环境,也不会修改程序的上下文环境,只会通过输入参数计算出一个确定的输出结果。简单来说,纯函数不会对外部变量进行修改,且满足输入参数相同,输出结果也必须相同的特点。
function add(x, y) {
return x + y;
}
// 非纯函数
let z = 0;
function addImpure(x, y) {
z++; // 有副作用
return x + y + z;
}
纯函数是特殊的无副作用函数,但不等同
下面就是一个符合副作用函数但不符合纯函数的例子:
function getCurrentTime() {
return new Date();
}
虽然它满足不依赖且不修改程序的上下文环境,但是它每次调用的返回值是不同的,因此不满足纯函数的特点。
那问题来了,什么又是不依赖且不修改程序的上下文环境?
通常指函数不依赖于外部变量或对象,并且不会修改外部变量或对象的值。这种函数可以被认为是更加纯粹的函数式编程。
如
function add(a, b) {
return a + b;
}
function calculateSum(numbers) {
let sum = 0;
for (let i = 0; i < numbers.length; i++) sum = add(sum, numbers[i]);
return sum;
}
const numbers = [1, 2, 3, 4, 5];
console.log(calculateSum(numbers)); // 输出 15
所有函数都只依赖于传入的参数,而不依赖于任何函数外的状态,因此它不依赖于上下文。虽然 numbers
是一个引用对象,但是它在函数调用时作为参数传入,而不是在函数内部定义或者修改,因此函数本身不会改变 numbers
,也就不会影响到函数外的环境。
纯函数可以优化缓存、延迟计算等技术,提高程序的性能。 后面再举例子说明。
函数式一等公民
重点来了:函数可以像变量一样作为参数传递、返回值返回,可以被赋值给变量或属性
这意味着函数被视为一种数据类型,可以像数字、字符串、对象等其他数据类型一样被处理。这种特性使得函数可以被组合、抽象和重用,从而提高代码的可读性和可维护性。
声明式 命令式
- 命令式:注重过程,我们通过编写一条又一条指令去让计算机执行一些动作,这其中一般都会涉及到很多繁杂的细节。命令式代码中频繁使用语句,来完成某个行为。比如 for、if、switch、throw 等这些语句。
- 声明式:注重结果,我们通过写表达式的方式来声明我们想干什么,而不是通过一步一步的指示。表达式通常是某些函数调用的复合、一些值和操作符,用来计算出结果值。
//命令式
var A = [];
for(var i = 0; i < B.length; i++){
A.push(B[i].name)
}
//声明式
var A = B.map(c => c.name);
从上面的例子中,我们可以看到声明式的写法是一个表达式,无需关心如何进行计数器迭代,返回的数组如何收集,它指明的是做什么,而不是怎么做。
函数式编程的一个明显的好处就是这种声明式的代码,对于无副作用的纯函数,我们完全可以不考虑函数内部是如何实现的,专注于编写业务代码。
高阶函数
函数式编程倾向于复用一组通用的函数功能来处理数据,它通过使用高阶函数来实现。高阶函数指的是一个函数以函数为参数,或以函数为返回值,或者既以函数为参数又以函数为返回值。
高阶函数经常用于:
- 抽象或隔离行为、作用,异步控制流程作为回调函数,promises,monads等
- 创建可以泛用于各种数据类型的功能
- 部分应用于函数参数(偏函数应用)或创建一个柯里化的函数,用于复用或函数复合。
- 接受一个函数列表并返回一些由这个列表中的函数组成的复合函数。
闭包
闭包 - JavaScript | MDN (mozilla.org)
对于闭包的定义: 闭包是指有权访问另外一个函数作用域中的变量的函数
注:在局部作用域访问全局作用域不算闭包原理
闭包延长了生命域周期,且外部是无法对闭包里面缓存的值进行直接访问的。
通俗来讲闭包也就是一个函数返回一个可自成作用域的引用对象,可以是块级作用域,也可以是函数作用域。(所以闭包返回值不一定是个函数)
举个简单的栗子:
function fn() {
let ins = 0;
return function(){
console.log(ins++);
}
}
const f = fn();
for(let i = 0; i < 5; i++) f();
// 0 1 2 3 4
在上面的栗子中,fn返回一个函数,f = fn(),此时f也是一个函数,如下:
所以!此时执行完fn(),此时函数内部还存在作用域,未执行完毕,且引用了外部变量ins,则包括ins和该函数都不会被垃圾回收机制回收。每次调用f(),会让ins++;注:我们无法在外部直接访问到这个ins变量,我们只能通过fn暴露出来的函数对ins进行间接操作
那怎么让它被垃圾回收掉呢?很简单:f = null
即可。此时虽然存在作用域,但是该函数不被引用了,后续不会再执行。
那闭包有什么作用呢?可以定义一些作用域局限的持久化变量,这些变量可以用来做缓存或者计算的中间量等。
如下创建一个缓存:
// 简单的缓存工具
// 匿名函数创造了一个闭包
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 对象 ,一直可以被引用,不会被回收。
闭包的弊端:持久化变量不会被正常释放,持续占用内存空间,很容易造成内存浪费,所以一般需要一些额外手动的清理机制。
一直听别人说什么闭包会造成内存泄漏,瞎扯淡,错误使用闭包才会。。
闭包有点难解释啊
来道闭包简单题压压惊
简单实现函数柯里化
const sum = (a,b,c,d) => a+b+c+d;
// 需要实现以下功能
console.log(currying(sum,1,2,3,4))
console.log(currying(sum,1)(2,3)(4))
console.log(currying(sum)(1,2,3,4))
/**
*
* @param {*} fn
* @param {...any} args
*/
function currying(fn, ...args) {
return args.length >= fn.length ? fn(...args) : currying.bind(null, fn, ...args)
}
进阶
认识这些数组api:
下面这些数组api传入一个函数作为参数,且除了引用对象属性的修改,对于基本数据类型变量的修改不会改变原数组状态。
const numbers = [1, 2, 3, 4, 5];
// 使用函数式编程计算数组的和
const sum = numbers.reduce((acc, num) => acc + num, 0);
// 使用函数式编程过滤数组中的偶数
const evenNumbers = numbers.filter(num => num % 2 === 0);
// 使用函数式编程将数组中的每个数都加1
const incrementedNumbers = numbers.map(num => num + 1);
map,filter比较简单,主要来reduce(fn, value):
Array.prototype.reduce() - JavaScript | MDN (mozilla.org)
Array.prototype.reduce(fn, value),接收俩个参数:
fn:必选,fn也有四个参数(pre,next,index可选,arr可选),reduce会遍历数组并执行fn(),pre代表前面函数返回的值,next代表数组中正在处理的元素
value:可选,如果有,则代表首个值(可以理解为往数组首塞入value,从该值开始遍历),如果没有则相当于pre的首个值
Array.prototype.reduceRight则是从右到左遍历。
// Array.prototype.reduce()简单实现
/**
* Array.prototype.reduce()
* @param {*} fn 回调函数
* @param {*} initialValue 起始值
*/
Array.prototype._reduce = function(fn, initialValue) {
let index = 0;
if(initialValue === undefined) {
initialValue = this[0];
index = 1;
}
while(index < this.length) {
initialValue = fn.call(this, initialValue, this[index], index++, this);
}
return initialValue;
}
组合与管道
组合和管道都是函数式编程中常见的概念,它们都是将多个函数组合在一起,以实现复杂的功能。
在函数式编程中,组合和管道是两种常见的技术,它们可以帮助我们更方便地处理数据和逻辑。具体来说,组合可以帮助我们将多个函数组合成一个新的函数,从而简化代码;管道可以帮助我们将多个函数串起来形成一个数据处理的管道,从而方便地处理数据。
常用的组合管道函数有compose
和pipe
,其中compose
是从右到左依次执行函数,pipe
是从左到右依次执行函数。具体实现如下:
举个简单的例子:
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x);
const pipe = (...fns) => (x) => fns.reduce((acc, fn) => fn(acc), x);
const add = (x) => x + 2;
const multiply = (x) => x * 3;
const subtract = (x) => x - 4;
const combinedFunctions = compose(subtract, multiply, add);
const pipelineFunctions = pipe(subtract, multiply, add);
console.log(combinedFunctions(5)); // 17
console.log(pipelineFunctions(5)); // 5
在上面的代码中,我们定义了三个函数add、multiply和subtract。组合函数和管道函数compose和pipe。
- 对于组合函数,它从右到左执行:(5+2)*3-4 = 17
- 对于管道函数,它从左到右执行:(5-4)*3+2 = 5
复杂一点的:
// 定义一些常用的纯函数
const add = x => y => x + y;
const multiply = x => y => x * y;
const square = x => x * x;
// 定义一个组合函数
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x);
// 定义一个管道函数
const pipe = (...fns) => x => fns.reduce((acc, fn) => fn(acc), x);
// 使用组合和管道组合一些函数
const calculate = compose(
add(10),
multiply(2),
square
);
const calculate2 = pipe(
square,
multiply(2),
add(10)
);
// 调用 calculate 和 calculate2 函数
console.log(calculate(2)); // 输出 18
console.log(calculate2(2)); // 输出 18
在上面的例子中,我们定义了一些常用的纯函数,然后使用组合和管道将它们组合起来形成一个新的函数。
组合函数
- 组合函数compose执行是从右到左的
- 常见的组合函数有
reduceRight
管道函数
- 而管道函数pipe,执行顺序是从左到右执行的
- 常用的管道函数有
map
、filter
和reduce
等,它们通常用于数组或对象的数据处理。
组合函数与管道函数的意义在于:可以把很多小函数组合起来完成更复杂的逻辑
来道经典面试题试试:
实现一个compose函数
组合多个函数,从右到左,比如:compose(f, g, h) 最终得到这个结果 (...args) => f(g(...args))
题目描述:
// 用法如下:
function fn1(x) {
return x + 1;
}
function fn2(x) {
return x + 2;
}
function fn3(x) {
return x + 3;
}
function fn4(x) {
return x + 4;
}
const a = compose(fn1, fn2, fn3, fn4);
console.log(a(1)); // 1+4+3+2+1=11
实现:
const compose = (...fns) => (x) => fns.reduceRight((acc, fn) => fn(acc), x); // 简单、跟前面一样
// 如果不使用reduceRight呢?用管道函数实现?考虑数组为空呢?
// 一样的,reverse倒转一下数组就得
function compose(...fns) {
if(!fns.length) return v => v;
return x => fns.reverse().reduce((acc, fn) => fn(acc), x);
}
优点
- 更好的管理状态:因为它的宗旨是无状态,或者说更少的状态,能最大化的减少这些未知、优化代码、减少出错情况
- 更简单的复用:固定输入->固定输出,没有其他外部变量影响,并且无副作用。这样代码复用时,完全不需要考虑它的内部实现和外部影响
- 更优雅的组合:往大的说,网页是由各个组件组成的。往小的说,一个函数也可能是由多个小函数组成的。更强的复用性,带来更强大的组合性
- 隐性好处。减少代码量,提高维护性
缺点
- 性能:函数式编程相对于指令式编程,性能绝对是一个短板,因为它往往会对一个方法进行过度包装,从而产生上下文切换的性能开销
- 资源占用:在 JS 中为了实现对象状态的不可变,往往会创建新的对象,因此,它对垃圾回收所产生的压力远远超过其他编程方式
- 递归陷阱:在函数式编程中,为了实现迭代,通常会采用递归操作
综上,函数式编程是一种强调函数作为程序基本构建块的编程范式,它具有副作用少、易于推理、易于并发编程、可复用性高、易于测试等优点,但也存在难以理解、性能问题、难以在某些领域使用、缺少直观性等缺点。
怎么学习?
分为俩种题型来进行学习,一种是算法类的题目(默认有算法基础,不会讲算法如何实现,只会讲典型例题用函数式编程思维解题),一种是场景类的题目(红绿灯)
算法类题目
先来俩道题:
算出1~50的总和,和求数组各个数之和的平均数
// 传统求和
let sum = 0;
for(let i = 1; i <= 50; i++) {
sum += i;
}
// 函数式编程的方式
// 思路:先生成一个 [1, ... 50] 的数组,然后通过数组的api进行遍历并相加
Array.from({length: 50}, (_, i) => i + 1).reduce((pre, next)=> pre + next, 0);
// 求平均数
function average(nums) {
const sum = nums.reduce((acc, cur) => acc + cur, 0);
return nums.length > 0 ? sum / nums.length : 0;
}
求深度遍历,直接看题:
输出数组中对象所有的name属性
const treeData = [
{
name: "root",
children: [
{ name: "src", children: [{ name: "index.html" }] },
{ name: "public", children: [] },
],
},
];
const RecursiveTree = (data) => {
return data.reduce((pre, acc) => {
acc.name && pre.push(acc.name);
acc.children && pre.push(...RecursiveTree(acc.children));
return pre;
}, [])
}
console.log(RecursiveTree(treeData)); // [ 'root', 'src', 'index.html', 'public' ]
当你把熟悉函数式编程写进简历时,手写的时候,面试官就会通过种种因素判断给面评,算法类的题目运用了函数式编程思维(也就是通过调用这些无副作用函数得到结果)也可以让面试官眼前一亮。
场景类题目:
学习这方面的知识首先我们要多注意性能方面的优化,规范编写代码。要求我们尽量不直接操作dom(dom元素属性多),尽量通过css类名切换样式(如果可以的话)。
举个栗子:
对于点击按钮让元素显示或者消失
<style>
.none {display: none;}
</style>
<div id="box"></div>
<button id="btn">按钮</button>
<script>
btn.onclick = function() {
// box.style.display = 'none'; // 不要这样子
box.className = 'none'
}
</script>
函数式编程最重要的点就是抽象分装函数。我们从红绿灯案例来学习:
思路:通过flex来实现布局,主要是灯的变化如何实现?
给ul绑定class,来实现样式切换
<style>
#traffic.stop li:nth-child(1) {background-color: red;}
#traffic.wait li:nth-child(2) {background-color: yellow;}
#traffic.pass li:nth-child(3) {background-color: green;}
</style>
<ul id="traffic">
<li></li>
<li></li>
<li></li>
</ul>
那定时器怎么写呢?如何通过函数式编程进行抽象并封装?
抽象的原则是什么?一个函数只负责某一模块,互不干扰。且不会对外部状态造成改变。
首先,我们应该考虑要实现什么函数?
抽象一个setEl,用来为dom元素设置classname
抽象一个trafficStatePoll,用来开启定时功能(通过setTimeout并递归,为什么不能用setInterval?红绿黄灯等待时间不同)
// setEl 和 trafficStatePoll 均为高阶函数,返回一个函数
const setState = setEl(traffic); // 设置dom元素(通过闭包缓存该dom元素)
const trafficStatePoll = poll( // 高阶函数入口,等需要计数时再调用
setState.bind(null, 'stop', 3000), // 不直接调用,把函数当成参数,该函数的参数分别是类名和延迟时间
setState.bind(null, 'wait', 500),
setState.bind(null, 'pass', 2000),
);
// 运行,开始计时
trafficStatePoll();
完整代码如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
#traffic {
display: flex;
flex-direction: column;
}
#traffic li {
list-style: none;
width: 50px;
height: 50px;
border-radius: 50%;
background-color: #000;
margin: 10px;
}
#traffic.stop li:nth-child(1) {
background-color: red;
}
#traffic.wait li:nth-child(2) {
background-color: yellow;
}
#traffic.pass li:nth-child(3) {
background-color: green;
}
</style>
</head>
<body>
<ul id="traffic">
<li></li>
<li></li>
<li></li>
</ul>
<script>
function wait(delay) { // 等待时间
return new Promise(resolve => setTimeout(resolve, delay));
}
function setEl(el) {
return async function(className, delay) {
el.className = className;
await wait(delay);
}
}
function poll(...args) {
let index = 0;
const n = args.length;
return async function fn() {
await args[index++ % n]();
fn();
}
}
const setState = setEl(traffic);
const trafficStatePoll = poll(
setState.bind(null, 'stop', 3000),
setState.bind(null, 'wait', 500),
setState.bind(null, 'pass', 2000),
);
// 运行
trafficStatePoll();
</script>
</body>
</html>
总结
学到这里后,你就可以高高兴兴的在你的简历上写:熟悉函数式编程。
当被面试官问道,什么是函数式编程?
如果理解这些的话,你就可以骄傲地答 :函数式编程是一种编程范式,它将计算过程看作是一系列函数的组合,并强调函数的纯粹性(纯函数)。。。
而不用担心面试官往深里问,诶~侃侃而谈,嘴里吐出一堆专业名词(可以自己编的很高级,反正概念都懂)
还有,前端没死,别瞎增加焦虑。一匹真正的好马,即使在鞭子的影子下,也能飞奔。加油加油!
转载自:https://juejin.cn/post/7207417288705589303