前端面试复习7---函数式编程

293 阅读13分钟

函数式编程

  • 概念:函数式编程通过使用函数来将值转换成抽象单元,接着用于构建软件系统。
  • 软件系统本质就是展示数据,函数式编程就是把一个数据通过一些方法,一步一步变成另外一些数据再展示在系统上。
  • 具体是通过那些方法,就是看业务的需求了。

一、函数式编程和传统编程思路上的区别是什么?

  • 函数式编程就是把一个数据通过一些方法,一步一步变成另外一些数。
  • 函数式编程它更关注的是“谓词”,例如:求和函数,过滤函数,它关注的是“求和”,“过滤”。

函数式编程和命令式编程的区别

  • 函数式编程和命令式编程都是编程规范。
  • 命令式编程是面向过程编程的思想,强调的是步骤和程序的控制结构。这意味着你需要明确地告诉计算机每个步骤应该执行什么任务,以及这些步骤应该如何顺序执行。
  • 函数式编程即不是面向过程也不是面向对象的编程思想,函数式编程的核心思想是,任何程序都可以被视为一系列函数的组合,这些函数通过接收和产生输出互相联系。此外,函数式编程还强调纯函数的运用,即不产生副作用并且在相同的输入下总是产生相同的输出的函数。

写一个函数传入 [0, 1, 2, 3, 4, 5, 6] 输出 [2, 4, 6, 8, 10, 12]

命令式编程示例:

const modifyArrCmd = (arr) => {
  const result = []
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] !== 0) {
      result.push(arr[i] * 2)
    }
  }
  return result
}
const list = [0, 1, 2, 3, 4, 5, 6]
console.log(modifyArrCmd(list))

函数编程示例

  1. 第一步:数组要经历一个筛选函数,把 0 筛除掉;
  2. 第二步:数组要经历一个乘法函数,每一个数据*2。
const modifyArrFP = (arr) => arr.filter(Boolean).map((item) => item * 2)
const list = [0, 1, 2, 3, 4, 5, 6]
console.log(modifyArrFP(list))

函数式编程和面向对象编程的区别

  • 函数式强调的是,一切皆是函数。
  • 面向对象强调的是,一切皆是对象。
  • 面向对象强调的是主语(对象),函数式强调的是谓词。

例如我要做我要做一道菜-回锅肉

  • 命令式编程:
    • 从养一头猪开始,养大、喂养、宰杀、切肉、炸、炒、上桌。
  • 面向对象编程:
    • 谁来做,哪个实例对象来做出这道菜。
  • 函数式编程:
    • 喂养的 function; 宰杀的 function; 制作的 function,将这 function 组合起来做出这道菜。

二、函数式的特点

函数式编程具有以下几个特点:

  1. 纯函数性质(Pure Function):函数式编程强调使用纯函数,即函数的输出仅依赖于输入参数,没有任何副作用。纯函数不修改可变状态,也不对外部环境产生可观察的影响。这种特性使得纯函数具有可重用、可测试和易推导的优势。
  2. 不可变数据(Immutable Data):函数式编程倾向于使用不可变数据,即数据一旦创建就不能被修改。通过避免直接对数据进行修改,函数式编程可以减少复杂性、提高可读性和可维护性。不可变数据也为并发和并行编程提供了更好的支持。
  3. 函数作为一等公民(First-class Functions):函数式编程将函数视为一等公民,即函数可以作为变量存储、作为参数传递给其他函数,也可以作为函数的返回值。这使得函数的抽象和组合成为可能,可以更灵活地构建和组合功能。
  4. 高阶函数(Higher-order Functions):函数式编程支持高阶函数,即可以接受一个或多个函数作为参数,或者返回一个函数作为结果的函数。高阶函数可以用于构建抽象和复用代码,可以将函数作为参数传递、组合和构造新的函数。
  5. 函数组合(Function Composition):函数式编程鼓励函数的组合,即将多个函数按照特定的方式组合在一起,形成新的函数。函数组合可以简化代码,提高可读性,同时也支持将小的函数组合成更大的、复杂的功能。
  6. 延迟求值(Lazy Evaluation):函数式编程支持延迟求值,在需要的时候才计算表达式的值。这种延迟求值的特性可以提高性能和资源的利用率,同时也支持按需计算。
  7. 声明式编程(Declarative Programming):函数式编程更倾向于使用声明式的方式来描述程序的逻辑,即关注“做什么”而不是“如何做”。通过函数的组合和转换,可以以一种更抽象的方式描述程序的意图和逻辑。

