6.7.1 函数式编程入门经典

优质
小牛编辑
130浏览
2023-12-01

前言

虽然大家已经被面向对象编程(Object-oriented programing)洗脑了,但很明显这种编程方式在 JavaScript 里非常笨拙,这种语言里没有类可以用,社区采取的变通方法不下三种,还要应对忘记调用 new 关键字后的怪异行为,真正的私有成员只能通过闭包(closure)才能实现,而多数情况,就像我们在亿书代码里那样,把私有方法放在一个privated变量里,视觉上区分一下而已,本质上并非私有方法。对大多数人来说,函数式编程看起来才更加自然。而且,在Nodejs的世界里,大量的回调函数是数据驱动的,使用函数式编程更加容易理解和处理。

函数式编程远远没有面向对象编程普及,本篇文章借鉴了几篇优秀文档(见参考),结合亿书项目实践和个人体会,汇总了一些平时用得到的函数式编程思路,为更好的优化设计亿书做好准备。本篇内容包括函数式编程基本概念,主要特点和编码方法,其中的一些代码实例主要参考了 《mostly adequate guide》,folktalejs 和 ramdajs 的相关代码,参考里也提供了它们的链接,请认真参考学习。如果想运行文中的代码,请提前安装ramda等相应的第三方组件。

什么是函数式编程?

简单说,“函数式编程”与“面向对象编程”一样,都是一种编写程序的方法论。它属于 “结构化编程” 的一种,主要思想是以数据为思考对象,以功能为基本单元,把程序尽量写成一系列嵌套的函数调用。

下面,我们就从一个简单的例子开始,来体会其中的奥妙和优势。这个例子来自于mostly-adequate-guide,作者说这是一个愚蠢的例子,并不是面向对象的良好实践,它只是强调当前这种变量赋值方式的一些弊端。这是一个海鸥程序,鸟群合并则变成了一个更大的鸟群,繁殖则增加了鸟群的数量,增加的数量就是它们繁殖出来的海鸥的数量。

(1)面向对象编码方式

var Flock = function(n) {
  this.seagulls = n;
};

Flock.prototype.conjoin = function(other) {
  this.seagulls += other.seagulls;
  return this;
};

Flock.prototype.breed = function(other) {
  this.seagulls = this.seagulls * other.seagulls;
  return this;
};

var flock_a = new Flock(4);
var flock_b = new Flock(2);
var flock_c = new Flock(0);

var result = flock_a.conjoin(flock_c).breed(flock_b).conjoin(flock_a.breed(flock_b)).seagulls;
//=> 32

按照正常的面向对象语言的编码风格,上面的代码好像没有什么错误,但运行结果却是错误的,正确答案是 16,是因为 flock_a 的状态值seagulls在运算过程中不断被改变。别的先不说,如果flock_a的状态保持始终不变,结果就不会错误。

这类代码的内部可变状态非常难以追踪,出现这类看似正常,实则错误的代码,对整个程序是致命的。

(2)函数式编程方式

var conjoin = function(flock_x, flock_y) { return flock_x + flock_y };
var breed = function(flock_x, flock_y) { return flock_x * flock_y };

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = conjoin(breed(flock_b, conjoin(flock_a, flock_c)), breed(flock_a, flock_b));
//=>16

先不用考虑其他场景,至少就这个例子而言,这种写法简洁优雅多了。从数据角度考虑,逻辑简单直接,不过是简单的加(conjoin)和乘(breed)运算而已。

(3)函数式编程的延伸

函数名越直白越好,改一下:

var add = function(x, y) { return x + y };
var multiply = function(x, y) { return x * y };

var flock_a = 4;
var flock_b = 2;
var flock_c = 0;

var result = add(multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b));
//=>16

这么一来,你会发现我们还能运用小学都学过的运算定律:

// 结合律
add(add(x, y), z) == add(x, add(y, z));

// 交换律
add(x, y) == add(y, x);

// 同一律
add(x, 0) == x;

// 分配律
multiply(x, add(y,z)) == add(multiply(x, y), multiply(x, z));

我们来看看运用这些定律如何简化这个海鸥小程序:

// 原有代码
add(multiply(flock_b, add(flock_a, flock_c)), multiply(flock_a, flock_b));

// 应用同一律,去掉多余的加法操作(add(flock_a, flock_c) == flock_a)
add(multiply(flock_b, flock_a), multiply(flock_a, flock_b));

// 再应用分配律
multiply(flock_b, add(flock_a, flock_a));

到这里,程序就变得非常有意思,如果更加复杂的应用,也能够确保结果可以预期,这就是函数式编程。

函数式编程的优势

1.易于开发:代码简洁,大大降低开发成本

