在上一篇文章中,我们了解了 Input/Output 模式以及在编写函数时如何使用它。今天,我想通过讨论函数式编程中最强大的概念之一 — 高阶函数 — 来继续本系列文章
高阶函数
高阶函数是接受另一个函数作为参数或返回函数的函数。或者两者兼而有之。
下面是一个高阶函数的示例:
// Function `fn` accepts `anotherFn` as an argument, function fn(anotherFn) { // ...and calls it with `x` to get the value of `y`. const y = anotherFn(x); }
虽然这个例子相当抽象,看起来可能不熟悉,但你每天都已经在使用多个高阶函数。在 JavaScript 中,许多标准数据类型方法都是高阶函数。例如:
所有这些函数都接受另一个函数作为参数,这使它们成为高阶函数。让我们仔细看看 :Array.prototype.map
const numbers = [1, 2, 3]; // Go through each number in the `numbers` array // and multiply it by 2. 5numbers.map((number) => number * 2); // [2, 4, 6]
您知道该方法遍历(迭代)每个数组成员并对其应用一些转换。请注意,在使用此方法时,您从未看到“goes through each array member”逻辑,您只描述了要应用的转换。这是因为 负责迭代,当涉及到映射每个值的点时,它会执行作为参数接受的函数。.map()
.map()
这是高阶函数的关键原则: logic encapsulation。
通过这样做,该函数与我们建立了使用合同。与任何合同一样,有一些条款可以使其发挥作用:.map()
- 高阶函数控制何时调用传递的函数;
- 高阶函数控制接受函数的参数。
这两个要求都与高阶函数接受函数定义的事实有关,换句话说:动作的指令。给定的函数定义由高阶函数作为参数访问,使其负责何时以及如何调用给定的函数。
为了更好地理解这个概念,让我们构建自己的函数。map
function map(arr, mapFn) { let result = []; for (let i = 0; i < arr.length; i++) { // Get the current array member by index. const member = arr[i]; // Call the `mapFn` function we accept as an argument, // and provide the current array member to it. const mappedValue = mapFn(member); // Push the result of the `mapFn` function // into the end array of transformed members. result.push(mappedValue); } return result; }
我们的自定义函数可以像这样使用:map
map([1, 2, 3], (number) => number * 2); // Identical to: // [1, 2, 3].map((number) => number * 2)
你可以看到迭代细节(如 cycle 和内部数组)没有暴露给函数,我们的自定义函数精确地控制何时调用给定的参数以及提供什么数据:for
results
mapFn
map
mapFn
我们函数的重点是它可以做的不仅仅是数字的乘法。事实上,由于它接受一个控制如何处理每个数组成员的函数,我敢说我们的函数可以做任何事情!map
map
map(["buy", "gold"], (word) => word.toUpperCase()); // ["BUY", "GOLD"]
但是为什么这个功能如此强大呢?因为它封装了 how(迭代)并接受 what(转换)。它隐藏了作为其 Contract 一部分的 logic,但提供了一种通过参数自定义特定行为以保持通用性的方法。
返回函数
高阶函数也可能返回函数。在这种情况下,它的作用恰恰相反:它不是负责何时以及如何调用给定的函数,而是生成一个函数并让您负责何时以及如何调用该函数。
让我们使用之前编写的相同函数,但现在重写它,使其返回一个函数:map
// Instead of accepting the array straight away, function map(mapFn) { // ...we return a function that accepts that array. return (arr) => { let result = []; // ...leaving the iteration logic as-is. for (let i = 0; i < arr.length; i++) { const member = arr[i]; const mappedValue = mapFn(member); result.push(mappedValue); } return result; }; }
由于 now 只接受一个参数,我们需要改变它的调用方式:map
调用签名在 JavaScript 中并不常见。此外,这相当令人困惑。fn(x)(y)
历史题外话:不久前,这样的调用签名在 React 中被用来描述高阶组件,所以它可能会敲响一些遥远的无钩子的钟声。
export default connect(options)(MyComponent);
别担心,我们不必遵守这个不寻常的呼叫签名。相反,让我们将该函数调用分解为两个单独的函数。map
// Calling `map` now returns a new function. const multiplyByTwo = map((number) => number * 2); // That returned function already knows it should // multiply each array item by 2. // Now we only call it with the actual array. multiplyByTwo([1, 2, 3]); // [2, 4, 6] // We can reuse the `multiplyByTwo` function // without having to repeat what it does, // only changing the data it gets. multiplyByTwo([4, 5, 6]); // [8, 10, 12]
我们的函数不再自行进行任何迭代,但它会生成另一个函数 (),该函数会记住要执行的转换,只等待我们为其提供数据。map
multiplyByTwo
应用
在设计函数时,高阶函数是一个很好的工具。高阶函数的重点是封装逻辑。该封装可以解决一个或多个目的:
- 抽象实现细节以支持声明性代码。
- 封装逻辑以实现多功能重用。
抽象逻辑
最基本的示例是当您需要重复某个操作 N 次时。与冗长的循环相比,函数式抽象可以派上用场。当然,它仍然会在内部使用 loop,抽象 iteration,因为当你使用该函数时,这并不重要。for
function repeat(fn, times) { for (let i = 0; i < times; i++) { fn(); } } // Where are not concerned with "how" (iteration), // but focus on the "what" (declaration), making // this function declarative. repeat(() => console.log("Hello"), 3);
通过将命令式代码移动到高阶函数的实现细节,我们通常会获得改进的代码可读性,使我们的逻辑更容易推理。比较这两个示例:一个使用命令式代码,另一个使用声明性高阶函数:
// Imperative const letters = ["a", "b", "c"]; const nextLetters = []; for (let i = 0; i < letters.length; i++) { nextLetters.push(letters[i].toUpperCase()); } // Declarative const letters = ["a", "b", "c"]; const nextLetters = map(letters, (letter) => letter.toUpperCase());
虽然这两个示例都将 Array 字母映射到大写,但您需要的认知工作要少得多,才能理解第二个示例背后的意图。
它还涉及重用和组合 - 通过组合现有函数来创建新逻辑。看看下面的抽象如何让你了解发生了什么,而无需你深入了解它们的编写方式:
map(ids, toUserDetail); map(users, toPosts); reduce(posts, toTotalLikes);
封装逻辑
假设我们的应用程序中有一个函数:sort
function sort(comparator, array) { array.sort(comparator); }
我们使用此功能按评级对多个事物进行排序:产品、书籍、用户。
sort((left, right) => left.rating - right.rating, products); sort((left, right) => left.rating - right.rating, books); sort((left, right) => left.rating - right.rating, users);
您可以看到每次调用的 comparator 函数都是相同的,无论我们处理什么数据。我们在应用程序中按很多评分进行排序,因此让我们将该比较器抽象为它自己的函数并重用它:
function byRating(left, right) { return left.rating - right.rating; } sort(byRating, products); sort(byRating, books); sort(byRating, users);
好多了!但是,我们的排序调用仍然在与条件无关的函数上运行。这是一件小事,但我们还必须在需要按 rating 排序的任何位置导入两个函数 ( 和 )。sort
sort
byRating
让我们从方程式中去掉比较器,并将其锁定在一个函数中,该函数立即通过评级对给定数组进行排序:sortByRating
function sortByRating(array) { sort(byRating, array); } sortByRating(products);
现在 rating 比较器内置到函数中,我们可以在按评分排序的任何地方重复使用它。这是一个单一的功能,它很短,它很棒。结案。sortByRating
我们的应用程序的大小和要求都在增长,我们发现自己不仅按评分排序,还按评论和下载排序。如果我们进一步遵循相同的抽象策略,我们会偶然发现一个问题:
function sortByRating(array) { sort(byRating, array); } function sortByReviews(array) { sort(byReviews, array); } function sortByDownloads(array) { sort(byDownloads, array); }
因为我们已经从参数中移出了 comparator,所以每当我们需要封装不同的 comparison logic时,我们不可避免地会创建一个新函数。这样做,我们引入了一个不同类型的问题:上面的函数都没有对数组进行排序的意图,而是到处重复实现 ()。sortBy*
sortBy*
sort
我们可以使用高阶函数来完成这个抽象任务,这将允许我们创建一个简洁且确定的函数来满足我们的要求。
function sort(comparator) { return (array) => { array.sort(comparator); }; }
该函数接受 a 并返回一个执行排序的 applicator 函数。请注意 and 的性质是可变的,来自参数,但尽管具有这种动态性质,但函数的意图 () 并没有重复。sort
comparator
comparator
array
array.sort
现在我们可以创建多个封装不同标准的排序函数,如下所示:
const sortByRating = sort(byRating); const sortByReviews = sort(byReviews); const sortByDownloads = sort(byDownloads); sortByRating(products); sortByReviews(books); sortByDownloads(songs);
这是 logic encapsulation 和 reuse 的一个很好的例子。它也很漂亮。
提及:柯里化
高阶函数也是部分应用和局部套用的基础,这两种技术在函数式编程中是不可替代的。如果它们对您来说听起来很陌生,请不要担心,我们将在本系列的后续章节中讨论它们。
付诸实践
与任何其他函数一样,在编写高阶函数时,应用 Input/Output 模式是一个很好的起点。有了这个,还有一些其他问题要问自己:
- 将什么操作委托给 argument 函数?
- 什么时候应该调用参数函数?
- 向 argument 函数提供哪些数据?
- argument 函数的返回数据会影响父函数吗?
在高阶函数的职责和它接受的参数函数之间建立明确的区分至关重要。
锻炼:尝试编写你自己的函数:它接受一个数组和一个返回 .它返回一个新数组,其中包含 argument function 为其返回的成员:
filter()
Boolean
true
filter([1, 3, 5], (number) => number > 2); // [3, 5]使用我们在本文前面创建的函数作为参考。
map
真实示例
在处理我的一个项目时,我决定创建一个自定义函数,该函数允许我将 XMLHttpRequest
实例作为 Promise 处理。目的是使此类请求的声明更短并支持语法。我首先创建了一个帮助程序函数:async/await
function createXHR(options) { const req = new XMLHttpRequest(); req.open(options.method, options.url); return new Promise((resolve, reject) => { req.addEventListener("load", resolve); req.addEventListener("abort", reject); req.addEventListener("error", reject); req.send(); }); }
然后,我会在我的测试中使用该函数,如下所示:createXHR
test("handles an HTTPS GET request", async () => { const res = await createXHR({ method: "GET", url: "https://test.server", }); });
问题是,我还需要针对各种测试场景以不同的方式配置请求:设置标头、发送请求正文或附加事件侦听器。为了支持这一点,我转到了我的函数并扩展了它的逻辑:createXHR
function createXHR(options) { const req = new XMLHttpRequest() req.responseType = options.responseType || 'text' if (options?.headers) { Object.entries(options.headers).forEach([header, value] => { req.setRequestHeader(header, value) }) } req.addEventListener('error', options.onError) return new Promise((resolve, reject) => { // ... req.send(options.body) }) }
随着测试场景的多样性增加,我的函数也越来越复杂。这导致了一个过于复杂的函数,难以阅读,甚至更难使用。为什么会这样呢?createXHR
我的错误是假设该函数应该自行配置请求。将请求配置描述为对象也不是一个明智的选择,因为对象是一个有限的数据结构,无法表示请求声明的所有种类。createXHR
options
相反,我的帮助程序函数应该允许每个单独的调用配置它所需的请求实例。它可以通过成为高阶函数并接受将请求实例配置为参数的 action 来实现这一点。
// Accept a `middleware` function, function createXHR(middleware) { const req = new XMLHttpRequest(); // ...that configures the given `XMLHttpRequest` instance, middleware(req); // ...and still promisifies its execution. return new Promise((resolve, reject) => { req.addEventListener("loadend", resolve); req.addEventListener("abort", reject); req.addEventListener("error", reject); }); }
在函数中声明 instance 而不被接受为参数的原因是,一旦发送了请求,就无法更改某些选项。
XMLHttpRequest
请注意,当该函数将请求配置委托给函数时,它变得多么简洁。这样,每个测试都可以提供自己的方法来设置请求,并且仍然会收到 Promise 作为回报。middleware
test("submits a new blog post", async () => { const req = await createXHR((req) => { req.open("POST", "/posts"); req.setRequestHeader("Content-Type", "application/json"); req.send(JSON.stringify({ title: "Thinking in functions", part: 2 })); }); }); test("handles error gracefully", async () => { const req = await createXHR((req) => { req.open("GET", "/posts/thinking-in-functions"); req.addEventListener("error", handleError); req.send(); }); });
后记
高阶函数一开始可能是一个很难掌握的概念,但给它一些时间,在实践中应用它,理解就会到来。这是函数式编程的关键部分,也是向函数式思维迈出的重要一步。我希望这篇文章对你的知识有所帮助,你现在觉得你的武器库中有一个额外的工具。