网络埋伏纪事

实例讲解 JS 函数式编程 (第二部分)

网络埋伏纪事 · 2016-12-14翻译 · 283阅读 原文链接

在第一部分中,我们通过两个示例讨论了:函数式编程基础知识、柯里化、纯函数、Fantasy-land 规范、Functor、Monad、Maybe Monad 以及 Either Monad。

在本部分,我们将涉及:Applicative、curryN 函数 和 Validation Applicative。

感谢函数式编程专家 Brian Lonsdorfkeithalexander 及其他人的评论。

示例 3 — 给潜在的空对象赋值

用到的函数式编程概念:Applicative

用例: 如果用户登录进来,我们又正在促销(即有折扣),就给用户打折。

假设我们正使用如下的 applyDiscount 方法。可想而知,只要 user(左手边)或者 discount(右手边)有一个为 null,applyDiscount 就会抛出 null 错误。

// 如果 user 和 discount 都存在,就给 user 对象添加 discount。
// 如果 user 或者 discount 有一个为 null,就抛出 null 错误

const applyDiscount = (user, discount) => {

    let userClone = clone(user);// 用某些库作个副本
    userClone.discount = discount.code; // 这一行

   return userClone;
}

下面我们来看看如何用 applicative 解决此问题。

Applicative:

类如果有 ap 方法,并且实现了 Applicative 规范,就被称为 Applicative。Applicative 可以用在处理等式左边(user)和右边(discount)空值的函数中。

Maybe Monad(以及每个 Monad)实现了 ap 规范,因此它不仅是 Monad,还是 Applicative。所以我们可以使用 Maybe Monad 在函数级上处理 null。

下面我们来看看如何解决使用 Maybe 让 applyDiscount 被用作为 applicative。

第 1 步: 将潜在的空值用 Maybe Monad 包起来

const maybeUser = Maybe(user);
const maybeDiscount = Maybe(discount);

第 2 步: 重写该函数,并对它柯里化,这样就能一次传递一个参数

//重写该函数,并对它柯里化,这样就能一次传递一个参数
var applyDiscount = curry(function(user, discount) {     
       user.discount = discount.code;     
       return user; 
});

第 3 步: 通过 map 传递第一个参数(maybeUser)给 applyDiscount

// 通过 map 传递第一个参数给 applyDiscount
const maybeApplyDiscountFunc = maybeUser.map(applyDiscount);

注意,因为 applyDiscount 被柯里化了,而 map 只接受一个参数,所以返回的结果(maybeApplyDiscountFunc)将是一个包着 applyDiscount 函数的 Maybe,在其闭包内,现在有 maybeUser(第一个参数)。

也就是说,现在我们有了一个包在一个 Monad 中的函数!

第 4 步: 处理maybeApplyDiscountFunc

在本阶段,maybeApplyDiscountFunc 可以是:

  1. 如果用户实际存在,那么 maybeApplyDiscountFunc 是一个包在 Maybe 内的函数。
  2. 如果用户不存在,那么 maybeApplyDiscountFunc 将是 Nothing(Maybe 的子类)。

如果用户不存在,那么就返回 Nothing,进一步的交互就会被完全忽略。所以,如果我们传递第二个参数,什么都不会发生,并且也不会抛出 Null 错误。

但是在用户实际存在的情况下,我们试着通过 mapmaybeApplyDiscountFunc 传递第二个参数,从而像下面这样执行该函数:

maybeDiscount.map(maybeApplyDiscountFunc)! // 问题!

哦豁!当 maybeApplyDiscountFunc 函数本身是在一个 Maybe 中时,map 不知道如何执行该函数!

这就是为什么我们需要一个不同的接口来处理这种情况的原因。就是要用 ap

第 5 步: 我们来回顾一下 ap 函数。ap 函数带有另一个 Maybe monad 为参数,并且将它当前存储的函数传递/应用到该 Maybe。

class Maybe {
  constructor(val) {
    this.val = val;
  }
  ...
  ...
  // ap 带有另一个 maybe,并且应用它存储的函数到该 Maybe。
  // this.val 必须是一个函数或者 Nothing(并且不能是字符串或者 int)
  ap(differentMayBe) { 
     return differentMayBe.map(this.val); 
  }
}

