JavaScript 功能编程详解:融合与传导

介绍

合并和转换可能是我在学习功能编程时所采用的最实用的工具,它们不是我每天都使用的工具,也不是绝对必要的工具,但它们完全改变了我对软件工程的编程,模块化和抽象思维的方式,永久和更好。

要明确的是,这就是这篇文章的真正意义:不要福音化FP,提供一个银子子弹,或点亮一些比你现在做得更好的魔法秘密酱油,而是要点亮关于编程的不同思维方式,并扩大你对日常问题的可能解决方案的感觉。

这些技术并不容易流畅地使用,可能需要一些时间,磨练和有意练习才能完全理解这里发生的事情。

但是,如果你把时间放进去,你可能只是出现了你曾经有过的关于功能的抽象的最尖锐的感觉。

快速的例子

记住纯函数的定义是没有副作用的函数,并且总是返回任何给定的输入的相同值。

由于纯函数 always 返回给定输入的相同值,我们可以安全地将其返回值直接传递给其他函数。

这样可以实现像这样的优点:

1// niceties
2colorBackground(wrapWith(makeHeading(createTitle(movie))), 'div')), 'papayawhip')

在这里,我们使用makeHeading来创建一个字符串标题从movie;使用这个字符串来创建一个新的标题(makeHeading代表document.createElement);将这个标题包装成一个div;最后,叫colorBackground,更新元素的风格以设置一个背景的papayawhip......这是我最喜欢的CSS味道。

在管道的每个步骤,一个函数接受一个输入,并返回一个输出,输入完全决定了这一点。更正式:在每个步骤中,我们将另一个参考透明函数添加到管道中。

值得指出的是,一个功能的眼睛也可能发现下面的可能性,但你不是在这里为说明 - 但是 - 创建的例子. 你在这里了解 fusion

让我们通过其余的这些前提,并看看链式数组方法。

链地图和过滤表达式

地图的更漂亮的特征之一是它会自动返回一个数组及其结果。

1const capitalized = ["where's", 'waldo'].map(function(word) {
2  return word.toUpperCase();
3});
4
5console.log(capitalized); // ['WHERE'S', 'WALDO']

当然,关于资本化没有什么特别的,它有相同的方法,任何其他数组都这样做。

由于地图过滤器返回数组,我们可以将呼叫到两种方法的链接直接到他们的返回值。

1const screwVowels = function(word) {
2  return word.replace(/[aeiuo]/gi, '');
3};
4
5// Calling map on the result of calling map
6
7const capitalizedTermsWithoutVowels = ["where's", 'waldo']
8  .map(String.prototype.toUpperCase)
9  .map(screwVowels);

這並不是一個特別劇烈的結果:這樣的連鎖數位方法在JS國家很常見,但它值得注意,因為它導致以下類型的代碼:

 1// Retrieve a series of 'posts' from JSON Placeholder (for fake demonstration data)
 2// GET data
 3fetch('https://jsonplaceholder.typicode.com/posts')
 4  // Extract POST data from response
 5  .then(data => data.json())
 6  // This callback contains the code you should focus on--the above is boilerplate
 7  .then(data => {
 8    // filter for posts by user with userId == 1
 9    const sluglines = data
10      .filter(post => post.userId == 1)
11      // Extract only post and body properties
12      .map(post => {
13        const extracted = {
14          body: post.body,
15          title: post.title
16        };
17
18        return extracted;
19      })
20      // Truncate "body" to first 17 characters, and add 3-character ellipsis
21      .map(extracted => {
22        extracted.body = extracted.body.substring(0, 17) + '...';
23        return extracted;
24      })
25      // Capitalize title
26      .map(extracted => {
27        extracted.title = extracted.title.toUpperCase();
28        return extracted;
29      })
30      // Create sluglines
31      .map(extracted => {
32        return `${extracted.title}\n${extracted.body}`;
33      });
34  });

这可能比常见的地图呼叫更多,当然......但是,考虑地图过滤器,这种风格变得更加可信。

地图过滤器的连续呼叫中使用单用途呼叫,使我们能够写出更简单的代码,因为函数呼叫和单用途呼叫的要求。

