Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

高阶函数的大杂烩 (JS) #1

Open
09473ZH opened this issue Feb 20, 2024 · 0 comments
Open

高阶函数的大杂烩 (JS) #1

09473ZH opened this issue Feb 20, 2024 · 0 comments

Comments

@09473ZH
Copy link
Owner

09473ZH commented Feb 20, 2024

引言

高阶函数是函数式编程中的重要概念,指的是能够接受函数作为参数或返回一个函数的函数。通过使用高阶函数,我们能够更灵活地构建代码结构,使其更模块化、易于理解和维护。

我们可以编写一个高阶函数用于数组排序,通过传入不同的函数作为参数实现不同的排序规则。

高阶函数具备返回函数的能力,使得我们能够在需要时调用返回的函数。这种特性可用于实现函数的柯里化(Currying)和函数的延迟执行。柯里化使得一个多参数函数转化为一系列接受单个参数的函数,提供更灵活的调用和组合方式。延迟执行则有助于在需要时执行特定函数,从而实现更高效的代码。

从数据类型说起

JavaScript支持的基本数据类型有:String、Number、Boolean、Null、Undefined,在ES6中引入了Symbol。引用类型包括Object、Array、Function,以及两个特殊的类型RegExp和Date。

在 JavaScript 中,函数是一等公民(first-class citizens),函数被视为与其他数据类型相同的实体,可以被当作其他任何数据类型使用。这个“一等公民”的概念,其实是相对其他语言来说的,在其他语言中, 函数可能并没有这样的自由:

  • 可以赋值给变量
const myFunction = function() {
console.log("Hello, world!")
}
  • 可以作为参数传递给其他函数
function doSomething(callback) {
  // 执行一些操作
  callback();
}

doSomething(myFunction);
  • 可以返回一个函数
function createMultiplier(factor) {
  return function(number) {
    return number * factor
  }
}

const double = createMultiplier(2);
console.log(double(5)); // 输出 10

正是这些这些特征使得 JavaScript 支持函数式编程范式的特性。针对高阶函数部分,后面两个特征是讨论的重点。
Untitled

有关更多“第一公民”相关的讨论,可以参考知乎链接

数组的常用方法及其模拟实现

后端返回的数据和用户输入的数据通常与前端期望的数据格式不一致,而其中数组又是最常见的数据类型。在处理数组时,会用到很多数组的方法,包括 mapfilterreduce 等。

在做leetcode的时候,会刷到一些模拟实现这些方法的题,所以借助这些题,来体会一下高阶函数中“接受函数作为参数”的具体用法。本文给出的题解和标准库是有差异的(具体可以去 mdn 查看),并且也不保证是最佳解法。

map

首先,给出leetcode涉及到模拟实现的题:

编写一个函数,这个函数接收一个整数数组 arr 和一个映射函数  fn ,通过该映射函数返回一个新的数组。

返回数组的创建语句应为 returnedArray[i] = fn(arr[i], i) 。

请你在不使用内置方法 Array.map 的前提下解决这个问题。

官方题解

function map(arr, fn) {
  const returnedArray = [];

  for (let i = 0; i < arr.length; i++) {
    returnedArray[i] = fn(arr[i], i);
  }

  return returnedArray;
}

可以看到,上述函数接受一个数组arr和一个函数fn作为参数(“函数可以作为参数传递给其他函数”),开了一个新数组 returnedArray,将接受的数组进行遍历后,使用fn进行数据变换,再将变换后的数据存在新数组里面,最后,将新数组作为结果返回出来。由此,我们知道map的作用就是将数组中的每个元素一一按照一定的方法进行转换,并输出一个新的数组。它也是我认为最基础最常用的数组方法。通常,我会将map用于数据格式的转换中,在 React 也会用 map 进行 一些组件的循环渲染

一个常见的例子(数据格式转换):

const list = [
  { number: 1, name: { firstName: 'John', lastName: 'Doe' } },
  { number: 2, name: { firstName: 'Jane', lastName: 'Smith' } }
]

function optionList() {
  return list.map(option => ({
    value: option.number,
    ...option.name
  }))
}

const result = optionList()
console.log(result)

上述代码的输出为

