写点什么

函数式编程如何帮助你编写高效、优雅的 Web 应用程序

作者:Uberto Barbini

  • 2024-10-22
    北京
  • 本文字数:9047 字

    阅读完需:约 30 分钟

大小:3.88M时长:22:36
函数式编程如何帮助你编写高效、优雅的 Web 应用程序

本文要点

  • 要保持一种内部可变状态并非易事。每次与程序交互时,我们都会更改后续交互的上下文。

  • 面向对象编程(OOP)和函数式编程(FP)试图提供处理软件可维护性和复杂性的解决方案。OOP 封装了复杂性,而 FP 则专注于数据及其转换。

  • 函数式编程概念提高了可预测性、促进了代码重用并管理了副作用——FP 对不变性和可组合性的重视带来了更强大和更易维护的系统。

  • FP 将 Web 应用程序视为数据转换管道,从而提升程序水平。它鼓励人们使用纯函数将输入数据转换为输出数据,从而实现透明的数据流、更轻松的测试和更可预测的行为。

  • Kotlin 无缝融合了面向对象和函数式两种编程范式,并允许开发人员在现有代码库中逐步采用函数式概念。这会带来更具表现力、更简洁、更安全的代码,从而提高可维护性并减少错误。

 

许多因素都会让软件更难理解,从而更难维护。最困难和最成问题的一个因素是管理内部可变状态。这个问题特别麻烦,因为它直接影响软件的行为和发展方式。听上去理所当然的一件事是,每次与程序交互时,我们都会更改后续交互的上下文。这意味着应用程序必须为每个用户维护一种内部状态并不断更新。当内部状态管理不善时,软件的行为会出乎意料,导致错误和修复,从而带来不必要的复杂性。因此,它使软件更难调试和扩展。

 

函数式编程乍一看可能令人生畏,学术味儿过浓,但一旦你掌握了它,它就会改变游戏规则,而且非常有趣!为了更好地理解函数式编程如何帮助我们构建更易维护的软件,我们先从头开始,了解为什么程序变得越来越难维护,问题愈加突出。

 

我们首先要说的是,有效管理软件的可变状态是构建可靠且可维护的软件的关键所在。我指的不是某种理论风格,而是指代码易于更改且易于识别错误的。

 

纵观编程历史,每种范式都有不同的方法来处理软件的内部状态。

 

在过程编程范式中,内部状态是可变且可全局访问的,可以轻松读取和更改。这种方法类似于较底层的 CPU 内部发生的情况,可以非常快速地编译和执行代码。当程序很小的时候,让所有东西都可变和全局化不是问题,因为开发人员可以记住一切并编写正确的代码。

 

但当项目变大时,修复错误和添加新功能会越来越困难。随着软件应用程序变得日趋复杂,代码维护也愈加艰难,可能出现许多流程和复杂的逻辑,就像“意大利面条式代码”。

 

可以将大的问题分解为一些小问题来克服这个困难,让它们更容易处理。模块化编程范式让你一次只关注一个部分,暂时将其余部分放在一边。

 

函数式编程和面向对象编程范式的目标都是让我们的代码更容易管理和修复,即使项目规模不断扩大也能应对。编写高质量代码的方法有很多种。

 

自 20 世纪 90 年代以来,面向对象设计一直是最流行的解决方案。在它最纯粹的形式中,一切都是对象。对象可以具有它们自己的可变状态,但对应用程序的其余部分隐藏。它们仅通过相互发送消息来通信。这样,你就为每个对象分配了工作,并相信它会完成自己的工作。

 

例如,Java 就遵循这种方法,虽说它不是纯粹的面向对象范式。应用程序的状态分散给每个对象,每个对象负责一项特定任务。这样,即使应用程序逐渐成长,修改起来也还是不难。此外,Java 的性能几乎与过程编程语言一样好,有时甚至更好,这让它成为了一种有价值且成功的折衷方案。

 

函数式编程范式采用了完全不同的方法。它使用纯函数,专注于数据及其转换,这些函数不依赖于任何全局上下文。在函数式编程中,状态是不可改变的,就像不可分割的原子一样。函数会转换这些原子,将简单的操作组合成更复杂的操作。这些函数是“纯”的,这意味着它们不依赖于系统的其他部分。

 