这些特点使得函数式编程具有一些独特的优势,例如代码的可读性、可维护性、可测试性和易于推导行为。函数式编程适用于处理数据转换、并发、并行、函数组合等领域,并且在现代编程中越来越受到重视。

纯函数

纯函数具有以下几个主要特点:

  1. 输入决定输出/幂等(无论调用多少次,结果是相同的):纯函数的输出完全取决于输入参数,即同样的输入将始终产生相同的输出。纯函数没有隐藏的状态依赖,不会受到外部状态的影响,也不会改变外部状态。
  2. 无副作用:纯函数不存在副作用,即不会对外部环境产生可观察的影响。它们不修改传入的参数,并且不会对共享的变量或状态进行修改。纯函数的唯一目的是根据输入计算输出。
  3. 可重用性:由于纯函数的输出仅取决于输入,没有任何隐藏的依赖关系,因此它们具有高度的可重用性。纯函数可以在不同的上下文中被多次调用,而不会导致意外的行为。
  4. 可测试性/无状态/无依赖:由于纯函数的行为完全可预测,并且不依赖于外部状态,因此它们很容易进行单元测试。通过提供不同的输入,可以轻松检查纯函数的输出是否符合预期。
  5. 缓存优化:由于纯函数的输出只取决于输入,函数的结果可以被缓存,以避免重复的计算。这种缓存优化在函数的运行时效率方面可以提供更好的性能。
  6. 引用透明性:纯函数是引用透明的,即可以用函数的输出来替换函数的调用,而不会影响程序的行为。这种特性使得代码更易于理解和推导。
  7. 并行和并发:由于纯函数不依赖于外部状态,且输入输出之间没有依赖关系,因此它们天然地适合并行和并发执行。多个纯函数可以在不同的线程或进程中独立地执行,而不会发生竞态条件或死锁等问题。

纯函数是函数式编程的核心概念之一,它们的特点使得代码更具可读性、可维护性和可测试性,同时也有助于构建可靠的、高效的软件系统。在函数式编程中,鼓励尽可能使用纯函数,并通过组合和转换这些纯函数来构建复杂的功能和逻辑。

围绕着纯函数,取代面向过程式的代码,往往能够有以下收益:

  • 可读性,因为“一切都是函数”,通过函数的合理命名,函数原子的拆分,我们能够一眼看出来程序在做什么,以及做的过程;
  • 复用性,因为“一切都是函数”,函数本身具有天然的复用能力;
  • 维护性,纯函数和幂等性保证同样的输入就有同样的输出,在维护或者调试代码时,能够更加专注,减少因为共享带来的潜在问题。

栗子

副作用很大

const minusCount = () => {
  window.count--
}

没那么纯,但是没有副作用

const minusCount = (global) => {
  global.count--
}

有副作用时,tree shaking 做性能优化时摇不掉 a,因为 window.a-- 引用过

export const a = window.a--;
import {a};

Vue 中的 methods 一般都不是纯函数,因为依赖了 this

methods: {
  getDataList(query) {
    get({ query })
    .then(res => {
      this.resData = res.data;
    })
  },
}

高阶函数(闭包的两个表现)

  • 函数可以作为返回值,给函数一些组合、缓存的能力
  • 函数可以作为参数传递,给函数提供了一个包装的能力

三、多人协作的开发过程中,如何去考虑副作用?

函数的编写

在我们编写一个函数时,比如,一个典型的接口请求,我们怎么写?

  • 封装成纯函数,通过传入参数,然后返回结果的形式
methods: {
  getDataList(query) {
    get({ query })
    .then(res => {
      this.resData = res.data;
    })
  },
}

改成

methods: {
  getDataList(query) {
    get({ query })
    .then(res => {
      return res.data;
    })
  },
}

