Scala 3 — 书籍

函数式错误处理

语言

函数式编程就像编写一系列代数方程,并且由于代数没有 null 值或抛出异常,因此您不会在 FP 中使用这些功能。这引发了一个有趣的问题:在通常可能在 OOP 代码中使用 null 值或异常的情况下,您会怎么做?

Scala 的解决方案是使用 Option/Some/None 类等构造。本课提供了使用这些技术的基础知识。

在开始之前有两点说明

  • SomeNone 类是 Option 的子类。
  • 以下文本通常仅指“Option”或“Option 类”,而不是重复地说“Option/Some/None”。

第一个示例

虽然第一个示例不处理 null 值,但它是介绍 Option 类的不错方法,因此我们从这里开始。

想象一下,你想编写一个方法来轻松地将字符串转换为整数值,并且希望以一种优雅的方式来处理在你的方法获得一个字符串(如 "Hello")而不是 "1")时引发的异常。对这样一个方法的第一个猜测可能如下所示

def makeInt(s: String): Int =
  try {
    Integer.parseInt(s.trim)
  } catch {
    case e: Exception => 0
  }
def makeInt(s: String): Int =
  try
    Integer.parseInt(s.trim)
  catch
    case e: Exception => 0

如果转换成功,此方法将返回正确的 Int 值,但如果转换失败,则该方法将返回 0。这对于某些目的来说可能还可以,但它并不准确。例如,该方法可能已收到 "0",但它也可能已收到 "foo""bar" 或无数其他将引发异常的字符串。这是一个真正的问题:你如何知道该方法何时真正收到 "0",或何时收到其他内容?答案是,使用这种方法,无法知道。

使用 Option/Some/None

Scala 中针对此问题的常见解决方案是使用称为 OptionSomeNone 的三个类。SomeNone 类是 Option 的子类,因此该解决方案的工作原理如下

  • 你声明 makeInt 返回一个 Option 类型
  • 如果 makeInt 接收一个它可以转换为 Int 的字符串,则答案将包装在 Some
  • 如果 makeInt 接收一个它无法转换的字符串,则它将返回一个 None

以下是 makeInt 的修订版

def makeInt(s: String): Option[Int] =
  try {
    Some(Integer.parseInt(s.trim))
  } catch {
    case e: Exception => None
  }
def makeInt(s: String): Option[Int] =
  try
    Some(Integer.parseInt(s.trim))
  catch
    case e: Exception => None

这段代码可以解读为,“当给定的字符串转换为整数时,返回包装在 Some 中的 Int,例如 Some(1)。当字符串无法转换为整数时,将引发并捕获异常,并且该方法将返回一个 None 值。”

这些示例展示了 makeInt 如何工作

val a = makeInt("1")     // Some(1)
val b = makeInt("one")   // None

如所示,字符串 "1" 产生 Some(1),而字符串 "one" 产生 None。这是 Option 错误处理方法的本质。如所示,此技术用于方法返回而不是异常。在其他情况下,Option 值也用于替换 null 值。

两点说明

  • 您会发现此方法在整个 Scala 库类和第三方 Scala 库中使用。
  • 此示例的一个关键点是函数方法不会引发异常;相反,它们返回 Option 之类值。

作为 makeInt 的使用者

现在想象您是 makeInt 方法的使用者。您知道它返回 Option[Int] 的子类,因此问题变成了,您如何处理这些返回类型?

根据您的需要,有两个常见答案

  • 使用 match 表达式
  • 使用 for 表达式

使用 match 表达式

一种可能的解决方案是使用 match 表达式

makeInt(x) match {
  case Some(i) => println(i)
  case None => println("That didn’t work.")
}
makeInt(x) match
  case Some(i) => println(i)
  case None => println("That didn’t work.")

在此示例中,如果 x 可以转换为 Int,则求值第一个 case 子句右侧的表达式;如果 x 无法转换为 Int,则求值第二个 case 子句右侧的表达式。

