4. 控制结构

正如名字所言,控制结构给程序员提供了一种控制程序执行流的方式。这是一个程序语言进行决策和轮询任务的基本功能。回到学习Scala的2010年,我曾认为控制结构如if/then语句,以及forwhile循环,是一个相对无聊的程序功能,但那只是因为我并不知道还有其他方式。这些天我知道了控制结构一个程序语言定义性的功能(defining feature)

Scala的控制结构包括:

  • for循环和for表达式

  • if/then/else语句和if表达式

  • match 表达式 (模式匹配)

  • try/catch/finally

  • while循环

接下来我会简单介绍一下,之后会在具体的小节中展示更多的细节来描述如何使用这些功能。

for循环和for表达式

在大多数的基础用法中, for循环提供了一个针对集合的迭代操作,如下:

    for i <- List(1, 2, 3) do println(i)

但是这仅仅只是一个基础用法。for循环也可以包含守卫 —— 嵌入if语句:

    for
        i <- 1 to 10
        if i > 3
        if i < 6
    do
        println(i)

通过使用yield关键字,for循环也变成了 for 表达式 —— 产生结果的循环:

    val listOfInts = for
        i <- 1 to 10
        if i > 3
        if i < 6
    yield
        i * 10

在循环运行后,listOfInts变成了一个Vector(40, 50)。这个在循环中的守卫语句过滤掉了除了45的所有值,并将这些值在yield代码块中乘以10

关于for循环和表达式的更多细节将在本章的初始章节中涉及。

if/then/else-if表达式

正如for循环和表达式给你遍历集合的能力,if/then/else 表达式则提供了分支决策的能力。在Scala 3中,首选的语法已经改变了,现在看起来是这样的:

    val absValue = if a < 0 then -a else a

    def compare(a: Int, b: Int): Int =
        if a < b then
            -1
        else if a == b then
            0
        else
            1
    end compare

正如两个例子中的展示,一个if表达式就是一个带返回值的表达式(表达式的讨论参阅4.5小节)。

match表达式和模式匹配

接下来,match表达式和模式匹配是Scala的基本功能,本章节的大部分内容将展示这些功能。正如if表达式,match表达式也有返回值,所以可以把他们用作方法体。

比如下面的一个例子,这个方法相似于Perl语言版的 truefalse 判断:

    def isTrue(a: Matchable): Boolean = a match
        case false | 0 | "" => false
        case _ => true

这段代码中, 如果isTrue收到了0或者空字串,它将返回false,否者true。本章花了十个小节来描述match表达式的功能细节。

try/catch/finally代码块

Scala的try/catch/finally代码块类似于Java,但语法上有一点不同,catch代码块是由match表达式构成的:

    try
        // 一些异常构造代码
    catch
        case e1: Exception1Type => // 处理该类异常
        case e2: Exception2Type => // 处理该类异常
    finally
        // 关闭资源和执行其他行文

正如ifmatchtry也是一个会返回值的表达式,你可以如下进行StringInt的转换:

    def toInt(s: String): Option[Int] =
        try
            Some(s.toInt)
        catch
            case e: NumberFormatException => None

这些例子展现了toInt如何运行:

    toInt("1")  // Option[Int] = Some(1)
    toInt("Yo") // Option[Int] = None

关于 try/catch 代码块的更多信息请参阅4.16小节。

while循环

当来到while循环,你会发现它是真的很少有在Scala中用到。这是因为while循环几乎都是用于副作用,比如更改可变参数和用println打印,并且这些都是可以通过for循环和foreach方法对集合进行操作的。也就是说,如果你需要用到它,大体如下所示:

    while
        i < 10
    do
        println(i)
        i += 1

4.1小节将简要介绍 while 循环。

最后,通过数个Scala功能的合并,你可以创建自己的控制结构,这些将在4.17小节进行讨论。

控制结构是编程语言的界定特征

