面向未来的 Android 代码,第 1 部分:函数式和反应式编程基础

已发表: 2022-08-31

编写干净的代码可能具有挑战性:库、框架和 API 是临时的,很快就会过时。 但是数学概念和范式是持久的; 它们需要多年的学术研究,甚至可能比我们更持久。

这不是一个向您展示如何使用库 Y 进行 X 的教程。相反,我们专注于函数式和反应式编程背后的持久原则,因此您可以构建面向未来且可靠的 Android 架构,并在不妥协的情况下扩展和适应变化效率。

本文奠定了基础,在第 2 部分中,我们将深入探讨函数式反应式编程 (FRP) 的实现,它结合了函数式和反应式编程。

本文是为 Android 开发人员编写的,但这些概念对任何具有通用编程语言经验的开发人员都是相关且有益的。

函数式编程 101

函数式编程 (FP) 是一种将程序构建为函数组合的模式,将数据从 $A$ 转换为 $B$,再转换为 $C$ 等,直到获得所需的输出。 在面向对象编程 (OOP) 中,您逐条告诉计算机要做什么。 函数式编程是不同的:你放弃控制流并定义一个“函数配方”来产生你的结果。

左侧带有文本“输入:x”的绿色矩形有一个箭头,指向标有“功能:f”的浅灰色矩形。在浅灰色的矩形内,有三个箭头指向右侧的圆柱体:第一个是浅蓝色标记为“A(x)”,第二个是深蓝色标记为“B(x)”,第三个是深灰色标记为“C” (X)。”在浅灰色矩形的右侧,有一个带有文本“输出:f(x)”的绿色矩形。浅灰色矩形的底部有一个向下指向文本“副作用”的箭头。
函数式编程模式

FP 起源于数学,特别是 lambda 演算,一种函数抽象的逻辑系统。 与循环、类、多态性或继承等 OOP 概念不同,FP 严格处理抽象和高阶函数,即接受其他函数作为输入的数学函数。

简而言之,FP 有两个主要的“参与者”:数据(模型或问题所需的信息)和函数(数据之间的行为和转换的表示)。 相比之下,OOP 类明确地将特定领域的数据结构——以及与每个类实例关联的值或状态——与打算与之一起使用的行为(方法)联系起来。

我们将更仔细地研究 FP 的三个关键方面:

  • FP 是声明性的。
  • FP 使用函数组合。
  • FP 函数是纯函数。

深入了解 FP 世界的一个很好的起点是 Haskell,这是一种强类型的纯函数式语言。 我推荐Learn You a Haskell for Great Good! 交互式教程作为有益的资源。

FP 要素 #1:声明式编程

关于 FP 程序,您首先会注意到它是用声明式而不是命令式编写的。 简而言之,声明式编程告诉程序需要做什么而不是如何去做。 让我们用一个命令式编程与声明式编程的具体示例来解决这个抽象定义,以解决以下问题:给定一个名称列表,返回一个列表,该列表仅包含具有至少三个元音且元音以大写字母显示的名称。

势在必行的解决方案

