关于标题
在开始之前,还是有必要解释一下这个标题。
原本想写一些关于RxJava的思考,脑子里迅速出现了以下几个方向:
-
RxJava的好处?显然,RxJava的好处大家有目共睹,响应式的编程风格,在事件的逻辑复杂的情况下,依然能够保持链式结构的简练和优雅。但一个框架的终极目的仍然是服务于业务,随着Android工程能力的不断演进,过去RxJava在事件消费和异步上能做到的事情,LiveData和协程或者其他框架也能做到,再去做一次RxJava的布道实在没有太大说服力;
-
RxJava的操作符?当然,由于一些历史原因和使用惯性,RxJava目前在我们业务中的比重还是相当大的,最常见的场景莫过于从服务端拉数据,消费,基本都是Retrofit+RxJava两兄弟搭配使用,而数据消费自然离不开RxJava一系列强大的操作符,基本只有你想不到,没有做不到的操作。而RxJava的注释可能是我们见过最优秀的注释之一,不光解释详尽,更是图文并茂,更何况站内站外都有各种保姆式教程,再去讲操作符似乎有点浪费时间;
-
RxJava的源码解析?这大概是我们在分享一个框架时,在兼具深度的考量下,很自然得出的一个思路,我们在掌握一项技能时,不仅需要知道它能干什么,还要知道它是怎么做到的,所谓「知其然,知其所以然」,这样才能达到熟练乃至精通的地步。但是源码分析很容易陷入一个误区:分享者希望面面俱到,于是大段大段的贴出源码,带着大家过一遍,而繁杂的代码细节本身是一个个的具象,在有限时间内信息输出密度是极高的,最终效果就是,讲了很多细节,但忽略了对抽象的总结,效果甚微。因此,我一直认为,源码分析不能作为技术分享的内容承载主体,最多只是一个辅助手段,方便你在阐述一些抽象时有可感知的具象,仅此而已。
好了,经过这一波排除法的心路历程,大致的方向渐渐清晰起来(毕竟排除的差不多了,可有的选项也不多了)——不妨从架构的视角,选择RxJava的一个架构设计点来讲,主要的好处有:
-
既然是架构设计,只要把设计思路,整体抽象模型讲清楚就行了,不会涉及太多代码细节,陷入源码解析的误区;
-
可能对业务的架构设计有启发;
-
没错,就一个设计点,很容易留下印象;
当然,RxJava的设计点有很多(实际上,任何一个优秀的框架,总有一些架构设计思路值得我们去挖掘),今天选取了其中一个点(也是大部分对RxJava解析中很少提及的一个设计点),但恰恰是
ReactiveX创造者在这套架构中注入的一个很重要的设计理念——「Monad」。
到这里,我们这篇文章的内容线索就很清晰了:
-
什么是Monad;
-
Monad能解决什么问题;
-
RxJava是如何运用Monad的;
从函数式编程说起
编程范式
由于Monad来源于函数式编程,一切还是从函数式编程开始说起。
函数式编程(Functional Programming)实际上是一种编程范式/风格,我们最常见的编程范式整体结构大概是这样:
可以看出,函数式和我们Android开发者常用的面向对象分别属于声明式和命令式的子范式,因此,在理解函数式编程的特点之前,不妨对比一下声明式和命令式的区别。
在声明式编程的维基百科中,是这么描述的:
Many languages that apply this style attempt to minimize or eliminate side effects by describing what the program must accomplish in terms of the problem domain, rather than describe how to accomplish it. This is in contrast with imperative programming, which implements algorithms in explicit steps.
简单来说:
-
声明式编程关注要做什么;
-
命令式编程关注具体每一步怎么做;
当然,这两点描述仍然比较抽象,接下来,我们尝试去分析一下函数式编程的特性,然后回过头来看看函数式编程是如何达成这一点的,即——关注要做什么,而非具体每个步骤。
纯函数
顾名思义,函数式编程基于数学中函数的概念来编码,以下是一个函数的定义:
f(x): x -> y
函数f(x)描述了集合x到集合y的一种映射关系,这里的集合对应到我们的编程中,其实就是类型,用Kotlin表示为:
fun f(x: X): Y {
......
return y
}
你可能会说,函数不是一个再普通不过的语言特性么,这里需要注意的是,函数f(x)描述的x -> y的映射关系是唯一且确定的,即你输入一个x,输出永远是相同不变的y,用数学中的说法就是,f(x)具有幂等性,而这一点就引出了函数式编程最典型的特性之一——使用纯函数。
当然,纯函数除了幂等性之外,还会要求没有副作用(side effects),关于side effects的解释,维基百科是这么描述的:
Function is said to have a side effect if it modifies some state variable value(s) outside its local environment, that is to say has an observable effect besides returning a value (the primary effect) to the invoker of the operation. Example side effects include modifying a non-local variable, modifying a static local variable, modifying a mutable argument passed by reference, performing I/O or calling other functions with side-effects.
可以看出,有side effects意味着,函数除了返回输出值以外,还对作用域以外的状态/变量造成了影响,常见的case有:
-
修改了外部变量的值;
-
修改了静态函数传入参数的值;
-
有I/O操作,比如读取用户输入,或者对外输出(打印日志、持久化等);
另外,抛异常也是一种side effects。
到这里,我们可以小结一下纯函数的两个最重要的特点:
-
给定一个值x,输出唯一的值y;
-
不会产生副作用;
那么,纯函数有什么好处呢?结合前面的说明,我们不难得出以下两点:
-
由于输入输出值的确定且唯一,使得函数的可推理度大大增加,从而提升了测试的可靠性(非常适用于单元测试);
-
由于没有副作用,消除了对外部的影响同时,减少了可变因素,提升了函数的复用性(比如包含打印日志的函数,在需要持久化结果的时候无法直接复用);
关于纯函数,我们先介绍到这里,后面还会继续展开。
接下来,我们来看看函数式编程的另一个特性——函数组合(Function Composition)。
函数组合
如果说纯函数是函数式编程中一个个砖块的话,那么函数组合(Function Composition)得以让这些砖块最终构建为大厦。
怎么理解呢,一图胜千言:
可以看出,f1的输出作为f2的输入,f2的输出又作为f3的输入,i1经过一个函数链条,最终得到结果o3,而这正是函数式编程的运作方式。
关于对这种运作方式的理解,有一个类似的例子有助于说明:Linux的管道(pipes)命令。
通过管道命令,我们可以将上一个命令的输出作为下一个命令的输入,以此类推,最终串联起一系列简单操作,达到复杂操作的目的,比如,我们想要统计当前目录下所有文件名称中包含“bytedance”的数量,使用管道命令:
ls | grep "bytedance" | wc -l
用函数组合表示为:
currentDirectory.listFiles()
.filter { f -> f.name.contains("bytedance") }
.size
在函数式编程中,通常会定义一个个轻量可复用的纯函数f1、f2、f3,然后,通过函数组合的方式,成为一个全新的更复杂的函数(即上图中的函数链条),从而能够处理更为复杂的逻辑,而新的函数又可以再去进行组合,按照这个思路,不难推断出,假设我们在一个业务领域抽象出足够多且合理的轻量函数,那么理论上,这个领域内的所有问题都可以通过函数组合来解决。
道生一,一生二,二生三,三生万物。 ——《道德经》
实际上,关于组合这种特性,在一些函数式编程语言中,有明确的语法定义,比如在经典的函数式编程语言Haskell中,"."表示对两个函数的组合,而(.)本身也是一个函数,声明如下:
(.) :: (b -> c) -> (a -> b) -> a -> c
这个函数表示,接收(b -> c)和(a -> b)这两个函数作为传参,返回(a -> c)这个函数作为结果,从而实现了两个函数组合的效果,因此,在Haskell中,要声明函数h为函数f,g的组合,可以这样写:
h = f . g
OK,关于函数组合,先到这里。
从上面Haskell中对函数组合的定义可以看出,在函数式编程中,函数既能作为传参,也能作为结果返回,接下来,我们来看最后一个特性——函数是第一等公民(First-class citizen)。
函数是第一等公民
这个特性对于熟悉Kotlin的我们并不陌生。
在Kotlin官网上,Functions章节有这样一段描述:
Kotlin functions are first-class, which means they can be stored in variables and data structures, and can be passed as arguments to and returned from other higher-order functions. You can perform any operations on functions that are possible for other non-function values.
第一句话就是,函数在Kotlin中是第一等公民,如何体现的呢:
-
可以定义独立于类以外的函数,也就是顶层函数;
-
函数可以像变量一样,也就是函数的引用;
-
函数可以作为参数和返回值,也就是函数的高阶函数和lambda;
当然,严格来说,Kotlin并不像Haskell一样,是一种纯粹的函数式语言,但毫无疑问,Kotlin设计者的确引入了一些函数式特性,让开发者可以使用函数式风格来进行编码。
关于函数式语言的定义,实际是存在一些争议的。 如果按照狭义的函数式标准,函数式语言必须使用纯函数进行编码,且没有任何副作用。 实际上,早期的函数式语言如Haskell也是这样去设计语言特性的,但对开发者而言,纯函数式编程的局限性也非常明显,比如完全没有副作用(比如变量不可变)的场景在业务开发中几乎是不存在的,或者需要更曲折的方式,更高的成本去达成,而同样的场景下,使用面向对象风格进行编码可能是更好的选择。 Kotlin作为一种更现代,更契合开发者业务场景的语言,在支持部分函数式语言特性的同时,还天然继承了Java这种面向对象语言的特性,鉴于这种多特性,再去定义它是不是函数式语言其实没有太大意义,我们更应该考虑的是,在不同的场景下选择哪种编码范式更佳。
小结
函数式编程相关的内容,当然远不止这些,但它不是这篇文章的重点,大家有兴趣的可以下来去研究。
我们来回顾一下函数式编程的三个特性:
-
纯函数(Pure Function);
-
函数组合(Function Composition);
-
函数是第一等公民(First-class);
在编程范式部分,我们简单的对比了一下两种编码范式声明式(Declarative)和命令式(Imperative)的区别:
-
声明式编程关注要做什么;
-
命令式编程关注具体每一步怎么做;
结合对函数式三种特性的介绍,我们通过一个简单的例子,来理解这种区别。
假设有这样一个消费违规feed的需求:
需求1: 给出一个awemeId,找出对应的aweme,然后统计这个aweme标题中敏感词出现的次数,没有则次数为0,简单起见,敏感词只有一个。
首先,定义Aweme类,以及一个保存敏感词信息的SensitiveCount类:
data class Aweme(val awemeId: String, val title: String)
data class SensitiveCount(val awemeId: String, val count: Int)
mock一个aweme数据库,提供出一个根据id查找aweme的方法:
package sample.model
object Repository {
private val mockAwemeList = arrayOf(
Aweme("1", "Today is awesome!"),
Aweme("2", "Sexy lady, sexy lady, sexy lady!"),
Aweme("3", "Sexy boy, sexy boy, sexy boy, sexy boy!"),
Aweme("4", "Today is like shit!"))
fun findAwemeById(id: String): Aweme {
return mockAwemeList
.filter { it.awemeId == id }
.run {
if (isNotEmpty()) {
get(0)
} else {
Aweme()
}
}
}
}
编码之前,我们大致理一下思路:
-
通过id找到目标aweme;
-
对aweme的标题做一次预处理,这里我们直接将非英文字符直接替换为空格;
-
利用空格将标题切割成单词集合;
-
遍历集合,统计敏感词出现的次数;
按照以上思路,我们先使用常规的命令式风格进行编码,用findInvalidAweme方法表示:
private fun findInvalidAweme(inputId: String, sensitiveWord: String): SensitiveCount {
val aweme = Repository.findAwemeById(inputId)
val cleanTitle = clean(aweme.title)
var sensitiveWordCount = 0
cleanTitle.split(" ").forEach {
if (sensitiveWord == it.lowercase()) {
sensitiveWordCount ++
}
}
return SensitiveCount(inputId, sensitiveWordCount)
}
其中,clean方法的声明为:
private fun clean(contents: String): String {
return contents.replace("[^A-Za-z]".toRegex(), " ").trim()
}
可以看出,这种风格的代码将每一步具体的实现描述得很清楚,你需要关注每一行是怎么做的,同时,定义了一些临时变量来保存状态,当然,你可以省略这些状态变量,改成嵌套的方式,但那样可读性会更差。
接下来,我们用函数式的风格来实现,前面说过,在函数式编程中,复杂的逻辑可以通过简单函数的组合(Function Composition)来达成,那么,findInvalidAweme最终应该写成这样:
findInvalidAweme = a().b().c().d()
为了让表达式成为链式的结构,我们需要把findAwemeById和clean方法改成扩展函数:
private fun String.findAweme(): Aweme {
return Repository.findAwemeById(this)
}
private fun String.cleanV2(): String {
return this.replace("[^A-Za-z]".toRegex(), " ").trim()
}
而对单词集合的遍历,我们使用kotlin库中集合的扩展方法fold(当然还有其他方式,这里仅列出其中一种),最终,我们用函数式实现的findInvalidAweme变成这样:
private fun findInvalidAwemeV2(inputId: String, sensitiveWord: String): SensitiveCount {
return inputId.findAweme()
.title
.cleanV2()
.split(" ")
.fold(0) { count, word ->
if (sensitiveWord == word.lowercase()) count + 1 else count
}
.run { SensitiveCount(inputId, this) }
}
对比命令式的风格,很明显,函数式的可读性更强,更接近我们说话的表达方式。
到这里,我们对函数式编程的特性,以及具体在编码层面,跟命令式编程的区别,有了一个初步的认知。
需要注意的是,我们并不去评判哪种范式好或者不好,应该根据不同的业务场景选择更有效的编程范式,我们关注的是,如果在那些适合使用函数式的场景下出现一些问题,应该如何解决。
Monad
副作用发生怎么办
我们现在知道,函数式编程就是使用没有副作用的纯函数,通过函数组合来构建逻辑。
但实际开发中,完全没有副作用几乎是不可能的,那么,如何处理业务中的副作用呢,比如下面这个例子:
需求2: 假设对于抖音中的每个用户都有一个特别关注,现在输入一个用户id,找到特别关注用户发布作品的内容(简单起见,内容为文本)。
我们先定义出两个数据类,User和Aweme:
data class User(
val id: String,
val name: String,
var specialFollowId: String? = null,
var awemeId: String? = null)
data class Aweme(
val awemeId: String,
var contents: String? = null)
User中包含一个specialFollowId代表特别关注用户的id,而awemeId代表该用户发布作品的id。
我们先理一下整体思路:
-
通过输入id查找到用户;
-
通过用户的specialFollowId查找特别关注用户;
-
通过特别关注用户的awemeId查找发布作品aweme;
-
输出作品内容文本;
这里,我们定义一个Repository类用来mock用户和作品数据,并提供根据id查找的方法:
object Repository {
private val mockUserList: Array<User> = arrayOf(
User("1", "Micheal", awemeId = "3683"),
User("2", "LeBron", "3", "2764"),
User("3", "Kobe", "1"),
User("4", "Curry", "2", "1125"))
private val mockAwemeList: Array<Aweme> = arrayOf(
Aweme("2764", "aaaa"),
Aweme("1125"),
Aweme("3683", "cccc")
)
fun findUserById(id: String): User? {
...
}
fun findAwemeById(id: String): Aweme? {
...
}
}
然后,根据前面的思路,很自然的写出方法getSpecialFollowAwemeContents,输入id,输出作品内容:
private fun getSpecialFollowAwemeContents(id: String?): String {
if (id != null){
val user = Repository.findUserById(id)
if (user?.specialFollowId != null){
val specialFollowUser = Repository.findUserById(user.specialFollowId)
if (specialFollowUser?.awemeId != null){
val aweme = Repository.findAwemeById(specialFollowUser.awemeId)
return aweme?.contents ?: ""
}
}
}
return ""
}
可以看出,该方法的整体结构实际就是if代码块的嵌套,而这些if的主要作用是确保查找时的id非空,虽然kotlin的非空类型可以减少样板代码,但在业务中,某些情况下仍然无法完全规避if判空的出现,而这些出现空值的点其实就是一种异常,在函数式编程中,属于副作用。
既然副作用(空值)无法避免,有没有办法去降低它的影响,从而消除if嵌套这类样板代码呢?
于是我们想到使用一个包装类Maybe,然后将值(对应到代码中,就是输入id,specialFollowId,awemeId等)包起来,中间不关注里面的值,所有逻辑走完之后,再取出来。
Maybe的命名很形象,类似薛定谔的猫,在最终打开盒子,取出值之前,不知道里面的值是不是空。
class Maybe<T> private constructor(private val value: T?) {
companion object {
@JvmStatic
fun <T> just(t: T): Maybe<T> {
return Maybe(t)
}
@JvmStatic
fun <T> none(): Maybe<T> {
return Maybe(null)
}
@JvmStatic
fun <T> fromValue(t: T?): Maybe<T> {
return if (t != null) just(t) else none()
}
}
fun getOrDefault(default: T): T {
return value ?: default
}
}
这里,我们将Maybe的构造方式不公开,提供三个创建实例的方法:一个传入非空值,一个传入空值,另外一个传入可空值。
用Maybe对可空的字段进行包装:
data class UserV2(
val id: String,
val name: String,
var specialFollowId: Maybe<String> = Maybe.none(),
var awemeId: Maybe<String> = Maybe.none())
data class AwemeV2(
val awemeId: String,
var contents: Maybe<String> = Maybe.none())
这里,我们对specialFollowId,awemeId以及contents进行包装,另外,两个查找方法findUserById和findAwemeById的返回值也有可能为空,处理一下:
fun findUserById(id: String?): Maybe<UserV2> {
return userList.filter { it.id == id }.run {
if (this.isNotEmpty())
Maybe.just(this[0])
else
Maybe.none()
}
}
fun findAwemeById(id: String?): Maybe<AwemeV2> {
return awemeList.filter { it.awemeId == id }.run {
if (this.isNotEmpty())
Maybe.just(this[0])
else
Maybe.none()
}
}
处理完毕,修改之后的getSpecialFollowAwemeContentsV2为:
private fun getSpecialFollowAwemeContentsV2(id: String?): Maybe<String> {
val u1 = RepositoryV2.findUserById(id)
val specialFollowId = u1.getOrDefault().specialFollowId
val u2 = RepositoryV2.findUserById(specialFollowId.getOrDefault())
val awemeId = u2.getOrDefault().awemeId
val aweme = RepositoryV2.findAwemeById(awemeId.getOrDefault())
return aweme.getOrDefault().contents
}
其中,getOrDefault方法省略了默认值参数。
或者省略部分中间临时变量:
private fun getSpecialFollowAwemeContentsV2(id: String?): Maybe<String> {
val specialFollowId = RepositoryV2
.findUserById(id).getOrDefault()
.specialFollowId
val awemeId = RepositoryV2
.findUserById(specialFollowId.getOrDefault()).getOrDefault()
.awemeId
return RepositoryV2.findAwemeById(awemeId.getOrDefault()).getOrDefault().contents
}
可以看出,if嵌套确实没有了,但是,代码质量整体没太大改善甚至劣化:
-
每次查找时传参还要通过getOrDefault取出值,增加了不必要的开销,且每次使用默认值在逻辑上并不合理;
-
代码可读性跟之前没有太大区别;
函数式!
尽管我们消除了if嵌套,让空值这个副作用得到了一定程度的控制,但解决方式并不能令人满意,还有一个最严重的问题就是:
代码依然是命令式风格!
为了成为函数式,我们还需要将每一步的操作变成函数组合的方式,最终的写法应该是:
getSpecialFollowAwemeContents = a().b().c().d()
先看一下现在的函数链条:
注意“×”的地方,代表当前函数的输出无法由于类型不匹配,无法直接作为下一个函数的输入,那么要如何将函数组合起来呢?
这里我们不再像上一讲小结一样,将每个方法改成参数的扩展方法来实现,而是直接基于Maybe包装类。
观察上面的图会发现,函数的输出类型实际是下一个函数输入的包装类型Maybe,那么,Maybe中肯定能取到输入所需要的值,于是,我们定义一个方法:
bind方法接收Maybe和函数getSpecialFollowId作为参数,返回结果为Maybe,注意,输入和输出的Maybe泛型参数可能不一致,而getSpecialFollowId的定义为:
(User) -> Maybe<String>
于是,bind更普遍性的定义为:
用Kotlin表示为:
inline fun <R> bind(transform: (T) -> Maybe<R>): Maybe<R> {
return if (value != null) transform(value) else none()
}
函数体也很简单,如果当前Maybe的持有值不为空,则执行transform,否则直接返回none(),最终的返回结果都是Maybe类型。
现在Maybe完整写法为:
class Maybe<T> private constructor(val value: T?) {
companion object {
@JvmStatic
fun <T> just(t: T): Maybe<T> {
return Maybe(t)
}
@JvmStatic
fun <T> none(): Maybe<T> {
return Maybe(null)
}
@JvmStatic
fun <T> fromValue(t: T?): Maybe<T> {
return if (t != null) just(t) else none()
}
}
inline fun <R> bind(transform: (T) -> Maybe<R>): Maybe<R> {
return if (value != null) transform(value) else none()
}
fun getOrDefault(default: T): T {
return value ?: default
}
}
OK,现在,我们使用新的Maybe去改写getSpecialFollowAwemeContents方法:
private fun getSpecialFollowAwemeContentsV3(id: String?): Maybe<String> {
return Maybe.fromValue(id)
.bind { i -> RepositoryV2.findUserById(i) }
.bind { u -> u.specialFollowId }
.bind { i -> RepositoryV2.findUserById(i) }
.bind { u -> u.awemeId }
.bind { i -> RepositoryV2.findAwemeById(i) }
.bind { a -> a.contents }
}
整个函数体通过函数组合变成了一行表达式!
再对比下最开始的getSpecialFollowAwemeContents:
private fun getSpecialFollowAwemeContents(id: String?): String {
if (id != null){
val user = Repository.findUserById(id)
if (user?.specialFollowId != null){
val specialFollowUser = Repository.findUserById(user.specialFollowId)
if (specialFollowUser?.awemeId != null){
val aweme = Repository.findAwemeById(specialFollowUser.awemeId)
return aweme?.contents ?: ""
}
}
}
return ""
}
在实际调用getSpecialFollowAwemeContentsV3时,从结果的Maybe中取值即可:
val contents = getSpecialFollowAwemeContentsV3(inputId).getOrDefault("")
可以看出,通过函数式的控制副作用以及函数组合:
-
不用关注空值的情况了;一旦中间某个节点出现副作用(空值),会将副作用forward下去,最终整个逻辑执行完毕后,再取出进行进行消费;
-
代码可读性大大提升;函数体只有一行表达式,整体上类似Linux的pipeline效果,每一步的目的非常清晰,程序可推理性极强;
更强大的异常处理
我们已经通过Maybe很好的处理了空值这种副作用,同时实现了函数组合,而实际业务场景中,异常的情况往往比空值更复杂。
需求3: 对需求2中可能出现的异常信息进行打印。
和空值一样,这里的打印异常信息也是一种副作用。显然,如果找到每一个异常点,然后进行打印,就又回到命令式的老路上去了。
参考Maybe的思路,我们把异常信息和正常值放到一个包装类中,为了承载异常信息,另外定义一个Error类:
data class Error(val msg: String)
新的包装类Result包含两个类型参数T,E,分别代表正常值和异常,提供对应创建实例方法,并提供一个取值方法:
class Result<T, E> private constructor(val value: T?, val error: E?) {
companion object {
@JvmStatic
fun <T, E> fromValue(t: T?): Result<T, E> {
return Result(t, null)
}
@JvmStatic
fun <T, E> fromError(e: E?): Result<T, E> {
return Result(null, e)
}
}
fun hasError(): Boolean {
return error != null
}
fun getOrHandle(errorHandler: (E) -> Unit): T? {
if (error != null) {
errorHandler(error)
}
return value
}
}
其中,取值方法getOrHandle支持传入一个方法作为参数,方便灵活定义异常处理方式。
对于可能出现异常的数据类字段,需要包装一下:
data class UserV3(
val id: String,
val name: String,
var specialFollowId: Result<String, Error> = Result.fromError(Error("Id is empty!")),
var awemeId: Result<String, Error> = Result.fromError(Error("AwemeId is empty!")))
data class AwemeV3(
val awemeId: String,
var contents: Result<String, Error> = Result.fromError(Error("Contents are empty")))
同时,调整Repository中的两个查找方法:
fun findUserByIdV2(id: String?): Result<User, Error> {
return if (id != null && id.isNotEmpty()) {
mockUserList.filter { it.id == id }.run {
if (this.isNotEmpty()){
Result.fromValue(this[0])
} else {
Result.fromError(Error("No user matches the given id!"))
}
}
} else {
Result.fromError(Error("Id is empty!"))
}
}
fun findAwemeByIdV2(id: String?): Result<Aweme, Error> {
return if (id != null && id.isNotEmpty()) {
mockAwemeList.filter { it.awemeId == id }.run {
if (isNotEmpty()){
Result.fromValue(this[0])
} else {
Result.fromError(Error("No aweme matches the given id!"))
}
}
} else {
Result.fromError(Error("AwemeId is empty!"))
}
}
输入id为空,或没有结果匹配id时,通过Result.fromError返回异常结果。
为了对比最终函数式的差异,我们先用命令式实现一下getSpecialFollowAwemeContents:
private fun getSpecialFollowAwemeContents(id: String?): Result<out Any, Error> {
val userResult = Repository.findUserByIdV2(id)
if (userResult.hasError()) {
return userResult
}
val user = userResult.value
val specialFollowUserResult = Repository.findUserByIdV2(user?.specialFollowId)
if (specialFollowUserResult.hasError()) {
return specialFollowUserResult
}
val specialFollowUser = specialFollowUserResult.value
val awemeResult = Repository.findAwemeByIdV2(specialFollowUser?.awemeId)
if (awemeResult.hasError()) {
return awemeResult
}
val aweme = awemeResult.value
return if (aweme?.contents != null)
Result.fromValue(aweme.contents)
else
Result.fromError(Error("Contents are empty!"))
}
然后,和Maybe的做法一样,给Result添加bind用来进行函数组合:
inline fun <R> bind(transform: (T) -> Result<R, E>): Result<R, E> {
return if (value == null) fromError(error) else transform(value)
}
最终getSpecialFollowAwemeContents的函数式写法为*:*
private fun getSpecialFollowAwemeContentsV4(id: String?): Result<String, Error> {
return Result.fromValue<String, Error>(id)
.bind { i -> RepositoryV3.findUserById(i) }
.bind { u -> u.specialFollowId }
.bind { i -> RepositoryV3.findUserById(i) }
.bind { u -> u.awemeId }
.bind { i -> RepositoryV3.findAwemeById(i) }
.bind { a -> a.contents }
}
调用一下:
val specialFollowAwemeContents2 = getSpecialFollowAwemeContentsV2(inputId)
.getOrHandle{ e -> println(e.msg)}
对比上一小节使用Maybe处理空值的getSpecialFollowAwemeContents函数式写法:
private fun getSpecialFollowAwemeContentsV3(id: String?): Maybe<String> {
return Maybe.fromValue(id)
.bind { i -> RepositoryV2.findUserById(i) }
.bind { u -> u.specialFollowId }
.bind { i -> RepositoryV2.findUserById(i) }
.bind { u -> u.awemeId }
.bind { i -> RepositoryV2.findAwemeById(i) }
.bind { a -> a.contents }
}
结构上几乎没有太大变化!
Monad!
简单回顾一下。
我们通过两个栗子(实际后面一个是前一个的拓展),展示了在副作用发生时,如何控制副作用,并最终通过函数组合实现函数式编程。而实现的方式就是,引入Maybe和Result包装类对值和异常进行包装,最后发现,代码结构几乎是一模一样!说明Maybe和Result本质上是同一类解决方案,而这种通用设计正是monad。
Monad在范畴论(数学的一个研究分支)中是有明确定义的,我们之所以不去给出这个定义,原因在于:
-
数学的描述过于抽象,增加了理解成本;
-
数学的定义对开发者而言并没有那么重要,通过具象的代码才能更好的理解;
尽管如此,我们还是给出monad的三个核心要素:
-
一个类型构造器M,可以将类型T变为M T;
对应到代码中,就是Maybe中的类型参数定义,即
class Maybe < T > -
一个产出包装类M T实例的构建方法;
在函数式语言中,一般声明为return或unit(x):
unit (x) : T -> M T return :: a -> m a对应到我们的代码中,就是:
fun <T> fromValue (t: T ?) : Maybe<T> -
一个组合子(combinator)函数bind,用来取出包装类实例中的值,然后作为一个逻辑函数的输入,最终返回另外一个包装类实例,Haskell中的定义为:
(>>=) :: m a -> (a -> m b) -> m b对应我们的代码中,就是:
inline fun <R> bind (transform: ( T ) -> Maybe < R >) : Maybe<R>
关于这三个要素的作用,在前面分析代码的过程中,其实已经有相关体现,再简单说明一下:
-
关于构造器M,在函数式语言中,有高阶类型的设计,类比高阶函数,我们可以理解为能够构建新类型的类型,在Java/Kotlin中,我们通过包装类泛型参数实现;
-
关于产出包装类M T实例的构建方法,这个很好理解,给定一个值类型实例,创建出一个包装类实例;
-
关于组合子函数bind,就是实现函数组合的一种手段,前面的代码已经能够体现它是如何推导出来的;
另外,按照严格的标准,monad还必须遵守三个规则,才能确保正常work,这里我们就不展开了,大家有兴趣的可以去看下维基百科:en.wikipedia.org/wiki/Monad_…
当然,我们的Maybe和Result是符合这三个规则的。
Monad在RxJava中的运用
简单回顾下目前为止,我们讲了什么:
-
函数式编程的3个特性;
-
在适用函数式的场景下,可能会产生的问题(副作用,函数组合),以及,我们通过包装类的设计将这些问题解决,最终演变为函数式代码;
-
对这种通用设计——Monad给出核心要素的定义;
那么RxJava中是如何运用monad的呢?
我们从RxJava的响应式设计说起。
更好的响应式
由于RxJava只是ReactiveX的一种语言实现,我们在ReactiveX官网看一下它的定义:
ReactiveX is a library for composing asynchronous and event-based programs by using observable sequences.
这里的异步和基于事件实际指的就是响应式,我们对响应式也并不陌生,比如熟悉的onClickListener,这也是最常用的响应式编程手段之一——callback。
而无论是callback,还是其他响应式方式,背后的理念都是观察者模式,即定义一个观察者Observer和被观察者Observable,Observable生产数据,然后通知Observer消费数据。
大家都知道,callback实现响应式有以下几个主要缺点:
-
作为一个消费者,需要关注的点太多,比如什么时候会有I/O或线程调度等异步操作;
-
消费者职责不够单一,比如有时候还需要对数据进行二次处理,而数据处理本应该完全由生产者负责;
-
发生多次异步切换时,产生callback嵌套,即所谓的回调地狱(callback hell);
-
代码可读性不高;
-
......
于是,ReactiveX在诞生之初,首要问题就是,如何实现更好的响应式呢?
在ReactiveX设计者看来,更好的响应式就是能像同步操作一样进行响应式操作,他们的目光放在了编程中最常用的数据抽象之一——Iterable上,我们知道,Iterable是List,Set,Map等集合类共同的父类:
interface Iterable<out T> {
fun iterator(): Iterator<T>
}
interface Iterator<out T> {
fun next(): T
fun hasNext(): Boolean
}
其中Iterator是迭代器,而Iterable的工作方式为:
val i = list.iterator()
while(i.hasNext()){
val value = i.next()
}
我们知道,Kotlin中,foreach方法是Iterable的扩展方法,最终仍然是上述的实现方式,即拿到一个迭代器,然后进入循环,每次消费前通过*hasNext()询问一下,有数据的话,就通过next()*取出数据进行消费。
比如我们定义一个方法:
fun getDataFromLocalMemory(): List<T>
然后进行数据消费:
getDataFromLocalMemory()
.skip(10)
.take(5)
.map({ s -> return s + " transformed" })
.forEach({ println "next => " + it })
这是一个同步操作,可以理解为消费者主动拉取数据,如果响应式,即生产者推数据,消费者被动接收的时候,也能像同步操作一样编码,不就能解决callback遇到的问题吗:
-
消费者完全不需要关注上游数据链路的情况:I/O,线程调度等;
-
消费者职责明确,只需要消费数据就行了;
-
没有回调地狱,链式结构;
-
代码可读性高;
于是,我们来参考Iterable的设计。
由于响应式是观察者模式,首先定义两个类:Observable和Observer,Observable对应Iterable,Observer对应iterator。
接下来的分析我们站在主动方的视角。
集合的主动方为消费者,在消费者视角上,需要关注:
-
是否还有数据;
-
如果有,给我数据;
而这两个操作只有生产者Iterable能做到,于是提供iterator及对应方法hasNext和next。
而响应式的主动方为生产者,Observable需要外部注册一个类似callback进来,
然后提供hasNext通知消费者是否还有数据,next方法将数据推给消费者:
interface Observable<out T> {
fun subscribe(observer: Observer<T>)
}
interface Observer<out T> {
fun next(t: T)
fun hasNext(b: Boolean)
}
由于生产者没有必要每次都去通知消费者是否还有数据,而只需要最后完成时通知即可,另外,如果执行出错也无法继续给出数据,因此hasNext()被拆解成onComplete()和onError(),Observer定义为:
interface Observer<out T> {
fun next(t: T)
fun onComplete()
fun onError(e: Throwable)
}
我们再对比一下Iterable和Observable的定义(截图来自ReactiveX官网):
最后,我们定义一个方法,来对比一下Iterable和Observable的使用:
fun getDataFromNetwork(): Observable<T>
形式上完全没有区别!
到这里,ReactiveX的目的就达到了:
像同步操作一样进行响应式操作。
函数式!
前面介绍了,RxJava是如何从Iterable中得到灵感,然后推导出更好的响应式实现方式的。
虽然我们得到了Observable和Observer的定义,但实际上漏掉了一点,最后用作对比的代码中,操作符的结构显然是函数式风格:
getDataFromNetwork()
.skip(10)
.take(5)
.map({ s -> return s + " transformed" })
.subscribe({ println "onNext => " + it })
怎么做到的呢?
我们直接给出结论,Observable是Monad。
由于Observable的操作符数量太多,我们选取比较常用的map和filter来分析。
比如以下代码:
Observable.fromArray("1", "2", "3", "4")
.map { it.toInt() + 1 }
.filter { it > 3 }
.subscribe(
{ value -> print(value) },
{ e -> print(e) })
进到源码,看看map做了什么:
public final <R> Observable<R> map(Function<? super T, ? extends R> mapper) {
ObjectHelper.requireNonNull(mapper, "mapper is null");
return RxJavaPlugins.onAssembly(new ObservableMap<T, R>(this, mapper));
}
这里的RxJavaPlugins.onAssembly()是一个hook function,我们直接忽略,相当于直接返回了一个ObservableMap类,我们看下这个类,为了尽可能减少其他代码的干扰,突出我们要分析的重点,这里我对代码做了简化:
class ObservableMap<T, U>
(
private val source: ObservableSource<T>,
private val mapper: (T) -> U,
) : Observable<U>() {
override fun subscribeActual(observer: Observer<U>) {
source.subscribe(MapObserver(observer, mapper))
}
class MapObserver<T, U>
(
override val downstream: Observer<U>,
val mapper: (T) -> U,
) : BaseObserver<T, U>(downstream) {
override fun onNext(t: T) {
if (!done) {
val v: U = mapper(t)
downstream.onNext(v)
}
}
}
}
可以看出,ObservableMap本身也是一个Observable,持有了上游的源Observable以及数据的处理逻辑mapper,最终在调用subscribe时,由上游的Observable执行实际的subscribe操作,而下游观察者实际消费的数据,会先通过mapper进行处理。
回顾一下前面函数组合时,最关键的bind函数模型:
Observable的map方法同样符合:
再看看filter方法:
public final Observable<T> filter(Predicate<? super T> predicate) {
ObjectHelper.requireNonNull(predicate, "predicate is null");
return RxJavaPlugins.onAssembly(new ObservableFilter<T>(this, predicate));
}
同样返回一个Observable类型的ObservableFilter:
class ObservableMapFilter<T>
(
private val source: ObservableSource<T>,
private val predicate: (T) -> Boolean,
) : Observable<T>() {
override fun subscribeActual(observer: Observer<T>) {
source.subscribe(FilterObserver(observer, predicate))
}
class FilterObserver<T>
(
override val downstream: Observer<T>,
val predicate: (T) -> Boolean,
) : BaseObserver<T, T>(downstream) {
override fun onNext(t: T) {
if (predicate(t)) {
downstream.onNext(t)
}
}
}
}
跟map的实现不能说没有相似之处,简直就是一模一样!
因此,经过map和filter,我们最终得到的Observable实际上是:
ObservableFilter(ObservableMap(Observable.fromArray("1", "2", "3", "4")))
但是形式上,由于monad的函数组合能力,最终成为一个简洁明了的链式调用。
小结
我们现在知道,正是因为引入了Iterable和monad的设计理念,RxJava才成为现在的样子,使得我们可以通过同步的方式来进行响应式操作,在大大提升代码可读性的同时,可以专注于业务逻辑本身,而不用考虑上游数据的诸多问题。
写在最后
本文从编程范式说起,介绍了函数式的几个重要特性,然后通过具体例子结合对应代码的演进,引出了monad这种函数式中的通用设计,最后印证了这种设计是如何运用在RxJava中的。
当然,函数式和monad本身是两个非常大的话题,比如函数式的其他诸多特性,以及monad其他典型的应用场景,都没有展开去讲,限于篇幅和本人理解有限,关于这两个话题的介绍可以当做初窥门径的引导,更广阔的世界等待大家自己去探索。
在本文接近尾声的时候,我不禁思考起一个问题,这篇文章对大家的作用是什么?
肯定不会有助于你更好的使用RxJava,也不会让你对RxJava的工作原理有更多的认识(本文绝大部分内容都不涉及RxJava源码分析),或许能让你多一个理解RxJava的视角,比如下次再去写Observable操作符的时候,感慨一句,哦,这不就是monad的bind函数嘛!如此而已。
而我的想法是,从更长远的视角上看,RxJava不过是诸多业务框架中的一个,随着架构理念的演进,总会有更新更易用的替代方案出现,因此,在RxJava上花太多精力去记住操作符,研究代码细节和工作流程,实际收益相对局限,不如从架构的角度,去理解它背后所抽象出来的设计理念,这样设计的好处是什么,如何在业务中落地,不断演进和优化代码架构质量,从而持续性的为业务带来收益。