wangzengdi's Blog

Functional Programming

0%

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Wrap-Up》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的总结篇。

在过去的八篇文章中,我们一直在讨论 Ramda JavsScipt 库,它提供了一系列以函数式、声明式和数据不变性方式工作的函数。

在这个系列中,我们了解了蕴含在 Ramda API 背后的一些指导原则:

  • 数据放在最后:几乎所有的函数都将数据参数作为最后一个参数。

  • 柯里化:Ramda 几乎所有的函数都是自动柯里化的。也即,可以使用函数必需参数的子集来调用函数,这会返回一个接受剩余参数的新函数。当所有参数都传入后,原始函数才被调用。

这两个原则使我们能编写出非常清晰的函数式代码,可以将基本的构建模块组合成更强大的操作。

总结

作为参考,一下是本系列文章的简单概要。

  • 入门:介绍了函数、纯函数和数据不变性思想。作为入门,展示了一些集合迭代函数,如:mapfilterreduce 等。

  • 函数组合:演示了可以使用工具(如 botheitherpipecompose)以多种方式组合函数。

  • 部分应用(Partial Application):演示了一种非常有用的函数延时调用方式:可以先向函数传入部分参数,以后根据需要将其余参数传入。借助 partialcurry 可以实现部分应用。我们还学习了 flip 和占位符(__)。

  • 声明式编程:介绍了命令式和函数式编程之间的区别。学习了如何使用 Ramda 的声明式函数代替算术、比较、逻辑和条件运算符。

  • 无参数风格编程(Pointfree Style):介绍了 pointfree 风格的思想,也被称为 “tatic” 式编程。在 pointfree 式编程时,实际上不会看到正在操作的数据参数,数据被隐含在函数中了。程序是由许多较小的、简单的构建模块组合而成。只有在最后才将组合后的函数应用于实际的数据上。

  • 数据不变性和对象:该节让我们回到了声明式编程的思想,展示了读取、更新、删除和转换对象属性所需的工具。

  • 数据不变性和数组:继续上一节的主题,展示了数据不变性在数组中的应用。

  • 透镜(Lenses):引入了透镜的概念,该结构允许我们把重点聚焦在较大的数据结构的一小部分上。借助 viewsetover 函数,可以对较大数据结构的小部分被关注数据进行读取、更新和变换操作。

后续

该系列文章并未覆盖到 Ramda 所有部分。特别是,我们没有讨论处理字符串的函数,也没有讨论一些更高阶的概念,如 transducers

要了解更多 Ramda 的作用,我建议仔细阅读 官方文档,那里有大量的信息。所有的函数都按照它们处理数据的类型进行了分类,尽管有一些重叠。比如,有几个处理数组的函数可以用于处理字符串,map 可以作用于数组和对象两种类型。

如果你对更高级的函数式主题感兴趣,可以参考一下资料:

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Lenses》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第八篇。

第六节第七节 中,我们学习了如何以声明式和不变式来读取、更新和转换对象的属性和数组的元素。

Ramda 提供了一个更通用的工具:透镜(lens),来进行这些操作。

什么是透镜?

透镜将 “getter” 和 “setter” 函数组合为一个单一模块。Ramda 提供了一系列配合透镜一起工作的函数。

可以将透镜视为对某些较大数据结构的特定部分的聚焦、关注。

如何创建透镜

在 Ramda 中,最常见的创建透镜的方法是 lens 函数。lens 接受一个 “getter” 函数和一个 “setter” 函数,然后返回一个新透镜。

1
2
3
4
5
6
7
8
9
10
11
12
13
const person = {
name: 'Randy',
socialMedia: {
github: 'randycoulman',
twitter: '@randycoulman'
}
}

const nameLens = lens(prop('name'), assoc('name'))
const twitterLens = lens(
path(['socialMedia', 'twitter']),
assocPath(['socialMedia', 'twitter'])
)

这里使用 proppath 作为 “getter” 方法;assocassocPath 作为 “setter” 方法。

注意,上面实现不得不重复传递属性和路径参数给 “getter” 和 “setter” 方法。幸运的是,Ramda 为最常见类型的透镜提供了便捷方法:lensProplensPathlensIndex

  • LensProp:创建关注对象某一属性的透镜。
  • lensPath: 创建关注对象某一嵌套属性的透镜。
  • lensIndex: 创建关注数组某一索引的透镜。

可以用 lensProplensPath 来重写上述示例:

1
2
const nameLens = lensProp('name')
const twitterLens = lensPath(['socialMedia', 'twitter'])

这样便摆脱了向 “getter” 和 “setter” 重复输入两次相同参数的烦扰,变得简洁多了。在实际工作中,我发现我几乎从来不需要使用通用的 lens 函数。

我能用它做什么呢?

我们创建了一些透镜,可以用它们做些什么呢?

Ramda 提供了三个配合透镜一起使用的的函数:

  • view:读取透镜的值。
  • set:更新透镜的值。
  • over:将变换函数作用于透镜。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
view(nameLens, person) // => 'Randy'

set(twitterLens, '@randy', person)
// => {
// name: 'Randy',
// socialMedia: {
// github: 'randycoulman',
// twitter: '@randy'
// }
// }

over(nameLens, toUpper, person)
// => {
// name: 'RANDY',
// socialMedia: {
// github: 'randycoulman',
// twitter: '@randycoulman'
// }
// }

注意,setover 会按指定的方式对被透镜关注的属性进行修改,并返回整个新的对象。

结论

如果想从复杂数据结构的操作中抽象出简单、通用的方法,透镜可以提供很多帮助。我们只需暴露透镜;而不需要暴露整个数据结构、或者为每个可访问属性都提供 “setter”、“getter” 和 变换方法。

下一节

我们现在已经了解了许多 Ramda 提供的方法,已经足以应对大部分编程需要。总结 将回顾整个系列的内容,并会提到一些可能需要自己进一步探索的其他主题。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Immutability and Arrays》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第七篇。

第六节 中,讨论了以函数式和数据不变性(immutable)的方式来处理 JavaScript 对象。

本节将继续用相同的方式讨论数组。

读取数组元素

第六节 中,展示了许多读取对象属性的 Ramda 函数,包括 proppickhas。Ramda 有更多的方法来读取数组的元素。

数组中与 prop 类似的是 nth;与 pick 类似的是 slice,跟 has 类似的是 contains。来看一些例子。

1
2
3
4
5
6
7
8
9
const numbers = [10, 20, 30, 40, 50, 60]

nth(3, numbers) // => 40 (0-based indexing)

nth(-2, numbers) // => 50 (negative numbers start from the right)

slice(2, 5, numbers) // => [30, 40, 50] (see below)

contains(20, numbers) // => true

slice 接受两个索引,返回从第 1 个索引开始(以 0 为起始)到第 2 个索引结束(不包含)的所有元素组成的子数组。

经常会访问首个(nth(0))和最后一个(nth(-1))元素,所以 Ramda 为这两种特殊情形提供的便捷方法:headlast。还提供了访问除首个元素之外的所有元素的函数:tail,除最后一个元素之外的所有元素的方法:init,前 N 个元素:take(N),后 N 个元素:takeLast(N)。来看看这些函数的实例。

1
2
3
4
5
6
7
8
9
10
const numbers = [10, 20, 30, 40, 50, 60]

head(numbers) // => 10
tail(numbers) // => [20, 30, 40, 50, 60]

last(numbers) // => 60
init(numbers) // => [10, 20, 30, 40, 50]

take(3, numbers) // => [10, 20, 30]
takeLast(3, numbers) // => [40, 50, 60]

增、删、改数组元素

对于对象,我们已经学了对其属性进行增、删、改的函数:assocdissocevolve 等。

但数组是有序数据结构,有好多函数与 assoc 类似。最常用的是 insertupdate,Ramda 还提供了 appendprepend 来在数组头部或尾部添加元素。insertappendprepend 会给数组添加新元素;update 使用新值替换已有元素。

正如一般函数式库应该具备的,所有这些函数都返回修改后的新数组,原有数组保持不变。