组件的封装时,我们应该考虑哪些问题和维度?

  • 副作用
    • css 隔离, 同时考虑 css 类重名问题。
  • 组件角色定位
    • 例如是纯 UI 的组件,把搜索、拉下框的信息,放在外面。
  • 复用性
    • props 越多复用性越强,但组件越难维护,在业务需求和复用性之间权衡。考虑把一个组件直接拷贝到第二个地方用方不方便

架构的设计

我们思考一下:模块化、微前端,究竟是解决什么问题?副作用究竟是什么?

解决熵增问题

  • 熵增:代表系统的无序程度增加。
  • 熵:代表着混乱程度。

四、函数的柯里化

柯里化

在计算机科学中,柯里化(currying),又译为卡瑞化或加里化,是把接受多个参数的函数变换成接受一个单一参数(最初函数的第一个参数)的函数,并且返回接受余下的参数而且返回结果的新函数的技术。这个技术由克里斯托弗·斯特雷奇以逻辑学家哈斯凯尔·加里命名的。

栗子

// 原始的非柯里化函数
function add(a, b, c) {
  return a + b + c
}

// 柯里化的函数
function curriedAdd(a) {
  return function (b) {
    return function (c) {
      return a + b + c
    }
  }
}

// 调用非柯里化函数
console.log(add(1, 2, 3)) // 输出 6

// 调用柯里化函数
console.log(curriedAdd(1)(2)(3)) // 输出 6

加强版栗子

const add = (num1) => {
  // 定义一个叫 nums 的数组,把所有的入参缓存起来(利用了闭包)
  const nums = [num1]

  const fn = (num2) => {
    nums.push(num2)
    return fn
  }

  // 改写 toString 方法,在进行逻辑判断的时候会触发 toString 方法
  fn.toString = function () {
    return nums.reduce((result, item) => result + item, 0)
  }

  return fn
}

console.log(add(1)(2)(3) == 6)

再把栗子炒一下

const add = (...num1) => {
  let nums = [...num1]

  const fn = (...num2) => {
    nums = [...nums, ...num2]
    if (nums.length === 3) {
      return nums.reduce((result, item) => result + item, 0)
    } else {
      return fn
    }
  }

  return fn
}

console.log(add(1)(2, 3) === 6)
console.log(add(1)(2)(3) === 6)

反柯里化

反柯里化(Uncurrying)是指将柯里化函数转换为接受多个参数的普通函数的过程。

在柯里化函数中,多个参数被分解为一系列单参数函数。而反柯里化则是相反的操作,将这些单参数函数重新合并为接受多个参数的函数。

通过反柯里化,我们可以将柯里化函数转换为普通函数,并使其更适合传统的多参数调用方式。这对于某些情况下使用柯里化函数不方便的场景,如使用其他需要多个参数的函数或算法时,特别有用。

下面是一个简单的示例来说明反柯里化的概念:

// 柯里化函数
function add(a) {
  return function (b) {
    return a + b
  }
}

// 反柯里化函数
function uncurry(fn) {
  return function (...args) {
    let result = fn
    for (let arg of args) {
      result = result(arg)
    }
    return result
  }
}

// 反柯里化后的函数
const uncurriedAdd = uncurry(add)

console.log(uncurriedAdd(2, 3)) // 输出:5

反柯里化是将柯里化函数转换为接受多个参数的普通函数的过程。它可以提供更传统的多参数调用方式,以适应某些情况下的需求。

五、函数式的应用

函数的缓存与闭包

  • 假如有一个计算密集型的函数
  • 我希望对这个函数进行包装,如何让这个函数如果之前计算过了我就不再计算了,直接返回值。
function add(a, b) {
  return a + b
}

function memorize(fn) {
  const map = {}

  return function (...args) {
    const key = args.join('_')
    if (map[key]) {
      return map[key]
    } else {
      return (map[key] = fn.apply(this || {}, args))
    }
  }
}

const madd = memorize(add)
console.log(madd(1, 2))
console.log(madd(1, 2))
console.log(madd(1, 2))

组合函数

示例

const compose =
  (...functions) =>
  (startNum) =>
    functions.reduce((result, item) => item(result), startNum)

function addOne(num) {
  return num + 1
}

function double(num) {
  return num * 2
}

function square(num) {
  return num ** 2
}

