Scala-编程学习指南-全-
Scala 编程学习指南(全)
原文:
zh.annas-archive.org/md5/1fdec9a760b006675d812f4db9ea26a3译者:飞龙
前言
今天的 Scala 与其早期版本大相径庭。
语言的第二版已经超过十二年历史,并且经历了与支持特性和库实现相关的多次变更。当时被认为至关重要的许多特性,例如对 XML 字面量的支持、Swing 和 Actors,都被从语言核心移动到外部库或被开源替代方案所取代。Scala 成为我们今天所知的编程语言的功能之一是通过直接添加或通过在发行版中包含另一个开源库来实现的。最显著的例子是在 2.10 版本中采用 Akka。
Scala 2.13,其重点是模块化标准库和简化集合,带来了进一步的变更。然而,这些变更不仅影响 Scala 的技术方面。多年来用它来解决实际问题,帮助我们收集了更多关于结构化函数程序和使用面向对象特性以新方式获得优势的知识。正如以前版本的习惯是使用“没有分号的 Java”一样,现在使用单子转换器和类型级编程技术来构建程序已成为常规。
本书通过提供对重新设计的标准库和集合的全面指南,以及深入探讨类型系统和函数的第一级支持,同时处理技术和架构上的变化。它讨论了隐式作为构建类型类的主要机制,并探讨了测试 Scala 代码的不同方法。它详细介绍了在函数式编程中使用的抽象构建块,提供了足够的知识来选择和使用任何现有的函数式编程库。它通过涵盖 Akka 框架和响应式流来探索响应式编程。最后,它讨论了微服务以及如何使用 Scala 和 Lagom 框架来实现它们。
本书面向对象
作为一名软件开发人员,你对某些命令式编程语言有实际的知识,可能是 Java。
你已经具备一些 Scala 基础知识,并在实际项目中使用过它。作为一个 Scala 初学者,你对它的生态系统之丰富、解决问题方式的多样性以及可用库的数量感到惊讶。你希望提高你的 Scala 技能,以便能够充分利用语言及其重构的标准库的潜力,最优地使用其丰富的类型系统来尽可能接近问题域地制定程序,并通过理解底层范式、使用相关语言特性和开源库来利用其功能能力。
要充分利用本书
-
我们期望读者能够舒适地构建和实现简单的 Scala 程序,并熟悉 SBT 和 REPL。不需要具备反应式编程、Akka 或微服务的先验知识,但了解相关概念将有益。
-
要使用本书中的代码示例,需要安装 Java 1.8+和 SBT 1.2+。建议安装 Git 以简化从 GitHub 检出源代码。对于那些没有准备好先决软件的读者,我们在附录 A 中提供了 Java 和 SBT 的安装说明。
下载示例代码文件
您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择 SUPPORT 标签。
-
点击代码下载与勘误。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
一旦文件下载完成,请确保您使用最新版本解压或提取文件夹:
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-Scala-Programming。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781788836302_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“在 Scala 2.13 中,StringOps已通过字符串字面量解析的返回选项的方法进行了扩展。支持的所有类型包括所有数值类型和Boolean。”
代码块设置如下:
object UserDb {
def getById(id: Long): User = ???
def update(u: User): User = ???
def save(u: User): Boolean = ???
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
scala> val user = User("John", "Doe", "jd@mail.me")
user: User = User(John,Doe,jd@mail.me)
scala> naiveToJsonString(user)
res1: String = { "name": "John", "surname": "Doe", "email": "jd@mail.me" }
任何命令行输入或输出都应如下编写:
take
-S--c--a--l--a-- --2--.--1--3-
take
Lazy view constructed: -S-S-c-C-a-A-l-L-a-A- -
Lazy view forced: -S--c--a--l--a-- -List(S, C, A, L, A, )
Strict: List(S, C, A, L, A, )
粗体:表示新术语、重要单词或屏幕上看到的单词。
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
欢迎读者提供反馈。
一般反馈:如果您对这本书的任何方面有疑问,请在邮件主题中提及书名,并给我们发送邮件至 customercare@packtpub.com。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告这一点,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过发送链接至 copyright@packt.com 与我们联系。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com。
评价
请留下您的评价。一旦您阅读并使用过这本书,为何不在购买它的网站上留下评价呢?潜在读者可以查看并使用您的客观意见来做出购买决定,我们 Packt 公司可以了解您对我们产品的看法,并且我们的作者可以查看他们对书籍的反馈。谢谢!
想了解更多关于 Packt 的信息,请访问 packt.com。
第一章:Scala 2.13 简介
在撰写本文时,Scala 2.13 已经达到了五年的里程碑,并接近第一个候选版本。在这个时候,其功能集不太可能发生变化,因此可以安全地查看更新的新特性。
在本章中,我们将讨论发布的范围,将对话的重点放在其核心——新的集合库上。
本章将讨论以下主题:
-
Scala 2.13 简介
-
Scala 2.13 的新特性
-
Scala 2.13 集合库
技术要求
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter01找到。
Scala 2.13 简介
Scala 2.13 是 Scala 编程语言的最新小版本更新。尽管看起来版本号只是一个小小的提升,但这个发布比它可能看起来更重要。
原因在于其主要关注的是经过重新设计的集合库,该库将取代在版本 2.8 中引入的当前版本,并在版本 2.9 中进行了轻微的重新设计。
新的集合框架将在 Scala 2 中保留,并也将成为 Scala 3 的一部分。
由于它主要是一个库发布,与上一个版本相比,语言本身的变化不大。除了集合之外,新版本在三个方面进行了改进:
-
最小化核心库
-
加快编译器速度
-
提高用户友好性
这些细节超出了本书的范围,我们不会进一步讨论。
此外,还增加了字面量和单例类型,我们将在第二章“理解 Scala 中的类型”中详细讨论,以及一些对标准库的微小更改,我们将在深入研究映射和列表之前查看这些更改。
想要展望未来吗?我们将带你前往那里!
Scala 2.13 的新特性
在本节中,我们将讨论新版本中的一些小改进,这些改进与集合主题无关,也不真正属于某些更大的主题,例如字符串字面量的可选解析、向案例类添加名称报告函数、链式操作的方法以及自动资源管理。
字符串字面量的可选解析
在 Scala 2.13 中,StringOps 已扩展了返回 Option 的方法,用于字符串字面量解析。支持的类型包括所有数值类型和 Boolean。
新的方法可以极大地简化处理用户提供的数据,无需将调用用异常处理包装起来,如下面的示例所示:
scala> "10".toIntOption
res3: Option[Int] = Some(10)
scala> "TrUe".toBooleanOption
res4: Option[Boolean] = Some(true)
scala> val bool = "Not True"
bool: String = Not True
scala> bool.toBooleanOption
res5: Option[Boolean] = None
可选的 Boolean 解析忽略参数的大小写,与抛出异常的 toBoolean 方法相同。
产品可以报告其元素名称
这个特性可能主要用于案例类,因为它使得在不使用反射或宏的情况下进行一些泛型编程成为可能。
以下示例演示了如何使用新的 productElementName(idx) 方法构建一个简单的 JSON 序列化器,用于简单案例类:
case class User(name: String, surname: String, email: String)
def naiveToJsonString(p: Product): String =
(for { i <- 0 until p.productArity } yield
s""""${p.productElementName(i)}": "${p.productElement(i)}"""")
.mkString("{ ", ", ", " }")
显然,这个简单的迭代不考虑嵌套和转义,但它已经在基本情况下可以产生有效结果:
scala> val user = User("John", "Doe", "jd@mail.me")
user: User = User(John,Doe,jd@mail.me)
scala> naiveToJsonString(user)
res1: String = { "name": "John", "surname": "Doe", "email": "jd@mail.me" }
不幸的是,当索引无效时,取元素索引的方法会抛出异常:
scala> user.productElementName(3)
java.lang.IndexOutOfBoundsException: 3
at User.productElementName(<console>:1)
... 38 elided
我们将在第六章“探索内置效果”中讨论为什么抛出异常不是最佳方法,以及可行的替代方案。
添加了链式操作的方法
通过 import scala.util.chaining._,现在可以将 tap 和 pipe 方法添加到任何类型的实例中。该功能由对 ChainingOps 的隐式转换提供。我们将在第四章“了解隐式和类型类”中详细查看隐式。
pipe 方法将给定的函数应用于值并返回结果。在需要将嵌套函数调用转换为类似流式接口的代码的情况下,这可能很有帮助。以下代码片段展示了通过 pipe 链接嵌套函数调用的一个虚构用户数据库示例。
考虑以下数据库接口:
object UserDb {
def getById(id: Long): User = ???
def update(u: User): User = ???
def save(u: User): Boolean = ???
}
我们可以将所有三个操作一次性应用于用户:
import UserDb._
val userId = 1L
save(update(getById(userId)))
pipe 允许我们以更可读的格式表示这一点:
getById(userId).pipe(update).pipe(save)
可以认为,通过在应用之前组合函数,可以得到相同(或更清晰)的结果:
val doEverything = (getById _).andThen(update).andThen(save)
doEverything(userId)
我们将在第三章“深入函数”中查看函数,特别是函数组合。
tap 方法仅为了产生副作用而应用作为参数传递的函数,并返回原始值。例如,它可能对日志记录目的和最简单的性能测量很有用。
以下代码片段演示了一个基本的副作用引起性能跟踪实现,该实现利用全局变量:
scala> import scala.util.chaining._
import scala.util.chaining._
scala> val lastTick = new java.util.concurrent.atomic.AtomicLong(0)
lastTick: java.util.concurrent.atomic.AtomicLong = 0
scala> def measureA: Unit = {
| val now = System.currentTimeMillis()
| val before = lastTick.getAndSet(now)
| println(s"$a: ${now-before} ms elapsed")
| }
measure: AUnit
scala> def start(): Unit = lastTick.set(System.currentTimeMillis())
start: ()Unit
scala> start()
scala> val result = scala.io.StdIn.readLine().pipe(_.toIntOption).tap(measure)
None: 291 ms elapsed
result: Option[Int] = None
scala> val anotherResult = scala.io.StdIn.readLine().pipe(_.toIntOption).tap(measure)
Some(3456): 11356 ms elapsed
anotherResult: Option[Int] = Some(3456)
在这里,我们定义了一个全局的 AtomicLong 类型的值来存储最后测量的时间戳。然后我们定义了一个多态的 measure 方法,它捕获最后测量时刻和现在之间的时间,以及一个 start 方法来重置时钟。之后,我们可以使用 tap 方法来跟踪我们动作的执行时间。
我们将在第二章在 Scala 中理解类型中讨论类型和多态性,在第八章处理效果中讨论副作用和更一般的概念,并展示全局变量和全局状态存在的缺点,这些内容在第九章熟悉基本 Monads 中。
自动资源管理
Scala 2.13 添加了一种自动管理资源的方法。我们将在第九章熟悉基本 Monads 和第十章scala.util.Using中讨论其他管理资源的方法和实现依赖注入,它允许我们以熟悉的方式执行副作用。所有对资源的操作都被封装在Try中,我们将在第六章探索内置效果中讨论。如果抛出Exceptions,则Try中返回第一个异常。在某些边缘情况下,异常处理相当复杂,我们邀请读者查阅 ScalaDoc 以获取其详细描述。
Using是一个类,它接受一些资源作为 by-name 参数。资源可以是任何具有scala.util.Resource类型类实例的对象。标准库中提供了java.lang.AutoCloseable的实例。我们将在第四章了解隐式和类型类中研究类型类。Using还有一个单调接口,允许我们在 for-comprehensions 中组合多个资源。我们将在第九章熟悉基本 Monads 中讨论单调性。
这里是Using的实际应用示例。我们将定义一个实现AutoCloseable的资源,并在 for-comprehension 中将这些资源作为数据源:
scala> import scala.util.{Try, Using}
import scala.util.{Try, Using}
scala> final case class Resource(name: String) extends AutoCloseable {
| override def close(): Unit = println(s"Closing $name")
| def lines = List(s"$name line 1", s"$name line 2")
| }
defined class Resource
scala> val List(r1, r2, r3) = List("first", "2", "3").map(Resource)
r1: Resource = Resource(first)
r2: Resource = Resource(2)
r3: Resource = Resource(3)
scala> val lines: Try[Seq[String]] = for {
| u1 <- Using(r1)
| u2 <- Using(r2)
| u3 <- Using(r3)
| } yield {
| u1.lines ++ u2.lines ++ u3.lines
| }
Closing 3
Closing 2
Closing first
lines: scala.util.Try[Seq[String]] = Success(List(first line 1, first line 2, 2 line 1, 2 line 2, 3 line 1, 3 line 2))
控制台输出显示结果包含所有资源的行,并且资源本身会按相反的顺序自动关闭。
现在,经过这个小热身,我们准备深入探讨 2.13 版本的基础——新的集合库。
Scala 2.13 集合库
Scala 2.13 提供了一个新的集合库,由于历史原因,它也被称为“集合 - strawman”。该库的重构追求几个主要目标,例如修复前一个版本中的常见问题,简化其实施和内部结构,以及使用和向后兼容性,实现与懒集合和 Java 流的更好集成,以及可变和不可变集合之间更清晰的 API 分离,提高性能,最后但同样重要的是,最小化从 Scala 2.12 集合迁移的工作量。
因此,我们有一个与上一个版本大部分源代码兼容的库,其中包含许多旧方法和类型(如Traversable、TraversableOnce和Stream)已被弃用,并且内部层次结构更加简单。
本书假设读者对 Scala 集合有基本的理解。基于这个假设,下一节将采取整体方法,重点介绍新集合框架的统一概述。
下一个图表示集合库的顶级层次结构:

从这里开始,我们将假设总是有import scala.collections._在作用域内,并在我们的图中使用以下颜色编码:

每个特质描述了结构,即集合的本质。正如其名所示,IterableOnce只能迭代一次。Iterable放宽了这个限制,使得可以多次迭代集合。Seq为集合的元素添加了连续性的概念,Set为其元素添加了唯一性的约束,而Map将集合的类型从单个元素A更改为键值对,键为K,值为V。
如前所述,本着关注点分离的精神,这些特质仅涵盖结构特性。为特定类型定义的操作被放置在带有Ops后缀的辅助特质中。这些特质形成一个类似于之前的层次结构,如下所示:

与“正常”特质只有一个类型参数(元素类型)不同,Ops有三个类型参数。除了元素类型A外,C类型描述了此特质混合到的集合的特定表示类型,以及此集合上定义的一阶方法的返回类型。CC类型指的是可以由高阶方法返回的表示类型或类型构造函数。我们将在本章后面看到这是如何在实际中工作的。
由于继承树的结构如此,IterableOps和IterableOnceOps实际上被混合到库中的每个集合实现中。底部的三个特质仅添加了一些针对特定集合类型的独特方法,并覆盖了一些定义以提高效率。Iterable*Ops特质定义了超过一百个方法,这也是 Scala 集合库非常一致和同质化的原因。
由于IterableOnceOps和IterableOps的重要性,我们将在下一节详细探讨它们。之后,我们将探索专用集合的独特特性。
IterableOnceOps
IterableOnceOps 代表可以一次或多次遍历的集合的蓝图。它定义了一些必须由每个集合实现的抽象方法,以及一些以 IterableOnce 中可用的迭代器为依据的具体方法。具体方法提供默认(如果可能)的懒实现,并分为以下几类:
-
大小操作:
isEmpty、nonEmpty、size、knownSize和isTraversableAgain检查集合的(非)空性或返回其大小。knownSize是一种优化,如果无法在不遍历集合的情况下确定大小,则返回-1。isTraversableAgain对于IterableOnce返回false。 -
元素测试:
forall、exists和count检查所有、至少一个或某些数量的元素是否满足给定的谓词。 -
字符串操作:
mkString和addString。这些方法具有不同的参数集,提供了构建替代字符串表示的可能性。 -
转换为其他集合:
copyToArray、toList、toMap、to、toSet、toSeq、toIndexedSeq、toBuffer和toArray。这些方法将Iterable复制或转换为另一个集合。to方法在这个列表中是特殊的,因为它允许我们返回具有可用Factory的任何类型的集合。我们很快会详细探讨它。 -
折叠和归约:
foldLeft、foldRight、reduce、reduceLeft、reduceRight、reduceOption、reduceLeftOption和reduceRightOption将二元运算应用于集合的元素。reduce*Option方法通过返回None来优雅地处理空集合的情况。 -
数值组合:
sum和product如果存在隐式的Numeric[B]使得B >: A可用,则计算元素的求和或乘积。 -
排序组合:
min、minOption、max、maxOption、maxBy、maxByOption、minBy和minByOption如果存在隐式的Ordering[B]使得B >: A可用,则找到满足给定谓词的集合元素。*Option方法在空集合的情况下返回None而不是抛出异常。 -
元素检索:
collectFirst和find。选择满足给定条件的元素。 -
平等性:
corresponds是比较集合的另一种方式。如果这个集合的每个元素都通过给定的谓词与另一个集合的匹配元素相关联,则满足条件。
抽象方法分为以下几类:
-
子集合检索:
filter、filterNot、take、takeWhile、drop、dropWhile、slice和span。从整个集合或其开始处取出或丢弃满足给定谓词或范围的元素。 -
映射:
map、flatMap、collect和scanLeft。通过应用某个函数并可能过滤结果来转换集合的元素。 -
Zipper:
zipWithIndex为集合的所有元素添加一个索引。
IterableOps
IterableOps 扩展了 IterableOnceOps 并包含了一些不可能在不允许多次遍历集合的情况下实现的方法。
它们分为以下几类:
-
元素检索:
head,headOption,last, 和lastOption返回集合的第一个或最后一个元素,对于空集合抛出NoSuchElementException或返回None。 -
大小:
sizeCompare是一种优化,允许我们有效地比较集合与给定值的大小。 -
子集合检索:
partition,partitionWith,splitAt,takeRight,dropRight,grouped,sliding,tail,init,groupBy,groupMap,groupMapReduce,tails, 和inits。这些方法根据某些谓词或索引拆分集合,从末尾取或丢弃元素,根据某些标准或谓词对元素进行分组,可能应用转换函数,并丢弃第一个或最后一个元素。 -
映射:
scanRight生成一个包含从集合末尾开始应用给定函数的累积结果的集合。 -
添加:
concat, ++返回包含此集合和作为参数提供的集合中所有元素的另一个集合。 -
Zipper:
zip,zipAll,unzip, 和unzip3将集合的元素与另一个集合的元素组合成一个产品,或者将它们拆分成单独的集合。 -
转换:
transpose通过将行转换为列和相反的方式转换集合的集合。
在 IterableOnceOps 中定义的以下方法在 IterableOps 中得到了具体的默认实现:filter, filterNot, take, takeWhile, span, drop, dropWile, slice, scanLeft, map, flatMap, flatten, collect, 和 zipWithIndex。isTraversableAgain 被覆盖以返回 true。
值得注意的是,Iterable 和 IterableOnce 不支持通用相等操作,它是在特定的集合子类型上定义的。因此,无法使用相等操作直接比较这些类型,如下面的示例所示:
scala> Set(1,2,3) == Seq(1,2,3)
res4: Boolean = false
此外,还有三个特别值得注意的方法,因为它们引入了尚未遇到过的类型:
-
def withFilter(p: A => Boolean): collection.WithFilter[A, CC] -
def iterableFactory: IterableFactory[CC] -
def view: View[A]
在继续讨论更具体的集合类型之前,让我们快速地讨论一下它们。
WithFilter
WithFilter 是一个模板类,它包含 Iterable 的 map, flatMap, foreach, 和 withFilter 方法。它允许我们对特殊集合进行映射和过滤操作的专门化。
由于其技术性质,我们在这里不会进一步详细介绍。
IterableFactory
trait IterableFactory[+CC[_]] 是特定集合伴随对象的基特质,它提供了一系列操作来创建由 CC 类型构造器指定的特定集合;这有时被称为 目标类型驱动构建,因为源集合的类型被忽略。集合库中的大多数伴随对象都扩展了这个特质,这使得它们可以在期望 IterableFactory 的地方使用。
作为允许从头开始构建集合的主要抽象,了解它提供的方法是有用的。所有这些方法都返回 CC[A]。以下表格包含了一个简要总结:
def fromA |
从现有的 IterableOnce 创建目标集合。 |
|---|---|
def empty[A]: CC[A] |
一个空集合,通常定义为对象。 |
def applyA: CC[A] |
从给定的 var-arg elems 创建集合。 |
def iterateA(f: A => A): CC[A] |
使用对 start 的应用结果、然后是生成的值,依此类推,重复 len 次来填充集合。 |
def rangeA : Integral: CC[A] |
包含递增整数 [start, end-1] 的集合,相邻数字之间的差为 step。此方法还有一个默认值 step = 1 的版本。 |
def fillA(elem: => A): CC[A] |
使用 n 次对 elem 的评估来填充集合。此函数有高达五维的变体。 |
def tabulateA(f: Int => A): CC[A] |
与 fill 相同,但使用索引作为评估的参数。类似地,此函数有高达五维的变体。 |
def concatA: CC[A] |
将所有参数集合连接成一个单一集合。 |
def unfoldA, S(f: S => Option[(A, S)]): CC[A] |
调用 f 来生成集合的元素,使用并修改从 init 状态开始的内部状态。 |
诚然,IterableFactory 提供了许多创建所需类型集合的不同可能性。
视图
View 在库的新版本中已被重新实现。现在它代表一个具体化的 Iterator 操作。
具体化是将关于计算机程序的概念转化为编程语言中创建的显式数据模型或其他对象的过程 (en.wikipedia.org/wiki/Reification_(computer_science))。
这意味着 Iterator 方法被表示为 View 的子类,并封装了要应用的转换。评估发生在视图转换为严格集合类型或遍历(例如使用 foreach 方法)的时刻。视图不 记住 源集合的类型。以下示例可以证明这一点。首先,我们定义一个通用的转换,它可能是严格的或懒散的,这取决于作为参数提供的集合类型:
def transform[C <: Iterable[Char]](i: C): Iterable[Char] = i
map { c => print(s"-$c-"); c.toUpper }
take { println("\ntake"); 6 }
接下来,对于每个转换步骤,我们在步骤发生时在控制台打印其结果。现在我们可以比较懒散和严格集合的行为:
val str = "Scala 2.13"
val view: StringView = StringView(str)
val transformed = transform(view) // A
val strict = transform(str.toList) // B
print("Lazy view constructed: ")
transformed.foreach(print) // C
print("\nLazy view forced: ")
println(transformed.to(List)) // D
println(s"Strict: $strict") // E
这段代码在 REPL 中产生以下输出:
take
-S--c--a--l--a-- --2--.--1--3-
take
Lazy view constructed: -S-S-c-C-a-A-l-L-a-A- -
Lazy view forced: -S--c--a--l--a-- -List(S, C, A, L, A, )
Strict: List(S, C, A, L, A, )
在第一行中,我们可以看到 take 方法始终严格评估,无论底层集合类型如何——这在上面的代码中注释为 A。第二行和第三行显示了 List[Char] 的严格评估,代码中的 B 行。第 4 行和第 5 行演示了 View[Char] 被评估两次,每次在强制转换的时刻,一次是通过调用 foreach(代码中的 C 行),一次是通过将其转换为 List(代码中的 D 行)。还有一点值得注意的是,即使在链中的第一个转换步骤,map 也只应用于 take 方法的输出。
集合
Set 是具有元素唯一概念集合的基特质。它定义为 trait Set[A] extends Iterable[A] with SetOps[A, Set, Set[A]] with Equals。我们可以看到它实际上是一个 Iterable,在 SetOps 中定义了一些额外的操作,并在集合之间添加了等价性的概念。Set 的子层次结构在以下图中表示:

之前提到的 SetOps 在 IterableOps 上添加了一些方法。这些方法在此处显示:
-
元素检索:
contains和apply如果这个集合包含一个给定元素,则返回true。 -
等价性:
subsetOf和subsets检查这个集合是否是另一个集合的子集,或者返回这个集合的所有子集,可能还有给定的大小。 -
与另一个集合的组合:
intersect、&、diff、&~、concat、++、union和|。这些方法计算这个集合与另一个集合的交集、差集或并集。
层次结构中很少有类增强了 Set 的进一步属性:
-
SortedSet 通过
SortedOps[A, +C]扩展了Set,并有两个不可变和两个可变实现——两个TreeSets和两个BitSets。SortedOps实现了以下依赖于Ordering概念的方法: -
键检索:
firstKey和lastKey返回这个集合的第一个或最后一个元素。 -
子集合检索:
range、rangeFrom、rangeUntil和rangeTo创建了这个集合的按范围投影,满足给定的标准。
由于重载,SortedSet 定义了多次重载方法,包括带排序和不带排序的。如果操作旨在应用于底层的未排序 Set,则必须强制转换类型:
scala> import scala.collection.SortedSet
import scala.collection.SortedSet
scala> val set = SortedSet(1,2,3)
set: scala.collection.SortedSet[Int] = TreeSet(1, 2, 3)
scala> val ordered = set.map(math.abs)
ordered: scala.collection.SortedSet[Int] = TreeSet(1, 2, 3)
scala> val unordered = set.to(Set).map(math.abs)
unordered: scala.collection.immutable.Set[Int] = Set(1, 2, 3)
请注意,在 Set 的情况下,直接类型注解将不起作用,因为其定义是不变的:
scala> val set1: Set[Int] = SortedSet(1,2,3)
^
error: type mismatch;
found : scala.collection.SortedSet[Int]
required: Set[Int]
Set 的不变性与其扩展函数 A => Boolean 的事实相关,该函数返回 true 如果集合包含给定元素。因此,集合可以用于期望此类单参数函数的地方:
scala> ordered.forall(set)
res3: Boolean = true
除了 TreeSet 和 BitSet 之外,还有四个更具体的集合实现:
-
ListSet使用基于列表的数据结构实现不可变集合。 -
不可变的
HashSet使用压缩哈希数组映射前缀树(CHAMP)实现不可变集合。 -
可变的
HashSet和LinkedHashSet使用哈希表实现可变集合,分别存储无序和有序的数据。
集合与 Map 密切相关,它表示一个元素以键值对形式表示的集合。
Map
Map 被定义为 trait Map[K, +V] extends Iterable[(K, V)] with MapOps[K, V, Map, Map[K, V]] with Equals,这使得它成为一个键值对 K 和 V 的 Iterable,并定义了映射之间的相等性概念。它还定义了映射的类层次结构,如下面的图中所示:

有三种不同的 MapOps,一个适用于可变和不可变,以及一个针对这些形式的每个具体形式。
MapOps 通过以下特定操作扩展了 IterableOps:
-
元素检索:
get、getOrElse、apply、applyOrElse、default、contains和isDefinedAt。这些方法允许我们通过给定的键检索值或检查值是否存在,可选地返回默认值或如果找不到键则抛出异常。 -
子集合检索:
keySet、keys、values、keysIterator和valuesIterator允许我们以不同形式获取键或值。 -
映射:
map、flatMap和collect转换并可选地过滤键值对的配对。 -
添加:
concat返回一个包含两个映射元素的新集合。
immutable.MapOps 在 MapOps 的基础上添加了以下方法:
-
元素移除:
remove、removeAll和--从映射中移除一个或所有给定元素,并返回新的映射。 -
元素更新:
updated和+使用给定的键更新元素,并返回新的映射。 -
映射:
transform将给定的函数应用于所有元素,生成一个新的映射,其返回结果作为值。
mutable.MapOps 与可变映射相比,有一组不同的方法:
-
元素添加:
put添加新值或更新现有值。 -
元素更新:
updated、+和getOrElseUpdate在原地更新值。 -
元素移除:
remove和clear用于从映射中移除一个或所有元素。 -
过滤:
filterInPlace仅保留满足谓词的映射。 -
映射:
mapValuesInPlace对映射的值应用转换,并将返回的结果作为值存储。
一般的 Map 定义有相当多的专用子类型,如前图所示。我们现在将快速浏览它们。
SortedMap
SortedMap 与 SortedSet 类似。它有两种实现,可变和不可变的 TreeMap,并提供了一些以 SortedOps 为基础的方法定义:
-
子集合检索:
iteratorFrom、keysIteratorFrom、valuesIteratorFrom和rangeTo给我们提供了以迭代器形式获取映射元素的方法。 -
元素检索:
firstKey、lastKey、minAfter和maxBefore允许我们检索满足某些排序条件的元素。
HashMap
HashMap 也有两种形式——不可变和可变。
不可变的 HashMap 使用 CHAMP 树实现。
可变的 HashMap 使用散列表实现可变映射。散列表将其元素存储在数组中。项目的哈希码用于计算数组中的位置。
MultiMap
MultiMap 是一个用于可变映射的特质,该映射将多个值分配给一个键。
它定义了 addBinding、removeBinding 和 entryExists 方法,可用于查询或操作键的条目。
SeqMap
SeqMap 是有序不可变映射的泛型抽象。SeqMap 本身存在可变和不可变两种形式。这些形式有几种不同的实现:
-
不可变的 ListMap 使用基于列表的数据结构实现不可变映射。遍历
ListMap的方法按插入顺序访问其元素。 -
可变的 ListMap 是一个由列表支持的简单可变映射。它像其不可变兄弟一样保留插入顺序。
-
VectorMap 仅存在于不可变形式。它使用基于向量/映射的数据结构实现,并保留插入顺序。它具有常数查找速度,但其他操作较慢。
-
LinkedHashMap 是一个基于散列表的可变映射,如果迭代,则保留插入顺序。
Seq
Seq 可能是库中最普遍的集合。像 Map 一样,它有元素连续性的概念,元素有索引。它定义为 trait Seq[+A] extends Iterable[A] with PartialFunction[Int, A] with SeqOps[A, Seq, Seq[A]] with Equals。与映射类似,Seq 也指定了对等关系支持,并扩展了 PartialFunction,它接受元素索引作为参数。由于有许多类实现了 Seq,我们将采取逐步的方法,逐级查看。Seq 的直接子类如下图所示:

scala.Seq 在之前的 Scala 版本中已知,现在被 scala.collection.immutable.Seq 替换。
与其他集合一样,SeqOps 通过添加相当多的方法来扩展 IterableOps:
-
元素检索:
apply通过给定索引检索一个元素。 -
索引和搜索:
segmentLength、isDefinedAt、indexWhere、indexOf、lastIndexOf、lastIndexWhere、indexOfSlice、lastIndexOfSlice、containsSlice、contains、startsWith、endsWith、indices和search。这些方法允许我们检索有关元素或子序列的存在或索引的信息,给定某些谓词或元素值。 -
大小:
length和lengthCompare提供了高效的操作来检索集合的长度。 -
添加操作:
prepend、+、appended、:+、prependAll、++、appendedAll、:++、concat和union。可用于向集合中追加或前置一个或多个元素。 -
过滤:
distinct和distinctBy移除重复项,可能给定一些谓词。 -
反转:
reverse和reverseIterator返回一个新集合,其元素顺序相反。 -
排序:
sorted、sortWith和sortBy根据某些隐式Ordering或给定函数或两者进行排序。 -
等价性:
sameElements和corresponds检查此集合是否包含与给定顺序相同的元素,使用等价性检查或提供的比较函数。 -
子集合检索:
permutations和combinations。这些方法允许我们检索满足给定条件的子集合(子集合)。 -
更新:
diff、intersect、patch、updated和update(可变)。使用另一个集合或元素修改此集合的元素,并返回另一个集合(除了在mutable.Seq上定义的最后一个方法,它是在原地更新元素)。
每个 Seq 的直接子类都有自己的特定属性和实现子树。我们现在将快速浏览它们。
IndexedSeq
IndexedSeq 不引入新的操作,至少在其不可变版本中不是这样,但它覆盖了在 SeqOps 中定义的许多方法以提供更有效的实现。有四个类实现了它:

mutable.IndexedSeq 添加了以下变异选项:mapInPlace、sortInPlace、sortInPlaceWith 和 sortInPlaceBy。
mutable.ArraySeq 是表示 Array 的集合。它定义了一个返回底层数组的 array 方法,以及一个返回所需元素类型的标签的 elemTag 方法,以正确支持 JVM 所需的不同类型的数组。由于这个要求,它为所有原始类型(包括数值类型,除了 ofByte 之外,还有所有其他数值原始类型的实现,图中未显示)以及 Boolean、AnyRef 和 Unit 提供了单独的实现。
immutable.ArraySeq是在版本 2.13 中添加的。它实际上是一个包装数组的不可变序列,用于传递可变参数。它与其可变表亲有相同的后代。
范围是一个包含整数值的不可变结构。它由start、end和step定义。还有两个额外的方法可用:isInclusive,对于Range.Inclusive为true,对于Range.Exclusive为false,以及by,它使用不同的step但相同的start和end创建新的范围。
向量是一个不可变的结构,它提供了常数时间的访问和更新,以及快速的追加和预追加。正因为如此,Vector是IndexedSeq的默认实现,如下代码片段所示:
scala> IndexedSeq.fill(2)("A")
res6: IndexedSeq[String] = Vector(A, A)
WrappedString是某些String的不可变包装器。它通过IndexedSeqOps中定义的所有操作扩展了字符串。
线性序列
线性序列有头和尾的概念。定义看起来像trait LinearSeq[+A] extends Seq[A] with LinearSeqOps[A, LinearSeq, LinearSeq[A]],类图如下所示:

LinearSeq有三个代表,它们都是不可变的:
-
列表定义了三个符号方法,为模式匹配和构建列表提供了良好的语法。
::将元素追加到列表中,:::将给定列表的所有元素追加到列表中,而reverse_:::将给定列表的所有元素以相反的顺序追加到列表中。 -
懒列表是自 Scala 2.13 以来可用的一种新的不可变集合。它实现了一个带有
head和tail的列表,这些部分只有在需要时才会被评估。由于它在尾部比只有尾部是懒的Stream更优越,因此Stream现在已被弃用。LazyList有两个额外的方法,force用于评估它,以及lazyAppendAll,它以懒方式将给定的集合追加到这个列表中。 -
在这个层次结构中,队列也是不可变的。它允许以先进先出(FIFO)的方式插入和检索元素。为此,它定义了
enqueue、enqueueAll、dequeue、dequeueOption和front方法。
缓冲区
Buffers总结了我们对集合库的快速浏览。本质上,Buffer只是一个可以增长和缩小的Seq。这个子层次结构只以不可变的形式存在,尽管IndexedBuffer从Buffer和IndexedSeq继承,如下一图所示:

让我们来看看这些集合定义的方法,以及从SeqOps继承的定义:
-
缓冲区定义了在原地添加或删除一个或多个元素或返回新缓冲区的方法:
prepend、append、appendAll、+=:、prependAll、++=、insert、insertAll、remove、subtractOne、trimStart、trimEnd、patchInPlace、dropInPlace、dropRightInPlace、takeRightInPlace、sliceInPlace、dropWhileInPlace、takeWhileInPlace和padToInPlace。 -
ListBuffer是一个由
List实现的具体系列缓冲实现。除了其他讨论过的方法外,它还提供了prependToList,允许我们将这个集合添加到另一个列表中,以及一个三元组mapInPlace、flatMapInPlace和filterInPlace,这使我们有机会就地修改元素。 -
取一个
Buffer,添加一个IndexedSeq,你将得到一个IndexedBuffer。类似于ListBuffer,它提供了flatMapInPlace和filterInPlace方法。 -
ArrayBuffer是
IndexedBuffer的一个具体实现,它使用数组来存储其元素,并且对于追加、更新和随机访问具有常数时间。它有一个sizeHint方法,可以用来扩大底层数组。如果创建了mutable.Seq,它是一个默认实现。 -
ArrayDeque是 2.13 版本中出现的一个高效的集合。它实现了一个双端队列,内部使用可调整大小的环形缓冲区。这允许在追加、预追加、移除第一个元素、移除最后一个元素和随机访问操作上具有常数时间。这个集合上还有许多其他方法,这主要是因为第二端的概念:
removeHeadOption、removeHead、removeLastOption、removeLast、removeAll、removeAllReverse、removeHeadWhile、removeLastWhile、removeFirst、removeAll、clearAndShrink、copySliceToArray和trimToSize。 -
在这个层次结构中的队列是可变的。它基于
ArrayDeque,并允许我们以 FIFO(先进先出)的方式插入和检索元素。为此提供了以下方法:enqueue、enqueueAll、dequeue、dequeueFirst、dequeueAll和dequeueWhile。 -
Stack与
Queue类似,但它实现的是后进先出(LIFO)顺序而不是 FIFO。它定义的方法是以相应的术语表述的:push、pushAll、pop、popAll、popWhile和top。
Scala Collection Contrib 库
不言而喻,标准的 Scala 集合库非常丰富,为大多数常见用例提供了集合。但当然,还有一些可能在许多特定情况下有用的结构。Scala 集合Contrib模块是 Scala 拥有稳定标准库和一些额外功能的方式。从某种意义上说,这个模块是新集合类型的孵化器;那些被证明对广大用户有用的类型可能会在未来的 Scala 版本中被纳入标准库。
目前,该模块中有四种集合类型可用,每种类型都是可变的和不可变的:
-
MultiDict -
SortedMultiDict -
MultiSet -
SortedMultiSet
此外,这个库还提供了一个通过隐式丰富来定义现有集合上额外操作的可能性。以下导入是使其可用的必要条件:
import scala.collection.decorators._
它还提供了以下方法:
-
在 Seq 上:
intersperse和replaced -
关于 Map:
zipByKey、join、zipByKeyWith、mergeByKey、mergeByKeyWith、fullOuterJoin、leftOuterJoin和rightOuterJoin
请查阅github.com/scala/scala-collection-contrib模块文档以获取更多详细信息。
摘要
Scala 2.13 是 Scala 的一个小更新,主要关注重新设计的集合库。标准库中的一些小增补,如自动资源管理,只是强调了这一点。
新的集合库主要由两个形状相似的混合继承层次结构组成。第一个层次结构的成员描述了集合的结构,而第二个层次结构的成员——在这个集合类型上可用的操作。由于继承关系,位于树中较低位置的集合定义了针对更具体集合的额外方法,并覆盖了父特质定义的方法,以提供更高效的实现。
三个主要的集合类型是Seq、Set和Map。每种类型都有多种实现,适用于特定的情况。Set 也是一个单参数函数;Seq和Map是PartialFunctions。
大多数集合都提供可变和不可变的形式。
除了集合层次结构之外,还有一个概念叫做 View,它是迭代器操作的具体化定义,可以用来对集合进行懒加载的转换。另一个相关的抽象是IterableFactory,它实现了一些创建集合实例和在不同集合表示之间进行转换的通用方法。
在下一章中,我们将把我们的重点从 2.13 版本的特性转移到对 Scala 的一般探索,从其类型系统开始。
问题
-
描述两种使某些资源
R能够与scala.util.Using资源管理实用工具一起使用的方法。 -
如何比较一个
Set实例和一个List实例? -
为不可变
Seq命名默认的具体实现。 -
为不可变索引
Seq命名默认的具体实现。 -
为可变
Seq命名默认的具体实现。 -
为可变索引
Seq命名默认的具体实现。 -
有时候人们说
List.flatMap比预期的更强大。你能解释为什么吗? -
描述一种方法,使用不同的函数多次映射集合,但又不产生中间集合。
进一步阅读
-
Mads Hartmann, Ruslan Shevchenko,专业 Scala:在允许你为 JVM、浏览器等构建环境的条件下,编写简洁且富有表现力的、类型安全的代码。
-
Vikash Sharma,学习 Scala 编程:**学习如何在 Scala 中编写可扩展和并发程序,这是一种随着你成长的语言。
第二章:理解 Scala 中的类型
强类型系统是 Scala 语言最重要的部分之一。就像一把双刃剑,它帮助编译器在一侧验证和优化代码,同时也在另一侧引导开发者向可能的正确实现方向前进,并防止他们在编程过程中犯错误。就像任何锋利的工具一样,它需要一些技巧,这样它才能在雕刻美丽源代码的同时,不会伤害用户。
在本章中,我们将通过回顾和总结基本类型相关知识,查看 Scala 2.13 中引入的新类型,并最终查看一些类型的先进用法来提高这项技能。
本章将涵盖以下主题:
-
创建类型的不同方式
-
参数化类型的不同方式
-
类型种类
-
使用类型来表示领域约束
技术要求
在我们开始之前,请确保你已经安装了以下内容:
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter02.
理解类型
事物的类型是编译器拥有的关于这个“事物”的信息的总和。在最一般的情况下,我们谈论的是变量的类型;编译器的知识包括这个变量上可用的方法和这个变量扩展的类。Scala 的一个非常方便的特性是它尽可能地使用类型推断,从而让开发者从需要显式定义类型的需要中解放出来。
让我们以结构化的方式审视 Scala 的类型系统,从对其基本知识的简要回顾开始。
创建类型的途径
在 Scala 中定义类型有四种方式:
-
通过使用字面量来定义单例类型
-
通过使用类型关键字为抽象或具体类型定义别名
-
通过定义一个类、对象或特质来创建一个具体类型
-
通过定义一个创建方法类型的函数
单例类型是在 Scala 2.13 中引入的,我们将在本章后面详细探讨它们。现在,让我们尝试在 Scala REPL 中定义和引用一些具体的类型:
scala> class SomeClass
defined class SomeClass
scala> object SomeObject
defined object SomeObject
scala> :type SomeObject
SomeObject.type
scala> :type new SomeClass
SomeClass
scala> trait SomeTrait {
| def usage(a: SomeClass, b: SomeObject.type): SomeTrait
| }
defined trait SomeTrait
类型可以在完全定义之前被引用,正如SomeTrait的例子所示。
当注解类型、特性和类时,可以直接使用它们,但对象的类型需要通过使用其type运算符来引用。在 Scala 中,a.type形式描述了一个单例类型。根据p是否符合scala.AnyRef,它表示一组值[a, null]或仅仅是a。由于我们通常不在 Scala 程序中使用null,我们可以说a.type表示一个包含单个元素a的类型。通常,它不用于常规代码,因为它直接引用对象比将其作为参数传递更简单,但这种风格在一些高级库中用于实现内部 DSL 的部分。
在 Scala 2.13 中,有一个新的标记特质Singleton,可以作为类型参数的上界应用,这表示应该为此参数推断单例类型:
scala> def singleInT <: Singleton: T = t
singleIn: T <: SingletonT
scala> final val t = singleIn(42)
t: 42 = 42
方法类型不表示一个值,也不直接出现在程序中。它是方法定义的内部表示。它表示为参数名称的序列,每个参数都有相应的类型,以及方法的返回类型。了解方法类型很重要,因为如果方法名称用作值,其类型会隐式转换为相应的函数类型。正如我们在usage方法中定义的,编译器内部创建了一个名为(a: SomeClass, b: SomeObject.type)SomeTrait的方法类型。
字面量类型
Scala 2.13 引入了一种特殊类型的单例类型——字面量类型。它表示某个字面量的单个值,并代表这个字面量最精确的类型。它对所有可以提供字面量语法的类型都可用(例如,Boolean、Char、String和Symbol)。根据规范,无法为Unit定义字面量类型,也无法为Byte和Short定义(因为没有语法来定义此类类型的字面量)。以下是实际的工作方式:
scala> def bool2String(b: true) = "ja"
bool2String: (b: true)String
scala> bool2String(true)
res7: String = ja
scala> bool2String(false)
^
error: type mismatch;
found : Boolean(false)
required: true
定义字面量类型的变量有两种方式。第一种方式是使用显式的类型注解,第二种方式是将其声明为非懒加载的final:
scala> val a1: true = true
a1: true = true
scala> bool2String(a1)
res10: String = ja
scala> final val a2 = true
a2: Boolean(true) = true
scala> bool2String(a2)
res11: String = ja
scala> // but
scala> val a3 = true
a3: Boolean = true
scala> bool2String(a3)
^
error: type mismatch;
found : a3.type (with underlying type Boolean)
required: true
字面量类型在编译期间被擦除为普通类型,因此无法使用字面量类型覆盖方法:
scala> object scope {
| def bool2String(b: true) = "ja"
| def bool2String(b: false) = "nein"
| }
def bool2String(b: false) = "nein"
^
On line 3: error: double definition:
def bool2String(b: true): String at line 2 and
def bool2String(b: false): String at line 3
have same type after erasure: (b: Boolean)String
在前面的代码片段中,编译器阻止了我们声明两个具有相同名称的方法,因此由于擦除,它采用了具有不同字面量类型的参数。
由.type运算符形成的单例类型可以用来说明一个函数应该返回一个字面量类型而不是一个普通类型,正如编译器推断出的t的类型所展示的——42:
scala> def singleOutT: t.type = t
singleOut: Tt.type
scala> final val t = singleOut(42)
t: 42 = 42
自从 Scala 2.13 以来,有一个名为scala.ValueOf[T]的类型类和一个名为scala.Predef.valueOf[T]的运算符,可以用来为单例类型提供值。这是valueOf[T]的定义方式:
def valueOfT: T = vt.value
这就是它的用法:
scala> final val a = valueOf[42]
a: 42 = 42
对字面量类型的模式匹配也按预期工作,尽管语法不寻常且可能不太有用,如下面的第一个案例所示:
scala> def int2str(i: Int) = i match {
| case t: 42 => "forty-two"
| case ii => ii.toString
| }
int2str: (i: Int)String
scala> int2str(42)
res24: String = forhty-two
scala> int2str(43)
res25: String = 43
这些字面类型在日常编程中可能不太有趣,但对于类型级别库的开发来说非常有用。
复合(交集)类型
复合类型被定义为零个或多个组件类型与细化的组合。如果没有提供细化,编译器会添加一个隐式的空细化({})。根据组件的数量,我们可以有以下几种情况:
-
如果只给出细化,复合类型等价于扩展
AnyRef -
使用相应的
extends关键字扩展单个类型 -
两个或多个类型通过使用
with关键字交错组合
在组合类型和/或细化中发生名称冲突的情况下,适用通常的重写规则。这意味着最右侧的类型或细化具有最高优先级。这种组合还代表了一个继承关系,并且可以使用super关键字访问扩展类型的成员。
复合类型可以很容易地想象成一层层的包装器。正因为如此,解决特质中冲突成员的过程被称为特质线性化,而装饰者设计模式被称为可堆叠特质。以下示例演示了特质层如何访问复合类型子类型上定义的方法,以实现装饰的toString表示:
scala> trait A { override def toString = "A" }
defined trait A
scala> trait B { override def toString = super.toString + "B" }
defined trait B
scala> trait C { override def toString = super.toString + "C" }
defined trait C
scala> class E extends A with B with C {
| override def toString: String = super.toString + "D"
| }
defined class E
scala> new E().toString
res28: String = ABCD
在零个组件被扩展的情况下,类型的定义只包含一个细化。这种方式定义的类型被称为结构化类型。在 Scala 中,通常不建议使用结构化类型,因为它可能导致生成使用反射访问结构定义成员的字节码,这会显著减慢速度。尽管如此,定义类型 lambda 是有用的,我们将在本章末尾探讨这一点。
类型约束
类型约束是与类型相关联的规则。它们定义了所有类型的一个子集,例如,一个变量可以具有的类型。类型约束的形式是下界(子类型关系)或上界(超类型关系)。可以为单个类型定义多个约束。在这种情况下,类型必须满足这两个约束。约束使用符号 >:(下界,不高兴的界限)和 <:(上界,高兴的界限)来定义,符号的方向对应于 UML 图上箭头的反向方向,如下面的截图所示:

类型约束是包含性的,这就是为什么类型B既代表上界也代表下界。在我们的类型层次结构中,除了B之外,只有A遵守LOWER类型约束,只有C遵守UPPER约束。
Scala 的类型层次结构和特殊类型
类型约束与 Scala 的类型层次结构结合,给我们带来了一些有趣且重要的类。为了回顾,类型层次结构在以下图中表示:

在 Scala 中,所有类型都有一个最大上界 Any 和一个下界 Nothing。
值类是 Scala 避免分配运行时对象的一种方式。这是通过包装 JVM 的原始类型来实现的。Scala 已经将数值类型和 Boolean 以及 Char 表示为 AnyVal,并且可以通过扩展 AnyVal 并遵守一些限制来实现自定义值类。AnyVal 的一个有趣的子类型是 Unit 类型,它表示在函数或方法中需要返回不重要的东西的情况。它大致对应于空返回类型,并且有一个成员,()。
AnyRef 是任何在运行时分配的类型的表现。在 JVM 中,在期望对象引用的地方可以有 null;null 的类型是 Null。Null 类型有一个成员,null,就像 Unit 有一个值 () 一样。
Nothing 是其他每个类型的特殊子类型,并且没有成员。正因为如此,无法实例化该类型的成员。因此,在表示一个方法或函数终止的唯一可能性是异常终止时,这非常有用,通常是通过抛出异常来实现。
有两个特质在先前的图中没有表示,Serializable 和 Product。前者标记特质用于告诉 JVM 某个类应该在跨平台上可序列化,并且它只是委托给 Java 的接口,java.io.Serializable。
Product 保持为笛卡尔积,这基本上只是一个有序的命名类型对的集合。在 Scala 中,Product 通过元组和案例类扩展。
Self 类型是 Scala 中的另一个特殊概念,用于在不需要声明扩展关系的情况下定义特质之间的依赖关系。此语法允许你引入来自其他特质的特质成员的作用域,如下面的代码所示:
trait A { def a: String }
trait B { def b: String }
trait C { this: A => // override `this`
def c = this.a
}
trait D { self: A with B => // any alias is allowed; mixed traits
def d = this.a + this.b
}
我们特殊类型动物园中的最后一个成员是 Dynamic。这是一个标记特质,允许你使用方法的动态调用(也称为 鸭子类型)。
在这里详细讨论 Dynamic 可能有点不合适,因为 Scala 的优势正好相反——使用适当的类型静态地表达对系统的了解。对于好奇的读者,官方文档在此处提供:www.scala-lang.org/api/current/scala/Dynamic.html。
类型推断
上述类型层次结构对于理解类型推断的工作方式非常重要。类型推断是编译器用来猜测表达式或方法的类型的一种机制,如果省略了其类型的定义。这同样适用于多态方法的类型参数或泛型类的泛型参数,有时也适用于匿名函数的参数类型。这种推断旨在提供最具体的类型,同时遵守所有现有的约束。编译器通过遍历层次树并找到最小上界来实现这一点。让我们看一个例子:
case class C(); class D(); case class E()
def iOrB(i: Int, s: Boolean)(b: Boolean): AnyVal = if (b) i else s
def iOrS(i: Int, s: String)(b: Boolean): Any = if (b) i else s
def sOrC(c: C, s: String)(b: Boolean): java.io.Serializable = if (b) c else s
def cOrD(c: C, d: D)(b: Boolean): AnyRef = if (b) c else d
def cOrE(c: C, e: E)(b: Boolean): Product with Serializable = if (b) c else e
在这里,我们指定了返回类型,编译器会推断它们。对于前两种情况,你可以很容易地遵循 Scala 类型的层次结构来理解编译器是如何进行推断的。最后三种情况稍微复杂一些:
-
在
sOrC中,推断出的类型是java.io.Serializable。这是因为 Scala 的String只是java.lang.String的一个别名,它扩展了java.io.Serializable。Scala 中的所有案例类默认都扩展了Product with Serializable,而Serializable扩展了java.io.Serializable。因此,在这种情况下,java.io.Serializable是最小上界。 -
在
cOrD中,D不是一个案例类,因此它不扩展任何东西,只是扩展了AnyRef,这成为了一个推断出的类型。 -
在
cOrE中,C和E都是案例类,因此编译器可以推断出最具体的类型,即Product with Serializable。
实际上,编译器的精确度可以相当高,以下示例可以证明:
trait Foo { def foo: Int }
case class F() extends Foo {def foo: Int = 0}
case class G() extends Foo {def foo: Int = 0}
def fOrG(f: F, g: G)(b: Boolean):
Product with Serializable with Foo = if (b) f else g
在这里,我们可以看到fOrG推断出的类型是一个包含三个成员的复合类型。
依赖路径的类型
到目前为止,我们避免谈论路径,主要是因为它们本身不是类型。然而,它们可以是命名类型的一部分,因此在 Scala 的类型系统中扮演着重要的角色。
路径可以有以下几种形式:
-
一个空路径,用
ε表示。它不能直接书写,但隐式地位于任何其他路径之前。 -
C.this,其中C是一个引用类。这是在类内部使用this时构建的路径。 -
C.super.x.或C.super[P].指的是C的超类或指定的父类P的成员x。它在类中的作用与this相同,但指向的是层次结构中更上层的类。 -
p.x,其中p是一个路径,x是p的一个稳定成员。稳定成员是一个对象定义或值定义,编译器可以确定它始终是可访问的(与不可访问的类型相对,例如,有一个可以被子类覆盖的抽象类型定义)。
路径内的类型可以通过两个运算符进行引用,#(哈希)和.(点)。前者被称为类型投影,T#m指的是类型T的成员m。我们可以通过构建一个类型安全的锁来展示这两个运算符之间的区别:
case class Lock() {
final case class Key()
def open(key: Key): Lock = this
def close(key: Key): Lock = this
def openWithMaster(key: Lock#Key): Lock = this
def makeKey: Key = new Key
def makeMasterKey: Lock#Key = new Key
}
在这里,我们定义了一个类型,Lock,它包含一个嵌套类型,Key。键可以通过其路径Lock.Key或通过投影Lock#Key来引用。前者表示与特定实例相关的类型,后者表示不是。键的具体类型由两个不同的构造方法返回。makeKey的返回类型是一个Key,它是this.Key的快捷方式,而this.Key又是一个别名,代表Lock.this.type#Key,它表示一个路径相关类型。后者只是一个类型投影,Lock#Key。因为路径相关类型引用了具体的实例,编译器将只允许使用适当类型来调用open和close方法:
val blue: Lock = Lock()
val red: Lock = Lock()
val blueKey: blue.Key = blue.makeKey
val anotherBlueKey: blue.Key = blue.makeKey
val redKey: red.Key = red.makeKey
blue.open(blueKey)
blue.open(anotherBlueKey)
blue.open(redKey) // compile error
red.open(blueKey) // compile error
masterKey 不是路径相关的,因此可以以通常的方式在任意实例上调用方法:
val masterKey: Lock#Key = red.makeMasterKey
blue.openWithMaster(masterKey)
red.openWithMaster(masterKey)
这些路径相关的类型结束了我们对具体类型的探索,并且可以用来描述值。我们迄今为止所看到的所有类型(除了方法类型)都被命名为值类型,以反映这一事实。一个命名的值类型被称为类型设计器。所有类型设计器都是类型投影的缩写。
我们现在将转换方向,检查如何使用类型来叙述其他类型的定义。
类型 – 贯穿始终
到目前为止,我们只讨论了具体类型。尽管它们相当简单,但它们已经在类型级别上允许表达程序的大量属性,并且这些属性在编译时得到验证。Scala 通过允许开发者在定义方法、类或其他类型时使用类型作为参数,为开发者提供了更多的自由。在下一节中,我们将探讨不同的实现方式,从基本类型参数和类型成员定义开始,继续到类型约束和变异性话题。我们将以高阶类型和类型 lambda 作为讨论的结论。
类型参数
类型参数使用方括号 [] 定义。如果应用于类和方法,它们必须在正常参数之前声明,其结果称为参数化类型:
case class WrapperA {
def unwrap: A = content
}
def createWrapperA:Wrapper[A] = Wrapper(a)
type ConcreteWrapper[A] = Wrapper[A]
val wInt: Wrapper[Int] = createWrapperInt
val wLong: ConcreteWrapper[Long] = createWrapper(10L)
val int: Int = wInt.unwrap
val long: Long = wLong.unwrap
Wrapper类通过A类型进行参数化。这个类型参数用于在unwrap方法中引用内容类型。作用域解析规则以与正常参数相同的方式应用于类型参数,如unwrap方法定义所示。
createWrapper方法定义展示了类型参数如何传播到实现端——编译器通过Wrapper(a)将A类型参数化。
ConcreteWrapper类型定义展示了类型别名以与类型相同的方式进行参数化。
我们随后使用我们的参数化类型来展示,在调用端提供显式的类型参数,以及依赖类型推断是可能的。
这种类型推断实际上非常强大。编译器总是试图找到最具体的类型,以下示例揭示了这一点(我提供了显式的类型注解,它们反映了编译器推断的类型):
case class AbcA
val intA: Abc[Int] = Abc(10, 20, 30)
val longA: Abc[Long] = Abc(10L, 20L, 30L)
val whatA: Abc[AnyVal] = Abc(10, 20, true)
val whatB: Abc[io.Serializable] = Abc("10", "20", Wrapper(10))
val whatC: Abc[Any] = Abc(10, "20", Wrapper(10))
我们之前讨论了 Scala 的类型层次结构,所以应该很明显编译器是如何在前面的代码片段中得出这些类型的。
可以通过使用类型约束来限制类型参数的可能定义,以下示例展示了这一点:
trait Constraints[A <: AnyVal, B >: Null <: AnyRef] {
def a: A
def b: B
}
// compile error - type parameter bounds
// case class AB(a: String, b: Int) extends Constraints[String, Int]
case class AB(a: Int, b: String) extends Constraints[Int, String]
编译器将检查具体定义是否符合类型参数的限制。
类型成员
一个 类型成员 类似于类型参数,但它被定义为抽象类或特质的一个类型别名。然后可以在抽象定义本身被具体化的时刻将其具体化。让我们看看以下几行代码,它将展示这是如何工作的:
trait HolderA {
type A
def a: A
}
class A extends HolderA {
override type A = Int
override def a = 10
}
在这里,我们定义了一个抽象类型成员 A,并在具体实现中通过将其绑定到 Int 来覆盖它。
当然,可以定义多个类型成员,并对它们进行约束,包括将类型成员本身作为约束的一部分:
trait HolderBC {
type B
type C <: B
def b: B
def c: C
}
在这种情况下不应用类型推断,因此以下代码将无法编译,因为缺少类型定义:
class BC extends HolderBC {
override def b = "String"
override def c = true
}
这些类型成员可以使用适用于其他类型定义的所有语言特性来定义,包括多个类型约束和路径相关类型。在以下示例中,我们通过在 HolderDEF 中声明类型成员并在类 DEF 中提供具体定义来展示这一点。不兼容的类型定义被标记为不兼容,并已注释掉:
trait HolderDEF {
type D >: Null <: AnyRef
type E <: AnyVal
type F = this.type
def d: D
def e: E
def f: F
}
class DEF extends HolderDEF {
override type D = String
override type E = Boolean
// incompatible type String
// override type E = String
// override def e = true
override def d = ""
override def e = true
// incompatible type DEF
// override def f: DEF = this
override def f: this.type = this
}
还可以将类型成员和类型参数结合起来,并在以后进一步约束前者的可能定义:
abstract class HolderGH[G,H] {
type I <: G
type J >: H
def apply(j: J): I
}
class GH extends HolderGH[String, Null] {
override type I = Nothing
override type J = String
override def apply(j: J): I = throw new Exception
}
类型成员和类型参数在功能上非常相似——这是为了定义可以稍后细化的抽象类型定义。鉴于这种相似性,开发者大多数时候可以使用其中一个。然而,还有一些细微差别,关于在哪些情况下你应该更倾向于使用它们。
这些类型参数通常更直接且更容易正确使用,因此通常应该优先选择。如果你遇到以下情况之一,类型成员是最佳选择:
-
如果具体的类型定义应该保持隐藏
-
如果提供类型具体定义的方式是通过继承(在子类中覆盖或在特质中混合)
还有一条简单易记的规则——类型参数用于定义方法的参数类型和类型成员用于定义此方法的结果类型:
trait Rule[In] {
type Out
def method(in: In): Out
}
在 Scala 中,还有另一种指定类型参数和类型成员边界的途径。
泛化类型约束
在前两节中,我们使用了语言提供的类型约束来精确地定义类型成员和类型参数。标准库中还定义了一些补充的泛化类型约束,允许你使用类型类来定义类型之间的关系。我们将在第四章,了解隐式和类型类中详细探讨类型类和隐式,但在这里我们将简要介绍泛化类型约束。
<:<约束表达了左侧类型是右侧类型的子类型的要求。基本上,拥有A <:< B的实例与拥有A <: B的定义相同。但为什么需要它呢?因为有时语言的表达能力不足。让我们通过一个例子来看看:
abstract class Wrapper[A] {
val a: A
// A in flatten shadows A in the Wrapper
// def flatten[B, A <: Wrapper[B]]: Wrapper[B] = a
def flatten(implicit ev: A <:< Wrapper[B]): Wrapper[B] = a
}
无法表达A <: Wrapper[B]类型约束,因为在这个定义中的A将覆盖Wrapper[A]定义中的A类型约束。隐式类型ev可以轻松解决这个问题。如果编译器可以证明子类型关系成立,ev将在作用域内可用。
Scala 标准库中可用的另一个泛化类型约束是=:=。因此,A =:= B允许你要求A和B相等,就像A <:< B允许你表达子类型关系一样。由于子类化的限制,它也证明了A <:< B,但不证明B <:< A。我们将在本章末尾详细探讨这种等价关系如何用来表达域约束。
A <:< B 和 A =:= B 的奇怪语法带我们进入下一节,中缀类型。
中缀类型
与 Scala 有中缀运算符一样,它也有中缀类型。中缀类型,如A Op B,只是恰好有两个类型操作数的任何类型。它等价于定义为Op[A, B]的类型。Op可以是任何有效的标识符。
类型运算符具有与项运算符相同的结合性——它们是左结合的,除非运算符以:(冒号)结尾,在这种情况下它是右结合的。连续的中缀运算符必须具有相同的结合性。让我们通过一个例子来理解这意味着什么:
type Or[A, B]
type And[A, B]
type +=[A, B] = Or[A, B]
type =:[A, B] = And[A, B]
type CC = Or[And[A, B], C]
type DA = A =: B =: C
type DB = A And B And C
// type E = A += B =: C // wrong associativity
type F = (A += B) =: C
在这里,我们定义了四种类型,它们都具有两个类型参数,因此可以用作中缀类型。然后,我们定义了一个名为CC的类型,它表达了A、B和C类型之间的一些关系。DA和DB类型定义显示了中缀表示法中的类型定义看起来是什么样子。尝试定义与C类型相同的某些类型E的第一种方法失败了,因为类型的结合性不同,=+和=:,并且我们已经展示了如何使用括号来绕过这个规则。
如果使用得当,中缀类型可以极大地提高代码的可读性:
type |[A, B] = Or[A, B]
type [A, B] = And[A, B]
type G = A B | C
在这里,我们可以看到中缀类型如何允许你以类似于布尔操作的方式定义类型关系。
可变性
协变性是与参数化类型相关的另一个方面。为了理解为什么需要它以及它是如何工作的,让我们喝一杯。首先,我们将定义一个可以(半)满或空的玻璃杯:
sealed trait Glass[Contents]
case class FullContents extends Glass[Contents]
case object Empty extends Glass[Nothing]
只能有一个空玻璃杯装满Nothing,我们用案例对象来模拟这种情况。一个满的玻璃杯可以装不同的内容。Nothing是 Scala 中任何类的子类,所以在这种情况下,它应该能够替代任何内容。我们现在将创建内容,并且希望能够喝掉它。在这个情况下,实现并不重要:
case class Water(purity: Int)
def drink(glass: Glass[Water]): Unit = ???
我们现在能够从满的玻璃杯中喝,但不能从空的玻璃杯中喝:
drink(Full(Water(100)))
drink(Empty) // compile error, wrong type of contents
但如果我们不想喝,而是想定义drinkAndRefill,这个方法应该能够给一个空玻璃杯重新装满?
def drinkAndRefill(glass: Glass[Water]): Unit = ???
drinkAndRefill(Empty) // same compile error
我们希望我们的实现不仅接受Glass[Water],还接受Glass[Nothing],或者更一般地,任何Glass[B],如果B <: Water。我们可以相应地更改我们的实现:
def drinkAndRefillB <: Water: Unit = ???
但如果我们想让我们的Glass与任何方法都这样工作,而不仅仅是drinkAndRefill?那么我们需要定义参数化类型之间的关系应该如何影响参数化类型的工作方式。这是通过协变来完成的。我们的定义,sealed trait Glass[Contents],被称为不变,这意味着参数化Glass的类型之间的关系不会影响不同内容玻璃杯之间的关系——它们之间根本没有任何关系。协变性意味着,对于编译器来说,如果类型参数处于子类关系,那么主要类型也应该如此。它通过在类型约束之前使用一个+(加号)来表示。因此,我们的玻璃定义变成了以下这样:
sealed trait Glass[+Contents]
其余的代码保持不变。现在,如果我们有相关的内容,我们可以喝掉它们,而不会遇到之前遇到的问题:
drink(Empty) // compiles fine
协变性的典型用法是与不同种类的不可变容器一起,其中在容器中拥有一个更具体的元素是安全的,就像由类型声明的那个元素。
但与可变容器一起这样做是不安全的。编译器不会允许我们这样做,但如果它允许,我们可能会传递一个包含某些子类B的容器C到方法中,期望得到一个包含超类A的容器。这个方法然后将能够用A(它甚至不应该知道B的存在)替换C的内容,从而使得未来对C[B]的使用变得不可能。
现在,让我们想象一下,我们的玻璃应该与一个饮酒者互动。我们将为这个目的创建一个Drinker类,并且饮酒者应该能够喝掉Glass中的内容:
class Drinker[T] { def drink(contents: T): Unit = ??? }
sealed trait Glass[Contents] {
def contents: Contents
def knockBack(drinker: Drinker[Contents]): Unit = drinker.drink(contents)
}
case class FullC extends Glass[C]
现在,让我们检查一下,如果我们有两种不同的Water会发生什么:
class Water(purity: Int)
class PureWater(purity: Int) extends Water(purity) {
def shine: Unit = ???
}
val glass = Full(new PureWater(100))
glass.knockBack(new Drinker[PureWater])
PureWater是带有一些额外属性的Water,我们可以创建一个装满它的玻璃杯,并让它填满一个饮酒者。显然,如果有人可以喝普通的水,他们也应该能够喝纯水:
glass.knockBack(new Drinker[Water]) // compile error
为了解决这个问题,我们需要使用逆变,这由类型参数前的-(减号)表示。我们这样修复我们的Drinker,我们的示例开始编译:
class Drinker[-T] { def drink(contents: T): Unit = ??? }
glass.knockBack(new Drinker[Water]) // compiles
重要的是要注意,协变和逆变并不定义类型本身,而只是类型参数。这对于 Scala 中的函数式编程非常重要,因为它允许将函数定义为第一类公民。我们将在下一章更详细地探讨函数定义,但为了给你一些提示,这里是什么意思。
如果我们要传递一个函数给调用者,即f(water: Water): Water,那么传递哪种替代函数是安全的呢?传递一个接受PureWater的函数是不安全的,因为调用者无法用这样的参数调用它。但是,如果函数接受Water及其任何描述逆变性的超类,那么这是安全的。对于结果,如果我们的替代函数返回比f更高的层次结构中的任何内容,那么这是不可接受的,因为调用者期望结果至少与f一样具体。如果替代函数更具体,那就没问题了。因此,我们最终得到的是协变。因此,我们可以将f定义为f[-Parameter,+Result]。
存在类型
如果我们不再关心类型参数的具体细节,存在类型就会发挥作用。以我们的前一个例子为例,如果我们有一个期望某种东西的玻璃的方法,但在方法内部,我们实际上并不关心这个“某种东西”是什么,那么我们得到以下结果:
def drinkT <: Water: Unit = { g.contents; () }
在这个定义中,我们实际上不需要知道T是什么,我们只是想确保它是某种Water。Scala 允许你使用下划线作为占位符,就像它可以用来表示未使用的变量一样:
def drink_ <: Water: Unit = { g.contents; () }
这是存在类型的占位符语法。正如我们之前看到的,如果省略了上界,则假定scala.Any。在未定义下界的情况下,编译器将隐式添加scala.Nothing。
这个语法只是更强大的语法T forSome { Q }的简短版本,其中Q是一系列类型声明,例如:
import scala.language.existentials
val glass = FullT forSome { type T <: Water })
存在类型被认为是高级语言特性,因此需要相应的导入使其在作用域内或作为编译器选项启用。
高阶类型
我们的玻璃示例已经变得有点无聊了。为了再次让它变得有趣,我们将添加另一个抽象概念,一个罐子。这样我们的模型将看起来是这样的:
sealed trait Contents
case class Water(purity: Int) extends Contents
case class Whiskey(label: String) extends Contents
sealed trait Container[C <: Contents] { def contents: C }
case class GlassC<: Contents extends Container[C]
case class JarC <: Contents extends Container[C]
玻璃和罐子都可以装满任何内容。例如,可以这样操作:
def fillGlassC <: Contents: Glass[C] = Glass(c)
def fillJarC <: Contents: Jar[C] = Jar(c)
如我们所见,这两种方法在用于构造结果的类型方面看起来是相同的。用于构造类型的参数化类型被称为类型构造器。作为一个一致的语言,Scala 允许你以与通过高阶函数抽象函数相同的方式抽象类型构造器(更多关于这一点将在下一章中讨论)。这种对类型构造器的抽象被称为高阶类型。语法要求我们在定义侧使用下划线来表示预期的类型参数。然后实现应该使用没有类型约束的类型构造器。
我们可以使用类型构造器来提供通用的填充功能。当然,我们无法摆脱关于如何填充我们容器的具体知识,但我们可以将其移动到类型级别:
sealed trait Filler[CC[_]] {
def fillC: CC[C]
}
object GlassFiller extends Filler[Glass] {
override def fillC: Glass[C] = Glass(c)
}
object JarFiller extends Filler[Jar] {
override def fillC: Jar[C] = Jar(c)
}
在前面的代码中,我们使用类型构造器CC[_]在Filler特质中表示Glass和Jar。现在我们可以使用创建的抽象来定义通用的填充功能:
def fill[C, G[_]](c: C)(F: Filler[G]): G[C] = F.fill(c)
G[_]类型是玻璃和罐子的类型构造器,Filler[G]是一个高阶类型,它使用这个类型构造器为任何内容C构建完整的G[C]。这就是通用填充方法在实际中是如何使用的:
val fullGlass: Glass[Int] = fill(100)(GlassFiller)
val fullJar: Jar[Int] = fill(200)(JarFiller)
目前这可能看起来并没有在特定方法上取得巨大的胜利,因为我们已经明确地提供了我们的类型构造器。真正的优势将在我们开始讨论第四章中的隐式内容时变得明显,即了解隐式和类型类。
类型 lambda
作为下一步,让我们想象一下,我们有一个通用的Filler,它能够用不同种类的填充物填充不同的容器,如下面的代码片段所示:
sealed trait Contents
case class Water(purity: Int) extends Contents
case class Whiskey(label: String) extends Contents
sealed trait Container[C] { def contents: C }
case class GlassC extends Container[C]
case class JarC extends Container[C]
sealed trait Filler[C <: Contents, CC <: Container[C]] {
def fill(c: C): CC
}
如果我们有一个需求,需要提供一个只能接受一种容器或内容的方法,我们该如何做?我们需要以类似部分应用函数的方式固定第二个类型参数。类型别名可以在类型级别上用来完成这个操作:
type WaterFiller[CC <: Container[Water]] = Filler[Water, CC]
def fillWithWater[CC <: Container[Water]](container: CC)(filler: WaterFiller[CC]) = ???
但是,仅仅为了在函数参数的定义中用一次,就定义一个类型别名,感觉有点冗长。类型 lambda是一种语法,允许我们在原地执行这种部分类型应用:
def fillWithWater[CC <: Container[Water], F: ({ type T[C] = Filler[Water, C] })#T[CC]](container: CC)(filler: F) = ???
类型 lambda 也可以用来直接定义参数类型:
def fillWithWater[CC <: Container[Water]](container: CC)(filler: ({ type T[C] = Filler[Water, C] })#T) = ???
T[C]的内部定义与我们之前定义的类型别名类似。增加的部分是类型投影()#T[C]`,它允许我们引用刚刚定义的类型。
使用类型定义域约束
我们已经看到如何使用简单类型来表示域约束,正如在路径依赖类型部分所讨论的。我们实现了一个锁,它在编译时保证了只能使用为这个特定锁创建的钥匙来打开和关闭它。我们将通过两个示例来结束对类型参数和高阶类型的探讨。
第一个示例将演示如何使用幻影类型创建锁的另一个版本,该版本可以在不使用继承的情况下在编译时保证状态转换的安全性。
第二个示例将展示自递归类型如何帮助约束可能的子类型。
幻影类型
Scala 中的幻影类型是一种在运行时永远不会实例化的类型。正因为如此,它仅在编译时有用,用于表达类似于(泛化)类型约束的领域约束。为了了解它是如何工作的,让我们想象以下情况——我们有一个Lock的抽象,它已经通过继承的不同方式实现了:
sealed trait Lock
class PadLock extends Lock
class CombinationLock extends Lock
我们希望在类型系统中编码,只有以下状态转换对于任何锁是允许的:
-
open -> closed
-
closed -> open
-
closed -> broken
-
open -> broken
由于我们已经有了一个现有的层次结构,我们无法通过将ClosedLock、OpenLock和BrokenLock扩展到Lock上来轻松地用继承来模拟这些状态转换。相反,我们将使用幻影类型Open、Closed和Broken来模拟状态(我们稍后将从零开始定义Lock,以避免在示例中添加不必要的细节):
sealed trait LockState
sealed trait Open extends LockState
sealed trait Closed extends LockState
sealed trait Broken extends LockState
现在,我们可以将这个State赋值给Lock:
case class Lock[State <: LockState]()
并使用类型约束定义我们的状态转换方法:
def break: Lock[Broken] = Lock()
def open[_ >: State <: Closed](): Lock[Open] = Lock()
def close[_ >: State <: Open](): Lock[Closed] = Lock()
我们可以将任何锁带到 broken 状态,这样break方法就不会有任何约束定义。
从Closed状态转换到Open状态仅从Closed状态可用,我们通过存在类型(尽管如此,它应该可用于成功编译)来编码这一事实,它是锁当前State的子类,也是Closed的超类。满足类型约束的唯一可能性是State等于Closed。这是通过只有一种可能的方式来调用close方法并满足类型约束,即让Lock处于Open状态来完成的。让我们看看编译器在不同情况下的反应:
scala> val openLock = Lock[Open]
openLock: Lock[Open] = Lock()
scala> val closedLock = openLock.close()
closedLock: Lock[Closed] = Lock()
scala> val broken = closedLock.break
broken: Lock[Broken] = Lock()
scala> closedLock.close()
^
error: inferred type arguments [Closed] do not conform to method close's type parameter bounds [_ >: Closed <: Open]
scala> openLock.open()
^
error: inferred type arguments [Open] do not conform to method open's type parameter bounds [_ >: Open <: Closed]
scala> broken.open()
^
error: inferred type arguments [Broken] do not conform to method open's type parameter bounds [_ >: Broken <: Closed]
编译器拒绝接受会导致不适当状态转换的调用。
我们也可以通过使用泛化类型约束来提供一个替代实现:
def open(implicit ev: State =:= Closed): Lock[Open] = Lock()
def close(implicit ev: State =:= Open): Lock[Closed] = Lock()
有争议的是,泛化语法更好地传达了意图,因为它在第一种情况下几乎可以读作“状态应等于 Closed”,在第二种情况下则读作“状态应等于 Open”。
让我们看看编译器对我们新实现有何反应:
scala> val openLock = Lock[Open]
openLock: Lock[Open] = Lock()
scala> val closedLock = openLock.close
closedLock: Lock[Closed] = Lock()
scala> val lock = closedLock.open
lock: Lock[Open] = Lock()
scala> val broken = closedLock.break
broken: Lock[Broken] = Lock()
scala> closedLock.close
^
error: Cannot prove that Closed =:= Open.
scala> openLock.open
^
error: Cannot prove that Open =:= Closed.
scala> broken.open
^
error: Cannot prove that Broken =:= Closed.
显然,对于具有泛化类型约束的实现,错误信息也更好。
自递归类型
让我们回顾一下之前示例中从单个特质继承的不同实现:
sealed trait Lock
class PadLock extends Lock
class CombinationLock extends Lock
我们现在将扩展Lock以包含一个open方法,该方法应返回与Lock相同的类型,并让我们的实现作为类型参数:
sealed trait Secret[E]
sealed trait Lock[E] { def open(key: Secret[E]): E = ??? }
case class PadLock() extends Lock[PadLock]
case class CombinationLock() extends Lock[CombinationLock]
目前这个实现并不很有趣——重要的是它返回了与调用实例相同的类型。
现在,有了这个实现,有一个问题是我们可以用它来与根本不是Lock的东西一起使用:
case class IntLock() extends Lock[Int]
lazy val unlocked: Int = IntLock().open(new Secret[Int] {})
当然,我们不想允许这样做!我们希望约束我们的类型参数,使其成为Lock的子类型:
sealed trait Lock[E <: Lock]
但不幸的是,这无法编译,因为Lock接受了一个在先前定义中不存在的类型参数。我们需要提供那个类型参数。它应该是什么?逻辑上,与用来参数化Lock的相同类型——E:
sealed trait Lock[E <: Lock[E]] {
def open(key: Secret[E]): E = ???
}
类型参数看起来有点奇怪,因为它以递归的方式引用自身。这种定义类型的方式被称为自递归类型参数(有时也称为 F 界限类型多态)。
现在,我们只能通过类型来参数化Lock,而这个类型本身也是一个Lock:
scala> case class IntLock() extends Lock[Int]
^
error: illegal inheritance;
self-type IntLock does not conform to Lock[Int]'s selftype Int
scala> case class PadLock() extends Lock[PadLock]
defined class PadLock
但不幸的是,我们仍然可以通过定义错误的子类型作为类型参数来搞砸事情:
scala> case class CombinationLock() extends Lock[PadLock]
defined class CombinationLock
因此,我们需要定义另一个约束,表明类型参数应该引用类型本身,而不仅仅是任何Lock。我们已经知道有一个自类型可以用于此:
sealed trait Lock[E <: Lock[E]] { self: E =>
def open(key: Secret[E]): E = self
}
scala> case class CombinationLock() extends Lock[PadLock]
^
error: illegal inheritance;
self-type CombinationLock does not conform to Lock[PadLock]'s selftype PadLock
scala> case class CombinationLock() extends Lock[CombinationLock]
defined class CombinationLock
scala> PadLock().open(new Secret[PadLock]{})
res2: PadLock = PadLock()
scala> CombinationLock().open(new Secret[CombinationLock]{})
res3: CombinationLock = CombinationLock()
太好了!我们刚刚定义了一个只能用扩展此特质的类以及自身来参数化的Lock特质。我们通过使用自递归类型参数和自类型组合实现了这一点。
摘要
类型系统是 Scala 语言的关键组件之一。它允许开发者表达对程序行为的期望,这些期望可以在编译时进行检查。这减少了验证解决方案正确性所需的测试数量,以及运行时错误的可能性。
通常,严格类型化的语言与冗长的代码相关联。通常,Scala 并不是这样,因为它拥有强大的类型推断机制。
Scala 允许你定义非常窄的类型,只包含单个值,以及更宽的类型,甚至那些表示为其他类型组合的类型。
通过使用类型约束、类型参数和变异性,可以使类型定义更加精确。
我们还查看了一些如何使用类型系统来表示领域约束的示例。
不言而喻,Scala 的生态系统比我们这里所涵盖的丰富得多。一些开源库提供了以精炼类型、定点类型或标记类型表达的高级类型约束。其他库,如 shapeless,提供了类型级别编程的可能性,这允许你在编译时表达和验证相当复杂的程序逻辑。
问题
-
你能说出哪些类型约束?
-
如果开发者在类型上没有定义任何类型约束,那么会添加哪些隐式类型约束?
-
哪些运算符可以用来引用某些类型的嵌套类型?
-
哪种类型可以用作中缀类型?
-
为什么在 Scala 中不建议使用结构化类型?
-
通过变异性表达了什么?
进一步阅读
-
Mads Hartmann 和 Ruslan Shevchenko 著,《Professional Scala》:你将学习如何在允许你为 JVM、浏览器等构建的环境下,简洁且富有表现力地编写类型安全的代码。
-
Vikash Sharma 著,《Learning Scala Programming》:学习如何在 Scala 中编写可扩展和并发程序,这是一种与你一同成长的编程语言。
第三章:深入探讨函数
Scala 结合了面向对象和函数式编程范式。特别是,函数是一个一等语言概念。它们可以用各种方式定义,分配给变量,作为参数传递,并存储在数据结构中。Scala 在如何执行这些操作方面提供了很大的灵活性。
我们将首先详细研究定义函数的不同方式。然后我们将继续应用上一章关于类型的知识,使我们的函数成为多态和更高阶的。我们将研究递归、尾递归和跳跃作为 JVM 函数式编程的重要方面。最后,我们将评估与 Scala 中的函数以面向对象方式实现相关的特殊性。
本章将涵盖以下主题:
-
定义函数的方法
-
多态函数
-
高阶函数
-
递归
-
跳跃
-
函数的面向对象特性
技术要求
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter03找到。
定义函数的方法
为了为不同 Scala 知识水平的读者提供一个共同的基础,让我们回顾一下如何定义一个函数。我们将从基本方法开始,例如定义一个方法并将其放置在不同的作用域中以创建一个局部函数。然后我们将探讨更有趣的方面,例如闭包作用域、部分应用、指定函数字面量的不同方式,以及最终的多态。
函数作为方法
大多数 Scala 开发者都是从 Java 开始的。因此,最常见的方法是在类、特质或对象内部定义一个方法,如下面的熟悉示例所示:
class MethodDefinition {
def eq(arg1: String, arg2: Int): Boolean = ! nonEqual(arg1, arg2)
private def nonEq(a: String, b: Int) = a != b.toString
}
按照惯例,我们已明确为公共方法定义了返回类型,就像我们为 Java 中的返回类型做的那样。对于非递归函数,可以省略结果类型。我们已经在私有方法中这样做。
值参数的类型声明是强制性的。
每个值参数可以分配一个默认值:
def defaultValues(a: String = "default")(b: Int = 0, c: String = a)(implicit d: Long = b, e: String = a) = ???
前面的代码片段还演示了定义多个值参数组是可能的。最后一个组的参数可以是隐式的,也可以提供默认值。连续组的默认值可以引用先前组中定义的参数,例如e引用了c的默认值,而c的默认值是a。
值参数前缀为=>的类型表示,在方法调用时不应评估此参数,而是在每次在方法体中引用参数时进行评估。这种参数称为按名参数,基本上,它们代表一个零参数方法,具有参数的返回类型:
scala> def byName(int: => Int) = {
| println(int)
| println(int)
| }
byName: (int: => Int)Unit
scala> byName({ println("Calculating"); 10 * 2 })
Calculating
20
Calculating
20
在这个例子中,我们可以看到传递的代码块被执行了两次,与在方法体内部的用法数量相匹配。
将*(星号)添加到最后一个值参数类型的名称后缀,表示这是一个重复的参数,它接受一定数量的定义类型的参数。然后,给定的参数作为指定类型的collection.Seq集合在方法体中可用:
def variable(a: String, b: Int*): Unit = {
val bs: collection.Seq[Int] = b
}
variable("vararg", 1, 2, 3)
variable("Seq", Seq(1, 2, 3): _*)
直接将Seq传递作为重复参数是不合法的。前一个代码块中的最后一行显示了:_*语法来标记最后一个参数为序列参数。重复参数不能接受默认值。
方法定义中的参数都有名字。这些名字可以用来通过提供任意顺序的参数来调用方法(与在方法定义中指定的顺序相反):
def named(first: Int, second: String, third: Boolean) = s"$first, $second, $third"
named(third = false, first = 10, second = "Nice")
named(10, third = true, second = "Cool")
命名参数和普通参数可以混合使用,如前一个代码块的最后一行所示。在这种情况下,必须首先指定位置参数。
到目前为止,我们定义的示例都是在包含类或对象的范围内。但 Scala 在这方面提供了更多的灵活性。方法可以在任何有效的范围内定义。这使得它局部于包含块,从而限制了其可见性。
局部函数
这里是两个局部于包含方法的函数的例子:
def average(in: Int*): Int = {
def sum(in: Int*): Int = in.sum
def count(in: Int*): Int = in.size
sum(in:_*)/count(in:_*)
}
在这个例子中,我们在average定义内部同时定义了sum和count,这使得它们从外部无法访问:
scala> :type sum
^
error: not found: value sum
scala> :type count
^
error: not found: value count
如前所述,函数不需要嵌套在另一个方法中。包含块可以是任何类型,例如,变量定义。例如,考虑如果上一个示例中的average函数只是为了计算单个平均值而定义:
val items = Seq(1,2,3,4,5)
val avg = average(items:_*)
我们可以将这两个代码块重写如下:
val items = Seq(1,2,3,4,5)
val avg = {
def sum(in: Int*): Int = in.sum
def count(in: Int*): Int = in.size
sum(items:_*)/count(items:_*)
}
范围可见性规则适用于方法,就像适用于其他语言结构一样。因此,outer方法的参数对内部函数是可见的,不需要显式传递。我们可以再次使用这个规则重写我们的第一个例子,如下所示:
def averageNoPassing(in: Int*): Int = {
def sum: Int = in.sum
def count: Int = in.size
sum /count
}
对于引用包含块定义的函数,有一个特殊的名称,闭包。让我们更深入地讨论一下。
闭包
在我们的函数定义中,我们提到了两种不同类型的变量:作为参数提供的那些(绑定变量)和在其他地方定义的,即在包含块中定义的(自由变量)。自由变量之所以被称为自由变量,是因为函数本身对它没有任何意义。
不引用任何自由变量的函数是自足的,编译器可以在任何上下文中将其翻译成字节码。另一种表述方式是说,这个定义是自封闭的。因此,它被称为封闭项。另一方面,引用自由变量的函数只能在所有这些变量都已定义的上下文中编译。因此,它被称为开放项,并且在编译时封闭这些自由变量,因此得名闭包(关于自由变量)。
与变量和其他定义一样,闭包的常规作用域解析规则同样适用,如下一个片段所示:
scala> def outerA = {
| val free = 5
| def innerA = {
| val free = 20
| def closure(in: Int) = free + in
| closure(10)
| }
| innerA + free
| }
outerA: Int
scala> outerA
res3: Int = 35
res3的计算方式为outerA.free (5) + innerA.free (20) + closure.in(10)。
自由变量必须在闭包之前定义,否则编译器会报错:
scala> def noReference(in: Int) = {
| def closure(input: Int) = input + free + in
| }
def closure(input: Int) = input + free + in
^
On line 2: error: not found: value free
scala> def forwardReference(in: Int) = {
| def closure(input: Int) = input + free + in
| val free = 30
| }
def closure(input: Int) = input + free + in
^
On line 2: error: forward reference extends over definition of value free
第一次尝试失败是因为我们忘记定义一个自由变量。第二次尝试仍然不成功,因为自由变量是在闭包之后定义的。
部分应用和函数
到目前为止,编译器以相同的方式处理方法和变量。我们能否进一步利用这些相似性,将一个方法作为另一个方法的结果返回,并将其存储到变量中?让我们试一试:
scala> object Functions {
| def method(name: String) = {
| def function(in1: Int, in2: String): String = name + in2
| function
| }
| val function = method("name")
| }
function
^
On line 4: error: missing argument list for method function
Unapplied methods are only converted to functions when a function type is expected.
You can make this conversion explicit by writing `function _` or `function(_,_)` instead of `function`.
不幸的是,它不起作用。我们试图在方法内部创建并返回一个函数,并将这个函数赋值给一个变量,但编译器不允许这样做。然而,它给了我们一个关于我们做错了什么的有用提示!
结果表明,对于编译器来说,函数和方法是不同的,方法只能以封装类的实例形式传递。这种区别与 JVM 中所有内容都表示为某个类的实例的事实有关。正因为如此,我们定义的方法成为类的成员方法,方法在 JVM 中不是一等公民。Scala 通过具有不同 arity 的函数的类层次结构来绕过这种方法。因此,为了使我们能够从一个方法中返回一个方法,前者必须成为一个函数。编译器也给了我们一个提示,如何实现这一点:通过在期望参数的地方使用_(下划线)。
scala> object Functions {
| def method(name: String) = {
| def function(in1: Int, in2: String): String = name + in2
| function _
| }
| val function = method("name")
| }
defined object Functions
部分应用可以有两种形式:用一个下划线替换整个参数列表,或者用一个下划线替换每个参数。因此,对于我们刚刚定义的函数的部分应用,function _或function(_,_)都是合适的。
可以使用部分应用语法来为在其他地方定义的函数创建快捷方式,通过同时导入和部分应用它们:
val next = Math.nextAfter _
next(10f, 20f)
val /\ = Math.hypot(_, _)
/\ (10 , 20)
通常,对于具有 N 个参数的函数,部分应用意味着指定 0 <= M < N 个参数,其余参数未定义,基本上是将函数应用于参数列表的一部分。这种部分应用产生一个具有(N-M)个参数的函数,其结果类型与原始函数相同。在我们的上一个例子中,我们将 M 定义为零,因此结果函数的签名保持不变。但部分应用的事实已经将我们的方法转换成了函数,这使我们能够像处理值一样进一步处理它。
在这种情况下,如果 0 < M < N,下划线将填充当前未应用的参数位置:
def four(one: String, two: Int, three: Boolean, four: Long) = ()
val applyTwo = four("one", _: Int, true, _: Long)
我们应用了第一个和第三个参数,并留下了第二个和第四个未应用。编译器要求我们为缺失的参数提供类型注解,以便在使用结果函数时进行类型推断。
为方法定义的参数名称在部分应用期间丢失,默认值也是如此。重复的参数被转换为Seq。
函数字面量
我们可以使用 REPL 来检查applyTwo函数的类型:
scala> :type Functions.applyTwo
(Int, Long) => Unit
这就是一等函数的类型!一般来说,函数类型由=>分隔的左右两部分组成。左侧定义了参数的类型,右侧定义了结果类型。实现遵循相同的模式,被称为函数字面量。以下是一个具有四个参数的函数的完整定义示例:
val hash: (Int, Boolean, String, Long) => Int = (a, b, c, d) => {
val ab = 31 * a.hashCode() + b.hashCode()
val abc = 31 * ab + c.hashCode
31 * abc + d.hashCode()
}
在实现方面,我们有一个由三个表达式组成的代码块,因此被括号包围。请注意,我们定义我们的函数为val。
通常,函数字面量可以使用简化的语法定义。例如,类型推断允许省略结果类型的定义。在这种情况下,类型定义完全消失,因为参数的类型定义将像方法定义一样靠近参数名称:
val hashInferred = (a: Int, b: Boolean, c: String, d: Long) =>
// ... same implementation as before
在应用方面,编译器可以帮助我们进一步简化定义。让我们考虑一个例子:
def printHash(hasher: String => Int)(s: String): Unit =
println(hasher(s))
对于hasher函数,我们可以有以下等价的定义。完整的定义如下代码块所示:
val hasher1: String => Int = s => s.hashCode
val hasher2 = (s: String) => s.hashCode
printHash(hasher1)("Full")
printHash(hasher2)("Inferred result type")
这个片段展示了四种表示函数字面量的不同方式:
-
内联定义:
printHash((s: String) => s.hashCode)("inline") -
使用函数参数的类型推断内联定义:
printHash((s: String) => s.hashCode)("inline") -
使用函数参数的类型推断内联定义(这被称为目标类型):
printHash((s) => s.hashCode)("inline") -
单个参数周围的括号可以省略:
printHash(s => s.hashCode)("single argument parentheses") -
在这种情况下,如果函数的实现中使用了参数,我们最多只能进一步使用占位符语法:
printHash(_.hashCode)("placeholder syntax")
实际上,占位符语法非常强大,也可以用来定义具有多个参数的函数以及不在目标类型位置上的函数。以下是一个使用占位符语法计算四个 Int 实例哈希码的函数示例:
scala> val hashPlaceholder =
(_: Int) * 31⁴ + (_: Int) * 31³ + (_: Int) * 31² + (_: Int) * 31
scala> :type hashPlaceholder
(Int, Int, Int, Int) => Int
这种语法看起来接近部分应用语法,但代表了一个完全不同的语言特性。
Currying
说到部分应用,我们还没有提到这个特殊案例,Currying。在某种意义上,Currying 是一种部分应用,我们取一个 N 个参数的函数,并对每个参数逐个应用部分应用,每次产生一个接受一个更少参数的函数。我们重复这个过程,直到我们剩下 N 个函数,每个函数接受一个参数。如果听起来很复杂,考虑下一个两个参数的函数示例:
def sum(a: Int, b: Int) = a + b
使用两个参数列表,我们可以将其重写如下:
def sumAB(a: Int)(b: Int) = a + b
这个方法类型是 (a: Int)(b: Int): Int,或者表示为一个函数:
:type sumAB _
Int => (Int => Int)
这是一个接受一个 Int 并返回一个从 Int 到 Int 的函数的函数!当然,参数的数量并不限于仅仅两个:
scala> val sum6 = (a: Int) => (b: Int) => (c: Int) => (d: Int) => (e: Int) => (f: Int) => a + b + c + d+ e + f
sum6: Int => (Int => (Int => (Int => (Int => (Int => Int)))))
占位符语法将给我们相同的功能,但以 未应用 Currying 的形式:
scala> val sum6Placeholder = (_: Int) + (_: Int) + (_: Int) + (_: Int) + (_: Int) + (_: Int)
sum6Placeholder: (Int, Int, Int, Int, Int, Int) => Int
与一些其他函数式编程语言相比,Currying 在 Scala 中并不是很重要,但了解作为一个有用的函数式编程概念是好的。
多态性和高阶函数
到目前为止,我们只玩过操作单一类型数据的函数(单态函数)。现在,我们终于将我们的类型系统知识应用到构建适用于多种类型的函数上。接受类型参数的函数被称为 多态函数,类似于在面向对象类层次结构中实现的多态方法(子类型多态)。对于 Scala 中的函数,这被称为 参数多态。
多态函数
在上一章中我们玩 Glass 示例时,我们已经使用了多态函数:
sealed trait Glass[+Contents]
case class FullContents extends Glass[Contents]
case object EmptyGlass extends Glass[Nothing]
case class Water(purity: Int)
def drink(glass: Glass[Water]): Unit = ???
scala> :type drink _
Glass[Water] => Unit
drink 方法是单态的,因此只能应用于类型 Glass[Water] 的参数,甚至不能用于 EmptyGlass。当然,我们不想为每种可能的内容类型实现一个单独的方法。相反,我们以多态的方式实现我们的函数:
def drinkAndRefillC: Glass[C] = glass
drinkAndRefill: CGlass[C]
scala> :type drinkAndRefill _
Glass[Nothing] => Glass[Nothing]
scala> :type drinkAndRefill[Water] _
Glass[Water] => Glass[Water]
类型参数在方法体中可用。在这种情况下,我们指定结果应具有与参数相同的内容类型。
当然,我们可以进一步约束类型参数,就像我们之前做的那样:
def drinkAndRefillWaterB >: Water, C >: B: Glass[C] = glass
scala> :type drinkAndRefillWater[Water, Water] _
Glass[Water] => Glass[Water]
在这里,我们的方法接受任何玻璃,只要它是装水的玻璃,并允许填充比水更具体的东西。
这两个例子也表明,我们可以在部分应用期间指定一个类型参数,以便有一个特定类型的单态函数。否则,编译器将以与我们在使用函数字面量定义多态函数时相同的方式应用底类型参数:
scala> def drinkFun[B] = (glass: Glass[B]) => glass
drinkFun: [B]=> Glass[B] => Glass[B]
scala> :type drinkFun
Glass[Nothing] => Glass[Nothing]
scala> drinkFun(Full(Water))
res17: Glass[Water.type] = Full(Water)
在函数应用的那一刻,推断出的结果类型是正确的。
高阶函数
到目前为止,我们讨论了函数字面量,并创建了一个printHash函数,我们用它来演示将函数传递给方法的不同的形式:
scala> def printHash(hasher: String => Int)(s: String): Unit =
println(hasher(s))
printHash: (hasher: String => Int)(s: String)Unit
printHash接受两个参数:hasher函数和要哈希的字符串。或者,以函数形式:
scala> :type printHash _
(String => Int) => (String => Unit)
我们的函数是curried的,因为它接受一个参数(一个String => Int函数)并返回另一个函数,String => Unit。printHash接受一个函数作为参数的事实反映在说printHash是一个高阶函数(HOF)。除了一个或多个参数是函数这一事实外,HOFs 没有其他特殊之处。它们的工作方式与普通函数一样,可以被分配和传递,部分应用,并且是多态的:
def printHashA(s: A): Unit = println(hasher(s))
事实上,HOFs 通常以创造性的方式将作为参数给出的函数应用于另一个参数,因此几乎总是多态的。
让我们再次看看我们的printHash示例。没有特别之处要求hasher函数来计算哈希;hasher执行的函数与printHash的逻辑无关。有趣的是,这种情况比人们预期的更常见,这导致了 HOF 的定义,例如:
def printerA, B, C <: A(a: C): Unit = println(f(a))
我们的打印逻辑不需要给定的函数具有任何特定的参数或结果类型。我们唯一需要强制执行的限制是,可以使用给定的参数调用该函数,我们用类型约束C <: A来表述这一点。函数和参数的性质也可以是任何东西,因此定义 HOF 时通常使用简短的、中性的名称。这就是我们的新定义如何在实践中使用:
scala> printer((_: String).hashCode)("HaHa")
2240498
scala> printer((_: Int) / 2)(42)
21
编译器需要知道函数的类型,因此我们需要将其定义为占位符语法的一部分。我们可以通过改变函数参数的顺序来帮助编译器:
def printerA, B, C <: A(f: A => B): Unit = println(f(a))
使用这个定义,将首先推断出C的类型,然后使用推断出的类型来强制f的类型:
scala> printer("HoHo")(_.length)
4
scala> printer(42)(identity)
42
identity函数在标准库中定义为def identityA: A = x。
递归和跳跃
函数调用另一个函数有一个特殊情况——函数调用自身。这样的函数被称为递归。递归函数可以是头递归或尾递归。还有一种以面向对象的方式模拟递归调用的方法,称为跳跃。递归非常方便,并且在函数式编程中经常使用这种技术,所以让我们仔细看看这些概念。
递归
递归用于实现循环逻辑,而不依赖于循环及其相关的内部状态。递归行为由两个属性定义:
-
基本案例:最简单的终止情况,其中不再需要任何递归调用
-
递归案例:描述如何将任何其他状态减少到基本案例的规则集
递归实现的可能示例之一是反转一个字符串。这两个递归属性将是:
-
基本情况是空字符串或单字符字符串。在这种情况下,反转后的字符串就是给定的相同字符串。
-
对于长度为 N 的字符串的递归案例,可以通过取字符串的第一个字符并将其附加到给定字符串的反转尾部来减少到长度为 N-1 的字符串的案例。
这就是我们在 Scala 中实现这种逻辑的方式:
def reverse(s: String): String = {
if (s.length < 2) s
else reverse(s.tail) + s.head
}
所以,这比解释起来容易,对吧?
在递归案例中,我们实现的一个重要方面是它首先执行递归调用,然后将剩余的字符串添加到结果中。这类函数在计算中保持递归调用在头部,因此是头递归(但通常只是称为递归)的。这类似于深度优先算法,实现首先深入到终端情况,然后从底部向上构建结果:
scala> println(reverse("Recursive function call"))
llac noitcnuf evisruceR
嵌套函数调用在运行时自然保持在栈中。正因为如此,对于较小输入量工作良好的函数可能会因为较大输入量而耗尽栈空间,导致整个应用程序崩溃:
scala> println(reverse("ABC" * 100000))
java.lang.StackOverflowError
at scala.collection.StringOps$.slice$extension(StringOps.scala:548)
at scala.collection.StringOps$.tail$extension(StringOps.scala:1026)
at ch03.Recursion$.reverse(Recursion.scala:7)
at ch03.Recursion$.reverse(Recursion.scala:7)
at ch03.Recursion$.reverse(Recursion.scala:7)
...
有可能增加 JVM 中为栈保留的内存大小,但通常有更好的解决方案——尾递归。
尾递归
在尾递归函数中,递归调用是作为最后一个活动完成的。正因为如此,才有可能“完成”所有对调用的“准备”,然后只需用新的参数“跳转”回函数的开始部分。Scala 编译器将尾递归调用重写为循环,因此这种递归调用根本不会消耗栈空间。通常,为了使递归函数成为尾递归,需要引入某种状态或某种局部辅助函数。
让我们以尾递归的方式重写我们的reverse函数:
def tailRecReverse(s: String): String = {
def reverse(s: String, acc: String): String =
if (s.length < 2) s + acc
else reverse(s.tail, s.head + acc)
reverse(s, "")
}
在这个实现中,我们定义了一个局部尾递归函数,reverse,它遮蔽了参数s,这样我们就不会无意中引用它,并且还引入了一个acc参数,这是为了携带字符串的剩余部分。现在,在将字符串的头部和acc粘合在一起之后调用reverse。为了返回结果,我们使用原始参数和一个空的累加器调用辅助函数。
这种实现不消耗栈空间,我们可以通过在基本案例中抛出异常并检查堆栈跟踪来检查:
scala> println(inspectReverse("Recursive function call"))
java.lang.Exception
at $line19.$read$$iw$$iw$.reverse$1(<console>:3)
at $line19.$read$$iw$$iw$.inspectReverse(<console>:5)
在我们完成字符串反转的时候,我们仍然只有一个递归调用在栈中。有时这会让开发者感到困惑,因为它看起来好像递归调用不会被执行。在这种情况下,可以通过使用 notailcalls 编译器选项来禁用尾调用优化。
有时情况相反,一个(可能)尾递归调用在运行时因为开发者忽略了头位置的递归调用而溢出栈。为了消除这种错误的可能性,有一个特殊的尾递归调用注释,@scala.annotation.tailrec:
def inspectReverse(s: String): String = {
@scala.annotation.tailrec
def reverse(s: String, acc: String): String = ...
}
编译器将无法编译带有此注释的头递归函数:
scala> @scala.annotation.tailrec
| def reverse(s: String): String = {
| if (s.length < 2) s
| else reverse(s.tail) + s.head
| }
else reverse(s.tail) + s.head
^
On line 4: error: could not optimize @tailrec annotated method reverse: it contains a recursive call not in tail position
看起来,如果我们正确注释了尾递归函数,我们就在安全的一边?嗯,不是 100%,因为也存在一些函数根本无法变成尾递归的可能性。
当尾递归无法实现时的一个例子是互递归。如果第一个函数调用第二个,而第二个函数又调用第一个,那么这两个函数就是互递归的。
在数学中,Hofstadter 序列是一系列相关整数序列的成员,这些序列由非线性递归关系定义。你可以在维基百科上了解更多信息,请参阅en.wikipedia.org/wiki/Hofstadter_sequence#Hofstadter_Female_and_Male_sequences。
这些函数的一个例子是 Hofstadter 女性和男性序列,定义为如下:
def F(n:Int): Int = if (n == 0) 1 else n - M(F(n-1))
def M(n:Int): Int = if (n == 0) 0 else n - F(M(n-1))
非尾递归函数的另一个例子是 Ackerman 函数(更多关于它可以在en.wikipedia.org/wiki/Ackermann_function上找到)的定义如下:
val A: (Long, Long) => Long = (m, n) =>
if (m == 0) n + 1
else if (n == 0) A(m - 1, 1)
else A(m - 1, A(m, n - 1))
它很简单,但不是原始递归,它对栈的需求很大,即使 m 和 n 的值适中也会溢出栈:
scala> A(4,2)
java.lang.StackOverflowError
at .A(<console>:4)
at .A(<console>:4)
...
有一种称为跳跃式递归的特殊技术,可以在 JVM 上实现非尾递归函数。
跳跃式递归
从本质上讲,跳跃式递归是替换递归函数调用为表示这些调用的对象。这样,递归计算就在堆上而不是栈上构建,并且由于堆的大小更大,因此可以表示更深的递归调用。
Scala 的 util.control.TailCalls 实现为跳跃式递归调用提供了一个现成的抽象。记住,我们在递归中有两个一般情况,它们分解为三个具体的情况?这些是:
-
基本情况
-
递归情况,可以是:
-
头递归
-
尾递归
-
表示通过遵循三个受保护的案例类来反映它们:
case class DoneA extends TailRec[A]
case class CallA => TailRec[A]) extends TailRec[A]
case class ContA, B extends TailRec[B]
由于这些是受保护的,我们不能直接使用它们,而是预期使用特殊的辅助方法。让我们通过重新实现我们的 Ackerman 函数来看看它们:
import util.control.TailCalls._
def tailA(m: BigInt, n: BigInt): TailRec[BigInt] = {
if (m == 0) done(n + 1)
else if (n == 0) tailcall(tailA(m - 1, 1))
else tailcall(tailA(m, n - 1)).flatMap(tailA(m - 1, _))
}
def A(m: Int, n: Int): BigInt = tailA(m, n).result
我们将递归调用封装到 tailcall 方法中,该方法创建一个 Call 实例。递归调用比基本情况要复杂一些,因为我们首先需要递归地封装内部调用,然后使用 TailRec 提供的 flatMap 方法将结果传递给外部的递归调用。
A 只是一个辅助方法,用于将计算结果从 TailRec 中解构出来。我们使用 BigInt 来表示结果,因为现在,由于实现是栈安全的,它可以返回相当大的数字:
scala> Trampolined.A(4,2).toString.length
现在,我们已经看到了如何将递归函数表示为对象,是时候揭示关于 Scala 函数的另一个真相了。
函数的面向对象方面
我们提到 Scala 是面向对象和函数式范式的融合。正因为如此,Scala 将函数作为语言的第一级元素。也因为如此,Scala 中的一切都是对象。这部分与 JVM 中一切都是对象或原始类型的事实有关,但 Scala 更进一步,还隐藏了原始类型在对象后面。
结果表明,函数也是对象!根据参数的数量,它们扩展了特殊特质之一。也因为它们的面向对象性质,可以通过在实现类上定义额外的方 法来实现额外的功能。这就是部分函数的实现方式。利用伴生对象定义函数的公共逻辑以方便重用也是自然的。甚至可以编写一些函数的定制实现,尽管这很少是一个好主意。
这些方面每一个都值得深入研究,但要真正理解它们,我们需要从一些实现细节开始。
函数是特质
Scala 中的每个函数都实现了 FunctionN 特质,其中 N 是函数的参数数量。零参数函数由编译器转换为 Function0 的实现,一个参数转换为 Function1,以此类推,直到 Function22。这种复杂性是由于语言的静态性质所必需的。这意味着不能定义超过 22 个参数的函数吗?嗯,总是可以通过柯里化或多个参数列表来定义函数,所以这并不是一个真正的限制。
函数字面量只是编译器为了开发者的方便而接受的语法糖。这就是我们之前定义的 Ackerman 函数去糖后的签名看起来像这样:
val A: Function2[Long, Long, Long] = (m, n) =>
if (m == 0) n + 1
else if (n == 0) A.apply(m - 1, 1)
else A.apply(m - 1, A.apply(m, n - 1))
标准库中 Function2 的(简化)定义如下:
trait Function2[-T1, -T2, +R] extends AnyRef { self =>
def apply(v1: T1, v2: T2): R
...
}
记得我们在上一章中讨论的共变和逆变讨论吗?这里就是它的实际应用;参数是逆变的,结果类型是共变的。
结果表明,编译器将我们的定义重写为实现了此特质的匿名类的实例:
val objectOrientedA: Function2[Long, Long, Long] =
new Function2[Long, Long, Long] {
def apply(m: Long, n: Long): Long =
if (m == 0) n + 1
else if (n == 0) objectOrientedA(m - 1, 1)
else objectOrientedA(m - 1, objectOrientedA(m, n - 1))
}
然后,这个类的实例可以被传递、赋值给变量、存储到数据结构中等。FunctionN特质还定义了一些辅助方法,这些方法在库级别实现与函数相关的功能,而不是在语言语法中。一个例子是将普通函数转换为curried形式,对于Function2[T1,T2,R],定义为def curried: T1 => T2 => R = (x1: T1) => (x2: T2) => apply(x1, x2)
scala> objectOrientedA.curried
res9: Long => (Long => Long)
这个方法适用于任何函数。
局部函数
有额外方法的可能性提供了一种定义其他方式难以表述的概念的方法,至少在没有扩展语言本身的情况下是这样。一个这样的例子是局部函数。局部函数是一个对于其参数的一些值未定义的函数。经典的例子是除法在除数等于零时未定义。但实际上,可以存在任意的域规则,使得某些函数成为局部函数。例如,我们可以决定我们的字符串反转函数对于空字符串应该是未定义的。
在程序中实现此类约束有几个可能性:
-
对于函数未定义的参数抛出异常
-
限制参数的类型,使其只能传递有效的参数给函数,例如使用精炼类型
-
在返回类型中反映局部性,例如使用
Option或Either
与这些方法中的每一个都有关明显的权衡,在 Scala 中,第一个方法因其最自然而被首选。但是,为了更好地模拟函数的局部性质,标准库中有一个特殊的特质可用:
trait PartialFunction[-A, +B] extends (A => B)
与普通函数的关键区别是,有一个额外的方法可用,允许我们检查函数是否对某些参数有定义:
def isDefinedAt(x: A): Boolean
这允许函数的使用者对“无效”的输入值执行不同的操作。
例如,让我们假设我们发明了一种非常高效的方法来检查一个字符串是否是回文。然后我们可以将我们的反转函数定义为两个局部函数,一个只对回文有效且不执行任何操作,另一个只对非回文有效并执行实际的反转操作:
val doReverse: PartialFunction[String, String] = {
case str if !isPalindrome(str) => str.reverse
}
val noReverse: PartialFunction[String, String] = {
case str if isPalindrome(str) => str
}
def reverse = noReverse orElse doReverse
在这里,我们再次使用语法糖来定义我们的局部函数作为模式匹配,编译器为我们创建isDefinedAt方法。我们的两个局部函数通过orElse方法组合成总函数。
函数对象
局部函数的orElse方法和普通函数的curried方法只是标准库中预定义的与函数相关的方法的例子。
与为每个函数实例定义的curried方法类似(除了Function0和Function1),还有一个tupled方法,它将 N 个参数的函数转换为只有一个参数的函数,该参数是一个TupleN。
此外,还有一个伴随对象,scala.Function,它包含了一些对高阶函数式编程有用的方法,最显著的是一个const函数,它总是返回其参数,还有一个chain函数,它将一系列函数组合成一个单一函数,如下面的示例所示:
val upper = (_: String).toUpperCase
def fill(c: Char) = c.toString * (_: String).length
def filter(c: Char) = (_: String).filter(_ == c)
val chain = List(upper, filter('L'), fill('*'))
val allAtOnce = Function.chain(chain)
scala> allAtOnce("List(upper, filter('a'), fill('C'))")
res11: String = ****
allAtOnce是一个函数,它类似于通过andThen(在FunctionN特质中定义)组合我们的三个原始函数可以构建的函数:
val static = upper andThen filter('a') andThen fill('C')
但是allAtOnce是以动态方式构建的。
扩展函数
开发者不能像处理PartialFunction那样扩展FunctionN特质,尽管由于引用透明性约束带来的限制,这很少有意义。这意味着这种函数的实现不应该有共享状态,也不应该改变状态。
例如,可能会诱使人们将贷款模式实现为一个函数,这样在函数应用之后,使用的资源就会被自动关闭,但这不会是引用透明的,因此不会满足函数的要求。
下面是可能的实现方式:
class Loan-T <: AutoCloseable, +R extends (T => R) {
override def apply(t: T): R = try app(t) finally t.close()
}
这就是如果我们这样称呼它时会发生的情况:
scala> new Loan((_: java.io.BufferedReader).readLine())(Console.in)
res13: String = Hello
scala> [error] (run-main-0) java.io.IOException: Stream Closed
[error] java.io.IOException: Stream Closed
[error] at java.io.FileInputStream.read0(Native Method)
[error] at java.io.FileInputStream.read(FileInputStream.java:207)
[error] at jline.internal.NonBlockingInputStream.read(NonBlockingInputStream.java:245)
...
不幸的是,甚至无法测试第二次调用是否会产生相同的结果(显然不会),因为我们通过关闭Console.in破坏了 REPL。
摘要
函数代表了 Scala 中面向对象和函数式特征融合的另一方面。它们可以通过多种方式定义,包括方法的偏应用、函数字面量和偏函数。函数可以在任何作用域中定义。如果一个函数封闭了作用域中可用的变量,它就被称为闭包。
多态函数实现了一个类似于面向对象中多态的概念,但应用于参数和结果类型。这被称为参数多态。当定义接受其他函数作为参数的函数时,这种思想特别有用,所谓的更高阶函数。
实现递归有两种方式,只有尾递归函数在 JVM 中是栈安全的。对于不能转换为尾递归的函数,有一种方法可以通过将调用链编码为对象来在堆中表示它。这种方法称为跳跃(trampolining),并且它在标准库中得到支持。
函数在 Scala 中是一等值,因为它们作为扩展FunctionN特质的匿名类来实现。这不仅使得像处理普通变量一样处理函数成为可能,而且还允许提供具有附加属性的扩展函数实现,例如,一个PartialFunction。
问题
-
以下函数在
curried形式中将会是什么类型:(Int, String) => (Long, Boolean, Int) => String? -
描述部分应用函数和偏函数之间的区别
-
定义一个签名并实现一个用于三个参数的
curried函数的uncurry函数A => B => C => R -
实现一个头递归函数用于阶乘计算 n! = n * (n-1) * (n-2) * ... * 1
-
实现一个尾递归函数用于阶乘计算
-
使用跳跃技术实现一个用于阶乘计算的递归函数
进一步阅读
Mads Hartmann 和 Ruslan Shevchenko,《专业 Scala>:在一个让你为 JVM、浏览器等构建的环境下,编写简洁且易于表达的类型安全代码。
Vikash Sharma,《Scala 编程学习>:学习如何在 Scala 语言中编写可扩展和并发的程序,这是一种与你一起成长的编程语言。
第四章:了解隐式和类型类
我们已经熟悉 Scala 的两个基石——其类型系统和一等函数。隐式是第三个。隐式使得优雅的设计成为可能,而且没有隐式,可能没有哪个 Scala 库能达到当前的技术水平。
在本章中,我们将从不同类型的隐式的系统概述开始,并回顾隐式作用域解析规则。在简要查看上下文边界后,我们将继续讨论类型类,这是现代函数式编程库中使用的核心实现机制。
本章将涵盖以下主题:
-
隐式类型的种类
-
上下文边界
-
类型类
-
类型类和递归解析
-
类型类变异性
-
隐式作用域解析规则
技术要求
在我们开始之前,请确保您已安装以下内容:
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter04。
隐式类型的种类
在 Scala 中,关键字 implicit 后面隐藏着几种不同的机制。这个列表包括隐式参数、隐式转换和隐式类。它们具有略微不同的语义,了解在哪些情况下哪种最适合非常重要。这三种类型每一种都值得简要概述。
隐式转换
我们列表中的第一种隐式类型是隐式转换。它们允许您自动将一种类型的值转换为另一种类型的值。这种隐式转换被定义为标记有 implicit 关键字的单参数方法。隐式转换被认为是一种有争议的语言特性(我们稍后会探讨原因),因此我们需要通过编译器标志或导入相应的语言特性来显式启用它们:
import scala.language.implicitConversions
Predef 包含了针对 Java 特定类和原语的一组隐式转换。例如,这是 Scala 的 Int 和 Java 的 Integer 中 自动装箱 和 自动拆箱 的定义方式:
// part of Predef in Scala
implicit def int2Integer(x: Int): java.lang.Integer = x.asInstanceOf[java.lang.Integer]
implicit def Integer2int(x: java.lang.Integer): Int = x.asInstanceOf[Int]
这两种方法在编译器期望 Int 类型的值,但提供了 java.lang.Integer 类型的值(反之亦然)的情况下被使用。假设我们有一个返回随机 Integer 的 Java 方法,我们将在以下场景中应用隐式转换:
val integer: Integer = RandomInt.randomInt()
val int: Int = math.abs(integer)
math.abs 期望 Int 类型,但提供了 Integer,因此编译器应用了隐式转换 Integer2int。
相同的原则也适用于返回类型,就像它们适用于参数一样。如果编译器在一个没有此方法的类型上找到方法调用,它将寻找隐式转换,以便原始返回类型可以转换为适合此方法的数据类型。这允许你实现一个名为扩展方法的模式。Scala 中的String类型是一个完美的例子。它被定义为 Java 的String类型的类型别名:
type String = java.lang.String
但是,你可以调用诸如map、flatMap、append、prepend以及许多其他未在原始String中定义的方法。这是通过每次调用此类方法时将String转换为StringOps来实现的:
@inline implicit def augmentString(x: String): StringOps = new StringOps(x)
scala> "I'm a string".flatMap(_.toString * 2) ++ ", look what I can do"
res1: String = II''mm aa ssttrriinngg, look what I can do
隐式转换可以是类型参数化的,但不能嵌套或直接链式调用。编译器一次只会应用一个隐式转换:
case class AT
case class BT
implicit def a2AT: A[T] = A(a)
implicit def a2BT: B[T] = B(a)
def abC: Unit = println(a)
由于隐式转换t2B在作用域内,编译器将接受带有A的调用,但会拒绝既不是A也不是B的所有内容:
scala> ab(A("A"))
B(A(A))
scala> ab("A")
^
error: type mismatch;
found : String("A")
required: B[A[?]]
有时,可以强制执行其中一个转换,以便编译器可以应用另一个。在这里,我们通过提供类型注解来告诉编译器应用从String到A[String]的转换。然后,从A到B[A]的转换就像之前一样发生:
scala> ab("A" : A[String])
B(A(A))
非常方便,不是吗?
那么,为什么隐式转换被认为是可疑的呢?因为有时它们可以在开发者不知道的情况下应用,并以意想不到的方式改变语义。在存在两种类型转换(如我们的 Int/Integer 示例)或涉及现有类型的情况下,这可能会特别糟糕。这个经典例子是基于作用域内存在一些隐式转换和后续的类型强制转换:
scala> implicit val directions: List[String] = List("North", "West", "South", "East")
directions: List[String] = List(north, west, south, east)
scala> implicit val grades: Map[Char, String] = Map('A' -> "90%", 'B' -> "80%", 'C' -> "70%", 'D' -> "60%", 'F' -> "0%")
grades: Map[Char,String] = ChampHashMap(F -> 0%, A -> 90%, B -> 80%, C -> 70%, D -> 60%)
scala> println("B" + 42: String)
B42
scala> println(("B" + 42): String)
B42
scala> println("B" + (42: String))
java.lang.IndexOutOfBoundsException: 42
at scala.collection.LinearSeqOps.apply(LinearSeq.scala:74)
at scala.collection.LinearSeqOps.apply$(LinearSeq.scala:71)
at scala.collection.immutable.List.apply(List.scala:72)
... 38 elided
scala> "B" + 'C'
res3: String = BC
scala> "B" + ('C': String)
res4: String = B70%
scala> "B" + (2: String)
res5: String = BSouth
在这里,我们可以看到这种行为的两个例子:一个是语义上相似的String加Int连接产生不同的结果,另一个是以相同的方式制作,但用于String和Char。
奇怪的结果和IndexOutOfBoundsException的原因是Map和List都实现了PartialFunction,因此只是Function1。在我们的例子中,对于List是Int => String,对于Map是Char => String。两者都被定义为隐式的,并且当需要其中一个类型转换时,相应的函数会被应用。
由于这种不可预测性,现代 Scala 中不建议使用隐式转换,尽管它们没有被从语言中移除或弃用,因为许多现有实现依赖于它们。它们主要用于向现有类添加方法或为新特质添加特质实现。
隐式参数
隐式参数使用与隐式转换相同的语法,但提供不同的功能。它们允许你自动将参数传递到函数中。隐式参数的定义是在函数定义中作为单独的参数列表完成的,前面有一个implicit关键字。只允许一个隐式参数列表:
case class AT
case class BT
def abC(a: A[C])(implicit b: B[C]): Unit =
println(s"$name$a$b")
隐式参数不需要任何特殊的导入或编译器选项来激活。前面的例子显示,它们也可以是类型参数化的。如果在调用方法时没有为隐式参数提供值,编译器将报告错误:
scala> ab("1")(A("A"))
^
error: could not find implicit value for parameter b: B[String]
这个错误可以通过提供所需的隐式值来修复:
scala> implicit val b = B("[Implicit]")
b: B[String] = B([Implicit])
scala> ab("1")(A("A"))
1A(A)B([Implicit])
如果作用域中有多个隐式值,编译器将返回错误:
scala> implicit val c = B("[Another Implicit]")
c: B[String] = B([Another Implicit])
scala> ab("1")(A("A"))
^
error: ambiguous implicit values:
both value b of type => B[String]
and value c of type => B[String]
match expected type B[String]
解决这个问题的方法是移除所有除一个之外的模糊隐式值,或者使其中一个值更具体。我们稍后会看看如何做到这一点。另一种方法是显式地提供隐式值:
scala> ab("1")(A("A"))(b)
1A(A)B([Implicit])
scala> ab("1")(A("A"))(c)
1A(A)B([Another Implicit])
隐式参数不需要是一个值——它可以定义为一种方法。拥有不纯的隐式方法可能会导致随机行为,尤其是在隐式参数类型相对通用的情况下:
scala> implicit def randomLong: Long = scala.util.Random.nextLong()
randomLong: Long
scala> def withTimestamp(s: String)(implicit time: Long): Unit = println(s"$time: $s")
withTimestamp: (s: String)(implicit time: Long)Unit
scala> withTimestamp("First")
-3416379805929107640: First
scala> withTimestamp("Second")
8464636473881709888: Second
由于这个原因,有一个普遍的规则,即隐式参数必须具有可能的具体类型。遵循这个规则还可以帮助你避免使用像以下这样的递归隐式参数来混淆编译器:
scala> implicit def recursiveLong(implicit seed: Long): Long = scala.util.Random.nextLong(seed)
recursiveLong: (implicit seed: Long)Long
scala> withTimestamp("Third")
^
error: diverging implicit expansion for type Long
starting with method recursiveLong
如果做得正确,隐式参数可以非常有用,可以为实现提供配置参数。通常,它是自顶向下进行的,并影响程序的各个层:
object Application {
case class Configuration(name: String)
implicit val cfg: Configuration = Configuration("test")
class Persistence(implicit cfg: Configuration) {
class Database(implicit cfg: Configuration) {
def query(id: Long)(implicit cfg: Configuration) = ???
def update(id: Long, name: String)(implicit cfg: Configuration) = ???
}
new Database().query(1L)
}
}
在这个例子中,配置在顶层定义一次,并自动传递到最低层的函数中。因此,函数的调用变得更加易读。
这些配置设置只是更通用用例的一个特例——上下文传递。与普通参数相比,上下文通常更稳定,这就是为什么隐式传递它是有意义的。这个经典的例子是ExecutionContext,这对于大多数Future方法都是必需的(我们将在第六章,探索内置效果)中详细探讨):
def filter(p: T => Boolean)(implicit executor: ExecutionContext): Future[T] = ...
执行上下文通常不会改变,与过滤逻辑相反,因此它是隐式传递的。
另一个用例是验证类型。我们已经在第二章,理解 Scala 中的类型中看到了一个例子,当时我们讨论了泛化类型约束。
隐式类
到目前为止,我们已经讨论了隐式转换和扩展方法模式。实现通常是这样的,即旧类型被包装在一个新类型的实例中,然后提供额外的功能。我们以StringOps为例,但让我们尝试自己实现这个模式。我们将有一个类型A,我们希望它能够执行某些操作b:
case class AT { def doA(): T = a }
A("I'm an A").doB() // does not compile
我们可以通过定义一个包含所需操作的类,并提供从A到B的隐式转换来修复编译错误:
case class BT { def doB(): T = b }
import scala.language.implicitConversions
implicit def a2bT: B[T] = B(a.a)
A("I'm an A").doB() // works
这种方法如此常见,以至于 Scala 为此提供了一个特殊的语法,称为隐式类。它将定义一个类和一个隐式转换合并为一个类的定义。扩展类型成为新类构造函数的参数,就像在前面的代码和以下示例中一样:
implicit class CT { def doC(): T = a.a }
A("I'm an A").doC()
这样更简洁,并且不需要scala.language.implicitConversions导入。
这种情况的原因在于,普通隐式转换和隐式类之间存在微妙但重要的区别。虽然隐式转换可以表示任何类型的改变,包括已经存在的和/或原始类型,但隐式类是一种在考虑类型转换的情况下创建的东西。它接受初始类型作为构造函数参数的事实使得它以这种方式参数化——从某种意义上说。总的来说,使用隐式类比使用隐式转换更安全。
视图和上下文边界
我们之前讨论过的隐式转换和隐式参数,它们无处不在,以至于有专门的编程语言语法来表示它们,即视图和上下文边界。自从 Scala 2.11 以来,视图边界已经被弃用,但我们相信了解它们将有助于你理解上下文边界,因此我们将讨论两者,尽管详细程度不同。
视图边界
视图边界是隐式参数的语法糖,它表示两种类型之间的转换。它允许你以略短的形式编写带有这种隐式参数的方法签名。我们可以通过开发一个方法来比较两种不相关的类型,如果两者都存在转换到第三个特定类型的话,来看到这两种方法之间的区别:
case class CanEqual(hash: Int)
def equalCA, CB(implicit ca: CA => CanEqual, cb: CB => CanEqual): Boolean = ca(a).hash == ca(a).hash
带有视图边界的版本(类似于我们在第二章,理解 Scala 中的类型)中讨论的上界和下界,有一个更简短的定义:
def equalsWithBoundsCA <% CanEqual, CB <% CanEqual: Boolean = {
val hashA = implicitly[CA => CanEqual].apply(a).hash
val hashB = implicitly[CB => CanEqual].apply(b).hash
hashA == hashB
}
我们在这里使用的隐式方法是helper方法,它在Predef中定义为以下内容:
@inline def implicitlyT = e
这允许我们召唤一个类型为T的隐式值。我们没有明确提供这个隐式值,因此我们需要通过在召唤的转换上使用apply方法来帮助编译器确定调用序列。
如果实现比原始版本更复杂,我们为什么要使用它呢?答案是——如果隐式参数只是传递给某个内部函数,它就会变得更好:
def equalsWithPassingCA <% CanEqual, CB <% CanEqual: Boolean = equal(a, b)
正如我们之前所说的,自从 Scala 2.11 以来,视图边界已经被弃用,所以我们不会进一步讨论。相反,我们将关注上下文边界。
上下文边界
在隐式参数以正常参数的类型进行参数化的另一个特殊情况下,我们之前的示例可以重写如下:
trait CanEqual[T] { def hash(t: T): Int }
def equalCA, CB(implicit ca: CanEqual[CA], cb: CanEqual[CB]): Boolean =
ca.hash(a) == cb.hash(b)
正如我们之前提到的,为此情况提供了一些语法糖,称为 上下文边界。有了上下文边界,我们的示例可以简化如下:
def equalBoundsCA: CanEqual, CB: CanEqual: Boolean = {
val hashA = implicitly[CanEqual[CA]].hash(a)
val hashB = implicitly[CanEqual[CB]].hash(b)
hashA == hashB
}
正如前一个例子,当隐式参数传递给内部函数时,这种语法变得简洁:
def equalDelegateCA: CanEqual, CB: CanEqual: Boolean = equal(a, b)
现在,这既简短又易于阅读!
缺失的是为不同的 CA 和 CB 实现隐式参数。对于 String 类型,可能实现如下:
implicit val stringEqual: CanEqual[String] = new CanEqual[String] {
def hash(in: String): Int = in.hashCode()
}
Int 的实现以非常相似的方式进行。使用单抽象方法语法,我们可以用函数替换类定义:
implicit val intEqual: CanEqual[Int] = (in: Int) => in
我们可以通过使用柯里化的恒等函数来用更短的代码实现这一点:
implicit val intEqual: CanEqual[Int] = identity _
现在,我们可以使用我们的隐式值来调用具有上下文边界的函数:
scala> equal(10, 20)
res5: Boolean = false
scala> equalBounds("10", "20")
res6: Boolean = false
scala> equalDelegate(10, "20")
res7: Boolean = false
scala> equalDelegate(1598, "20")
res8: Boolean = true
在前面的代码片段中,编译器为不同类型的参数解析不同的隐式参数,这些隐式参数用于比较函数的参数。
类型类
前面的示例表明,为了使上下文边界工作,我们需要三个部分:
-
被定义为将要调用的函数的隐式参数的参数化类型
T -
在
T上定义的一个或多个操作(方法),在转换后可用 -
实现
T的隐式实例
如果方法定义中引用的类型是抽象的,并且该方法在实例中以不同的方式实现,那么我们谈论的是 特殊参数多态(与函数的参数多态和子类多态相对)。在这里,我们将探讨如何使用类型类实现这个概念,如果需要,编译器如何找到合适的实例,以及如何在特殊参数多态的情况下应用变异性。
类型类
针对非面向对象的语言,特别是那些不能有子类型多态性的语言,如 Haskell,这种特殊的参数多态非常有用。我们讨论的模式在 Haskell 中被称为 类型类,这个名字也传到了 Scala 中。类型类在 stdlib 和开源库中广泛使用,并且对于 Scala 的函数式编程至关重要。
对于面向对象开发者来说,由于类这个概念,类型类这个名字听起来非常熟悉。不幸的是,它与面向对象的类没有关系,反而让人困惑。为了重新调整我的大脑以适应这个模式,我帮助自己将类型类视为类型的一个类。
让我们将其与传统面向对象方法以及用于定义一组 USB 电缆的类型类进行比较。在面向对象中,我们会得到以下定义:
trait Cable {
def connect(): Boolean
}
case class Usb(orientation: Boolean) extends Cable {
override def connect(): Boolean = orientation
}
case class Lightning(length: Int) extends Cable {
override def connect(): Boolean = length > 100
}
case class UsbC(kind: String) extends Cable {
override def connect(): Boolean = kind.contains("USB 3.1")
}
def connectCable(c: Cable): Boolean = c.connect()
每个子类通过重写基特质的 connect 方法来实现 connect 方法。connectCable 只是将调用委托给实例,并通过动态分派调用适当的实现:
scala> connectCable(Usb(false))
res9: Boolean = false
scala> connectCable(Lightning(150))
res10: Boolean = true
类型类版本看起来略有不同。类不再需要扩展 Cable(因此可以成为不同类层次结构的一部分)。我们还为了好玩,将 UsbC 类型泛型化:
case class Usb(orientation: Boolean)
case class Lightning(length: Int)
case class UsbCKind
连接逻辑已经移动到了由电缆类型参数化的类型类中:
trait Cable[C] {
def connect(c: C): Boolean
}
它是在相应的类型类实例中实现的:
implicit val UsbCable: Cable[Usb] = new Cable[Usb] {
override def connect(c: Usb): Boolean = c.orientation
}
或者使用相同的方法,使用单个抽象方法语法:
implicit val LightningCable: Cable[Lightning] = (_: Lightning).length > 100
我们不能为最近参数化的 UsbC 定义一个隐式实例,因为我们不能为任何类型参数提供一个通用实现。UsbC[String] 的实例(与面向对象版本相同)可以通过以下方式轻松实现:
implicit val UsbCCableString: Cable[UsbC[String]] =
(_: UsbC[String]).kind.contains("USB 3.1")
connectCable 是通过上下文绑定实现的,并使用临时多态来选择合适的委托方法:
def connectCableC : Cable: Boolean = implicitly[Cable[C]].connect(c)
这个方法可以像调用它的面向对象兄弟一样调用:
scala> connectCable(Usb(false))
res11: Boolean = false
scala> connectCable(Lightning(150))
res12: Boolean = true
scala> connectCable(UsbC("USB 3.1"))
res13: Boolean = true
在调用端,语法相同,但实现不同。它是完全解耦的——我们的案例类对连接逻辑一无所知。实际上,我们可以在另一个封闭源代码库中为定义的类实现这个逻辑!
类型类递归解析
在我们之前的例子中,我们没有为参数化的 UsbC 类型实现连接功能,我们的解决方案仅限于 UsbC[String]。
我们可以通过进一步委托连接逻辑来改进我们的解决方案。比如说,我们有一个隐式函数 T => Boolean 可用——我们可以说这是用户想要用来描述连接方法的逻辑。
这是一个不良使用隐式的例子。这不仅包括原始的 Boolean 类型;在定义隐式转换的时刻,它很可能引用另一个预定义的类型。我们提供这个例子正是如它所提及的那样——作为一个避免不良设计的示例!
这就是我们的委托方法可能的样子:
implicit def usbCCableDelegateT: Cable[UsbC[T]] = (c: UsbC[T]) => conn(c.kind)
它直接反映了我们对委托函数的直觉——如果存在 T => Boolean 的隐式转换,编译器将创建一个 Cable[UsbC[T]] 的实例。
这就是它的用法:
implicit val symbolConnect: Symbol => Boolean =
(_: Symbol).name.toLowerCase.contains("cable")
scala> connectCable(UsbC('NonameCable))
res18: Boolean = true
scala> connectCable(UsbC('FakeKable))
res19: Boolean = false
但然后,我们必须处理我们委托的隐式转换的所有危险。例如,存在以下无关的转换:
implicit val isEven: Int => Boolean = i => i % 2 == 0
implicit val hexChar: Char => Boolean = c => c >= 'A' && c <='F'
将突然允许我们以意想不到的方式连接电缆:
scala> connectCable(UsbC(10))
res23: Boolean = true
scala> connectCable(UsbC(11))
res24: Boolean = false
scala> connectCable(UsbC('D'))
res25: Boolean = true
这可能看起来像是一种危险的方法,即依赖于另一个隐式定义的存在来产生所需的隐式值,但这正是类型类获得其力量的原因。
为了演示这一点,让我们想象我们想要实现一个 USB 适配器,该适配器应该连接具有不同标准的两个 USB 设备。我们可以通过将适配器表示为连接电缆的两个电缆端来轻松实现这一点,并将实际连接委托给电缆的相应端:
implicit def adaptA, B: Cable[(A, B)] = new Cable[(A, B)] {
def connect(ab: (A, B)): Boolean =
ev1.connect(ab._1) && ev2.connect(ab._2)
}
或者,我们可以使用上下文界限和 SAM 语法:
implicit def adapt[A: Cable, B: Cable]: Cable[(A, B)] =
(ab: (A, B)) =>
implicitly[Cable[A]].connect(ab._1) &&
implicitly[Cable[B]].connect(ab._2)
现在,我们可以使用这个隐式定义来调用我们现有的connectCable方法,但带有适配器逻辑:
scala> val usb2usbC = (Usb(false), UsbC('NonameCable))
usb2usbC: (Usb, UsbC[Symbol]) = (Usb(false),UsbC('NonameCable))
scala> connectCable(usb2usbC)
res33: Boolean = false
scala> val lightning2usbC = (Lightning(150), UsbC('NonameCable))
lightning2usbC: (Lightning, UsbC[Symbol]) = (Lightning(150),UsbC('NonameCable))
scala> connectCable(lightning2usbC)
res34: Boolean = true
非常令人印象深刻,不是吗?想象一下,要为 OO 版本添加这个功能需要多少努力!
乐趣还没有结束!由于上下文界限解析的递归性质,我们现在可以构建任意长度的链,编译器将递归地检查在编译时是否可以构建所需的适配器:
scala> val usbC2usb2lightning2usbC = ((UsbC('NonameCable), Usb(false)), (Lightning(150), UsbC("USB 3.1")))
usbC2usb2lightning2usbC: ((UsbC[Symbol], Usb), (Lightning, UsbC[String])) = ((UsbC('NonameCable),Usb(false)),(Lightning(150),UsbC(USB 3.1)))
scala> connectCable(usbC2usb2lightning2usbC)
res35: Boolean = false
scala> val noUsbC_Long_Cable = (UsbC('NonameCable), (Lightning(150), UsbC(10L)))
noUsbC_Long_Cable: (UsbC[Symbol], (Lightning, UsbC[Long])) = (UsbC('NonameCable),(Lightning(150),UsbC(10)))
scala> connectCable(noUsbC_Long_Cable)
^
error: could not find implicit value for evidence parameter of type Cable[(UsbC[Symbol], (Lightning, UsbC[Long]))]
我们可以通过在我们的类型类定义上应用特殊注解来稍微改进错误信息:
@scala.annotation.implicitNotFound("Cannot connect cable of type ${C}")
trait Cable[C] {
def connect(c: C): Boolean
}
那么,我们的最后一次失败的尝试将更好地解释失败的原因:
scala> connectCable(noUsbC_Long_Cable)
^
error: Cannot connect cable of type (UsbC[Symbol], (Lightning, UsbC[Long]))
很遗憾,在这个案例中我们只能做到这一步。编译器目前无法确定失败的真实原因仅仅是UsbC[Long]而不是整个类型。
编译器将始终尝试根据子类型和方差推断最具体的隐式值。这就是为什么可以结合子类型多态和特设多态。
类型类方差
为了了解这种组合是如何工作的,让我们想象我们的 USB 电缆代表一个具有共同祖先的层次结构:
abstract class UsbConnector
case class Usb(orientation: Boolean) extends UsbConnector
case class Lightning(length: Int) extends UsbConnector
case class UsbCKind extends UsbConnector
这将如何影响我们的类型类定义?当然,我们之前的每个子类型都单独实现的版本将正常工作。但如果我们想为整个UsbConnector层次结构提供一个泛型类型类实例,就像以下示例中所示,会怎样呢?
implicit val usbCable: Cable[UsbConnector] = new Cable[UsbConnector] {
override def connect(c: UsbConnector): Boolean = {
println(s"Connecting $c")
true
}
}
我们将无法再连接我们的电缆:
scala> connectCable(UsbC("3.1"))
^
error: could not find implicit value for evidence parameter of type Cable[UsbC[String]]
这是因为我们的类型类定义是不变的——因此,我们预计要提供一个Cable[T]的实例,其中T <:< UsbC[String]。usbCable是一个合适的匹配吗?结果证明它不是,因为它的返回类型是Cable[UsbConnector],而我们期望提供一个UsbC[String]。
我们可以通过两种方式解决这个问题,具体取决于我们是否希望我们的类型类对任何类层次结构都以相同的方式工作,或者是否每个需要一般处理的类层次结构都必须单独定义它。
在第一种情况下,我们需要确保编译器理解以下内容:
Cable[UsbConnector] <:< Cable[UsbC[String]]
我们可以在 REPL 中检查这目前不是这种情况:
implicitly[Cable[UsbConnector] <:< Cable[UsbC[String]]]
^
error: Cannot prove that Cable[UsbConnector] <:< Cable[UsbC[String]]
但我们已经知道我们需要做什么才能让它通过——我们的Cable应该成为协变类型:
trait Cable[-C] {
def connect(c: C): Boolean
}
一旦我们在Cable的定义中引入适当的变体,所有问题都会迎刃而解,编译器可以解决所有必需的隐式类型:
scala> implicitly[Cable[UsbConnector] <:< Cable[UsbC[String]]]
res1: TypeClassVariance.Cable[TypeClassVariance.UsbConnector] <:< TypeClassVariance.Cable[TypeClassVariance.UsbC[String]] = generalized constraint
scala> connectCable(UsbC("3.1"))
Connecting UsbC(3.1)
不幸的是,如果我们决定只为我们的类层次结构中的某些类进行特殊处理,我们就无法通过定义一个更具体的类型类实例来重用我们的实现:
implicit val usbCCable: Cable[UsbC[String]] = new Cable[UsbC[String]] {
override def connect(c: UsbC[String]): Boolean = {
println(s"Connecting USB C ${c.kind}")
true
}
}
scala> connectCable(UsbC("3.1"))
Connecting UsbC(3.1)
这个测试表明,泛型实例仍然被使用,而特定实例被忽略。
幸运的是,我们还有另一个选择,在我们可以承担每个层次结构自己处理子类型解析的情况下是可行的。在这种情况下,我们保持类型类的不变性,但将类型类实例改为特定类型而不是通用类型:
implicit def usbPolyCable[T <: UsbConnector]: Cable[T] = new Cable[T] {
override def connect(c: T): Boolean = {
println(s"Poly-Connecting $c")
true
}
}
我们需要将val改为def以便能够对其进行参数化。我们的泛化约束再次开始对不变类型类失效:
scala> implicitly[Cable[UsbConnector] <:< Cable[UsbC[String]]]
^
error: Cannot prove that Cable[UsbConnector] <:< Cable[UsbC[String]].
尽管如此,我们可以连接电缆:
scala> connectCable(UsbC("3.1"))
Poly-Connecting UsbC(3.1)
现在,编译器能够为我们类型类选择最具体的可用实例!如果我们将implicit val usbCCable的定义重新引入作用域,我们会看到输出发生变化:
scala> connectCable(UsbC("3.1"))
Connecting USB C 3.1
这显示了静态重载解析是如何工作的。但这只是部分情况。让我们澄清编译器在需要隐式类型时如何以及在哪里查找它们。
隐式作用域解析
为了将隐式类型放在它们所需的位置,编译器首先必须找到它们。这个过程称为隐式作用域解析,并具有明确的规则,以确保隐式类型按照语言规范和开发者使用它们的方式被确定。隐式作用域解析是一个三步过程。
或者如果我们把隐式参数作为方法参数显式提供的情况算作第四步。我们将考虑这种情况为“零”,因为它具有最高的优先级,并且不涉及隐式查找。
我们将简要概述这些步骤,以便我们有一个地方可以方便地参考,然后我们将详细介绍列表中的每个细节:
-
当前调用(或词法)作用域。它具有优先级,并包含可以直接通过其名称(无需前缀)访问的隐式类型,如下所示:
-
局部声明
-
外部作用域声明
-
包对象
-
继承链
-
导入语句
-
-
隐式作用域。它是递归查找的,包括以下内容:
-
参数的伴生对象
-
超类型伴生对象
-
混合类型(超特性)的伴生对象
-
类的伴生对象
-
类型参数的伴生对象
-
类型构造器的伴生对象
-
-
在一个作用域中找到多个隐式类型时的静态重载规则。
词法作用域
让我们从词法作用域开始。词法作用域定义了如何在嵌套语言结构(如方法、函数和其他结构化块)中解析变量。一般来说,外部块的定义在内部块内部是可见的(除非它们被遮蔽)。
以下列表显示了在此作用域中隐式解析期间的所有可能的冲突:
package object resolution {
implicit val a: TS = new TS("val in package object") // (1)
}
package resolution {
class TS(override val toString: String)
class Parent {
// implicit val c: TS = new TS("val in parent class") // (2)
}
trait Mixin {
// implicit val d: TS = new TS("val in mixin") // (3)
}
// import Outer._ // (4)
class Outer {
// implicit val e: TS = new TS("val in outer class") // (5)
// import Inner._ // (6)
class Inner(/*implicit (7) */ val arg: TS = implicitly[TS]) extends Parent with Mixin {
// implicit val f: TS = new TS("val in inner class") (8)
private val resolve = implicitly[TS]
}
object Inner {
implicit val g: TS = new TS("val in companion object")
}
}
object Outer {
implicit val h: TS = new TS("val in parent companion object")
}
}
可能的冲突已用下划线标出。很容易看出,包对象中的隐式值、外部和内部作用域,以及那些被引入内部或外部作用域的值,具有相同的权重。如果类构造函数的参数(7)被声明为隐式,它也会导致冲突。
隐式作用域
现在,让我们来看一个关于隐式作用域的例子,它的优先级低于词法作用域。隐式作用域通常包括(如果适用)类型的伴随对象、参数类型的隐式作用域、类型参数的隐式作用域,以及对于嵌套类型,外部对象。
以下示例演示了前三个情况的实际操作:
import scala.language.implicitConversions
trait ParentA { def name: String }
trait ParentB
class ChildA(val name: String) extends ParentA with ParentB
object ParentB {
implicit def a2Char(a: ParentA): Char = a.name.head
}
object ParentA {
implicit def a2Int(a: ParentA): Int = a.hashCode()
implicit val ordering = new Ordering[ChildA] {
override def compare(a: ChildA, b: ChildA): Int =
implicitly[Ordering[String]].compare(a.name, b.name)
}
}
object ChildA {
implicit def a2String(a: ParentA): String = a.name
}
trait Test {
def test(a: ChildA) = {
val _: Int = a // companion object of ParentA
val _: String = a // companion object of ChildA
val _: Char = a // companion object of ParentB
}
def constructorT: Ordering: List[T] = in.toList.sorted // companion object of type constructor
constructor(new ChildA("A"), new ChildA("B")).sorted // companion object of type parameters
}
在这里,我们在类层次结构中散布了一些隐式转换,以展示查找是如何在参数及其超类型(包括超特型)的伴随对象上进行的。最后两行演示了隐式作用域包括构造函数和sorted方法参数类型的类型参数。
与第一个例子不同,我们在这个例子中定义的所有隐式内容都是明确的。如果不是这样,编译器将应用静态解析规则来尝试确定最具体的隐式内容。
静态重载规则
静态重载规则的定义相当长且复杂(可以在官方文档www.scala-lang.org/files/archive/spec/2.13/06-expressions.html#overloading-resolution中找到)。它指定了编译器用来决定选择哪个替代隐式内容的一组规则。这个决定基于替代方案的相对权重。权重越高意味着替代方案A比B更具体,A获胜。
A相对于B的相对权重是两个数字的总和:
-
如果
A定义在从定义B的类或对象派生的类或对象中(简化地说,如果A是B的子类或子类伴随对象,或者B是A的超类伴随对象A的子类B的伴随对象) -
如果
A与B一样具体(简化地说,这意味着如果A是一个方法,它可以与B使用相同的参数调用;对于多态方法,这也意味着更具体的类型约束)
这两个规则允许你计算两个隐式转换或参数之间的相对权重,在权重不同的情况下选择更合适的替代方案。如果权重相等,编译器将报告模糊的隐式值。
摘要
在本章中,我们讨论了三种类型的隐式表达式。这包括隐式转换、隐式类和隐式参数。
我们还讨论了语言提供的语法糖,即视图边界和上下文边界。我们已经看到,前者以某种简洁的方式允许定义隐式转换,而后者对类型类做同样的事情。
我们比较了面向对象和基于类型类的多态行为方法。根据我们对这个主题的了解,我们通过案例类的递归解析进行了工作,并展示了类型类变异性示例。
总之,我们研究了三个级别的隐式作用域解析的工作方式。我们已经表明,所有在词法作用域中的隐式表达式具有相同的优先级。只有当在词法作用域中找不到合适的隐式表达式时,编译器才会查看隐式作用域。如果作用域中有多个隐式表达式,则使用静态重载规则来解决可能的冲突。
本章总结了本书中关于 Scala 语言结构的部分。在接下来的章节中,我们将转向更复杂的概念。但在这样做之前,在下一章中,我们将简要地探讨基于属性的测试,以了解我们将用于验证本书第二部分所写代码假设的一些技术。
问题
-
描述一个隐式参数也是隐式转换的情况。
-
将以下使用视图边界的定义替换为使用上下文边界的定义:
def compare[T <% Comparable[T]](x: T, y: T) = x < y? -
为什么有时人们说类型类将行为和数据分开?
-
很容易改变词法作用域中可能冲突的示例,以便其中一个隐式表达式胜过其他所有隐式表达式,并且所有其他隐式表达式都可以在不产生冲突的情况下取消注释。你能改变这个吗?
进一步阅读
Mads Hartmann, Ruslan Shevchenko, 《专业 Scala》: 在一个让你为 JVM、浏览器等更多环境构建的环境中使用简洁和表达性强的、类型安全的代码。
Vikash Sharma, 《学习 Scala 编程》: 学习如何在 Scala 中编写可扩展和并发程序,这是一种随着你成长的语言。
第五章:Scala 中的基于属性的测试
单元测试是许多程序员日常活动的一部分。它是为了验证正在开发的软件的行为而进行的。基于属性的测试是单元测试的替代和补充方法。它允许描述软件的预期属性,并使用自动生成数据对这些属性进行验证,如果这些属性保持不变。
在本章中,我们将讨论在哪些情况下基于属性的测试特别有用,并探讨如何制定预期的属性以及生成测试数据。
本章将涵盖以下主题:
-
基于属性的测试概念
-
属性
-
生成器
-
缩减器
-
属性作为法则
技术要求
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在以下链接获取:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter05.
基于属性的测试简介
单元测试的概念应该为任何专业开发者所熟知。单元测试通常包含多个测试用例。每个测试用例描述了程序一部分的预期行为。描述通常以以下形式制定:对于这个代码单元在该特定状态下,我们期望给定的输入产生以下输出。然后,开发者通过在初始状态和/或输入数据以及结果期望中引入一些偏差来复制此类测试用例,以覆盖不同的代码路径。
测试用例的规范以测试代码的形式表示,该代码依赖于测试框架。截至撰写本文时,Scala 项目有两个流行的测试框架,ScalaTest和Specs2。至少有一个应该为任何 Scala 开发者所熟悉,因此我们不会在本书中介绍它们。
相反,我们将探讨其他制定关于程序行为预期的替代方法。
从单元测试到属性
结果表明,测试场景(有时也称为基于示例的测试)只是定义系统预期如何工作的许多方法之一。示例仅描述了软件在特定状态下的某些属性。状态通常会影响输出,以响应提供的输入。
一般而言,除了通过示例描述的属性外,还有其他类型的属性可以表征软件,例如:
-
全称量化属性
-
条件属性
通过它们,我们可以了解关于系统的某些信息,这些信息应该适用于任何有效输入,并且可能适用于所有可能的状态。这种测试形式被称为基于属性的测试(PBT)。与单元测试案例中的具体场景相比,属性是一个抽象规范。
与单元测试框架提供结构化测试和以单元测试形式制定期望的功能相同,Scala 框架也提供了 PBT(参数化测试)的功能。
ScalaCheck
ScalaCheck (www.scalacheck.org) 是 Scala 中自动化 PBT(参数化测试)的框架。它与 SBT 或 IntelliJ IDEA 配合得很好,并且内置了测试运行器,因此可以独立使用。它还很好地与ScalaTest和specs2集成。
ScalaCheck是一个外部依赖项,因此我们需要将其添加到build.sbt中:
libraryDependencies += "org.scalacheck" %% "scalacheck" % "1.14.0" % Test
为了能够在 REPL(Read-Eval-Print Loop)中玩弄代码,我们需要将其添加到默认作用域中(通过移除% Test部分(这已经在章节的代码中完成)并使用 SBT 依赖项启动 REPL。如果您不知道如何做,请参阅附录 A,准备环境和运行代码示例,其中我们详细解释了它)。
现在,我们可以定义和验证我们的第一个属性:
scala> import org.scalacheck.Prop.forAll
import org.scalacheck.Prop.forAll
scala> val stringLengthProp = forAll { (_: String).length >= 0 }
stringLength: org.scalacheck.Prop = Prop
scala> stringLengthProp.check
+ OK, passed 100 tests.
我们刚刚定义并验证了所有Strings都具有非负长度!有点困惑吗?让我们更仔细地看看这是如何完成的。
在第一行,我们导入了一个forAll属性工厂。本质上,它的目的是将函数转换为属性。
在我们的情况下,在第二行中,函数的类型是String => Boolean。自然,这里有一些隐式魔法在起作用。其中之一是一个隐式转换Boolean => Property和一个Arbitrary[String],它提供了测试数据,在我们的例子中是随机字符串。
在第三行,我们调用了一个check方法,这是在Prop(ScalaCheck 使用这个名字作为“属性”的缩写)以及其他组合和执行方法中可用的,用于使用默认配置执行我们的测试。因此,它使用 100 个随机字符串作为输入数据。
现在我们对 PBT(参数化测试)的一般形式有了感觉,我们将严格地处理它的每个方面,从属性开始。
属性
定义属性是 PBT(参数化测试)最重要的方面。如果没有良好的属性定义,就无法正确测试系统。从测试场景到属性的过渡通常是刚开始采用 PBT 的开发者面临的最困难的部分。
因此,拥有某种系统来帮助以系统化的方式定义属性是有用的。通常,系统化某事物的第一步是分类。
属性类型
我们已经说过,存在普遍量化的属性和条件属性,这取决于某个属性是否始终成立或仅对所有可能输入的某个子集成立。现在,我们想要从不同的维度分解属性——根据它们的定义方式。让我们看看我们如何用一般术语描述一些操作。
交换律
如果操作数的顺序不重要,我们说该操作是交换的。最简单的例子就是加法和乘法。这个属性应该适用于这两个操作。在下面的代码中,我们创建了两个属性,一个用于加法,一个用于乘法,并通过比较运算结果的改变顺序来检查我们的假设是否正确:
scala> forAll((a: Int, b: Int) => a + b == b + a).check
+ OK, passed 100 tests.
scala> forAll((a: Int, b: Int) => a * b == b * a).check
+ OK, passed 100 tests.
对于字符串,加法定义为连接,但在一般情况下不是交换的:
scala> forAll((a: String, b: String) => a + b == b + a).check
! Falsified after 1 passed tests.
> ARG_0: "\u0001"
> ARG_0_ORIGINAL
> ARG_1: "\u0000"
> ARG_1_ORIGINAL:
在这个例子中,我们还可以看到ScalaCheck如何生成随机输入并找到一些最小失败案例。如果至少有一个字符串为空,该属性变为交换的,这可以通过以下修改之前的测试来证明,其中b被分配一个空字符串:
scala> forAll((a: String) => a + "" == "" + a).check
+ OK, passed 100 tests.
这是一个字符串连接条件测试的示例。
结合性
结合性与交换性对于操作数相同——如果有多个操作,则只要操作数的顺序不变,操作执行的顺序并不重要。
乘法和加法的结合性属性看起来非常相似,如下例所示,其中我们有三个属性,每个属性比较两种不同运算顺序的计算结果:
scala> forAll((a: Int, b: Int, c: Int) => (a + b) + c == a + (b + c)).check
+ OK, passed 100 tests.
scala> forAll((a: Int, b: Int, c: Int) => (a * b) * c == a * (b * c)).check
+ OK, passed 100 tests.
scala> forAll((a: String, b: String, c: String) =>
(a + b) + c == a + (b + c)).check
+ OK, passed 100 tests.
最后一行证明了字符串连接也是结合的。
恒等性
某些操作的恒等性属性表明,如果操作数之一是恒等值,则操作的结果将等于另一个操作数。对于乘法,恒等值是 1;对于加法,它是 0。由于乘法和加法的交换性,恒等值可以出现在任何位置。例如,在下面的代码片段中,恒等元素作为所有这些的第一个和第二个操作数出现:
scala> forAll((a: Int) => a + 0 == a && 0 + a == a).check
+ OK, passed 100 tests.
scala> forAll((a: Int) => a * 1 == a && 1 * a == a).check
+ OK, passed 100 tests.
scala> forAll((a: String) => a + "" == a && "" + a == a).check
+ OK, passed 100 tests.
对于字符串连接,恒等性是一个空字符串。结果证明,我们为字符串定义的条件交换性属性只是普遍恒等性属性的一种表现!
不变量
不变量属性是指在操作上下文中永远不会改变的属性。例如,对字符串内容进行排序或更改其大小写不应改变其长度。下一个属性证明了它对普通字符串以及大写字符串都成立:
scala> forAll((a: String) => a.sorted.length == a.length).check
+ OK, passed 100 tests.
scala> forAll((a: String) => a.toUpperCase().length == a.length).check
! Falsified after 50 passed tests.
> ARG_0:
> ARG_0_ORIGINAL:
或者,至少对于toUpperCase来说,如果区域设置与字符串内容匹配或字符串只包含 ASCII 符号,它应该可以工作:
scala> forAll(Gen.asciiStr)((a: String) => a.toUpperCase().length == a.length).check
+ OK, passed 100 tests.
在这里,我们有点超前,使用了Gen.asciiStr来生成只包含 ASCII 字符的字符串。
幂等性
幂等操作只改变它们的操作数一次。在初始更改之后,任何后续应用都应该保持操作数不变。对字符串内容进行排序和转换大小写是幂等操作的良例。请注意,在之前的例子中,相同的操作具有长度属性的不变量。
我们可以通过应用不同次数的操作并期望结果与第一次应用相同来证明 toUpperCase 和 sorted 操作是幂等的:
scala> forAll((a: String) =>
a.toUpperCase().toUpperCase() == a.toUpperCase()).check
+ OK, passed 100 tests.
scala> forAll((a: String) => a.sorted.sorted.sorted == a.sorted).check
+ OK, passed 100 tests.
对于乘法,自然幂等元素按定义是单位元素。但它也是一个零:
scala> forAll((a: Int) => a * 0 * 0 == a * 0) .check
+ OK, passed 100 tests.
逻辑 AND 和 OR 分别对布尔值 false 和 true 是幂等的。
归纳
归纳属性反映了它们的操作数属性。它们通常用于归纳情况。
例如,任何参数的阶乘函数都应该遵守阶乘定义:
scala> def factorial(n: Long): Long = if (n < 2) n else n * factorial(n-1)
factorial: (n: Long)Long
scala> forAll((a: Byte) => a > 2 ==>
(factorial(a) == a * factorial(a - 1))).check
+ OK, passed 100 tests.
当然,这是一个对于 n > 2 的条件属性,我们使用蕴涵运算符 ==> 来指定(关于这个运算符的更多内容稍后介绍)。
对称性
对称性是一种不变性类型。它表明操作数在应用一些有序操作集之后将保持其原始形式。通常这个集合限于一对操作,甚至限于一个对称操作。
对于我们常用的实验字符串,有一个对称操作 reverse;对于数字,我们可以定义一对加法和减法:
scala> forAll((a: String) => a.reverse.reverse == a).check
+ OK, passed 100 tests.
scala> forAll((a: Int, b: Int) => a + b - b == a).check
+ OK, passed 100 tests.
有可能定义另一对,以乘法和除法作为操作数(关于除以零、溢出和精度):
对称属性通常被称为 往返 属性。对于单个操作,它必须对任何可逆函数成立。
测试预言者
严格来说,测试预言者不属于这个列表,因为它没有指定操作的内禀质量。尽管如此,它是一种有用且方便的方式来指明预期的行为。
原则很简单,特别是在重构或重写现有系统时特别有用。它使用给定的可信实现来验证新代码的行为。回到我们的字符串示例,我们可能使用 Java 的数组作为字符串内容排序的测试预言者,通过期望字符串排序和由其元素组成的数组的排序结果相同:
scala> forAll { a: String =>
| val chars = a.toCharArray
| java.util.Arrays.sort(chars)
| val b = String.valueOf(chars)
| a.sorted == b
| }.check
+ OK, passed 100 tests.
但是,当然,在真实的重构场景中,在数组的位置上会使用现有的实现。
定义属性
我们以相同的方式定义了所有不同类型的属性,使用 forAll 构造函数的最简洁版本和 check 方法。有一些方法可以自定义它们。
检查属性
check() 方法接受 Test.Parameters,允许配置检查执行的一些方面。最有用的是描述最小成功测试次数、并行运行的工人数、每个测试后执行的测试回调、条件测试中通过和丢弃测试之间的最大丢弃比率,以及一个初始种子,这有助于使属性评估确定性。还可以限制测试允许执行的时间。以下是一个示例,它使用了测试参数和时间限制:
scala> val prop = forAll { a: String => a.nonEmpty ==> (a.reverse.reverse == a) }
prop: org.scalacheck.Prop = Prop
scala> val timed = within(10000)(prop)
timed: org.scalacheck.Prop = Prop
scala> Test.check(timed) {
| _.withMinSuccessfulTests(100000).withWorkers(4).withMaxDiscardRatio(3)
| }
res47: org.scalacheck.Test.Result = Result(Failed(List(),Set(Timeout)),0,0,Map(),10011)
在这里,我们使用了Test.check方法,该方法执行带有给定参数的属性并返回测试统计信息。我们可以看到,我们的测试因为超时而失败了。
除了within之外,还在Prop上定义了其他包装方法。例如,可以将属性抛出的异常转换为测试失败,可以延迟评估属性,或者收集测试报告的数据:
scala> forAll { a: String =>
| classify(a.isEmpty, "empty string", "non-empty string") {
| a.sorted.length ?= a.length
| }
| }.check()
+ OK, passed 100 tests.
> Collected test data:
96% non-empty string
4% empty string
在之前的代码中使用的==和?=之间的区别是微妙的——==比较两个值并返回一个布尔值,然后隐式转换为Prop;?=直接创建一个Prop,有时在属性组合的情况下可能很有用,正如我们将在后面看到的。
属性也可以被标记,这使得在结果中更容易找到它:
scala> val prop2 = "Division by zero" |: protect(forAll((a: Int) => a / a == 1))
prop2: org.scalacheck.Prop = Prop
scala> prop2.check()
! Exception raised on property evaluation.
> Labels of failing property:
Division by zero
> ARG_0: 0
> Exception: java.lang.ArithmeticException: / by zero
$line74.$read$$iw$$iw$$iw$$iw$$iw$$iw$$iw$$iw$.$anonfun$prop2$2(<console>:2
3)
...
在这里,我们也使用了protect方法将异常转换为测试失败。
组合属性
到目前为止,我们一直在谈论单个、孤立的属性。有时,确保某些属性的组合成立是有用的,甚至是有必要的。例如,我们可能想要定义一个属性,该属性仅在所有其他属性都成立时成立。或者我们可能想要有一个属性,该属性在属性集中至少有一个属性为真时为真。Prop上定义了组合方法,正好用于此类用例。结果是另一个属性,我们可以像之前那样进行检查:
forAll { (a: Int, b: Int, c: Int, d: String) =>
val multiplicationLaws = all(
"Commutativity" |: (a * b ?= b * a),
"Associativity" |: ((a * b) * c ?= a * (b * c)),
"Identity" |: all(a * 1 ?= a, 1 * a ?= a)
) :| "Multiplication laws"
val stringProps = atLeastOne(d.isEmpty, d.nonEmpty)
all(multiplicationLaws, stringProps)
}.check()
+ OK, passed 100 tests.
这是一个属性的嵌套组合。最顶层的属性在multiplicationLaws和stringProps都成立的情况下成立。stringProps验证任何String要么为空,要么非空;同时只能有一个这样的属性为真。对于multiplicationLaws,所有嵌套属性都必须成立。
还有一些更具体的组合器,例如someFailing和noneFailing,它们分别在底层属性失败或没有任何属性失败的情况下成立。
生成器
我们已经对属性进行了详细的讨论,但还没有提到这些属性的输入数据来自哪里。让我们纠正这个遗漏,并给予生成器应有的关注。
生成器的想法来源于类型的通用概念。从某种意义上说,类型是对符合该类型可能值的规范。换句话说,类型描述了值必须遵守的规则。这些规则为我们提供了为给定类型生成数据值范围的可能性。
对于某些类型,值更多;对于其他类型,值更少。正如我们已知的那样,存在包含单个值的字面类型。对于具有()值的Unit类型,情况也是如此。对于Boolean类型,存在两个值:true和false。对于想象中的等价关系类型,也存在两个值——相等和非相等。按照同样的原则,完整的排序可以取三个值之一:小于、等于或大于。
定义在具有如此有限可能值集合的类型上的属性被称为 可证明 属性。这是因为可以尝试给定类型的所有值(如果有多个参数,则是组合),并证明程序对所有可能的输入都是正确的。
另一种类型的属性是可证伪属性。尝试所有可能的输入参数值是不可能的(或没有意义),因此只能说明正在测试的功能对于所有输入的某个子集是有效的。
为了使可证伪属性更加可信,现有的 ScalaCheck 生成器为 Byte、Short、Int 和 Long 类型在 zero、+1、-1 以及 minValue 和 maxValue 上增加了额外的权重。
让我们看看 ScalaCheck 中包含哪些生成器,以及我们如何使用它们为特定于我们代码的数据类型创建新的生成器。我们还将简要讨论逐渐减少已知失败案例的测试数据,即缩小。
现有生成器
说到现有生成器,ScalaCheck 提供了许多内置生成器,例如 AnyVal 的所有子类型、Unit、Function0、具有不同内容的字符和字符串(alphaChar、alphaLowerChar、alphaNumChar、alphaStr、alphaUpperChar、numChar、numStr)、容器、列表和映射(containerOf、containerOf1、containerOfN、nonEmptyContainerOf、listOf、listOf1、listOfN、nonEmptyListOf、mapOf、mapOfN、nonEmptyMap)、数字(chooseNum、negNum、posNum)、持续时间、Calendar、BitSet,甚至 Test.Parameters!
如果没有适合测试目的的生成器可用,可以通过实现一个 Gen 类来创建一个自定义生成器:
sealed abstract class Gen[+T] extends Serializable { self =>
...
def apply(p: Gen.Parameters, seed: Seed): Option[T]
def sample: Option[T]
...
}
这是一个抽象类,它基本上就是一个接受测试参数并返回所需类型可选值的函数。
虽然部分实现了它,但手动扩展它仍然有点枯燥。因此,通常通过重用现有的生成器来实现新的生成器。作为一个练习,让我们实现一个字面类型生成器:
def literalGenT <: Singleton: Gen[T] = Gen.const(t)
implicit val myGen: Arbitrary[42] = Arbitrary(literalGen(42))
val literalProp = forAll((_: 42) == 42).check
在第一行,我们通过将值生成委托给 Gen.const 来创建一个字面类型生成器工厂。这样做是安全的,因为根据定义,字面类型只包含单个值。第二行创建了一个 implicit Arbitrary[42],它期望通过 forAll 属性在作用域内。
生成器的组合
虽然创建一个自定义生成器并不非常困难,但绝大多数生成器是通过组合现有实现来构建的。Gen 提供了一些在这种情况下非常有用的方法。一个经典的例子是使用 map 和 flatMap 方法为案例类创建生成器。
让我们用一个玩牌的例子来演示这一点:
sealed trait Rank
case class SymRank(s: Char) extends Rank {
override def toString: String = s.toString
}
case class NumRank(n: Int) extends Rank {
override def toString: String = n.toString
}
case class Card(suit: Char, rank: Rank) {
override def toString: String = s"$suit $rank"
}
首先,我们需要一些用于花色和点数的生成器,我们可以通过重用现有的 oneOf 和 choose 构造函数来创建它们:
val suits = Gen.oneOf('♡', '♢', '♤', '♧')
val numbers = Gen.choose(2, 10).map(NumRank)
val symbols = Gen.oneOf('A', 'K', 'Q', 'J').map(SymRank)
现在,我们可以使用for推导式将我们的生成器组合到牌生成器中:
val full: Gen[Card] = for {
suit <- suits
rank <- Gen.frequency((9, numbers), (4, symbols))
} yield Card(suit, rank)
我们还使用Gen.frequency以确保我们的组合生成器产生的数字和符号有适当的分布。
使用suchThat组合器很容易将这个生成器改为只为牌组生成牌:
val piquet: Gen[Card] = full.suchThat {
case Card(_, _: SymRank) => true
case Card(_, NumRank(n)) => n > 5
}
我们可以通过使用Prop.collect方法来检查我们的生成器是否产生可信的值:
scala> forAll(piquet) { card =>
| Prop.collect(card)(true)
| }.check
+ OK, passed 100 tests.
> Collected test data:
8% ♡ J
6% ♢ 7
6% ♡ 10
... (couple of lines more)
scala> forAll(full) { card =>
| Prop.collect(card)(true)
| }.check
+ OK, passed 100 tests.
> Collected test data:
6% ♡ 3
5% ♢ 3
... (a lot more lines)
当然,也可以使用容器生成器方法之一从牌堆中生成一些牌:
val handOfCards: Gen[List[Card]] = Gen.listOfN(6, piquet)
然后像以前一样使用它:
scala> forAll(handOfCards) { hand: Seq[Card] =>
| Prop.collect(hand.mkString(","))(true)
| }.check
! Gave up after only 58 passed tests. 501 tests were discarded.
> Collected test data:
2% ♤ 8,♤ 10,♤ 8,♤ 7,♡ Q,♢ 8
哦,我们的手中有多张重复的牌。结果是,我们需要使用容器生成器的更一般形式,它接受容器类型和元素类型的类型参数:
val handOfCards = Gen.containerOfNSet, Card
scala> forAll(handOfCards) { hand =>
| Prop.collect(hand.mkString(","))(true)
| }.check
! Gave up after only 75 passed tests. 501 tests were discarded.
> Collected test data:
1% ♡ A,♤ J,♡ K,♢ 6,♧ K,♧ A
1% ♤ 9,♧ A,♧ 8,♧ 9
这样更好,但现在看起来重复的元素似乎消失了,所以我们仍然没有预期的行为。此外,还有一个明显的问题——许多测试被丢弃。这是因为我们的piquet生成器是在过滤更通用的full生成器的输出定义的。ScalaCheck注意到有太多测试不符合有效输入的资格,因此提前放弃。
让我们修复我们的piquet生成器和一个缺失牌的问题。对于第一个问题,我们将使用与full生成器相同的方法。我们只需更改用于排名的数字:
val piquetNumbers = Gen.choose(6, 10).map(NumRank)
val piquet: Gen[Card] = for {
suit <- suits
rank <- Gen.frequency((5, piquetNumbers), (4, symbols))
} yield Card(suit, rank)
请注意,频率是如何相对于可能值的变化而变化的。
为了修复第二个问题,我们将使用retryUntil组合器反复生成牌组,直到它达到预期的尺寸:
val handOfCards = Gen.containerOfNSet, Card.retryUntil(_.size == 6)
scala> forAll(handOfCards) { hand =>
| Prop.collect(hand.mkString(","))(true)
| }.check
+ OK, passed 100 tests.
> Collected test data:
1% ♤ 9,♢ 9,♧ 9,♢ Q,♧ J,♤ 10
...
现在,我们的手牌生成正如预期的那样。
当然,还有更多有用的组合方法,可以用来创建其他复杂的生成器。请参阅文档(github.com/rickynils/scalacheck/blob/master/doc/UserGuide.md)或源代码以获取更多详细信息。
缩减器
我们已经探讨了 PBT 的两个基石——属性和生成器。在我们认为自己完成之前,还有一个方面我们应该看看。
在 PBT 中,测试数据来自生成器,它有点随机。考虑到这个事实,我们可能会预期很难找出为什么测试失败。考虑以下示例:
scala> forAllNoShrink { num: Int =>
| num < 42
| }.check
! Falsified after 0 passed tests.
> ARG_0: 2008612603
在这里,我们可以看到我们的属性被数字2008612603所否定,这个数字可以说是没有太大用处。对于一个Int来说,这几乎是显而易见的,但考虑一个包含许多元素的列表和为这些元素制定的属性的情况:
scala> forAllNoShrink(Gen.listOfN(1000, Arbitrary.arbString.arbitrary)) {
| _.forall(_.length < 10)
| }.check
! Falsified after 10 passed tests.
> ARG_0: List
",
... // a lot of similar lines
显然,在这个测试中找出哪个 1,000 个字符串长度错误几乎是不可能的。
在这个时候,新的组件开始发挥作用:Shrink。收缩器的作用是找到使属性不成立的最小测试数据。在前两个例子中,我们使用了forAllNoShrink属性构造器,因此没有激活收缩器。如果我们将定义更改为正常的forAll,结果将如下所示:
scala> forAll(Gen.listOfN(1000, Arbitrary.arbString.arbitrary)) {
| _.forall(_.length < 10)
| }.check
! Falsified after 10 passed tests.
> ARG_0: List("")
> ARG_0_ORIGINAL: // a long list as before
在这里,我们可以看到,使我们的属性为假的极小列表是包含一个空字符串的列表。原始的失败输入以ARG_0_ORIGINAL的形式展示,其长度和复杂性与我们之前看到的相似。
Shrink实例作为隐式参数传递,因此我们可以召唤一个来查看它们是如何工作的。我们将使用我们的Int属性失败值来做这件事:
val intShrink: Shrink[Int] = implicitly[Shrink[Int]]
scala> intShrink.shrink(2008612603).toList
res23: List[Int] = List(1004306301, -1004306301, 502153150, -502153150, 251076575, -251076575, 125538287, -125538287, 62769143, -62769143, 31384571, -31384571, 15692285, -15692285, 7846142, -7846142, 3923071, -3923071, 1961535, -1961535, 980767, -980767, 490383, -490383, 245191, -245191, 122595, -122595, 61297, -61297, 30648, -30648, 15324, -15324, 7662, -7662, 3831, -3831, 1915, -1915, 957, -957, 478, -478, 239, -239, 119, -119, 59, -59, 29, -29, 14, -14, 7, -7, 3, -3, 1, -1, 0)
shrink方法生成一个值流,我们通过将其转换为列表来评估它。很容易看出模式——Shrink产生的值相对于0(零)的中心值对称,从初始的失败值开始,然后每次都除以二,直到它们收敛到零。这基本上就是它对数字的实现方式,包括硬编码的+-two、+-one和zero值。
很容易看出,Shrink生成的数字将取决于初始的失败参数。这就是为什么对于第一个属性,返回的值每次都会不同的原因:
scala> forAll { (_: Int) < 42 }.check
! Falsified after 0 passed tests.
> ARG_0: 47
> ARG_0_ORIGINAL: 800692446
scala> forAll { (_: Int) < 42 }.check
! Falsified after 0 passed tests.
> ARG_0: 54
> ARG_0_ORIGINAL: 908148321
scala> forAll { (_: Int) < 42 }.check
! Falsified after 2 passed tests.
> ARG_0: 57
> ARG_0_ORIGINAL: 969910515
scala> forAll { (_: Int) < 42 }.check
! Falsified after 6 passed tests.
> ARG_0: 44
> ARG_0_ORIGINAL: 745869268
如我们所见,产生的失败值取决于原始的失败值,永远不会是43,但有时它非常接近。
当存在一些不成立的属性时,收缩器是必不可少的,尤其是如果输入数据具有显著的大小。
摘要
基于属性的测试是传统单元测试和行为驱动开发的一种补充技术。它允许人们以抽象规范的形式描述程序属性,并以规则的形式描述用于生成测试数据的规则。
正确生成的数据包括边缘情况,这些情况在基于示例的测试中通常被忽略,并允许更高的代码覆盖率。
ScalaCheck是一个基于 Scala 的属性测试框架。它有三个主要组件——属性、生成器和收缩器。
全称量化属性必须适用于程序任何状态的任何测试数据。条件属性是为数据的一个子集或系统的特定状态定义的。
ScalaCheck提供了大量标准类型的生成器。创建自定义类型的生成器的最佳方式是通过使用它们上定义的合适方法组合现有生成器。
可选收缩器的作用是减少失败属性的测试数据集,帮助识别最小失败测试用例。
有几个扩展库可供使用,允许用户生成任意的案例类和 ADT(scalacheck-shapeless),cats 类型类实例(cats-check),以及其他常见情况(scalacheck-toolbox)。
现在,我们已经准备好开始我们的功能性编程概念之旅,这部分内容将在本书的下一部分中介绍。我们将从检查标准库中的一些类型开始,这些类型被称为效果,例如Option、Try、Either和Future。
问题
-
定义一个用于排序列表的不变量性质
-
定义一个用于排序列表的幂等性质
-
定义一个用于排序列表的归纳性质
-
定义一个生成器,用于
List[Lists[Int]],使得嵌套列表的元素都是正数 -
定义一个生成器,用于
Map[UUID, () => String]
第六章:探索内置效果
有时,计算机的行为与开发者的预期不同。有时,一个函数可能无法为给定的一组参数返回值,设备在运行时不可用,或者调用外部系统所需的时间比预期长得多。
函数式方法努力捕捉这些方面,并用类型来表示它们。这允许对程序进行精确推理,并有助于在运行时避免意外。
在本章中,我们将研究这些方面是如何被 Scala 标准库所涵盖的。我们将查看以下内容:
-
使用类型编码运行时方面的基础
-
选项
-
或者
-
尝试
-
未来
技术要求
在我们开始之前,请确保您已安装以下内容:
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter06.
效果简介
Scala 代码编译成 Java 字节码,并在 JVM(Java 虚拟机)上运行。正如其名所示,JVM 并非专门为 Scala 构建。因此,Scala 语言能够表达的内容与 JVM 支持的内容之间存在不匹配。后果是双重的:
-
编译器将 Scala 中 JVM 不支持的功能转换为适当的字节码,主要是通过创建包装类。因此,一个简单的 Scala 程序的编译可能会导致创建数十或数百个类,这反过来又会导致性能下降和更高的垃圾回收足迹。本质上,这些负面后果只是实现细节。随着 JVM 的改进,有可能优化编译器为 Java 的新版本生成的字节码,而无需应用程序开发者的任何努力。
-
从相反的方向来看,平台有一些特性与 Scala 并不特别一致。尽管如此,对这些特性的支持是必要的,部分原因是出于 Scala 与 Java 互操作性的原因,部分原因是因为如果底层运行时发生某些情况,它需要能够在语言中表达出来。
对于我们这些语言使用者来说,第一类差异更多的是一种理论上的兴趣,因为我们很舒服地假设编译器开发者正在尽最大努力生成最适合当前 JVM 版本的字节码,因此我们可以依赖他们。第二类差异对我们影响更直接,因为它们可能会影响我们编写代码的方式,并且它们肯定会影响我们与现有库(尤其是 Java 库)交互时编写的代码。
在这里,我们讨论的是可能会对我们的代码推理能力产生负面影响的功能,特别是那些破坏其引用透明性的功能。
为了说明最后一点,让我们看一下以下代码:
scala> import java.util
import java.util
scala> val map = new util.HashMap[String, Int] { put("zero", 0) }
map: java.util.HashMap[String,Int] = {zero=0}
scala> val list = new util.ArrayList[String] { add("zero") }
list: java.util.ArrayList[String] = [zero]
scala> println(map.get("zero"))
0
scala> println(list.get(0))
zero
我们定义了一个 Java HashMap,一个ArrayList,在其中放入了一些项目,并如预期地得到了这些项目。到目前为止,一切顺利。
让我们进一步探讨:
scala> println(map.get("one"))
null
scala> :type null
Null
对于在map中找不到的元素,我们得到了一个null: Null的返回值,这有点出乎意料。这真的那么糟糕吗?嗯,可能确实如此:
scala> println(map.get("one").toString)
java.lang.NullPointerException
... 40 elided
我们遇到了一个NullPointerException,如果不捕获它,程序在运行时将会崩溃!
好吧,但我们可以检查返回的元素是否为null。我们只需要记住每次调用可能返回null的函数时都要这样做。让我们用list来做这个操作:
scala> list.get(1) match {
| case null => println("Not found")
| case notNull => println(notNull)
| }
java.lang.IndexOutOfBoundsException: Index: 1, Size: 1
at java.util.ArrayList.rangeCheck(ArrayList.java:653)
at java.util.ArrayList.get(ArrayList.java:429)
... 49 elided
哦,列表对于缺失的元素不会返回null,它只是直接抛出IndexOutOfBoundsException!看起来我们需要在我们的调用中添加一个catch子句,以便使其更安全...
到目前为止,我们的观点已经很明确了——在没有查看 JavaDocs,最终查看实现源代码的情况下,很难或不可能推理出以这种风格编写的某些代码的执行可能的结果。原因在于我们调用的函数可以以它们类型中未编码的方式返回结果。在第一个例子中,函数在集合中没有元素的情况下返回一个特殊的null值。但这个特殊值也可能是其他东西!另一个例子是ArrayList上定义的indexOf方法的-1。还有另一种情况是指出无法执行操作,这是通过抛出异常来完成的。
从某种意义上说,我们调用的函数改变了它们执行的环境。在异常的情况下,程序的执行路径改变以传播异常,而在返回null的情况下,调用者的期望发生了变化,不幸的是,不是在编译时,而是在运行时。
在函数式编程中,我们称这种行为为效果,并努力在类型级别上表达这种效果。函数式编程(FP)中的效果与副作用重叠,但代表了一个更广泛的概念。例如,结果的可选性(返回null)并不是副作用。
效果的优势在于它们不需要在语言级别上实现。正因为如此,根据库作者的目标和架构考虑,可以以不同的方式设计相同的效果。最重要的是,它们可以被扩展和组合,这允许我们以结构化和类型安全的方式表示复杂的效果集。
在本章的后续部分,我们将探讨标准库中可用的四种不同类型的效果,从Option开始。
可选
Option可能是新手 Scala 开发者首先熟悉的效果。它编码了函数可能不返回结果的情况。简化来说,它在stdlib中以代数数据类型的形式表示,如下面的代码所示:
sealed abstract class Option[+A]
case object None extends Option[Nothing]
final case class Some+A extends Option[A]
None表示不存在结果的情况,而Some(value)表示存在结果的情况。接下来,我们将探讨一种三步法来更深入地理解如何使用Option——如何创建它、从它(如果有的话)读取值以及从Option是一个效果的事实中产生的可能性。
创建一个 Option
Option可以通过多种方式创建。最直接的方法,尽管不推荐,是使用情况类的构造器或直接返回None:
val opt1 = None
val opt2 = Some("Option")
这是不推荐的,因为绝对有可能再次返回被Option包裹的null,从而违背了它的目的:
val opt3 = Some(null)
因此,我们需要首先检查构造器参数是否为null:
def opt4A: Option[A] = if (a == null) None else Some(a)
实际上,这种模式如此常见,以至于Option伴随对象提供了相应的构造器:
def opt5A: Option[A] = Option(a)
伴随对象定义了几个更多的构造器,允许你完全避免直接使用Some或None:
val empty = Option.empty[String]
val temperature: Int = 26
def readExactTemperature: Int = 26.3425 // slow and expensive method call
val temp1 = Option.when(temperature > 45)(readExactTemperature)
val temp2 = Option.unless(temperature < 45)(readExactTemperature)
第一个构造器创建类型None,第二个和第三个返回Some,但只有当条件分别为true或false时。第二个参数是一个按名传递的参数,并且只有在条件成立时才会计算。
从 Option 中读取
现在我们有一个Option,需要从中取出值。最明显的方法是在“空值检查”风格中这样做:
if (opt1.isDefined) println(opt1.get)
if (opt1.isEmpty) println("Ooops, option is empty") else println(opt1.get)
在这里,我们使用了两种空值检查中的一种,在这种情况下,如果Option非空,我们调用.get来检索其值。除了相当冗长之外,这种方法的主要缺点是很容易忘记检查Option是否已定义。如果调用None.get,它将抛出NoSuchElementException:
scala> None.get
java.util.NoSuchElementException: None.get
at scala.None$.get(Option.scala:378)
... 40 elided
还有几个方法允许你检查Option的内容是否满足给定的条件,但它们都以相同的方式存在问题:
if (option.contains("boo")) println("non-empty and contains 'boo'")
if (option.exists(_ > 10)) println("non-empty and greater then 10")
if (option.forall(_ > 10)) println("empty or greater then 10")
contains方法在Option被定义的情况下将其参数与Option的内容进行比较。exists接受一个谓词,如果Option非空,则应用于Option的值。与其它方法相比,forall是特殊的,因为它在应用于参数的谓词对非空Option成立或Option为空时返回true。
从Option中获取值的另一种方式是解构它:
if (opt.isDefined) { val Some(value) = opt }
你也可以使用模式匹配来完全避免检查非空性:
opt match {
case Some(value) => println(value)
case None => println("there is nothing inside")
}
有时,对于调用者来说,如果Option为空,只需要一个“默认”值。为此有一个特殊的方法叫做getOrElse:
val resultOrDefault = opt.getOrElse("No value")
另一个类似的方法是orNull。在 Scala 代码中,它并不很有用,但对于 Java 互操作性来说非常方便,并且对Option[AnyRef]可用。在空Option的情况下,它返回null,否则返回Option的值:
scala> None.orNull
res8: Null = null
foreach方法与之前我们所见的不同,因为它在Option的值被定义的情况下执行一个函数:
scala> val opt2 = Some("I'm a non-empty option")
opt2: Some[String] = Some(I'm a non-empty option)
scala> opt2.foreach(println)
I'm a non-empty option
与我们之前看到的其他方法相比,它之所以特殊,是因为它不将选项视为特殊值。相反,它在语义上被表述为一个回调——“如果这个效果(已经)发生,就在它上面执行以下代码。”
这种对 Option 的看法提供了另一种可能性,以便我们可以与之交互——提供在空或非空选项的情况下将执行的更高阶函数。让我们详细看看这是如何工作的。
选项作为效果
上述方法的第一种后果是,可以在不检查其内容的情况下约束 Option 的可能值(如果条件不成立,则将其转换为 None)。以下是一个进一步过滤选项的例子,包含一个大于或小于 10 的数字:
val moreThen10: Option[Int] = opt.filter(_ > 10)
val lessOrEqual10: Option[Int] = opt.filterNot(_ > 10)
还可以使用部分函数作为过滤器。这允许你在过滤和转换值的同时进行操作。例如,你可以过滤大于 10 的数字并将它们转换为 String:
val moreThen20: Option[String] = opt.collect {
case i if i > 20 => s"More then 20: $i"
}
从功能上讲,collect 方法可以看作是 filter 和 map 的组合,其中后者可以单独使用来转换非空选项的内容。例如,让我们想象一系列我们需要调用的函数来捕捉鱼:
val buyBait: String => Bait = ???
val castLine: Bait => Line = ???
val hookFish: Line => Fish = ???
def goFishing(bestBaitForFish: Option[String]): Option[Fish] =
bestBaitForFish.map(buyBait).map(castLine).map(hookFish)
在这里,我们是在购买一些诱饵,抛出鱼线,并在适当的时机钓到鱼。我们实现中的参数是可选的,因为我们可能不知道鱼的最佳咬钩时机是什么。
然而,这个实现存在一个问题。我们忽略了我们的函数对于给定的参数将没有结果的事实。鱼店可能已经关闭,抛出的鱼线可能断裂,鱼也可能滑走。结果是我们违反了我们之前定义的关于使用类型表达效果的规则!
让我们通过使我们的函数返回 Option 来修复这个问题。我们首先从 hookFish 开始:
val hookFish: Line => Option[Fish]
def goFishingOld(bestBaitForFish: Option[String]): Option[Option[Fish]] =
bestBaitForFish.map(buyBait).map(castLine).map(hookFish)
但现在我们的函数返回一个嵌套的 Option,这很难处理。我们可以通过使用相应的方法来展平结果来解决这个问题:
def goFishingOld(bestBaitForFish: Option[String]): Option[Fish] =
bestBaitForFish.map(buyBait).map(castLine).map(hookFish).flatten
现在,我们也可以让 castLine 返回 Option:
val castLine: Bait => Option[Line]
val hookFish: Line => Option[Fish]
def goFishingOld(bestBaitForFish: Option[String]): Option[Fish] =
bestBaitForFish.map(buyBait).map(castLine).map(hookFish).flatten
不幸的是,这个实现无法编译:
error: type mismatch;
found : FishingOptionExample.this.Line => Option[FishingOptionExample.this.Fish]
required: Option[UserExample.this.Line] => ?
为了处理链式非空选项,有一个 flatMap 方法,它接受一个返回 Option 的函数,并在返回之前展平结果。使用 flatMap,我们可以实现我们的调用链,而无需在最后调用 flatten:
val buyBait: String => Option[Bait]
val makeBait: String => Option[Bait]
val castLine: Bait => Option[Line]
val hookFish: Line => Option[Fish]
def goFishingOld(bestBaitForFish: Option[String]): Option[Fish] bestBaitForFish.flatMap(buyBait).flatMap(castLine).flatMap(hookFish)
有 map 和 flatMap 也允许我们在 for 简化表达式中使用 Option。例如,我们可以这样重写前面的例子:
def goFishing(bestBaitForFish: Option[String]): Option[Fish] =
for {
baitName <- bestBaitForFish
bait <- buyBait(baitName).orElse(makeBait(baitName))
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
在这里,我们还添加了一个后备情况,以应对鱼店关闭的情况,以及当你需要手工制作诱饵时。这表明空选项也可以链式使用。orElse 方法解决一系列选项,直到找到第一个定义的选项,或者返回链中的最后一个 Option,无论其内容如何:
val opt5 = opt0 orElse opt2 orElse opt3 orElse opt4
有一种方法可以对Option进行映射,并为空情况提供一个默认值。这是通过fold方法完成的,它接受默认值作为第一个参数列表,映射函数作为第二个参数:
opt.fold("Value for an empty case")((i: Int) => s"The value is $i")
对于Option,最后两个可用方法是toRight和toLeft。它们返回下一个我们想要查看的效果的实例,即Either。toRight返回Left,其中包含其参数的None,或者返回包含Some值的Right:
opt.toRight("If opt is empty, I'll be Left[String]")
toLeft做同样的事情,但返回Either的不同侧面:
scala> val opt = Option.empty[String]
opt: Option[String] = None
scala> opt.toLeft("Nonempty opt will be Left, empty - Right[String]")
res8: Either[String,String] = Right(Nonempty opt will be Left, empty - Right[String])
但我们所说的这些Left和Right选项是什么?
Either
Either表示一个函数可能有两种不同的结果,这些结果不能由单一类型表示的可能性。
例如,让我们想象我们有一个新的模拟系统,它取代了旧的一个。新系统非常受欢迎,因此它始终处于负载状态,因此并不总是可用。出于这个原因,旧系统被保留作为后备。不幸的是,两个系统的模拟结果格式非常不同。因此,将它们表示为Either是有意义的:
type OldFormat
type NewFormat
def runSimulation(): Either[OldFormat, NewFormat]
如果这个例子让你觉得替代项的类型必须相关,那么你就有误解了。通常,结果类型会完全不相关。为了说明这一点,让我们考虑另一个例子。
在我们钓鱼的时候,有可能钓到非常不同种类的鱼。还有一种可能是拉出完全不同的东西——一个两年前游客丢失的旧靴子,或者被罪犯隐藏的潜在证据:
def catchFish(): Either[Boot, Fish]
传统上,右边被用来表示更理想的、正确的结果,而左边则表示不那么理想的结果。
Either在 Scala 库中的简化定义如下:
sealed abstract class Either[+A, +B]
final case class Left+A, +B extends Either[A, B]
final case class Right+A, +B extends Either[A, B]
它为左边和右边接受两个类型参数,并且有两个案例类代表这些侧面。让我们深入一点,使用与Option相同的方法——创建一个效果,从效果中读取,并对其抽象:
创建 Either
同样,在Option的情况下,创建Either实例的一个明显方法是使用相应的案例类的构造函数:
scala> Right(10)
res1: scala.util.Right[Nothing,Int] = Right(10)
注意事项是,前面的定义让我们得到了一个左边的类型为Nothing的Either,这很可能不是我们的初衷。因此,为两边提供类型参数是很有必要的:
scala> LeftString, Int
res2: scala.util.Left[String,Int] = Left(I'm left)
这可能有点繁琐。
再次强调,与Option类似,Either伴随对象提供了一个有用的构造函数,它接受一个谓词和两个按名称指定的构造函数,用于右边和左边:
scala> val i = 100
i: Int = 100
scala> val either = Either.cond(i > 10, i, "i is greater then 10")
either: scala.util.Either[String,Int] = Right(100)
如果条件成立,则构建一个带有给定参数的Right,否则创建一个Left。由于两边都已定义,编译器可以正确地推断出Either的结果类型。
有两个辅助方法定义在 Left 和 Right 上,帮助将先前定义的侧升级为完整的 Either:
scala> val right = Right(10)
right: scala.util.Right[Nothing,Int] = Right(10)
scala> right.withLeft[String]
res11: scala.util.Either[String,Int] = Right(10)
scala> Left(new StringBuilder).withRight[BigDecimal]
res12: scala.util.Either[StringBuilder,BigDecimal] = Left()
在这里,我们将 Right[Nothing,Int] 升级为 Either[String,Int],并将 Left 也做同样的处理,这会产生类型为 Either[StringBuilder,BigDecimal] 的结果值。
从 Either 读取值
Either 与 Option 不同之处在于它表示两个可能的值而不是一个。因此,我们无法仅仅检查 Either 是否包含值。我们必须指定我们谈论的是哪一侧:
if (either.isRight) println("Got right")
if (either.isLeft) println("Got left")
与 Option 的方法相比,它们用处不大,因为 Either 不提供从其中提取值的方法。在 Either 的情况下,模式匹配是一种可行的方法:
either match {
case Left(value) => println(s"Got Left value $value")
case Right(value) => println(s"Got Right value $value")
}
断言函数也提供了与 Option 类似的语义,其中 None 由 Left 表示,而 Some 由 Right 表示:
if (either.contains("boo")) println("Is Right and contains 'boo'")
if (either.exists(_ > 10)) println("Is Right and > 10")
if (either.forall(_ > 10)) println("Is Left or > 10")
将 Right 作为默认侧的特殊处理使得 Either 具有向右偏好的特性。另一个例子是 getOrElse 函数,它也会在 Left 的情况下返回提供的默认值:
scala> val left = Left(new StringBuilder).withRight[BigDecimal]
res14: scala.util.Either[StringBuilder,BigDecimal] = Left()
scala> .getOrElse("Default value for the left side")
res15: String = Default value for the left side
向右偏好在转换为 Option 时表现得非常好,其中 Some 表示右侧,而 None 表示左侧,无论 Left 的 value 如何:
scala> left.toOption
res17: Option[BigDecimal] = None
类似地,toSeq 将 Right 表示为一个只有一个元素的 Seq,而将 Left 表示为一个空 Seq:
scala> left.toSeq
res18: Seq[BigDecimal] = List()
如果我们希望将 Left 转换为 Right 或反之亦然,有一个 swap 方法,它的唯一目的是改变两侧:
scala> left.swap
res19: scala.util.Either[BigDecimal,StringBuilder] = Right()
这可以帮助在需要应用值的左侧应用向右偏好的方法。
Either 作为 Effect
自然地,以效应定义的方法在 Either 上也是向右偏好的。例如,回调方法 foreach 也是这样,我们已经在 Option 中知道了它:
scala> val left = Left("HoHoHo").withRight[BigDecimal]
left: scala.util.Either[String,BigDecimal] = Left(HoHoHo)
scala> left.foreach(println)
scala> left.swap.foreach(println)
HoHoHo
在前面的例子中,回调在 left 上没有执行,而是在我们对它调用 swap 后立即变为 Right 时被调用。过滤的定义略有不同,因为它接受一个用于过滤右侧的谓词,以及一个在谓词不成立时作为 Left 返回的值:
scala> left.swap.filterOrElse(_.length > 10, "Sorry, too short")
res27: ... = Left(Sorry, too short)
map 和 flatMap 允许你在提供适当的函数时转换右侧。flatMap 期望函数的结果类型也是 Either。为了演示这一点,我们将重用我们的 Option 示例:
val buyBait: String => Bait = ???
val makeBait: String => Bait = ???
val castLine: Bait => Line = ???
val hookFish: Line => Fish = ???
但这次我们将从 bestBaitForFish 开始,这是我们询问另一位渔夫的结果。渔夫可能心情不好,我们可能会听到他们咒骂而不是我们期望得到的提示。这两者都是 String 类型,但我们绝对想要区分它们:
def goFishing(bestBaitForFishOrCurse: Either[String, String]): Either[String, Fish] =
bestBaitForFishOrCurse.map(buyBait).map(castLine).map(hookFish)
再次,我们没有达到自己设定的标准。我们可能从商店的卖家那里得到解释,说明为什么我们不能买到我们想要的诱饵。如果我们未能制作诱饵,抛出鱼线或钓到鱼,我们也可以用一些文字来表达,这些文字我们不会放在这本书的例子中。表达函数在出错时返回这种口头反馈的可能性是有意义的:
val buyBait: String => Either[String, Bait]
val makeBait: String => Either[String, Bait]
val castLine: Bait => Either[String, Line]
val hookFish: Line => Either[String, Fish]
现在,我们可以用flatMap重写使用map的代码。将其写成for推导式是有意义的:
def goFishing(bestBaitForFishOrCurse: Either[String, String]): Either[String, Fish] = for {
baitName <- bestBaitForFishOrCurse
bait <- buyBait(baitName).fold(_ => makeBait(baitName), Right(_))
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
调用将一直进行,直到最后一个成功或其中一个产生Left。在第二种情况下,我们遇到的第一Left将被作为函数调用的结果返回。
在先前的例子中,我们使用了fold方法,它允许我们将给定的函数应用于Either的一侧。在我们的用例中,我们这样做是为了忽略卖家在商店返回的任何可能的错误消息,并自己制作诱饵。如果我们成功了,我们在返回之前将诱饵包裹在Right中,以便我们有正确的类型对齐。
fold方法是无偏的,因为它平等地对待Either的左右两侧。
在我们之前看到的最后一个例子中,我们用Either表示的模型,其左侧专门用于描述其操作期间发生的失败。拥有比String更具体的错误类型总是有用的。特别是在涉及与 Java 集成的案例中,最合适的选择可能是将错误表示为Exception的子类型。事实上,这在 Scala 中是如此普遍,以至于有一个特殊的效果叫做Try。一个左侧类型继承自Throwable的Either可以转换为Try,使用相应的方法:
def toTry(implicit ev: A <:< Throwable): Try[B] = this match {
case Right(b) => Success(b)
case Left(a) => Failure(a)
}
让我们考察一下在哪些情况下Try比Either更好,并学习如何使用它。
Try
就像Either代表可能结果的效应一样,Try表示函数抛出Exception的效应。在某种意义上,它只是Either的一个子集,但它如此常见,以至于它有自己的实现。不出所料,它的简化表示看起来相当熟悉:
sealed abstract class Try[+T]
final case class Success+T extends Try[T]
final case class Failure+T extends Try[T]
显然,Success代表操作的快乐路径结果,而Failure用于异常条件。Failure的内容类型被固定为Throwable的子类,因此我们回到了整个 ADT 的单个类型参数,这与Option类似。
我们将以与Option和Either相同的方式研究Try——通过创建、读取和抽象其效应。
创建一个 Try
由于与Either的相似性,Try的定义已经熟悉,创建其实例的方法也是如此。首先,我们可以使用案例类的构造函数直接创建实例:
scala> import scala.util._
import scala.util._
scala> Success("Well")
res1: scala.util.Success[String] = Success(Well)
scala> Failure(new Exception("Not so well"))
res2: scala.util.Failure[Nothing] = Failure(java.lang.Exception: Not so well)
Try背后的理念是它可以在通常抛出异常的场景中使用。因此,我们刚才提到的构造函数通常会形成以下模式:
try Success(System.console().readLine()) catch {
case err: IOError => Failure(err)
}
这将以try块的结果被包裹在Success中,以及catch异常被包裹在Failure中的方式结束。再次强调,stdlib已经在Try的伴生对象中实现了这种模式。apply方法接受一个单参数的 by-name 参数,用于try块,如下所示:
Try(System.console().readLine())
然后捕获所有NonFatal异常。
NonFatal代表一类开发者能够处理的异常。它不包括像OutOfMemoryError、StackOverflowError、LinkageError或InterruptedException这样的致命错误。这些错误在程序上处理没有意义。与NonFatal不匹配的另一组Throwables是scala.util.control.ControlThrowable,它用于内部控制程序流程,因此也不应该在捕获异常时使用。
通常会将多行块用花括号括起来作为Try构造函数的参数,使其看起来像是一种语言特性:
scala>val line = Try {
val line = System.console().readLine()
println(s"Got $line from console")
line
}
这个构造函数非常常见,它涵盖了绝大多数的使用场景。
现在,让我们看看如何从一个Try实例中获取值。
从Try中读取值
有多种方法可以处理这个任务。可以使用类似于Option的isDefined和isEmpty的方法,这些方法允许进行空指针检查风格的检查:
if (line.isSuccess) println(s"The line was ${line.get}")
if (line.isFailure) println(s"There was a failure")
显然,这种方法存在与Option相同的问题——如果我们忘记在提取之前检查结果是否为Success,调用.get将抛出异常:
scala> Try { throw new Exception("No way") }.get
java.lang.Exception: No way
at .$anonfun$res34$1(<console>:1)
at scala.util.Try$.apply(Try.scala:209)
... 40 elided
为了避免在捕获异常后立即抛出异常,有一个get版本允许我们为Try是Failure的情况提供一个默认参数:
scala> Try { throw new Exception("No way") }.getOrElse("There is a way")
res35: String = There is a way
不幸的是,没有像Option那样的接受谓词的方法。这是因为Try是从 Twitter 的实现中采纳的,并且首次在 Scala 2.10 版本的标准库中添加。
尽管如此,foreach回调仍然可用,并允许我们定义一个函数,该函数将在Success的值上执行:
scala> line.foreach(println)
Hi, I'm the success!
foreach方法将我们的讨论带到了Try的效果方面。
将Try作为效果
Try在用谓词过滤其结果方面提供了与Option相同的功能。如果谓词不成立,结果将表示为Failure[NoSuchElementException]。以我们之前的例子中的line定义为例,如下所示:
scala> line.filter(_.nonEmpty)
res38: scala.util.Try[String] = Success(Hi, I'm the success!)
scala> line.filter(_.isEmpty)
res39: scala.util.Try[String] = Failure(java.util.NoSuchElementException: Predicate does not hold for Hi, I'm the success!)
collect的工作方式相同,但它接受一个部分函数,并允许我们在过滤和转换Try的内容的同时进行:
scala> line.collect { case s: String => s * 2 }
res40: scala.util.Try[String] = Success(Hi, I'm the success!Hi, I'm the success!)
scala> line.collect { case s: "Other input" => s * 10 }
res41: scala.util.Try[String] = Failure(java.util.NoSuchElementException: Predicate does not hold for Hi, I'm the success!)
filter 和 collect 函数是 Success 有偏的,map 和 flatMap 也是如此。让我们在这个情况下重新实现钓鱼示例,其中我们的参数是 Try[String] 类型,异常正在替换字符串作为我们 Either 示例中的问题描述:
def goFishing(bestBaitForFishOrCurse: Try[String]): Try[Fish] =
bestBaitForFishOrCurse.map(buyBait).map(castLine).map(hookFish)
操作是在 Success 上链式的。再次强调,我们必须修复我们函数的签名,以便它们在结果的类型中编码每个步骤的错误可能性:
val buyBait: String => Try[Bait]
val makeBait: String => Try[Bait]
val castLine: Bait => Try[Line]
val hookFish: Line => Try[Fish]
现在,我们必须使用 flatMap 而不是 map 来对齐类型。再次强调,如果以 for 循环的形式表示,则更易于阅读:
def goFishing(bestBaitForFishOrCurse: Try[String]): Try[Fish] = for {
baitName <- bestBaitForFishOrCurse
bait <- buyBait(baitName).fold(_ => makeBait(baitName), Success(_))
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
这个实现几乎与我们的 Either 实现相同,唯一的区别在于我们现在必须将成功的调用包装到 Success 中,而不是 Right 中(我们必须使用不同的构造函数来表示效果)。
fold 是对 Try 无偏的方法之一。它接受传递 Success 和 Failure 的参数,如前述代码所示。另一个无偏方法是 transform,它与 fold 类似,但接受返回 Try 的函数作为参数。在某种意义上,transform 可以被称为 flatFold:
scala> line.transform((l: String) => Try(println(l)), (ex: Throwable) => Try(throw ex))
Hi, I'm the success!
res45: scala.util.Try[Unit] = Success(())
还有一些函数是 Failure 有偏的。
recover 和 recoverWith 将给定的部分函数应用于 Failure。它们基本上是 map 和 flatMaps 的对偶,但针对异常方面:
line.recover {
case ex: NumberFormatException => Math.PI
}
line.recoverWith {
case ex: NoSuchElementException => Try(retryAfterDelay)
}
orElse 方法允许我们以与 None 相同的方式链式连接失败:
val result = firstTry orElse secondTry orElse failure orElse success
如我们所见,Try 与 Option 和 Either 类似,因此它能够转换为 Option 和 Either[Throwable, _] 并不令人惊讶:
scala> line.toOption
res51: Option[String] = Some("Hi, I'm the success!")
scala> line.toEither
res53: scala.util.Either[Throwable,String] = Right("Hi, I'm the success!")
标准库中还有一个效果与前面我们看到的三个略有不同,因为它考虑了调用函数的一个更微妙方面——返回结果所需的时间。
未来
有时,我们调用的函数需要时间来返回计算的结果。通常,原因是有副作用,如从磁盘读取或调用慢速的远程 API。有时,操作本身需要大量的 CPU 时间才能完成。在这两种情况下,程序的主要流程都会停止,直到函数返回结果。在后一种情况下,如果计算后立即需要结果,等待结果可能是可以接受的(尽管在这种情况下也是次优的,因为它使系统无响应),但在前一种情况下是不希望的,因为这意味着我们的程序在什么也不做(好吧,等待计算机的其他子系统返回结果,但仍然与 CPU 无关)时消耗 CPU。通常,这样的长时间运行的操作是在单独的执行线程中执行的。
作为一名函数式程序员,我们希望将这些——即两个方面的内容,即调用的持续时间和代码在单独的线程中执行的事实——表达为一个效果。这正是Future所做的事情。更具体地说,它并不明确表示调用的持续时间,而是将其编码为二进制形式——一个操作要么运行时间较长且可能在单独的线程中运行,要么不运行。
Future是一个非常有趣的概念,值得单独用一整章来介绍。在这里,我们只是简要地看看它的一些方面。我们强烈建议您参考官方文档以获取更多详细信息。让我们再次应用我们无处不在的三步法,这次是为了Future。
创建一个 Future
Future不是编码为一个 ADT,因此我们必须使用由伴生对象提供的构造函数来构建它。因为我们将提供的代码将在单独的线程中执行,所以Future必须有一种方法来获取这个Thread。这是通过隐式地有一个ExecutionContext在作用域内来完成的,我们通过两个步骤导入它。首先,我们将导入作用域内的scala.concurrent包及其中的ExecutionContext:
scala> import scala.concurrent._
import scala.concurrent._
scala> import ExecutionContext.Implicits.global
import ExecutionContext.Implicits.global
ExecutionContext基本上是一个Thread的工厂。它可以根据特定的用例进行配置。为了演示目的,我们使用全局上下文,但通常不推荐这样做。请参阅www.scala-lang.org/api/current/scala/concurrent/ExecutionContext.html下的 ScalaDocs 以获取更多详细信息。
在作用域内有这个上下文的情况下,我们可以通过向其构造函数提供一个按名参数来构建一个Future:
val success = Future("Well")
val runningForever = Future {
while (true) Thread.sleep(1000)
}
Future在创建后立即开始执行,相对于从执行器获取线程所需的时间。
有时,我们只想将手头的值包装到Future中,以便代码期望一个Future作为参数。在这种情况下,我们不需要执行上下文,因为我们不需要进行任何计算。我们可以使用帮助成功创建它的特殊构造函数之一:failed和从Try创建的Future:
scala> val success = Future.successful("Well")
success: scala.concurrent.Future[String] = Future(Success(Well))
scala> val failure = Future.failed(new IllegalArgumentException)
failure: scala.concurrent.Future[Nothing] = Future(Failure(java.lang.IllegalArgumentException))
scala> val fromTry = Future.fromTry(Try(10 / 0))
fromTry: scala.concurrent.Future[Int] = Future(Failure(java.lang.ArithmeticException: / by zero))
此外,还有一个预定义的Future[Unit],可以通过映射作为间接构造函数使用:
scala> val runningLong = Future.unit.map { _ =>
| while (math.random() > 0.001) Thread.sleep(1000)
| }
runningLong: scala.concurrent.Future[Unit] = Future(<not completed>)
现在,既然我们在Future内部有一个值,让我们看看获取它的可能方法。
从 Future 读取值
由于Future不是作为 ADT 实现的,我们不能像在本章中查看的其他效果那样直接在它上进行模式匹配。
相反,我们可以使用空值检查风格:
scala> if (runningLong.isCompleted) runningLong.value
res54: Any = Some(Success(()))
幸运的是,value方法返回一个Option,在Future完成之前将是None,因此我们可以在模式匹配中使用它:
scala> runningForever.value match {
| case Some(Success(value)) => println(s"Finished successfully with $value")
| case Some(Failure(exception)) => println(s"Failed with $exception")
| case None => println("Still running")
| }
Still running
当然,最有用的方法不是与Future的值相关,而是与Future作为效果相关。
作为效果的未来
Future具有到目前为止我们已经从本章中了解到的所有常见功能。foreach允许我们定义在Future成功完成后执行的回调:
scala> runningLong.foreach(_ => println("First callback"))
scala> runningLong.foreach(_ => println("Second callback"))
scala> Second callback
First callback
执行顺序没有保证,如前一个示例所示。
还有一个回调会在任何完成的功能上被调用,无论其成功与否。它接受一个函数作为参数,该函数接受Try作为参数:
scala> runningLong.onComplete {
| case Success(value) => println(s"Success with $value")
| case Failure(ex) => println(s"Failure with $ex")
| }
scala> Success with ()
transform方法也在这两种情况下应用。它有两种形式。一种接受两个函数,分别对应Success和Failure,另一种接受一个函数Try => Try:
stringFuture.transform(_.length, ex => new Exception(ex))
stringFuture.transform {
case Success(value) => Success(value.length)
case Failure(ex) => Failure(new Exception(ex))
}
在这两种情况下,我们都会在成功的情况下将字符串转换为它的长度,在失败的情况下包装一个异常。然而,第二种变体更加灵活,因为它允许我们将成功转换为失败,反之亦然。
这种过滤也是以与其他效果相同的方式进行,即使用filter和collect方法:
stringFuture.filter(_.length > 10)
stringFuture.collect {
case s if s.length > 10 => s.toUpperCase
}
前者如果谓词不成立,则将Success转换为Failure(NoSuchElementException)(或者保留现有的Failure不变)。后者也会将Success的内容修改为大写。
当然,map和flatMap也是可用的。我们将让我们的用户服务使用Future作为效果——这次是为了表示每个动作,包括我们为鱼寻找最佳咬合名称的研究,都需要一些时间来完成:
val buyBait: String => Future[Bait]
val makeBait: String => Future[Bait]
val castLine: Bait => Future[Line]
val hookFish: Line => Future[Fish]
这使我们来到了以下已经熟悉的实现:
def goFishing(bestBaitForFish: Future[String]): Future[Fish] = for {
baitName <- bestBaitForFish
bait <- buyBait(baitName).fallbackTo(makeBait(baitName))
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
很容易看出,除了效果类型的变化外,与之前的实现相比,唯一的区别是使用了回退方法来在buyBait方法调用失败的情况下提供替代方案。
关于Future及其双胞胎Promise还有很多内容要介绍。我们鼓励您查看官方文档和相关博客文章(例如 viktorklang.com/blog/Futures-in-Scala-2.12-part-9.html),以了解一些高级用法的示例。
摘要
在本章中,我们讨论了标准库中定义的效果。首先是一个Option,它表示函数可能无法返回结果的情况。然后是Try,它通过在失败情况下返回错误描述来扩展可选性。接下来是Either,它通过允许提供任意类型作为失败路径的描述来进一步扩展Try的概念。最后是Future,它在列表中稍微独立一些,代表长时间可能在不同上下文中执行的计算。
我们注意到,这些效果有不同的构造函数,针对需要创建相应实例的情况进行了定制。相应地,它们提供了稍微不同的方式来访问容器内部存储的值。
我们注意到,将效果作为一个一等概念可以让我们不仅根据包含的值来定义方法,还可以根据效果本身来定义,这通常会导致更具有表现力的代码。
最重要的是,我们意识到许多方法,如filter、collect、map、flatMap等,从用户的角度来看是相同的,并为不同类型的效果诱导出相同的高级实现。我们通过实现四个统一示例来展示这一点,这些示例涉及在不同效果中编码的几个步骤来捕鱼。
在本书的后面部分,我们将确定导致这些相似性的基本概念。
我们还将探讨结合不同类型效果的话题,目前我们将其排除在讨论范围之外。
问题
-
如何表示获取列表的第一个元素的效果,例如获取推文列表?对于给定
userId的用户信息从数据库中获取呢? -
以下表达式的可能值范围是什么:
Option(scala.util.Random.nextInt(10)).fold(9)(_-1)? -
以下表达式的结果会是什么?
TryInt).filter(_ > 10).recover {
case _: OutOfMemoryError => 100
}
- 描述以下表达式的结果:
FutureInt).filter(_ > 10).recover {
case _: OutOfMemoryError => 100
}(20)
- 给定以下函数,以下调用
either(1)的结果会是什么?
def either(i: Int): Boolean =
Either.cond(i > 10, i * 10, new IllegalArgumentException("Give me more")).forall(_ < 100)
第七章:理解代数结构
在上一章中,我们查看了一些标准的 Scala 效果,并发现了它们之间许多相似之处。我们还承诺要深入挖掘,找出这些相似性背后的原则。
在深入抽象的海洋之前,让我们先去捕捉一些简单的概念,以便获得一些熟悉和操作它们的技能。
在本章中,我们将探讨一些抽象代数结构——这些结构完全由定义它们的法则所识别。我们将从一个更简单但可用的抽象开始,并逐步深入更复杂的话题。
在本章中,我们将探讨以下主题:
-
半群
-
单群
-
Foldable
-
群
技术要求
在我们开始之前,请确保您已安装以下内容:
-
Java 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter07。
抽象代数结构简介
抽象代数结构是由一组法则完全定义的东西。抽象代数结构的根源在于范畴论,这是数学的一个分支,致力于研究这些结构。
这个主题的“抽象性”对我们有两个后果。首先,我们需要进入一种特定的思维状态,并就一般事物进行讨论,而不是我们现在所讨论的具体实现。其次,我们将要查看的结构,即半群、单群、群和 Foldable,适用于广泛的案例,每个案例都可以导致抽象概念的实现。如果这听起来太理论化,请不要担心;我们将在 Semigroup 中很快变得实用。
半群
半群可能是最简单但最有用的代数结构。它完全由两个特性定义:
-
它定义了一些(可能是无限的)元素集合
-
它为这个集合中任意两个元素的任意对定义了二元运算
它还具有以下两个特性:
-
运算是封闭的,这意味着运算的结果属于与操作数相同的集合
-
运算是结合的,这意味着多次运算应该产生相同的结果,无论它们的操作顺序如何
我们几乎可以逐字逐句地将这些定义翻译成 Scala 代码:
trait Semigroup[S] {
def op(l: S, r: S): S
}
S 表示集合元素所属的类型,op 表示运算。结果类型也是 S——我们在类型级别上定义了运算的封闭性。形式上,可以说 S 在 op 下 构成一个半群。
熟悉第五章,“Scala 中的基于属性的测试”的读者也会记得我们提到结合律是制定ScalaCheck属性的一种方式。以我们之前提到的方式定义这个属性来检查第二个半群法是非常容易的:
import org.scalacheck._
import org.scalacheck.Prop._
def associativity[S : Semigroup : Arbitrary]: Prop =
forAll((a: S, b: S, c: S) => {
val sg = implicitly[Semigroup[S]]
sg.op(sg.op(a, b), c) == sg.op(a, sg.op(b, c))
})
我们需要S类型具有Semigroup和Arbitrary,前者我们需要获取要检查的实现,后者能够为S生成随机数据。
该属性本身,sg.op(sg.op(a, b), c) == sg.op(a, sg.op(b, c)),只是验证以不同顺序应用的操作会产生相同的结果——正如口头所述。
请注意,我们在这章中定义的属性也可以通过在 SBT 中直接运行测试来执行。为此,只需启动 SBT 会话,然后按Enter键输入 test。
好的,我们现在为S定义了一个半群以及检查半群是否正确实现的方法,但S是什么?嗯,这就是抽象代数数据结构的美丽之处。只要半群法则成立,S可以是任何东西。作为一个例子,我们可以坚持我们关于捕鱼的比喻。
我们可以为S指定至少两个定义在所有鱼上的eat操作。大鱼吃小鱼操作定义在两条鱼上,结果是较大的鱼的体积等于参与运算的两条鱼的体积之和。同样,重鱼吃轻鱼也是按照相同的原则定义的,但以鱼的重量为标准。很容易看出,这个半群的第一个属性按定义是成立的。以下图表代表了第二个属性的证明。第一个图表说明了大鱼吃小鱼/重鱼吃轻鱼操作的结合律:

图 1:大鱼吃小鱼/重鱼吃轻鱼
以下图表说明了“牙齿越多越有利”规则的相同属性:

图 2:牙齿越多越有利
最后的插图表明,结合律也适用于毒性属性:

图 3:有毒鱼类的组合
半群运算不一定是加法。例如,我们可以定义它,使得从两个参与者中,毒性最强的鱼位于左侧作为结果。这本书没有年龄限制,所以我们应该假装在这个过程中第二条鱼被吓跑了。但经验丰富的渔民知道这些生物——相反,我们必须将这个运算的半群约束在所有活鱼的集合中,这样我们就可以避免对运算结果的不确定性。
根据我们的类比,我们可以定义另一个以牙齿为术语的操作——操作结果中牙齿更大的鱼位于左侧。为了避免歧义,我们的半群由在这个操作下坚固、活着的鱼组成。
为了实现我们刚刚定义的半群,我们需要一个鱼的定义。让我们将其表示为一个案例类:
final case class Fish(volume: Int, size: Int, teeth: Int, poisonousness: Int)
相关属性表示为字段。
让我们从大鱼和小鱼的半群开始。我们之前定义的定律检查属性需要一个作用域内的隐式半群,所以我们将我们的实例标记为implicit:
implicit val volumeSemigroup: Semigroup[Fish] = (l: Fish, r: Fish) => {
val result = if (l.volume >= r.volume) l else r
result.copy(volume = r.volume + l.volume)
}
我们可以通过比较它们的体积来检查哪条鱼获胜。在操作过程中,大鱼包含了被吃掉的鱼的体积。
此时,你们中的一些人可能正在挠头,试图回忆为什么这个结构看起来如此熟悉。敏锐的读者已经在我们之前提到的第四章,“了解隐式和类型类”中识别出了类型类模式。干得好,敏锐的读者!
我们的实现如此简单,以至于不可能出错,但为了完整性,让我们为它定义一个属性。首先,我们需要一个Fish的生成器:
val fishGen: Gen[Fish] = for {
weight <- Gen.posNum[Int]
volume <- Gen.posNum[Int]
poisonousness <- Gen.posNum[Int]
teeth <- Gen.posNum[Int]
} yield Fish(volume, weight, teeth, poisonousness)
implicit val arbFish: Arbitrary[Fish] = Arbitrary(fishGen)
我们通过组合单个属性的生成器来实现它,正如我们在第四章,“了解隐式和类型类”中所做的那样。现在,定义检查本身归结为导入适当的隐式并将任务委托给之前定义的属性:
property("associativity for fish semigroup under 'big eats little'") = {
associativity[Fish]
}
本章中属性的定义不能粘贴到 REPL 中,因为它们需要放置在测试包装器中。请参阅附带的代码以了解如何操作。
运行这个属性的方式与第四章,“了解隐式和类型类”中相同,所以这里不应该有任何惊喜:
scala> associativity[Fish]
res1: org.scalacheck.Prop = Prop
scala> .check
! Falsified after 3 passed tests.
> ARG_0: Fish(2,3,2,1)
> ARG_1: Fish(2,3,3,3)
> ARG_2: Fish(3,2,3,2)
哎呀,结果发现还有惊喜!我们的实现不满足相同体积的鱼的情况下的结合律要求!
这个小案例展示了检查一个抽象代数结构的实现是否遵守为其定义的定律对于所有可能的输入值是多么重要。在我们的情况下,我们实际上还没有一个合适的半群实现。让我们通过假装大鱼包含了被吃掉鱼的全部属性来解决这个问题。这可能会让牙齿和毒液的数量看起来很奇怪,但毕竟我们是在谈论抽象:
final case class Fish(volume: Int, weight: Int, teeth: Int, poisonousness: Int) {
def eat(f: Fish): Fish = Fish(volume + f.volume, weight + f.weight, teeth + f.teeth, poisonousness + f.poisonousness)
}
这给我们提供了一个稍微简单一些的半群实现:
implicit val volumeSemigroup: Semigroup[Fish] = (l: Fish, r: Fish) =>
if (l.volume > r.volume) l.eat(r) else r.eat(l)
在鱼体积相等的情况下,右边的鱼获胜。这是一个随机选择,但这使我们的半群具有右偏置。
让我们重试我们的检查(如果你在 REPL 中玩代码,你需要再次粘贴fishGen和arbFish的定义以应用它们到新的Fish定义):
scala> associativity[Fish]
res10: org.scalacheck.Prop = Prop
scala> .check
+ OK, passed 100 tests.
这表明我们正在正确的道路上——它通过了。
我们可以通过类比定义和检查其他半群:
implicit val weightSemigroup: Semigroup[Fish] = (l: Fish, r: Fish) =>
if (l.weight > r.weight) l.eat(r) else r.eat(l)
implicit val poisonSemigroup: Semigroup[Fish] = (l: Fish, r: Fish) =>
if (l.poisonousness > r.poisonousness) l else r
implicit val teethSemigroup: Semigroup[Fish] = (l: Fish, r: Fish) =>
if (l.teeth > r.teeth) l else r
这段代码反映了我们之前关于鱼在操作期间不同属性如何工作的讨论。定义基本上总是相同的;它只是指代了鱼的不同属性!我们省略了ScalaCheck属性的定义,因为它们与我们之前查看的属性相同。
如果半群只在捕鱼领域有用,那将是个失望。让我们看看另一个例子——混合彩色形状:

图 3:不同透明度级别的彩色圆圈
我们可以选择以下操作之一来工作:
-
组合透明度
-
组合形状
-
组合颜色(在图像上表示为纹理)
这些在以下图中表示。第一个关注透明度:

图 4:组合透明度
第二个是关于以一致方式组合形状:

图 5:组合形状
这张最后的图表明,结合颜色(填充)会产生与之前相同的结果:

图 6:组合颜色(填充)
前面的图提供了证明结合律成立,并且闭包性质再次由定义成立的证据。
你有没有注意到捕鱼和形状示例之间的共性?我们选择实体的一个数值属性,并对此属性应用操作。例如,我们所有的捕鱼示例都可以简化为两种情况:整数加法(体积和重量)和整数比较(牙齿和毒性)。
当然,我们也可以为这些情况指定半群。数字在加法和乘法下都形成半群。我们可以用一个Int类型的例子来展示这一点:
implicit val intAddition: Semigroup[Int] = (l: Int, r: Int) => l + r
implicit val intMultiplication: Semigroup[Int] = (l: Int, r: Int) => l * r
property("associativity for int under addition") = {
import Semigroup.intAddition
associativity[Int]
}
property("associativity for int under multiplication") = {
import Semigroup.intMultiplication
associativity[Int]
}
这个定义与之前我们所拥有的完全类似,因此 SBT 中执行测试命令的结果也是如此:
+ Semigroup.associativity for int under addition: OK, passed 100 tests.
+ Semigroup.associativity for int under multiplication: OK, passed 100 tests.
字符串在连接下形成半群:
implicit val stringConcatenation: Semigroup[String] =
(l: String, r: String) => l + r
property("associativity for strings under concatenation") = {
import Semigroup.stringConcatenation
associativity[String]
}
这个半群完全像其他半群一样实现,但在概念上这与我们之前所拥有的略有不同。在String的情况下,操作不是基于某个属性,而是基于内容定义的。从某种意义上说,我们正在定义一个关于字符容器和操作的半群,操作被指定为以有序方式将两个容器的内容结合起来。
在我们的捕鱼领域,一个类似的例子是两种鱼的组合:一种含有鱼子酱,另一种含有牛奶,这会产生许多小鱼作为结果。只要我们谈论的是单条鱼,这就不能作为一个半群的例子,因为操作不是封闭的——我们期望得到一条鱼作为结果,但操作返回了许多条。如果我们开始谈论鱼桶,情况就不同了。将两个装有单条鱼的桶组合起来,将产生一个装满小鱼的大桶。这个操作是封闭的,如果我们能证明它是结合的,这将是一个有效的半群例子。
从单个项目到容器的视角转换还有另一个微妙的影响:现在可以为任何操作有一个空容器(桶)。在其中一个操作数是空容器的情况下,操作只返回另一个操作数作为结果。这使得我们的抽象更强大。它将半群转化为幺半群。
幺半群
幺半群是一个带有单位元素的半群。形式上,单位元素z是一个元素,对于任何x,方程z + x = x + z = x都成立。这个方程被称为单位属性。为半群定义的封闭性和结合性属性也必须对幺半群成立。
单位属性的存在要求我们实现幺半群,如下所示:
trait Monoid[S] extends Semigroup[S] {
def identity: S
}
我们为半群指定的检查也需要增强,以验证新的属性在幺半群中是否成立:
def identity[S : Monoid : Arbitrary]: Prop =
forAll((a: S) => {
val m = implicitly[Monoid[S]]
m.op(a, m.identity) == a && m.op(m.identity, a) == a
})
def monoidProp[S : Monoid : Arbitrary]: Prop = associativity[S] && identity[S]
现在,我们可以定义我们的第一个幺半群,它将把两个桶中的所有鱼放入一个桶中:
type Bucket[S] = List[S]
implicit val mergeBuckets: Monoid[Bucket[Fish]] = new Monoid[Bucket[Fish]] {
override def identity: Bucket[Fish] = List.empty[Fish]
override def op(l: Bucket[Fish], r: Bucket[Fish]): Bucket[Fish] = l ++ r
}
在这里,我们用List来表示Bucket,只是合并两个桶来表示两个桶的内容已经被合并在一起。你好奇要检查这个实现是否是一个幺半群吗?属性定义并不引人注目,因为它只是委托给之前定义的monoidProp:
implicit val arbBucketOfFish: Arbitrary[Bucket[Fish]] = Arbitrary(Gen.listOf(fishGen))
property("bucket of fish monoid") = {
import Monoid.mergeBuckets
monoidProp[Bucket[Fish]]
}
但是,下面有一套机制。首先,我们需要定义一个鱼桶的生成器,这样我们就可以用它来制定结合性和单位性的组合属性。幸运的是,这个属性是成立的:
scala> implicit val arbBucketOfFish: Arbitrary[Bucket[Fish]] = Arbitrary(Gen.listOf(fishGen))
arbBucketOfFish: org.scalacheck.Arbitrary[Monoid.Bucket[Fish]] = org.scalacheck.ArbitraryLowPriority$$anon$1@3dd73a3d
scala> monoidProp[Bucket[Fish]]
res13: org.scalacheck.Prop = Prop
scala> .check
+ OK, passed 100 tests.
幺半群是否只定义在容器上?不,绝对不是。容器只是一个特殊、舒适的例子,因为在大多数情况下,显然应该有一个单位元素作为相应幺半群的单位。
从容器上移开目光,回到上一节中的颜色示例,我们还可以为那里定义的操作选择一个单位元素,以将半群扩展到幺半群:
-
组合透明度需要一个完全透明的单位元素
-
组合形状有一个没有形状的单位——一个点
-
组合颜色可能有一个白色作为单位(这个单位元素会使颜色不那么饱和,但不会改变颜色本身)
我们甚至可以富有创意,指定一个适合所有这些操作的单位元素——一个完全透明的白色点。
那么,我们之前定义的其他半群呢?从数学上我们知道,自然数对于加法和乘法有单位元素,分别是零和一。这使我们能够将我们为整数实现的半群升级为单例:
implicit val intAddition: Monoid[Int] = new Monoid[Int] {
override def identity: Int = 0
override def op(l: Int, r: Int): Int = l + r
}
implicit val intMultiplication: Monoid[Int] = new Monoid[Int] {
override def identity: Int = 1
override def op(l: Int, r: Int): Int = l * r
}
这个定义与我们之前为半群定义的类似——我们只是向其中添加了单位元素。属性检查的实现也与之前相同。
显然,字符串在连接下也形成一个单例,正如我们之前注意到的,单位元素将是一个空容器——一个空白的String:
implicit val stringConcatenation: Monoid[String] = new Monoid[String] {
override def identity: String = ""
override def op(l: String, r: String): String = l + r
}
容器还有一个很好的特性,实际上不仅可以在整体上定义一个代数结构,还可以重用为其元素定义的现有代数结构。
这正是抽象代数结构的真正力量——它们可以组合!
为了演示这一点,让我们稍微作弊一下,定义一个无重量、无牙齿、无毒、体积为零的鱼作为我们钓鱼示例的单位元素。以下是“大鱼吃小鱼”单例的定义:
val ZeroFish = Fish(0,0,0,0)
implicit val weightMonoid: Monoid[Fish] = new Monoid[Fish] {
override def identity: Fish = ZeroFish
override def op(l: Fish, r: Fish): Fish =
if (l.weight > r.weight) l.eat(r) else r.eat(l)
}
对于其他三种情况,通过向所有这些情况添加一个ZeroFish作为单位元素来实现单例。
在作用域中有这个定义后,我们现在可以实施两个鱼桶的生存逻辑。首先,我们将从两个桶中形成一对鱼,然后这对鱼中的一条应该存活下来:
implicit def surviveInTheBucket(implicit m: Monoid[Fish]): Monoid[Bucket[Fish]] =
new Monoid[Bucket[Fish]] {
override def identity: Bucket[Fish] = List.fill(100)(ZeroFish)
override def op(l: Bucket[Fish], r: Bucket[Fish]): Bucket[Fish] = {
val operation = (m.op _).tupled
l zip r map operation
}
}
在这里,我们根据一个更简单的单例定义我们的单例。操作本身是由原始操作转换为元组形式并应用于两个鱼桶中的鱼对。这适用于大小小于或等于100的鱼桶,因为对于这个操作的单位桶,在组合的桶具有不同元素数量的情况下,需要包含足够的ZeroFish。
现在,我们只需通过在作用域中有一个所需单例的实例,就可以测试不同的生存策略:
property("props for survival in the bucket for most poisonousness") = {
import Monoid.poisonMonoid
import Monoid.surviveInTheBucket
monoidProps[Bucket[Fish]]
}
我们需要导入作用域内的两个单例,以便隐式解析正常工作。在这个例子中,从每对鱼中,毒性更强的鱼会存活下来。这可以通过引入不同的单例到作用域中轻松改变,例如,weightMonoid给较重的鱼提供了生存的机会:
scala> {
| import ch07.Monoid.weightMonoid
| import ch07.Monoid.surviveInTheBucket
| monoidProp[Bucket[Fish]]
| }
res3: org.scalacheck.Prop = Prop
scala> .check
+ OK, passed 100 tests.
我们可以检查并看到派生单例的性质是成立的。这甚至可以通过数学证明——通过压缩两个单例,我们创建了一个乘积单例,这已经被证明遵守单例法则。
单例和半群的一个有趣方面是它们能够将任何可迭代的集合简化为单个值。
例如,以下是从标准库中为IterableOnce定义的reduce方法:
def reduceB >: A => B): B
这接受一个关联二元操作符,并将其应用于集合的所有元素之间。类型签名告诉我们,它非常适合半群作为其操作满足此函数的要求:
scala> val bucketOfFishGen: Gen[List[Fish]] = Gen.listOf(fishGen)
bucketOfFishGen: org.scalacheck.Gen[List[Fish]] = org.scalacheck.Gen$$anon$1@34d69633
scala> val bucket = bucketOfFishGen.sample.get
bucket: List[Fish] = List(Fish(98,44,11,22), Fish(69,15,57,18), ...
scala> bucket.reduce(poisonSemigroup.op)
res7: Fish = Fish(25,6,29,99)
在前面的代码片段中,我们生成一个随机的鱼桶,然后通过指定reduce方法的参数来应用poisonSemigroup操作。
显然,只要类型匹配,任何半群操作都是完美的选择。
不幸的是,reduce的实现对于空集合抛出UnsupportedOperationException,这使得它不适合真正的函数式编程:
scala> List.empty[Fish].reduce(poisonSemigroup.op)
java.lang.UnsupportedOperationException: empty.reduceLeft
at scala.collection.IterableOnceOps.reduceLeft(IterableOnce.scala:527)
at scala.collection.IterableOnceOps.reduceLeft$(IterableOnce.scala:524)
at scala.collection.AbstractIterable.reduceLeft(Iterable.scala:759)
at scala.collection.IterableOnceOps.reduce(IterableOnce.scala:496)
at scala.collection.IterableOnceOps.reduce$(IterableOnce.scala:496)
at scala.collection.AbstractIterable.reduce(Iterable.scala:759)
... 40 elided
有一个名为reduceOption的reduce的兄弟函数,对于空集合返回None而不是抛出异常,但这使得整个结果类型变为可选。
在IterableOnce上定义了一些类似的方法。将它们组合在一起将允许我们表示“可折叠”属性作为一个独立的概念:
trait Reducible[A] {
@throws("UnsupportedOperationException ")
def reduceLeft(op: (A, A) => A): A
@throws("UnsupportedOperationException ")
def reduceRight(op: (A, A) => A): A
@throws("UnsupportedOperationException ")
def reduce(op: (A, A) => A): A = reduceLeft(op)
def reduceLeftOption(op: (A, A) => A): Option[A]
def reduceRightOption(op: (A, A) => A): Option[A]
def reduceOption(op: (A, A) => A): Option[A] = reduceLeftOption(op)
}
这看起来不太美观,因为我们已经讨论过,这些方法要么抛出异常,要么引入可选性的效果。我们如何改进这个实现呢?我们可以利用单例的恒等性质!
Foldable
单例恒等性质允许我们以通用方式处理空集合。所以,而不是有如下:
def reduceLeft(op: (A, A) => A): A
我们将有一个定义,它将恒等元素作为另一个参数。按照惯例,这种方法被称为fold:
def foldLeft(identity: A)(op: (A, A) => A): A
foldLeft这个名字的由来是因为将恒等元素用作减少集合的初始参数,这导致了以下调用序列:
op(op(op(op(identity, a1), a2), a3), a4), ...
可选地,它可以用后缀表示法表示:
(((identity op a1) op a2) op a3) ...
这实际上是将集合折叠起来,从其身份和第一个元素开始。
操作的关联性和恒等元素告诉我们,另一种方法也是可能的,即从集合的恒等元素和最后一个元素开始,然后向其头部移动:
(a1 op (a2 op (a3 op identity))) ...
沿着这个方向折叠自然被称为foldRight:
def foldRight(identity: A)(op: (A, A) => A): A
同样的属性也赋予了我们从任何地方折叠集合的能力!这在平衡折叠中尤其有用,因为它从两端工作:
(a1 op (a2 op identity)) op ((a3 op identity) op a4)
有趣的是,这两边都可以递归处理,即我们可以将它们各自分成两部分,然后再次使用平衡折叠。更有趣的是,由于左右两边是独立折叠的,折叠操作可以并行进行!
同样地,正如我们为Reducible所做的那样,我们可以将这些函数组合成另一个抽象,MonoidFoldable:
trait MonoidFoldable[A, F[_]] {
def foldRight(as: F[A])(i: A, op: (A,A) => A): A
def foldLeft(as: F[A])(i: A, op: (A,A) => A): A
def foldBalanced(as: F[A])(i: A, op: (A,A) => A): A
}
这次,我们将其定义为一种类型类,它能够折叠类型为F的集合,其元素类型为A。对于大多数现有的集合,此类型类的实例应该能够将foldLeft和foldRight的实现委托给F。让我们通过ListMonoidFoldable的一个实例来演示这一点:
implicit def listMonoidFoldable[A : Monoid]: MonoidFoldable[A, List] = new MonoidFoldable[A, List] {
private val m = implicitly[Monoid[A]]
override def foldRight(as: List[A])(i: A, op: (A, A) => A): A = as.foldRight(m.identity)(m.op)
override def foldLeft(as: List[A])(i: A, op: (A, A) => A): A = as.foldLeft(m.identity)(m.op)
override def foldBalanced(as: List[A])(i: A, op: (A, A) => A): A = as match {
case Nil => m.identity
case List(one) => one
case _ => val (l, r) = as.splitAt(as.length/2)
m.op(foldBalanced(l)(m.identity, m.op), foldBalanced(r)(m.identity, m.op))
}
}
首先,我们需要确保类型A是一个幺半群。然后,我们通过隐式调用通常的方法来获取它的实例。然后,我们通过在底层的List上调用相应的方法来实现foldRight和foldLeft。最后,我们以头递归的方式实现foldBalanced。这种实现将列表分成两半并独立折叠,正如我们之前所推理的那样。不过,它并不是并行完成的。
我们可以通过利用上一章中讨论的Future来改进这一点。我们引入了一个新方法foldPar,它接受一个额外的implicit ExecutionContext:
def foldPar(as: F[A])(i: A, op: (A,A) => A)(implicit ec: ExecutionContext): A
执行上下文需要在创建用于并行计算的Future时传递这个时刻。由于我们必须将集合分成两部分并递归折叠,所以方法的结构与balancedFold相似。这次,我们限制了并行折叠的集合中元素的最小数量,因为创建Future可能比以顺序方式折叠少量元素更耗费计算资源:
private val parallelLimit = 8
override def foldPar(as: List[A])(i: A, op: (A, A) => A)(implicit ec: ExecutionContext): Future[A] = {
if (as.length < parallelLimit) Future(foldBalanced(as)(i, op))
else {
val (l, r) = as.splitAt(as.length/2)
Future.reduceLeft(List(foldPar(l)(m.identity, m.op), foldPar(r)(m.identity, m.op)))(m.op)
}
}
为了简单起见,我们硬编码了适用于并行计算的最小元素数量,但这个值可以作为参数传递。在方法本身中,如果集合的长度小于限制,我们将在单独的线程中启动一个平衡的折叠;否则,我们将以之前相同的方式启动两个并行折叠,但这次我们使用Future.reduceLeft在两个计算完成时将它们组合在一起。
我们期望foldPar比其他折叠更快。让我们为这一点编写一个属性。这次的努力将比以前更复杂。原因是到目前为止,我们构建的幺半群非常简单,因此非常快。正因为如此,我们将无法看到并行化的优势——启动Future的成本将超过折叠本身。为了我们的目的,我们将通过向其中添加一个小延迟来使我们的幺半群变慢:
implicit val slowPoisonMonoid: Monoid[Fish] = new Monoid[Fish] {
override def identity: Fish = ZeroFish
override def op(l: Fish, r: Fish): Fish = {
Thread.sleep(1)
if (l.poisonousness > r.poisonousness) l else r
}
}
另一点是列表应该足够长,否则我们实际上不会真正测试并行化功能。我们需要创建一个长度在 100 到 1,000 之间的列表的专用生成器:
val bucketOfFishGen: Gen[List[Fish]] = for {
n <- Gen.choose(100, 1000)
gen <- Gen.listOfN(n, fishGen)
} yield gen
implicit val arbBucketOfFish: Arbitrary[Bucket[Fish]] = Arbitrary(bucketOfFishGen)
我们还需要一个helper方法来测量代码块的执行时间:
def withTime(block: => Fish): (Fish, Long) = {
val start = System.nanoTime()
val result = block
(result, (System.nanoTime() - start) / 1000000)
}
对于foldPar,我们同样需要一个隐式执行上下文。我们将使用全局的,因为它对我们的目的来说已经足够好了:
import scala.concurrent.ExecutionContext.Implicits.global
在完成所有准备工作后,我们可以制定我们的属性:
property("foldPar is the quickest way to fold a list") = {
import Monoid.slowPoisonMonoid
val foldable = MonoidFoldable.listMonoidFoldable[Fish]
forAllNoShrink((as: Bucket[Fish]) => {
...
})
}
在主体中,我们首先测量不同折叠方法的执行时间:
val (left, leftRuntime) = withTime(foldable.foldLeft(as))
val (right, rightRuntime) = withTime(foldable.foldRight(as))
val (balanced, balancedRuntime) = withTime(foldable.foldBalanced(as))
val (parallel, parallelRuntime) = withTime(Await.result(foldable.foldPar(as), 5.seconds))
foldPar返回一个Future[Fish]作为结果,所以我们正在等待它完成。最后,我们检查所有折叠的结果是否相同,以及并行折叠是否比其他方法花费更多时间。我们适当地标记这些属性:
s"${as.size} fishes: $leftRuntime, $rightRuntime, $balancedRuntime, $parallelRuntime millis" |: all(
"all results are equal" |: all(left == right, left == balanced, left == parallel),
"parallel is quickest" |: parallelRuntime <= List(leftRuntime, rightRuntime, balancedRuntime).min
)
})
现在,我们可以运行我们的测试,并在 SBT 会话中检查我们的实现是否符合预期:
+ MonoidFoldable.foldPar is the quickest way to fold a list: OK, passed 100 tests.
结果证明它确实如此!
不言而喻,可以定义MonoidFoldable类型类的其他实例。此外,集合不需要是线性的。一旦可以迭代其元素,任何结构都可以——二叉树、映射、包,或者任何更复杂的结构。能够抽象不同的数据结构并并行化计算的可能性使得幺半群在大数据和分布式场景中特别有用。
话虽如此,我们需要强调的是MonoidFoldable可以变得更加灵活和通用。要理解如何做到这一点,我们需要再次看看我们之前给出的折叠过程的定义:
(((identity op a1) op a2) op a3) ...
我们可以注意到,递归操作接受两个参数,并返回一个结果,该结果成为下一次迭代的第一个参数。这个观察导致了一个结论,即折叠函数不需要两个参数都是同一类型。只要返回类型与第一个参数的类型相同,并且与单位元素的类型相同,任何函数都适用于折叠。这允许我们定义一个更通用的Foldable抽象,它可以在转换集合元素的同时将它们组合:
trait Foldable[F[_]] {
def foldLeftA,B(z: B)(f: (B, A) => B): B
def foldRightA,B(z: B)(f: (A, B) => B): B
}
这种方法允许我们使用任何函数进行折叠,而不仅仅是幺半群。当然,仍然可以使用现有的幺半群定义,例如,通过定义一个接受幺半群作为隐式参数的方法:
def foldMapA,B : Monoid(f: A => B): B = {
val m = implicitly[Monoid[B]]
foldLeft(as)(m.identity)((b, a) => m.op(f(a), b))
}
这个定义依赖于存在某个互补函数f,它在元素可以结合使用幺半群操作之前,将集合的元素转换为适当的类型。
从相反的方向看,抽象代数结构并不止于幺半群。实际上,还有很多更高级的定义,比如群、阿贝尔群和环。
我们将简要地看看群的实现,以加强我们对代数结构主题的理解。
群
群给幺半群的性质添加了一个可逆性属性,这意味着对于定义在集合S上的每个元素a,其中群被定义,都有一个逆元素,使得对这两个元素的操作结果是单位元素。
用代码形式化,看起来如下:
trait Group[S] extends Monoid[S] {
def inverse(a: S): S
}
对于这个新定律的ScalaCheck属性看起来与我们为半群和幺半群定义的属性相似:
def invertibility[S : Group : Arbitrary]: Prop =
forAll((a: S) => {
val m = implicitly[Group[S]]
m.op(a, m.inverse(a)) == m.identity && m.op(m.inverse(a), a) == m.identity
})
就像我们之前做的那样,我们可以定义一个综合的单个属性检查:
def groupProp[S : Group: Arbitrary]: Prop = monoidProp[S] && invertibility[S]
通过向群添加一个commutative属性,我们将得到一个阿贝尔群。commutative属性表明,对于任何两个输入元素,参数的顺序并不重要。为了使一个群成为阿贝尔群,它不需要实现任何东西;它只需要满足这个额外的属性!
我们可以将我们的检查定义扩展以包含这一点:
def commutativity[S : Group : Arbitrary]: Prop =
forAll((a: S, b: S) => {
val m = implicitly[Group[S]]
m.op(a, b) == m.op(b, a)
})
def abelianGroupProp[S : Group: Arbitrary]: Prop =
groupProp[S] && commutativity[S]
再次,我们也定义了一个单一的综合属性,以一次性检查交换群的全部定律。
交换群的例子是加法下的整数。它可以通过扩展我们之前定义的幺半群来实现:
implicit val intAddition: Group[Int] = new Group[Int] {
override def identity: Int = 0
override def op(l: Int, r: Int): Int = l + r
override def inverse(a: Int): Int = identity - a
}
我们之前定义的属性可以帮助确保这确实是一个有效的交换群实现:
property("ints under addition form a group") = {
import Group.intAddition
groupProp[Int]
}
property("ints under addition form an abelian group") = {
import Group.intAddition
groupProp[Int]
}
我们将留给读者执行这些属性的任务,以检查我们的实现是否正确。
摘要
交换群的定义结束了我们对抽象代数结构的讨论;也就是说,这些结构仅由它们满足的定律定义。
我们研究了三种这样的结构:半群、幺半群和群。半群由一个封闭且结合的二元运算定义。幺半群在此基础上添加了一个单位元素,使得对它和另一个参数的操作返回第二个参数不变。群通过可逆性定律扩展了幺半群,表明对于每个元素,都应该存在另一个元素,使得它们之间的操作返回一个单位元素。如果群定义的运算是对称的,则该群称为阿贝尔群。
我们为所有这些代数方程提供了一个示例实现,以及ScalaCheck属性来验证我们的实现是否合理。
不言而喻,我们的代码仅用于演示目的,因此它相当简单。
在 Scala 生态系统中有至少两个主要的函数式编程库,其中我们讨论的概念被更严格地实现——cats (typelevel.org/cats/),和 scalaz (github.com/scalaz/scalaz)。它们有很好的文档,并且经常在博客和讨论中被提及,为对在实际项目中使用我们讨论的概念感兴趣的读者提供了坚实的基础。
在下一章中,我们将通过研究一般效果来锻炼我们的抽象能力,扩展我们在第六章,“探索内置效果”中开始填充的工具箱,引入新的概念。
问题
-
为什么交换性这一对幺半群至关重要的性质在分布式设置中有用?
-
实现一个在
OR下的Boolean幺半群。 -
实现一个在
AND下的Boolean幺半群。 -
给定
Monoid[A],实现Monoid[Option[A]]。 -
给定一个
Monoid[R],实现Monoid[Either[L, R]]。 -
将前两个实现推广到任何由
A参数化的效果,或者描述为什么这是不可能的。
进一步阅读
阿图尔·S·科特,《Scala 函数式编程模式》:掌握 Scala 中的函数式编程并有效执行
伊万·尼古洛夫,《Scala 设计模式》—— 第二版:学习如何使用 Scala 编写高效、简洁且可重用的代码
第八章:处理效果
在前两章中,我们的视角发生了相当大的转变。在第六章探索内置效果中,我们查看了一些标准库中可用的具体效果的实现。在第七章理解代数结构中,我们从实际跳到了理论,并玩转了抽象代数结构。
现在我们已经熟悉了使用由法律定义的抽象的过程,我们最终可以履行我们在第六章探索内置效果中给出的承诺,并识别出隐藏在我们在那里接触到的标准实现背后的抽象。
我们将定义和实现一个函子,这是一个在处理任何效果时都很有用的抽象概念。此外,我们还将有三种不同的风味,所以请保持关注!
到本章结束时,你将能够识别并实现或使用以下结构之一:
-
函子
-
适用函子
-
可遍历函子
技术要求
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter08。
函子
在前一章中,我们讨论了想要在容器内部组合元素的情况。我们发现,例如Reducible和Foldable这样的抽象可以帮助我们通过接受一个两个参数的函数并将其带入容器,以便可以在其中内部的元素对上应用它。作为一个例子,我们展示了这种方法如何使实现一桶鱼的不同的生存策略成为可能。
我们还没有涵盖的情况是我们不想在容器中组合元素,而是逐个对它们进行操作。这是函数式编程的精髓——将纯函数应用于参数并获取结果,然后重复这个过程,使用结果。通常,连续应用于参数的函数可以组合成一个单一函数,这在某种程度上是将所有中间步骤融合为一个步骤。
回到我们的鱼示例。让我们想象我们有一条鱼,我们想吃它。我们首先检查鱼是否健康且仍然新鲜,然后我们会以某种方式烹饪它,最后我们会消费它。我们可能会用以下模型来表示这个序列,扩展前一章中原始的Fish定义:
final case class Fish(volume: Int, weight: Int, teeth: Int, poisonousness: Int)
sealed trait Eatable
final case class FreshFish(fish: Fish)
final case class FriedFish(weight: Int) extends Eatable
final case class CookedFish(goodTaste: Boolean) extends Eatable
final case class Sushi(freshness: Int) extends Eatable
我们可能采取的行动将自然地表示为函数:
import ch08.Model._
import ch08.ModelCheck._
val check: Fish => FreshFish = f => FreshFish(f)
val prepare: FreshFish => FriedFish = f => FriedFish(f.fish.weight)
val eat: Eatable => Unit = _ => println("Yum yum...")
然后,我们可能想要组合我们的操作,使它们代表从新鲜到被吃掉的鱼的全过程:
def prepareAndEat: Fish => Unit = check andThen prepare andThen eat
现在,我们可以通过将组合函数应用于鱼来按需对鱼进行操作:
val fish: Fish = fishGen.sample.get
val freshFish = check(fish)
在这个例子中,我们使用了我们在前一章中定义的 Gen[Fish] 函数。如果您需要刷新对这个是如何完成的了解,请查阅 GitHub 仓库。
到目前为止一切顺利——我们满意且高兴。但如果我们有一个鱼桶,情况就会改变。突然之间,我们定义的所有函数都变得毫无用处,因为我们不知道如何将这些函数应用于桶内的鱼!我们现在该怎么办?
在桶内“工作”的要求可能听起来很奇怪,但这仅仅是因为我们的例子与实现脱节。在编程中,大多数时候,与集合一起工作意味着我们在应用操作后拥有相同的集合(尽管元素已更改)。此外,如果集合的 结构 被保留,那么我们之前提到的范畴论可以提供一些关于组合操作保证的保证,只要这些操作遵守所需的一组定律。我们已经看到了这是如何与抽象代数结构一起工作的,并且这个原则适用于从范畴论派生出的所有抽象。在实践中,保留集合结构的要求意味着操作不能改变集合的类型或其中的元素数量,也不能抛出异常。
事实上,有一个抽象可以帮助我们在这个情况下。
Functor 有一个 map 方法,它接收一个容器和一个函数,并将此函数应用于容器中的所有元素,最后返回具有相同结构但填充了新元素的容器。这就是我们如何在 Scala 中指定它的方式:
import scala.language.higherKinds
trait Functor[F[_]] {
def mapA,B(f: A => B): F[B]
}
F[_] 是容器的类型构造函数。map 本身接收一个容器和一个要应用的函数,并返回一个包含新元素的容器。我们也可以将 map 定义得稍微不同,以柯里化的形式:
def mapCA,B: F[A] => F[B]
在这里,mapC 接收一个名为 A => B 的函数,并返回一个名为 F[A] => F[B] 的函数,然后可以将其应用于容器。
由于这是一个抽象定义,我们自然会期望定义并满足一些定律——就像前一章一样。对于函子,有两个定律:
-
恒等律指出,对恒等函数进行映射不应改变原始集合
-
分配律要求对两个函数的连续映射应始终产生与对这两个函数组合进行映射相同的结果
我们将像前一章那样,将这些要求作为属性来捕捉。
首先,让我们看看恒等律:
def id[A, F[_]](implicit F: Functor[F], arbFA: Arbitrary[F[A]]): Prop =
forAll { as: F[A] => F.map(as)(identity) == as }
在这个属性中,我们使用了来自 第三章 的 identity 函数,深入函数的探讨,它只是返回其参数。
结合律稍微复杂一些,因为我们需要用随机函数来测试它。这需要大量的隐式函数可用:
import org.scalacheck._
import org.scalacheck.Prop._
def associativity[A, B, C, F[_]](implicit F: Functor[F],
arbFA: Arbitrary[F[A]],
arbB: Arbitrary[B],
arbC: Arbitrary[C],
cogenA: Cogen[A],
cogenB: Cogen[B]): Prop = {
forAll((as: F[A], f: A => B, g: B => C) => {
F.map(F.map(as)(f))(g) == F.map(as)(f andThen g)
})
}
在这里,我们正在创建任意的函数f: A => B和g: B => C,并检查组合函数的效果是否与连续应用这两个函数相同。
现在,我们需要一些函子来应用我们的检查。我们可以通过委托到定义在Option上的map函数来实现Functor[Option]:
implicit val optionFunctor: Functor[Option] = new Functor[Option] {
override def mapA, B(f: A => B): Option[B] = in.map(f)
def mapCA, B: Option[A] => Option[B] = (_: Option[A]).map(f)
}
实例定义为implicit,就像上一章一样,因此它代表一个类型类。
这个实现遵守必要的法则吗?让我们看看。本章中的属性在测试范围内定义,可以使用 SBT 中的test命令运行。它们不能作为 REPL 的独立部分粘贴,但只能作为Properties定义的一部分:
property("Functor[Option] and Int => String, String => Long") = {
import Functor.optionFunctor
functor[Int, String, Long, Option]
}
+ Functor.Functor[Option] and Int => String, String => Long: OK, passed 100 tests.
property("Functor[Option] and String => Int, Int => Boolean") = {
import Functor.optionFunctor
functor[String, Int, Boolean, Option]
}
+ Functor.Functor[Option] and String => Int, Int => Boolean: OK, passed 100 tests.
我们需要指定函子和函数的类型,以检查使其不可能——在我们的情况下——在一般情况下制定函子属性的法则。函数式编程库 cats 通过也为参数类型定义类型类来解决此问题。我们将坚持显式定义——这对我们的学习目的来说足够了。
我们也可以以相同的方式实现我们在第六章中看到的其他效果的函子,探索内置效果。Try的函子与效果类型相同。我们将把这个实现留给读者作为练习。
Either的情况要复杂一些,因为我们需要将它接受的两个类型参数转换为一个Functor类型构造函数期望的单个类型参数。我们通过将左侧的类型固定为L并在函子的定义中使用类型 lambda 来实现这一点:
implicit def eitherFunctor[L] = new Functor[({ type T[A] = Either[L, A] })#T] {
override def mapA, B(f: A => B): Either[L, B] = in.map(f)
def mapCA, B: Either[L, A] => Either[L, B] = (_: Either[L, A]).map(f)
}
有趣的是,实现本身再次相同。结果证明,这正是我们在第六章的探索内置效果结束时寻找的抽象。我们在第六章中讨论的所有标准效果都是函子!map方法定义中的可见差异来自于,对于标准效果,它是使用面向对象的多态定义的,而在我们的函子代码中,我们通过使用类型类的自定义多态来实现这一点。
让我们回到我们的鱼。因为我们有一个桶,它由List类型表示,所以我们需要一个Functor[Bucket]:
implicit val bucketFunctor: Functor[List] = new Functor[List] {
override def mapA, B(f: A => B): List[B] = in.map(f)
def mapCA, B: List[A] => List[B] = (_: List[A]).map(f)
}
定义再次与之前相同。然而,我们现在可以按照需要对桶中的鱼执行操作,重用bucketOfFishGen:
type Bucket[S] = List[S]
val bucketOfFishGen: Gen[List[Fish]] = Gen.listOf(fishGen)
val bucketOfFriedFish: Bucket[FriedFish] = ch08.Functor.bucketFunctor.map(bucketOfFishGen.sample.get)(check andThen prepare)
在这里,我们正在使用我们刚刚定义的函子来检查和准备桶内的鱼。我们实现的一个优点是桶可以是任何具有函子的类型。为了演示这一点,我们需要一个辅助函数,它将允许我们将函子作为第三个参数传递,连同我们在Functor.map定义中的两个参数一起:
def mapFunc[A, B, F[_]](as: F[A])(f: A => B)(implicit functor: ch08.Functor[F]): F[B] = functor.map(as)(f)
这个函数接受一个效果和一个函数,并隐式解析适当的函子。调用代码不再进行这种区分,因为我们通过使用不同的函数以相同的方式映射三种不同类型的效果:
import ch08.Functor._
import ch08.ModelCheck._
{
type Bucket[S] = Option[S]
mapFunc(optionOfFishGen.sample.get)(check)
}
{
type Bucket[S] = Either[Exception, S]
mapFunc(eitherFishGen.sample.get)(check andThen prepare)
}
{
type Bucket[S] = List[S]
mapFunc(listOfFishGen.sample.get)(prepareAndEat)
}
现在,它开始看起来像是一个有用的抽象——好吧,只要我们的愿望仅限于单参数函数。我们将在下一节中看到原因。
应用
通过函子,我们现在有了一种方便的方式来将函数应用到效果的内容中,无论效果本身的类型如何。我们能够通过应用与无效果鱼相同的逻辑来检查鱼并烹饪它。为了更熟悉函子,我们现在将使用新工具制作鱼馅饼。
首先,我们将定义一个从单个鱼制作馅饼的函数:
final case class FishPie(weight: Int)
import ch08.Model._
def bakePie(fish: FreshFish, potatoes: Int, milk: Float): FishPie = FishPie(fish.fish.weight)
这很简单——一条鱼,一个馅饼,鱼的大小。现在,我们准备好在桶中烘焙每条鱼:
mapFunc(listOfFishGen.sample.get)(bakePie)
哎呀!这不能编译,因为函子只接受单参数的函数,而我们有三个。
我们能做什么?一个可能性是对我们的函数进行重构和部分应用。我们还可以创建一个使用 mapC 将鱼桶转换为新鲜鱼桶的函数,以便我们可以进一步简化操作:
val freshFishMaker: List[Fish] => List[FreshFish] = ch08.Functor.bucketFunctor.mapC(check)
然后,我们可以使用部分应用函数来实现其余的逻辑:
def bucketOfFish: Bucket[Fish] = listOfFishGen.sample.get
def bakeFishAtOnce(potatoes: Int, milk: Float): FreshFish => FishPie =
bakePie(_: FreshFish, potatoes, milk)
val pie: Seq[FishPie] = mapFunc(freshFishMaker(bucketOfFish))(bakeFishAtOnce(20, 0.5f))
这是一个有效的方法,并且会起作用,但这种方法将为每条鱼使用相同数量的原料。如果采用这种策略,一些馅饼可能味道不太好。我们能做得更好吗?
好吧,我们可以将我们的原始函数转换为柯里化形式。这将给我们一个接受单个鱼然后是其他参数的函数:
def bakeFish: FreshFish => Int => Float => FishPie = (bakePie _).curried
val pieInProgress: List[Int => Float => FishPie] =
mapFunc(freshFishMaker(bucketOfFish))(bakeFish)
现在,我们希望使用另一个桶中的原料,以便我们可以添加到 pieInProgress 中。不幸的是,这是函子无法帮助我们的事情。如果我们尝试嵌套,对土豆桶和牛奶桶的映射调用,我们就会得到以下类似的结果:
mapFunc(pieInProgress) { (pieFactory: Int => Float => FishPie) =>
mapFunc(bucketOfPotatoes) { potato =>
mapFunc(bucketOfMilk) { milk =>
pieFactory(potato)(milk)
}
}
}
不幸的是,每个嵌套调用都会将结果留在嵌套的桶中,以至于即使最终能够编译,我们也会有三个嵌套的桶。我们的函子不知道如何从彼此中提取嵌套的桶。
能帮助我们的是 应用函子。有时也称为 应用函子,这个结构通过两个额外的函数扩展了原始函子:
trait Applicative[F[_]] extends Functor[F] {
def applyA,B(f: F[A => B]): F[B]
def unitA: F[A]
}
应用方法接受一个效果 a 和一个函数 f,该函数在相同的效果上下文中定义,并将 f 应用到 a 上,从而返回被相同效果包装的结果。
unit 方法允许我们将一个普通值 a 包装成效果。这通常被称为 提升,尤其是当 a 是一个函数时,因为它“提升”了原始值(或函数)到效果 F 的上下文中。
一个敏锐的读者会期待上述函数出现一些定律。你绝对是对的!有几个定律:
-
同一律指出,应用一个恒等函数应该返回未改变的参数,就像恒等函数所做的那样。这与函子恒等律类似,但这次是为
apply函数定义的。 -
同态律指出,将函数应用于值然后提升结果与首先提升这个函数和值然后在应用上下文中应用它们是相同的。
-
交换律指出,改变
apply方法参数的顺序不应该改变结果。 -
组合律指出,函数组合应该得到保留。
现在,这可能会开始听起来很抽象。让我们通过将它们作为属性来明确这些观点。
同一律是最简单的一个。唯一的注意事项是我们不能使用identity函数——我们必须明确指定unit方法的参数类型,因为没有编译器能为我们推断它:
def identityProp[A, F[_]](implicit A: Applicative[F],
arbFA: Arbitrary[F[A]]): Prop =
forAll { as: F[A] =>
A(as)(A.unit((a: A) => a)) == as
}
同态也不是非常引人注目——它实际上编码了我们用散文形式陈述的规则。类似于identityProp的情况,我们正在利用apply语法:
def homomorphism[A, B, F[_]](implicit A: Applicative[F],
arbA: Arbitrary[A],
arbB: Arbitrary[B],
cogenA: Cogen[A]): Prop = {
forAll((f: A => B, a: A) => {
A(A.unit(a))(A.unit(f)) == A.unit(f(a))
})
}
交换律是开始变得有趣的地方。我们将分别定义左右两侧以简化定义:
def interchange[A, B, F[_]](implicit A: Applicative[F],
arbFA: Arbitrary[F[A]],
arbA: Arbitrary[A],
arbB: Arbitrary[B],
cogenA: Cogen[A]): Prop = {
forAll((f: A => B, a: A) => {
val leftSide = A(A.unit(a))(A.unit(f))
val func = (ff: A => B) => ff(a)
val rightSide = A(A.unit(f))(A.unit(func))
leftSide == rightSide
})
}
左侧与同态定义相同——我们将某个随机函数和值提升到应用中。现在,我们需要改变f和a的顺序。f是一个一等值,所以我们在这方面没问题,但a不是一个函数。因此,我们定义了一个辅助函数func,它接受与f相同类型的参数并返回类型B。给定a,我们只有一种方式来实现这一点。有了这个辅助函数,类型就会对齐。最后,我们定义了rightSide,其中参数顺序已改变,并以比较它们的属性结束。
组合属性是最长的,因为我们必须定义我们即将组合的函数。首先,让我们将函数组合定义为函数:
def composeF[A, B, C]: (B => C) => (A => B) => (A => C) = _.compose
给定两个类型匹配的函数,composeF将通过委托给第一个参数的compose方法返回一个函数组合。
我们将再次分别定义属性的左右两侧:
def composition[A, B, C, F[_]](implicit A: Applicative[F],
arbFA: Arbitrary[F[A]],
arbB: Arbitrary[B],
arbC: Arbitrary[C],
cogenA: Cogen[A],
cogenB: Cogen[B]): Prop = {
forAll((as: F[A], f: A => B, g: B => C) => {
val af: F[A => B] = A.unit(f)
val ag: F[B => C] = A.unit(g)
val ac: F[(B => C) => (A => B) => (A => C)] = A.unit(composeF)
val leftSide = A(as)(A(af)(A(ag)(ac)))
val rightSide = A(A(as)(af))(ag)
leftSide == rightSide
})
}
右侧很简单——我们连续将提升的函数f和g应用于某个效果as。根据组合律,如果我们在一个应用中应用组合,这必须得到保留。这正是左侧所做的。最好是从右到左阅读它:我们将组合函数的函数提升到应用中,然后连续应用提升的f和g,但这次是在A内部。这给我们一个构建在应用内部的compose函数,我们最终将其应用于as。
对于一个有效的适用性,所有这些属性都必须成立,以及我们之前定义的函子属性,如下面的代码片段所示(未显示隐含参数):
identityProp[A, F] && homomorphism[A, B, F] && interchange[A, B, F] && composition[A, B, C, F] && FunctorSpecification.functor[A, B, C, F]
通过属性保护,我们可以为标准效果定义几个适用性的实例,就像我们为函子(functor)所做的那样。Option 可能是最容易实现的一个。不幸的是,我们无法像 map 那样委托给实例方法,所以我们不得不亲自动手:
implicit val optionApplicative: Applicative[Option] = new Applicative[Option] {
... // map and mapC are the same as in Functor
override def applyA, B(f: Option[A => B]): Option[B] = (a,f) match {
case (Some(a), Some(f)) => Some(f(a))
case _ => None
}
override def unitA: Option[A] = Some(a)
}
类型签名决定了实现。我们不能通过应用 f 到 a 来返回任何其他 Option[B]。同样,我们也不能从 unit 方法返回 Option[A]。请注意,我们在这两种情况下都使用 Some 构造函数而不是 Option 构造函数,这是为了在 null 参数或返回值的情况下保持结构。
Either 和 Try 的实现与效果类型非常相似。值得注意的是,我们的 Bucket 类型,它由 List 表示,相当不同:
implicit val bucketApplicative: Applicative[List] = new Applicative[List] {
... // map and mapC are the same as in Functor
override def applyA, B(f: List[A => B]): List[B] = (a, f) match {
case (Nil, _) => Nil
case (_, Nil) => Nil
case (aa :: as, ff :: fs) =>
val fab: (A => B) => B = f => f(aa)
ff(aa) :: as.map(ff) ::: fs.map(fab) ::: apply(as)(fs)
}
override def unitA: List[A] = List(a)
}
因为我们需要将所有函数应用到所有参数上,所以在我们的例子中以递归方式执行,将过程分为四个部分——处理两个第一个元素,第一个元素及其所有函数,所有元素和第一个函数,以及从两个列表中除了第一个元素之外的所有元素的递归调用。(注意:这并不是尾递归!)
使用 bucketApplicative,我们最终可以通过首先将其应用到 potato,然后应用到 milk 来完成我们的柯里 pieInProgress 函数:
def bakeFish: FreshFish => Int => Float => FishPie = (bakePie _).curried
val pieInProgress: List[Int => Float => FishPie] =
mapFunc(freshFishMaker(bucketOfFish))(bakeFish)
def pie(potato: Bucket[Int], milk: Bucket[Float]) =
bucketApplicative(milk)(bucketApplicative(potato)(pieInProgress))
scala> pie(List(10), List(2f))
res0: List[ch08.Model.FishPie] = List(FishPie(21), FishPie(11), FishPie(78))
这个定义是有效的,并产生了预期的结果——很好。但实现并没有显示出混合三种成分的意图,这并不那么好。让我们改进它。
实际上,有三种不同的有效方式可以用基本函数来定义一个适用性(applicative):
-
我们刚刚实现的,使用
apply和unit。 -
使用
unit和map2方法来定义它,使得map2A, B, C(f: (A, B) => C): F[C]。 -
使用
unit、map和product函数来定义它,使得productA, B: F[(A, B)]。
apply 和 map2 方法在功能上同样强大,因为我们可以用其中一个来实现另一个。同样的,对于 product 方法也适用,尽管它较弱,因为它需要一个已定义的 map 函数。
由于这些函数功能相同,我们可以在类型类定义中直接实现它们,这样它们就可在所有类型类实例上使用。map2 方法看起来是一个好的开始:
trait Applicative[F[_]] extends Functor[F] {
// ...
def map2A,B,C(f: (A, B) => C): F[C] =
apply(fb)(map(fa)(f.curried))
}
实现看起来很简单,有些令人失望——我们只是将转换成柯里形式的给定 f 依次应用到 fa 和 fb 上,这样我们就可以分两步应用它们。
很有趣的是,map2方法是如何用map实现的,这在某种程度上是一个低级的映射。好奇的读者可能会问,是否可能用另一个低级的函数实现map。结果是我们能这样做!以下是实现方式:
override def mapA,B(f: A => B): F[B] = apply(fa)(unit(f))
我们需要做的就是将给定的函数f提升到应用式的上下文中,并使用我们已有的apply函数。
以其他函数定义函数的方式在函数式编程中很常见。在抽象的底层,有一些提供所有其他定义基础的方法,不能定义为其他方法的组合。这些被称为原始的。我们正在讨论的应用式的三种风味通过它们选择的原始函数而不同。结果,我们的最初选择是它们中的第一个,即unit和apply方法。使用这些原始函数,我们能够用Applicative来定义Functor!在map的基础上定义一个Functor.mapC也是有意义的:
def mapCA,B: F[A] => F[B] = fa => map(fa)(f)
以这种方式推导实现的好处是,一旦原始函数被正确实现并遵守应用式(或函子)定律,推导出的实现也应该是有法的。
回到应用式的风味——我们仍然需要实现product方法,它从两个应用式中创建一个产品应用式:
def product[G[_]](G: Applicative[G]): Applicative[({type f[x] = (F[x], G[x])})#f] = {
val F = this
new Applicative[({type f[x] = (F[x], G[x])})#f] {
def unitA = (F.unit(a), G.unit(a))
override def applyA,B)(fs: (F[A => B], G[A => B])) =
(F.apply(p._1)(fs._1), G.apply(p._2)(fs._2))
}
}
这次,我们不得不再次使用类型 lambda 来表示两个类型F和G的乘积作为一个单一的类型。我们还需要将应用式的当前实例引用存储为F,这样我们才能稍后调用其方法。实现本身自然地用unit和apply原始函数表达。对于结果应用式,unit定义为F和G的单位乘积,而apply只是对给定参数使用apply方法的乘积。
不幸的是,我们仍然无法以一种非常可读的方式定义我们的pie函数。如果我们有map3,我们可以这样实现它:
def pie3[F[_]: Applicative](fish: F[FreshFish], potato: F[Int], milk: F[Float]): F[FishPie] =
implicitly[Applicative[F]].map3(fish, potato, milk)(bakePie)
显然,这种实现表达了一个非常清晰的意图:取三个装满成分的容器,对这些成分应用一个函数,然后得到一个装有派饼的容器。这对于任何有Applicative类型类实例的容器都适用。
好吧,我们已经知道如何从为抽象定义的原始函数推导函数。为什么我们不再次这样做呢?让我们开始吧:
def map3A,B,C,D(f: (A, B, C) => D): F[D] =
apply(fc)(apply(fb)(apply(fa)(unit(f.curried))))
嗯,这实际上是对map2函数的定义,只是增加了一个对第三个参数的apply调用!不用说,可以以这种方式为任何这种类型的任意数量实现mapN方法。我们也可以通过调用较小数量级的map以归纳方式定义它:
def map4A,B,C,D,E(f: (A, B, C, D) => E): F[E] = {
val ff: (A, B, C) => D => E = (a,b,c) => d => f(a,b,c,d)
apply(fd)(map3(fa, fb, fc)(ff))
}
我们只需要将提供的函数转换为可以提供所有但最后一个参数和最后一个参数单独的形式。
现在,既然我们有 pie3 的实现,我们必须停下来一会儿。我们需要告诉你一些事情。是的,我们必须承认我们在定义 check 函数时有点作弊。当然,我们不能每次有 Fish 就返回 FreshFish,就像我们之前做的那样:
lazy val check: Fish => FreshFish = f => FreshFish(f)
我们故意这样做,以便我们能够专注于 Applicative。现在,我们准备改进这一点。我们已经熟悉了可选性的概念,因此我们可以将这个函数改为返回 Option:
lazy val check: Fish => Option[FreshFish]
但让我们稍后决定它应该是哪种类型的效果。现在我们先叫它 F。我们需要两种可能性:
-
如果鱼不新鲜,则返回一个空的
F -
在其他情况下返回一个包含新鲜鱼的
F
在抽象方面,我们有一种方法可以将鱼提升到 F 中,一旦我们有了它的 applicative——applicative 会提供这个 unit。我们需要的只是一个空的 F[FreshFish],我们将将其作为函数的参数提供。
因此,我们新的检查定义将如下所示:
def checkHonestly[F[_] : Applicative](noFish: F[FreshFish])(fish: Fish): F[FreshFish] =
if (scala.util.Random.nextInt(3) == 0) noFish else implicitly[Applicative[F]].unit(FreshFish(fish))
将空的 F 作为单独的参数列表,将允许我们稍后部分应用此函数。前面的实现在大约 30%的情况下返回一个空的 F。我们要求编译器检查隐式的 Applicative 是否对 F 可用,正如所讨论的。如果是这种情况,我们的实现将委托给它来创建一个适当的结果。
好的,我们现在有了一种方法来区分新鲜鱼和其他鱼,但还有一个问题。我们的 pie3 函数期望所有原料都被包裹在相同类型的 applicative 中。这在函数式编程中很常见,我们将通过将其他参数提升到相同的容器中来克服这个障碍。我们可以像对鱼那样对土豆和牛奶进行新鲜度检查,但为了简单起见,我们假设它们总是新鲜的(抱歉,挑剔的读者):
def freshPotato(count: Int) = List(Some(count))
def freshMilk(gallons: Float) = List(Some(gallons))
val trueFreshFish: List[Option[FreshFish]] =
bucketOfFish.map(checkHonestly(Option.empty[FreshFish]))
在检查了所有原料的新鲜度后,我们可以使用我们现有的 pie3 函数,几乎就像我们之前做的那样:
import ch08.Applicative._
def freshPie = pie3[({ type T[x] = Bucket[Option[x]]})#T](trueFreshFish, freshPotato(10), freshMilk(0.2f))
差别在于我们需要帮助编译器识别正确的类型参数。我们通过使用类型 lambda 显式地定义容器的类型来实现这一点。然而,还有一个缺失的拼图。如果我们尝试编译前面的代码,它将失败,因为我们还没有 Applicative[Bucket[Option]] 的实例。
准备卷起袖子来实现它吗?好吧,尽管弄脏手没有什么不对,但我们不希望在每次想要组合它们时都实现一个新的 applicative。我们将要做的是定义一个通用的 applicative 组合,它本身也是一个 applicative。applicative 能够 组合 是它们最值得称赞的特性。让我们看看它是如何工作的。这是我们可以为我们的 Applicative[F] 实现它的方法:
def compose[G[_]](G: Applicative[G]): Applicative[({type f[x] = F[G[x]]})#f] = {
val F = this
def fab[A, B]: G[A => B] => G[A] => G[B] = (gf: G[A => B]) => (ga: G[A]) => G.apply(ga)(gf)
def fgB, A: F[G[A] => G[B]] = F.map(f)(fab)
new Applicative[({type f[x] = F[G[x]]})#f] {
def unitA = F.unit(G.unit(a))
override def applyA, B(f: F[G[A => B]]): F[G[B]] =
F.apply(a)(fg(f))
}
}
再次,我们不得不使用类型 lambda 来告诉编译器这实际上只是一个类型参数,而不是两个。unit方法的实现只是将一个应用包裹进另一个应用中。apply方法更复杂,我们将其实现为一个局部函数,以便更清楚地了解发生了什么。我们首先做的事情是将类型为G[A => B]的内部函数转换为类型G[A] => G[B]。我们通过在f包裹的“内部”函数上应用应用G来实现这一点。现在我们有了这个函数,我们可以调用外部应用的map函数,将结果包裹进F。最后,我们应用这个包裹好的组合函数到原始函数和结果函数上,即apply方法的原始参数。
现在,我们可以按我们的意愿组合这些应用:
implicit val bucketOfFresh: ch08.Applicative[({ type T[x] = Bucket[Option[x]]})#T] =
bucketApplicative.compose(optionApplicative)
然后使用这种组合来调用我们原始的馅饼制作逻辑:
scala> println(freshPie)
List(Some(FishPie(40)), None, Some(FishPie(36)))
这种方法的优点在于,它允许我们像以下人工示例一样,以任意嵌套的方式重用现有的逻辑:
import scala.util._
import ch08.Applicative
def deepX = Success(Right(x))
type DEEP[x] = Bucket[Try[Either[Unit, Option[x]]]]
implicit val deepBucket: Applicative[DEEP] =
bucketApplicative.compose(tryApplicative.compose(eitherApplicative[Unit].compose(optionApplicative)))
val deeplyPackaged =
pie3DEEP, freshPotato(10).map(deep), freshMilk(0.2f).map(deep))
在容器结构改变的情况下,我们只需要定义一个新的组合应用(以及一些语法辅助,如构造函数的类型别名,但这些不是必需的)。然后,我们就可以像之前一样使用现有的逻辑。这是在 REPL 中的结果:
scala> println(deeplyPackaged)
List(Success(Right(Some(FishPie(46)))), Success(Right(Some(FishPie(54)))), Success(Right(None)))
我们可以通过重新布线组合应用来轻松地改变结果的结构:
type DEEP[x] = Try[Either[Unit, Bucket[Option[x]]]]
implicit val deepBucket: Applicative[DEEP] =
tryApplicative.compose(eitherApplicative[Unit].compose(bucketApplicative.compose(optionApplicative)))
val deeplyPackaged =
pie3DEEP, deep(freshPotato(10)), deep(freshMilk(0.2f)))
我们改变了组合的顺序,现在结果看起来不同了:
scala> println(deeplyPackaged)
Success(Right(List(Some(FishPie(45)), Some(FishPie(66)), None)))
是否感觉结合应用(applicatives)能满足所有需求?嗯,从某种意义上说,确实如此,除非我们想要改变当前结果的结构。为了举例说明,让我们回顾一下我们为新鲜鱼制作的烘焙成果:List(Some(FishPie(45)), Some(FishPie(66)), None)。这是一个桶,里面要么有馅饼(如果鱼是新鲜的),要么如果没有,就是空的。但如果我们雇佣了一个新厨师,现在桶里的每条鱼都必须是新鲜的,否则整个桶就会被丢弃呢?在这种情况下,我们的返回类型将是Option[Bucket[FishPie]]——如果我们有一个装满新鲜鱼的桶,桶里就会装满馅饼,否则什么也没有。尽管如此,我们还想保留我们的厨房流程!这时,Traversable函子就登场了。
Traversable
Traversable函子与我们在上一章中讨论的Reducible和Foldable类似。区别在于,在Traversable上定义的方法在遍历过程中保留了底层结构,而其他抽象则将其折叠成单个结果。Traversable定义了两个方法:
import scala.{ Traversable => _ }
trait Traversable[F[_]] extends Functor[F] {
def sequence[A,G[_]: Applicative](a: F[G[A]]): G[F[A]]
def traverse[A,B,G[_]: Applicative](a: F[A])(f: A => G[B]): G[F[B]]
}
不幸的是,Scala 保留了一个过时的Traversable定义,这是从之前的版本遗留下来的,所以我们通过使用导入重命名来消除它。我们的Traversable定义了sequence和traverse方法,这些方法与在单例上定义的reduce和fold方法松散对应。从sequence方法开始,我们可以看到它将其参数“翻转”。这正是我们让新厨师高兴所需要的。让我们暂时跳过实现部分,看看它在实际中是如何工作的:
scala> println(freshPie)
List(None, None, Some(FishPie(38)))
scala>println(ch08.Traversable.bucketTraversable.sequence(freshPie))
None
一旦我们在列表中遇到None,我们就会得到作为结果的None。让我们再试一次:
scala> println(freshPie)
List(Some(FishPie(40)), Some(FishPie(27)), Some(FishPie(62)))
scala> println(ch08.Traversable.bucketTraversable.sequence(freshPie))
Some(List(FishPie(40), FishPie(27), FishPie(62)))
如果所有的鱼都是新鲜的,我们会得到一些预期的派,但我们对这种方法仍然不满意。原因是我们首先尽可能多地烤制所有新鲜派,然后在不是所有鱼都新鲜的情况下将它们丢弃。相反,我们希望在遇到第一只腐烂的鱼时立即停止。这正是traverse方法的作用。使用它,我们可以这样实现我们的烤制过程:
ch08.Traversable.bucketTraversable.traverse(bucketOfFish) { a: Fish =>
checkHonestly(Option.empty[FreshFish])(a).map(f => bakePie(f, 10, 0.2f))
}
在这里,我们正在遍历bucketOfFish。我们为此使用bucketTraversable。它期望一个名为Fish => G[?]的函数,这样G就是可应用的。我们可以通过提供一个名为Fish => Option[FishPie]的函数来满足这个要求。我们使用checkHonestly将一个Fish提升到Option[FreshFish],然后我们需要使用我们的原始bakePie方法来map它。
traverse是如何实现的?不幸的是,这个实现需要知道效果的结构,以便可以保留它。因此,它需要为类型类的每个实例实现,或者委托给另一个抽象,在这个抽象中保留这种知识,比如Foldable。
这是如何为Traversable[List]实现traverse方法的:
override def traverse[A, B, G[_] : Applicative](a: Bucket[A])(f: A => G[B]): G[Bucket[B]] = {
val G = implicitly[Applicative[G]]
a.foldRight(G.unit(List[B]()))((aa, fbs) => G.map2(f(aa), fbs)(_ :: _))
}
为了保留列表的结构,我们从空列表开始foldRight,在每次折叠迭代中使用map2来调用提供的函数,将原始列表的下一个元素提升到G,并将其附加到结果中。
对于Option,我们可以使用与fold类似的方法,但由于我们只需要处理两种情况,模式匹配实现可以更好地揭示意图:
implicit val optionTraversable = new Traversable[Option] {
override def mapA, B(f: A => B): Option[B] =
Functor.optionFunctor.map(in)(f)
override def traverse[A, B, G[_] : Applicative](a: Option[A])(f: A => G[B]): G[Option[B]] = {
val G = implicitly[Applicative[G]]
a match {
case Some(s) => G.map(f(s))(Some.apply)
case None => G.unit(None)
}
}
}
我们只是通过使用Option的不同状态的方法将Option提升到G的上下文中。值得注意的是,在非空Option的情况下,我们直接使用Some.apply来保留所需的结构。
好消息是第二种方法sequence比traverse弱。正因为如此,它可以直接在Traversable上根据traverse定义:
def sequence[A,G[_]: Applicative](a: F[G[A]]): G[F[A]] = traverse(a)(identity)
它只是使用identity函数返回G[A]的正确值,正如traverse所期望的。
作为一种函子,Traversables 也可以组合。compose函数将具有以下签名:
trait Traversable[F[_]] extends Functor[F] {
// ...
def compose[H[_]](implicit H: Traversable[H]): Traversable[({type f[x] = F[H[x]]})#f]
}
我们将把这个实现的任务留给读者。
这就是组合Traversable可以使生活变得更轻松的方式。还记得我们有争议的deeplyPackaged示例吗?这又是容器类型的模样:
type DEEP[x] = scala.util.Try[Either[Unit, Bucket[Option[x]]]]
你能想象遍历它并对它的元素应用一些逻辑吗?使用组合的Traversable,这绝对简单直接:
import ch08.Traversable._
val deepTraverse = tryTraversable.compose(eitherTraversable[Unit].compose(bucketTraversable))
val deepYummi = deepTraverse.traverse(deeplyPackaged) { pie: Option[FishPie] =>
pie.foreach(p => println(s"Yummi $p"))
pie
}
println(deepYummi)
我们首先将Traversable组合起来以匹配我们的嵌套类型。然后,我们遍历它,就像我们之前做的那样。请注意,我们省略了底层的Option类型,并将其作为遍历函数参数的包装类型。这是前面代码片段的输出:
Yummi FishPie(71)
Yummi FishPie(5)
Yummi FishPie(82)
Some(Success(Right(List(FishPie(82), FishPie(5), FishPie(71)))))
你感觉像拥有了超能力吗?如果你仍然没有这种感觉,我们将在下一章提供更多内容!
摘要
这是一章内容密集的章节。我们学习了以某种方式处理效果的概念,即效果的结构知识被外包给另一个抽象。我们研究了三种这样的抽象。
Functor允许我们将一个参数的函数应用到容器中存储的每个元素上。
Applicative(或应用函子)以扩展Functor的方式,使得应用一个有两个参数的函数(以及通过归纳,任何数量的参数的函数)成为可能。我们已经看到,可以选择三个同样有效的原始函数集来定义应用函子,并从这些原始函数中推导出所有其他方法。
我们说,定义一组最小原始函数以及将这些原始函数作为其他功能定义的方法是函数式编程中的一种常见方法。
我们最后看到的抽象是Traversable(或可遍历函子),它允许我们遍历效果,从而改变其内容,但保留底层结构。
我们特别关注了应用和遍历的组合。在实现了允许我们构建任意函子堆栈并使用这些堆栈直接到达核心的通用方法之后,我们能够重用那些以纯无效果类型定义的现有函数。
然而,我们没有展示的是,一个应用函子的数据如何影响堆栈中更深层次的函数调用——我们只是在使用示例中使用了常量参数。我们这样做的原因是应用不支持计算序列化。
在下一章,我们将学习另一种能够真正链式计算抽象——单子。
问题
-
实现
Functor[Try]。检查你的实现是否通过属性检查,就像在本章中做的那样。 -
实现
Applicative[Try]。检查你的实现是否通过属性检查,就像在本章中做的那样。 -
实现
Applicative[Either]。检查你的实现是否通过属性检查,就像在本章中做的那样。 -
实现
Traversable[Try]。 -
实现
Traversable[Either]。 -
实现
Traversable.compose,就像我们在本章末尾讨论的那样。
进一步阅读
-
Atul S. Khot,《Scala 函数式编程模式》:掌握和执行有效的 Scala 函数式编程
-
Ivan Nikolov,《Scala 设计模式》第二版:学习如何使用 Scala 编写高效、简洁且可重用的代码
第九章:熟悉基本单子
在上一章中,我们了解了 Functors,这是一个抽象,它给map方法赋予了标准库中定义的效果。回顾第六章,《探索内置效果》,这里仍然有些缺失——flatMap方法的来源,所有标准效果都有这个来源。
在本章中,我们终于将遇到单子的概念,这是定义flatMap的结构。为了深入了解这个函数,我们将实现四个不同的单子。
到本章结束时,你将熟悉以下主题:
-
抽象单子及其属性
-
为标准效果实现单子
-
以下基本单子的实现和应用:
-
Id
-
状态
-
读者
-
作者
-
技术要求
在我们开始之前,请确保你已经安装了以下内容:
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到,网址为github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter09。
单子简介
我们用了三章的篇幅才到达这样一个时刻,即我们准备讨论flatMap方法的起源,这与我们在第六章,《探索内置效果》中看到的效果有关。之所以需要这么多章节,并不是因为主题的复杂性,而是与之相关的抽象家族的丰富性。
在这个介绍之后,一个可疑的读者可能会失望地想——好吧,现在他们将要使用他们常用的伎俩,说有一个flatMap、flattener或flatMapative的抽象,从空中拉出一些法则,然后认为自己已经完成了。这些骗子!
好吧,从技术上讲,我们并没有作弊,因为我们并没有从任何地方拉取东西。相反,我们从范畴论中获取这些概念,这是我们之前提到的数学分支。我们的抽象必须遵守的规则是由数学家定义的。这种方法的优点在于——一旦我们能够证明我们的实现遵循所需的法则,我们就可以利用范畴论已经证明的一切来为我们所用。一个例子就是将两个应用项合并为一个,就像我们在上一章讨论的例子中那样。
回到我们的flatMap方法,这里还有一些神秘之处。抽象的名称是Monad,它由两个方法定义,flatMap和unit:
import scala.language.higherKinds
trait Monad[F[_]] {
def unitA: F[A]
def flatMapA, B(f: A => F[B]): F[B]
}
单子定义于类型F的容器。unit方法将它的参数提升到F的上下文中;flatMap在某种意义上类似于普通的map方法,它将f应用于a。flatMap与普通map的不同之处以及它之所以特殊的地方在于它能够折叠或扁平化两层F,将其合并为一层。这可以从a和f的类型签名中轻易看出。
将F[F[A]]扁平化为F[A]的可能性是为什么通常用一组不同的方法来表示 monads;也就是说,map和flatten:
trait Monad[F[_]] {
def unitA: F[A]
def mapA, B(f: A => B): F[B]
def flattenA: F[A]
}
flatten方法正是我们刚才所说的那样——它允许我们将 Fs 的堆栈减少到单个F。有时,flatten方法也被称为join。我们很快就会看到为什么这个名字是有意义的。
显然,我们与 monads 的情况与与 applicatives 的情况相同——我们可以选择原始函数集,并以原始函数的形式实现其余的功能。例如,flatMap与map和flatten的组合一样强大,我们可以选择这些组合中的任何一个。
让我们坚持最初的定义,并以flatMap为其他方法实现。这就是map将看起来像:
def mapA, B(f: A => B): F[B] =
flatMap(a)(a => unit(f(a)))
我们在这里所做的是基本上用函数f进行映射,并将结果以F上下文的形式提升,正如类型要求的那样。
你能记得那个以具有map方法的抽象体为特征的名称吗?对,这就是 functor。我们能够仅用flatMap来定义每个Monad的map的能力证明了每个Monad都是Functor。正因为如此,我们可以声明Monad extends Functor。
flatten方法的定义同样简单明了:
def flattenA: F[A] = flatMap(a)(identity)
使用identity函数,我们正在使用flatMap的一部分力量将两层F转换为一层,而实际上并没有对a做任何事情。
我们能否更进一步,将已经存在于F上下文中的函数应用到a上?结果是我们可以,而且我们知道这个方法——这就是在Applicative中定义的apply:
def applyA, B(f: F[A => B]): F[B] =
flatMap(f) { fab: (A => B) => map(a) { a: A => fab(a) }}
在这里,我们假装f是一个值,所以我们只需要将a表示为一个可以将此值应用于其上的函数。fab函数接受一个名为A => B的函数,我们用它来map原始的a,返回B,由于map的应用,它变成了F[B]。
apply函数也是针对每个 monad 以flatMap(以及从flatMap派生出的map)来定义的。这证明了每个Monad都是Applicative。因此,我们可以将Monad的定义改为以下形式:
trait Monad[F[_]] extends ch08.Applicative[F] {
def flatMapA, B(f: A => F[B]): F[B]
def flattenA: F[A] = flatMap(a)(identity)
override def unitA: F[A]
override def mapA, B(f: A => B): F[B] =
flatMap(a)(a => unit(f(a)))
override def applyA, B(f: F[A => B]): F[B] =
flatMap(f) { fab: (A => B) => map(a) { a: A => fab(a) }}
}
我们可以看到,flatMap方法仅对Monad可用,而不是对Applicative。这导致了有趣的后果,我们将在本章后面讨论。
现在,在切换到实现特定 monads 的实例之前,让我们首先讨论 monadic laws。
幸运的是,只有两个,而且它们都与我们在上一章中讨论的 functor laws 非常相似;即恒等性和结合性定律。
标识律指出,应用 flatMap 和 unit 应该返回原始参数。根据应用的顺序,存在左和右的标识律。我们将像往常一样使用 ScalaCheck 属性(以下代码片段未显示隐式参数;请参阅附带的代码以获取完整定义)来正式表示它们:
val leftIdentity = forAll { as: M[A] =>
M.flatMap(as)(M.unit(_)) == as
}
左标识律规定,通过将参数提升到单子的上下文中使用 flatMap 的结果应该等于原始参数。
右标识律稍微复杂一些:
val rightIdentity = forAll { (a: A, f: A => M[B]) =>
M.flatMap(M.unit(a))(f) == f(a)
}
基本上,规则是将 a 提升到上下文中,然后使用某个函数 f 进行扁平映射,应该产生与直接应用此函数到 a 相同的结果。
现在,我们只需要将这两个属性组合成一个单一的标识属性。我们需要相当多的不同 implicit Arbitrary 参数来生成输入数据,包括 A, M[A] 和 A => M[B],但属性本身应该不会令人惊讶:
import org.scalacheck._
import org.scalacheck.Prop._
def id[A, B, M[_]](implicit M: Monad[M],
arbFA: Arbitrary[M[A]],
arbFB: Arbitrary[M[B]],
arbA: Arbitrary[A],
cogenA: Cogen[A]): Prop = {
val leftIdentity = forAll { as: M[A] =>
M.flatMap(as)(M.unit(_)) == as
}
val rightIdentity = forAll { (a: A, f: A => M[B]) =>
M.flatMap(M.unit(a))(f) == f(a)
}
leftIdentity && rightIdentity
}
结合律属性表明,连续使用函数进行扁平映射应该与在单子上下文中应用函数相同:
forAll((a: M[A], f: A => M[B], g: B => M[C]) => {
val leftSide = M.flatMap(M.flatMap(a)(f))(g)
val rightSide = M.flatMap(a)(a => M.flatMap(f(a))(g))
leftSide == rightSide
})
我们将省略隐式参数的定义以及组合规则:
def monad[A, B, C, M[_]](implicit M: Monad[M], ...): Prop = {
id[A, B, M] && associativity[A, B, C, M]
}
请在 GitHub 上查找源代码以查看这些属性的完整签名。
既然我们已经了解了需要定义哪些方法和它们应该如何表现,让我们来实现一些单子!为了实现 flatMap,我们需要了解相应容器的内部结构。在前一章中,我们通过委托给底层容器实现了 map 方法。现在,我们将使用一种低级方法来展示确实需要了解结构知识。
正如往常一样,我们将从标准效果中最简单的一个开始,即 Option。这是我们实现 Monad[Option] 的方法:
implicit val optionMonad = new Monad[Option] {
override def unitA: Option[A] = Some(a)
override def flatMapA, B(f: A => Option[B]): Option[B] = a match {
case Some(value) => f(value)
case _ => None
}
}
unit 的实现应该是显而易见的——将 A 转换为 Option[A] 的唯一方法是通过包装。就像我们之前做的那样,我们直接使用案例类构造函数来保留在 a 为 null 的情况下的结构。
flatMap 的实现也非常透明——我们不能将给定的函数应用到 None 上,因此我们直接返回 None。在 a 已定义的情况下,我们解包值并应用 f 到它上。这种 解包 正是我们使用对 Option 内部结构的了解来扁平化可能嵌套结果的时刻。
我们可以通过为不同类型的 a 和 f 定义一些属性来检查我们的实现是否遵守单调律:这些属性需要放置在一个扩展 org.scalacheck.Properties 的类中,就像往常一样:
property("Monad[Option] and Int => String, String => Long") = {
monad[Int, String, Long, Option]
}
property("Monad[Option] and String => Int, Int => Boolean") = {
monad[String, Int, Boolean, Option]
}
+ Monad.Monad[Option] and Int => String, String => Long: OK, passed 100 tests.
+ Monad.Monad[Option] and String => Int, Int => Boolean: OK, passed 100 tests.
既然我们的属性对于两种不同的 a 类型以及两种不同的函数类型都成立,我们可以相当确信我们的代码是正确的,并继续处理其他容器。
对于 Either,我们遇到了一个小麻烦,就像我们定义它的 Functor 时一样——需要两个类型参数而不是 Monad 所需要的那个。你准备好以同样的方式处理它了吗——通过修复第二个类型参数并使用类型 lambda 来定义 monad 的最终类型?好消息是,我们不需要这样做!类型 lambda 是类型类编程中非常常见的东西,以至于许多人渴望有一个更简单的方式来完成这个任务。这就是插件被创建的原因。它允许我们在 Scala 中使用简化的语法来处理类型 lambda。
为了开始使用插件,我们只需要在我们的项目配置文件 build.sbt 中添加依赖项:
addCompilerPlugin("org.spire-math" %% "kind-projector" % "0.9.8")
现在我们有了这个,我们可以将我们的类型 lambda 语法从通常的 ({type T[A] = Either[L, A]})#T 简化为 Either[L, ?]。插件功能丰富,我们在这里不会进一步详细介绍;强烈建议访问文档页面 index.scala-lang.org/non/kind-projector/kind-projector/0.9.7。
使用我们的新工具,eitherMonad 的定义很容易阅读:
implicit def eitherMonad[L] = new Monad[Either[L, ?]] {
override def unitA: Either[L, A] = Right(a)
override def flatMapA, B(f: A => Either[L, B]): Either[L, B] = a match {
case Right(r) => f(r)
case Left(l) => Left(l)
}
}
类型类构造函数接受一个类型参数 L,用于 Either 的左侧。其余的实现现在应该非常熟悉了。值得提醒的是,Either 是右偏的——这就是我们从 unit 方法返回 Right 的原因。还值得提到的是在 flatMap 模式匹配中的最后一个情况,我们将从 Left[L, A] 中的 l 重新打包到 Left[L, B]。这样做是为了帮助编译器推断正确的返回类型。
对于属性定义,我们还需要修复左侧的类型。我们可以通过定义一个类型别名来完成这个任务,这将提高可读性:
type UnitEither[R] = Either[Unit, R]
property("Monad[UnitEither[Int]] and Int => String, String => Long") = {
monad[Int, String, Long, UnitEither]
}
property("Monad[UnitEither[String]] and String => Int, Int => Boolean") = {
monad[String, Int, Boolean, UnitEither]
}
除了类型别名之外,属性的定义与我们对 Option 的定义相同。
Monad[Try] 的定义是通过类比完成的,我们将把它留给读者作为练习。
相比之下,Monad[List](或者如果我们使用前一章的术语,是 Monad[Bucket])相当不同,因为列表可以包含多个元素:
implicit val listMonad = new Monad[List] {
def unitA = List(a)
def flatMapA,B(f: A => List[B]): List[B] = as match {
case Nil => Nil
case a :: as => f(a) ::: flatMap(as)(f)
}
}
unit 的实现方式与其他效果相同——只是通过包装它的参数。flatMap 是以递归方式定义的。对于 Nil,我们返回 Nil。这种情况类似于 Monad[Option] 中的 None 的情况。在非空列表的情况下,我们必须将给定的函数应用于列表的所有元素,并同时展平结果。这是在第二个匹配情况中完成的。
让我们看看我们的属性是否成立:
property("Monad[List] and Int => String, String => Long") = {
monad[Int, String, Long, List]
}
property("Monad[List] and String => Int, Int => Boolean") = {
monad[String, Int, Boolean, List]
}
+ Monad.Monad[List] and Int => String, String => Long: OK, passed 100 tests.
+ Monad.Monad[List] and String => Int, Int => Boolean: OK, passed 100 tests.
它看起来是这样的,但唯一的原因是 ScalaCheck 中的列表生成器不会生成具有显著大小的输入列表。如果它做到了,我们的属性会因为 StackOverflowError 而失败,因为这不是尾递归!
让我们通过使用在第三章,“深入函数”中讨论的技术来解决这个问题:
override def flatMapA,B(f: A => List[B]): List[B] = {
@tailrec
def fMap(as: List[A], acc: List[B])(f: A => List[B]): List[B] = as match {
case Nil => acc
case a :: aas => fMap(aas, acc ::: f(a))(f)
}
fMap(as, Nil)(f)
}
现在我们通过引入累加器使我们的实现尾递归,我们可以安全地使用任意长度的列表。但是,由于这种方法仍然相当直接且速度较慢,所以这种方法仍然相当直接且速度较慢。在我的笔记本电脑上,这个实现消耗了比 List 的“原生”优化实现flatMap方法大约五倍的时间。结果证明,这正是委托有意义的场合:
override def flatMapA,B(f: A => List[B]): List[B] = as.flatMap(f)
好吧,所以我们忽略了Future,但已经为我们在第六章,“探索内置效果”中讨论的所有容器实现了类型类实例。我们关于单子的任务完成了吗?结果证明,我们还没有——远远没有。就像只要适用性属性保持不变,就可以为不同的类型构造器定义无限数量的适用性一样,也可以用单子做同样的事情。
在接下来的部分,我们将一些广泛使用的单子,如 Id、State、Reader 和 Writer,放入代码中,并讨论它们有什么好处。
Id 单子
与Option编码可选性一样,Id代表没有什么特别之处。它包装了一个值,但对它不做任何事情。我们为什么需要不是东西的东西呢?Id是一种元提升,可以代表任何东西作为效果,而不改变它。这该如何做到呢?首先,我们必须告诉编译器Id[A]与A是同一件事。这可以通过一个类型别名轻松完成:
type Id[A] = A
这个类型定义将决定单子实现的细节:
implicit val idMonad = new Monad[Id] {
override def unitA: Id[A] = a
override def flatMapA, B(f: A => Id[B]): Id[B] = f(a)
}
显然,unit(a)只是a,通过我们刚刚定义的类型别名,我们让编译器相信它不是A类型,而是一个Id[A]。同样,对于flatMap,我们无法做任何花哨的事情,所以我们只是将给定的函数f应用到a上,利用Id[A]实际上只是A的事实。
显然,由于我们不做任何事情,单子定律应该成立。但为了 100%确定,我们将它们编码为属性:
property("Monad[Id] and Int => String, String => Long") = {
monad[Int, String, Long, Id]
}
property("Monad[Id] and String => Int, Int => Boolean") = {
monad[String, Int, Boolean, Id]
}
+ Monad.Monad[Id] and Int => String, String => Long: OK, passed 100 tests.
+ Monad.Monad[Id] and String => Int, Int => Boolean: OK, passed 100 tests.
属性是成立的——你还能期望从什么不做的事情中得到什么呢?但我们为什么一开始就需要这个“东西”呢?
这个问题有多个答案。从抽象的角度来看,Id单子承担了(惊喜!)在单子空间中的恒等元素的功能,就像零或一在加法或乘法下的数字空间中的恒等元素一样。正因为如此,它可以作为单子变换的占位符(我们将在下一章中了解它们)。在现有代码期望单子但不需要单子的情况下,它也可能很有用。我们将在本章后面看到这种方法是如何工作的。
现在我们已经用最简单的单子打好了基础,是时候做一些更复杂的事情了——实现 State 单子。
State 单子
在命令式编程中,我们有全局变量的概念——在程序中的任何地方都可以访问的变量。这种方法被认为是一种不好的做法,但仍然被相当频繁地使用。全局状态的概念通过包括系统资源扩展了全局变量的概念。由于只有一个文件系统或系统时钟,因此从程序代码的任何地方都可以全局和普遍地访问它们,这是完全有道理的,对吧?
在 JVM 中,一些这些全局资源可以通过java.lang.System类获得。例如,它包含对“标准”输入、输出和错误流的引用,系统计时器,环境变量和属性。那么,如果 Java 在语言级别上公开全局状态,这绝对是一个好主意!
全局状态的问题在于它破坏了代码的引用透明性。本质上,引用透明性意味着应该始终可以在程序中的任何地方用评估的结果替换代码的一部分,例如函数调用,并且这种更改不应该导致程序行为可观察的变化。
引用透明性的概念与纯函数的概念密切相关——一个函数是纯的,如果对于所有引用透明的参数,它都是引用透明的。
我们将在稍后看到它是如何工作的,但首先,请考虑以下示例:
var globalState = 0
def incGlobal(count: Int): Int = {
globalState += count
globalState
}
val g1 = incGlobal(10) // g1 == 10
val g2 = incGlobal(10) // g1 == 20
在incGlobal的情况下,该函数不是纯函数,因为它不是引用透明的(因为我们不能用它的评估结果替换它的调用,因为这些结果每次调用函数时都不同)。这使得在没有知道每次访问或修改全局状态的情况下,无法对程序的可能的输出进行推理。
相比之下,以下函数是引用透明的且是纯函数:
def incLocal(count: Int, global: Int): Int = global + count
val l1 = incLocal(10, 0) // l1 = 10
val l2 = incLocal(10, 0) // l2 = 10
在函数式编程中,我们期望只使用纯函数。这使得全局状态作为一个概念不适合函数式编程。
但是,仍然有许多情况下需要积累和修改状态,但我们应该如何处理这种情况呢?
这就是State单子发挥作用的地方。状态单子是围绕一个函数构建的,该函数将相关部分的全局状态作为参数,并返回一个结果和修改后的状态(当然,不会在全局意义上改变任何东西)。此类函数的签名看起来像这样:type StatefulFunction[S, A] = S => (A, S)。
我们可以将这个定义包装成一个案例类,以简化对其辅助方法的定义。这个State类将表示我们的效果:
final case class StateS, A)
我们还可以在伴随对象中定义几个构造函数,以便在三种不同情况下创建状态(要在 REPL 中这样做,您需要使用:paste命令粘贴案例类和伴随对象,然后按Ctrl + D:
object State {
def applyS, A: State[S, A] = State(s => (a, s))
def get[S]: State[S, S] = State(s => (s, s))
def setS: State[S, Unit] = State(_ => ((), s))
}
默认构造函数通过返回给定的参数作为结果,并传播现有的状态而不做任何改变,将一些值a: A提升到State的上下文中。获取器创建一个State,它包装了一个函数,返回给定的参数既作为状态也作为结果。设置器将State包装在函数上,该函数接受一个要包装的状态并产生没有结果。这些语义与读取全局状态(因此结果是相同的状态)和设置它(因此结果是Unit)相似,但应用于s: S。
目前,State只是围绕一些涉及通过(并可能改变)一些状态的计算的一个薄包装。我们希望能够将这个计算与下一个计算组合起来。我们希望以与组合函数类似的方式做到这一点,但现在我们有State[S, A]和State[S, B]。我们该如何做到这一点?
根据定义,我们的第二次计算接受第一次计算的结果作为其参数,因此我们以(a: A) =>开始。我们还指出,由于可能的状态变化和第二个状态的返回类型,我们将有一个State[S, B],这为我们提供了与第一个计算组合的完整签名:f: A => State[S, B]。
我们可以将这种组合实现为State上的一个方法:
final case class StateS, A) {
def composeB: State[S, B] = {
val composedRuns = (s: S) => {
val (a, nextState) = run(s)
f(a).run(nextState)
}
State(composedRuns)
}
}
我们将组合的计算定义为两次运行的组合。第一次使用提供给第一个状态的输入,我们将它分解为结果和下一个状态。然后我们在结果上调用提供的转换f,并使用下一个状态来运行它。这两次连续的运行一开始看起来可能有些奇怪,但它们只是表示我们将来自不同状态的两个run函数融合为一个定义在组合状态上的函数。
现在,我们有一个效果,可以为其创建一个单子。你应该已经注意到,我们刚刚定义的compose方法的签名与单调的flatMap方法的签名相同。
在本例及以下情况中的compose并不指代我们在第三章“深入函数”中学习的函数组合,而是指 Kleisli 组合的概念。它通常被称为 Kleisli 箭头,本质上只是A => F[B]函数的一个包装,允许对返回单调值的函数进行组合。它通常命名为>>=,但在这里我们将坚持使用compose。
这允许我们将单调行为委托给我们在State中已有的逻辑,就像我们可以为标准效果做的那样:
import ch09._
implicit def stateMonad[S] = new Monad[State[S, ?]] {
override def unitA: State[S, A] = State(a)
override def flatMapA, B(f: A => State[S, B]): State[S, B] = a.compose(f)
}
幸运的是,我们还可以将unit所做的提升委托给默认构造函数!这意味着我们已经完成了单子的定义,可以继续使用严格的测试方法,通过指定对其的属性检查来继续。
除了这种情况,我们不会这么做。
其背后的原因是State与到目前为止我们所考虑的其他效果在所包含的值方面相当不同。State是第一个完全围绕某个函数构建的效果。技术上,因为函数在 Scala 中是一等值,其他效果如Option也可以包含一个函数而不是一个值,但这是一种例外。
这给我们的测试尝试带来了复杂性。以前,我们以不同的方式修改了效果中的值,并通过比较它们来检查结果是否相等,这是符合单调律的要求。现在,我们需要将函数作为效果的值,这就面临了比较两个函数是否相等的问题。在撰写本书时,这是一个活跃的学术研究课题。就我们的实际目的而言,目前还没有其他方法可以证明两个函数相等,除了测试它们对每个可能的输入参数(s)并检查它们是否返回相同的结果——这显然是我们无法承担的。
相反,我们将证明我们的实现是正确的。我们将使用一种称为替换模型的方法来做这件事。该方法的核心在于使用引用透明性,用它们返回的值来替换所有的变量和函数调用,直到结果代码不能再简化为止——这非常类似于解代数方程。
让我们看看这是如何工作的。
在证明单调律之前,我们将首先证明一个有用的引理。
该引理表述如下:对于as: M[A], f: A => M[B]和M = State,使得as.run = s => (a, s1)(run方法返回一对a和s1,对于某个输入s和f(b) = (b: A) => State(s1 => (b, s2))),M.flatMap(as)(f)将始终产生State(s => (b, s2))。
这就是我们的公式是如何得出来的:
-
根据定义,
as.run = s => (a, s1),这给我们as = State(s => (a, s1))。 -
flatMap方法委托给定义在State上的compose方法,因此对于M = State,M.flatMap(a)(f)变为a.compose(f)。 -
在
as和f的术语中,as.compose(f)可以表示为State(s => (a, s1)).compose(f)。
现在,我们将用其定义来替换compose方法的调用:
State(s => (a, s1)).compose(f) = State(s => {
f(a).run(s1) // substituting f(a) with the result of the call
}) = State(s => {
State(s1 => (b, s2)).run(s1)
}) = State(s => (b, s2))
在这里,我们已经证明了我们的假设,即对于as = State(s => (a, s1))和f(a) = (b: A) => State(s1 => (b, s2)),Monad[State].flatMap(as)(f) = State(s => (b, s2))。
现在,我们可以在证明State的单调律时使用这个引理。
我们将从恒等律开始,更具体地说,从左恒等律开始。这是我们如何在ScalaCheck属性中表述它的:
val leftIdentity = forAll { as: M[A] =>
M.flatMap(as)(M.unit(_)) == as
}
因此,我们想要证明的是,如果我们让M = State,那么随后的每个as: M[A]总是正确的:
M.flatMap(as)(M.unit(_)) == as
让我们先简化等式的左边。根据定义,我们可以用State实现来替换as:
M.flatMap(State(s => (a, s1)))(M.unit(_))
我们必须做的下一步是替换 unit 方法的调用及其实现。我们只是在委托给 State 的默认构造函数,它定义如下:
def applyS, A: State[S, A] = State(s => (a, s))
因此,我们的定义变成了以下形式:
M.flatMap(State(s => (a, s1)))(b => State(s1 => (b, s1)))
为了替换 flatMap 调用,我们必须记住它所做的只是委托给定义在 State 上的 compose 方法:
State(s => (a, s1)).compose(b => State(s1 => (b, s1)))
现在,我们可以使用我们的状态组合引理,这给我们以下简化的形式:
State(s => (a, s1))
这不能再简化了,所以我们现在将查看等式的右侧,as。同样,根据定义,as 可以表示为 State(s => (a, s1))。这给我们最终的证明,即 State(s => (a, s1)) == State(s => (a, s1)),这对于任何 a: A 总是成立的。
右侧的恒等性证明与左侧类似,我们将其留给读者作为练习。
我们需要证明的第二条定律是结合律。让我们回顾一下在 ScalaCheck 术语中它是如何描述的:
forAll((as: M[A], f: A => M[B], g: B => M[C]) => {
val leftSide = M.flatMap(M.flatMap(as)(f))(g)
val rightSide = M.flatMap(as)(a => M.flatMap(f(a))(g))
leftSide == rightSide
})
让我们看看我们能用它做什么,从 leftSide 开始,M.flatMap(M.flatMap(as)(f))(g)。
通过在内部部分将 M 替换为 State,M.flatMap(as)(f) 变成了 State(s => (a, s1)).compose(f),通过应用我们的引理,它变成了 State(s => (b, s2))。
现在,我们可以替换外部的 flatMap:
M.flatMap(State(s => (b, s2)))(g) 等同于 State(s => (b, s2)).compose(g) (1)。
让我们保持这种形式,并查看rightSide:M.flatMap(as)(a => M.flatMap(f(a))(g))。
首先,我们将内部 flatMap 替换为 compose,然后再将 (a: A) => M.flatMap(f(a))(g) 转换为 (a: A) => f(a).compose(g)。
现在,根据我们用于左侧的 f 的定义,我们有 f(a) = a => State(s1 => (b, s2)),因此内部 flatMap 变成了 a => State(b, s2).compose(g)。
将外部的 flatMap 替换为 compose,结合先前的定义,我们得到 State(s => (a, s1)).compose(a => State(s1 => (b, s2)).compose(g))。
我们将再次使用我们的引理来替换第一次应用的 compose,这将得到 State(s => (b, s2)).compose(g) 作为结果。(2)。
(1) 和 (2) 是相同的,这意味着我们属性的 leftSide 和 rightSide 总是相等的;我们刚刚证明了结合律。
太好了,我们已经实现了 State 和相应的 monad,并且已经证明它是正确的。现在是时候看看它们在实际中的应用了。作为一个例子,让我们想象我们正乘船去钓鱼。这艘船有一个位置和方向,可以在一段时间内前进或改变方向:
final case class Boat(direction: Double, position: (Double, Double)) {
def go(speed: Float, time: Float): Boat = ??? // please see the accompanying code
def turn(angle: Double): Boat = ??? // please see the accompanying code
}
我们可以通过调用其方法来绕着这条船走:
scala> import ch09._
import ch09._
scala> val boat = Boat(0, (0d, 0d))
boat: Boat = Boat(0.0,(0.0,0.0))
scala> boat.go(10, 5).turn(0.5).go(20, 20).turn(-0.1).go(1,1)
res1: Boat = Boat(0.4,(401.95408575015193,192.15963378398988))
然而,这种方法有一个问题——它不包括燃油消耗。不幸的是,在开发船的导航时,这个方面没有被预见,后来作为全局状态添加。现在,我们将使用状态单子重构旧风格。如果燃油量被建模为升数,定义状态的最直接方式如下:
type FuelState = State[Float, Boat]
现在,我们可以定义我们的船移动逻辑,该逻辑考虑了燃油消耗。但在做之前,我们将稍微简化一下我们的单子调用语法。目前,我们的单子 flatMap 和 map 方法接受两个参数——容器和应用于容器的函数。
我们希望创建一个包装器,它将结合效果和单子,这样我们就有了一个效果实例,并且只需要传递转换函数给映射方法。这就是我们表达这种方法的途径:
object lowPriorityImplicits {
implicit class MonadF[A, F[_] : Monad](val value: F[A]) {
private val M = implicitly[Monad[F]]
def unit(a: A) = M.unit(a)
def flatMapB: F[B] = M.flatMap(value)(fab)
def mapB: F[B] = M.map(value)(fab)
}
}
当 F 有隐式单子定义可用时,隐式转换 MonadF 将任何效果 F[A] 包装起来。有了 value,我们可以将其用作定义在单子上的 flatMap 和 map 方法的第一个参数——因此,在 MonadF 的情况下,它们被简化为接受单个参数的高阶函数。通过导入这个隐式转换,我们现在可以直接在 State 上调用 flatMap 和 map:
StateFloat, Boat.flatMap((boat: Boat) => StateFloat, Boat)
我们还需要创建一些纯函数,在移动船时考虑燃油消耗。假设我们无法更改 Boat 的原始定义,我们必须将这些函数的 boat 作为参数传递:
lazy val consumption = 1f
def consume(speed: Float, time: Float) = consumption * time * speed
def turn(angle: Double)(boat: Boat): FuelState =
State(boat.turn(angle))
def go(speed: Float, time: Float)(boat: Boat): FuelState =
new State(fuel => {
val newFuel = fuel - consume(speed, time)
(boat.go(speed, time), newFuel)
})
consume 函数根据 speed 和 time 计算燃油消耗。在 turn 函数中,我们接受一个 boat,通过委托到默认实现来将其旋转指定的 angle,并将结果作为 FuelState 实例返回。
在 go 方法中也使用了类似的方法——为了计算船的位置,我们委托给船的逻辑。为了计算可用的燃油新总量,我们减少初始燃油量(作为参数传递),并将结果作为状态的一部分返回。
我们最终可以创建与最初定义相同的动作链,但这次是通过跟踪燃油消耗来实现的:
import Monad.lowPriorityImplicits._
def move(boat: Boat) = StateFloat, Boat.
flatMap(go(10, 5)).
flatMap(turn(0.5)).
flatMap(go(20,20)).
flatMap(turn(-0.1)).
flatMap{b: Boat => go(1,1)(b)}
如果你将这个片段与原始定义进行比较,你会看到船的路径是相同的。然而,幕后发生的事情要多得多。每次调用flatMap都会传递状态——这是在 monad 的代码中定义的。在我们的例子中,定义是State上定义的compose方法。传递给flatMap方法的函数描述了结果应该发生什么,以及可能传递的状态。从某种意义上说,使用 monads 给我们带来了责任分离——monad 描述了计算步骤之间应该发生什么,作为一步的结果传递给下一步,我们的逻辑描述了在传递给下一步计算之前结果应该发生什么。
我们使用部分应用函数定义我们的逻辑,这使真正发生的事情变得有些模糊——为了使这一点明显,最后一步使用显式语法定义。我们也可以通过使用 for-comprehension 使步骤之间传递结果的过程更加明确:
def move(boat: Boat) = for {
a <- StateFloat, Boat
b <- go(10,5)(a)
c <- turn(0.5)(b)
d <- go(20, 20)(c)
e <- turn(-0.1)(d)
f <- go(1,1)(e)
} yield f
方法与之前相同,但只是语法有所改变——现在,在步骤之间传递船是显式的,但状态传递在视觉上消失了——for-comprehension 使 monadic 代码看起来像命令式。这是执行这两种方法的结果:
scala> println(move(boat).value.run(1000f))
(Boat(0.4,(401.95408575015193,192.15963378398988)),549.0)
我们如何确保状态被正确传递?嗯,这正是 monad 法则保证的。对于那些好奇的人,我们甚至可以使用我们在状态伴生对象中定义的方法来操作状态:
def logFuelState(f: Float) = println(s"Current fuel level is $f")
def loggingMove(boat: Boat) = for {
a <- StateFloat, Boat
f1 <- State.get[Float]
_ = logFuelState(f1)
_ <- State.set(Math.min(700, f1))
b <- go(10,5)(a)
f2 <- State.get[Float]; _ = logFuelState(f2)
c <- turn(0.5)(b)
f3 <- State.get[Float]; _ = logFuelState(f3)
d <- go(20, 20)(c)
f3 <- State.get[Float]; _ = logFuelState(f3)
e <- turn(-0.1)(d)
f3 <- State.get[Float]; _ = logFuelState(f3)
f <- go(1,1)(e)
} yield f
我们通过添加日志语句来增强我们之前的 for-comprehension,以输出每一步后的当前状态——这些是如下形式的语句:
f1 <- State.get[Float]
_ = logFuelState(f1)
是否感觉我们真的在读取某个全局状态?嗯,实际上,正在发生的事情是我们正在获取当前的State作为结果(这就是我们之前定义State.get的方式),然后将其传递给下一个计算——日志语句。后续的计算只是显式地使用前一步的结果,就像之前一样。
使用这种技术,我们也在修改状态:
_ <- State.set(Math.min(700, f1))
在这里,我们模拟我们的船有一个最大容量为 700 的油箱。我们通过首先读取当前状态,然后设置较小的值——run方法的调用者传递的状态或我们的油箱容量。State.set方法返回Unit——这就是我们忽略它的原因。
增加日志后的定义输出如下:
scala> println(loggingMove(boat).value.run(1000f))
Current fuel level is 1000.0
Current fuel level is 650.0
Current fuel level is 650.0
Current fuel level is 250.0
Current fuel level is 250.0
如我们所见,700 的限制是在船的第一步移动之前应用的。
我们的move实现仍然存在问题——它使用硬编码的go和turn函数,好像我们只能导航一艘特定的船。然而,事实并非如此——我们应该能够使用任何具有go和turn功能的船,即使它们的实现略有不同。我们可以通过将go和turn函数作为参数传递给move方法来模拟这种情况:
def move(
go: (Float, Float) => Boat => FuelState,
turn: Double => Boat => FuelState
)(boat: Boat): FuelState
这个定义将允许我们在不同情况下为go和turn函数有不同的实现,但仍然沿着给定的硬编码路径引导船只。
如果我们仔细观察,我们会发现创建初始包装器后,move方法的定义不再有关于State的概念——我们需要它是一个 monad 才能使用 for-comprehension,但这个要求比我们目前拥有的 State 要通用得多。
我们可以通过改进这两个方面来使move函数的定义通用——通过传递效果而不是创建它,并使方法多态:
def move[A, M[_]: Monad](
go: (Float, Float) => A => M[A],
turn: Double => A => M[A]
)(boat: M[A]): M[A] = for {
a <- boat
b <- go(10,5)(a)
// the rest of the definition is exactly like before
} yield f
现在,我们可以使用任何具有单子以及具有指定签名的go和turn函数的类型来遵循给定的路径。鉴于这种功能现在是通用的,我们也可以将它与默认船的定义一起移动到Boat伴随对象中。
让我们看看这种方法与状态 monad 一起是如何工作的。结果是,我们的go和turn方法定义根本不需要改变。我们唯一需要做的就是调用新的通用move方法:
import Boat.{move, boat}
println(move(go, turn)(State(boat)).run(1000f))
它看起来更美观,但仍然有一些改进的空间。特别是,turn方法什么也不做,只是传播对默认实现的调用。我们可以像对move方法做的那样,使它通用:
def turn[M[_]: Monad]: Double => Boat => M[Boat] =
angle => boat => Monad[M].unit(boat.turn(angle))
我们不能使它关于Boat多态,因为我们需要传播对特定类型的调用,但我们仍然有通用的 monad 类型。这个特定的代码使用了Monad.apply的隐式定义来召唤特定类型的 monad。
实际上,我们也可以对go方法做同样的事情——提供一个默认的伪装实现,并将它们都放入Boat的伴随对象中:
object Boat {
val boat = Boat(0, (0d, 0d))
import Monad.lowPriorityImplicits._
def go[M[_]: Monad]: (Float, Float) => Boat => M[Boat] =
(speed, time) => boat => Monad[M].unit(boat.go(speed, time))
def turn[M[_]: Monad]: Double => Boat => M[Boat] =
angle => boat => Monad[M].unit(boat.turn(angle))
def move[A, M[_]: Monad](go: (Float, Float) => A => M[A], turn: Double => A => M[A])(boat: M[A]): M[A] = // definition as above
}
再次,要将这个定义放入 REPL,你需要使用:paste命令,然后是boat案例类的定义和伴随对象,以及Ctrl + D的组合。
现在,对于不需要覆盖默认行为的场景,我们可以使用默认实现。例如,对于状态的情况,我们可以去除默认的turn实现,并使用默认值调用move方法:
import ch09._
import Boat.{move => moveB, turn => turnB, boat}
import StateExample._
type FuelState[B] = State[Float, B]
println(moveBoat(go, turnB[FuelState])(State(boat)).run(1000f))
我们必须通过提供类型参数来帮助编译器推断要使用的正确 monad 类型,但我们的状态行为定义现在简化为覆盖的go方法定义——其余的代码是通用的。
作为说明,我们可以重用我们迄今为止与Id单子一起使用的一切——结果应该与直接在Boat上执行调用链相同。这是使用Id单子完成的完整实现:
import Monad.Id
import Boat._
println(move(go[Id], turn[Id])(boat))
再次强调,我们提供了要使用的单子类型,但这基本上就是全部了。由于Id[Boat] = Boat,我们甚至可以直接传递boat,而无需将其包装到Id中。
这不是很好吗?我们可以使用我们迄今为止定义的任何单子来传递不同的效果到以单子形式表述的主要逻辑中。我们将把使用现有定义的简单部分留给读者作为练习,现在我们将实现两个其他单子,代表State的读取和写入方面,即Reader和Writer单子。
读取单子
State单子代表一个外部(相对于逻辑定义)的状态,这个状态需要被考虑并可能被修改。《Reader》单子在这方面相似——它接受一个外部上下文并将其不变地传递给队列中的每个计算。在讨论状态单子时提到的全局状态方面,Reader将能够访问只读的系统属性。正因为如此,读取单子通常被称为依赖注入的机制——因为它接受一些外部配置(不一定是基本的东西,如字符串或数字,也可能是其他复杂的组件、数据库访问机制、网络套接字或其他资源),并将其提供给它包装的函数。
让我们看看Reader是如何定义的。我们已经在State和Reader之间进行了比较,定义也非常相似——唯一的区别是我们不需要返回更改后的上下文(毕竟它是只读的)。在代码中,它看起来是这样的:
final case class ReaderR, A {
def composeB: Reader[R, B] =
Reader { r: R =>
f(run(r)).run(r)
}
}
Reader类型只是对函数的一个包装,该函数接受一个类型为R的上下文并返回一些类型为A的结果。《flatMap将两个run函数组合在一起——我们通过使用给定上下文调用run,将给定的转换应用于结果,然后对结果调用run来实现这一点。第一次调用run基本上是为了this,而第二次是为了通过应用f得到的Reader`。
我们也可以定义一个构造器,它忽略任何给定的上下文:
object Reader {
def applyR, A: Reader[R, A] = Reader(_ => a)
}
现在我们有了这个模型,我们可以为它创建一个单子,就像我们为状态单子所做的那样——通过使用 kind-projector 语法:
implicit def readerMonad[R] = new Monad[Reader[R, ?]] {
override def unitA: Reader[R, A] = Reader(a)
override def flatMapA, B(f: A => Reader[R, B]): Reader[R, B] = a.compose(f)
}
毫不奇怪,这个单子只是委托给刚刚定义的构造器和compose方法。令人惊讶的是,现在我们做了这件事,我们就完成了读取单子的定义,并且可以使用我们的移动函数定义来使用它!
让我们假设我们有一个规定,它定义了船只的速度限制以及它们一次允许的最大转向角度(听起来很奇怪,但在我们钓鱼的地方有判例法,所以我们就是这样做的)。
由于这是外部规则,我们必须用案例类来建模它:
final case class Limits(speed: Float, angle: Double)
type ReaderLimits[A] = ch09.Reader[Limits, A]
我们还将定义一个别名,将 Reader 的上下文类型固定为 Limits。
现在,我们可以通过应用这些限制来重新定义我们的 go 和 turn 方法,如下所示:
def go(speed: Float, time: Float)(boat: Boat): ReaderLimits[Boat] =
ch09.Reader(limits => {
val lowSpeed = Math.min(speed, limits.speed)
boat.go(lowSpeed, time)
})
def turn(angle: Double)(boat: Boat): ReaderLimits[Boat] =
ch09.Reader(limits => {
val smallAngle = Math.min(angle, limits.angle)
boat.turn(smallAngle)
})
实现本身并没有什么特别之处。函数的类型签名由 move 方法预定义。在每个动作之后,我们返回 Reader[Limits, Boat]。为了计算船的新状态,我们在确定可以应用的最大速度或角度后,委托给其方法。
由于我们以通用方式设计了其余的代码,这就足够了——让我们 move:
import Monad.readerMonad
import Boat._
println(move(go, turn)(ch09.Reader(boat)).run(Limits(10f, 0.1)))
Boat(0.0,(250.00083305560517,19.96668332936563))
要运行此示例,请使用 SBT 的 run 命令。
我们将刚刚定义的 go 和 turn 函数传递给通用的 move 方法,以及正确包装的 boat,然后运行它。通过查看结果,我们可以断定速度限制得到了适当的运用。
在仔细审查了状态模态之后,关于读者就没有太多可讨论的了,因此我们可以继续到 Writer 模态。
Writer 模态
Writer 模态是状态和读者模态的兄弟,它侧重于修改状态。其主要目的是通过在计算之间传递日志来提供一个写入某种日志的便利设施。日志的类型没有指定,但通常会选择具有可能低开销的追加操作的结构。举几个合适的例子,你可以使用标准库中的 Vector 或 List。在 List 的情况下,我们需要在最后将日志条目添加到前面,并反转生成的日志。
在我们深入讨论日志类型之前,最好意识到我们可以推迟这个决定。我们只需要知道如何将条目追加到现有日志中。或者换句话说,如何将两个日志结合起来,其中一个只包含单个条目。我们已经知道具有这种功能的结构——它是 Semigroup。实际上,我们还需要能够表示一个空日志,因此我们的最终决定是拥有一个 Monoid。
让我们把这些放在一起。Writer 类接受两个类型参数,一个用于日志条目,另一个用于结果。我们还需要能够有一个 Monoid 用于日志。逻辑本身不依赖外部任何东西;它只返回结果和更新后的日志:
import ch07._
final case class WriterW: Monoid, A)
接下来,我们想要将我们的 writer 与另一个模态函数组合起来,就像我们之前做的那样:
final case class WriterW: Monoid, A) {
def composeB: Writer[W, B] = Writer {
val (a, w) = run
val (b, ww) = f(a).run
val www = implicitly[Monoid[W]].op(w, ww)
(b, www)
}
}
方法的签名与其他我们在本章中遇到的模态非常相似。在内部,我们将当前 Writer 的状态分解为结果 a 和日志 w。然后,我们将给定的函数应用于结果,收集下一个结果和日志条目。最后,我们通过利用模态操作来组合日志条目,并返回结果和组合后的日志。
我们还可以定义默认构造函数,它只是返回一个带有空日志的给定参数:
object Writer {
def applyW: Monoid, A: Writer[W, A] = Writer((a, implicitly[Monoid[W]].identity))
}
单子的定义现在是对这些方法的机械委托。唯一的小区别是要求Monoid[W]可用:
implicit def writerMonad[W : Monoid] = new Monad[Writer[W, ?]] {
override def unitA: Writer[W, A] = Writer(a)
override def flatMapA, B(f: A => Writer[W, B]): Writer[W, B] = a.compose(f)
}
再次,我们已经完成了,现在我们可以开始使用我们的新抽象了。假设现在规定要求我们将每个机器人的移动记录到日志中。我们很高兴遵守。只要它只涉及移动,我们就不需要修改turn函数——我们只需要扩展go的定义:
type WriterTracking[A] = Writer[Vector[(Double, Double)], A]
def go(speed: Float, time: Float)(boat: Boat): WriterTracking[Boat] = new WriterTracking((boat.go(speed, time), Vector(boat.position)))
我们正在将船的位置写入日志,位置由Vector表示。在定义中,我们只是再次调用船,并将移动前的船的位置作为日志条目返回。我们还需要满足单子要求。单子定义的方式与我们在第七章中提到的类似,理解代数结构:
implicit def vectorMonoid[A]: Monoid[Vector[A]] =
new Monoid[Vector[A]] {
override def identity: Vector[A] = Vector.empty[A]
override def op(l: Vector[A], r: Vector[A]): Vector[A] = l ++ r
}
准备就绪后,我们再次使用 SBT 会话中的run命令来移动我们的船:
import Monad.writerMonad
import Boat.{move, boat, turn}
println(move(go, turn[WriterTracking])(Writer(boat)).run)
(Boat(0.4,(401.95408575015193,192.15963378398988)),Vector((0.0,0.0), (50.0,0.0), (401.0330247561491,191.77021544168122)))
我们将增强的go函数和原始的turn函数(尽管使用WriterTracking类型)作为第一个参数列表,以及包裹在Writer中的boat作为第二个参数列表传递。输出不言自明——它是原始结果和包含每次移动前船的位置的向量——所有这些都无需触及转向逻辑的定义!
Writer单子结束了我们对单子王国的探索。在下一章中,我们将看看如何将它们结合起来。如果你的直觉告诉你这比结合应用性更复杂——毕竟,有一个整章是专门讨论这个主题的——那么你是正确的。它更复杂,但也更有趣。让我们看看吧!
概述
在本章中,我们探讨了将单子作为计算序列化的一种方式。我们研究了这种序列化在不同我们实现的单子之间的意义变化。Id只是按原样组合计算。Option在某个步骤返回无结果时提供了停止并返回无结果的可能性。Try和Either与Option具有类似的语义,但允许你指定no result的意义,无论是作为一个Exception还是Either的Left部分。Writer在链式计算中提供了一个只读日志。Reader为每个计算步骤提供了一些配置。State在动作之间携带一个可变状态。
我们讨论了定义单子的两个原始方法unit和flatMap如何允许你实现其他有用的方法,如map、map2和apply,从而证明每个单子都是一个函子和一个应用性。
在map和flatMap——作为 for-comprehensions——方面,我们定义了一些小的业务逻辑来控制船的移动。然后我们展示了即使底层单子的实现被重塑,这种逻辑也可以无变化地重用。
问题
-
实现
Monad[Try]。 -
证明
State单子的右单位律。 -
从本章定义的单子中选择一个,实现
go函数,该函数将以 1%的概率编码船只沉没的概念。 -
请与第 3 个问题做同样的事情,但在 1%的移动中编码引擎故障的概念,使船只无法移动。
-
使用以下模板(松散地)描述本章定义的单子本质——状态单子通过链式计算传递状态。计算本身接受前一次计算的结果,并返回结果以及新的状态。
-
定义一个
go方法,该方法既跟踪船只的位置,又使用以下类型的结构来采取船只沉没的可能性:
type WriterOption[B] = Writer[Vector[(Double, Double)], Option[Boat]]
- 将答案与第 6 个问题的答案以及我们在上一章中组合
Applicatives的方式进行比较。
进一步阅读
-
阿图尔·S·霍特,《Scala 函数式编程模式:掌握 Scala 中的有效函数式编程》
-
伊万·尼古洛夫,《Scala 设计模式 第二版:学习如何使用 Scala 编写高效、简洁和可重用的代码》
第十章:查看 Monad 转换器和自由 Monads
在第六章,“探索内置效果”中,我们研究了标准效果,并承诺揭示它们背后的概念真相;我们还讨论了组合它们的话题。从那时起,我们已经讨论了代数结构,如幺半群和群,函子,applicatives 和 Monads,履行了我们的第一个承诺。但组合主题一直未被揭露。
在第八章,“处理效果”中,我们实现了一种通用方法来组合 applicatives——这本身非常有用,但无法帮助我们组合具有 Monadic 性质的标准化效果。
在本章中,我们将最终履行我们的第二个承诺,通过讨论一些将不同的 Monadic 效果结合起来的方法。我们将探讨相关的复杂性以及 Scala 社区用来处理这些障碍的一些解决方案,包括:
-
Monad 转换器
-
Monad 转换器堆栈
-
自由 Monads
技术要求
在我们开始之前,请确保你已经安装了以下内容:
-
JDK 1.8+
-
SBT 1.2+
本章的源代码可在github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter10找到。
组合 Monads
在第六章,“探索内置效果”中,我们讨论了标准效果,如Option、Try、Either和Future。在第九章,“熟悉基本 Monads”中,我们继续前进,并为它们都实现了 Monads。在我们的例子中,我们展示了 Scala 如何通过 for-comprehension 提供良好的语法,for-comprehension 是map、flatMap和可能的filter方法的组合的语法糖。在我们的所有例子中,我们使用 for-comprehension 来定义一系列步骤,这些步骤构成了某个过程,其中前一步的计算结果被下一步消耗。
例如,这是我们在第六章,“探索内置效果”中用Option定义的钓鱼过程的定义方式:
val buyBait: String => Option[Bait]
val makeBait: String => Option[Bait]
val castLine: Bait => Option[Line]
val hookFish: Line => Option[Fish]
def goFishing(bestBaitForFish: Option[String]): Option[Fish] =
for {
baitName <- bestBaitForFish
bait <- buyBait(baitName).orElse(makeBait(baitName))
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
通过我们对 Monads 的新知识,我们可以使这个实现不受效果影响:
def goFishing[M[_]: Monad](bestBaitForFish: M[String]): M[Fish] = {
val buyBait: String => M[Bait] = ???
val castLine: Bait => M[Line] = ???
val hookFish: Line => M[Fish] = ???
import Monad.lowPriorityImplicits._
for {
baitName <- bestBaitForFish
bait <- buyBait(baitName)
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
}
Ch10.goFishing(Option("Crankbait"))
我们使用这种方法无法做到的是使用Option特有的orElse方法来定义诱饵获取的不愉快路径。
我们在这里进行的另一个简化是假设我们所有的行为都可以用相同的效果来描述。实际上,这几乎肯定不是情况。更具体地说,获取诱饵并等待钓鱼可能比抛线要花更长的时间。因此,我们可能希望用Future而不是Option来表示这些行为:
val buyBait: String => Future[Bait]
val hookFish: Line => Future[Fish]
或者,用通用术语来说,我们会有N类型的 effect 而不是M:
def goFishing[M[_]: Monad, N[_]: Monad](bestBaitForFish: M[String]): N[Fish] = {
val buyBait: String => N[Bait] = ???
val castLine: Bait => M[Line] = ???
val hookFish: Line => N[Fish] = ???
// ... the rest goes as before
}
import scala.concurrent.ExecutionContext.Implicits.global
Ch10.goFishingOption, Future)
但不幸的是,这不再能编译了。让我们考虑一个更简单的例子来理解为什么:
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
scala> for {
| o <- Option("str")
| c <- Future.successful(o)
| } yield c
c <- Future.successful(o)
^
On line 3: error: type mismatch;
found : scala.concurrent.Future[String]
required: Option[?]
编译器不再接受Future而不是Option。让我们将我们的 for-comprehension 去糖化,看看发生了什么:
Option("str").flatMap { o: String =>
val f: Future[String] = Future(o).map { c: String => c }
f
}
现在问题浮出水面——Option.flatMap期望一个返回Option的函数作为效果(这特别使用了Option.flatMapB: Option[B]的定义,以及一般意义上的Monad.flatMap)。但我们的返回值被Future包裹,这是应用Future的map函数的结果。
将这个推理推广,我们可以得出结论,在单个 for-comprehension 中,只能使用相同类型的 effect。
由于这个原因,我们似乎有两种方法来组合所需的效果:
-
将它们放入单独的 for-comprehensions
-
将不同的 effect 提升到某种公共分母类型
我们可以使用我们的钓鱼示例作为游乐场来比较这两种方法。单独的 for-comprehensions 的变化将如下所示:
for {
baitName <- bestBaitForFish
} yield for {
bait <- buyBait(baitName)
} yield for {
line <- castLine(bait)
} yield for {
fish <- hookFish(line)
} yield fish
这看起来比原始版本略差,但除了结果类型从N[Fish]变为M[N[M[N[Fish]]]]之外,仍然相当不错。在Future和Option的具体情况下,它将是Option[Future[Option[Future[Fish]]]],没有简单的方法可以提取结果,除非逐层通过。这不是一件好事,我们将把它留给谨慎的读者作为练习。
另一个选择是放弃我们实现的慷慨,使其非多态,如下所示:
def goFishing(bestBaitForFish: Option[String]): Future[Fish] =
bestBaitForFish match {
case None => Future.failed(new NoSuchElementException)
case Some(name) => buyBait(name).flatMap { bait: Bait =>
castLine(bait) match {
case None => Future.failed(new IllegalStateException)
case Some(line) => hookFish(line)
}
}
}
除了失去通用适用性之外,这种实现显然的缺点是可读性大大降低。
让我们希望第二种方法,即效果类型的公共分母,比第一种方法更有成效。
首先,我们需要决定我们想要如何组合我们目前拥有的两个 effect。有两种选择:Future[Option[?]]和Option[Future[?]]。从语义上看,在某个时间点有一个可选的结果感觉比有一个将来完成的操作更合适,所以我们将继续使用第一种选择。
使用这种新的固定类型,我们拥有的函数现在都无效了——它们现在都有错误的结果类型。转换为正确的类型只需调整类型,我们可以在现场完成:
val buyBaitFO: String => Future[Option[Bait]] = (name: String) => buyBait(name).map(Option.apply)
val castLineFO: Bait => Future[Option[Line]] = castLine.andThen(Future.successful)
val hookFishFO: Line => Future[Option[Fish]] = (line: Line) => hookFish(line).map(Option.apply)
我们需要做的就是将Option包裹进Future或Future包裹进Option,具体取决于原始函数的返回类型。
为了保持一切一致,我们也将goFishing函数的参数类型和返回类型以相同的方式更改:
def goFishing(bestBaitForFish: Future[Option[String]]): Future[Option[Fish]] = ???
由于我们努力将逻辑本身作为 for-comprehension 来表述,合理地尝试用flatMap来表述它是合理的:
bestBaitForFish.flatMap { /* takes Option[?] and returns Future[Option[?]] */ }
作为对 flatMap 的一个论证,我们必须提供一个函数,该函数接受一个 Option[String] 并返回 Future[Option[Fish]]。但我们的函数期望“真实”的输入,而不是可选的。我们不能像之前讨论的那样在 Option 上使用 flatMap,也不能简单地使用 Option.map,因为它将我们的结果类型包裹在额外的可选性层中。我们可以使用模式匹配来提取值:
case None => Future.successful(Option.empty[Fish])
case Some(name) => buyBaitFO(name) /* now what ? */
在 None 的情况下,我们只需简化流程并返回结果。在这种情况下,我们确实有一个 name;我们可以调用一个相应的函数,并将这个 name 作为参数传递。问题是,我们如何进一步操作?如果我们仔细观察 buyBaitFO(name) 的返回类型,我们会看到它与初始参数相同——Future[Option[?]]。因此,我们可以尝试再次使用 flatmapping 和模式匹配的方法,经过多次迭代后给出以下实现:
def goFishingA(bestBaitForFish: Future[Option[String]]): Future[Option[Fish]] =
bestBaitForFish.flatMap {
case None => Future.successful(Option.empty[Fish])
case Some(name) => buyBaitFO(name).flatMap {
case None => Future.successful(Option.empty[Fish])
case Some(bait) => castLineFO(bait).flatMap {
case None => Future.successful(Option.empty[Fish])
case Some(line) => hookFishFO(line)
}
}
}
这个片段中有许多重复,但它看起来已经有些结构了。通过提取重复的代码片段,我们可以提高其可读性。首先,我们可以将 无结果 的情况做成多态,如下所示:
def noResult[T]: Future[Option[T]] = Future.successful(Option.empty[T])
其次,我们可能将关于 flatMap 和模式匹配的推理作为一个独立的、多态的函数捕捉:
def continueA, B(f: A => Future[Option[B]]): Future[Option[B]] =
arg.flatMap {
case None => noResult[B]
case Some(a) => f(a)
}
经过这些修改,我们最后的尝试开始看起来更加简洁:
def goFishing(bestBaitForFish: Future[Option[String]]): Future[Option[Fish]] =
continue(bestBaitForFish) { name =>
continue(buyBaitFO(name)) { bait =>
continue(castLineFO(bait)) { line =>
hookFishFO(line)
}
}
}
这可以说是相当好的,我们可以在这一刻停止,但还有一个方面我们可以进一步改进。continue 函数调用是嵌套的。这使得业务逻辑流程的制定变得复杂。如果我们能够有一种流畅的接口,并且能够链式调用 continue 调用,可能会更有益:
这很容易通过将 continue 的第一个参数捕捉为某个类的值来实现。这将改变我们的实现形式如下:
final case class FutureOptionA {
def continueB: FutureOption[B] = new FutureOption(value.flatMap {
case None => noResult[B]
case Some(a) => f(a).value
})
}
有两种方法可以进一步改进。首先,continue 的签名表明它是一个 Kleisli 箭头,这是我们之前章节中介绍的。其次,以这种形式,每次我们需要调用 continue 方法时,都需要手动将 value 包装在 FutureOption 中。这将使代码变得冗长,我们可以通过将其作为一个 implicit 类来增强我们的实现:
implicit class FutureOptionA {
def composeB: FutureOption[B] = new FutureOption(value.flatMap {
case None => noResult[B]
case Some(a) => f(a).value
})
}
让我们看看在引入这些更改后,我们的主要流程看起来像什么:
def goFishing(bestBaitForFish: Future[Option[String]]): Future[Option[Fish]] = {
val result = bestBaitForFish.compose { name =>
buyBaitFO(name).compose { bait =>
castLineFO(bait).compose { line =>
hookFishFO(line)
}
}
}
result.value
}
太棒了!你能发现进一步改进的可能性吗?如果我们仔细审查 FutureOption 的类型签名,我们会看到我们对包裹的 value 所做的一切都是调用在 Future 上定义的 flatMap 方法。但我们已经知道适当的抽象——这是一个 monad。利用这一知识将允许我们使我们的类多态,并在需要的情况下可能将其用于其他类型的效应:
implicit class FOption[F[_]: Monad, A](val value: F[Option[A]]) {
def composeB: FOption[F, B] = {
val result = value.flatMap {
case None => noResultF[F, B]
case Some(a) => f(a).value
}
new FOption(result)
}
def isEmpty: F[Boolean] = Monad[F].map(value)(_.isEmpty)
}
为了证明新实现的多态性质不会损害我们根据需要定义辅助函数的灵活性,我们还添加了一个方法来检查我们拥有的 monad 组合是否为空。
不幸的是,如果我们尝试使这种实现对第二个效果的类型多态,我们会发现这是不可能的——我们需要像之前解释的那样将其分解,为此我们需要了解效果实现的细节。
在这一点上,一个敏锐的读者会记得,我们在上一章中开发的所有的 monad 都是基于具有相同签名的compose函数实现的。我们能再次尝试同样的技巧,为FutureOption类型实现一个 monad 吗?熟悉上一章的读者会知道,这几乎是一个机械的任务,即委托给我们刚刚提出的实现:
implicit def fOptionMonad[F[_] : Monad] = new Monad[FOption[F, ?]] {
override def unitA: FOption[F, A] = Monad[F].unit(Monad[Option].unit(a))
override def flatMapA, B(f: A => FOption[F, B]): FOption[F, B] =
a.compose(f)
}
现在,我们还需要将原始函数的返回类型更改为FOption[Future, ?],以匹配我们新 monad 的类型签名。我们不需要接触实现——编译器会自动将implicit FOption包装在结果周围:
val buyBaitFO: String => FOption[Future, Bait] = // as before
val castLineFO: Bait => FOption[Future, Line] = // as before
val hookFishFO: Line => FOption[Future, Fish] = // as before
现在,我们再次可以制定我们的逻辑,这次是使用 for-comprehension:
def goFishing(bestBaitForFish: FOption[Future, String]): FOption[Future, Fish] = for {
name <- bestBaitForFish
bait <- buyBaitFO(name)
line <- castLineFO(bait)
fish <- hookFishFO(line)
} yield fish
最后,这既简洁又清晰!最后的润色将是处理FOption这个临时的名称。这个类型的作用是将Option转换成我们选择的更高阶的 monadic 效果,通过将一个Option包装成我们选择的 monadic 效果。我们可以将其重命名为OptionTransformer或简称OptionT。
恭喜!我们刚刚实现了一个 monad 转换器。
Monad 转换器
让我们稍作停顿,回顾一下我们刚才做了什么。
我们做出了一些小的牺牲,增加了我们原始函数返回类型的复杂性,使其成为某种“公因数”类型。这种牺牲相当小,因为在我们的示例以及现实生活中,这通常只是通过将原始函数提升到它们适当的环境中来实现。
我们提出的签名看起来有点尴尬,但这部分是因为我们开始将它们作为具体的实现来开发。实际上,如果我们以更抽象的方式实现,我们的钓鱼组件的用户界面 API 从一开始就应该类似于以下片段:
abstract class FishingApi[F[_]: Monad] {
val buyBait: String => F[Bait]
val castLine: Bait => F[Line]
val hookFish: Line => F[Fish]
def goFishing(bestBaitForFish: F[String]): F[Fish] = for {
name <- bestBaitForFish
bait <- buyBait(name)
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
}
这种方法抽象化了效果类型,为我们作为库作者提供了更多的灵活性,并为 API 的用户提供了更多的结构。
这个 API 可以与任何 monad 一起使用。这是一个示例,说明我们可以如何利用我们目前拥有的函数来实现它——返回混合的Future和Optional结果:
import Transformers.OptionTMonad
import ch09.Monad.futureMonad
import scala.concurrent.ExecutionContext.Implicits.global
// we need to fix the types first to be able to implement concrete fucntions
object Ch10 {
type Bait = String
type Line = String
type Fish = String
}
object Ch10FutureFishing extends FishingApi[OptionT[Future, ?]] with App {
val buyBaitImpl: String => Future[Bait] = Future.successful
val castLineImpl: Bait => Option[Line] = Option.apply
val hookFishImpl: Line => Future[Fish] = Future.successful
override val buyBait: String => OptionT[Future, Bait] =
(name: String) => buyBaitImpl(name).map(Option.apply)
override val castLine: Bait => OptionT[Future, Line] =
castLineImpl.andThen(Future.successful(_))
override val hookFish: Line => OptionT[Future, Fish] =
(line: Line) => hookFishImpl(line).map(Option.apply)
goFishing(Transformers.optionTunitFuture, String)
}
正如之前一样,我们为原始函数实现了门面,所做的只是将它们提升到适当的效果中。而goFishing方法可以像以前一样使用——编译器只需要一个 monad 来使它发生,即OptoinT[Future]可用的 monad:
例如,在某个时候,底层函数的实现者可以决定它们应该返回 Try 而不是现在的 future。这是可以的,因为要求会变化,我们可以在我们的逻辑中相当容易地纳入这个变化:
import scala.util._
object Ch10OptionTTryFishing extends FishingApi[OptionT[Try, ?]] with App {
val buyBaitImpl: String => Try[Bait] = Success.apply
val castLineImpl: Bait => Option[Line] = Option.apply
val hookFishImpl: Line => Try[Fish] = Success.apply
override val buyBait: String => OptionT[Try, Bait] =
(name: String) => buyBaitImpl(name).map(Option.apply)
override val castLine: Bait => OptionT[Try, Line] =
castLineImpl.andThen(Try.apply(_))
override val hookFish: Line => OptionT[Try, Fish] =
(line: Line) => hookFishImpl(line).map(Option.apply)
goFishingM(Transformers.optionTunitTry, String)
}
假设库中的变化是既定的,我们这边唯一需要改变的是:
-
castLine函数的提升方法;它从Future.success变为Try.apply -
我们传递给
goFishing函数初始参数包装器的类型参数
我们完成了。我们根本不需要触及我们的钓鱼“业务”逻辑!
在某种意义上,monad transformer “扁平化”了两个 monad,这样在调用 map 和 flatMap 方法时就可以一次性穿透所有层——因此也在 for-comprehension 中。
目前,我们无法更改“内部”效果的类型——我们只有可用的 OptionT monad transformer。但这只是实施另一个 transformer 一次的问题,就像我们处理 monads 一样。更具体地说,让我们看看将基本函数的返回类型从 Option 更改为 Either 的效果。假设新版本期望使用 String 作为不愉快情况的描述;我们会有以下代码:
object Ch10EitherTFutureFishing extends FishingApi[EitherT[Future, String, ?]] with App {
val buyBaitImpl: String => Future[Bait] = Future.successful
val castLineImpl: Bait => Either[String, Line] = Right.apply
val hookFishImpl: Line => Future[Fish] = Future.successful
override val buyBait: String => EitherT[Future, String, Bait] =
(name: String) => buyBaitImpl(name).map(l => Right(l): Either[String, Bait])
override val castLine: Bait => EitherT[Future, String, Line] =
castLineImpl.andThen(Future.successful(_))
override val hookFish: Line => EitherT[Future, String, Fish] =
(line: Line) => hookFishImpl(line).map(l => Right(l): Either[String, Fish])
goFishing(Transformers.eitherTunitFuture, String, String).value
}
castLineImpl 的返回类型现在是 Either[String, Line],因为新的要求规定。我们正在进行的提升稍微复杂一些,因为我们需要将 Either 的左右两侧的类型传达给编译器。其余的实现与之前相同。
这依赖于我们有一个 EitherT 的实例和相应的 monad。我们已经知道如何实现 monad transformer,并且可以立即想出代码。首先,EitherT 类,与 OptionT 几乎完全相同,需要携带 Either 左侧的类型如下:
implicit class EitherT[F[_]: Monad, L, A](val value: F[Either[L, A]]) {
def composeB: EitherT[F, L, B] = {
val result: F[Either[L, B]] = value.flatMap {
case Left(l) => Monad[F].unit(LeftL, B)
case Right(a) => f(a).value
}
new EitherT(result)
}
def isRight: F[Boolean] = Monad[F].map(value)(_.isRight)
}
我们不再在 None 和 Some 上进行模式匹配,而是在 Either 的 Left 和 Right 两侧进行模式匹配。我们还用更合适的 isRight 替换了辅助方法 isEmpty。
提升函数和 monad 的实现也有相当大的相似性——如果你愿意,就是一些样板代码:
def eitherTunit[F[_]: Monad, L, A](a: => A) = new EitherTF, L, A))
implicit def EitherTMonad[F[_] : Monad, L]: Monad[EitherT[F, L, ?]] =
new Monad[EitherT[F, L, ?]] {
override def unitA: EitherT[F, L, A] =
Monad[F].unit(ch09.Monad.eitherMonad[L].unit(a))
override def flatMapA, B(f: A => EitherT[F, L, B]): EitherT[F, L, B] =
a.compose(f)
}
太棒了!我们现在有两个 monad transformer 在我们的工具箱中,之前损坏的 Ch10EitherTFutureFishing 定义已经开始编译和运行了!
急切地想要实现 TryT 来巩固新获得的知识?我们很高兴把这个练习留给你。
monad transformer 堆栈
同时,我们将用以下想法自娱自乐:
-
monad transformers 需要一个 monad 的实例作为外层
-
monad transformer 本身有一个 monad
-
如果我们将 monad transformer 作为另一个 monad transformer 的 monad 实例使用,会发生什么不好的事情吗?
让我们试试看。我们已经实现了两个单子转换器,所以让我们将它们放在一起。首先,我们将定义堆栈的类型。它将是EitherT包裹在OptionT中。这将给我们以下代码的未包装类型:
Future[Either[String, Option[Fish]]]
这可以解释为一个耗时操作,可能会在非技术故障的情况下返回错误,并且需要有一个解释(技术故障由失败的Futures表示)。Option表示一个可以以自然方式返回无结果的操作,无需进一步解释。
使用类型别名,我们可以表示内部转换器的类型,将String固定为左侧的类型,如下所示:
type Inner[A] = EitherT[Future, String, A]
堆栈中的外层转换器甚至更简单。与内部类型不同,我们固定了效果类型为Future,它将一个效果类型构造函数作为类型参数,如下所示:
type Outer[F[_], A] = OptionT[F, A]
我们现在可以使用这些别名来定义整个堆栈,如下所示:
type Stack[A] = Outer[Inner, A]
为了使情况更现实,我们将只取我们原始钓鱼函数的最后一个版本——即castLineImpl返回Either[String, Line]的那个版本。我们需要装饰所有原始函数,以便结果类型与我们现在拥有的堆栈类型相匹配。这就是事情开始变得难以控制的地方。编译器不允许连续应用两次隐式转换,因此我们必须手动应用其中之一。对于返回Future[?]的两个函数,我们还需要将底层包裹进Option中:
override val buyBait: String => Stack[Bait] =
(name: String) => new EitherT(buyBaitImpl(name).map(l => Right(Option(l)): Either[String, Option[Bait]]))
override val hookFish: Line => Stack[Fish] =
(line: Line) => new EitherT(hookFishImpl(line).map(l => Right(Option(l)): Either[String, Option[Fish]]))
现在,编译器将能够对OptionT应用隐式转换。
同样,返回Either[String, Line]的函数需要在外部转换为EitherT,如下所示:
override val castLine: Bait => Stack[Line] =
(bait: Bait) => new EitherT(Future.successful(castLineImpl(bait).map(Option.apply)))
内部,我们必须将Either的内容map到一个Option中,并将Future应用于整个结果。
编译器可以帮助我们通过应用所需的隐式转换来创建适当类型的输入——在这边我们不会看到很多变化,如下所示:
val input = optionTunitInner, String
目前我们需要进行一个小调整,因为我们正在使用这个转换器堆栈调用我们的业务逻辑——现在我们有两层转换,所以我们需要调用value两次来提取结果,如下所示:
val outerResult: Inner[Option[Fish]] = goFishing(input).value
val innerResult: Future[Either[String, Option[Fish]]] = outerResult.value
在每个构成堆栈的单子转换器上反复转向value方法可能会很快变得令人厌烦。我们为什么需要这样做呢?因为用特定转换器的类型返回结果可能会很快污染客户端代码。因此,通常有一些关于单子和单子转换器堆栈的建议值得考虑,如下所示:
-
堆叠单子和特别是单子转换器会增加性能和垃圾收集开销。仔细考虑向现有类型添加每个额外效果层的必要性是至关重要的。
-
也有争议说,堆栈中更多的层会增加心理负担并使代码杂乱。这种方法与第一个建议相同——除非绝对需要,否则不要这样做。
-
客户通常不根据单调转换器操作,因此它们(转换器)应被视为实现细节。API 应以通用术语定义。如果需要具体化,则优先考虑效果类型而不是转换器类型。在我们的例子中,返回类型
Future[Option[?]]比返回OptionT[Future, ?]更好。
考虑到所有这些因素,单调转换器在现实生活中真的有用吗?当然有!然而,就像往常一样,总有一些替代方案,例如自由单调。
自由单调
在本章和前几章中,我们使用单调表示了顺序计算。单调的 flatMap 方法描述了计算步骤应该如何连接,以及作为参数给出的函数——计算步骤本身。自由单调将顺序计算的概念提升到下一个层次。
首先,我们开始将计算步骤表示为我们选择的某些 ADT(代数数据类型) 的实例。其次,我们使用另一个 ADT 的实例表示单调概念。
为了证实这种方法,我们可以再次回到钓鱼的例子。早些时候,我们有三个动作,我们将它们编码为函数。现在,这些动作将被表示为值类。我们还需要给之前使用的类型别名赋予特定的含义,以便以后能够运行示例。
下面是钓鱼模型及其相应的 ADT(代数数据类型)的定义如下:
case class Bait(name: String) extends AnyVal
case class Line(length: Int) extends AnyVal
case class Fish(name: String) extends AnyVal
sealed trait Action[A]
final case class BuyBaitA extends Action[A]
final case class CastLineA extends Action[A]
final case class HookFishA extends Action[A]
在模型中,我们表示了诱饵、钓线和鱼的一些属性,以便我们以后可以利用它们。
Action 类型有几个值得讨论的方面。首先,Action 的实例反映了我们之前的功能通过将此参数声明为类的字段来接受单个参数。其次,所有动作都由 下一个动作的类型 类型化,并且这个下一个动作被捕获为类的另一个字段,形式为一个函数,该函数期望包装动作的结果作为参数。第二个字段是我们编码动作顺序的方式。
现在我们需要将单调方法表示为类。
Done 以与 Monad.unit 相同的方式从值组装 Free 实例:
final case class Done[F[_]: Functor, A](a: A) extends Free[F, A]
F[_] 指的是要包装的动作的类型,A 是结果类型。F 需要有一个 Functor;我们将在稍后看到原因。
Join 构建了 flatMap 的表示——它应该通过将 F 应用到 Free 的前一个实例来实现这一点。这给我们以下类型的 action 参数如下:
final case class Suspend[F[_]: Functor, A](action: F[Free[F, A]]) extends Free[F, A]
现在,正如我们所说的,这是一个单调,因此我们需要提供一个 flatMap 的实现。我们将在 Free 上这样做,以便可以在 for-comprehensions 中使用 Done 和 Join 的实例,如下所示:
class Free[F[_]: Functor, A] {
def flatMapB: Free[F, B] = this match {
case Done(a) => f(a)
case Join(a) => Join(implicitly[Functor[F]].map(a)(_.flatMap(f)))
}
}
flatMap自然接受 Kleisli 箭头作为参数。类似于其他单子上的flatMap定义,例如Option,我们区分了短路和退出以及继续计算链。在前一种情况下,我们可以直接应用给定的函数;在后一种情况下,我们必须构建序列。这就是我们使用Functor[F]进入F并在包装的Free[F, A]上应用flatMap的地方,基本上是以古老的单子方式执行序列化。
函子在这里提供给我们成功进行计算的可能性,这决定了我们的操作函子应该如何实现——给定的函数应该在下一个操作的结果上被调用。我们的操作可能有相当不同的结构,因此描述这种方法的最简单方式是模式匹配,如下所示:
implicit val actionFunctor: Functor[Action] = new Functor[Action] {
override def mapA, B(f: A => B): Action[B] = in match {
case BuyBait(name, a) => BuyBait(name, x => f(a(x)))
case CastLine(bait, a) => CastLine(bait, x => f(a(x)))
case HookFish(line, a) => HookFish(line, x => f(a(x)))
}
}
我们 ADT 的值以类似的结构组织,这也是为什么所有操作看起来相似的原因。
我们需要的最后一个准备步骤是有一个用户友好的方式来为每个操作创建自由单子的实例。让我们以下面的方式创建辅助方法:
def buyBait(name: String): Free[Action, Bait] = Join(BuyBait(name, bait => Done(bait)))
def castLine(bait: Bait): Free[Action, Line] = Join(CastLine(bait, line => Done(line)))
def hookFish(line: Line): Free[Action, Fish] = Join(HookFish(line, fish => Done(fish)))
这些方法中的每一个都创建了一个自由单子实例,它描述了一个由单个操作组成的计算;Done(...)编码了我们已经完成,并且有一些结果的事实。
现在,我们可以使用这些辅助函数来构建一个计算链,就像我们之前做的那样。但这次的计算不会是一个可调用的东西——它只是将自由单子的实例作为单个Free实例捕获的实例序列,如下所示:
def catchFish(baitName: String): Free[Action, Fish] = for {
bait <- buyBait(baitName)
line <- castLine(bait)
fish <- hookFish(line)
} yield fish
这个单一的实例包含了所有步骤,以Free包含操作的形式。以伪代码的形式表示,调用此方法的结果将看起来像嵌套结构,如下所示:
Join(BuyBait("Crankbait", Join(CastLine(bait, Join(HookFish(line, Done(fish)))))))
在这个时候,我们已经创建了计算序列,但这个序列是无用的,因为它只是一个数据结构。我们需要一种方法让它变得有用——我们必须为它创建一个解释器。这正是自由单子真正开始发光的地方——如何呈现这些数据取决于我们。我们可以创建尽可能多的解释器,例如,一个用于测试目的,另一个用于生产使用。例如,对于测试,仅仅收集应该在某个日志中发生的所有操作可能是有用的——以事件源的方式(我们将在本书的后面详细探讨事件源)。因为我们只是在测试,所以我们的日志不需要持久化——因此,我们可以使用某种类型的集合;例如,一个List就足够了,如下所示:
@tailrec
def goFishingAccA: List[AnyVal] = actions match {
case Join(BuyBait(name, f)) =>
val bait = Bait(name)
goFishingAcc(f(bait), bait :: log)
case Join(CastLine(bait, f)) =>
val line = Line(bait.name.length)
goFishingAcc(f(line), line :: log)
case Join(HookFish(line, f)) =>
val fish = Fish(s"CatFish from ($line)")
goFishingAcc(f(fish), fish :: log)
case Done(_) => log.reverse
}
前面的片段确实是一个程序的解释器,该程序是用Free中封装的操作构建的。逻辑是重复的——我们产生操作的结果,并递归地调用这个操作,将带有新增条目的日志作为参数传递。在Done的情况下,我们忽略结果;我们的目标是日志,我们通过调用.reverse来以相反的方向构建它,并返回反转的形式。
执行的结果看起来如下所示:
scala> import ch10.FreeMonad._
import ch10.FreeMonad._
scala> println(goFishingAcc(catchFish("Crankbait"), Nil))
List(Bait(Crankbait), Line(9), Fish(CatFish from (Line(9))))
对于生产环境,我们可以做些其他事情,例如收集执行的动作。我们将通过写入控制台来模拟这种副作用,如下所示:
def logA: Unit = println(a)
@scala.annotation.tailrec
def goFishingLoggingA: A = actions match {
case Join(BuyBait(name, f)) =>
goFishingLogging(f(Bait(name)), log(s"Buying bait $name"))
case Join(CastLine(bait, f)) =>
goFishingLogging(f(Line(bait.name.length)), log(s"Casting line with ${bait.name}"))
case Join(HookFish(line, f)) =>
goFishingLogging(f(Fish("CatFish")), log(s"Hooking fish from ${line.length} feet"))
case Done(fish) => fish
}
这个解释器的结构自然与之前相同。计算的输出类型是Unit——我们做的所有事情都有副作用,所以没有必要传递任何东西。我们不是将操作累积到日志中,而是直接将报告写入控制台。Done的情况也略有不同——我们返回fish,即执行组合操作的结果。
执行的结果如预期的那样发生变化,如下所示:
scala> println(goFishingLogging(catchFish("Crankbait"), ()))
Buying bait Crankbait
Casting line with Crankbait
Hooking fish from 9 feet
Fish(CatFish)
我们成功实现了一个非常基本的自由 monad 版本,以及一个小型的钓鱼语言和两个不同的解释器。代码量相当大,所以是时候回答一个明显的问题了:我们为什么投入额外的努力?
自由 monad 具有明显的优势;我们提到了这些,它们如下:
-
将计算作为类粘合在一起发生在堆上,并节省了栈内存。
-
可以将计算传递到代码的不同部分,并且副作用将延迟到显式运行时。
-
有多个解释器允许在不同情况下有不同的行为。
-
本章的范围没有允许我们展示不同的“语言”(ADTs)如何组合成一个代数结构,然后可以使用这个结构同时使用两种语言来定义逻辑。这种可能性为 monad 变换和 monad 变换堆栈提供了替代方案,例如,一种结合业务术语和持久性术语的语言。
它们的缺点与 monads 的缺点处于同一层面。这包括额外的初始实现工作量、垃圾收集器的运行时开销,以及对于新接触这个概念的开发者来说,处理额外的指令和心理负担。
摘要
Monads 可以说是函数式编程中最普遍的抽象。不幸的是,它们在一般情况下不能组合——与函数和应用不同。
Monad 变换提供了一种绕过这种限制的方法,通过指定一组总体结构来表示 monads 的组合,每个组合都针对单个内部效应类型。Monad 变换以这种方式组合 monads,使得可以通过一次flatMap或map调用同时跨越两种效应。
Monad transformer stacks 将 monad transformer 的概念提升了一个层次,利用了每个 monad transformer 同时也是一个 monad 的这一事实。通过堆叠 monad transformer,我们可以像处理单个 monad 一样,以相同的方式在单个堆栈中组合几乎任何数量的效果。
Monad transformer 并非没有缺点。该列表包括由于需要在堆栈中解包和重新打包效果而增加的垃圾收集足迹和处理器利用率。同样的推理也适用于开发者在编写和维护代码时需要在脑海中构建和维护的心理模型。
Free monad 通过明确分离计算的结构和解释,提供了一个合理的替代方案。它是通过将业务逻辑表示为某些 ADT 编码的步骤序列来实现的,并使用合适的解释器执行这些步骤。
本章总结了本书的第二部分。在本部分和第一部分中,我们避免使用第三方库,专注于向读者传授对语言特性和底层理论概念的深入理解。
不言而喻,本部分中的代码示例无疑是简化的,仅适用于学习目的。
特别针对函数式编程方面,有两个特别好的库值得再次提及,并且可用于 Scala:Cats (typelevel.org/cats/) 和 Scalaz (github.com/scalaz/scalaz)。如果我们成功地激发了您使用本书本部分展示的函数式风格编程 Scala 的兴趣,我们强烈推荐您查看这两个库。除了提供我们研究的概念的生产就绪实现外,它们还包含了许多我们没有讨论的抽象。
在本书的第三部分,我们将放宽对第三方依赖的自我施加的限制,并将其致力于使用不同的 Akka 库在 Scala 中进行响应式编程的主题。
问题
-
为什么 monad transformer 的类型反映了堆栈类型的“倒置”,其名称指的是最内层 monad 的类型?
-
为什么可以在堆栈的顶层重用现有的 monad?
-
为什么不能在堆栈的底层重用现有的 monad?
-
实现
TryTmonad transformer。 -
在本章的示例函数中使用
TryTmonad transformer 而不是EitherT。 -
实现另一种 monad transformer stack 的实现,这次将层放置在上方:
EitherT[OptionT[Future, A], String, A]。 -
在本章开发的 free monad 示例中添加一个释放捕获的鱼的动作。
进一步阅读
Anatolii Kmetiuk,精通函数式编程:学习函数式编程如何帮助你以声明性和纯方式部署网络服务器和与数据库交互。
Atul S. Khot,《Scala 函数式编程模式》:掌握并执行有效的 Scala 函数式编程
Ivan Nikolov,《Scala 设计模式》—— 第二版:学习如何使用 Scala 编写高效、简洁且可重用的代码
第十一章:Akka 和 Actor 模型简介
在本章中,我们将学习 actor 模型及其在 Akka 中的实现方式。我们将通过构建一个简单而完整的 actor 系统来熟悉 Akka。然后,我们将学习如何创建 actor 系统,以及 actors,如何在它们之间传递消息,如何利用位置透明性和远程通信,如何为有效的监督合理地构建系统,以及如何查看有限状态机(FSM)actors 的工作原理。最后,我们将向您展示如何测试基于 actor 的应用程序。
本章将涵盖以下主题:
-
Actor 模型
-
Akka 基础
-
Akka FSM
-
Akka 远程通信
-
测试
技术要求
在我们开始之前,请确保您已安装以下内容:
-
Java 1.8+
-
SBT 1.2+
如果您需要执行 Java 或 SBT 的首次设置,请参阅附录 A 中的安装说明,准备环境和运行代码示例。
源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter11。
Actor 模型简介
从各种计算应用的第一天起,它们就面临着在减少的处理时间内处理日益增长的数据量的需求。直到最近,这些挑战通过垂直扩展应用程序来解决,即通过增加内存和更快的处理器。这种方法之所以可行,是因为处理器速度的增长,这被摩尔定律描述如下:
“摩尔定律是观察到的现象,即密集集成电路中的晶体管数量大约每两年翻一番。... 摩尔的预测在几十年的时间里被证明是准确的,并在半导体行业中用于指导长期规划,并为研究和开发设定目标。.... 摩尔定律是对历史趋势的观察和预测,而不是物理或自然法则。”
但正如英特尔在 2015 年所陈述的那样,硬件进步的速度开始放缓。这种趋势使得很明显,从现在开始,唯一扩展应用程序的方法是通过横向扩展——通过增加处理核心和机器的数量,使用多个应用程序线程并行处理数据。在这种情况下有两个明显的挑战:
-
为了防止并发修改,从而避免数据损坏
-
为了提供对在不同核心或机器上运行的过程的数据访问
传统的第一个挑战是通过使用共享内存和不同的锁定方法来解决的。不幸的是,这种方法实际上使得应用程序中相互同步的部分变成了伪顺序的,这反过来又根据 Amdahl 的定律限制了可能的加速:
“阿姆达尔定律是一个公式,它以固定工作负载下执行任务的延迟为理论速度提升。 ... 阿姆达尔定律常用于并行计算,以预测使用多个处理器时的理论速度提升。”
最重要的推论是,加速受到程序串行部分的限制。
幸运的是,有其他解决方案可以使程序的不同部分并行工作,共同实现同一目标。其中一种方法就是演员模型。幸运的是,演员模型还通过一个称为位置透明性的概念解决了第二个挑战。
演员的概念最早由卡尔·休伊特、彼得·毕晓普和理查德·斯蒂格在 1973 年提出。
卡尔·休伊特、彼得·毕晓普和理查德·斯蒂格:人工智能的通用模块化演员形式。在:IJCAI'73 国际人工智能联合会议论文集。1973 年,第 235–245 页 (dl.acm.org/citation.cfm?id=1624804)。
理念是,一切都被表示为演员,这是一种基本的计算实体。演员通过消息相互通信。在接收到消息后,演员可以执行以下任何一项操作:
-
向其他演员发送有限数量的消息
-
创建有限数量的其他演员
-
改变下一个要处理的消息的行为
这个定义相当抽象,但已经允许你识别出实现必须满足的一些约束:
-
演员通过消息进行通信,并且不允许暴露或检查彼此的内部状态。
-
在演员模型中,没有共享的可变状态的位置。
-
在这个定义中没有提到副作用,但它们显然是任何系统的最终目标。因此,演员对消息的响应可能是以下组合中的任何一种:
-
改变内部状态
-
修改行为
-
产生副作用
-
-
演员需要能够相互定位。因此,预期存在一个命名系统。拥有适当的命名系统是位置透明性的先决条件,这意味着每个演员都可以通过一个关于其实际位置的规范名称进行定位。
上述定义也未能回答以下问题,以及其他问题:
-
基础硬件、操作系统和运行时环境的限制会产生什么影响?
-
演员在哪里共存,第一个演员是如何被创建的?
-
消息是如何从一个演员传递到另一个演员的?
-
演员是会死亡的,如果是的话,他们是如何死亡的?
使用演员模型的最为突出的语言包括 Erlang、Io、D、Pony 和 Scala。
我们将通过构建一个企业烘焙应用来更详细地研究 Scala 实现——Akka。我们的烘焙店将被不同的演员占据,每个演员都有自己的职责,通过团队合作生产饼干。
Akka 基础知识
我们将首先将 Akka 依赖项添加到空 Scala SBT 项目的build.sbt文件中:
libraryDependencies += "com.typesafe.akka" %% "akka-actor" % akkaVersion
可以在 Akka 网站上查看akkaVersion。在撰写本书时,它是 2.5.13,因此我们将在前面的代码片段前添加val akkaVersion = "2.5.13"。
SBT 可以通过 giter8 模板为您创建一个最小的 Akka 项目:sbt new https://github.com/akka/akka-quickstart-scala.g8。
现在,我们可以实例化一个ActorSystem,这是 Akka 演员的居住地:
import akka.actor._
val bakery = ActorSystem("Bakery")
避免在同一个 JVM 或同一台机器上定义多个 actor 系统。actor 系统并不轻量级,通常配置得与它运行的硬件配置紧密相关。因此,多个 actor 系统不仅会消耗比所需更多的资源,而且在最坏的情况下,它们将竞争这些资源。
也可以创建一个不带名称的默认 actor 系统,但最好不要这样做。命名 actor 系统和 actor 将使您在推理和调试现有代码时更容易。
作为下一步,让我们定义一个 actor。
定义 actor
Akka 中的 actor 必须扩展同名特质并实现receive方法:
class Cook extends Actor {
override def receive = {
case _ =>
}
}
receive动作的类型定义为type Receive = PartialFunction[Any, Unit],这与 actor 模型的抽象定义非常相似。在 Akka 中,演员可以接收任何消息,任何演员的活动都表现为其状态的变化或副作用。
我们定义的演员接受任何消息而不做任何事情。这可能是最简单、最懒惰的演员了。
为了使其有用,让我们定义其行为和词汇。
由于我们正在构建一个企业烘焙店,我们的演员将只有一个职责,这在任何类型的系统中都是一个好主意,而不仅仅是基于演员的系统。我们的厨师演员将取一块现成的面团,并从中制作出原始饼干。首先,我们必须定义消息:
object Cook {
final case class Dough(weight: Int)
final case class RawCookies(number: Int)
}
然后是 actor 的行为:
class Cook extends Actor {
override def receive = {
case Dough(weight) =>
val numberOfCookies = makeCookies(weight)
sender() ! RawCookies(numberOfCookies)
}
private val cookieWeight = 30
private def makeCookies(weight: Int):Int = weight / cookieWeight
}
在这个定义中,有几件事情正在进行:
-
我们的 actor 只理解一种消息类型,
Dough -
它通过计算数量将面团制作成原始饼干
-
我们使用
sender()来获取发送消息的演员的引用,并将响应发送到这个引用
此外,还有一些更微妙但值得注意的细节:
-
如果,出于巧合,我们的
Cook收到除Dough以外的任何其他消息,这个消息将不会被 actor 处理,并会丢失。Akka 有一个专门处理此类消息的机制,称为死信队列。 -
我们为每种消息类型定义了案例类,以使代码理解更加简单。
-
演员的逻辑与协议解耦,可以提取到伴随对象中。这样做是为了使测试更容易。
现在我们已经定义了演员,是时候实例化它并向其发送一些消息了。在 Akka 中,我们必须使用特殊的Prop构造函数:
val cookProps: ActorRef = Props[Cook]
在这种情况下,我们不需要向演员传递任何构造函数参数,因此我们可以从使用Props形式中受益,它将演员的唯一类型作为其参数。
不要直接使用类构造函数来构建演员。虽然可以这样做,然后从演员中获取ActorRef,但这将在运行时导致错误。
现在,我们可以将所有这些放在一起并发送我们的第一条消息:
object Bakery extends App {
val bakery = ActorSystem("Bakery")
val cook: ActorRef = bakery.actorOf(Props[Cook], "Cook")
cook ! Dough(1000)
}
在这里,我们使用演员系统创建了一个命名演员并向其发送了一条消息。
让我们定义其他一些演员,使我们的面包店更加活跃。我们将按照以下方式分离责任:
-
一个男孩将访问杂货店并获取必要的原料。
-
他将把它们交给面包店经理,以便他们可以检查数量和质量。
-
然后将配料交给厨师,厨师将它们制成面团。
-
厨师根据面团的体积使用一个或多个搅拌机。
-
准备好的面团交给厨师,厨师将其制成原始饼干。
-
原始饼干由面包师烘焙。
-
面包师使用烤箱进行烘焙。由于烤箱的大小有限,可能需要几轮才能烘焙完所有的饼干。
-
饼干一准备好就返回给经理。
然后,我们需要找出演员之间的关系,如下所示:

然后我们需要构建并展示它们之间的消息流:

我们将从底部向上构建我们的演员层次结构,从烤箱开始:
object Oven {
final case object Extract
final case class Cookies(count: Int)
def props(size: Int) = Props(classOf[Oven], size)
}
class Oven(size: Int) extends Actor {
private var cookiesInside = 0
override def receive = LoggingReceive {
case RawCookies(count) => insert(count).foreach(sender().!)
case Extract => sender() ! Cookies(extract())
}
def insert(count: Int): Option[RawCookies] =
if (cookiesInside > 0) {
Some(RawCookies(count))
} else {
val tooMany = math.max(0, count - size)
cookiesInside = math.min(size, count)
Some(tooMany).filter(_ > 0).map(RawCookies)
}
def extract(): Int = {
val cookies = cookiesInside
cookiesInside = 0
cookies
}
}
我们在这里引入了许多新功能。
首先,我们定义了两种新的消息类型,这些类型将被用来命令烤箱返回饼干并为准备好的饼干制作容器。在演员本身中,我们使用构造函数参数来指定可以放入其中的饼干数量。我们还使用了 Akka 的LoggingReceive,它将传入的消息写入日志。在receive方法本身中,我们坚持将 Akka 语义与业务逻辑分离的原则。
insert方法检查烤箱是否为空,并将尽可能多的原始饼干放入其中,可选地返回那些放不进去的饼干,这样我们就可以将它们转发给发送者。
在extract方法中,我们修改烤箱内的饼干数量并将它们返回给发送者。
在演员内部使用var绝对安全,并说明了 Akka 的核心特性之一——消息按接收顺序逐个由演员处理。即使在高度并发的环境中,Akka 也会保护演员代码免受任何并发相关问题的困扰。
总是使用深度不可变的消息。使用可变结构将允许两个不同的 actor 从不同的线程访问相同的数据,这可能导致并发修改并损坏数据。
为了实例化一个烤箱,我们将使用另一种Prop构造函数的版本,它允许我们定义构造函数参数:
val prop = Props(classOf[Oven], size)
按惯例,它被放置在 actor 的伴生对象中。这里也定义了烤箱的size。
如以下代码所示,我们将描述烤箱的使用者,即Bakeractor:
object Baker {
import scala.concurrent.duration._
private val defaultBakingTime = 2.seconds
def props(oven: ActorRef) = Props(new Baker(oven))
}
class Baker(oven: ActorRef,
bakingTime: FiniteDuration = Baker.defaultBakingTime)
extends Actor {
private var queue = 0
private var timer: Option[Cancellable] = None
override def receive: Receive = {
case RawCookies(count) =>
queue += count
if (sender() != oven && timer.isEmpty) timer = sendToOven()
case c: Cookies =>
context.actorSelection("../Manager") ! c
assert(timer.isEmpty)
if (queue > 0) timer = sendToOven() else timer = None
}
private def sendToOven() = {
oven ! RawCookies(queue)
queue = 0
import context.dispatcher
Option(context.system.scheduler.scheduleOnce(bakingTime, oven, Extract))
}
override def postStop(): Unit = {
timer.foreach(_.cancel())
super.postStop()
}
}
让我们更详细地看看这里发生了什么。首先,我们需要使用另一种类型的Props构造函数,因为 Akka 不支持具有默认参数的构造函数。
Props,连同实例一起,是一个非常强大的构造,允许你创建匿名 actor,这些 actor 反过来可以封装另一个 actor 的内部状态。如果可能,尽量避免使用它。
Bakeractor 接收一个Oven的ActorRef作为参数。这个引用被面包师用来向Oven发送饼干并从中提取它们。
在从Oven接收烘焙好的饼干后,Bakeractor 查找Manageractor 并发送Cookies给它。之后,如果需要,它会将另一批生饼干放入Oven中。我们将在本章后面讨论context.actorSelection的内在特性。
Bakeractor 维护一个生饼干的内部队列,并定期将它们放入烤箱中。这是一个老式烤箱,为了使用它,我们需要设置一个厨房计时器以便在适当的时间提取烘焙好的饼干。最后,我们为计时器添加一个postStop生命周期钩子,以便在 actor 停止时取消它。我们这样做是因为,如果 actor 不再存在,就没有人会在那里监听计时器的信号。
Actor 的生命周期
Akka 中的 Actors 定义了在它们生命周期不同时刻被调用的方法,具体如下:
-
preStart: 在 actor 启动后或重启期间调用 -
preRestart: 在即将因重启而被销毁的 actor 上调用 -
postRestart: 在重启后刚刚创建的 actor 上调用 -
postStop: 在 actor 停止后调用
给定两个相同 actor 的实例——一个已经失败,另一个作为替代被创建——它们的执行顺序如下所示:
-
Actor A,停止:
constructor|preStart|preRestart|postStop -
Actor B,启动:
constructor|postRestart|preStart|postStop
现在,我们可以实现一个Chefactor。
这个 actor 将结合原料制作面团。它将使用它的神奇力量来创建一个Mixer,并使用这个Mixer来完成实际的混合工作。一个Mixer的容量有限,因此Chef需要为更大的购物清单创建多个搅拌器,并并行使用它们以加快准备过程。
我们将首先定义一个搅拌器:
object Mixer {
final case class Groceries(eggs: Int, flour: Int, sugar: Int, chocolate: Int)
def props: Props = Props[Mixer].withDispatcher("mixers-dispatcher")
}
class Mixer extends Actor {
override def receive: Receive = {
case Groceries(eggs, flour, sugar, chocolate) =>
Thread.sleep(3000)
sender() ! Dough(eggs * 50 + flour + sugar + chocolate)
}
}
Mixer 只理解一种消息类型,即 Groceries。在接收到这种类型的消息后,它会通过混合所有原料产生一定量的 Dough 并将其返回给发送者。Thread.sleep 表示阻塞——等待硬件完成其操作。
尽量避免阻塞。在演员中的阻塞会消耗一个线程,如果许多演员都阻塞了,其他演员将因线程不足而无法处理消息。
不幸的是,在我们的案例中,由于硬件限制,在混合操作期间阻塞是不可避免的。Akka 以分派器的形式提供了解决这个问题的方案。
分派器
分派器是使演员工作的机器。它们负责分配 CPU 给演员,管理演员的邮箱,并将邮箱中的消息传递给演员。有四种常用的分派器类型:
-
默认分派器:这种分派器为每个演员创建一个邮箱,并且可以被任何数量的演员共享。它使用
java.util.concurrent.ExecutorService来完成这个过程。它设计用于与非阻塞代码的演员一起使用。分派器选择一个空闲的线程并将其分配给它选择的演员。然后演员处理一定数量的消息,之后释放线程。 -
平衡分派器:这种分派器创建一个可以被同一类型的多个演员共享的单个邮箱。来自邮箱的消息在共享分派器的演员之间进行分配。
-
固定分派器:这种分派器使用一个线程池的单个线程。这个线程被分配给一个演员。因此,每个演员都有自己的线程和邮箱,可以在不影响其他演员的情况下执行阻塞或长时间运行的活动。
-
调用线程分派器:这种分派器为每个演员分配一个线程。这主要用于测试。
在我们的案例中,Mixer 的实现中有一个阻塞调用。正因为如此,我们更倾向于使用固定分派器。首先,我们将向 application.conf 添加一个分派器配置:
mixers-dispatcher {
executor = "thread-pool-executor"
type = PinnedDispatcher
}
分派器的名称在配置的根级别定义,并且不嵌套在 akka 命名空间中。
Akka 使用 Typesafe Config 作为配置库。这是一个非常强大且有用的配置设施,绝对值得一看。您可以在 github.com/lightbend/config 找到它。
然后我们可以在创建演员时使用配置的分派器:
def props: Props = Props[Mixer].withDispatcher("mixer-dispatcher")
这样,每个混合器都将有自己的线程,阻塞不会影响其兄弟姐妹和其他系统中的演员。
在等待后,混合器将生产的面团返回给 sender() 并给自己发送一个 PoisonPill 以便终止。
终止演员
在 Akka 中停止一个演员有几种方法。最直接的方法是调用上下文的 stop 方法,如下所示:
context stop child
context.stop(self)
当演员处理完当前消息后,它会异步终止,但不会在收件箱中的其他消息之后终止。这与向演员发送PoisonPill或Kill消息不同,这些消息会被排队到邮箱中,并按顺序处理。
Kill消息将导致演员抛出ActorKilledException异常,这反过来又涉及其监管链(关于这个话题,在本章后面将详细介绍)以决定如何处理这个演员的失败。
与使用Kill相比,使用context或PoisonPill停止演员是优雅的。演员将停止其所有子演员,执行生命周期钩子,并适当地通知其监管者。
演员的终止是自上而下传播的,但实际的停止是自下而上的。一个慢速演员可能需要很长时间(或无限时间)来停止,这可能会阻止整个演员链的终止。
现在我们已经有了我们的阻塞Mixer,是时候定义一个Chef了:
ask 模式
使用Chef,我们将介绍 Akka 中另一个流行的模式——ask 模式:
class Chef extends Actor with ActorLogging with Stash {
import scala.concurrent.duration._
private implicit val timeout = Timeout(5 seconds)
override def receive = {
case Groceries(eggs, flour, sugar, chocolate) =>
for (i <- 1 to eggs) {
val mixer = context.watch(context.actorOf(Mixer.props, s"Mixer_$i"))
val message = Groceries(1, flour / eggs, sugar / eggs, chocolate / eggs)
import akka.pattern.ask
val job = (mixer ? message).mapTo[Dough]
import context.dispatcher
import akka.pattern.pipe
job.pipeTo(sender())
}
log.info("Sent jobs to {} mixers", eggs)
context.become(waitingForResults, discardOld = false)
}
def waitingForResults: Receive = {
case g: Groceries => stash()
case Terminated(child) =>
if (context.children.isEmpty) {
unstashAll()
context.unbecome()
log.info("Ready to accept new mixing jobs")
}
}
}
这里发生了很多事情,所以让我们逐行分析代码,并描述正在发生的事情。
сlass Chef extends Actor with ActorLogging with Stash
我们的Chef演员不仅是一个Actor——它还扩展了ActorLogging和Stash。ActorLogging特质为演员提供了一个预定义的logger。也可以直接定义Logger,例如,如下面的代码所示:
val log = akka.event.Logging(this)
Akka 内部使用基于消息的特殊日志记录设施,以最小化演员内部的阻塞。
Akka 日志记录支持 SLF4J 作为后端。官方文档(doc.akka.io/docs/akka/2.5/logging.html)详细解释了如何扩展配置以启用 SLF4J 日志记录到 Akka 应用程序中:
import scala.concurrent.duration._
private implicit val timeout = Timeout(5 seconds)
在这里,我们定义了一个 5 秒的超时,当我们开始使用混合器时将需要这个超时:
override def receive = {
case Groceries(eggs, flour, sugar, chocolate) =>
在receive方法中,我们的演员只接受Groceries消息,并使用模式匹配来提取字段值:
for (i <- 1 to eggs) {
val message = Groceries(1, flour / eggs, sugar / eggs, chocolate / eggs)
我们的混合器很小,所以我们需要将手头的杂货分成一个鸡蛋的份量,以便这部分可以放入混合器中:
val mixer = context.watch(context.actorOf(Mixer.props, s"Mixer_$i"))
在这里,我们使用之前定义的props创建了一个Mixer演员(这反过来又为它分配了正确的分发器),并适当地为其命名。
在以下两行代码中,我们可以看到隐式ask魔法的应用:
import akka.pattern.ask
val job: Future[Dough] = mixer ? message
在作用域内需要ask允许我们隐式地将ActorRef转换为AskableActorRef,然后将其用作消息的目标。actor ? message语法表示 ask 模式。Akka 向目标演员发送消息,并创建一个响应的期望作为Future[Any]。这个Future可以像其他任何Future一样处理。为了方便,Akka 提供了一个mapTo[T]方法,允许你将其转换为Future[T]。
在for理解中的最后一行代码,使用了 Akka 提供的另一个隐式转换,这次是针对Future的:
import akka.pattern.pipe
import context.dispatcher
job.pipeTo(sender())
在这里,我们引入了一个pipe作用域,它将正常的Future转换为PipeableFuture。后者可以通过使用第二行导入的隐式执行上下文,像前述代码的第三行那样,被管道传输到一个或多个演员。
第三行代码在成功的情况下将Future执行的结果管道传输给发送者。
如果第一次尝试失败,我们可以使用job.recoverWith将作业重新发送到混音器(mixer)。这是使用 ask 模式实现“至少一次”语义的简单方法。
在创建所有混音器并发送工作包后,Chef演员写入日志条目并开始等待结果:
log.info("Sent jobs to {} mixers", eggs)
context.become(waitingForResults, discardOld = false)
Akka 日志中有一个特殊的语法。第一个参数是一个String,其中包含{}占位符来表示其他参数。替换是在单独的线程中完成的,但只有当相应的日志级别被启用时。这样做是为了最小化演员线程的日志工作。
使用上下文改变演员的行为
在代码的最后一行,演员通过使用context.become构造来改变其行为。
become和unbecome是 Akka 在接收到消息时改变演员行为的常用方法。become接受一个Receive参数(它是PartialFunction[Any, Unit]的类型别名,也是receive方法的正常签名),它从下一个消息开始成为新的演员行为(这种行为的变化在演员重启时不会保留)。discardOld参数控制这个新行为是否应该替换旧行为,或者是否应该仅仅将其推到演员内部维护的行为堆栈中。我们将在下一分钟看到这个堆栈是如何工作的。
让我们回顾一下waitingForResults方法,这是演员刚才获得的新行为。第一行将任何Groceries消息挂起,因为我们已经在等待作业完成。这是通过使用Stash特质的stash()方法完成的,该方法将当前消息放入演员的内部存储中:
case g: Groceries => stash()
Chef演员监视它所创建的混音器(Mixer)演员。在这种情况下,如果一个孩子演员死亡,监视演员将接收到一个包含受害者演员引用的Terminated消息:
case Terminated(child) =>
演员(actor)通过使用context.children检查所有孩子(children)是否都已终止,如果是这样,它将使用unstashAll()将所有存储的消息(stashed messages)添加到消息框中,并通过使用context.unbecome()返回到其先前的行为。
不平衡的context.become()和context.unbecome()操作可能会在长时间运行的应用程序中引入内存泄漏的源头。
现在我们已经准备好了Chef,让我们继续并实现一个Manager。
高级主题
到目前为止,我们通过依赖基本 Akka 概念来实现我们的面包店。现在是时候深化我们的知识并开始使用高级概念了。
Akka FSM
Manager通过协调面包店的其他居民来推进饼干制作过程。它通过从演员那里获取表示工作结果的短信并将它们传递给适当的后续者来实现这一点。这个过程应该是连续的,也就是说,在目前只有购物清单而没有面团的情况下,应该不可能制作生饼干。我们将这种行为表示为状态机。
有限状态机(FSM)是由机器可以处于的一组状态定义的抽象。对于每个状态,它还定义了在此状态下可以接受的消息类型以及机器的可能反应,包括其新状态。
让我们深入研究代码,看看如何使用 Akka FSM 实现这种方法。
演员是通过扩展FSM特质来定义的:
class Manager(chef: ActorRef, cook: ActorRef, baker: ActorRef) extends FSM[State, Data]
类型参数State表示演员可以处于的状态类型,而Data表示可能的关联内部状态。
术语State(指代演员所代表的 FSM 的状态)和State(指代与过程中每个步骤相关联的数据)之间有一个明显的混淆。为了避免歧义,我们将进一步将演员的状态称为Data,将 FSM 的状态称为State。
演员的状态反映了面包店中发生的过程:商品从店员转移到厨师和厨师,然后转移到面包师,再回到经理(注意,由于经理执行的工作传递的顺序艺术,在我们面包店中此时只有一个工作演员是活跃的,即使他们可以与更复杂的经理并行工作)。
以下消息表示受管理面包店的状态:
trait State
case object Idle extends State
case object Shopping extends State
case object Mixing extends State
case object Forming extends State
case object Baking extends State
我们之前定义的消息也需要扩展以表示可能的数据类型:
sealed trait Data
case object Uninitialized extends Data
final case class ShoppingList(...) extends Data
final case class Groceries(...) extends Data
final case class Dough(weight: Int) extends Data
final case class RawCookies(count: Int) extends Data
有限状态机(FSM)本身是通过描述状态机的三个主要方面来定义的:
-
状态
-
状态转换
-
初始状态
让我们看看演员的代码,看看这是如何实现的。状态是在when块中定义的,它接受一个状态名称和一个状态函数:
when(Idle) {
case Event(s: ShoppingList, Uninitialized) ⇒
goto(Shopping) forMax (5 seconds) using s
case _ =>
stay replying "Get back to work!"
}
当同一状态下有多个when块时,构成它们的州函数将被连接。
状态函数是一个PartialFunction[Event, State],它描述了在特定状态下接收到的每个事件类型的新状态。Akka FSM 为此提供了一个很好的领域特定语言(DLS)。例如,在先前的代码中,演员通过在 5 秒超时的情况下将状态转换为Shopping状态来响应ShoppingList事件。购物清单用作新的状态数据。
在任何其他消息的情况下,演员保持同一状态,并以友好的评论回复发送者。
在Shopping状态中,Manager根据杂货是否符合购物单而做出不同的反应:
when(Shopping) {
case Event(g: Groceries, s: ShoppingList)
if g.productIterator sameElements s.productIterator ⇒
goto(Mixing) using g
case Event(_: Groceries, _: ShoppingList) ⇒
goto(Idle) using Uninitialized
}
在第一种情况下,它使用Groceries作为新的状态并进入下一个状态。在第二种情况下,它回到Idle状态并将状态设置为Uninitialized。
其他状态以类似的方式描述:
when(Mixing) {
case Event(p: Dough, _) ⇒
goto(Forming) using p
}
when(Forming) {
case Event(c: RawCookies, _) ⇒
goto(Baking) using c
}
when(Baking, stateTimeout = idleTimeout * 20) {
case Event(c: Cookies, _) ⇒
log.info("Cookies are ready: {}", c)
stay() replying "Thank you!"
case Event(StateTimeout, _) =>
goto(Idle) using Uninitialized
}
我们只是在进入下一个状态的过程中移动并更新状态数据。目前最明显的观察结果是这个演员除了自得其乐之外什么都不做,我们将通过使用onTransition块来解决这个问题,该块描述了演员在状态转换发生时的行为:
onTransition {
case Idle -> Shopping ⇒
val boy = sendBoy
boy ! stateData
case Shopping -> Idle =>
self ! stateData
case Shopping -> Mixing ⇒
chef ! nextStateData
case Mixing -> Forming =>
cook ! nextStateData
case Forming -> Baking =>
baker ! nextStateData
}
Manager 已经知道其下属,因此它只需要查找一个Boy。然后,对于每个状态转换,它通过使用stateData或nextStateData来获取必要的状态,这些数据引用了在相应状态转换之前和之后的演员状态数据。这些数据被发送到适当的下属。
现在,唯一缺少的是可选的whenUnhandled块,它在所有状态下执行。定时器设置和强制initiate()调用设置了定义的定时器并执行到初始状态的状态转换。
Akka FSM 强迫你将业务逻辑与演员相关代码混合,这使得测试和支持它变得困难。它还使你锁定在提供的实现中,使得引入另一个状态机实现变得不可能。在决定使用 Akka FSM 之前,始终考虑其他可能性。
同时,通过将状态的定义与行为分开,Akka FSM 允许对业务逻辑进行清晰的构建。
Akka 远程操作
现在,我们可以实现拼图的最后一部分——商店男孩和之前未覆盖的sendBoy函数。男孩不属于Bakery。管理者需要将男孩发送到代表另一个演员系统的杂货Store。
为了做到这一点,我们将依赖 Akka 的位置透明性和远程功能。首先,管理者将部署一个男孩演员到远程系统中。部署的演员将获得商店中Seller演员的引用,以便它可以在需要时获取杂货。
在 Akka 中使用远程操作有两种方式——要么通过使用演员查找,要么通过使用演员创建。这两种方式与我们迄今为止在本地使用的方式相同,即通过调用actorSelection和actorOf分别实现。
在这里,我们将演示管理者如何查找男孩应该从他那里获取杂货的卖家(想象这个卖家与面包店以预付费为基础合作),然后要求男孩与这个特定的演员进行交互。
在我们深入研究代码之前,我们需要增强我们应用程序的设置。远程操作是 Akka 中的一个独立依赖项,我们将将其放入build.sbt:
libraryDependencies += "com.typesafe.akka" %% "akka-remote" % akkaVersion
然后,我们需要将本地演员提供者替换为远程的一个,并在 application.conf 中配置网络设置:
akka {
actor.provider = remote
remote {
enabled-transports = ["akka.remote.netty.tcp"]
netty.tcp {
hostname = "127.0.0.1"
port = 2552
}
}
}
同样的配置,但端口必须不同,是为代表杂货店的第二个演员系统提供的。这是通过包含 application.conf 并重新定义 TCP 端口来完成的:
include "application"
akka.remote.netty.tcp.port = 2553
然后,我们需要定义杂货店:
import akka.actor._
import com.example.Manager.ShoppingList
import com.example.Mixer.Groceries
import com.typesafe.config.ConfigFactory
object Store extends App {
val store = ActorSystem("Store", ConfigFactory.load("grocery.conf"))
val seller = store.actorOf(Props(new Actor {
override def receive: Receive = {
case s: ShoppingList =>
ShoppingList.unapply(s).map(Groceries.tupled).foreach(sender() ! _)
}
}), "Seller")
}
我们不能使用默认配置,因为它已经被面包店系统占用,所以我们需要使用 ConfigFactory.load 加载一个自定义的 grocery.conf。接下来,我们需要创建一个匿名(但命名!)演员,其唯一责任是根据 ShoppingList 将杂货返回给发送者。
最后,我们准备在 Manager 中实现 sendBoy 函数:
private def sendBoy: ActorRef = {
val store = "akka.tcp://Store@127.0.0.1:2553"
val seller = context.actorSelection(s"$store/user/Seller")
context.actorOf(Boy.props(seller))
}
首先,我们必须定义杂货店的地址。然后,我们需要使用远程系统上的地址查找卖家。Akka 的文档指定了以下远程演员查找的模式:
akka.<protocol>://<actor system name>@<hostname>:<port>/<actor path>
我们将稍后查看这个模板,特别是演员路径。
然后,我们需要使用我们常用的 actorOf 方法创建一个男孩。为了告诉 Akka 远程部署这个演员,我们需要将以下配置放入 application.conf:
akka.actor.deployment {
/Manager/Boy {
remote = "akka.tcp://Store@127.0.0.1:2553"
}
}
这指示 Akka 不要实例化一个本地演员,而是通过名为 Store 的网络地址 127.0.0.1:2553 的远程守护进程来联系演员系统,并告诉这个守护进程创建一个远程演员。
我们可以在不扩展配置的情况下实现相同的结果,直接在代码中提供部署配置:
val storeAddress = AddressFromURIString(s"$store")
val boyProps = Boy.props(seller).withDeploy(Deploy(scope = RemoteScope(storeAddress)))
context.actorOf(boyProps)
这个片段创建了一个从我们之前定义的字符串中创建的商店地址,并明确告诉 Akka 在创建演员时使用它。
Boy 的实现现在很简单:
object Boy {
def props(seller: ActorSelection): Props = Props(classOf[Boy], seller)
}
class Boy(seller: ActorSelection) extends Actor {
override def receive = {
case s: ShoppingList =>
seller forward s
self ! PoisonPill
}
}
Boy 构造函数接受一个类型为 ActorSelection 的参数,这是之前由 Manager 执行的远程查找的结果。通过接收一个 ShoppingList,我们的实现使用 forward 将消息直接转发给卖家。正因为这种转发,卖家将收到一个由原始演员(即管理者)作为发送者的消息。
最后,我们将考虑到男孩是由管理者仅为一次购物而创建的,我们需要清理资源。这可以通过管理者演员完成,但我们更倾向于在这里进行自我清理。Boy 在转发原始消息后立即发送给自己一个 PoisonPill 并终止。
现在我们已经定义了面包店的全部居民,我们可以将它们连接起来,做一些饼干:
object Bakery extends App {
val bakery = ActorSystem("Bakery")
val cook: ActorRef = bakery.actorOf(Props[Cook], "Cook")
val chef: ActorRef = bakery.actorOf(Props[Chef], "Chef")
val oven: ActorRef = bakery.actorOf(Oven.props(12), "Oven")
val baker: ActorRef = bakery.actorOf(Baker.props(oven), "Baker")
val manager: ActorRef = bakery.actorOf(Manager.props(chef, cook, baker), "Manager")
}
在我们运行我们的应用程序并享受一些饼干之前,让我们暂时放下编码,看看我们在远程演员查找模式中看到的演员路径。
演员路径
根据演员模型,Akka 演员是分层的。
角色路径是通过将层次结构中每个角色的名称向上到根角色,然后使用斜杠从右到左连接它们来构建的。在路径的开始部分,有一个地址部分用于标识协议和角色的系统位置。这个地址部分被称为 锚点,其表示方式对于本地和远程系统是不同的。
在我们的例子中,Boy 的整个路径,由部署配置中的本地路径 /Manager/Boy 描述,对于 Boy 演员将是 akka://user/Bakery/Manager/Boy(纯本地路径),而对于远程 Store 演员系统中的 Seller 演员将是 akka.tcp://Store@127.0.0.1:2553/user/Seller(远程路径),如从 Bakery 方面所示。
如你所见,远程引入了构建和使用演员路径的方式所必需的差异。
角色路径的主要目的是定位我们即将发送消息给的角色。在技术层面上,我们有一个用于向演员发送消息的抽象,即 ActorRef。对于每个演员,其 ActorRef 通过 self 字段提供对其本地引用的访问,并通过 context.sender() 提供对当前消息发送者的访问。每个 ActorRef 指向一个演员。ActorRef 还包含演员的调度器和邮箱。调度器负责在演员的邮箱中排队消息,并在将它们传递给演员的 receive 方法之前在特定时间将其出队。
我们已经看到了创建 ActorRef 的两种方式:
-
通过使用
context.actorOf创建一个角色 -
通过使用
context.actorSelection查找一个或多个角色
在第二种情况下,有不同方式提供演员路径进行查找:
-
绝对路径:
context.actorSelection("/user/Manager/Boy")返回一个具有指定路径或空选择的单个演员 -
相对路径:
context.actorSelection("../sibling")向上到层次结构中的父级,然后在存在的情况下向下到 "sibling"。 -
通配符:
context.actorSelection("../*")向上到层次结构,并选择演员父级的所有子代,包括当前演员
角色监督
现在,让我们解释另一个你可能想知道的演员路径中的奇怪之处——我们之前看到的演员路径中的前导 /user 部分。这一部分的存在是 Akka 对我们在本章开头提出的问题的回答——第一个角色是如何创建的?
在 Akka 中,第一个角色是由库本身创建的。它代表一个根角色,因此被称为根守护者(我们将在稍后解释 守护者 部分的内容)。
实际上,Akka 为每个演员系统创建了三个守护者演员,如下面的图所示。
/ 根守护者是两个其他守护者的父级,因此是系统中任何其他角色的祖先。
/user 守护者是系统中所有用户创建的演员的根演员。因此,由 Akka 库的任何用户创建的每个演员在层次结构中都有两个父演员,因此其路径前缀为 /user/。
/system 是系统创建的内部演员的根演员。
让我们用我们刚刚学到的守护者演员扩展我们的演员图:

我们使用 system.context 实例化所有演员,除了混合器。因此,它们被创建为用户守护者的子演员。根守护者在层次结构的顶部,其子演员是用户守护者和系统守护者。
守护者是 Akka 另一个重要功能——监督的一部分。为了了解什么是监督以及为什么它很重要,让我们最终运行我们的应用程序。
以下是在控制台中的清理输出:
...
[INFO] Remoting now listens on addresses: [akka.tcp://Bakery@127.0.0.1:2552]
[INFO] [akka.tcp://Bakery@127.0.0.1:2552/user/Chef] Sent jobs to 24 mixers
[INFO] [akka.tcp://Bakery@127.0.0.1:2552/user/Chef] Ready to accept new mixing jobs
[INFO] [akka.tcp://Bakery@127.0.0.1:2552/user/Manager] Cookies are ready: Cookies(12)
[ERROR] [akka.actor.LocalActorRefProvider(akka://Bakery)] guardian failed, shutting down system
java.lang.AssertionError: assertion failed
at scala.Predef$.assert(Predef.scala:204)
...
...
[akka.tcp://Bakery@127.0.0.1:2552/system/remoting-terminator] Remoting shut down.
发生了什么?演员系统已启动,演员之间的通信也已开始,但随后抛出了一个 AssertionError 并导致整个系统终止!
这种异常的原因是我们之前描述的 Baker 演员中的简单编程错误:
override def receive: Receive = {
...
case c: Cookies =>
context.actorSelection("../Manager") ! c
assert(timer.isEmpty)
if (queue > 0) timer = sendToOven() else timer = None
}
断言“计时器为空”是错误的,因此在运行时抛出异常。该异常没有被捕获,导致程序终止。显然,在这种情况下,演员模型(如本章开头所述)的规则没有得到尊重。一个演员在没有发送任何消息的情况下影响了所有其他演员和整个系统。
实际上,这并不是 Akka 的缺陷。我们的应用程序之所以表现如此,是因为我们忽略了基于演员系统的非常重要的一个方面——监督。
在 Akka 中,监督意味着任何创建子演员的演员在出现问题的情况下负责其管理。
检测到错误条件的演员应暂停其所有后代和自身,并向其父演员报告失败。这种失败报告具有异常抛出的形式。
按照惯例,预期的错误条件,例如数据库中记录的缺失,通过技术性质的消息和错误在协议级别进行建模,例如使用异常来模拟不可用的数据库连接。为了更好地区分错误条件,鼓励开发人员定义一组丰富的异常类,类似于正常消息类。
子演员抛出的异常被传递给父演员,然后父演员需要以四种可能的方式之一来处理这种情况:
-
恢复子演员,并让它从下一个开始处理消息框中的消息。导致演员失败的该消息已丢失。
-
重新启动子演员。这将清理其内部状态,并递归地停止其所有后代。
-
完全停止子演员。这也会递归地传播到其后代。
-
将故障传播给其父组件。通过这样做,监督者以与下属相同的原因为自己失败。
在深入研究定义监督策略的技术细节之前,让我们回顾一下我们的演员结构。目前,我们所有的演员(除了动态混合器)都是作为用户守护者的直接子组件创建的。这导致有必要在演员层次结构的整个位置定义监督策略。这是对分离关注点原则的明显违反,在 Akka 中被称为扁平演员层次结构反模式。我们应该追求的是创建一个结构,其中错误处理发生在最接近错误发生位置的演员处,该演员最有能力处理此类错误。
以此目标为指导,让我们重构我们的应用程序,使Baker演员负责监督Oven,而Manager负责系统中所有的演员。这种结构在以下图中表示:

现在,我们有一个合理的层次结构,其中每个监督者都对其子组件可能出现的故障以及如何处理它们有最佳的了解。
在技术层面上,监督策略是通过覆盖相应演员的supervisorStrategy字段来定义的。为了演示如何实现这一点,让我们扩展我们的Mixer演员,使其能够报告不同的硬件故障。首先,我们必须在伴随对象中定义一个丰富的异常集:
class MotorOverheatException extends Exception
class SlowRotationSpeedException extends Exception
class StrongVibrationException extends Exception
然后我们需要在消息处理过程中随机抛出它们:
class Mixer extends Actor with ActorLogging {
override def receive: Receive = {
case Groceries(eggs, flour, sugar, chocolate) =>
val rnd = Random.nextInt(10)
if (rnd == 0) {
log.info("Motor Overheat")
throw new MotorOverheatException
}
if (rnd < 3) {
log.info("Slow Speed")
throw new SlowRotationSpeedException
}
...
}
}
现在,我们将在Chef演员中覆盖一个监督策略:
override val supervisorStrategy: OneForOneStrategy =
OneForOneStrategy(maxNrOfRetries = 10, withinTimeRange = 1 minute) {
case _: MotorOverheatException ⇒
self ! Dough(0)
Stop
case _: SlowRotationSpeedException ⇒
sender() ! message
Restart
case _: StrongVibrationException =>
sender() ! message
Resume
case _: Exception ⇒ Escalate
}
OneForOneStrategy指示Chef个别处理任何子组件的故障。
对于MotorOverheatException,我们决定停止失败的Mixer。Chef向自己发送一个空的Dough消息,这被视为来自损坏子组件的响应。
SlowRotationSpeedException表示在将食品放入Mixer的过程中出现了问题。原始消息在Mixer抛出异常时丢失,所以我们正在重新发送此消息并重新启动Mixer。
我们可以容忍StrongVibrationException,所以我们只需通过重新发送丢失的消息并恢复子组件来补偿。
在任何其他异常的情况下,Chef不知道如何处理它,只是将故障传播给Manager。Manager没有定义任何supervisorStrategy,异常最终传播到用户守护者。
用户守护者按照默认策略处理异常。如果没有覆盖,默认策略对所有用户空间中的演员都是相同的,定义如下:
-
ActorInitializationException:停止失败的子演员 -
ActorKilledException:停止失败的子演员 -
DeathPactException:停止失败的子演员 -
Exception:重启失败的子演员 -
Throwable:升级到父演员
根守护者配置为SupervisorStrategy.stoppingStrategy,它区分了Exception和其他可抛出物。前者导致失败演员的终止(这实际上意味着/user或/system空间中的所有演员),而后者进一步传播并导致演员系统的终止。这就是我们早期实现抛出AssertionError时发生的情况。
可以通过使用其配置属性来覆盖用户守护者的监督策略。让我们演示如何使用它来处理偶尔抛出的LazinessException,这可能是系统中的任何演员抛出的。首先,我们增强application.conf:
akka {
actor {
guardian-supervisor-strategy = ch11.GuardianSupervisorStrategyConfigurator
}
}
然后我们实现配置的策略,如下所示:
class GuardianSupervisorStrategyConfigurator
extends SupervisorStrategyConfigurator {
override def create(): SupervisorStrategy = AllForOneStrategy() {
case _: LazyWorkerException ⇒
println("Lazy workers. Let's try again with another crew!")
Restart
}
}
懒惰具有传染性,所以我们使用AllForOneStrategy通过重启用户守护者的所有子代来替换整个团队。
测试演员
基于演员的系统与传统方法构建的系统不同。自然地,测试演员与常规测试不同。演员以异步方式发送和接收消息,通常通过消息流分析来检查。典型的设置将包括三个部分:
-
消息的来源
-
待测试的演员
-
演员的响应接收者
幸运的是,Akka 包括一个测试模块,它抽象了大量的设置逻辑,并为常见的测试活动提供了有用的辅助工具。该模块的名称是 Akka TestKit,它包含在一个需要添加到项目测试范围的独立模块中:
libraryDependencies += "com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
有这个依赖项,我们可以扩展TestKit类。TestKit实现了一个特殊的测试环境,它模仿了正常演员系统的内部结构,但提供了访问一些在生产实现中隐藏的细节。
这是一个扩展TestKit的ScalaTest规范的示例:
class BakerySpec(_system: ActorSystem) extends TestKit(_system)
with Matchers with WordSpecLike with BeforeAndAfterAll
with ImplicitSender {
def this() = this(ActorSystem("BakerySpec"))
override def afterAll: Unit = shutdown(system)
在这里,我们使用通常的ScalaTest匹配器和WordSpec扩展TestKit,但还混合了BeforeAndAfterAll和ImplicitSender。然后,我们通过实例化一个BakerySpec演员系统来实现默认构造函数。最后,我们覆盖一个afterAll方法,以确保测试后的测试演员系统被正确终止。
在 SBT 中,测试通常并行运行。在这种情况下,正确命名演员系统很重要,并且在这种情况下,远程通信也用于覆盖默认端口,以避免同时执行的测试之间的冲突。此外,我们不应忘记优雅地关闭演员系统,以确保我们的资源被正确清理。
TestKit实现并引入了testActor字段,我们可以用它从测试代码中发送消息。通常,我们希望这些消息来自一个知名的演员。ImplicitSender特质实现了一个引用testActor,它在消息发送时附加到消息上。
TestKit还维护一个发送到testActor的消息的内部队列,并定义了一系列有用的方法来检查这个队列。
这就是一些预定义方法如何用来测试我们的Boy演员:
"The boy should" should {
val boyProps = Boy.props(system.actorSelection(testActor.path))
val boy = system.actorOf(boyProps)
"forward given ShoppingList to the seller" in {
val list = ShoppingList(0, 0, 0, 0)
boy ! list
within(3 millis, 20 millis) {
expectMsg(list)
lastSender shouldBe testActor
}
}
"ignore other message types" in {
boy ! 'GoHome
expectNoMessage(500 millis)
}
}
回顾Boy演员的逻辑,它所做的只是将ShoppingList转发到作为构造函数参数提供的另一个演员。为了测试这种行为,我们首先创建一个ActorSelection,这是男孩构造函数所必需的,使用我们的默认testActor作为目标,并在TestKit为我们提供的测试演员系统中创建一个男孩演员。
在第一个测试中,我们向Boy发送一个ShoppingList,并期望它在 3 到 30 毫秒的预定义时间间隔内将其转发给testActor。我们验证消息确实是一个ShoppingList,发送者是testActor。
在第二个测试中,我们验证Boy忽略了其他消息。为了检查这一点,我们向它发送一个Symbol类型的消息,并期望我们的testActor在 500 毫秒内没有收到任何消息。由于正常的转发在我们的第一个测试中预计不会超过 20 毫秒,我们可以确信消息已被Boy忽略。
testActor、lastSender、within、expectMsg和expectNoMsg由TestKit实现,并帮助我们避免编写样板代码。
在TestKit中还有许多其他有用的方法,我们很快就会查看它们。大多数方法存在两种形式:一种接受超时作为参数,另一种使用默认超时。超时定义了TestKit等待条件发生的时长。默认超时可以通过使用within包装器来覆盖,如前所述,通过更改配置或使用会影响作用域内所有持续时间的timescale参数。
我们已经熟悉了expectMsg和expectNoMessage断言。让我们看看其他一些可用的辅助工具:
-
def expectMsgClassC:C期望并返回一个类型为 C 的单条消息。 -
def expectMsgTypeT:T与之前的辅助工具相同,但使用隐式构造类型参数。 -
def expectMsgAnyOfT: T这期望一条消息并验证它是否等于构造函数参数之一。 -
def expectMsgAnyClassOfC:C与之前相同,但验证的是消息的类型而不是实际的消息。 -
def expectMsgAllOfT:Seq[T]期望消息的数量并验证它们是否都等于构造函数参数。 -
def expectMsgAllClassOfT:Seq[T]与之前的功能相同,但验证消息的类型。 -
def expectMsgAllConformingOfT:Seq[T]与expectMsgAllClassOf的功能相同,但检查的是一致性(instanceOf)而不是类相等。 -
def expectNoMessage():Unit验证在指定的或默认的超时时间内没有接收到任何消息。 -
def receiveN(n: Int):Seq[AnyRef]接收 N 条消息并将它们返回给调用者以进行进一步验证。 -
def expectMsgPFT(f: PartialFunction[Any, T]):T期望接收一个消息,并验证给定的部分函数是定义的。 -
def expectTerminated(target: ActorRef, max: Duration = Duration.Undefined):Terminated期望从指定的target接收一个Terminated消息。 -
def fishForMessage(max: Duration = Duration.Undefined, hint: String = "")(f: PartialFunction[Any, Boolean]):Any期望接收多个消息,其中给定的部分函数是定义的。它返回第一个使f返回 true 的消息。
我们的Baker演员设计成向其父演员发送消息,这意味着如果我们使用测试演员系统创建它,我们将无法接收来自Baker的响应。让我们看看TestKit如何帮助我们解决这个问题:
"The baker should" should {
val parent = TestProbe()
val baker = parent.childActorOf(Props(classOf[Baker], 0 millis))
"bake cookies in batches" in {
val count = Random.nextInt(100)
baker ! RawCookies(Oven.size * count)
parent.expectMsgAllOf(List.fill(count)(Cookies(Oven.size)):_*)
}
}
在这里,我们使用TestProbe()构建一个测试演员。TestProbe是TestKit提供的另一个很好的功能,允许你发送、接收和回复消息,在需要多个测试演员的测试场景中非常有用。在我们的情况下,我们使用其创建子演员的能力来创建一个Baker演员作为子演员。
然后,我们需要生成多个RawCookies,这样就需要多次翻烤。我们预计在下一行会向parent发送这么多条消息。
到目前为止,我们已经在隔离状态下测试了演员。我们的Store是以一种方式构建的,它会实例化一个匿名演员。这使得在隔离状态下测试演员的方法变得不可能。让我们演示一下我们如何验证Seller演员在给定ShoppingList时返回预期的Groceries:
class StoreSpec(store: Store) extends TestKit(store.store)
with Matchers with WordSpecLike with BeforeAndAfterAll {
def this() = this(new Store {})
override def afterAll: Unit = shutdown(system)
"A seller in store" should {
"do nothing for all unexpected message types" in {
store.seller ! 'UnexpectedMessage
expectNoMessage()
}
"return groceries if given a shopping list" in {
store.seller.tell(ShoppingList(1, 1, 1, 1), testActor)
expectMsg(Groceries(1,1,1,1))
}
}
}
我们将像之前一样构建我们的测试类,但有一个细微的区别。Seller演员是匿名定义的,因此它只作为整个演员系统的一部分被构建。因此,我们在默认构造函数中实例化Store,并使用通过store字段可访问的底层演员系统作为TestKit实例的构造函数参数。
在测试本身中,我们直接通过我们之前构建的store将测试输入发送到seller ActorRef。我们没有扩展ImplicitSender,需要显式提供一个testActor作为发送者引用。
现在我们已经实现了并测试了我们的应用程序,让我们运行它吧!
运行应用程序
如果您还没有安装 Java 和 SBT,请参阅附录 A,准备环境和运行代码示例。
我们将通过使用两个独立的终端会话来在终端中运行我们的应用程序,一个用于Shop,另一个用于Bakery。可以通过在相应的 shell 中发出以下两个命令之一来运行这两个命令:
-
sbt "runMain ch11.Store" -
sbt "runMain ch11.Bakery"
在我们的代码中,我们没有处理Shopping/ShoppingList状态的StateTimeout。因此,必须首先启动存储会话,然后它加载并开始接受连接,表示可以启动面包店会话。
还可以使用在附录 A,准备环境和运行代码示例中记录的方法来在 SBT 会话中运行代码,并在之后选择适当的主类。这种方法在以下屏幕截图中表示:

在这里,我们可以看到如何在ch11文件夹中启动两个 SBT 会话。在屏幕的右侧,存储的主要类已经被 SBT 选中并运行。日志显示Store正在监听连接,因此可以安全地启动主Bakery会话。
这是在我们在左侧终端窗口中输入1之后发生的:

存储端反映了连接已建立,面包店端开始输出关于当前活动的日志语句。它持续运行并记录所发生的事情,直到通过按下Ctrl + C停止。
摘要
让我们回顾一下在本章中学到的内容。
使用传统方法很难满足当前的扩展需求。无共享模式的 actor 模型为解决这个问题提供了解决方案。Akka 是一个用于在 JVM 上构建基于 actor 的应用程序的库。
Actor 通过发送和接收消息进行通信,并改变其内部状态以及产生副作用。每个 actor 都有一个地址,形式为ActorRef,它还封装了一个 actor 的邮箱和调度器。actor 被组织成层次结构,其中父 actor 负责对其子 actor 的监督。
Actor 有一个定义良好的生命周期,并在其生命周期中的适当时刻实现了一系列方法。
Akka 提供了额外的模块,这些模块进一步扩展了提供的功能。
我们还研究了 Akka FSM,它允许我们通过编码 actor 的可能状态和状态转换来将 actor 表示为 FSM。
Akka 远程实现实际上的位置透明原则,并允许您轻松访问远程 Akka 系统。
测试演员与测试常规代码不同。Akka TestKit 是由 Akka 团队提供的库,它简化并加速了测试过程。它通过将测试的演员置于一个受控但接近真实环境的场景中来实现这一点。
在下一章中,我们将使用不同的基于演员的方法和不同的 Akka 库——Akka Typed,重新构建我们的面包店。
问题
-
列举两种演员在接收到消息后可以改变自己的方式。
-
ActorRef的目的是什么? -
在系统守护者的官方文档描述中,查找的主要目的是什么?
-
使用 Akka FSM 的优缺点是什么?
-
在另一个演员系统中,演员可以通过多少种方式被访问?描述它们。
-
为什么测试演员需要特殊的工具包?
进一步阅读
-
Christian Baxter,精通 Akka:掌握使用 Akka 创建可扩展、并发和响应式应用程序的艺术。
-
Héctor Veiga Ortiz 和 Piyush Mishra,Akka 烹饪秘籍:学习如何使用 Akka 框架在 Scala 中构建有效的应用程序。
第十二章:使用 Akka Typed 构建反应式应用
本章揭示了使用 Akka 构建反应式应用的另一种方式。我们将介绍 Akka Typed,这是一个 Akka 模块,它以一种与无类型 Akka 略有不同的方式实现了演员模型。我们将对比经典和类型化方法,并展示后者如何减少开发者的选择,但增加类型安全性,并在维护阶段简化基于演员的程序推理。
本章将涵盖以下主题:
-
类型化和无类型方法之间的差异
-
创建、停止和发现演员
-
演员的生命周期和监督
-
调度器
-
缓存
-
组合行为
-
测试
技术要求
在我们开始之前,请确保你已经安装了以下内容:
-
Java 1.8+
-
SBT 1.2+
本章的源代码可在我们的 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter12.
本章中的代码片段已经简化了一些,省略了不必要的技术细节。请参考 GitHub 仓库以获取完全功能性的示例。
简介
在第十一章《Akka 和演员模型简介》中,我们发现了演员模型以及 Akka 是如何实现它的。
原始的演员论文提出了演员作为计算单元可以执行的三种可能的行为:
-
-
他们可以向其他已知的演员发送消息
-
他们可以创建新的演员
-
他们可以为未来的消息处理指定行为
-
由于该模型的通用性,这些点的具体实现方式取决于硬件、操作系统、编程语言、现有库,以及最终取决于实现者的设计选择。Akka Typed 与无类型 Akka 相比提供了一个略微不同的编程模型。
此外,在本章中,我们将提到正常的 Akka 为 Akka 无类型,以明确指出正在讨论哪个库,尽管在前一章中无类型的 Akka 总是被称为Akka。
让我们更仔细地看看这两种实现之间的差异和相似之处。
类型化方法和无类型 Akka 之间的差异
与无类型版本相比,Akka Typed 在定义演员是什么方面采取了一种略微不同的方法。
在 Akka 无类型中,演员是任何继承自抽象Actor类并重写def receive: PartialFunction[Any, Unit]方法的对象。这允许开发者在其实现中做任何事情,除了返回一个有意义的成果,这使得推理代码变得困难,并且无法组合演员逻辑。
Akka Typed 声明任何定义良好的行为都是一个计算实体,因此可以声明为 actor。在 Akka Typed 中,“定义良好”的意思是任何定义了静态类型Behavior的东西。Behavior的类型限制了 actor 只能接收特定类型的消息。actor 行为的返回类型需要是同一类型的下一个Behavior,相对于继承。这样,就可以在编译时确保 actor 只会接收它声明的要处理的类型的消息。
为了实现这一点,演员地址也需要进行类型化,并且地址的类型需要在编译时已知。因此,无类型 Akka 的一些功能,如对当前消息发送者的隐式访问和通用 actor 查找,在类型化 Akka 中不可用。相比之下,演员的地址需要作为协议的一部分进行定义,或者需要由外部(相对于演员)的设施进行管理。
另一个值得注意的改动是引入了Signal消息类型,它代表了演员生命周期中的事件,并取代了在无类型 Akka 中由Actor类公开的专用回调方法。尽管这在整个变化图中不是一个非常大的亮点,但这是一个很好的举措,可以使 Akka 的 actor 模型实现更接近抽象 actor 模型。
简而言之,Akka Typed 将 actor 的通信和行为限制在模型中,然后可以在编译时进行类型检查。这限制了开发者在实现方面的选择和可能性,但同时也使得结果更容易推理和测试。某些无类型功能的不可用使得无法以表示 Akka 反模式的方式编写代码,并导致解决方案类似于在正常 Akka 中被认为是最佳实践的解决方案。
此模块目前标记为可能更改 (doc.akka.io/docs/akka/2.5/common/may-change.html)。这反映了该主题本身是活跃研究的主题,API 可能会有一些变化。然而,当前的实现是稳定的,并且与最近版本更新相比,API 的变化很小。因此,Akka 团队认为 Akka Typed 已经准备好投入生产。
让我们看看这些差异在实际中是如何体现的。
示例演员系统
为了说明 Akka Typed 的特性,我们将重新实现第十一章中构建的示例,Akka 和 Actor 模型简介,但这次使用类型化演员。
对于熟悉上一章内容的读者,这种方法将允许您比较两种不同的风格。对于新加入的读者,让我们快速回顾一下这个示例的结构。
我们正在构建一个小型饼干烘焙坊,其中包含许多演员,每个演员都有自己的责任集:
-
Manager驱动整个过程,并将材料从一个工人传递到另一个工人。 -
Boy拿取ShoppingList并将Store中的相应Groceries返回给Manager。 -
Chef拿取Groceries并将其制作成Dough。它通过使用多个Mixers来完成,具体的Mixers数量取决于需要混合的物品数量。 -
Cook拿取Dough并制作RawCookies。 -
Baker使用单个有限容量的Oven批量烘焙RawCookies。
我们将要构建的 actor 系统的结构在以下图中表示:

让我们从实现我们系统中最简单的 actor —— Oven 开始。在这里以及本章后面的内容中,我们将参考之前的实现,即我们在第十一章 An Introduction to the Akka and Actor Models 中关于无类型 actor 的实现。这些差异非常具有说明性,因此我们建议读者即使已经熟悉无类型 Akka,也请参考前一章的代码。
要能在我们的代码中使用 Akka Typed,我们需要在 build.sbt 中添加以下依赖项:
lazy val akkaVersion = "2.5.13"
libraryDependencies += "com.typesafe.akka" %% "akka-actor-typed" % akkaVersion
将 akkaVersion 分别定义为 val 有一个优点,即它可以被其他模块重用,并在新版本可用时在单个位置进行更改。
为了使我们的示例保持简洁和简短,我们假设以下输入在每个代码片段中都是现成的:
import akka.actor.typed._
import akka.actor.typed.scaladsl._
第一个输入将较低级别的 actor 系统抽象引入作用域,第二个允许我们使用更高层次的 DSL 来定义 actor 的行为。
第一个示例
首先,我们需要定义我们的 Oven 将要使用的协议。与无类型实现相比,我们不能重用由另一个 actor 定义的消息。这是因为 Oven(以及后续阶段的其他 actor)定义了它应该处理的消息类型。这种类型不应该过于通用,以避免使整个实现比期望的更少类型化。
领域模型对所有 actor 都是通用的,因此我们将在 Bakery 应用程序级别定义它:
final case class Groceries(eggs: Int, flour: Int, sugar: Int, chocolate: Int)
final case class Dough(weight: Int)
final case class RawCookies(count: Int)
final case class ReadyCookies(count: Int)
这就是我们 Oven 使用的简短语言:
sealed trait Command
case class Put(rawCookies: Int, sender: ActorRef[Baker.Command]) extends Command
case class Extract(sender: ActorRef[Baker.Command]) extends Command
当 Put 命令中的饼干数量多于烤箱容量时,Oven 可以返回 ReadyCookies(一旦饼干被放入烤箱,就被认为是准备好了)和 RawCookies。Command 是我们 actor 的一种行为类型。我们可以看到它包括 sender 字段,这样烤箱就知道提取的饼干的接收者是谁。
现在,我们需要定义 actor 的行为。如果你跟随了上一章,你会记得我们使用内部可变字段来存储烤箱在当前时刻的内容。通过使用这个字段,我们可以区分其对传入消息的反应。Akka Typed 鼓励我们采取不同的方法,并为 actor 的不同状态使用不同的行为。首先,我们定义在没有任何内容的情况下的行为:
def empty: Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Put(rawCookies, sender) =>
val (inside, overflow: Option[RawCookies]) = insert(rawCookies)
overflow.foreach(sender.tell)
full(inside)
}
这里,我们使用Behaviors工厂定义了一个空Oven的行为。在我们的例子中,这是一个带有类型参数Command的receiveMessage方法。这指定了我们的 actor 可以处理的消息类型。
接下来,我们定义在接收到Put命令的情况下的行动方案。insert方法返回我们可以放入Oven中的饼干数量以及可选的溢出。在这种情况下,如果有溢出,我们使用其ActorRef[Cookies]的tell方法将其返回给发送者。引用类型允许我们发送RawCookies。由于 actor 定义的类型安全特性,这将Bakeractor(我们很快将实现)的行为绑定到Behaviors.Receive[Cookies]。
现在,我们需要定义当Oven不为空时应该发生什么:
def full(count: Int): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Extract(sender) =>
sender ! ReadyCookies(count)
empty
}
这种行为甚至更简单,但仍然具有相同的类型—Behaviors.Receive[Command]。我们只需将所有内部的饼干返回给发送者,并将未来的行为更改为我们之前定义的empty行为。
现在,如果我们编译这个实现,编译器会提出投诉:
Warning:(18, 77) match may not be exhaustive.
It would fail on the following input: Extract(_)
def empty: Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
Warning:(25, 88) match may not be exhaustive.
It would fail on the following input: Put(_, _)
def full(count: Int): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
编译器已经帮助我们识别出前两个错误!它对当前实现不满意的原因是我们忘记定义对特定状态下不合适的消息的反应。这将尝试从空烤箱中提取饼干并将某些东西放入满的烤箱中。从类型角度来看,这是可能的,编译器已经通知了我们这一点。
让我们通过正确实现我们的状态来解决这个问题:
这是empty状态的扩展定义:
def empty: Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Put(rawCookies, sender) =>
val (inside, tooMuch) = insert(rawCookies)
tooMuch.foreach(sender.tell)
full(inside)
case Extract(sender) =>
sender ! ReadyCookies(0)
Behaviors.same
}
发送者将收到零个饼干,我们通过使用Behavior.same来保持当前行为。
对于full情况,原则保持不变:
def full(count: Int): Behaviors.Receive[Command] = Behaviors.receiveMessage[Command] {
case Extract(sender) =>
sender ! ReadyCookies(count)
empty
case Put(rawCookies, sender) =>
sender ! RawCookies(rawCookies)
Behaviors.same
}
同样,我们只是将我们拥有的所有东西都返回给发送者,并保持当前行为与空情况完全一样。
Akka Typed 基础知识
现在我们让编译器满意,并且对我们如何使用类型化 actor 有了初步的了解,让我们采取更原则性的方法,详细看看它们是如何被创建、发现和停止的,以及有哪些可能性可以改变 actor 的行为。
创建一个 actor
根据演员模型定义,创建演员的方式只有一种——它可以由另一个演员生成。Akka 通过使用ActorContext提供了两种稍微不同的可能性来完成这个任务。这两种方法都不是线程安全的,并且应该仅在演员的线程中直接使用。
第一种变体允许您从行为中实例化一个匿名演员,并返回一个ActorRef[T]:
def spawnAnonymousT: ActorRef[T]
这种实现为 props 参数提供了一个默认的空值,以便实际的调用可以简化为spawnAnonymous(behavior)。
在特定情况下不命名演员可能是有用的,但通常被认为是一种不好的做法。这是因为它使得调试更加困难,并且在没有依赖库当前实现细节的情况下,通过名称查找子演员变得不可能。
因此,在合理使用的情况下,应该优先考虑另一种实现:
def spawnT: ActorRef[T]
这里,我们需要提供一个行为和一个即将实例化的演员的名称。
spawn和spawnAnonymous都接受一个props参数,可以用来进一步配置演员实例。目前,只能配置演员的调度器。
调度器构成了运行行为的机制。调度器使用ExecutorServices将线程分配给演员,并且可以按照第十一章,Akka 和演员模型简介中描述的方式进行配置。目前,Akka Typed 仅支持从配置中定义调度器。可以通过覆盖akka.actor.default-dispatcher下的设置来更改默认调度器的属性。
在我们的示例系统中,Chef演员应该实例化Mixers,因为它们需要并行处理大量工作。此外,由于硬件的限制,mixers 使用阻塞代码,因此需要单独的调度器以避免系统其他部分的线程饥饿。让我们看看如何实现这种行为。
首先,通过使用application.conf,我们配置了一个用于阻塞 mixers 的调度器:
mixers-dispatcher {
executor = "thread-pool-executor"
type = PinnedDispatcher
}
然后,我们实例化所需数量的子演员:
object Chef {
sealed trait Command
final case class Mix(g: Groceries, manager: ActorRef[Manager.Command]) extends Command
def idle = Behaviors.receive[Command] {
case (context, mix@Mix(Groceries(eggs, flour, sugar, chocolate), manager)) =>
val props = DispatcherSelector.fromConfig("mixers-dispatcher")
val mixers = for (i <- 1 to eggs) yield
context.spawn(Mixer.mix, s"Mixer_$i", props)
mixing
}
def mixing = Behaviors.unhandled[Command]i
}
Chef演员有自己的命令层次结构,我们目前将其限制为Mix。我们需要为每个鸡蛋提供一个单独的Mixer,所以我们通过spawn来实例化它们。spawn返回一个演员引用,我们将它们收集在mixers val中。最后,我们返回下一个演员的Behavior,目前是unhandled。
好的,所以从演员的上下文中创建新的演员是可能的。这把我们带到了阿基里斯和乌龟的芝诺悖论一样的情境。自然地,要创建一个新的演员,我们需要已经有一个演员。Akka 通过要求开发者在创建演员系统时提供一个根演员的定义来解决这个悖论。
def applyT: ActorSystem[T]
这使得开发者别无选择,只能从上到下设计合适的演员层次结构。遵循这种类型化的方法可以自动避免扁平演员层次结构的反模式!
实例化演员的另一种可能性是通过使用ActorSystem的systemActorOf方法,该方法在/system空间中创建一个演员。这可以是有争议的,因为这个特性通常不应该被使用,因此这里没有涵盖。
现在,由于我们的Chef已经产生了足够的Mixers来完成工作,我们需要一种方法在任务完成后将它们移除。
停止一个演员
演员可以通过以下方式之一停止:
-
通过指定其下一个行为为
Behaviors.stopped。 -
通过将
ActorContext的stop方法应用于直接子演员。子演员将完成当前消息的处理,但将其他仍处于邮箱中的消息留待处理。 -
通过演员系统在停止其祖先时。实际的关闭是递归的,从下到上,遵循层次结构。
在我们的混合器示例中,最自然的方法是选择第一个选项。我们将在以下示例中实现它:
object Mixer {
final case class Mix(groceries: Groceries, sender: ActorRef[Collect])
def mix = Behaviors.receiveMessage[Mix] {
case Mix(Groceries(eggs, flour, sugar, chocolate), sender) =>
Thread.sleep(3000)
sender ! Collect(Dough(eggs * 50 + flour + sugar + chocolate))
Behaviors.stopped
}
}
Mixer行为非常简单,所以我们不需要为它定义 ADT 并直接使用单个Mix命令。Chef演员期望返回Collect(Dough)。这迫使我们将其定义为发送者引用的类型参数。行为本身模拟混合完成所需的硬件延迟,将准备好的面团发送给Chef,并返回Behaviors.stopped作为下一个行为。这导致Mixer演员的优雅终止。
现在我们已经将面团送回Chef,让我们看看它应该如何处理。Chef需要收集它创建的所有混合器的结果。为此,我们可以将我们在空闲状态下创建的子演员的引用传递给混合行为,但让我们假设我们由于某种原因丢失了收集到的引用。在这种情况下,Chef可以查找其子演员。
发现一个演员
演员发现是获取演员引用的另一种替代方法。首选的方法仍然是将演员引用纳入消息协议中。
Akka 提供了通过以下方法按名称(仅精确匹配)查找单个子演员的可能性:
def child(name: String): Option[ActorRef[Nothing]]
如果存在一个具有该名称且处于存活状态的子演员,则此操作返回对该子演员的引用。请注意,由于此方法的返回类型,需要使用ActorRef的narrow方法将结果强制转换为正确的类型。
另一种允许我们查找一个演员所有存活子演员的方法如下:
def children: Iterable[ActorRef[Nothing]]
结果的类型再次是一个没有特定类型的ActorRefs集合。
有争议的是,我们在这里描述的查找方法由于其基本无类型性质而用处不大。Akka Typed 提供了一个更好的替代方案,即receptionist。
receptionist是一个(集群)单例演员,在演员系统级别可用,可以通过以下调用链从ActorContext获取:
val receptionist: ActorRef[Receptionist.Command] = context.system.receptionist
receptionist只是一个类型为[Receptionist.Command]的演员,因此让我们研究一下Receptionist.Command类型,以了解它能够做什么。
抽象Command类有三个具体实现:Register、Find和Subscribe。
Register用于将给定的ActorRef与提供的ServiceKey关联。对于同一个键,可以注册多个引用。如果注册的演员停止,注册会自动从接待员那里移除。
通过提供一个可选的引用,可以提供另一个演员,如果服务成功注册,该演员应该被通知。
Find是一种询问接待员关于给定ServiceKey所知所有已注册演员的机制。接待员会以一个包含已知演员引用(称为服务)的Set以及一个包裹在Listing中的键本身作为响应,这些演员引用已注册到给定的键。Find可以用来实现一次性查询接待员。
Subscribe是一种实现接待员推送行为的途径。一个演员可以使用subscribe来接收有关某些预定义键中添加或删除的所有服务的通知。
在我们的例子中,Manager演员被用来向Boy提供一个seller演员的引用。Boy应该与提供的引用进行通信。在前一章中,我们使用了无类型的 Akka 远程查找来获取这个引用。在类型化环境中,我们将利用接待员来完成这个目的。
这就是它的实现方式。
首先,seller行为需要在初始化时向接待员注册自己:
import akka.actor.typed.receptionist.Receptionist._
val SellerKey = ServiceKeySellByList
val seller = Behaviors.setup { ctx ⇒
ctx.system.receptionist ! Register(SellerKey, ctx.self)
Behaviors.receiveMessage[SellByList] {
case SellByList(list, toWhom) ⇒
import list._
toWhom ! Groceries(eggs, flour, sugar, chocolate)
Behaviors.same
}
}
Shop定义了SellerKey,该键将由演员用来注册为服务,并由服务客户端用来查找卖家的引用。
接下来,我们介绍一种新的行为构造函数类型—Behaviors.setup。setup是一个行为工厂。它接受行为构造函数作为按名参数,并在演员启动时创建行为(与行为构造时不同)。我们需要使用这个工厂有两个原因:
-
我们需要我们的演员被实例化,这样我们才能访问其上下文
-
我们希望我们的
Seller正好注册一次
在将Seller注册到接待员之后,实际的行为被构造。行为本身只是接受SellByList消息,并针对toWhom引用响应提供的Groceries。
在接待员的另一边,Manager演员需要查找Seller并使用其引用来引导Boy:
def idle: Behavior[Command] = Behaviors.setup { context =>
implicit val lookupTimeout: Timeout = 1.second
context.ask(context.system.receptionist)(Find(Shop.SellerKey)) {
case Success(listing: Listing) =>
listing
.serviceInstances(Shop.SellerKey)
.headOption
.map { seller =>
LookupSeller(seller)
}
.getOrElse {
NoSeller
}
case Failure(_) =>
NoSeller
}
这里有很多事情在进行。再次使用setup来定义行为。
查找演员是一个异步操作,在这种情况下,我们利用ask模式来使代码简洁。Ask 需要知道它允许等待答案多长时间,因此,在第二行,我们定义了一个lookupTimeout。
然后,我们在演员上下文中调用可用的ask方法,并提供一个receptionist作为演员的引用来询问。第二个参数是接待员的Find命令,它被赋予了一个卖家键。通常,Find命令接受一个第二个参数,该参数定义了响应的接收者,但因为它经常与ask一起使用,所以有一个特殊的构造函数允许我们使用在这个片段中使用的良好语法。
接下来的情况字面量定义了一个在实际上将响应发送回询问演员之前必须应用到响应上的转换。它解构并转换接待员的响应,使其成为NoSeller或只是一个OneSeller。
接下来,我们必须通过定义一个行为来处理转换后的响应,这个行为是这个漫长的工厂方法的结果:
Behaviors.receiveMessagePartial {
case OneSeller(seller) =>
val boy = context.spawn(Boy.goShopping, "Boy")
boy ! GoShopping(shoppingList, seller, context.self)
waitingForGroceries
case NoSeller =>
context.log.error("Seller could not be found")
idle
}
在当前的管理员行为中,我们只期望收到所有可能消息的小子集。我们使用receiveMessagePartial来避免未处理消息类型的编译器警告。
在这种情况下,如果没有卖家,我们可以使用演员的context中可用的log来报告这种状态并返回当前的行为。
在这种情况下,如果有Seller可用,我们实例化一个Boy并使用它将shoppingList传递给这个卖家。注意我们如何使用context.self作为GoShopping消息的第二个参数。通过这样做,我们使提供的管理员引用能够说服Seller直接将杂货发送给Manager,然后Boy在发送消息后可以立即停止自己:
object Boy {
final case class GoShopping(shoppingList: ShoppingList,
seller: ActorRef[SellByList],
manager: ActorRef[Manager.Command])
val goShopping = Behaviors.receiveMessage[GoShopping] {
case GoShopping(shoppingList, seller, manager) =>
seller ! SellByList(shoppingList, manager)
Behaviors.stopped
}
}
在这里,我们看到了GoShopping命令如何禁止我们交换卖家和经理的演员引用,因为在无类型的 Akka 中这种情况很容易发生。
Akka Typed – 超越基础
我们已经定义了Chef演员的行为,以便在混合器之间分配工作,但留下了等待部分未覆盖,所以现在让我们看看这一点。
我们将Chef的mixing行为的定义留给了以下内容:
def mixing = Behaviors.unhandled[Command]
实际上,Chef需要了解由其idle行为创建的混合器。虽然从技术上讲,我们可以执行子查找,如前所述,这样做将引入一个隐含的假设,即在这个时刻,我们会得到一个列表,表明所有的混合器仍在处理工作。这个假设在高度并发的环境中或混合器失败的情况下可能是错误的。
因此,我们需要对行为构造函数进行一点重构:
def mixing(mixers: Set[ActorRef[Mixer.Mix]],
collected: Int,
manager: ActorRef[Manager.Command]): Behavior[Command]
现在,我们有一个构建器,它捕获了Chef状态的所有部分。让我们看看这些部分如何在它的行为定义中使用:
Behaviors.receivePartial {
case (context, Collect(dough, mixer)) =>
val mixersToGo = mixers - mixer
val pastryBuf = collected + dough.weight
context.stop(mixer)
if (mixersToGo.isEmpty) {
manager ! ReceivePastry(Dough(pastryBuf))
idle
} else {
mixing(mixersToGo, pastryBuf, manager)
}
}
我们已经熟悉了构造函数。在行为本身中,我们计算从混合器接收到的每个Dough消息,并使用新状态重新创建行为。在这种情况下,如果所有混合器都已交付他们的部分,我们将结果返回给经理,并进入idle状态。
演员的生命周期
我们对Mixer的实现相当简单,没有考虑到硬件偶尔会出故障。
在 Akka 中,传统上我们区分预期和意外的故障。例如,验证错误通常在协议级别上用适当的消息类型表示。意外性质的异常条件,如硬件错误,通过抛出异常进行通信。这允许你为成功路径和错误路径分别定义处理程序,从而将业务逻辑与底层平台的技术细节分离。因此,拥有丰富的异常集是正确错误处理定义的前提条件。
让我们考虑这个方面。我们将通过定义一组异常来表示不可靠的硬件,每个可能的故障一个。我们将以与第十一章《演员模型与 Akka 简介》相同的方式进行:
class MotorOverheatException extends Exception
class SlowRotationSpeedException extends Exception
class StrongVibrationException extends Exception
现在,为了模拟硬件故障,我们将添加一些代码,目的是向Mixer的逻辑抛出定义的异常。为了使示例简单,我们只需抛出其中之一:
case (_, Mix(Groceries(eggs, flour, sugar, chocolate), sender)) =>
if (Random.nextBoolean()) throw new MotorOverheatException
...
看起来我们的面包店非常热。每当Chef试图混合Groceries时,混合器电机大约每两次就会过热。
演员可以通过在演员的上下文中调用receiveSignal方法,并提供一个PartialFunction[(ActorContext[T], Signal), Behavior[T]]作为参数来自我监视。如果演员终止或重启,将调用提供的部分函数,参数为生命周期消息。
这种自我监视的可能性在适当的情况下可以用来改变演员的行为。以下代码片段显示了混合器如何自我监控:
val monitoring: PartialFunction[(ActorContext[Mix], Signal), Behavior[Mix]] = {
case (ctx, PostStop) =>
ctx.log.info("PostStop {}", context.self)
Behaviors.same
case (context, PreRestart) =>
ctx.log.info("PreRestart {}", context.self)
Behaviors.same
case (context, t: Terminated) =>
ctx.log.info("Terminated {} while {}", context.self, t.failure)
Behaviors.same
}
在我们的情况下,混合器只是将发生的生活变化事件写入日志,并保持相同的行为。为了查看PostStop、PreRestart和Terminated事件发生的情况,我们首先需要熟悉监督的概念。
监督
在本质上,Akka Typed 中的监督指的是所有从行为抛出的异常都被捕获并采取行动。一个动作可以有以下三种形式之一:恢复、重启和停止。
让我们看看如何定义监督以及它会产生什么影响。
首先,让我们以当前状态运行我们的系统,并观察其输出:
...
[INFO] Opening Bakery
[INFO] Go shopping to Actor[akka://Typed-Bakery/user/Seller#1376187311]
[INFO] Mixing Groceries(13,650,130,65)
[ERROR] [akka://Typed-Bakery/user/Chef/Mixer_5] null
ch12.Mixer$MotorOverheatException
at ch12.Mixer$.$anonfun$mix$1(Mixer.scala:19)
at akka.actor.typed.internal.BehaviorImpl$ReceiveBehavior.receive(BehaviorImpl.scala:73)
...
at java.lang.Thread.run(Thread.java:745)
[INFO] PostStop Actor[akka://Typed-Bakery/user/Chef/Mixer_5#-1604172140]
...
我们可以看到我们的演员是如何开始处理消息,直到Mixer抛出异常。这个异常使用默认的监督策略处理,该策略停止了演员。混合器通过我们之前定义的监控函数记录了PostStop事件,并将其附加到演员的行为上,如下所示:
def mix: Behavior[Mix] = Behaviors.receive[Mix] {
...
}.receiveSignal(monitoring)
让我们看看如果我们覆盖默认的监督策略会发生什么。为了改变行为,我们只需使用标准构造函数将其包装到监督行为中。在电机过热的情况下,让我们重启混合器而不是停止它:
val controlledMix: Behavior[Mix] =
Behaviors
.supervise(mix)
.onFailureMotorOverheatException
如果我们使用Chef演员来创建混合器,运行应用程序将产生略微不同的输出:
...
[INFO] Mixing Groceries(6,300,60,30)
[ERROR] Supervisor [restart] saw failure: null
ch12.Mixer$MotorOverheatException
at ch12.Mixer$.$anonfun$mix$1(Mixer.scala:29)
...
[INFO] PreRestart Actor[akka://Typed-Bakery/user/Chef/Mixer_2#-1626989026]
[INFO] PreRestart Actor[akka://Typed-Bakery/user/Chef/Mixer_4#-668414694]
[INFO] PreRestart Actor[akka://Typed-Bakery/user/Chef/Mixer_4#-668414694]
现在,异常已经被监督者报告,混合器已经被重启,我们可以通过观察混合器记录的PreRestart事件来得出结论。这里没有PostStop事件。
还有另一种监督策略需要查看,让我们来看看:
val controlledMix: Behavior[Mix] =
Behaviors
.supervise(mix)
.onFailureMotorOverheatException
使用这种策略,我们仍然会看到来自监督者的日志输出,但演员不会记录任何生命周期事件:
...
[INFO] Mixing Groceries(5,250,50,25)
[ERROR] Supervisor [resume] saw failure: null
ch12.Mixer$MotorOverheatException
at ch12.Mixer$.$anonfun$mix$1(Mixer.scala:29)
...
可以通过嵌套监督构造函数来为同一行为抛出的不同类型的异常定义不同的监督策略:
val controlledMix: Behavior[Mix] =
Behaviors.supervise(
Behaviors.supervise(
Behaviors.supervise(
mix) .onFailureMotorOverheatException(SupervisorStrategy.stop)) .onFailure[SlowRotationSpeedException(SupervisorStrategy.restart))
.onFailure[StrongVibrationException
定义显然有点冗长。
监督策略是粘性的。它们递归地应用于由监督行为返回的新行为。
有时候,尝试重启一个演员几次可能是有用的,如果情况没有改善,那么最终停止它。为此,有一个特殊的构造函数可用:
Behaviors.supervise(mix).onFailureSlowRotationSpeedException)
在不幸的情况下,混合器演员每次构建时都会从Behavior.setup构造函数抛出异常,我们会看到以下输出:
...
[INFO] Mixing Groceries(6,300,60,30)
[ERROR] Supervisor [restartWithLimit(4, 2.000 s)] saw failure: null
ch12.Mixer$MotorOverheatException
at ch12.Mixer$.$anonfun$mix$1(Mixer.scala:26)
...
[ERROR] Supervisor [restartWithLimit(4, 2.000 s)] saw failure: null
...
[ERROR] Supervisor [restartWithLimit(4, 2.000 s)] saw failure: null
...
[ERROR] Supervisor [restartWithLimit(4, 2.000 s)] saw failure: null
...
[ERROR] [akka://Typed-Bakery/user/Chef/Mixer_1] null
akka.actor.ActorInitializationException: akka://Typed-Bakery/user/Chef/Mixer_1: exception during creation at akka.actor.ActorInitializationException$.apply(Actor.scala:193)
...
Caused by: ch12.Mixer$MotorOverheatException at ch12.Mixer$.$anonfun$mix$1(Mixer.scala:26)
...
[INFO] Message [ch12.Mixer$Mix] without sender to Actor[akka://Typed-Bakery/user/Chef/Mixer_1#-263229034] was not delivered.
监督者尝试重启演员四次,但最终放弃并停止了它。由于失败发生在设置块中,演员无法接收Mix命令或生命周期事件通知。
观察一个演员
如果我们回顾一下Chef演员的实现,我们会发现我们的系统现在卡住了。这是因为,如果混合器失败,它们会被外部监督力量停止。然而,Chef演员仍在等待这个混合器的部分工作。结果是,我们需要一种方式来通知Chef有关已终止的混合器。
Akka Typed 为此提供了一个监控机制。为了监控被停止的混合器,我们将以下代码添加到Chef中:
val mixers = for (i <- 1 to eggs)
yield context.spawn(Mixer.controlledMix, s"Mixer_$i")
mixers.foreach(mixer => context.watchWith(mixer, BrokenMixer(mixer)))
在这里,对于每个生成的Mixer,我们调用context.watchWith。第一个参数是要观察的演员,第二个参数是消息适配器。需要消息适配器的原因是,已终止演员的正确消息类型将是akka.actor.typed.Terminated。我们可以使用一个观察者,仅接受单个演员引用,来订阅此消息类型——def watchT: Unit。
但,事实上我们的Chef无法处理这种消息类型,因为它不属于它的Command类型。因此,我们需要定义一个单独的演员类型来观察混合器的终止。相反,我们需要使用扩展版本的观察方法,它将一个要发送的消息作为第二个参数。BrokenMixer消息被定义和处理如下:
case class BrokenMixer(mixer: ActorRef[Mixer.Mix]) extends Command
def mixing(...): Behavior[Command] = Behaviors.receivePartial {
...
case (context, BrokenMixer(m)) =>
context.log.warning("Broken mixer detected {}", m)
context.self ! Collect(Dough(0), m)
Behaviors.same
}
在这种情况下,如果我们检测到一个已终止的子演员,Chef将写入一条日志条目并给自己发送一条消息来补偿丢失的工作部分。
现在,我们有Dough准备好了,需要一个Cook来形成饼干,一个Baker来在Oven中烘烤它们。Cook的实现很简单——它只是将Dough转换成一定数量的RawCookies并将它们发送回管理员。如果您对实现细节感兴趣,请参阅 GitHub 仓库中的代码。
定时器
Baker更有趣。首先,它需要一个单独的Oven。我们将通过使用一个特殊的行为来实现这一点,我们只执行一次:
def turnOvenOn: Behavior[Command] = Behaviors.setup { context =>
val oven = context.spawn(Oven.empty, "Oven")
idle(oven)
}
现在,让我们定义一个idle行为,它只是在等待工作:
def idle(oven: ActorRef[Oven.Command]): Behavior[Command] =
Behaviors.receivePartial {
case (context, BakeCookies(rawCookies, manager)) =>
oven ! Put(rawCookies.count, context.self)
Behaviors.withTimers { timers =>
timers.startSingleTimer(TimerKey, CheckOven, DefaultBakingTime)
baking(oven, manager)
}
}
在这里,我们期待来自管理员的消息,告诉我们烘烤饼干。然后,我们使用一个新的行为构造函数withTimers,它为我们提供了访问TimerScheduler的权限。使用调度器,可以定义由某些键标识的周期性和单次定时器。使用相同键定义新定时器将取消之前定义的定时器,并删除它发送的消息,如果它们仍然在演员的消息框中。
在这里,我们使用定时器作为厨房时钟,在烘焙时间过后设置一个单次提醒来检查Oven。
存储
另一个挑战是,Baker需要根据需要从Manager接受RawCookies,但由于烤箱的容量有限,需要批量烘烤它们。基本上,它需要管理一个RawCookies的队列。
我们将通过使用一个存储区来实现这一点。通过使用存储,我们的演员将缓冲当前行为无法处理的消息,并在切换到应该处理缓冲消息的替代行为之前重新播放它们。
让我们看看这种方法如何在演员的烘焙行为中体现出来:
def baking(oven: ActorRef[Oven.Command],
manager: ActorRef[Manager.Command]): Behavior[Command] =
Behaviors.setup[Command] { context =>
val buffer = StashBufferCommand
Behaviors.receiveMessage {
case CheckOven =>
oven ! Extract(context.self)
Behaviors.same
case c: TooManyCookies=>
buffer.stash(BakeCookies(c.raw, manager))
Behaviors.same
case c : BakeCookies =>
buffer.stash(c)
Behaviors.same
case CookiesReady(cookies) =>
manager ! ReceiveReadyCookies(cookies)
buffer.unstashAll(context, idle(oven))
}
}
首先,我们定义一个将包含我们的存储消息的缓冲区。
缓存正在内存中保存消息。通过存储过多的消息,可能会导致系统因OutOfMemory错误而崩溃。容量参数有助于避免这种情况。但是,如果指定的容量过低,在尝试将消息存入已满的缓冲区后,将抛出StashOverflowException异常。
然后,我们处理四种类型的消息。CheckOven是定时器发送给Baker的提醒,以免忘记从Oven中取出饼干。
在TooManyCookies(这是一个来自Oven的消息,表示没有适合放入其中的饼干)或从管理者那里收到BakeCookies的情况下,Baker将它们存储起来,直到再次空闲并能够处理烘焙工作。
CookiesReady表示Oven现在为空,因此我们将饼干转发给Manager,取消存储所有消息,并进入idle状态。
结合行为
现在我们已经定义了面包店的每个工人,是时候最终获得一个Manager了。在第十一章,《Akka 和 Actor 模型简介》中,我们使用 FSM 库实现了Manager。在 Akka Typed 中,我们可以通过定义每个状态的原子行为,然后根据需要返回适当的行为来实现相同的效果:
def waitingForGroceries = receiveMessagePartial[Command] {
case ReceiveGroceries(g) =>
context.log.info("Mixing {}", g)
chef ! Chef.Mix(g, context.self)
waitingForPastry
}
def waitingForPastry = receiveMessagePartial[Command] {
case ReceivePastry(p) =>
context.log.info("Forming {}", p)
cook ! Cook.FormCookies(p, context.self)
waitingForRawCookies
}
...
在这里,我们定义了两种行为,每种行为都期望接收特定的消息类型,执行必要的消息传递给受管理的演员,并返回链中的下一个行为。这样,我们可以使用无类型的 Akka 实现我们实现的串行行为。
然而,我们可以做得更好。Akka Typed 允许我们组合行为,这样我们可以通过将行为链接在一起并从我们定义的每个原子行为中返回组合行为来实现Manager的并行版本:
def manage(chef: ActorRef[Chef.Command],
cook: ActorRef[Cook.FormCookies],
baker: ActorRef[Baker.Command]): Behavior[Command] =
...
def sendBoyShopping = receiveMessagePartial ...
def waitingForGroceries = receivePartial[Command] {
...
manage(chef, cook, baker)
}
def waitingForPastry = receiveMessagePartial[Command] {
...
manage(chef, cook, baker)
}
def waitingForRawCookies = receiveMessagePartial[Command] {
case ReceiveRawCookies(c) =>
baker ! Baker.BakeCookies(c, context.self)
manage(chef, cook, baker)
}
def waitingForReadyCookies = receiveMessagePartial[Command] {
case ReceiveReadyCookies(c) =>
context.log.info("Done baking cookies: {}", c)
manage(chef, cook, baker)
}
lookupSeller orElse
sendBoyShopping orElse
waitingForGroceries orElse
waitingForPastry orElse
waitingForRawCookies orElse
waitingForReadyCookies
}
在这里,manage构造函数用于为Manager应该能够处理的每种消息类型定义原子行为。然后,将现有的行为组合成一个。这使得我们的Manager能够处理任何处理状态中的每条消息。
集群
现在Bakery已经到位,但我们仍然希望杂货店作为一个独立的 actor 系统运行,就像我们在上一章中做的那样。使用无类型的 Akka,我们通过远程通信实现了这种通信,但在类型化的设置中远程通信不可用。使用 Akka Typed,我们可以通过集群来实现这一点:
Akka 集群是一组作为动态整体工作的 Akka 系统。这是与 Akka 远程的主要区别,集群是在其之上构建的。单个系统代表集群中的一个节点。一个演员可以存在于集群的任何位置。集群的一些功能包括负载均衡(将消息路由到集群中的特定节点)、节点分区(将特定角色分配给节点)和集群管理(容错节点成员资格),仅举几例。在我们的示例中,我们没有使用任何高级集群功能,而是按顺序抛出,以便我们有与远程演员系统通信的可能性。
为了能够在我们的项目中使用集群,我们需要在 build.sbt 中添加以下依赖项:
"com.typesafe.akka" %% "akka-cluster-typed" % akkaVersion,
集群还需要定义一些配置参数。我们可以通过在 application.conf 中添加以下附加行来提供它们。这将作为 Bakery 的默认配置使用:
akka {
actor.provider = "cluster"
remote {
netty.tcp {
hostname = "127.0.0.1"
port = 2552
}
}
cluster.seed-nodes = [
"akka.tcp://Typed-Bakery@127.0.0.1:2553",
"akka.tcp://Typed-Bakery@127.0.0.1:2552"
]
}
Store 的配置是通过导入默认配置并覆盖端口定义来定义的:
include "application"
akka.remote.netty.tcp.port = 2553
现在,我们需要为 Store 实例化一个演员系统:
object Store extends App {
val config = ConfigFactory.load("grocery.conf")
val system = ActorSystem(seller, "Typed-Bakery", config)
}
我们还需要为 Bakery 本身添加一个:
object Bakery extends App {
...
val system = ActorSystem(Manager.openBakery, "Typed-Bakery")
}
这两个定义的演员系统现在都可以启动,并将通过集群从远程系统获取所需资源来模拟烘焙饼干。
我们刚刚通过只更改配置就将本地演员系统转变为集群演员系统,展示了 Akka 的位置透明性。
测试
目前,我们有一个工作的 Bakery 实现,但我们不能确定我们的演员是否在执行我们期望他们执行的操作。让我们通过测试他们的行为来解决这个问题。
由于演员的并发性和消息导向,测试演员是出了名的困难。幸运的是,在 Akka Typed 中,演员的行为只是一个函数,因此通常可以独立进行测试。有些情况下,我们可能想要测试演员之间的交互,在这种情况下,不可避免地要诉诸于异步测试。
在同步设置中,我们创建一个待测试的行为,发送它应该能够反应的事件,并验证该行为产生了预期的效果(例如,产生或停止子演员)并发送进一步所需的消息。
异步场景将这种方法引入了测试演员系统的上下文中,这接近于真实环境。我们将在稍后看到这是如何在实际中完成的。
依赖和设置
为了自动化重复性任务,例如为演员设置测试环境,Akka Typed 提供了一个测试套件,就像 Akka 无类型一样。我们需要在 build.sbt 中存在以下依赖项,以便我们可以在项目中使用它:
"com.typesafe.akka" %% "akka-actor-testkit-typed" % akkaVersion % Test,
"org.scalatest" %% "scalatest" % "3.0.5" % Test
将这两个都纳入范围将允许我们创建 ScalaTest 规范,并使用 Akka Typed 测试套件功能。
如前所述,关于同步演员测试,我们不需要有ActorSystem。在这种情况下,唯一的依赖是一个演员上下文。Akka 提供了一个工厂,用于以BehaviorTestKit的形式构建特殊的测试演员上下文。在这种情况下,ScalaTest规范的骨架可能如下所示:
import akka.actor.testkit.typed.scaladsl.BehaviorTestKit
import org.scalatest.WordSpec
class SynchronousSpec extends WordSpec {
"TestScenario" should {
"have test conditions" in {
val testKit = BehaviorTestKit(behaviorToTest)
// ... testing logic
}
}
}
在异步测试的情况下,我们必须扩展ActorTestKit,以便在规范范围内有一个测试演员系统。这个演员系统需要在所有测试完成后关闭,以防止资源泄漏。因此,在异步测试的情况下,最小的规范将看起来更复杂一些:
class AsyncronousSpec extends WordSpec with ActorTestKit with BeforeAndAfterAll {
override def afterAll: Unit = shutdownTestKit()
// actual testing code
}
现在,是时候看看 Akka TestKit提供了哪些不同的功能,以简化检查基于演员的系统是否正确。
同步测试
BehaviorTestKit提供了验证演员行为对特定消息反应的可能性。反应可以表现为Effect(以不同方式创建和停止子演员的方式)、发送和接收消息以及行为的变化。让我们用一个例子来说明这个测试过程:
"The boy should" should {
"forward given ShoppingList to the seller" in {
val testKit = BehaviorTestKit(Boy.goShopping)
val seller = TestInbox[Shop.SellByList]()
val manager = TestInbox[Manager.Command]()
val list = ShoppingList(1, 1, 1, 1)
testKit.run(GoShopping(list, seller.ref, manager.ref))
seller.expectMessage(SellByList(list, manager.ref))
assert(!testKit.isAlive)
testKit.expectEffect(NoEffects)
}
}
在这里,我们将goShopping行为封装到了BehaviorTestKit中,以便我们可以同步测试它。两个TestInbox引用代表Boy应该与之通信的演员。它们基本上是ActorRefs,但它们允许我们表达对传入消息的期望。为了触发测试,我们可以创建一个消息,并使用这个消息作为输入运行testKit。
在下一行,我们期望seller演员接收到相同的消息,其中manager引用作为发送者被传播。这正是我们男孩逻辑应该工作的方式。然后,我们通过检查它是否不再存活来验证Boy已经停止了自己。最后,由于Boy演员不应该拥有或创建任何子演员,我们不应该期望对子演员有任何影响。
与我们测试Boy对子演员没有影响的方式相同,我们可以测试Chef有这样的影响:
"The chef should" should {
"create and destroy mixers as required" in {
val mixerFactory = Mixer.mix(0 seconds)
val chef = BehaviorTestKit(Chef.idle(mixerFactory))
val manager = TestInbox[Manager.Command]()
val message = Mix(Groceries(1, 1, 1, 1), manager.ref)
val dispatcher = DispatcherSelector.fromConfig("mixers-dispatcher")
chef.run(message)
chef.expectEffect(Spawned(mixerFactory, "Mixer_1", dispatcher))
val expectedByMixer = Mixer.Mix(Groceries(1, 1, 1, 1), chef.ref)
chef.childInbox("Mixer_1").expectMessage(expectedByMixer)
}
}
在这个测试中,我们以与刚才对Boy演员所做的方式创建一个待测试的行为。我们创建一个消息,并用测试行为包装器运行它。结果,我们期望一个chef会以适当的名字和调度器创建一个单独的Mixer演员。最后,我们通过使用childInbox方法查找已创建的子演员的邮箱,并期望其中包含由chef发送的消息。
不幸的是,在撰写这本书的时候,Akka 的TestKist还有一些粗糙的边缘,这要求我们在这种特定情况下重构我们的Chef行为,以接受混合器工厂作为参数。这是因为行为是通过引用比较的,这要求我们为测试通过而拥有相同的行为实例。
BehaviorTestKit的另一个限制是其不支持扩展,如集群、集群单例、分布式数据和接待员。这使得在同步设置中测试Seller演员变得不可能,因为该演员会将自己注册到receptionist:
context.system.receptionist ! Register(SellerKey, context.self)
我们可以使用同步方法,或者我们可以重构卖家以接受一个用于接待员的构造函数,并在测试中提供一个模拟接待员。这是在Seller代码中如何做到这一点的示例:
type ReceptionistFactory = ActorContext[SellByList] => ActorRef[Receptionist.Command]
val systemReceptionist: ReceptionistFactory = _.system.receptionist
def seller(receptionist: ReceptionistFactory) = setup { ctx ⇒
receptionist(ctx) ! Register(SellerKey, ctx.self)
...
工厂只是从ActorContext到ActorRef的函数,具有适当的类型。
通过这个改变,我们可以实现我们的测试,如下所示:
"A seller in the shop" should {
"return groceries if given a shopping list" in {
val receptionist = TestInbox[Receptionist.Command]()
val mockReceptionist: Shop.ReceptionistFactory = _ => receptionist.ref
val seller = BehaviorTestKit(Shop.seller(mockReceptionist))
val inbox = TestInbox[Manager.Command]()
val message = ShoppingList(1,1,1,1)
seller.run(SellByList(message, inbox.ref))
inbox.expectMessage(ReceiveGroceries(Groceries(1, 1, 1, 1)))
receptionist.expectMessage(Register(Shop.SellerKey, seller.ref))
seller.expectEffect(NoEffects)
}
}
我们提供了一个模拟接待员,它只是一个TestInbox[Receptionist.Command],并将其作为工厂的结果使用,忽略了实际的演员上下文。然后,我们像之前一样执行测试,并期望消息被适当地发送到manager和receptionist。
异步测试
同步测试是测试演员逻辑的好方法,但有时它并不足够,例如,当测试演员之间通信的特定方面时。另一个例子是在演员的行为中存在异步代码,例如Feature或调度器,它需要在测试断言可以执行之前完成。
这种情况的一个例子是Baker演员。我们期望它在预定义的时间间隔后检查Oven。不幸的是,这个间隔是硬编码的,所以在测试中无法覆盖它,我们需要等待计时器触发。
作为异步测试工具包的一部分,Akka 提供了一个ManualTimer,可以在测试中以灵活的方式推进时间。我们将使用它来可靠地测试我们的Baker演员。
首先,我们需要为手动计时器提供一个适当的配置。我们通过覆盖演员系统的config方法(由ActorTestKit表示)并定义我们将在测试中使用的计时器实例来完成此操作:
override def config: Config = ManualTime.config
val manualTime: ManualTime = ManualTime()
现在,我们可以指定测试逻辑:
"The baker should" should {
"bake cookies in batches" in {
val oven = TestProbe[Oven.Command]()
val manager = TestInbox[Manager.Command]()
val baker = spawn(Baker.idle(oven.ref))
baker ! BakeCookies(RawCookies(1), manager.ref)
oven.expectMessage(Oven.Put(1, baker))
val justBeforeDone = DefaultBakingTime - 1.millisecond
manualTime.expectNoMessageFor(justBeforeDone, oven)
manualTime.timePasses(DefaultBakingTime)
oven.expectMessage(Extract(baker))
}
}
在这个场景中,我们使用TestProbe(与之前使用的TestInbox相反)创建了一个oven和一个manager,还使用ActorTestKit的spawn方法创建了一个baker行为。我们向baker发送一个请求,并期望它通过将单个饼干放入烤箱来适当地做出反应。
接下来,我们可以看到baker通过检查在这个时间段内没有发送任何消息来等待饼干准备好。我们在这里使用的是年度时间,因此检查本身是瞬间完成的。最后,我们手动推进计时器,以便baker需要从烤箱中取出饼干并验证这确实发生了,并且oven如预期那样接收到了Extract消息。
应用程序已经成功测试;让我们不再等待,运行它吧!
运行应用程序
如果您还没有安装 Java 和 SBT,请参阅附录 A,准备环境和运行代码示例。
我们将像上一章一样在终端中运行我们的应用程序,使用两个独立的终端会话为Store和Bakery。它们可以在交互模式下运行,或者在相应的 shell 中发出以下两个命令之一:
sbt "runMain ch12.Store"
sbt "runMain ch12.Bakery"
由于我们在示例中使用的是集群而不是远程通信,因此我们不需要按照上一章中必须的顺序启动它们。以下截图显示了两个准备运行应用程序的终端窗口,已输入上述命令:

当应用程序的两个部分同时启动时,它们将建立连接并开始协同工作以生成饼干。以下截图显示应用程序的面包店部分已经运行并等待屏幕右侧的商店启动:

如果您想从 SBT shell 以交互模式启动演示,请参阅第十一章,Akka 和 Actor 模型简介,其中我们详细解释了如何进行此操作。
摘要
Akka Typed 允许您以类型安全的方式实现 actor 系统。它将 actor 逻辑表示为具有编译时确定的输入和输出通道类型的良好类型化行为。行为可以组合在一起,从而允许更高的代码重用程度。
类型化的 Actor 不仅应该接收和发送消息,而且在处理完每条消息后还必须显式定义新的行为。与其他 Actor 的交互仅限于创建、停止、查找和监视子 Actor,以及获取显式注册服务的类型引用。
Actor 上下文提供了有用的功能,例如计时器和存储。
类型化监督直接定义在行为上,如果需要,必须显式实现向父 actor 的故障传播。Akka 团队通过推广 actor 的生命周期钩子,从方法到事件,采取了一种整体的方法。
在类型化的 Akka 中,Actor 基本上只是函数。正因为如此,测试不再仅限于之前的异步通信。这可以同步进行,从而允许执行快速、确定性和稳定的测试代码。
Akka Typed 提供了一系列有用的扩展,如集群、集群单例、持久化和分布式数据。我们简要介绍了集群模块如何通过仅更改系统配置,使我们能够在分布式场景中利用现有代码。请参考 Akka Typed 的官方在线文档(doc.akka.io/docs/akka/current/typed/index.html)以进一步探索类型化演员工具包提供的功能。
在下一章中,我们将再次实现面包店,这次将使用另一个 Akka 库——Akka Streams。
第十三章:Akka Streams 基础知识
在本章中,我们将更深入地了解 Akka Streams。我们将从一般流的描述开始,特别是反应式流的描述。我们将触及反压的概念,并为你提供一些使用 Akka Streams 作为反应式流标准的具体实现的动机。我们将再次实现我们的面包店,这次使用流作为设计抽象。这将使我们能够详细检查 Akka Streams 的基本知识,如流和图、错误处理和测试。
本章将涵盖以下主题:
-
反应式流
-
反压
-
Akka Streams 哲学
-
Akka Streams 基本概念
-
源和汇
-
流
-
图
-
日志记录
-
实化
-
故障处理
-
测试
技术要求
-
安装的 Scala
-
安装的 SBT
本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter13。
Akka Streams 简介
在现代计算中,“流”这个词在意义上被大量复用。它根据上下文的不同承载着许多不同的含义。例如,在 Java 中,在不同的时期,“流”意味着对阻塞 IO、非阻塞 IO 的抽象,后来又成为表达数据处理查询的一种方式。
从本质上讲,计算中的流只是数据或指令的流动。通常,流的内容不会完全加载到内存中。这种在内存容量有限的设备上处理基本上无限量的信息的能力是流兴起的一个推动因素,这种流行趋势最近正在发生。
流作为流动的定义意味着它应该有一些数据元素来源和目的地。在计算中,这些概念自然地在代码中以某种方式表达,即在流动的一侧代码发出数据,在另一侧其他代码消费这些数据。发出数据的一侧通常被称为生产者,接收数据的一侧相应地称为消费者。通常,在内存中会有一些数据,这些数据已经被生产者发出但尚未被消费者摄取。
流的这一特性引发了下一个想法:应该可以通过中间的代码在飞行中操纵数据,就像热水器插在进水口和水龙头之间,改变冷水为热水一样。有趣的是,在这个场景中,生产者或消费者并不知道热水器的存在。如果水流强度增加,我们可以很容易地想象插入另一个热水器或用更强大的型号替换现有的一个。热水器在意义上成为流的所有权,即消费者接收到的水量取决于生产者发出的量,但温度取决于管道系统的特性,或者说本质上就是流的特性。
这就是使用流的基本理念:流通常被视为生产者、消费者以及中间的转换步骤的组合。在流式传输场景中,生产者和消费者变得不那么有趣,主要焦点转向中间的转换步骤。为了模块化和代码重用,定义许多微小的转换通常被认为是更可取的方法。
根据在流的部分之间传输数据的技术,我们区分流的推送和拉取元素。
在推送模型中,生产者控制整个过程。数据一旦可用,就会推送到流中,其余的流应该能够吸收它。自然地,并不是总是可能以不可预测的速度消费生产出的数据。在流式传输的情况下,这通常通过丢弃数据或使用缓冲区来处理。丢弃数据有时是合适的,但更常见的是不希望这样做。缓冲区的大小有限,因此如果数据产生速度超过消费速度,长时间内可能会填满。满缓冲区再次导致内存溢出或需要丢弃数据。显然,在推送模型中,快速生产者和慢速消费者的组合是一个问题。
在拉取模型中,消费者驱动整个过程。它试图在需要时尽快从流中读取数据。如果有数据,就取走。如果没有数据,消费者可以选择等待或稍后再次尝试。通常,这两种可能性都不太理想。等待数据通常是通过阻塞和轮询数据来完成的,这意味着资源过度消耗以及数据可用和消费之间的延迟。显然,在慢速生产者和快速消费者的情况下,拉取模型并不是最优的。
这种二分法导致了名为反应式流的动态拉取-推送概念的创造,以及 2013 年由 Lightbend、Netflix 和 Pivotal 的工程师发起的同名倡议。
反应式流和背压
反应流(www.reactive-streams.org)是一个旨在提供异步流处理非阻塞背压标准的倡议。
非阻塞背压是一种处理流环境中拉取和推送语义缺陷的机制。通过一个例子来解释会更好。
想象一个建筑工地,工头负责及时交付建筑材料,以及其他职责。工地只能容纳最多 100 吨的材料。工头可以从另一家公司订购材料,但订单是由卡车司机在公司的办公室接收,而不是将材料运送给客户。
对于工头来说,拉取行为可能是打电话给承包商并等待卡车司机在办公室并接听电话(阻塞拉取),或者定期打电话,希望这次有人会接电话(轮询)。在我们的案例中,工头给承包商发送语音消息,要求 100 吨材料,然后返回日常工作。这是一种非阻塞拉取。
承包商一旦有足够的能力,就会接受订单。他们准备发送几辆每辆容量为 32 吨的卡车,但意识到他们不能发送超过 100 吨,因为工地无法接收这么大的量。因此,只发送了三辆卡车和 96 吨材料。
消耗了 30 吨材料后,工头意识到他们可以再从承包商那里订购更多,以避免如果剩余材料快速消耗,工地后来变得闲置。他们又订购了 30 吨。但承包商记得前一个订单还剩下另外 4 吨,所以可以安全地发送另一辆载有 32 吨的整车,这可以装进一辆单独的卡车。我们反映的事实是,第一次请求中的一些需求是通过连续交付后来得到满足的,我们说请求是可累加的。
这基本上就是反应流背压概念的工作方式。在现实中,这种方法可能更好地反映为“向前轻松”,但这个名字可能不会像“背压”那样流行。
反应流规范努力定义一个低级 API,它可以由不同的库实现,以实现实现之间的互操作性。该标准定义了 API 和技术兼容性工具包(TCK),这是一个 API 实现的标准测试套件。
TCK 的目的是帮助库作者验证他们的实现是否符合标准。
API 包含以下组件:
-
发布者
-
订阅者
-
订阅
-
处理器
发布者代表源,订阅者与消费者相关,处理器是流的处理阶段,而订阅是背压的表示。
API 中定义的所有方法都返回void,这意味着它们旨在在不等待调用者等待结果的情况下执行,因此 Reactive Streams 标准定义中的异步流处理。
Reactive Streams 是一个库标准,定义了库如何相互通信,以便能够进行交互。预计库将为用户提供不同的高级 API,可能反映了某些实现细节。
Akka Streams 是使用 Akka 演员作为底层技术构建的此类库之一。它实现了 Reactive Streams 标准,并具有丰富的、高级的 API,允许您使用高级 DSL 描述流,并展示了底层的 Akka 机制。
Akka Streams
Akka Streams(doc.akka.io/docs/akka/2.5.13/stream/stream-introduction.html)的目的是提供一种直观且安全的方式来制定流处理设置,以便我们可以高效地执行它们,并且资源使用量有限。
Akka Streams 完全实现了 Reactive Stream 标准,以便与其他符合 Reactive Streams 规范的库进行交互,但这一事实通常被视为实现细节。
Akka Streams 最初的动机是所有 Akka actor 系统都面临着相同的技术问题,这增加了意外的复杂性,并且几乎需要为每个单独的项目单独解决多次。例如,Akka 没有通用的流控制机制,并且为了防止 actor 的邮箱溢出,它需要在每个应用程序中实现为自制的解决方案。另一个常见的痛点是至多一次的消息语义,这在大多数情况下都不理想,但也是逐个解决的。另一个 Akka 受到批评的不便之处是其无类型性质。类型的缺失使得在编译时检查 actor 之间可能交互的健全性成为不可能。
Akka Streams 旨在通过在 actor 系统之上放置一个流层来解决此问题。这一层遵循一组小的架构原则,以提供一致的用户体验。这些原则是流处理和组合的全面领域模型。库的重点在于模块化数据转换。从这个意义上说,Reactive Streams 只是数据在流程步骤之间传递的实现细节,而 Akka 演员则是单个步骤的实现细节。
分布式有界流处理的领域模型完整性的原则意味着 Akka Streams 拥有一个丰富的 DSL,允许你表达领域的各个方面,例如单个处理和转换步骤及其相互连接,具有复杂图拓扑的流,背压,错误和故障处理,缓冲等等。
模块化原则意味着单个转换、以特定方式连接的多个转换,甚至整个图的定义必须是可自由共享的。这一原则导致了将流描述与流执行分离的设计决策。因此,Akka Streams 的用户必须完成以下三个步骤来执行流:
-
以构建块及其之间连接的形式描述流。这一步骤的结果通常在 Akka 文档中被称为蓝图。
-
材料化蓝图以创建流的实例。材料化是通过提供一个材料化器来完成的,在 Akka 中,这采取使用 actor 系统或 actor 上下文为每个处理阶段创建 actor 的形式。
-
使用
run方法之一执行材料化流。
在实践中,通常最后两个步骤会合并,并将材料化器作为隐式参数提供。
带着这个理论,让我们看看在实际中如何使用 Akka Streams 构建和执行流。
设置和依赖
为了使 Akka Streams 可用于我们的项目,我们需要将以下依赖项放入 build.sbt 文件中:
lazy val akkaVersion = "2.5.14"
libraryDependencies += "com.typesafe.akka" %% "akka-stream" % akkaVersion
这使我们能够使用以下导入语句在我们的示例中导入相关类:
import akka.stream._
import akka.stream.scaladsl._
假设以下导入语句在本书后面的每个示例中都是现成的。
我们还需要一个包装器,以便我们能够为我们的流创建材料化器。包装器需要在流处理完成后立即终止 actor 系统:
package ch13
import akka.actor.ActorSystem
import akka.stream._
object Bakery extends App {
implicit val bakery: ActorSystem = ActorSystem("Bakery")
implicit val materializer: Materializer = ActorMaterializer()
// stream processing happens here
private def afterAll = bakery.terminate()
}
现在,在我们深入代码之前,我们需要一些词汇来描述我们在示例中所做的工作。
核心概念
让我们看看 Akka Streams 用来描述流及其元素的词汇表。
如前所述,每个流都有生产者、消费者和转换步骤。在 Akka Streams 中,它们分别命名为 Source、Sink 和 Flow。
更正式地说,在 Akka Streams 中,任何流构建块都被称为处理阶段。具有单个输出的处理阶段是 Source,单个输入的是 Sink,具有一个输入和一个输出的则是 Flow。通过将源和汇连接到流中,我们构建了一个可执行的可运行图。
此外,还有一些特殊的处理阶段:
-
具有两个或多个输入和一个输出的扇入
-
具有一个输入和两个或更多输出的扇出
-
具有两个输入和两个输出且方向相反的 Bidiflow
我们绘制了以下图,其中所有处理阶段相互连接,以简化概念的理解。虚线表示我们之前提到的背压:

图 1.可运行图中的不同处理阶段相互连接
示例结构
让我们看看流式实现是如何改变我们构建的面包店应用的形状的第十一章,Akka 和 Actor 模型简介,以及第十二章,使用 Akka Typed 构建响应式应用。为了回顾,这是用 actor 表示的面包店设计:

这个层次结构中的 actor 有以下职责:
-
经理启动新的烘焙轮次,并在下属之间转移工作包,并监督他们。
-
男孩被分配了一个购物清单,并从远程系统中获取了杂货。
-
厨师创建并使用了一些有限容量的搅拌机,以便将给定的杂货转换为面团。
-
厨师从提供的面团中制作了若干块生饼干。
-
面包师在固定大小的烤箱中烘焙生饼干。它维护一个内部队列,以备面包店的其余部分制作生饼干的速度超过烤箱烘焙的速度时使用。
-
烤箱和搅拌机代表硬件资源。它们将生饼干转换为可食用的饼干,将杂货转换为面团,但这样做会有延迟,并且可能发生故障。
现在我们有更多可能性以更静态的方式定义参与者之间的关系,因此我们只保留启动经理的行为,并通过直接连接涉及的转换步骤在流程级别组织工作转移。
这就是我们面包店的架构,以处理阶段表示:

actor 系统的层次结构已经转变为扁平的数据流。显然,这个图中不再有搅拌机和烤箱。它们怎么了?嗯,我们在隐藏转换步骤的内部细节上有点作弊。实际上,这些步骤中的一些是由更小的组件构建的复合块。让我们详细描述一下它的样子。
经理只保留了其启动行为。这将被表示为一个定时源,它将不时地滴答作响,并推动其他人烘焙一些饼干。男孩不能仅仅通过经理的愿望来工作,因此我们需要经理给它一个适当的购物清单。因此,我们必须将滴答的冲动转换为购物清单,如下一图所示:

这个复合块恰好有一个输出和没有输入,因此它显然是一个Source[ShoppingList]。
源的实际类型是Source[+Out, +Mat],因为它还考虑了物化方面。对于现在来说,物化对我们来说不是必要的,所以我们将在描述流结构时讨论简化的伪类型。
Boy和Cook是简单的步骤;它们都可以被视为将输入转换为输出的转换,我们稍后会详细探讨这个转换。从这一描述中,我们可以得出结论,Boy是一个Flow[ShoppingList, Groceries],而Cook只是一个Flow[Dough, RawCookies]。
Chef显然不像他的兄弟姐妹那样简单。它需要创建与食材数量相对应的多个搅拌器,并行使用它们来搅拌面团,并在将它们发送到下一步之前将结果合并在一起。我们将用以下结构来表示这一点:

这个图中的内容比我们之前描述的要多。这是因为我们将具有单一职责的构建块流与转换结合起来,我们需要进行转换:
-
我们需要将进来的食材分成适合单个搅拌器的份量;这就是分割步骤。
-
接下来,我们需要确保工作在搅拌器之间均匀分配,以避免出现需要等待某个搅拌器因为它接收了多个工作包的情况。这就是平衡发挥作用的地方。
-
多个搅拌器的参与是显而易见的。
-
合并步骤的职责是成为一个扇入块;它将多个小份面团的多个流合并成一个相同块的单一流。
-
最后,在我们将其进一步加工之前,我们需要将小块面团合并成一个大的碗。
内部子流的类型如下:分割、合并以及所有的搅拌器都是流,平衡是扇出,合并是扇入。结果类型是Flow[Groceries, Dough]。
面包师在之前的图中看起来并不简单,因为它隐藏了一个烤箱以及与之的交互:

图中的烤箱有一个输入和一个输出,所以这只是一个Flow[RawCookies,ReadyCookies]。面包师有两个输入和两个输出,其形状是BidiFlow[RawCookies, RawCookies, ReadyCookies, ReadyCookies]。
结果的组合类型是Flow[RawCookies,ReadyCookies]。
在之前构建的示例中,使用演员系统时,面包师在烤箱不为空时维护了一个内部队列,以存放到达的生饼干。这有一个缺点,即如果经理非常急切,烘焙过程可能会被频繁启动,而生饼干到达面包师的速度可能会比烤箱烘焙的速度快得多。因此,生饼干的队列可能会无限增长,直到占用所有可用空间,我们可能需要丢弃生饼干以腾出空间,或者关闭面包房,因为没有其他演员可以工作的空间。
在这个版本的面包房中,我们决定不实现任何队列,而是依赖背压。我们预计如果Oven无法接受更多工作,它会与Baker通信。Baker也会这样做,一直追溯到Manger,这样就不可能表达出需要更多烘焙饼干的需求,除非有更多的烤箱容量可用。通过不同的缓冲策略,可以管理在任何时刻面包房中正在进行的工作量。为了示例的目的,我们将这个限制设置得较低,以展示背压的作用。
我们流的最后一步是一个Customer,其类型为Sink[ReadyCookies]。
现在,让我们转换思路,将我们构思的结构编码到代码中。
Akka Streams 基础知识
Akka Streams 流的元素通常使用适当类型的构造函数来定义。我们将逐一实现构成我们图表的构建块,从最简单的开始,逐渐过渡到越来越复杂的。
源和汇
我们流中最简单的组件可能是Consumer。它只是一个汇,应该打印出有关传入数据的信息。为了构建它,我们将使用Sink工厂,如下所示:
val consumer: Sink[ReadyCookies, Future[Done]] =
Sink.foreach(cookie => println(s"$cookie, yummi..."))
Sink 工厂提供了二十多种不同的构造函数来定义汇。我们正在利用其中最简单的一个,它为流中的每个元素调用一个提供的函数。
在这里,我们看到它的实际类型是Sink[ReadyCookies, Future[Done]]。这反映了ReadyCookies元素的类型以及Sink被物化为的类型。在这种情况下,如果流通过到达其结束而结束,则物化为Success,如果流中发生故障,则物化为Failure。
现在,我们将查看流的另一端并定义一个源。Source 工厂同样提供了近三十种不同的方法来创建源。我们不希望压倒我们的面包房团队,所以我们决定使用一个定时数据源:
private val delay = 1 second
private val interval = 1 second
val manager1: Source[NotUsed, Cancellable] =
Source.tick(delay, interval, NotUsed)
这代表了我们复合Source的第一个块,它的类型不适合我们的Boy,因此我们需要实现图表的第二块,即生成器,并将两者连接起来。这比解释起来更容易:
val manager: Source[ShoppingList, Cancellable] =
Source.tick(delay, interval, NotUsed).map { _ =>
shoppingList
}
我们基本上只是映射输入,但忽略它,并返回一个shoppingList。现在我们的Source有了合适的类型,这样我们就可以稍后将其连接到Boy。
这个实现有一个我们未考虑到的微妙之处。我们有一个预定义的间隔,目的是不让流的其他部分被请求淹没。但与此同时,我们正准备依赖Oven的背压来达到同样的目的。这并不理想,因为如果我们选择太大的间隔,我们的面包店将得不到充分利用;如果我们选择太小的间隔,这将是由背压来管理的流。我们可以简化我们的源,使其只产生购物清单,并在下游有可用容量时立即将它们放入管道:
val manager: Source[ShoppingList, NotUsed] =
Source.repeat(NotUsed).map(_ => shoppingList)
在这里,我们只是重复了NotUsed元素(它提供了一个很好的语法),然后像之前一样用随机购物清单替换它。不同的是,经理将在有需求时每次都生成购物清单,而不会因为计时器设置而等待太长时间。
流
现在我们有了源和汇,让我们来实现流本身。同样,我们将从最简单的部分开始,随着我们的进展逐步转向更复杂的部分。
最容易的流构建块无疑是Cook。它可以作为一个在先前的流定义上调用的映射函数来实现,但出于组合的原因,我们希望单独定义它。
流定义的方法与前面两个保持一致——Flow构造函数是最佳选择。流是在输入操作的基础上定义的,但定义本身与这个输入是解耦的。再次强调,有很多方法可以选择;就我们的目的而言,我们选择简单的map:
object Cook {
def formFlow: Flow[Dough, RawCookies, NotUsed] =
Flow[Dough].map { dough =>
print(s"Forming $dough - ")
val result = RawCookies(makeCookies(dough.weight))
println(result)
result
}
private val cookieWeight = 50
private def makeCookies(weight: Int): Int = weight / cookieWeight
}
cook的流只是映射输入面团,并将其转换为输出,即未加工的饼干,正如类型注解所表示的那样。
在这个意义上,《男孩》与Cook非常相似,因为它是一个简单的构建块,将输入转换为输出。不过有一个需要注意的地方——我们的Boy需要与远程演员进行通信才能完成这个任务。
Akka Streams 建立在 Akka 之上,因此提供了一些在不同阶段利用和与演员通信的可能性;例如,可以使用ActorRef作为源或汇。在这种情况下,远程方面由于 Akka 的位置透明性,实际上只是一个实现和配置细节。
在我们的用例中,与远程商店系统中部署的卖家进行通信的最合适方式将是询问模式。让我们一步一步来做。首先,我们将查找远程演员,以便能够与之通信:
def lookupSeller(implicit as: ActorSystem): Future[ActorRef] = {
val store = "akka.tcp://Store@127.0.0.1:2553"
val seller = as.actorSelection(s"$store/user/Seller")
seller.resolveOne()
}
给定一个ActorSystem,我们使用远程系统的地址和演员路径来查找一个演员。我们知道应该只有一个演员,因此我们解析一个引用。根据查找的结果,它将返回所需的引用的Success或Failure[ActorNotFound]。失败将通过错误流传播,并导致流终止,因为我们没有定义如何处理它。让我们称这为期望的行为,因为没有卖家,我们无法将购物清单转换为食品。
我们可以使用Future[ActorRef]与演员进行通信:
def goShopping(implicit as: ActorSystem, ec: ExecutionContext):
Future[Flow[ShoppingList, Groceries, NotUsed]] =
lookupSeller.map { ref =>
Flow[ShoppingList].askGroceries
}
在这里,我们不仅需要一个ActorSystem,还需要一个ExecutionContext,以便能够映射从lookupSeller获取的Future。我们使用演员引用(如果有)作为参数来调用Flow.ask。Flow的类型对应于预期的输入类型和ask的类型——预期的输出类型。
现在我们可以使用另一个Flow构造函数将Future[Flow]转换为Flow:
def shopFlow(implicit as: ActorSystem, ec: ExecutionContext): Flow[ShoppingList, Groceries, Future[Option[NotUsed]]] =
Flow.lazyInitAsync { () => goShopping }
lazyInitAsync将Future的内部Flow转换为正常的Flow。这个子流有适当的输入和输出类型,因此我们可以稍后将其插入到我们的流定义中。
在application.conf中扩展配置以包含 Akka 远程所需的属性非常重要,如第十一章所述,Akka 和 Actor 模型简介。
我们接下来要实现的是下一个复合步骤,即Baker,包括其组成部分Oven。
Oven需要花费一些时间将原始饼干转化为可食用的饼干,我们可以通过引入一点阻塞行为来实现这一点。但这样做会通过无谓地消耗可用线程来影响系统的其余部分。因此,我们将使用 Akka Streams 的另一个功能,Flow.delay,它允许我们在时间上延迟元素的发射:
def bakeFlow: Flow[RawCookies, ReadyCookies, NotUsed] =
Flow[RawCookies]
.delay(bakingTime, DelayOverflowStrategy.backpressure)
.addAttributes(Attributes.inputBuffer(1, 1))
.map(bake)
由于我们只有一个Oven,我们定义一个缓冲区大小为初始和最大大小为 1。我们也不希望丢弃到达的原始饼干或释放尚未准备好的饼干,因此我们定义一个溢出策略为背压。
bake方法再次是一个简单的转换:
private def bake(c: RawCookies): ReadyCookies = {
assert(c.count == ovenSize)
ReadyCookies(c.count)
}
现在,有了这个Oven,我们可以定义一个Baker,我们计划给它一个BidiFlow类型:
def bakeFlow = BidiFlow.fromFlows(inFlow, outFlow)
为了做到这一点,我们需要分别定义两个方向的inFlow和outFlow。
outFlow只是将准备好的饼干传递给消费者,我们已经知道如何做这件事:
private def outFlow = Flow[ReadyCookies]
inFlow稍微复杂一些,因为我们需要将来自某些随机数量的原始饼干分组到具有烤箱大小的组中。我们将通过定义单个饼干的子源并将它们按所需方式分组来实现这一点。以下是第一步:
def extractFromBox(c: RawCookies) = Source(List.fill(c.count)(RawCookies(1)))
我们正在创建一个源:单个饼干的数量。重新组合逻辑如下:
val inFlow = Flow[RawCookies]
.flatMapConcat(extractFromBox)
.grouped(Oven.ovenSize)
.map(_.reduce(_ + _))
flatMapConcat逐个消耗源,并将结果连接起来。然后,我们将单个饼干的流分组到ovenSize的List[RawCookie]流中。最后,我们将单个饼干的列表缩减为Oven期望的RawCookie(ovenSize)。
现在,我们可以通过连接将面包师傅的BidiFlow和烤箱的Flow组合成复合Flow:
val bakerFlow: Flow[RawCookies, ReadyCookies, NotUsed] =
Baker.bakeFlow.join(Oven.bakeFlow)
join方法将一个给定的Flow作为最终转换添加到BidiFlows的堆栈中。在我们的情况下,堆栈的大小为 1,结果的流类型为Flow[RawCookies, ReadyCookies, NotUsed]。这个子流程隐藏了重新分组饼干和等待它们准备好的所有细节,为我们提供了一个简洁的定义。
图
我们流程的最后一部分是一个Chef。它整合了混合器的工作管理。让我们首先实现Mixers。
混合行为本身很简单,但为了模仿真实硬件,我们包括一个混合时间的块:
def mix(g: Groceries) = {
Thread.sleep(mixTime.toMillis)
import g._
Dough(eggs * 50 + flour + sugar + chocolate)
}
由于混合行为,我们需要使用一个特殊的异步流程构造函数来为每个混合器启动一个单独的线程。为了更好地控制线程的分配方式,我们将在配置中添加一个单独的固定线程调度器的定义,该调度器为每个子流程分配一个线程:
mixers-dispatcher {
executor = "thread-pool-executor"
type = PinnedDispatcher
}
在此定义到位后,我们现在能够定义阻塞的混合行为:
private def subMixFlow: Flow[Groceries, Dough, NotUsed] =
Flow[Groceries].async("mixers-dispatcher", 1).map(mix)
async构造函数接受一个缓冲区大小作为参数,我们希望我们的混合器不要分配任何大缓冲区。
工作管理可以作为一个独立的概念实现,它类似于 Akka Streams 文档食谱中的某个配方——平衡器。它接受一个工作subFlow和工作者数量,并构建一个具有给定工作者数量的图:
import akka.stream.scaladsl.GraphDSL
import GraphDSL.Implicits._
def createGraphOut, In = {
val balanceBlock = BalanceIn
val mergeBlock = MergeOut
GraphDSL.create() { implicit builder ⇒
val balancer = builder.add(balanceBlock)
val merge = builder.add(mergeBlock)
for (_ ← 1 to count) balancer ~> subFlow ~> merge
FlowShape(balancer.in, merge.out)
}
}
Balance块是一个具有多个输出的扇出流。它将流元素均匀地分配给工作者。通过waitForAllDownstreams = false,我们指定一旦至少有一个工作者要求工作,分配就可以开始。通过false,我们将行为更改为在所有工作者要求工作之前等待分配。Merge是一个具有指定输入数量的扇入块。通过指定eagerComplete = false,我们告诉它等待所有下游完成,而不是在任何一个工作者完成时立即完成。
然后,我们使用GraphDSL.create()构造一个图,并提供实际的图构建逻辑作为参数。首先,我们将balanceBlock和mergeBlock转换为形状,并将它们添加到builder中。然后,我们使用import GraphDSL.Implicits._提供的~>语法将所需数量的子流程连接到平衡器并合并。五个工作者的for推导式相当于以下简单的定义:
balancer ~> subFlow ~> merge
balancer ~> subFlow ~> merge
balancer ~> subFlow ~> merge
balancer ~> subFlow ~> merge
定义了此图之后,我们可以使用另一个Flow构造函数来指定其余的Balancer流程:
def applyIn, Out: Flow[In, Out, NotUsed] =
Flow.fromGraph(createGraph(subFlow, count))
我们可以使用它来构建我们的Chef子流程:
def mixFlow: Flow[Groceries, Dough, NotUsed] =
Flow[Groceries]
.map(splitByMixer)
.flatMapConcat(mixInParallel)
def splitByMixer(g: Groceries) = {
import g._
val single = Groceries(1, flour / eggs, sugar / eggs, chocolate / eggs)
List.fill(g.eggs)(single)
}
def mixInParallel(list: List[Groceries]) =
Source(list)
.via(Balancer(subMixFlow, list.size))
.grouped(list.size)
.map(_.reduce(_ + _))
在这里,我们再次将“杂货”分成更小的部分,并使用专用混合器并行混合每一部分,然后使用之前与“面包师”和“烤箱”相同的技巧将它们组合起来。
日志
在Cook的流程中,我们使用了两个print语句来查看Cook的表现。对于我们的例子来说是可以的,但我们会更倾向于使用适当的日志。让我们改进这一点。
Akka 提供了一个log方法,它接受一个日志名称作为参数,可以在流程中的任何处理阶段调用。让我们用它来代替我们的print语句:
def formFlow: Flow[Dough, RawCookies, NotUsed] =
Flow[Dough]
.log("Cook[Before Map]")
.map { dough =>
RawCookies(makeCookies(dough.weight))
}
.log("Cook[After Map]")
.withAttributes(
Attributes.logLevels(
onElement = Logging.InfoLevel,
onFinish = Logging.DebugLevel,
onFailure = Logging.WarningLevel
)
)
这里,我们在日志中写入流程在转换前后的元素,并提供可选的日志配置,以指定不同类型事件的日志级别。
为了看到这些更改的效果,我们需要扩展application.conf:
akka {
loggers = ["akka.event.Logging$DefaultLogger"]
# Options: OFF, ERROR, WARNING, INFO, DEBUG
loglevel = "INFO"
}
现在,在启动我们的示例之后,我们将在日志中看到以下条目:
[INFO] [Bakery-akka.actor.default-dispatcher-14] [akka.stream.Log(akka://Bakery/system/StreamSupervisor-0)] [Cook[Before Map]] Element: Dough(575)
...
[INFO] [Bakery-akka.actor.default-dispatcher-14] [akka.stream.Log(akka://Bakery/system/StreamSupervisor-0)] [Cook[After Map]] Element: RawCookies(11)
...
[INFO] [Bakery-akka.actor.default-dispatcher-14] [akka.stream.Log(akka://Bakery/system/StreamSupervisor-0)] [Cook[Before Map]] Element: Dough(1380)
[INFO] [Bakery-akka.actor.default-dispatcher-14] [akka.stream.Log(akka://Bakery/system/StreamSupervisor-0)] [Cook[After Map]] Element: RawCookies(27)
在设置日志后,我们已经定义了流程的所有部分,可以尝试将它们组合在一起。
物化
现在我们可以为我们的面包店指定整个流程:
val flow = Boy.shopFlow
.via(Chef.mixFlow)
.via(Cook.formFlow)
.via(bakerFlow)
val graph: RunnableGraph[Future[Done]] = manager.via(flow).toMat(consumer)(Keep.right)
implicit val materializer: Materializer = ActorMaterializer()
graph.run().onComplete(_ => afterAll)
这里,我们首先通过组合之前定义的子流程来构建完整的流程。然后,我们通过附加manager源和consumer汇来将流程转换为可运行的图。
我们还指定我们想要保留正确的物化值。左边的物化值将是流的结果,在我们的例子中是NotUsed,因为我们只是将生产的饼干写入控制台。右边的值是一个当流程完成后完成的未来,我们希望用它来关闭我们的 actor 系统,一旦发生就立即关闭。
最后,我们通过将ActorMaterializer引入作用域并调用相应的run方法来运行图。
我们的系统运行并烘焙美味的饼干,但不幸的是,我们忘记考虑一个重要的方面:在我们的设置中,混合器容易发生硬件故障。
处理故障
为了使混合步骤更真实,我们将添加几个异常,并在混合阶段随机抛出它们。这将模拟在不可预测的时间出现的硬件故障。混合器可以像在前两章基于 actor 的示例中那样抛出三种异常之一:
object MotorOverheatException extends Exception
object SlowRotationSpeedException extends Exception
object StrongVibrationException extends Exception
val exceptions = Seq(MotorOverheatException,
SlowRotationSpeedException,
StrongVibrationException)
实际的mix方法可能看起来像这样:
private def mix(g: Groceries) = {
if (Random.nextBoolean()) throw exceptions(Random.nextInt(exceptions.size))
Thread.sleep(mixTime.toMillis)
import g._
Dough(eggs * 50 + flour + sugar + chocolate)
}
异常可以以几种不同的方式处理。最直接的方法是在逻辑中直接捕获它们:
private def mix(g: Groceries) = try {
if (Random.nextBoolean()) throw exceptions(Random.nextInt(exceptions.size))
Thread.sleep(mixTime.toMillis)
import g._
Dough(eggs * 50 + flour + sugar + chocolate)
} catch {
case SlowRotationSpeedException =>
Thread.sleep(mixTime.toMillis * 2)
import g._
Dough(eggs * 50 + flour + sugar + chocolate)
}
在慢速旋转的情况下,我们决定忽略这个问题,保留混合物,并给混合器双倍的时间来完成混合。
这种方法可行,但它有一个明显的缺点,那就是我们混淆了业务和错误处理实现。这种情况通常是不受欢迎的,因为这两个方面的代码通常具有不同的性质。快乐路径包含与业务相关的代码,而错误处理具有技术本质。因此,通常更倾向于将这些代码路径分开。在我们的案例中,在阶段级别处理失败是合理的,因为我们不希望丢弃流中的元素。
Akka 提供了指定失败处理的其他方法。其中之一是恢复逻辑,可以为阶段定义,以便将失败转换为流的最终元素:
def subMixFlow: Flow[Groceries, Dough, NotUsed] =
Flow[Groceries].async("mixers-dispatcher", 1).map(mix).recover {
case MotorOverheatException => Dough(0)
}
在这里,我们决定在电机故障的情况下返回一个空的面团碗。然后流完成,但在这个案例中这没关系,因为我们的搅拌器本身就是一次性子流程。
recover方法是recoverWithRetries的特殊情况。后者不仅接受用于决策的部分函数,还在同一处理阶段发生多次失败的情况下接受重试次数。
现在我们唯一缺少的是如何处理StrongVibrationException的决定。如果我们决定不处理它,默认行为将能够停止整个流。如果发生这种情况,下游阶段将得知失败,上游阶段将被取消。
我们绝对不希望因为我们的搅拌器振动得太厉害而关闭我们的面包店。恰恰相反;我们希望完全忽略这一点。一些阶段支持与演员相同的方式定义监督策略。我们可以利用这种可能性来定义一个通用的错误处理行为。首先,我们需要定义一个决策策略:
val strategy: Supervision.Decider = {
case StrongVibrationException ⇒ Supervision.resume
case _ => Supervision.Stop
}
有三种策略可用——停止、重启和恢复:
-
停止策略是默认的,它将停止处理阶段并传播上下游阶段的失败。
-
恢复策略只是丢弃当前元素,流继续。
-
重启与恢复类似——它丢弃当前元素,流继续,但在那之前阶段被重启,因此任何内部状态都被清除。
在我们的决策器中,我们只想在强振动的情况下让流继续,但在任何其他失败的情况下停止。我们除了监督策略外还处理其他类型的异常,因此这个决定是安全的。
这是我们如何将我们的监督策略应用到处理阶段的定义中:
private def subMixFlow: Flow[Groceries, Dough, NotUsed] =
Flow[Groceries].async("mixers-dispatcher", 1).map(mix).recover {
case MotorOverheatException => Dough(0)
}.withAttributes(ActorAttributes.supervisionStrategy(strategy))
现在,如果我们启动我们的示例,它将按预期运行并处理硬件故障。
它看起来不错,但我们还没有完成,因为我们还没有测试我们的面包店。
测试
由于系统的各个部分相互关联,基于流的代码的测试可能看起来很复杂。但更常见的是,测试流归结为在隔离状态下对处理阶段进行单元测试,并依赖于 Akka Streams 确保这些阶段之间的数据流按预期发生。
通常情况下,不需要特殊的测试库。让我们通过测试我们的源来演示这一点:
"manager source" should {
"emit shopping lists as needed" in {
val future: Future[Seq[ShoppingList]] = Manager.manager.take(100).runWith(Sink.seq)
val result: Seq[ShoppingList] = Await.result(future, 1.seconds)
assert(result.size == 100)
}
}
为了运行这个测试片段,我们需要一个隐式材料化器在作用域内:
implicit val as: ActorSystem = ActorSystem("test")
implicit val mat: Materializer = ActorMaterializer()
通用方法是,为了测试一个Sink,它可以连接到特殊的Source,而对于正在测试的Source,我们需要一个特殊的Sink。
在这两种情况下,基于序列的Source和Sink可能是最有用的。在我们的例子中,我们正在测试我们的源至少发出一百个购物清单,并且以及时的方式完成。结果可以作为Seq[ShoppingList]提供,并在需要时进行检查。
为了测试一个流程,我们需要提供测试Source和Sink:
"cook flow" should {
"convert flow elements one-to-one" in {
val source = Source.repeat(Dough(100)).take(1000)
val sink = Sink.seq[RawCookies]
val future: Future[Seq[RawCookies]] = source.via(Cook.formFlow).runWith(sink)
val result: Seq[RawCookies] = Await.result(future, 1.seconds)
assert(result.size == 1000)
assert(result.forall(_.count == 2))
}
}
在这里,我们看到相同的方法。在定义测试输入和输出后,我们驱动被测试的流程,并验证输出具有预期的属性。
在这两种情况下,都存在对Await.result的不理想调用,这与运行 Akka Streams 流程产生Future的事实相关。我们可以通过使用如第五章中描述的测试技术来改进这一点,基于属性的 Scala 测试。
或者,也可以使用其他 Akka 库提供的测试工具包。
Akka TestKit
Akka Streams 通过actorRef方法提供与 Akka actors 的集成。它作为 Sink 构造函数可用,因此我们可以使用一个 actor 来接收流程的元素,这些元素随后被表示为 actor 接收到的消息。使用 Akka TestKit中的TestProbe来验证关于流程的假设是方便的。首先,我们需要在build.sbt中添加对 Akka TestKit的依赖:
libraryDependencies += com.typesafe.akka" %% "akka-testkit" % akkaVersion % Test
这里是一个如何使用TestProbe的例子:
"the boy flow" should {
"lookup a remote seller and communicate with it" in {
val probe = TestProbe()
val source = Manager.manager.take(1)
val sink = Sink.actorRefGroceries
source.via(Boy.shopFlow).runWith(sink)
probe.expectMsgType[Groceries]
}
}
我们测试如果有一个消息进入流程,那么流程将会有一个消息输出。这次我们不是等待未来完成,而是使用TestProbe支持的语法来制定我们的假设。
到现在为止,你应该已经识别出我们使用的模式。首先,设置源和/或汇,然后等待流程完成,最后验证关于流程输出的假设。当然,Akka 团队为 Akka Streams 提供了一个特殊的测试套件来抽象这一点。
Streams TestKit
为了使用 Akka Streams TestKit,我们需要在我们的项目配置中添加另一个依赖到build.sbt:
libraryDependencies ++= "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % Test
让我们看看这个模块提供的TestSink和TestSource如何简化我们制定测试逻辑的方式。现在我们将测试从Boy到Baker的整个流程:
"the whole flow" should {
"produce cookies" in {
val testSink = TestSink.probe[ReadyCookies]
val source = TestSource.probe[ShoppingList]
val (publisher: TestPublisher.Probe[ShoppingList],
subscriber: TestSubscriber.Probe[ReadyCookies]) =
source.via(Bakery.flow).toMat(testSink)(Keep.both).run()
subscriber.request(10)
publisher.sendNext(ShoppingList(30, 1000, 100, 100))
subscriber.expectNext(40.seconds, ReadyCookies(12))
subscriber.expectNext(40.seconds, ReadyCookies(12))
}
}
在这个场景中,我们首先使用测试工具包提供的构造函数创建TestSink和TestSource探针。然后我们将它们实体化为publisher和subscriber,以便能够驱动流程。在这里,我们再次使用toMat语法。到目前为止,我们隐式地使用了默认值(Keep.left),但现在我们希望保留流程和 sink 的实体化结果。运行流程返回其实体化实例,它是一个对:TestPublisher和TestSubscriber。
然后,我们使用subscriber从流程中请求 10 条消息。在 Reactive Streams 中,生产者不应该在存在需求之前向下游发送任何内容,我们通过这个调用表达需求。我们期望流程输出代表RawCookies(12)的元素。因此,我们的subscriber.request转换为要生产的 120 个饼干。
在有这种需求的情况下,我们从源发送下一个购物清单以启动流程。
最后,我们期望至少有两批饼干到达 sink。我们为流通过所有阶段提供足够的时间,考虑到混合和烘焙阶段的延迟。
由于我们在MotorOverheatException和SlowRotationSpeedException的情况下在混合阶段丢弃消息的方式,我们也不能可靠地预测将制作多少饼干。
在这个例子中,我们只是触及了 Akka Streams TestKit提供的所有可能性的表面。随着你开发基于 Akka Streams 的系统,值得重新查阅库的文档和源代码,并记住它们提供的不同测试方法。
运行应用程序
如果你仍然需要安装 Java 和/或 SBT,请参阅附录 A,准备环境和运行代码示例。
我们将以与第十一章,Akka 和 Actor 模型简介和第十二章,使用 Akka Typed 构建响应式应用程序相同的方式在终端中运行我们的应用程序,使用两个独立的终端会话分别运行Store和BakeryApp,使用以下命令:
-
sbt "runMain ch13.BakeryApp" -
sbt "runMain ch13.Store"
我们更喜欢这种方法,因为它简洁。如果你即将以交互模式运行应用程序,请参阅第十一章,Akka 和 Actor 模型简介,以获取对此方法的详细解释。
在我们的示例中,我们期望远程的 Store 应用在启动主 Bakery 流时可用。因此,我们必须首先启动 Store,否则 BakeryApp 将在无法连接到存储时以异常退出。下一个截图显示了两个终端窗口,左侧窗口中输入了运行 Store 的命令,右侧窗口中启动了 BakeryApp。在接下来的截图中,我们可以看到 Store 已经运行了一段时间,而 BakeryApp 刚开始执行:

右侧终端中的 Bakery 现在将一直运行,直到使用 Ctrl + C 快捷键停止或关闭终端窗口。
摘要
传统的流解决方案存在两个问题之一。在拉取的情况下,需要快速消费者端锁定或大量使用资源。在推送的情况下,要处理的消息数量可能会超过可用内存,需要慢速消费者丢弃消息或因内存溢出而终止。反应式流通过定义具有背压的动态异步拉-推来解决此问题。Akka Streams 使用 Akka 实现了反应式流标准,这允许与这两种技术无缝集成。
Akka 中的流是由称为阶段或流程的块构建的。这些块可以嵌套并相互连接,形成图。通过将它们连接到源和汇,具有单个输入和单个输出的图可以变得可执行。图定义可以自由共享和重用。
运行一个图需要一个材料化器,并产生一个根据图和汇定义的材料化值。
Akka Streams 中的错误处理可以通过不同的方式完成,包括在流程定义中直接捕获错误,定义一个带有可选重试和/或覆盖支持它的处理阶段的监督策略的恢复方法。
流定义的模块化特性允许对单个阶段及其组合进行直接测试。为了减少重复测试设置和期望定义的样板代码,Akka Streams 提供了特殊的测试工具包。
鼓励读者查看官方 Akka 文档doc.akka.io/docs/akka/current/stream/index.html,以更详细地了解 Akka Streams 提供的可能性。
问题
-
列出与“经典”流相关的两种不同模式。它们有什么问题?
-
为什么反应式流被认为在动态拉-推模式下是可行的?
-
Akka Stream 图的典型构建块是什么?
-
我们如何将一个图转换为可执行的图?
-
为什么要将材料化作为一个单独的明确步骤的主要目标?
-
描述应用不同监督策略的效果。
-
哪些主要抽象提供了 Akka Streams
TestKit?为什么它们是有用的?
进一步阅读
-
Christian Baxter,《精通 Akka:**掌握使用 Akka 创建可扩展、并发和响应式应用程序的技艺》
-
Héctor Veiga Ortiz, Piyush Mishra,《Akka 烹饪书:学习如何使用 Akka 框架在 Scala 中构建有效的应用程序》
-
Rambabu Posa,《Scala Reactive Programming:在 Scala 中构建容错、健壮和分布式应用程序》
第十四章:项目 1 - 使用 Scala 构建微服务
在本书中,我们逐渐扩大了我们的兴趣范围。在第一部分,我们从语言结构和小型构建块开始,例如类型和函数。在第二部分,我们专注于函数式编程的模式。在第三部分,我们探讨了更大的抽象——actor 模型和流。
在本节中,我们将再次放大视角,这次从设计层面上升到架构层面。我们将利用到目前为止所学的内容来构建两个全面的项目。
现在,不言而喻,所有服务器端软件都应该提供 API,特别是 HTTP RESTful API。提供 API 的软件被称为服务,如果它符合一系列原则,通常被称为微服务。我们将跟随潮流,将我们的项目设计为微服务。
在本章中,我们将涵盖两个主题。首先,我们将讨论微服务的概念,描述其优势和构建原则。我们还将探讨与基于微服务的方法相关的几个技术和组织挑战。
第二,我们将利用本书其余部分获得的知识,从头开始构建两个真实项目。这两个项目都代表了简单的微服务,实现了有状态的 REST API,这些 API 代表了你在本书第三部分熟悉的杂货店。这次,我们不仅提供下单的机会,还可以创建和删除商品,以及补充库存并获取当前库存状态。
第一个项目将基于本书第二部分介绍的原则构建。我们将使用开源函数式编程库——http4s 和 circe 用于客户端 API,以及 doobie 用于数据库访问。
第二个项目将使用本书第三部分涵盖的响应式编程库和技术构建。我们将使用 Akka-HTTP 构建 API 层,并使用 Akka Persistence 实现其有状态的部分。
本章将涵盖以下主题:
-
微服务基础
-
使用 http4s 的纯函数式 HTTP API
-
使用 doobie 的纯函数式数据库访问
-
使用 Http1Client 进行 API 集成测试
-
使用 Akka-HTTP 的响应式 HTTP API
-
使用 Akka Persistence 的事件源持久状态
技术要求
在我们开始之前,请确保您已安装以下内容:
-
SBT 1.2+
-
Java 1.8+
本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter14。
微服务基础
讨论微服务时,最好从大小问题开始。显然,软件系统的大小在满足用户日益增长的需求时也在增长。功能数量、它们的复杂性和复杂性都在增长,软件项目中的代码行数也在增加。即使在结构良好的活系统中,组件的大小和数量也会随着时间的推移而增加。鉴于有限的人类心智能力,单个程序员所能理解的系统比例会缩小,这导致团队中开发人员数量的增加。更大的团队规模会导致通信开销的增加,从而减少编写代码的时间,导致需要更多的开发人员,这引入了一个自我强化的循环。
因此,将系统作为单一项目构建的传统单体方式,即使用单一部署模块或可执行文件和单一数据库,正变得越来越低效,最终使得及时交付可工作的软件变得不可能。一种替代方法是,将单体拆分为独立的项目,称为微服务,这些项目可以独立开发。
微服务似乎是单体方法的唯一可行替代方案,因此越来越受欢迎。但是,它们究竟是什么?根据microservices.io,微服务,也称为微服务架构,是一种将应用程序结构化为一系列松散耦合的服务,这些服务实现业务能力。
这意味着什么?本质上,这就是如果将结构良好的应用程序拆分,并从每个负责单一业务功能的模块中创建一个自主应用程序会发生的情况。
在这个定义中,“自主性”适用于多个层面:
-
代码库和技术栈:服务的代码不应与其他服务共享。
-
部署:服务在时间和底层基础设施方面都是独立于其他服务的。
-
状态:服务拥有自己的持久存储,其他服务访问数据的唯一方式是通过调用拥有该数据的服务。
-
故障处理:预期微服务具有弹性。在下游服务出现故障的情况下,预期相关服务将隔离故障。
这些自主性方面使我们能够从基于微服务的架构中获得许多好处:
-
即使对于非常复杂的应用程序,持续交付也是可行的
-
每个服务的复杂性都很低,因为它仅限于单一业务能力
-
独立部署意味着具有不同负载的服务可以独立扩展
-
代码无关性使得多语言环境成为可能,并使得采用新技术更加容易
-
团队可以缩小规模,这减少了通信开销并加快了决策速度
当然,这种方法也有缺点。最明显的缺点与微服务需要相互通信的事实有关。以下是几个重要的困难:
-
习惯性事务不可用
-
调试、测试和跟踪涉及多个微服务的调用
-
复杂性从单个服务转移到它们之间的空间
-
服务位置和协议发现需要大量努力
但不要害怕!在本章的剩余部分,我们只构建一个微服务,这样我们就不会受到这些弱点的困扰。
使用 http4s 和 doobie 构建微服务
让我们看看,如果使用基于本书前两章所学的原则的开源库来实现,一个具有 RESTful 接口的微服务将是什么样子。
我们将从讨论构成应用程序的基本构建块以及它们如何连接在一起开始。说到块,我们需要简要地谈谈 FS2 库,它是我们将使用的其他库的基础,因此它决定了我们如何将它们组合在一起。之后,我们将讨论数据库迁移、项目配置、数据库逻辑的实现以及服务层。当然,我们将通过为我们构建的服务实现集成测试来结束我们的讨论。
项目结构
我们的项目将包括以下组件:
-
数据库仓库代表数据库上的一个抽象层
-
数据库迁移器包含数据库表的初始化逻辑
-
REST API 定义了可用的 HTTP 调用和相关业务逻辑
-
配置合并了应用程序参数,例如服务器绑定和数据库属性
-
服务器将所有其他组件连接在一起,并在配置的地址上启动和绑定 HTTP 服务器
我们首先将以下依赖项添加到build.sbt(确切版本可以在 GitHub 仓库中找到):
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-blaze-server" % http4sVersion,
"org.http4s" %% "http4s-circe" % http4sVersion,
"org.http4s" %% "http4s-dsl" % http4sVersion,
"org.tpolecat" %% "doobie-core" % doobieVersion,
"org.tpolecat" %% "doobie-h2" % doobieVersion,
"org.tpolecat" %% "doobie-hikari" % doobieVersion,
"com.h2database" % "h2" % h2Version,
"org.flywaydb" % "flyway-core" % flywayVersion,
"io.circe" %% "circe-generic" % circeVersion,
"com.github.pureconfig" %% "pureconfig" % pureConfigVersion,
"ch.qos.logback" % "logback-classic" % logbackVersion,
"org.typelevel" %% "cats-core" % catsVersion,
"org.http4s" %% "http4s-blaze-client" % http4sVersion % "it,test",
"io.circe" %% "circe-literal" % circeVersion % "it,test",
"org.scalatest" %% "scalatest" % scalaTestVersion % "it,test",
"org.scalamock" %% "scalamock" % scalaMockVersion % Test
)
这个列表肯定比您期望的示例项目要长。让我们仔细检查为什么我们需要将其中的每个依赖项都放入其中:
-
http4s 是我们将用于 HTTP 层的库
-
doobie 是一个功能性的 JDBC(Java 数据库连接)装饰器
-
H2 是一个嵌入式数据库,我们将使用它来避免安装独立实例
-
Flyway 用于数据库迁移(用于更改数据库结构的版本化 SQL 语句)
-
Circe 是一个 JSON 瑞士军刀
-
PureConfig是一个类型化配置包装器 -
Cats是一个包含通用函数式编程抽象的库
FS2 – 功能流
您可能想知道,如果我们不将 FS2 库作为应用程序的组件使用,为什么我们还要有一个关于 FS2 库的部分。实际上,我们是。它是我们使用的数据库和 HTTP 库的底层构建块,因此简要讨论它对于您了解其他构建块是如何连接在一起的是很重要的。
FS2是一个流库,允许我们构建和转换复杂的流。FS2 中的流不仅包含元素,还可以体现效果,如 IO。这个特性使得几乎可以将任何东西描述为 FS2 流。像http4s和doobie这样的库建立在它之上,并为用户提供更高级别的 API。但这个 API 仍然是流式的。
流被表示为Stream[F,O],其中F描述了流的可能效果,O是其元素或输出的类型。为了完全指定它,需要给出两个类型参数。如果流没有效果,它将是纯的:Stream[Pure, O]。
让我们构建一个chars流:
val chars: fs2.Stream[fs2.Pure,Char] = Stream.emits(List('a','b','c'))
纯流可以在不评估的情况下转换为List或Vector:chars.toList
由于存在效果,带有效果的数据流不能以相同的方式进行转换。首先需要将效果减少到单个效果。同时,我们需要定义如何处理流的输出。最后,我们可以执行效果并获得流的输出。这个过程类似于我们在第十三章中看到的 Akka 流的定义和具体化,Akka Streams 基础。因为我们有很多东西要定义,所以语法有点繁琐,但它反映了我们描述的逻辑:
object Test extends App {
import fs2.Stream
import cats.effect.IO // 1
val io: IO[String] = IO { println("IO effect"); "a" * 2 } // 2
val as: Stream[IO, String] = Stream.eval(io) // 3
val c: Stream.ToEffect[IO, String] = as.compile // 4
val v: IO[Vector[String]] = c.toVector // 5
val l: IO[List[String]] = c.to[List] // 6
val d: IO[Unit] = c.drain // 7
val e: IO[Option[String]] = c.last // 8
println(v.unsafeRunSync()) // 9
println(e.unsafeRunSync()) // 10
Stream.eval(IO { 42 }).compile.toList.unsafeRunSync() // 11
}
让我们逐行分析这个片段,看看发生了什么。代码中的数字将对应于以下解释中的数字:
-
我们使用 cats 的
IO作为效果的类型。 -
我们将
IO定义为带名称的参数,用于写入控制台并返回aa。 -
我们
eval我们的IO。这创建了一个单元素流。 -
通过编译流,我们创建其投影到单个效果。
-
通过将
ToEffect投影转换为Vector,它被编译为预期的效果类型。这个过程可以被视为执行一系列效果并将发出的结果记录到所需的结构中。 -
我们展示了另一种定义转换为集合的方法。
-
drain用于丢弃任何发出的值,如果我们只对执行效果感兴趣,它非常有用。 -
还有其他可能性来定义应该对流的输出元素做什么,例如,只收集最后一个。
-
unsafeRunSync()运行定义,同步产生效果并发出输出。这是第一次在控制台出现任何内容,因为我们到目前为止只是创建和修改了流的定义。 -
定义是不可变的,可以共享。正因为如此,我们可以多次运行相同的流描述(相对于效果类型)。
-
所有这些通常都定义为一行:评估效果,将流编译为单个效果,定义元素的输出类型,稍后运行流。
现在让我们看看 http4s 和 doobie 如何利用 FS2。我们将从数据库层开始,因为它的实现将指导其他层的结构。
数据库迁移
为了使数据库能够在应用程序中使用,它需要包含所有必需的表、索引和其他定义。
我们将我们的存储表示为一个简单的表,其中项目的名称作为主键,每个项目的非负计数:
CREATE TABLE article (
name VARCHAR PRIMARY KEY,
count INTEGER NOT NULL CHECK (count >= 0)
);
我们将这个定义放入 db_migrations/V1__inventory_table.sql,并使用 Flyway 在启动时检查我们的数据库是否处于正确的状态。
Flyway 通过遵循特定的命名约定,将数据库模式迁移 SQL 放置在项目文件夹中,提供了一种机制来定义和更改数据库模式。您可以在flywaydb.org了解更多相关信息。
Flyway 迁移的代码非常简单:
def initialize(transactor: HikariTransactor[IO]): IO[Unit] = {
transactor.configure { dataSource =>
IO {
val flyWay = new Flyway()
flyWay.setLocations("classpath:db_migrations")
flyWay.setDataSource(dataSource)
flyWay.migrate()
}
}
}
给定一个 transactor(我们将在稍后进行描述,目前我们将讨论 doobie),我们使用它提供的数据源来创建一个 Flyway 实例,配置它使用适当的迁移位置,并执行迁移。请注意,初始化逻辑被封装在 IO 效应中,因此延迟到效应被评估时。
使用 doobie 提供的实用工具创建 transactor:
def transactor(c: DBConfig): IO[HikariTransactor[IO]] = {
HikariTransactor
.newHikariTransactorIO
}
再次封装在 IO 中,因此直到我们运行此函数的结果,不会有任何效应被评估。
在我们转到数据库仓库的定义之前,让我们快速看一下我们在 transactor 方法中使用的配置抽象。
使用 PureConfig 进行配置
我们已经熟悉 Typesafe Config 库,我们在面包店示例中积极使用了它。这是一个非常有用且灵活的库。不幸的是,由于这种灵活性,它有一个缺点:每个配置位都需要单独读取并转换为适当的类型。理想情况下,我们希望我们的配置以案例类的形式表示,并依赖于命名约定将配置文件的结构映射到应用程序中(有类型的)配置结构。理想情况下,我们希望在启动时快速失败,如果配置文件无法映射到代码级别的配置描述的案例类。
pureconfig 库使得这一点成为可能。这个库可以在github.com/pureconfig/pureconfig找到。
使用它,我们可以在 Scala 中定义配置结构,如下所示:
case class ServerConfig(host: String, port: Int)
case class DBConfig(driver: String, url: String, user: String, password: String)
case class Config(server: ServerConfig, database: DBConfig)
这个定义反映了 HOCON 格式中的配置结构:
server {
host = "0.0.0.0"
port = 8080
}
database {
driver = "org.h2.Driver"
url = "jdbc:h2:mem:ch14;DB_CLOSE_DELAY=-1"
user = "sa"
password = ""
}
现在,我们可以使用 pureconfig 直接将其加载并映射到案例类:
object Config {
def load(fileName: String): IO[Config] = {
IO {
val config = ConfigFactory.load(fileName)
pureconfig.loadConfigConfig
}.flatMap {
case Left(e) =>
IO.raiseErrorConfig)
case Right(config) =>
IO.pure(config)
}
}
}
再次封装在 IO 中,因此延迟,我们正在尝试加载和映射配置,并在 IO 的上下文中引发适当的错误,如果这个尝试失败。
配置位完成了示例的基础设施部分,我们最终可以转向核心——数据库仓库。
Doobie – 函数式数据库访问
在我们的例子中,数据库层是用 Doobie 库实现的。它的官方网站将其描述为Scala 和 Cats 的纯函数式 JDBC 层。它允许我们以优雅的函数式方式抽象现有的 JDBC 功能。让我们看看这是如何实现的。该库可以在tpolecat.github.io/doobie/找到。在以下示例中,请假设以下导入是有效的:
import cats.effect.IO
import fs2.Stream
import doobie._
import doobie.implicits._
import doobie.util.transactor.Transactor
import cats.implicits._
我们还需要一些模型类来持久化,为了示例的目的,我们将 ADT 保持尽可能小:
object Model {
type Inventory = Map[String, Int]
abstract sealed class Operation(val inventory: Inventory)
final case class Purchase(order: Inventory)
extends Operation(order.mapValues(_ * -1))
final case class Restock(override val inventory: Inventory)
extends Operation(inventory)
}
这个模型将允许我们以映射的形式表示我们商店的库存,键指的是文章名称,值表示相应物品的库存数量。我们还将有两个可以应用于库存的操作——购买操作将减少相应物品的数量,而补货操作将通过组合我们的现有库存来增加相应物品的数量。
现在我们可以为这个模型定义我们的仓库。我们将以之前相同纯函数的方式来做这件事:
class Repository(transactor: Transactor[IO]) { ... }
仓库被赋予Transactor[IO]作为构造参数。在这个例子中,IO是cats.effect.IO。事务处理者知道如何与数据库连接一起工作。它可以以与连接池相同的方式管理连接。在我们的实现中,Transactor被用来将 FS2 的Stream[IO, ?]转换为IO,如果运行,它将连接到数据库并执行 SQL 语句。让我们详细看看这是如何为文章创建完成的:
def createArticle(name: String): IO[Boolean] = {
val sql: Fragment = sql"INSERT INTO article (name, count) VALUES ($name, 0)" // 1
val update: Update0 = sql.update // 2
val conn: ConnectionIO[Int] = update.run //3
val att: ConnectionIO[Either[Throwable, Int]] = conn.attempt //4
val transact: IO[Either[Throwable, Int]] = att.transact(transactor) // 5
transact.map { // 6
case Right(affectedRows) => affectedRows == 1
case Left(_) => false
}
}
让我们逐行查看这个定义,看看这里发生了什么:
-
我们定义了一个
Fragment,它是一个可以包含插值值的 SQL 语句。片段可以组合在一起。 -
从
Fragment,我们构建了一个Update。Update可以用来稍后构建一个ConnectionIO。 -
我们通过在
update上调用run方法来构建一个ConnectionIO。ConnectionIO基本上是对 JDBC 连接上可能进行的操作的一种抽象。 -
通过调用
attempt方法,我们在ConnectionIO中添加了错误处理。这也是为什么ConnectionIO的类型参数从Int变为Either[Throwable, Int]的原因。 -
通过向
transact方法提供一个transactor,我们将ConnectionIO转换为IO,这代表了一个可运行的 doobie 程序。 -
我们将
Either的不同方面强制转换为单个布尔值。我们期望创建的行数正好为一行,在这种情况下,调用是成功的。如果我们未能创建行或抛出了异常,则视为失败。
在错误情况下,区分唯一索引或主键违反和其他情况可能更合适,但不幸的是,不同的数据库驱动程序对这一点的编码不同,因此无法提供简洁的通用实现。
我们仓库中的其他方法将遵循相同的模式。deleteArticle是一个单行代码,我们在这个情况下不处理错误(异常将向上层冒泡,并在抛出时传播给客户端),所以我们只需检查受影响的行数是否正好为一:
def deleteArticle(name: String): IO[Boolean] =
sql"DELETE FROM article WHERE name = $name"
.update.run.transact(transactor).map { _ == 1 }
getInventory有点不同,因为它需要返回查询的结果:
def getInventory: Stream[IO, Inventory] = {
val query: doobie.Query0[(String, Int)] =
sql"SELECT name, count FROM article".query[(String, Int)]
val stream: Stream[IO, (String, Int)] =
query.stream.transact(transactor)
stream.fold(Map.empty[String, Int])(_ + _)
}
在这里,我们看到查询是doobie.Query0[(String, Int)]类型,类型参数表示结果列的类型。我们通过调用stream方法将查询转换为Stream[ConnectionIO, (String, Int)](一个带有ConnectionIO效果类型和元组作为元素类型的 FS2 流),然后通过提供一个事务转换器将ConnectionIO转换为IO。最后,我们将流中的元素折叠到Map中,从而从单个行构建当前时刻的库存状态。
更新库存还有一个需要注意的地方。我们希望一次性更新多个文章,这样如果某些文章的供应不足,我们就放弃整个购买。
这是一个设计决策。我们可以决定将部分完成的订单返回给客户。
每个文章的计数都需要单独更新,因此我们需要在单个事务中运行多个更新语句。这是如何实现的:
def updateStock(inventory: Inventory): Stream[IO, Either[Throwable, Unit]] = {
val updates = inventory.map { case (name, count) =>
sql"UPDATE article SET count = count + $count WHERE name = $name".update.run
}.reduce(_ *> _)
Stream
.eval(
FC.setAutoCommit(false) *> updates *> FC.setAutoCommit(true)
)
.attempt.transact(transactor)
}
我们得到一个name -> count对的映射作为参数。我们首先将这些对中的每一个转换为更新操作,通过映射它们。这给我们留下了一个CollectionIO[Int]的集合。然后我们使用 cats 的Apply操作符将这些更新组合在一起,它产生一个单一的CollectionIO[Int]。
JDBC 默认启用自动提交,这将导致我们的批量更新逐个执行和提交。这可能导致部分完成的订单。为了避免这种情况,我们将更新包装到流中,这将禁用在更新之前自动提交,并在所有更新执行完毕后再次启用自动提交。然后我们提升结果的错误处理,并将其转换为之前的可运行IO。
该方法的结果是Stream[IO, Either[Throwable, Unit]]类型。流中元素的类型编码了两种可能性:由于库存中文章不足而无法进行的更新作为Left,以及成功的更新作为Right。
通过这四个方法,我们实际上拥有了所有必需的基本功能,并可以开始在 API 层使用它们。
http4s – 流式 HTTP
我们项目中 HTTP 接口的实现基于 http4s (http4s.org)库。http4s 建立在 FS2 和 Cats IO 之上,因此我们与使用 doobie 实现的持久层有很好的交互。使用 http4s,我们可以使用高级 DSL 构建功能性的服务器端服务,也可以在客户端调用 HTTP API。我们将在本章后面使用客户端功能来构建我们的 API 的集成测试。
服务器端由HttpService[F]表示,这本质上只是从Request到F[Response]的映射,在我们的情况下F是 cats 的IO。http4s DSL 通过使用模式匹配帮助构建这样的 RESTful 服务。
这是在实际中的样子。首先我们需要为fs2和IO,以及 http4s DSL 和 circe 添加以下导入,并将它们放入作用域:
import cats.effect.IO
import fs2.Stream
import org.http4s._
import org.http4s.dsl.Http4sDsl
import org.http4s.circe._
import org.http4s.headers.`Content-Type`
import io.circe.generic.auto._
import io.circe.syntax._
在这些导入就绪后,我们可以开始构建我们的服务定义:
class Service(repo: Repository) extends Http4sDsl[IO] { ... }
该服务以数据库存储库作为参数提供。
对于每个 HTTP 动词和 URL 模板,路由被单独定义。我们首先定义服务方法,该方法从请求到响应接收一个部分函数:
val service: HttpService[IO] = HttpService[IO] {
然后我们继续使用简单的文章删除路由:
case DELETE -> Root / "articles" / name if name.nonEmpty =>
val repoResult: IO[Boolean] = repo.deleteArticle(name)
val toResponse: Boolean => IO[Response[IO]] = if (_) NoContent() else NotFound()
val response: IO[Response[IO]] = repoResult.flatMap(toResponse)
response
在这里,我们使用http4s DSL 来分解Request为部分,并对这些部分进行模式匹配。->对象从请求中提取路径,/类允许我们表示请求 URL 的子路径的连接(还有/:,它匹配从应用点开始的 URL 到 URL 的末尾)。模式匹配本身只是一个普通的 Scala case,因此我们可以使用其全部功能。在这种情况下,我们将 URL 的最后一部分映射到name,并有一个保护者来确保只有当name不为空时路径才匹配(因为我们不希望在商店中拥有匿名文章!)。
函数的预期结果是IO[Response[IO]]类型。幸运的是,我们存储库的deleteArticle方法的返回类型是IO[Boolean],因此我们可以在IO内部将返回的布尔值flatMap到响应体中。在这种情况下,我们不想响应体,只想通知调用者操作的成功,这通过相应的响应代码表示:204 No Content和404 Not Found。http4s 提供了一个带有一些冗长类型的良好构造函数:IO[Response[IO]]。在我们的情况下,我们定义一个从Boolean到这种类型的函数,并使用这个函数flatMap存储库调用的结果,这样我们最终得到IO[Response[IO]]作为最终结果,这正是预期返回的类型。
当然,所有这些逻辑都可以以简洁的方式编写。以下是一个创建文章 API 调用的示例:
case POST -> Root / "articles" / name if name.nonEmpty =>
repo.createArticle(name).flatMap { if (_) NoContent() else Conflict() }
这种方法与我们之前用于文章删除的方法完全相同。
我们正在构建的 API 不是一个原则上的 RESTful API。为了使这个例子成为一个有效的二级 API,我们还需要实现一个GET调用,用于检索单个商品的表示。这可以通过向仓库添加相应的方法和在服务中添加一个case来实现。实现留给读者作为练习。
现在我们已经在仓库中创建了一些文章,我们希望能够检索其当前状态。我们可以按以下方式实现:
case GET -> Root / "inventory" =>
val inventory: Stream[IO, Inventory] = repo.getInventory
renderInventory(inventory)
上述模式匹配很简单,调用仓库的getInventory方法也是如此。但它返回的是Stream[IO, Inventory]类型的结果,我们需要将其转换为HttpService[IO]匹配的类型。http4s有一个名为EntityEncoder的概念来处理这个问题。
下面是相应的实现:
private def renderInventory(inventory: Stream[IO, Inventory]): IO[Response[IO]] = {
val json: Stream[IO, String] = inventory.map(_.asJson.noSpaces)
val response: IO[Response[IO]] =
Ok(json, `Content-Type`(MediaType.`application/json`))
response
}
在这里,我们通过将返回的Map[String, Int]转换为 JSON 来准备库存以表示为 HTTP 响应。我们依赖于 circe (github.com/circe/circe)来完成自动转换。接下来,通过Ok状态构造函数和一个隐式的EntityEncoder[IO, String],流被转换为适当的响应类型。我们明确地将响应的内容类型强制设置为application/json,以确保它正确地在响应中表示。
最后,我们希望提供一种修改库存状态的方法,就像我们处理仓库时做的那样。我们将实现两个 API 调用,一个用于补充库存,另一个用于购买。它们的实现方式相似,所以我们只介绍其中一个;另一个可以在GitHub仓库中找到。以下是补充调用的实现:
case req @ POST -> Root / "restock" =>
val newState = for {
purchase <- Stream.eval(req.decodeJson[Restock])
_ <- repo.updateStock(purchase.inventory)
inventory <- repo.getInventory
} yield inventory
renderInventory(newState)
我们需要一个请求来读取其主体,因此我们在模式匹配中将它绑定到req变量。接下来,我们解码请求的 JSON 主体并将其映射到我们的模型。在这里,我们再次依赖于 circe 来完成繁重的工作。updateStock仓库方法返回流,因此我们需要将我们的参数放在相同的上下文中,以便我们能够在for comprehension 中优雅地使用它。我们通过将解码的结果包装在Stream.eval中来完成这个操作。
然后我们调用仓库,并以Inventory的形式提供所需的变化。该方法返回Stream[IO, Either[Throwable, Unit]],所以我们忽略结果(在发生错误的情况下,它将缩短 for comprehension)。最后,我们读取仓库的新状态,并像以前一样将其呈现给调用者。
读写后是已知数据库反模式。我们使用这种方法来说明如何在 for comprehension 中优雅地链式调用流式调用。在实际项目中,可能最好以某种方式编写 SQL 语句,以便在更新后立即返回新状态。
服务层现在已经实现。我们可以将我们的应用程序连接起来,看看它的工作情况。
整合一切
服务器代码除了我们常用的集合外,还需要一些新的导入:
import org.http4s.server.blaze.BlazeBuilder
import scala.concurrent.ExecutionContext.Implicits.global
BlazeBuilder是一个服务器工厂,ExecutionContext将在我们启动服务器时被需要。服务器定义如下:
object Server extends StreamApp[IO] { ... }
StreamApp要求我们实现一个stream方法,其唯一目的是产生副作用,并为这个流提供清理钩子。这是我们的实现:
override def stream(args: List[String],
requestShutdown: IO[Unit]): Stream[IO, ExitCode] = {
val config: IO[Config] = Config.load("application.conf")
new ServerInstance(config).create().flatMap(_.serve)
}
我们只是读取了配置,并将实际服务器的创建委托给了ServerInstance。让我们看看它:
class ServerInstance(config: IO[Config]) {
def create(): Stream[IO, BlazeBuilder[IO]] = {
for {
config <- Stream.eval(config)
transactor <- Stream.eval(DB.transactor(config.database))
_ <- Stream.eval(DB.initialize(transactor))
} yield BlazeBuilder[IO]
.bindHttp(config.server.port, config.server.host)
.mountService(new Service(new Repository(transactor)).service, "/")
}
}
这里我们又看到了相同的方法:我们将config提升到Stream的上下文中,创建一个 transactor,初始化数据库,从 transactor 和 repository 构建仓库,并从 repository 构建服务,最后使用BlazeBuilder工厂挂载服务。
调用方法将执行服务器的serve方法,启动我们迄今为止构建的整个 IO 程序。
在构建这个例子时,我们遵循了一个提供依赖项的模式——我们在构建类实例的时刻将它们作为构造函数参数给出。将依赖项作为构造函数参数传递的方法在 Scala 中被称为基于构造函数的依赖注入。
现在我们的应用程序可以启动并使用了。但我们想通过测试来确保它的行为是正确的。
测试
这个例子相当简单,基本上只是数据库上的一个 HTTP 门面,所以我们不会单独测试组件。相反,我们将使用集成测试来检查整个系统。
为了让 SBT 正确识别我们的集成测试,我们需要在build.sbt中添加适当的配置。请参考 GitHub 上的代码章节(github.com/PacktPublishing/Learn-Scala-Programming),了解如何完成此操作。
在我们的集成测试中,我们将让我们的系统正常运行(但使用测试数据库),并使用 HTTP 客户端调用 API 并检查它将返回的响应。
首先,我们需要准备我们的 HTTP 客户端和服务器:
class ServerSpec extends WordSpec with Matchers with BeforeAndAfterAll {
private lazy val client = Http1Client[IO]().unsafeRunSync()
private lazy val configIO = Config.load("test.conf")
private lazy val config = configIO.unsafeRunSync()
private val server: Option[Http4sServer[IO]] = (for {
builder <- new ServerInstance(configIO).create()
} yield builder.start.unsafeRunSync()).compile.last.unsafeRunSync()
在这里,我们创建了一个客户端,我们将使用它来查询我们的 API,通过实例化http4s库提供的Http1Client。我们还读取了一个测试配置,它覆盖了数据库设置,这样我们就可以自由地修改数据。我们使用的是内存中的 H2 数据库,测试完成后将被销毁,这样我们就不需要在测试后清理状态。然后我们通过重新使用ServerInstance来构建服务器。与生产代码相比,我们使用start方法启动它,该方法返回一个服务器实例。测试后,我们将使用此实例来关闭服务器。
请注意我们如何在多个地方使用unsafeRunSync()来评估IO的内容。对于服务器,我们甚至做了两次,一次用于IO,一次用于Stream[IO, ...]。在测试代码中这样做是可以的,因为它有助于保持测试逻辑简洁。
测试完成后,我们需要关闭客户端和服务器:
override def afterAll(): Unit = {
client.shutdown.unsafeRunSync()
server.foreach(_.shutdown.unsafeRunSync())
}
再次,我们在这里运行 IO,因为我们希望立即发生关闭操作。
现在,让我们看看其中一个测试方法:
"create articles" in {
val eggs = RequestIO)
client.status(eggs).unsafeRunSync() shouldBe Status.NoContent
val chocolate = RequestIO)
client.status(chocolate).unsafeRunSync() shouldBe Status.NoContent
val json = client.expectJson.unsafeRunSync()
json shouldBe json"""{"eggs" : 0,"chocolate" : 0}"""
}
在这里,我们首先使用 http4s 提供的工厂创建一个测试请求。然后我们检查如果使用本节中较早创建的客户端发送此请求,API 是否返回正确的NoContent状态。然后我们使用相同的方法创建第二个文章。
最后,我们使用客户端直接调用 URL,并让它解析 JSON 格式的响应。最后,我们通过将 JSON 响应与 circe 的 JSON 字面量进行比较来检查库存是否处于正确状态。
对于测试其他 API 调用,我们也可以使用 circe JSON 字面量提供请求体。请参阅 GitHub 上放置的章节源代码,以了解如何实现这一点。
完全可以使用其他 HTTP 客户端或甚至命令行工具实现相同的测试逻辑。http4s提供的Http1Client允许有很好的语法和简洁的期望定义。
运行应用程序
运行我们的 API 最简单的方法是在 SBT shell 中执行run命令。本章的项目配置为一个多模块 SBT 项目。因此,run命令必须以模块名称为前缀,以便完全拼写为http4s/run,如下一张截图所示:

我们 API 的不同组件将输出大量信息。应用程序在显示 HTTP 服务器的地址后启动。您可以在下一张截图的底部看到它的样子:

之后,API 应该能够处理 HTTP 请求,例如,在另一个终端窗口中使用 curl 发出的请求,如下截图所示:

由于我们的示例使用的是内存数据库,它将在重启后丢失其状态。
使用 Akka-HTTP 和 Akka Persistence 构建微服务
现在我们已经看到了实现微服务的原则功能方法的工作原理,让我们改变我们的技术栈,并使用 Akka-HTTP 和 Akka Persistence 实现相同的商店。这个例子讨论的流程将与关于功能方法的讨论类似——我们将从查看服务状态持久化的方式以及所需的配置开始。然后我们将解决实际持久化数据和通过 HTTP 服务提供访问的任务。像以前一样,我们将通过测试我们将要提出的实现来结束我们的旅程。
项目结构
在这种情况下,项目结构几乎与之前相同。
我们将有一个负责与 HTTP 客户端交互的 API 层。我们不可避免地会有一些配置和数据库初始化代码,这些代码将以与我们构建先前微服务时类似或相同的方式进行实现。
持久化层将由持久化 actor 表示。这将影响模型定义以及数据库表的结构。
Akka Persistence 介绍了不同的系统状态存储和表示范式。这种方法被称为事件溯源,花一分钟讨论它是有意义的。
事件溯源和 CQRS
事件溯源是关于系统状态如何存储的。通常,系统状态作为一系列相关表持久化到数据库中。状态的变化通过修改、添加或删除表行在数据库中体现。采用这种方法,数据库包含系统的当前状态。
事件溯源提供了一种替代方法。它处理状态更新的方式与函数式编程处理效果的方式非常相似。它不是执行计算,而是描述它,以便稍后执行。计算的描述可以像本书的第二部分所看到的那样组合。同样,状态的变化可以在事件溯源方法中组合,以产生当前状态。本质上,事件溯源对于状态,就像函数式编程对于计算和效果一样。
这种状态变化的描述被称为事件,通常(但不一定!)对应于某些用户操作,称为命令。系统接收命令,验证它们,如果命令在当前系统状态下有意义,则创建相应的事件并将其持久化到事件日志中。然后,事件应用于状态的内存表示,并执行所需的副作用。
当事件溯源的系统重新启动时,事件从日志中读取并逐个应用于初始状态,修改它但不执行副作用。最后,在所有事件应用完毕后,系统的内部状态应该与重启前相同。因此,事件是这种场景下系统状态表示的来源。重构的状态通常只代表整个系统的一个方面,被称为视图。
事件日志仅用于追加事件。因此,它通常被视为只追加存储,并且经常使用除关系数据库之外的其他解决方案。
CQRS 是与事件溯源紧密相关的另一个名称。这是命令查询责任分离(Command Query Responsibility Segregation)的缩写,它反过来又是一种用命令和查询实体(而不是方法调用)实现 命令-查询分离 原理的优雅命名方式。CQS 原则指出,每个方法应该是 命令,它修改状态,或者 查询,它返回状态,并且这些责任不应混合。在事件溯源中,这种分离自然地从 事件 的定义(在 CQS 定义中是命令)和内部状态作为需要单独查询的 视图 的概念中产生。
与传统的数据库突变方法相比,事件溯源有许多优势:
-
仅追加方式存储数据比传统的数据库扩展性更好。
-
事件免费提供审计、可追溯性和在特殊存储中的安全性。
-
不需要使用 ORM。
-
领域模型和事件模型可以以不同的速度发展。
-
可以将系统状态恢复到过去任何特定时刻。
-
事件可以以不同的方式组合,使我们能够构建不同的状态表示。结合先前的优势,它赋予我们以当时事件创建时未知的方式分析过去数据的能力。
当然,也有一些缺点:
-
状态不存在,直到它从事件中重建。根据日志的格式,可能甚至无法在不为这个目的编写特殊代码的情况下分析事件。无论如何,从事件中构建状态表示都需要一些努力。
-
在复杂项目中领域模型的爆炸性增长。实现新的用例总是需要引入新的命令和事件。
-
随着项目的演变,模型的变化。现有用例的变化通常意味着现有事件结构的改变,这需要在代码中完成,因为事件日志是仅追加的。
-
事件的数量可能会迅速增长。在活跃使用的系统中,每天可能会产生数百万事件,这可能会影响构建状态表示所需的时间。快照用于解决这个问题。
配置 Akka Persistence
Akka Persistence 允许我们存储和回放发送给 PersistentActor 的消息,从而实现事件溯源方法。在深入了解演员实现细节之前,让我们看看在项目配置中我们需要做出的安排。
我们将在这个项目中使用 H2 关系型数据库。Akka Persistence 支持许多不同的存储插件,包括用于存储快照的本地文件系统,在我们的情况下,使用与 doobie 相同的数据库来强调架构风格之间的差异似乎是个好主意。
再次,我们使用 Flyway 来创建数据库的结构。不过,表将会有所不同。这是将存储事件的表:
CREATE TABLE IF NOT EXISTS PUBLIC."journal" (
"ordering" BIGINT AUTO_INCREMENT,
"persistence_id" VARCHAR(255) NOT NULL,
"sequence_number" BIGINT NOT NULL,
"deleted" BOOLEAN DEFAULT FALSE,
"tags" VARCHAR(255) DEFAULT NULL,
"message" BYTEA NOT NULL,
PRIMARY KEY("persistence_id", "sequence_number")
);
persistence_id是特定持久化演员的 ID,在整个演员系统中需要是唯一的(我们稍后会看到它是如何映射到代码中的),tags字段包含分配给事件的标签(这使得构建视图变得更容易)。message字段包含序列化形式的事件。序列化机制与存储解耦。Akka 支持不同的风味,包括 Java 序列化、Google Protobuf、Apache Thrift 或 Avro 和 JSON。我们将使用 JSON 格式以保持示例小巧。
快照表甚至更简单:
CREATE TABLE IF NOT EXISTS PUBLIC."snapshot" (
"persistence_id" VARCHAR(255) NOT NULL,
"sequence_number" BIGINT NOT NULL,
"created" BIGINT NOT NULL,
"snapshot" BYTEA NOT NULL,
PRIMARY KEY("persistence_id", "sequence_number")
);
基本上,它只是一个序列化形式的快照,包含时间戳和所属演员的persistence_id。
在迁移文件中的这些表格,我们现在需要将以下依赖项添加到build.sbt中:
"com.typesafe.akka" %% "akka-persistence" % akkaVersion,
"com.github.dnvriend" %% "akka-persistence-jdbc" % akkaPersistenceVersion,
"com.scalapenos" %% "stamina-json" % staminaVersion,
"com.h2database" % "h2" % h2Version,
"org.flywaydb" % "flyway-core" % flywayVersion,
akka-persistence依赖项是显而易见的。akka-persistence-jdbc是 h2 数据库的 JDBC 存储实现。Flyway-core用于设置数据库,就像之前的例子一样。stamina-json允许进行模式迁移——它为我们提供了一种描述如果需要,如何将存储在数据库中旧格式的事件转换为代码中使用的新的格式的方法。
我们还需要在application.conf中为 Akka 持久化添加相当多的配置来配置日志。这个配置相当冗长,所以我们不会在这里完整讨论,但我们会看看其中描述序列化的一部分:
akka.actor {
serializers.serializer = "ch14.EventSerializer"
serialization-bindings {
"stamina.Persistable" = serializer
}
}
在这里,我们为 stamina 配置序列化。让我们看看EventSerializer:
class EventSerializer
extends stamina.StaminaAkkaSerializer(v1createdPersister,
v1deletedPersister,
v1purchasedPersister,
v1restockedPersister,
v1inventoryPersister)
在这里,我们告诉 stamina 使用哪些序列化器。序列化器定义如下:
import stamina.json._
object PersistenceSupport extends JsonSupport {
val v1createdPersister = persisterArticleCreated
val v1deletedPersister = persisterArticleDeleted
val v1purchasedPersister = persisterArticlesPurchased
val v1restockedPersister = persisterArticlesRestocked
val v1inventoryPersister = persisterInventory
}
在PersistenceSupport对象中,我们为我们的事件定义持久化器。我们目前不需要任何迁移,但如果需要,迁移将在这里描述。持久化器需要隐式的RootJsonFormat可用,我们在JsonSupport特质中提供它们:
import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport
import spray.json.{DefaultJsonProtocol, RootJsonFormat}
import DefaultJsonProtocol._
trait JsonSupport extends SprayJsonSupport {
implicit val invJF: RootJsonFormat[Inventory] =
jsonFormat1(Inventory)
implicit val createArticleJF = jsonFormat2(CreateArticle)
implicit val deleteArticleJF = jsonFormat1(DeleteArticle)
implicit val purchaseJF = jsonFormat1(PurchaseArticles)
implicit val restockJF = jsonFormat1(RestockArticles)
implicit val createdJF = jsonFormat2(ArticleCreated)
implicit val deletedJF = jsonFormat1(ArticleDeleted)
implicit val pJF = jsonFormat1(ArticlesPurchased)
implicit val reJF = jsonFormat1(ArticlesRestocked)
}
我们扩展SprayJsonSupport并导入DefaultJsonProtocol._以获取由spray-json预先定义的基本类型的隐式格式。然后我们为所有的命令(这些格式将由 API 层用于反序列化请求体)定义RootJsonFormat,事件(这些将由 API 层用于序列化响应,以及持久化层用于序列化事件),以及库存(这是快照可序列化所必需的)。在这里,我们不依赖于 circe 的自动推导,因此我们单独描述每个 case 类。
现在我们有了模型持久化和格式,但这个模型是什么?它反映了事件源方法!
领域模型
使用事件溯源,我们希望将状态的变化存储为事件。并非与客户端的每次交互都是事件。在我们知道可以遵守之前,我们将其建模为命令。具体来说,在我们的示例中,它被表示为密封特质:
sealed trait Command
sealed trait Query
object Commands {
final case class CreateArticle(name: String, count: Int) extends Command
final case class DeleteArticle(name: String) extends Command
final case class PurchaseArticles(order: Map[String, Int]) extends Command
final case class RestockArticles(stock: Map[String, Int]) extends Command
final case object GetInventory extends Query
}
在 CQRS 的精神下,我们将传入的数据建模为四个命令和一个查询。如果当前状态允许,命令可以转换为事件:
object Events {
final case class ArticleCreated(name: String, count: Int) extends Event
final case class ArticleDeleted(name: String) extends Event
final case class ArticlesPurchased(order: Map[String, Int]) extends Event
final case class ArticlesRestocked(stock: Map[String, Int]) extends Event
}
在我们的简单案例中,命令和事件是对应的,但在实际项目中,这并不总是如此。
我们还有一个表示存储当前状态的表示:
final case class Inventory(state: Map[String, Int]) extends Persistable { ... }
Inventory 继承自 Persistable,这样我们就可以稍后创建快照。我们将保持业务逻辑与与 actor 相关的代码分离。因此,我们的库存应该能够自行处理事件:
def update(event: Event): Inventory = event match {
case ArticleCreated(name, cnt) => create(name, cnt).get
case ArticleDeleted(name) => delete(name).get
case ArticlesPurchased(order) => add(order.mapValues(_ * -1))
case ArticlesRestocked(stock) => add(stock)
}
create 方法将文章添加到商店,并在可能的情况下为其分配一些初始数量。在成功的情况下,它返回新的状态下的库存:
def create(name: String, count: Int): Option[Inventory] =
state.get(name) match {
case None => Some(Inventory(state.updated(name, count)))
case _ => None
}
delete 方法尝试从库存中删除一篇文章:
def delete(name: String): Option[Inventory] =
if (state.contains(name))
Some(Inventory(state.filterKeys(k => !(k == name))))
else None
add 方法将另一库存中的文章数量与当前库存中所有文章的数量相加:
def add(o: Map[String, Int]): Inventory = {
val newState = state.foldLeft(Map.empty[String, Int]) {
case (acc, (k, v)) => acc.updated(k, v + o.getOrElse(k, 0))
}
Inventory(newState)
}
现在库存可以接受事件并返回更新后的状态,但我们仍然需要首先处理命令。命令处理逻辑的一个可能实现可能如下所示:
def canUpdate(cmd: Command): Option[Event] = cmd match {
case CreateArticle(name, cnt) =>
create(name, cnt).map(_ => ArticleCreated(name, cnt))
case DeleteArticle(name) => delete(name).map(_ => ArticleDeleted(name))
case PurchaseArticles(order) =>
val updated = add(order.mapValues(_ * -1))
if (updated.state.forall(_._2 >= 0)) Some(ArticlesPurchased(order)) else None
case RestockArticles(stock) => Some(ArticlesRestocked(stock))
}
canUpdate 方法接受一个命令,并在可以成功应用该命令的情况下返回相应的事件。对于创建和删除文章,我们检查操作将产生有效结果;对于购买,我们检查库存中是否有足够的文章,补货应该总是成功。
我们的库存未同步,因此在并发场景下工作是不安全的。此外,如果某个线程在另一个线程已经调用 canUpdate 但尚未调用 update 时修改库存,我们可能会因为这种竞争条件而得到不正确的状态。但不必担心这一点,因为我们将在 actor 内部使用我们的库存。
持久化 actor
Akka 中的持久 actor 扩展了正常的 Actor 并混合了 PersistentActor。PersistentActor 实现了 receive 方法,但需要我们实现一些其他方法:
class InventoryActor extends Actor with PersistentActor {
private var inventory: Inventory = Inventory(Map.empty)
override def persistenceId: String = InventoryActor.persistenceId
override def receiveRecover: Receive = ???
override def receiveCommand: Receive = ???
}
除了作为状态表示的 inventory 之外,我们还需要定义一个唯一的 persistenceId 和两个方法:receiveRecover 和 receiveCommand。前者在恢复期间被调用,例如在启动时或如果持久 actor 重新启动时,它接收来自日志的所有事件。它预计将修改内部状态,但不会执行任何副作用。后者在正常生命周期期间被调用,它接收所有命令。它应该将有效的命令转换为事件,持久化事件,修改内部状态,并在之后执行副作用代码。
在我们的示例中,receiveRecover 只是将事件处理委托给 inventory:
override def receiveRecover: Receive = {
case SnapshotOffer(_, snapshot: Inventory) => inventory = snapshot
case event: Event => inventory = inventory.update(event)
case RecoveryCompleted => saveSnapshot(inventory)
}
此外,它还处理 SnapshotOffer 的实例,通过从最新的快照中恢复整个库存。如果存在快照,SnapshotOffer 将是演员接收到的第一条消息,并且它将包含最新的快照,因此从它恢复库存是安全的。快照之前的事件记录将不会重放。最后,在收到 RecoveryCompleted 事件后,我们将当前状态保存为快照,以便在下次重启后使用。
receiveCommand 的实现稍微复杂一些:
override def receiveCommand: Receive = {
case GetInventory =>
sender() ! inventory
case cmd: Command =>
inventory.canUpdate(cmd) match {
case None =>
sender() ! None
case Some(event) =>
persistAsync(event) { ev =>
inventory = inventory.update(ev)
sender() ! Some(ev)
}
}
}
我们通过向发送者发送当前状态来处理 GetInventory 查询。库存是一个不可变映射的包装器,因此它是安全的共享。
我们以相同的方式处理所有 Commands,让库存实际执行工作。如果命令无法应用,我们向发送者响应 None。在相反的情况下,我们异步持久化相应的事件,并提供一个在事件持久化后执行的回调。在回调中,我们将事件应用于内部状态,并将新状态发送给发送者。与普通演员不同,在异步块中使用 sender() 是安全的。
到此为止,我们现在有一个在重启后可以恢复其状态的持久演员。是时候让它对 HTTP 客户端可用。
Akka-HTTP
Akka-HTTP 提供了一个很好的 DSL 来描述类似于 doobie 的服务器端 API。但 Akka 的语言流动略有不同。它不像逐个应用规则来匹配请求的模式匹配,更像是一个筛子。它通过提供多个指令来过滤请求,每个指令匹配请求的某些方面。指令是嵌套的,这样每个请求就会越来越深入地进入匹配分支,直到达到处理逻辑。这种指令的组合被称为路由。这是库存路由:
lazy val inventoryRoutes: Route =
path("inventory") {
get {
???
}
} ~
path("purchase") {
post {
entity(as[PurchaseArticles]) { order =>
???
}
}
} ~
path("restock") {
post {
entity(as[RestockArticles]) { stock =>
???
}
}
}
此路由包含三个较小的路由,一个匹配 GET /inventory,另一个 POST /purchase,第三个 POST /restock。第二个和第三个路由还定义了请求实体必须可以解析为 PurchaseArticles 和 RestockArticles,并将解析结果作为参数提供给体。让我们看看这些路由的内部实现。我们知道库存路由应该返回当前状态,所以我们询问库存演员关于这一点:
complete((inventory ? GetInventory).mapTo[Inventory])
complete 方法接受 ToResponseMarshallable,我们依赖于之前定义的 Akka-HTTP 和 JSON 序列化器来执行从我们在这里应用 ask 模式得到的 Future[Inventory] 的隐式转换。
目前 inventory 被提供一个抽象的字段。这是它在 Routes 定义中的样子:
trait Routes extends JsonSupport {
implicit def system: ActorSystem
def inventory: ActorRef
def config: Config
implicit lazy val timeout: Timeout = config.timeout
implicit lazy val ec: ExecutionContext = system.dispatcher
我们定义了一个抽象的 config,然后使用它来为 ask 操作定义一个 implicit timeout。我们还定义了 ExecutionContext,以便能够在其他路由中映射 Future。
其他两条路径的实现方式类似。这是购买路径:
val response: Future[Option[ArticlesPurchased]] =
(inventory ? order).mapTo[Option[ArticlesPurchased]]
onSuccess(response) {
case None => complete(StatusCodes.Conflict)
case Some(event) => complete(event)
逻辑几乎相同,只是与不能满足要求的情况相关的差异。在这种情况下,我们返回 409 冲突 错误代码。
补货路径甚至更简单,因为它总是成功的:
val response: Future[Option[ArticlesRestocked]] =
(inventory ? stock).mapTo[Option[ArticlesRestocked]]
complete(response)
articleRoute 的定义用于文章的创建和删除,与之前的定义非常相似,可以在 GitHub 上找到,所以这里我们将省略它。
路由通过 ~ 组合在一起,就像我们之前内联做的那样:
lazy val routes: Route = articlesRoutes ~ inventoryRoutes
将所有内容整合在一起
路由实现完成后,我们现在可以继续进行服务器定义:
object Server extends App with Routes with JsonSupport {
val config = Config.load()
implicit val system: ActorSystem = ActorSystem("ch14")
implicit val materializer: ActorMaterializer = ActorMaterializer()
DB.initialize(config.database)
lazy val inventory: ActorRef = system.actorOf(InventoryActor.props, InventoryActor.persistenceId)
Http().bindAndHandle(routes, config.server.host, config.server.port)
Await.result(system.whenTerminated, Duration.Inf)
}
我们将 Routes 和 JsonSupport 特性混合在一起,并定义抽象字段。演员系统是实例化 materializer 所必需的,而 materializer 是驱动 Akka-HTTP 的机器。然后我们初始化数据库,实例化我们的持久演员(它开始从日志接收事件并恢复其状态),绑定并启动服务器,等待演员系统的终止。
通过定义抽象成员然后混合特性来注入依赖的方式被称为基于特性的 DI 或薄蛋糕模式。通常,在简单的情况下,我们更愿意选择基于构造函数的 DI,就像在 http4s 示例中那样。
与 http4s 的实现相比,这个服务器是急切的。每条语句都在定义的瞬间执行(尊重惰性)。
现在我们已经完成了我们商店的另一个版本,并且可以对其进行测试。
测试
当然,如果没有提供用于测试 HTTP 路由的优雅 DSL,Akka 就不会是 Akka。这个 DSL 允许我们以无需启动真实服务器的方式测试路由。可以提供业务逻辑的模拟实现,以独立测试路由。在我们的案例中,逻辑非常简单,实际上测试整个应用程序是有意义的,就像我们在 http4s 案例中所做的那样。
规范的定义不应该令人惊讶:
class RoutesSpec extends WordSpec with Matchers with ScalaFutures with ScalatestRouteTest with Routes {
override lazy val config: Config = Config.load()
DB.initialize(config.database)
override lazy val inventory: ActorRef = system.actorOf(InventoryActor.props, "inventory")
...
}
好消息是 ScalatestRouteTest 已经为演员系统提供了定义,以及 materializer,所以我们不需要在测试之前初始化它们,在测试之后关闭。Routes 与我们之前定义的相同,现在即将进行测试。我们这里仍然有 config 和 inventory 的抽象定义,所以为它们提供实现。
这就是我们测试路由的方法:
"Routes" should { "be able to add article (POST /articles/eggs)" in {
val request = Post("/articles/eggs")
request ~> routes ~> check {
status shouldBe StatusCodes.Created
contentType shouldBe ContentTypes.`application/json`
entityAs[String] shouldBe """{"name":"eggs","count":0}"""
}
}}
首先,我们定义要检查路由的 request。然后我们使用 route 将其转换为响应,然后响应再转换为 check。在 check 的主体中,我们可以以简单的方式引用响应的属性。
在 request ~> routes ~> check 中的 routes 指的是在 Routes 特性中定义的字段。
类似地,可以创建一个带有主体的请求,并使用它来测试期望此类请求的路由:
"be able to restock articles (POST /restock)" in {
val restock = RestockArticles(Map("eggs" -> 10, "chocolate" -> 20))
val entity = Marshal(restock).to[MessageEntity].futureValue
val request = Post("/restock").withEntity(entity)
request ~> routes ~> check {
status shouldBe StatusCodes.OK
contentType shouldBe ContentTypes.`application/json`
entityAs[String] shouldBe """{"stock":{"eggs":10,"chocolate":20}}"""
}
}
在这里,我们以与路由相同的方式将 Marshal 重置库存案例类转换为 Entity。.futureValue 来自 ScalaTest 的 ScalaFutures 辅助工具。其余的代码片段与上一个示例非常相似。
运行应用程序
要运行 Akka-HTTP API,我们必须使用与 http4s 版本相同的方法。模块的名称将是,嗯,akkaHttp,但原则是相同的。下一个截图显示了在 SBT 壳中输入 akkaHttp/run 后控制台中的输出:

应用程序输出几行后等待接收请求。现在可以像处理 http4s 版本一样安全地玩它:

一个微妙但重要的区别是,Akka 版本将数据库持久化到文件系统,并在重启之间保留状态,如前一个屏幕上的第一个请求所示。
摘要
在本章中,我们简要讨论了基于微服务方法的优缺点。
我们构建了两个具有相似功能但技术栈不同的简单示例。
第一个项目是使用纯函数式方法构建的,通过在 IO monad 和函数式流中包装效果。这使我们能够将系统描述为一个仅在“世界末日”时启动的计算。在这种情况下,我们通过将系统状态映射到数据库表并对其做出响应的更改来使用 ORM 方法。最后,我们展示了如何通过构建集成测试来使用 http4s 客户端测试整个系统。
第二个项目的基座是 "官方" Lightbend 栈。我们研究了 Akka-HTTP 和 Akka Persistence 如何协同工作。我们证明了事件源方法允许我们通过重新组合持久事件来在内存中重建状态,从而帮助我们避免编写任何 SQL 语句。我们还研究了如何使用 Akka-HTTP 测试套件来测试路由,而无需启动真实的 HTTP 服务器。
问题
-
什么是数据库迁移?
-
描述在库存不足的情况下,如何完全取消订单的替代方法。
-
描述在定义路由方面,http4s 和 Akka-HTTP 的概念差异。
-
列出一个原因,说明事件源数据存储可以比传统的数据库扩展得更好。
-
使用 http4s 和 doobie 实现一个
GET /articles/:name调用。 -
使用 Akka-HTTP 和 Akka Persistence 实现一个
GET /articles/:name调用。
进一步阅读
-
Vinicius Feitosa Pacheco,*微服务模式和最佳实践[探索您需要发现微服务世界的概念和工具,包括各种设计模式。
-
Jatin Puri, Selvam Palanimalai,*Scala 微服务:设计、构建、优雅地使用 Scala 运行微服务。
-
Héctor Veiga Ortiz, Piyush Mishra,《Akka 烹饪书:学习如何使用 Akka 框架在 Scala 中构建有效的应用程序》
-
Rambabu Posa,《Scala 反应式编程:在 Scala 中构建容错、健壮和分布式应用程序》
-
Christian Baxter,《精通 Akka:掌握使用 Akka 创建可扩展、并发和反应式应用程序的技艺》
第十五章:项目 2 - 使用 Lagom 构建微服务
本书最后一部分将详细探讨 Lagom 框架的细节,通过讨论其哲学、Lagom 应用程序的构建方式以及可用的 API 以及如何使用它们。
在本章中,我们将再次构建我们的面包店项目,这次将其构建为多个微服务。
阅读本章后,你将能够做到以下事项:
-
理解使用 Lagom 的优势
-
设置 Lagom 并使用它来创建项目
-
按照框架要求构建应用程序结构
-
高效使用提供的 API
-
单元测试 Lagom 服务
技术要求
在我们开始之前,请确保你已经安装了以下内容:
-
JDK 8+
-
SBT 1.2+
本章的代码可在 GitHub 上找到:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter15。
为什么选择 Lagom?
在上一章中,我们讨论了基于微服务方法的利弊。我们命名了微服务的主要架构属性,如自治性、隔离性和数据所有权。我们还指出,与传统单体方法相比,微服务减少了单个服务的复杂性,但整个系统的复杂性并没有消失。从某种意义上说,它只是从单个微服务的内部移动到了它们之间的空间。我们研究了将商店作为 RESTful 微服务的实现,并承认我们将通过专注于单个服务来避免这种额外的复杂性。
在我们处理基于 Akka 的解决方案时,我们还选择了适当的数据库来存储事件,并定义和实施迁移以拥有适当的数据库模式。配置机制的选取由 Akka 预先确定,但我们仍然需要手动阅读和验证配置。我们还需要决定如何在构建可运行的应用程序时传递依赖,并正确实现这一传递。
Lagom 框架建立在一些现有技术之上,并利用“约定优于配置”的方法来减轻这些重复机械任务的压力,并为微服务系统提供一些特定的附加功能。它是通过为项目提供一种“模板”来实现的。
预配置的功能包括以下内容:
-
使用事件溯源作为分布式持久化的机制。推荐的数据库是 Apache Cassandra,因为它具有卓越的可伸缩性和对 CQRS 原则的读侧的自然支持。
-
通过利用 Akka Streams 作为实现和 Apache Kafka 作为代理的消息传递风格,支持异步通信。
-
对不同通信协议提供透明支持,允许你将复杂的 API 调用抽象为简单的函数调用。
-
表达式服务描述 DSL,允许您以灵活和简洁的方式定义 API。
-
使用 Akka Cluster 实现动态可伸缩性。
-
选择依赖注入框架,以在编译时或运行时连接应用程序。
-
开发模式,具有热代码重载功能,以及能够通过单个命令启动所有服务和所需的基础设施组件,包括特殊开发服务注册表和服务网关。
-
基础设施和预配置日志的默认配置。
让我们看看这些功能将如何帮助我们重新实现我们的面包店项目。
项目概述和设置
我们已经在第十一章,《Akka 和 Actor 模型简介》到第十三章,《Akka Streams 基础》中,使用不同的技术实现了我们的面包店项目三次。让我们回顾一下,对于不熟悉本书第三部分的读者来说,这是关于什么的。
面包店项目
面包店包含一些员工,他们共同努力制作美味的饼干。他们的通信结构在以下图中表示:

每位员工都在特定领域有专长:
-
经理通过将每个参与者的工作结果传递给流程中的下一步来驱动流程。他们还创建了初始购物清单。
-
男孩,如果得到购物清单,将跑到杂货店并带回食材。
-
厨师,如果得到食材,将制作面团。他们通过创建几个容量有限的混合器来这样做,以便可以并行处理更多的食材。
-
厨师,如果得到面团,将制作一些生饼干。
-
面包师,如果得到生饼干,将使用烤箱烘烤一段时间,从而制作出美味的饼干。
这次,我们将每个工作者建模为一个微服务。混合器和烤箱将成为所属服务的实现细节。
项目设置
不言而喻,我们将使用 Lagom 框架来实现我们的微服务。Lagom 支持 Maven 和 SBT 作为构建工具,但 SBT 提供了更好的用户体验,因此我们将忽略 Maven,因为它对 Scala 项目来说不太相关。
此外,在本节中,我们将从头开始创建一个 Lagom 项目。我们可以通过使用 Giter8 创建一个示例项目,然后根据需要对其进行修改和扩展,采用一种稍微不同的方法。命令与我们提到的第十一章,《Akka 和 Actor 模型简介》:sbt new lagom/lagom-scala.g8相似。
与之前一样,我们的 SBT 设置将包含描述项目不同方面的多个文件。以下假设我们已创建项目文件夹并在终端中导航到它:
slasch@void:~$ mkdir bakery
slasch@void:~$ cd bakery
slasch@void:~$ echo 'addSbtPlugin("com.lightbend.lagom" % "lagom-sbt-plugin" % "1.4.7")' > plugins.sbt
slasch@void:~$ echo 'sbt.version=1.1.5' > build.properties
slasch@void:~$ cd ..
slasch@void:~$ echo '-J-Xms1024M
> -J-Xmx4096M
> -J-Xss2M
> -J-XX:MaxMetaspaceSize=1024M' > .sbtopts
在这里,我们定义了我们的项目需要 Lagom SBT 插件,要使用的 SBT 版本,以及.sbtopts文件中的几个 SBT 选项。前两行定义了 SBT 被允许消耗的初始和最大内存量。我们将启动相当多的微服务和支持基础设施组件,因此需要足够的内存。-Xss参数定义了每个线程的栈大小为2M。在 Scala 项目中,这通常很有用,可以防止非尾递归函数过早地溢出栈。最后一个参数-XX:MaxMetaspaceSize定义了用于存储类元数据(从 JVM 8 开始)的元空间的大小。由于 Lagom 的热重载,我们在开发过程中将创建和加载许多类,因此我们需要一个较大的元空间。
build.sbt 将包含多个子模块,因此使用文本编辑器创建它会更简单。这就是(部分)最终结果的样子:
organization in ThisBuild := "packt"
version in ThisBuild := "1.0-SNAPSHOT"
scalaVersion in ThisBuild := "2.12.6"
val macwire = "com.softwaremill.macwire" %% "macros" % "2.3.0" % Provided
val scalaTest = "org.scalatest" %% "scalatest" % "3.0.5" % Test
val defaultDependencies = Seq(lagomScaladslTestKit, macwire, scalaTest)
lazy val `shared-model` = (project in file("shared-model"))
.settings(libraryDependencies += lagomScaladslApi)
lazy val bakery = (project in file("."))
.aggregate(
`boy-api`, `boy-impl`,
`chef-api`, `chef-impl`,
`cook-api`, `cook-impl`,
`baker-api`, `baker-impl`,
`manager-api`, `manager-impl`)
lazy val `boy-api` = (project in file("boy-api"))
.settings(libraryDependencies += lagomScaladslApi)
// other APIs defined the same way
lazy val `boy-impl` = (project in file("boy-impl"))
.enablePlugins(LagomScala)
.settings(libraryDependencies ++= defaultDependencies)
.dependsOn(`boy-api`)
// other implementations defined the same way
lazy val `chef-impl` = (project in file("chef-impl"))
.enablePlugins(LagomScala)
.settings(
libraryDependencies ++= Seq(
lagomScaladslPersistenceCassandra,
lagomScaladslKafkaBroker,
lagomScaladslTestKit,
lagomScaladslPubSub,
macwire
)
)
.settings(lagomForkedTestSettings: _*)
.dependsOn(`chef-api`)
lazy val `manager-impl` = (project in file("manager-impl"))
.enablePlugins(LagomScala)
.settings(libraryDependencies ++= defaultDependencies)
.dependsOn(`manager-api`, `boy-api`, `chef-api`, `cook-api`, `baker-api`)
我们已经删除了重复的定义,请参阅 GitHub 仓库(github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Chapter15)以获取完整的源代码。在这里,我们定义了项目的一般属性、共享模型和五个微服务。
每个微服务由两个模块组成,一个 API 定义和一个实现。在我们的案例中,大多数 API 定义只需要一个lagomScaladslApi依赖项,实现则只需要在主范围内使用macwire。我们已定义了defaultDependencies,包括测试范围,以保持进一步的定义简洁。
对于chef-impl,我们还包括了三个其他编译时依赖项:
-
lagomScaladslPersistenceCassandra -
lagomScaladslPubSub -
lagomScaladslKafkaBroker
大厨将花一些时间来搅拌面团,因此我们希望通过消息代理与 Manager 进行通信,以解耦两者。
另一个偏差是manager-impl的定义。Manager 将与其他每个服务进行通信,因此它需要知道其他工作者的 API 定义。我们还在文件系统中为每个定义的微服务创建了一个文件夹。这就是我们最终具有多个模块的文件结构的样子:
slasch@void:~/ch15 [ch15-lagom]$ tree --dirsfirst
.
├── baker-api
├── baker-impl
├── boy-api
├── boy-impl
├── chef-api
├── chef-impl
├── cook-api
├── cook-impl
├── manager-api
├── manager-impl
├── project
│ ├── build.properties
│ └── plugins.sbt
└── build.sbt
随着我们继续实施,我们将为每个模块定义 SBT 所需的项目结构:
slasch@void:~/ch15/cook-api [ch15-lagom]$ tree
.
└── src
├── main
│ ├── resources
│ └── scala
└── test
├── resources
└── scala
通过拥有这种结构,我们已经完成了项目基础设施的准备,可以继续实现服务。
下一个图表概述了模块之间的通信流程:

为了简化我们的示例,我们将违反每个微服务应该拥有其模型定义的规则。我们将定义一个包含将要构建的服务使用的所有案例类定义的模块。我们将把这个模块作为依赖项添加到我们之前定义的每个其他模块中。
该模块本身将包含我们之前章节中已经了解到的定义:
package ch15
object model {
final case class ShoppingList(eggs: Int, flour: Int, sugar: Int, chocolate: Int)
final case class Groceries(eggs: Int, flour: Int, sugar: Int, chocolate: Int)
final case class Dough(weight: Int)
final case class RawCookies(count: Int)
final case class ReadyCookies(count: Int)
}
我们的对象将在服务之间以序列化形式发送。这意味着我们还需要为它们定义序列化规则。我们将通过依赖 Play 的宏来实现这一点,类似于我们在http4s示例项目中使用的 Circe:
import play.api.libs.json._
implicit val dough: Format[Dough] = Json.format
implicit val rawCookies: Format[RawCookies] = Json.format
implicit val readyCookies: Format[ReadyCookies] = Json.format
implicit val groceries: Format[Groceries] = Json.format
implicit val shoppingList: Format[ShoppingList] = Json.format
这些格式都进入同一个object model实例,沿着案例类。
Lagom 的 API
如前所述,我们将从一个最简单的工人实现开始,它只执行基本的数学运算,以便使用Cook,将Dough转换为RawCookies。
服务 API
为了将我们的 Cook 定义为服务,我们需要实现一个特殊接口,称为服务描述符。服务描述符定义了 Lagom 服务的两个方面:
-
服务签名:如何调用服务及其返回类型
-
服务元数据:服务调用如何映射到传输层,例如 REST 调用
服务描述符扩展了 Lagom 的Service特质,在其最简单形式中只需要重写descriptor方法。这就是我们在Cook定义中看到的样子,我们将其放入cook-api模块:
import com.lightbend.lagom.scaladsl.api._
import ch15.model._
trait CookService extends Service {
def cook: ServiceCall[Dough, RawCookies]
override def descriptor: Descriptor = {
import Service._
named("CookService").withCalls(call(cook))
}
}
在这里,我们定义了一个描述符,它将服务的单个调用(cook方法)连接到服务标识符"CookService",这在稍后的路由中将是必需的。对于调用,我们使用最简单的标识符,它只取方法的名字。配置将导致调用映射到/cook REST URL。
调用本身被定义为ServiceCall[Dough, RawCookies]类型。让我们更详细地看看ServiceCall。在 Lagom 的源代码中的定义如下:
trait ServiceCall[Request, Response] {
def invoke(request: Request): Future[Response]
}
ServiceCall由请求和响应类型指定,并且可以在客户端发出请求时异步调用,产生响应。
Lagom 中的请求和响应类型可以是严格的或流式的。有四种可能的组合,从两边都是严格的到两边都是流式的。严格意味着请求或响应在方法边界处完全缓冲在内存中。严格请求和响应的组合导致调用的同步语义。流式请求或响应是Source类型,这是我们熟悉的,在第十三章,Akka Streams 基础中我们探讨了 Akka 流。对于流式调用,Lagom 将尽力选择适当的语义。通常,这将是通过 WebSockets,我们将在本章后面看到它是如何工作的。
我们的Cook实例非常快,因此以同步方式定义服务是合适的。
Cook的实现位于另一个模块cook-impl中。这种分离对于微服务能够引用彼此的定义而没有了解实现细节是至关重要的。
实现稍微复杂一些,但并不是因为服务定义本身。代码现在应该非常熟悉了:
package ch15
import ch15.model._
import com.lightbend.lagom.scaladsl.api._
import scala.concurrent.Future
class CookServiceImpl extends CookService {
override def cook = ServiceCall { dough =>
Future.successful(RawCookies(makeCookies(dough.weight)))
}
private val cookieWeight = 60
private def makeCookies(weight: Int): Int = weight / cookieWeight
}
这里唯一的新部分是对服务调用包装器的定义。这是通过使用在ServiceCall伴生对象中定义的构造函数来完成的:
def applyRequest, Response: ServiceCall[Request, Response]
我们提供了一个函数,它将Dough(请求)转换为Future[RawCookies](响应),并且构造函数从它构建一个合适的ServiceCall。
之前提到的复杂性与我们还需要将我们的服务连接起来并启动的事实有关。对于那些阅读了第十四章,项目 1 - 使用 Scala 构建微服务的人来说,这种方法将非常类似于我们在那里查看的两种方法的组合:混合特性和为抽象成员提供具体实现,以及将依赖项作为构造函数参数传递。但这次,我们将得到 Lagom 的帮助来完成这项任务。首先,我们定义LagomApplication:
abstract class CookApplication(context: LagomApplicationContext)
extends LagomApplication(context) {
override lazy val lagomServer: LagomServer = serverForCookService
}
应用程序扩展了LagomApplication并需要LagomApplicationContext,这只是一个通过构造函数传递的。毫无疑问,您已经认识到了我们用来连接Akka-HTTP示例组件的薄饼模式。lagomServer是一个重写的方法,Lagom 用它来为服务调用提供正确的连接。这里发生的另一个连接是使用 Macwire 将CookServiceImpl绑定到CookService。
Macwire (github.com/adamw/macwire) 是一个基于构造函数的依赖注入框架。它是通过生成带有作用域内找到的适当参数的类构造函数调用来实现的。从某种意义上说,它以 Circe 或 Play 提供对 JSON 结构正确映射相同的方式,在幕后提供正确的构造函数调用。这对于大型项目来说非常有用。
现在我们可以将应用程序用于应用程序加载器,它在开发或生产环境中启动服务的实际工作:
class CookLoader extends LagomApplicationLoader {
override def load(context: LagomApplicationContext) =
new CookApplication(context) with AhcWSComponents {
override def serviceLocator: ServiceLocator = NoServiceLocator
}
override def loadDevMode(context: LagomApplicationContext) =
new CookApplication(context) with AhcWSComponents with LagomDevModeComponents
}
CookLoader可以根据需要由 Lagom 启动。它覆盖了相应环境的两个load方法。请注意我们如何通过AhcWSComponents扩展了CookApplication。后者是必需的,以便提供wsClient,而wsClient反过来又是我们定义的CookApplication基类所需的。对于开发模式,我们还混合了LagomDevModeComponents,这为我们提供了一个开发模式服务定位器。
现在我们需要通过在众所周知的application.conf中提供相应的 play 设置来配置应用程序加载器:
play.application.loader = ch15.CookLoader
就这样——现在我们已准备好启动我们的应用程序。在 SBT 控制台中,使用 Lagom 的 runAll 命令是最简单的方法。它将尝试启动我们迄今为止定义的所有服务以及底层基础设施的组件——开发模式服务定位器、Cassandra 数据库和 Kafka 消息代理:
sbt:bakery> runAll
[info] Starting Kafka
[info] Starting Cassandra
[info] Cassandra server running at 127.0.0.1:4000
[info] Service locator is running at http://localhost:9008
[info] Service gateway is running at http://localhost:9000
[info] Service cook-impl listening for HTTP on localhost:57733
[info] Service baker-impl listening for HTTP on localhost:50764
[info] Service manager-impl listening for HTTP on localhost:63552
[info] Service chef-impl listening for HTTP on localhost:56263
[info] Service boy-impl listening for HTTP on localhost:64127
日志证明,日志记录正在与其他基础设施组件一起工作。
在这个阶段,日志将包含大量堆栈跟踪(此处未显示),因为除了 boy-impl 模块之外的所有模块都缺少加载器配置。我们将在本章中修复这个问题,因为我们将会一个接一个地实现服务。
我们还可以看到我们的服务正在端口 57733 上运行,并且可以尝试与其通信:
slasch@void:~$ curl -X POST http://localhost:57733/cook -d '{ "weight": 100 }'
{"count":1}
恭喜,我们刚刚与第一个 Lagom 微服务进行了通信!
我们直接与服务通信,而没有使用服务注册表和服务定位器。将端口号放入代码列表作为参考是安全的,因为尽管它们看起来是随机的,但 Lagom 以确定性的方式(基本上是通过项目名称的哈希值)将端口号分配给服务。因此,服务在任何环境中都会被分配相同的端口号(考虑到端口冲突)。
现在,我们可以继续实现 Boy 服务,它在功能上同样简单。它预计将接收到的购物清单转发到外部服务,并将收到的杂货转发给初始调用者。
服务的定义应该看起来很熟悉,只是我们正在使用 namedCall 方法将 shop 调用映射到 go-shopping 名称,以便有一个更友好的 URL:
trait BoyService extends Service {
def shop: ServiceCall[ShoppingList, Groceries]
override def descriptor: Descriptor =
named("BoyService").withCalls(namedCall("go-shopping", shop))
}
实现比 Cook 服务要复杂一些,因为 Boy 服务需要调用外部 HTTP 服务来下订单。以下模板不应引起任何疑问:
class BoyServiceImpl extends BoyService {
override def shop = ServiceCall(callExternalApi)
private val callExternalApi: ShoppingList => Future[Groceries] = ???
}
那我们如何调用外部 API 呢?当然,我们可以使用 HTTP 客户端库,并以与之前相同的方式发出 HTTP 请求,获取 HTTP 响应,并处理序列化和反序列化。但这样做会降低我们解决方案的抽象级别,并将实现硬编码到外部服务的位置。
我们将采取以下措施。首先,我们将通过将服务的 URL 添加到 build.sbt 中,通过服务定位器注册我们外部运行的服务:
lagomUnmanagedServices in ThisBuild := Map("GroceryShop" -> "http://localhost:8080")
然后,我们将定义一个用于杂货店的 API,就像我们即将实现它一样:
trait ShopService extends Service {
def order: ServiceCall[Order, Purchase]
override def descriptor: Descriptor = {
named("GroceryShop").withCalls(restCall(Method.POST, "/purchase", order))
}
}
在这里,我们为与我们在服务定位器中注册的相同名称的服务指定了一个服务描述符。API 调用通过restCall描述符注册,以确保 HTTP 方法和路径正确映射到现有服务。我们还需要将ShoppingList和Groceries包装和展开为预期的Order和Purchase,就像现有的商店服务一样。幸运的是,我们 case 类的 JSON 表示与Map[String, Int]相同,因此我们可以安全地重用现有的模型以及序列化器,并在其上添加包装器:
object ShopService {
final case class Order(order: ShoppingList)
final case class Purchase(order: Groceries)
implicit val purchase: Format[Purchase] = Json.format
implicit val order: Format[Order] = Json.format
}
我们不需要为ShopService提供实现;我们只是希望 Lagom 应用所有现有机制来表示一个现有的REST服务,就像它是用 Lagom 制作的。
现在商店服务已经准备好与Boy一起使用:
class BoyServiceImpl(shopService: ShopService)
(implicit ec: ExecutionContext) extends BoyService {
override def shop: ServiceCall[ShoppingList, Groceries] = ServiceCall(callExtApi)
private val callExtApi: ShoppingList => Future[Groceries] = list =>
shopService.order.invoke(Order(list)).map(_.order).recover {
case _ => Groceries(0, 0, 0, 0)
}
}
注意,我们提供了shopService客户端和一个执行上下文。后者将用于转换我们从服务调用中获得的未来结果。callExtApi函数展示了如何实现:我们从ShopService定义中引用order方法,它返回ServiceCall,我们愉快地用从购物列表中创建的Order调用它。结果是Future[Purchase],所以我们从中提取一个订单。最后,我们定义,如果外部服务发生任何错误,例如,服务不可用或库存不足无法完成订单,Boy应该空手而归。
现在Boy能够与我们第十四章中构建的ShopService进行通信,项目 1 - 使用 Scala 构建微服务,使用Akka-HTTP。
为了使本章的后续示例正常工作,商店服务必须正在运行,并且必须有足够的库存。
我们的Boy和Cook服务是无状态的。Cook服务只是立即返回结果,因此在其中保留任何状态都没有意义。Boy很简单,如果发生任何意外情况,它只是回来获取指令。但Chef和Baker不同,因为它们应该代表需要花费一些时间的过程。因此,我们不能以同步方式实现它们。
在这个意义上,面包师具有m:n语义,即它可以对单个传入消息响应零个、一个或多个响应消息。让我们使用 Lagom 定义异步服务的能力来实现它。这将允许我们重用我们在第十三章中构建的Baker的流定义,Akka Streams 基础。
我们首先需要定义服务,就像我们之前做的那样,但这次使用异步语义:
import akka.NotUsed
import akka.stream.scaladsl.Source
trait BakerService extends Service {
def bake: ServiceCall[Source[RawCookies, NotUsed],
Source[ReadyCookies, NotUsed]]
override def descriptor: Descriptor = named("BakerService").withCalls(call(bake))
}
在这里,我们定义 BakerService 具有请求类型为 Source[RawCookies, NotUsed] 和响应类型为 Future[Source[ReadyCookies, NotUsed]]。这应该允许我们在 RawCookies 可用时直接写入,并在烘焙后返回 ReadyCookies。
实现很简单,因为它实际上是封装了来自 第十三章 的流程,Akka Streams 基础:
import play.api.Logger
class BakerServiceImpl extends BakerService {
private val logger = Logger("Baker")
override def bake: ServiceCall[Source[RawCookies, NotUsed],
Source[ReadyCookies, NotUsed]] =
ServiceCall { dough =>
logger.info(s"Baking: $dough")
Future.successful(dough.via(bakerFlow))
}
private val bakerFlow: Flow[RawCookies, ReadyCookies, NotUsed] =
Baker.bakeFlow.join(Oven.bakeFlow)
}
我们重用了 Baker 和 Oven 流的定义,并将组合流作为调用结果返回。在这个片段中,我们还展示了如何使用来自底层 Play 框架的 Logger。
持久化 API
在我们的场景中,Chef 完成混合过程需要一些时间。正因为如此,我们希望将正在进行的工作存储起来,以便在服务重启时不会丢失,并且恢复后可以从中断的地方继续进行。
我们将使用框架提供的持久化功能来实现这一点。在 Lagom 中,推荐通过利用事件溯源方法来持久化数据,我们已经在 第十四章 的示例项目中使用了这种方法,项目 1 - 使用 Scala 构建微服务。Lagom 自动使用 Cassandra 创建数据模式,并为开发目的提供了一个 Cassandra 实例。因此,我们可以直接开始定义数据模型。与上一章一样,我们需要提供一组命令和事件,并有一个状态的内部表示。以下几段代码展示了表示这些部分的一种可能方式。由于这个模型只是 Chef 的实现细节,它被放入了 chef-impl 模块。
首先,我们需要在作用域内有一系列导入:
import ch15.model._
import java.util.UUID
import akka.Done
import com.lightbend.lagom.scaladsl.persistence._
import PersistentEntity.ReplyType
import com.lightbend.lagom.scaladsl.playjson.JsonSerializer
import play.api.libs.json._
拥有这些,我们可以定义我们的命令:
sealed trait ChefCommand
final case class MixCommand(groceries: Groceries) extends ChefCommand with ReplyType[Done]
final case class DoneCommand(id: UUID) extends ChefCommand with ReplyType[Done]
MixCommand 代表对混合某些食品的请求。Lagom 中的命令定义了预期的响应类型,我们使用 Akka 的 Done 作为响应。这样做的原因是,我们总是会接受 MixCommand(因为没有不接受的理由),但在命令被接受的那一刻,无法预测它将产生什么效果。
DoneCommand 代表从“混合进行中”到“混合完成”的状态转换。它将是 Chef 向自身发送的内部命令。技术上我们在这里不需要响应类型,但我们必须再次使用 Done 以使编译器满意。id 代表混合作业的唯一标识符。它从哪里来?它是在我们从命令创建事件的那一刻生成的:
sealed trait ChefEvent
final case class Mixing(id: UUID, groceries: Groceries) extends ChefEvent
final case class MixingDone(id: UUID, dough: Dough) extends
ChefEvent with AggregateEvent[MixingDone] {
override def aggregateTag: AggregateEventTag[MixingDone] = ChefModel.EventTag
}
Mixing事件是在响应MixCommand和MixingDone事件时创建的——响应DoneCommand。这两个事件通过id属性相互关联。在恢复时间,具有相同id的两个事件将相互抵消:两个事件的存在意味着在以前已经启动并完成了混合工作。相比之下,如果只有一个事件,我们可以得出结论,该工作未完成。在恢复后存在不平衡的Mixing事件意味着我们需要重新启动这些事件的混合过程。不平衡的MixingDone事件只能意味着编程错误。
为了提供这种功能,我们将状态定义为以下内容:
sealed trait ChefState {
def batches: List[Mixing]
}
final case class MixingState(batches: List[Mixing]) extends ChefState
在讨论完模型定义的最后部分之后,我们将稍后查看它在服务实现中的使用方式:
object ChefModel {
import play.api.libs.json._
implicit val mixingFormat = Json.format[Mixing]
val serializers = List(
JsonSerializer(mixingFormat),
JsonSerializer(Json.format[MixingDone]),
JsonSerializer(Json.format[MixingState]))
val EventTag: AggregateEventTag[MixingDone] = AggregateEventTagMixingDone
}
在这里,我们以与上一章相同的方式为我们的事件和命令提供序列化器。Lagom 推荐使用 JSON 作为序列化格式,因此我们正在使用我们之前已经用于共享模型定义的相同方法。
我们还定义了EventTag,我们将需要实现事件日志的读取端,以便通知Manager关于完成的混合工作。
我们需要的最后一部分配置是定义 Cassandra 的键空间用于Chef。这通常在application.conf中完成:
user.cassandra.keyspace = chefprogress
cassandra-journal.keyspace = ${user.cassandra.keyspace}
cassandra-snapshot-store.keyspace = ${user.cassandra.keyspace}
lagom.persistence.read-side.cassandra.keyspace =
${user.cassandra.keyspace}
服务的定义反映了请求端通信是同步的,而响应端基于消息的事实:
trait ChefService extends Service {
def mix: ServiceCall[Groceries, Done]
def resultsTopic: Topic[Dough]
override def descriptor: Descriptor = {
named("ChefService")
.withCalls(call(mix))
.withTopics(topic(ChefService.ResultsTopic, resultsTopic))
.withAutoAcl(true)
}
}
object ChefService {
val ResultsTopic = "MixedResults"
}
mix调用接受Groceries并返回Done(与我们所定义的命令的返回类型进行比较)。服务实现也很简洁,因为它将状态管理委托给ChefPersistentEntity:
class ChefServiceImpl(persistentEntities: PersistentEntityRegistry,
as: ActorSystem) extends ChefService {
private lazy val entity = wire[ChefPersistentEntity]
persistentEntities.register(entity)
override def mix: ServiceCall[Groceries, Done] = ServiceCall { groceries =>
val ref = persistentEntities.refForChefPersistentEntity
ref.ask(MixCommand(groceries))
}
override def resultsTopic: Topic[Dough] = ???
}
首先,我们需要传递两个依赖项,PersistentEntityRegistry和ActorSystem。我们在连接ChefPersistentEntity时传递 actor 系统as,并使用持久化实体注册表根据 Lagom 的要求注册我们的持久化实体。然后mix调用仅使用注册表查找对实体的引用,并使用ask模式发送传入的命令并获取响应,就像我们使用 actor 一样。
我们现在省略resultsTopic的实现,以专注于服务的持久性方面。
ChefPersistentEntity有点长,所以让我们分块查看它。我们首先从覆盖 Lagom 的PersistentEntity开始:
final class ChefPersistentEntity(
persistentEntities: PersistentEntityRegistry, as: ActorSystem
) extends PersistentEntity { ... }
持久化实体可以从集群中的任何地方访问。正因为如此,在 Lagom 中使用持久性自动意味着使用集群(这绝对是一个好主意)。持久化实体需要覆盖一些字段:
override type Command = ChefCommand
override type Event = ChefEvent
override type State = ChefState
override def initialState: ChefState = MixingState(Nil)
Command、Event和State类型指的是我们之前定义的类型。我们还定义了一个初始状态为空的MixingState。
为了简单起见,我们不会实现完整的混合行为,因为我们已经在之前的章节中这样做过三次。相反,我们将模拟它:
private def dough(g: Groceries) = {
import g._
Dough(eggs * 50 + flour + sugar + chocolate)
}
现在,我们最终可以定义我们实体的行为,该实体将接受命令、持久化事件和修改状态。同样,这是按照我们在上一章中做的方式进行的,但 Lagom 通过提供一个Actions构造函数来增加其价值,该构造函数允许我们以构建器类似的方式定义命令和事件处理器:
Actions()
.onCommand[MixCommand, Done] {
case (MixCommand(groceries), ctx, _) if groceries.eggs <= 0 =>
ctx.invalidCommand(s"Need at least one egg but got: $groceries")
ctx.done
case (MixCommand(groceries), ctx, _) =>
val id = UUID.randomUUID()
ctx.thenPersist(Mixing(id, groceries)) { evt =>
as.scheduler.scheduleOnce(mixingTime)(
thisEntity.ask(DoneCommand(id)))
ctx.reply(Done)
}
}
命令处理器必须返回一个Persist指令,该指令描述了应该持久化什么内容,并可选地提供一个回调函数,用于执行副作用代码,该代码应在事件成功写入存储后执行。
在前面的代码片段中,我们处理了两个命令。带有负Dough数量的MixCommand被标记为无效(这是通过向调用者发送InvalidCommandException来建模的),调用ctx.done返回PersistNone,表示不需要持久化任何内容。
第二个处理器是用于有效命令的。使用它,我们首先为将要持久化的事件生成随机的id,然后构建事件并返回PersistOne,其中包含事件和回调。回调安排向持久化实体本身发送命令,意味着混合已完成,并将Done发送回调用者。
为了能够解引用一个实体,我们需要使用一个注册表,就像我们在服务中之前所做的那样:
lazy val thisEntity = persistentEntities.refForChefPersistentEntity
请注意,我们的持久化回调仅执行副作用,而不修改状态。对于状态修改,应使用另一个构造函数onEvent。这种分离是为了在恢复期间能够根据需要多次重建状态,但副作用代码只在实际事件发生后并已持久化后才执行一次:
Actions()
.onCommand[MixCommand, Done] { ... }
.onEvent {
case (m: Mixing, state) =>
MixingState(state.batches :+ m)
case (MixingDone(id, _), state) =>
MixingState(state.batches.filterNot(_.id == id))
}
在这里,我们只是将新的混合作业放入队列,并在它们完成后从队列中删除。现在我们必须定义实体发送给自身的DoneCommand的响应方式:
Actions()
.onCommand[MixCommand, Done] { ... }
.onEvent { ... }
.onCommand[DoneCommand, Done] {
case (DoneCommand(id), ctx, state) =>
state.batches
.find(_.id == id)
.map { g =>
ctx.thenPersist(MixingDone(id, dough(g.groceries))) {
_ => ctx.reply(Done)
}
}
.getOrElse(ctx.done)
}
我们正在当前状态中寻找我们之前通过使用id作为相等标准创建的MixingCommand,只是为了有一个对groceries的引用。这些杂货将在以后需要;目前我们将在读取端读取事件。然后我们构建并持久化一个事件,并返回Done以使编译器满意。你可能已经注意到,我们没有为MixingDone事件定义任何副作用。我们不需要这样做,因为这些事件将流式传输到我们之前指定的resultsTopic。
为了完成Chef的实现,我们需要将所有组件连接起来。ChefLoader与我们之前定义的其他加载器没有太大区别。相比之下,ChefApplication有一些不同:
abstract class ChefApplication(context: LagomApplicationContext)
extends LagomApplication(context) with CassandraPersistenceComponents with LagomKafkaComponents {
override lazy val lagomServer: LagomServer = serverForChefService
override lazy val jsonSerializerRegistry = new JsonSerializerRegistry {
override def serializers = ChefModel.serializers
}
}
我们需要提供一个JsonSerializerRegistry的实现,以便 Lagom 能够拾取我们的序列化器。我们的应用程序还需要扩展CassandraPersistenceComponents,因为我们使用了持久化,并且也扩展了LagomKafkaComponents——通过发布我们的事件,我们实际上也在使用消息。不幸的是,目前 Lagom 无法在编译时检查应用程序是否使用了消息传递,因此很容易忘记扩展 Kafka 组件,这将在应用程序启动时导致运行时错误。
我们已经定义了Chef服务MixingDone事件的持久化部分,现在让我们转向消息部分。
消息代理 API
当我们在Chef中实现持久化时,我们跳过了在 API 定义中提供的resultsTopic的定义。现在让我们看看resultsTopic的定义:
class ChefServiceImpl(...) extends ChefService {
...
override def resultsTopic: Topic[Dough] =
TopicProducer.singleStreamWithOffset { fromOffset =>
persistentEntities
.eventStream(ChefModel.EventTag, fromOffset)
.map { ev => (convertEvent(ev), ev.offset) }
}
private def convertEvent(chefEvent: EventStreamElement[ChefEvent]): Dough = {
chefEvent.event match {
case MixingDone(_, dough) => dough
}
}
}
我们使用TopicProducer工厂的singleStreamWithOffset构造函数来构建一个主题,所有标记为ChefModel.EventTag的事件都将发布到这个主题。在发布之前,我们将ChefEvent转换为下游服务期望的Dough。这是在convertEvent方法中完成的。
接收方是Manager。Lagom 提供了所有基础设施,使得事件的消费简化为以下一行代码:
val sub: Future[Done] = chefService.resultsTopic.subscribe.atLeastOnce(cookChefFlow)
在这里,我们使用chefService resultsTopic来订阅事件。我们提供cookChefFlow作为回调,该回调将至少为每个发布的事件调用一次。atLeastOnce方法期望akka.stream.scaladsl.Flow[Payload, Done, _]作为参数,其中 Payload 指的是消息的类型。我们将在稍后定义我们的Flow[Dough, Done, _]类型的流。
客户端服务 API
我们已经定义了所有的工人服务,让我们看看Manager以及它是如何通过调用其他服务按正确顺序驱动烘焙过程的。让我们从服务定义开始:
trait ManagerService extends Service {
def bake(count: Int): ServiceCall[NotUsed, Done]
def sell(count: Int): ServiceCall[NotUsed, Int]
def report: ServiceCall[NotUsed, Int]
override def descriptor: Descriptor = {
import Service._
named("Bakery").withCalls(
restCall(Method.POST, "/bake/:count", bake _),
restCall(Method.POST, "/sell?count", sell _),
pathCall("/report", report)
)
}
}
我们定义了三个方法:
-
bake,用于启动多个饼干的烘焙过程 -
sell,如果库存足够,用于出售饼干 -
report,用于检查当前库存中饼干的数量
我们将它们映射到两个 REST 调用和一个路径调用。我们使用一个路径和一个查询参数只是为了展示 Lagom 的描述符 DSL 提供的可能性。
让我们继续进行服务实现:
class ManagerServiceImpl(boyService: BoyService,
chefService: ChefService,
cookService: CookService,
bakerService: BakerService,
as: ActorSystem)
extends ManagerService {
private val count: AtomicInteger = new AtomicInteger(0)
private val logger = Logger("Manager")
...
}
我们必须提供所有即将调用的服务作为构造函数参数,这样我们可以在应用程序定义中稍后将它们连接起来。我们还定义了logger和count,它们将保存当前的饼干数量。在实际项目中,我们会实现一个基于事件源的方法来处理 Manager 的内部状态,但在这里我们只是为了简单起见将其保存在内存中。
report和sell方法通过检查内部状态并在适当的情况下修改它来实现:
override def sell(cnt: Int): ServiceCall[NotUsed, Int] =
ServiceCall { _ =>
if (cnt > count.get()) {
Future.failed(new IllegalStateException(s"Only $count cookies on sale"))
} else {
count.addAndGet(-1 * cnt)
Future.successful(cnt)
}
}
override def report: ServiceCall[NotUsed, Int] = ServiceCall { _ =>
Future.successful(count.get())
}
bake方法通过实际调用其他服务来实现:
override def bake(count: Int): ServiceCall[NotUsed, Done] = ServiceCall { _ =>
val sl = shoppingList(count)
logger.info(s"Shopping list: $sl")
for {
groceries <- boyService.shop.invoke(sl)
done <- chefService.mix.invoke(groceries)
} yield {
logger.info(s"Sent $groceries to Chef")
done
}
}
在这里,我们根据请求烘焙的饼干数量生成购物清单。然后,在 for-comprehension 中,我们调用 boyService 和 chefService。在调用厨师服务时,我们需要返回,因为厨师制作面团需要一些时间。
我们已经定义了 Dough 的监听器,这是 Chef 通过消息主题发送回来的,所以我们只需要定义处理传入消息的流程:
private lazy val chefFlow: Flow[Dough, Done, NotUsed] = Flow[Dough]
.map { dough: Dough =>
val fut = cookService.cook.invoke(dough)
val src = Source.fromFuture(fut)
val ready: Future[Source[ReadyCookies, NotUsed]] =
bakerService.bake.invoke(src)
Source.fromFutureSource(ready)
}
.flatMapConcat(identity)
.map(count.addAndGet(cookies.count))
.map(_ => Done)
在这里,我们再次将可能的单行表示为几个语句,以便容易发现正在发生的事情:我们定义 Flow,通过调用 cookService 将面团转换为 Future[RawCookies]。bakerService 是一个流式服务,它期望 Source[RawCookies, _],我们从 Future 中创建它。调用 bakerService 返回 Future[Source[ReadyCookies, _]],所以我们再次将 Future 转换为 Source,然后使用 flatMapConcat 平滑 Source[Source[ReadyCookies, _]]。最后,我们更改服务的内部状态,并返回 Done,这是订阅方法所期望的。
是时候一起构建 ManagerApplication 了!我们需要为在 ManagerImpl 中使用过的所有服务提供引用。当然,我们将使用 serviceClient 来完成这项工作:
abstract class ManagerApplication(context: LagomApplicationContext)
extends LagomApplication(context) with LagomKafkaClientComponents {
lazy val boyService: BoyService = serviceClient.implement[BoyService]
lazy val chefService: ChefService = serviceClient.implement[ChefService]
lazy val cookService: CookService = serviceClient.implement[CookService]
lazy val bakerService: BakerService = serviceClient.implement[BakerService]
override lazy val lagomServer: LagomServer =
serverForManagerService
}
ManagerServiceImpl 本身仍然使用 Macwire 构建。
运行应用程序
现在,我们已经构建了所有服务,并可以使用之前的 runAll 命令作为一个整体运行项目。我们还需要运行上一章中的 Akka-HTTP 示例,并确保有足够的库存,以便男孩可以从其中获取一些杂货:

上一张截图显示了两个终端窗口:在右侧是第十四章中的 Akka HTTP 商店正在运行,在左侧 runAll 命令准备执行。runAll 命令需要一些时间来启动所有子系统,并在控制台产生大量输出。
在撰写本文时,处理管道在返回烘焙的饼干给经理之前停止了。我们将此问题报告为 Lagom 的一个错误 (github.com/lagom/lagom/issues/1616),但不幸的是,我们还没有收到 Lagom 团队的反馈。我们保留了示例,希望问题将在框架的下一个版本中得到修复。在不太可能的情况下,如果这不是一个错误,我们将在收到对错误报告的相应反应后立即更新示例。
一切都平静下来后,我们可以从另一个窗口使用 http 客户端调用我们的 Manager 服务:
curl -X "POST" "http://localhost:58866/bake/10"
这应该在 Lagom 终端产生类似于以下输出:

看起来是时候享受饼干了!嗯,还不完全是时候!
测试
与之前一样,我们希望通过测试我们提出的实现来结束我们的旅程。幸运的是,Lightbend 遵循与其他库相同的测试方法。有一个测试套件允许我们轻松地测试服务和持久化实体。我们将在本节中演示如何测试服务,并将测试持久化实体留给读者作为练习。
有道理从测试我们定义的最简单的服务——CookService开始。这里是对它的测试,放置在cook-impl模块的测试范围内:
import ch15.model.Dough
import com.lightbend.lagom.scaladsl.server.LocalServiceLocator
import com.lightbend.lagom.scaladsl.testkit.ServiceTest
import org.scalatest.{AsyncWordSpec, Matchers}
import play.api.libs.ws.ahc.AhcWSComponents
class CookServiceSpec extends AsyncWordSpec with Matchers {
"The CookService" should {
"make cookies from Dough" in ServiceTest.withServer(ServiceTest.defaultSetup) { ctx =>
new CookApplication(ctx) with LocalServiceLocator with AhcWSComponents
} { server =>
val client = server.serviceClient.implement[CookService]
client.cook.invoke(Dough(200)).map { cookies =>
cookies.count should ===(3)
}
}
}
}
Lagom 提供了一个ServiceTest对象,其目的是支持单个服务的测试。它的withServer构造函数接受两个参数:一个应用程序构造函数和一个测试代码块。它看起来与我们之前在测试第十二章中的Akka-HTTP实现时使用的方法类似,但行为不同。ServiceTest实际上启动了带有服务的真实服务器。在我们的例子中,我们将其与LocalServiceLocator混合,这样我们就可以在测试块中从中获取服务实现。在这里,我们可以调用服务并验证它是否按预期工作。
我们的规范扩展了AsyncWordSpec,这使我们能够通过映射服务返回的Future来制定我们的期望。
测试像CookService这样的同步服务非常简单。但是,如何测试异步(流式)服务呢?我们已经使用BakerService构建了一个示例。以下是该单元测试的一个可能的实现:
class BakerServiceSpec extends AsyncWordSpec with Matchers {
"The BakerService" should {
"bake cookies" in ServiceTest.withServer(ServiceTest.defaultSetup) { ctx =>
new BakerApplication(ctx) with LocalServiceLocator
} { server =>
val client = server.serviceClient.implement[BakerService]
implicit val as: Materializer = server.materializer
val input: Source[RawCookies, NotUsed] =
Source(List(RawCookies(10), RawCookies(10), RawCookies(10)))
.concat(Source.maybe)
client.bake.invoke(input).map { output =>
val probe = output.runWith(TestSink.probe(server.actorSystem))
probe.request(10)
probe.expectNext(ReadyCookies(12))
probe.expectNext(ReadyCookies(12))
// because the oven is not full for the 6 other
probe.cancel
succeed
}
}
}
}
测试的定义、测试服务器和客户端与之前相同。唯一不同的是,我们需要提供Source而不是我们正常的领域类型,并返回Source。因此,我们求助于 Akka-Streams 测试套件,并利用我们在第十三章,“Akka Streams 基础”中学到的知识来制定对流的期望。我们从List创建一个输入Source,并使用TestSink来确认服务的输出符合我们的期望。
现在是享受饼干的时候了!
摘要
Lagom 框架是 Lightbend 提供的一种解决方案,旨在简化使用 Scala 和 Java 构建微服务的过程。它建立在现有的库和框架之上,如 SBT、Akka 和 Play,并提供了额外的功能,例如具有热代码重载的丰富开发环境、服务注册和服务定位器、嵌入式数据库和消息代理。
Lagom 有三个有用的 API:
-
服务 API,允许您将远程服务调用表示为本地函数调用
-
持久化 API,为
Akka-Persistence提供额外的结构和一些有用的默认值 -
消息代理 API,使公共/订阅通信和与日志的读取端交互变得容易
Lagom 随附一个测试套件,可以帮助独立测试服务或持久化实体。将 Lagom 的测试套件与 Akka-Streams 测试套件和来自 ScalaTest 的 AsyncWordSpec 结合使用,使得编写简洁且表达性强的测试期望成为可能。
在本章中,我们只是简要地提到了 Lagom 为基于微服务系统的开发提供的可能性。这个软件工程领域仍然相当不成熟,而 Lagom 是解决这些新挑战的第一次尝试之一。
我们希望通过我们的示例项目能够激发你对 Lagom 的兴趣,并强烈推荐你查看官方 Lagom 文档www.lagomframework.com/documentation/1.4.x/scala/Home.html以获取更多灵感。
问题
-
你如何将带有查询参数的端点映射到 REST 调用?
-
对于持久化实体,推荐使用哪种序列化格式?
-
你能解释为什么在 Lagom 中使用持久化需要聚类吗?
-
描述一个可能的数据模型,该模型可用于使管理器成为一个持久化实体。
-
概述一种实现 Baker 服务的替代方法。
-
你能否在 Chef 当前实现中识别出一个设计错误?
-
管理器实现将多个 cookie 存储在内存中,而这个数字在服务重启时将会丢失。你能说出另一个为什么将 cookie 的数量保存在局部变量中是一个坏主意的原因吗?
进一步阅读
-
Rambabu Posa,《Scala 反应式编程:在 Scala 中构建容错、健壮和分布式应用程序》
-
Jatin Puri 和 Selvam Palanimalai,《Scala 微服务:优雅地使用 Scala 设计、构建和运行微服务》
第十六章:准备环境和运行代码示例
本章中的说明基于我们在 2018 年晚些时候安装 JDK 和 SBT 以及运行代码示例的经验。由于软件更新速度很快,此类说明一旦发布通常会很快过时;请在阅读本章时牢记这一点。
安装软件
安装 JDK、Scala 和 SBT 的推荐方法是使用在sdkman.io可用的 SDK 管理器。请参考网页上的说明以完成安装。
此外,我们将描述手动安装 Java 和 SBT 的过程。为了您的方便,我们将进一步截图中的命令打包成脚本,为 OS X 和 Linux 提供,并在 GitHub 仓库的“附录”文件夹中提供:github.com/PacktPublishing/Learn-Scala-Programming/tree/master/Appendix.
安装 Java 虚拟机 (JVM)
目前,在撰写本文时,在jdk.java.net上可用的最后一个 JDK 版本是 11。Scala 2.13 需要 Java 1.8+。
您可以使用现有的工具之一,例如 apt 或 yum(用于 Linux 的包管理器)或 brew(用于 OS X 环境),在 Linux 或 macOS X 平台上安装 Java。如果您打算以这种方式安装 JDK,请使用相应工具的帮助页面和指南。
Java 的手动安装包括三个步骤:
-
从互联网下载软件包。不同平台上的分发文件选择可在
jdk.java.net/11/找到。 -
解压安装文件。
-
更新环境。
使用 Linux 或 OS X,您可以使用以下一系列 shell 命令执行所有这些步骤,如下一两个截图所示:

在 OS X 中安装 JDK 11
Linux 中的安装流程如此相似,以至于我们甚至没有在截图中使用箭头:

Linux 中的相同步骤
在 Windows 上,您需要使用网络浏览器下载安装程序包,使用文件导航器解压缩它,并使用系统设置更新路径变量。请注意,Java 11 仅提供 64 位版本,因此如果您有 32 位系统,您必须安装 JVM 的早期版本。
请按照以下截图中的步骤进行设置:
- 首先,从互联网下载安装文件:

第 1 步:使用网络浏览器下载安装程序
- 然后,从存档中提取 Java 运行时:

第 2 步:从下载的捆绑包中提取二进制文件
- 接下来,需要通过修改相应的属性来扩展系统路径:

第 3 步:更新环境的五个动作
- 最后,可以检查 Java 的版本:

完成安装并检查 Java 版本
安装 Java 后的下一步是安装 SBT。您不需要单独安装 Scala 就能使用本书的示例代码。
安装 SBT
对于简单的构建工具 SBT,有几种安装选项:使用 apt-get、brew 或 macports 等工具,或者手动安装。首先,我们将介绍手动设置。
对于 Linux 和 macOS,步骤与 JDK 相同:
-
获取安装包。
-
解压下载的文件。
-
更新环境。
SBT 的最新版本可以在相应的网站上找到,网址为 www.scala-sbt.org/download.html。
在 Linux 或 macOS 上,您可以使用下一张截图所示的方式使用命令行来执行这三个安装步骤:

我们在 GitHub 仓库的 附录 文件夹中包含了一个安装脚本,以节省您输入。
在 Windows 上,安装方式略有不同。网站将提供 MSI 安装包供下载,下载后可以通过双击它来安装,如下所示:

下载 SBT 安装包
下载 SBT 安装程序后,可以执行:

开始 SBT 安装的步骤
安装开始后,只需按照向导中的步骤完成设置即可。
使用代码
本书中的代码示例可以在以下 GitHub 仓库中找到:github.com/PacktPublishing/Learn-Scala-Programming。
在本节中,我们将简要介绍如何使用 SBT 控制台和 Scala REPL 下载和使用源代码。
获取代码
推荐的安装配套代码的方式是使用 Git 工具进行克隆。由于我们可能需要更新和改进代码,这将使我们能够轻松获取未来的更改。请参考您操作系统的 Git 工具安装说明。
在这种情况下,没有可用的 Git 工具;可以使用网络浏览器或其他下载工具下载源代码。GitHub 仓库中有一个“下载 ZIP”按钮,可以生成用于下载的正确 URL,如下所示:

以下截图演示了克隆和下载源代码的两种方法:

克隆或下载源代码完成首次设置过程。
与源代码一起工作
源代码组织方式是,每个章节都有自己的文件夹,按照惯例命名为第一章,Scala 2.13 简介到第十五章,项目 2 - 使用 Lagom 构建微服务。您可以通过导航到相应的文件夹并启动 SBT 来实验示例。建议偶尔发出git pull命令以获取克隆存储库的最新更新和错误修复,如下所示:

每一章都有自己的配置文件,project/build.properties和build.sbt。前者配置要使用的 SBT 版本,后者配置所需的 Scala 版本和其他依赖项。SBT 将根据需要下载和缓存所有配置的版本。这就是为什么我们不需要单独安装 Scala。
使用 SBT 控制台
启动 SBT 后,用户将进入交互模式或 SBT shell。shell 允许发出不同的命令来驱动 SBT 进行测试、执行或对源代码进行其他操作。对我们来说最重要的是以下命令:
-
exit: 关闭 SBT 并退出到终端 shell -
test: 运行当前项目中的所有测试套件(如果src/test/scala文件夹中有)
下一张截图显示了 SBT 如何编译和运行第十一章,Akka 和 Actor 模型简介的测试:

run: 如果项目中只有一个主类,则运行主类。如果无法检测到main类,此命令将抛出异常。如果有多个主类,SBT 将要求您从中选择一个来运行。此行为如以下截图所示:

console: 启动 scala REPL
使用 REPL
REPL 是用于评估 Scala 表达式的工具。可以通过在命令行中输入scala或直接从 SBT 会话中启动。从 SBT shell 启动它的好处是,所有为项目配置的依赖项都将可用,如下一张截图所示:

请参考docs.scala-lang.org/overviews/repl/overview.html中的 Scala 文档,了解如何高效地使用 REPL。
要退出 REPL 并返回 SBT shell,请输入 :q.
第十七章:评估
第一章
- 描述两种使某些资源
R能够与scala.util.Using资源管理实用程序一起使用的方法。
让R扩展java.lang.AutoCloseable。这将允许将现有的从AutoCloseable到Resource的隐式转换应用于R。
提供对Resource[R]的隐式实现。
- 如何比较
Set和List?
Set和List之间没有定义等性,因此我们必须以以下两种方式之一使用sameElements方法,直接在List上或在Set的迭代器上,如下面的代码片段所示:
val set = Set(1,2,3)
val list = List(1,2,3)
set == list // false
set.iterator.sameElements(list) // true
list.sameElements(set) // true
另一种可能性是利用corresponds操作与等性检查函数的组合。这在两个方向上工作方式相似:
scala> set.corresponds(list)(_ == _)
res2: Boolean = true
scala> list.corresponds(set)(_ == _)
res3: Boolean = true
- 为不可变的
Seq命名默认的具体实现。
scala.collection.immutable.List
- 为不可变的索引
Seq命名默认的具体实现。
scala.collection.immutable.Vector
- 为可变的
Seq命名默认的具体实现。
scala.collection.mutable.ArrayBuffer
- 为可变的
IndexedSeq命名默认的具体实现。
scala.collection.mutable.ArrayBuffer
- 有时人们会说
List.flatMap比预期的更强大。你能尝试解释一下为什么吗?
flatMap是在IterableOnce上定义的,因此它的参数是一个返回IterableOnce的函数。正因为如此,在flatMap操作时可以混合不同的类型。考虑以下示例,其中List能够将Set[Int]和其元素进行flatMap操作:
scala> List(1,2,3,4,5,6).flatMap(i => if (i<3) Set.fill(i)(i) else Seq.fill(i)(i))
res28: List[Int] = List(1, 2, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6)
- 描述一种使用不同函数多次映射集合的方法,但又不产生中间集合。
创建一个视图,按需映射视图,并强制将其转换回原始表示类型:
scala> List(1,2,3,4,5).view.flatMap(List.fill(_)("a")).map(_.toUpperCase).toList
res33: List[String] = List(A, A, A, A, A, A, A, A, A, A, A, A, A, A, A)
第二章
- 你能命名哪些类型约束?
有两个约束:下界或子类型关系和上界或超类型关系。
- 如果没有开发者为类型定义类型约束,那么会添加哪些隐式类型约束到该类型中?
对于缺少上界,编译器添加Any作为约束,对于缺少下界则添加Nothing。
- 哪些运算符可以用来引用某些类型的嵌套类型?
有两个运算符。A#B的概念指的是A类型的嵌套类型。a.B的概念指的是实例a的B子类型。
- 哪种类型可以用作中缀类型?
任何恰好由两个类型参数参数化的类型。
- 为什么在 Scala 中不建议使用结构化类型?
结构化类型的用法通常会导致生成的字节码,它通过反射访问方法,这比正常方法调用要慢。
- 通过变异性表达了什么?
参数化类型和参数化类型之间的子类型关系的相关性。
第三章
- 以下函数在柯里化形式中的类型将是什么:
(Int, String) => (Long, Boolean, Int) => String?
Int => (String => ((Long, Boolean, Int) => String))) 或简化为 Int => String => (Long, Boolean, Int) => String
- 描述部分应用函数和部分函数之间的区别。
部分函数未定义在某些可能的输入值上。部分应用函数将其一些参数固定为特定值。
- 定义一个签名并实现一个函数,
uncurry,用于三个参数的柯里化函数,A => B => C => R。
def uncurryA,B,C,R: (A,B,C) => R = (a,b,c) => in(a)(b)(c)
- 实现一个用于阶乘计算的头部递归函数(n! = n * (n-1) * (n-2) * ... * 1)。
def factorial(n: Long): Long = if (n < 2) n else n * factorial(n-1)
- 实现一个用于阶乘计算的尾递归函数。
def factorial(n: Long): Long = {
def f(n: Long, acc: Long): Long = if (n < 2) acc else f(n-1, n * acc)
f(n,1)
}
- 实现一个使用跳跃递归的阶乘计算的递归函数。
import util.control.TailCalls._
def factorial(n: Long): TailRec[Long] = if (n<2) done(n) else tailcall(factorial(n-1)).map(_ * n)
第四章
- 描述一个隐式参数也是隐式转换的情况。
如果隐式参数是一个函数,则这是这种情况:def funcA, T(implicit adapter: A => T): T = adapter(a)。
- 将以下使用视图边界的定义替换为使用上下文边界的定义:
def compare[T <% Comparable[T]](x: T, y: T) = x < y。
type ComparableContext[T] = T => Comparable[T]
def compareT : ComparableContext = x < y
- 为什么有时说类型类将行为和数据分开?
因为类型类实例定义了逻辑以工作,并且数据来自类型类应用到的值。
很容易改变可能的冲突的示例,以便其中一个隐式参数胜过其他隐式参数,并且所有其他隐式参数都可以取消注释而不再有冲突。进行此更改。例如,可以通过将val的定义之一更改为object的定义来完成:
package object resolution {
// change // implicit val a: TS = new TS("val in package object") // (1)
// to //
implicit object TSO extends TS("object in package object") // (1)
}
然后,由于静态解析规则,TSO 将比其他值更具体,并且将由编译器为应用程序选择。
第五章
- 定义一个用于排序列表的不变性质。
def invariant[T: Ordering: Arbitrary]: Prop =
forAll((l: List[T]) => l.sorted.length == l.length)
scala> invariant[Long].check
+ OK, passed 100 tests.
scala> invariant[String].check
+ OK, passed 100 tests.
- 定义一个用于排序列表的幂等性质。
def idempotent[T: Ordering: Arbitrary]: Prop =
forAll((l: List[T]) => l.sorted.sorted == l.sorted)
scala> idempotent[Long].check
+ OK, passed 100 tests.
scala> idempotent[String].check
+ OK, passed 100 tests.
- 定义一个用于排序列表的归纳性质。
def inductive[T: Ordering: Arbitrary]: Prop = {
def ordered(l: List[T]): Boolean =
(l.length < 2) ||
(ordered(l.tail) && implicitly[Ordering[T]].lteq(l.head, l.tail.head))
forAll((l: List[T]) => ordered(l.sorted))
}
scala> inductive[Int].check
+ OK, passed 100 tests.
scala> inductive[String].check
+ OK, passed 100 tests.
- 定义一个用于
List[List[Int]]的生成器,使得嵌套列表的元素都是正数。
val genListListInt = Gen.listOf(Gen.listOf(Gen.posNum[Int]))
scala> genListListInt.sample
res35: Option[List[List[Int]]] = Some(List(List(60, 99, 5, 68, 52, 98, 31, 29, 30, 3, 91, 54, 88, 49, 97, 2, 92, 28, 75, 100, 100, 38, 16, 2, 86, 41, 4, 7, 43, 70, 21, 72, 90, 59, 69, 43, 88, 35, 57, 67, 88, 37, 4, 97, 51, 76, 69, 79, 33, 53, 18), List(85, 23, 4, 97, 7, 50, 36, 24, 94), List(97, 9, 25, 34, 29, 82, 59, 24, 94, 42, 34, 80, 7, 79, 44, 54, 61, 84, 32, 14, 9, 17, 95, 98), List(4, 70, 13, 18, 42, 74, 63, 21, 58, 4, 32, 61, 52, 77, 57, 40, 37, 54, 11), List(9, 22, 33, 19, 56, 29, 45, 34, 61, 48, 42, 56, 64, 96, 56, 77, 58, 90, 30, 48, 32, 49, 80, 58, 65, 5, 24, 88, 27, 44, 15, 5, 65, 11, 14, 80, 30, 5, 23, 31, 38, 55, 1, 94, 15, 89, 69, 23, 35, 45, 38, 96, 11, 35, 22, 90, 46, 39, 69, 11, 26, 53, 18, 23, 8, 85, 22, 12, 49, 79, 63, 39, 1, 89, 68, 91, 24...
- 定义一个用于
Map[UUID, () => String]的生成器。
val pairGen = for {
uuid <- Gen.uuid
function0 <- Gen.function0(Gen.asciiStr)
} yield (uuid, function0)
val mapGen = Gen.mapOf(pairGen)
mapGen: org.scalacheck.Gen[Map[java.util.UUID,() => String]] = org.scalacheck.Gen$$anon$1@16ca4e8d
scala> mapGen.sample
res36: Option[Map[java.util.UUID,() => String]] = Some(Map(31395a9b-78af-4f4a-9bf3-c19b3fb245b6 -> org.scalacheck.Gen$$$Lambda$2361/1400300928@178b18c, ...
请注意,Gen.function0生成一个零参数的函数,该函数仅返回由提供的生成器生成的随机值。
第六章
- 表示获取以下每个元素的适当效果是什么:
-
某些列表的第一个元素:
Option[?],其中None表示空列表没有头元素 -
推文列表:
Future[List[Tweet]],因为操作可能需要一些时间,因为它通过网络进行 -
使用数据库中的用户信息
userId:Future[Option[?]],其中Future表示网络调用,Option表示对于给定的userId没有用户账户
- 以下表达式的可能值范围是什么:
Option(scala.util.Random.nextInt(10)).fold(9)(_-1)
一个包含 [-1;9] 的区间
- 以下表达式的结果会是什么:
TryInt).filter(_ > 10).recover {
case _: OutOfMemoryError => 100
}(20)
Try 构造函数不会捕获 OutOfMemoryError,因此给定的表达式将抛出 OutOfMemoryError。
- 描述以下表达式的结果:
FutureInt).filter(_ > 10).recover {
case _: OutOfMemoryError => 100
}
表达式的结果将是 Future(<未完成>),最终会抛出 OutOfMemoryError,就像上一个案例一样。
- 给定以下函数:
def either(i: Int): Boolean =
Either.cond(i > 10, i * 10, new IllegalArgumentException("Give me more")).forall(_ < 100)
- 以下调用会有什么结果:
either(1)?
结果将是 true,因为 Either.cond 在 i == 2 时评估为 Left,而 Left.forall 对任何 Left 评估为 true。
第七章
- 为什么结合律对于幺半群在分布式设置中有用是本质的?
在分布式设置中,我们通常讨论的是折叠和重用数据集,其中部分数据由不同的计算机处理。幺半群操作应用于远程机器。无论它们是从主机器发送的顺序如何,网络延迟、不同的负载模式和硬件设置都将影响它们返回的顺序。能够在不等待第一次操作完成的情况下,对已经掌握的中间结果应用操作是很重要的。
- 在
OR下实现Boolean的幺半群。
实现如下:
implicit val booleanOr: Monoid[Boolean] = new Monoid[Boolean] {
override def identity: Boolean = false
override def op(l: Boolean, r: Boolean): Boolean = l || r
}
属性如下::
property("boolean under or") = {
import Assessment.booleanOr
monoidProp[Boolean]
}
- 在
AND下实现Boolean的幺半群。
实现如下:
implicit val booleanAnd: Monoid[Boolean] = new Monoid[Boolean] {
override def identity: Boolean = true
override def op(l: Boolean, r: Boolean): Boolean = l && r
}
属性如下:
property("boolean under and") = {
import Assessment.booleanAnd
monoidProp[Boolean]
}
- 给定
Monoid[A],实现Monoid[Option[A]].
实现如下:
implicit def option[A : Monoid]: Monoid[Option[A]] = new Monoid[Option[A]] {
override def identity: Option[A] = None
override def op(l: Option[A], r: Option[A]): Option[A] = (l, r) match {
case (Some(la), Some(lb)) => Option(implicitly[Monoid[A]].op(la, lb))
case _ => l orElse r
}
}
属性如下:
property("Option[Int] under addition") = {
import Monoid.intAddition
import Assessment.option
monoidProp[Option[Int]]
}
property("Option[String] under concatenation") = {
import Monoid.stringConcatenation
import Assessment.option
monoidProp[Option[String]]
}
- 给定
Monoid[R],实现Monoid[Either[L, R]].
实现如下:
def either[L, R : Monoid]: Monoid[Either[L, R]] = new Monoid[Either[L, R]] {
private val ma = implicitly[Monoid[R]]
override def identity: Either[L, R] = Right(ma.identity)
override def op(l: Either[L, R], r: Either[L, R]): Either[L, R] = (l, r) match {
case (l @ Left(_), _) => l
case (_, l @ Left(_)) => l
case (Right(la), Right(lb)) => Right(ma.op(la, lb))
}
}
属性如下:
property("Either[Int] under multiplication") = {
import Monoid.intMultiplication
implicit val monoid: Monoid[Either[Unit, Int]] = Assessment.either[Unit, Int]
monoidProp[Either[Unit, Int]]
}
property("Either[Boolean] under OR") = {
import Assessment.booleanOr
implicit val monoid: Monoid[Either[String, Boolean]] = Assessment.either[String, Boolean]
monoidProp[Either[String, Boolean]]
}
- 泛化前两个实现以适用于任何由
A参数化的效果,或描述为什么不可能。
不幸的是,在一般情况下无法实现这样的幺半群,因为实现将需要两个方面:
-
新幺半群的单位元素
-
检查一个效果是否为空,并在它不为空时检索一个元素的可能性
可以将单位元素作为参数传递给构造函数,但这样就没有办法按照第二点的要求与现有的效果一起工作。
第八章
- 实现
Functor[Try]。
implicit val tryFunctor: Functor[Try] = new Functor[Try] {
override def mapA, B(f: A => B): Try[B] = in.map(f)
override def mapCA, B: Try[A] => Try[B] = fa => map(fa)(f)
}
- 实现
Applicative[Try]。
implicit val tryApplicative: Applicative[Try] = new Applicative[Try] {
override def applyA, B(f: Try[A => B]): Try[B] = (a, f) match {
case (Success(a), Success(f)) => Try(f(a))
case (Failure(ex), _) => Failure(ex)
case (_, Failure(ex)) => Failure(ex)
}
override def unitA: Try[A] = Success(a)
}
- 实现
Applicative[Either]。
implicit def eitherApplicative[L] = new Applicative[({ type T[A] = Either[L, A] })#T] {
override def applyA, B(f: Either[L, A => B]): Either[L, B] = (a, f) match {
case (Right(a), Right(f)) => Right(f(a))
case (Left(l), _) => Left(l)
case (_, Left(l)) => Left(l)
}
override def unitA: Either[L, A] = Right(a)
}
- 实现
Traversable[Try]。
implicit val tryTraversable = new Traversable[Try] {
override def mapA, B(f: A => B): Try[B] = Functor.tryFunctor.map(in)(f)
override def traverse[A, B, G[_] : Applicative](a: Try[A])(f: A => G[B]): G[Try[B]] = {
val G = implicitly[Applicative[G]]
a match {
case Success(s) => G.map(f(s))(Success.apply)
case Failure(ex) => G.unit(Failure(ex)) // re-wrap the ex to change the type of Failure
}
}
}
- 实现
Traversable[Either]。
implicit def eitherTraversable[L] = new Traversable[({ type T[A] = Either[L, A] })#T] {
override def mapA, B(f: A => B): Either[L, B] =
Functor.eitherFunctor[L].map(in)(f)
override def traverse[A, B, G[_] : Applicative](a: Either[L, A])(f: A => G[B]): G[Either[L, B]] = {
val G = implicitly[Applicative[G]]
a match {
case Right(s) => G.map(f(s))(Right.apply)
case Left(l) => G.unit(Left(l)) // re-wrap the l to change the type of Failure
}
}
}
- 实现
Traversable.compose。
trait Traversable[F[_]] extends Functor[F] {
def traverse[A,B,G[_]: Applicative](a: F[A])(f: A => G[B]): G[F[B]]
def sequence[A,G[_]: Applicative](a: F[G[A]]): G[F[A]] = traverse(a)(identity)
def compose[H[_]](implicit H: Traversable[H]): Traversable[({type f[x] = F[H[x]]})#f] = {
val F = this
new Traversable[({type f[x] = F[H[x]]})#f] {
override def traverse[A, B, G[_] : Applicative](fa: F[H[A]])(f: A => G[B]) =
F.traverse(fa)((ga: H[A]) => H.traverse(ga)(f))
override def mapA, B(f: A => B): F[H[B]] =
F.map(in)((ga: H[A]) => H.map(ga)(f))
}
}
}
第九章
- 实现
Monad[Try]。
implicit val tryMonad = new Monad[Try] {
override def unitA: Try[A] = Success(a)
override def flatMapA, B(f: A => Try[B]): Try[B] = a match {
case Success(value) => f(value)
case Failure(ex) => Failure(ex)
}
}
- 证明
State幺半群的右单位律。
让我们从本章中已有的属性定义开始:
val rightIdentity = forAll { (a: A, f: A => M[B]) =>
M.flatMap(M.unit(a))(f) == f(a)
}
设 f(a) = a => State(s => (b, s2))
首先,我们将单位定义的值替换为调用结果。因此,M.flatMap(M.unit(a))(f) 变为 M.flatMap(State(s => (a, s)))(f)。
接下来,我们将M.flatMap替换为compose,这给我们State(s => (a, s)).compose(f).
接下来,我们将使用本章中证明的引理,用其定义替换compose调用:
State(s => {
val (a, nextState) = (a, s)
f(a).run(nextState)
}
通过应用f,之前的代码可以简化为State(s => State(s => (b, s2)).run(s),进一步简化为State(s => (b, s2))。(1)
方程式的右侧,f(a),根据定义等于State(s => (b, s2))。(2)
我们有(1) == (2),因此证明了状态单子的右单位律。
- 在本章定义的单子中选择一个,并实现一个
go函数,该函数将以 1%的概率编码船沉没的概念。
Option将代表船沉没的概念:
import Monad.optionMonad
def go(speed: Float, time: Float)(boat: Boat): Option[Boat] =
if (Random.nextInt(100) == 0) None
else Option(boat.go(speed, time))
println(move(go, turn[Option])(Option(boat)))
- 请同样操作,但以 1%的概率编码电机在移动中损坏的概念,使船无法移动。
Try和右偏Either都可以用来编码电机损坏的情况。
以下是使用Try的实现:
import Monad.tryMonad
def go(speed: Float, time: Float)(boat: Boat): Try[Boat] =
if (Random.nextInt(100) == 0) Failure(new Exception("Motor malfunction"))
else Success(boat.go(speed, time))
println(move(go, turn[Try])(Success(boat)))
以下是使用Either的实现:
import Monad.eitherMonad
type ErrorOr[B] = Either[String, B]
def go(speed: Float, time: Float)(boat: Boat): ErrorOr[Boat] =
if (Random.nextInt(100) == 0) Left("Motor malfunction")
else Right(boat.go(speed, time))
println(move(go, turn[ErrorOr])(Right(boat)))
- 使用以下模板(松散地)描述本章中定义的单子的本质:状态单子传递链式计算之间的状态。计算本身接受前一次计算的结果,并返回结果以及新的状态。
选项单子允许链式计算,这些计算可能不会返回结果。计算会一直进行,直到最后一个或直到第一个返回无结果。
尝试单子与选项单子做同样的事情,但不是通过一个特殊的无结果值来中断整个计算链,而是有一个由Failure案例类表示的失败概念。
两个单子都有类似于选项和尝试单子的语义,但在这个情况下,中断步骤序列的概念由Left类型承担,而继续序列的概念由Right类型承担。
- 定义一个
go方法,该方法既跟踪船的位置,又使用以下类型的结构来考虑船沉没的可能性:type WriterOption[B] = Writer[Vector[(Double, Double)], Option[Boat]]。
object WriterOptionExample extends App {
type WriterOption[B] = Writer[Vector[(Double, Double)], Option[B]]
import WriterExample.vectorMonoid
// this implementation delegates to the logic we've implemented in the chapter
def go(speed: Float, time: Float)(boat: Boat): WriterOption[Boat] = {
val b: Option[Boat] = OptionExample.go(speed, time)(boat)
val c: WriterTracking[Boat] = WriterExample.go(speed, time)(boat)
Writer((b, c.run._2))
}
// constructor - basically unit for the combined monad
private def writerOptionA =
Writer[Vector[(Double, Double)], Option[A]](Option(a))
// we need a monad of the appropriate type
implicit val readerWriterMonad = new Monad[WriterOption] {
override def flatMapA, B(f: A => WriterOption[B]): WriterOption[B] =
wr.compose {
case Some(a) => f(a)
case None => Writer(Option.empty[B])
}
override def unitA: WriterOption[A] = writerOption(a)
}
// tracks boat movement until it is done navigating or sank
println(move(go, turn)(writerOption(boat)).run)
}
- 比较第 6 题的答案和我们在上一章中组合应用的方式。
在第八章,处理效果中,我们实现了一个通用的组合子用于应用。在这个涉及单子的实现中,我们需要了解如何剖析选项的效果,以便能够实现组合逻辑。请阅读第十章,单子变换器和自由单子的观察,以获取更多详细信息。
第十章
- 为什么单子变换器的类型反映了栈类型的“颠倒”?
在一般情况下,无法定义单子组合,只有在其堆栈内部效果特定的情况下才能定义。因此,效果的名字被固定在转换器的名字中,外部效果成为类型参数。
- 为什么可以在堆栈顶层重用现有的单子?
Kleisli 箭的返回类型与堆栈的类型很好地匹配。因此,可以通过利用外部单子的 flatMap 方法来产生正确类型的正确结果。
- 为什么不能在堆栈底层重用现有的单子?
箭的参数类型期望一个普通参数。因此,我们需要从内部效果上下文中提取无效果的价值。这只能在特定方式下实现,而不能在一般方式下实现。
- 实现
TryT单子转换器。
private def noResultTryT[F[_] : Monad, T](ex: Throwable): F[Try[T]] =
Monad[F].unit(FailureT)
implicit class TryT[F[_] : Monad, A](val value: F[Try[A]]) {
def composeB: TryT[F, B] = {
val result = value.flatMap {
case Failure(ex) => noResultTryTF, B
case Success(a) => f(a).value
}
new TryT(result)
}
def isSuccess: F[Boolean] = Monad[F].map(value)(_.isSuccess)
}
def tryTunit[F[_] : Monad, A](a: => A) = new TryT(Monad[F].unit(Try(a)))
implicit def TryTMonad[F[_] : Monad]: Monad[TryT[F, ?]] = new Monad[TryT[F, ?]] {
override def unitA: TryT[F, A] = Monad[F].unit(Monad[Try].unit(a))
override def flatMapA, B(f: A => TryT[F, B]): TryT[F, B] = a.compose(f)
}
- 请使用
TryT单子转换器代替本章中的示例函数中的EitherT。
object Ch10FutureTryFishing extends FishingApi[TryT[Future, ?]] with App {
val buyBateImpl: String => Future[Bate] = ???
val castLineImpl: Bate => Try[Line] = ???
val hookFishImpl: Line => Future[Fish] = ???
override val buyBate: String => TryT[Future, Bate] =
(name: String) => buyBateImpl(name).map(Try(_))
override val castLine: Bate => TryT[Future, Line] =
castLineImpl.andThen(Future.successful(_))
override val hookFish: Line => TryT[Future, Fish] =
(line: Line) => hookFishImpl(line).map(Try(_))
val result: Future[Try[Fish]] = goFishing(tryTunitFuture, String).value
}
- **实现另一种单子转换器堆栈,这次将层放置颠倒: **
EitherT[OptionT[Future, A], String, A]。
type Inner[A] = OptionT[Future, A]
type Outer[F[_], A] = EitherT[F, String, A]
type Stack[A] = Outer[Inner, A]
object Ch10EitherTOptionTFutureFishing extends FishingApi[Stack[?]] with App {
val buyBateImpl: String => Future[Bate] = ???
val castLineImpl: Bate => Either[String, Line] = ???
val hookFishImpl: Line => Future[Fish] = ???
override val castLine: Bate => Stack[Line] =
(bate: Bate) => new OptionT(Future.successful(Option(castLineImpl(bate))))
override val buyBate: String => Stack[Bate] =
(name: String) => new OptionT(buyBateImpl(name).map(l => Option(Right(l)): Option[Either[String, Bate]]))
override val hookFish: Line => Stack[Fish] =
(line: Line) => new OptionT(hookFishImpl(line).map(l => Option(Right(l)): Option[Either[String, Fish]]))
val input: EitherT[Inner, String, String] = eitherTunitInner, String, String
val outerResult: Inner[Either[String, Fish]] = goFishing(input).value
val innerResult: Future[Option[Either[String, Fish]]] = outerResult.value
}
- 将释放捕获的鱼的动作添加到我们在本章中开发的自由单子示例中。
这里只显示了示例的更改部分。请参阅附带的代码,以查看包含更改的示例:
final case class ReleaseFishA extends Action[A]
def releaseFish(fish: Fish): Free[Action, Unit] = Join(ReleaseFish(fish, _ => Done(())))
implicit val actionFunctor: Functor[Action] = new Functor[Action] {
override def mapA, B(f: A => B): Action[B] = in match {
... // other actions as before
case ReleaseFish(fish, a) => ReleaseFish(fish, x => f(a(x)))
}
}
def catchFish(bateName: String): Free[Action, _] = for {
bate <- buyBate(bateName)
line <- castLine(bate)
fish <- hookFish(line)
_ <- releaseFish(fish)
} yield ()
def goFishingLoggingA: A = actions match {
... // the rest as in the chapter code
case Join(ReleaseFish(fish, f)) =>
goFishingLogging(f(()), log(s"Releasing the fish $fish"))
}
def goFishingAccA: List[AnyVal] = actions match {
...
// the rest as in the chapter code
case Join(ReleaseFish(fish, f)) =>
goFishingAcc(f(()), fish.copy(name = fish.name + " released") :: log)
}
我们需要扩展动作模型和函数,添加一个辅助提升方法,将额外的步骤添加到进程的定义中,并增强两个解释器以支持新的动作。
第十一章
- 请列举两种演员可以对其接收到的消息做出反应并改变自己的方式。
演员可以使用 var 字段来改变其内部状态。这是一种经典的对象导向方法。
另一种方法是使用上下文并围绕某个将成为新状态一部分的值进行成为操作。context.become 也可以用来完全改变演员的行为。这更是一种函数式方法,因为状态和行为实际上都是不可变的。
ActorRef的目的是什么?
ActorRef 提供了一种通过演员路径来寻址演员的方法。它还封装了一个演员的邮箱和调度器。Akka 中的演员通过 ActorReference 进行通信。
- 在官方文档中查找系统守护者的描述。它的主要目的是什么?
系统守护者的主要目的是监督系统级演员。它还用于确保适当的关闭顺序,以便在用户守护者终止之前,系统级演员对用户定义的演员可用。
- 描述使用 Akka FSM 的优缺点。
Akka FSM 允许将演员行为建模为状态机,定义这些状态之间的单独状态转换和数据。
Akka FSM 将业务逻辑与特定实现耦合,使得测试和调试变得困难。
- 以多少种方式可以访问另一个演员系统中的演员?描述它们。
在远程系统中访问 actor 有两种方式——远程部署和远程查找。通过远程部署,在远程系统中创建一个新的 actor。远程部署可以在代码中显式执行,或者通过提供部署配置来完成。远程查找允许使用与本地查找相同的方法来选择远程系统中的现有 actor。
- 为什么测试 actor 需要特殊的工具集?
Actors 具有高度的非确定性。actor 的状态是不可访问的。正确测试 actor 的唯一方法是通过向其发送消息并等待其响应。有时需要创建整个 actor 层次结构来完成此目的。
第十二章
Behavior[Tpe]定义的意义是什么?
Behavior[Tpe]明确指定了这个 actor 能够处理Tpe的子类型消息。通过递归,我们可以得出结论,返回的行为也将是Behavior[Tpe]。
- 如何在 actor 的行为中获取对调度器的访问?
调度程序可以通过行为构造函数Behaviors.withTimers访问。
- 描述 actor 可能被停止的可能方式。
父 actor 可以使用父 actor 的 actor 上下文来停止 actor:context.stop(child)。
actor 也可以通过返回相应的行为来停止自己:Behaviors.stopped。
如果 actor 的逻辑抛出了异常,并且为该 actor 定义的SupervisorStrategy是stop,actor 也可以被停止。
- 本地和集群接待员之间的区别是什么?
虽然有不同的实现方式,但对开发者来说没有明显的区别。
- 存在哪些监督可能性,以及它们是如何定义的?
有三种监督策略:停止、重启和恢复。它们通过使用Behaviors.supervise将监督行为包装在 actor 上来定义。
- 为什么应该谨慎使用 stashing?
当前的 stashing 实现将消息缓冲在内存中,可能导致OutOfMemory或StashOverflowException,具体取决于 stash 的大小。如果消息被移除,actor 将不会产生其他传入的消息,直到所有缓存的位都处理完毕,这可能会使其无响应。
- 在独立测试 actor 逻辑方面,有什么推荐的方法?
使用 BehaviorTestKit 提供的同步测试可以更好地测试 actor 逻辑的独立性。
第十三章
- 与“经典”流相关联的两个不同模式是什么?为什么它们有问题?
这两种模式是推送和拉取。在消费者速度较慢的情况下,推送可能会导致流元素丢失或内存溢出。在生产者速度较慢的情况下,拉取可能不理想,因为它可能导致阻塞或大量资源消耗。
- 为什么 Reactive Streams 被认为是在动态拉-推模式下工作?
Reactive Streams 引入了非阻塞背压的概念。消费者报告其需求,生产者根据这个需求批量推送数据。当消费者更快时,需求总是存在,因此生产者总是尽快推送数据。如果有一个比消费者更快的生产者,总是有数据可用,消费者一旦有需求就会立即拉取。流会自动在这些模式之间切换。
- Akka Stream 图的典型构建块是什么?
流是一个具有一个输入和一个输出的阶段。Fan-In 有多个输入和一个输出。Fan-Out 是相反的,有多个输出和一个输入。BidiFlow 代表双向流,有两个输入和两个输出。
- 如何将图转换为可运行的图?
通过将源和汇连接到图中,可以将一个图连接成一个可运行的图。
- 为什么将材料化作为一个独立的显式步骤的主要目标?
在材料化步骤之前,任何图都可以被视为流的蓝图,因此可以自由共享和重用。
- 描述应用不同监督策略的效果。
有三种不同的监督策略。
停止中断失败的处理阶段的流。失败会向下传播,取消会向上传播。
恢复丢弃当前元素并继续流处理。
重启丢弃当前元素,清理处理阶段的内部状态(通常是通过重新创建它),并继续流处理。
- 哪些主要抽象提供了 Akka Streams TestKit?为什么它们是有用的?
Akka Streams TestKit 提供的两个主要抽象是 TestSink 和 TestSource。它们允许在不同级别上控制和验证关于流流的假设,例如,在高消息级别或低 reactive-streams 级别。它们还使得可以使用一个漂亮的 DSL 来驱动测试,并形成关于结果期望。
第十四章
- 什么是数据库迁移?
数据库迁移(或模式迁移)是数据库模式更新的自动管理。模式更改是增量性的,通常是可逆的,并在数据库模式需要更改以反映应用程序代码更改的时刻应用。
- 描述在库存不足的情况下,完全丢弃订单的替代方法可能是什么?
一种可能的替代方案是满足所有有足够库存的文章的订单。这可以通过在每个库存更新中运行单独的事务并组合所有成功的事务的结果来实现。
另一种可能的替代方案是尽可能满足订单。这种方法需要选择更新行,计算新的可能状态,并在同一事务中应用它们。
- 描述 http4s 和 Akka HTTP 在定义路由方面的概念差异。
http4s 将路由定义为部分函数,该函数通过请求进行模式匹配。Akka HTTP 路由定义是由嵌套指令构建的。请求通过匹配指令自顶向下遍历路径。
- 你能说出为什么事件源数据存储可以比传统的关系型数据库扩展得更好吗?
并发更新比追加操作需要更多的锁定和同步。
-
使用 http4s 和 doobie 实现
GET /articles/:name调用。 -
添加新的路由定义:
case GET -> Root / "articles" / name => renderInventory(repo.getArticle(name))
- 扩展
getArticle方法:
def getArticle(name: String): Stream[IO, Inventory] =
sql"SELECT name, count FROM article where name = $name"
.query[(String, Int)].stream.transact(transactor)
.fold(Map.empty[String, Int])(_ + _)
在 GitHub 上查看重构版本的源代码,该版本重用了getInventory的无参数定义。
-
使用 Akka HTTP 和 Akka Persistence 实现
GET /articles/:name调用。 -
添加新的查询定义:
final case class GetArticle(name: String) extends Query
- 在
InventoryActor中添加查询处理器:
case GetArticle(name) =>
sender() ! Inventory(inventory.state.filter(_._1 == name))
- 添加路由定义:
pathPrefix("articles") {
path(Segment) { name =>
get {
complete((inventory ? GetArticle(name)).mapTo[Inventory])
}
}
}
GitHub 仓库包含此路由定义,它嵌入在先前定义的lazy val articlesRoutes: Route中。
第十五章
- 如何将带有查询参数的端点映射到 REST 调用?
def answer(parameter: Int): ServiceCall[NotUsed, Done]
override def descriptor: Descriptor = {
import Service._
named("Answer").withCalls(
restCall(Method.POST, "/answer?parameter", answer _)
)
}
- 推荐用于持久化实体的序列化格式是什么?
Lagom 推荐使用 JSON 作为序列化格式。
- 你能解释为什么在 Lagom 中使用持久化需要集群吗?
Lagom 的持久化是在 Akka 持久化之上实现的。Akka 要求每个持久化 actor 都有一个唯一的持久化 ID。在微服务领域中,每个服务应该同时拥有多个实例。如果没有集群,将会有多个具有相同 ID 的持久化 actor 将事件存储到同一个数据库中,这将损坏数据。通过利用集群和集群分片,Akka 确保在服务的所有实例中集群中只有一个持久化 actor。
- 描述一个可能的数据模型,该模型可用于将
Manager转换为持久化实体。
trait ManagerCommand
final case class AddCookies(count: Int) extends ManagerCommand with ReplyType[Int]
final case class RemoveCookies(count: Int) extends ManagerCommand with ReplyType[Int]
trait ManagerEvent
final case class NumberOfCookiesChanged(count: Int) extends ManagerEvent with AggregateEvent[NumberOfCookiesChanged] {
override def aggregateTag: AggregateEventTag[NumberOfCookiesChanged] = AggregateEventTagNumberOfCookiesChanged
}
sealed trait ManagerState {
def cookies: Int
}
final case class MixingState(cookies: Int) extends ManagerState
- 概述实现 Baker 服务的另一种方法。
Baker服务也可以实现类似于Chef服务的信息传递风格。
- 你能识别出 Chef 当前实现中的设计缺陷吗?
Chef在恢复后不会触发不平衡混合事件的混合。
Manager实现将多个 cookie 存储在内存中,而这个数字在服务重启的瞬间将会丢失。你能说出另一个为什么将 cookie 的数量保存在局部变量中不是一个好主意的原因吗?
在生产环境中,将有多个服务实例运行。每个实例都将有自己的内部状态。


浙公网安备 33010602011771号