Scala-专业指南-全-

Scala 专业指南(全)

原文:zh.annas-archive.org/md5/1616bd34ccc553b47b7c00d481c0137c

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Scala 是一种类型安全的 JVM 语言,将面向对象和函数式编程结合到一个极其简洁、逻辑性强、功能强大的语言中。有些人可能会惊讶地发现,Scala 并不像他们想象的那么新,它首次在 2003 年推出。然而,特别是在过去几年里,Scala 开始获得了显著的追随者。

这本书使你能够构建和贡献 Scala 程序,识别与该语言一起使用的常见模式和技巧。

这是一本实用的书,它为你提供了大量的 Scala 实战经验。

本书面向的对象

这本书是为对学习 Scala 语言的高级特性感兴趣的开发者编写的。要遵循本书中的说明,需要具备 Scala 编程语言的基本知识。

本书涵盖的内容

第一章, 设置开发环境,展示了如何设置你的开发环境。你将学习 Scala 的基础知识,例如简单的 Scala 程序是什么样的,以及典型的开发者流程是什么。它还将涵盖使用单元测试测试你的 Scala 程序的一些方面。

第二章, 基本语言特性,涵盖了类和对象、特质、模式匹配、案例类等。你还将通过应用面向对象的概念来实现你的聊天机器人应用程序。

第三章, 函数,涵盖了使用 Scala 进行函数式编程以及面向对象和函数式方法如何相互补充。你将识别泛型类,并了解如何创建用户定义的模式匹配以及为什么它是有用的。

第四章, Scala 集合,教你如何处理列表。然后,它将涵盖一些相关的数据结构。最后,你将了解集合如何与单子相关联,以及你如何利用这些知识在你的代码中创建一些强大的抽象。

第五章, Scala 类型系统,涵盖了类型系统和多态性。它还将使你能够识别不同类型的变异性,这为约束参数化类型提供了一种方法。然后,你将了解一些高级类型,例如抽象类型成员和选项。

第六章, 隐式,涵盖了隐式参数和隐式转换。你将学习它们是如何工作的,如何使用它们,以及它们提供的哪些好处和风险。

第七章, 函数式习惯用法,涵盖了函数式编程的核心概念,如纯函数、不可变性和高阶函数。它还将介绍两个流行的函数式编程库 Cats 和 Doobie,并使用它们编写一些有趣的程序。

第八章,领域特定语言,介绍了 Scala 如何通过提供一些有趣的语言特性,使得编写强大的 DSL 成为可能。然后,它将涵盖一个如果您打算专业使用 Scala 时很可能使用的 DSL。

最后,您将实现您自己的 DSL。

您需要为本本书准备的东西

最小硬件要求如下:

  • 英特尔酷睿 i3 处理器

  • 2 GB RAM

  • 一台互联网连接

请确保您已在您的机器上安装以下软件:

  • 微软 Windows 10/8/7 (64 位)

  • JDK 8

  • IntelliJ + Scala 插件

Mac

  • macOS 10.5 或更高版本(64 位)

  • JDK 8

  • IntelliJ + Scala 插件

Linux

  • Linux 64 位

  • KDE、GNOME 或 Unity DE 桌面环境

  • JDK 8

  • IntelliJ + Scala 插件

惯例

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 处理方式如下所示:

在这里,我们定义了一个名为 name 的不可变值,它保留了用户的 stdin

主方法是任何 Scala 程序的一个基本部分。

代码块设置如下:

package com.packt.courseware
import scala.io.StdIn
object Chatbot1
{
   def main(args: Array[String]):Unit =  {
     // do something
   }
}

新术语和重要单词以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:“选择运行 sbt-test。”

重要新的 编程术语 以粗体显示。概念性术语以斜体显示。

注意事项

关于一个主题的重要附加细节看起来像这样,就像在侧边栏中一样。

小贴士

重要注意事项、技巧和技巧如下所示。

安装和设置

在我们开始这本书之前,我们将安装 IntelliJ IDE。

安装 IntelliJ IDE

  1. 在您的浏览器中访问 www.jetbrains.com/idea/

  2. 点击网页上的 下载 按钮。

  3. 选择您适当的操作系统,然后在 社区 下点击 下载 选项。

  4. 按照安装程序中的步骤操作,然后就可以了!您的 IntelliJ IDE 已经准备好了。

安装 Scala 插件(创建标题)

  1. 打开 IntelliJ IDEA。

  2. 前往 文件菜单

  3. 在文件菜单中,选择 插件

  4. 点击 浏览 仓库按钮并输入 Scala

  5. 选择安装 Scala 插件。

读者反馈

我们的读者反馈始终受到欢迎。告诉我们您对本书的看法——您喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。

如果您在某个主题上具有专业知识,并且您有兴趣撰写或为本书做出贡献,请参阅我们的作者指南 www.packtpub.com/authors

客户支持

现在您是 Packt 书籍的骄傲拥有者,我们有多个方面可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从www.packtpub.com下载您购买的所有 Packt 出版物的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上版权材料的盗版是一个持续存在的问题,涉及所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。

我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您在这本书的任何方面有问题,您可以通过<questions@packtpub.com>与我们联系,我们将尽力解决问题。

第一章:设置开发环境

在我们开始编写本书中的各种程序之前,让我们先简单谈谈 Scala 语言本身。为什么它是必要的,是什么让它变得独特?语言最重要的方面是什么?

Scala 是由 Martin Odersky 在 2001 年在洛桑联邦理工学院(EPFL)创建的。这是 Pascal 语言(在 1990 年代末广泛使用)被创建的同一个实验室。

Scala 是 'Scalable Language' 的缩写——一种可扩展的语言,也就是说,它允许你编写具有巨大功能复杂性的复杂系统。如 Scala 的主页所述:" Scala 将面向对象和函数式编程结合在一个简洁、高级的语言中。"

注意

你可以在这里访问 Scala 的官方网站:www.scala-lang.org/

到本章结束时,你将能够:

  • 识别 Scala 项目的结构

  • 识别 Scala 的 sbt 工具(交互式构建工具)的使用,用于构建和运行你的项目

  • 识别如何使用 IDE

  • 实现与简单聊天机器人的交互

Scala 是建立在 JVM 平台之上的(Scala 程序被编译为使用 JVM 字节码)。

现在,该语言被用作许多领域中最受欢迎的平台之一,例如高负载软实时应用、数据科学工具包的广告服务器。

Scala 的一些特性如下:

  • 一个高级类型系统,这使得 Scala 相比于大多数其他工业编程语言更优越(但同时也更复杂)。

  • 静态类型,它允许你在编译时检查错误,以安全的方式编写代码。

在本章中,我们将学习 Scala 的基础知识,例如简单的 Scala 程序是什么样的,以及典型的开发者流程是什么。开发的一个重要部分是与工具的交互——构建工具、依赖提取器、IDE 等,这些工具与语言一起形成了工具生态系统。我们将使用主流工具构建一个简单的程序。

简单程序

在本节中,我们将介绍基本 Scala 程序的结构。我们将介绍包、导入和对象等定义。我们还将探讨 Scala 程序的 main 方法。

让我们在 Scala 中创建最简单的程序。我们将实现一个在屏幕上打印 "Hello World" 的程序。该程序的结构如下定义:

package com.packt.courseware
import scala.io.StdIn
object Chatbot1
{
   def main(args: Array[String]):Unit =  {
     // do something
   }
}

定义:包、导入和对象

如果你查看前面的代码,第一行是一个包名。在我们的例子中,这是 com.packt.courseware

所有编译单元都组织到包中。包可以嵌套,形成代码对象的分层命名空间。

当一个编译单元没有包声明时,它属于所谓的 ' default' 包。默认包中的模块不能从另一个包导入。

通常,Scala 项目的源目录组织方式与包相同。这并非强制要求,但已成为一个经验法则。一些工具(如 IDE)使用这些约定作为默认项目设置。

现在我们将看看 import 语句。

对象定义

这里,我们定义了对象 Chatbot1.

如果你熟悉传统的类,因为它们是用 Java 实现的,你可以查看具有一个默认实例的类对象,即对象是单例模式的实现:在 JVM 层面上,对象定义创建了一个类和一个预定义的该类实例。

主要方法

最后,main 方法是程序的入口点。它必须接受一个字符串数组(命令行参数)并返回一个单位。

历史上,Scala 中使用 main 方法名称。这是因为 Java 语言遵循相同的传统,从 C 语言中继承了入口方法的名称,而 C 语言又从 BCPL 中继承了这一传统。

方法定义如下:

         package com.packt.couserware
    object X  { def f() = { … } }

main 方法内

main 方法是任何 Scala 程序的一个基本部分。程序的执行首先从 main 方法开始。

让我们看看 main 方法内部:

def main(args: Array[String]): Unit = {
val name = StdIn.readLine("Hi! What is your name?")
println(s" $name, tell me something interesting, say 'bye' to end the talk")
var timeToBye = false  
while (!timeToBye)timeToBye = StdIn.readLine(">") 
match {case "bye" => println("ok, bye")
                             truecase  _      => println("interesting...")false}
}

在这里,我们使用名称 name 定义了一个不可变值,它保存了从 stdin 的用户输入。Scala 是一种静态类型语言,因此该值是 String 类型。

如我们所见,值的类型没有明确写出,而是从其上下文中自动推断出来的。

在下一行,使用“字符串插值”运算符打印值:在一个以 s 为前缀的字符串中,所有 ${} 括号内的表达式都会被这些表达式的值替换,并转换为字符串。对于简单的标识符,我们可以省略 {} 括号,例如,在 s"x=$y" 的字符串插值中,y 的值将被替换为 $y

var timeToBye 是一个具有 Boolean 类型的可变变量。与值不同,可变变量可以被赋值多次。

看向前面的循环,我们可以看到程序试图成为一个好的倾听者,对任何消息都回答 interesting,除了 bye

case 语句的结果被分配给 timeToBye,并在 while 循环条件中进行检查。

Scala 作为一种多范式语言,既有可变变量也有不可变变量。对于几乎任何任务,我们都可以选择多种实现方式。

如果存在指南,我们应该在哪里使用可变变量,在哪里使用不可变变量?

通常,关于不可变变量的推理更简单。通常的经验法则是尽可能使用不可变值,将可变变量留给性能关键部分和状态检查语言结构(如 while 循环)。

在我们的小型示例中,我们可以通过在 while 中放置循环退出条件的表达式来消除可变标志。结果代码更小,更容易阅读,但添加新功能变得更加困难。然而,有一个可能性——使用 recursive 函数而不是循环语言结构。

现在,让我们给我们的 chatbot 添加一些功能:当用户询问 time 时,chatbot 应该报告当前时间。

要做到这一点,我们必须使用 Java API 检索当前时间,并使用字符串插值显示时间的输出。

例如,使用 java.time.LocalTime.now 方法。

用于显示此内容的代码将是 println("time is ${java.time.LocalTime.now()}").

以下是这个功能的代码,但我们将在设置我们将要使用的开发环境之后实际实现它:

package com.packt.coursewarepackage com.packt.courseware

import scala.io.StdIn

object Chatbot1 {

  def main(args: Array[String]): Unit = {
    val name = StdIn.readLine("Hi! What is your name?")
    println(s" $name, tell me something interesting, say 'bye' to end the talk")
    var timeToBye = false
    while (!timeToBye)
       timeToBye = StdIn.readLine(">") match {
         case "bye" => println("ok, bye")
         true
         case "time" => println(s"time is ${java.time.LocalTime.now()}")
         true
         case _ => println("interesting...")
         false
       }
}

}

Scala 项目的结构

让我们看看我们的 chatbot 程序在一个完整的可运行项目中。让我们导航到我们的代码补充中的 /day1-lesson1/1-project 目录。

注意

代码可在以下链接的 Github 上找到:github.com/TrainingByPackt/Professional-Scala

上述图表是 Scala 项目的典型目录结构。如果您熟悉 Java 工具生态系统,您将注意到 maven 项目布局之间的相似性。

src 中,我们可以看到项目源代码(maintest)。target 是创建输出工件的地方,而 project 则用作内部构建项目的地方。我们将在稍后介绍所有这些概念。

organization := "com.packt.courseware"name := "chatbot1"version := "0.1-SNAPSHOT"
scalaVersion := "2.12.4"

任何项目的头部都是其 build.sbt 文件。它由以下代码组成:

其中的文本是一个普通的 Scala 代码片段。

organizationnameversionsbt.key 的实例。从这个角度来看,:= 是在 keys 上定义的二进制运算符。在 Scala 中,任何具有两个参数的方法都可以使用二进制运算符的语法使用。:= 是一个有效的方法名。

build.sbtsbt 工具解释。

注意

sbt – 当 sbt 由 Mark Harrah 创建时,其原始名称的意图是 ' Simple Build Tool'。后来,作者决定避免这种解释,并保留了原样。您可以在以下链接中了解 sbt 的详细信息:www.scala-sbt.org/1.x/docs/index.html

基本 sbt 命令

我们现在将讨论基本的 sbt 命令。

sbt compile 应该编译项目,并位于其目标编译的 Java 类中。

sbt run 执行项目的 main 函数。因此,我们可以尝试与我们的 chatbot 进行交互:

rssh3:1-project rssh$ sbt run
[info] Loading global plugins from /Users/rssh/.sbt/0.13/plugins
[info] Set current project to chatbot1 (in build file:/Users/rssh/work/packt/professional-scala/Lesson 1/1-project/)
[info] Running com.packt.courseware.Chatbot1
Hi! What is your name? Jon
  Jon, tell me something interesting, say 'bye' to end the talk
>qqq
interesting..
>ddd
interesting...
>bye
ok, bye
 [success] Total time: 19 s, completed Dec 1, 2017 7:18:42 AM

代码的输出如下:

sbt package 准备输出工件。运行后,它将创建一个名为 target/chatbot1_2.12-0.1-SNAPSHOT.jar 的文件。

chatbot1 是我们项目的名称;0.1-SNAPSHOT – version. 2.12 是 Scala 编译器的版本。

Scala 仅在次要版本范围内保证二进制兼容性。如果由于某种原因,项目仍然使用scala-2.11,那么它必须使用为scala-2.11创建的库。另一方面,对于具有许多依赖项的项目,升级到下一个编译器版本可能是一个漫长的过程。为了允许同一库在不同的scalaVersions中存在于仓库中,我们需要在 jar 文件中有一个适当的后缀。

sbt publish-local – 将工件发布到本地仓库。

现在我们来看一下我们的示例项目和sbt工具。

活动:使用sbt执行基本操作:构建、运行、打包

  1. 如果之前未安装,请在您的计算机上安装 sbt。

  2. 通过在1-projectbuild.sbt所在目录)的根目录中输入sbt console来启动sbt控制台。

  3. sbt控制台中输入compile命令来编译代码。

  4. sbt控制台中输入sbt run命令来运行程序。

  5. 当运行此操作时,对机器人说bye,然后返回控制台。

  6. 通过在sbt控制台中输入package来打包程序。

IDE

开发者工具箱的另一部分是 IDE 工具(集成开发环境)。对于我们的书籍,我们将使用带有 Scala 插件的 IntelliJ IDEA 社区版。这不是唯一的选择:其他替代方案包括基于 IBM Eclipse 的 scala-ide 和 Ensime (ensime.github.io/),它将 IDE 功能带到任何可编程文本编辑器,从 vi 到 emacs。

所有工具都支持从build.sbt导入项目布局。

活动:在 IDE 中加载和运行示例项目

  1. 导入我们的项目:

    • 前往File -> Import -> 导航到build.sbt
  2. 在 IDE 中打开程序:

    • 启动 IDEA

    • Open

    • 选择day1-lesson1/1-project/build.sbt

  3. 在询问是否将其作为文件或项目打开的对话框窗口中,选择project

  4. 在项目结构的左侧部分,展开src条目。

  5. 点击main.

  6. 确保您可以看到代码中指定的main

  7. 确保项目可以通过sbt控制台编译和运行。

要从 IDE 运行我们的项目,我们应该编辑项目的配置(菜单:Build/ Edit configurationRun/ Edit configuration,具体取决于您使用的 IDEA 版本)。

从 IDE 运行项目:

  1. 选择Run/Edit Configuration.

  2. 选择Application

  3. 设置应用程序的名称。在我们的例子中,使用Chatbot1

  4. 设置Main类的名称。在我们的例子中,它必须是com.packt.courseware.Chatbot1

  5. 实际运行应用程序:选择 Run,然后从下拉菜单中选择 Chatbot1。

REPL

我们将经常使用的另一个工具是 REPL(读取评估打印循环)。它通常用于快速评估 Scala 表达式。

sbt,我们可以通过sbt console命令进入 REPL 模式。让我们尝试一些简单的表达式。

现在,我们将看看如何评估表达式。按照以下步骤进行操作:

  1. 打开sbt工具。

  2. 通过输入以下命令打开REPL

    sbt console
    
  3. 输入以下表达式并按Enter

    • 2 + 2

    • "2" + 2

    • 2 + "2"

    • (1 to 8).sum

    • java.time.LocalTime.now()

请注意,我们可以在 IDE 中通过创建特殊文件类型:Scala 工作表来拥有一个交互式的 Scala playboard。这很有用,但主要是为了演示目的。

从我们的聊天机器人程序中获取时间请求

现在,让我们回到我们的任务:修改chatbot程序,使其能够根据time的使用回复当前时间。让我们学习如何做到这一点:

完成步骤

  1. 检查time是否与声明匹配:

    case "time" =>
    
  2. 使用 Java API 检索当前时间。使用java.time.LocalTimenow方法:

         java.time.LocalTime.now()
    
  3. 使用字符串插值器显示时间的输出,如下所示:

    println("time is ${java.time.LocalTime.now()}")
    

main方法将如下所示:

def main(args: Array[String]): Unit = {
val name = StdIn.readLine("Hi! What is your name?")
println(s" $name, tell me something interesting, say 'bay' to end the talk")
var timeToBye = false
while (!timeToBye)timeToBye = StdIn.readLine(">") 
match {case "bye" => println("ok, bye")truecase "time" => 
println(s"time is ${java.time.LocalTime.now()}")truecase _ => 
println("interesting...")false}
}

在我们准备和打包我们的工件之后,我们还需要运行它们。

在这本书中,我们将使用从未打包的源代码通过sbt(就像 Ruby 应用程序的早期日子一样)运行的运行系统,假设源代码和sbt工具可以从生产环境中访问。使用这种方式,我们可以使用针对源代码的构建工具命令,例如sbt run。在现实生活中,为生产打包要复杂得多。

做这件事的流行方法如下:

  • 准备一个包含所有依赖项的胖 jar(这有一个sbt插件,可以在以下链接找到:github.com/sbt/sbt-assembly)。

  • 准备一个本地系统包(包括 jar、依赖项、自定义布局等)。还有一个sbt插件可以创建本地系统包,可以在以下链接找到:github.com/sbt/sbt-native-packager

基本语法

现在我们可以使用 REPL,让我们了解 Scala 的基本语法。现在,我们不需要详细了解它,但让我们通过使用示例来熟悉它。

注意

对于正式的详细描述,请参阅 SLS:Scala 语言规范,在这里:scala-lang.org/files/archive/spec/2.12/

定义的基本语法

Scala 编译单元 – 这是在一个实体(模板实体)内部的一组定义,该实体可以是对象、类或特质。我们将在稍后详细讨论 Scala 语言的面向对象部分。现在,让我们看看基本语法。让我们在 REPL 中定义一些类:

> class X {  def f():Int = 1 }
> Class X defined  // answer in REPL

实体内部的定义可以是嵌套实体、函数或值:

> def f():Int = 1

在这里,函数f被定义为返回1。我们将在第三章中详细讨论这个函数。现在,让我们保持顶层视图:

> val x = 1

在这里,值x被定义为值1

> var y = 2

在这里,可变变量y被定义为值2

其他高级实体包括对象和特质。我们可以通过编写对象或特质定义来创建对象:

>  object O {  def f():Int =1  }
>  trait O {  def f():Int =1  }


我们将在下一章讨论类、对象和特质。

现在,让我们看看如何在 REPL 中定义一个名为 ZeroPoint 的对象。

完成步骤:

  1. sbt中输入以下命令以打开 REPL:

    sbt console
    
  2. 在 REPL 中输入以下命令:

    >  object ZeroPoint {
    >     val x:Int = 0
    >     val y:Int = 0
    > }
    

表达式的基语法

Scala 是一种基于表达式的语言,这意味着一切(在函数和值/变量定义的右侧)都是表达式。

一些基本表达式包括:

  • 基本表达式:常量或值/变量名。

  • 函数调用:这些可以是:

    • 常规函数调用f(x, y)

    • 运算符调用语法:

    • 二进制:x + y.

    注意

    任何带有参数的方法都可以用作二元运算符。一组预定义的二元运算符类似于 Java:

  • 一元:!x

  • 构造函数:new x创建类 x 的一个实例。

  • 不可变变量的赋值:

    • y = 3:将值3赋给y

    • x = 3:这是一个编译器错误,无法赋值。

  • 块:

    { A; B }
    

    块表达式的值是最后一个表达式。注意,如果AB位于不同的行上,则可以省略;。此语法的语法如下所示:

    {
       A
       B
    }
    

上述语法将产生与 { A; B } 相同的输出。

  • 控制结构

    • if语句:

             >  if (1 == 1)  "A"  else "B"
             - let's eval one in REPL
      
    • match/case 表达式:

          >  x match {
               case "Jon"  =>  doSomethingSpecialForJon()
               case "Joe" =>   doSomethingSpecialForJoe()
               case   _   => doForAll()
            }
      
    • 循环:

    • while/do

           var i=0
            var s=0
            while(i < 10) {
                s = s+i
                i = i +1
            }
      
    • Do/while

    • Foreachfor

高阶函数的快捷方式将在第四章Scala 集合中详细描述。

我们将查看定义一个打印屏幕上内容并调用主函数的主函数。

  1. 你应该已经打开了project1。如果没有,请将其导入 IDE。

  2. 在对象定义内插入新方法。

  3. main方法中插入调用。

完整的方法可能看起来像这样:

  object Chatbot1 {def printHello():Unit = {
println("Hello")}def main(args: Array[String]): Unit = {
printHello() … // unchanged code here
     }
}

单元测试

在任何大于算术运算的程序中,程序员应该在可能的情况下确保新更改不会破坏旧功能。

最常见的做法是单元测试,这是程序员在开发过程中并行测试代码功能,通过创建测试代码来验证代码是否真正满足他们的要求。

本节的主题将介绍 Scala 中的单元测试工具。

向我们的项目添加测试

让我们在我们的小程序中添加测试。我们将在 IDE 中导入<for-students/lesson1/2-project>

这是 Scala 项目的目录结构。为了添加测试,我们应该做以下操作:

  • 将测试依赖项添加到build.sbt

  • 在源测试目录中编写测试

为了添加依赖项,让我们在我们的build.sbt中添加以下行:

libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.4" % "test"

这是一个 Scala DSL(领域特定语言)中的表达式,这意味着我们应该将 scalatest 添加到我们的库依赖集合中。运算符 %%% 用于形成发布的工件名称和分类器。你可以参考 sbt 文档以获取更多详细信息:www.scala-sbt.org/1.x/docs/Library-Dependencies.html

在编译之前,sbt 将从公共仓库(Maven central)下载 scalatest,在运行测试时,它将 scalatest 添加到类路径中。

