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

我读过维基百科上关于响应式编程的文章。我也读过关于函数响应式编程的小文章。描述很抽象。

  1. 函数响应式编程(FRP)在实践中意味着什么?
  2. 响应式编程(相对于非响应式编程?)由什么组成?

我的背景是命令式/OO语言,因此与此范式相关的解释将不胜感激。

274233 次浏览

好的,从背景知识和阅读你指向的维基百科页面来看,反应式编程似乎有点像数据流计算,但有特定的外部“刺激”触发一组节点触发并执行它们的计算。

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

具有“体积值”节点边缘的各种节点将被自动触发,任何必要的计算和更新将自然地在应用程序中产生涟漪。应用程序对用户刺激做出“反应”。函数式反应式编程只是用函数式语言或通常在函数式编程范式中实现这一想法。

有关“数据流计算”的更多信息,请在维基百科上搜索这两个词或使用您最喜欢的搜索引擎。一般的想法是:程序是节点的有向图,每个节点执行一些简单的计算。这些节点通过图形链接相互连接,这些链接将一些节点的输出提供给其他节点的输入。

当节点触发或执行其计算时,连接到其输出的节点会“触发”或“标记”其相应的输入。任何具有所有输入触发/标记/可用的节点都会自动触发。该图可能是隐式的或显式的,具体取决于响应式编程的实现方式。

节点可以被视为并行触发,但它们通常是串行执行的或并行性有限(例如,可能有几个线程在执行它们)。一个著名的例子是曼彻斯特数据流机,它(IIRC)使用标记的数据架构通过一个或多个执行单元来调度图中节点的执行。数据流计算非常适合异步触发计算从而产生级联计算的情况,而不是试图让执行由时钟(或多个时钟)管理。

反应式编程引入了这种“执行级联”的想法,似乎以类似数据流的方式思考程序,但条件是一些节点连接到“外部世界”,当这些类似感官的节点改变时,会触发执行级联。然后,程序执行看起来像是类似于复杂反射弧的东西。程序可能在刺激之间基本上是静止的,也可能在刺激之间基本上不是静止的状态。

“非反应式”编程将是对执行流程以及与外部输入的关系持截然不同的观点的编程。这可能有点主观,因为人们可能会想说任何响应外部输入对他们做出“反应”的东西。但是从本质上看,一个以固定间隔轮询事件队列并将发现的任何事件分派给函数(或线程)的程序的反应性较低(因为它只在固定间隔关注用户输入)。同样,这是这里的精神:可以想象将具有快速轮询间隔的轮询实现放入非常低的级别的系统中,并在其上以响应式方式进行编程。

Conal Elliott的论文简单有效的功能反应性直接pdf,233 KB)是一个相当好的介绍。相应的库也有效。

这篇论文现在被另一篇论文推拉函数响应式编程直接pdf,286 KB)取代。

在纯函数式编程中,没有副作用。对于许多类型的软件(例如,任何具有用户交互的软件),副作用在某种程度上是必要的。

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

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

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

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的框,并在它移动的任何地方跟踪它。

这是一个非常好的函数响应式编程

如果你想了解玻璃钢,你可以从1998年的老Fran教程开始,它有动画插图。对于论文,从功能性反应式动画开始,然后在我的主页上的出版物链接和haskell wiki上的玻璃钢链接上跟进。

就我个人而言,我喜欢在讨论如何实现FRP意味着之前考虑它。(没有规范的代码是没有问题的答案,因此“甚至没有错”。)因此,我不会像Thomas K在另一个答案(图、节点、边、触发、执行等)中那样用表示/实现术语描述FRP。有许多可能的实现风格,但没有实现说出FRP

我确实与Laurence G的简单描述产生共鸣,即FRP是关于“代表值'随时间'的数据类型”。传统的命令式编程仅通过状态和突变间接地捕获这些动态值。完整的历史(过去,现在,未来)没有一流的代表性。此外,由于命令式范式在时间上是离散的,因此只能(间接)捕获离散演变值。相比之下,FRP捕获这些不断变化的值直接,并且对不断不断变化的值没有困难。

FRP也不寻常,因为它是并发的,而不会与困扰命令式并发的理论和实用老鼠窝相冲突。从语义上讲,FRP的并发是细粒度确定连续。(我说的是意义,而不是实现。实现可能涉及也可能不涉及并发或并行。)语义确定性对于推理非常重要,无论是严格的还是非正式的。虽然并发给命令式编程增加了巨大的复杂性(由于非确定性交错),但在FRP中却毫不费力。

