27.3 解释器模式的应用

27.3.1 解释器模式的优点

解释器是一个简单语法分析工具,它最显著的优点就是扩展性,修改语法规则只要修改相应的非终结符表达式就可以了,若扩展语法,则只要增加非终结符类就可以了。

27.3.2 解释器模式的缺点

  • 解释器模式会引起类膨胀

每个语法都要产生一个非终结符表达式,语法规则比较复杂时,就可能产生大量的类文件,为维护带来了非常多的麻烦。

  • 解释器模式采用递归调用方法

每个非终结符表达式只关心与自己有关的表达式,每个表达式需要知道最终的结果,必须一层一层地剥茧,无论是面向过程的语言还是面向对象的语言,递归都是在必要条件下使用的,它导致调试非常复杂。想想看,如果要排查一个语法错误,我们是不是要一个断点一个断点地调试下去,直到最小的语法单元。

  • 效率问题

解释器模式由于使用了大量的循环和递归,效率是一个不容忽视的问题,特别是一用于解析复杂、冗长的语法时,效率是难以忍受的。

27.3.3 解释器模式使用的场景

  • 重复发生的问题可以使用解释器模式

例如,多个应用服务器,每天产生大量的日志,需要对日志文件进行分析处理,由于各个服务器的日志格式不同,但是数据要素是相同的,按照解释器的说法就是终结符表达式都是相同的,但是非终结符表达式就需要制定了。在这种情况下,可以通过程序来一劳永逸地解决该问题。

  • 一个简单语法需要解释的场景

为什么是简单?看看非终结表达式,文法规则越多,复杂度越高,而且类间还要进行递归调用(看看我们例子中的栈)。想想看,多个类之间的调用你需要什么样的耐心和信心去排查问题。因此,解释器模式一般用来解析比较标准的字符集,例如SQL语法分析,不过该部分逐渐被专用工具所取代。

在某些特用的商业环境下也会采用解释器模式,我们刚刚的例子就是一个商业环境,而且现在模型运算的例子非常多,目前很多商业机构已经能够提供出大量的数据进行分析。

27.3.4 解释器模式的注意事项

尽量不要在重要的模块中使用解释器模式,否则维护会是一个很大的问题。在项目中可以使用shell、JRuby、Groovy等脚本语言来代替解释器模式,弥补Java编译型语言的不足。我们在一个银行的分析型项目中就采用JRuby进行运算处理,避免使用解释器模式的四则运算,效率和性能各方面表现良好。

27.4 最佳实践

解释器模式在实际的系统开发中使用得非常少,因为它会引起效率、性能以及维护等问题,一般在大中型的框架型项目能够找到它的身影,如一些数据分析工具、报表设计工具、 科学计算工具等,若你确实遇到“一种特定类型的问题发生的频率足够高”的情况,准备使用解释器模式时,可以考虑一下Expression4J、MESP(Math Expression String Parser)、Jep等开源的解析工具包(这三个开源产品都可以通过百度、Google搜索到,请读者自行查询), 功能都异常强大,而且非常容易使用,效率也还不错,实现大多数的数学运算完全没有问题,自己没有必要从头开始编写解释器。有人已经建立了一条康庄大道,何必再走自己的泥泞小路呢?

29.4 最佳实践

大家对类的继承有什么看法吗?继承的优点有很多,可以把公共的方法或属性抽取,父类封装共性,子类实现特性,这是继承的基本功能。缺点有没有?有!即强侵入,父类有一个方法,子类也必须有这个方法。这是不可选择的,会带来扩展性的问题。我举个简单的例子来说明:Father类有一个方法A,Son继承了这个方法,然后GrandSon也继承了这个方法, 问题是突然有一天Son要重写父类的这个方法,他敢做吗?绝对不敢!GrandSon要用从Father 继承过来的方法A,如果你修改了,那就要修改Son和GrandSon之间的关系,那这个风险就太大了!

