BT

您是否属于早期采用者或者创新人士?InfoQ正在努力为您设计更多新功能。了解更多

函数式反应型编程(FRP) —— 实时互动应用开发的新思路

| 作者 邓际锋 关注 0 他的粉丝 发布于 2013年3月25日. 估计阅读时间: 2 分钟 | 智能化运维、Serverless、DevOps......2017年有哪些最新运维技术趋势?CNUTCon即将为你揭秘!

一、Reactive?

请先看一个非常简单的小应用,它允许用户在一个搜索输入框里输入关键词,然后在其下方的结果区域实时显示从Flicker网站搜索得到的图片,当用户输入的关键词发生变化,显示的图片也会随即跟着发生变化。

这实际上便是一种reactive能力。而类似这种能自动对外部环境的变化作出响应的系统我们称之为反应型系统(Reactive System)。典型的外部环境变化包括外部输入信号的变化、事件的发生,而且系统的响应通常是实时的。互动系统是最重要的一种反应型系统,它不但能响应外部环境的变化,还会将自己的内部状态通过某种方式反馈给外部观察者。

二、如何构建Reactive System?

如果要开发上面的这个Flicker实时搜索小应用,应该怎么做呢?

别忙,程序员在动手开发之前应该首先把软件要做什么尽可能搞清楚。虽然这是个很小的应用,但我们仍然可以把它做的事情用一个简单但更清晰的流程图表示出来:

用户在输入框键入各种关键词向Flicker发出搜索请求,它们就像一道不断流向Flicker服务器的”请求流”,服务器处理完请求后向客户端返回搜索结果,形成了一道“应答流”,客户端将“应答流”转换成“图片流”注入到网页中的结果区域。只要用户的输入发生变化,这些流中的内容也跟着改变,网页中显示的图片也就跟着变化。

功能不复杂,试着把它变成程序。

Callback Style

通过处理相应的事件便能响应用户的输入,那么就可以采用大家所熟悉的注册event callback的方式来做:

//处理response的回调函数
var responseHandler = function (answer)
{
  //根据response创建image nodes
  var nodes = buildImages(answer);
  //用image nodes动态更新显示区域
  swapChildren($('images'), nodes);
}

//搜索框回调函数
var searchHandler = function (txt)
{
  //发送request,并注册该次request的回调
  requestFlickerSearch(txt, responseHandler);
}

//使指定的输入框具有实时flicker搜索能力
function enableFlickerSearch(nodeId)
{
  //注册搜索输入框回调
  registerCallback($(nodeId), searchHandler);
}

enableFlickerSearch('search');

X Style

上面这种方式是大家最熟悉的,也是最常用的。但今天我要给大家看看另外一种不同的方式,暂时称之为X Style吧。

核心代码如下:

//将指定的输入框包装成具有flicker search能力的输入框,
//并返回相关联的实时图片流
function makeFlickerImagesStream(searchEditId)
{
  //根据指定的搜索输入框内容创建request stream
  var requestE = extractValueE(searchEditId).
                  calmE(1000).
                  mapE(flickrSearchRequest);
  //根据request stream创建response stream
  var responseE = getForeignWebServiceObjectE(requestE); 
  //根据response stream创建image DOM stream
  var imagesE = responseE.mapE(createImageNodes);
  return imagesE;
}

function start() 
{
  //创建一个实时图片流,流中的内容根据"search"输入框内容的变化而变化
  var imageNodes = makeFlickerImagesStream("search");

  //用imageNodes图片流里的内容动态更新images节点
  insertDomE(imageNodes,"images");
}

注意到makeFlickerImageStream的实现里有requestE, responseE, imagesE这样的变量,从名字上很容易将它们和之前流程图中的“请求流”“应答流”“图片流”对应起来。

目前为止,这两种方式看起来代码量差不多,没感觉X风格有什么特别的。 好,假设这个Flicker实时搜索框被做成了独立的控件,对于使用者来说是一个黑盒子,内部实现不可见,如果想添加一个黄色图片过滤功能,不是一古脑儿显示搜索得到的所有图片,而是过滤掉黄色图片只在结果区域显示健康图片,这时该怎么做?

对于callback风格的实现来说,应用代码只有这一行,其它的都不可见。

enableFlickerSearch('search');

你能想到一种不修改搜索框控件内部实现的方案吗?至少我没有想到。

一个比单纯修改模块内部实现更好的方案也许是同时修改接口和控件实现以便让用户去定制如何处理response:

enableFlickerSearch('search', customResponseHandler);

那么用户可以在这个定制的处理函数中先过滤图片再显示出来。

对于X风格实现,start函数才是应用层的代码。如果要过滤掉黄色图片,很简单,只要将最后一行代码改变一下就行了:

function start() 
{
  //创建一个实时图片流,流中的内容根据"search"输入框内容的变化而变化
  var imageNodes = makeFlickerImagesStream("search");

  //用指定的健康图片判断函数healthy
  //对imageNodes进行过滤得到健康图片流
  var healthyImageNodes = 
        imageNodes.filterE(healthy);
  //用过滤得到的健康图片流里的内容动态更新images节点
  insertDomE(healthyImageNodes,"images");
}

看见了吗?完全不需要修改控件内部的实现就轻松实现了新功能。

三、另外一个例子-Drag and Drop

现在请看另一个鼠标拖拽的小例子

对于这样一种交互操作,如果抛开实现细节,用自然语言通常会怎么描述它呢?我想极有可能是这样:

当鼠标左键按下时便开始移动拖拽,直到鼠标左键抬起时停止。

这句话实际上定义出了拖拽交互的功能规约(function specification),有了规约便可以写出代码。

这次先来看看X风格的代码实现:

function start() {  
  //为节点创建和它绑定的拖拽消息流
  var posE = dragE($('dragTarget'));

  //根据拖拽消息去调整节点的位置
  posE.doE(function(m) {
    $('dragTarget').style.left = m.clientX;
    $('dragTarget').style.top = m.clientY;
  });
}

这段代码很简单,注释里的解释应该清楚了。但是,最关键的用来创建拖拽消息流的dragE是怎么实现的呢?它在这里:

function dragE(target) 
{
  //鼠标左键按下消息流
  var mousedown = extractEventE(target,'mousedown');
  //鼠标左键抬起消息流
  var mouseup = extractEventE(document, "mouseup");
  //鼠标移动消息流
  var mousemove = extractEventE(document, "mousemove");
  //返回一个拖拽消息流:
  //每当有鼠标左键按下消息时它就输出鼠标移动消息,
  //直到鼠标左键抬起才停止输出鼠标移动消息,
  //当下一次鼠标左键按下时又重复以上模式。
  return mousedown.thenE(function (_)
                         { 
                           return mousemove.untilE(mouseup);
                         });
}

这些代码暂时看不懂没关系,只要注意最后return出去的表达式中的thenE和untilE,这和之前自然语言描述中的“当...发生时便开始... ,直到....”一一对应,代码就象对自然语言的直接翻译!

如果换用callback的方式怎么做呢?这里就不写了,读者可以自己尝试一下,看看最终出来的代码的语义和自然语言的描述差别有多大。

四、Functional Reactive Programming

上面两例中X风格的代码主要描述系统中数据(消息)流的结构关系,至于环境变化如何导致某些数据流变化,这些变化了的数据流又如何导致其它的相关数据流变化,压根儿不需要关心。这是一种编程范式,称之为Reactive Programming

而主要利用函数式编程(Functional Programming)的思想和方法(函数、高阶函数)来支持Reactive Programming就是所谓的Functional Reactive Programming,简称FRP。

现在可以给X正名了,它就是FRP。

FRP vs. Callback

程序员在写代码之前,首先会需要一个功能规约,而规约的描述形式并不固定,可以是流程图(第一个例子),可以是自然语言(第二个例子),也可以是其它的方式,只要能精准方便地表达意图就好。

当有了规约之后,FRP编程就像照镜子,代码基本上就是规约的直接映射。

而写callback风格的代码,就得费力地咀嚼规约,艰难地消化,最后挤出的代码就像一坨缠来绕去的线团,它呈现出来的样子和程序真正的意图相去甚远。

为什么?

为什么callback风格的代码会表现成这样,背后的原因是什么?请读者带着这个问题边思考边往下看,在文章的后面作者会给出自己的结论。

五、Flapjax

本文的所有示例都利用了一个叫Flapjax的库。简单来说,它就是一个支持FRP的JavaScript框架。接下来将以它为参考详细介绍FRP的各种概念。

1、基本思想

引起实时互动系统发生变化的最根本的自变量是时间,变化的环境和各种事件(比如键盘、鼠标、网络)归根结底都是由时间的变化引起的,时间是本质特征,从一开始就要考虑。

那些对外部环境具有反应能力、随时间变化的数据被抽象为一种叫信号(Signal)的概念。