什么是FRP?你本可以自己发明的。从这些想法开始:

  • 动态/不断变化的值(即“随时间”变化的值)本身就是一流的值。您可以定义它们并组合它们,将它们传入和传出函数。我称这些为“行为”。

  • 行为是由一些原语建立起来的,比如常量(静态)行为和时间(比如时钟),然后是顺序和并行组合。n行为是通过应用一个n元函数(关于静态值)“点到点”来组合的,即随着时间的推移连续。

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

  • 想出所有行为和事件都可以用的组合词汇,玩一些例子。继续解构成更一般/简单的片段。

  • 所以你知道你是在坚实的基础上,给整个模型一个组合的基础,使用指称语义学的技术,这只是意味着(a)每个类型都有一个相应的简单和精确的数学类型的“意义”,(b)每个原语和运算符都有一个简单和精确的意义作为成分意义的函数。永远不要将实现考虑混合到你的探索过程中。如果这个描述对你来说是胡言乱语,请参考(a)具有类型类态射的指称设计,(b)推拉函数响应式编程(忽略实现位)和(c)指称语义Haskell维基百科页面。注意,指称语义学有两个部分,来自它的两位创始人克里斯多夫·斯特拉奇和达纳·斯科特:更容易和更有用的斯特拉奇部分和更难和更不有用的(对于软件设计)斯科特部分。

如果你坚持这些原则,我希望你会得到或多或少符合FRP精神的东西。

我从哪里得到这些原则?在软件设计中,我总是问同样的问题:“这意味着什么?”。指称语义学为我这个问题提供了一个精确的框架,并且符合我的美学(与操作性或公理语义学不同,两者都让我不满意)。我问自己,什么是行为?我很快意识到命令式计算的时间离散性质是对机器的特定风格的适应,而不是对行为本身的自然描述。我能想到的对行为最简单的精确描述就是“(连续)时间的函数”,这就是我的模型。令人高兴的是,这个模型轻松而优雅地处理连续的、确定性的并发。

正确有效地实现这个模型是一个相当大的挑战,但这是另一个故事。

获得第一直觉的一个简单方法是想象你的程序是一个电子表格,你所有的变量都是单元格。如果电子表格中的任何单元格发生变化,引用该单元格的任何单元格也会发生变化。FRP也是如此。现在想象一些单元格会自己改变(或者更确切地说,是从外部世界获取的):在GUI的情况下,鼠标的位置就是一个很好的例子。

这肯定错过了很多。当你实际使用FRP系统时,这个比喻很快就会崩溃。首先,通常也会尝试对离散事件进行建模(例如点击鼠标)。我把它放在这里只是为了让你知道它是什么样子。

Paul Hudak的书,哈斯克尔表达学派,不仅是对Haskell的很好的介绍,而且还花了相当多的时间在FRP上。如果你是FRP的初学者,我强烈推荐它,让你了解FRP是如何工作的。

还有看起来像是这本书的新改写(2011年发布,2014年更新),哈斯克尔音乐学院

伙计,这真是个绝妙的主意!为什么我在1998年就没有发现呢?不管怎样,这是我对Fran教程的解释。欢迎提出建议,我正在考虑以此为基础启动一个游戏引擎。

import pygamefrom pygame.surface import Surfacefrom pygame.sprite import Sprite, Groupfrom pygame.locals import *from time import time as epoch_deltafrom math import sin, pifrom 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 = varself.func = funcself.phase = phaseself.scale = scaleself.offset = offsetdef 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 += nreturn resultdef __mul__(self, n):result = self.copy()result.scale += nreturn resultdef __inv__(self):result = self.copy()result.scale *= -1.return resultdef __abs__(self):return Function(self, abs)
def FuncTime(func, phase = 0., scale = 1., offset = 0.):global timereturn 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 = xself.y = yself.radius = radius@propertydef size(self):return [self.radius * 2] * 2circle = 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 = colorself.model = modelself.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 * 2circle_view = CircleView(circle)
sprites = Group(circle_view)running = Truewhile running:for event in pygame.event.get():if event.type == QUIT:running = Falseif event.type == KEYDOWN and event.key == K_ESCAPE:running = Falsescreen.fill((0, 0, 0))sprites.update()sprites.draw(screen)pygame.display.flip()pygame.quit()

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

