前言

我常常觉得人们低估了设计模式的作用和意义。它们不仅是简历上的金边、程序员的黑话,也不仅是常见业务的常用处理方式或经验总结。

设计模式不仅是这些,它们更是面向对象思想理论结合实践的切入点。我们前面聊过抽象、高内聚低耦合、封装继承多态、SOLID设计原则。它们更偏理论指导,离编码实践还有一段距离。而这里要聊的设计模式,不仅有扎实的理论基础,而且实实在在地俯下身子、扎根到了实践当中。

从编码实践的角度来讲设计模式,这类文章没有一万也有八千。细抠几种设计模式之间的区别,这类文章写再多也没太大意义。这里就不凑这些热闹了。

这里,我会简单聊聊几个主要设计模式的编程应用,然后把主要精力放在它们与面向对象思想的关联上。另外,在聊过几种设计模式之后,计划提供一种复合模式,作为我使用设计模式的“最佳实践”,供读者参考。


模板模式

如果说“用到了接口-实现类,就用到了策略模式”,那么策略模式可能是我们无意之间用得最多的设计模式。如果排除“无意中”的使用,只考虑有意识、有目的情况,我想,模板模式应该算得上“使用最多的设计模式”,应该也是大多数人第一个运用到实践中的设计模式。

有意和无意,有很大差别

我想,其中的主要原因在于我们大多数人开发的都是业务系统、业务流程。业务系统和流程有一个显著特点:在一套基础业务流程上,改一改这一步、调一调那一步,就得到了所谓新业务、新流程、新需求。模板模式与这一特点一拍即合,自然就很容易进入到开发实践中。

是什么

关于模板模式的定义,我在网上查到的了很多这样描述的:

模板模式在一个抽象类中定义了一个算法的骨架,而将一些步骤延迟到子类中。

这个定义里,其它部分都好说,就是“延迟”二字让人费解。延迟一般是指“比xxx更慢/更晚”,这里是谁比谁更慢或更晚呢?

看了一圈,我比较喜欢这个定义:

The Template Method design pattern is a behavioral design pattern that defines the skeleton of an algorithm in a superclass but allows subclasses to override specific steps of the algorithm without changing its structure.
模板方法模式是一种行为设计模式。它在父类中好定义算法骨架,并允许子类在不修改算法结构的情况下,重写其中的特定的步骤。

从这个定义中,我们可以找出模板模式的四个关键要素:父类、子类、算法骨架、被重写的步骤,如下图所示: 模板模式

模板模式类图

父类之于子类有很多种意义。在模板模式中,父类的主要意义在于定义算法骨架。所谓“算法骨架”,首先是操作流程,其次则是“可重写的步骤”。有了“可重写的步骤”,子类才能够“重写”这些步骤。但也因为操作流程在父类中定义好了,因而子类只能重写个别步骤的具体实现,而不能修改算法的完整流程、步骤顺序等。

网上提到模板模式,大多强调父类一定是抽象类。其实未必。虽然不太符合里氏替换原则,但模板模式中的父类也可以是普通类。这是后话。

所谓“可重写步骤”,通常即封装有部分操作流程的方法。有些时候,它们也叫“钩子方法”。子类通过重写这些方法,来改写操作流程中的部分细节。这些方法的可见性、入参、返回值,以及“颗粒度”等,都值得慎重考虑。

可见性也许是其中最简单的。为了兼容跨包的子类,父类的可见性一般都是public。但方法的可见性则不宜过大。有些代码里一股脑地将它们也定义为public,也有些开发者把这些方法放到了接口上,实在没有必要。这些方法只服务于两个目标:组成完整的操作流程,以及供子类重写。因此,保证这些方法能被子类访问到即可,其它调用者一律禁止入内。

一个方法的可扩展性,受方法入参影响很大。我见过不少子类A要用参数ABC、而子类B要用参数CDE的。如果方法入参定义不当,从子类一扩展到子类二时,就免不了一番伤筋动骨。

怎么做

明白了模板模式的四个要素,实现起来就很简单了:

// 父类,定义算法的骨架
public class ParentClass {
    // 定义算法的步骤
    public final Result templateMethod(Param p) {
        Temp1 temp1 = step1(p);
        Temp2 temp2 = step2(p, temp1);
        Result result = step3(temp1, temp2);
        return result;
    }