使用 for 表达式

另一种常见解决方案是使用 for 表达式,即本书前面部分中所示的 for/yield 组合。例如,假设您想将三个字符串转换为整数值,然后将它们相加。以下是如何使用 for 表达式和 makeInt 来实现的

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val y = for
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

该表达式运行后,y 将变为以下两项之一

  • 如果所有三个字符串都转换为 Int 值,y 将变为 Some[Int],即一个用 Some 包装的整数
  • 如果任何一个字符串无法转换为 Inty 将变为 None

你可以自己测试一下

val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for {
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
} yield {
  a + b + c
}
val stringA = "1"
val stringB = "2"
val stringC = "3"

val y = for 
  a <- makeInt(stringA)
  b <- makeInt(stringB)
  c <- makeInt(stringC)
yield
  a + b + c

使用该示例数据,变量 y 将具有值 Some(6)

要查看失败案例,请将其中任何一个字符串更改为无法转换为整数的内容。更改后,你将看到 yNone

y: Option[Int] = None

将 Option 视为容器

心理模型通常可以帮助我们理解新情况,因此,如果你不熟悉 Option 类,可以将它们视为容器

  • Some 是一个包含一个项目的容器
  • None 是一个容器,但它里面没有任何内容

如果你更愿意将 Option 类视为一个盒子,None 就如同一个空盒子。它本可以装一些东西,但现在没有。

使用 Option 替换 null

回到 null 值,null 值可能悄无声息地潜入你的代码的一个地方是使用类似这样的类

class Address(
  var street1: String,
  var street2: String,
  var city: String,
  var state: String,
  var zip: String
)

虽然地球上的每个地址都有一个 street1 值,但 street2 值是可选的。因此,street2 字段可以被分配一个 null

val santa = new Address(
  "1 Main Street",
  null,               // <-- D’oh! A null value!
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  null,               // <-- D’oh! A null value!
  "North Pole",
  "Alaska",
  "99705"
)

从历史上看,开发人员在这种情况下使用了空字符串和 null 值,这两者都是为了解决根本问题而采用的权宜之计:street2 是一个可选字段。在 Scala(以及其他现代语言)中,正确的解决方案是预先声明 street2 是可选的

class Address(
  var street1: String,
  var street2: Option[String],   // an optional value
  var city: String, 
  var state: String, 
  var zip: String
)

现在,开发人员可以编写更准确的代码,如下所示

val santa = new Address(
  "1 Main Street",
  None,           // 'street2' has no value
  "North Pole",
  "Alaska",
  "99705"
)
val santa = Address(
  "1 Main Street",
  None,           // 'street2' has no value
  "North Pole",
  "Alaska",
  "99705"
)

或如下所示

val santa = new Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)
val santa = Address(
  "123 Main Street",
  Some("Apt. 2B"),
  "Talkeetna",
  "Alaska",
  "99676"
)

Option 不是唯一的解决方案

虽然本节重点介绍 Option 类,但 Scala 还有其他一些替代方案。

例如,称为 Try/Success/Failure 的三个类以相同的方式工作,但 (a) 当代码可能抛出异常时,你主要使用这些类,以及 (b) 你希望使用 Failure 类,因为它使你可以访问异常消息。例如,这些 Try 类在编写与文件、数据库和互联网服务交互的方法时通常使用,因为这些函数可以轻松抛出异常。

快速回顾

本节很长,让我们快速回顾一下

  • 函数式程序员不使用 null
  • 替换 null 值的主要方法是使用 Option
  • 函数式方法不会抛出异常;相反,它们会返回诸如 OptionTryEither 的值
  • 处理 Option 值的常用方法是 matchfor 表达式
  • 可以将选项视为一个项目 (Some) 和没有项目 (None) 的容器
  • 选项还可以用于可选构造函数或方法参数

此页面的贡献者