对我来说,这是关于符号=的两种不同含义:

  1. 在数学x = sin(t)中,xsin(t)不同的名字。所以写x + ysin(t) + y是一样的。函数响应式编程在这方面就像数学:如果你写x + y,它是用t在使用时的值计算的。
  2. 在类C编程语言(命令式语言)中,x = sin(t)是一个赋值:它意味着x存储赋值时的的价值sin(t)

我在Clojure subreddit上找到了这个关于FRP的不错的视频。即使你不知道Clojure,它也很容易理解。

视频:http://www.youtube.com/watch?v=nket0K1RXU4

以下是视频在下半场提到的来源:https://github.com/Cicayda/yolk-examples/blob/master/src/yolk_examples/client/autocomplete.cljs

如上所述,类似于电子表格。通常基于事件驱动框架。

与所有“范式”一样,它的新颖性值得商榷。

根据我对参与者分布式流网络的经验,它很容易成为节点网络中状态一致性的一般问题的牺牲品,即你最终会在奇怪的循环中遇到很多振荡和陷阱。

这是很难避免的,因为一些语义学意味着引用循环或广播,并且由于参与者网络在一些不可预测的状态上收敛(或不收敛),可能会非常混乱。

类似地,一些状态可能无法达到,尽管有明确定义的边,因为全局状态偏离了解决方案。2+2可能会或可能不会变成4,这取决于2何时变成2,以及它们是否保持这种状态。电子表格有同步时钟和循环检测。分布式参与者通常没有。

好好玩:)

免责声明:我的答案是在rx.js的上下文中-一个JavaScript的“反应式编程”库。

在函数式编程中,不是迭代遍历集合的每个项,而是将高阶函数(HoF)应用于集合本身。因此,FRP背后的想法是,与其处理每个单独的事件,不如创建一个事件流(使用可观察对象*实现)并将HoF应用于该事件。通过这种方式,你可以将系统可视化为连接发布者和订阅者的数据管道。

使用observable的主要优点是:
i)它从你的代码中抽象出状态,例如,如果你想让事件处理程序只在每个第n个事件被触发,或者在第一个第n个事件后停止触发,或者只在第一个第n个事件后开始触发,你可以只使用HoF(分别是filter,bex直到,skip)而不是设置,更新和检查计数器。ii)它提高了代码局部性-如果你有5个不同的事件处理程序改变组件的状态,你可以合并它们的observable并在合并的observable上定义一个事件处理程序,从而有效地将5个事件处理程序组合为1。这使得推断整个系统中的哪些事件可以影响一个组件变得非常容易,因为它都存在于一个处理程序中。

  • Observable是Iterable的对偶。

Iterable是一个懒惰消费的序列——每个项目都由迭代器在想要使用它的时候拉取,因此枚举是由消费者驱动的。

observable是一个延迟生成的序列——每当将每个项目添加到序列中时,它都会被推送到观察者,因此枚举由生产者驱动。

在阅读了许多关于FRP的页面后,我终于遇到了关于FRP的这个启发性写作,它终于让我明白了FRP的真正含义。

我在下面引用Heinrich Apfelmus(活性香蕉的作者)。

函数响应式编程的本质是什么?

一个常见的答案是“FRP是关于描述一个系统的时变函数而不是可变状态的术语”,并且肯定不会错。这是语义观点。但在在我看来,更深刻、更令人满意的答案是以下纯语法标准:

函数响应式编程的本质是在声明时完全指定值的动态行为。

例如,以计数器为例:您有两个按钮标记为“向上”和“向下”,可用于递增或递减计数器。必须首先指定一个初始值然后每当按下按钮时更改它;像这样:

counter := 0                               -- initial valueon buttonUp   = (counter := counter + 1)   -- change it lateron buttonDown = (counter := counter - 1)

关键是在声明时,只有初始值因为指定了计数器;计数器的动态行为是隐含在程序文本的其余部分中。相反,功能响应式编程指定了当时的整个动态行为的声明,像这样:

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

每当你想了解计数器的动态时,你只有看看它的定义。任何可能发生在它身上的事情出现在右手边。这与命令式方法,其中后续声明可以更改以前声明的值的动态行为。

因此,在我的理解中,FRP程序是一组方程:在此处输入图片描述

j是离散的:1,2,3,4…

f依赖于t,因此这包含了模拟外部刺激的可能性

程序的所有状态都封装在变量x_i

FRP库负责处理进展时间,换句话说,取jj+1

我在这个视频中更详细地解释了这些方程。

编辑:

在最初的答案大约2年后,最近我得出结论,FRP实现还有另一个重要方面。他们需要(并且通常确实)解决一个重要的实际问题:缓存失效

