[TIR-04]Ramda思维:声明式编程思想

标签:nodejs, functional programming


这是【Ramda思维】这个讨论函数式编程思想系列文章的第04篇。

(英文原文链接在此)

上一篇(第3篇)中,我们讨论了如何使用Currying及部分应用函数(partial application)的技术来组合那些不只一个参数的函数。

在我们开始写一些短小的函数式的程序块并将其组合时,你会发现我们需要写很多函数来封装JavaScript语言的基本运算操作,例如算术运算、比较、逻辑及流程控制。写这些函数很枯燥乏味,但幸好Ramda这个FP库会帮我们解决这个烦恼。

但是,我们需要了解一些背景知识。

声明式编程 VS 指令式编程

有很多方法来分类各种编程语言/风格。例如静态类型vs动态类型、解释型语言vs编译型 语言、底层vs高级语言…等等。

另一个类似的划分方式为声明式vs指令式编程范式。

本文并不深入探讨所有的编程范式,简单地说,指令式编程范式是这样一种编程风格:由程序员告诉电脑要做什么,并且明确写清楚如何去做。这样就导致了我们日常有一大堆构建元素:控制流(if-then-else语句及循环结构),算术运算符(+,-,*,/),比较运算符(===,>,<,等等),还有逻辑运算符(&&,||,!)。

而声明式编程范式则是:程序员告诉电脑要做什么,但只告诉电脑人类想要的是什么(不像指令式连处理步骤都要写清楚)。然后电脑需要自己去思考如何计算出结果。

其中一个经典的声明式编程语言叫Prolog。在Prolog语言里,一个程序由一组事实及推理规则组成。通过提问题来开始Prolog的程序,然后Prolog的推理引擎使用你定义的事实及推理法则来推理出你问题的答案。

函数式编程也是声明式编程范式的其中一个子集。在一个函数式程序中,我们定义一系列的函数,然后通过组合这些函数来告诉电脑我们想要什么。

当然,即使在声明式的程序里,也需要做类似指令式程序里的简单任务。控制流程、算术运算、比较、逻辑分支等仍然是声明式编程里的基本构建元素。但我们会用声明式的风格来表达以上构建元素。

声明式的构建元素

由于我们使用的Javascript是一种指令式的编程语言,所以当我们在写“常规”的Javascript代码时,使用标准的指令构建元素也是可以的。

但当我们在使用pipeline之类的“管道”或类似的构造元素来写函数式的变换时,指令式的构造元素就不太适用了。

下面我们看看Ramda提供了什么基本构建元素给我们来解决这个问题。

数学运算

