《I See a Monad in Your Future》翻译

对于一个C++程序员,如果你认为你可以躲在你的舒适区中不用考虑函数式编程,那你就大错特错了!

先是lambdas和std::function对象,再是伪装成std::future的单子(monad),它们为何而来?

不过不用慌,它们都只不过是一些函数式编程模式而已。你不会在设计模式书中找到他们,但是一旦你了解它们,你会发现它们也不过是一些显而易见的模式而已。

让我们从一些背景说明开始:我对C++11中的std::future的设计非常失望,这一点我已经在 Broken Promises — C++0x futures中说明了。我也作出了一些关于如何修改的建议:Futures Done Right。五年过去,一个关于提升std::future的提案N3721被提交至标准委员讨论。我以为它的通过毫无疑问,因为它修复了原先设计中存在的明显缺陷。一周前,我参加了C++标准委员会的会议,但出乎我意料的是,函数式编程中一些常用的设计模式并不为大家所熟知。所以我会尝试解释为什么关于std::future的改进设计是正确的。

关于如何设计程序的争论并不简单。你没有办法从数学上证明一种设计好过另一种,或者某组抽象好过另一组,除非你发现了某种设计存在显然的设计缺陷。从直觉上来说,你也许会觉得某种解决方案是优雅的,但你应该如何评价优雅呢?

庆幸的是,程序库设计有一些广泛接受的评价标准。在我心中,最重要的一个就是正交性,也就是关注点的分离和组合。其次就是该方案是否已经在多种语言中预先实现并测试过。我坚信这一点正是关于std::future的拓展提案面临的处境。我将会一点一点解释一些对于C++程序员来说可能陌生的编程模式,这些模式在其他函数式编程语言中已经得到广泛的使用和测试。这些模式在一些解释性语言中越来越受到欢迎,特别是在处理并行和并发问题时。

存在的问题

简而言之,std::future要解决的问题是得到并行计算或者异步任务的结果。举例来说,你在一个单独的线程(或某种计算单元)中开始了一组计算任务,然后你希望在某个时刻得到计算的结果。这是一种最简单的并发模型——将函数对象(闭包)的执行委派给其他线程。

要从一个线程中返回一个值给另一个线程,你需要某种通信的管道。一个线程设置了管道的值后另一个线程便可以取值。与ML或Haskell提供一种管道抽象的方式不同,C++11将管道分成了两种抽象:promisefuture。前者是管道的输入端,而后者则是管道的输出端(在Rust中,类似的对象是ChanPort)。

常见的做法是创建一个promise,并用get_future得到future,然后将promise传递新的线程。当新的线程中计算结束后,使用set_value设置promise的值。与此同时,原线程可以执行其他的工作,最后使用get方法从future中取出结果。如果promise已经得到结果,get方法会立刻返回该结果,否则线程会阻塞直到promise被赋值。

这种做法带来了一些冗余的代码,标准库提供了一种简化的方式。客户可以使用std::async来调用一个函数对象(闭包),函数计算的结果保存在一个隐式的promise中,而调用的结果正是一个future(出于简化问题的角度我忽略了异常值的处理等问题)。

The Functor Pattern

从抽象角度来说,一个std::future对象的内部封装了一个值。单就这一点来说,除非这种封装带有一些其他的功能或者限制,否则它提供的抽象很难派上用场。举例来说,std::unique_ptr封装了一个值,同时也提供了这个对它的生命周期或内存资源的管理。一个std::future对象封装了一个值,但是在取出这个值时,你可能会被阻塞。函数式编程语言中有一个非常有用的模式来处理这种问题:Functor模式。一个Functor封装了一个任意类型的值,而且允许你通过一个函数来修改它。

需要留意的是,一般Functor并不允许你直接访问内部的值,而是提供了修改它的方式。这种模式的精妙之处在于,以future为例,Functor允许你通过函数修改一个还不存在的值——而且并不会阻塞。当然,从实现的角度来说,你定义的函数(闭包)会被保存在future中,当内部的值可用时函数会自动被调用,而函数内部可以通过get方法来访问该值。

提交给委员会的修改第一部分是将std::future变为一个functor。具体来说,就是给它加上一个新的方法then

1
2
template<typename F>
auto future::then(F&& func) -> future<decltype(func(*this))>;

future的这个方法以一个函数对象func为参数,而这个函数对象正是以该future<T>对象为参数,then方法的返回值是保存了函数对象返回值的future<R>