从上面的代码,可以体会到这一点。使用函数式编程,可以充分发挥Javascript语言自身的优点,每一个函数都是独立单元,便于调试和测试,也方便模块化组合,代码量少、开发效率高。有人比较过C语言与Lisp语言,同样功能的程序,极端情况下,Lisp代码的长度可能是C代码的二十分之一。

2.易于分享:更接近自然语言,代码即文档

上面使用分配律之后的代码multiply(flock_b, add(flock_a, flock_a)),完全可以改成下面这样:

add(flock_a, flock_a).multiply(flock_b) // = (flock_a + flock_a) * flock_b

特别是下面这样的句式,如果是用惯了ruby on rails的小伙伴,更加熟悉下面的语句:

User.all().sortBy('name').limit(20)

3.性能更高:能够实现"并发编程"(concurrency)

函数式编程不依赖、也不会改变外界的状态,相同输入获得相同输出,因此不存在"锁"线程的问题。不必担心一个线程的数据,被另一个线程修改,所以可以很放心地把工作分摊到多个线程,实现"并发编程"。目前的计算机基本上都是多核的了,多线程应用将是常态,亿书客户端也会考虑优化线程服务,提高软件性能,改善用户体验,这将非常有帮助。

4.部署简单:热部署和热升级

函数式编程没有副作用,只要接口没变化,改变函数内部代码对外部没有任何影响。所以,可以在运行状态下直接升级代码,不需要重启,也不需要停机。这对于每秒要处理很多交易的加密货币系统来说,尤为重要。亿书将在未来实现每个节点的热部署和热升级,使节点建立和维护零难度。

函数式编程的基本原则

总的来说,就是“正确地写出正确的函数”。这话有点绕,不过我们写了太多面向对象的代码,可能已被“毒害”太深,不得不下点猛药,说点狠话,不然记不住。函数式编程,当然函数是主角,被称为“一等公民”,特别是对于 JavaScript 语言来说,可以像对待任何其他数据类型一样对待——把它们存在数组里,当作参数传递,赋值给变量...等等。但是,说起来容易,真正做起来,并非每个人都能轻松做到。下面是写出正确函数的几个原则:

(1)直接把函数赋值给变量

记住:凡是使用return返回函数调用的,都可以去掉这个间接包裹层,最终连参数和括号一起去掉!

以下代码都来自 npm 上的模块包:

// 太傻了
var getServerStuff = function(callback){
  return ajaxCall(function(json){
    return callback(json);
  });
};

// 这才像样
var getServerStuff = ajaxCall;

世界上到处都充斥着这样的垃圾 ajax 代码。以下是上述两种写法等价的原因:

// 这行
return ajaxCall(function(json){
  return callback(json);
});

// 等价于这行
return ajaxCall(callback);

// 那么,重构下 getServerStuff
var getServerStuff = function(callback){
  return ajaxCall(callback);
};

// ...就等于
var getServerStuff = ajaxCall; // <-- 看,没有括号哦

(2)使用最普适的方式命名

函数属于操作,命名最好简单直白体现功能性,比如add等。参数是数据,最好不要限定在特定的数据上,比如articles,就能让写出来的函数更加通用,避免重复造轮子。例如:

// 只针对当前的博客
var validArticles = function(articles) {
  return articles.filter(function(article){
    return article !== null && article !== undefined;
  });
};

// 对未来的项目友好太多
var compact = function(xs) {
  return xs.filter(function(x) {
    return x !== null && x !== undefined;
  });
};

(3)避免依赖外部变量

不依赖外部变量和环境,就能确保写出的函数是纯函数,即:相同输入得到相同输出的函数。 比如 slicesplice,这两个函数的作用一样,但方式不同, slice 符合函数的定义是因为对相同的输入它保证能返回相同的输出。而 splice 却会嚼烂调用它的那个数组,然后再吐出来,即这个数组永久地改变了。

再如:

// 不纯的
var minimum = 21;

var checkAge = function(age) {
  return age >= minimum;
};


// 纯的
var checkAge = function(age) {
  var minimum = 21;
  return age >= minimum;
};

在上面不纯的版本中,checkAge 的结果取决于 minimum 这个外部可变变量的值(系统状态值)。输入值之外的因素能够左右 checkAge 的返回值,产生很多副作用,不仅让它变得不纯,而且导致每次我们思考整个软件的时候都将痛苦不堪,本法就会产生很多bug。这些副作用包括但不限于:更改文件系统、往数据库插入记录、发送一个 http 请求、打印log、获取用户输入、DOM 查询、访问系统状态等,总之,就是在计算结果的过程中,导致系统状态发生变化,或者与外部世界进行了可观察的交互

