1. 使用场景
模版方法模式的两个使用场景:
- 设计通用函数。功能抽象的重要内容。
- 代码向上集中。用于在各种子类已有代码的基础上,通过重构,提取公共的行为并集中到父类中以免代码重复。
2. 求和与”传统”模板模式
以求和为例,我们可能有下面这些需求。
- 求[a,b]之间自然数的和;
- 求[a,b]之间自然数的立方的和
- 求调和级数前n项的和,其中调和级数为H(n)= 1/1 + 1/2 + 1/3 + 1/4 + … + 1/n
- 求Pi,其中pi/8 = 1/(13)+1/(57)+1/(9*11)+…
- ……
通过分析,将求和分为以下两部分。
- 抽象,提取出的统一部分。统一形参部分,当参数个数不同时,设计的通用函数时需要取最多个数的参数;不同求和函数的返回值类型不同时,则取更大的数据类型如double,作为通用函数的返回值类型…
- 变化,需要参数化的部分。各种求和函数的关键不同、可变化部分有2处——步进和累加项。将两个可变的部分设计为抽象方法 next() 和 item()。
变化部分的抽象方法如何封装,GoF 选择“将它的一些步骤延迟到子类中”(让子类继承,然后强制实现),可以称之为传统模版方法模式。
该模式的缺点在于两个抽象方法next() 和 item()的组合会造成子类数量爆炸。
//抽象类实现求和模板方法
public abstract class Sum {
public final double getSum(double a,double b){
double sum=0;
for(;a<=b;a=next(a)){
sum+=item(a);
}
return sum;
}
public abstract double next(double a);
public abstract double item(double a);
}
//抽象类实现求和模板方法
public interface Sum1 {
default double getSum(double a,double b){
double sum=0;
for(;a<=b;a=next(a)){
sum+=item(a);
}
return sum;
}
double next(double a);
double item(double a);
}
//调和级数
//H(n)= 1/1 + 1/2 + 1/3 + 1/4 + ... + 1/n
public class Harmonic extends Sum {
@Override
public double next(double i) {
return i + 1;
}
@Override
public double item(double x) {
return 1.0 / x;
}
}
3. 策略模式 VS 模板模式
为避免子类爆炸,尤其是考虑到 item(double x) 的实现有无限可能,程序员通常应该将 模板方法所依赖的抽象方法从Sum中分离出去(行为参数化)。
重构 Sum 作为环境类而二次使用策略模式,而 IItem 、 INext 可以作为两个成员变量,也可作为模板方法的参数(提供4参数的模板方法),此时 Sum 返璞归真为 final 工具类。
public interface IItem{
double item(double a);
}
public interface INext{
double next(double a);
}
public final class Sum{
public static double getSum(double a, double b, INext iNext, IItem iItem) {
double sum = 0;
for (; a <= b; a = iNext.next(a)) {
sum += iItem.item(a);
}
return sum;
}
}
这一有4参数的模板方法说明,模板方法代码中的变化部分存在多种设计选项:
- 可以延迟到子类中,由子类继承实现
- 作为模板方法的参数
采用了多重策略的模板模式可以定义为:模板方法定义一个算法的骨架。它的一些可变部分可以延迟到子类中,或采用多重策略(行为参数化),这些策略彼此独立。
4. 可变步骤如何设计
首先,要了解子类对待父类函数的5种形式/态度:
- 直接继承。如父类的final方法、不准备被子类override的其他方法。
- 改进语意的改写。子类的改写方法中必须调用父类相同方法。
- 替换语意的改写。完全以新的实现替换父类的实现,体现了对父类函数的代码的漠视。
- 空方法的改写。没有代码复用的诱惑,一种特定情况下使用的设计技巧。
- 接口继承(抽象方法)。对抽象方法的继承,无代码可以复用。
对象抽象的代码向上集中原则,本身意味着对父类代码的复用。
这5种形式中,仅前2种复用了父类代码,前3种父类都提供了具体代码,或是final类,或是。
父类在设计模板模式中的可变步骤时,通常有3种设计选择:
- 抽象方法
- 空方法
- 具体方法(含接口中的default方法)
4.1 抽象方法
求和函数中使用的 item(double x) ,就是一种最典型的设计选择——抽象方法。可变/不稳定的部分定义为抽象方法(C++中为纯虚函数)是首选,子类必须改写它们。4.2 空方法
空方法指方法体为空或者返回固定值的方法。空方法可以称为可选方法/操作(optional operation),是一种特定情况下使用的设计技巧,不会强迫(具体的)子类必须改写该方法。4.3 具体方法
父类提供具体方法时,一定要警惕实现继承的缺陷。
考虑求和函数使用的 next(double x) ,在循环计算中步进为一的 i++ 最为常见,能否在设计 INext 时充分强调 i++ 这种应用呢?将 i++ 设计成一个具体的方法( default 方法),更进一步,将 i++ 设计为静态成员 ONE 也是一种设计选择。
但是,这种方案使得INext成为普通接口,用户需要INext的其他实现时,只能够采用匿名类而不能够使用简洁的lambda表达式。
实际上,充分重视 i++ ,完全仍然采用抽象方法,而在 Sum 工具类中,为步进为一的情况,添加一个重载的三参数的版本,即getSum(double a, double b, IItem iItem)。
4.4 改进语意的改写
父类的设计者应该明白,作为设计者,他不能够依靠其他程序员(编写子类型的人)的自律。
父类设计者可以强制子类必须采用改进语意的改写,手段是将 m3() 重构为一个小的、private的模板方法 _m3() 。
public abstract class Sup{
//模板方法
private void _m3(){
//原来的m3()的代码。避免子类忘记
pln("Sup._m3(),base");
//假定子类的扩展代码将放在原来的m3()之后
this.m3();
}
public abstract void m3(); //强制子类复写,GoF所称的钩子
public void m3(){}; //可选
public final void templateMethod(){
_m3() ;
}
}