我们也享受不变性的好处,因为地图过滤器不会改变你调用它们的数组,而是每次都会创建新的数组。

这使我们能够避免由于微妙的副作用而导致的混淆,并保留了我们的初始数据源的完整性,使我们能够毫无问题地将其传递给多个处理管道。

中间线路

另一方面,在地图过滤器的每个召唤中分配一个全新的数组似乎有点沉重。

我们上面做的呼叫序列感觉有点沉重,因为我们只关心我们在完成所有呼叫地图过滤后获得的数组。

我们创建它们的唯一目的是为链中的下一个函数提供它所期望的格式的数据。我们只依赖于我们生成的最后一个数组。

如果您使用这种编程风格来处理大清单,这可能会导致大量的内存过剩,换句话说:我们正在交易内存和一些随机代码的复杂性,以确保可测试和可读性。

消除中间层次

为了简单,让我们考虑一系列的地图呼叫。

 1// See bottom of snippet for `users` list
 2users
 3  // Extract important information...
 4  .map(function (user) {
 5      // Destructuring: https://jsonplaceholder.typicode.com/users
 6      return { name, username, email, website } = user
 7  })
 8  // Build string... 
 9  .map(function (reducedUserData) {
10    // New object only has user's name, username, email, and website
11    // Let's reformat this data for our component
12    const { name, username, email, website } = reduceduserdata
13    const displayname = `${username} (${name})`
14    const contact = `${website} (${email})`
15
16    // Build the string want to drop into our UserCard component
17    return `${displayName}\n${contact}`
18  })
19  // Build components...
20  .map(function (displayString) {
21      return UserCardComponent(displayString)
22  })
23
24// Hoisting so we can keep the important part of this snippet at the top
25var users = [
26    {
27    "id": 1,
28    "name": "Leanne Graham",
29    "username": "Bret",
30    "email": "[email protected]",
31    "address": {
32      "street": "Kulas Light",
33      "suite": "Apt. 556",
34      "city": "Gwenborough",
35      "zipcode": "92998-3874",
36      "geo": {
37        "lat": "-37.3159",
38        "lng": "81.1496"
39      }
40    },
41    "phone": "1-770-736-8031 x56442",
42    "website": "hildegard.org",
43    "company": {
44      "name": "Romaguera-Crona",
45      "catchPhrase": "Multi-layered client-server neural-net",
46      "bs": "harness real-time e-markets"
47    }
48  },
49  {
50    "id": 2,
51    "name": "Ervin Howell",
52    "username": "Antonette",
53    "email": "[email protected]",
54    "address": {
55      "street": "Victor Plains",
56      "suite": "Suite 879",
57      "city": "Wisokyburgh",
58      "zipcode": "90566-7771",
59      "geo": {
60        "lat": "-43.9509",
61        "lng": "-34.4618"
62      }
63    }
64  }
65]

要重述问题:这会产生一个中间的穿透数组,每一个地图的召唤,这意味着我们不会分配中间数组,如果我们能找到一种方法来执行我们所有的处理逻辑,但只能召唤地图一次。

通过一个单一的呼叫去地图的一种方法是在一个单一的呼叫中完成所有工作。

 1const userCards = users.map(function (user) {
 2    // Destructure user we're interested in...
 3    const { name, username, email, website } = user
 4
 5    const displayName = `${username} (${name})`
 6    const contact = `${website} (${email})`
 7
 8    // Create display string for our component...
 9    const displayString = `${displayName}\n${contact}`
10
11    // Build/return UserCard
12    return UserCard(displayString)
13})

这消除了中间数组,但这是一个后退步骤.将一切扔进一个单一的回调中会失去激励序列呼叫地图的可读性和可测试性优势。

改善此版本的可读性的一种方法是将回复提取到自己的函数中,并在地图呼叫中使用它们,而不是字面函数声明。

 1const extractUserData = function (user) {
 2    return { name, username, email, website } = user
 3}
 4
 5const buildDisplayString = function (userData) {
 6    const { name, username, email, website } = reducedUserData
 7    const displayName = `${username} (${name})`
 8    const contact = `${website} (${email})`
 9
10    return `${displayName}\n${contact}`
11}
12
13const userCards = users.map(function (user) {
14    const adjustedUserData = extractUserData(user)
15    const displayString = buildDisplayString(adjustedUserData)
16    const userCard = UserCardComponent(displayString)
17
18    return userCard
19})

