Read Buf

Read Buf

如何在 Kotlin 中利用函数式编程编写更优雅的代码

developer

随着 DoorDash 从 Python 单体架构转向 Kotlin 微服务,我们的工程团队迎来了很多提升操作效率和可靠性的机会。虽然优化硬件和系统设计可以提高工程指标,但作为开发人员,每天写出更好的代码是最直接的贡献。

编写更清晰代码的一种方法是使用 Kotlin 进行 函数式编程 (FP)。Kotlin 是一种多范式的通用编程语言,提供了许多适用于 FP 的工具包。

在这篇文章中,我们将讨论什么是函数式编程,函数式编程的优点和潜在缺点是什么,如何与另一类编程范式——命令式编程 (IP) 进行比较,Kotlin 为开发人员提供了哪些工具以利用 FP,以及我们在 DoorDash 如何用 Kotlin 编写 FP 风格代码的 示例

什么是函数式编程 (FP) ?

简而言之,FP 是一种编程范式,通过应用和组合函数来构建程序。FP 中的典型程序是这样的:给定一些输入,对输入应用一系列函数(可以是大的或小的)以获得所需的输出。这看似简单,但背后有很多规则和约束,规定了函数如何构建以及它们能影响的范围。这些规则和约束使 FP 成为一种值得考虑的编程范式。

在所有的 FP 概念中,以下三个对我们日常编程的帮助最大(我们将在后文中详细讨论这些概念如何帮助我们编写更好的代码):

  • 纯函数。根据 定义,纯函数在相同的输入下总是返回相同的结果,并且没有副作用(比如更新其他局部变量或进行 I/O 操作)。例如,所有的数学函数,如求和、求最大值和求平均值,都是纯函数。
  • 不可变状态。与可以重新赋值的变量或在运行时插入或移除值的数组等可变状态不同,不可变状态在被创建或赋值后不可修改。
  • 函数组合。如其名称所示,函数组合指的是将简单的函数组合成更复杂的函数。在实践中,一个函数的输出成为另一个函数的输入,依此类推。

如果以前没有听说过这些概念,那是正常的。事实上,这也是 FP 不如其他范式那么普及的原因之一。与大多数开发者熟悉的命令式编程 (IP) 不同,FP 在大多数计算机科学课程中并未得到广泛覆盖。虽然许多数学课程会讲解与 FP 相关的核心概念,如纯函数和组合,但很少将这些概念与编程联系起来。

FP 与 IP 的对比

虽然 FP 和 IP 之间有许多差异,但我们将扩展 Microsoft 在 .NET 环境中比较 FP 和 IP 的解释 并强调以下三个方面:

  • 程序员的关注点。IP 需要程序员考虑如何执行算法并跟踪内部状态变化以达到预期结果。而在 FP 中,程序员主要关注三点:

    • 输入是什么
    • 预期的输出是什么
    • 如何将输入转化为输出
  • 状态变化。FP 基本没有状态变化,因为不可变状态是其核心。而在 IP 中,状态变化无处不在且至关重要,因为程序的执行流程依赖这些状态变化来追踪进度和下一步操作。

  • 主要的流程控制。在 FP 中,函数被用于对数据集合(如数组和映射)进行所需转换。函数是 一等公民,可以被赋值、传递和返回。而在 IP 中,流程控制主要依赖循环、条件语句和函数调用(可以是纯的或非纯的)来操作内部状态并实现最终目标。

图 1: FP 和 IP 之间程序员关注点、状态变化和主要流程控制的差异

很明显,FP 和 IP 之间的方法论不同,程序员在编码时的思维方式也有所不同。然而,FP 和 IP 并不是互斥的。实际上,许多编程语言,如 Kotlin,采用了多范式,这意味着程序员可以在同一段代码中使用多种范式。例如,由于 Kotlin 设计为完全与 Java 互操作,而 Java 主要是一种面向对象的语言,因此可以预期 Kotlin 代码中会有许多 OOP 示例。这并不妨碍程序员在 Java 对象上应用 FP 风格的函数,我们将在下文中展示更多示例。