回到第2章,我们实现了一系列的数学运算变换来演示何为“管道”(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为数学运算提供了add,subtract,multiplydivide函数。所以我们可以直接使用Ramda提供的multiply来替换上面自定义的对应函数,还能利用Ramda提供的Curry化的add函数来代替上面的addOne,而multiply其实也能使用square来替代之:

// 使用Ramda的数学运算函数

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

add(1)与我们熟知的自增运算符(++)很相似,不同之处在于++会修改被自增的变量的值,所以它是会变异的。我们在第01篇提到,”不变异“是函数式编程的一个核心原则,所以我们一般不使用++或类似的--

我们一般使用add(1)subtract(1)来做递增及递减的操作,而这两个操作很常要用到,所以Ramda分别提供了incdec给我们使用。

因此,现在可以把我们的管道再进一步简化如下:

// 使用Ramda的inc函数

const square = x => multiply(x, x)
 
const operate = pipe(
  multiply,
  inc,
  square
)

subtract是二元运算符-的对应函数,但同时我们知道还有个取负数的一元运算符-。当然也可以使用multiply(-1)来实现取负数,但Ramda也提供了negate函数来实现此操作。

比较

同样地,在第2篇时,我们写了一些函数来计算一个人是否有投票权。当时最终版的代码如下:

// 是否有投票权

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同样也提供了对应的函数来替代它们。

下面我们使用equalsgte函数来分别替换上面的===>=

// 使用Ramda的比较函数

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

这些函数的参数顺序似乎与我们熟悉的一样(第1个参数大于第2个参数?)。单独使用的时候当然没有问题,但当需要做函数组合的时候就有点问题了。这些函数的设计似乎违反了Ramda的“数据放在最后一个参数”的原则,所以当我们在管道或类似场景使用这些函数时就需要多加注意。这个时候,flip及点位符函数(__)就能派上用场了。

同样地,除了equals外,Ramda还提供了一个identical函数来判断两个值是否在同一个内存引用。

===有很多常见的使用场景:例如检测一个字符串或数组是否为空( str === '' arr.length === 0 ),再例如检测一个变量是否为nullundefined。Ramda同样地提供了相应的函数:isEmptyisNil

逻辑运算

第2篇(或就在上面),我们使用botheither函数来替代&&||运算符。也提及到使用complement来替代!

当我们要组合处理的是同一个值时,这些组合起来的函数运作得很好。在上例中,wasBornInCountrywasNaturalizedisOver18都是对一个人进行操作运算。

但有时我们需要分别应用&&||!运算符到不同的值。为了处理这种情况,Ramda提供了andornot函数。我用这个方式来区分这些函数:andornot操作数值,而botheithercomplement操作的是函数。

||的常见用法是提供默认值。例如,我们经常会这样写:


const lineWidth = settings.lineWidth || 80

这是一个常见的写法,而且可用,但这种写法依赖于JavaScript的”逻辑假”定义。如果0是一个有效的设定呢?由于0在JavaScript的逻辑运算里会被视作“假”,所以上例的结果是得到一个lineWidth值为80。

当然我们可以使用刚刚提到的isNil函数来解决这个问题,但其实Ramda提供了另一个更优雅的选择:defaultTo

// 使用defaultTo函数

const lineWidth = defaultTo(80, settings.lineWidth)

defaultTo会使用isNil来检测第2个参数是否为空,如果非空,则返回这个参数的值,否则返回第1个参数的值(默认值)。

条件分支

其实在函数式的程序里,流程控制并不太必要,但偶尔还是会用到。我们在第1篇博文里讨论到的集合迭代函数会处理大部分的循环场景,但条件分支还是相当重要的。

IFELSE

下面我们写一个函数,forever21,这个函数只有一个参数”age”,返回下一年的岁数。但是正如函数名所暗示的,如果参数值大于或等于21,则永远只返回21.

// 永远21岁

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

上面我们的条件( age >= 21 )及第2个分支( age + 1 )其实可以写成 age 的函数。可以把第一个分支(21)重写成一个常量函数( () => 21 )。现在,我们有3个函数都接受(或忽略)一个age参数。

这时,我们就可以使用Ramda的ifElse函数,这个函数与我们习惯的if...then...else结构的作用一样,或类似三元操作符(?:)。

// 使用 ifElse

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

上面也提到,当使用比较函数来组合函数时,有时结果并非如我们所想,所以这里需要引入占位符(___)。另外我们也可以使用lte来实现:

// 使用 lt 来替代 gte

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

上例代码可这样理解:”21小于或等于age”。后面我们会坚持使用占位符的版本,因为该版本能提高代码可读性及减少困惑。

常量函数

在上面的场景里,常量函数就很有用了。Ramda提供了一个简短的常量函数:always

// 使用always常量函数

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

Ramda也提供了另外两个快捷函数TF来替代always(true)always(false)

INDENTITY函数

现在看看另一个函数,alwaysDrivingAge。这个函数只有一个age参数,当age大于等于16时,函数返回值为输入参数。但当age小于16时,函数返回16。这个函数其实就相当于让人可以假装自己到了能考驾照的年龄(即使实际上未到)。

// 驾照年龄

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

这个条件的第2个分支( a => a )是另一个在函数式编程范式中常见的模式。这叫“身份函数”。即这个函数永远原样返回输入的参数值。

你应该能猜到了,Ramda同样提供了一个identity函数。

// 使用indentity函数

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

identity能接受多个参数,但只返回第一个参数的值。如果我们想返回的是其它第n个参数的值,可以使用nthArg函数。但nthArg没有identity常用。

WHEN 及 UNLESS

在一个ifElse语句里有一个分支是identity的情况其实很常见,所以Ramda为此场景也提供了一个快捷函数。

在我们的例子中,第2个分支是identity时,可以使用when来替代ifElse

// 使用when

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

如果第一个分支是identity,则可使用unless。如果把我们的条件分支反过来写,可以使用unless来替代gte(__, 16)

// 使用 unless

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

COND函数(替代switch)

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

下面使用Ramda文档里的例子来介绍这个cond的使用方法:

// 使用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代码里还没正式用过这个函数,如果你写过多年Common Lisp程序的话,这个cond对你而言应该就很熟悉了。

总结

本文介绍的一系列Ramda函数其实都是为了使我们原来指令式的代码变成声明式的函数式风格。

下一篇

最后举的几个自定义函数的例子(forever21drivingAgewater)都是接受一个参数,构建出一个新的函数,然后应用这个函数到一个参数。

这就是一个常见的模式,而且Ramda提供了相关的工具函数来提高代码可读性。在下一篇,无指针风格将会讲解如何用这个模式去进一步地简化我们的函数。



上篇: [TIR-03]Ramda思维:部分应用函数