[{
"value": 1,
"firstName": "John",
"lastName": "Doe"
},
{
"value": 2,
"firstName": "Jane",
"lastName": "Smith"
}]

观察可以发现,map的语法是:**map(callbackFn),**其中,callbackFn(数组中每个元素执行的函数) 接受三个参数,当前元素、索引和数组本身。在mdn中,提到了更多的注意事项,这里就不赘述了。

filter

先给出 leetcode 涉及到的模拟实现的题:

给定一个整数数组 arr 和一个过滤函数 fn,并返回一个过滤后的数组 filteredArr 。

fn 函数接受一个或两个参数:

  • arr[i] - arr 中的数字
  • i - arr[i] 的索引

filteredArr 应该只包含使表达式 fn(arr[i], i) 的值为 真值 的 arr 中的元素。真值 是指 Boolean(value) 返回参数为 true 的值。

请在不使用内置的 Array.filter 方法的情况下解决该问题。

官方题解

var filter = function(arr, fn) {
    const filteredArr = []
    for(let i=0;i<arr.length;i++){
        if(fn(arr[i], i)){
            filteredArr.push(arr[i])
        }
    }
    return filteredArr
};

上述函数接受了两个参数,arr (要过滤的数组)和 fn(实现过滤的函数),函数通过遍历输入数组,根据传递给 fn 函数的条件筛选出符合条件的元素,并将它们放入新数组 filteredArr 中,最后返回过滤后的新数组。由此,我们知道filter的作用就是将数组中的每个元素进行条件判断,并输出一个新的数组。filer较为常见的使用场景是实现本地搜索,或是一些数据筛选。在mdn中给出了一些注意事项和使用例子,下面给出一段作者写过的代码,场景是本地搜索:

filteredList = filteredList.filter(it => it.name.includes(name))

filteredList 是一个包含一组对象的数组,其中,每个对象都有一个 name 属性。过滤条件使用字符串的 includes 方法来检查对象的 name 属性是否包含给定的参数 name

reduce

reduce 的含义之一为 ”减少“(make smaller or less in amount, degree, or size),所以有些人称 reduce方法为“归约” 。同样地,先给出 leetcode 模拟实现的题:

给定一个整数数组 nums、一个 reducer 函数 fn 和一个初始值 init,返回通过依次对数组的每个元素执行 fn 函数得到的最终结果。

通过以下操作实现这个结果:val = fn(init, nums[0]),val = fn(val, nums[1]),val = fn(val, nums[2]),... 直到处理数组中的每个元素。然后返回 val 的最终值。

如果数组的长度为 0,则函数应返回 init

请你在不使用内置数组方法的 Array.reduce 前提下解决这个问题。

官方题解

var reduce = function(arr, fn, initialVal) {
  let accumulator = initialVal
  for (const element of arr) {
      accumulator = fn(accumulator, element)
  }
  return accumulator
}

观察到,函数通过循环遍历数组 nums,对每个元素调用回调函数 fn,并将调用后的结果累积到 init 中。最后,返回累积的结果,即对数组所有元素执行回调函数后的最终值。所以,reduce的作用是:“按顺序对数组的每个元素执行用户提供的“reducer”回调函数,并传入前一个元素计算的返回值。对数组的所有元素运行缩减程序的最终结果是单个值。”

一个常见的应用,是对数组求和。

const array1 = [1, 2, 3, 4]

// 1 + 2 + 3 + 4
const sumWithInitial = array1.reduce(
  (accumulator, currentValue) => accumulator + currentValue
)

console.log(sumWithInitial)
// 10

未设置初始值时,索引 0 处的数组元素将用作初始值,并且迭代从下一个元素(索引 1 )开始。

这样就体现了“归约”——将数组返回为单个值。reduce 在很多时候可以替代 map 和 filter!和它们不同的是,reduce有一个姊妹函数——reduceRight,这是reduce的逆序版本。通过与其他方法结合, reduce的使用场景有很多,就像瑞士军刀一样。但是具体使用的时候,要根据数据进行合理的选择。

Something else