所以我们只需应用(即 apmaybeApplyDiscountFuncmaybeDiscount 即可,而不是像上面那样使用 map,而且它很管用!

maybeApplyDiscountFunc.ap(maybeDiscount)

// 在内部它实际上是做如下事情,因为 applyDiscount 是存储在 maybeApplyDiscountFunc 包装器的 this.val 中:
// maybeDiscount.map(applyDiscount)

现在,如果 maybeDiscount 确实有折扣,那么该函数就被执行。如果 maybeDiscount 为 Null,那么什么都不会发生。

供参考:FL 规范中有一个很明显的变化,比如,老版本中是Just(f).ap(Just(x))(这里 f 是一个函数,而 x 是一个值),而新版本的写法是 Just(x).ap(Just(f)),但是实现大多还没有修改。多谢 keithalexander

总结一下,如果有一个函数要处理多个可能都为 null 的参数,那么应该首先对该函数进行柯里化,然后将它放在一个 Maybe 中。而且,还要把所有参数放到一个 Maybe 中,然后使用 ap 来执行该函数。

curryN 函数

我们已经熟悉了柯里化。它只是将一个接受多个参数的函数,转换为一个一个接受参数的函数。

// 柯里化示例:
const add = (a, b) =>a+b;

const curriedAdd = R.curry(add);

const add10 = curriedAdd(10);// 传递第一个参数。返回接受第二个参数(b)的函数。

// 通过传递第二个参数来执行函数
add10(2) // -> 12 // 内部执行 10 和 2 相加("add")

不过,如果 add 函数要对传递给它作为参数的所有数字求和,而不是只加两个数字,该怎么办呢?

const add = (...args) => R.sum(args); // 对 args 中所有数求和

我们依然可以像如下这样使用 curryN 来限制 args 的数量,来柯里化它:

// CurryN 示例:
const add = (...args) => R.sum(args);

const add3Numbers = R.curryN(3, add);
const add5Numbers = R.curryN(5, add);
const add10Numbers = R.curryN(10, add);

add3Numbers(1,2,3) // 6
add3Numbers(1) // 返回一个带有两个以上参数的函数
add3Numbers(1, 2) // 返回一个带有一个以上参数的函数

使用 curryN 等待多次函数调用

假如我们想写一个函数,该函数只在三次调用之后才输出日志(并且忽略第一次和第二次调用)。有点像下面这样:

// 不纯的
let counter = 0;
const logAfter3Calls = () => {
 if(++counter == 3)
   console.log('调用了我 3 次');
}

logAfter3Calls() // 什么都不发生
logAfter3Calls() // 什么都不发生
logAfter3Calls() // '调用了我 3 次'

我们可以像下面这样用 curryN 模仿它。

// 纯
const log = () => {
   console.log('调用了我 3 次');
}

const logAfter3Calls = R.curryN(3, log);

// 调用
logAfter3Calls('')('')('') // '调用了我 3 次'

注意:我们会在 Applicative Validation 中使用这种技术。

示例 4 — 收集并显示多个错误

涉及的主题: Validation(又称 Validation Functor、Validation Applicative、Validation Monad)

Validation 通常被称为 Validation Applicative,因为它通常被用于使用它的 ap 函数(apply)执行校验。

Validation 与 Either Monad 类似,被用来组合多个抛出错误的函数。不过,Either Monad 通常是用它的 chain 方法来组合,而 Validation Monad 通常是用 ap 方法来组合。并且 Either Monad 的 chain 方法是收集第一个错误,而 ap 方法,特别是 Validation Monad 中的 ap 方法,允许将所有错误收集到一个数组中。

Validation 通常被用在表单校验中,此时我们可能想让所有错误同时显示出来。

用例: 有一个登录表单,要使用三个函数(isUsernameValid、isPwdLengthCorrect 和 ieEmailValid)验证用户名、密码和电子邮件。如果三个验证都没有通过,需要将三个错误都显示出来。

为了显示多个错误,要用 Validation Functor。

OK,下面我们来看按如何用 Validation Applicative 来实现它。

我们将使用来自 folktalejs 的 data.validation 库,因为 ramda-fantasy 还没有实现它。

与 Either Monad 相似,它有两个构造器:SuccessFailure。这两个构造器像子类一样,都实现了 Either 的规范。

第 1 步: 为了 使用 Validation,所有我们需要做的就是把有效值和错误包装在 SuccessFailure 构造器中(即创建这些类的实例)。

const Validation = require('data.validation') // from [folktalejs](https://github.com/folktale/data.validation)
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');

//不要用下面的代码:
function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? 
           ["用户名不能是数字"] : a
}

