什么是(函数式)响应式编程?

1147

我已经阅读过维基百科上关于反应式编程的文章,还读了一篇关于函数响应式编程的小文章。这些描述非常抽象。

  1. 实际上,函数响应式编程 (FRP) 是什么意思?
  2. 与非响应式编程相比,什么构成了反应式编程?

我的背景是命令式/OO语言,因此希望能够提供与该范式相关的解释。


159
这篇文章是一个想象力丰富、讲故事能力很强的人对整个反应式编程的看法。http://www.paulstovell.com/reactive-programming - melaos
39
有人真的需要为我们这些自学者编写一本《白痴的函数响应式编程指南》。 我发现的所有资源,甚至是Elm,都似乎假定你在过去五年中获得了计算机科学硕士学位。 熟悉FRP的人似乎完全失去了从天真的角度看待问题的能力,而这对教学、培训和宣传至关重要。 - TechZen
26
另一个优秀的FRP介绍:由我的同事André撰写的《您一直缺乏的响应式编程介绍》(The introduction to Reactive Programming you've been missing),请参考链接: https://gist.github.com/staltz/868e7e9bc2a7b8c1f754。 - Jonik
5
我见过的最好的之一,以示例为基础:https://gist.github.com/staltz/868e7e9bc2a7b8c1f754 - Razmig
2
我发现电子表格的比喻非常有帮助,作为第一次粗略印象(请参见Bob的答案:https://dev59.com/cHNA5IYBdhLWcg3wQ7m4#1033066)。 电子表格单元格对其他单元格的更改做出反应(拉),但不会去改变其他单元格(不推)。 最终结果是您可以更改一个单元格,而其他无数单元格将“独立地”更新其自己的显示。 - Jon Coombs
显示剩余3条评论
18个回答

930
如果你想了解FRP的感觉,可以从1998年的Fran tutorial开始,其中有动画插图。至于论文,可以从Functional Reactive Animation开始,然后跟进我主页上的出版物链接和Haskell wiki上的FRP链接。
个人而言,在讨论如何实现之前,我喜欢思考FRP的含义。 (没有规范的代码是没有问题的答案,因此“不正确”) 因此,我不像Thomas K在另一个答案中描述的那样,用表示/实现术语描述FRP(图形、节点、边缘、触发、执行等)。 有许多可能的实现风格,但没有一种实现能说明FRP是什么。
我赞同Laurence G简单的描述,即FRP是关于“代表随时间变化的值的数据类型”。 传统的命令式编程只间接地捕捉这些动态值,通过状态和突变。 完整的历史记录(过去、现在、未来)没有第一类表示。 此外,只有离散演化的值才能(间接地)捕获,因为命令式范式是时间离散的。 相比之下,FRP直接捕捉这些演化的值,并且对于连续演化的值没有困难。

FRP也很不寻常,因为它是并发的,而不会违反困扰命令式并发的理论和实践的问题。 从语义上讲,FRP的并发是细粒度确定性连续性的。 (我说的是意义,而不是实现。一个实现可能涉及并发或并行,也可能不涉及。) 语义确定性对于推理(严格和非正式)非常重要。 虽然并发将使命令式编程变得非常复杂(由于非确定性交错),但在FRP中却是毫不费力的。

那么,FRP是什么? 你自己可能已经发明了它。 从这些想法开始:

  • 动态/演化的值(即“随时间变化”的值)本身就是一等值。您可以定义它们并组合它们,将它们传递到和从函数中传递出来。我称这些东西为“行为”。

  • 行为由一些基元组成,例如常量(静态)行为和时间(如时钟),然后使用顺序和并行组合。通过在静态值上连续地进行“点对点”n元函数应用来组合n个行为。

  • 为了解释离散现象,还有另一种类型(系列)的“事件”,每个事件都有一系列(有限或无限)的发生。每次发生都有一个关联的时间和值。

  • 为了找出所有行为和事件可以构建的组合词汇,可以尝试一些例子。保持分解成更通用/简单的部分。

  • 为了确保您在坚实的基础上,使用指示语义的技术给整个模型提供组合基础,这意味着(a)每种类型都有相应的简单且精确的“含义”数学类型,以及(b)每个原始和运算符都具有简单且精确的意义作为成分含义的函数。您的探索过程中永远不要混合实现考虑因素。如果此描述对您来说是无意义的,请参阅(a)通过类型类态射进行指示性设计,(b)推-拉函数响应式编程(忽略实现部分),以及指示语义 Haskell wikibooks页面。请注意,指示语义有两个创始人Christopher Strachey和Dana Scott的两个部分:更容易且更有用的Strachey部分和更难且对软件设计不太有用的Scott部分。

如果你坚持这些原则,我相信你会得到一些或多或少符合FRP精神的东西。
我从哪里得到这些原则?在软件设计中,我总是问同样的问题:“这是什么意思?”。 指称语义为我提供了一个精确的框架来回答这个问题,而且它符合我的审美(不像操作语义或公理语义,两者都让我感到不满意)。 所以我问自己行为是什么? 我很快意识到,命令式计算的时间离散性是对特定机器风格的一种适应,而不是行为本身的自然描述。 我能想到的最简单的行为精确描述是“(连续)时间的函数”,因此这是我的模型。 令人愉悦的是,这个模型轻松优雅地处理连续、确定的并发。
实现这个模型正确和高效地是一个相当大的挑战,但这是另一个故事。

78
我已经意识到函数响应式编程。它似乎与我自己的研究(交互式统计图形)相关,我相信其中许多想法对我的工作会有所帮助。但是,我发现要理解这个主题,必须掌握“指称语义”和“类型类态射”等专业术语,这令我感到很困难。一份针对普通读者的介绍将非常有用。 - hadley
212
@Conal:你显然知道自己在说什么,但你的用语假定我拥有计算数学博士学位,而我并没有。我的背景是系统工程,具有20多年的计算机和编程语言经验,但我仍感到你的回答让我困惑不解。我向你发起挑战,请求你重新用英语发布你的回答;-) - mindplay.dk
50
您的评论并没有给我足够的线索,让我明确您不理解哪些特定内容,而且我也不愿意猜测您需要哪个具体方面的英语知识。但是,我邀请您明确指出我上面解释中让您困惑的部分,这样我和其他人就可以帮助您了。例如,您是否需要解释某些特定单词或者加入相关概念的参考资料?我非常乐意提高我的写作清晰度和易读性,而不会简化内容。 - Conal
27
“确定性/确定”意味着存在一个明确的正确值。相比之下,几乎所有形式的命令式并发都可以根据调度程序或者你是否在查看以及甚至可能出现死锁而得出不同的答案。“语义”(更具体地说是“指称语义”)是指表达式或表示形式的值(“指称”),与“操作性”(如何计算答案或由什么类型的机器消耗多少空间和/或时间)相对应。 - Conal
18
我同意@mindplay.dk的看法,尽管我不能够夸口在这个领域已经有很长时间了。即使你似乎知道你在谈论什么,但它并没有像我在SO上期望的那样给我一个快速、简洁和简单的理解。这个答案主要让我产生了一堆新问题,而并没有真正回答我的第一个问题。我希望分享我在这个领域相对无知的经验,可以让你了解到你需要多么简单和简洁。顺便说一下,我和提问者来自类似的背景。 - Aske B.
显示剩余16条评论

737

在纯函数式编程中,不存在副作用。然而,在许多类型的软件中(例如任何用户交互的情况下),副作用在某个层面上是必要的。

在保留函数式风格的同时实现类似于副作用的行为的一种方法是使用函数响应式编程。这是函数式编程和响应式编程的结合体。(您链接的维基百科文章是关于后者的。)

响应式编程背后的基本思想是有某些数据类型表示值“随时间”变化。涉及这些随时间变化的值的计算本身也会随时间变化而产生值。

例如,您可以将鼠标坐标表示为整数-随时间变化的值对。假设我们有类似以下的东西(这是伪代码):

x = <mouse-x>;
y = <mouse-y>;

在任意时刻,x和y将具有鼠标的坐标。与非响应式编程不同,我们只需要进行一次此类赋值,x和y变量就会自动地保持“最新状态”。这就是为什么响应式编程和函数式编程能够很好地结合使用的原因:响应式编程消除了变量突变的需求,同时仍然让您执行许多可以通过变量突变实现的操作。

如果我们根据此进行一些计算,则生成的值也将是随时间变化的值。例如:

minX = x - 16;
minY = y - 16;
maxX = x + 16;
maxY = y + 16;

在这个示例中,minX将始终比鼠标指针的x坐标小16。使用响应式感知库,您可以这样说:

rectangle(minX, minY, maxX, maxY)

当鼠标指针移动时,将绘制一个32x32的方框并在其周围跟踪它。

这里有一篇关于函数式反应编程的相当不错的论文


25
那么响应式编程是声明式编程的一种形式吗? - troelskn
31
那么,反应式编程是声明式编程的一种形式吗?函数式反应式编程是函数式编程的一种形式,而函数式编程是声明式编程的一种形式。 - Conal
7
不是的。例如,如果我在C语言中用你的宏调用sqrt(x),那就只会计算sqrt(mouse_x())并返回一个双精度浮点数。在真正的函数响应式系统中,sqrt(x)将返回一个新的“随时间变化的双精度浮点数”。如果你试图用#define模拟FR系统,你几乎必须放弃使用变量,转而使用宏。FR系统通常只在需要重新计算时才进行重新计算,而使用宏意味着你将不断地重新评估所有表达式,一直到子表达式为止。 - Laurence Gonsalves
4
@tieTYT x从未被重新分配/改变。x的值是随时间变化的一系列值。另一种看待它的方式是,与其说x具有“正常”的值,如数字,不如说x的值(概念上)是一个以时间为参数的函数。(这有点过于简化。您无法创建时间值来预测诸如鼠标位置之类的事物的未来。) - Laurence Gonsalves
4
Elm 是一种函数式响应式编程语言(FRP),可编译成 HTML、CSS 和 JS。 - Marimuthu Madasamy
显示剩余16条评论

144

一个简单的方法来直观地理解是将你的程序想象成一个电子表格,而所有的变量就是单元格。如果电子表格中的任何一个单元格发生变化,那些引用了该单元格的其他单元格也会随之改变。在FRP中也是一样的道理。现在想象一下,有些单元格会自动改变(或者说从外部世界获取):在GUI情况下,鼠标的位置就是一个很好的例子。

这个比喻肯定还有很多遗漏的地方。当你真正使用FRP系统时,这个比喻很快就会失效。首先,通常还会尝试对离散事件进行建模(例如鼠标点击)。我只是把这个放在这里是为了让你有个大概的了解。


3
一个非常贴切的例子。拥有理论知识是很好的,也许有些人可以在没有实际例子的情况下理解其含义,但我需要从对我有用的东西开始,而不是抽象的东西。我最近才明白(通过 Netflix 的 Rx 讲座!),RP(或 Rx)使这些“可变值”成为一流公民,并允许您对它们进行推理,或编写使用它们的函数。可以编写函数来创建电子表格或单元格。它还处理值何时结束(消失)并让您自动清理。 - Benjohn
这个例子强调了事件驱动编程和反应式方法之间的区别,其中您只需声明依赖项以使用智能路由。 - kinjelom

131
对我而言,符号=有两个不同的意思:
  1. 在数学中,x = sin(t)的意思是xsin(t)另一个名称。因此,写x + y就相当于写sin(t) + y。在这方面,函数响应式编程就像数学一样:如果你写x + y,它会根据使用时t的值来计算。
  2. 在类C编程语言(命令式语言)中,x = sin(t)是赋值操作:它意味着x存储在赋值时sin(t)

5
好的解释。我认为您还可以补充说,FRP中的“时间”通常指“来自外部输入的任何变化”。每当外部力量改变FRP的输入时,您就会将“时间”向前推进,并重新计算受更改影响的所有内容。 - Didier A.
4
在数学中,x = sin(t)意味着对于给定的txsin(t)的值。它 不是 sin(t)函数的另一个名称。否则它将是x(t) = sin(t) - Dmitri Zaitsev
+Dmitri Zaitsev 等号在数学中有几种含义。其中之一是,每当您看到左侧时,都可以将其与右侧交换。例如 2 + 3 = 5a**2 + b**2 = c**2 - user712092

69

好的,从背景知识和你提供的维基百科页面阅读来看,反应式编程似乎类似于数据流计算,但是具有特定的外部"刺激"来触发一组节点进行计算。

这非常适合UI设计,例如在音乐播放应用程序中触摸用户界面控件(比如音量控制)可能需要更新各种显示项和实际音频输出的音量。当你修改音量(比如滑块)时,相应地也会修改指向有向图中节点关联的值。

与该"音量值"节点有边相连的各种节点会自动被触发,并且任何必要的计算和更新都会自然地涟漪传递到应用程序中。应用程序对用户刺激做出"反应"。函数式反应式编程只是在函数式语言或通常在函数式编程范例中实现这个思想。

更多关于"数据流计算"的内容,请在维基百科或您喜欢的搜索引擎上搜索这两个词。总体思路是:程序是一个由节点组成的有向图,每个节点执行一些简单的计算。这些节点通过图链接连接在一起,提供一些节点的输出作为其他节点的输入。

当一个节点触发或执行其计算时,连接到其输出的节点会"触发"或"标记"其对应的输入。任何所有输入都被触发/标记/可用的节点都会自动触发。根据反应式编程的具体实现方式,图可能是隐式的或显式的。

节点可以并行地触发,但通常它们是串行执行或受限的并行性(例如,可能有少量线程在执行它们)。一个著名的例子是曼彻斯特数据流机,它(如果我没记错的话)使用标记数据架构通过一个或多个执行单元调度图中节点的执行。数据流计算非常适合异步触发计算引起级联计算的情况,而不是尝试由时钟(或时钟)来管理执行。

响应式编程引入了“执行级联”的思想,将程序视为一种类似于数据流的方式,但前提是其中一些节点与“外部世界”相连,当这些感知类节点发生变化时就会触发级联执行。程序执行看起来就像复杂的反射弧一样。程序可能在刺激之间基本上是静止的,也可能在刺激之间进入基本稳态。

“非响应式”编程将具有非常不同的执行流和与外部输入的关系。这可能有点主观,因为人们很可能会倾向于说任何响应外部输入的东西都“响应”它们。但从整体上看,一个轮询事件队列并将找到的任何事件分派给功能(或线程)的程序会更少地响应(因为它只在固定时间间隔内处理用户输入)。再次强调这里的意图:可以想象在系统的非常低层级中使用快速轮询间隔的轮询实现,并在其上以响应式的方式进行编程。


1
好的,现在上面有一些很好的答案了。我应该删除我的帖子吗?如果我看到两三个人说它没有任何意义,除非它的有用计数增加,否则我会将其删除。除非它添加了一些价值,否则留在这里没有任何意义。 - Thomas Kammeyer
3
你提到了数据流,我认为这增加了一些价值。 - Rainer Joswig
3
对我来说,这个答案是最容易理解的,特别是因为使用了自然类比,比如“在应用程序中产生涟漪”和“类似感官的节点”。太好了! - Akseli Palén
请保留这个内容。它很有帮助。当你在理解新事物时,多角度的观点有助于最终理解。 - Charlie Flowers
1
不幸的是,曼彻斯特数据流机器的链接已经失效。 - Pac0
显示剩余4条评论

65

经过阅读许多有关FRP的页面后,我终于发现了这篇关于FRP的启示性文章,它让我最终理解了FRP的真正含义。

以下是Heinrich Apfelmus(reactive banana的作者)的引述。

What is the essence of functional reactive programming?

A common answer would be that “FRP is all about describing a system in terms of time-varying functions instead of mutable state”, and that would certainly not be wrong. This is the semantic viewpoint. But in my opinion, the deeper, more satisfying answer is given by the following purely syntactic criterion:

The essence of functional reactive programming is to specify the dynamic behavior of a value completely at the time of declaration.

For instance, take the example of a counter: you have two buttons labelled “Up” and “Down” which can be used to increment or decrement the counter. Imperatively, you would first specify an initial value and then change it whenever a button is pressed; something like this:

counter := 0                               -- initial value
on buttonUp   = (counter := counter + 1)   -- change it later
on buttonDown = (counter := counter - 1)

The point is that at the time of declaration, only the initial value for the counter is specified; the dynamic behavior of counter is implicit in the rest of the program text. In contrast, functional reactive programming specifies the whole dynamic behavior at the time of declaration, like this:

counter :: Behavior Int
counter = accumulate ($) 0
            (fmap (+1) eventUp
             `union` fmap (subtract 1) eventDown)

Whenever you want to understand the dynamics of counter, you only have to look at its definition. Everything that can happen to it will appear on the right-hand side. This is very much in contrast to the imperative approach where subsequent declarations can change the dynamic behavior of previously declared values.

所以,在我理解中,FRP程序是一组方程: enter image description here j是离散的:1、2、3、4... f取决于t,因此可以模拟外部刺激 程序的所有状态都封装在变量x_i中 FRP库负责推进时间,换句话说,将j带到j+1。 我在this视频中更详细地解释了这些方程。
编辑: 原回答大约两年后,最近我得出结论,FRP实现还有另一个重要方面。它们需要(通常也会)解决一个重要的实际问题:缓存失效。

x_i的方程描述了一个依赖图。当一些x_i在时间j发生变化时,不是所有其他x_i'值在j+1时都需要更新,因此不需要重新计算所有依赖关系,因为某些x_i'可能与x_i无关。

此外,发生变化的x_i可以进行增量更新。例如,在Scala中考虑一个映射操作f=g.map(_+1),其中fgInts列表。这里f对应于x_i(t_j),而gx_j(t_j)。现在,如果我在g前添加一个元素,那么为了所有g中的元素执行map操作将是浪费的。一些FRP实现(例如reflex-frp)旨在解决这个问题。这个问题也被称为incremental computing. 换句话说,在FRP中,行为(x_i)可以被视为缓存计算。如果一些f_i发生变化,FRP引擎的任务就是有效地使这些缓存(x_i)失效并重新计算它们。

4
直到你提到“离散方程”,我才开始有点跟不上。 FRP 的创始理念是“连续时间”,其中没有“ j + 1”。相反,考虑连续时间的函数。正如牛顿、莱布尼兹和其他人向我们展示的那样,通常使用积分和 ODE 系统来连续地描述这些函数非常方便(从字面意义上来说是“自然的”),否则,你描述的就是一个近似算法(而且还很差),而不是本身。 - Conal
HTML模板和布局约束语言layx似乎表达了FRP的元素。 - user246672
@Conal 这让我想知道 FRP 和 ODE 有什么不同。它们有什么区别? - jhegedus
@jhegedus 在 FRP 中,积分(可能是递归的,即 ODEs)提供了其中一个构建块,而不是全部。 FRP 词汇表中的每个元素(包括但不限于积分)都是通过连续时间精确解释的。这种解释有帮助吗? - Conal

29

28

免责声明:我的回答是在rx.js上下文中的 - 一个用于Javascript的“响应式编程”库。

在函数式编程中,不需要遍历集合中的每个项目,而是将高阶函数(HoFs)应用于集合本身。因此FRP背后的想法是,创建事件流(使用observable*实现)而不是处理每个单独的事件,并将HoFs应用于该事件流。这样,您可以将系统视为将发布者连接到订阅者的数据管道。

使用observable的主要优点是:
i) 它从代码中抽象出状态,例如,如果您希望事件处理程序仅在每个'n'个事件后触发,或在第一次'n'个事件后停止触发,或仅在第一次'n'个事件后开始触发,则可以只使用HoFs(过滤器,takeUntil,skip等),而无需设置,更新和检查计数器。
ii) 它提高了代码局部性 - 如果您有5个不同的事件处理程序更改组件的状态,则可以合并它们的observable并定义单个事件处理程序在合并的observable上,有效地将5个事件处理程序合并为1个。这使得很容易推断出整个系统中哪些事件会影响组件,因为所有这些都存在于单个处理程序中。

  • Observable是Iterable的对偶。

