This the multi-page printable view of this section. Click here to print.
Scala 模式
- 1: CH01-模式分类
- 2: CH02-特质与混入组合
- 3: CH03-统一化
- 4: CH04-抽象与自类型
- 5: CH05-AOP 与组件
- 6: CH06-创建型模式
- 7: CH06-1-工厂方法
- 8: CH06-2-抽象工厂
- 9: CH06-3-其他工厂模式
- 10: CH06-4-懒初始化
- 11: CH06-5-单例模式
- 12: CH06-6-构建器模式
- 13: CH06-7-原型模式
- 14: CH07-结构型模式
- 15: CH07-1-适配器模式
- 16: CH07-2-装饰器模式
- 17: CH07-3-桥接模式
- 18: CH07-4-组合模式
- 19: CH07-5-外观模式
- 20: CH07-6-享元模式
- 21: CH07-7-代理模式
- 22: CH08-行为型模式-1
- 23: CH09-行为型模式-2
- 24: CH10-函数式模式理论
- 25: CH11-函数式模式应用
- 26: CH12-实际应用
1 - CH01-模式分类
在计算机编程世界,有多种不同的方式来构建一个解决方案以用于解决某些问题。然而,有些人会考虑是否存在一种正确的方式来实现特定的任务。答案是肯定的;通常总是会存在正确的方式,但是在软件开发领域,通常会存在多种方式来实现一个任务。存在一些将程序员引导向正确方案的因素,基于此,人们往往也能得到预期的结果。这些因素可以包含多种事物—实际使用的语言,算法,可执行文件类型,输出格式,代码结构等等。本书中我们将会选择使用 Scala 语言。当然,有很多种方式来使用 Scala,不过这里我们仅会关注“设计模式”部分。
本章中,我们将会介绍什么是设计模式以及他们存在的意义。我们将会贯穿多种不同类型的设计模式。本书的目的在于提供有用的实例,以帮助你在学习过程中能够轻松运行他们。因此,这里会提供一些环境搭建的细节。下面是我们要涉及的几个首要话题:
- 什么是一个设计模式以及它们为什么会存在?
- 主要类型的设计模式及其特性
- 选择正确的设计模式
- 搭建开发环境
最后一点可能与设计模式本书关联不大。不过正确的构建项目总归是一个好的想法,同时也能使未来的工作更加轻松。
设计模式
在我们深入 Scala 设计模式之前,需要解释一下它们实际的含义、存在的意义,以及为什么值得去精通它们。
软件是一个宽泛的主题,人们可以用它们来做数不尽的事。乍一看,所做的这些事的是完全不同的—游戏、网站、移动手机应用或用于不同产业的特殊系统。然而软件的构建构过程却有很多相似性。无论需要创建的软件为哪种类型,通常人们都需要解决相似的问题。比如,网站和游戏都需要访问数据库。同时贯穿整个时间,开发者们通过经验来学习如何为不同的任务来组织他们的代码。
设计模式的一个正式定义能够让你理解其在构件优雅高效关键中的实际价值。
The formal definition for design patterns
一个设计模式是一个用于解决反复出现的问题的可复用解决方案。它并非一段已完成的代码片段,而是作为一个模板,用于帮助解决一些特殊问题或一些列问题。
设计模式是软件开发社区通过很长的时间总结出的最佳实践。它们被认为能够帮助编写高效、可读、易测试、易扩展的代码。在某些情况下,它们可能是某些表达不够优雅的编程语言(比如 Java..)的目标实现的结果。这意味着大多特性丰富的语言可能甚至都不需要一个设计模式。Scala 就属于这种富特性语言,有些情况下,它会让一些设计模式的应用显得过时或者更加简洁。我们将会在本书中看到它是如何做到这一点的。
一个编程语言中的功能缺陷或优势同样决定了其是否能够实现相比其他语言更多的设计模式。
Scala 和设计模式
Scala 是一个混合型语言,它同时结合了面向对象和函数式编程。这不仅让他能够拥有一些经典的面向对象设计模式,同时提供了多种不同的方式来利用其特性以编写高效、可读、易测试、易扩展的代码。这种混合的性质可以使用更加清晰的技术让一些经典的设计模式变得过时,或者让不能成为可能。
设计模式的需要及其收益
每个人都需要设计模式并在编写代码之前需要深入了解他们。像我们之前提到的,它们能够帮助编写高效、可读、易测试、易扩展的代码。所有这些特性对于行业中的公司来说都是非常重要的。
尽管有些情况下更需要的是快速编写一个原型并落地,然而更常见的情况是软件的一部分代码需要不断的演变。可能你已经拥有一些对已存在的烂代码扩展经验,但无论如何,这是一项挑战并且会花费很长时间,甚至有时会感觉重写会相对简单。此外,这也可能会给系统引入新的 Bug。
代码可读性有些时候也是需要引起重视的。当然,尽管有些人使用了设计模式但仍然会让他的代码难以理解,但通常来说设计模式会带来一些帮助。大型系统通常是由很多人协同开发,每个人也必须知道到底发生了什么。同时,新加入到团队中的人员也能更简单快速的融入进来,如果他们是基于程序中写的很好的部分工作的话。
可测试性有助于在编写或扩展代码时避免开发者引入 Bug。有些情况下,代码可能会很糟或甚至没法测试。设计模式一般也能很好的解决这些问题。
虽然效率通常与算法相关,设计模式同样也能对其造成影响。比如一个对象,需要很长时间才能完成初始化,并且其实例会被应用的各个部分使用,但这是可以使用单例来代替的。你可以在本书的后续章节中看到更多正确的实例。
模式分类
软件开发是一个非常宽泛的话题,这也使得大量的事情可以通过编程来完成。每件事都是不同的,这导致对软件质量的需求也多种多样。所有这些事实导致了大量不同的设计模式被发明。而这又进一步被现有的具有不同功能和表现力水平的编程语言所促进。
本书关注于从 Scala 的视角来看设计模式。如前所述,Scala 是一个混合型语言。这导致一些非常有名的设计模式不再被需要—比如“null 对象”设计模式,可以简单的使用 Scala 中的Option
替代。其他的设计模式同过不同的方式也变得可行—装饰器模式可以使用叠加特质实现。最终,一些新的设计模式变得可用,他们尤其适用于 Scala 这种编程语言—比如蛋糕模式、皮条客等。我们将关注所有这些模式,并通过 Scala 的丰富特性使我们的代码更加简洁,从而使这些模式变得更加清晰。
尽管有多种不同的设计模式,但他们都可以被划分为不同的主要分类:
- 创建型
- 结构型
- 行为型
- 函数式
- Scala 特有设计模式
一些特定于 Scala 的模式可以被归类到最后一个组。他们均可以作为现有模式的补充或替代。他们是 Scala 独有的,并且利用了一些其他语言中没有的高级语言特性或简单特性的优势。
前三个分类包含了著名的 Gang of Four 设计模式。每本设计模式的书都会涵盖这些,因此我们也不例外。而剩余的部分,尽管它们仍能归类于前三类,但都特定于 Scala 和函数式编程语言。在后续的一些章节中,我们将解释这些分类的主要特点并介绍它们实际所属的设计模式。
创建型设计模式
创建型设计模式用于处理对象的创建机制。它们的目的是在当前的场景中以合适的方式创建对象实例。如果不使用这些模式,将会带来更多不必要的复杂性同时也需要更多的知识。创建型模式背后的思想主要有一下几点:
- 具体类的知识封装
- 隐藏实际的创建过程和对象组合的细节
本书中我们将关注如下几种创建型模式:
- 抽象工厂
- 工厂方法
- 惰性初始化
- 单例
- 对象池
- 建造者
- 原型
下面的一些小节给出了这些模式的简要定义,并会在后续章节中进行深入分析。
抽象工厂
用于封装那些拥有通用主题的单个工厂。使用时,开发者为抽象工厂创建一个具体实现,像工厂模式的使用方式一样使用其方法来创建对象。可以认为是用来帮助创建类的又一层抽象(类-工程-抽象工厂)。
工厂方法
创建实例时无需显式指定实例所拥有的具体类—它会基于工厂在运行时决定。这些工厂可能包括操作系统、不同的数据类型或输入参数。它使开发者能够调用一个方法而不是调用一个具体的构造器。
惰性初始化
用于延迟一个对象的创建或求值过程,直到第一次需要使用它的时候。在 Scala 中它会变得更加简单,而不像 Java 这种 OO 语言。
单例
限制一个类只能创建一个实例。如果应用中的多个类都要使用这样的实例,将会返回同一个实例给所有使用者。这种模式可以通过 Scala 的语言特性轻松实现。
对象池
创建一个已经初始化并等待使用的对象池。一旦有人需要使用池中的对象则会立即返回,使用完成后手动或自动放回到池中。最常用的场景是数据库连接,创建代价较为昂贵,通常是一次创建后服务于不同的客户端请求。
建造者
用于那些拥有大量构造器参数的对象,否则开发者需要为多种不同的创建方式编写多种不同的辅助构造器。这不同于工厂模式,其目的是支持多态。很多种现代化的库都运用了这种模式,Scala 中实现这种模式也非常简便。
原型
这种模式支持在已有的对象实例使用clone()
方法来创建对象。用于某些创建起来非常昂贵的特殊资源,或者不愿使用抽象工厂模式的时候。
结构型设计模式
用于在多种不同的实体间建立联系以构造大型的结构。定义了各个组件的组织方式,以灵活的使多个模块在一个大型系统中协作。结构型模式的主要特性如下:
- 将多个对象的实现结合的组合使用
- 通过多种不同的组件构建大型系统并维持一个高级别的灵活性
本书中将关注如下几种结构型模式:
- 适配器
- 装饰器
- 桥接
- 组合
- 门面
- 享元
- 代理
适配器
该模式支持一个接口可以使用一个已存在的类的接口。接入一个客户端希望你的类暴露一个doWork()
方法。你可能在其他类中已经有这样的实现了,但是方法的调用方式不同而且不兼容。或许需要更多的参数。或者是一些开发者不拥有修改权限的库。这时适配器可以用于包装这些功能并暴露需要的方法。适配器用于集成已存在的组件。在 Scala 中,可以通过隐式类来轻松实现适配器。
装饰器
适配器是子类化的轻量级替代方案。它支持开发者扩展一个对象的功能而不影响用一个类的其他实例。将已被扩展的类的对象包装到另一个扩展了该类的对象,并重写需要被修改功能的方法。这种模式在 Scala 中可以通过特质叠加来实现。
桥接
用于将一个抽象与其实现之间解耦,从而使各自的变化独立。多用于当一个类和它的功能会经常变化。这种模式使我们想起适配器,但不同在于适配器用于一些已存在的而且无法改变的东西,而桥接则用在构建的时候。桥接可以让我们避免向客户端暴露多种不同的具体类。当我们深入该主题的时候你可能会理解的更清晰,不过现在,假如我们想拥有一个FlieReader
类,它要支持多种不同的平台。使用适配器可以避免该类需要为不同的平台提供不同的实现类。在 Scala 中,我们可以使用自类型(self-type)来实现桥接。
组合
组合是一个分区设计模式,表示一组对象能够被当做一个单独的对象处理。它支持开发者能够统一处理单个对象或组合,并使用不复杂的代码来构建复杂的层次结构。比如一个树结构,一个节点可以包含另一个节点,等等。
门面
门面的目的在于为客户端提供一个简单的接口来隐藏系统的复杂性和实现细节。这使代码更具可读性并使外部代码减少依赖。它像是系统的一个简化过的包装器,当然也可以与前面提到的其他模式结合使用。
享元
该模式通过在系统中共享对象来优化内存使用。对象需要包含尽可能多的数据。比如一个单词处理器,每个字符的图形表示与其他相同的字符进行共享,本地信息将会仅包含字符的位置,被在内部保存。
运用共享技术有效地支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于享元模式要求能够共享的对象必须是细粒度对象,因此它又称为轻量级模式。
代理
代理模式支持开发者对其他对象进行包装以提供一个接口。同时能够提供额外的功能,比如安全性或线程安全。该模式可以与享元模式结合使用,那些共享对象的引用可以包装在代理对象的内部。
行为类设计模式
基于对象之间特殊的相互作用方式增加他们之间的灵活性。创建类模式大多描述对象的创建,结构类模式大多用于描述静态结构,而行为模式则描述一个过程或流向。它使这个流程更简单且更易理解。
行为类模式的主要特性:
- 描述一个过程或流向
- 流程更加简单且易于理解
- 完成那些由对象难于或不可能完成的任务
本书将关注的行为类模式:
- 值对象
- Null 对象
- 策略
- 命令
- 职责链
- 解释器
- 迭代器
- 中介
- 备忘
- 管擦者
- 状态
- 模板方法
- 访问者
值对象
值对象是不可变的并且他们的相等性并不基于他们的标示符(identity),而是基于他们的字段相等性。可以被用作数据传输对象、颜色、货币金额、数字等等。他们的不变型使其多用于多线程编程。Scala 中鼓励不可变性,值对象也作为自然存在的事情(源生提供不可变型对象)。
Null 对象
Null 对象表示值并不存在并定义一个中性的行为。该技术使对 null 引用的检查不再必要并且使代码更简明。Scala 添加了一个可选值的概念,即 Option,可以替代该模式。
策略
该模式支持在运行时选择算法。它定义了一类能够替换的封装算法,给客户端提供一个通用的接口。算法的选择取决于应用运行时能够决定的因素。在 Scala 中,可以向方法传递一个函数作为参数,基于该函数将会执行一个不同的动作。
命令
将一个稍后会触发的动作的信息保存为一个对象,信息中包含了:
- 方法名
- 方法的拥有者
- 参数值
然后客户端来决定调用者在何时执行哪个命令。该模式在 Scala 中可以通过传名参数这种语言特性实现。
职责链
请求的发送者与其接收者进行解耦。这种方式能够使多个对象来接收请求并且保持逻辑分离。接收者组成的链条对请求进行传递,尝试处理,否则传递给下一个接收者。可以变化为一个处理者可以将请求同时分发给多个不同的处理者。
类似于函数组合,在 Scala 中通过叠加特质实现。
解释器
该模式基于语言是否能够通过其静态语法来描述一个著名领域。它定义了每个语法规则的类以解释给定语言中的句子。这些类很可能代表层次,因为语法通常也是分层的。解释器可以用在不同的解析器,比如 SQL 或其他语言。
迭代器
用于当一个迭代器穿过容器并访问其对象时。用于将容器与执行在容器上的算法解耦。迭代器应该提供的是对聚合对象的元素的顺序访问,而不必暴露被迭代集合的内部表示。
中介
封装一个应用中不同类之间的通信。对象通过中介进行通信而不是直接相互作用,以减少他们之间的依赖并解耦。
备忘
提供将一个对象回滚到上一个状态的能力。通过三个对象实现:originator, caretaker, memento。originator 是初始状态,caretaker 会修改 originator,memento 包含 originator 返回的状态。originator 知道如何处理一个 memento 以便存储它的上一个状态。
观察者
支持创建一个发布、订阅系统。有一个特殊的对象称为主题,当它的状态发生任何改变时会通知所有的观察者。在 GUI 工具包中处理事件时比较常用。与响应式编程也相关,比如 Akka。
状态
类似于策略模式,使用一个状态对象封装同一个对象的不同行为。它通过避免大量的条件语句来提高代码的可读性和易维护性。
模板方法
在一个方法中定义一个算法的纲要,然后传递一些实际的步骤到子类。支持开发者在不修改算法结构的前提下修改其中的一些步骤。可以是一个抽象类中的方法并调用其他的抽象方法,这些方法在子类中进行实现。
访问者
表示一个执行在对象结构的元素上的操作。支持开发者在不修改原始类的情况下定义一个新的操作。
Scala 中通过传递函数到方法来实现。
函数式模式
我们将从 Scala 的视角来研究前面所提到的设计模式。这意味着它们会看起来与在其他语言中有所不同,但他们并没有专门的被设计为用于函数式编程。函数式编程比面向对象编程拥有更强的表现力。它拥有其自己的设计模式来帮助开发者活的更轻松。我们将会关注:
- Monoids(幺半群、幺元)
- Monads(单子)
- Functors(函子)
在我们了解过 Scala 的一些函数式编程概念之后将会贯穿这些,我们将会提高一些 Scala 世界中比较有趣的设计模式。
Monoids
Monoid 是一个来自数学的概念。我们会在本书的后续部分深入分析需要理解它的更多理论。现在则只需要记住“Monoid 是一个拥有可结合的二元运算和一个单位元的代数结构”。一些你需要知道的关键字:
- 可结合的二元运算:(a+b)+c = a+(b+c)
- 单位元:a+i = i+a = a,这里 i 则为单位元,比如整数中的 0,字符串中的空串““
重要的是 Monoid 为我们提供了一种可能性:使用通用的方式来处理不同类型的值。它支持我们将成对的(两两的)操作适用于序列;结合律让并行化称为可能,单位元元素让我们知道如何处理空列表。Monoid 让我们更易于描述和实现集合。
Monads
在函数式编程中,Monad 是用于将计算描述为步骤序列的结构。Monad 可用于构建流水线,在一切都是不可变的语言中以更清洁的方式添加带有副作用的操作,实现组合。定义可能听起来晦涩难懂,但是通过几个句子来解释 Monad 恐怕是难以实现的。在本书的后续部分,我们会继续关注它并尝试不使用复杂的数学理论来使其更加清晰。我们将会尝试展示为什么 Monad 有用,他们又带来了什么帮助,直到开发者能够很好的理解他们。
Functors
Functor 来自范畴论,和 Monad 一样,正确的解释它恐怕要花点时间。我们将会在本书后续部分继续关注它,现在你可以理解为 Functor 能够支持我们将类型A => B
的一个函数提升为类型F[A] => F[B]
的函数。
Scala 特定模式
这个分类中的模式可以被归类到之前提到的那些分类中。然而,它们是 Scala 所特有的并利用了一些本书中将会关注的语言特性,因此我们决定将它们放在一个单独的分类中。
这里我们将会关注如下模式:
- 透镜(lens)模式
- 蛋糕模式
- 皮条客(Pimp my library)模式
- 叠加特质
- 类型类
- 惰性求值
- 偏函数
- 隐式注入
- 鸭子类型
- 记忆(Memoization)模式
透镜模式
Scala 提倡不可变性。对象的不可变可以保证更少的错误。但是有时候可变性在所难免,透镜模式使我们更好的实现可变性。
蛋糕模式
蛋糕模式是 Scala 中实现依赖注入的方式。常被用于实际的应用,有多中库来帮助开发者实现这种模式。Scala 提供了一种使用语言特性实现这种模式的方式,也就是蛋糕模式。
皮条客模式
很多时候开发者需要使用非常泛型的库。但是有时我们需要更接近于我们场景的东西。这种模式支持为这些不可变的库添加扩展方法。或者用于我们自己的库,并且能够提供更好的可读性。
叠加特质
以 Scala 的方式实现装饰器模式。也可以用于组合功能,基于一些 Scala 提倡的特性。
类型类
通过定义一个行为并且能够被指定类型的类的所有成员所支持,来编写泛型代码。比如,所有的数字都必须支持加法和减法操作。
惰性求值
有时需要处理一些耗时或代价昂贵的操作。但是有些时候这些操作最终并不需要。该技术使一些操作只有在需要的时候才会执行。可以用于应用优化。
偏函数
数学和函数式编程非常近似。这些函数被定义为只能处理接收类型的一个子集。
隐式(implicit)注入
基于 Scala 的隐式功能。当对象被需要时进行隐式注入,只要他们在当前作用域。
鸭子类型
Scala 提供的类似于动态语言的特性。
记忆模式
根据输入,通过记住函数结果来提供优化。
如何选择
我们已经见到相当多的设计模式了。在很多场景下他们可以组合使用。不幸的是,对于如何选择概念来设计我们的代码并没有确定的回答。有多种因素会影响最后的决定,你也要问自己下面所列的问题:
- 这段代码是趋于稳定还是将会被修改
- 是否需要动态决定算法的选择
- 代码是否会被他人使用
- 是否有约定的接口
- 计划使用哪种库
- 是否有性能需求或限制
这绝不是一个详尽的问题清单。有大量的因素会指示我们如何来构建系统的决定。然而,真正重要的是要拥有一个清晰的说明(clear specification),如果有些东西看起来没遗漏了,它总是要在第一时间被检查。
在剩余的章节中,我们将会对是否要使用一种设计模式给出一些特定的建议。它们将会帮助你在开始编写代码之前去问正确的问题并作出正确的决定。
总结
2 - CH02-特质与混入组合
在专研一些实际的设计模式之前,我们需要确保读者对 Scala 中的一些概念都很了解。这些概念中的很多一部分都会在后续用于实现实际的设计模式,同时能够意识到这些概念的能力、限制以及陷阱也是编写正确、高效代码的关键因素。尽管有些概念并不被认为是“官方上”的设计模式,但仍然可以使用它们来编写好的软件。甚至有些场景中,基于 Scala 的丰富特性,一些概念甚至可以使用语言特性来代替一种设计模式。最后,向我们前面提到的,设计模式的存在是因为一种语言的特性缺失,因为特性的不够丰富而不能完成一些需要的任务。
在第一个主题中我们将会了解特质与混入(mixin)组合。他们为开发者提供了一种可能来共享已实现的功能或在应用中为类定义接口。很多由特质与混入组合为开发者带来的可能性对于本书后续要实现的一些设计模式都是很有帮助的。本章将主要关注一下几个主题:
- 特质
- 混入组合
- 多重继承
- 线性化
- 测试特质
- 特质与类
特质
大家或许对 Scala 中的特质持有不同的看法。他们不仅可以被视作类似其他语言中的接口,同时可以被当做一个拥有无参构造器的类。
特质参数
Scala 编程语言充满活力,其发展很快。其中一个将会在 2.12 版本中探讨的趋势是特质参数。更多信息请查看 http://www.scala-lang.org/news/roadmap-next/。
在下面的几个小节中,我们将会以不同的视角来了解特质并为你提供一些如何使用它们的想法。
特质作为接口
特质可以看做是其他语言中的接口,比如 Java。但是支持开发者实现其中的一部分或全部方法。每当特质中拥有一些代码,该特质则被称为 mixin(混入、混合)。让我们看一下下面这个例子:
trait Alarm {
def trigger(): String
}
这里Alarm
是一个接口。仅拥有一个trigger
方法,不拥有任何实现,如果它被混入一个非抽象的类则必须提供一个该方法的实现。
让我们看一下另一个特质的例子:
trait Notifier {
val notificationMessage: String
def printNotification(): Unit = {
println(notificationMessage)
}
def clear(): Unit
}
这个Notifier
特质拥有一个已实现的方法,而notificationMessage
的值和clear
则需要由混入它的类来提供实现。此外,特质可以要求一个类必须拥有一个指定的变量,这类似于其他语言中的抽象类。
混入带有变量的特质
像上面提到的,特质可以要求类拥有一个指定的变量。一个有趣的用法是我们可以传递一个变量到类的构造器中。这样则可以满足该特质的需求:
class NotifierImpl(val notificationMessage:String) extends Notifier {
override def clear(): Unit = println(""cleared)
}
这里唯一的必要是在类的定义中该变量必须使用相同的名字并且前面要使用val
关键字来修饰。如果上面的代码中我们不适用val
关键字作为前缀,编译器则仍然会要求我们实现该特质。这种情况下,我们不得不为类的参数使用一个不同的名字,并在类体中拥有一个override val notificationMessage
赋值。
这种行为的原因很简单:如果我们显式的使用val
(或 var,根据特质要求),编译器会创建一个字段,并拥有与参数相同作用域的 getter。如果我们仅拥有参数,仅当该参数被用作构造器的作用域之外时(比如用在该类的方法中)才会创建一个字段和内部的 getter。更完备的是,样例类(case class)会自动在参数之前追加val
关键字。因此,如果我们使用这个val
关键字,我们将会自动获得一个与该参数同名的字段、相同的作用域,同时将会自动覆写特质中对我们的要求。
特质作为类
特质同样可以以类的视角来看待。这种情况下,他们需要实现其所有的方法,同时仅拥有一个不接收任何参数的构造器。考虑下面的代码:
trait Beeper {
def beep(times: Int): Unit = {
assert(times >= 0)
1 to times foreach(i => println(s"Beep number: $i"))
}
}
现在我们实际上可以直接实例化Beeper
并调用其方法。比如下面的例子:
object BeeperRunner extends App{
val TIMES = 10
val beeper = new Beeper{} // <= 实例化一个特质
beeper.beep(TIMES)
}
像预期一样,运行代码后我们将看到如下输出:
Beep number: 1
Beep number: 2
Beep number: 3
....
扩展类
特质同样可以用来扩展类。让我们看一下下面的例子:
abstract class Connector {
def connect()
def close()
}
trait ConnectorWithHelper extends Connector {
def findDriver(): Unit = {
println("Find driver called.")
}
}
class PgSqlConnector extends ConnectorWithHelper {
override def connect(): Unit = {
println("Connected...")
}
override def close(): Unit = {
println("Closed...")
}
}
和预期的一样,PgSqlConnector
会被抽象类约束来实现其抽象方法。你可能会推测,我们可以用一些别的特质来扩展这些类然后再将他们(同时)进行混入。在 Scala 中,我们会对一些情况稍加限制,在后续章节中我们研究组合的时候会来看一下这么做会对我们产生哪些影响。
扩展特质
特质可以被互相扩展。看一下如下例子:
trait Ping {
def ping():Unit = {
println("ping")
}
}
tring Pong {
def pong():Unit = {
println("pong")
}
}
trait PingPong extends Ping with Pong {
def pingPong():Unit = {
ping()
pong()
}
}
object Runner extends App with PingPong {
pingPong()
}
上面这个例子是比较简单的,实际上也可以让
Runner
分别来扩展两个特质。扩展特质可以很好的用来实现特质堆叠这种设计模式,本书的后续部分中我们将会探讨这个话题。
混入组合
Scala 支持开发者在一个类中扩展多个特质。这相对于那些不允许同时扩展多个类的语言增加了多重继承的可能性并且能剩下很多编写代码的精力。在这个子主题中,我们将会展示如果将多个特质混入到一个指定的类,或者在编写代码的时候如何创建一个带有某些指定功能的匿名类。
混入特质
首先,我们先修改一下上个例子的代码。这个改动很小,它将会展示特质是如何被混入的:
object MixinRunner extends App with Ping with Pong {
ping()
pong()
}
从上面的代码中可以发现,我们可以将多个特质添加到同一个类中。上面代码中扩展的App
特质主要是为了使用其自带的main
方法。这会和创建无参类有点类似。
如何混入特质?
将特质混入到类可以使用如下语法:
extends with T1 with T2 with T3 with ... Tn
。如果一个类已经扩展了别的类,我们仅需要通过
with
关键字来添加特质。如果特质方法在特质体内并没有被实现,同时我们混入该特质的类也没有声明为抽象类,则该类必须实现特质中的方法。否则,将会出现编译错误。
组合
创建时的组合给我们提供了一个无需显式定义就能创建匿名类的机会。同样,如果我们想要组合多个不同的特质,创建所有的可能则要花费很多的工作。
组合简单特质
让我们看一个组合简单特质的例子,这里不会扩展其他类或特质:
class watch(brand:String, initialTime: Long){
def getTime(): Long = System.currentTimeMillis() - initialTime
}
object WatchUser extends App{
val expensiveWatch = new Watch("expensive brand", 1000L) with Alarm with Notifier {
override def trigger(): String = "The alarm was triggered."
override def clear(): Unit = 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.
println(expensiveWatch.trigger())
expensiveWatch.printNotification()
println(s"The time is ${expensiveWatch.getTime()}.")
expensiveWatch.clear()
println(cheapWatch.trigger())
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 extends App{
val reallyExpensiveWatch = new Watch("really expensive brand", 1000L) with ConnectorWithHelper {
override def connect(): Unit = println("Connected with another connector.")
override def close(): Unit = println("Closed with another connector.")
}
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
的子类。然而这可能并非我们所原本期望的,或许这种情况下需要一些重构。
组合自类型(self-type)
在上一节中,我们看到了如何在Watch
类中扩展Connector
以便能够编译我们的代码。有些场景中我们或许真的需要强制一个类来混入一个特质,或者同时有其他的特质或多个特质需要混入。让我们加入需要一个闹钟同时带有提醒功能:
trait AlarmNotifier {
this: Notifier =>
def trigger(): String
}
这里我们展示了什么是自类型。第二行代码将Notifier
的所有方法引入到了新特质的当前作用域,它同时要求所有混入了该特质的类必须同时混入Notifier
。否则将会出现编译错误。如果使用self
来代替this
,我们则可以使用手动的方式来在AlarmNotifier
中引用Notifier
的方法,比如self.printNotification()
。
下面的代码展示了如何来使用这个新的特质:
object SelfTypeWatchUser extends App {
AlarmNotifier {
val watch = new Watch("alarm with notification", 1000l) with AlarmNotifier with Notifier {
override def trigger():String = "Alarm triggered."
override def clear(): Unit = println("Alarm cleared.")
override val notificationMessage:String = "The notification."
}
}
println(watch.trigger())
watch.printNotification()
println(s"The time is ${watch.getTime()}.")
watch.clear()
}
如果在上面的代码中去掉同时扩展Notifier
的部分则会出现编译错误。
在这个小节中,我们展示了子类型的简单用法。一个特质可以要求在被混入的同时混入其他一个或多个特质,多个的时候仅需要使用with
关键字分割即可。子类型是实现“蛋糕模式”的关键,该模式用于依赖注入。本书后续部分我们会看到更多有趣的用法。
特质冲突
你的脑海中可能已经出现了一个问题——如果我们混入的特质中拥有相同签名的方法会怎样?下面的几个小节我们将会探讨该问题。
相同签名和返回类型
考虑一个例子,我们想要混入的两个特质拥有相同的方法:
trait FormalGreeting {
def hello():String
}
trait InformalGreeting {
def hello():String
}
class Greeter extends FormalGreeting with InformalGreeting {
override def hello():String = "Good morning, ser/madam!"
}
object GreeterUser extends App {
val greeter = new Greeter()
println(greetrt.hello())
}
在这个例子中,接待员总是会很有礼貌并且同时混入正式的和非正式的问候。在实现时仅需要实现该方法一次。
相同签名和不同返回类型
如果我们的问候特质拥有更多方法,签名相同但返回类型不同呢?我们将下面的声明添加到FormalGreeting
中:
def getTime():String
同时向InformalGreeting
中添加:
def getTime():Int
这种情况下我们需要在Greeter
中实现同时实现这两个方法。然而,编译器不允许我们定义getTime
两次,这表示 Scala 中会避免发生这样的事。
相同签名和返回类型的混入
在继续之前,快速回忆一下混入只是一个带有一些代码实现的特质。这意味着在下面的例子中,我们不需要在使用它们的类中实现这些方法:
trait A {
def hello(): String = "Hello, I am trait A!"
}
trait B {
def hello(): String = "Hello, I am trait B!"
}
object Clashing extends App with A with B {
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()
然而,如果处于某些原因我们相同时使用两个方法呢?这种情况下,我们可以创建另外一个名字不同的方法来调用另一个指定特质中的方法。我们同样可以直接通过super
符号直接引用这些方法而不是将他们包装在另一个方法中。然而我个人更倾向于包装在另一个方法内,否则代码将会变得很乱。
super 符号
如果在上面的例子中,我们直接使用
override def hello(): String = super.hello()
而不是super[A]. hello()
,真正被选择的又是那个特质中的方法呢?这种情况下将会选择 B 中的方法。这取决于 Scala 中的线性化特性,我们将在后面的章节中详细讨论。
相同签名和不同返回类型的混入
和预期一样,如果方法的传入参数在类型或数量上有所不同则上面的问题就不再存在,因为这成了一个新的签名。但如果特质中有如下两个方法问题则仍然存在:
def value(a: Int): Int = a // in trait A
def value(a: Int): String = a.toString // in trait B
我用用过的方式在这里不再有效,你可能会对此感到吃惊。如果我们决定仅覆写特质 A 中的方法,将会得到如下编译错误:
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 中的方法,错误也会随之改变。而如果两个都覆写,则会得到如下错误:
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 D {
def value(a:Int):String = a.toString
}
object Example extends App{
val c = new C{}
val d = new D{}
println(s"c.value: ${c.value(10)}")
println(s"d.value: ${d.value(10)}")
}
这段代码中把特质当做合作者使用,但这也丢掉了混入这些特质的类的实例同样也拥有这些特质的类型这一事实(即扩展了特质的类,其实例同时拥有特质的类型),这一性质在某些操作中会很有用。
多重继承
因为可以同时混入多个特质,而且这些特质都可以拥有各自不同的方法实现,因此我们已经在前面的章节中多次提到了多重继承。多重继承不仅是一个强大的技术,同时也很危险,甚至有些语言中决定不进行支持,比如 Java。向我们看到的一样,Scacla 对此进行了支持,不过带有一些限制。本节中我们会接收多重继承的问题及 Scala 是如何处理这些问题的。
菱形问题
多重继承忍受着菱形问题的痛苦。
让我们看一下下面的图示:
如图,B 和 C 同时扩展了 A,然后 D 同时扩展了 B 和 C。这看起来可能不是很清晰。比如我们有个方法一开始定义在 A,但是 B 和 C 同时覆写了它。当在 D 中调用该方法时会发生什么呢?实际上调用的是哪个方法呢?
因为上面的问题有点模糊或将引起错误。让我们使用 Scala 的特质来重新描述一下该问题:
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 App with D {
println(hello())
}
运行后会得到如下结果:
Hello from C
如果我们把特质 D 修改为:
trait D extends C with B {
}
则会得到结果为:
Hello from B
你会发现,尽管例子仍然会有点模糊甚至易于出错,我们可以告诉你实际上被调用的是哪个方法。这是通过**线性化(linearization)**实现的,在下一节中会深入介绍。
限制
在我们关注线性化之前,让我们指出 Scala 所支持的多重继承拥有的限制。我们之前已经见过他们很多次,这里会概括描述。
Scala 多重继承限制
Scala 中的多重继承由特质实现并遵循线性化规则。
在多重继承中,如果一个特质已经显式扩展了一个类,则混入该特质的类必须是之前特质混入的类的子类。这意味着当混入一个已扩展了别的类的特质时,他们必须拥有相同的父类。
如果特质中定义或声明了相同签名但返回类型不同的方法,则无法混入这些特质。
需要特别小心的是特质中定义了相同签名和返回类型的方法。若果方式仅是声明而被要求实现,这不会带来问题而且只需要提供一个实现即可。
测试特质
测试实际上是软件开发中很重要的一部分。它确保了代码中变更的部分不再产生错误,无论是方法的改变还是其他部分。
我们可以使用多种不同的测试框架,这完全取决于个人喜好。本书中我们将使用 ScalaTest,这也是我在项目中使用的框架;它很容易理解,可读且易于使用。
有些情况下,如果一个特质被混入到了类中,我们则可以直接测试这个类。然而,我们或许仅需要测试一个指定的特质。测试一个没有任何方法实现的特质也没有什么意义,因此这么我会探讨那些拥有代码实现的特质。同时,我们这里展示的单元测实际上是很简单的,他们也仅作为示例。我们会在本书的后续章节讨论更加复杂和有意义的测试。
使用一个类
让我们看一下前面见到的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 DoubledMultipliersIdentityClass extends DoubledMultiplierIdentity
val instance = new DoubledMultiplierIdentityClass
"identity" should "return 2 * 1" in {
instance.identity should equals(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.{ShouldMatchers, FlatSpec}
class TraitTest extends FlatSpec with ShouldMatchers 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.{ ShouldMatchers, FlatSpec }
class TraitCaseScopeTest extends FlatSpec with ShouldMatchers {
"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。这支持我们对不同的用例设置自定义,比如一个特质需要一个方法的实现或者一个变量的初始化。这种方式也可以让我们仅专注于要测试的特质,而不需要创建它的一些实际的实例。
运行测试
在测试编写完成后,运行并观察一切是否符合预期是很有用的。仅需要在项目的根目录执行以下命令将会运行所有测试:mvn clean test
。
如果你需要,可以将 Maven 项目转换为 SBT 项目,然后通过sbt test
来触发所有测试。
特质与类
特质或许与类很相似,但同时又有很大的不同。对于开发者来说或许很难在不同的场景中进行选择,不过我们会尝试提供一个通用的指导方针以帮助开发者:
使用类:
- 当一个行为根本不会被复用或在多个地方出现
- 如果你计划在其他语言中使用 Scala 代码,比如创建一个将会在 Java 中使用的库
使用特质:
- 当一个行为将会被复用到多个不相关的类中
- 当你需要定义一个接口并在 Scala 之外使用,比如 Java 中。因为那些没有任何代码实现的特质被编译后与接口类似
3 - CH03-统一化
为了能够理解和编写好的 Scala 代码需要开发者熟知语言中不同的概念。到目前,我们已经在几个地方提到了 Scala 是真的很具有表现力。在某种程度上,这是因为有很多编程的概念被统一化了。在本章中,我们将会关注如下概念:
- 函数与类
- 代数数据类型和类层级
- 模块与对象
函数与类
在 Scala 中,所有的值都是一个对象。函数作为第一类值,同时作为他们各自的类的对象。下面的图示展示了 Scala 中被统一的类型系统和实现方式。该图来自 http://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 extends App{
val obj = new FunctionLiterals
println(s"3 + 9 = ${obj.sum(3, 9)}")
}
这里我们可以看到FunctionLiterals
类的sum
字段是如何被赋值为一个函数的。我们可以将任意函数赋值给一个变量,然后把它当做一个函数调用(实际上是调用了它的apply
方法)。函数同样可以作为参数传递给其他方法。让我们向FunctionLiterals
类中添加如下代码:
def runOperation(f: (Int, Int) => Int, a: Int, b: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 extends App{
val obj = new FunctionObjects
println(s"3 + 9 = ${obj.sum(3, 9)}")
println(s"Calling run operation: ${obj.runOperation(obj.sum, 10, 20)}")
println(s"Using Math.max: ${obj.runOperation(Math.max, 10, 20)}")
}
增加的表现力
像你在例子中看到的一样,统一化的类和函数提升了表现力,你可以轻松实现不同的任务,比如回调、惰性参数求值、集中的异常处理等等,而无需编写额外的代码和逻辑。此外,函数可以作为类意味着我们可以扩展它们以提供能多的功能。
代数数据类型
代数数据类型和类层级是 Scala 中的另一种统一化。在其他的函数式语言中都拥有不同的方式来创建自定义的代数数据类型。在 Scala 中,这是通过类层级来实现的,称为case class
和object
。让我们看一下 ADT 到底是什么,他们是什么类型,以及如何定义它们。
ADTs
代数数据类型只是一种组合类型,它会组合一些已存在的类型或仅表示一些新类型。它们中仅包含数据而非像常规类一样包含任何数据之上的功能。比如,一周之中的一天,或者一个表示 RGB 颜色的类——他们没有任何功能,仅仅是包含类一些信息。下面的几个小节将会带你洞悉 ADT 是什么以及它们是什么类型。
概括 ADT
整体 ADT 是一种我们可以简单的枚举一个类型所有可能的值并为每个值提供一个单独的构造器。作为一个例子,我们考虑一下一年的所有月份。一年中仅有 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 extends App {
val month:Month = February
println(s"The current month is: $month")
}
运行这个程序将会得到如下输出:
The current month is: February
上面代码中的
Month
是密闭的(使用 sealed 关键字声明),因为我们不想在当前文件之外被扩展。你可以发现,我们将不同的月份定义为对象,因为没有动机让他们成为分开的实例。值就是他们本身,同时也不会改变。
产品 ADT
在产品 ADT 类型中,我们无法枚举所有可能的值。手写他们通常会非常多。我们不能为每个单独的值提供各自的构造器。
比如颜色,有不同的颜色模型,不过最常见的是 RGB。它将主要颜色的不同值进行组合(红绿蓝)以表示其他颜色。假如每种颜色的值区间为 0 - 255,这意味着要表示所有可能的颜色,我们需要 256 的三次方个构造器。因此我们可以使用一个产品 ADT:
sealed case class REB(red: Int, green:Int, blue:Int)
object RGBDemo extends App {
val magenta = RGB(255, 0, 255)
println(s"Magenta in RGB is: $magenta")
}
可以发现,对于产品 ADT 来说,所有不同的值拥有同一个构造器。
混合 ADT
混合 ADT 表示一个概况和产品的组合。这意味我们可以拥有特定的值构造器,不过这些值构造器同时接收参数以包装其他类型。
让我们看一个例子。加入我们正在编写一个绘图程序:
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。当基于 ADT 的值来做什么事的时候它会使代码更清晰易读,相对于if-else
语句也更易扩展。你可能会认为这些语句在有些场景会难以处理,特别是某个数据类型拥有多种可能的值时。
对值进行模式匹配
在之前月份的例子中,我们仅拥有月份的名字。我们或许需要他们的数字,否则电脑将不知道这是什么。下面展示了做法:
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
}
}
你可以看到我们基于他们来匹配不同的值,并最终返回这行正确的值。现在可以这样使用该方法:
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
对产品类型进行模式匹配
当模式匹配用于产品或混合 ADT 时将会展示其强大力量。在这种情况下,我们可以匹配这些数据类型的实际值。让我们展示如何实现一个功能来计算面积的值:
object Shape{
def area(shape: Shape):Double = shape match {
case Circle(Point(x, y), radius) => Math.PI * Match.pow(radius, 2)
case Rectangle(_, h, w) => h * w
}
}
在匹配时可以忽略我们不关心的值。对于面积,我们实际上不需要位置信息。在上面的代码中,我们仅仅展示了匹配的两种方式。下划线_
操作符可以用于match
语句的任何位置,它仅仅会忽略所处位置的值。在这之后,直接使用我们的例子:
object ShapeDemo extends App {
val circle = Circle(Point(1, 2), 2.5)
val rect = Retangle(Point(6, 7), 5, 6)
println(s"The circle area is: ${Shape.area(circle)}")
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 cont(): 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(trigger.count() % 10 == 0){
println(s"Alarm triggered at ${ticker.count()}!")
}
}
}
object alarm extends AlarmUserImpl
}
有意思的是我们在AlarmUser
同时扩展了两个模块。这展示了模块可以怎样互相基于对方。最终,我们可以像下面这样使用这些模块:
object ModuleDemo extends App with Alarmer with TickUser{
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 中的模块可以像其他对象一样传递。他们是可扩展的、可互换的,并且实现是被隐藏的。
4 - CH04-抽象与自类型
为了能够拥有易于扩展和维护的应用,在软件工程中设计和编写高质量的代码是非常重要的。这些活动要求开发者能够熟悉并正确理解领域,同时应用的需求也能被清晰的定义。如果缺少了任何一项,那么想要编写好的程序则会称为一个挑战。
很多时候,工程师使用一些抽象来建模“世界”。这有助于代码的可扩展性和可维护性,并能避免重复,这在在很多情况下可能会成为错误的原因。好的代码通常有多个小的组件构成,它们会互相依赖或集成。有多种不同的方式来实现抽象和集成。我们将会在本章中关注以下概念:
- 抽象类型
- 多态
- 自类型
这里所涉及的几个主题将非常有助于我们研究具体的设计模式。理解它们也能有助于理解那些基于它们的设计模式。此外,使用本章所涉及的这些概念本身就能有助于编写良好的代码。
泛型与抽象类型
参数化一个类的常用方式之一是使用值。这十分简单,可以通过向类的构造器参数传入不同的值来实现。在下面的例子中,我们可以向Person
类的name
参数传入不同的值来创建不同的实例:
case class Person(name:String)
以这种方式我们能够创建不同的实例并将他们加以区分。但这样既不有趣也不是“火箭科学”。更进一步,我们会关注一些更加有趣的参数化来帮助我们编写更好的代码。
泛型
泛型是另一种参数化类的方式。当我们编写一个要操作多种不同类型的功能时,泛型会很有用,同时我们能够简单的推迟到最后再选择具体类型。一个例子是开发者所熟知的集合类。比如List
,可以保存任何类型的数据,我们可以拥有整形、双精度浮点型、字符串、自定义类等等类型的列表。即便如此,列表的实现仍然总会是一样的。
我们同样可以参数化方法。比如,如果我们想要实现加法,对于不同的数字类型不会有什么不同之处。因此,我们可以使用泛型并仅实现方法一次,而不需要重载来适应世界上的每个类型。
让我们看一些例子:
trait Adder{
def sum[T](a: T, b: T)(implicit numeric:Numeric[T]): T =
numeric.plus(a, b)
}
上面这段代码可能有点难懂,它定义了一个可以用于numeric类型的sum
方法。这实质上是一个专用(ad-hoc)泛型的表示,我们将会在本章的后续部分讨论。
下面的代码展示了如何泛型一个能够包含任何数据的类:
class Container[T](data: T) {
def compare(other:T) = data.equals(other)
}
下面的片段展示了例子的用法:
object GenericsExample extends App with Adder {
println(s"1 + 3 = ${sum(1, 3)}")
println(s"1.2 + 6.7 = ${sum(1.2, 6.7)}")
// compilation fails
// System.out.println(s"abc + cde = ${sum("abc", "cde")}")
val intContainer = new Container(10)
println(s"Comparing with int: ${intContainer.compare(11)}")
val stringContainer = new Container("some text")
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 conpare(other:T) = data.equals(other)
}
然后在类中使用这个特质:
class StringContainer(val data:String) extends ContainerAT {
override type T = String
}
然后就可以使用与前面相同的方式来使用这个类:
object AbstractTypesExample extends App{
val stringContainer = new StringContainer("some text")
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 extends App{
val laser = new LaserPrinter
val threeD = new ThreeDPrinter
println(laser.print(Text(), Toner(), Paper()))
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]
println(genericLaser.print(Text(), Toner(), Paper()))
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]
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 extends App {
val shoppingBasket:List[Item] = List(
new Fruit, new Drink
)
shoppingBasket.foreach(i => 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
关键字使用继承来表示。
参数式多态
函数式编程中的参数化多态即为我们上节中展示的泛型。泛型既是参数化多态,我们已经见过,它支持我们定义基于任何类型或给定类型的子集类型的方法和数据结构。而具体的类型则可以在后续的阶段指定。
参数式多态使用类型参数表示。
专用(ad-hoc)多态
专用多态类似于参数式多态;然而在这种情况下,参数的类型是很重要的,因为具体实现会依赖于这些参数。它会在编译时被分析,不像子类型多态是在运行时。这多少类似于函数重载。
我们在本章的前面部分看到过它的一个例子,我们创建了一个Adder
特质可以对不同的类型求和。让我们一步一步定义一个更加精密的例子,期望这样有助于连接它是如何工作的。我们的目标是让sum
方法可以对任何类别的类型(之前是数字类型)求和:
trait Adder[T]{
def sum(a:T, b:T): T
}
下一步,我们创建一个使用sum
方法的对象并向外界暴露:
object Adder {
def sum[T: Adder](a: T, b: T): T =
implicitly[Adder[T]].sum(a, b)
}
我们看到的上面这段代码是 Scala 中的一些语法糖,implicitly
表示拥有一个隐式转换可以将T
转换为Adder[T]
。现在我们可以编写如下程序:
object AdhocPolymorphismExample extends App{
import Adder._
println(s"The sum of 1 + 2 is ${sum(1, 2)}")
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 sum[T: Adder](a: T, b: T): 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
}
implicit val string2Adder:Adder[String] = new Adder[String] {
override def sum(a:String, b:String):Int = s"$a concatenated with $b"
}
}
现在如果允许上面的程序则会得到如下输出:
The sum of 1 + 2 is 3
The sum of abc + def is abc concatenated with def
同样,如果你记得本章开头的例子,我们是不能对字符串求和的。现在你会发现,我们可以提供不同的实现,因为我们定义了一个转换为Adder
方式,因此使用任何类型都是没有问题的。
专用泛型支持我们在不修改基类的基础上扩展代码。这在使用外部库时将会很有帮助,比如因为某些原因我们无法修改原始代码。它很强大而且在编译时求解,这可以确保我们的程序会和预期的一样运行。另外,它可以支持我们为无法访问的类型(比如这里的 Int、String)提供功能。
为多个类型提供功能
如果我们回头看本章的开头,我们为数字类型定义了一个Adder
,会发现后面最终的实现会要求我们为不同的数字类型单独定义不同的操作,比如 Long、Double等等。有没有方式来实现本章开头的哪种效果呢?当然有,就像下面这样:
implicit def numeric2Adder[T: Numeric]:Adder[T] = new Adder[T]{
override def sum(a: T, b: T) = implicitly[Numeric[T]].plus(a, b)
}
我们仅仅定义了另一个隐式转换(将Numeric[T]
转换为Adder[T]
),它会为我们处理好一切。现在可以像下面这样使用:
println(s"The sum of 1.2 + 6.5 is ${sum(1.2, 6.5)}")
专用多态通过隐式混入行为来表示。这是类型类设计模式的主要构建方式,后续章节中将会详细解释。
例子中,首先定义一个泛型接口,然后通过传入具体类型参数来创建不同类型的具体子类实例,从而调用支持不同类型的实例方法。只是这个过程是通过隐式转换完成的。
自类型
好代码的其中一个特性是关注点分离。开发者需要致力于使类与其方法的职责仅有一个。这有助于测试和维护,而且更有助于简化代码的理解。记得,简单的总是最好的。
然而,在编写实际软件时总是无法避免,为了实现某些功能我们需要在一个类的实例中持有别的类的实例。换句话说,一旦我们的对构件块进行了清晰的分离,为了实现功能它们之间则会存在依赖。我们这里所讨论的总结起来实际上就是依赖注入。自类型提供了一种方法来以更加优雅的方式来处理这些依赖。本节中,我们将讨论它们的用法及优点。
使用自类型
自类型支持我们在应用中更简便的拆分代码,然后再在其他地方指定那些需要的部分。例子会让一切变得清晰,因此让我们看一个实例。假如我们想要往数据库持久化信息:
trait Persister[T] {
def persist(data:T)
}
persist
方法会对数据做一些转换并存储到我们的数据库。当然,我们的代码写的很好,因此数据库实现是互相分离的。我们拥有以下几种数据库:
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 = {
println("Saving to in memory database.")
db.+=:(data)
}
}
trait FileDatabase[T] extends Database[T] = {
override def save(data:T):Unit = {
println("Saving to file.")
}
}
我们拥有一个特质及一些具体的数据库实现。那么如何把数据库传递给Persister
呢?它应该能够调用数据库中的save
方法。可能会有以下几种方式:
- 在
Persister
中扩展Database
。这样虽然可行,但是会让Persister
也变成了Database
的实例,我们并不希望这样。后面会解释原因。 - 在
Persister
中拥有一个Database
变量,然后使用它。 - 使用自类型。
为了能够观察自类型是如何工作的,因此使用自类型的方式。我们的Persister
接口将会变成下面这样:
trait Persister[T] { this: Database[T] =>
def persist(data:T):Unit = {
println("Calling persist.")
save(data)
}
}
现在我们访问了数据库中的方法并在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 extends App {
val fileStringPersister = new FilePersister[String]
val memoryIntPersister = new MemoryPersister[Int]
fileStringPersister.persist("something")
fileStringPersister.persist("something else")
memoryIntPersister.persist(100)
memoryIntPersister.persist(123)
}
自类型与继承所做的事是不同的,它需要一些代码的存在,因此也能支持我们对功能进行很好的拆分。这可以很大的改变的对程序的维护、重构和理解。
使用多个组件
在真实的应用中,可能需要使用自类型来对多个组件做出要求。让我们在例子中展示一个Histoty
特质,它能够追踪改变并回滚到某个点。不过这里仅做一些打印:
trait History {
def add():Unit = {
println("Action added to history.")
}
}
我们需要在Persister
中使用它,看起来像是这样:
trait Persister[T] { this: Database[T] with History =>
def persist(data:T):Unit = {
println("Calling persist.")
save(data)
add()
}
}
我们可以通过with
关键字同时添加多个需求。然而,如果我们仅让代码做出这些改变,它并不会编译成功。原因是现在我们必须同时混入History
到Persister
中:
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 = {
println("Mystery added!")
}
}
然后使用到Persister
中:
trait Persister[T] { this:Database[T] with History with Mystery =>
def persist(data:T):Unit = {
println("Calling persist.")
save(data)
add()
}
}
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
,这意味着Persister
本省也变成了一个Database
(is-a 关系)。然而这是不正确的。这里它只是使用一个数据库来实现其功能,而不能称为一个数据库。
继承将子类暴露给父级的实现细节。然而并非总是期望得到这样的结果。根据Design Patterns: Elements of Reusable Object-Oriented Software一书的作者所提倡的,开发者总是应该优先使用组合,而不是继承。
继承泄露了功能
如果我们使用了继承,同样会将我们不希望的功能泄露给子类。让我们看一下下面的代码:
trait DB {
def connect():Unit = println("Connected.")
def dropDatabase():Unit = println("Dropping!")
def close():Unit = println("Closed.")
}
trait UserDB extends DB{
def createUser(username:String):Unit = {
connect()
try {
println(s"Creating a user: $username")
} finally{
close()
}
}
def getUser(username:String):Unit = {
connect()
try{
println(s"Getting a user: $username")
} finally {
close()
}
}
}
trait UserService extends UserDB{
def bad():Unit = dropDatabase()
}
这会是一个真实的情况。因为这就是继承的工作方式,我们可以在UserService
中访问dropDatabase
。这是一些我们不希望发生的事情,而且可以通过自类型来修复。特质DB
不需要变动,仅需要修改以下内容:
trair UserDB{ this:DB =>
def createUser(username:String):Unit = {
connect()
try{
println(s"Creating a user: $username")
} finally close()
}
def getUser(username:String):Unit = {
connect()
try{
println(s"Getting a user: $username")
} finally close()
}
}
trait UserService{ this: UserDB =>
//...
}
这样,在UserService
中就无法再访问到dropDatabase
了。我们只能调用我们需要的方法,这也就是我们要真正实现的。
5 - CH05-AOP 与组件
AOP 与组件
有些时候在编程中,我们会发现一些代码片段重复出现在不同的方法中。有些情况下,我们会重构我们的代码将它们移到单独的模块中。有时这又是行不通的。有一些经典的日志和验证的例子。面向切面编程在这种情况下会很有帮助,我们将在本章的末尾来理解它。
组件是可复用的代码片段,它会提供多种服务并具有一定的要求。它们对避免代码重复极其有用,当然也能促进代码的复用。这里我们将会看到如何构建组件,以及 Scala 是如何让组件的编写和使用相比其他语言更加简单。
在熟悉面向切面编程和组件的过程中,我们将会贯穿以下主题:
- 面向切面编程
- Scala 中的组件
面向切面编程
面向切面编程,即 AOP(Aspect-oriented programming),解决了一个通用的功能,贯穿于整个应用,但是又无法使用传统的面向对象技术抽象成一个单独的模块。这种重复的功能通常被引用为“横切关注点(cross-cutting concerns)”。一个通用的例子是日志——通常日志记录器会在一个类中创建,然后在类的方法中调用这个记录器的方法。这有助于调试和追踪应用中的事件,但由于应用实际的功能没什么相关性。
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。这需要在pom.xml
中配置额外的依赖:
<dependency>
<groupId>org.json4s</groupId>
<artifactId>json4s-jackson_2.11</artifactId>
<version>3.2.11</version>
</dependency>
上面这个依赖可以轻易转换为 SBT,如果读者更愿意使用 SBT 作为构建系统的话。
我们要编写一个拥有两个方法的类,用于解析前面所指定的格式的输入文件并返回一个Person
对象列表。这两个方法实际上在做同一件事,但是其中一个会比另一个效率更高:
trait DataReader{
def readData():List[Person]
def readDataInefficiently():List[Person]
}
class DataReaderImpl extends DataReader{
implicit val formats = DefaultFormats
private def readUnitimed():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 extends App{
val dataReader = new DataReadImpl
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 的实现。
不使用 AOP
有一种基本的方法来进行计时。我们可以把println
语句包裹起来,或者让计时称为DataReaderImpl
类方法的一部分。通常,将计时作为方法的一部分会是一个更好的选择,因为这个方法可能会在不同的地方被调用,同时它们的性能也取决于传入的参数和一些其他因素。基于我们所说,这也就是DataReaderImpl
类将会如何被重构以支持计时的方式:
class DataReaderImpl extends DataReader {
implicit val formats = DefaultFormats
private def readUnitimed():List[Person] = parse(StreamInput(getClass.getResourceAsStream("users.json"))).extract[List[Person]]
override def readData(): List[Person] = {
val startMillis = System.currentTimeMillis()
val result = readUnitimed()
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 1000) foreach {
case num => readUntimed()
}
val result = readUntimed()
val time = System.currentTimeMillis() - startMillis
System.err.println(s"readDataInefficiently took ${time} milliseconds.")
result
}
}
因此你会发现,代码会变得不可读,计时功能干扰了实际的功能。运行这段代码将会发现其中一个方法花费的更多的时间来执行。
在下节中将会展示如何使用 AOP 来提升我们的代码。
使用 AOP
向前面看到的一样,向我们的方法中添加计时代码将会引入重复代码同时也使代码难以追踪,尽管是一个很小的例子。现在,假如我们同样需要打印一些日志或进行一些其他活动。AOP 将会帮助分离这些关注点。
我们可以把DataReadImpl
重置到一开始的状态,这时它不再打印任何日志。现在创建另一个名为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.pringln(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"readDataIneffieciently took $time milliseconds.")
result
}
}
这里有趣的地方在于abstract override
修饰符。它提醒编译器我们会进行**叠加性(stackable)**的修改。如果我们不使用该修饰符,编译将会失败:
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 extends App{
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()}")
}
运行这段代码将会得到带有计时信息的输出。
使用 AOP 的优势是很明显的——实现不会被其他代码污染。再者,我们可以以同样的方式添加更多修改——更多的日志、重试逻辑、回滚等等。所有这些都可以通过创建一个新特质并扩展DataReader
接口,然后在创建具体实现的实例中混入即可。当然,我们可以同时应用多个修改,它们将会按顺序执行,而顺序将会遵循线性化原则。
组件
组件作为应用的一部分意味着会与应用的其他部分进行结合。它们应该是可复用的,以便减少代码的重复。组件通常拥有接口,用于描述它们提供的服务或者它们依赖的一些服务或是其他组件。
在大型的应用中,我们通常会看到多个组件会被集成在一起工作。要描述一个组件提供的服务通常会很直接,这会使用接口的帮助来完成。与其他组件进行集成则可能需要开发者完成更多的工作。这通常会通过将需要的组件的接口作为参数来传递。然而,加入有一个大型的应用需要很多的组件;完成这些链接需要花费时间和精力。进一步,每次需要一个新的需求,我们也需要进行大量的重构。多重继承可以作为参数传递的替代方案;然而,首先需要语言支持这种方式。
像 Java 语言中用来链接组件的流行做法是使用依赖注入。Java 中拥有这样的库用于在运行时将组件注入。
丰富的 Scala
本书中我们已经提到多次,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)
一旦这些完成后,我们就可以开始创建组件了。首先是TimeConponent
,而Time
的实现是一个嵌套类:
trait TimeConponent{
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 RobotRegisty extends TimeComponent with ReipeComponent with CookingComponent {
override val time:Time = new TimeImpl
override val recipe:RecipeFinder = new RecipeFinderImpl
override val cooker:Cooker = new CookerImpl
}
现在创建机器人:
class Robot extends RobotRegisty{
def cook(what:String) = cooker.cook(what)
def getTime() = time.getTime()
}
最后使用我们的机器人:
object RobotExample extends App {
val robot = new Robot
System.out.println(robot.getTime())
System.out.println(robot.cook("chips"))
System.out.println(robot.cook("sandwich"))
}
上面的例子中,我们看到了 Scala 不使用外部库来实现依赖注入的方式。这种方式真的很有用,它会避免我们的构造器过大,也不需要扩展过多的类。更进一步,各个组件可以很好的分离,可测试,并能清晰定义各自的依赖。我们同样看到了可以使用一些依赖其他组件的组件来递归的添加需求。
上面这个例子实际上展示了蛋糕模式。一个好的特性是,依赖的存在会在编译期间进行求值,而不像 Java 那些流行的库一样在运行时进行求值。
蛋糕模式同样也存在缺点,我们会在稍后关注所有特性——无论好坏。那里我们将会展示组件如何可以被测试。
这个蛋糕模式例子实质上很简单。在真是的应用中,我们可能需要一些组件依赖于其他组件,而那些组件有拥有各自的依赖。在这些情况下,事情会变得很复杂。我们将会本书的后续部分更好更详细的展示这一点。
总结
本章我们探讨了 Scala 中的 AOP,现在我们知道如何将那些本不需要出现在模块中的代码进行拆分。这将有效减少代码重复并使我们的程序拥有不同的专用模块。
我们同样展示了如何使用本书前面部分讨论的技术来创建可复用的组件。组件提供接口并拥有指定的需求,这可以很方便的使用 Scala 的丰富特性。这与设计模式很相关,应为它们拥有相同的目标——使代码更好,避免重复,易于测试。
本书的后续章节我们将会讨论一些具体的设计模式,及其有用特性和用法。我们将会以创建型模式开始,它们由四人帮(Gof)创建,当然,这里会以 Scala 的视角。
6 - CH06-创建型模式
创建型模式
从这章开始,我们将会深入到实际的实际模式当中。我们已经提到过了解并能正确使用现有的设计模式的重要性。
设计模式可以被认为是一些能够解决特定问题的最佳实践或甚至是模板。开发者需要处理的问题的数量是无穷尽的,有些情况下不同的设计模式不得不被组合使用。然而,基于编写一段代码来解决问题的程序的各个方面,我们可以将设计模式分为一下几个组要的组。
- 创建型
- 结构型
- 行为型
本章将会关注于创建型设计模式,当然,我们将会以 Scala 语言的视角。我们将会贯穿以下一个主要的主题:
- 什么是创建型设计模式
- 工厂方法
- 抽象工厂
- 其他工厂设计模式
- 单例
- 构建器
- 原型
在正式的定义这些创建型设计模式之后,我们将会更详细的单独研究其中的每一种。我们将会强调何时及如何使用它们,何时拒绝使用一些模式,当然,会展示一些相关的实例。
什么是创建型设计模式
创建型设计模式,和它名字描述的一样,用于处理对象的创建。有些情况下,对象的创建在程序中可能会引起一些额外的复杂性,而创建型模式隐藏这些复杂性以使软件组件的使用更加简单。对象创建的复杂性可能由下面任何一种原因引起:
- 初始化参数的数量
- 必要的验证
- 捕获必要参数的复杂性
上面这个列表在有些情况下可能会变得更长,这些因素也不会单独出现,总是伴随而来。
我们将会在下个小节关注创建型设计模式的方方面面,希望你会对为何需要他们以及如何在实际生活中应用它们,有个好的理解。
7 - CH06-1-工厂方法
工厂方法模式用于封装实际类的初始化。它简单的提供一个借口来创建对象,然后工厂的子类来决定创建哪个具体的类。当我们需要在应用的运行时创建不同类的实例,这种模式将会很有用。这种模式同样可以有效的用于当需要开发者传入额外的参数时。
例子可以让一切变得简单,后面的小节我们将会提供一个实例。
类图
对于工厂方法,我们将会展示一个数据库相关的例子。为了使事情变得简单(因为实际的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 = "SimpePgSqlConnection"
override def executeQuery(query:String):Unit = {
System.out.println(s"Executing the query '$query' the PgSQL way.")
}
}
对于这些实现的使用将会发生在工厂方法中,名为connect
。下面的代码片段展示了我们可以如何利用连接,以及如何在特定的数据库客户端中实现。
// the factory
abstract class DatabaseClient {
def executeQuery(query:String):Unit = {
val connection = connect()
connection.executeQuery(query)
}
// the factory method
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 extends App {
val clientMysql:DatabaseClient = new MysqlClient
val clientPgsql:DatabaseClient = new PgSqlClient
clientMySql.executeQuery("SELECT * FROM users")
clientPgSql.executeQuery("SELECT * FROM employees")
}
我们看到了工厂方法模式是如何工作的。如果我们需要添加另一个数据库客户端,则可以扩展DatabaseClient
,并在实现connect
方法时返回一个扩展自SimpleConnection
的类。
上面例子中选择使用特质来定义 SimpleConnection,并使用抽象类来实现 DatabaseClient,这只是随机的选择。当然,我们完全可以使用特质来替代抽象类(除非需要参数构造器的时候)。
在有些场景中,由工厂方法创建的对象可能会在构造器中需要一些参数,而这些参数有可能基于持有工厂方法的对象的一些特定状态或功能。这也是该模式的实际闪光点。
Scala 中的替代选择
与软件工程中的任何事情一样,该模式同样可以由不同的方式来实现。不同的选择则要归结于需求的不同和应用、被创建对象的特定属性。一些可选的替代方法有:
- 通过构造器向该类传入其需要的组件(对象组合)。这样一来可能意味着,每次请求,这些组件可能是特定的实例而非一个新的。
- 传入一个能够创建我们所需要的实例的函数。
使用 Scala 的丰富特性,我们能够避免这种设计模式,我们能够更明智的知道,我们将要使用或暴露的这些对象是如何创建的,而无论工厂方法是如何被创建的。没有绝对对错的方式。然而,应该基于特定的需求来选择一种能够同时满足使用、维护更加简便的方式。
优点
和其他工厂一样,对象的创建细节被隐藏了。这意味着,如果我们改变了一个特定对象的创建方式,我们仅需要修改创建它的工厂方法(基于设计,这可能会涉及很多创建器)。工厂方法支持我们使用一个类的抽象版本并把对象的创建指派给子类。
缺点
在上面的例子中,如果我们拥有多于一个的工厂方法则很快会遇到问题。这首先需要开发者实现更多的方法,但更重要的是,这将导致返回的对象互不兼容。让我们看一个小例子。首先我们会定义另一个特质SimpleConnectionPrinter
,拥有一个在被调用时打印一些东西的方法:
trait SimpleConnectionPrinter {
def printSimpleConnection(connection:SimpleConnection):Unit
}
然后我们想改变我们的DatabaseClient
并换个不同的名字:
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
创建另外两个具体实现:
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 = {
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 // note here
}
上面这个实现则有效完成了,现在我们可以使用它:
object BadExample extends App{
val clientMySql: BadDatabaseClient = new BadMySqlClient
val clientPgSql: BadDatabaseClient = new BadPgSqlClient
clientMySql.executeQuery("SELECT * FROM users")
clientPgSql.executeQuery("SELECT * FROM employees")
}
但是运行会得到如下结果:
I require a MySQL connection. It is 'SimpleMysqlConnection'
Execution the query 'SELECT * FROM users' the MySQL way.
I require a Mysql connection. It is 'SimplePysqlConnection'
Execution the query 'SELECT * FROM employees' the PgSQL way.
上面的例子显然发生了一个逻辑错误,同时也没有给我们提供任何提醒(在具体的工厂方法中使用了错误的具体实现)。随着要实现的方法数量的增长,这将会成为一个问题,并且错误会更易于发生。比如,我们的代码没有抛出任何异常,但是这种陷阱会引起运行时错误,这让问题变得难于发现和调试。
8 - CH06-2-抽象工厂
抽象工厂是工厂模式系列的另一种设计模式。其目的与所有工厂设计模式一样——封装对象的创建逻辑并对用户隐藏。不同在于它的实现方式。
抽象工厂设计模式基于对象组合,而非继承,像工厂方法模式的实现中则是基于继承。这里我们有一个单独的对象,它提供一个借口来创建我们需要的类的实例。
类图
这里我们仍然使用前面的SimpleConnection
例子。下面的类图展示了抽象工厂是如何被组织的:
从上图中我们可以发现,现在我们可以拥有一个带有层级结构的工厂,而非数据库客户端中的一个方法。我们将会在应用中使用DatabaseConnectorFactory
,同时它也会基于实际的实例类型返回正确的对象。
实例
让我们以代码的视角来观察我们的例子。下面的代码清单展示了工厂的层级结构:
trait DatabaseConnectionFactory{
def connection():SimpleConnection
}
class MysqlFactory extends DatabaseConnectionFactory{
override def connect():SimpleConnection = new SimpeMysqlConnection
}
class PgsqlFactory extends DatabaseConnectionFactory{
override def connect():SimpleConnection = new SimplePgsqlConnection
}
然后我们可以将这些工厂传递给类并调用需要的方法来使用它们。这里有一个与之前展示的工厂方法类似的例子:
class DatabaseClient(connectionFactory:DatabaseConnectionFactory){
def executeQuery(query:String):Unit = {
connectionFactory.connect()
connection.executeQuery(query)
}
}
然后使用这个数据库客户端:
object Example extends App {
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)和方法改变了签名,将会引起问题的出现。有些情况下,该模式可能会为我们的代码带来一些没有必要的复杂性,以及难以阅读和维护。
9 - CH06-3-其他工厂模式
还有一些工厂设计模式的变种。而在所有的场景中,它们的目的都是相同的——隐藏创建过程的复杂性。在下个小节中,我们将简短的提到另外两种工厂模式:静态工厂、简单工厂。
静态工厂
静态工厂可以被表示为一个静态方法,并作为基类的一部分。调用它来创建那些具体类的实例。这里最大的缺点之一是,如果给基类添加了另外一个扩展,基类也需要给修改,即修改这个静态方法。让我们展示一个动物世界中的例子:
trait Animal
class Bird extends Animal
class Mammal extends Ainmal
class Fish extends Animal
object Animal {
def apply(animal:String):Animal = animal match {
case "bird" => Bird
case "mammal" => Mammal
case "fish" => Fish
case x:String => throw new RuntimeException(s"Unknown animal: $x")
}
}
这样每次添加一种新的具体动物,我们不得不修改这个apply
方法来包括它,特别是当我们要考虑新的类型时。
简单工厂
简单工厂比静态工厂要好一点,因为实际的工厂功能是在另一个类中。这使得每次扩展基类不必再去修改基类。类似于抽象工厂,但不同在于这里我们没有一个抽象工厂类,会直接使用具体工厂。通常从一个简单的工厂开始,随着时间的推移和项目的进化,最终会演变成一个抽象工厂。
工厂组合
当然,可以将不同的工厂模式组合在一起。当然,这种方式需要谨慎选择并且仅在有必要的时候使用。否则,设计模式的滥用将会导致烂代码。
10 - CH06-4-懒初始化
在软件工程中,懒初始化是是将对象或变量的初始化过程延迟直到第一次被需要使用的时候。这个想法的背后是想要延迟或避免昂贵操作的执行。
类图
在其他的语言中,比如 Java,懒初始化通常与工厂方法模式联合使用。该方法通常会检查我们需要使用的对象或变量是否已经被初始化了,如果没有则初始化该对象并返回。在后续的使用中,将会直接返回已被初始化的对象或变量。
Scala 语言中对懒初始化提供了内建支持,通过lazy
关键字。因此在这里提供类图也没有意义了。
实例
让我们看一下懒初始化在 Scala 中是如何工作的,并证明它真的是“懒”的。我们会看一个求圆形面积的计算。我们知道,公式是pi * r²
。数学语言中已经提供了数学常量,我们在现实生活中也不会这么做。然而,如果我们讨论的是一个不为大家熟知常量,这个例子仍然是有关联的,或者基于一个值波动的常量,但是每天都会不同。
在学习里我们被教育 π 的值为 3.14。这是对的,然而,如果我们关心精度的话后面还有很多额外的小数,我们仍然要包括它们。比如,包含 100 个小数的 π:
3.14159265358979323846264338327950288419716939937510582097494459230781 64062862089986280348253421170679
因此让我们创建一个根据半径计算圆形面积的工具。我们将会把我们的 π 值作为变量放在工具类中,不过我们仍然支持用户来选择是否需要一个精确的面积值。如果需要,我们将会从配置文件中读取一个包含 100 个小时的 π 值:
object CircleUtils{
val basicPi = 3.14
lazy val precisePi:Double = {
println("Reading properties for the precise PI.")
val props = new Properties()
props.load(getClass.getResourceAsStream("pi.properties"))
props.getProperty("pi.high").toDouble
}
def area(redius:Double, isPrecise:Boolean = false):Double = {
val pi:Double = if (isPrecise) precisePi else basicPi
pi * Math.pow(radius, 2)
}
}
基于对精度的需求,我们将会使用 π 的不同版本。这里的懒初始化是很有帮助的,因为我们可能永远都不需要计算一个精确的面积。或者有时需要有时不需要。更进一步,读取配置文件是一个 IO 操作,可以认为是一个慢的或者多次执行会产生不好的副作用的操作。现在来使用它:
object Example extends App {
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
来进行初始化。为了能够提供更好的安全性,应该优先使用double-checked locking,而 Scala 中则没有这种危险(?)。
11 - CH06-5-单例模式
单例模式用于确保一个类在整个应用中仅有一个实例。它在使用它的应用用引入了一个全局状态。
一个单例对象可以由不同的策略来初始化——懒初始化或热(eager)初始化。这都基于期望的用途,以及对象需要被初始化的时机,等等。
类图
单例是另一种由 Scala 语言语法所支持的设计模式。我们通过object
关键字来实现它,并没有必要提供一个类图,因此我们下一小节直接进入一个实例。
实例
这个实例的目的是展示如果在 Scala 中创建一个单例对象,以及理解在 Scala 中对象是何时被创建的。我们将会看到一个名为StringUtils
的类,提供了一些字符串相关的工具方法:
object StringUtils{
def countNumberOfSpecs(text:String):Int = text.split("\\+s").length -1
}
这个类的用法也很直接。Scala 会管理对象的创建过程、线程安全,等等:
object UtilsExample extends App{
val sentence = "Hello there! I am a utils example."
println(s"The number of spaces in '$sentence' is:
${StringUtils.countNumberOfSpaces(sentence)}")
}
尽管StringUtils
对象是一个单例实例,上面这个例子看起来仍然很清晰,类似于带有静态方法的类。这也就是在 Scala 中定义静态方法的方式。给一个单例类添加状态或许会更有意思。下面的例子展示了这种方式:
object AppRegistry{
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
包含一个使用应用的当前用户所构成的并发 Map。这是我们的全局状态,同时提供了一些方法来支持用户操作它。同时我们有一个打印语句,它会在这个单例对象被创建时执行。我们可以在下面的应用中使用这个注册表:
object AppRegistryExample extends App{
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(",")}")
}
从运行这段代码得到的输出你会发现,在 “I woke up” 之后会打印单例对象被初始化的信息,因为在 Scala 中,单例会被懒初始化。
优点
在 Scala 中,单例模式与静态方法的实现方式一样。这也是单例可以有效的用于创建没有状态的工具类的原因。Scala 中的单例也可以用于创建 ADT。
另一个在 Scala 中严格有效的事情是,单例会被以线程安全的方式创建,因此无需一些额外的操作来确保。
缺点
单例模式实际上通常被称为是“anti-pattern”(反面模式、反面教材)。很多人说全局状态不能以单例的方式来实现。也有人说如果你不得不使用单例模式,则你需要尝试重构你的代码。虽然这在有些场景中是对的,但有些场景很适合使用单例模式。通常首要的原则是:如果你能避免,那就避免。
对于 Scala 的单例需要特别指出的一点是它可以真的仅拥有一个实例。虽然这实际上是该模式的定义,在其他语言中,我们可以拥有一个预定义的数量而非仅仅一个单例对象,我们可以使用自定义的逻辑进行控制。
有一点对 Scala 没有影响,不过还是值得一提。当应用中的单例被延迟初始化时,为了能够提供线程安全,需要基于一种加锁机制,比如前面提到过的double-checked locking。访问应用中的单例时,无论是在 Scala 中还是别的语言,同样需要以线程安全的方式来完成,或者由单例内部来处理这个问题。
12 - CH06-6-构建器模式
构建起模式支持以类方法而类构造器的方式来创建实例。当一个类的构造器拥有多个版本以支持不同的用途时,这种模式尤其有用。更进一步,有些情况下,创建所有的组合是不可能的或者它们也无法被了解。构建起模式使用一个额外的对象,称为builder
,用于在创建最终版本的对象之前接收和保存初始化参数。
类图
这个小节中,我们首先会提供一个在其他语言中看起来比较经典的类图,包括 Java。然后,我们会基于它们来提供不同版本的代码实现来使其更符合 Scala,以及一些围绕它们的观察和讨论。
我们会拥有一个Person
类,带有参数:firstName
,lastName
,age
,departmentId
等等。下个小节中会展示实际的代码。创建一个具体的构造器可能会花费太长时间,尤其是有些参数有些时候可能并不需要或被了解。这也会让代码在以后变得难以维护。而构建器模式可能会是个不错的想法,它的来图看起来像下面这样:
我们已经提到过,这也就是构建器模式在纯面向对象语言中看起来的样子。当构建器是抽象的时候,表示也会有所不同,这时会存在一些具体的构建器。这对它所创建的产品来说也是一样。最终,它们的目标都一样——使对象的创建更简单。
实例
实际上在 Scala 中有三种不同的方式来表示构建器模式:
- 经典方式,像上面展示的类图,类似与其他编程语言。这种方式实际上是不推荐的,尽管在 Scala 中也能实现。为了实现它使用了可变性,这违背了语言的可不变原则。为了完整性和体现使用 Scala 的简单特性会使实现多么简单,我们会进行展示。
- 使用带有默认参数的的样例类。我们会看到两种版本的实现,一种验证参数而另一种则没有。
- 使用泛化的(generalized)类型约束。
下面的几个小节我们将会关注这些方式。为了使事情变得简单便于演示,我们会在类中使用较少的字段;然而,需要注意的是构建器模式真正适用的是拥有大量字段的类。
类似 Java 方式的实现
首先是 Person 类:
class Person(builder: PersonBuilder){
val firstName = builder.firstName
val lastName = builder.lastName
val age = builder.age
}
该类接收一个 builder 并使用它被设置的值来初始化字段。下面是 Builder 的实现:
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
}
}
构建器中提供了方法用于设置与Person
向对应的字段值。这些方法都会返回相同的构建器实例,这样就能支持我们链式的调用它们。下面是如何使用这个构建器:
object PersonBuilderExample extends App{
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 拥有样例类,这使得构建器模式的实现更加简单:
case class Person(
firstName:String = "",
lastName:String = "",
age:Int = 0
)
其用法也与之前构建器类似:
object PersonCaseClassExample extends App {
val person1 = Person(
firstName = "Ivan",
lastName = "Nikolov",
age = 0
)
val person2 = Person(firstName = "John")
System.out.println(s"Person 1: ${person1}")
System.out.println(s"Person 2: ${person2}")
}
这种实现要比前面第一种实现更加简短也更易维护。它能为开发者提供与第一种实现完全相同的功能,但是更简短、语法更清晰。同时保持了Person
类的字段不可变,这也遵循了 Scala 中好的实践。
这两种实现都有的缺点是没有对参数进行验证。如果一些组件相互依赖并且有些特定的参数需要验证呢?前面这两种方式的用例中,我们可能会得到一些运行时异常。下一小节中将会展示如何确保验证和需求得到满足被实现。
使用泛化类型约束
在软件工程中的一些创建对象的用例中,我们会拥有一些依赖。我们要需要一些东西已经被初始化完成,那么需要一个特定的初始化顺序,等等。前面我们看到的两种构建器模式实现都缺乏确保某些东西已被实现或未被实现的能力。在这种方式中,我们需要围绕构建器模式创建一些额外的验证,以确保一切都符合预期。当然,我们会看一下在运行时创建对象是否是安全的。
使用一些我们之前在本书中见到的技术,我们可以创建一个能够在运行时验证所有的需求是否都已被满足的构建器。这杯称为type-safe builder,下个小节中我们会展示这种模式。
改变 Person 类
首先,我们将使用与 Java 实现方式中展示的例子中相同的类开始。现在添加一些约束,比如每个人都最少拥有firstName
和lastName
。为了能够让编译器感知到这些字段是否已被设置,需要将这些编码为一个类型。我们将使用 ADT 来达到这个目的。让我们定义下面的代码:
sealed trait BuildStep
sealed trait HasFirstStep extends BuildStep
sealed trait HasLastStep 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
的声明为如下方式:
class PersonBuilder[PassedStep <: BuildStep] private (
var firstName:String,
var lastName:String,
var age:Int
)
这将要求之前所有那些返回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
类中的方法。下面是这些方法的定义:
def setFirstName(firstName:String):PersonBuilder[HasFirstName] = {
this.firstName = firstName
new PersonBuilder[HasFirstName](this)
}
def setLastName(lastName:String):PersonBuilder[HasLastName] = {
this.lastName = lastName
new PersonBuilder[HasLastName](this)
}
有趣的部分是最后的build
方法,让我们首先看一下最初的额实现:
def build()(implicit ev:PassedStep =:= HasLastName):Person =
new Person(firstName, lastName, age)
上面的语法设置了一个泛化类型约束,它要求仅能在已经通过了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 setFirtstName(firstName:String):PersonBuilder[HasFirstName] = {
this.firstName = firstName
new PersonBuilder[HasFirstName](this)
}
def setLastName(lastName:String)(implicit ev:PassedStep =:= HasFirstName):PersonBuilder[HasLastName] = {
this.lastName = lastName
new PersonBuilder[HasLastName](this)
}
def setAge(age:Int):PsersonBuilder[PassedStep] = {
this.age = age
this
}
def build()(implicit ev: PassedStep =:= HasLastStep):Person =
new Person(firstName, lastName, age)
}
使用 type-safe 构建器
object PersonBuilderTypeSafeExample extends App {
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")
^
顺序的要求可以被认为是一个缺点,特别是根本不需要顺序的时候,不过可以通过一些额外的方式来解决这个问题。
对这个 type-safe 构建器的一些观察:
- 使用 type-safe 构建器,我们可以限定一个指定的调用顺序,和一些已经被初始化的字段
- 如果我们限定了多个字段,则需要将它们链接,这使得调用顺序变得重要。有些情况下会使库变得难用
- 编译器消息,如果构建器没有被正确使用,这些消息并不能明确指示错误
- 代码看起来跟 Java 方式的实现类似
- 这种类似 Java 的实现方式导致了对并不推荐的可变性的依赖
Scala 支持我们使用一个很好很清晰的构建器模式实现,同时能够要求构建顺序和已被初始化的字段。这是一个很好的特性,尽管有时会变得冗长乏味,或者限制了方法的实际用途。
使用 require 语句
上面展示的 type-safe 构建器很好,不过仍然有一些缺点:
- 复杂性
- 可变性
- 一个预定义的初始化顺序
然而,它有时可能会很有用,因为能够支持在编译期间检查所编写的代码。尽管有些时候编译期间的检查是没有必要的。如果这样的话,我们可以使事情变得非常简单并摆脱之前的复杂性,即使用已知的 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 extends App{
val person1 = Person(firstName = "Ivan", lastName = "Nikolov", age = 26)
println(s"Person 1: ${person1}")
try{
val person2 = Person(firstName = "John")
} catch {
case e: Throwable => e.printStackTrace()
}
}
我们发现这里变得更加简单了,字段也不再可变,实际上也没有任何特定的初始化顺序。更进一步,我们可以设置有意义的信息来帮助我们诊断潜在的问题。如果编译期间的验证并非必须,这可能是更好的方式。
优点
当我们需要创建一个复杂的对象时,这种构建器模式尤其适用,否则需要创建很多构造器。它通过一个 step-by-step 的方式使对象的创建更加简单、清晰、可读。
缺点
像我们在 type-safe 构建器中看到的,添加一些高级的逻辑和要求会带来相当多的工作。如果没有这种可能性,开发者会将危险暴露给这些类的用户以出现更多错误。同时,构建器包含很多看起来重复的代码,尤其是以类似 Java 的方式实现。
13 - CH06-7-原型模式
原型模式是一种从已存在的对象创建新对象的创建型模式。其目的在于避免昂贵的调用来保持高性能。
类图
在 Java 语言中,我们通常会看到一个类实现一个带有clone
方法的接口,它返回一个该类的新实例。限免的图示展示了其类图:
实例
Scala 中原型模式的实现变得尤其简单。我们可以使用一种语言特性。由于原型设计模式真的类似于生物细胞分裂,让我们用一个细胞作为一个例子:
/** Represents a bio cell */
case class Cell(dna: String, proteins: List[String])
在 Scala 中,所有的 case 类都拥有一个copy
方法,它会返回一个克隆自原有实例的新实例。并在复制的过程中改变一些值。下面是用法:
object PrototypeExample extends App{
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
方法,我们不用花费时间创建就能得到实例。缓慢可能源自一些创建过程中引起的计算,必须数据库调用或数据查询,等待。
缺点
使用对象的浅(shallow)拷贝可能引起错误或副作用,实际的引用会指向原始的实例。同时,避免构造器可能导致烂代码。原型模式需要被真正的用于不使用则会引起巨大的性能影响的场景。
14 - CH07-结构型模式
结构型设计模式
我们设计模式之旅的下一张将关注一系列结构型设计模式。我们将以 Scala 的视角探索以下结构型设计模式:
- 适配器
- 装饰器
- 桥接
- 组合
- 门面
- 享元
- 代理
这一节我们将对什么是结构型设计模式以及它们为什么有用给出一个很好的理解。当我们熟悉了它们是什么之后,我们将单独的研究其中每一个并深入其细节,包括代码实例,何时使用、何时避免,以及在使用时需要注意什么。
什么是结构型设计模式
结构型设计模式关心于软件中对象与类的组合。它们使用不同的方式以获得新的、更大型的、通常也更复杂的结构。这些方式有一下几种:
- 继承
- 组合
能够正确识别软件中对象之间的关系是简化软件结构的关键。在下面的几个小节,我们将讨论几种不同的设计模式并提供一些实例,能够为如何使用不同的结构型设计模式找到一些感觉。
15 - CH07-1-适配器模式
在很多情况下,我们需要通过将不同的组件组合在一起来让应用跑起来。然而,通常会出现不同组件的接口互相不兼容的问题。类似于使用一些公共库或任何库,我们自己并不能对其进行修改——因为别人的意见跟我们目前的配置完全一样是不太常见的。这也就是适配器的用途所在。它的目的在于在不修改源代码的情况下帮助兼容各个接口以能够在一起工作。
我们将会在下面的小节中通过类图和实例来展示适配器是如何工作的。
类图
对于适配器的类图来说,让我们假如我们需要在应用中转换为使用一个新的日志库。我们尝试使用的新的库拥有一个log
方法,它接收日志的消息和严重程度。然而,贯穿我们的整个应用,我们期望拥有info/debug/warning/error
方法并且只接收日志消息,它能够自动的设置严重程度。当然,我们不能编辑原始库的代码,因此需要使用适配器模式。下面的图示展示了适配器模式的类图:
在上面的类图中,可以发现我们的适配器 AppLogger 扩展了 Logger,并使用其实例作为一个字段。在实现这些方法的时候,我们就可以简单的调用其log
方法并传入不同的参数。这也就是通用的适配器实现方式,后面的小节我们会看到具体的代码实例。可能有些情况下不能直接扩展(比如原始类为 final),我们将展示 Scala 如何来处理这种问题。同时,我们也会展示一些推荐的用法来使用语言特性实现适配器模式。
实例
首先,首先看一下我们不能进行修改的Logger
类的代码:
class Logger {
def log(message:String, sevrvity:String):Unit = {
System.out.println(s"${severity.toUpperCase}: $message")
}
}
我们尽量保持简单以避免读者从本书的主旨上分心。下一步,我们可以要么仅仅编写一个类来扩展Logger
,或者要么提供一个接口用于抽象。让我们使用第二种方式:
trait Log {
def into(message:String):Unit
def debug(message:String):Unit
def warning(message:String):Unit
def error(message:String):Unit
}
最终,我们可以创建AppLogger
:
class AppLogger extedns Logger with Log{
overrride def log(messgae: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 extends App {
val logger: 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 的一个实例了(继承自 Logger),因此可以直接方法它的 log 方法。
这就是实现和使用基本的适配器模式的方法。然而,有些情况下我们想要适配的类被声明为 final,因此不能再对其进行扩展。下面的小节将展示如何处理这种情况。
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")
}
这里,我们简单的将最终日志类的实例包装在一个类中,并调用它的方法来传入不同的参数。这种用法跟前面的实现完全一样。或者有一种变种是将最终日志类的实例作为构造器参数传入适配日志类。这在原始日志类的创建过程需要一些额外的参数时比较有用。
Scala 方式的适配器模式
之前我们已经提到过很多次,Scala 是一个特性丰富的语言。基于这样的事实,我们可以使用隐式类来实现适配器的功能。这里我们会使用前面例子中的定义的 FinalLogger 来演示。
隐式类会在可能的地方提供隐式转换。为了能使隐式转换生效,我们需要导入这些隐式声明,这也就是为何它们通常被声明在对象(object)或包对象(package-object)中。对于这个例子,我们将使用包对象:
package object adapter {
implicit class FinalLoggerImplicit(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")
}
}
这是一个我们日志实例定义位置的包对象。它户自动将FinalLogger
实例转换为我们的隐式类。下面的代码片段展示了如何使用它:
object AdapterImplicitExample extends App{
val logger = 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!")
}
优点
适配器模式可以用于代码已经被设计并编写。它可以是接口协同工作或进行兼容。其实现和用法也很直接。
缺点
上面例子中的最后一种实现存在一些问题。就是每当我们需要使用这个日志器的时候总是需要导入包或对象。同时,隐式类或转换可能会让代码难以阅读和理解。隐式类也存在一些限制,描述在这:implicit-classes。
像我们已经提到过的,当我们有一些无法改变的代码时该模式是有帮助的。如果我们能够修改原始代码,这将是最好的选择,因为在贯穿整个应用中使用适配器模式将会导致程序难以维护和理解。
16 - CH07-2-装饰器模式
有些情况下我们需要给应用中的类添加一些功能。这可以通过继承来完成;然而,我们并不像这么做以避免影响应用中的其他所有类。这也就是装饰器的用途。
装饰器的目的在于在不扩展原有类、并不影响其他扩展自该类的对象的行为的基础上为原有类添加额外的功能。
装饰器模式通过包装被装饰的类来工作,并可以在运行时应用。装饰器尤其适用于一个类拥有多个扩展,并且这些扩展会以不同的方式组合的场景。替代编写所有可能的组合,装饰器可以被创建并在每个之上叠加它们的修改。后面的几个小节我们将展示何时在真实的场景中使用装饰器。
类图
像我们之前看到的适配器设计模式,它的目标是将接口改变成不同的一个。在装饰器中,另一方面来说,帮助我们给方法提供额外的功能来增强一个接口。对于类图,我们将使用一个数据流的例子。假如我们拥有一个基本的流(stream),我们想要对其加密、压缩、替换字符等等。下面是类图:
在上面的类图中,AdvancedInputReader
为InputReader
提供了基本的实现。它包装了一个BufferedReader
。然后我们拥有一个抽象的InputReaderDecorator
类扩展自InputReader
并拥有它的一个实例。通过扩展基本的装饰器,我们为流提供了加密、压缩或 Base64 编码它得到的输入的能力。我们可能在应用中想要不同的流,并且想要在上面的操作中以不同的顺序执行一个或多个。如果我们想要提供所有的可能性,代码将很快变得凌乱从而难以维护,尤其是当可能的操作数量很多。而使用装饰器,会在后面小节中看到的一样清晰整洁。
实例
让我们看一下描述上面类图的具体代码实例。首先,我们使用一个特质来定义InputReader
接口:
trait InputReader {
def readLines():Stream[String]
}
然后在AdvancedInputReader
类中为接口提供一个基本的实现:
class AdvancedInputReader(reader:BufferedReader) extends InputReader {
override def readLines():Stream[String] =
reader.readLines().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) {
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 readLines():Stream[String] = super.readLines().map {
case line =>
Base64.getEncoder.encodeToString(line.getBytes (Charset.forName("UTF-8")))
}
}
我们使用了一个中间的抽象类来演示装饰器模式,所有具体的装饰器都扩展自该抽象类。也可以不使用该中间抽象类而是直接扩展 InputReader 并包装其实例来实现这种模式。
现在则可以在应用中根据需要来使用这些装饰器给输入流增加功能。用法和直接,像下面这样:
object DecoratorExample extends App{
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 extends App{
val stream = new BufferedReader(
new InputStreamReader(
new BufferedInputStream(this.getClass.getResourceAsStream ("data.txt"))
)
)
try{
val reader = new CompressingInputReader(
new Base64InputReader(
new CapitalizedInputReader(
new AdvancedInputReader(stream)
)
)
)
reader.readLines().foreach(println)
} finally {
stream.close()
}
}
这个例子会读取文本、转换为大写、Base64 编码,最终压缩为 GZIP。
Scala 方式的装饰器模式
向其他设计模式一样,该模式也有一种利用 Scala 丰富特性的实现,其中使用了一些本书开头介绍的几种概念。在 Scala 中装饰器模式被称为特质叠加(stackable traits)。让我们看一下它的形式以及如何使用它。InputReader
和AdvancedInputReader
会像前面小节的实现一样被完全保留。我们实际是在两个例子中对其进行了复用。
下一步,不再定义一个abstract decorator
类,取而代之的是仅仅在新的特质中定义不同的读取器修改:
trait CapitalizedInputReaderTrait extends InputReader {
abstract override def readLines():Stream[String]
super.readLines().map(_.toUpperCase)
}
然后,定义压缩输入读取器:
trait CompressingInuptReadrTrait 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 {
conpressor.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")))
}
}
你会发现这些实现并没有很大区别,这里我们使用特质替代了类,扩展自基本的InputReader
特质,并使用了abstract override
。
抽象覆写(abstract override)支持我们在特质中对声明为 abstract 的方法调用 super。这允许在混入了一个已实现前一个方法的特质或类之后混入特质。abstract override 会告诉编译器我们是在故意这么做从而避免编译失败——它会在稍后进行检查,当我们使用特质并且其需要已经被满足的时候。
上面我们展示了两个例子,现在我们会展示使用堆叠特质又会是什么样子。首先是仅进行转换大写:
object StackableTraitsExample extends App{
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 extends App{
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()
}
}
这两个例子的输出将会和原来的例子一样。然而这里我们使用了混入组合,看起来则会更加清晰。同时我们也少了一个类,因为我们不再需要abstract decorator
类。要理解修改是如何被应用的也很简单——这里将会遵循叠加特质被混入的顺序。
叠加特质遵循线性化规则
事实上在我们当前的例子中,修改从左到右被应用会被误解。这会发生的原因是,我们会将调用推到栈上,直到我们抵达 readLines 的基本实现,然后再以反向的顺序应用修改。
在后续的章节中我们将会看到更加深入的例子。
优点
装饰器给我们的应用带了个更多灵活性。它们不会修改原有的类,因此不会向原有的代码引入错误,也会节省更多代码的编写和维护。同时,它们可以避免我们对所创建的类的应用场景有所遗忘或没有遇见到。
在上面的例子中,我们展示了一些静态行为修改。然而,同样能够在运行时对实例进行动态装饰。
缺点
我们已经覆盖了使用装饰器的正面影响;然而,同样要支持的是对装饰器的过度使用仍然会带来问题。最终我们会得到大量很小的类,这会使我们的库难以使用,对领域知识的要求也更高。它们也会使初始化过程变得复杂,会需要一些其他的(创建型)设计模式,比如工厂或构建器。
17 - CH07-3-桥接模式
有些应用中对一个特定的功能拥有多种不同的实现。这些实现可能是一组算法或者在多个平台上执行的操作。这些实现经常会发生变化,同时贯穿整个应用的声明周期中会添加有新的实现。更进一步,这些实现可能会以不同的方式应用于不同的抽象。像在这些场景下,更好的方式是从我们的代码中解耦,否则我们将面临类爆炸的危险。
桥接模式的目的在于将抽象与其实现解耦,然后二者可以互相独立地进行变动。
当抽象或实现经常会独立的进行变动时,桥接设计模式会很有帮助。如果我们直接实现一个抽象,对于抽象或实现的变动将总是会影响继承层级中的其他类。这将使扩展、修改、对类的独立复用变得难以进行。
桥接模式消除了直接实现抽象带来的问题,因此能够使抽象和实现易于复用和修改。
桥接模式与适配器模式非常类似,而它们的不同在于,前者是我们在设计的时候应用,而后者是在使用一些遗留代码或外部库时应用。
类图
对于类图和代码实例,让我们假设我们正在编写一个散列密码的库。在实践中,以普通文本的方式保存密码是需要避免的。这也是我们的库能够帮助用户的地方。有很多不同的散列算法可用。比如 SHA-1,MD5 和 SHA-256。我们想最少能够支持这些必能够轻松地添加新的方式。有不同的散列策略——你可以散列多次,组合不同的散列,给密码加盐等等。这些策略会让我们的密码很难通过*彩虹表(rainbow-table, 一种破解工具)*猜到。作为例子,我们将会展示带盐的散列和没有任何算的简单散列。
从上面的类图中你会发现,我们将实现(Hasher 及子类)和抽闲(PasswordConveter)进行了分离。这样就能支持我们添加新的散列实现,并在创建PasswordConveter
的时候传入一个它的实例来立即使用它。如果我们没有之前的构建器模式,或许需要为每个散列算法分别创建一个密码转换器——这会使我们的代码规模膨胀或太过冗长而难以使用。
实例
现在让我们以 Scala 的视角看一下上面的类图。首先,我们会关注于实现这一边的Haser
特质:
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
}
}
然后我们有三个类来实现它。代码看起来很简单也很类似,只是返回的结果不同:
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()))
}
现在让我们看一下抽象这一边。这也就是客户端要使用的部分:
abstract class PasswordConverter(hasher:Hasher){
def convert(password:String):String
}
我们选择提供两种不同的实现,SimplePasswordConverter
和SaltedPasswordConverter
:
class SimplePasswrodConverter(hasher:Hasher) extends PasswordConveter(hasher){
override def convert(password:String):String = hasher.hash(password)
}
class SaltedPasswordConverter(hasher:Hasher) extends PasswordConverter(hasher){
override def convert(password:String):String = hasher.hash(passwrod)
}
现在,如果客户端想要使用这个库,可以编写类似下面这样的代码:
object BridgeExample extends App {
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
抽象现在看起来会像这样:
abstract class PasswordConverterBase {
self: Hasher =>
def convert(password:String):String
}
自类型会告诉编译器当我们使用PasswordConverterBase
的时候同时需要混入一个Hasher
。
class SimplePasswordConverterScala extends PasswordConverterBase {
self:Hasher =>
override def convert(password:String):String = convert(password)
}
class SaltedPasswrodConverterScala(salt:String) extends PasswordConverterBase{
self: Hasher =>
override def convert(password:String):String = hash(s"${salt}:${password}")
}
最终,我们可以像下面这样使用新的实现:
object ScalaBridgeExample extends App{
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")}")
}
运行这段代码将会与第一种实现得到的输出一样。然而,当我们使用我们的抽象时,可以混入我们需要使用的散列算法。但我们拥有更多的实现需要与散列结合时,这种优势会变得更加明显。使用混入看起来也会更加自然和易懂。
优点
像我们已经提到的,桥接模式类似于适配器模式,不过我们会在设计应用的时候应用该模式。使用它的一个明显优势是最终我们的应用中不会拥有爆炸数量的类,从而导致库的使用变得相当复杂。分离的层级结构也支持我们能够独立的扩展它们而不会影响其他的类。
缺点
桥接模式需要我们编写一些模板代码。当需要选择哪种的具体的实现时,会增加使用库的复杂性,或许使用桥接模式和一些创建型模式相结合或是一个不错的主意。总而言之,它没有主要的缺点,开发者需要根据当前的情况明智的选择是否使用这种模式。
18 - CH07-4-组合模式
组合设计模式用于描述一组对象应该像一个对象一样处理。
目的在于将对象组合为一个树结构以表示 整体-部分 的层级关系。
组合模式可用于移除重复代码或者在一组对象应该以相同的方式处理时避免错误。一个典型的例子是文件系统,我们可以拥有文件夹,其中又可以拥有其他文件夹或文件。通常,用于交互文件或文件夹的接口是相同的,因此它们可以很好的适用组合模式。
类图
像我们之前提到的,文件系统能够很好的适用组合模式。总的来说,它们是一个树形结构,因此对于我们的例子来说,我们将会展示如何使用组合模式来构建一个树形结构。
从上面的类图你会发现,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 = .println(s"${prefix}${data}")
}
class Tree extends Node{
private val children = ListBuffer.empty[Node]
override def print(prefix: String): Unit = {
println(s"${prefix}(")
children.foreach(_.print(s"${prefix}${prefix}"))
println(s"${prefix})")
}
def add(child: Node): Unit = children += child
def remove(): Unit = {
if (children.nonEmpty) children.remove(0)
}
}
之后便可以直接使用这些代码。在打印的时候,我们不用关心它具体是叶子还是树。我们的代码将会自动处理这些:
object CompositeExample extends App{
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("-")
}
代码实际上会对我们的树形结构进行深度优先遍历。我们拥有的数据结构看起来会像下面这样:
优点
组合模式能够有效的减少代码重复,在创建层级结构的时候也很直接。简化的部分来自于客户端并不知道它们实际上正在处理的什么类型的对象。添加新的节点类型也很简单,不需要改变其他任何东西。
缺点
组合模式没有任何主要的缺点。它确实适用于具体场景。开发者需要注意的一点是在处理大规模的层级结构时,因为在这种情况下,我们可能会深入到递归签到的项目里,而这可能会引起栈溢出问题。
19 - CH07-5-外观模式
每当我们要构建一些库或大型的系统,总是会需要依赖一些其他的库或功能。方法的实现有时需要同时依赖多个类。这就需要一些必要的知识以了解这些类。无论何时我们为用户构建一些库,我们总是尝试使其对用户来说变得简单以假设他们并不会也并不需要像我们一样拥有广泛的领域知识。另外,开发者需要确保那些组件能够在整个用户的应用中易于使用。这也就是外观模式的用途所在。
其目的在于通过一个简单的结构来包装一个复杂的系统,以隐藏使用的复杂性来简化客户端的交互。
我们已经看到过一些基于包装来实现的设计模式。适配器模式通过将一个接口转换为另一个,装饰器用来添加额外的功能,而外观模式则使一切变得更加简单。
类图
对于类图,让我们假设一下的设置:我们想要用户能够从服务端下载一些数据并能以对象的方式完成反序列化。服务端以编码的形式返回数据,因此我们首先要对其进行解码,然后解析,最终返回正确的对象。所涉及的这些大量的操作使事情变的复杂。这也就是我们要使用外观模式的原因。
当客户端使用上面的应用时,他们仅需要与DataReader
进行交互。而在内部会处理好对数据的下载、解码及反序列化。
实例
上面的类图中展示了DataDownloader
、DataDecoder
、DataDeserializer
作为对象组合在DataReader
中使用。这样实现起来直接清晰——它们要么可以通过默认的构造器创建,要么作为参数传入。对于我们例子的代码展示来说,我们选择使用特质来代替类,并将它们混入到DataReader
类中。
首先让我们看一下这几个特质:
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
}
}
trait DataDecoder{
def decode(data: Array[Byte]): String =
new String(Base64.getDecoder.decode(data), "UTF-8")
}
trait DataDeserializer{
implicit val formats = DefaultFormats
def parse[T](data: String)(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)
parse[Person](json)
}
}
这个实例清晰的展示了替代使用三个不同的接口,我们可以使用一个简单的方法调用。而所有的复杂性都隐藏在方法的内部。下面的清单展示了这个类的用法:
object FacadeExample extends App{
val reader = new DataReader
System.out.println(s"We just read the following person: ${reader.readPerson("http://www.ivan-nikolov.com/")}")
}
当然,在这个例子中,我们同样可以使用类来代替特质混入,这完全基于实现的需要。
优点
外观模式可以用于隐藏库的实现细节,使接口更加易于用来交互负载的系统。
缺点
人们常犯的一个错误是尝试把一切都放到一个外观中。这种形式是没有任何帮助的,开发者仍然要面对复杂的系统。另外,外观可能会被证明限制了那些拥有足够领域知识的用户对原始的功能的使用。当门面称为与内部系统交互的唯一方式时这种限制就尤为明显。
20 - CH07-6-享元模式
通常当软件编写完成后,开发者会尝试使其更加快速高效。通常这意味着更少的处理循环和更少的内存占用。有多种不同的方式来实现这两个概念。大多数时间,一个好的算法能够处理好第一个问题。内存的使用规模则存在不同的原因和解决方案,而享元模式则是用于帮助减少内存的使用。
该模式的目是通过将一个对象与其类似的对象共享尽可能多的数据来帮助优化内存的使用。
在很多情况下很多对象会共享一些相同的信息。讨论享元模式时一个常用的例子是单词处理。替代使用所有的信息包括字体、大小、颜色、图片等等来表示一个字符,我们可以仅仅保存类似字符的坐标并引用一个拥有相同信息的对象。这样可以使内存的使用显著减少。否则,这样的应用将无法使用。
类图
对于类图,让我们假设正在尝试表示一个类似下面这样用于色盲测试的图片:
我们可以发现,它由拥有不同大小和颜色的原型组合而成。可能的情况下,这可能是一个无限大的图片从而拥有无数个原型。为了使事情变的简单,让我们设置一个限制,假设仅拥有五种不同的颜色:红、绿、蓝、黄、洋红。下面的类图展示了如何使用享元模式来表示类似上面的图片:
实际的享元模式通过CircleFactory
、Circle
、Client
来实现。客户端请求工厂,要么返回一个新的Circle
实例,要么是拥有相同参数的实例已经存在,则会从缓存中返回。比如,共享的数据是带有各自颜色的Circle
对象,而每个特定的Circle
都拥有各自的坐标和半径。Graphic
中会包含带有所有信息的圆形。基于上面类图的代码实现会使一切变得更加清晰。
实例
是时候看一下使用 Scala 来表示享元模式是什么样子了。我们将使用前面展示过的相同的例子。如果代码版本中的名称等属性与类图中有所不同,并没有什么值得注意的。这种变化主要是源自 Scala 的习俗。在贯穿代码的时候我们将会在发生的地方明确指出。
关于享元模式和例子中有趣的一点是我们实际上使用了一些前面提到过的模式和技术。我们同样会在贯穿代码的时候明确指出。
首先我们要做的是表示颜色。这实际上跟模式无关,这里我们使用 ADT 来表示:
sealed abstract class Color
case object Red extends Color
case object Blue extends Color
case object Green 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)"
}
这些圆形将会是享元对象,因此对象中仅拥有将会与其他对象共享的数据。现在我们拥有了圆形的模型,现在创建圆形工厂。这里使用了工厂模式:
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
类:
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 extends App{
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
类的时候,我们会在其构造期间打印一条信息。运行这段代码会发现,带有每个特定颜色的圆形仅会被创建一次,尽管我们在构件整个图的时候使用了很多圆形。最后一行信息显示了我们事实上仅拥有 5 个实际的圆形实例,尽管我们的图拥有十个不同的圆形。
该实例仅仅展示了享元模式是如何工作的。在真实的应用中,享元对象可能会共享更多的信息,从能能够在实际的应用中减少内存的占用。
优点
像我们前面已经提到的,享元模式可以用于尝试减少应用的内存占用。使用共享的对象,我们的应用会需要更少对象的构造和解构,同时也能提高性能。
缺点
基于所要共享数据的规模,有时实际共享对象的数量会动态的增长而不能带来更多的优势。另外,会增加工长本身和其用法的复杂性。多线程应用则需要对工厂进行额外的处理。最后但并非最不重要的是,开发者在使用共享对象时需要额外小心,其中的任何改变都会影响到整个应用,在 Scala 中由于不可变性则不需要额外关心。