// 要用:
function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? 
         Failure(["用户名不能是数字"]) : Success(a)
}

对所有抛出错误的校验函数重复该过程。

步骤 2: 创建一个哑函数来保存 validation success.

const returnSuccess = () => 'success'; // 只返回 success

步骤 3: 使用 curryN 来重复应用 ap

ap 的问题是,左手边应该是一个包含函数的 functor(或者 Monad)。

例如,假如我们想像下面一样重复应用 ap。只有在 monad1 包含一个函数时它才会起作用。并且 monad1.ap(monad2) 的结果(即 resultingMonad)也是一个包含函数的 Monad,这样我们就可以 apmonad3

let finalResult = monad1.ap(monad2).ap(monad3)

// 可以被重写为:
let resultingMonad = monad1.ap(monad2)
let finalResult = resultingMonad.ap(monad3)

//will only work if: monad1 has a function and monad1.ap(monad2) results in another monad (resultingMonad) with a function

一般来说,要两次应用 ap,我们就需要包含有函数的两个 monad。

在本例中,有三个函数需要应用。

假如说我们像如下这样做:

Success(returnSuccess)
.ap(isUsernameValid(username)) // 可以运行
.ap(isPwdLengthCorrect(pwd)) // 不会运行
.ap(ieEmailValid(email)) // 不会运行

上面不能运行的是因为 Success(returnSuccess).ap(isUsernameValid(username)) 会得到一个值。并且不再继续在第二个和第三个函数上执行 ap

进入 curryN。

我们可以使用 curryN,让函数一直返回,直到它被调用了 N 次。

所以我们这样做就可以了:

// 3 是因为我们正调用 `ap` 3 次。
let success = R.curryN(3, returnSuccess);

现在,被柯里化的 success 一直返回函数 3 次。

function validateForm(username, pwd, email) {
    // 3 是因为我们正调用 `ap` 3 次。
    let success = R.curryN(3, returnSuccess);

    return Success(success)// 默认;用于 3 次 `ap`
        .ap(isUsernameValid(username))
        .ap(isPwdLengthCorrect(pwd))
        .ap(ieEmailValid(email))
}

把上面的代码放在一起:

const Validation = require('data.validation') //from folktalejs
const Success = Validation.Success
const Failure = Validation.Failure
const R = require('ramda');

function isUsernameValid(a) {
    return /^(0|[1-9][0-9]*)$/.test(a) ? Failure(["用户名不能是数字"]) : Success(a)
}

function isPwdLengthCorrect(a) {
    return a.length == 10 ? Success(a) : Failure(["密码必须是 10 个字符"])
}

function ieEmailValid(a) {
    var re = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;

    return re.test(a) ? Success(a) : Failure(["Email 是无效的"])
}

const returnSuccess = () => 'success';// 仅返回 success

function validateForm(username, pwd, email) {
    let success = R.curryN(3, returnSuccess);// 3 是因为我们正调用 `ap` 3 次。
    return Success(success)
        .ap(isUsernameValid(username))
        .ap(isPwdLengthCorrect(pwd))
        .ap(ieEmailValid(email))
}


validateForm('raja', 'pwd1234567890', 'r@r.com').value;
// 输出: success

validateForm('raja', 'pwd', 'r@r.com').value;
// 输出: ['密码必须是 10 个字符' ]


validateForm('raja', 'pwd', 'notAnEmail').value;
// 输出: ['密码必须是 10 个字符', 'Email is not valid']

validateForm('123', 'pwd', 'notAnEmail').value;
//['用户名不能是数字', '密码必须是 10 个字符', 'Email 是无效的']

如果喜欢本文,请点下面的 💚,并在推特上分享!感谢阅读!🙏🏼