模式重构
剩余部分将探讨如何以一种逐渐演进的方式来应用设计模式,从而解决问题。首先会选择一种设计用于实现最初的方案,然后对该方案进行验证,随后会尝试更多的设计模式来解决问题———有些有效,有些行不通。寻找解决方案的过程中,最关键的问题永远是“哪些部分是会改变的”。
一开始我们会选择一种解决方案,然后渐渐发现该方案无法满足需求的持续变化,便要做出相应的改进。这是一种自然的趋势,但是在计算机编程中,很难用过程式的程序来完成。接受“我们可以重构代码和设计”这种理念,是迈向系统改进的第一步。
我们用来重构的示例是一个垃圾收集系统。垃圾以未分类的状态到达垃圾收集厂,随后我们建立了垃圾分类和评估的模型。在最开始的方案中,反射会接收匿名的垃圾分块,并检测出它们的类型以进行分类。
Trash 和它的子类
Trash 的基类含有 weight 和 price() 等信息,并带有一个通过反射来生成 Trash 子类精确名称的 toString() 方法。其中还包含了 accept() 方法,稍后会用它实现访问者模式。
public abstract class Trash {
public final double weight;
public Trash(double weight) {
this.weight = weight;
}
public abstract double price();
@Override public String toString() {
return String.format("%s weight: %.2f * price: %.2f = %.2f",
getClass().getSimpleName(), weight, price(), weight * price());
}
public abstract void accept(Visitor v);
}price() 是一个会返回材料当前价格的 abstract 方法。价格由材质决定,不同 Trash 的价格可能相同。将所有价格放在一个地方,这样改变就很容易。通过 interface,每个字段都被自动分配了 public, static 和 final 权限:
每种不同类型的 Trash 的 price() 都会返回 Price 中合适的字段:
TrashValue 是一个带有 static 函数 sum() 的工具类。这个类接收一个由 Trash 组成的 List,显示其中的每块 Trash,最后显示出该 List 中所有 Trash 的总价值。
这里似乎没必要把 total 定义在 sum() 外面,因为在 sum() 之外不会用到它。但是如果将其定义为 sum() 内部的本地变量,就会报错:“lambda 表达式引用的本地变量必须定义为 final,或具有 final 的效果”。
List<Trash> 装载了各种类型的垃圾对象,Bins(垃圾箱)构造器会通过 instanceof 将该 List 分类到其类型化的 List 中:
增加一种新类型的垃圾意味着在 Bins 中增加一个新的 List 和 instanceof,以及 show() 中的新一行。
现在可以创建一个简单工厂了,该工厂持有一个名为 constructors 的 List,其中包含了用于创建新 Trash 对象的构造器。
注意,这里 constructors 没有 Cardboard::new,因此不会生成 Cardboard。
constructors 这个 List 持有的是 Function<Double, Trash>,Function 对象接收 Double 类型参数并返回 Trash 对象,正好对应 Trash 的单参数构造器。由于它的单参数构造器接收的是 double,因此这里会自动装箱。
random() 通过 apply() 方法调用 Function,并传入随机生成的 double 参数。
在 main() 中,Stream.generate() 的参数并不是 Supplier<T>,后者是 generate() 的具体参数类型。即使生成 Trash 的函数名是 random() 而不是 get() ,也是可以的。
这个程序满足了设计需求,但是一个有用的程序往往会随着时间的推移而发展,因此你需要问自己:“万一情况有变化呢?”比如,塑料是一种有价值的可回收垃圾,那么应该怎样将其集成到系统中呢(特别是在程序代码很庞杂的情况下)?虽然 SimpleFactory 确实封装了创建过程,但程序的其余部分散落着若干类型检查的代码,因此每次增加新类型时,都必须找到这些代码,如果遗漏了某一处,则编译器并不会产生任何有帮助的错误消息。
信使对象
面向对象的设计有一个原则:如果觉得设计太复杂,那就生成更多对象。“生成更多对象”通常等同于“再增加一层抽象”。总的来说,如果发现有些地方代码很乱,就要考虑用哪种类可以清理代码。通常清理代码带来的副作用是使系统更灵活并且结构更好。
SimpleFactory 是个合理的首选方案,但是如果派生的 Trash 构造器需要不同的或更多的参数呢?“生成更多对象”可以解决该问题。为了隐藏用于创建的数据,TrashInfo 包含了工厂为创建合适的 Trash 对象的所有必要信息:
TrashInfo 对象的唯一职责就是持有并传递信息,因此它被称为信使对象(Messenge Object)或者数据传输对象(Data Transfer Object, DTO)。信使一般是不可变的,因此这两个字段都是 final 并且 public 的。
如果某些事物发生了变化,导致工厂需要不同的或更多的信息来创建新的 Trash 对象,此时无须改变工厂的参数。可以通过增加新的数据和构造器,或者通过子类化来直接修改 TrashInfo。
使工厂通用化
如果有类型被加入 RecycleA.java 中,就必须为每个新类型修改 SimpleFactory。在 DynaFactory 中,我们会通过反射(如 ShapeFactory2.java 所示)来为该类型的 Trash 生成一个 Constructor。现在再向系统中增加新类型时,工厂就无须变动了:
constructors 会将一个 String(Trash 子类的名称)映射到它对应的构造器。具体的 Trash 子类名称来自 TrashInfo.type 属性,并用来构造 typename。typename 用于在 constructors 中查找构造器。如果不存在,computeIfAbsent() 就会调用 findConstructor() 来生成与 typename 对应的构造器,并添加到 constructors 中。最后,newInstance(info.data) 会调用构造器,向其传入 weight 参数。
可以通过很多种方法来决定要使用哪个对象,这里使用了 info.type 的方法,因此可以将从某个文件中读取的文本信息转化为对象。
DynaFactory 会假定所有的 Trash 类型都有一个以 double(注意 double.class 和 Double.class 是不同的)为参数的构造器。还可能有另一种更灵活的方案,即调用 getConstructors(),该方法会返回包含所有可能的构造器的数组。
这种设计的好处在于不论该方案用于哪种场景,代码都无须变动。
从文件中解析 Trash
现在不再随机生成 Trash 对象,而是通过从文本文件中读取信息的方式来创建它们。该文件包含了每块垃圾需要的所有信息,以 Trash:weight 的格式保存:
ParseTrash.fillBin() 会打开 Trash.dat,并针对每一行创建一块 Trash,添加到一个 Fillable 中:
Trash.dat 被转化为一个多行的流,并会被过滤移除掉注释和空行。然后 String 的 indexOf() 方法得到了":"的索引。String 的 substring() 方法提取出了垃圾类型的名称。接下来使用 substring() 找到了 weight,后者通过 Double.parseDouble() 转化为 double。 trim() 方法移除了 String 两端的空白。
RecycleA.java 中用一个 ArrayList 持有 Trash 对象。为了可以使用其他类型的集合,fillBin() 将 Fillable 的引用作为参数,Fillable 只是一个支持调用 addTrash() 方法的接口:
任何支持该接口的类都可以用于 fillBin()。不过 List 并未实现 Fillable,因此需要一些帮助才能使用。大多数示例中使用了 List,因此有理由增加另一个以 List<Trash> 为参数的重载 fillBin() 方法。通过适配器类,可以将 List<Trash> 当作 Fillable 使用。
该类的唯一作用是将 Fillable 的 addTrash() 方法与 List 的 add() 方法联系起来。这样重载的 FillBin() 方法就可以和 List 在 ParseTrash.java 中结合使用了。
这种方法对于任何常用的集合类都有效。或者,集合类可以提供自己的实现了 Fillable 接口的适配器(稍后会在 TypeMapTrash.java 中看到)。
用 DynaFactory 实现回收
下面是 RecycleA.java 的另一个版本,使用了 ParseTrash.fillBin(),继而用 Dynafacory 执行了 Trash 的创建:
解析文件 Trash.dat 的过程包装到了 ParseTrash.fillBin() 中,因此不再是我们设计重点的一部分。在本章的剩余部分,不论添加了什么新类,ParseTrash.fillBin() 都无须做任何改变。
将用法抽象化
解决了创建问题后,就可以解决剩余部分设计了:在什么位置使用这些类。将垃圾分类到垃圾箱的操作尤为丑陋,并且暴露在外,因此我们应用将丑陋的隐藏到方法或者类中这个原则进行优化,如下图所示
TrashSorter 类大概可以这样实现:
即 TrashSorter 是个由 Trash 组成的 List 组成的 List,而通过 List 的 add() 方法,可以向其中添加其他的 List<Trash> 对象。因此,必须改变 TrashSorter 的初始化方式,以应对向模型中添加新类型 Trash 的情况。
然而现在 sort() 成了一个问题。静态代码的方法如何能够处理添加新类型的情况呢?要解决该问题,就必须从 sort() 中去除类型信息。新版本的 sort() 方法调用了一个处理类型细节的通用方法。这是另一种实现动态绑定方法效果的方式。因此 sort() 只需在序列中移动,并为每个 List<Trash>调用一个动态绑定的方法。该方法的任务是抓取它感兴趣的垃圾块,因此方法名是 grab(Trash)。最后的结构看起来如下图所示:
TrashSorter 会调用每个 grab() 方法,并根据当前 List<Trash> 持有的 Trash 类型得到不同的结果。也就是说,每个 List 都必须知道它所持有的是什么类型。解决这个问题的经典方法是创建一个 TrashBin 基类,并为每个要持有的不同类型继承一个新的子类。通过进一步观察还可以得到更好的方法。
面向对象编程的一个基本设计原则是:用数据成员处理状态的变化,用多态处理行为的变化。
你最初可能会认为 grab() 方法对待 List<Paper> 和 List<Glass> 的行为肯定不同。但是该方法的行为完全取决于类型,和其他因素无关。这可以解释为一种不同的状态。在 Java 中,Class 是类型在运行时的表现形式,而这可以用来确定某个特定 TrashBin 会持有的 Trash 类型。
[1] TrashBinList 包含一个由 TrashBin 引用组成的 List,因此 sort() 在为每个 Trash 对象查找匹配项时,可以遍历 TrashBin。 [2] sortBin() 从它的 TrashBin 参数中选出每一块 Trash,并将其分类到相应的具体 TrashBin 中。注意,在添加新类型的时候,这段代码完全无须修改。如果在添加新类型(或发生其他变更)时大部分代码不需要修改,那么我们就拥有了一个易于扩展的系统。 [3] 一次方法调用会触发操作,将 bin 的内容分别放到各自特定类型的垃级箱(bin)中。
用多路分发重新设计
上述设计已经满足了需求。通过增加或修改不同的类即可增加新的类型,而不会在系统中产生额外的代码变更。反射没有像在 RecycleA.java 中那样被“误用”(如 Bins 类那样)。然而,还有可能更进一步可以用一种更纯粹的观点来看待反射,也就是说,应该将反射从垃级分类的操作中完全去掉。
为了达到这个目的,应该首先采用这样的观点:所有依赖类型的活动(例如检测一块垃圾的类型,并将其放人合适的垃圾箱)都应该通过多态来控制。
前面的示例首先根据类型分类,然后在由某个特定类型的元素组成的序列上进行操作。如果你发现自己在挑选特定类型,就要停下来想想。多态(动态绑定方法调用)的整体思想就是为你处理特定类型的行为,所以为什么还要寻找类型呢?
你寻找类型是因为 Java 只会实现单路分发。也就是说,如果一个操作要作用于一个以上的未知类型的对象,Java 只会在其中一个类型上应用动态绑定机制。这样并不能解决问题,因此你最终需要手动检测某些类型,并实现自己的动态绑定行为。
解决的方案是使用多路分发。我们将代码配置为让单个方法生成多个动态方法调用从而确定多个类型。要实现这个效果,一般需要用到多种层次结构:每路分发使用一个层次结构。下面示例使用了两个层次结构:已有的 Trash 家族,以及要放入垃圾的垃圾箱的层次结构。如果只有两路分发,就称为双路分发。
为了实现双路分发,我们在 Trash 的层次结构中增加了一个名为 addToBin() 的新方法,该方法以 List<TypedBin> 为参数。一块垃圾 Trash 会调用 addToBin() 来遍历 List,并使用双路分发,将自身加入到正确的 TypedBin 中:
新的层次结构是 TypedBin,它包含一个同样以多态方式使用的 add() 方法。但是这里为了能够接收不同类型的 Trash,add() 被重载了。因此双路分发方案中很重要的部分也会涉及重载。
重新设计该程序会产生一个难题:Trash 类现在必须包含一个 addToBin() 方法。一种方法是从已有的 Trash 层次结构中复制代码,并引入 addToBin() 方法,由此创建一个新的层次结构。另一种方法则可以在你没有源代码的控制权,或者不希望向已有代码中引入新 bug 风险的时候采用。这种方法是将 addToBin() 方法放人一个接口,留下 Trash,并继承出新的 Aluminum、Paper、Glass 和 Cardboard 的具体类型。下面便是这个接口:
每一个适配后的 Aluminum、Paper、Glass和 Cardboard 的子类中都实现了 addToBin() 方法,但是每种情况下的 addToBin() 代码看起来都完全相同:
anyMatch(tb -> tb.add(this)) 调用了数组中每个 TypedBin 上的 add() 方法。该方法返回一个 boolean,表示该 Trash 是否成功加人到了特定的 TypedBin 中,而 anyMatch() 则会找出这些 add() 调用中是否有执行成功的。如果有,那么这块 Trash 就被分到了一个垃圾箱中。
每个 addToBin() 方法的代码似乎都是一样的。但是要注意 tb.add(this) 中的 this 参数。每个 Trash 子类的 this 的类型都是不同的,因此代码实际上也是不同的。这便是双路分发的第一个部分,在 addToBin() 中,你知道你持有的 Trash 是明确的 Aluminum 或 Paper 等类型。在调用 add() 的过程中,该信息是通过 this 类型传递的。编译器会将调用解析到恰当的 add() 重载版本。但是 tb 生成的是指向基类 TypedBin 的引用,因此该动态调用最终会根据所选择的 TypedBin 类型,调用到不同的方法上,这便是第二路分发。
下面便是 TypedBin 的基类。注意 private 的字段 typedBin 永远都不会直接对外暴露,在调用 bin() 时,会复制一份 ArrayList 的副本。由此,typedBin Arraylist 就不会被外部的代码所修改:
这些重载后的 add() 方法全都会返回 false。add() 方法如果没有在子类中被重写,就会一直返回 false,而调用者(addToBin())会假设当前的 Trash 对象还没有被加入到集合中。
每个 TypedBin 的子类只会重写一个 add() 方法,即和要定义的 TypedBin 类型相对应。例如,CardboardBin 重写了 add(Cardboard)。重写后的方法将 Trash 对象加入自身的集合并返回 true,而 CardboardBin 中其余的 add() 方法都会继续返回 false,因为它们并没有被重写。
下面是程序的剩余部分:
TrashBinSet 封装了所有不同类型的 TypedBin,以及用于启动双路分发的 sortIntoBins() 方法。
添加新类型所需的修改相对独立:继承新的 Trash 类型和其中的 addToBin() 方法,然后继承一个新的 TypedBin,最后在 TrashBinSet.binSet 的初始化过程中添加新类型。
访问者模式
接下来考虑应用一种设计目标与之前完全不同的设计模式。
对于这种设计模式,我们不再关心如何优化“向系统中添加新的 Trash 类型”这一动作。在某些方面,访问者模式反而会使新 Trash 类型的添加变得更复杂。假设现在有一个不可改动的主类层次结构,然而我们希望向该层次结构中添加新的多态方法,一般情况下,我们会在基类中增加方法,但是现在整个层次结构都动不了,怎样才能解决这个问题呢?
访问者模式是建立在双路分发的基础上的。访问者使我们可以通过创建独立的 Visitor 类层次结构,来扩展主类型的接口,以模拟对主类型执行的操作。主类的对象只需简单地“接受”访问者,然后调用访问者的动态绑定方法,如下图所示。
假设 v 是一个指向某个 Aluminum 对象的引用,则代码:
会产生两次多态方法调用:第一次用来选择 Aluminum 的 accept() 的版本,第二次发生在 accept() 中,即通过 Visitor 的引用 pv 动态调用具体版本的 visit()。
这种设置意味着新功能可以用新的 Visitor 子类的形式添加到系统中,而 Trash 的层次结构则不需要修改。这便是访问者模式的好处:在不修改原有类层次结构的前提下,向其中增加更多的多态功能。
要注意一点:这个不可改动的层次结构的提供者需要能预见到访问者模式的使用并做好支持,可以通过显式地引入 accept() 方法(就像在Trash 层次结构中所做的那样),也可以通过使层次结构中的类具有足够的可访问性来覆盖包含 accept() 的接口,类似于前面双路分发的示例。
访问者是有帮助的,但它并不是本来想实现的,因此你可能会认为这并不是想要的方案。但是访问者的方案避免了从主 Trash 序列分类到单个类型的容器中。因此,可以将所有内容都保留在单个主序列中,只需使用合适的访问者对它进行遍历,即可实现目标。
访问者模式中的双路分发在没有使用反射的情况下,确定了 Trash 和 Visitor 这两者的类型。在随后的示例中,实现了两个 Visitor:用来显示和累加价格的 PriceVisitor. 以及用来显示和累加重量的 WeightVisitor。
Visitor 是个抽象类而不是接口,因为它使用了 double 来持有不同类型垃圾的总数,以及用 show() 和 total() 相关的公共代码来显示结果。descriptor 由各个子类设置为"price"或"weight",因此 show() 可以生成有意义的输出:
注意,在添加新的 Trash 类型时,必须修改 Visitor 和它的子类。这实际上并不太槽糕,因为修改是隔离于 Visitor 层次结构的。比如,如果你向 Visitor 添加的是类似 visit(Plastic p)这样的内容,则编译器会确保所有的 Visitor 子类都会实现新的重载版本的 visit()。
程序的剩余部分创建了具体的 Visitor 类,并通过一个 Trash 对象的 list 来传递:
在 main() 中只有一个 Trash bin(垃圾箱)。两个 Visitor 对象被该序列中的所有元素所接受。访问者保留了自己的内部数据,以计算总的重量和价格。
在双路分发的方案中,每个子类的创建过程中只有一个重载的 add() 方法在被重写了。而此处,每个重载的 visit() 方法都在 Visitor 的所有子类中被重写了。
反射是否有害
本章中一直在试图去除反射,你可能会觉得反射有害,实际上并不是,问题在于对反射的滥用。我们的设计要去除反射,因为对反射的误用会阻碍程序的可扩展性一一我们的预期目标是在对周围代码的影响较小的情况下向系统中添加新类型。反射常常被拿来在系统中挨个查找每个类型,这是一种滥用,会产生无扩展性的代码,因为在添加新类型的时候,必须找到所有使用了反射的代码,如果有遗漏的话,编译器是帮不上忙的。
不过,反射并不会自动创建无扩展性的代码。现在来回顾一下垃圾收集器,并引入一个新工具,我称之为 TypeMap,它包含一个 Map<Class,List<T>。接口很简单:可以 add() 一个新对象,values() 会生成一个 List 的 Stream,每个 List 都包含了某个具体类型的全部对象。只要向系统中添加新类型,TypeMap 都会动态地进行适配。
用 add() 添加一个新对象时,会提取出该对象的 Class,该 Class 会作为键,用来确认(查询)持有该类型对象的 List 在 Map 中是否已经存在。如果已经存在,则会返回该 List。如果不存在,computelfAbsent() 会添加一个由该 Class 对象和一个新 ArrayList 组成的键值对。不论哪种情况,对象都会被添加到一个 List 中。
接下来的两个示例会通过 show() 方法展示出 Map<Class, List<Trash>>所包含的内容,因此会将此实现为一个工具函数:
每次向 bin 中插入一个 Trash 对象时,ParseTrash.fillBin() 都会进行分类:
这里的大部分内容你应该很熟悉了。这一次没有将 Trash 对象放入 List 类型的 bin,而是使用了 TypeMap 类型的 bin。在将垃圾放人 bin 中的时候,TypeMap 的内部排序机制会立刻对该垃圾进行分类。之后又对每个独立的 List 进行了操作。
向系统中添加新类型完全不会影响到这部分代码,也不会影响到 Trash 中的代码。这个方案很依赖反射,但要注意到,Map 中的每个键值对都只会查找一种类型。此外在增加新类型的时候,你无须担心忘记添加或修改代码,因为根本就无须任何改动。
Java 8 提供了 Stream 的函数 groupingBy(),这个函数同样可以得到 Map<Class, List<Trash>>:
这种方式产生的效果和 TypeMapTrash 相同,但是不再需要 TypeMap 或 TypeMapAdapter 类了,此外也不再需要屏蔽“unchecked”警告了。
为了能够理解这些内容,试着增加一个叫做 Plastic 的 Trash 子类,看它是怎样融入这些示例的。