尽管有些令人费解,但一个future不仅封装了函数执行的结果,而且也保存了函数执行可能的异常。这也正是为什么传递给then方法的函数对象的参数是future<T>而不是T,从future<T>中你可以使用get方法取值,同时也会重新抛出异常(如果存在的话)。另一项提案N3865介绍了另一个方法next,它用于处理值而不是异常。next方法的优点是它的参数可以是一个以值为参数(而不是future<T>)的普通函数。

Functor模式非常适用于组合异步函数(返回一个future)和普通的函数,但也可以帮助你设计一些能够处理任意类型参数的泛型类。在C++中,对参数类型没有限制的类被称为模板类。标准库中的容易大部分都是基于此。对于一个泛型类来说,只有它当提供了对内部值的操作接口时才能被称为Functor。标准库中的大部分容器通过std::transform算法提供了该功能。对于一个习惯于命令式编程的程序员来说,future和容器这两种截然不同的事情竟然属于同一种函数编程模式——Functor,这一点可能会让他们大吃一惊。

与函数式编程语言不通,在C++中没有一种统一的可以重用的Functor模式的表达方式,它更多地是在程序员的脑中。举例来说,出于内存管理的考量,std::tranform对迭代器进行操作而不是直接操作容器,目标容器的存储必须提前分配好或者通过迭代器来适配需求。你可以尝试着为future提供一个迭代适配器,这样就可以利用std::transform来访问future,但最终来说,变换操作依赖future内部实现(比如将函数对象保存在其中),所以变换操作必须是一个方法或者一个future的友元。

The Monad Pattern

Functor模式不足以提供future以组合性。一个常见的应用场景是这样的,用户定义了一组返回future的函数,每一个表示了一种具体的任务,然后用户需要将这些函数组合起来完成更复杂的任务。

以一个组合异步操作的例子来说,如果我们要打开一个文件然后读取它的内容。假定我们有个async_open函数返回一个文件句柄的future:

1
future<HANDLE> async_open(string &);

还有一个async_read函数以文件句柄为参数,返回一个字符buffer的future:

1
future<Buffer> async_read(HANDLE fh);

如果你用next方法将这两个函数组合起来,函数调用的返回值是一个futurefuture:

1
future<future<Buffer>> ffBuf = async_open("foo").next(&async_read);

为了在不阻塞的情况下继续组合函数调用,比如异步处理字符buffer,你需要一种将双重future展开成一个future的办法,然后便可以继续调用next

这种展开的方法unwrap正是关于future拓展提案的另一部分。它以 future<future<T>> 为 参数,返回future<T>。通过在next后调用unwrap的方式,你可以链式组合异步函数。

1
async_open("foo").next(&async_read).unwrap().next(&async_process);

在函数式编程中,这种展开函数被称为join。这种在next后接unwrap(在Haskell中,fmap后接join)的组合方式是如此的常见,甚至有一个单独的名字bind(在Haskell中操作符>>=)。所以如果bindfuture的一个方法的话也不足为奇。实际上,提案(n3721)建议重载then方法使得它能自动展开多个future,这样的话,then方法就是bind了。

另外一个重要的应用场景是:一个可能异步执行的函数有时可能会立刻返回结果。在一些迭代算法中这非常常见,比如当迭代满足终止条件时。举例来说,一个并行式的树遍历算法可能在遍历子节点时异步地进行一些操作,当子节点是一个叶子节点时,操作会同步地返回一个结果。与在代码中对节点设置复杂的条件相比,提供一个返回一个“假“的future——它的get方法不会阻塞——的方式要更轻松一些。通过一个make_ready_future函数来创建一个假的future的方式正是提案的内容之一。

总的来说,next(或者then)和unwrap方法,和make_ready_future函数对于一个函数式编程程序员来说非常容易辨认,因为它们正是所谓的monad模式(在Haskell中,它们对应的是fmapjoinreturn)。这是一种非常通用的模式用于组合返回了一个封装值的函数。通过monad模式你可以直接组合这些函数,而不用在每次组合后手动展开它们的返回值。对于future来说,这是一个非常重要的问题,因为展开future意味着一次对get方法的调用,而这种调用可能带来阻塞,而阻塞不利于并行处理。更好的做法是,你可以创建一组链式的计算任务,然后交给系统来裁定执行的优先顺序。

通过组合nextunwrap(或者bind)和make_ready_future等函数的方式,你可以指定在多个计算任务中数据的依赖关系,然后在运行时尽可能地利用并行能力加速运算。

The Applicative Pattern

thennext适用于一些线性组合的情况:一个函数的返回值(输出)是另一个函数的参数(输入)。但一个更常见的情况是需要将多个异步计算的结果组合起来。在函数式编程中,这种情况被称为将函数应用于多个参数,而这正是被称为Applicative模式的原因。一个函数式编程程序员可以将一个多参数函数提升,使得它能够以future为参数。

