JS函数式编程【译】5.3 单子 (Monad)
单子是帮助你组合函数的工具。
像原始类型一样,单子是一种数据结构,它可以被当做装载让函子取东西的容器使用。 函子取出了数据,进行处理,然后放到一个新的单子中并将其返回。
我们将要关注三种单子:
- Maybes
- Promises
- Lenses
除了用于数组的map和函数的compose以外,我们还有三种函子(maybe、promise和lens)。 这仅仅是另一些函子和单子。
Maybe
Maybe可以让我们优雅地使用有可能为空并且有默认值的数据。maybe是一个可以有值也可以没有值的变量,并且这对于调用者来说无所谓。
就他自己来说,这看起来不是什么大问题。所有人都知道空值检查可以通过一个if-else语句很容易地实现。
if (getUsername() == null) {
username = 'Anonymous'
} else {
username = getUsername();
}
但是用函数式编程,我们要打破过程的、一行接一样的做事方式,而应该用函数和数据的管道方式。 如果我们不得不从链的中间断开来检查值是否存在,我们就得创建临时变量并写更多的代码。 maybe仅仅是帮助我们保持逻辑跟随管道的工具。
要实现maybe,我们首先要创建一些构造器。
// Maybe单子构造器,目前是空的
var Maybe = function(){};
// None实例, 对一个没有值的对象的包装
var None = function(){};
None.prototype = Object.create(Maybe.prototype);
None.prototype.toString = function(){return 'None';};
// 现在可以写`none`函数了
// 这让我们不用总去写`new None()`
var none = function(){return new None()};
// Just实例, 对一个有一个值的对象的包装
var Just = function(x){return this.x = x;};
Just.prototype = Object.create(Maybe.prototype);
Just.prototype.toString = function(){return "Just "+this.x;};
var just = function(x) {return new Just(x)};
最后,我们可以写maybe函数了。它返回一个新的函数,这个函数返回“没东西”或者maybe。它是个函子。
var maybe = function(m) {
if (m instanceof None) {
return m;
} else if (m instanceof Just) {
return just(m.x);
} else {
throw new TypeError("Error: Just or None expected, " +
m.toString() + " given.");
}
}
我们还可以生成一个函子的生成器,就像数组的那样。
var maybeOf = function(f) {
return function(m) {
if (m instanceof None) {
return m;
} else if (m instanceof Just) {
return just(f(m.x));
} else {
throw new TypeError("Error: Just or None expected, " +
m.toString() + " given.");
}
}
}
那么Maybe是单子,maybe是函子,maybeOf返回一个已经分配了态射的函子。
在我们继续往下进行之前,我们还得做点事情。我们需要给Maybe这个单子对象添加一个方法让它用起来更直观。
Maybe.prototype.orElse = function(y) {
if (this instanceof Just) {
return this.x;
} else {
return y;
}
}
在现在这样的形式里,maybe可以直接被使用。
maybe(just(123)).x; // Returns 123
maybeOf(plusplus)(just(123)).x; // Returns 123
maybeOf(plusplus)(none()).orElse('none'); // returns 'none'
所有东西都返回一个方法然后再被调用,这太复杂了,简直是在自找麻烦。我们可以通过我们的curry()函数让它看起来稍微明白一点:
maybePlusPlus = maybeOf.curry()(plusplus);
maybePlusPlus(just(123)).x; // returns 123
maybePlusPlus(none()).orElse('none'); // returns none
不过当直接调用none()和just()这些脏业务被抽象起来的时候,maybe真正的力量就变得明显了。我们用一个User对象的例子来试一下,这里username将使用maybe。
var User = function() {
this.username = none(); // 初始设置为`none`
};
User.prototype.setUsername = function(name) {
this.username = just(str(name)); // 这里用一个just
};
User.prototype.getUsernameMaybe = function() {
var usernameMaybe = maybeOf.curry()(str);
return usernameMaybe(this.username).orElse('anonymous');
};
var user = new User();
user.getUsernameMaybe(); // Returns 'anonymous'
user.setUsername('Laura');
user.getUsernameMaybe(); // Returns 'Laura'
现在我们有了强大且安全的方法来定义默认值。记住这个User对象,因为在后面的章节里要用到它。
Promise
在函数式编程中,我们经常使用管道和数据流:也就是函数链,每个函数产生的数据类型被下一个函数消费。然而,有很多这些函数是异步的:文件、事件、Ajax等等。如果不用持续传递的风格和深层嵌套的回调,我们该如何修改这些函数的返回类型来说明结果?把它们封装到promise里。
promise像是回调的函数式等价物。很明显,回调不是那么函数式,如果不止一个函数要改变同样的数据,就会出现竞争条件和bug。promise解决了这个问题。
不用promise的代码是这样:
fs.readFile("file.json", function(err, val) {
if (err) {
console.error("unable to read file");
} else {
try {
val = JSON.parse(val);
console.log(val.success);
} catch (e) {
console.error("invalid json in file");
}
}
});
用promise应该把代码编程这样:
fs.readFileAsync("file.json").then(JSON.parse)
.then(function(val) {
console.log(val.success);
})
.catch(SyntaxError, function(e) {
console.error("invalid json in file");
})
.catch(function(e) {
console.error("unable to read file")
});
前面的代码来自于bluebird的README,bluebird是一个对Promises/A+的全面实现,并有非常好的性能。Promises/A+是一个JavaScript中promise的实现规范。这里只给出JavaScript社区当前的成果,把实现留给Promises/A+团队吧,因为它比maybe复杂得多。
不过这里给出部分实现:
// Promise单子
var Promise = require('bluebird');
// promise函子
var promise = function(fn, receiver) {
return function() {
var slice = Array.prototype.slice,
args = slice.call(arguments, 0, fn.length - 1),
promise = new Promise();
args.push(function() {
var results = slice.call(arguments),
error = results.shift();
if (error) promise.reject(error);
else promise.resolve.apply(promise, results);
});
fn.apply(receiver, args);
return promise;
};
};
现在我们可以利用函子promise()把需要传入回调的函数改为返回promise的函数。
var files = ['a.json', 'b.json', 'c.json'];
readFileAsync = promise(fs.readFile);
var data = files
.map(function(f) {
readFileAsync(f).then(JSON.parse)
})
.reduce(function(a, b) {
return $.extend({}, a, b)
});
lens
程序员真正喜欢单子的另一个原因是它们使编写库非常简单。为了一探究竟,我们来给User对象扩展出更多的函数,这些函数用于设置和获取值,不过我们不用getter和setter,而使用lens。
lens是头等getter和setter,它让我们不仅可以设置和获取值,还可以直接运行函数。不过它不会改变数据,而是克隆一份数据,函数对克隆出来的数据进行修改后返回。它强制数据不可变,这对很多库所需要的安全性和一致性都很有好处。它有益于写出优雅的代码,无论什么样的应用,只要拷贝数组带来的增加量造成的性能冲击不是什么大问题。
在我们写lens()函数前,先来看看它是如何工作的:
var first = lens(
function(a) { return arr(a)[0]; }, // get
function(a, b) { return [b].concat(arr(a).slice(1)); } // set
);
first([1, 2, 3]); // 输出 1
first.set([1, 2, 3], 5); // 输出 [5, 2, 3]
function tenTimes(x) { return x * 10 }
first.modify(tenTimes, [1, 2, 3]); // 输出 [10,2,3]
下面展示了lens()是函数如何工作的。它返回了一个定义了set、get和mod的函数。lens()函数本身是个函子。
var lens = fuction(get, set) {
var f = function (a) {return get(a)};
f.get = function (a) {return get(a)};
f.set = set;
f.mod = function (f, a) {return set(a, f(get(a)))};
return f;
};
来试个例子,我们要扩展前面例子中的User对象。
// userName :: User -> str
var userName = lens(
function(u) {
return u.getUsernameMaybe()
}, // get
function(u, v) { // set
u.setUsername(v);
return u.getUsernameMaybe();
}
);
var bob = new User();
bob.setUsername('Bob');
userName.get(bob); // 返回'Bob'
userName.set(bob, 'Bobby'); //return 'Bobby'
userName.get(bob); // 返回'Bobby'
userName.mod(strToUpper, bob); // 返回'BOBBY'
strToUpper.compose(userName.set)(bob, 'robert'); // 返回'ROBERT'
userName.get(bob); // 返回'robert'
jQuery是一个单子
如果你觉得所有这些关于范畴、函子和单子的抽象的玩意儿并没有在真实世界的应用,那你再想想。jQuery,这个最流行的JavaScript库,它提供了操作HTML的增强接口,实际上,它是一个单子化的库。
jQuery对象是一个单子,它的方法是函子。实际上它们是一种特殊类型的函子,叫做endofunctor。endofunctor是返回与输入相同范畴的函子,也就是F :: X -> X。每个jQuery方法都取一个jQuery对象并返回一个jQuery对象,这就使得方法可以链式调用。它们的类型签名是jFunc :: jquery-obj -> jquery-obj。
$('li').add('p.me-too').css('color', 'red').attr({id:'foo'});
这也用于jQuery的插件框架。如果一个插件以jQuery对象为输入,并返回一个jQuery对象为输出,那它就可以被添加到方法链中。
来看下jQuery是如何实现这个的。
单子是函子“伸手进去”取数据的容器。通过这种方式,数据就可以被保护起来并由库来控制。jQuery通过一些方法提供了访问里面数据(一系列封装起来的HTML元素)的方式。
jQuery自身是写成一个匿名函数调用的结果。
var jQuery = (function() {
var j = function(selector, context) {
var jq - obj = new j.fn.init(selector, context);
return jq - obj;
};
j.fn = j.prototype = {
init: function(selector, context) {
if (!selector) {
return this;
}
}
};
j.fn.init.prototype = j.fn;
return j;
})();
在这个高度简化的jQuery版本里,它返回了一个定义了j对象的函数。j函数实际上是一个增强了的init构造器。
var $ = jQuery(); // 从这个函数得到了返回值并赋值给`$`
var x = $('#select-me'); // 返回了jQuery对象
与函子从容器中取用值的方式相同,jQuery把HTML元素封装了起来,并提供了访问他们的方法,而没有直接去修改HTML元素。
尽管jQuery不经常宣扬它,但jQuery有自己的map方法用于从封装中取出HTML元素对象。就像fmap()方法一样,这些元素被取出,对它们做一些事情,然后放回到容器中。这就是许多jQuery命令后期的使用方式。
$('li').map(function(index, element) {
// do something to the element
return element
});
另一个用于操作HTML元素的库Prototype就不是这样工作的。它通过一些辅助方式直接修改HTML元素。因此它在JavaScript社区里就没有那样的地位。