首先,让我们看看这个问题在 Kotlin 中的命令式解决方案:

 fun namesImperative(input: List<String>): List<String> { val result = mutableListOf<String>() val vowels = listOf('A', 'E', 'I', 'O', 'U','a', 'e', 'i', 'o', 'u') for (name in input) { // loop 1 var vowelsCount = 0 for (char in name) { // loop 2 if (isVowel(char, vowels)) { vowelsCount++ if (vowelsCount == 3) { val uppercaseName = StringBuilder() for (finalChar in name) { // loop 3 var transformedChar = finalChar // ignore that the first letter might be uppercase if (isVowel(finalChar, vowels)) { transformedChar = finalChar.uppercaseChar() } uppercaseName.append(transformedChar) } result.add(uppercaseName.toString()) break } } } } return result } fun isVowel(char: Char, vowels: List<Char>): Boolean { return vowels.contains(char) } fun main() { println(namesImperative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

现在,我们将考虑几个关键的开发因素来分析我们的命令式解决方案:

  • 最高效:此解决方案具有最佳的内存使用率,并且在 Big O 分析中表现良好(基于最少的比较次数)。 在这个算法中,分析字符之间的比较次数是有意义的,因为这是我们算法中的主要操作。 令$n$ 为名字的数量,令$k$ 为名字的平均长度。

    • 最坏情况比较次数:$n(10k)(10k) = 100nk^2$
    • 解释:$n$(循环 1)* $10k$(对于每个字符,我们与 10 个可能的元音进行比较)* $10k$(我们再次执行isVowel()检查以决定是否将字符大写——同样,在最坏的情况,这与 10 个元音相比)。
    • 结果:由于平均名称长度不会超过 100 个字符,我们可以说我们的算法在$O(n)$时间内运行。
  • 可读性差的复杂:与我们接下来要考虑的声明式解决方案相比,这个解决方案更长且更难遵循。
  • 容易出错:代码transformedCharresultvowelsCount和 encryptedChar ; 这些状态突变会导致一些细微的错误,比如忘记将vowelsCount重置为 0。执行流程也可能变得复杂,很容易忘记在第三个循环中添加break语句。
  • 可维护性差:由于我们的代码复杂且容易出错,因此重构或更改此代码的行为可能很困难。 例如,如果将问题修改为选择具有三个元音和五个辅音的名称,我们将不得不引入新变量并更改循环,从而给错误留下很多机会。

我们的示例解决方案说明了命令式代码的复杂性,尽管您可以通过将代码重构为更小的函数来改进代码。

声明式解决方案

现在我们了解了声明式编程不是什么,让我们在 Kotlin 中展示我们的声明式解决方案:

 fun namesDeclarative(input: List<String>): List<String> = input.filter { name -> name.count(::isVowel) >= 3 }.map { name -> name.map { char -> if (isVowel(char)) char.uppercaseChar() else char }.joinToString("") } fun isVowel(char: Char): Boolean = listOf('A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u').contains(char) fun main() { println(namesDeclarative(listOf("Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"))) // [IlIyAn, AnnAbEl, NIcOlE] }

使用我们用来评估命令式解决方案的相同标准,让我们看看声明性代码是如何成立的:

  • 高效:命令式和声明式实现都在线性时间内运行,但命令式的效率更高一些,因为我在这里使用name.count() ,它将继续计算元音​​直到名称的结尾(即使在找到三个元音之后)。 我们可以通过编写一个简单的hasThreeVowels(String): Boolean函数来轻松解决这个问题。 此解决方案使用与命令式解决方案相同的算法,因此此处适用相同的复杂性分析:我们的算法在$O(n)$时间内运行。
  • 简洁且可读性好:命令式解决方案是 44 行,缩进很大,而我们的声明性解决方案是 16 行,缩进很小。 行和制表符并不是万能的,但是从这两个文件中可以明显看出,我们的声明式解决方案更具可读性。
  • 不易出错:在此示例中,一切都是不可变的。 我们将所有名称的List<String> List<String>为具有三个或更多元音的名​​称的 List<String>,然后将每个String单词转换为具有大写元音的String单词。 总体而言,没有突变、嵌套循环或中断并放弃控制流使代码更简单,错误空间更小。
  • 良好的可维护性:由于其可读性和健壮性,您可以轻松地重构声明性代码。 在我们之前的示例中(假设问题修改为选择具有三个元音和五个辅音的名称),一个简单的解决方案是在filter条件中添加以下语句: val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5 val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5

作为补充,我们的声明式解决方案是纯函数式的:此示例中的每个函数都是纯函数并且没有副作用。 (稍后会详细介绍纯度。)

奖金声明式解决方案

让我们看一下用 Haskell 之类的纯函数式语言对同一问题的声明式实现,以演示它是如何读取的。 如果您不熟悉 Haskell,请注意. Haskell 中的运算符读作“之后”。 例如, solution = map uppercaseVowels . filter hasThreeVowels solution = map uppercaseVowels . filter hasThreeVowels翻译为“在过滤具有三个元音的名称后将元音映射为大写”。

 import Data.Char(toUpper) namesSolution :: [String] -> [String] namesSolution = map uppercaseVowels . filter hasThreeVowels hasThreeVowels :: String -> Bool hasThreeVowels s = count isVowel s >= 3 uppercaseVowels :: String -> String uppercaseVowels = map uppercaseVowel where uppercaseVowel :: Char -> Char uppercaseVowel c | isVowel c = toUpper c | otherwise = c isVowel :: Char -> Bool isVowel c = c `elem` vowels vowels :: [Char] vowels = ['A', 'E', 'I', 'O', 'U', 'a', 'e', 'i', 'o', 'u'] count :: (a -> Bool) -> [a] -> Int count _ [] = 0 count pred (x:xs) | pred x = 1 + count pred xs | otherwise = count pred xs main :: IO () main = print $ namesSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"] -- ["IlIyAn","AnnAbEl","NIcOlE"]

该解决方案的性能与我们的 Kotlin 声明式解决方案类似,但还有一些额外的好处:如果您了解 Haskell 的语法,它是可读的、简单的、纯函数式的和惰性的。

关键要点

声明式编程对于 FP 和反应式编程(我们将在后面的部分中介绍)都很有用。

  • 它描述了您想要实现的“什么”——而不是“如何”实现它,以及语句执行的确切顺序。
  • 它抽象了程序的控制流,而是关注于转换方面的问题(即,$A \rightarrow B \rightarrow C \rightarrow D$)。
  • 它鼓励更简单、更简洁、更易读、更容易重构和更改的代码。 如果您的 Android 代码读起来不像一个句子,那么您可能做错了什么。

如果您的 Android 代码读起来不像一个句子,那么您可能做错了什么。

鸣叫

尽管如此,声明式编程还是有一些缺点。 最终可能会出现效率低下的代码,这些代码消耗更多的 RAM,并且性能比命令式实现更差。 排序、反向传播(在机器学习中)和其他“变异算法”并不适合不可变的声明式编程风格。

FP 成分 #2:功能组合

函数组合是函数式编程核心的数学概念。 如果函数 $f$ 接受 $A$ 作为其输入并产生 $B$ 作为其输出 ($f: A \rightarrow B$),并且函数 $g$ 接受 $B$ 并产生 $C$ ($g: B \rightarrow C$),然后你可以创建第三个函数 $h$,它接受 $A$ 并产生 $C$ ($h: A \rightarrow C$)。 我们可以将这第三个函数定义为 $g$ 与 $f$ 的组合,也记为 $g \circ f$ 或 $g(f())$:

标有“A”的蓝色框有一个箭头,“f”指向标有“B”的蓝色框,该框有一个箭头“g”,指向标有“C”的蓝色框。方框“A”还有一个平行箭头“g o f”,直接指向方框“C”。
函数 f、g 和 h,g 与 f 的合成。

通过将问题分解为更小的问题,独立解决它们,并通过函数组合将更小的解决方案重新组合成最终解决方案,每个命令式解决方案都可以转化为声明式解决方案。 让我们看看上一节中的名称问题,以了解这个概念的实际应用。 命令式解决方案中的小问题是:

  1. isVowel :: Char -> Bool :给定一个Char ,返回它是否是元音( Bool )。
  2. countVowels :: String -> Int :给定一个String ,返回其中元音的数量( Int )。
  3. hasThreeVowels :: String -> Bool :给定一个String ,返回它是否至少有三个元音( Bool )。
  4. uppercaseVowels :: String -> String :给定一个String ,返回一个带有大写元音的新String

我们通过函数组合实现的声明式解决方案是map uppercaseVowels . filter hasThreeVowels map uppercaseVowels . filter hasThreeVowels

上图有三个蓝色的“[String]”框,由指向右侧的箭头连接。第一个箭头标记为“filter has3Vowels”,第二个箭头标记为“map uppercaseVowels”。下面,第二个图左侧有两个蓝色框,顶部有“Char”,下方有“String”,指向右侧的蓝色框“Bool”。从“Char”到“Bool”的箭头标记为“isVowel”,从“String”到“Bool”的箭头标记为“has3Vowels”。 “字符串”框还有一个指向自身的箭头,标记为“大写元音”。
使用我们的名称问题的函数组合示例。

这个例子比简单的 $A \rightarrow B \rightarrow C$ 公式要复杂一些,但它展示了函数组合背后的原理。

关键要点

函数组合是一个简单而强大的概念。

  • 它提供了一种解决复杂问题的策略,其中问题被分解成更小、更简单的步骤并组合成一个解决方案。
  • 它提供了构建块,允许您轻松添加、删除或更改最终解决方案的部分内容,而不必担心会破坏某些内容。
  • 如果 $f$ 的输出与 $g$ 的输入类型匹配,则可以编写 $g(f())$。

组合函数时,您不仅可以传递数据,还可以将函数作为输入传递给其他函数——高阶函数的一个例子。

FP 成分 #3:纯度

我们必须解决函数组合的另一个关键要素:您组合的函数必须是的,这是另一个源自数学的概念。 在数学中,所有函数都是在使用相同输入调用时总是产生相同输出的计算; 这是清净的基础。

让我们看一个使用数学函数的伪代码示例。 假设我们有一个函数makeEven ,它将整数输入加倍以使其均匀,并且我们的代码使用输入x = 2执行makeEven(x) + x行。 在数学中,这个计算总是转化为 $2x + x = 3x = 3(2) = 6$ 的计算并且是一个纯函数。 然而,这在编程中并不总是正确的——如果函数makeEven(x)在代码返回我们的结果之前通过将x加倍来改变 x,那么我们的行将计算 $2x + (2x) = 4x = 4(2) = 8$更糟糕的是,每次调用makeEven都会改变结果。

让我们探索几种类型的函数,它们不是纯函数,但可以帮助我们更具体地定义纯函数:

  • 偏函数:这些函数不是为所有输入值定义的,例如除法。 从编程的角度来看,这些是引发异常的函数: fun divide(a: Int, b: Int): Float将为输入b = 0引发由零除引起的ArithmeticException
  • 总函数:这些函数是为所有输入值定义的,但在使用相同输入调用时会产生不同的输出或副作用。 Android 世界充满了各种功能: Log.dLocalDateTime.nowLocale.getDefault只是其中的几个例子。

考虑到这些定义,我们可以将纯函数定义为没有副作用的总函数。 仅使用纯函数构建的函数组合可以生成更可靠、可预测和可测试的代码。

提示:要使总函数纯粹,您可以通过将其作为高阶函数参数传递来抽象其副作用。 这样,您可以通过传递模拟的高阶函数来轻松测试总函数。 此示例使用了我们稍后在教程中检查的库 Ivy FRP 中的@SideEffect注释:

 suspend fun deadlinePassed( deadline: LocalDate, @SideEffect currentDate: suspend () -> LocalDate ): Boolean = deadline.isAfter(currentDate())

关键要点

纯度是函数式编程范式所需的最终要素。

  • 小心使用偏函数——它们可能会使您的应用程序崩溃。
  • 组合总函数不是确定性的; 它会产生不可预测的行为。
  • 尽可能编写纯函数。 您将从提高的代码稳定性中受益。

完成对函数式编程的概述后,让我们检查面向未来的 Android 代码的下一个组件:反应式编程。

反应式编程101

反应式编程是一种声明式编程模式,其中程序对数据或事件更改做出反应,而不是请求有关更改的信息。

两个主要的蓝色框,“Observable”和“State”,它们之间有两条主要路径。第一个是通过“观察(监听变化)”。第二个是通过“通知(最新状态)”到蓝色框“UI(后端 API)”,通过“将用户输入转换为”到蓝色框“事件”,通过“触发器”到蓝色框“功能组合”,最后通过“生产(新状态)”。然后,“状态”还通过“作为输入”连接回“功能组合”。
一般的反应式编程周期。

响应式编程周期中的基本元素是事件、声明性管道、状态和可观察对象:

  • 事件是来自外部世界的信号,通常以用户输入或系统事件的形式触发更新。 事件的目的是将信号转换为管道输入。
  • 声明式管道是一个函数组合,它接受(Event, State)作为输入并将此输入转换为新的State (输出): (Event, State) -> f -> g -> … -> n -> State 。 管道必须异步执行以处理多个事件,而不会阻塞其他管道或等待它们完成。
  • 状态是软件应用程序在给定时间点的数据模型表示。 域逻辑使用状态来计算所需的下一个状态并进行相应的更新。
  • Observables监听状态变化并根据这些变化更新订阅者。 在 Android 中,可观察对象通常使用FlowLiveDataRxJava ,它们会通知 UI 状态更新,以便它可以做出相应的反应。

反应式编程有很多定义和实现。 在这里,我采取了一种务实的方法,专注于将这些概念应用到实际项目中。

连接点:函数响应式编程

函数式编程和反应式编程是两个强大的范例。 这些概念超越了库和 API 的短暂生命周期,并将在未来几年提高您的编程技能。

此外,FP 和反应式编程的功能结合起来会成倍增加。 现在我们已经对函数式编程和响应式编程有了明确的定义,我们可以将它们组合在一起。 在本教程的第 2 部分中,我们定义了函数响应式编程 (FRP) 范式,并通过示例应用程序实现和相关的 Android 库将其付诸实践。

Toptal 工程博客对 Tarun Goyal 对本文中提供的代码示例的审阅表示感谢。