与之相对的,在命令式编程中,程序员必须对所有输入的future设置一道屏障(barrier),获取它们内部的值,然后才能将值作为参数传递给函数。提案N3721包括了一个名为when_all的函数用于处理问题的前一部分——设置屏障。它以一组包含了多个future的容器的迭代器,或者任意数量的future为参数,当所有的future参数均被赋值时,返回一个新的future。它抽象了对所有输入的future的一次逻辑与操作。

以迭代器为参数的when_all函数返回future<vector<future<T>>>,而以多个future为参数时返回future<tuple<future<T>>>。用户必须自己从返回值里取出结果。也正是因此,直接对when_all的返回值调用then或者next的方式行不通。

如果你想知道这种链式组合在函数式编程语言中是如何实现的,你就必须理解偏应用(partial application)是什么。一个带有多个输入参数的函数并不需要一次性绑定所有的参数。你可以想象,为有n个参数的函数绑定第一个参数时会返回一个有n-1个的参数的函数。在C++11中,这种方法可以通过std::bind实现,它以一个函数和一些值为参数,返回一个绑定了这些值为参数的函数。

基于这种思想,你可以将一个带有n个参数的函数与一个future绑定,得到一个带有n-1个参数的函数的future。然后你需要处理的问题就变成了如何对一个future对象调用一个future函数,而这正是applicative pattern解决的问题。在Haskell中,Applicative 类定义了操作符<*>来对一个封装的值调用一个封装了的函数。

The Monoid Pattern

并发编程中一种非常常见的场景是,你开启多个计算任务,然后选择最先结束的那一个作为结果。这种同时测试多个算法的做法正是推测计算的基础,当然你也可能在等待任意数量的异步事件发生来进行决策。

你可能希望有一个基本的抽象,能够对两个future进行逻辑或操作。一个函数式编程程序员可能立刻就想到了幺半群。幺半群定义了一个群和一组二元操作。如果说对future的二元操作选择的是先被赋值的那一个,那future的群是什么呢?从定义可知,群的任意两个元素的二元运算结果都是该集合的一个元素。因此我们需要一个被称为”从不(never)”的future,对它的get方法调用会永远阻塞。

实际上,我们可以稍微放宽对never的定义。它不会返回一个值,但是它可以抛出一个异常。这样的future可以被用于定义超时行为。将它和其他的future组合起来后,要么其他的future得到计算结果,要么就会有一个超时异常。

但这不并不是future拓展提案提倡的方式。提案提倡的是一个名为when_any的方法,与when_all类似。用户可以对返回值调用is_ready方法来确定是否得到结果。这种做法的优点是用户仍可以编写等待其他future完成的代码。缺点则是用户需要写许多样板代码,使得程序逻辑变得晦涩。

性能和编程考量

提案N3896:Library Foundations for Asynchronous Operations对使用future作为异步编程的主力的做法表示反对,它指出:一个异步API可能在用户调用then(或next)之前就已经得到了结果,这是会有不必要的同步,可能带来性能的损失。

另一种可选的方法是将函数对象(闭包)直接传递给异步API,因为许多的异步API的底层正是这样实现的(Boost::Asio大法好)。这两种方法并不互斥,它们可以同时存在,正如上述提案中指出的那样,不过这也给编程模型带来了更多的复杂性。

从程序员的角度来看,提案N3896提出的并发模型可能非常难以使用。因为它的编程模型像是一台状态机,用户需要对每个状态的转变定义操作。

Future提供了一种有用的对期待值的抽象。程序员在编写代码时可以假定这些值已经得到。此外,它也提供了一种在并发,并行和异步世界通用的语言。无论值是通过一个新的线程计算得到,还是一个轻量级的代理,亦或是一个异步API的调用得到,值都可以被封装在future中。future的组合性和实用性在函数式编程的各种模式中得到体现。

另一个非常有吸引力的编程模型Resumable Functions也在C++中被提出,它使得异步代码的编写就串行一样简单。在Haskell中,这种做法作为”do”关键字为程序员所熟知。在C++中,一个可重入的函数会通过await关键字被编译器分割成一组连续的动作。与创建一个future并且将它和lambda函数通过then组合的方式不同,程序员可以在函数内部插入await关键字然后像同步编程一样编写代码。

附录

C++17: I See a Monad in Your Future!

[Functional Programming in C++.pdf](Functional Programming in C++.pdf “下载”)