作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
Iliyan Germanov的头像

Iliyan Germanov

伊利扬是一名安卓开发者和首席技术官,他创立了四家初创公司,开发了几款顶级应用, 包括Ivy Wallet, 它获得了10个YouTube技术社区“最佳UI/UX”奖. 他擅长函数式编程、UX、芬兰湾的科特林和Haskell.

以前的角色

首席技术官

工作经验

8

以前在

Reddit
分享

编写干净的代码可能具有挑战性:库, 框架, 和api都是临时的,很快就会过时. But mathematical concepts 和 paradigms are lasting; they require years of academic research 和 may even outlast us.

这不是向您展示如何使用库Y执行X的教程. 而不是, 我们专注于函数式和响应式编程背后的持久原则,因此您可以构建面向未来且可靠的程序 安卓系统架构,在不影响效率的情况下扩大规模并适应变化.

这篇文章奠定了基础, 在第2部分, 我们将深入研究函数式响应式编程(FRP)的实现。, 它结合了函数式编程和响应式编程.

这篇文章是由 安卓开发者 记住, 但是,这些概念对任何具有通用编程语言经验的开发人员都是相关且有益的.

函数式编程101

函数式编程 (FP)是一种将程序构建为函数组合的模式, 将数据从A$转换为B$, 美元加元, 等.,直到达到期望的输出. 在面向对象编程(OOP)中,你一条指令一条指令地告诉计算机该做什么. 函数式编程则不同:放弃控制流,定义“函数配方”来生成结果.

左边有一个绿色矩形,上面写着“Input: x”,它有一个箭头指向一个浅灰色矩形,上面写着“Function: f”.“在浅灰色的长方形里面, 有三个箭头指向右的圆柱体:第一个是浅蓝色的,标记为“A(x)”。,第二个是深蓝色,标为“B(x)”。,,第三个是深灰色的,标有“C(x)”。.在浅灰色矩形的右侧,有一个绿色矩形,文本为“输出:f(x)”。.浅灰色矩形的底部有一个箭头指向文本“副作用。."
函数式编程模式

具体来说,FP源自数学 微积分,一个功能抽象的逻辑系统. 而不是像循环这样的OOP概念, 类, 多态性, 或继承, FP严格处理抽象和高阶函数, 接受其他函数作为输入的数学函数.

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

我们将更仔细地研究计划生育的三个关键方面:

  • FP是声明式的.
  • FP使用函数组合.
  • FP函数是纯函数.

进一步深入FP世界的一个很好的起点是 Haskell一种强类型的纯函数式语言. 我推荐 学习Haskell为伟大的好! 交互式教程是一种有益的资源.

FP要素#1:声明式编程

关于FP程序,您会注意到的第一件事是它是用 声明,而不是命令式. 简而言之,声明性编程告诉程序需要做什么,而不是如何做. 让我们以一个命令式编程与声明式编程的具体示例作为这个抽象定义的基础,以解决以下问题, 返回一个列表,其中仅包含至少有三个元音且元音字母为大写字母的名称.

必要的解决方案

首先,让我们检查一下这个问题的命令式解决方案 芬兰湾的科特林:

fun 名字。sImperative(input: List<字符串>): List<字符串> {
    val 结果 = mutableListOf<字符串>()
    val元音=自然(“A”,“E”,‘我’,‘O’,‘你’,‘‘,‘E’,‘我’,‘O’,‘U’)

    For (名字。 in input){//循环1
        var vowelsCount = 0

        For (char in 名字。){//循环2
            if (isVowel(char, vowel)) {
                vowelsCount + +

                if (vowelsCount == 3) {
                    val uppercaseName = 字符串Builder()

                    for (final字符 in 名字。){//循环
                        var transformmedchar = final字符
                        
                        //忽略第一个字母可能是大写
                        if (isVowel(final字符,元音)){
                            transformmedchar = final字符.uppercase字符 ()
                        }
                        uppercaseName.追加(transformed字符)
                    }

                    结果.add (uppercaseName.to字符串 ())
                    打破
                }
            }
        }
    }

    返回结果
}