在数学领域,函数是这么定义的(请参考 百度百科上函数定义):一般的,在一个变化过程中,假设有两个变量x、y,如果对于任意一个x都有唯一确定的一个y和它对应,那么就称y是x的函数。直白一点就是,函数是不同数值之间的特殊关系:对每一个输入值x返回且只返回一个输出值y。从这个定义来看,我所谓的纯函数其实就是数学函数。这会给我们带来可缓存性、可移植性、自文档化、可测试性、合理性、并行代码等很多好处(具体实例请参考[mostly-adequate-guide(英文)][])。

(4)面对 this 值,小心加小心

this 就像一块脏尿布,尽可能地避免使用它,因为在函数式编程中根本用不到它。然而,在使用其他的类库时,你却不得不低头。如果一个底层函数使用了 this,而且是以函数的方式被调用的,那就要非常小心了。比如:

var fs = require('fs');

// 太可怕了
fs.readFile('freaky_friday.txt', Db.save);

// 好一点点
fs.readFile('freaky_friday.txt', Db.save.bind(Db));

把 Db 绑定(bind)到它自己身上以后,你就可以随心所欲地调用它的原型链式垃圾代码了。

怎样进行函数式编程?

这里汇总一些常用的工具和方法。

(1)柯里化(curry):动态产生新函数

什么是柯里化?维基百科(见参考)的解释是,柯里化是指把接受多个参数的函数变换成接受一个单一参数(第一个)的函数,返回接受剩余参数的新函数的技术。通俗点说,只传递给函数一部分参数来调用它,让它返回一个函数去处理剩下的参数。

这里有两个关键,首先,明确规则。前面的参数(可以是函数)是规则,相当于新函数的私有变量(通过类似闭包的方式);其次,明确目的。目的是获得新函数,而这个新函数才是真正用来处理业务数据的,所以与业务数据相关的参数,最好放在后面。

这是函数式编程的重要技巧之一,在使用各类高阶函数(参数或返回值为函数的函数)的时候非常常见。通常大家不定义直接操作数组的函数,因为只需内联调用 mapsortfilter 以及其他的函数就能达到目的,这时候多会用到这种方法 。

最简单的例子:

var add = function(x) {
  return function(y) {
    return x + y;
  };
};

var addTenTo = add(10);

addTenTo(2);
// 12

这里是自定义的 add 函数,它接受一个参数并返回一个新的函数。调用 add 之后,返回的函数就通过闭包的方式记住 add 的第一个参数。我们还可以借助工具使这类函数的定义和调用更加容易。比如,利用lodash包的 curry 方法,上面的add方法就变成这样:

var curry = require('lodash').curry;

var add = curry(function(x, y) {
    return x + y;
});

下面看几个更实用的例子:

var curry = require('lodash').curry;

var match = curry(function(what, str) {
  return str.match(what);
});

// 1.当普通函数调用
match(/\s+/g, "hello world");
// [ ' ' ]

match(/\s+/g)("hello world");
// [ ' ' ]

// 2.使用柯里化的新函数
var hasSpaces = match(/\s+/g);
// function(x) { return x.match(/\s+/g) }

hasSpaces("hello world");
// [ ' ' ]

hasSpaces("spaceless");
// null

// 3.大胆嵌套使用
var filter = curry(function(f, ary) {
  return ary.filter(f);
});

var findHasSpacesOf = filter(hasSpaces);
// function(xs) { return xs.filter(function(x) { return x.match(/\s+/g) }) }

findHasSpacesOf(["tori_spelling", "tori amos"]);
// ["tori amos"]

(2)组合(compose):自由组合新函数

组合就是把两个函数结合起来,产生一个崭新的函数。这符合范畴学的组合理论,也就是说组合某种类型(本例中是函数)的两个元素还是会生成一个该类型的新元素,就像把两个乐高积木组合起来绝不可能得到一个林肯积木一样。组合函数的代码非常简单,如下:

var compose = function(f,g) {
  return function(x) {
    return f(g(x));
  };
};

fg 都是函数,x 是在它们之间通过“管道”传输的值。g 先于 f 执行,因此数据流是从右到左的,这符合数学上的“结合律”的概念,能为编码带来极大的灵活性(可以任意拆分和组合),而且不用担心执行结果出现意外。比如:

// 结合律(associativity)
var associative = compose(f, compose(g, h)) == compose(compose(f, g), h);
// true

函数式编程有个叫“隐式编程”([Tacit programming][],见参考)的概念,也叫“point-free 模式”,意思是函数不用指明操作的参数(也叫 points),而是让组合它的函数处理参数。柯里化以及组合协作起来非常有助于实现这种模式。首先,利用柯里化,让每个函数都先接收数据,然后操作数据;接着,通过组合,实现把数据从第一个函数传递到下一个函数那里去。这样,就能做到通过管道把数据在接受单个参数的函数间传递。

显然,隐式编程模式隐去了不必要的参数命名,让代码更加简洁和通用。通过这种模式,我们也很容易了解一个函数是否是接受输入返回输出的小函数。比如,replace,split等都是这样的小函数,可以直接组合;map接受两个参数自然不能直接组合,不过可以先让它接受一个函数,转化为一个参数的函数;但是 while 循环是无论如何不能组合的。另外,并非所有的函数式代码都是这种模式的,所以,适当选择,不能使用的时候就用普通函数。

下面,看个例子:

// 非隐式编程,因为提到了数据:word
var snakeCase = function (word) {
  return word.toLowerCase().replace(/\s+/ig, '_');
};

// 隐式编程
var snakeCase = compose(replace(/\s+/ig, '_'), toLowerCase);

本来,js的错误定位就不准确,这样的组合给 debug 带来了更多困难。还好,我们可以使用下面这个实用的,但是不纯的 trace 函数来追踪代码的执行情况。

var trace = curry(function(tag, x){
  console.log(tag, x);
  return x;
});

var dasherize = compose(join('-'), toLower, split(' '), replace(/\s{2,}/ig, ' '));

dasherize('The world is a vampire');
// TypeError: Cannot read property 'apply' of undefined

这里报错了,来 trace 下:

var dasherize = compose(join('-'), toLower, trace("after split"), split(' '), replace(/\s{2,}/ig, ' '));
// after split [ 'The', 'world', 'is', 'a', 'vampire' ]

啊,toLower 的参数是一个数组(记住,上面的代码是从右向左执行的奥),所以需要先用 map 调用一下它。

var dasherize = compose(join('-'), map(toLower), split(' '), replace(/\s{2,}/ig, ' '));

dasherize('The world is a vampire');

// 'the-world-is-a-vampire'

(3)注释:签名函数的行为和目的

函数式编程非常灵活,包括隐式编程在内,参数被大大减少和弱化,这就为我们阅读和使用函数带来困扰,对协作开发也是一项挑战,如何解决?通常的做法就是添加详细的文档或注释,不过函数式编程有其自己的处理方式,那就是类型签名。类型签名在写纯函数时所起的作用非常大,短短一行,就能暴露函数的行为和目的。这里介绍一下,在函数式编程里,非常著名的Hindley-Milner类型系统。这个名称是两位科学家的名字组合,常被简称为HM类型系统。在使用该类型系统中,注意以下几点要素:

  • 函数都写成 a -> b 的样子。其中 ab 是任意类型的变量,这句话的意思是“一个接受a,返回b的函数”。延伸一下,a -> (b -> c) 的意思就是“一个接受a,返回一个接受b返回c的函数的函数”,即柯里化了。

  • 把最后一个类型视作返回值。比如 a -> (b -> c) 简单的理解成 a -> b -> c 也没有关系,只不过中间柯里化的过程被忽略了而已。

  • 参数优先级是从左向右,每传一个参数,就会得到后面对应部分的类型签名。比如,a -> (b -> c),传入了参数 a,得到的自然是新函数,b -> c

  • 可以在类型签名中使用变量。把变量命名为 ab 只是一种约定俗成的习惯,可以使用任何自己喜欢的名称。对于相同的变量名,其类型也一定相同。比如:a -> b 可以是从任意类型的 a 到任意类型的 b,但是 a -> a 必须是同一个类型。例如,可以是 String -> String,也可以是 Number -> Number,但不能是 String -> Bool。同时,也说明函数将会以一种统一的行为作用于所有的类型 a 或 b ,而不能做任何特定的事情,这能够帮助我们推断函数可能的实现。

最简单的例子:

//  capitalize :: String -> String
var capitalize = function(s){
  return toUpperCase(head(s)) + toLowerCase(tail(s));
}

capitalize("smurf");
//=> "Smurf"

这里,capitalize 函数的类型签名就是“capitalize :: String -> String”这行注释,可以理解为“一个接受 String 返回 String 的函数”。通俗点说,它接受一个 String 类型作为输入,并返回一个 String 类型的输出。

复杂一点的例子:

//  match :: Regex -> String -> [String]
//  理解为:接受一个 `Regex` 和一个 `String`,返回一个 `[String]`
var match = curry(function(reg, s){
  return s.match(reg);
});

//  match :: Regex -> (String -> [String])
//  理解为:接受一个 `Regex` 作为参数,返回一个从 `String` 到 `[String]` 的函数
var match = curry(function(reg, s){
  return s.match(reg);
});

//  onHoliday :: String -> [String]
//  理解为:已经调用了 `Regex` 参数的 `match`。给了第一个参数 `Regex`,返回结果就是后面的签名内容
var onHoliday = match(/holiday/ig);

让我们体会类型签名的好处:

// head :: [a] -> a
compose(f, head) == compose(head, map(f));

// filter :: (a -> Bool) -> [a] -> [a]
compose(map(f), filter(compose(p, f))) == compose(filter(p), map(f));

上面第一个等式,左边:先获取数组的头部,然后对它调用函数 f;右边:先对数组中的每一个元素调用 f,然后再取其返回结果的头部。尽管没有看到head,f等具体的代码实现,我们也知道这两个表达式的作用显然是相等的,但是前者要快得多,这是常识。这就为我们使用和优化提供了便利,在使用函数的时候,可以更加灵活的选择。

(4)容器:处理控制流、异常、异步和状态的独立模块

从代码功能来说,类型是最小单元,类似原子;函数就是基本单元,类似由原子形成的各类细胞;容器就是由细胞构成的人体组织。在一个程序里,容器就像一个功能模块(比面向对象中的类要灵活得多),有自己的上下文,包含了特定的方法(函数,主要是仿函数)和属性(状态),能够独立完成某方面的功能或事务。容器之间的操作和通信,需要用到特殊的数据类型——仿函数(functor)。

首先,创建一个容器:

var Container = function(x) {
  this.__value = x;
}

Container.of = function(x) { return new Container(x); };

// (a -> b) -> Container a -> Container b
Container.prototype.map = function(f){
  return Container.of(f(this.__value))
}

我们把它命名为 Container,使用 Container.of 作为构造器(constructor),这样就不用到处去写糟糕的 new 关键字了,非常省心。这个容器具备一切容器的标准特征:

  • 容器有且只有一个属性的对象。尽管容器可以有不止一个的属性,但大多数容器还是只有一个。我们很随意地把 Container 的这个属性命名为 __value,你也可以改成别的。所以说,容器就像面向对象的类,但不是,因为我们不会为它添加面向对象观念下的属性和方法。
  • 容器必须能够装载任意类型的值。因此,这里的__value 不能是某个特定的类型。
  • of方法是容器的入口访问方法。这是一种简单通用地往仿函数里填值的方式。数据一旦存放到容器,就会一直待在那儿。我们可以.__value 获取到数据,但尽量别这么做。
  • 仿函数是容器的出口访问方法。操作和使用数据要使用仿函数,因此,在具体使用中,仿函数基本上代表了容器本身。在函数式编程里,到处都有仿函数的身影,像 tree、list、map 和 pair 等可迭代数据类型,以及eventstream 和 observable 也都是。

什么是仿函数?

网上搜索了一下,大家普遍认为这个概念很难理解。说实话,从字面意思上,无论是中文函子、仿函数,还是英文functor,我一开始都没有理解是个什么东西。 大家普遍认可的是 Functor, Applicative, 以及 Monad 的图片阐释 里的解释,图文并茂非常清晰,请自行查阅吧。

对这种舶来品,我通常的做法是直接看它的英文解释(见参考 维基百科上的functor词条),In mathematics, a functor is a type of mapping between categories, which is applied in category theory. 译为:在数学中,仿函数应用于范畴学,是一种范畴之间的映射。按照这里的解释把它称为 Mappable 或许更为恰当。

接着上面的容器话题,一旦容器里有了值,不管这个值是什么,我们就需要一种方法来让别的函数能够操作它。这句话的意思是,值外面有了容器,就给定了一个范畴(上下文),我们就没有办法像前面那样简单的操作这个值了,怎么办?仿函数就派上用场了。形象点说,也就是容器设定了一个范畴,仿函数就为它搭建了一个交流通道。

上面代码里的 map 跟数组那个著名的 map 一样,除了前者的参数是 Container a 而后者是 [a]。它们的使用方式也几乎一致:

// 非特殊情况,我们使用 Ramdajs
var _ = require('ramda');

Container.of(2).map(function(two){ return two + 2 })
//=> Container(4)

Container.of("flamethrowers").map(function(s){ return s.toUpperCase() })
//=> Container("FLAMETHROWERS")

Container.of("bombs").map(concat(' away')).map(_.prop('length'))
//=> Container(10)

of 方法不单单是用来避免使用 new 关键字的,而是用来把值放到默认最小化上下文(default minimal context)中的。of其实就是purepointunitreturn 之类的函数,它是一个称之为 pointed仿函数 (实现了 of 方法的仿函数)的重要接口的一部分。这里的关键是把任意值丢到容器里然后开始到处使用 map 的能力。

我们能够在不离开 Container 的情况下操作容器里面的值。Container 里的值传递给 map 函数之后,就可以任我们操作;操作结束后,为了防止意外再把它放回它所属的 Container。这样做的结果是,我们能连续地调用 map,运行任何我们想运行的函数,甚至还可以改变值的类型。

让容器自己去运用函数有利于对函数运用的抽象。当 map 一个函数的时候,我们请求容器来运行这个函数,这是一种十分强大的理念。这种让容器去运行函数的方法就是“仿函数”。通俗点讲,一个函数在调用的时候,如果被 map 包裹了,那么它就会从一个非仿函数转换为一个仿函数。一般情况下,普通函数更适合操作普通的数据类型而不是容器类型,在必要的时候再通过 map 变为合适的仿函数去操作容器类型,这样做的好处是随需求而变,能得到更简单、重用性更高的函数。

