函数响应式编程介绍
我今年发布了一个关于函数响应式编程(Funcational Reactive Programming 简称FRP)的演讲,试图分解给它的名字还有你为什么要关心它的名字。这是关于那次演讲的文章。
介绍
在过去几年中,函数响应式编程非常流行。但是为什么是它?你为什么需要关心。
即使是对于使用像RxJava
这类响应式框架的人,FRP背后的根本原因也可能是神秘的。今天要打破这份神秘,把它分解成单独的组件:响应编程和函数编程。
响应编程
首先,让我们来看看响应代码的意义。
我们从一个简单的例子开始:一个开关和一个灯泡。当你滑动开关,灯泡打开或者关闭。
编码方面,两个组件是耦合的。通常你不会关心它们是怎么耦合的,但是让我们深入挖掘。
一种方法是让开关修改灯泡的状态。这种情况下,开关是主动的,将新的状态推给灯泡;而灯泡是被动的,仅仅接收指令去改变它的状态。
我们将通过在开关上放一个箭头来表示这段关系——也就是说,连接两个元件的是开关,不是灯泡。
这有一个主动解决方案的草图:这个 Switch
包含一个LightBulb
实例,当它状态发生变化时它会修改。
另一种对这些组件进行耦合的方法是让灯泡监听开关的状态然后响应地修改自己。在这个模型中,灯泡是响应的,根据开关的状态改变它的状态;而开关是可观察的,其他的是可以观察到他的状态改变。
这里是被动解决方案的一个示意图:LightBulb
接收到一个 Switch
的监听事件,然后根据监听修改它自己的状态。
最终对于用户,主动和被动的代码都导致了相同的结果。两种方法有什么不同之处呢?
第一个区别是谁控制灯泡。在主动模型中,它必须是调用LightBulb.power()
的额外组件。灯泡具有响应性,灯泡自己控制它的亮度。
第二个区别是谁决定Switch
的控制。在主动模型中,Swtich
自己决定了它的控制是谁。在响应模型中,Switch
不知道他的驱动是什么,因为其他组件通过监听接入它。
你会觉得这两种模型互为镜像。主动和被动的编码之间有一种二重性。
然而,两个组件的耦合紧密或者松散还有有一个细微的差别。在主动模型中,模块直接相互控制。在响应模型中,模块控制他们自己,间接的相互连接。
让我们来看看这是怎么样在现实生活例子中体现出来的。这是Trello的主屏幕。它显示了你从数据库中得到的板。如何体现和主动或被动模型的关系呢?
使用主动模型,当数据库变化时,它将这些改变推给 UI。但是这没有任何意义:为什么我的数据库要关心UI?为什么它必须检查主屏幕是否正在显示,并知道是否应该推新数据给它。主动模型在DB和UI之间创建了一个奇怪的紧密耦合。
相比之下,响应式模型干净很多。现在我的UI监听数据的变化,有必要的时候更新自己。数据库只是提供一个监听的不说话的知识库。谁都能更新它,这些改变仅仅反映在需要的UI中。
这是好莱坞的原则:别大电弧给我们,我们会打电话给你。这对松散耦合代码来说是很好的,允许你封装你的组件。
我们现在能回答什么是 响应式编程:当你首先关注响应式代码,代替你默认的主动代码。
如果你想默认为响应式,我的的简单的监听就不是很好了。它有以下几个问题:
第一,每个监听是独一无二的。我们有 Switch.OnFlipListener
,但是只适用于 Switch
。可以观察到的每个模块都必须实现它自己的侦听器设置。这不仅意味着成串的额外样板代码,还意味着你不能重用响应模式,因为构建通用的框架。
第二个问题是每个观察者对可观察组件有直接使用权。LightBulb
必须有直接的 Switch
才能开始监听它。这导致模块之间的紧密耦合,破坏了我们的目标。
我们真正想要的是,如果 Switch
.flips()返回一些可以传递的通用类型。我们来看看能返回什么类型,满足我们的需求。
函数可以返回四个基本对象。子啊一个轴上返回item的数量:单个项或者多个项。另一个轴,表示item是否立马返回(sync),或者表示项是否稍后将交付的值(async)。
同步返回很简单。单个返回类型为T:任何类型。同样的,多个项只是一个Iterable<T>
。
使用同步代码进行编程很简单,因为当你获得它们时,你能开始使用返回值,但是我们不在这个世界中。响应式编码被指上市异步的:无法知道可观察组件何时会发出一个新的状态。
因此,让我们研究一下异步返回。一个单一的异步项相当于Future<T>
。很好,但不是我们想要的——一个可观察组件可能有多项(例如: Switch
可以打开/关闭很多项)。
我们真正想要的是右下角。我们称上一象限是一个Observable<T>
。一个Observable
是所有响应式框架的基础。
让我们看看Observable<T>
是怎么工作的。在我们的新代码中, Switch
.flips()返回一个Observable<Boolean>
——即一个表示 Switch
状态的true/false的序列。现在我们的LightBulb
,不是直接消费 Switch
,将会订阅 Switch
提供的一个Observable<Boolean>
。
这段代码的表现和我们没有Observable
的代码是一样的,但是修复了我前面提到的两个问题。Observable<T>
是一个广义的类型,允许我们在它上面建立。它可以被传递,所以我们的组件也不是紧密耦合的了。
让我们巩固一下Observable
的基础知识。Observable
是随着时间推移的item的集合。
这是我展示的一个弹珠图。这条线表示时间,而圆表示的是Observable
推给订阅者的事件。
Observable
可能导致两种可能终端状态之一:成功的完成和错误。
一个成功完成弹珠图中的一条垂直线表示。不是所有集合都是无限的,也有必要能够表示它。例如,你在Netflix
放一个视频,视频在某个时候会结束。
一个错误是由X表示,是由于某种原因导致数据流的结果无效。例如,有人拿锤子砸我们的开关,那就值得告诉大家我们的开关不仅停止发出任何新的状态而且也不能有效的监听更多,因为它已经坏了。
函数式编程
让我们先把响应式编程放到一边,然后跳到什么是函数式编程。
函数式编程关注函数。对吧?我不是说任何简单的老函数:我们用纯函数来教学。
让我通过反例解释下什么是纯函数。
假设我们有一个完全合理的add()
函数,它将两个数加在一起。但是等等,函数中所有的空空间是什么?
哦!看起来像add()
发送文本到控制台。这就是所谓的副作用。add()
的目的不是打印到控制台;它是两个数相加。然而它正在修改应用程序的全局的状态。
但是等等,还有更多。
哎哟!它不仅打印到控制台,还杀死程序。如果你只是看函数定义(两个int输入,一个int输出),你就不会知道使用这个方法会对你的应用程序造成什么样的毁坏。
让我们看另一个例子。
这里,我们取一个列表看看所有元素的和和乘积是否一样。我认为这对[1,2,3]是成立的,因为1+2+3==6和1*2*3==6.
然而,检查sum()
方法是如何实现。它不会影响我们app全局的状态,但是它修改我们的输入!这意味着代码将会失败,因为在product(numbers)
执行的时候,numbers
是空的。像天方夜谭,这类问题会一直出现在实际的不纯的函数中。
任何时候你在函数外面改变状态都会产生副作用。正如你所看到的,副作用可能使写码变的困难。纯函数不允许有任何的副作用。
有趣,这意味着纯函数必须返回一个值。返回类型是void的纯函数什么都做不了,因为不能修改输入或者任何函数外的状态。
它也意味着你的函数输入必须是不可变的。我们不允许输入可变,否则当一个函数执行时并发代码可能改变输入,打破了纯洁性。顺便说下,这也意味着输出也应该是不可变的(否则他们不能作为纯函数的输入)。
纯函数有第二个方面,给相同的输入,它们必须总是返回相同的输出。换句话说,它们布恩那个依赖与任何函数外部的状态。
例如,检查和用户打招呼的函数。它是没有任何副作用的,但是它随机返回两个问候中的一个。随即形式通过一个外部的静态函数还提供的。
这让写码更加困难,有两个原因。首先,不管的输入该方法有不一致的结果。如果你知道给函数相同的输入结果是相同的输出,那写码就更容易了。其次,你现在有一个有外部依赖项的行数;如果外部依赖被任何方式改变,函数运行的表现可能会不同。
对于面向对象开发者来说是困惑的,这意味着纯函数甚至不能访问它包含在其中的类的状态。例如:Random的方法本质是不纯的,因为调用它们根据Random的内部状态返回新的值。
简单地说,函数式编程是基于纯函数的。纯函数是一个不能消费或者改变外部状态的函数——他们完全依赖于输入来获取输出。
有一点困惑,经常碰到人介绍FP:你是如何改变的?例如,如果我想要获取一个整数列表并将它们的所有值都加倍?当然你必须改变列表,对吧?
嗯,不完全是。你能用纯函数转换你的列表。这是一个使列表中的值加倍的纯函数。没有副作用,没有外部状态,也没有输入/输出的改变。这个函数提供了改变工作,所以你不必做。
然而,我们写的函数灵活度不高。它能做是将数组中每个数加倍,但是我能想象我们对一个整型数组做更多的操作:所有值三倍,所有值减半...想法是无限的。
让我们写一个通用的整数操作器。我们从一个Function
接口开始,这允许我们定义我们想要的如何才做每个整数。
然后,我们将写一个map()行数,同时接收整数数组和一个Function
。每一个数组中的整数,我们都能应用Function
。
瞧!有了一些额外的代码,我们现在可以将任何整数数组映射到另一个整数数组。
我们更深入的看这个例子:为什么不使用泛型,这样我们对于任何列表都可以从一种类型转换为另一种类型。修改之前的代码不是那么难。
现在,我们可以将任何List<T>
映射到List<R>
中。例如,我们能获取一个字符串列表转换为每个字符串长度的列表。
我们的map()被称为高阶函数,因为它接收一个函数作为参数。能够传递和使用函数的去昂达工具因为它允许代码更加灵活。而不是写重复的、特定的函数,你可以写像map()这样的通用函数,这函数很多情况下都是可重用的。
除了由于没有外部状态工作更容易以外,纯函数也让编写函数更容易。如果你一个函数是A->B而另一个是B->C,那么我们能把这两个函数结合起来创建一个A->C。
当你便携不纯函数的时候,经常出现不想要的副作用,这意味着很难知道组合函数是否能正常工作。只有纯函数能保证程序员的组合是安全的。
让我们来看一个合成的例子。这是另一个通常的FP函数——filter()。他使我们所有列表中的项。现在,我们可以通过组合两个函数在转换之前过滤我们的列表。
我们现在有了一对小但是功能强大的转换函数,它们通过允许我们组合它们来增强它们的功能。
函数式编程的内容远比我这里介绍的多,但这一速成课程足以理解“FRP”部分的“FP”部分。
函数响应式编程
让我们看看函数式编程如何增强响应式代码。
假设我们的 Switch
,提供的不是Observable<Boolean>
,而是提供一个有枚举基础的Observable<State>
。
现在表面上看来我们没法 Switch
换LightBulb
因为我们不兼容泛型。但是有一个很明显的方法Observable<State>
模仿Observable<Boolean>
——如果我们能将一种类型的流转换成另一种类型,那怎么做?
还记得我们在FP中看到的map()
函数么?它将一个类型的同步集合转换成另一种类型。如果我们将相同的想法应用到一个异步集合上呢?像Observable
。
Ta-da:这是map()
,但是是为了Observable
。Observable.map()
是所谓的操作符。操作符让你以任何方式转换一个Observable
流。
这是一个操作符的弹珠图,比我们之前看到的要复杂。让我们来分解一下:
上面的一行表示输入流:一系列彩色的圆圈。
中间框表示操作符:将圆形转换为正方形。
下面的一行表示输出流:一系列彩色的方块。
本质上,它是输入流中的每个条目的1:1转换。
让我们把它应用到我们的开关问题。我们从Observable<State>
开始。然后我们用map()
这样每当一个新状态被释放时,它就会被转换为一个布尔值;因此map()返回Observable<Boolean>
。现在我们有了正确的类型,我们能构造我们的LightBulb
了。
好了,这就是有用的。但是这和纯函数有什么关系呢?你不能在map()内部写任何东西,包括副作用?当然,你可以...但是,你的代码很难和它一起工作。另外,你错过了一些无副作用的操作符组合。
想象我们的State枚举
在两个以上,但是我们只关心开启/关闭状态。这种情况下,我我们希望过滤掉中间的状态。看,在FRP中还有一个filter()
操作符;我们可以和map()
组合起来获得我们想要的结果。
如果你将FRP的代码和FP的代码进行对比,你会看到相似之处。唯一的区别就是FP代码是处理同步集合,而FRP菜吗处理的是异步集合。
FRP中有大量的运算符,覆盖了许多用于流处理的常见情况,这些操作可以应用和组合在一起。让我们来看一个真实的例子。
我之前展示的Trello主屏幕非常简单——它有一个从数据库到UI的大箭头。但实际上,我们的主屏幕使用了大量的数据来源。
特别是,我们有团队的来源,每个团队内部都有多个董事会。我们要确保我们能同步接收到这些数据;我们不希望有不匹配的数据,比如没有它的父团队的董事会。
为了解决这个问题,我们可以使用combineLatest()
操作符,它接收多个流并将他们组合成一个复合流。特别有用的,每次它的输入流更新时它也会更新,因此我们可以确保我们发送到UI
的数据是完整的、最新的。
在FRP方面确实有大量的操作符。这里有几个有用的...通常,当人们第一次接触到FRP时,他们看到操作符的列表就头晕了。
然而这些操作符的目的不是要压倒一切,而是在应用程序中对典型的数据流惊醒建模。他们是你的朋友,而不是你的敌人。
我的建议是,每次都要一步一步来。不要一下子记住所有的元素安抚,相反,只需要意识到一个操作符可能已经存在于你想要做的事情中。当你需要的时候去找他们,再练习之后你会习惯的。
题外话
我试着回答“什么是函数响应式编程?”“我们现在有了一个答案:它是响应式的流,结合了函数运算符。
但是你为什么要尝试使用FRP呢?
反应流允许您通过标准化组件间通信的方法来编写模块化代码。活性数据流也允许这些组件之间的松散耦合。
响应流本身也是异步的。也许您的工作完全是同步的,但是我所使用的大多数应用程序都依赖于异步用户输入和并发操作。使用异步代码设计的框架比尝试编写自己的并发解决方案更容易。
FRP的函数部件是有用的,因为它能让你以一种合理的方式处理流。函数操作符允许你控制流的交互方式。它还提供了用于复制一个逻辑的通用逻辑工具。
函数响应式编程不是很直观。大多数人都是主动和不纯的编码,包括自己。你做的时间足够长,你的脑海会开始固化,不纯的编码示威的解决方案。打破这种思维模式可以让你通过函数响应式编程编写更有效的代码。
资源
我想感谢/指出我为这次演讲所做的一些资源。
- cycle. js对主动与被动代码有很好的解释,这是我在这次演讲中大量借用的。
- Erik Meijer对主动/被动性的二元性进行了精彩的讨论。我从这里借用了函数的四个基本效应。这个演讲很有数学意义,但如果你能通过它,它会很有启发性。
- 如果您想要更多地了解函数式编程,我建议您尝试使用一种实际的FP语言。Haskell尤其具有启发性,因为它严格遵守外交政策,这意味着你不能欺骗你的实际学习。如果你想进一步调查,“学Haskell”是一本不错的免费在线书。
- 如果你想了解更多关于FRP的知识,请参阅我自己的系列博客文章。这些文章中涉及到的一些主题都是在这里讨论的,所以读这两篇文章可能会有点重复,但是它会详细介绍RxJava的更多细节。