fun isVowel(char: 字符, vowels: List<字符>): 保龄球。ean {
    返回元音.包含(char)
}

Fun main() {
    println(名字。sImperative(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)$ time.
  • 复杂的,可读性差的; 与我们接下来要考虑的声明式解决方案相比, 这个解决方案要长得多,也更难遵循.
  • 出错: 代码改变了 结果, vowelsCount, transformed字符; these state mutations can lead to subtle errors like forgetting to reset vowelsCount 回到0. 执行流程也可能变得复杂,而且很容易忘记添加 打破 语句.
  • 可怜的可维护性: 因为我们的代码很复杂而且容易出错, 重构或更改此代码的行为可能很困难. 例如, 如果这个问题被修改为选择有三个元音和五个辅音的名字, 我们必须引入新的变量,改变循环, 给bug留下了很多机会.

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

声明式解决方案

现在我们明白了 声明性编程 不是,让我们在芬兰湾的科特林中展示我们的声明式解决方案:

fun 名字。sDeclarative(input: List<字符串>): List<字符串> = input.过滤器 { 名字。 ->
    名字。.count(::isVowel) >= 3
}.map { 名字。 ->
    名字。.map { char ->
        if (isVowel(char)) char.uppercase字符 () else char
    }.joinTo字符串 (" ")
}

fun isVowel(char: char): 保龄球。ean =
    自然(“A”、“E”,‘我’,‘O’,‘你’,‘“,‘E’,‘我’,‘O’,‘U’).包含(char)

Fun main() {
    println(名字。sDeclarative(listOf(“Iliyan”,“Annabel”,“Nicole”,“John”,“Anthony”,“Ben”,“Ken”))))))
    // [IlIyAn, AnnAbEl, NIcOlE]
}

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

  • 高效: 命令式和声明式实现都在线性时间内运行, 但命令式更有效率一点,因为我用过 名字。.count () 在这里,它将继续计数元音直到名字的结尾(即使找到三个元音)。. 我们可以通过编写一个简单的 hasThreeVowels (字符串):布尔 函数. 此解决方案使用与命令式解决方案相同的算法, 所以同样的复杂性分析也适用于这里:我们的算法运行 $O(n)$ time.
  • 简洁,可读性好; 命令式解决方案有44行,有较大的缩进,而声明式解决方案只有16行,有较小的缩进. 行和制表符不是一切, 但是,从这两个文件的一瞥中可以明显看出,我们的声明式解决方案更具可读性.
  • 更少出错: 在这个示例中,一切都是不可变的. 我们变换a List<字符串> 在所有的名字中 List<字符串> 包含三个或更多元音的名字,然后对每个元音进行转换 字符串 逐字逐句 字符串 有大写元音的单词. 整体, 没有突变, 嵌套循环, 或中断和放弃控制流使代码更简单,出错的空间更小.
  • 很好的可维护性: 由于声明性代码的可读性和健壮性,可以很容易地重构它. 在我们前面的例子中(我们说这个问题 修改为选择包含三个元音和五个辅音的名字), 方法中添加以下语句是一个简单的解决方案 过滤器 条件: Val元音= 名字。.count(::isVowel); vowels >= 3 && 名字。.length - vowels >= 5.

作为一个额外的积极因素, 我们的声明式解决方案是纯函数式的:本例中的每个函数都是纯函数,没有副作用. (稍后会详细介绍纯度.)

附加声明式解决方案

让我们看一下在纯函数式语言(如Haskell)中对相同问题的声明式实现,以演示它是如何读取的. 如果您不熟悉Haskell,请注意 . 操作符在Haskell中读作“after”.“例如, solution = 地图uppercaseVowels . 过滤器hasThreeVowels 翻译为“在过滤包含三个元音的名称后将元音映射为大写字母”.”