1
2
3
4
5
6
7
8
9
const numbers = [10, 20, 30, 40, 50, 60]

insert(3, 35, numbers) // => [10, 20, 30, 35, 40, 50, 60]

append(70, numbers) // => [10, 20, 30, 40, 50, 60, 70]

prepend(0, numbers) // => [0, 10, 20, 30, 40, 50, 60]

update(1, 15, numbers) // => [10, 15, 30, 40, 50, 60]

为了将两个对象合并为一个,我们学习了 merge;Ramda 为数组合并提供了 concat

1
2
3
const numbers = [10, 20, 30, 40, 50, 60]

concat(numbers, [70, 80, 90]) // => [10, 20, 30, 40, 50, 60, 70, 80, 90]

注意,第二个数组添加到第一个数组之后。当单独使用 concat 时,可以很好的工作;但类似于 merge,在 pipeline 中可能并不像预期的那样工作。可以为在 pipeline 中使用定义一个辅助函数 concatAfterconst concatAfter = flip(concat)

Ramda 还提供了几个删除元素的函数。remove 删除指定索引处的元素,without 通过值删除元素。还有常用到的删除前 N 或 后 N 个元素的函数:dropdropLast

1
2
3
4
5
6
7
8
9
const numbers = [10, 20, 30, 40, 50, 60]

remove(2, 3, numbers) // => [10, 20, 60]

without([30, 40, 50], numbers) // => [10, 20, 60]

drop(3, numbers) // => [40, 50, 60]

dropLast(3, numbers) // => [10, 20, 30]

注意,remove 接受一个索引和一个删除元素的数量,而 slice 接受两个索引。如果你不知道这种不一致,可能会造成使用上的困扰。

变换元素

与对象一样,我们可能希望通过将函数应用于元素的原始值来更新数组元素。

1
2
3
const numbers = [10, 20, 30, 40, 50, 60]

update(2, multiply(10, nth(2, numbers)), numbers) // => [10, 20, 300, 40, 50, 60]

为了简化这个常见的用例, Ramda 提供了 adjust,其工作方式类似于操作对象的 evolve。与 evolve 不同的是, adjust 只能作用于数组的单个元素。

1
2
3
const numbers = [10, 20, 30, 40, 50, 60]

adjust(multiply(10), 2, numbers)

注意,与 update 相比,adjust 将前两个参数的位置交换了一下。这可能会引起困扰,但当进行部分应用时,这样做还是很有道理的。你可能会先提供一个调整函数,比如 adjust(multiply(10)) ,然后再决定要调整的索引和数组。

结论

我们现在有了以声明式和不变式操作对象和数组的一系列方法。这允许我们在不改变已有数据的情况下,从较小的、函数式的构建模块来构建程序,通过对函数进行组合来实现我们想要的功能。

下一节

我们学习了读取、更新和转换对象属性和数组元素的方法。Ramda 提供了更通用的进行这些操作的工具:lens(透镜)。Lenses 向我们演示了它们的工作原理和方式。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Immutability and Objects》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第六篇。

第五节 中,我们讨论了如何以 “pointfree” 或 “tacit” 风格来编写函数:函数的参数不会显式的出现。

那时候,因为缺少一些工具,我们还无法将所有的函数转换为 “pointfree” 的风格。现在我们就来学习这些工具。

读取对象属性

再来回顾一下 第五节 已经重构过的 “合格选民” 的例子:

1
2
3
4
5
6
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18

const isCitizen = either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = both(isOver18, isCitizen)

如上所示,我们已经将 isCitizenisEligibleToVote 变为 “pointfree” 风格的了,但前三个函数还没有 “pointfree” 化。

正如 第四节 所学,可以使用 equalsgte 来让函数更 “声明式” 一些。我们就此开始:

1
2
3
const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => gte(person.age, 18)

为了让这些函数变为 “pointfree” 的,需要一种方法来使构建出来的函数的 person 参数排在参数列表的最后。问题是,我们需要访问 person 的属性,现有唯一的方法却是命令式的。

prop

幸运的是, Ramda 为我们提供了访问对象属性的辅助函数:prop

使用 prop,可以将 person.birthCountry 转换为 prop('birthCountry', person)。现在来试试。

1
2
3
const wasBornInCountry = person => equals(prop('birthCountry', person), OUR_COUNTRY)
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(prop('age', person), 18)

哇!现在看起来更糟了,还需要继续重构。首先,需要交换传递给 equals 的参数的顺序,这样可以将 prop 放到最后。equals 在任意顺序下都能正常工作。

1
2
3
const wasBornInCountry = person => equals(OUR_COUNTRY, prop('birthCountry', person))
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(prop('age', person), 18)

接下来,使用 equalsgte 的柯里化特性来创建新函数,新函数可以作用于 prop 输出的结果上。

1
2
3
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry', person))
const wasNaturalized = person => Boolean(prop('naturalizationDate', person))
const isOver18 = person => gte(__, 18)(prop('age', person))

还是不太好,还需要继续优化。我们继续利用柯里化的特性来优化 prop 的调用。

1
2
3
const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry')(person))
const wasNaturalized = person => Boolean(prop('naturalizationDate')(person))
const isOver18 = person => gte(__, 18)(prop('age')(person))

又变糟了。但现在我们看到了一种熟悉的模式,所有的三个函数都具有相同的形式:g(f(person))。由 第二节 可知,这等价于 compose(g, f)(person)

我们来利用这一点。

1
2
3
const wasBornInCountry = person => compose(equals(OUR_COUNTRY), prop('birthCountry'))(person)
const wasNaturalized = person => compose(Boolean, prop('naturalizationDate'))(person)
const isOver18 = person => compose(gte(__, 18), prop('age'))(person)

现在好一些了,三个函数的形式变成了 person => f(person)。由 第五节 可知,现在可以将这三个函数写成 “pointfree” 的了。

1
2
3
const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry'))
const wasNaturalized = compose(Boolean, prop('naturalizationDate'))
const isOver18 = compose(gte(__, 18), prop('age'))

未重构前,并不能明显看出我们的方法是在做两件事情。它们都先访问对象的属性,然后对该属性的值进行一些操作。重构为 “pointfree” 风格后,程序的表意变得清晰了许多。

我们来展示更多 Ramda 处理对象的函数。

pick

prop 用来读取并返回对象的单个属性,而 pick 读取对象的多个属性,然后返回有这些属性组成的新对象。

例如,如果想同时获取一个人的名字和年龄,可以使用:pick(['name', 'age'], person)

has

在不读取属性值的情况下,想知道对象中是否包含该属性,可以使用 has 来检测对象是否拥有该属性,如 has('name' ,person);还可以使用 hasIn 来检测原型链上的属性。

path

prop 用来读取对象的属性,path 可以读取对象的嵌套属性。例如,我们可以从更深层的结构中访问邮编:path(['address', 'zipCode'], person)

注意,path 容错性更强。如果路径上的任意属性为 nullundefined,则 path 返回 undefined,而 prop 会引发错误。

propOr / pathOr

propOrpathOr 像是 prop/pathdefaultTo 的组合。如果在目标对象中找不到属性或路径的值,它们允许你提供默认值。

例如,当我们不知道某人的姓名时,可以提供一个占位符:propOr('<Unnamed>', 'name', person)。注意,与 prop 不同,如果 personnullundefined 时,propOr 不会引发错误,而是会返回一个默认值。

keys / values

keys 返回一个包含对象中所有属性名称的数组。values 返回这些属性的值组成的数组。当与 第一节 中提到集合迭代函数结合使用时,这两个函数会非常有用。

对属性增、删、改、查

现在已经有很多对对象进行声明式读取的函数,但如果想要进行更改操作呢?

由于数据不变性很重要,我们不想直接更改对象。相反,我们想要更改后形成的新对象。

Ramda 再次为我们提供了很多辅助函数。

assoc / assocPath

在命令式编程时,可以使用赋值操作符设置或更改一个人的名字:person.name = 'New name'

