背景
在面向对象程序设计过程中,程序员常常会遇到这种情况:设计一个系统时知道了算法所需的关键步骤,而且确定了这些步骤的执行顺序,但某些步骤的具体实现还未知,或者说某些步骤的实现与具体的环境相关。
例如,去银行办理业务一般要经过以下4个流程:取号、排队、办理具体业务、对银行工作人员进行评分等,其中取号、排队和对银行工作人员进行评分的业务对每个客户是一样的,可以在父类中实现,但是办理具体业务却因人而异,它可能是存款、取款或者转账等,可以延迟到子类中实现。
这样的例子在生活中还有很多,例如,一个人每天会起床、吃饭、做事、睡觉等,其中“做事”的内容每天可能不同。我们把这些规定了流程或格式的实例定义成模板,允许使用者根据自己的需求去更新它,例如,简历模板、论文模板、Word 中模板文件等。
以下介绍的模板方法模式将解决以上类似的问题。
例子
星巴兹同时售卖咖啡和茶, 咖啡冲泡方法如下:
- 把水煮开
- 用沸水冲泡咖啡
- 把咖啡倒进杯子
- 加糖和牛奶
而茶叶冲泡方法如下:
- 把水煮开
- 用沸水浸泡茶叶
- 把茶倒进杯子
- 加柠檬
根据上述步骤,可以很容易写出如下代码:
class Coffee {
public:
void PrepareRecipe() {
BoilWater();
BrewCoffeeGrinds();
PourInCup();
AddSugarAndMilk();
}
private:
void BoilWater();
void BrewCoffeeGrinds();
void PourInCup();
void AddSugarAndMilk();
};
class Tea {
public:
void PrepareRecipe() {
BoilWater();
SteepTeaBag();
PourInCup();
AddLemon();
}
private:
void BoilWater();
void SteepTeaBag();
void PourInCup();
void AddLemon();
};
此时我们注意到,冲咖啡和冲茶有一些重复步骤,如煮开水、倒进杯子等。现在思考如何除去这些重复代码。 一种很容易想到的方式是将共同的方法抽出来当做基类。
class CoffeineBeverage{
public:
virtual void PrepareRecipe() = 0;
protected:
BoilWater();
PourInCup();
};
然后每种饮料实现自己的特殊方法:
class Coffee : public CoffeineBeverage{
public:
void PrepareRecipe() override {
BoilWater();
BrewCoffeeGrinds();
PourInCup();
AddSugarAndMilk();
}
private:
void BrewCoffeeGrinds();
void AddSugarAndMilk();
};
class Tea : public CoffeineBeverage{
public:
void PrepareRecipe() override {
BoilWater();
SteepTeaBag();
PourInCup();
AddLemon();
}
private:
void SteepTeaBag();
void AddLemon();
};
更进一步设计
两种饮料的冲泡法都使用了相同的算法
- 把水煮开
- 用热水泡茶或者泡咖啡
- 把饮料倒入杯子
- 加入适当的调料
-
于是我们尝试将
PrepareRecipe()抽象,遇到的第一个问题是咖啡是使用BrewCoffeeGrinds()和AddSugarAndMilk()方法,而茶使用的是SteepTeaBag()和AddLemon(),void PrepareRecipe() { BoilWater(); SteepTeaBag(); PourInCup(); AddLemon(); }void PrepareRecipe() { BoilWater(); BrewCoffeeGrinds(); PourInCup(); AddSugarAndMilk(); }考虑到冲和泡的区别不大,可统一将其改为
Brew()。同时,加糖、加柠檬也很相似,统一改成AddCondimments()。如此一来,PrepareRecipe()变成了:void PrepareRecipe() { BoilWater(); Brew(); PourInCup(); AddCondiments(); } -
将其放到基类中:
class CoffeineBeverage{ public: void PrepareRecipe() { BoilWater(); Brew(); PourInCup(); AddCondiments(); } private: virtual void Brew() = 0; virtual void AddCondiments() = 0; }; -
最后处理咖啡和茶。这两个类都是依赖超类(
CoffeineBeverage)来处理冲泡法,因此只需实现自行处理冲泡和添加调料部分。class Coffee : public CoffeineBeverage { private: void Brew() override { std::cout << "Brew Coffee Grinds" << std::endl; } void AddCondiments() override { std::cout << "Add Milk and Sugar" << std::endl; } };class Tea : public CoffeineBeverage { private: void Brew() override { std::cout << "Steep Tea Bag" << std::endl; } void AddCondiments() override { std::cout << "Add Lemon" << std::endl; } };
认识模板方法
模板方法定义了一个算法的步骤,并且允许子类为一个或多个步骤提供实现。
上例中,PrepareRecipe()是一个模板方法,因为:
- 它是一个方法
- 它用作一个算法的模板,在这个例子中,算法是用来做咖啡因饮料的;
在这个模板中,算法内的每个步骤都被一个方法代表了,某些方法是由这个基类处理的,某些方法则是由子类处理的。需要由子类提供的方法,必须在基类中声明为抽象(虚函数)。
让我们逐步泡茶,追踪这个模板方法是如何工作的。
- 首先需要一个茶对象:
CoffeineBeverage* beverage = new Tea(); - 然后我们调用模板方法:
beverage->PrepareRecipe(); - 模板方法第一步是把水煮沸:
这件事是在基类中进行的。CoffeineBeverage::BoilWater(); - 接下来,我们需要泡茶,这件事只有子类才知道该怎么做
beverage->Brew(); - 现在把茶倒进杯子中,所有的饮料的做法都一样,所以这件事发生在基类中;
CoffeineBeverage::PourInCup(); - 最后,我们加进调料,由于调料是各个饮料独有的,所以由子类来实现
beverage->AddCondiments();
模板方法给我们带来了什么
| 不好的实现 | 模板方法的实现 |
|---|---|
Coffee和Tea主导一切,它们控制了算法 | 有CaffeineBeverage类主导一切,它拥有算法,而且保护这个算法 |
Coffee和Tea之间存在重复代码 | 对于子类来说,CaffeineBeverage类的存在,可以将代码的复用最大化 |
| 对于算法所做的代码改变,需要打开子类修改很多地方 | 算法只存在于一个地方,所以很容易修改 |
| 由于类的组织形式不具有弹性,所以加入新种类的咖啡因饮料需要做许多工作 | 模板方法提供了一个框架,可以让其他的咖啡因饮料插进来。新的咖啡因饮料只需实现自己的方法就可以了 |
| 算法的知识和它的实现分散在各个类中 | CaffeineBeverage类专注在算法本身,而由子类提供完整实现 |
定义模板方法模式
模板方法模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
class AbstractClass {
public:
void TemplateMethod() final {
PrimitiveOperation1();
PrimitiveOperation2();
ConcreteOperation();
Hook();
}
private:
virtual void PrimitiveOperation1() = 0;
virtual void PrimitiveOperation2() = 0;
void ConcreteOperation() final {
std::cout << "Concrete Operation" << std::endl;
}
virtual void Hook() {}
};
需要注意以下几点:
- 模板方法被声明为
final,以免子类篡改算法; - 基类中的具体实现也被声明为
final,同样也是为了防止子类覆盖; - 同时也可以声明一个可被覆盖的钩子函数
Hook(),子类可以视情况决定要不要覆盖钩子函数。
模板方法模式包含以下主要角色。
-
抽象类/抽象模板(Abstract Class)
抽象模板类,负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。这些方法的定义如下。
-
模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。
-
基本方法:是整个算法中的一个步骤,包含以下几种类型。
- 抽象方法:在抽象类中声明,由具体子类实现。
- 具体方法:在抽象类中已经实现,在具体子类中可以继承或重写它。
- 钩子方法:在抽象类中已经实现,包括用于判断的逻辑方法和需要子类重写的空方法两种。
-
-
具体子类/具体实现(Concrete Class)
具体实现类,实现抽象类中所定义的抽象方法和钩子方法,它们是一个顶级逻辑的一个组成步骤。类图如下:
对模板方法进行挂钩
钩子是一种声明在抽象类中的方法,但是只有空的或者默认实现。钩子的存在,可以让子类有能力对算法的不同点进行挂钩。要不要挂钩,由子类自行决定。
举个例子,在之前的PrepareRecipe()方法中,我们希望由子类来决定要不要加调料。
class CoffeineBeverage{
public:
void PrepareRecipe() {
BoilWater();
Brew();
PourInCup();
if (CustomerWantsCondiments()) {
AddCondiments();
}
}
private:
virtual void Brew() = 0;
virtual void AddCondiments() = 0;
virtual bool CustomerWantsCondiments() {
return true;
}
};
上述代码中,
- 我们加了一个条件判断语句,而该条件是否成立,是由一个具体方法CustomerWantsCondiments()决定的。如果顾客想要调料,我们在调用AddCondiments();
- CustomerWantsCondiments()就是一个钩子函数,子类可以自由选择是否需要覆盖。
为了使用钩子,我们在子类中覆盖钩子函数。在这个例子中,钩子控制了咖啡因饮料是否需要加咖啡。
class Coffee : public CoffeineBeverage {
private:
void Brew() override {
std::cout << "Brew Coffee Grinds" << std::endl;
}
void AddCondiments() override {
std::cout << "Add Milk and Sugar" << std::endl;
}
bool CustomerWantsCondiments() {
std::string ans = GetInput();
if (std::strcmp(ans, 'yes') == 0) {
return true;
}
else{
return false;
}
}
};
更通用的类图如下:
好莱坞原则
别调用我们,我们会调用你。
好莱坞原则可以给我们一种防止“依赖腐败”的方法。当高层组件依赖底层组件、低层组件又依赖高层组件时,依赖腐败就发生了。这种情况下,没有人可以轻易理解系统是如何设计的。
在好莱坞原则下,我们允许低层组件将自己挂钩到系统上,但是高层组件会决定什么时候以及怎样使用这些底层组件。换句话说,高层组件对待低层组件的方式就是“别调用我们,我们会调用你”。
模式的应用场景
模板方法模式通常适用于以下场景。
- 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
- 当多个子类存在公共的行为时,可以将其提取出来并集中到一个公共父类中以避免代码重复。首先,要识别现有代码中的不同之处,并且将不同之处分离为新的操作。最后,用一个调用这些新的操作的模板方法来替换这些不同的代码。
- 当需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展。
模式的优缺点
该模式的主要优点如下。
- 它封装了不变部分,扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
- 它在父类中提取了公共的部分代码,便于代码复用。
- 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能,符合开闭原则。
该模式的主要缺点如下。
-
对每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。
-
父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度。
-
由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍。