异步任务
回调(callback)是通往地狱的狭窄的螺旋阶梯。它们是埃舍尔(译者注:荷兰版画艺术家)设计的控制流。看到一个个嵌套的回调挤在大小括号搭成的架子上,让人不由自主地联想到地牢里的灵薄狱(还能再低点么!)(译者注:灵薄狱即 limbo,基督教中地狱边缘之意)。光是想到这样的回调就让我幽闭恐怖症发作了。不过别担心,处理异步代码,我们有一种更好的方式,它的名字以“F”开头。
这种方式的内部机制过于复杂,复杂得哪怕我唾沫横飞也很难讲清楚。所以我们就直接用 Quildreen Motta 的 Folktale 里的 Data.Task
(之前是 Data.Future
)。来见证一些例子吧:
// Node readfile example:
//=======================
var fs = require('fs');
// readFile :: String -> Task(Error, JSON)
var readFile = function(filename) {
return new Task(function(reject, result) {
fs.readFile(filename, 'utf-8', function(err, data) {
err ? reject(err) : result(data);
});
});
};
readFile("metamorphosis").map(split('\n')).map(head);
// Task("One morning, as Gregor Samsa was waking up from anxious dreams, he discovered that
// in bed he had been changed into a monstrous verminous bug.")
// jQuery getJSON example:
//========================
// getJSON :: String -> {} -> Task(Error, JSON)
var getJSON = curry(function(url, params) {
return new Task(function(reject, result) {
$.getJSON(url, params, result).fail(reject);
});
});
getJSON('/video', {id: 10}).map(_.prop('title'));
// Task("Family Matters ep 15")
// 传入普通的实际值也没问题
Task.of(3).map(function(three){ return three + 1 });
// Task(4)
例子中的 reject
和 result
函数分别是失败和成功的回调。正如你看到的,我们只是简单地调用 Task
的 map
函数,就能操作将来的值,好像这个值就在那儿似的。到现在 map
对你来说应该不稀奇了。
如果熟悉 promise 的话,你该能认出来 map
就是 then
,Task
就是一个 promise。如果不熟悉你也不必气馁,反正我们也不会用它,因为它并不纯;但刚才的类比还是成立的。
与 IO
类似,Task
在我们给它绿灯之前是不会运行的。事实上,正因为它要等我们的命令,IO
实际就被纳入到了 Task
名下,代表所有的异步操作——readFile
和 getJSON
并不需要一个额外的 IO
容器来变纯。更重要的是,当我们调用它的 map
的时候,Task
工作的方式与 IO
几无差别:都是把对未来的操作的指示放在一个时间胶囊里,就像家务列表(chore chart)那样——真是一种精密的拖延术。
我们必须调用 fork
方法才能运行 Task
,这种机制与 unsafePerformIO
类似。但也有不同,不同之处就像 fork
这个名称表明的那样,它会 fork 一个子进程运行它接收到的参数代码,其他部分的执行不受影响,主线程也不会阻塞。当然这种效果也可以用其他一些技术比如线程实现,但这里的这种方法工作起来就像是一个普通的异步调用,而且 event loop 能够不受影响地继续运转。我们来看一下 fork
:
// Pure application
//=====================
// blogTemplate :: String
// blogPage :: Posts -> HTML
var blogPage = Handlebars.compile(blogTemplate);
// renderPage :: Posts -> HTML
var renderPage = compose(blogPage, sortBy('date'));
// blog :: Params -> Task(Error, HTML)
var blog = compose(map(renderPage), getJSON('/posts'));
// Impure calling code
//=====================
blog({}).fork(
function(error){ $("#error").html(error.message); },
function(page){ $("#main").html(page); }
);
$('#spinner').show();
调用 fork
之后,Task
就赶紧跑去找一些文章,渲染到页面上。与此同时,我们在页面上展示一个 spinner,因为 fork
不会等收到响应了才执行它后面的代码。最后,我们要么把文章展示在页面上,要么就显示一个出错信息,视 getJSON
请求是否成功而定。
花点时间思考下这里的控制流为何是线性的。我们只需要从下读到上,从右读到左就能理解代码,即便这段程序实际上会在执行过程中到处跳来跳去。这种方式使得阅读和理解应用程序的代码比那种要在各种回调和错误处理代码块之间跳跃的方式容易得多。
天哪,你看到了么,Task
居然也包含了 Either
!没办法,为了能处理将来可能出现的错误,它必须得这么做,因为普通的控制流在异步的世界里不适用。这自然是好事一桩,因为它天然地提供了充分的“纯”错误处理。
就算是有了 Task
,IO
和 Either
这两个 functor 也照样能派上用场。待我举个简单例子向你说明一种更复杂、更假想的情况,虽然如此,这个例子还是能够说明我的目的。
// Postgres.connect :: Url -> IO DbConnection
// runQuery :: DbConnection -> ResultSet
// readFile :: String -> Task Error String
// Pure application
//=====================
// dbUrl :: Config -> Either Error Url
var dbUrl = function(c) {
return (c.uname && c.pass && c.host && c.db)
? Right.of("db:pg://"+c.uname+":"+c.pass+"@"+c.host+"5432/"+c.db)
: Left.of(Error("Invalid config!"));
}
// connectDb :: Config -> Either Error (IO DbConnection)
var connectDb = compose(map(Postgres.connect), dbUrl);
// getConfig :: Filename -> Task Error (Either Error (IO DbConnection))
var getConfig = compose(map(compose(connectDB, JSON.parse)), readFile);
// Impure calling code
//=====================
getConfig("db.json").fork(
logErr("couldn't read file"), either(console.log, map(runQuery))
);
这个例子中,我们在 readFile
成功的那个代码分支里利用了 Either
和 IO
。Task
处理异步读取文件这一操作当中的不“纯”性,但是验证 config 的合法性以及连接数据库则分别使用了 Either
和 IO
。所以你看,我们依然在同步地跟所有事物打交道。
例子我还可以再举一些,但是就到此为止吧。这些概念就像 map
一样简单。
实际当中,你很有可能在一个工作流中跑好几个异步任务,但我们还没有完整学习容器的 api 来应对这种情况。不必担心,我们很快就会去学习 monad 之类的概念。不过,在那之前,我们得先检查下所有这些背后的数学知识。
一点理论
前面提到,functor 的概念来自于范畴学,并满足一些定律。我们先来探索这些实用的定律。
// identity
map(id) === id;
// composition
compose(map(f), map(g)) === map(compose(f, g));
同一律很简单,但是也很重要。因为这些定律都是可运行的代码,所以我们完全可以在我们自己的 functor 上试验它们,验证它们是否成立。
var idLaw1 = map(id);
var idLaw2 = id;
idLaw1(Container.of(2));
//=> Container(2)
idLaw2(Container.of(2));
//=> Container(2)
看到没,它们是相等的。接下来看一看组合。
var compLaw1 = compose(map(concat(" world")), map(concat(" cruel")));
var compLaw2 = map(compose(concat(" world"), concat(" cruel")));
compLaw1(Container.of("Goodbye"));
//=> Container('Goodbye cruel world')
compLaw2(Container.of("Goodbye"));
//=> Container('Goodbye cruel world')
在范畴学中,functor 接受一个范畴的对象和态射(morphism),然后把它们映射(map)到另一个范畴里去。根据定义,这个新范畴一定会有一个单位元(identity),也一定能够组合态射;我们无须验证这一点,前面提到的定律保证这些东西会在映射后得到保留。
可能我们关于范畴的定义还是有点模糊。你可以把范畴想象成一个有着多个对象的网络,对象之间靠态射连接。那么 functor 可以把一个范畴映射到另外一个,而且不会破坏原有的网络。如果一个对象 a
属于源范畴 C
,那么通过 functor F
把 a
映射到目标范畴 D
上之后,就可以使用 F a
来指代 a
对象(把这些字母拼起来是什么?!)。可能看图会更容易理解:
比如,Maybe
就把类型和函数的范畴映射到这样一个范畴:即每个对象都有可能不存在,每个态射都有空值检查的范畴。这个结果在代码中的实现方式是用 map
包裹每一个函数,用 functor 包裹每一个类型。这样就能保证每个普通的类型和函数都能在新环境下继续使用组合。从技术上讲,代码中的 functor 实际上是把范畴映射到了一个包含类型和函数的子范畴(sub category)上,使得这些 functor 成为了一种新的特殊的 endofunctor。但出于本书的目的,我们认为它就是一个不同的范畴。
可以用一张图来表示这种态射及其对象的映射:
这张图除了能表示态射借助 functor F
完成从一个范畴到另一个范畴的映射之外,我们发现它还符合交换律,也就是说,顺着箭头的方向往前,形成的每一个路径都指向同一个结果。不同的路径意味着不同的行为,但最终都会得到同一个数据类型。这种形式化给了我们原则性的方式去思考代码——无须分析和评估每一个单独的场景,只管可以大胆地应用公式即可。来看一个具体的例子。
// topRoute :: String -> Maybe(String)
var topRoute = compose(Maybe.of, reverse);
// bottomRoute :: String -> Maybe(String)
var bottomRoute = compose(map(reverse), Maybe.of);
topRoute("hi");
// Maybe("ih")
bottomRoute("hi");
// Maybe("ih")
或者看图:
根据所有 functor 都有的特性,我们可以立即理解代码,重构代码。
functor 也能嵌套使用:
var nested = Task.of([Right.of("pillows"), Left.of("no sleep for you")]);
map(map(map(toUpperCase)), nested);
// Task([Right("PILLOWS"), Left("no sleep for you")])
nested
是一个将来的数组,数组的元素有可能是程序抛出的错误。我们使用 map
剥开每一层的嵌套,然后对数组的元素调用传递进去的函数。可以看到,这中间没有回调、if/else
语句和 for
循环,只有一个明确的上下文。的确,我们必须要 map(map(map(f)))
才能最终运行函数。不想这么做的话,可以组合 functor。是的,你没听错:
var Compose = function(f_g_x){
this.getCompose = f_g_x;
}
Compose.prototype.map = function(f){
return new Compose(map(map(f), this.getCompose));
}
var tmd = Task.of(Maybe.of("Rock over London"))
var ctmd = new Compose(tmd);
map(concat(", rock on, Chicago"), ctmd);
// Compose(Task(Maybe("Rock over London, rock on, Chicago")))
ctmd.getCompose;
// Task(Maybe("Rock over London, rock on, Chicago"))
看,只有一个 map
。functor 组合是符合结合律的,而且之前我们定义的 Container
实际上是一个叫 Identity
的 functor。identity 和可结合的组合也能产生一个范畴,这个特殊的范畴的对象是其他范畴,态射是 functor。这实在太伤脑筋了,所以我们不会深入这个问题,但是赞叹一下这种模式的结构性含义,或者它的简单的抽象之美也是好的。
总结
我们已经认识了几个不同的 functor,但它们的数量其实是无限的。有一些值得注意的可迭代数据类型(iterable data structure)我们没有介绍,像 tree、list、map 和 pair 等,以及所有你能说出来的。eventstream 和 observable 也都是 functor。其他的 functor 可能就是拿来做封装或者仅仅是模拟类型。我们身边到处都有 functor 的身影,本书也将会大量使用它们。
用多个 functor 参数调用一个函数怎么样呢?处理一个由不纯的或者异步的操作组成的有序序列怎么样呢?要应对这个什么都装在盒子里的世界,目前我们工具箱里的工具还不全。下一章,我们将直奔 monad 而去。
练习
require('../../support');
var Task = require('data.task');
var _ = require('ramda');
// 练习 1
// ==========
// 使用 _.add(x,y) 和 _.map(f,x) 创建一个能让 functor 里的值增加的函数
var ex1 = undefined
//练习 2
// ==========
// 使用 _.head 获取列表的第一个元素
var xs = Identity.of(['do', 'ray', 'me', 'fa', 'so', 'la', 'ti', 'do']);
var ex2 = undefined
// 练习 3
// ==========
// 使用 safeProp 和 _.head 找到 user 的名字的首字母
var safeProp = _.curry(function (x, o) { return Maybe.of(o[x]); });
var user = { id: 2, name: "Albert" };
var ex3 = undefined
// 练习 4
// ==========
// 使用 Maybe 重写 ex4,不要有 if 语句
var ex4 = function (n) {
if (n) { return parseInt(n); }
};
var ex4 = undefined
// 练习 5
// ==========
// 写一个函数,先 getPost 获取一篇文章,然后 toUpperCase 让这片文章标题变为大写
// getPost :: Int -> Future({id: Int, title: String})
var getPost = function (i) {
return new Task(function(rej, res) {
setTimeout(function(){
res({id: i, title: 'Love them futures'})
}, 300)
});
}
var ex5 = undefined
// 练习 6
// ==========
// 写一个函数,使用 checkActive() 和 showWelcome() 分别允许访问或返回错误
var showWelcome = _.compose(_.add( "Welcome "), _.prop('name'))
var checkActive = function(user) {
return user.active ? Right.of(user) : Left.of('Your account is not active')
}
var ex6 = undefined
// 练习 7
// ==========
// 写一个验证函数,检查参数是否 length > 3。如果是就返回 Right(x),否则就返回
// Left("You need > 3")
var ex7 = function(x) {
return undefined // <--- write me. (don't be pointfree)
}
// 练习 8
// ==========
// 使用练习 7 的 ex7 和 Either 构造一个 functor,如果一个 user 合法就保存它,否则
// 返回错误消息。别忘了 either 的两个参数必须返回同一类型的数据。
var save = function(x){
return new IO(function(){
console.log("SAVED USER!");
return x + '-saved';
});
}
var ex8 = undefined