我们现在将从命令行运行 sbt 测试。

  1. 在命令行环境中,导航到项目的根目录并选择以下测试:

      Lesson 1/2-project
    
  2. 如果你正在使用 Unix/Linux 机器,并且你的代码位于家目录的 courses/pactscala 中,那么运行以下命令:

     > cd  ~/courses/packscala/Lesson 1/2-project
    
  3. 运行以下命令:`

        > sbt test
    
  4. 你将得到预期的输出,其中包括以下字符串:

    [info] ExampleSpec:
    [info] - example test should pass
    [info] StepTest:
    [info] - step of unparded word must be interesting
    

我们现在将展示如何在 IDEA IDE 中运行 sbt 测试。

我们现在将在 IDEA IDE 中运行 sbt 测试。

  1. 在 IDE 中打开项目。

  2. 导航到 运行/编辑配置将测试添加到我们的项目中

  3. 选择 sbt test 作为配置。

    • 选择复选框 使用 sbt

    将测试添加到我们的项目中

  4. 选择 运行 sbt-test

在测试内部

现在,让我们看看一个简单的测试:

package com.packt.courseware.l1
import org.scalatest.FunSuite

class ExampleSpec extends FunSuite {

  test("example test  should pass") {
     assert(1==1)
  }

}

这里,我们定义了一个继承自 scalatest FunSuite 的类。

FunSuite 类初始化并添加到测试集合中时,名为 example test should pass 的测试将被调用,并作为参数断言一个表达式。现在这看起来像是魔法,但我们将向你展示如何在下一章中构建这样的 DSL。

让我们借助 sbt 运行我们的测试:

sbt test

此命令将运行所有测试并评估测试表达式。

现在,我们将添加另一个测试。

  1. 在同一文件中添加一个额外的测试:src/test/scala/com/packt/courseware/l1/ExampleSpec.scala in 2-project

  2. 我们编写了一个 trivial 测试,它断言一个 false 表达式:

          test("trivial")  {
                assert(false)
           }
    
  3. 运行测试并查看错误报告。

  4. 在 assert 中的表达式取反,以便测试通过:

          test("trivial")  {
                assert(true)
           }
    
  5. 再次运行 sbt 测试以确保所有测试都通过。

运行 Chatbot 的测试

记住,当我们编写 chatbot 时,我们想要测试一个功能。我们的原始程序只有一个函数(main),它包含所有逻辑,不能分割成可测试的部分。

让我们看看版本 2。

注意

请将 Lesson 1/2-project 导入到你的 IDE 中。

package com.packt.courseware.l1

import java.time.LocalTime
import java.time.format.DateTimeFormatter
import scala.io.StdIn

case class LineProcessResult(answer:String,timeToBye:Boolean)

object Chatbot2 {

  def main(args: Array[String]): Unit = {
    val name = StdIn.readLine("Hi! What is your name? ")
    println(s" $name, tell me something interesting, say 'bye' to end the talk")

    var c = LineProcessResult("",false)
    while(!c.timeToBye){
      c = step(StdIn.readLine(">"))
      println(c.answer)
    }

  }

  def step(input:String): LineProcessResult = {
    input match {
      case "bye" => LineProcessResult("ok, bye", true)
      case "time" => LineProcessResult(LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss")),false)
      case _ => LineProcessResult("interesting...", false)
    }
  }

}

这里,我们看到了一些新的结构:

LineProcessingResult 是一个案例类,其中存储了处理某一行(即,chatbot 答案和退出标志)的结果。

类名前的单词 case 是什么意思?

case类可以参与模式匹配(虽然我们调用一个case),通常用于数据对象。我们将在下一章中查看case类。重要的是要注意,可以使用LineProcessingResult(x,y)语法(即不使用new)以及传递给case类构造函数的参数(answerstimeToBye)来创建case类的实例,这些参数自动成为case类的实例变量。

处理单行功能封装在step方法中,我们可以对其进行测试。

Step从方法参数接收输入,而不是从System.in接收,这使得测试更加容易。在直接测试main方法的情况下,我们需要在test之前替换System.in,并在测试完成后返回一个。

好的,让我们专注于第一个测试:

package com.packt.courseware.l1

import org.scalatest.FunSuite

class StepTestSpec extends FunSuite {

  test("step of unparded word must be interesting") {
    val r = Chatbot2.step("qqqq")
    assert(! r.timeToBye)
    assert(r.answer == "interesting...")
  }

}

以相同的方式编写第二个测试将是一个简单的任务。我们将在接下来的练习中查看这一点。

现在,让我们添加第二个测试,它检查 bye。

  1. 在我们的项目中向StepTestSpec类添加第二个测试:

    test("after bye, timeToBye should be set to true")
    {
    
    }
    
  2. 在这个测试中:

    • 使用bye作为参数调用step函数:

      val r = Chatbot2.step("bye")
      
    • 检查在此调用之后,返回类中的timeToQuit是否设置为true

      assert(! r.timeToBye)
      
  3. 整个代码应该如下所示:

    test("after bye, timeToBye should be set to true") {  
    val r = Chatbot2.step("bye")
    assert(! r.timeToBye)
    
    
  4. 运行sbt test.

编写时间查询的测试将是一个更复杂的任务。

请注意,我们无法使用具体的时间值运行测试,但至少我们可以确保机器人答案不能被解析回时间格式。

那么,我们怎样才能检查行答案并尝试将其转换回时间?解决方案如下所示:

test("local time must be parser") {
val r = Chatbot2.step("time")
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
val t = LocalTime.parse(r.answer,formatter)// assertion is not necessary
}

注意,不需要断言。如果时间不满足给定的格式,则将抛出异常。

将功能时间和效果时间分开测试是一种好习惯。为此,我们需要通过自己的提供者替换系统时间的提供者。

这将是下一章的第一个实际任务。

现在,让我们将日期命令添加到我们的聊天机器人程序中。

  1. 将以下代码添加到匹配语句中,以便检查date命令,该命令应以DD:MM:YYYY格式输出本地日期:

      case "date" => LineProcessResult(LocalDate.now().format(DateTimeFormatter.ofPattern("dd:YYYY-MM")),false)
    
  2. 为此函数添加一个测试用例。

  3. 生成的代码将如下所示:

    test("local date must be parser") {
    val r = Chatbot2.step("date")
    val formatter = DateTimeFormatter.ofPattern("dd:MM-YYYY")
    val t = LocalDate.parse(r.answer,formatter)// assertion is not necessary
    }
    

摘要

我们已经到达了本章的结尾。在本章中,我们学习了设置开发环境的各个方面。我们涵盖了 Scala 项目的结构,并确定了使用sbt构建和运行项目的方法。我们还涵盖了 REPL,它是运行 Scala 代码的命令行界面。我们还介绍了如何在 IDEA IDE 中开发和运行代码。最后,我们实现了与我们的简单chatbot应用程序的交互。

在下一章中,我们将涵盖 Scala 程序的结构,并深入探讨 Scala 中的面向对象特性,例如类、对象和特质。我们还将涵盖调用函数的语法和各种参数传递模型。

第二章. 基本语言特性

在上一章中,我们学习了设置开发环境的各个方面,其中我们涵盖了 Scala 项目的结构,并确定了使用sbt构建和运行项目。我们介绍了 REPL,它是运行 Scala 代码的命令行界面,以及如何在 IDEA IDE 中开发和运行代码。最后,我们实现了与我们的简单chatbot应用程序的交互。

在本章中,我们将探索所谓的 Scala 的“OO”部分,它允许我们构建类似于任何主流语言(如 Java 或 C++)的类似结构。Scala 的面向对象部分将涵盖类和对象、特性、模式匹配、案例类等。最后,我们将把所学的面向对象概念应用到我们的聊天机器人应用程序中。

通过观察编程范式的历史,我们会注意到第一代高级编程语言(Fortran、C、Pascal)是过程导向的,没有面向对象或函数式编程功能。然后,面向对象在 20 世纪 80 年代成为编程语言的热门话题。

到本章结束时,你将能够做到以下事情:

  • 识别非平凡 Scala 程序的结构

  • 识别如何使用主要面向对象功能:对象、类和特性

  • 识别函数调用语法和参数传递模式的细节

对象、类和特性

Scala 是一种多范式语言,它结合了函数式和面向对象编程。现在,我们将探索 Scala 的传统面向对象编程功能:对象、类和特性。

这些功能在某种意义上是相似的,因为每个都包含一些数据集和方法,但在生命周期和实例管理方面是不同的:

  • 当我们需要一个只有一个实例的类型(例如单例)时,会使用对象。

  • 当我们需要许多实例,并且可以使用 new 运算符创建时,会使用类。

  • 特性用于将混合到其他类中

注意,没有必要在代码中导航,因为这已经在示例中暴露了。

对象

在上一章中,我们看到了一个对象。现在让我们浏览我们的代码库,并在Lesson 2/3-project目录下打开名为Main的文件:

object Chatbot3 {
val effects = DefaultEffects
def main(args: Array[String]): Unit = {
   ….}
   def createInitMode() = (Bye or CurrentDate or CurrentTime) otherwise InterestingIgnore
}

这只是一组定义,被组合成一个对象,并且是静态可用的。也就是说,这是单例模式的实现:我们只有一个给定类型的对象实例。

在这里,我们可以看到值的定义(val effects)和主要函数。语法或多或少是可见的。一个不那么明显的事情是,所表示的valvar定义不是普通字段,而是内部字段和函数对:var-sgettersetter函数。这允许通过val-s覆盖def-s

注意,对象定义中的名称是对象的名称,而不是类型的名称。对象的类型Chatbot3可以通过Chatbot3.type访问。

让我们定义对象并调用一个方法。我们还将尝试将对象分配给一个变量。

注意

你应该在 IDEA 中打开 project-3

  1. 导航到项目结构并找到 com.packt.courseware.l3 包。

  2. 右键单击并从上下文菜单中选择 创建类

  3. 在名称字段中输入 ExampleObject,并在表单的类型字段中选择 object

  4. IDEA 将在对象中生成文件。

  5. 在对象定义中插入以下内容:

       def hello(): Unit = {
           println("hello")
        }
      - navigate to main object
       Insert before start of main method:
        val example = ExampleObject
       Insert at the beginning of the main method:
         example.hello()
    

类是抽象的下一步。以下是一个类定义的示例:

package com.packt.courseware.l4
import math._
class PolarPoint(phi:Double, radius:Double) extends Point2D
{
require(phi >= - Pi && phi < Pi )
require(radius >= 0)
def this(phi:Double) = this(phi,1.0)
override def length = radius
def x: Double = radius*cos(phi)
def y: Double = radius*sin(phi)
def * (x:Double) = PolarPoint(phi,radius*x)
}

这里是一个在类定义中指定了参数(phiradius)的类。类方法之外(如 require 语句)的语句构成了主构造函数的主体。

下一个定义是一个次要构造函数,它必须在第一行调用主构造函数。

我们可以使用 new 运算符创建对象实例:

val p = new PolarPoint(0)

默认情况下,成员访问修饰符是 public,因此一旦我们创建了一个对象,我们就可以使用它的方法。当然,也可以将方法定义为 protectedprivate

有时,我们希望构造函数参数在类成员的角色中可用。为此存在一种特殊语法:

case class PolarPoint(val phi:Double, val radius:Double) extends Point2D

如果我们将 val 作为构造函数参数的修饰符(phi),那么 phi 就成为类的一个成员,并将作为字段可用。

如果你浏览典型 Scala 项目的源代码,你会注意到与类定义一起经常定义一个与类同名的对象。这样的对象被称为类的 companion 对象:

object PolarPoint{
def apply(phi:Double, r:Double) = new PolarPoint(phi,r)
}

这通常是实用函数的典型位置,在 Java 世界中通常由 static 方法表示。

方法名称也存在,这允许你在调用侧使用特殊语法糖。我们稍后会告诉你所有这些方法。现在,我们将讨论 apply 方法。

当一个方法命名为 apply 时,它可以通过函数调用括号来调用(例如,如果 applyx 中定义,则 x(y)x.apply(y) 相同)。

传统上,伴随对象的 apply 方法通常用于实例创建,以允许不使用 new 运算符的语法。所以,在我们的例子中,PolarPoint(3.0,5.0) 将被解耦为 PolarPoint.apply(3.0,5.0)

现在,让我们定义一个带有 length 方法的案例类 CartesianPoint

  1. 确保在 IDE 中打开了 Lesson 2/4-project 项目。

  2. 使用名称 CartesianPoint 创建一个新的 Scala 类。

  3. 代码应该像这样:

    case class CartesianPoint(x:Double, y:Double) extends Point2D {
    override def length(): Double = x*x + y*y
    }
    

平等性和案例类

通常,存在两种类型的平等:

  • 扩展性,当两个对象的所有外部属性都相等时,它们是相等的。

    • 在 JVM 中,用户可以通过重写对象的 equalshashCode 方法来实现这种行为。

    • 在 Scala 表达式中,如果 x 是引用类型(例如,一个类或对象),则 x == yx.equals(y) 的快捷方式。

  • 有意(或引用),其中具有相同属性的两个对象可以不同,因为它们是在不同的时间和环境中创建的。

    • 在 JVM 中,这是引用的比较;(Java 中的(x == y)和 Scala 中的(x eq y))。

看看我们的PolarPoint,如果我们想PolarPoint(0,1)等于PolarPoint(0,1),那么我们必须重写equalshashCode

Scala 语言提供了一种类风味,它将自动执行这项工作(以及其他一些工作)。

让我们看看case类:

case class PolarPoint(phi:Double, radius:Double) extends Point2D

当我们将一个类标记为案例类时,Scala 编译器将生成以下内容:

  • equalshashCode方法,这些方法将通过组件比较类

  • A toString方法,它将输出组件

  • A copy方法,它将允许你创建类的副本,其中一些字段已更改:

    val p1 = PolarPoint(Pi,1)
    val p2 = p1.copy(phi=1)
    
  • 所有参数构造函数都将成为类值(因此,我们不需要写val

  • 具有 apply 方法(用于构造器快捷方式)和unapply方法(用于案例模式中的解构)的类的伴生对象

现在,我们将探讨说明值相等和引用相等之间的差异。

  1. test/com.packt.courseware.l4中创建一个工作表。

    注意

    要创建工作表,导航到包,然后右键单击并从下拉菜单中选择创建 Scala 工作表。

  2. 在导入之后,在此文件中定义一个非案例类,包含字段:

    class NCPoint(val x:Int, val y:Int)
    val ncp1 = new NCPoint(1,1)
    val ncp2 = new NCPoint(1,1)
    ncp1 == ncp2
    ncp1 eq ncp2
    

    注意

    注意,结果是false

  3. 定义具有相同字段的案例类:

    case class CPoint(x:Int, y:Int)
    
  4. 编写一个类似的测试。注意差异:

    val cp1 = CPoint(1,1)val cp2 = CPoint(1,1)cp1 == cp2cp1 eq cp2
    

模式匹配

模式匹配是一种构造,它最初于 1972 年左右引入到 ML 语言家族中(另一种类似的技术也可以被视为模式匹配的前身,这发生在 1968 年的 REFAL 语言中)。在 Scala 之后,大多数新的主流编程语言(如 Rust 和 Swift)也开始包含模式匹配结构。

让我们看看模式匹配的使用:

val p = PolarPoint(0,1)
val r = p match {
case PolarPoint(_,0) => "zero"
case x: PolarPoint if (x.radius == 1) => s"r=1, phi=${x.phi}"
case v@PolarPoint(x,y) => s"(x=${x},y=${y})"
case _ => "not polar point"
}

在第二行,我们看到一个 match/case 表达式;我们将p与 case-e 子句的序列进行匹配。每个 case 子句包含一个模式和主体,如果匹配的表达式满足适当的模式,则评估主体。

在这个例子中,第一个案例模式将匹配任何半径为0的点,即_匹配任何。

第二 - 这将满足任何半径为 1 的PolarPoint,如可选模式条件中指定的。注意新值(x)被引入到主体上下文中。

第三 - 这将匹配任何点;将xy绑定到phi和相应的radius,并将v绑定到模式(v与原始匹配模式相同,但具有正确的类型)。

最终的案例表达式是一个default案例,它匹配p的任何值。

注意,模式可以嵌套。

如我们所见,案例类可以参与案例表达式,并提供将匹配值推入主体内容的方法(这是解构)。

现在,是时候使用 match/case 语句了。

  1. 在当前项目的测试源中创建一个名为 Person 的类文件。

  2. 创建一个名为 Person 的案例类,包含成员 firstNamelastName:

    case class Person(firstName:String,lastName:String)
    
  3. 创建一个伴随对象并添加一个接受 person 并返回 String 的方法:

    def classify(p:Person): String = {
    // insert match code here .???
    }
    }
    
  4. 创建一个 case 语句,它将打印:

    • 如果人的名字是 "Joe",则输出 "A"

    • 如果人不满足其他情况,则输出 "B"

    • 如果 lastName 以小写字母开头,则输出 "C"

  5. 为此方法创建一个测试用例:

    class PersonTest extends FunSuite {
    test("Persin(Joe,_) should return A") {
    assert( Person.classify(Person("Joe","X")) == "A" )
    }
    }
    }
    

特性

特性用于对方法和值进行分组,这些方法和值可以在其他类中使用。特性的功能与其他特性和类混合,在其他语言中,这种适当的构造被称为 mixins。在 Java 8 中,接口类似于特性,因为可以定义默认实现。但这并不完全准确,因为 Java 的默认方法不能完全参与继承。

让我们看看以下代码:

trait Point2D {
def x: Double
def y: Double
def length():Double = x*x + y*y}

这里有一个特性,它可以被 PolarPoint 类扩展,或者与 CartesianPoint 使用以下定义一起使用:

case class CartesianPoint(x:Double, y:Double) extends Point2D

特性的实例不能创建,但可以创建扩展特性的匿名类:

val p = new Point2D {override def x: Double = 1
override def y: Double = 0}
assert(p.length() == 1)

这里是一个特性的示例:

trait A {
def f = "f.A"
}
trait B {def f = "f.B"def g = "g.B"
}
trait C extends A with B {override def f = "f.C" // won't compile without override.
}

正如我们所见,冲突的方法必须被覆盖:

然而,还有一个谜题:

trait D1 extends B1 with C{override def g = super.g}
trait D2 extends C with B1{override def g = super.g}

D1.g 的结果将是 g.B,而 D2.g 将是 g.C。这是因为特性被线性化为序列,其中每个特性覆盖了前一个特性中的方法。

现在,让我们尝试在特性层次结构中表示菱形。

创建以下实体:

Component – 一个具有 description() 方法的 base 类,该方法输出组件的描述。

Transmitter – 一个生成信号并具有名为 generateParams 的方法的组件。

Receiver – 一个接受信号并具有名为 receiveParams 的方法的组件。

无线电 – 一个 TransmitterReceiver。编写一组特性,其中 A 被建模为继承。

这个问题的答案应该是以下内容:

trait Component{
def description(): String
}
trait Transmitter extends Component{
def generateParams(): String
}
trait Receiver extends Component{
def receiverParame(): String
}
trait Radio extends Transmitter with Receiver

自类型

在 Scale-trait 中,有时可以看到自类型注解,例如:

注意

对于完整的代码,请参阅 Code Snippets/Lesson 2.scala 文件。

trait Drink
{
 def baseSubstation: String
 def flavour: String
 def description: String
}

trait VanillaFlavour
{
 thisFlavour: Drink =>

 def flavour = "vanilla"
 override def description: String = s"Vanilla ${baseSubstation}"
}

trait SpecieFlavour
{
 thisFlavour: Drink =>

 override def description: String = s"${baseSubstation} with ${flavour}"
}

trait Tee
{
  thisTee: Drink =>

  override def baseSubstation: String = "tee"

  override def description: String = "tee"

    def withSpecies: Boolean = (flavour != "vanilla")
}

这里,我们看到 identifier => {typeName} 前缀,这通常是一个自类型注解。

如果指定了类型,则该特性只能混合到该类型中。例如,VanillaTrait 只能与 Drink 混合。如果我们尝试将其与另一个对象混合,我们将收到一个错误。

注意

如果 Flavor 不是从 Drink 扩展,但可以访问 Drink 方法,如 Flavor 中的外观,那么我们将其置于 Drink 内部。

此外,可以使用自注解而不指定类型。这在嵌套特性中很有用,当我们想要调用封装特性的 "this" 时:

trait Out{
thisOut =>

trait Internal{def f(): String = thisOut.g()
  def g(): String = .
  }
def g(): String = ….
}

有时,我们可以将一些大型类的组织看作是一组特质,围绕一个“基础”分组。我们可以将这种组织看作是“蛋糕”,它由自注解的特质“Pieces:”组成。我们可以通过更改混入的特质来改变一个部分到另一个部分。这种代码组织方式被称为“蛋糕模式”。请注意,使用蛋糕模式通常是有争议的,因为它很容易创建一个“上帝对象”。另外,请注意,在蛋糕模式内部重构类层次结构更难实现。

现在,让我们探索注解。

  1. 创建一个带有VanillaFlavour的 Tee 实例,该实例引用description

    val tee = new Drink with Tee with VanillaFlavour
    val tee1 = new Drink with VanillaFlavour with Tee
    tee.description
    tee1.description
    
  2. 尝试在Tee类中覆盖描述:

    Drinks文件中取消注释Tee: def description = plain tee

    检查是否有错误信息出现。

  3. 创建第三个对象,从Drink派生,带有TeeVanillaFlavour,具有重载的描述:

    val tee2 = new Drink with Tee with VanillaFlavour{
    override def description: String ="plain vanilla tee"
    }
    

    注意

    对于完整代码,请参考Code Snippets/Lesson 2.scala文件。

还要注意,存在特殊的方法语法,必须在覆盖方法之后进行“混合”,例如:

trait Operation
{

  def doOperation(): Unit

}

trait PrintOperation
{
  this: Operation =>

  def doOperation():Unit = Console.println("A")
}

trait LoggedOperation extends Operation
{
  this: Operation =>

  abstract override def doOperation():Unit = {
    Console.print("start")
    super.doOperation()
    Console.print("end")
  }
}

我们可以看到标记为abstract override的方法可以调用super方法,这些方法实际上是在特质中定义的,而不是在这个基类中。这是一种相对罕见的技巧。

特殊类

有几个类具有特殊语法,在 Scala 类型系统中起着重要作用。我们将在稍后详细讨论这个问题,但现在让我们只列举一些:

  • 函数:在 Scala 中,这可以编码为A => B

  • 元组:在 Scala 中,这可以编码为(A,B), (A,B,C)等等,这是Tuple2[A,B]Tuple3[A,B,C]等的语法糖。

我们聊天机器人的面向对象

现在我们已经了解了理论基础知识,让我们看看这些设施以及它们在我们程序中的使用方式。让我们在我们的 IDE 中打开Lesson 2/3-project并扩展我们在上一章中开发的聊天机器人。

解耦逻辑和环境

要做到这一点,我们必须解耦环境和逻辑,并在main方法中仅集成一个。

让我们打开EffectsProvider类:

注意

对于完整代码,请参考Code Snippets/Lesson 2.scala文件。

trait EffectsProvider extends TimeProvider {

 def input: UserInput

 def output: UserOutput

}

object DefaultEffects extends EffectsProvider
{
 override def input: UserInput = ConsoleInput

 override def output: UserOutput = ConsoleOutput

 override def currentTime(): LocalTime = LocalTime.now()

 override def currentDate(): LocalDate = LocalDate.now()
}

在这里,我们将所有效果封装到我们的特质中,这些特质可以有不同实现。

例如,让我们看看UserOutput

对于完整代码,请参考Code Snippets/Lesson 2.scala文件。

trait UserOutput {

 def write(message: String): Unit

 def writeln(message: String): Unit = {
  write(message)
  write("\n")
 }

}

object ConsoleOutput extends UserOutput
{

 def write(message: String): Unit = {
  Console.print(message)
 }
}

在这里,我们可以看到特性和对象,它们实现了当前特质。这样,当我们需要接受来自标准输入之外的命令,比如来自聊天机器人 API 或 Twitter 的命令时,我们只需要更改UserOutput/ConsoleOutput接口的实现。

现在是时候实现ConsoleOutputDefaultTimeProvider了。

main中将???替换为适当的构造函数。

实现ConsoleOutputDefaultTimeProvider的步骤如下:

  1. 确保在 IDE 中打开Lesson 2/3-project

  2. UserOutput文件中,找到ConsoleOutput文件,将???改为write方法的主体。结果方法应该看起来像这样:

    object ConsoleOutput extends UserOutput{
    def write(message: String): Unit = {
    Console.print(message)
    }
    }
    }
    
  3. TimeProvider文件中,添加一个扩展自TimeProvider并实现currentTimecurrentDate函数的DefaultTimeProvide对象。结果代码应该看起来像这样:

    object DefaultTimeProvider extends TimeProvider {
    override def currentTime(): LocalTime = LocalTime.now()
    
      override def currentDate(): LocalDate = LocalDate.now()
      }
    
     }
    

密封特性和代数数据类型

让我们处理第二个问题——让我们将聊天机器人模式的逻辑封装到特质中,这个特质将只处理逻辑,不处理其他任何事情。看看以下定义:

trait ChatbotMode {
def process(message: String, effects: EffectsProvider): LineStepResult
def or(other: ChatbotMode): ChatbotMode = Or(this,other)
def otherwise(other: ChatbotMode): ChatbotMode = Otherwise(this,other)
}

现在,让我们忽略orotherwise组合子,看看process方法。它接受输入消息和效果,并返回处理结果,这可能是一个失败或发送给用户的消息,带有模式的下一个状态:

sealed trait LineStepResultcase class Processed(
  answer:String,
  nextMode: ChatbotMode,
 endOfDialog:Boolean) extends LineStepResult
case object Failed extends LineStepResult

在这里,我们可以看到一个新修饰符:sealed

当一个特性(或类)被密封时,它只能在定义它的同一个文件中进行扩展。由于这个原因,你可以确信,在你的类家族中,没有人能够在你的项目中添加一个新的类。如果你使用 match/case 表达式进行用例分析,编译器可以进行详尽的检查:所有变体都存在。

从一个sealed特质扩展的 case 类/对象家族的构造通常被称为代数数据类型(ADT)。

这个术语来自 1972 年的 HOPE 语言(爱丁堡大学),在那里所有类型都可以通过代数运算从一个初始类型集合中创建:其中之一是一个命名的product(在 Scala 中看起来像 case 类)和distinct union(由密封特质和子类型建模)。

在领域建模中使用 ADT 是有益的,因为我们可以对领域模型进行明显的用例分析,并且没有弱抽象;我们可以实现各种设计,这些设计可以在未来的模型中添加。

回到我们的ChatbotMode

bye时,我们必须退出程序。

这很简单——只需定义适当的对象:

object Bye extends ChatbotMode {
 override def process(message: String, effects: EffectsProvider): LineStepResult =
  if (message=="bye") {
   Processed("bye",this,true)
  } else Failed
}

现在,我们将查看为CurrentTime查询创建相同的模式。

注意

这个练习的代码可以在Lesson 2/3-project中找到。

  1. CurrentTime模式包中创建一个新文件。

  2. Main中的模式链中添加一个(例如,createInitMode的 Modify 定义)。

  3. 确保通过时间功能检查的test通过。

下一步是从几个更简单的模式中创建一个更大的模式。让我们看看这个扩展了两个模式并可以选择能够处理传入消息的模式:

case class Or(frs: ChatbotMode, snd: ChatbotMode) extends ChatbotMode
{
override def process(message: String, effects: EffectsProvider): LineStepResult ={
frs.process(message, effects) match {
case Processed(answer,nextMode,endOfDialog) => Processed(answer, Or(nextMode,snd),endOfDialog)
case Failed => snd.process(message,effects) match {
case Processed(answer,nextMode,endOfDialog) => Processed(answer, Or(nextMode,frs),endOfDialog)
case Failed => Failed}}
 }}

在这里,如果frs可以处理一条消息,那么处理这条消息的结果将被返回。它将包含一个答案。NextMode(它将接受下一个序列)与frs中的nextMode相同,处理结果和snd

如果frs不能回答这个问题,那么我们尝试snd。如果snd's处理成功,那么,在下一个对话步骤中,第一个消息处理器将是一个nextStep,来自snd。这允许模式形成自己的对话上下文,就像一个理解你语言的人。这将是下次你问的第一个问题。

我们可以用这样的组合子将简单的模式链接成复杂的模式。Scala 允许我们使用花哨的语法进行链式调用:任何只有一个参数的方法都可以用作二元运算符。所以,如果我们定义ChatbotMode中的or方法,我们就能组合我们的模式:

def or(other: ChatbotMode): ChatbotMode = Or(this,other)

然后在main中,我们可以写这个:

 def createInitMode() = (Bye or CurrentDate or CurrentTime) otherwise InterestingIgnore

Otherwise看起来非常相似,只有一个区别:第二个模式必须始终是第二个。

当我们写一个时,它看起来像这样。

def main(args: Array[String]): Unit = {
val name = StdIn.readLine("Hi! What is your name? ")
println(s" $name, tell me something interesting, say 'bye' to end the talk")
var mode = createInitMode()
var c = Processed("",mode,false)
while(!c.endOfDialog){
c = c.nextMode.process(effects.input.read(),effects) match {
case next@Processed(_,_,_) => next
case Failed => // impossible, but let be here as extra control.
      Processed("Sorry, can't understand you",c.nextMode,false)}
  effects.output.writeln(c.answer)}}

我们可以使其更好:让我们首先将交互(程序询问用户姓名的地方)移动到模式。

现在,我们将第一个交互移动到模式

这里,我们将创建一个mode,它记得你的名字并为你创建一个。

  1. 定义一个新的对象,它实现了chatbot特质,当运行第一个单词my name is时,接受一个名字并回答hi,然后告诉你你的名字:

    case class Name(name:String) extends ChatbotMode {
    override def process(message: String, effects: EffectsProvider): LineStepResult = {
    message match {
    case "my name" => if (name.isEmpty) {
    effects.output.write("What is your name?")
    val name = effects.input.read()
    Processed("hi, $name", Name(name), false)
    } else {
    Processed(s"your name is $name",this,false)}case _ =>  Failed
    }
    }
    }
    }
    
  2. 将此对象添加到main:节点序列中

    def createInitMode() = (Bye or CurrentDate or CurrentTime or Name("")) otherwise InterestingIgnore
    
  3. 向 testcase 添加一个具有此功能的测试,注意自定义效果的使用:

    注意

    对于完整的代码,请参阅Code Snippets/Lesson 2.scala文件。

    test("step of my-name") {
      val mode = Chatbot3.createInitMode()
      val effects = new EffectsProvider {
        override val output: UserOutput = (message: String) => {}
    
        override def input: UserInput = () => "Joe"
    
        override def currentDate(): LocalDate = Chatbot3.effects.currentDate()
    
        override def currentTime(): LocalTime = Chatbot3.effects.currentTime()
      }
      val result1 = mode.process("my name",effects)
      assert(result1.isInstanceOf[Processed])
      val r1 = result1.asInstanceOf[Processed]
      assert(r1.answer == "Hi, Joe")
      val result2 = r1.nextMode.process("my name",effects)
      assert(result2.isInstanceOf[Processed])
      val r2 = result2.asInstanceOf[Processed]
      assert(r2.answer == "Your name is Joe")
    
    }
    

函数调用

现在,我们将看看如何在 Scala 中实现函数调用。

语法小技巧

Scala 提供了灵活的语法,值得花几分钟时间了解这个概念。

命名参数

以下是一个函数,f(a:Int, b:Int)。我们可以使用命名参数语法调用此函数:f(a = 5, b=10)。如果我们交换参数但保留正确的名称,方法仍然正确。

可以组合位置和命名函数调用——前几个参数可以是位置的。

例如:

def f(x:Int, y:Int) = x*2 + y
f(x=1,y=2) // 4
f(y=1,x=2) // 5

默认参数

当指定一个函数时,我们可以设置默认参数。然后,稍后当我们调用此函数时,我们可以省略参数,编译器将使用默认值:

def f(x:Int, y:Int=2) = x*2 + y
f(1) // 4

有可能通过命名和默认参数的组合创建一个舒适的 API。例如,对于具有 N 个组件的案例类,编译器生成一个具有 N 个参数的复制方法;所有这些都有默认值:

case class Person(firstName: String, lastName: String)
val p1 = Person("Jon","Bull")
val p2 = p1.copy(firstName = "Iyan")

现在,让我们将OrOtherwise组合器中的代码转换为使用copy方法而不是Processed构造函数。

  1. 将情况表达式更改为类型,检查(processed:Processed)或向情况类模式添加bind变量(processed@Processed(… )

  2. 在情况体中,使用copy方法而不是Processed构造函数:

    • 生成的代码应如下所示:

    • 如果学生在情况表达式中使用类型检查:

         case processed:Processed =>processed.copy(nextMode = Or(processed.nextMode,snd))
      
    • 如果学生使用绑定变量:

        case processed@Processed(answer,nextMode,endOfDialog) =>
           processed.copy(nextMode = Or(nextMode,snd))
      
  3. 对第二个匹配语句做同样的转换。

完整的代码如下所示:

case class Or(frs: ChatbotMode, snd: ChatbotMode) extends ChatbotMode{
override def process(message: String, 
           effects: EffectsProvider): LineStepResult = {
   frs.process(message, effects) match {
   case processed@Processed(answer,nextMode,endOfDialog) =>
   processed.copy(nextMode = Or(nextMode,snd))
   case Failed => snd.process(message,effects) match {
   case processed@Processed(answer,nextMode,endOfDialog) =>
   processed.copy(nextMode=Or(nextMode,frs))
   case Failed => Failed
   }
   }
   }
   }
}

柯里化形式(多个参数列表)

柯里化是一个用于描述将多个参数的函数转换为单个参数的函数的术语。我们将在下一章详细描述这个过程。

对于语法,我们可以使用多个参数列表非常重要:

def f1(x:Int,y:Int) = x + y 
def f2(x:Int)(y:Int) = x + y

在这里,f2是它的柯里化形式。它与f1具有相同的语义,但可以以不同的语法调用。这在需要视觉上分离参数时很有用。

特殊魔法方法

以下表格显示了各种魔法方法:

x.apply(y,z) x(y,z)
x.update(y,z) x(y)=z
x.y_=(z) x.y=z 方法 y 也必须被定义。
x.unary- -x 同样适用于 +, ~, !
x = x + y x += y 同样适用于 -,*,/,|,&

CartesianPoint中实现+

Lesson2打开之前的项目并实现CartesianPoint中的+

  1. 在你的 IDE 中,打开之前的项目(4-project,命名为coordinates)。

  2. CartesianPoint.scala文件中,添加以下定义的+方法:

     def +(v:CartesianPoint) = CartesianPoint(x+v.x,y+v.y)
    

参数传递模式

在本节中,我们将学习参数传递模式中的参数类型:by valueby nameby need

通过值

在前面的章节中,我们使用了默认的参数传递模式:by value,这是大多数编程语言的默认模式。

在这个模型中,函数调用表达式以以下方式评估:

  • 首先,所有参数都从左到右进行评估

  • 然后,函数被调用,参数被引用为评估过的参数:

    def f(x:Int) = x + x + 1
    f({ println("A "); 10 }) // A res: 21
    
  • 有时,我们听说 Java 参数模式,其中值通过by value传递,引用通过by reference(例如,如果我们把reference作为一个value传递给对象)

通过名称

by name参数传递模式的核心是参数在函数调用之前不会被评估,而是在目标函数中每次使用参数名称时:

def f(x: =>Int) = x + x + 1
f({ println("A "); 10 }) // A A res: 21

名称术语来自 Algol68:通过名称传递参数被描述为用参数体替换名称。这对编译器编写者来说是一个多年的挑战。

通过名称参数可以用于定义控制流表达式:

def until(condition: =>Boolean)(body: =>Unit) ={
while(!condition) body
}

注意,构造函数参数也可以通过名称传递:

class RunLater(x: =>Unit){
def run(): Unit = x
}
}

通过需要

By need仅在必要时评估参数一次。这可以通过by name调用和懒val来模拟:

def f(x: =>Int): Int = {lazy val nx = xnx + nx + 1
}
f({ println("A "); 10 }) // A res: 21

我们看到val的懒修饰符。懒值在第一次使用时进行评估,然后作为值存储在内存中。

懒值可以是特质、类和对象的组成部分:这是定义懒初始化的常用方式。

创建可运行的构造

让我们创建一个可运行的构造,其语法与Scalatest FunSuite相同,并且executor将返回true,如果test参数内的代码评估没有异常。

  1. 定义一个父类,其中包含将要捕获代码的变量。以下是一个可能的示例:

    class DSLExample {val name: String = "undefined"var code: ()=>Unit = { () => () }
    }
    
  2. 使用名称和按名称参数定义函数,该函数将填充此变量:

    def test(testName:String)(testCode: =>Unit):Unit = {
    name = testName
     code = () => testCode }
    
  3. 定义executor方法,该方法在 try/catch 块中使用命名参数。

    def run(): Boolean = {
    try {
     code()
     true} catch {
    case ex: Exception => 
    ex.printStackTrace()
    false
    	}
    }
    
    

将日志参数打印到控制台和文件

让我们创建一个log语句,该语句将参数打印到控制台和文件,但仅当在记录器构造函数中设置名为enabled的参数为 true 时。

  1. 使用参数和类定义logger。签名必须类似于以下这样:

    class Logger(outputStream:PrintStream, dupToConsole: Boolean, enabled: Boolean) {
    
         …. Inset method here
    
    }
    
  2. 使用按需参数定义方法,该参数仅在启用记录器时使用:

    def log(message: => String): Unit = {
    if (enabled) {
    	val evaluatedMessage = message
    	if (dupToConsole) {
    Console.println(evaluatedMessage)
    }
    outputStream.println(evaluatedMessage)
    	}
    }
    
    

让我们创建一个mode命令,该命令理解store name定义和remind定义。

  1. 定义一个新的对象,该对象实现了ChatbotMode特质,并具有数据结构(一个形成链表的密封特质)作为状态。

  2. 在处理store时,修改状态并回答ok. 在处理时,remind – 回答。

  3. testcase.添加测试

摘要

我们现在已经到达了本章的结尾。在本章中,我们涵盖了 Scala 的面向对象方面,如类、对象、模式匹配、自类型、案例类等。我们还实现了我们在聊天机器人应用程序中学到的面向对象概念。

在下一章中,我们将介绍 Scala 的函数式编程以及面向对象和函数式方法如何相互补充。我们还将介绍泛型类,这些类通常与模式匹配一起使用。我们还将介绍如何创建用户定义的模式匹配以及为什么它是有用的。

第三章。函数

在上一章中,我们介绍了 Scala 的面向对象方面,例如类、对象、模式匹配、自类型、案例类等。我们还在我们的聊天机器人应用程序中实现了我们学到的面向对象概念。

在本章中,我们将介绍使用 Scala 进行函数式编程以及面向对象和函数式方法如何相互补充。我们还将介绍泛型类,它们通常与模式匹配一起使用。我们还将介绍如何创建用户定义的模式匹配以及为什么它是有用的。

到本章结束时,你将能够:

  • 识别函数式编程的基本知识

  • 识别 Scala 中泛型类型的基本知识

  • 实现用户定义的模式匹配

  • 识别和使用函数式组合模式

函数

在本节中,我们将介绍函数式编程的基础,例如函数值和高阶函数。

函数值

函数是什么?我们熟悉的方法必须在作用域(类或对象)中定义:

  def f1(x:Int, y:Int): Int =  x + y

在 Scala 中,我们还可以定义一个函数值:

   val f2: (Int,Int) => Int = (x,y) => (x+y).

在这里,我们定义了一个函数值,其类型为 (Int,Int) => Int。当然,与所有类型声明一样,如果可以从上下文中推断出类型,则可以省略类型。因此,此的另一种语法可以是:

   val f2 = (x:Int,y:Int) => (x+y).

f1(1,2)f2(1,2) 都将强制评估。f1f2 之间的区别在于第二个是一个值,它可以存储在变量中或传递给另一个函数。

接受其他函数作为参数的函数称为高阶函数,例如:

def twice(f: Int => Int): Int => Int = x => f(f(x))

此函数返回函数,这些函数将参数应用两次。此用法的示例如下:

val incr2 = (x:Int) => x+2val incr4 = twice(incr2)
incr4(2)   //==6
twice(x=>x+2)(3)   // 7

通常,函数定义的语法如下:

val fname: (X1 … XN)  => Y = (x1:X1, … x2:XN) => expression

如果可以从上下文中推断出类型,则可以省略类型签名。

现在,让我们看看如何定义一个函数变量。

在 IDE 工作表的 REPL 中定义函数变量:

  val f:  Int=>Int = x => x+1

从面向对象的角度定义函数

当查看 Scala 源代码时,我们将看到以下定义:

trait Function1[-T1, +R] {
def apply(v1: T1): R
  ….
}

在这里,Function1 是一个具有一个参数的函数的基础特质。Function1 有一个抽象方法:apply。Scala 为 apply 方法的调用提供了语法糖。

T1RFunction1 的类型参数。T1 是第一个参数的类型,而 R 是结果类型。

类型参数前的符号 [ -T1, +R ] 表示参数的 逆变协变;我们将在稍后详细讨论一个。现在,让我们编写一个定义:

 F[T]  covariant,  iff   A <: B =>  F[A]  <:  F[B]
  F[T]   contravariant  iff  A >: B => F[A] <: F[B]

一个具有一个参数的函数的值,f: A => B,只是一个 Function1[A,B] 特质的实例。对于具有两个参数的函数,我们有 Function2[T1,T2,R] 等等。我们可以使用面向对象的设施以以下形式重写使用 twice 的示例:

//   val twice(f:Int=>Int):Int=>Int = x => f(f(x))
object  Twice extends Function1[Function1[Int,Int],Function1[Int,Int]] 
{
	def apply(f: Function1[Int,Int]): Function1[Int,Int] =new Function1[Int,Int] 
	{
		def apply(x:Int):Int =   f.apply(f.apply(x))
	}
}

在这里,我们定义了一个具有之前描述的函数相同行为的 Twice 对象:

val incr2 = (x:Int) => x+2
val incr4 = Twice(incr2)
incr4(2)  //=6
Twice(x=>x+2)(5) //=9

总结来说,我们可以这样说:

  • 函数值是适当函数特质的实例。

  • 函数值的调用是函数特质中 apply() 方法的调用。

现在,是时候创建一些函数了。

  1. 在你的项目中打开一个空白工作表。

  2. 定义一个接受二元函数和参数的函数,并返回该函数的应用:

    g(f: (Int,Int)=>Int, x: Int, y:Int): Int = f(x,y)
    
  3. 通过使用柯里化重新表述函数来改善语法:

    g(f: (Int,Int)=>Int)( x: Int, y:Int): Int = f(x,y)
    
  4. 部分应用:编写 fix 函数,该函数接受二元函数和参数,并返回一个一元函数,该函数将应用放置的函数和参数。例如,定义 g 为:val g = fix1((x,y)=>x+y,3)

  5. g(2) 应该被评估为 5

    fix(f: (Int,Int)=>Int)( x: Int): Int => Int = y => f(x,y)
    

转换

我们有方法和函数值,它们可以完成相同的事情。期望它们之间有转换是合乎逻辑的,例如,将方法分配给函数变量或将函数作为面向对象接口传递的能力。

Scala 提供了一种特殊的语法,用于将方法转换为值。我们只需在方法名称后添加下划线(_)即可。

例如:

val printFun = Console.print _

此外,一个函数值可以隐式转换为所谓的 SAM(单抽象方法)特质。SAM 特质只有一个抽象方法,我们可以在需要 SAM 特质(并且函数类型符合方法签名)的上下文中传递一个函数:

val th = new Thread(()=> println(s"threadId=${Thread.currentThread().getId}"))
th.start()

在这里,我们将零参数的函数传递给 Thread 构造函数,该构造函数接受 Runnable 接口。

在 Scala 中,我们有三种不同的方式来实现延迟调用功能。

定义和测量单元函数的时间

定义一个函数,该函数接受一个运行单元函数并测量纳秒时间的函数。这需要多长时间?以三种不同的方式实现它。

  1. 编写一个接受其他函数的函数。运行一个并测量执行时间:

    def measure1(f: Unit => Unit): Long = {
    	val startTime = System.nanoTime()
    f()
    	val endTime = System.nanoTime()
    endTime – startTime
    }
    
    
  2. 编写一个接受按名参数的函数。运行一个并测量执行时间:

    def measure2(f: => Unit): Long = {
    	val startTime = System.nanoTime()
    	f
    	val endTime = System.nanoTime()
    	endTime – startTime
    }
    
    
  3. 编写一个扩展 Function1 特质的对象,并在 apply 方法中做同样的事情:

    object Measure3 extends Function1[Unit=>Unit,Long]
    {
    	override def apply(f: Unit=>Unit) = {
    		val startTime = System.nanoTime()
    		f()
    		val endTime = System.nanoTime()
        endTime – startTime
       }
    }
    

函数定义中的语法糖

有时候,编写如 x => x+1 这样的表达式看起来过于冗长。为了解决这个问题,存在语法糖,它允许你以紧凑和惯用的方式编写小的函数表达式。根本不需要写左边的部分,在编写时,使用 _(下划线)代替参数。第一个下划线表示第一个参数,第二个下划线表示第二个参数,依此类推:

_ + 1   is a shortcut for  x =>  x+1,   _ + _  -- for (x,y) => x + y.

一些函数,如 (x,y) => x*x + y,不能用这种表示法表示。

部分函数

部分函数也被称为部分定义函数——一些值存在于函数输入域中,其中该函数未定义。

让我们看看一个简化的定义:

trait PartialFunction[-A, +B] extends (A => B) { 
/** Checks if a value is contained in the function's domain.
*
*@param  x   the value to test
*  @return `'''true'''`, iff `x` is in the domain of this function, `'''false'''` otherwise.*/
def isDefinedAt(x: A): Boolean
}

除了 apply 方法外,还有一个 isDefinedAt 方法,它返回 true 如果我们的函数适用于一个参数,并且有一个特殊的语法:

val pf: PartialFunction[Int,String] = {
case 0 => "zero"
case 1 => "one"
case 2 => "two"
case x:Int if x>0 => "many"}

pf.isDefinedAt(1)  - true
pf.isDefinedAt(-1)  - false

pf(-1)  throws exceptions.

我们可以将几个部分函数组合成一组新的标准组合子—orElse

val pf1: PartialFunction[Int,String] = pf orElse { case _ => "other" }

注意,类型注解是必需的,以给出正确的上下文以进行类型推导。否则,编译器将无法推导内联函数参数的类型。

一个有用的组合子—andThen,它允许构建管道,也是必要的:

pf andThen (_.length)(1)  

现在,定义一个接受函数并提供转换函数的函数。例如,让输入函数为fInt => Int,然后构建g(f)g(f)(x) = f(x) + x。如果fx处未定义,g(f)也不能定义。

  1. 复制以下类:

    class g1(f:PartialFunction[Int,Int]) extends 
    PartialFunction[Int,Int] {
    	override def isDefinedAt(x: Int) =
    		f.isDefinedAt(x)
    	override def apply(x: Int) =
    		f(x) + x 
    }
    
  2. 或者作为一个带有if子句的 case 表达式:

                   def g2(f:PartialFunction[Int,Int]):PartialFunction[Int,Int] = {
    case x if f.isDefinedAt(x) => f(x)+x
                    }
    

我们现在将实现一个部分函数,用于在名称和值之间构建关联。

  1. 编写一个函数,作为具有参数(name,value)的类,仅在参数等于 name 时定义:

    class NVPair(name: String, value: String) extends 
    PartialFunction[String,String] {
    	override def isDefinedAt(x: String): Boolean = (x==name)
    	override def apply(x: String): String = {
    		if (x==name) value else throw new MatchError()
    	}
    }
    
  2. 使用orElse组合子将这样的对组合成更大的函数:

    val f = new NVPair("aa","bb") orElse new NVPair("cc","dd")
    

探索模式匹配

现在,我们将回到模式匹配,并了解扩展 case 类背后的功能。正如你将从上一章中记住的那样,我们可以对 case 类使用模式匹配,其中类的字段可以绑定到适当 case 子句作用域内的变量。我们能否为我们的非 case 类做这件事,并嵌入我们自己的自定义匹配逻辑?

在本节中,我们将学习如何编写我们自己的模式匹配器,并熟悉一些常用于模式匹配的标准通用类。

现在,让我们从一个最小示例开始。

  1. 首先,我们在 IDE 中编写以下代码:

    case class Wrapper(x:Int)
    
    w match {case Wrapper(x) => doSomething(x)}
    
  2. 在底层,编译器将其编译为下一个中间形式:

    val container = Wrapper.unapply(w)
    if (container.isDefined) {
    	val x = container.get
    	doSomething(x)
    }
    
    • 伴随对象的unapply方法被调用,它必须返回具有 get 和isDefined方法的类。
  3. 当我们有一个以上的绑定变量时,结果容器应包含一个元组。例如,对于点中间形式,这将产生以下代码:

    val container = Point.unapply(p)
    if (container.isDefined) 
    {
      val (x,y) = container.getdoSomething(x,y)
    }
    
    • 标准 Scala 库提供了Option类型,尽管你可以使用这样的方法(这在某些重优化场景中可能很有用)来定义自己的类型。
  4. 定义类:

    sealed abstract class Option[+A]  {
    
     def isEmpty: Boolean
     def isDefined: Boolean = !isEmpty
     def get: A
    
      // …  other methods
    
    }
    
    final case class Some+A extends Option[A] {
      def isEmpty = false
      def get = value
    }
    
    case object None extends Option[Nothing] {
      def isEmpty = true
      def get = throw new NoSuchElementException("None.get")
    }
    
    • 在这里,我们看到带有通用类型参数 A 的代数类型(例如 case 类/对象的层次结构)。你可能听说过在提到这种结构时使用的缩写 GADT(通用代数数据类型)。

    • Option[A]的非正式值——一个包含一个或零个元素或可能存在或可能不存在的元素的容器。Some(a)——当元素存在时,None——对于其不存在。

    • None扩展了Option[Nothing]Nothing是 Scala 类型系统中的一个最小类型,它是任何类型的子类型。

  5. 因此,为了定义自定义模式匹配器,我们需要创建一个具有unapply方法的对象,并在其中放置逻辑,该逻辑在选项容器中返回一个绑定变量(或绑定变量的元组):

    case class Point(x:Int, y:Int)
    
  6. 让我们定义一个模式匹配器Diagonal,它将只匹配位于对角线上的点:

    object Diagonal {
    def unapply(p:Point): Option[Int] =if (p.x == p.y) Some(p.x) else None
    }
    

我们现在将实现unapply自定义。

  1. 定义对象Axis(编号项目符号)

  2. 定义方法unapply(编号项目符号结束)

  3. 确定对象是否在 X 轴上(将 BULLET INSIDE BULLET 应用于所有三个点)

  4. 确定对象是否在 Y 轴上

  5. 否则,返回None

在模式匹配器中绑定变量序列

有时候,我们需要一种特定的模式匹配器,其中绑定变量的数量可以变化。例如,标准 Scala 库中的正则表达式如下:

val r1 = "([\\d]+)".r
val r2 = "([\\d]+)  ([^\\W]*)".r

v match {case r1(x) => "1"case r2(x,y) => "2"
}

这里,我们可以看到r1与一个变量匹配,但r2使用了两个绑定变量。对于这种情况,存在另一个约定:伴生对象应该提供unapplySeq方法而不是unapply,它返回一个包裹在选项中的序列。

我们将在下一章中学习更多关于序列的知识,但到目前为止,我们可以这样说,Seq[A]是一个用于序列的泛型特质。序列中的apply操作符作为索引访问工作(例如,seq(n)返回序列的第 n 个元素,并且可以使用Seq伴生类创建默认序列,例如Seq(1,2,3))。

现在我们来实现自定义的unapplySeq方法。这个方法定义在字符串上,并返回一个单词序列。

  1. 定义Words对象。

  2. 定义unapplySeq方法。将数组转换为seq,使用 Scala Array 中的.toSeq方法:

    object Words {def unapplySeq(arg: String):Option[Seq[String]] = {
    val array = arg.split("\\W+")
    if (array.size == 1 && array(0).isEmpty ) {
          None} else {
      Some(array.toSeq)
      }
      }
    }
    
  3. 编写一个测试,比较以下字符串的单词:

        "1",    "AA AA",  "AA   AA",   "ABC CBA",  "A B C D E     F G X-L",""    
    "AAA     AAA" match {case Words(x,y) => (x,y)
    }
    
    • 有时候,当将变量绑定到序列中时,我们不需要为序列中的每个值使用var,而只需要第一个值和序列的其余部分。
  4. 我们可以使用模式匹配中的变量函数调用语法,例如:

    object AsSeq
    {
    def unapplySeq(x:Array[Int]):Option[Seq[Int]] = {Some(x)
    }
    }Array(1,2,3,6) match {case AsSeq(h, _*) => h 
    }
    

实践中的部分函数

现在我们已经学到了很多关于函数和模式匹配的知识,让我们将我们的理论知识应用到实际编程中。

让我们获取我们在上一章中开发的聊天机器人,并将模式改为部分函数而不是类。

注意

在补充材料中打开/Lesson 3/5-project,并将项目导入 IDE。

将 ChatbotMode 表示为部分函数

让我们导航到com.packt.courseware.l4中的scala文件包:

package com.packt.courseware.l4

package object modes {
  type ChatbotMode = PartialFunction[(String,EffectsProvider),Processed]

     …
}

这里,我们看到package对象,这是我们之前章节中没有提到的。

package对象是与包相关联的对象。当你使用通配符导入一个包时,如果存在,则导入包对象的当前作用域内容。

因此,package对象是一个存储一些实用定义和函数的好方法,这些函数应该在包中可用。

下一个句子是ChatbotMode的类型别名:我们将其定义为一个从(StringEffectsProvider)到Processed的部分函数。

如你所记,Processed是一个LineStepResult特质,与 Processed 或 Failed 联合。使用部分函数,我们不需要Failed变体;相反,我们模式中的isDefined将被设置为false

现在我们来看一些简单的模式:

val bye: ChatbotMode = { 
case ("bye", eff) => Processed("bye", bye, true) 
}

因此,我们可以像写vars一样编写部分函数。

在上一个版本中,我们有OrMode,它组合了模式。我们能否用部分函数做同样的事情?

def or(frs:ChatbotMode, snd: ChatbotMode): ChatbotMode = {
val frsPost = frs.andThen(p => p.copy(nextMode = or(p.nextMode,snd)))
val sndPost = snd.andThen(p => p.copy(nextMode = or(p.nextMode,frs)))
frsPost orElse sndPost
}

我们使用andThen组合子来后处理frssnd应用的结果,以便在或链中插入nextMode,并通过orElse组合子返回这些函数。

因此,正如我们所见,我们可以借助部分函数来描述模式。生成的代码稍微短一些,但我们只失去了组合模式的复杂语法。

主模式现在看起来是这样的:

import modes._
def createInitMode() = otherwise (
or(StoreRemindCommand.empty, or(bye,or(currentDate,currentTime))),
  interestingIgnore)

现在我们来实现部分函数。在 l4 中,一些模式已从源代码中删除。你能以部分函数的形式将它们移回来吗?

完成步骤:

  1. 打开Lesson 3/5-project

  2. 实现当前的currentTimeotherwiseinterestingIgnore模式。

  3. 确保测试正在运行。

将提醒存储实现为部分函数的集合

让我们看看RemindStore的实现。导航到com/packt/courseware/l4/RemindCommand.scala

在模式中使用正则表达式:

val StorePattern = raw"store ([^\W]+) (.*)".r;
val RemindPattern = raw"remind ([^\W]+)".r;

def process(state:RemindedState): ChatbotMode =
{
  case (StorePattern(n,v),effects) => Processed("ok",process(state.store(n,v)),false)
  case (RemindPattern(n),effects) if state.isDefinedAt(n) => Processed(state(n),process(state),false)
}

注意RemindedState有一个内存泄漏:当我们要求我们的聊天机器人存储相同的单词几次时,函数的行为会是什么?

注意

注意

内存泄漏是一种情况,我们分配了一个对象,但在使用后仍然可以访问它。

现在我们来查找并修复StoreRemindCommand中的内存泄漏。

  1. 打开Lesson 3/5-project

  2. 分析我们存储相同工作几次的情况。

  3. 考虑如何为这个写单元测试(***)?

  4. 修复内存泄漏。

正如我们所见,在聊天机器人中构建模式作为部分函数是可能的。

使用提升进行全函数和部分函数之间的对话

这样的设计有一些缺点:

第一个缺点是我们的部分函数始终接受一个参数:输入和效果的元组。这可能是一个混淆的来源。

还要注意,在处理输入或拒绝(它将通过组合子传递到下一个链)的决策中,应该写两次:首先在isDefinedAt中,然后在apply中。在简单的情况下,这通过 case 语法隐藏起来,其中isDefinedAt是自动生成的。

它看起来像丢失了二元运算符语法是第三个问题。然而,这并不是一个真正的问题。我们将在第五章中学习如何在第三方类上定义自己的语法。

我们能否有一个决策点并处理一个部分定义的值?

让我们看看标准库中的下一个方法:

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

  /** Turns this partial function into a plain function returning an `Option` result.
  *  @see     Function.unlift
  *  @return  a function that takes an argument `x` to `Some(this(x))` if `this`
  *           is defined for `x`, and to `None` otherwise.
  */
  def lift: A => Option[B]

}

我们可以将部分函数表示为将结果包装在Option中的全函数。对于部分函数的组合器,我们有与Option非常类似的方法。

让我们再次改变我们模式的设计。

查看Lesson 3/6-project

ChatbotMode是一个特质:

trait ChatbotMode {
def process(line:String,effects:EffectsProvider):Option[Processed]
def or(other: ChatbotMode) = OrMode(this,other)
def otherwise(other: ChatbotMode) = OtherwiseMode(this,other)}

但我们可以通过部分函数的帮助定义简单的模式,并使用helper构造函数将它们转换到我们的特质中:

object ChatbotMode{
def partialFunction(f:PartialFunction[String,Processed]): ChatbotMode =
{(line,effects) => f.lift(line) }}

之后,我们可以这样做:

val bye: ChatbotMode = ChatbotMode.partialFunction(
                       { case "bye" => Processed("bye", bye, true) })

还要注意,我们可以从函数初始化ChatbotMode,因为ChatbotMode是一个 SAM 类型:

val interestingIgnore: ChatbotMode = ( line, effects ) => 
                       Some(Processed("interesting...",interestingIgnore,false))

此外,我们可以将OrMode的实现与基于部分函数组合器的先前变体进行比较:

case class OrMode(frs:ChatbotMode, snd:ChatbotMode) extends ChatbotMode {
	override def process(line: String, effects: EffectsProvider): Option[Processed] = {
		frs.process(line,effects).map(p => p.copy(nextMode = OrMode(p.nextMode,snd))
)orElse snd.process(line,effects).map(
p => p.copy(nextMode = OrMode(p.nextMode,frs))
		)
	}
}

如您所见,该结构非常相似:在部分函数中使用andThen代替,Option 也使用了orElse。我们可以说,PartialFunction[A,B]Function[A,Option[B]]的域是同构的。

从部分函数到选项函数的默认转换器,命名为lift

这是一个部分函数的方法:

{ case "bye" => Processed("bye", bye, true) }.lift

这将产生与这个相同的效果:

          x => if (x=="bye") Some(Processed("bye", bye, true)) else None

让我们编写一个逆转换器,unlift

def unliftX,Y:PartialFunction[X,Y] = new PartialFunction[X,Y] {
	override def isDefinedAt(x: X) =
	f(x).isDefined
	override def apply(x: X) =
	f(x) match {
		case Some(y) => y
	}
}

提供更高效的链式操作是一种好习惯,例如:

override def applyOrElseA1 <: X, B1 >: Y: B1 =
	f(x) match {
		case Some(y) =>y
		case None => default(x)  
}

这里,我们调用底层的f一次。

现在我们给我们的聊天机器人添加一个简单的 TODO 列表。

我们将通过允许多个模式评估输入来改变我们的评估模型。组合器将选择最佳的评估。

  1. 打开Lesson 3/6-project

  2. Processedrelevance参数之间添加 0 到 1。

  3. 修改or组合器,以评估两个子模式并基于其相关性选择答案。

  4. test用例中添加一个测试。

摘要

在本章中,我们介绍了 Scala 的函数式编程以及面向对象和函数式方法如何相互补充。我们还介绍了泛型类,它们通常与模式匹配一起使用。最后,我们介绍了如何创建用户定义的模式匹配,并学习了为什么它是有用的。

在下一章中,我们将介绍重要的 Scala 集合,如SetsMaps。我们还将讨论可变和不可变集合及其在 Scala 代码中的应用。

第四章:Scala 集合

在上一章中,我们介绍了使用 Scala 的函数式编程以及面向对象和函数式方法如何相互补充。我们还介绍了泛型类,它们通常与模式匹配一起使用。最后,我们讨论了如何创建用户定义的模式匹配以及为什么它是有用的。

在本章中,我们将介绍 Scala 集合库。我们将从学习如何处理列表开始,这将使我们熟悉整个集合库的设计原则。之后,我们将推广到序列,并介绍一些相关的数据结构。最后,我们将探讨集合与单子之间的关系,以及我们如何利用这些知识在代码中创建一些强大的抽象。

Scala 的集合库非常丰富,由适用于非常不同用例和性能考虑的数据结构组成。它在不可变数据结构方面尤其丰富,我们将在本章中更详细地介绍。

Scala 集合库中可用的集合继承自常见的顶级抽象类和特质,因此它们共享一些共同的功能性,这使得一旦熟悉了某些方法和设计原则,使用它们就会变得更容易。

到本章结束时,你将能够:

  • 识别标准库中可用的 Scala 集合

  • 识别如何使用高阶函数抽象序列

  • 实现与 Scala 集合一起工作的关键设计原则

与列表一起工作

列表可能是 Scala 程序中最常用的数据结构。学习如何处理列表不仅从数据结构的角度来看很重要,而且也是设计围绕递归数据结构的程序的一个切入点。

构建列表

为了能够使用列表,必须学习如何构建它们。Lists 是递归的,并且基于两个基本构建块:Nil(表示空列表)和 ::(发音为 cons,来自大多数 Lisp 方言的 cons 函数)。

我们现在将在 Scala 中创建列表:

  1. 启动 Scala REPL,它应该为你提供一个提示符:

    $ scala
    
  2. 使用以下方法创建一个字符串 list

    scala> val listOfStrings = "str1" :: ("str2" :: ("str3" :: Nil))
    listOfStrings: List[String] = List(str1, str2, str3)
    
  3. 通过省略括号并得到相同的结果来展示 :: 操作是右结合的:

    scala> val listOfStrings = "str1" :: "str2" :: "str3" :: Nil
    listOfStrings: List[String] = List(str1, str2, str3)
    
  4. 创建不同类型的列表。

  5. 展示 List 伴生对象的 apply 方法提供了一个方便的方式来从可变数量的参数创建列表:

    scala> val listOfStrings = List("str1", "str2", "str3")
    listOfStrings: List[String] = List(str1, str2, str3)
    

注意

如果你想知道 :: 操作符如何是右结合的,请注意操作符的结合性由操作符的最后一个字符决定。以冒号 : 结尾的操作符是右结合的。所有其他操作符都是左结合的。由于 :: 以冒号结尾,因此它是右结合的。

列表上的操作

List 类提供了 headtailisEmpty 方法。head 返回列表的第一个元素,而 tail 方法返回不包含第一个元素的列表。isEmpty 方法在列表为空时返回 true,否则返回 falseheadtail 只在非空列表上定义,并在空列表上抛出异常。

注意

在空列表(如 Nil.headNil.tail)中调用 headtail 会抛出异常。

要使用以下签名实现 evenInts 方法,请使用以下代码:

def evenInts(l: List[Int]): List[Int]

该方法应返回包含列表 l 中所有偶数的列表。使用 ListheadtailisEmpty 方法。此问题的可能解决方案如下:

def evenInts(l: List[Int]): List[Int] = {
  if (l.isEmpty) l
  else if (l.head % 2 == 0) l.head :: evenInts(l.tail)
  else evenInts(l.tail)
}

列表上的模式匹配

模式匹配是 Scala 中检查值与模式的一种强大机制,并提供了一种习惯用法来分解列表。您可以在 :: 上进行模式匹配,它模仿列表结构,或在 List(...) 上进行匹配以匹配列表的所有值。

让我们在 Scala 的 REPL 中进行模式匹配实验。请确保展示使用 List(...):: 的模式匹配示例。

下面是一个可能的示例:

val l = List(1, 2, 3, 4, 5)
List(a, b, c, d, e) = l
val h :: t = l

使用模式匹配通常比使用 ifelse 来结构化程序更符合习惯用法。

现在,我们将再次实现 evenInts 方法。这次,我们不会使用 ListheadtailisEmpty 方法:

  1. 打开我们已写入 evenInts 方法的文件。

  2. 不要使用 listheadtailisEmpty 方法。

  3. 此问题的可能解决方案如下:

    def evenInts(l: List[Int]): List[Int] = l match {
      case h :: t if h % 2 == 0 => h :: evenInts(t)
      case _ :: t => evenInts(t)
      case Nil => Nil
    }
    

列表上的第一阶方法

List 类提供了各种有用的第一阶方法。第一阶方法是不接受函数作为参数的方法。我们将在以下小节中介绍一些最常用的方法。

添加和连接

我们已经学习了如何使用 :: 在列表的头部添加一个元素。如果我们想在列表的末尾添加一个元素,我们可以使用 :+ 操作符。为了连接两个列表,我们可以使用 ::: 操作符。请注意,然而,:+ 操作符的时间复杂度为 O(n),其中 n 是列表中元素的数量。::: 操作符的时间复杂度也是 O(n)n 是第一个列表中的元素数量。请注意,::: 操作符也具有右结合性,就像 :: 操作符一样。

示例代码:

scala> val a = List(1, 2, 3)
a: List[Int] = List(1, 2, 3)

scala> val b = List(4, 5, 6)
b: List[Int] = List(4, 5, 6)
scala> val c = a ::: b
c: List[Int] = List(1, 2, 3, 4, 5, 6)

scala> val d = b :+ 7
d: List[Int] = List(4, 5, 6, 7)

获取列表的长度

获取列表的长度是一个有用的操作。所有列表都有确定的大小,因此它们提供了返回其大小的 length 方法。我们将在另一个主题中介绍可能无限的数据结构。

注意,length 在列表上是一个昂贵的操作,因为它需要遍历整个列表以找到其末尾,所需时间与列表中元素的数量成比例。

反转列表

如果你需要频繁访问列表的末尾,反转一次并使用结果会更方便。reverse 方法创建一个新列表,其元素与原始列表相反。reverse 方法的复杂度为线性。

前缀和后缀

Listtakedrop 方法返回列表的任意前缀或后缀。它们都接受一个整数作为参数:分别是要取或丢弃的元素数量。

示例代码:

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

scala> val b = a.take(2)
b: List[Int] = List(1, 2)

scala> val c = a.drop(2)
c: List[Int] = List(3, 4, 5)

元素选择

即使对于列表来说这不是一个常见的操作,List 类通过其 apply 方法支持随机元素选择:

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

scala> a.apply(2)
res0: Int = 3

由于在方法调用中对象出现在函数位置时,会隐式插入 apply,因此我们也可以这样做:

scala> a(2)
res1: Int = 3

显示

使用 toString 获取列表的规范字符串表示:

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

scala> a.toString
res0: String = List(1, 2, 3, 4, 5)

mkString 方法更加灵活,因为它允许你指定打印在所有元素之前的前缀、打印在元素之间的分隔符以及打印在所有元素之后的后缀。mkString 方法有两个重载变体,允许你在前缀和后缀参数为空字符串时省略它们。如果你想要一个空字符串作为分隔符,也可以不带参数调用 mkString

scala> a.mkString("[", ", ", "]")
res1: String = [1, 2, 3, 4, 5]
scala> a.mkString(", ")
res2: String = 1, 2, 3, 4, 5

scala> a.mkString
res3: String = 12345

注意

请参阅 www.scala-lang.org/api/current/scala/collection/immutable/List.html 上的 Scaladoc,了解 scala.collection. immutable.List 类。如果你对其他有用的方法感兴趣,可以查看该类提供的内容。

活动:使用列表为 Chatbot 创建新模式

在这个活动中,我们将构建一个新的模式,这个模式是我们在这本书的第一天创建的 Chatbot 的。这个新模式将能够保持和更新条目的 todo 列表。我们将使用 lists 作为主要的数据结构来存储我们的信息,并且我们希望至少支持以下命令:

  • todo list:列出机器人当前所知的所有当前项。

  • todo new <item description>:插入一个带有提供描述的新 TODO 项。

  • todo done <item number>:从列表中删除编号为 <item number> 的项。使用 todo list 时应显示项的编号。

  • todo done <item description>:删除与 <item description> 匹配的项。

  1. 首先定义一个新的类,该类扩展 ChatbotMode。由于我们的 TODO 列表项只需要作为字符串建模,因此我们的新模式可以定义为 case class TodoList(todos: List[String]) extends ChatbotMode

  2. 实现所需的 process 方法。正则表达式可能有助于解析 line 参数。根据提供的输入,我们希望创建一个新的 TodoList 实例,其 todos 的值可能已修改。在无效输入(不可识别的命令或尝试删除不存在的项等)时返回 None

  3. 在之前实现的聊天机器人中实验你新定义的模式。看看它与其他已定义的模式如何协同工作。

在本节中,我们从 Scala 程序的主要工作马视角之一来介绍列表。我们学习了可以在列表上执行的操作,并介绍了 Scala 代码中处理列表的一些习惯用法。

在序列上抽象

所有 Scala 集合都源自一个名为Traversable的共同特质。Scala 集合采用的设计允许在几乎所有集合中使用类似高阶函数,并在特定实例中具有适当的返回类型。将集合视为序列或元素容器,允许无缝地使用不同的数据结构。

可遍历特质

集合层次结构的根部是Traversable特质。Traversable特质有一个抽象方法:

def foreachU

此方法的实现足以使Traversable特质提供一系列有用的更高阶方法。

我们希望专注于map操作。map方法接受一个函数并将其应用于集合的每个元素。

让我们在 Scala 的 REPL 中实验map方法,并展示它如何应用于不同类型的集合。现在,创建一个将整数乘以 2 的函数,并将其应用于ListArray

scala> def f(i: Int) = i * 2
f: (i: Int)Int

scala> val l = List(1, 2, 3, 4).map(f)
l: List[Int] = List(2, 4, 6, 8)

scala> val a = Array(1, 2, 3, 4).map(f)
a: Array[Int] = Array(2, 4, 6, 8)

注意

注意map方法的返回类型根据其被调用的集合类型而变化。

flatMap略有不同。它接受一个从集合元素类型到另一个集合的函数,然后在该返回集合中“扁平化”。

作为flatMap方法的示例,考虑一个接受整数并创建一个填充 1 的整数大小列表的函数。看看当该函数通过mapflatMap应用于list时返回值是什么。

scala> def f(v: Int): List[Int] = if (v == 0) Nil else 1 :: f(v - 1)
f: (v: Int)List[Int]

scala> val l = List(1, 2, 3, 4).map(f)
l: List[List[Int]] = List(List(1), List(1, 1), List(1, 1, 1), List(1, 1, 1, 1))

scala> val ll = List(1, 2, 3, 4).flatMap(f)
ll: List[Int] = List(1, 1, 1, 1, 1, 1, 1, 1, 1, 1)

注意

注意在flatMap调用中列表是如何被扁平化的。

这类操作与单子的操作非常相似。

单子是一个包装和操作序列化的机制。它提供了两个基本操作:identity,用于将值包装在单子中,以及bind,用于转换单子的底层值。单子将在第七章功能习惯中更详细地介绍,所以如果你现在还没有完全掌握它们的复杂性,请不要担心。

链接flatMaps的单子机制非常常见,以至于 Scala 在 for-comprehensions 中为此提供了特殊语法。

单子操作为程序员提供了一种抽象和链式计算的方法,其中mapflatMap是粘合剂。事实上,mapflatMap是高阶函数,换句话说,它们接受其他函数作为参数,这使得程序员可以在他们的代码中重用组件(函数)。

集合 API 提供的其他重要高阶函数是folds。一般来说,折叠提供了将容器中的元素与某些二元运算符组合的方法。折叠与减少的不同之处在于,使用折叠时你提供了一个起始值,而使用reduce时你只使用容器中的元素。*Left*Right变体决定了元素组合的顺序。

我们现在将通过使用foldLeft来实现列表上的求和。这个问题的可能解决方案如下:

def add(a: Int, b: Int) = a + b
def sum(l: List[Int]) = l.foldLeft(0)(add)
val res = sum(List(1, 2, 3, 4))
// Returns 10

迭代器

在 Scala 集合层次结构中,紧接在Traversable特质之下的是Iterable特质。Iterable是一个具有单个抽象方法的特质:

def iterator: Iterator[A]

Iterator提供了一个方法来逐个遍历集合的元素。需要注意的是,Iterator是可变的,因为其大多数操作都会改变其状态。具体来说,在iterator上调用next会改变其head的当前位置。由于Iterator只是具有nexthasNext方法的东西,因此可以创建一个没有任何集合支持的迭代器。由于所有 Scala 集合也源自Iterable,因此它们都有一个iterator方法来返回其元素的Iterator

提供了一种惰性列表的实现,其中元素仅在需要时才被评估。流也具有递归结构,类似于列表,基于#::Stream.empty构建块(类似于::Nil)。最大的区别在于#::是惰性的,并且只有当需要其中的元素时才会评估尾部。流的一个重要特性是它们被缓存,所以如果值已经被计算过一次,就不会重新计算。这种方法的缺点是,如果你保留了对Stream头部的引用,你将保留对到目前为止已计算的Stream中所有元素的引用。

活动:使用流和迭代器实现斐波那契数列

在数学中,被称为斐波那契的序列是由加在数字之前的两个整数生成的数定义的。根据定义,该系列中的前两个整数应该是 1 和 1,或者 0 和 1。

使用流和迭代器实现斐波那契数的无限序列:

lazy val fibIterator: Iterator[BigInt]
lazy val fibStream: Stream[BigInt]

这些实现的可能解决方案如下:

lazy val fibStream: Stream[BigInt] = BigInt(0) #:: BigInt(1) #:: 
fibStream.zip(fibStream.tail).map { n => n._1 + n._2 }
lazy val fibIterator = new Iterator[BigInt] {
  var v1 = 0
  var v2 = 1
  val hasNext = true
  def next = {
    val res = v1
    v1 = v2
    v2 = res + v1
    res
  }
}

在本节中,我们介绍了Traversable作为在 Scala 中使用和推理集合的抽象方式。我们还介绍了迭代器和流以及它们在实现可能无限序列中的有用性。

其他集合

现在我们已经介绍了 Scala 标准库中的List和一些相关的Traversable,我们也应该访问 Scala 提供的一些其他有用的集合。尽管本节的理论材料较少,这意味着我们将有更多时间用于本章的最终活动。

集合

SetsIterables,不包含重复元素。Set类提供了检查集合中元素包含的方法,以及合并不同集合的方法。请注意,由于Set继承自Traversable,你可以将其应用于之前看到的所有高阶函数。由于其apply方法的特点,Set可以被视为一个类型为A => Boolean的函数,如果元素存在于集合中,则返回true,否则返回false

元组

元组是一个能够包含任意数量不同类型元素的类。元组通过将元素括在括号中创建。元组的类型根据其元素的类型进行定义。

现在让我们按照以下步骤在 REPL 中创建元组并访问它们的元素:

  1. REPL中创建一些元组并访问它们的元素。

  2. 观察创建的元组的类型,以及它是如何依赖于封装元素的类型的。

  3. 使用模式匹配作为解构元组的方法。

完整的代码如下:

scala> val tup = (1, "str", 2.0)
tup: (Int, String, Double) = (1,str,2.0)

scala> val (a, b, c) = tup
a: Int = 1
b: String = str
c: Double = 2.0

scala> tup._1
res0: Int = 1

scala> tup._2
res1: String = str

scala> tup._3
res2: Double = 2.0

scala> val pair = 1 -> "str"
pair: (Int, String) = (1,str)

映射

Map是一个大小为两的元组序列(键/值对的配对),也称为映射或关联。Map不能有重复的键。关于 Scala 中的映射的一个有趣的事实是,Map[A, B]扩展了PartialFunction[A, B],因此你可以在需要PartialFunction的地方使用Map

注意

如需更多信息,请参阅 Map 特质的 Scaladoc,链接如下:www.scala-lang.org/api/current/scala/collection/Map.html

可变和不可变集合

到目前为止,我们主要介绍的是不可变集合(除了Iterators,因为大多数操作都会改变其状态,所以它是固有的可变的——请注意,从 Scala 集合的iterator方法获得的迭代器不期望改变底层集合)。然而,值得注意的是,Scala 还提供了scala.collection.mutable包中的一组可变集合。可变集合提供在原地更改集合的操作。

在同一位置使用不可变和可变集合的一个有用约定是导入scala.collection.mutable包,并在集合声明前使用可变关键字作为前缀,即Mapmutable.Map

以下代码显示了 Scala 中不可变和可变映射之间的区别,显示后者有一个update方法,该方法会原地更改集合:

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

scala> val m = Map(1 -> 2, 3 -> 4)
m: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 4)

scala> val mm = mutable.Map(1 -> 2, 3 -> 4)
mm: scala.collection.mutable.Map[Int,Int] = Map(1 -> 2, 3 -> 4)

scala> mm.update(3, 5)

scala> mm
res1: scala.collection.mutable.Map[Int,Int] = Map(1 -> 2, 3 -> 5)

scala> m.update(3, 5)
<console>:14: error: value update is not a member of scala.collection.immutable.Map[Int,Int]
       m.update(3, 5)
         ^

scala> m.updated(3, 5)
res3: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 5)

scala> m
res4: scala.collection.immutable.Map[Int,Int] = Map(1 -> 2, 3 -> 4)

活动:实现汉诺塔问题

我们想要创建一个解决汉诺塔问题的求解器。如果你不熟悉这个谜题,请访问维基百科页面en.wikipedia.org/wiki/Tower_of_Hanoi。这是了解它的一个好起点:

  1. 实现以下函数的paths内部函数:

    def path(from: Int, to: Int, graph: Map[Int, List[Int]]): List[Int] = {
      def paths(from: Int): Stream[List[Int]] = ???
      paths(from).dropWhile(_.head != to).head.reverse
    }
    

    path函数应该返回graph图中从from节点到to节点的最短路径。graph被定义为编码为Map[Int, List[Int]]的邻接表。pathinner函数应该返回按长度递增的路径Stream(以广度优先搜索的方式)。

  2. 实现以下nextHanoi函数:

    type HanoiState = (List[Int], List[Int], List[Int])
    def nextHanoi(current: HanoiState): List[HanoiState]
    

    nextHanoi函数应该返回一个列表,其中包含从当前HanoiState可以实现的合法状态。例如:nextHanoi((List(1, 2, 3), Nil, Nil))应该返回List((List(2, 3),List(1),List()), (List(2, 3),List(),List(1)))

  3. 将之前实现的路径方法泛化,使其参数化为我们正在操作的状态类型:

    def genericPathA: List[A] = {
     def paths(current: A): Stream[List[A]] = ???
      paths(from).dropWhile(_.head != to).head.reverse
    }
    
  4. 使用这个新的实现,你应该能够通过调用,例如,来解决汉诺塔问题:

    val start = (List(1, 2, 3), Nil, Nil)
    val end = (Nil, Nil, List(1, 2, 3))
    genericPath(start, end, nextHanoi)
    
  5. 建议的活动的可能实现如下:

    // Does not avoid already visited nodes
    def path(from: Int, to: Int, graph: Map[Int, List[Int]]): List[Int] = {
      def paths(current: Int): Stream[List[Int]] = {
        def bfs(current: Stream[List[Int]]): Stream[List[Int]] = {
          if (current.isEmpty) current
          else current.head #:: bfs(current.tail #::: graph(current.head.head).map(_ :: current.head).toStream)
        }
    
        bfs(Stream(List(current)))
      }
    
      paths(from).dropWhile(_.head != to).head.reverse
    }
    
    type HanoiState = (List[Int], List[Int], List[Int])
    
    def nextHanoi(current: HanoiState): List[HanoiState] = {
      def setPile(state: HanoiState, i: Int, newPile: List[Int]): HanoiState = i match {
    …
    …
     genericPath(start, end, nextHanoi).size
    

摘要

在本章中,我们介绍了 Scala 集合库。我们介绍了如何处理列表,这将使我们熟悉整个集合库的一些设计原则。我们还介绍了如何泛化到序列,并介绍了一些相关的数据结构。最后,我们还介绍了集合与单子之间的关系,以及我们如何利用这些知识在我们的代码中使用一些强大的抽象。

在下一章中,我们将介绍type系统和多态性。我们还将介绍不同类型的变异性,这提供了一种约束参数化类型的方法。最后,我们还将介绍一些高级type,如抽象类型成员、选项等。

第五章:Scala 类型系统

在上一章中,我们介绍了如何处理列表,这使我们熟悉了整个集合库的一些设计原则。我们还介绍了如何泛化到序列,并覆盖了一些相关的数据结构。最后,我们还介绍了集合与单子之间的关系,以及我们如何利用这些知识在我们的代码中使用一些强大的抽象。

在本章中,我们将介绍type系统和多态。我们还将介绍不同类型的方差,它提供了一种约束参数化类型的方法。最后,我们将介绍一些高级type,如抽象类型成员、选项等。

Scala 是静态类型语言。这意味着变量的类型在编译时已知。静态类型语言的主要优势是编译器可以执行大量检查,从而增加在早期阶段捕获的简单错误数量。静态类型语言对重构也更友好,因为只要代码能够编译,程序员就可以对他们的更改感到更安全。

然而,Scala 不仅仅是静态类型。在本章中,我们将看到 Scala 的表达式类型系统如何启用并强制执行静态类型安全的抽象。类型推断的能力减少了程序员对程序进行冗余类型信息注解的工作量。本章将建立在下一章所需的基础之上,下一章我们将讨论类型类以及它们所启用的类型多态:临时多态。

到本章结束时,你将能够:

  • 识别 Scala 类型层次结构

  • 使用 Scala 类型系统提供的特性

  • 识别 Scala 类型系统所启用的抽象

类型基础和多态

在本节中,我们将探讨不同类型和多态。我们将从 Scala 的统一类型系统开始,以存在类型结束。

统一的类型系统

Scala 有一个统一的类型系统。这意味着所有类型,包括“原始”类型,都继承自一个公共类型。Any是所有类型的超类型,通常被称为顶级类型,并定义了通用方法,如equalshashCodetoStringNothing是所有类型的子类型,通常被称为底层类型。没有值具有Nothing类型,因此它的常见用例是表示非终止:抛出的异常、程序退出或无限循环。

Any有两个直接子类:AnyValAnyRef。值类型由AnyVal表示。AnyRef表示引用类型。有九个不可为空的预定义值类型:DoubleFloatLongBooleanUnitByteCharShortInt。所有这些类型在其他编程语言中都很相似,除了UnitUnit有一个实例,其声明方式为()Unit是一个重要的返回类型,因为 Scala 中的所有函数都必须返回某些内容。所有非值类型都定义为引用类型。Scala 中的每个用户定义类型都是AnyRef的子类型。将AnyRef与 Java 运行时环境比较,AnyRef类似于java.lang.Object

null是所有引用类型的子类型。它包含一个由字面量null标识的单个值。null用于与其他编程语言进行操作,但在 Scala 中不建议使用它。Scala 提供了其他更安全的null选项,我们将在本章后面介绍。

参数多态

参数多态是允许你为不同类型的值编写泛型代码而不失去静态类型优势的特性。

没有多态,泛型列表类型结构始终看起来像这样:

scala> 2 :: 1 :: "bar" :: "foo" :: Nil
res0: List[Any] = List(2, 1, bar, foo)

在这些情况下必须处理Any类型意味着我们无法恢复关于各个成员的任何类型信息:

scala> res0.head
res1: Any = 2

没有多态,我们就必须使用类型转换,因此会缺乏类型安全性(因为类型转换是动态的,发生在运行时)。

Scala 通过指定type变量来实现多态,这在你实现泛型函数时可能已经遇到过了:

scala> def drop1A = l.tail
drop1: AList[A]

scala> drop1(List(1, 2, 3))
res1: List[Int] = List(2, 3)

类型推断

静态类型语言的一个常见问题是它们提供了过多的“语法开销”。Scala 通过引入类型接口来解决这个问题。在 Scala 中,类型推断是局部的,并且它一次只考虑一个表达式。

类型推断减少了大多数类型注解的需求。例如,在 Scala 中声明变量的类型是不必要的,因为编译器可以从初始化表达式中识别类型。方法返回类型也由编译器成功识别,因为它们类似于主体类型:

val x = 3 + 4 * 5         // the type of x is Int
val y = x.toString()      // the type of y is String
def succ(x: Int) = x + 2  // method succ returns Int values

尽管编译器无法从递归方法中推断出结果类型。以下声明将无法编译:

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

错误信息足以识别问题:

<console>:11: error: recursive method fac needs result type
       def fac(n: Int) = if (n == 0) 1 else n * fac(n - 1)

参数化类型

参数化类型与 Java 中的泛型类型相同。泛型类型是参数化类型的泛型类或接口。例如:

class Stack[T] {
  var elems: List[T] = Nil
  def push(x: T) { elems = x :: elems }
  def top: T = elems.head
  def pop() {elems = elems.tail }
}

泛型类型可以通过界限或变异性与类型检查进行交互。我们将在下一节中介绍变异性。

界限

Scala 允许程序员使用界限来限制多态变量。这些界限表达了子类型(<:)或超类型(:>)关系。例如,如果我们之前已经定义了以下drop1方法:

scala> def drop1A <: AnyRef = l.tail
drop1: A <: AnyRefList[A]

以下代码将无法编译:

scala> drop1(List(1, 2, 3))
<console>:13: error: inferred type arguments [Int] do not conform to method drop1's type parameter bounds [A <: AnyRef]
       drop1(List(1, 2, 3))
       ^
<console>:13: error: type mismatch;
 found   : List[Int]
 required: List[A]
       drop1(List(1, 2, 3))

存在类型

Scala 中的存在类型是包含一些未知部分的类型。例如:

Foo[T] forSome { type T }

存在类型包括对已知存在但具体值我们不关心的类型成员的引用。在前面代码中,T 是一个我们不知道具体类型的类型,但我们知道它存在。使用存在类型,我们可以让程序的一些部分保持未知,并且仍然可以使用不同实现对这些未知部分进行类型检查。

想象你有一个以下的方法:

scala> def foo(x: Array[Any]) = x.length
foo: (x: Array[Any])Int

如果你尝试以下操作,它将无法编译,因为 Array[String] 不是 Array[Any](将在下一节中看到原因):

scala> val a = Array("foo", "bar", "baz")
a: Array[String] = Array(foo, bar, baz)

scala> foo(a)
<console>:14: error: type mismatch;
 found   : Array[String]
 required: Array[Any]
We can fix this by adding a type parameter:
scala> def fooT = x.length
foo: TInt
scala> foo(a)
res0: Int = 3

现在,foo 被参数化以接受任何 T。但现在我们必须携带这个 type 参数,而我们只关心 Array 上的方法,而不是 Array 包含的内容。因此,我们可以使用存在类型来解决这个问题。

scala> def foo(x: Array[T] forSome { type T }) = x.length
foo: (x: Array[_])Int

scala> foo(a)
res0: Int = 3

这种模式很常见,因此 Scala 为我们提供了“通配符”,当我们不想命名类型变量时:

scala> def foo(x: Array[_]) = x.length
foo: (x: Array[_])Int

scala> foo(a)
res0: Int = 3

活动:泛化二叉树的实现

在这个活动中,我们将泛化二叉搜索树的实现。假设你有一个整数二叉搜索树的以下定义。我们希望将我们的二叉搜索树实现从 IntTree 泛化到 Tree[A]。对代码进行必要的修改以支持新的定义,并使 insertsearch 方法在新的定义上工作。你可能需要修改 insertsearch 定义以提供一个泛型比较函数。我们希望使用这个新的泛型数据结构来存储访问我们网站的用户信息,这些用户被建模为 User(username: String, country: String) 案例类:

trait IntTree
case class IntNode(value: Int, left: IntTree, right: IntTree) extends IntTree
case object IntEmpty extends IntTree

之前的定义支持以下方法:

def insert(value: Int, tree: IntTree): IntTree =
  tree match {
    case IntEmpty => IntNode(value, IntEmpty, IntEmpty)
    case IntNode(currentValue, left, right) =>
      if (value < currentValue)
        IntNode(currentValue, insert(value, left), right)
      else
        IntNode(currentValue, left, insert(value, right))
  }

def search(value: Int, tree: IntTree): Boolean =
  tree match {
    case IntEmpty => false
    case IntNode(currentValue, left, right) =>
      value == currentValue ||
        (value < currentValue && search(value, left)) ||
       (value >= currentValue && search(value, right))
  }
  1. 首先,将树的 ADT 从 IntTree 修改为 Tree[A]。对 IntNode(变为 Node[A])和 IntEmpty(变为 Empty)进行必要的修改。

    注意

    注意,IntEmpty 是一个对象,因此 IntEmpty 类型只有一个实例。Empty 应该是哪种类型的子类型?现在,将 Empty 转换为案例类:case class Empty[A]() extends Tree[A]。我们稍后会看看定义此类型更好的方法。

  2. 修改 insert 定义以接受一个额外的比较函数作为函数参数:

    insertA => Boolean).
    
  3. 相应地修改代码以考虑新的 comp 参数。

  4. 修改 search 定义以接受一个额外的 comparison 函数作为函数参数:

    searchA => Boolean)
    
  5. 相应地修改代码以考虑新的 comp 参数。

  6. 创建一个 User 的比较函数,并使用它来填充 Tree[User]

  7. 实现 def usersOfCountry(country: String, tree: Tree[User]): Int 函数,该函数返回给定国家在 Tree[User] 中的用户数量。

在本节中,我们介绍了 Scala 的统一类型系统以及 Scala 如何实现多态。我们还介绍了类型推断及其应用的基本规则。界限也被引入作为一种方便地限制多态类型的方法。

协变

协变提供了一种约束参数化类型的方法。它根据其组件类型的子类型关系定义了参数化类型之间的子类型关系。

想象一下,你有一个以下类层次结构:

class Tool
class HandTool extends Tool
class PowerTool extends Tool
class Hammer extends HandTool
class Screwdriver extends HandTool
class Driller extends PowerTool
If we define a generic box:
trait Box[T] {
  def get: T
}

工具箱中的Box如何相互关联?Scala 提供了三种方式:

  • 协变:Box[Hammer] <: Box[Tool]当且仅当Hammer <: Tool

  • 逆变:Box[Tool] <: Box[Hammer]当且仅当Tool <: Hammer

  • 不变:无论ToolHammer的子类型关系如何,Box[Tool]Box[Hammer]之间没有子类型关系

协变

假设我们想要定义一个名为isSuitable的函数,它接受一个Box[HandTool]并测试该盒子是否适合容纳它试图装箱的工具:

def isSuitable(box: Box[HandTool]) = ???

你能传递一个锤子箱给函数吗?毕竟,锤子是一种HandTool,所以如果函数想要根据底层工具确定盒子的适用性,它应该接受Box[Hammer]。然而,如果你按原样运行代码,你会得到一个编译错误:

<console>:14: error: type mismatch;
 found   : Box[Hammer]
 required: Box[HandTool]

这里的问题是Box[Hammer]不是Box[HandTool]的子类型,尽管HammerHandTool的子类型。在这种情况下,我们希望如果BA的子类型,则Box[B]Box[A]的子类型。这就是协变。然后我们可以告诉 Scala 编译器Box[A]A上是协变的,如下所示:

trait Box[+T] {
  def get: T
}

逆变

现在,假设我们有一些专门针对特定工具的操作符,所以你会有类似以下的内容:

trait Operator[A] {
  def operate(t: A)
}

你有一个需要操作符能够处理锤子的问题:

def fix(operator: Operator[Hammer]) = ???

你能传递一个HandTool的操作符来解决这个问题吗?毕竟,锤子是一种HandTool,所以如果操作符能够与手工具一起工作,它们也应该能够与锤子一起工作。

然而,如果你尝试运行代码,你会得到一个编译错误:

<console>:14: error: type mismatch;
 found   : Operator[HandTool]
 required: Operator[Hammer]

这里的问题是Operator[HandTool]不是Operator[Hammer]的子类型,尽管HammerHandTool的子类型。在这种情况下,我们希望如果BA的子类型,则Operator[A]Operator[B]的子类型。这就是逆变。我们可以告诉 Scala 编译器Operator[A]A上是逆变的,如下所示:

trait Operator[-A] {
  def operate(t: A)
}

不变

默认情况下,类型参数是不变的,因为编译器无法猜测你打算用给定的类型来建模什么。另一方面,编译器通过禁止定义可能不合理的类型来帮助你。例如,如果你将Operator类声明为协变的,你会得到一个编译错误:

scala> trait Operator[+A] { def operate(t: A) }
<console>:11: error: covariant type A occurs in contravariant position in type A of value t
       trait Operator[+A] { def operate(t: A) }

通过将Operator定义为协变的,你会说Operator[Hammer]可以用作Operator[HandTool]的替代。所以,只能使用锤子的操作符能够操作任何HandTool

观察到Box[+A]Operator[-A]的定义,注意到类型A只出现在Box[+A]的方法的返回类型中,并且只出现在Operator[-A]的方法的参数中。因此,只产生类型A值的类型可以在A上变得协变,而消耗类型A值的类型可以在A上变得逆变。

你可以通过前面的点推断出可变数据类型必然是不变的(它们有getterssetters,因此它们都产生和消费值)。

实际上,Java 在这方面存在问题,因为 Java 数组是协变的。这意味着一些在编译时有效的代码可能在运行时失败。例如:

String[] strings = new String[1];
Object[] objects = strings;
objects[0] = new Integer(1); // RUN-TIME FAILURE

在 Scala 中,大多数集合都是协变的(例如,List[+A])。然而,你可能想知道::方法和类似方法是如何实现的,因为它们可能在逆变位置有一个类型:

trait List[+A] {
  def ::(a: A): List[A]
}

实际上,像::这样的方法是这样实现的:

def ::B >: A: List[B]

这实际上允许集合始终在它们能够的更具体类型上参数化。注意以下列表是如何提升到HandTool列表的:

scala> val l = List(new Hammer {}, new Hammer {}, new Hammer {})
l: List[Hammer] = List($anon$1@79dd6dfe, $anon$2@2f478dcf, $anon$3@3b88adb0)

scala> val l2 = new Screwdriver {} :: l
l2: List[HandTool] = List($anon$1@7065daac, $anon$1@79dd6dfe, $anon$2@2f478dcf, $anon$3@3b88adb0)

活动:实现协变和工具数据库

在这个活动中,我们将使我们的Tree[A]之前的实现对A进行协变。我们还希望开始为我们定义的工具建立一个数据库。我们已经扩展了工具的定义,现在它们具有重量和价格:

trait Tool {
  def weight: Long
  def price: Long
}

trait HandTool extends Tool
trait PowerTool extends Tool
case class Hammer(weight: Long, price: Long) extends HandTool
case class Screwdriver(weight: Long, price: Long) extends HandTool
case class Driller(weight: Long, price: Long) extends PowerTool
  1. 首先定义TreeTree[+A]。你现在可以定义Empty为一个扩展Tree[Nothing]的案例对象。

  2. 定义一些工具的比较函数。例如,你可以按重量、按价格或两者的组合来比较工具。在创建树时,尝试不同的比较函数。

  3. 实现一个def mergeA => Boolean): Tree[A]函数,该函数将两个树合并为一个。

在本节中,我们介绍了方差作为在类型上根据其组件类型定义子类型关系的方法。

高级类型

如果你来自 Java,这些事情可能不会让你感到惊讶。因此,让我们看看 Scala 类型系统的其他一些特性。

抽象类型成员

抽象类型成员是对象或类中留下的抽象类型成员。它们可以在不使用类型参数的情况下提供一些抽象。如果一个类型在大多数情况下打算存在性地使用,我们可以通过使用类型成员而不是参数来减少冗余。

class Operator {
  type ToolOfChoice
}

class Susan extends Operator {
  type ToolOfChoice = Hammer
}

class Operator[ToolOfChoice]
class Susan extends Operator[ToolOfChoice]

你可以使用哈希运算符来引用抽象类型变量。

scala> val tool: Susan#ToolOfChoice = new Hammer
tool: Hammer = Hammer@d8756ac

结构化类型

Scala 支持结构化类型:类型要求是通过接口结构而不是具体类型来表达的。结构化类型提供了一种类似于动态语言在支持鸭子类型时允许你做的事情的功能,但在静态类型实现中,这些功能在编译时进行检查。然而,请注意,Scala 使用反射在结构化类型上调用方法,这会对性能产生成本:

def quacker(duck: { def quack(value: String): String }) {
  println(duck.quack("Quack"))
}

object BigDuck {
  def quack(value: String) = value.toUpperCase
}

object SmallDuck {
  def quack(value: String) = value.toLowerCase
}
…
…
 required: AnyRef{def quack(value: String): String}
       quacker(NotADuck)

结构化类型在 Scala 代码库中并不常见。

Option

我们之前在 Scala 层次结构中讨论了 Null 类型,但评论说在 Scala 代码中很少看到 null。背后的原因是 Scala 标准库中存在 Option 类型。如果你以前使用过 Java,那么你可能在某个时候遇到过 NullPointerException。这通常发生在某些方法返回 null 时,而程序员没有预料到这一点,也没有在客户端代码中处理这种情况。Scala 通过通过 Option[A] 特质使可选类型显式化来尝试解决这个问题。Option[A] 是类型为 A 的可选值的容器。如果值存在,则 Option[A]Some[A] 的实例,否则它是 None 对象。通过在类型级别上使可选值显式化,就不可能意外地依赖于实际上可选的值的存在。

你可以使用 Some 情况类或通过分配 None 对象来创建一个 Option。当与 Java 库一起工作时,你可以使用 Option 伴生对象的工厂方法,如果给定的参数为 null,则创建 None,否则将参数包装在 Some 中:

val intOption1: Option[Int] = Some(2)
val intOption2: Option[Int] = None
val strOption: Option[String] = Option(null)

Option 特质定义了一个 get 方法,在 Some 的情况下返回包装的值,在 None 的情况下抛出 NoSuchElementException。一个更安全的方法是 getOrElse,在 Some 的情况下返回包装的值,但在 None 的情况下返回默认值。请注意,getOrElse 方法中的默认值是一个按名传递的参数,因此它只会在 None 的情况下进行评估。

使用模式匹配是处理 Option 的便捷方式:

def foo(v: Option[Int]) = v match {
  case Some(value) => println(s"I have a value and it's $value.")
  case None => println("I have no value.")
}

Option 的一个优点是它扩展了 Traversable,因此你拥有了我们在上一章中提到的所有 mapflatMapfoldreduce 以及其他方法。

高阶类型

Scala 可以抽象化高阶类型。你可以将其视为类型的类型。它的一个常见用例是,如果你想要抽象化多个类型的容器,这些容器用于存储多种类型的数据。你可能想要为这些容器定义一个接口,而不必确定值的类型:

trait Container[M[_]] {
  def putA: M[A]
  def getA: A
}

val listContainer = new Container[List] {
  def putA = List(x)
  def getA = m.head
}

scala> listContainer.put("str")
res0: List[String] = List(str)

scala> listContainer.put(123)
res1: List[Int] = List(123)

类型擦除

为了不产生运行时开销,Java 虚拟机执行类型擦除。在其他方面,类型擦除将泛型类型中的所有类型参数替换为其边界或 Object(如果类型参数未指定边界)。这导致字节码只包含普通类、接口和方法,并确保不会为参数化类型创建新类。这导致我们在尝试对泛型类型参数进行匹配时会出现一些陷阱:

def optMatchA = opt match {
  case opt: Option[Int] => println(s"Got Option[Int]: $opt.")
  case opt: Option[String] => println(s"Got Option[String]: $opt.")
  case other => println(s"Got something else: $other.")
}

scala> optMatch(Some(123))
Got Option[Int]: Some(123).

scala> optMatch(Some("str"))
Got Option[Int]: Some(str).

因此,你应该始终避免对泛型类型参数进行匹配。如果无法重构执行模式匹配的方法,尝试通过将具有类型参数的输入装箱并指定类型参数的容器来控制传递给函数的值的类型:

case class IntOption(v: Option[Int])
case class StringOption(v: Option[String])

活动:基于给定谓词查找元素

在这个活动中,我们希望为我们的Tree提供基于给定谓词查找元素的功能。更具体地说,我们希望实现def findA: Option[A]函数。如果找不到满足谓词的元素,则该函数应返回None,或者返回满足谓词的第一个元素(按顺序)。

  1. 我们希望按顺序返回第一个元素,因此我们需要假设该树是一个搜索树,并按顺序遍历它。实现def inOrderA: Iterator[A]方法,该方法返回一个包含Tree中元素顺序遍历的Iterator

  2. 使用之前实现的方法,现在依靠Iteratorfind方法来实现target函数。

  3. 我们希望找到重量低于 100 的最便宜的工具。实现创建树时应使用的函数,以及find方法中应使用的谓词。

摘要

在本章中,我们介绍了type系统和多态。我们还介绍了不同类型的变异性,它提供了一种约束参数化类型的方法。最后,我们介绍了某些高级type,例如抽象类型成员、选项等。

在下一章中,我们将介绍implicits,这将使使用外部库的工作更加愉快。我们将介绍隐式转换,并最终通过使用类型类来介绍特设多态。

第六章:隐式

在上一章中,我们介绍了类型系统和多态性。我们还介绍了不同类型的变异性,它们提供了约束参数化类型的方法。最后,我们介绍了某些高级类型,如抽象类型成员、选项等。

在本章中,我们将介绍隐式参数和隐式转换。我们将学习它们是如何工作的,如何使用它们,以及它们提供的哪些好处和风险。

当你在代码中使用第三方库时,通常必须接受其代码原样。这可能会使某些库难以处理。可能是代码风格与你的代码库不同,或者库缺少某些功能,你无法优雅地提供。

一些语言已经提出了缓解这个问题的解决方案。Ruby 有模块,Smalltalk 允许包向彼此的类中添加功能,而 C# 3.0 有静态扩展方法。

Scala 有隐式参数和转换。当以受控的方式使用时,隐式参数可以使与外部库的交互更加愉快,并允许你在自己的代码中使用一些优雅的模式。

到本章结束时,你将能够:

  • 描述隐式参数以及 Scala 编译器如何处理它们

  • 解释隐式参数启用的设计模式

  • 分析过度使用隐式参数可能出现的常见问题

隐式参数和隐式转换

Scala 有隐式参数和转换。当以受控的方式使用时,隐式参数可以使与外部库的交互更加愉快,并允许你在自己的代码中使用一些优雅的模式。

隐式参数

隐式参数是一种让编译器在方法调用缺少某些(或所有)隐式参数时自动填充一些参数的方法。编译器将寻找标记为隐式的所需类型的定义。例如,假设你想编写一个程序,在显示消息后提示用户进行某些操作,你希望自定义消息和提示上出现的字符串。我们可以假设提示字符串的默认值将比消息更默认,因此使用隐式参数实现它的方法之一如下:

case class Prompt(value: String)
def message(msg: String)(implicit prompt: Prompt) = {
  println(msg)
  println(s"${prompt.value}>")
}

在之前的实现中,你可以调用消息函数,并显式地提供一个参数给提示参数:

message("Welcome!")(Prompt("action"))

然而,如果我们想在不同的消息调用中重用提示,我们可以创建一个默认对象。

默认值

object Defaults {
  implicit val defaultPrompt = Prompt("action")
}

我们可以在使用消息方法时引入那个默认值,从而避免必须显式提供提示参数:

import Defaults._
message("Welcome!")
message("What do you want to do next?")

每个方法只能有一个隐式参数列表,但它可以有多个参数。隐式参数列表必须是函数的最后一个参数列表。

隐式参数的有效参数是可以在方法调用点访问且不带前缀的标识符,它们表示隐式定义或隐式参数,以及隐式参数类型的伴生模块中的标记为隐式的成员。例如,在先前的例子中,如果你将 defaultPrompt 隐式放入 Prompt 的伴生对象中,就不需要在调用消息时导入 Prompt 来将 defaultPrompt 放入作用域。

object Prompt {
  implicit val defaultPrompt = Prompt("action")
}
message("Welcome!")
message("What do you want to do next?")

隐式转换

隐式 转换提供了一种在 类型 之间透明转换的方法。当你需要一个你无法控制的 类型(例如来自外部库)以符合指定接口时,隐式转换非常有用。例如,假设你想将一个整数作为可遍历的来处理,以便你可以遍历其数字。完成此操作的一种方法是通过提供隐式转换:

implicit def intToIterable(i: Int): Traversable[Int] = 
  new Traversable[Int] {
  override def foreachU: Unit = {
    var value = i
    var l = List.empty[Int]
   do {
      l = value % 10 :: l
      value /= 10
    } while (value != 0)
    l.foreach(f)
  }
}

intToIterable 隐式转换就像一个普通方法一样工作。特别之处在于定义开头处的隐式关键字。你可以显式地应用转换,或者省略它,得到相同的行为:

scala> intToIterable(123).size
res0: Int = 3
scala> 123.size
res1: Int = 3
scala> 123 ++ 456
res2: Traversable[Int] = List(1, 2, 3, 4, 5, 6)

隐式转换的最好之处在于它们支持在代码的某个位置需要的类型的转换。例如,如果你有以下函数,它从一个 Traversable 返回一个有序的 Seq

def orderedSeqA: Ordering = t.toSeq.sorted

注意

你可以将 Int 传递给 orderedSeq,因为存在从 IntTraversable[Int] 的隐式转换。

orderedSeq(472).toList
// Returns List(2, 4, 7)

当不加区分地使用隐式转换时,它们可能会很危险,因为它们可以在我们更希望编译器不编译代码的位置启用运行时错误。

应该避免在常见类型之间进行隐式转换。Scala 编译器在默认定义隐式转换时发出警告,将其标记为危险。

如前所述,隐式转换使语言能够进行类似语法的扩展。这种模式在标准库和 Scala 生态系统中的库中很常见。这种模式通常被称为“丰富包装器”,因此当你看到名为 RichFoo 的类时,它很可能是向 Foo 类型添加类似语法的扩展。

为了提供无分配的扩展方法,你可以使用隐式类与值类结合。例如,如果你有以下 RichInt 定义:

implicit class RichInt(val self: Int) extends AnyVal {
  def toHexString: String = java.lang.Integer.toHexString(self)
}

例如,调用 3.toHexString 将会在一个 static 对象(RichInt$.MODULE$.extension$toHexString(3))上调用方法,而不是在新生成的对象上调用方法。

隐式解析

了解编译器在哪里查找隐式转换以及它如何在看似模糊的情况下决定使用哪个隐式转换,这一点非常重要。

注意

有关隐式解析的更多信息,请参阅:docs.scala-lang.org/tutorials/FAQ/finding-implicits.html

为了根据静态重载解析规则选择最具体的隐式定义,请参考:scala-lang.org/files/archive/spec/2.11/06-expressions.html

隐式解析的规则有点难以记住,所以通过实验它们可以给您关于 Scala 编译器的更多直观感受。

以下列表定义了编译器查找隐式的位置:

  • 当前作用域中定义的隐式

  • 显式导入

  • 通配符导入

  • 类型类的伴随对象

  • 参数类型的隐式作用域

  • 类型参数的隐式作用域

  • 嵌套类型的外部对象

活动:扩展方法的创建

在这个活动中,我们将通过依赖隐式转换来为Int类型创建扩展方法。

  1. 首先,定义一个新的类RichInt,它将实现您所需的方法。

  2. 创建从IntRichInt的隐式转换。您可以选择创建一个隐式方法或一个隐式值类。由于避免运行时开销很重要,建议使用隐式值类。

  3. 实现方法squareplus

  4. 确保隐式转换在作用域内,并实验调用squareplus在类型为Int的值上。

本节涵盖了隐式参数和隐式转换。我们看到了如何为您的代码启用优雅的扩展方法。我们还查看了一下 Scala 编译器如何解析隐式参数。

特设多态性和类型类

在本节中,我们将通过类型类来探讨特设多态性。

多态性的类型

在计算机科学中,多态性是提供单一接口以供不同类型的实体使用。多态性由三种类型组成:子类型、参数多态性和特设多态性。

子类型通过在不同的子类中具有相同方法的不同实现(但保持接口)来启用多态性。参数多态性通过允许编写不提及特定类型的代码来启用多态性。例如,当您操作泛型List时,您正在应用参数多态性。特设多态性通过允许根据指定的类型允许不同和异构的实现来启用多态性。方法重载是特设多态性的一个例子。

类型类

类型类是一种使特设多态性成为可能的构造。它们最初出现在 Haskell 中,Haskell 原生支持它们,但通过隐式参数的使用过渡到了 Scala。

在其核心,type类是一个带有type参数的类,旨在连接类型层次。也就是说,我们希望通过参数化我们的type类并为具体类型提供特定实现来为类型层次提供行为。类型类提供了一种在不接触现有代码的情况下扩展库的简单方法。

在本节的整个过程中,我们将考虑以下 JSON 的实现:

sealed trait JsValue
case class JsObject(fields: Map[String, JsValue]) extends JsValue
case class JsArray(elements: Vector[JsValue]) extends JsValue
case class JsString(value: String) extends JsValue
case class JsNumber(value: BigDecimal) extends JsValue

sealed trait JsBoolean extends JsValue
case object JsTrue extends JsBoolean
case object JsFalse extends JsBoolean

case object JsNull extends JsValue

我们将引入一个名为JsonWriter[A]的类型类,其接口有一个单一的方法write,它接受一个A并返回一个JsValue。让我们定义JsonWriter并提供两个实现,一个用于Int,另一个用于String

trait JsonWriter[A] {
  def write(value: A): JsValue
}

object JsonWriter {
  implicit object IntJsonWriter extends JsonWriter[Int] {
    def write(value: Int): JsValue = JsNumber(value)
  }

  implicit object StringJsonWriter extends JsonWriter[String] {
    def write(value: String): JsValue = JsString(value)
  }
}

我们可以使用这些特定的JsonWriter实现将Ints和字符串转换为 JSON。例如,我们可以调用IntJsonWriter.write(4)StringJsonWriter.write("Hello World")。然而,我们不想显式地调用编写器。

我们不是显式地调用JsonWriters,而是引入了toJson方法,该方法可以将类型转换为 JSON,前提是在作用域中有一个JsonWriter

def toJsonA(implicit jw: JsonWriter[A]) =
  jw.write(value)

我们现在已经在toJson函数中引入了特设多态。根据提供给toJson的值的类型,我们为toJson函数提供了不同的行为,这些行为由作用域内可用的JsonWriters控制。作用域的问题很重要。回想一下,隐式解析有优先级。因此,库的作者可以为其类型类提供自己的默认实现,但您可以在客户端代码中覆盖它,同时保持相同的接口。

上下文边界和隐式

上下文边界是一种语法糖,当您需要传递隐式值时可以减少冗余。通过使用上下文边界,您减少了隐式参数列表的需求。然而,当使用上下文边界时,您将失去调用方法时使用的隐式参数的访问权限。为了提供访问权限,您可以使用implicitly函数。implicitly提供了对作用域中请求类型的隐式的访问。它的实现很简单:

def implicitlyT = e

标准库中的类型类

类型类模式在 Scala 标准库中被广泛使用。其使用的主要例子是之前引入的Ordering类型类和代表 Scala 集合构建器工厂的CanBuildFrom类型类。

注意

请自行查看OrderingCanBuildFrom类型类。关于CanBuildFrom类型类的好概述可以从以下指南中获得:docs.scala-lang.org/overviews/core/architecture-of-scala-collections.html

活动:实现支持转换的类型类

在这个活动中,我们将实现type类以支持将常见 Scala 类型转换为JsValue。考虑本节开头引入的JsValue ADT。

  1. 首先,如果您还没有定义,请定义toJson方法:

    def toJsonA(implicit jw: JsonWriter[A]): JsValue and 
    the JsonWriter trait as trait JsonWriter[A] { def write(value: A): JsValue }
    
  2. IntStringBoolean实现JsonWriter。根据之前引入的隐式解析规则,这些实现的好地方是在JsonWriter的伴生对象中。

  3. 实现JsonWriter用于ListSetMap。在这些泛型集合中,请注意,如果你有一个JsonWriter[A],例如,你可以提供一个JsonWriter[List[A]]。并非所有映射都可以转换为 JSON,因此只提供一个JsonWriter[Map[String, A]]

摘要

在本章中,我们介绍了隐式参数和隐式转换。我们看到了如何为你的代码启用优雅的扩展方法。我们还了解到了 Scala 编译器如何解析隐式参数。最后,我们讨论了隐式参数的工作原理、如何使用它们以及它们能提供什么样的好处。

在下一章中,我们将介绍函数式编程的核心概念,如纯函数、不可变性和高阶函数。我们将在此基础上介绍一些在大规模函数式程序中普遍存在的模式,你无疑会在开始使用专注于函数式编程的 Scala 库时遇到这些模式。最后,我们将介绍两个流行的函数式编程库,即CatsDoobie,并使用它们编写一些有趣的程序。

第七章. 函数式习语

在上一章中,我们介绍了隐式参数和隐式转换。我们看到了如何为你的代码启用优雅的扩展方法。我们还查看了一下 Scala 编译器如何解析隐式参数。最后,我们讨论了隐式参数的工作原理、如何使用它们以及它们提供的益处。

在本章中,我们将介绍函数式编程的核心概念,如函数、不可变性和高阶函数。我们将在此基础上建立理解,并介绍一些在大规模函数式程序中普遍存在的、你无疑会在开始使用专注于函数式编程的 Scala 库时遇到的模式。最后,我们将介绍两个流行的函数式编程库,即CatsDoobie,并使用它们编写一些有趣的程序。

函数式编程语言已经存在很长时间了,但最近它们获得了更多的关注,因为大多数流行的编程语言都采用了函数式编程的概念。可能的原因是函数式编程很容易解决在命令式语言中难以解决的问题,例如编写可并行化的程序。函数式编程还可以提高你程序的模块化程度,从而使它们更容易测试、重用和推理,希望最终产生更少的错误。

到本章结束时,你将能够:

  • 确定函数式编程的核心概念

  • 识别和实现流行的函数式编程设计模式

  • 在你的 Scala 项目中实现 Cats 和 Doobie

函数式编程概念简介

在本节中,我们将介绍函数式编程背后的核心概念,并为你提供理解和编写简单函数式程序所需的知识。

到本节结束时,你应该对函数式编程背后的核心概念有很好的理解,例如:

  • 编写和使用纯函数

  • 使用不可变类而不是可变类

  • 编写和使用高阶函数

纯函数

函数式编程的核心是函数的概念。一个函数是纯的,如果它没有任何副作用,也就是说,该函数仅根据函数的参数计算结果,不做其他任何事情。

副作用示例包括修改变量、在对象上设置字段、执行输入/输出操作,如读取或写入文件、将值打印到控制台等。

让我们看看一些函数和非纯函数的例子,以便更好地理解它们之间的区别。让我们看看两个函数:

case class Person(var name: String, var age: Int)

def birthday(p: Person) = p.age += 1

def getName(p: Person) = {
  println(s"Getting the name of ${p.name}")
  p.name
}

def rename(p: Person, name: String) = 
  Person(name, p.age)

在这里,我们定义了一个简单的名为Person的 case 类,它有一个name和一个age。然后,我们定义了两个操作Person的函数。你能看出为什么这些函数不是纯函数吗?为了测试这些函数是否是纯函数,我们可以尝试用相同的参数两次调用它们,看看是否得到不同的结果——记住,纯函数是一个仅基于函数参数计算结果的函数,并且不做其他任何事情。

让我们从birthday:开始。

val p = Person("Jack", 41)
birthday(p)
println(p) // prints Person(Jack,42)
birthday(p)
println(p) // prints Person(Jack,43)

好吧,所以birthday不是一个纯函数,因为它正在修改传递给函数的Person p的状态。我们也许能够猜测这一点,因为birthday的返回类型是Unit——由于函数不返回任何值,它必须执行一些副作用,否则函数将完全没有用处。

接下来,让我们看看getName:

val n1 = getName(p) // Getting the name of Jack
val n2 = getName(p) // Getting the name of Jack
println(n1) // Jack
println(n2) // Jack

好消息是,函数返回了名称值,并提供了相同的参数。然而,该函数仍然不是纯函数,因为它在每次被调用时都会打印到控制台。

最后,让我们看看rename:

val r1 = rename(p, "John")
val r2 = rename(p, "John")
println(r1) // Person(John,43)
println(r2) // Person(John,43)

好吧,所以rename是一个纯函数。当提供相同的参数时,它会产生相同的值,并且不会执行任何可观察的副作用。

我们现在已经涵盖了纯函数的概念,并看到了纯函数和不纯函数的例子。你已经看到了定义纯函数的两种方式:

  • 当提供相同的参数时,它会产生相同的值,并且不会执行任何可观察的副作用。

  • 纯函数是一个只包含引用透明表达式的函数。如果一个表达式是引用透明的,那么它可以被它的值替换,而不会改变程序的行为。

在下一个子节中,我们将探讨不可变性,这是另一个使编写纯函数成为可能的核心概念。

不可变性

在理解了什么是纯函数之后,现在是时候介绍另一个核心概念,它使得编写纯函数成为可能:不可变性。如果无法更改某个东西,那么它就是不可变的——这是可变的对立面。

Scala 之所以非常适合函数式编程,其中一个原因就是它提供了保证不可变性的构造。让我们看看一些例子。

如果我们使用val关键字(而不是var)在类上定义一个变量或字段,那么 Scala 编译器将不允许我们更改该值。

  1. 在你的电脑上打开终端。

  2. 通过输入scala.来启动 Scala REPL。

  3. 现在,你可以在scala>之后开始粘贴代码。

    scala> val x = "test"
    x: String = test
    
    scala> x = "test 2"
    <console>:12: error: reassignment to val
           x = "test 2"
    
  4. 预期的输出显示在scala>的下一行。

  5. 然而,Scala 不能保证你不修改分配给变量的值的州,例如:

    scala> case class Person(var name: String)
    defined class Person
    
    scala> val p = Person("Jack")
    p: Person = Person(Jack)
    
    scala> p.name
    res0: String = Jack
    scala> p.name = "John"
    p.name: String = John
    
    scala> p
    res1: Person = Person(John)
    
  6. 然而,如果我们移除var关键字,Scala 将默认使用val来声明字段名,因此不允许我们更改它,从而强制执行不可变性。

实现标准库

Scala 的标准库在scala.collection.immutable包中提供了一整套不可变数据结构。

其中最常用的可能是scala.collection.immutable.List,它在Predef中导入,因此可以在 Scala 程序中简单地作为List访问。

scala> val xs = List(1,2,3)
xs: List[Int] = List(1, 2, 3)

scala> xs.reverse
res0: List[Int] = List(3, 2, 1)
scala> xs
res1: List[Int] = List(1, 2, 3)

这里,你可以看到xs.reverse返回一个新的List,它是反转的,并且x s保持不变。

Scala 提供了确保不可变性的构造,并且在许多情况下使用不可变性作为默认值,例如在定义案例类或使用标准库提供的某些不可变集合时。在下一小节中,我们将探讨高阶函数,当你使用函数式编程在 Scala 中编写程序时,你将广泛使用这些函数。

高阶函数

高阶函数是接受其他函数作为参数的函数。这是在 Scala 中广泛使用的一种技术,当你编写 Scala 程序时,你将经常使用它。高阶函数已经在之前讨论过,但为了完整性,我们在这里简要回顾一下。

这里是使用高阶函数mapList上的一个例子,它对列表中的每个元素调用一个函数以生成一个新的List

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

scala> xs.map(_ * 2)
res0: List[Int] = List(2, 4, 6, 8, 10)

注意,这个例子使用了纯函数、不可变性和高阶函数——这是完美的函数式程序。

这里是如何定义一个高阶函数的例子。它接受一个A => Boolean类型的函数,并返回一个A => Boolean类型的函数,该函数否定原始函数的结果:

def negateA: A => Boolean =
  (a: A) => !f(a)

我们将编写一个高阶函数,它根据给定的谓词在列表中找到第二个元素:

def sndWhereA(pred: A => Boolean): Option[A] = ???

这里有两个使用该函数的例子:

println(sndWhere(List(1,3,2,4,4))(_ > 2)) // Some(4)
println(sndWhere(List(1,3,2,4,4))(_ > 10)) // None

现在,尝试编写你自己的高阶函数,以更好地理解它们的工作方式。

  1. 在你的编辑器中创建一个新的 Scala 文件,命名为HOExample.scala

  2. 将以下代码粘贴进来:

    object HOExample extends App {
      def sndWhereA(pred: A => Boolean): Option[A] = ???
      println(sndWhere(List(1, 3, 2, 4, 4))(_ > 2)) // Some(4)
      println(sndWhere(List(1, 3, 2, 4, 4))(_ > 10)) // None
    }
    
  3. 这个高阶函数应该根据给定的谓词在列表中找到第二个元素。

  4. 当你在编辑器中运行应用程序时,你应该看到以下输出:

    Some(4) 
    None
    
  5. 一种可能的解决方案是:

      def sndWhereA(pred: A => Boolean): Option[A] =  
    
        xs.filter(pred) match { 
    
          case _ :: snd :: _ => Some(snd) 
    
          case _             => None 
    
        }
    

我们回顾了高阶函数是什么,并看到了它们如何被用来编写通用函数,其中函数的一些功能由函数的参数提供。

在下一节中,我们将超越函数式编程的基本概念,并查看在使用函数式库时可能会遇到的一些函数式设计模式。

你已经看到了函数式编程的三个基石:

  • 纯函数:一个函数仅根据函数的参数计算结果,并做其他任何事情。

  • 不可变性:你已经看到了 Scala 如何支持不可变性,并在许多情况下将其用作默认值。

  • 高阶函数:接受其他函数作为参数或返回函数的函数称为高阶函数。

函数式设计模式

在本节中,我们正在超越函数式编程的基本概念,并查看在使用函数式库时可能会遇到的一些函数式设计模式。你将介绍MonoidsFunctorsMonads和其他你可以用来结构化程序的函数式编程模式——这些模式是当你第一次学习面向对象编程时可能熟悉的面向对象设计模式的函数式编程等价物。

你是否听说过范畴论?本节中我们将看到的模式来源于范畴论。每个概念(例如一个Monoid)都有一个明确的数学定义和一组相关定律。

注意

在本节中,我们不会详细介绍这些定律,但如果你想要进一步研究这个主题,了解这个领域被称为范畴论是有好处的。

这将为你进一步学习函数式设计模式做好准备,并使你能够使用一些最受欢迎的函数式编程库,如CatsScalaz

以下小节将分别介绍一个抽象结构,展示其在 Scala 中的定义,并展示在编写程序时如何使用它——这些结构一开始可能看起来非常抽象,但请耐心,因为例子将展示这些结构如何非常有用。

注意

以下代码将大量使用类型类,所以请参考前面的章节,确保你对它们有很好的理解。

Monoids

我们将要考察的第一个结构被称为MonoidMonoid是一个非常简单的结构,但一旦你学会了识别它,你将经常遇到它。

一个Monoid有两个操作,combineempty。在 Scala 中,Monoid的定义可以像以下这样表达为一个类型类:

trait Monoid[A] {
  def combine(x: A, y: A): A
  def empty: A
}

也就是说,Monoid类型类的实例支持两种操作:

  • combine:这个操作接受两个类型为A的参数,并返回一个A

  • empty:这个操作不接受任何参数,但它返回一个A

注意

在范畴论中,这些操作被称为乘法单位。如果你想在以后学习这个主题,这可能是有用的。

这非常抽象,所以让我们通过一些例子来具体化它。

让我们看看Monoid类型类的一个实例,以更好地了解它是如何工作的以及你可以如何使用它。

  1. String定义一个Monoid类型类实例,即Monoid[String]

    implicit val strMonoid = new Monoid[String] {
      def combine(x: String, y: String): String = x + y
     def empty: String = ""
    }
    
  2. 按如下定义stringMonoid

    strMonoid.combine("Monoids are ", "great")
    strMonoid.combine("Hello", strMonoid.empty)
    

在大多数情况下,你不会像我们在这里这样明确地引用Monoid的具体实例,而是当编写多态函数时使用Monoid类型类,正如你在练习之后的例子中将会看到的。

让我们创建用于创建Monoid的隐式定义。

  1. Int编写一个Monoid实例:

    implicit val intMonoid = new Monoid[Int] {
        def combine(x: Int, y: Int): Int = x + y
        def empty: Int = 0
      }
    
  2. 编写一个implicit def,可以为任何A创建一个Monoid[List[A]]

    implicit def listMonoid[A]: Monoid[List[A]] = 
      new Monoid[List[A]] {
       def combine(x: List[A], y: List[A]): List[A] = ???
        def empty: List[A] = ???
      }
    

使用 Monoids 编写多态函数

尽管Monoid可能看起来很简单,但它非常有用。Monoid的力量以及你稍后将介绍的其他结构,在我们定义多态函数时发挥作用,这些函数对其参数一无所知,除了它们类型的Monoid实例存在。

让我们编写一个求列表和的函数:

def sumA(implicit m: Monoid[A]): A = xs.foldLeft(m.empty)(m.combine)

第一个参数列表很简单——它定义了该函数接受一个不同A值的List。然而,第二个隐式参数列表要求编译器为A找到一个Monoid实例,并且只有当存在Monoid[A]的实例时,你才能调用sum[A]

使用这个函数,只要作用域中存在适当的Monoid实例,你就可以对任何List求和:

sum(List("Monoids", " are", " cool")) // "Monoids are cool"
sum(List(1,2,3)) // 6
sum(List(List(1,2),List(3,4)) // List(1,2,3,4)

我们看到了第一个结构,即Monoid。我们看到尽管Monoid有一个非常简单的接口,但它展示了自己是一个有用的结构,它允许我们编写有趣的泛型函数。在下一节中,我们将查看Functors。简单来说,Functor是你可以在其上映射的东西。

Functor

我们接下来要看的第二个结构是Functor。简单来说,Functor是你可以在其上映射的东西。在 Scala 中,你无疑已经多次使用这个操作来操作ListsOptions等。在 Scala 中,Functors的类型类可能看起来像这样:

trait Functor[F[_]] {
  def mapA, B(f: A => B): F[B]
}

注意

Functor抽象了一个类型构造器F——抽象类型构造器的类型被称为高阶类型。

你可能会觉得map是一个方便的方式来迭代集合,如ListSet,但在Functor的上下文中,有一个更有趣的方式来看待它。你应该把map看作是对某种类型上的操作进行序列化的方式,它保留了类型结构,正如类型的具体性所定义的那样。细节将根据类型而变化:

  • Option:可能没有值。

  • List:可能有零个或多个值。

  • Either:可能有一个错误或一个值——不会两者都有。

让我们看看一些具体的例子,以便使这更加清晰。

现在我们将在不同的上下文中评估相同的东西。

  1. 编写一个抽象不同Functors的多态函数:

    def compute[F[_]](fa: F[Int])(implicit f: Functor[F]): F[Int] = {
     val fx = f.map(fa) { _ + 2 }
      f.map(fx) { _ * 2}
    }
    

    它定义了一个函数compute,该函数首先使用map2添加到 functor 内的值,然后使用map将结果乘以2

  2. 我们现在可以用任何具有Functor实例定义的值调用这个方法:

    compute(List(1,2,3)) // List(6, 8, 10)compute(Option(2) // Some(8)
    compute(Right(2): Either[String, Int]) // Right(8)
    

思考这个练习,你可以看到Functor如何让你编写多态函数,这些函数对其参数一无所知,除了它们有一个为它们定义的map函数。具体Functor定义了在其特定上下文中map的含义。

让我们定义并使用ListOptionFunctor

  1. 定义ListFunctor

    implicit val listFunctor = new Functor[List] {
      def mapA, B(f: A => B): List[B] = fa.map(f)
    }
    
  2. 使用它看起来可能像这样:

    listFunctor.map(List(1,2,3))(_ * 2)
    

为 Option 定义 Functor

  1. Option 编写一个 Functor 实例:

    注意

    解决方案可以在 Examples/src/main/scala/Functor.scala 中找到,定义为 optionFunctor.

     implicit val optionFunctor = new Functor[Option] {
        def mapA, B(f: A => B): Option[B] = fa match {
          case None => None
          case Some(x) => Some(f(x))
        }
    

我们已经讨论了第二个结构,Functor,它代表可以映射的事物。你看到了如何为 Functor 定义实例,以及如何将 map 视为在某种类型上按顺序执行操作的一种方式,这种方式保留了类型的结构,正如类型的具体性所定义的那样。在下一节中,我们将讨论 Monads – 一种你可能已经熟悉但不知道其名称的结构。

Monads

我们将要讨论的最后一个结构是 Monad。大多数 Scala 程序员对 Monads 都很熟悉,即使他们不知道这个名字,因为这种抽象在编写 for-comprehension 时总是被使用。一个 Monad 有两个操作,pureflatMap:

trait Monad[F[_]] extends Functor[F] {
  def flatMapA, B(f: A => F[B]): F[B]
  def pureA: F[A]
def mapA, B(f: A => B): F[B] =
    flatMap(fa)(f andThen pure)
}

注意

注意,范畴论中 flatMap 操作的名称是 bind,而 pure 的名称是 unit

回想一下上一节,你可以将 Functors 视为在某种类型上按顺序执行操作的一种方式,这种方式保留了类型的结构,正如类型的具体性所定义的那样。嗯,对于 Monad 也是如此,只是它们更强大。对于 Functors,复杂性只能发生在序列的开始部分,而对于 Monads,它们可以发生在序列的任何部分。

让我们看看一个例子:

Option(10).map(_ + 1).map(_ * 4)
res3: Option[Int] = Some(44)

如果其中一个操作返回了一个 Option 值呢?

def big(i: Int): Option[Int] = 
  if (i > 5) Some(i) 
  else None

big(10).map(_ - 5).map(big)
res3: Option[Option[Int]] = Some(None)

现在,我们有一个 Option[Option[Int]] 类型的值,这并不太方便。这就是 Monad 发挥作用的地方。如果你有一系列操作,你希望在每个步骤上保留某些类型的特定性,那么你将想要使用一个 Monad

注意

如前所述的定义所示,Monad 也是一个 Functor,因为 map 可以用 flatMappure 来实现。

下面是如何为 Option 定义一个 Monad:

implicit val optionMonad = new Monad[Option] {
  def pureA: Option[A] = Some(x)
  def flatMapA, B(f: A => Option[B]): Option[B] = fa match {
    case Some(x) => f(x)
    case None => None
  }
}

pure 通过简单地用 Some 包装值来定义。flatMap 通过对值进行模式匹配并在它是 Some 时应用函数来定义,否则返回 None

我们可以避免之前不便利的情况,即有一个 Option[Option[Int]]

flatMap(map(big(10))(_ - 5))(big)

我们已经看到了 Monad 的定义以及它如何可以在需要保留依赖于 Monad 实例的特定性的特定上下文中按顺序执行操作。

流行库

到目前为止,你应该对函数式编程背后的主要概念有了很好的理解,例如纯函数、不可变性和高阶函数。除此之外,你应该熟悉在编写函数式程序时使用的最流行的抽象。有了所有这些知识,你就可以开始研究 Scala 中一些流行的函数式编程库了。

在本节中,我们将查看 Scala 生态系统中的几个流行的函数式编程库。在本节之后,你应该能够:

  • 使用 CatsValidated 类型类来验证你的数据

  • 使用 Doobie 与数据库通信

使用 Cats 验证数据

在本节中,我们将快速概述 Cats 库,并查看它提供用于 Validate 数据的一个数据类型。

注意

更多关于 Cats 的信息,请参阅 github.com/typelevel/cats

到本节结束时,你应该了解 Cats 如何融入 Scala 生态系统,并知道如何在你的项目中使用它,特别是用于验证数据。

使用 Cats 的先决条件

  1. 你需要将 Cats 添加为 Scala 项目的依赖项。创建一个新的 SBT 项目,包含以下 build.sbt 文件:

    name := "cats-example"
    
    scalaVersion := "2.12.4"
    libraryDependencies += "org.typelevel" %% "cats-core" % "1.0.0"
    
    scalacOptions ++= Seq(
      "-Xfatal-warnings",
      "-Ypartial-unification"
    )
    
  2. 由于 Cats 严重依赖隐式函数来提供类型类实例和扩展方法,因此当你在文件中使用 Cats 时,始终需要以下导入:

    import cats._
    import cats.implicits._
    

Cats 简介

Cats 是一个库,它为 Scala 编程语言中的函数式编程提供抽象。具体来说,它为上一节中看到的所有模式(Monoid、Monad 等)提供了定义,并且还有更多。它还包含 Scala 标准库中所有相关类的类型类实例。

Cats 的更广泛目标是提供一个支持 Scala 应用程序中纯函数式编程的生态系统的基础。

验证数据

使用 Cats 验证数据有两种不同的方式。第一种是 Either,正如你从标准库中了解的那样,另一种是 Validated。如果你想快速失败验证,你应该使用 Either;如果你想累积错误,你应该使用 Validated。你应该使用哪一个取决于你的用例。由于你可能已经熟悉标准库中的 Either,我们将在这个子节中重点介绍 Validated

首先,让我们看看一个基本的领域模型和一些我们对该数据的要求。假设你有一个如下所示的 User

final case class User(
  username: String,
  age: Int
)

让我们假设你为这个用户有以下规则:

  • 用户名必须至少包含三个字符。

  • 用户名只能包含字母数字字符。

  • 年龄必须是正数。

我们可以使用 Scala 中的 sealed trait 和 case objects 来表示这三个错误,如下所示:

sealed trait Error {
  def message: String
}

case object SpecialCharacters extends Error {
  val message: String = "Value can't contain special characters"
}

case object TooShort extends Error {
  val message: String = "Value is too short"
}

case object ValueTooLow extends Error {
  val message: String = "Value is too low"
}

让我们看看如何使用 Validated 在 Scala 中编写前面的规则。

使用 Validated 验证

Validated 数据类型定义如下:

sealed abstract class Validated[+E, +A] extends Product with Serializable
final case class Valid+A extends Validated[Nothing, A]
final case class Invalid+E extends Validated[E, Nothing]

在这里,Valid[A] 表示通过某种验证的类型 A 的值,而 Invalid[A] 表示某些验证失败,产生类型为 E 的错误。让我们尝试实现上一节中的规则:

def validateAge(age: Int): Validated[NonEmptyList[Error], Int] =
    if (age >= 1) age.validNel
    else ValueTooLow.invalidNel

在这里,我们定义了一个方法 validateAge,它接受一个 Int 并返回 Validated[NonEmptyList[Error], Int],这意味着如果它有效,则返回 Valid(age);如果它无效,则返回 Invalid(NonEmptyList(ValueTooLow))。我们使用 Cats 为您提供的便利扩展方法 validNelinvalidNel

接下来,让我们定义一个用于用户名的验证器:

private def checkLength(str: String): Validated[NonEmptyList[Error], String] =
  if (str.length > 3) str.validNel
  else TooShort.invalidNel

private def checkSpecialCharacters(str: String): Validated[NonEmptyList[Error], String] =
  if (str.matches("^[a-zA-Z]+$")) str.validNel
  else SpecialCharacters.invalidNel

def validateUsername(username: String): Validated[NonEmptyList[Error], String] =
  (checkLength(username), checkSpecialCharacters(username)).mapN { 
    case (a, _) => a 
  }

在这种情况下,我们定义了两个辅助函数,checkLengthcheckSpecialCharacters,它们检查字符串是否超过 3 个字符,并且不包含任何字母数字字符。我们使用 Cats 为元组提供的 mapN 扩展方法结合这两个检查。如果两个检查都通过,mapN 函数将使用包含两个有效值的元组调用,但我们只对用户名感兴趣,一旦我们简单地返回第一个有效值。

最后,让我们编写一个验证用户名和年龄的方法,并在一切有效的情况下返回一个 User

def validate(username: String, age: Int) =
    (validateUsername(username), validateAge(age)).mapN { User.apply }

再次,我们使用 mapN 方法并传递一个函数,如果所有检查都通过,则调用该函数。在这种情况下,我们使用 User 上的 apply 方法来创建 User 的实例。

如果你调用这个,你可以看到如果有任何错误,它会累积错误;否则,它返回一个经过验证的 User

User.validate("!!", -1)
// Invalid(NonEmptyList(TooShort, SpecialCharacters, ValueTooLow))

User.validate("jack", 42)
// Valid(User(jack,42))

到目前为止,你应该知道如何将 Cats 添加到你的 Scala 项目中,并知道如何使用它们的 Validated 数据类型以优雅的函数式方式编写你的领域模型的验证方法。在下一节中,我们将探讨如何使用 Doobie 库以函数式类型安全的方式与数据库进行通信。

使用 Doobie 与数据库通信

在本小节中,我们将使用 Doobie 库以函数式和类型安全的方式与数据库进行通信。你将了解 Doobie 的核心概念,并了解如何使用它来查询、更新和删除数据库中的行。

在本小节之后,你应该能够在自己的 Scala 项目中使用 Doobie 进行简单的查询和插入,并知道在哪里找到更高级用例的文档。

Doobie 的先决条件

你需要将 Doobie 添加到你的 Scala 项目中依赖项。创建一个新的 SBT 项目,包含以下 build.sbt 文件:

scalaVersion := "2.12.4"

lazy val doobieVersion = "0.5.0-M13"

libraryDependencies ++= Seq(
  "org.tpolecat" %% "doobie-core"   % doobieVersion,
  "org.tpolecat" %% "doobie-h2"     % doobieVersion
)

Doobie 可以与许多不同的数据库进行通信,例如 MySQL、Postgres、H2 等。在以下示例中,我们将使用内存数据库 H2 来简化设置。查看文档了解如何使用其他数据库。

在这些示例中,我们将使用的数据库表相当简单。有两个表,usertodo,它们具有以下 SQL 定义:

CREATE TABLE user (
  userId INT AUTO_INCREMENT PRIMARY KEY,
  username VARCHAR(255) NOT NULL,
  age INT NOT NULL
);

CREATE TABLE todo (
  todoId INT AUTO_INCREMENT PRIMARY KEY,
  userId INT NOT NULL,
  title VARCHAR(255) NOT NULL,
  completed BOOL DEFAULT false,
  FOREIGN KEY (userId) references user(userId)
);

Doobie

在 Doobie 中,为了编写大型程序,你需要编写小的程序。创建程序后,你可以直接将其放入主函数中,并用作有效的 monad。

Doobie 有一个高级 API 和一个低级 API。在本讲座中,我们将专注于高级 API。在高级 API 中,只有少数几个重要的类。其中最重要的两个是ConnectionIOTransactor。

ConnectionIO

你将遇到的最常见的类型具有ConnectionIO[A]的形式,指定在java.sql.Connection可用的上下文中进行的计算,最终产生类型为A的值。

ConnectionIO[A]是最常见的类型,它指定了在java.sql.Connection可用的位置进行的计算,它生成类型为A的值。

事务处理者

Transactor是一个用于数据库连接、连接分配和清理的结构;它可以从ConnectionIO[A]接收并产生IO[A],这提供了一段可执行代码。它特别提供了一个IO,当执行时,将连接到数据库并在单个事务中运行程序。

下面是一个完整的示例,它创建了一个可以连接到 H2 数据库的Transactor。然后我们使用这个transactor来执行简单的查询SELECT 42:

package com.example

import doobie._
import doobie.implicits._
import cats.effect.IO

object ExampleConnection extends App {

  val transactor =
    Transactor.fromDriverManagerIO
 val program: ConnectionIO[Int] =
    sql"select 42".query[Int].unique

  val task: IO[Int] =
    program.transact(transactor)

  val result: Int =
    task.unsafeRunSync

  println(s"Got result ${result}")

}

上述示例使用Transactor.fromDriverManager创建了一个Transactor,然后创建了一个小程序,当提供Connection时,将运行SELECT 42。最后,程序通过在ConnectionIO上使用 transact 方法转换为IO单子,并通过unsafeRunSync执行IO单子来产生一个Int,它是执行SELECT 42的结果。重要的是要理解,直到使用unsafeRunSync执行IO单子,不会发生任何副作用。

接下来,让我们看看如何编写查询。

选择行

现在我们已经看到了如何使用ConnectionIOTransactor,让我们看看一些更有趣的查询,以执行一个非常简单的查询。

让我们从上一个例子中的以下表达式开始分析:

val fragment: Fragment = sql"select 42"
val query: Query0[Int] = fragment.query[Int]
val program = query.unique

我们使用 Doobie 的字符串插值函数sql将一个普通的String转换为FragmentFragment是 Doobie 对 SQL 查询部分的表示,可能包括插值值。Fragments可以通过连接组合,这保持了插值值的正确偏移和映射。在本讲座中,我们不会详细介绍你可以用Fragments做什么,但你可以在文档中找到更多信息。

一旦你有了Fragment,你可以使用Fragment上的query[A]方法将其转换为QueryQuery[A, B]是 Doobie 对完整 SQL 查询的表示,该查询接受类型为A的一些输入并产生类型为 B 的一些输出。在这个特定例子中,我们的查询不接受任何输入,因此返回特定的类型Query0,它表示一个不接受任何参数的查询。

最后,通过在 Query 上使用 unique 方法生成一个 ConnectionIO[Int]。如前所述,ConnectionIO[A] 表示在具有 java.sql.Connection 的上下文中进行的计算,最终产生一个类型为 A 的值。在这种情况下,我们使用 unique 因为只期望返回一行。其他有趣的方法是 listopti on,分别返回 ConnectionIO[List[A]]ConnectionIO[Option[A]]

使用参数进行查询

在 Doobie 中,你将参数传递给查询的方式与任何字符串插值器一样。参数被转换为预处理语句中的 ? 表达式,以避免 SQL 注入。让我们看看如何使用参数进行查询:

注意

你可以在 lesson-1/doobie-example/src/main/scala/com/example/User.scala 中找到以下查询。

case class User(userId: Int, username: String, age: Int)

def allManual(limit: Int): ConnectionIO[List[User]] = sql"""
    SELECT userId, username, age
    FROM user
    LIMIT $limit
  """
    .query[(Int, String, Int)]
    .map { case (id, username, age) => User(id, username, age) }
    .list

def withUsername(username: String): ConnectionIO[User] = sql"""
    SELECT userId, username, age
    FROM user
    WHERE username = $username
  """.query[User].unique

第一个查询 allManual 接收一个 Int 并将其用作参数来定义 SQL 查询的 LIMIT。第二个查询接收一个 String 并在 WHERE 子句中使用它来选择具有该特定用户名的用户。allManual 查询选择一个 (Int, String, Int)Tuple 并对其 maps 以生成一个 User,而 withUsername 使用 Doobie 的能力在查询该类型的行时自动使用 case class 的 apply 方法。

删除、插入和更新行

删除、插入和更新的工作方式类似。首先,让我们看看如何删除行:

def delete(username: String): ConnectionIO[Int] = sql"""
    DELETE FROM user
    WHERE username = $username
  """.update.run

再次,我们使用 SQL 字符串插值器来定义我们的查询,并简单地引用作用域内的变量来定义查询的参数。然而,我们不是在 Fragment 上使用 query[A] 方法来生成一个 Query,而是使用 update[A] 来生成一个 Update[A]。然后我们使用 Update 上的 run 方法来生成 ConnectionIO[A]:

def setAge(userId: Int, age: Int): ConnectionIO[Int] = sql"""
    UPDATE user
    SET age = $age
    WHERE userId = $userId
  """.update.run

def create(username: String, age: Int): ConnectionIO[Int] = sql"""
    INSERT INTO user (username, age)
    VALUES ($username, $age)
  """.update.withUniqueGeneratedKeysInt

setAgecreate 都很相似,但 create 使用 Update 上的一个有趣的方法 withUniqueGeneratedKeys,该方法返回最后插入行的 id

一个完整的示例

让我们看看一个完整的示例。你可以在 lesson-1/doobie-example/src/main/scala/com/example/Main.scala 中找到它。我们将逐部分查看每个部分:

val program = for {
    _  <- Tables.create
    userId <- User.create("Jack", 41)
    _ <- User.setAge(userId, 42)
    _ <- Todo.create(userId, "Buy Milk", false)
    _ <- Todo.create(userId, "Read the newspaper", false)
    _ <- Todo.create(userId, "Read the full documentation for Doobie", false)
    uncompleted <- Todo.uncompleted(userId)
  } yield uncompleted

在本节中,你可以看到如何通过 Scala 的 for-comprehensions 使用 flatMap 链接 ConnectionIO。这允许我们很好地从执行另一个 ConnectionIO 的结果中构建 ConnectionIO。在这种情况下,我们首先创建一个 User,然后使用后续方法中的 userId 来设置用户的年龄并为用户构造三个 Todo 方法:

 val all: IO[Unit] = for {
    todos <- program.transact(xa)
    users <- User.all(10).transact(xa)
  } yield {
    todos.foreach(println)
    users.foreach(println)
  }

在本节中,我们将通过在 for -comprehension 中使用 transact 方法从两个 ConnectionIO 实例生成 IO

 all.unsafeRunSync

最后,执行 IO 以产生副作用。

活动:向待办事项列表添加优先级

想象一个场景,一个客户告诉你他需要在待办事项列表中添加优先级功能。为应用程序设计优先级。

通过在 lesson-1/doobie-example 中的示例程序中为每个 Todo 添加优先级,并在查询未完成的 Todo 时使用该优先级,以便首先返回最重要的 Todo。你需要执行以下步骤:

  1. 通过添加一个 priority: Int 字段来扩展 Todo case class

  2. 更新 Todo.table. 中的 Table 定义。

  3. 更新 Todo.create 方法,使其接受一个表示优先级的 Int 类型的参数。

  4. 更新 Todo.uncompleted 以按降序排列行。

摘要

在本章中,我们介绍了函数式编程的核心概念,如纯函数、不可变性和高阶函数。我们还介绍了一些在大型函数式程序中普遍存在的模式。最后,我们介绍了两个流行的函数式编程库,即 Cats 和 Doobie,并使用它们编写了一些有趣的程序。

在下一章中,我们将介绍 Scala 如何通过提供一些有趣的语言特性,使得编写强大的领域特定语言(DSLs)成为可能。我们将简要地看看一般意义上的 DSLs 是什么。我们还将介绍一个如果你打算专业地使用 Scala 的话,你很可能要使用的 DSL。最后,你将实现你自己的 DSL。

第八章。领域特定语言

在上一章中,我们介绍了函数式编程的核心概念,如纯函数、不可变性和高阶函数。我们介绍了一些在大型函数式程序中普遍存在的模式。最后,我们介绍了两个流行的函数式编程库 Cats 和 Doobie,并使用它们编写了一些有趣的程序。

在本章中,我们将介绍 Scala 如何通过提供一些有趣的语言特性来编写强大的 DSLs。我们将简要地看看 DSLs 的一般概念。我们还将介绍一个你如果打算专业地使用 Scala 的话很可能要使用的 DSL。最后,你将实现你自己的 DSL。

本章展示了 Scala 如何通过提供一些有趣的语言特性,使得编写强大的领域特定语言(DSLs)成为可能。

到本章结束时,你将能够:

  • 识别领域特定语言(DSLs)的使用

  • 使用 DSL ScalaTest,一个流行的 Scala 测试库

  • 在 Scala 中设计你自己的 DSLs

  • 识别本书之外将有用的额外库和工具

DSLs 和 DSL 类型

领域特定语言,正如其名所示,是一种针对特定领域专门化的语言。与之相对的是像 Scala 这样的语言,它是一种通用语言,因为它适用于广泛的领域。

通过限制领域,你希望创建一个不那么全面但更适合解决领域内特定问题集的语言。一个构建良好的 DSL 将使解决领域内的问题变得容易,并使用户难以出错。DSLs 形态各异,大小不一,但你可以大致将它们分为两组:外部 DSLs 和内部 DSLs。

外部 DSLs

外部 DSLs 是在宿主语言之外编写的(用于实现 DSL 的语言称为宿主语言)。这意味着你将不得不解析文本、评估它等等,就像你正在创建一种通用编程语言一样。我们不会创建外部 DSL,因此我们不会进一步深入探讨这个主题。

外部领域特定语言的一个例子是 DOT,它用于描述图。下面是一个简单的 DOT 程序示例,它生成了你在这里看到的图:

外部领域特定语言

下面是实现上述图的代码:

graph graphname {
   a -- b -- c;
   b -- d;
}

因此,DOT 专门用于描述图的领域。

注意

关于 DOT 的更多信息,请参阅 en.wikipedia.org/wiki/DOT_(graph_description_language)

内部 DSLs

内部 DSLs 嵌入在宿主语言中,可以分为两组:

  • 浅显的:操作直接使用宿主语言的操作(例如,+ 使用 Scala 的 +)。

  • :您构建抽象语法树 (AST) 并像使用外部 DSL 一样评估它。

在本章中,我们将编写一个内部浅 DSL,这在我的经验中,也是你在使用各种 Scala 库时最常遇到的一种 DSL 类型。

ScalaTest 是一个非常流行的 Scala 测试库。它提供了一套不同的 DSLs 用于编写测试规范。我们将在下一节深入探讨 ScalaTest

现在,你对 DSLs 是什么以及它们如何被分组为内部/外部和浅/深有了非常基本的了解。在下一节中,我们将探讨 ScalaTest 以及该库如何使用 DSLs 使编写测试规范变得容易。

ScalaTest – 一个流行的 DSL

注意

ScalaTest 在 第一章 中介绍,设置开发环境,但由于我们将在本次讲座中广泛使用它,我们在这里稍作回顾,并确保每个人都拥有一个可工作的 ScalaTest 环境。

在本节中,我们将探讨一个用于测试 Scala 程序的流行库,ScalaTest,并了解该库如何使用 DSLs 允许用户以各种风格编写可读的测试。

研究 ScalaTest 的目的是双重的。首先,ScalaTest 是 Scala 项目的广泛使用的测试库,因此当你使用 Scala 进行专业开发时,你很可能会用到它。其次,它是一个很好的示例,展示了如何使用 DSLs 使代码更易读。

到本节结束时,你应该能够:

  • 识别如何在您的项目中使用 ScalaTest

  • 识别 ScalaTest 提供的各种风格,并能够选择与你项目相关的风格

  • 使用 FlatSpec 风格编写 ScalaTest 测试

将 ScalaTest 添加到您的项目中

ScalaTest 与其他 Scala 库一样,您只需将其作为项目依赖项添加即可。由于我们在本书中使用 SBT,因此我们将使用它作为示例。创建一个新的 SBT 项目,包含以下 build.sbt 文件:

name := "Lession2-ScalaTest"
scalaVersion := "2.12.4"
libraryDependencies += "org.scalatest" %% "scalatest" % "3.0.4" % "test"

注意

如需了解如何在外部使用它的更多信息,请参阅文档中的安装部分 (www.scalatest.org/install)。

创建一个简单的测试并将其放置在你的 src/test/scala/com/example/ExampleSpec.scala 项目中:

package com.example

import collection.mutable.Stack
import org.scalatest._

class ExampleSpec extends FlatSpec with Matchers {
  "A Stack" should "pop values in last-in-first-out order" in {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    stack.pop() should be (2)
    stack.pop() should be (1)
  }
}

为了验证您的设置是否正确,在项目根目录中启动一个 SBT 会话,并运行以下命令:

test:compile                     # to compile your tests
test                             # to run your test-suite
testOnly com.example.ExampleSpec # To run just that test

您应该看到以下类似的输出:

testOnly com.example.ExampleSpec
[info] ExampleSpec:
[info] A Stack
[info] - should pop values in last-in-first-out order
[info] Run completed in 282 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: 6 s, completed Dec 4, 2017 9:50:04 PM

由于我们将在下一节中使用 ScalaTest 编写一些测试,因此确保你有一个正确配置的 SBT 项目,你可以用它来进行练习非常重要。请按照以下步骤操作:

  1. 使用之前的 build.sbt 定义创建一个新的 SBT 项目。

  2. src/test/scala/com/example/ExampleSpec.scala 创建一个新的测试文件,并包含之前的内容。

  3. 使用 sbt test 命令运行测试,并确保它已经检测到测试并且它们通过。

你已经看到了如何将 ScalaTest 添加到你的 Scala 项目中,以及如何使用 SBT 运行测试。你现在应该有一个正确配置的 Scala 项目,你可以用它来完成本章剩余的练习。在下一节中,我们将探讨你可以使用 ScalaTest 编写的各种测试风格。

ScalaTest 风格概述

ScalaTest 提供了不同的风格供你在编写测试时使用。使用哪种风格取决于你团队的经验和偏好。

在本节中,我们将查看一些不同的风格,以便你可以了解你更喜欢哪种风格:

  • FunSuite 是一个简单且大多数人都会熟悉的风格:

    describe("A Set") {
    
      describe("(when empty)") {
        it("should have size 0") {
    
          assert(Set.empty.size == 0)
    
        }
    
      }
    
    }
    
  • FlatSpecFunSuite 非常相似,但它通过强迫你以更像规格的方式命名测试来更多地关注行为驱动设计(BDD):

    "An empty Set" should "have size 0" in {
    
        assert(Set.empty.size == 0)
    
    }
    
  • FunSpec 是编写规格式测试的一个很好的通用风格:

    describe("A Set") {
    
      describe("(when empty)") {
    
        it("should have size 0") {
    
          assert(Set.empty.size == 0)
    
        }
    
      }
    
    }
    
  • FreeSpec 专注于规格式测试,但不对你的测试施加任何结构:

    "A Set" - {
    
      "(when empty)" - {
    
        "should have size 0" in {
    
          assert(Set.empty.size == 0)
    
        }
    
      }
    
    }
    
  • PropSpec 是如果你只想用属性检查来编写测试的话:

    property("An empty Set should have size 0") {
    
        assert(Set.empty.size == 0)
    
    }
    
  • FeatureSpec 主要用于验收测试:

    class TVSetSpec extends FeatureSpec with GivenWhenThen {
    
      info("As a TV set owner")
    
      info("I want to be able to turn the TV on and off")
    
      feature("TV power button") {
        scenario("User presses power button when TV is off") {
    
          Given("a TV set that is switched off")
    
          val tv = new TVSet
          assert(!tv.isOn)
    
          When("the power button is pressed")
    
          tv.pressPowerButton()
    
          Then("the TV should switch on")
    
          assert(tv.isOn)
    
        }
    
      }
    
    }
    

示例:FunSuite

让我们看看上一节中创建的测试用例:

package com.example

import collection.mutable.Stack
import org.scalatest._

class ExampleSpec extends FlatSpec with Matchers {
 "A Stack" should "pop values in last-in-first-out order" in {
   val stack = new Stack[Int]
   stack.push(1)
   stack.push(2)
   stack.pop() should be (2)
   stack.pop() should be (1)
}
}

这里有两个内部 DSLs 在使用中。第一个用于以可读的形式 "X" should "Y" in { <code> }" 编写你的测试规格。这种风格是通过扩展 FlatSpec 来提供的。另一个 DSL 用于以 <expression> should be <expression> 的形式编写你的断言,这是通过扩展 Matchers 来提供的。

DSLs 是通过类和扩展方法的组合来实现的,但当我们实现自己的小 DSL 时,我们将在下一节中更详细地探讨这一点。

活动:实现 ScalaTest 风格

最好的方法是使用它们来感受不同的风格。从上一列表中选择三种风格,并将以下测试转换为这些风格。

  1. 继续使用你在上一活动中创建的 Scala 项目。

  2. 为你选择的每个风格创建一个文件。如果你选择了 FunSpec,那么创建一个 FunSpecExample.scala 文件。

  3. 对于每种风格,将以下测试转换为使用该风格的测试:

    import collection.mutable.Stack
    import org.scalatest._
    
    class ExampleSpec extends FlatSpec with Matchers {
     "A Stack" should "pop values in last-in-first-out order" in {
       val stack = new Stack[Int]
       stack.push(1)
       stack.push(2)
       stack.pop() should be (2)
       stack.pop() should be (1)
     }
    }
    

你已经看到了 ScalaTest 提供的不同风格,并且对它们之间的区别有了大致的了解。

ScalaTest 是一个使用 DSLs(领域特定语言)来使编写可读性很高的测试成为可能的测试库。我们已经看到了如何将其添加到自己的 Scala 项目中,我们概述了它支持的不同风格,并且我们已经使用不同的风格编写了一些测试。在下一节中,我们将探讨 Scala 提供的使编写 Scala 中的 DSLs 成为可能的功能。

编写 DSLs 的语言特性

在本节中,我们将探讨 Scala 的特性,这些特性使得编写小型 DSL 变得容易:

  • 方法调用的灵活语法

  • 按名参数

  • 扩展方法和Value

在创建我们的 Scala DSL 时,我们将在下一节中使用所有这些特性。

方法调用的灵活语法

Scala 具有灵活的方法调用语法,在某些情况下,调用方法时可以省略点(.)和括号(())。

规则是这样的:

  • 对于阶数为0的方法,意味着它们不接受任何参数,可以省略括号并使用后缀表示法。

  • 对于阶数为1或更多的方法,意味着它们接受一个或多个参数,可以编写使用中缀表示法的方法。

这里是一个在调用filter时使用中缀表示法的示例:

List.range(0, 10).filter(_ > 5)
List.range(0, 10) filter (_ > 5)

这里是一个在调用toUpperCase时省略括号的示例:

"Professional Scala".toUpperCase()
"Professional Scala".toUpperCase

这允许你编写看起来更像散文的代码,当你创建自己的 DSL 时,这是一个很好的选项。

按名参数

按名参数使得可以指定传递给函数的参数不应在实际上使用之前进行评估:

def whileLoop(condition: => Boolean)(body: => Unit): Unit =
 if (condition) {
   body
   whileLoop(condition)(body)
 }

var i = 2

whileLoop (i > 0) {
 println(i)
 i -= 1
}  // prints 2 1

注意,conditionbody参数类型前面都带有=>。这就是指定一个参数为按名参数的方式。

当我们在本章后面编写自己的 DSL 时,我们将使用按名参数来允许用户为测试用例编写… in { … code … }块。

注意

重要的是要注意,按名参数每次被引用时都会被评估。

扩展方法和值类

扩展方法是一种向已存在的 Scala 类添加新方法的技术。Value类是 Scala 的一个特性,它使得在不产生任何分配开销的情况下创建扩展方法成为可能。

这里是一个添加扩展方法到StringValue类的示例:

implicit class StringExtensions(val self: String) extends AnyVal {
 def repeat(count: Int): String =
  List.range(0, count).map(_ => self).fold("")(_ + _)
}

根据这个定义,将会有从StringStringExtension的隐式转换,这使得你可以在字符串上调用repeat,就像它一直存在一样(注意后缀表示法的使用):

"Professional Scala" repeat 5

当我们在本章后面编写自己的 DSL 时,我们将使用扩展方法和值类来为String添加should方法。

我们已经看到了 Scala 的特性如何使得在 Scala 中编写内部领域特定语言(DSL)成为可能。现在我们将看到如何编写自定义的 DSL。

编写小型 DSL

在本节中,我们将重新实现一些FlatSpec ScalaTest DSL,以便了解如何在 Scala 中实现 DSL。

首先,我们将看看在 Scala 中使用案例类来建模测试用例的简单方法。然后,我们将看看如何创建一个用于创建这些测试用例的小型 DSL。

建模测试用例

在我们能够创建 DSL 之前,我们需要有一个为它创建的东西。在我们的例子中,我们想要创建一个用于指定测试的 DSL,因此让我们看看我们如何使用 Scala 中的案例类来建模测试:

sealed trait TestResult
case class TestFailure(message: String) extends TestResult
case object TestSuccess extends TestResult

我们将创建一个代数数据类型,它表示运行测试用例的结果。结果可以是包含有关失败信息的失败,或者是一个简单的TestSuccess,表示测试通过:

case class TestCase(description: TestDescription, run: () => TestResult)
case class TestDescription(name: String, specification: String)

然后,我们定义两个简单的案例类。TestDescription包含测试用例的描述,而TestCase具有这样的描述和一个run函数,可以用来调用测试用例。

使用这个简单的模型,我们可以描述一个测试用例,如下所示:

TestCase(
    TestDescription("A stack", "pop values in last-in-first-out order"),
   TestResult.wrap({
        val stack = new Stack[Int]
        stack.push(1)
        stack.push(2)
        assert(stack.pop() == 2, "should be (2)")
        assert(stack.pop() == 1, "should be (1)")
    })
)

这里,TestResult.wrap是一个具有签名def wrap(body: => Unit): () => TestResult的方法。

现在,这看起来与我们在上一节中使用FlatSpec DSL 编写的良好测试用例完全不同,所以让我们看看我们如何创建一个创建类似之前TestCase的 DSL。

TestCase DSL

我们将首先查看 DSL 中允许编写测试规范的部分,即 DSL 中显示的部分:

"A Stack (with one item)" should "be non-empty" in { … code … }

从上一节中,应该很清楚这是使用String上的后缀方法should,使用中缀表示法调用的。所以,我们将向String添加一个扩展方法,使用之前的小型 DSL 来创建TestDescription

implicit class StringExtension(val name: String) extends AnyVal {
 def should(specification: String): TestDescription =
   TestDescription(name, specification)
}

在作用域内有这个隐式值类的情况下,我们可以使用以下语法创建一个TestDescription

"A Stack (with one item)" should "be non-empty"
// Returns TestDescription("A Stack (with one item)","be non-empty")

这将我们的TestCase创建简化为以下内容。

TestCase(
    "A Stack (with one item)" should "be non-empty"
    TestResult.wrap({
        val stack = new Stack[Int]
        stack.push(1)
        stack.push(2)
        assert(stack.pop() == 2, "should be (2)")
        assert(stack.pop() == 1, "should be (1)")
    })
)

这稍微好一些,但远非理想。让我们继续。现在,让我们专注于 DSL 的剩余部分,即允许编写实际测试用例的部分。这是 DSL 中显示的部分:

"A Stack (with one item)" should "be non-empty" in { … code … }

再次,在上一节中,我们看到了我们可以在 Scala 中使用中缀表示法和按名参数来编写这样的表达式。现在,为了允许使用 DSL 创建TestCase实例,我们必须向TestDescription添加一个方法,如下所示:

def in(body: => Unit): TestCase = TestCase(this, TestResult.wrap(body))

使用这个方法,我们现在可以使用以下语法来编写我们的测试用例:

"A Stack (with one item)" should "be non-empty" in {
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    assert(stack.pop() == 2, "should be (2)")
    assert(stack.pop() == 1, "should be (1)")
}

然后,我们就完成了创建我们的小型领域特定语言(DSL)来编写测试用例规范的过程。

我们在这里并不是试图创建一个功能齐全的测试库,但能够运行测试会很有趣,所以让我们看看如何实现测试运行器。

由于我们已经使用 Scala 类来模拟测试用例,因此创建一个运行测试并打印出一份简洁报告的测试运行器相当简单。

你已经看到了如何使用 Scala 的一些特性来非常容易地编写一个用于创建测试用例的 DSL。通过使用方法调用的灵活语法、按名参数和扩展方法(通过值类),你成功地创建了一个内部 DSL,使得可以将以下表达式:

TestCase(
    TestDescription("A stack", "pop values in last-in-first-out order"),
    TestResult.wrap({
        val stack = new Stack[Int]
        stack.push(1)
        stack.push(2)
        assert(stack.pop() == 2, "should be (2)")
        assert(stack.pop() == 1, "should be (1)")
    })
)

转换为以下:

"A Stack" should "pop values in last-in-first-out order" in {
    val stack = new Stack[Int]
   stack.push(1)
    stack.push(2)
    assert(stack.pop() == 2, "should be (2)")
    assert(stack.pop() == 1, "should be (1)")
}

活动:创建一个用于指定断言的 DSL

我们的 DSL 使得编写TestCase实例变得容易。然而,我们测试用例中的断言看起来并不美观。创建一个用于指定断言的 DSL。它应该支持以下语法:

expected(2) from stack.pop()

提示

首先,通过使用案例类来模拟断言:

case class AssertionA => A)
case class ExpectedA

注意

您可以在本章的 dsl/src/main/scala/com/example/Assertion.scala 代码中看到最终结果,并在 dsl/src/main/scala/com/example/Assertion.scala/Main.scala 中查看其用法。

完整的代码应如下所示:

package com.example
case class AssertionA => A) {
 def run(): Unit = {
 val result = value()
 assert(expected.expected == result, s"Failed asserting that ${expected.expected} == $result")
 }
}
case class ExpectedA {
 def from(expression: => A): Assertion[A] = Assertion(
 this,
 () => expression
 )
}
object Assertion {
 def expectedA: Expected[A] = Expected(x)
}

除此之外

本节将帮助您更好地了解 Scala 生态系统,并在本书结束后指导您自学,以便您可以继续提高您的 Scala 技能。

各种 Scala 库

本主题的目的是简要介绍几个不同的 Scala 库,用于解决不同领域的问题,以便您可以在本书之后研究那些对您感兴趣的内容。

Akka

我们将要查看的第一个库是 Scala 生态系统中最受欢迎的库之一。它已经存在很长时间了——该库的第一个公开发布是在 2010 年——并且被许多大型组织在生产环境中使用。

它的主要抽象是 ActorStreams

  • Actor 是一种不依赖锁来模拟并发的途径。如果您阅读过关于编程语言 Erlang 的内容,您可能已经听说过它们。Actor 是一个可以接收并响应消息、生成新的 actors 并向其他 actors 发送消息的实体。因此,您将程序建模为一组通过发送消息相互通信的 actors

    注意

    您可以在以下链接中找到有关 Actors 的更多信息:doc.akka.io/docs/akka/current/guide/actors-intro.html?language=scala

  • 如果您必须处理流式数据,可以使用 Akka Streams 将您的程序建模为从源到汇流的数据转换。

    注意

    您可以在以下链接中了解更多关于 Streams 的信息:doc.akka.io/docs/akka/current/stream/stream-introduction.html?language=scala

  • 如果您想在 Scala 中构建分布式系统,强烈建议使用 Akka。

    注意

    您可以在其网站上了解更多关于 Akka 的信息:akka.io/

Apache Spark

Apache Spark 是一个用于处理大型 Scala 数据集的库。Apache Spark 最初于 2009 年在加州大学伯克利分校开发,2013 年捐赠给 Apache 软件基金会,现在是一个拥有超过 1000 位贡献者的顶级 Apache 项目。

您可以使用 Java、Scala、Python 和 R 编写 Spark 程序。您可以使用 Spark API 编写自己的自定义数据分析程序,或者您可以使用高级 API 之一:Spark SQL 用于 SQL 和结构化数据处理,MLlib 用于机器学习,GraphX 用于图处理,以及 Spark Streaming。

如果您对大数据处理感兴趣,请查看 Spark。

注意

您可以在其网站上了解更多关于 Spark 的信息:spark.apache.org/

Shapeless

Shapeless 是一个 Scala 的类型类和依赖类型基于的泛型编程库。它最初由 Miles Sabin 于 2011 年编写,现在被许多公司用来编写类型安全的代码。它也被许多库内部使用。

Shapeless 的一个主要特性是它使得自动推导类型类成为可能。

使用 Shapeless,您可以让编译器检查那些您可能认为不可能检查的事情。一些例子包括:

  • 异构列表,即每个元素可以是不同类型的列表,Scala 编译器跟踪这些类型

  • 让编译器检查集合的长度是否符合要求

在本节中,我们看到了三个不同的 Scala 库,这些库可以用于解决以下领域的问题:

  • 分布式编程

  • 大数据处理

  • 泛型编程

揭示未覆盖的语言特性

本主题的目的是简要向您介绍一些我们没有覆盖的语言特性,并告诉您如果想要了解更多关于这些主题的信息,应该去哪里。这些特性包括:

  • 反射

宏是一种编程语言特性,它使得编写以其参数的 AST(抽象语法树)作为输入并产生新 AST 的函数成为可能,从而有效地允许您编写生成其他程序的程序。

宏有多种形状和大小。在本节中,我们将探讨如何在 Scala 中使用宏。Scala 2.10 中包含了 Scala 宏的实验性支持,并且从那时起,它们在每次发布中都得到了改进。

注意

您可以在 Scala 文档网站上找到关于宏的官方文档,链接如下:docs.scala-lang.org/overviews/macros/overview.html.

定义宏

定义宏是定义为 Scala 函数的宏,这些函数引用宏实现。让我们看看一个非常简单的宏,它接受一个String并返回该字符串的大写版本:

object Macros {

 def uppercaseImpl(c: Context)(strExpr: c.Expr[String]): c.Expr[String] = {
   import c.universe._
   val Literal(Constant(str: String)) = strExpr.tree
   c.ExprString
 }

 def uppercase(strExpr: String): String = macro uppercaseImpl
}

uppercase方法是宏向宏的用户公开的方式。实际的宏实现是uppercaseImpl,它有两个参数列表。第一个参数包含一个参数,即Context,它包含编译器在宏调用点的收集信息。第二个参数包含宏被调用时使用的类型为String的表达式的 Scala 抽象语法树。让我们看看如何调用这个宏:

val x = Macros.uppercase("testing")
println(x)

这看起来非常像正常的 Scala 方法调用;然而,参数的大写操作是在编译时发生的。

注意,这里实现的宏只与String字面量一起工作;如果您用其他任何东西调用它,您将使编译器崩溃。

隐式宏

隐式宏使得在隐式方法的实现中引用宏成为可能。这种用法的一个例子是编写可以生成给定类型类实例的宏,给定任何类型T

trait Showable[T] { def show(x: T): String }

然后,而不是为所有类型编写单独的类型类实例,如下所示:

final case class Person(name: String)

object Person {
 implicit val showable: Showable[Person] = new Showable[Person]{
   def show(x: Person) = s"Person(name=${x.name})"
 }
}

你可以定义一个隐式宏,它可以生成任何给定类型T的类型类实例,如下所示:

object Showable {
 implicit def materializeShowable[T]: Showable[T] = macro ...
}

Quasiquotes

你可以使用 Scala 强大的字符串插值功能,在字符串内部编写 AST,这样你就不必手动构建 AST。也就是说,你可以编写以下内容:

q"List(10)"

而不是编写以下内容:

List(Literal(Constant(10)))))

反射

你很可能在其他编程语言中使用了反射,例如 Java 或 Python。反射使得程序能够检查并有时修改自身。你可以将宏视为编译时反射,而我们将要查看的反射则是运行时反射。

使用运行时反射,你可以:

  • 检查对象的类型

  • 实例化新对象

  • 访问或调用对象的成员

让我们看看如何检查运行时类型的示例:

import scala.reflect.runtime.{universe => ru}

def getTypeTagT: ru.TypeTag = ru.typeTag[T]

getTypeTag(List(1,2,3)).tpe.decls
 .filter(_.name.toString.length < 5)
 .foreach(d => println(s"${d.name} : ${d.typeSignature}"))

此示例使用scala.reflect.runtime.universe实现了一个方法,对于给定类型T的对象,该方法将获取该类型的TypeTag。有了TypeTag,我们可以通过tpe访问Type,然后通过decls获取类型的成员列表。示例随后过滤掉任何名称少于五个字符的成员,并打印它们的nametype

注意

反射在某些情况下可能会产生不可忽视的运行时开销。如果你在性能敏感的地方使用它,请确保对结果进行基准测试。

在本小节中,我们简要介绍了两个有趣的 Scala 语言特性,并提供了多个链接,以便你在本书之后进一步学习这些特性,从而提高你的 Scala 技能。

资源更新

在本小节中,我们将探讨如何保持对 Scala 编程语言及其生态系统发展的最新了解。

Scala 改进过程

Scala 改进过程(SIP)和 Scala 平台过程(SPP)分别是 Scala 编程语言和 Scala 标准库变更的方式。如果你想对其中任何一个进行更改,你可以提出一个更改提案,该提案将被审查并可能被接受。

注意

你可以在以下位置找到所有当前 SIPs 的列表:[docs.scala-lang.org/sips/all.html](https://docs.scala-lang.org/sips/all.html)。

Scala Times

Scala Times 是一份每周通讯,其中包含关于 Scala 的有趣博客文章,并对上周发布的各种 Scala 库进行了简要回顾。

注意

你可以在这里了解更多信息并订阅通讯:[scalatimes.com/](http://scalatimes.com/)

# 摘要 在本章中,我们介绍了 Scala 如何通过提供一些有趣的语言特性,使得编写强大的领域特定语言(DSLs)成为可能。我们简要地看了看在一般意义上 DSL 是什么。我们还介绍了一个你如果打算在 Scala 专业领域工作,很可能要使用的 DSL。最后,我们实现了我们自己的 DSL。 现在我们来到了这本书的结尾。在这本书中,我们涵盖了 Scala 语言的所有专业概念,从设置开发环境到编写你自己的自定义 DSLs。我们介绍了 Scala 语言中的面向对象和函数式编程方面。我们还介绍了一些在 Scala 中使用的有用库,例如`Cats`和`Doobie`。最后,我们介绍了可以帮助你保持行业前沿的额外资源和工具。

posted @ 2025-09-11 09:51  绝不原创的飞龙  阅读(6)  评论(0)    收藏  举报