This the multi-page printable view of this section. Click here to print.
设计模式
- 1: Java 模式
- 1.1: CH01-创建型-简单工厂
- 1.2: CH02-创建型-工厂方法
- 1.3: CH03-创建型-抽象工厂
- 1.4: CH04-创建型-建造者
- 1.5: CH05-创建型-单例
- 1.6: CH06-结构型-适配器
- 1.7: CH07-结构型-桥接
- 1.8: CH08-结构型-装饰器
- 1.9: CH09-结构型-外观
- 1.10: CH10-结构型-享元
- 1.11: CH11-结构型-代理
- 1.12: CH12-行为型-命令
- 1.13: CH13-行为型-中介者
- 1.14: CH14-行为型-观察者
- 1.15: CH15-行为型-状态
- 1.16: CH16-行为型-策略
- 2: Scala 模式
- 2.1: CH01-模式分类
- 2.2: CH02-特质与混入组合
- 2.3: CH03-统一化
- 2.4: CH04-抽象与自类型
- 2.5: CH05-AOP 与组件
- 2.6: CH06-创建型模式
- 2.7: CH06-1-工厂方法
- 2.8: CH06-2-抽象工厂
- 2.9: CH06-3-其他工厂模式
- 2.10: CH06-4-懒初始化
- 2.11: CH06-5-单例模式
- 2.12: CH06-6-构建器模式
- 2.13: CH06-7-原型模式
- 2.14: CH07-结构型模式
- 2.15: CH07-1-适配器模式
- 2.16: CH07-2-装饰器模式
- 2.17: CH07-3-桥接模式
- 2.18: CH07-4-组合模式
- 2.19: CH07-5-外观模式
- 2.20: CH07-6-享元模式
- 2.21: CH07-7-代理模式
- 2.22: CH08-行为型模式-1
- 2.23: CH09-行为型模式-2
- 2.24: CH10-函数式模式理论
- 2.25: CH11-函数式模式应用
- 2.26: CH12-实际应用
1 - Java 模式
1.1 - CH01-创建型-简单工厂
创建型模式概述
创建型模式(Creational Pattern)对类的实例化过程进行了抽象,能够将软件模块中对象的创建和使用分离。为了使软件的结构更加清晰,外界对于这些对象只需要知道他们的共同接口,而不清楚其具体细节,使整个系统的设计更加符合单一职责原则。
该模式在创建什么(what)、由谁创建(who)、何时创建(when)等方面都为软件设计者提供了尽可能大的灵活性。创建型模式隐藏了类实例的创建细节,通过隐藏对象如何被创建和组合在一起达到使整个系统独立的目的。
简单工厂(Simple Factory Pattern)
模式动机
比如一个软件系统可以提供多个外观不同的按钮(圆形、矩形、菱形等),这些按钮都源自同一个基类,在继承基类后不同的子类修改了部分属性从而使得他们可以呈现不同的外观,如我们在使用这些按钮时,不需要知道这些具体按钮类的名字,只需要知道表示该按钮类的一个参数,并提供一个调用方便的方法,把参数传入该方法即可返回一个相应的按钮对象,这时就可以使用该模式。
模式定义
又称为静态工厂方法(Static Factory Method),它属于类创建型模式。该模式中,可以根据参数的不同返回不同类的实例。简单工厂模式专门定义一个类来负责创建其他类的实例,这些被创建的实例通常拥有共同的父类。
模式结构
简单工厂模式包含如下角色:
Factory:工厂角色
负责实现创建所有实例的内部逻辑。
Produce:抽象产品角色
是创建的所有对象的父类,负责描述所有实例所共有的公共接口
ConcreteProduce:具体产品角色
是创建目标,所有创建的对象多充当这个角色某个具体类的实例
类图
时序图
代码实例
C++
#include "Factory.h"
#include "ConcreteProductA.h"
#include "ConcreteProductB.h"
Product* Factory::createProduct(string proname){
if ( "A" == proname )
{
return new ConcreteProductA();
}
else if("B" == proname)
{
return new ConcreteProductB();
}
return NULL;
}
模式分析
- 将对象的创建和对象的业务处理分离可以降低系统的耦合度,使得两者修改起来更简便。
- 在调用工厂类的工厂方法时,由于工厂方法是静态方法,使用方便,可通过类名直接调用,而且只需要传入一个简单的参数即可。在实际开发中,还可以在调用时将所传入的参数保存在 XML 等配置文件中,修改参数时无需修改任何源代码。
- 该模式最大的问题在于工厂类的职责过重,增加新的产品需要修改工厂类的逻辑判断,这一点与开闭原则是相违背的。
- 该模式的要点在于:当你需要什么,只需要传入一个正确的参数,就可以获得你需要的对象,而无需知道其创建细节。
优点
- 工厂类含有必要的逻辑判断,可以决定什么时候(条件)创建什么类的实例,客户端可以免除直接创建产品对象的责任,而仅仅“消费”(使用)产品。通过这种方式实现对责任的分割,提供专门的工厂类用于创建对象。
- 客户端无需知道所创建的具体产品类的类名,只需要知道具体产品类对应的参数即可,对于一些复杂的类名,通过该模式可以减少使用者的记忆量。
- 通过引入配置文件,可以在不修改任何客户端代码的情况下更换个增加新的产品类,在一定程度上提高了系统的灵活性。
缺点
- 由于工厂类中集中了所有产品类的创建逻辑,一旦不能正常工作,将影响整个系统。
- 该模式将会增加系统中类的个数,在一定程度上增加了系统的复杂度和立即难度。
- 系统扩展困难,一旦添加新的产品类则必须修改工厂逻辑,在产品类型较多时,有可能造成工厂逻辑较为复杂,不利于系统的扩展和维护。
- 由于使用了静态工厂方法,造成工厂角色无法形成基于继承的等级结构。
适用场景
- 工厂类负责创建的对象比较少:由于创建的对象类别少,不会造成工厂方法中的业务逻辑太过复杂。
- 客户端只知道传入工厂类的参数,对于如何创建对象不关心:客户端既不需要关心创建细节,也不需要知道类名,只需要知道该类型需要的参数。
模式应用
JDK 类库中广泛使用了该模式,如工具类
java.text.DateFormat
,用于格式化一个本地日期:public final static DateFormat getDateInstance(); public final static DateFormat getDateInstance(int style); public final static DateFormat getDateInstance(int style, Local local);
Java 加密技术:
获取不同加密算法的密钥生成器:
KeyGenerator keyGen = KeyGenerator.getInstance("DESede");
创建密码器:
Cipher cp = Cipher.getInstance("DESede");
总结
- 创建型模式对类的实例化过程进行了抽象,能够将对象的创建与对象的使用分离。
- 简单工厂模式又称为静态工厂方法模式,属于类创建型模式。在该模式中,可以根据参数的不同返回不同类的实例。同时专门定义了一个类来负责创建其他类的实例,被创建的实例通常都具有共同的父类。
- 包含三个角色:工厂角色负责实现创建所有实例的内部逻辑;抽象产品角色是需要创建的所有类的父类,负责描述所有实例所共有的公共接口;具体产品角色是创建目标,所有创建的对象都充当这个角色某个具体类的实例。
- 要点在于:当你需要什么,只需要传入对应正确的参数,就可以获得对应的对象,而无需知道创建细节。
- 有点在于:将对象的创建与使用分离,将对象的创建交给工厂类负责,但是其最大的缺点在于工厂类不够灵活,增加新的具体产品则需要修改工厂的判断逻辑,当产品较多时,逻辑将会复杂。
- 适用场景:工厂类需要创建的对象比较少;客户端只知道传入工厂类的参数,对于创建过程不关心。
1.2 - CH02-创建型-工厂方法
模式动机
针对“1-简单工厂”中提到的系统进行修改,不再使用一个按钮工厂类来统一负责所有产品的创建,而是将具体按钮的创建过程交给专门的工厂子类去完成。首先定义一个抽象的按钮工厂类,再定义具体的工厂类来生成圆形、矩形、菱形按钮等,这些具体的工厂类会实现抽象工厂类中定义的方法。
这种抽象化的结构可以在不修改具体工厂类的情况下引入新的产品,如果出现新的按钮类型,只需要为这种新的按钮类型创建对应的工厂类即可获得该新按钮的实例。因此,使得工厂方法模式具有超越简单工厂的优越性,更加符合开闭原则。
模式定义
**工厂方法模式(Factory Method Pattern)**又称工厂模式,或虚拟构造器(Virtual Constructor)模式、多态工厂(Polymorphic Factory),属于类创建型模式。该模式中,工厂父类负责负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪个具体产品类。
模式结构
工厂方法包含的角色:
- Product:抽象产品
- ConcreteProduct:具体产品
- Factory:抽象工厂
- ConcreteFactory:具体工厂
类图
时序图
代码示例
#include "ConcreteFactory.h"
#include "ConcreteProduct.h"
Product* ConcreteFactory::factoryMethod(){
return new ConcreteProduct();
}
#include "Factory.h"
#include "ConcreteFactory.h"
#include "Product.h"
#include <iostream>
using namespace std;
int main(int argc, char *argv[])
{
Factory * fc = new ConcreteFactory();
Product * prod = fc->factoryMethod();
prod->use();
delete fc;
delete prod;
return 0;
}
模式分析
由于使用了面向对象的多态性,工厂方法模式保持了简单工厂模式的优点,而且克服了它的缺点。在该模式中,核心的工厂类不再负责所有产品的创建,而是将具体创建工作交给子类去做。这个核心类仅仅负责给出具体工厂必须实现的接口,而不负责哪一个产品类被实例化这个细节,使得工厂方法模式可以允许系统在不修改工厂角色的情况下引进新产品。
实例
日志记录器
某系统日志记录器要求支持多种日志记录方式,如文件、数据库等。且用户可以根据要求动态选择日志记录方式,使用工厂方法进行设计:
类图:
时序图:
优点
- 该模式中,工厂方法用来创建用户所需要的产品,同时还向客户隐藏了哪种具体产品类将被实例化这一细节,用户只需要关心所需产品对应的工厂,无需关心创建细节,甚至无需知道具体产品类的类名。
- 基于工厂角色和产品角色的多态性设计是工厂方法模式的关键。它能使工厂可以自主确定创建何种产品对象,而如何创建这个对象的细节则完全封装在具体工厂内部。
- 在加入新产品时,无需修改抽象工厂和抽象产品提供的接口,无需修改客户端,也无需修改其他的具体工厂和具体产品,只需要添加一个具体工厂和具体产品就可以了。增加了扩展性,符合开闭原则。
缺点
- 在添加新产品时需要编写新的产品类,还要提供于此对应的具体工厂类,类的个数会成对增加,一定程度上增加了复杂性。
- 引入了抽象层,客户端的代码均使用抽象层进行定义,增加了系统的抽象性和理解难度。
适用场景
- 无需知道需要创建对象的类:该模式中,客户端不需要知道具体产品类的类名,只需要知道他对应的工厂即可,具体的产品对象由具体工厂类创建。
- 通过子类来指定创建哪个对象:该模式中,对于抽象工厂类只需要提供一个创建产品的接口,由其子类来确定具体要创建的对象,利用面向对象的多态性和里氏替换原则,在程序运行时,子类对象将覆盖父类对象,从而使系统更易扩展。
- 将创建对象的任务委托给多个工厂子类中的某一个,客户端在使用时无需关心是哪个工厂子类来创建产品子类,需要时再动态指定,可将具体工厂类的类名存储在配置文件或数据库中。
模式应用
JDBC 中的工厂方法:
Connection conn=DriverManager.getConnection("jdbc:microsoft:sqlserver://localhost:1433;DatabaseName=DB;user=sa;password=");
Statement statement=conn.createStatement();
ResultSet rs=statement.executeQuery("select * from UserInfo");
模式扩展
- 使用多个工厂方法:才抽象工厂中可以定义多个工厂方法,从而使具体工厂角色实现这些不同的工厂方法,这些方法可以包含不同的业务逻辑,以满足不同的产品对象的需求。
- 产品对象的重复使用:工厂对象将已创建过的产品保存到一个集合,然后根据客户端对产品的请求,对集合进行查询。
- 多态性的丧失和模式的退化:如果工厂仅仅返回一个具体产品对象,便违背了工厂方法的用意,发生退化,此时就不再是工厂方法模式了。工厂对象应该有一个抽象的父类型,如果工厂等级结构中只有一个工厂类的话,抽象工厂可以省略,则发生退化;当只有一个具体工厂,在具体工厂中创建所有产品对象,并且工厂方法设计为静态方法时,该模式就退化成了简单工厂模式。
总结
- 工厂方法模式又称为工厂模式,属于类创建型模式。该模式中,工厂父类负责定义创建产品对象的公共接口,而工厂子类则负责生成具体的产品对象,以将产品类的实例化操作延迟到工厂子类中完成,即通过工厂子类来确定究竟应该实例化哪一个具体产品类。
- 工厂方法模式包含四个角色:
- 抽象产品是定义产品的接口,是工厂方法模式所创建对象的超类型,即产品对象的共同父类或接口;
- 具体产品实现了抽象产品接口,某种类型的具体具体产品由专门的具体工厂创建,他们之间往往一一对应;
- 抽象工厂中声明了工厂方法,用于返回一个产品,它是该模式的核心,任何在模式中创建对象的工厂类都必须实现该接口;
- 具体工厂是抽象工厂的子类,实现了抽象工厂中定义的方法,并可由客户调用,返回一个具体产品类的实例。
- 该模式是简单工厂模式的进一步抽象和推广。由于使用了面向对象的多态性,工厂方法模式保持了简单工厂模式的优点,同时克服了它的缺点。核心的工厂类不再负责创建所有类型的产品,而是将其创建交给具体子类去做。该核心类仅负责给出具体工厂必须实现的接口,而不负责产品类被实例化这种细节,这使得该模式可以允许系统在不修改工厂角色的情况下引进新产品。
- 主要优点是增加新产品而无需修改现有系统,并封装了产品对象的创建细节,系统具有良好的灵活性和可扩展性;缺点在于增加新产品的同时需要增加对应的具体工厂,导致类的个数增长,一定程度上增加了系统的复杂性。
- 该模式适合的情况包括:一个类不知道他所需要的对象的类;一个类通过其子类来指定创建哪个对象;将创建对象的任务委托给多个工厂子类中的某一个,客户端在使用时可以无需关心是哪一个工厂子类创建产品子类,需要时再动态绑定。
1.3 - CH03-创建型-抽象工厂
模式动机
在工厂方法模式中,具体工厂负责生成具体的产品,每个具体工厂对应一个具体产品,工厂方法也具有唯一性,一般情况下,一个具体工厂中只有一个工厂方法或一组重载的工厂方法。但是有些时候我们需要一个工厂能够提供多个产品对象,而不是一个单一的产品对象。
为了清晰理解工厂方法模式,需要知道两个概念:
- 产品等级结构:产品等级结构即产品的继承结构(如一个抽象类是电视机,其子类有:海尔、海信、TCL等,抽象电视机与对应品牌的电视机即构成了产品等级结构)。
- 产品族:在抽象工厂模式中,产品族是指由一个工厂生产的,位于不同产品等级结构中的一组产品。如海尔电器工厂生产的电视机、电冰箱,海尔电视机位于电视机产品等级结构中,海尔电冰箱位于电冰箱产品等级结构中。
当系统提供的工厂需要生产的具体产品并不是一个简单的对象,而是多个位于不同产品等级结构中、属于不同类型的具体产品时,需要使用抽象工厂模式。
该模式是所有形式的工厂模式中最为抽象和最具一般性的形态。
抽象工厂模式与工厂方法模式最大的区别在于:工厂方法模式针对的是一个产品等级结构,而抽象工厂模式需要面对多个产品等级结构,一个工厂等级结构,可以负责多个不同产品等级结构中的产品对象的创建。当一个工厂等级结构(海尔电器工厂),可以创建出分属不同产品等级结构的 一个产品族中的所有对象(电视机、电冰箱)时,抽象工厂模式比工厂方法模式更有效率。
模式定义
抽象工厂模式(Abstract Factory Pattern):提供一个 创建一系列相关或相互依赖对象 的接口,而无需指定他们的具体类。又称为 Kit 模式。
模式结构
包含如下角色:
- AbstractFactory:抽象工厂
- ConcreteFactory:具体工厂
- AbstractProduct:抽象产品
- Product:具体产品
类图
解释:
- AbstractFactory 可以理解为 电器工厂;
- ConcreteFactory1 可以理解为 海尔电器工厂;
- ConcreteFactory2 可以理解为 格力电器工厂;
- AbstractProductA 理解为产品 电视机;
- AbstractProductB 理解为产品 电冰箱;
- ProductA1 则为 海尔牌电视机;
- ProductA2 则为 格力牌电视机;
- ProductB1 则为 海尔牌电冰箱;
- ProductB2 则为 格力牌电视机;
因此,海尔电器工厂 可以生产 海尔牌的电视机和电冰箱,同理格力电器工厂。这样,产品等级结构(电视机、电冰箱)和产品族(海尔牌、格力牌)通过两种维度实现对产品的建模。
时序图
优点
- 抽象工厂模式隔离了具体类的生产,使得客户端并不知道什么被创建。由于这种隔离,更换一个具体工厂就变得相对容易。所有的具体工厂都实现了抽象工厂中定义的那些公共接口,因此只需改变具体工厂的实例,就可以从某种程度上改变整个系统的行为。同时该模式可以实现高内聚低耦合的实际目的,因此该模式被广泛应用。
- 当一个产品族中的多个对象被设计成一起工作时,它能够保证客户端始终只使用一个产品族中的对象。这对一些需要根据当前环境来决定其行为的软件系统来说,是一种非常实用的设计模式。
- 增加新的具体工厂和产品族非常方便,无需修改已有系统,符合开闭原则。
缺点
- 在添加新的产品对象时,难以扩展抽象工厂来生产新种类的产品,这是因为在抽象工厂角色中规定了所有可能被创建的产品集合,要支持新种类的产品就要对该接口进行扩展,这会涉及到对抽象工厂角色及其所有子类进行修改,会带来较大不便。
- 开闭原则的倾斜性,增加新的工厂和产品族容易,增加新的产品等级结构麻烦。
适用场景
- 当一个系统不需要依赖于产品类实例如何被创建、组合、表达的细节,这对于所有形式的工厂模式都是重要的。
- 系统中有多于一个的产品族,而每次只适用其中一个产品族。
- 属于同一个产品族的产品将在一起使用,这一约束必须在系统的设计中体现出来。
- 系统提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于实现。
模式应用
比如一些软件系统中需要更换系统主题,要求界面中的按钮、文本框、背景等一起发生改变时,就可以使用该模式。比如:按钮元素的不同形式构成一个产品等级结构,不同元素的同一主题形式构成一个产品族。
模式扩展
开闭原则的倾斜性:
- 开闭原则要求对系统扩展开发,对修改关闭,通过扩展达到增强其功能的目的。对于涉及到多个产品族与多个产品等级结构的系统,其功能增强则包括两方面:
- 增加产品族:对于增加新的产品族,本模式很好的支持了开闭原则,只需要增加一个新的具体工厂即可,对已有代码无需做任何修改;
- 增加产品等级结构:对于增加新的产品等级结构,需要修改所有的工厂角色,包括抽象工厂类,在所有的工厂类中都需要增加生成新产品的方法,不能很好的支持开闭原则。
- 抽象工厂模式的这种性质称为开闭原则的倾斜性,该模式以一种倾斜的方式来支持增加新的产品,为新产品族的增加提供方便,但不能为新的产品等级结构增加提供方便。
工厂模式退化:
- 当抽象工厂模式中每一个具体工厂类只创建一个产品对象,也就是只存在一个产品等级结构时,该模式也就退化成了工厂方法模式;
- 当工厂方法模式中抽象工厂与具体工厂合并,提供一个统一的工厂来创建产品对象,并将创建产品的方法设计为静态方法时,工厂方法模式退化成简单工厂模式。
总结
- 该模式提供了一个创建一系列相关或相互依赖对象的接口,而无需指定他们的具体类。
- 包含四种角色。
- 是所有形式的工厂模式中最为抽象和最具一般性的一种形态。
- 主要优点是隔离了具体类的生成,使得客户端不知道什么被创建。
- 。。。
1.4 - CH04-创建型-建造者
模式动机
一些复杂的对象,拥有多个组成部分,比如汽车,包括车轮、方向盘、发动机等。对于大多数用户而言,无需知道这些部件的装配细节,也几乎不会使用单独某个部件,而是使用一辆完整的汽车。可以通过建造者模式对其进行设计与描述,建造者模式可以将其部件和其组装过程分开,逐步创建一个对象。用户只需要指定复杂对象的类型就可以得到该对象,而无需知道其内部的具体构造细节。
软件系统中也存在大量类似汽车的复杂对象,拥有一系列成员和属性,这些成员属性中有些是引用类型的成员对象。而且这些复杂对象中,还可能存在一些限制条件,如某些属性赋值则复杂对象不能作为一个完整的产品使用;有些属性的赋值必须按照某个顺序,一个属性没有赋值之前,另一个属性可能无法赋值等。
复杂对象相当于一辆有待建造的汽车,而对象的属性相当于汽车的部件,建造产品的过程就相当于组合部件的过程。由于组合部件的过程很复杂,因此,这些部件的组合过程往往被“外部化”到一个称为建造者的对象里,建造者返还给客户端的是一个已经建造完毕的完整产品对象,而用户无需关心该对象所包含的属性以及他们的组装方式,这就是建造者模式的模式动机。
模式定义
建造者模式(Builder Pattern),将一个复杂对象的构建与他的表示分离,使得同样的构建过程可以创建不同的表示。
该模式是逐步创建一个复杂对象,允许用户只通过指定复杂对象的类型和内容就可以构建他们,而不需要知道他们内部的具体构建细节。又称为生成器模式。
模式结构
- Builder:抽象建造者
- ConcreteBuilder:具体建造者
- Director:指挥者
- Product:产品角色
类图
时序图
代码示例
抽象建造者:
public interface PersonBuilder{
void buildHead();
void buildBody();
void buildFoot();
Persion buildPerson();
}
具体建造者 1:
public class ManBuilder implements PersonBuilder{
Person person;
public ManBuilder() {
person = new Man();
}
public void buildBody() {
person.setBody("set man's body");
}
public void buildFoot() {
person.setFoot("set man's foot");
}
public void buildHead() {
person.setHead("set man's head");
}
public Person buildPerson() {
return person;
}
}
具体建造者 2:
public class WomanBuilder implements PersonBuilder{
Person person;
public WomanBuilder() {
person = new Woman();
}
public void buildBody() {
person.setBody("set woman's body");
}
public void buildFoot() {
person.setFoot("set woman's foot");
}
public void buildHead() {
person.setHead("set woman's head");
}
public Person buildPerson() {
return person;
}
}
指挥者:
public class PersonDirector {
public Person constructPerson(PersonBuilder pb){
pb.buildHead();
pb.buildBody();
pb.buildFoot();
return pb.buildPerson();
}
}
产品:
public class Person {
private String head;
private String body;
private String foot;
public void setHead(String head){
this.head = head;
}
public void setBody(String body){
this.body = body;
}
public void setFoot(String foot){
this.foot = foot;
}
}
public class Man extends Person {
public Man() {
System.out.println("Start building Man");
}
}
public class Woman extends Person {
public Woman() {
System.out.println("Start building Woman");
}
}
用例:
public class Usage{
public static void main(String[] args){
PersonDirector pd = new PersonDirector();
Person manPerson = pd.constructPerson(new ManBuilder());
Person womanPerson = pd.constructPerson(new WomanBuilder());
}
}
模式分析
抽象建造者类中定义了产品的创建方法和返回方法。
建造者模式的结构中还引入了一个指挥者类 Director,该类作用主要有两个:一方面将客户端与产品生产过程;另一方面负责整个产品的生产过程。
指挥者针对抽象建造者编程,客户端只需要提供具体建造者的类型,即可通过指挥者来调用具体建造者的方法,返回一个完整的产品对象。
在客户端代码中,则不需要关心产品具体的生产过程,只需要提供具体建造者类型来调用指挥者的创建方法。建造者模式将复杂对象的构建与对象的表现分离开来,这使得同样的构建过程可以创建出不同的表现。
实例
KFC 套餐
套餐看做是一个复杂对象,一般包括主食(汉堡、鸡肉卷等)和饮料(果汁、可乐等)等组成部分,服务员可以根据要求,逐步装配这些组成部分,构造一份完整的套餐,然后返回给顾客。
类图:
优点
- 客户端不必知道产品内部的组成细节,将产品本身与产品的创建过程解耦,使得相同的创建过程(由父类创建者约束了创建该过程)可以创建不同的产品对象。
- 每一个具体建造者都相对独立,与其他的具体建造者无关,因此可以很方便的替换具体建造者或者增加新的具体建造者,用户使用不同的具体建造者即可得到不同的产品对象。
- **可以更加精细的控制产品的创建过程。**将复杂产品的的创建步骤分解在不同的方法中,使得创建过程更加清晰,也更方便使用程序来控制创建过程。
- **增加新的具体创建者无需修改原有代码,指挥者针对抽象建造者编程。**系统扩展方便,符合开闭原则。
缺点
- 建造者模式所创建的产品一般具有较多的共同点,其组成部分相似,如果产品之间的差异性很大,则不适合使用建造者模式。
- 如果产品的内部变化复杂,可能需要定义很多具体建造者来实现这些变化,导致系统庞大。
适用场景
- 需要生产的产品有复杂的内部结构,产品通常包含多个成员属性。
- 需要生产的额产品,其属性相互依赖,需要制定生成顺序。
- 对象的创建过程独立于创建该对象的类。在该模式中引入了指挥者类,将创建过程封装在指挥者类中,而不再建造者中。
- 隔离复杂对象的创建和使用,并使得相同的创建过程可以创建不同的产品。
模式扩展
建造者模式的简化:
- 省略掉抽象建造角色:如果系统中只需要一个具体建造者的话。
- 省略掉指挥者:在具体建造者只有一个的情况下,如果抽象建造者以及被省略掉,那么还可以省略掉指挥者,让建造者充当指挥者与建造者角色。
建造者模式与抽象工厂模式的比较:
- 建造者返回一个组装好的完整产品,抽象工厂返回一系列相关的产品,这些产品位于不同的产品等级结构,构成一个产品族。
- 抽象工厂中,客户端实例化工厂类,然后调用工厂方法获取所需产品对象;建造者中,客户端通过指挥类而不是调用建造者的相关方法,侧重于逐步构造一个复杂对象。
- 如果将抽象工厂看做是“汽车配件生产工厂”,生产一个产品族的产品;建造者模式就是一个“汽车组装工厂”,通过对部件的组装返回一辆完整的汽车。
1.5 - CH05-创建型-单例
模式动机
对于系统中的某些类来说,只有一个实例很重要。比如,系统中可以有多个打印任务,但是只能有一个正在工作的任务;一个系统只能有一个窗口管理器和文件系统;一个系统只能有一个计时器或 ID 生成器。
方式是让类自身负责保存它的唯一实例,保证没有其他实例被创建,并且提供一个访问该实例的方法。
模式定义
单例模式(Singleton Pattern),确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例,这个类称为单例类,它提供全局访问的方法。
单例模式的要点有三个:类只能有一个实例;必须自行创建这个实例;必须向整个系统提供这个实例。
模式结构
单例模式仅有一个角色:Singleton
类图
时序图
代码实例
模式分析
单例模式的目的是保证一个类仅有一个实例,并提供一个访问该实例的全局访问点。单例模式的角色只有一个,就是单例类-Singleton。单例类拥有一个私有构造函数,确保用户无法通过new
关键字直接实例化它。该模式中包含一个静态私有成员变量与静态公共工厂方法,工厂方法负责检验实例是否存在并实例化自己,然后存储在静态成员变量中,以确保只有一个实例被创建。
在单例模式的实现过程中,需要注意一下要点:
- 单例类的构造函数为私有;
- 提供一个自身的静态私有成员变量;
- 提供一个共有的静态工厂方法。
优点
- 提供了对唯一实例的受控访问。因为单例类封装了它唯一的实例,所以他可以严格控制客户端怎样以及何时访问它,并为设计及开发团队提供了共享的概念。
- 由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象,该模式可以提高系统性能。
- 允许可变数目的实例。可以基于单例模式进行扩展,使用与单例控制相似的方法来获得指定个数的对象实例。
缺点
- 由于单例模式中没有抽象层,因此单例类的扩展有很大的困难。
- 单例类的职责过重,在一定程度上违背了单一职责原则。因为单例类既充当了工厂角色,提供了工厂方法,同时又充当产品角色,包含一些业务方法,将产品的创建和产品本身的功能融合到了一起。
- 滥用单例将带来一些负面问题,比如,为了节省资源将数据库连接池对象设计成单例类,可能会导致共享该连接池对象的程序过多而出现连接池溢出;如果该对象长期不使用将被 GC 回收,导致对象状态丢失。
适用场景
- 系统中只需要一个实例对象,如提供一个序列号生成器,或资源消耗太大而只能创建一个实例。
- 客户端调用类的单个实例只允许适用一个公共访问点,不能通过其他途径访问该实例。
- 系统中要求一个类只能有一个实例时才应当使用单例模式。
1.6 - CH06-结构型-适配器
模式动机
- 在软件开发中采用类似于电源适配器的设计和编码技巧被称为适配器模式。
- 通常,客户端可以通过目标类的接口访问他所提供的服务。有时,现有的类可以满足客户类的功能需要,但是它所提供的接口不一定是客户类所期望的,这可能是现有类中方法名与目标类中定义的方法名不一致等原因导致的。
- 这时,现有的接口要转化为客户类期望的接口,这样保证了对现有类的重用。如果不进行这样的转化,客户类就不能利用现有类提供的功能,适配器用于完成这些转化。
- 在适配器模式中可以定义一个包装类,包装不兼容接口的对象,这个包装类即为适配器(Adapter),他所包装的对象就是适配者(Adaptee),即被适配的类。
- 适配器提供客户类需要的接口,适配器的实现就是把客户类的请求转化为对适配者的相应接口调用。也就是说:当客户类调用适配器的方法时,在适配器的内部将调用适配者的方法,而这个过程对客户端是透明的,客户端并不直接访问适配者类。因此,适配器可以使因为接口不兼容而不能交互的类完成兼容。即该模式的动机。
模式定义
适配器模式(Adapter Pattern),将一个接口转换为客户端期望的另一个接口,使接口不兼容的类可以在一起工作,别名为包装器(Wrapper)。该模式即可以作为类接口型模式,也可以作为对象结构型模式。
模式结构
包含如下角色:
- Target:目标抽象类
- Adapter:适配器类
- Adaptee:适配者类
- Client:客户类
类图
分为对象适配器和类适配器两种实现:
对象适配器
类适配器
时序图
代码分析
对象适配器:
int main(int argc, char *argv[]){
Adaptee * adaptee = new Adaptee();
Target * target = new Adapter(adaptee);
target->request();
return 0;
}
优点
- 将目标类与适配者类解耦,通过引入一个适配器类来重用现有的适配者类,而无需修改原有代码;
- 增加了类的透明性和复用性,将具体的实现封装在适配者类中,对于客户端来说是透明的,而且提高了适配者的复用性。
- 灵活性和扩展性很好,通过使用配置文件,可以方便的更换适配器,也可以在不修改原有代码的基础上增加的的适配器类,完全符合“开闭原则”。
类适配器模式的独特优点:
由于适配器类是适配者类的子类,因此可以在适配器类中置换一些适配者的方法,使得适配器的灵活性更强。
对象适配器模式的独特优点:
一个对象适配器可以把多个不同的适配者适配到同一目标,也就是说,同一个适配器可以把适配者类和它的子类都适配到目标接口。
缺点
- 类适配器的缺点:对于 Java、C# 等不支持多重继承的语言,一次只能适配一个适配者类,而且目标类只能为抽象类,不能为具体类,有一定局限性,不能将一个适配者类和其子类都适配到目标接口。
- 对象适配器的缺点:对类适配器相比,要想置换适配者类的方法不方便。如果一定要置换掉适配者类的一个或多个方法,就只好先做一个适配者类的子类,将适配者类的方法置换掉,然后把这个子类当做真正的适配者进行适配,实现复杂。
适用场景
- 需要使用现有类,而这些类的接口不符合系统的需要。
- 想要建立一个可重复使用的类,用于与一些彼此之间没有太大关联的类,包括一些可能在将来引进的类一起工作。
模式扩展
默认适配器模式(Default Adapter Pattern),或称缺省适配器模式。当不需要全部实现接口提供的方法时,可先设计一个抽象类实现接口,并为该接口中每个方法提供一个默认实现(空方法),该抽象类的子类则可以有选择的覆盖父类的某些方法来实现需求。适用于一个接口不想使用其所有的方法的情况,因此也称为单接口适配器模式。
1.7 - CH07-结构型-桥接
模式动机
设想如果要绘制矩形、圆形、椭圆、正方形,至少需要四种形状类,但是如果绘制的图形具有不同的颜色,此时至少有以下两种设计方案:
- 为每一种形状都提供一套各种颜色的版本
- 根据实际需要对形状和颜色进行组合
对于有两个变化维度的系统,采用第二种方案进行设计则类的个数更少,系统扩展也更方便。方案二即为桥接模式,该模式将继承关系转换为关联关系,减少耦合和编码量。
模式定义
桥接模式(Bridge Pattern),将抽象部分与它的实现部分分离,使他们都可以独立变化。又称为柄体模式(Handle and Body)或接口模式(Interface)。
模式结构
包含四种角色:
- Abstraction:抽象类
- RefinedAbstraction:扩充抽象类
- Implementor:实现类接口
- ConcreteImplementor:具体实现类
类图
时序图
代码分析
int main(int argc, char argv[]){
Implementor * pImp = new ConcreteImplementorA();
Abstraction * pa = new RefinedAbstraction();
pa->operation();
Abstraction * pb = new RefinedAbstraction(new ConcreteImplementorB());
pb->operation();
...
}
模式分析
理解桥接模式,重点需要理解如何将抽象化(Abstraction)和实现化(Implementation)解耦,使得二者可以独立的变化。
- 抽象化:抽象化就是忽略一些信息,把不同的实体当做同样的实体对待,将对象的共同性质抽取出来形成类的过程即为抽象化;
- 解耦:解耦就是将抽象化和实现化之间的耦合解脱开,或者将他们之间的强关联转换成弱关联,将两个角色之间的继承关系改为关联关系。桥接模式中的解耦,是指在一个软件系统的抽象化和实现化之间使用关联关系(组合、聚合),而不是继承关系,从而使两者可以相互独立的变化,也就是桥接模式的动机。
实例
如果需要开发一个跨平台的视频播放器,可以在不同操作系统平台上播放多种格式的视频文件,常见的视频格式包括 MPEG、RMVB、AVI、WMV 等。
优点
- 分离抽象接口及其实现部分。
- 桥接模式有时类似于多继承方案,但是多继承方案违背了类的单一职责原则(一个类只有一个变化的原因),复用性较差,而且多继承中类的个数非常庞大,桥接则是更好的方法。
- 提高了系统的可扩充性,在两个变化维度任意扩展一个维度,都不需要修改原有的系统。
- 实现细节对客户透明,可以对用户隐藏实现细节。
缺点
- 桥接模式的引入会增加系统的理解与设计难度,由于聚合关联关系建立在抽象层,要求开发者针对抽象进行设计与编程。
- 要求正确识别出系统中两个独立变化的维度,使用范围有一定局限性。
适用场景
- 如果一个系统需要在构件的抽象化角色和具体化角色之间增加更多灵活性,避免在两个层次之间建立静态继承关系,通过桥接模式可以使他们在抽象层建立一个关联关系。
- 抽象化角色和实现化角色可以以继承的方式独立扩展而互不影响,在程序运行时可以动态将一个抽象化子类的对象和一个实现化子类的对象进行组合,即系统需要对抽象化角色和实现化角色进行动态耦合。
- 一个类存在两个独立变化的维度,且这两个维度都需要独立扩展。
- 虽然在系统中使用继承是没有问题的,但是由于抽象化角色和实现化角色需要独立变化,设计要求独立管理这两者。
- 对于那些不希望使用继承或因为多层继承导致系统类的个数急剧增加的系统,桥接模式尤为适用。
模式扩展
适配器与桥接模式联用:
桥接模式和适配器模式用于设计的不同阶段。桥接模式用于系统的初步设计,对于存在两个独立变化维度的类可以将其分为抽象化和实现化两个角色,使他们可以进行分别变化;在初步设计完成后,当发现系统与已有类无法协同工作时,可以使用适配器模式。有时也需要在设计初期使用适配器模式,尤其是在那些涉及大量第三方应用接口的时候。
1.8 - CH08-结构型-装饰器
模式动机
一般有两种方式可以实现给一个类或对象增加行为:
- 继承机制:通过继承一个类,使子类在拥有自身方法的同时拥有父类的方法。但是这种方式是静态的,用户不能控制增加行为的方式和时机。
- 关联机制:即将一个类的对象嵌入到另一个对象中,由另一个对象来决定是否调用嵌入对象的行为以便扩展自己的行为,这个嵌入的被称为装饰器。
装饰器模式以客户透明的方式动态给一个对象附加上更多的责任,客户端并不会觉得对象在装饰前后有什么不同。可以在不创建更多子类的情况下扩展对象的功能。即该模式的动机。
模式定义
装饰器模式(Decorator Pattern),动态给对象增加一些额外的职责(Responsibility),就增加对象功能来说,装饰模式比创建子类的实现更为灵活。或称为“包装器”,与适配器别名相同但应用场景不同,或称“油漆工模式”。
模式结构
包含四种角色:
- Component:抽象构件
- ConcreteComponent:具体构件
- Decorator:抽象装饰类
- ConcreteDecorator:具体装饰类
类图
时序图
代码分析
模式分析
- 与继承关系相比,关联关系的主要优势在于不会破坏的类的封装性,而且继承是一种耦合度较大的静态关系,无法在程序运行时动态扩展。在软件开发阶段,关联关系虽然不会比继承关系减少编码量,但是到了维护阶段,由于关联关系具有较好的松耦合性,因此系统会更易维护。而关联关系的缺点则是会比继承关系要多创建更多对象。
- 使用装饰器模式来实现扩展比继承更加灵活,它以对客户透明的方式动态的给一个对象附加更多的责任。该模式可以在不创建更多子类的情况下,扩展对象功能。
实例
变形金刚
变形金刚在变型之前是一辆汽车,它可以在陆地上移动。变型之后除了能够移动外还可以说话;或者变成飞机飞行。
类图:
时序图:
优点
- 装饰模式与继承关系的目的都是要扩展对象的功能,但是装饰器比继承更为灵活。
- 可以通过动态的方式来扩展一个对象的功能,通过配置文件在运行时选择不同的装饰器,从而实现不同的行为。
- 通过使用不同的具体装饰类已经这些装饰类的排列组合,可以创造出很多不同行为的组合。或者使用多个不同的具体装饰类装饰同一个对象,得到功能更对强大的对象。
- 具体构件类与具体装饰类可以独立变化,用户可以根据需要增加新的具体构建类和具体装饰类,在使用时再对其进行组合,原有代码无需改变,符合开闭原则。
缺点
- 使用装饰模式进行系统设计时将产生很多小对象,这些对象的区别在于他们之间的相互连接方式有所不同,而不是他们的类或属性值有所不同,同时还将产生很多具体装饰类。这些装饰类和小对象的产生将增加系统的复杂性,加大学习与理解难度。
- 这种比继承机制更加灵活的特性,同时也意味着装饰模式比继承模式更易出错,排查也更加困难,对于多次装饰的对象,调试时寻找试错可能需要逐级排查,较为繁琐。
适用场景
- 在不影响其它对象的情况下,以动态、透明的方式给单个对象增加职责。
- 需要动态给一个对象增加功能,这些功能也可以动态的被撤销。
- 当不能采用继承的方式对系统中进行扩从或采用继承不利于系统扩展与维护时。有两种情况不适合使用继承:系统中存在大量独立的扩展,为支持每一种组合将产生大量的子类;活类的定义为 final,不能继承。
模式扩展
装饰器模式简化需要注意的问题:
- 一个装饰类的接口必须与被装饰类的接口保持相同,对于客户端来说,对象在被装饰前后都可以一致对待。
- 尽量保持具体构件类 Component 作为一个“轻”类,不要把太多的逻辑和状态放在具体构件类中,可以通过装饰类来完成这些工作
对该模式进行扩展时:如果只有一个具体构件类而没有抽象构件类,那么抽象装饰类可以作为具体构件类的直接子类。
1.9 - CH09-结构型-外观
模式动机
模式定义
外观模式(Facade Pattern),外部与子系统的通信必须通过一个统一的外观对象进行,为子系统中的一组接口提供一个一致的界面,外观模式定义了一个高层接口,这个接口使得这一子系统更加容易使用。又称门面模式。
模式结构
包含两种角色:
- Facade:外观角色
- SubSystem:子系统角色
类图
时序图
代码分析
模式分析
- 根据单一职责原则,在软件中将一个系统划分为若干个子系统有利于降低整个系统的复杂性,一个最常见的目标是使子系统之间的通信和相互依赖关系达到最小,达到该目标的方式之一就是引入一个外观对象,来为子系统提供一个简单而单一的入口。
- 外观模式也是迪米特法则的体现,通过引入一个新的外观类可以降低原有系统的复杂度,同时降低客户类与子系统类的耦合度。
- 该模式要求,一个子系统的外部与其内部的通信通过一个统一的外观对象进行,外观类将客户端与子系统的内部复杂度分隔开,客户端则只需要与外观类交互,而不需要和子系统内部的诸多对象交互。
- 该模式的目的在于降低系统的复杂度。
- 该模式很大程度上提高了客户端使用上的便捷性,使得客户端无需关心子系统的内部细节。
优点
- 对客户端屏蔽子系统组件,减少客户处理的对象数目,并使得子系统用起来更容易。
- 实现客户端与子系统的松耦合,使子系统的组件变化不会影响到客户类,只需要调整外观类即可。
- 降低大型系统中的编译依赖性,并简化了系统在不同平台之间的迁移过程,可以以一个更小粒度(子系统)修改、编译软件。
- 只是提供了一个统一访问子系统的统一入口,并不影响用户直接使用子系统类。
缺点
- 不能很好的限制客户使用子系统类,如果对客户访问子系统类做太多的限制则减少了可变性和灵活性。
- 在不引入抽象外观类的情况下,增加新的子系统可能要修改外观类或客户端的代码,违背了开闭原则。
适用场景
- 当要为复杂子系统提供一个简单接口时可以考虑该模式。
- 当客户与多个子系统之间存在很大的依赖性。引入外观类将子系统与客户以及其他子系统解耦,可以提高子系统的独立性和可移植性。
- 在层次化结构中,可以使用外观模式定义系统中的每一层接口,曾与曾之间不直接产生联系,而通过外观类建立联系,降低层之间的耦合度。
模式扩展
一个系统有多个外观类
通常只有一个外观类,同时只有一个实例,即它是一个单例类。同时也可以定义多个外观类,分别于不同的特定子系统交互。
不要通过外观类为子系统增加新的行为
外观模式与迪米特法则
外观类充当了客户与子系统之间的第三者,降低客户与系统的耦合度。
引入抽象外观类
该模式最大的缺点在于违背了开闭原则。当增减子系统时可以通过引入抽象外观类来解决该问题,客户端则针对抽象外观类编程。以增减具体外观类的方式来支持子系统的变更。
1.10 - CH10-结构型-享元
模式动机
OOP 可以很好的解决一些灵活性和扩展性问题,但同时要增加对象的个数。当对象数过多则会导致运行代价过高,带来性能问题。
- 享元模式正式为了解决这类问题。该模式通过共享技术实现相同或相似对象的重用。
- 在该模式中可以共享的相同内容称为内部状态(Intrinsic State),而那些需要通过外部环境来配置的不能共享的内容称为外部状态(Extrinsic State)。因为区分了内外状态,可以通过设置不同的外部状态使得相同对象可以具有一些不同的特征,而相同的内部状态则可以共享。
- 该模式中通常会出现工厂模式,需要创建一个享元工厂来负责维护一个**享元池(Flyweight Pool)**用于存储具有相同内部状态的享元对象。
- 该模式中共享的享元对象的内部状态、外部状态需要通过环境来配置。在实际使用中,能够共享的内部状态是有限的,因此享元对象一般都设计为较小的对象,所包含的内部状态较少,也称为细粒度对象。该模式的目的就是使用共享技术来实现大量细粒度对象的复用。
模式定义
享元模式(Flyweight Pattern),运用共享技术有效的支持大量细粒度对象的复用。系统只使用少量的对象,而这些对象都很相似,状态变化很小,可以实现对象的多次复用。由于该模式要求能够共享的对象必须是细粒度对象,因此也称为轻量级模式。
模式结构
该模式包含四种角色:
- Flyweight:抽象享元类
- ConcreteFlyweight:具体享元类
- UnsharedConcreteFlyweight:非共享具体享元类
- FlyweightFactory:享元工厂类
类图
时序图
代码分析
模式分析
该模式是一种考虑系统性能的设计模式,使用该模式以节约内存空间,提供系统性能。
享元模式的核心在于享元工厂类,它的作用在于提供一个用于存储享元对象的享元池,用户需要对象时,首先从享元池中取,如果不存在则创建一个该具体享元类的实例,存入享元池并返回给客户。
该模式以共享的方式高效的支持大量的细粒度对象,享元对象能够做到共享的关键是区分内部状态和外部状态:
- 内部状态:是存储在享元对象内部并且不会随着环境改变而改变的状态。
- 外部状态:是随环境而改变的、不可共享的状态。享元对象的外部状态必须由客户端保存,并在享元对象被创建之后,在需要使用的时候在传入到享元对象内部。外部状态相互之间是独立的。
优点
- 可以极大减少内存中对象的数量,使得相同对象或者相似对象在内存中只保存一份。
- 外部状态相互独立,而且不会影响其内部状体,从而使得享元对象可以在不同的环境中被共享。
缺点
- 该模式使得系统更加复杂,需要分离内部状态和外部状态,使程序逻辑复杂。
- 为了使对象可以共享,需要将对象的状态外部化,而读取外部状态会使运行时间变长。
适用场景
- 一个系统有大量相同或相似对象,由于这类对象的大量使用,造成内存大量使用。
- 对象的大部分状态可以外部化,可以将这些外部状态传入对象中。
- 使用该模式需要维护一个存储享元对象的享元池,而这需要消耗资源,因此,应当在多次重复使用享元对象时才值得使用该模式。
模式应用
该模式大量应用于编辑器软件,如在一个文档中多次使用相同的图片等。
模式扩展
单纯享元模式和复合享元模式:
- 单纯享元模式:所有的享元对象都是可以共享的,即所有抽象享元类的子类都可共享,不存在非共享具体享元类。
- 复合享元模式:将一些单纯享元使用组合模式加以组合,可以形成复合享元对象,这样的复合享元对象本身不能共享,但是可以分解成单纯享元对象,然后进行共享。
与其他模式联用:
- 在享元工厂类中通常提供一个静态工厂方法用于返回享元对象,使用简单工厂模式来生成享元对象。
- 在一个系统中,通常只有唯一一个享元工厂,因此享元工厂类可以使用单例模式进行设计。
- 可以结合组合模式形成复合享元模式,统一对享元对象设置外部状态。
1.11 - CH11-结构型-代理
模式动机
某些情况下,一个客户不想或者不能直接引用一个对象,此时可以使用一个称为代理的第三者来实现间接引用。代理对象可以在客户端和目标对象之间起到中介作用,并且可以通过代理对象来去掉客户不能看到的内容、服务或资源,或添加额外的功能。
通过引入一个新的对象(小图片、远程代理 对象)来实现对真实对象的操作,或者将新的对象作为真实对象的一个替身,这种实现机制即为代理模式,通过引入代理对象来间接访问一个对象。
模式定义
代理模式(Proxy Pattern),给某一个对象提供一个代理,并由代理对象控制对源对象的引用。或称为 Surrogate 。
模式结构
共包含三种角色:
- Subject:抽象主题角色
- Proxy:代理主题角色
- RealSubject:真实主题角色
类图
时序图
优点
- 能够协调调用者和被调用者,一定程度上降低了系统耦合度。
- 远程代理使得客户端可以访问在远程机器上的对象,远程机器可能具有更好的计算性能与处理速度,可以快速响应并处理客户端请求。
- 虚拟代理通过使用一个小对象来代表一个大对象,可以减少系统资源的消耗,优化系统以提升速度。
- 保护代理可以控制真是对象的使用权限。
缺点
- 由于在客户端和真实对象之间增加了代理,因此,有些类型的代理模式(如远程)可能会造成请求的处理速度过慢。
- 实现代理模式需要额外的工作,有些实现则比较复杂。
适用场景
- 远程代理(Remote):为一个处于不同地址空间的对象提供一个本地代理对象。
- 虚拟代理(Virtual):如果需要创建一个资源消耗过大的对象,先创建一个消耗相对较小的对象来表示,真实对象只在需要时在会被真正创建。
- Copy-To-Write 代理:是虚拟代理的一种,把复制(克隆)操作 延迟到只有在客户端真正需要时才执行。对象的深克隆是一个开销较大的工作,此方式可以使该操作延迟,需要时才执行。
- 保护代理(Protect or Access):控制一个对象的访问,可以给不同的用户提供不同级别的使用权限。
- 缓冲代理(Cache):为某一个目标操作的结果提供临时的存储空间,以使多个客户端可以共享这些结果。
- 防火墙代理(Firewall):保护目标不让恶意用户接触。
- 同步化代理(Synchronizaiton):使几个用户能够同时使用一个对象而没有冲突。
- 智能引用代理(Smart Reference):当一个对象被引用时,提供一些额外操作,比如记录对象的调用次数。
模式应用
EJB、Web Service等分布式技术都是代理模式的应用。
1.12 - CH12-行为型-命令
行为型模式概述
行为型模式(Behavioral Pattern)是对在不同的对象之间划分职责和算法的抽象。
该模式不仅仅关注类和对象的结构,而且关注他们之间的相互作用。
通过该模式,可以更加清晰地划分类与对象的职责,并研究系统在运行时实例对象之间的交互。在系统运行时,对象并不孤立,他们可以通过相互通信与协作完成某些复杂功能,一个对象在运行时也将影响到其他对象的运行。
该类模式又分为类行为模式和对象行为模式:
- 类行为模式:使用继承关系在几个子类之间分配行为,通过多态方式分配父类与子类的职责。
- 对象行为模式:使用对象的聚合关联关系来分分配行为,主要是通过对象关联等方式类分配两个或多个类的职责。根据合成复用原则,系统中要尽量通过关联关系来取代继承关系,因此绝大多数的行为设计模式都属于对象行为型模式。
命令模式
模式动机
软件设计中,经常要向对象发送请求,但是并不知道对象的接收者是谁,也不知道被请求的操作是哪个,只需要在运行时指定具体的请求接收者即可,此时,可以使用命令模式来进行设计,使得请求发送者和接收者之间接触耦合,增加对象调用之间的灵活性。
命令模式可以对发送者和接收者完全解耦,发送者与接收者之间没有直接引用关系,发送请求的对象只需要知道如何发送请求,而不必知道如何完成请求。这就是命令模式的模式动机。
模式定义
命令模式(Command Pattern):将一个请求封装为一个对象,从而使我们可以用不同的请求对客户进行参数化;对请求排队或记录请求日志,以及支持可撤销的操作。属于对象行为型模式,别名为动作模式(Action)或事务模式(Transaction)。
模式结构
包含五种角色:
- Command:抽象命令类
- ConcreteCommand:具体命令类
- Invoker:调用者
- Reciver:接收者
- Client:客户端
类图
时序图
代码示例
模式分析
命令模式的本质是对命令进行封装,将发送命令的责任和执行命令的责任分隔开。
- 每一个命令都是一个操作:请求方发出请求,要求执行一个操作;接收方收到请求,并执行操作。
- 命令模式运行请求方和接收方独立开来,使得请求方不必知道接收方的接口,更不必知道请求是如何被接收的,以及操作是否被执行、合适被执行,以及如何执行。
- 命令模式使请求本身称为一个对象,这个对象和其他对象一样可以被存储和传递。
- 命令模式的关键在于引入了抽象命令接口,并且发送者针对抽象命令接口编程,只有实现了抽象命令接口的具体命令才能与接收者相互关联。
优点
- 降低系统的耦合度
- 新的命令可以很容易地加入到系统
- 可以比较容易的设计一个命令队列和宏命令(组合命令)
- 可以方便的实现对请求的 Undo 和 Redo 操作
缺点
- 可能会导致某些系统有过多的具体命令类。因为针对每一个命令都需要设计一个具体命令类,因此某些系统可能会需要大量命令类,这将影响命令模式的使用。
适用场景
- 系统需要将请求调用者和接收者解耦,使得调用者和接收者不直接交互
- 需要在不同的时间指定请求、将请求排队、执行
- 系统需要支持命令的撤销和恢复操作
- 系统需要将一组操作组合在一起,即自持宏命令
模式扩展
- 宏命令又称组合命令,它是命令模式和组合模式联用的产物。
- 宏命令也是一个具体命令,不过他包含了对其他命令对象的引用。在调用宏命令的
execute()
方法时,将会递归调用它所包含的每个成员命令的execute()
方法。 - 一个宏命令的成员对象可以是一个简单命令也可以是另外一个宏命令。
- 执行一个宏命令将执行多个具体命令,从而实现对命令的批处理。
1.13 - CH13-行为型-中介者
模式动机
- 在用户与用户直接聊天的设计方案中,用户对象之间存在很强的关联性,将导致系统出现如下问题:
- 系统结构复杂:对象之间存在大量的相互关联和调用,如有一个对象发生变化,则需要跟踪那些与该对象关联的其他所有对象,并进行适当处理。
- 对象可用性差:由于一个对象和其他对象具有很强的关联,如没有其他对象的支持,一个对象很难被另一个系统或模块复用,这些对象表现出来更像一个不可分割的整体,职责较为混乱。
- 系统扩展性低:增加一个新的对象需要在原有对象上增加引用,增加新的引用关系也需要调整原有对象,系统耦合度高,对象操作不灵活,扩展性差。
- 在面向对象的软件设计与开发过程中,根据“单一职责原则”,应该尽量将对象细化,使其只负责或呈现大一的职责。
- 对于一个模块,可能有很多对象构成,而且这些对象之间可能存在相互的引用,为了减少对象两两之间复杂的引用关系,使之成为一个松耦合的系统,我们需要使用中介者模式,这就是该模式的动机。
模式定义
中介者模式(Mediator Pattern):用一个中介对象来封装一系列对象交互,中介者使各个对象不需要直接的相互引用,从而使其耦合松散,而且可以独立的改变他们之间的交互。中介者模式又称为调停者模式,是一种对象行为型模式。
模式结构
包含四种角色:
- Mediator:抽象中介者
- ConcreteMediator:具体中介者
- Colleague:抽象同事类
- ConcreteColleague:具体同事类
类图
时序图
模式分析
中介者模式可以使对象之间的关系数量急剧减少。
中介者承担两方面的职责:
- 中转作用(结构性):通过中介者提供的中转作用,各个同事对象之间就不需要再进行显式引用,当需要和其他同事进行通信时,通过中介者即可。该中转作用属于中介者在结构上的支持。
- 协调作用(行为性):中介者可以更进一步的对同事之间的关系进行封装,同事可以一致的和中介者进行交互,而不需要指明中介者需要怎么做,中介者根据封装在自身内部的协调逻辑,对同事的请求进行进一步处理,将同事成员之间的关系行为进行分离和封装。该协调作用属于中介者在行为上的支持。
优点
- 简化了对象之间的交互
- 将各同事解耦
- 减少子类生成
- 可以简化各同事类的设计和实现
缺点
在具体中介类中包含类同事之间的交互细节,可能会导致具体中介类非常复杂,使系统难以维护。
适用场景
- 系统中对象之间存在复杂的引用关系,产生的相互依赖关系结构混乱且难以理解。
- 一个对象由于引用了其他很多对象并且直接和这些对象通信,导致难以复用该对象。
- 想通过一个中间类来疯转多个类中的行为,而又不想生成太多的子类。
- 交互的公共行为,如果需要改变行为可以增加新的中介者类。
模式应用
MVC 架构中的控制器:Controller 作为一个中介者,它负责控制试图对象 View 和模型对象 Model 之间的交互。
1.14 - CH14-行为型-观察者
模式动机
建立一种对象与对象之间的依赖关系,一个对象改变时将自动通知其他对象,其他对象将作出相应反应。在此,发生改变的对象成为观察目标,被通知的对象成为观察者,一个观察目标可以对应多个观察者,而且这些观察者之间没有相互联系,可以根据需要增加或删除观察者,使得系统更易于扩展。这便是该模式的动机。
模式定义
观察者模式(Observer Pattern):定义对象的一种一对多关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新。或称为发布-订阅(Publish/Subscribe)模式、模型-试图(Model/View)模式、源-监听(Source/Listener)模式、从属(Dependents)模式。
模式结构
包含 4 中角色:
- Subject:目标
- ConcreteSubject:具体目标
- Observer:观察者
- ConcreteObserver:具体观察者
类图
时序图
模式分析
- 管擦者模式描述了如何建立对象与对象之间的依赖关系,如何构造满足这种需求的系统。
- 该模式中的关键对象是观察目标和管擦者,一个目标可以有任意数目的、与之相依赖的观察者,一旦目标的状态发生改变,所有的观察者都将得到通知。
- 作为多这个通知的响应,每个观察者都将更新自己的状态,以与目标状态同步,这种交互也被称为发布-订阅。目标是通知的发布者,它发出通知时并不知道谁是它的观察者,可以有任意数量的观察者订阅它并接收通知。
优点
- 该模式可以实现表示层和数据逻辑层的分离,并定义了稳定的消息更新传递机制,抽象了更新接口,使得可以有各种不同的表示层作为具体观察者角色。
- 该模式在观察目标和观察者之间建立了一个抽象的耦合。
- 观察者模式支持广播通信。
- 观察者模式符合“开闭原则”。
缺点
- 如果一个目标对象有很多直接和间接的观察者的话,将所有的观察者都通知到将会花费很多时间。
- 如果在观察者和目标之间有循环依赖的话,目标会触发他们之间进行循环调用,导致系统崩溃。
- 该模式没有相应的机制让观察者知道相应的观察目标是怎样发生变化的,而仅仅只是知道目标发生了变化。
适用场景
- 一个抽象模型有两个方面,其中一个方面依赖于另一个方面。将这些方面封装在独立的对象中,可以使他们各自独立的改变和复用。
- 一个对象的改变将导致其他一个或多个对象也发生改变,而不知道具体有多少个对象发生改变,可以降低对象之间的耦合度。
- 一个对象必须通知其他对象,而并不知道这些对象是谁。
- 需要在系统中创建一个触发链,A 对象的行为将影响 B 对象, B 对象的行为将会影响 C 对象…等等,可以使用该模式创建一种链式触发机制。
1.15 - CH15-行为型-状态
模式动机
- 很多情况下,一个对象的行为取决于一个或多个动态变化的属性,这样的属性称为状态,这样的对象称为有状态(stateful)对象,这样的对象状态是从事先定义好的一系列值中取出的。当一个这样的对象与外部事件产生互动时,其内部状态就会改变,从而使得系统行为也随之改变。
- 在 UML 中可以使用状态图来描述对象状态的变化。
模式定义
状态模式(State Pattern):允许一个对象在其内部状态改变时改变它自身的行为,对象开起来似乎修改了它的类。其别名为状态对象(Objects for states)。
模式结构
包含 3 种角色:
- Context:环境类
- State:抽象状态类
- ConcreteState:具体状态类
类图
时序图
模式分析
- 状态模式描述了对象的状态变化以及对象如何在每一种状态下表现出不同的行为。
- 状态模式的关键是引入了一个抽象类来专门表示对象的状态,这个类称为抽象状态类,而对象的每一种具体状态类都继承自该类,并在不同具体状态类中实现了不同状态的行为,以及不同状态间的转换。
需要理解环境类与抽象状态类的作用:
环境类实际上就是拥有状态的对象,有时候可以充当状态管理器(State Manager)的角色,可以在环境类中对状态进行切换操作。
抽象状态类可以是抽象类,也可以是接口,不同状态类就是继承这个父类的不同子类,状态类的产生是由于环境类存在多个状态,同时还满足两个条件:
- 这些状态需要经常切换
- 不同状态下,对象的行为不同
因此,可以将不同对象下的行为单独提取出来封装在具体的状态类中,使得环境类对象在其内部状态改变时可以改变它的行为,对象看起来似乎修改了它的类,实际上是由于切换到不同的状态类实现的。
由于环境类可以设置为任一具体状态类,因此它针对抽象状态类进行编程,在程序运行时可以将任一具体状态类的对象设置到环境类中,从而使得环境类可以改变内部状态,并且改变行为。
优点
- 封装了转换规则
- 枚举可能的状态,在枚举状态之前需要确定状态种类
- 将所有与某个状态有关的行为放到一个类中,并且可以方便的增加新的状态,只需要改变对象状态即可改变对象行为。
- 允许状态转换逻辑与状态对象合成一体,而不是一个巨大的条件语句块。
- 可以让多个环境对象共现一个状态对象,从而减少系统中对象的个数。
缺点
- 必然增加系统类和对象的个数
- 结构与实现比较复杂,使用不当将导致结构与代码混乱
- 对“开闭原则”的支持并不是很好,对于可以切换状态的状态模式,增加新的状态类需要修改那些负责状态装换的源代码,否则无法切换到新增状态;修改某个状态类的行为也需要修改对应类的源代码。
适用场景
- 对象的行为依赖于其状态(属性),并且需要根据它的状态改变而改变相关行为。
- 代码中包含大量与对象状态有关的条件语句,这些条件语句的出现,会导致代码的可维护性和灵活性变差,不能方便的增加和删除状态,使客户类与类库之间的耦合增强。在这些条件语句中包含了对象的行为,而且这些条件对应于对象的各种状态。
1.16 - CH16-行为型-策略
模式动机
- 完成一项任务,往往可以有多种不同的方式,每种方式称为一个策略,我们可以根据环境、条件的不同选择不同的策略来完成该项任务。
- 在软件开发中也常常遇到类似的情况,实现某一功能有多个途径,此时可以使用一种设计模式来使得系统可以灵活的选择解决途径,也能够方便的增加新的解决途径。
- 在软件系统中,有许多算法可以实现某一功能,如查找、排序等,一种常用的方法是硬编码在一个类中,如需要提供多种查询算法,可以将这些算法写到一个类中,在该类中提供多个方法,每个方法对应一种算法;或者将所有的算法封装在一个方法中,通过
if-else
语句来进行选择。这些方法都可以称为硬编码,如果需要增加一种新的算法,则需要修改封装算法类的源代码;修改、更换算法时,仍然需要修改客户端调用代码。这种方式使封装算法的类过于庞大、逻辑复杂,难以维护。 - 除了提供专门的查找算法外,还可以在客户端程序中直接包含算法代码,这种做法更不可取,将导致客户端程序庞大而难以维护,如果存在大量可供选择的代码则将家中问题的严重性。
- 为了解决这个问题,可以定义一些独立的类来封装不同的算法,每一个类封装一个具体的算法,每一个封装算法的类我们都可以称之为策略(Strategy),为了保证这些策略的一致性,一般会使用一个抽象的策略类来做算法的定义,而具体每种算法则应对应于一个具体的策略类。
模式定义
策略模式(Strategy Pattern):定义一系列算法,将每一个算法封装起来,并让他们可以互相替换。该模式实现算法能够独立于使用它的客户端而独立变化,或称政策模式(Policy)。
模式结构
包含 3 种角色:
- Context:环境类
- Strategy:抽象策略类
- ConcreteStrategy:具体策略类
类图
时序图
模式分析
- 策略模式是一个比较容易理解和使用的模式,就是对算法的封装,它把算法的责任和算法本身分割开,委派给不同的对象管理。策略模式通常把一个系列的算法封装到一系列的策略类里面,作为一个抽象策略类的子类。即:准备一组算法,并将每一个算法封装起来,使得他们可以互换。
- 在该模式中,应当有客户端自己决定何时、选择哪个具体策略。
- 该模式仅仅是封装算法,提供新算法插入到已有系统中,以及老算法从系统中移除。策略模式并不觉得在何时使用哪种算法,这些由客户端类决定。在一定程度上提升了灵活性,但是客户端需要理解所有具体策略之间的区别,以便选择合适的算法,这也是该模式的缺点,一定程度上增加了客户端的使用难度。
优点
- 提供了对开闭原则的支持,用户可以在不修改原有系统的基础上选择、增减算法或行为。
- 提供了管理相关算法族的办法。
- 提供了可以替换继承关系的办法。
- 可以避免使用多重条件语句。
缺点
- 客户端必须知道所有的策略类,并自行决定使用哪个策略。
- 将会产生很多策略类,可以通过使用享元模式在一定程度上减少对象的数量。
适用场景
- 如果一个系统中有很多类,他们之间的区别仅在于他们的行为,这时使用策略模式就可以动态的让一个对象在许多行为中选择一个。
- 系统需要动态地在几种算法中选择一种。
- 如果一个对象有很多的行为,如果不用恰当的模式,这些行为就只好使用多重的条件选择语句实现。
- 不希望客户端知道复杂的、与算法相关的数据结构,在具体策略类中封装算法和相关的数据结构,提高算法的保密性与安全性。
2 - Scala 模式
2.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.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 中。因为那些没有任何代码实现的特质被编译后与接口类似
2.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 中的模块可以像其他对象一样传递。他们是可扩展的、可互换的,并且实现是被隐藏的。
2.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
了。我们只能调用我们需要的方法,这也就是我们要真正实现的。
2.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 的视角。
2.6 - CH06-创建型模式
创建型模式
从这章开始,我们将会深入到实际的实际模式当中。我们已经提到过了解并能正确使用现有的设计模式的重要性。
设计模式可以被认为是一些能够解决特定问题的最佳实践或甚至是模板。开发者需要处理的问题的数量是无穷尽的,有些情况下不同的设计模式不得不被组合使用。然而,基于编写一段代码来解决问题的程序的各个方面,我们可以将设计模式分为一下几个组要的组。
- 创建型
- 结构型
- 行为型
本章将会关注于创建型设计模式,当然,我们将会以 Scala 语言的视角。我们将会贯穿以下一个主要的主题:
- 什么是创建型设计模式
- 工厂方法
- 抽象工厂
- 其他工厂设计模式
- 单例
- 构建器
- 原型
在正式的定义这些创建型设计模式之后,我们将会更详细的单独研究其中的每一种。我们将会强调何时及如何使用它们,何时拒绝使用一些模式,当然,会展示一些相关的实例。
什么是创建型设计模式
创建型设计模式,和它名字描述的一样,用于处理对象的创建。有些情况下,对象的创建在程序中可能会引起一些额外的复杂性,而创建型模式隐藏这些复杂性以使软件组件的使用更加简单。对象创建的复杂性可能由下面任何一种原因引起:
- 初始化参数的数量
- 必要的验证
- 捕获必要参数的复杂性
上面这个列表在有些情况下可能会变得更长,这些因素也不会单独出现,总是伴随而来。
我们将会在下个小节关注创建型设计模式的方方面面,希望你会对为何需要他们以及如何在实际生活中应用它们,有个好的理解。
2.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.
上面的例子显然发生了一个逻辑错误,同时也没有给我们提供任何提醒(在具体的工厂方法中使用了错误的具体实现)。随着要实现的方法数量的增长,这将会成为一个问题,并且错误会更易于发生。比如,我们的代码没有抛出任何异常,但是这种陷阱会引起运行时错误,这让问题变得难于发现和调试。
2.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)和方法改变了签名,将会引起问题的出现。有些情况下,该模式可能会为我们的代码带来一些没有必要的复杂性,以及难以阅读和维护。
2.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
方法来包括它,特别是当我们要考虑新的类型时。
简单工厂
简单工厂比静态工厂要好一点,因为实际的工厂功能是在另一个类中。这使得每次扩展基类不必再去修改基类。类似于抽象工厂,但不同在于这里我们没有一个抽象工厂类,会直接使用具体工厂。通常从一个简单的工厂开始,随着时间的推移和项目的进化,最终会演变成一个抽象工厂。
工厂组合
当然,可以将不同的工厂模式组合在一起。当然,这种方式需要谨慎选择并且仅在有必要的时候使用。否则,设计模式的滥用将会导致烂代码。
2.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 中则没有这种危险(?)。
2.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 中还是别的语言,同样需要以线程安全的方式来完成,或者由单例内部来处理这个问题。
2.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 的方式实现。
2.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)拷贝可能引起错误或副作用,实际的引用会指向原始的实例。同时,避免构造器可能导致烂代码。原型模式需要被真正的用于不使用则会引起巨大的性能影响的场景。
2.14 - CH07-结构型模式
结构型设计模式
我们设计模式之旅的下一张将关注一系列结构型设计模式。我们将以 Scala 的视角探索以下结构型设计模式:
- 适配器
- 装饰器
- 桥接
- 组合
- 门面
- 享元
- 代理
这一节我们将对什么是结构型设计模式以及它们为什么有用给出一个很好的理解。当我们熟悉了它们是什么之后,我们将单独的研究其中每一个并深入其细节,包括代码实例,何时使用、何时避免,以及在使用时需要注意什么。
什么是结构型设计模式
结构型设计模式关心于软件中对象与类的组合。它们使用不同的方式以获得新的、更大型的、通常也更复杂的结构。这些方式有一下几种:
- 继承
- 组合
能够正确识别软件中对象之间的关系是简化软件结构的关键。在下面的几个小节,我们将讨论几种不同的设计模式并提供一些实例,能够为如何使用不同的结构型设计模式找到一些感觉。
2.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。
像我们已经提到过的,当我们有一些无法改变的代码时该模式是有帮助的。如果我们能够修改原始代码,这将是最好的选择,因为在贯穿整个应用中使用适配器模式将会导致程序难以维护和理解。
2.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 的基本实现,然后再以反向的顺序应用修改。
在后续的章节中我们将会看到更加深入的例子。
优点
装饰器给我们的应用带了个更多灵活性。它们不会修改原有的类,因此不会向原有的代码引入错误,也会节省更多代码的编写和维护。同时,它们可以避免我们对所创建的类的应用场景有所遗忘或没有遇见到。
在上面的例子中,我们展示了一些静态行为修改。然而,同样能够在运行时对实例进行动态装饰。
缺点
我们已经覆盖了使用装饰器的正面影响;然而,同样要支持的是对装饰器的过度使用仍然会带来问题。最终我们会得到大量很小的类,这会使我们的库难以使用,对领域知识的要求也更高。它们也会使初始化过程变得复杂,会需要一些其他的(创建型)设计模式,比如工厂或构建器。
2.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")}")
}
运行这段代码将会与第一种实现得到的输出一样。然而,当我们使用我们的抽象时,可以混入我们需要使用的散列算法。但我们拥有更多的实现需要与散列结合时,这种优势会变得更加明显。使用混入看起来也会更加自然和易懂。
优点
像我们已经提到的,桥接模式类似于适配器模式,不过我们会在设计应用的时候应用该模式。使用它的一个明显优势是最终我们的应用中不会拥有爆炸数量的类,从而导致库的使用变得相当复杂。分离的层级结构也支持我们能够独立的扩展它们而不会影响其他的类。
缺点
桥接模式需要我们编写一些模板代码。当需要选择哪种的具体的实现时,会增加使用库的复杂性,或许使用桥接模式和一些创建型模式相结合或是一个不错的主意。总而言之,它没有主要的缺点,开发者需要根据当前的情况明智的选择是否使用这种模式。
2.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("-")
}
代码实际上会对我们的树形结构进行深度优先遍历。我们拥有的数据结构看起来会像下面这样:
优点
组合模式能够有效的减少代码重复,在创建层级结构的时候也很直接。简化的部分来自于客户端并不知道它们实际上正在处理的什么类型的对象。添加新的节点类型也很简单,不需要改变其他任何东西。
缺点
组合模式没有任何主要的缺点。它确实适用于具体场景。开发者需要注意的一点是在处理大规模的层级结构时,因为在这种情况下,我们可能会深入到递归签到的项目里,而这可能会引起栈溢出问题。
2.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/")}")
}
当然,在这个例子中,我们同样可以使用类来代替特质混入,这完全基于实现的需要。
优点
外观模式可以用于隐藏库的实现细节,使接口更加易于用来交互负载的系统。
缺点
人们常犯的一个错误是尝试把一切都放到一个外观中。这种形式是没有任何帮助的,开发者仍然要面对复杂的系统。另外,外观可能会被证明限制了那些拥有足够领域知识的用户对原始的功能的使用。当门面称为与内部系统交互的唯一方式时这种限制就尤为明显。
2.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 中由于不可变性则不需要额外关心。