导入数据.字符 (toUpper)

名字。sSolution :: [字符串] -> [字符串]
名字。sSolution = map uppercasevwell . 过滤器hasThreeVowels

hasThreeVowels :: 字符串 -> 保龄球。
hasThreeVowels s = count isVowel s >= 3

uppercaseVowels :: 字符串 -> 字符串
uppercaseVowel = map uppercaseVowel
 在哪里
   uppercaseVowel :: 字符 -> 字符
   uppercaseVowel c
     | isVowel c = toUpper c
     |否则= c

isVowel :: 字符 -> 保龄球。
isVowel c = c ' elem '元音

元音::[字符]
元音= [' A ',‘E’,‘我’,‘O’,‘你’,‘‘,‘E’,‘我’,‘O’,‘U’)

count :: (a -> 保龄球。) -> [a] -> Int
Count _ [] = 0
Count pred (x:xs)
  | pred x = 1 + count pred x
  |否则= count pred xs

main:: IO ()
main = print $ 名字。sSolution ["Iliyan", "Annabel", "Nicole", "John", "Anthony", "Ben", "Ken"]

——(“IlIyAn”,”安娜贝利”,“妮可”)

此解决方案的执行类似于我们的 芬兰湾的科特林 声明式解决方案, 还有一些额外的好处:它是可读的, 如果你理解Haskell的语法,就会觉得很简单, 纯粹的功能, 懒惰的.

关键的外卖

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

  • 它描述的是你想要实现“什么”,而不是“如何”实现它, 与语句的执行顺序一致.
  • 它将程序的控制流抽象出来,转而关注转换方面的问题.e., $A \右箭头B \右箭头C \右箭头D$).
  • 它鼓励不那么复杂, 更简洁, 代码可读性更强,更容易重构和修改. 如果你的 安卓代码 读起来不像句子,你可能做错了什么.

如果你的安卓代码读起来不像一个句子,你可能做错了什么.

不过,声明式编程也有一些缺点. 最终可能会产生效率低下的代码,消耗更多的RAM,并且比命令式实现的性能更差. 排序, 反向传播(在机器学习中), 而其他的“变异算法”并不适合不可变, 声明式编程风格.

FP要素2:功能组合