编写 FP 风格代码的好处

在了解了 FP 和更为熟知的 IP 之间的差异之后,让我们看看 FP 带来的好处。简而言之,有三个主要优势:

  • 无副作用的执行
  • 轻松迭代现有函数
  • 增强的可测试性

无副作用的执行

如前所述,纯函数保证除了产生预期输出外,没有任何副作用。纯函数不会修改其输入的状态,也不会修改任何系统全局参数的状态。在像 DoorDash 这样高度复杂的系统中,这一特性非常宝贵。作为开发者,能够预期一个函数确实只执行其承诺的操作,并且不会产生其他副作用,这非常有利。当不同部门的多个团队开发人员在同一代码库上工作时,理解代码逻辑变得更加直观,因为可以轻松阅读应用在输入上的函数序列,并弄清楚每个函数的作用,而无需逐一探查所有函数。

轻松迭代现有函数

由于以 FP 风格编写的所有函数都是无副作用的,因此在现有函数和逻辑上进行迭代变得更加容易。例如,假设已有一些函数在计算一次配送的基本薪资。如果我们想增加一个新功能,即在高峰时段完成的配送基本薪资增加 50%。这将非常容易实现:我们只需在原有计算末尾添加一个新函数,该函数在高峰时段将薪资乘以 1.5。作为开发者,不需要考虑输入来自哪里或如何计算,只需知道此纯函数的任务是计算一个新值即可。

增强的可测试性

当一个函数是纯函数时,给定相同的输入,函数的输出是确定的。这使得测试函数变得更加容易,因为测试可以设置为一组输入及其预期输出,并且保证这些输入的测试结果始终一致。例如,假设我们有一个函数,它接受一个整数数组并返回数组中的第二大数字。这一操作是纯粹的,因为:

  • 该函数不依赖于除输入之外的任何其他东西
  • 它不会修改输入数组或系统中的任何其他东西
  • 给定相同的数组,第二大的数字始终是相同的。

因此,这个函数的单元测试将非常直观,因为不需要模拟任何系统变量或函数调用,并且输出是确定的,不会有不稳定的测试结果。因此,如果我们都能编写 FP 风格的程序,编写测试将变得更加容易,特别是对于关键任务的应用程序。

FP 的潜在缺点

如果 FP 只带来好处而没有任何潜在缺点,那将是太美好了。根据编程语言和编译器的不同,一个缺点是每次函数调用可能会创建一个新的调用栈。在没有优化的情况下,这些调用栈的创建和销毁可能会迅速增加应用程序的运行时开销,即使是执行简单的操作也是如此。幸运的是,这一缺点并不严重,因为 Kotlin 提供了让函数 内联 的能力,正确使用可以解决许多问题。简而言之,内联函数不是创建一个新的调用栈并在函数内部执行代码,而是用实际内容替换函数调用,并将其放置在调用者函数的主体中。

FP 的另一个潜在缺点是速度和内存使用。由于每个函数本质上是从现有数据中创建新数据,这些数据的创建可能需要额外的时间和空间来在内存中实例化。在 IP 中,我们主要处理可以就地更新的可变数据结构,无需分配新内存。运行时速度的问题可以通过 并行 来缓解。自然地,FP 中的大多数纯函数是高度可并行化的,这意味着我们可以运行大量的函数,而不必担心它们之间的交互或它们如何影响系统变量。有效的并行运行函数策略可以为程序带来正向的速度提升。

现代应用程序中最常见的操作之一是输入/输出 (I/O)。当涉及 I/O 时,这意味着应用程序正在处理外部世界。I/O 的例子包括提示用户输入、调用远程过程调用 (RPC) 到另一个服务以及从数据库读取数据。由于 I/O 任务的不可预测性,它们很可能不是纯函数,这意味着输入和输出不确定。当我们处理 I/O 任务时,强行用纯函数来处理 I/O 不是正确的方法。实际上,鉴于许多现代编程语言如 Kotlin 的多范式性质,开发人员应该根据任务选择最适合的范式,而不是严格遵循单一范式。在 Kotlin 中,开发人员可以使用 Kotlin 的标准 I/O 库 以及 Java 的标准 I/O 库