在函数式、数据不变的世界里,可以使用 assoc 来代替:const updatedPerson = assoc('name', 'New name', person)

assoc 返回一个添加或修改属性的新对象,原对象保持不变。

还有用于更新嵌套属性的方法:assocPathconst updatedPerson = assocPath(['address', 'zipcode'], '97504', person)

dissoc / dissocPath / omit

如何删除属性呢?我们可能想删除 person.age 。在 Ramda 中,可以使用 dissocconst updatedPerson = dissoc('age', person)

dissocPath 类似于 dissoc,但可以作用于对象的嵌套属性:dissocPath(['address', 'zipCode'], person)

还有一个 omit,用于一次删除多个属性。const updatedPerson = omit(['age', 'birthCountry'], person)

注意,pickomit 的操作很像,两者是互补的关系。它们能辅助实现白名单(使用 pick 保留想要的属性集)和黑名单(使用 omit 删除不想要的属性集)的功能。

属性转换

我们现在已经知道如何利用声明式和数据不变性的方式来处理对象。我们来写一个函数:celebrateBirthday,在生日当前更新他的年龄。

1
2
const nextAge = compose(inc, prop('age'))
const celebrateBirthday = person => assoc('age', nextAge(person), person)

这是一种很常见的模式。如上所示,我们并不想用给定的新值覆盖已有属性值,而是想通过函数作用于属性的旧值来对其进行转换。

就目前已知的方法,我尚未找到一种以更少重复代码和 pointfree 的形式来优化该段代码的方式。

Ramda 使用 evolve 方法再次拯救了我们。我在 之前的文章 中也提到过 evolve

evolve 接受一个对象,其中包含对每个需要转换属性的转换函数。我们来使用 evolve 来重构 celebrateBirthday

1
const celebrateBirthday = evolve({ age: inc })

这段代码通过将 evolve 参数对象属性对应的函数作用于被变换对象相同属性值上,来转换已有对象的属性。本例中使用 incpersonage 属性进行加 1 操作,并返回 age 更新后的新 person 对象。

evolve 可以一次转换多个属性,还可以进行嵌套转换。“转换函数对象”(包含转换函数的对象)与被转换对象具有基本相同的结构,evolve 会递归地遍历这两个对象,然后将转换函数作用于对应的属性值上。

注意,evolve 不会添加新属性,如果为目标对象不存在的属性指定转换函数,evolve 会将其忽略。

evolve 已经很快成为我编程时的主力。

合并对象

有时,需要合并两个对象。一种常见的情形是当使用含有 “options” 配置项的函数时,常常需要将这些配置项与一组默认配置项进行组合。Ramda 为此提供了 merge 方法。

1
2
3
4
function f(a, b, options = {}) {
const defaultOptions = { value: 42, local: true }
const finalOptions = merge(defaultOptions, options)
}

merge 返回一个包含两个对象的所有属性和值的新对象。如果两个对象具有相同的属性,则采用第二个对象参数的属性值。

在单独使用 merge 时,采用第二个参数的属性值作为最终值是非常有用的;但在 pipeline 中可能没什么用。在 pipeline 中,通常会对一个对象进行一系列转换,其中一个转换是合并一些新的属性值到对象中。这种情况,可能需要第一个参数中的属性值作为最终值。

如果只是在 pipeline 中简单地使用 merge(newValues),可能不会得到你想要的结果。

对于这种情况,我通常会定义一个辅助函数 reverseMergeconst reverseMerge = flip(merge)。回想一下,flip 会翻转函数前两个参数的位置。

merge 执行的是浅合并。如果被合并的对象存在属性值为对象的属性,子对象并不会继续嵌套合并。如果想递归地进行 “深合并”,可以使用 Ramda 的 mergeDeep 系列函数。(译者注:作者在写这篇文章时,Ramda 还没有 mergeDeep 系列函数,mergeDeep 系列函数是在 v0.24.0 中加入的)

注意,merge 只接受两个参数。如果想要将多个对象合并为一个对象,可以使用 mergeAll,它接受一个需要被合并对象的数组作为参数。

结论

本文展示了 Ramda 中一系列很好的以声明式和数据不变方式处理对象的方法。我们现在可以对对象进行增、删、改、查,而不会改变原有的对象。并且也可以在组合函数时使用这些方法来做这些事情。

下一节

现在可以以 Immutable 的方式处理对象,那么数组呢?数据不变性和数组 将演示对数组的处理。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Pointfree Style》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第五篇。

第四节中,我们讨论了如何用声明式编程(告诉计算机做什么,我们想要什么)代替命令式编程(告诉计算机该怎么做,详细的执行步骤)来编写代码。

你可能已经注意到了,我们编写的几个函数(如 forever21alwaysDrivingAgewater)都接受一个参数,构建一个新函数,然后将该函数作用于该参数。

这是函数式编程里非常常见的一种模式,Ramda 同样提供了优化这种模式的方法。

Pointfree 风格(无参数风格)

我们在 第三节 中讨论了 Ramda 的两个指导原则:

  • 将数据放到参数列表的最后面。
  • 柯里化所有的东西。

这两个原则衍生出了一种被函数式程序员称为 “pointfree” 的风格。我喜欢将 pointfree 的代码看作:“数据?什么数据?这里没有数据!”

有一篇很好的博客:Why Ramda?,展示了 pointfree 风格 真得不错。具体来说,它有一些有趣的标题,例如:“数据在哪里?”,“好了,已经有了!”,“那么我可以看看数据吗?” 和 “拜托,我只是想要我的数据”。

我们还没有使用需要的工具来让所有的例子都变成完全 “pointfree” 的,现在就开始吧。

再看一下 forever21

1
const forever21 = age => ifElse(gte(__, 21), always(21), inc)(age)

注意,参数 age 出现了两次:一次在参数列表中;一次在函数的最后面:我们将由 ifElse 返回的新函数作用于 age

在使用 Ramda 编程时稍加留意,就会发现很多这种模式的代码。这也意味着,总应该有一种方法将这些函数转成 “pointfree” 风格。

我们来看看这会是什么样子:

1
const forever21 = ifElse(gte(__, 21), always(21), inc)

嘭~~!我们刚刚让 age 消失了。这就是 Pointfree 风格。注意,这两个版本所做的事情完全一样。我们仍然返回一个接受年龄的函数,但并未显示的指定 age 参数。

可以对 alwaysDrivingAgewater 进行相同的处理。

原来的 alwaysDrivingAge 如下所示:

1
const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), identity)(age)

可以使用相同的方法使其变为 pointfree 的。

1
const alwaysDrivingAge = when(lt(__, 16), always(16))

下面是 water 原来的形式:

1
2
3
4
5
const water = temperature => cond([
[equals(0), always('water freezes at 0°C')],
[equals(100), always('water boils at 100°C')],
[T, temp => `nothing special happens at ${temp}°C`]
])(temperature)

现在将其变为 pointfree 风格的:

1
2
3
4
5
const water = cond([
[equals(0), always('water freezes at 0°C')],
[equals(100), always('water boils at 100°C')],
[T, temp => `nothing special happens at ${temp}°C`]
])

多元函数(多参数函数)

如果函数接受多个参数会怎样呢?回顾一下 第三节 中的例子:titlesForYear

1
2
3
4
5
6
const titlesForYear = curry((year, books) =>
pipe(
filter(publishedInYear(year)),
map(book => book.title)
)(books)
)

注意,books 出现了两次:一次作为参数列表的最后一个参数(最后一个数据!);一次出现在函数最后,当我们将其传入 pipeline 的时候。这跟我们之前看到参数为 age 的模式类似,所以可以对它进行相同的转换:

1
2
3
4
5
const titlesForYear = year =>
pipe(
filter(publishedInYear(year)),
map(book => book.title)
)

可以了!我们现在有了一个 pointfree 版本的 titlesFroYear

其实,这种情况下,我可能不会刻意追求 pointfree 风格,因为就像之前文章讨论过的:JavaScript 在调用一系列单参数函数方面并不方便。

