我已阅读有关响应式编程的 Wikipedia 文章。我还阅读了关于函数式反应式编程的小文章。描述很抽象。
- 函数式反应式编程(FRP)在实践中意味着什么?
- 反应式编程(相对于非反应式编程?)由什么组成?
我的背景是命令式/OO 语言,因此将不胜感激与此范例相关的解释。
如果你想感受一下 FRP,你可以从 1998 年的旧Fran 教程开始,它有动画插图。对于论文,从功能反应动画开始,然后跟进我主页上的出版物链接和Haskell wiki上的FRP链接。
就个人而言,我喜欢在讨论 FRP 的实施方式之前先考虑一下 FRP 的含义。(没有规范的代码是没有问题的答案,因此“甚至没有错”。)所以我不会像 Thomas K 在另一个答案(图、节点、边、触发、执行、 ETC)。有许多可能的实现样式,但没有一个实现说明 FRP是什么。
我确实赞同 Laurence G 的简单描述,即 FRP 是关于“表示‘随时间’的值的数据类型”。传统的命令式编程仅通过状态和突变间接地捕获这些动态值。完整的历史(过去、现在、未来)没有一流的表现。此外,只能(间接)捕获离散演变的值,因为命令式范式在时间上是离散的。相比之下,FRP直接捕获这些不断变化的值,并且在不断变化的值方面没有任何困难。
FRP 的不寻常之处还在于它是并发的,而不会与困扰命令式并发的理论和实用鼠巢相冲突。从语义上讲,FRP 的并发是细粒度的、确定的、连续的。(我说的是意义,而不是实现。实现可能涉及也可能不涉及并发或并行性。)语义确定性对于推理非常重要,无论是严格的还是非正式的。虽然并发给命令式编程增加了巨大的复杂性(由于非确定性交错),但在 FRP 中却毫不费力。
那么,什么是玻璃钢?你本可以自己发明的。从这些想法开始:
动态/不断变化的价值(即“随时间变化”的价值)本身就是一流的价值。您可以定义它们并将它们组合起来,将它们传入和传出函数。我把这些东西称为“行为”。
行为由几个原语构成,例如恒定(静态)行为和时间(如时钟),然后是顺序和并行组合。 n行为通过应用 n 元函数(静态值)“逐点”组合,即随着时间的推移连续。
为了解释离散现象,有另一种类型(家族)的“事件”,每个事件都有一个(有限或无限)事件流。每个事件都有一个关联的时间和值。
要提出可以构建所有行为和事件的组合词汇,请尝试一些示例。继续解构为更一般/更简单的部分。
为了让你知道你在坚实的基础上,给整个模型一个组合基础,使用指称语义技术,这意味着(a)每种类型都有一个相应的简单而精确的数学类型的“意义”,并且( b) 每个原语和操作符都有一个简单而精确的含义,作为成分含义的函数。 永远不要将实施考虑因素混入您的探索过程中。如果此描述对您来说是胡言乱语,请查阅 (a)具有类型类态射的指称设计,(b)推拉功能反应式编程(忽略实现位)和 (c)指称语义Haskell wikibooks 页面. 请注意,指称语义有两个部分,来自其两位创始人 Christopher Strachey 和 Dana Scott:更容易且更有用的 Strachey 部分和更难且不太有用(用于软件设计)的 Scott 部分。
如果你坚持这些原则,我希望你会或多或少地获得 FRP 精神的东西。
我从哪里得到这些原则?在软件设计中,我总是问同样的问题:“这是什么意思?”。指称语义为我提供了一个精确的框架来解决这个问题,并且符合我的审美(与操作语义或公理语义不同,这两者都让我不满意)。所以我问自己什么是行为?我很快意识到命令式计算的时间离散性是对特定机器风格的适应,而不是对行为本身的自然描述。我能想到的对行为的最简单精确描述就是“(连续)时间的函数”,这就是我的模型。令人高兴的是,这个模型可以轻松优雅地处理连续的、确定性的并发。
正确有效地实施这个模型是一个相当大的挑战,但那是另一回事了。
在纯函数式编程中,没有副作用。对于许多类型的软件(例如,任何与用户交互的软件),副作用在某种程度上是必要的。
在保持函数式风格的同时获得类似副作用的行为的一种方法是使用函数式反应式编程。这是函数式编程和反应式编程的结合。(您链接到的维基百科文章是关于后者的。)
反应式编程背后的基本思想是,某些数据类型代表“随着时间的推移”的值。涉及这些随时间变化的值的计算本身将具有随时间变化的值。
例如,您可以将鼠标坐标表示为一对随时间变化的整数值。假设我们有类似的东西(这是伪代码):
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 的框,并将跟踪它移动到哪里。
这是一篇关于函数式反应式编程的非常好的论文。
获得关于它是什么样的第一直觉的一种简单方法是想象你的程序是一个电子表格,你的所有变量都是单元格。如果电子表格中的任何单元格发生更改,则引用该单元格的任何单元格也会更改。玻璃钢也是如此。现在想象一些单元格自己改变(或者更确切地说,是从外部世界获取的):在 GUI 情况下,鼠标的位置就是一个很好的例子。
这必然会错过很多。当您实际使用 FRP 系统时,这个比喻很快就失效了。一方面,通常也会尝试对离散事件进行建模(例如,单击鼠标)。我把它放在这里只是为了让你知道它是什么样的。
对我来说,符号大约有两种不同的含义=
:
x = sin(t)
,这x
是不同的名称。sin(t)
所以写作x + y
和sin(t) + y
. 函数式反应式编程在这方面就像数学:如果你写x + y
,它是用它被使用时的任何值来计算t
的。x = sin(t)
是赋值:它表示x
存储赋值时所取的值 sin(t)
。好的,从背景知识和阅读您指出的维基百科页面来看,反应式编程似乎类似于数据流计算,但具有特定的外部“刺激”,触发一组节点触发并执行它们的计算。
这非常适合 UI 设计,例如,触摸用户界面控件(例如,音乐播放应用程序上的音量控件)可能需要更新各种显示项目和音频输出的实际音量。当您修改与修改与有向图中的节点关联的值相对应的音量(比方说滑块)时。
具有来自该“体积值”节点的边缘的各种节点将被自动触发,并且任何必要的计算和更新都会自然地在应用程序中产生波动。应用程序对用户刺激做出“反应”。函数式反应式编程只是用函数式语言或通常在函数式编程范式中实现这个想法。
有关“数据流计算”的更多信息,请在 Wikipedia 上搜索这两个词或使用您最喜欢的搜索引擎。总体思路是这样的:程序是节点的有向图,每个节点执行一些简单的计算。这些节点通过图形链接相互连接,这些链接将一些节点的输出提供给其他节点的输入。
当一个节点触发或执行它的计算时,连接到它的输出的节点有它们相应的输入“触发”或“标记”。任何具有所有输入触发/标记/可用的节点都会自动触发。该图可能是隐式的或显式的,具体取决于反应式编程的实现方式。
可以将节点视为并行触发,但它们通常是串行执行或以有限的并行性执行的(例如,可能有几个线程在执行它们)。一个著名的例子是Manchester Dataflow Machine,它 (IIRC) 使用标记数据架构通过一个或多个执行单元来调度图中节点的执行。数据流计算非常适合异步触发计算以产生级联计算的情况,而不是试图让执行由一个(或多个时钟)控制。
反应式编程引入了这种“级联执行”的思想,并且似乎以类似数据流的方式来考虑程序,但前提是一些节点与“外部世界”挂钩,并且当这些感觉时触发级联执行-like节点改变。程序执行看起来就像一个复杂的反射弧。该程序可能在刺激之间基本上是固着的,也可能不是基本上固着的,或者可能在刺激之间进入基本上固着的状态。
“非反应性”编程将是对执行流程和与外部输入的关系有非常不同的看法的编程。这可能有点主观,因为人们可能会倾向于说出任何对外部输入做出“反应”的东西。但是从本质上看,以固定间隔轮询事件队列并将发现的任何事件分派给函数(或线程)的程序反应性较小(因为它只以固定间隔处理用户输入)。再一次,这就是这里的精神:人们可以想象将一个具有快速轮询间隔的轮询实现放在一个非常低级别的系统中,并在其之上以一种反应方式进行编程。
在阅读了很多关于 FRP 的页面后,我终于看到了这篇关于 FRP 的启发性文章,它终于让我明白了 FRP 的真正含义。
我在下面引用 Heinrich Apfelmus(反应香蕉的作者)。
函数式反应式编程的本质是什么?
一个常见的答案是“FRP 就是用时变函数而不是可变状态来描述系统”,这当然不会错。这是语义的观点。但在我看来,更深层次、更令人满意的答案是由以下纯句法标准给出的:
函数式反应式编程的本质是在声明时完整地指定值的动态行为。
例如,以计数器为例:您有两个标记为“向上”和“向下”的按钮,可用于增加或减少计数器。必须先指定一个初始值,然后在按下按钮时更改它;像这样的东西:
counter := 0 -- initial value on buttonUp = (counter := counter + 1) -- change it later on buttonDown = (counter := counter - 1)
重点是在声明的时候,只指定了计数器的初始值;计数器的动态行为隐含在程序文本的其余部分中。相比之下,函数式反应式编程在声明时指定了整个动态行为,如下所示:
counter :: Behavior Int counter = accumulate ($) 0 (fmap (+1) eventUp `union` fmap (subtract 1) eventDown)
每当你想了解计数器的动态时,你只需要看看它的定义。它可能发生的一切都会出现在右侧。这与后续声明可以改变先前声明值的动态行为的命令式方法形成鲜明对比。
因此,在我的理解中,FRP 程序是一组方程:
j
是离散的:1,2,3,4...
f
取决于t
所以这包含了模拟外部刺激的可能性
程序的所有状态都封装在变量中x_i
FRP 库负责处理进度,换句话说,j
将j+1
.
我在这个视频中更详细地解释了这些方程。
编辑:
在原始答案之后大约 2 年,最近我得出结论,FRP 实施还有另一个重要方面。他们需要(并且通常会)解决一个重要的实际问题:缓存失效。
-s的方程x_i
描述了一个依赖图。当某些x_i
时间发生变化时,j
并非所有其他x_i'
值都j+1
需要更新,因此并非所有依赖项都需要重新计算,因为有些x_i'
可能独立于x_i
.
此外,x_i
进行更改的 -s 可以增量更新。例如,让我们考虑f=g.map(_+1)
Scala 中的 map 操作,其中f
和g
are List
of Ints
。这里f
对应x_i(t_j)
并且g
是x_j(t_j)
。现在,如果我在前面添加一个元素,g
那么map
对g
. 一些 FRP 实现(例如reflex- frp )旨在解决这个问题。这个问题也被称为增量计算。
换句话说,x_i
FRP 中的行为(-s)可以被认为是缓存计算。x_i
如果某些f_i
-s 确实发生了变化,则 FRP 引擎的任务是有效地使这些缓存-s(-s)无效并重新计算。
Conal Elliott的论文《简单高效的功能反应性》(直接 PDF,233 KB)是一个相当不错的介绍。相应的库也可以工作。
该论文现在被另一篇论文Push-Pull 函数式反应式编程(直接 PDF,286 KB)取代。
免责声明:我的答案是在 rx.js 的上下文中 - 一个用于 Javascript 的“反应式编程”库。
在函数式编程中,不是遍历集合的每个项目,而是将高阶函数 (HoF) 应用于集合本身。因此,FRP 背后的想法是,与其处理每个单独的事件,不如创建一个事件流(使用 observable* 实现)并将 HoF 应用于该事件。通过这种方式,您可以将系统可视化为连接发布者和订阅者的数据管道。
使用 observable 的主要优点是:
i) 它从您的代码中抽象出状态,例如,如果您希望事件处理程序仅在每个“n”个事件时触发,或者在第一个“n”个事件后停止触发,或仅在第一个“n”事件后开始触发,您可以只使用 HoF(分别为 filter、takeUntil、skip)而不是设置、更新和检查计数器。
ii) 它提高了代码局部性——如果你有 5 个不同的事件处理程序来改变组件的状态,你可以合并它们的 observables 并在合并的 observable 上定义一个事件处理程序,从而有效地将 5 个事件处理程序合并为 1。这使得它非常很容易推断整个系统中的哪些事件会影响组件,因为它们都存在于单个处理程序中。
Iterable 是一个惰性消耗的序列 - 每个项目都由迭代器在它想要使用时拉取,因此枚举由消费者驱动。
一个可观察对象是一个延迟生成的序列——每一项被添加到序列中时都会被推送给观察者,因此枚举是由生产者驱动的。
伙计,这真是一个绝妙的主意!为什么我在 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()
简而言之:如果每个组件都可以被视为一个数字,那么整个系统就可以被视为一个数学方程式,对吧?
Paul Hudak 的书The Haskell School of Expression不仅对 Haskell 进行了很好的介绍,而且在 FRP 上也花费了相当多的时间。如果您是 FRP 的初学者,我强烈推荐它让您了解 FRP 的工作原理。
这本书(2011 年出版,2014 年更新)看起来像是新的重写,哈斯克尔音乐学院。
根据前面的答案,似乎在数学上,我们只是以更高的顺序思考。我们不考虑类型X的值x,而是考虑函数x : T → X,其中T是时间的类型,可以是自然数、整数或连续统。现在当我们在编程语言中写y := x + 1 时,我们实际上是指方程y ( t ) = x ( t ) + 1。
如前所述,就像电子表格一样。通常基于事件驱动的框架。
与所有“范式”一样,它的新颖性值得商榷。
根据我对参与者分布式流网络的经验,它很容易成为节点网络中状态一致性的一般问题的牺牲品,即您最终会陷入大量的振荡和奇怪的循环中。
这很难避免,因为某些语义意味着引用循环或广播,并且当参与者网络在某些不可预测的状态上收敛(或不收敛)时可能会非常混乱。
类似地,尽管具有明确定义的边缘,但可能无法达到某些状态,因为全局状态会远离解决方案。2+2 可能会也可能不会成为 4,这取决于 2 何时变为 2,以及他们是否保持这种状态。电子表格具有同步时钟和环路检测。分布式演员通常不会。
一切都很有趣:)。
我在 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
Andre Staltz 的这篇文章是我迄今为止看到的最好和最清晰的解释。
文章中的一些引用:
反应式编程是使用异步数据流进行编程。
最重要的是,您将获得一个惊人的功能工具箱来组合、创建和过滤任何这些流。
以下是文章中精彩图表的示例:
它是关于随时间(或忽略时间)的数学数据转换。
在代码中,这意味着功能纯度和声明式编程。
状态错误是标准命令式范式中的一个大问题。不同的代码位可能会在程序执行的不同“时间”改变一些共享状态。这很难处理。
在 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
关于响应式编程的简短而清晰的解释出现在Cyclejs - Reactive Programming上,它使用简单直观的示例。
[模块/组件/对象]是反应式的,意味着它完全负责通过对外部事件做出反应来管理自己的状态。
这种方法有什么好处?它是Inversion of Control,主要是因为 [module/Component/object] 对自己负责,使用私有方法对公共方法改进封装。
这是一个很好的起点,而不是完整的知识来源。从那里你可以跳到更复杂和更深入的论文。
查看 Rx,.NET 的反应式扩展。他们指出,使用 IEnumerable,您基本上是从流中“拉取”。在 IQueryable/IEnumerable 上的 Linq 查询是从集合中“吸出”结果的集合操作。但是在 IObservable 上使用相同的运算符,您可以编写“反应”的 Linq 查询。
例如,您可以编写一个 Linq 查询,例如 (from m in MyObservableSetOfMouseMovements where mX<100 and mY<100 select new Point(mX,mY))。
有了 Rx 扩展,就是这样:你的 UI 代码可以对传入的鼠标移动流做出反应,并在你处于 100,100 框时进行绘制......
FRP 是函数式编程(基于一切都是函数的思想的编程范式)和反应式编程范式(基于一切都是流的思想(观察者和可观察的哲学))的组合。它应该是世界上最好的。
查看关于反应式编程的 Andre Staltz 帖子。