Kotlin 为开发人员提供了哪些 FP 工具?

在我们进入如何在 Kotlin 中编写 FP 代码的实际操作之前,自然会问,Kotlin 是 FP 的合适语言吗?答案是肯定的!实际上,Kotlin 语言官方网站的顶级常见问题之一 说明“Kotlin 既有面向对象的构造,也有函数式的构造。Kotlin 可以使用面向对象 (OO) 和函数式 (FP) 风格,或者混合两者的元素。”那么 Kotlin 拥有哪些功能和工具,使得开发人员能够编写 FP 风格的代码呢?

高阶函数和 Lambda 表达式

Kotlin 文档中的 专门章节 讨论了这个主题,所以我们不会详细介绍。总之,由于 Kotlin 函数是一等公民,它们可以存储在变量中、作为参数和返回值传递,并且可以定义围绕函数的类型。凭借这一能力,常见的 FP 函数如 折叠 操作可以轻松在 Kotlin 中编写,因为我们可以将任何累积函数传递给折叠函数来组合数据。

除了支持高阶函数,Lambda 表达式也是简化代码的方法,无需编写所有通常在代码中造成混乱的函数声明。简而言之,Lambda 表达式 是立即作为表达式传递的未声明函数。这使得推理和理解代码变得更加容易,无需多次跳转来找出函数的实际功能。

举一个快速的例子,考虑以下代码片段:

deliveries.sumOf { delivery -> delivery.customerTip }

在这个片段中,sumOf 是一个高阶函数,因为它接受另一个函数作为参数,而 { delivery -> delivery.customerTip } 是一个 Lambda 表达式,它接受一个 delivery 对象并返回顾客小费金额。我们将在后文展示更多实际的 FP 风格代码示例。

基于集合的操作

Kotlin 提供了一套强大的基于集合的操作,用于促进 FP 风格的计算。根据 Kotlin 文档,给定一个项目列表,常见操作 分为以下几组:

  • 变换:变换数据集合中的所有项目
  • 过滤:根据某些条件返回项目的子集
  • 分组:根据某些标准将项目分组
  • 检索集合部分:以某种方式返回项目的子集
  • 检索单个元素:根据某些条件返回单个项目
  • 排序:根据每个项目的某些标准对数据集合排序
  • 聚合:对所有项目应用某些操作后返回单个值

所有标准库中的集合函数都在 Kotlin 集合 API 文档 中。在下文中,我们将看到 DoorDash 的开发人员如何利用这些常见操作进行日常编码。

比较 Kotlin 与 Python、JavaScript 和 C++ 等语言

虽然 Kotlin 为开发人员提供了强大的工具集来编写 FP 代码,但这些工具和函数并不局限于 Kotlin。实际上,许多现代语言支持 FP 风格开发,并提供类似的基于集合的操作,尤其是在这些语言的较新版本中。以下表格概述了 Kotlin 与这些流行编程语言在我们讨论的一些功能方面的比较。

Kotlin Python Javascript/Typescript C++
高阶函数 是(在 C++11 中引入)
Lambda 表达式 是(在 C++11 中引入)
函数类型 部分(动态类型) JS 中无,TS 中有
变换 是(无 map 函数,但有 transform 函数)
分组 否(非内置,需要导入其他包)
聚合

虽然 Kotlin 原生支持所有功能,其他现代语言如 TypeScript(DoorDash 网页客户端的主要语言)也有内置库支持。因此,Kotlin 中的 FP 和常见操作知识可以轻松应用于其他现代语言中的日常编码。

我们在 DoorDash 如何用 Kotlin 编写 FP 风格代码的示例

现在我们理解了什么是 FP、它的优点和缺点以及 Kotlin 提供的 FP 工具,是时候来看一些 FP 的实际应用了。在所有示例中,我们将以以下数据类作为背景。请注意,所有示例皆为假设,仅用于说明。