在 pipeline 中使用 titleForYear 是很方便,如我们可以很轻松的调用 titlesForYear(2012),但当想要单独使用它时,我们就不得不回到之前文章里看到的形式 )(,对我而言,并不值得做出这种妥协(没必要为了 pointfree 而 pointfree)。

但只要有如上形式的单参数函数(或者可能以后会被重构),我几乎总是写成 pointfree 风格的。

重构为 pointfree 风格的代码

有时我们的代码不会遵循这种模式。我们可能会在同一函数内多次对数据进行操作。

第二节 的几个例子中便是这种情形。我们使用诸如 botheitherpipecompose 来重构代码。一旦我们这样做了,便会很容易让函数转换为 pointfree 风格的。

我们来回顾一下 isEligibleToVote 这个例子,代码如下:

1
2
3
4
5
6
7
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18

const isCitizen = person => wasBornInCountry(person) || wasNaturalized(person)

const isEligibleToVote = person => isOver18(person) && isCitizen(person)

先从 isCitizen 开始。它接受一个 person, 然后将两个函数作用于该 person,将结果使用 || 组合起来。正如在 第二节 中学到的,可以使用 either 将两个函数组合成一个新函数,然后将该组合函数作用于该 person

1
const isCitizen = person => either(wasBornInCountry, wasNaturalized)(person)

可以使用 bothisEligibleToVote 做类似的处理。

1
const isEligibleToVote = person => both(isOver18, isCitizen)(person)

现在我们已经完成了这些重构,可以看到,这两个函数都遵循上面提到的模式:person 出现了两次,一次作为函数参数;一次放到最后,将组合函数作用其上。现在可以将它们重构为 pointfree 风格的代码:

1
2
const isCitizen = either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = both(isOver18, isCitizen)

为什么要这么做?

Pointfree 风格需要一定的时间才能习惯。可能并不需要所有的地方都没有参数。有时候知道某些 Ramda 函数需要多少参数,也是很重要的。

但是,一旦习惯了这种方式,它将变得非常强大:可以以非常有趣的方式将很多小的 pointfree 函数组合起来。

Pointfree 风格的优点是什么呢?人们可能会认为,这只不过是为了让函数式编程赢得 “优点徽章” 的学术活动而已(实际上并没有什么用处)。然而,我认为还是有一些优点的,即使需要花一些时间来习惯这种方式也是值得的:

  • 它让编程更简单、精练。这并不总是一件好事,但大部分情况下是这样的。
  • 它让算法更清晰。通过只关注正在组合的函数,我们可以在没有参数的干扰下,更好地了解发生了什么。
  • 它促使我们更专注于正在做的转换的本身,而不是正被转换的数据。
  • 它可以帮助我们将函数视为可以作用于不同数据的通用构建模块,而非对特定类型数据的操作。如果给数据一个名字,我们的思想便会被禁锢在:“需要在哪里使用我们的函数”;如果去掉参数,便会使我们更有创造力。

结论

Pointfree 风格也被成为 tacit 式编程(隐含式编程),可以使代码更清晰、更易于理解。通过代码重构将所有的转换组合成单一函数,我们最终会得到可以在更多地方使用的更小的构建块(函数)。

下一节

在当前示例中,我们尚未将所有代码都重构为 pointfree 的风格。还有一些代码是命令式的。大部分这种代码是处理对象和数组的。

我们需要找到声明式的方式来处理对象和数组。Immutability (不变性) 怎么样?我们如何以 “不变” (immutable) 的方式来操作对象和数组呢?

本系列的下一节,数据不变性和对象 将讨论如何以函数式和 immutable 的方式来处理对象。紧随其后的章节:数据不变性和数组 对数组也是相同的处理方式。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Declarative Programming》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第四篇。

第三节中,讨论了使用 “部分应用” 和 “柯里化” 技术来组合多元(多参数)函数。

当我们开始编写小的函数式构建块并组合它们时,发现必须写好多函数来包裹 JavaScript 操作符,比如算术、比较、逻辑操作符和控制流。这可能比较乏味,但 Ramda 将我们拉了回来,让事情变得有趣起来。

开始之前,先介绍一些背景知识。

命令式 vs 声明式

存在很多编程语言分类的方式,如静态语言和动态语言,解释型语言和编译型语言,底层和高层语言等等。

另一种划分的方式是命令式编程和声明式编程。

简单地说,命令式编程中,程序员需要告诉计算机怎么做来完成任务。命令式编程带给我们每天会用到的大量的基本结构:控制流(if-then-else 语句和循环),算术运算符(+-*/),比较运算符(===>< 等),和逻辑运算符(&&||!)。

而声明式编程,程序员只需告诉计算机我想要什么,然后计算机自己理清如何产生结果。

其中一种经典的声明式编程语言是 Prolog。在 Prolog 中,程序是由一组 “facts” (谓词) 和 一组 “rules” (规则) 组成。可以通过提问来启动程序。Prolog 的推理机使用 facts 和 rules 来回答问题。

函数式编程被认为是声明式编程的一个子集。在一段函数式程序中,我们定义函数,然后通过组合这些函数告诉计算机做什么。

即使在声明式程序中,也需要做一些命令式程序中的工作。控制流,算术、比较和逻辑操作仍然是必须使用的基本构建块。但我们需要找到一种声明式的方式来描述这些基本构建块。

声明式替换

由于我们使用 JavaScript (一种命令式语言)编程,所以在编写 “普通” JavaScript 代码时,使用标准的命令式结构也是正常的。

但当使用 “pipeline” 或类似的结构编写函数式变换时,命令式的结构并不能很好的工作。

算术

第二节 ,我们实现了一系列算术变换来演示 “pipeline”:

1
2
3
4
5
6
7
8
9
10
11
const multiply = (a, b) => a * b
const addOne = x => x + 1
const square = x => x * x

const operate = pipe(
multiply,
addOne,
square
)

operate(3, 4) // => ((3 * 4) + 1)^2 => (12 + 1)^2 => 13^2 => 169

注意我们是如何编写函数来实现我们想要的基本构建块的。

Ramda 提供了 addsubtractmultiplydivide 函数来替代标准的算术运算符。所以我们可以使用 Ramda 的 multiply 来代替我们自己实现的乘法,可以利用 Ramda 的柯里化 add 函数的优势来取代我们的 addOne,也可以利用 multiply 来编写 square

1
2
3
4
5
6
7
const square = x => multiply(x, x)

const operate = pipe(
multiply,
add(1),
square
)

add(1) 与增量运算符(++)非常相似,但 ++ 修改了被操作的值,因此它是 “mutation” 的。正如在 第一节 中所讲,Immutability 是函数式编程的核心原则,所以我们不想使用 ++--

可以使用 add(1)subtract(1) 来做递增和递减操作,但由于这两个操作非常常用,所以 Ramda 专门提供了 incdec

所以可以进一步简化我们的 “pipeline”:

1
2
3
4
5
6
7
const square = x => multiply(x, x)

const operate = pipe(
multiply,
inc,
square
)

subtract 是二元操作符 - 的替代,但还有一个表示取反的一元操作符 -。我们可以使用 multiply(-1),但 Ramda 也提供了 negate 来实现相同的功能。

Comparison (比较)

还是在 第二节,我们写了一些函数来确定一个人是否有资格投票。该代码的最终版本如下所示:

1
2
3
4
5
6
7
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18

const isCitizen = either(wasBornInCountry, wasNaturalized)

const isEligibleToVote = both(isOver18, isCitizen)

注意,上面的一些函数使用了标准比较运算符(===>=)。正如你现在所怀疑的,Ramda 也提供了这些运算符的替代。

我们来修改一下代码:使用 equals 代替 ===,使用 gte 替代 >=

1
2
3
4
5
6
7
const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => gte(person.age, 18)

const isCitizen = either(wasBornInCountry, wasNaturalized)

const isEligibleToVote = both(isOver18, isCitizen)

Ramda 还提供了其他比较运算符的替代:gt 对应 >lt 对应 <lte 对应 <=

注意,这些函数保持正常的参数顺序(gt 表示第一个参数是否大于第二个参数)。这在单独使用时没有问题,但在组合函数时,可能会让人产生困惑。这些函数似乎违反了 Ramda 的 “待处理数据放在最后” 的原则,所以我们在 pipeline 或类似的情况下使用它们时,要格外小心。这时,flip 和 占位符 (__) 就派上了用场。

除了 equals,还有一个 identical,可以用来判断两个值是否引用了同一块内存。

=== 还有一些其他的用途:可以检测字符串或数组是否为空(str === ''arr.length === 0),也可以检查变量是否为 nullundefined。Ramda 为这两种情况提供了方便的判断函数:isEmptyisNil

Logic (逻辑)

第二节 中(参见上面的相关代码)。我们使用 botheither 来代替 &&|| 运算符。我们还提到使用 complement 代替 !

当组合的函数作用于同一份输入值时,这些组合函数帮助很大。上述示例中,wasBornInCountrywasNaturalizedisOver18 都作用于同一个人上。

但有时我们需要将 &&||! 作用于不同的数值。对于这些情况, Ramda 提供了 andornot 函数。我以下列方式进行分类:andornot 用于处理数值;botheithercomplement 用于处理函数。

经常用 || 来提供默认值。例如,我们可能会编写如下代码:

1
const lineWidth = settings.lineWidth || 80

这是一个常见的用法,大部分情况下都能正常工作,但依赖于 JavaScript 对 “falsy” 值的定义。假设 0 是一个合法的设置选项呢?由于 0 是 “falsy” 值,所以我们最终会得到的行宽为 80 。

我们可以使用上面刚学到的 isNil 函数,但 Ramda 提供了一个更好的选择:defaultTo

1
const lineWidth = defaultTo(80, settings.lineWidth)

defaultTo 检查第二个参数是否为空(isNil)。如果非空,则返回该值;否则返回第一个值。

Conditionals (条件)

控制流在函数式编程中不是必要的,但偶尔也会有些用处。在 第一节 中讨论的集合迭代函数在大部分情况下都可以很好的取代循环,但 “条件” 仍然非常重要。

ifElse

我们来写一个函数,forever21,接受一个年龄,并返回下一个年龄。但正如名字所示,一旦成长到 21 岁,就一直保持这样。

1
const forever21 = age => age >= 21 ? 21 : age + 1

注意,条件(age >= 21)和第二个分支(age + 1)都可以写作 age 的函数。第一个分支(21)也可以重写成一个常量函数(() => 21)。现在我们有三个接受(或忽略)age 为参数的函数。

现在可以使用 Ramda 的 ifElse 函数了,这是一个相当于 if...then...else?: 的函数。

1
const forever21 = age => ifElse(gte(__, 21), () => 21, inc)(age)

如上所示,比较函数在进行组合时,可能并不是以我们想要的形式进行工作。所以在这里被迫引入了占位符(__)。我们也可以使用 lte

1
const forever21 = age => ifElse(lte(21), () => 21, inc)(age)

在这种情况下,我们不得不读作:“21岁小于或等于给定年龄”。但这样可读性很低、比较乱,所以我坚持使用占位符版本的函数。

constants (常量)

常量函数在这种情形下非常有用。你可能已经想到了,Ramda 为我们提供了一些便捷的方法。本例中,这个方法是 always

1
const forever21 = age => ifElse(gte(__, 21), always(21), inc)(age)

Ramda 还提供了 TF,作为 always(true) 和 always(false) 的缩写。

identity (恒等)

再来写一个函数:alwaysDrivingAge。该函数接受一个年龄,如果 gte 16,则将该年龄返回;但如果小于 16,则返回 16。这样任何人都可以伪造他们的驾驶年龄了,即使他们还没有达到。

1
const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), a => a)(age)