除了上述说的三个方法之外,数组的方法还有很多。上述提到的三个方法其实都是和遍历相关的,js一些其他的遍历方法也经常用到:例如和map相似的for Each(不会返回新数组),someevery(可以提前退出,map filer reduce 则不能),和 filter相似的find(返回第一次匹配到的元素,而不是所有的匹配项),等等。除了遍历方法,还有排序,链接,切割等等。在使用这些方法的时候,除了要考虑使用场景、代码可读性外,还需要考虑是否会改变入参,不过这不是本文讨论的重点。(ps:关于函数与方法,可以简单看作方法是函数的特殊情况)

接下来,我们通过柯里化和函数的延迟执行(防抖、节流)来体会一下“高阶函数具备返回函数的能力”。

柯里化

柯里化是一个奇怪的术语,事实上,它是来源于逻辑学家哈斯凯尔·加里的名字(咖喱?),是一种处理函数中附有多个参数的方法,可以将一个含有多参数的函数转换为一个嵌套的一元函数。

下面是一个非常经典的例子:

// 非柯里化函数,有多个参数
function add(x, y, z) {
  return x + y + z
}

// 柯里化函数,利用闭包,返回函数,每个函数都只有一个参数
function curryAdd(x) {
  return function(y) {
    return function(z) {
      return x + y + z
    }
  }
}

const result1 = add(1, 2, 3) // 直接调用
const result2 = curryAdd(1)(2)(3) // 柯里化

console.log(result1) // 6
console.log(result2) // 6

柯里化的基础是 JS 的闭包机制,简单来说,就是写在函数里面的函数(内部函数),并且,它有三个可访问的作用域:自身声明的变量;全局变量;外部函数变量。所以在 function(z)中,除了自身的变量以外,还可以访问 function(y)和curryAdd(x)的变量。

那么,如何实现一个柯里化函数呢(手写柯里化)?

让我们来看看这个题

请实现一个 curry() 函数,它接受一个函数并返回一个柯里化函数。例子:

const join = (a, b, c) => {
   return `${a}_${b}_${c}`}

const curriedJoin = curry(join)

curriedJoin(1, 2, 3) // '1_2_3'

curriedJoin(1)(2, 3) // '1_2_3'

curriedJoin(1, 2)(3) // '1_2_3'

首先,我们知道一个柯里化函数会返回一个函数(所以能够分步调用),并且最后返回的函数应该是能访问到所有的变量的。 所以有:

const curry = (func, ...args) =>
  args.length >= func.length
    ? func(...args)
    : (...newArgs) => curry(func, ...args, ...newArgs)

核心思想是判断当前函数是否能接收到所有参数,如果有足够的参数,它会调用原始函数 func 以及提供的参数,如果没有,就会继续获取新参数。

还有一种常见的实现,涉及到applybind 方法:

function curry(func) {
  return function curried(...args) {  
    if (args.length >= func.length) {
      return func.apply(this, args)
    } else {
      return curried.bind(this, ...args)
    }
  }
}

思考一下,如果有占位符怎么办呢

首先,我们的最终目的是确保所有参数都被传递,并且占位符都被正确地替换。使用 map 方法遍历之前传入的参数数组 args ,对于每个参数 arg,如果 arg 等于之前定义的占位符 curry.placeholder,并且还有未使用的新参数( newArgs.length 大于 0),那么就用新参数数组中的第一个元素替换掉这个占位符。这样就完成了对占位符的替换操作。

const curry = func => {
  const curried = (...args) => {
    const complete = args.length >= func.length && !args.slice(0, func.length).includes(curry.placeholder)

    if (complete) {
      return func(...args)
    }

    return (...newArgs) => {
      const combinedArgs = args.map(arg => (arg === curry.placeholder && newArgs.length ? newArgs.shift() : arg))
      return curried(...combinedArgs, ...newArgs)
    }
  }

  return curried
}

curry.placeholder = Symbol()

说了这么多,为什么要把函数柯里化呢?简单来说,柯里化可以让函数实现分步调用,也就是允许我们部分应用一个函数,并在稍后提供剩余的参数。这使得参数可以被复用,也可以实现函数缓存。

一个例子:

function curryAdd(x) {
  const cache = {};

  return function(y) {
    if (cache[y]) {
      console.log(`Cache hit for ${x} + ${y}`)
      return cache[y];
    } else {
      const result = x + y
      cache[y] = result
      console.log(`Calculating ${x} + ${y}`)
      return result;
    }
  }
}

