Scala-设计模式-全-
Scala 设计模式(全)
原文:
zh.annas-archive.org/md5/483c7d8caeecdab1dabbbc736910bbe2译者:飞龙
前言
软件工程和设计已经存在了许多年。我们在生活的方方面面都使用软件,这使得程序在解决问题的方面具有独特性。
无论可以用编程做什么,仍然有一些特定的特性会反复出现。随着时间的推移,人们已经提出了一些最佳实践,有助于解决程序中出现的特定模式。这些被称为设计模式。
设计模式不仅解决了常见问题,还处理了语言限制。无论具体的设计模式是什么以及它们解决的单个问题是什么,最终它们的目标都是生产出更好的软件。这包括提高可读性、简单性、易于维护性、可测试性、可扩展性和效率。如今,设计模式是每位优秀软件工程师工具箱中的重要组成部分。
除了我们用编程解决的问题的大量问题之外,还有许多我们可以使用的语言。每种语言都不同,有其优势和劣势,因此我们在做事情时也需要考虑这一点。在这本书中,我们将从 Scala 的角度来探讨设计模式。
在过去几年中,Scala 变得极为流行,使用它的人数也在不断增长。许多公司出于各种目的在生产中使用它——大数据处理、编写 API、机器学习等等。从像 Java 这样的流行语言切换到 Scala 实际上非常简单,因为它是一种面向对象语言和函数式编程语言的混合体。然而,要充分发挥 Scala 的潜力,我们需要熟悉不仅面向对象的特性,还要熟悉函数式特性。Scala 的使用可以提高性能和实现特性的时间。其中一个原因是 Scala 的高度可表达性。
Scala 接近面向对象语言的事实意味着许多面向对象编程的设计模式在这里仍然适用。它也是函数式编程的事实意味着一些其他的设计模式也适用,一些面向对象的模式可能需要修改以更好地适应 Scala 的范式。在这本书中,我们将关注所有这些——我们将探讨 Scala 的一些特定特性,然后从 Scala 的角度来看待流行的四人组设计模式。我们还将熟悉 Scala 特有的设计模式,并理解不同的函数式编程概念,包括幺半群和单子。有意义的例子总是使学习和理解更容易。我们将尝试提供你可以轻松映射到你可能会解决的实际问题的例子。我们还将介绍一些对编写现实世界应用程序的人有用的库。
这本书面向的对象是谁
本书面向已经对 Scala 有一定了解,但希望更深入地了解如何在实际应用开发中应用它的人。本书在应用设计时作为参考也很有用。理解使用最佳实践和编写良好代码的重要性是好的;然而,即使你没有,希望你在阅读完这本书后能被说服。不需要具备设计模式的知识,但如果你熟悉一些,这本书将很有用,因为我们将从 Scala 的角度来探讨它们。
为了充分利用本书
本书假设读者已经熟悉 Scala。我们为每个章节提供了使用 Maven 和 SBT 的项目示例。你应该对这两种工具中的任何一种有所了解,并且已经将其安装在您的机器上。我们还建议您在计算机上安装一个现代且最新的 IDE,例如 IntelliJ。我们鼓励您打开实际的项目,因为本书的示例专注于设计模式,在某些情况下,为了节省空间,省略了导入。
书中的示例是在基于 Unix 的操作系统上编写和测试的;然而,它们也应该能够在 Windows 上成功编译和运行。
下载示例代码文件
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com上登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书籍名称,并遵循屏幕上的说明。
文件下载完成后,请确保使用最新版本的以下软件解压或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
本书代码包也托管在 GitHub 上,地址为github.com/PacktPublishing/Scala-Design-Patterns-Second-Edition。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富的书籍和视频目录的代码包可供选择,请访问 github.com/PacktPublishing/。查看它们吧!
使用的约定
本书使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“使用extends指定基类,然后使用with关键字添加所有特质。”
代码块设置如下:
class MultiplierIdentity {
def identity: Int = 1
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
Error:(11, 8) object Clashing inherits conflicting members:
method hello in trait A of type ()String and
method hello in trait B of type ()String
(Note: this can be resolved by declaring an override in object Clashing.)
object Clashing extends A with B {
^
任何命令行输入或输出都应如下所示:
Result 1: 6
Result 2: 2
Result 3: 6
Result 4: 6
Result 5: 6
Result 6: 3
粗体:表示新术语、重要单词或屏幕上出现的单词。
警告或重要注意事项看起来像这样。
小贴士和技巧看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请将邮件发送至 feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请通过 questions@packtpub.com 发送邮件给我们。
勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告这一错误。请访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上以任何形式发现我们作品的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,请通过 copyright@packtpub.com 与我们联系,并提供材料的链接。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且有兴趣撰写或参与一本书籍,请访问 authors.packtpub.com.
评论
请留下评论。一旦您阅读并使用了这本书,为何不在购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com.
第一章:现有的设计模式和设置您的环境
在计算机编程的世界里,有多种方式来解决给定的问题。然而,有些人可能会想知道是否有一种正确的方法来实现特定的任务。答案是肯定的;总有一种正确的方法,但在软件开发中,通常有多种正确的方法来实现任务。存在一些因素可以指导程序员找到正确的解决方案,并且根据这些因素,人们往往能够得到预期的结果。这些因素可以定义许多事情——实际使用的语言、算法、生成的可执行文件类型、输出格式以及代码结构。在这本书中,语言已经为我们选择了——Scala。然而,使用 Scala 的方法有很多,我们将重点关注它们——设计模式。
在本章中,我们将解释什么是设计模式以及它们为什么存在。我们将介绍现有的不同类型的设计模式。本书旨在提供有用的示例,以帮助您在学习过程中,能够轻松运行它们是关键。因此,这里将给出一些关于如何正确设置开发环境的要点。我们将讨论的顶级主题如下:
-
什么是设计模式以及它们为什么存在?
-
设计模式的主要类型及其特点
-
选择合适的设计模式
-
在现实生活中设置开发环境
最后一点与设计模式关系不大。然而,始终正确地构建项目是一个好主意,因为这会使未来的工作变得容易得多。
设计模式
在深入研究 Scala 设计模式之前,我们必须解释它们实际上是什么,为什么它们存在,以及为什么熟悉它们是值得的。
软件是一个广泛的领域,人们可以用它做无数的事情。乍一看,这些事情大多数都是完全不同的——游戏、网站、手机应用程序以及为不同行业定制的系统。然而,在软件构建方面有许多相似之处。很多时候,无论他们创建的软件类型如何,人们都必须处理类似的问题。例如,计算机游戏和网站可能需要访问数据库。随着时间的推移,通过经验,开发者了解到他们如何根据他们执行的各种任务来结构化他们的代码。
设计模式的正式定义
设计模式是对软件设计中反复出现的问题的可重用解决方案。它不是一个完整的代码片段,而是一个模板,有助于解决特定问题或一系列问题。
设计模式是软件社区经过一段时间积累的最佳实践。它们旨在帮助你编写高效、可读、可测试且易于扩展的代码。在某些情况下,它们可能是由于编程语言表达能力不足而无法优雅地实现目标的结果。这意味着功能更丰富的语言可能甚至不需要设计模式,而其他语言仍然需要。Scala 就是这些丰富语言之一,在某些情况下,它使得某些设计模式变得过时或更简单。我们将在本书中看到它是如何做到这一点的。
编程语言中是否存在某种功能,也使得它能够实现其他语言无法实现的设计模式。反之亦然——它可能无法实现其他语言可以实现的事情。
Scala 和设计模式
Scala 是一种混合语言,它结合了面向对象和函数式语言的特点。这不仅使它能够保持一些已知的面向对象设计模式的相关性,而且还提供了各种其他方式来利用其特性,以编写既干净、高效、可测试又可扩展的代码。语言的混合特性也使得一些传统的面向对象设计模式变得过时,或者可以通过其他更干净的技术来实现。
需要设计模式和它们的益处
在没有有意识地使用设计模式的情况下编写代码是许多软件工程师的做法。然而,最终,他们要么在不经意间使用了某个模式,要么得到的代码在某些方面可以改进。正如我们之前提到的,设计模式有助于编写高效、可读、可扩展和可测试的代码。所有这些特性对于行业中的公司来说都非常重要。
尽管在某些情况下,快速编写原型并尽快推出可能更可取,但通常情况下,软件应该会演变。也许你会有扩展一些编写糟糕的代码的经验,但无论如何,这都是一项具有挑战性的任务,需要花费很长时间,有时甚至觉得重写它会更简单。此外,这也使得向系统中引入错误的可能性大大增加。
代码可读性也是应该被重视的一点。当然,即使使用设计模式,代码也可能难以阅读,但通常来说,设计模式是有帮助的。大型系统通常由许多人共同开发,每个人都应该能够理解正在发生的事情。此外,如果团队成员在编写一个编写良好的软件项目,他们能够更容易、更快地融入团队。
可测试性是防止开发者在编写或扩展代码时引入错误的一个因素。在某些情况下,代码可能编写得如此糟糕,以至于根本无法进行测试。设计模式旨在消除这些问题。
虽然效率通常与算法相关联,但设计模式也可能影响效率。一个简单的例子是一个需要很长时间才能实例化的对象,在应用程序的许多地方使用实例,但可以将其改为单例。你将在本书的后续章节中看到更多具体的例子。
设计模式类别
软件开发是一个极其广泛的话题,这导致了许多可以用编程来完成的事情。不同行业和工程团队的需求可能会有很大差异。这些事实导致了众多不同设计模式的发明。此外,存在各种具有不同特性和表达能力的编程语言也进一步促进了这一点。
本书从 Scala 的角度关注设计模式。正如我们之前提到的,Scala 是一种混合语言。这导致了一些著名的设计模式不再需要——一个例子是 null 对象设计模式,它可以简单地用 Scala 的Option替换。其他设计模式可以通过不同的方法实现——装饰者设计模式可以使用可堆叠特质实现。最后,一些新的设计模式变得可用,这些模式专门适用于 Scala 编程语言——如蛋糕设计模式、pimp my library 等。我们将关注所有这些,并清楚地说明 Scala 的丰富性如何帮助我们使代码更加简洁和简单。
即使有众多不同的设计模式,它们都可以归纳为以下几类:
-
创建型
-
结构型
-
行为型
-
函数式
-
Scala 特定设计模式
一些特定于 Scala 的设计模式可以被分配到之前的组中。它们可以是现有模式的补充或替代。它们是 Scala 的典型代表,利用了一些高级语言特性或仅在其他语言中不可用的特性。
前三个组包含著名的四人帮设计模式。每本设计模式书籍都会涵盖它们,我们也不例外。其余的,即使它们可以被分配到前三个组中,也将是 Scala 和函数式编程语言的特定模式。在接下来的几个小节中,我们将解释所列组的特征,并简要介绍属于它们的设计模式。
创建型设计模式
创建型设计模式处理对象创建机制。它们的目的是以适合当前情况的方式创建对象,如果没有这些模式,可能会导致不必要的复杂性和额外知识的需求。创建型设计模式背后的主要思想如下:
-
关于具体类的知识封装
-
隐藏实际创建和对象组合的细节
在本书中,我们将重点关注以下创建型设计模式:
-
抽象工厂设计模式
-
工厂方法设计模式
-
懒加载初始化设计模式
-
单例设计模式
-
对象池设计模式
-
构建者设计模式
-
原型设计模式
以下几节简要定义了这些模式是什么。它们将在本书稍后的部分进行深入探讨。
抽象工厂设计模式
这用于封装具有共同主题的一组单独的工厂。当使用时,开发者创建抽象工厂的具体实现,并像在工厂设计模式中一样使用其方法来创建对象。它可以被视为另一层抽象,有助于实例化类。
工厂方法设计模式
这种设计模式处理对象的创建,而不需要显式指定实例将具有的实际类——它可能是基于许多因素在运行时决定的某物。这些因素可能包括操作系统、不同的数据类型或输入参数。它给开发者带来了只需调用方法而不是调用具体构造函数的安心。
懒加载初始化设计模式
这种设计模式是一种延迟创建对象或评估值的方法,直到第一次需要时。在 Scala 中,它比在 Java 这样的面向对象语言中要简单得多。
单例设计模式
这种设计模式将特定类的创建限制为仅一个对象。如果应用程序中的多个类试图使用此类实例,则返回相同的实例给所有人。这是另一种可以通过使用基本 Scala 特性轻松实现的设计模式。
对象池设计模式
这种设计模式使用一个已经实例化并准备好使用的对象池。每当有人需要池中的对象时,它就会被返回,用户使用完毕后,它会手动或自动将其放回池中。池的常见用途是数据库连接,通常创建成本高昂;因此,它们一旦创建,就会在请求时提供给应用程序。
构建者设计模式
构建者设计模式对于具有许多可能构造参数的对象来说极其有用,否则开发者需要为对象可能创建的不同场景创建许多重写。这与工厂设计模式不同,工厂设计模式旨在实现多态。许多现代库今天都采用这种设计模式。正如我们稍后将会看到的,Scala 可以非常容易地实现这种模式。
原型设计模式
这种设计模式允许使用已创建实例的clone()方法进行对象创建。在创建特定资源成本高昂或不需要抽象工厂模式的情况下,可以使用它。
结构化设计模式
结构设计模式的存在是为了帮助建立不同实体之间的关系,以便形成更大的结构。它们定义了每个组件应该如何构建,以便它具有非常灵活的互联模块,可以在更大的系统中协同工作。结构设计模式的主要特点包括以下内容:
-
使用组合来组合多个对象的实现
-
通过保持高度的灵活性,帮助构建由各种组件组成的大型系统
在这本书中,我们将重点关注以下结构设计模式:
-
适配器设计模式
-
装饰器设计模式
-
桥接设计模式
-
组合设计模式
-
门面设计模式
-
享元设计模式
-
代理设计模式
在我们在这本书的后续部分深入探讨这些模式之前,下一小节将简要介绍这些模式的内容。
适配器设计模式
适配器设计模式允许使用另一个接口使用现有类的接口。想象一下,有一个客户端期望你的类公开一个doWork()方法。你可能已经在另一个类中准备好了实现,但方法的调用方式不同且不兼容。可能还需要额外的参数。这也可能是一个开发者无法修改的库。这就是适配器通过包装功能并公开所需方法来帮助的地方。适配器对于集成现有组件非常有用。在 Scala 中,可以使用隐式类轻松实现适配器设计模式。
装饰器设计模式
装饰器是子类化的灵活替代方案。它们允许开发者在不影响同一类其他实例的情况下扩展对象的功能。这是通过将扩展类的对象包装在一个扩展相同类并覆盖那些需要改变功能的方法的对象中实现的。在 Scala 中,可以使用另一个称为可堆叠特质的设计模式来更轻松地构建装饰器。
桥接设计模式
桥接设计模式的目的在于将抽象与其实现解耦,以便两者可以独立变化。当类及其功能变化很大时,它很有用。桥接模式让我们想起了适配器模式,但不同之处在于适配器模式用于当某物已经存在且无法更改时,而桥接设计模式用于构建事物时。它帮助我们避免最终产生多个将暴露给客户端的具体类。当我们更深入地探讨这个主题时,你会得到更清晰的理解,但到目前为止,让我们想象我们想要有一个支持多个不同平台的FileReader类。桥接模式将帮助我们得到FileReader,它将根据平台使用不同的实现。在 Scala 中,我们可以使用自类型来实现桥接设计模式。
组合设计模式
组合是一种分区设计模式,它表示一组将被视为单个对象的对象。它允许开发者以统一的方式处理单个对象和组合,并在不复杂化源代码的情况下构建复杂的层次结构。组合的一个例子可以是树结构,其中节点可以包含其他节点,依此类推。
门面设计模式
门面设计模式的目的在于通过向客户端提供一个更简单的接口来使用,从而隐藏系统的复杂性和其实施细节。这也帮助代码更易于阅读,并减少外部代码的依赖。它作为简化系统的包装器,当然,它可以与其他之前提到的设计模式一起使用。
享元设计模式
享元设计模式提供了一个对象,该对象通过在整个应用程序中共享来最小化内存使用。这个对象应该包含尽可能多的数据。一个常见的例子是文字处理器,其中每个字符的图形表示都与其他相同的字符共享。局部信息只是字符的位置,该位置在内部存储。
代理设计模式
代理设计模式允许开发者通过包装其他对象来为它们提供接口。他们还可以提供额外的功能,例如安全性或线程安全性。代理可以与享元模式一起使用,其中对共享对象的引用被包装在代理对象内部。
行为设计模式
行为设计模式通过基于对象之间相互交互的具体方式来增加对象之间的通信灵活性。在这里,创建型模式主要描述创建过程中的一个时刻,结构型模式描述一个更多或更少的静态结构,而行为模式描述一个过程或流程。它们简化了这个流程,使其更容易理解。
行为设计模式的主要特点如下:
-
所描述的是一种过程或流程
-
流程被简化并变得易于理解
-
它们完成那些用对象难以或无法实现的任务
在这本书中,我们将关注以下行为设计模式:
-
值对象设计模式
-
空对象设计模式
-
策略设计模式
-
命令设计模式
-
责任链设计模式
-
解释器设计模式
-
迭代器设计模式
-
中介者设计模式
-
备忘录设计模式
-
观察者设计模式
-
状态设计模式
-
模板方法设计模式
-
访问者设计模式
以下小节将简要定义上述行为设计模式。
值对象设计模式
值对象是不可变的,它们的相等性不是基于它们的身份,而是基于它们的字段相等。它们可以用作数据传输对象,并且可以代表日期、颜色、金额、数字等。它们的不可变性使它们在多线程编程中非常有用。Scala 编程语言推崇不可变性,值对象是那里自然发生的事物。
空对象设计模式
空对象代表值的缺失,并且它们定义了一种中立的行为。这种方法消除了检查null引用的需要,并使代码更加简洁。Scala 添加了可选值的概念,这可以完全替代此模式。
策略设计模式
策略设计模式允许在运行时选择算法。它定义了一组可互换的封装算法,并向客户端提供了一个公共接口。所选择的算法可能取决于在应用程序运行时确定的多种因素。在 Scala 中,我们可以简单地将一个函数作为参数传递给一个方法,根据函数的不同,将执行不同的操作。
命令设计模式
此设计模式代表一个用于存储有关需要在以后某个时间触发的动作信息的对象。信息包括以下内容:
-
方法名称
-
方法的所有者
-
参数值
客户端随后决定由调用者执行哪些命令以及何时执行。这种设计模式可以很容易地使用 Scala 语言中按名称传递参数的功能实现。
责任链设计模式
职责链模式是一种设计模式,其中请求的发送者与其接收者解耦。这样,它使得多个对象能够处理请求,并保持逻辑的清晰分离。接收者形成一个链,它们传递请求,如果可能的话,处理它,如果不可以,则将其传递给下一个接收者。在某些变体中,处理程序可能会同时将请求分发给多个其他处理程序。这多少让我们想起了函数组合,在 Scala 中可以通过可堆叠特性设计模式来实现。
解释器设计模式
解释器设计模式基于使用具有严格语法的语言来表征已知领域的能力。它为每个语法规则定义类,以便解释给定语言中的句子。这些类很可能会表示层次结构,因为语法通常是分层的。解释器可以用于不同的解析器,例如 SQL 或其他语言。
迭代器设计模式
迭代器设计模式是指使用迭代器遍历容器并访问其元素的情况。它有助于将容器与其上执行的操作解耦。迭代器应提供对聚合对象元素的顺序访问,而不暴露迭代集合的内部表示。
中介者设计模式
此模式封装了应用程序中不同类之间的通信。不是直接相互交互,对象通过中介进行通信,这减少了它们之间的依赖性,降低了耦合度,并使得整个应用程序更容易阅读和维护。
备忘录设计模式
此模式提供了将对象回滚到其先前状态的能力。它通过三个对象实现——发起者、保管者和备忘录。发起者是具有内部状态的对象;保管者将修改发起者,而备忘录是一个包含发起者返回的状态的对象。发起者知道如何处理备忘录以恢复其先前状态。
观察者设计模式
此设计模式允许创建发布/订阅系统。有一个特殊对象称为主题,当状态有任何变化时,它会自动通知所有观察者。这种设计模式在各种 GUI 工具包中很受欢迎,通常在需要事件处理的地方。它也与响应式编程相关,响应式编程由如 Akka 之类的库启用。我们将在本书的末尾看到一个例子。
状态设计模式
这种设计模式类似于策略设计模式,它使用一个状态对象来封装同一对象的不同的行为。通过避免使用大型条件语句,它提高了代码的可读性和可维护性。
模板方法设计模式
此设计模式在方法中定义算法的框架,然后将一些实际步骤传递给子类。它允许开发者在不修改其结构的情况下更改算法的一些步骤。一个例子可能是抽象类中的方法调用其他抽象方法,这些方法将在子类中定义。
访问者设计模式
访问者设计模式表示对对象结构中的元素执行的操作。它允许开发者定义一个新操作,而无需更改原始类。Scala 通过将函数传递给方法,可以比纯面向对象方式实现此模式时最小化其冗长性。
函数式设计模式
我们将从 Scala 的角度审视所有前面的设计模式。这意味着它们在其他语言中看起来会不同,但它们还没有被专门设计用于函数式编程。函数式编程比面向对象编程更具表现力。它有自己的设计模式,有助于使程序员的编程生活更轻松。我们将重点关注:
-
摩纳哥
-
摩纳哥
-
函子
在我们审视了一些 Scala 函数式编程概念,并且已经了解了这些概念之后,我们将提到一些来自 Scala 世界中的有趣设计模式。
在接下来的几个小节中,将对前面列出的模式进行简要说明。
摩纳哥
摩纳哥是一个来自数学的概念。我们将在本书后面部分详细探讨它,包括理解它所需的所有理论。现在,只需记住,摩纳哥是一个具有单个关联二进制运算和单位元素的代数结构。以下是你应该记住的关键词:
-
联合二进制运算。这意味着
(a+b)+c = a+(b+c)。 -
单位元素。这意味着
a+i = i+a = a。在这里,单位是i。
摩纳哥的重要之处在于它们为我们提供了以相同方式处理许多不同类型值的可能性。它们允许我们将成对操作转换为与序列一起工作;结合性为我们提供了并行化的可能性,而单位元素允许我们知道如何处理空列表。摩纳哥非常适合轻松描述和实现聚合。
摩纳哥
在函数式编程中,摩纳哥是表示计算为一系列步骤的结构。摩纳哥对于构建管道、干净地添加副作用操作到一切都是不可变的语言中非常有用,并且用于实现组合。这个定义可能听起来模糊不清,但用几句话解释摩纳哥似乎是一项艰巨的任务。在本书的后面部分,我们将专注于它们,并尝试在不使用复杂数学理论的情况下澄清问题。我们将尝试展示摩纳哥为什么有用以及它们能帮助开发者做什么,只要开发者对它们有很好的理解。
函子
拟人来自范畴论,至于单子,解释它们需要时间。我们将在本书的后面部分查看拟人。现在,你可以记住,拟人是一些可以让我们将类型A => B的函数提升到类型F[A] => F[B]的函数的东西。
Scala 特定的设计模式
这个组中的设计模式可以被分配到一些之前的组中。然而,它们是特定于 Scala 的,并利用了我们在本书中将要关注的某些语言特性,因此我们决定将它们放在自己的组中。
我们将关注以下内容:
-
镜头设计模式
-
蛋糕设计模式
-
优化我的库
-
可堆叠特性
-
类型类设计模式
-
延迟评估
-
部分函数
-
隐式注入
-
鸭式编程
-
缓存
在我们稍后在本书中详细研究这些模式之前,以下小节将为您提供一些关于这些模式的简要信息。
镜头设计模式
Scala 编程语言提倡不可变性。使对象不可变使得犯错误更难。然而,有时需要可变性,而镜头设计模式帮助我们很好地实现这一点。
蛋糕设计模式
蛋糕设计模式是 Scala 实现依赖注入的方式。这在现实生活中的应用中相当常见,有许多库帮助开发者实现它。Scala 有一种使用语言特性来实现它的方法,这就是蛋糕设计模式的主要内容。
优化我的库
许多时候,工程师需要与库一起工作,这些库被设计得尽可能通用。有时,我们需要对我们自己的用例做更多具体的事情。优化我的库设计模式提供了一种为无法修改的库编写扩展方法的方式。我们也可以用它来为我们自己的库编写。这种设计模式还有助于提高代码的可读性。
可堆叠特性
可堆叠特性是 Scala 实现装饰器设计模式的方式。它也可以用来组合函数,并且它基于一些高级的 Scala 特性。
类型类设计模式
这种设计模式允许我们通过定义必须由特定类型类的所有成员支持的行为来编写通用代码。例如,所有数字都必须支持加法和减法操作。
延迟评估
通常,工程师必须处理一些慢速和/或昂贵的操作。有时,这些操作的结果甚至可能不需要。延迟评估是一种将操作执行推迟到实际需要的技术的技术。它可以用于应用程序优化。
部分函数
数学与函数式编程非常接近。因此,一些函数只为所有可能输入值的一个子集定义。一个流行的例子是平方根函数,它只对非负数有效。在 Scala 中,这样的函数可以用来高效地同时执行多个操作或组合函数。
隐式注入
隐式注入基于 Scala 编程语言的隐式功能。只要对象存在于特定的作用域中,它们就会在需要时自动注入。它可以用于许多事情,包括依赖注入。
鸭式类型
这是一个在 Scala 中可用且与某些动态语言提供的类似的功能。它允许开发者编写需要调用者具有某些特定方法(但不需要实现接口)的代码。当有人使用鸭式类型的函数时,实际上在编译时会检查参数是否有效。
缓存
这个设计模式通过记住基于输入的函数结果来帮助优化。这意味着只要函数是稳定的,并且在传递相同的参数时返回相同的结果,就可以记住其结果,并在每次连续相同的调用中简单地返回它们。
选择设计模式
正如我们之前看到的,有大量的设计模式。在许多情况下,它们可以组合使用。不幸的是,关于如何选择设计代码的概念,并没有一个确定的答案。有许多因素可能会影响最终的决定,你应该问自己以下问题:
-
这段代码将会相对静态,还是将来会发生变化?
-
我们需要动态决定使用哪些算法吗?
-
我们的代码会被其他人使用吗?
-
我们有一个共同同意的接口吗?
-
如果有的话,我们计划使用哪些库?
-
是否有任何特殊的性能要求或限制?
这绝对不是问题的完整列表。有大量的因素可能会决定我们构建系统的方式。然而,有一个清晰的规范是非常重要的,如果似乎有什么缺失,应该首先检查。
在接下来的章节中,我们将尝试给出具体建议,关于何时应该和不应该使用设计模式。它们应该帮助你提出正确的问题,并在编写代码之前做出正确的决定。
设置开发环境
本书旨在为您提供可运行的代码示例,以便您进行实验。除了在本书的页面中展示最重要的代码片段外,您还可以通过 Packt Publishing 以及 GitHub 获取代码,以便您方便使用。仓库可以在 github.com/nikolovivan/scala-design-patterns-v2 找到。
拥有代码示例意味着能够轻松运行我们提供的任何示例非常重要,并且不要与代码发生冲突。我们将尽力确保代码经过测试并正确打包,但您也应该确保您拥有运行示例所需的一切。
安装 Scala
当然,您需要 Scala 编程语言。它需要安装 Java,发展迅速,最新版本可以在 www.scala-lang.org/download/ 找到。安装语言有多种方式,您可以选择最适合您的方式。有关如何在您的操作系统上安装语言的几个提示可以在 www.scala-lang.org/download/install.html 找到。正如官方 Scala 网站所建议的,最简单的方法是下载一个 IDE(例如 IntelliJ),安装 Scala 插件,它将为您设置一切。我将提供一些在我的职业生涯中证明有用的提示,这些提示使我能够在实验和学习时非常灵活。
手动安装 Scala 的提示
您始终可以下载多个 Scala 版本并对其进行实验。我使用 Linux,我的提示也适用于 Mac OS 用户。Windows 用户也可以进行类似的设置。以下是步骤:
-
在
/opt/scala-{version}/或您喜欢的任何路径下安装 Scala。 -
使用此命令创建一个符号链接:
sudo ln -s /opt/scala-{version} scala-current。如果您决定进行实验,这可以使版本切换变得容易得多。 -
使用以下命令将 Scala 的
bin文件夹路径添加到您的.bashrc(或等效)文件中:
-
export SCALA_HOME=/opt/scala-current -
export PATH=$PATH:$SCALA_HOME/bin
现在,如果您已经定义了一个符号链接,并且您决定安装另一个 Scala 版本,您可以简单地重新定义现有的符号链接,然后继续您的工作。
如果您不想手动安装 Scala 或者您发现您经常切换到不同版本的编程语言,SBT 可能是一个更舒适的选择。
使用 SBT 安装 Scala 的提示
您还可以使用 SBT 尝试任何 Scala 版本。为此,您应该:
-
下载并安装 SBT:
www.scala-sbt.org/download.html。 -
打开终端并运行
sbt。 -
在 SBT 壳中,输入
++ 2.12.4或你想要尝试的任何版本。请注意,如果当前使用的 Scala 版本与你要使用的版本不兼容,你必须修改命令为以下形式——++ 2.12.4!。二进制兼容性在 Scala 中非常重要,你应该确保它们使用与它们相同的 Scala 版本编写的库。否则,你可能会遇到麻烦。 -
输入
console命令,你将进入一个运行你选择的版本的 Scala 壳。
本书中的所有示例都使用 SBT 或 Maven(根据你的偏好)。它们是构建和依赖管理工具,这意味着你可能甚至不需要做任何额外的事情来安装 Scala。你只需导入一个示例项目,所有的事情都会自动处理。
Scala 集成开发环境(IDE)
现在有多个 IDE 支持 Scala 的开发。在用于与代码一起工作的 IDE 方面,没有任何偏好。以下是一些最受欢迎的 IDE:
-
IntelliJ
-
Eclipse
-
NetBeans
IntelliJ 目前是 Scala 网站上推荐的,也可能是写作时最常用的 IDE。所有这些 IDE 都使用插件来与 Scala 一起工作,下载和使用它们应该是直接的。
依赖管理
在本书中运行大多数示例不需要任何特殊库的额外依赖。然而,在某些情况下,我们可能需要展示如何对 Scala 代码进行单元测试,这需要我们使用测试框架。此外,我们还将展示一些实际使用案例,其中使用了额外的库。如今,处理依赖通常使用专门的工具。它们通常是可互换的,使用哪个工具是个人选择。与 Scala 项目一起使用最流行的工具是 SBT,但 Maven 也是一个选择,还有许多其他工具。前者通常在项目从头开始且 Scala 是主要编程语言时使用。后者在主要使用的语言是 Java,例如,并且我们想要添加用 Scala 编写的模块时可能很有用。
现代 IDE 提供了生成所需构建配置文件的功能,但我们将提供一些通用的示例,这些示例不仅在这里有用,而且在未来的项目中也可能有用。根据你偏好的 IDE,你可能需要安装一些额外的插件才能使一切正常运行,快速进行 Google 搜索应该会有所帮助。
SBT
SBT代表简单构建工具,它使用 Scala 语法来定义项目如何构建、管理依赖等。它使用.sbt文件来完成这个目的。它还支持基于.scala文件的 Scala 代码的设置,以及两者的混合使用。
要下载 SBT,请访问 www.scala-sbt.org/1.0/docs/Setup.html 并按照说明操作。如果您希望获取最新版本,只需在 Google 上搜索并使用返回的结果即可。
以下截图显示了骨架 SBT 项目的结构:

显示主 .sbt 文件的内容是很重要的。
version.sbt 文件如下所示:
version in ThisBuild := "1.0.0-SNAPSHOT"
它包含当前版本,如果发布新版本,则该版本会自动递增。
assembly.sbt 文件的内容如下:
assemblyMergeStrategy in assembly := {
case PathList("javax", "servlet", xs @ _*) => MergeStrategy.first
case PathList(ps @ _*) if ps.last endsWith ".html" => MergeStrategy.first
case "application.conf" => MergeStrategy.concat
case "unwanted.txt" => MergeStrategy.discard
case x =>
val oldStrategy = (assemblyMergeStrategy in assembly).value
oldStrategy(x)
}
assemblyJarName in assembly := { s"${name.value}_${scalaVersion.value}-${version.value}-assembly.jar" }
artifact in (Compile, assembly) := {
val art = (artifact in (Compile, assembly)).value
art.withClassifier(Some("assembly"))
}
addArtifact(artifact in (Compile, assembly), assembly)
它包含有关如何构建汇编 JAR 的信息——合并策略、最终 JAR 名称等。它使用一个名为 sbtassembly 的插件(github.com/sbt/sbt-assembly)。
build.sbt 文件是包含项目依赖、一些关于编译器的额外信息和元数据的文件。骨架文件如下所示:
organization := "com.ivan.nikolov"
name := "skeleton-sbt"
scalaVersion := "2.12.4"
scalacOptions := Seq("-unchecked", "-deprecation", "-encoding", "utf8")
javaOptions ++= Seq("-target", "1.8", "-source", "1.8")
publishMavenStyle := true
libraryDependencies ++= {
val sparkVersion = "2.2.0"
Seq(
"org.apache.spark" % "spark-core_2.11" % sparkVersion % "provided",
"com.datastax.spark" % "spark-cassandra-connector_2.11" % "2.0.5",
"org.scalatest" %% "scalatest" % "3.0.4" % "test",
"org.mockito" % "mockito-all" % "1.10.19" % "test" // mockito for tests
)
}
如您所见,这里我们定义了编译某些清单信息和库依赖项所针对的 Java 版本。
我们项目的依赖项定义在我们 SBT 文件的 libraryDependencies 部分中。它们具有以下格式:
"groupId" %[%] "artifactId" % "version" [% "scope"]
如果我们决定用 %% 而不是 % 将 groupId 和 artifactId 分隔开,SBT 将自动使用 scalaVersion 并将 _2.12(对于 Scala 2.12.*)附加到 artifactId 上。这种语法通常用于包含用 Scala 编写的依赖项,因为那里的惯例要求我们将 Scala 版本作为 artifactId 的一部分。我们当然可以手动将 Scala 版本附加到 artifactId 并使用 %。这也适用于导入用不同主要版本的 Scala 编写的库的情况。在后一种情况下,我们需要注意二进制兼容性。当然,并非所有库都会用我们使用的版本编写,因此我们要么彻底测试它们并确保它们不会破坏我们的应用程序,要么更改我们的 Scala 版本,或者寻找替代方案。
显示的依赖项在任何本书的任何地方都不需要(Spark 和 Datastax 的那个)。它们只是用于说明目的,如果不需要,您可以安全地删除它们。
SBT 要求每个语句都在新的一行上,并且如果我们在 .sbt 文件中工作,则每个语句与前一个语句之间需要用空白行隔开。当使用 .scala 文件时,我们只需用 Scala 编写代码。
依赖项中的 %% 语法是一种语法糖,它使用 scalaVersion 将库的名称替换掉,例如,在我们的例子中,scalatest 将变成 scalatest_2.12。
SBT 允许工程师用不同的方式表达相同的内容。一个例子是前面的依赖项——我们不是添加一系列依赖项,而是可以逐个添加。最终的结果将是相同的。SBT 的其他部分也有很多灵活性。有关 SBT 的更多信息,请参阅文档。
project/build.properties 文件定义了在 sbt 下构建和与应用程序交互时要使用的 sbt 版本。它就像以下这样简单:
sbt.version = 1.1.0
最后,有一个 project/plugins.sbt 文件,它定义了用于使项目启动运行的不同插件。我们之前提到了 sbtassembly:
addSbtPlugin("com.eed3si9n" % "sbt-assembly" % "0.14.5")
在线有不同插件提供有用的功能。以下是一些常见的可以在该骨架项目的终端中运行的 sbt 命令:
-
sbt: 这将打开当前项目的 sbt 控制台。所有后续的命令都可以在这里发出,无需使用sbt关键字。 -
sbt test: 这将运行应用程序单元测试。 -
sbt compile: 这将编译应用程序。 -
sbt assembly: 这将创建应用程序的汇编(一个胖 JAR),可以像其他任何 Java JAR 一样运行。
Maven
Maven 在名为 pom.xml 的文件中保存其配置。它易于支持多模块项目,而对于 sbt,则需要做一些额外的工作。在 Maven 中,每个模块都有自己的子 pom.xml 文件。
要下载 Maven,请访问 maven.apache.org/download.cgi。
以下截图显示了骨架 Maven 项目的结构:

主要的 pom.xml 文件比之前的 SBT 解决方案要长得多。让我们分别看看它的各个部分。
通常在 POM 文件的开头有一些关于项目和不同属性的信息:
<modelVersion>4.0.0</modelVersion>
<groupId>com.ivan.nikolov</groupId>
<artifactId>skeleton-mvn</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<scala.version>2.12.4</scala.version>
<scalatest.version>3.0.4</scalatest.version>
<spark.version>2.2.0</spark.version>
</properties>
然后,是依赖项:
<dependencies>
<dependency>
<groupId>org.apache.spark</groupId>
<artifactId>spark-core_2.11</artifactId>
<version>${spark.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>com.datastax.spark</groupId>
<artifactId>spark-cassandra-connector_2.11</artifactId>
<version>2.0.5</version>
</dependency>
<dependency>
<groupId>org.scala-lang</groupId>
<artifactId>scala-library</artifactId>
<version>${scala.version}</version>
</dependency>
<dependency>
<groupId>org.scalatest</groupId>
<artifactId>scalatest_2.12</artifactId>
<version>${scalatest.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-all</artifactId>
<version>1.10.19</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
</dependencies>
最后,是构建定义。在这里,我们可以使用各种插件对我们的项目进行不同的操作,并向编译器提供提示。构建定义被 <build> 标签包围。
首先,我们指定一些资源:
<sourceDirectory>src/main/scala</sourceDirectory>
<testSourceDirectory>src/test/scala</testSourceDirectory>
<resources>
<resource>
<directory>${basedir}/src/main/resources</directory>
</resource>
</resources>
我们使用的第一个插件是 scala-maven-plugin,当与 Scala 和 Maven 一起工作时使用:
<plugin>
<groupId>net.alchim31.maven</groupId>
<artifactId>scala-maven-plugin</artifactId>
<version>3.3.1</version>
<executions>
<execution>
<goals>
<goal>compile</goal>
<goal>testCompile</goal>
</goals>
</execution>
</executions>
<configuration>
<scalaVersion>${scala.version}</scalaVersion>
</configuration>
</plugin>
我们使用的另一个插件是 maven-assembly-plugin,用于构建应用程序的胖 JAR:
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<version>3.1.0</version>
<configuration>
<appendAssemblyId>false</appendAssemblyId>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
<executions>
<execution>
<id>make-assembly</id>
<phase>package</phase>
<goals>
<goal>single</goal>
</goals>
</execution>
</executions>
</plugin>
完整的 pom.xml 文件等同于我们之前提供的 sbt 文件。
如前所述,Spark 和 Datastax 依赖项仅用于说明目的。
在 Scala 2.12 中使用 JUnit 运行单元测试
如果你深入研究依赖项,你会看到我们导入了 junit,这是一个 Java 测试框架。乍一看,有人可能会认为我们实际上并不需要它。然而,有一个陷阱。快速 Google 搜索如何使用 Maven 运行 Scalatest 单元测试会指向推荐使用 scalatest-maven-plugin 的资源。如果我们遵循那些说明并尝试从命令行运行一些测试,我们会得到一个奇怪的错误。这是由于我们使用了 Scala 2.12,而当前的 scalatest-maven-plugin 版本与该语言的这个版本不兼容。
就像软件工程中的许多事情一样,我们必须找到解决方案。在这里,我们可以做两件事:
-
使用较旧的 Scala 版本。
-
强制 Maven 运行我们的测试。
当然,第二个选项更可取。这意味着我们只需要在每个 Scalatest 中做一件事,那就是在每个测试类中添加以下注解:
@RunWith(classOf[JUnitRunner]),并确保我们的测试类名称中包含单词Test。
类似于 SBT,您可以从命令行使用 Maven。本书示例项目中您可能发现最有用的命令将在下一个提示中展示。
有用的 Maven 命令:
-
mvn clean test:这会运行应用程序单元测试 -
mvn clean compile:这会编译应用程序 -
mvn clean package:这会创建一个应用程序的集合(一个胖 JAR),可以像其他 Java JAR 一样运行
SBT 与 Maven 的比较
在本书中,我们将使用 SBT 和 Maven 进行依赖管理并创建我们的项目。它们可以互换使用,我们的源代码将不依赖于我们选择的构建系统。您可以使用我们提供的框架轻松地将.pom文件转换为.sbt文件。真正不同的将是依赖项以及它们是如何表达的。
摘要
到目前为止,我们已经有了一个关于设计模式意味着什么以及它如何影响我们编写代码方式的大致了解。我们回顾了最著名的设计模式,并概述了它们之间的主要区别。我们看到了在许多情况下,我们可以使用 Scala 的特性来使一个模式变得过时、更简单或与纯面向对象语言的经典案例相比具有不同的实现方式。本书将向您展示 Scala 如何使编写高质量代码变得更加容易。
知道在挑选设计模式时应该寻找什么很重要,您应该已经知道应该注意哪些具体细节以及规范的重要性。
最后但同样重要的是,我们建议您运行本书中的示例,并且我们已经提供了一些应该会使这变得非常简单的提示。在某些情况下,使用 SBT 或 Maven 创建一个完整的解决方案可能过于繁琐且有些不必要,但我们相信这是一个好的实践。此外,我们解释的方法在整个行业中都有应用,并且将超出本书的范围带来益处。
在下一章中,我们将直接进入本书的实践部分,我们将探讨特性和混合组合,它们有什么用途,以及何时何地使用它们。
第二章:特性与混入组合
在深入研究一些实际的设计模式之前,我们必须确保许多 Scala 语言概念对读者来说是清晰的。这些概念中的许多将在实现实际设计模式时被使用,了解可能性、限制和陷阱是使我们能够正确且高效地编写代码的关键因素。尽管这些概念不被视为官方设计模式,但它们仍然可以用来编写好的软件。在某些情况下,由于 Scala 的丰富性,一些概念可以通过仅使用语言特性来替代设计模式。毕竟,正如我们之前所说的,设计模式的存在是因为编程语言缺乏功能,不足以完成某些任务。
我们将要探讨的第一个主题是关于特性和混入组合。它们为开发者提供了共享已实现的功能或为应用程序中的类定义接口的可能性。特性与混入组合为开发者提供的许多可能性,对于实现我们将在本书后面关注的一些设计模式非常有用。在本章中,我们将探讨以下主要主题:
-
特性
-
混入组合
-
多重继承
-
线性化
-
测试特性
-
特性对比类
特性
你们中很多人可能对 Scala 中的特性有不同的看法。它们不仅可以被视为其他语言中的接口,还可以被视为只有无参构造函数的类。
特性参数
Scala 编程语言非常动态,并且在过去的几年中发展迅速。根据语言创造者的说法,Dotty 项目是 Scala 的未来,它是一个正在测试和实现将参数传递给特性和许多其他功能的地方。其背后的主要思想是语言简化,更多信息可以在dotty.epfl.ch和scala-lang.org/blog/2017/05/31/first-dotty-milestone-release.html找到。
在接下来的几节中,我们将从不同的角度来探讨特性,并尝试给您一些关于如何使用它们的想法。
特性作为接口
特性在其他语言中可以被视为接口,例如 Java。然而,它们允许开发者实现一些或所有的方法。每当特性能量中存在一些代码时,该特性就被称为混入。让我们看看以下示例:
trait Alarm {
def trigger(): String
}
在这里,Alarm是一个接口。它的唯一方法trigger没有任何实现,如果在一个非抽象类中混入,则需要实现该方法。
让我们看看另一个特性示例:
trait Notifier {
val notificationMessage: String
def printNotification(): Unit = {
System.out.println(notificationMessage)
}
def clear()
}
之前显示的Notifier接口实现了一个方法,而clear和notificationMessage的值必须由将与Notifier接口混合的类处理。此外,特性可以要求类在其内部具有特定的变量。这在其他语言中的抽象类中有些类似。
将特性与变量混合
正如我们刚才指出的,特性可能要求类具有特定的变量。一个有趣的用例是在将变量传递给类的构造函数时。这将涵盖特性要求:
class NotifierImpl(val notificationMessage: String) extends Notifier {
override def clear(): Unit = System.out.println("cleared")
}
这里的唯一要求是变量具有相同的名称,并且在类定义中由val关键字 precede。如果我们不在前面的代码中使用val在参数前面,编译器仍然会要求我们实现特性。在这种情况下,我们必须为类参数使用不同的名称,并在类体中有一个override val notificationMessage赋值。这种行为的原因很简单:如果我们显式使用val(或var),编译器将创建一个与参数具有相同作用域的 getter 字段。如果我们只是有参数,只有当参数在构造函数作用域之外使用时,例如在方法中,才会创建一个字段和内部 getter。为了完整性,case 类自动将val关键字 前置 到参数上。根据我们刚才说的,这意味着当使用val时,我们实际上有一个具有给定名称和正确作用域的字段,并且它将自动覆盖特性要求我们做的任何事情。
特性作为类
特性也可以从类的角度来理解。在这种情况下,它们必须实现所有方法,并且只有一个不接受任何参数的构造函数。考虑以下示例:
trait Beeper {
def beep(times: Int): Unit = {
1 to times foreach(i => System.out.println(s"Beep number: $i"))
}
}
现在,我们实际上可以实例化Beeper并调用其方法。以下是一个仅执行此操作的控制台应用程序:
object BeeperRunner {
val TIMES = 10
def main (args: Array[String]): Unit = {
val beeper = new Beeper {}
beeper.beep(TIMES)
}
}
如预期的那样,在运行应用程序后,我们将在我们的终端中看到以下输出:
Beep number: 1
Beep number: 2
Beep number: 3
Beep number: 4
Beep number: 5
Beep number: 6
Beep number: 7
Beep number: 8
Beep number: 9
Beep number: 10
扩展类
特性可以扩展类。让我们看看以下示例:
abstract class Connector {
def connect()
def close()
}
trait ConnectorWithHelper extends Connector {
def findDriver(): Unit = {
System.out.println("Find driver called.")
}
}
class PgSqlConnector extends ConnectorWithHelper {
override def connect(): Unit = {
System.out.println("Connected...")
}
override def close(): Unit = {
System.out.println("Closed...")
}
}
如预期的那样,PgSqlConnector将被迫实现抽象类的方法。正如你可以猜到的,我们可能有其他扩展其他类的特性,然后我们可能想要将它们混合在一起。然而,Scala 在某些情况下会设置限制,我们将在本章后面查看组合时看到它将如何影响我们。
扩展特性
特性也可以相互扩展。看看以下示例:
trait Ping {
def ping(): Unit = {
System.out.println("ping")
}
}
trait Pong {
def pong(): Unit = {
System.out.println("pong")
}
}
trait PingPong extends Ping with Pong {
def pingPong(): Unit = {
ping()
pong()
}
}
object Runner extends PingPong {
def main(args: Array[String]): Unit = {
pingPong()
}
}
之前的示例很简单,它应该只是让Runner对象分别混合两个特性。扩展特性在一种称为可堆叠特性的设计模式中很有用,我们将在本书后面的章节中探讨。
混合组合
Scala 允许开发者在单个类中扩展多个特性。这增加了实现多重继承的可能性,并在代码编写上节省了大量精力,这在不允许扩展多个类的语言中是必须执行的。在本子主题中,我们将展示如何将特性混合到特定的类中,或者在我们编写代码时使用它们来创建具有特定功能的匿名类。
在类中混合特性
首先,让我们修改前一个例子中的代码。这是一个非常简单的修改,它也将确切地展示特性是如何混合的:
object MixinRunner extends Ping with Pong {
def main(args: Array[String]): Unit = {
ping()
pong()
}
}
如前述代码所示,我们可以将多个特性添加到类中。我们在示例中使用对象仅仅是因为主方法。这类似于创建一个没有构造函数参数的类(Scala 中的对象是单例类)。
如何混合特性?
使用以下语法将特性混合到类中:
extends T1 with T2 with … with Tn。
如果一个类已经扩展了另一个类,我们只需继续使用with关键字添加特性。
如果一个特性方法没有在特性体内部实现,并且我们将它混合到的类不是抽象类,那么这个类将不得不实现该特性。否则,将发生编译错误。
组合
在创建时进行组合,给我们提供了创建匿名类而不需要显式定义它们的机会。此外,如果我们想要组合许多不同的特性,创建所有可能性将涉及太多工作,所以这有助于使事情变得更容易。
组合简单特性
让我们看看一个例子,其中我们组合了简单的特性,这些特性不扩展其他特性或类:
class Watch(brand: String, initialTime: Long) {
def getTime(): Long = System.currentTimeMillis() - initialTime
}
object WatchUser {
def main(args: Array[String]): Unit = {
val expensiveWatch = new Watch("expensive brand", 1000L) with Alarm with Notifier {
override def trigger(): String = "The alarm was triggered."
override def clear(): Unit = {
System.out.println("Alarm cleared.")
}
override val notificationMessage: String = "Alarm is running!"
}
val cheapWatch = new Watch("cheap brand", 1000L) with Alarm {
override def trigger(): String = "The alarm was triggered."
}
// show some watch usage.
System.out.println(expensiveWatch.trigger())
expensiveWatch.printNotification()
System.out.println(s"The time is ${expensiveWatch.getTime()}.")
expensiveWatch.clear()
System.out.println(cheapWatch.trigger())
System.out.println("Cheap watches cannot manually stop the alarm...")
}
}
在前面的例子中,我们使用了之前提到的Alarm和Notifier特性。我们创建了两个手表实例——一个功能丰富且更有用的昂贵实例,另一个是便宜且控制力不足的实例。本质上,它们是匿名类,在实例化时定义。另外,正如预期的那样,我们必须实现我们包含的特性中的抽象方法。我希望这能给你一个关于在我们有更多特性时可能有多少种组合的想法。
仅为了完整性,这里是一个前述程序的示例输出:
The alarm was triggered.
Alarm is running!
The time is 1234567890562.
Alarm cleared.
The alarm was triggered.
Cheap watches cannot manually stop the alarm...
如预期的那样,突出显示的时间值在不同的运行中会有所不同。
组合复杂特性
在某些情况下,我们可能需要组合更复杂的特性,这些特性扩展了其他特性或类。如果一个特性(以及继承链上的其他特性)明确扩展了特定的类,那么事情将会非常简单,它们不会改变太多。在这种情况下,我们将能够访问超特性的方法。然而,让我们看看如果继承层次结构中的任何特性扩展了特定的类会发生什么。在下一个示例中,我们将使用之前定义的ConnectorWithHelper特性。这个特性扩展了抽象的Connector类。想象一下,如果我们想拥有另一款非常昂贵的智能手表,它也可以连接到数据库:
object ReallyExpensiveWatchUser {
def main(args: Array[String]): Unit = {
val reallyExpensiveWatch = new Watch("really expensive brand", 1000L) with ConnectorWithHelper {
override def connect(): Unit = {
System.out.println("Connected with another connector.")
}
override def close(): Unit = {
System.out.println("Closed with another connector.")
}
}
System.out.println("Using the really expensive watch.")
reallyExpensiveWatch.findDriver()
reallyExpensiveWatch.connect()
reallyExpensiveWatch.close()
}
}
看起来一切都很正常;然而,当我们编译时,我们得到以下错误信息:
Error:(36, 80) illegal inheritance; superclass Watch
is not a subclass of the superclass Connector
of the mixin trait ConnectorWithHelper
val reallyExpensiveWatch = new Watch("really expensive brand", 1000L) with ConnectorWithHelper {
^
这个错误信息告诉我们,由于ConnectorWithHelper特性扩展了Connector类,所有使用此特性进行组合的类都必须是Connector类的子类。现在让我们想象一下,如果我们想混合另一个也扩展类的特性,但在这个情况下是另一个类。根据前面的逻辑,将需要Watch类也应该是其他类的子类。然而,这是不可能的,因为我们一次只能扩展一个类,这就是 Scala 如何限制多重继承以防止发生危险错误的方式。
如果我们想在示例中修复编译问题,我们就必须修改原始的Watch类,并确保它是Connector类的子类。然而,这可能不是我们想要的,在这种情况下可能需要进行一些重构。
使用自类型进行组合
在前面的子节中,我们看到了我们如何在Watch类中被迫扩展Connector类以正确编译我们的代码。有些情况下,我们可能实际上想强制一个特性被混合到一个已经混合了另一个特性或多个特性的类中。让我们想象一下,我们想要一个能够通知我们的闹钟,无论发生什么:
trait AlarmNotifier {
this: Notifier =>
def trigger(): String
}
在前面的代码中,我们展示了自类型。高亮显示的代码将Notifier的所有方法都带到了我们新特性的作用域中,并且它还要求任何混合AlarmNotifier类的类也应该混合Notifier类。否则,将发生编译错误。相反,我们可以使用self,然后通过输入例如self.printNotification()来在AlarmNotifier内部引用Notifier的方法。
以下代码是使用新特性的示例:
object SelfTypeWatchUser {
def main(args: Array[String]): Unit = {
// uncomment to see the self-type error.
// val watch = new Watch("alarm with notification", 1000L) with AlarmNotifier {
//}
val watch = new Watch("alarm with notification", 1000L) with AlarmNotifier with Notifier {
override def trigger(): String = "Alarm triggered."
override def clear(): Unit = {
System.out.println("Alarm cleared.")
}
override val notificationMessage: String = "The notification."
}
System.out.println(watch.trigger())
watch.printNotification()
System.out.println(s"The time is ${watch.getTime()}.")
watch.clear()
}
}
如果我们在前面的代码中注释掉watch变量,并取消注释注释的部分,我们会看到一个编译错误,这是由于我们必须也混合Notifier类。
在本小节中,我们展示了 self-types 的简单用法。一个特性可以要求混合多个其他特性。在这种情况下,它们只是通过 with 关键字分隔。Self-types 是 蛋糕设计模式 的关键部分,该模式用于依赖注入。我们将在本书的后面部分看到更多有趣的用例。
冲突的特性
有些人可能已经在心中提出了一个问题——如果我们混合了具有相同签名的方法的特性怎么办?我们将在接下来的几节中探讨这个问题。
相同签名和返回类型
考虑一个例子,我们想要将两个特性混合到一个类中,并且它们的声明方法相同:
trait FormalGreeting {
def hello(): String
}
trait InformalGreeting {
def hello(): String
}
class Greeter extends FormalGreeting with InformalGreeting {
override def hello(): String = "Good morning, sir/madam!"
}
object GreeterUser {
def main(args: Array[String]): Unit = {
val greeter = new Greeter()
System.out.println(greeter.hello())
}
}
在前面的示例中,问候者总是有礼貌的,并且混合了正式和非正式的问候。在实现时,只需实现一次方法即可。
相同签名和不同返回类型的特性
如果我们的问候特性有更多具有相同签名但返回类型不同的方法呢?让我们向 FormalGreeting 添加以下声明:
def getTime(): String
还需向 InformalGreeting 添加以下内容:
def getTime(): Int
我们必须在我们的 Greeter 类中实现这些方法。然而,编译器不会允许我们定义 getTime 两次的消息,这表明 Scala 阻止此类事情发生。
相同签名和返回类型的 mixins
在继续之前,先快速提醒一下,mixin 只是一个在内部实现了一些代码的特性。这意味着在下面的示例中,我们不需要在使用它们的类内部实现方法。
让我们看看以下示例:
trait A {
def hello(): String = "Hello, I am trait A!"
}
trait B {
def hello(): String = "Hello, I am trait B!"
}
object Clashing extends A with B {
def main(args: Array[String]): Unit = {
System.out.println(hello())
}
}
可能正如预期的那样,我们的编译将失败,并显示以下消息:
Error:(11, 8) object Clashing inherits conflicting members:
method hello in trait A of type ()String and
method hello in trait B of type ()String
(Note: this can be resolved by declaring an override in object Clashing.)
object Clashing extends A with B {
^
这条消息很有用,甚至给出了如何解决问题的提示。冲突的方法是多重继承中的问题,但如您所见,我们被迫选择其中一种可用方法。以下是在 Clashing 对象中可能的修复方法:
override def hello(): String = super[A].hello()
然而,如果我们出于某种原因想要同时使用两个 hello 方法怎么办?在这种情况下,我们可以创建其他名称不同的方法,并像前面的示例(super 符号)那样调用特定的特性。我们也可以直接使用 super 符号来引用方法,而不是将它们包装在方法中。不过,我个人更喜欢将其包装起来,因为否则代码可能会变得混乱。
超类符号
如果在前面的示例中,我们不是使用 super[A]. hello(),而是这样做:override def hello(): String = super.hello(),会发生什么?
将调用哪个 hello 方法,为什么?在当前情况下,它将是 B 特性中的那个,输出将是 Hello, I am trait B! 这取决于 Scala 中的线性化,我们将在本章后面探讨这个问题。
相同签名和不同返回类型的 mixins
预期之中,当方法输入参数的类型或数量不同,形成新的签名时,之前的问题就不存在了。然而,如果我们在我们 traits 中有如下两个方法,问题仍然存在:
def value(a: Int): Int = a // in trait A
def value(a: Int): String = a.toString // in trait B
您可能会惊讶地发现,我们之前使用的方法在这里不起作用。如果我们决定只覆盖 A 特性中的 value 方法,我们将得到以下编译错误:
Error:(19, 16) overriding method value in trait B of type (a: Int)String;
method value has incompatible type
override def value(a: Int): Int = super[A].value(a)
^
如果我们在 B 特性中覆盖 value 方法,错误将相应地改变。
如果我们尝试同时覆盖它们,错误将如下所示:
Error:(20, 16) method value is defined twice
conflicting symbols both originated in file '/path/to/traits/src/main/scala/com/ivan/nikolov/composition/Clashing.scala'
override def value(a: Int): String = super[B].value(a)
这表明 Scala 实际上阻止我们做一些在多重继承中可能发生的危险操作。为了完整性,如果您遇到类似问题,有一个解决方案(牺牲混合功能)。它看起来如下:
trait C {
def value(a: Int): Int = a
}
trait D {
def value(a: Int): String = a.toString
}
object Example {
val c = new C {}
val d = new D {}
def main (args: Array[String]): Unit = {
System.out.println(s"c.value: ${c.value(10)}")
System.out.println(s"d.value: ${d.value(10)}")
}
}
前面的代码使用 traits 作为协作者,但它也失去了使用它们的类也是 trait 类型的实例这一事实,这对于其他操作也可能很有用。
多重继承
由于我们可以混合多个 traits,并且它们都有自己的方法实现,我们不可避免地要在前面的章节中提到多重继承。多重继承不仅是一种强大的技术,也是一种危险的技术,一些语言如 Java 甚至决定不允许它。正如我们已经看到的,Scala 允许这样做,但有一些限制。在本小节中,我们将介绍多重继承的问题,并展示 Scala 如何处理这些问题。
钻石问题
多重继承受到 钻石问题 的困扰。
让我们看一下以下图表:

在这里,B 和 C 都扩展了 A,然后 D 扩展了 B 和 C。这种情况下可能会出现一些歧义。假设有一个原本在 A 中定义的方法,但 B 和 C 都覆盖了它。如果 D 调用这个方法,会发生什么?它到底会调用哪一个?
所有的前一个问题都使事情变得模糊,这可能导致错误。让我们尝试在 Scala 中使用 traits 重新创建这个问题:
trait A {
def hello(): String = "Hello from A"
}
trait B extends A {
override def hello(): String = "Hello from B"
}
trait C extends A {
override def hello(): String = "Hello from C"
}
trait D extends B with C {
}
object Diamond extends D {
def main(args: Array[String]): Unit = {
System.out.println(hello())
}
}
程序的输出会是什么?以下是输出:
Hello from C
如果我们只改变 D 特性如下所示:
trait D extends C with B {
}
然后,我们程序的输出将如下所示:
Hello from B
如您所见,尽管示例仍然存在歧义且容易出错,我们实际上可以确切地知道哪个方法会被调用。这是通过线性化实现的,我们将在下一节更深入地探讨。
限制
在专注于线性化之前,让我们指出 Scala 施加的多重继承限制。我们之前已经看到了很多,所以在这里我们只是简单地总结它们。
Scala 多重继承限制
Scala 中多重继承是通过 traits 实现的,并且遵循线性化规则。
在继承层次结构中,如果一个特性显式地扩展了一个类,那么混入这个特性的类也必须是特性父类的子类。这意味着当混入扩展类的特性时,它们都必须有相同的父类。
无法混入定义或声明具有相同签名但不同返回类型的函数的特性。
当多个特性定义了具有相同签名和返回类型的函数时,必须特别小心。在方法被声明并预期实现的情况下,这不是问题,只有一个实现就足够了。
线性化
正如我们已经看到的,特性提供了一种多继承的形式。在这种情况下,层次结构不一定是线性的,而是一个需要编译时展开的无环图。线性化所做的就是这个——它为类的所有祖先指定一个单一的线性顺序,包括常规的超类链和所有特性的父链。
我们不需要处理那些不包含代码的特性的线性化。然而,如果我们使用混入(mixins),我们就必须考虑它。以下内容会受到线性化的影响:
-
方法定义
-
变量(包括可变变量—
var和不可变变量—val)
我们之前已经看到了线性化的一个简单例子。然而,如果线性化的规则不清楚,事情可能会变得非常复杂和出乎意料。
继承层次结构的规则
在探讨线性化规则之前,我们需要清楚 Scala 中的一些继承规则:
-
在 Java 中,即使一个类没有显式地扩展另一个类,它的超类也将是
java.lang.Object。在 Scala 中也是如此,等效的基类是AnyRef。 -
直接扩展特性和扩展特性超类并使用
with关键字混入特性之间存在相似性。
在较老的 Scala 版本中,还有一个名为ScalaObject的类型,它被隐式添加到所有特性和类中。
使用这些规则,我们可以为所有特性和类始终得到一个规范形式,其中基类使用extends指定,然后使用with关键字添加所有特性。
线性化规则
Scala 中的线性化规则被定义并存在是为了确保有定义的行为。规则如下:
-
任何类的线性化都必须包括它所扩展的任何未修改的线性化类(但不是特性)。
-
任何类的线性化都必须包括它所扩展的任何特性的线性化中的所有类和混入特性,但混入特性并不绑定以与它们在混入的特性中的线性化中出现的相同顺序出现。
-
线性化中的每个类或特性只能出现一次。重复项将被忽略。
在之前的某些例子中,我们已经看到,无法将具有不同基类的特性混入,或者当它们的基类不同时,将特性混入类中。
线性化是如何工作的
在 Scala 中,线性化是从左到右列出的,最右侧的类是最一般的,例如,AnyRef。在进行线性化时,Any也会被添加到层次列表中。这,加上任何类都必须包含其超类线性化的规则,意味着超类线性化将作为类线性化的后缀出现。
让我们通过一些非常简单的类来举一个例子:
class Animal extends AnyRef
class Dog extends Animal
这两个类的线性化分别是:
Animal -> AnyRef -> Any
Dog -> Animal -> AnyRef -> Any
现在,让我们尝试形式化一个描述如何计算线性化的算法:
-
从以下类声明开始——
class A extends B with T1 with T2。 -
逆序排列列表(除了第一个项目),并丢弃关键字。这样,超类将作为后缀出现——
A T2 T1 B。 -
每个元素都被替换为其线性化形式——
A T2L T1L BL。 -
使用右结合的连接操作符连接列表元素:
A +: T2L +: T1L +: BL。 -
添加标准的
AnyRef和Any类——A +: T2L +: T1L +: BL +: AnyRef +: Any。 -
评估前面的表达式。由于右结合的连接操作,我们从右向左开始。在每一步中,我们移除任何已经出现在右侧的元素。在我们的例子中,当我们到达
BL时,我们不会添加它所包含的AnyRef和Any;我们只添加BL然后继续。在T1L时,我们将跳过添加之前已经添加的任何元素的步骤,以此类推,直到我们到达A。
最后,在完成线性化之后,我们将得到一个不包含重复的类和特质的列表。
初始化
现在我们知道了线性化过程中发生了什么,我们将理解实例是如何被创建的。规则是,构造函数代码的执行顺序与线性化顺序相反。这意味着,从右到左,首先将调用Any和AnyRef构造函数,然后调用实际的类构造函数。此外,将先调用超类构造函数,然后调用实际的类或其任何 mixin,因为我们已经提到过,它作为后缀被添加。
记住,我们是从右到左遍历线性化,这也意味着在调用超类构造函数之后,将调用 mixin 特质构造函数。在这里,它们将按照它们在原始类定义中出现的顺序被调用(因为右到左的方向以及它们在创建线性化时顺序被反转)。
方法重写
当在子类中覆盖一个方法时,你可能想同时调用原始实现。这是通过在方法名前缀super关键字来实现的。开发者还可以控制使用特质类型来限定super关键字,从而调用特定特质中的方法。我们在本章前面已经看到了一个这样的例子,其中我们调用了super[A].hello()。在那个例子中,我们具有具有相同方法的 mixins;然而,这些方法本身并没有引用super,而是只定义了自己的实现。
让我们在这里看一个例子,其中我们实际上在覆盖方法时引用了super类:
class MultiplierIdentity {
def identity: Int = 1
}
现在,让我们定义两个特质,分别将原始类中的身份值加倍和三倍:
trait DoubledMultiplierIdentity extends MultiplierIdentity {
override def identity: Int = 2 * super.identity
}
trait TripledMultiplierIdentity extends MultiplierIdentity {
override def identity: Int = 3 * super.identity
}
正如我们在一些先前的例子中所看到的,我们混合特质的顺序很重要。我们将提供三种实现,首先混合DoubledMultiplierIdentity然后是TripledMultiplierIdentity。第一个不会覆盖身份方法,这相当于使用以下 super 表示法:super.identity。其他两个将覆盖该方法,并将引用特定的父类:
// first Doubled, then Tripled
class ModifiedIdentity1 extends DoubledMultiplierIdentity with TripledMultiplierIdentity
class ModifiedIdentity2 extends DoubledMultiplierIdentity with TripledMultiplierIdentity {
override def identity: Int = super[DoubledMultiplierIdentity].identity
}
class ModifiedIdentity3 extends DoubledMultiplierIdentity with TripledMultiplierIdentity {
override def identity: Int = super[TripledMultiplierIdentity].identity
}
// first Doubled, then Tripled
让我们像前面代码中展示的那样做同样的事情,但这次,我们首先混合TripledMultiplierIdentity然后是DoubledMultiplierIdentity。实现与前面的类似:
// first Tripled, then Doubled
class ModifiedIdentity4 extends TripledMultiplierIdentity with DoubledMultiplierIdentity
class ModifiedIdentity5 extends TripledMultiplierIdentity with DoubledMultiplierIdentity {
override def identity: Int = super[DoubledMultiplierIdentity].identity
}
class ModifiedIdentity6 extends TripledMultiplierIdentity with DoubledMultiplierIdentity {
override def identity: Int = super[TripledMultiplierIdentity].identity
}
// first Tripled, then Doubled
最后,让我们使用我们的类:
object ModifiedIdentityUser {
def main(args: Array[String]): Unit = {
val instance1 = new ModifiedIdentity1
val instance2 = new ModifiedIdentity2
val instance3 = new ModifiedIdentity3
val instance4 = new ModifiedIdentity4
val instance5 = new ModifiedIdentity5
val instance6 = new ModifiedIdentity6
System.out.println(s"Result 1: ${instance1.identity}")
System.out.println(s"Result 2: ${instance2.identity}")
System.out.println(s"Result 3: ${instance3.identity}")
System.out.println(s"Result 4: ${instance4.identity}")
System.out.println(s"Result 5: ${instance5.identity}")
System.out.println(s"Result 6: ${instance6.identity}")
}
}
这个例子展示了一个多重继承层次结构,我们可以看到与前面图中解释的钻石关系完全一样。在这里,我们有所有可能的混合DoubledMultiplier和TripledMultiplier的顺序,以及我们如何调用身份基方法。
那么,这个程序的输出会是什么?人们可能会预期,在我们没有覆盖身份方法的情况下,它会调用最右侧特质的身份方法。由于在这两种情况下它们都调用了它们扩展的类的 super 方法,结果应该是2和3。让我们在这里看看:
Result 1: 6
Result 2: 2
Result 3: 6
Result 4: 6
Result 5: 6
Result 6: 3
前面的输出相当令人意外。然而,这正是 Scala 类型系统的工作方式。在多重继承的线性化情况下,对相同方法的调用是从右到左根据特质在类声明中出现的顺序链式调用的。请注意,如果我们没有使用 super 表示法,我们就会打破这个链,正如我们可以在一些先前的例子中看到的那样。
之前的例子相当有趣,证明了了解线性化规则以及线性化是如何工作的重要性。不了解这个特性可能会导致严重的陷阱,这可能导致代码中的关键错误。
我的建议仍然是尽量避免菱形继承的情况,尽管有人可能会争辩说,通过这种方式,可以无缝地实现一些相当复杂的系统,而且不需要编写太多的代码。像前面那样的情况可能会使程序在未来变得非常难以阅读和维护。
你应该知道,线性化在 Scala 中无处不在——不仅在与特性打交道时。这正是 Scala 类型系统的工作方式。这意味着了解构造函数调用的顺序是一个好主意,以避免错误,并且通常,尝试保持层次结构相对简单。
测试特性
测试是软件开发的一个重要部分。它确保对某段代码的更改不会导致更改的方法或其他地方出现错误。
有不同的测试框架可以使用,这实际上是一个个人偏好的问题。在这本书中,我们使用了ScalaTest(www.scalatest.org),因为这是我项目中所使用的;它易于理解、阅读和使用。
在某些情况下,如果一个特性被混合到一个类中,我们可能会测试这个类。然而,我们可能只想测试特定的特性。测试一个没有实现所有方法的特性没有太多意义,所以我们将查看那些已经编写了代码的(混合)。此外,这里我们将展示的单元测试相当简单,但它们只是为了说明目的。我们将在本书的后续章节中探讨更复杂和有意义的测试。
使用类
让我们看看之前看到的DoubledMultiplierIdentity是如何被测试的。人们会尝试简单地将特性混合到测试类中并测试方法:
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers with DoubledMultiplierIdentity
然而,这不会编译,并会导致以下错误:
Error:(5, 79) illegal inheritance; superclass FlatSpec
is not a subclass of the superclass MultiplierIdentity
of the mixin trait DoubledMultiplierIdentity
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers with DoubledMultiplierIdentity {
^
我们之前已经讨论过这个问题,以及一个特性只能混合到与其自身具有相同超类的类中。这意味着为了测试这个特性,我们应该在我们的测试类中创建一个虚拟类,然后使用它:
package com.ivan.nikolov.linearization
import org.scalatest.{ShouldMatchers, FlatSpec}
class DoubledMultiplierIdentityTest extends FlatSpec with ShouldMatchers {
class DoubledMultiplierIdentityClass extends DoubledMultiplierIdentity
val instance = new DoubledMultiplierIdentityClass
"identity" should "return 2 * 1" in {
instance.identity should equal(2)
}
}
将特性混合进来
我们可以通过混合来测试一个特性。有几个地方我们可以这样做——将其混合到测试类中或单独的测试用例中。
混合到测试类中
将特性混合到测试类中仅当特性没有显式扩展其他类时才可能,因此特性和测试的超类将是相同的。除此之外,其他一切都是与之前完全相同的。
让我们来测试本章前面提到的A特性,它表示hello。我们还添加了一个额外的pass方法,现在这个特性看起来如下所示:
trait A {
def hello(): String = "Hello, I am trait A!"
def pass(a: Int): String = s"Trait A said: 'You passed $a.'"
}
这就是单元测试的样子:
package com.ivan.nikolov.composition
import org.scalatest.{FlatSpec, Matchers}
class TraitATest extends FlatSpec with Matchers with A {
"hello" should "greet properly." in {
hello() should equal("Hello, I am trait A!")
}
"pass" should "return the right string with the number." in {
pass(10) should equal("Trait A said: 'You passed 10.'")
}
it should "be correct also for negative values." in {
pass(-10) should equal("Trait A said: 'You passed -10.'")
}
}
混合到测试用例中
我们还可以将特性分别混合到单个测试用例中。这可能允许我们只为这些测试用例应用特定的定制化。以下是对前面单元测试的不同表示:
package com.ivan.nikolov.composition
import org.scalatest.{FlatSpec, Matchers}
class TraitACaseScopeTest extends FlatSpec with Matchers {
"hello" should "greet properly." in new A {
hello() should equal("Hello, I am trait A!")
}
"pass" should "return the right string with the number." in new A {
pass(10) should equal("Trait A said: 'You passed 10.'")
}
it should "be correct also for negative values." in new A {
pass(-10) should equal("Trait A said: 'You passed -10.'")
}
}
如前述代码所示,测试用例与之前的相同。然而,它们各自混合了 A。这使我们能够针对需要方法实现或变量初始化的特性应用不同的定制化。这样,我们也可以专注于正在测试的特性,而不是创建其实例。
运行测试
在编写测试之后,运行它们以查看是否一切按预期工作是有用的。如果你使用 Maven,只需从项目的根目录运行以下命令,它将执行所有测试:
mvn clean test
如果你使用 SBT,则可以使用以下命令触发测试:
sbt test
特性 versus 类
特性可能与类相似,但也可能非常不同。对于开发者来说,在各种情况下选择使用哪一个可能很困难,但在这里我们将尝试提供一些一般性指南,这些指南应该会有所帮助。
使用类:
-
当一种行为根本不会在多个地方重用时
-
当你计划从其他语言使用你的 Scala 代码时,例如,如果你正在构建一个可以在 Java 中使用的库
使用特性:
-
当一种行为将在多个不相关的类中重用时。
-
当你想定义接口并希望在 Scala 之外使用它们时,例如,Java。原因是没有任何实现的特性被编译得类似于接口。
摘要
在本章中,我们探讨了 Scala 中的特性和混入组合。到现在,你应该对它们是什么以及使用它们可以实现什么有很好的理解。我们还探讨了特性的不同用法示例以及在使用它们时需要注意的事项。我们介绍了使用特性进行多重继承的限制。特性是一个非常强大的概念,但正如我们在多重继承中看到的那样,它们也有其陷阱,因此你应该谨慎使用。线性化被深入讨论,你应该熟悉使用特性进行多重继承时可以期待什么,以及为什么事情会以当前的方式工作。
测试是每个优秀软件项目的必要部分,我们也介绍了如何为特性进行测试。最后,但同样重要的是,我们准备了一些指南,这些指南应该有助于开发者选择在 Scala 中使用特性或类。
在下一章中,我们将花一些时间讨论统一。我们将展示为什么它是有用的,以及它如何帮助开发者实现他们的程序。
第三章:统一
能够理解和编写良好的 Scala 代码要求开发者熟悉该语言的不同概念。到目前为止,我们在几个地方提到 Scala 确实具有很强的表达能力。在一定程度上,这是因为有多个编程概念被统一了。在本章中,我们将重点关注以下概念:
-
函数和类
-
代数数据类型和类层次结构
-
模块和对象
函数和类
在 Scala 中,每个值都是一个对象。函数是一等值,这也使它们成为各自类的对象。
以下图表显示了 Scala 统一类型系统及其实现方式。它改编自www.scala-lang.org/old/sites/default/files/images/classhierarchy.png,并代表了对该模型(一些类如ScalaObject已经消失,如我们之前提到的)的最新看法:

如您所见,Scala 没有 Java 那样的原始类型概念,所有类型最终都是Any的子类型。
作为类的函数
函数作为类的事实意味着它们可以像值一样自由地传递给其他方法或类。这提高了 Scala 的表达能力,并使其比其他语言(如 Java)更容易实现回调等功能。
函数字面量
让我们看看一个示例:
class FunctionLiterals {
val sum = (a: Int, b: Int) => a + b
}
object FunctionLiterals {
def main(args: Array[String]): Unit = {
val obj = new FunctionLiterals
System.out.println(s"3 + 9 = ${obj.sum(3, 9)}")
}
}
在这里,我们可以看到FunctionLiterals类的求和字段实际上被分配了一个函数。我们可以将任何函数分配给一个变量,然后像调用函数一样调用它(本质上是在对象名称或实例后使用括号调用其apply方法)。函数也可以作为参数传递给其他方法。让我们向我们的FunctionLiterals类添加以下内容:
def runOperation(f: (Int, Int) => Int, a: Int, b: Int): Int = {
f(a, b)
}
然后,我们可以将所需的函数传递给runOperation,如下所示:
obj.runOperation(obj.sum, 10, 20)
obj.runOperation(Math.max, 10, 20)
无语法糖的函数
在前面的示例中,我们只是使用了语法糖。为了确切了解发生了什么,我们将向您展示函数字面量被转换成了什么。它们基本上是FunctionN特质的扩展,其中N是参数的数量。字面量的实现是通过apply方法调用的(每当一个类或对象有apply方法时,它可以通过在对象名称或实例后使用括号并传递所需的参数(如果有)来隐式调用)。让我们看看与我们之前示例等效的实现:
class SumFunction extends Function2[Int, Int, Int] {
override def apply(v1: Int, v2: Int): Int = v1 + v2
}
class FunctionObjects {
val sum = new SumFunction
def runOperation(f: (Int, Int) => Int, a: Int, b: Int): Int = f(a, b)
}
object FunctionObjects {
def main(args: Array[String]): Unit = {
val obj = new FunctionObjects
System.out.println(s"3 + 9 = ${obj.sum(3, 9)}")
System.out.println(s"Calling run operation: ${obj.
runOperation(obj.sum, 10, 20)}")
System.out.println(s"Using Math.max: ${obj.runOperation(Math.max,
10, 20)}")
}
}
增强表达能力
如从示例中可以看出,统一类和函数导致表达能力增强,我们可以轻松实现回调、延迟参数评估、集中异常处理等功能,而无需编写额外的代码和逻辑。此外,函数作为类意味着我们可以扩展它们以提供额外功能。
代数数据类型和类层次结构
代数数据类型(ADTs)和类层次结构是 Scala 编程语言中的其他统一方式。在其他函数式语言中,有特殊的方法来创建自定义的代数数据类型。在 Scala 中,这是通过类层次结构以及所谓的 案例类 和 对象 来实现的。让我们看看 ADT 实际上是什么,有哪些类型,以及如何定义它们。
ADTs
代数数据类型只是组合类型,它们结合了其他现有类型或仅仅代表一些新的类型。它们只包含数据,并且不包含任何在数据之上作为正常类会包含的功能。一些例子可以包括一周中的某一天或表示 RGB 颜色的类——它们没有额外的功能,只是携带信息。接下来的几个小节将更深入地探讨 ADT 是什么以及有哪些类型。
求和 ADTs
求和代数数据类型是我们可以在其中简单地列出类型的所有可能值并为每个值提供一个单独构造函数的类型。作为一个例子,让我们考虑一年中的月份。它们只有 12 个,而且它们不会改变(希望如此):
sealed abstract trait Month
case object January extends Month
case object February extends Month
case object March extends Month
case object April extends Month
case object May extends Month
case object June extends Month
case object July extends Month
case object August extends Month
case object September extends Month
case object October extends Month
case object November extends Month
case object December extends Month
object MonthDemo {
def main(args: Array[String]): Unit = {
val month: Month = February
System.out.println(s"The current month is: $month")
}
}
运行此应用程序将产生以下输出:
The current month is: February
前面的代码中的 Month 特质是密封的,因为我们不希望它在当前文件之外被扩展。正如你所见,我们将不同的月份定义为对象,因为没有理由让它们成为单独的实例。值就是它们的样子,它们不会改变。
产品 ADTs
在积代数数据类型中,我们无法列出所有可能的值。通常值太多,无法手动编写。我们无法为每个单独的值提供一个单独的构造函数。
让我们思考一下颜色。有不同颜色模型,但其中最著名的一个是 RGB。它结合了主要颜色(红色、绿色和蓝色)的不同值来表示其他颜色。如果我们说每种颜色都可以在 0 到 255 之间取值,这意味着要表示所有可能性,我们需要有 256³ 个不同的构造函数。这就是为什么我们可以使用积 ADT:
sealed case class RGB(red: Int, green: Int, blue: Int)
object RGBDemo {
def main(args: Array[String]): Unit = {
val magenta = RGB(255, 0, 255)
System.out.println(s"Magenta in RGB is: $magenta")
}
}
现在,我们可以看到对于积 ADTs,我们有一个构造函数用于不同的值。
混合 ADTs
混合代数数据类型代表了我们之前描述的求和和积的混合。这意味着我们可以有特定的值构造函数,但这些值构造函数也提供了参数来封装其他类型。
让我们看看一个例子。想象我们正在编写一个绘图应用程序:
sealed abstract trait Shape
case class Circle(radius: Double) extends Shape
case class Rectangle(height: Double, width: Double) extends Shape
我们有不同的形状。前面的例子显示了一个求和 ADT,因为我们有特定的 Circle 和 Rectangle 值构造函数。此外,我们还有一个积 ADT,因为构造函数接受额外的参数。
让我们稍微扩展一下我们的类。当我们绘制形状时,我们需要知道它们的位置。这就是为什么我们可以添加一个 Point 类来持有 x 和 y 坐标:
case class Point(x: Double, y: Double)
sealed abstract trait Shape
case class Circle(centre: Point, radius: Double) extends Shape
case class Rectangle(topLeft: Point, height: Double, width: Double) extends Shape
希望这能清楚地说明在 Scala 中 ADT(抽象数据类型)是什么以及它们如何被使用。
统一
在所有前面的例子之后,很明显,类层次和 ADT 是统一的,看起来像是同一件事。这为语言增加了高度的灵活性,使得建模比其他函数式编程语言更容易。
模式匹配
模式匹配通常与 ADT 一起使用。与使用if…else语句相比,它使代码更加清晰、易读,并且更容易扩展。正如你所想象的那样,在某些情况下,这些语句可能会变得相当繁琐,特别是当某个数据类型有多个可能的值时。在某些情况下,模式匹配可以像枚举一样使用,就像 Java 中的switch语句一样。
值的模式匹配
在之前提到的月份示例中,我们只有月份名称。然而,我们可能还想获取它们的数字,因为否则计算机将不知道这一点。以下是这样做的方法:
object Month {
def toInt(month: Month): Int =
month match {
case January => 1
case February => 2
case March => 3
case April => 4
case May => 5
case June => 6
case July => 7
case August => 8
case September => 9
case October => 10
case November => 11
case December => 12
}
}
你可以看到我们如何匹配不同的值,并根据它们返回正确的值。以下是现在如何使用这个方法:
System.out.println(s"The current month is: $month and it's number ${Month.toInt(month)}")
如预期的那样,我们的应用程序将产生以下内容:
The current month is: February and it's number 2
我们指定我们的基本特质为密封的,这保证了没有人会在我们的代码之外扩展它,我们将能够进行详尽的模式匹配。不详尽的模式匹配是有问题的。作为一个实验,如果我们尝试注释掉二月匹配规则并编译,我们将看到以下警告:
Warning:(19, 5) match may not be exhaustive.
It would fail on the following input: February
month match {
^
以这种方式运行示例证明了这个警告是真实的,当我们使用February作为参数时,我们的代码失败了。为了完整性,我们可以添加一个默认情况:
case _ => 0
本小节中展示的例子是可以在 Scala 中使用,以实现 Java 中枚举的功能性。
产品 ADT 的模式匹配
当用于产品和混合 ADT 时,模式匹配展示了它的真正力量。在这种情况下,我们可以匹配数据类型的实际值。让我们看看我们如何实现一个计算形状面积的功能,如之前定义的那样:
object Shape {
def area(shape: Shape): Double =
shape match {
case Circle(Point(x, y), radius) => Math.PI * Math.pow(radius, 2)
case Rectangle(_, h, w) => h * w
}
}
在匹配时,我们可以忽略我们不关心的值。对于面积,我们实际上并不需要位置信息。在前面的代码中,我们只展示了两种可能的匹配方式。_运算符可以在匹配语句中的任何位置,它将忽略它被放置的值。之后,使用我们的示例就很简单了:
object ShapeDemo {
def main(args: Array[String]): Unit = {
val circle = Circle(Point(1, 2), 2.5)
val rect = Rectangle(Point(6, 7), 5, 6)
System.out.println(s"The circle area is: ${Shape.area(circle)}")
System.out.println(s"The rectangle area is: ${Shape.area(rect)}")
}
}
这将产生类似于以下内容的输出:
The circle area is: 19.634954084936208
The rectangle area is: 30.0
我们甚至可以在模式匹配期间用常量代替 ADT 构造函数参数中的变量。这使得语言非常强大,并允许我们实现更复杂的逻辑,同时看起来仍然相当不错。你可以尝试使用前面的例子来了解模式匹配实际上是如何工作的。
模式匹配通常与 ADT 一起使用,有助于实现干净、可扩展和详尽的代码。
模块和对象
模块是组织程序的一种方式。它们是可互换的、可插入的代码片段,具有定义良好的接口和隐藏的实现。在 Java 中,模块组织在包中。在 Scala 中,模块就像其他一切一样是对象。这意味着它们可以被参数化、扩展,并且可以作为参数传递,等等。
Scala 模块可以提供要求以便被使用。
使用模块
我们已经确定在 Scala 中模块和对象也是统一的。这意味着我们可以将整个模块传递到我们的应用程序中。然而,展示模块的实际外观将是有用的。以下是一个示例:
trait Tick {
trait Ticker {
def count(): Int
def tick(): Unit
}
def ticker: Ticker
}
在这里,Tick只是我们模块的一个接口。以下是其实现:
trait TickUser extends Tick {
class TickUserImpl extends Ticker {
var curr = 0
override def count(): Int = curr
override def tick(): Unit = {
curr = curr + 1
}
}
object ticker extends TickUserImpl
}
TickUser特质实际上是一个模块。它实现了Tick并包含其内部的代码。我们创建了一个单例对象,它将携带实现。注意对象中的名称与Tick中的方法名称相同。这满足了在混入特质时实现它的需求。
同样,我们可以定义另一个接口及其实现如下:
trait Alarm {
trait Alarmer {
def trigger(): Unit
}
def alarm: Alarmer
}
实现将如下所示:
trait AlarmUser extends Alarm with Tick {
class AlarmUserImpl extends Alarmer {
override def trigger(): Unit = {
if (ticker.count() % 10 == 0) {
System.out.println(s"Alarm triggered at ${ticker.count()}!")
}
}
}
object alarm extends AlarmUserImpl
}
这里有趣的是,我们在AlarmUser中扩展了两个模块。这展示了模块如何相互依赖。最后,我们可以如下使用我们的模块:
object ModuleDemo extends AlarmUser with TickUser {
def main(args: Array[String]): Unit = {
System.out.println("Running the ticker. Should trigger the alarm
every 10 times.")
(1 to 100).foreach {
case i =>
ticker.tick()
alarm.trigger()
}
}
}
为了让ModuleDemo使用AlarmUser模块,编译器还要求混入TickUser或任何混入Tick的模块。这提供了插入不同功能的可能性。
程序的输出将如下所示:
Running the ticker. Should trigger the alarm every 10 times.
Alarm triggered at 10!
Alarm triggered at 20!
Alarm triggered at 30!
Alarm triggered at 40!
Alarm triggered at 50!
Alarm triggered at 60!
Alarm triggered at 70!
Alarm triggered at 80!
Alarm triggered at 90!
Alarm triggered at 100!
Scala 中的模块可以像其他任何对象一样传递。它们是可扩展的、可互换的,并且它们的实现是隐藏的。
摘要
在本章中,我们专注于统一。我们看到了函数和类、ADT、类层次结构以及模块和对象之间的统一。这使我们能够更加表达性,编写更干净、更高效的代码。我们还介绍了模式匹配是什么以及如何在 Scala 中使用它来编写良好的代码。
本章中涉及的一些概念将在后续章节中非常有用,在这些章节中我们将实现具体的设计模式。仅仅因为 Scala 的表达能力允许这样做,它们也可以用来编写符合定义的设计模式的良好软件,而这违背了需要做额外工作来实现设计模式的需求。
在下一章中,我们将探讨抽象和自类型以及它们可能有什么用。
第四章:抽象和自类型
在软件工程中设计和编写高质量的代码对于拥有易于扩展和维护的应用程序非常重要。这项活动要求领域知识要被开发者充分了解,并且应用程序的要求要明确定义。如果其中任何一个条件不满足,编写好的程序就会变得相当具有挑战性。
通常,工程师会使用一些抽象来模拟 世界。这有助于代码的可扩展性和可维护性,并消除了重复,这在许多情况下可能是错误的原因。好的代码通常由多个小型组件组成,这些组件相互依赖并相互作用。有几种不同的方法可以帮助实现抽象和交互。我们将在本章中探讨以下主题:
-
抽象类型
-
多态
-
自类型
我们在这里将要讨论的主题,当我们开始研究一些具体的设计模式时,将会非常有用。了解它们也将帮助我们理解依赖于它们的设计模式。此外,本章中涵盖的概念本身对于编写好的代码就很有用。
抽象类型
使用值来参数化类是最常见的方法之一。这很简单,通过为类的构造函数参数传递不同的值来实现。在下面的示例中,我们可以为 Person 类的 name 参数传递不同的值,这就是我们创建不同实例的方式:
case class Person(name: String)
这样,我们可以创建不同的实例并将它们区分开来,但这既不有趣也不需要火箭科学。进一步来说,我们将关注一些更有趣的参数化方法,这将帮助我们改进我们的代码。
泛型
泛型是另一种参数化类的方法。当我们要编写一个在多种类型中应用相同功能的功能时,它们非常有用,并且我们可以简单地推迟选择具体类型直到以后。每个开发者都应该熟悉的一个例子是集合类。例如,List 可以存储任何类型的数据,我们可以有整数列表、双精度浮点数列表、字符串列表、自定义类列表等等。然而,列表实现始终是相同的。
我们还可以参数化方法。例如,如果我们想实现加法,它不会在不同数值数据类型之间改变。因此,我们可以使用泛型,只需编写一次方法,而不是重载并试图适应世界上每一个类型。
让我们看看一些例子:
trait Adder {
def sumT(implicit numeric: Numeric[T]): T =
numeric.plus(a, b)
}
上一段代码稍微复杂一些,它定义了一个名为 sum 的方法,这个方法可以用于所有数值类型。这实际上是对 ad hoc polymorphism 的一个表示,我们将在本章后面讨论这个概念。
以下代码展示了如何参数化一个类以包含任何类型的数据:
class ContainerT {
def compare(other: T) = data.equals(other)
}
以下代码片段展示了几个示例用法:
object GenericsExamples extends Adder {
def main(args: Array[String]): Unit = {
System.out.println(s"1 + 3 = ${sum(1, 3)}")
System.out.println(s"1.2 + 6.7 = ${sum(1.2, 6.7)}")
// System.out.println(s"abc + cde = ${sum("abc", "cde")}") // compilation fails
val intContainer = new Container(10)
System.out.println(s"Comparing with int: ${intContainer.compare(11)}")
val stringContainer = new Container("some text")
System.out.println(s"Comparing with string:
${stringContainer.compare("some text")}")
}
}
这个程序的输出将如下所示:
1 + 3 = 4
1.2 + 6.7 = 7.9
Comparing with int: false
Comparing with string: true
抽象类型
另一种通过抽象类型参数化类的方法。泛型在其他语言(如 Java)中也有对应物。然而,与它们不同,Java 中没有抽象类型。让我们看看前面的Container示例如何通过抽象类型而不是泛型来转换:
trait ContainerAT {
type T
val data: T
def compare(other: T) = data.equals(other)
}
我们将在类中使用这个特性,如下所示:
class StringContainer(val data: String) extends ContainerAT {
override type T = String
}
在完成这些之后,我们可以有与之前相同的例子:
object AbstractTypesExamples {
def main(args: Array[String]): Unit = {
val stringContainer = new StringContainer("some text")
System.out.println(s"Comparing with string:
${stringContainer.compare("some text")}")
}
}
预期的输出如下:
Comparing with string: true
当然,我们可以通过创建特例的实例并指定参数的方式,以类似的方式使用它,就像通用示例那样。这意味着泛型和抽象类型实际上给了我们两种不同的方式来实现相同的事情。
泛型与抽象类型比较
那么,为什么 Scala 中既有泛型又有抽象类型?它们之间有什么区别,何时应该使用一个而不是另一个?我们将在这里尝试回答这些问题。
泛型和抽象类型可以互换。我们可能需要做一些额外的工作,但最终,我们可以使用泛型得到抽象类型提供的东西。选择哪一个取决于不同的因素,其中一些是个人偏好,例如是否有人追求可读性或类的不同使用方式。
让我们看看一个例子,并尝试了解泛型和抽象类型何时以及如何被使用。在这个当前例子中,我们将讨论打印机。每个人都知道有不同类型——纸张打印机、3D 打印机等等。这些打印机各自使用不同的材料进行打印,例如碳粉、墨水或塑料,并且它们被用于打印在不同的媒体上,如纸张,或者在周围环境中。我们可以使用抽象类型来表示类似的东西:
abstract class PrintData
abstract class PrintMaterial
abstract class PrintMedia
trait Printer {
type Data <: PrintData
type Material <: PrintMaterial
type Media <: PrintMedia
def print(data: Data, material: Material, media: Media) =
s"Printing $data with $material material on $media media."
}
为了调用print方法,我们需要有不同的媒体、数据类型和材料:
case class Paper() extends PrintMedia
case class Air() extends PrintMedia
case class Text() extends PrintData
case class Model() extends PrintData
case class Toner() extends PrintMaterial
case class Plastic() extends PrintMaterial
现在我们来制作两个具体的打印机实现,一个激光打印机和 3D 打印机:
class LaserPrinter extends Printer {
type Media = Paper
type Data = Text
type Material = Toner
}
class ThreeDPrinter extends Printer {
type Media = Air
type Data = Model
type Material = Plastic
}
在前面的代码中,我们实际上给出了一些关于这些打印机可以使用的类型、媒体和材料的规范。这样,我们就不能要求我们的 3D 打印机使用碳粉打印东西,或者我们的激光打印机在空中打印。这就是我们将如何使用我们的打印机:
object PrinterExample {
def main(args: Array[String]): Unit = {
val laser = new LaserPrinter
val threeD = new ThreeDPrinter
System.out.println(laser.print(Text(), Toner(), Paper()))
System.out.println(threeD.print(Model(), Plastic(), Air()))
}
}
前面的代码非常易于阅读,并且使我们能够轻松指定具体类。它使建模变得更容易。有趣的是看到前面的代码如何转换为泛型:
trait GenericPrinter[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] {
def print(data: Data, material: Material, media: Media) =
s"Printing $data with $material material on $media media."
}
特性很容易表示,可读性和逻辑正确性在这里都没有受到影响。然而,我们必须以这种方式表示具体类:
class GenericLaserPrinter[Data <: Text, Material <: Toner, Media <: Paper] extends GenericPrinter[Data, Material, Media]
class GenericThreeDPrinter[Data <: Model, Material <: Plastic, Media <: Air] extends GenericPrinter[Data, Material, Media]
这会变得相当长,开发者很容易出错。以下片段显示了如何创建实例和使用这些类:
val genericLaser = new GenericLaserPrinter[Text, Toner, Paper]
val genericThreeD = new GenericThreeDPrinter[Model, Plastic, Air]
System.out.println(genericLaser.print(Text(), Toner(), Paper()))
System.out.println(genericThreeD.print(Model(), Plastic(), Air()))
在这里,我们可以看到,每次创建实例时都必须指定类型。想象一下,如果我们有超过三个泛型类型,其中一些可能基于泛型,例如集合。这可能会很快变得相当繁琐,并使代码看起来比实际更复杂。
另一方面,使用泛型允许我们重用GenericPrinter,而无需为每个不同的打印机表示显式地多次子类化它。然而,存在犯逻辑错误的风险:
class GenericPrinterImpl[Data <: PrintData, Material <: PrintMaterial, Media <: PrintMedia] extends GenericPrinter[Data, Material, Media]
如果按照以下方式使用,可能会出错:
val wrongPrinter = new GenericPrinterImpl[Model, Toner, Air]
System.out.println(wrongPrinter.print(Model(), Toner(), Air()))
使用建议
之前的例子展示了泛型和抽象类型使用之间的相对简单比较。这两个都是有用的概念;然而,了解确切正在做什么对于使用正确的一个来应对情况非常重要。以下是一些可以帮助做出正确决定的提示。
使用泛型:
-
如果你只需要类型实例化;一个很好的例子是标准集合类
-
如果你正在创建一系列类型
使用抽象类型:
-
如果你希望允许人们使用特性混合类型
-
如果你需要在两种类型可以互换的场景中提高可读性
-
如果你希望从客户端代码中隐藏类型定义
多态
多态是每个进行过一些面向对象编程的开发商都知道的东西。
多态帮助我们编写通用的代码,这些代码可以重用并应用于各种类型。
了解存在不同类型的多态很重要,我们将在本节中探讨它们。
子类型多态
这是每个开发商都知道的多态,它与在具体类实现中重写方法相关。考虑以下简单的层次结构:
abstract class Item {
def pack: String
}
class Fruit extends Item {
override def pack: String = "I'm a fruit and I'm packed in a bag."
}
class Drink extends Item {
override def pack: String = "I'm a drink and I'm packed in a bottle."
}
现在,让我们有一个包含物品的购物篮,并对每个物品调用pack:
object SubtypePolymorphismExample {
def main(args: Array[String]): Unit = {
val shoppingBasket: List[Item] = List(
new Fruit,
new Drink
)
shoppingBasket.foreach(i => System.out.println(i.pack))
}
}
如您所见,在这里我们可以使用抽象类型,只需调用pack方法,而不必考虑它确切是什么。多态将负责打印正确的值。我们的输出将如下所示:
I'm a fruit and I'm packed in a bag.
I'm a drink and I'm packed in a bottle.
子类型多态使用extends关键字通过继承来表示。
参数多态
函数式编程中的参数多态是我们之前关于泛型的章节中展示的内容。泛型是参数多态,正如我们之前所看到的,它们允许我们在任何类型或给定类型的子集上定义方法或数据结构。然后可以在稍后的阶段指定具体类型。
临时多态
临时多态与参数多态相似;然而,在这种情况下,参数的类型很重要,因为具体的实现将依赖于它。它是在编译时解决的,与在运行时进行的子类型多态不同。这有点类似于函数重载。
我们在本章前面看到了一个例子,其中我们创建了可以求和不同类型的Adder特质。让我们再举一个例子,但更加精细,一步一步来,我们希望理解事物是如何工作的。我们的目标是拥有一个可以添加许多不同类型的sum方法:
trait Adder[T] {
def sum(a: T, b: T): T
}
接下来,我们将创建一个 Scala 对象,它使用这个sum方法并将其暴露给外界:
object Adder {
def sumT: Adder: T = implicitly[Adder[T]].sum(a, b)
}
在前面的代码中,我们看到的是 Scala 中的一些语法糖,implicitly表示存在从T类型到Adder[T]的隐式转换。我们现在可以编写以下程序:
object AdhocPolymorphismExample {
import Adder._
def main(args: Array[String]): Unit = {
System.out.println(s"The sum of 1 + 2 is ${sum(1, 2)}")
System.out.println(s"The sum of abc + def is ${sum("abc", "def")}")
}
}
如果我们尝试编译并运行这个程序,我们将遇到麻烦并得到以下错误:
Error:(15, 51) could not find implicit value for evidence parameter of type com.ivan.nikolov.polymorphism.Adder[Int]
System.out.println(s"The sum of 1 + 2 is ${sum(1, 2)}")
^
Error:(16, 55) could not find implicit value for evidence parameter of type com.ivan.nikolov.polymorphism.Adder[String]
System.out.println(s"The sum of abc + def is ${sum("abc", "def")}")
^
这表明我们的代码不知道如何隐式地将整数或字符串转换为Adder[Int]或Adder[String]。我们必须做的是定义这些转换,并告诉我们的程序sum方法将做什么。我们的Adder对象将如下所示:
object Adder {
def sumT: Adder: T = implicitly[Adder[T]].sum(a, b)
implicit val int2Adder: Adder[Int] = new Adder[Int] {
override def sum(a: Int, b: Int): Int = a + b
}
// same implementation as above, but allowed when the trait has a single method
implicit val string2Adder: Adder[String] =
(a: String, b: String) => s"$a concatenated with $b"
}
如果我们现在编译并运行我们的应用程序,我们将得到以下输出:
The sum of 1 + 2 is 3
The sum of abc + def is abc concatenated with def
此外,如果你记得本章开头的例子,我们无法在字符串上使用sum方法。正如你所看到的,我们可以提供不同的实现,只要我们定义了一种将类型转换为Adder的方法,使用它就不会有问题。
特设多态性允许我们在不修改基类的情况下扩展我们的代码。如果我们正在使用外部库,或者由于某种原因我们无法更改原始代码,这将非常有用。这非常强大,并且在编译时进行评估,确保我们的程序按预期工作。此外,它还允许我们为无法访问的类型(在我们的例子中是Int和String)提供函数定义。
为多种类型添加函数
如果我们回顾本章开头,我们让Adder与数值类型一起工作的地方,我们会看到我们的最后一个Adder实现将需要我们为每种不同的数值类型分别定义一个操作。有没有办法在这里实现本章开头展示的内容呢?是的,有,做法如下:
implicit def numeric2Adder[T : Numeric]: Adder[T] = new Adder[T] {
override def sum(a: T, b: T): T = implicitly[Numeric[T]].plus(a, b)
}
我们刚刚定义了另一个隐式转换,它将为我们处理正确的事情。现在,我们也可以编写以下代码:
System.out.println(s"The sum of 1.2 + 6.5 is ${sum(1.2, 6.5)}")
特设多态性使用隐式表达式来混入行为。它是类型类设计模式的主要构建块,我们将在本书的后面部分探讨。
自类型
优秀代码的一个特点是关注点的分离。开发者应该努力使类及其方法只负责一件事情。这有助于测试、维护和更好地理解代码。记住——简单总是更好。
然而,在编写真实软件时,我们需要某些类的实例在其他类中,以便实现某些功能。换句话说,一旦我们的构建块被很好地分离,它们将具有依赖关系以执行其功能。我们在这里谈论的实际上归结为依赖注入。自定义类型提供了一种优雅地处理这些依赖关系的方法。在本节中,我们将看到如何使用它们以及它们有什么好处。
使用自定义类型
自定义类型允许我们轻松地将代码在我们的应用程序中分离,然后从其他地方调用它。一切都会通过例子变得更加清晰,所以让我们看看一个例子。假设我们想要能够将信息持久化到数据库中:
trait Persister[T] {
def persist(data: T)
}
persist方法将对数据进行一些转换,然后将其插入我们的数据库中。当然,我们的代码写得很好,所以数据库实现是分开的。对于我们的数据库,我们有以下内容:
import scala.collection.mutable
trait Database[T] {
def save(data: T)
}
trait MemoryDatabase[T] extends Database[T] {
val db: mutable.MutableList[T] = mutable.MutableList.empty
override def save(data: T): Unit = {
System.out.println("Saving to in memory database.")
db.+=:(data)
}
}
trait FileDatabase[T] extends Database[T] {
override def save(data: T): Unit = {
System.out.println("Saving to file.")
}
}
我们有一个基特质和一些具体的数据库实现。那么,我们如何将数据库传递给Persister呢?它应该能够调用数据库中定义的save方法。我们的可能性包括以下内容:
-
在
Persister中扩展Database。然而,这将使Persister也成为Database的一个实例,我们不想这样。我们将在稍后展示原因。 -
在
Persister中有一个Database变量的变量并使用它。 -
使用自定义类型。
我们在这里试图了解自定义类型是如何工作的,所以让我们使用这种方法。我们的Persister接口将变为以下内容:
trait Persister[T] {
this: Database[T] =>
def persist(data: T): Unit = {
System.out.println("Calling persist.")
save(data)
}
}
现在,我们可以访问Database中的方法,并在Persister内部调用save方法。
命名自定义类型 在前面的代码中,我们使用以下语句包含了我们的自定义类型——this: Database[T] =>。这允许我们直接访问包含它们的特质的成员方法,就像它们是特质的成员方法一样。在这里做同样的事情的另一种方式是写self: Database[T] =>。有很多例子使用后者方法,这在需要在一些嵌套特质或类定义中引用this时可以避免混淆。然而,使用这种方法调用注入依赖项的方法时,开发人员需要使用self.来访问所需的方法。
自定义类型要求任何将Persister混合进来的类也混合Database。否则,我们的编译将失败。让我们创建一些将数据持久化到内存和数据库的类:
class FilePersister[T] extends Persister[T] with FileDatabase[T]
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T]
最后,我们可以在我们的应用程序中使用它们:
object PersisterExample {
def main(args: Array[String]): Unit = {
val fileStringPersister = new FilePersister[String]
val memoryIntPersister = new MemoryPersister[Int]
fileStringPersister.persist("Something")
fileStringPersister.persist("Something else")
memoryIntPersister.persist(100)
memoryIntPersister.persist(123)
}
}
下面是我们程序的输出:
Calling persist.
Saving to file.
Calling persist.
Saving to file.
Calling persist.
Saving to in memory database.
Calling persist.
Saving to in memory database.
自定义类型所做的是与继承不同的。它们需要一些代码的存在,因此允许我们很好地分割功能。这可以在维护、重构和理解程序方面产生巨大的差异。
需要多个组件
在实际应用程序中,我们可能需要使用多个使用自身类型的组件。让我们通过一个History特性来展示这个例子,该特性可能会在某个时刻跟踪更改以进行回滚。我们的例子只是打印:
trait History {
def add(): Unit = {
System.out.println("Action added to history.")
}
}
我们需要在我们的Persister特性中使用这个功能,它看起来是这样的:
trait Persister[T] {
this: Database[T] with History =>
def persist(data: T): Unit = {
System.out.println("Calling persist.")
save(data)
add()
}
}
使用with关键字,我们可以添加我们想要的任何要求。然而,如果我们只是留下我们的代码更改,它将无法编译。原因是现在我们必须在每个使用Persister的类中混合History:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History
就这样。如果我们现在运行我们的代码,我们会看到这个:
Calling persist.
Saving to file.
Action added to history.
Calling persist.
Saving to file.
Action added to history.
Calling persist.
Saving to in memory database.
Action added to history.
Calling persist.
Saving to in memory database.
Action added to history.
冲突的组件
在前面的例子中,我们有一个对History特性的要求,它有一个add()方法。如果不同组件中的方法具有相同的签名并且它们发生冲突,会发生什么?让我们尝试这样做:
trait Mystery {
def add(): Unit = {
System.out.println("Mystery added!")
}
}
我们现在可以在我们的Persister特性中使用这个功能:
trait Persister[T] {
this: Database[T] with History with Mystery =>
def persist(data: T): Unit = {
System.out.println("Calling persist.")
save(data)
add()
}
}
当然,我们将更改所有混合Persister的类:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery
如果我们尝试编译我们的应用程序,我们会看到它导致以下错误消息:
Error:(47, 7) class FilePersister inherits conflicting members:
method add in trait History of type ()Unit and
method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class FilePersister.)
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery
^
Error:(48, 7) class MemoryPersister inherits conflicting members:
method add in trait History of type ()Unit and
method add in trait Mystery of type ()Unit
(Note: this can be resolved by declaring an override in class MemoryPersister.)
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery
^
幸运的是,错误消息还包含信息,告诉我们如何修复问题。这与我们之前使用特性时看到的情况完全相同,我们可以提供以下修复方案:
class FilePersister[T] extends Persister[T] with FileDatabase[T] with History with Mystery {
override def add(): Unit ={
super[History].add()
}
}
class MemoryPersister[T] extends Persister[T] with MemoryDatabase[T] with History with Mystery {
override def add(): Unit ={
super[Mystery].add()
}
}
运行示例后,我们将看到以下输出:
Calling persist.
Saving to file.
Action added to history.
Calling persist.
Saving to file.
Action added to history.
Calling persist.
Saving to in memory database.
Mystery added!
Calling persist.
Saving to in memory database.
Mystery added!
自身类型和蛋糕设计模式
在我们前面的例子中,我们看到的是一个纯粹的依赖注入示例。我们要求一个组件通过自身类型在另一个组件中可用。
自身类型通常用于依赖注入。它们是蛋糕设计模式的主要部分,我们将在本书的后面部分熟悉它。
蛋糕设计模式完全依赖于自身类型。它鼓励工程师编写小型且简单的组件,这些组件声明并使用它们的依赖项。在应用程序中的所有组件都被编程之后,它们可以在一个公共组件注册表中实例化,并使它们对实际应用程序可用。蛋糕设计模式的一个很好的优点是,它实际上在编译时检查所有依赖项是否都能得到满足。我们将在本书的后面部分专门用一整节来介绍蛋糕设计模式,我们将提供更多关于如何实际连接模式、它有哪些优点和缺点等详细信息。
自身类型与继承
在前面的章节中,我们说我们不想使用继承来访问Database方法。为什么是这样?如果我们让Persister扩展Database,这意味着它将成为一个数据库本身(is-a关系)。然而,这是不正确的。它使用数据库来实现其功能。
继承将子类暴露于其父类的实现细节。然而,这并不总是我们所希望的。根据《设计模式:可复用面向对象软件元素》的作者,开发者应该优先选择对象组合而非类继承。
继承泄露功能
如果我们使用继承,我们也会泄露我们不希望子类拥有的功能。让我们看看以下代码:
trait DB {
def connect(): Unit = {
System.out.println("Connected.")
}
def dropDatabase(): Unit = {
System.out.println("Dropping!")
}
def close(): Unit = {
System.out.println("Closed.")
}
}
trait UserDB extends DB {
def createUser(username: String): Unit = {
connect()
try {
System.out.println(s"Creating a user: $username")
} finally {
close()
}
}
def getUser(username: String): Unit = {
connect()
try {
System.out.println(s"Getting a user: $username")
} finally {
close()
}
}
}
trait UserService extends UserDB {
def bad(): Unit = {
dropDatabase()
}
}
这可能是一个现实生活中的场景。因为这就是继承的工作方式,我们会在UserService中获得对dropDatabase的访问权限。这是我们不想看到的事情,我们可以通过使用自身类型来修复它。DB 特质保持不变。其他所有内容都变为以下内容:
trait UserDB {
this: DB =>
def createUser(username: String): Unit = {
connect()
try {
System.out.println(s"Creating a user: $username")
} finally {
close()
}
}
def getUser(username: String): Unit = {
connect()
try {
System.out.println(s"Getting a user: $username")
} finally {
close()
}
}
}
trait UserService {
this: UserDB =>
// does not compile
// def bad(): Unit = {
// dropDatabase()
//}
}
如代码注释所示,在这最后一个版本的代码中,我们无法访问DB特质的函数。我们只能调用所需类型的函数,这正是我们想要实现的目标。
摘要
在本章中,我们熟悉了一些有助于我们编写更好、更通用和可扩展的软件的概念。我们专注于 Scala 中的抽象类型、多态和自身类型。
我们探讨了类中泛型和抽象类型值的区别,并附带了一些示例和用法建议。然后,我们介绍了不同类型的多态——子类型、参数化和特定。最后,我们探讨了 Scala 中的自身类型及其使用方法。我们展示了自身类型提供了一种封装功能并编写模块化代码的好方法。
在下一章中,我们将探讨分离软件组件责任的重要性。我们还将介绍面向方面编程。
第五章:面向方面编程与组件
在编程中,我们经常看到在不同方法中重复的源代码片段。在某些情况下,我们可以重构我们的代码并将它们移动到单独的模块中。然而,有时这是不可能的。一些值得注意的例子包括日志记录和验证。面向方面编程在这种情况下很有帮助,我们将在本章结束时对其有一个了解。
组件是可重用的代码片段,提供一系列服务并有一些要求。它们对于避免代码重复以及当然促进代码重用非常有用。在这里,我们将了解如何构建组件以及 Scala 如何使组件的编写和使用比其他语言更简单。
在熟悉面向方面编程和组件的过程中,我们将探讨以下顶级主题:
-
面向方面编程
-
Scala 中的组件
面向方面编程
面向方面编程(AOP)解决了一个常见功能,该功能跨越整个应用程序,但无法使用传统的面向对象技术在一个模块中抽象。这种重复的功能通常被称为横切关注点。一个常见的例子是日志记录——通常,日志记录器是在类内部创建的,然后在其方法内部调用这些方法。这有助于调试和跟踪应用程序中的事件,但与实际功能并没有真正的关联。
面向方面编程建议将横切关注点抽象并封装在其自己的模块中。在接下来的几个小节中,我们将探讨 AOP 如何改进代码,以及如何使横切关注点易于扩展。
理解应用程序效率
每个程序的一个重要部分是效率。在许多情况下,我们可以计时我们的方法,并找到应用程序中的瓶颈。让我们看看一个示例程序,我们将在之后尝试计时。
我们将看看解析。在许多实际应用中,我们必须以特定格式读取数据并将其解析为代码中的对象。对于这个例子,我们将有一个以 JSON 格式表示的小型人员数据库:
[
{
"firstName": "Ivan",
"lastName": "Nikolov",
"age": 26
},
{
"firstName": "John",
"lastName": "Smith",
"age": 55
},
{
"firstName": "Maria",
"lastName": "Cooper",
"age": 19
}
]
为了在 Scala 中表示这个 JSON,我们必须定义我们的模型。它将是简单的,只包含一个类——Person。以下是它的代码:
case class Person(firstName: String, lastName: String, age: Int)
由于我们将读取 JSON 输入,我们不得不解析它们。市面上有很多解析器,每个人可能都有自己的偏好。在当前示例中,我们使用了 json4s (github.com/json4s/json4s)。在我们的build.sbt/pom.xml文件中,我们有以下额外的依赖项:
<dependency>
<groupId>org.json4s</groupId>
<artifactId>json4s-jackson_2.12</artifactId>
<version>3.6.0-M2</version>
</dependency>
下面的示例显示了pom.xml版本的build.sbt等效:
"org.json4s" %% "json4s-jackson" % "3.6.0-M2"
我们已经编写了一个包含两个方法的类,这些方法解析给定格式的输入文件并返回一个Person对象列表。这两个方法做的是完全相同的事情,但其中一个比另一个更高效:
import org.json4s._
import org.json4s.jackson.JsonMethods._
trait DataReader {
def readData(): List[Person]
def readDataInefficiently(): List[Person]
}
class DataReaderImpl extends DataReader {
implicit val formats = DefaultFormats
private def readUntimed(): List[Person] =
parse(StreamInput(getClass.getResourceAsStream("/users.json"))).extract[List[Person]]
override def readData(): List[Person] = readUntimed()
override def readDataInefficiently(): List[Person] = {
(1 to 10000).foreach {
case num =>
readUntimed()
}
readUntimed()
}
}
DataReader特质充当一个接口,使用其实现相当直接:
object DataReaderExample {
def main(args: Array[String]): Unit = {
val dataReader = new DataReaderImpl
System.out.println(s"I just read the following data efficiently:
${dataReader.readData()}")
System.out.println(s"I just read the following data inefficiently:
${dataReader.readDataInefficiently()}")
}
}
它将产生如下截图所示的输出:

前面的例子很清晰。然而,如果我们想优化我们的代码并查看导致其变慢的原因呢?之前的代码没有给我们这个可能性,所以我们将不得不采取一些额外的步骤来计时并查看我们的应用程序的性能。在以下小节中,我们将展示如何在不使用和在使用 AOP 的情况下完成这项工作。
不使用 AOP 计时我们的应用程序
有一种基本的方法来做我们的计时。我们可以在应用程序中的println语句周围添加,或者将计时作为DataReaderImpl类中的方法的一部分。一般来说,将计时作为方法的一部分似乎是一个更好的选择,因为在某些情况下,这些方法可能在不同的地方被调用,并且它们的性能将取决于传递的参数和其他因素。考虑到我们所说的,这是我们的DataReaderImpl类可以被重构以支持计时的方法:
import org.json4s._
import org.json4s.jackson.JsonMethods._
class DataReaderImpl extends DataReader {
implicit val formats = DefaultFormats
private def readUntimed(): List[Person] =
parse(StreamInput(getClass.getResourceAsStream("/users.json")))
.extract[List[Person]]
override def readData(): List[Person] = {
val startMillis = System.currentTimeMillis()
val result = readUntimed()
val time = System.currentTimeMillis() - startMillis
System.err.println(s"readData took ${time} milliseconds.")
result
}
override def readDataInefficiently(): List[Person] = {
val startMillis = System.currentTimeMillis()
(1 to 10000).foreach {
case num =>
readUntimed()
}
val result = readUntimed()
val time = System.currentTimeMillis() - startMillis
System.err.println(s"readDataInefficiently took ${time} milliseconds.")
result
}
}
如你所见,代码变得难以阅读,计时干扰了实际的功能。无论如何,如果我们运行我们的程序,输出将显示问题所在:

我们将在下一小节中看到如何使用面向方面的编程来改进我们的代码。
在前面的例子中,我们使用了System.err.println来记录计时。这只是为了示例目的。在实践中,使用日志记录器,例如slf4j (www.slf4j.org/),是推荐的选择,因为你可以有不同的日志级别,并通过配置文件切换日志。在这里使用日志记录器会添加额外的依赖项,并且会分散你的注意力,使其远离重要的材料。
使用 AOP 计时我们的应用程序
正如我们所见,将我们的计时代码添加到我们的方法中引入了代码重复,并使得我们的代码难以理解,即使是对于一个小例子。现在,想象一下我们还需要进行日志记录和其他活动。面向方面的编程有助于分离这些关注点。
我们可以将DataReaderImpl类恢复到其原始状态,其中它不会进行任何日志记录。然后,我们创建另一个名为LoggingDataReader的特质,它从DataReader扩展而来,并包含以下内容:
trait LoggingDataReader extends DataReader {
abstract override def readData(): List[Person] = {
val startMillis = System.currentTimeMillis()
val result = super.readData()
val time = System.currentTimeMillis() - startMillis
System.err.println(s"readData took ${time} milliseconds.")
result
}
abstract override def readDataInefficiently(): List[Person] = {
val startMillis = System.currentTimeMillis()
val result = super.readDataInefficiently()
val time = System.currentTimeMillis() - startMillis
System.err.println(s"readDataInefficiently took ${time} milliseconds.")
result
}
}
这里有趣的是abstract override修饰符。它通知编译器我们将进行可堆叠的修改。如果我们不使用这个修饰符,我们的编译将失败,并出现以下错误:
Error:(9, 24) method readData in trait DataReader is accessed from super. It may not be abstract unless it is overridden by a member declared `abstract' and `override'
val result = super.readData()
^
Error:(17, 24) method readDataInefficiently in trait DataReader is accessed from super. It may not be abstract unless it is overridden by a member declared `abstract' and `override'
val result = super.readDataInefficiently()
^
现在,让我们使用我们新的特质,通过混合组合,这是我们在这本书的早期部分已经介绍过的,在以下程序中:
object DataReaderAOPExample {
def main(args: Array[String]): Unit = {
val dataReader = new DataReaderImpl with LoggingDataReader
System.out.println(s"I just read the following data efficiently:
${dataReader.readData()}")
System.out.println(s"I just read the following data inefficiently:
${dataReader.readDataInefficiently()}")
}
}
如果我们运行这个程序,我们会看到,就像之前一样,我们的输出将包含时间信息。
使用面向方面编程的优势是显而易见的——实现不会被其他与之无关的代码所污染。此外,我们可以使用相同的方法添加额外的修改——更多的日志记录、重试逻辑、回滚等。所有这些操作只需创建新的特质,扩展 DataReader 并将它们混合在一起,就像之前所展示的那样。当然,我们可以同时应用多个修改,它们将按顺序执行,其执行顺序将遵循我们已熟悉的线性化规则。
Scala 中的组件
组件是应用程序的组成部分,旨在与其他应用程序的组成部分结合使用。它们应该是可重用的,以便减少代码重复。组件通常具有接口,这些接口描述了它们提供的服务以及它们依赖的服务或其他组件的数量。
在大型应用程序中,我们通常看到多个组件被集成在一起协同工作。描述一个组件提供的服务通常是直接的,并且是通过接口来完成的。然而,集成其他组件有时可能需要开发者做额外的工作。这通常是通过将所需接口作为参数传递来完成的。然而,想象一下,在一个大型应用程序中,我们可能有很多需求;连接这些组件可能需要时间和精力。此外,每次出现新的需求时,我们都必须进行相当多的重构。参数的另一种选择是多重继承;然而,语言需要以某种方式支持它。
在像 Java 这样的语言中,将组件连接起来的流行方式是通过依赖注入。在 Java 中存在一些库,可以在运行时将组件注入到彼此中。
使用 Scala 的表达力构建组件
在这本书中,我们已经多次提到 Scala 是一种比简单的面向对象语言更具表达力的语言。我们已经探讨了诸如抽象类型、自类型、统一和混合组合等概念。它们使我们能够创建泛型代码,要求特定的类,并且能够以相同的方式处理对象、类、变量和函数,从而实现多重继承。使用这些组合的不同组合将使我们能够编写我们寻找的模块化代码。
实现组件
依赖注入在连接组件方面非常流行。然而,在像 Java 这样的语言中,这意味着我们需要有人使用与我们相同的库。在我们的应用程序中的类中拥有大量的参数也不可接受。这使得犯错误更容易,并将重构和代码扩展变成了一场噩梦。
在下一个子节中,我们将探讨如何使用 Scala 的自类型来创建和组合组件。
组件的自类型
例如,让我们想象我们正在尝试构建一个烹饪食物的机器人。我们的机器人将能够查找食谱并烹饪我们要求的菜肴,还能告诉我们时间。我们可以通过简单地创建新的组件来为我们的机器人添加额外的功能。
我们希望我们的代码是模块化的,因此分割功能是有意义的。以下图表显示了我们的机器人将是什么样子以及不同组件之间的关系:

首先,让我们定义不同组件的接口:
trait Time {
def getTime(): String
}
trait RecipeFinder {
def findRecipe(dish: String): String
}
trait Cooker {
def cook(what: String): Food
}
我们需要定义Food类,在这个例子中,它将非常简单:
case class Food(name: String)
完成这些后,我们可以开始创建我们的组件。首先是TimeComponent和嵌套类中Time的实现:
trait TimeComponent {
val time: Time
class TimeImpl extends Time {
val formatter = DateTimeFormatter.ofPattern("HH:mm:ss")
override def getTime(): String = s"The time is:
${LocalDateTime.now().format(formatter)}"
}
}
现在,我们可以以类似的方式实现RecipeComponent。以下是对应的组件代码和实现代码:
trait RecipeComponent {
val recipe: RecipeFinder
class RecipeFinderImpl extends RecipeFinder {
override def findRecipe(dish: String): String = dish match {
case "chips" => "Fry the potatoes for 10 minutes."
case "fish" => "Clean the fish and put in the oven for 30 minutes."
case "sandwich" => "Put butter, ham and cheese on the bread,
toast and add tomatoes."
case _ => throw new RuntimeException(s"${dish} is unknown recipe.")
}
}
}
最后,我们需要实现CookingComponent。实际上,它需要一个RecipeComponent。以下是实现方式:
trait CookingComponent {
this: RecipeComponent =>
val cooker: Cooker
class CookerImpl extends Cooker {
override def cook(what: String): Food = {
val recipeText = recipe.findRecipe(what)
Food(s"We just cooked $what using the following recipe:
'$recipeText'.")
}
}
}
现在,我们已经分别实现了所有组件,并且可以将它们组合起来创建我们的机器人。我们将创建一个机器人将使用的组件注册表,如下所示:
class RobotRegistry extends TimeComponent with RecipeComponent with CookingComponent {
override val time: Time = new TimeImpl
override val recipe: RecipeFinder = new RecipeFinderImpl
override val cooker: Cooker = new CookerImpl
}
现在,让我们创建一个Robot:
class Robot extends RobotRegistry {
def cook(what: String) = cooker.cook(what)
def getTime() = time.getTime()
}
使用我们的机器人的示例程序将如下所示:
object RobotExample {
def main(args: Array[String]): Unit = {
val robot = new Robot
System.out.println(robot.getTime())
System.out.println(robot.cook("chips"))
System.out.println(robot.cook("sandwich"))
}
}
该程序的示例输出如下截图所示:

在前面的示例中,我们看到了 Scala 实现依赖注入的方式,这种方式易于连接,无需使用额外的库。这非常有用,因为它不会使我们的构造函数变得庞大,我们也不必扩展许多类。此外,我们拥有的组件被很好地分离,可测试,并且清楚地定义了它们的要求。我们还看到了如何使用需要其他组件的组件递归地添加要求。
上述示例实际上是蛋糕设计模式的表示。这里的一个优点是,依赖关系的存在是在编译时而不是在运行时评估的,这与流行的 Java 库不同。
蛋糕设计模式也有其缺点,但我们将在这个书中关注所有特性——无论是好是坏。这就是我们将展示如何测试组件的地方。
本章中蛋糕设计模式的示例非常简单。在实际应用中,我们可能会有依赖于其他组件的组件,这些组件又有自己的依赖关系,如此等等。在这种情况下,事情可能会变得复杂。我们将在本书的后面部分以更好的方式、更详细地展示这一点。
摘要
在本章中,我们探讨了 Scala 中的面向方面编程。我们现在知道如何将通常不可能移动到模块中的代码分离出来。这将有助于避免代码重复,并使我们的程序通过不同的、专业的模块变得更加出色。
我们还展示了如何使用本书前几章中介绍的技术来创建可重用组件。组件提供接口并具有特定要求,这些要求可以利用 Scala 的丰富性轻松满足。它们与设计模式非常相关,因为它们有相同的目的——使代码更优,避免重复,并且能够轻松测试。
在本书的后续章节中,我们将开始探讨一些具有有用特性和用例的具体设计模式。我们将从创建型设计模式开始,因为这些模式是由GoF定义的,但当然,从 Scala 的角度来看。
第六章:创建型设计模式
从本章开始,我们将深入研究实际存在的各种设计模式。我们已经提到了了解和能够正确使用不同设计模式的重要性。
可以将设计模式视为解决特定问题的最佳实践或甚至模板。开发者将不得不解决的问题数量是无限的,在许多情况下,必须结合不同的设计模式。然而,基于代码编写以解决程序中某个问题的方面,我们可以将设计模式分为以下主要类别:
-
创建型
-
结构型
-
行为型
本章将专注于创建型设计模式,当然,我们将从 Scala 编程语言的角度来审视它们。我们将讨论以下主题:
-
什么是创建型设计模式
-
工厂方法
-
抽象工厂
-
其他工厂设计模式
-
懒初始化
-
单例
-
构造器
-
原型
在正式定义创建型设计模式之后,我们将更详细地分别审视每一个。我们将关注何时以及如何使用它们,何时避免某些模式,当然,还会展示一些相关的例子。
什么是创建型设计模式?
如其名所示,创建型设计模式处理对象创建。在某些情况下,在程序中创建对象可能涉及一些额外的复杂性,而创建型设计模式隐藏这些复杂性,以便使软件组件的使用更加容易。对象创建的复杂性可能由以下任何一个原因引起:
-
初始化参数的数量
-
需要的验证
-
获取所需参数的复杂性
前面的列表可能还会进一步扩展,并且在许多情况下,这些因素不仅单独存在,而且以组合的形式存在。
我们将在本章接下来的部分中关注创建型设计模式的各个方面,并希望你能对为什么需要它们以及如何在现实生活中使用它们有一个良好的理解。
工厂方法设计模式
工厂方法设计模式的存在是为了封装实际的类实例化。它仅仅提供了一个创建对象的接口,然后工厂的子类决定实例化哪个具体的类。这种设计模式在需要在不同运行时创建不同对象的情况下可能很有用。当对象创建需要开发者传递额外的参数时,这种设计模式也很有帮助。
通过一个例子,一切都会变得清晰起来,我们将在以下小节中提供一个例子。
一个示例类图
对于工厂方法,我们将通过数据库的例子来展示。为了使事情简单(因为实际的 java.sql.Connection 有很多方法),我们将定义自己的 SimpleConnection,并为 MySQL 和 PostgreSQL 提供具体实现。
连接类的图示如下:

现在,创建这些连接将取决于我们想要使用的数据库。然而,由于它们提供的接口相同,使用它们的方式也将完全一样。实际的创建可能还涉及一些我们想要从用户那里隐藏的额外计算,这些计算在讨论每个数据库的不同常量时将是相关的。这就是我们使用工厂方法设计模式的原因。以下图示显示了我们的其余代码结构:

在前面的图中,MysqlClient 和 PgsqlClient 是 DatabaseClient 的具体实现。工厂方法为 connect,它在不同客户端返回不同的连接。由于我们进行了重写,代码中的方法签名仍然显示该方法返回一个 SimpleConnection,但实际上返回的是具体类型。在图中,为了清晰起见,我们选择显示实际返回的类型。
代码示例
从前面的图中可以看出,根据我们使用的数据库客户端,将使用和创建不同的连接。现在,让我们看看前面图的代码表示。首先是 SimpleConnection 及其具体实现:
trait SimpleConnection {
def getName(): String
def executeQuery(query: String): Unit
}
class SimpleMysqlConnection extends SimpleConnection {
override def getName(): String = "SimpleMysqlConnection"
override def executeQuery(query: String): Unit = {
System.out.println(s"Executing the query '$query' the MySQL way.")
}
}
class SimplePgSqlConnection extends SimpleConnection {
override def getName(): String = "SimplePgSqlConnection"
override def executeQuery(query: String): Unit = {
System.out.println(s"Executing the query '$query' the PgSQL way.")
}
}
我们在名为 connect 的工厂方法中使用这些实现。以下代码片段显示了如何利用 connect 并如何在特定数据库客户端中实现它:
abstract class DatabaseClient {
def executeQuery(query: String): Unit = {
val connection = connect()
connection.executeQuery(query)
}
protected def connect(): SimpleConnection
}
class MysqlClient extends DatabaseClient {
override protected def connect(): SimpleConnection = new SimpleMysqlConnection
}
class PgSqlClient extends DatabaseClient {
override protected def connect(): SimpleConnection = new SimplePgSqlConnection
}
使用我们的数据库客户端很简单,如下所示:
object Example {
def main(args: Array[String]): Unit = {
val clientMySql: DatabaseClient = new MysqlClient
val clientPgSql: DatabaseClient = new PgSqlClient
clientMySql.executeQuery("SELECT * FROM users")
clientPgSql.executeQuery("SELECT * FROM employees")
}
}
上述代码示例将产生以下输出:

我们看到了工厂方法设计模式是如何工作的。如果我们需要添加另一个数据库客户端,我们只需扩展 DatabaseClient,并在实现 connect 方法时返回一个扩展 SimpleConnection 的类。
前面选择使用抽象类作为 DatabaseClient 和特质作为 SimpleConnection 只是随机的。我们当然可以用特质替换抽象类。
在其他情况下,由工厂方法创建的对象可能在构造函数中需要参数,这些参数可能依赖于拥有工厂方法的对象的某些特定状态或功能。这正是这种设计模式可以真正发挥作用的地方。
Scala 的替代方案
就像软件工程中的任何事物一样,这个设计模式也可以使用不同的方法来实现。到底使用哪种方法,实际上取决于应用程序和创建的对象的需求和特定功能。一些可能的替代方案包括以下内容:
-
在构造函数中将所需的组件传递给需要它们的类(对象组合)。然而,这意味着每次请求这些组件时,它们将是特定的实例而不是新的实例。
-
传递一个将创建我们需要的对象的函数。
利用 Scala 的丰富性,我们可以避免这种设计模式,或者我们可以更聪明地创建我们将要使用或暴露的对象,或者工厂方法将要创建的对象。最终,没有绝对的对错。然而,有一种方法可以使事情在用法和维护方面都更简单,这应该根据具体要求来选择。
它有什么好处?
与其他工厂一样,对象创建的细节被隐藏了。这意味着如果我们需要改变特定实例的创建方式,我们只需要更改创建它的工厂方法(尽管这可能涉及到很多创建者,这取决于设计)。工厂方法允许我们使用类的抽象版本,并将对象创建推迟到子类。
它有什么不好?
在前面的例子中,如果我们有多个工厂方法,我们可能会很快遇到问题。这首先要求程序员实现更多的方法,但更重要的是,它可能导致返回的对象不兼容。让我们通过一个简短的例子来看看这一点。首先,我们将声明另一个特质SimpleConnectionPrinter,它将有一个方法,当被调用时打印一些内容:
trait SimpleConnectionPrinter {
def printSimpleConnection(connection: SimpleConnection): Unit
}
现在,我们想要改变我们的DatabaseClient并将其命名为不同的名称(BadDatabaseClient)。它看起来如下所示:
abstract class BadDatabaseClient {
def executeQuery(query: String): Unit = {
val connection = connect()
val connectionPrinter = getConnectionPrinter()
connectionPrinter.printSimpleConnection(connection)
connection.executeQuery(query)
}
protected def connect(): SimpleConnection
protected def getConnectionPrinter(): SimpleConnectionPrinter
}
与我们的原始示例相比,这里唯一的区别是我们还有一个工厂方法,我们也会在执行查询时调用它。类似于SimpleConnection实现,现在让我们为我们的SimpleConnectionPrinter创建两个更多用于 MySQL 和 PostgreSQL 的:
class SimpleMySqlConnectionPrinter extends SimpleConnectionPrinter {
override def printSimpleConnection(connection: SimpleConnection): Unit = {
System.out.println(s"I require a MySQL connection. It is: '${connection.getName()}'")
}
}
class SimplePgSqlConnectionPrinter extends SimpleConnectionPrinter {
override def printSimpleConnection(connection: SimpleConnection): Unit = {
System.out.println(s"I require a PgSQL connection. It is: '${connection.getName()}'")
}
}
现在我们可以应用工厂设计模式并创建 MySQL 和 PostgreSQL 客户端,如下所示:
class BadMySqlClient extends BadDatabaseClient {
override protected def connect(): SimpleConnection = new SimpleMysqlConnection
override protected def getConnectionPrinter(): SimpleConnectionPrinter = new SimpleMySqlConnectionPrinter
}
class BadPgSqlClient extends BadDatabaseClient {
override protected def connect(): SimpleConnection = new SimplePgSqlConnection
override protected def getConnectionPrinter(): SimpleConnectionPrinter = new SimpleMySqlConnectionPrinter
}
前面的实现是完全有效的。我们现在可以在一个例子中使用它们:
object BadExample {
def main(args: Array[String]): Unit = {
val clientMySql: BadDatabaseClient = new BadMySqlClient
val clientPgSql: BadDatabaseClient = new BadPgSqlClient
clientMySql.executeQuery("SELECT * FROM users")
clientPgSql.executeQuery("SELECT * FROM employees")
}
}
这个例子将产生以下输出:

在前面的例子中发生的情况是我们遇到了逻辑错误,并且没有任何通知告诉我们这一点。当需要实现的方法数量增加时,这可能会成为一个问题,错误也容易被犯。例如,我们的代码没有抛出异常,但这个陷阱可能导致难以发现和调试的运行时错误。
抽象工厂
抽象工厂是工厂模式家族中的另一个设计模式。其目的是与所有工厂设计模式相同——封装对象创建逻辑并隐藏它。不同之处在于它的实现方式。
与工厂方法使用的继承相比,抽象工厂设计模式依赖于对象组合。在这里,我们有一个单独的对象,它提供了一个接口来创建我们需要的类的实例。
一个示例类图
让我们继续使用之前的SimpleConnection示例。以下图显示了抽象工厂的结构:

如前图所示,现在我们有一个工厂的层次结构,而不是数据库客户端内部的某个方法。在我们的应用程序中,我们将使用抽象的DatabaseConnectorFactory,并且它将根据实际的实例类型返回正确的对象。
代码示例
让我们从源代码的角度来看我们的示例。以下代码列表显示了工厂层次结构:
trait DatabaseConnectorFactory {
def connect(): SimpleConnection
}
class MySqlFactory extends DatabaseConnectorFactory {
override def connect(): SimpleConnection = new SimpleMysqlConnection
}
class PgSqlFactory extends DatabaseConnectorFactory {
override def connect(): SimpleConnection = new SimplePgSqlConnection
}
我们可以通过将其传递给一个类来使用我们的工厂,该类将调用所需的方法。以下是一个类似于我们之前展示的工厂方法设计模式的示例:
class DatabaseClient(connectorFactory: DatabaseConnectorFactory) {
def executeQuery(query: String): Unit = {
val connection = connectorFactory.connect()
connection.executeQuery(query)
}
}
让我们看看一个使用我们的数据库客户端的示例:
object Example {
def main(args: Array[String]): Unit = {
val clientMySql: DatabaseClient = new DatabaseClient(new MySqlFactory)
val clientPgSql: DatabaseClient = new DatabaseClient(new PgSqlFactory)
clientMySql.executeQuery("SELECT * FROM users")
clientPgSql.executeQuery("SELECT * FROM employees")
}
}
以下截图显示了此程序的输出:

这就是抽象工厂设计模式的工作方式。如果我们需要将另一个数据库客户端添加到我们的应用程序中,我们可以通过添加一个扩展DatabaseConnectionFactory的类来实现这一点。这很好,因为它使得重构和扩展变得容易。
Scala 替代方案
这个设计模式也可以使用不同的方法实现。我们使用对象组合将工厂传递给我们的类的事实表明,我们可以做其他事情——我们只需传递一个函数,因为在 Scala 中,它们是统一的一部分,并且被当作对象一样对待。
它适用于什么?
与所有工厂一样,对象的创建细节是隐藏的。当我们想要暴露对象家族(例如,数据库连接器)时,抽象工厂设计模式特别有用。客户端因此与具体类解耦。这个模式通常在不同的 UI 工具包中作为示例展示,其中元素因不同的操作系统而异。它也相当易于测试,因为我们可以向客户端提供模拟而不是实际的工厂。
尽管我们之前提到的不兼容性问题仍然存在,但现在遇到它的难度有所增加。这主要是因为在这里,客户端实际上只需传递一个单独的工厂作为参数,如果我们提供了具体的工厂,那么在编写这些工厂时,所有的事情都已经处理好了。
它不适用于什么?
如果我们使用的对象和方法(在我们的例子中是SimpleConnection)更改了签名,可能会出现问题。在某些情况下,这种模式也可能不必要地使我们的代码复杂化,使其难以阅读和跟踪。
其他工厂设计模式
工厂设计模式有多种不同的变体。不过,在所有情况下,目的通常都是相同的——隐藏创建复杂性。在接下来的小节中,我们将简要介绍两种其他的工厂设计模式——静态工厂和简单工厂。
静态工厂
静态工厂可以表示为一个静态方法,它是基类的一部分。它被调用以创建扩展基类的具体实例。然而,这里最大的缺点之一是,如果添加了基类的另一个扩展,由于静态方法,基类也必须被编辑。让我们从一个动物世界的简单例子来展示:
trait Animal
class Bird extends Animal
class Mammal extends Animal
class Fish extends Animal
object Animal {
def apply(animal: String): Animal = animal.toLowerCase match {
case "bird" => new Bird
case "mammal" => new Mammal
case "fish" => new Fish
case x: String => throw new RuntimeException(s"Unknown animal: $x")
}
}
在这里,每次我们添加Animal的新扩展时,我们都需要更改apply方法来考虑它,尤其是如果我们想要考虑新的类型。
之前的例子使用了Animal伴生对象的特殊apply方法。我们可以有不同的版本,它将为我们提供一种语法糖,允许我们简单地使用Animal("mammal")。这使得使用工厂变得更加方便,因为由于基类,其存在将由好的 IDE 指示。
简单工厂
简单工厂比静态工厂更好,因为实际的工厂功能在另一个类中。这消除了每次添加新扩展时修改基类的要求。这与抽象工厂类似,但不同之处在于这里我们没有基工厂类,而是使用一个具体的类。通常,人们从一个简单的工厂开始,随着时间的推移和项目的演变,它逐渐演变为抽象工厂。
工厂组合
当然,可以将不同类型的工厂组合在一起。然而,这需要谨慎进行,并且只有在必要时才这么做。否则,过度使用设计模式可能会导致代码质量下降。
懒加载
软件工程中的懒加载是指在第一次需要时才实例化一个对象或变量。这种做法背后的理念是推迟或甚至避免一些昂贵的操作。
一个示例类图
在其他语言中,例如 Java,懒加载通常与工厂方法设计模式结合使用。这种方法通常检查我们想要使用的对象/变量是否已初始化;如果没有,它将初始化对象,并最终返回它。在连续使用中,已初始化的对象/变量将被返回。
Scala 编程语言内置了对懒加载的支持。它使用了lazy关键字。这就是为什么在这种情况下提供类图是毫无意义的。
代码示例
让我们看看 Scala 中的懒加载是如何工作的,并证明它确实是懒加载的。我们将查看一个计算圆面积的示例。正如我们所知,公式是π * r²。编程语言支持数学常数,但这并不是我们在现实生活中会这样做的方式。然而,如果我们谈论的是一个不太为人所知的常数,或者一个通常围绕某个值波动但每天可能不同的常数,这个例子仍然相关。
在学校,我们被教导π等于 3.14。然而,这确实是正确的,但在那之后还有很多额外的数字,如果我们真的关心精度,我们也需要考虑它们。例如,100 位数字的π看起来是这样的:
3.1415926535897932384626433832795028841971693993751058209749445923078164062862089986280348253421170679
因此,让我们创建一个实用工具,当给定圆的半径时,它会返回面积。在我们的实用工具类中,我们将有一个基本的π变量,但我们将允许用户决定他们是否想要精确的面积。如果他们想要,我们将从配置文件中读取 100 位数字的π:
import java.util.Properties
object CircleUtils {
val basicPi = 3.14
lazy val precisePi: Double = {
System.out.println("Reading properties for the precise PI.")
val props = new Properties()
props.load(getClass.getResourceAsStream("pi.properties"))
props.getProperty("pi.high").toDouble
}
def area(radius: Double, isPrecise: Boolean = false): Double = {
val pi: Double = if (isPrecise) precisePi else basicPi
pi * Math.pow(radius, 2)
}
}
上述代码确实做了我们所说的。根据精度,我们将使用π的不同版本。这里的懒加载是有用的,因为我们可能永远不需要精确的面积,或者我们可能有时需要,有时不需要。此外,从配置文件中读取是一个 I/O 操作,被认为是慢的,并且当多次执行时可能会产生负面影响。让我们看看我们如何使用我们的实用工具:
object Example {
def main(args: Array[String]): Unit = {
System.out.println(s"The basic area for a circle with radius 2.5 is ${CircleUtils.area (2.5)}")
System.out.println(s"The precise area for a circle with radius 2.5 is ${CircleUtils.area (2.5, true)}")
System.out.println(s"The basic area for a circle with radius 6.78 is ${CircleUtils.area (6.78)}")
System.out.println(s"The precise area for a circle with radius 6.78 is ${CircleUtils.area (6.78, true)}")
}
}
这个程序的输出将是以下内容:

我们可以从我们的示例输出中得出一些观察结果。首先,精度确实很重要,有些行业,包括金融机构、航天工业等,对精度非常重视。其次,在懒加载初始化块中,我们使用了print语句,并且它是在我们第一次使用精确实现时打印的。正常值在实例创建时初始化。这表明,Scala 中的懒加载确实是在变量第一次使用时才进行初始化。
它有什么好处?
懒加载初始化在初始化一个耗时过长或可能根本不需要的对象或变量时特别有用。有些人可能会说我们可以简单地使用方法,这在某种程度上是正确的。然而,想象一下,我们可能需要在对象的多次调用中从多个方法访问一个懒加载的变量/对象。在这种情况下,将结果存储在某个地方并重复使用它是很有用的。
它不适用于什么?
在 Scala 以外的语言中,当在多线程环境中使用懒加载时,需要特别注意。例如,在 Java 中,你需要在一个synchronized块中进行初始化。为了提供更好的安全性,双重检查锁定是首选的。在 Scala 中没有这样的危险。
单例设计模式
单例设计模式确保在整个应用程序中,一个类只有一个对象实例。它在使用的应用程序中引入了全局状态。
单例对象可以使用不同的策略进行初始化——懒加载初始化或急加载初始化。这完全取决于预期的用途、对象初始化所需的时间等因素。
一个示例类图
单例是设计模式的一个例子,Scala 编程语言的语法默认支持这些模式。我们通过使用对象关键字来实现这一点。在这种情况下,再次提供类图是不必要的,所以我们将直接进入下一小节的示例。
一个代码示例
本例的目的是展示如何在 Scala 中创建单例实例,并理解在 Scala 中实例的确切创建时间。我们将查看一个名为 StringUtils 的类,它提供了与字符串相关的不同实用方法:
object StringUtils {
def countNumberOfSpaces(text: String): Int = text.split("\\s+").length - 1
}
使用此类非常简单。Scala 会负责创建对象、线程安全等:
object UtilsExample {
def main(args: Array[String]): Unit = {
val sentence = "Hello there! I am a utils example."
System.out.println(
s"The number of spaces in '$sentence' is: ${StringUtils.countNumberOfSpaces(sentence)}"
)
}
}
这个程序的输出将是以下内容:

之前的例子很清晰,尽管 StringUtils 对象将是一个单例实例,但它更像是一个具有静态方法的类。这实际上就是在 Scala 中定义静态方法的方式。给单例类添加一些状态会更有趣。以下示例正好展示了这一点:
object AppRegistry {
System.out.println("Registry initialization block called.")
private val users: Map[String, String] = TrieMap.empty
def addUser(id: String, name: String): Unit = {
users.put(id, name)
}
def removeUser(id: String): Unit = {
users.remove(id)
}
def isUserRegistered(id: String): Boolean = users.contains(id)
def getAllUserNames(): List[String] = users.map(_._2).toList
}
AppRegistry 包含了一个所有当前使用应用程序的用户并发映射。这是我们全局状态,我们有一些方法可以操作它。我们还有一个 println 语句,当单例实例创建时将会执行。我们可以在以下应用程序中使用我们的注册表:
object AppRegistryExample {
def main(args: Array[String]): Unit = {
System.out.println("Sleeping for 5 seconds.")
Thread.sleep(5000)
System.out.println("I woke up.")
AppRegistry.addUser("1", "Ivan")
AppRegistry.addUser("2", "John")
AppRegistry.addUser("3", "Martin")
System.out.println(s"Is user with ID=1 registered? ${AppRegistry.isUserRegistered("1")}")
System.out.println("Removing ID=2")
AppRegistry.removeUser("2")
System.out.println(s"Is user with ID=2 registered? ${AppRegistry.isUserRegistered("2")}")
System.out.println(s"All users registered are: ${AppRegistry.getAllUserNames().mkString (",")}")
}
}
让我们运行这个示例,看看最终的输出会是什么:

现在,我们的例子展示了一个合适的单例实例,它包含一个全局状态。在实例运行期间,所有应用程序类都可以访问这个状态。从示例代码和我们的输出中,我们可以得出一些结论:
-
Scala 中的单例是懒加载的
-
在创建单例实例时,我们不能向单例类实例提供动态参数
它有什么好处?
在 Scala 中,单例设计模式和静态方法以相同的方式进行实现。这就是为什么单例对于创建无状态的实用类非常有用。Scala 中的单例还可以用来构建 ADT(抽象数据类型),这在之前的章节中已经讨论过。
对于 Scala 来说,还有一个严格有效的事实,那就是在 Scala 中,单例(singleton)是默认以线程安全的方式创建的,无需采取任何特殊措施。
它有什么坏处?
通常,单例设计模式实际上被认为是反模式。许多人说全局状态不应该像单例类那样存在。有些人说,如果你必须使用单例,你应该尝试重构你的代码。虽然这在某些情况下是正确的,但有时单例也有其合理的使用场景。一般来说,一个经验法则是——如果你能避免使用它们,那么就避免使用。
对于 Scala 单例,还有一点需要特别指出,那就是它们确实只能有一个实例。虽然这是模式的实际定义,但在其他语言中,我们可能有一个预定义的多个单例对象,并可以通过自定义逻辑来控制这一点。
这实际上并不影响 Scala,但仍值得一提。在单例在应用程序中懒加载的情况下,为了提供线程安全,你需要依赖锁定机制,例如,前一小节中提到的双重检查锁定。无论是否是 Scala,应用程序中对单例的访问也需要以线程安全的方式进行,或者单例应该内部处理这个问题。
构建设计模式
构建设计模式有助于使用类方法而不是类构造函数来创建类的实例。它在类可能需要多个构造函数版本以允许不同的使用场景的情况下特别有用。
此外,在某些情况下,甚至可能无法定义所有组合,或者它们可能未知。构建设计模式使用一个额外的对象,称为builder,来接收和存储在构建最终版本的对象之前的初始化参数。
一个示例类图
在本小节中,我们将提供构建模式的类图,包括其经典定义和在其他语言(包括 Java)中的样子。稍后,我们将基于它们更适合 Scala 以及我们对它们的观察和讨论,展示不同版本的代码。
让我们有一个具有不同参数的Person类——firstName、lastName、age、departmentId等等。我们将在下一小节中展示它的实际代码。创建一个具体的构造函数,尤其是如果这些字段可能不是总是已知或必需的,可能会花费太多时间。这也会使未来的代码维护变得极其困难。
构建模式听起来是个好主意,其类图看起来如下:

如我们之前提到的,这是纯面向对象语言(非 Scala)中构建模式的样子。它可能有不同的表示形式,其中构建器被抽象化,然后有具体的构建器。对于正在构建的产品也是如此。最终,它们都旨在达到同一个目标——使对象创建更容易。
在下一个小节中,我们将提供代码实现,以展示如何在 Scala 中使用和编写建造者设计模式。
代码示例
实际上,我们有三种主要方式可以在 Scala 中表示建造者设计模式:
-
经典的方式,如前面图所示,类似于其他面向对象的语言。实际上,虽然这在 Scala 中是可能的,但这种方式并不推荐。它使用可变性来工作,这与语言的不可变性原则相矛盾。我们将在这里展示它以示完整,并指出使用 Scala 的简单特性实现建造者模式有多容易。
-
使用具有默认参数的案例类。我们将看到两个版本——一个验证参数,另一个不验证。
-
使用泛型类型约束。
在接下来的几个小节中,我们将重点关注这些内容。为了使内容简短明了,我们将在类中减少字段数量;然而,需要注意的是,当字段数量较多时,建造者设计模式才能真正发挥其优势。您可以通过向本书提供的代码示例中添加更多字段来实验。
类似 Java 的实现
这种实现直接反映了我们之前图中的内容。首先,让我们看看我们的Person类将是什么样子:
class Person(builder: PersonBuilder) {
val firstName = builder.firstName
val lastName = builder.lastName
val age = builder.age
}
如前述代码所示,它需要一个建造者,并使用在建造者中设置的值来初始化其字段。建造者代码将如下所示:
class PersonBuilder {
var firstName = ""
var lastName = ""
var age = 0
def setFirstName(firstName: String): PersonBuilder = {
this.firstName = firstName
this
}
def setLastName(lastName: String): PersonBuilder = {
this.lastName = lastName
this
}
def setAge(age: Int): PersonBuilder = {
this.age = age
this
}
def build(): Person = new Person(this)
}
我们的建造者有可以设置Person类每个相应字段的方法。这些方法返回相同的建造者实例,这使得我们可以将多个调用链接在一起。以下是我们可以如何使用我们的建造者:
object PersonBuilderExample {
def main(args: Array[String]): Unit = {
val person: Person = new PersonBuilder()
.setFirstName("Ivan")
.setLastName("Nikolov")
.setAge(26)
.build()
System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.")
}
}
这就是如何使用建造者设计模式。现在,我们可以创建一个Person对象,并为其提供我们拥有的任何数据——即使我们只有所有可能字段的一个子集,我们也可以指定它们,其余的将具有默认值。如果向Person类添加其他字段,无需创建新的构造函数。它们只需通过PersonBuilder类提供即可。
使用案例类实现
前面的建造者设计模式看起来很清晰,但需要编写一些额外的代码和创建样板代码。此外,它要求我们在PersonBuilder类中拥有可变字段,这与 Scala 的一些原则相矛盾。
偏好不可变性
不可变性是 Scala 中的一个重要原则,应该优先考虑。使用案例类的建造者设计模式使用不可变字段,这被认为是一种良好的实践。
Scala 具有案例类,这使得建造者模式的实现变得更加简单。以下是它的样子:
case class Person(
firstName: String = "",
lastName: String = "",
age: Int = 0
)
使用此案例类的方式与前面提到的建造者设计模式的使用方式相似:
object PersonCaseClassExample {
def main(args: Array[String]): Unit = {
val person1 = Person(
firstName = "Ivan",
lastName = "Nikolov",
age = 26
)
val person2 = Person(
firstName = "John"
)
System.out.println(s"Person 1: ${person1}")
System.out.println(s"Person 2: ${person2}")
}
}
之前的代码比第一个版本更短,更容易维护。它允许开发者以绝对相同的方式使用原始构建器模式,但语法更简洁。它还保持了Person类的字段不可变,这是在 Scala 中遵循的良好实践。
选择默认值
构建器设计模式的默认值选择完全取决于开发者。有些人可能更喜欢使用Option并在没有指定值时使用None。其他人可能会分配一些不同的特殊值。这个选择可以由个人选择、正在解决的问题、工程团队采用的风格指南等因素决定。
前两种方法的一个缺点是没有验证。如果某些组件相互依赖,并且存在需要初始化的特定变量,会怎样呢?在使用前两种方法的案例中,我们可能会遇到运行时异常。下一小节将展示如何确保验证和需求满足得到实现。
使用通用类型约束
在软件工程中创建对象时,我们常常会遇到依赖关系。我们可能需要初始化某些内容才能使用第三方组件,或者需要特定的初始化顺序,等等。我们之前讨论的两种构建器模式实现都缺乏确保某些内容是否初始化的能力。因此,我们需要在构建器设计模式周围创建一些额外的验证,以确保一切按预期工作,同时我们还将看到是否在运行时创建对象是安全的。
通过使用本书前面已经讨论的一些技术,我们可以在编译时创建一个验证所有需求是否得到满足的构建器。这被称为类型安全的构建器,在下一个示例中,我们将展示这个模式。
修改 Person 类
首先,我们以我们在示例中展示 Java 如何使用构建器模式的相同类开始。现在,让我们对示例施加一个约束,即每个人必须指定至少firstName和lastName。为了使编译器意识到字段正在被设置,这需要编码为一个类型。我们将为此目的使用 ADTs。让我们定义以下内容:
sealed trait BuildStep
sealed trait HasFirstName extends BuildStep
sealed trait HasLastName extends BuildStep
上述抽象数据类型定义了构建进度的不同步骤。现在,让我们对构建器类和Person类进行一些重构:
class Person(
val firstName: String,
val lastName: String,
val age: Int
)
我们将使用Person类的完整构造函数而不是传递一个构建器。这是为了展示另一种构建实例的方法,并在后续步骤中使代码更简单。这个更改需要将PersonBuilder中的build方法也进行更改,如下所示:
def build(): Person = new Person(
firstName,
lastName,
age
)
这将要求我们之前返回PersonBuilder的所有方法现在返回PersonBuilder[PassedStep]。此外,这将使得使用new关键字创建构建器变得不可能,因为构造函数现在是私有的。让我们添加一些更多的构造函数重载:
protected def this() = this("","",0)
protected def this(pb: PersonBuilder[_]) = this(
pb.firstName,
pb.lastName,
pb.age
)
我们将在稍后看到这些重载是如何使用的。我们需要允许我们的用户使用另一种方法创建构建器,因为所有构造函数对外部世界都是不可见的。这就是为什么我们应该添加一个伴随对象,如下所示:
object PersonBuilder {
def apply() = new PersonBuilder[BuildStep]()
}
伴随对象使用我们之前定义的一个构造函数,并确保返回的对象处于正确的构建步骤。
将泛型类型约束添加到必需方法中
然而,到目前为止我们所拥有的仍然不能满足我们对每个Person对象应该初始化的内容的要求。我们不得不更改PersonBuilder类中的某些方法。这些方法是setFirstName、setLastName和build。以下是设置方法的更改:
def setFirstName(firstName: String): PersonBuilder[HasFirstName] = {
this.firstName = firstName
new PersonBuilderHasFirstName
}
def setLastName(lastName: String): PersonBuilder[HasLastName] = {
this.lastName = lastName
new PersonBuilderHasLastName
}
有趣的部分在于build方法。让我们看看以下初始实现:
def build()(implicit ev: PassedStep =:= HasLastName): Person =
new Person(
firstName,
lastName,
age
)
之前的语法设置了一个泛型类型约束,并说明build只能在已通过HasLastName步骤的构建器上调用。看起来我们正在接近我们想要实现的目标,但现在build只有在setLastName是最后四个在构建器上调用的方法之一时才会工作,并且它仍然不会验证其他字段。让我们为setFirstName和setLastName方法使用类似的方法,并将它们链接起来,以便每个方法都需要在调用之前调用前一个方法。以下是我们的PersonBuilder类的最终代码(注意设置方法中的其他隐式声明):
class PersonBuilder[PassedStep <: BuildStep] private(
var firstName: String,
var lastName: String,
var age: Int
) {
protected def this() = this("", "", 0)
protected def this(pb: PersonBuilder[_]) = this(
pb.firstName,
pb.lastName,
pb.age
)
def setFirstName(firstName: String): PersonBuilder[HasFirstName] = {
this.firstName = firstName
new PersonBuilderHasFirstName
}
def setLastName(lastName: String)(implicit ev: PassedStep =:= HasFirstName): PersonBuilder[HasLastName] = {
this.lastName = lastName
new PersonBuilderHasLastName
}
def setAge(age: Int): PersonBuilder[PassedStep] = {
this.age = age
this
}
def build()(implicit ev: PassedStep =:= HasLastName): Person =
new Person(
firstName,
lastName,
age
)
}
使用类型安全构建器
我们现在可以使用构建器创建一个Person对象:
object PersonBuilderTypeSafeExample {
def main(args: Array[String]): Unit = {
val person = PersonBuilder()
.setFirstName("Ivan")
.setLastName("Nikolov")
.setAge(26)
.build()
System.out.println(s"Person: ${person.firstName} ${person.lastName}. Age: ${person.age}.")
}
}
如果我们省略了两个必需方法中的一个或以某种方式重新排列它们,我们将得到类似于以下编译错误(错误是针对缺失的姓氏的):
Error:(103, 23) Cannot prove that com.ivan.nikolov.creational.builder.
type_safe.BuildStep =:=
com.ivan.nikolov.creational.builder.type_safe.HasFirstName.
.setLastName("Nikolov")
^
顺序要求可以被认为是一个轻微的缺点,特别是如果它不是必需的。
关于我们的类型安全构建器的观察如下:
-
使用类型安全构建器,我们可以要求特定的调用顺序和某些字段被初始化。
-
当我们需要多个字段时,我们必须将它们链接起来,这使得调用顺序变得很重要。这可能会使库在某些情况下难以使用。
-
当构建器使用不正确时,编译器消息并不真正具有信息性。
-
代码看起来几乎与在 Java 中实现的方式相同。
-
与 Java 的代码相似性导致依赖于可变性,这是不建议的。
Scala 允许我们有一个既优雅又干净的构建器设计模式实现,它还对顺序和初始化的内容有要求。这是一个很好的特性,尽管有时它在方法的具体使用上可能会显得繁琐和受限。
使用 require 语句
我们之前展示的类型安全构建器很好,但它有一些缺点:
-
复杂性
-
可变性
-
预定义的初始化顺序
然而,这可能会非常有用,因为它允许我们编写在编译时就会检查正确使用的代码。尽管有时不需要编译时验证。如果是这种情况,我们可以使事情变得极其简单,并使用已知的 case 类和require语句来消除整个复杂性:
case class Person(
firstName: String = "",
lastName: String = "",
age: Int = 0
) {
require(firstName != "", "First name is required.")
require(lastName != "", "Last name is required.")
}
如果前面的布尔条件不满足,我们的代码将抛出一个带有正确信息的IllegalArgumentException。我们可以像通常使用 case 类一样使用我们的类:
object PersonCaseClassRequireExample {
def main(args: Array[String]): Unit = {
val person1 = Person(
firstName = "Ivan",
lastName = "Nikolov",
age = 26
)
System.out.println(s"Person 1: ${person1}")
try {
val person2 = Person(
firstName = "John"
)
System.out.println(s"Person 2: ${person2}")
} catch {
case e: Throwable =>
e.printStackTrace()
}
}
}
如我们所见,这里的事情要简单得多,字段是不可变的,我们实际上没有任何特殊的初始化顺序。此外,我们可以添加有助于诊断潜在问题的有意义的信息。只要不需要编译时验证,这应该是首选的方法。
它适用于什么?
构建器设计模式非常适合我们需要创建复杂对象且否则不得不定义许多构造函数的情况。它通过逐步方法使对象的创建更加容易,并且更加清晰、易于阅读。
它不适用于什么?
正如我们在类型安全的构建器示例中所见,添加更高级的逻辑和要求可能需要相当多的工作。如果没有这种可能性,开发者将面临其类用户犯更多错误的风险。此外,构建器包含相当多的看似重复的代码,尤其是在使用类似 Java 的代码实现时。
原型设计模式
原型设计模式是一种创建型设计模式,它涉及通过克隆现有对象来创建对象。其目的是与性能相关,并试图避免昂贵的调用以保持高性能。
一个示例类图
在像 Java 这样的语言中,我们通常看到一个实现了具有clone方法的接口的类,该方法返回该类的新实例。考虑以下图示:

在下一节中,我们将从 Scala 的角度提供一个原型设计模式的代码示例。
代码示例
原型设计模式在 Scala 中实现起来非常简单。我们可以使用语言的一个特性。由于原型设计模式与生物细胞分裂的方式非常相似,让我们以细胞为例:
/**
* Represents a bio cell
*/
case class Cell(dna: String, proteins: List[String])
在 Scala 中,所有 case 类都有一个copy方法,该方法返回一个从原始对象克隆的新实例。它还可以在复制时更改一些原始属性。以下是我们细胞的某些示例用法:
object PrototypeExample {
def main(args: Array[String]): Unit = {
val initialCell = Cell("abcd", List("protein1", "protein2"))
val copy1 = initialCell.copy()
val copy2 = initialCell.copy()
val copy3 = initialCell.copy(dna = "1234")
System.out.println(s"The prototype is: ${initialCell}")
System.out.println(s"Cell 1: ${copy1}")
System.out.println(s"Cell 2: ${copy2}")
System.out.println(s"Cell 3: ${copy3}")
System.out.println(s"1 and 2 are equal: ${copy1 == copy2}")
}
}
这个示例的输出将是这样的:

正如你所见,使用copy,我们获得了原型细胞的不同实例。
它适用于什么?
当性能很重要时,原型设计模式是有用的。使用copy方法,我们可以获得在其他情况下需要时间来创建的实例。这种缓慢可能是由创建过程中进行的某些计算、检索数据的数据库调用等原因造成的。
它不适用于什么?
使用对象的浅拷贝可能会导致错误和副作用,其中实际引用指向原始实例。避免构造函数可能会导致糟糕的代码。原型设计模式应该真正用于在没有它的情况下可能会产生巨大性能影响的情况。
摘要
这是本书的第一章,专注于一些特定的设计模式。我们研究了以下创建型设计模式——工厂方法、抽象工厂、延迟初始化、单例、构建器和原型。在相关的地方,我们展示了显示类关系的图表。此外,我们还给出了典型示例,并讨论了使用它们的可能陷阱和建议。
在现实生活中的软件工程中,设计模式通常是组合使用,而不是孤立地使用。一些例子包括由单例实例提供的原型,能够存储不同原型并在创建对象时提供副本的抽象工厂,能够使用构建器创建实例的工厂,等等。在某些情况下,设计模式可以根据用例进行互换。例如,延迟初始化可能足以降低性能影响,可以替代原型设计模式。
在下一章中,我们将继续我们的设计模式之旅;这次,我们将专注于结构型设计模式家族。
第七章:结构型设计模式
我们设计模式之旅的下一站将聚焦于结构型设计模式系列。我们将从 Scala 的角度探索以下结构型设计模式:
-
适配器
-
装饰者模式
-
桥接模式
-
组合
-
门面模式
-
享元模式
-
代理
本章将更好地理解结构型设计模式是什么以及为什么它们是有用的。在熟悉了它们之后,我们将分别详细研究每一个,包括代码示例以及何时使用它们、何时避免它们以及使用它们时需要注意的事项。
定义结构型设计模式
结构型设计模式关注于在我们的软件中组合对象和类。它们使用不同的方法来获得新的功能以及更大和可能更复杂的结构。这些方法包括以下内容:
-
继承
-
组合
正确识别应用程序中对象之间的关系对于简化应用程序的结构至关重要。在接下来的章节中,我们将探讨不同的设计模式并提供示例,这将帮助我们更好地了解如何使用各种结构型设计模式。
适配器设计模式
在许多情况下,我们必须通过组合不同的组件来使应用程序工作。然而,相当常见的问题是组件接口之间不兼容。同样,在使用公共或任何库时,我们无法修改,其他人当前的设置中,他们的观点通常与我们的大相径庭。这就是适配器发挥作用的地方。它们的目的在于帮助不兼容的接口协同工作,而不需要修改它们的源代码。
在接下来的几个小节中,我们将通过类图和示例展示适配器是如何工作的。
示例类图
对于适配器类图,让我们假设我们想在应用程序中切换到使用一个新的日志库。我们试图使用的库有一个接受消息和日志严重性的日志方法。然而,在我们的整个应用程序中,我们期望有info、debug、warning和error方法,这些方法只接受消息并自动设置正确的严重性。当然,我们无法编辑原始库代码,因此我们必须使用适配器模式。以下图显示了代表适配器设计模式的类图:

在前面的图中,我们可以看到我们的适配器(AppLogger)扩展了,并且也使用了一个Logger实例作为字段。在实现方法时,我们只需简单地调用带有不同参数的日志方法。这是通用的适配器实现,我们将在下一小节中看到它的代码。有些情况下,扩展可能不可行,我们将展示 Scala 如何处理这种情况。此外,我们还将展示一些高级语言特性的用法,以实现适配器模式。
代码示例
首先,让我们看看我们的Logger代码,我们假设我们无法更改它:
class Logger {
def log(message: String, severity: String): Unit = {
System.out.println(s"${severity.toUpperCase}: $message")
}
}
我们尽量让它尽可能简单,以免分散读者的注意力,从而影响本书的主要目的。接下来,我们既可以写一个扩展Logger的类,也可以提供一个接口进行抽象。让我们采取第二种方法:
trait Log {
def info(message: String)
def debug(message: String)
def warning(message: String)
def error(message: String)
}
最后,我们可以创建我们的AppLogger:
class AppLogger extends Logger with Log {
override def info(message: String): Unit = log(message, "info")
override def warning(message: String): Unit = log(message, "warning")
override def error(message: String): Unit = log(message, "error")
override def debug(message: String): Unit = log(message, "debug")
}
然后,我们可以在以下程序中使用它:
object AdapterExample {
def main(args: Array[String]): Unit = {
val logger = new AppLogger
logger.info("This is an info message.")
logger.debug("Debug something here.")
logger.error("Show an error message.")
logger.warning("About to finish.")
logger.info("Bye!")
}
}
如预期的那样,我们的输出将如下所示:

您可以看到,我们没有按照显示的完全实现类图。我们不需要将Logger实例作为我们类的字段,因为我们的类已经是Logger的一个实例,并且我们无论如何都可以访问其方法。如果我们想要扩展原始的log方法的行为,那么我们还需要一个Logger实例。
这就是我们实现和使用基本适配器设计模式的方法。然而,有些情况下,我们想要适配的类被声明为final,我们无法扩展它。我们将在下一小节中展示如何处理这种情况。
最终类适配器设计模式
如果我们将原始的日志器声明为final,我们将看到我们的代码将无法编译。在这种情况下,我们可以使用不同的方式来使用适配器模式。以下是代码:
class FinalAppLogger extends Log {
private val logger = new FinalLogger
override def info(message: String): Unit = logger.log(message, "info")
override def warning(message: String): Unit = logger.log(message,
"warning")
override def error(message: String): Unit = logger.log(message, "error")
override def debug(message: String): Unit = logger.log(message, "debug")
}
在这种情况下,我们只需将最终的日志器封装在一个类中,然后使用它以不同的参数调用log方法。使用方法与之前完全相同。这可以有一个变化,即日志器作为构造函数参数传递。这在创建日志器需要一些额外的参数化时很有用。
Scala 风格的适配器设计模式
正如我们多次提到的,Scala 是一种丰富的编程语言。正因为如此,我们可以使用隐式类来实现适配器模式的功能。我们将使用与上一个示例中相同的FinalLogger。
隐式类在可能的地方提供隐式转换。为了使隐式转换生效,我们需要导入隐式定义,这就是为什么它们通常定义在对象或包对象中。对于这个例子,我们将使用一个包对象。以下是代码:
package object adapter {
implicit class FinalAppLoggerImplicit(logger: FinalLogger) extends Log {
override def info(message: String): Unit = logger.log(message, "info")
override def warning(message: String): Unit = logger.log(message,
"warning")
override def error(message: String): Unit = logger.log(message,
"error")
override def debug(message: String): Unit = logger.log(message,
"debug")
}
}
这是一个为定义我们的记录器示例的包定义的package object。它将自动将FinalLogger实例转换为我们的隐式类。以下代码片段展示了我们记录器的示例用法:
object AdapterImplicitExample {
def main(args: Array[String]): Unit = {
val logger: Log = new FinalLogger
logger.info("This is an info message.")
logger.debug("Debug something here.")
logger.error("Show an error message.")
logger.warning("About to finish.")
logger.info("Bye!")
}
}
最终输出将与我们的第一个例子完全相同。
它有什么好处
适配器设计模式在代码设计和编写之后的情况中很有用。它允许我们使本应不兼容的接口一起工作。它实现和使用也非常简单直接。
它有什么不好
在前面部分提到的最后一种实现中存在一个问题。那就是我们将在使用记录器时始终需要导入我们的包或普通对象。此外,隐式类和转换有时会使代码难以阅读和理解。隐式类有一些限制,如以下链接所述:docs.scala-lang.org/overviews/core/implicitclasses.html
正如我们之前提到的,适配器设计模式在我们无法更改代码时很有用。如果我们能够修复我们的源代码,那么这可能会是一个更好的决定,因为在我们整个程序中使用适配器将使维护变得困难,并且难以理解。
装饰器设计模式
在某些情况下,我们可能想在应用程序中的类上添加一些额外的功能。这可以通过继承来完成;然而,我们可能不想这样做,或者它可能会影响我们应用程序中的其他所有类。这就是装饰器设计模式有用的地方。
装饰器设计模式的目的是在不影响同一类其他对象行为的情况下,向对象添加功能而不扩展它们。
装饰器设计模式通过包装被装饰的对象来工作,并且可以在运行时应用。在可能需要多个类扩展并且可以以各种方式组合的情况下,装饰器非常有用。我们不需要编写所有可能的组合,可以创建装饰器并将修改堆叠在一起。接下来的几个小节将展示如何在现实世界场景中使用装饰器。
示例类图
正如我们之前在适配器设计模式中看到的,它的目的是将一个接口转换为另一个接口。另一方面,装饰器通过向方法添加额外功能来帮助我们增强接口。对于类图,我们将使用数据流的例子。想象一下,我们有一个基本的流。我们可能希望能够加密它、压缩它、替换其字符等等。以下是类图:

在前面的图中,AdvancedInputReader提供了一个InputReader的基本实现。它包装了一个标准的BufferedReader。然后,我们有一个扩展了InputReader的抽象InputReaderDecorator类,并包含了一个其实例。通过扩展基础装饰器,我们提供了有流可以大写、压缩或Base64编码输入的可能性。在我们的应用程序中,我们可能想要有不同的流,并且它们能够以不同的顺序执行前面提到的操作之一或多个。如果我们尝试提供所有可能性,尤其是当可能的操作数量更多时,我们的代码将很快变得难以维护和混乱。使用装饰器,它既简洁又清晰,正如我们将在下一节中看到的那样。
代码示例
现在,让我们看看描述前面图中装饰器设计模式的实际代码。首先,我们使用特质定义我们的InputReader接口:
trait InputReader {
def readLines(): Stream[String]
}
然后,我们在AdvancedInputReader类中提供接口的基本实现:
class AdvancedInputReader(reader: BufferedReader) extends InputReader {
override def readLines(): Stream[String] =
reader.lines().iterator().asScala.toStream
}
为了应用装饰器设计模式,我们必须创建不同的装饰器。我们有一个如下所示的基础装饰器:
abstract class InputReaderDecorator(inputReader: InputReader) extends InputReader {
override def readLines(): Stream[String] =
inputReader.readLines()
}
然后,我们有我们装饰器的不同实现。首先,我们实现了一个将所有文本转换为大写的装饰器:
class CapitalizedInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) {
override def readLines(): Stream[String] =
super.readLines().map(_.toUpperCase)
}
接下来,我们实现了一个使用gzip压缩输入每一行的装饰器:
class CompressingInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) with LazyLogging {
override def readLines(): Stream[String] = super.readLines().map {
case line =>
val text = line.getBytes(Charset.forName("UTF-8"))
logger.info("Length before compression: {}", text.length.toString)
val output = new ByteArrayOutputStream()
val compressor = new GZIPOutputStream(output)
try {
compressor.write(text, 0, text.length)
val outputByteArray = output.toByteArray
logger.info("Length after compression: {}",
outputByteArray.length.toString)
new String(outputByteArray, Charset.forName("UTF-8"))
} finally {
compressor.close()
output.close()
}
}
}
最后,一个将每一行编码为Base64的装饰器:
class Base64EncoderInputReader(inputReader: InputReader) extends InputReaderDecorator(inputReader) {
override def readLines(): Stream[String] = super.readLines().map {
case line => Base64.getEncoder.encodeToString(line.getBytes(Charset.forName("UTF-8")))
}
}
我们使用一个中间的抽象类来演示装饰器设计模式,这个抽象类被所有装饰器扩展。我们本可以直接扩展并包装InputReader来实现这个设计模式。然而,这种实现给我们的代码增加了一些结构。
现在,我们可以在我们的应用程序中使用这些装饰器,根据需要为我们的输入流添加额外的功能。使用方法很简单。以下是一个示例:
object DecoratorExample {
def main(args: Array[String]): Unit = {
val stream = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(this.getClass.getResourceAsStream("data.txt"))
)
)
try {
val reader = new CapitalizedInputReader(new AdvancedInputReader(stream))
reader.readLines().foreach(println)
} finally {
stream.close()
}
}
}
在前面的例子中,我们使用了类路径中的文本文件部分,其内容如下:
this is a data file
which contains lines
and those lines will be
manipulated by our stream reader.
如预期的那样,我们应用装饰器的顺序将定义它们增强的顺序。前面示例的输出将是以下内容:

让我们看看另一个示例,但这次我们将应用我们所有的装饰器:
object DecoratorExampleBig {
def main(args: Array[String]): Unit = {
val stream = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(this.getClass.getResourceAsStream("data.txt"))
)
)
try {
val reader = new CompressingInputReader(
new Base64EncoderInputReader(
new CapitalizedInputReader(
new AdvancedInputReader(stream)
)
)
)
reader.readLines().foreach(println)
} finally {
stream.close()
}
}
}
这个例子将读取文本,将其转换为大写,Base64编码,并最终使用gzip压缩。以下截图显示了输出:

如您从前面的截图中所见,在压缩装饰器代码中,我们正在记录行的字节数。输出被 gzip 压缩,这也是文本显示为不可读字符的原因。您可以尝试更改装饰器应用的顺序或添加新的装饰器,以查看事物如何不同。
Scala 风格的装饰器设计模式
就像其他设计模式一样,这个实现利用了 Scala 的丰富性,并使用了一些我们在本书初始章节中探讨的概念。Scala 中的装饰器设计模式也称为可堆叠特性。让我们看看它的样子以及如何使用它。InputReader 和 AdvancedInputReader 代码将与上一节中展示的完全相同。我们实际上在两个示例中都重用了它。
接下来,我们不再定义一个 abstract 装饰器类,而是将不同的读取修改定义在新特性中,如下所示:
trait CapitalizedInputReaderTrait extends InputReader {
abstract override def readLines(): Stream[String] =
super.readLines().map(_.toUpperCase)
}
然后,我们定义压缩输入读取器:
trait CompressingInputReaderTrait extends InputReader with LazyLogging {
abstract override def readLines(): Stream[String] =
super.readLines().map {
case line =>
val text = line.getBytes(Charset.forName("UTF-8"))
logger.info("Length before compression: {}", text.length.toString)
val output = new ByteArrayOutputStream()
val compressor = new GZIPOutputStream(output)
try {
compressor.write(text, 0, text.length)
val outputByteArray = output.toByteArray
logger.info("Length after compression: {}",
outputByteArray.length.toString)
new String(outputByteArray, Charset.forName("UTF-8"))
} finally {
compressor.close()
output.close()
}
}
}
最后,Base64 编码读取器如下所示:
trait Base64EncoderInputReaderTrait extends InputReader {
abstract override def readLines(): Stream[String] =
super.readLines().map {
case line =>
Base64.getEncoder.encodeToString(line.getBytes(Charset.forName("UTF-8")))
}
}
如您所见,这里的实现并没有太大的不同。在这里,我们使用了特性(traits)而不是类,扩展了基类 InputReader 特性,并使用了 abstract override。
抽象覆盖(abstract override)允许我们在声明为抽象的特性中调用 super 方法。只要特性在另一个特性或实现先前方法的类之后混合,特性就是允许的。抽象覆盖告诉编译器我们故意这样做,它不会使我们的编译失败——它将在我们使用特性时检查是否满足使用它的要求。
之前,我们展示了两个示例。现在,我们将向您展示它们使用可堆叠特性(stackable traits)时的样子。第一个仅将字母大写的示例如下:
object StackableTraitsExample {
def main(args: Array[String]): Unit = {
val stream = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(this.getClass.getResourceAsStream("data.txt"))
)
)
try {
val reader = new AdvancedInputReader(stream) with CapitalizedInputReaderTrait
reader.readLines().foreach(println)
} finally {
stream.close()
}
}
}
第二个示例,将文本大写、Base64 编码和压缩流,如下所示:
object StackableTraitsBigExample {
def main(args: Array[String]): Unit = {
val stream = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(this.getClass.getResourceAsStream("data.txt"))
)
)
try {
val reader = new AdvancedInputReader(stream) with CapitalizedInputReaderTrait
with Base64EncoderInputReaderTrait
with CompressingInputReaderTrait
reader.readLines().foreach(println)
} finally {
stream.close()
}
}
}
这两个示例的输出将与原始示例完全相同。然而,在这里,我们使用了混合组合(mixin composition),看起来要干净一些。我们还有一个类更少,因为我们不需要抽象装饰器类。理解修改是如何应用的是很容易的——我们只需遵循可堆叠特性混合的顺序即可。
可堆叠特性遵循线性化规则。在我们当前的示例中,修改从左到右应用的事实是具有欺骗性的。这种情况发生的原因是因为我们在堆栈上推送调用,直到我们达到 readLines 的基本实现,然后以相反的顺序应用修改。我们将在本书接下来的章节中看到更深入的可堆叠特性示例,这些示例将展示它们的所有具体细节。
它的优点是什么
装饰器(decorators)为我们的应用程序增加了许多灵活性。它们不会改变原始类,因此不会在旧代码中引入错误,并且可以节省大量的代码编写和维护工作。此外,它们还可以防止我们忘记或未能预见我们创建的类的一些用例。
在之前的示例中,我们展示了某些静态行为修改。然而,也有可能在运行时动态地装饰实例。
它的缺点是什么
我们已经讨论了使用装饰器的积极方面;然而,我们应该指出,过度使用装饰器也可能导致问题。我们可能会拥有大量的小类,这可能会使我们的库更难以使用,并且需要更多的领域知识。它们还使实例化过程复杂化,这需要其他(创建型)设计模式,例如工厂或构建者。
桥接设计模式
一些应用程序可以具有特定功能的不同实现。这些实现可能是不同的算法或与多个平台有关的东西。实现往往经常变化,它们也可能在整个程序的生命周期中具有新的实现。此外,实现可能以不同的方式用于不同的抽象。在这些情况下,在我们的代码中解耦事物是很好的,否则我们面临类爆炸的风险。
桥接设计模式的目的在于将抽象与其实现解耦,以便它们可以独立变化。
桥接设计模式在抽象或实现可能经常且独立变化的情况下非常有用。如果我们直接实现抽象,对抽象或实现的任何变化都会影响层次结构中的所有其他类。这使得扩展、修改和独立重用类变得困难。
桥接设计模式通过直接实现抽象来消除问题,从而使得抽象和实现可重用且更容易更改。
桥接设计模式与适配器设计模式非常相似。它们之间的区别在于,前者在我们设计应用程序时应用,而后者用于遗留或第三方代码。
示例类图
对于类图和代码示例,让我们假设我们正在编写一个哈希密码的库。在实践中,以纯文本形式存储密码是应该避免的。这正是我们的库将帮助用户做到的。有许多不同的哈希算法可以使用。一些是SHA-1、MD5和SHA-256。我们希望能够支持至少这些,并且能够轻松地添加新的算法。存在不同的哈希策略——你可以多次哈希,组合不同的哈希,向密码中添加盐,等等。这些策略使得我们的密码更难以使用彩虹表猜测。对于这个例子,我们将展示使用盐的哈希和简单使用我们拥有的任何算法的哈希。
这里是我们的类图:

如您从前面的图中可以看到,我们将实现(Hasher 及其实现)与抽象(PasswordConverter)分离开来。这允许我们轻松地添加一个新的哈希实现,然后在创建 PasswordConverter 时只需提供一个实例即可立即使用它。如果我们没有使用前面的构建器模式,我们可能需要为每个哈希算法分别创建一个密码转换器——这可能会使我们的代码规模爆炸或变得难以使用。
代码示例
现在,让我们从 Scala 代码的角度来看一下之前的类图。首先,我们将关注 Hasher 特质的实现方面:
trait Hasher {
def hash(data: String): String
protected def getDigest(algorithm: String, data: String) = {
val crypt = MessageDigest.getInstance(algorithm)
crypt.reset()
crypt.update(data.getBytes("UTF-8"))
crypt
}
}
然后,我们有三个类实现了它——Md5Hasher、Sha1Hasher 和 Sha256Hasher。它们的代码相当简单且相似,但会产生不同的结果:
class Sha1Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}
class Sha256Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("SHA-256", data).digest()))
}
class Md5Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("MD5", data).digest()))
}
现在,让我们看看抽象方面的事情。这是我们客户将要使用的内容。以下列表显示了 PasswordConverter:
abstract class PasswordConverter(hasher: Hasher) {
def convert(password: String): String
}
我们在这里选择了提供两种不同的实现方式——SimplePasswordConverter 和 SaltedPasswordConverter。它们的代码如下:
class SimplePasswordConverter(hasher: Hasher) extends PasswordConverter(hasher) {
override def convert(password: String): String =
hasher.hash(password)
}
class SaltedPasswordConverter(salt: String, hasher: Hasher) extends PasswordConverter(hasher) {
override def convert(password: String): String =
hasher.hash(s"${salt}:${password}")
}
现在,如果客户想要使用我们的库,他们可以编写一个类似于以下程序:
object BridgeExample {
def main(args: Array[String]): Unit = {
val p1 = new SimplePasswordConverter(new Sha256Hasher)
val p2 = new SimplePasswordConverter(new Md5Hasher)
val p3 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha1Hasher)
val p4 = new SaltedPasswordConverter("8jsdf32T^$%", new Sha256Hasher)
System.out.println(s"'password' in SHA-256 is:
${p1.convert ("password")}")
System.out.println(s"'1234567890' in MD5 is:
${p2.convert ("1234567890")}")
System.out.println(s"'password' in salted SHA-1 is:
${p3.convert ("password")}")
System.out.println(s"'password' in salted SHA-256 is:
${p4.convert ("password")}")
}
}
这个示例应用程序的输出将类似于以下截图:

我们现在的库允许我们轻松地添加新的策略或新的哈希算法,并立即使用它们。我们不需要更改任何现有的类。
Scala 方式的桥梁设计模式
桥接设计模式是 Scala 强大功能实现的另一个例子。在这里,我们将使用自类型。初始的 Hasher 特质保持不变。然后,实际的实现变成了特质而不是类,如下所示:
trait Sha1Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("SHA-1", data).digest()))
}
trait Sha256Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("SHA-256", data).digest()))
}
trait Md5Hasher extends Hasher {
override def hash(data: String): String =
new String(Hex.encodeHex(getDigest("MD5", data).digest()))
}
拥有特质将允许我们在需要时将它们混合使用。
我们只是更改了示例版本中的一些名称,以避免混淆。PasswordConverter(在这种情况下为 PasswordConverterBase)抽象现在看起来如下:
abstract class PasswordConverterBase {
self: Hasher =>
def convert(password: String): String
}
这告诉编译器,当我们使用 PasswordConverterBase 时,我们还需要混合使用一个 Hasher。然后,我们将转换器实现更改为以下内容:
class SimplePasswordConverterScala extends PasswordConverterBase {
self: Hasher =>
override def convert(password: String): String = hash(password)
}
class SaltedPasswordConverterScala(salt: String) extends PasswordConverterBase {
self: Hasher =>
override def convert(password: String): String =
hash(s"${salt}:${password}")
}
最后,我们可以使用我们的新实现,如下所示:
object ScalaBridgeExample {
def main(args: Array[String]): Unit = {
val p1 = new SimplePasswordConverterScala with Sha256Hasher
val p2 = new SimplePasswordConverterScala with Md5Hasher
val p3 = new SaltedPasswordConverterScala("8jsdf32T^$%") with
Sha1Hasher
val p4 = new SaltedPasswordConverterScala("8jsdf32T^$%") with
Sha256Hasher
System.out.println(s"'password' in SHA-256 is:
${p1.convert("password")}")
System.out.println(s"'1234567890' in MD5 is:
${p2.convert("1234567890")}")
System.out.println(s"'password' in salted SHA-1 is:
${p3.convert("password")}")
System.out.println(s"'password' in salted SHA-256 is:
${p4.convert("password")}")
}
}
这个程序的输出将与原始程序相同。然而,当我们使用我们的抽象时,我们可以混合使用我们想要的哈希算法。在可能需要将更多实现组合在一起进行哈希的情况下,这种好处将变得更加明显。使用混入(mixins)看起来也更自然,更容易理解。
它有什么好处
正如我们之前所说的,桥接设计模式类似于适配器。然而,在这里,我们是在设计应用程序时应用它。使用它的一个明显好处是,我们不会在我们的应用程序中结束于指数级数量的类,这可能会使模式的使用和维护变得相当复杂。层次结构的分离使我们能够独立扩展它们,而不会影响另一个。
它不适用于什么
桥接设计模式要求我们编写一些样板代码。它可能会在库的使用方面使选择确切实现变得复杂,因此可能是一个好主意将桥接设计模式与一些创建型设计模式一起使用。总的来说,它没有任何重大缺点,但开发者应该根据当前情况明智地决定是否使用它。
组合设计模式
组合设计模式用于描述应该像单个对象一样对待的一组对象。
组合设计模式的目的在于将对象组合成树结构以表示整体-部分层次结构。
组合设计模式对于移除代码重复和在通常以相同方式对待对象组的情况下避免错误是有用的。一个流行的例子可能是在文件系统中,我们有目录,这些目录可以有其他目录或文件。通常,与目录和文件交互的接口是相同的,因此它们是组合设计模式的好候选者。
示例类图
正如我们之前提到的,文件系统是组合设计模式的良好候选者。本质上,它们只是树结构,因此在我们的例子中,我们将向您展示如何使用组合设计模式构建树。
考虑以下类图:

如您从前面的图中可以看到,Tree 是我们的组合对象。它包含子节点,这些子节点可以是具有更多嵌套子节点的其他 Tree 对象,或者只是 Leaf 节点。
代码示例
让我们看看之前图中代码的表示。首先,我们必须通过特质定义 Node 接口:
trait Node {
def print(prefix: String): Unit
}
print 方法中的 prefix 参数用于在控制台打印树时辅助可视化。
在我们有了接口之后,我们现在可以定义实现:
class Leaf(data: String) extends Node {
override def print(prefix: String): Unit =
System.out.println(s"${prefix}${data}")
}
class Tree extends Node {
private val children = ListBuffer.empty[Node]
override def print(prefix: String): Unit = {
System.out.println(s"${prefix}(")
children.foreach(_.print(s"${prefix}${prefix}"))
System.out.println(s"${prefix})")
}
def add(child: Node): Unit = {
children += child
}
def remove(): Unit = {
if (children.nonEmpty) {
children.remove(0)
}
}
}
在此之后,使用我们的代码变得相当简单。在打印时,我们不需要关心是在叶节点还是树上进行操作。我们的代码将自动处理这一点:
object CompositeExample {
def main(args: Array[String]): Unit = {
val tree = new Tree
tree.add(new Leaf("leaf 1"))
val subtree1 = new Tree
subtree1.add(new Leaf("leaf 2"))
val subtree2 = new Tree
subtree2.add(new Leaf("leaf 3"))
subtree2.add(new Leaf("leaf 4"))
subtree1.add(subtree2)
tree.add(subtree1)
val subtree3 = new Tree
val subtree4 = new Tree
subtree4.add(new Leaf("leaf 5"))
subtree4.add(new Leaf("leaf 6"))
subtree3.add(subtree4)
tree.add(subtree3)
tree.print("-")
}
}
这段代码实际上执行的是我们数据结构的深度优先遍历。我们实际拥有的示例数据结构如下所示:

下面的截图显示了我们的程序输出:

如您所见,使用组合,我们可以组合具有相似用途的对象层次结构。
它适用的场合
组合设计模式在创建层次结构时有助于减少代码重复和简化。简化部分来自于客户端不需要知道他们正在处理哪种类型的对象。添加新的节点类型也很容易,而且不会让我们改变其他任何东西。
它不适用的情况
组合设计模式没有明显的缺点。它确实适用于特定情况。开发者应该注意的一点是,当处理大量层次结构时。原因是,在这种情况下,我们可能会有非常深层次的嵌套项,这可能会导致栈溢出问题。
外观设计模式
无论我们是在构建库或大型系统时,我们常常依赖于其他库和功能。实现方法有时需要同时使用多个类。这需要知识。当我们为某人构建库时,我们通常会尝试通过假设他们没有(也不需要)像我们这样广泛的知识来简化用户的使用。此外,开发者确保组件在其应用程序中易于使用。这就是外观设计模式可以变得有用的地方。
外观设计模式的目的在于用更简单的接口封装复杂的系统,以隐藏使用复杂性并简化客户端交互。
我们已经研究了基于封装的其他设计模式。虽然适配器设计模式将一个接口转换为另一个接口,装饰者添加额外的功能,但外观使事情变得更简单。
示例类图
对于类图,让我们设想以下场景——我们希望我们的用户能够从服务器下载一些数据,并以对象的形式对其进行反序列化。服务器以编码形式返回我们的数据,因此我们应该首先对其进行解码,然后解析它,最后返回正确的对象。这涉及到许多操作,使事情变得复杂。这就是为什么我们使用外观设计模式:

当客户端使用前面的应用程序时,他们只需与DataReader交互。内部,它将负责下载、解码和反序列化数据。
代码示例
前面的图示显示了DataDownloader、DataDecoder和DataDeserializer作为DataReader内部的组成对象。这是直接且清晰的——它们可以通过默认构造函数创建,或者作为参数传递。然而,对于我们的示例代码表示,我们选择使用特性(traits)而不是类,并将它们与DataReader类混合使用。
让我们先看看DataDownloader、DataDecoder和DataDeserializer特性:
trait DataDownloader extends LazyLogging {
def download(url: String): Array[Byte] = {
logger.info("Downloading from: {}", url)
Thread.sleep(5000)
// {
// "name": "Ivan",
// "age": 26
// }
// the string below is the Base64 encoded Json above.
"ew0KICAgICJuYW1lIjogIkl2YW4iLA0KICAgICJhZ2UiOiAyNg0KfQ==".getBytes
}
}
DataDecoder特性如下:
trait DataDecoder {
def decode(data: Array[Byte]): String = new String(Base64.getDecoder.decode(data), "UTF-8")
}
以下代码片段是DataDeserializer特质的示例:
trait DataDeserializer {
implicit val formats = DefaultFormats
def parseT(implicit m: Manifest[T]): T =
JsonMethods.parse(StringInput(data)).extract[T]
}
之前的实现相当直接,并且它们是分开的,因为它们处理不同的任务。任何人都可以使用它们;然而,这需要一些知识,使事情更加复杂。这就是为什么我们有一个名为DataReader的外观类:
class DataReader extends DataDownloader with DataDecoder with DataDeserializer {
def readPerson(url: String): Person = {
val data = download(url)
val json = decode(data)
parsePerson
}
}
这个例子清楚地表明,我们不再需要使用三个不同的接口,现在有一个简单的方法可以调用。所有复杂性都隐藏在这个方法中。以下列表显示了我们的类的一个示例用法:
object FacadeExample {
def main(args: Array[String]): Unit = {
val reader = new DataReader
System.out.println(s"We just read the following person:
${reader.readPerson("https://www.ivan-nikolov.com/")}")
}
}
之前的代码利用了我们的库,这些库对客户端来说是隐藏的,使用起来非常简单。以下是一个示例输出:

当然,在先前的例子中,我们可以在DataReader内部使用类,而不是混合特质。这完全取决于需求,无论如何都应该产生相同的结果。
它的优点是什么
外观设计模式在需要隐藏许多库的实现细节、使接口更容易使用以及与复杂系统交互时非常有用。
它的缺点是什么
一些人们可能会犯的一个常见错误是试图将一切放入外观中。这通常不会有所帮助,开发者仍然会保留一个复杂系统,甚至可能比之前更复杂。此外,外观可能会对那些有足够领域知识来使用原始功能的人来说具有约束性。这尤其适用于外观是唯一与底层系统交互的方式时。
享元设计模式
通常当编写软件时,开发者会尝试使其快速高效。通常这意味着更少的处理周期和更小的内存占用。实现这两个方面有不同的方法。大多数时候,一个好的算法会处理第一个方面。使用的内存量可能有多种原因和解决方案,而享元设计模式就是为了帮助并减少内存使用。
享元设计模式的目的是通过尽可能多地与其他相似对象共享数据来最小化内存使用。
有许多情况是许多对象共享相同的信息。当谈到享元时,一个常见的例子是文字处理。我们不需要用所有关于字体、大小、颜色、图像等信息来表示每个字符,我们只需存储相似字符的位置,并有一个指向包含公共信息的对象的引用。这使得内存使用显著减少。否则,这样的应用程序将变得无法使用。
示例类图
对于类图,首先让我们想象我们正在尝试表示一个类似以下颜色盲测试的绘图:

如我们所见,它由不同大小和颜色的圆组成。理论上,这可以是一个无限大的图片,并且可以有任意数量的圆。为了简化问题,让我们只设定一个限制,即我们只能有五种不同的圆颜色——红色、绿色、蓝色、黄色和洋红色。以下是我们类图的样子,以便使用享元设计模式来表示前面提到的图像:

实际的享元设计模式是通过CircleFactory、Circle和Client类实现的。客户端请求工厂,它返回Circle的新实例,或者如果存在具有所需参数的实例,则从缓存中返回它。对于这个例子,共享数据将是具有其颜色的Circle对象,然后每个特定的圆将有自己的位置和半径。Graphic将包含所有这些信息的实际圆。通过我们的代码示例,事情将会变得更加清晰,前一个图就是基于这个示例的。
代码示例
是时候看看享元设计模式在 Scala 代码中的样子了。我们将使用之前显示的相同示例。值得注意的是,在代码版本中,一些类的名称与图中的不同。这样做的原因是 Scala 的命名约定。我们将在查看代码时明确指出这些情况。
关于享元设计模式和我们的例子,一个有趣的事情是它实际上使用了我们之前已经讨论过的其他设计模式和技巧。我们也会在查看代码时指出它们。
我们首先做的事情是表示颜色。这与实际的享元设计模式无关,但我们决定使用 ADTs:
sealed abstract class Color
case object Red extends Color
case object Green extends Color
case object Blue extends Color
case object Yellow extends Color
case object Magenta extends Color
在我们定义了颜色之后,我们可以实现我们的Circle类:
class Circle(color: Color) {
System.out.println(s"Creating a circle with $color color.")
override def toString(): String = s"Circle($color)"
}
圆将是享元对象,因此模型只包含将与其他圆实例共享的数据。现在我们有了圆的模型,我们可以创建我们的CircleFactory。正如其名所示,它使用工厂设计模式。以下是它的代码:
import scala.collection.mutable.Map
object Circle {
val cache = Map.empty[Color, Circle]
def apply(color: Color): Circle = cache.getOrElseUpdate(color,
new Circle(color))
def circlesCreated(): Int = cache.size
}
我们有一个伴随对象,用于在 Scala 中实现工厂设计模式。这就是为什么这里的名字与之前显示的图中的名字不同的原因。这种表示方式允许我们使用以下语法获取一个旧的圆实例或创建一个新的实例:
Circle(Green)
现在我们有了我们的圆和工厂,我们可以实现Graphic类:
import scala.collection.mutable.ListBuffer
class Graphic {
val items = ListBuffer.empty[(Int, Int, Double, Circle)]
def addCircle(x: Int, y: Int, radius: Double, circle: Circle): Unit = {
items += ((x, y, radius, circle))
}
def draw(): Unit = {
items.foreach {
case (x, y, radius, circle) =>
System.out.println(s"Drawing a circle at ($x, $y) with radius
$radius: $circle")
}
}
}
Graphic类实际上将持有我们的圆以及与它们相关的所有其他数据。前一个图中的Client在我们的代码中没有特定的表示——它将只是使用工厂获取圆的代码。同样,Graphic对象将通过程序检索圆对象,而不是通过客户端的显式访问。以下是我们如何在我们的例子中实现所有这些:
object FlyweightExample {
def main(args: Array[String]): Unit = {
val graphic = new Graphic
graphic.addCircle(1, 1, 1.0, Circle(Green))
graphic.addCircle(1, 2, 1.0, Circle(Red))
graphic.addCircle(2, 1, 1.0, Circle(Blue))
graphic.addCircle(2, 2, 1.0, Circle(Green))
graphic.addCircle(2, 3, 1.0, Circle(Yellow))
graphic.addCircle(3, 2, 1.0, Circle(Magenta))
graphic.addCircle(3, 3, 1.0, Circle(Blue))
graphic.addCircle(4, 3, 1.0, Circle(Blue))
graphic.addCircle(3, 4, 1.0, Circle(Yellow))
graphic.addCircle(4, 4, 1.0, Circle(Red))
graphic.draw()
System.out.println(s"Total number of circle objects created:
${Circle.circlesCreated()}")
}
}
如果我们运行这段代码,我们将得到以下输出:

在之前定义Circle类时,我们在构造函数中添加了一条打印消息。从前面的图中,我们可以看到每个圆只使用特定的颜色创建了一次,即使我们多次请求它来构建我们的图形。最后一行显示,我们恰好有五个不同的圆对象,尽管我们的图形包含 10 个不同的圆。
这只是一个示例,用来说明飞 weight 是如何工作的。在现实生活中,飞 weight 对象将共享更多属性,从而降低整个应用程序的整体内存占用。
它适用于什么
正如我们之前提到的,当试图降低应用程序使用的内存时,飞 weight 设计模式非常有用。使用共享对象,我们的应用程序将需要更少的对象构建和销毁,这可能会进一步提高性能。
它不适用于什么
根据共享数据量的大小,有时不同共享对象的数量可能会大幅增加,但这并不会带来太多好处。此外,它可能会使工厂及其使用变得更加复杂。在处理工厂时,多线程应用程序需要格外小心。最后但同样重要的是,开发者在使用共享对象时需要格外小心,因为它们中的任何变化都可能影响整个应用程序。幸运的是,在 Scala 中,由于不可变性,这并不是一个很大的问题。
代理设计模式
在某些应用程序中,开发者可能需要提供对对象的访问控制。这可能由许多原因引起。其中一些包括隐藏实现细节、提高与昂贵资源的交互、与远程资源接口、缓存、提供懒加载或预加载初始化等。代理设计模式有助于实现这些。
代理设计模式的目的在于提供一个接口,然后在该接口背后为用户提供服务。
代理设计模式是包装器的一个例子。它与装饰器设计模式非常相似,但感觉更基础和有限。这是因为代理与包装对象之间的关系是在编译时建立的,而装饰器可以在运行时应用。最终,它的目的也不同。
示例类图
对于类图,让我们假设我们有一个将文件中的文本可视化的应用程序。它可能需要根据用户操作来可视化文本,或者可能不需要。这些文件可能非常大,或者可能位于远程位置。以下是代理设计模式如何帮助我们实现这一目标的示例:

根据前面的图示,我们可以使用FileReaderProxy对象,并且只有在有人需要访问文件内容时,我们才会将功能委托给FileReaderReal。这种设计既好又方便,因为我们实际上可以使用FileReader对象;然而,我们可以通过不需要一次性加载所有内容,而只是在需要时加载一次来保持应用程序的效率。
代码示例
现在,让我们更仔细地看看实现前面类图所需的代码。首先,我们需要定义一个接口(使用 Scala 特质):
trait FileReader {
def readFileContents(): String
}
然后,我们创建了两个实现它的类——FileReaderReal和FileReaderProxy。首先,让我们看看前者是如何实现文件读取的,因为它并没有什么真正的意义:
class FileReaderReal(filename: String) extends FileReader {
val contents = {
val stream = this.getClass.getResourceAsStream(filename)
val reader = new BufferedReader(
new InputStreamReader(
stream
)
)
try {
reader.lines().iterator().asScala.mkString
(System.getProperty("line.separator"))
} finally {
reader.close()
stream.close()
}
}
System.out.println(s"Finished reading the actual file: $filename")
override def readFileContents(): String = contents
}
在对象的构建过程中,它将获取文件,读取它,并将其存储在contents变量中。然后,每次调用readFileContents时,该类将返回它已缓冲的内容。现在,让我们看看FileReaderProxy的实现:
class FileReaderProxy(filename: String) extends FileReader {
private var fileReader: FileReaderReal = null
override def readFileContents(): String = {
if (fileReader == null) {
fileReader = new FileReaderReal(filename)
}
fileReader.readFileContents()
}
}
实现中包含一个FileReaderReal的实例,它在第一次调用readFileContents时创建。实际的文件读取操作随后委托给FileReaderReal类。
FileReaderProxy的一个更优雅的实现将使用lazy val而不是可变变量。在这种情况下,if语句将不再需要。
现在,让我们看看我们的代理如何在应用程序中使用:
object ProxyExample {
def main(args: Array[String]): Unit = {
val fileMap = Map(
"file1.txt" -> new FileReaderProxy("file1.txt"),
"file2.txt" -> new FileReaderProxy("file2.txt"),
"file3.txt" -> new FileReaderProxy("file3.txt"),
"file4.txt" -> new FileReaderReal("file1.txt")
)
System.out.println("Created the map. You should have seen
file1.txt read because it wasn't used in a proxy.")
System.out.println(s"Reading file1.txt from the proxy:
${fileMap("file1.txt").readFileContents()}")
System.out.println(s"Reading file3.txt from the proxy:
${fileMap("file3.txt").readFileContents()}")
}
}
值得注意的是,每个文件实际上都是应用程序中的一个资源,并包含一行文本,形式为I am file x。在运行前面的示例之后,我们将得到以下输出:

如您从前面的屏幕截图中所见,实际对象是延迟创建的,因此实际的文件读取是在需要时进行的。这导致我们的应用程序跳过了file2.txt的读取,因为我们甚至没有请求它。有人可能会提出不同的解决方案来达到相同的目的,但它可能是一个不同的设计模式或类似代理的东西。
它的优点是什么
当我们想要将一些昂贵的操作委托给其他类,进行延迟操作,从而使我们的应用程序更高效时,代理设计模式是好的。
它的不足之处是什么
代理设计模式相当简单,实际上,没有可以提到的缺点。与其他任何设计模式一样,它们应该谨慎使用,并且只有在实际需要时才使用。
摘要
在本章中,我们学习了结构设计模式,特别是以下内容——适配器、装饰器、桥接、组合、外观、享元和代理。我们详细介绍了每一个,并为每一个展示了类图以及代码示例。由于 Scala 的丰富性,有时可以使用 Scala 的一些优秀特性来实现更好的实现,但有时设计模式在 Java 等语言中看起来可能是一样的。
在许多情况下,结构设计模式看起来相当相似。然而,这不应该让你感到困惑,因为它们仍然有不同的目的。一些例子包括:
-
适配器与桥接的比较:适配器用于在没有访问代码的情况下将一个接口转换为另一个接口。桥接用于软件设计时,它将抽象与实现解耦,以便于未来的扩展。
-
代理与装饰器的比较:装饰器通常增强一个接口。代理提供相同的接口,但有助于提高应用程序的效率。
现在,你应该对结构设计模式有了很好的理解,并且拥有足够的知识来在实际项目中应用它们。
在下一章中,你将学习关于行为设计模式的内容。
第八章:行为设计模式 – 第一部分
我们对 Scala 设计模式的探索已经到达了行为设计模式这一组。这个组比我们之前经历的其他组成员更多,所以我们将将其分为两个独立的部分。在本章中,我们将重点关注以下行为设计模式:
-
值对象
-
空对象
-
策略
-
命令
-
责任链模式
-
解释器
本章和下一章将阐明行为设计模式是什么,它们在哪里有用,以及如何在 Scala 中实现它们。我们将遵循与之前章节类似的道路,其中我们展示了模式、类图和代码示例,并最终给出了一些需要注意的事项和某些模式更倾向于在哪里使用的一些提示。希望你能对它们有一个感觉,并能够自信地识别出它们适用的场景。
定义行为设计模式
如其名所示,行为设计模式与行为有关。它们的目的在于识别和实现应用程序中对象之间的常见通信模式。它们以定义对象交互的方式,使得对象之间的通信变得容易,并且耦合仍然保持在较低水平。
行为设计模式描述了对象和类如何通过消息相互交互。与创建型和结构型设计模式相反,行为设计模式描述了一个流程或过程。这意味着开发者应该非常熟悉他们试图实现的实际过程。与其他类型的设计模式一样,行为设计模式的存在是为了提高产生的代码的可测试性、可维护性和灵活性。
值对象设计模式
在编程中,比较数据的方式有很多种。我们可以比较对象的标识或它们的值。这些在不同的场景中很有用,在这里,我们将看到值对象是什么以及它们何时可以使用。
值对象是小而简单的不可变对象。它们的等价性不是基于身份,而是基于值等价性。
值对象用于表示数字、货币、日期等。它们应该是小而不可变的;否则,改变值可能会引起错误和意外行为。由于它们的不可变性,它们在多线程应用程序中非常有用。它们也常在企业应用程序中用作数据传输对象。
一个示例类图
在诸如 Java 这样的语言中,没有直接对值对象的支持。开发者最终会做的是将字段声明为 final 并实现hashCode和equals方法。
然而,不可变性是一个在 Scala 中几乎强制执行的概念。我们之前已经看到了代数数据类型(ADTs)——它们也属于值对象类别。案例类和元组也是不可变的,它们用于实现值对象设计模式。以下类图显示了 Scala 中值对象设计模式的一个示例:

这个图表实际上并没有做什么特别的事情。它是一个名为Date的案例类的表示。这就是我们为了实现不可变性和能够实现值对象设计模式所需要做的所有事情。
一个代码示例
在我们的代码示例中,我们将使用我们的Date类。日期在软件产品中相当常用。当然,有一些库提供了关于日期操作的完整功能,但这对于示例来说已经足够了。首先,这是Date类的表示:
case class Date(
day: Int,
month: String,
year: Int
)
这是我们需要的一切,以便获得值对象。Scala 在后台为我们创建默认实现,用于hashCode、equals和toString方法。案例类给我们带来了额外的功能,但这超出了本节的范围。
现在,让我们使用我们的Date类:
object DateExample {
def main(args: Array[String]): Unit = {
val thirdOfMarch = Date(3, "MARCH", 2016)
val fourthOfJuly = Date(4, "JULY", 2016)
val newYear1 = Date(31, "DECEMBER", 2015)
val newYear2 = Date(31, "DECEMBER", 2015)
System.out.println(s"The 3rd of March 2016 is the same as
the 4th of July 2016: ${thirdOfMarch == fourthOfJuly}")
System.out.println(s"The new year of 2015 is here twice:
${newYear1 == newYear2}")
}
}
如您所见,我们使用了我们的对象作为值。我们应该注意,在这里,我们对参数没有任何验证;然而,这很容易添加,但与当前示例无关。如果我们现在运行我们的代码,我们将看到以下输出:

为了证明案例类使我们能够轻松实现值对象设计模式,而普通类则不能,让我们尝试将我们的Date类改为普通类,然后在相同的示例中使用它。我们的类将变为以下内容:
class BadDate(
day: Int,
month: String,
year: Int
)
然后,我们将使用相同的示例;然而,这次我们将使用BadDate,由于它不是一个案例类,我们将使用new关键字创建它:
object BadDateExample {
def main(args: Array[String]): Unit = {
val thirdOfMarch = new BadDate(3, "MARCH", 2016)
val fourthOfJuly = new BadDate(4, "JULY", 2016)
val newYear1 = new BadDate(31, "DECEMBER", 2015)
val newYear2 = new BadDate(31, "DECEMBER", 2015)
System.out.println(s"The 3rd of March 2016 is the same as the
4th of July 2016: ${thirdOfMarch == fourthOfJuly}")
System.out.println(s"The new year of 2015 is here twice:
${newYear1 == newYear2}")
}
}
这个示例的输出将是:

如您从前面的输出中看到的那样,普通类与案例类的工作方式不同,需要做一些额外的工作才能使用它们实现值对象设计模式。前面结果的原因是,类默认情况下是通过它们的引用标识来相互比较的,而不是通过它们携带的值。为了改变这一点,应该实现hashCode和equals。Scala 还允许我们为类重写==运算符。
不同的实现
在 Scala 中,也可以使用预定义的元组类来实现值对象设计模式。在这种情况下,我们甚至不需要创建我们的类,可以写一些像(3, "March", 2016)的东西。这将自动具有与值对象相同的特性。有最多 22 个元素的元组实现,但在实际应用中使用它们并不推荐,因为可读性和质量可能会大幅下降。此外,两个n个元素的元组可以被认为是相等的,即使从语义上讲,它们在我们应用中是不同类型的对象。最后但同样重要的是,使用案例类访问元素比编写像tuple._3这样的东西更容易、更易于阅读。
它适用于什么
正如我们之前提到的,值对象设计模式适用于多线程和创建数据传输对象(DTOs)。在 Scala 中,这非常容易实现,许多人每天都在使用它,甚至没有意识到它实际上是一种设计模式。值对象是 Scala 真正强大语言的一个例子。
它不适用于什么
除了在 Scala 中使用元组来表示值对象之外,使用这种模式没有其他主要缺点。
空对象设计模式
大多数面向对象的语言都有一种指定某些值不存在的方式。例如,在 Scala 和 Java 中,这可能是可以分配给对象的null值。对一个null对象调用任何方法都会导致NullPointerException,因此开发者应该小心并检查是否存在这种可能性。然而,这些检查可能会使源代码难以理解和扩展,因为开发者应该始终保持警觉。这就是空对象设计模式发挥作用的地方。
空对象设计模式的目的在于定义一个实际的对象,它代表null值并具有中性行为。
使用空对象可以消除检查某个值是否设置为null的需求。代码变得更加易于阅读和理解,并使得错误发生的可能性降低。
一个示例类图
对于类图,让我们想象我们有一个系统必须轮询队列以获取消息。当然,这个队列可能不会总是有东西提供,所以它会返回null。而不是检查null,我们可以简单地返回具有空行为的特殊空对象。让我们在图中展示这些消息类:

使用前面图中的类,每当没有数字要打印时,我们将返回一个具有空行为的NullMessage对象。在某些情况下,为了优化目的,人们可能会将NullMessage作为一个单例实例。
代码示例
在我们真正查看代码之前,我们将注意一些关于前面图表的观察。它代表了一个使用空对象设计模式的经典案例。然而,如今,在 Java 或 Scala 中实际上并没有这样使用。例如,Java 现在支持Optional,这被用作替代(假设人们使用语言的新版本)。在 Scala 中,情况类似——我们可以使用Option[Message]而不是空对象。此外,我们还获得了Option的所有其他良好特性,例如在模式匹配中使用它们的能力。
因此,如前所述,我们的代码实际上不会使用前面类图中的层次结构。它根本不需要它,并且将会更简单。相反,我们将使用Option[Message]。首先,让我们看看Message类的定义:
case class Message(number: Int) {
def print(): String = s"This is a message with number: $number."
}
我们提到我们将轮询队列以获取消息,然后显示它们。我们在应用程序中使用不同的线程模拟了一个随机填充的队列:
import java.util.concurrent.ConcurrentLinkedQueue
import scala.util.Random
class DataGenerator extends Runnable {
val MAX_VAL = 10
val MAX_TIME = 10000
private var isStop = false
private val queue: ConcurrentLinkedQueue[Int] = new ConcurrentLinkedQueue[Int]()
override def run(): Unit = {
val random = new Random()
while (!isStop) {
Thread.sleep(random.nextInt(MAX_TIME))
queue.add(random.nextInt(MAX_VAL))
}
}
def getMessage(): Option[Message] =
Option(queue.poll()).map {
case number => Message(number)
}
def requestStop(): Unit = this.synchronized {
isStop = true
}
}
上述代码展示了将在不同线程中运行的代码。队列将在随机间隔内填充 0(包含)到 10(不包含)之间的随机值。然后,可以调用getMessage,并读取队列中的任何内容。由于队列可能为空,我们向调用者返回一个Message的Option。可能值得提一下,在 Scala 中,Option(null)返回None。这正是我们在前面的代码中所利用的。
让我们看看在我们的示例中所有这些是如何结合在一起的:
object MessageExample {
val TIMES_TO_TRY = 10
val MAX_TIME = 5000
def main(args: Array[String]): Unit = {
val generator = new DataGenerator
// start the generator in another thread
new Thread(generator).start()
val random = new Random()
(0 to TIMES_TO_TRY).foreach {
case time =>
Thread.sleep(random.nextInt(MAX_TIME))
System.out.println("Getting next message...")
generator.getMessage().foreach(m =>
System.out.println(m.print()))
}
generator.requestStop()
}
}
上述程序创建了一个生成器,并在不同的线程上运行它。然后,它随机请求生成器中的项目,并在有返回值时打印它们。由于使用了随机生成器,程序每次打印的内容都不同。以下是一个示例运行:

如您从我们的示例和前面的输出中看到的,我们实际上从未检查过null,并且当队列返回null时,我们的代码根本不做任何事情。这在大型项目中工作得很好,并且使源代码看起来非常优雅且易于理解。
在实际应用中,像前面示例中的代码可能不是一个好主意。首先,我们可以在线程上使用定时器而不是调用sleep。其次,如果我们想创建生产者-消费者应用程序,我们可以使用如Akka([akka.io/](https://akka.io/))之类的库,这些库允许我们进行响应式编程,并拥有非常优化的代码。
它适用于什么
正如您已经看到的,空对象设计模式已经通过使用Option(Java 中的Optional)在 Scala(以及 Java 的新版本)中实现。这使得它非常容易使用,并再次展示了语言的力量。使用空对象使我们的代码看起来更加易读,并消除了在值是null时需要额外小心的需求。它还降低了出错的风险。
它不适用于什么
我们想不出这个设计模式有什么缺点。可能值得提一下的是——只有在真正需要的时候才使用它,而不是到处都使用。
策略设计模式
在企业应用程序中,有不同算法的实现,并在应用程序运行时选择一个使用,这是相当常见的事情。一些例子可能包括对不同大小或类型数据的排序算法,对不同数据可能表示的解析器,等等。
策略设计模式使我们能够在运行时定义一组算法并选择一个特定的算法。
策略设计模式有助于封装,因为每个算法都可以单独定义,然后注入到使用它的类中。不同的实现也是可互换的。
一个示例类图
对于类图,让我们想象我们正在编写一个需要从文件加载数据并使用这些数据的程序。当然,数据可以以不同的格式表示(在这种情况下是 CSV 或 JSON),并且根据文件类型,我们将使用不同的解析策略。表示我们的解析器的类图如下所示:

我们基本上有一个接口,不同的类实现它,然后根据需要,PersonApplication 被注入正确的实现。
上述类图与我们在书中看到的桥接设计模式的类图非常相似。尽管如此,这两种模式有不同的目的——构建者关注结构,而这里完全是关于行为。此外,策略设计模式看起来耦合度更高。
一个代码示例
在上一节中,我们展示了这里将要展示的示例的类图。正如你所见,我们使用了一个名为 Person 的模型类。它只是一个具有以下定义的案例类:
case class Person(name: String, age: Int, address: String)
由于我们的应用程序中可能会有不同的格式,我们定义了一个公共接口,所有解析器都将实现:
trait Parser[T] {
def parse(file: String): List[T]
}
现在,让我们看看实现。首先是 CSVParser:
import com.github.tototoshi.csv.CSVReader
class CSVParser extends Parser[Person] {
override def parse(file: String): List[Person] =
CSVReader.open(new
InputStreamReader(this.getClass.getResourceAsStream(file))).all().map {
case List(name, age, address) =>
Person(name, age.toInt, address)
}
}
它依赖于一个名为 scala-csv 的库(详细信息见 pom.xml/build.sbt 文件),该库将每一行读取为字符串列表。然后,它们被映射到 Person 对象。
接下来是 JsonParser 类的代码:
import org.json4s._
import org.json4s.jackson.JsonMethods
class JsonParser extends Parser[Person] {
implicit val formats = DefaultFormats
override def parse(file: String): List[Person] =
JsonMethods.parse(StreamInput(this.getClass.getResourceAsStream(file))).extract[List[Person]]
}
它读取一个 JSON 文件并使用 json4s 库进行解析。正如你所见,尽管两种实现做的是同一件事,但它们相当不同。当我们有一个 JSON 文件时,我们不能应用 CSV 实现,反之亦然。文件看起来也非常不同。以下是我们在示例中使用的 CSV 文件:
Ivan,26,London
Maria,23,Edinburgh
John,36,New York
Anna,24,Moscow
这是 JSON 文件:
[
{
"name": "Ivan",
"age": 26,
"address": "London"
},
{
"name": "Maria",
"age": 23,
"address": "Edinburgh"
},
{
"name": "John",
"age": 36,
"address": "New York"
},
{
"name": "Anna",
"age": 24,
"address": "Moscow"
}
]
前面的数据集包含完全相同的数据,但格式使它们看起来完全不同,并且需要不同的解析方法。
在我们的例子中,我们做了一件事。我们使用工厂设计模式来在运行时根据文件类型选择正确的实现:
object Parser {
def apply(filename: String): Parser[Person] =
filename match {
case f if f.endsWith(".json") => new JsonParser
case f if f.endsWith(".csv") => new CSVParser
case f => throw new RuntimeException(s"Unknown format: $f")
}
}
之前的工厂只是一个例子。它只检查文件扩展名,当然,可以做得更加健壮。使用这个工厂,我们可以为应用程序类选择正确的解析器实现,其代码如下所示:
class PersonApplicationT {
def write(file: String): Unit = {
System.out.println(s"Got the following data ${parser.parse(file)}")
}
}
不论实现方式如何,应用程序类看起来都是一样的。不同的实现可以被插入,只要没有错误,一切都应该运行。
现在,让我们看看我们如何在示例中使用我们的策略设计模式:
object ParserExample {
def main(args: Array[String]): Unit = {
val csvPeople = Parser("people.csv")
val jsonPeople = Parser("people.json")
val applicationCsv = new PersonApplication(csvPeople)
val applicationJson = new PersonApplication(jsonPeople)
System.out.println("Using the csv: ")
applicationCsv.write("people.csv")
System.out.println("Using the json: ")
applicationJson.write("people.json")
}
}
如您所见,这相当简单。上一应用程序的输出如下所示:

在两种情况下,我们的应用程序都能够很好地处理不同的格式。为新的格式添加新的实现也很简单——只需实现 Parser 接口并确保工厂知道它们。
Scala 方式的策略设计模式
在上一节中,我们展示了使用类和特性来设计策略模式。这就是在纯面向对象语言中的样子。然而,Scala 既是函数式语言,又提供了通过编写更少的代码来实现它的更多方法。在本小节中,我们将利用 Scala 中函数是一等对象的事实来展示策略模式。
首先会改变的是,我们不需要有一个接口及其实现它的类。相反,我们的 Application 类将如下所示:
class ApplicationT => List[T]) {
def write(file: String): Unit = {
System.out.println(s"Got the following data ${strategy(file)}")
}
}
这里需要注意的最重要的事情是,策略参数是一个函数而不是一个普通对象。这立即允许我们传递任何我们想要的函数,而无需实现特定的类,只要它满足这些要求——一个 String 参数并返回一个 List[T]。如果我们策略中有多个方法,我们可以使用一个 case 类或元组来分组它们。
对于当前示例,我们决定将函数实现放在某个地方,以便它们与工厂一起分组,工厂将选择使用哪一个:
import com.github.tototoshi.csv.CSVReader
import org.json4s.{StreamInput, DefaultFormat}
import org.json4s.jackson.JsonMethods
object StrategyFactory {
implicit val formats = DefaultFormats
def apply(filename: String): (String) => List[Person] =
filename match {
case f if f.endsWith(".json") => parseJson
case f if f.endsWith(".csv") => parseCsv
case f => throw new RuntimeException(s"Unknown format: $f")
}
def parseJson(file: String): List[Person] =
JsonMethods.parse(StreamInput(this.getClass.getResourceAsStream(file))).extract[List[Person]]
def parseCsv(file: String): List[Person] = CSVReader.open(new
InputStreamReader(this.getClass.getResourceAsStream(file))).all().map {
case List(name, age, address) => Person(name, age.toInt, address)
}
}
之前的代码与之前的工厂相同,但这次它返回方法,然后可以调用这些方法。
最后,这是如何使用应用程序的:
object StrategyExample {
def main(args: Array[String]): Unit = {
val applicationCsv = new ApplicationPerson)
val applicationJson = new ApplicationPerson)
System.out.println("Using the csv: ")
applicationCsv.write("people.csv")
System.out.println("Using the json: ")
applicationJson.write("people.json")
}
}
上一例子的输出将绝对与之前相同。
它适用于什么
策略设计模式帮助我们能够在运行时更改实现。此外,如您所见,实现与使用它们的代码是分开的,因此很容易添加新的实现,而不会在其他系统部分引入错误。
它不适用于什么
尽管从长远来看,使用函数实现的 Scala 策略模式可能会节省大量代码,但有时它会影响可读性和可维护性。方法可以存储在对象、类、case 类、特质中等,这表明不同的人可能更喜欢不同的方法,而在大型团队中工作时不总是好的。除此之外,只要正确使用和放置,策略设计模式没有任何重大缺陷。
命令设计模式
在我们的应用程序中,有时我们可能需要将有关如何执行某些操作的信息传递给其他对象。通常,这个操作将在某种事件的基础上稍后执行。将执行我们命令的对象称为调用者,它甚至可能不知道它实际运行的命令。它只知道关于接口的信息,这意味着它知道如何触发命令。命令设计模式帮助我们实现这一点。
命令设计模式的目的是在稍后阶段执行操作所需的信息进行封装,并将这些信息传递给将运行实际代码的对象。
通常,命令信息将包含拥有方法的对象、方法名称以及调用方法时应传递的参数。命令设计模式在许多方面都很有用,其中包括支持撤销操作、实现并行处理,或者通过延迟和可能避免代码执行来优化代码。
一个示例类图
当谈论命令设计模式时,通常有几个对象,每个对象都有其特定的角色:
-
命令: 我们可以将其视为调用者调用的接口及其实现。
-
接收者: 这是实际知道如何执行命令的对象。可以将其想象为一个被传递给命令并在接口方法中使用的对象。
-
调用者: 它通过调用它们的接口方法来调用命令。正如我们之前提到的,它甚至可能不知道正在调用哪些命令。
-
客户端: 它通过调用调用者来大致指导执行哪些命令。
现在我们已经了解了最重要的对象及其在命令设计模式中的角色,我们可以看看一个例子。对于类图,让我们想象我们有一个机器人,它可以烹饪。我们通过控制器连接到它并向我们的机器人发送命令。事情相当简化,但应该足以理解这个模式是如何工作的。以下是类图:

我们可以快速识别出,在这里,RobotCommand 接口及其实现是命令。接收者是 Robot 类,因为它知道如何运行所有发送给它的命令。RobotController 类是调用者。它不知道它执行的是哪种类型的命令,只是在需要时运行它们。我们还没有在先前的图中展示我们的客户端类,因为它只是通过运行代码的示例应用程序来表示,该应用程序实现了之前显示的类图。
你可以很容易地看到,如果代表先前图示的代码发生变化,它可以很容易地添加多线程支持和撤销功能。
代码示例
现在,是时候看看代表先前图示的有趣部分——代码了。一如既往,我们将逐个查看各个类,并在必要时给出简要说明。
我们将要查看的第一段代码是 Robot 类。我们之前提到过,它充当接收者并知道如何执行一些特定的功能:
case class Robot() {
def cleanUp(): Unit = System.out.println("Cleaning up.")
def pourJuice(): Unit = System.out.println("Pouring juice.")
def makeSandwich(): Unit = System.out.println("Making a sandwich.")
}
我们保持了代码的简单性,方法只是将不同的内容打印到命令行。接下来是带有其不同实现的机器人命令:
trait RobotCommand {
def execute(): Unit
}
case class MakeSandwichCommand(robot: Robot) extends RobotCommand {
override def execute(): Unit = robot.makeSandwich()
}
case class PourJuiceCommand(robot: Robot) extends RobotCommand {
override def execute(): Unit = robot.pourJuice()
}
case class CleanUpCommand(robot: Robot) extends RobotCommand {
override def execute(): Unit = robot.cleanUp()
}
上述代码绝对没有特殊之处。它是一个简单的特质,由不同的类实现。它依赖于 Robot 接收者,它知道如何执行方法。
RobotController 类是我们的调用者,根据 RobotCommand 接口发出命令。它不需要了解它发出的命令的任何信息,只要遵循接口即可。我们决定添加一些命令的历史记录,这些记录可以在以后进行回滚。调用者的代码如下所示:
class RobotController {
val history = ListBuffer[RobotCommand]()
def issueCommand(command: RobotCommand): Unit = {
command +=: history
command.execute()
}
def showHistory(): Unit = {
history.foreach(println)
}
}
现在,让我们看看使用所有先前类的示例。正如我们之前提到的,它实际上将充当客户端。以下是源代码:
object RobotExample {
def main(args: Array[String]): Unit = {
val robot = Robot()
val robotController = new RobotController
robotController.issueCommand(MakeSandwichCommand(robot))
robotController.issueCommand(PourJuiceCommand(robot))
System.out.println("I'm eating and having some juice.")
robotController.issueCommand(CleanUpCommand(robot))
System.out.println("Here is what I asked my robot to do:")
robotController.showHistory()
}
}
本应用程序的输出将与以下截图中的相同:

我们可以看到,我们的调用者成功保存了事件的记录。这意味着只要我们的命令和接收者(Robot)有撤销方法,我们就可以实现这些方法并具有额外的功能。
Scala 方式的命令设计模式
命令设计模式是另一种设计模式,在 Scala 中与其他语言相比可以以不同的方式实现。我们将展示先前的示例的另一种实现。这次,我们将使用语言的 按名参数 功能。它可以替换为传递函数作为参数(我们之前已经为策略设计模式看到过),但更冗长。让我们看看它将是什么样子。
实际上,应用程序不需要做太多改变。我们只重构并重命名了 RobotController 和 RobotExample 类。以下是之前的类,现在称为 RobotByNameController:
class RobotByNameController {
val history = ListBuffer[() => Unit]()
def issueCommand(command: => Unit): Unit = {
command _ +=: history
command
}
def showHistory(): Unit = {
history.foreach(println)
}
}
正如你所见,我们并没有传递一个实际的命令对象,而是仅仅将一个按名传递的参数传递给issueCommand方法。这个方法所做的就是延迟对获取传递值的调用,直到实际需要时。为了使前面的代码能够工作,我们不得不对我们的示例代码进行重构:
object RobotByNameExample {
def main(args: Array[String]): Unit = {
val robot = Robot()
val robotController = new RobotByNameController
robotController.issueCommand(MakeSandwichCommand(robot).execute())
robotController.issueCommand(PourJuiceCommand(robot).execute())
System.out.println("I'm eating and having some juice.")
robotController.issueCommand(CleanUpCommand(robot).execute())
System.out.println("Here is what I asked my robot to do:")
robotController.showHistory()
}
}
当我们不希望为命令接口及其实现编写额外代码时,按名参数方法很有用。我们只需传递任何函数调用(在这种情况下,直接从接收者传递)即可,它将被延迟到数据需要时,或者根本不调用。输出将与之前相同,但现在的区别是我们有了函数,历史打印输出将略有不同。
它的优点是什么
命令设计模式适用于我们想要延迟、记录或按某种原因对方法调用进行排序的情况。另一个优点是它将调用者与实际执行特定操作的对象解耦。这使我们能够轻松地进行修改和添加新功能。
它的缺点是什么
尽管按名传递参数的方法看起来很优雅,并且可以使我们的写作更简洁,但在这里可能并不是一个好主意。与我们的前一个例子相比,一个很大的缺点是我们实际上可以插入任何Unit数据,这可能与接收者应该执行的操作不相关。然而,在其他情况下,按名传递参数技术非常有用,并且可以显著提高我们的应用程序性能。
责任链设计模式
现在,随着数据规模的增长和大数据的热潮,流处理是许多应用程序必须能够执行的操作。流处理的特点是数据流的无尽流动,这些数据从一个对象传递到另一个对象,而每个对象都可以进行一些处理,然后将它传递给下一个对象。在其他情况下,数据可以在链中移动,直到到达一个知道如何处理特定命令的对象。
前面的行为非常适合责任链设计模式。
责任链设计模式的目的是通过让多个对象有机会处理请求,从而将请求的发送者与其接收者解耦。
责任链设计模式可能会有一些变化。原始模式是,每当一个请求到达一个可以处理它的对象时,它就不会再进一步。然而,在某些情况下,我们可能需要进一步推动请求,甚至将其乘以并广播给其他接收者。
值得注意的是,责任链模式根本不是数据特定的,它可以在任何出现上述特性的场景中使用。
一个示例类图
常用来说明责任链设计模式的一个例子是关于应用程序中的事件处理,这取决于事件是来自鼠标还是键盘操作。对于我们的类图和代码示例,让我们看看我们每天都会用到的东西——ATM。它们如何以不同的纸币组合返回正确的金额?答案是,当然,责任链。
我们在这里将展示两个图——一个显示允许我们实现责任链模式的类,另一个将展示这些类如何一起使用来构建我们的 ATM。
首先,让我们单独看一下我们的类:

在前面的图中,我们有一个基类(在 Scala 中用特质表示),然后由不同的具体分配器扩展。每个分配器都有一个相同类的可选实例,这样我们就可以构建一个链。对于所有分配器,dispense方法都是相同的,然后每个分配器都有一个不同的金额和链中的不同下一个元素。
当我们展示我们的 ATM 实现时,一切将会变得更加清晰。这可以在以下图中看到:

前面的图显示了我们在 ATM 中实际拥有的链。每当有人请求现金时,ATM 将前往 50 英镑纸币的分配器,然后是下一个分配器,依此类推,直到满足用户的请求。在接下来的小节中,我们将逐步展示我们的代码。
代码示例
让我们逐行查看前一个示例的代码。首先,我们定义了一个Money类,它代表了用户请求的金额。其定义如下:
case class Money(amount: Int)
现在,让我们看看Dispenser特质。这是具体分配器扩展的特质,如下所示:
trait Dispenser {
val amount: Int
val next: Option[Dispenser]
def dispense(money: Money): Unit = {
if (money.amount >= amount) {
val notes = money.amount / amount
val left = money.amount % amount
System.out.println(s"Dispensing $notes note/s of $amount.")
if (left > 0) next.map(_.dispense(Money(left)))
} else {
next.foreach(_.dispense(money))
}
}
}
如前所述,对于扩展我们的Dispenser的每个人,分配方法都是相同的,但金额和链中的下一个元素需要由扩展它的人定义。dispense方法尝试返回尽可能多的指定面额的纸币;在此之后,如果还有钱要分配,它将责任传递给下一个分配器。
下面的代码块展示了我们对不同分配器的实现——包括50、20、10和5英镑纸币的分配器:
class Dispenser50(val next: Option[Dispenser]) extends Dispenser {
override val amount = 50
}
class Dispenser20(val next: Option[Dispenser]) extends Dispenser {
override val amount: Int = 20
}
class Dispenser10(val next: Option[Dispenser]) extends Dispenser {
override val amount: Int = 10
}
class Dispenser5(val next: Option[Dispenser]) extends Dispenser {
override val amount: Int = 5
}
我们到目前为止所展示的是责任链设计模式的精髓。使用定义的类,我们现在将构建一个易于使用的链。
下面是我们ATM类的代码:
class ATM {
val dispenser: Dispenser = {
val d1 = new Dispenser5(None)
val d2 = new Dispenser10(Some(d1))
val d3 = new Dispenser20(Some(d2))
new Dispenser50(Some(d3))
}
def requestMoney(money: Money): Unit = {
if (money.amount % 5 != 0) {
System.err.println("The smallest nominal is 5 and we cannot
satisfy your request.")
} else {
dispenser.dispense(money)
}
}
}
在前面的代码中,我们构建了将由我们的ATM类使用的分配器链。这里的顺序对于系统的正确运行至关重要。我们还进行了一些合理性检查。然后,使用我们的ATM类就相当直接,如下所示的应用程序:
object ATMExample {
def main(args: Array[String]): Unit = {
val atm = new ATM
printHelp()
Source.stdin.getLines().foreach {
case line =>
processLine(line, atm)
}
}
def printHelp(): Unit = {
System.out.println("Usage: ")
System.out.println("1\. Write an amount to withdraw...")
System.out.println("2\. Write EXIT to quit the application.")
}
def processLine(line: String, atm: ATM): Unit = {
line match {
case "EXIT" =>
System.out.println("Bye!")
System.exit(0)
case l =>
try {
atm.requestMoney(Money(l.toInt))
System.out.println("Thanks!")
} catch {
case _: Throwable =>
System.err.println(s"Invalid input: $l.")
printHelp()
}
}
}
}
这是一个交互式应用程序,它等待用户输入,然后使用 ATM。让我们看看以下截图中的这个示例运行将如何显示:

如您在代码中所见,我们的 ATM 没有其他 ATM 具有的额外功能——检查钞票可用性。然而,这是一个可以进一步扩展的功能。
Scala 风格的链式责任设计模式
仔细查看代码和类图,您可以看到一些与装饰器设计模式的相似之处。这意味着在这里,我们可以使用相同的可堆叠特质,它们使用abstract override构造。我们已经看到了一个例子,这不会为您提供任何新的信息。然而,Scala 编程语言中还有另一个功能,我们可以用来实现链式责任设计模式——部分函数。
使用部分函数,我们不需要分别定义特定的分发器类。我们的分发器将变为以下:
trait PartialFunctionDispenser {
def dispense(dispenserAmount: Int): PartialFunction[Money, Money] = {
case Money(amount) if amount >= dispenserAmount =>
val notes = amount / dispenserAmount
val left = amount % dispenserAmount
System.out.println(s"Dispensing $notes note/s of $dispenserAmount.")
Money(left)
case m @ Money(amount) => m
}
}
当然,有不同的方式来完成这件事——我们可以有一个抽象特质,然后实现部分函数(类似于原始示例),并且不指定dispenserAmount参数,或者我们可以有一个具有该函数不同实现的特质,而不是传递dispenserAmount参数,等等。然而,这样做允许我们后来模拟无限数量的不同钞票的存在。
在我们有了新的分发器,它返回一个PartialFunction而不是什么也没有(Unit)之后,我们可以定义我们的ATM类:
class PartialFunctionATM extends PartialFunctionDispenser {
val dispenser =
dispense(50)
.andThen(dispense(20))
.andThen(dispense(10))
.andThen(dispense(5))
def requestMoney(money: Money): Unit = {
if (money.amount % 5 != 0) {
System.err.println("The smallest nominal is 5 and we cannot
satisfy your request.")
} else {
dispenser(money)
}
}
}
这里有趣的部分是dispenser字段以及我们使用它的方式。在前面的代码中,我们使用andThen方法链式连接多个部分函数,最后,我们将它们的输出作为方法使用。
根据开发者想要创建的链,他们可以使用部分函数的orElse或andThen方法。前者对单个处理器很有用,后者用于链式操作。
运行原始示例,但用 ATM 实现替换,将产生绝对相同的结果。
正如您在本小节中看到的那样,使用部分函数可以使我们的应用程序更加灵活,并且将需要我们编写更少的代码。然而,在理解高级 Scala 语言概念方面可能会更加要求严格。
为了完整性,值得一提的是,我们还可以使用 Akka 库实现责任链设计模式。我们将在本书的后续章节中探讨这个库,你可能会看到这个设计模式如何用 Scala 转换为响应式编程。
它的优点是什么
当我们想要解耦请求的发送者与接收者,并将这些接收者分离成它们自己的实体时,应该使用链式责任设计模式。这对于创建管道和处理事件很有用。
它不擅长的地方
作为责任链设计模式的负面和可能的陷阱,我们将讨论涉及部分函数的实现。这是因为它可能无法总是实现开发者的期望,这可能会进一步复杂化代码并影响可读性。
解释器设计模式
在现代编程中,我们有时必须处理来自理解良好和定义明确的领域的难题。在某些情况下,用语言表示领域是有意义的,这可以使得使用解释器解决问题变得容易。
解释器设计模式通过使用类来表示语言并构建语法树以评估语言表达式,用于指定如何评估语言中的句子。
解释器设计模式利用了组合设计模式。解释器的常见用途包括语言解析、协议等。
一个示例类图
创建语言和语法是一项复杂的工作,在深入之前,开发者应该确信这确实值得付出努力。它需要对正在建模的领域有很好的理解,通常需要一些时间。在本节中,我们将展示一个程序的解释器部分的类图,该程序以逆波兰表示法解析和评估表达式。这是计算机科学中的一个重要概念,它展示了计算机在执行不同操作时是如何实际工作的。截图如下所示:

我们语言的主要概念是表达式。一切都是一个表达式,正在被解释。
在我们的图中,我们可以区分两种主要的表达式类型:
-
终端表达式:这是
Number类。在构建表达式语法树时,它没有其他子节点(叶节点)。 -
非终端表达式:这些是
Add、Subtract和Multiply类。它们有子表达式,这就是整个语法树是如何构建的。
上一张截图只显示了解释器将转换成我们语言的那些表达式。在下一小节中,我们还将展示所有其他可以使此类应用程序工作的类。
代码示例
在这里,我们将逐步展示我们的解释器应用程序的代码。我们目前有一些限制,例如只支持整数,没有良好的错误报告机制,并且只有三种操作,但很容易添加新的操作。你可以尝试在我们已有的基础上进行构建。
首先,让我们看看基本的Expression特质:
trait Expression {
def interpret(): Int
}
它非常简单,包含一个其他表达式必须实现的方法。终端表达式,即我们的Number类,如下所示:
class Number(n: Int) extends Expression {
override def interpret(): Int = n
}
它没有做任何特别的事情——只是在interpret被调用时返回它携带的数字。非终端表达式有更多的代码,但它们都非常简单:
class Add(right: Expression, left: Expression) extends Expression {
override def interpret(): Int = left.interpret() + right.interpret()
}
class Subtract(right: Expression, left: Expression) extends Expression {
override def interpret(): Int = left.interpret() - right.interpret()
}
class Multiply(right: Expression, left: Expression) extends Expression {
override def interpret(): Int = left.interpret() * right.interpret()
}
到目前为止,这是我们展示在图中的所有内容,它是解释器设计模式的基本部分。有些人可能会注意到,在所有构造函数中,我们首先有右边的表达式,然后是左边的表达式。这是故意为之,因为它会在我们实际实现解析器时使代码更加清晰。
从现在开始,我们将展示如何在实际应用程序中解析和使用设计模式。首先,我们需要创建一个基于标记的工厂,该标记决定它应该返回哪个表达式:
object Expression {
def apply(operator: String, left: => Expression, right: => Expression): Option[Expression] =
operator match {
case "+" => Some(new Add(right, left))
case "-" => Some(new Subtract(right, left))
case "*" => Some(new Multiply(right, left))
case i if i.matches("\\d+") => Some(new Number(i.toInt))
case _ => None
}
}
在前面的代码中,我们应用了一些我们已经讨论过的技术和设计模式——工厂模式和按名称传递参数。后者非常重要,因为根据我们的代码触发的哪个情况,它们将决定是否会被评估。
我们有一个看起来像下面的解析器类:
class RPNParser {
def parse(expression: String): Expression = {
val tokenizer = new StringTokenizer(expression)
tokenizer.asScala.foldLeft(mutable.Stack[Expression]()) {
case (result, token) =>
val item = Expression(token.toString, result.pop(), result.pop())
item.foreach(result.push)
result
}.pop()
}
}
在这里,我们依赖于StringTokenizer和栈。我们使用了之前定义的工厂方法,这是有趣的部分——只有在我们遇到运算符情况时,才会调用pop。它将按照我们在工厂内部使用参数的顺序被调用。
从栈中弹出元素
如前所述的代码所示,在工厂中,我们使用了按名称传递的参数,并且在我们访问参数的地方,我们首先访问右边的参数,然后是左边的参数。这,以及我们的表达式类首先指定右参数的事实,使我们的代码更加清晰,并确保一切按预期工作。这样做的原因是因为我们依赖于栈,并且它反转了运算符的顺序。
在我们处理完一个表达式后,如果一切顺利,我们应该只在栈中有一个元素,它将包含完整的树。然后,我们将有一个只获取表达式并在其上调用interpret方法的解释器类:
class RPNInterpreter {
def interpret(expression: Expression): Int = expression.interpret()
}
最后,让我们看看一个使用我们的语言和解释器设计模式的应用程序:
object RPNExample {
def main(args: Array[String]): Unit = {
val expr1 = "1 2 + 3 * 9 10 + -" // (1 + 2) * 3 - (9 + 10) = -10
val expr2 = "1 2 3 4 5 * * - +" // 1 + 2 - 3 * 4 * 5 = -57
val expr3 = "12 -" // invalid
val parser = new RPNParser
val interpreter = new RPNInterpreter
System.out.println(s"The result of '${expr1}' is:
${interpreter.interpret(parser.parse(expr1))}")
System.out.println(s"The result of '${expr2}' is:
${interpreter.interpret(parser.parse(expr2))}")
try {
System.out.println(s"The result is:
${interpreter.interpret(parser.parse(expr3))}")
} catch {
case _: Throwable => System.out.println(s"'$expr3' is invalid.")
}
}
}
这个应用程序的输出将是以下内容:

如你所见,我们的代码正确地评估了表达式。当然,有一些改进可以做出,它们主要与错误处理和解析有关,但这超出了本小节的范围。在本小节中,我们看到了如何使用解释器设计模式。
它的优点
解释器设计模式适用于处理定义明确且理解良好的领域的应用程序。它可以极大地简化应用程序代码。你不应该将解释器设计模式与解析混淆,尽管我们为了构建表达式需要解析。
它的缺点
创建语言和语法不是一项容易的工作。开发者应该在决定使用这种设计模式之前,彻底评估他们试图解决的问题。
摘要
在本章中,我们回顾了第一组行为设计模式。我们研究了值对象、空对象、策略、命令、责任链和解释器。正如前几章所看到的,其中一些模式有更好的替代方案,它们使用了 Scala 更强大和灵活的特性。在许多情况下,实现相同的设计模式有多种不同的方法。我们试图展示一些好的方法,并且在更多设计模式可以使用 Scala 编程语言的相同功能的情况下,我们也试图避免重复。到目前为止,你应该已经拥有了足够的知识,以便在被告知使用哪种方法时,能够根据我们已经展示的内容自行使用替代实现。
我们提供了一些指导,这些指导应该有助于在编写软件时确定要寻找的内容,以及识别潜在的应用行为设计模式的位置。
在接下来的章节中,我们将探讨下一组行为设计模式,这也标志着我们一直关注的前四个设计模式的结束。
第九章:行为设计模式 – 第二部分
行为设计模式组相对较大。在前一章中,我们研究了行为设计模式的第一部分,并了解了它们的目的。正如我们已经知道的,这些模式用于处理计算机程序中的行为和对象通信的建模。
在本章中,我们将继续从 Scala 的角度研究不同的行为设计模式。我们将探讨以下主题:
-
迭代器
-
中介者
-
备忘录
-
观察者
-
状态
-
模板方法
-
访问者
本章我们将要讨论的设计模式可能不像我们之前看到的一些模式那样与函数式编程相关。它们可能看起来像是 Java 设计模式的 Scala 实现,实际上也是如此。然而,这并不意味着它们是不必要的,由于 Scala 的混合特性,它们仍然很重要。
如前几章一样,我们将遵循相同的结构,给出模式定义,展示类图和代码示例,并讨论特定设计模式的优缺点。
迭代器设计模式
我们在软件项目中经常使用迭代器。当我们遍历列表或遍历集合或映射的项目时,我们使用迭代器。
迭代器设计模式提供了一种以顺序方式访问聚合对象(集合)元素的方法,而不暴露项目底层的表示。
当使用迭代器设计模式时,开发者不需要知道底层是链表、数组、树还是哈希表。
示例类图
使用迭代器设计模式,我们可以创建自己的对象,使其充当集合,并在循环中使用它们。在 Java 中,有一个名为Iterator的接口,我们可以为此目的实现它。在 Scala 中,我们可以混入Iterator特质并实现其hasNext和next方法。
对于类图和示例,让我们有一个ClassRoom类,它将支持遍历所有学生的 foreach 循环。以下图表显示了我们的类图:

我们决定让我们的ClassRoom类实现Iterable,它应该返回一个迭代器,并在方法调用时返回迭代器的新实例。迭代器设计模式在图表的右侧表示。图表的其余部分是我们为了使与我们的类一起工作更简单而做的事情。
代码示例
让我们看看实现前面图表的代码。首先,Student类只是一个看起来如下所示的 case 类:
case class Student(name: String, age: Int)
我们已经在StudentIterator类中实现了标准的 Scala Iterator特质。以下是实现代码:
class StudentIterator(students: Array[Student]) extends Iterator[Student] {
var currentPos = 0
override def hasNext: Boolean = currentPos < students.size
override def next(): Student = {
val result = students(currentPos)
currentPos = currentPos + 1
result
}
}
关于迭代器,有一件事需要知道,它们只能单向工作,你不能回退。这就是为什么我们简单地使用一个currentPos变量来记住我们在迭代中的位置。在这里我们使用了一个可变变量,这与 Scala 的原则相悖;然而,这只是一个例子,并不太关键。在实践中,你可能会结合数据结构使用迭代器设计模式,而不是这种形式。我们选择迭代器的底层结构为Array的原因是数组的索引访问是常数,这将提高大型集合的性能并使我们的实现简单。
前面的代码足以展示迭代器设计模式。其余的代码在这里是为了帮助我们展示它如何被使用。让我们看看ClassRoom类:
import scala.collection.mutable.ListBuffer
class ClassRoom extends Iterable[Student] {
val students: ListBuffer[Student] = ListBuffer[Student]()
def add(student: Student): Unit = {
student +=: students
}
override def iterator: Iterator[Student] = new StudentIterator(students.toArray)
}
在前面的代码中,我们混入了Iterable特质并实现了它的iterator方法。我们返回我们的StudentIterator。
我们创建了一个自定义迭代器仅作为一个例子。然而,在现实中,你只需在ClassRoom类中实现Iterable并返回底层集合(在这种情况下是学生)的迭代器。
让我们看看一个使用我们的ClassRoom类的例子:
object ClassRoomExample {
def main(args: Array[String]): Unit = {
val classRoom = new ClassRoom
classRoom.add(Student("Ivan", 26))
classRoom.add(Student("Maria", 26))
classRoom.add(Student("John", 25))
classRoom.foreach(println)
}
}
我们混入Iterable特质的事实使我们能够在ClassRoom类型的对象上使用foreach、map、flatMap等许多其他操作。以下截图显示了我们的示例输出:

正如你在本例中可以看到的,我们的ClassRoom类的用户对持有我们的Student对象的数据结构一无所知。我们可以在任何时候替换它(我们甚至可以从数据库中获取学生的数据),只要我们的类中还有Iterable特质,整个代码就会继续工作。
它有什么好处
迭代器设计模式在软件工程中经常被使用。它可能是最常用的设计模式之一,每个人都听说过它。它几乎与所有可以想到的集合一起使用,它很简单,并允许我们隐藏复合对象内部组织的细节。
它有什么不好
我们实现的一个明显的缺点,这显示了迭代器设计模式可能存在的问题,是在并行代码中的使用。如果另一个线程向原始集合中添加或删除对象会发生什么?我们的迭代器将不会反映这一点,并且可能由于缺乏同步而导致问题。使迭代器能够处理多线程环境不是一个简单的任务。
中介设计模式
现实世界的软件项目通常包含大量不同的类。这有助于分配复杂性和逻辑,使得每个类只做一件特定的事情,简单而不是许多复杂任务。然而,这要求类以某种方式相互通信,以实现某些特定功能,但保持松散耦合原则可能成为一个挑战。
中介设计模式的目的在于定义一个对象,该对象封装了一组其他对象如何相互交互,以促进松散耦合,并允许我们独立地改变类交互。
中介设计模式定义了一个特定的对象,称为 中介,它使其他对象能够相互通信,而不是直接这样做。这减少了它们之间的依赖性,使得程序在未来易于更改和维护,并且可以正确地进行测试。
示例类图
让我们想象我们正在为学校构建一个系统,其中每个学生可以参加多个课程,每个课程由多个学生参加。我们可能希望有一个功能,可以通知特定课程的所有学生该课程已被取消,或者我们可能希望轻松地添加或从课程中删除用户。我们可以冲动地开始编写我们的代码,并将课程列表作为 student 类的一部分,以及 group 类中的学生列表。然而,这样我们的对象将变得相互关联,并且实际上不可重用。这正是中介模式的好用例。
让我们看看我们的类图:

如您从前面的图中可以看到,学校是中介,它包含有关用户到组和组到用户的信息。它管理这些实体之间的交互,并允许我们使我们的 学生 和 组 类可重用,并且彼此独立。
我们已经给出了学生和课程的示例;然而,这可以很容易地应用于任何多对多关系——软件中的权限组、出租车系统、空中交通管制系统等等。
代码示例
现在我们已经展示了我们的类图,让我们来看看示例的源代码。首先,让我们看看我们有的模型类:
trait Notifiable {
def notify(message: String)
}
case class Student(name: String, age: Int) extends Notifiable {
override def notify(message: String): Unit = {
System.out.println(s"Student $name was notified with message:
'$message'.")
}
}
case class Group(name: String)
在前面的代码中,Notifiable 特性在当前示例中不是必需的;然而,例如,如果我们添加教师,那么在需要向同一组中的所有人发送通知的情况下,它将是有用的。前一个代码中的类可以有自己的独立功能。
我们的 Mediator 特性有以下定义:
trait Mediator {
def addStudentToGroup(student: Student, group: Group)
def isStudentInGroup(student: Student, group: Group): Boolean
def removeStudentFromGroup(student: Student, group: Group)
def getStudentsInGroup(group: Group): List[Student]
def getGroupsForStudent(student: Student): List[Group]
def notifyStudentsInGroup(group: Group, message: String)
}
如您所见,前面的代码定义了允许学生和组之间交互的方法。这些方法的实现如下:
import scala.collection.mutable.Map
import scala.collection.mutable.Set
class School extends Mediator {
val studentsToGroups: Map[Student, Set[Group]] = Map()
val groupsToStudents: Map[Group, Set[Student]] = Map()
override def addStudentToGroup(student: Student, group: Group): Unit = {
studentsToGroups.getOrElseUpdate(student, Set()) += group
groupsToStudents.getOrElseUpdate(group, Set()) += student
}
override def isStudentInGroup(student: Student, group: Group): Boolean =
groupsToStudents.getOrElse(group, Set()).contains(student) &&
studentsToGroups.getOrElse(student, Set()).contains(group)
override def getStudentsInGroup(group: Group): List[Student] =
groupsToStudents.getOrElse(group, Set()).toList
override def getGroupsForStudent(student: Student): List[Group] = studentsToGroups.getOrElse(student, Set()).toList
override def notifyStudentsInGroup(group: Group, message: String): Unit = {
groupsToStudents.getOrElse(group, Set()).foreach(_.notify(message))
}
override def removeStudentFromGroup(student: Student, group: Group): Unit = {
studentsToGroups.getOrElse(student, Set()) -= group
groupsToStudents.getOrElse(group, Set()) -= student
}
}
School是我们应用程序将使用的事实上的中介者。正如你所看到的,它确实做了中介者设计模式应该做的事情——防止对象直接相互引用,并在内部定义它们的交互。以下代码展示了使用我们的School类的应用程序:
object SchoolExample {
def main(args: Array[String]): Unit = {
val school = new School
// create students
val student1 = Student("Ivan", 26)
val student2 = Student("Maria", 26)
val student3 = Student("John", 25)
// create groups
val group1 = Group("Scala design patterns")
val group2 = Group("Databases")
val group3 = Group("Cloud computing")
school.addStudentToGroup(student1, group1)
school.addStudentToGroup(student1, group2)
school.addStudentToGroup(student1, group3)
school.addStudentToGroup(student2, group1)
school.addStudentToGroup(student2, group3)
school.addStudentToGroup(student3, group1)
school.addStudentToGroup(student3, group2)
// notify
school.notifyStudentsInGroup(group1, "Design patterns in Scala
are amazing!")
// see groups
System.out.println(s"$student3 is in groups:
${school.getGroupsForStudent(student3)}")
// remove from group
school.removeStudentFromGroup(student3, group2)
System.out.println(s"$student3 is in groups:
${school.getGroupsForStudent(student3)}")
// see students in group
System.out.println(s"Students in $group1 are
${school.getStudentsInGroup(group1)}")
}
}
上述示例应用程序非常简单——它创建了Student和Group类型的对象,并使用中介者对象将它们连接起来,使它们能够交互。示例的输出如下:

如输出所示,我们的代码确实做了预期的事情,并且成功地将应用程序中的概念保持为松散耦合。
它的好处
中介者设计模式对于在应用程序中保持类之间的耦合松散很有用。它有助于实现简单性和可维护性,同时仍然允许我们模拟应用程序中对象之间的复杂交互。
它不是那么好的地方
在使用中介者设计模式时可能的一个陷阱是将许多不同的交互功能放在一个类中。随着时间的发展,中介者往往会变得更加复杂,这将使得改变或理解我们的应用程序能做什么变得很困难。此外,如果我们实际上有更多必须相互交互的类,这也会立即影响中介者。
记忆体设计模式
根据我们正在编写的软件,我们可能需要能够将对象的状态恢复到其先前的状态。
记忆体设计模式的目的是为了提供执行撤销操作的能力,以便将对象恢复到先前的状态。
原始的记忆体设计模式是通过三个主要对象实现的:
-
Originator: 我们希望能够恢复其状态的对象 -
Caretaker: 触发对originator对象进行更改的对象,并在需要时使用memento对象进行回滚 -
Memento: 带有原始对象实际状态的对象,可以用来恢复到先前的某个状态
重要的是要知道,memento对象只能由原始对象处理。看护者和所有其他类只能存储它,不能做其他事情。
示例类图
记忆体设计模式的一个经典例子是文本编辑器。我们可以随时撤销所做的任何更改。我们将在类图和示例中展示类似的内容。
以下是一个类图:

正如您在前面的图中可以看到,我们的保管者是TextEditorManipulator。它在每次操作时都会自动将状态保存在状态栈中。TextEditor实现了Originator,并创建了一个memento对象,并从其中恢复。最后,TextEditorMemento是我们文本编辑器将用来保存状态的具象memento对象。我们的状态只是编辑器中当前文本的字符串表示。
代码示例
在本小节中,我们将逐行分析文本编辑器代码,并看看备忘录设计模式如何在 Scala 中实现。
首先,让我们看看Caretaker、Memento和Originator特质:
trait Memento[T] {
protected val state: T
def getState(): T = state
}
trait Caretaker[T] {
val states: mutable.Stack[Memento[T]] = mutable.Stack[Memento[T]]()
}
trait Originator[T] {
def createMemento: Memento[T]
def restore(memento: Memento[T])
}
我们使用了泛型,这使得我们可以在需要实现备忘录设计模式时多次重用这些特质。现在,让我们看看我们应用中必要的特质的特定实现:
class TextEditor extends Originator[String] {
private var builder: StringBuilder = new StringBuilder
def append(text: String): Unit = {
builder.append(text)
}
def delete(): Unit = {
if (builder.nonEmpty) {
builder.deleteCharAt(builder.length - 1)
}
}
override def createMemento: Memento[String] = new TextEditorMemento(builder.toString)
override def restore(memento: Memento[String]): Unit = {
this.builder = new StringBuilder(memento.getState())
}
def text(): String = builder.toString
private class TextEditorMemento(val state: String) extends Memento[String]
}
前面的代码显示了实际的Originator实现以及Memento实现。通常,将备忘录类创建为私有于创建和从类中恢复的对象,这就是我们为什么这样做的原因。这样做的原因是,原始者应该是唯一知道如何创建和从memento对象中恢复,以及如何读取其状态的人。
最后,让我们来看看Caretaker的实现:
class TextEditorManipulator extends Caretaker[String] {
private val textEditor = new TextEditor
def save(): Unit = {
states.push(textEditor.createMemento)
}
def undo(): Unit = {
if (states.nonEmpty) {
textEditor.restore(states.pop())
}
}
def append(text: String): Unit = {
save()
textEditor.append(text)
}
def delete(): Unit = {
save()
textEditor.delete()
}
def readText(): String = textEditor.text()
}
在我们的实现中,保管者公开了用于操作originator对象的方法。在每次操作之前,我们将状态保存到栈中,以便在将来需要时能够回滚。
现在我们已经看到了我们示例的所有代码,让我们看看一个使用它的应用:
object TextEditorExample {
def main(args: Array[String]): Unit = {
val textEditorManipulator = new TextEditorManipulator
textEditorManipulator.append("This is a chapter about memento.")
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// delete 2 characters
System.out.println("Deleting 2 characters...")
textEditorManipulator.delete()
textEditorManipulator.delete()
// see the text
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// undo
System.out.println("Undoing...")
textEditorManipulator.undo()
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
// undo again
System.out.println("Undoing...")
textEditorManipulator.undo()
System.out.println(s"The text is:
'${textEditorManipulator.readText()}'")
}
}
在前面的代码中,我们只是手动向我们的文本编辑器添加了一些文本,删除了一些字符,然后撤销了删除操作。下面的截图显示了此示例的输出:

我们应用程序设计中可能需要改进的一个可能问题是states栈——我们没有绝对的限制,如果有很多更改,它可能会变得很大。在真实的文本编辑器中,我们不能无限回退,这个栈限制在一定的操作数内。另一个性能问题可能是我们在每次操作中都调用内部StringBuilder的toString方法。然而,传递实际的StringBuilder可能会对应用程序产生不良影响,因为更改将影响所有构建器的引用。
它的优点
备忘录设计模式对于想要支持可撤销状态的应用程序非常有用。在我们的例子中,我们使用了一个状态栈;然而,这并不是必需的——某些应用程序可能只需要保存最后一次操作。
它的缺点
开发者在使用备忘录设计模式时应小心。如果可能,他们应该尝试将状态保存在值对象中,因为如果传递了一个可变类型,它将通过引用被更改,这会导致不希望的结果。开发者还应小心允许更改可撤销的时间跨度,因为保存的操作越多,所需的内存就越多。最后,Scala 是不可变的,备忘录设计模式并不总是与语言哲学相一致。
观察者设计模式
有时,某些对象对另一个对象的状态变化感兴趣,并希望在发生这种情况时执行一些特定的操作。一个常见的例子是,每当你在应用程序中点击一个按钮时;其他对象订阅点击事件并执行一些操作。观察者设计模式帮助我们实现这一点。
观察者设计模式的目的是有这样一个对象(称为subject),它通过调用它们的方法之一自动通知所有观察者任何状态变化。
观察者设计模式在大多数 GUI 工具包中都有应用。它也是 MVC 架构模式的一部分,其中视图是一个观察者。Java 甚至自带了Observable类和Observer接口。
示例类图
对于类图,让我们关注以下示例——我们有一个网站,有帖子,人们可以订阅以在添加新评论时收到通知。以下图表显示了如何使用观察者模式表示类似的东西:

Post类是我们的可观察对象,它具有User类型的观察者,每当帖子发生变化时(在我们的例子中,当添加评论时)都会收到通知。
注意,前面的场景只是一个例子。在现实中,订阅可以在数据库中完成,人们会收到电子邮件通知。然而,如果我们谈论的是你在网站上的一些通知,那么这个例子是有效的。
在示例中,观察者模式可以被(并且可能应该被)Scala 中使用 Akka 和 actor 的响应式编程所取代。这样,我们可以实现更好的可伸缩性,并实现一个适当的异步发布-订阅系统。
在以下子节中,我们将查看代表前面图表的代码。
代码示例
现在,让我们通过所有代表前面图表的代码。首先,让我们看看Observer接口。我们决定将其作为一个可以混合到任何类中的特质:
trait Observer[T] {
def handleUpdate(subject: T)
}
这非常简单。接下来,我们将看看Observable类。它是一个可以混合使用的特质,可以使类变得可观察:
trait Observable[T] {
this: T =>
private val observers = ListBuffer[Observer[T]]()
def addObserver(observer: Observer[T]): Unit = {
observers.+=:(observer)
}
def notifyObservers(): Unit = {
observers.foreach(_.handleUpdate(this))
}
}
在前面的代码中,我们使用了自类型以确保我们限制Observable特质的混合方式。这确保了参数化类型将与我们要混合的对象的类型相同。
我们对Observer接口的实现将是我们的User类。它有以下代码:
case class User(name: String) extends Observer[Post] {
override def handleUpdate(subject: Post): Unit = {
System.out.println(s"Hey, I'm ${name}. The post got some new comments: ${subject.comments}")
}
}
它就像实现一个方法并与更改的Post主题进行交互一样简单。
Comment类只是一个简单的模型类,没有特别之处:
case class Comment(user: User, text: String)
Post类将是Observable。每当添加评论时,这个类将通知所有已注册的观察者。代码如下:
case class Post(user: User, text: String) extends Observable[Post] {
val comments = ListBuffer[Comment]()
def addComment(comment: Comment): Unit = {
comments.+=:(comment)
notifyObservers()
}
}
所有的前述代码片段实现了我们的观察者设计模式。在示例中看到它是如何工作的很有趣。以下代码块展示了我们的类如何一起使用:
object PostExample extends LazyLogging {
def main(args: Array[String]): Unit = {
val userIvan = User("Ivan")
val userMaria = User("Maria")
val userJohn = User("John")
logger.info("Create a post")
val post = Post(userIvan, "This is a post about the observer
design pattern")
logger.info("Add a comment")
post.addComment(Comment(userIvan, "I hope you like the post!"))
logger.info("John and Maria subscribe to the comments.")
post.addObserver(userJohn)
post.addObserver(userMaria)
logger.info("Add a comment")
post.addComment(Comment(userIvan, "Why are you so quiet? Do you
like it?"))
logger.info("Add a comment")
post.addComment(Comment(userMaria, "It is amazing! Thanks!"))
}
}
我们应用程序的输出如下所示:

正如您在前面的屏幕截图中看到的,观察者设计模式很容易实现。正如我们之前提到的,更好的方法是将响应式编程用于使事物异步和更具可扩展性。它也将更加函数式。我们将在本书的后续章节中看到一个如何使用 Akka 实现的例子。
它适用于什么
观察者设计模式易于实现,并允许我们在运行时添加新的观察者或删除旧的观察者。它有助于解耦逻辑和通信,从而产生一些只有一个责任的优秀类。
它不适用于什么
在使用 Scala 的函数式编程中,人们可能会更倾向于使用 Akka 并创建一个发布-订阅设计。此外,在观察者设计模式中,对象引用被保存在主题的观察者集合中,这可能导致在应用程序或主题对象的生命周期中发生内存泄漏或不必要的分配。最后,就像任何其他设计模式一样,观察者设计模式应该仅在必要时使用。否则,我们可能会无端地使我们的应用程序变得复杂。
状态设计模式
状态设计模式实际上与我们之前章节中看到的策略设计模式非常相似。
状态设计模式的目的允许我们根据对象的内部状态选择不同的行为。
基本上,状态设计模式和策略设计模式之间的区别来自以下两个点:
-
策略设计模式是关于如何执行一个动作的。它通常是一个算法,它产生的结果与其他算法相同。
-
状态设计模式是关于什么动作被执行的。根据状态的不同,一个对象可能执行不同的操作。
实现状态设计模式也与策略设计模式的实现非常相似。
示例类图
想象一个媒体播放器。大多数媒体播放器都有一个播放按钮——当我们激活它时,它通常会改变其外观并变成暂停按钮。现在点击暂停按钮也会执行不同的操作——它暂停播放并恢复为播放按钮。这是一个很好的状态设计模式候选,其中根据播放器所处的状态,会发生不同的操作。
以下类图显示了实现播放和暂停按钮所需的功能的类:

我们的播放和暂停实现将状态设置为相反的状态,并使我们的播放器功能正常。使用状态设计模式也使我们的代码更加优雅——我们当然可以使用 if 语句,并根据值执行不同的操作。然而,当有多个状态时,它很容易失控。
代码示例
让我们看看我们之前展示的类图的代码。首先,让我们看看State特质:
trait State[T] {
def press(context: T)
}
它非常简单,允许扩展类实现press方法。根据我们的类图,我们有两种实现:
class Playing extends State[MediaPlayer] {
override def press(context: MediaPlayer): Unit = {
System.out.println("Pressing pause.")
context.setState(new Paused)
}
}
class Paused extends State[MediaPlayer] {
override def press(context: MediaPlayer): Unit = {
System.out.println("Pressing play.")
context.setState(new Playing)
}
}
我们使它们变得简单,它们只打印一条相关消息,然后改变当前状态为相反的状态。
我们定义了一个MediaPlayer类,其外观如下:
case class MediaPlayer() {
private var state: State[MediaPlayer] = new Paused
def pressPlayOrPauseButton(): Unit = {
state.press(this)
}
def setState(state: State[MediaPlayer]): Unit = {
this.state = state
}
}
这真的是我们需要的所有东西。现在,我们可以在以下应用中使用我们的媒体播放器:
object MediaPlayerExample {
def main(args: Array[String]): Unit = {
val player = MediaPlayer()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
player.pressPlayOrPauseButton()
}
}
如果我们运行前面的代码,我们将看到以下输出:

如示例输出所示,每次按钮按下时都会改变状态,并执行不同的操作,我们使用不同的打印消息来展示这一点。
对我们应用程序的一个可能的改进是使状态对象成为单例。正如你所看到的,它们总是相同的,所以实际上没有必要每次都创建新的。
它适用于什么
状态设计模式对于使代码可读和消除条件语句非常有用。
它不适用于什么
状态设计模式没有重大缺点。开发者应该注意的一点是对象状态变化引起的副作用。
模板方法设计模式
有时候当我们实现一些算法或算法族时,我们定义一个共同的骨架。然后,不同的实现处理骨架中每个方法的特定细节。模板方法设计模式使我们能够实现我们之前提到的。
模板方法设计模式的目的是通过模板方法将算法步骤推迟到子类。
模板方法设计模式在面向对象编程中似乎非常自然。每当使用多态时,这实际上代表了设计模式本身。通常,模板方法是通过抽象方法实现的。
示例类图
模板方法设计模式适合实现框架。这里典型的事情是算法通常执行相同的步骤集合,然后不同的客户端以不同的方式实现这些步骤。你可以想出各种可能的使用案例。
对于我们的示例,让我们假设我们想要编写一个应用程序,该程序将从数据源读取一些数据,解析它,并查找是否存在满足某些条件的对象并返回它。如果我们仔细想想,我们有以下主要操作:
-
读取数据
-
解析数据
-
搜索满足条件的项目
-
如果需要,请清理任何资源
以下图表显示了我们的代码的类图:

我们使用了一个之前展示过的示例——从文件中读取关于人的数据。然而,这里我们使用它来查找满足过滤函数的人的数据。使用模板方法设计模式,我们可以从服务器、数据库或任何想到的地方读取不同格式的文件中的人的列表。通过多态,我们的应用程序确保调用正确的方法,并且一切运行正常。
代码示例
让我们查看代表前面图表的代码,并看看它做了什么。首先,我们的Person模型类:
case class Person(name: String, age: Int, address: String)
这没有什么特别的。现在,让我们继续到有趣的部分——DataFinder类:
abstract class DataFinder[T, Y] {
def find(f: T => Option[Y]): Option[Y] =
try {
val data = readData()
val parsed = parse(data)
f(parsed)
} finally {
cleanup()
}
def readData(): Array[Byte]
def parse(data: Array[Byte]): T
def cleanup()
}
我们使用了泛型,以便使这个类适用于各种类型。正如您在前面的代码中所看到的,DataFinder类的三个方法没有实现,但它们仍然在find方法中被引用。后者是实际的模板方法,而抽象方法将在扩展DataFinder的不同类中实现。
对于我们的示例,我们提供了两种不同的实现,一个是针对 JSON 的,另一个是针对 CSV 文件的。JSON 查找器如下所示:
import org.json4s.{StringInput, DefaultFormats}
import org.json4s.jackson.JsonMethods
class JsonDataFinder extends DataFinder[List[Person], Person] {
implicit val formats = DefaultFormats
override def readData(): Array[Byte] = {
val stream = this.getClass.getResourceAsStream("people.json")
Stream.continually(stream.read).takeWhile(_ != -1).map(_.toByte).toArray
}
override def cleanup(): Unit = {
System.out.println("Reading json: nothing to do.")
}
override def parse(data: Array[Byte]): List[Person] =
JsonMethods.parse(StringInput(new String(data, "UTF-8"))).extract[List[Person]]
}
CSV 查找器有以下代码:
import com.github.tototoshi.csv.CSVReader
class CSVDataFinder extends DataFinder[List[Person], Person] {
override def readData(): Array[Byte] = {
val stream = this.getClass.getResourceAsStream("people.csv")
Stream.continually(stream.read).takeWhile(_ != -1).map(_.toByte).toArray
}
override def cleanup(): Unit = {
System.out.println("Reading csv: nothing to do.")
}
override def parse(data: Array[Byte]): List[Person] =
CSVReader.open(new InputStreamReader(new ByteArrayInputStream(data))).all().map {
case List(name, age, address) => Person(name, age.toInt, address)
}
}
无论何时我们使用它,根据我们拥有的特定实例,find方法将通过多态调用正确的实现。通过扩展DataFinder类,可以添加新的格式和数据源。
使用我们的数据查找器现在很简单:
object DataFinderExample {
def main(args: Array[String]): Unit = {
val jsonDataFinder: DataFinder[List[Person], Person] = new JsonDataFinder
val csvDataFinder: DataFinder[List[Person], Person] = new CSVDataFinder
System.out.println(s"Find a person with name Ivan in the json:
${jsonDataFinder.find(_.find(_.name == "Ivan"))}")
System.out.println(s"Find a person with name James in the json:
${jsonDataFinder.find(_.find(_.name == "James"))}")
System.out.println(s"Find a person with name Maria in the csv:
${csvDataFinder.find(_.find(_.name == "Maria"))}")
System.out.println(s"Find a person with name Alice in the csv:
${csvDataFinder.find(_.find(_.name == "Alice"))}")
}
}
我们提供了一些示例数据文件。CSV 文件的内容如下:
Ivan,26,London
Maria,23,Edinburgh
John,36,New York
Anna,24,Moscow
以下数据是针对 JSON 文件的:
[
{
"name": "Ivan",
"age": 26,
"address": "London"
},
{
"name": "Maria",
"age": 23,
"address": "Edinburgh"
},
{
"name": "John",
"age": 36,
"address": "New York"
},
{
"name": "Anna",
"age": 24,
"address": "Moscow"
}
]
将前面的示例运行在这些数据集上会产生以下输出:

我们示例中的代码使用了一个抽象类。这在某种程度上使其有些限制,因为我们只能扩展一个类。然而,将抽象类更改为特性和将其混合到类中是非常直接的。
它的优点是什么
正如你所见,每当我们在算法结构相同且提供不同实现的情况下,我们都可以使用模板方法设计模式。这对于创建框架来说是一个非常合适的匹配。
它不适用的情况
当我们使用模板方法设计模式实现的框架变得很大时,简单地扩展一个巨大的类并实现其中的一些方法会变得更加困难。在这些情况下,将接口传递给构造函数并在骨架中使用它可能是一个更好的主意(策略设计模式)。
访问者设计模式
有些应用程序在设计时,并不是所有可能的使用案例都是已知的。可能会有新的应用程序功能时不时地出现,为了实现它们,可能需要进行一些重构。
访问者设计模式帮助我们在不修改现有对象结构的情况下添加新的操作。
这有助于我们分别设计我们的结构,然后使用访问者设计模式在顶部添加功能。
另一个可以使用访问者模式的情况是,如果我们正在构建一个包含许多不同类型节点的大对象结构,这些节点支持不同的操作。而不是创建一个具有所有操作的基础节点,只有少数具体节点实现了这些操作,或者使用类型转换,我们可以创建访问者,在需要的地方添加我们需要的功能。
示例类图
初始时,当开发者看到访问者设计模式时,似乎它可以很容易地通过多态来替换,并且可以依赖于类的动态类型。然而,如果我们有一个庞大的类型层次结构呢?在这种情况下,每一个变化都将不得不改变一个接口,这将导致一大堆类的改变,等等。
对于我们的类图和示例,让我们假设我们正在编写一个文本编辑器,并且我们有文档。我们希望能够以至少两种数据格式保存每个文档,但可能会有新的格式出现。以下图显示了使用访问者设计模式的我们的应用程序的类图:

正如前一个图所示,我们有两个看似不相关的层次结构。左侧代表我们的文档——每个文档只是一个不同元素的列表。所有这些元素都是Element抽象类的子类,它有一个accept方法,用于接受一个Visitor。右侧,我们有访问者层次结构——我们的每个访问者都将混入Visitor特质,其中包含为我们的文档元素每个都重写的visit方法。
访问者模式的工作方式是,根据需要执行的操作创建一个Visitor实例,然后将其传递给Document的accept方法。这样,我们可以非常容易地添加额外的功能(在我们的例子中是不同的格式),并且额外的功能不会涉及对模型的任何更改。
代码示例
让我们逐步查看实现前一个示例的访问者设计模式的代码。首先,我们有文档的模型以及所有可以构建它的元素:
abstract class Element(val text: String) {
def accept(visitor: Visitor)
}
class Title(text: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Text(text: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Hyperlink(text: String, val url: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Document(parts: List[Element]) {
def accept(visitor: Visitor): Unit = {
parts.foreach(p => p.accept(visitor))
}
}
上述代码没有什么特别之处,只是对不同文档元素进行简单子类化,以及Document类及其包含的元素进行组合。这里的重要方法是accept。它接受一个访问者,由于特质的类型已知,我们可以传递不同的访问者实现。在所有情况下,它都会调用访问者的visit方法,并将当前实例作为参数传递。
现在,让我们看看另一边——Visitor特质及其实现。Visitor特质看起来就像这样简单:
trait Visitor {
def visit(title: Title)
def visit(text: Text)
def visit(hyperlink: Hyperlink)
}
在这种情况下,它具有具有不同具体元素类型的visit方法的重载。在上述代码中,访问者和元素允许我们使用双重分派来确定哪些调用将被执行。
现在,让我们看看具体的Visitor实现。第一个是HtmlExporterVisitor:
class HtmlExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getHtml(): String = builder.toString
override def visit(title: Title): Unit = {
builder.append(s"<h1>${title.text}</h1>").append(line)
}
override def visit(text: Text): Unit = {
builder.append(s"<p>${text.text}</p>").append(line)
}
override def visit(hyperlink: Hyperlink): Unit = {
builder.append(s"""<a href=\"${hyperlink.url}\">${hyperlink.text}</a>""").append(line)
}
}
它简单地根据获取到的Element类型提供不同的实现。没有条件语句,只有重载。
如果我们想以纯文本格式保存我们拥有的文档,我们可以使用PlainTextExporterVisitor:
class PlainTextExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getText(): String = builder.toString
override def visit(title: Title): Unit = {
builder.append(title.text).append(line)
}
override def visit(text: Text): Unit = {
builder.append(text.text).append(line)
}
override def visit(hyperlink: Hyperlink): Unit = {
builder.append(s"${hyperlink.text} (${hyperlink.url})").append(line)
}
}
在有了访问者和文档结构之后,将一切连接起来相当直接:
object VisitorExample {
def main(args: Array[String]): Unit = {
val document = new Document(
List(
new Title("The Visitor Pattern Example"),
new Text("The visitor pattern helps us add extra functionality
without changing the classes."),
new Hyperlink("Go check it online!", "https://www.google.com/"),
new Text("Thanks!")
)
)
val htmlExporter = new HtmlExporterVisitor
val plainTextExporter = new PlainTextExporterVisitor
System.out.println(s"Export to html:")
document.accept(htmlExporter)
System.out.println(htmlExporter.getHtml())
System.out.println(s"Export to plain:")
document.accept(plainTextExporter)
System.out.println(plainTextExporter.getText())
}
}
上述示例展示了如何使用我们实现的两个访问者。我们的程序输出如下截图所示:

如您所见,使用访问者很简单。在我们的例子中,添加新的访问者和新的格式甚至更容易。我们只需要创建一个实现了所有访问者方法的类,并使用它。
Scala 风格的访问者设计模式
就像我们之前看到的许多其他设计模式一样,访问者设计模式可以用一种更简洁、更接近 Scala 的方式表示。在 Scala 中实现访问者的方式与策略设计模式相同——将函数传递给accept方法。此外,我们还可以使用模式匹配而不是在Visitor特质中拥有多个不同的visit方法。
在本小节中,我们将展示改进步骤。让我们从后者开始。
首先,我们需要将模型类转换为 case 类,以便能够在模式匹配中使用它们:
abstract class Element(text: String) {
def accept(visitor: Visitor)
}
case class Title(text: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
case class Text(text: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
case class Hyperlink(text: String, val url: String) extends Element(text) {
override def accept(visitor: Visitor): Unit = {
visitor.visit(this)
}
}
class Document(parts: List[Element]) {
def accept(visitor: Visitor): Unit = {
parts.foreach(p => p.accept(visitor))
}
}
然后,我们将我们的Visitor特质更改为以下内容:
trait Visitor {
def visit(element: Element)
}
由于我们将使用模式匹配,我们只需要一个方法来实现它。最后,我们可以将我们的访问者实现如下:
class HtmlExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getHtml(): String = builder.toString
override def visit(element: Element): Unit = {
element match {
case Title(text) =>
builder.append(s"<h1>${text}</h1>").append(line)
case Text(text) =>
builder.append(s"<p>${text}</p>").append(line)
case Hyperlink(text, url) =>
builder.append(s"""<a href=\"${url}\">${text}</a>""").append(line)
}
}
}
class PlainTextExporterVisitor extends Visitor {
val line = System.getProperty("line.separator")
val builder = new StringBuilder
def getText(): String = builder.toString
override def visit(element: Element): Unit = {
element match {
case Title(text) =>
builder.append(text).append(line)
case Text(text) =>
builder.append(text).append(line)
case Hyperlink(text, url) =>
builder.append(s"${text} (${url})").append(line)
}
}
}
模式匹配类似于 Java 中的 instanceOf 检查;然而,它是 Scala 的一个强大特性,并且相当常用。因此,我们的示例无需任何更改,输出将与之前相同。
接下来,我们将展示如何我们可以传递函数而不是访问者对象。我们将传递函数的事实意味着现在,我们可以将我们的模型更改为以下形式:
abstract class Element(text: String) {
def accept(visitor: Element => Unit): Unit = {
visitor(this)
}
}
case class Title(text: String) extends Element(text)
case class Text(text: String) extends Element(text)
case class Hyperlink(text: String, val url: String) extends Element(text)
class Document(parts: List[Element]) {
def accept(visitor: Element => Unit): Unit = {
parts.foreach(p => p.accept(visitor))
}
}
我们将 accept 方法实现移至基类 Element(也可以表示为特质)中,并在其中简单地调用了作为参数传递的函数。由于我们将传递函数,我们可以去掉 Visitor 特质及其实现。我们现在所拥有的就是以下示例:
object VisitorExample {
val line = System.getProperty("line.separator")
def htmlExporterVisitor(builder: StringBuilder): Element => Unit = {
case Title(text) =>
builder.append(s"<h1>${text}</h1>").append(line)
case Text(text) =>
builder.append(s"<p>${text}</p>").append(line)
case Hyperlink(text, url) => builder.append(s"""<a href=\"${url}\">${text}</a>""").append(line)
}
def plainTextExporterVisitor(builder: StringBuilder): Element => Unit = {
case Title(text) => builder.append(text).append(line)
case Text(text) => builder.append(text).append(line)
case Hyperlink(text, url) => builder.append(s"${text} (${url})").append(line)
}
def main(args: Array[String]): Unit = {
val document = new Document(
List(
Title("The Visitor Pattern Example"),
Text("The visitor pattern helps us add extra functionality
without changing the classes."),
Hyperlink("Go check it online!", "https://www.google.com/"),
Text("Thanks!")
)
)
val html = new StringBuilder
System.out.println(s"Export to html:")
document.accept(htmlExporterVisitor(html))
System.out.println(html.toString())
val plain = new StringBuilder
System.out.println(s"Export to plain:")
document.accept(plainTextExporterVisitor(plain))
System.out.println(plain.toString())
}
}
我们将访问者功能移至 VisitorExample 对象的组成部分中。在初始示例中,我们有一个 StringBuilder 作为访问者类的一部分。我们使用了柯里化函数以便能够在这里传递。将这些函数传递给 Document 结构是直截了当的。再次强调,这里的输出将与示例的先前版本完全相同。然而,我们可以看到我们节省了多少代码和样板类。
它擅长什么
访问者设计模式非常适合具有大型对象层次结构的应用程序,其中添加新功能将涉及大量重构。每当我们需要能够对对象层次结构执行多种不同操作,并且更改对象类可能有问题时,访问者设计模式是一个有用的替代方案。
它不擅长什么
正如你在我们示例的初始版本中所见,访问者设计模式可能会很庞大,包含相当多的样板代码。此外,如果某些组件未设计为支持该模式,如果我们不允许更改原始代码,我们实际上无法使用它。
摘要
在本章中,我们介绍了行为设计模式的第二组。你现在熟悉了迭代器、调解者、备忘录、观察者、状态、模板方法和访问者设计模式。你可能觉得这些纯粹是面向对象的设计模式,与函数式编程关系不大,你是对的。然而,由于 Scala 的混合性质,它们仍然与 Scala 相关,了解它们以及何时使用它们是很重要的。
本章中的一些设计模式相当常用,可以在许多项目中看到,而其他一些则相对较少见,且特定于某些用例。这些模式,结合你在前几章中学到的所有其他模式,可以一起使用,以构建优雅且强大的现实世界问题的解决方案。
在下一章中,我们将深入探讨函数式编程理论。我们将介绍一些高级概念,这些概念将展示 Scala 以及一般函数式编程语言是多么强大。
第十章:函数式设计模式 – 深入理论
Scala 编程语言是函数式和面向对象语言的混合体。大多数面向对象的设计模式仍然适用。然而,为了充分发挥 Scala 的威力,你还需要了解其纯函数式方面。当使用该语言和阅读教程或最佳实践时,开发者很可能会注意到,随着问题的复杂度增加或需要更优雅的解决方案时,单例、单子和函子等术语出现的频率更高。在本章中,我们将重点关注以下函数式设计模式:
-
单例
-
函子
-
单子
互联网上关于前面主题的资源很多。问题是许多内容都非常理论化,对于不熟悉数学,尤其是范畴论的人来说很难理解。实际上,许多开发者缺乏掌握这些主题所需的深厚数学背景,完全避免这些概念在代码中并不罕见。
根据我的经验,我所认识的绝大多数 Scala 开发者都尝试阅读本章涵盖主题的教程,并且他们发现这些主题很难理解,并最终放弃了。专家数学家似乎觉得这些概念更容易理解。然而,尽管反复尝试理解,大多数人承认他们对深入函数式编程理论并不完全适应。在本章中,我们将尝试以一种易于理解的方式呈现这一理论,并给出如何以及何时应用这一理论的想法。
抽象和词汇
编程的一大部分是抽象。我们找到常见的功能、法则和行为,并将它们封装到类、接口、函数中等,这些是抽象的,允许代码重用。然后,我们引用并重用它们以最小化代码重复和错误的可能性。其中一些抽象比其他更常见,并在不同的项目中观察到,被更多的人使用。这些抽象导致了一个共同词汇表的形成,这还额外有助于沟通和理解。每个人都知道某些数据结构,如树和哈希表,因此没有必要深入了解它们,因为它们的行为和需求是众所周知的。同样,当某人在设计模式方面有足够的经验时,他们可以很容易地看到它们,并将这些模式应用到他们试图解决的问题上。
在本章中,我们将尝试从一种将教会我们如何识别它们以及何时使用它们的角度来看待单例、单子(monads)和函子(functors)。
单例
所有幺半群、单子(monads)和函子(functors)都源自数学。关于这个主题的一个特点是,与编程类似,它试图寻找抽象。如果我们试图将数学映射到编程,我们可以考虑我们拥有的不同数据类型——Int、Double、Long或自定义数据类型。每个类型都可以通过它支持的运算和这些运算的法则来表征,这被称为类型的代数。
现在,如果我们仔细思考,我们可以识别出多个类型共有的运算,例如,加法、乘法、减法等等。不同的类型可以共享相同的运算,并且它们可以完全遵循相同的法则。我们可以利用这一点,因为这允许我们编写适用于遵循某些特定规则的不同类型的通用程序。
什么是幺半群?
在对幺半群进行前面的简要介绍之后,让我们直接进入正题,看看幺半群的正式定义:
幺半群是一个纯代数结构,这意味着它仅由其代数来定义。所有幺半群都必须遵循所谓的幺半群公理。
前面的定义绝对不足以对幺半群有一个好的理解,所以让我们在本节中将它分解成几个部分,并尝试给出一个更好的定义。
首先,让我们明确一下术语代数结构:
代数性:它仅由其代数来定义,例如,它支持的运算和它遵循的法则。
现在我们知道,幺半群仅由它们支持的运算来定义,那么让我们来看看幺半群公理:
-
幺半群包含一个
T类型。 -
幺半群包含一个结合二元运算。这意味着对于
T类型的任何x、y和z,以下都是正确的:op(op(x, y), z) == op(x, op(y, z))。 -
一个结构必须有一个单位元——
零。这个元素的特点是前一个运算总是返回另一个元素——op(x, zero) == x和op(zero, x) == x。
除了前面的法则之外,不同的幺半群可能根本没有任何关系——它们可以是任何类型。现在让我们看看一个更好的幺半群的定义,这个定义对你作为开发者来说实际上更有意义:
幺半群是一个具有结合二元运算的类型,它还有一个单位元。
幺半群规则非常简单,但它们给了我们极大的能力,仅基于幺半群总是遵循相同的规则这一事实来编写多态函数。使用幺半群,我们可以轻松地促进并行计算,并从小块构建复杂的计算。
生活中的幺半群
我们经常使用幺半群而没意识到——字符串连接、整数求和、乘积、布尔运算、列表等等,它们都是幺半群的例子。让我们看看整数加法:
-
我们的类型:
Int。 -
我们的结合运算:
add。它确实是结合的,因为((1 + 2) + 3) == (1 + (2 + 3))。 -
我们的单位元素:
0。当它被添加到另一个整数时,什么也不做。
我们可以轻松地找到类似的例子,例如字符串连接,其中单位元素将是一个空字符串,或者列表连接,其中单位元素将是一个空列表,以及其他许多例子。类似的例子可以在任何地方找到。
我们之前提到的所有内容都引出了以下 Scala 单例表示:
trait Monoid[T] {
def op(l: T, r: T): T
def zero: T
}
从这个基础特质开始,我们可以定义我们想要的任何单例。以下是一些整数加法单例、整数乘法单例和字符串连接单例的实现:
package object monoids {
val intAddition: Monoid[Int] = new Monoid[Int] {
val zero: Int = 0
override def op(l: Int, r: Int): Int = l + r
}
val intMultiplication: Monoid[Int] = new Monoid[Int] {
val zero: Int = 1
override def op(l: Int, r: Int): Int = l * r
}
val stringConcatenation: Monoid[String] = new Monoid[String] {
val zero: String = ""
override def op(l: String, r: String): String = l + r
}
}
使用之前展示的相同框架,我们可以为尽可能多的不同类型定义单例,只要它们始终满足规则。然而,你应该注意,并非每个操作都遵循单例规则。例如,整数除法—(6/3)/2 != 6/(3/2)。
我们看到了如何编写单例。但我们是怎样使用它们的?它们有什么用,我们能否仅基于我们知道的规则编写通用函数?当然可以,我们将在以下小节中看到这一点。
使用单例
在前面的章节中,我们已经提到单例可以用于并行计算,以及使用小块和简单的计算构建复杂计算。单例也可以与列表和集合自然地结合使用。
在本小节中,我们将通过示例查看单例的不同用例。
单例和可折叠集合
为了展示单例与支持foldLeft和foldRight函数的集合的有用性,让我们看看标准的 Scala 列表和这两个函数的声明:
def foldLeftB(f: (B, A) => B): B
def foldRightB(f: (A, B) => B): B
通常,这两个函数中的z参数被称为zero值,所以如果A和B是同一类型,我们最终会得到以下结果:
def foldLeftA(f: (A, A) => A): A
def foldRightA(f: (A, A) => A): A
现在看看这些函数,我们可以看到这些正是单例规则。这意味着我们可以编写一个示例,如下面的代码所示,该代码使用了我们之前创建的单例:
object MonoidFolding {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${strings.foldLeft(stringConcatenation.zero)(stringConcatenation.op)}")
System.out.println(s"Right folded:\n${strings.foldRight(stringConcatenation.zero)(stringConcatenation.op)}")
System.out.println(s"6! is: ${numbers.foldLeft(intMultiplication.zero)(intMultiplication.op)}")
}
}
在前面的代码中,还有一点需要注意,即对于最终结果来说,我们使用foldLeft还是foldRight并不重要,因为我们的单例具有结合操作。然而,在性能方面,这确实很重要。
前面示例的输出如下所示:

看看前面的例子,你可以看到我们可以编写一个通用函数,该函数将使用单例折叠列表,并根据单例操作执行不同的操作。以下是该函数的代码:
object MonoidOperations {
def foldT: T = list.foldLeft(m.zero)(m.op)
}
现在,我们可以重写我们的示例并使用我们的通用函数如下:
object MonoidFoldingGeneric {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${MonoidOperations.fold(strings,
stringConcatenation)}")
System.out.println(s"Right folded:\n${MonoidOperations.fold(strings,
stringConcatenation)}")
System.out.println(s"6! is: ${MonoidOperations.fold(numbers,
intMultiplication)}")
}
}
当然,输出将完全相同。然而,现在事情要整洁得多,这就是当与列表一起使用时,单例可以变得有用的原因。
在前面的例子中,我们在foldLeft和foldRight函数中将A和B类型设为相同。然而,我们可能使用不同的类型构建不同的数据结构,或者我们的算法可能依赖于具有不同单例的类型,而不是我们拥有的列表类型。为了支持这种场景,我们必须添加将原始列表类型映射到不同类型的可能性:
object MonoidOperations {
def foldT: T = foldMap(list, m)(identity)
def foldMapT, Y(f: T => Y): Y =
list.foldLeft(m.zero) {
case (t, y) => m.op(t, f(y))
}
}
上述代码展示了我们的折叠函数将如何改变。这将给我们提供在列表上使用不同类型的单例实现更复杂操作的可能性。
单例和并行计算
单例操作的结合性意味着如果我们必须链式多个操作,我们可能可以在并行中进行。例如,如果我们有数字1、2、3和4,并且想要找到4!,我们可以使用之前使用的方法,这将最终被评估为以下内容:
op(op(op(1, 2), 3), 4)
然而,结合性将允许我们做以下事情:
op(op(1, 2), op(3, 4))
在这里,嵌套操作可以独立且并行地进行。这也被称为平衡折叠。一个平衡折叠的实现可能如下所示:
def balancedFoldT, Y(f: T => Y): Y =
if (list.length == 0) {
m.zero
} else if (list.length == 1) {
f(list(0))
} else {
val (left, right) = list.splitAt(list.length / 2)
m.op(balancedFold(left, m)(f), balancedFold(right, m)(f))
}
值得注意的是,在这里我们使用了IndexedSeq,因为它将保证通过索引获取元素将是高效的。此外,这段代码不是并行的,但我们已经按照之前提到的顺序改变了操作顺序。对于整数来说,这可能不会有太大的区别,但对于其他类型,如字符串,这将提高性能。原因是字符串是不可变的,每次连接都会通过分配新的空间来创建一个新的字符串。因此,如果我们只是从左侧到右侧进行操作,我们将不断分配更多的空间,并且总是丢弃中间结果。
下面的代码示例展示了如何使用我们的balancedFold函数:
object MonoidBalancedFold {
def main(args: Array[String]): Unit = {
val numbers = Array(1, 2, 3, 4)
System.out.println(s"4! is: ${MonoidOperations.balancedFold(numbers, intMultiplication)(identity)}")
}
}
结果将如下所示:

使此代码并行化有几种方法。困难的方法将涉及编写大量的额外代码来管理线程,这非常高级。这可能值得一个章节(如果不是整本书),我们只是为更好奇的读者提及——纯函数式并行性。GitHub 上(github.com/fpinscala/fpinscala/wiki/Chapter-7:-Purely-functional-parallelism)有一些材料,通过示例很好地介绍了这个概念。
我们还可以使用大多数 Scala 集合都有的par方法。由于单例遵守的定律,我们可以保证无论底层集合如何并行化,我们总能得到正确的结果。下面的列表展示了我们折叠方法的示例实现:
def foldParT: T =
foldMapPar(list, m)(identity)
def foldMapParT, Y(f: T => Y): Y =
list.par.foldLeft(m.zero) {
case (t, y) => m.op(t, f(y))
}
与我们之前的方法相比,这两种方法的唯一区别是在使用foldLeft之前调用了par。使用这些方法与之前的方法完全相同:
object MonoidFoldingGenericPar {
def main(args: Array[String]): Unit = {
val strings = List("This is\n", "a list of\n", "strings!")
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"Left folded:\n${MonoidOperations.foldPar(strings,
stringConcatenation)}")
System.out.println(s"Right folded:\n${MonoidOperations.foldPar(strings,
stringConcatenation)}")
System.out.println(s"6! is: ${MonoidOperations.foldPar(numbers,
intMultiplication)}")
}
}
如你所预期,这里的输出将与顺序示例中的输出完全相同。
幺半群和组合
到目前为止,我们已经看到了一些例子,其中幺半群被用来提高效率并编写通用函数。然而,它们的功能更强大。原因在于它们遵循另一个有用的规则:
幺半群支持组合;如果A和B是幺半群,那么它们的乘积(A, B)也是一个幺半群。
这究竟意味着什么?我们如何利用这一点?让我们看看以下函数:
def composeT, Y: Monoid[(T, Y)] =
new Monoid[(T, Y)] {
val zero: (T, Y) = (a.zero, b.zero)
override def op(l: (T, Y), r: (T, Y)): (T, Y) =
(a.op(l._1, r._1), b.op(l._2, r._2))
}
在前面的代码中,我们展示了如何按照我们的定义应用组合函数。这将现在允许我们同时使用幺半群应用多个操作,我们可以组合更多,并应用更多操作。让我们看看以下示例,它将计算给定数字的和与阶乘:
object ComposedMonoid {
def main(args: Array[String]): Unit = {
val numbers = Array(1, 2, 3, 4, 5, 6)
val sumAndProduct = compose(intAddition, intMultiplication)
System.out.println(s"The sum and product is: ${MonoidOperations.balancedFold(numbers, sumAndProduct)(i => (i, i))}")
}
}
在前面的例子中,我们利用了map函数,因为我们的新幺半群期望一个整数元组,而不是我们数组中只有一个整数。运行这个例子将产生以下结果:

前面的compose函数功能非常强大,我们可以用它做很多事情。我们还可以高效地计算列表中所有项的平均值——我们只需要使用intAddition幺半群两次,并将数字映射到(number, 1),以便将计数和总和一起考虑。
到目前为止,我们已经看到了如何通过操作来组合幺半群。然而,幺半群在构建数据结构方面也非常有用。只要它们的值也形成幺半群,数据结构也可以形成幺半群。
让我们通过一个例子来讲解。在机器学习中,我们可能需要从某些文本中提取特征。然后,每个特征将使用一个系数和出现次数的数值进行加权。让我们尝试找到一个可以折叠集合并给出所需结果的幺半群——即每个特征的计数。
首先,很明显我们将计算每个特征出现的次数。构建一个要计数的特征的映射听起来是个好主意!每次我们看到一个特征时,我们都会增加其计数。所以,如果我们想象我们的特征列表中的每个元素都变成一个包含一个元素的映射,我们就必须折叠这些映射,并使用我们的整数求和幺半群来对相同键的值进行求和。
让我们构建一个函数,它可以返回一个幺半群,该幺半群可以用于将项目折叠到映射中,并将任何幺半群应用于映射中相同键的值:
def mapMergeK, V: Monoid[Map[K, V]] =
new Monoid[Map[K, V]] {
override def zero: Map[K, V] = Map()
override def op(l: Map[K, V], r: Map[K, V]): Map[K, V] =
(l.keySet ++ r.keySet).foldLeft(zero) {
case (res, key) => res.updated(key, a.op(l.getOrElse(key,
a.zero), r.getOrElse(key, a.zero)))
}
}
现在我们可以使用这个幺半群来进行不同的聚合操作——求和、乘法、连接等。对于我们的特征计数,我们将不得不使用求和,以下是我们的实现方法:
object FeatureCounting {
def main(args: Array[String]): Unit = {
val features = Array("hello", "features", "for", "ml", "hello",
"for", "features")
val counterMonoid: Monoid[Map[String, Int]] = mapMerge(intAddition)
System.out.println(s"The features are: ${MonoidOperations.balancedFold(features, counterMonoid)(i => Map(i -> 1))}")
}
}
前面程序的输出将如下所示:

我们之前定义的mapMerge函数现在可以接受任何单子,我们甚至可以轻松地创建映射的映射等,而无需额外的代码编写。
何时使用单子
在前面的例子中,我们展示了如何使用单子来实现某些功能。然而,如果我们看看前面的例子,我们可以以以下方式简化它:
object FeatureCountingOneOff {
def main(args: Array[String]): Unit = {
val features = Array("hello", "features", "for", "ml", "hello",
"for", "features")
System.out.println(s"The features are: ${
features.foldLeft(Map[String, Int]()) {
case (res, feature) => res.updated(feature,
res.getOrElse(feature, 0) + 1)
}
}")
}
}
实际上,每个例子都可以重写为类似于前面代码的表示形式。
虽然有人可能会倾向于这样做,但这可能并不总是可扩展的。正如我们之前提到的,单子的目的是实际上允许我们编写通用和可重用的代码。借助单子,我们可以专注于简单的操作,然后只需将它们组合在一起,而不是为所有我们想要的每一件事都构建具体的实现。对于一次性函数来说,这可能不值得,但使用单子肯定会在我们重用功能时产生积极的影响。此外,正如你之前看到的,这里的组合非常简单,随着时间的推移,它将帮助我们避免编写大量的代码(减少代码重复和引入错误的可能性)。
函子
函子是那些来自数学范畴论术语之一,对于数学背景较少的开发者在接触函数式编程时可能会造成很多困扰。它是单子的一个要求,在这里我们将尝试以一种易于理解的方式解释它。
什么是函子?在前一节中,我们研究了单子作为抽象某些计算的方法,然后以不同的方式使用它们进行优化或创建更复杂的计算。尽管有些人可能不同意这种方法的正确性,但让我们从相同的角度来看待函子——它将抽象某些特定的计算。
在 Scala 中,一个函子是一个具有map方法并符合几条公理的类。我们可以称它们为函子公理。
F[T]类型的函子的map方法接受一个从T到Y的函数作为参数,并返回一个F[Y]作为结果。这将在下一小节中变得更加清晰,我们将展示一些实际的代码。
函子也遵循一些函子公理:
-
恒等性:当
identity函数映射到某些数据上时,它不会改变它,换句话说,map(x)(i => i) == x。 -
组合性:多个映射必须组合在一起。如果我们这样做操作:
map(map(x)(i => y(i)))(i => z(i))或map(x)(i => z(y(i))),结果应该没有区别。 -
map函数保留数据的结构,例如,它不会添加或删除元素,改变它们的顺序等。它只是改变表示形式。
前面的法则为开发者提供了在进行不同计算时假设某些事情的基础。例如,我们现在可以安全地推迟对数据的不同映射,或者一次性完成它们,并确信最终的结果将是相同的。
从我们之前提到的内容中,我们可以得出结论,函子为其操作(在这种情况下是map)设定了一组特定的法则,这些法则必须就位,并允许我们自动推理其结果和效果。
现在我们已经为函子定义了一个概念,并展示了它们应遵循的法则,在下一小节中,我们可以创建一个所有函子都可以扩展的基本特质。
生活中的函子
在我们展示基于前一小节中展示的法则的示例函子特质之前,您可以得出结论,标准 Scala 类型如List、Option等定义了map方法的类型都是函子。
内置 Scala 类型如List中的map方法与我们这里展示的示例有不同的签名。在我们的示例中,第一个参数是函子,第二个参数是我们应用到的转换函数。在标准 Scala 类型中,第一个参数不需要传递,因为它是我们实际调用的对象(this)。
如果我们想要创建遵循函子法则的自定义类型,我们可以创建一个基本特质并确保实现它:
trait Functor[F[_]] {
def mapT, Y(f: T => Y): F[Y]
}
现在,让我们创建一个简单的列表函子,它将简单地调用 Scala List的map函数:
package object functors {
val listFunctor = new Functor[List] {
override def mapT, Y(f: (T) => Y): List[Y] = l.map(f)
}
}
在前面的代码中,一个对象是函子的这一事实仅仅允许我们假设某些法则已经就位。
使用我们的函子
使用我们之前定义的listFunctor的一个简单例子如下:
object FunctorsExample {
def main(args: Array[String]): Unit = {
val numbers = List(1, 2, 3, 4, 5, 6)
val mapping = Map(
1 -> "one",
2 -> "two",
3 -> "three",
4 -> "four",
5 -> "five",
6 -> "six"
)
System.out.println(s"The numbers doubled are:
${listFunctor.map(numbers)(_ * 2)}")
System.out.println(s"The numbers with strings are:
${listFunctor.map(numbers)(i => (i, mapping(i)))}")
}
}
前一个示例的输出显示在下述屏幕截图:

如您所见,函子本身并不真正做很多事情。它们一点也不令人兴奋。然而,它们设定了一些特定的规则,帮助我们理解特定操作的结果。这意味着我们可以在Functor特质内部的抽象map方法上定义方法,这些方法依赖于我们之前声明的规则。
函子是一个重要的概念,对于单子(monads)来说是必需的,我们将在下一小节中探讨。
单子
在前一小节中,我们定义了函子。通过它们的map方法,标准的 Scala 集合似乎是函子的好例子。然而,我们再次强调,函子并不意味着集合——它可以是容器和任何自定义类。基于一个抽象的map方法和它遵循的规则,我们可以定义其他函数,这些函数将帮助我们减少代码重复。然而,基于映射本身,我们并不能做很多令人兴奋的事情。在我们的程序中,我们将有不同的操作,其中一些不仅会转换集合或对象,还会以某种方式修改它们。
单子是来自范畴论的那些令人畏惧的术语之一,我们将尝试以一种你能够轻松理解、识别并在作为开发者的日常工作中使用的方式解释它。
什么是单子?
我们在本章前面已经讨论过法律了。单子是基于它遵循的一些法律来定义的,这些法律允许我们以确定性实现通用功能,仅仅因为我们期望某些条件成立。如果法律被违反,我们就无法确定地知道在某种行为方面可以期待什么。在这种情况下,事情很可能会以错误的结果结束。
与本章中我们已经看到的其他概念类似,单子是在它们遵循的法律的术语中定义的。为了使一个结构被认为是单子,它必须满足所有规则。让我们从一个简短的定义开始,我们稍后会对其进行扩展:
单子是具有 unit 和 flatMap 方法并遵循 单子规则 的函子。
那么,前面的定义意味着什么呢?首先,这意味着单子遵循我们之前定义的所有关于函子的规则。此外,它们更进一步,并添加了对两个更多方法的支持。
flatMap 方法
在我们正式定义规则之前,让我们先简要讨论一下 flatMap。我们假设你已经熟悉 Scala 集合,并且知道存在 flatten 方法。所以,flatMap 的名字本身告诉我们它会先映射然后扁平化,如下所示:
def flatMapT : Monad[T] = flatten(map(f))
我们还没有在前面代码中提到的单子定义,但那没关系。我们很快就会到达那里。现在,让我们把它看作另一个通用参数。你还应该知道 flatten 有以下声明:
def flattenT: M[T]
例如,如果 F 实际上是一个 List,flatten 将将列表的列表转换成一个简单的列表,其类型与内部列表的类型相同。如果 F 是一个 Option,那么嵌套选项中的 None 值将消失,其余的将保留。这两个例子表明,flatten 的结果实际上取决于被扁平化的类型的特定情况,但在任何情况下,它如何转换我们的数据都是清晰的。
单子的 unit 方法
我们之前提到的另一个方法是 unit。实际上,这个方法叫什么并不重要,它可能根据不同语言的标准而不同。重要的是它的功能。unit 的签名可以写成以下方式:
def unitT: Monad[T]
前面的行是什么意思?这很简单——它将T类型的值转换为T类型的单子。这不过是一个单参数构造函数或只是一个工厂方法。在 Scala 中,这可以通过具有apply方法的伴随对象来表示。只要它做正确的事情,实现并不真正重要。在 Scala 中,我们有许多集合类型作为例子——List、Array、Seq——它们都有支持以下内容的apply方法:
List(x)
Array(x)
Seq(x)
map、flatMap 和 unit 之间的联系
在前面的章节中,我们展示了如何使用map和flatten来定义flatMap。然而,我们可以采取不同的方法,并使用flatMap来定义map。以下是我们伪代码中的定义:
def mapT: Monad[T] = flatMap { x => unit(f(x)) }
前面的定义很重要,因为它描绘了所有map、flatMap和unit方法之间的关系。
根据我们实现哪种类型的单子,有时先实现map可能更容易(通常如果我们构建类似集合的单子),然后基于它和flatten实现flatMap,而有时先实现
flatMap。只要满足单子法则,我们采取的方法就不重要。
方法的名称
在前面的章节中,我们提到实际上调用unit方法的方式并不重要。虽然这对于unit来说是正确的,并且这可以传播到任何其他方法,但建议map和flatMap实际上保持这种方式。这并不意味着不可能让事情工作,但遵循通用约定会使事情变得简单得多。此外,map和flatMap给我们带来了额外的功能——使用我们的类在for 推导式中的可能性。考虑以下示例,它只是为了说明具有此类名称的方法如何帮助:
case class ListWrapper(list: List[Int]) {
// just wrap
def mapB: List[B] = list.map(f)
// just wrap
def flatMapB: List[B] =
list.flatMap(f)
}
在前面的例子中,我们只是在一个对象中包装了一个列表,并定义了map和flatMap方法。如果我们没有前面的对象,我们可以写点像这样的事情:
object ForComprehensionWithLists {
def main(args: Array[String]): Unit = {
val l1 = List(1, 2, 3, 4)
val l2 = List(5, 6, 7, 8)
val result = for {
x <- l1
y <- l2
} yield x * y
// same as
// val result = l1.flatMap(i => l2.map(_ * i))
System.out.println(s"The result is: ${result}")
}
}
使用我们的包装对象,我们可以这样做:
object ForComprehensionWithObjects {
def main(args: Array[String]): Unit = {
val wrapper1 = ListWrapper(List(1, 2, 3, 4))
val wrapper2 = ListWrapper(List(5, 6, 7, 8))
val result = for {
x <- wrapper1
y <- wrapper2
} yield x * y
System.out.println(s"The result is: ${result}")
}
}
两次应用都做同样的事情,并将产生完全相同的输出:

然而,第二次应用使用的是我们的包装类包含具有map和flatMap等名称的特定方法的事实。如果我们重命名任何一个,我们就会得到一个编译错误——我们仍然可以写出相同的代码,但将无法在 Scala 中使用语法糖。另一个要点是,for 推导式将正确地在两个方法实际上遵循map和flatMap规则的情况下工作。
单子法则
在对一元组应该支持的方法进行了一些了解之后,现在我们可以正式定义一元组的定律。你已经看到一元组是函子,并且遵循函子定律。明确总是更好的,所以在这里我们将混合这些定律:
-
恒等定律:对恒等函数进行
map操作不会改变数据——map(x)(i => i) == x。对unit函数进行扁平映射也会保持数据不变——x.flatMap(i => unit(i)) == x。后者基本上说flatMap是unit的逆操作。使用我们之前定义的map、flatMap和unit之间的联系,我们可以从其中一个规则推导出另一个规则,反之亦然。unit方法可以被认为是幺半群中的零元素。 -
单位定律:从
unit的定义中,我们也可以说:unit(x).flatMap { y => f(y) } == f(x)。从这个定义中,我们将得到unit(x).map { y => f(x) } == unit(f(x))。这给我们提供了一些有趣的方法之间的联系。 -
组合:多个映射必须组合在一起。如果我们做
x.map(i => y(i)).map(i => z(i))或x.map(i => z(y(i))),应该没有区别。此外,多个flatMap调用也必须组合,使得以下成立:x.flatMap(i => y(i)).flatMap(i => z(i)) == x.flatMap(i => y(i).flatMap(j => z(j)))。
类似于幺半群,一元组也有一个零元素。一些实际的零一元组例子有 Scala 列表中的Nil和None选项。然而,我们也可以有多个零元素,这些元素由一个代数数据类型表示,该类型有一个构造函数参数,我们可以向它传递不同的值。为了完整,如果我们所建模的一元组没有这样的概念,我们可能根本不会有零元素。无论如何,零一元组代表某种空缺,并遵循一些额外的定律:
-
零恒等律:这一点相当直接。它说无论我们应用什么函数到零一元组,它仍然将是零——
zero.flatMap(i => f(i)) == zero和zero.map(i => f(i)) == zero。Zero不应该与unit混淆,因为它们是不同的,后者不表示空缺。 -
逆零:这一点也很直接。基本上,如果我们用零替换一切,我们的最终结果也将是零——
x.flatMap(i => zero) == zero。 -
交换律:一元组可以有一个加法概念,无论是连接还是其他什么。无论如何,这种操作与零一元组一起进行时将是交换的,例如,
x plus zero == zero plus x == x。
一元组和副作用
当展示组合定律时,我们假设操作没有副作用。我们说了以下内容:
x.map(i => y(i)).map(i => z(i)) == x.map(i => z(y(i)))。
然而,现在让我们思考一下,如果 y 或 z 导致了一些副作用会发生什么。在左侧,我们首先运行所有的 y,然后运行所有的 z。然而,在右侧,我们却是交错进行,一直进行 y 和 z。现在,如果一个操作导致了副作用,这意味着两者最终可能会产生不同的结果。这就是为什么开发者应该更喜欢使用左侧版本,尤其是在可能存在诸如 IO 之类的副作用的情况下。
我们已经讨论了单子的定律。对于那些有更多 Scala 经验的人来说,单子可能看起来非常接近集合类,我们之前定义的规则可能看起来很合理。然而,我们再次指出,单子不一定是集合,遵循这些规则对于能够将代数数据结构称为单子来说非常重要。
生活中的单子
在学习了许多关于单子(monads)的理论之后,现在去了解一些代码示例,这些示例展示了如何实现和使用这些理论概念,以及它们在现实世界中的应用情况,将会非常有用。
现在我们来做类似的事情,展示在 Scala 中单子特质的样子。然而,在这样做之前,让我们稍微改变一下我们的函子定义:
trait Functor[T] {
def mapY: Functor[Y]
}
在前面的代码中,我们不是传递将要映射的元素,而是假设混合了 Functor 的类型将有一种方法可以将它传递给 map 实现。我们还改变了返回类型,以便我们可以使用 map 连接多个函子。完成这些后,我们可以展示我们的 Monad 特质:
trait Monad[T] extends Functor[T] {
def unitY: Monad[Y]
def flatMapY: Monad[Y]
override def mapY: Monad[Y] =
flatMap(i => unit(f(i)))
}
上述代码遵循了与我们用于单子的约定相似的惯例。单子拥有的方法与我们已经在本章的理论部分提到的方法完全相同。签名可能略有不同,但将它们映射到易于理解的代码上,不应该引起任何问题。
如你所见,单子扩展了函子。现在,每当我们想要编写单子时,我们只需要扩展前面的特质并实现方法。
使用单子
简单地拥有一个单子特质就使我们处于一个可以遵循的框架中。我们已经了解了单子的理论和它们遵循的定律。然而,为了理解单子是如何工作的以及它们有什么用,查看一个实际的例子是无价的。
然而,如果我们不知道单子的用途是什么,我们该如何使用它们呢?让我们称它们为计算构建器,因为这正是它们被用于的地方。这使普通开发者对何时何地以某种方式使用单子的计算构建器链操作有了更深入的理解,这些操作随后被执行。
选项单子
我们已经多次提到,标准的 Scala Option 是一个单子。在本小节中,我们将提供我们自己的单子实现,并展示单子的多种可能用途之一。
为了展示选项的有用性,我们将看到如果没有它会发生什么。让我们想象我们有以下类:
case class Doer() {
def getAlgorithm(isFail: Boolean) =
if (isFail) {
null
} else {
Algorithm()
}
}
case class Algorithm() {
def getImplementation(isFail: Boolean, left: Int, right: Int): Implementation =
if (isFail) {
null
} else {
Implementation(left, right)
}
}
case class Implementation(left: Int, right: Int) {
def compute: Int = left + right
}
为了测试,我们添加了一个Boolean标志,它将成功或失败地获取所需的对象。实际上,这可能是一个复杂的函数,它可能根据参数或其他因素在某些特定情况下返回null。以下代码片段展示了如何使用前面的类来完全避免失败:
object NoMonadExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The result is: ${compute(Doer(), 10, 16)}")
}
def compute(doer: Doer, left: Int, right: Int): Int =
if (doer != null) {
val algorithm = doer.getAlgorithm(false)
if (algorithm != null) {
val implementation = algorithm.getImplementation(false,
left, right)
if (implementation != null) {
implementation.compute
} else {
-1
}
} else {
-1
}
} else {
-1
}
}
NoMonadExample对象中的compute方法看起来真的很糟糕,难以阅读。我们不应该编写这样的代码。
观察前面的代码,我们可以看到我们实际上正在尝试构建一个操作链,这些操作可以单独失败。单子可以帮助我们并抽象这种保护逻辑。现在,让我们展示一个更好的解决方案。
首先,让我们定义我们自己的Option单子:
sealed trait Option[A] extends Monad[A]
case class SomeA extends Option[A] {
override def unitY: Monad[Y] = Some(value)
override def flatMapY => Monad[Y]): Monad[Y] = f(a)
}
case class None[A]() extends Option[A] {
override def unitY: Monad[Y] = None()
override def flatMapY => Monad[Y]): Monad[Y] = None()
}
在前面的代码中,我们有两个具体的例子——一个是可以获取值的情况,另一个是结果将为空的情况。现在,让我们重新编写我们的计算类,以便它们使用我们刚刚创建的新单子:
case class Doer_v2() {
def getAlgorithm(isFail: Boolean): Option[Algorithm_v2] =
if (isFail) {
None()
} else {
Some(Algorithm_v2())
}
}
case class Algorithm_v2() {
def getImplementation(isFail: Boolean, left: Int, right: Int): Option[Implementation] =
if (isFail) {
None()
} else {
Some(Implementation(left, right))
}
}
最后,我们可以用以下方式使用它们:
object MonadExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The result is: ${compute(Some(Doer_v2()), 10, 16)}")
}
def compute(doer: Option[Doer_v2], left: Int, right: Int) =
for {
d <- doer
a <- d.getAlgorithm(false)
i <- a.getImplementation(false, left, right)
} yield i.compute
// OR THIS WAY:
// doer.flatMap {
// d =>
// d.getAlgorithm(false).flatMap {
// a =>
// a.getImplementation(false, left, right).map {
// i => i.compute
// }
// }
// }
}
在前面的代码中,我们展示了我们的单子的for推导用法,但注释掉的部分也是有效的。第一个更受欢迎,因为它使事情看起来非常简单,一些完全不同的计算最终看起来相同,这对理解和修改代码是有益的。
当然,我们示例中展示的所有内容都可以使用标准的 Scala Option来实现。几乎可以肯定,您之前已经见过并使用过这个类,这意味着您实际上已经使用过单子,可能没有意识到这一点。
更高级的单子示例
之前的例子相当简单,展示了单子的强大用途。我们使代码更加直接,并在单子内部抽象了一些逻辑。此外,我们的代码比之前更容易阅读。
在本小节中,我们将探讨 monads 的另一种更高级的使用方法。每当我们在软件中添加 I/O 操作时,所有我们编写的软件都会变得更加具有挑战性和趣味性。这包括读取和写入文件、与用户通信、发起网络请求等等。Monads 可以被用来以纯函数式的方式编写 I/O 应用程序。这里有一个非常重要的特性:I/O 必须处理副作用,操作通常按顺序执行,结果取决于状态。这个状态可以是任何东西——如果我们询问用户他们喜欢什么车,他们的回答会因用户而异;如果我们询问他们早餐吃了什么,或者天气如何,对这些问题的回答也会因用户而异。即使我们尝试两次读取同一个文件,也可能会有差异——我们可能会失败,文件可能会被更改等等。我们迄今为止所描述的一切都是状态。Monads 帮助我们隐藏这个状态,只向用户展示重要的部分,以及抽象我们处理错误的方式等等。
关于我们将要使用的状态有几个重要的方面:
-
状态在不同 I/O 操作之间发生变化
-
状态只有一个,我们不能随意创建一个新的
-
在任何时刻,只能有一个状态
所有的前述陈述都非常合理,但它们实际上将指导我们实现状态和 monads 的方式。
我们将编写一个示例,它将读取文件中的行,然后遍历它们,并将它们写入一个新文件,所有字母都大写。这可以用 Scala 非常简单直接地完成,但一旦某些操作变得更为复杂,或者我们试图正确处理错误,这可能会变得相当困难。
在整个示例中,我们将尝试展示我们为确保关于我们状态的前述陈述正确所采取的步骤。
我们将要展示的以下示例实际上并不需要使用状态。它只是以 monadic 方式执行文件读取和写入。读者现在应该有足够的知识,如果需要,可以从代码中移除状态。
我们决定展示一个非常简单的状态使用示例,我们只是增加一个数字。这可以让读者了解状态如何在可能需要它的应用程序中使用和连接。此外,状态的使用实际上可以修改我们程序的行为,并触发不同的动作,例如,自动售货机和用户尝试请求缺货的商品。
让我们从状态开始。对于当前示例,我们实际上并不需要一个特殊的状态,但我们仍然使用了它。只是为了展示在确实需要时如何处理这种情况:
sealed trait State {
def next: State
}
上述特质有一个 next 方法,当我们在不同操作之间移动时,它将返回下一个状态。只需在传递状态时调用它,我们就可以确保不同的操作会导致状态的变化。
我们需要确保我们的应用程序只有一个状态,并且没有人可以在任何时候创建状态。事实是,特质的密封性帮助我们确保没有人可以在我们定义它的文件之外扩展我们的状态。尽管密封是必要的,但我们还需要确保所有状态实现都是隐藏的:
abstract class FileIO {
// this makes sure nobody can create a state
private class FileIOState(id: Int) extends State {
override def next: State = new FileIOState(id + 1)
}
def run(args: Array[String]): Unit = {
val action = runIO(args(0), args(1))
action(new FileIOState(0))
}
def runIO(readPath: String, writePath: String): IOAction[_]
}
上述代码将状态定义为私有类,这意味着没有人能够创建它。现在我们先忽略其他方法,因为我们稍后会回到它们。
我们之前为状态定义的第三条规则要复杂得多。我们采取了多个步骤以确保状态的行为正确。首先,正如前一个列表所示,用户无法获取任何关于状态的信息,除了一个无人能实例化的私有类。我们不是让用户承担执行任务和传递状态的负担,而是只向他们暴露一个 IOAction,其定义如下:
sealed abstract class IOAction[T] extends ((State) => (State, T)) {
// START: we don't have to extend. We could also do this...
def unitY: IOAction[Y] = IOAction(value)
def flatMapY => IOAction[Y]): IOAction[Y] = {
val self = this
new IOAction[Y] {
override def apply(state: State): (State, Y) = {
val (state2, res) = self(state)
val action2 = f(res)
action2(state2)
}
}
}
def mapY: IOAction[Y] =
flatMap(i => unit(f(i)))
// END: we don't have to extend. We could also do this...
}
首先,让我们只关注 IOAction 的签名。它将一个函数从一个旧状态扩展到新状态和操作结果的元组。因此,结果是我们在某种方式上仍然以类形式向用户暴露了状态。然而,我们已经看到,通过创建一个无人能实例化的私有类,隐藏状态是非常直接的。我们的用户将使用 IOAction 类,因此我们需要确保他们不必自己处理状态。我们已经定义了 IOAction 为密封的。此外,我们可以创建一个工厂对象,这将帮助我们创建新的实例:
object IOAction {
def applyT: IOAction[T] =
new SimpleActionT
private class SimpleActionT extends IOAction[T] {
override def apply(state: State): (State, T) =
(state.next, result)
}
}
上述代码在后续的连接中非常重要。首先,我们有一个 IOAction 的私有实现。它只接受一个按名称传递的参数,这意味着它只会在调用 apply 方法时被评估——这非常重要。此外,在上述代码中,我们有一个 apply 方法用于 IOAction 对象,它允许用户实例化操作。在这里,值也是按名称传递的。
上述代码基本上使我们能够定义操作,并且只有在有状态可用时才执行它们。
如果我们现在思考一下,你可以看到我们已经满足了我们对状态的所有三个要求。的确,通过将状态隐藏在我们控制的类后面,我们成功地保护了状态,以确保我们不会同时拥有多个状态。
现在我们已经一切就绪,我们可以确保我们的 IOAction 是一个单子。它需要满足单子法则并定义所需的方法。我们已经展示了它们,但让我们再次仔细看看这些方法:
// START: we don't have to extend. We could also do this...
def unitY: IOAction[Y] = IOAction(value)
def flatMapY => IOAction[Y]): IOAction[Y] = {
val self = this
new IOAction[Y] {
override def apply(state: State): (State, Y) = {
val (state2, res) = self(state)
val action2 = f(res)
action2(state2)
}
}
}
def mapY: IOAction[Y] =
flatMap(i => unit(f(i)))
// END: we don't have to extend. We could also do this...
我们没有具体扩展我们的Monad特质,而是在这里定义了方法。我们已经知道map可以使用flatMap和unit来定义。对于后者,我们使用了SimpleAction的工厂方法。我们前者的实现相当有趣——它首先执行当前操作,然后根据结果状态,顺序执行第二个操作。这允许我们将多个 I/O 操作链接在一起。
让我们再次看看我们的IOAction类。它是否满足单子规则?答案是:不,但有一个非常简单的修复方法。问题在于,如果我们深入研究,我们的unit方法会改变状态,因为它使用了SimpleAction。但是它不应该这样做。我们必须做的是创建另一个不改变状态的IOAction实现,并用于unit:
private class EmptyActionT extends IOAction[T] {
override def apply(state: State): (State, T) =
(state, value)
}
然后,我们的IOAction对象将获得一个额外的函数:
def unitT: IOAction[T] = new EmptyActionT
我们还必须更改IOAction抽象类中的unit方法:
def unitY: IOAction[Y] = IOAction.unit(value)
到目前为止,我们已经定义了我们的单子,确保状态得到适当的处理,并且用户可以以受控的方式创建操作。我们现在需要做的就是添加一些有用的方法并尝试它们:
package object io {
def readFile(path: String) =
IOAction(Source.fromFile(path).getLines())
def writeFile(path: String, lines: Iterator[String]) =
IOAction({
val file = new File(path)
printToFile(file) { p => lines.foreach(p.println) }
})
private def printToFile(file: File)(writeOp: PrintWriter => Unit): Unit = {
val writer = new PrintWriter(file)
try {
writeOp(writer)
} finally {
writer.close()
}
}
}
上述是读取和写入文件并返回IOAction实例(在当前情况下,使用IOAction的apply方法创建SimpleAction)的包对象的代码。现在我们有了这些方法和我们的单子,我们可以使用我们定义的框架,并将一切连接起来:
abstract class FileIO {
// this makes sure nobody can create a state
private class FileIOState(id: Int) extends State {
override def next: State = new FileIOState(id + 1)
}
def run(args: Array[String]): Unit = {
val action = runIO(args(0), args(1))
action(new FileIOState(0))
}
def runIO(readPath: String, writePath: String): IOAction[_]
}
上述代码定义了一个框架,我们的库的用户将遵循;他们必须扩展FileIO,实现runIO,并在他们准备好使用我们的应用程序时调用run方法。到目前为止,你应该足够熟悉单子,看到高亮代码唯一要做的事情是构建计算。它可以被认为是一个必须执行的操作的图。它不会执行任何操作,直到下一行,在那里它实际上接收传递给它的状态:
object FileIOExample extends FileIO {
def main(args: Array[String]): Unit = {
run(args)
}
override def runIO(readPath: String, writePath: String): IOAction[_] =
for {
lines <- readFile(readPath)
_ <- writeFile(writePath, lines.map(_.toUpperCase))
} yield ()
}
上述代码展示了我们创建的FileIO库的一个示例用法。现在我们可以用以下输入文件运行它:
this is a file, which
will be completely capitalized
in a monadic way.
Enjoy!
我们需要使用的命令如下所示:

如预期的那样,输出文件将包含所有大写字母的相同文本。当然,你可以尝试不同的输入,看看代码的表现如何。
单子直觉
在本节中,我们讨论了一些关于单子的理论和实际例子。希望我们已经成功地给出了易于理解的解释,说明了什么是什么是,以及它是如何和为什么工作的。单子并不像它们最初看起来那么可怕,花一些时间与它们相处将更好地理解为什么事情以某种方式工作。
最后的例子可能看起来相当复杂,但花一些额外的时间在 IDE 中使用它,会使它变得清晰易懂,让你能够清楚地意识到一切是如何连接起来的。然后,你将能够轻松地发现并在自己的代码中使用单子。
当然,开发者可能可以不使用单子(monads)而逃脱,但使用它们可以帮助隐藏关于异常处理、特定操作等方面的细节。单子之所以好,实际上是因为它们内部发生的额外工作,并且它们可以用来实现我们在本书前面看到的一些设计模式。我们可以实现更好的状态、回滚以及许多其他功能。还值得一提的是,我们可能很多次在使用单子时甚至都没有意识到。
摘要
本章专门介绍了一些似乎让很多人对纯函数式编程望而却步的函数式编程理论。因为大多数解释都需要强大的数学背景,所以我们看到人们避免本章中涵盖的概念。
我们讨论了单子、单子和函子,并展示了如何使用它们的示例以及有它们和无它们之间的区别。结果证明,我们比我们想象的更经常使用这些概念,但我们只是没有意识到。
我们看到单子、函子和单子可以用作各种目的——性能优化、抽象和代码重复的移除。正确理解这些概念并感到舒适可能最初需要一些时间,但经过一些实践,开发者往往会获得更好的理解,并且比以前更频繁地使用它们。希望这一章使单子、单子和函子看起来比你可能想象的要简单得多,你将更频繁地将它们作为生产代码的一部分。
在下一章中,我们将介绍一些特定于 Scala 的函数式编程设计模式,这得益于其表达性。其中一些将是新的且之前未见过的,而其他一些我们已经遇到过,但我们将从不同的角度来审视它们。
第十一章:应用所学知识
在 Scala 和关于该语言的各种设计模式的学习中,我们已经走了很长的路。现在,你应该已经到了一个可以自信地使用特定设计模式并避免它们的时候了。你看到了 Scala 的一些具体和优秀的特性,这些特性导致了它的表现力。我们探讨了四人帮设计模式以及一些重要的函数式编程概念,如单子。在整个书中,我们尽量将数学理论保持在最基本水平,并尽量避免在公式中使用一些难以理解的希腊字母,这些公式对于非数学家来说很难理解,他们可能也希望充分发挥函数式编程语言的最大潜力。
本章和下一章的目的是从更实际的角度来看 Scala。了解一种语言和一些设计模式并不总是足以让开发者看到整个画面和语言可能性的潜力。在本章中,我们将展示我们之前提出的一些概念如何结合在一起,以编写更强大、更干净的程序。我们将探讨以下主题:
-
镜头设计模式
-
蛋糕设计模式
-
优化我的图书馆设计模式
-
可堆叠特质设计模式
-
类型类设计模式
-
惰性评估
-
部分函数
-
隐式注入
-
鸭式类型
-
缓存
本章的一些部分将展示我们之前没有见过的概念。其他部分将结合 Scala 的一些特性和我们迄今为止学到的设计模式,以实现其他目的。然而,在所有情况下,这些概念都将涉及特定的语言特性或我们已经看到的限制,或者有助于在实际的软件工程项目中实现常见的事情。
镜头设计模式
我们已经提到,在 Scala 中,对象是不可变的。当然,你可以确保一个特定的类的字段被声明为vars,但这是不被推荐的,并被认为是坏做法。毕竟,不可变性是好的,我们应该努力追求它。
镜头设计模式是为了这个目的而创建的,它使我们能够克服不可变性的限制,同时保持代码的可读性。在接下来的小节中,我们将从一些没有使用镜头设计模式的代码开始,一步一步地展示如何使用它以及它是如何改进我们的应用的。
镜头示例
为了在实践中展示镜头设计模式,我们将创建一个通常在企业应用程序中看到的类层次结构。让我们想象我们正在为一家图书馆构建一个系统,该系统可以被不同公司的员工使用。我们可能会得到以下类:
case class Country(name: String, code: String)
case class City(name: String, country: Country)
case class Address(number: Int, street: String, city: City)
case class Company(name: String, address: Address)
case class User(name: String, company: Company, address: Address)
这些类的表示作为类图将看起来如下所示:

该图非常清晰,不需要太多解释。我们基本上有一个User类,其中包含有关用户的其他信息。其他类包含其他类,依此类推。如果我们不想修改任何内容,使用我们的类绝对没有任何挑战。然而,一旦我们开始修改某些内容,事情就会变得复杂。
没有透镜设计模式
在本节中,我们将看到如果我们要修改它们的某些属性,如何使用我们的类。
不可变和冗长
不深入细节,让我们看看一个示例应用程序将是什么样子:
object UserVerboseExample {
def main(args: Array[String]): Unit = {
val uk = Country("United Kingdom", "uk")
val london = City("London", uk)
val buckinghamPalace = Address(1, "Buckingham Palace Road", london)
val castleBuilders = Company("Castle Builders", buckinghamPalace)
val switzerland = Country("Switzerland", "CH")
val geneva = City("geneva", switzerland)
val genevaAddress = Address(1, "Geneva Lake", geneva)
val ivan = User("Ivan", castleBuilders, genevaAddress)
System.out.println(ivan)
System.out.println("Capitalize UK code...")
val ivanFixed = ivan.copy(
company = ivan.company.copy(
address = ivan.company.address.copy(
city = ivan.company.address.city.copy(
country = ivan.company.address.city.country.copy(
code = ivan.company.address.city.country.code.toUpperCase
)
)
)
)
)
System.out.println(ivanFixed)
}
}
之前的应用程序为我们的库创建了一个用户,然后决定更改公司国家代码,就像我们最初用小写字母创建的那样。应用程序的输出如下:

我们的应用程序运行正确,但正如你在高亮代码中所看到的,它非常冗长且容易出错。我们不希望编写这样的代码,因为这将是难以维护和未来更改的。
使用可变属性
可能首先出现在你脑海中的想法是更改类并使属性可变。以下是我们的案例类将如何改变:
case class Country(var name: String, var code: String)
case class City(var name: String, var country: Country)
case class Address(var number: Int, var street: String, var city: City)
case class Company(var name: String, var address: Address)
case class User(var name: String, var company: Company, var address: Address)
在此之后,使用这些类将像这样简单:
object UserBadExample {
def main(args: Array[String]): Unit = {
val uk = Country("United Kingdom", "uk")
val london = City("London", uk)
val buckinghamPalace = Address(1, "Buckingham Palace Road", london)
val castleBuilders = Company("Castle Builders", buckinghamPalace)
val switzerland = Country("Switzerland", "CH")
val geneva = City("geneva", switzerland)
val genevaAddress = Address(1, "Geneva Lake", geneva)
val ivan = User("Ivan", castleBuilders, genevaAddress)
System.out.println(ivan)
System.out.println("Capitalize UK code...")
ivan.company.address.city.country.code = ivan.company.address.city.country.code.toUpperCase
System.out.println(ivan)
}
}
在前面的代码示例中,我们也可以使用这种方式更改国家代码——uk.code = uk.code.toUpperCase。这将有效,因为我们使用User对象中的国家引用。
之前的示例将产生完全相同的输出。然而,在这里我们打破了 Scala 中一切都是不可变的规则。在当前示例中,这可能看起来不是什么大问题,但事实上,这与 Scala 的原则相悖。这被认为是糟糕的代码,我们应该尽量避免。
使用透镜设计模式
在前面的子节中,我们看到了改变嵌套类的一个属性会变得多么复杂。我们追求的是漂亮、干净和正确的代码,而且我们也不想违反 Scala 的原则。
幸运的是,我们之前提到的那些情况正是透镜设计模式被创造出来的原因。在本章中,我们将第一次在本书中介绍 Scalaz 库。它为我们定义了许多函数式编程抽象,我们可以轻松地直接使用它们,而不用担心它们是否遵循某些特定的规则。
那么,透镜究竟是什么呢?在这里,我们不会深入探讨理论方面,因为这超出了本书的范围。我们只需要知道它们是用来做什么的,如果你想要了解更多,网上有大量关于透镜、存储和单子的材料,这些材料可以使这些概念更加清晰。表示透镜的一个简单方法是以下内容:
case class LensX, Y => X)
这基本上让我们能够获取和设置 X 类型对象的不同的属性。这意味着在我们的情况下,我们将不得不为想要设置的每个属性定义不同的镜头:
import scalaz.Lens
object User {
val userCompany = Lens.lensuUser, Company => u.copy(company = company), _.company
)
val userAddress = Lens.lensuUser, Address => u.copy(address = address), _.address
)
val companyAddress = Lens.lensuCompany, Address => c.copy(address = address), _.address
)
val addressCity = Lens.lensuAddress, City => a.copy(city = city), _.city
)
val cityCountry = Lens.lensuCity, Country => c.copy(country = country), _.country
)
val countryCode = Lens.lensuCountry, String => c.copy(code = code), _.code
)
val userCompanyCountryCode = userCompany >=> companyAddress >=> addressCity >=> cityCountry >=> countryCode
}
前面的代码是我们 User 类的伴随对象。这里有很多事情在进行中,所以我们将解释这一点。你可以看到对 Lens.lensu[A, B] 的调用。它们创建实际的镜头,以便对于 A 类型的对象,调用获取和设置 B 类型的值。实际上它们并没有什么特别之处,看起来就像模板代码。这里有趣的部分是高亮显示的代码——它使用了 >=> 操作符,这是 andThen 的别名。这允许我们组合镜头,这正是我们将要做的。我们将定义一个组合,允许我们从 User 对象通过链路设置 Company 的国家代码。我们也可以使用 compose,它的别名为 <=<,因为 andThen 内部调用 compose,它看起来如下:
val userCompanyCountryCodeCompose = countryCode <=< cityCountry <=< addressCity <=< companyAddress <=< userCompany
然而,后者并不像前者那样直观。
现在使用我们的镜头非常简单。我们需要确保导入我们的伴随对象,然后我们可以简单地使用以下代码来将国家代码转换为大写:
val ivanFixed = userCompanyCountryCode.mod(_.toUpperCase, ivan)
你看到了如何通过镜头设计模式,我们可以干净地设置我们的案例类的属性,而不违反不可变性规则。我们只需要定义正确的镜头,然后使用它们。
最小化模板代码
前面的例子显示了大量的模板代码。它并不复杂,但需要我们编写相当多的额外内容,并且任何重构都可能影响这些手动定义的镜头。已经有人努力创建库来自动为所有用户定义的类生成镜头,这样就可以轻松使用。一个似乎维护得很好的库示例是 Monocle:github.com/julien-truffaut/Monocle。它有很好的文档,可以用来确保我们不需要编写任何模板代码。尽管如此,它也有其局限性,用户应该确保他们接受库提供的内容。它还提供了其他可能有用的光学概念。
蛋糕设计模式
实际的软件项目通常会结合多个组件,这些组件必须一起使用。大多数时候,这些组件将依赖于其他组件,而这些组件又依赖于其他组件,依此类推。这使得在应用程序中创建对象变得困难,因为我们还需要创建它们依赖的对象,依此类推。这就是依赖注入派上用场的地方。
依赖注入
那么,依赖注入到底是什么呢?实际上它非常简单——任何在其构造函数中有一个对象作为参数的类实际上都是依赖注入的一个例子。原因是依赖被注入到类中,而不是在类内部实例化。开发者实际上应该尝试使用这种类型的做法,而不是在构造函数中创建对象。这样做有很多原因,但其中最重要的一个原因是组件可能会变得紧密耦合,实际上难以测试。
然而,如果使用构造函数参数来实现依赖注入,可能会降低代码质量。这将使构造函数包含大量的参数,因此使用构造函数将变得非常困难。当然,使用工厂设计模式可能会有所帮助,但还有其他在企业应用程序中更为常见的方法。在接下来的小节中,我们将简要介绍这些替代方案,并展示如何仅使用 Scala 的特性轻松实现依赖注入。
依赖注入库和 Scala
许多拥有 Java 背景的开发者可能已经熟悉一些著名的依赖注入库。一些流行的例子包括 Spring (spring.io/) 和 Guice (github.com/google/guice)。在 Spring 中,依赖通常在 XML 文件中管理,其中描述了依赖项,并且文件告诉框架如何创建实例以及将对象注入到类中。其中使用的术语之一是 bean。
另一方面,Guice 使用注解,然后这些注解会被评估并替换为正确的对象。这些框架相当流行,并且它们也可以很容易地在 Scala 中使用。那些熟悉 Play Framework 的人会知道,它正是使用 Guice 来连接事物的。
然而,使用外部库会增加项目的依赖项,增加 jar 文件的大小等等。如今,这并不是真正的问题。然而,正如我们已经看到的,Scala 是一种相当表达性的语言,我们可以不使用任何额外的库本地实现依赖注入。我们将在接下来的小节中看到如何实现这一点。
Scala 中的依赖注入
为了在 Scala 中实现依赖注入,我们可以使用一种特殊的设计模式。它被称为蛋糕设计模式。不深入细节,让我们创建一个应用程序。我们创建的应用程序将需要有一系列相互依赖的类,这样我们就可以展示注入是如何工作的。
编写我们的代码
我们将创建一个应用程序,可以从数据库中读取有关人员、班级以及谁报名了哪些班级的数据。我们将有一个用户服务,它将使用数据实现一些简单的业务逻辑,并且还有一个将访问数据的服 务。这将会是一个小型应用程序,但它将清楚地展示依赖注入是如何工作的。
让我们从简单的事情开始。我们需要有一个模型来表示我们将要表示的对象:
case class Class(id: Int, name: String)
case class Person(id: Int, name: String, age: Int)
在前面的代码中,我们有两个将在我们的应用程序中使用的类。它们没有什么特别之处,所以让我们继续前进。
我们说过,我们希望我们的应用程序能够从数据库中读取数据。有不同类型的数据库——MySQL、PostgreSQL、Oracle 等。如果我们想使用这些数据库中的任何一个,那么你需要安装一些额外的软件,这将需要额外的知识,并且可能会很棘手。幸运的是,有一个内存数据库引擎叫做 H2 (www.h2database.com/html/main.html),我们可以用它来代替。使用这个引擎就像在我们的 pom.xml 或 build.sbt 文件中添加一个依赖项,然后使用数据库一样。我们很快就会看到这一切是如何工作的。
此外,让我们让事情更有趣,并确保可以轻松地插入不同的数据库引擎。为了实现这一点,我们需要某种类型的接口,该接口将由不同的数据库服务实现:
trait DatabaseService {
val dbDriver: String
val connectionString: String
val username: String
val password: String
val ds = {
JdbcConnectionPool.create(connectionString, username, password)
}
def getConnection: Connection = ds.getConnection
}
在前面的代码中,我们使用了一个特质,并且每当我们要创建一个 H2 数据库服务、Oracle 数据库服务等时,都会扩展这个特质。前面的代码中的所有内容似乎都很直接,不需要额外的解释。
vals 的顺序
在前面的代码中,变量定义的顺序很重要。这意味着如果我们首先声明了 ds,然后是其他所有内容,我们就会遇到一个 NullPointerException。这可以通过使用 lazy val 来轻松克服。
在我们的例子中,我们将实现一个针对 H2 数据库引擎的服务,如下所示:
trait DatabaseComponent {
val databaseService: DatabaseService
class H2DatabaseService(val connectionString: String, val username: String, val password: String) extends DatabaseService {
val dbDriver = "org.h2.Driver"
}
}
数据库服务的实际实现是在嵌套的 H2DatabaseService 类中。它没有什么特别之处。但是,关于 DatabaseComponent 特质呢?很简单——我们希望有一个数据库组件,我们可以将其混合到我们的类中,并提供连接到数据库的功能。databaseService 变量被留为抽象的,并且当组件被混合时必须实现。
仅有一个数据库组件本身并没有什么用处。我们需要以某种方式使用它。让我们创建另一个组件,它将创建我们的数据库及其表,并用数据填充它们。显然,它将依赖于前面提到的数据库组件:
trait MigrationComponent {
this: DatabaseComponent =>
val migrationService: MigrationService
class MigrationService() {
def runMigrations(): Unit = {
val connection = databaseService.getConnection
try {
// create the database
createPeopleTable(connection)
createClassesTable(connection)
createPeopleToClassesTable(connection)
// populate
insertPeople(
connection,
List(Person(1, "Ivan", 26), Person(2, "Maria", 25),
Person(3, "John", 27))
)
insertClasses(
connection,
List(Class(1, "Scala Design Patterns"), Class(2,
"JavaProgramming"), Class(3, "Mountain Biking"))
)
signPeopleToClasses(
connection,
List((1, 1), (1, 2), (1, 3), (2, 1), (3, 1), (3, 3))
)
} finally {
connection.close()
}
}
private def createPeopleTable(connection: Connection): Unit = {
// implementation
}
private def createClassesTable(connection: Connection): Unit = {
// implementation
}
private def createPeopleToClassesTable(connection: Connection):
Unit = {
// implementation
}
private def insertPeople(connection: Connection, people: List[Person]): Unit = {
// implementation
}
// Other methods
}
}
现在代码确实很多!但这并不令人害怕。让我们逐行分析,并尝试理解它。首先,我们遵循了之前的模式——创建了一个具有抽象变量的组件特质,在这个例子中,它被称为migrationService。我们不需要有多个不同的迁移,所以我们只需在组件特质内部创建一个类。
这里有趣的部分是我们突出显示的第一行——this: DatabaseComponent =>。这是什么意思?幸运的是,我们在书中已经见过这种语法了——它不过是一个self 类型注解。然而,它所做的确实很有趣——它告诉编译器,每次我们将MigrationComponent混入时,我们还需要将DatabaseComponent混入。这正是 Scala 知道迁移组件将依赖于数据库组件的拼图的一部分。因此,我们现在能够在第二行突出显示的代码中运行。如果我们仔细观察,它实际上访问了databaseService,这是DatabaseComponent的一部分。
在上一段代码中,我们跳过了大多数其他实现,但它们都很直接,与蛋糕设计模式无关。让我们看看其中的两个:
private def createPeopleTable(connection: Connection): Unit = {
val statement = connection.prepareStatement(
"""
|CREATE TABLE people(
| id INT PRIMARY KEY,
| name VARCHAR(255) NOT NULL,
| age INT NOT NULL
|)
""".stripMargin
)
try {
statement.executeUpdate()
} finally {
statement.close()
}
}
private def insertPeople(connection: Connection, people: List[Person]): Unit = {
val statement = connection.prepareStatement(
"INSERT INTO people(id, name, age) VALUES (?, ?, ?)"
)
try {
people.foreach {
case person =>
statement.setInt(1, person.id)
statement.setString(2, person.name)
statement.setInt(3, person.age)
statement.addBatch()
}
statement.executeBatch()
} finally {
statement.close()
}
}
上一段代码只是创建表并将数据插入到表中的数据库代码。类中的其余方法类似,但它们在表定义和插入内容上有所不同。完整的代码可以在本书提供的示例中看到。在这里,我们只提取创建数据库模型的语句,以便您了解数据库的结构以及我们可以用它做什么:
CREATE TABLE people(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT NOT NULL
)
CREATE TABLE classes(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
)
CREATE TABLE people_classes(
person_id INT NOT NULL,
class_id INT NOT NULL,
PRIMARY KEY(person_id, class_id),
FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY(class_id) REFERENCES classes(id) ON DELETE CASCADE ON UPDATE CASCADE
)
上一段代码中的迁移服务简单地创建数据库中的表,并将一些信息插入到这些表中,以便我们随后可以使用该服务。我们看到了这个迁移服务依赖于数据库服务,同时也看到了这种依赖是如何实现的。
仅通过这些课程,我们的应用程序将不会那么有用。我们需要能够与数据交互并对它进行一些有趣的操作。我们可以这样说,迁移组件只是确保我们拥有数据。在现实世界的场景中,我们可能已经有一个预先填充的数据库,我们需要与数据库中的内容进行工作。无论哪种情况,我们都需要有一个数据访问层来检索所需的数据。我们已经创建了以下组件:
trait DaoComponent {
this: DatabaseComponent =>
val dao: Dao
class Dao() {
def getPeople: List[Person] = {
// skipped
}
def getClasses: List[Class] = {
// skipped
}
def getPeopleInClass(className: String): List[Person] = {
val connection = databaseService.getConnection
try {
val statement = connection.prepareStatement(
"""
|SELECT p.id, p.name, p.age
|FROM people p
| JOIN people_classes pc ON p.id = pc.person_id
| JOIN classes c ON c.id = pc.class_id
|WHERE c.name = ?
""".stripMargin
)
statement.setString(1, className)
executeSelect(statement) {
rs =>
readResultSet(rs) {
row =>
Person(row.getInt(1), row.getString(2), row.getInt(3))
}
}
} finally {
connection.close()
}
}
private def executeSelectT(f: (ResultSet) => List[T]): List[T] =
try {
f(preparedStatement.executeQuery())
} finally {
preparedStatement.close()
}
private def readResultSetT(f: ResultSet => T): List[T] =
Iterator.continually((rs.next(), rs)).takeWhile(_._1).map {
case (_, row) => f(rs)
}.toList
}
}
这个DaoComponent在依赖方面与DatabaseComponent类似。它只是定义了用于检索数据的查询。我们跳过了简单的select语句。当然,它也可以定义更多用于插入、更新和删除的方法。它很好地隐藏了处理数据库数据的复杂性,现在我们实际上可以在我们的应用程序中创建一些有用的东西。
在企业应用程序中常见的是不同的服务可以访问数据库中的数据,对它执行一些业务逻辑,返回结果,并将其写回数据库。我们创建了一个简单的处理用户的服务:
trait UserComponent {
this: DaoComponent =>
val userService: UserService
class UserService {
def getAverageAgeOfUsersInClass(className: String): Double = {
val (ageSum, peopleCount) = dao.getPeopleInClass(className).foldLeft((0, 0)) {
case ((sum, count), person) =>
(sum + person.age, count + 1)
}
if (peopleCount != 0) {
ageSum.toDouble / peopleCount.toDouble
} else {
0.0
}
}
}
}
在我们的UserComponent中,我们遵循我们已知的相同模式,但这次我们的依赖是DaoComponent。然后我们可以有其他依赖此组件和其他组件的组件。我们没有在这里展示任何组件同时依赖多个组件的例子,但这并不难做到。我们只需使用以下方法:
this: Component1 with Component2 with Component3 … =>
我们可以依赖尽可能多的组件,这就是蛋糕设计模式开始发光并显示其优势的地方。
连接所有组件
在前面的代码中,我们看到了一些组件及其实现,它们声明了对其他组件的依赖。我们还没有看到所有这些组件是如何一起使用的。通过将我们的组件定义为特质,我们只需将它们混合在一起,它们就会对我们可用。这就是我们这样做的方式:
object ApplicationComponentRegistry
extends UserComponent
with DaoComponent
with DatabaseComponent
with MigrationComponent {
override val dao: ApplicationComponentRegistry.Dao = new Dao
override val databaseService: DatabaseService = new H2DatabaseService("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1", "", "")
override val migrationService: ApplicationComponentRegistry.MigrationService = new MigrationService
override val userService: ApplicationComponentRegistry.UserService = new UserService
}
在前面的代码中,ApplicationComponentRegistry也可以是一个类,而不是 Scala 对象。它将组件混合在一起,由于每个组件都有一个抽象变量,它迫使我们为它们分配实际值。最棒的部分是,如果我们知道我们的应用程序需要UserComponent,编译器会告诉我们还需要DaoComponent,依此类推,直到链的末端。编译器基本上会确保我们在编译期间有完整的依赖链可用,并且它不会让我们运行应用程序,直到我们正确地完成了这些事情。这非常实用。在其他库中,情况并非如此,我们经常发现我们的依赖图在运行时没有正确构建。此外,这种方式连接组件确保我们只有一个实例。
如果我们用类而不是对象来表示ApplicationComponentRegistry,关于每个组件只有一个实例的声明不会自动成立。我们需要格外小心,否则注册表的每个实例可能会有不同的组件实例。
在我们创建组件注册表之后,我们可以轻松地在我们的应用程序中使用所有内容:
object Application {
import ApplicationComponentRegistry._
def main(args: Array[String]): Unit = {
migrationService.runMigrations()
System.out.println(dao.getPeople)
System.out.println(dao.getClasses)
System.out.println(dao.getPeopleInClass("Scala Design Patterns"))
System.out.println(dao.getPeopleInClass("Mountain Biking"))
System.out.println(s"Average age of everyone in Scala Design Patterns: ${userService.getAverageAgeOfUsersInClass("Scala Design Patterns")}")
}
}
在前面的代码中,我们简单地从注册表中导入了所有内容,然后使用了它。该应用程序的输出如下截图所示:

这就是使用 Scala 中的蛋糕设计模式有多简单。
对我们的应用程序进行单元测试
测试是每个应用程序的重要部分。我们需要确保我们添加的更改不会对我们的系统其他部分产生负面影响,并且每个单元都表现正确。使用蛋糕设计模式进行测试也非常简单。
蛋糕设计模式允许我们轻松地创建不同的环境。这就是为什么我们可以创建以下测试环境:
trait TestEnvironment
extends UserComponent
with DaoComponent
with DatabaseComponent
with MigrationComponent
with MockitoSugar {
override val dao: Dao = mock[Dao]
override val databaseService: DatabaseService = mock[DatabaseService]
override val migrationService: MigrationService = mock[MigrationService]
override val userService: UserService = mock[UserService]
}
上述代码简单地包含了每个组件,并使用 Mockito 模拟了每个服务。让我们使用我们的新测试环境为我们的UserComponent编写一个测试类:
class UserComponentTest extends FlatSpec with Matchers with MockitoSugar with TestEnvironment {
val className = "A"
val emptyClassName = "B"
val people = List(
Person(1, "a", 10),
Person(2, "b", 15),
Person(3, "c", 20)
)
override val userService = new UserService
when(dao.getPeopleInClass(className)).thenReturn(people)
when(dao.getPeopleInClass(emptyClassName)).thenReturn(List())
"getAverageAgeOfUsersInClass" should "properly calculate the average of all ages." in {
userService.getAverageAgeOfUsersInClass(className) should equal(15.0)
}
it should "properly handle an empty result." in {
userService.getAverageAgeOfUsersInClass(emptyClassName) should equal(0.0)
}
}
在上述代码中,我们覆盖了userService以使用实际实现,然后我们使用它进行测试。我们使用 Mockito 来模拟我们的数据库访问,然后我们简单地编写一个测试来检查一切是否正常工作。我们已经决定模拟我们的数据库访问。然而,在某些情况下,人们有测试数据库或使用 H2 进行测试。使用我们的测试环境,我们有灵活性去做我们决定的事情。
运行我们之前编写的测试可以通过mvn clean test或sbt test命令实现。
我们的测试环境允许我们在测试中启用我们想要的任何组件。我们可以在我们的测试类中简单地覆盖多个这样的组件。
其他依赖注入替代方案
关于我们之前提出的蛋糕设计模式的一个问题是我们需要编写的样板代码量,以便正确地连接一切。在大型应用程序中,这可能会成为一个问题,因此有其他替代方案可以用来处理这个问题。我们在这里简要讨论一下。
依赖注入的隐式参数
使用隐式参数是消除蛋糕设计模式中组件特性和 self 类型注解要求的一种方法。然而,隐式参数会迅速使方法定义复杂化,因为每个方法都必须声明它所依赖的任何组件的隐式参数。
依赖注入的 Reader monad
Reader monad 在 Scalaz 库中可用。依赖注入与它的结合方式是,我们让每个方法返回一个被Reader monad 包装的函数,例如:
def getAverageAgeOfUsersInClass(className: String) =
Reader((userService: UserService) => userService.getAverageAgeOfUsersInClass(className))
在上述代码中,我们只向用户公开了getAverageAgeOfUsersInClass(className: String)。通常,对于 monads,计算在这里构建,但直到最后一刻才执行。我们可以构建复杂的操作,使用map、flatMap和 for comprehensions。我们推迟注入依赖,直到最后一刻,那时我们可以在需要实际组件或组件的 reader 上简单地调用apply。前面的解释可能听起来有点抽象,但事情实际上非常简单,可以在网上许多地方看到。
在某些情况下,这种方法与蛋糕设计模式一起使用。
修改我的库设计模式
在我们作为开发者的日常工作中,我们经常使用不同的库。然而,它们通常被设计成通用的,允许许多人使用它们,因此有时我们需要做一些额外的工作,以适应我们的特定用例,以便使事情能够正常工作。我们无法真正修改原始库代码的事实意味着我们必须采取不同的方法。我们已经探讨了装饰器和适配器设计模式。好吧,改进我的库模式实现了类似的功能,但它以 Scala 的方式实现,并且一些额外的工作交由编译器处理。
改进我的库设计模式在 C#中的扩展方法非常相似。我们将在以下小节中看到一些示例。
使用改进我的库
改进我的库设计模式非常容易使用。让我们看看一个例子,我们想在标准String类中添加一些有用的方法。当然,我们无法修改其代码,因此我们需要做些其他事情:
package object pimp {
implicit class StringExtensions(val s: String) extends AnyVal {
def isAllUpperCase: Boolean =
!(0 until s.length).exists {
case index =>
s.charAt(index).isLower
}
}
}
在上述代码中,我们有一个包对象。它为我们提供了便利,使我们能够不进行任何额外操作就能从同一包中的类访问其成员。它可以是简单的对象,但那时我们将不得不import ObjectName._以获得对成员的访问。
上述对象只是一个细节,与设计模式无关。改进我的库代码是内部类。关于这一点有几个重要的事项:
-
它是隐式的
-
它扩展了
AnyVal
这些特性使我们能够编写以下应用程序:
object PimpExample {
def main(args: Array[String]): Unit = {
System.out.println(s"Is 'test' all upper case:
${"test".isAllUpperCase}")
System.out.println(s"Is 'Tes' all upper case:
${"Test".isAllUpperCase}")
System.out.println(s"Is 'TESt' all upper case:
${"TESt".isAllUpperCase}")
System.out.println(s"Is 'TEST' all upper case:
${"TEST".isAllUpperCase}")
}
}
我们基本上向标准字符串添加了一个扩展方法,用于检查整个字符串是否为大写。我们唯一需要做的是确保隐式类在我们想要使用其定义的方法的作用域内可用。
上述应用程序的输出如下所示:

在我们的例子中,我们不需要编写在扩展类中包装字符串的代码。我们的代码将类型显示为普通字符串;然而,我们可以对其进行额外的操作。此外,装饰器设计模式在尝试装饰的类是 final 的情况下会受到影响。在这里,没有问题。再次强调,所有魔法都发生因为我们有一个隐式类,Scala 编译器会自动确定它可以根据我们调用的方法来包装和展开字符串。
我们当然可以向StringExtensions类添加更多方法,并且它们将对所有可用的隐式类中的字符串可用。我们还可以添加其他类:
implicit class PersonSeqExtensions(val seq: Iterable[Person]) extends AnyVal {
def saveToDatabase(): Unit = {
seq.foreach {
case person =>
System.out.println(s"Saved: ${person} to the database.")
}
}
}
上述代码能够将整个Person类型的集合保存到数据库中(尽管在示例中我们只是将集合打印到标准输出)。为了完整性,我们的Person模型类定义如下:
case class Person(name: String, age: Int)
使用新的扩展方法与早期的扩展方法类似:
object PimpExample2 {
def main(args: Array[String]): Unit = {
val people = List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
people.saveToDatabase()
}
}
上述示例将产生预期的结果,如下所示:

如果需要并且合理,我们也可以将改进我的库设计模式应用于我们的自定义类。
真实生活中的改进我的库
如前所述,改进我的库设计模式极其容易使用。这很常见,尤其是在需要装饰器或适配器设计模式时。我们当然可以找出处理问题的方法,但事实上,它帮助我们避免样板代码。它也真正有助于使我们的代码更易于阅读。最后但同样重要的是,它可以用来简化特定库的使用。
可堆叠特性设计模式
有时候,我们希望能够为类的某个方法提供不同的实现。我们甚至可能不知道在编写时所有可能存在的可能性,但我们可以在以后添加它们,并将它们组合起来,或者我们可以允许其他人来做这件事。这是装饰器设计模式的一个用例,为了这个目的,它可以与可堆叠特性设计模式一起实现。我们在这本书的第七章中已经看到了这个模式,结构型设计模式,但我们用它来读取数据,这在那里增加了一个非常重要的限制。在这里,我们将看到另一个例子,以确保一切完全清楚。
使用可堆叠特性
可堆叠特性设计模式基于混入组合——这是我们在这本书的前几章中熟悉的。我们通常有一个定义接口、基本实现和扩展抽象类以在其上堆叠修改的抽象类或特性。
对于我们的例子,让我们实现以下图示:

上述图示是一个非常简单的应用程序。我们有一个基本的StringWriter类,它有一个基本实现(BasicStringWriter),它只是返回一个包含字符串的消息。在右侧,我们有可以添加可堆叠修改的StringWriter特性的特性。
让我们看看以下代码:
abstract class StringWriter {
def write(data: String): String
}
class BasicStringWriter extends StringWriter {
override def write(data: String): String =
s"Writing the following data: ${data}"
}
上述代码是抽象类和基本实现。这些没有什么特别之处。现在,让我们看看可堆叠特性:
trait CapitalizingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.split("\\s+").map(_.capitalize).mkString(""))
}
}
trait UppercasingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.toUpperCase)
}
}
trait LowercasingStringWriter extends StringWriter {
abstract override def write(data: String): String = {
super.write(data.toLowerCase)
}
}
上述代码中的全部魔法都是因为方法上的abstract override修饰符。它允许我们在super类的抽象方法上调用super。否则这将失败,但在这里,它只需要我们将特性与一个实现了write的类或特性混合。如果我们不这样做,我们就无法编译我们的代码。
让我们看看我们特性的一个示例用法:
object Example {
def main(args: Array[String]): Unit = {
val writer1 = new BasicStringWriter
with UppercasingStringWriter
with CapitalizingStringWriter
val writer2 = new BasicStringWriter
with CapitalizingStringWriter
with LowercasingStringWriter
val writer3 = new BasicStringWriter
with CapitalizingStringWriter
with UppercasingStringWriter
with LowercasingStringWriter
val writer4 = new BasicStringWriter
with CapitalizingStringWriter
with LowercasingStringWriter
with UppercasingStringWriter
System.out.println(s"Writer 1: '${writer1.write("we like learning
scala!")}'")
System.out.println(s"Writer 2: '${writer2.write("we like learning
scala!")}'")
System.out.println(s"Writer 3: '${writer3.write("we like learning
scala!")}'")
System.out.println(s"Writer 4: '${writer4.write("we like learning
scala!")}'")
}
}
在前面的代码中,我们只是通过混入组合将修改堆叠在一起。在当前示例中,它们只是说明性的,并没有做任何智能的事情,但现实中我们可以有提供强大修改的变体。以下图显示了我们的示例输出:

我们代码中的修改将取决于它们应用的顺序。例如,如果我们首先将所有内容转换为大写,那么大写化将没有任何效果。让我们看看代码和相关的输出,并尝试找出修改是如何应用的。如果你查看所有示例和输出,你会发现修改是按照我们混合特性的顺序从右到左应用的。
如果我们回顾第七章中的示例,结构设计模式,然而,我们会看到实际的修改是相反的。原因是每个特性都会调用super.readLines然后映射。嗯,这实际上意味着我们将调用堆栈上的调用,直到我们到达基本实现,然后我们将返回去做所有的映射。所以,在第七章中,结构设计模式,修改也是从右到左应用的,但由于我们只是获取输出而不传递任何东西,所以事情是按照从左到右的顺序应用的。
可堆叠特性执行顺序
可堆叠特性总是从右边的混入到左边执行。然而,有时如果我们只获取输出并且它不依赖于传递给方法的内容,我们最终会在堆栈上得到方法调用,然后这些调用将被评估,看起来就像是从左到右应用的一样。
理解前面的解释对于使用可堆叠特性非常重要。它实际上完美地匹配我们在第二章中关于线性化的观察,特性和混入组合。
类型类设计模式
在我们编写软件的许多时候,我们会遇到不同实现之间的相似性。良好的代码设计的一个重要原则是避免重复,这被称为不要重复自己(DRY)。有多种方法可以帮助我们避免重复——继承、泛型等等。
确保我们不重复自己的一个方法是通过类型类。
类型类的目的是通过类型必须支持的操作来定义一些行为,以便被认为是类型类的成员。
一个具体的例子是Numeric。我们可以这样说,它是一个类型类,并为Int、Double以及其他类似类定义了操作——加法、减法、乘法等等。实际上,我们已经在本书的第四章中遇到过类型类,抽象和自类型。类型类是允许我们实现特定多态的。
类型类示例
让我们看看一个实际例子,这个例子对开发者来说也有些有用。在机器学习中,开发者往往在他们的工作中经常使用一些统计函数。有统计库,如果我们尝试它们,我们会看到这些函数对不同数值类型——Int、Double等等——都是存在的。现在,我们可以想出一个简单的方法,为所有我们认为的数值类型实现这些函数。然而,这是不可行的,并使得我们的库无法扩展。此外,统计函数的定义对于任何类型都是相同的,所以我们不希望像数值类型那么多地重复我们的代码。
所以让我们首先定义我们的类型类:
trait Number[T] {
def plus(x: T, y: T): T
def minus(x: T, y: T): T
def divide(x: T, y: Int): T
def multiply(x: T, y: T): T
def sqrt(x: T): T
}
前面只是一个定义了一些需要数字支持的操作的特质的例子。
Scala 中的 Numeric
Scala 编程语言有一个Numeric特质,它定义了许多前面提到的操作。
如果我们在前面的代码中使用了Numeric特质,我们可以节省一些代码编写,但为了这个例子,让我们使用我们的自定义类型。
在我们定义了一个数字的特质之后,我们现在可以按照以下方式编写我们的库:
object Stats {
// same as
// def meanT(implicit ev: Number[T]): T =
// ev.divide(xs.reduce(ev.plus(_, _)), xs.size)
def meanT: Number: T =
implicitly[Number[T]].divide(
xs.reduce(implicitly[Number[T]].plus(_, _)),
xs.size
)
// assumes the vector is sorted
def medianT: Number: T =
xs(xs.size / 2)
def varianceT: Number: T = {
val simpleMean = mean(xs)
val sqDiff = xs.map {
case x =>
val diff = implicitly[Number[T]].minus(x, simpleMean)
implicitly[Number[T]].multiply(diff, diff)
}
mean(sqDiff)
}
def stddevT: Number: T =
implicitly[Number[T]].sqrt(variance(xs))
}
在前面的例子中有很多代码。定义函数相当直接。然而,让我们解释一下implicitly关键字的作用。它使用 Scala 中的所谓上下文界限,这是允许我们实现类型类设计模式的关键部分。为了使用前面的方法,它需要一个类型类成员Number对于T类型是隐式可用的。正如你在mean上面的注释中可以看到的,我们还可以为方法提供一个隐式参数。
现在,让我们编写一些示例代码,这些代码将使用前面提到的方法:
import Stats._
object StatsExample {
def main(args: Array[String]): Unit = {
val intVector = Vector(1, 3, 5, 6, 10, 12, 17, 18, 19, 30, 36, 40, 42, 66)
val doubleVector = Vector(1.5, 3.6, 5.0, 6.6, 10.9, 12.1, 17.3, 18.4, 19.2, 30.9, 36.6, 40.2, 42.3, 66.0)
System.out.println(s"Mean (int): ${mean(intVector)}")
System.out.println(s"Median (int): ${median(intVector)}")
System.out.println(s"Std dev (int): ${stddev(intVector)}")
System.out.println(s"Mean (double): ${mean(doubleVector)}")
System.out.println(s"Median (double): ${median(doubleVector)}")
System.out.println(s"Std dev (double): ${stddev(doubleVector)}")
}
}
编译前面的代码现在将不会成功,我们会看到类似于以下错误的错误:
Error:(9, 44) could not find implicit value for evidence parameter of type com.ivan.nikolov.type_classes.Number[Int]
System.out.println(s"Mean (int): ${mean(intVector)}")
^
原因是我们还没有为Int和Double定义任何隐式可用的Number成员。让我们在Number特质的伴随对象中定义它们:
import Math.round
object Number {
implicit object DoubleNumber extends Number[Double] {
override def plus(x: Double, y: Double): Double = x + y
override def divide(x: Double, y: Int): Double = x / y
override def multiply(x: Double, y: Double): Double = x * y
override def minus(x: Double, y: Double): Double = x - y
override def sqrt(x: Double): Double = Math.sqrt(x)
}
implicit object IntNumber extends Number[Int] {
override def plus(x: Int, y: Int): Int = x + y
override def divide(x: Int, y: Int): Int = round(x.toDouble / y.toDouble).toInt
override def multiply(x: Int, y: Int): Int = x * y
override def minus(x: Int, y: Int): Int = x - y
override def sqrt(x: Int): Int = round(Math.sqrt(x)).toInt
}
}
现在,我们的代码将成功编译。但是当我们刚刚在一个完全不同的文件中的伴随对象中定义了这些隐式值时,整个事情是如何工作的呢?首先,我们的嵌套对象是隐式的,其次,它们在伴随对象中是可用的。
在伴随对象中定义你的默认类型类成员
隐式类型类参数的伴随对象是编译器最后查找隐式值的地方。这意味着不需要做任何额外的事情,用户可以轻松地覆盖我们的实现。
我们现在可以轻松地运行我们的代码:

当然,我们可以将我们的隐式值放在我们想要的地方。然而,如果它们不在伴随对象中,我们就必须进行额外的导入,以便使它们可用。
类型类设计模式替代方案
当然,类型类设计模式有替代方案。我们可以使用适配器设计模式。然而,这将使我们的代码难以阅读,因为事物将始终被包装,并且将更加冗长。类型类设计模式利用了 Scala 类型系统的良好特性。
看看我们前面的代码,我们还可以看到有很多样板代码。在更大的项目或尝试定义更复杂的类型类时,这可能会成为问题。一个专门编写来处理这些问题的库可以在github.com/mpilquist/simulacrum/找到。
懒加载
编写高效的代码是软件工程的重要组成部分。很多时候,我们会看到由于不同的可能原因,表达式评估成本高昂的情况——数据库访问、复杂计算等等。在某些情况下,我们甚至可以在不评估这些昂贵表达式的情况下退出应用程序。这就是懒加载变得有帮助的地方。
懒加载确保表达式仅在真正需要时才被评估一次。
Scala 支持几种懒加载方式——懒变量和按名参数。在这本书中,我们已经看到了这两种方式:前者是在我们查看第六章的创建型设计模式时看到的,即创建型设计模式,特别是懒初始化。后者我们在几个地方都看到了,但第一次是在第八章行为设计模式 - 第一部分中遇到的,我们向您展示了如何以更接近 Scala 的方式实现命令设计模式。
懒变量和按名参数之间存在一个重要的区别。懒变量只计算一次,而按名参数每次在方法中引用时都会计算。这里有一个非常简单的技巧我们将展示,这将解决这个问题。
只计算一次按名参数
让我们设想我们有一个从数据库中获取人员数据的程序。读取操作是一种昂贵的操作,是懒加载的良好候选者。在这个例子中,我们将简单地模拟从数据库中读取。首先,我们的模型将尽可能简单,如下所示:
case class Person(name: String, age: Int)
现在,让我们创建一个伴随对象,它将有一个模拟从数据库获取人员数据的方法:
object Person {
def getFromDatabase(): List[Person] = {
// simulate we're getting people from database by sleeping
System.out.println("Retrieving people...")
Thread.sleep(3000)
List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
}
}
之前的代码只是让当前线程休眠三秒钟并返回一个静态结果。多次调用getFromDatabase方法会使我们的应用程序变慢,因此我们应该考虑惰性评估。现在,让我们向我们的伴随对象添加以下方法:
def printPeopleBad(people: => List[Person]): Unit = {
System.out.println(s"Print first time: ${people}")
System.out.println(s"Print second time: ${people}")
}
如您所见,我们简单地打印了两次关于人员的数据列表,并且两次访问了按名称参数。这是不好的,因为它将评估函数两次,我们不得不等待两倍的时间。让我们写另一个版本来解决这个问题:
def printPeopleGood(people: => List[Person]): Unit = {
lazy val peopleCopy = people
System.out.println(s"Print first time: ${peopleCopy}")
System.out.println(s"Print second time: ${peopleCopy}")
}
这次,我们将按名称参数分配给lazy val,然后使用它。这将只评估一次按名称参数,而且,如果我们最终没有使用它,它将根本不会评估。
让我们看看一个例子:
object Example {
import Person._
def main(args: Array[String]): Unit = {
System.out.println("Now printing bad.")
printPeopleBad(getFromDatabase())
System.out.println("Now printing good.")
printPeopleGood(getFromDatabase())
}
}
如果我们运行这个应用程序,我们将看到以下输出:

如您从程序输出中可以看到,我们方法的第一个版本检索了按名称参数值两次,而第二个版本只检索了一次。在第二个方法中使用lazy val的事实也意味着如果我们实际上没有使用它,我们可能根本不会评估我们的昂贵表达式。
替代惰性评估
在 Scala 中实现惰性求值还有另一种方法。这是通过使用匿名函数并利用函数是 Scala 中统一的一部分以及我们可以轻松地将它们作为参数传递的事实来实现的。这样做的方式如下——一个值被表示为() => value而不是仅仅是值本身。然而,这有点没有意义,尤其是因为我们已经有了两种可以做到很多事情的机制。使用匿名函数进行惰性求值是不推荐的。
将一个函数传递给一个方法也可以被认为是一种惰性评估一些数据的方式。然而,这可能是有用的,不应该与我们在匿名函数中提到的内容混淆。
部分函数
在数学中,以及作为结果在编程中,有一些函数并不是对所有可能的输入都定义的。一个简单的例子是平方根函数——它只对非负实数有效。在本节中,我们将探讨部分函数以及我们如何使用它们。
部分函数不是部分应用函数
关于部分函数是什么以及不是什么似乎存在一些混淆。重要的是你要明白,这些函数不是部分应用函数。部分应用函数只是可能接受多个参数的函数,我们指定了一些参数,然后它们返回具有较少参数的函数,我们可以指定这些参数。还有一个与部分应用函数相关的术语——柯里化函数。在功能方面,它们提供相同的功能。让我们快速看一个例子:
/**
* Note that these are not partially defined functions!
*/
object PartiallyAppliedFunctions {
val greaterOrEqual = (a: Int, b: Int) => a >= b
val lessOrEqual = (a: Int, b: Int) => a <= b
def greaterOrEqualCurried(b: Int)(a: Int) = a >= b
def lessOrEqualCurried(b: Int)(a: Int) = a <= b
val greaterOrEqualCurriedVal: (Int) => (Int) => Boolean = b => a => a >= b
val lessOrEqualCurriedVal: (Int) => (Int) => Boolean = b => a => a <= b
}
在前面的代码中,我们对大于和小于或等于函数有不同的定义。首先,我们将它们作为普通函数。第二种版本是带有多个参数列表的,最后一个是实际的柯里化函数。以下是它们的用法:
object PartiallyAppliedExample {
import PartiallyAppliedFunctions._
val MAX = 20
val MIN = 5
def main(args: Array[String]): Unit = {
val numbers = List(1, 5, 6, 11, 18, 19, 20, 21, 25, 30)
// partially applied
val ge = greaterOrEqual(_: Int, MIN)
val le = lessOrEqual(_: Int, MAX)
// curried
val geCurried = greaterOrEqualCurried(MIN) _
val leCurried = lessOrEqualCurried(MAX) _
// won't work because of the argument order
// val geCurried = greaterOrEqual.curried(MIN)
// val leCurried = lessOrEqual.curried(MAX)
// will work normally
// val geCurried = greaterOrEqualCurriedVal(MIN)
// val leCurried = lessOrEqualCurriedVal(MAX)
System.out.println(s"Filtered list: ${numbers.filter(i => ge(i) && le(i))}")
System.out.println(s"Filtered list: ${numbers.filter(i => geCurried(i) && leCurried(i))}")
}
}
我们使用部分应用函数的方式如下:
greaterOrEqual(_: Int, MIN)
这将返回一个从 Int 到 Boolean 的函数,我们可以用它来检查参数是否大于或等于 MIN 值。这是一个部分应用函数。
对于这些函数的柯里化版本,正如你所见,我们已经交换了参数。原因是柯里化函数只是一系列的单参数函数,参数按照我们看到的顺序应用。greaterOrEqualCurried(MIN) 这行代码部分应用了函数,并返回了一个我们可以像上面一样使用的柯里化函数。正如代码注释中所示,我们可以将任何多参数函数转换为柯里化函数。greaterOrEqual 和 lessOrEqual 在我们的例子中不工作是因为参数是按照它们出现的顺序应用的。最后,我们在 greaterOrEqualCurriedVal 和 lessOrEqualCurriedVal 中有一个纯柯里化版本。当我们部分应用具有多个参数列表的函数时,返回这种类型的函数。
如果我们运行前面的例子,我们将看到以下输出:

选择是否使用部分应用函数或柯里化函数取决于许多因素,包括个人偏好。在两种情况下,我们可以用稍微不同的语法达到相同的目标。正如你所见,我们可以使用 .curried 从普通函数到柯里化函数转换。我们也可以使用 Function.uncurried 调用并传递函数来实现相反的操作。当柯里化函数链中包含多个函数时,这个调用是有意义的。
使用部分应用函数进行依赖注入
由于部分应用函数和柯里化函数的工作方式,我们可以将它们用于依赖注入。我们基本上可以将依赖项应用到函数上,然后得到另一个函数,我们可以在之后使用它。
部分定义的函数
我们已经说过,部分函数只为函数可能得到的所有可能值的一个特定子集定义。这非常有用,因为我们基本上可以同时执行filter和map。这意味着更少的 CPU 周期和更易读的代码。让我们看看一个例子:
object PartiallyDefinedFunctions {
val squareRoot: PartialFunction[Int, Double] = {
case a if a >= 0 => Math.sqrt(a)
}
}
我们定义了一个从Int到Double的部分函数。它检查一个数字是否为非负数,并返回该数字的平方根。这个部分函数可以这样使用:
object PartiallyDefinedExample {
import PartiallyDefinedFunctions._
def main(args: Array[String]): Unit = {
val items = List(-1, 10, 11, -36, 36, -49, 49, 81)
System.out.println(s"Can we calculate a root for -10:
${squareRoot.isDefinedAt(-10)}")
System.out.println(s"Square roots: ${items.collect(squareRoot)}")
}
}
我们使用了接受部分函数的collect方法。我们还展示了部分函数的一个方法——isDefinedAt,其名称确切地告诉我们它做什么。我们程序的输出将是这样的:

我们的部分函数过滤掉了负数,并返回了其余数的平方根。
部分函数也可以用来链式操作,或者在某个操作不可行时执行不同的操作。它们有orElse、andThen、runWith等这样的方法。从它们的名字就可以清楚地知道前两种方法的作用。第三种方法使用部分应用函数的结果并执行可能产生副作用的行为。让我们看看orElse的一个例子:
val square: PartialFunction[Int, Double] = {
case a if a < 0 => Math.pow(a, 2)
}
首先,我们定义另一个部分函数,它对负数进行平方。然后,我们可以在我们的例子中添加一些额外的代码:
object PartiallyDefinedExample {
import PartiallyDefinedFunctions._
def main(args: Array[String]): Unit = {
val items = List(-1, 10, 11, -36, 36, -49, 49, 81)
System.out.println(s"Can we calculate a root for -10:
${squareRoot.isDefinedAt(-10)}")
System.out.println(s"Square roots: ${items.collect(squareRoot)}")
System.out.println(s"Square roots or squares:
${items.collect(squareRoot.orElse(square))}")
}
}
这将产生以下输出:

我们基本上会对负数进行平方,对正数进行平方根。从我们在本例中进行的操作的角度来看,这可能没有太多意义,但它展示了我们如何链式使用部分函数。如果我们结合不同的部分函数后,最终覆盖了整个可能的输入空间,那么使用模式匹配和普通函数可能更有意义。然而,如果我们没有匹配所有可能的值,我们可能会得到运行时异常。
隐式注入
我们已经在本书的几个地方看到了隐式转换。我们在类型类设计模式和“改进我的库”设计模式中使用了它们,我们还提到它们可以用于依赖注入。隐式转换也用于从一种类型到另一种类型的无声转换。
它们只是编译器所知的某些对象、值或方法,编译器会为我们将它们注入到需要它们的方法或位置。我们需要确保的是,使这些隐式转换对将使用它们的方法的作用域可用。
隐式转换
我们已经提到,隐式转换可以用于无声转换。有时,可能有用能够将Double赋值给Int而不出错。在其他时候,我们可能想要将一个类型的对象包装到另一个类型中,并利用新类型提供的方法:
package object implicits {
implicit def doubleToInt(a: Double): Int = Math.round(a).toInt
}
在前面的代码列表中,我们有一个包对象定义了一个方法,该方法将Double转换为Int。这将允许我们编写并成功编译以下代码:
object ImplicitExamples {
def main(args: Array[String]): Unit = {
val number: Int = 7.6
System.out.println(s"The integer value for 7.6 is ${number}")
}
}
只要ImplicitExamples对象与我们的包对象在同一个包中,我们就不需要做任何额外的事情。另一种选择是在对象内部定义我们的隐式转换,并在我们需要它的作用域中导入该对象。
我们甚至可以将类型包裹在新的对象中。Scala 中的LowPriorityImplicits类中有一些示例,可以将字符串转换为序列等。现在,让我们添加一个将Int列表转换为String的隐式转换:
implicit def intsToString(ints: List[Int]): String = ints.map(_.toChar).mkString
现在,我们可以使用我们的隐式转换来打印一个 ASCII 字符码列表作为String:
object ImplicitExamples {
def main(args: Array[String]): Unit = {
val number: Int = 7.6
System.out.println(s"The integer value for 7.6 is ${number}")
// prints HELLO!
printAsciiString(List(72, 69, 76, 76, 79, 33))
}
def printAsciiString(s: String): Unit = {
System.out.println(s)
}
}
运行这个示例将产生以下输出:

我们可能需要隐式转换的有很多有用的东西。它们可以帮助我们很好地分离代码,但我们应该小心不要过度使用它们,因为调试可能会变得困难,代码的可读性可能会受到影响。
使用隐式转换进行依赖注入
当我们展示了使用蛋糕设计模式的依赖注入时,我们还提到可以使用隐式转换来实现它。想法是服务在一个地方创建,然后我们可以编写需要服务的隐式方法。到现在为止,你应该已经获得了足够的知识,能够独立找到正确的解决方案,所以这里我们只展示之前的大例子的一部分:
case class Person(name: String, age: Int)
在我们定义了一个模型之后,我们可以创建一个DatabaseService,如下所示:
trait DatabaseService {
def getPeople(): List[Person]
}
class DatabaseServiceImpl extends DatabaseService {
override def getPeople(): List[Person] = List(
Person("Ivan", 26),
Person("Maria", 26),
Person("John", 25)
)
}
我们的数据库服务不依赖于任何东西。它只是模拟从数据库中读取某些内容。现在,让我们创建一个UserService,它将依赖于DatabaseService:
trait UserService {
def getAverageAgeOfPeople()(implicit ds: DatabaseService): Double
}
class UserServiceImpl extends UserService {
override def getAverageAgeOfPeople()(implicit ds: DatabaseService): Double = {
val (s, c) = ds.getPeople().foldLeft((0, 0)) {
case ((sum, count), person) =>
(sum + person.age, count + 1)
}
s.toDouble / c.toDouble
}
}
如您从用户服务提供的唯一方法签名中看到的那样,它需要一个DatabaseService实例隐式可用。我们也可以显式传递一个,并覆盖我们用于测试的目的的现有实例。现在我们有了这些服务,我们可以将它们连接起来:
package object di {
implicit val databaseService = new DatabaseServiceImpl
implicit val userService = new UserServiceImpl
}
我们选择使用包对象,但任何对象或类都可以,只要我们可以在需要对象的地方导入它。现在,我们应用程序的使用很简单:
object ImplicitDIExample {
def main(args: Array[String]): Unit = {
System.out.println(s"The average age of the people is:
${userService.getAverageAgeOfPeople()}")
}
}
输出将是以下内容:

如您所见,现在我们使用的样板代码比蛋糕设计模式少。这种方法的缺点是方法签名,当有更多依赖项时可能会变得更加复杂。在现实世界的应用中,可能会有大量的依赖项,而且由于隐式变量,代码可读性也会受到影响。可能的解决方案是将依赖项包装在对象中,并隐式传递它们。最后,关于使用哪种依赖注入策略,这主要是一个个人偏好的问题,因为两者都可以实现相同的事情。
使用隐式依赖注入进行测试
使用隐式依赖注入进行测试与使用蛋糕设计模式进行测试相似。我们可以有一个新对象,它创建服务的模拟并使它们对测试类可用。当我们想要使用服务的具体实现时,我们只需覆盖它。我们也可以在这里显式传递一个依赖项。
Duck typing
开发者的工作很大一部分是尽量减少代码重复。有多种不同的方法可以做到这一点,包括继承、抽象、泛型、类型类等等。然而,在某些情况下,强类型语言将需要一些额外的工作来最小化一些重复。让我们想象我们有一个可以读取并打印文件内容的方法。如果我们有两个不同的库允许我们读取文件,为了使用我们的方法,我们必须确保读取文件的方法以某种方式变得相同。一种方法是通过将它们包装在实现特定接口的类中来实现。假设在两个库中读取方法都有相同的签名,这很容易发生,Scala 可以使用鸭子类型,这样就可以最小化我们不得不做的额外工作。
Duck typing 是一个来自动态语言的术语,它允许我们根据它们共有的一个方法以相似的方式处理不同类型的对象。
Duck typing 的另一个名称是结构化类型。
Duck typing 示例
通过一个例子,一切都会变得清晰。让我们想象我们想要一个可以接受一个解析器并打印出解析器检测到的每个单词的方法。我们的解析器将有一个以下签名的方法:
def parse(sentence: String): Array[String]
做这件事的一个好方法是拥有一个公共接口,并让所有解析器实现它。然而,让我们设定一个条件,我们不能这样做。解析器可能来自两个不同的库,我们无法以任何方式修改或连接。
我们为这个例子定义了两种不同的解析器实现。第一个如下所示:
import java.util.StringTokenizer
class SentenceParserTokenize {
def parse(sentence: String): Array[String] = {
val tokenizer = new StringTokenizer(sentence)
Iterator.continually({
val hasMore = tokenizer.hasMoreTokens
if (hasMore) {
(hasMore, tokenizer.nextToken())
} else {
(hasMore, null)
}
}).takeWhile(_._1).map(_._2).toArray
}
}
这个解析器使用了StringTokenizer类,并返回一个由空格分隔的所有单词组成的数组。另一个实现方式如下所示:
class SentenceParserSplit {
def parse(sentence: String): Array[String] = sentence.split("\\s")
}
在这里,我们只是使用正则表达式按空格分割句子。
如您所见,这两个类都有一个具有相同签名的解析方法,但它们之间没有关联。然而,我们希望能够在方法中使用它们并避免代码重复。以下是我们可以这样做的方法:
object DuckTypingExample {
def printSentenceParts(sentence: String, parser: {
def parse(sentence: String): Array[String]
}) = parser.parse(sentence).foreach(println)
def main(args: Array[String]): Unit = {
val tokenizerParser = new SentenceParserTokenize
val splitParser = new SentenceParserSplit
val sentence = "This is the sentence we will be splitting."
System.out.println("Using the tokenize parser: ")
printSentenceParts(sentence, tokenizerParser)
System.out.println("Using the split parser: ")
printSentenceParts(sentence, splitParser)
}
}
在前面的代码中,我们将两个解析器都传递给了printSentenceParts方法,并且一切编译和运行正常。事情之所以能正常工作,是因为鸭子类型,这可以在我们示例的高亮部分中看到。我们应用程序的输出如下:

我们可以通过扩展参数签名来使用鸭子类型(duck typing)来要求对象有更多的方法可用。
鸭子类型的替代方案
如您从前面的代码中看到的那样,鸭子类型使我们免去了编写额外代码和定义通用接口的需要。实现相同目的的其他方法可能包括创建实现通用接口的包装器。
何时使用鸭子类型
过度使用鸭子类型可能会对代码质量和应用程序性能产生负面影响。你不应该为了避免创建通用接口而使用鸭子类型。它应该真正只在无法在不同类型之间实现通用接口的情况下使用。关于限制鸭子类型使用的论点,还得到了这样一个事实的进一步强化,即它们在底层使用反射,这较慢且对性能产生负面影响。
记忆化
编写高性能程序通常是将良好的算法与计算机处理能力的智能使用相结合。缓存是我们可以帮助的一种机制,尤其是在方法需要花费时间计算或在我们的应用程序中被频繁调用时。
记忆化是一种基于函数的参数记录其结果以减少连续调用中计算的方法。
除了节省 CPU 周期外,记忆化还可以通过只保留每个结果的单个实例来最小化应用程序的内存占用。当然,为了使整个机制正常工作,我们需要一个函数,当传递相同的参数时,它总是返回相同的结果。
记忆化示例
实现记忆化(memoization)的方式有很多。其中一些使用命令式编程风格,并且获取它们相对直接。在这里,我们将展示一个更适合 Scala 的方法。
让我们想象一下,我们将需要多次对字符串进行哈希处理。每次哈希处理都需要一些时间,这取决于底层算法,但如果我们存储一些结果并重复使用它们来处理重复的字符串,我们就可以在牺牲结果表的情况下节省一些计算。
我们将从以下简单内容开始:
import org.apache.commons.codec.binary.Hex
class Hasher extends Memoizer {
def md5(input: String) = {
System.out.println(s"Calling md5 for $input.")
new String(Hex.encodeHex(MessageDigest.getInstance("MD5").digest(input.getBytes)))
}
}
前面的代码是一个具有名为md5的方法的类,该方法返回我们传递给它的字符串的哈希值。我们混合了一个名为Memoizer的特质,其表示如下:
import scala.collection.mutable.Map
trait Memoizer {
def memoX, Y: (X => Y) = {
val cache = Map[X, Y]()
(x: X) => cache.getOrElseUpdate(x, f(x))
}
}
之前的特质有一个名为 memo 的方法,该方法使用可变映射根据其输入参数检索函数的结果,或者如果结果不在映射中,则调用传递给它的实际函数。此方法返回一个新的函数,实际上使用上述映射并对其结果进行记忆化。
之前提到的记忆化示例可能不是线程安全的。多个线程可能并行访问映射并导致函数被执行两次。如果需要,确保线程安全是开发者的责任。
我们使用了泛型的事实意味着我们可以实际上使用这种方法来创建任何单参数函数的记忆化版本。现在,我们可以回到我们的 Hasher 类并添加以下行:
val memoMd5 = memo(md5)
这使得 memoMd5 函数确实与 md5 函数做相同的事情,但内部使用映射尝试检索我们已计算的结果。现在,我们可以用以下方式使用我们的 Hasher:
object MemoizationExample {
def main(args: Array[String]): Unit = {
val hasher = new Hasher
System.out.println(s"MD5 for 'hello' is '${hasher.memoMd5("hello")}'.")
System.out.println(s"MD5 for 'bye' is '${hasher.memoMd5("bye")}'.")
System.out.println(s"MD5 for 'hello' is '${hasher.memoMd5("hello")}'.")
System.out.println(s"MD5 for 'bye1' is '${hasher.memoMd5("bye1")}'.")
System.out.println(s"MD5 for 'bye' is '${hasher.memoMd5("bye")}'.")
}
}
此示例的输出将如下所示:

之前的输出证明,对于相同的输入调用我们的记忆化函数实际上是从映射中检索结果,而不是再次调用处理结果的代码部分。
记忆化替代方案
我们之前展示的 memo 方法相当简洁且易于使用,但它有限制。我们只能获取具有一个参数的函数的记忆化版本(或者我们必须将多个参数表示为元组)。然而,Scalaz 库已经通过 Memo 对象支持记忆化。我们可以简单地做以下操作:
val memoMd5Scalaz: String => String = Memo.immutableHashMapMemo {
md5
}
之前的代码可以放入我们的 Hasher 类中,然后我们可以在示例中调用 memoMd5Scalaz 而不是编写额外的 Memoizer 特质。这将不需要我们编写额外的 Memoizer 特质,并且会产生与之前展示完全相同的结果。此外,Scalaz 版本在缓存方式等方面给我们提供了更多的灵活性。
摘要
在本章中,我们了解了如何将 Scala 编程语言的某些高级概念应用于解决实际软件项目中常见的问题。我们探讨了透镜设计模式,在那里我们也首次接触到了卓越的 Scalaz 库。我们看到了如何在 Scala 中实现依赖注入而无需任何额外库,以及它的用途。我们还学习了如何为我们没有修改权限的库编写扩展。最后但同样重要的是,我们研究了类型类设计模式、Scala 中的懒加载、部分函数(也称为函数柯里化)、鸭子类型、记忆化和隐式注入。到目前为止,你应该对 Scala 的语言可能性以及设计模式有了相当广泛的知识,这些可以一起用来编写出色的软件。
在本书的下一章和最后一章中,我们将更加关注 Scalaz 库,并展示其对我们已经看到的一些概念的支持。我们还将完成一个最终项目,将我们的知识整合成可以用于生产代码的东西。最后,我们将简要总结本书涵盖的内容,并提供有用的指导。
第十二章:真实世界的应用
在 Scala 的设计模式世界中,我们已经走了很长的路。我们从 Scala 的角度看到了一些经典的四人帮设计模式,以及适用于这种编程语言的具体特性。到现在为止,你应该已经拥有了足够的知识来构建高质量、可扩展、高效和优雅的应用程序。到目前为止我们所涵盖的一切,如果综合考虑,应该会对你创建的任何应用程序产生真正积极的影响。
在这本书中,我们看到的大部分内容都是从头开始编写的。这有助于理解给定的概念,但需要时间,而在现实世界的应用程序中,使用提供某些功能的库通常更受欢迎。通过简单的谷歌搜索就可以找到许多不同的库,它们几乎可以解决你所能想到的任何问题。除了可以节省大量时间之外,这也意味着我们将彻底测试过的、被许多人信任的组件整合到我们的代码中。当然,这取决于我们试图整合的库,但只要它为社区带来有用的东西,它很可能就是可靠的。话虽如此,我们本章的主要焦点将包括以下内容:
-
Scalaz 库
-
编写完整的应用程序
-
总结到目前为止我们所学的
有很多 Scala 库,有些人可能认为其他库比 Scalaz 对语言更重要。由于各种原因,也有一些替代品被创造出来。然而,我们将在这里关注 Scalaz,因为它通常被用来在应用程序中实现诸如单子、函子、和单子等概念。正如我们之前所看到的,这些概念在函数式编程中非常重要。我们还将编写一个完整的应用程序,使用我们在前几章中熟悉的一些技术和设计模式。这一章将提供一些关于应用程序应该如何构建、如何理解我们的需求以及如何正确构建解决方案的见解。最后,我们将总结在这里所学的所有内容。
使用库的原因
编写完整的软件应用程序不可避免地会将开发者带到需要实现已经存在的东西的地步。除非我们有极其具体和严格的要求,世界上没有任何库能满足这些要求,或者有很好的理由不将特定的依赖项包含在我们的项目中,否则重新发明轮子通常是一个坏主意。
人们编写库来处理软件中的各种问题。在一个像开源社区这样的社区中,库是共享的,每个人都可以使用或为其做出贡献。这带来了很多好处,主要的好处是代码变得更加成熟、经过更好的测试和更可靠。然而,有时这也使得事情变得更难——许多人会创建相同的库,这使得理解哪个是最合适的变得困难。
尽管可能存在多个相同库的实现,但在编写企业应用程序时,使用一个库是最佳选择。现在从好的库中筛选出不好的库变得容易——如果一个库是好的,许多人会使用它。如果它不好,人们会避免它。如果有多个好的库,开发者将不得不花一些时间调查哪个最适合他们的用例。
Scalaz 库
Scala 是一种函数式编程语言,因此它支持基于诸如幺半群、单子等概念的编程模式。我们已经在第十章 功能设计模式 - 深度理论中看到了这些模式,并且我们知道它们遵循的规则和结构。我们都是自己编写的,但已经存在一个库可以为我们完成这项工作——Scalaz (github.com/scalaz/scalaz)。当我们需要纯函数式数据结构时,我们会使用这个库。
在社区中与 Scalaz 具有相似知名度的另一个库是 Cats (github.com/typelevel/cats)。它们都应该能够帮助开发者实现相同的函数式编程概念。在大多数情况下,两者之间的选择基于个人偏好、本地社区文化或公司政策。
在上一章我们讨论透镜时,我们已经遇到了 Scalaz。在接下来的小节中,我们将从幺半群、函子、单子的角度来审视这个库。
Scalaz 中的幺半群
我们在第十章 功能设计模式 - 深度理论中探讨的一个概念是幺半群。我们为它们定义了一个特质和一些规则,然后展示了如何使用它们以及它们的好处。在这些示例中,我们为整数加法和乘法以及字符串连接定义了幺半群。Scalaz 已经有一个 Monoid 特质,我们可以用它来编写自己的幺半群。此外,这个特质已经实现了我们之前定义的一些幺半群:
import scalaz.Monoid
package object monoids {
// Int addition and int multiplication exist already,
// so we will show them in an example.
val stringConcatenation = new Monoid[String] {
override def zero: String = ""
override def append(f1: String, f2: => String): String = f1 + f2
}
}
在前面的代码中,我们展示了如何实现一个自定义的幺半群。
stringConcatenation 幺半群是在一个包对象中定义的。这意味着它将无需导入任何内容即可在同一个包中的任何代码中使用。我们在以下一些示例中利用了这一点。
同时也存在一个字符串连接单子,但在这里我们只是展示了如果不存在,你可以选择实现自定义单子的方法。它与之前的方法非常相似。区别仅在于操作方法(append)及其签名。然而,这只是微小的差异。
使用单子
使用 Scalaz 单子非常直接。以下是一个示例程序:
import scalaz._
import Scalaz._
object MonoidsExample {
def main(args: Array[String]): Unit = {
val numbers = List(1, 2, 3, 4, 5, 6)
System.out.println(s"The sum is: ${numbers.foldMap(identity)}")
System.out.println(s"The product (6!) is:
${numbers.foldMap(Tags.Multiplication.apply)}")
val strings = List("This is\n", "a list of\n", "strings!")
System.out.println(strings.foldMap(identity)(stringConcatenation))
}
}
我们代码中的导入确保我们可以对我们的数字列表调用 foldMap。如果我们运行这个示例,我们将得到以下输出:

通过查看输出和代码,你可以看到对于整数加法和乘法,我们使用了 Scalaz 的内置单子。sum 单子具有优先级,并且实际上是以隐式方式传递给 foldMap 的。为了使乘法正常工作,我们必须传递 Tags.Multiplication.apply 以使事情按预期工作。我们显式地传递了我们的字符串连接单子,以便使最后一个语句正确工作。
测试单子
我们知道单子必须满足一些特定的定律。我们的示例足够简单,可以清楚地看到定律实际上已经到位,但有时可能并不那么明显。在 Scalaz 中,你可以实际测试你的单子:
import org.scalacheck.Arbitrary
import org.scalatest.prop.Checkers
import org.scalatest.{FlatSpec, Matchers}
import scalaz._
import scalaz.scalacheck.ScalazProperties._
class MonoidsTest extends FlatSpec with Matchers with Checkers {
implicit def arbString(implicit ev: Arbitrary[String]):
Arbitrary[String] =
Arbitrary { ev.arbitrary.map(identity) }
"stringConcatenation monoid" should "satisfy the identity rule." in {
monoid.lawsString.check()
}
}
为了能够编译和运行前面的示例,我们需要将以下依赖项添加到我们的 pom.xml 文件中:
<dependency>
<groupId>org.scalcheck</groupId>
<artifactId>scalacheck_2.12</artifactId>
<version>${scalacheck.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.scalaz</groupId>
<artifactId>scalaz-scalacheck-binding_2.12</artifactId>
<version>${scalaz.version}</version>
<scope>test</scope>
</dependency>
相应的 build.sbt 文件需要添加以下依赖项:
"org.scalacheck" %% "scalacheck" % scalacheckVersion % "test",
"org.scalaz" %% "scalaz-scalacheck-binding" % scalazVersion % "test",
前面的代码部分只是本书附带代码示例的片段。这些依赖项将 ScalaCheck (www.scalacheck.org/)—一个基于属性的测试框架的绑定添加到我们的代码中。前面的代码将测试我们的自定义单子是否满足所有定律,如果不满足则失败。为我们的自定义类编写测试需要我们有一个 Arbitrary 实现以及在我们的测试作用域中隐式提供我们的单子。
Scalaz 中的单子
在 第十章,函数式设计模式 - 深入理论中,我们也探讨了单子。如果你还记得,我们首先必须定义一个函子特质,然后在单子特质中扩展它。与单子类似,单子也遵循一些特定的规则,这些规则必须到位。
使用单子
Scalaz 定义了相当多的不同方法,可以直接应用于我们拥有的任何单子。有多个示例展示了列表和选项。该库还有一个可以扩展的 Monad 特质,它与 Monoid 特质类似。
我们不想深入探讨展示如何使用单子的列表示例。为了使事情更有趣,让我们看看 Scalaz 中的 IO 单子,它可以以单子的方式执行 I/O。这基本上意味着我们可以描述和组合这些动作,而不必实际执行它们。这将导致更好的代码重用。让我们看一个示例:
import java.io.{PrintWriter, File}
import scala.io.Source
package object monads {
def readFile(path: String) = {
System.out.println(s"Reading file ${path}")
Source.fromFile(path).getLines()
}
def writeFile(path: String, lines: Iterator[String]) = {
System.out.println(s"Writing file ${path}")
val file = new File(path)
printToFile(file) { p => lines.foreach(p.println) }
}
private def printToFile(file: File)(writeOp: PrintWriter => Unit): Unit = {
val writer = new PrintWriter(file)
try {
writeOp(writer)
} finally {
writer.close()
}
}
}
首先,我们在一个包对象中定义了一些可以操作文件的方法。它们绝对没有什么特别之处。它们看起来和我们在第十章中做的很相似,功能设计模式 - 深入理论,当我们展示了我们的自定义IO单调子。在readFile和writeFile中,我们添加了打印语句以帮助调试并显示正在发生的事情。这将会非常有用。
我们将使我们的应用程序读取一个包含人员数据的制表符分隔文件,解析它,并将其写入文件或控制台。示例文件将包含以下内容:
Ivan 26
Maria 26
John 25
当然,我们有一个模型可以反映这个文件,它将和这样一样简单:
case class Person(name: String, age: Int)
object Person {
def fromArray(arr: Array[String]): Option[Person] =
arr match {
case Array(name, age) => Some(Person(name, age.toInt))
case _ => None
}
}
与模型一起,我们还展示了它的伴生对象。它有一个方法,根据一个字符串数组返回一个可选的Person对象。
现在是时候看看我们的应用程序并解释正在发生的事情了:
import com.ivan.nikolov.monads.model.Person
import scalaz._
import effect._
import Scalaz._
import IO._
object IOMonadExample {
def main(args: Array[String]): Unit = {
args match {
case Array(inputFile, isWriteToFile) =>
val people = {
for {
line <- readFile(inputFile)
person <- Person.fromArray(line.split("\t"))
} yield person
}.pure[IO]
System.out.println("Still haven't done any IO!")
System.out.println("About to do some...")
if (isWriteToFile.toBoolean) {
val writePeople = for {
_ <- putStrLn("Read people successfully.
Where to write them down?")
outputFile <- readLn
p <- people
_ <- writeFile(outputFile, p.map(_.toString)).pure[IO]
} yield ()
System.out.println("Writing to file using toString.")
writePeople.unsafePerformIO
} else {
System.out.println(s"Just got the following people:
${people.unsafePerformIO.toList}")
}
case _ =>
System.err.println("Please provide input file and true/false
whether to write to file.")
System.exit(-1)
}
}
}
让我们现在看看前面的列表中到底发生了什么。实际的代码在第一个模式匹配情况中。其余的是一些用于运行控制台应用程序及其参数的验证。
首先要注意的是对.pure[IO]的调用,这是针对people变量以及我们写入文件时的。这个方法接受它给出的值并将其提升到单调子中。另一件重要的事情是传递给方法的值是惰性评估的。在我们的例子中,单调子是IO单调子。
其次,我们可以看到一些对putStrLn和readLn方法的引用。它们的名字应该足以解释它们的功能。它们来自我们导入到应用程序中的scalaz.effect.IO对象。这个导入需要在我们的pom.xml文件中添加另一个依赖项:
<dependency>
<groupId>org.scalaz</groupId>
<artifactId>scalaz-effect_2.12</artifactId>
<version>${scalaz.version}</version>
</dependency>
build.sbt文件中的等效依赖项将是以下内容:
"org.scalaz" %% "scalaz-effect" % scalazVersion
putStrLn和readLn方法也返回IO单调子实例,它们只是辅助工具。
现在,因为我们的应用程序是单调的,我们使用了IO单调子,除非我们采取行动,否则什么都不会发生。要触发实际的操作,我们必须在IO实例上调用unsafePerformIO。我们已经添加了一些print语句,以证明代码按预期工作。
由于我们的应用程序有两个分支,我们在这里将进行两次运行。一个是打印,另一个是写入文件。示例输出在以下屏幕截图中:

前面的截图显示了打印到控制台的运行。我们可以看到,尽管我们较早调用了该方法,但文件日志的读取发生在我们应用程序的初始日志之后。这证明了.pure[IO]调用确实提升了我们的函数而没有评估它。
类似于前面的输出,下面的输出显示,直到我们写入输出文件名并按Enter键,什么都不会发生:

我们的示例表明,IO单子帮助我们构建计算并在最后一刻执行它。在这里,我们决定用 for 推导包围它,并在其上调用pure[IO],这样我们就可以在不使用单子的情况下实际使用读取和写入方法。在其他情况下,你可以确保从读取和写入方法返回一个IO单子,然后定义映射方法,这些方法也返回IO单子,并在 for 推导中使用它们。这看起来可能像这样:
val people = for {
lines <- readFile(inputFile).pure[IO]
p <- lines.map(i => Person("a", 1)).pure[IO]
} yield p
这个版本实际上看起来更像是单子。我们用IO单子封装了较小的实体,并将它们组合起来,可能在网上会有更多遵循这种方法的示例。这个版本实际上也保留了read方法不变。在这种情况下,唯一不同的是 for 推导的行为与通常不同。
采用哪种方法可能取决于个人偏好以及某人想要实现的目标。
之前的例子类似于我们在第十章中做的,即我们的自定义 I/O 单子,功能设计模式 - 深度理论。然而,在这里,我们可以看到比以前少得多的额外代码。
测试单子
Scalaz 还提供了测试单子的设施。测试看起来和我们所看到的单子测试没有太大区别,但在这里我们只需要使用monad.laws即可。
Scalaz 的可能性
我们只看了 Scalaz 库涵盖的一些概念。它包含的远不止这些。你可以把 Scalaz 看作是 Scala 的一个补充,使其更加函数式。它提供了各种类型类、数据类型、为标准集合的库实例添加功能、为各种标准类型提供开箱即用的功能等等。目的也各不相同——从编写纯函数式应用程序到使代码更易读。Scalaz 如此庞大,以至于我们可以为它写一本书。
似乎人们一开始觉得 Scalaz 很难用,只有后来才了解到它提供的可能性。我们鼓励你熟悉文档和库提供的所有内容。还有各种不同难度的博客文章,对于 Scalaz 的新用户来说,这些文章可能是一扇真正的启蒙之门。
编写完整的应用程序
到目前为止,在书中我们已经看到了很多例子。其中一些相当完整,而另一些只是用来展示我们所观察到的特定部分。在实际应用中,你很可能会需要结合我们所学过的多个设计模式。为了正确地做到这一点,理解需求是非常重要的。在接下来的子节中,我们将提供应用程序规范,然后我们将一步一步地通过实际编写应用程序。我们将编写的代码量会很多,所以我们将关注我们应用程序中更重要的部分,我们可能会跳过其他部分。
应用程序规范
在做任何事情之前,我们必须始终有一些规范。有时,这些规范并不完全清楚,我们的责任是确保一切足够详细,以便我们理解和实现它们。然而,在实际的软件开发过程中,我们可能会在需求不是 100%明确的情况下开始做某件事,而且事情会在项目进行中发生变化。有时可能会很沮丧,但这就是生活,我们必须应对。这也使得事情变得有趣和动态,并促使开发者思考在使用给定应用程序时可能出现的更多可能性和问题。一些开发者甚至可能拒绝开始开发一个定义不完整的应用程序。他们可能有一个合理的观点,这取决于任务的紧急程度,但通常,这种态度并不能使项目走得很远。
幸运的是,在这里我们可以提出自己的任务,所以一切都将被明确定义,我们不会在过程中改变要求。所以让我们动手做吧。
创建一个调度应用程序,可以运行针对数据库的控制台命令或 SQL 查询。用户应该能够使用配置文件调度任何命令或查询,并且他们应该能够选择粒度——每小时或每天,在特定时间。
到目前为止,我们只给出了我们想要实现的高级解释。正如我们之前所说的,有些人甚至可能拒绝进一步深入,直到他们有一个包含每个细节的完整定义。这是一个合理的观点;然而,了解不同的用例、边缘情况和可能的改进是很有趣的。产品经理的职责实际上是提出所有这些规范;但在这里,我们不是在学习如何成为一个产品经理。我们是在学习如何编写优秀的代码。所以让我们利用我们所拥有的,尝试提出一些可用、高效、可测试和可扩展的东西。
实现
让我们开始编写一些代码。不!这是错误的。在我们开始编写之前,我们应该回答所有问题,并清楚我们的目标在哪里。有些人喜欢画图,有些人喜欢写下东西,等等。每个人都有自己的技巧。让我们先尝试画出一个图。它将展示我们将使用的顶级组件,以及它们如何相互通信等等。我们的应用程序的视觉表示将非常有助于早期发现问题,并有助于我们轻松实现应用程序:

前面的图从非常高的层面展示了我们的未来应用程序。然而,它足以识别几个核心组件:
-
主应用程序
-
调度器
-
路由器
-
工作者
通过观察组件之间的连接,我们还可以看到它们将有什么依赖关系以及它们将支持哪些功能。
现在我们有了这张图,以及我们最终应该达到的视图,我们可以开始思考如何构建应用程序的结构。
要使用的库
你总是可以采取从头开始自己实现一切的方法。然而,这会减慢你的速度,并且需要大量其他并行学科的深入领域知识。我们一直在本书的示例中鼓励使用库,这里也是如此。
观察前面的图,我们可以看到我们需要执行以下操作:
-
读取应用程序配置
-
读取调度器配置文件
-
调度任务
-
交换消息
-
访问数据库
-
执行控制台命令
有时候,在确定要使用哪些库时,需要测试不同的替代方案,并查看哪一个对我们来说是有用的。我们在这里不会这样做,我们只会使用我们已经看到或了解的库。
读取应用程序配置
为了读取应用程序配置文件,我们决定使用 Typesafe config: github.com/typesafehub/config。这是一个成熟且维护良好的库,支持各种配置格式,并且相当容易使用。我们已经在 pom.xml 文件中使用了以下语句:
<dependency>
<groupId>com.typesafe</groupId>
<artifactId>config</artifactId>
<version>${typesafe.config.version}</version>
</dependency>
同样的依赖关系通过以下行添加到 build.sbt 中:
"com.typesafe" % "config" % typesafeConfigVersion
读取调度器配置
我们的应用程序将读取调度器的配置文件。我们可以强制用户使用不同的格式。我们决定选择的格式是 JSON。基于它编写我们的模型很容易,我们已经在本书的前几章中使用了解析 JSON 格式的库。我们将使用 json4s: github.com/json4s/json4s。我们已经在 pom.xml 文件中包含了以下行:
<dependency>
<groupId>org.json4s</groupId>
<artifactId>json4s-native_2.12</artifactId>
<version>${json4s.version}</version>
</dependency>
<dependency>
<groupId>org.json4s</groupId>
<artifactId>json4s-jackson_2.12</artifactId>
<version>${json4s.version}</version>
</dependency>
如果你决定使用 build.sbt,相同的依赖关系将按以下方式添加:
"org.json4s" %% "json4s-native" % json4sVersion,
"org.json4s" %% "json4s-jackson" % json4sVersion,
调度任务
有各种调度库和程序。有些更成熟,有些则不那么成熟。在这个应用程序中,我们决定使用 Akka:akka.io/。首先,它是一个值得熟悉的良好库。其次,我们已经在本书的早期章节中讨论过它。使用 Akka 可以帮助你了解如何使用响应式编程编写应用程序。我们可以通过将以下行添加到我们的 pom.xml 文件中来将 Akka 包含到我们的项目中:
<dependency>
<groupId>com.typesafe.akka</groupId>
<artifactId>akka-actor_2.12</artifactId>
<version>${akka.version}</version>
</dependency>
同样的依赖项,但在你的 build.sbt 文件中,将看起来像这样:
"com.typesafe.akka" %% "akka-actor" % akkaVersion
Akka 使用消息将任务发送给工作者,我们将看到整个流程是如何优雅地处理的。
访问数据库
在上一章中,我们已经看到了如何编写代码来访问数据库。在这里,我们将再次使用 H2 数据库引擎,因为它不需要你执行任何额外的操作来运行示例。相关的 pom.xml 条目如下:
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>1.4.197</version>
</dependency>
对于 sbt,build.sbt 文件需要包含以下内容:
"com.h2database" % "h2" % "1.4.197"
执行控制台命令
为了执行控制台命令,我们将使用 Scala 的内置功能。我们还将使用在其他项目中已经使用过的额外依赖项——一个日志库(slf4j)和测试依赖项——ScalaTest 和 Mockito。
编写一些代码
现在我们已经知道了我们将要做什么以及我们将依赖哪些库,是时候编写一些代码了。从没有其他内部依赖项的事情开始是合理的。
其中之一是应用程序配置。这是不依赖于任何东西的东西,但许多东西都依赖于它。我们决定使用 .conf 文件,因为它们简单、分层,类似于 JSON。一个示例配置文件如下所示:
job-scheduler {
config-path="/etc/scheduler/conf.d"
config-extension="json"
workers=4
db {
connection-string="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
username=""
password=""
}
}
选项清晰,任何人都可以轻松提供适合他们需求的新的配置。
提出我们的配置选项
当然,我们并没有一开始就想到这个文件。它是随着我们不断向应用程序添加功能而演变的。从小处着手,不要试图一次性考虑所有事情。
使用这种预期的格式,我们现在可以编写一个组件:
package com.ivan.nikolov.scheduler.config.app
import com.typesafe.config.ConfigFactory
trait AppConfigComponent {
val appConfigService: AppConfigService
class AppConfigService() {
//-Dconfig.resource=production.conf for overriding
private val conf = ConfigFactory.load()
private val appConf = conf.getConfig("job-scheduler")
private val db = appConf.getConfig("db")
val configPath = appConf.getString("config-path")
val configExtension = appConf.getString("config-extension")
val workers = appConf.getInt("workers")
val dbConnectionString = db.getString("connection-string")
val dbUsername = db.getString("username")
val dbPassword = db.getString("password")
}
}
上一段代码中高亮显示的行显示了读取我们的配置文件是多么简单。它从我们的 resources 文件夹中获取 application.conf。用户可以通过在启动我们的应用程序时传递 -Dconfig.resource=path.conf 来轻松地覆盖它。
我们的配置文件指定了多个属性。其中两个是 config-path 和 config-extension。我们基本上已经决定提供一个文件夹和一个扩展名,我们的程序将读取所有给定扩展名的文件,并将它们用作作业配置文件。我们已经编写了一个支持从文件夹中读取并返回所有给定扩展名文件的组件:
package com.ivan.nikolov.scheduler.io
import java.io.File
trait IOServiceComponent {
val ioService: IOService
class IOService {
def getAllFilesWithExtension(basePath: String, extension: String):
List[String] = {
val dir = new File(basePath)
if (dir.exists() && dir.isDirectory) {
dir.listFiles()
.filter(f => f.isFile &&
f.getPath.toLowerCase.endsWith(s".${extension}"))
.map {
case f => f.getAbsolutePath
}.toList
} else {
List.empty
}
}
}
}
这个组件没有做任何特别的事情。我们没有在这里使用 monads 或其他花哨的 I/O,因为我们实际上希望在这种情况下积极评估事物。
现在我们知道了如何找到所有的作业配置文件,我们需要读取它们并解析它们。我们说过它们将是 JSON 文件,这意味着我们必须定义一个模型。让我们先看看一个作业配置的例子,然后定义模型:
{
"name": "Ping Command",
"command": "ping google.com -c 10",
"frequency": "Hourly",
"type": "Console",
"time_options": {
"hours": 21,
"minutes": 10
}
}
从一个包含我们所需一切内容的文件开始,然后定义模型,而不是反过来,这相当容易。根据前面的代码,我们可以为我们的作业配置定义以下模型:
case class JobConfig(name: String, command: String, jobType: JobType, frequency: JobFrequency, timeOptions: TimeOptions)
JobType和JobFrequency将被定义为 ADT。当我们使用 json4s 时,在序列化和反序列化这些类型时需要特别注意,所以我们定义了一些额外的CustomSerializer实现:
import org.json4s.CustomSerializer
import org.json4s.JsonAST.{JNull, JString}
sealed trait JobType
case object Console extends JobType
case object Sql extends JobType
object JobTypeSerializer extends CustomSerializerJobType => jobType match {
case "Console" => Console
case "Sql" => Sql
}
case JNull => null
},
{
case jobType: JobType =>
JString(jobType.getClass.getSimpleName.replace("$", ""))
}
))
JobFrequency与前面的代码相当相似:
import org.json4s.CustomSerializer
import org.json4s.JsonAST.{JNull, JString}
sealed trait JobFrequency
case object Daily extends JobFrequency
case object Hourly extends JobFrequency
case object JobFrequencySerializer extends CustomSerializerJobFrequency => frequency match {
case "Daily" => Daily
case "Hourly" => Hourly
}
case JNull => null
},
{
case frequency: JobFrequency =>
JString(frequency.getClass.getSimpleName.replace("$", ""))
}
))
我们的JobConfig类还需要定义一个模型,如下所示:
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.concurrent.TimeUnit
import scala.concurrent.duration.{Duration, FiniteDuration}
case class TimeOptions(hours: Int, minutes: Int) {
if (hours < 0 || hours > 23) {
throw new IllegalArgumentException("Hours must be between 0 and 23:
" + hours)
} else if (minutes < 0 || minutes > 59) {
throw new IllegalArgumentException("Minutes must be between 0 and
59: " + minutes)
}
def getInitialDelay(now: LocalDateTime, frequency: JobFrequency):
FiniteDuration = {
val firstRun = now.withHour(hours).withMinute(minutes)
val isBefore = firstRun.isBefore(now)
val actualFirstRun = frequency match {
case Hourly =>
var tmp = firstRun
Iterator.continually({
tmp = tmp.plusHours(1); tmp
})
.takeWhile(d => d.isBefore(now))
.toList.lastOption.getOrElse(
if (isBefore)
firstRun
else
firstRun.minusHours(1)
).plusHours(1)
case Daily =>
var tmp = firstRun
Iterator.continually({
tmp = tmp.plusDays(1); tmp
})
.takeWhile(d => d.isBefore(now))
.toList.lastOption
.getOrElse(
if (isBefore)
firstRun
else
firstRun.minusDays(1)
).plusDays(1)
}
val secondsUntilRun = now.until(actualFirstRun, ChronoUnit.SECONDS)
Duration.create(secondsUntilRun, TimeUnit.SECONDS)
}
}
TimeOptions类在创建时有一些验证,以及一个getInitialDelay方法。这个方法的目的是在安排任务时获取初始延迟,这取决于它的选项。
在我们为作业配置定义了模型之后,我们可以编写一个服务来读取和解析配置:
import java.io.File
import com.ivan.nikolov.scheduler.config.app.AppConfigComponent
import com.ivan.nikolov.scheduler.config.job.{JobTypeSerializer, JobFrequencySerializer, JobConfig}
import com.ivan.nikolov.scheduler.io.IOServiceComponent
import com.typesafe.scalalogging.LazyLogging
import org.json4s._
import org.json4s.jackson.JsonMethods._
trait JobConfigReaderServiceComponent {
this: AppConfigComponent with IOServiceComponent =>
val jobConfigReaderService: JobConfigReaderService
class JobConfigReaderService() extends LazyLogging {
private val customSerializers = List(
JobFrequencySerializer,
JobTypeSerializer
)
implicit val formats = DefaultFormats ++ customSerializers +
JobConfig.jobConfigFieldSerializer
def readJobConfigs(): List[JobConfig] =
ioService.getAllFilesWithExtension(
appConfigService.configPath,
appConfigService.configExtension
).flatMap {
case path => try {
val config = parse(FileInput(new File(path))).extract[JobConfig]
Some(config)
} catch {
case ex: Throwable =>
logger.error("Error reading config: {}", path, ex)
None
}
}
}
}
这取决于我们在前面的代码中已经展示的两个组件。这个组件没有什么特别之处,除了高亮显示的部分。第一条语句取了我们为频率和职位类型定义的自定义序列化器。第二条语句将它们添加到默认格式中,这样 json4s 就知道如何处理它们。如果你仔细观察,你会注意到JobConfig.jobConfigFieldSerializer的调用。让我们看看它是什么样子:
import org.json4s.FieldSerializer
import org.json4s.JsonAST.JField
case class JobConfig(name: String, command: String, jobType: JobType, frequency: JobFrequency, timeOptions: TimeOptions)
object JobConfig {
val jobConfigFieldSerializer = FieldSerializerJobConfig => Some("time_options", x)
case ("jobType", x) => Some("type", x)
},
{
case JField("time_options", x) => JField("timeOptions", x)
case JField("type", x) => JField("jobType", x)
}
)
}
我们需要它,因为我们使用的 Scala 字段名与我们的 JSON 文件中的不同,json4s 需要知道如何翻译它们。
现在我们已经拥有了读取作业配置所需的所有机制,我们可以更深入地了解它们将如何用于执行我们的作业。我们之前说过,我们将使用 Akka 来实现我们的调度器和工作者。关于 Akka 有一件事是它通过消息进行通信。我们必须想出一些我们的应用程序将需要的消息:
sealed trait SchedulerMessage
case class Work(name: String, command: String, jobType: JobType)
case class Done(name: String, command: String, jobType: JobType, success: Boolean)
case class Schedule(configs: List[JobConfig])
这些消息相当详细,所以我们不要在这些上面浪费时间。让我们直接进入正题,看看我们的调度器会是什么样子:
class Master(numWorkers: Int, actorFactory: ActorFactory) extends Actor with LazyLogging {
val cancelables = ListBuffer[Cancellable]()
val router = context.actorOf(
Props(actorFactory.createWorkerActor()).withRouter(
RoundRobinPool(numWorkers)), "scheduler-master-worker-router"
)
override def receive: Receive = {
case Done(name, command, jobType, success) =>
if (success) {
logger.info("Successfully completed {} ({}).", name, command)
} else {
logger.error("Failure! Command {} ({}) returned a non-zero
result code.", name, command)
}
case Schedule(configs) =>
configs.foreach {
case config =>
val cancellable = this.context.system.scheduler.schedule(
config.timeOptions.getInitialDelay(LocalDateTime.now(),
config.frequency),
config.frequency match {
case Hourly => Duration.create(1, TimeUnit.HOURS)
case Daily => Duration.create(1, TimeUnit.DAYS)
},
router,
Work(config.name, config.command, config.jobType)
)
cancellable +: cancelables
logger.info("Scheduled: {}", config)
}
}
override def postStop(): Unit = {
cancelables.foreach(_.cancel())
}
}
我们将我们的调度器命名为Master,因为它是我们将要实现的演员系统中的主演员。我们跳过了导入以节省一些空间。在这个演员中有两个地方值得更多关注——receive方法和router。前者基本上是演员的工作方式——开发者实现这个方法,它只是一个部分定义的函数,如果我们收到了我们知道的任何消息,它就会被处理。我们的主演员可以通过创建工作项并将它们发送到路由器来安排一系列作业。另一方面,路由器只是一个轮询的工人池,所以我们将安排的每个任务都会分配给不同的工人。
所有工作进程都将运行相同的代码,如下所示:
import sys.process._
class Worker(daoService: DaoService) extends Actor with LazyLogging {
private def doWork(work: Work): Unit = {
work.jobType match {
case Console =>
val result = work.command.! // note - the ! are different methods
sender ! Done(work.name, work.command, work.jobType, result == 0)
case Sql =>
val connection = daoService.getConnection()
try {
val statement = connection.prepareStatement(work.command)
val result: List[String] = daoService.executeSelect(statement) {
case rs =>
val metadata = rs.getMetaData
val numColumns = metadata.getColumnCount
daoService.readResultSet(rs) {
case row =>
(1 to numColumns).map {
case i =>
row.getObject(i)
}.mkString("\t")
}
}
logger.info("Sql query results: ")
result.foreach(r => logger.info(r))
sender ! Done(work.name, work.command, work.jobType, true)
} finally {
connection.close()
}
}
}
override def receive: Receive = {
case w @ Work(name, command, jobType) => doWork(w)
}
}
它们只能接受一种消息类型(Work)并相应地处理它。例如,为了运行控制台任务,我们使用了内置的 Scala 功能。然后高亮行确保将消息发送回发送者(在我们的例子中是Master),如您所见,它将处理它。
我们已经看到了一些组件,它们看起来就像您使用 Scala 实现依赖注入的方式——蛋糕设计模式。然而,我们演员遵循的一般模式并不是蛋糕设计模式所设定的方式。这就是为什么我们创建了一个工厂——ActorFactory,它可以向我们的演员注入对象:
package com.ivan.nikolov.scheduler.actors
import com.ivan.nikolov.scheduler.config.app.AppConfigComponent
import com.ivan.nikolov.scheduler.dao.DaoServiceComponent
trait ActorFactory {
def createMasterActor(): Master
def createWorkerActor(): Worker
}
trait ActorFactoryComponent {
this: AppConfigComponent
with DaoServiceComponent =>
val actorFactory: ActorFactory
class ActorFactoryImpl extends ActorFactory {
override def createMasterActor(): Master =
new Master(appConfigService.workers, this)
override def createWorkerActor(): Worker = new Worker(daoService)
}
}
您可以看到前面的工厂是如何通过this引用传递并用于Master演员来创建Worker实例的。
我们已经看到了运行控制台作业所需的全部内容。现在我们必须实现支持数据库访问的功能。在这里我们将跳过数据库代码,因为它基本上与我们在第十一章,“应用所学”,所看到的相同,它基于蛋糕设计模式。我们只是跳过了一些在这里不需要的便利方法。然而,我们的调度器可以查询的数据库仍然具有相同的模式:
CREATE TABLE people(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
age INT NOT NULL
);
CREATE TABLE classes(
id INT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
);
CREATE TABLE people_classes(
person_id INT NOT NULL,
class_id INT NOT NULL,
PRIMARY KEY(person_id, class_id),
FOREIGN KEY(person_id) REFERENCES people(id) ON DELETE CASCADE ON UPDATE CASCADE,
FOREIGN KEY(class_id) REFERENCES classes(id) ON DELETE CASCADE ON UPDATE CASCADE
);
我们还添加了一些额外的数据库语句来帮助我们进行单元测试,但它们是微不足道的,不在这里列出不会影响任何事情。
将所有组件连接起来
看起来我们已经有我们应用程序将使用到的所有组件了。现在我们只需要将它们连接起来并启动。我们正在使用蛋糕设计模式进行依赖注入;所以,正如您已经看到的,我们可以创建一个包含所有需要的组件的一个组件注册表:
package com.ivan.nikolov.scheduler.registry
import com.ivan.nikolov.scheduler.actors.{ActorFactory, ActorFactoryComponent}
import com.ivan.nikolov.scheduler.config.app.AppConfigComponent
import com.ivan.nikolov.scheduler.dao._
import com.ivan.nikolov.scheduler.io.IOServiceComponent
import com.ivan.nikolov.scheduler.services.JobConfigReaderServiceComponent
object ComponentRegistry extends AppConfigComponent
with IOServiceComponent
with JobConfigReaderServiceComponent
with DatabaseServiceComponent
with MigrationComponent
with DaoServiceComponent
with ActorFactoryComponent {
override val appConfigService: ComponentRegistry.AppConfigService = new AppConfigService
override val ioService: ComponentRegistry.IOService = new IOService
override val jobConfigReaderService: ComponentRegistry.JobConfigReaderService = new JobConfigReaderService
override val databaseService: DatabaseService = new H2DatabaseService
override val migrationService: ComponentRegistry.MigrationService = new MigrationService
override val daoService: DaoService = new DaoServiceImpl
override val actorFactory: ActorFactory = new ActorFactoryImpl
}
现在我们有了组件注册表,我们可以使用它并编写主应用程序类:
package com.ivan.nikolov.scheduler
import akka.actor.{Props, ActorSystem}
import com.ivan.nikolov.scheduler.actors.messages.Schedule
import com.typesafe.scalalogging.LazyLogging
import scala.concurrent.Await
import scala.concurrent.duration.Duration
object Scheduler extends LazyLogging {
import com.ivan.nikolov.scheduler.registry.ComponentRegistry._
def main(args: Array[String]): Unit = {
logger.info("Running migrations before doing anything else.")
migrationService.runMigrations()
logger.info("Migrations done!")
val system = ActorSystem("scheduler")
val master = system.actorOf(
Props(actorFactory.createMasterActor()),
"scheduler-master"
)
sys.addShutdownHook({
logger.info("Awaiting actor system termination.")
// not great...
Await.result(system.terminate(), Duration.Inf)
logger.info("Actor system terminated. Bye!")
})
master ! Schedule(jobConfigReaderService.readJobConfigs())
logger.info("Started! Use CTRL+C to exit.")
}
}
我们的应用程序有一些非常简单的 Akka 连接,然后当执行高亮行时,一切都会被触发。我们向主节点发送一个包含所有作业配置的Schedule消息,然后它将根据它们的定义定期调度它们运行。
最终结果
在编写所有这些代码之后,我们将在我们的 IDE 中得到以下树状结构:

您可以看到我们的代码中也包含单元测试。我们将在下一个子节中花一些时间来讨论它们。
测试我们的应用程序
测试是每个应用程序的重要部分。使用 TDD(测试驱动开发)非常好,因为我们可以在编写和测试应用程序的同时进行,而不是回到已经完成的事情上。我们在编写应用程序时使用了这种方法,但为了更好地解释事情,我们将代码和测试分开。
单元测试
正如您之前所见,依赖于蛋糕设计模式的应用程序测试是简单的。我们定义了以下测试环境:
package com.ivan.nikolov.scheduler
import com.ivan.nikolov.scheduler.actors.{ActorFactory, ActorFactoryComponent}
import com.ivan.nikolov.scheduler.config.app.AppConfigComponent
import com.ivan.nikolov.scheduler.dao._
import com.ivan.nikolov.scheduler.io.IOServiceComponent
import com.ivan.nikolov.scheduler.services.JobConfigReaderServiceComponent
import org.mockito.Mockito._
import org.scalatest.mockito.MockitoSugar
trait TestEnvironment
extends AppConfigComponent
with IOServiceComponent
with JobConfigReaderServiceComponent
with DatabaseServiceComponent
with MigrationComponent
with DaoServiceComponent
with ActorFactoryComponent
with MockitoSugar {
// use the test configuration file.
override val appConfigService: AppConfigService = spy(new AppConfigService)
// override the path here to use the test resources.
when(appConfigService.configPath).thenReturn(this.getClass.getResource("/").getPath)
override val ioService: IOService = mock[IOService]
override val jobConfigReaderService: JobConfigReaderService = mock[JobConfigReaderService]
override val databaseService: DatabaseService = mock[DatabaseService]
override val migrationService: MigrationService = mock[MigrationService]
override val daoService: DaoService = mock[DaoService]
override val actorFactory: ActorFactory = mock[ActorFactory]
}
在进行测试时,我们使用实际的应用程序配置文件,而不是使用模拟,并且可以使用测试资源文件夹中的任何文件。我们已经为TimeOptions类编写了相当广泛的测试,特别是计算初始延迟的部分。还有读取作业配置文件的测试以及数据库访问测试。所有这些都可以在本书提供的项目中看到。
应用程序测试
毫无疑问,大家最想看到的部分是我们真正启动应用程序的地方。然而,因为它是一个调度器,我们首先需要准备一些配置。我们将使用以下应用程序配置文件:
job-scheduler {
config-path="/etc/scheduler/conf.d"
config-extension="json"
workers=4
db {
connection-string="jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
username=""
password=""
}
}
我们将命名我们的文件为production.conf,并将其放置在/etc/scheduler/conf.d目录下。现在我们需要创建一些实际的作业配置。我们必须将它们放在config-path属性指向的位置:
// ping.json
{
"name": "Ping Command",
"command": "ping google.com -c 10",
"frequency": "Hourly",
"type": "Console",
"time_options": {
"hours": 21,
"minutes": 10
}
}
// ping1.json
{
"name": "Ping1 Command",
"command": "ping facebook.com -c 10",
"frequency": "Hourly",
"type": "Console",
"time_options": {
"hours": 21,
"minutes": 15
}
}
// query.json
{
"name": "See all people",
"command": "SELECT * FROM people",
"frequency": "Hourly",
"type": "Sql",
"time_options": {
"hours": 21,
"minutes": 5
}
}
最后一个作业应该没有问题在任何操作系统上运行。例如,如果你使用 Windows,可能需要更改前两个作业中的命令。此外,时间选项可能需要根据应用程序运行的时间和是否希望立即看到实际工作的证据而进行更改。
如果我们现在用这些配置运行应用程序,我们将得到以下输出:

我们可以离开应用程序,它将根据任务的调度方式,每小时或每天持续执行我们设定的任务。当然,我们还可以添加更多有意义的作业,分配更多工作者,更改其他配置等等。
我们应用程序的未来
我们在这本书中学到了许多技术和概念——依赖注入、工厂设计模式、ADTs 以及 Akka 库,这些都可以用来实现观察者设计模式。这是一个完全设计的应用程序,旨在可测试和可扩展。
我们可以轻松地为作业的执行计划添加更多粒度,包括不同类型的任务,还可以让任务相互触发,不同的路由机制等等。我们展示了在这本书中我们学到了许多有用的概念,现在我们可以将这些知识应用到实践中,以创建出优秀的程序。
摘要
在这里,我们已经完成了 Scala 设计模式的旅程。正如你所知,设计模式的存在是为了应对语言的某些限制。它们还帮助我们以易于更改、使用、测试和维护的方式组织代码。Scala 是一种极其丰富的语言,我们关注了一些使其能够实现其他语言可能需要额外努力和知识才能完成的事情的功能。
我们从 Scala 的角度审视了不同的“四人帮”(Gang of Four)设计模式——创建型、结构型和行为型设计模式。我们看到了其中一些在函数式语言中甚至不适用,而另一些则可以有不同的处理方式。我们还看到,一些设计模式仍然有效,并且了解它们对于任何开发者来说都至关重要。
我们在谈论 Scala 时,不可避免地要处理诸如单子(monoids)和单态(monads)这样的概念。起初,它们可能显得相当可怕和抽象,以至于让人望而却步。因此,我们花了一些时间来了解它们,并展示了它们的价值。它们可以用来以纯函数式方式编写强大的应用程序。它们可以用来抽象和重用功能。通过最小化枯燥的理论,并专注于可理解的例子,我们希望使它们对那些没有深厚数学背景的你们来说更加易于接近和使用。
利用 Scala 的丰富特性,打开了另一组大量设计模式的大门。我们花了一些时间研究那些仅仅因为 Scala 的工作方式和它提供的不同特性才可能实现的设计模式。
在整本书中,我们试图提供有意义的例子,这些例子可以作为参考,以找到我们在这里学到的技术的特定模式和应用程序。在本章的最后,我们甚至实现了一个完整的应用程序。在许多场合,我们试图展示不同的设计模式如何结合使用。当然,在某些情况下,概念本身可能相当复杂,因此我们简化了例子。
我们提供了一些关于何时使用某些设计模式以及何时避免它们的建议。这些观点在关注哪些细节方面应该对你非常有帮助。
在整本书中,我们鼓励使用库。特别是在最后几章,你们已经接触到了相当多的有趣库,这些库可以轻松地添加到你们的工具箱中。我们也希望激发了对在尝试新事物之前总是进行检查的兴趣和习惯。
最后,本书中找到的所有示例也可以在github.com/nikolovivan/scala-design-patterns上找到。


浙公网安备 33010602011771号