这里讲的这个桥梁模式就是这一问题的解决方法,桥梁模式描述了类间弱关联关系,还说上面的那个例子,Father类完全可以把可能会变化的方法放出去,Son子类要拥有这个方法很简单,桥梁搭过去,获得这个方法,GrandSon也一样,即使你Son子类不想使用这个方法也没关系,对GrandSon不产生影响,它不是从Son中继承来的方法!

不能说继承不好,它非常好,但是有缺点,我们可以扬长避短,对于比较明确不发生变化的,则通过继承来完成;若不能确定是否会发生变化的,那就认为是会发生变化,则通过桥梁模式来解决,这才是一个完美的世界。

29.3 桥梁模式的应用

  • 抽象和实现分离

这也是桥梁模式的主要特点,它完全是为了解决继承的缺点而提出的设计模式。在该模式下,实现可以不受抽象的约束,不用再绑定在一个固定的抽象层次上。

  • 优秀的扩充能力

看看我们的例子,想增加实现?没问题!想增加抽象,也没有问题!只要对外暴露的接口层允许这样的变化,我们已经把变化的可能性减到最小。

  • 实现细节对客户透明

客户不用关心细节的实现,它已经由抽象层通过聚合关系完成了封装。

29.3.2 桥梁模式的使用场景

  • 不希望或不适用使用继承的场景

例如继承层次过渡、无法更细化设计颗粒等场景,需要考虑使用桥梁模式。

  • 接口或抽象类不稳定的场景

明知道接口不稳定还想通过实现或继承来实现业务需求,那是得不偿失的,也是比较失败的做法。

  • 重用性要求较高的场景

设计的颗粒度越细,则被重用的可能性就越大,而采用继承则受父类的限制,不可能出现太细的颗粒度。

29.3.3 桥梁模式的注意事项

桥梁模式是非常简单的,使用该模式时主要考虑如何拆分抽象和实现,并不是一涉及继承就要考虑使用该模式,那还要继承干什么呢?桥梁模式的意图还是对变化的封装,尽量把可能变化的因素封装到最细、最小的逻辑单元中,避免风险扩散。因此读者在进行系统设计时,发现类的继承有N层时,可以考虑使用桥梁模式。

28.5 最佳实践

Flyweight是拳击比赛中的特用名词,意思是“特轻量级”,指的是51公斤级比赛,用到设 计模式中是指我们的类要轻量级,粒度要小,这才是它要表达的意思。粒度小了,带来的问 题就是对象太多,那就用共享技术来解决。

享元模式在Java API中也是随处可见,如这样的程序就是一个很好的例子,如代码清单28-17所示。

代码清单28-17 API中的享元模式

1
2
3
4
5
6
7
8
9
10
11
12
public class Test {
public static void main(String[] args) {
String str1 = "和谐";
String str2 = "社会";
String str3 = "和谐社会";
String str4;
str4 = str1 + str2;
System.out.println(str3 == str4);
str4 = (str1 + str2).intern();
System.out.println(str3 == str4);
}
}

看看Java的帮助文件中String类的intern方法。如果是String的对象池中有该类型的值,则直接返回对象池中的对象,那当然相等了。

需要说明一下的是,虽然可以使用享元模式可以实现对象池,但是这两者还是有比较大的差异,对象池着重在对象的复用上,池中的每个对象是可替换的,从同一个池中获得A对象和B对象对客户端来说是完全相同的,它主要解决复用,而享元模式在主要解决的对象的共享问题,如何建立多个可共享的细粒度对象则是其关注的重点。

28.4 享元模式的扩展

28.4.1 线程安全的问题

线程安全是一个老生常谈的话题,只要使用Java开发都会遇到这个问题,我们之所以要在今天的享元模式中提到该问题,是因为该模式有太大的几率发生线程不安全,为什么呢?

我们还以报考系统为例来说明这个问题。大家有没有想过,为什么要以考试科目+考试地点作为外部状态呢?为什么不能以考试科目或者考试地点作为外部状态呢?这样池中的对象会更少!可以!完全可以!我们把程序以考试科目为外部状态,把享元工厂稍作修改,如代码清单28-10所示。

代码清单28-10 报考信息工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SignInfoFactory {
//池容器
private static HashMap<String,SignInfo> pool = new HashMap<String,SignInfo>();
//从池中获得对象
public static SignInfo getSignInfo(String key){
//设置返回对象
SignInfo result = null;
//池中没有该对象,则建立,并放入池中
if(!pool.containsKey(key)){
result = new SignInfo();
pool.put(key, result);
}
else{
result = pool.get(key);
}
return result;
}
}

下面做很小的改动,只修改了黑色字体部分。为了展示多线程的情况,我们写一个多线程的类,如代码清单28-11所示。

代码清单28-11 多线程场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MultiThread extends Thread {
private SignInfo signInfo;
public MultiThread(SignInfo _signInfo){

this.signInfo = _signInfo;
}
public void run(){
if(!signInfo.getId().equals(signInfo.getLocation())){
System.out.println("编号:"+signInfo.getId());
System.out.println("考试地址:"+signInfo.getLocation());
System.out.println("线程不安全了!");
}
}
}

在run方法中判断特殊值,检查是否是线程安全,我们来看看场景类,如代码清单28-12 所示。

代码清单28-12 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//在对象池中初始化4个对象
SignInfoFactory.getSignInfo("科目1");
SignInfoFactory.getSignInfo("科目2");
SignInfoFactory.getSignInfo("科目3");
SignInfoFactory.getSignInfo("科目4");
//取得对象
SignInfo signInfo = SignInfoFactory.getSignInfo("科目2");
while(true){
signInfo.setId("ZhangSan");
signInfo.setLocation("ZhangSan");
(new MultiThread(signInfo)).start();
signInfo.setId("LiSi");
signInfo.setLocation("LiSi");
(new MultiThread(signInfo)).start();
}
}
}

模拟实际的多线程情况,在对象池中我们保留4个对象,然后启动N多个线程来模拟, 我们马上就看到如下的提示:

1
2
3
编号:LiSi 
考试地址:ZhangSan
线程不安全了!

看看,线程不安全了吧,这是正常的,设置的享元对象数量太少,导致每个线程都到对象池中获得对象,然后都去修改其属性,于是就出现一些不和谐数据。只要使用Java开发,线程问题是不可避免的,那我们怎么去避免这个问题呢?享元模式是让我们使用共享技术, 而Java的多线程又有如此问题,该如何设计呢?没什么可以参考的标准,只有依靠经验,在需要的地方考虑一下线程安全,在大部分的场景下都不用考虑。我们在使用享元模式时,对象池中的享元对象尽量多,多到足够满足业务为止。

28.4.2 性能平衡

尽量使用Java基本类型作为外部状态。在报考系统中,我们不考虑系统的修改风险,完全可以重新建立一个类作为外部状态,因为这才完全符合面向对象编程的理念。好,我们实现处理,先看类图,如图28-4所示。

image-20210930115503856

图28-4 类作为外部状态

我们首先来看ExtrinsicState外部状态类,如代码清单28-13所示。

代码清单28-13 外部状态类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class ExtrinsicState {
//考试科目
private String subject;
//考试地点
private String location;
public String getSubject() {
return subject;
}
public void setSubject(String subject) {

this.subject = subject;
}
public String getLocation() {
return location;
}
public void setLocation(String location) {
this.location = location;
}
@Override
public boolean equals(Object obj){
if(obj instanceof ExtrinsicState){
ExtrinsicState state = (ExtrinsicState)obj;
return state.getLocation().equals(location) && state.getSubject().equals(subject);
}
return false;
}
@Override
public int hashCode(){
return subject.hashCode() + location.hashCode();
}
}

注意,一定要覆写equals和hashCode方法,否则它作为HashMap中的key值是根本没有意义的,只有hashCode值相等,并且equals返回结果为true,两个对象才相等,也只有在这种情况下才有可能从对象池中查找获得对象。

1
注意 如果把一个对象作为Map类的键值,一定要确保重写了equals和hashCode方法, 否则会出现通过键值搜索失败的情况,例如map.get(object)、map.contains(object)等会返回失败的结果。

SignInfo的修改较小,仅在SignInfo中引入该ExtrinsicState外部状态对象,在此不再赘述。 我们再来看享元工厂,它是以ExtrinsicState类作为外部状态,如代码清单28-14所示。

代码清单28-14 享元工厂

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SignInfoFactory {
//池容器
private static HashMap<ExtrinsicState,SignInfo> pool = new HashMap <ExtrinsicState,SignInfo>();
//从池中获得对象
public static SignInfo getSignInfo(ExtrinsicState key){
//设置返回对象
SignInfo result = null;
//池中没有该对象,则建立,并放入池中
if(!pool.containsKey(key)){
result = new SignInfo();
pool.put(key, result);
}
else{
result = pool.get(key);
}
return result;
}
}

重点是看看我们的场景类,我们来测试一下性能差异,如代码清单28-15所示。

代码清单28-15 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Client {
public static void main(String[] args) {
//初始化对象池
ExtrinsicState state1 = new ExtrinsicState();
state1.setSubject("科目1");
state1.setLocation("上海");
SignInfoFactory.getSignInfo(state1);
ExtrinsicState state2 = new ExtrinsicState();
state2.setSubject("科目1");
state2.setLocation("上海");
//计算执行100万次需要的时间
long currentTime = System.currentTimeMillis();
for(int i=0;i<1000000;i++){
SignInfoFactory.getSignInfo(state2);
}
long tailTime = System.currentTimeMillis();
System.out.println("执行时间:"+(tailTime - currentTime) + " ms");
}
}

运行结果如下所示:

1
执行时间:172 ms

同样,我们看看以String类型作为外部状态的运行情况,如代码清单28-16所示。

代码清单28-16 场景类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Client {
public static void main(String[] args) {
String key1 = "科目1上海";
String key2 = "科目1上海";
//初始化对象池
SignInfoFactory.getSignInfo(key1);
//计算执行10万次需要的时间
long currentTime = System.currentTimeMillis();

for(int i=0;i<10000000;i++){
SignInfoFactory.getSignInfo(key2);
}
long tailTime = System.currentTimeMillis();
System.out.println("执行时间:"+(tailTime - currentTime) + " ms");
}
}

运行结果如下所示:

1
执行时间:78 ms

看到没?一半的效率,这还是非常简单的享元对象,看看我们重写的equals方法和hashCode方法,这段代码是必须实现的,如果比较复杂,这个时间差异会更大。

各位,想想看,使用自己编写的类作为外部状态,必须覆写equals方法和hashCode方法,而且执行效率还比较低,这种吃力不讨好的事情最好别做,外部状态最好以Java的基本类型作为标志,如String、int等,可以大幅地提升效率。

28.3 享元模式的应用

28.3.1 享元模式的优点和缺点

享元模式是一个非常简单的模式,它可以大大减少应用程序创建的对象,降低程序内存的占用,增强程序的性能,但它同时也提高了系统复杂性,需要分离出外部状态和内部状态,而且外部状态具有固化特性,不应该随内部状态改变而改变,否则导致系统的逻辑混乱。

28.3.2 享元模式的使用场景

在如下场景中则可以选择使用享元模式。

  • 系统中存在大量的相似对象。
  • 细粒度的对象都具备较接近的外部状态,而且内部状态与环境无关,也就是说对象没 有特定身份。
  • 需要缓冲池的场景。

26.4 最佳实践

上面的例子可能比较复杂,请各位看官耐心看,看完肯定有所收获。我翻遍了所有能找得到的资料(关于这个电梯的例子也是由《Design Pattern for Dummies》这本书激发出来的),基本上没有一本把这个状态模式讲透彻的(当然,还是有几本讲得不错),我不敢说我就讲得透彻,大家都只讲了一个状态到另一个状态的过渡。状态间的过渡是固定的,举个简单的例子,如图26-6所示。

image-20210930111216250

图26-6 简单状态切换示意图

这个状态图是很多书上都有的,状态A只能切换到状态B,状态B再切换到状态C。举例最多的就是TCP监听的例子。TCP有3个状态:等待状态、连接状态、断开状态,然后这3个状态按照顺序循环切换。按照这个状态变更来讲解状态模式,我认为是不太合适的,为什么呢?你在项目中很少看到一个状态只能过渡到另一个状态情形,项目中遇到的大多数情况都是一个状态可以转换为几种状态,如图26-7所示。

image-20210930111248277

图26-7 复杂状态切换示意图
状态B既可以切换到状态C,又可以切换到状态D,而状态D也可以切换到状态A或状态B,这在项目分析过程中有一个状态图可以完整地展示这种蜘蛛网结构,例如,一些收费网站的用户就有很多状态,如普通用户、普通会员、VIP会员、白金级用户等,这个状态的变更你不允许跳跃?!这不可能,所以我在例子中就举了一个比较复杂的应用,基本上可以实现状态间自由切换,这才是最经常用到的状态模式。

再提一个问题,状态间的自由切换,那会有很多种呀,你要挨个去牢记一遍吗?比如上面那个电梯的例子,我要一个正常的电梯运行逻辑,规则是开门->关门->运行->停止;还要一个紧急状态(如火灾)下的运行逻辑,关门->停止,紧急状态时,电梯当然不能用了;再要一个维修状态下的运行逻辑,这个状态任何情况都可以,开着门电梯运行?可以!门来回开关?可以!永久停止不动?可以!那这怎么实现呢?需要我们把已经有的几种状态按照一定的顺序再重新组装一下,那这个是什么模式?什么模式?大声点!建造者模式!对,建造模式+状态模式会起到非常好的封装作用。

更进一步,应该有部分读者做过工作流开发,如果不是土制框架,那么就应该有个状态机管理(即使是土制框架也应该有),如一个Activity(节点)有初始化状态(Initialized State)、挂起状态(Suspended State)、完成状态(Completed State)等,流程实例也有这么多状态,那这些状态怎么管理呢?通过状态机(State Machine)来管理,那状态机是个什么东西呢?就是我们上面提到的Context类的升级变态BOSS!

26.3 状态模式的应用

26.3.1 状态模式的优点

  • 结构清晰

避免了过多的switch…case或者if…else语句的使用,避免了程序的复杂性,提高系统的可维护性。

  • 遵循设计原则

很好地体现了开闭原则和单一职责原则,每个状态都是一个子类,你要增加状态就要增加子类,你要修改状态,你只修改一个子类就可以了。

  • 封装性非常好

这也是状态模式的基本要求,状态变换放置到类的内部来实现,外部的调用不用知道类内部如何实现状态和行为的变换。

26.3.2 状态模式的缺点

状态模式既然有优点,那当然有缺点了。但只有一个缺点,子类会太多,也就是类膨胀。如果一个事物有很多个状态也不稀奇,如果完全使用状态模式就会有太多的子类,不好管理,这个需要大家在项目中自己衡量。其实有很多方式可以解决这个状态问题,如在数据库中建立一个状态表,然后根据状态执行相应的操作,这个也不复杂,看大家的习惯和嗜好了。

26.3.3 状态模式的使用场景

  • 行为随状态改变而改变的场景

这也是状态模式的根本出发点,例如权限设计,人员的状态不同即使执行相同的行为结果也会不同,在这种情况下需要考虑使用状态模式。

  • 条件、分支判断语句的替代者

在程序中大量使用switch语句或者if判断语句会导致程序结构不清晰,逻辑混乱,使用状态模式可以很好地避免这一问题,它通过扩展子类实现了条件的判断处理。

26.3.4 状态模式的注意事项

状态模式适用于当某个对象在它的状态发生改变时,它的行为也随着发生比较大的变化,也就是说在行为受状态约束的情况下可以使用状态模式,而且使用时对象的状态最好不要超过5个。

25.5 最佳实践

访问者模式是一种集中规整模式,特别适用于大规模重构的项目,在这一个阶段需求已经非常清晰,原系统的功能点也已经明确,通过访问者模式可以很容易把一些功能进行梳理,达到最终目的——功能集中化,如一个统一的报表运算、UI展现等,我们还可以与其他模式混编建立一套自己的过滤器或者拦截器,请大家参考混编模式的相关章节。