const composedFunction = compose(addOne, double, square)
// composedFunction --> (startNum) => functions.reduce((result, item) => item(result), startNum)
// result = startNum = 2
// result = item(2) --> addOne(2) --> 3
// result = item(3) --> double(3) --> 6
// result = item(6) --> square(6) --> 36

console.log(composedFunction(2)) // 输出 36,即 square(double(addOne(2))) -- > square(6)
  1. functions 参数包中的每个函数都会被调用,并且传入前面所有函数的结果作为参数。
  2. 初始值 startNum 会首先被传入第一个函数。
  3. 第一个函数的返回值会作为输入传给第二个函数,依此类推,直到所有的函数都执行完毕。
  4. 最后,返回所有函数执行完毕后的结果。

应用场景 - 设计模式

装饰器

  • 实现一个 wrappedFn 包装我的 fn,让 beforeFn 先执行
function fn() {
  console.log('This is fn running ...')
}

function beforeFn() {
  console.log('This is beforeFn running ...')
}

function wrappedBefore(fn, beforeFn) {
  return function (...args) {
    beforeFn()
    return fn.apply(this, args)
  }
}

const wrappedFn = wrappedBefore(fn, beforeFn)
wrappedFn()

改造一下 wrappedBeforefn 可以有多个前置函数

function fn() {
  console.log('This is fn running ...')
}

function beforeFn1() {
  console.log('This is beforeFn1 running ...')
}

function beforeFn2() {
  console.log('This is beforeFn2 running ...')
}

function beforeFn3() {
  console.log('This is beforeFn3 running ...')
}

const compose =
  (...functions) =>
  (params) =>
    functions.reduce((result, item) => item(result), params)

function wrappedBefore(fn, ...beforeFns) {
  return function (...args) {
    compose(...beforeFns)()
    return fn.apply(this, args)
  }
}

const wrappedFn = wrappedBefore(fn, beforeFn1, beforeFn2, beforeFn3)
wrappedFn()

我们再改造一下让 wrappedFn 可以传入参数,写一个更具体一线的例子

function fn(data) {
  console.log('我是被处理过后的 data', data)
}

function beforeFn1(data) {
  console.log('我是一个检验字符串的函数,如果数据中字符长度大于7,就截断它')
  if (data.str.length > 7) {
    data.str = data.str.slice(0, 7)
  }
  return data
}

function beforeFn2(data) {
  console.log('我是一个检验数字的函数,如果数据中数字大于10,就将它除2')
  if (data.num > 10) {
    data.num = data.num / 2
  }
  return data
}

function beforeFn3(data) {
  console.log('我是一个检验数组的函数,需要过滤数组中的0')
  data.arr = data.arr.filter(Boolean)
  return data
}

// 上面说到的组合函数
const compose =
  (...functions) =>
  (data) =>
    functions.reduce((result, item) => item(result), data)

function wrappedBefore(fn, ...beforeFns) {
  return function (data) { // wrappedFn(data)
    const result = compose(...beforeFns)(data) // 将 data 传进去给组合函数处理,result 是被处理后的 data
    return fn.call(this, result)
  }
}

const wrappedFn = wrappedBefore(fn, beforeFn1, beforeFn2, beforeFn3)
const data = {
  str: '我是一段字符串,我的长度最长只能是7',
  num: 100,
  arr: [0, 1, 2, 3, 4],
}
wrappedFn(data)

策略模式

写一个策略根据会员的等级计算商品的价格

const strategies = {
  S: (price) => price * 0.7,
  A: (price) => price * 0.8,
  B: (price) => price * 0.9,
}

const calculateDiscount = function (level, price) {
  return strategies[level](price) // 价格策略的函数,通过 return 返回
}

const discountedPrice = calculateDiscount('S', 1000)
console.log(discountedPrice)

六、写在最后

这个章节复习的内容比较抽象,主要是想表达函数编程的一个思想,我们活在金字塔底层前端业务猿用的函数式编程并不多 🤣,甚至纯函数用的都不多,可能在写一些公用工具函数的时候用得比较多,例如,日期、金额的格式化函数,防抖函数等。但是这并不妨碍我们学习它,它可能是我们从金字塔底层向上攀登的必要路径之一。