x_i-s的方程描述了一个依赖关系图。当一些x_i在时间j发生变化时,并不需要更新j+1的所有其他x_i'值,所以并不需要重新计算所有的依赖关系,因为一些x_i'可能独立于x_i

此外,确实发生变化的x_i-s可以增量更新。例如,让我们考虑Scala中的映射操作f=g.map(_+1),其中fgInts中的List。这里f对应于x_i(t_j)gx_j(t_j)。现在,如果我在g前面添加一个元素,那么对g中的所有元素执行f=g.map(_+1)1操作将是浪费的。一些FRP实现(例如f=g.map(_+1)3)旨在解决这个问题。这个问题也被称为f=g.map(_+1)4

换句话说,FRP中的行为(x_i-s)可以被认为是缓存计算。如果一些f_i-s确实发生了变化,FRP引擎的任务是有效地使这些缓存(x_i-s)无效并重新计算。

它是关于随时间(或忽略时间)的数学数据转换。

在代码中,这意味着功能纯度和声明式编程。

状态bug是标准命令式中的一个大问题。在程序执行的不同“时间”,各种代码可能会改变一些共享状态。这很难处理。

在FRP中,你描述(就像在声明式编程中一样)数据如何从一种状态转换到另一种状态以及触发它的原因。这允许你忽略时间,因为你的函数只是对其输入做出反应,并使用它们的当前值创建一个新的。这意味着状态包含在转换节点的图(或树)中,并且在功能上是纯粹的。

这大大降低了复杂性和调试时间。

想想数学中的A=B+C和程序中的A=B+C之间的区别。在数学中,你描述的是一种永远不会改变的关系。在程序中,它说“现在”A是B+C。但是下一个命令可能是B++,在这种情况下,A不等于B+C。在数学或声明式编程中,无论你问什么时间点,A总是等于B+C。

因此,通过消除共享状态的复杂性并随着时间的推移更改值。您的程序更容易推理。

EventStream是一个EventStream+一些转换函数。

行为是内存中的一个EventStream+一些值。

当事件触发时,通过运行转换函数更新值。这产生的值存储在行为内存中。

可以组合行为以产生新的行为,这些行为是对N个其他行为的转换。当输入事件(行为)触发时,这个组合值将重新计算。

“由于观察者是无状态的,我们通常需要其中几个来模拟状态机,如拖动示例中所示。我们必须保存所有相关观察者都可以访问的状态,例如上面的可变路径。”

引用-弃用观察者模式http://infoscience.epfl.ch/record/148043/files/DeprecatingObserversTR2010.pdf

根据前面的答案,似乎在数学上,我们只是以更高的顺序思考。我们没有考虑类型为X的值x,而是考虑函数xTX,其中T是时间的类型,无论是自然数、整数还是连续体。现在,当我们用编程语言写y:=x+1时,我们实际上是指方程yt)=xt)+1。

看看Rx,. NET的反应式扩展。他们指出,使用IQueryable,你基本上是从流中“拉”出来的。IQueryable/IENumable上的Linq查询是从集合中“吸收”结果的集合操作。但是使用IObservable上的相同运算符,你可以编写“反应”的Linq查询。

例如,您可以编写类似于(从m在MyObservableSetOfMouse运动其中m. X<100和m. Y<100选择新点(m. X, m. Y))。

使用Rx扩展,就是这样:您有UI代码可以对输入的鼠标移动流做出反应,并在您处于100,100框中时进行绘制…

安德烈·斯塔尔茨的《0》是迄今为止我见过的最好、最清晰的解释。

引用文章中的一些内容:

响应式编程是使用异步数据流进行编程。

最重要的是,您将获得一个惊人的函数工具箱,用于组合、创建和过滤任何这些流。

以下是作为文章一部分的精彩图表的示例:

点击事件流图

关于响应式编程的简短而清晰的解释出现在Cyclejs-响应式编程上,它使用简单的可视化示例。

A[模块/组件/对象]是反应性表示它完全负责通过响应外部事件来管理自己的状态。

这种方法的好处是什么?它是控制权倒置,主要是因为[模块/组件/对象]对自己负责,使用私有方法改进对公共方法的封装。

这是一个很好的起点,不是一个完整的知识来源。从那里你可以跳到更复杂和更深入的论文。

FRP是函数式编程(基于一切都是函数的思想的编程范式)和反应式编程范式(基于一切都是流的思想(观察者和可观察哲学))的结合。它应该是世界上最好的。

首先,请查看Andre Staltz关于反应式编程的帖子。