JavaScript函数式编程解释:融合与转导
###介绍
融合和转导可能是我在学习函数式编程时学到的最实用的工具。 它们不是我每天都在使用的工具,也不是绝对必要的,但是,它们彻底改变了我对软件工程中编程、模块化和抽象的看法,永久地,而且变得更好。
需要明确的是,这才是本文的真正重点:不要宣传 FP、提供灵丹妙药或阐明比你现在所做的“更好”的魔法秘诀。 相反,重点是阐明 思考 关于编程的不同方式,并扩大您对日常问题的可能解决方案的认识。
这些技术并不容易流利地使用,可能需要一些时间、修修补补和刻意练习才能完全理解这里发生的事情。 对于大多数人来说,这是一个全新的抽象层次。
但是,如果你投入时间,你可能会围绕你曾经拥有的函数获得最敏锐的抽象感。
一个简单的例子
回想一下纯函数的定义,即没有副作用的函数,并且对于任何给定的输入总是返回相同的值。
由于纯函数 always 对于给定的输入返回相同的值,我们可以安全地将它们的返回值直接传递给其他函数。
这可以实现以下细节:
// niceties colorBackground(wrapWith(makeHeading(createTitle(movie))), 'div')), 'papayawhip')
在这里,我们使用 makeHeading 从 movie 创建一个字符串标题; 使用此字符串创建新标题(makeHeading 代表 document.createElement); 将此标题包装在 div 中; 最后,调用 colorBackground,它会更新元素的样式以设置 papayawhip 的背景......这是我最喜欢的 CSS 风格。
让我们明确一下这个片段中的组成。 在管道的每一步,一个函数接受一个输入,并返回一个输出,由输入完全决定。 更正式地说:在每个步骤中,我们向管道添加另一个引用透明函数。 更正式地说:papayaWhipHeading 是引用透明函数的组合。
值得指出的是,功能性的眼睛也可能会发现以下可能性。 但是你不是为了说明性但人为的例子。 您在这里了解 fusion。
让我们冲破其余的先决条件,并看看链接数组方法。
链接映射和过滤器表达式
map 的一个更好的特性是它会自动返回一个数组及其结果。
const capitalized = ["where's", 'waldo'].map(function(word) {
return word.toUpperCase();
});
console.log(capitalized); // ['WHERE'S', 'WALDO']
当然,capitalized并没有什么特别之处。 它具有任何其他数组所具有的所有相同方法。
由于 map 和 filter 返回数组,我们可以将任一方法的调用直接链接到它们的返回值。
const screwVowels = function(word) {
return word.replace(/[aeiuo]/gi, '');
};
// Calling map on the result of calling map
const capitalizedTermsWithoutVowels = ["where's", 'waldo']
.map(String.prototype.toUpperCase)
.map(screwVowels);
这并不是一个特别引人注目的结果:像这样的链式数组方法在 JS 领域很常见。 但是,它导致如下代码值得关注:
// Retrieve a series of 'posts' from JSON Placeholder (for fake demonstration data)
// GET data
fetch('https://jsonplaceholder.typicode.com/posts')
// Extract POST data from response
.then(data => data.json())
// This callback contains the code you should focus on--the above is boilerplate
.then(data => {
// filter for posts by user with userId == 1
const sluglines = data
.filter(post => post.userId == 1)
// Extract only post and body properties
.map(post => {
const extracted = {
body: post.body,
title: post.title
};
return extracted;
})
// Truncate "body" to first 17 characters, and add 3-character ellipsis
.map(extracted => {
extracted.body = extracted.body.substring(0, 17) + '...';
return extracted;
})
// Capitalize title
.map(extracted => {
extracted.title = extracted.title.toUpperCase();
return extracted;
})
// Create sluglines
.map(extracted => {
return `${extracted.title}\n${extracted.body}`;
});
});
这可能是比常见的更多的 map 调用,当然……但是,考虑 map 和 filter,这种风格变得更加可信。
在对 map 和 filter 的顺序调用中使用“单一用途”回调可以让我们编写更简单的代码,但代价是函数调用和“单一用途”回调的要求造成的开销。
我们还享受不变性的好处,因为 map 和 filter 不会修改您调用它们的数组。 相反,他们每次都创建新数组。
这让我们避免了由于微妙的副作用而造成的混乱,并保留了我们初始数据源的完整性,允许我们将其传递到多个处理管道而不会出现问题。
中间数组
另一方面,在每次调用 map 或 filter 时分配一个全新的数组似乎有点笨拙。
我们在上面进行的调用序列感觉有点“笨拙”,因为我们只关心在对 map 和 filter 进行所有调用之后得到的数组。 我们沿途生成的中间数组是一次性的。
我们创建它们的唯一目的是为链中的下一个函数提供所需格式的数据。 我们只保留我们生成的最后一个数组。 JavaScript 引擎最终会垃圾收集我们构建但不需要的中间数组。
如果您使用这种编程风格来处理大型列表,这可能会导致相当大的内存开销。 换句话说:我们正在用内存和一些附带的代码复杂性来换取可测试性和可读性。
消除中间数组
为简单起见,让我们考虑对 map 的一系列调用。
// See bottom of snippet for `users` list
users
// Extract important information...
.map(function (user) {
// Destructuring: https://jsonplaceholder.typicode.com/users
return { name, username, email, website } = user
})
// Build string...
.map(function (reducedUserData) {
// New object only has user's name, username, email, and website
// Let's reformat this data for our component
const { name, username, email, website } = reduceduserdata
const displayname = `${username} (${name})`
const contact = `${website} (${email})`
// Build the string want to drop into our UserCard component
return `${displayName}\n${contact}`
})
// Build components...
.map(function (displayString) {
return UserCardComponent(displayString)
})
// Hoisting so we can keep the important part of this snippet at the top
var users = [
{
"id": 1,
"name": "Leanne Graham",
"username": "Bret",
"email": "Sincere@april.biz",
"address": {
"street": "Kulas Light",
"suite": "Apt. 556",
"city": "Gwenborough",
"zipcode": "92998-3874",
"geo": {
"lat": "-37.3159",
"lng": "81.1496"
}
},
"phone": "1-770-736-8031 x56442",
"website": "hildegard.org",
"company": {
"name": "Romaguera-Crona",
"catchPhrase": "Multi-layered client-server neural-net",
"bs": "harness real-time e-markets"
}
},
{
"id": 2,
"name": "Ervin Howell",
"username": "Antonette",
"email": "Shanna@melissa.tv",
"address": {
"street": "Victor Plains",
"suite": "Suite 879",
"city": "Wisokyburgh",
"zipcode": "90566-7771",
"geo": {
"lat": "-43.9509",
"lng": "-34.4618"
}
}
}
]
重申问题:这会在每次调用 map 时生成一个中间的“一次性”数组。 这意味着如果我们能找到一种方法来执行我们所有的处理逻辑,我们就不会分配中间数组,而是只调用一次 map。
摆脱对 map 的一次调用的一种方法是在一个回调中完成我们所有的工作。
const userCards = users.map(function (user) {
// Destructure user we're interested in...
const { name, username, email, website } = user
const displayName = `${username} (${name})`
const contact = `${website} (${email})`
// Create display string for our component...
const displayString = `${displayName}\n${contact}`
// Build/return UserCard
return UserCard(displayString)
})
这消除了中间数组,但这是倒退了一步。 将所有内容都放在一个回调中会失去可读性和可测试性的好处,而这些好处是首先激发对 map 的顺序调用。
提高此版本可读性的一种方法是将回调提取到它们自己的函数中,并在对 map 的调用中使用它们,而不是文字函数声明。
const extractUserData = function (user) {
return { name, username, email, website } = user
}
const buildDisplayString = function (userData) {
const { name, username, email, website } = reducedUserData
const displayName = `${username} (${name})`
const contact = `${website} (${email})`
return `${displayName}\n${contact}`
}
const userCards = users.map(function (user) {
const adjustedUserData = extractUserData(user)
const displayString = buildDisplayString(adjustedUserData)
const userCard = UserCardComponent(displayString)
return userCard
})
由于引用透明性,这在逻辑上等同于我们开始时的内容。 但是,它绝对更容易阅读,并且可以说更容易测试。
真正的胜利在于这个版本让我们处理逻辑的结构更加清晰:听起来像函数组合,不是吗?
我们可以更进一步。 我们可以简单地将每次调用的结果直接传递给序列中的下一个函数,而不是将每个函数调用的结果保存到一个变量中。
const userCards = users.map(function (user) {
const userCard = UserCardComponent(buildDisplayString(extractUserData(user)))
return userCard
})
或者,如果您喜欢更简洁的代码:
const userCards = users.map(user => UserCardComponent(buildDisplayString(extractUserData(user))))
组合与融合
这恢复了我们原始的 map 调用链的所有可测试性和一些可读性。 由于我们已经成功地通过一次调用 map 来表达这种转换,因此我们消除了中间数组带来的内存开销。
我们通过将我们对 map 的调用序列转换为对 map 的单个调用,其中每个调用都收到一个“单一用途”回调,在其中我们使用这些回调的组合.
这个过程称为 fusion,它允许我们避免中间数组的开销,同时享受对 map 的顺序调用的可测试性和可读性优势。
最后一项改进。 让我们从 Python 中汲取灵感,并明确说明我们在做什么。
const R = require('ramda');
// Use composition to use "single-purpose" callbacks to define a single transformation function
const buildUsercard = R.compose(UserCardComponent, buildDisplayString, extractUserData)
// Generate our list of user components
const userCards = users.map(buildUserCard)
我们可以编写一个助手来使这个更干净。
const R = require('ramda')
const fuse = (list, functions) => list.map(R.compose(...functions))
// Then...
const userCards = fuse(
// list to transform
users,
// functions to apply
[UserCardComponent, buildDisplayString, extractUserData]
)
崩溃
如果你像我一样,这就是你开始在任何地方使用 map 和 filter 的部分,即使是你可能不应该使用它的东西。
但是这个高潮不会持续很长时间。 检查这个:
users
// Today, I've decided I hate the letter a
.filter(function (user) {
return user.name[0].toLowerCase() == 'a'
})
.map(function (user) {
const { name, email } = user
return `${name}'s email address is: ${email}.`
})
Fusion 适用于一系列 map 调用。 它同样适用于对 filter 的一系列调用。 不幸的是,它因涉及这两种方法的顺序调用而中断。 Fusion 仅适用于对这些方法之一的顺序调用。
那是因为他们以不同的方式解释回调的返回值。 map 获取返回值并将其推送到数组中,无论它是什么。
另一方面,filter 解释回调返回值的真实性。 如果回调为一个元素返回 true,它会保留该元素。 否则,它会将其丢弃。
融合不起作用,因为没有办法告诉融合函数哪些回调应该用作过滤器,哪些应该用作简单的转换。
换句话说:这种融合方法仅适用于对 map 和 filter 的一系列调用的特殊情况。
转导
正如我们所见,fusion 仅适用于仅涉及 map 或仅涉及 filter 的一系列调用。 这在实践中不是很有帮助,我们通常会同时调用两者。 回想一下,我们能够用 reduce 来表示 map 和 filter。
// Expressing `map` in terms of `reduce`
const map = (list, mapFunction) => {
const output = list.reduce((transformedList, nextElement) => {
// use the mapFunction to transform the nextElement in the list
const transformedElement = mapFunction(nextElement);
// add transformedElement to our list of transformed elements
transformedList.push(transformedElement);
// return list of transformed elements
return transformedList;
}, [])
// ^ start with an empty list
return output;
}
// Expressing `filter` in terms of `reduce`
const filter = (list, predicate) => {
const output = list.reduce(function (filteredElements, nextElement) {
// only add `nextElement` if it passes our test
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
// return the list of filtered elements on each iteration
return filteredElements;
}, [])
})
}
理论上,这意味着我们可以用对 reduce 的调用替换对 map 和 filter 的调用。 然后,我们会有一个只涉及 reduce 的调用链,但它实现了我们已经在使用的相同映射/过滤逻辑。
从那里,我们可以应用一种非常类似于我们在融合中看到的技术来表达我们在单个函数组合方面的一系列简化。
第 1 步:mapReducer 和 filterReducer
第一步是用 reduce 重新表达我们对 map 和 filter 的调用。
之前,我们编写了自己的 map 和 filter 版本,如下所示:
const mapReducer = (list, mapFunction) => {
const output = list.reduce((transformedList, nextElement) => {
// use the mapFunction to transform the nextElement in the list
const transformedElement = mapFunction(nextElement);
// add transformedElement to our list of transformed elements
transformedList.push(transformedElement);
// return list of transformed elements
return transformedList;
}, [])
// ^ start with an empty list
return output;
}
const filterReducer = (list, predicate) => {
const output = list.reduce(function (filteredElements, nextElement) {
// only add `nextElement` if it passes our test
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
// return the list of filtered elements on each iteration
return filteredElements;
}, [])
})
}
我们使用这些来演示 reduce 和 map/filter 之间的关系,但是如果我们想在 reduce 链中使用它,我们需要进行一些更改.
让我们首先删除对 reduce 的调用:
const mapReducer = mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList.push(transformedElement);
return transformedList;
}
const filterReducer = predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
return filteredElements;
}
之前,我们过滤并映射了一个 user 名称数组。 让我们开始用这些新函数重写这个逻辑,让这一切变得不那么抽象。
// filter's predicate function
function removeNamesStartingWithA (user) {
return user.name[0].toLowerCase() != 'a'
}
// map's transformation function
function createUserInfoString (user) {
const { name, email } = user
return `${name}'s email address is: ${email}.`
}
users
.reduce(filterReducer(removeNamesStartingWithA), [])
.reduce(mapReducer(createUserInfoString), [])
这会产生与我们之前的 filter/map 链相同的结果。
这涉及到相当多的间接层。 在继续之前,请花一些时间逐步完成上述代码段。
第 2 步:概括我们的折叠函数
再看看 mapReducer 和 filterReducer。
const mapReducer = mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList.push(transformedElement);
return transformedList;
}
const filterReducer = predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements.push(nextElement);
}
return filteredElements;
}
我们允许用户将映射和谓词函数作为参数传递,而不是硬编码转换或谓词逻辑,mapReducer 和 filterReducer 的部分应用程序由于闭包而记住了这些函数。
这样,我们可以通过传递适合我们用例的 predicate 或 mapFunction 将 mapReducer 和 filterReducer 用作构建任意归约链的“主干”。
如果你仔细观察,你会注意到我们仍然在这两个 reducer 中对 push 进行了显式调用。 这很重要,因为 push 是允许我们将两个对象合并或归约为一个的函数:
// Object 1... const accumulator = ["an old element"]; // Object 2... const next_element = "a new element"; // A single object that combines both! Eureka! accumulator.push(next_element); // ["an old element", "a new element"] console.log(accumulator)
回想一下,像这样组合元素是首先使用 reduce 的重点。
如果您考虑一下,push 并不是我们可以用来执行此操作的唯一函数。 我们可以使用 unshift 来代替:
// Object 1... const accumulator = ["an old element"]; // Object 2... const next_element = "a new element"; // A single object that combines both! Eureka! accumulator.unshift(next_element); // ["a new element", "an old element"] console.log(accumulator);
如前所述,我们的 reducer 将我们锁定为使用 push。 如果我们想要 unshift,我们必须重新实现 mapReducer 和 filterReducer。
解决方案是抽象。 与硬编码 push 不同,我们将让用户将他们想要用来组合元素的函数作为参数传递。
const mapReducer = combiner => mapFunction => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = combiner => predicate => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
我们像这样使用它:
// push element to list, and return updated list
const pushCombiner = (list, element) => {
list.push(element);
return list;
}
const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
users
.reduce(
filterReducer(removeNamesStartingWithA)(pushCombiner), [])
.reduce(
mapReducer(createUserInfoString)(pushCombiner), [])
第 3 步:转导
至此,我们的最后一个技巧一切就绪:组合这些转换以融合对 reduce 的链式调用。 让我们先看看它的实际效果,然后再回顾一下。
const R = require('ramda');
// final mapReducer/filterReducer functions
const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
const transformedElement = mapFunction(nextElement);
transformedList = combiner(transformedList, transformedElement);
return transformedList;
}
const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
if (predicate(nextElement)) {
filteredElements = combiner(filteredElements, nextElement);
}
return filteredElements;
}
// push element to list, and return updated list
const pushCombiner = (list, element) => {
list.push(element);
return list;
}
// filter's predicate function
const removeNamesStartingWithA = user => {
return user.name[0].toLowerCase() != 'a'
}
// map's transformation function
const createUserInfoString = user => {
const { name, email } = user
return `${name}'s email address is: ${email}.`
}
// use composition to create a chain of functions for fusion (!)
const reductionChain = R.compose(
filterReducer(removeNamesStartingWithA)
mapReducer(createUserInfoString),
)
users
.reduce(reductionChain(pushCombiner), [])
我们可以通过实现一个辅助函数更进一步。
const transduce = (input, initialAccumulator, combiner, reducers) => {
const reductionChain = R.compose(...reducers);
return input.reduce(reductionChain(combiner), initialAccumulator)
}
const result = transduce(users, [], pushCombiner, [
filterReducer(removeNamesStartingWithA)
mapReducer(createUserInfoString),
]);
结论
几乎任何问题的解决方案都比任何人都可以列举的要多; 你遇到的人越多,你对自己的想法就越清晰,你这样做的乐趣就越多。
我希望与 Fusion and Transduction 的会面能激起您的兴趣,帮助您更清晰地思考,并且尽管雄心勃勃,但至少有点乐趣。