说到模板方法模式,它可能是一个让我们深入骨髓而又不自知的模式了,因为它在我们开发过程中会经常遇到,并且也非常简单。只不过,很多时候我们并不知道它就是模板方法模式而已。不负责任的说,当我们用到override
关键字重写父类方法的时候,十有八九就跟模板方法模式有关了。
定义
先看一下模板方法模式的定义,模板方法模式定义了一个操作中的算法的框架,而将一些步骤延迟到子类中。使得子类可以不改变一个算法的结构即可重定义该算法的某些步骤。
这里延迟到子类说的玄乎,其实就是子类继承并实现父类中的抽象方法(abstract
),而重定义该算法的某些步骤指的就是子类重写父类的虚方法(virtual
)。不过,不管是哪一个,子类都需要用到override
。
实例
我们还是通过一个例子来解释模板方法模式,先来一个经典的脑筋急转弯。
把一个大象装进冰箱要几个步骤?
答案是三步:
- 第一步,把冰箱门打开
- 第二步,把大象放进去
- 第三步,把冰箱门关上
对应到前面的定义,这里把大象装进冰箱的步骤就是算法的框架,而其中的每一步就是算法的具体步骤。我们用代码实现看看:
public abstract class AnimalToFridge
{
public void Do()
{
OpenFridge();
PutIntoFridge();
CloseFridge();
}
private void OpenFridge()
{
Console.WriteLine("把冰箱门打开");
}
public abstract void PutIntoFridge();
private void CloseFridge()
{
Console.WriteLine("把冰箱门关上");
}
}
上面定义了一个把动物放进冰箱的基类,Do()
方法定义了把大象装进冰箱的算法骨架,其中,打开冰箱和关闭冰箱两个步骤是固定不变的,变化只是把什么动物放进去。
再定义一个把大象放冰箱的子类,继承自上面的基类:
public class ElephantToFridge:AnimalToFridge
{
public override void PutIntoFridge()
{
Console.WriteLine("把大象放进去");
}
}
使用时,我们只需要调用Do()
方法就可以完成把大象放冰箱的动作了:
static void Main(string[] args)
{
AnimalToFridge elephantToFridge = new ElephantToFridge();
elephantToFridge.Do();
}
这时候如果我们要把其他动物放进去,只需要继承AnimalToFridge
就可以了,例如,我们把狗放进冰箱:
public class DogToFridge: AnimalToFridge
{
public override void PutIntoFridge()
{
Console.WriteLine("把狗放进去");
}
}
但是你以为这么简单就结束了吗?知道这个脑筋急转的朋友应该都知道它还有第二问。
然后把一个长颈鹿装进冰箱要几个步骤?
答案是四步:
- 第一步,把冰箱门打开
- 第二步,把大象弄出来
- 第三步,把长颈鹿放进去
- 第四步,把冰箱门关上
我们可以分析一下需求,也就是说,把大象放进之前不需要先把什么拿出来,但是放长颈鹿需要先把大象弄出来。再进一步分析的话,可以推测把鸡蛋、蚂蚁这样的小东西放进去,即使里面有大象,应该也不需要先把大象拿出来,而放狮子、老虎这样的大型动物就需要清空冰箱。为了满足这样的需求,我们的虚方法就登场了,代码可以做如下改进:
public abstract class AnimalToFridge
{
public void Do()
{
OpenFridge();
BeforePutIntoFridge();
PutIntoFridge();
CloseFridge();
}
private void OpenFridge()
{
Console.WriteLine("把冰箱门打开");
}
protected virtual void BeforePutIntoFridge() { }
protected abstract void PutIntoFridge();
private void CloseFridge()
{
Console.WriteLine("把冰箱门关上");
}
}
基类中增加了一个BeforePutIntoFridge()
的虚方法,方法只有一个空的实现(当然,如果需要的话,也可以添加具体内容),除此之外,我把虚方法和抽象方法的访问修饰符都改成protected
了,因为,算法的单个步骤不应该被客户端直接调用,调用了也没有任何意义。这样,我们的大象和长颈鹿子类就可以如下实现了:
public class ElephantToFridge : AnimalToFridge
{
protected override void PutIntoFridge()
{
Console.WriteLine("把大象放进去");
}
}
public class GiraffeToFridge : AnimalToFridge
{
protected override void BeforePutIntoFridge()
{
Console.WriteLine("把大象弄出来");
}
protected override void PutIntoFridge()
{
Console.WriteLine("把长颈鹿放进去");
}
}
ElephantToFridge
类不重写父类的BeforePutIntoFridge()
方法,而GiraffeToFridge
类重写了,也就是定义中所说的重定义了该算法的某些步骤了。
好了,这样就改造完成并满足需求了,我们再来看一下最终的整体类图:
这就是模板方法模式,其实就是对继承加抽象方法和虚方法的使用,这可能算是继承的巅峰时刻了吧,在其他模式中只有被吐槽的命。
UML类图
再抽象一下就可以得到模板方法模式的UML类图了:
钩子函数
在学习模板方法模式的时候,我们可能会经常听到钩子函数这个概念。钩子就是给子类一个授权,让子类来可重定义模板方法的某些步骤,听着高大上,说白了就是虚方法而已。
优缺点
优点
- 封装了算法骨架,提高了代码复用性,简化了使用难度;
- 封装不变部分,扩展可变部分,满足开闭原则。
缺点
- 算法骨架不易更改,也就是原先定义的算法步骤如果需要变化,就不得不修改源代码了;
- 扩展时,可能会产生很多子类,这是继承不可避免的缺陷。
跟建造者模式的异同
建造者模式很多地方跟模板方法模式是很相似的,例如,他们都是通过继承实现,都会把易变化的部分延迟到子类实现,并且都有一个方法封装骨架,只不过,建造者模式延迟到子类的是各部件的创建,封装的是最后的构建流程。而模板方法模式延迟到子类实现的是算法的某些步骤,封装的是算法骨架。也就是说如果你承认创建对象也是一种算法的话,那二者其实就差不多了。不过呢?他们也是有区别的,因为建造者模式中,各部件的建造需要客户端配合完成,因此,建造各部件的方法需要是public
的,而模板方法模式中,各单独的算法步骤不应该被客户端直接调用,因此通常是protected
的。不过,尽管如此,他们的设计思想确实是大同小异的。
说到这里,还记得建造者模式是如何通过使用委托来缓解子类过多的问题的吗?既然模板方法模式与建造者模式相似,那么处理方式也应该相似了,我们看看最终实现效果:
public class AnimalToFridge
{
public void Do(Action beforePutIntoFridge,Action putIntoFridge)
{
OpenFridge();
beforePutIntoFridge?.Invoke();
putIntoFridge?.Invoke();
CloseFridge();
}
private void OpenFridge()
{
Console.WriteLine("把冰箱门打开");
}
private void CloseFridge()
{
Console.WriteLine("把冰箱门关上");
}
}
直接把抽象方法和虚方法都去掉了,换成了委托,只保留了算法骨架。这样做好处很明显,不需要子类了,无论多少动物,全部都通过委托搞定了。不过缺点也很明显,算法的实现交给了客户端,给客户端的使用带来了不小的负担,并且如果调用位置很多,还会导致大量代码重复,难以维护。
因此,模板方法模式具体该如何使用还得视情况而定。