Iterable是一个懒加载的序列 - 每个项目都是在迭代器随时想要使用它时由其拉取的,因此枚举由使用方驱动。

Observable是一个懒加载的生产者序列 - 每个项目都被推送到观察者中,只要它被添加到序列中,因此枚举由生产者驱动。


1
非常感谢您对可观察对象的简明定义以及与可迭代对象的区别的阐述。我认为将一个复杂的概念与其众所周知的对偶概念进行比较,可以获得真正的理解,这通常非常有帮助。 - user6445533
2
因此,FRP的想法是,不是处理每个单独的事件,而是创建一个事件流(用observable*实现)并对其应用高阶函数。我可能错了,但我认为这实际上不是FRP,而是一种很好的抽象,可以通过HoF进行功能操作(非常好!),同时仍打算与命令式代码一起使用。关于该主题的讨论 - http://lambda-the-ultimate.org/node/4982 - nqe

18

老兄,这真是个绝妙的点子!我为什么没在1998年发现它呢?无论如何,这是我对Fran教程的理解。欢迎提出建议,我正在考虑基于此开始一个游戏引擎。

import pygame
from pygame.surface import Surface
from pygame.sprite import Sprite, Group
from pygame.locals import *
from time import time as epoch_delta
from math import sin, pi
from copy import copy