条件中的第二个分支(a => a)是函数式编程中的另一种常见的模式。它被称为恒等函数。也即,输出永远等于输入的函数。

正如你所想的,Ramda 为我们提供了 identity 函数。

1
const alwaysDrivingAge = age => ifElse(lt(__, 16), always(16), identity)(age)

identity 可以接受多个参数,但总是返回首个参数。如果想要返回除首个参数之外的参数,可以使用更通用的 nthArg 函数。但 nthArg 不如 identity 用的频繁。

when 和 unless

ifElse 代码中,其中一个条件分支为 identity 也很常见。所以 Ramda 也提供了便捷的方法。

如果像上例所示,第二个分支是 identity,可以用 when 代替 ifElse

1
const alwaysDrivingAge = age => when(lt(__, 16), always(16))(age)

如果第一个条件分支是 identity,可以用 unless。借助 gte(__, 16) 来翻转一下我们的条件,便可以使用 unless 了。

1
const alwaysDrivingAge = age => unless(gte(__, 16), always(16))(age)

cond

Ramda 还提供了 cond 函数,来代替 switch 语句或链式的 if...then...else 语句。

这里采用 Ramda 文档中的例子来展示 cond 的用法:

1
2
3
4
5
const water = temperature => cond([
[equals(0), always('water freezes at 0°C')],
[equals(100), always('water boils at 100°C')],
[T, temp => `nothing special happens at ${temp}°C`]
])(temperature)

我目前还不需要在 Ramda 代码中使用 cond。但我很多年前编写过 Common Lisp 代码,所以 cond 函数感觉就像是位老朋友。

结论

本节中展示了很多将命令式代码转为函数声明式代码的 Ramda 函数。

下一节

你可能已经注意到了,最后我们编写的几个函数(forever21alwaysDrivingAgewater)都接受一个参数,构建一个新函数,然后将该函数作用于参数。

这也是一种常见的模式,并且 Ramda 照例提供了一些简化这些代码的便捷方法。下一节中,Pointfree Style 将演示如何简化符合这种模式的代码。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Partial Application》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第三篇。

第二节中,讨论了各种函数组合的方式。最后,演示了 composepipe, 可以以 “pipeline” (管道)的形式对一系列函数进行调用。

在上篇文章中,简单的函数链式调用(“pipeline”)时,其中的被调用函数都是一元的(除了首个函数)。但如果要使用多元函数呢?

例如,假设有一个书籍对象的集合,我们想要找到特定年份出版的所有图书的标题。可以使用 Ramda 的集合迭代函数完成该需求:

1
2
3
4
5
6
7
const publishedInYear = (book, year) => book.year === year

const titlesForYear = (books, year) => {
const selected = filter(book => publishedInYear(book, year), books)

return map(book => book.title, selected)
}

如果能将 filtermap 组合成 “pipeline” 就好了,但我们并不知道该如何处理,因为 filtermap 都是二元函数。

如果不需要在 filter 中使用箭头函数会更好些。先来解决这个问题,并借此展示一些制作 “pipeline” 的知识。

高阶函数

在本系列文章的第一篇中,我们将函数视为 “一等结构”。一等函数可以作为参数传递给其他函数,也可以作为其他函数的返回值。我们一直在使用前者,但还没有见过后者(函数作为其他函数的返回值)。

获取或返回其他函数的函数称为 “高阶函数”。

在上面的示例中,我们传递了一个箭头函数给 filterbook => publishedInYear(book, year),但我们想去掉箭头函数。为了做到这点,需要一个函数:输入一本书,若该书是在指定年份出版的则返回 true。但还需要一个指定的年份,让该操作更加灵活。

为了解决这个问题,可以将 publishedInYear 变为返回另一个函数的函数。我将使用普通的语法来实现该函数,以便能够清晰地展示其内部具体实现,然后使用箭头函数实现一个更短版本的函数:

1
2
3
4
5
6
7
8
9
// Full function version:
function publishedInYear(year) {
return function(book) {
return book.year === year
}
}

// Arrow function version:
const publishedInYear = year => book => book.year === year

利用新实现的 publishedInYear,可以重写 filter 调用,从而消除箭头函数:

1
2
3
4
5
6
7
const publishedInYear = year => book => book.year === year

const titlesForYear = (books, year) => {
const selected = filter(publishedInYear(year), books)

return map(book => book.title, selected)
}

现在,当调用 filter 时,publishedInYear(year) 会立即调用,并返回一个接受 book 为参数的函数,这正是 filter 需要的。

部分应用函数

可以按上面的方式重写任何多参数函数。但我们不可能拥有所有我们想要的函数的源码;另外,很多情况下,我们可能还是希望以普通的方式调用多参数函数。

例如,在其他一些代码中,只是想检查一本书是否是在指定年份出版的,我们可能想要 publishedInYear(book, 2012),但现在不能再那么做了。相反,我们必须要用这种方式:publishedInYear(book)(2012)。这样做降低了代码的可读性,也很烦人。

幸运的是,Ramda 提供了两个函数:partialpartialRight,来帮我们解决这个问题。

这两个函数可以让我们不必一次传递所有需要的参数,也可以调用函数。它们都返回一个接受剩余参数的新函数,当所有参数都传入后,才会真正调用被包裹的原函数。

partialpartialRight 的区别在于参数传递的顺序:partial 先传递原函数左侧的参数,而 partialRight 先传递右侧的参数。

回到刚开始的例子,使用上面的一个函数来代替原来对 publishedInYear 的重写。由于刚开始我们只需要最右侧的参数:year,所以需要使用 partialRight.

1
2
3
4
5
6
7
const publishedInYear = (book, year) => book.year === year

const titlesForYear = (books, year) => {
const selected = filter(partialRight(publishedInYear, [year]), books)

return map(book => book.title, selected)
}

如果 pubilshedInYear 原本参数的顺序为 (year, book) ,而非 (book, year) ,则需要用 partial 代替 partialRight

注意,为被 partialpartialRight 包裹的函数提供的参数必须包裹在数组中,即使只有一个参数。我不会告诉你我已经忘记了多少次,导致出现令人困惑的错误信息:

1
First argument to _arity must be a non-negative integer no greater than ten

柯里化(Curry)

如果到处使用 partialpartialRight 的话,会让代码变得冗长乏味;但是,将多元函数以一系列一元函数的形式调用同样不好。

幸运的是,Ramda 给我们提供了一个解决方案:curry

Currying(柯里化) 是函数式编程的另一个核心概念。从技术角度讲,一个柯里化了的函数是一系列高阶一元函数,这也是我刚刚抱怨过的。在纯函数式语言中,柯里化函数在调用时,语法上看起来和调用多个参数没有什么区别。

但由于 Ramda 是一个 JavaScript 库,而 JavaScript 并没有很好的语法来支持一系列一元函数的调用,所以作者对传统柯里化的定义放宽了一些。

在 Ramda 中,一个柯里化的函数只能用其参数的子集来调用,它会返回一个接受其余参数的新函数。当使用它的所有参数调用,真正的原函数将被调用。

柯里化的函数在下列两种情况下工作的都很好:

  1. 可以按正常情况下使用所有参数调用它,它可以像普通函数一样正常工作;
  2. 也可以使用部分参数来调用它,这时它会像使用 partial 一样工作。

注意,这种灵活性带来了一些性能上的损失,因为 curry 需要搞清楚函数的调用方式,然后确定该做什么。一般来说,我只有需要在多个地方对同一个函数使用 partial 的时候,才会对函数进行柯里化。

接下来写一个柯里化版本的 publishedInYear 函数。注意,curry 会像 partial 一样工作;并且没有 partialRight 版本的 curry 函数。对这方面后续会有更多讨论,但现在我们需要将 publishedInYear 的参数翻转一下,以便让参数 year 在最前面。

1
2
3
4
5
6
7
const publishedInYear = curry((year, book) => book.year === year)

const titlesForYear = (books, year) => {
const selected = filter(publishedInYear(year), books)

return map(book => book.title, selected)
}