data class Delivery(
    val id: UUID,
    val dasherId: UUID,
    val basePay: Double,
    val customerTip: Double,
    val dropOffTime: Calendar
)

data class Dasher(
    val id: UUID,
    val name: String
)

我们从一个简单但常见的例子开始:给定一个配送列表,返回总薪酬大于 10 美元的列表。

首先看如何用 IP 风格编写:

val totalPayAmounts = mutableListOf<Double>()
for (delivery: Delivery in deliveries) {
    val totalPay = delivery.basePay + delivery.customerTip
    if (totalPay > 10) {
        totalPayAmounts.add(totalPay)
    }
}
return totalPayAmounts

这段代码的思维过程如下:

  1. 创建一个空容器保存输出
  2. 遍历配送列表中的每个项目
  3. 计算总薪酬
  4. 如果总薪酬大于 10 美元,则将其添加到输出容器中
  5. 返回输出容器

现在我们看看如何用 FP 风格编写相同的逻辑:

return deliveries
    .map { delivery -> delivery.basePay + delivery.customerTip }
    .filter { totalPay -> totalPay > 10 }

这段代码的思维过程如下:

  1. 将配送列表中的每个项目转换为总薪酬
  2. 过滤并保留总薪酬大于 10 美元的项目

从这个例子中不难看出,FP 和 IP 之间思维方式的巨大差异。在迭代风格中,逻辑从上到下流动,使用了一个可变状态 (totalPayAmounts) 和一个 for 循环来计算最终结果。相反,FP 关注的是如何通过转换和过滤数据来处理输入。在 FP 风格的代码片段中,没有引入额外状态,也没有使用循环。相反,它使用了 Kotlin 内置的 mapfilter 函数,结合两个 Lambda 表达式来计算最终结果列表。整体上,使逻辑更加直观,并减少了程序中的额外状态。

让我们看看另一个更为复杂的示例。假设我们有一个配送列表,并且我们想保留顾客小费超过 5 美元的配送,按配送的交付时间找到最新的 10 个配送,并获取这些配送的 Dasher ID。同样,我们将首先看看如何用 IP 风格编写。

val filteredDeliveries = mutableListOf<Delivery>()
for (delivery: Delivery in deliveries) {
    if (delivery.customerTip > 5) {
        filteredDeliveries.add(delivery)
    }
}

// 按 delivery.dropOffTime 降序排序
val sortedDeliveries = Collections.sort(
    filteredDeliveries,
    dropOffTimeComparator
)

val result = mutableListOf<UUID>()
for (i in sortedDeliveries.indices) {
    result.add(sortedDeliveries[i].dasherId)
    if (i == 9) break
}

对于相同的逻辑,FP 风格的代码如下:

val result = deliveries
    .filter { it.customerTip > 5 }
    .sortedByDescending { it.dropOffTime }
    .map { it.dasherId }
    .take(10)

在这个示例中,我们使用了 Kotlin 特殊标识符 it,它在 Lambda 表达式内部用于隐式地引用其参数。在上面的 Lambda 表达式中,所有 it 都代表列表中的 delivery 对象。

无需赘言,FP 风格的代码相比 IP 代码显得更加简洁和优雅。这段代码基本上就是在阅读自然语言:

  1. 过滤配送,仅保留顾客小费超过 5 美元的配送
  2. 按配送的交付时间降序排序
  3. 将配送转换为其 Dasher ID
  4. 取前 10 个配送

虽然这个示例足够简单,但不难看出在处理更复杂的逻辑时,FP 风格的代码同样具备灵活性。假设多个团队想基于不同的逻辑过滤配送列表,例如 complexFilterFunc1, complexFilterFunc2 等等,他们可以简单地通过调用这些函数来实现过滤,因为 filter 是一个高阶函数,可以接受其他函数作为参数。

val result = deliveries
    .filter { complexFilterFunc1(it) }
    .filter { complexFilterFunc2(it) }
    .filter { ... }
    ...

更好的是,因为这些过滤函数是纯函数,可以以任意顺序调用而不会影响逻辑。

val result = deliveries
    .filter { complexFilterFunc3(it) }
    .filter { complexFilterFunc1(it) }
    .filter { ... }
    ...