仿函数的概念既然来自于范畴学,应该满足一些定律,学习理解这些实用的定律,会帮助我们更好的使用它。两个重要的定律:

// 同一性 identity
map(id) === id;

// 结合律 composition
compose(map(f), map(g)) === map(compose(f, g));

下面,根据功能不同,介绍几种重要的仿函数。

1> 数据验证(Maybe)

上面的Container功能单一,下面,让我们对它进行一系列重构,以满足我们不同的需要。请注意,每次重构,得出的新容器,都是一种具备特定功能的仿函数。先给数据加上验证看看,毕竟数据是否为空,是编程无法绕开的逻辑之一,具备这种特性的仿函数,函数式编程里称之为Maybe。更多内容,请参考学习 folktale's data.maybe

var Maybe = function(x) {
  this.__value = x;
}

Maybe.of = function(x) {
  return new Maybe(x);
}

Maybe.prototype.isNothing = function() {
  return (this.__value === null || this.__value === undefined);
}

Maybe.prototype.map = function(f) {
  return this.isNothing() ? Maybe.of(null) : Maybe.of(f(this.__value));
}

Maybe.of(null).map(match(/a/ig));
//=> Maybe(null)

Maybe.of({name: "Boris"}).map(_.prop("age")).map(add(10));
//=> Maybe(null)

看似很小的改进,却让代码更加健壮。除了时刻检查参数的存在性,还能用 Maybe(null) 来发送失败的信号,让程序接到这个信号时立刻切断后续代码的执行,这通常是编码希望达到的效果。

2> 错误处理(Either)

对于错误处理,当我们不知道可能是什么错误时,用的比较多的是throw/catch,这会影响性能,错误信息也不怎么友好,而且可能会打破“纯”函数,让其变得不再“纯”。当然,我们也会使用if语句来判定,给出具体的错误信息。在函数式编程里,通常使用 Either 这个仿函数。字面含义为“或者”的意思,属于二选一的两个分支。更多内容,请参考学习 folktale's data.either

这里,列出部分代码,如下:

var Left = function(x) {
  this.__value = x;
}

Left.of = function(x) {
  return new Left(x);
}

Left.prototype.map = function(f) {
  return this;
}

var Right = function(x) {
  this.__value = x;
}

Right.of = function(x) {
  return new Right(x);
}

Right.prototype.map = function(f) {
  return Right.of(f(this.__value));
}

这里略去了创建 Either 父类,只给出了它的两个子类Left(代表错误) 和 Right(代表正确),来看看它们是怎么运行的:

Right.of("rain").map(function(str){ return "b"+str; });
// Right("brain")

Left.of("rain").map(function(str){ return "b"+str; });
// Left("rain")

Right.of({host: 'localhost', port: 80}).map(_.prop('host'));
// Right('localhost')

Left.of("rolls eyes...").map(_.prop("host"));
// Left('rolls eyes...')

Left 无视 map 它的请求。Right 的作用就是一个最基础的 Container。这里强大的地方在于,Left 有能力在它内部嵌入一个错误消息。

前面说了,我们可以用 Maybe(null) 来表示失败并把程序引向另一个分支,但是它不会告诉我们太多信息,有时候我们想知道失败的原因是什么。比如:

var moment = require('moment');

//  getAge :: Date -> User -> Either(String, Number)
var getAge = _.curry(function(now, user) {
  //moment v2.3.0 以后的版本要添加true参数,表示使用严格模式,参考:http://momentjs.com/docs/#/parsing
  var birthdate = moment(user.birthdate, 'YYYY-MM-DD', true);
  if(!birthdate.isValid()) return Left.of("Birth date could not be parsed");
  return Right.of(now.diff(birthdate, 'years'));
});

getAge(moment(), {birthdate: '2005-12-12'});
// Right(9)

getAge(moment(), {birthdate: '20010704'});
// Left("Birth date could not be parsed")

这么一来,就像 Maybe(null),当返回一个 Left 的时候就直接让程序终止。跟 Maybe(null) 不同的是,现在我们对程序为何终止有了更多信息。让我们进一步看看,怎么用:

//  fortune :: Number -> String
var fortune  = _.compose(_.concat("If you survive, you will be "), _.add(1));

//  zoltar :: User -> Either(String, _)  
// 在类型签名中使用 `_`, 表示一个应该忽略的值;
// 通常,不会把 `console.log` 放到 `zoltar` 函数里,而是在调用 `zoltar` 的时候才 `map` 它
var zoltar = _.compose(_.map(console.log), _.map(fortune), getAge(moment()));

zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 11"
// Right(undefined)

zoltar({birthdate: 'balloons!'});
// Left("Birth date could not be parsed")

进一步优化,添加一个 either 方法,让它接受两个函数(分别对应left 和 right的情况)和一个静态值为参数:

//  either :: (a -> c) -> (b -> c) -> Either a b -> c
var either = _.curry(function(f, g, e) {
  switch(e.constructor) {
    case Left: return f(e.__value);
    case Right: return g(e.__value);
  }
});

//  zoltar :: User -> _
var zoltar = _.compose(console.log, either(_.identity, fortune), getAge(moment()));

zoltar({birthdate: '2005-12-12'});
// "If you survive, you will be 10"
// undefined

zoltar({birthdate: 'balloons!'});
// "Birth date could not be parsed"
// undefined

Either 并不仅仅只对合法性检查这种一般性的错误有作用,对一些更严重的、能够中断程序执行的错误,比如文件丢失或者 socket 连接断开等,同样效果显著。另外,这里仅把Either当作一个错误消息的容器,其实它还能做更多的事情。

3> 异步处理(Task)

在JavaScript的世界里,回调(callback)是无法回避的。还好,处理异步代码,函数式编程有一种更好的方式。这种方式的内部机制比较复杂,所以还是通过使用 Folktale 里的 Data.Task ,来从实例中去体会吧:

// 这是Folktale官方的例子
var Task = require('data.task')
var fs = require('fs')

// read : String -> Task(Error, Buffer)
function read(path) {
  return new Task(function(reject, resolve) {
    fs.readFile(path, function(error, data) {
      if (error)  reject(error)
      else        resolve(data)
    })
  })
}

// decode : Task(Error, Buffer) -> Task(Error, String)
function decode(buffer) {
  return buffer.map(function(a) {
    return a.toString('utf-8')
  })
}

var one = decode(read('one.txt'))
var two = decode(read('two.txt'))

// 使用 `.chain` 来关联两个异步行为,使用`.map` 同步运算任务的最终值
var concatenated = one.chain(function(a) {
                     return two.map(function(b) {
                       return a + b
                     })
                   })

// 必须使用 `.fork` 来明确执行,错误使用第一个函数抛出,成功就调用第二个函数
concatenated.fork(
  function(error) { throw error },
  function(data)  { console.log(data) }
)

例子中的 rejectresolve 函数分别是失败和成功的回调。我们用chain 来运行两个异步行为;简单地调用 Taskmap 函数,就能操作异步执行之后的值,好像这个值就在那儿似的;调用 fork 方法才能运行 Task,它会 fork 一个子进程运行它接收到的参数代码,其他部分的执行不受影响,主线程也不会阻塞。

这里的控制流是线性的。我们只需要从下读到上,从右读到左就能理解代码,即便这段程序实际上会在执行过程中到处跳来跳去。这种方式使得阅读和理解应用程序的代码比那种要在各种回调和错误处理代码块之间跳跃的方式容易得多。

4> 嵌套处理(Monad 和 Applicative)

这个世界是复杂的,我们把普通函数放进了容器,弄出这么多仿函数出来。那如果把容器放进相同的容器里,一层层嵌套起来(就像层层包裹的洋葱Monad)怎么办呢?最直接的处理方式就是多用几次map就可以了。不过,这类情况,有专门的处理方式:

  • 同类型容器嵌套调用 —— Monad

如果有两层相同类型的嵌套,那么就可以用 join 把它们压扁到一块去。这种结合的能力,仿函数之间的关联,就是 monad 之所以成为 monad 的原因。来看看它更精确的完整定义:

monad 是可以变扁(flatten)的 pointed 仿函数。

一个仿函数,只要它定义了一个 join 方法和一个 of 方法,并遵守一些定律,那么它就是一个同类型嵌套仿函数(这是我个人杜撰的名称,真正函数式编程的高手会不喜欢,大家习惯的还是monad)。join 的实现并不复杂,来为 Maybe 定义一个:

Maybe.prototype.join = function() {
  return this.isNothing() ? Maybe.of(null) : this.__value;
}

如果有一个 Maybe(Maybe(x)),那么 .__value 将会移除多余的一层,然后就能安心地从那开始进行 map。要不然,将会只有一个 Maybe,因为从一开始就没有任何东西被 map 调用。

很多情况下,总是在紧跟着 map 的后面调用 join。这时候,可以把这个行为进一步抽象到一个叫做 chain 的函数里:

//  chain :: Monad m => (a -> m b) -> m a -> m b
var chain = curry(function(f, m){
  return m.map(f).join(); // 或者 compose(join, map(f))(m)
});