换句话说,纯函数的结果仅由其输入决定,因此可预测且容易调试。此外,由于它们是自包含的,且容易理解,因此它们很容易在代码的不同部分中重用。

 

纯函数的另一个优点是它们易于测试,原因如上所述。开发人员无需模拟对象,因为每个函数仅依赖于其输入,并且无需在测试结束时设置和验证内部状态,因为它们没有任何内部状态。

 

最后,使用不可变数据和纯函数大大简化了网络上多个 CPU 和机器之间的任务并行化操作。出于这个原因,许多所谓的“大数据”解决方案都采用了函数式架构。

 

但是,计算机编程领域中没有银弹。函数式方法和面向对象方法都有自己的权衡。

 

如果你的应用程序具有非常复杂的可变状态,并且主要是本地的,则在函数式设计中建模可能需要大量工作。例如,复杂的桌面界面、视频游戏和像模拟器一样工作的应用程序通常更适合面向对象设计。

 

另一方面,函数式编程在程序对明确定义的输入和输出流转换进行操作的场景中尤其出色,例如 Web 开发。在这种范式中,每个任务都变成一个接收一些数据并返回其他一些数据的函数。这种方法对应了 Web 服务的操作:它们接收请求并返回响应。

 

下面,我们将探讨函数式编程如何让 Web 开发受益(代码示例以 Kotlin 语言提供)。这种方法可以简化流程,同时生成更易访问、容易理解和可维护的代码。也不要忘记有趣的部分——它也使编程更有意思了!

 

那么,函数式编程可以简化某些应用程序意味着什么呢?以函数式方式编写代码就像解决逻辑难题。我们必须将问题分解为一组函数,每个函数执行特定的转换,并且它们必须作为一个连贯的整体来协同工作。

 

我们需要定义函数及其操作的数据的结构,确保它们可以组合在一起。与使用全局状态和单例的宽松方法相比,这些限制要严格得多,通常会让应用程序更加复杂。另一方面,一旦编译完成,函数式应用程序的错误应该会少一些,因为许多潜在的错误源已经在设计时就规避掉了。

 

例如,在编写一个 Web 应用程序时,我们设计了所有必要的函数,每个函数都有特定的任务,诸如提取用户个人资料和创建 HTML 页面等等。当这些函数以逻辑方式组合在一起时,真正的魔力就会显现出来,产生一个优雅而实用的解决方案。这种方法跑通的时候,就像是完美的拼图一样,做出的图像感觉很正确。

 

函数式编程就是这样。它不仅是一种编写代码的方式;它还是一种思考和解决问题,识别和利用数据之间联系的方法。

 

传统的编程思维涉及很多过程、函数和方法,将它们作为执行某些操作的代码片段。在函数式编程中则应该采用略微不同,且更数学化的观念:“纯函数是将输入转换为输出的实体”。

 

我们从 Kotlin 中的一些简单纯函数开始来说明这个概念。

fun celsiusToFahrenheit(celsius: Double): Double =    celsius * 9.0 / 5.0 + 32.0
复制代码

celsiusToFahrenheit 函数接收一个单位为摄氏 degrees 的温度值,并将其转换为华氏温度。它不依赖任何外部状态,也不修改任何外部变量,因此我们可以称其为纯粹的函数。它会做计算,但这是一个技术细节。它也可以使用某种预定义值的表格来解决问题。重要的是它能将温度从一个单位转换为另一个单位。

 

在 Kotlin 中,我们可以用箭头表示每个函数的类型。任何函数都可以描述为从类型 A 到类型 B 的箭头,其中 A 和 B 也可以是同一类型。

A -> B
复制代码

在这个例子中,它接收一个 Double 作为参数,并返回另一个 Double,是转换后的温度值。我们可以将其类型描述为 (Double) -> Double。这种简洁的函数类型箭头语法是让 Kotlin 中的函数式工作变得愉快惬意的小细节之一。与 Java 对比来看,Java 中的等效函数是 Function<Double, Double>,以及其他一些具有不同数量参数的类型,如 BiFunction、Supplier 和 Consumer。

 