函数组合是函数式编程的核心数学概念. 如果函数$f$接受$A$作为其输入并产生$B$作为其输出($f: A \右转B$), 函数$g$接受$B$并产生$C$ ($g: B \右转C$), 然后可以创建第三个函数, $h$, 它接受$A$并产生$C$ ($h: A \右转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 :: 字符 -> 保龄球。给定一个 字符,返回是否为元音(保龄球。).
  2. countVowels :: 字符串 -> Int给定一个 字符串,返回其中的元音数目(Int).
  3. hasThreeVowels :: 字符串 -> 保龄球。给定一个 字符串,返回是否至少有三个元音(保龄球。).
  4. uppercaseVowels :: 字符串 -> 字符串给定一个 字符串,返回一个新的 字符串 用大写元音.

通过函数组合实现的声明性解决方案是 地图uppercaseVowels . 过滤器hasThreeVowels.

顶部的图表有三个蓝色的“[字符串]”框,由指向右侧的箭头连接. 第一个箭头标记为“过滤器 has3Vowels”,第二个箭头标记为“地图uppercaseVowels”.“低于, 第二个图的左边有两个蓝框, "字符"在上面, 和下面的“字符串”, 指着右边的蓝盒子, “保龄球。.从“字符”到“保龄球。”的箭头标记为“isVowel”,,从“字符串”到“保龄球。”的箭头标记为“has3Vowels”.“字符串”框也有一个箭头指向它自己,标记为“uppercaseVowels”."
一个使用我们的名字问题的函数组合的例子.

这个例子比简单的$ a \右箭头B \右箭头C$公式要复杂一些, 但是它展示了函数组合背后的原理.

关键的外卖

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

  • 它提供了一种解决复杂问题的策略,其中问题被分解成更小的问题, 更简单的步骤和组合成一个解决方案.
  • 它提供了构建模块, 允许您轻松添加, 删除, 或者改变最终解决方案的一部分,而不用担心破坏什么.
  • 如果$f$的输出与$g$的输入类型匹配,则可以组合$g(f())$.

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

FP成分#3:纯度

对于函数组合,还有一个我们必须解决的关键因素:所组合的函数必须是 是另一个源自数学的概念. 在数学, all 函数s are computations that always yield the same output 当使用相同的输入调用时; this is the basis of purity.

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

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

  • 部分功能: 这些函数不是为所有输入值定义的,比如除法. 从编程的角度来看,这些函数会抛出异常: fun divide(a: Int, b: Int): Float 我会扔一个 ArithmeticException 对于输入 b = 0 由除以0得到.
  • 总功能: 这些函数是为所有输入值定义的,但可以产生不同的输出或 副作用 当使用相同的输入调用时. 安卓世界充满了各种功能: 日志.d, LocalDateTime.现在, 语言环境.getDefault 这只是几个例子吗.

记住这些定义,我们就可以定义 纯函数 作为整体功能,没有副作用. 仅使用纯函数构建的函数组合更可靠, 可预测的, 以及可测试的代码.

提示: 使总函数纯净, 您可以通过将其作为高阶函数参数传递来抽象其副作用. 通过这种方式,您可以通过传递模拟的高阶函数轻松地测试整个函数. 此示例使用 @SideEffect 注释来自我们稍后在教程中检查的库,Ivy FRP:

暂停游戏截止日期(
最后期限:LocalDate, 
    @SideEffect
    currentDate: suspend () -> LocalDate
): 保龄球。ean = deadline.isAfter (currentDate ())

关键的外卖

纯粹性是函数式编程范式所需的最后一个要素.

  • 小心使用部分函数——它们可能会使你的应用崩溃.
  • Composing total 函数s is not deterministic; it can produce un可预测的 behavior.
  • 只要有可能,就写纯函数. 您将受益于代码稳定性的提高.

完成了函数式编程的概述, 让我们检查下一个面向未来的组件 安卓代码: 反应性编程.

响应式编程101

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

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

响应式编程周期的基本元素是事件, 声明性管道, 州, 可见:

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

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

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

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

此外,FP和响应式编程的能力在结合使用时会成倍增加. 现在我们已经对函数式编程和响应式编程有了清晰的定义, 我们可以把碎片拼起来. In 第2部分 本教程的, 我们定义了功能反应性编程(FRP)范式, 并通过示例应用程序实现和相关安卓库将其付诸实践.

Toptal 工程博客向 塔伦Goyal 查看本文中提供的代码示例.

了解基本知识

  • 你如何解释函数式编程?

    函数式编程的核心是函数组合, 其中数据从A转换为B, C, 到期望的输出. 它还有另外两个关键元素:它是声明性的,它的函数是纯的.

  • 响应式编程是什么意思?

    响应式编程是一种基于两个概念的编程模式:声明式编程和响应性.

  • 响应式编程解决了什么问题?

    响应式程序直接响应数据或事件更改,而不是请求有关更改的信息.

聘请Toptal这方面的专家.
现在雇佣
Iliyan Germanov的头像
Iliyan Germanov

位于 保加利亚索非亚

成员自 2022年3月30日

作者简介

伊利扬是一名安卓开发者和首席技术官,他创立了四家初创公司,开发了几款顶级应用, 包括Ivy Wallet, 它获得了10个YouTube技术社区“最佳UI/UX”奖. 他擅长函数式编程、UX、芬兰湾的科特林和Haskell.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

以前的角色

首席技术官

工作经验

8

以前在

Reddit

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® 社区.