pygame.init()
screen = pygame.display.set_mode((600,400))
pygame.display.set_caption('Functional Reactive System Demo')

class Time:
    def __float__(self):
        return epoch_delta()
time = Time()

class Function:
    def __init__(self, var, func, phase = 0., scale = 1., offset = 0.):
        self.var = var
        self.func = func
        self.phase = phase
        self.scale = scale
        self.offset = offset
    def copy(self):
        return copy(self)
    def __float__(self):
        return self.func(float(self.var) + float(self.phase)) * float(self.scale) + float(self.offset)
    def __int__(self):
        return int(float(self))
    def __add__(self, n):
        result = self.copy()
        result.offset += n
        return result
    def __mul__(self, n):
        result = self.copy()
        result.scale += n
        return result
    def __inv__(self):
        result = self.copy()
        result.scale *= -1.
        return result
    def __abs__(self):
        return Function(self, abs)

def FuncTime(func, phase = 0., scale = 1., offset = 0.):
    global time
    return Function(time, func, phase, scale, offset)

def SinTime(phase = 0., scale = 1., offset = 0.):
    return FuncTime(sin, phase, scale, offset)
sin_time = SinTime()

def CosTime(phase = 0., scale = 1., offset = 0.):
    phase += pi / 2.
    return SinTime(phase, scale, offset)