还要注意,非常多的函数有着相同的类型和签名。例如,这个函数也有同样的签名:

fun square(x: Double): Double = x * x
复制代码

现在想象一下,在从 A 到 B 的函数之上,我们还有另一个从 B 到 C 的函数,返回一个新类型 C。



第一个关于“组合”的例子是:“我们可以组合这两个函数吗?”“是的!我们可以!”我们需要像处理数据一样处理函数。

fun compose(fn1: (A)->B, fn2: (B)->C): (A)->C = ???
复制代码

为了实现这种函数组合,我们需要将第二个函数应用于第一个函数的结果。在 Kotlin 中我们可以这样定义它:

fun <A,B,C> compose(fn1: (A)->B, fn2: (B)->C): (A)->C =   {a -> fn2(fn1(a))}
复制代码

乍一看这可能很奇怪,因为我们将其描述为先执行 fn1,然后执行 fn2,但在实现中,第二个函数出现在第一个函数之前。这是因为计算机使用括号语法从内到外计算函数。



我们看一个实际的例子,演示如何使用它通过组合两个现有函数来创建一个新函数,而无需编写任何额外代码:

val isInLeapYear = compose(LocalDate::parse, LocalDate::isLeapYear)isInLeapYear("2024-02-01") 
复制代码

在这个示例中,compose 将 LocalDate::parse(将一个 String 转换为 LocalDate)与 LocalDate::isLeapYear(检查给定的 LocalDate 是否为闰年)结合起来。由此而生的函数 isInLeapYear 直接以一个 String(日期)作为输入,并返回一个 Boolean,指示是否为闰年。

 

实现相同效果的另一种方法是使用 let(Kotlin 作用域函数之一)。

 

使用作用域函数时,前面的示例可以写成如下形式:

"2024-02-01"    .let(LocalDate::parse)    .let(LocalDate::isLeapYear) 
复制代码

在 Kotlin 中使用 let 的优势在于其可读性和对不变性的增强。在 let 块内链接转换后,从类型 A 到 B 再到 C 的进展变得更加明确,从而增强了代码的清晰度和简洁性。

 

请注意,你也可以将 lambda 块与作用域函数一起使用,但使用函数引用可以更明确地揭示意图。

 

函数组合是函数式编程的基石,标志着一种重大的视角转变:它不仅将函数视为执行任务的代码单元,还将其视为一等实体。这些实体可以作为参数传递,从其他函数返回,并以各种方式组合。这种方法是函数式程序员工作的基础,丰富了他们用来优雅解决复杂问题的工具包。

函数依赖注入

假设我们有一个从数据库获取用户的函数。这个函数需要两件事(请记住,函数式编程中没有隐藏的依赖项或单例):与数据库的连接和用户 ID。

fun fetchUser(db: DbConnection, userId: UserId): User = 
复制代码

在函数内部,它查询数据库、检索用户,并将该用户返回给调用该函数的对象。

 

该函数完全符合我们的需求。但我们有一个问题:这里的数据库连接仅在应用程序的外层可用,但我们需要在域代码的中间调用它,在那里我们有用户 ID,但没有数据库连接等基础设施细节。

 

让数据库连接穿过几层代码以到达我们需要使用这个函数的位置也能做到,但很尴尬且不切实际。换句话说,这个函数的两个输入(用户 ID 和数据库连接)位于我们代码中相隔很远的位置。

 

解决这个常见问题的一种干净方法是部分应用该函数。我们可以首先只为其提供一个参数(例如数据库连接),我们得到的是一个新函数,它只需要剩下的那个参数(用户 ID)即可运行并返回所需的结果。这就像将函数分成两个阶段:首先我们输入第一个参数,然后接收一个需要第二个参数的新函数来完成该过程并传递响应。

 

这个概念乍一听可能很复杂,但代码示例应该会让它更清楚一些:

fun userFetcherBuilder(db: DbConnection): (UserId)->User ={ id: UserId -> fetchUser(db, id) }
复制代码

userFetcherBuilder 函数接收 DB 连接但不返回用户。其结果是另一个将为用户提供用户 ID 的函数。如果不清楚,请再次查看代码和签名。

 