现在可以只使用参数 year 来调用 publishedInYear,并返回一个新函数,该函数接受参数 book 并执行原函数。但是,仍然可以按普通方式对它调用:publishedInYear(2012, book),不需要写烦人的语法 )(。所以,柯里化的函数在两种情况下都能很好地工作。

参数的顺序

注意,为了让 curry 工作,我们不得不对参数的顺序进行翻转。这在函数式编程中非常常见,所以几乎所有的 Ramda 函数都将待处理的数据放到参数列表的最后面。

你可以将先期传入的参数看作对操作的配置。所以,对于 publishedInYear,参数 year 作为配置(需要查找的年份),而参数 book 作为被处理的数据(被查找的对象)。

我们已经在集合迭代函数中见过这样的例子。它们都将集合作为最后一个参数,这样可以使这种风格的编程更容易些。

顺序错误的参数

如果不改变 publishedInYear 的顺序,还可以继续使用柯里化特性的优势吗?

当然可以了,Ramda 提供了几个选择。

flip

第一个选择是 flipflip 接受一个多元函数(元数 >= 2),返回一个元数相同的新函数,但前 2 个参数的顺序调换了。它主要用于二元函数,但也可以用于一般函数。

使用 flip,我们可以恢复 publishedInYear 参数的初始的顺序:

1
2
3
4
5
6
7
const publishedInYear = curry((book, year) => book.year === year)

const titlesForYear = (books, year) => {
const selected = filter(flip(publishedInYear)(year), books)

return map(book => book.title, selected)
}

多数情况下,我更喜欢使用方便的参数顺序,但如果用到不能自己掌控的函数,flip 是一个好的选择。

placeholder (占位符)

更通用的选择是使用 “placeholder” 参数(__

假设有一个三元柯里化的函数,并且我们想传入第一个和最后一个参数,中间参数后续再传,应该怎么办呢?我们可以使用 “占位符” 作为中间参数:

1
2
3
const threeArgs = curry((a, b, c) => { /* ... */ })

const middleArgumentLater = threeArgs('value for a', __, 'value for c')

可以在函数调用中多次使用 “占位符”。例如,如果只想传递中间参数呢?

1
2
3
const threeArgs = curry((a, b, c) => { /* ... */ })

const middleArgumentOnly = threeArgs(__, 'value for b', __)

也可以使用 “占位符” 代替 flip

1
2
3
4
5
6
7
const publishedInYear = curry((book, year) => book.year === year)

const titlesForYear = (books, year) => {
const selected = filter(publishedInYear(__, year), books)

return map(book => book.title, selected)
}

我觉得这个版本的可读性更好,但如果需要频繁使用参数顺序翻转的 publishedInYear,我可能会使用 flip 定义一个辅助函数,然后在任何用到它的地方使用辅助函数。在后续文章中会看到一些示例。

注意, __ 仅适用于柯里化的函数,而 partialpartialRightflip 适用于任何函数。如果需要对某个普通函数使用 __,可以先用 curry 将其包裹起来。

来做一条管道(pipeline)

现在看看能否将我们的 filtermap 调用放入 “pipeline” (管道)中?下面是代码当前的状态,使用了方便的参数顺序的 publishedInYear

1
2
3
4
5
6
7
const publishedInYear = curry((year, book) => book.year === year)

const titlesForYear = (books, year) => {
const selected = filter(publishedInYear(year), books)

return map(book => book.title, selected)
}

在上一节中,我们了解了 pipecompose,但我们还需要另一部分信息,以便能够使用上面所学的知识。

缺少的信息是:几乎所有的 Ramda 函数都是默认柯里化的,包括 filtermap。所以 filter(publishedInYear(year)) 是完全合法的,它会返回一个新函数,该函数等待我们传递 books 给它,map(book => book.title) 也是如此。

现在可以编写 “pipeline” 了:

1
2
3
4
5
6
7
const publishedInYear = curry((year, book) => book.year === year)

const titlesForYear = (books, year) =>
pipe(
filter(publishedInYear(year)),
map(book => book.title)
)(books)

我们来更进一步,将 titlesForYear 的参数顺序也调换一下,这样更符合 Ramda 中待处理数据放在最后的约定。也可以将该函数进行柯里化,以便其在后续的 “pipeline” 中使用。

1
2
3
4
5
6
7
8
const publishedInYear = curry((year, book) => book.year === year)

const titlesForYear = curry((year, books) =>
pipe(
filter(publishedInYear(year)),
map(book => book.title)
)(books)
)

结论

本文可能是这个系列中讲解最深的一篇。部分应用和柯里化可能需要花一些时间和精力来熟悉和掌握。但一旦学会,他们会以一种强大的方式将数据处理变得更加函数式。

它们引导你通过创建包含许多小而简单代码块的 “pipeline” 的方式,来构建数据处理程序。

下一节

为了以函数式的方式编写代码,我们需要用 “声明式” 的思维代替 “命令式” 思维。要做到这点,需要找到一种函数式的方式来表示命令式的结构。声明式编程 将会讨论这些想法。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Combining Functions》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第二篇。

第一节中,介绍了 Ramda 和函数式编程的一些基本思想,如函数、纯函数和数据不变性。并介绍了如何入门:可以从集合迭代函数(如 forEachmapreduce)开始。

简单组合

一旦熟悉了可以将函数传递给其他函数,你可能会开始找将多个函数组合在一起的场景。

Ramda 为简单的函数组合提供了一些函数。我们来看看。

Complement

在上一节,我们使用 find 来查找列表中的首个偶数。

1
2
const isEven = x => x % 2 === 0
find(isEven, [1, 2, 3, 4]) //=> 2

如果想找首个奇数呢?我们可以随手写一个 isOdd 函数并使用它。但我们知道任何非偶整数都是奇数,所以可以重用 isEven 函数。

Ramda 提供了一个更高阶的函数:complement,给它传入一个函数,返回一个新的函数:当原函数返回 “假值” 时,新函数返回 true;原函数返回 “真值” 时,新函数返回 false,即新函数是原函数的补函数。

1
2
3
const isEven = x => x % 2 === 0

find(complement(isEven), [1, 2, 3, 4]) // --> 1

更进一步,可以给 complement 过的函数起个名字,这样新函数便可以复用:

1
2
3
4
const isEven = x => x % 2 === 0
const isOdd = complement(isEven)

find(isOdd, [1, 2, 3, 4]) // --> 1

注意,complement 以函数的方式实现了逻辑非操作(!, not)的功能。

Both/Either

假设我们正在开发一个投票系统,给定一个人,我们希望能够确定其是否有资格投票。根据现有知识,一个人必须年满 18 岁并且是本国公民,才有资格投票。成为公民的条件:在本国出生,或者后来加入该国国籍。

1
2
3
4
5
6
7
const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18

const isCitizen = person => wasBornInCountry(person) || wasNaturalized(person)

const isEligibleToVote = person => isOver18(person) && isCitizen(person)

上面代码实现了我们的需求,但 Ramda 提供了一些方便的函数,以帮助我们精简代码。

both 接受两个函数,返回一个新函数:当两个传入函数都返回 truthy 值时,新函数返回 true,否则返回 false

either 接受两个函数,返回一个新函数:当两个传入函数任意一个返回 truthy 值时,新函数返回 true,否则返回 false

我们可以使用这两个函数来简化 isCitizenisEligibleToVote

1
2
const isCitizen = either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = both(isOver18, isCitizen)

注意,both 以函数的方式实现了逻辑与(&&)的功能,either 实现了逻辑或(||)的功能。

Ramda 还提供了 allPassanyPass,接受由任意多个函数组成的数组作为参数。如名称所示,allPass 类似于 both,而 anyPass 类似于 either

Pipelines(管道)

有时我们需要以 pipeline 的方式将多个函数依次作用于某些数据。例如,接受两个数字,将它们相乘,加 1 ,然后平方。我们可以这样写:

1
2
3
4
5
6
7
8
9
10
11
12
13
const multiply = (a, b) => a * b
const addOne = x => x + 1
const square = x => x * x

const operate = (x, y) => {
const product = multiply(x, y)
const incremented = addOne(product)
const squared = square(incremented)

return squared
}

operate(3, 4) // => ((3 * 4) + 1)^2 => (12 + 1)^2 => 13^2 => 169

注意,每次操作是对上次操作的结果进行处理。

pipe

Ramda 提供了 pipe 函数:接受一系列函数,并返回一个新函数。

新函数的元数与第一个传入函数的元数相同(元数:接受参数的个数),然后顺次通过 “管道” 中的函数对输入参数进行处理。它将第一个函数作用于参数,返回结果作为下一个函数的入参,依次进行下去。“管道” 中最后一个函数的结果作为 pipe 调用的最终结果。

注意,除首个函数外,其余的函数都是一元函数。

了解这些后,我们可以使用 pipe 来简化我们的 operate 函数:

1
2
3
4
5
const operate = pipe(
multiply,
addOne,
square
)

当调用 operate(3, 4) 时,pipe34 传给 multiply 函数,输出 12,然后将 12 传给 addOne,返回 13,然后将 13 传给 square,返回 169,并将 169 作为最终 operate 的最终结果返回。

compose

另一种编写原始 operate 函数的方式是内联所有暂时变量:

1
const operate = (x, y) => square(addOne(multiply(x, y)))

这样更紧凑,但也更不便于阅读。然而这种形式可以使用 Ramda 的 compose 函数进行重写。

compose 的工作方式跟 pipe 基本相同,除了其调用函数的顺序是从右到左,而不是从左到右。下面使用 compose 来重写 operate

1
2
3
4
5
const operate = compose(
square,
addOne,
multiply
)

这与上面的 pipe 几乎一样,除了函数的顺序是相反的。实际上,Ramda 中的 compose 函数的内部是用 pipe 实现的。

我一直这样思考 compose 的工作方式:compose(f, g)(value) 等价于 f(g(value))

注意,与 pipe 类似,compose 中的函数除最后一个外,其余都是一元函数。

compose 还是 pipe?

具有命令式编程背景的人可能觉得 pipe 更容易理解,因为可以按照从左往右的顺序进行阅读。但 compose 更容易对如上所示的嵌套函数进行转换。

我也不太清楚什么时候该用 compose,什么时候该用 pipe。由于它们在 Ramda 中基本等价,所以选择用哪个可能并不重要。只要根据自己的情况选择合适的即可。

结论

通过特定的方式进行函数组合,我们已经可以开始编写更强的函数了。

下一节

你可能已经注意到了,在进行函数组合时,我们多数情况下都可以省略函数参数。只有在最终调用组合好的函数时,才传入参数。

这在函数式编程中非常常见,我们将在下一节 Partial Application (部分应用)进行更多详细介绍。我们还会讨论如何组合多元(多参数)函数。

译者注:本文翻译自 Randy Coulman 的 《Thinking in Ramda: Getting Started》,转载请与原作者本人联系。下面开始正文。


本文是函数式编程系列文章:Thinking in Ramda 的第一篇。

本系列文章使用 Ramda JavaScript 库进行演示。许多理论、方法同样适用于其他函数式 JavaScript 库,如 UnderscoreLodash

我将尽量用通俗、非学术性的语言演示函数式编程。一方面想让更多的人理解该系列文章;另一方面本人在函数式编程方面造诣尚浅。

Ramda

我已经在博客中多次提到过 Ramda JavaScript 库:

我发现 Ramda 是一个精心设计的库:包含许多 API ,来简洁、优雅进行 JavaScript 函数式编程。

如果你想在阅读本系列文章时进行 Ramda 实验,Ramda 网站有一个 repl 运行环境

函数

正如名字所示,函数式编程与函数有很大的关系。为了演示,我们定义一个函数为一段可重用的代码:接受 0 到多个参数,返回单个值。

下面是一个简单的 JavaScript 函数:

1
2
3
function double(x) {
return x * 2
}

使用 ES6 箭头函数,可以以更简洁的方式实现相同的函数。现在就提一下,是因为在接下来会大量用到箭头函数:

1
const double = x => x * 2

几乎每种语言都会支持函数调用。

有些语言更进一步,将函数视为一等公民:可以像使用普通类型的值的方式使用函数。例如:

  • 使用变量或常量引用函数
  • 将函数作为参数传递给其他函数
  • 将函数作为其他函数的返回值

JavaScript 就是一种这样的语言,我们将利用它的这一优势进行编程。

纯函数

在进行函数式编程时,使用所谓的 “纯” 函数进行工作将变得非常重要。

纯函数是没有副作用的函数。它不会给任何外部变量赋值,不会获取输入,不会产生 “输出”,不会对数据库进行读写,不会修改输入参数等。

纯函数的基本思想是:相同的输入,永远会得到相同的输出。

当然可以用非纯函数编程(而且这也是必须的,如果想让程序做任何有趣的事情),但在大多数情况下,需要保持大部分函数是纯函数。(译者注:并不是说,要禁止使用一切副作用,而是说,要让它们在可控的范围内发生)

IMMUTABILITY

函数式编程的另一个重要概念是 “Immutability”。什么意思呢?“Immutability” 是指 “数据不变性”。

当以 immutable 方式工作时,一旦定义了某个值或对象,以后就再也不会改变它了。这意味着不能更改已有数组中的元素或对象中的属性。

如果想改变数组或对象中的元素时,需要返回一份带有更改值的新拷贝。后面文章将会对此做详细介绍。

Immutability 和 纯函数息息相关。由于纯函数不允许有副作用,所以不允许更改函数体外部的数据结构。纯函数强制以 immutable 的方式处理数据。

从哪里开始呢?

开始以函数式思维思考最简单的方式是,使用集合迭代函数代替循环。

如果用过具备这些特性的其他语言(如 Ruby、Smalltalk),你可能已经熟悉了这些特性。

Martin Fowler 有几篇关于 “Collection PipeLines” 非常好的文章,展示了如何使用这些函数 以及如何将现有代码重构为 collection pipelines

注意,所有这些函数 Array.prototype 都有(除了 reject)。因此不需要 Ramda 也可以使用它们。但是,为了保持和本系列其他文章一致,本文将使用 Ramda 版本的函数。

foreach

不必写显式的循环,而是用 forEach 函数代替循环。示例如下:

1
2
3
4
5
6
7
// Replace this:
for (const value of myArray) {
console.log(value)
}

// with:
forEach(value => console.log(value), myArray)

forEach 接受一个函数和一个数组,然后将函数作用于数组的每个元素。

虽然 forEach 是这些函数中最简单的,但在函数式编程中它可能是最少用到的一个。forEach 没有返回值,所以只能用在有副作用的函数调用中。

map

下一个要学习的最重要的函数是 map。类似于 forEachmap 也是将函数作用于数组的每个元素。但与 forEach 不同的是,map 将函数的每个返回值组成一个新数组,并将其返回。示例如下:

1
map(x => x * 2, [1, 2, 3]) //=> [2, 4, 6]

这里使用了匿名函数,但我们也可以在这里使用具名函数:

1
2
const double = x => x * 2
map(double, [1, 2, 3])

filter/reject

接下来,我们来看看 filterreject。就像名字所示,filter 会根据断言函数的返回值从数组中选择元素,例如:

1
2
const isEven = x => x % 2 === 0
filter(isEven, [1, 2, 3, 4]) //=> [2, 4]

filter 将断言函数(本例中为 isEven)作用于数组中的每个元素。每当断言函数返回 “真值” 时,相应的元素将包含到结果中;反之当断言函数返回为 “falsy” 值时,相应的元素将从结果数组中排除掉(过滤掉)。

rejectfilter 的补操作。它保留使断言函数返回 “falsy” 的元素,排除使断言函数返回 “truthy” 的元素。

1
reject(isEven, [1, 2, 3, 4]) //=> [1, 3]

find

find 将断言函数作用于数组中的每个元素,并返回第一个使断言函数返回真值的元素。

1
find(isEven, [1, 2, 3, 4]) //=> 2

reduce

reduce 比之前遇到的其他函数要复杂一些。了解它是值得的,但如果刚开始不太好理解,不要被它挡住。你可以在理解它之前继续学习其他知识。

reduce 接受一个二元函数(reducing function)、一个初始值和待处理的数组。

归约函数的第一个参数称为 “accumulator” (累加值),第二个参数取自数组中的元素;返回值为一个新的 “accumulator”。

先来看一个示例,然后看看会发生什么。

1
2
3
const add = (accum, value) => accum + value

reduce(add, 5, [1, 2, 3, 4]) //=> 15
  1. reduce 首先将初始值 5 和 数组中的首个元素 1 传入归约函数 addadd 返回一个新的累加值:5 + 1 = 6
  2. reduce 再次调用 add,这次使用新的累加值 6 和 数组中的下一个元素 2 作为参数,add 返回 8
  3. reduce 再次使用 8 和 数组中的下个元素 3 来调用 add,输出 11
  4. reduce 最后一次调用 add,使用 11 和 数组中的最后一个元素 4 ,输出 15
  5. reduce 将最终累加值 15 作为结果返回。

结论

从这些集合迭代函数开始,需要逐渐习惯将函数传入其他函数的编程方式。你可能在其他语言中用过,但没有意识到正在做函数式编程。

下一节

本系列的下一篇文章,函数组合 将演示怎样以新的、有趣的方式对函数进行组合。

Applicative类 的定义

1
2
3
class (Functor f) => Applicative f where
pure :: a -> f a
(<*>) :: f (a -> b) -> f a -> f b

Applicative 中定义了 pure<*>

Applicative Functor 的几个实例

Maybe

1
2
3
4
instance Applicative Maybe where
pure = Just
Nothing <*> _ = Nothing
(Just f) <*> something = fmap f something

Applicative 相较于 Functor 的改进之处:

with the Applicative type class, we can chain the use of the <*> function, thus enabling us to seamlessly operate on several applicative values instead of just one. For instance, check this out:

1
pure(+) <*> Just 3 <*> Just 5 -- Just 8

lift 相当于 pure

Applicative 中还定义了 <$>

<$> 相当于中缀版的 fmap,但应用于 Applicative 的链式调用特别方便

1
2
3
(<$>) :: (Functor f) => (a -> b) -> f a -> f b
f <$> x = fmap f x
-- pure f <*> x <*> y <*> ... === fmap f x <*> y... === f <$> x <*> y...

List 也是 Applicative Functor

1
2
3
instance Applicative [] where
pure x = [x]
fs <*> xs = [f x | f <- fs, x <- xs]

理解了 haskell 中 List 的 <*> 也就理解了 Ramda 中的 liftN
将 fs 中的每个 f map 到 xs 中的每个 x。

例如

1
2
3
fs = [f1, f2, f3]
xs = [x1, x2]
fs <*> xs === [f1 x1, f1 f2, f2 x1, f2 x2, f3 x1, f3 x2]

函数 (->) r 也是 Applicative Functor 很有意思

1
2
3
Function :: ((->) r)
Function a = ((->) r) a
= r -> a
1
2
3
instance Applicative ((->) r) where
pure x = (\_ -> x)
f <*> g = \x -> f x (g x)
1
2
(pure 3) "blah" -- 3
(+) <$> (+3) <*> (*100) $ 5 -- 508

<$> + <*> 大致对标 Ramda 中的 converge

Laws

1. Functor Laws

1
2
fmap id = id
fmap (g . f) = fmap g . fmap f

2. Applicative Functor Laws

1
2
3
4
pure id <*> v = v                             -- Identity
pure f <*> pure x = pure (f x) -- Homomorphism
u <*> pure y = pure ($ y) <*> u -- Interchange
pure (.) <*> u <*> v <*> w = u <*> (v <*> w) -- Composition

a bonus law

1
fmap f x = pure f <*> x

3. Monad Laws

1
2
3
return a >>= k  =  k a
m >>= return = m
m >>= (x -> k x >>= h) = (m >>= k) >>= h