2、信号

信号分为两种,分别叫事件流(Event Stream)和行为(Behavior)。

Event Stream

事件流可以看成时间轴上无限长的数据流,这个里面流淌的数据代表着一个个事件,它们在时间轴上是离散的。

Behavior

行为也是随时间变化的数据,它在时间轴上是连续的。

信号的类型

信号(EventStream和Behavior)都是First-Class Value,所以它们也有自己的类型。 信号所代表的随时间变化的数据的类型X决定了信号的类型。记为:EventStream X 或 Behavior X。

比如:

  • 代表输入框内容变化的事件流,类型为EventStream String
  • 随时间不停变化的数值,类型为Behavior Number

函数角度看信号

如果从函数式编程的角度来看,behavior可以看成这样一个函数:输入某个时刻,它计算得到在那个时刻自身的采样值。

同样地,event stream也可以看成这样一个函数:输入也是某个时刻,如果在这个时刻有事件发生就把相应的event数据返回出来,否则就返回一个特殊的nothing表示没有任何事件发生。

3、Event Stream

基本API

//为指定DOM节点创建鼠标左键按下的事件流
es1 = extractEventE(targetDom, "mousedown");

//为指定DOM节点创建内容动态更新事件流,
//每当节点内容发生变化,这个事件流里都会产生相应的事件
es2 = extractValueE(searchEdit);

//创建会且只会发生一个事件的事件流,
//这个唯一的事件所携带的信息是调用者传入的参数
es3 = oneE("hello world");

//永远也不会有事件发生的事件流
nothingEs = zeroE();

//将事件流中的动态内容注入到指定的dom节点上,
//此后只要事件流里有新的事件发生,
//这个节点的内容就会跟着新事件的内容自动发生变化。
insertDomE(es2, "result");

转换和组合