在2020年末,我足够幸运的合写了在Scala官方文档网站( https://docs.scala-lang.org/ )的Scala 3 Book,包括如下三章:

  • 给Java开发者的Scala ( https://oreil.ly/Psbc4

  • 给JS开发者的Scala ( https://oreil.ly/cDyzW

  • 给Python开发者的Scala( https://oreil.ly/6zIAX

当我之前说控制结构作为“编程语言的界定特征”时,我的意思之一是,在我写完这些章节后,我开始意识到该章节所述功能的威力,以及和其他语言相比Scala的一致性是如何强大。这样的一致性是保持Scala能被愉快使用的功能之一。

4.1 在数据结构上的for循环

问题

你想要用一个传统for循环的方式来遍历一个集合的所有元素。

解决方案

有很多方法可以遍历 Scala 集合,包括 for 循环、while 循环、以及如foreach、map、flatMap 等集合方法。 该解决方案重点主要在for循环上。

给定一个简单的列表:

    val fruits = List("apple", "banana", "orange")

你可以像这样遍历这些元素并打印它们:

    scala> for f <- fruits do println(f)
    apple
    banana
    orange

同样的方法适用于所有序列,包括 ListSeqVectorArrayArrayBuffer等。

当你的算法需要多行时,使用相同的 for 循环语法,并将你的处理程序放在大括号内的一个块中:

    scala> for f <- fruits do
        |       // imagine this requires multiple lines
        |       val s = f.toUpperCase
        |       println(s)
    APPLE
    BANANA
    ORANGE

for循环计数器

如果你需要访问 for 循环内的计数器,请使用以下方法之一。

首先,你可以使用这样的计数器访问序列中的元素:

    for i <- 0 until fruits.length do
        println(s"$i is ${fruits(i)}")

该循环产生此输出:

    0 is apple
    1 is banana
    2 is orange

你很少需要依靠索引访问序列元素,然而当你需要时,这是一种可能的方法。Scala集合还提供了一个 zipWithIndex 方法,可用于创建循环计数器:

    for (fruit, index) <- fruits.zipWithIndex do
        println(s"$index is $fruit")

它的输出如下:

    0 is apple
    1 is banana
    2 is orange

生成器

在相关记录中,以下示例展示了如何使用 Range 执行循环三次:

    scala> for i <- 1 to 3 do println(i)
    1
    2
    3

for循环中的 1 to 3 代码部分创建了一个 Range,如REPL中所示:

    scala> 1 to 3
    res0: scala.collection.immutable.Range.Inclusive = Range(1, 2, 3)

像这样使用一个 Range 对象,被称为使用一个 生成器(generator)。4.2小节展示了如何使用此技术创建带有多个计数器的for循环。

Map上的循环

当迭代 Map 中的键和值时,我发现这样的for循环最简洁易读的:

    val names = Map(
        "firstName" -> "Robert",
        "lastName" -> "Goren"
    )
    for (k,v) <- names do println(s"key: $k, value: $v")

REPL展示了的输出是:

    scala> for (k,v) <- names do println(s"key: $k, value: $v")
    key: firstName, value: Robert
    key: lastName, value: Goren

讨论

因为我已经切换到函数式编程风格,所以我已经好几年没有使用while循环了,但是REPL展示了它是如何工作的:

    scala> var i = 0
    i: Int = 0

    scala> while i < 3 do
         |     println(i)
         |     i += 1
    0
    1
    2

while 循环通常用于副作用,例如,像我这样更新可变变量以及向外界输出。随着我的代码越来越接近纯函数式编程 —— 一种没有可变状态的方式 —— 我不再需要 while

也就是说,当你以面向对象的编程风格进行编程时,仍然会经常使用while循环,该示例展示了它们的语法。 也可以像这样把一个while循环写在多行上:

    while
        i < 10
    do
        println(i)
        i += 1

集合方法,比如foreach

在某些方面,Scala 让我想起了 Perl 的口号,“有不止一种方法可以做它”,并且集合上的迭代提供了一些很好的例子。随着集合上可用的大量方法,这重点声明的是一个 for 循环甚至可能不是解决特定问题的最佳方法;foreach、map、flatMap、collect、reduce 等方法经常可以用来解决你的问题而不需要显式的 for 循环。

例如,当你使用集合时,你还可以通过调用集合上的foreach方法遍历每个集合元素:

    scala> fruits.foreach(println)
    apple
    banana
    orange

当你有一个算法要在集合中的每个元素上运行时,只需将这个匿名函数传递给foreach

    scala> fruits.foreach(e => println(e.toUpperCase))
    APPLE
    BANANA
    ORANGE

for循环一样,如果你的算法需要多行,请在一个代码块中执行相应的程序:

    scala> fruits.foreach { e =>
        |      val s = e.toUpperCase
        |      println(s)
        |  }
    APPLE
    BANANA
    ORANGE

另见

  • 有关如何使用zipWithIndex的更多示例,请参阅13.4小节“使用zipWithIndex或zip以创建循环计数器”。

  • 有关如何迭代Map中的元素的更多示例,请参阅14.9小节“遍历Map”。

for 循环的工作原理非常有趣,了解它可以帮助你进步。我在这些文章中详细介绍了这一点:

  • “How to Make a Custom Sequence Work as a for Loop Generator” ( https://oreil.ly/UFVQ2

  • “How to Enable Filtering in a for Expression” ( https://oreil.ly/3anl9

  • “How to Enable the Use of Multiple Generators in a for Expression”( https://oreil.ly/RZGNP

4.2 使用带有多个计数器的for循环

问题

你想创建一个具有多个计数器的循环,例如在迭代多维数组时。

解决方案

你可以创建如下带有两个计数器的for循环:

    scala> for i <- 1 to 2; j <- 1 to 2 do println(s"i = $i, j = $j")
    i = 1, j = 1
    i = 1, j = 2
    i = 2, j = 1
    i = 2, j = 2

执行如下:i 被设置为 1 时,遍历 j 中的元素,然后将 i 设置为 2 并重复该过程。

使用这种方法可以很好地处理这些简单示例,但是当你的代码变复杂时,这是首选样式:

    for
        i <- 1 to 3
        j <- 1 to 5
        k <- 1 to 10 by 2
    do
      println(s"i = $i, j = $j, k = $k")

这种方法在循环多维数组时很有用。假设你创建并填充一个像这样的小型二维数组:

    val a = Array.ofDim[Int](2,2)
    a(0)(0) = 0
    a(0)(1) = 1
    a(1)(0) = 2
    a(1)(1) = 3

你可以像这样打印每一个数组元素:

    scala> for
        |    i <- 0 to 1
        |    j <- 0 to 1
        | do
        |    println(s"($i)($j) = ${a(i)(j)}")
    (0)(0) = 0
    (0)(1) = 1
    (1)(0) = 2
    (1)(1) = 3

讨论

如15.2小节“创建Range”所示,1 to 5 的语法创建了一个Range

    scala> 1 to 5
    val res0: scala.collection.immutable.Range.Inclusive = Range 1 to 5

Range适用于许多目的,并且在for循环中使用的 <- 符号创建的Range称为生成器。同理,你也可以轻松在一个循环中使用多个生成器。

另见

  • 关于格式化控制结构的Scala风格指南( https://oreil.ly/3jB28

4.3 使用带有嵌入式if语句的for循环(守卫语句)

问题

你希望在for循环中添加一个或多个条件子句,通常是为了过滤掉集合中的某些元素,同时处理其他元素。

解决方案

在生成器之后添加一个或多个if语句,如下所示:

    for
        i <- 1 to 10
        if i % 2 == 0
    do
        print(s"$i ")
        // output: 2 4 6 8 10

这些if语句被称为filterfilter表达式或守卫语句,而你可以根据手头的问题使用尽可能需要的守卫语句。这个循环显示了一个打印数字4的生硬方式:

    for
        i <- 1 to 10
        if i > 3
        if i < 6
        if i % 2 == 0
    do
        println(i)

讨论

使用旧样式的if表达式编写for循环仍然是可以的。例如,给定这段代码:

    import java.io.File
    val dir = File(".")
    val files: Array[java.io.File] = dir.listFiles()

理论上,你可以像这样用这种风格编写一个 for 循环,这让人想起C和Java:

    // 一个C/Java风格的'for'循环
    for (file <- files) {
        if (file.isFile && file.getName.endsWith(".scala")) {
            println(s"Scala file: $file")
        }
    }

然而,一旦你熟悉了Scala的for循环语法,我想你会发现它使代码更具可读性,因为它将循环和过滤的关注点从业务逻辑中分开:

    for
        // 循环和过滤
        file <- files
        if file.isFile
        if file.getName.endsWith(".scala")
    do
        // 所需要的业务代码
        println(s"Scala file: $file")

请注意,由于守卫语句通常旨在过滤集合,因此你可能希望使用可用于集合的众多过滤方法中的一种(filter、take、drop等),而不是for循环,具体取决于你的需求。具体例子请参阅第11章。

4.4 通过for/yield从现有集合创建新集合

问题

你想从一个现有的集合通过应用一个算法(和可能的一个或多个守卫语句)到每个元素来创建一个新的集合。

解决方案

使用一个带有for循环的yield语句来从一个现有的集合中创建一个新的集合。例如,给定一个小写的字符串数组:

    scala> val names = List("chris", "ed", "maurice")
    val names: List[String] = List(chris, ed, maurice)

你可以通过将带有for循环的yield和一个简单的算法结合起来新创建一个首字母大写的字符串数组:

    scala> val capNames = for name <- names yield name.capitalize
    val capNames: List[String] = List(Chris, Ed, Maurice)

使用带有yield语句的for循环称为for 推断(for-comprehension)

如果你的算法需要多行代码,请在块中通过yield关键字,并手动指定结果变量的类型来实现,或者不用:

    // [1] 声明 `lengths` 的类型
    val lengths: List[Int] = for name <- names yield
        // 假设这个主体需要多行代码
        name.length

    // [2] 不用声明 `lengths` 的类型
    val lengths = for name <- names yield
        // 假设这个主体需要多行代码
        name.length

两种方法yield可以得到相同的结果:

    List[Int] = List(5, 2, 7)

for推断(也称为for表达式)的两个部分都可以变得比较复杂。下面是一个更大的例子:

    val xs = List(1,2,3)
    val ys = List(4,5,6)
    val zs = List(7,8,9)
    val a = for
        x <- xs
        if x > 2
        y <- ys
        z <- zs
        if y * z < 45
    yield
        val b = x + y
        val c = b * z
        c

for推断产生以下结果:

    a: List[Int] = List(49, 56, 63, 56, 64, 63)

一个for推断甚至可以是完整方法体:

    def between3and10(xs: List[Int]): List[Int] =
        for
            x <- xs
            if x >= 3
            if x <= 10
        yield x

    between3and10(List(1,3,7,11)) // List(3, 7)

讨论

如果你不熟悉将yieldfor循环一起使用,则可以这样考虑循环:

  1. 当它开始运行时,for/yield循环立即创建一个新的与输入集合具有相同类型的空集合。例如,如果输入类型是Vector,输出类型也将是Vector。你可以想象这个新集合就像一个空桶。

  2. for循环的每次迭代中,可能会从输入集合创建一个新的输出元素。输出元素被创建时,它就会被放在桶里。

  3. 当循环结束运行时,返回桶的全部内容。

这值是一种简化的描述,但我发现它在解释过程时很有帮助。

注意,写一个不带守卫的for表达式就像在一个集合上调用map方法一样。

例如,下面的for推断将fruits中的所有字符串集合转为大写:

    scala> val namesUpper = for n <- names yield n.toUpperCase
    val namesUpper: List[String] = List(CHRIS, ED, MAURICE)

在集合上调用map方法做同样的事情:

    scala> val namesUpper = names.map(_.toUpperCase)
    val namesUpper: List[String] = List(CHRIS, ED, MAURICE)

当我刚开始学习Scala时,我所有的代码都是用 for/yield 表达式写的,直到有一天我意识到,使用不带守卫的 for/yield ,就等于使用 map方法。

另见

  • 13.5小节“使用map将一个集合转换为另一个集合”,更详细了展示了for推断和map方法之间的比较。

4.5 像三元运算符一样使用if结构

问题

你熟悉如下Java中特殊的 三元运算符(ternary operator或称三目运算符 ) 语法:

    int absValue = (a < 0) ? -a : a;

并且你想知道如何在Scala中使用等价的表达方式。

解决方案

这是一个有点棘手的问题,因为与 Java 不同的是,在 Scala 中没有特殊的三元操作符;所以只需使用 if/else/then 表达式:

    val a = 1
    val absValue = if a < 0 then -a else a

因为 if 表达式返回一个值,所以你可以将其嵌入到 print 语句中:

    println(if a == 0 then "a" else "b")

你还可以在另一个表达式中使用它,例如 hashCode 方法的这一部分:

    hash = hash * prime + (if name == null then 0 else name.hashCode)

if/else 表达式返回值这一事实也让你可以编写简洁的方法:

    // 版本 1:单行样式
    def abs(x: Int) = if x >= 0 then x else -x
    def max(a: Int, b: Int) = if a > b then a else b
    // 版本 2:方法体在单独的一行,如果你愿意的话
    def abs(x: Int) =
        if x >= 0 then x else -x

    def max(a: Int, b: Int) =
        if a > b then a else b

讨论

“等式、关系和条件运算符”的Java文档页面( https://oreil.ly/rUDNP )指出,Java条件运算符 ?: “被称为三元运算符,因为它使用三个操作数。”

Java 在这里需要单独的语法,因为Java的if/else构造是一个语句;它没有返回值,仅用于副作用,例如更新可变字段。相反,因为Scala的if/else/then确实是一个表达式,并不需要特殊的操作符。参阅 24.3小节“编写表达式(而不是语句)”,以获取更多的语句和表达式的细节。

Arity(操作符词量) -- TODO 鸽子栏

三元 这个词与函数的操作符数量( Arity )有关。维基百科的“Arity页面”( https://oreil.ly/CJtbV )指出,“在逻辑、数学和计算机科学中,函数或操作的操作符数量是函数采用的参数或操作数的数量。” 一元运算符采用一个操作数,二元运算符采用两个操作数,三元运算符采用三个操作数。

4.6 像switch语句一样使用match表达式

问题

你有一种情况,你想创建一个简单的基于Java整数的switch语句,例如匹配一周中的天数、一年中的月数,以及一个整数映射到某个结果的其他场景。

解决方案

需要使用Scala的match表达式,如简单的基于整数的switch语句,请使用以下方法:

    import scala.annotation.switch

    // `i` 是一个整数
    (i: @switch) match
        case 0 => println("Sunday")
        case 1 => println("Monday")
        case 2 => println("Tuesday")
        case 3 => println("Wednesday")
        case 4 => println("Thursday")
        case 5 => println("Friday")
        case 6 => println("Saturday")
        // 使用变量捕获默认值,以便你可以打印它
        case whoa => println(s"Unexpected case: ${whoa.toString}")

该示例展示了如何基于match执行副作用操作(println)。一个更加函数式的方法是从match表达式返回一个值:

    import scala.annotation.switch
    // `i` 是一个整数
    val day = (i: @switch) match
        case 0 => "Sunday"
        case 1 => "Monday"
        case 2 => "Tuesday"
        case 3 => "Wednesday"
        case 4 => "Thursday"
        case 5 => "Friday"
        case 6 => "Saturday"
        case _ => "invalid day"// 默认,包罗万象

@switch 注解

在编写这样的简单match表达式时,建议使用 @switch 注解,如下所示。如果switch无法编译为tableswitchlookupswitch,此注释会在编译时提供警告。将match表达式编译为tableswitchlookupswitch会更好地提高性能,因为它会生成分支表而不是决策树。当给表达式一个值时,它可以直接跳转到结果,而不是通过决策树。

Scala @switch 注释文档( https://oreil.ly/rrNBP )指出:

如果该注解存在,编译器将验证match是否已编译为tableswitch或lookupswitch,如果它编译为一系列条件表达式,则会发出错误。

可以通过一个简单的例子来展示 @switch 注解的效果。首先,将以下代码放入名为SwitchDemo.scala的文件中:

    // 版本 1 - 编译为 tableswitch
    import scala.annotation.switch

    class SwitchDemo:
        val i = 1
        val x = (i: @switch) match
            case 1 => "One"
            case 2 => "Two"
            case 3 => "Three"
            case _ => "Other"

然后像往常一样编译代码:

    $ scalac SwitchDemo.scala

编译此类不会产生警告并创建SwitchDemo.class输出文件。接下来,使用以下 javap 命令反汇编该文件:

    $ javap -c SwitchDemo

这个命令行的输出给出了类似如下的tableswitch

    16: tableswitch { // 1 to 3
                 1: 44
                 2: 52
                 3: 60
                 default: 68
    }

这表明 Scala 能够将match表达式优化为 tableswitch。(这是一件好事。)

接下来,对代码稍作改动,将整数文字 1 替换为一个值:

    class SwitchDemo:
        val i = 1
        val one = 1 // added
        val x = (i: @switch) match
            case one => "One" // replaced the '1'
            case 2 => "Two"
            case 3 => "Three"
            case _ => "Other"

同样,使用scalac编译代码,但你会立即看到一条警告消息:

    $ scalac SwitchDemo.scala
    SwitchDemo.scala:7: warning: could not emit switch for @switch annotated match
        val x = (i: @switch) match {
                     ^
    one warning found

这条警告信息意味着既不能为match生成一个tableswitch,也不能为其生成一个lookupswitch。你可以通过运行 javap 的命令在生成的SwitchDemo.class文件上来确认这一点。当你查看该输出时,会看到前面示例中显示的tableswitch现在已消失。

Joshua Suereth在他的书Scala in Depth(Manning)中指出,在Scala中使用tableswitch优化必须满足以下条件:

  • 匹配的值必须是已知的整数。

  • 匹配的表达式必须是“简单的”。它不能包含任何类型检查、if 语句或提取器。

  • 表达式的值必须在编译时可用。

  • 应该有两个以上的 case 语句。

讨论

解决方案中的示例展示了两种处理默认“catch all”情况的方法。首先,如果你不关心默认匹配的值,你可以使用 _ 通配符来捕获它:

    case _ => println("Got a default match")

相反,如果你对默认匹配的内容感兴趣,请为其分配一个变量名称。然后,你可以在表达式的右侧使用该变量:

    case default => println(default)

使用像default这样的名称通常是最有意义的,但你可以为变量使用任何合法名称:

    case oops => println(oops)

重要的是要知道,如果你不处理默认情况,你会得到MatchError。给定这个match表达式:

    i match
        case 0 => println("0 received")
        case 1 => println("1 is good, too")

如果i01以外的值,则表达式将引发MatchError

    scala.MatchError: 42 (of class java.lang.Integer)
      at .<init>(<console>:9)
      at .<clinit>(<console>)
        much more error output here ...

因此,除非你有意编写偏函数,否则你将需要处理默认情况。

你真的需要match表达式吗?

请注意,对于这样的示例,你可能不需要match表达式。例如,任何时候你只是将一个值映射到另一个值,最好使用Map

    val days = Map(
        0 -> "Sunday",
        1 -> "Monday",
        2 -> "Tuesday",
        3 -> "Wednesday",
        4 -> "Thursday",
        5 -> "Friday",
        6 -> "Saturday"
    )

    println(days(0)) // prints "Sunday"

另见

  • 有关JVM开关如何工作的更多信息,请参阅编译开关的JVM规范( https://oreil.ly/oUcwX )。

  • 关于lookupswitchtableswitch 之间的区别,这个Stack Overflow页面( https://oreil.ly/JvE3P )指出,“区别在于lookupswitch 使用带有键和标签的表,而tableswitch 只使用带有标签的表。” 同样,有关更多详细信息,请参阅Java 虚拟机 (JVM) 规范的“Compiling Switches”部分( https://oreil.ly/oUcwX )。

  • 有关偏函数的更多信息,请参阅10.7小节“创建偏函数”。

4.7 用一个case语句匹配多个条件

问题

你需要在match的多个匹配条件中执行相同的业务逻辑,而不是为每个case重复你的业务逻辑,你希望为匹配条件使用同一个业务逻辑代码。

解决方案

将执行相同业务逻辑的匹配条件放在一行,用 | 分隔(管道)字符:

    // `i` 是一个 Int
    i match
        case 1 | 3 | 5 | 7 | 9 => println("odd")
        case 2 | 4 | 6 | 8 | 10 => println("even")
        case _ => println("too big")

相同的语法适用于字符串和其他类型。下面是一个基于字符串匹配的例子:

    val cmd = "stop"
    cmd match
        case "start" | "go" => println("starting")
        case "stop" | "quit" | "exit" => println("stopping")
        case _ => println("doing nothing")

下面的例子展示了如何在每个case语句中匹配多个对象:

    enum Command:
        case Start, Go, Stop, Whoa

    import Command.*
    def executeCommand(cmd: Command): Unit = cmd match
        case Start | Go => println("start")
        case Stop | Whoa => println("stop")

如上所示,为每个case语句定义多个可能匹配项的能力可以简化你的代码。

另见

  • 相关方法请参阅4.12小节。

4.8 将match表达式的结果分配给变量

问题

你想要从match表达式返回一个值并将其分配给变量,或者使用match表达式作为方法的主体。

解决方案

要将match表达式的结果分配给变量,请在表达式之前创建变量并赋值,如下面例子中的变量evenOrOdd

    val someNumber = scala.util.Random.nextInt()
    val evenOrOdd = someNumber match
        case 1 | 3 | 5 | 7 | 9 => "odd"
        case 2 | 4 | 6 | 8 | 10 => "even"
        case _ => "other"

这种方法通常用于创建短方法或函数。例如,下面的方法实现了Perl对truefalse的定义:

    def isTrue(a: Matchable): Boolean = a match
        case false | 0 | "" => false
        case _ => true

讨论

你可能听说Scala是一种面向表达式的编程(expression-oriented programming(EOP))语言。EOP意味着每个构造都是一个表达式,产生一个值,并且没有副作用。与其他语言不同,在Scala中,每个构造(如if、match、fortry)都会返回一个值。有关更多详细信息,请参阅24.3小节。

4.9 在match表达式中访问默认情况的值

问题

你希望在使用match表达式时访问默认“catch all”的case表达式的值,但当前在使用 _ 通配符语法匹配时无法访问该值。

解决方案

不要使用 _ 通配符,而是为默认case分配一个变量名:

    i match
        case 0 => println("1")
        case 1 => println("2")
        case default => println(s"You gave me: $default")

通过给默认匹配项指定一个变量名,你可以在表达式右侧访问该变量。

讨论

这种技巧的关键是使用一个变量名作为默认匹配,而不是通常的 _ 通配符。你分配的名称可以是任何合法的变量名称,因此你可以将其命名为其他名称,而不是default名称,例如waht

    i match
        case 0 => println("1")
        case 1 => println("2")
        case what => println(s"You gave me: $what" )

提供默认匹配很重要。不这样做可能会导致MatchError

    scala> 3 match
         |     case 1 => println("one")
         |     case 2 => println("two")
         |     // no default match
    scala.MatchError: 3 (of class java.lang.Integer)
    many more lines of output ...

有关MatchError的更多详细信息,请参阅4.6小节的讨论。

4.10 在match表达式中使用模式匹配

问题

你需要在match表达式中匹配一个或多个模式,该模式可以是常量模式、变量模式、构造函数模式、序列模式、元组模式或类型化模式。

解决方案

为要匹配的每个模式定义一个case语句。 以下方法显示了你可以在match表达式中使用的许多不同类型的模式的示例:

    def test(x: Matchable): String = x match

        // 常量模式
        case 0 => "zero"
        case true => "true"
        case "hello" => "you said 'hello'"
        case Nil => "an empty List"

        // 序列模式
        case List(0, _, _) => "a 3-element list with 0 as the first element"
        case List(1, _*) => "list, starts with 1, has any number of elements"

        // 元组模式
        case (a, b) => s"got $a and $b"
        case (a, b, c) => s"got $a, $b, and $c"

        // 构造函数模式
        case Person(first, "Alexander") => s"Alexander, first name = $first"
        case Dog("Zeus") => "found a dog named Zeus"

        // 类型化模式
        case s: String => s"got a string: $s"
        case i: Int => s"got an int: $i"
        case f: Float => s"got a float: $f"
        case a: Array[Int] => s"array of int: ${a.mkString(",")}"
        case as: Array[String] => s"string array: ${as.mkString(",")}"
        case d: Dog => s"dog: ${d.name}"
        case list: List[_] => s"got a List: $list"
        case m: Map[_, _] => m.toString

        // 默认通配符模式
        case _ => "Unknown"

    end test

上述方法中的大match表达式显示了Programming in Scala一书中描述的不同类别的模式,包括常量模式、序列模式、元组模式、构造函数模式和类型化模式。

以下代码展示了 match 表达式中的所有情况,每个表达式的输出显示在注释中。 注意println方法是在导入时重命名使得例子更简洁:

    import System.out.{println => p}

    case class Person(firstName: String, lastName: String)
    case class Dog(name: String)

    // 触发常量模式
    p(test(0))             // zero
    p(test(true))          // true
    p(test("hello"))       // you said 'hello'
    p(test(Nil))           // an empty List

    // 触发序列模式
    p(test(List(0,1,2)))   // a 3-element list with 0 as the first element
    p(test(List(1,2)))     // list, starts with 1, has any number of elements
    p(test(List(1,2,3)))   // list, starts with 1, has any number of elements
    p(test(Vector(1,2,3))) // vector, starts w/ 1, has any number of elements

    // 触发元组模式
    p(test((1,2)))         // got 1 and 2
    p(test((1,2,3)))       // got 1, 2, and 3

    // 触发构造函数模式
    p(test(Person("Melissa", "Alexander"))) // Alexander, first name = Melissa
    p(test(Dog("Zeus")))                    // found a dog named Zeus

    //  触发类型化的模式
    p(test("Hello, world"))                 // got a string: Hello, world
    p(test(42))                             // got an int: 42
    p(test(42F))                            // got a float: 42.0
    p(test(Array(1,2,3)))                   // array of int: 1,2,3
    p(test(Array("coffee", "apple pie")))   // string array: coffee,apple pie
    p(test(Dog("Fido")))                    // dog: Fido
    p(test(List("apple", "banana")))        // got a List: List(apple, banana)
    p(test(Map(1->"Al", 2->"Alexander")))   // Map(1 -> Al, 2 -> Alexander)

    // 触发通配符模式
    p(test("33d"))                          // you gave me this string: 33d

请注意,在 match 表达式中,ListMap表达式是这样编写的:

    case m: Map[_, _] => m.toString
    case list: List[_] => s"thanks for the List: $list"

也可以写成如下的形式:

    case m: Map[A, B] => m.toString
    case list: List[X] => s"thanks for the List: $list"

我更喜欢下划线语法,因为它清楚地表明我不关心ListMap中存储的内容。实际上,有时我可能对存储在ListMap中的内容感兴趣,但由于JVM中的类型擦除,这成为一个难题。

类型擦除 -- TODO 耗子栏

当我第一次写这个例子的时候,我写的List表达式如下:

    case l: List[Int] => "List"

如果你熟悉Java平台上的类型擦除,你可能知道这是行不通的。Scala编译器通过以下警告消息让你了解此问题:

    Test1.scala:7: warning: non-variable type argument Int in type pattern List[Int] is unchecked since it is eliminated by erasure 
    case l: List[Int] => "List[Int]"
            ^

如果你不熟悉类型擦除,我在本章节的查看更多部分中包含了一个链接,该链接的网页描述了它是如何在JVM上工作的。

讨论

通常,当使用这种技术时,你的方法将期望一个从基类或特质继承的实例,然后你的case语句将引用该基类型的子类型。这是在测试方法中推断出来的,其中每个Scala类型都是 Matchable 的子类型。下面的代码展示了一个更明显的例子。

在我的Blue Parrot 应用程序( https://alvinalexander.com/blueparrot )中,它要么播放声音文件,要么以随机的时间间隔“说出”它给出的文本,如下方法所示:

    import java.io.File

    sealed trait RandomThing

    case class RandomFile(f: File) extends RandomThing
    case class RandomString(s: String) extends RandomThing

    class RandomNoiseMaker:
        def makeRandomNoise(thing: RandomThing) = thing match
            case RandomFile(f) => playSoundFile(f)
            case RandomString(s) => speakText(s)

makeRandomNoise方法携带一个RandomThing类型的参数,且match表达式处理它的两个子类型RandomFileRandomString的方法。

模式

解决方案中的大match表达式显示了在 Programming in Scala 一书中定义的各种模式(由Scala语言的创建者Martin Odersky合著)。这些模式包括:

  • 常量模式

  • 变量模式

  • 构造函数模式

  • 序列模式

  • 元组模式

  • 类型化模式

  • 变量绑定模式

这些模式将在以下段落中进行简要描述。

常量模式

一个常量模式只能匹配它自己。任何文字都可以用作常量。如果将 0 指定为常量,则只会匹配为 0Int 值。例如:

    case 0 => "zero"
    case true => "true"

变量模式

这并没有在解决方案的大match示例中演示,但匹配任何对象的一个变量模式,就像 _ 通配符一样。可以被Scala绑定到任何对象,这使你可以在case语句的右侧使用变量。例如,在match表达式的末尾,你可以像这样使用 _ 通配符来捕获其他任何内容:

    case _ => s"Hmm, you gave me something ..."

但是使用变量模式,你可以改为:

    case foo => s"Hmm, you gave me a $foo"

更多信息请参阅4.9小节。

构造函数模式

构造函数模式允许你在 case 语句中匹配构造函数。如示例所示,你可以根据需要指定常量或变量模式:

    case Person(first, "Alexander") => s"found an Alexander, first name = $first"
    case Dog("Zeus") => "found a dog named Zeus"

序列模式

你可以匹配List、Array、Vector等序列。使用 _ 字符代表序列中的一个元素,使用 _* 代表零个或多个元素,如示例中所示:

    case List(0, _, _) => "a 3-element list with 0 as the first element"
    case List(1, _*) => "list, starts with 1, has any number of elements"
    case Vector(1, _*) => "vector, starts with 1, has any number of elements"

元组模式

如示例中所示,你可以匹配元组模式并访问元组中每个元素的值。如果你对值不感兴趣,也可以使用 _ 通配符:

    case (a, b, c) => s"3-elem tuple, with values $a, $b, and $c"
    case (a, b, c, _) => s"4-elem tuple: got $a, $b, and $c"

类型化模式

在以下示例中,str: String是类型化模式,str模式变量

    case str: String => s"you gave me this string: $str"

如示例所示,你可以在声明后访问表达式的模式变量。

变量绑定模式

有时你可能希望将变量添加到模式中。你可以使用以下通用语法来执行此操作:

    case variableName @ pattern => ...

这称为变量绑定模式。使用时,将match表达式的输入变量与模式进行比较,如果匹配,则将输入变量绑定到variableName

通过展示它解决的问题可以最好地体现它的有用性。假设你有前面演示的 List 模式:

    case List(1, _*) => "a list beginning with 1, having any number of elements"

正如展示的那样,这让你可以匹配第一个元素为1List,但到目前为止,还没有在表达式的右侧访问List。访问列表时,你知道可以这样做:

    case list: List[_] => s"thanks for the List: $list"

所以看起来你应该用序列模式试试这个:

    case list: List(1, _*) => s"thanks for the List: $list"

不幸的是,这会失败并出现以下编译错误:

    Test2.scala:22: error: '=>' expected but '(' found.
        case list: List(1, _*) => s"thanks for the List: $list"
                       ^
    one error found

这个问题的解决方案是在序列模式中添加一个变量绑定模式:

    case list @ List(1, _*) => s"$list"

此代码能够完成编译并按预期工作,你也可以在语句的右侧访问该List

以下代码展示了该示例以及该处理方式的高效:

    case class Person(firstName: String, lastName: String)

    def matchType(x: Matchable): String = x match
        //case x: List(1, _*) => s"$x" // doesn’t compile
        case x @ List(1, _*) => s"$x"  // prints the list

        //case Some(_) => "got a Some" // works, but can’t access the Some
        //case Some(x) => s"$x"        // returns "foo"
        case x @ Some(_) => s"$x"      // returns "Some(foo)"

        case p @ Person(first, "Doe") => s"$p" // returns "Person(John,Doe)"
    end matchType

    @main def test2 =
        println(matchType(List(1,2,3)))           // prints "List(1, 2, 3)"
        println(matchType(Some("foo")))           // prints "Some(foo)"
        println(matchType(Person("John", "Doe"))) // prints "Person(John,Doe)"

match表达式内的两个List示例中,注释掉的代码行是无法编译的,但第二行展示了如何匹配所需的List对象,然后将该列表绑定到变量x。当这行代码匹配像 List(1,2,3) 这样的列表时,它会产生输出 List(1, 2, 3),如第一个println语句的输出所示。

第一个Some示例表示你可以将Some按所示方式匹配,但你无法在右侧的表达式访问其信息。第二个示例展示了如何访问Some中的值,第三个示例更进一步,让你可以访问Some对象本身。当它与第二个println调用匹配时,它会打印Some(foo),表明你现在可以访问Some对象。

最后,此方法用于匹配姓氏为 DoePerson。此语法允许你将模式匹配的结果分配给变量p,然后在表达式的右侧访问该变量。

在match表达式中使用Some和None

为了完善这些示例,你经常将SomeNonematch表达式一起使用。

例如,当你尝试使用类似toIntOption的方法从字符串创建数字时,你可以在match表达式中处理结果:

    val s = "42"

    // later in the code ...
    s.toIntOption match
        case Some(i) => println(i)
        case None => println("That wasn't an Int")

match表达式中,你只需指定SomeNone情况,如上所示来处理成功和失败条件。参阅24.6小节“使用 Scala 的错误处理类型(Option、Try 和 Either)”,以了解更多使用Option、SomeNone的示例。

另见

  • Stack Overflow( http://bit.ly/15odxST )上的讨论,在使用match表达式时绕过类型擦除

  • My Blue Parrot应用程序( https://alvinalexander.com/blueparrot

  • 类型擦除文档( http://bit.ly/139WrFj

4.11 在match表达式中使用枚举和样例类

问题

你想在match表达式中匹配枚举、样例类或样例对象。

解决方案

以下示例展示了如何使用模式匹配枚举的不同方式,具体取决于你在每个case语句右侧需要哪些信息。首先,这是一个名为Animal的枚举,它具有三个实例Dog、CatWoodpecker

    enum Animal:
        case Dog(name: String)
        case Cat(name: String)
        case Woodpecker

给定该枚举,下面getInfo方法展示了在match达式中匹配枚举类型的不同方式:

    import Animal.*

    def getInfo(a: Animal): String = a match
        case Dog(moniker) => s"Got a Dog, name = $moniker"
        case _: Cat => "Got a Cat (ignoring the name)"
        case Woodpecker => "That was a Woodpecker"

下面的例子展示了当给定Dog、CatWoodpeckergetInfo如何工作:

    println(getInfo(Dog("Fido")))   // Got a Dog, name = Fido
    println(getInfo(Cat("Morris"))) // Got a Cat (ignoring the name)
    println(getInfo(Woodpecker))    // That was a Woodpecker

getInfo中,如果Dog类匹配,则提取其名称并用于在表达式右侧创建字符串。为了展示提取名称时使用的变量名称可以是任何合法的变量名称,我使用名称moniker

匹配Cat时,我想忽略名称,因此我使用上述的语法来匹配任何Cat实例。因为Woodpecker不是使用参数创建的,所以它也匹配,如上所示。

讨论

在 Scala 2 中,sealed traitscase classcase object一起使用,以实现与枚举相同的效果:

    sealed trait Animal
    case class Dog(name: String) extends Animal
    case class Cat(name: String) extends Animal
    case object Woodpecker extends Animal

如6.12小节“如何使用枚举创建命名值集”所述,枚举是(a)定义sealed classtrait以及(b)定义为类的伴随对象成员的值的快捷方式。这两种方法都可以在getInfomatch表达式中使用,因为case class有一个内置的unapply方法,可以让它们在match表达式中工作。我在7.8小节“使用unapply实现模式匹配”中描述了它是如何工作的。

4.12 将if表达式(守卫语句)添加到case语句中

问题

你希望将特定的逻辑添加到match表达式中的case语句,例如允许一定范围的数字或匹配模式,但前提是该模式与某些附加条件匹配。

解决方案

在你的case语句中添加一个if守卫语句。用它来匹配一系列数字:

    i match
        case a if 0 to 9 contains a => println("0-9 range: " + a)
        case b if 10 to 19 contains b => println("10-19 range: " + b)
        case c if 20 to 29 contains c => println("20-29 range: " + c)
        case _ => println("Hmmm...")

用它来匹配对象的不同值:

    i match
        case x if x == 1 => println("one, a lonely number")
        case x if (x == 2 || x == 3) => println(x)
        case _ => println("some other value")

只要你的类有一个unapply方法,你就可以在你的if守卫语句中引用类字段。例如,因为一个样例类有一个自动生成的unapply方法,给定这个Stock类和实例:

    case class Stock(symbol: String, price: BigDecimal)
    val stock = Stock("AAPL", BigDecimal(132.50))

你可以在类变量中使用模式匹配和守卫语句条件:

    stock match
        case s if s.symbol == "AAPL" && s.price < 140 => buy(s)
        case s if s.symbol == "AAPL" && s.price > 160 => sell(s)
        case _ => // do nothing

你还可以从case class和已正确实现unapply方法的类中提取字段,并在你的守卫语句条件中使用这些字段。例如:

    // 提取“case”中的“name”,然后使用该值
    def speak(p: Person): Unit = p match
        case Person(name) if name == "Fred" =>
            println("Yabba dabba doo")
        case Person(name) if name == "Bam Bam" =>
            println("Bam bam!")
        case _ =>
            println("Watch the Flintstones!")

如果将Person定义为case class,则可以正常运行:

    case class Person(aName: String)

或者正确实现了unapply方法的Person类:

    class Person(val aName: String)
    object Person:
        // 'unapply' 解构 Person。它也被称为
        // 提取器,Person 是一个“提取器对象”。
        def unapply(p: Person): Option[String] = Some(p.aName)

有关如何编写unapply方法的更多详细信息,请参阅7.8小节“使用unapply实现模式匹配”。

讨论

每当你想在case语句的左侧(即 => 符号之前)添加条件推断时,都可以使用这样的if表达式。

请注意,所有这些示例都可以通过将if推断放在表达式的右侧来编写,如下所示:

    case Person(name) =>
        if name == "Fred" then println("Yabba dabba doo")
        else if name == "Bam Bam" then println("Bam bam!")

但是,在许多情况下,通过将if守卫语句直接与case语句相结合,你的代码将更简单、更易于阅读;它有助于将守卫与后来的业务逻辑分开。

另请注意,这个Person示例有点刻意,因为Scala的模式匹配功能让你可以像这样编写案例:

    def speak(p: Person): Unit = p match
        case Person("Fred") => println("Yabba dabba doo")
        case Person("Bam Bam") => println("Bam bam!")
        case _ => println("Watch the Flintstones!")

在这种情况下,当Person更复杂并且你需要做的不仅仅是匹配其参数时,确实需要一个守卫语句。

此外,如4.10小节中所示,与其使用如下的解决方案:

    case x if (x == 2 || x == 3) => println(x)

还有另一种可能的解决方案是使用变量绑定模式

    case x @ (2|3) => println(x)

这段代码可以理解为:“如果match表达式的值 x23,则将该值赋给变量x,然后使用println打印 x。”

4.13 使用match表达式代替isInstanceOf

问题

你想编写一段代码来匹配一种类型或多种不同的类型。

解决方案

你可以使用isInstanceOf方法来测试对象的类型:

    if x.isInstanceOf[Foo] then ...

然而,“Scala方式”更倾向于match表达式这种工作方式,因为使用match表达式通常比isInstanceOf更强大和更方便。

例如,在一个基本用例中,你可能会收到一个未知类型的对象,并希望确定该对象是否是Person的实例。这段代码展示了如何编写一个match表达式,如果类型是Person,则返回true,否则返回false

    def isPerson(m: Matchable): Boolean = m match
        case p: Person => true
        case _ => false

更常见的场景是你将有一个这样的模型:

    enum Shape:
        case Circle(radius: Double)
        case Square(length: Double)

然后你会想写一个方法来计算Shape的面积。此问题的一种解决方案是使用模式匹配来编写area方法:

    import Shape.*

    def area(s: Shape): Double = s match
        case Circle(r) => Math.PI * r * r
        case Square(l) => l * l

    // examples
    area(Circle(2.0)) // 12.566370614359172
    area(Square(2.0)) // 4.0

这是一种常见的用法,其中area接受一个参数,其类型是你在match中解构类型的直接父级。

请注意,如果CircleSquare采用额外的构造函数参数,并且你只需要访问它们的半径和长度,则完整的解决方案如下所示:

    enum Shape:
        case Circle(x0: Double, y0: Double, radius: Double)
        case Square(x0: Double, y0: Double, length: Double)

    import Shape.*

    def area(s: Shape): Double = s match
        case Circle(_, _, r) => Math.PI * r * r
        case Square(_, _, l) => l * l

    // examples
    area(Circle(0, 0, 2.0)) // 12.566370614359172
    area(Square(0, 0, 2.0)) // 4.0

match表达式中的case语句所示,只需忽略不需要的参数,用 _ 字符指代它们。

讨论

如上所示,match表达式允许你匹配多种类型,因此使用它来替换isInstanceOf方法只是对match/case语法和Scala应用程序中使用的一般模式匹配方法的自然使用。

对于最基本的用例,isInstanceOf方法可以是一种更简单的方法来确定一个对象是否匹配一个类型:

    if (o.isInstanceOf[Person]) { // handle this ...

但是,对于比这更复杂的场景,match表达式比长if/then/else if语句更具可读性。

面向对象中的isInstanceOf -- TODO 方块中

在面向对象编程中,使用isInstanceOf可能表明你没有正确使用继承。例如,有人可能写过这样的代码:

    enum Animal:
        case Cat, Dog, Ostrich

如果由于某种原因你发现自己正在编写这样的isInstanceOf代码,这表明你可能做错了:

    if currentInstance.isInstanceOf[Ostrich] then ...

相反,OOP代码看起来像这样:

    val animal: Animal =
        AnimalFactory.getAnimal("big flightless bird")

    // some time later in your code ...
    animal.walk()

或者这样:

    val oz: Ostrich =
        AnimalFactory.getAnimal(
            "big flightless bird"
        ).asInstanceOf[Ostrich]

    // some time later in your code ...
    oz.tryToFly()

如果你写这样的代码来接收一个Animal,代码不应该关心是否你收到的动物是CatDogOstrich,如果你立即强制转换该实例到Ostrich,你知道你可以调用它的任何其他方法。因此,在OOP中,你应该很少需要使用isInstanceOf测试实例。相反,在函数式编程代码中,可以一直使用match表达式的模式匹配来处理类型。

另见

  • 4.10小节展示了更多的 match 的技术。

4.14 在match表达式中使用列表

问题

你知道List数据结构与其他顺序数据结构有点不同:它由cons单元构建,并以Nil元素结束。你想要结合match表达式时来利用这个优势, 例如在编写递归时功能。

解决方案

你可以创建一个包含整数123的列表,如下所示:

    val xs = List(1, 2, 3)

或像这样:

    val ys = 1 :: 2 :: 3 :: Nil

如第二个示例所示,ListNil元素结尾,你可以在编写match表达式利用这个优势来处理列表,尤其是当编写递归算法。例如,在下面的listToString方法中,如果当前元素不是Nil,该方法被递归调用剩余的List,但如果当前元素为Nil,则停止递归调用并返回返回空字符串:

    def listToString(list: List[String]): String = list match
        case s :: rest => s + " " + listToString(rest)
        case Nil => ""

REPL展示了此方法的是如何工作的:

    scala> val fruits = "Apples" :: "Bananas" :: "Oranges" :: Nil
    fruits: List[java.lang.String] = List(Apples, Bananas, Oranges)

    scala> listToString(fruits)
    res0: String = "Apples Bananas Oranges "

在处理其他类型和不同算法的列表时,可以使用相同的方法。例如,虽然你可以只编写List(1,2,3).sum,但此示例展示了如何使用匹配和递归编写你自己的sum方法:

    def sum(list: List[Int]): Int = list match
        case Nil => 0
        case n :: rest => n + sum(rest)

同样,这是一个乘积算法:

    def product(list: List[Int]): Int = list match
        case Nil => 1
        case n :: rest => n * product(rest)

REPL展示了这些方法是如何工作的:

    scala> val nums = List(1,2,3,4,5)
    nums: List[Int] = List(1, 2, 3, 4, 5)

    scala> sum(nums)
    res0: Int = 15

    scala> product(nums)
    res1: Int = 120

不要忘记reduce和fold操作 -- TODO 耗子栏

虽然递归很棒,但Scala在集合类上的各种reducefold方法是为了让你在遍历集合的同时应用算法,且它们通常不需要递归。

例如,你可以使用以下两种形式中的任何一种来编写求和算法:

    // long form
    def sum(list: List[Int]): Int = list.reduce((x,y) => x + y)
    // short form
    def sum(list: List[Int]): Int = list.reduce(_ + _)

有关详细信息,请参阅13.10小节“使用reduce和fold方法遍历集合”。

讨论

如上所示,递归是一种方法调用自身以解决问题的技术。在函数式编程中——所有变量都是不可变的——递归提供了一种迭代List中的元素以解决问题的方法,例如计算List中所有元素的总和或乘积。特别是使用List类的一个好处是ListNil元素结尾,因此你的递归算法通常具有以下模式:

    def myTraversalMethod[A](xs: List[A]): B = xs match
        case head :: tail =>
            // do something with the head
            // pass the tail of the list back to your method, i.e.,
            // `myTraversalMethod(tail)`
        case Nil =>
            // end condition here (0 for sum, 1 for product, etc.)
            // end the traversal

函数式编程中的变量 -- TODO 耗子栏

在FP中,我们使用术语变量,但当我们只使用不可变变量时,这个词似乎没有什么意义,即我们有一个不能变化的变量。

这里发生的事情是,我们真正的意思是代数意义上的“变量”,而不是计算机编程意义上的“变量”。例如,在代数中,当我们写下这个代数方程时,我们说a、bc是变量:

    a = b * c

但是,一旦它们被分配,它们就不能改变。术语变量在函数式编程中具有相同的含义。

另见

我最初发现递归是一个不必要的难以掌握的话题,所以我写了很多关于它的博客文章:

  • “Scala Recursion Examples (Recursive Programming)” ( https://oreil.ly/5LASz

  • “Recursive: How Recursive Function Calls Work”( https://oreil.ly/vIfgS

  • “Tail-Recursive Algorithms in Scala”( https://oreil.ly/wJat4

  • “Recursion: Visualizing the Recursive sum Function”( https://oreil.ly/wJat4

  • 在“Recursion: Thinking Recursively”( https://oreil.ly/0grXf )中,我写了特征(identity)元素,包括 0 是和运算的特征元素,1 是乘运算的特征元素,而""(一个空字符串)是处理字符串的特征元素。

4.15 用try/catch匹配一个或多个异常

问题

你想在try/catch块中捕获一个或多个异常。

解决方案

Scala的try/catch/finally语法类似于Java,但它在catch块中使用了match表达式:

    try
        doSomething()
    catch
        case e: SomeException => e.printStackTrace
    finally
        // do your cleanup work

当你需要捕获和处理多个异常时,只需将异常类型添加为不同的case语句即可:

    try
        openAndReadAFile(filename)
    catch
        case e: FileNotFoundException =>
            println(s"Couldn’t find $filename.")
        case e: IOException =>
            println(s"Had an IOException trying to read $filename.")

如果你愿意,也可以像这样编写代码:

    try
        openAndReadAFile(filename)
    catch
        case e: (FileNotFoundException | IOException) =>
            println(s"Had an IOException trying to read $filename")

讨论

综上所述,Scala的case语法用于匹配不同的可能出现的异常。如果你不关心抛出哪些特定的异常,并且想捕获所有异常并对其进行处理(例如记录它们),请使用以下语法:

    try
        openAndReadAFile(filename)
    catch
        case t: Throwable => logger.log(t)

由于某种原因,如果你不关心异常的值,你也可以像这样捕获它们并忽略它们:

    try
        openAndReadAFile(filename)
    catch
        case _: Throwable => println("Nothing to worry about, just an exception")

基于try/catch的方法

如本章的介绍所示,try/catch/finally块可以返回一个值,因此可以用作方法的主体。以下方法返回一个 Option[String]。如果找到文件,它返回一个包含字符串的 Some,如果读取文件有问题,则返回None

    import scala.io.Source
    import java.io.{FileNotFoundException, IOException}

    def readFile(filename: String): Option[String] =
        try
            Some(Source.fromFile(filename).getLines.mkString)
        catch
            case _: (FileNotFoundException|IOException) => None

这展示了一种从try表达式返回值的方式。

这些天来,我很少编写抛出异常的方法,但是像Java一样,你可以从catch子句中抛出异常。然而,因为Scala没有检查异常,你不需要指定一个方法抛出的异常。如下例子所示,该方法不包含任何注解:

    // 危险:此方法不会警告你可能会抛出异常
    def readFile(filename: String): String =
        try
            Source.fromFile(filename).getLines.mkString
        catch
            case t: Throwable => throw t

这实际上是一种非常危险的方法 —— 不要写这样的代码!

要声明方法抛出异常,请将 @throws 注解添加到方法定义中:

    // 更好:这个方法警告其他人可以抛出异常
    @throws(classOf[NumberFormatException])
    def readFile(filename: String): String =
        try
            Source.fromFile(filename).getLines.mkString
        catch
            case t: Throwable => throw t

虽然最后一种方法比前一种方法好,但两者都不是首选的方法。 “Scala 方式”是从不抛出异常。相反,你应该使用Option,如前所示,或者当你想要返回有关失败的信息时,使用Try/Success/FailureEither/Right/Left类。下面的例子展示了如何使用Try

    import scala.io.Source
    import java.io.{FileNotFoundException, IOException}
    import scala.util.{Try,Success,Failure}

    def readFile(filename: String): Try[String] =
        try
            Success(Source.fromFile(filename).getLines.mkString)
        catch
            case t: Throwable => Failure(t)

每当涉及异常消息时,我总是更喜欢使用TryEither而不是Option,因为它们使你可以访问FailureLeft中的消息,其中Option仅返回None

捕捉一切的简洁方法

捕获所有异常的另一种简洁方法是使用scala.util.control.Exception对象的allCatch方法。以下示例展示了如何使用 allCatch,先展示成功案例,再展示失败案例。每个表达式的输出放在每一行的注释之后:

    import scala.util.control.Exception.allCatch

    // OPTION
    allCatch.opt("42".toInt)  // Option[Int] = Some(42)
    allCatch.opt("foo".toInt) // Option[Int] = None

    // TRY
    allCatch.toTry("42".toInt) // Matchable = 42
    allCatch.toTry("foo".toInt)
        // Matchable = Failure(NumberFormatException: For input string: "foo")

    // EITHER
    allCatch.either("42".toInt) // Either[Throwable, Int] = Right(42)
    allCatch.either("foo".toInt)
        // Either[Throwable, Int] =
        // Left(NumberFormatException: For input string: "foo")

另见

  • 有关声明方法可能抛出异常的更多示例,请参阅8.7小节“声明方法可以抛出异常”。

  • 有关使用 Option/Some/NoneTry/Success/Failure 的更多信息,请参阅24.6小节“使用Scala的错误处理类型(OptionTryEither)”。

  • 有关 allCatch 的更多信息,请参阅 scala.util.control.Exception 的Scaladoc页面( https://oreil.ly/6pUah )。

4.16 在try/catch/finally块中使用变量之前先声明该变量

问题

你想在try块中使用一个对象,并且需要在finally代码中访问它,例如当你需要调用某个对象的close方法时。

解决方案

通常,在try/catch块之前将你的字段声明为Option,然后在try子句中将变量绑定到一个Some。如下所示,其中sourceOption字段声明在try/catch块之前,并在try子句中进行赋值:

    import scala.io.Source
    import java.io.*

    var sourceOption: Option[Source] = None
    try
        sourceOption = Some(Source.fromFile("/etc/passwd"))
        sourceOption.foreach { source =>
            // do whatever you need to do with 'source' here ...
            for line <- source.getLines do println(line.toUpperCase)
        }
    catch
        case ioe: IOException => ioe.printStackTrace
        case fnf: FileNotFoundException => fnf.printStackTrace
    finally
        sourceOption match
            case None =>
                println("bufferedSource == None")
            case Some(s) =>
                println("closing the bufferedSource ...")
                s.close

这是一个预先写好的例子 —— 16.1小节“读取文本文件”,展示了很多更好的读取文件的方法 —— 但它确实展示了这种方式。首先,在try块之前定义一个var字段作为Option

    var sourceOption: Option[Source] = None

然后,在try子句中,将一个Some值赋值给该变量:

    sourceOption = Some(Source.fromFile("/etc/passwd"))

当你有一个资源要关闭时,使用像这样的技术(尽管 16.1 节“读取文本文件”也展示了一种更好的关闭资源的方法)。请注意,如果在此代码中抛出异常,则finally里面的sourceOption将为None值。如果没有抛出异常,将执行match表达式的Some分支。

讨论

本小节的一个关键点是了解声明未初始化的Option字段的语法:

    var in: Option[FileInputStream] = None
    var out: Option[FileOutputStream] = None

也可以使用第二种形式,但首选第一种形式:

    var in = None: Option[FileInputStream]
    var out = None: Option[FileOutputStream]

不要使用null值

当我第一次开始使用Scala时,我认为编写此代码的唯一方法是使用 null 值。以下代码展示了我在检查电子邮件帐户的应用程序中使用的方法。此代码中的storeinbox字段被声明为具有StoreFolder类型的 null 字段(来自javax.mail包):

    // (1) 声明 null 变量(不要使用 null;这只是一个例子)
    var store: Store = null
    var inbox: Folder = null

    try
        // (2) 使用 try 块中的变量/字段
        store = session.getStore("imaps")
        inbox = getFolder(store, "INBOX")
        //这里的其余代码...
    catch
        case e: NoSuchProviderException => e.printStackTrace
        case me: MessagingException => me.printStackTrace
    finally
        // (3) 在 finally 子句中的对象上调用 close()
        if (inbox != null) inbox.close
        if (store != null) store.close

但是,在Scala工作中,甚至让你有机会忘记空值的存在,因此不推荐使用这种方法。

另见

有关 (a) 如何不使用 null 值以及 (b) 如何使用 OptionTryEither 的更多详细信息,请参阅这些章节:

  • 24.5小节,“消除代码中的null值”

  • 24.6小节,“使用Scala的错误处理类型(OptionTryEither)”

  • 24.8小节,“使用高阶函数处理Option值”

每当你编写需要在启动时打开资源并在完成时关闭资源的代码时,使用 scala.util.Using 对象( https://oreil.ly/N47eZ )会很有帮助。参阅16.1小节“读取文本文件”,以了解如何使用这个对象以及更好的读取文本文件的方法。

此外,24.8小节“使用高阶函数处理Option值”,展示了除了使用match表达式之外的其他处理Option值的方法。

4.17 创建自己的控制结构

问题

你想定义自己的控制结构,简化代码或创建领域特定语言 (DSL)。

解决方案

得益于多参数列表、传名参数、扩展方法、高阶函数等功能,你可以自定义创建一个像控制结构一样的有效代码。

例如,假设 Scala 没有自己的内置 while 循环,而你想创建自己的自定义whileTrue循环,可以像这样使用它:

    var i = 0
    whileTrue (i < 5) {
        println(i)
        i += 1
    }

要创建此whileTrue控制结构,请定义一个名为whileTrue的方法,该方法采用两个参数列表。第一个参数处理判断条件——在该示例中,i < 5——第二个参数是用户想要运行的代码块,即大括号之间的代码。将这两个参数定义为传名参数。因为whileTrue只用于副作用,比如更新可变变量或打印到控制台,所以声明它返回Unit。方法大致的签名如下所示:

    def whileTrue(testCondition: => Boolean)(codeBlock: => Unit): Unit = ???

实现方法主体的一种方法是编写递归算法。此代码展示了一个完整的解决方案:

    import scala.annotation.tailrec

    object WhileTrue:
        @tailrec
        def whileTrue(testCondition: => Boolean)(codeBlock: => Unit): Unit =
            if (testCondition) then
                codeBlock
                whileTrue(testCondition)(codeBlock)
            end if
        end whileTrue

在这段代码中,对testCondition求值,如果条件为真,则执行codeBlock,然后递归调用whileTrue。它一直在调用自己,直到测试条件返回false

要测试此代码,首先将其导入:

    import WhileTrue.whileTrue

然后运行前面显示的whileTrue循环,你会看到它的工作原理是符合预期的。

讨论

Scala语言的创建者有意识地决定不在Scala中实现某些关键字,而是通过Scala库实现功能。例如,Scala没有内置的breakcontinue关键字。相反,它通过一个库来实现它们,正如我在我的博客文章 “Scala:如何在 for 和 while 循环中使用 break 和 continue”( https://oreil.ly/KOAHI )中所描述的那样。

如解决方案中所示,创建自己的控制结构的能力来自以下功能:

  • 多个参数列表让你可以像我对whileTrue 所做的那样:创建一个参数组用于测试条件,第二组用于代码块。

  • 传名参数还可以让你做我对whileTrue 所做的事情:接受的参数在方法内部访问它们之前是不会对其进行评估的。

类似地,中缀表示法、高阶函数、扩展方法和流式接口等其他特性允许你创建其他自定义控制结构和 DSL。

传名参数

传名参数是whileTrue控制结构的重要组成部分。在Scala中,重要的是要知道使用 => 定义方法参数的语法:

    def whileTrue(testCondition: => Boolean)(codeBlock: => Unit) =
                                -----                 -----

你正在创建所谓的传名调用传名参数。仅当在你的方法内部访问时才评估计算传名参数,因此,正如我所写的博客文章“如何在Scala中使用传名参数”( https://oreil.ly/fuWGM )和“Scala和传名调用参数”( https://oreil.ly/shdre ),这些参数的更准确的名称是“访问时求值”。那是因为这正是它们的工作方式:只有在方法内部访问它们时才会评估计算它们。正如我在第二篇博文中所指出的,Rob Norris进行了比较,即传名参数就像接收一个def方法。

另一个例子

whileTrue示例中,我使用递归调用来保持循环运行,但对于更简单的控制结构,你不需要递归。例如,假设你想要一个接受两个测试条件的控制结构,如果两个测试条件都为真,就运行提供的代码块。使用该控制结构的表达式如下所示:

    doubleIf(age > 18)(numAccidents == 0) { println("Discount!") }

在这种情况下,将doubleIf定义为采用三个参数列表的方法,每个参数都是一个传名参数:

    // 两个“if”条件测试
    def doubleIf(test1: => Boolean)(test2: => Boolean)(codeBlock: => Unit) =
        if test1 && test2 then codeBlock

因为doubleIf只需要执行一次测试,不需要无限循环,因此不需要在其方法体中进行递归调用。它只是检查两个测试条件,如果它们都为true,则执行codeBlock

另见

  • David Pollak (Apress) 所著的 Beginning Scala 一书中展示了我最喜欢使用这种技术的一个方法。虽然它已被scala.util.Using废弃对象,我在这篇博文“The using Control Structure in Beginning Scala“( https://oreil.ly/fiLHH )中描述了该技术的工作原理。

  • Scala Breaks 类用于在for循环中实现breakcontinue功能,我写了一篇关于它的文章:“Scala: How to Use break and continue in for and while Loops”( https://oreil.ly/KOAHI )。Breaks类源代码相当简单,并提供了另一个如何实现控制结构的示例。你可以在其Scaladoc页面( https://oreil.ly/xI78S )上以找到其源代码。

最后更新于

这有帮助吗?