Scala-编程学习指南第二卷-全-

Scala 编程学习指南第二卷(全)

原文:zh.annas-archive.org/md5/1fdec9a760b006675d812f4db9ea26a3

译者:飞龙

协议:CC BY-NC-SA 4.0

第一章:Scala 编程入门

“当你不创造东西时,你被自己的品味而不是能力所定义,你的品味只会变得狭隘并排斥他人。所以,创造吧。”

  • Why the Lucky Stiff

Scala 容易入门,但太深奥了,难以掌握。正如其名所示,Scala 意味着 一种可扩展的语言,一种随着你的编程能力增长而增长的编程语言。本章将向您介绍这种非常流行的语言。

在本章中,我们将涵盖以下主题:

  • Scala 简介

  • Scala 优势

  • 使用 Scala

  • 运行我们的第一个程序

Scala 简介

考虑一个场景,你得到一个段落和一个单词,并被要求计算该单词出现的次数。你很幸运地知道一种语言,比如 Java。你的解决方案可能看起来像这样:

String str = "Scala is a multi-paradigm language. Scala is scalable too."
int count = 0;
for (stringy: str.split (" ")) {
    if (word.equals (stringy))
        count++;
}
System.out.println ("Word" + word + " occurred " + count + " times.")

那很简单,不是吗?现在我们的 Scalable 语言有简单的方法来完成这个任务。让我们看看它是如何做到的:

val str = "Scala is a multi-paradigm language. Scala is scalable too."
println ("Word" + word + " occurred " + str.split(" ").filter(_ == word).size + " times.")

就这样,对同一个问题的单行解决方案。代码现在可能看起来不熟悉,但渐渐地你会掌握它。到本章结束时,我们将了解运行 Scala 程序所需的一切,而不仅仅是 Hello World 程序,还包括做些事情的程序。

Scala 与其他语言并无不同。它运行在 Java 虚拟 JVM)上,因此熟悉 Java 的人肯定对其有所了解。如果不熟悉,JVM 可以定义为一种抽象计算机,它执行一系列指令(Java 字节码)。它使机器能够运行 Java 程序。因此,结论是:当我们编写 Scala 程序并编译它们时,它们会被转换成 Java 字节码,然后在 JVM 上运行。Scala 与所有 Java 库兼容。编写我们自己的 Scala 代码并集成用 Java 编写的库函数既容易又当然可行。

Scala 是一种多范式语言;它是面向对象和函数式编程的混合体。但它对我们有什么好处呢?

编程范式

范式只是做某事的一种方式。因此,编程范式意味着编程的方式或编写程序的一定模式。存在许多编程范式,但其中四种已经获得了流行:

  • 命令式范式:先做这个,再做那个

  • 函数式范式:评估和使用

  • 逻辑范式:通过解决方案来回答

  • 面向对象范式:通过对象间发送消息来模拟一组现实世界现象的时间演化

面向对象与函数式范式

函数式编程范式起源于数学学科,非常简单。它基于函数理论,这些函数产生不可变值。不可变值意味着它们不能直接在之后被修改。在函数式范式下,所有计算都是通过调用自我/其他函数来执行的。函数是函数式世界中的第一公民。这开启了一个新的可能性世界,其中所有计算都由某种需求驱动。

面向对象的星球围绕着封装和抽象旋转。组件的逻辑分组使得维护更大和更复杂的程序变得容易。数据和模型封装在对象中。信息隐藏对于包含对象属性是有效的。继承层次、类的概念以及对象之间的消息传递使得面向对象编程的整个模型/模式部分成功。

Scala 是多范式的

Scala 作为一种多范式语言,支持两种范式。在我们学习 Scala 的过程中,我们拥有这两种范式的力量。我们可以根据需要创建函数,也可以让对象相互通信。我们可以有类层次和抽象。有了这些,对特定范式的控制不会影响另一个。

今天,对于并发、不可变性、异构性、反应性和容错架构的需求急剧增加,而开发周期却越来越短。在这个时代,像 Scala 这样的语言在支持函数式编程以及面向对象编程方面做得更多。

对于我们这样的程序员来说,一种语言是一个创造有意义事物的工具。我们倾向于重用和操作其他工具,在我们的例子中,让我们假设是其他库。现在,我们希望与一种提供使用扩展性和灵活性的语言一起工作。Scala 就是这样做的。这种强大的语言让你可以混合使用新创建的trait(你可能没有听说过,但你可以将其与 Java 的接口进行比较)。我们有多种方法可以使我们的代码更有意义,当然也更简洁。如果使用得当,你可以使用本地语言特性创建自己的自定义结构。所以这种语言就像你一样令人兴奋!

这就是学习它的一个原因。选择 Scala 而不是其他语言的原因还有很多,而且相当多。让我们逐一来看。但首先让我们感到困惑:

"Scala 是一种函数式语言,支持多种范式,Scala 中的每个函数都是一个对象。"

太棒了!现在你已经知道了这种语言的主要三个特点。但这很难接受。它是一种函数式语言,每个函数都是一个对象。真的吗?

以下是一个在 Scala 中定义的trait示例,称为Function1

package scala
trait Function1[A, B] {
        def apply(x: A) : B
}

这些还有很多,从Function0Function22。使用它们有一定的方法。在这本书中,我们会多次使用它们。我们也将它们称为A => B(我们称之为,AB)。这意味着这个函数接受一个类型为A的参数,按照定义执行一些操作,并返回一个类型为B的值:

val answer = new Functiona1[Int, Int] {
        def apply(x: Int): Int = x * 2
}

这看起来有点多,但熟悉这些结构是个好主意。val 是一个关键字,用于声明值类型。这意味着一旦声明和实例化,就不能再进一步更改它。这个 answer = (x: Int) => x * 2 成为一个函数字面量,可以被传递给另一个函数。我们之所以能达到这个点,是因为我们能够实例化我们的 Function1 特质的对象(我们将在第七章[part0240.html#74S700-921a8f8dca2a47ea817d3e6755fa0e84],面向对象的 Scala 的下一步中看到这是如何工作的)。

想想任何两个幸运数字,现在代表你如何将它们相加。假设你的数字是 42 + 61. 在这里,你的数字 42 和 61 是 Int 类型的对象,而 +Int 类型上的一个方法。这就是你和 Scala 处理实体的方式。我们将实体视为对象,并在它们上执行的操作视为方法。这正是使这种语言可扩展的原因。

我们可以执行函数式操作,其中输入被转换成输出,而不是改变它们的数据/状态。考虑到这一点,我们的大部分操作(几乎全部)将不会依赖于状态变化;这意味着函数不会产生副作用。一个例子可以是这样一个函数,它接收你的出生日期,并以年数和月数的形式返回你的年龄:

class YearsAndMonths(years: Int, months: Int)
def age(birthdate: Date): YearsAndMonths = //Some Logic

这是一个纯函数,因为它不操作输入。它接收输入,转换,并给出输出。Case 类只是帮助我们以某种方式定义年龄。有了这个,我们可以引入更多术语,称为引用透明方法.* 我们的 age 方法可以被称作引用透明。这些方法调用可以被结果替换,而不会改变你程序中的任何意义/语义。

纯函数、不可变性的概念和引用透明性都只是为了使这种语言更强大。有更多理由选择这种语言作为你下一个应用程序的工具。

Scala 优势

我们是聪明的程序员。我们已经对我们的语言选择设定了期望。我们的语言应该足够广泛和灵活。它应该是友好的,支持用 Java 等语言编写的库,易于使用,有良好的在线支持,还有很多其他优点。而且你知道吗!Scala 给你提供了完整的包。

在 JVM 上运行

考虑效率优化作为衡量一种语言是否表现良好的因素。Scala 利用 JVM 来实现这一点。JVM 使用即时编译JIT)和自适应优化技术来提高性能。在 JVM 上运行使 Scala 与 Java 兼容。你有很多库可用作为重用工具。

如果你在心中比较 Java 和 Scala 的性能,让我们澄清一下。Java 和 Scala 程序都是编译成字节码。JVM 理解字节码并为你运行它。所以这主要取决于你编写程序的方式。Scala 融合了一些语法糖,编译器逻辑,这可能导致你的程序比 Java 更/更少高效。使用特质进行混入可能对你的程序架构有益,但可能会影响你的程序性能。但在 Java 中的替代方案可能成本相同或更高。所以这更多关于你对结构的核心理解以及你的代码将如何编译和执行。这需要一些时间和努力去理解,所以选择权在你;作为一个聪明的程序员,你可能会选择一个语法强大的语言。

超智能语法

你将用 Scala 编写简洁的代码。我们可以查看很多示例来了解 Scala 语法的简洁性。让我们从 Scala 丰富的集合中取一个例子来创建一个Map

val words = Map ("Wisdom" -> "state of being wise")
println(words("Wisdom"))

> state of being wise

上述代码正在创建一个单词及其含义的映射。只需Map ("Wisdom" -> "state of being wise")这一行代码,我们就能实现这一功能。无需添加分号。我们甚至没有提及我们值的类型,而 Scala 编译器却能推断出来。类型推断是这种语言的特点。由于类型推断,很多时候我们省略了类型声明,直接使用值。这样,仅使用最小的一组单词/标记,你就可以表达实现它们的逻辑。像 case 类和模式匹配这样的结构可以减少你可能需要做的额外工作,并使编写代码变得愉快。它还有助于你大幅减少代码量。

两者之最佳结合

Scala 是函数式和面向对象世界的混合体。它提供了两个好处。首先,你可以利用函数式结构的强大功能:高阶函数、嵌套函数、纯函数和闭包。你可以使用更多可用(且推荐)的不可变数据结构。使用不可变代码有助于消除可能引入副作用或状态变化的代码。这也使得这种语言适合并发编程。这只是 Scala 提供的另一个优势。其次,你拥有所有面向对象的优点。

你可以定义特质,将它们与类或对象混合,从而实现继承。在 Scala 中,创建对象、定义抽象类和子类也是可能的。

类型是核心

在早期(即使现在也很棒)你可能遇到过这种情况:

f : R -> N

这是函数的数学表示。这就是我们表示任何函数 f 的定义域和陪域的方式。在这种情况下,函数 f 将实数集的值映射到自然数集。在这个深层次的抽象级别上,你可以思考 Scala 丰富的类型系统。其中一些可用的类型是参数化的、结构化的、复合的、存在性的、路径依赖的、高阶的,是的,我们正在讨论抽象类型。对这些类型的解释超出了本书的范围。但如果你好奇,你可以参考 Scala 文档在 www.scala-lang.org/documentation/。了解这些对于设计框架或库非常有帮助。

并发编程变得简单

Scala 推荐使用不可变数据结构、不可变集合、使用值类型、函数组合和转换。除此之外,使用演员和其他并发结构使得编写并发程序变得非常容易。大多数情况下,程序员不需要处理线程生命周期管理的复杂性,因为现代结构如演员和反应器以原生支持和库的形式提供。Akka 就是这些工具包之一,它是用 Scala 编写的。此外,使用未来和承诺使得编写异步代码成为可能。

异步代码

简单来说,异步代码是在调用一个指令块(即函数)并启动一些并行/后台任务以完成请求后,程序控制立即返回的地方。这意味着程序流程不会因为某个函数需要时间完成而停止。

异步编程与并行编程和并发编程的比较

异步编程涉及一些计算密集型任务,这些任务一方面在后台占用一个线程,但不会影响程序的正常流程。

并行编程通过结合多个线程来更快地完成任务,并发编程也是如此。但这两者之间有一个细微的差别。并行编程中的程序流程是确定的,而在并发编程中则不是。例如,发送多个请求执行并返回响应,而不考虑响应顺序的场景被称为并发编程。但将任务分解成多个子任务以实现并行性的地方可以定义为并行编程的核心思想。

现在可用于前端

Scala.js 是专门为前端设计的,它可以帮助你避免基于类型的错误,因为 Scala.js 能够推断类型。你可以利用性能优化和与一些现有的 JavaScript 框架(如 Angular 和 React)的互操作性。然后,再加上可用的宏,这些宏可以帮助你扩展语言。

智能集成开发环境

有许多选项可以使您的编程之旅更加轻松。Scala IDE 为基于 Scala 的应用程序的开发提供了许多编辑和调试选项。Scala IDE 是建立在知名 Eclipse IDE 之上的。还有可用于编写 Scala 应用程序的插件。我们将在接下来的章节中探讨如何安装和使用 IDE 进行 Scala 开发。

广泛的语言

Scala 非常深入。丰富的类型抽象、反射和宏都有助于您构建一些真正强大的库和框架。Scala 文档为您解释了一切:从参数化类型到反射组件。理解编译时反射(宏)和运行时反射对于使用 Scala 编写框架是必不可少的。而且这很有趣。

在线支持

Scala 作为编程语言的增长及其成功的一个最大原因是可用的广泛在线支持。Scala 团队投入了大量工作,并提供了丰富的文档。您可以在docs.scala-lang.org找到文档。

学习 Scala 具有挑战性但很有趣。它激发了你作为程序员的最佳表现。用几乎相同的性能能力思考和编写更短、更智能的语法不是很有趣吗?

使用 Scala

在这本书中,我们使用 Scala 版本 2.12.2。Scala 2.12 要求您的系统已安装 Java 8。较老的 Scala 版本支持 Java 6 及以上版本。对 Java 9 的支持仍然是 Scala 2.13 路线图讨论的主题。

Scala 2.12 相比之前的版本是一个进步,主要是为了支持 Java 和 Scala 的 lambda 互操作性。特性和函数被直接编译到它们的 Java 8 等价物。

Java 安装

做必要的事情。如果您的机器上尚未安装 Java,您可以参考 Oracle 的网站docs.oracle.com/javase/8/docs/technotes/guides/install/install_overview.html,了解如何为您的操作系统安装 Java。

SBT 安装

如同其名,SBT 是一个简单构建工具。从管理所有源文件到它们的编译目标版本,再到下载所有依赖项,SBT 都可以帮助您轻松创建 Scala 应用程序。您可以配置测试用例的运行方式。SBT 提供了各种命令来执行此类任务。

要在您的机器上安装 SBT,请执行以下操作:

  1. 访问www.scala-sbt.org/download.html

  2. 您可以从适合您操作系统的可用选项中进行选择。

安装后,您可以检查版本,因此请打开命令提示符/终端并输入以下内容:

sbt sbt-version
[info] 0.13.11

您应该得到相应的版本号。

Scala REPL

与 Scala 交互的方式不止一种。其中之一是使用 Scala 解释器(REPL)。要使用 SBT 运行 Scala REPL,只需在命令提示符/终端中输入以下命令:

sbt console

此命令将运行 Scala REPL。

要使用 Scala 二进制文件运行 Scala REPL,请执行以下操作:

  1. 前往www.scala-lang.org/download/

  2. 下载最新的 Scala 存档。

  3. 将存档提取到任何目录。

  4. 将目录路径设置为环境变量,如www.scala-lang.org/download/install.html所示。

  5. 尝试运行scala命令,它应该看起来像这样:

图片

如果是这样,恭喜你。你已经做到了。现在它要求你输入任何表达式。你可以尝试输入任何表达式。尝试任何东西,比如 1 + 2 或 1 + "2"。REPL 是你的学习 Scala 的游乐场。

Scala IDEs

在熟悉了 Scala REPL 之后,现在是时候安装 IDE(集成开发环境)了。有选项可以在 IDE 中与 Scala 一起工作。选择最适合你的选项。Eclipse 爱好者可以选择 Scala IDE。下载:

  1. 前往scala-ide.org/download/sdk.html

  2. 你可以从适合你操作系统的可用选项中选择。

如果你习惯于 IntelliJ IDE,你可以下载 SBT 插件。这将使你能够创建 Scala 应用程序。要在 IntelliJ IDE 上开始 Scala 开发:

  1. 前往www.jetbrains.com/idea/download/

  2. 你可以从适合你操作系统的可用选项中选择。

  3. 安装后,转到文件 | IntelliJ IDEA | 首选项 | 插件,并搜索Scala

  4. 点击安装 | 应用。

这样,你就可以在 IntelliJ IDE 上使用 Scala 了。如果你对 IDE 没有偏好,你可以选择最适合你的。我们将使用 IntelliJ IDE(社区版)2017.1 版本、SBT 版本 0.13.15 和 Scala 2.12.2 版本。

运行我们的第一个程序

是时候做一些实际工作了。开始 Scala 项目的推荐方式是使用activator/gitor8种子模板。对于gitor8,你需要 SBT 版本 0.13.13 及以上。使用 SBT,输入命令sbt new并提供模板名称。模板列表可以在github.com/foundweekends/giter8/wiki/giter8-templates/30ac1007438f6f7727ea98c19db1f82ea8f00ac8找到。

为了学习目的,你可以在 IntelliJ 中直接创建一个项目。为此,你首先可以启动 IDE 并创建一个新的项目:

  1. 点击“创建新项目”功能:

图片

  1. 选择 Scala | IDEA 选项并点击下一步:

图片

  1. 输入项目名称、项目位置,选择/定位 Scala SDK,然后完成:

图片

你现在可以编写你的第一个程序了。

让我们编写一些代码:

package lsp

object First {
  def main(args: Array[String]): Unit = {
  val double: (Int => Int) = _ * 2
    (1 to 10) foreach double .andThen(println)
  }
}

上述程序所做的只是打印出从 1 到 10 的数字的双倍值。让我们来分析一下代码。首先,我们给出了名为lsp的包声明. 在下一行中,我们创建了一个名为First的对象 在 Scala 中,对象是一个代码的单例容器,不能接受任何参数。不允许创建对象的实例。接下来,我们使用def关键字定义了作为应用程序入口点的main方法。main方法接受一个字符串数组作为参数并返回Unit. 在 Scala 术语中,Unitvoid相同,它不表示任何类型。

在这个方法的定义中,我们定义了一个函数字面量并使用了它。一个名为double的值是一个类型为Int => Int的函数字面量(也称为匿名函数),读作整数到整数。这意味着这个匿名函数将接受一个整数参数并返回一个整数响应。匿名函数被定义为_ * 2。这里的_(即下划线)是一种语法糖,它推断出任何预期的值,在我们的例子中,它将是一个整数。由于签名(Int => Int)是整数到整数,这个函数字面量被推断为整数值。这个函数字面量应用于整数范围 1 到 10,表示为(1 to 10),为每个整数返回双倍值:

(1 to 10) foreach double .andThen(println)

这一行包含了一些标记。让我们逐个来看。首先是(1 to 10),在 Scala 中这是一种表示范围的方式。它是不可变的,所以一旦生成就不能改变。接下来,使用foreach遍历这个范围。随后,对范围中的每个元素应用double。在应用匿名函数andThen之后,它组合了double的结果并打印出来。通过这个例子,你成功编写并理解了你的第一个 Scala 程序。尽管代码简洁,但还有一些开销是可以避免的。例如,main方法的声明。代码可以写成如下所示:

package lsp

object FirstApp extends App {
 val double: (Int => Int) = _ * 2
  (1 to 10) foreach double .andThen(*print*)
}

这里,相同的代码被写在一个扩展了App特质的对象中。通过扩展可用的App特质,你不必显式地编写main方法。

摘要

本章对我们来说是 Scala 的入门。我们开始学习编程范式。之后,我们讨论了 Scala 相较于其他可用语言的优点。然后我们准备好了我们的开发环境。最后,我们编写了我们的第一个 Scala 程序。

在下一章中,我们将继续我们的 Scala 之旅,学习字面量、数据类型和 Scala 的基本构建块。

第二章:Scala 的构建块

"你不能在薄弱的基础上建造一座伟大的建筑。如果你想有一个强大的上层结构,你必须有一个坚实的基础。"

  • 戈登·B·欣克利

我们作为程序员的目的是通过某种逻辑实现来提供一个问题的解决方案。编程语言正是为此而工作的工具。当我们实现一个问题的解决方案时,我们必须能够描述这个问题(规范),以便编程语言可以验证(验证)该解决方案是否确实解决了问题。

问题解决方案

对于实现,我们使用各种编程结构,这些是具有一些语法规则的基本实体。这些实体作为任何编程语言的构建块。在 Scala 中,我们有与几乎所有其他编程语言相似的语法。我们使用关键字/名称/分类符/绑定实体. 在本章中,我们的目标是熟悉一些构建块。我们将探讨:

  • valvar关键字

  • 文字面量

  • 数据类型

  • 类型推断

  • 运算符

  • 包装类

  • 字符串插值

Scala 程序下面是什么?

Scala 程序是一个嵌套定义的树。一个定义可能以一个关键字、定义的名称、一个分类符开始,如果是具体定义,那么还有一个与该定义绑定的实体。所以语法是规范的,就像任何其他编程语言都有关键字/名称/分类符/绑定实体。让我们举一个例子。我们将使用 Scala REPL 来查看一个简单的 Scala 程序是如何构建的。为此,让我们导入一个名为universe的 Scala 包:

scala> import scala.reflect.runtime.universe._ 
import scala.reflect.runtime.universe._ 

这个import子句将universe包内的所有定义都纳入作用域。这意味着我们将要使用的所需函数都在作用域内,可供我们使用。然后我们将使用一个reify方法,它返回一个Expr来从我们的简单 Scala 程序表达式构建tree。我们向reify方法传递了一个 Scala 类。让我们假装一个 Scala 类封装了一些成员,如名为segment的值和一个name定义。我们将在后续章节中讨论所有这些成员。现在,让我们执行这一行并查看我们得到的响应:

scala> val expr = reify {class Car {val segment="SUV"; def name="Q7"}} 
expr: reflect.runtime.universe.Expr[Unit] = 
ExprUnit = { 
      super.<init>(); 
      () 
    }; 
    val segment = "SUV"; 
    def name = "Q7" 
  }; 
  () 
}) 

上一段代码显示了 Scala 的reify方法的响应。它看起来像是外星代码(目前是这样),我们对此一无所知,所以让我们找出对我们有意义的内容。我们知道它使用我们传递的Car类来生成一些代码。我们认出这个Car类,并且知道它扩展了一个名为AnyRef. 的结构。我们在 Scala 中定义的每个类都是AnyRef的子类,因此我们可以看到解释器已经显示了我们的类定义的显式视图,包括我们定义的修饰符、构造函数和成员。我们将使用showRaw(expr.tree)方法来打印树:

scala> showRaw(expr.tree) 
res0: String = Block(List(ClassDef(Modifiers(), TypeName("Car"), List(), Template(List(Ident(TypeName("AnyRef"))), noSelfType, List(DefDef(Modifiers(), termNames.CONSTRUCTOR, List(), List(List()), TypeTree(), Block(List(Apply(Select(Super(This(typeNames.EMPTY), typeNames.EMPTY), termNames.CONSTRUCTOR), List())), Literal(Constant(())))), ValDef(Modifiers(), TermName("segment"), TypeTree(), Literal(Constant("SUV"))), DefDef(Modifiers(), TermName("name"), List(), List(), TypeTree(), Literal(Constant("Q7"))))))), Literal(Constant(()))) 

现在,我们将更仔细地查看响应 res0. 表达式以 Block 开头这是一个表示我们定义的类的树。我们的类 Car 包含一个名为 segment 的值声明和一个名为 name 的方法. 我们将类表示为树的表示包含我们定义的所有实体。这些共同构成了我们的程序。使用方法 showRaw(tree) 获得的树为我们编写的程序的骨架。这个树包含字符串字面量如 SUVQ7,值定义如 segment,以及其他有意义的结构。我们将在本章学习这些字面量和 Scala 中的数据类型的基本知识。

值和变量

在编写我们的 Scala 程序时,我们可以使用 valvar 关键字来定义我们的成员字段当我们使用 val 关键字将值分配给任何属性时,它就变成了一个值在程序执行过程中不允许更改该值。因此,val 声明用于允许将不可变数据绑定到属性。让我们举一个例子:

scala> val a = 10
a: Int = 10

这里,我们使用 val 关键字和一个名为 a 的属性,并给它赋值 10。此外,如果我们尝试更改该值,Scala 编译器将给出一个错误,说:reassignment to val:

scala> a = 12
<console>:12: error: reassignment to val
    a = 12

Scala 建议尽可能使用 val 以支持不可变性。但如果属性值在程序执行过程中将要改变,我们可以使用 var 声明:

scala> var b = 10
b: Int = 10

当我们使用 var 关键字定义一个属性时,我们可以更改其值。这里的 var 关键字代表变量,它可能随时间变化:

scala> b = 12
b: Int = 12

如果你仔细观察我们的值声明 a,你会发现我们并没有在任何地方提供 类型 信息,但 Scala 解释器仍然能够推断出定义的值的类型,在我们的例子中是一个整数。这是因为 Scala 编译器的类型推断特性。我们将在本章后面学习 Scala 的类型推断。Scala 的编译器能够推断出声明的值的类型。因此,取决于程序员是否希望显式地给出类型信息以增强代码的可读性,或者让 Scala 为他/她完成这项工作。在 Scala 中,我们可以在属性名称之后显式地给出类型:

scala> val a: String = "I can be inferred."
a: String = I can be inferred.

这与我们在 Java 中声明字段的方式略有不同。首先,我们使用 valvar 关键字,然后给出其类型,然后给出一个字面量值。在这里,它是一个 String 字面量。当我们显式地为属性定义类型信息时,我们给出的值应该与指定的类型相符:

scala> val a: Int = "12"
<console>:11: error: type mismatch;
found : String("12")
required: Int
    val a: Int = "12"

上述代码对我们不起作用,因为指定的类型是 Int,而绑定到我们的属性上的字面量是一个 String,正如预期的那样,Scala 抛出了一个类型不匹配错误。现在我们知道绑定到我们的属性上的值是一个字面量,我认为我们已经准备好讨论 Scala 中的字面量了。

字面量

在之前的讨论中,我们已经看到了字符串字面量和整数。在这里,我们将讨论所有可用的字面量,以及如何在 Scala 中定义它们。如果你来自 Java 背景,那么其中相当一部分对你来说将是相同的:整数、浮点数、布尔、字符和字符串是相似的。除了这些之外,元组和函数字面量可以被视为新学习的内容。所有字面量如下所示:

  • 整数字面量

  • 浮点字面量

  • 布尔字面量

  • 字符字面量

  • 字符串字面量

  • 符号字面量

  • 元组字面量

  • 函数字面量

我们将逐一讨论它们。让我们从整数字面量开始。

整数字面量

数值字面量可以表示为十进制、八进制或十六进制形式。这些是基本整数值,可以是带符号或无符号的。自版本 2.10 起已弃用八进制值,因此如果你尝试使用前导0的数值,它将给出编译时错误:

scala> val num = 002
<console>:1: error: Decimal integer literals may not have a leading zero. (Octal syntax is obsolete.)
val num = 002
    ^

如果我们使用前缀0x0X定义字面量,它将是一个十六进制字面量。此外,Scala 解释器将这些值作为十进制值打印出来。例如:

scala> 0xFF
res0: Int = 255

要打印的值被转换为它的十进制等效值,然后打印出来。十六进制字面量可以包含数字(0 到 9)和字母(A 到 F),大小写不敏感。整数字面量进一步分为不同类型,如IntLongByteShort字面量。这些字面量根据值的范围进行划分。以下表格显示了指定类型的最大和最小值:

类型 最小值 最大值
Int -2³¹ 2³¹ - 1
Long -2⁶³ 2⁶³- 1
Short -2¹⁵ 2¹⁵ - 1
Byte -2⁷ 2⁷ - 1

如果我们尝试为指定类型定义超出这些范围的任何字面量,编译器将给出一些错误,指出类型不匹配:

scala> val aByte: Byte = 12
aByte: Byte = 12

在这里,我们定义了一个带有显式类型信息的普通Byte值。如果我们尝试赋予一个超出Byte值范围的值,编译器将尝试将该值转换为整数,然后尝试将其分配给属性,但将无法完成:

scala> val aByte: Byte = 123456
<console>:20: error: type mismatch;
found : Int(123456)
required: Byte
    val aByte: Byte = 123456

这是因为编译器试图将Int(123456)转换后的值分配给Byte类型的aByte,因此类型不匹配。如果我们不显式使用类型,Scala 本身就足以通过类型推断推断出类型。如果我们尝试分配一个属性,一个不属于任何提到的范围的整数值,会怎样呢?让我们试试:

scala> val outOfRange = 123456789101112131415
<console>:1: error: integer number too large
val outOfRange = 123456789101112131415

在这种情况下,Scala 编译器足够聪明,能够感知到情况失控,并给出整数数值过大的错误信息。

要定义long字面量,我们在字面量的末尾放置字符Ll。否则,我们也可以为我们的属性提供类型信息:

scala> val aLong = 909L
aLong: Long = 909

scala> val aLong = 909l
aLong: Long = 909

scala> val anotherLong: Long = 1
anotherLong: Long = 1

可以通过明确告诉解释器类型来定义ByteShort值:

scala> val aByte : Byte = 1
aByte: Byte = 1

scala> val aShort : Short = 1
aShort: Short = 1

浮点字面量

浮点字面量包括一个可以位于开头或数字之间的十进制点,但不能位于末尾。我们的意思是,如果你写下以下语句,它将不会工作:

scala> val a = 1\. //Not possible!

如果你尝试在 Scala REPL 中这样做,表达式将会继续到下一行。一个快速技巧:如果你在 REPL 中给出两个额外的回车,它将开始一个新的命令。但这就是我们无法在 Scala 中创建浮点数的原因,所以现在让我们看看我们如何定义一个DoubleFloat值。默认情况下,Scala 将十进制点值视为Double,如果我们不指定它为Float

scala> val aDoubleByDefault = 1.0
aDoubleByDefault: Double = 1.0

我们可以像对Long字面量那样指定我们的值要为Float类型,但有一个不可见的星号。让我们检查一下这个条件:

scala> val aFloat: Float = 1.0 //Compile Error!
scala> val aFloat: Float = 1.0F //Works
scala> val aFloat: Float = 1.0f //Works

所有的三个都应该工作并给我们返回Float值,但不幸的是,只有后两个返回了Float值。第一个将会返回一个类型不匹配的错误,指出你指定的是Float类型,而你绑定的是Double类型。所以,在 Scala 中,为了指定一个字面量为Float类型,我们必须给出后缀fF

scala> val aFloat: Float = 1.0
<console>:11: error: type mismatch;
found : Double(1.0)
required: Float
    val aFloat: Float = 1.0
        ^

然后,我们可以选择性地追加Dd以表示Double值,但我们很少这样做。浮点字面量也可以包含指数部分。这将是一个eE,后面跟着一个可选的+-,然后是一些数字。Ee表示 10 的幂。所以,一个 3.567e2 的值意味着 3.567 乘以 10²,即 356.7,也就是说,3.567 乘以 100。

布尔字面量

这些很简单,它们表示 0 或 1,这意味着 true 或 false。布尔字面量的基本用途是在比较或条件上操作。这两个被称为布尔字面量,不能被 0 或 1 替换:

scala> val aBool: Boolean = 1
<console>:11: error: type mismatch;
found : Int(1)
required: Boolean
    val aBool: Boolean = 1
        ^

要定义一个布尔值,我们只需给出truefalse

scala> val aBool = true
aBool: Boolean = true
scala> val aBool = false
aBool: Boolean = false

这就是布尔字面量的全部内容。

字符字面量

如果你想要将一些单词和空格分解成单独的字符,你会创建字符字面量。我们用单引号表示字符字面量。任何 Unicode 字符或转义序列都可以表示为字符字面量。顺便问一下,什么是转义序列?以这个backslash为例。如果我们尝试这样做:

scala> val aChar = '\'
<console>:1: error: unclosed character literal
val aChar = '\'

这将完全不工作,因为这个\'是一个转义字符。根据定义,转义序列或字符是在字符串或字符字面量中不表示自身的字符。为了定义这些字符,我们使用这个序列:

scala> val doublequotes = "\""
doublequotes: String = "
scala> val aString = doublequotes + "treatme a string" + doublequotes
aString: String = "treatme a string"

在前面的代码中,我们使用我们的doublequotes作为字符串treatme a string的前缀和后缀,并得到一个响应。

我们在以下表中展示了以下转义序列字符列表:

序列 Unicode
\b 退格 \u0008
\t 水平制表符 \u0009
\r 回车符 \u000D
\n 换行符 \u000A
\f 分页符 \u000C
\" 双引号 \u0022
\\ 反斜杠 \u005C
\' 单引号 \u0027

你也可以使用十六进制代码来表示字符字面量,但我们需要在其前面加上\u

scala> val c = '\u0101'
c: Char = ā

字符串字面量

到目前为止,我们已经在几个地方使用了String字面量。所以在这里,除了对String字面量的正式介绍外,我们还将看看 Scala 中的String字面量是如何不同的,因为写String字面量的方式不止一种。到目前为止,我们已经在双引号内声明了String字面量:

scala> val boringString = "I am a String Literal."
boringString: String = I am a String Literal.

所以让我们从三引号内的String字面量声明开始。听起来很有趣!不是吗?看看这个:

scala> val interestingString = """I am an Interesting String
    | Also span in multiple Lines!
    | Ok, That's it about me"""
interestingString: String =
"I am an Interesting String
Also span in multiple Lines!
Ok, That's it about me"

看到它后有什么想法吗?三引号引起来的字符串可以跨越多行,因此它们被称为多行字符串字面量。这些也被称为原始字符串,因为如果你在三个引号内尝试给出任何转义字符,这些多行字符串字面量会将它们视为普通字符:

scala> val aString = """ / " ' """
aString: String = " / " ' "
scala> println(aString)

/ " '

因此,这些转义字符在多行字符串内部被视为已定义。这可以包含任何字符,甚至是空格。字符串还有很多其他功能,比如字符串插值,我们可以从当前作用域动态地将值分配给字符串。为此,我们使用插值符。我们将在本章稍后进一步研究这些内容。

符号字面量

符号有一个名称,它可以定义为单引号(')后跟字母数字标识符:

scala> val aSymbol = 'givenName
aSymbol: Symbol = 'givenName

scala> aSymbol.name
res10: String = givenName

在正常的 Scala 程序中,我们并不经常使用符号。如果我们试图深入了解 Scala 中的符号,我们会知道我们在 Scala 中定义并命名的每一件事都是符号。我们可以检查符号的绝对类型:

scala> import scala.reflect.runtime.universe._
import scala.reflect.runtime.universe._

scala> typeOf[Symbol]
res12:reflect.runtime.universe.Type= scala.reflect.runtime.universe.Symbol

所以这个Symbol来自 Scala 的反射包。我们不会深入挖掘。如果你好奇的话,我建议你阅读 Scala 文档docs.scala-lang.org/overviews/reflection/symbols-trees-types.html

所以这就是关于符号字面量的全部内容。

元组字面量

元组是 Scala 中的数据类型。我们将在本章后面讨论类型。首先,让我们看看我们如何定义相同类型的字面量:

scala> val aTuple = ("Val1", "Val2", "Val3")
aTuple: (String, String, String) = (Val1,Val2,Val3)
scala> println("Value1 is: " + aTuple._1)
Value1 is: Val1

在这里,我们定义了一个Tuple3,它接受三个参数,这些参数用括号和逗号分隔。它的类型将是Tuple3,就像我们可以用N为 1 到 22 来定义TupleN一样。让我们仔细看看第一个声明的 REPL 响应:

aTuple: (String, String, String) = (Val1,Val2,Val3)

在这里aTupleType (String, String, String),所以当我们为我们的标识符分配值时,aTuple Scala 能够根据我们给出的值构造类型。元组值可以使用特殊下划线语法来访问。在这里,我们使用元组属性名称,以及一个下划线(_),然后是值的索引。在我们的例子中,我们使用val1,所以我们给出了aTuple._1 value

有两个元素的元组也称为,可以使用箭头关联*(->)运算符来定义:

scala> val smartPair = 1 -> "One"
smartPair: (Int, String) = (1,One)

函数字面量

函数字面量是一种表示函数的语法方式。函数的基本结构是能够接受一些参数并返回响应的东西。如果我们必须表示一个接受 Int 值并返回 String 的函数,它将像这样:

Int => String

左边表示输入参数,右边给出响应类型的参数。前置函数字面量的类型是 Function1[Int, String],参数 IntString 分别代表输入和输出参数。我们将在后续章节讨论函数时进一步讨论这些内容。

我们已经讨论了在 Scala 中字面量的表示方式,现在我们知道了这一点,我们就有了继续进行数据类型学习的先机。

数据类型

我们刚刚介绍了 Scala 中的字面量,并且随着这一点,我们几乎涵盖了所有现有数据类型的介绍。我们讨论了如何定义 IntLongShortByte 数据类型。除了这些,我们还涵盖了 FloatDouble 类型。所有这些统称为数值数据类型。ByteShortChar 被称为 子范围类型。我们还讨论了布尔值、字符和字符串:

数值值类型

在 Java 中,这些数值类型被称为 原始类型,然后还有用户定义的类型。但在 Scala 中,这些与原始类型有些相似的类型被称为 值类型。这些值类型的对象在底层运行时系统中不是由对象表示的。因此,执行的计算操作是以 Int 和其他数值值类型定义的方法的形式进行的。想想看,这意味着我们可以对这些执行方法操作。所以,让我们举一个例子:

scala> val x = 10 //x is an object of Type Int
x: Int = 10  //x is assigned value 10

scala> val y = 16 //y is an object of Type Int
y: Int = 16 //y is assigned value 16

scala> val z = x + y //z is addition of x and y's value
z: Int = 26

如您所见,创建了两个名为 xy 的 Integer 对象,还有一个名为 z 的对象。结果是 *z*,即 xy 的和。这里的符号 +Int 对象上的一个方法,这意味着它不仅仅是一个运算符,它是一个为 Int 类型定义的方法,并期望一个 Int 类型的参数。这将有一个类似的定义:

scala> def +(x: Int): Int = ??? //Some definition
$plus: (x: Int)Int

我们从这得到了什么?这意味着这个结构更强大,因为方法看起来更自然,也可以为其他类型编写。这就是为 Int 编写的方式。让我们试试:

scala> val aCharAndAnInt = 12 + 'a'
aCharAndAnInt: Int = 109

这是因为为字符类型有一个重载的 + 方法。类似于这样:

scala> def +(x: Char): Int = ???
$plus: (x: Char)Int

您可以参考类 Int.scalawww.scala-lang.org/api/2.12.0/scala/Int.html,并了解这些方法是如何结构的。我建议您仔细查看这个类的源代码,看看是否有任何特别之处。

Scala 的类层次结构

让我们讨论 Scala 的类层次结构,以及一些存在的额外类型,例如 Scala 中的底层类型。Scala 有一个统一的类型层次结构,这意味着只有一个顶级类 Any,所有其他类型都直接或间接地从这个顶级类型扩展。这也意味着我们定义的任何类或 Scala 中预先存在的类都可以访问顶级类 Any 中定义的通用方法。以下图中显示的关系的两种变体是 子类型视图。前者,子类型 描述了两种类型之间的关系,而后者显示了一种类型可以被转换为另一种类型。视图关系用于值类型,其中 Char 可以转换为 Int

下面的图显示了 Scala 中类之间的关系:

Scala 类层次结构

Any

来自 Scala 官方网站关于所有类 的一个片段:

"*类 Any 是 Scala 类层次结构的根。在 Scala 执行环境中,每个类都直接或间接地继承自这个类。从 Scala 2.10 开始,可以直接使用通用特质扩展 Any。通用特质是一个扩展 Any 的特质,只有 def 成员,并且不进行初始化。"

是的,Any 是 Scala 中所有现有或定义的类的超类。如果你不知道继承或超类是什么,这里有一个快速示例给你。假设我们为我们的新开店铺的订单管理应用程序定义了一个类型 Item。每个 Item 都有一些参数,例如 id。我们进一步想要对商品进行分类,并提出了几个商品类别,例如 ElectronicItem 等。现在,ElectronicItem 可以是 Item 的子类型,而 Item 将被称为 ElectronicItem 的超类型,因此它不需要再次声明这三个参数,可以直接使用它们来赋值。看看下面的例子:

import java.util.UUID

class Item {
 val id: UUID = UUID.randomUUID()
 }

class ElectronicItem(val name: String, val subCategory: String) extends Item {
val uuid: String = "Elec_" + id
}

object CartApp extends App {

 def showItem(item: ElectronicItem) = println(s"Item id: ${item.id} uuid: ${item.uuid} name: ${item.name}")

  showItem(new ElectronicItem("Xperia", "Mobiles"))
  showItem(new ElectronicItem("IPhone", "Mobiles"))
 }

下面的结果是:

Item id: 16227ef3-2569-42b3-8c5e-b850474da9c4 uuid: Elec_16227ef3-2569-42b3-8c5e-b850474da9c4 name: Xperia

Item id: 1ea8b6af-9cf0-4f38-aefb-cd312619a9d3 uuid: Elec_1ea8b6af-9cf0-4f38-aefb-cd312619a9d3 name: IPhone

这个例子展示了我们用继承想要表达的内容。"ElectronicItem 函数扩展 Item" 意味着 "每个 ElectronicItem 都是一个项目。"这就是为什么我们能够从一个 ElectronicItem 实例中引用 ID、UUID 和名称。我们已经使用了 import 语句将 UUID 类型引入我们的编译单元的作用域,所以当我们使用 UUID 时,它不应该在编译时产生错误。

现在,正如我们讨论的那样,每个类都是 Any 的子类。因此,我们可以访问 Any 的所有非私有成员。如 !===asInstanceOfequalsisInstanceOftoStringhashCode 等方法都在 Any 类中定义。这些是以以下形式存在的:

final def  !=  (that: Any): Boolean 
final def  ==  (that: Any): Boolean
def isInstanceOf[a]: Boolean
def equals(that: Any): Boolean
def ##: Int
def hashCode: Int
def toString: String

并且是的!你可以覆盖这些非最终方法,这意味着你可以有自己的定义。

AnyVal 和 AnyRef

AnyValAnyRef 都是根类 Any 的两个子类。这两个代表了 Scala 中的两种类型家族:前者是对象引用,后者是值类。

AnyRef

AnyRef类代表在底层运行时系统中可以表示为对象的全部值。它包括所有明确不继承自AnyVal的用户定义类。一些标准引用类包括StringTupleFunctionArray。Scala 编译器要么为它们提供语法糖,要么在编译时生成特殊代码以执行它们的操作。我们已经看到了一些语法糖,例如Tuple2,它可以表示为(A, B),其中 A 和 B 是类型参数。这种Tuple2的应用形式可以是(StringInt)。在 Scala 中,我们将其表示为Tuple2[String, Int]

AnyVal

后者,AnyVal,代表在底层运行时系统中未实现为对象的值。Scala 有一些预定义的数值和非数值值类,如类层次结构所示。

可以定义用户自定义的值类型,但需要满足一些条件。记得我让你仔细查看Int.scala文件源码吗?你可能已经注意到,在扩展AnyVal.Int类中没有valvar声明。这是定义AnyVal.子类型的一个约束。你可能考虑定义AnyVal类型的原因之一是避免在运行时进行对象实例化。一些约束包括:

  • 它必须有一个单一的val参数作为底层表示。这意味着如果你声明class Num(val underlying: Int) extends AnyVal那么它的编译时表示将是Num类型,但在运行时它将被转换为Int,并且定义在其中的方法将被用作静态方法。

  • 它必须只定义def,不能有valvar、嵌套类、特质或对象。

  • 它只能扩展通用特质,即只扩展超类型Any的特质。

  • 它不能用于模式匹配或类型测试。

Scala 的AnyVal实现包括九种实现。其中,ByteShortIntLongCharFloatDouble是数值值类型,而BooleanUnit是非数值类型。

Unit

Scala 的UnitAnyVal的子类型,其实现包含一个equals方法,如果传入的参数也是一个Unit(即一个值()),则返回 true 值,否则返回 false。其他方法包括hashCodetoString,分别返回实现特定的哈希码和(),因为Unit只有一个值:(),这与 Java 的void类型等价。

Boolean

布尔值代表两个值:truefalse。正如预期的那样,它实现了布尔算术方法,如andorstrict andstrict orequalityinequalitynegation,分别以&&||&|==!=unary_!的形式。布尔值还实现了来自Any类的equalshashCodetoString方法。

等于方法检查参数评估并返回其结果,而hashCode方法则返回基于值truefalse的固定实现特定哈希码。

Null 和 Nothing

在 Scala 中,Null 和 Nothing 被称为底类型。为什么我们需要这些底类型呢?看看以下代码片段:

def checkIF10AndReturn20(x: Int): Int =  {
  if(x == 10)
    x * 2
  else 
    throw new Exception("Sorry, Value wasn't 10")
 }

方法checkIf10AndReturn20期望返回Int类型的值,但这里发生的情况不同。如果传递的参数值不是 10,我们会抛出异常,然而编译器对我们的代码仍然满意。这是怎么可能的?

这是因为类型推断。它总是在if语句的两个分支中寻找公共类型,所以如果在另一个分支中,类型扩展了一切,那么推断的类型将自动是第一个。在 Scala 中,Nothing是所有类型的子类型,因此推断的类型自动变为Int类型。让我们可视化这个:

图片

可视化推断的类型

因此,了解类型推断在 Scala 生态系统中的重要作用是很重要的。

类型推断

我们可以将类型推断称为 Scala 的一个内置功能,允许我们在编写代码时省略类型信息。这意味着我们不需要在声明任何变量时指定类型;Scala 编译器会为我们完成:

scala> val treatMeAString = "Invisible"
treatMeAString: String = Invisible

我们没有指定val的类型为String,但看到Invisible的值后,Scala 编译器能够推断其类型。此外,在某些约束下,我们还可以省略方法的返回类型:

defcheckMeImaString(x: Boolean) = if(x) "True"else "False"

在这里我们没有指定返回类型,因为 Scala 编译器能够推断其类型。但对于递归方法,这不起作用。著名的阶乘方法期望你在实现递归时指定返回类型:

def recursiveFactorial(n: Int) = if(n == 0) 1 else recursiveFactorial(n-1) 
//Recursive method recursiveFactorial needs result type

Scala 使用基于约束的算法来实现类型推断。这意味着 Scala 编译器试图推断约束,然后尝试统一类型。我们正在谈论约束,那么它们是什么呢?约束是关于表达式类型的陈述。即使它不是一个表达式,例如,当我们将值绑定到变量时,我们也必须推断它们的类型。但首先考虑一下我们能从表达式类型中推断出什么:

  • 如果它与某些标识符的类型相关

  • 如果它与某些其他表达式的类型相关

  • 如果它是一个基本类型,例如数字和布尔值

  • 如果它是一个构造类型,例如一个函数,其域和范围类型进一步受到约束

Scala 编译器使用这种方法来推断约束,然后应用统一(解释超出了本书的范围)来推断类型。在无法从表达式提取任何语句的情况下,推断类型是不可能的:

scala> val x = x => x
<console>:11: error: missing parameter type
       val x = x => x

由于仅类型推断,我们能够使用语法糖来处理不需要指定类型的情况:

scala> List(1,4,6,7,9).filter(_+1 > 5)
res0: List[Int] = List(6, 7, 9)

激动人心的,不是吗?这种方式,我们用更少的类型信息简单地执行了逻辑。这里使用的下划线(_)是语法糖,并且由于类型推断,这里可以使用。

我们将继续我们的良好工作,学习如何实现这一点,使用所有这些类型执行操作,并加强我们的基础知识。

Scala 中的运算符

根据我们使用它们的方式,Scala 运算符可以分为三种类型:

  • 中缀运算符

  • 前缀运算符

  • 后缀运算符

我们使用运算符在操作数上执行某些操作,这是显而易见的,而我们实现的方式使它们成为中缀、前缀或后缀。一个基本的中缀运算符示例是加法+

scala> val x = 1 + 10
x: Int = 11

我们有两个操作数(1 和 10)在这个加法运算上执行。我们已经讨论过,运算符是方法。这意味着某种方式,操作是以1.+(10)的形式执行的,而1 + 10只是我们如何书写它的语法糖。这是因为加法方法为给定的类型定义了。在这里,在我们的例子中,加法(+)方法为Int. 除此之外,还有几个重载方法的版本,支持其他数值类型。这意味着我们可以传递任何其他类型,只要该方法的重载版本存在,它就会执行正常的加法运算:

scala> val y = 1 + 'a'
y: Int = 98

这里,调用了方法def+(arg: Char): Int并返回了一个Int。想想看,如果这些方法不是原生的 Scala 运算符而是方法,那么我们也可以创建类似这些作为运算符工作的方法。这让你感觉很有力量。让我们试试这个:

class Amount(val amt: Double) {

  def taxApplied(tax: Double) = this.amt * tax/100 + this.amt

 }

object Order extends App {
  val tax = 10
  val firstOrderAmount = 130

  def amountAfterTax(amount: Amount) = amount taxApplied tax

  println(s"Total Amount for order:: ${amountAfterTax(new Amount(firstOrderAmount))}")
 }

以下结果是:

Total Amount for order:: 143.0

太棒了!taxApplied是我们定义的第一个运算符,它针对的类型是Amount. 我们程序中有一个名为Amount的类,它只是一个Double值,并定义了一个名为taxApplied. 这个方法期望一个用于应用在this上的tax双精度值,this将是金额的当前值。运算符是我们使用方法的一种方式,这就是为什么我们有这个运算符。我们在Order对象中定义函数amountAfterTax: 时使用了它:

> amount taxApplied tax

它也可以写成amount.taxApplied(tax). 在 Scala 中也有一些例子;例如,在String上工作的indexOf运算符:

scala> val firstString = "I am a String"
firstString: String = I am a String

scala> firstString indexOf 'a'
res1: Int = 2

我们已经讨论了中缀运算符,其中运算符位于两个操作数之间。现在让我们看看另一种使用运算符的方式,那就是前缀后缀。第一个,前缀运算符位于操作数之前。这些运算符的例子有-!等等:

scala> def truthTeller(lie: Boolean) = !lie
truthTeller: (lie: Boolean)Boolean

scala> truthTeller(false)
res2: Boolean = true

这里,!lie: 使用了前缀运算符!,这是我们放置操作数在运算符右侧的方式。但这是作为一个方法调用的。在后台发生的事情是 Scala 使用unary_来调用这些运算符,这是显而易见的,因为这些运算符只使用一个操作数。所以我们的实现看起来像以下这样:

scala> def truthTeller(lie: Boolean) = lie.unary_!
truthTeller: (lie: Boolean)Boolean

运算符 ! 是为布尔类型定义的,因此我们可以在布尔类型上调用。另一种方式是操作数位于左侧,称为 后缀 运算符。这些运算符的例子包括转换器,如 toLowerCasetoInttoString 等:

scala> 1.toString
res4: String = 1

scala> "1".toInt
res5: Int = 1

scala> "ABC".toLowerCase
res7: String = abc

这意味着这些运算符是在相应的类型中定义为方法的。这是在 Scala 中对运算符进行分类的一种方式。现在我们将快速查看根据它们在编程语言中使用时的上下文对运算符类型进行分类。这些基本上被分类为:

  • 算术运算符

  • 关系运算符

  • 逻辑运算符

  • 位运算符

算术运算符

我们可以使用算术运算符进行算术运算。算术运算符包括加法 (+),减法 (-),乘法 (*),除法 (/),和取余 (%)。我们已经看到了很多加法的例子,不提这些运算符也是方法!

让我们看看其他例子:

scala> val x = 10 - 1
x: Int = 9

scala> val y = 10 * 1
y: Int = 10

scala> val z = 10 / 1
z: Int = 10

scala> val yx = 10 % 9
yx: Int = 1

这些运算符也有它们重载的版本定义,为了看到我们可以用不同的类型作为操作数。让我们拿一个 Int 和一个 Double

scala> val yx = 10 % 9.0
yx: Double = 1.0

这里,第一个操作数是 Int 类型,第二个操作数是 Double 类型,由于 Int 类型可以看作是 Double 类型的子集,所以结果会被转换为 Double 类型。

关系运算符

关系运算符用于比较两个操作数。我们有相当多的这些,==,!=,>,<,>= 和 <=。让我们试试:

scala> val equal_op = 10 == 10
equal_op: Boolean = true

scala> val not_eq_op = 10 != 10
not_eq_op: Boolean = false

scala> val gt_than_op = 10 > 10
gt_than_op: Boolean = false

scala> val gt_than_op = 11 > 10
gt_than_op: Boolean = true

scala> val lt_than_op = 11 < 10
lt_than_op: Boolean = false

scala> val gt_eq_op = 11 >= 11
gt_eq_op: Boolean = true

scala> val lt_eq_op = 11 <= 11
lt_eq_op: Boolean = true

使用这些运算符,我们比较两个操作数的值,这些操作会得到一个布尔结果。

逻辑运算符

逻辑运算符包括 !(非),&&(与),和 ||(或),显然我们使用这些来对操作数执行逻辑运算。这些方法是为布尔类型编写的,因此它们期望布尔类型的操作数:

scala> val log_not = !true
log_not: Boolean = false

scala> val log_or = true || false
log_or: Boolean = true

scala> val log_and = true && true
log_and: Boolean = true

逻辑与和或都是短路运算符。这意味着这些运算符只会在结果未确定时进行评估。在 Scala 中,即使运算符是方法,也可以实现这一点,因为函数调用有一个名为 按名参数 的特性。它允许我们通过名称传递参数,这些参数在方法调用时需要时才会进行评估。

位运算符

我们可以使用位运算符对整型类型的单个位进行操作。这些包括位与 (&),位或 (|),和位异或 (^):

scala> 1 & 2
res2: Int = 0

scala> 1 | 2
res3: Int = 3

scala> 1 ^ 2
res5: Int = 3

这些运算符只能在 Int 上执行。如果你在 Double 上尝试这样做,将会抛出一个错误:value & 不是 Double 的成员. 这些运算符对单个位执行操作;在我们的例子中,1 被转换为位 01,2 被转换为位 10,然后执行 AND,OR 和 XOR 操作:

  • 0001 AND 0010 结果为 00 表示 0

  • 0001 OR 0010 结果为 11 表示 3

  • 0001 XOR 0010 结果为 11 表示 3

我们可以使用 ~ 运算符执行逻辑非操作:

scala> ~2
res8: Int = -3

对于Int类型,还有三种名为右移(>>), 左移(<<)和无符号右移(>>>)的位移方法。这些是作用于两个操作数的二进制运算符。操作数左侧的位根据值向右移动。

运算符优先级

如果没有评估这些运算符的规则,像2 + 3 * 4 / 2 - 1这样的运算可能会得到不同的结果。因此,我们有一些基于优先级的规则。我们将在本部分讨论它:

scala> 2 + 3 * 4 / 2 - 1
res15: Int = 7

为了参考目的,我们有了前面的表达式。评估返回结果*7。它是如何做到的?

表达式(2 + ((3 * 4) / 2))) - 1将被按以下步骤评估:

  1. (2 + (12 / 2)) - 1

  2. (2 + 6) - 1

  3. 8 - 1

  4. 7

它基于运算符优先级。顺序如下:

运算符优先级

如前图所示,Scala 运算符表达式是根据显示的优先级评估的。根据图示,*****, /, 和 % 具有最高优先级,然后是 +-

其他运算符也遵循相同的优先级。如果相同优先级级别的运算符一起出现,则从左到右评估操作数。这意味着表达式1 + 2 + 3 * 3 * 4 - 1的结果将是38

scala> 1 + 2 + 3 * 3 * 4 - 1
res16: Int = 38

表达式(1 + 2) + ((3 * 3) * 4) - 1将被按以下步骤评估:

  1. 1 + 2 + 9 * 4 - 1

  2. 1 + 2 + 36 - 1

  3. 3 + 36 - 1

  4. 39 - 1

  5. 38

这为 Scala 中表达式的评估提供了清晰性。

包装类

在 Scala 中,我们可以创建自己的宇宙,除了提供的原生方法外,我们还可以添加自己的实现,这些我们称之为富包装类。这是由于隐式转换的可能。首先,我们将列出一些已经可用的包装类:

富包装

要了解它是如何发生的,让我们看一个例子:

scala> val x = 10
x: Int = 10

scala> x.isValidByte
res1: Boolean = true

前面的表达式试图检查x的值是否可以转换为Byte,并检查其是否在Byte的范围内,并发现它是true

scala> val x = 260
x: Int = 260

scala> x.isValidByte
res2: Boolean = false

scala> val x = 127
x: Int = 127

scala> x.isValidByte
res3: Boolean = true

如你所知,Byte的范围是-128 到 127。如果你尝试将其分配给超出Byte范围的值,并期望它像Byte一样行为,它将不起作用。因此,前面表达式的结果是false

除了这个isValidByte之外,类RichByte包装类中还有许多实用方法。

这些包装方法看起来像是为现有类型原生定义的。其中一个例子是围绕String的包装,即StringOps。在 Scala 中,String不过是一个java.lang.String的实例,所以很明显,为java.lang.String实现的所有方法在这里也同样适用。例如,charAt方法在这里表现得相当不错:

scala> val x = "I am a String"
x: String = I am a String
scala> x.charAt(5)
res13: Char = a

现在让我们尝试一些StringOps的方法:

scala> x.capitalize
res14: String = I am a String

scala> x.toUpperCase
res15: String = I AM A STRING

scala> x.toLowerCase
res16: String = i am a string

capitalizetoUpperCasetoLowerCase 这三个方法定义在 StringOps 类中,而不是 String 类中,但它们仍然以相同的方式工作,就像调用 String 类型的本地方法一样。还有更多这样的方法,它们作为 String 的实用方法工作。这是因为 隐式转换 的力量。我们将在后面的章节中学习 Scala 中的 隐式 如何工作。

Int 类型创建 Range 类的一种方法可以通过使用一个 方法 实现。我们称这些为 丰富的方法。使用它们非常简单,根据它们解决的问题,我们也可以给它们命名:

scala> val rangeOfNumbers = 1 to 199
rangeOfNumbers: scala.collection.immutable.Range.Inclusive = Range 1 to 199

scala> val rangeOfNumbersUntil = 1 until 199
rangeOfNumbersUntil: scala.collection.immutable.Range = Range 1 until 199

scala> rangeOfNumbers contains 1
res17: Boolean = true

scala> rangeOfNumbersUntil contains 1
res18: Boolean = true

scala> rangeOfNumbersUntil contains 199
res19: Boolean = false

scala> rangeOfNumbers contains 199
res20: Boolean = true

以下是一些来自 Range 类的方法的例子,它们为 Int 提供了创建 Range 的丰富方法。Range 可以包含这些值,也可以构建时包含或排除这些值。构建这些值的函数是 tountil. 第一个包含我们用于构建 Range 的两个值;后者只包含起始值。我们已经尝试了所有这些。如您所见,rangeOfNumbersUntil 不包含 199。我们也可以创建具有某些 step 差别的 Range

scala> 1 to 10 by 2 foreach println

以下结果是:

1
3
5
7
9

这很简单;既简单又漂亮。特别是由于后端发生的 隐式转换类型推断,我们能够以简洁的语法编写。Scala 编译器正在处理所有这些部分,让我们只需以美观的方式编写代码。在编写字符串时利用简洁性的另一种方法是使用字符串插值器。

字符串插值器

我们已经使用了字符串插值器,当它们可用时很难避免使用它们。记得我们什么时候使用过它们吗?是的!当我们学习自己创建运算符时:

println(s"Total Amount for order:: ${amountAfterTax(new Amount(firstOrderAmount))}")

这里的 s 是一个字符串插值器。在使用这些插值器时,我们在 String 前面加上一个名为 s 的字符,并在字符串的双引号内可以使用任何带有 $ 的变量,它将被其值替换:

scala> val myAge = s"I completed my $age."
myAge: String = I completed my 25.

这是我们如何使用插值器的例子。s 并不是 Scala 中唯一的插值器。我们还有一些其他的插值器。我们将逐一介绍它们。

  • s 插值器

  • f 插值器

  • raw 插值器

s 插值器

首先,我们将查看 s 插值器。我们已经看到了如何使用变量创建处理过的字符串。现在,让我们举一个使用表达式的例子:

scala> val nextYearAge = s"Next Year, I'll complete ${age + 1}."
nextYearAge: String = Next Year, I'll complete 26.

在这里,我们使用了 ${...},其中 $ 符号后面跟着一对花括号 {},包含要评估的表达式。它可以是一个任何表达式。就像我们刚才做的算术运算,或者方法调用:

scala> def incrementBy1(x: Int) = x + 1
incrementBy1: (x: Int)Int

scala> val nextYearAge = s"Next Year, I'll complete ${incrementBy1(age)}."
nextYearAge: String = Next Year, I'll complete 26.

在这里,我们定义了一个名为 incrementBy1 的方法,该方法将任何传入的 Int 增加 1 并返回结果。我们已经从我们的插值器中调用了该方法。顺便说一下,了解我们的插值器 s 就像 Scala 中的任何其他运算符一样是一个方法。我们在 Scala 中可以创建自己的插值器。

f 插值器

要在 Scala 中实现类似于printf样式的格式化,我们可以使用f插值器。我们这样做是通过在字符串的双引号前使用一个f,然后在字符串内部我们可以使用一个格式说明符:

scala> val amount = 100
amount: Int = 100
scala> val firstOrderAmount = f"Your total amount is: $amount%.2f"
firstOrderAmount: String = Your total amount is: 100.00

从前面的例子中可以看出,我们使用f作为字符串的前缀,并使用$后跟包含格式说明符的表达式。这作为我们的字符串格式化器工作。

一些格式说明符如下所示:

图片

格式说明符

原始插值器

Scala 中预存的最终插值器之一是raw插值器。这个插值器不允许你在字符串中使用任何转义序列字符,这意味着如果你尝试给出一个转义序列字符,它将被视为一个普通字面量,不再有其他作用。我们编写raw插值器的方式几乎与其他两个插值器相似。我们在字符串前加上一个raw关键字,它就会为我们工作:

scala> val rawString = raw"I have no escape \n character in the String \n "
rawString: String = "I have no escape \n character in the String \n "

在这里,字符串中的转义字符\n被视为一个普通字面量,在生成的字符串中保持不变。在一个普通字符串中,\n会转换成换行字符。

scala> val rawString = "I have no escape \n character in the String \n "
rawString: String =
"I have no escape
character in the String
"

使用这个原始插值器,我们可以避免转义序列。这些结构为我们提供了一种更高效、更简洁地编写代码的方法。

概述

因此,让我们总结一下在本章中学到的内容。我们从最基本的valvar变量结构开始。然后,我们学习了如何编写字面量,以及 Scala 中有哪些数据类型。接着,我们研究了 Scala 中有趣的类层次结构,其中我们讨论了统一类层次结构和值类型和引用类型类。后来,我们学习了 Scala 中类型推断最重要的概念之一。之后,我们开始学习运算符及其优先级。我们学习了包装类如何为我们程序员提供所有丰富的功能。最后,我们学习了一种简单但实用的方法,即使用插值器处理我们的字符串字面量。现在,很明显,通过练习我们所学到的所有这些结构,我们将准备好进一步学习一些逻辑和循环结构,这些结构塑造了我们的程序。

在下一章中,我们将讨论 Scala 中的一些循环结构、逻辑结构,以及模式匹配的介绍和如何使用模式匹配和启用程序流程控制来加强我们的程序在逻辑基础上的能力。

第三章:塑造我们的 Scala 程序

"我得出结论,构建软件设计有两种方式:一种方式是让它如此简单,以至于显然没有缺陷;另一种方式是让它如此复杂,以至于没有明显的缺陷。第一种方法要困难得多。"

  • C. A. R. 霍尔

程序是对特定问题的解决方案。我们编写的解决方案被分解为不同的任务。任务可以是特定于一个步骤或解决方案的一部分。在为这样的任务编码时,我们使用构造作为工具来使它们更容易、更易读、更高效。这些工具使您能够塑造手中的程序。

for, while, and do while loops. We'll see how these loops work in Scala. From there, we'll have a quick look at for expressions. We'll also go through the FP (functional programming) way of doing loops through recursion. Then, we'll start taking a look at Scala's conditional statements if and end, with learning how we can shape up program flow using pattern matching. Here's a quick card for what's in there for us in this chapter:
  • 循环

    • forwhiledo while 循环
  • for 表达式:快速浏览

  • 递归

  • 条件语句

    • ifif else
  • 模式匹配

循环

站在打印机前,你给它一个指令,让它从你的书中打印索引为 2 到 16 的页面。这个被编程来这样做打印机,使用一个算法为你打印页面;它检查你请求打印的文档和页数。它将起始点设置为 2,最后一个点设置为 16,然后开始打印,直到达到最后一个点。打印页面我们可以称之为重复性的,因此,使用循环结构来编程打印你的文档中的每一页是很好的。就像任何其他语言一样,Scala 支持 forwhiledo while 循环。

看看以下程序:

object PagePrinter extends App {

   /*
    * Prints pages page 1 to lastIndex for doc
    */
   def printPages(doc: Document, lastIndex: Int) = ??? //Yet to be defined

   /*
    * Prints pages page startIndex to lastIndex for doc
    */
   def printPages(doc: Document, startIndex: Int, lastIndex: Int) = ???

   /*
    * Prints pages with given Indexes for doc
    */
   def printPages(doc: Document, indexes: Int*) = ??? 

   /*
    * Prints pages 
    */ 
  private def print(index: Int) = println(s"Printing Page $index.")

 }

  /*
   * Declares a Document type with two arguments numOfPages, typeOfDoc
   */
 case class Document(numOfPages: Int, typeOfDoc: String) 

我们创建了一个名为 PagePrinter 的对象。我们使用 /* ... */ 语法来声明多行注释,并在 Scala 中使用 // 来声明单行注释。我们声明了三个方法,这些方法应该执行注释中所述的操作。这些方法尚未定义,并且我们已经通过使用语法 "???"(即三个问号符号)通知 Scala 编译器我们尚未定义该方法。

让我们回到我们的方法。第一个方法接受一个文档和要打印的页数作为参数,并打印到传递的索引页。第二个方法接受要打印的页面的起始和结束索引,并执行打印。第三个方法可以接受随机索引来打印,并从这些索引打印页面。在第三个方法中,我们使用星号 * 将我们的 Int 参数作为可变参数,即变量参数。现在,任务是定义这些方法。我们还可以看到,为了定义什么是文档,我们使用了案例类——我们将在下一章深入探讨 Scala 的面向对象部分时学习案例类。现在,了解案例类将很有帮助,它允许您创建一个带有所有样板代码的类;这意味着您可以访问成员,在我们的例子中,是 numOfPagestypeOfDoc。嗯,关于案例类有很多东西要了解,但我们稍后再谈。我们将使用我们的循环结构来定义我们的 PagePrinter

让我们看看我们的循环结构。我们首先将了解 for 循环。

for 循环

在 Scala 中,for循环,也称为for推导式,接受一系列元素,并对它们中的每一个执行操作。我们可以使用它们的一种方式是:

scala> val stocks = List("APL", "GOOG", "JLR", "TESLA") 
stocks: List[String] = List(APL, GOOG, JLR, TESLA) 

scala> stocks.foreach(x => println(x))
APL 
GOOG 
JLR 
TESLA 

我们定义了一个名为stocks的列表,其中包含一些股票名称。然后我们使用一个简单的for循环来打印出列表中的每个股票。看看语法:我们有stock <- stocks,它表示在生成器符号<-左侧列表中的一个单一值,以及右侧的列表或序列。然后最终,我们可以提供任何要执行的操作,在我们的例子中我们打印了名称。现在我们已经看到了如何编写一个简单的for循环,让我们定义我们的printPages方法集:

object PagePrinter extends App{

   /*
    * Prints pages page 1 to lastIndex for doc
    */
   def printPages(doc: Document, lastIndex: Int) = if(lastIndex <= doc.numOfPages) for(i <- 1 to lastIndex) print(i)

   /*
    * Prints pages page startIndex to lastIndex for doc
    */
   def printPages(doc: Document, startIndex: Int, lastIndex: Int) = if(lastIndex <= doc.numOfPages && startIndex > 0 && startIndex < lastIndex) for(i <- startIndex to lastIndex) print(i)

   /*
    * Prints pages with given Indexes for doc
    */
 def printPages(doc: Document, indexes: Int*) = for(index <- indexes if index <= doc.numOfPages && index > -1) print(index)

  /*
   *  Prints pages
   */
   private def print(index: Int) = println(s"Printing Page $index.")

   println("---------Method V1-----------")
   printPages(Document(15, "DOCX"), 5)

   println("---------Method V2-----------")
   printPages(Document(15, "DOCX"), 2, 5)

   println("---------Method V3-----------")
   printPages(Document(15, "DOCX"), 2, 5, 7, 15)

 }

/*
  * Declares a Document type with two arguments numOfPages, typeOfDoc
  */
 case class Document(numOfPages: Int, typeOfDoc: String) 

以下是输出:

---------Method V1----------- 
Printing Page 1\. 
Printing Page 2\. 
Printing Page 3\. 
Printing Page 4\. 
Printing Page 5\. 
---------Method V2----------- 
Printing Page 2\. 
Printing Page 3\. 
Printing Page 4\. 
Printing Page 5\. 
---------Method V3----------- 
Printing Page 2\. 
Printing Page 5\. 
Printing Page 7\. 
Printing Page 15\. 

我们有一个名为print的实用方法,它只是打印一个带有索引数字的简单字符串,尽管你可以自由想象一个真正的打印机打印页面。

我们对printPages方法版本 1 的定义仅仅包括一个条件检查,即文档是否包含要打印的页面。这是通过一个if条件语句完成的。关于if语句的更多内容将在本章后面介绍。在条件语句之后,有一个循环遍历索引,范围从 1 到传递的lastIndex。同样,其他方法版本 2 也是定义的,它接受startIndexlastIndex并为你打印页面。对于printPages的最后一个方法版本 3,我们正在遍历传递的索引,并且有一个以if语句开始的条件保护器。这检查页面索引是否小于作为参数传递的文档中的页面数,并打印它。最后,我们得到了从我们的方法中期望的结果。

while 循环

如同在大多数其他语言中一样,while循环是另一种循环结构。while循环可以在满足条件之前执行任何重复任务。这意味着提供的条件必须为真,代码执行才能停止。while循环的通用语法是:

while (condition check (if it's true))  
        ... // Block of Code to be executed 

需要检查的条件将是一个布尔表达式。当条件为false时,它将终止。我们可以使用它们的一种方式是:

scala> val stocks = List("APL", "GOOG", "JLR", "TESLA") 
stocks: List[String] = List(APL, GOOG, JLR, TESLA) 

scala> val iteraatorForStocks = stocks.iterator 
iteraatorForStocks: Iterator[String] = non-empty iterator 

scala> while(iteraatorForStocks.hasNext) println(iteraatorForStocks.next()) 
APL 
GOOG 
JLR 
TESLA 

我们使用我们的股票列表和一些股票名称。然后我们在列表上调用iterator方法来获取我们序列的迭代器。在这里,iteraatorForStocksType Iterator[String]的非空迭代器,我们可以用它来遍历列表。迭代器有hasNext方法来检查序列中是否还有剩余的组件。在迭代器上调用next将给出结果元素。我们通过迭代股票列表的元素来打印。让我们看看do while循环。

do while 循环

do while循环与while循环没有太大区别。do while循环的通用语法是:

do
        ... // Block of Code to be executed 
        while(condition check (if it's true)) 

do while循环确保代码块至少执行一次,然后检查在while表达式中定义的条件:

scala> do println("I'll stop by myself after 1 time!") while(false) 

以下是结果:

I'll stop by myself after 1 time! 

这是一个简单的例子,我们的声明在while循环的条件变为false之前只打印了一次。这就是我们如何在 Scala 中使用do while循环的方式。

你可能想尝试使用while循环和do while循环的PagePrinter示例。

for表达式

我们已经看到了for循环,以及如何在 Scala 中使用它们是多么简单。我们可以用for语法做更多的事情。以下是一个例子:

object ForExpressions extends App {

   val person1 = Person("Albert", 21, 'm')
   val person2 = Person("Bob", 25, 'm')
   val person3 = Person("Cyril", 19, 'f')

   val persons = List(person1, person2, person3)

   for {
     person <- persons
     age = person.age
     name = person.name
     if age > 20 && name.startsWith("A")
   } {
     println(s"Hey ${name} You've won a free Gift Hamper.")
   }

 case class Person(name: String, age: Int, gender: Char)
 }

以下结果是:

Hey Albert You've won a free Gift Hamper. 

在前面的例子中,我们在for表达式中使用了生成器、定义和过滤器。我们在人员列表上使用了一个for表达式。我们为名字以A开头且年龄超过 20 岁的人提出了一个礼品篮。

for中的第一个表达式是一个生成器表达式,它从人员列表中生成一个新的个人,并将其分配给person。第二个是年龄和名字的定义。然后最后,我们使用if语句应用过滤器来为我们的获胜者设置条件:

for表达式

如果我们想要为我们的员工增加一些奖品,那么我们可能想要获取获胜者的子列表。通过引入yield.这是可能的。

for yield表达式

以下是一个for yield表达式的例子,我们在这里列出了获胜者的名字。获奖的条件是年龄,应该超过 20 岁:

object ForYieldExpressions extends App {

   val person1 = Person("Albert", 21, 'm')
   val person2 = Person("Bob", 25, 'm')
   val person3 = Person("Cyril", 19, 'f')

   val persons = List(person1, person2, person3)

   val winners = for {
     person <- persons
     age = person.age
     name = person.name
     if age > 20
   } yield name

   winners.foreach(println)

  case class Person(name: String, age: Int, gender: Char)
 }

以下结果是**:

Albert
Bob

在这里,yield起到了作用,并产生了一个满足条件的个人列表。这就是for yield表达式在 Scala 中的工作方式。

但这些迭代并不是 Scala 或其他任何函数式编程语言所推荐的。让我们来看看为什么是这样,以及迭代循环的替代方案。

递归

递归是一个函数对自己的调用。简单来说,递归函数是一个调用自身的函数。函数式编程推荐使用递归而不是迭代循环结构。出于同样的明显原因,Scala 也推荐使用递归。让我们首先看看一个递归函数:

object RecursionEx extends App {

   /*
   * 2 to the power n
   * only works for positive integers!
   */
 def power2toN(n: Int): Int = if(n == 0) 1 else 2 * power2toN(n - 1)

   println(power2toN(2))
   println(power2toN(4))
   println(power2toN(6))
 } 

以下结果是:

4 
16 
64 

我们定义了一个名为power2toN的函数,它期望一个整数n,检查n的值,如果它不是 0,则函数会调用自身,递减n整数的值,直到n变为 0。然后,在每次递归调用中,将值乘以 2,以获得所需的结果。

考虑以下内容:

def power2toN(n: Int) = if(n == 0) 1 else (2 * power2toN(n - 1)) 

Scala 编译器会报错,指出Recursive method power2N needs result type.这是 Scala 编译器的一个必要条件。我们必须显式地定义递归函数的响应类型——这就是为什么我们必须在方法定义中给出返回类型的原因。

为什么递归比迭代更好?

根据声明函数式编程推荐递归而不是迭代,让我们讨论一下为什么是这样。如果你仔细看看我们的定义:

def power2toN(n: Int): Int = if(n == 0) 1 else 2 * power2toN(n - 1) 

函数定义由一些条件语句和最终对自身的调用组成。没有任何变量状态的突变。函数式编程推荐纯函数,这意味着没有副作用。副作用可能包括突变一个变量的状态,执行 I/O 操作。这在迭代中是不可能的。迭代由其计数器/索引变量的突变组成,在重复中突变。另一方面,递归可以在不执行任何此类状态变化的情况下完成。这使得它强大且在函数式语言中使用。通过递归函数执行的操作可以使用所有多核执行它们的强大功能,而不用担心不同线程会改变相同的变量状态。因此,递归是推荐的。但递归中有一个问题。

递归的限制

在较小的重复次数或较少的函数调用级别时,递归被认为是可行的,但随着级别的增加,最终会导致栈被填满。那是什么意思?

程序中的函数调用会在调用栈中添加一个新的元素来调用。调用栈跟踪函数调用的信息。对于每次递归调用,都会在栈中添加一个新的调用,因此对于较少的递归调用来说,它运行良好。但随着递归调用级别的加深,调用栈达到其极限,程序会终止。这是意料之外的事情,破坏了我们的程序。那么,我们应该避免递归还是使用它?

编写递归函数的理想方式

递归的复杂性在于调用栈空间的填充。如果我们找到一种方法,可以在每次递归调用中释放当前栈,并用于所有后续的递归调用,我们可以在一定程度上优化调用栈的使用,这可以导致递归函数性能的更好。让我们这样理解:我们有我们的递归函数power2N的定义:*

if(n == 0) 1 else 2 * power2toN(n - 1) 

如定义所示,在对自己进行调用之后,power2toN需要跟踪其调用栈,因为它的结果需要乘以 2 以完成步骤并得到期望的结果。为了避免这种情况并有效地使用调用栈,我们可以在递归函数的最后一个步骤中定义一个辅助函数。换句话说,如果我们使我们的函数调用成为尾调用,我们将能够优化调用栈的使用,从而实现更好的递归。这种现象称为尾调用优化:

package chapter3

import scala.annotation.tailrec

 object TailRecursionEx extends App {

   /*
    * 2 to the power n
    * @tailrec optimization
    */
   def power2toNTail(n: Int): Int = {
     @tailrec
     def helper(n: Int, currentVal: Int): Int = {
       if(n == 0) currentVal else helper(n - 1, currentVal * 2)
     }
     helper(n, 1)
   }

   println(power2toNTail(2))
   println(power2toNTail(4))
   println(power2toNTail(6))
 } 

以下结果是:

4 
16 
64 

上面的是我们方法 power2toN尾部优化 版本。这里使用的注解 @tailrec 是为了明确告诉 Scala 编译器识别一个尾部递归函数并相应地进行优化。这里的不同之处在于使用了嵌套的、递归的 helper 方法,它包含尾部调用。在调用 helper(n-1, currentVal * 2) 之后,就不再需要这个调用栈了。因此,Scala 编译器可以自由地进行优化。更多关于尾部递归和尾部调用优化的内容请参阅 第九章,使用强大的函数式构造

这是 Scala 中编写递归函数的首选方式。理解需求并编写一个递归版本比简单地编写方法的迭代版本要费劲得多。但在函数式世界中,这是值得的。

条件语句

我们已经多次使用条件了。没有条件或逻辑语句,程序很难有意义。这些语句有助于保持程序的流程。此外,使用这些语句实现逻辑也更容易。Scala 支持 ifelse 条件语句。

if else 条件表达式

在 Scala 中,你可以使用 if else 来控制程序流程。if else 语句的通用语法如下:

if (condition (is true)) 
          ... //Block of code to be executed 
else 
          ... //Block of code to be executed 

scala> val age = 17 
age: Int = 17 

scala> if(age > 18) println("You're now responsible adult.") else println("You should grow up.") 
You should grow up. 

上文,我们定义了一个值为 17 的变量 age。在下一行,我们检查了一个条件 age > 18。如果年龄大于 18,则打印一些字符串。你现在是一个负责任的成年人,或者其他的字符串。我们不仅可以打印字符串,还可以将任何操作作为控制流程的一部分执行。在 Scala 中,我们还可以使用 if else 表达式声明和赋值变量:

scala> val marks = 89 
marks: Int = 89 

scala> val performance = if(marks >= 90) "Excellent" else if(marks > 60 && marks < 90) "Average" else "Poor" 
performance: String = Average 

在这里,我们使用条件表达式即时给变量 performance 赋值。我们检查分数是否大于 90,或者介于 60 到 90 之间,或者小于 90,并根据这个条件为性能分配值。这是因为 Scala 中的条件是表达式——if 语句的结果是一个表达式。

在 Scala 中,还有另一种控制程序流程的方法,它使用表达式或结构的匹配到一个值,并在成功匹配后评估相应的代码块。我们称之为模式匹配。在 Scala 中。

模式匹配

模式匹配更像是 Java 的switch语句,但有一些不同。有一个表达式/值要匹配多个 case 子句,每当发生匹配时,相应的代码块就会被执行。这为我们程序的流程提供了多个选择。Java 的switch是一个穿透语句,这意味着它执行第一个匹配之后的所有语句,直到遇到break语句。在 Scala 中,没有break语句。此外,Scala 的模式匹配中没有默认情况。相反,使用通配符“_”来匹配之前case子句中没有涵盖的其他情况。

让我们看看 Java 的switch语句和 Scala 的模式匹配语句在语法上的差异:

图片

差异很明显,正如我们之前讨论的那样。在 Scala 中,我们必须为我们的表达式提供一个 case 匹配,否则编译器将抛出一个错误,MatchError*:

object PatternMatching extends App {

   def matchAgainst(i: Int) = i match {
     case 1 => println("One")
     case 2 => println("Two")
     case 3 => println("Three")
     case 4 => println("Four")
   }

   matchAgainst(5)
 } 

下面的结果是:

Exception in thread "main" scala.MatchError: 5 (of class java.lang.Integer) 
            at PatternMatching$.matchAgainst(PatternMatching.scala:6) 
            at PatternMatching$.delayedEndpoint$PatternMatching$1(PatternMatching.scala:13) 
            at PatternMatching$delayedInit$body.apply(PatternMatching.scala:4) 
            at scala.Function0.apply$mcV$sp(Function0.scala:34) 
            at scala.Function0.apply$mcV$sp$(Function0.scala:34) 
            at scala.runtime.AbstractFunction0.apply$mcV$sp(AbstractFunction0.scala:12) 

在前面的例子中,我们没有涵盖所有可能的情况,因此编译器返回了这个错误。为了涵盖所有情况,我们可以在最后一个case子句中添加通配符“_”。这将工作得很好。让我们试试:

object PatternMatching extends App {

   def matchAgainst(i: Int) = i match {
     case 1 => println("One")
     case 2 => println("Two")
     case 3 => println("Three")
     case 4 => println("Four")
     case _ => println("Not in Range 1 to 4")
   }

   matchAgainst(1)
   matchAgainst(5)
 } 

下面的结果是:

One 
Not in Range 1 to 4 

在覆盖完每一个case子句后,模式匹配表现得非常出色。我们得到了响应;对于值 1,我们得到了相应的One,而对于 5,我们得到了Not in Range 1 to 4。Scala 中的模式匹配还有很多内容。我们将在第九章“使用强大的函数式构造”中详细讲解模式匹配,并学习几个匹配构造。

摘要

我们可以总结本章内容;让我们回顾一下我们所学到的。我们从学习 Scala 中的循环结构开始。我们讨论了原生的循环结构,如for whiledo while循环。之后,我们看到了for表达式,以及for yield表达式。然后我们理解了迭代的替代方案,即递归。我们也编写了一些递归函数。最后,我们研究了if else条件语句和模式匹配。我们知道还有更多内容要学习,但有了这些概念,我们正在更好地理解 Scala 语言结构。我们将在下一章继续这样做。在那里,我们将探讨函数式编程的核心:函数。我们将看到函数是如何定义和使用的。它将展示 Scala 中可用的各种函数式构造。我们将尝试通过函数为我们的程序赋予意义。

第四章:使用函数为程序赋予意义

"面向对象编程通过封装移动部分使代码易于理解;函数式编程通过最小化移动部分使代码易于理解。"

  • 迈克尔·费思

作为一种混合范式语言,Scala 鼓励你重用你编写的代码,同时期望你遵循函数式编程背后的动机。这个动机是,你的程序应该被分解成更小的抽象来完成一个定义良好的任务。这可以通过使用函数来实现。函数不过是一个执行特定任务并可以按需重用的逻辑构造。

有一些方法可以将这些函数引入并应用到我们的程序中。有许多原因可以在我们的程序中使用函数。编写得好的函数,具有确切数量的所需参数,具有明确定义的作用域和隐私性,可以使你的代码看起来很好。此外,这些函数给你的程序赋予了意义。根据我们的需求,可以采用一些语法变化来采用特定的评估策略。例如,只有在需要时才评估函数,或者惰性评估,意味着表达式将在首次访问时进行评估。让我们准备好了解 Scala 中的函数。在本章中,我们将熟悉以下内容:

  • 函数语法

  • 调用函数

  • 函数字面量/匿名函数

  • 评估策略

  • 部分函数

因此,让我们从编写函数所需的内容开始我们的讨论。

函数语法

Scala 中的函数可以使用def关键字来编写,后面跟函数名,并给函数提供一些作为输入的参数。让我们看看函数的通用语法:

modifiers... 
def function_name(arg1: arg1_type, arg2: arg2_type,...): return_type = ???

上述语法显示了 Scala 中通用的函数签名。首先,我们给出函数的修饰符。修饰符可以理解为为函数定义的属性。修饰符有多种形式。其中一些如下:

  • 注解

  • 覆盖修饰符

  • 访问修饰符(private等)

  • final关键字

建议的做法是按需且按照给定顺序使用前面的修饰符。在指定修饰符后,我们使用def关键字来表示函数,然后跟函数名。在给出函数名之后,我们指定参数。参数在括号中指定:首先,参数名然后是其类型。这与 Java 有所不同。在 Java 中,这个顺序正好相反。最后,我们指定函数的返回类型。我们也可以省略返回类型,因为 Scala 可以推断它。除了某些特殊情况外,它将正常工作。为了提高我们程序的可读性,我们可以将返回类型作为函数签名的一部分。在声明函数之后,我们可以给出定义体。让我们看看一些具体的例子:

def compareIntegers(value1: Int, value2: Int): Int = if (value1 == value2) 0 else if (value1 > value2) 1 else -1

上述示例简单地定义了一个期望两个整数值、比较它们并返回整数响应的函数。定义也很简单,我们正在检查输入的相等性。如果值相等,则返回0;如果第一个值大于第二个值,则返回1,否则返回-1。在这里,我们没有为我们的函数使用任何修饰符。默认情况下,Scala 将其函数视为public,这意味着你可以从任何其他类访问它们并覆盖它们。

如果你仔细观察函数体,它是行内的。我们直接定义了函数,这样做有两个原因:

  • 为了使我们的代码简单易读

  • 定义足够小,可以放在一行中

定义函数定义行内的一个推荐做法是,当你的函数签名及其定义大约有 30 个字符时。如果它更长但仍然简洁,我们可以在下一行开始定义,如下所示:

def compareIntegersV1(value1: Int, value2: Int): Int = 
  if (value1 == value2) 0 else if (value1 > value2) 1 else -1 

所以选择权在你;为了使你的代码更易读,你可以选择在行内定义函数。如果函数体中有多行,你可以选择将它们封装在一对大括号内:

def compareIntegersV2(value1: Int, value2: Int): Int = { 
  println(s" Executing V2") 
  if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
} 

在这里,函数定义中有两个语句。第一个是打印,后者是评估比较。因此,我们使用一对大括号将它们封装起来。让我们看看整个程序:

object FunctionSyntax extends App{ 
 /* 
  * Function compare two Integer numbers 
  * @param value1 Int 
  * @param value2 Int 
  * return Int 
  * 1  if value1 > value2 
  * 0  if value1 = value2 
  * -1 if value1 < value2 
  */ 
  def compareIntegers(value1: Int, value2: Int): Int = if (value1 == value2) 0 else if (value1 > value2) 1 else -1 

  def compareIntegersV1(value1: Int, value2: Int): Int = {
    if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
   }

  def compareIntegersV2(value1: Int, value2: Int): Int =
    if (value1 == value2) 0 else if (value1 > value2) 1 else -1 

  println(compareIntegers(1, 2)) 
  println(compareIntegersV1(2, 1)) 
  println(compareIntegersV2(2, 2)) 

} 

以下结果是:

-1
1 
0 

当我们定义一个函数体时,最后一个表达式作为函数的返回类型。在我们的例子中,if else表达式的评估,即一个整数,将是compareIntegers函数的返回类型。

函数嵌套

无论何时我们有封装逻辑的可能性,我们都将代码片段转换为一个函数。如果我们这样做很多次,可能会污染我们的代码。此外,当我们将函数分解成更小的辅助单元时,我们倾向于给出几乎相同的名字。让我们举一个例子:

object FunctionSyntaxOne extends App { 

  def compareIntegersV4(value1: Int, value2: Int): String = { 
 println("Executing V4") 
    val result = if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
    giveAMeaningFullResult(result, value1, value2) 
  } 

  private def giveAMeaningFullResult(result: Int, value1: Int, value2: Int) = result match { 
    case 0 => "Values are equal" 
    case -1 => s"$value1 is smaller than $value2" 
    case 1 => s"$value1 is greater than $value2" 
    case _ => "Could not perform the operation" 
  } 

  println(compareIntegersV4(2,1)) 
} 

以下结果是:

Executing V4 
2 is greater than 1 

在前面的程序中,我们定义了compareIntegersV4函数,在该函数中,在评估两个整数的比较之后,我们调用了一个名为giveAMeaningFullResult的辅助函数,传递了一个结果和两个值。这个函数根据结果返回一个有意义的字符串。代码运行正常,但如果你仔细思考,可能会发现这个私有方法只对compareIntegersV4有意义,因此最好将giveAMeaningFullResult的定义放在函数内部。让我们重构我们的代码,以嵌套方式在compareIntegersV5内部定义辅助函数:

object FunctionSyntaxTwo extends App { 

  def compareIntegersV5(value1: Int, value2: Int): String = { 
 println("Executing V5") 

    def giveAMeaningFullResult(result: Int) = result match { 
      case 0 => "Values are equal" 
      case -1 => s"$value1 is smaller than $value2" 
      case 1 => s"$value1 is greater than $value2" 
      case _ => "Could not perform the operation" 
    } 

    val result = if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
    giveAMeaningFullResult(result) 
  } 

  println(compareIntegersV5(2,1)) 
} 

以下结果是:

Executing V5 
2 is greater than 1 

如你在上述代码中所见,我们定义了一个嵌套函数giveAMeaningFullResult并且也做了一些修改。现在它只期望接收一个整数类型的参数,并返回一个有意义的字符串。我们可以访问外部函数的所有变量;这就是为什么我们没有将value1value2传递给我们的嵌套辅助函数. 这使得我们的代码看起来更简洁。我们能够直接传递参数调用我们的函数,在我们的例子中是21。我们可以以多种方式调用函数;我们为什么不看看这些方式呢?

调用函数

我们可以调用一个函数来执行我们为其定义的任务。在调用时,我们传递函数作为输入参数的参数。这可以通过多种方式实现:我们可以指定可变数量的参数,我们可以指定参数的名称,或者我们可以指定一个默认值,以防在调用函数时未传递参数。让我们考虑一个场景,我们不确定要传递给函数进行评估的参数数量,但我们确定它的类型。

传递可变数量的参数

如果你还记得,我们在上一章已经看到了一个关于接受可变数量参数并在其上执行操作的函数的例子:

 /* 
  * Prints pages with given Indexes for doc 
  */ 
  def printPages(doc: Document, indexes: Int*) = for(index <- indexes if index <= doc.numOfPages) print(index) 

我们的方法接受索引数字,并打印出作为第一个参数传递的文档中的那些页面。在这里,参数indexes被称为可变参数. 这表示我们可以传递任何指定类型的参数数量;在这种情况下,我们指定了Int. 在调用此函数时,我们可以传递任何类型的Int. 我们已经尝试过了。现在,让我们考虑一个期望接收多个整数并返回所有数字平均值的数学函数。它应该是什么样子呢?

它可能是一个带有def关键字、名称和参数的签名,或者只是一个可变参数

def average(numbers: Int*): Double = ??? 

上述代码是average函数的签名。函数的主体尚未定义:

object FunctionCalls extends App { 

  def average(numbers: Int*) : Double = numbers.foldLeft(0)((a, c) => a + c) / numbers.length 

  def averageV1(numbers: Int*) : Double = numbers.sum / numbers.length 

  println(average(2,2)) 
  println(average(1,2,3)) 
  println(averageV1(1,2,3)) 

} 

以下就是结果:

2.0 
2.0 
2.0 

让我们看看第一个 average 函数;它期望一个类型为 Int* 的可变参数。它已经用参数 22 被调用。这里参数的数量是 2。我们可以提供任意数量的参数来执行操作。我们函数的定义使用了 fold 操作来对所有传递的数字进行求和。我们将在下一章讨论我们的集合函数时看到 fold 的工作细节。现在,只需理解它遍历集合中的每个元素并与提供的参数执行操作即可,在我们的例子中,即 0。我们用不同数量的参数调用了函数。同样,我们可以定义我们的函数以支持任意类型的可变数量参数。我们可以相应地调用函数。唯一的要求是 可变参数 参数应该在函数签名参数列表的末尾:

def averageV1(numbers: Int*, wrongArgument: Int): Double = numbers.sum / numbers.length 

这意味着 numbers 即一个可变参数,应该放在最后声明,之后声明 wrongArgument 将会导致 编译时错误

使用默认参数值调用函数

我们可以在声明函数时提供默认参数值。如果我们这样做,在调用函数时可以避免为该参数传递参数。让我们通过一个例子看看这是如何工作的。我们已经看到了这个例子,我们将比较两个整数。让我们给第二个参数一个默认值 10

def compareIntegersV6(value1: Int, value2: Int = 10): String = { 
 println("Executing V6") 

  def giveAMeaningFullResult(result: Int) = result match { 
    case 0 => "Values are equal" 
    case -1 => s"$value1 is smaller than $value2" 
    case 1 => s"$value1 is greater than $value2" 
    case _ => "Could not perform the operation" 
  } 

  val result = if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
  giveAMeaningFullResult(result) 
} 

println(compareIntegersV6(12)) 

以下结果是:

Executing V6 
12 is greater than 10 

在这里,当我们声明 compareIntegersV6 函数时,我们给参数 value2* 提供了一个默认值 10。在调用函数的末尾,我们只传递了一个参数:

compareIntegersV6(12) 

在调用函数时,我们只传递了一个参数 12,这是 value1* 的值。在这些情况下,Scala 编译器会寻找绑定到其他参数的值。在我们的例子中,编译器能够推断出对于其他参数,默认值已经是 10,所以函数应用将基于这两个值进行评估。提供默认值并使用它们仅在 Scala 编译器能够推断值的情况下才有效。在存在歧义的情况下,它不允许你调用函数。让我们举一个例子:

def compareIntegersV6(value1: Int = 10, value2: Int) = ??? 

对于这个函数,让我们尝试使用以下函数调用方式来调用:

println(compareIntegersV6(12)) // Compiler won't allow 

如果我们尝试以这种方式调用函数,Scala 编译器将会抛出一个错误,因为编译器无法将值 12 绑定到 value2 这是因为参数的顺序问题。如果我们能以某种方式告诉编译器我们传递的参数绑定到了名为 value2 的参数上,我们的函数就能正常工作。为了实现这一点,我们通过命名传递参数来调用函数。

在传递命名参数时调用函数

是的,在调用函数时,我们可以直接命名参数。这确保了参数传递的正确顺序不受限制。让我们调用我们的函数:

def compareIntegersV6(value1: Int = 10, value2: Int): String = { 
 println("Executing V6") 

  def giveAMeaningFullResult(result: Int) = result match { 
    case 0 => "Values are equal" 
    case -1 => s"$value1 is smaller than $value2" 
    case 1 => s"$value1 is greater than $value2" 
    case _ => "Could not perform the operation" 
  } 

  val result = if (value1 == value2) 0 else if (value1 > value2) 1 else -1 
  giveAMeaningFullResult(result) 
} 

println(compareIntegersV6(value2 = 12)) 

以下结果是:

Executing V6 
10 is smaller than 12 

原因很简单:唯一要确保的是 Scala 编译器能够推断。这也允许你无论函数签名中出现的顺序如何,都可以传递参数。因此,我们可以这样调用我们的函数:

println(compareIntegersV6(value2 = 12, value1 = 10)) 

以下结果是:

Executing V6 
10 is smaller than 12 

这给我们提供了定义和调用函数的多种方式。好消息是,你还可以以字面量的形式将函数传递给函数;我们称之为函数字面量。让我们看看函数字面量是什么样的。

函数字面量

我们可以将一个函数以字面量的形式传递给另一个函数,让它为我们工作。让我们以相同的 compareIntegers 函数为例:

def compareIntegersV6(value1: Int = 10, value2: Int): Int = ??? 

我们知道我们的函数应该做什么:接收两个整数作为输入,并返回一个整数响应,告诉我们比较的结果。如果我们看一下我们函数的抽象形式,它将看起来像这样:

(value1: Int, value2: Int) => Int     

这意味着该函数期望两个整数,并返回一个整数响应;我们的需求是相同的。这是一个抽象形式,表示左侧的元素是输入,右侧的元素是函数的响应类型。我们可以说这是它的字面量形式,也称为函数字面量。因此,它也可以分配给任何变量:

val compareFuncLiteral = (value1: Int, value2: Int) => if (value1 == value2) 0 else if (value1 > value2) 1 else -1 

记得在上一章的PagePrinter中,我们有一个接受索引并打印该页面的print函数:

private def print(index: Int) = println(s"Printing Page $index.") 

如果我们看我们的函数的形式,它接受一个整数并打印页面。所以形式将如下所示:

(index: Int) => Unit 

这里的Unit关键字表示我们的字面量不会返回任何值。现在让我们考虑一个场景,要求告诉打印机以彩色或简单的方式打印一页。我们将重构我们的代码以支持使用函数字面量:

object ColorPrinter extends App { 

  def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit) = if(lastIndex <= doc.numOfPages) for(i <- 1 to lastIndex) print(i) 

  val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

  val simplePrint = (index: Int) => println(s"Printing Simple Page $index.") 

  println("---------Method V1-----------") 
  printPages(Document(15, "DOCX"), 5, colorPrint) 

   println("---------Method V2-----------") 
   printPages(Document(15, "DOCX"), 2, simplePrint) 
} 

case class Document(numOfPages: Int, typeOfDoc: String) 

以下结果是:

---------Method V1----------- 
Printing Color Page 1\. 
Printing Color Page 2\. 
Printing Color Page 3\. 
Printing Color Page 4\. 
Printing Color Page 5\. 
---------Method V2----------- 
Printing Simple Page 1\. 
Printing Simple Page 2\. 

我们重构了printPages方法,现在它接受一个函数字面量。函数字面量代表我们的print函数的形式。我们表示了两种print函数的形式,第一种打印彩色页面,第二种打印简单页面。这使得调用相同的printPages函数并按需传递一个函数字面量变得简单。我们只需要告诉函数这种函数可以被传递,在调用函数时,我们可以传递相同形式的函数字面量。

Scala 在默认构造中也使用函数字面量。一个例子是集合的filter函数。filter函数期望一个谓词,该谓词检查条件并返回一个布尔响应,基于此,我们可以从列表或集合中过滤出元素:

scala> val names = List("Alice","Allen","Bob","Catherine","Alex") 
names: List[String] = List(Alice, Allen, Bob, Catherine, Alex) 

scala> val nameStartsWithA = names.filter((name) => name.startsWith("A")) 
nameStartsWithA: List[String] = List(Alice, Allen, Alex) 

我们检查名称是否以A开头的那部分是一个函数字面量的例子:

 (name) => name.startsWith("A") 

Scala 编译器只在需要推断类型信息的地方需要额外信息;有了这个,它允许我们省略只是额外语法的部分,因此可以写成以下语法:

scala> val nameStartsWithA = names.filter(_.startsWith("A")) 
nameStartsWithA: List[String] = List(Alice, Allen, Alex) 
placeholder syntax instead. What if we pass a function literal as an argument and want it to be evaluated only when it's needed, for example, a predicate that gets evaluated only if a certain functionality is active? In that case, we can pass the parameter as a named parameter. Scala does provide this functionality in the form of *call by name* parameters. These parameters get evaluated lazily whenever needed or first called. Let's take a look at some evaluation strategies provided by Scala.

评估策略

当函数中定义了一些参数时,这些函数调用期望我们在调用时传递参数。正如我们所知,我们可以传递一个在调用或使用时被评估的函数字面量。Scala 支持函数的按值调用按名调用。让我们详细讨论一下。

按名调用

按名调用是一种评估策略,其中我们用调用函数的位置处的字面量来替换。字面量在第一次出现并被调用时进行评估。我们可以用一个简单的例子来理解这一点。首先,让我们以我们的ColorPrinter应用程序为例,并传递一个检查打印机是否开启的布尔函数字面量。为此,我们可以重构我们的函数:

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit, isPrinterOn: () => Boolean) = { 

  if(lastIndex <= doc.numOfPages && isPrinterOn()) for(i <- 1 to lastIndex) print(i) 

} 

要调用这个函数,我们可以使用:

printPages(Document(15, "DOCX"), 16, colorPrint, () => !printerSwitch) 

这种方法有两个问题。首先,它看起来很奇怪;在这里使用``() => expression,因为我们已经知道它将是一个布尔函数字面量。其次,我们可能不希望我们的表达式在它被使用之前被评估。为此,我们将在printPages`函数签名中进行一些小的修改:

object ColorPrinter extends App { 

  val printerSwitch = false 

  def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit, isPrinterOn: => Boolean) = { 

    if(lastIndex <= doc.numOfPages && isPrinterOn) for(i <- 1 to lastIndex) print(i) 

  } 

  val colorPrint = (index: Int) => { 
    println(s"Printing Color Page $index.") 
  } 

  println("---------Method V1-----------") 
  printPages(Document(15, "DOCX"), 2, colorPrint, !printerSwitch) 

} 

case class Document(numOfPages: Int, typeOfDoc: String) 

以下为结果:

---------Method V1----------- 
Printing Color Page 1\. 
Printing Color Page 2\. 

仔细观察,你会发现我们在函数签名中移除了()括号,并添加了*=>*。这使得我们的代码理解这是一个按名参数,并且只有在调用时才会对其进行评估。这就是我们允许进行这种调用的原因:

printPages(Document(15, "DOCX"), 2, colorPrint, !printerSwitch) 

这个调用由一个布尔表达式作为最后一个参数组成。由于我们的函数期望它是一个按名类型,它将在实际调用时被评估。

按值调用

按值调用是一种简单且常见的评估策略,其中表达式被评估,结果被绑定到参数上。在参数被使用的地方,绑定的值简单地被替换。我们已经看到了许多这种策略的例子:

def compareIntegers(value1: Int, value2: Int): Int = 
       if (value1 == value2) 0 else if (value1 > value2) 1 else -1 

compareIntegers(10, 8) 

对这个函数的调用是按值调用策略的例子。我们简单地给出作为参数的值,这些值在函数中被参数值所替代。

这些策略为我们提供了多种调用函数的方式。此外,仅在需要时评估表达式是函数式语言的特征;这被称为惰性评估。我们将在第九章使用强大的函数式构造中更详细地学习惰性评估,我们将讨论强大的函数式构造

函数式编程支持这种编写函数的类比,这些函数对输入值有效且能正常工作,而不是通过错误来失败。为了支持这一点,Scala 有一个定义部分函数的功能。

部分函数

部分函数对于给定的每个输入都不够用,这意味着这些函数是为特定的一组输入参数定义的,以实现特定目的。为了更好地理解,让我们首先定义一个部分函数:

scala> val oneToFirst: PartialFunction[Int, String] = { 
     | case 1 => "First" 
     | } 
oneToFirst: PartialFunction[Int, String] = <function1> 

scala> println(oneToFirst(1)) 
First 

在前面的代码中,我们定义了一个名为 oneToFirst 的部分函数*。我们还为我们的部分函数指定了类型参数;在我们的例子中,我们传递了 IntStringPartialFunction 函数是 Scala 中的一个特质,定义为:

trait PartialFunction[-A, +B] extends (A) => B 

该特性显示期望两个参数 AB,它们将成为我们部分函数的输入和输出类型。我们的 oneToFirst 部分函数仅期望 1 并返回 1 的字符串表示形式作为第一个。这就是为什么当我们尝试通过传递 1 调用该函数时,它运行良好;但如果我们尝试传递任何其他参数,比如说 2,它将抛出一个 MatchError

scala> println(oneToFirst(2)) 
scala.MatchError: 2 (of class java.lang.Integer) 
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:254) 
  at scala.PartialFunction$$anon$1.apply(PartialFunction.scala:252) 
  at $anonfun$1.applyOrElse(<console>:12) 
  at $anonfun$1.applyOrElse(<console>:11) 
  at scala.runtime.AbstractPartialFunction.apply(AbstractPartialFunction.scala:34) 

这是因为我们的部分函数仅适用于单个值,即 1;对于其他值则不适用。为了确保我们的函数不会抛出错误,我们可以使用 isDefinedAt 方法检查部分函数是否适用于某个值:

scala> oneToFirst.isDefinedAt(1) 
res3: Boolean = true 

scala> oneToFirst.isDefinedAt(2) 
res4: Boolean = false 

对于我们的部分函数支持的值,isDefinedAt 返回 true;对于其他值,它返回 false。这些部分函数也可以组合。为此,PartialFunction 特质定义了两个方法:orElseandThen

object PartialFunctions extends App { 

  val isPrimeEligible: PartialFunction[Item, Boolean] = { 
    case item => item.isPrimeEligible 
  } 

  val amountMoreThan500: PartialFunction[Item, Boolean] = { 
    case item => item.price > 500.0 
  } 

  val freeDeliverable = isPrimeEligible orElse amountMoreThan500 

  def deliveryCharge(item: Item): Double = if(freeDeliverable(item)) 0 else 50 

  println(deliveryCharge(Item("1", "ABC Keyboard", 490.0, false))) 

} 

case class Item(id: String, name: String, price: Double, isPrimeEligible: Boolean)

以下为结果:

50.0 

在前面的程序中,我们定义了名为 isPrimeEligibleamountMoreThan500 的部分函数*,然后使用 orElse 方法组合了另一个部分函数,该方法检查项目是否可以免费交付。因此,部分函数为我们提供了组合和定义函数的方法,以服务于特定值集的特定目的。此外,部分函数还为我们提供了一种根据某些区分定义从给定输入值集合中分离逻辑的方法。重要的是要记住,我们的部分函数仅对一个操作数起作用。因此,这是一种一元函数的形式,程序员有责任检查对于特定值,函数是否已定义。

摘要

是时候总结本章内容了。在本章中,我们对 Scala 中重要的 函数 概念进行了简要介绍。我们从定义函数的语法开始。重要的是要知道,我们可以嵌套函数并使我们的代码看起来更整洁。我们学习了如何以各种方式调用函数,例如使用可变数量的参数、默认参数值和使用命名参数。然后我们学习了如何在 Scala 中编写函数字面量。之后,我们讨论了 Scala 中函数的几种评估策略,其中我们讨论了 按名调用按值调用。最后,我们讨论了在 Scala 中定义 部分函数 的另一个重要概念。

通过本章,我们完成了旅程的第一部分。学习所有这些概念无疑增加了我们编写和理解成熟 Scala 代码的能力。在后面的部分,我们将继续这样做。第二部分是关于 Scala 丰富的集合层次结构。在下一章,我们将学习 Scala 提供的集合数量以及以各种方式使用集合的各种方法。

第五章:熟悉 Scala 集合

"你编写的代码应该吸收更多的意义,而不会变得臃肿或失去可理解性。"

  • 匿名

在任何编程语言中,一个基本要求是能够处理一组数据,换句话说,一组数据集合。如果你使用过任何编程语言,你肯定已经知道其集合框架的重要性。Scala 拥有丰富的集合;一组丰富的辅助函数使得处理任何 Scala 集合变得容易得多。在本章中,我们将介绍 Scala 集合的所有基本知识。我们将能够区分几种集合选项,并有效地使用所有集合。在这个过程中,我们将学习:

  • 不可变和可变 Scala 集合

  • Scala 的集合层次结构

  • Scala 中常用的集合

  • 在集合上执行丰富操作

  • 并行集合

  • 从 Java 到 Scala 集合的转换

  • 选择一个集合

  • 集合性能

动机

在我们开始学习 Scala 中的不可变和可变集合之前,我们将尝试使用 Scala 集合提供的强大方法解决一个简单的问题。为此,让我们看看一个场景:

RESTful API

如前图所示,我们有一组具有GETPOSTPUT等方法类型以及相关 URI 的 API。由于这些是两个实体(方法和 URI),将这些视为元组的列表。现在我们想要将它们分开,以便我们可以创建一个映射,如前图右侧所示。映射是一种存储键值对的集合。因此,在右侧你可以看到 API 信息作为键值对,其中键是方法名称,值是该特定请求类型的 URI 列表。所以,想法是将List[(String, String)]转换为Map[String, List[String]]*。你可能想要考虑解决方案,并提出你自己的。

同时,让我们看看 Scala 是否以任何方式帮助我们解决我们的问题:

object RESTFulAPIs extends App { 
    //List of Method and URI 
    val listOfAPIs = List(("GET", "/user/:id"), 
                          ("GET", "user/:id/profile/:p_id"), 
                          ("POST", "/user"), 
                          ("POST", "/profile"), 
                          ("PUT", "/user/:id")) 

    /* 
     * Returns a scala.collection.immutable.Map[String,  List[(String,String)]] 
     */ 
    val groupedListOfAPIs = listOfAPIs.groupBy(_._1) 
    println(s"APIs grouped to a Map :: $groupedListOfAPIs") 

    /* 
     * Returns a scala.collection.immutable.Map[String, List[String]] 
     */ 
    val apisByMethod = groupedListOfAPIs.mapValues(_.map(_._2)) 
    println(s"APIs By Method :: $apisByMethod") 
} 

这是结果:

APIs grouped to a Map :: Map(POST -> List((POST,/user), (POST,/profile)), GET -> List((GET,/user/:id), (GET,user/:id/profile/:p_id)), PUT -> List((PUT,/user/:id))) 
APIs By Method :: Map(POST -> List(/user, /profile), GET -> List(/user/:id, user/:id/profile/:p_id), PUT -> List(/user/:id)) 

如果你已经想出一组for循环或递归方法来完成可以用单一方法完成的事情,你可能需要重新思考,或者看看我们这里提供的解决方案。在这里,我们使用了两个满足我们目的的实用方法。第一个是定义在TraversableLike特质中的groupBy*,它将我们的List[(String, String)]转换为按元组的第一个元素(方法名称)分组的Map[String, List[String]]。这个groupBy操作给我们带来了以下内容:

Map(POST -> List((POST,/user), (POST,/profile)), GET -> List((GET,/user/:id), (GET,user/:id/profile/:p_id)), PUT -> List((PUT,/user/:id))) 

后者是来自MapLike特质的mapValues方法,它用于将给定的映射包装在相同的键上。每个键的值简单地是f(this(key))

def mapValuesW: Map[K, W] 

这两种方法足以提供解决方案,并帮助我们避免使用循环进行多次遍历。这只是其中一个例子,仅用几行代码就能完成原本可能需要几行代码才能完成的工作。这确实使 Scala 集合变得强大。Scala 的集合框架易于使用;大多数辅助方法都是通用的,只有少数例外。此外,性能上没有妥协;这些是经过性能优化的方法。人们可以依赖这些方法来完成任何逻辑;这使得你的代码看起来更美观。但不仅如此,这只是开始。通常,集合容易编写出针对当前环境的代码。这通常使得调试出错变得困难,特别是在有可变集合的情况下。因此,为了消除这种复杂性,Scala 提供了这些不可变数据集合。一旦创建,不可变集合就不能更新。但它们是如何工作的,与可变集合有何不同?让我们逐一了解。

不可变和可变集合

集合用于存储程序在将来某个时间点使用的数据。在多线程环境中,如果多个线程同时尝试访问一个集合,这可能会让你在调试出错时遇到困难。这是程序员在多线程环境中处理集合时通常会遇到的问题。但有一个通用的解决方案,它期望你使用不可变集合。不可变意味着你不能改变/修改它。Scala 提供了选择选项:rootmutableimmutable集合。这三个变体存在于三个不同的包中:scala.collectionscala.collection.mutablescala.collection.immutable。如果你没有指定集合而使用了一个,它将默认为不可变集合。但它们究竟是如何工作的呢?让我们来看看:

scala> val studentsPresent = List("Alex", "Bob", "Chris") 

studentsPresent: List[String] = List(Alex, Bob, Chris) 

一个不允许我们更新或删除其元素的集合几乎没有什么用处。那么,我们为什么说这些是丰富的集合呢?原因在于,尽管这些集合是不可变的,但仍然有方法可以添加和删除元素,但这些操作会返回一个新的集合。我们将在本章后面看到这些集合是如何构建的,以及添加一个元素是如何影响集合的;但就目前而言,重要的是要知道不可变集合是可以更新的,尽管这样做会返回另一个包含相同元素集的集合,以及更新后的集合。

另一方面,我们有可变集合,它们的工作方式与大多数面向对象编程语言相似。你可以用几个元素声明并实例化一个集合。然后,根据后续的任何要求,你可以更改其元素,或者删除它们。使用这些可变集合,Scala 为你选择要与之一起工作的集合时提供了一个选择。当你使用可变集合时,你会得到一组额外的用于更改集合的方法。不过,请确保你可能会更改集合的实例。这样,你的程序将不会出现可变性复杂性。

第三种变体,根集合,位于scala.collection包中. 当你使用根集合时,它可以是可变的或不可变的。这意味着什么?这意味着特定的集合是位于scala.collection.mutablescala.collection.immutable包中的同一家族集合的超类。为了理解我们刚才说的,请看以下方法:

def afunction(xs: scala.collection.Iterable[String]) = ??? 

afunction函数可以接受可变和不可变集合,只要它们是可迭代的,这是 Scala 集合层次结构中可用的特性之一.

有一些额外的允许你更改集合的方法,正如我们可能预期的,这些方法仅定义在scala.collection.mutable包中的集合,而不是scala.collectionscala.collection.immutable包中的集合. 在这种情况下,很明显,在编写你的根集合时,Scala 编译器不会允许你更新你的集合。我们讨论了根集合的一个用例,其中,无论你的集合类型如何,你都可以定义一个函数——即afunction,它接受各种集合。我们可以找到更多用例,或者区分根集合和不可变集合。

根集合和不可变集合之间的区别

通过一个场景,我们可以很容易地理解rootimmutable集合用例之间的区别。想象一个场景,其中:

  • 函数声明期望一个集合作为参数

  • 函数不会作为定义的一部分更改集合

  • 函数可以很好地工作,无论集合值是否可能被其他线程在时间上更改

如果这三个场景都满足,你可以使用root集合类型作为函数定义的参数。这意味着以下声明将适用于你:

def afunction(xs: scala.collection.Iterable[String]) 

如果第三个场景不是你想要的,那么使用scala.collection.immutable. 该场景解释了我们可以使用根集合的地方。仅仅因为你没有访问更改集合的方法,这并不限制集合的运行时修改。

也很重要的是要知道,即使在这三个包中,集合也是以层次结构的方式存在的。所以,让我们看看 Scala 中集合的层次结构。

Scala 集合的层次结构

Scala 集合的层次结构易于理解。当你开始使用集合时,使用在超特质中已定义的任何方法都变得容易,对于特定的实现,你可以定义自己的版本。结构已经被分为三个不同的类别,即:root, mutable,immutable. 我们已经讨论了它们之间的区别。为了加强我们的讨论,让我们看看root包集合的层次结构:

根集合层次结构

上述层次结构是针对root包中的集合。所有从超特质继承的集合都称为Traversable. Traversable 定义了foreach抽象方法,以及与其他集合相关的其他辅助方法。因此,很明显,其他每个集合都需要在其实现中定义foreach方法的定义。我们将在接下来的几个主题中查看 Traversable 特质

在 Traversable之后,有一个名为Iterable特质,它继承自 Traversable,实现了从 Traversable 继承的foreach方法,并有自己的迭代器*抽象方法。从这里开始,层次结构分为三个不同的类别:

  • Seq

  • Set

  • Map

这三个类别在实现、上下文和使用场景上都有所不同。第一个是一个用于包含元素序列的序列,例如数字序列。序列进一步分为LinearSeqIndexedSeq. 第二个是一个集合,它是一组不同的元素,这意味着元素不能重复。集合分为SortedSetBitSet. 最后一个是映射,它是一个基于键值对的集合,可以有一个SortedMap. 这些都是scala.collection包的一部分。

在根包之后,让我们来看看scala.collection.mutable包中的集合层次结构:

scala.collection.mutable集合包

当你第一次看到这些时可能会感到害怕,但当你明智地使用这些集合时,它作为一个实用工具。scala.collection.mutable包中的集合,正如预期的那样,包含可以用来在集合中添加/删除元素的方法。这个包的层次结构与SeqSetMap特质相似。之后,更多针对某些特定用例的具体实现也成为了这个包的一部分。图本身是自解释的;越来越多的这些集合特质的实现供我们使用。IndexedSeq现在有更多的实现,例如ArraySeq StringBuilderArrayBuffer. 另一个名为Buffer的特质被引入。一些实现,如Stack ArrayStackPriorityQueue直接扩展自Seq特质。同样,其他两个特质,Set 和 Map,也有一些具体实现。HashSet 和 BitSet 用于集合,而 HashMap、LinkedHashMap 和 ListMap 扩展自 Map。

最后,让我们来看看scala.collection.immutable包的层次结构:

图片

scala.collection.immutable

除了不可变集合没有Buffer特质之外,集合层次结构与可变集合相似;区别在于这些集合没有让你修改相同集合的方法。尽管可以转换集合,但这意味着这些集合有高阶方法可以遍历并应用一个函数到每个元素,从而得到另一个集合。这是处理不可变集合的一种方法。

这个层次结构也有TraversableIterable超级特质*。这些特质包含大多数辅助方法。这些是通用方法,可以与 Scala 中的几乎所有集合一起使用。默认情况下,Scala 假设集合是不可变类型。如果你使用它们,那很好。了解你也可以创建自己的自定义集合是很好的。为此,scala.collection包中有一个名为generic的子包,它包含创建集合实现所需的辅助函数。

当我们实现这些集合时,我们假设有方法来使用和操作这些集合,实际上确实存在许多高阶辅助函数可以让你这样做。大多数基本和通用的辅助函数都定义在超级特质中,其他集合实现这些特质。让我们来看看这些特质。

Traversable

这是 Scala 中所有其他集合实现的超级特质。Traversable定义了一些帮助访问集合元素或对它们进行操作的方法。这些操作可以按以下方式分类:

  • 添加:将两个可遍历集合一起添加的方法。对于两个可遍历集合,例如xsys

    • 例如,xs ++ ys
  • 转换:如mapflatMapcollect之类的转换方法:

    • 例如,xs.map(elem => elem.toString + "default")
  • 转换:具有 toXXXmkString 等格式的方法. 这些方法用于将一个集合转换为另一个合适的集合:

    • 例如,xs.toArrayxs.mkStringxs.toStream
  • 复制:帮助方法,用于将集合中的元素复制到另一个集合中,例如数组或缓冲区:

    • 例如,xs.copyToBuffer(arr)
  • 信息检索:检索信息的方法,例如大小,或者集合是否有元素:

    • 例如,xs.isEmptyxs.isNonEmptyxs.hasDefiniteSize
  • 元素检索:从集合中检索元素的方法:

    • 例如,xs.headxs.find(elem => elem.toCharArray.length == 4)
  • 子集合:返回子集合的方法,基于排序或谓词:

    • 例如,xs.tailxs.initxs.filter(elem => elem.toCharArray.length == 4)
  • 折叠:对集合的连续元素应用二元操作的方法。此外,还有一些特殊的折叠操作形式:

    • 例如,xs.foldLeft(z)(op)xs.product

我们将在本章后面详细讲解几乎所有这些方法的实现细节。现在,重要的是要知道 Traversable 特质与 TraversableLike 和少数其他特质混入。如果你不理解特质混入是什么 我们将在后续章节中讨论 Scala 中的面向对象编程时进行讨论:

trait Traversable[+A] extends TraversableLike[A, Traversable[A]] with GenTraversable[A] with TraversableOnce[A] with GenericTraversableTemplate[A, Traversable] 

Traversable 有一个抽象的 foreach 方法. 任何混入 Traversable 的实现都需要定义这个抽象的 foreach 方法:

def foreachU 

foreach 方法签名所示,它有一个类型参数 U,代表我们将要使用该方法时施加的类型。foreach 方法遍历集合,对每个元素应用一个函数。

Iterable

Iterable 也是一个特质,其他集合可以混入其中。Iterable 混入了 Traversable,并定义了 foreach 抽象方法。Iterable 还有一个名为 iterator 的抽象方法. 混入 Iterable 特质的实现必须定义这个抽象方法:

def iterator: Iterator[A] 

你可以看到 iterator 方法返回一个 Iterator,它有 hasNextnext 方法。使用 Iterator,我们可以逐个获取所有元素,或者执行一个操作。仔细观察会发现,Traversable 可以一次性遍历整个集合,而对于 Iterable,拉取方法效果很好。每次迭代都会提供一个元素。

当然,Iterables 支持所有来自 Traversable 的方法。除此之外,还有一些其他方法:

  • 子迭代:返回另一个分块迭代器的方法:

    • 例如,xs.grouped(size)xs.sliding(size)
  • 子集合:返回集合部分的方法:

    • 例如,xs.takeRight(n)xs.dropRight(n)
  • 连接:返回可迭代集合元素对的方法:

    • 例如,xs.zip(ys)xs.zipWithIndex
  • 比较:根据元素顺序比较两个可迭代集合的方法:

    • 例如,xs sameElements ys

Iterable 的一个可能声明如下:

trait Iterable[+A] extends Traversable[A] with GenIterable[A] with GenericTraversableTemplate[A, Iterable] with IterableLike[A, Iterable[A]] 

如您所见,Iterable 特质混合了 Traversable 以及其他几个特质。这就是它在层次结构中的存在方式。以下可迭代的三个特质,分别命名为 Seq SetMap. 让我们逐一介绍。

Seq

Seq 代表元素序列。其签名如下:

trait Seq[+A] extends PartialFunction[Int, A] with Iterable[A] with GenSeq[A] with GenericTraversableTemplate[A, Seq] with SeqLike[A, Seq[A]]

如所示,Seq 扩展了 PartialFunction,但这意味着什么呢?记住,我们在上一章讨论了部分函数。这些函数定义了域中特定的一组值。在 Seq 的情况下,该域是 length -1. 从签名中可以清楚地看出,Seq 可以接受一个 Int,并响应类型为 A* 的元素。在这里,A 是集合元素的类型。让我们看一个例子:

scala> val aSeq = scala.collection.LinearSeqInt 
aSeq: scala.collection.LinearSeq[Int] = List(1, 2, 3, 4) 

scala> aSeq(1) 
res0: Int = 2 

在前面的例子中,当我们定义序列时,它变成了类型为 PartialFunction[Int, Int] 的部分函数。这意味着将长度减一的任何值作为参数传递给我们的序列将导致类型为 A* 的序列值,在我们的情况下,它也是一个 Int*。定义 Seq 仅用于域中的一些特定值的原因是为了表明,如果我们传递一个 aSeq 没有值的索引,它将引发异常:

scala> aSeq(5) 
java.lang.IndexOutOfBoundsException: 5 
  at scala.collection.LinearSeqOptimized.apply(LinearSeqOptimized.scala:63) 
  at scala.collection.LinearSeqOptimized.apply$(LinearSeqOptimized.scala:61) 
  at scala.collection.immutable.List.apply(List.scala:86) 
  ... 29 elided 

作为 PartialFunctionSeq,对我们开发者来说可能是一个福音,因为在许多情况下,非常复杂的逻辑可以变得非常容易实现。还有一个方法,isDefinedAt. 如您所知,我们可以用它来检查部分函数是否为某个值定义。序列有一个长度,并包含两个变体,分别命名为 IndexedSeqLinearSeq. 这些名称暗示了这些集合的主要用途。当通过索引访问时,建议使用索引序列;换句话说,通过调用 lengthapply 方法。然而,线性序列用于集合的子部分性能很重要。这意味着在集合上调用方法并将其分解为子序列是很重要的。现在,在了解所有这些之后,让我们看看可以在这些序列上执行的操作类别:

  • 长度和索引:依赖于序列长度的方法,主要通过索引或大小:

    • 例如,xs.apply(1)xs.lengthxs.indicesxs.indexWhere(predicate) ``
  • 添加:在序列的开始或结束处添加元素的方法:

    • 例如,x+:(xs)xs.:+(x)
  • 更新:更新序列中元素的方法:

    • 例如,xs(1) = 12xs updated (1, 12)
  • 排序:对给定序列进行排序的方法:

    • 例如,xs.sortedxs sortWith op
  • 反转:反转序列的方法:

    • 例如,xs.reverse
  • 比较和检查:反转序列的方法:

    • 例如,xs.contains(x)xs.endsWith(x)
  • 多集操作:基于某些集合操作(如并集和 distinct)的结果的方法:

    • 例如,xs.union(ys)xs.distinct

我们将在后续章节中介绍这些方法的实现细节。现在,让我们看看序列的不同变体:

序列

还有另一种序列形式,称为 Buffer,它是可变的。它允许对它进行添加、更新、移除和其他突变操作。这些突变是通过 +=、++= 和 insert 等方法完成的。还有另一个名为 MapIterable 子特质。让我们来看看它。

映射

Map 可以表示为包含键值对形式的元素集合:

trait Map[K, +V] extends Iterable[(K, V)] with GenMap[K, V] with MapLike[K, V, Map[K, V]] 

正如我们刚才所展示的,Map 混合了 IterableMapLike 特质,而 MapLike 扩展了 PartialFunction 特质,因此我们也可以将映射用作部分函数。此外,值得注意的是类型参数 KV. 在这里,类型 K 将键绑定到值 V. 这是我们定义 Map 的方法:

scala> val aMap = Map("country" -> "capital", "Poland" -> "Warsaw") 

aMap: scala.collection.immutable.Map[String, String] = Map(country -> capital, Poland -> Warsaw) 

前面的代码是一个将字符串映射到字符串的映射,这意味着它将字符串键映射到字符串值。对于任何其他集合,我们都需要一些方法来访问映射。这样做有多种类别。让我们来看看它们:

  • 关联和查找:从映射中查找元素的方法:

    • 例如,as.get(key)as(key)as 包含键
  • 添加:向现有映射中添加键值对的方法:

    • 例如,as + (key -> value)as ++ kvs
  • 移除:从给定映射中移除一对元素的方法:

    • 例如,as - (key)
  • 子集合:从给定映射中返回子集合的方法:

    • 例如,as.keysas.keySetas.values
  • 转换:通过将函数应用于给定映射的每个值来转换映射的方法:

    • 例如,as.mapValues func

Map 可以根据类别有几种不同的变体,无论是可变的还是不可变的:

映射

在前面的图像中,我们看到了 Scala 中映射的几种变体。你注意到我们在这两个版本的不同包中都有相同的 HashMap,既有可变的也有不可变的吗?我们根据需求使用这些不同的映射。在 SeqMap 之后,Iterable 另有一个子特质,名为 Set.

集合

Set 是一个包含多个元素且没有任何重复的集合:

trait Set[A] extends (A) ⇒ Boolean with Iterable[A] with GenSet[A] with GenericSetTemplate[A, Set] with SetLike[A, Set[A]] 

如前所示,Set extends (A) => Boolean 表达式*,这意味着将类型 A 的参数传递给类型 A 的集合将得到一个布尔值。布尔结果表示 Set 是否包含作为参数传递的元素。让我们通过一个例子来看看:

scala> val aSet = Set(1,2,3,4) 
aSet: scala.collection.immutable.Set[Int] = Set(1, 2, 3, 4) 

scala> aSet(2) 
res0: Boolean = true 

scala> aSet(5) 
res1: Boolean = false 

scala> aSet(0) 
res2: Boolean = false 

可以看到值 2 在 aSet 中,而值 5 和 0 不在,因此传递 5 或 0 会返回 false。让我们通过 Set 中定义的一些方法来了解一下:

  • 比较和检查:检查条件的方法:

    • 例如,xs.contains(x)xs.subsetOf(x)
  • 添加操作:向集合中添加一个元素或一组元素的方法:

    • 例如,xs + xxs ++ ys
  • 移除操作:从集合中移除一个元素或一组元素的方法:

    • 例如,xs - xxs -- ys
  • 二元操作:在两个不同集合之间执行二元操作的方法:

    • 例如,xs | ysxs & ysxsys 交集、xsys 并集等等

一个 Set 可以有几种不同的变体,这取决于它是可变的还是不可变的:

图片

集合

集合的一些变体包含不同类型,例如 HashSetBitSetSynchronizedSet,根据需要,我们可以使用它们。

现在我们已经对 Scala 中的一些顶级集合特性有了了解,让我们看看它们的具体实现。我们将讨论 Scala 中常用的集合,以及它们如何构建,Scala 在我们添加或删除元素时如何处理特定的集合。这将给你一个关于 Scala 集合行为的简要了解。

Scala 中常用的集合

让我们先讨论一些不可变的具体集合。

列表

列表是一个线性序列,可以简单地定义为如下:

val aList = List(1,2,3,4) 

显示的语法声明并实例化了由提供的元素组成的线性序列。构建的列表的运行时表示将如下所示:

1 :: 2 :: 3 :: 4 :: Nil 

在这里,Nil 代表列表的末尾。将空列表表示为 Nil 是正常的。前面的表示法也是一种构建列表的方法,这是由于 "::" 操作符的缘故。这被称为 cons 操作符,它用于构建列表。它是一个右结合操作符:

scala> aList.::(5) 
res2: List[Int] = List(5, 1, 2, 3, 4) 

在列表上调用 cons 操作符会在列表的开头添加新元素,这相当于使用以下代码调用相同的操作:

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

我们提到,像向列表中添加元素这样的操作不会影响之前创建的列表,而是将相同的列表复制到另一个带有添加元素的列表中。这是因为列表是不可变的。以下是如何呈现这种情况的图示,这将帮助你了解正在发生的事情。所以,让我们看看这个:

图片

向列表中添加元素

该图是自我解释的,它代表了向列表中添加元素的概念。列表提供了许多高阶函数,如mapflatMapfilter,这使得处理列表变得容易。有了这种构造语法和更容易访问列表的头部(列表的第一个元素)和尾部(代表列表中除了第一个元素之外的所有元素),就可以轻松创建列表模式并按需在模式匹配中使用它们。像foldreduce这样的操作也很重要,因为它们提供了一种在序列中的元素上执行二元操作的方法。这些是高性能的数据结构,并且可以提供对它们元素的常数时间访问。

Map

标准不可变映射可以像这样实例化:

scala> val aMap = Map(1 -> "one", 2 -> "two", 3 -> "three") 
aMap: scala.collection.immutable.Map[Int,String] = Map(1 -> one, 2 -> two, 3 -> three) 

我们可以提供尽可能多的键值对。我们通过使用"->" 运算符 将键与其对应的值关联起来。可以看出,映射的默认实现对应于scala.collection.immutable.Map[Int, String],如果我们尝试修改这些对,则不可能。尽管可以通过使用+ *方法添加新对来构建另一个具有更新元素的映射,但这仍然是可能的:

scala> aMap.+(4 -> "four") 
res5: scala.collection.immutable.Map[Int, String] = Map(1 -> one, 2 -> two, 3 -> three, 4 -> four) 

但这不会改变我们声明的aMap映射:

scala> println(aMap) 
Map(1 -> one, 2 -> two, 3 -> three) 

我们还讨论了映射和序列也是PartialFunction,因此我们可以检查是否为特定的键定义了值:

scala> aMap.isDefinedAt(4) 
res8: Boolean = false 

scala> aMap.isDefinedAt(2) 
res9: Boolean = true 

Map的其他版本也存在,例如ListMapSynchronizedMap.。如果需要,可以使用它们。例如,当你需要以线性方式遍历具有更好性能的映射时,你可能更喜欢使用ListMap。另外,当需要以线程安全的方式实现可变映射时,人们更愿意使用SynchronizedMap.

SortedSet

SortedSet是一个表示元素集合的特质。它根据排序顺序以排序方式生成元素,因为默认实现以二叉树的形式存储元素。SortedSet的一种形式是TreeSet. 创建TreeSet时,需要提供一个隐式Ordering[A],它负责元素的排序方式:

TreeSet()(implicit ordering: Ordering[A]) 

因此,要创建TreeSet,我们将在当前作用域中创建一个Ordering对象。没有Ordering 编译器不会允许你创建TreeSet

package chapter5 

import scala.collection.immutable.TreeSet 

object TreeSetImpl extends App { 

  //implicit val ordering = Ordering.fromLessThanInt 

  val treeSet = new TreeSet() + (1, 3, 12, 3, 5) 

  println(treeSet) 
} 

这里是结果:

Error:(9, 17) diverging implicit expansion for type scala.math.Ordering[T1] 
starting with method Tuple9 in object Ordering 
  val treeSet = new TreeSet() + (1, 3, 12, 3, 5) 
Error:(9, 17) not enough arguments for constructor TreeSet: (implicit ordering: Ordering[A])scala.collection.immutable.TreeSet[A]. 
Unspecified value parameter ordering. 
  val treeSet = new TreeSet() + (1, 3, 12, 3, 5) 

取消注释我们为Ordering定义隐式值的行将正常工作。所以,取消注释并尝试运行它。这将产生以下输出:

TreeSet(12, 5, 3, 1) 

Streams

很强大。让我们看看为什么。流可以是无限长的;无限长序列听起来可能不太实用,但当计算是惰性发生时,这就可以正常工作。流服务于相同的目的,计算也是惰性发生的。让我们看看我们如何创建流:

scala> val aStream = Stream(1,2,3,4,55,6) 
aStream: scala.collection.immutable.Stream[Int] = Stream(1, ?) 

我们没有做任何非凡的事情,只是将List关键字替换为Stream,但 REPL 返回了不同的结果:

scala.collection.immutable.Stream[Int] = Stream(1, ?) 

你可以看到这里,Stream只计算到第一个元素,因为目前没有必要去计算其他元素。这就是我们所说的惰性计算。流也可以使用cons来构建,如下所示:

scala> val anotherStream = 1 #:: 2 #:: 3 #:: Stream.empty 
anotherStream: scala.collection.immutable.Stream[Int] = Stream(1, ?) 

很容易理解,无论何时我们需要对元素进行惰性计算,我们都可以使用流。一个例子用例是当你需要从你的函数中获取短路评估。你可以传递一个流并评估。值得注意的是,流对其头元素不是惰性的,所以你的函数将针对第一个元素进行评估。

向量

当我们的需求是操作序列中间的元素时,线性序列和索引序列的性能差异开始变得重要。由于列表等序列的线性性能,它们的性能会下降。因此,索引序列就派上用场了!向量是不可变索引序列的一个例子。创建向量的方法很简单,只需使用Vector关键字及其apply方法,或者简单地表示如下:

scala> val vector = Vector(1,2,3) 
vector: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3) 

scala> println(vector) 
Vector(1, 2, 3) 

要向vector添加元素,我们可以使用:++:等方法:

scala> vector :+ 4 
res12: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3, 4) 

scala> 4 +: vector  
res15: scala.collection.immutable.Vector[Int] = Vector(4, 1, 2, 3) 

这些是按顺序索引的,以便通过传递索引来检索值:

scala> vector(2) 
res16: Int = 3 

我们得到了值 3,因为索引从 0 开始。我们很高兴我们可以检查特定索引处的值是否已定义,因为它是一个序列:

scala> vector.isDefinedAt(5) 
res17: Boolean = false 

可以使用updated方法在某个位置更新一个元素:

scala> vector.updated(2,10) 
res19: scala.collection.immutable.Vector[Int] = Vector(1, 2, 10) 

使用索引和元素调用此updated方法将替换传递的索引处的元素。这个操作的好处是它只需要常数时间,并且不会影响原始序列。因此,如果你尝试打印我们创建的序列,你会得到未更改的元素:

scala> println(vector) 
Vector(1, 2, 3) 

不可变栈

你可能需要一个具有后进先出遍历的集合。为此,Scala 提供了stack实现。创建栈很容易,可以在栈中pushpop元素:

scala> val stack = Stack(1,2,3) 
stack: scala.collection.immutable.Stack[Int] = Stack(1, 2, 3) 

scala> stack.pop 
res24: scala.collection.immutable.Stack[Int] = Stack(2, 3) 

scala> stack.push(4) 
res26: scala.collection.immutable.Stack[Int] = Stack(4, 1, 2, 3) 

栈是不可变的,所以执行任何操作都不会改变之前创建的栈中的元素。

不可变队列

对于那些还不知道的人来说,队列是一种先进先出数据结构。有两个辅助方法可以将元素放入队列并移除它们,即enqueuedequeue. 让我们创建一个队列:

scala> val queue = Queue(1,2,3) 
queue: scala.collection.immutable.Queue[Int] = Queue(1, 2, 3) 

scala> queue.enqueue(4) 
res27: scala.collection.immutable.Queue[Int] = Queue(1, 2, 3, 4) 

scala> queue.dequeue 
res28: (Int, scala.collection.immutable.Queue[Int]) = (1,Queue(2, 3)) 

上述代码是向队列中入队元素的函数。可以看出dequeue返回了被移除的元素以及队列的其余部分。

范围

范围描述了一组数字。在 Scala 中,我们可以使用一些辅助方法来创建范围。让我们来看看它们:

scala> val oneTo10 = 1 to 10 
oneTo10: scala.collection.immutable.Range.Inclusive = Range 1 to 10 

scala> val oneTo10By2 = 1 to 10 by 2 
oneTo10By2: scala.collection.immutable.Range = inexact Range 1 to 10 by 2 

scala> oneTo10 foreach println 
1 
2 
3 
4 
. . . remaining elements 

scala> oneTo10By2 foreach println 
1 
3 
5 
7 
9 

我们创建了两个范围。第一个简单地包含从 1 到 10 的数字,包括 10。然后我们创建了从 1 到 10,步长为 2 的数字。还有创建不包含最后一个元素的范围的方法。这可以通过使用util方法来完成:

scala> val oneUntil5 = 1 until 5 
oneUntil5:scala.collection.immutable.Range = Range 1 until 5 

scala> oneUntil5 foreach println 
1 
2 
3 
4 

在这里,我们使用包含数字 1 到 5 的Until方法创建了一个范围。打印这个范围产生了从 1 到 4 的数字,因为Until不包括最后一个元素。"Range"也是一个不可变集合。现在,在了解了这些不可变集合之后,让我们看看几个可变的具体集合。我们将从最常见的一个开始,即ArrayBuffer

ArrayBuffer

ArrayBuffer在 Scala 中仅作为可变序列可用。这些是高效的集合;它们使得在集合末尾添加元素变得容易。"ArrayBuffer"也是一个索引序列,因此通过索引检索元素不会降低性能。让我们看看如何在 Scala 中创建和使用ArrayBuffer

scala> import scala.collection.mutable._ 
import scala.collection.mutable._ 

scala> val buff = ArrayBuffer(1,2,3) 
buff: scala.collection.mutable.ArrayBuffer[Int] = ArrayBuffer(1, 2, 3)  

scala> println(buff) 
ArrayBuffer(1, 2, 3) 

我们可以通过首先导入scala.collection.mutable包来创建ArrayBuffer。我们可以通过在构造函数中提供元素来实例化它。我们可以使用为ArrayBuffer提供的addremove方法向缓冲区添加和移除元素:

scala> buff += 4 
res35: buff.type = ArrayBuffer(1, 2, 3, 4) 

scala> println(buff) 
ArrayBuffer(1, 2, 3, 4) 

scala> buff -= 4 
res36: buff.type = ArrayBuffer(1, 2, 3) 

scala> println(buff) 
ArrayBuffer(1, 2, 3) 

ListBuffer

ArrayBuffer使用数组来存储元素,而ListBuffer使用链表表示。对这些缓冲区执行的操作与ArrayBuffer类似:

scala> val listBuffer = ListBuffer("Alex", "Bob", "Charles") 
listBuffer: scala.collection.mutable.ListBuffer[String] = ListBuffer(Alex, Bob, Charles) 

scala> listBuffer += "David" 
res39: listBuffer.type = ListBuffer(Alex, Bob, Charles, David) 

scala> println(listBuffer) 
ListBuffer(Alex, Bob, Charles, David) 

值得注意的是,由于内部链表表示,如果我们的需求是将我们的集合转换为列表,则建议使用ListBuffer而不是ArrayBuffer。如果需求是将我们的集合转换为数组,则反之亦然。

StringBuilder

StringBuilder用于构建字符串序列。简单的调用toString将将其转换为String

scala> val builder = new StringBuilder 
builder: StringBuilder = 

scala> builder ++= "aa" 
res45: builder.type = aa 

scala> builder ++= "b" 
res46: builder.type = aab 

scala> builder.toString 
res48: String = aab 

还有其他可变的栈、队列和映射版本。提供了方法来更新这些可变集合。

数组

Scala 中的数组定义如下;它扩展了 Java 中的SerializableCloneable特性。同时,我们可以看到数组是泛型的:

final class Array[T] extends java.io.Serializable with java.lang.Cloneable 

在 Scala 中,数组是可变的。定义数组很简单:

scala> val array = Array("about", "to", "declare") 
array: Array[String] = Array(about, to, declare) 

在这里,我们可以执行我们在序列上执行的操作。数组是索引和可变的:

scala> array(0) = "Where" 

scala> array foreach println 
Where 
to 
declare 

在数组上也可以执行映射操作,这使得 Scala 的数组比它们的 Java 对应物更好。

到目前为止,我们已经介绍了所有常见的集合,并对如何声明和使用这些集合有一个基本的了解。但使 Scala 集合强大的是对这些集合执行的一组丰富操作。有一组可以使用这些集合中的一些高阶方法执行的操作。是时候去看看那些了。

在集合上执行的丰富操作

我们为我们的集合提供了一些方法,我们可以使用这些方法简化 Scala 中几乎所有的集合问题。我们将查看一些重要方法。首先,让我们设定场景:假设你有一些与足球运动员相关的结构化数据,并且你必须基于这些数据进行操作。我们将使用我们的集合对数据进行一系列操作。我们还将了解方法、它们的签名和用例。以下是一个简单的代码片段,展示了我们刚才所说的——目前没有什么特别有趣的内容:

package chapter5 

object CollectionOperations extends App { 

  val source = io.Source.fromFile("../src/chapter5/football_stats.csv")   // Give pathString for the csv file 

} 

问题是——我们有一个包含一些以逗号分隔值的 CSV 文件。我们可以在程序中以BufferredSource的形式读取数据:

io.Source.fromFile("filePath") 

这将加载 CSV 文件的全部内容。我们可以做的第一件事是将数据转换为可读格式,这样我们就可以更容易地对它进行任何操作。为此,我们有一个 case 类,Player

case class Player(name: String, nationality: String, age: String, club: String, domesticLeague: String, rawTotal: String, finalScore: String, ranking2016: String, ranking2015: String) 

我们将尝试从文件中读取内容并创建一个球员集合。以下表达式从缓冲源中读取,并将每一行转换为List中的一个元素:

source.getLines().toList 

在这里,getLines方法将缓冲源转换为字符串类型的Iterator对象。可以使用toXXX形式的调用在traversables之间进行转换。我们在Iterator对象上调用toList方法。这把字符串类型的Iterable转换为字符串列表。还有其他版本,如toIterabletoSeqtoIndexedSeqtoBuffertoSettoVector。所有这些方法都位于名为TraversableOnce的特质中。

我们可以使用toXXX方法将数据转换为另一种类型的Traversable,其中XXX是一个占位符,代表SetVectorBuffer等。

在从 CSV 文件读取内容后,我们现在有一个字符串列表,每个字符串都包含关于球员的信息,格式如下:

2016,Up/down,2015,2014,2013,Name,Nationality, Club at Dec 20 2016,Domestic league, Age at 20 Dec 2016,RAW TOTAL,HIGHEST SCORE REMOVED,FINAL SCORE,VOTES CAST,No1 PICK 

每一行都包含诸如过去几年球员排名、姓名、国籍、俱乐部、得分和年龄等信息。我们将解析字符串并将所有信息映射到我们的Player对象上。让我们来做这件事:

def giveMePlayers(list: List[String]): List[Player] = list match { 
    case head :: tail => tail map {line => 
      val columns = line.split((",")).map(_.trim) 
      Player(columns(5),columns(6),columns(9),columns(7), 
        columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 
    case Nil => List[Player]() 
  } 

在这种方法中,我们看到了一个适用于集合的重要方法。我们的giveMePlayers函数解析字符串列表,正如其名称所暗示的,它返回一个玩家列表。该函数对字符串列表执行模式匹配。我们将列表匹配为head :: tail;它将头视为列表的第一个元素,将尾视为其余元素。我们还可以看到列表可以为空;在这种情况下,将执行第二种情况,函数将返回一个空列表。正如我们从 CSV 中知道的那样,第一行包含关于文件其余部分的元信息。因此,我们省略了head并在tail上执行map操作,即列表的其余部分。map方法基本上对每个元素执行给定的操作,并返回包含结果的集合。在我们的情况下,我们正在分割每行的逗号分隔值,并将值转换为Player对象。在map方法的调用结束时,我们将得到一个玩家列表。

map方法组合另一个集合,应用传递给它的操作。

map方法的定义如下:

def mapB ⇒ B): Traversable[B] 

map方法接受一个将类型A转换为类型B的函数,并对集合的每个元素执行该操作,最后返回类型为 B 的集合。在我们的情况下,类型ABStringPlayer.

到目前为止,我们的代码看起来是这样的:

package chapter5 

object CollectionOperations extends App { 

  val source = io.Source.fromFile("/Users/vika/Documents/LSProg/LSPWorkspace/FirstProject/src/chapter5/football_stats.csv") 
  val bufferedSourceToList: List[String] = { 
    val list = source.getLines().toList 
    source.close() 
    list 
  } 

  def giveMePlayers(list: List[String]): List[Player] = list match { 
      case head :: tail => tail map {line => 
        val columns = line.split((",")).map(_.trim) 
        Player(columns(5),columns(6),columns(9),columns(7), 
          columns(8),columns(10), columns(12), columns(0),columns(2)) 
      } 
      case Nil => List[Player]() 
    } 

  val players = giveMePlayers(bufferedSourceToList)} 

case class Player(name: String, nationality: String, age:String, club: String, domesticLeague: String, rawTotal: String, finalScore: String, ranking2016: String, ranking2015: String) 

现在,我们有了由我们的map方法完全形成的Players集合。我们可以执行许多不同的操作。我们可以制作一个前 10 名玩家的列表。这该如何做到?通过使用我们的filter方法:

val filterTop10 = players filter(_.ranking2016.toInt < 11)

这很简单;这只是一个对filter的调用,然后我们告诉它要基于什么谓词过滤元素。它会为你进行过滤。我们已经检查了每个玩家的排名,并保留了那些值小于 11 的玩家:

  • filter方法过滤掉满足谓词的集合元素

  • filterNot方法过滤掉不满足谓词的集合元素

filter方法的签名如下:

def filter(p: A => Boolean): Repr 

这个filter方法接受一个基于谓词的方法。该方法过滤掉可遍历的。在这里,Repr是集合的类型参数,我们的可遍历集合看起来像TraversableLike[+A, +Repr]

要检查该方法是否过滤出了正确的玩家,你可能想打印出来看看。为什么不以某种结构化的方式打印我们的玩家呢?看看以下代码:

def showPlayers(players: List[Player]) = players.foreach{p => 
  println(s"""Player: ${p.name}    Country: ${p.nationality}   Ranking 2016: ${p.ranking2016}

***** Other Information *****
Age: ${p.age}  |  Club: ${p.club}  |  Domestic League: ${p.domesticLeague}
Raw Total: ${p.rawTotal}  |  Final Score: ${p.finalScore}  |  Ranking 2015: ${p.ranking2015}
##########################################################""")
 }

我们定义了showPlayers函数,它接受一个列表,并以以下方式打印玩家信息:

Player: Cristiano Ronaldo  Country: Portugal       Ranking 2016: 1  

***** Other Information *****  
Age: 32  |  Club: Real Madrid  |  Domestic League: Spain  
Raw Total: 4829  |  Final Score: 4789  |  Ranking 2015: 2 
########################################################## 

我想你已经足够敏锐地注意到,过滤会遍历整个列表来检查排名是否小于 11,太棒了!也要理解,如果列表是有序的,我们不会以这种方式对我们的集合做这件事。在这种情况下,我们有几种其他方法,其中之一是takeWhile*:

val takeTop10 = players takeWhile(_.ranking2016.toInt < 11) 

这个takeWhile方法也接受一个谓词,在我们的情况下是相同的,并返回一个元素列表,这些元素的谓词为真。在我们的情况下,它工作得很好,我们得到了前 10 名玩家:

  • takeWhile方法取满足谓词的最长子集合元素

  • dropWhile方法丢弃满足谓词的最长子集合元素

签名看起来几乎与我们的filter方法完全相同。它接受一个谓词,并返回一个可遍历的集合:

def takeWhile(p: (A) ⇒ Boolean): Traversable[A] 

def dropWhile(p: (A) ⇒ Boolean): Traversable[A] 

该方法还有一个dropWhile版本。其意图几乎与takeWhile相同;唯一不同的是它丢弃满足谓词的元素。根据我们的需求,可能会有更多这类方法。其中之一是partition方法,它将我们的列表拆分为两个列表的元组:一个满足谓词,另一个不满足。请看以下代码片段:

val first50Players = players take 50 
val (top20,least30) = first50Players partition(_.ranking2016.toInt < 21) 
showPlayers(top20) 

首先,take方法从我们的玩家列表中选择 50 名玩家。然后,我们对前 50 名玩家调用partition方法,根据我们传递的谓词将我们的列表拆分为两个子列表。在这里,我们想要将前 50 名玩家分成两个单独的集合,每个集合有 20 名和 30 名玩家。调用此函数后,我们得到两个新值top20least30,它们分别包含前 20 名玩家和前 50 名中的后 30 名玩家。

一行简单的代码就可以用元素集合做到这么多;这就是 Scala 集合的力量:

  • take方法从集合中选择前n个元素

  • drop方法从集合中丢弃前n个元素

  • partition方法从集合中丢弃前n个元素

这些方法的签名很简单:

def drop(n: Int): Traversable[A] 

def take(n: Int): Traversable[A] 

def partition(p: (A) ⇒ Boolean): (Traversable[A], Traversable[A]) 

takedrop方法接受要选择或丢弃的元素数量。另一个方法partition期望一个谓词,将集合拆分为两个子集合,并返回这两个子集合的元组。还有一些其他的方法;让我们来看看它们:

  • slice方法选择元素的一个区间

  • span方法根据谓词将集合分割成两个集合,其中元素的顺序不被保留

  • splitAt方法在给定位置分割集合

这些方法很简单,因为它们确实做了它们描述中所说的。它们的描述也解释了同样的内容:

def slice(from: Int, until: Int): Traversable[A] 

def span(p: (A) ⇒ Boolean): (Traversable[A], Traversable[A]) 

def splitAt(n: Int): (Traversable[A], Traversable[A]) 

slice方法接受初始和最后一个索引,并返回相应数量的元素作为集合。第二个方法spanpartition方法的工作方式完全相同。它接受一个谓词并返回一对集合:第一个满足谓词,第二个不满足。元素的顺序可能没有被保留。

最后一个方法splitAt接受一个值n,并返回一对子集合,在n处分割。这些方法使得实现如下场景变得容易:

Select Players from Germany who have ranking in Top 50\. 

first50Players filter(_.nationality.equals("Germany")) 

让我们看看另一组方法,使用这些方法我们可以检查集合中的谓词:

  • count 方法计算满足给定谓词的元素数量

  • exists 方法检查给定集合中是否至少有一个元素满足谓词

  • forAll 方法检查给定集合中所有元素是否满足谓词

  • find 方法找到满足谓词的第一个元素

我们可以这样计算来自特定国家的玩家数量:

val isGermanPlayer: (Player => Boolean) = _.nationality.equalsIgnoreCase("Germany") 

val numberOfGermanPlayers = players count isGermanPlayer 
println(s"German Players: $numberOfGermanPlayers") 

Run: 
German Players: 17 

部分函数 isGermanPlayer 检查球员的国籍。然后我们将这个部分函数作为谓词传递给 count 方法它给出了球员的数量。我们可能还想检查是否有年龄超过 45 岁的球员,我们可以使用 exists 方法来检查:

val isAnyPlayerAbove45 = players exists(p => p.age.toInt > 40) 
println(s"isAnyPlayerAbove45: $isAnyPlayerAbove45") 

Run: 
isAnyPlayerAbove45: false 

还有另外两个方法,forAllfind. 我们将检查年龄超过 35 岁的顶级球员:

val topPlayerWithAge35plus = players find(p => p.age.toInt > 35) 
printPlayer(topPlayerWithAge35plus.get) 

Run: 
Player: Zlatan Ibrahimovic       Country: Sweden   Ranking 2016: 20  

***** Other Information *****  
Age: 36  |  Club: Manchester United  |  Domestic League: England  
Raw Total: 1845  |  Final Score: 1809  |  Ranking 2015: 7 
########################################################## 

这些方法简单而强大,组合它们可以将我们的解决方案方法简化。让我们通过排名找出年龄超过 35 岁的顶级前 5 名球员:

val top5PlayerWithAge35plus = players filter isAge35plus take 5 
showPlayers(top5PlayerWithAge35plus) 

Run: 
Player: Zlatan Ibrahimovic       Country: Sweden   Ranking 2016: 20  

***** Other Information *****  
Age: 36  |  Club: Manchester United  |  Domestic League: England  
Raw Total: 1845  |  Final Score: 1809  |  Ranking 2015: 7 
########################################################## 
. . . and next 4 player information 

其中一个例子是我们首先在我们的球员列表上调用 filter 方法,然后调用 take 5 来从结果中选择前 5 个。我们已经看到了这些例子,那么让我们看看这些方法的定义:

def find(p: (A) ⇒ Boolean): Option[A] 

def count(p: (A) ⇒ Boolean): Int 

def exists(p: (A) ⇒ Boolean): Boolean 

def forall(p: (A) ⇒ Boolean): Boolean 

所有方法都接受一个谓词并作出不同的响应。find 方法从集合中选择满足谓词的第一个元素。接下来的 countexists 方法分别检查满足谓词的元素总数以及是否存在满足谓词的单个元素。

最后,forAll 方法检查集合中所有元素是否满足谓词。我们也可以使用 isEmpty 来检查列表中是否有任何元素,因为很明显,对集合结果的过滤将得到一个空列表。有一些方法可以用来检查列表信息:

  • isEmpty 方法计算满足给定谓词的元素数量

  • hasDefiniteSize 方法检查给定集合中是否至少有一个元素满足谓词

  • size 方法检查给定集合中所有元素是否满足谓词

这些方法,正如它们的名称所暗示的,使用简单且易于理解。记得我们使用 head :: tail 在列表上进行了模式匹配吗?嗯,我们也可以以同样的方式在我们的集合上调用这些方法。还有一些额外的实用方法来访问列表元素:

  • head 方法返回集合的头元素

  • tail 方法返回除了头元素之外的所有元素

  • init 方法返回除了最后一个元素之外的所有元素

  • last 方法返回集合的最后一个元素

  • reverse 方法返回反转的列表

我们可以在进行模式匹配时使用这些方法,或者在我们可能需要检索第一个或最后一个元素时。递归地使用inittail方法也是利用列表元素的一种方式。最后,我们可以对元素列表执行的最重要操作之一是将列表折叠或归约为一个值——一个单一值。因此,我们可以折叠我们的球员列表,并从该列表中构建另一个国家名称列表。我们如何做到这一点?让我们看看我们如何使用fold操作:

  • fold方法使用二进制关联操作折叠集合

  • foldLeft方法通过从左到右应用二进制操作来折叠集合

  • foldRight方法通过从右到左应用二进制操作来折叠集合

假设我们想从我们前 20 名球员的信息中构建一个国家名称列表。我们可以这样做:

val Top20Countries = top20.foldLeft(List[String]())((b,a) => a.nationality :: b)

运行代码后,我们会得到以下结果:

List(Sweden, England, Germany, France, France, Spain, Argentina, Belgium, Croatia, Argentina, Algeria, Chile, Gabon, Poland, Wales, Brazil, France, Uruguay, Argentina, Portugal)

这也可以通过从右到左遍历列表来完成:

val top20Countries = top20.foldRight(List[String]())((b,a) => b.nationality :: a) 

运行代码后,我们会得到以下结果:

List(Portugal, Argentina, Uruguay, France, Brazil, Wales, Poland, Gabon, Chile, Algeria, Argentina, Croatia, Belgium, Argentina, Spain, France, France, Germany, England, Sweden) 

fold方法的定义如下:

def foldLeftB(op: (B, A) ⇒ B): B 

def foldRightB(op: (A, B) => B): B 

此方法需要一个初始值;在我们的例子中,它是一个列表。然后我们传递一个函数,该函数作用于我们的集合中的每个元素,传递的值作为操作函数的种子。foldLeftfoldRight方法对这些两个元素执行二进制操作,直到处理完集合中的最后一个元素,从而为我们产生最终值。如果你看看这两种方法,你会看到参数的顺序已经改变。此外,这些foldLeftfoldRight方法的签名是柯里化的。第一个柯里化参数是初始元素,它在遍历或折叠我们的集合时充当累加器。第二个参数是一个二进制函数,它作用于集合元素。这个fold函数在集合上产生一个值,它是整个集合的累积响应。

在使用所有这些使处理集合更容易的方法之后,让我们看看一个可以将我们的集合转换为并行集合的方法,这个并行集合可以并行处理。这个方法是par:当你对我们的集合调用该方法时,它返回一个ParSeq,即一个并行序列。这个并行序列是集合的并行等价物。

如果你尝试多次打印这个序列的元素,顺序将不会保留,因为序列的并行性质:

top20Countries.par map(println(_)) 

运行代码后,我们会得到以下结果:

Wales 
Portugal 
Argentina 
France 
Croatia 
Argentina 
Poland 
France 
Uruguay 
. .. remaining elements 

现在我们知道我们可以将我们的集合转换为它们的并行等价物,应该有其他方法来构建并行集合。让我们看看 Scala 中的并行集合。

Scala 中的并行集合

显然,如果集合中的元素数量非常大,那么你希望最小化操作集合数据所需的时间。这就是将任务分解并并行执行成为选择,并且是一个很好的选择。Scala 以并行集合的形式提供并行性,在处理大量数据的情况下表现得非常出色。好事是,我们的 par 方法可以轻松地将普通顺序集合隐式转换为它的并行对应版本,并且 map foldfilter 方法与并行集合也配合得很好。

理解并行集合的架构,或者这些在 JVM 上的工作原理,超出了本书的范围。我们将把讨论限制在并行集合的具体实现上,以及如何在 Scala 中使用它们。如果你对理解并行集合感兴趣,Scala 的文档在 docs.scala-lang.org/overviews/parallel-collections/overview 提供了一个简要概述。另一个资源是亚历山大·普罗科佩奇(Aleksandar Prokopec)所著的《Learning Concurrent Programming in Scala》一书。现在,让我们从 Scala 中并行集合的具体实现开始。有几个并行集合类,例如 ParArray、* ParVectorParRange,还有一些 setmap 实现,如 ParHashMapParHashSet.*

ParArray

ParArray 构造函数是 ArraySeq 的并行实现,以线性方式存储元素。它位于 scala.collection.parallel.mutable 包中。要创建并行数组,我们可以按如下方式导入此包:

scala> import scala.collection.parallel.mutable._ 
import scala.collection.parallel.mutable._ 

scala> val pararr = ParArray(1,2,3,4,5,6,7,8,9,10) 
pararr: scala.collection.parallel.mutable.ParArray[Int] = ParArray(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) 

在这里,我们通过简单地使用 ParArray 构造函数,并传递作为参数的元素,创建了一个名为 pararr 的并行数组。出于演示目的,我们在实现中使用了有限数量的元素,但很明显,我们希望并行抽象包含更多的元素,以便真正有效地工作。还有可能使用 seq 方法将并行集合转换为它的顺序对应版本:

scala> pararr.seq 
res1: scala.collection.mutable.ArraySeq[Int] = ArraySeq(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) 

并行数组是一个并行可变集合。我们还有并行集合的不变版本。ParVector 就是其中之一。

ParVector

ParVector 是一个不可变的并行序列。我们可以以创建并行数组类似的方式创建并行向量:

scala> val parvec = Vector(1,2,3,4,5,6,7,8,9,10) 
parvec: scala.collection.immutable.Vector[Int] = Vector(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) 

我们可以使用 map* 等方法执行相同的操作,这些方法可以从其顺序对应版本中使用。让我们以大于 5 的整数为例,从我们的 parvec 并行向量中提取:

scala> parvec.filter(_ > 5) 
res0: scala.collection.immutable.Vector[Int] = Vector(6, 7, 8, 9, 10) 

And yes we can anytime convert our collection to it's sequential version using seq method. 

scala> parvec.seq 
res1: scala.collection.immutable.IndexedSeq[Int] = Vector(1, 2, 3, 4, 5, 6, 7, 8, 9, 10) 

以类似的方式,我们还可以创建 ParHashSetParHashMap. 这些是不可变的并行集合。ParHashMap 通过使用链式机制来避免内部冲突。

值得注意的是,并行化集合的基本思想是使用诸如分而治之等算法将其分割成更小的部分。然后使用多个任务对这些较小的部分进行操作。Scala 的并行集合通过一个可配置的 scala.collection.parallel.TaskSupport 对象来执行这个任务调度。同时,也应该记住,副作用操作是不可预测的,当并行执行时,它们可能会产生死锁或竞争条件。因此,作为程序员,我们有责任编写避免这些竞争条件的代码。并行集合使得解决需要处理大量数据的问题变得更加容易。它们使 Scala 中的集合更加强大。当您使用来自库的任何 Java 集合时,您可能也想利用这种力量;在这种情况下,您可能想阅读下一节。

将 Java 集合转换为 Scala 集合

Scala 与许多 Java 库进行交互,因此您可能不得不处理 Java 代码。您可能会遇到这样的情况:您有一个 Java 集合,并且需要将其转换为 Scala。有了从 Java 到 Scala 转换集合的想法,我们可能会觉得有点可怕,因为我们需要遍历 Java 集合中的元素,然后将它们追加到 Scala 集合中。但是,这里有一个转折点:已经有方法可以做到这一点。很简单:在 scala.collection 包中有一个 JavaConverters 对象,它负责这个转换。实际上,当您调用这些转换方法时,幕后发生的是隐式转换。为什么不看看一个例子呢:

package chapter5

import java.time.LocalDate
import scala.collection.JavaConverters._

object CollectionConvertors extends App {
    /*
     We'll create a java collection of a couple of days and convert it to Scala Collection
     */
    val aJavaList = new java.util.ArrayList[LocalDate]()
    aJavaList.add(LocalDate.now())
    aJavaList.add(aJavaList.get(0).plusDays(1))

    println(s"Java List of today and tomorrow: $aJavaList")

    val scalaDates = aJavaList.asScala
    scalaDates map { date =>
      println(s"Date :: $date")
    }

    val backToJavaList = scalaDates.asJavaCollection
    println(backToJavaList)
}

以下为结果:

Java List of today and tomorrow: [2017-10-01, 2017-10-02]
Date :: 2017-10-01
Date :: 2017-10-02
[2017-10-01, 2017-10-02]

下面是这个程序。我们有一个名为 aJavaList 的 Java 列表,它包含一些日期对象。是的,这不是一个复杂的问题;我们只需将这个列表转换为 Scala 集合,这样我们就可以执行像 mapflatMap 这样的高级操作。为此,正如我们所看到的,我们导入了 scala.collection.JavaConvertors 对象。导入此对象后,我们现在可以访问一个转换方法,asScala,它将您的 Java 集合转换为 Scala 对应的集合,该转换在内部检查适合转换的集合,并且转换是隐式发生的。最后,在转换之后,我们能够在 scalaDates 对象上使用 map 函数,而 scalaDates 不是一个 Scala 集合。

同样,您也可以使用 asJava 将 Scala 集合转换为 Java 对应的集合。所以写点这样的东西是完全没问题的:

val backToJavaList = scalaDates.asJava 
println(backToJavaList) 

它会将 Scala 集合转换为 Java 集合。当你从源集合转换到目标集合,然后再重新转换时,你实际上得到了主要真实对象。现在你已经看到了所有的 Scala 集合,并且也有了一个想法,即 Java 集合也可以转换为 Scala 集合,因此对于任何需求,你有很多选择。

选择一个集合

这里的问题是:在学习了这么多在 Scala 中创建元素集合的方法之后,我们似乎面临着一个大困境。我们有相当多的选择。几乎每个集合都有高阶函数来操作。这些是高性能、类型参数化和不可变的集合。那么我们如何决定使用哪个集合呢?现在,我们有了答案。答案是,这取决于。是的,这取决于多个因素;例如,你希望以什么格式拥有数据。是简单的序列还是成对格式?显然,我们到目前为止一直在谈论序列和映射。在大多数情况下,我们会选择 MapSetListArrayBufferVector*。让我们谈谈影响这些选择的因素:

  • 如果需要键值查找,我们使用 Maps。

  • 当顺序不重要时,我们使用 HashMap;当我们想要按顺序存储键值对时,我们使用 ListMapListMap 的操作时间是线性的,因为元素的数量增加。所以,在正常情况下,使用 Map 是推荐的;如果,某种情况下,我们需要操作几个集合元素中的第一个,那么 ListMap 可以成为一个更好的选择。

  • 如你所知,集合不包含重复元素,因此为了去除重复项,我们可能选择使用 Set,或者将我们的集合转换为 Set.* 在 Scala 中,Set 也扩展了 (A) ⇒ Boolean,这意味着我们可以使用集合来检查元素在我们集合中的存在。所以很简单:如果你经常需要检查元素的存在,或者需要去除重复项,请使用 Set

  • 如果你需要存储有限元素、遍历它们或对它们执行某些操作,请选择 List。如果需要可变性的话,ArrayBuffer 也是一个不错的选择。List 是一个线性序列,所以当元素数量增加时,执行操作的成本很高,因为性能是线性的。

  • 如果需要随机访问,且遍历不是很重要,建议使用索引序列,所以请给 ArrayBuffer 一个机会.

  • 如果你需要更快的随机访问和持久序列,请使用 Vector. 是的,Vector 是持久的,因为它保留了它自己的上一个版本。这是不可能用 ArrayBuffer* 实现的,因为它是可变的。

  • 惰性求值在处理 Streams. 时可以是一个优点。当需求是基于需要评估集合元素时,这是一个明智的选择。我们已经看到了 Stream 的表示,因为元素是惰性计算的。

  • 最后,不可变对象。我们可以即时创建一个基于某些集合大小的 Range,或者类似的东西。使用 inuntilby 方法创建 Range 很简单。

因此,这主要取决于我们将如何使用我们的集合元素。当然,性能对我们来说至关重要,因为集合占据了将逻辑写入我们程序的大部分。我们可以从一个错误的集合选择开始,然后根据需要转换它们。这可能看起来很简单,但肯定会影响性能。例如,如果你在程序后期决定将其转换为列表,使用 ListBuffer 是明智的。原因是 ListBuffer 在内部以链表格式存储元素。因此,转换为列表比转换为数组要容易。不同的操作,从实例化集合到更新、追加,或在你的集合上调用头部或尾部,都需要不同数量的时间,这可能会使你的代码性能降低。所以,想法是根据你的程序中什么最重要来明智地选择。那么,为什么不比较几个集合的性能呢?

集合性能

你可以用集合做什么?让我们考虑一些用例:

  • 首先,创建一个集合对象

  • 插入一个元素

  • 对集合中的每个元素执行操作,这只有在访问每个元素时才可能

  • 遍历集合

  • 将其分解成部分,可能是一个包含单个第一个元素的单独部分,另一个包含集合的其余部分(显然是 headtail

  • 随机查找特定元素

  • 更新一个元素

  • 反转集合

因此,这涵盖了你可以对集合做的几乎所有事情。好事是,如果你确定你将在集合上执行的操作类型,你可以编写一个性能良好的程序。坏消息是我们很少考虑程序后期将要执行的操作,除非你很幸运。但我们已经了解了所有集合及其性能。你可能想参考 Scala 官方文档中的性能特性表,网址为 docs.scala-lang.org/overviews/collections/performance-characteristics.html.

以下观察结果将有助于在集合上调用 applyappendheadtail 操作的想法:

  • 列表****: 列表是一个线性序列,因此随着元素数量的增加,applyappend 等方法需要更多的时间。但访问 headtail 元素需要固定的时间:
apply => 线性 append => 线性 head => 常数 tail => 常数
  • 对于Stream也是如此。

  • 向量:向量在某些方面比列表性能更好,applyappend操作肯定比列表更高效。

apply => 近似常数 append => 近似常数
head => 近似常数 Tail => 近似常数
  • 范围:对于需要常数时间访问apply headtail操作的人来说,范围是一个不错的选择。
apply => 常数 head => 常数 tail => 常数
  • 字符串:字符串和数组在applyhead操作中具有常数时间响应,但tail对这些操作来说是一个昂贵的操作。
apply => 常数 head => 常数 tail => 线性
  • 映射:映射用于根据键进行值查找或添加/删除键值对。HashMap有效地为这些操作提供了常数时间。
lookup => 近似常数 add => 近似常数 remove => 近似常数

了解集合的结构几乎可以提供关于其性能的所有信息。我们现在对 Scala 中的所有集合都有了了解,所以现在是时候更多地练习和实验这些集合了。

摘要

本章介绍了 Scala 集合的实现。我们开始学习不可变和可变集合。之后,我们讨论了 Scala 的集合层次结构,其中我们学习了各种超特性,如TraversableIterable. 我们还讨论了三种抽象集合类型:SeqSetMap。然后我们查看 Scala 中最常用的集合。之后,我们更进一步地学习了所有用于处理集合的重要函数。然后我们学习了如何将集合从 Java 转换为 Scala,反之亦然,并发现这很容易。之后,我们讨论了从所有这些选项中选择哪个集合,这使我们考虑了集合的性能特性。

通过这种方式,我们结束了第一部分。在下一部分,我们将从 Scala 提供的面向对象和函数式结构开始。下一章将介绍 Scala 中面向对象结构的基础,如类、特性和对象。学习它们将使我们能够有效地在本书的后续部分利用函数式结构。

第六章:面向对象 Scala 基础

“有一种核心品质,是人的生命和精神、城镇、建筑或荒野的根本标准。这种品质是主观且精确的。”

  • 永恒的建筑方式

由于 Scala 包含了许多优点,许多程序员会选择 Scala。这是一种既具有函数式又具有面向对象特性的语言,这对程序员来说意义重大。它为我们提供了一种以模块化和有意义的方式构建应用程序的方法。重要的是要知道,Scala 的函数式概念是基本、强大且位于我们程序核心的。毫无疑问,代数数据类型已经提供了必要的抽象和不可变数据结构,使得代码能够在并发环境中工作。但现实世界的应用可能需要更多。通常,我们编写的代码量使得管理它们变得至关重要。这就是面向对象抽象发挥作用的地方。现在我们有了类和对象,它们提供了一种创建可管理代码的方法。在接下来的几章中,我们将掌握 Scala 中的这些面向对象概念,它们将帮助我们在开始越来越多地使用函数式概念和抽象时。

如果你来自 Java 或其他面向对象语言背景,你可能熟悉面向对象的基础知识。如果不熟悉,我们将随着讨论的进行讨论这些概念。主要的是,当我们讨论面向对象原则时,我们会提出四个核心原则,即:抽象封装继承多态。它们的工作方式正如其名。例如,抽象基本上是隐藏任务或过程的具体或内部处理,用更简单的话说,就是使某物抽象化。在 Scala 中,我们有抽象类、特性和一些其他概念,它们提供了抽象。对象是提供封装的方式,基本上是将有意义的一段代码封装在一个单元中。

我们可以从父类继承成员及其行为,并将它们放入称为子类的其他类中。这种特性被称为继承。最后,多态,正如其名,意味着定义和执行一个操作的不同方式。多态的一个例子是方法重写。

本章是 Scala 中类和对象的基本介绍。为了使我们的议程清晰,我们将主要在本章中探讨三个主题:

  • 对象

  • 情况类

要理解 Scala 中的类,让我们先明确一点:类不仅仅为我们做一件事情。类在我们的程序中充当成员的容器,就像在任何其他面向对象的语言中一样,我们可以创建我们类构造的实例并重用它们。当我们提到成员时,我们指的是在内部定义的变量和方法。为什么不看看一个简单的 Scala 类呢?

class Country(var name: String, var capital: String) 

是的,前面的代码是我们定义的一个名为Country的类. 它有两个成员,分别命名为namecapital. 让我们创建一个新的国家实例并打印其值:

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  println(s"Country Name: ${country.name} and Capital: ${country.capital}") 
} 

运行前面的代码,我们得到以下结果:

Country Name: France and Capital: Paris 

现在,请相信我,一旦我告诉你,具有相同功能的 Java 类可能需要更多几行代码,你将很难抗拒 Scala。看看下面的代码:

public class CountryJava { 
    private String name; 
    private String capital; 

    public CountryJava(String name, String capital){ 
        this.name = name; 
        this.capital = capital; 
    } 

    public void setName(String name){this.name = name;} 
    public String getName(){return this.name;} 

    public void setCapital(String capital){this.capital = capital;} 
    public String getCapital(){return this.capital;}
 } 

这还不是结束。现在我们将创建一个实例并打印国家对象:

class CountryJavaApp { 
    public static void main(String[] args) { 
        CountryJava country = new CountryJava("France", "Paris"); 
        System.out.println("Country Name: "+ country.getName() + " and Capital: "+ country.getCapital()); 
    } 
} 

以下是将要输出的内容:

Country Name: France and Capital: Paris 

我们之所以根据定义类的方式区分它们,是为了看到简洁性。很多不必要的或样板代码已经被省略。由于我们在定义类时添加了var关键字,所以我们的成员如namecapital的访问器仍然存在。

让我们尝试省略类构造函数中的varval关键字:

class Country(name: String, capital: String) 

这两个,namecapital,将作为类构造函数的参数保留。它们的范围将受到限制;在类作用域之外无法使用这些参数。为了更好地理解,可以将类构造函数想象成一个带有一些参数的方法。在调用方法时,我们传递一定数量的参数,它们的范围限制在函数的定义内。Scala 类也是这样。你可以考虑一些使用类构造函数参数的用例:

println(s"Country Name: ${country.name} and Capital: ${country.capital}") 

编译器不会让你访问namecapital成员。

当我们使用var关键字作为构造函数参数的修饰符时,我们可以访问这些参数。所以,如果你在这个类外部实例化Country对象,你将得到这两个字段的引用并可以访问它们。将var作为前缀允许你重新分配参数的值;但这不是一个好主意。以下代码片段做了同样的事情:

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  country.name = "Germany" 
  country.capital = "Berlin" 
  println(s"Country Name: ${country.name} and Capital: ${country.capital}") 
} 

以下是将要输出的内容:

Country Name: Germany and Capital: Berlin 

对于val关键字也是同样的情况。唯一的区别是val参数只能被读取而不能被修改。换句话说,你的成员变成了不可变的:

class Country(val name: String, val capital: String) 

前面的定义允许你访问名为namecapital的成员,但它不会让你在实例化后更改namecapital的值:

country.name = "Germany" 
country.capital = "Berlin" 

如果你尝试这样做,编译器将抛出一个错误,指出“将值赋给 val”。还有一个结构允许你这样做,这意味着你实际上并没有在构造函数参数中放置val,而是使用了一个case类。定义一个case类就像定义任何其他类一样简单:

case class Country(name: String, capital: String) 

使用默认参数的case类意味着它将它们作为不可变参数接收;没有必要显式地将它们声明为val。我们将在后续主题中了解更多关于case类的信息。我们还可以在我们的类中声明一些方法,这些方法将特定于我们类的实例。例如,让我们添加一个方法,用于获取给定年份的人口。为了使其工作,我们将添加一个包含年份和人口的百万单位的映射。以下内容仅用于说明目的:

class Country(val name: String, val capital: String){ 
  var populationMap = scala.collection.mutable.Map[String, Double]() 

  def getPopulation(year: String): Double = populationMap(year) //In Million 
} 

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  country.populationMap += ("2015" -> 64.39) += ("2016" -> 64.67) += ("2017" -> 64.93) 
  println(s"Country Name: ${country.name} and Population 2017: ${country.getPopulation("2017")} million") 
} 

以下输出:

Country Name: France and Population 2017: 64.93 million 

在前面的类定义中,我们为population定义了一个可变的映射,它根据年份作为键存储一个国家的人口。传递一个年份将返回该年份的人口(以百万为单位)。现在,country类的每个实例都将包含对这些成员的单独引用。你可以想象,对于每个我们创建的实例,都有一个单独的namecapitalpopulationMapgetPopulation方法。然后,我们赋予这些成员的值也是相互独立的。然而,在实例成员指向不同的值对象时,我们可能有两个不同的引用指向相同的值。让我们看一下以下图示,以使其更清晰:

图片

类实例

重要的是要知道,当我们编译我们的类时,它们会被转换为它们的 Java 等效代码。当转换为 Java 等效代码时,具有var构造参数的类看起来如下:

public class chapter6.Country { 
     public java.lang.String name(); 
     public void name_$eq(java.lang.String); 
     public java.lang.String capital(); 
     public void capital_$eq(java.lang.String); 
     public chapter6.Country(java.lang.String, java.lang.String); 
} 

上述方法,name()capital(),作为获取器返回这两个字段的值。其他两个是名为name_$eqcapital_$eq的方法。我们可以使用这些方法来赋值:

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  country.name_=("Germany") 
  country.capital_=("Berlin") 
  println(s"Country Name: ${country.name} and 
                    capital: ${country.capital}") 
} 

以下输出:

Country Name: Germany and capital: Berlin 

这表明我们没有显式创建这些修改器方法,但 Scala 编译器为我们完成了这项工作。注意,name_$eq代表name_=,没有其他含义。最后,编译形式中最后表示的方法实际上是Country类的构造函数。

使用val的类构造函数无法访问修改器方法。这意味着类的编译形式不包含修改器方法:

public class chapter6.Country { 
    public java.lang.String name(); 
    public java.lang.String capital(); 
    public chapter6.Country(java.lang.String, java.lang.String); 
} 

上述代码是我们Country类使用val参数的编译形式。在这里,我们只能访问访问器方法,而不能访问修改器方法。

默认情况下,我们定义的所有成员都作为公共成员工作。这意味着可以在类外部访问它们。我们可以通过添加private修饰符来使它们私有:

private def getPopulation(year: String): Double = populationMap(year) 
//In Million 

为了说明这一点,让我们将我们的getPopulation(year: String)方法设为私有。之后,即使有Country类的实例,我们也将无法在类外部执行此操作:

println(s"Country Name: ${country.name} and Population 2017: ${country.getPopulation("2017")} million")

Scala 编译器不会允许你执行此操作。关于 Scala 中的类,还有一点需要注意,那就是你必须为你的类定义 toStringequalshashCode 的实现。如果你想让 Scala 以某种特定格式打印你的类实例或执行相等操作,这些方法是必不可少的。为了消除这种冗余,建议使用 case 类。如果不定义这些方法,尝试运行以下代码:

val country = new Country("France", "Paris") 
println(country) 

Scala 将直接打印运行时实例,即 Country@2d209079. 为了使这个输出有意义,我们可以重写 toString 方法并给出其定义:

class Country(val name: String, val capital: String){ 
  override def toString: String = s"Country($name, $capital)" 
} 

现在,如果你尝试打印 Country. 的一个实例,它将以前面的格式打印:

val country = new Country("France", "Paris") 
println(country) 

以下为结果:

Country(France, Paris) 

有时我们想要创建只包含类名、表示某些后续类想要继承的类型、且为抽象的类。我们将这些声明为抽象类。

抽象类

我们可以使用 abstract 关键字来定义抽象类:*

abstract class Person 
class Customer extends Person 
class Employee extends Person 

在这里,我们想要的两个子类也可以被当作超类(在我们的例子中是 Person.)的实例。目前,我们在抽象类中还没有展示任何行为。但是,有时我们想在抽象类中暗示一些行为,以便后续的子类可以继承并自行定义:

abstract class Person(category: String) { 
  val idPrefix: String 
} 

class Customer extends Person("External") { 
  override val idPrefix: String = "CUST" 
} 

class Employee extends Person("Internal") { 
  override val idPrefix: String = "EMP" 
} 

我们现在对使用抽象类的意图更清晰了。我们可能想要一组继承自特定类的类。当我们扩展类时,我们可以在定义中使用 override 修饰符。这种行为很可能会在 Scala 的另一个概念中体现出来,那就是 trait.

抽象类和 traits

你可以声明如下内容:

trait Person { 
  val category: String 
  val idPrefix: String 
} 

class Customer extends Person { 
  override val category: String = "External" 
  override val idPrefix: String = "CUST" 
} 

class Employee extends Person { 
  override val category: String = "Internal" 
  override val idPrefix: String = "EMP" 
} 

在这里,我们使用了 trait. 我们将在下一章中了解更多关于 traits 的内容。现在,让我们看看它们与抽象类有何不同。我们可能会发现,我们在抽象类中提供了构造函数参数;这是 traits 所不可能的:

abstract class Person(category: String) //can give cons params 

trait Person(category: String) //can't give cons params  

我们不能为 traits 提供构造函数参数。然而,我们可以在抽象类和 traits 中定义方法。这不是我们想要使用类的唯一方式。也许你不想让任何其他类继承你的类。在这些情况下,我们可能需要使用 final 关键字来声明类。

最终类

如果你来自 Java 背景,你可能对 String 类有这个想法:

public final class String extends Object 

final 关键字告诉你你不能从 String 类继承. 同样地,我们也可以使用这个 final 关键字来创建具有无法被任何其他类修改或继承的行为的类。你已经明白了这个概念。如果你将我们的 Person 类声明为 final,那么你将无法继承它。但在 Scala 中,你还可以将类声明为 final 以及 abstract. 是的,以下是这样做的:

scala> abstract final class Person 
defined class Person 

scala> class Employee extends Person 
<console>:12: error: illegal inheritance from final class Person 
       class Employee extends Person 

幸运的是,编译器不允许我们从抽象最终类继承。虽然如此,如果你能找到用例那就太好了。为了即时满足,你可能想搜索 Scala 中的幻影类型,并考虑我们无法实例化或继承抽象类的情况。

此外,如果你来自面向对象编程的背景,或者对 Java 中的static成员有所了解,你可能正在想,在 Scala 中我们如何实现这一点?静态类或静态成员的基本用法是,对于只有一个实例的情况,特定成员对于该类的所有实例都是相同的。如果你改变静态成员的值,它将改变所有实例的值。你将在我们接下来要讨论的下一个主题中了解更多关于我们正在讨论的内容。

对象作为单例

Scala 中没有静态成员或类。一旦你感觉到需要创建一个静态成员,例如一个静态方法或只将有一个实例的类,你应该创建一个对象。是的,到目前为止,我们几乎总是创建一个扩展App特质的对象,这样我们就不必定义main方法。这是我们的应用程序的入口点。所以,当提到object*时,我们并不是指任何类的实例;相反,Scala 中的object有不同含义。

一个对象就像类一样,是函数和值的容器。我们可能想要声明一个对象的原因是,我们可以为任何特定类型定义实用方法,或者有时定义 JSON 格式化器等类似用例。让我们再看看我们如何定义一个对象*:

object CountryUtil { 

} 

看起来我们刚刚创建了一个对象。没有什么花哨的,只是一个object关键字和对象的名字。我们知道对象是单例的,因此,在构造函数中传递参数没有任何意义,因此 Scala 不允许这样做。就是这样。为什么不使用你的CountryUtil对象来定义一些实用方法,比如一个接受人口序列并返回其平均值的方法?让我们在下面的代码中尝试一下:

object CountryUtil { 
  /* 
  * Function takes a sequence of population per million and returns average. 
  * */ 
  def populationAverage(pops: Seq[Double]) = pops.sum / pops.length 
} 

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  country.populationMap += ("2015" -> 64.39) += ("2016" -> 64.67) += ("2017" -> 64.93) 

  println(s"Country Name: ${country.name} and Population 2017: ${country.getPopulation("2017")} million") 

  println(s"${country.name}'s average population: ${CountryUtil.populationAverage(country.populationMap.values.toSeq)}") 

} 

以下是对应的输出:

Country Name: France and Population 2017: 64.93 million 
France's average population: 64.66333333333334 

现在,我们已经得到了我们想要的年份的法国平均人口。我们通过将映射传递给名为populationAverage的实用函数,该函数接受以百万为单位的连续人口值,实现了这一点。这里的想法是提供一个可以包含类实用方法的容器,这就是为什么我们有对象。

如果你正在想,是否可以从对象扩展:

class WeCant extends CountryUtil { 
} 
// Sorry we can't extend from an object 

不,我们不能。第一个原因是这不是预期的。其次,Scala 编译器为我们创建了一个对象的编译后的 Java 版本,这是一个 final 类。当我们创建一个 对象 并将其编译时会发生什么?Scala 会根据你选择的修饰符创建几个类文件。在我们的例子中,如果你编译 CountryUtil 对象,编译器会创建 CountryUtil.classCountryUtil$.class.,并且这些文件天生就是最终的。让我们看看这些文件。

下面的代码是 CountryUtil.class 类的实现:

public final class chapter6.CountryUtil { 
  public static double populationAverage(scala.collection.Seq<java.lang.Object>); 
} 

下面的代码是 CountryUtil$.class 类的实现:

public final class chapter6.CountryUtil$ { 
  public static chapter6.CountryUtil$ MODULE$; 
  public static {}; 
  public double populationAverage(scala.collection.Seq<java.lang.Object>); 
} 

是的,这是一些 Java 代码。你可能现在对细节不太感兴趣,但有两点需要注意:关键字 staticfinal. 首先,正如我们之前讨论的,这两个类是 final 的,实用方法是 static 的。因此,你可能对 Scala 编译器在后台做了什么有一个大概的了解。太好了。此外,这并不会阻止你从一个对象或特质扩展一个类。因此,编写如下内容是可能的:

class Continent 

object CountryUtil extends Continent { 
      //some code here 
} 

好吧,既然这样,我们就来讨论一下 Scala 中对象的更多用例。我们都知道,在 Scala 对象中,我们可以为特定类定义实用方法。使用它们也很简单,而且这些实用方法可以从任何地方使用。你只需要添加一个 import 语句:

import java.time.LocalDate 
import java.time.format.{DateTimeFormatter, TextStyle} 
import java.util.Locale 
import scala.util.{Failure, Success, Try} 

object DateUtil { 
  /* 
  * Just pass a date with format DD/MM/YYYY, get back DAY_OF_WEEK 
  * */ 
  def dayOfWeek(date: String): Option[String] = Try{ 
      LocalDate.parse(date, DateTimeFormatter.ofPattern("dd/MM/yyyy")).getDayOfWeek 
    } match { 
      case Success(dayOfWeek) => Some(dayOfWeek.getDisplayName(TextStyle.FULL, Locale.ENGLISH)) 
      case Failure(exp) => exp.printStackTrace; None 
    } 
} 

object TestDateUtil extends App { 
  import DateUtil._ 

  val date = "01/01/1992" 

  dayOfWeek(date) match { 
      case Some(dow) => println(s"It was $dow on $date") 
      case None => println(s"Something went wrong!") 
  } 

} 

下面的输出是:

It was Wednesday on 01/01/1992 

我们定义了一个 DateUtil 对象,并包含一个名为 dayOfWeek(date: String) 的实用函数。这告诉我们,当我们以特定格式将日期传递给它时,它是星期几。我们还创建了一个应用程序,它导入了这个 DateUtil 对象,并从那里,我们可以访问该对象内部的所有函数。有了这个,我们能够直接调用我们的函数并获取结果。

这是一个合理的模式,我们定义一个类,然后创建一些不是实例特定的函数,但可以从实例中使用,例如我们之前为 Country 类及其名为 CountryUtil. 的实用对象提供的例子。语言创造者对此了如指掌,并引入了我们所称的伴生对象的概念。这就是我们接下来要学习的内容。

伴生对象

伴生对象与我们之前讨论的对象并没有太大的不同。一个具体的区别是,我们给它们的命名与我们的类名相同。这意味着我们不需要定义 CountryUtil.,我们可以给这个对象与我们的 Country 类相同的名字,并称它为我们的伴生对象:

class Country(val name: String, val capital: String){ 
  var populationMap = scala.collection.mutable.Map[String, Double]() 
  def getPopulation(year: String): Double = populationMap(year) //In Million 

  override def toString: String = s"Country($name,$capital)" 
} 

object Country { 
  /* 
  * Function takes a sequence of population per million and returns average. 
  * */ 
  def populationAverage(pops: Seq[Double]) = pops.sum / pops.length 
} 

object CountryApp extends App { 
  val country = new Country("France", "Paris") 
  country.populationMap += ("2015" -> 64.39) += ("2016" -> 64.67) += ("2017" -> 64.93) 

  println(s"Country Name: ${country.name} and Population 2017: ${country.getPopulation("2017")} million") 

  println(s"${country.name}'s average population: ${Country.populationAverage(country.populationMap.values.toSeq)}") 
} 

上述代码做了我们之前所说的。我们给我们的对象取的名字与我们的 Country 类相同。这就是我们定义伴生对象的方式。这样,你的代码结构也会很清晰,因为它为你提供了让你的类型做些事情的能力。知道这一点很有用,为了使你的对象成为 伴生 对象,你必须将它们定义在同一个源文件中。

如果你在 Scala REPL 中尝试这样做,应该使用粘贴模式(:paste)。让我们看看我们是什么意思。所以,打开 Scala REPL 并尝试创建一个类及其伴随对象。REPL 将显示一个警告:之前定义的类不是对象的伴随。伴随对象必须一起定义;你可能希望使用:paste模式来做这件事。

因此,我们按照建议去做。在 REPL 中给出:paste命令,这将启用粘贴模式,然后以伴随模式编写我们的类。然后一切看起来都很好。

这些是我们已经在各个地方使用过的有用构造。当我们使用case类或类型类(我们将在第十章[part0194.html#5P0D40-921a8f8dca2a47ea817d3e6755fa0e84],高级函数式编程)实现时,我们将寻找它们的伴随对象。实际上,每当我们看到我们的类有特定的事情要做时,我们就有一种很好的方式来使用apply方法构建它的伴随对象。看看以下例子:

import java.time.LocalDate 
import java.time.format.DateTimeFormatter 

class Date(val dateStr: String) { 
  override def toString: String = s"Date(${this.dateStr})" 
} 

object Date{ 
  def apply(str: String): Date = { 
    val dater = LocalDate.parse(str, DateTimeFormatter.ofPattern("dd/MM/yyyy")) 
    new Date(s"${dater.getDayOfWeek} ${dater.getDayOfMonth}-${dater.getMonth}-${dater.getYear}") 
  } 
} 

object DateApp extends App { 
  val date = Date("01/01/1992") 
  println(date)
 } 

以下就是结果:

Date(WEDNESDAY 1-JANUARY-1992) 

这个例子是为了说明目的。我们本来有其他不同的方法可以达到这里所做的东西,但为了理解带有apply方法的伴随对象,这种方式更简单。那么,前一个例子中有什么不同呢?是我们使用DateApp应用程序的方式吗?是的,当我们定义伴随对象中的apply方法时,Scala 编译器允许我们无需显式调用它就能使用它。这正是我们在这里所做的事情:

val date = Date("01/01/1992") 

我们定义了一个名为Date的类,并随之创建了一个带有apply方法的伴随对象。这给了我们操作实例并从中获取一些东西的优势。在我们的例子中,我们检索了一些更多信息,使Date的实例更有意义,并用更多信息实例化DateDate的使用者将得到比我们给出的更多信息。这似乎很有趣。然而,实际的实现还需要注意异常情况。例如,如果在解析日期时遇到异常,会怎么样?我们现在不会深入细节,因为我们的座右铭是理解这个模式并找到它的用途。

想想更简单的情况,我们想要省略在创建简单类时必须编写的冗余代码,比如这个:

class Country(val name: String, val capital: String) { 

  override def toString: String = s"Country($name,$capital)" 

  override def equals(obj: scala.Any): Boolean = ??? 

  override def hashCode(): Int = ??? 

} 

在这里,我们有我们的简单Country类,由于 Scala 的存在,我们不需要为我们的类定义访问器和修改器。然而,这并不那么好。我们仍然需要定义像toStringequalshashCode这样的方法。如果 Scala 能为我们做这些,同时移除我们必须写的额外关键字,如newval等等,会怎么样呢?

当然,提到所有这些的整个目的就是为了确认,我们可以在 Scala 中使用案例类做很多事情,我们将在下一节中讨论这一点。

案例类

案例类是什么,为什么我们有它们,以及我们如何使用它们?这些问题你可能想要得到答案。所以,用更简单的语言来说,案例类可以省略我们可能需要编写的代码量以实现这一点:

class Country(val name: String, val capital: String) { 

  override def toString: String = s"Country($name,$capital)" 

  override def equals(obj: scala.Any): Boolean = ??? 

  override def hashCode(): Int = ??? 

} 

与前面代码中声明的Country不同,我们更愿意这样做:

case class Country(name: String, capital: String) 

我们的案例类Country定义处理了其余部分。我们有namecapital成员的访问器方法。我们有 Scala 编译器定义的toStringequals方法,或者说,为我们自动生成的方法:

case class Country(name: String, capital: String) 

object CountryUtil extends App { 
  val country = Country("France", "Paris") 
  println(s"Our country is: $country") 

  println(s"Equality => ${country == country}") 

  println(s"HashCode for country instance: ${country.hashCode()}") 
}  

以下就是输出:

Our country is: Country(France,Paris) 
Equality => true 
HashCode for country instance: -66065175 
toString*,* equals, or hashCode methods for the Country class. Still, we were able to perform these operations for the case class instance of the Country class. Why so? This happens because when we define a case class in Scala, the compiler automatically creates its companion object with a few methods, such as apply, unapply, and so on. During compilation, whenever the Scala compiler finds a case class, it converts the class files from Scala to Java; in our case, we'll get Country$.class and Country.class. The body of these files tells you a lot about what's happening in the background:

以下是一个Country$.class类的实例:

public final class chapter6.Country$ extends scala.runtime.AbstractFunction2<java.lang.String, java.lang.String, chapter6.Country> implements scala.Serializable { 
  public static chapter6.Country$ MODULE$; 
  public static {}; 
  public final java.lang.String toString(); 
  public chapter6.Country apply(java.lang.String, java.lang.String); 
  public scala.Option<scala.Tuple2<java.lang.String, java.lang.String>> unapply(chapter6.Country); 
  public java.lang.Object apply(java.lang.Object, java.lang.Object); 
} 

以下是一个Country.class类的实例:

public class chapter6.Country implements scala.Product,scala.Serializable { 
  public static scala.Option<scala.Tuple2<java.lang.String, java.lang.String>> unapply(chapter6.Country); 
  public static chapter6.Country apply(java.lang.String, java.lang.String); 
  public static scala.Function1<scala.Tuple2<java.lang.String, java.lang.String>, chapter6.Country> tupled(); 
  public static scala.Function1<java.lang.String, scala.Function1<java.lang.String, chapter6.Country>> curried(); 
  public java.lang.String name(); 
  public java.lang.String capital(); 
  public chapter6.Country copy(java.lang.String, java.lang.String); 
  public java.lang.String copy$default$1(); 
  public java.lang.String copy$default$2(); 
  public java.lang.String productPrefix(); 
  public int productArity(); 
  public java.lang.Object productElement(int); 
  public scala.collection.Iterator<java.lang.Object> productIterator(); 
  public boolean canEqual(java.lang.Object); 
  public int hashCode(); 
  public java.lang.String toString(); 
  public boolean equals(java.lang.Object); 
  public chapter6.Country(java.lang.String, java.lang.String); 
} 

在幕后正在进行许多有趣的事情。Scala 编译器为我们的案例类创建并定义了所有必要的方法,使我们的生活变得简单。所有这些简洁性都是因为编译器能够删除我们可能需要编写的所有样板代码。编译器为我们定义的一些重要方法包括:

  • apply

  • unapply

  • copy

  • canEqual

  • hashCode

  • equals

  • toString

与此同时,编译器还创建了构造函数以及不可变字段,以及这些字段的访问器,还有一些实用方法,如productArityproductPrefixproductElement. 值得注意的是,一些方法,如applyunapply方法,在编译后的形式中被声明为static。这意味着它们可以以Country.apply(...)Country.unapply(...).的形式调用,但其他方法可以在案例类的实例上调用。将无法调用Country.copy(...),因为编译器不允许我们执行此操作。说到所有这些,让我们尝试我们的示例Country案例类:

package chapter6 

object CountryUtil extends App { 
  case class Country(name: String, capital: String) 
  val country = Country("France", "Paris") 
  println(s"Country: => $country") 
  println(s"Equality: => ${country == country}") 
  println(s"HashCode: => ${country.hashCode()}") 

  println(s"Unapply: => ${Country.unapply(country)}") 
  println(s"apply: => ${Country.apply("Germany","Berlin")}") 

  println(s"copy: => ${country.copy("Germany","Berlin")}") 
  println(s"copyName: => ${country.copy(name="Germany")}") 
  println(s"copyCapital: => ${country.copy(capital="Berlin")}") 

  println(s"productArity: => ${country.productArity}") 
  println(s"productPrefix: => ${country.productPrefix}") 
  println(s"productElement(0): => ${country.productElement(0)}") 
  println(s"productElement(1): => ${country.productElement(1)}") 
} 

以下就是结果:

Country: => Country(France,Paris) 
Equality: => true 
HashCode: => -66065175 
Unapply: => Some((France,Paris)) 
apply: => Country(Germany,Berlin) 
copy: => Country(Germany,Berlin) 
copyName: => Country(Germany,Paris) 
copyCapital: => Country(France,Berlin) 
productArity: => 2 
productPrefix: => Country 
productElement(0): => France 
productElement(1): => Paris 

我们看到了前面代码中应用的所有方法。值得注意的是,这些方法是有用的,并且提供了关于类实例的基本行为以及更多信息的更多信息。这就是我们创建伴随对象的目的。记住,我们创建了一个带有伴随对象的Date类,其实例在声明时提供的信息更为有意义。

在 Scala 中,有多个原因使我们更愿意使用案例类而不是普通类。很明显,案例类既简洁又给我们提供了我们可能通过编写自己的代码所无法得到的东西。正因为如此,我们对这些案例类及其成员有了更多的了解。例如,unapply方法提供了在检查实例时你可能需要的信息。我们可以在模式匹配中使用这些方法,并且也建议这样做:

case class Country(name: String, capital: String) 
val country = Country("France", "Paris") 

country match { 
  case Country("Germany", _) => println(s"It's Germany") 
  case Country("France", _) => println(s"It's France") 
  case Country(_, _) => println(s"It's some country") 
} 

这可能看起来很简单,但它是一个强大的结构,并且被广泛使用。难怪你也发现自己更频繁地匹配案例类实例。这样做很简单,根据我们的代码片段,它将打印以下内容:

It's France  

这是因为unapply方法按预期工作;模式匹配在我们的案例类实例上起作用。还值得知道的是,你不能声明一个与具有相同名称的类一起的案例类。Scala 不会允许你在同一作用域内声明具有相同名称的类和案例类。当我们说同一作用域时,我们的意思是,如果你声明了一个案例类,比如说名为 country 的案例类,与同一编译单元中的 Country 类同名,这是完全可以的,因为它们属于不同的作用域。为了使这个声明更清晰,请看一个例子:

package chapter6 

object CountryUtil extends App { 
  case class Country(name: String, capital: String) 
  val country = Country("France", "Paris") 
} 

class Country(name: String, capital: String) 

上述代码声明是完全可以的,但如果我们尝试将我们的类放入CountryUtil或把我们的案例类放在CountryUtil之外,编译器将不允许我们这样做:

package chapter6 
case class Country(name: String, capital: String) 

object CountryUtil extends App {  
  val country = Country("France", "Paris") 
} 

class Country(name: String, capital: String) 

Scala 编译器不会允许你这样做,它会显示以下信息:

Country is already defined in scope 

嗯,你可能根本不想做这样的事情。如果你想知道是否可以从案例类扩展,使用案例类可以做到以下事情:

abstract class Continent 
case class Country(name: String, capital: String) extends Continent 

我们将在下一章中学习更多关于继承的内容。到目前为止,我们已经看到了足够的,为接下来做好准备。现在是时候总结本章我们学到的内容了。

摘要

本章很有趣,因为我们学习了 Scala 类和对象实现的细节。我们从 Scala 中类的含义开始,讨论了如何声明它们以及如何使用它们。然后我们讨论了 Scala 中对象作为单例实例的概念。接着我们讨论了有趣的伴生对象,这引出了案例类。我们了解到案例类不仅给我们带来了我们想要的简洁性,而且在可能需要进行实例模式匹配的场景中也非常有用。最后,我们讨论了案例类提供的所有方法和优点。

在下一章中,我们将把面向对象的 Scala 知识提升到新的水平,并更多地讨论特质、继承以及更多内容。

第七章:面向对象 Scala 的下一步

“我生来无知,只有一点时间在这里和那里改变这一点。”

– 理查德·费曼

伴随对象的想法让我们觉得了解你的编程语言如何处理你编写的结构是很重要的。假设你被分配了一个任务,生成一个带有一些敏感参数的 case 类(敏感的意思是,当尝试打印该类时,那些敏感字段应打印一些占位符值)。你将如何实现这一点完全取决于你对 Scala 如何处理 case 类的了解,我们已经在上一章中学到了这一点。那么现在呢?现在是时候做一些组合,并使用继承。记住,我们讨论过我们应该如何将类视为可以定义的类型吗?将所有这些类型混合在一起,并试图理解它们,同时添加功能,这是一项非常有用且有趣的任务。这就是为什么我们有静态类型,不是吗?让我告诉你,在学习组合的同时混合这些类型,创造多种访问这些结构的方法是很有趣的。这正是我们将在本章中做的事情。我们将享受乐趣并学习。所以,这就是我们将如何进行:

  • 组合与继承的比较

  • 类继承

  • 默认和参数化构造函数

  • 特性

  • 特性作为混合体

  • 线性化

  • 打包与导入

  • 可见性规则

  • 密封特性

在我们继续前进之前,我想澄清这一点。在本章中,我们将大量使用 组合继承 这两个术语。最好一开始就区分这两个概念。

组合与继承

在编程术语中,要继承或扩展我们的类,我们使用 extendswith 关键字。这些对于两个或更多类或类似结构之间的关系是必不可少的。这种两个类或类似结构之间的关联或关系可以是继承(是-A)或组合(有-A)。它们是两个不同的概念,但在某种程度上它们是趋同的。简单来说,继承 是一个超类-子类关系,其中子类继承了超类的实现,而组合 是一个类依赖于另一个对象以提供某些或全部功能的情况。在继承关系中,你可以在期望超类的地方使用子类对象。想想看,这是 字典 类之间的关系:

class Book(val title: String) 
class Dictionary(name: String) extends Book(name) { 
  // data and behavior 
} 

我们可以将 字典 的关系如图所示:

在这里,字典 类正在继承自 类,这意味着:

  • 字典 的一个子类。它可以在需要的地方用作 类型。

  • 字典可以访问Book类的所有字段(数据)和成员函数(行为)。这意味着你可以覆盖超类中特定函数的行为,以及根据超类函数定义自己的函数。

这些点在某种程度上使理解继承关系变得更容易,并帮助你形成良好的面向对象设计。然而,有时作为开发者,你可能会觉得有责任去改变超类的行为,因为现在它也成为其他子类依赖的一部分。

组合在实现时(在特质混入的情况下)可能看起来相似,但本质上不同。正如其名所示,组合实际上是将部分组合成一个整体。通过一个例子更容易理解:

class Book(val title: String) {
  val chapters = scala.collection.mutable.Set[Chapter]()
  def addChapter(chapter: Chapter) = chapters.add(chapter)
  def pages = chapters.foldLeft(0)((b, c) => b + c.noOfPages)
}

case class Chapter(name: String, sn: Int, noOfPages: Int)

object BookApp extends App {
  val book = new Book("The New Book")
  book.addChapter(Chapter("Chapter1", 1, 15))
  book.addChapter(Chapter("Chapter2", 2, 13))
  book.addChapter(Chapter("Chapter3", 3, 17))

  println(book.title)
  println(book.pages)
}

在前面的代码中,我们展示了一个由一组章节组成的Book类。每个章节由一个Chapter类表示。这是Book类和Chapter类之间的具有关系。这种关系也称为聚合。作为聚合的特殊形式,我们现在感兴趣的主题是组合。Book类中有一个名为chapters的字段,它由chapters组成。没有Chapter结构,就不可能形成有意义的书籍对象。

这就是组合,并且它是具有方向的。以下图将帮助我们理解这个概念:

图片

书籍与章节之间的组合关系

chapters中的Set对象,我们可以扩展一些只能在Chapter对象上执行的功能。我敢打赌你已经明白了组合的概念。我们讨论这个的原因是因为我们将在 Scala 中使用特质的混入来实现这一点,这看起来几乎像是我们在继承中扩展它们,但实际上并非如此。所以,想法是清晰的:

  • 组合不仅仅是代码复用,更是将部分组合成一个整体。没有Chapter对象的存在,Book的存在是不清晰的。

  • 组合还向我们的已定义类结构添加了功能(我们将在本章后面讨论特质作为混入时看到这一点)。

了解这两者之间的区别很重要。现在你可能对如何在 Scala 中实现继承或组合只有一个模糊的概念,但术语及其含义是清晰的。这将为我们指明前进的道路,并讨论我们如何在 Scala 中实现类继承。

类继承

你已经知道继承在良好的面向对象设计中起着重要作用。我们很幸运有诸如命名类这样的结构,我们可以通过使用继承来增加将它们与其他类相关联的可能性。继承是关于形成有意义的类层次结构以实现代码复用的目的。记住我提到的 有意义的层次结构。我稍后会解释我的话。让我们看看我们如何扩展类以形成一个层次结构。

扩展类

我们使用 extend 关键字来继承一个类。让我们看看我们的 Book 示例来理解这一点:

class Book(val title: String){ 
  // data and behaviour for Book 
} 

class Dictionary(name: String) extends Book(name) { 
  // data and behaviour for dictionary 
} 

object BookApp extends App { 
  val dictionary = new Dictionary("Collins") 
  println(dictionary.title) 
} 

结果如下:

Collins 

我们可以看到,Dictionary 类继承自 BookDictionary,是 Book 超类的子类。重要的是要知道,在 Scala 中,所有类,无论是否使用 extends 关键字,都明确地继承自 Any。这意味着我们的 Book 类继承自 Any。现在,随着这种关系的建立,如果你尝试创建一个 Dictionary 实例,你将能够访问超类中所有非私有成员。这就是我们能够打印出字典标题的原因。这就是代码复用的体现。如果你不希望某个成员从外部访问,你可以将其设置为 private,这是一个访问修饰符。我们将在本章后面学习访问级别。通过这种继承关系,可以在需要 Book 实例的地方使用 Dictionary 实例。这是因为 dictionaryBook 类型的子类型。为了使事情更清晰,让我们谈谈子类型和子类。

子类型与子类

在 Scala 中,类不是类型。当我们从一个类继承时,我们在这两个类之间形成了一个超类-子类关系。在大多数情况下,子类和子类型的概念是相同的。但在 方差(参数化类下的继承行为) 发挥作用的场合,事情就变得不同了。在方差下,子类不保证子类型。

我们将查看两种情况,正面和负面。假设字典继承自书籍,并且可以说一摞字典是一摞书籍。这是正面场景,其中子类化和子类型在本质上相似。但第二种情况呢?假设Keyboard类扩展自Button. Button有一个值,也有功能。所以,在形成有意义的键盘对象时,我们使用了Button类。但你觉得一摞键盘和一摞按钮是一样的吗?不!因此,在这里,KeyboardButton类之间的子类化是完美的,但我们不允许说KeyboardButton的子类型。我想你已经理解了子类型和子类化以及它们之间的区别。然而,在编程术语中,让我们这样看待它:如果DictionaryBook的子类型,那么List[Dictionary]将是List[Book]的子类型。我们称这个属性为协变。在另一种情况下,Keyboard子类化Button,但它不是Button的子类型。让我们可视化我们刚刚试图理解的内容:

子类化

这种关系不言自明。在键盘和按钮的情况下,选择组合而不是继承会更好。但这是一种不同的观点。目前,我们已经清楚,我们应该区分子类型和子类化。所以现在,让我们再探讨一个更有趣的话题——覆盖超类的行为。是的,我们可以覆盖超类中定义的函数。

覆盖数据和行为

我们知道子类可以访问超类中所有非私有成员,因此我们可以从超类中调用或调用函数来展示一些行为。如果我们想操纵行为,在 Scala 中,我们可以覆盖超类中的任何函数或值。让我们看看我们如何做到这一点。我们将参考之前提到的BookDictionary示例。假设在Book类中我们有一个名为cover的函数,为了演示目的,它简单地接受一个String对象作为cover

我们从Book类继承并创建一个Dictionary类,该类想要覆盖名为cover的函数的功能:

class Book(val title: String){ 
  def cover(cover: String): String = "Paperback_" + cover 
} 

class Dictionary(name: String) extends Book(name){ 
  // wants to define its own version of cover method 
} 

我们可以通过简单地添加名为override的修饰符关键字来覆盖cover方法的定义:

class Dictionary(name: String) extends Book(name){ 
  override def cover(cover: String): String = "Hardcover_" + cover 
} 

object BookApp extends App { 
  val dictionary = new Dictionary("Collins") 
  println(dictionary.title) 
  println(dictionary.cover("The Collins Dictionary")) 
} 

结果如下:

Collins 
Hardcover_The Collins Dictionary 

正如前一个示例所示,我们已经覆盖了cover方法. 如果我们不使用名为override的这个关键字,会怎样呢?在这种情况下,Scala 编译器会给我们一个错误,指出以下内容:

Error:(18, 7) overriding method cover in class Book of type (cover: String)String; 
 method cover needs `override' modifier 
 def cover(cover: String): String = "Hardcover_" + cover 

cover方法需要override修饰符。如果我们在子类中保留了签名,方法覆盖就会生效。如果你尝试更改签名,编译器会给你一个错误,指出cover方法覆盖了无内容:

Error:(18, 16) method cover overrides nothing. 
Note: the super classes of class Dictionary contain the following, non-final members named cover: 
def cover(cover: String): String 
override def cover(cover: Cover): String = "Hardcover_" + cover 

因此,在子类中覆盖方法时,保持方法签名不变是很重要的。Scala 要求你在子类中重用相同的方法时,始终提供override关键字。在 Java 中,override关键字是可选的。这在编写代码的实际场景中会导致问题。假设你想要在Book超类中更改行为,以支持新引入的Cover类作为cover方法的参数,并且你在几个地方覆盖了此cover方法,例如在Dictionary类中。如果没有强制使用override关键字会发生什么情况?你可能想知道。因此,Scala 要求你添加它。所以,在编译时,你将得到一个错误,指出Dictionary中的cover方法没有覆盖任何内容,你可以根据需要更改定义。

还有一件重要的事情需要了解:为了彻底理解覆盖行为,我们必须了解它们的范围。我们可以用val字段覆盖非参数def成员。我们这是什么意思?让我们看看:

class Book(val title: String){ 
  def coverType: String = "Paperback" 
  def cover(cover: String): String = coverType + "_" + cover 
} 

class Dictionary(name: String) extends Book(name){ 
  override val coverType: String = "Hardcover" 
} 

这个例子向你展示了Book中的非参数方法coverType可以在Dictionary子类中用val对象覆盖。进行这样的覆盖操作是完全合法的。如果你尝试反过来做,这是不可能的。Scala 编译器将不允许你执行这样的操作,并指出coverType方法需要是一个稳定、不可变的价值:

Error:(19, 16) overriding value coverType in class Book of type String; 
 method coverType needs to be a stable, immutable value 
  override def coverType: String = "Hardcover" 

这是合乎逻辑的;你在这里试图做的是使某个东西可变,定义是可变的。这不应该被允许,Scala 编译器足够智能,能够告诉你这一点。假设你希望你的超类中的某个特定成员保持完整,不可更改。你想要限制后续子类覆盖超类的行为,在这种情况下你会怎么做?这就是我们接下来要学习的。

限制继承 – final关键字

如果你想要限制子类覆盖特定的行为或某些数据,你可以使用final关键字来实现:

class Book(val title: String){ 
  final val coverType: String = "Paperback" 
  def cover(cover: String): String = coverType + "_" + cover 
} 

在这里,我们已将coverType值声明为final。如果你尝试覆盖它,Scala 编译器将抛出一个错误,指出值不能覆盖final成员:

Error:(19, 16) overriding value coverType in class Book of type String; 
 value coverType cannot override final member 
 override val coverType: String = "Hardcover" 

这在你想保持某些数据完整的同时,仍然让超类-子类关系正常工作的情况下很有用。在几个相关类中覆盖行为时,当你尝试调用超类以及子类中的特定方法时,会出现混淆。这种混淆是通过动态绑定概念解决的。所以,在我们详细了解它的工作原理之前,让我们先解释一下动态绑定在哪里被使用。动态绑定用于解决当类之间存在继承关系时,哪个成员函数将被调用。这是在运行时基于对象来解决的。让我们详细谈谈它。

函数调用中的动态绑定

我们讨论了对象与其引用的动态绑定以及方法的调用。调用是基于调用该方法的对象的类型。用例子来说明这一点更容易理解。看一下下面的例子:

class Book(val title: String){ 
  val coverType: String = "Paperback" 
  def cover(cover: String): String = coverType + "_" + cover 
} 

class Dictionary(name: String) extends Book(name){ 
  override val coverType: String = "Hardcover" 
} 

class Encyclopedia(name: String) extends Book(name){ 
  override val coverType: String = "Blue_Hardcover" 
} 

object BookApp extends App { 
  val dictionary: Book = new Dictionary("Collins") 
  val encyclopedia: Book = new Encyclopedia ("Britannica") 
  val theBoringBook: Book = new Book("TheBoringBook") 

  println(s"${dictionary.title} has cover ${dictionary.cover("The Collins Dictionary")}") 
  println(s"${encyclopedia.title} has cover ${encyclopedia.cover("Britannica")}") 
  println(s"${theBoringBook.title} has cover ${theBoringBook.cover("Some Book")}") 
} 

结果如下:

Collins has cover Hardcover_The Collins Dictionary 
Britannica has cover Blue_Hardcover_Britannica 
TheBoringBook has cover Paperback_Some Book 

仔细看看以下内容:

val dictionary: Book = new Dictionary("Collins") 
val encyclopedia: Book = new Encyclopedia ("Britannica") 
val theBoringBook: Book = new Book("TheBoringBook") 

在这里,我们创建了三个具有相同返回类型Book的不同对象。首先要注意的是,等于运算符左侧的类在继承层次结构中总是更高的。其次,这三个都是不同的实例。所以,当你尝试从所有这些实例中调用名为cover的成员函数时,你可能会观察到三种情况下的不同行为,并看到调用是基于类的运行时类型。我们说的类的运行时类型是什么意思?这意味着在编译时,所有三个表达式都是同一类型Book,但在运行时,情况就不同了。DictionaryEncyclopediaBook的调用发生,导致调用它们各自的封面方法。在我们的例子中,打印出来的结果显示了预期的行为。这种行为在面向对象语言中被称为动态绑定

到现在为止,我相信你已经非常熟悉继承的概念,并且准备好在为你的下一个应用程序设计模型时考虑这个特性,对吧?为了使它更清晰,我们将继承视为一个通过代码复用来解决代码重复问题的概念。但是,我们必须记住,使用继承形成的这些类之间的关系,它们也传递了一些重要的语义信息。我们这是什么意思呢?我们的意思是,理解当我们从某个超类继承时,它就变成了代表我们的子类的公共接口/构造体是很重要的。

因此,当从book继承字典时,如果它还保留有关书籍是否将具有标题、页数、封面、作者或其他信息的语义信息,那么其子类也预计将具有这些信息可用。原因在于,由于继承关系,Bookdictionary的公共接口。这就是我们有时发现自己错误地使用继承的地方。让我们简要讨论一下。

继承滥用

看一下这个例子:

class CustomStack extends util.ArrayList[String] { 
  def push(value: String) = ??? 
  def pop = ??? 
} 

在这里,一个名为 CustomStack 的类正在继承自 ArrayList[String]。你所看到的是一种语义代码异味。仅仅因为你的 CustomStack 类扩展了 ArrayList,并不意味着你能够访问其所有公共成员。任何拥有栈对象的用户不仅将能够访问 pushpop,还将能够访问一系列方法,如 getsetaddremove 等等。这是错误的;你可以在内部使用数组列表对象来形成一个栈对象,但公共接口应该与此无关。在我们的例子中,我们的代码表明如果你能够访问 CustomStack,你将能够访问所有这些方法。你应该避免在 Stack 构造中使用这种做法。实际上,这两个概念完全不同,因此我们应该避免使用这种在语义上错误的建模方式。尽管这可能是一个设计选择,但我们仍然应该在做出这些决策时牢记以下几点:

  • 检查 Is-A 关系,如果它成立。

  • 检查封装规则,如数据隐藏,是否成立。你永远不应该向外部世界暴露内部实现细节。在我们的例子中,Stack 应该在内部实现数组列表,而不是从它继承。

  • 我们应该检查每个构造的域,交叉域继承模型肯定不是一个好的选择。StackArrayList 在概念上是不同的。一个可以用另一个来组合,但不应该继承另一个。

如果你遵循这些要点,你将能做出更好的设计选择来建模继承关系。

嗯,你还记得我们学习了关于 case 类以及我们如何使用不带 new 关键字的方式来实例化它们吗?这让我想知道,如果我们想要有超过一种方式来构造这种 case 类的新对象呢?如果你也在想同样的事情;太好了!Scala 中可以有参数化构造函数。让我们来谈谈它们。

默认和参数化构造函数

在 Scala 中定义的任何类的首要构造函数是其本身。这意味着你在一个类体内声明的和定义的任何内容,在创建其实例时都会被实例化。还有其他方法可以定义二级/辅助构造函数。看看下面的 case 类:

import java.time.LocalDate

case class Employee(name: String, id: String, contact: String, email: String) 

case class StartUp(name: String, founder: Employee, coFounders: Option[Set[Employee]], members: Option[List[Employee]], foundingDate: Option[LocalDate]) 

我们可以看到两个名为 EmployeeStartUpcase 类。你可能想知道为什么 Employee 是特定于我们的 StartUp 类的。StartUp case 类接受一些属性,如 foundercoFoundermembersfoundingDate。因此,为了创建这些 case 类的实例,我们必须为每个成员提供值。在这种情况下,如果客户端某个人想使用这个 case 类并且不想提供 memberscoFounder 信息,他们仍然必须给出占位符值。现在,这个解决方案可能是为我们的客户端构建辅助构造函数。如果我们为 StartUp case 类提供替代调用策略,这就可以做到。让我们这样做:

case class StartUp(name: String, founder: Employee, coFounders: Option[Set[Employee]], members: Option[List[Employee]], foundingDate: Option[LocalDate]){ 

  //founder | name 
  def this(name: String, founder: Employee) = this(name, founder, None, None, None) 

  //founder | foundingDate 
  def this(name: String, founder: Employee, foundingDate: LocalDate) = this(name, founder, None, None, Some(foundingDate)) 

  //founder | coFounders 
  def this(name: String, founder: Employee, coFounders: Set[Employee]) = this(name, founder, Some(coFounders), None, None) 

  //founder | coFounders | members 
  def this(name: String, founder: Employee, coFounders: Set[Employee], members: List[Employee]) = this(name, founder, Some(coFounders), Some(members), None) 

  //founder | coFounders | foundingDate 
  def this(name: String, founder: Employee, coFounders: Set[Employee], foundingDate: LocalDate) = this(name, founder, Some(coFounders), None, Some(foundingDate)) 

  //founder | members    | foundingDate 
  def this(name: String, founder: Employee, members: List[Employee], foundingDate: LocalDate) = this(name, founder, None, Some(members), Some(foundingDate)) 

} 

这里有几个需要注意的点。首先,我们可以定义 this 方法的重载版本,它们可以作为辅助构造函数工作。其次,在每个定义中,我们都在调用主构造函数,并传递一些其他值。这种做法可以在这些类的任一边进行。这意味着在声明这些 case 类时或在客户端使用这些类时传递可选值都可以。我们已经在这类声明时这样做过了。现在让我们在 StartUpApp 中使用它们:

object StartUpApp extends App { 

  val startUpWithFoundingDate = new StartUp("WSup", Employee("Rahul Sharma", "RH_ID_1", "9090000321", "rahul_sharma@abc.com"), LocalDate.now()) 

  println(s"${startUpWithFoundingDate.name} founded on ${startUpWithFoundingDate.foundingDate.get} by ${startUpWithFoundingDate.founder.name}") 

  val startUp = new StartUp("Taken", Employee("David Barbara", "DB_ID_1", "9090654321", "david_b@abc.com")) 

  println(s"${startUp.name} founded by ${startUp.founder.name}") 
} 

结果如下:

WSup founded on Sun Jun 13 20:29:00 IST 2016 by Rahul Sharma 
Taken founded by David Barbara 

使用这些 StartUp case 类的辅助构造函数版本很容易。我们可以看到我们只传递了所需的参数,如果这个版本对我们可用,我们就能为其创建一个实例。但是等等,我们已经有这些 case 类,但我们仍然使用 new 关键字为 StartUp 类创建实例。如果我们尝试不使用 new 关键字创建实例会怎样呢?让我们试试:

object StartUpApp extends App { 

  val startUp = StartUp("Taken", Employee("David Barbara", "DB_ID_1", "9090654321", "david_b@abc.com")) 
  println(s"${startUp.name} founded by ${startUp.founder.name}")
 } 

如果你尝试这样做,Scala 编译器会抛出一个错误,如下所示:

Error:(30, 24) not enough arguments for method apply: (name: String, founder: chapter7.Employee, coFounders: Option[Set[chapter7.Employee]], members: Option[List[chapter7.Employee]], foundingDate: Option[java.util.Date])chapter7.StartUp in object StartUp. 
Unspecified value parameters coFounders, members, foundingDate. 
val startUp = StartUp("Taken", Employee("David Barbara", "DB_ID_1", "9090654321", "david_b@abc.com"))  

这是因为当你声明 case 类时,编译器会为它生成一个伴随对象,其中包含一个 apply 方法,并且 apply 方法接受 case 类定义的所有参数。当我们定义辅助构造函数时,我们并没有真正地覆盖伴随对象中定义的 apply 方法。因此,当我们尝试不使用 new 关键字使用 case 类实例化时,Scala 编译器无法找到相应的 apply 方法版本,并给出错误。如果你真的想那样使用它,你可以在伴随对象中定义 apply 方法的重载版本。我们将尝试只使用两个参数(namefounder)的重载实现*。让我们这样做:

object StartUp { 

  def apply(name: String, founder: Employee): StartUp = new StartUp(name, founder, None, None, None) 
} 

做这件事很简单。定义一个只接受 namefounderapply 方法,我们可以选择创建我们的 StartUp 对象实例而不使用 new 关键字:

object StartUpApp extends App { 

  val startUp = StartUp("Taken", Employee("David Barbara", "DB_ID_1", "9090654321", "david_b@abc.com")) 

  println(s"${startUp.name} founded by ${startUp.founder.name}") 
} 

结果如下:

Taken founded by David Barbara 

这些只是几种方法;我们可以通过提供构建新对象的各种选项来使客户的体验更轻松。现在我们已经看到了我们如何为类构造定义各种选项,以及如果正确使用,继承可以如何有效,我们可以通过在 Scala 中使用特性来使我们的构造更强大。这就是我们将要探索的。

特性

特性是什么?对于来自 Java 背景的人来说,他们可能会倾向于将特性视为接口,但实际上它们是不同的东西。特性构造可能看起来相似,但与 Java 中的接口在本质上不同。特性这个词的含义是:一种区别质量或特征,通常是指一个人的。特性的一种用途也是一样的。如果你想要向我们的类层次结构或单个类添加特定的特性,你可以通过扩展或混合特性来实现。我们说混合特性而不是扩展特性更容易。这两者有什么不同?我们将随着讨论的进行来讨论这个问题,但现在,让我们看看如何在 Scala 中定义一个trait

trait Socialize { 

  //people who socialise, greets. 
  def greet(name: String) = "Hello " + name
}

这样看吧。那些善于社交的人的一个特点是,当他们遇见你时,他们会全心全意地问候你。在编程术语中,你想要创建一个名为Person的社交类。你可以创建一个名为Socializetrait,目前它只定义了一个方法greet。这很简单:通过定义一个trait,我们使用关键字trait并给它一个名字。我们将随着讨论的进行来讨论这个问题,但现在,让我们看看如何在 Scala 中定义一个trait

case class Person(val name: String) 

object SocializeApp extends App { 
  val person = Person("Victor Mark") 
  val employee = new Employee("David Barbara") with Socialize 

  println(employee.greet(person.name)) 

  class Employee(fullName: String) extends Person(fullName) 
} 

结果如下:

Hello Victor Mark 
Employee class that extends Person in the inheritance hierarchy. While instantiating the Employee object, we're able to extend an employee's characteristic of socializing through a mix-in. Also, we have access to the greet method from our trait. This happened on the go, we didn't specify Employee to have this characteristic statically, but dynamically. When instantiating, we extended the possibilities and characteristics of Employee. That's why traits are powerful. A few points to note about traits are as follows*:*
  • 特性不接受参数

  • 特性也可以形成一个继承层次结构,一个特性可以混合另一个特性

  • 在同一个作用域内,你不能有一个类和一个具有相同名称的特性

  • 混合特性的顺序很重要,并且可能会影响你的实现行为

我们可以通过观察前面的点来定义特性,但了解 Scala 在编译时如何处理特性也是很好的。为了理解这一点,我们可以使用javap命令在我们的Socialize类上。它将显示我们特性的编译形式:

public interface chapter7.Socialize { 
  public static java.lang.String greet$(chapter7.Socialize, java.lang.String); 
  public java.lang.String greet(java.lang.String); 
  public static void $init$(chapter7.Socialize); 
} 
Socialize trait. The Scala compiler compiles down a trait to its Java counterpart, interface (this happens in Scala version 2.12 and later. In previous versions, traits were also compiled to a set of classes). Here, the greet method is available as a static and a non-static member. It's possible for us to include an abstract, as well as concrete, method of a trait. For example, take a look at the following:
trait Socialize { 

  def greet(name: String) = "Hello " + name 

  val socialNetworks = Set("Facebook", "LinkedIn", "Twitter", "Instagram", "Youtube") 

  def linkToSocialNetwork(network: String, uri: String) 
} 

object SocializeApp extends App { 

  val employee = new Employee("David Barbara") 
  employee.linkToSocialNetwork("LinkedIn", "www.linkedin.com/profiles/david_b") 

  println(employee.mapOfSocialNetwork) 

} 

class Employee(fullName: String) extends Person(fullName) with Socialize { 

  var mapOfSocialNetwork = new scala.collection.mutable.HashMap[String, String]() 

  override val socialNetworks = Set("LinkedIn", "Twitter", "Youtube") 
  override def linkToSocialNetwork(network: String, uri: String): Unit = if (socialNetworks contains network) mapOfSocialNetwork.put(network, uri) 
}  

结果如下:

Map(LinkedIn -> www.linkedin.com/profiles/david_b) 

上述示例显示,我们的特性可以包含抽象成员以及具体成员。在这里,我们只是声明了我们的linkToSocialNetwork方法而没有定义,以及我们之前的方法,名为greet,它有它的定义:

def linkToSocialNetwork(network: String, uri: String)   

我们已经在 Employee 类中给出了它的实现,这个类混合了这个特性。有了这种抽象,我们现在可以将特性与 Scala 中的抽象类进行比较。这两者之间的区别是什么?首先,你可能注意到我们不能向我们的特性传递构造函数参数。其次,作为构造,特性比抽象类更重。所以,选择哪一个取决于你。建议如果你正在扩展任何功能,或者如果只是作为一个类层次结构,那么抽象类可能是一个不错的选择。特性的另一个方面是它是可堆叠的。这意味着很明显你可以混合多个特性,并且其效果可以以堆叠的方式使用。让我们看看特性如何被用来展示可堆叠行为。

特性作为混合体

我们进行特性混合的方式与在 Scala 中继承任何类没有不同;唯一的区别是你可以混合多个特性,为此我们有一个叫做 with 的关键字。我们为什么叫它混合体?我们本可以叫它别的名字。好吧,是的,但这几乎解释了你可以用特性做的一切。很容易修改或添加到现有功能或构造中的行为,而不影响现有的行为。我们很快就会看到这一点。特性可以在各种用例中使用,例如:

  • 可组合的混合体;使现有的接口更丰富

  • 可堆叠的修改

特性作为可组合的混合体

通过 可组合的混合体 我们意味着我们可以创建一个特定类型的实例,带有特性的混合,它可以具有某些附加功能。如果你在想我们为什么要这样做,那么答案是也许你想要添加一些特定的行为,这些行为对你的功能有意义,并且你希望它表现得好像它来自库本身。作为这种构造或库的客户,我们希望它们感觉尽可能自然。特性帮助我们添加某些功能,同时保持真正的库完整。

我们可以通过一个例子更好地理解这一点。所以,假设你已经有了一个现有的 CreditCard 功能。为了演示目的,我们将限制我们对这个 CreditCard 功能的期望。这个类有生成每个订阅者信用卡号码的特定方式。这是过去的事情了。现在,在未来的几天里,我们想要引入一些新的带有更多优惠(适用条件)的 CreditCard 订阅。此外,我们希望有自己的方式生成 CreditCard 号码,而不影响现有的功能或业务。现实可能不同,但为了学习目的,我们自己生成信用卡号码是可以的,不是吗?所以,现在你有了这个画面。让我们看看我们已经有什么:

package chapter7 

case class Amount(amt: Double, currency: String){ 
  override def toString: String = s"$amt ${currency.toUpperCase}" 
} 

abstract class CreditCard { 
  val ccType = "Default" 
  def creditLimit(x: Double) : Amount 

  //legacy creditCardNumberGeneratorLogic 
  val ccNum = scala.util.Random.nextInt(1000000000).toString 

  //other methods 
} 

object CCApp extends App { 
  val basicCreditCard = new CreditCard { 
    override def creditLimit(x: Double): Amount = Amount(x, "USD") 
  } 

  val limit = basicCreditCard.creditLimit(1000) 
  println(s"CreditCardNumber ${basicCreditCard.ccNum} with limit: $limit") 
} 

结果如下:

CreditCardNumber 44539599 with limit: 1000.0 USD 

现在,我们可以看到这个CreditCard抽象类的定义,我们的客户端CCApp正在访问它以创建一个具有特定金额的新信用卡账户。如果你仔细观察,我们没有尝试定义独立的具体类来扩展我们名为CreditCard的抽象类,而是直接实例化了CreditCard,这是由于 Scala 为我们生成并实例化了一个匿名类,并期望我们定义抽象方法,在我们的例子中是creditLimit。现在,我们的要求是在不修改现有代码的情况下,我们想要有一个自己的creditCardNumber生成器版本,它可以为我们生成信用卡号。那么让我们来做这件事。但是想想看,我们如何做才能让它看起来很自然?我们希望这样做:

val basicCreditCard = // Some Credit Card impl 
basicCreditCard.ccNumber 

对于这一点,我们可以定义一个trait,比如说CreditCardOps,它将定义我们可以用来引用新逻辑而不影响之前实现的功能。对于客户端来说,这将很自然。让我们看看实现:

trait CreditCardOps { 
   self: CreditCard => 
   val ccNumber: String = ccType match { 
     case "BASIC" => "BC" + ccNum 
     case _ => "DC" + ccNum 
   } 
} 

object CCApp extends App { 
  val basicCreditCard = new CreditCard with CreditCardOps { 
    override def creditLimit(x: Double): Amount = Amount(x, "USD") 
  } 

  val limit = basicCreditCard.creditLimit(1000) 
  println(s"CreditCardNumber ${basicCreditCard.ccNumber} with limit: $limit") 
} 

结果如下:

CreditCardNumber DC896146072 with limit: 1000.0 USD 

使用方式变化不大,我们也实现了我们的目标。我们引入的唯一新事物是一个名为CreditCardOpstrait。这个trait定义了我们想要的新逻辑。关于这个实现,有几个需要注意的点:

  • 首先,我们需要在我们想要这种合成行为的时候,将这个trait混合到我们的CreditCard实现中。

  • 我们还可能想要确保这个trait仅针对CreditCard类型,因此不允许其他特质或类混合使用这个。我们最终也确保了这一点。如果你尝试做以下类似的事情:

class DebitCard 
val someDebitCard = new DebitCard with CreditCardOps 
  • Scala 编译器不会允许我们这样做;原因是我们在定义特质时选择的语法如下:
self: CreditCard => 
  • 这个语法给 Scala 编译器提供了一些关于当前作用域中即将到来的语句的信息,同时也限制了特质对某些类型的可用性。因为这个语法,我们只能将CreditCardOps特质混合到CreditCard类型中。

  • 看看下面的实现:

trait CreditCardOps { 
   self: CreditCard => 
   val ccNumber: String = ccType match { 
     case "BASIC" => "BC" + ccNum 
     case _ => "DC" + ccNum 
   } 
} 

我们能够引用ccTypeccNumCreditCard类的成员,仅仅是因为这个self类型声明 这个声明让我们可以访问指定类型的成员。

  • 我们只是修改了ccNum的逻辑,并消耗了之前的逻辑来创建新的逻辑。这是由于混合使用(mix-ins)实现的。

  • 此外,我们面临的一个约束是,你可能想要重写overrideccNum的值,这样当你的CreditCard对象的客户端访问ccNum时,他们可以得到基于新逻辑的值,类似于以下内容:

trait CreditCardOps { 

   self: CreditCard => 
   val ccNumber: String = ccType match { 
     case "BASIC" => "BC" + ccNum 
     case _ => "DC" + ccNum 
   } 
  override val ccNum = ccNumber // will be null 
} 

但是,这是不可能的。编译器会允许你这样做,但在运行时,值将是 null。仔细观察你会发现self只是CreditCard类型的引用,因此你将能够引用成员,就像我们在进行模式匹配时做的那样,但如果你尝试override,它不会显示预期的行为。造成这种情况的一个原因是特性在运行时评估。

通过这种方式,我们最终能够只为CreditCard类型添加附加行为。这是我们使现有结构更强大的方法,也可以根据我们的特定需求对其进行修改。我们有一个术语来描述这个过程,我们称之为通过可组合混入使薄接口丰富。为什么是丰富的?因为它是可添加的或选择性的修改。基于我们的用例,特性还有更多。一个实体自然会有多个特性,不是吗?另外,如果两个或多个可组合的行为一起应用,它们都会产生影响。这是一个有趣的话题;这些行为是如何实现的。这些行为可以实现的其中一种方式是作为可堆叠修改

特性作为可堆叠修改

在我们学习可堆叠修改之前,我们应该知道我们为什么可能需要它们,不是吗?是的,所以理解这个最好的方式是通过使用一个例子。假设有一个服务的消费者。他可以付费订阅特定的服务。例如,你的 DTH 电视订阅服务包括几个频道包。现在,对我们来说,有趣的是我们可能想要实现这个服务。为了实现,我们想要有一个消费者订阅的频道或包列表。在月初,他订阅了一个特定的包,比如BasicPackage。所以我们说了以下内容:

new Service with BasicPackage 

这实际上是很直观的。我们创建了一个带有BasicPackageService。从编程的角度来看,我们可以假设在我们的Service类中有一个特定的值,它包含在某个包中列出的包/频道列表。因此,根据这个声明,该属性必须已经更新了值。到目前为止,一切都很顺利。如果消费者想要订阅更多的包怎么办?我们不希望有一个明确修改我们频道列表的机制,但这是默认发生的。这就像随着我们不断添加不同的包,行为在不断地被修改。这种情况下,你会接触到可堆叠修改的概念:

new Service with BasicPackage with DiamondPackage 

每添加一个包,通道/包的列表都会更新。我们稍后会看到,当我们添加更多包时,实现会隐式地堆叠修改。这些修改可以在某些数据结构上进行,我们可以在数据上管道操作,或者像我们刚才提到的场景。可以添加一系列关于特质的操作/修改。让我们再举一个例子并对其进行处理。为此,我们将使用我们的 CreditCard 抽象类并将其修改为适合我们新引入的 GoldCreditCardPlatinumCreditCard。这些新的卡片订阅为消费者提供了很多好处,同时还有更高的信用额度。金卡订阅者将获得比标准/基本信用卡高出 10% 的信用额度。白金卡的信用额度增加量比标准卡高出 25%,除此之外,它们还带来了其他好处。一个足够冒险的消费者可能想要同时选择这两种订阅。那么你认为我们该如何实现呢?一个提示,我们可能会使用我们刚才讨论的堆叠修改。那么,让我们看看我们已经有的是什么:

abstract class CreditCard { 
  val ccType = "Default" 
  def creditLimit(x: Double) : Amount 

  //legacy creditCardNumberGeneratorLogic 
  val ccNum = scala.util.Random.nextInt(1000000000).toString 

  //other methods} 

这里没有什么不同。我们有一个旧的抽象 CreditCard 类。我们可能想要创建一个标准/基本信用卡:

class BasicCreditCard extends CreditCard { 
  override def creditLimit(x: Double): Amount = Amount(x,"USD") 
} 

这不是陌生的代码,很容易理解。我们创建了一个扩展 CreditCardBasicCreditCard 类,并且我们正在重写 creditLimit 方法。这个方法简单地返回限制金额的 Amount 对象。现在,让我们看看其他订阅类型的实现:

trait GoldSubscription extends CreditCard { 
  abstract override def creditLimit(x: Double): Amount = super.creditLimit(x * 1.10) 
} 

trait PlatinumSubscription extends CreditCard { 
  abstract override def creditLimit(x: Double): Amount = super.creditLimit(x * 1.25) 
} 

根据我们之前讨论的特性,增加了两个名为 GoldSubscriptionPlatinumSubscription 的类型。这里有什么不同呢?语法分析会说这有一个 abstract override 修饰符,但它是如何实现的?如何将这两个结合在一起呢?如果我们不打算提供其定义,我们会声明某个东西为 abstract,并使用 override 来重新定义已经存在于作用域中的某个东西。所以,问题是,这两个都是冲突的。然而,正如我们已知的,Scala 足够智能,知道这是在特质的上下文中完成的,特质在运行时会被评估,当你尝试创建这样一个特质的实例或与某个已经创建的具体类混合时,它将覆盖特定的定义。看下面的例子:

abstract override def creditLimit(x: Double): Amount = //super call 

这里,我们期望有一个 CreditCard 的具体实现来混合这个特质。让我们看看 CCApp 的实现来更好地理解:

object CCApp extends App { 
  val basicCreditCard = new BasicCreditCard()       
  println(basicCreditCard.creditLimit(15000)) 

  val goldCreditCard = new BasicCreditCard() with GoldSubscription 
  println(goldCreditCard.creditLimit(15000)) 

  val platinumCreditCard = new BasicCreditCard() with PlatinumSubscription 
  println(platinumCreditCard.creditLimit(15000)) 

  val gpluspCreditCard = new BasicCreditCard() with GoldSubscription with PlatinumSubscription 
  println(gpluspCreditCard.creditLimit(15000)) 
} 

结果如下:

15000.0 USD 
16500.0 USD 
18750.0 USD 
20625.0 USD 

在前面的代码中,是使用堆叠修改为我们的问题找到的解决方案的实现。这里有几个需要注意的点:

  • 考虑以下代码片段:
trait GoldSubscription extends CreditCard { 
  abstract override def creditLimit(x: Double): Amount = super.creditLimit(x * 1.10) 
} 

我们没有尝试定义一些明确的逻辑实现,而是在一个已经定义的方法上进行了 super 调用,并在参数值中添加了 添加/限制/修改

  • 因此,我们可以传递相同类型的参数,并且随着它的组合,我们将得到的值将被修改。

  • 我们可以应用任意多的CreditCard订阅,根据逻辑,我们将得到预期的creditLimit值。这就是为什么我们能够调用以下内容:

val gpluspCreditCard = new BasicCreditCard() with GoldSubscription with PlatinumSubscription 

println(gpluspCreditCard.creditLimit(15000)) 
             and we got the desired result: 20625.0 USD 
  • 最后但同样重要的是,我们将特性与具有覆盖我们抽象方法的实现的具体类混合,这就是为什么在这里abstract override可以工作。

这很有趣,你知道。我们为所有不同的实例传递了15000creditLimit值,并得到了相应的值。所有这些都是由于特性使用可堆叠的修改。

线性化

当我们尝试实现多重继承时,它变得负担沉重的原因是菱形问题。看看下面的图像:

菱形问题

在这里,假设我们有一个名为Language的抽象类,它有一个名为sayHello的方法。两个特性,名为BritishSpanish,扩展了抽象的Language类,并定义了它们自己的sayHello方法实现。然后我们创建了一个名为Socializer的特性,它通过调用sayHello方法实现的super来混合其他两个特性。现在,当我们调用此方法时,关于哪个sayHello实现被调用产生了混淆。这个问题的主要原因是 Java 中没有多重继承,但 Scala 通过特性混合支持一种形式的多重继承。Scala 用于解决超调用问题的概念是线性化。让我们首先为这个问题编写代码并观察其行为,然后我们将了解线性化以及证明我们即将看到的行为的规则。

假设我们为前面图像中显示的问题编写代码。它将如下所示:

abstract class Language { 
  def sayHello: String 
} 

trait British extends Language { 
  override def sayHello: String = "Hello" 
} 

trait Spanish extends Language { 
  override def sayHello: String = "Hola" 
} 

class Socializer extends British with Spanish { 
  override def sayHello: String = super.sayHello 
} 

object Linearization extends App { 

  class Person(val name: String) 

  val albert = new Person("Alberto") 
  val socializer = new Socializer() 

  println(s"${socializer.sayHello} ${albert.name}") 
} 

结果如下:

Hola Alberto 

你期望这个结果吗?我们已声明我们的类Socializer

class Socializer extends British with Spanish { 
  override def sayHello: String = super.sayHello 
} 

结果显示,调用super.sayHello调用了Spanish特性中的sayHello。嗯,这种行为是由于线性化。但它是如何工作的?线性化的规则如下:

  1. 我们将从第一个扩展的特性/类开始,考虑层次结构。我们将考虑AnyRefAny也是继承层次结构的一部分。

这将给我们以下结果:

British -> AnyRef -> Any

让我们暂时称这为线性化层次结构

  1. 为下一个特性/类写下它的层次结构:
Spanish -> AnyRef -> Any

从这个层次结构中删除已经存在于线性化层次结构中的特性/类。

因此,我们将删除AnyRef -> Any。剩余的语法将是:

Spanish ->

将剩余的特性/类添加到已存在的线性化层次结构的底部。

Spanish -> British -> AnyRef -> Any
  1. 对所有特性/类重复步骤 2

  2. 最后,将特性/类放置在左侧的第一个位置:

Socializer -> Spanish -> British -> AnyRef -> Any

超类调用的方向将是从 。对于我们的实现,我们通过 British 扩展了 Spanish,我们现在知道由于线性化,super 调用是在 Spanish 特质上进行的。结果是来自 Spanish 特质。

结果如下:

Hola Alberto 

这种理解可以帮助我们更好地学习特质混合和继承的常规行为。了解线性化是如何工作的对于理解已编写的代码和创建良好的设计至关重要。既然你已经知道了线性化是如何工作的,为什么不学习面向对象编程中的另一个重要概念呢?

打包和导入

面向对象程序的一个重要方面是我们如何定义模块化、可重用和层次化的结构。我们允许将我们编写的所有代码,如类、特性和对象,放入某个特定的包中。通过使用 打包可见性 规则,我们可以使我们的代码更容易推理,意味着将某些方法暴露给其他类,并且我们得到了结构化和模块化的代码作为额外的优势。在 Scala 中,你可以以几种方式编写包声明;我们将查看这些。

包声明

我们可以在文件的开始处编写包声明。一个最简单的例子如下:

package country 

class Country(val name: String) { 
  import Country._ 

  val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

  def showAveragePopulation() = println(averagePopulation(this.populationsMap.values))
 } 

object Country {
   def averagePopulation(populations: Iterable[Double]) = populations.sum / populations.size 

} 

在这里,我们将包名定义为 country。现在,无论我们在相应的文件中定义什么,都将保持在 country 包的作用域内。这就是为什么当我们想要在 Country 伴生对象中使用一个函数时,我们不需要明确地放置以下内容:

  import country.Country._ 

结尾的下划线 _ 符号,是 Scala 实现通配符导入的方式。这意味着非私有成员将在当前作用域内可访问。

文件中的多个包声明

Scala 还允许我们放置多个包声明和嵌套包声明。首先让我们看看如何:

import country.Country 
import customutil.Util.averagePopulation 

package country { 

  class Country(val name: String) { 

    val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

    def showAveragePopulation() = println(averagePopulation(this.populationsMap.values))
   } 
} 

package state { 

  class State(val name: String) { 

    val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

    def showAveragePopulation() = println(averagePopulation(this.populationsMap.values))
   } 

} 

package customutil { 
  object Util { 

    def averagePopulation(populations: Iterable[Double]) = populations.sum / populations.size 

  } 
} 

在前面的代码中,我们看到我们如何在文件中定义多个包声明。值得注意的是我们这样做的方式。我们使用了花括号和package关键字来告诉 Scala 编译器,这里是我们的包。我们在这个包内部写下的任何内容都将属于这个包。在这里,我们定义了三个包。一个是country,用于定义特定国家的结构;另一个是state,用于定义特定州的结构。最后一个是一个customutil包,正如其名称所表明的,它是为了定义所有国家/州功能性的实用函数。averagePopulation函数既不特定于国家也不特定于州,因此可以在两种情况下使用,因此它被定义在单独的包中,并在顶部全局导入。有了这个导入,我们就为customutil获得了整个编译单元的作用域。我们还可以做的一件事是将包state声明为嵌套在country中的包,因为很明显,多个country结构和成员也应该直接对state结构可用。让我们看看它将如何看起来。

嵌套包声明

我们知道,有时我们可能需要将一些country包结构直接提供给state包。在这些场景中,我们可以将state包定义为country内部的嵌套包。让我们这样写:

import country.Country 

package country { 
  import customutil.Util.averagePopulation 

  abstract class Region 

  class Country(val name: String) extends Region{ 

    val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

    def showAveragePopulation() = println(averagePopulation(this.populationsMap.values)) 

  } 

  package state { 

    class State(val name: String) extends Region { 

      val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

      def showAveragePopulation() = println(averagePopulation(this.populationsMap.values)) 
    } 

  } 
} 

package customutil { 
  object Util { 

    def averagePopulation(populations: Iterable[Double]) = populations.sum / populations.size 

  } 
} 

在这里,我们有一个包state声明嵌套在一个country包声明内部。这样做的作用是,在导入时避免明确提及完整的包声明。同时,它使代码更容易理解,结构更清晰。这表明,预期一个国家是Region,一个州也可以被视为一个区域是合理的。因此,包的嵌套也是一种很好的代码文档方式。它显示了对于结构的理解。

链接包声明

我们还可以通过链式声明包来放置这些包声明。我们如何做到这一点?

package country 
package state 

class State(val name: String) extends Region { 

  val populationsMap  = scala.collection.mutable.Map[Int, Double]() 

  def showAveragePopulation = println(averagePopulation(this.populationsMap.values)) 
} 

通过将Region类放在country包中,如果我们使用前面的语法声明State类,一切都会顺利,你的类State将位于包country.state中。这意味着state将是一个嵌套包。这种编写包名的方式在许多库中广泛使用,当你探索其中一些时,你可能会发现这一点。

这些都是你可以声明包名的方法,现在你也知道了背后的原因。仅仅因为我们有声明多个包名的许可,这意味着我们更容易根据它们的用途来分离结构。例如,为同一文件中的类声明一个名为TestSuite的包是完全可行的。每个人都有自己的编写和结构化包名的方式。有了这些声明,我们也有多种方法在其他作用域中导入这些结构。Scala 导入包的方式也很有趣;为什么不深入研究一下。

导入包

在更简单的场景中,为了将一些对象引入作用域,我们在它们之前放置一个import语句。这就是导入的方式:

object CountryApp extends App { 
  import customutil.Util.averagePopulation 
  val aCountry = new Country("France") 
  aCountry.populationsMap.put(2017, 64.94) 
  aCountry.populationsMap.put(2016, 64.66) 
  aCountry.populationsMap.put(2015, 64.395) 

  println(averagePopulation(aCountry.populationsMap.values)) 
} 

结果如下:

64.66499999999999 

我们使用import关键字并给出导入的名称。在使用 Scala 中的import时,以下几点需要注意:

  • 我们可以在文件的任何地方使用导入

  • 在两个导入冲突的情况下,我们有权利隐藏一个覆盖另一个

  • 我们还可以重命名一些导入

这样,我们在导入包时获得了灵活性。让我们看看其中的一些:

package country {
   abstract class Region

   import customutil.{Util => u}

   class Country(val name: String) extends Region {

     val populationsMap  = scala.collection.mutable.Map[Int, Double]()

     def showAveragePopulation = println(u.averagePopulation(this.populationsMap.values))
   }

   package state {

     class State(val name: String) extends Region {

       import u.{averagePopulation => ap}

       val populationsMap  = scala.collection.mutable.Map[Int, Double]()

       def showAveragePopulation = println(ap(this.populationsMap.values))
     }

   }
 }

 package customutil {
   object Util {

     def averagePopulation(populations: Iterable[Double]) = populations.sum / populations.size

   }
 } 

在前面的例子中,我们是在想要使用它之前声明了一个import语句。另外,还可以看看以下语句:

import customutil.{Util => u} 

这允许我们用u代替Util对象

println(u.averagePopulation(this.populationsMap.values)) 

使用这些(方法)取决于场景;大多数情况下,我们需要注意代码的可读性。例如,将scala.collection.mutable重命名为更短的名字是一个很好的做法——跟随代码的开发者会发现这更容易理解。此外,如果你仔细观察,我们发现重命名了对象Util,这证明了 Scala 允许我们导入并重命名对象。此外,Scala 足够强大,可以重命名函数名,为什么不呢?这些都是我们正在导入的成员。我们就在下面的例子中这样做了:

import u.{averagePopulation => ap} 

我们将averagePopulation函数重命名为ap*,并在以下内容中使用它:

println(ap(this.populationsMap.values)) 

我们可能需要记住同一个点:我们可能在有意义的任何时候重命名——在我们的例子中,它并不适用。但为了演示的目的,考虑它是可以的。Scala 广泛的打包和导入方式使我们更容易处理。在命名空间冲突场景中,我们有方法隐藏一个定义覆盖另一个。这可以通过以下语法完成:

import package1.{Member => _} 

你看?声明是自我解释的。我们将一个特定的包/成员重命名为通配符。它不再提供在当前作用域中使用这个特定的Member的任何手段,并隐藏了它。这是 Scala 隐藏特定包/成员的方式。

这些多种打包和导入方式使我们的代码易于理解,但这还不是全部。我们还可以提供一些修饰符来控制成员的访问权限。我们可以将它们设置为privateprotected,或者默认为public。让我们来讨论它们。

可见性规则

有时候我们不想让另一个类或类似的结构使用一些成员。在这里,我们可以使用 Scala 提供的访问修饰符。我们通过 privatepublicprotected 访问修饰符来控制我们类/特质/对象的成员的可访问性。看看以下例子:

package restaurant 

package privaterestaurant { 

  case class Dish(name: String) 

  trait Kitchen { 
    self: PrivateRestaurant => 

    private val secret = "Secret to tasty dish" //Think of a secret logical evaluation resulting in value, we don't want to expose. 

    def cookMyDish: Option[Dish] = Some(Dish(secret)) 

  } 

  class PrivateRestaurant extends Kitchen { 

    def serveDishWithSecret = Dish(secret) // Symbol secret is inaccessible from this place. 

    def serveDish = cookMyDish // Works fine 
  } 

} 

在这里,我们有一个名为 Kitchen 的特性。它有一种秘密的方法来烹饪一道非常美味的菜肴,但仅限于 PrivateRestaurant 类型。这个秘密不能在厨房外分享。在这些情况下,我们可以通过使用名为 private 的修饰符来保持秘密不变。有了这个,当我们尝试访问秘密食谱时,我们无法做到。Scala 编译器会显示一个错误,指出:符号 secret 在此处不可访问。这样我们就能保持我们的秘密完好无损。但如果你想让你的私人餐厅访问这个秘密,而其他人则不行。在这种情况下,你可以将其设置为 protected

在将我们的秘密设置为受保护之后,我们只能通过 PrivateRestaurant 扩展 Kitchen 来访问它。如果你记得,这是在执行组合,因此我们只需将 private 修饰符更改为 protected 就可以访问秘密:

protected val secret = "Secret to tasty dish" //Think of a secret logical evaluation resulting in value, we don't want to expose. 

我们应该知道,为了使 protected 起作用,必须继承成员类/特质。我们这里的意思是以下内容:

class PrivateRestaurant extends Kitchen { 

  def serveDishWithSecret = Dish(secret) // Works fine 
  def serveDish = cookMyDish // Works fine 
} 

正如你所见,我们有一个这样的混合。这就是为什么在应用了 protected 修饰符之后,我们能够访问秘密食谱的原因。顺便说一句,当我们没有指定任何访问修饰符时,Scala 会将成员视为 public,并且如果成员在作用域内,则可以访问。当涉及到控制访问级别时,Scala 提供的比我们刚刚学到的更多。我们还可以指定保护的范围。通过保护范围,我们指的是特定的成员在某个级别上是可见的。在这里,我们定义什么级别取决于我们。让我们通过一个例子来理解这一点:

case class Dish(name: String) 

class Restaurant 

package privaterestaurant{
   trait Kitchen { 
    self: Restaurant => 

    private[privaterestaurant]  val secret = "Secret to tasty dish" //Think of a secret logical evaluation resulting in value, we don't want to expose. 

    def cookMyDish: Option[Dish] = Some(Dish(secret)) 

  } 

  class  PrivateRestaurant  extends Restaurant with Kitchen  { 

    def serveDishWithSecret = Dish(secret) // Symbol secret is inaccessible from this place. 

    def serveDish = cookMyDish // Works fine 
  } 

} 

package  protectedrestaurant { 

  import restaurant.privaterestaurant.Kitchen 

  class ProtectedRestaurant extends Restaurant with Kitchen { 

    def serveDishWithSecret = Dish(secret) // Symbol secret is inaccessible from this place. 

    def serveDish = cookMyDish // Works fine 
  } 
} 

通过对代码进行适当的审查,有一些要点我们可以考虑:

  • 在以下声明中,它清楚地表明秘密值将在这个名为 privaterestaurant 的包内私下访问:
private[privaterestaurant] val secret = "Secret to tasty dish" //Think of a secret logical evaluation resulting in value, 
//we don't want to expose 
  • 此外,Scala 编译器不会对您使用这些范围的方式提出异议,但如果你在运行时期望的范围不在包内,你可以预期会抛出异常。这意味着对于我们的以下声明:
private[privaterestaurant] val secret  

为了正确工作,我们需要是这个包的一部分。这是逻辑上正确的,否则对不属于我们的东西应用隐私是不明智的。

  • private[X] 类似,我们也可以定义 protected[Y] 等。为某个包定义 protected 将使其对继承树中的类可用。

  • 还有另一个级别的保护,那就是 private[this]。这将使特定的成员对同一类的实例可用。

控制访问级别非常重要。有了它,我们可以为每个结构提供精细的粒度范围。这使客户端远离你可能不希望他们访问的不必要细节。这完全是关于限制成员的访问范围。还有一些其他结构,如 sealedtraits,有助于我们以良好的方式组织代码,但这只是它们的一个方面。还有更多需要了解的内容。

密封特质

关于 密封特质 的一个好处是,标准的 Scala 库大量使用了这些结构,你到目前为止也已经看到过很多次了。是时候学习它们了。我们将从一个例子开始:

sealed trait Season 

case object Autumn extends Season 
case object Winter extends Season 
case object Spring extends Season 
case object Summer extends Season 
case object Monsoon extends Season 

object SealedApp extends App { 
  def season(season: Season) = season match { 
    case Autumn => println(s"It's Autumn :)") 
    case Winter => println(s"It's Winter, Xmas time!") 
    case Spring => println(s"It's Spring!!") 
    case Summer => println(s"It's summer, who likes summer anyway!") 
    case Monsoon => println(s"It's Monsoon!!") 
  } 
  season(Spring) 
} 

结果如下:

It's Spring!!" 

在这里,我们定义了一个名为 Season 的密封特质。然后,有几个从 sealed trait Season 继承的子季节案例对象。顺便说一下,案例对象类似于 case 类,不同之处在于这些对象只有已经可用的名称,而 case 类则不同。关于名为 Season 的密封特质的先前实现,有几个要点需要注意。

  • 所有扩展 Season 特质的子类都应该定义在同一个文件中。

  • 像密封特质这样的结构允许我们进行模式匹配。这对设计结构很有好处。

  • 此外,当我们对密封特质进行模式匹配时,我们需要注意所有可能性。否则,正如我们所知,Scala 编译器足够智能,会在看到模式匹配是在密封特质上时,对剩余场景发出警告。

一些好的和已知的密封特质示例包括 Option 类型到两个名为 SomeNone 的子类。然后,List 也是一个密封特质。这是对密封特质的简要介绍,并允许你实验这个结构。有了密封特质,我们就结束了这一章。让我们总结一下到目前为止我们学到了什么。

摘要

这一章非常有趣。我们学习了 Scala 中的继承,并讨论了组合和继承。这真的有助于我们在实现设计决策时做出选择。然后我们学习了名为特质的这个惊人的结构,并尝试了各种使用特质的方法。然后我们学习了线性化的概念,这有助于我们理解超调用是如何解决的。然后我们讨论了打包和导入,这在 Scala 中提供了不同的使用方式,非常有趣。最后,我们学习了 可见性规则密封特质。在了解了所有这些概念之后,我们可以自信地说,我们现在已经理解了 Scala 中的面向对象概念。所以,是我们做一些真正的函数式编程的时候了。在接下来的几章中,我们的重点将放在 Scala 中的函数使用上。

第八章:更多关于函数的内容

“问题不是通过提供新信息来解决的,而是通过安排我们长期以来所知道的信息。”

——路德维希·维特根斯坦

我们选择编写函数的一个原因是因为它可以使我们的代码更易于阅读,看起来更简洁。在提供解决方案时,将我们的问题陈述分解为组件/操作并为它们形成相应的函数也是更好的做法。这样,使用任何提供函数构造(几乎所有语言都提供这样的构造,因为它们很重要)的编程语言解决任何问题都会更容易。Scala 提供了多种方式来编写函数以实现目标。我们在这章中的目的是学习如何。如果你记得正确,我们在第四章中提到了 Scala 中的函数,即通过函数给程序赋予意义。我们讨论了如何声明函数以及使用不同的参数集调用它们。我们已经了解了函数评估策略和函数字面量和部分函数等重要概念,这些概念将帮助我们更好地理解本章将要学习的内容。让我们看一下。我们将继续讨论函数字面量,然后讨论以下内容:

  • 方法

  • 函数与方法的区别

  • 闭包

  • 高阶函数

  • 部分应用函数

  • 柯里化

高阶函数带你进入一个新宇宙,在那里使用函数、返回函数和找到其他使用这些函数的方法都很有趣。我们将在本章的结尾讨论高阶函数。现在,让我们继续理解函数字面量。

函数字面量

简单来说,函数字面量是表示可以执行以指定输入和输出参数类型的行为的表示:

(value1: Int, value2: Int) => Int 

这一行代表一个函数字面量,它易于阅读。它显示了一个接受两个值,即类型为Intvalue1value2,并返回另一个Int类型的函数。我们已经看到了一些例子,例如我们的ColorPrinter示例,我们只需使用一个名为printPages的函数就能简单地打印彩色以及黑白页面:

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit) = if(lastIndex <= doc.numOfPages) for(i <- 1 to lastIndex) print(i) 

val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

val simplePrint = (index: Int) => println(s"Printing Simple Page $index.") 

println("---------Method V1-----------") 
printPages(Document(15, "DOCX"), 5, colorPrint) 

println("---------Method V2-----------") 
printPages(Document(15, "DOCX"), 2, simplePrint) 

我们的colorPrintsimplePrint值是以下类型的函数字面量的例子:

(index: Int) => Unit

这里的Unit类型表示没有返回类型。换句话说,不要期望函数字面量有任何输出。我们在第四章中讨论了这个概念,即通过函数给程序赋予意义,在那里我们回答了函数字面量的是什么如何。现在,让我们回答为什么,这里的为什么指的是它解决的问题。我们将以先前的例子来说明。仔细观察,你会发现我们抽象了问题陈述。如果我们为简单和彩色页面声明了特定的函数,它们会自己说话,如下面的例子所示。

这里有一个printColorPages函数,它接受一个DocumentlastIndex页面编号,startIndex页面编号,然后打印彩色页面。printSimplePages也是一样:

def printColorPages(doc: Document, lastIndex: Int, startIndex: Int) = if(lastIndex <= doc.numOfPages) for(i <- startIndex to lastIndex) colorPrint(i) 

def printSimplePages(doc: Document, lastIndex: Int, startIndex: Int) = if(lastIndex <= doc.numOfPages) for(i <- startIndex to lastIndex) simplePrint(i) 

这里有一些代码异味,它们如下:

  • 在这两个函数中,只有实现部分不同,即colorPrintsimplePrint

  • 在这两个实现中,输入和输出参数都没有变化

  • 让我们把这两个实现都取出来,并将它们作为参数传递。这将是一个与函数相同形式的函数字面量:

(index: Int) => Unit 

通过回答这类问题,我们得到了最终函数。它看起来如下:

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit) = if(lastIndex <= doc.numOfPages) for(i <- 1 to lastIndex) print(i) 

我希望现在你已经清楚我们如何进行这种重构。最好的事情是,一旦你自己编写了代码,这一切都会对你来说很容易。还有一点我们想了解的是,当你指定这些字面量并将它们赋值给一个值时,你应该知道它们之间的区别。下面的表示将帮助你做到这一点。让我们看看以下图示:

函数值和函数字面量

在前面的图中,我们可以看到两种形式。左边的是函数值,右边的是函数字面量。函数值是运行时对象。问题来了,运行时对象是哪种类型?答案是Function1类型,它是 Scala 中的一个特质,以下是其声明形式:

trait Function1[-T1, +R] extends AnyRef 

在括号[]中,我们指定了类型参数;我们将在第十章中学习这些内容,高级函数式编程。现在,只需理解这两者指定我们的Function1实现将接受第一个类型的输入参数T1,输出将是类型R*。基本上,我们的字面量表示只是这个特质的匿名实例化,我们可以验证编译后的版本看起来如何。之前函数字面量的javap表示如下:

public static scala.Function1<java.lang.Object, scala.runtime.BoxedUnit> colorPrint() 

public static void printPages(chapter4.Document, int, scala.Function1<java.lang.Object, scala.runtime.BoxedUnit>, scala.Function0<java.lang.Object>) 

前面的表示意味着以下内容是等价的:

在这里,在右侧,我们重写了Function1特质的apply方法*。这样写起来更简洁吗?是的!这就是为什么 Scala 有这种语法。这也意味着这种类型的函数值将支持Function1中定义的所有方法。我们知道特质有一个apply方法,它是特质的一个抽象成员。我们还有一些具体的成员,如下所示:

def andThenA ⇒ A): (T1) ⇒ A 

def composeA ⇒ T1): (A) ⇒ R 
incrementByOne and isEven*,* and we want to form another method that performs these two operations in sequence. We can use andThen for this. Check out our implementation of incrementAndCheckForEven. It looks like plain English, telling the Scala compiler, "I'll provide you with an integer, please first execute the incrementByOne function and then check for even numbers.":
val incrementByOne = (num: Int) => num + 1 

val isEven = (num: Int) => num % 2 == 0 

val incrementAndCheckForEven = incrementByOne andThen isEven 

println(s"Representing andThen function ${incrementAndCheckForEven(1)}") 

看看以下内容,以更好地理解:

def andThenA ⇒ A): (T1) ⇒ A 

这告诉我们isEven是函数字面量g,即形式为(R) => A。我们实现中的类型RInt,而XBooleanandThen的返回类型是g(apply(x))

同样,我们也可以为我们的compose函数找到用例。andThencompose之间的区别可以通过两个示例函数——fg来理解:

  • andThen(g):f(x) == g(apply(x))的情况下

  • compose(g): f(x) == apply(g(x))的情况下

你可以完成剩余的数学运算,但使用文字来形成这样的操作流水线很有趣,因为它们易于阅读和实现。这个例子结束了我们对函数文字的讨论,但请相信我,在实践中我们会大量使用这些。

到目前为止,我们一直在将“方法”和“函数”这两个术语互换使用,但实际情况略有不同。这两个概念是不同的。问题是“如何”,所以让我们先从了解方法开始,以便了解更多。

方法

我们已经看到了 Scala 方法,并且到目前为止已经多次使用过它们。然而,为了区分方法和函数,我们将再次查看 Scala 方法。Scala 中的方法是什么?在我们对方法的讨论中,我们将查看几个关于方法是什么的定义。让我们从方法可能包含的内容开始:

图片

方法签名

如你所见,我们的方法以一些修饰符开始,如注解,或者像final这样的关键字等。这之后是def关键字、方法名、参数列表,然后是一个可选的返回类型。Scala 方法足够智能,可以推断出返回类型。通常,方法的返回类型是最后一个表达式评估的结果。查看以下示例:

object Methods { 

  def filePrinter() = { 
    println("Version 0") 
    val filesHere = (new File(".")).listFiles() 
    for(file <- filesHere) 
      println(file) 
  } 

  def main(args: Array[String]): Unit = { 
    filePrinter() 
  } 

} 

结果如下:

Version 0 
./.idea 
./FirstProject.iml 
./out 
./src 

在前面的例子中,我们有一个名为filePrinter的方法,它除了逐个打印当前目录下的文件名外,不做任何其他操作。值得注意的是,我们使用了def关键字、赋值运算符和大括号。我们更喜欢省略指定返回类型,即在我们的情况下是Unit。除了Unit之外不返回任何特定类型的方法也可以用过程语法来编写。在过程语法中,我们省略了赋值运算符,直接定义方法:

def filePrinterV2() { 
  println("Version 2") 
  val filesHere = (new File(".")).listFiles() 
  for(file <- filesHere) 
    println(file) 
} 

在前面的代码中,我们编写了一个方法,用于打印当前目录下的所有文件名。现在,看看下面的内容:

import java.io.File 

object Methods { 

  def filePrinter() = { 
    println("Version 0") 
    val filesHere = (new File(".")).listFiles() 
    for(file <- filesHere) 
      println(file) 
  } 

  def filePrinterV1() 
  { 
    println("Version 1") 
    val filesHere = (new File(".")).listFiles() 
    for(file <- filesHere) 
      println(file) 
  } 

  def filePrinterV2() { 
    println("Version 2") 
    val filesHere = (new File(".")).listFiles() 
    for(file <- filesHere) 
      println(file) 
  } 

  def filePrinterV3() = println("Version 3") 
    val filesHere = (new File(".")).listFiles() 
    for(file <- filesHere) 
      println(file) 

  def main(args: Array[String]): Unit = { 
    filePrinter() 
    filePrinterV1() 
    filePrinterV2() 
    filePrinterV3() 
  } 

} 

我们以不同版本的形式对filePrinter方法有四种表示,从V0V3。对于所有四种方法,我们的意图是相同的。我们想要打印当前目录下的文件名。你能猜出哪些会起作用吗?

如果你猜完了,让我们看看输出结果:

./.idea 
./FirstProject.iml 
./out 
./src 
Version 0 
./.idea 
./FirstProject.iml 
./out 
./src 
Version 1 
./.idea 
./FirstProject.iml 
./out 
./src 
Version 2 
./.idea 
./FirstProject.iml 
./out 
./src 
Version 3 

这是我们的文件打印器文件的输出。通过仔细观察,你会发现对于filePrinter版本V0V1V2,输出是正确的,但对于版本V3,输出是不正确的。此外,评估的顺序告诉我们,在我们的代码的某个地方,我们正在打印文件名。你可能会意识到这是因为我们尝试定义方法版本 v3 的方式。filePrinterV3方法只定义了一个简单的println。Scala 编译器将方法作用域之外后续的语句视为错误。嗯,我们可以确保这类错误不会发生。

我们可以通过显式指定方法的返回类型来实现这一点。指定返回类型会让 Scala 编译器负责处理这类错误,并在编译时通知你。

关于 Scala 中的方法,需要注意的一点是它们是非值类型。这意味着在运行时没有实例或对象。这种概念让我们去探究我们究竟意味着什么以及它是如何工作的。这个声明也提供了函数和方法之间的一大区别。让我们来探究一下这个区别。

函数与方法的比较

在本章的开头,我们提到我们通常将术语函数方法互换使用。但现实情况并非如此。在 Scala 中,这两个是完全不同的概念。我们将使用几个例子来帮助理解这一点。

我们将查看所有差异,从语法差异到语义差异。我们还将探讨何时使用函数或方法:对于现在,让我们使用一个之前的例子。对于colorPrinter函数,在两个版本中,我们将定义一个方法并给它命名为colorPrintV3

val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

val colorPrintV2 = new Function1[Int, Unit]{ 
  override def apply(index: Int): Unit = 
    println(s"Printing Color Page $index.") 
} 

def colorPrintV3(index: Int) = println(s"Printing Color Page $index.") 

我们可以以类似的方式调用它们。在语法上没有区别:

println("---------Function V1-----------") 
printPages(Document(15, "DOCX"), 2, colorPrint) 

println("---------Function V2-----------") 
printPages(Document(15, "DOCX"), 2, colorPrintV2) 

println("---------Method V3-----------") 
printPages(Document(15, "DOCX"), 2, colorPrintV3) 

在这里,colorPrintcolorPrintV2是函数,而colorPrintV3是方法。在前面的用例中,我们将所有这些作为字面量传递。这看起来很相似,并且它以类似的方式工作。它也提供了输出:

---------Function V1----------- 
Printing Color Page 1\. 
Printing Color Page 2\. 
---------Function V2----------- 
Printing Color Page 1\. 
Printing Color Page 2\. 
---------Method V3----------- 
Printing Color Page 1\. 
Printing Color Page 2\.  

我们说 Scala 中的方法和函数是不同的,但我们使用它们的方式和结果是相似的。这是因为编译器在看到可能进行转换的情况下会动态地将方法转换为函数。因此,这是我们对这种冲突情况的反证。我们最好调查一下。

我们将检查编译器生成的类文件。我们的目的是调查这三个:

  • colorPrint

  • colorPrintV2

  • colorPrintV3

编译器生成的文件如下:

:> javap ColorPrinter.class 
Compiled from "FuncLiterals.scala"
public final class chapter4.ColorPrinter { 
  public static void main(java.lang.String[]); 
  public static void delayedInit(scala.Function0<scala.runtime.BoxedUnit>); 
  public static void delayedEndpoint$chapter4$ColorPrinter$1(); 
  public static long executionStart(); 

  public static void colorPrintV3(int); 

  public static scala.Function1<java.lang.Object, scala.runtime.BoxedUnit> colorPrintV2(); 

  public static scala.Function1<java.lang.Object, scala.runtime.BoxedUnit> colorPrint(); 

  public static void printPages(chapter4.Document, int, scala.Function1<java.lang.Object, scala.runtime.BoxedUnit>, scala.Function0<java.lang.Object>); 

  public static boolean printerSwitch(); 
} 

当我们观察前面编译的colorPrint函数系列的类表示时,我们可以清楚地看到 Scala 在内部处理它们的方式是不同的。从这个角度来看,我们可以得出以下结论:

  • Scala 函数字面量被编译成 FunctionX 特质的形态(这里的 X 是一个占位符,代表一个数字,意味着这个函数将要支持的参数数量)。我们已经看到,这个 FunctionX 特质自带了更多方法,例如applyandThencompose

  • Scala 方法被编译成普通的 Java 方法。

  • 最后,适合对象上下文的方法,例如 FunctionX 特质中的 apply 方法,仅在 FunctionX 的匿名实例上调用,因此以下对 apply 方法是有意义的:

colorPrintV2(3) 

上述代码等同于以下代码:

new Function1[Int, Unit]().apply(3) 

这告诉我们 Scala 在编译时对函数语法做了一些魔法操作。我们还知道我们的字面量是 Ffunction objects,因此我们可以对它们执行操作,如 toString,以及等价操作。因此,以下操作是有效的:

colorPrint == colorPrintV2 //false 

然而,编译器不会让你执行以下操作:

colorPrint == colorPrintV3 //Compile Time Error 

原因在于 colorPrintV3 是一个方法而不是值类型。此外,如果你尝试在 colorPrintV3 上调用 toString,编译器会对此提出抱怨,并阻止你在方法类型上执行此类操作。Scala 编译器会自动将方法转换为它的字面等价物,并提供了一种显式执行它的方式。我们使用以下语法来实现这一点:

val colorPrintV4 = colorPrintV3 _ 

看一下后面的下划线。这种语法糖足以让 Scala 编译器将方法转换为函数。现在,你可以调用 toString,创建一个函数管道,或者在 colorPrintV4 上使用 andThencompose 方法。我们甚至可以在它上面执行等价方法。所以这就是 Scala 中方法和函数的不同之处,但现在问题来了,何时选择什么?

方法还是函数?

现在你已经了解了方法和函数之间的区别,你可能想知道在哪里以及何时使用什么。我应该更倾向于函数而不是方法,或者相反吗?早些时候,我们定义了一个 colorPrinterV3 方法,并将其传递给我们的高阶 printPages 函数(我们将在后面的章节中讨论高阶函数) 现在你已经知道编译器必须付出额外的努力将方法转换为它的函数等价物,因此很明显,在依赖于高阶函数的使用场景中,拥有函数的作用域是一个好的选择,这样我们才能正确地沟通。除此之外,很明显,定义函数而不是方法给我们提供了更多的功能选项。我们已经看到了 andThencompose 等方法的例子。这些方法让我们增强了功能。在性能方面,使用上没有太大的区别。有一些场景下,只有方法才是解决方案:

  • 我们可以为方法参数提供默认值,但函数则不行。

  • 在父类-子类关系中的父类方法重写,我们仍然可以使用 super 调用访问父类的该方法版本。然而,一旦你重写了函数,你就不能进行 super 调用,并且只能使用实现。

因此,根据你的需求选择使用哪一个是很明智的。函数为我们提供了更多的链式操作能力。我们可能会选择最适合我们的任何一个。

现在,让我们再次查看我们已有的代码片段,看看我们是否可以做一些修改:

object ColorPrinter extends App { 

  val printerSwitch = false 

 def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit, isPrinterOn: => Boolean) = { 

    if(lastIndex <= doc.numOfPages && isPrinterOn) for(i <- 1 to lastIndex) print(i) 

  } 

  val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

  val colorPrintV2 = new Function1[Int, Unit]{ 
    override def apply(index: Int): Unit = 
      println(s"Printing Color Page $index.") 
  } 

  println("---------Function V1-----------") 
  printPages(Document(15, "DOCX"), 2, colorPrint, !printerSwitch) 

  println("---------Function V2-----------") 
  printPages(Document(15, "DOCX"), 2, colorPrintV2, !printerSwitch) 

} 

case class Document(numOfPages: Int, typeOfDoc: String) 

我使用这个Printer示例是因为它容易理解,我们之前已经看到了它的部分内容。所以,当我们查看printPages函数的调用时,我们可能想要进行一些重构。首先,我们知道通过检查printerSwitch值来检查打印机是开启还是关闭的逻辑。此外,每次我们调用printPages时,我们必须传递!printerSwitch参数。我们希望省略告诉打印机检查它是否开启的额外负担。我们希望打印机已经知道这一点,这正是我们要做的。但在编程环境中,是否可以从printPages函数的内层作用域引用printerSwitch?是的,如果我们选择使用闭包,这是可能的。让我们讨论闭包以及我们如何在 Scala 中定义它们。

什么是闭包?

我们将使用闭包解决上一节中的问题。但首先,让我们解释一下闭包的概念。在编程术语中,闭包有多个定义:

  • 闭包简单地说是在运行时创建的函数值,它包含对不在局部作用域中的自由变量的引用。

  • 在实际意义上,闭包是一个你可以传递的函数,它保留了在创建时相同的范围和值。

我们这些话是什么意思?让我们用一个简单但有趣示例来检查一下:

object AClosure extends App { 

  var advertisement = "Buy an IPhone7" 

  val playingShow = (showName: String) => println(s"Playing $showName. Here's the advertisement: $advertisement") 

  playingShow("GOT") 
  advertisement = "Buy an IPhone8" 

  playingShow("GOF") 

} 

结果如下:

Playing GOT. Here's the advertisement: Buy an IPhone7
Playing GOF. Here's the advertisement: Buy an IPhone8

所以,你明白了,对吧?在这里,我们创建了一个期望一个showName并播放它的函数。这并不那么顺利,我们不得不观看一些广告。这就是前面代码中发生的事情。很明显,用例并不实用,但概念很容易理解。我们试图引用不在我们playingShow函数局部作用域中的变量。当我们第一次使用这个函数时,playingShow的运行时表示正在引用 iPhone 7 广告。然后我们进行时间旅行,当我们第二次调用playingShow时,我们观看了一个与前一个不同的广告。要吸取的要点是,我们playingShow的运行时表示被称为闭包。与闭包相关的术语包括开放项封闭项。在这里,在我们的例子中,advertisement被称为自由变量,因为它不在我们函数/闭包的局部作用域中,而showName参数,我们明确引用的,被称为绑定变量。当我们尝试仅使用绑定变量形成函数字面量时,它被称为封闭项。而反过来,当你包含一个自由变量时,它就变成了开放项。

一个封闭项的例子如下:

(showName: String) => println(s"Playing $showName. No Advertisement") 

一个开放项的例子如下:

(showName: String) => println(s"Playing $showName. Here's the advertisement: $advertisement") 

还有一点需要注意,闭包只保留对自由变量的引用。这就是我们能够检测到advertisement值变化的原因。

现在你对闭包有了些了解,让我们回到printPages函数的重构上来。我们期望的行为是打印机在打印之前就应该知道如何切换。我们可以省略printPages中指定的函数字面量。然后有两种可能的解决方案:

val printerSwitch = false 

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit) = { 

  if(lastIndex <= doc.numOfPages) for(i <- 1 to lastIndex) print(i) 

} 

val colorPrint = (index: Int) => if(!printerSwitch) println(s"Printing Color Page $index.") 

val colorPrintV2 = new Function1[Int, Unit]{ 
  override def apply(index: Int): Unit = 
    if(!printerSwitch) println(s"Printing Color Page $index.") 
} 
isPrinterOn function literal and added the explicit printerSwitch check in two functions, colorPrint and colorPrintV2. This is possible because we were sure about the printer functionality driven by the switch. Also, we removed the extra burden of passing this function literal each time we call for a print. This is a fine and acceptable solution to our problem and the reason we're trying out this example is that it's using a closure in the solution. When we include printerSwitch, which is not in the local scope of our colorPrint function literal*,* we make it a closure. Then, as with our last example, the runtime representation of our colorPrint is going to keep the reference of our printerSwitch forming a closure*.* The refactor seems fine, but it can be enhanced, let's check out the second solution:
val printerSwitch = false 

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit) = { 

  if(lastIndex <= doc.numOfPages && !printerSwitch) for(i <- 1 to lastIndex) print(i) 

} 

val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

val colorPrintV2 = new Function1[Int, Unit]{ 
  override def apply(index: Int): Unit = 
    println(s"Printing Color Page $index.") 
} 

在第二个解决方案中,我们所做的是从printPages函数的参数列表中移除了isPrinterOn,并将实现与!printerSwitch一起放置。这使得我们的printPages函数成为一个闭包,并且我们能够在colorPrintcolorPrintV2中减少代码重复。所以这是我们解决方案的另一种替代方案。

我希望你能理解闭包的概念以及它是如何被使用的。我们还了解到,闭包并不能解决那些没有它们就无法解决的问题。它们只是以简洁的方式完成任务的一种替代方案。我们还看到闭包会携带状态;这是一个在许多语言中定义的概念,在那些没有状态存在的语言(如Haskell)中,这些被用来携带不可变状态。然而,这些是解决特定问题的基本且有趣的替代方案。在 Scala 中,有几种这样的工具类型结构可用,使用这些结构我们可以使我们的代码更加优雅,闭包就是其中之一。

我们已经讨论了很多关于函数、方法和闭包的内容。在这个过程中,我们提出了各种高阶函数。所以现在,我们对高阶函数的概念已经相当熟悉了。当我们看到它时,不会觉得奇怪或感到不舒服。太好了,那么让我们详细讨论一下。你已经对它们在函数式编程环境中的强大功能有所了解。我们将进一步探索它们。

高阶函数

我们知道我们只能将一等值对象作为参数传递给方法或函数。例如,看看这个简单的方法:

def sum(a: Int, b: Int) = a + b 

这是一个名为sum的方法,声明了两个参数ab。现在,要使用这个方法,我们将传递参数。在参数列表中,很明显我们必须传递整数值。显然,任何类型,如果它是一个值,都可以声明为函数参数,并在调用函数时用作参数。

在 Scala 中,函数字面量不过是函数特质对象,因此很明显我们可以将它们声明为参数并用作参数。这产生了包含函数作为参数的函数,以及包含函数字面量作为参数的函数调用。这类函数被称为高阶函数HOF)。使用高阶函数有其自身的优势。我们已经看到了其中的一些。无论我们在库、框架或代码中定义抽象语法的地方,高阶函数都得到了广泛的应用。如果你考虑这些行为/函数如何以高阶方式使用,你会想到以下几种场景:

  • 函数作为输出

  • 函数作为输入

  • 函数作为参数

前面的场景指定了三种我们可以使用函数字面量作为高阶函数的条件。看看下面的图,以获得清晰的画面:

图片

高阶函数的几种形式

如表所示,我们使用了形式为 int => int 的函数字面量,这意味着一个接受整数作为输入并在其上执行某些操作后返回另一个整数的函数。图中的第一种形式接受一个整数作为输入参数并返回一个形式为 int -> int 的函数字面量。在第二种形式中,我们接受一个形式为 int -> int 的函数字面量,并给出一个整数作为输出。在最终形式中,我们期望一个整数和一个形式相同的 int -> int 函数字面量作为参数。让我们看几个例子来更清楚地了解:

object HOFs extends App { 

  def multiplier10(x : Int): Int => Int = x => x * 10   //functionAsOutput 

  def intOpPerformer(g: Int => Int) : Int = g(5)        //functionAsInput 

  def multiplicator(x: Int)(g: Int => Int): Int = g(x)  //functionAsParameter 

  println(s"functionAsInput Result: ${intOpPerformer(multiplier10(5))}") 

  println(s"functionAsParameter Result: ${multiplicator(5)(multiplier10(5))}") 

} 

结果如下:

functionAsInput Result: 50 
functionAsParameter Result: 50 

在前面的代码中,我们定义了所有三种形式。第一种形式输出一个函数,命名为 multiplier10,形式本身具有说明性。它接受一个整数并返回一个乘以 10 的函数字面量。

第二个是一个高阶方法,它接受一个函数字面量作为输入,并输出一个整数作为结果。intOpPerformer 方法,正如其名所示,执行类型为 Int => Int 的操作,无论是乘法操作还是任何其他操作。无论我们传递什么,它都将被使用,输出将如签名中所述的整数。我们可以通过提供一个输入函数字面量来调用该函数:

intOpPerformer(multiplier10(5)) 

因此,这是我们可以利用函数高阶性质的另一种方式。还有另一种,第三种版本,我们将这个函数字面量作为参数列表的一部分传递,它将第一个参数值应用于第二个参数函数,并给出一个整数结果。multiplicator 函数是这种结构的例子。我们使用了函数的柯里化形式。我们将在后续章节中学习柯里化:

multiplicator(5)(multiplier10(5)) 

这些是我们可以整合高阶函数的方式,并且它们都解决了某些问题。通过移除代码重复,我们可以抽象出模式,并创建我们函数的高阶版本。这就是我们使用它们的方式。

我们著名的ColorPrinter示例也使用了高阶函数:

def printPages(doc: Document, lastIndex: Int, print: (Int) => Unit, isPrinterOn: => Boolean) = { 

    if(lastIndex <= doc.numOfPages && isPrinterOn) for(i <- 1 to lastIndex) print(i) 

  }  

在这里,print是一个高阶函数,我们将其作为参数传递。仔细观察将更好地解释它。colorPrint参数本身就是一个函数字面量:

printPages(Document(15, "DOCX"), 2, colorPrint, !printerSwitch) 

val colorPrint = (index: Int) => println(s"Printing Color Page $index.") 

这仅仅是因为colorPrint是一个值对象。在此话题上,有时你可能读到:“函数式语言将函数视为一等值。”我们所说的一等值是什么意思?这意味着我们声明一个整数或字符串值并使用它们作为参数的方式。同样,我们可以声明一个函数字面量并将其用作其他函数的参数。

高阶函数使得以简单和可读的方式组合函数或函数链以执行复杂任务变得容易得多。不用说,像mapflatmapfilterfold等这样的实用函数都是高阶函数。在 Scala 或其他任何编程语言中,像mapfilterflatmap这样的函数是试图解决特定模式问题的一个结果。因此,很明显,我们从函数中提取了某个模式的一部分,并用高阶函数替换了它。这个动作的想法是将实现抽象成函数字面量。

为了更好地理解,一个简单的例子是对两个整数进行简单的数学运算。看看以下内容:

def add(a: Int, b: Int) = a + b 

def multiply(a: Int, b: Int) = a * b 

def subtract(a: Int, b: Int) = a - b 

def modulus(a: Int, b: Int) = a % b 

在前面的代码中,有几个方法接受两个输入参数并执行特定的操作。使用这些方法也很简单:

add(10, 5) 
subtract(10, 5) 
multiply(10, 5) 
modulus(10, 5) 

但作为优秀的程序员,检查实现细节是我们的职责,即使解决方案已经可行。当你这样做时,你会看到所有四种实现有许多共同之处。所有四种方法都有两个参数,但定义证明是不同的,因为每种方法执行的操作不同。从某种意义上说,我们知道我们可以将签名和实现细节抽象出来,以函数字面量的形式形成一个高阶函数。因此,我们为此采取步骤。

  • 首先,我们创建了所有四种实现的功能字面量版本,它们的形式为(Int, Int) => Int,我们得到了以下类似的内容:
val add = (a: Int, b: Int) => a + b 
val multiply = (a: Int, b: Int) => a * b 
val subtract = (a: Int, b: Int) => a - b 
val modulus = (a: Int, b: Int) => a % b 

这确保了我们可以在不担心内部动态转换的情况下传递这些字面量。

  • 然后,我们编写了一个抽象方法,它接受一个这样的函数字面量和两个整数参数来执行操作。结果看起来如下:
def operation(op: (Int, Int) => Int, a: Int, b: Int) : Int = op(a, b) 

在这里,operation是一个高阶方法,它接受一个函数字面量和两个参数,并调用传递给其他参数的函数。

现在,使用高阶方法就像调用任何其他函数一样简单:

operation(add, 10, 5) 
operation(subtract, 10, 5) 
operation(multiply, 10, 5) 
operation(modulus, 10, 5) 

你可能会问这是否有意义。我们仍然写了同样(加上一些额外)的代码行。所以让我们移除我们写的字面量,因为那些只是我们命名的字面量。我们可以通过提供动态意图来直接使用 operation 函数的功能。在最终实现之后,我们的代码看起来如下:

object HOFs extends App { 

  def operation(op: (Int, Int) => Int, a: Int, b: Int) : Int = op(a,b) 

  println(operation((a, b) => a + b, 10, 5)) 
  println(operation((a, b) => a * b, 10, 5)) 
  println(operation((a, b) => a - b, 10, 5)) 
  println(operation((a, b) => a % b, 10, 5)) 

} 

结果如下:

15 
50 
5 
0 

真正需要的只是一个单行函数,即 operation 高阶函数。接下来的几行是对先前 operation 函数的调用。有一点需要注意,在 operation 函数的调用端,我们没有提供 ab 参数的类型。这是因为 Scala 足够强大,能够理解 operation 函数的第一个参数期望的类型与我们提供的类型相同。

希望这个例子能帮助你理解高阶函数的概念。在实践中,你用得越多,就越能体会到它们的强大。存在几种形式,我们可以用它们来解决问题。

现在我们已经看到了高阶函数的多个表示和用法,让我们看看另一种使用柯里化的方式来调用函数。你可能已经听说过柯里化这个概念。在这里,我们的座右铭是理解柯里化的含义以及它解决的问题。

柯里化

Scala 允许你在函数或方法中传递多个参数。我们可能想要创建这种函数的中间版本。这给了我们一个单一函数的多个版本。换句话说,我们可以将具有多个参数的每个函数分解为单参数函数。我们为什么要创建单参数函数呢?这个答案就是,我们可以利用它来进行函数组合。例如,我们可以借助域名、托管和网站平台来启动一个网站。请看以下示例:

WebsitePlatform => DomainName => Host 

如果你有一个接受域名作为参数的函数,另一个接受网站平台作为参数的函数,以及另一个接受托管平台作为参数的函数,你可以将它们组合起来以拥有一个完整的网站。函数组合之所以强大,是因为它为你提供了更多的选项,同时结合了中间函数。一个普通的函数可能看起来像以下这样:

def makeWebsite(platform: WebsitePlatform, domainName: DomainName, host: Host) = println(s"Making $domainName using $platform with hosting from $host ") 

这种方法的形式并不提供你在函数组合时所拥有的相同功能。如前所述,在 Scala 中,我们可以将我们的函数转换为柯里化形式,或者将具有多个参数的函数转换为单参数函数。为此,Haskell Curry 提供了柯里化的概念。以下是一个帮助我们理解这个概念的示例。我们将使用制作网站的相同示例。问题陈述很清晰。我们想要组合中间函数,在这些函数中我们可以传递多个单参数列表。最终函数应该看起来像以下这样:

def makeWebsite(platform: WebsitePlatform)(domainName: DomainName)(host: Host): Unit 

在这里,WebsitePlatformDomainNameHost是我们选择使用的类型。我们可以使用 Scala,提供type关键字来创建这些类型。前面函数的一种形式如下:

WebsitePlatform => DomainName => Host => Unit 

假设你想要一个中间函数,它不需要处理网站平台,只需为指定的账户创建一个 WordPress.com 平台账户。该函数应返回如下内容:

DomainName => Host => Unit 

其他两个中间版本也是如此。例如,你想要创建一个使用默认 WordPress.com (wordpress.com/) 平台和你的网站的默认 WordPress URL 的虚拟网站。该版本看起来如下:

Host => Unit 

最终版本使用 bluehost.com 作为默认托管提供商并为你创建网站来处理所有默认设置。一个示例应用看起来如下:

object Curried extends App { 

  type WebsitePlatform = String 
  type DomainName = String 
  type Host = String 

  def makeWebsite(platform: WebsitePlatform)(domainName: DomainName)(host: Host) = 
    println(s"Making $domainName using $platform with hosting from $host ") 

  val wordPress: DomainName => Host => Unit = makeWebsite("WordPress") 

  val wordPressDummyDotCom : Host => Unit = wordPress("dummy123.com") 

  val blueHostedWordPressDummyDotCom : Unit = wordPressDummyDotCom("Bluehost.com") 

  blueHostedWordPressDummyDotCom

 }

结果如下:

Making dummy123.com using WordPress with hosting from Bluehost.com 

你查看过前面的代码了吗?我们逐步使用了组合来制作一个默认网站创建器,该创建器使用 WordPress.com 作为网站平台,bluehost.com 作为托管提供商,以及一些虚拟 URI 作为 URL。让我们尝试理解这是如何工作的。我们首先做的是添加一个语法增强,以便更好地理解。我们使用类型关键字声明的三个类型只是字符串。当然,这些字符串是用于演示目的的。它们可以是不同类型的。然后我们声明了一个接受三个不同单参数列表的方法的柯里化版本。定义现在并不很重要,我们只是打印出来。

然后是有趣的部分。我们创建了一个名为wordPress的函数的中间版本,该函数的返回类型是DomainName => Host => Unit。在接下来的步骤中,我们创建了一个名为wordPressDummyDotCom的另一个中间函数,该函数特定于 WordPress.com 并使用虚拟 URL。同样,我们又组合了另一个函数,提供了另一个默认网站组件。这种做法的优势在于,我们可以创建具有不同网站平台的多个版本,从而使你的程序客户端更容易操作,因为你几乎为每一组参数提供了多个默认函数的版本。为此,我们使用的不过是函数的柯里化形式。在 Scala 中,转换或编写函数的柯里化版本非常常见,以至于该语言有默认的方式来做这件事。在 Scala 中,可以将具有多个参数的函数转换为它的柯里化对应版本。

将具有多个参数的函数转换为柯里化形式

在 Scala 中,我们有一个名为curried的函数,我们可以用它将我们的函数转换为柯里化形式。让我们通过一个简单的例子来更好地理解:

def add = (x: Int, y: Int) => x + y 

val addCurried = add.curried 

println(add(5,6)) 
println(addCurried(5)(6)) 

结果如下:

11 
11 

这里,我们定义了一个简单的两个参数函数字面量名为add。然后,我们使用 Scala 提供的名为curried的函数将函数转换为它的柯里化形式。我们将结果命名为addCurried。有了这个,我们能够调用这两个函数并得到相同的结果。

也有一种方法可以将柯里化函数取消柯里化。我们有这个uncurried方法,使用它可以转换柯里化函数为非柯里化形式:

val addCurriedUncurried = Function.uncurried(addCurried) 

println(addCurriedUncurried(5,6)) 

结果如下:

11 

这就是在 Scala 中使用柯里化的方式。在 Scala 中,我们可以使用类似的构造来满足相同的目的,我们称之为部分应用函数。这些与柯里化不同。让我们详细讨论这个话题。

部分应用函数

部分应用函数,正如其名所示,只部分应用函数。这意味着对于参数列表中有多个参数的函数,我们不为每个参数提供一个值。如果我们不想提供参数,我们只需将它们留空。现在我们知道了这一点,让我们看看一个与我们在学习柯里化时看到的类似例子。有了这个,你将能够区分这两种情况。

首先,看看多参数函数,我们将它们转换为部分应用形式:

def makeWebsite(platform: WebsitePlatform, domainName: DomainName, host: Host) = 
  println(s"Making $domainName using $platform with hosting from $host ") 

在这里,makeWebsite,正如我们之前所看到的,接受三个参数,platformdomainNamehost。看看我们可以使用各种中间或部分应用函数创建的应用程序:

object PaF extends App { 

  type WebsitePlatform = String 
  type DomainName = String 
  type Host = String 
  type Protocol = String 

  def makeWebsite(platform: WebsitePlatform, domainName: DomainName, host: Host) = 
    println(s"Making $domainName using $platform with hosting from $host ") 

  val wordPressSite: (DomainName, Host) => Unit = makeWebsite("WordPress", _: DomainName, _: Host) 

  val makeExampleDotCom: (WebsitePlatform, Host) => Unit = makeWebsite(_: WebsitePlatform, 
      "example.com", 
      _: Host 
    ) 

  val makeBlueHostingExampleDotCom: (WebsitePlatform) => Unit = makeWebsite(_: WebsitePlatform, 
    "example.com", 
    "bluehost.com" 
    ) 

  makeWebsite("Wordpress", "anyDomain.com", "Godaddy.com") 
  wordPressSite("example.com", "Godaddy.com") 
  makeExampleDotCom("Wordpress", "bluehost.com") 
  makeBlueHostingExampleDotCom("Blogger") 

} 

结果如下:

Making anyDomain.com using Wordpress with hosting from Godaddy.com  
Making example.com using WordPress with hosting from Godaddy.com  
Making example.com using Wordpress with hosting from bluehost.com  
Making example.com using Blogger with hosting from bluehost.com 

因此,这里我们可以看到三个部分应用函数。看看第一个:

val wordPressSite: (DomainName, Host) => Unit = makeWebsite("WordPress", _: DomainName, _: Host) 

函数名暗示了它的作用。我们可以预期这个函数将提供一个针对WebsitePlatform的部分应用函数,这就是为什么函数的返回类型是以下形式:

(DomainName, Host) => Unit 

要使用此函数,我们只需提供未应用的参数即可,它就会工作:

  wordPressSite("example.com", "Godaddy.com") 

同样,我们也描述了其他版本,让我们看看它们。对于其中一个,我们在调用时提供了一个默认的虚拟 URL、网站平台和托管服务:

val makeExampleDotCom: (WebsitePlatform, Host) => Unit = makeWebsite(_: WebsitePlatform, 
      "example.com", 
      _: Host 
    ) 

该版本返回的类型如下:

(WebsitePlatform, Host) => Unit 

当我们查看实现部分时,我们看到未应用的参数被下划线替换。我们还明确地提供了参数的类型。因此,从某种意义上说,部分应用函数解决了柯里化为我们解决的问题。此外,我们知道部分函数有类似的概念。因为我们已经了解了部分函数,我们必须知道它们只是为特定的一组输入值定义的函数。因此,我们应该能够区分这三个概念。

通过对部分应用函数的讨论,我们结束了本章的内容。让我们总结一下我们学到了什么。

概述

在本章中,我们增强了我们在 Scala 中对函数的知识。我们从基本的方法和函数定义开始,研究了它们之间的区别,并探讨了 Scala 如何处理它们。我们还看到 Scala 足够智能,能够在需要时将方法转换为函数。然后我们进一步讨论,并谈到了闭包。我们了解了什么是 闭包,然后我们对 Scala 中的高阶函数进行了深入的讨论。这是必要的,因为我们已经在使用高阶函数,并且看到了它们的多种形式。之后,我们研究了柯里化,并讨论了 部分应用函数。我们知道部分应用函数与 部分函数柯里化 是不同的。因此,我们现在对 Scala 中的函数有了坚实的理解,因为我们已经彻底研究了它们。

现在是时候更进一步,学习一些高级函数式结构了。本章获得的知识将帮助我们完成下一章的学习,我们将学习高级函数式结构。

第九章:使用强大的函数式构造

"我们不能用创造问题时使用的相同思维方式来解决我们的问题。”

– 阿尔伯特·爱因斯坦

当我们试图通过编写程序来解决问题时,我们的目的是编写更好的代码。更确切地说,我们指的是代码应该是可读的,并且在编译时和运行时都是高效的。可读性和效率是两个主要因素,以及其他重要的概念,如并发、异步任务等。我们可以将前两个视为我们想要的下一种特性的构建块。Scala 作为一种多范式语言,提供了多种结构来确保我们编写的代码是优化的,并在需要时提供了语法糖。许多在 函数式编程 中使用的函数式结构或概念使你能够编写 更好 的代码,不仅满足前两个要求,而且允许你的代码在 并发分布式 环境中运行。

我们在本章中的目的是学习我们可以使我们的代码变得更好的方法。为此,我们将探讨一些语法结构。让我们看看本章我们将要探讨的内容:

  • 对于表达式

  • 模式匹配

  • 选项类型

  • 懒声明

  • 尾调用优化

  • 组合子

  • 类型参数化

所有这些概念都很简单但非常有用,尤其是在你编写 Scala 代码时。其中一些我们已经讨论过,比如 for 表达式。我们的目的是将 for 表达式与可用的更高阶函数,如 mapflatMapwithFilter 进行比较。

对于表达式

如果我们说 for 表达式是 Scala 中的强大构造,那我们并不会错。for 表达式允许你遍历任何集合并执行过滤和产生新集合的操作。我们已经在 第三章,“塑造我们的 Scala 程序”中讨论过这个概念。让我们回顾一下我们看到的例子:

object ForExpressions extends App { 

  val person1 = Person("Albert", 21, 'm') 
  val person2 = Person("Bob", 25, 'm') 
  val person3 = Person("Cyril", 19, 'f') 
  val persons = List(person1, person2, person3) 

  val winners = for { 
    person <- persons 
    age = person.age 
    name = person.name 
    if age > 20 
  } yield name 

  winners.foreach(println) 

} 

case class Person(name: String, age: Int, gender: Char) 

结果如下:

Albert 
Bob 

在前面的例子中,我们有一个 Person 对象的集合。我们在这个集合上执行遍历,并基于某些条件生成一个包含所有人员名称的新集合。正如我们所知,为此我们使用了三个结构,或者说表达式:

  • 生成器

    • person <- persons
  • 定义

    • age = person.age

    • name = person.name

  • 过滤器

    • age > 20

通过这三个表达式,我们能够以非常少的语法努力执行稍微复杂的逻辑。我们也可以用高阶函数的形式执行类似的操作。使用 mapwithFilter 我们可以执行这样的操作,让我们来看一个例子:

val winners1 = persons withFilter(_.age > 20) map(_.name)
winners1.foreach(println)

case class Person(name: String, age: Int, gender: Char) 

结果如下:

Albert 
Bob 

在这里,我们使用了高阶函数来实现我们使用 for 表达式实现的相同逻辑。我们已经熟悉了集合中的 map 方法。它将提供一个年龄大于 20 岁的人的列表。所以现在,我们以两种不同的方式实现了相同的逻辑。首先,以 for 表达式 的形式,其次,以 高阶函数 的形式。因此,了解这是如何发生的对我们来说很重要。Scala 编译器所做的是,它将 for 表达式内部分解为高阶函数。程序员倾向于使用 for 表达式以提高可读性,但这只是一个选择问题。既然我们已经了解了内部发生的事情,我们可以开始思考 Scala 对稍微复杂的 for 表达式进行的转换,不是吗?是的,所以让我们试试看。

现在,假设我们有一个汽车品牌列表,每个品牌下有许多汽车(换句话说,每个品牌都有一个汽车列表)。代码看起来可能如下所示:

case class Car(name: String, brandName: String) 
case class Brand(name: String, cars: List[Car]) 

val brands = List( 
Brand("Toyota", List(Car("Corolla", "Toyota"))), 
Brand("Honda",  List(Car("Accord", "Honda"))), 
Brand("Tesla",  List(Car("Model S", "Tesla"), 
                                      Car("Model 3", "Tesla"), 
                                      Car("Model X", "Tesla"), 
                                      Car("New Model", "Tesla")))) 

你可能希望为所有以 Model 关键字开头的特斯拉汽车生成一个对列表。你将执行如下操作:

val teslaCarsStartsWithModel = for { 
  brand <- brands 
  car <- brand.cars 
  if car.name.startsWith("Model") && brand.name == "Tesla" 
} yield (brand.name, car.name) 

teslaCarsStartsWithModel foreach println 

结果如下:

(Tesla,Model S) 
(Tesla,Model 3) 
(Tesla,Model X) 

我们使用了 for 表达式来完成这个任务。这个任务有两个生成器表达式,我们也在它上面执行了过滤操作。在将这些类型的 for 表达式转换为高阶函数时,Scala 使用了 flatMap 方法。让我们看看如何使用 flatMap 实现相同的功能:

val teslaCarsStartsWithModel2 = brands.flatMap(brand =>  
  brand.cars withFilter(_.name.startsWith("Model") && brand.name == "Tesla") map(car => (brand.name, car.name))) 

teslaCarsStartsWithModel2 foreach println 

结果如下:

(Tesla,Model S) 
(Tesla,Model 3) 
(Tesla,Model X) 

我们得到了类似的结果。所以让我们尝试将这个 teslaCarsStartsWithModel2 分解开来,理解我们是如何实现这个功能的。首先,我们有的如下所示:

For(gen1 <- list, gen2 <- gen1.list, filter1) 

在有两个生成器的情况下,我们使用 flatMap 函数而不是 map。让我们一步一步地通过 for 表达式到高阶函数的转换过程:

  1. 我们有以下内容:
for { 
  brand <- brands 
  car <- brand.cars 
  if car.name.startsWith("Model") && brand.name == "Tesla" 
} yield (brand.name, car.name) 
  1. 我们首先使用了 flatMap:
brands.flatMap{ brand => 
     for{ 
       car <- brand.cars 
        if car.name.startsWith("Model") && brand.name == "Tesla" 
     } yield (brand.name, car.name) 
} 
  1. 现在我们已经得到了品牌,我们可以访问汽车列表。我们可以继续使用以下过滤谓词:
brands.flatMap{ brand => 
      brand.cars withFilter{ car =>  
         car.name.startsWith("Model") && brand.name == "Tesla" 
      } map(car => (brand.name, car.name))  
} 

这是我们的实现最终版本。我们在这里所做的是,我们从 cars 集合中过滤出元素,并最终将我们的集合转换为预期的形式。

因此,这就是 Scala 编译器将我们的 for 表达式转换为提供函数的方式。作为程序员,我们只需要处理实现部分。你可能希望将你的逻辑放在 for 表达式中,而不是编写嵌套的高阶函数,Scala 会为你完成剩下的工作。

在这里,我们详细学习了你可以以不同形式添加你的逻辑。同样,你也会找到我们必须通过并执行任何适用逻辑的案例。在这个过程中匹配不同的案例,我们可以通过匹配不同的模式来增强自己。例如,我们可能想要匹配我们的列表类型与可能的值。选项将是空列表或具有某些值的列表。Scala 不会限制你只以这两种方式匹配,但你将拥有更多匹配的选项。所有这些都是通过一个称为模式匹配的概念实现的。好事是,我们已经了解了模式匹配的概念,所以我们现在要做的是进一步理解它。

模式匹配

我们使用模式匹配根据案例执行代码。看看以下内容:

val somelist = List(1,2,3) 

somelist match { 
  case Nil => Nil 
  case _ => ??? 
} 

通过查看我们的模式匹配表达式结构,我们可以看到一些事情。首先,我们执行一个匹配某个值,然后跟随着match关键字,然后放置案例。对于每个案例,我们指定一些模式。现在,模式可以是一个常量值、一个变量,甚至是一个构造函数。我们很快就会查看所有这些模式。模式匹配还允许我们以条件的形式在我们的匹配中放置守卫。在这种情况下,只有当条件适用时,模式才会匹配。如果你看一下之前的关于somelist的玩具示例,你会看到一个**_*下划线。它被称为通配符模式。它将匹配所有值或模式与案例。从逻辑上讲,你不能在通配符之后放置另一个案例。例如,以下没有意义,并会抛出一个警告:

*```java
val somelist = 1 :: 2 :: 3 :: Nil

val x = somelist match {
case Nil => Nil
case _ => println("anything")
case head :: tail => println("something with a head and a tail")
}
Warning:(21, 10) patterns after a variable pattern cannot match (SLS 8.1.1)
case _ => println("anything")
Warning:(22, 33) unreachable code due to variable pattern on line 21
case head :: tail => println("something with a head and a tail")
Warning:(22, 33) unreachable code
case head :: tail => println("something with a head and a tail")


这是一个 Scala 中模式匹配的相当基础的例子。我们还有更多可以模式匹配的方式。为什么不看看所有这些方式呢?

# 我们可以以不同的方式模式匹配

Scala 中的模式匹配证明是一个非常重要的概念。我们可以对变量、常量甚至构造函数进行匹配。我们将逐一查看它们。让我们从对变量的匹配开始。

# 匹配变量

有时,当我们需要在模式匹配成功后使用值时,我们想要对带有变量的案例进行匹配。这样做的作用是将值分配给变量,然后我们可以在我们的代码中为特定案例使用它。如果我们看一下以下示例,会更好:

```java
import scala.util.control.NonFatal

def safeToInt(canBeNumber: String): Option[Int] = { 
  try { 
    Some(canBeNumber.toInt) 
  } catch { 
    case NonFatal(e) => None 
  } 
} 

safeToInt("10") match { 
  case None => println("Got nothing") 
  case someValue =>  println(s"Got ${someValue.get}") 
} 

结果如下:

Got 10 

这里,我们定义了一个方法,该方法尝试将字符串表示的数字转换为整数。然后,我们用参数调用该方法,并尝试使用名为someValue的变量进行匹配。这个someValue变量将与匹配的值的类型相同。

匹配常量

我们还可以对常量进行案例匹配,例如基本的 switch 语句。看看以下内容:

def matchAgainst(i: Int) = i match { 
  case 1 => println("One") 
  case 2 => println("Two") 
  case 3 => println("Three") 
  case 4 => println("Four") 
  case _ => println("Not in Range 1 to 4") 
} 

matchAgainst(1)
 matchAgainst(5)

结果如下:

One 
Not in Range 1 to 4 

在这里,我们直接将我们的表达式与常量值进行匹配。这可以是任何值,具体取决于你的方法接受的数据类型。你可以匹配布尔值、字符串或任何其他常量值。

匹配构造函数

好的,构造函数模式看起来是什么样子?它涉及到将构造函数与一个值进行匹配,或者说,提取我们选择的价值。让我们来看一个例子:

def safeToInt(canBeNumber: String): Option[Int] = { 
  try { 
    Some(canBeNumber.toInt) 
  } catch { 
    case NonFatal(e) => None 
  } 
} 

safeToInt("10") match { 
  case None => println("Got nothing") 
  case Some(value) =>  println(s"Got $value") 
} 

结果如下:

Got 10 

我们能看到的唯一区别是,我们不是提供一个变量,而是给出了一个构造函数模式。Some(value)让你从自身中提取value。在这个给定的例子中,safeToInt方法返回一个Option类型。我们将在后续章节中学习类型。现在,对我们来说有趣的信息是我们有Option类型的两个子类型,分别命名为SomeNone。正如名称所暗示的,Some表示有值,而None表示没有值。Some子类型期望一个特定的值作为其构造函数参数。因此,我们可以对它进行匹配。下面的行正是我们刚才提到的:

case Some(value) => println(s"Got $value") 

通过这个声明,我们可以提取一个值,在我们的例子中,提取的参数名称也是value,因此我们使用了它。这是一个使用构造函数进行模式匹配的例子。我们已经了解了 Scala 中的case类,并且提到case类为我们提供了一个精确的结构,通过这个结构我们可以直接进行模式匹配。所以让我们来看一个例子:

 trait Employee 
 case class ContractEmp(id: String, name: String) extends Employee 
 case class Developer(id: String, name: String) extends Employee 
 case class Consultant(id: String, name: String) extends Employee 

/* 
 * Process joining bonus if 
 *     :> Developer has ID Starting from "DL"  JB: 1L 
 *     :> Consultant has  ID Starting from "CNL":  1L 
 */ 
 def processJoiningBonus(employee: Employee, amountCTC: Double) = employee match { 
   case ContractEmp(id, _) => amountCTC 
   case Developer(id, _) => if(id.startsWith("DL")) amountCTC + 10000.0 else amountCTC 
   case Consultant(id, _) => if(id.startsWith("CNL")) amountCTC + 10000.0 else amountCTC 
 } 

 val developerEmplEligibleForJB = Developer("DL0001", "Alex") 
 val consultantEmpEligibleForJB = Consultant("CNL0001","Henry") 
 val developer = Developer("DI0002", "Heith") 

 println(processJoiningBonus(developerEmplEligibleForJB, 55000)) 
 println(processJoiningBonus(consultantEmpEligibleForJB, 65000)) 
 println(processJoiningBonus(developer, 66000)) 

结果如下:

65000.0 
75000.0 
66000.0 

在这个例子中,我们定义了三种员工类别:DeveloperConsultantContractEmp。我们有一个问题要解决:我们必须根据某些条件处理特定类别中特定员工的入职奖金。整个逻辑在case类和模式匹配方面非常容易实现,这正是我们在这里所做的事情。看看以下来自前一个解决方案的行:

case Developer(id, _) => if(id.startsWith("DL")) amountCTC + 10000.0 else amountCTC 

在这里,我们与case类构造函数进行匹配。我们对所需的参数给出了一些名称,其他则用通配符_(下划线)替换。在这里,我们必须对id参数设置一个条件,因此在相应的case类构造函数中提到了它。你可以看到case类和模式匹配如何使一个稍微复杂的领域问题变得非常容易解决。嗯,这还没有结束,还有更多。我们还可以在我们的case表达式上设置守卫。让我们看看带有守卫的相同例子:

/* 
 * Process joining bonus if 
 *     :> Developer has ID Starting from "DL"  JB: 1L 
 *     :> Consultant has  ID Starting from "CNL":  1L 
 */ 
 def processJoiningBonus(employee: Employee, amountCTC: Double) = employee match { 
   case ContractEmp(id, _) => amountCTC 
   case Developer(id, _) if id.startsWith("DL") => amountCTC + 10000.0 
   case Consultant(id, _) if id.startsWith("CNL") =>  amountCTC + 10000.0 
   case _ => amountCTC 
 } 

结果如下:

65000.0 
75000.0 
66000.0 

如果我们看看以下表达式,我们可以看到在我们的case模式上有守卫。所以值只有在守卫允许的情况下才会匹配:

case Developer(id, _) if id.startsWith("DL") => amountCTC + 10000.0 

因此,在执行块的右侧之前,这个表达式检查id是否以"DL"开头,并根据这个结果进行匹配。这就是我们可以直接使用构造器提取参数并使用它们的方式。你还可以以更多的方式使用模式。例如,我们可以对序列或元组执行匹配。当我们必须匹配一些嵌套表达式,或者匹配一个包含另一个case类的case类时,这也是可能的。为了使我们的代码更有意义,并且为了可读性,我们可以使用@符号绑定嵌套case类并执行模式匹配。让我们举一个例子:

case class Car(name: String, brand: CarBrand) 
case class CarBrand(name: String) 

val car = Car("Model X", CarBrand("Tesla")) 
val anyCar = Car("Model XYZ", CarBrand("XYZ")) 

def matchCar(c: Car) = c match { 
  case Car(_, brand @ CarBrand("Tesla")) => println("It's a Tesla Car!") 
  case _ => println("It's just a Carrr!!") 
} 

matchCar(car) 
matchCar(anyCar) 

结果如下:

It's a Tesla Car! 
It's just a Carrr!! 

上述示例是自我解释的。我们在Car内部有一个嵌套的case类,名为CarBrand,并对其进行了模式匹配。我们使用@符号访问了该特定对象。所以,这些都是我们可以使用模式匹配轻松执行所有这些任务的几种方法。到现在,你一定对模式匹配有了概念;它有多么强大和重要。

在执行所有这些模式匹配的过程中,我们感觉到有一些反例,我们不想执行匹配,并使用通配符,这样我们就可以提供任何返回值。可能在这种情况下没有预期的值,我们只想让我们的代码有意义,同时返回一个有意义的响应。在这些情况下,我们可以使用我们的Option类型。正如其名所示,当你将类型定义为Option时,你可能得到一些值或没有值。为了使其更清楚,让我们回顾一下我们的safeToInt函数:

def safeToInt(canBeNumber: String): Option[Int] = { 
  try { 
    Some(canBeNumber.toInt) 
  } catch { 
    case NonFatal(e) => None 
  } 
} 

safeToInt("10") match { 
  case None => println("Got nothing") 
  case Some(value) =>  println(s"Got $value") 
} 

在这里,在我们的safeToInt函数的定义中,我们定义了我们的响应类型为Option,仅仅因为我们知道它可能或可能不会响应一个有意义的值。现在使用Option而不是直接使用任何类型的理由是清晰的,让我们讨论Option类型。

选项类型

选项是 Scala 提供的一种类型构造器。问题随之而来,什么是类型构造器?答案是简单的;它允许你构造一个类型。我们将考虑两个陈述:

  1. Option是一种类型构造器

  2. Option[Int]是一种类型

让我们详细讨论这些。当我说Foo是一个类型构造器时,我的意思是Foo期望你以参数的形式提供一个特定的类型。它看起来像Foo[T],其中T是一个实际类型。我们称它们为类型参数,我们将在接下来的几节中讨论它们。

在第二个陈述中,我们看到了我们给我们的Option类型构造器括号中一个Int类型,并形成了一个类型。如果你在 Scala REPL 中尝试这样做,它会告诉你我们讨论的完全相同的事情:

scala> val a: Option = Some(1) 
<console>:11: error: class Option takes type parameters 
       val a: Option = Some(1) 

scala> val a: Option[Int] = Some(1) 
a: Option[Int] = Some(1) 

简单来说,Option[T]类型表示任何给定类型T的可选值。现在T可以是您传递的任何类型,在上一个例子中它是IntOption[T]类型有两个子类型:

  • Some(T)

  • None

当有值可用时,我们会得到Some值,否则得到NoneOption类型还为你提供了一个map方法。你想要使用选项值的方式是调用map方法:

scala> a map println 
1 

在这里发生的情况是,map方法会给你相应的值,如果它可用的话。否则,如果可选值是None,它将不会做任何事情。通常,你可能会想将这种类型用作异常处理机制。我们如何做到这一点?我们已经看到了一个例子。回想一下我们的safeToInt方法,如果没有Option,它可能看起来像这样(也许):

def safeToInt(canBeNumber: String): Int = { 
  try { 
     canBeNumber.toInt 
  } catch { 
    case NonFatal(e) => throw Exception 
  } 
} 

但是,如果你看一下签名,声明告诉你函数将返回一个Int,但实际上函数可能会抛出一个Exception。这既不是预期的,也不正确。函数应该遵循其自己的声明。因此,我们可以使用我们的Option类型,作为救星,做我们声明的事情。Option是函数式编程为你提供的构造之一。

这些类型还有很多,它们为你提供了一些现成的构造。其中一些是类型,如EitherTry以及一些其他的。你可以参考 Scala API 文档(www.scala-lang.org/api/2.12.3/scala/util/Either.html)来获取更多关于这些类型的信息。

接下来,我们将讨论另一个功能构造。它不仅仅是一个构造,它是一种评估方案。是的,我们正在谈论懒加载。Scala 允许你以多种方式使用这种方案。让我们谈谈lazy关键字。

懒声明

在学习更多关于lazy关键字或懒加载之前,让我们先谈谈为什么我们需要它以及它究竟是什么。懒加载的好处可以用几行或几页文字来解释,但为了我们的理解,让我们用一个简单的句子来说明。

懒加载让你能够以评估顺序无关的方式编写代码。它还通过只评估所需的表达式来为你节省一些时间。它就像代码中存在许多复杂的评估,但由于某种原因从未被评估。最后一行之所以可能,是因为懒加载的概念。在 Scala 中,你可以声明一个值为lazy。让我们举一个例子。在 Scala REPL 中尝试以下操作:

scala> lazy val v = 1 
v: Int = <lazy> 

scala> val z = 1 
z: Int = 1 

在这里,当我们将值1赋给我们的val v时,REPL 给了我们Int类型和值<lazy>,而对于val z,我们得到了1。为什么会发生这种情况是因为lazy声明。在 Scala 中,当你声明一个值为懒时,编译器只有在第一次使用它时才会评估这个值。有了这个,你就无需担心将val声明放在任何顺序。每个lazy值在其需要时才会被评估。

当我们在讨论优化我们的代码时,让我们看看另一个概念,尾递归优化。我们首次介绍尾递归优化是在第三章,“塑造我们的 Scala 程序”中,讨论递归时。让我们简要地谈谈它。

尾递归优化

我们熟悉递归带来的局限性。我们知道,如果函数调用不是尾递归,每次函数调用都会创建一个新的栈帧。对于必须处理大量函数调用的场景,这可能会导致栈溢出,这是我们不希望的。因此,在这种情况下建议的是,将递归函数调用作为你函数定义中的最后一个语句,然后 Scala 编译器会为你完成剩下的工作。看看下面的例子:

import scala.annotation.tailrec

object TailRecursion { 
  def main(args: Array[String]): Unit = { 
      val list = List("Alex", "Bob", "Chris", "David", "Raven", "Stuart") 
    someRecursiveMethod(list) 

  } 

  /* 
      You have a sorted list of names of employees, within a company. 
      print all names until the name "Raven" comes 
  */ 
  @tailrec 
  def someRecursiveMethod(list: List[String]): Unit = { 
      list match { 
        case Nil => println("Can't continue. Either printed all names or encountered Raven") 
        case head :: tail => if(head != "Raven") { 
          println(s"Name: $head") 
          someRecursiveMethod(tail) 
        } else someRecursiveMethod(Nil) 
      }
   }
 }

结果如下:

Name: Alex 
Name: Bob 
Name: Chris 
Name: David 
Can't continue. Either printed all names or encountered Raven 

在前面的例子中,如果你仔细观察,你会发现我们每次进行递归调用时,它都是该特定作用域中的最后一个语句。这意味着我们对someRecursiveMethod的调用是最后一个调用,之后没有其他调用。如果不是这样,Scala 编译器会通过一条消息提醒你,说递归调用不在尾递归位置:

@tailrec 
def someRecursiveMethod(list: List[String]): Unit = { 
    list match { 
      case Nil => println(s"Can't continue. Either printed all names or encountered Raven") 
      case head :: tail => if(head != "Raven") { 
        println(s"Name: $head") 
        someRecursiveMethod(tail) 
        println("Won't happen") 
      } else someRecursiveMethod(Nil) 

    } 
} 
Error:(21, 30) could not optimize @tailrec annotated method someRecursiveMethod: it contains a recursive call not in tail position 
someRecursiveMethod(tail) 

另外,需要注意的一点是,我们通过提供tailrec注解来帮助 Scala 编译器。当我们提供这个注解时,编译器会将你的函数视为尾递归函数。这种函数的评估不会在每个调用时创建新的栈帧,而是使用已经创建的栈帧。这就是我们避免栈溢出并使用递归的方式。在我们的例子中,我们尝试匹配一个名字列表。是的,你已经熟悉模式匹配的概念。如果不是Nil,我们会在列表中检查名字Raven,然后停止进一步的调用。如果你在 Scala 中缺少breakcontinue语句,这是你可以实现它们的方式:通过递归和检查条件。

所以这就是关于尾递归优化的一切。我们还看到,当提供注解时,Scala 编译器会帮助我们。嗯,递归可以帮助你避免可变性,同时实现复杂的逻辑,这增加了已经强大的函数式编程。由于我们正在学习函数式编程结构,了解它们根植于永恒的数学是非常重要的。数学创造了函数式编程的概念,几乎所有函数式编程的概念都来自某些数学证明或概念。其中之一是组合子。了解组合子或理解它们如何与数学相关超出了本书的范围,但我们将简要介绍并查看一个简单的例子。这将会很有趣。让我们通过组合子来了解。

组合子

维基百科关于组合子的描述如下:

“组合子是一个高阶函数,它只使用函数应用和先前定义的组合子来从其参数定义一个结果。”

除了这个定义,我们还可以说,组合子是一个封闭的 lambda 表达式。我们已经在前几个地方看到了 lambda 的应用,并且对它们进行了定义。lambda 仅仅是对任何函数的匿名定义。例如,当你将一个表达式传递给我们的foreach方法时,你以 lambda 表达式的形式传递它。看看下面的例子:

val brands = List(Brand("Toyota", List(Car("Corolla", "Toyota"))), 
                  Brand("Honda", List(Car("Accord", "Honda"))), 
                  Brand("Tesla", List(Car("Model S", "Tesla"), 
                                      Car("Model 3", "Tesla"), 
                                      Car("Model X", "Tesla"), 
                                      Car("New Model", "Tesla")))) 

brands.foreach((b: Brand) => { 
  //Take the brand name, and check the number of Cars and print them.
val brandName = b.name 
  println(s"Brand: $brandName || Total Cars:${b.cars.length}") 
  (brandName, b.cars) 
}) 

在这里,foreach 方法接受一个 lambda 表达式并执行它。更准确地说,括号内的 (b: Brand) 是 lambda 的一个例子。现在,让我们提出一些问题。lambda 与组合子有什么关系?或者让我们问,工作(函数式)程序员对组合子的定义是什么?好吧,为了回答这些问题,我们将使用第一个维基百科的定义。如果你仔细观察,有几个需要注意的地方。首先,它是一个高阶函数,其次,它是一个封闭的 lambda。封闭意味着它不包含任何 自由 变量。对于那些想知道什么是自由变量的你,请看以下 lambda:

((x) => x * y) 

在这里,y 是一个自由变量。我们之前已经见过这类没有自由变量的高阶函数:我们的 mapfilter 函数。这些被称为组合子。在函数式编程中,我们倾向于大量使用这些。你也可以将这些用作对现有数据的转换。如果我们将这些组合子组合起来,它们对于形成数据流逻辑非常有帮助。这意味着,如果你将 mapfilterfold 作为组合子一起使用,你可以从你的程序中创建出领域逻辑。这种编写程序的方式在函数式编程中经常被使用。当你查看编写好的库时,你会找到更多这样的例子。各种组合子被用于各种集合和其他数据结构。使用这些组合子的原因在于它们提供了一种抽象的,或者说,一种通用的方法来执行某些操作。因此,它们被广泛应用于各个领域。实现一些类似以下逻辑的代码既容易又有趣:

creditCards.filter(_.limit < 55000)
               .map(cc => cc.accounts(cc.holder))            .filter(_.isLinkedAccount) 
  .get 
  .info 

这确实是一个强大的构造,在函数式编程中被广泛使用。

现在你已经了解了组合子,并且也意识到了高阶函数,你就有能力解决编程问题。那么接下来呢?现在是时候迈出下一步了。我们将深入 Scala 编程的抽象(海洋)层面。如果我们能使我们的解决方案抽象化,这意味着我们提供的解决方案应该满足不止一个问题陈述。让我们从学习关于 type parameterization 开始。

类型参数化

对于类型参数化的介绍,我们将参考我们已经看到的两个例子,试图弄清楚它的意义。我知道你很感兴趣地跟随着这一章,你已经阅读了我们讨论的例子和概念,所以让我们做一个练习。想想我们的救星Option[T]类型,并尝试思考你为什么想向Option传递一个类型(因为它要求T是一个类型)。它可以有什么用途?

我认为你提出了一些想法。也许你认为通过传递我们选择的一种类型,我们可以让我们的代码使用Option类型在多个场景下工作。如果你这样想,太好了!让我们称它为我们的解决方案的泛化。而且,让我们称这种方法为编程的泛型方法。它看起来怎么样?让我们看看下面的代码:

object TypeParameterization { 

  def main(args: Array[String]): Unit = { 
      val mayBeAnInteger = Some("101") 
      val mayBeADouble = Some("101.0") 
      val mayBeTrue = Some("true") 

    println(s"Calling mapToInt: ${mapToInt(mayBeAnInteger, (x: String) => x.toInt)}") 
    println(s"Calling mapToDouble: ${mapToDouble(mayBeADouble, (x: String) => x.toDouble)}") 
    println(s"Calling mapToBoolean: ${mapToBoolean(mayBeTrue, (x: String) => x.toBoolean)}") 
  } 
  def mapToInt(mayBeInt: Option[String], function: String => Int) = function(mayBeInt.get) 

  def mapToDouble(mayBeDouble: Option[String], function: String => Double) = function(mayBeDouble.get) 

  def mapToBoolean(mayBeBoolean: Option[String], function: String => Boolean) = function(mayBeBoolean.get) 
} 

结果如下:

Calling mapToInt: 101 
Calling mapToDouble: 101.0 
Calling mapToBoolean: true 

所以正如代码所暗示的,我们有一些可选的字符串,可以是IntStringBoolean类型. 我们的意图是将它们转换为它们各自类型。因此,我们形成了一些函数,它们接受可选的字符串,然后我们将它们转换为它们各自类型,所以为此我们传递了一个函数字面量。如果我们不能想到几个反例,那么这意味着它正在工作。然而,这个解决方案很庞大;它感觉不太好。此外,我们可以看到代码中存在一些重复性。我们在几乎相同的操作中使用了mapToXXX,其中我们将XXX视为任何类型。看起来我们可以泛化这个解决方案。让我们考虑一下,我们如何做到这一点?

我们如何告诉方法我们将提供的类型?一个解决方案是将类型作为参数传递给方法,然后在声明中使用它们。让我们尝试这个解决方案,看看代码会是什么样子:

object TypeParameterization { 

  def main(args: Array[String]): Unit = { 
      val mayBeAnInteger = Some("101") 
      val mayBeADouble = Some("101.0") 
      val mayBeTrue = Some("true") 

    println(s"Calling mapToValue: ${mapToValue(mayBeAnInteger, x => x.toInt)}") 
    println(s"Calling mapToValue: ${mapToValue(mayBeADouble, x => x.toDouble)}") 
    println(s"Calling mapToValue: ${mapToValue(mayBeTrue, x => x.toBoolean)}") 
  } 

  def mapToValueT = function(mayBeValue.get) 
} 

结果如下:

Calling mapToValue: 101 
Calling mapToValue: 101.0 
Calling mapToValue: true 

泛化之后,我们只需一个函数就能执行相同的逻辑。所以,让我们看看变化以及我们如何给出类型参数:

def mapToValueT : T = function(mayBeValue.get) 

在前面的mapToValue函数中,在给出函数名,即mapToValue之后,我们在花括号中给出了T作为类型参数。有了这个,我们就得到了在函数声明和定义中使用这个类型参数的许可。因此,我们将其用作函数字面量中的类型以及返回类型。根据使用情况,我们可以给出任意数量的类型参数。例如,如果你想让它更通用,函数可能看起来像以下这样:

def mapToValueA, B : B = function(mayBeValue.get) 

在这个定义中,我们使用了两个类型参数AB,因此使我们的方法更加通用。如果你看看Option[T]中的map方法,它看起来如下:

def mapB: Option[B] = if (isEmpty) 
     None 
 else 
     Some(f(this.get)) 

在这里,我们在map函数的定义中使用了类型参数。根据这一点,对于Option[A],我们有一个从A类型到B类型的函数map方法。所以当你从这个map方法中给出调用时,编译器会根据上下文推断出AB类型。

这只是一个关于类型参数化的简介。在下一章中,我们将看到更多关于它的内容,以及一些高级概念。有了这些,我们可以结束本章,让我们总结一下我们学到了什么。

概述

本章让我们了解了如何以不同的实现风格实现逻辑。我们讨论了 for 表达式及其转换为高阶函数。我们看到模式匹配如何使复杂的逻辑看起来非常简单。我们还讨论了诸如Optionlazy关键字这样的结构。这些使我们能够编写有意义的和优化的代码。然后我们讨论了尾调用优化。我们面对了组合子,最后我们得到了关于类型参数化的介绍。

在下一章中,我们将从本章结束的地方开始。我们将更多地讨论类型、参数化类型和变体关系,让我告诉你,那将会很有趣*。

第十章:高级函数式编程

“就像双关语一样,编程是一种文字游戏。”

– 艾伦·佩里斯

你是开发者,对吧?想象一下,有人要求你编写一些具有某些实体的软件。看看以下内容:

Animal         |      Food 

我们有AnimalFood实体。我们正在开发一个自动化的系统,为动物园中的动物提供食物。假设我们要编写一个函数,允许两个动物共享它们的食物。它期望两个动物对象、食物,然后完成工作。这个函数看起来是这样的:

def serveSharedMeal(
    animalOne: Animal, 
    animalTwo: Animal, 
    meal: Food) = ??? 
//don't care about the implementation 

到目前为止,一切都很顺利,对吧?现在让我们引入两种动物。我们有两种动物子类名为LionDeer。我们的简单函数接受两个animal实例并在它们之间共享meal实例。现在,你可能会想知道当我们传递LionDeer的实例时会发生什么;可能会有什么后果?因此,我们提倡严格类型化的程序,这些程序可能在编译时失败。这意味着我们可以以这种方式编写我们的程序,以至于 Scala 编译器不会允许我们编写这样的程序。你能够看到类型如何救命。太好了,所以这就是这里的议程。

我们将讨论类型以及参数化我们的类型。我们在上一章中介绍了类型参数化,所以我们将继续我们的旅程,并学习更多关于类型参数化、抽象类型以及更多内容。让我们看看接下来会发生什么:

  • 泛型/类型参数化

  • 参数化类型

  • 可变性

  • 抽象类型

  • 界限

  • 抽象类型与参数化类型

  • 类型类

让我告诉你,这一章将会是另一个有趣的章节。我们将使用我们已经使用过的结构来使我们的代码更有意义。但在我们深入学习参数化类型之前,让我们谈谈为什么类型如此备受关注。

为什么对类型如此认真?

我们已经看到,了解我们在做什么可以救命。但开个玩笑,如果我们真的在编写应用程序之前思考,这真的有帮助。我们的程序由两个成分组成:

Data     |            Operations 

我们可以对可用的数据进行操作。同时,并不是所有操作都可以在所有类型的数据上执行。这就是类型差异所在。你不会想在整数和字符串字面量之间执行加法操作。这就是为什么编译器不允许我们这样做。即使它假设你试图将字符串与字面量连接起来,它也不会给出一个有意义的成果。这就是为什么定义类型是有意义的。

让我们讨论一下我们刚才提到的几个术语。Scala 是一个静态类型语言真是太好了,因为它为我们提供了编译时类型安全。我们编写的代码不太可能发生运行时错误,因为我们非常聪明,我们就是这样编写的(我们将在本章中学习这一点)。我们心爱的 Scala 编译器会对我们编写的程序进行编译时类型检查,并在编译时抛出错误,如果我们试图过于聪明。以下图表可能会消除你的疑虑:

图片

你看?前面的图片描绘了,如果你的程序正在运行,那还不够,最终你可能会发现一些边缘情况会导致它们失败。所以,我们最好选择一种可以帮助你更好地覆盖这一部分的编程语言,这样你的程序就能在一个快乐的世界中生存:

图片

这对我们理解为什么我们支持一个鼓励使用类型的系统至关重要。

我们开始理解类型在编程中的重要性,当我们接近编写优化和结构良好的代码时,我们发现了一种叫做类型泛型性的东西。我们开始编写可以在以后指定的类型。这导致了类型构造函数的概念的出现。现在,我们可以编写接受参数的类型。例如,考虑一个类型constructorList[T]. 在这里,我们已知的List[T]期望你提供一个类型。你也可以把它看作是一个类型上的函数,你在构造时提供参数。在我们的例子中,如果我们选择提供一个字符串,那么我们的列表将被称为List[String]这是List[T]String类型的应用版本。这个概念被称为参数多态,我们说我们的List[T]使用类型参数T来抽象其元素的类型。让我们在 Scala REPL 中尝试一下我们讨论的内容:

scala> val xs: List = List("ABC") 
<console>:11: error: type List takes type parameters 
       val xs: List = List("ABC") 
List instead of a concrete type:
scala> val xs: List[T] = List("ABC") 
<console>:11: error: not found: type T 
       val xs: List[T] = List("ABC") 

之前的代码片段也因为错误而失败了,因为我们知道我们应该提供一个类型参数,但我们提供了一些本身就不是类型的元素。那么我们现在应该怎么做?让我们尝试以下方法:

scala> val xs: List[String] = List("ABC") 
xs: List[String] = List(ABC) 

这次对我们来说成功了:我们向我们的类型构造函数提供了一个类型String,并且它对我们来说工作得很好。所以我相信我们可以区分类型构造函数和类型。既然你已经了解了我们为什么要这样做,那么让我们深入探讨一下参数化类型。

接下来是类型参数化

想象一下相同的场景。你被要求编写一个包含一些不同实体的程序,例如,人类和动物。现在,两者都需要食物来维持生命。我们的同事已经意识到了这一点,并编写了处理人类和动物食物供应方式的代码。

他们已经编写了代码,并将其作为库提供(我们可以通过导入他们编写的包来访问这些函数)。我们的同事提前编写了一个看起来像以下这样的函数:

def servseMealA, B = ??? 

我们被告知这个函数会工作,我们只需要提供第一个参数是谁来提供食物,以及一个可选的餐点作为食物。其余的将由他们放置的逻辑来处理。我们尝试了几种方法,并按照以下方式编写了一些应用:

serveMeal(Human(Category("MALE")), None) 
serveMeal(Human(Category("FEMALE")), Some(Food())) 
serveMeal(Animal(), None) 
serveMeal(Animal(), Some("NONVEG")) 

case class Human(category: Category) 
case class Category(name: String) 
case class Animal() 
case class Food() 

并且,它以预期的方式工作。但是等等,这真的很酷,我们只接触到了一个函数,而且一切似乎都按预期工作。他们不希望我们特别调用serveMealToHumansserveNonVegMealToAnimals函数。

这真的帮助我们编写了更好的代码。我们都是聪明的团队,我们分离了我们的关注点。你知道,他们的关注点是照顾所有人提供食物的方式,我们的关注点是确保我们的每个实体都能得到它们应该得到的食物。工作完成了。让我们谈谈为什么我们选择了这个例子。为了理解这一点,看看他们写的函数签名:

def serveMealA, B = ??? 

我们看到def关键字,然后是函数名,但之后他们写了字母AB这是什么?为了了解更多,让我们继续阅读整个签名。有一个参数是我们应该提供的,类型被命名为A,食物选项被赋予了一个名为B的类型(*option B`)。嗯,这些都是类型参数。这个函数本身是参数多态的一个例子。我们知道这意味着有多种形式。这里也是一样。我们的函数可以有多个参数类型。为了更好地理解,看看这个:

serveMeal(Human(Category("MALE")): Human, None: Option[Food]) 
serveMeal(Human(Category("FEMALE")): Human, Some(Food()): Option[Food]) 
serveMeal(Animal() : Animal, None: Option[Food]) 
serveMeal(Animal(): Animal, Some(Food()): Option[Food]) 

在函数调用中,我们指定了参数的类型。从函数的签名中可以清楚地看出,我们可以给出我们想要的任何类型,而serveMeal函数会处理其余的逻辑。所以,我们可以得出结论,这些AB参数被称为类型参数。整个概念可以称为类型参数化。我们不仅可以编写泛型方法,还可以编写泛型类和特性. 让我们来了解一下。

另一种方法 - 泛型类和特性

我们刚刚看到了泛型的效果,它解决了不止一个问题,我们写了更少的代码,实现了更多。serveMeal函数是一个泛型函数,因为它接受类型参数,在我们的例子中是AB它执行了预期的逻辑,太棒了!让我们谈谈参数化类型。你知道List*这个类型吗?让我们看看它在 Scala 标准库中的声明:

sealed abstract class List[+A] extends AbstractSeq[A] 
  with LinearSeq[A] 
  with Product 
  with GenericTraversableTemplate[A, List] 
  with LinearSeqOptimized[A, List[A]] 
  with Serializable 

好吧,声明看起来过于复杂,不是吗?不,等等,我们知道sealed的意思,我们知道为什么我们使用抽象类,然后是List这个名字,然后是一些显示继承关系的声明。但是在我们的声明中有一个叫做[+A]的东西。我们的任务是找出这是什么,以及为什么我们使用了它。

从前面的几个主题中,我们得到了对这个概念类型构造函数的想法。所以,让我们把List[+A]称为类型构造函数。我们知道如果我们提供一个具体的类型,List 将创建一个有意义的类型。我们之前已经尝试过,所以不会创建另一个字符串列表。我们将在接下来的几个主题中学习这个加号的含义。它表示一个变异性关系。让我们先看看之前的声明。

类型参数名称

在这里,在 List[+T] 类型构造器的声明中(我们可以互换使用参数化类型或类型构造器的名称),我们使用了参数名称 T,在泛型编程中使用这样的名称是一种惯例。名称 T、* A B* 或 C 与你初始化列表实例时提供的初始化器类型无关。例如,当你为之前提到的类型参数提供 String 类型以实例化 List[String] 时,声明是 List[T] 还是 List[A] 实际上并不重要。我们的意思是以下两个声明是等价的:

//With type parameter name A 

sealed abstract class List[+A] extends AbstractSeq[A]  

//With type parameter name T 

sealed abstract class List[+T] extends AbstractSeq[T] 

容器类型

我们已经看到了 Scala 的类层次结构,因此我们了解了许多集合类型,例如 ListSetMap。这些类型以及如 OptionEither** 的类型的不同之处在于,它们都要求你提供一个类型然后实例化。我们将 List 称为容器类型,因为它就是这样工作的。我们使用列表来包含特定数据类型的元素。同样,我们可以将 Option 视为一个二进制容器化类型,因为 Option 可以是某个值或 None. Either 类型也是同样的道理。在 Scala 中,当我们创建这样的容器类型** 时,我们倾向于使用类型参数来声明并提供一个具体类型,例如在实例化时提供 String Int Boolean* 等等。看看 Option 在 Scala 中的声明(关于 OptionEither 类型的更多内容将在下一章中介绍):

sealed abstract class Option[+A] extends Product  
   with Serializable 

它接受一个类型参数 A. 如果你的类型期望提供多个类型以进行实例化,则可以提供多个类型参数。这样的类型的一个例子是 Either

sealed abstract class Either[+A, +B] extends Product with Serializable 

如前所述,我们的 Either 类型接受两种类型,AB. 但当我尝试以下代码片段时,它并没有按预期工作:

object TypeErasure extends App { 
  val strings: List[String] = List("First", "Second", "Third") 
  val noStringsAttached: List[Int] = List(1, 2, 3) 

  def listOfA = value match { 
    case listOfString: List[String] => println("Strings Attached!") 
    case listOfNumbers: List[Int] => println("No Strings Attached!") 
  } 

  listOf(strings) 
  listOf(noStringsAttached) 
} 

结果如下:

Strings Attached! 
Strings Attached! 

你看?我们创建了两个列表,一个是字符串列表,另一个是数字列表。然后,一个名为 listOf 的泛型函数,它简单地接受一个列表并告诉列表的类型。我们执行了模式匹配来检查传递给函数的列表类型并打印它。但它并没有按预期工作(可能对任何人都不行)。此外,它还抛出了一些警告,告诉我们第二个情况表达式的代码不可达。让我们来谈谈原因!

类型擦除

当 Scala 编译器编译前面的代码时,它会从之前的代码中擦除参数化类型信息,因此在运行时没有必要的知识;我们传递的列表不会携带任何关于自身的进一步信息。换句话说,在代码编译完成时,所有泛型类型的类型信息都被丢弃。这种现象被称为 类型擦除.* 这也是我们的 listOf 函数没有按预期或假设的方式工作或导致收到不可达代码警告的原因。这是因为我们的静态类型语言能够知道第二个情况永远不会被执行,第一个情况是这个模式匹配中的通配符。让我们更好地解释一下。看看一些类型擦除将适用的案例。想象你有一个名为 Tfoo 的特质:

trait Tfoo[T]{ 
  val member: T 
} 

在编译过程之后,泛型类型被转换为对象,并变成如下所示:

trait Tfoo { 
  val member: Object   //Cause scala files gets converted to *.class files. 
} 
type parameter, it's not alien code to us. But, then you ask, what's with the + sign in List[+T]? Yes, this shows variance under an inheritance relationship and this + is called variance annotation. Let's go through it.

继承下的协变

了解一个概念的一种方式是提出引导你到该概念的问题。所以,让我们自己提出一个问题。鉴于 Cat 类类型扩展了 Animal 类,将一群猫视为一群动物是否合适?从程序的角度来看,请看以下内容:

abstract class Animal() 

class Cat(name: String) extends Animal()               // Inheritance relationship between Cat and Animal 

def doSomethingForAnimals(animals: List[Animal]) = ??? //definitely do something for animals. 

Is it possible to pass an argument that's a list of Cats? 
val cats = List(new Cat("Mischief"), new Cat("Birdie")) 
doSomethingForAnimals(cats) 

如果可能的话,List[Cat]List[Animal] 的子类型这一说法是有意义的。这个概念被称为 协变。因此,我们说 List 在其类型参数 T 上是协变的:

图片

如果你看看前面的图像,两个具体类(即 *Cat*Animal)及其参数化版本(即 List[Cat]List[Animal] 类型)之间的继承关系方向是相同的。但并非总是如此。可能会有一些情况,其中容器类型的协变关系是相反的。

这样想:给定两个类型 AB,其中 AB 的超类型,以及某个容器类型 Foo[T],那么 Foo[A]Foo[B] 的子类型的关系称为 T 上的逆协变,表示为 Foo[-T]。在这里,- 符号代表逆协变。

如果你认为这太理论化了,一些代码示例可能会使这个概念更清晰。让我们看看一个场景。如今,企业喜欢为员工提供各种各样的安排,从食物和保险到他们的旅行需求。如果某个公司决定与航空公司合作,为员工提供企业预订,这并不是什么大问题。为了支持这样的预订,航空公司可以支持为商务旅客、行政旅客和普通旅客预订座位的方案。所以,想象在我们的程序中预订飞机座位,我们代表每个座位为一个飞机座位。从程序的角度来看,我们可以将其表示为一个类:

class AircraftSeat[-T] 

现在,我们有几个由 Passenger 类表示的乘客。有几个 Passenger 的子类型,如 CorporatePassengerExecutivePassenger* 和 RegularPassenger

它们之间的关系如下:

如上图所示,CorporatePassengersRegularPassengers 继承自 Passengers 类,因此这些类型之间存在继承关系。这可以表示如下:

abstract class Passengers 
class CorporatePassengers extends Passengers 
class RegularPassengers extends Passengers 

现在,如果你有一个为公司员工预订座位的函数,其签名可能看起来像以下这样:

def reserveSeatForCorporatePassengers(corporateSeats: AircraftSeat[CorporatePassengers]) = ??? //Seat booking logic! 

之前的函数期望你提供一个 AircraftSeat[CorporatePassengers],并完成其任务。如果我们尝试在一个 Scala 应用程序中写出整个函数,它看起来会像以下这样:

object ContraVariance extends App { 

  class AircraftSeat[-T] 

  def reserveSeatForCorporatePassengers(corporateSeats: AircraftSeat[CorporatePassengers]) = { 
    //Performs some logic regarding the seat reservation! 
    println(s" Seats Confirmed!") 
  } 

  abstract class Passengers 
  class CorporatePassengers extends Passengers 
  class RegularPassengers extends Passengers 

  reserveSeatForCorporatePassengers(new AircraftSeat[CorporatePassengers]) 

  reserveSeatForCorporatePassengers(new AircraftSeat[Passengers]) 

} 

结果如下:

Seats Confirmed! 
Seats Confirmed! 

现在,花一点时间,回顾一下前面的代码。我们将讨论几个要点,并尝试玩转注解和继承:

  • 在我们的情况下,AircraftSeat[-T] 是一个使用逆变注解的容器类型,即其类型参数前有一个 - 符号。

  • reserveSeatForCorporatePassengers(corporateSeats: AircraftSeat[CorporatePassengers]) 函数接受 AircraftSeat 类型的 CorporatePassengers 或其超类型,如 Passengers,这是因为其类型参数中的逆变关系。

  • 由于参数化类型 AircraftSeat 中的逆变,reserveSeatForCorporatePassengers(new AircraftSeat[CorporatePassengers])reserveSeatForCorporatePassengers(new AircraftSeat[Passengers]) 的函数调用是有效的。

  • 在前面的代码中,尝试将类型构造器 AircraftSeat[-T] 改为 AircraftSeat[+T]。你会遇到一个编译错误,说类型不匹配,因为参数化类型在 T 中变成了协变,因此 Passengers 超类型不再适用于 Aircraft[CorporatePassengers]**。

  • 同样地,如果我们尝试用 RegularPassengers 调用 reserveSeatForCorporatePassengers 函数,它将不会工作并抛出一个关于类型不匹配的编译错误。原因是相同的:我们的参数化类型在 T 中是逆变。

之前的例子和实验澄清了协变和逆变是什么,以及它们之间的区别。请看以下图像:

前面的图像解释了具体类型 PassengersCorporatePassengers 和参数化类型 AircraftSeat[Passengers]AircraftSeat[CorporatePassengers] 之间的继承关系。你可能已经注意到,逆变的方向与继承方向相反。

通过这一点,我们已经理解了参数化类型内部两种类型的方差关系。首先是协方差,然后是反协方差。现在,还有一种可能的方差关系,称为T中的不变关系。我们不需要任何符号来表示这种不变关系。我们只需使用容器类型和类型参数名称,例如Foo[T],在T中的类型是不变的。因此,如果你想消费Foo[T]类型的实例,你必须提供一个Foo[T]实例,任何 T 的超类型或子类型都不会起作用。

方差是函数式编程中一个非常重要的概念。因此,你可能会在 Scala 中找到许多关于它的例子。我们已经看到了一些例子,例如List[+T]option[+T]*,它们在 T 中是协变的。

另一个这样的方差关系的流行例子是Function特质:

trait Function1[-T1, +R] extends AnyRef {self => 
  /** Apply the body of this function to the argument. 
    * 
    * @return the result of function application. 
    */ 
  def apply(v1: T1): R 

} 

在这里,我们声明如下:

scala> val func = (i: Int) => i.toString 
func: Int => String 

在这里,Scala 将其转换为这些Function特质的实例,这变成了以下:

new Function1[Int, String]{ 
  override def apply(v1: Int): String = v1.toString 
} 

在这里,这个特质,如签名所示,消费类型T并产生R。我们在TR前面放置了方差注释,其中Function1[-T]中是反协变的,可消费类型,在[+R]中是协变的,可生产类型。我希望这澄清了方差关系。让我们看看何时使用协方差和反协方差。

何时使用哪种类型的方差关系

很明显,方差对于告诉编译器何时可以将一个参数化类型的实例绑定到具有不同类型参数的相同参数化类型的引用非常有用。就像我们在List[Animal]List[Cat]*中所做的那样。但是,问题出现了,是使用协方差还是反协方差。

在最后一个例子中,我们看到了 Scala 标准库中的Function1特质。输入/可消费类型是反协变的,输出/可生产类型是协变的。这让我们有一种感觉,当我们即将消费某种类型时,使用反协方差是可以的。(为什么?我们很快就会了解到。)当我们即将产生一些结果时,我们应该选择协方差。这里的想法是,作为一个消费者,你可以消费各种类型,换句话说,你被允许消费更一般的(反协变的)东西,同时作为一个生产者,你被允许生产更具体(协变的)东西。你明白这个意思,对吧?

所以你记得,我们谈论了由两个实体组成的程序:数据操作。我们也说过,并非所有操作都可以在所有数据上执行。因此,我们有了类型的概念。现在,由于类型参数化,我们的生活变得更加有趣,但我们在声明参数化类型时应该小心,因为对于 Foo[T] 下的所有类型,可能有一些操作是没有定义的。为此,我们有一个叫做 类型边界 的东西。使用类型边界,我们可以指定我们想要对哪些类型执行操作。嗯,我们也会谈到 边界,但在那之前,我想谈谈另一种在我们的程序中实现抽象的方法,那就是通过 抽象类型。让我们来探讨并尝试理解当人们开始使用 Scala 时觉得困难的一个概念(你不会,我们会让它变得清晰!)。

抽象类型

好的,首先,当我们引入 类型参数化 时,我们试图实现 抽象。我们将使用 抽象类型成员 来做同样的事情。但什么是 抽象类型成员?我们如何编写它们,我们如何使用它们,以及为什么我们甚至需要它们,因为我们已经有了 参数化类型?这些问题有几个。我们将尝试回答它们。所以让我们从第一个问题开始。我们如何编写一个抽象类型。这是按照以下方式完成的:

trait ThinkingInTermsOfT { 
      type T 
} 

好的,我们刚刚写了一个名为 ThinkingInTermsOfT 的特质,并且它有一个抽象类型成员。因此,要声明一个抽象类型成员,我们使用关键字 type 以及参数名称,在我们的例子中是 T。从我们的基础 Scala 介绍,或者说,从之前的章节中,我们知道如何实例化一个特质。所以当我们实例化我们的特质时,我们会给我们的抽象成员赋予一个类型。这将是一个具体类型:

val instance = new ThinkingInTermsOfT { 
  type T = Int 

  def behaviourX(t: T): T = ??? 
} 

在这里,我们使用 new 关键字实例化了特质,然后在定义特质实现时,我们将 Int 类型赋予我们的抽象类型成员 T。这允许我们在声明侧的特质中不关心类型的具体是什么就使用 T 类型。当我们实例化时,我们分配一个具体类型,就像我们在这里做的那样:

type T = Int 

你现在对如何执行这些类型的操作以及为什么我们使用这种 类型 T 声明有了一些了解:这样我们就可以编写我们的行为方法,如 doX 并返回 T,或者使用类型 TdoY(t: T) 并返回一些东西。抽象成员给我们提供了编写代码时的灵活性,无需担心类型。我们的函数/方法可以与我们在实例化特质/类时定义的任何类型一起工作。

让我们举一个例子来比较我们使用类型成员实现了什么以及如何实现的:

object AbstractTypes extends App { 

  trait ColumnParameterized[T] { 
       def column() : T 
  } 

  trait ColumnAbstract { 
    type T 

    def column(): T 
  } 

  val aColumnFromParameterized = new ColumnParameterized[String] { 
    override val column = "CITY" 
  } 

  val aColumnFromAbstract = new ColumnAbstract { 
    type T = String 

    override val column = "HOUSE_NO" 
  } 

  println(s"Coloumn from Parameterized: ${aColumnFromParameterized.column}   |  and Column from Abstract: ${aColumnFromAbstract.column} ") 

} 

结果如下:

Column from Parameterized: CITY   |  and Column from Abstract: HOUSE_NO 

这个例子很容易理解。我们既有参数化版本,也有当前版本,一个具有抽象类型的特质。所以看看参数化版本。我们指定了我们的traitColumnParameterized[T]是一个带有参数T的参数化类型。我们对这种语法很舒服,对吧?我们刚刚已经看过了,它简单易懂。现在,后者的声明,特质ColumnAbstract有一个类型成员,我们使用type关键字声明的。

现在看看实现。对于参数化类型,我们知道我们必须做什么,并实例化了特质(你知道在实例化时这些花括号发生了什么,对吧?)。同样,我们实例化了具有抽象成员的特质,并使用val** 覆盖了定义,这是可能的,你知道这一点。

正因为如此,我们才能调用这两个并从中获取它们的值。现在,如果你尝试分配以下内容:

type T = String 

这里,我们使用了以下内容:

override val column = 23 

这里,我们遇到了错误,一个不兼容类型的错误。我们知道原因:因为整数无法满足签名,签名期望的类型是T,而TString. 所以,你正在尝试做错事;编译器不会让你通过。我建议你尝试这些参数化类型和抽象类型的两个概念。你用得越多,写得越多,就会越舒服。记住,这些抽象成员期望你提供类型,在 Scala 中,函数也是一种类型。所以,你可以通过动态生成函数来深入思考,使用特质。

让我们花点时间思考一下。假设你想形成一个执行动作/操作的机制。这样一个动作生成器的代码可能看起来像这样:

trait ActionGenerator[In] { 
  type Out 

  def generateAction(): Out 
} 

现在,这看起来很酷:我们使用了一个参数化类型和一个抽象类型成员。此外,我们的类型Out成员可以是一个函数类型。把它想象成一个规则或模式。它可以如下所示:

type Out = Int => String 

为了获得更多见解,让我们考虑一个场景,你想要提供一个机制,从你在博客文章上收到的评论列表中生成 1 到 5 的评分。现在,不同的人有不同的欣赏(或不欣赏)方式,所以有些人足够好,给你评了 4 或 5 分。有些人突然写道,“太棒了”,“很好”,或者“史上最差”。你必须从所有这些评论中为你的博客生成评分。

现在,从场景来看,编写一个规则可能看起来很酷,这个规则基于注释可以生成评分。仔细看看:生成评分是一个动作/操作。我们将抽象化这个,并编写生成评分生成器,我们指的是ActionGenerator。它可能看起来如下:

object RatingApp extends App { 

  type Rating = Int 
  type NumericString = String //String that can be converted into Int! 
  type AlphaNumeric = String  //Alphanumeric String 

  val simpleRatingGenerator = new ActionGenerator[NumericString] { 
    type Out = NumericString => Rating 

    /* Times when ratings are simple NumericStrings 
     * Rating as 1, 2, 3, 4, 5 
     * We don't care about numbers more than 5 
     */ 
    override def generateAction(): NumericString => Rating = _.toInt 
  } 

  val generateNumericRating = simpleRatingGenerator.generateAction() 

  println(generateNumericRating("1")) 
} 

结果如下:

1 

这里,simpleRatingGeneratorActionGenerator. 从实现中我们可以得到以下几点启示:

  • 语法,如type Rating = Int,只是为了使代码更易读。它使读者可以将Rating视为一个内部接受整数的类型。这仅仅是一个类型声明。

  • 我们的simpleRatingGenerator从其定义中指定它可以接受一个NumericString并给出一个类型为NumericString => Rating的函数. 我们可以将其视为如果simpleRatingGenerator是一个ActionGenerator,它提供了一个从NumericString生成评分的机制.

  • 现在,我们可以使用这种动作生成器的方式是获取机制并将一个数值字符串值传递给它以获取评分。这就是我们这样做的方式:

val generateNumericRating = simpleRatingGenerator.genrateAction()
println(generateNumericRating("1"))

我们还可以创建另一个评分生成器,它接受诸如Awesome Good Nice、等等这样的评论. 下面是一个创建AlphanumericRatingGenerator的例子,它可以提供一个从AlphanumericString生成评分的机制:

val alphanumericRatingGenerator = new ActionGenerator[AlphaNumeric] { 
  type Out = AlphaNumeric => Rating 

 /* Times when ratings are Awesome, Super, Good, something else like Neutral 
  * Rating as 1, 2, 3, 4, 5 
  */ 
  override def generateAction(): AlphaNumeric => Rating = toRating// Some other mechanism to generate the rating 
} 

val toRating: AlphaNumeric => Rating = _ match { 
  case "Awesome" => 5 
  case "Cool"    => 4 
  case "Nice"    => 3 
  case "Worst Ever" => 1 
  case _ => 3 // No Comments then average rating. 
} 

我们使用它的方式与使用simpleRatingGenerator的方式相同:


val generateAlphanumericRating = alphanumericRatingGenerator.generateAction() 

println(generateAlphanumericRating("Awesome")) 

结果如下:

5 

所以,当我们尝试为我们的功能提供简单的接口时,这些方法可能会很有用。例如,如果一个人想根据评论查看评分,他可能对复杂性不感兴趣,只想调用特定的函数。

为什么你现在想尝试使用这些概念,比如类型参数和抽象类型,现在更有意义:为了创建抽象,比如ActionGenerator. 当你有一套已经定义好的规则时,生活会更简单,你只需要编码它们。所以,让我们继续尝试制定更精确的规则。换句话说,让我们看看我们如何定义参数化类型的限制。

类型界限

我们看到一个例子,我们被允许为乘客创建AircraftSeat。例子看起来如下:

class AircraftSeat[-T] 

从我们目前所知,Aircraft在其类型参数T上是协变的。但是,当涉及到创建AircraftSeat实例时,它可以创建任何类型的T。预期的是,这个类型参数只能为Passengers类型或其子类型。因此,为了实现这一点,我们可以引入类型界限,在我们的情况下,我们将使用上界。这样做的原因是我们想指定继承层次结构顶部的类型,在我们的例子中是Passengers

它看起来如下:

  class AircraftSeat[-T <: Passengers] 

这里,符号*<:*指定了它的上限。这会做什么?让我们通过一个例子来更好地理解它:

object Bounds extends App { 

   /* 
    * AircraftSeats can be consumed only by Passengers. 
    */ 
  class AircraftSeat[-T <: Passengers] 

  def reserveSeatForCorporatePassengers(corporateSeats: AircraftSeat[CorporatePassengers]) = { 
    //Performs some logic regarding the seat reservation! 
    println(s"Seats Confirmed!") 
  } 

  val corporateSeat = new AircraftSeat[CorporatePassengers]() 
  val passengersSeat = new AircraftSeat[Passengers]() 

  reserveSeatForCorporatePassengers(new AircraftSeat[CorporatePassengers]()) 

  reserveSeatForCorporatePassengers(new AircraftSeat[Passengers]()) 

  abstract class Passengers 
  class CorporatePassengers extends Passengers 
  class RegularPassengers extends Passengers 

} 

这里,我们有之前用过的相同例子;唯一不同的是现在我们只能为Passengers类型创建AircraftSeat。代码将正常工作。但我们想看看当我们尝试用不是Passengers子类型的类型创建AircraftSeat实例时的行为。为此,让我们创建另一个类并尝试从中创建一个AircraftSeat实例:

class Person(name: String) 

val seat: AircraftSeat[Person] = new AircraftSeat[Person]() 

如果你尝试使用此实例编译代码,Scala 编译器将在编译时抛出一个错误。它显示以下内容:

type arguments [chapter10.Bounds.Person] do not conform to class AircraftSeat's type parameter bounds [-T <: chapter10.Bounds.Passengers] 

  val seat: AircraftSeat[Person] = new AircraftSeat[Person]() 

从之前显示的错误中,很明显,编译器能够理解我们所指定的内容,并且除了 Passengers 之外,不准备接受任何其他内容。

同样,我们也可以为我们的类型参数指定下边界。我们可以使用符号 >: 来指定下边界。它看起来如下:

class ListLikeStructure[T >: AnyRef] 

在这里,我们为任何引用类型指定了一个 ListLikeStructure。它在某种程度上是特殊的,因为它只接受 AnyAnyRef* 或等价类型在层次结构中. 因此,让我们尝试为 AnyAnyRef 创建相同的实例。Scala 编译器不会对代码提出任何异议,并且对于以下内容将正常工作:

new ListLikeStructure[Any]() 
new ListLikeStructure[AnyRef]() 

当我们尝试使用不同于 AnyAnyRef 的不同类型创建相同的实例时,Scala 编译器将给出以下错误:

new ListLikeStructure[String]() 
Error:(30, 7) type arguments [String] do not conform to class ListLikeStructure's type parameter bounds [T >: AnyRef] 

  new ListLikeStructure[String]() 

从这个错误中,很明显,指定了下边界的签名不会让你提供在类型层次结构中更低级别的类型。这就是为什么我们为 String 类型得到了错误。这些是我们可以在 Scala 中提供上下边界的途径。还有一种方法可以同时使用两者来指定层次结构中的特定类型范围。

想象一个类继承层次结构如下:

abstract class Zero 
trait One extends Zero 
trait Two extends One 
trait Three extends Two 
trait Four extends Three 

对于此类结构,可以以下述方式声明 ListLikeStructure

class ListLikeStructure[T >: Four <: Two] 

它指定你只能提供介于 TwoFour 之间的类型,因此可以创建以下类型的结构:

new ListLikeStructure[Four] 
new ListLikeStructure[Three] 
new ListLikeStructure[Two] 

但一旦你尝试传递不在边界内的类型,Scala 编译器将向你显示编译时错误:

new ListLikeStructure[One] 
type arguments [chapter10.Bounds.One] do not conform to class ListLikeStructure's type parameter bounds [T >: chapter10.Bounds.Four <: chapter10.Bounds.Two] 
  new ListLikeStructure[One] 

正如错误所指示的,这个实例化不满足边界条件。因此,你现在应该了解类型参数的边界。同样,我们也可以将边界应用于抽象类型:

trait ThinkingInTermsOfT { 
      type T <: Two 
} 

在这里,正如前一个声明所示,我们的类型 T 只能使用 Two 作为上边界的类型进行实例化。

关于边界的讨论和变异性,两者之间的差异和用例都很清楚。再次指出,变异性仅仅是参数化/容器类型之间继承关系的规则。边界仅写一条规则,说明可以使用一定范围内的类型来实例化一个参数化或包含抽象成员的类型。

现在我们已经了解了参数化类型、抽象类型以及定义它们的方法,我们也应该尝试找出为什么我们选择抽象类型而不是参数化类型,或者反之亦然。

抽象类型与参数化类型

这两种都是 Scala 中提供多态抽象的形式。大多数情况下,是否更喜欢其中一种是一个设计选择。谈到设计选择,让我们更仔细地看看。为此,我们将举一个有两个类层次结构的例子如下:

abstract class Food 
class Grass extends Food 
class Meat extends Food 

abstract class Animal { 
   type SuitableFood <: Food 

   def eatMeal(meal: SuitableFood) 
} 

从关于抽象类型和上界的知识中,我们可以说Animal是一个抽象类,它有一个名为SuitableFood的抽象类型成员,该成员只期望Food类型。如果我们声明Animal类的两个子类型,即CowLion,那么看起来一头牛可以吃GrassMeat,因为它们都是Food的子类。但这不是我们想要的行为。为了解决这个问题,我们可以这样声明Cow

class Cow extends Animal { 
  type SuitableFood <: Grass 

  override def eatMeal(meal: SuitableFood): Unit = println("Cow's eating grass!") 

} 

我们对抽象类型成员SuitableFood设置了一个限制. 现在,对于任何牛(实例),我们提供的类型必须是Grass类型(是的,我们之所以可以只使用Grass,是因为它是Food的子类型)。我们可以对Lion类做同样的处理:

class Lion extends Animal { 
  type SuitableFood <: Meat 

  override def eatMeal(meal: SuitableFood): Unit = println("Lion's eating meat!") 
} 

当我们尝试使用这些类时,我们必须提供预期的具体类型。让我们看看这个应用:

object AbsVsParamTypes extends App { 

  abstract class Animal { 
     type SuitableFood <: Food 

     def eatMeal(meal: SuitableFood) 
  } 

  class Lion extends Animal { 
    type SuitableFood <: Meat 

    override def eatMeal(meal: SuitableFood): Unit = println("Lion's eating meat!") 
  } 

  class Cow extends Animal { 
    type SuitableFood <: Grass 

    override def eatMeal(meal: SuitableFood): Unit = println("Cow's eating grass!") 
  } 

  val lion = new Lion(){ 
    type SuitableFood = Meat 
  } 

  val cow = new Cow(){ 
    type SuitableFood = Grass 
  } 

  cow.eatMeal(new Grass) 
  lion.eatMeal(new Meat) 

  abstract class Food 
  class Grass extends Food 
  class Meat extends Food 
} 

结果如下:

Cow's eating grass! 
Lion's eating meat! 

因此,如所示,我们可以通过提供一个满足指定边界的类型来创建一个牛的实例。这类要求最好使用抽象类型来编写。当你需要提供一个带有参数的类型,仅用于实例化时,例如List[String]或类似类型。当使用相同的类型来随着你编写/定义你的类/特质的成员时,考虑抽象类型会更好。

现在我们已经讨论了抽象类型与参数化类型的区别,让我们再谈谈一个你在使用 Scala 时经常会遇到的概念。我们将简要介绍类型类。

类型类

为什么有人需要像类型类这样的概念呢?为了回答这个问题,我们首先必须理解类型类究竟是什么。正如他们所说,“类型类允许我们对一组类型进行泛化,以便为这些类型定义和执行一组标准特性。”让我们试着理解这一点。

我相信你熟悉编码和解码的概念。让我们把编码想象成应用一定的规则将 A 转换为特定的模式。现在,在你编码了某物之后,它就在那个特定的模式中。解码是我们刚才所做事情的相反:它将你的类型 A 从我们刚才创建的模式中转换回其原始形状。例如,逗号分隔值CSV)可以被认为是一个编码模式。因此,有一个方案将单词从源转换为 CSV 格式:

trait CSVEncoder[T] { 
  def encode(t: T): List[String] 
} 

我们编写了一个名为CSVEncoder[T]的特质。现在是时候重新表述我们关于类型类的说法了。CSVEncoder允许我们对类型T进行泛化,以便为相同类型提供编码机制。这意味着我们可以为所有我们想要的类型使用CSVEncoder,并在需要时使用它们。这是类型类的一个实现。在讨论隐式之后,我们将在下一章中详细介绍整体类型类的实现。现在,了解类型类这样的概念是很好的。

在类型类的概念下,是时候总结一下我们在本章中学到的内容了。

概述

从理解类型的基本需求到理解什么是类型类,我们已经经历了一切。在这个过程中,我们讨论了使用参数类型和抽象类型的参数多态。随着可变性概念以及边界的引入,我们已经全部经历,现在它变得稍微清晰一些了。要获得更多见解,实践是必不可少的。我们可以设想一些场景来学习这些概念。我们试图按照它们本来的样子来理解这些概念,并查看一些例子,但如果你自己尝试其中的一些,那一定会很有趣。这一章是真实 Scala 编程的基础或形成。

在下一章中,我们将讨论诸如隐式等概念以及我们在 Scala 中如何进行异常处理。当然,我们还会玩转类型类。

第十一章:与隐式和异常一起工作

“多么讽刺,当你做生意时,你通过创造异常来创造新的机会,当你编写代码(做工作)时,你处理异常以使其变得干净。”

  • Pushkar Saraf

函数式程序是表达式。当我们说我们想要运行一个函数式程序时,我们的意思是我们要评估表达式。当我们评估一个表达式时,我们得到一个值。我们还知道,函数式编程是关于组合和评估表达式。这意味着你写下的函数签名对每次评估都成立。但有些情况下,这种情况不太可能发生。你的代码可能不会按预期工作,并可能导致异常行为。我们如何处理这些情况,如何在函数式编程中处理异常?这些问题是基本的,任何刚开始学习函数式编程的人可能会问同样的问题。所以,在本章中,我们将尝试回答这些问题,然后我们将继续前进,看看另一个在 Scala 中非常重要且广为人知的概念,称为 隐式。我们将看看它们是什么,以及我们可能想要使用它们的场景。所以,以下是我们在本章中将要讨论的内容:

  • 异常处理 - 旧方法

  • 使用选项方式

  • 要么是左边,要么是右边

  • 隐式 - 什么是以及为什么

  • 隐式类

  • 隐式参数

让我们从向特定功能引入异常行为并处理它开始。

异常处理 – 旧方法

让我们编写一些代码,以便我们可以讨论异常处理。看看下面的代码:

def toInt(str: String): Int = str.toInt 

在前面的代码中,toInt 是一个接受 String 值的函数,理论上它可以转换成相应的 Int 值。定义看起来没问题,但作为函数式程序员,我们习惯于尝试函数以查看它是否如定义中所说那样工作。让我们尝试调用这个函数:

println(toInt("121")) 
println(toInt("-199")) 

前面的代码给出了以下结果:

121 
-199 

对于我们来说,一切都很顺利。我们传递了一个字符串格式的数字,并得到了相应的整数值。但是,如果你尝试以下内容会怎样呢?

println(toInt("+ -199")) 

假设我们得到了一些意外的情况,一些异常信息如下:

Exception in thread "main" java.lang.NumberFormatException: For input string: "+ -199" 
   at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) 

我们没有得到整数结果,而是一个异常——这很糟糕。但让我们保持积极;我们可以从这次失败中学到一些东西:

  • 我们函数的定义不正确。它告诉我们,“你给我一个字符串,我会给你相应的整数。”但事实并非如此。

  • 我们知道,除了理想情况外,可能还有我们的操作可能无法完成的情况。

因此,从这次经历中学习,我们现在有一个想法,我们可能想要处理这些不期望的情况。但怎么办呢?

在某些编程语言中,我们得到一个构造函数,它可以包装可能抛出异常的代码块,并在抛出异常时捕获异常,我们允许通过在捕获块中放入我们期望的行为来引入我们期望的行为。这些不过是try... catch块。我们为什么不尝试这些块呢?

import java.lang.Exception 

object Main extends App { 

  def toInt(str: String): Int = 
    try{ 
      str.toInt 
    } catch { 
      case exp: Exception => 
        println("Something unexpected happened, you may want to check the string you passed for conversion.") 

        println("WARN: Overriding the usual behavior, returning Zero!") 
        0 
    } 

  println(toInt("121")) 
  println(toInt("-199")) 
  println(toInt("+ -199")) 
} 

以下就是结果:

121 
-199 

发生了意外的情况;你可能想检查你传递给转换的字符串:

WARN: Overriding the usual behavior, returning Zero! 
0 
try block and also prepared what should be the behavior in case something went wrong. That change gave us a synthetic result, along with a pretty warning message.

这个实现看起来合理吗?在某种程度上,它是一个折衷方案,并且它确实做了函数签名中所说的。但在异常情况下返回零仍然不是一个好的选择。

使用 Option 方法

让我们尝试更改函数签名,以便我们可以推理和修改它,使其按其所说的那样工作:

def toInt(str: String): Option[Int] = Try(str.toInt) match { 
  case Success(value) => Some(value) 
  case Failure(_) => None 
}

在前面的定义中,我们知道响应是可选的。我们可能或可能不会为传递给我们的函数的每个字符串得到相应的整数值。因此,我们将响应类型设为Option[Int]。此外,正如你可能已经注意到的,我们使用了scala.util包中可用的另一个构造函数,名为Try。我们如何使用Try?我们传递一个函数给Try块的构造函数/apply方法。显然,Try块的apply方法接受一个函数作为by-name参数,尝试评估该函数。根据结果或异常,它响应为Success(value)Failure(exception)

我们使用了Try构造函数,并将逻辑作为参数传递。在成功的情况下,我们响应为Some(value),在失败的情况下,我们返回None。两者都运行良好,因为它们都是Option类型的子类型。我们已经在第九章中看到了Option[+T]使用强大的函数式构造函数。让我们简单谈谈Try[+T]类型。我们将从签名开始:

sealed abstract class Try[+T] extends Product with Serializable 

object Try { 
  /** Constructs a 'Try' using the by-name parameter.  This 
   * method will ensure any non-fatal exception is caught and a 
   * 'Failure' object is returned. 
   */ 
  def applyT: Try[T] = 
    try Success(r) catch { 
      case NonFatal(e) => Failure(e) 
    } 
} 

final case class Success+T extends Try[T] 

final case class Failure+T extends Try[T] 

现在我们已经熟悉了工作与参数化类型,理解Try的签名将更容易。要注意的两点是SuccessFailure子类型——这里不需要解释它们在这里的作用。让我们看看Try类型的伴随对象,它有一个apply方法,正如之前讨论的那样。它期望一个by-name参数。我们著名的try... catch块正在处理其余的事情。

这是你可能想要更改函数签名以处理异常并按其所说的那样工作的方法之一。让我们谈谈一个我们可能想要管道化几个操作的场景——换句话说,我们想要执行函数式组合。看看以下函数定义:

def getAccountInfo(id: String): Option[AccountInfo] 

def makeTransaction(amt: Double, accountInfo: AccountInfo): Option[Double] 

case class AccountInfo(id: String, balance: Double) 

看到这两个函数,它们似乎可以一起流水线化执行逻辑。但是如何实现呢?我们可以向我们的getAccountInfo函数传递一个账户 ID,该函数随后返回一个可选的AccountInfo。我们可以使用这个账户信息和金额调用makeTransaction来执行交易。这两个操作看起来足够好,可以组合在一起,但我们唯一的问题是第一个操作的输出是可选的,因此第二个函数可能被调用也可能不被调用。所以对于这个问题,flatMap操作看起来是个不错的选择。那么让我们试试看:

import scala.util.Try 

object BankApp extends App { 

  val accountHolders = Map( 
    "1234" -> AccountInfo("Albert", 1000), 
    "2345" -> AccountInfo("Bob", 3000), 
    "3456" -> AccountInfo("Catherine", 9000), 
    "4567" -> AccountInfo("David", 7000) 
  ) 

  def getAccountInfo(id: String): Option[AccountInfo] = Try(accountHolders(id)).toOption 

  def makeTransaction(amt: Double, accountInfo: AccountInfo): Option[Double] = Try(accountInfo.balance - amt).toOption 

  println(getAccountInfo("1234").flatMap(actInfo => makeTransaction(100, actInfo))) 

  println(getAccountInfo("12345").flatMap(actInfo => makeTransaction(100, actInfo))) 
} 

case class AccountInfo(id: String, balance: Double) 

以下就是结果:

Some(900.0) 
None 

如果我们看一下前面的代码,我们可以看到我们的getAccountInfomakeTransaction函数返回可选值,这两个结果中的任何一个都可能为None. 由于没有好的错误信息告诉我们出了什么问题,所以很难知道哪个操作出了错。所以总结一下,Option是一种处理此类场景的方法,但如果我们能知道出了什么问题会更好。为此,我们可以使用 Scala 的另一个结构,名为Either

左或右

Scala 为我们提供了一个Either[+A, +B]类型。但在我们谈论Either之前,让我们先使用它。我们将使用Either类型重构我们的代码:

import java.lang.Exception 
import scala.util.{Failure, Success, Try} 

object Main extends App { 

  def toInt(str: String): Either[String, Int] = Try(str.toInt) match { 
    case Success(value) => Right(value) 
    case Failure(exp) => Left(s"${exp.toString} occurred," + 
      s" You may want to check the string you passed.") 
  } 

  println(toInt("121")) 
  println(toInt("-199")) 
  println(toInt("+ -199")) 
} 

以下就是结果:

Right(121) 
Right(-199) 
Left(java.lang.NumberFormatException: For input string: "+ -199" occurred, You may want to check the string you passed.) 

在前面的代码中,我们知道从stringint的转换可能会出错。所以结果可能是一个异常或预期的整数。所以我们尝试做同样的事情:我们使用Either类型,当出错时左值是一个String消息,而右值是一个Int。为什么这样做呢?让我们看一下Either类型的签名来理解这一点:

sealed abstract class Either[+A, +B] extends Product with Serializable 
final case class Right+A, +B extends Either[A, B] 
final case class Left+A, +B extends Either[A, B] 

从前面的签名中,我们可以看到Either类型接受两个类型参数,AB;按照惯例,我们认为Left值是异常情况值,而右值是预期的结果值。这就是为什么我们声明响应类型如下:

Either[String, Int] 

这表示我们期望得到一个StringInt值。所以用例很清楚。我们知道了我们的操作发生了什么——即从字符串到相应整数的转换。现在,为什么我们不尝试使用Either类型进行一些函数组合呢?我们可以用同样的场景来做这件事:

import scala.util.{Failure, Success, Try} 

object BankApp extends App { 

  val accountHolders = Map( 
    "1234" -> AccountInfo("Albert", 1000), 
    "2345" -> AccountInfo("Bob", 3000), 
    "3456" -> AccountInfo("Catherine", 9000), 
    "4567" -> AccountInfo("David", 7000) 
  ) 

  def getAccountInfo(id: String): Either[String, AccountInfo] = Try(accountHolders(id)) match { 
    case Success(value) => Right(value) 
    case Failure(excep) => Left("Couldn't fetch the AccountInfo, Please Check the id passed or try again!") 
  } 

  def makeTransaction(amount: Double, accountInfo: AccountInfo): Either[String, Double] = Try { 
    if(accountInfo.balance < amount) throw new Exception("Not enough account balance!") else accountInfo.balance - amount 
  } match { 

    case Success(value) => Right(value) 
    case Failure(excep) => Left(excep.getMessage) 
  }

  println(getAccountInfo("1234").flatMap(actInfo => makeTransaction(100, actInfo))) 

  println(getAccountInfo("1234").flatMap(actInfo => makeTransaction(10000, actInfo))) 

  println(getAccountInfo("12345").flatMap(actInfo => makeTransaction(100, actInfo))) 
} 

case class AccountInfo(id: String, balance: Double) 

以下就是结果:

Right(900.0) 
Left(Not enough account balance!) 
Left(Couldn't fetch the AccountInfo, Please Check the id passed or try again!) 

这很有趣。这个新的结构让我们的生活变得更简单,并给我们提供了关于失败的有意义的信息。现在我们也能识别出哪里出了问题,以及何时出了问题。

我们可以看到Either帮助我们更好地处理异常。我们也看到了几种处理异常情况的方法。这次讨论的收获是什么?让我们总结一下。

我们已经看到了一些在 Scala 程序中处理异常场景的构造。你可能认为其中一个构造Try[+T]除了使用一个try... catch块来处理异常之外,没有做什么。所以我们对这个论点的回应是关于函数组合的。你可能会选择使用scala.util.Try[+T]而不是普通的try... catch块,原因在于函数组合。**

这个类型提供了一些函数,例如map用于转换和flatMap用于组合,这样我们就可以使用flatMap操作将两个操作组合在一起。如果你想知道这是什么,让我告诉你,我们已经看到了这个例子。我们想要使用flatMap方法将两个函数组合起来得到结果,这之所以可能,仅仅是因为我们的类型TryOptionEither有这个看起来很疯狂的功能flatMap。看看flatMap方法的实现是值得的。这个OptionflatMap函数可能看起来如下:

def flatMapA, B(functionToPerfom: A => Option[B]): Option[B] = 
  if (someValue.isEmpty) None else functionToPerfom(someValue.get) 

根据签名,我们将传递Option[A]。这里的A参数不过是一个类型参数和一个形式为A => Option[B]的函数,定义将返回类型Option[B]。这很强大,并帮助我们组合这两个函数。这就是你可能想要选择Option/Either/Try构造的原因之一。三个中哪一个将被使用取决于用例。Either类型在你出错时提供了返回消息的便利。

所以这就解释了我们在 Scala 程序中如何处理异常。现在让我们继续讨论 Scala 提供的一个概念,让你能够隐式地做事情。让我们来谈谈 Scala 中的隐式参数。

隐式参数 - 什么是隐式参数以及为什么

什么是隐式参数?当我们谈论隐式参数时,我们指的是隐式参数或隐式转换。隐式参数是与关键字implicit一起出现的参数,如果这些参数在作用域内,我们不需要显式传递这些参数的参数。让我们看看它是如何工作的。

让我们举一个例子,并创建一个Future值。Future不过是我们提供的将在未来某个时间点发生的计算。这意味着一个将在未来发生的计算。当我们讨论第十三章中的并发编程技术时,我们将深入讨论Future值,Scala 中的并发编程。现在我们先写一个代码片段:

import scala.concurrent.Future 

object FuturesApp extends App { 

  val futureComp = Future { 
     1 + 1 
  } 

  println(s"futureComp: $futureComp") 

  futureComp.map(result => println(s"futureComp: $result")) 
} 
Future block and that we are then printing out this Future instance. After that, we're extracting the computation's result out of the Future value and printing it. Looks like it should work fine. Let's run this. We will get the following result:
Error:(7, 27) Cannot find an implicit ExecutionContext. You might pass 
an (implicit ec: ExecutionContext) parameter to your method 
or import scala.concurrent.ExecutionContext.Implicits.global. 
  val futureComp = Future { 

Error:(7, 27) not enough arguments for method apply: (implicit executor: scala.concurrent.ExecutionContext)scala.concurrent.Future[Int] in object Future. 
Unspecified value parameter executor. 
  val futureComp = Future { 

Scala 编译器给我们带来了两个编译错误。第一个错误说它找不到ExecutionContext类型的隐式值. 好的,我们现在还不知道ExecutionContext是什么。让我们看看下一个错误。它说方法 apply 的参数不足:(implicit executor: ExecutionContext) scala.concurrent.Future[Int]`.

现在,我们有一个想法,即有一个需要但对我们代码不可用的参数。让我们看看Future块的apply方法来解决这个问题:

def applyT(implicit executor: ExecutionContext): Future[T] 

好的,这似乎很有趣。我们为参数ExecutionContext提供了一个implicit关键字。这意味着调用Future块的apply方法是允许的;我们唯一需要关注的是声明类型的隐式值。所以,如果我们能以某种方式将ExecutionContext类型的值引入我们的作用域,事情应该会顺利。我们所说的作用域是什么意思呢?让我们暂时将当前的编译单元(Scala 文件)视为作用域。那么,让我们这样做:

import scala.concurrent.Future 

object FuturesApp extends App { 

  implicit val ctx = scala.concurrent.ExecutionContext.Implicits.global 

  val futureComp = Future { 
     1 + 1 
  } 

  println(s"futureComp: $futureComp") 

  futureComp.map(result => println(s"futureComp: $result")) 
} 

以下结果是:

futureComp: Future(Success(2)) 
futureComp: 2 

我们声明了一个名为ctximplicit值,其类型为ExecutionContext,然后再次尝试运行应用程序,神奇的是一切正常。我们没有明确传递任何上下文或做任何特别的事情——我们只是将所需类型的值引入了作用域,事情就顺利了。我们得到了结果。不过,有一点需要注意,那就是我们使用了这个implicit关键字;这就是为什么Future.apply能够推断出作用域中可用的值。如果我们没有使用implicit关键字尝试这样做,我们会得到与之前类似的编译错误。所以,我们的想法是在作用域中获取一个隐式值,现在我们知道什么是隐式了。不过,有一个大问题:你为什么想要这种行为呢?我们将就这个想法进行一次有益的讨论。

让我们从这样的想法开始,即 Scala 中的隐式可以用来自动化将值传递给操作或从一种类型到另一种类型的转换的过程。让我们先谈谈第一个:隐式参数。

隐式参数

当我们想要编译器帮助我们找到一个已经为某种类型可用的值时,我们会使用隐式参数。当我们谈论Future时,我们已经看到了一个隐式参数的例子。为什么我们不为自己定义一个类似的东西呢?

我们可以想象一个场景,我们需要在我们的应用程序中显示当前日期,并且我们希望避免再次明确传递日期实例。相反,我们可以使LocalDateTime.now值对相应的函数是隐式的,让当前日期和时间作为隐式参数传递给它们。让我们为这个写一些代码:

import java.time.{LocalDateTime} 

object ImplicitParameter extends App { 

  implicit val dateNow = LocalDateTime.now() 

  def showDateTime(implicit date: LocalDateTime) = println(date) 

  //Calling functions! 
  showDateTime 
} 

以下结果是:

2017-11-17T10:06:12.321 

showDateTime函数视为需要日期和时间当前值的函数——因此,我们可以将其作为隐式值提供。这就是我们做的——在showDateTime的定义中,我们声明了一个名为date的隐式参数,其类型为LocalDateTime。我们还有一个名为dateNow的隐式值在作用域中。这就是为什么我们不需要在调用点传递参数,事情仍然对我们很顺利。

这似乎是一个很好的用例。你可以使用隐式来使你需要的值自动对你自己可用。

隐式方法

Scala 的标准库提供了一个创建类型具体实例的实用方法,该方法的名称也是implicitly. 让我们看看函数签名:

def implicitlyT = e 

这个implicitly方法只期望一个类型参数,找到作用域中可用的隐式值,并召唤并返回它给我们。这是我们用来判断特定类型的值是否在隐式作用域中可用的一个好选项。让我们看看这个方法的一个应用:

import java.time.{LocalDateTime} 

object ImplicitParameter extends App { 

  implicit val dateNow = LocalDateTime.now() 

  def showDateTime(implicit date: LocalDateTime) = println(date) 

  val ldt = implicitly[LocalDateTime] 

  println(s"ldt value from implicit scope: $ldt") 
} 

以下结果是:

ldt value from implicit scope: 2017-12-17T10:47:13.846 
implicitly, along with the type, returned us the value available—as we already know, it's the current date-time value.

因此,这就是我们如何在定义中使用implicit参数,并使它们在相应的范围内可用。

现在我们对隐式转换有了些了解,让我们来看看隐式转换

隐式转换

标准 Scala FAQ 页面将隐式转换描述为:“如果在对象o的类C上调用方法m,而该类C不支持方法m,那么 Scala 编译器将寻找从C类型到支持m方法的类型的隐式转换”。

理念很清晰:这是一种合成行为(使用方法),我们正在强制应用于特定类型的实例,而这些行为(方法)并不是定义类型的一部分。这就像我们有一个具有某些功能的库,我们希望向库中的某个类型添加一些附加功能。想想看——这是强大的。能够为特定类型添加功能本身就是强大的。这正是隐式转换让我们做到的。我们将尝试做一些类似以下的事情。

首先,考虑一个我们需要创建一些语法方法的场景。我们有一些可用于日期时间库java.time.LocalDate的方法,可以帮助我们添加或减去天数/周数/月数/年数,这些方法包括:

scala> import java.time.LocalDate 
import java.time.LocalDate 

scala> val ld = LocalDate.now 
ld: java.time.LocalDate = 2017-12-17 

scala> ld.plusDays(1) 
res0: java.time.LocalDate = 2017-12-18 

scala> ld.plusWeeks(1) 
res1: java.time.LocalDate = 2017-12-24 

scala> ld.plusMonths(1) 
res2: java.time.LocalDate = 2018-01-17 

scala> ld.plusYears(1) 
res3: java.time.LocalDate = 2018-12-17 

我们希望有一个简单的加号(+)或减号(-)用于表示天数/周数/月数/年数,以便它们能像plusXXXminusXXX方法一样工作。我们有哪些选项可以实现这样的语法?

其中一个选项是在LocalDate上创建一个Wrapper类,例如CustomDate(date: LocalDate),并为它定义这些方法。在这种情况下,代码可能看起来像这样:

import java.time.LocalDate 

case class CustomDate(date: LocalDate) { 
  def +(days: Day): CustomDate = CustomDate(this.date.plusDays(days.num)) 
  def -(days: Day): CustomDate = CustomDate(this.date.minusDays(days.num)) 

  def +(weeks: Week): CustomDate = CustomDate(this.date.plusWeeks(weeks.num)) 
  def -(weeks: Week): CustomDate = CustomDate(this.date.minusWeeks(weeks.num)) 

  def +(months: Month): CustomDate = CustomDate(this.date.plusMonths(months.num)) 
  def -(months: Month): CustomDate = CustomDate(this.date.minusMonths(months.num)) 

  def +(years: Year): CustomDate = CustomDate(this.date.plusYears(years.num)) 
  def -(years: Year): CustomDate = CustomDate(this.date.minusYears(years.num)) 

  def till(endDate: CustomDate): CustomDateRange = if(this.date isBefore endDate.date) 
    CustomDateRange(this, endDate) 
  else { 
    throw new IllegalArgumentException("Can't create a DateRange with given start and end dates.") 
  } 

  override def toString: String = s"Date: ${this.date}" 
} 

case class Day(num: Int) 
case class Week(num: Int) 
case class Month(num: Int) 
case class Year(num: Int) 

case class CustomDateRange(sd: CustomDate, ed: CustomDate){ 
  override def toString: String = s"$sd till $ed " 
} 

如您在前面的代码中所注意到的,我们有一个CustomDate类封装了LocalDate类型,并使用LocalDate类型的这些方法来定义我们自己的期望语法方法。让我们尝试使用它。为此,我们可以创建另一个扩展App特质的对象:

import java.time.LocalDate 

object BeautifulDateApp extends App { 

  val today = CustomDate(LocalDate.now()) 
  val tomorrow = today + Day(1) 
  val yesterday = today - Day(1) 

  println(today) 
  println(tomorrow) 
  println(today + Year(1)) 

  val dateRange = today till tomorrow + Day(20) 
  println(dateRange) 

} 

以下结果是:

Date: 2017-12-17 
Date: 2017-12-18 
Date: 2018-12-17 
Date: 2017-12-17 till Date: 2018-01-07 
LocalDate gives us the feeling that this syntax isn't a part of the standard library we have. So for this, implicits come into the picture. We're going to do a similar syntax hack using the implicit class.

为了做到这一点,我们将创建一个隐式类,它只接受一个val类型的LocalDate*,然后使用类似的逻辑提供我们所有的语法方法。之后,我们将通过导入它来将这个隐式类引入作用域。让我们写下这个:

case class Day(num: Int) 
case class Week(num: Int) 
case class Month(num: Int) 
case class Year(num: Int) 

case class CustomDateRange(sd: CustomDate, ed:CustomDate){ 
  override def toString: String = s"$sd till $ed " 
} 

object LocalDateOps { 
  implicit class CustomDate(val date: LocalDate) { 

    def +(days: Day): CustomDate = CustomDate(this.date.plusDays(days.num)) 
    def -(days: Day): CustomDate = CustomDate(this.date.minusDays(days.num)) 

    def +(weeks: Week): CustomDate = CustomDate(this.date.plusWeeks(weeks.num)) 
    def -(weeks: Week): CustomDate = CustomDate(this.date.minusWeeks(weeks.num)) 

    def +(months: Month): CustomDate = CustomDate(this.date.plusMonths(months.num)) 
    def -(months: Month): CustomDate = CustomDate(this.date.minusMonths(months.num)) 

    def +(years: Year): CustomDate = CustomDate(this.date.plusYears(years.num)) 
    def -(years: Year): CustomDate = CustomDate(this.date.minusYears(years.num)) 

    def till(endDate: CustomDate): CustomDateRange = if(this.date isBefore endDate.date) 
      CustomDateRange(this, endDate) 
    else { 
      throw new IllegalArgumentException("Can't create a DateRange with given start and end dates.") 
    } 

    override def toString: String = s"Date: ${this.date}" 
  } 
} 

现在,是时候在我们的BeautifulDateApp类中使用它了*:

import java.time.LocalDate 
import LocalDateOps._ 

object BeautifulDateApp extends App { 

  val today = LocalDate.now() 
  val tomorrow = today + Day(1) 
  val yesterday = today - Day(1) 

  println(today) 
  println(tomorrow) 
  println(today + Year(1)) 

  val dateRange = today till tomorrow + Day(20) 
  println(dateRange) 
} 

以下结果是:

2017-12-17 
Date: 2017-12-18 
Date: 2018-12-17 
Date: 2017-12-17 till Date: 2018-01-07 

我们可以看到我们采取的两种方法之间的区别。第二种方法似乎更符合本地方法。作为这些语法方法的消费者,我们从未尝试调用CustomDate类——相反,我们创建了一个LocalDate类型的实例

  val today = LocalDate.now() 

我们使用了+-,就像在LocalDate类中定义的本地方法一样。这就是力量,或者说魔法,在于隐式转换。对于那些想知道幕后发生了什么的人来说,让我们更详细地看看代码的工作原理。

Scala 编译器看到了以下内容:

val tomorrow = today + Day(1) 

然后,编译器试图在LocalDate类中寻找一个名为+的方法,该方法接受一个日期作为参数。编译器无法在那里找到这样的方法并不奇怪,因此它试图检查是否在隐式作用域中存在任何其他类,该类期望一个LocalDate,并且执行了诸如+这样的操作(日期/周/月/年)。然后,编译器找到了我们的CustomDate隐式类。最后,发生了隐式转换,这个特定的方法调用对我们有效。然后我们能够使这样的方法语法黑客成为可能。

现在我们已经看到了这样的例子,我们可能想要问自己一个问题:我们所说的隐式作用域是什么意思?我们还需要了解 Scala 编译器如何搜索隐式值。让我们尝试找到这个答案。

查找隐式值

您的常规 Scala 应用程序代码可能包含一些导入其他类和对象的构造,或者它也可能继承其他类。您编写的方法期望类型作为参数,并声明参数。因此,当 Scala 编译器寻找隐式值时,它应该在何处开始寻找这样的值?编译器开始根据以下标准寻找隐式值:

  • 在当前作用域中定义

  • 明确导入

  • 使用通配符导入

  • 类型伴生对象

  • 参数类型的隐式作用域

  • 类型参数的隐式作用域

  • 嵌套类型的外部对象

我们知道,如果我们当前作用域(代码块)中定义了一个隐式值,它将获得最高的优先级。之后,您也可以使用import语句导入它,如下面的代码所示:

import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global 

object FuturesApp extends App { 

  val futureComp = Future { 
     1 + 1 
  } 

  println(s"futureComp: $futureComp") 

  futureComp.map(result => println(s"futureComp: $result")) 
} 

以下结果是:

futureComp: Future(Success(2)) 
futureComp: 2 

通配符导入也可以适用于此:

import scala.concurrent.ExecutionContext.Implicits._ 

但是,当编译器看到同一作用域中存在两个适用于相同类型的隐式值时,生活和我们都感到有些不舒服。然后我们看到的是一个编译错误,指出存在歧义隐式值*。让我们试试看:

import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global 

object FuturesApp extends App { 

  implicit val ctx = scala.concurrent.ExecutionContext.Implicits.global 

  val futureComp = Future { 
     1 + 1 
  } 

  println(s"futureComp: $futureComp") 

  futureComp.map(result => println(s"futureComp: $result")) 
} 

对于前面的代码,我们将面临以下编译错误:

Error:(10, 27) ambiguous implicit values: 
 both lazy value global in object Implicits of type => scala.concurrent.ExecutionContext 
 and value ctx in object FuturesApp of type => scala.concurrent.ExecutionContext 
 match expected type scala.concurrent.ExecutionContext 
  val futureComp = Future { 

因此,我们需要注意隐式值的歧义。

如果编译器无法在当前代码块或通过导入中找到隐式值,它将在类型的伴生对象中搜索它。这就是编译器搜索隐式值的方式。标准的 Scala 文档解释了查找隐式的主题,你可以在docs.scala-lang.org/tutorials/FAQ/finding-implicits.html找到它。

通过对隐式的讨论,我们看到了我们可以使用这个概念并让魔法为我们工作的几种方式。当库设计者在定义类型类并通过隐式值提供它们的实例时,这被广泛使用。我们已经涵盖了类型类是什么,并且我们可以自己创建一个。让我们试试:

接下来是类型类!

当创建类型类来解决像为特定格式提供编码类型机制这样的问题时,我们必须释放像 Scala 这样的语言的力量。我们想要的编码特定类型值的逗号分隔值(CSV)格式的方法。为此,我们将创建一个名为 CSVEncoder 的类型类。在 Scala 中,我们可以通过使用某种类型的 trait 来做这件事:

trait CSVEncoder[T]{ 
  def encode(value: T): List[String] 
} 

我们定义的是一个为我们类型提供功能的功能提供者。目前的功能是将特定类型的值编码并返回一个我们可以表示为 CSV 的字符串值列表。现在,你可能想通过调用一些函数来使用这个功能,对吧?对于像 Person 这样的简单类型,它可以看起来像这样:

case class Person(name: String) 

CSVEncoder.toCSV(Person("Max")) 

一些其他的语法可能看起来像这样:

Person("Caroline").toCSV 

要使用类似这些的东西,我们需要的是这个:

  • 一种将 Person 类型编码为 CSV 格式的方法

  • 实用函数 toCSV

让我们定义我们的类型类提供的功能可以使用的方法:

object CSVEncoder { 

 def toCSVT(implicit encoder: CSVEncoder[T]): String = 
  list.map(mem => encoder.encode(mem).mkString(", ")).mkString(", ") 

} 

在这里,我们为 CSVEncoder 定义了一个伴生对象,并定义了一个名为 toCSV 的实用函数,它接受一个类型参数和相同类型的值序列,除了它期望一个相同类型的隐式 CSVEncoder 实例。它返回的是一个 List[String]. 我们知道将字符串值序列转换为 CSV 很容易。这就是我们从这个函数中想要的东西。因此,我们简单地调用 encoder.encode(value) 并将值转换为逗号分隔的格式。

现在,让我们定义一种编码 Person 类型的方法:

implicit val personEncoder: CSVEncoder[Person] = new CSVEncoder[Person] { 
  def encode(person: Person) = List(person.name) 
} 

在前面的代码中,我们提供了一种编码我们的 Person 类型的方法。现在,让我们来使用它:

object EncoderApp extends App { 
  import CSVEncoder.personEncoder 

  println(CSVEncoder.toCSV(List(Person("Max Black"), Person("Caroline Channing")))) 

} 

以下就是结果:

Max Black, Caroline Channing 

在我们的 EncoderApp 中,我们隐式地导入了 CSVEncoder[Person] 并调用一个带有预期值的 toCSV 函数。调用这个函数会给我们期望的结果. 我们现在可以使用隐式类来修改 toCSV 函数的语法,并为我们的类型类的消费者提供另一种使用我们的编码器的方式。让我们这么做:

trait CSVEncoder[T]{ 
  def encode(value: T): List[String] 
} 

object CSVEncoder { 

  def toCSVT(implicit encoder: CSVEncoder[T]): String = 
    list.map(mem => encoder.encode(mem).mkString(", ")).mkString(", ") 

  implicit val personEncoder: CSVEncoder[Person] = new CSVEncoder[Person] {
     def encode(person: Person) = List(person.name) 
  } 

} 

case class Person(name: String) 

object EncoderApp extends App { 
  import CSVEncoder._ 
  import CSVEncoderOps._ 

  println(CSVEncoder.toCSV(List(Person("Max Black"), Person("Caroline Channing")))) 

  println(List(Person("Max Black"), Person("Caroline Channing")).toCSV) 
} 

object CSVEncoderOps { 
  implicit class CSVEncoderExtT { 
    def toCSV(implicit encoder: CSVEncoder[T]) : String = 
      list.map(mem => encoder.encode(mem).mkString(", ")).mkString(", ") 
  } 
} 

以下就是结果:

Max Black, Caroline Channing 
Max Black, Caroline Channing 
toCSV function as a method:
List(Person("Max Black"), Person("Caroline Channing")).toCSV 

我们使用隐式 CSVEncoderExt 类实现了这种语法调用,这是我们为 LocalDate 的语法方法所采取的方法:

implicit class CSVEncoderExtT { 
    def toCSV(implicit encoder: CSVEncoder[T]) : String = 
      list.map(mem => encoder.encode(mem).mkString(", ")).mkString(", ") 
  } 

我们所需要做的就是确保这个特定的类在调用点的作用域内,所以我们导入了它。这就是我们创建和使用我们的第一个类型类的方式。这并不难,对吧?当然,我们已经在本章中足够详细地介绍了类型类。让我们继续总结本章我们所学的知识。

摘要

首先,我们讨论了在尝试编程时出现的异常情况。我们看到了在函数式编程中如何处理这些异常情况。我们甚至在函数组合中尝试了异常处理。然后,我们开始看到 Scala 中隐式带来的魔法。我们讨论了隐式参数和隐式转换。我们看到了由 Scala 标准库提供的implicitly方法。最后,我们谈论了已经讨论得很多的类型类,并定义/使用了我们的第一个类型类。一旦你足够多地练习了我们讨论的概念,详细学习类型类是值得的。Scala 的大多数库框架都大量使用了这个概念。

在下一章,我们将学习 Akka 工具包。我们将涵盖 Akka 提供的一项服务,即Actor 系统,以及更多内容。

第十二章:Akka 简介

“技术什么都不是。重要的是你相信人们,相信他们是好的和聪明的,如果你给他们工具,他们会用它们做出奇妙的事情。”

  • 史蒂夫·乔布斯

作为开发者,我们习惯于面对编程问题,并使用抽象、编程模型或某些设计模式来解决问题。这些编程模型往往使我们的生活和消费者的生活变得更轻松。本章将介绍一种解决多个问题的编程模型。我们将了解并使用基于Actor 模型的 Akka。我们可以将 Akka 库(好吧,大部分)视为一套开源库,帮助你编写并发、容错和分布式应用程序。我们将讨论从这个工具包中你可以期待什么。随着我们进入本章,我们将尝试理解 actor 模型以及这些 actor 如何协同工作,以及 actor 机制与其他任何并发机制的不同之处。

由于本章以及本书的范围不包括遍历所有 Akka 库,因此我们将专注于理解 actor 系统,这是 Akka 工具包中任何其他库的基础。这将使我们能够在需要时使用它们。在本章中,我们将讨论以下内容:

  • 我们为什么关心 Akka*?

  • Actor 模型有什么问题?

  • 实践中的 Actor

  • 监督 Actor*的故障

  • 测试 Actor

那么我们为什么还要关心另一种编程模型呢?让我们来探究一下。

我们为什么关心 Akka?

在我们周围充斥着大量数据的情况下,我们的计算机/处理系统正在努力追求性能。通过多核架构和分布式计算,我们实现了可接受的服务可用性下的高性能。但这一点不能视为理所当然;我们已经到了一个已经拥有机制来处理由于系统或我们使用的编程模型的不完善而出现的问题的地步。

由于多核架构的出现,我们的系统能够以高性能处理大量数据。但我们的编程模型中存在一个故障,我们使用它来改变状态,同时使用多个线程来改变程序中存在的状态。这给了我们思考的理由。

两个或多个线程试图处理特定的共享状态可能会导致死锁(更多内容请参阅第十三章,Scala 中的并发编程,我们将更详细地讨论并发和线程),而且你的程序可能甚至无法完成。但尽管如此,我们仍在讨论这个问题;我们无处看到解决方案。我们可以考虑的一种处理线程和问题的方法是通过某种锁定机制,这样两个独立的线程就不能同时访问相同的实例/状态。

但这样想,通过引入锁,我们使操作系统中断/挂起线程,稍后恢复以执行相同任务。这要求你的计算机 CPU 承担更多。

这意味着在没有锁的情况下,我们面临着实例状态的难题,而现在有了锁,程序的性能受到了影响。现在想象一个多线程的分布式环境;那里的生活更糟糕。

此外,我们在多线程环境中处理失败的方式并不令人满意。因此,我们需要一个不同的机制来处理这些问题。在 Akka*的情况下,我们让实体通过消息进行交互。我们通过创建 actors 来创建实体,这些 actors 通过相互传递消息进行通信。你可以将这种通信方式与网络通信进行比较,在那里我们依赖于 HTTP 请求-响应来执行预期的操作。同样,通过在 Actor 实例中封装状态,我们倾向于将不可变实例传递给另一个 actor 以执行某些逻辑。接收消息的 actor 在应用/执行一些逻辑后返回响应。这就是我们构建系统的方式。

Actor 模型已被证明是一种非常有效的解决方案。Akka 提供了这种 actor 模型,并为 actors 强制执行树状结构。关于 actors 的一些需要注意的点:

  • 通过通过消息进行通信,我们排除了对特定实例状态进行破坏的可能性

  • 由于一个 actor 一次只能处理一条消息,我们避免了死锁的情况

  • 在建立层次结构后,形成领域逻辑更容易

  • 两个 actors 之间的父子关系使我们能够处理错误行为;在 Akka 术语中,我们称之为监督策略

我们将探讨 actors 如何通信以及消息是如何排序/存储和执行的。首先,让我们尝试理解Actor 模型

有关 Actor 模型的问题是什么?

从我们的讨论中可以看出,我们有一些实体在接收到某些消息或请求时采取行动。我们称它们为 Actors。为了解决某些特定领域的问题,我们可能需要多个 Actors。考虑一个基本的电子商务结账流程场景。有多个关注点。以下图表表示了基本预期的流程:

图片

通过查看图表,很明显我们有一些实体,这些实体将负责特定的关注点。我们根据它们的关注点命名这些实体:

  • CheckoutActor可能负责从购物车中获取详细信息并显示相应的信息。

  • 此外,你可能想要应用一些优惠券代码或优惠。我们的系统必须验证该优惠券或优惠代码,并根据此可能修改订单详情。对于这个特定的过程,我们使用HandleOffersActor.

  • ShippingActor负责获取用户特定的信息,例如地址,根据这些信息我们可以计算出预计的运输时间。需要注意的是,ShippingActor不仅限于在内部处理整个逻辑,还可以调用另一个名为UserInfoActor的子演员,该演员除了获取用户信息外不做任何事情。另一个需要注意的点是,获取用户信息的操作非常通用,这个演员可能在这个特定的层次结构之外也很有用。

  • 在发货详情就绪后,我们可能希望将用户重定向到支付页面,在那里我们可以执行支付特定的逻辑。为此,我们有一个PaymentsActor

  • 最后,根据支付的成功或失败,我们可能想要处理订单。例如,在支付成功的情况下,我们想要进行订购,而在失败的情况下,我们可能想要发送一封电子邮件给用户,说明他们需要再次处理支付!为此,我们有一个HandleNewOrdersActor

在整个场景中,我们可以看到整个流程形成了一个演员的层次结构。假设ShippingActor由于网络/连接问题无法从数据库中提供用户信息,现在取决于我们如何处理这种情况。同样,在PaymentsActor失败的情况下,业务将决定下一步做什么。这可能是在支付状态挂起和支付方式为货到付款的情况下继续处理订单,或者要求用户重试。因此,当你的实体以层次结构的方式执行逻辑时,处理此类场景会更容易。

在这个简单的场景中,我们了解到这些演员形成了一个层次结构,或者让我们称之为一个组,并生活在系统中;在 Akka 术语中,我们称之为ActorSystem

理解演员系统

Akka 文档简单地解释ActorSystem为一个重量级结构,它将分配 1 到 N 个线程,我们应该为每个逻辑应用程序创建一个。一旦我们创建了一个演员系统,我们就获得了在该系统下创建演员的许可。我们将在下一节中看看我们如何创建演员。

当我们将演员作为系统的一部分创建时,这些演员与演员系统共享相同的配置(例如调度器、路径地址)。

在一个演员系统中,有一个根守护者演员;这个演员作为所有居住在演员系统中的演员的父演员,包括内部演员以及我们创建的演员。因此,正如预期的那样,这是系统终止时最后停止的演员。

Akka 提供这些守护者演员的原因是为了监督我们创建的一级演员,因此对于用户创建的演员来说,我们也有一个特定的用户守护者。同样,对于系统提供的演员,Akka 有系统守护者。

查看以下图表以了解 Akka 系统中守护者演员的层次结构:

在前面的图中,我们可以看到每个指定的 actor 的路径表示:

  • 根守护者: /

  • 用户守护者: /user

  • 系统守护者: /system

因此,每次我们在 Actor 系统中创建 Actor 时,我们倾向于创建一个第一级。因此,在图中显示的示例中,我们可以看到 Actor 的路径附加到/user,在我们的例子中是SimpleActor,因此形成的路径是/user/simpleActor. 对于定义了为system的 Actor 系统,为了创建这些第一级*(更多内容将在下一节中介绍)Actor,我们使用:

val system = ActorSystem("SimpleActorSystem") 
system.actorOf(Props[SimpleActor], "simple-actor") 

我们将在后续章节中尝试自己创建 Actor,但就目前而言,值得注意的是我们如何调用system上的actorOf方法来创建一个第一级Actor。从这里,我们可以为我们的第一级 Actor 创建子 Actor。为此,我们使用context而不是system实例。它看起来如下:

val anotherSimpleActor = context.actorOf(Props[AnotherSimplActor], "another-simple-actor") 

在这里,通过使用context而不是system*,我们指定我们想要创建的 Actor 将处于当前 Actor 的上下文中,使其成为一个子 Actor。重要的是要注意,这个调用只能从 Actor 内部进行。因此,通过这个方法调用,我们为simpleActor获得一个子 Actor:

context.actorOf(Props[AnotherSimpleActor], "another-simple-actor") 

假设调用是从SimpleActor发出的,并且SimpleActor是第一级 Actor,我们的anotherSimpleActor路径可能看起来像这样:

akka://SimpleActorSystem/user/simple-actor/another-simple-actor 

现在我们已经看到了子 Actor 的路径,很明显,我们的simple-actor的路径将是:

akka://SimpleActorSystem/user/simple-actor 

此外,还有一些值得注意的点,包括Props的使用以及actorOf方法的返回类型。

Props

Props可以看作是ActorRef*的配置对象。我们可以使用一些配置来创建 props 配置的实例。以下是一个例子:

val props = Props[SimpleActor]() 
  .withDispatcher("some-simple-dispatcher") 

val simpleActor: ActorRef = system.actorOf(props, "simple-actor") 

关于Props对象的一个重要事实是它是不可变的,因此是线程安全的。

Actor 引用和路径

当我们创建一个 Actor时,我们得到的响应是一个ActorRef.* 这是对我们创建的 Actor*的引用。我们可能需要ActorRef的原因是将其作为引用在整个系统中传递给其他 Actor。这些引用用于消息传递。我们创建的每个 Actor 都通过self拥有对自己引用。

在 Actor 内部,可以通过名为sender()的方法获取调用 Actor 的actor 引用*。

我们也可以给 actor 引用命名。在我们的例子中,我们给我们的SimpleActor引用命名为simple-actor:

val simpleActor: ActorRef = system.actorOf(props, "simple-actor")

我们还知道这些 Actor 是以分层的方式创建的,我们可以为actor 实例赋予唯一的名称。因此,这些名称共同为每个 Actor 形成一个路径。路径对每个 Actor 是唯一的。我们的SimpleActor路径可能看起来像这样:

图片

我们可以看到,由于层次结构,我们有不同 actor 的路径,因为 actor 必须具有唯一的名称。我们还可以看到,无论你的 actor 是在远程网络上创建的,其路径都将具有相同的结构,包括主机和端口。

通过 actorSelection 选择现有的 actorRefs

由于每个 actor 都有一个唯一的 ID,我们可以通过actorSelection方法通过其路径引用特定的 actor。我们可以在systemcontext上调用actorSelection方法并获取ActorRef.

当我们在system上调用actorSelection时,我们需要传递从根开始的绝对 Actor 路径,而当我们对context调用相同的操作时,我们可以传递相对于当前 Actor 的路径。

假设当前 Actor(第一级 Actor)在同一级别有一个SiblingActor,我们可以将兄弟 Actor 的 actor 引用称为:

context.actorSelection("../siblingActor") 

context.actorSelection("/user/siblingActor") 

在这两种方法中,第一种用于表示父 Actor.另一种方法直接引用 Actor 的路径。通过这种方式,我们能够获取 actor 引用,但这种方法是不推荐的,因为我们可能不想明确地编写 actor 路径。当我们假设想要使用通配符()时,我们可以利用actorSelection,即向层次结构中某个()级别以下的所有 actor 发送消息。以下图表将清楚地说明我们的意思:

在这里,在之前的图表中,通过提供以下代码:

context.actorSelection("akka://someActorSystem/user/*/LogicAActor")

我们可以获取引用,这些引用指向之前提到的层次结构中所有LogicAActor参数。还值得注意的是,对actorOf方法的调用根据其被调用的上下文在上下文或系统中创建 actor。而actorSelection的调用不会创建任何新的 actor,它指向我们传递的actorpath,并不确保 actor 在那里存在。

现在我们已经了解了 Actor 系统中的简单实体,让我们尝试理解 Actor 的生命周期是如何工作的,以及我们可能想要选择哪些方法来终止 actor 实例。

Actor 生命周期是如何工作的

当我们调用方法actorOf*时,我们得到的返回值是一个ActorRef,它反过来也拥有我们创建 Actor 的特定路径。通过这个调用,我们知道确实创建了一个 Actor 实例,分配了唯一的 ID,并调用了钩子方法。有一个名为preStart()的方法,它在创建新的 Actor 后作为第一个动作被调用。

在创建新的 Actor 时需要注意以下几点:

  • 为 Actor 预留一个新的 Actor 路径

  • Actor 会被分配一个唯一的 ID

  • 实例创建后,会调用preStart()方法:

当 Actor 重启时:

  1. 在实例上调用preRestart()

  2. 创建新实例,替换旧实例。

  3. 调用postRestart()方法。

当 Actor 停止时:

  1. 在实例上调用postStop()方法。

  2. 向观察者发送终止消息。

  3. Actor 路径允许再次使用。

之前的图示说明了整个周期。一个需要注意的重要点是,我们以preStartpreRestartpostStoppostRestart的形式获得这些钩子方法。使用这些方法,我们可以根据需要定义一些逻辑。

因此,现在我们已经了解了 actor 模型,并且也讨论了 actor 通过消息进行通信,让我们来实践一下。

Akka 中的 Hello world

要编写我们的第一个 Akka actor,我们需要添加akka-actor库依赖。对于依赖管理,我们将使用 SBT,并且正如我们所知,我们将在build.sbt文件中定义这些库依赖。为此,我们需要在我们的系统上安装 SBT。

设置环境

要开始一个简单的 Akka 项目,我们可以简单地遵循以下步骤:

  1. 前往 Lightbend 的TECH HUBdeveloper.lightbend.com)并点击 START A PROJECT:

图片

  1. 在 Akka 项目下搜索Akka Quickstart Scala。

图片

  1. 点击 CREATE A PROJECT FOR ME!:

图片

  1. 提取下载的 ZIP(压缩)文件。

我们可以在IntelliJ IDEA IDE 中打开提取的文件夹:

  1. 打开 IntelliJ IDE。

  2. 点击 File | New | Project from Existing Sources...:

图片

  1. 从我们刚刚提取的项目(akka-quickstart-scala)中选择build.sbt

图片

  1. 然后您可以在 IntelliJ 窗口中打开项目:

图片

这是一种开始 Akka 项目的方法。该项目已经定义了所有akka-actors特定的依赖项。我们不必单独定义它们。但我们都想从自己迈出的第一步开始,因此让我们从一个自引导的sbt-scala项目开始并定义库依赖项。

我们可以采取一些步骤来实现这一点:

  1. 打开您喜欢的命令行(Windows 中的命令提示符/Linux 和 macOS 中的终端),并进入您想要定位项目的目标目录。

  2. 输入命令sbt new sbt/scala-seed.g8

图片

  1. 命令提示符将询问项目的名称。给它起一个名字:

图片

  1. 命令将为我们生成 Scala 种子项目。我们可以按照步骤 13在 IntelliJ IDE 中打开Acala种子项目。

  2. 打开build.sbt文件。文件可能看起来像这样:

    import Dependencies._ 

    lazy val root = (project in file(".")). 
      settings( 
        inThisBuild(List( 
          organization := "com.example", 
          scalaVersion := "2.12.3", 
          version      := "0.1.0-SNAPSHOT" 
        )), 
        name := "Hello", 
        libraryDependencies += scalaTest % Test 
      ) 

此文件指定我们在当前目录中有一个名为Hello的根项目。除此之外,我们还提供了一些特定版本的详细信息,最后一行指定我们目前有一个libraryDependency,即scala-test。此值来自Dependencies.scala文件。

我们将在本build.sbt文件中定义akka-actors特定的依赖项:

libraryDependencies ++= Seq( 
  "com.typesafe.akka" %% "akka-actor" % "2.5.8", 
  "com.typesafe.akka" %% "akka-testkit" % "2.5.8", 
  "org.scalatest" %% "scalatest" % "3.0.1" % "test" 
  ) 
akka-actor specific dependency. The second is for testing Akka Actors. The last one is specific to Scala testing.

因此,我们将使用sbt update命令来更新库,并在当前项目目录中使用cmd/terminal输入命令。

通过这些步骤,我们就可以编写我们的第一个 Akka actor 了。

编写我们的第一个 Actor

编写一个 Actor 就像编写一个扩展了akka.actor.Actor类的类一样简单。我们知道 Actor 会响应消息,因此为了识别消息,我们有一个名为receive的方法,我们必须为每个我们编写的 Actor 定义它。让我们编写我们的SimpleActor

import akka.actor.Actor 

class SimpleActor extends Actor { 

  override def receive = Actor.emptyBehavior 

} 

因此,我们编写了SimpleActor,在receive方法中定义了一些空的行为。但在这里,我们只是编写了我们的 Actor;我们必须将 Actor 实例化作为 Actor 系统的一部分。实例化后,我们可能还想运行我们的应用程序来查看行为,因此,让我们编写应用程序的入口点并实例化一个 Actor 系统:

import akka.actor.ActorSystem 

object AkkaStarter extends App { 

  val simpleActorSystem = ActorSystem("SimpleActorSystem") 

} 

这条语句为我们提供了一个名为SimpleActorSystem的 Actor 系统的实例。现在,我们想要创建一个我们的SimpleActor实例作为顶级(第一级)actor,因此我们将使用可用的simpleActorSystem.actorOf方法:

import akka.actor.{Actor, ActorSystem, Props} 

class SimpleActor extends Actor { 
  override def receive = Actor.emptyBehavior 
} 

object SimpleActor { 
  val props = Props[SimpleActor] 
} 

object AkkaStarter extends App { 

  val simpleActorSystem = ActorSystem("SimpleActorSystem") 

  val simpleActor = simpleActorSystem.actorOf(SimpleActor.props) 
} 

目前我们有一个可用的 Actor 系统,并且我们已经创建了一个 Actor 实例。需要注意的是,按照惯例,我们为我们的 Actor 类创建了一个伴生对象,并在其中定义了它的props值。我们也可以通过提供一个额外的字符串参数来命名我们的 actor:

val simpleActor = simpleActorSystem.actorOf(SimpleActor.props, "simple-actor") 

这行代码给我们的 actor 分配了一个namesimple-actor标识符。现在,我们为我们的 Actor 定义的行为是空的。我们希望为我们的 actor 定义一个receive方法。让我们思考,我们的 actor 能做的最简单的事情是什么?可能是调用任何公共金融 API 来提供股票信息,或者调用任何 API 来执行货币转换;这取决于我们。在这个示例场景中,我们将调用我们的football.csv文件来获取并展示给用户信息。让我们看看如何使用 actors 来实现这一点。

首先,让我们定义一些我们可能需要的实用方法,用于将字符串响应解析为Players数据。我们有这个Players的 case 类:

case class Player(name: String, nationality: String, age:String, club: String, domesticLeague: String, rawTotal: String, finalScore: String, ranking2016: String, ranking2015: String) 

object Util { 

  def bufferedSourceToList(source: BufferedSource): List[String] = { 
      val list = source.getLines().toList 
      source.close() 
      list 
  } 

  def asPlayers(listOfPlayersString: List[String]) : List[Player] = listOfPlayersString match { 
    case head :: tail => tail map {line => 
      val columns = line.split((",")).map(_.trim) 
      Player(columns(5),columns(6),columns(9),columns(7), 
        columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 
    case Nil => List[Player]() 
  } 

} 

我们定义了两个名为bufferedSourceToListasPlayers的实用方法;这些方法做了它们所说的。因此,现在让我们定义我们的SimpleActorreceive方法:

class SimpleActor extends Actor { 
  import scala.io.Source 
  import SimpleActor.ShowFootballPlayersRequest 
  import Util._ 

  override def receive = { 
    case ShowFootballPlayersRequest(url) => { 
      val playersInfoSource = Source.fromFile(url) 

      val players = asPlayers(bufferedSourceToList(playersInfoSource)) 
      players.foreach(player => println(player + "n")) 
    } 
  } 

} 

object SimpleActor { 
  val props = Props[SimpleActor] 

  final case class ShowFootballPlayersRequest(uri: String)
 }

我们已经为特定的请求,如ShowFootballPlayersRequest,定义了接收方法,或者说SimpleActor方法的特定行为。请求本身包含了从 URI 获取信息的所需信息。我们在SimpleActor的伴生对象中定义了这个请求,并将其定义为最终的 case 类。这种传统方法指定了我们的 actor 支持的请求。在收到这样的球员信息请求后,我们的 actor 从指定的位置获取信息,然后打印球员信息。

让我们使用它。我们将使用我们的 actor 引用发送一个ShowFootballPlayersRequest类型的请求:

val fileSource = "/Users/vika/Workspace/akkaa/akka-starter/src/main/scala/files/football_stats.csv" 

simpleActor ! ShowFootballPlayersRequest(fileSource) 
!", and passed an instance of ShowFootballPlayersRequest with the file source URI. This method, let's call it bang for now, in an ideal case goes to the actor's mailbox and delivers this message. This happens in a fire-and-forget manner. There's no guarantee of message delivery at the called actor's mailbox. There might be scenarios where you expect a response from called actors; in that case instead of calling bang*,* we make an ask call to our Actors.

我们将考虑一个包含球员名字和球员列表来源的请求。例如,来源可以是任何公共 API 来获取球员信息;在我们的例子中,它是一个包含所有球员数据的简单List[Player]

我们首先想做的事情是创建一个简单的请求和响应。为此,我们可以在我们的SimpleActor伴生对象中定义这些。第一个是一个简单的请求,包含玩家的名字和我们将传递的玩家列表。第二个是一个响应容器,它只包含一个可选的玩家:

final case class GetPlayerInformationRequest(name: String, source: List[Player]) 
final case class PlayerInformationResponse(player: Option[Player]) 

现在,让我们为这种请求定义一个接收方法,类型为GetPlayerInformationRequest

import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global

case GetPlayerInformationRequest(name, listOfPlayers) => { 
  log.info(s"Executing GetPlayerInformationRequest($name, listOfPlayers)")

akka.pattern.pipe(
 Future.successful(PlayerInformationResponse(listOfPlayers.find(_.name.contains(name))))
) to sender()

}

关于这种行为的一些要点:

  • 我们使用了一个日志实现来记录与该 Actor 相关的特定信息。为此,我们使用了ActorLogging特质。只需通过以下方式混合这个特质:
    class SimpleActor extends Actor with ActorLogging
  • 当我们收到一个执行某些昂贵操作的消息时,我们执行该操作并将其封装在 future 中,并希望返回 future 的引用给调用 Actor。为此,我们使用了akka.pattern包中的pipe方法。此方法期望一个执行上下文。我们使用 pipe 或类似的语法方法pipeTo的方式如下:
    akka.pattern.pipe(someFuture) to sender() 

或者我们可以使用:

  import akka.pattern._ 
  someFuture pipeTo sender() 

这个pipepipeTo方法将响应发送回调用方。

在定义了我们的 Actor 对类型为GetPlayerInformationRequest的消息的行为后,让我们用这个消息调用 Actor。首先,我们将创建源,List[Player]

//Storing players in a collection! 
val players: List[Player] = Util 
  .asPlayers(bufferedSourceToList( 
    scala.io.Source.fromFile(fileSource) 
  )) 

向简单的 Actor 发起 ask 调用就像询问 Actor 一样简单:

simpleActor ? GetPlayerInformationRequest("Cristiano Ronaldo", players) 

现在这个?被称为ask 方法;我们使用这个方法当我们期望被调用 Actor 返回响应时。我们需要给出一个import语句来在作用域中导入这个方法:

import akka.pattern.ask 

此外,我们可能还想确保这个请求在给定的时间范围内完成。我们将通过引入一个隐式超时值来确保特定的超时持续时间:

import akka.util.Timeout 
import scala.concurrent.duration._ 

implicit val timeout = Timeout(5 seconds) 

现在,在请求成功完成后,我们可以从响应中获取值。所以,让我们这样做并打印玩家的信息:

val playerInformation = (simpleActor ? GetPlayerInformationRequest("Cristiano Ronaldo", players)) 

playerInformation 
  .mapTo[PlayerInformationResponse] 
  .map(futureValue => { 
      futureValue.player map println 
    }) 

首先,我们通过提供一个mapTo方法将响应映射到所需类型,然后从未来映射值并打印。我们使用了发送消息到单个 Actor 的fire-and-forget方式,并且使用ask方法等待了一些响应。我们还有另一种通信消息的方式,那就是使用forward方法。

告诉与询问与转发方法

我们使用这三种方法之一来从一个 Actor 传输消息到另一个 Actor。正如我们已经建立的,tell传输消息且不等待响应;这种方式确保了最多一次的交付。我们还可以在期望我们的调用actors以响应类型的一些消息响应的情况下使用ask方法。可能存在你想要将特定类型的消息与相同的 actor 引用(ActorRef)转发给另一个 Actor 的场景。为此,我们可以使用forward方法:

class AnotherActor extends Actor { 
  override def receive = { 
    case ShowFootballPlayersRequest(url) => { 
      val playersInfoSource = Source.fromFile(url) 

      val players = asPlayers(bufferedSourceToList(playersInfoSource)) 
      players.foreach(player => println(player + "n")) 
    } 
  } 
} 

object AnotherActor { 
  val props = Props[AnotherActor] 
} 

我们已经定义了 AnotherActor,并且我们可以将其作为我们的 SimpleActor 的子 actor。为此,让我们通过从 SimpleActor* 调用 context.actorOf 来实例化这个 actor。然后,在接收到类型为 ShowFootballPlayersRequest 的消息时,我们将消息转发给 anotherActor,如下面的代码片段所示:

class SimpleActor extends Actor with ActorLogging { 

  implicit val ec = context.dispatcher 
  // Works as executionContext for actor calls 

  val anotherActor = context.actorOf(AnotherActor.props) 

  override def receive = { 
    case ShowFootballPlayersRequest(url) => { 
      anotherActor forward ShowFootballPlayersRequest(url) 
    } 

    case GetPlayerInformationRequest(name, listOfPlayers) => { 
      log.info(s"Executing GetPlayerInformationRequest($name, listOfPlayers)") 

      akka.pattern.pipe(       Future.successful(PlayerInformationResponse(listOfPlayers.find(_.name.contins(name)) ))
) to sender() } } }

SimpleActor 类简单地转发消息给另一个 actor;现在,我们可以查看我们编写的整个代码并尝试运行它:

package lsp 

import lsp.SimpleActor.{GetPlayerInformationRequest, PlayerInformationResponse, ShowFootballPlayersRequest} 
import akka.actor.{Actor, ActorLogging, ActorSystem, PoisonPill, Props} 
import akka.pattern.ask 
import scala.io.{BufferedSource, Source} 
import akka.util.Timeout 
import lsp.Util.{asPlayers, bufferedSourceToList} 
import scala.concurrent.duration._ 
import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global 

class SimpleActor extends Actor with ActorLogging { 

  val anotherActor = context.actorOf(AnotherActor.props) 

  override def receive = { 
    case ShowFootballPlayersRequest(url) => { 
      anotherActor forward ShowFootballPlayersRequest(url) 
    } 

    case GetPlayerInformationRequest(name, listOfPlayers) => { 
      log.info(s"Executing GetPlayerInformationRequest($name, listOfPlayers)") 

      akka.pattern.pipe( 
        Future { 
          PlayerInformationResponse(listOfPlayers.find(_.name.contains(name))) 
        } 
      ) to sender() 

    } 

  } 

} 
object SimpleActor { 
  val props = Props[SimpleActor] 

  final case class ShowFootballPlayersRequest(uri: String) 

  final case class GetPlayerInformationRequest(name: String, source: List[Player]) 
  final case class PlayerInformationResponse(player: Option[Player]) 
} 

之前的代码是我们的主 SimpleActor 及其伴随对象。让我们看看 AnotherActor

class AnotherActor extends Actor { 
  override def receive = { 
    case ShowFootballPlayersRequest(url) => { 
      val playersInfoSource = Source.fromFile(url) 

      val players = asPlayers(bufferedSourceToList(playersInfoSource)) 

      players.foreach(player => println(player)) 
    } 
  } 

} 
object AnotherActor { 
  val props = Props[AnotherActor] 
} 

最后,我们应用程序的入口点,在这里我们启动 Actor 系统:

object AkkaStarter extends App { 
  import Util._ 

  implicit val timeout = Timeout(5 seconds) 

  val simpleActorSystem = ActorSystem("SimpleActorSystem") 
  val simpleActor = simpleActorSystem.actorOf(SimpleActor.props, "simple-actor") 

  val fileSource = 
    "/Users/vika/Workspace/akkaa/akka-starter/src/main/scala/files/football_stats.csv" 

  //simpleActor ! ShowFootballPlayersRequest(fileSource) 

  //Storing players in a collection! 
  val players: List[Player] = Util 
    .asPlayers(bufferedSourceToList( 
      scala.io.Source.fromFile(fileSource) 
    )) 

  val playerInformation = (simpleActor ? GetPlayerInformationRequest("Cristiano Ronaldo", players)) 

  playerInformation 
    .mapTo[PlayerInformationResponse] 
    .map(futureValue => { 
        futureValue.player map println 
      }) 

  simpleActor ! PoisonPill 
} 

我们还有一个包含实用方法的 Util 对象。有了这个,我们定义了 Player 案例类:

object Util { 

  def bufferedSourceToList(source: BufferedSource): List[String] = { 
      val list = source.getLines().toList 

      source.close() 
      list 
  } 

  def asPlayers(listOfPlayersString: List[String]) : List[Player] = listOfPlayersString match { 
    case head :: tail => tail map {line => 
      val columns = line.split((",")).map(_.trim) 
      Player(columns(5),columns(6),columns(9),columns(7), 
        columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 
    case Nil => List[Player]() 
  } 

} 

case class Player(name: String, nationality: String, age:String, club: String, 
                  domesticLeague: String, rawTotal: String, finalScore: String, 
                  ranking2016: String, ranking2015: String) 

运行:

[INFO] [12/27/2017 14:40:48.150] [SimpleActorSystem-akka.actor.default-dispatcher-2] [akka://SimpleActorSystem/user/simple-actor] Executing GetPlayerInformationRequest(Cristiano Ronaldo, listOfPlayers) 
Player(Cristiano Ronaldo,Portugal,32,Real Madrid,Spain,4829,4789,1,2) 

使用我们的 Actor 之后,我们应该终止其实例。

停止 actor

停止 actor 的一种方式是通过从 systemcontext 对特定 actor 调用 stop 方法。为此,我们可以定义一个特定的消息,可以传递给 actor,告诉它停止。例如:

case "terminate" => context stop self 

大多数情况下,终止 actor 的首选方式是通过向其发送 PoisonPill 消息:

simpleActor ! PoisonPill 

这种简单的消息传递可以优雅地终止 actor。终止发生在处理完 actor 队列中的所有消息之后,再处理毒药丸之前。停止一个 actor 会停止其所有子 actor。记住,我们讨论了那些在 actor 启动或终止时可以调用的钩子方法。让我们看看那些方法。

preStart 和 postStop 钩子

让我们在 SimpleActor 类中定义这些方法来记录我们的 SimpleActor 的启动和停止:

override def preStart(): Unit = log.info("SimpleActor starting!") 

override def postStop(): Unit = log.info("SimpleActor stopping!") 

运行:

[INFO] [12/27/2017 14:56:54.887] [SimpleActorSystem-akka.actor.default-dispatcher-3] [akka://SimpleActorSystem/user/simple-actor] SimpleActor starting! 
[INFO] [12/27/2017 14:56:54.915] [SimpleActorSystem-akka.actor.default-dispatcher-2] [akka://SimpleActorSystem/user/simple-actor] Executing GetPlayerInformationRequest(Cristiano Ronaldo, listOfPlayers) 
Player(Cristiano Ronaldo,Portugal,32,Real Madrid,Spain,4829,4789,1,2) 
[INFO] [12/27/2017 14:56:54.938] [SimpleActorSystem-akka.actor.default-dispatcher-2] [akka://SimpleActorSystem/user/simple-actor] SimpleActor stopping! 

类似的重启操作方法也以 preRestartpostRestart. 的形式提供。

当我们讨论通过消息进行通信时,消息被投递给其他 actor 的顺序以及消息传递的保证问题就出现了。

通过消息传递的 actor 通信及其语义

我们讨论了关于消息传递的 fire-and-forget 风格;为了更好地理解这一点,让我们看看一个解释消息传递语义的图表。

以下图表解释了消息传递的语义;当我们通过网络发送消息时,存在消息被成功投递和丢失的可能性。此外,在尝试成功或失败地投递消息的情况下,我们可能会尝试发送消息,也可能不会。这取决于我们是否想要尝试一次投递消息而不进行第二次或更多次。

基于这些假设,我们可以制定一些正式的术语来具体说明我们所讨论的内容,我们称它们为:

  • 最多一次

  • 至少一次

  • 精确一次

图表以简单的方式解释了三种方法。重要的是要知道,在演员通信的情况下,我们最多只能有一次投递;换句话说,这意味着没有保证的投递。当演员通信时,消息可能被投递给被调用的演员,也可能不会。

演员相当轻量级,可以容纳大量发送给他们的消息;当我们向演员发送消息时,消息会被投递到演员的邮箱。演员的邮箱是一个队列,在我们创建演员实例时被实例化。我们知道队列的工作方式是 FIFO,即先进先出。消息执行的顺序取决于它们到达邮箱的顺序。

到目前为止,我们的讨论都是基于通过演员实现的逻辑成功执行的可能性。但我们知道,Akka 提供了一个非常好的错误处理机制,即监督策略的形式。让我们来讨论一下。

监督我们的演员中的故障

有可能我们的逻辑最终会导致网络错误或某些意外的异常。想象一下,我们的服务需要调用特定的数据库实例来获取一些数据。我们可能会遇到连接超时或其他类似的错误。在这种情况下,我们应该怎么做?也许尝试建立连接几次会有所帮助,这可以通过以这种层次结构的方式执行任务来实现。我们可以通过在现有的演员中执行层次结构来完成任务。如果层次结构下方的某个演员失败并能够将故障通知父演员,那么基于故障的类型,父演员可以重启/杀死演员或执行所需的某些其他操作。这在某种意义上是对层次结构下方的演员进行监督;比如说,父演员可以监督子演员。我们定义这种策略的方式属于 Akka 定义的监督策略。

在某种意义上,监督是关于对演员层次结构内的故障做出反应。除了根监护人之外,每个演员都有一个父/监督者来监督。每个演员在实例化时都成为默认监督策略的一部分。观察到一个重要的现象是,故障需要单独的通道来与监督者通信。因此,Akka 有一个专门的系统级演员组,负责处理此类消息的通信。

由于我们在出现故障的情况下也会处理演员,我们的反应应该以演员特定的动作来衡量。

因此,考虑监督者可以执行的动作:

  • 恢复子演员

  • 重启子演员

  • 停止子演员

  • 升级故障

当一个监督演员遇到一个失败的子演员时,它可以执行上述描述的其中一个动作。根据我们的偏好,我们可能希望将策略应用于所有子演员,无论他们是否都失败了。也有可能只恢复/重启/停止失败的子演员。

根据应该应用监督策略的子 Actor,我们有两种策略,即OneForOneStrategyAllForOneStrategy。让我们来看看它们。

OneForOne 与 AllForOne 策略

考虑到我们有SimpleActorAnotherSimpleActoractors 的场景。SimpleActor有一个名为SimplerrrActor的子 actor:*

  • SimpleActor: /user/topLevelActor/simpleActor

  • AnotherSimpleActor: /user/topLevelActor/anotherSimpleActor

  • SimplerrrActor: /user/topLevelActor/simpleActor/simplerrrActor

在这种情况下,用户守护者将负责topLevelActor,而topLevelActor将监督SimpleActorAnotherSimpleActor. 如果SimpleActor出现问题,我们想要所有 actors 恢复/重启/停止,我们可以定义一个AllForOneStrategy. 如果我们只想对失败的SimpleActor及其后续子 actors 执行此类操作,我们可以选择OneForOneStrategy.

这两个在 Scala 中定义为案例类,它们以maxNrOfRetrieswithinTimeRangeloggingEnabled的形式接受一些参数:

case class OneForOneStrategy( 
  maxNrOfRetries:              Int      = -1, 
  withinTimeRange:             Duration = Duration.Inf, 
  override val loggingEnabled: Boolean  = true) 

case class AllForOneStrategy( 
  maxNrOfRetries:              Int      = -1, 
  withinTimeRange:             Duration = Duration.Inf, 
  override val loggingEnabled: Boolean  = true) 

第一个参数是用来指定我们可能想要在子 actor 上重试策略的次数;我们可以通过指定-1 作为数字来使其无限次数。在指定次数之后,子 actor 将停止。第二个参数指定了下一次重试应该发生的持续时间。如图所示,值Duration.Inf指定了没有任何时间窗口。最后,我们必须指定日志行为;它期望一个布尔值,默认情况下为 true,表示启用。

这两个策略类扩展了父抽象类SupervisorStrategy。这些策略如何工作可以通过下面的图解来理解:

现在,选择权在我们手中,我们可以应用任何适合我们需求/情况的策略。我们根据失败类型定义这些策略;如果我们没有覆盖到特定的失败情况,则该失败将升级到父监督 Actor。监督者执行一系列定义好的动作。

默认监督策略

默认情况下,Akka 系统会从从子 actors 接收到的失败消息中查找一些异常类型。让我们看看那些场景。

默认的监督策略将在以下情况下停止失败的子 Actor:

  • ActorInitializationException

  • ActorKilledException

  • DeathPactException

注意,在发生异常的情况下,它将重新启动失败的 Actor。

带着这些信息,让我们尝试自己实现一个策略。

应用监督策略

当覆盖默认的supervisorStrategy*时,我们只需定义带有参数的值并提供一个Decider;这个 decider 包含在发生异常时需要实现的逻辑。它看起来像这样:

import akka.actor.SupervisorStrategy.{Resume, Restart} 

override val supervisorStrategy = 
  OneForOneStrategy( 
    maxNrOfRetries = 3, 
    withinTimeRange = 1 minute 
  ){ 
    case _: ArithmeticException => { 
      log.info("Supervisor handling ArithmeticException! n Resuming!") 
      Resume 
    } 
    case _: Exception => { 
      log.info("Supervisor handling Exception! n Restarting!") 
      Restart 
    } 
  } 

在这里,我们定义了一个OneForOneStrategy,并且根据每个案例,对失败演员采取的操作。使用此策略的完整示例可能如下所示:

package example 

import akka.actor.{Actor, ActorSystem, OneForOneStrategy, Props, ActorLogging} 
import scala.concurrent.duration._ 

object SupervisionStrategyInPractice extends App { 
  val system = ActorSystem("anActorSystem") 

  val topLevelActor = system.actorOf(TopLevelActor.props) 

  //Sending StopIt 
  topLevelActor ! TopLevelActor.StopIt 
  //Sending RestartIt 
  topLevelActor ! TopLevelActor.RestartIt 
} 

class TopLevelActor extends Actor with ActorLogging { 
  import akka.actor.SupervisorStrategy.{Resume, Restart 

  import TopLevelActor._ 

  override val preStart = log.info(s"TopLevelActor started!") 
  override val postStop = log.info(s"TopLevelActor stopping!") 

  val superSimpleActor = context.actorOf(SuperSimpleActor.props) 

  override def receive = { 
    case StopIt => superSimpleActor ! SuperSimpleActor.ArithmeticOpRequest 
    case RestartIt => superSimpleActor ! SuperSimpleActor.OtherMessage 
  } 

  override val supervisorStrategy = 
    OneForOneStrategy( 
      maxNrOfRetries = 3, 

      withinTimeRange = 1 minute 
    ){ 
      case _: ArithmeticException => { 
        log.info("Supervisor handling ArithmeticException! n Resuming!") 
        Resume 
      } 
      case _: Exception => { 
        log.info("Supervisor handling Exception! n Restarting!") 
        Restart 
      } 
    } 
} 

 object TopLevelActor { 
  val props = Props[TopLevelActor] 
  case object StopIt 
  case object RestartIt 
} 

class SuperSimpleActor extends Actor with ActorLogging { 
  import SuperSimpleActor._ 

  override val preStart = log.info(s"SuperSimpleActor started!") 
  override val postStop = log.info(s"SuperSimpleActor stopping!") 

  override def preRestart(reason: Throwable, message: Option[Any]): Unit = 
    log.info(s"SuperSimpleActor restarting!") 

  override def receive = { 
    case ArithmeticOpRequest => 1 / 0 
    case OtherMessage => throw new Exception("Some Exception Occurred!") 
  } 

} 

object SuperSimpleActor { 
  val props = Props[SuperSimpleActor] 

 case object ArithmeticOpRequest 
  case object OtherMessage 

} 

如代码所示,我们有一个TopLevelActor,它向其子演员SuperSimpleActor发送消息,因此TopLevelActor成为其子演员的监督者。我们已经覆盖了supervisorStrategy。根据新的策略,我们可以根据异常类型恢复/重启。其余的示例是自我解释的。我们通过覆盖preStartpreRestart方法记录了我们的演员的启动和重启步骤。运行示例后,我们将得到记录的输出。

运行:

[INFO] [12/28/2017 13:35:39.856] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a] TopLevelActor started! 
[INFO] [12/28/2017 13:35:39.856] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a] TopLevelActor stopping! 
[INFO] [12/28/2017 13:35:39.857] [anActorSystem-akka.actor.default-dispatcher-3] [akka://anActorSystem/user/$a/$a] SuperSimpleActor started! 
[INFO] [12/28/2017 13:35:39.857] [anActorSystem-akka.actor.default-dispatcher-3] [akka://anActorSystem/user/$a/$a] SuperSimpleActor stopping! 
[INFO] [12/28/2017 13:35:39.864] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a] Supervisor handling ArithmeticException!  
 Resuming! 
[WARN] [12/28/2017 13:35:39.865] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a/$a] / by zero 
[INFO] [12/28/2017 13:35:39.867] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a] Supervisor handling Exception!  
 Restarting! 
[ERROR] [12/28/2017 13:35:39.868] [anActorSystem-akka.actor.default-dispatcher-2] [akka://anActorSystem/user/$a/$a] Some Exception Occurred! 
java.lang.Exception: Some Exception Occurred! at example.SuperSimpleActor$$anonfun$receive$2.applyOrElse(SupervisionStrategyInPractice.scala:66) 
   at akka.actor.Actor.aroundReceive(Actor.scala:517) 
   at akka.actor.Actor.aroundReceive$(Actor.scala:515) 
   at example.SuperSimpleActor.aroundReceive(SupervisionStrategyInPractice.scala:55) ... 
[INFO] [12/28/2017 13:35:39.868] [anActorSystem-akka.actor.default-dispatcher-3] [akka://anActorSystem/user/$a/$a] SuperSimpleActor restarting! 
[INFO] [12/28/2017 13:35:39.871] [anActorSystem-akka.actor.default-dispatcher-3] [akka://anActorSystem/user/$a/$a] SuperSimpleActor started! 
[INFO] [12/28/2017 13:35:39.871] [anActorSystem-akka.actor.default-dispatcher-3] [akka://anActorSystem/user/$a/$a] SuperSimpleActor stopping! 

由于这些失败是通过系统级演员传达的,因此记录的消息顺序并不重要。

通过这个例子,我们几乎涵盖了如何为我们演员实现监督策略。

Akka 库还提供了akka-testkit用于演员的测试。它包含一个构造,使得测试演员变得更加容易。让我们浏览这个库并为我们演员的实现编写单元测试用例。

测试演员

对于我们创建的演员的测试,我们可能需要考虑一些必须存在的实体。这些实体可能包括:

  • 一个测试演员系统

  • 一个testActor(消息发送者)

  • 正在测试的演员(我们想要测试其行为)

  • 在演员预期消息的情况下需要提出的断言

Akka 的test-kit库为我们提供了所有这些现成的所需实体。我们可以使用这些来测试我们的演员。让我们编写一个简单的演员测试用例。

预期情况是检查我们的GetPlayerInformationRequest是否工作正常:

package lsp 

import akka.actor.ActorSystem 
import akka.testkit.{ImplicitSender, TestKit} 
import lsp.SimpleActor.{GetPlayerInformationRequest, PlayerInformationResponse} 
import org.scalatest.{BeforeAndAfterAll, WordSpecLike} 

class SimpleActorSpec extends TestKit(ActorSystem("testActorSystem")) 
  with ImplicitSender with WordSpecLike with BeforeAndAfterAll { 

  override def afterAll(): Unit = super.afterAll() 

  val players = List(Player("Cristiano Ronaldo", "Portuguese", "32", "Real Madrid", "La Liga", "1999", "1999", "1", "1")) 

  "SimpleActor" must { 

    "test for PlayerInformationRequest" in { 

      val simpleActor = system.actorOf(SimpleActor.props) 

      simpleActor ! GetPlayerInformationRequest("Cristiano Ronaldo", players) 

      val expectedResponse = 
        PlayerInformationResponse(Some(Player("Cristiano Ronaldo", "Portuguese", "32", "Real Madrid", "La Liga", "1999", "1999", "1", "1"))) 

      expectMsg(expectedResponse) 
    } 

  } 

} 

之前的代码是我们如何编写演员测试用例的一个非常简单的示例。我们应该确保我们编写的测试用例位于测试目录中:

在我们讨论测试用例之前,让我们先运行它。要运行它,我们可以简单地使用鼠标右键点击并选择运行选项.在我们的情况下,测试用例应该通过。现在,让我们看看我们编写的案例。

首先要注意的是我们编写的声明:

class SimpleActorSpec extends TestKit(ActorSystem("testActorSystem")) 
  with ImplicitSender with WordSpecLike with BeforeAndAfterAll 

我们将我们的演员测试用例命名为SimpleActorSpec,并通过传递一个ActorSystem来扩展Testkit以进行测试目的。我们还混合了ImplicitSender,它反过来返回我们正在测试的演员SimpleActor的响应。最后,WordSpecLike和其他BeforeAndAfterAll只是为了提供类似于 DSL 的语法方法来编写测试用例。我们可以以 must 和 in 的形式看到这些语法方法。

在实现中,我们做了预期的事情,创建了测试演员的引用,并提供了玩家列表中的模拟数据。我们还创建了一个预期的SimpleActor的模拟响应。以下行将消息发送到SimpleActor,它反过来响应:

      simpleActor ! GetPlayerInformationRequest("Cristiano Ronaldo", players) 

断言部分是通过expectMsg方法处理的。在这里,我们比较了模拟响应和预期响应。为了断言目的,scala-test库提供了许多替代方案。

有了这个,我们为我们的SimpleActor.编写了一个简单的测试用例。我们已经涵盖了理解和编写 Akka.中演员所需的所有基础知识。还有一些高级配置相关主题,例如调度器、邮箱实现路由,你可能想看看。对于这些,我们首先感谢 Akka 提供的精美标准文档,可在以下网址找到:doc.akka.io/docs/akka/2.5.8/index-actors.html.

如果你仍然对接下来要做什么或者从这里开始往哪里走有疑问,让我告诉你,Akka 提供了更多。我们已经讨论了 Akka 作为一个开源库集合。这些库针对不同的问题可用。例如akka-http、流和集群等库提供了相应的解决方案。好的部分是所有这些库都是基于 actor 模型抽象的。我们已经在我们的章节中涵盖了这一点,所以让我们总结一下我们在本章中学到了什么。

摘要

这章对我们来说是 Akka 的入门。我们试图理解演员的基本底层原理。我们已经涵盖了 Akka 提供的重要库之一,akka-actors。从为什么我们需要这样的库,到理解我们在 Akka 中实现演员的方式,我们涵盖了所有内容。然后从那里,我们涵盖了 Akka 中的重要监督策略。我们讨论并实践了我们自己的自定义监督策略。最后,我们查看了一下 Akka 提供的akka-testkit测试套件。通过这个,我们涵盖了理解 Akka 演员及其基础所需的所有内容。在下一章中,我们将关注如何在 Scala 中处理并发。我们知道它在现代架构中的重要性,所以下一章将会非常精彩。

第十三章:Scala 中的并发编程

“昨天不属于我们恢复,但今天可以尝试,明天可以赢或输。”

—— 匿名

现代计算机的多核架构能够提供更好的性能这一观点是基于这样一个事实:多个处理器可以同时运行不同的进程。每个进程可以运行多个线程以完成特定任务。想象一下,我们可以编写具有多个线程同时工作以确保更好的性能和响应性的程序。我们称之为并发编程。在本章中,我们的目标是了解 Scala 在并发编程方面的提供。我们可以使用多种方式使用结构来编写并发程序。我们将在本章中学习它们。让我们看看这里将有什么:

  • 并发编程

  • 并发构建块:

    • 进程和线程

    • 同步和锁

    • 执行器和执行上下文

    • 无锁编程

  • 使用 Futures 和 Promises 进行异步编程

  • 并行集合

在我们开始学习我们可以编写并发程序的方法之前,了解底层图景非常重要。让我们开始了解并发编程,然后我们将探讨并发的基本构建块。

并发编程

这是一种编程方法,其中可以同时执行一系列计算。这些计算可能共享相同的资源,例如内存。它与顺序编程有何不同?在顺序编程中,每个计算可以一个接一个地执行。在并发程序的情况下,可以在同一时间段内执行多个计算。

通过执行多个计算,我们可以在程序中同时执行多个逻辑操作,从而提高性能。程序可以比以前运行得更快。这听起来可能很酷;并发实际上使得实现真实场景变得更加容易。想想互联网浏览器;我们可以同时流式传输我们最喜欢的视频和下载一些内容。下载线程不会以任何方式影响视频的流式传输。这是可能的,因为浏览器标签上的内容下载和视频流是独立的逻辑程序部分,因此可以同时运行。

类似地,在可能需要用户执行一些 I/O 操作以输入,同时我们想要运行程序的情况下,我们需要这两个部分同时运行。将这两个部分一起运行使其对用户交互做出响应。在这种情况下编写并发程序很有用。从运行在互联网浏览器上的非常酷的 Web 应用程序到运行在您的移动设备上的游戏,响应性和良好的用户体验都是由于并发程序而成为可能的。

这就是为什么了解并发抽象很重要,更重要的是在我们的程序实现中保持它们简单。因此,让我们来探讨并发的基本构建块。

并发构建块

Scala 是一种基于 JVM 的语言,因此用 Scala 编写的程序在 JVM 中运行。JVM,正如我们已知的,是Java 虚拟机,并在我们的操作系统中作为一个单独的进程运行。在 JVM 中,一个基本并发结构是线程;我们可以在 Scala 程序中创建/使用多个线程。因此,为了对进程和线程有一个基本理解,让我们来探讨它们。

理解进程和线程

将一个过程视为一个程序或应用,我们的计算机可能需要运行它。这个过程将包含一些可执行的代码,一个进程标识符pid),以及至少一个执行线程。这个过程可能还会消耗一些计算机资源,例如内存。每当一个过程需要消耗内存时,它会将自己与其他过程隔离开来;这意味着两个进程不能使用相同的内存块。

现代计算机具有多个处理器核心。这些核心被分配为在特定时间片中执行的程序部分。分配这些可执行部分的任务由操作系统完成。如今的大多数操作系统都使用一种称为抢占式多任务的机制,这是从所有运行进程同时执行多个可执行部分。这些可执行部分不过是线程。这意味着每个进程至少需要有一个线程,我们可以称之为主线程,以便正确运行。

很明显,操作系统内的进程使用一些内存资源,并且可以包含多个线程。现在,来自特定进程的这些线程可以自由共享分配的内存块,但两个进程不能这样做。借助以下图示,这将更容易理解:

之前的图是针对具有两个处理器核心、抢占式多任务操作系统内存的系统的简化版本。我们为运行的不同进程分配了单独的内存资源,在我们的例子中,是进程 1进程 2进程 3进程 1的内存块无法访问进程 2进程 3的内存块。每个进程包含多个线程。每个线程都可以访问从父进程分配的内存。这些线程可以共享分配的内存。现在,操作系统将这些可执行块,换句话说就是线程,分配给处理核心进行执行,正如我们在先前的图中所示。

在特定的时间片中:

  • 核心 1正在执行进程 1中的线程 1

  • 核心 2正在执行进程 3中的线程 2

这两个执行是同时发生的。我们已经知道 JVM 作为一个进程运行;我们编写的程序将具有线程作为实体。为了我们的程序运行,我们需要至少一个主线程,它可以作为我们应用程序的入口点。我们可以创建更多线程作为java.lang.Thread类的实例。

现在我们知道我们可以让应用程序的多个部分同时运行,重要的是要理解我们需要某种方式来同步它们。通过同步,我们可以确保特定的执行不会影响其他执行。进程内的线程可以访问相同的内存块,因此可能两个线程会同时尝试访问内存——这可能会引起问题。线程是 Scala 中的低级并发抽象,随着并发部分或线程数量的增加,复杂性也随之增加。为了理解我们如何限制其他线程同时访问某些代码块,首先我们需要理解同步是如何工作的。

锁和同步

我们在之前的章节中讨论了线程——我们首先尝试自己创建一些线程,然后再进一步讨论。让我们为这个目的编写一些代码:

object ThreadFirstEncounter extends App { 

  class FirstThread extends Thread { 
    override def run(): Unit = println(s"FirstThread's run!") 
  } 

  val firstThread = new FirstThread() 
  firstThread.start() 

  println(s"CurrentThread: ${Thread.currentThread().getName}") 
  println(s"firstThread: ${firstThread.getName}") 

} 

关于前面代码的一些提示:

  1. 我们简单地创建了一个扩展App的对象来创建应用程序的入口点。

  2. 我们创建了一个名为FirstThread的类,它扩展了Thread类,这实际上就是我们之前章节中提到的相同的java.lang.Thread

  3. 当我们创建一个线程时,我们可能想要指定它需要运行的内容。这可以通过重写run方法来定义。

  4. 直到第 3 点,我们已经定义了我们的线程类;现在,为了运行线程,我们将创建其实例,然后调用start方法。

  5. start方法触发线程的执行。

  6. 最后,我们打印了线程的名称。首先,是main主线程,然后是firstThread类的名称。

运行应用程序将给出以下输出:

FirstThread's run! 
CurrentThread: main 
firstThread: Thread-0 

因此,从第一次运行来看,很明显一个名为main的线程运行了应用程序,随着我们创建越来越多的线程,这些线程也进入了画面。有多个线程一起工作以执行某些计算是非常好的。我们知道从之前的讨论中,操作系统执行任务执行的调度,所以哪个线程以何种顺序执行不在我们的控制范围内。现在,考虑一个你可能想要对你的程序中的变量执行读写操作的场景。当多个线程执行此类任务时,可能会看到结果的不一致性。这意味着这个执行暴露于竞态条件;换句话说,它取决于操作系统对语句执行的调度。为了更好地理解这一点,让我们尝试我们讨论的场景:

object TowardsLocking extends App { 
  var counter = 0 // counter variable 

  def readWriteCounter(): Int = { 
    val incrementedCounter = counter + 1  //Reading counter 
    counter = incrementedCounter // Writing to counter 
    incrementedCounter 
  } 

  def printCounter(nTimes: Int): Unit = { 
    val readWriteCounterNTimes = for(i <- 1 to nTimes) yield readWriteCounter() 
    println(s"${Thread.currentThread.getName} executing :: counter $nTimes times:  $readWriteCounterNTimes") 
  } 

  class First extends Thread { 
    override def run(): Unit = { 
      printCounter(10) 
    } 
  } 

  val first = new First 
  first.start() // thread-0 

  printCounter(10)   // main thread 

} 

在这个小型应用中,我们首先创建了一个变量counter;我们将使用两个线程来读取和写入这个变量。接下来,我们有两个方法,第一个是readWriteCounter,第二个是printCounterreadWriteCounter方法正如其名。这个方法增加计数器(读取操作)并将增加后的计数器incrementedCounter赋值给counter变量。第二个方法printCounter接受一个整数参数,指定增加计数器的次数,并打印出来。

定义了所有这些之后,我们创建了一个名为First的线程并调用了我们的printCounter方法,覆盖了run方法。为了观察行为,我们应该从这个First线程和主应用程序线程中调用printCounter。由于两个线程是同时工作的,我们预期这两个线程的输出不应该包含相同的数字。我们还从应用程序中调用了printCounter作为程序的最终语句。

运行程序几次(如果你很幸运,第一次),你可能会看到一些不一致的行为。

运行:

main executing :: counter 10 times:  Vector(1, 3, 5, 7, 9, 11, 13, 15, 17, 18) 
Thread-0 executing :: counter 10 times:  Vector(1, 2, 4, 6, 8, 10, 11, 12, 14, 16) 

在两个线程的输出中,我们可以看到数字1出现了两次,而我们知道这不应该发生。我们看到这种行为是因为在我们的counter变量上通过多个线程进行的读写操作。如下所示:

def readWriteCounter(): Int = { 
    val incrementedCounter = counter + 1  //Reading counter 
    counter = incrementedCounter // Writing to counter 
    incrementedCounter 
  } 

counter = incrementCounter语句有机会执行时,counter变量被增加了两次(由多个线程)。这导致了不一致。问题出在这两个语句的执行上;这些语句必须是原子的,以提供一致的输出,其中相同的数字不能出现在不同的线程中。当我们说原子时,意味着这两个语句必须由同一个线程一起执行。

synchronized statement in Scala, using which we can implement a locking mechanism. Let's try that and see how it looks:
object TowardsLockingOne extends App { 
  var counter = 0 // counter variable 

  def readWriteCounter(): Int = this.synchronized { 
    val incrementedCounter = counter + 1  //Reading counter 
    counter = incrementedCounter // Writing to counter 
    incrementedCounter 
  } 

  def printCounter(nTimes: Int): Unit = { 
    val readWriteCounterNTimes = for(i <- 1 to nTimes) yield readWriteCounter() 
    println(s"${Thread.currentThread.getName} executing :: counter $nTimes times:  $readWriteCounterNTimes") 
  } 

  class First extends Thread { 
    override def run(): Unit = { 
      printCounter(10) 
    } 
  } 

  val first = new First 
  first.start() // thread-0 

  printCounter(10)   // main thread 
} 

在应用中,我们可以看到我们关心的方法块没有被这个synchronized语句保护:

def readWriteCounter(): Int = this.synchronized { 
    val incrementedCounter = counter + 1  //Reading counter 
    counter = incrementedCounter // Writing to counter 
    incrementedCounter 
  } 

通过使用这个,我们让 synchronized 语句指向当前对象以保护这个块。我们也可以创建某个类型的特定实例,比如说Any,这个实例可以作为我们的同步时钟的守卫。如下所示:

val any = new Any() 

def readWriteCounter(): Int = any.synchronized { 
    val incrementedCounter = counter + 1  //Reading counter 
    counter = incrementedCounter // Writing to counter 
    incrementedCounter 
  } 
volatile and *atomic variables*. These are lightweight and less expensive than synchronized statements, and better in performance. They need additional mechanisms to ensure correct synchronization when you only use volatile variables large in numbers. We should be aware that OS scheduler can also freeze any thread for any reason, which might also cause a thread carrying locks to freeze. In this case, if a thread holding a lock gets frozen, it'll block execution of other threads as well; that's not something we want for sure.

创建一个线程是一个昂贵的操作——如果你有更多的计算需要并发执行,你创建了几个线程来计算这些。这将导致性能下降,并且由于一些共享数据访问,你的生活将变得更糟。所以,为了防止这种昂贵的操作发生,JDK 提出了线程池的概念。在线程池中,提供了多个线程实例。这些线程在池中保持等待状态;当你想要执行一些计算时,我们可以运行这些线程。运行这些线程的工作由executor.来完成。让我们试着理解它。

Executor 和 ExecutionContext

执行器是一个接口,它封装了线程池,并通过其中一个线程或调用者线程本身来执行计算。执行器的一个例子是java.util.concurrent.ForkJoinPool. Scala 对这种执行器的实现是ExecutionContext,它内部使用相同的ForkJoinPool. 在进一步查看示例之前,为什么不思考一下这种Executor机制的需求呢?

作为程序员,在编写性能高效的并发应用程序时,我们可能需要处理两个主要任务,第一个是定义并发抽象的实例,比如说线程,并确保它们以正确的方式处理我们的数据/状态。第二个,在我们的程序中使用这些线程。现在,如果我们自己创建所有这些线程,它们将是:

  • 成本高昂的操作

  • 难以管理

因此,像Executor这样的机制可以免除创建这些线程的工作。我们不必明确决定哪个线程将执行我们提供的逻辑;我们也不需要管理它们。当使用执行器实现时,会创建守护线程和工作线程。当我们通过execute方法分配计算时,特定的工作线程被分配任务。关闭守护线程将关闭所有工作线程。这可以通过以下代码片段的帮助更容易理解:

import java.util.concurrent.ForkJoinPool 
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} 

object TowardsExecutor extends App { 

  val executor: ForkJoinPool = new java.util.concurrent.ForkJoinPool() 
  executor.execute(new Runnable { 
    override def run(): Unit = 
      println(s"${Thread.currentThread().getName()} printing this in execution of juc.ForkJoinPool!") 
  }) 

  val ec: ExecutionContextExecutor = ExecutionContext.global 
  ec.execute(new Runnable { 
    override def run(): Unit = 
      println(s"${Thread.currentThread().getName()} printing this in execution of sc.ExecutionContext!") 
  }) 
} 

在应用程序中,我们使用了两个Executor实现;第一个来自java.util.concurrent.ForkJoinPool,第二个类似于 Scala 特定的ExecutionContext

val executor: ForkJoinPool = new java.util.concurrent.ForkJoinPool() 
val ec: ExecutionContextExecutor = ExecutionContext.global 

对于这两种实现,我们都有一个execute方法,它期望一个Runnable实例。为了创建Runnable实例,我们必须定义一个run方法。这是创建线程实例的另一种方式。在run方法的定义中,我们只是打印了执行器线程的名称。

但是运行上述程序没有输出。这种行为的原因是两种实现都创建了一个守护线程,在第一次运行后关闭。守护线程的关闭会杀死所有工作线程。调用execute方法唤醒workerthreads. 这些workerthreads异步执行 run 方法。因此,我们将尝试通过调用Thread.sleep方法来包含一些超时,等待一小段时间作为最后一条语句:

import java.util.concurrent.ForkJoinPool 
import scala.concurrent.{ExecutionContext, ExecutionContextExecutor} 

object TowardsExecutor extends App { 

  val executor: ForkJoinPool = new java.util.concurrent.ForkJoinPool() 
  executor.execute(new Runnable { 
    override def run(): Unit = 
      println(s"${Thread.currentThread().getName()} printing this in execution of juc.ForkJoinPool!") 
  }) 

  val ec: ExecutionContextExecutor = ExecutionContext.global 
  ec.execute(new Runnable { 
    override def run(): Unit = 
      println(s"${Thread.currentThread().getName()} printing this in execution of sc.ExecutionContext!") 
  }) 

  Thread.sleep(500) 

} 

运行:

scala-execution-context-global-11 printing this in execution of sc.ExecutionContext! 
ForkJoinPool-1-worker-1 printing this in execution of juc.ForkJoinPool! 

在工作线程执行一些等待时间后,我们得到输出。如图所示,输出告诉我们线程名称:两者都是工作线程。第一个,名为scala-execution-context-global-11,来自 Scala 的ExecutionContext,第二个,名为ForkJoinPool-1-worker-1,来自 Java 的ForkJoinPool.

这些线程池及其实现成为高级并发抽象的基础。在示例中,当我们等待执行结果时,我们也遇到了一点异步。说异步包含在并发中并没有错,因为异步程序倾向于在程序的主流程之外执行。因此,可以同时执行多个异步计算;一旦我们得到这些计算的结果,我们就可以执行所需的操作。

Scala 提供了标准库中的异步编程构造,以及多个库,这些库提供了异步构造,使开发者更容易开发程序。让我们来看看这些构造。

异步编程

如果我们尝试定义异步编程,我们会得出这样一个结论:它是一种编程方法,其中计算(可以是任务或线程)在基本程序流程之外执行。在编程术语中,这些计算在不同的调用栈上执行,而不是当前的调用栈。正因为如此,我们可以同时考虑多个异步计算;我们可以等待每个计算发生,以便进行结果聚合或其他结果操作。

到目前为止,我们已经探讨了这些术语中的三个,如并发、多线程和异步。我们往往容易混淆这些概念,但根据我们的讨论,很明显异步包含并发,而不是多线程。我们知道异步可以通过调度来实现:

图片

好吧,我们必须组合同时运行的多个异步问题的结果这一事实意味着我们可能需要某种形式的同步。幸运的是,我们不必处理这些繁琐的任务,因为 Scala 的ExecutionContext提供了管理这些任务的方法。这些异步提供之一是 Scala 中的Futures。让我们来谈谈 Scala 中的Futures

使用 Futures

这里的想法很简单;我们有一个简单的程序流程。如果我们把一些复杂且耗时的计算放在主程序流程中,它将会阻塞,用户体验不会好。因此,我们希望在基本程序流程之外执行这些耗时的计算,同时在主程序流程中继续做其他事情,保持计算值(将在稍后某个时间点可用)的值。一旦值可用,我们通过某种机制使用它。我们可以这样想象这个过程:

图片

目前,我们可以想到的两个实体是:Futurecomputation 和未来的 value. 这两者是不同的;Future computation 是你想要异步计算耗时部分,而 future value 是我们依赖于程序流程的 value 引用。一旦 Future computation 在一个单独的流程中开始,程序不会停止执行,当值变得可用时,该部分才会执行。ExecutionContext 负责执行这部分,我们可能使用 value Future.

很明显,每次我们开始一些 Future computation 时,我们可能需要提供与之相关的执行上下文。在 Scala 中,Future 类型位于 scala.concurrent 包中,这个包还有 ExecutionContext 和它的执行器 ExecutionContextExecutor.

Future value 用 Future[T] 表示,其中 T 是将在某个未来的时间点可用的值的类型。因此,在我们的程序中,每当我们需要一些异步计算的结果值时,我们用这个提到的类型来表示那个值。一个例子可以澄清这一点:

import scala.concurrent._ 
import scala.concurrent.ExecutionContext.Implicits.global 

object FutureExample extends App { 

  val fileSource = "/Users/vika/Documents/LSProg/LSPWorkspace/FirstProject/src/chapter5/football_stats.csv" 

  def listOfPlayers(): Future[List[Player]] = Future { 
    val source = io.Source.fromFile(fileSource) 
    val list = source.getLines().toList 
    source.close() 
    giveMePlayers(list) 
  } 

  println(s"listOfPlayers completed: ${listOfPlayers.isCompleted}") 

  Thread.sleep(500) 

  println(s"listOfPlayers completed: ${listOfPlayers.isCompleted}") 

  def giveMePlayers(list: List[String]): List[Player] = list match { 
    case head :: tail => tail map {line => 
      val columns = line.split((",")).map(_.trim) 
      Player(columns(5),columns(6),columns(9),columns(7), 
        columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 
    case Nil => List[Player]() 
  } 

} 

case class Player(name: String, nationality: String, age:String, club: String, domesticLeague: String, rawTotal: String, finalScore: String,ranking2016: String, ranking2015: String) 

运行:

listOfPlayers completed: false 
listOfPlayers completed: true 

在这个简单的应用程序中,我们指定了一个包含一些内容的文件。这个文件有关于几个足球运动员的信息。现在,为了读取文件内容以及解析和编码成 Player 实例可能需要一些时间,因此我们决定将 load parseencode 步骤作为一个 Future computation,结果值将是一个类型为 Future[List[Player]]. 的 Future value。

现在,在定义了这样的计算之后,我们检查了计算是否完成。然后我们等待一段时间,再次尝试检查它是否完成。运行应用程序给我们的是 false 和然后 true。如果我们通过图表来思考这个例子,流程可能看起来像这样:

图片

通过这张图,我们可以很容易地理解执行流程;在计算进行期间,isCompleted 标志保持为 false。完成后,它被设置为 true。之后,我们可以使用 future 的值,但在这个例子中,我们没有使用这个值;此外,也出现了如何使用它的疑问。我们是否需要再次检查值是否可用?这听起来很糟糕,所以另一种方法是为此异步计算注册一个 回调

好的,什么是回调?为了回答这个问题,让我们首先扩展我们的程序来为我们的 Future computation 注册一个:

import scala.concurrent._ 
import scala.concurrent.ExecutionContext.Implicits.global 

object FutureExample extends App { 

  val fileSource = 
"/Users/vika/Documents/LSProg/LSPWorkspace/FirstProject/src/chapter13/football_stats.csv" 

  val listOfPlayers: Future[List[Player]] = Future { 
      val source = io.Source.fromFile(fileSource) 
      val list = source.getLines().toList 

      source.close() 

      giveMePlayers(list) 
  } 

  def giveMePlayers(list: List[String]): List[Player] = list match { 
    case head :: tail => tail map {line => 
      val columns = line.split((",")).map(_.trim) 
      Player(columns(5),columns(6),columns(9),columns(7), 
        columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 
    case Nil => List[Player]() 
  } 

  // Registering a callback 
  listOfPlayers foreach { 
    case list => list foreach println 
  } 

  Thread.sleep(5000) 

} 

case class Player(name: String, nationality: String, age: String, club: String, domesticLeague: String, rawTotal: String, finalScore: String,ranking2016: String, ranking2015: String) 
foreach function, on Future value. This is exactly what we mean when we say registering a *callback.* When the value of Future computation becomes available, it gets executed. That's going to work only if we get some value out of our Future computation. But we should be aware of the fact that the computation might fail. In that case, this callback is not going to be executed. The task of callback execution is taken care of by the execution context.

值得注意的是,回调是我们处理异步计算结果的一种方式。同时,我们知道我们需要提供一个执行上下文,该上下文管理计算何时何地发生以及 回调 何时执行。这允许我们为单个异步计算注册多个回调,并且执行顺序是随机的。回调的随机执行可以通过之前图表的扩展版本来解释;现在,图表中也有回调了:

回调执行仅在 Future 计算完成后发生,如图所示。只有当计算成功完成时,才会执行 回调。在其他情况下,应该有一种方法让程序知道出了问题,这样我们就可以对它进行处理。让我们看看我们能做些什么来解决这个问题。

如果 Future 计算出错怎么办?

Future 计算可能成功,产生一个值,或者失败,最终抛出异常。我们需要一个机制来处理这两种情况。Scala 的 Future 通过一个方法,onComplete. 首先让我们看看实际应用;为了做到这一点,让我们注释掉上次添加的回调代码片段,并添加以下片段:

listOfPlayers onComplete { 
  case Success(list) => list foreach println 
  case Failure(_) => println(s"listOfPlayers couldn't be fetched.") 
} 
onComplete*,* which gets called once the Future's value is available; in other words, when Future gets completed. Let's take a look at the onComplete method's signature for a Future[T]:
def onCompleteU(implicit executor: ExecutionContext): Unit 

函数期望一个要执行的功能;我们还需要提供执行上下文。函数字面量是类型 Try[T] => U。幸运的是,执行上下文来自隐式作用域。因此,我们可以直接提供部分函数来执行;在我们的情况下,我们提供了相同的。现在,有一种可能性是一个异步调用依赖于另一个异步调用的结果,在这种情况下,我们可能需要执行回调函数的嵌套。这可能会看起来像这样:

import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global 
import scala.util.{Failure, Success} 

object TowardsFutureComposition extends App { 

def firstFuture: Future[String] = Future { "1" } 
def secondFuture(str: String): Future[Int] = Future { str.toInt } 

  firstFuture onComplete { 
    case Success(value1) => 
         secondFuture(value1) onComplete { 
      case Success(value2) => println(s"Converted int: $value2") 
      case Failure(exception) => println(s"Conversion failed due to ${exception.getMessage} ") 
    } 
    case Failure(excep) => Future.failed(excep) 
  } 

  Thread.sleep(5000) 
} 

在之前的代码中,我们只有两个嵌套的 Future,以及 回调,这已经看起来应该以更简单的方式进行。现在考虑更多这样的 Future 和回调组合。这将是一场回调地狱。因此,这里需要的是组合。这正是 Scala Futures 的一个强大功能;你可以组合两个 Future 来执行一些包含回调嵌套的复杂逻辑。我们该如何做到这一点?通过使用 Scala Future API 给我们的高阶函数集合。让我们来看看。

为什么不组合两个或更多的 Future?

现在我们已经得到了之前的玩具示例,其中我们按顺序调用了两个 Future,让我们在这两个 Future 上进行组合。我们将首先在 firstFuture* 上调用 flatMap 函数,这将给我们一个值。我们将使用这个值来调用 secondFuture*。最后,我们将调用 map 函数来执行打印操作:

object FutureComposition extends App { 

  def firstFuture: Future[String] = Future { "1" } 

  def secondFuture(str: String): Future[Int] = Future { str.toInt } 

  firstFuture flatMap( secondFuture(_) ) map(result => println(s"Converted int: $result")) 

  Thread.sleep(5000) 
} 

运行:

Converted int: 1 

整个回调逻辑神奇地消失了,我们使用了 Future 组合来实现同样的效果。所有神奇的事情都发生在这行代码中:

firstFuture flatMap( secondFuture(_) ) map(result => println(s"Converted int: $result")) 

因此,让我们借助图表来尝试理解这一点:

如所示,星号代表 Future;我们首先获取第一个 future,并在其上调用flatMap函数。flatMap函数的签名如下:

def flatMapS(implicit executor: ExecutionContext): Future[S] 

将签名与图进行比较,我们可以看到flatMap函数接受 future 值并调用后续调用以获取另一个 Future。flatMap函数的输出恰好是另一个 Future 值,因此我们调用一个map函数从 future 中提取值,然后我们可以执行我们想要的任何操作;在我们的例子中,我们只是打印了值。根据我们之前的知识,我们知道 comprehension 作为语法上的技巧,可以用于我们的flatMapmap调用。因此,以下代码也适用于我们的 future 组合:

for { 
  value1 <- firstFuture 
  value2 <- secondFuture(value1) 
} yield println(s"Converted int: $value2") 

需要注意的是,在我们的 for comprehension 中,第二个语句只有在第一个值value1可用时才会执行。这让我们可以在第二个语句中使用第一个值,如示例所示。所以,关于 future 组合就到这里。这种机制让我们可以将多个 future/async 调用串联起来。这种组合使得 Scala 的 Future 功能强大。

因此,我们刚刚讨论了通过创建 future 对象来创建 Future 计算的方式;了解 Scala 也提供了一种将特定值赋给此 future 对象的方法是有价值的。这种机制以 Promise 的形式存在。让我们来了解一下 Scala 的 Promise。

与 Promise 一起工作

正如我们之前讨论的,Promise 用于将值赋给 future 对象。Promise 本身是一个与特定 future 对象相对应的对象。我们可以通过在相应的 Promise 上调用future方法来访问这个future对象。让我们首先创建一个 Promise 对象:

import scala.concurrent._ 
import scala.concurrent.ExecutionContext.Implicits.global 

object ItsAPromise extends App { 

  val firstPromise = Promise[String] 

  for { 
    value1 <- firstPromise.future 
  } yield println(s"Value1: $value1") 

  firstPromise.success("1") 

  Thread.sleep(500) 
} 

运行:

Value1: 1 

在之前的代码中,我们通过简单地调用Promise.apply方法创建了一个Promise实例:

def apply[T](): Promise[T] 

在这里,apply方法没有参数,因此Promise实例本身不包含任何值;可以通过 Promise API 中可用的一种方法将值赋给此对象。successfailurecomplete等方法用于将值赋给Promise实例。可以通过调用future方法获取每个Promise实例对应的 Future。在我们的例子中,我们在Promise对象上调用success方法将值赋给关联的 future。我们还使用了它来获取 future 的值并打印出来。运行此程序将产生通过此success调用传递的结果:

  firstPromise.success("1") 

我们也可以通过调用failure方法将一个失败对象赋值给关联的 future。以下是一些需要注意的点:

  • 调用Promise.apply方法创建了一个不带值的实例,就像我们为 Futures 所做的那样

  • Promise 不会启动任何异步计算

  • 每个 Promise 只对应一个Future对象

  • 每个 Promise 对象只能被赋予一个值

  • Promise 提供了一种将值赋给Future对象的方法

这些点阐明了 Promise 的概念,并给我们关于 Scala 中 Future API 实现的提示。

Futures 和 Promises 为我们程序中的低级异步构造提供了一个简单的抽象。我们已经看到了我们可以如何使用和组合这些 Futures 来链式调用多个异步调用以完成任务。Scala 中还有其他异步库可用于执行异步编程。这些库的一些示例是scala-async(github.com/scala/scala-async))和monix(github.com/monix/monix)).您可能想查看这些库以了解和尝试其他异步编程结构。

可能存在一些用例,需要操纵大量数据以执行某些逻辑。让我们以我们的football.csv文件为例。我们已经读取了数据,并将这些行转换为List[String],现在每个元素都可以解析为Player对象,从而得到List[Player]。如果我们稍微思考一下,将String解析为Player的步骤不需要按顺序执行,可以并行完成。现在,Scala 提出了并行集合的概念。因此,如果您需要在某些集合上执行某些功能,功能可以并行执行。您可以通过在常规集合上调用简单的par方法将集合转换为它的并行对应物。让我们看看 Scala 中的并行集合,并尝试一下。

并行集合

好吧,在讨论 Scala 中的并行集合之前,了解什么是并行计算是很重要的。它与并发和异步有何不同?

好吧,我们已经花了一些时间来理解异步计算是非阻塞的,因此我们知道异步计算发生在主程序流程之外,一旦计算完成就会给出值。为了理解并发和并行计算之间的区别,让我们看看以下示例:

图片

在这个示例中,我们有一个数字集合,我们想要对集合中的每个元素应用一个函数来得到一个新的集合。一种方法是从起始集合中取出一个值,加一,然后将这个值放入一个新的集合中,直到第一个集合为空。现在,通过引入两个线程来执行对集合中元素加一的任务,这个过程可以变得更快;让我们换一种说法,说我们可以创建两个线程来使我们的集合能够进行并发访问:

图片

另一种方法是将集合分解为两个子集合,并并行执行添加任务。这种并行性是可能的,因为我们执行的操作与起始集合中元素的顺序无关,也不依赖于集合中的任何其他元素。因此,操作可以以独立的方式并行执行。这就是并发计算和并行计算之间的区别。语义本身解释了并行性是否适用:

图片

这成为了 Scala 中 并行集合 的基础。让我们用我们熟知的例子来尝试,即 football.csv 文件。我们将 List[String] 转换为其并行对应版本,然后在并行中执行解析逻辑:

import scala.collection.parallel.immutable.ParSeq 
import scala.concurrent.Future 
import scala.util.{Failure, Success} 
import scala.concurrent.ExecutionContext.Implicits.global 

object TowardsParallelCollections extends App { 

  val fileSource =    "/Users/vika/Documents/LSProg/LSPWorkspace/FirstProject/src/chapter13/football_stats.csv" 

  val listOfPlayers: Future[List[Player]] = Future { 
    val source = io.Source.fromFile(fileSource) 
    val list: List[String] = source.getLines().toList 

    source.close() 

    val parSequence = list.par.tail 

    val playerParSequence: ParSeq[Player] = parSequence.map { 
      case line => val columns = line.split((",")).map(_.trim) 
        Player(columns(5),columns(6),columns(9),columns(7), 
          columns(8),columns(10), columns(12), columns(0),columns(2)) 
    } 

    playerParSequence.toList 
  } 

  listOfPlayers foreach { 
    case list => list foreach println 
  } 

  Thread.sleep(5000) 

} 

在示例中,我们将 List[String] 转换为 ParSeq,它是 Scala 集合 List 的并行对应版本. 在转换为并行集合后,我们在并行集合上调用 map 方法并执行解析操作。并行集合 API 非常一致,以至于调用 map 方法执行一些操作看起来很正常,但底层任务执行是由多个处理器同时处理的;换句话说,计算是在并行进行的。运行前面的代码将按预期打印出球员列表。

Scala 的并行集合位于 scala.collection.parallel 包中。要创建一个并行集合,我们可以使用与集合名称一起的新关键字,或者我们可以通过调用 par 函数将顺序集合转换为它的并行对应版本,就像我们在示例中所做的那样。

可用的几个并行集合包括:

  • ParArray

  • ParVector

  • immutable.ParHashMap

  • immutable.ParHashSet

  • immutable.ParHashMap

  • immutable.ParHashSet

  • ParRange

  • ParTrieMap

我们可以像对顺序集合那样实例化这些并行集合;这是 Scala 并行集合的力量。这使得许多基于集合的计算执行速度更快。有了这个,我们可以总结我们的章节。

摘要

在本章中,我们学习了 Scala 中并发的基础构建块。了解操作系统中 JVM 的并发底层块非常令人着迷。我们学习了进程和线程之间的区别。我们讨论了 ExecutionContext 以及为什么我们需要它。然后,我们讨论了使用 Future 和 Promises 的异步编程。最后,我们讨论了 Scala 中的并行集合。

在下一章中,我们将讨论 Scala 中另一个重要且广受讨论的响应式编程抽象。我们将了解 Scala 中的响应式扩展。

第十四章:使用响应式扩展进行编程

“我们不能用创造它们时使用的相同思维方式来解决我们的问题。”

  • 阿尔伯特·爱因斯坦

当我们在解决问题时赋予思考新的维度时,这是美丽的。在编程中,我们解决问题的方法可能各不相同。大多数时候,用户和程序之间存在交互。基于 GUI 和 Web 应用程序是这些应用程序的几个例子。我们可以思考我们的应用程序在用户尝试与之交互时如何被通知。可能我们的程序等待用户的交互,反之亦然。在相反的情况下,用户的交互有时会通知应用程序某种事件。让我们假设我们编写程序以对用户的交互做出反应。这就是作为应用程序程序响应式的本质。我们编写一个作为周围环境系统的程序,环境本身将事件推送到我们的程序。这些事件驱动整个系统,因此它们成为响应式编程模型的核心。而这只是响应式开始的起点,还有更多从这种模型中产生的概念。

从上一章,我们已经得到了异步计算的核心。异步计算包括某种将在不久的将来执行的计算及其回调,该回调在计算完成时执行。

好吧,在本章中,我们将继续理解异步计算,以了解响应式扩展,这实际上是在考虑到响应式编程模型时创建的一个 API。以下是我们将讨论的内容:

  • 响应式编程

  • 响应式扩展

  • 对 RxScala 的反应

响应式编程,一句话概括,就是使用异步数据流进行编程的过程。如果这不是你预期的定义,让我们试着了解一下它。让我们把它想象成一段旅程。我们将从零开始,对编程术语中的响应式一无所知。随着我们的前进,我们将与一些形成响应式生态系统的概念和机制进行互动。最终目标是能够以响应式的方式思考。那么,让我们开始吧。

响应式编程

理解任何新的范式或概念的最佳方式是推理其存在。为此,让我们考虑一个非常常见且简单的场景,如下所示:

你可能已经遇到了前面图中展示的表单行为。我们有一个协议去阅读它,然后我们点击复选框来同意它,这样我们才能继续前进。在左侧,复选框是未勾选的。因此,基于它的值,我们将继续按钮(目前为未勾选的框)的可见性设置为 false。一旦我们点击并设置复选框的值为 true,继续按钮的可见性也变为 true。所发生的是,当我们点击复选框时,它会发出一个带有其值的(即 true/false)事件。现在,基于它的值,我们可以设置按钮的可见性为 true/false。这是一个观察简单复选框事件并利用事件值的例子。如果你想象一下,它可能看起来如下所示:

下面的图显示了复选框状态和按钮的可见性。我们不需要关心代码语法,因为我们现在不讨论任何语言或框架。在这个例子中,我们只关心复选框触发的单个事件。这很简单理解。现在让我们考虑一个反应性的计算。考虑以下计算:

A := 5 
B := A 
C := A 
D := B + C 

这个计算表明A将有一个值,BC将具有与A相同的值,最后,D将是BC相加的结果。所以在常规程序中,一旦你执行了语句,你就能得到ABCD的值。现在,让我们考虑当我们考虑时间和允许值在我们的系统中流动时会发生什么。我们这是什么意思?看看下面的图:

在左侧,我们描绘了与之前提到的相同的场景。在右侧,有一个表格,它包含了一些时刻我们的变量的值。所以让我们说我们将A的值更新为 6:

A := 6 

现在,这个语句应该增加BC的值,因为它现在处于反应性世界中。所以当数据变化时,它会流动并改变所有依赖变量。因此,BC被更新为6,最终由于依赖项的变化,D的值也发生变化,变为 12。从表中可以看出,B、C 和 D 的值在它们观察到A的值变化时都会更新。到目前为止,我们还没有处理任何特定的术语。但我们看到了两个例子:第一个例子给出了发射事件的本质,第二个例子给出了系统包含变量中的数据流的本质。这些都是反应式编程的两个重要概念。但是等等,我们还提到了反应式编程是使用异步数据流进行编程。为了理解这个概念,考虑A变量的值作为数据流中的值。现在,在某个特定的时间点,如果我们尝试访问A的值,有三种可能性:

  • 你会得到一个返回值

  • 当你尝试获取值时会出现错误

  • 你会收到一个消息,表明流已完成且不再包含更多值

想象一下,我们会得到以下类似的内容:

之前的图展示了数据流的可能结果。以下是从前面的图中可以提取的一些重要点:

  • 所有值都是时间中发生的事件。因此,这些值的顺序将得到保持。在流完成后,没有可能从流中发出任何值。

  • 时间扮演着重要的角色;这是为了保持事件顺序。

  • 在从流中访问值时可能会发生错误。

  • 此外,由于这些事件是相对于时间发生的,我们可以将这些计算异步化,并将一些回调绑定到某些特定事件上。我们还可以根据值过滤掉一些事件。

这类反应式系统根据时间和事件进行反应。我们还可以将这些流与我们在上一章中讨论的承诺进行比较。承诺负责一个将在未来可用的值。而流包含多个异步评估的返回值。这些都是反应式编程的构建块。这个范式为这样的流有自己的术语,这些术语被称为Observables. 有各种 API 可供创建这样的可观察流,并允许我们转换、过滤和合并这些可观察流,以创建一个数据流动的反应式系统。

这些 API 是 ReactiveX 的一部分,被称为反应式扩展。对于多种语言,都有可用的异步流 API,以下是一些例子:

  • RxJava

  • RxJS

  • Rx.NET

  • RxScala

  • RxLua

  • RxGo

  • RxDart

所有这些 API 都服务于相同的目的,即在其各自平台上提供异步的 Observable 流。这些 API 围绕事件和流的原理展开。让我们来看看这些反应式扩展提供了什么。

反应式扩展

所有这些 API 都服务于相同的目的,即在其各自平台上提供异步的 Observable 流。这些 API 围绕事件和流的原理展开。让我们来看看这些反应式扩展提供了什么。是时候看看我们刚刚介绍给自己的术语了,那就是 Observables.

可观察对象是发出事件流的实体。事件可以是按钮点击或检查复选框,或者通过接口提供的其他事件,例如向设备提供输入。必须有一个观察者来对可观察对象发出的任何值或值的序列做出反应。观察者通过订阅可观察对象来对事件做出反应。因此,这些观察者订阅可观察对象以对事件做出反应是 ReactiveX 的主要主题。在为不同平台提供的任何库中,这个过程在某种程度上与以下类似:

  • 我们定义了 Observable,它负责发出事件。这将以异步方式进行,因为事件可能依赖于某些环境/周围环境,例如用户的交互或某些条件成功。

  • 我们定义了一个方法,它使用异步计算返回的值进行一些操作。这个方法是观察者的一部分。

  • 我们通过订阅观察者附加到可观察对象上。

通过将Observer订阅到 Observable,我们指定现在机制已经到位,我们的观察者已经准备好对事件做出反应。基于这种现象,可观察对象被分为热和冷可观察对象。

  • 热可观察对象:这些在创建时立即开始发出事件,因此如果在稍后的某个时间点我们将观察者附加/订阅到它,观察者将继续从当前时间点对事件做出反应。

  • 冷可观察对象:另一方面,这些在观察者订阅它们时开始发出事件。通过这种方式,我们的观察者从事件开始就做出反应。

此外,当我们从流中观察事件时,我们已经看到可能存在错误或流完成的可能。因此,对于 Observer[T],我们提供了以下方法:

  • onNext(T)

  • onError(Exception)

  • onComplete()

观察者onNext方法的每次调用都伴随着来自事件的值。如果你创建了一个String类型的观察者,那么onNext方法将给你一个字符串值。在典型的响应式编程场景中,对onNext方法的调用会一直持续到事件流完成或发生某些错误;在这些情况下,会调用onCompleteonError。在这些方法之后,我们不会从该订阅中获得任何值。

如果你还记得,当我们讨论集合时提到的可迭代对象,这些可迭代对象的工作方式在某种程度上是相似的,唯一的区别在于可观察对象在本质上是异步的。可迭代对象按顺序包含数据,我们可以一次获取一个值并对它们执行一些操作。要从这样的可迭代对象中获取数据,我们创建一个迭代器。调用iterator.next()让我们可以访问可迭代对象中的值。可迭代对象允许你以同步方式访问一系列值:

图片

这两种之间的区别在于它们的本质:Observables 是异步的,而 Iterables 则不是。看一下这两个接口,我们可以看到以下内容:

我们知道 Observables,但使它们强大的是一系列 实用 方法,这些方法被称为 算子。这些算子允许我们 创建、组合、转换、过滤转换 Observable*s。我们可以将这些算子链接起来,以特定的顺序执行这些操作。

既然我们已经确定了理论上的东西,现在是时候尝试使用 Scala 在反应式世界中的提供,即 RxScala 来应用你所学的知识了。让我们看看 RxScala API 如何表示 Observables。

反应式地使用 RxScala

RxScala 是 Scala 中提供的一个反应式异步流 API。它让我们执行我们讨论的所有概念,例如创建 Observables、观察者以及 订阅。我们有各种各样的算子可供我们操作这些 Observables。大多数算子返回 Observables,因此 Observables 的链式操作也是可能的。

要开始编写一些 Observables,我们首先需要设置空间,以便库对我们可用。为此,让我们遵循以下步骤:

  1. 在你最喜欢的互联网浏览器中,打开 developer.lightbend.com 并点击 START A PROJECT 按钮。

  2. 你将得到一些选项;只需选择 Scala 并点击 CREATE A PROJECT FOR ME! 按钮。它将以压缩格式下载源代码:

  1. 我们可以提取它并在 IntelliJ IDE 中打开。

  2. 一旦在 IntelliJ 中打开,我们就可以在构建文件中添加 RxScala 的库依赖项。这可以通过指定以下内容来完成:

     libraryDependencies += "io.reactivex" %% "rxscala" % "0.26.5" 
  1. 在指定依赖项后,我们可以从命令行执行 sbt update 命令这将下载依赖文件。我们应该从这个指定 build.sbt 的目录中进行此更新。

现在,我们已经准备好编写 Observables 了。那么,还等什么呢?让我们开始编写一些。

创建 Observables

对于创建 Observables,有如 justemptyintervalfromdefer 等一系列算子。让我们首先编写一些代码来以不同的方式创建 Observables:

package example 

import rx.lang.scala.Observable 
import scala.concurrent.Future 
import scala.concurrent.ExecutionContext.Implicits.global 
import scala.concurrent.duration._ 

object FirstRxApp extends App { 

  //Creates an empty Observable. 
  val emptyObservable = Observable.empty 

  //Creates an Observable that only emits 1\. 
  val numObservable = Observable.just(1) 

  val sequence = List(1, 3, 5, 7, 9) 

  //Creates an Observable, which emits values from the sequence mentioned. 
  val sequenceObservable = Observable.from(sequence) 

  val someAsyncComputation = Future { 1 } 
  //Creates an Observable, from an async computation 
  val fromAsyncObservable = Observable.from(someAsyncComputation) 

  //Creates an Observable, which emits items at a duration gap specified. 
val intervalObservables = Observable.interval(200 millis) 

  //Creates an Observable, which starts emitting, once some observer subscribe to it. 
  val deferObservable = Observable.defer(fromAsyncObservable) 

  //Creates an Observable, which never emits any value. 
  val neverObservable = Observable.never 

} 

在前面的代码片段中,我们只创建了 Observables,并且没有对它们进行订阅。因此,即使它们发出一些值,我们也没有机制来对这些值做出反应。首先让我们看看所写的代码:

  1. 第一条语句创建了一个空的 Observable,这意味着它里面没有任何值。

  2. 第二条语句调用了一个名为 just* 的算子,它接受一个值并将其包装在 Observable 上下文中。因此,当这个 observable 发出时,它只会发出我们指定的值。

  3. 接下来,我们使用名为 from. 的方法向我们的可观察对象提供了一系列值。

  4. from 方法还可以接受一个未来值并创建一个 Observable。这个 Observable 会发出 Future 中指定的异步计算的结果。

    def fromT(implicit execContext: ExecutionContext): Observable[T] 
  1. 然后是interval方法,它期望我们提供发射值的间隔。

  2. 最后,我们还有两种指定的方式。首先,我们使用了名为defer的算子,它将byname参数作为另一个可观察者,并且只有在观察者订阅它之后才开始发射事件:

    def deferT: Observable[T] 

使用这些算子,我们能够创建可观察者*。现在让我们通过调用subscribe方法将这些可观察者附加到订阅上。为此,我们可以在我们的 Scala 文件中添加以下片段。首先,让我们添加一些导入:

import rx.Subscription 
import rx.observers.TestSubscriber 

接下来,我们在创建可观察者之后添加以下片段:

//Subscribing to Observables 
emptyObservable 
  .subscribe(value => println(s"From emptyObservable: $value")) 

numObservable 
  .subscribe(value => println(s"From numObservable: $value")) 

sequenceObservable 
  .subscribe(value => println(s"From sequenceObservable: $value")) 

fromAsyncObservable 
  .subscribe(value => println(s"From fromAsyncObservable: $value")) 

intervalObservables 
  .subscribe(value => println(value)) 
Thread.sleep(1000) 

new TestSubscriber[Subscription].awaitTerminalEvent(1000, MILLISECONDS) 

deferObservable 
  .subscribe(value => println(s"From deferObservable: $value")) 
subscribe method by passing what to do with the value. Take a look at the subscribe method's signature:
def subscribe(onNext: T => Unit): Subscription 

该方法接受值并对其执行操作,或者说对其做出反应。它返回一个Subscription实例。这个Subscription参数的优势在于我们可以通过调用名为unsubscribe的方法来取消订阅*。

intervalObservables的情况下,我们还需要提供Thread.sleep(1000),这样我们的intervalObservables类型就有时间发射值。如果我们不使用某种机制等待这些发射,线程将被杀死,我们就看不到发射的值。

这些是我们创建可观察者并订阅它们的一些方法。但这更多的是熟悉 API。我们可能想看看一些示例,以展示这种响应式模式的使用。

让我们考虑一个场景。假设用户必须填写一个包含关于足球运动员信息的表单。他必须提供球员的名字、年龄、国籍和联赛。填写完信息后,用户将按下提交,然后表单数据就会交给我们处理:

图片

现在轮到我们为玩家的信息提供数据流。通过这种方式,我们指的是,有了具体的信息,我们可以创建一个玩家的实例,验证信息,以某种特定的格式显示信息,或者做任何你能想到的事情。对于每个表单提交,流程都是一样的。在这种情况下,我们可以为事件创建一个可观察者,让我们称它为表单提交事件。订阅此事件的观察者将获取每个表单提交的数据。然后我们可以定义onNext来以我们想要的方式操作表单数据。无论用户输入信息或按下提交按钮的速度有多快,我们都有 Observable 系统在位,它会通过事件的触发来接收通知。然后这个过程就开始了。

现在,为了在实际情况中看到这一点,让我们假设数据源是我们最喜欢的 CSV 文件,而不是有人为我们填写表单,看看代码可能是什么样子:

package example 

import rx.lang.scala.Observable 

object SmartApp extends App { 

  val src = 
   "/Users/vika/Documents/LSProg/LSPWorkspace/First_Proj_Rx/src/main/scala/example/football_stats.csv" 

  val playerObservable = 
    Observable.from(PlayerService.readPlayerDataFromSource(src)) 

  playerObservable 
    .map(playerString => 
    PlayerService.parseToPlayer(playerString)) 
    .subscribe(player => PlayerService.showPlayerInformation(player), 
    error => println(s"Error Occurred: ${error.getMessage}")) 

  Thread.sleep(10000) 
} 

在前面的代码中,将 playerObservable 类型视为从某些事件源创建。它的类型是 String,在我们的例子中,它应该包含以字符串格式表示的玩家信息。现在我们应该对字符串做什么,就是将这个字符串信息解析为 Player 实例。readPlayerDateFromSourceparseToPlayershowPlayerInformation 方法是另一个名为 PlayerService 的对象的一部分:*

package example 

import scala.io.BufferedSource 
import scala.util.{Failure, Success, Try} 

object PlayerService { 

  def readPlayerDataFromSource(src: String): List[String] = { 
    val source: BufferedSource = io.Source.fromFile(src) 
    val list: List[String] = source.getLines().toList 

    source.close() 
    list 
  } 

  def parseToPlayer(string: String): Option[Player] = { 
    Try { 
      val columns = string.split((",")).map(_.trim) 
      Player(columns(5), columns(6), columns(9).toInt, columns(7)) 
    } match { 
      case Success(value) => Some(value) 
      case Failure(excep) => None 
    } 
  } 

  def showPlayerInformation(playerOp: Option[Player]): Unit = { 
    playerOp.map { player => 
      println("------------ Here's our Player Information ----------- ") 
      println(s"Name: ${player.name}") 
      println(s"Age: ${player.age} | Nationality: ${player.nationality} | League: ${player.league}") 
      println 
    } 
  } 

  case class Player(name: String, nationality: String, age: Int, league: String) 

} 

让我们看看我们案例中操作符的魔力。第一个是 map 本身,它接受一个玩家字符串,并调用 parseToPlayer

playerObservable 
    .map(playerString => 
      PlayerService.parseToPlayer(playerString)) 

如果你尝试推断其结果类型,它只是 Observable[Option[Player]],所以没有变化;它仍然是一个可观察对象。我们只是在可观察对象世界中进行了一次转换并得到了一个结果。之后,我们订阅了可观察对象并调用了我们想要的 showPlayerInformation(player) 方法:

.subscribe(player => PlayerService.showPlayerInformation(player), 
    error => println(s"Error Occurred: ${error.getMessage}")) 

这本身就很令人惊讶,因为系统是为特定事件设置的,在我们的例子中是 表单提交 事件,它给我们 Observable[String]。多亏了我们假设的用户,他输入信息并提交,这样系统就能得到一个字符串来操作。

现在为了再次澄清画面,我们将借助以下图:

如前图所示,目前,只有一个玩家信息源附加,整个管道工作良好。现在考虑另一个场景,我们的假设用户也可以从一些建议输入中选择一个玩家。那会是什么样子?

在这种情况下,我们可能会得到整个玩家信息作为 Option[Player] 而不是字符串。因此,在这种情况下工作的可观察对象将是 Observable[Option[Player]] 类型。现在,如果我们想执行与表单提交情况相同的逻辑,我们只需要使用合并操作符。这将合并可观察对象,因此我们将能够实现我们想要的结果。重要的是要在正确的位置合并我们的新可观察对象。从图中,我们得到一个提示,如果我们像以下图所示合并 observable[Option[Player]],将会更好:

看看前面的图。正如你所见,在最左侧,建议输入表单给我们 Observable[Option[Player]],我们将其与我们的转换后的相同类型的可观察对象合并。我们可以在我们的代码中做同样的事情。让我们从一个自定义可选玩家创建一个可观察对象,然后合并它。

我们将重构我们的代码如下:

package example 

import rx.lang.scala.Observable 

object SmartApp extends App { 

  val src = 
    "/Users/vika/Documents/LSProg/LSPWorkspace/First_Proj_Rx/src/main/scala/example/football_stats.csv" 

  val playerObservable: Observable[String] = 
    Observable.from(PlayerService.readPlayerDataFromSource(src)) 

  val somePlayer = Some(PlayerService.Player("Random Player", "Random Nation", 31, "Random League")) 

  playerObservable 
    .map(playerString => 
      PlayerService.parseToPlayer(playerString)) 
    .merge(Observable.just(somePlayer)) 
    .subscribe(player => PlayerService.showPlayerInformation(player), 
      error => println(s"Error Occurred: ${error.getMessage}")) 

  Thread.sleep(10000) 
} 

我们可以看到,我们将observable.just(somePlayer)类型与类型为Observable[Option[Player]]的 Observable 合并了. 这个合并调用将这两个 Observable 合并在一起,因此这些合并来源的值将通过管道传输。我们可以通过运行应用程序来尝试这一点。如果 CSV 文件位于正确的位置,我们将看到来自我们的 CSV 文件以及我们创建的somePlayerObservable 的值。这样,我们可以使用操作符使我们的系统与多个事件源一起工作。这只是冰山一角。我们可以用这些操作符实现很多功能。我强烈建议您阅读 ReactiveX.的文档(reactivex.io/documentation/operators.html)。它们的宝石图解释了每个操作符,您可以根据需要使用它们。

基于这个推荐,我们希望总结本章所涵盖的内容。

摘要

本章向我们介绍了反应式编程的概念。我们理解了可观察事件的概念以及如何对它们做出反应。这让我们对反应式编程有了本质的理解。之后,我们了解了反应式扩展并探索了 API。我们看到了如何创建 Observables 并订阅它们。最后,我们查看了一个示例,解释了我们可以如何使用可用的某些操作符来组合 Observables。

在下一章中,我们将介绍一个最重要的主题,那就是测试我们的程序。我们将探讨目前讨论得很多的开发模型,即测试驱动开发TDD)。我们将从回答 TDD 的为什么和是什么开始,然后了解 Scala 中可用的测试工具包。

第十五章:Scala 中的测试

“改变是所有真正学习的最终结果。”

  • 利奥·布斯卡利亚

软件开发是一个不断变化的过程。在过去的几十年里,我们已经看到许多模式被发现/重新发现。这些编程技术/范式已经成为一个重要的部分,并改变了我们处理编程的方式。其中之一是测试驱动开发TDD)。在 TDD 方法中,我们首先通过新测试指定我们应用程序的需求。然后,一个接一个地,我们编写具体的代码来通过所有这些测试。通过这种方式,我们通过编写新的测试用例、实现通过它们的代码,最终构建了一个按预期运行的程序。Scala 提供了许多测试框架(例如,ScalaTestSpecs2等),我们还有MockitoScalaMock用于模拟对象。从某种意义上说,测试是一个小概念,但可以有大量的解释。在本章中,我们将专注于理解 TDD 方法以及我们如何遵循这种方法在 Scala 中成功应用它。为此,我们将通过以下内容进行学习:

  • TDD 的为什么和是什么

    • TDD 的过程

    • 行为驱动开发BDD

  • ScalaTest

  • ScalaCheck

那么,让我们从为什么这个被称为 TDD 的火箭在当今软件开发空间中飞得如此之高开始吧。

TDD 的为什么和是什么

为了编写预期和设计良好的软件,我们倾向于在开发过程开始之前明确需求。有了敏捷实践,我们将需求转化为我们所说的用户/功能故事。将这些故事转化为我们将要实现的简单规范增加了优势。这就是编写测试用例派上用场的地方。我们以测试用例的形式指定我们程序的行为,然后实现这些行为。

这种方法有一些优点。先编写测试用例然后提供实现可以推动我们程序的设计。这意味着当我们接近实现行为时,我们可以思考我们的设计和代码。如果你的一个类A依赖于另一个类B,我们可以确保将B注入到A中。从某种意义上说,我们可以将其作为一种习惯来遵循这些方法,例如,从其他类中注入依赖。除了推动应用程序的设计,TDD 还有助于我们思考用例以及我们的应用程序用户可能使用它的方式。它帮助我们清晰地思考用户将获得的接口,以便我们可以相应地编写代码。

在 TDD 中,尽管我们倾向于先编写测试用例,但我们几乎覆盖了软件实现的全部行。这为我们提供了自动的代码覆盖率。让我们看看 TDD 的过程。

TDD 的过程

根据这种方法,我们可以将 TDD 的过程分解为以下步骤。这个过程可以包含在你的开发工作流程中:

  1. 编写一个会失败的测试。

  2. 编写一些代码以通过失败的测试。

  3. 重构你的代码以提升质量,同时不改变行为。

  4. 重复步骤 1 到 3。

我们将这个过程分解成这些步骤。让我们看看每个步骤,以更好地理解每个步骤背后的推理。

第 1 步 - 编写一个会失败的测试

编写失败的代码并不是我们感到自信的事情,但这就是 TDD 的工作方式。在我们确定应用程序需要什么以及我们对某个功能有信心之后,我们可以按照我们希望它工作的方式编写功能测试用例。我们想确保运行这个测试用例,并且它失败了。我们的测试用例失败是预期的,因为我们还没有实现任何代码来使其成功。我们所说的初始失败是 TDD 的第一步。

第 2 步 - 编写代码以通过失败的测试

这个步骤最好的地方是知道我们如何通过一个失败的测试用例。为此,我们将实现一个功能。在这个步骤中,我们需要编写一些代码。我们可以尝试以不是最佳的方式实现一个函数,但足以通过失败的测试。通过测试保证了特定功能的行为。

第 3 步 - 重构代码以提升质量

现在我们确信我们的功能正在工作,我们可以继续提高代码质量。如果功能不是很大,那么这一步可以是之前步骤的一部分。在功能工作后进行代码审查是合乎逻辑的。这可能会提高我们代码的质量。重构后,我们应该确保特性/功能处于工作状态且完好无损。

第 4 步 - 重复步骤 1 到 3

现在,对于这个特定的功能,我们已经编写了测试用例并实现了代码,我们确保了它正在工作,代码质量是合适的。我们完成了这个特定的特性/功能,现在可以编写另一个测试用例并重复这个过程步骤。

我们可以这样可视化 TDD(测试驱动开发)的工作流程:

图片

从之前的图中可以看出,TDD 是一个重复的过程,其中你指定一个用例并为它编写代码。

TDD 的一个好处是我们的测试充当了文档。库/框架开发者倾向于编写测试用例,这些测试用例也服务于文档的目的。如何?通过使用领域特定语言(DSL)或者,让我们说,使用类似英语的句子来设计我们的测试套件。一个测试套件由我们程序的多个测试用例组成。让我们看看我们在几个章节中使用的场景。我们将使用的例子是从名为football.csv的 CSV 文件中读取足球运动员的数据,将其转换为Player对象,并进一步使用这些数据来显示球员信息或基于这些信息进行一些分析。我们如何进行这样的场景的测试用例,或者至少,当我们说“类似英语的句子”来指定规范时,它应该是什么样子?

场景

读取玩家数据并在控制台上展示:

"PlayerService" should { 
    "return a Player object." in {//some code} 
    "return an empty collection if there's no data." in {//some code} 
    "return a Nothing on call to getPlayer method with right player string." in {//some code} 
    "return a Some Player instance on call to getPlayer method with right player string." in {//some code} 
    "print a Player's information on console." in {//some code} 
} 

在给定的示例场景中,我们指定了一个服务并命名为 PlayerService。现在,这个服务应该有执行指定案例的方法。这些案例并不太复杂,但每个案例都期望我们的服务提供简单的功能。这个例子是为了解释目的,因为我们已经看到了代码。我们还将尝试使用测试驱动开发(TDD)方法来实现它。

前面的测试规范值得注意的一点是,在尝试编码之前,我们确实对某些事情有把握:

  • 应该有一个 PlayerService 服务。

  • PlayerService 中我们应该有一个 Player 实体*。

  • PlayerService 中我们应该有一个读取玩家数据的功能。它必须是一个集合;当源无法读取或源不包含数据时,该功能应返回一个空集合。

  • PlayerService 中应该有一个 getPlayer 功能,它期望我们提供一些数据给它,并返回一个具体的 Player 实体。当我们提供错误数据(如格式)时,功能不会抛出异常,但指定它无法创建具体的 Player 实体。

  • PlayerService 中应该有一个 getPlayer 功能,它期望我们提供一些数据给它,并在接收到正确数据时返回一个具体的 Player 实体。

  • PlayerService 中应该有一个 showPlayers 功能,它期望我们提供一个 Player 实体的集合,并在控制台上打印玩家的信息。

事实是,我们之前提到的点和规范在语义上是相似的,并且我们可以使用其中之一来编写测试用例,这使得测试驱动开发(TDD)变得有趣。这些测试规范与我们将要编写的真实世界测试规范非常接近。仔细看看我们刚才描述的规范;它没有提到我们必须做出的编程语言选择。我们没有得到任何关于语言规范的提示,所以如果使用的编程语言可以支持这样的 领域特定语言DSL)类似机制,这些测试用例将适用于任何语言。

这个规范并不限制你只以指定格式编写测试用例,你可以选择自己的写作风格。这是从测试驱动开发(TDD)中出现的惯例之一,被称为 行为驱动开发BDD)。这个术语 BDD 通过指定行为来驱动开发任何功能。它还充当我们程序的文档。如果你看到我们编写的规范,从某种意义上说,我们记录了我们可以使用功能的方式。如果这些规范是用 Scala 编写的,我们就可以根据功能提供方法名称。

让我们讨论一下通过指定行为来驱动开发的方式。

行为驱动开发(BDD)

我们已经看到了指定我们功能行为的方式,即使用should ... in。还有其他方式来指定行为,或者说,确保我们功能的有效性。一个例子是given... when... then**....

在这里,我们指定以下内容:

  • Given: 这是执行某些功能时可用的情况

  • When: 我们面对的是一个基于给定数据的条件

  • Then: 执行预期发生的部分

以这种方式,我们验证了功能的行为。当我们为某个功能编写测试用例时,最佳实践之一是指定所有可能的场景(理想情况下,这是不可能的,但我们尽力包括所有我们能想到的可能性)。这些场景包括空场景单元场景失败场景。最后,我们覆盖了所有条件可能性。有了这些,我们确保规范是有效的,我们的实现也是。这些规范作为我们功能性的验收标准。你漏掉一些重要情况的几率较小。重要的是要知道,描述我们的测试用例没有硬性规定。

行为驱动开发的一个好处是,我们不是用测试术语来谈论,而是用规范或场景来谈论。因此,不仅开发者,大多数业务利益相关者和领域专家也可以指定应用程序的需求。

现在,谈到 Scala 为测试提供的框架或工具包,有很多。ScalaTestScalaCheckSpecs2是开发者用来为代码编写测试的几个例子。我们将通过最广泛使用的测试工具之一,ScalaTest,并尝试通过示例查看规范是如何实现的。

ScalaTest

如我们所提到的,ScalaTest 因其提供的多种编写规范的风格而闻名。不仅限于多种风格,这个套件还作为Scala.js和 Java 类的测试工具。ScalaTest 涵盖了 Scala 生态系统的大部分内容,并允许你根据功能的行为选择不同的方式来编写规范。

设置测试环境

要使用 ScalaTest,让我们通过一些基本步骤。我们可以创建一个新的 SBT 项目,并通过build.sbt文件添加ScalaTest依赖项,或者使用 Lightbend 的技术中心下载一个简单的 Scala 项目。让我们尝试第二种方法,因为它会在我们的build.sbt文件中添加 ScalaTest 作为依赖项。让我们按照以下步骤进行:

  1. 在你喜欢的浏览器中打开:developer.lightbend.com

  2. 点击 START A PROJECT 按钮:

  1. 从项目类型中选择 Scala:

  1. 你可以命名项目并点击 CREATE A PROJECT FOR ME!*:

这将为你下载一个名为你指定的压缩文件。将压缩文件提取到特定位置并在 IntelliJ IDE 中打开它。

我们可以打开build.sbt来检查指定的依赖项和项目设置。我们的sbtbuild文件应该看起来像以下这样:

import Dependencies._ 

lazy val root = (project in file(".")). 
  settings( 
    inThisBuild(List( 
      organization := "com.example", 
      scalaVersion := "2.12.4", 
      version      := "0.1.0-SNAPSHOT" 
    )), 
    name := "Hello", 
    libraryDependencies += scalaTest % Test 
  ) 

在这里,在libraryDependencies设置中,指定了scalaTest。因此,我们可以使用它。示例项目包含一些源代码和测试。所以,我们首先尝试运行测试案例。如果你可以看到外部依赖目录中的scalatest库,如图所示,那么我们就准备好执行测试了:

图片

如果这些库没有显示在你的项目结构中,我们可以执行sbt update命令,这样 SBT 就可以下载所有指定的依赖项。我们还将使用一些 SBT 命令来运行我们的测试案例。让我们看看它们:

  • sbt test:SBT 假设 Scala 的测试源位于src/test/scala目录,而测试源的资源,如测试源的配置,位于src/test/resources目录

根据之前描述的假设,当我们执行前文所述的命令时,SBT 将编译相应位置的所有测试文件并对它们进行测试。如果你只关心特定的测试案例怎么办?

  • sbt testOnly:基于我们对sbt test命令的类似假设,当我们执行testOnly命令时,SBT 仅编译和测试我们通过命令指定的测试案例。考虑以下示例:
        sbt testOnly example.HelloSpec
  • sbt testQuick:基于我们对sbt test命令的类似假设,当我们执行testQuick命令时,SBT 仅编译和测试满足以下条件的测试案例:

    • 上次运行失败的测试

    • 尚未运行的测试

    • 具有传递性依赖的测试

这些是我们用来测试案例的几个 Scala 命令。此外,在测试我们的规范时,我们将使用触发执行。我们通过在测试命令前加上~来启动触发执行。这样,SBT 期望保存更改,再次保存文件将触发测试执行。因此,尝试这个命令,假设我们已经打开了 SBT shell(在命令提示符中打开项目目录,其中包含build.sbt文件和触发sbt命令的位置)。让我们在 SBT shell 中定位我们的项目并执行以下命令:

图片

在我们下载的项目测试目录中有一个名为HelloSpec的类。如果一切正常,我们应该通过测试,你将开始喜欢绿色的颜色。但如果代码是红色,这意味着它失败了。所以,我们准备深入研究ScalaTest.

使用 ScalaTest 进行风格化的测试

我们已经测试了HelloSpec,所以我们将查看测试以及它的编写方式。因此,我们可以打开位于src/test/scala/example的文件Hello。源代码如下:

package example 

import org.scalatest._ 

class HelloSpec extends FlatSpec with Matchers { 
  "The Hello object" should "say hello" in { 
    Hello.greeting shouldEqual "hello" 
  } 
} 

看一下代码示例,我们可以观察到几个要点:

  • 我们导入了org.scalatest._以引入我们想要使用的所有特性。

  • 命名为HelloSpec的类定义扩展了FlatSpec以及混合Matchers类。将我们的测试规范命名为类名后跟Spec是一种约定。

  • 定义只包含一个规范。规范声明“有一个名为 Hello 的对象,在指定的代码片段中说 hello”。这就是它多么酷——规范是以任何英语句子都可以编写的方式编写的。

  • 规范是以类似 DSL 的方式编写的。语言看起来很自然,进一步下文,规范声明将有一个返回字符串等于hello的方法调用。

  • shouldEqual的调用是一个Matcher*。Matcher的职责是将左操作数与右操作数匹配。还有几种其他方式可以编写这样的Matchers,但我们将后续章节中再讨论这些。

这非常简单,到目前为止一切都很顺利。现在,让我们看看位于src/main/scala/example位置的相应代码文件*。让我们看看名为Hello.scala的文件:

package example 

object Hello extends Greeting with App { 
  println(greeting) 
} 

trait Greeting { 
  lazy val greeting: String = "hello" 
} 

根据我们的规范,有一个名为Hello的对象。我们可以调用greeting,它除了打印这个字符串hello之外,什么都不做。嗯,这个例子已经对我们来说是可以观察到的,所以我们没有遵循 TDD。但我们将尝试通过指定行为来编写类似的东西,使用测试优先方法

在我们编写自己的第一个规范之前,让我们遵循一些约定或一项好的实践,创建一个扩展FlatSpec并默认带有Matchers的抽象类,这样我们就不必在编写的每个规范中扩展它们。我们可以通过创建一个规范文件来实现这一点,让我们将其命名为SomeSpec并保存在src/test/scala/example/目录中。它应该看起来像这样:

package example 

import org.scalatest._ 

abstract class SomeSpec(toBeTested: String) extends FlatSpec with Matchers 

现在我们创建了一些扩展FlatSpecMatchers类的抽象类,我们准备遵循 TDD 的第一步来编写一个失败的测试规范。我们的SomeSpec抽象类接受一个名为toBeTested的参数,它只是功能名称。

让我们创建另一个测试规范,命名为PlayerSpec,并保存在src/test/scala/example

package example 

class PlayerSpec extends SomeSpec("PlayerService") { 

  it should "compile" in { 
  """PlayerService.Player("Cristiano Ronaldo", "Portuguese", 32, "Real Madrid")""" should compile 
  } 

} 
Player instances*.* When we try to run the test case, it's going to fail as expected, because we have not written the  Player class till now:
> testOnly example.PlayerSpec 
[info] PlayerSpec: 
[info] - should compile *** FAILED *** 
[info] Expected no compiler error, but got the following type error: "not found: value Player", for code: Player("Cristiano Ronaldo", "Portuguese", 32, "Real Madrid") (PlayerSpec.scala:6) 
[info] Run completed in 281 milliseconds. 
[info] Total number of tests run: 1 
[info] Suites: completed 1, aborted 0 
[info] Tests: succeeded 0, failed 1, canceled 0, ignored 0, pending 0 
[info] *** 1 TEST FAILED *** 
[error] Failed tests: 
[error]  example.PlayerSpec 
[error] (test:testOnly) sbt.TestsFailedException: Tests unsuccessful 

让我们编写Player案例类:

// src/main/scala/example/PlayerService.scala

object PlayerService extends App { 

  case class Player(name: String, nationality: String, age: Int, league: String) 

} 

使用这段代码,我们可以保存文件,由于我们的测试是在触发模式下运行的,我们可以看到测试用例通过了:

[info] PlayerSpec: 
[info] - should compile 
[info] Run completed in 199 milliseconds. 
[info] Total number of tests run: 1 
[info] Suites: completed 1, aborted 0 
[info] Tests: succeeded 1, failed 0, canceled 0, ignored 0, pending 0 
[info] All tests passed. 
[success] Total time: 1 s, completed 

我们将再次遵循相同的步骤。所以,让我们编写一些更多的测试用例:

package example 

class PlayerSpec extends SomeSpec("PlayerService") { 

  it should "compile" in { 
    """PlayerService.Player("Cristiano Ronaldo", "Portuguese", 32, "Real Madrid")""" should compile 
    } 

  it should "throw an exception when source is wrong" in { 
    val src = "some source" 
    assertThrowsjava.io.FileNotFoundException) 
  } 

  it should "return collection of String when data is read from correct source" in { 
    val src = "/Users/vika/Documents/LSProg/LSPWorkspace/First_Proj_Testing/src/main/scala/example/football_stats.csv" 
    PlayerService.readPlayerDataFromSource(src) should not be empty 
  } 

  it should "return None while parsing wrong player string data into player instance" in { 
    val data = "some wrong player string" 
    PlayerService.parseToPlayer(data) shouldBe None 
  } 

  it should "return Some Player while parsing player string data into player instance" in { 
    val data = """1,1,2,1,2,Cristiano Ronaldo,Portugal,Real Madrid,Spain,32,4829,40,4789,124,63""" 
    val player = PlayerService.Player("Cristiano Ronaldo", "Portugal", 32, "Real Madrid") 

    PlayerService.parseToPlayer(data) shouldBe Some(player) 
  } 

} 

我们对我们的功能行为有了清晰的了解,因此我们编写了更多的测试用例。现在,我们的规范包括了更多的测试条款。我们已经使用了几个可用的Matchers。让我们看看我们的测试是如何工作的:

  1. 在检查我们的代码后,它应该能够编译。在下一个条款中,我们指定了当尝试访问错误的文件路径时,我们的代码应该抛出FileNotFoundException。我们使用了assertThrows断言来指定这种行为。在这里,我们不需要指定任何Matchers,因为指定一个断言就足够了。

  2. 在下一个条款中,我们提到readPlayerDataFromSource的结果不应该为空,这里的空指的是集合为空。

  3. 下一个规范条款期望当我们尝试用错误格式的数据调用parseToPlayer方法到Player实例时,将返回None

  4. 最后,当我们用正确格式的数据调用parseToPlayer方法时,我们期望它返回一个玩家对象。

从我们的规范和提到的条款中可以清楚地看出我们对我们功能的具体要求。当我们尝试运行测试用例时,它无法编译,因为我们没有readPlayerDataFromSourceparseToPlayer这些方法。我们可以定义所有这些方法和相应的代码。代码应该看起来像这样:

package example 

import scala.util.Try 
import scala.util.{Failure, Success} 

object PlayerService extends App { 

  def readPlayerDataFromSource(src: String): List[String] = { 
    val source = io.Source.fromFile(src) 

    val list: List[String] = source.getLines().toList 

    source.close() 
    list 
  } 

  def parseToPlayer(string: String): Option[Player] = { 
    Try { 
      val columns = string.split((",")).map(_.trim) 
      Player(columns(5), columns(6), columns(9).toInt, columns(7)) 
    } match { 
      case Success(value) => Some(value) 
      case Failure(excep) => None 
    } 
  } 

  case class Player(name: String, nationality: String, age: Int, league: String) 

} 

在编写代码之后,如果需要,我们可以对其进行重构。在我们的例子中,我们已经重构了代码。如果你已经将相应的文件放置在正确的路径,我们可以尝试运行测试用例。所有的测试用例都将成功通过,这意味着它们都将显示为绿色:

[info] PlayerSpec: 
[info] - should compile 
[info] - should throw an exception when source is wrong 
[info] - should return collection of String when data is read from correct source 
[info] - should return None while parsing wrong player string data into player instance 
[info] - should return Some Player while parsing player string data into player instance 
[info] Run completed in 324 milliseconds. 
[info] Total number of tests run: 5 
[info] Suites: completed 1, aborted 0 
[info] Tests: succeeded 5, failed 0, canceled 0, ignored 0, pending 0 
[info] All tests passed. 

现在,我们对工具包以及 TDD 如何使编写软件变得有趣有了一些了解。在我们的例子中,我们使用了FlatSpec。还有更多这样的工具;一些常用的 Spec 如下:

  • FunSpec

  • WordSpec

  • FreeSpec

  • PropSpec

  • FeatureSpec

这些风格只在外观上有所不同。如果我们考虑用类似英语的语言编写的测试规范,我们可以说这些风格是由我们可以用不同的方式来写/说我们的句子组成。我们已经看到了FlatSpec. FunSpec规范使用嵌套条款和关键字如describeit。让我们看看所有这些 Spec 风格的几个例子:

  • FunSpec
    describe("In PlayerService object"){
        it("should compile") {
            assertCompiles("""PlayerService.Player (
                                  "Cristiano Ronaldo", 
                                  "Portuguese", 32, 
                                  "Real Madrid")""")
        }
    }
  • WordSpec
    "PlayerService.Player.parseToPlayer" when {
        "wrong parsing data passed" should {
            "return None" in {
                PlayerService.parseToPlayer("some wrog data") shouldBe None
            }
        }
    }
  • FreeSpec
    "PlayerService.Player.parseToPlayer" - {
        "wrong parsing data passed" - {
            "return None" in {
                PlayerService.parseToPlayer("some wrog data") shouldBe None
            }
        }
    }

这些是我们可以在ScalaTest中用来编写测试规范的几种风格。你绝对应该查看ScalaTest的文档(www.scalatest.org/user_guide)来了解更多关于这些风格的信息。

我们已经看到了如何使用断言和Matchers来检查测试条款的有效性。让我们更深入地了解这些。我们将从断言特质*开始。

断言

特质断言包含在指定测试用例行为时我们可以做出的断言。所有风格规范中都有三个默认断言可用。这些断言如下:

  • assert

  • assertResult

  • assertThrows

我们已经使用了assertThrows,其他两个也可以以类似的方式使用。assertResult断言期望我们提供一个值,这个值将是我们要指定的某些计算的输出结果。同样,assert期望我们提供一个布尔谓词,它有左右两部分。在某些条件下,可以进行一些相等性检查,这些检查基于布尔值,根据这些值,测试条款通过。

除了这些,还有更多的断言可用。其中一些是failcancelsucceedinterceptassertCompilesassertDoesNotCompile等等。除了这些断言之外,我们还可以使用Matchers来检查测试规范的有效性。我们在一些示例条款中使用了Matchers和关键字。

匹配器

当我们查看示例时,我们已经看到了一些Matchers。这些Matchers是断言的 DSL(领域特定语言)编写方式。ScalaTest提供了一套丰富的断言,这些断言与字符串和集合一起工作。我们还可以为自定义类编写Matchers。有了这些Matchers,我们可以执行最基础的断言,如相等性检查,到更复杂的断言,其中我们需要处理聚合和排序。

这些Matchers很酷,因为它们有类似原生语言的方法。考虑以下示例:

someCollection should have length 7 
someString should include ("world") 
twenty should be > 10 
number shouldBe odd 
sequence should contain ("thisvalue")  

有很多种方式可以表达一个特定的句子来传达相同的信息。同样,使用ScalaTestMatchers,我们可以用不同的方法指定一些条款。Matchers在这方面非常有用。

我们还知道,在编写软件时,有时我们需要创建模拟对象作为参数传递。为此,我们不必自己编写模拟对象,但有一些库会为我们做这件事。让我们看看 Scala 中可用的其中一个。

ScalaMock – 一个用于模拟对象的原生库

正如我们讨论的,在需要一些我们尚未定义的其他服务,或者由于使用它们是一个复杂的过程,创建它们的实例比较困难的情况下,我们倾向于使用一些模拟框架。

ScalaMock是一个在 Scala 中可用的原生框架。为了将 ScalaMock 包含到我们的项目中,我们将在build.sbt文件中添加一个对其的依赖项。让我们这样做。我们将在构建文件中添加以下行:

libraryDependencies += "org.scalamock" %% "scalamock" % "4.0.0" % Test 

我们已经指定了测试范围,因为我们确信scalamock只会在我们的测试用例中使用。在编写这个依赖项之后,我们将通过在 SBT shell 中调用sbt update命令来执行一个sbt update命令.这次更新将把scalamock依赖项添加到我们的项目中。我们可以通过查看外部源文件夹来确保这一点。那里将有一个名为scalamock的依赖项。如果它可用,我们就准备好模拟一些服务了:

我们将在我们的应用程序中尝试模拟PlayerService对象,我们想在其中显示一些玩家的列表。让我们为这个指定一个规范:

import org.scalamock.scalatest.MockFactory 

class PlayerAppSpec extends SomeSpec("PlayerAppSpec") with MockFactory { 

  it should "give us a collection of 2 players" in { 

    val mockPlayer = mock[PlayerService.Player] 

    val list = List(mockPlayer, mockPlayer) 
    list should have length 2 
  } 

} 
scalamock dependency, the MockFactory trait in the scope, all by importing the dependencies and calling the mock method, specifying which type of object to create as a mock. It's as simple as that. We can also mock functions and set some expectations to that mocked function. Let's see the following example:
val someStringToIntFunc  = mockFunction[String, Int] 
someStringToIntFunc expects ("Some Number") returning 1 

现在,我们可以执行一些测试用例,这包括对模拟函数的调用。我们的测试用例必须以某种方式至少调用这个函数一次,才能通过。在编写测试用例时模拟对象是一种常见的做法。它减轻了我们手动编写多个类/函数实例的负担,让我们专注于真正重要的事情,即提前验证我们程序的行为。这就是为什么我们有这样的框架可用,使我们的开发生活更加轻松。带着这个,我们来到了这本书的最后一章的结尾。让我们总结一下你所学到的东西。

摘要

这一章从未感觉像是最后一章;它很有趣。在经历了几个 Scala 编程结构和框架之后,你学会了如何确保我们程序的合法性。我们知道测试我们编写的代码是至关重要的,但这种新的 TDD 编程风格是一种不同的体验。我们理解了 TDD 实际上是什么——一种在代码完成后而不是开始时通过指定行为来驱动的设计方案。我们讨论了为什么这种方法是好的。然后,我们从 Scala 中可用的测试工具开始,学习了ScalaTest. 我们编写了规范,然后为这些规范编写了代码。最后,我们还研究了 Scala 中名为 ScalaMock 的模拟框架。

虽然这是最后一章,但我们可以思考还能做些什么来使我们对这门语言的理解更好。最好的方法之一是进行更多的练习;这将帮助我们更好地理解概念,并且我们也会对结构了如指掌。但当我们开始学习函数式编程时,我们才能真正获得洞察力,因为那里才是所有魔法的源泉。尝试用函数式思维去思考,即我们不改变任何东西,这是最重要的方面之一。最后,让我们感谢这个奇妙且不断成长的 Scala 社区。加入社区,提出问题,并以你自己的方式做出贡献。

posted @ 2025-09-11 09:46  绝不原创的飞龙  阅读(19)  评论(0)    收藏  举报