这一技术还可以推广到所有接收两个参数的函数。换句话说,我们有一个接受 A 和 B 以返回 C 的函数。我们想要一个接受 A 并返回一个接受 B 以返回 C 的函数。

fun <A,B,C> partialApply(a: A, operation: (A, B)->C): (B)->C =     { b: B -> operation(a, b) }
复制代码

现在,我们可以以更简洁的形式重写我们的函数:

fun userFetcherBuilder(db: DbConnection): (UserId)->User =    partialApply(db, ::fetchUser)
复制代码

我们如何使用这个应用程序片段来解决我们一开始的问题呢?先从原始函数开始,该函数从数据库中检索用户。然后,在进入域逻辑之前,一旦我们可以访问基础设施(应用程序片段),我们就应用这个数据库连接。

 

这会产生一个只需要用户 ID 即可获取用户的新函数。域逻辑不需要关心数据库连接细节;它可以简单地调用这个嵌入了数据库连接的新函数来检索用户。

 

我们不必将连接传递到所有层,但必须传递那个片段应用的函数。传递函数是一种更灵活的解决方案,它允许我们在各种场景中使用不同的函数,例如用于测试的模拟函数,或从远程 HTTP 调用,而不是通过数据库来获取用户的另一个函数。如果函数返回一个被赋予用户 ID 的用户,函数的接收者才会关心实现细节。

 

换句话说,通过应用程序片段,我们有效地将域逻辑与基础设施问题分离开来,简化了获取数据的过程,而不会用不必要的细节扰乱业务逻辑。这种方法简化了我们的代码并增强了其模块化和可维护性。

fun fetchUserFromDb(db: DbConnection, userId: UserId): User? =     fun initApplication() {      val dbConnection = DatabaseConnection()      val fetchUser: (UserId) -> User? =            partialApply(dbConnection, fetchUser)    settingUpDomain(fetchUser)}fun somewhereInDomain() {    val userId = readUserId()val user = fetchUser(userId)doSomethingWithUser(user)}
复制代码

在这个示例中,partialApply 是一个高阶函数,它将其他函数作为输入和输出。它预先获取原始函数 (dbConnection) 的第一个参数并返回一个新函数 (fetchUser)。这个新函数只需要剩下的参数 (UserId) 即可执行。

 

这样做以后,我们就将数据库连接细节封装在了域逻辑之外,使域逻辑可以专注于业务规则,而无需考虑数据库连接等基础设施细节。这使我们的代码更简洁、更模块化、更易维护。

可调用类

这种方法优雅实用,但抽象地思考函数有时会颇具挑战性。Kotlin 提供了一种更直接、更符合习惯的方法来实现这一点,那就是使用对象。

 

我们可以创建一个从一个函数类型继承的类。这听起来可能很奇怪,但它是一种非常顺手的模式。这种技术允许我们在需要某个独立函数的任何地方使用该类的实例,从而提供一种更直观、面向对象的方式来处理函数式编程概念。以下是它的实现方法:

data class UserFetcher(val db: DbConnection) : (UserId) -> User? {    override fun invoke(id: UserId): User = fetchUser(db, id)}
复制代码

在这个示例中,UserFetcher 是一个数据类(但也是一个函数!),它以 DbConnection 作为构造器参数,并从函数类型 (UserId) -> User 继承。

 

当我们从一个函数类型继承时,我们必须定义该类的调用(invoke)方法,该方法要有与我们要继承的函数相同的签名。 在我们的例子中,此函数获取用户 ID 作为输入并返回用户。

 

UserFetcher 可以像常规函数一样使用,获取 UserId 并返回 User。 这种方法简化了代码,并将函数式编程的初学者熟悉的面向对象概念与函数式范式融合在一起,为他们提供了更平滑的学习曲线

 

虽然我喜欢函数式编程的简单性,但面向对象模式有时非常方便。 Kotlin 同时利用了这两种范式。

 

上面的代码揭示了函数式编程和面向对象编程之间的一些主要区别,尤其是在依赖管理方面。 在面向对象编程中,这一任务可能需要创建具有多种方法的接口并让一个类来实现它。这可能会为相对简单的要求引入复杂的依赖关系网。

 

