面向未来的Android代码第一部分:功能性和反应性编程基础

111 阅读12分钟

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

本教程探讨了两个关键的编程范式--函数式编程和反应式编程背后的数学概念,因此你可以建立持久的Android架构。

Iliyan是一名安卓开发者和CTO,他创立了四家初创公司,并创建了几个评级最高的应用程序,包括Ivy Wallet,它获得了10个YouTube技术社区 "最佳UI/UX "奖。他专注于函数式编程、用户体验、Kotlin和Haskell。

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

这不是一个向你展示如何用Y库做X的教程。相反,我们专注于函数式和反应式编程背后的持久原则,这样你就可以建立面向未来的可靠的Android架构,并在不影响效率的情况下扩展和适应变化。

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

这篇文章是以Android开发者为对象写的,但这些概念对任何有一般编程语言经验的开发者都是相关和有益的。

函数式编程101

函数式编程(FP)是一种模式,在这种模式中,你将程序构建为函数的组合,将数据从AA转化为BB,再转化为CC,等等,直到实现预期的输出。在面向对象编程(OOP)中,你逐条指令告诉计算机该怎么做。函数式编程则不同:你放弃了控制流,而是定义一个 "函数配方 "来产生你的结果。

函数式编程模式

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

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

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

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

进一步深入FP世界的一个好的起点是Haskell,一种强类型的纯函数式语言。我推荐你学习Haskell的大好时机!互动教程是一个有益的资源。

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]
}

现在我们将结合一些关键的开发因素来分析我们的命令式解决方案:

  • 最高效:这个解决方案有最佳的内存使用,在大O分析中表现良好(基于最小的比较次数)。在这个算法中,分析字符间的比较次数是有意义的,因为这是我们算法中最主要的操作。让nn为名字的数量,让kk为名字的平均长度。

    • 最坏情况下的比较次数:n(10k)(10k)=100nk2n(10k)(10k)=100nk^2
    • 解释:nn(循环1)*10k(对于每个字符,我们与10个可能的元音进行比较)\*10k(对于每个字符,我们与10个可能的元音进行比较)\*10k(我们再次执行isVowel() 检查,以决定是否将字符大写--同样,在最坏情况下,这与10个元音进行比较)。
    • 结果。由于名字的平均长度不会超过100个字符,我们可以说我们的算法在**O(n)O(n)**时间内运行。
  • 复杂可读性差。与我们接下来要考虑的声明式解决方案相比,这个解决方案要长得多,也更难理解。

  • 容易出错:代码会突变resultvowelsCounttransformedChar ;这些状态的突变可能会导致细微的错误,比如忘记将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)O(n)**时间内运行。
  • 简洁,可读性好:命令式解决方案有44行,缩进幅度很大,而我们的声明式解决方案的长度为16行,缩进幅度很小。行数和制表符并不代表一切,但从这两个文件中可以看出,我们的声明式解决方案更具可读性。
  • 更不容易出错:在这个例子中,所有东西都是不可改变的。我们将所有名字的List<String> 转化为具有三个或更多元音的名字的List<String> ,然后将每个String 字词转化为具有大写元音的String 。总的来说,没有突变、嵌套循环或中断,放弃控制流,使代码更简单,错误空间更小。
  • 良好的可维护性:由于声明式代码的可读性和健壮性,你可以很容易地重构它。在我们之前的例子中(假设问题修改为选择有三个元音和五个辅音的名字),一个简单的解决方案是在filter 条件中添加以下语句:val vowels = name.count(::isVowel); vowels >= 3 && name.length - vowels >= 5

作为一个额外的积极因素,我们的声明式解决方案是纯粹的函数式。这个例子中的每个函数都是纯粹的,没有副作用。(稍后会有更多关于纯洁性的内容)。

额外的声明式解决方案

让我们看一下同一问题在Haskell这样的纯函数式语言中的声明式实现,以证明它是如何读的。如果你不熟悉Haskell,请注意在Haskell中,. 操作符读作 "后"。例如,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和反应式编程都很有用(我们将在后面的章节中介绍):

  • 它描述了你想要实现的 "什么",而不是 "如何 "实现它,以及语句的确切执行顺序。
  • 它将程序的控制流抽象化,而将重点放在转换的问题上(即,ArightarrowBrightarrowCrightarrowDA\\rightarrow B\\rightarrow C\\rightarrow D)。
  • 它鼓励不那么复杂、更简明、更可读的代码,更容易重构和改变。如果你的Android代码读起来不像是一个句子,你可能做错了什么。