// 使用柯里化的函数
const add5 = curryAdd(5);

console.log(add5(3)) // 第一次计算,输出 "Calculating 5 + 3",返回 8
console.log(add5(3)) // 第二次调用相同参数,输出 "Cache hit for 5 + 3",直接返回缓存的结果 8
console.log(add5(7)) // 新的参数,输出 "Calculating 5 + 7",返回 12
console.log(add5(7)) // 再次调用相同参数,输出 "Cache hit for 5 + 7",直接返回缓存的结果 12

More to learn

节流与防抖

在实际开发中,通常会借助相关的库来实现节流和防抖的处理。出于学习的目的,我们来了解一下节流与防抖的实现思路是怎样的。

节流

节流是一种很常见的优化,它通过控制函数的执行频率,让它在一定时间内只执行一次。它常用于处理一些频繁触发的事件(滚动,输入,发送请求等)。举例来说,假设有一个按钮,每次点击按钮都会发送一个请求,用户频繁点击,短时间内重复请求太多次,会造成服务器压力也会影响性能。这时候,就可以使用节流。

所以,如果我们要实现一个节流,首先最重要的就是思考,这个函数最终是要什么时候执行?其次,我们如何控制这个函数的执行频率呢?

要回答这个问题,首先我们需要了解,延迟执行是怎么实现的。通常,我们会用JS的两个调度方法:**setTimeout 和 setInterval他们的区别是setTimeout** 用于在一定延迟后执行一次函数,而

setInterval 则是以一定的时间间隔循环执行函数。在这里,我们选择更为常用的**setTimeout。**

我们可以通过设置一个布尔类型的flag,用于判断函数是否要立即执行。如果不需要立即执行,使用**setTimeout** 进行延迟执行**。setTimeout** 需要指定一个delay,我们可以通过delay结合flag来控制函数的执行频率。

代码如下:


const throttle = (func, delay) => {
  let waiting = false
  let lastArgs = null // 如果在等待状态时再次触发新函数,就将参数存储在 lastArgs 中

  return (...args) => {
    if (!waiting) {
      func(...args)
      waiting = true
      setTimeout(() => {
        waiting = false
        if (lastArgs) {
          func(...lastArgs) //如果发现 lastArgs 不为 null,表示在等待期间有再次触发的调用,此时执行原始函数
          lastArgs = null
        }
      }, delay)
    } else {
      lastArgs = args
    }
  }
}

我们使用 lastArgs 确保在多次点击后,只能触发最后一次点击。观察这个函数,发现我们实际上是做了一个包装,将原函数func作为参数传递,并且会返回一个新函数,这个新函数通过一些控制逻辑(主要是利用setTimeout)对原函数进行延时执行的处理,最后达到节流的目的。

more to learn

防抖

和节流相似的性能优化还有防抖。防抖也是用于处理一些频发的事件,但是和节流不同的是,它会对频发事件作一个取消的处理。例如,我们在用百度进行搜索时,如果输入一个字就马上进行搜索,渲染的文本可能并非是我们想要的。一个期许是输入完毕后(某一个周期结束后)再进行搜索,期间所有的搜索请求都被取消。这样不仅对可以减轻服务端和客户端的负担,还能提升用户体验。

leetcode有一道这样的题目:

请你编写一个函数,接收参数为另一个函数和一个以毫秒为单位的时间 t ,并返回该函数的 函数防抖 后的结果。

函数防抖 方法是一个函数,它的执行被延迟了 t 毫秒,如果在这个时间窗口内再次调用它,它的执行将被取消。你编写的防抖函数也应该接收传递的参数。

function debounce(func, wait) {
  let cancel = null
  return (...args) => {
    clearTimeout(cancel)
    cancel = setTimeout(() => func(...args), wait)
  }
}

和节流一样,我们需要使用 setTimeout 进行函数的延时执行。不同的是,我们需要一个取消函数执行的逻辑。这里,我们通过 clearTimeout 来实现这个逻辑。当调用防抖函数时,会取消之前的定时器,确保在短时间内不会重复执行函数;接着设置一个新的定时器,在指定的等待时间后执行原函数func。这样就能确保只有在最后一次调用后,经过指定等待时间,才真正执行 func 函数。