相反,函数式编程依靠 lambda匿名函数来完成类似的任务。例如,如果我们需要通过用户的 ID 获取用户,我们只需将相关函数作为参数传递给负责生成用户网页的另一个函数,而无需费心定义数据库访问层的完整接口。

 

这种策略尽可能减少了对接口、抽象类或其他复杂结构的需求,简化了代码并消除了不必要的耦合。

一个 Web 服务器示例

我们用一个 Kotlin 中的实际示例来演示这一点,展示如何应用前文展示的所有概念来创建一个显示关于我们“用户”的一些信息的 Web 应用程序。它必须从 HTTP 路径获取用户的 ID,然后从数据库中检索详细信息。我们将使用 Ktor,这是 Kotlin 中广泛使用的 Web 应用程序框架。

 

我们首先实现一个页面来显示包含用户详细信息的页面。这里的 URI 将是 /user/{userId},并将显示从数据库获取的,带有该 userId 的用户详细信息。

 

Ktor 中的“Hello World”如下所示:

fun main() {   embeddedServer(Netty, port = 8080) {       routing {           get("/") {                call.respond(HtmlContent(HttpStatusCode.OK){                body("Hello World")                })       }   }.start(wait = true)}
复制代码

上面的代码很好也很清晰,但是我们如何函数式地实现我们的 API 呢?

 

如果我们用函数式的眼光来观察 Web 服务器的工作方式,我们会看到一个函数将 HTTP 请求(通常来自用户浏览器)转换为 HTTP 响应(可以是页面 HTML)。

从请求到响应的转换

我们如何在代码中应用这种直觉呢?先考虑一下请求 -> 响应类型的函数需要做什么才能生成包含用户详细信息的页面。

 

考虑这里的数据流,数据的旅程从包含用户 ID 的 HTTP 请求开始。这个 ID 可能嵌入在请求的各个部分中——可以是查询参数、URL 路径的一段或其他元素——因此第一步是提取它。

private fun getId(parameters: Parameters): Outcome<Int> =   parameters["id"]?.toIntOrNull()
复制代码

一旦我们有了用户 ID,就可以使用类似我们之前遇到的 UserFetcher 可调用类来检索相应的用户实体。

 

下面就是我们使用 UserFetcher 和数据库连接来获取我们需要的 fetchUser 函数:

val dbConnection = DbConnection()val fetchUser: (UserId)->User? = UserFetcher(dbConnection) 
复制代码

此处的 fetchUser 函数不是纯函数:它将根据数据库数据返回不同的用户。但是,最关键的部分是我们将其视为纯函数。这意味着我们代码的其余部分仍然是纯函数,我们将不纯函数区域仅限制在这个函数中。

 

其他技术(在这本中讨论到的)可以更精确地限制和标记不纯函数。例如,一些函数式编程模式(如 monad 或代数数据类型)可以帮助我们更好地管理副作用。但作为第一步,这种方法已经比更宽松的方法在纯度方面有了显著改进。

 

通过隔离不纯函数,我们让代码库变得更清洁、更可预测且更易测试了。这里的第一步是朝着编写更强大和更可维护的代码迈出的一大步。

 

现在我们有了用户数据。下一步是将这个用户实体转换为适合 HTTP 响应的格式。在这个示例中,我们希望生成用户数据的最小 HTML 表示。

fun userHtml(user: User): HtmlContent =   HtmlContent(HttpStatusCode.OK) {       body("Welcome, ${user.name}")   }
复制代码

我们还需要生成一个 HTML 页面,以在数据库中不存在该用户时显示错误:

fun userNotFound(): HtmlContent =   HtmlContent(HttpStatusCode.NotFound) {   body { "User not found!" }}
复制代码

然后,我们可以创建一个函数,将上述所有函数链接在一起,并生成 Ktor 显示页面所需的 HtmlContent:

fun userPage(request: ApplicationRequest): HtmlContent =   getUserId(request)       ?.let(::fetchUser)       ?.let(::userHtml)       ?: userNotFound()
复制代码

最后,我们可以调用我们的函数来从路由获取用户详细信息:

get("/user/{id}") {   call.respond(userPage(call.parameters))}
复制代码

就这样,我们在 Web 服务器上实现了第一个函数式 API!过程非常简单。以这种方式工作,一次一个函数和一个类型,难道不是一种乐趣吗?

 

当然,这并不是旅程的终点​​。我们还可以通过更有效地处理数据库连接、返回精细调整的错误、添加审计等方式来改进这段代码。

 

但是,作为对函数式世界的首次探索,这一步非常具有启发性,让你明白了从协作对象思维转变为转换思维意味着什么。



总结

让我们快速总结一下我们所做的工作以及我们如何将请求处理分解为四个函数:

 

  1. 从 HTTP 请求中提取用户 ID:第一步是解析 HTTP 请求以提取用户 ID。根据请求结构,这里的工作可能涉及 URL 路径、查询参数或请求正文。

  2. 检索用户数据:有了用户 ID,我们就能使用一个函数来获取此 ID 并返回用户的域表示。这就是我们之前关于函数组合和应用片段的讨论发挥作用的地方。我们可以设计这个函数,以便快速与其他函数组合,实现灵活性和可重用性。

  3. 将用户数据转换为 HTTP 响应格式:获取用户实体后,我们将其转换为适合 HTTP 响应的格式。根据应用程序的要求,这可以是 HTML、JSON 或其他任何格式。

  4. 生成 HTTP 响应:最后,我们将格式化的用户数据封装到 HTTP 响应中,设置适当的状态代码、标头和正文内容。

 

这个小例子说明了为什么函数式编程在 Web 服务器应用程序中表现非常好,因为它们天然适合处理定义明确的输入和输出转换。这恰恰对应了 Web 服务的操作,也就是接收、处理请求并发回响应。

 

这种方法特别吸引人的是它的模块化。从提取用户 ID 到生成 HTTP 响应,该过程中的每个步骤都是一个离散的、独立的任务。这种模块化设计简化了每个任务,并提升了我们代码的清晰度和可维护性。通过将问题分解为更小、更易于管理的多个部分,我们将一个可能较为复杂的过程转变为一系列简单的任务。

 

每个任务都会产生一个或多个明确定义的函数和特定类型,这些函数和类型可以单独测试并在代码库的其他部分复用,而无需从原始上下文中引入依赖项。这使得代码更易维护、更强大,因为开发人员可以独立开发、测试和改进各个组件。

 

此外,这种方法还兼具灵活性和实验性。虽然总体框架保持一致,但提取用户 ID、将用户数据转换为响应友好格式以及将数据封装在 HTTP 响应中的具体方法可能会有所不同。这种灵活性鼓励创新和适应,确保我们的解决方案在不同场景和要求下保持稳健性。

 

我们利用函数式编程的模块化优势来创建更灵活、更易于维护的代码库。这使得我们的开发过程更加愉快,并带来更可靠、适应性更强的应用程序,能够随着需求和技术的变化而发展。


作者介绍

Uberto Barbini 是一位经验丰富的软件开发人员,在测试驱动开发、领域驱动设计和事件源方面拥有丰富的经验。他对这些最佳实践的深厚专业知识和对函数式编程的热情使他开始着手一个雄心勃勃的项目:撰写一本关于 Kotlin 函数式编程的综合书籍。经过四年的专注工作,Uberto 出版了他的巨作,该书长达 400 多页。本书借鉴他的实践经验,展示了函数式编程如何有效地应用于现代后端开发。它指导读者使用函数式编程技术构建一个完整、不普通的应用程序,展示这种范式的实际应用。作为精确、高效编码实践的倡导者,Uberto 用他的书来挑战人们对函数式编程的常见误解。通过他的写作和与世界各地开发人员的互动,他努力证明函数式编程不仅仅是一个植根于复杂数学的理论概念,而是一种实用、直接且有益的软件开发方法。Uberto 的目标是激励和教育开发人员了解函数式编程的强大功能和简单性。他利用自己在软件工程最佳实践方面的丰富背景,提出了在专业开发工作中采用这种范式的令人信服的案例。

 

原文链接:

How Functional Programming Can Help You Write Efficient, Elegant Web Applications

2024-10-22 10:3027

评论

发布
暂无评论
函数式编程如何帮助你编写高效、优雅的 Web 应用程序_编程语言_InfoQ精选文章