    // 定义算法的第一步
    protected Temp1 step1(Param p){
        // 默认实现,略
    }

    // 定义算法的第二步
    protected Temp2 step2(Param p, Temp1 temp1){
        // 默认实现,略    
    }

    // 定义算法的第三步
    protected Result step3(Temp1 temp1,Temp2 temp2){
        // 默认实现,略    
    }
}

// 具体子类,实现算法的步骤
class ConcreteClassA extends ParentClass {
    @Override
    protected Temp1 step1(Param p){
        // 修改实现步骤1,略
    }

    @Override
    protected Result step3(Temp1 temp1,Temp2 temp2){
        // 修改实现步骤3,略
    }
}

// 具体子类,实现算法的步骤
class ConcreteClassB extends ParentClass {

    @Override
    protected Temp2 step2(Param p, Temp1 temp1){
        // 修改实现步骤2,略
    }
}

// 使用模板模式
public class Client {
    public static void main(String[] args) {
        ParentClass template = new ConcreteClassA();
        template.templateMethod();

        template = new ConcreteClassB();
        template.templateMethod();
    }
}

上面的例子看起来很简单,其实有隐藏的“坑”:“方法步骤”要怎样拆分比较合适?入参和返回值要怎样设计比较合理?

一般来说,拆分方法步骤有两种思路。第一种是在还没有子类的时候,就在父类中设计好方法步骤,并预留下供子类重写的方法。第二种则是没有子类时,父类不做扩展性考虑;在必要时,再根据子类的扩展需求,在父类中拆分方法步骤、设计可重写方法。

这两种方法,和“自上而下”和“自下而上”的方法论颇有些异曲同工。它们的优缺点也可以从类似的角度来分析。

第一种方法,“自上而下”地设计好一个模板类,为子类预留下扩展空间。如果父类的流程有明确的步骤,或者子类的扩展有清晰的方向,那么借助这种自上而下的方法,我们可以设计出一套卓有成效的父子类来。然而,如果没有这两个前提——“父类的流程有明确的步骤”,或者“子类的扩展有清晰的方向”——自上而下的方法就很容易变成过度设计,最终得到南辕北辙的效果。

第二种方法则是“自下而上”地演化出一套模板和父子类。它很好地弥补了第一种方法的缺陷,但也有明显的问题。“按需演化”很容易演变成“按需特化”,得到的模板不伦不类,既复杂难懂、又难以扩展。

自上而下和自下而上,有很大差别

从中庸的角度来说,结论显而易见:不要拘泥于某一种方法,而要灵活地、综合地加以运用。但我个人更倾向于从上而下的方法论。条件允许时,应该优先考虑自上而下地设计,用优秀的“顶层设计”来指导、推动底层实现。如果从自下而上入手,也应注意审时度势,在必要的时候梳理顶层设计,优化模板类结构。

无论自上而下还是自下而上,都是一件费时费心的工作。我们为什么要花费时间精力来设计和使用模板模式呢?模板模式有怎样的优点,又有哪些缺点呢?

为什么

模板模式可以提高代码复用率和可扩展性。这大概是它最显而易见的两个优点,这里就不啰嗦了。

模板模式还有一个更高层次的优点:它是一种入门级的建模方式。

模型思维和普通思维,有很大差别

个人理解,建模需要抓住事物的核心本质,并简练而明确地描述其组成元素和各元素的组织结构、运转规律。

设计模板模式正是这样一个过程。我们先要分析业务流程,然后把流程按一定顺序分解成若干步骤,识别各步骤的入参和返回值,还要把这些步骤区分为“所有子类通用的”和“允许子类修改的”。完成这些工作之后,我们才能确定父类如何实现、子类如何重写。

在这个过程中,我们得到的步骤、入参和返回值,其实就是这个流程模型中的“元素”;各步骤的执行顺序、入参和返回值的转换和传递,就是这个模型的运转规律;步骤的分类则可以理解为这个模型的组织结构的基础:我们需要借助“允许子类修改的步骤”,来组织父子类结构。

你看,一个流程模型就这样拔地而起了。

当然,建模没有这么简单, 建好模更不容易。不过,用模型思维来分析和构建系统,于系统、于自己都百利一弊——唯一的弊端在于成本偏高。而模板模式作为一种入门级的建模方式,就像是一扇通往模型思维的大门。当我们有意识、有目的地使用模板模式时,其实就已经站到了这扇门前。推开它、走进去,我们会发现更广阔的天地。