这里仅仅是把 map/join 打包到一个单独的函数中。在一些库里, chain 也叫做 >>=(读作 bind)或者 flatMap,都是同一个概念的不同名称罢了。flatMap 是最简单明了,但chain是 JS 里接受程度最高的一个。虽然这么简单的处理了一下,该方法的用途却被无限扩展,我们上面使用的“Task”的例子用的就是它,请访问它的源码自行研究吧。

  • 不同类型容器的调用 —— Applicative

同类型容器嵌套,使用Monad仿函数处理方式,这种方式有一个问题,那就是顺序执行问题:所有的代码都只会在前一个函数执行完毕之后才执行。其实,在很多情况下,这是没有必要的。其更好的替代方法就是使用applicative仿函数。applicative仿函数是实现了 ap 方法的pointed仿函数。ap 方法,类似下面这样:

Container.prototype.ap = function(other_container) {
  return other_container.map(this.__value);
}

this.__value 是一个函数,将会接收另一个 functor 作为参数,所以我们只需 map 它。这里隐含的定律是:

F.of(x).map(f) == F.of(f).ap(F.of(x))

map 一个 f 等价于 ap 一个值为 f 的 functor。更简单的理解就是: map 等价于 of/ap。那么,可以利用这点来定义 map

// 从 of/ap 衍生出的 map
X.prototype.map = function(f) {
  return this.constructor.of(f).ap(this);
}

如果已经有了一个 chain 函数,我们也可以借助 monad 轻松得到仿函数(含有map方法的) 和 applicative(含有ap方法的):

// 从 chain 衍生出的 map
X.prototype.map = function(f) {
  var m = this;
  return m.chain(function(a) {
    return m.constructor.of(f(a));
  });
}

// 从 chain/map 衍生出的 ap
X.prototype.ap = function(other) {
  return this.chain(function(f) {
    return other.map(f);
  });
};

这一点非常强大,甚至可以审查一个数据类型,然后自动化这个过程。处理多个仿函数作为参数的情况,是 applicative functor 一个非常好的应用场景。借助applicative,能够在仿函数的世界里调用函数。尽管已经可以通过 monad 达到这个目的,向下的嵌套结构使得 monad 拥有串行计算、变量赋值和暂缓后续执行等独特的能力,但在不需要 monad 特定功能的时候,最好使用 applicative,用合适的方法来解决合适的问题。

给个最常见的例子,假设要创建一个旅游网站,既需要获取游客目的地的列表,还需要获取地方事件的列表。这两个请求仅仅需要通过相互独立的 api 调用。

// Http.get :: String -> Task Error HTML

var renderPage = curry(function(destinations, events) { /* render page */  });

Task.of(renderPage).ap(Http.get('/destinations')).ap(Http.get('/events'))
// Task("<div>some page with dest and events</div>")

两个请求将会同时立即执行,当两者的响应都返回之后,renderPage 就会被调用。这与 monad 版本的那种必须等待前一个任务完成才能继续执行后面的操作完全不同。本来就无需根据目的地来获取事件,因此也就不需要依赖顺序执行。由此来看 applicative 处理异步和并发是多么简单的事情。还有更多更好的实例,请参阅相关文档。

总结

本文作为函数式编程的入门文章,主要面向初学者,以及习惯了面向对象编程的程序员。文章从思维和实践角度来研究函数式编程方法,针对编码中的常见问题列举了对应的基本解决思路,一些概念与其他编码方法做了类比,可能并不十分确切,请阅读时区分辨别。因为文章重在思维逻辑,并没罗列函数式编程的过多内容和方式方法,提到的内容也没有做深入探讨,参考里列举了一些优秀文档,含有大量分析解读,请自行查阅。

目前,开源社区提供了很多优秀的函数式编程库,比如:lodash/FP,ramda.js,Folktale等,文章 Javascript的函数式库 还列出了其他一些优秀的库,并作了深入分析和介绍。这些库,既可以在生产中使用,更可以作为学习研究的范本,值得学习参考。亿书将在未来的版本和产品中,认真学习实践这些优秀的编程方法,进一步减少核心库的代码量,提高主链和侧链的综合性能。

链接

本系列文章即时更新,若要掌握最新内容,请关注下面的链接

本源文地址: https://github.com/imfly/bitcoin-on-nodejs

亿书白皮书: http://ebookchain.org/ebookchain.pdf

亿书官网: http://ebookchain.org

亿书官方QQ群:185046161(亿书完全开源开放,欢迎各界小伙伴参与)

参考

lodash FP Guide

ramda.js

Folktale

mostly-adequate-guide

函数式编程初探

使用 JavaScript 进行函数式编程

结构化编程

Functional programming

函数式编程(百度百科)

柯里化(维基百科)

Tacit programming

Hindley–Milner type system

Functor, Applicative, 以及 Monad 的图片阐释

图解 Monad

Functor(维基百科)

JavaScript Function Composition

Javascript的函数式库