不过,声明式编程也有一定的缺点。有可能最终会出现低效的代码,消耗更多的内存,而且性能比命令式实现更差。排序、反向传播(在机器学习中)和其他 "变异算法 "并不适合不可改变的声明式编程风格。

FP成分#2:函数组合

函数组合是函数式编程的核心数学概念。如果函数ff接受AA作为其输入并产生BB作为其输出(f:ArightarrowBf: A\\rightarrow B),函数gg接受BB并产生CCg:BrightarrowCg: B\\rightarrow C),那么你可以创建第三个函数hh,它接受AA并产生CCh:ArightarrowCh: A\\rightarrow C)。我们可以把这第三个函数定义为ggff组合,也可以记为gcircfg\\circ fg(f())g(f())

函数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

一个使用我们的名字问题的函数组合的例子。

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

主要启示

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

  • 它提供了一种解决复杂问题的策略,即把问题分割成更小、更简单的步骤,然后合并成一个解决方案。
  • 它提供了构件,允许你轻松地添加、删除或改变最终解决方案的部分,而不必担心破坏某些东西。
  • 如果ff的输出与gg的输入类型相匹配,你可以合成g(f())g(f())

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

FP成分之三:纯洁性

对于函数组合,还有一个关键因素是我们必须解决的。你所组合的函数必须是纯粹的,这是另一个源自数学的概念。在数学中,所有的函数都是计算,在调用相同的输入时总是产生相同的输出;这就是纯粹性的基础。

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

让我们来探讨一下几类不纯粹的函数,但会帮助我们更具体地定义纯粹性:

  • 局部函数:这些是没有为所有输入值定义的函数,例如除法。从编程的角度来看,这些是抛出异常的函数:fun divide(a: Int, b: Int): Float ,对于除以0引起的输入b = 0 ,会抛出一个ArithmeticException
  • 总函数:这些函数是为所有的输入值定义的,但在调用相同的输入时,可以产生不同的输出或副作用。安卓世界中充满了总函数。Log.d,LocalDateTime.now, 和Locale.getDefault 只是几个例子。

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

提示:为了使总函数变得纯粹,你可以通过将其作为高阶函数参数来抽象出其副作用。这样,你可以通过传递一个模拟的高阶函数来轻松地测试总函数。这个例子使用了@SideEffect 注解,这个注解来自于我们在本教程后面要研究的一个库,Ivy FRP:

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

主要收获

纯度是函数式编程范式所需的最后成分:

  • 小心使用部分函数--它们会使你的应用程序崩溃。
  • 组成总函数不是确定性的;它可能产生不可预测的行为。
  • 只要有可能,就写纯函数。你会从提高代码的稳定性中受益。

随着我们对函数式编程的概述的完成,让我们来看看面向未来的Android代码的下一个组成部分:反应式编程

反应式编程101

反应式编程是一种声明式的编程模式,程序对数据或事件的变化做出反应,而不是请求关于变化的信息。

一般的反应式编程循环。

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

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

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

连接点:函数式反应式编程

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

此外,FP和反应式编程的力量在结合起来时也会成倍增长。现在我们对函数式编程和反应式编程有了明确的定义,我们可以把这些碎片放在一起。在本教程的第二部分,我们定义了函数式反应式编程(FRP)范式,并通过一个示例应用的实现和相关的Android库将其付诸实践。

了解基础知识

如何解释函数式编程?

函数式编程的核心是函数组合,在这个过程中,数据从A到B,再到C,转化为所需的输出。它还有另外两个关键因素。它是声明性的,而且它的函数是纯粹的。

反应式编程是什么意思?

反应式编程是一种植根于两个概念的编程模式:声明式编程和反应性。

反应式编程能解决什么问题?

反应式程序直接响应数据或事件的变化,而不是请求关于变化的信息。