推门进去和扭头离开,有很大差别

顺带,关于模型思维的“百利”,这里简单列举几个。

模型的7大用途(REDCAPE)
推理: 识别条件并推断逻辑含义。
解释: 为经验现象提供(可检验的)解释。
设计: 选择制度、政策和规则的特征。
沟通: 将知识与理解联系起来。
行动: 指导政策选择和战略行动。
预测: 对未来和未知现象进行数值和分类预测。
探索: 分析探索可能性和假说。
——《模型思维》

有兴趣的话,不妨对照理解一下自己代码中的模板模式,看看通过模板模式,我们能解释什么、预测什么、探索什么。

最后也说一说模板模式的缺点。

模板模式最大的问题在于:它对类继承有很强的依赖。继承是一把双刃剑,它可以高效率复用代码,也很容易导致“类爆炸”问题。不仅类数量会暴增,类的继承结构也会层层叠叠、复杂度暴涨。在使用模板模式时,这是必须谨慎处理的问题。

复杂的类继承结构与代码复用之间,还有一个具体的矛盾:多继承问题。Java中,一个类只能有一个父类。但有些时候,一个类除了要继承模板父类之外,有可能还需要继承其它类。遇到这种情况,要怎么办呢。

虽然模板模式总是和类继承有着密切关联,但是二者也并非牢不可破的联盟。我们可以通过把继承转变为组合,来规避类继承带来的问题,同时保留模板模式的优点。至于具体怎么做,容我先卖个关子。

除了类继承之外,模板模式中还有另一把小小的双刃剑:固定流程中各步骤的顺序。固定顺序能够保证业务流程的一致性,但是也大大降低了子类的灵活性。如果子类的步骤顺序不一样,那么父类对子类来说,就不是模板而是束缚了。

总的来说,模板模式在需要重用业务流程、同时允许子类重写某些步骤的场景中非常有用。然而,在选择使用模板模式时,我们需要权衡其带来的代码复用和行为一致性的优点,以及可能增加的类数量和复杂性的缺点,审慎使用。


往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?

《抽象》

抽象这个东西,说起来很抽象,其实很简单。

《高内聚与低耦合》

《细说几种内聚》

《细说几种耦合》

"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。

《封装》

《继承》

《多态》

——“面向对象的三大特性是什么?”
——“封装、继承、多态。”

《[5+1]单一职责原则》

单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。

《[5+1]开闭原则(一)》

《[5+1]开闭原则(二)》

什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。
什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。

《[5+1]里氏替换原则(一)》

《[5+1]里氏替换原则(二)》

里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则

《[5+1]接口隔离原则(一)》

《[5+1]接口隔离原则(二)》

一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更 小、更具体的接口。
不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的……客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。

《[5+1]依赖倒置原则(一)》

《[5+1]依赖倒置原则(二)》

在Java世界里谈到依赖倒置原则,相信90%的人都会立即想起SpringIOC;还有9%的人会想起“面向接口编程”。最多只有1%的人能想起依赖倒置原则的真正定义。

《[5+1]迪米特法则(一)》

《[5+1]迪米特法则(二)》

迪米特法则可以用一句话概括:Only talk to your friends。
“只和你的朋友说话”,这是1987年的表述。2003/2004年左右,Karl Liebertherr对迪米特法则做了一次升级:由“Only talk to your friends”升级为了“Only talk to your friends who share your concerns”——“只和与你同忧同乐的朋友说话”。

《常用设计模式-策略模式》

策略模式把属于同一类别的不同行为封装为某种“策略抽象”,而把这些行为统一为这个抽象下的某个“策略实现”。这样,我们就可以很灵活地决定在哪种场景下使用哪种“策略”了。

《常用设计模式-工厂模式(一)》

《常用设计模式-工厂模式(二)》

工厂模式的定义其实很简单:提供一个独立组件,用以根据不同条件选择并构建不同实例。这个组件就是“工厂”。

《常用设计模式-注册器模式(一)》

《常用设计模式-注册器模式(二)》

注册器模式提供了一个注册器,一组相同类型的实例可以被注册到注册器上并由后者进行保存。调用方则可以通过注册器来取用这些实例。