Thinking in Ramda: 声明式编程

该文章来自wangzengdi's Blog

译者注:本文翻译自 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":

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

const square = x => multiply(x, x)

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

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

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

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

const square = x => multiply(x, x)

const operate = pipe(
 multiply,
 inc,
 square
)

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

Comparison (比较)

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

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 替代 >=

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 用于处理函数。

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

const lineWidth = settings.lineWidth || 80

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

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

const lineWidth = defaultTo(80, settings.lineWidth)

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

Conditionals (条件)

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

ifElse

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

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

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

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

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

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

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

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

constants (常量)

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

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

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

identity (恒等)

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

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

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

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

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

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

when 和 unless

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

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

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

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

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

cond

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

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

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 将演示如何简化符合这种模式的代码。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容