除了基本API,框架还提供了相应的API对事件流进行转换或者将多个事件流组合成新的更复杂的事件流。常用的有:

  • 过滤
    filterE(sourceEs, pred)会用谓词函数pred对事件流sourceEs进行过滤生成一个新的事件流,所有出现在sourceEs并且被pred判断为true的事件都会出现在新生成的事件流中。

  • 映射
    mapE(f, sourceEs)会用转换函数f对事件流sourceEs进行转换生成一个新的事件流,所有出现在sourceEs中的事件都会被f转换成新的事件并出现在新生成的事件流中。

  • 合并
    mergeE(es1, es2)会把两个事件流es1和es2合并成一个新的事件流,所有出现在es1或者es2中的事件都会出现在新生成的事件流中。

  • 直到
    es1.untilE(es2)会根据两个事件流es1和es2生成一个新的事件流,es1出现的所有事件都会出现在新生成的事件流中,直到es2的第一个事件发生,此后结果事件流中将不再发生任何事件。就好像es2中的事件是一个永久的阻断信号,它一旦出现,意味着此后es1中的事件都被阻断不再进入结果事件流中。

  • 当...发生时便...
    srcEs.thenE(f)会根据事件流srcEs和函数f生成一个新的事件流,这里的f必须是一个输入为event输出为EventStream的函数。每当srcEs中出现一个事件,都会把这个事件传给f计算得到一个临时事件流,接下来这个临时事件流中的事件都会出现在最终的结果事件流中,直到srcEs中再次有新的事件发生,又会重新调用f得到另一个临时事件流并替换掉老的,同时结果事件流开始接收这个最新产生的临时事件流中的事件。srcEs中不断有事件发生,上述过程也就不断重复。在前文鼠标拖拽的例子中,正是这个方法使得拖拽事件流的表达非常接近自然语言的描述。熟悉FP的读者一定已经发现,如果把事件流看成是[Monad](http://en.wikipedia.org/wiki/Monad(functionalprogramming))的话,那么thenE就相当于它的bind操作。实际上在Flapjax里,这个方法的名字确实是叫bindE,为了使代码的可读性更好,作者对Flapjax做了小小的修改和扩展,为bindE取了个别名thenE。本文所有示例使用的都是这个定制版Flapjax。

4、Behavior

基本API

//最基本的behavior就是时间本身!
//timerB创建一个表示系统时间值的behavior,
//输入参数表示它每隔多长时间(毫秒)更新一次
behavior1 = timerB(1000);

//创建一种特殊的常量型behavior
//任何时刻它的值都等于你传进去的值
cb = constantB("老子是个常量");

//valueNow获取behavior在当前时刻的值
v = valueNow(behavior1);

//为指定DOM节点创建相关的behavior,
//它的值会自动随着节点的内容变化而变化
domb = extractValueB(dom);

//把behavior的动态内容注入到dom节点上,
//此后只要behavior的值发生变化,
//这个dom节点的内容就会自动发生变化。
insertDomB(behavior1, "timer");

Lift

Number类型的数据有加减乘除等运算操作,和它对应的Behavior Number类型的行为也有相应的addB、subB、mulB、divB这些运算操作。比如这样一个调用

b3 = addB(b1, b2);

就会根据b1和b2这两个行为生成一个新的行为b3,并且任何时刻b3的值都会等于同时刻b1和b2的值之和。其它运算符的语义可以依此类推。有了这些操作后就意味着behavior是可以直接运算的!这使得我们可以像写普通运算表达式那样方便地创建复杂行为。

addB可以看成是把+这个原本作用在Number类型数据上的运算提升(lift)后得到的作用在Behavior Number类型上的运算,其它运算符同此类比。

Flapjax提供了lift这个API,它能将计算普通值的函数提升为计算相应behavior的函数。比如标准数学库中的sqrt函数的类型是输入参数为Number类型输出结果为Number类型:

Math.sqrt: Number -> Number

它的提升版本(lifted version)可以这样得到:

sqrtB = lift(Math.sqrt);

得到的sqrtB是一个新的函数,类型是输入参数为Behavior Number输出结果为Behavior Number:

sqrtB: Behavior Number -> Behavior Number

很明显,lift是一个输入参数为函数输出结果也为函数的高阶函数。

接下来就可以直接调用sqrtB:

//b1是个behavior,b2是调用生成的新的behavior,
//b2在任何时刻的值都等于b1同时刻值的平方根。
b2 = sqrtB(b1); 

实际上之前的addB的定义是这样的:

addB = lift(function (v1,v2){
              return v1 + v2;
            });

请看这个演示,它通过以上介绍的方式定义了多个behavior,并将它们在网页上呈现出来。

5、更多的信号和组合操作

Flapjax还提供了更多的信号:

mouseLeftB
mouseTopB
getWebServiceObjectE(requestE)
......

更多的组合操作:

collectE
calmE
switchB
condB
......

具体请查阅参考手册。

六、FRP的优势

在FRP的编程模型里,基本信号可以在不做任何修改的情况下被转换或者组合成新的复杂信号,而新的复杂信号又可以在不做任何修改的情况下被转换或者组合成更复杂的信号,就像乐高积木的搭建,这个过程可以一直进行下去,直到构建出足够复杂的信号以满足系统的需求。

这正是程序员梦寐以求的组合能力(Composability)!

现在重新回到前文提出的那个问题,为什么callback风格的代码总是像一坨线团一样那么地杂乱?让我们先来看看一个通用的event-driven框架大概是什么样的:

请读者回想一下,程序中注册的事件回调处理函数的返回值一般都是什么类型?想必要么是void要么是一个表示执行状态的状态码,不太可能让返回值可以随意使用各种数据类型,这是因为一个事件驱动框架要通用地处理各种callback,就只能让它的返回值类型足够通用,void便是首选。因此,回调与回调之间是无法直接传递类型丰富的数据的,它们只能通过修改应用程序的共享状态来间接地通讯,这迫使程序员不得不把应用逻辑分割得支离破碎,从而丧失了核心的组合能力!这便是callback风格程序的最大问题。

Callback模型关注控制流,但它对控制流的描述不具有很好的组合性。FRP模型换了一个视角,关注数据流,且数据流的组合能力极佳,使得代码更接近于只描述做什么(what)的声明式(declarative)代码,而不是描述怎么做(how)的命令式(imperative)代码,相当简洁和直观,更符合人的自然思维!

七、实现简述

实现一个FRP框架最关键的是要在信号之间合理有序地传播变化,通常分为两种做法:

1、Push方式

信号主动把变化传播给受影响的其它信号。

  • 优点:外部的变化是同步处理的,系统的反应延迟小。
  • 缺点:
    • 所有构建出来的信号不管程序是否真的需要,都会参与更新和计算,有可能存在大量无用功;
    • 无用信号必须显式地从数据流网络中删除才有可能被回收,资源管理较麻烦,常常造成资源泄漏。

2、Pull方式

信号主动去查询可能会影响到自己的信号是否发生了变化,如果发生变化就进行重计算。

  • 优点:
    • 按需计算,只有真正被程序需要(即被取值)的信号才参与更新和计算。
    • 信号在程序里没有了任何引用就会被自动回收掉,资源管理简单。
  • 缺点:
    • 外部的变化采用异步处理,系统的反应延迟依赖于pull的频率。
    • 当没有变化发生时,仍然会以固定频率pull,也存在一定的消耗。

Flapjax采用的是push的方式。

八、其它的FRP框架

FRP最早发源于Haskell社区,在Haskell社区里有许多关注点不同实现方式各异的FRP框架,比如:

  • Fran
  • Yampa
  • Reactive
  • ....

FrTime是用Racket(Scheme)语言实现的一款FRP框架,它背后的团队和Flapjax的团队是同一个,所以它们在理念、设计和实现上都极为相似。

LuaTime是笔者在几年前为Lua语言实现的FRP框架,它在API的设计上主要参考了FrTime,但是底层的实现采用了pull的方式。这个项目计划在今年年内开源。

九、相关研究

  • Microsoft Reactive Extensions
    微软用于解决实时互动、异步编程问题的跨语言的开发框架,基本思想和FRP极为类似:将变化的数据抽象出来,称之为IObservable,并为其定义各种转换和组合操作。它背后的主要设计者是Haskell社区的大牛。
  • Arrowlets
    FRP关注数据流,Arrowlets和callback一样关注控制流,但它利用[Arrow](http://en.wikipedia.org/wiki/Arrow(computerscience) )这种计算模型使得控制流具备了很好的组合能力。
  • Promise or Future
    在JavaScript社区中很流行的对异步编程的解决方案,有无数的实现库。
  • Sandglass
    笔者所在团队设计的一种基于Behavior Tree模型的AI编程语言,为Behavior Tree引入了协作式多任务机制和显式的时间控制机制,支持以同步化的思维来写异步程序,能编译成标准的JavaScript。

关于作者

邓际锋,来自网易公司杭州研究院前台技术中心引擎技术组。

我们团队的努力方向包括:

  • 开发跨平台实时互动多媒体应用解决方案;
  • 为儿童和非技术人员开发更自然更有趣的创作工具;
  • 探索各种更自然高效的编程模型并积极实践;
  • 编程语言及相关工具的设计与实现;

你可以通过新浪微博@邓际锋或者邮箱soloist.deng at gmail.com与我联系。

评价本文

专业度
风格

您好,朋友!

您需要 注册一个InfoQ账号 或者 才能进行评论。在您完成注册后还需要进行一些设置。

获得来自InfoQ的更多体验。

告诉我们您的想法

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

如何销毁对象? by 李 宇翔

我想知道当产生大量的EventStream对象之后,如何销毁,如何撤销事件监听

Re: 如何销毁对象? by Deng Soloist

Flapjax的behavior最终也是用EventStream来实现的,因为采用了push方式来传播变化,所以EventStream有一个removeListener方法用于撤销监听关系,但不知为什么,手册文档里并没有列出该方法(同样情况的还有bindE方法)。信号之间的依赖关系管理是push style的最大问题,在这方面Flapjax仅仅是做了最最基础的事情,如果是pull style,那情况就简单得多了。要是你想在实际产品中使用frp模型的话,建议考察微软的Reactive Extension,底层同样采用push方式实现,但是提供了一套系统结构化的依赖关系管理接口,相当程度上减轻了这方面的负担。

Re: 如何销毁对象? by 李 宇翔

其实我是想移植该库到as3.0中,因为flash是事件驱动的,最需要这个了,但如果没有很好的回收的话,很容易内存泄漏。我仔细看了一下源码,EventStream是以树状结构依赖在一起,新生成的对象的引用是挂在老的对象的sendTo数组中的,是否可以增加一个dispose方法,递归调用sendTo数组里的对象然后释放sendTo数组并且removeListener从而达到回收内存的效果呢?

Reactive Programming中文好像常叫做“响应式编程” by Jeffrey Zhao

如题。

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

允许的HTML标签: a,b,br,blockquote,i,li,pre,u,ul,p

当有人回复此评论时请E-mail通知我

4 讨论

登陆InfoQ,与你最关心的话题互动。


找回密码....

Follow

关注你最喜爱的话题和作者

快速浏览网站内你所感兴趣话题的精选内容。

Like

内容自由定制

选择想要阅读的主题和喜爱的作者定制自己的新闻源。

Notifications

获取更新

设置通知机制以获取内容更新对您而言是否重要

BT