文章目录
在Lab3的计划项设计中,笔者使用的方案是基于组合复用的思想。在写实验的过程中,笔者感觉这种思路十分直观清晰,于是写下这篇博客记录一下设计的流程和组合复用原则的优缺点。
设计流程
#1 设计接口,定义计划项特性
实验手册中已经将计划项的特性抽象为五个维度:位置的数量、位置是否可更改、资源特征(数量、个体是否可区分、多个体是否排序)、计划项是否可阻塞及其时间描述、时间是否可预先设定。
针对上述每个维度可以定义不同的接口,这里以“资源特征”维度为例。由于本实验选定的三个具体的计划项实现类中,航班和课程使用的资源是单个可区分资源;而高铁使用的是多个带次序的可区分资源,因此只需要设计表示这两种资源特征的接口SingleResourceEntry和MultipleSortedResourseEntry,在两个接口中分别定义资源的setter和getter方法即可:
/**
* 表示计划项使用单个可区分资源的接口
*
* @param <R> 资源的类型,要求为不可变类型
*/
public interface SingleResourceEntry<R> {
/**
* 分配计划项使用的资源
*
* @param resource 待分配的资源
*/
public void setResource(R resource);
/**
* 获取计划项使用的资源
*
* @return 计划项使用的资源
*/
public R getResource();
}
/**
* 表示计划项使用多个带次序的可区分资源的接口
*
* @param <R> 资源的类型,要求为不可变类型
*/
public interface MultipleSortedResourceEntry<R> {
/**
* 设置计划项使用的资源
*
* @param resources 待设置的资源列表
*/
public void setResources(List<R> resources);
/**
* 获取计划项使用的资源
*
* @return 计划项使用的资源列表
*/
public List<R> getResources();
}
#2 实现计划项特性
承接上面的例子,分别设计两个接口的具体实现类SingleResourceEntryImpl和MultipleSortedResourseEntryImpl,其中表示“单个可区分资源”的类中唯一的成员变量是一个R类型的资源,而表示“多个带次序的可区分资源”的类中唯一的成员变量是一个R类型的资源列表。两个具体实现类都实现接口中的setter和getter方法,只是实现逻辑有所不同。
/**
* 表示计划项使用单个可区分资源的不可变类
*
* @param <R> 资源的类型,要求为不可变类型
*/
public class SingleResourceEntryImpl<R> implements SingleResourceEntry<R>{
private R resource;
/**
* 创建表示计划项使用单个可区分资源的对象
*
* @param resource 计划项使用的资源
*/
public SingleResourceEntryImpl(R resource) {
this.resource = resource;
}
@Override
public void setResource(R resource) {
this.resource = resource;
}
@Override
public R getResource() {
return this.resource;
}
}
/**
* 表示计划项使用多个带次序的可区分资源的可变类
*
* @param <R> 资源的类型,要求为不可变类型
*/
public class MultipleSortedResourceEntryImpl<R> implements MultipleSortedResourceEntry<R>{
private List<R> resources = new ArrayList<>();
/**
* 创建表示计划项使用多个带次序的可区分资源的对象
*
* @param resources 多个带次序的可区分资源列表
*/
public MultipleSortedResourceEntryImpl(List<R> resources){
if(resources != null) {
List<R> thisResources = new ArrayList<>(resources);
this.resources = thisResources;
}else {
this.resources = null;
}
}
@Override
public void setResources(List<R> resources) {
List<R> thisResources = new ArrayList<>(resources);
this.resources = thisResources;
}
@Override
public List<R> getResources() {
List<R> newResources;
if(resources != null) {
newResources = new ArrayList<>(resources);
} else {
newResources = null;
}
return newResources;
}
}
#3 组合接口,定义特性的组合
前面两步针对计划项各个维度的不同特征,都设计了不同的接口以及相应的具体实现类。下面要做的工作就是通过接口组合,根据不同计划项子类的需求将各种特性组合在一起,形成满足每个应用要求的特殊接口(包含了该应用内的全部特殊功能)。
以航班计划项FlightEntry为例,它使用一个起点和一个终点,位置不可更改,占用单个资源,且不可阻塞。为FlightEntry设计接口,命名为FlightPlanningEntry。这个接口继承了TwoLocationEntry(表示计划项使用一个起点和一个终点)、SingleResourceEntry(表示计划项使用单个资源)、UnblockableEntry接口(表示计划项不可阻塞),即继承了这些特性接口中的所有方法:
/**
* 表示航班计划项的接口
*/
public interface FlightPlanningEntry<Plane> extends TwoLocationEntry, SingleResourceEntry<Plane>, UnblockableEntry {
}
#4 通过委托实现特性的组合
各个计划项子类可直接通过实现上面组合出的接口进行具体计划项的设计,即各计划项子类拥有接口中各个复合特性的全部方法。
在计划项子类内,不是直接实现每个特殊操作,而是通过委托到外部每个维度上的各具体实现类的相应特殊操作逻辑进行实现,也就是像“搭积木”一样,利用上面已经设计的高可复用性模块,搭建面向各应用的PlanningEntry子类型。
承接上面一部分,仍以FlightEntry为例,令FlightEntry实现FlightPlanningEntry接口,即需要在FlightEntry中实现FlightPlanningEntry接口中的所有方法。这里注意我们的做法是将FlightEntry的所有方法都委托给上述三个特性接口的实现类,即这三个对象的类型分别为TwoLocationEntryImpl、SingleResourceEntryImpl和UnblockableEntryImpl。
可以这样来理解这三个对象:TwoLocationEntryImpl可以看做航班计划项的“位置管理器”,它保存航班计划项所使用的起点和终点,航班计划项可以通过调用它的方法进行位置的操作;SingleResourceEntryImpl可以看做航班计划项的“飞机管理器”,它保存航班计划项所使用的飞机,航班计划项可以通过调用它的方法操作飞机资源;UnblockableEntryImpl可以看做航班计划项的“时间管理器”,它保存航班计划项的起止时间对,航班计划项可以通过调用它的方法操作起止时间对。
/**
* 表示航班计划项的不可变类,有一个起点和一个终点,占用一架飞机,不可阻塞,持续一个连续的时间段
*/
public class FlightEntry extends CommonPlanningEntry<Plane> implements FlightPlanningEntry<Plane>{
//管理起点和终点
private final TwoLocationEntryImpl twoLocationEntry;
//管理单架飞机
private final SingleResourceEntryImpl<Plane> singleResourceEntry;
//管理单个时间段
private final UnblockableEntryImpl unblockableEntry;
/**
* 根据传入的航班号、位置管理器、飞机管理器和时间管理器创建一个航班计划项
*
* @param name 航班号,要求由两位大写字母和2-4位数字构成
* @param twoLocationEntry 航班计划项的位置管理器
* @param singleResourceEntry 航班计划项的飞机管理器
* @param unblockableEntry 航班计划项的时间管理器
*/
public FlightEntry(String name, TwoLocationEntryImpl twoLocationEntry,
SingleResourceEntryImpl<Plane> singleResourceEntry, UnblockableEntryImpl unblockableEntry) {
super(name);
this.twoLocationEntry = twoLocationEntry;
this.singleResourceEntry = singleResourceEntry;
this.unblockableEntry = unblockableEntry;
if(singleResourceEntry.getResource()!=null) {
this.allocate();
}
}
@Override
public void setResource(Plane resource) {
singleResourceEntry.setResource(resource);
}
@Override
public Plane getResource() {
return singleResourceEntry.getResource();
}
@Override
public LocationPair getLocations() {
return twoLocationEntry.getLocations();;
}
@Override
public boolean isLocationShareable() {
return twoLocationEntry.isLocationShareable();
}
@Override
public Timeslot getTimeslot() {
return unblockableEntry.getTimeslot();
}
}
至此完成了FlightEntry的设计,相关的接口和类之间的关系如下图所示:
组合复用的优点
#1 维护类的封装性
这一点是相较于使用继承树的方案而言的。在使用继承树的方案中,继承层次越深入,在该层上的临时特性组合类所拥有的属性越多。在某个子类中,可能只是扩展了“计划项使用单个资源”的属性,但却从父类中继承了计划项使用的起点和终点。这样不仅破坏了父类的封装性,在逻辑上也令人费解。
而对于使用组合复用的方案,我们可以看到不同维度的特性之间不存在任何关系,且各个特性类的内部实现对于它们所组合出的计划项类是不可见的,因而在“特性”层面和“组合类”层面很好地维护了类的封装性。
#2 易于增加新的接口
这一点是显而易见的。在上面的例子中,各个特性的接口及其实现类都是相互独立的模块。如果用户需求发生变化,需要增加新的特性接口,例如笔者在本实验中没有用到的表示计划项使用多个带次序且不需区分ID的资源的接口MultipleIndistinguishableResource,则不需要考虑该特性接口的实现类与其他类之间的关系,从而简化了设计。
#3 复用的灵活性好
在上面的例子中,通过在计划项接口中继承所需的特性接口,在计划项实现类中组合所需的特性实现类,可以实现灵活的复用,满足不同种类计划项的需求。
从3.14节中对FlightEntry所做的变化即可看出复用的灵活性,此时客户的需求发生改变,需要航班支持最多一次经停。
首先需要修改FlightPlanningEntry继承的接口。在“位置”维度上,将表示“计划项使用一个起点和一个终点”的接口更改为表示“计划项使用多个位置”的接口;在“是否可阻塞”维度上,将表示“计划项不可阻塞”的接口更改为表示“计划项可阻塞”的接口。
/**
* 表示航班计划项的接口
*/
public interface FlightPlanningEntry<Plane> extends MultipleLocationEntry, SingleResourceEntry<Plane>, BlockableEntry {
}
然后修改FlightEntry的实现。将FlightEntry类中的“位置管理器”类型改为MultipleLocationEntryImpl,“时间管理器”类型改为BlockableEntryImpl,并根据新的FlightPlanningEntry接口定义将各个操作委托给新的“管理器”。
/**
* 表示航班计划项的不可变类,在运行过程中经过一系列位置,其间最多经停一次,占用一架飞机,可阻塞,运行时间为一系列不连续的时间段
*/
public class FlightEntry extends CommonPlanningEntry<Plane> implements FlightPlanningEntry<Plane>{
//管理多个位置
private final MultipleLocationEntryImpl multipleLocationEntry;
//管理单架飞机
private final SingleResourceEntryImpl<Plane> singleResourceEntry;
//管理多个时间段
private final BlockableEntryImpl blockableEntry;
/**
* 根据传入的航班号、位置管理器、飞机管理器和时间管理器创建一个航班计划项
*
* @param name 航班号,要求由两位大写字母和2-4位数字构成
* @param multipleLocationEntry 航班计划项的位置管理器
* @param singleResourceEntry 航班计划项的飞机管理器
* @param blockableEntry 航班计划项的时间管理器
*/
public FlightEntry(String name, MultipleLocationEntryImpl multipleLocationEntry,
SingleResourceEntryImpl<Plane> singleResourceEntry, BlockableEntryImpl blockableEntry) {
super(name);
this.multipleLocationEntry = multipleLocationEntry;
this.singleResourceEntry = singleResourceEntry;
this.blockableEntry = blockableEntry;
if(singleResourceEntry.getResource()!=null) {
this.allocate();
}
}
/**
* 分配航班计划项使用的飞机
*
* @param resource 待分配的飞机
*/
@Override
public void setResource(Plane resource) {
singleResourceEntry.setResource(resource);
}
/**
* 获取航班计划项使用的飞机
*
* @return 航班计划项使用的飞机
*/
@Override
public Plane getResource() {
return singleResourceEntry.getResource();
}
/**
* 获取航班计划项使用的位置列表
*
* @return 航班计划项使用的位置列表
*/
@Override
public List<Location> getLocations() {
List<Location> newLocations = multipleLocationEntry.getLocations();
return newLocations;
}
/**
* 判断航班计划项使用的位置是否可共享
*
* @return true,若航班计划项使用的位置可共享;
* false,若航班计划项使用的位置不可共享
*/
@Override
public boolean isLocationShareable() {
boolean isLocationShareable = multipleLocationEntry.isLocationShareable();
return isLocationShareable;
}
/**
* 获取航班计划项经历的时间段列表
*
* @return 航班计划项经历的时间段列表
*/
public List<Timeslot> getTimeslots() {
return blockableEntry.getTimeslots();
}
/**
* 阻塞当前的航班计划项
*/
@Override
public void block() {
blockableEntry.block();
state = state.changeState("block");
}
}
可以看到,由于各个特性实现类具有较高的模块化程度,当对原有的特性组合进行改变时,只需要修改变化的特性接口,并在实现类中修改变化特性的委托对象和委托方式;而对于没有发生变化的特性(在本例中是“使用单个资源”),则不需要进行任何修改,体现了组合复用方法的灵活性。
组合复用的小缺点(?)
#1 系统中的接口和类过多
可以预料到这种方法一定会导致系统中产生大量的接口和类。事实上,为了覆盖三个计划项的所有特性,笔者设计了9个接口,并分别编写了对应的实现类。进一步地,在运行时这种方法可能会使系统中产生大量的对象。
不过考虑到这些特性ADT是面向可复用性开发的,如果将来需要利用这些接口组合出其他种类的计划项,那么编写大量的接口和类所付出的代价就是值得的。
#2 需要定义细粒度的接口
这一条是针对ADT的开发人员来说的。为了让设计出的接口能适应各种情况下的组合变化,开发者不得不定义细粒度的接口。
用笔者上一篇博客中的例子来说明,课程计划项的位置特性是“单个位置,且可更改”的,但是我们不能把表示“单个位置”的接口和表示“位置可更改”的接口整合成一个。因为并不是所有使用单个位置的计划项的位置都可更改,如果客户组合出的接口使用单个位置,但不能更改该位置,我们就不应该强迫用户去接受“更改位置”的方法。因此最好的做法是定义一个表示“单个位置”的接口和一个表示“位置可更改”,用户可以根据需要任意将它们与其他特性进行组合。
但对于客户来说,这无疑是一个巨大的优点,因为细粒度的接口增强了组合的灵活性,客户可以利用这些ADT组合出更多样的计划项。