cos_time = CosTime()

class Circle:
    def __init__(self, x, y, radius):
        self.x = x
        self.y = y
        self.radius = radius
    @property
    def size(self):
        return [self.radius * 2] * 2
circle = Circle(
        x = cos_time * 200 + 250,
        y = abs(sin_time) * 200 + 50,
        radius = 50)

class CircleView(Sprite):
    def __init__(self, model, color = (255, 0, 0)):
        Sprite.__init__(self)
        self.color = color
        self.model = model
        self.image = Surface([model.radius * 2] * 2).convert_alpha()
        self.rect = self.image.get_rect()
        pygame.draw.ellipse(self.image, self.color, self.rect)
    def update(self):
        self.rect[:] = int(self.model.x), int(self.model.y), self.model.radius * 2, self.model.radius * 2
circle_view = CircleView(circle)

sprites = Group(circle_view)
running = True
while running:
    for event in pygame.event.get():
        if event.type == QUIT:
            running = False
        if event.type == KEYDOWN and event.key == K_ESCAPE:
            running = False
    screen.fill((0, 0, 0))
    sprites.update()
    sprites.draw(screen)
    pygame.display.flip()
pygame.quit()

简而言之:如果每个组件都可以被视为一个数字,那么整个系统就可以被视为一个数学方程,对吗?


1
这有点晚了,但无论如何... Frag是一个使用FRP的游戏 - arx

14

Paul Hudak的书,《表达式的Haskell学校》,不仅是Haskell的一个很好的入门指南,而且还花了相当长的时间来介绍函数响应式编程(FRP)。如果你是FRP的初学者,我强烈推荐你阅读此书,以让你了解FRP的工作原理。

此外,还有一本看起来像是对这本书的新修订(2011年发布,2014年更新),《音乐的Haskell学校》


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接