【译】精通 JavaScript: 什么是函数组合(Function Composition)?

原文:Master the JavaScript Interview: What is Function Composition?

Google Datacenter Pipes — Jorge Jorquera — (CC-BY-NC-ND-2.0)

“精通 JavaScript 面试” 是一个系列的文章,旨在帮助面试者准备他们在申请中高级职位时可能遇到的常见问题。这些是我在现实面试中经常提出的问题。

函数式编程已经成为了 JavaScript 世界的一个热门话题。仅仅在几年前,甚至只有很少 JavaScript 开发者知道函数式编程,但是在过去的3年中我看到了大量使用函数式编程思维构建的应用。

函数组合是将两个或多个函数组合以产生新函数的过程。将函数组合在一起就像是将一系列管道拼凑在一起,以便我们的数据流过。

简而言之,函数 fg 的组合可以定义为 f(g(x)) ,它从内到外,从右到左进行求值。换句话说,求值顺序是:

  1. x

  2. g

  3. f

让我们在代码中进一步观察这个概念。想象一下,你希望将用户的全名转换为 URL slugs ,以便为每个用户提供一个 profile 页面。为了实现这一点,你需要完成一系列的步骤:

  1. 将姓名以空格拆分到一个数组中

  2. 将名字映射为小写

  3. 用破折号 - 链接数组中的名字

  4. 编码为 URI component

下面是一个简单的实现:

还不错,但如果我告诉你让它更具可读性呢?

想象一下,每个操作都对应一个可组合的函数。它可以写成这样:

这看起来比我们的第一次尝试更难阅读,但先忍一下,我们就要解决了。

为了实现这一点,我们使用可组合形式的常用工具函数,例如 split() join()map() 。下面是它们的实现:

除了 toLowerCase() 之外,这些函数都可以从 Lodash/fp 获得。你可以像这样导入它们:

1
import { curry, map, join, split } from 'lodash/fp'

或者像这样:

1
2
3
const curry = require('lodash/fp/curry')
const map = require('lodash/fp/map')
//...

在这我偷懒了。注意这个 curry 不是真正的技术上的柯里化函数,真正的柯里化函数总是产生一元函数。这里的 curry 只是一个简单的偏函数应用。参考 “What’s the Difference Between Curry and Partial Application?” ,但是为了这次演示的目的,将它作为真正的柯里化函数。

回头看我们的 toSlug() 的实现,有一些东西真的困扰了我:

在我看来它似乎有很深的嵌套,读起来有点混乱。我们可以使用一个自动组合这些函数的函数来展平嵌套,这意味着它将从一个函数获得输出并自动传入到下一个函数的输入,直到输出最终值。

想想看,好像数组中有一个函数可以做到差不多的事情。这个函数就是 reduce() ,它拿到一个值的列表并对这每个值应用一个函数,累计得出一个结果。这些值可以是函数。但是为了和上面的行为组合相匹配,我们需要 reduce() 从右向左递减而不是从左到右递减。

好事情是有一个 reduceRight() 函数做到了我们需要的事情:

.reduce() 一样, .reduceRight() 方法也有一个 reducer 函数和初始值 x 。我们从右向左迭代数组中的函数,依次将每个函数应用后的值累计到最终值 v

使用 compose , 我们可以不使用嵌套来重写上面的组合函数:

当然, compose() 在 lodash/fp 中也有:

1
import { compose } from 'lodash/fp'

或者:

1
const compose = require('lodash/fp/compose')

当以数学形式从内到外的角度思考时, compose 是很棒的,但是如果你想从左到右顺序的角度来思考,该如何去做?

通常还有另外一种方式称作 pipe() 。在 Lodash 中称作 flow()

注意,pipe()compose() 的实现完全相同,除了使用了 .reduce() 而不是 .reduceRight() ,即是从左到右缩减而不是从右到左。

让我们看看用 pipe() 实现的 toSlug() 函数:

这对我来说更易阅读。

硬核函数式程序员使用函数组合定义他们的整个应用程序。我经常使用它来消除对临时变量的需求。仔细观察 pipe() 版本的 toSlug() 函数,你可能会注意到一些特别的事情。

在命令式编程中,当您对某个变量执行转换时,您将在转换的每个步骤中找到对该变量的引用。上面 pipe() 的实现是以 无参(points-free) 风格的方式编写,这意味着它根本不识别它运行的参数。

我经常在单元测试和 Redux state reducers 之类的实现中使用 pipes 来消除对中间变量的需求,这些中间变量仅存于一个操作和下一个操作之间的瞬间值。

这听起来可能很奇怪,但是随着你使用它,你会发现在函数式编程中,你是在和相当抽象广义的函数打交道,其中事物的名称并不重要。名称只是妨碍。你可能开始将变量视为不必要的样板。

也就是说,我认为无参风格可能被过度使用。它可能变得过于密集,难以理解,但是如果你感到困惑,这里有一个小小的建议,你可以进入流程来跟踪发生了什么:

下面如何使用它:

trace() 只是更通用的 tap() 的一种特殊形式,它允许你为流经管道的每个值执行一些操作。明白了吗? Pipe ? Tap ? 你可以这么编写 tap()

现在你知道为什么 trace()tap() 的一个特例:

你应该开始了解函数式编程是什么了,以及 偏函数应用(partial application)柯里化(currying) 是如何协同 函数组合(function composition) 来帮助你编写更易读且更少样板的程序。

探索 ‘Master the JavaScript Interview’ 系列