如果传递 it 给所有过滤函数显得多余,Kotlin 支持使用双冒号 :: 将函数引用传给高阶函数。

val result = deliveries
    .filter(::complexFilterFunc1)
    .filter(::complexFilterFunc2)
    .filter(::...)
    ...

到目前为止,我们应该已经熟悉了如何在项目列表中编写 FP 风格的代码并将其转换为另一个列表。那么,如果我们想将列表转换为其他数据结构,如 map 呢?这不仅是可能的,而且在我们的日常编码中也非常常见。让我们看看一个例子。

假设我们有一个配送列表。我们现在想看看每位 Dasher 在一天中的每个小时赚了多少小费。最终结果将结构化为一个从 Dasher ID 到另一个 map 的映射,其中键是一天中的小时,值是他们赚取的小费总额。我们首先看看如何用 IP 风格实现。

val dasherIdToDeliveries = mutableMapOf<UUID, MutableList<Delivery>>()
for (delivery: Delivery in deliveries) {
    if (dasherIdToDeliveries.containsKey(delivery.dasherId)) {
        dasherIdToDeliveries[delivery.dasherId]!!.add(delivery)
    } else {
        dasherIdToDeliveries[delivery.dasherId] = mutableListOf(delivery)
    }
}

val resultMap = mutableMapOf<UUID, MutableMap<Int, Double>>()
for ((dasherId, deliveriesByDasher) in dasherIdToDeliveries) {
    val hourToTotalTipMap = mutableMapOf<Int, Double>()
    for (delivery in deliveriesByDasher) {
        val hour = delivery.dropOffTime.get(Calendar.HOUR_OF_DAY)
        if (hourToTotalTipMap.containsKey(hour)) {
            hourToTotalTipMap[hour] = hourToTotalTipMap[hour]!! + delivery.customerTip
        } else {
            hourToTotalTipMap[hour] = delivery.customerTip
        }
    }
    resultMap[dasherId] = hourToTotalTipMap
}
return resultMap

这段代码显然不简洁。它使用了双重 for 循环、两个可变 map 和两个 if-else 块来实现最终结果。现在我们看看如何用 FP 风格编写这段代码。

val result = deliveries
    .groupBy { it.dasherId }
    .mapValues { it.value
        .groupBy { delivery -> 
            delivery.dropOffTime.get(Calendar.HOUR_OF_DAY) 
        }
        .mapValues { hourToDeliveries -> 
            hourToDeliveries.value.sumOf { delivery -> 
                delivery.customerTip
            }
        }
    }

这里使用了几个新函数,所以我们先解释这些函数的作用,再逐一解析代码:

  • groupBy:给定一个项目列表,返回一个 map,其键由键选择器(这里是 Lambda 表达式)返回,值为具有相应键的项目列表
  • mapValues:给定一个 map,返回一个新 map,其条目具有原始 map 的键,值由转换函数生成
  • sumOf:给定一个项目列表,通过键选择器求和

了解这些定义后,FP 风格代码的逻辑如下:

  1. 按 Dasher ID 对配送列表进行分组
  2. 对每组配送按交货小时进行分组
  3. 对每个小时的小组按顾客小费求和

此示例展示了在 Kotlin 中使用 FP 进行数据集合分组和聚合的能力。常见的是,在中间集合中嵌套基于集合的函数并将其转换为所需的任何新数据类型。这种能力非常强大,开发者不受限于将数据转换为与输入相同的类型。

结 论

函数式编程是一种强大的编程范式,可以帮助开发人员轻松编写更清晰、更好的代码,以满足日常编程需求。特别是在关键任务操作、大型分布式系统和密集的数据转换上,函数式编程尤为适用。通过与其他常见范式(如面向对象编程)的结合,特别是利用 Kotlin 语言提供的丰富生态系统,可以实现两者的最佳效果。虽然 FP 具有潜在的缺点,但通过现代技术和精心设计,我们可以在不牺牲效率和速度的情况下,追求更高的简洁性、可测试性和可读性。