这在逻辑上相当于我们所开始的,由于参考透明度,但它绝对更容易阅读,而且可以说更容易测试。

这里真正的胜利在于,这个版本使我们处理逻辑的结构更加清晰:听起来像函数组成,不是吗?

我们可以进一步一步,而不是将每个函数召唤的结果保存到一个变量中,我们可以简单地将每个召唤的结果直接传递到序列中的下一个函数中。

1const userCards = users.map(function (user) {
2    const userCard = UserCardComponent(buildDisplayString(extractUserData(user)))
3    return userCard
4})

或者,如果你喜欢代码更 terse:

1const userCards = 
2  users.map(user => UserCardComponent(buildDisplayString(extractUserData(user))))

组成与合并

这恢复了我们原来的地图呼叫链的所有可测试性,以及部分可读性,并且由于我们只用单一的地图呼叫来表达这种转变,我们已经消除了中间数组所强加的内存。

我们通过将我们的地图呼叫序列转换为单一目的呼叫回复,转换为地图的单一呼叫,在其中我们使用这些呼叫回复的组成。

这个过程被称为 fusion,并允许我们避免中间数组的重叠,同时享受地图的序列呼叫的可测试性和可读性优势。

最后一个改进:让我们从Python中获取一个提示,并明确我们正在做什么。

1const R = require('ramda');
2
3// Use composition to use "single-purpose" callbacks to define a single transformation function
4const buildUsercard = R.compose(UserCardComponent, buildDisplayString, extractUserData)
5
6// Generate our list of user components
7const userCards = users.map(buildUserCard)

我們可以寫一個幫手,讓這件事情更清潔。

 1const R = require('ramda')
 2
 3const fuse = (list, functions) => list.map(R.compose(...functions))
 4
 5// Then...
 6const userCards = fuse(
 7    // list to transform
 8    users, 
 9    // functions to apply
10    [UserCardComponent, buildDisplayString, extractUserData]
11)

混合体

如果你像我一样,这就是你开始使用地图过滤器的地方,即使你可能不应该使用它。

但是,这个高不长久,看看这个:

1users
2  // Today, I've decided I hate the letter a
3  .filter(function (user) {
4      return user.name[0].toLowerCase() == 'a'
5  })
6  .map(function (user) {
7      const { name, email } = user
8      return `${name}'s email address is: ${email}.`
9  })

合并与地图呼叫的序列工作很好. 它与过滤呼叫的序列工作同样好. 不幸的是,它打破了涉及两种方法的连续呼叫。

这是因为他们以不同的方式解释他们的回归值,地图取回值并将其推入一个数组,无论它是什么。

过滤器,另一方面,解释了回调的返回值的真实性. 如果回调返回一个元素的真实,它保留了该元素,否则它会扔掉它。

合并不起作用,因为没有办法告诉合并函数哪个回复要用作过滤器,哪个要用作简单的转换。

换句话说,这种合并方法只在地图过滤器的呼叫序列的特殊情况下有效。

转换

正如我们所看到的,合并只适用于一系列仅涉及地图或仅涉及过滤器的呼叫,这在实践中并不非常有用,我们通常会调用两者。

 1// Expressing `map` in terms of `reduce`
 2const map = (list, mapFunction) => {
 3    const output = list.reduce((transformedList, nextElement) => {
 4        // use the mapFunction to transform the nextElement in the list 
 5        const transformedElement = mapFunction(nextElement);
 6
 7        // add transformedElement to our list of transformed elements
 8        transformedList.push(transformedElement);
 9
10        // return list of transformed elements
11        return transformedList;
12    }, [])
13    // ^ start with an empty list
14
15    return output;
16}
17
18// Expressing `filter` in terms of `reduce`
19const filter = (list, predicate) => {
20    const output = list.reduce(function (filteredElements, nextElement) {
21        // only add `nextElement` if it passes our test
22        if (predicate(nextElement)) {
23            filteredElements.push(nextElement);
24        }
25
26        // return the list of filtered elements on each iteration
27        return filteredElements;
28        }, [])
29    })
30}

在理论上,这意味着我们可以用减少来代替地图,然后用过滤来代替减少

从那里,我们可以应用一个非常类似于我们在合并中看到的技术来表达我们在单个函数组成方面的一系列减少。

步骤 1: mapReducer 和 filterReducer

第一步是用减少来重新表达我们对地图过滤的呼吁。

以前,我们写了我们自己的版本的地图过滤器,看起来像这样:

 1const mapReducer = (list, mapFunction) => {
 2    const output = list.reduce((transformedList, nextElement) => {
 3        // use the mapFunction to transform the nextElement in the list 
 4        const transformedElement = mapFunction(nextElement);
 5
 6        // add transformedElement to our list of transformed elements
 7        transformedList.push(transformedElement);
 8
 9        // return list of transformed elements
10        return transformedList;
11    }, [])
12    // ^ start with an empty list
13
14    return output;
15}
16
17const filterReducer = (list, predicate) => {
18    const output = list.reduce(function (filteredElements, nextElement) {
19        // only add `nextElement` if it passes our test
20        if (predicate(nextElement)) {
21            filteredElements.push(nextElement);
22        }
23
24        // return the list of filtered elements on each iteration
25        return filteredElements;
26        }, [])
27    })
28}

我们用这些来证明减少地图 / 过滤之间的关系,但如果我们想在减少链中使用这些,我们需要做出一些改变。

让我们从删除这些减少呼叫开始:

 1const mapReducer = mapFunction => (transformedList, nextElement) => {
 2    const transformedElement = mapFunction(nextElement);
 3
 4    transformedList.push(transformedElement);
 5
 6    return transformedList;
 7}
 8
 9const filterReducer = predicate => (filteredElements, nextElement) => {
10    if (predicate(nextElement)) {
11        filteredElements.push(nextElement);
12    }
13
14    return filteredElements;
15}

之前,我们过滤和绘制了一系列的用户名称,让我们开始用这些新函数重写这个逻辑,使这一切变得不那么抽象。

 1// filter's predicate function
 2function removeNamesStartingWithA (user) {
 3    return user.name[0].toLowerCase() != 'a'
 4}
 5
 6// map's transformation function
 7function createUserInfoString (user) {
 8    const { name, email } = user
 9    return `${name}'s email address is: ${email}.`
10}
11
12users
13  .reduce(filterReducer(removeNamesStartingWithA), [])
14  .reduce(mapReducer(createUserInfoString), [])

这产生了与我们以前的过滤器/地图链相同的结果。

这是涉及的一些间接层。在继续前,花一些时间通过上面的片段。

步骤2:概括我们的折叠功能

再看看mapReducerfilterReducer

 1const mapReducer = mapFunction => (transformedList, nextElement) => {
 2    const transformedElement = mapFunction(nextElement);
 3
 4    transformedList.push(transformedElement);
 5
 6    return transformedList;
 7}
 8
 9const filterReducer = predicate => (filteredElements, nextElement) => {
10    if (predicate(nextElement)) {
11        filteredElements.push(nextElement);
12    }
13
14    return filteredElements;
15}

而不是硬代码转换或预测逻辑,我们允许用户通过地图和预测函数作为参数,而mapReducerfilterReducer的部分应用程序会因为关闭而记住。

这样,我们可以用mapReducerfilterReducer作为构建任意减少链的背骨,通过适用于我们的使用案例的predicatemapFunction

如果你仔细观察,你会注意到我们仍然在这两个减速器中发出明确的呼叫,这很重要,因为是允许我们将两个对象合并或缩小为一个函数:

 1// Object 1...
 2const accumulator = ["an old element"];
 3
 4// Object 2...
 5const next_element = "a new element";
 6
 7// A single object that combines both! Eureka!
 8accumulator.push(next_element);
 9
10// ["an old element", "a new element"]
11console.log(accumulator)

请记住,将这样的元素结合起来,是使用减少的全部意义。

如果你考虑到这一点,‘推’并不是我们可以使用的唯一功能,我们可以使用‘unshift’,而不是:

 1// Object 1...
 2const accumulator = ["an old element"];
 3
 4// Object 2...
 5const next_element = "a new element";
 6
 7// A single object that combines both! Eureka!
 8accumulator.unshift(next_element);
 9
10// ["a new element", "an old element"]
11console.log(accumulator);

正如我们所写的那样,我们的减速器将我们锁定在使用上,如果我们想要不变更,我们将不得不重新实现mapReducerfilterReducer

解决方案是抽象化,而不是硬代码,我们会让用户通过他们想要使用的函数来组合元素作为一个论点。

 1const mapReducer = combiner => mapFunction => (transformedList, nextElement) => {
 2    const transformedElement = mapFunction(nextElement);
 3
 4    transformedList = combiner(transformedList, transformedElement);
 5
 6    return transformedList;
 7}
 8
 9const filterReducer = combiner => predicate => (filteredElements, nextElement) => {
10    if (predicate(nextElement)) {
11        filteredElements = combiner(filteredElements, nextElement);
12    }
13
14    return filteredElements;
15}

我们用这个样子:

 1// push element to list, and return updated list
 2const pushCombiner = (list, element) => {
 3    list.push(element);
 4    return list;
 5}
 6
 7const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
 8    const transformedElement = mapFunction(nextElement);
 9
10    transformedList = combiner(transformedList, transformedElement);
11
12    return transformedList;
13}
14
15const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
16    if (predicate(nextElement)) {
17        filteredElements = combiner(filteredElements, nextElement);
18    }
19
20    return filteredElements;
21}
22
23users
24  .reduce(
25      filterReducer(removeNamesStartingWithA)(pushCombiner), [])
26  .reduce(
27      mapReducer(createUserInfoString)(pushCombiner), [])

步骤三:转换

在这一点上,一切都在我们的最终技巧:构成这些转变来合并那些链接的呼吁减少

 1const R = require('ramda');
 2
 3// final mapReducer/filterReducer functions
 4const mapReducer = mapFunction => combiner => (transformedList, nextElement) => {
 5    const transformedElement = mapFunction(nextElement);
 6
 7    transformedList = combiner(transformedList, transformedElement);
 8
 9    return transformedList;
10}
11
12const filterReducer = predicate => combiner => (filteredElements, nextElement) => {
13    if (predicate(nextElement)) {
14        filteredElements = combiner(filteredElements, nextElement);
15    }
16
17    return filteredElements;
18}
19
20// push element to list, and return updated list
21const pushCombiner = (list, element) => {
22    list.push(element);
23    return list;
24}
25
26// filter's predicate function
27const removeNamesStartingWithA = user => {
28    return user.name[0].toLowerCase() != 'a'
29}
30
31// map's transformation function
32const createUserInfoString = user => {
33    const { name, email } = user
34    return `${name}'s email address is: ${email}.`
35}
36
37// use composition to create a chain of functions for fusion (!)
38const reductionChain = R.compose(
39    filterReducer(removeNamesStartingWithA)
40    mapReducer(createUserInfoString),
41)
42
43users
44  .reduce(reductionChain(pushCombiner), [])

我们可以通过实施一个辅助函数进一步。

1const transduce = (input, initialAccumulator, combiner, reducers) => {
2    const reductionChain = R.compose(...reducers);
3    return input.reduce(reductionChain(combiner), initialAccumulator)
4}
5
6const result = transduce(users, [], pushCombiner, [
7    filterReducer(removeNamesStartingWithA)
8    mapReducer(createUserInfoString),
9]);

结论

几乎任何问题都有更多的解决方案,比任何人都能列出;你遇到的解决方案越多,你就越清楚地思考自己的问题,你就越有趣。

我希望与Fusion和Transduction会面会引起你的兴趣,有助于你更清楚地思考,而且,虽然雄心勃勃,但至少有点乐趣。

Published At
Categories with 技术
Tagged with
comments powered by Disqus