记忆化

在谈论记忆化之前,我们先来看一个经典的例子—斐波那契数列。

斐波那契数列定义如下:F(0) = 0,F(1) = 1,对于每个整数 n > 1,F(n) = F(n-1) + F(n-2)。

斐波那契数列的一个通常解法是利用递归。然而,如果直接使用递归,会重复计算相同的问题。

function fibonacci(n) {
  if (n <= 1) {
    return n
  } else {
    return fibonacci(n - 1) + fibonacci(n - 2)
  }
}

例如计算 fibonacci(5)

                      fibonacci(5)
                   /               \
      fibonacci(4)                 fibonacci(3)
     /         \                   /           \
fibonacci(3)  fibonacci(2)  fibonacci(2)  fibonacci(1)
/           \
fibonacci(2)  fibonacci(1)

如上,我们会发现出现了很多冗余的计算,如果能够将计算结果存储起来用于后续计算之前的检查,如果已经存在了就直接返回,那效率是不是就会高很多呢?这种思想就是“记忆”。它会让函数避免重复计算之前已经被处理的输入。

让我们看一个leetcode的题:

请你编写一个函数,它接收另一个函数作为输入,并返回该函数的 记忆化 后的结果。

记忆函数 是一个对于相同的输入永远不会被调用两次的函数。相反,它将返回一个缓存值。

你可以假设有 3 个可能的输入函数:sum 、fib 和 factorial 。

  • sum 接收两个整型参数 a 和 b ,并返回 a + b 。
  • fib 接收一个整型参数 n ,如果 n <= 1 则返回 1,否则返回 fib (n - 1) + fib (n - 2)
  • factorial 接收一个整型参数 n ,如果 n <= 1 则返回 1 ,否则返回 factorial(n - 1) * n 。

官方题解

function memoize(func) {
  const cache = {}

  return function (...args) {
    const key = JSON.stringify(args) // 将输入参数数组 args 转换为字符串

    if (cache[key] !== undefined) {
      return cache[key]
    } else {
      const result = func(...args)
      cache[key] = result
      return result
    }
  }
}

观察到,memoize是非常典型的高阶函数。它接收一个函数 func 作为参数,并返回一个新的函数。我们在这个新函数增加一个记忆化的功能。首先,创建一个空对象 cache,用于存储已经计算过的函数结果(其中键是输入参数的字符串化表示,值是对应的函数计算结果,后续也需要将输入参数数组 args 转换为字符串)。然后对传入的参数进行检查,看是否有已经存储的结果了。如果存在,就直接返回缓存值,避免重新计算;如果不存在,就先调用函数进行计算,然后再将结果存在cache对象里。

让我们回到之前的斐波那契数列。我们利用上面实现的记忆化函数,来创建一个拥有记忆功能的斐波那契函数,然后进行性能测试。

const memoizedFib = memoizedFunction(fibOriginal)

// 原始版本的执行时间
const startOriginal = performance.now()
console.log(fibonacci(30))
const endOriginal = performance.now()
console.log(`Original Fibonacci took ${endOriginal - startOriginal} milliseconds`)

// 记忆化版本的执行时间
const startMemoized = performance.now()
console.log(memoize(30))
const endMemoized = performance.now()
console.log(`Memoized Fibonacci took ${endMemoized - startMemoized} milliseconds`)

结果如下:

832040
Original Fibonacci took 22.533595085144043 milliseconds
832040
Memoized Fibonacci took 14.824218034744263 milliseconds

可以看到,有了记忆功能的斐波那契函数的执行时间会更快,并且提升还不错。将 memoize 函数抽象出来,而不是写在原本函数的逻辑里,这样使得我们可以更加方便地为其他函数提供记忆化功能,不仅减少了工作量,代码看起来也更加整洁了。

react 中,也有一个类似的api—memo。使用memo包裹组件,让组件拥有记忆化功能,是不是思路和上面很相似呢?

结语

这篇文章主要是为了梳理一些JS比较常见的知识点。通过探讨数组方法、柯里化、节流与防抖以及记忆化,对高阶函数有了更具体的理解,希望能为将来的学习提供坚实基础。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant