当我们开发一个类库、框架,或者业务软件的领域层,即那些被其他开发人员而不是最终用户所使用的代码时,对于我们要编写的每一个模块、每一个类、甚至每一个方法,要时刻把三种人的需求记在心里:代码使用者、实现者和扩展者,他们对代码的需求和约束各不相同。
对于代码的使用者来说,我们要为他们提供最小化的接口,即在提供完整功能的前提下最小化他们需要/能够了解的类和方法的数量。这就是俭省原则——能不给就不给;也可称之为“最小概念重量”原则——为了了解和使用我们的代码,代码的使用者需要掌握的知识量要尽可能的少。就像电视观众不必掌握电子学知识就可以通过简单的按钮和遥控器观看电视一样,软件代码也应该通过最小化的接口把系统的复杂性封装起来,给它的用户一个简单的表象。如果你把系统的复杂性一股脑抛给代码的使用者,巨大的知识量会压垮他们的脑子,再也没有剩余精力去做他分内的事情——写他那个抽象层级的代码——了。
对于代码的实现者——你,你的团队,以及未来的维护者——来说,要为他们保留最大化的自由度。其实这一点跟“为代码使用者提供最小化的接口”是一脉相承的——为代码使用者提供的接口内容越多,为代码的实现者保留的自由度越少;为代码使用者提供的接口内容越少,为代码的实现者保留的自由度越大。应该把接口看作使用者和实现者之间的契约,你承诺的越多(通过接口暴露出去的类和方法越多),你能够腾挪的空间越小。你暴露出去的每一个类和每一个方法都可能被使用者在他的代码中使用,这意味着你未来不能够随意删除或重构它们,即使是在接口上添加一个方法,或者修改一个方法的参数类型,都可能导致在升级之后使用者的代码无法运行的问题。我们要严格划分哪些内容要暴露给使用者,作为我们对使用者的承诺;哪些内容要隐藏起来,作为内部实现使用。以发动机为例,我们只承诺马力和推重比,不承诺组成材料和结构,后者属于我们的实现自由。
下面是一些具体做法:
- 只向使用者暴露接口或抽象基类,隐藏具体类和子类的实现。使用者对模块内部的组成结构和类型结构一无所知。
- 只暴露满足使用需求的最少量的方法。
- 除非确有必要,尽可能不要通过接口方法的参数、返回值和异常暴露内部类型。
- 接口方法抽象层级要高,也就是说,它的参数和返回值的类型最好是接口或抽象基类,而不是具体类/子类。
- 每个接口方法都应该是独立的,不能要求使用者按指定顺序调用它的多个方法。
软件开发需要关注的第三种角色是系统的扩展者,则扩展我们的模块以提供更多的功能或灵活性的人。我们在设计每一段代码的时候都要想到扩展性,一方面我们要规划在哪些地方预留扩展点,另一方面要保证扩展者不会违反预定的契约。
一般而言,我们可以像JDBC和JPA那样,通过服务提供者接口(SPI)提供明确的接口,供扩展者实现;也可以提供包含一个或多个protected的方法的基类,供扩展者扩展。在任何情况下必须遵守OO的“开放-封闭原则”——对扩展开放,对修改封闭。扩展者在不修改现有代码的前提下通过提供新类来扩展系统的功能。
我们必须进行精心的设计,防止扩展者破坏我们对父类的预期。设想有这样一个士兵父类Soldier,它定义了一个冲锋方法charge(),它的实现是向前进攻goForward():
public abstract class Soldier {
public void charge() {
goForward();
}
}
然后扩展者给Soldier类定义了一个子类海军士兵Seaman,它覆盖了父类的charge()方法,错误地实现为向后跑:
public class Seaman extends Soldier {
@Overwrite
public void charge() {
goAfterward();
}
}
当司令部发出冲锋命令时,就会乱套了,陆军士兵可能向前进攻,海军士兵却向后逃跑:
public class Headquarter {
public void charge(Set<Soldier> soldiers) {
for (Soldier soldier : solidiers) {
solider.charge();
}
}
}
所以结论是:父类的非抽象非private方法都应该是final的,也就是说,只有抽象方法才允许子类覆盖。父类中的具体方法代表所有具体子类的共性,不应该被某个子类改写,否则可能会打破父类的预期,出现代码用户和实现者意想不到的结果。
那么,如果所有的士兵在冲锋时都是向前进攻,但是海军士兵在冲锋前必须做点特别的准备怎么办?这时应该允许子类扩展而不是覆盖父类的方法,具体做法是在父类Soldier的charge()方法中开一个新的扩展点beforeCharge,这个扩展方法默认啥都不做:
public abstract class Soldier {
public final void charge() {
beforeCharge();
goForward();
}
protected void beforeCharge() {}
}
海军士兵子类Seaman覆盖beforeCharge()方法而不是charge()方法,这样既保证所有的士兵在接到冲锋命令时都会向前进攻,同时海军士兵还能在冲锋前做一些军种特有的准备工作:
public class Seaman extends Soldier {
@Overwrite
public void beforeCharge() {
doSomething();
}
}
通过上面的方法,一方面可以保证系统可以扩展,另一方面可以保证子类不会打破在父类中实现的共同契约。