likes
comments
collection
share

对函数式编程的一些理解,怎么进行学习?

作者站长头像
站长
· 阅读数 11

PS:我本人是比较喜欢通过代码来进行对知识的学习的!所以我会比较喜欢通过记录代码来唤起我当时的记忆! 对函数式编程的一些理解,怎么进行学习?

老是听说函数式编程函数式编程?那什么是函数式编程?当面试官问函数式编程?怎么回答能提高面评让面试官满意?

是什么?

函数式编程是一种编程范式,它将计算视为函数执行,并避免了使用共享状态和可变数据。

简言之,它是对过程的一种抽象封装,我们无需关心函数内部的的实现,也不用担心任何副作用。

函数式编程强调以下几个特点:

  1. 函数是第一等公民:函数可以像变量一样作为参数传递、返回值返回,可以被赋值给变量或属性,也可以被存储在数据结构中。
  2. 纯函数:函数的执行结果只依赖于它的输入参数,而且没有副作用,具有不可变性,这样的函数被称为纯函数(不改变输入参数和全局变量)。纯函数可以更容易地理解和测试,也更容易被复用。
  3. 组合:函数式编程中的函数可以像积木一样组合起来,形成更复杂的函数。这种组合方式可以使得代码更加简洁、清晰,并且更容易进行复用和测试。

单纯看特点切实有点懵逼,这都啥啥啥? 对函数式编程的一些理解,怎么进行学习? 下面我解释一下

纯函数

纯函数是指输入相同的参数,总是返回相同的结果,并且不会有副作用。即纯函数的执行过程不依赖于程序的上下文环境,也不会修改程序的上下文环境,只会通过输入参数计算出一个确定的输出结果。简单来说,纯函数不会对外部变量进行修改,且满足输入参数相同,输出结果也必须相同的特点。

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;
}

组合与管道

组合和管道都是函数式编程中常见的概念,它们都是将多个函数组合在一起,以实现复杂的功能。

在函数式编程中,组合和管道是两种常见的技术,它们可以帮助我们更方便地处理数据和逻辑。具体来说,组合可以帮助我们将多个函数组合成一个新的函数,从而简化代码;管道可以帮助我们将多个函数串起来形成一个数据处理的管道,从而方便地处理数据。

常用的组合管道函数有composepipe其中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,执行顺序是从左到右执行的
  • 常用的管道函数有mapfilterreduce等,它们通常用于数组或对象的数据处理。

组合函数与管道函数的意义在于:可以把很多小函数组合起来完成更复杂的逻辑

来道经典面试题试试:

实现一个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>

总结

学到这里后,你就可以高高兴兴的在你的简历上写:熟悉函数式编程。

当被面试官问道,什么是函数式编程?

如果理解这些的话,你就可以骄傲地答 :函数式编程是一种编程范式,它将计算过程看作是一系列函数的组合,并强调函数的纯粹性(纯函数)。。。

而不用担心面试官往深里问,诶~侃侃而谈,嘴里吐出一堆专业名词(可以自己编的很高级,反正概念都懂) 对函数式编程的一些理解,怎么进行学习?

还有,前端没死,别瞎增加焦虑。一匹真正的好马,即使在鞭子的影子下,也能飞奔。加油加油!