模板方法模式

592 阅读7分钟

背景

在面向对象程序设计过程中,程序员常常会遇到这种情况:设计一个系统时知道了算法所需的关键步骤,而且确定了这些步骤的执行顺序,但某些步骤的具体实现还未知,或者说某些步骤的实现与具体的环境相关。

例如,去银行办理业务一般要经过以下4个流程:取号、排队、办理具体业务、对银行工作人员进行评分等,其中取号、排队和对银行工作人员进行评分的业务对每个客户是一样的,可以在父类中实现,但是办理具体业务却因人而异,它可能是存款、取款或者转账等,可以延迟到子类中实现。

这样的例子在生活中还有很多,例如,一个人每天会起床、吃饭、做事、睡觉等,其中“做事”的内容每天可能不同。我们把这些规定了流程或格式的实例定义成模板,允许使用者根据自己的需求去更新它,例如,简历模板、论文模板、Word 中模板文件等。

以下介绍的模板方法模式将解决以上类似的问题。

例子

星巴兹同时售卖咖啡和茶, 咖啡冲泡方法如下:

  1. 把水煮开
  2. 用沸水冲泡咖啡
  3. 把咖啡倒进杯子
  4. 加糖和牛奶

而茶叶冲泡方法如下:

  1. 把水煮开
  2. 用沸水浸泡茶叶
  3. 把茶倒进杯子
  4. 加柠檬

根据上述步骤,可以很容易写出如下代码:

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();
};

更进一步设计

两种饮料的冲泡法都使用了相同的算法

  1. 把水煮开
  2. 用热水泡茶或者泡咖啡
  3. 把饮料倒入杯子
  4. 加入适当的调料
  • 于是我们尝试将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()是一个模板方法,因为:

  1. 它是一个方法
  2. 它用作一个算法的模板,在这个例子中,算法是用来做咖啡因饮料的;

在这个模板中,算法内的每个步骤都被一个方法代表了,某些方法是由这个基类处理的,某些方法则是由子类处理的。需要由子类提供的方法,必须在基类中声明为抽象(虚函数)。

让我们逐步泡茶,追踪这个模板方法是如何工作的。

  1. 首先需要一个茶对象:
    CoffeineBeverage* beverage = new Tea();
    
  2. 然后我们调用模板方法:
    beverage->PrepareRecipe();
    
  3. 模板方法第一步是把水煮沸:
    CoffeineBeverage::BoilWater();
    
    这件事是在基类中进行的。
  4. 接下来,我们需要泡茶,这件事只有子类才知道该怎么做
    beverage->Brew();
    
  5. 现在把茶倒进杯子中,所有的饮料的做法都一样,所以这件事发生在基类中;
    CoffeineBeverage::PourInCup();
    
  6. 最后,我们加进调料,由于调料是各个饮料独有的,所以由子类来实现
    beverage->AddCondiments();
    

模板方法给我们带来了什么

不好的实现模板方法的实现
CoffeeTea主导一切,它们控制了算法CaffeineBeverage类主导一切,它拥有算法,而且保护这个算法
CoffeeTea之间存在重复代码对于子类来说,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() {}
};

需要注意以下几点:

  1. 模板方法被声明为final,以免子类篡改算法;
  2. 基类中的具体实现也被声明为final,同样也是为了防止子类覆盖;
  3. 同时也可以声明一个可被覆盖的钩子函数Hook(),子类可以视情况决定要不要覆盖钩子函数。

模板方法模式包含以下主要角色。

  • 抽象类/抽象模板(Abstract Class)

    抽象模板类,负责给出一个算法的轮廓和骨架。它由一个模板方法和若干个基本方法构成。这些方法的定义如下。

    1. 模板方法:定义了算法的骨架,按某种顺序调用其包含的基本方法。

    2. 基本方法:是整个算法中的一个步骤,包含以下几种类型。

      • 抽象方法:在抽象类中声明,由具体子类实现。
      • 具体方法:在抽象类中已经实现,在具体子类中可以继承或重写它。
      • 钩子方法:在抽象类中已经实现,包括用于判断的逻辑方法和需要子类重写的空方法两种。
  • 具体子类/具体实现(Concrete Class)

    具体实现类,实现抽象类中所定义的抽象方法和钩子方法,它们是一个顶级逻辑的一个组成步骤。类图如下:

    image.png

对模板方法进行挂钩

钩子是一种声明在抽象类中的方法,但是只有空的或者默认实现。钩子的存在,可以让子类有能力对算法的不同点进行挂钩。要不要挂钩,由子类自行决定。

举个例子,在之前的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;
        }
    };

上述代码中,

  1. 我们加了一个条件判断语句,而该条件是否成立,是由一个具体方法CustomerWantsCondiments()决定的。如果顾客想要调料,我们在调用AddCondiments();
  2. 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;
            }
        }
    };

更通用的类图如下:

image.png

好莱坞原则

别调用我们,我们会调用你。

好莱坞原则可以给我们一种防止“依赖腐败”的方法。当高层组件依赖底层组件、低层组件又依赖高层组件时,依赖腐败就发生了。这种情况下,没有人可以轻易理解系统是如何设计的。

在好莱坞原则下,我们允许低层组件将自己挂钩到系统上,但是高层组件会决定什么时候以及怎样使用这些底层组件。换句话说,高层组件对待低层组件的方式就是“别调用我们,我们会调用你”。

Screenshot from 2021-04-09 08-52-09.png

Screenshot from 2021-04-09 08-51-31.png

模式的应用场景

模板方法模式通常适用于以下场景。

  1. 算法的整体步骤很固定,但其中个别部分易变时,这时候可以使用模板方法模式,将容易变的部分抽象出来,供子类实现。
  2. 当多个子类存在公共的行为时,可以将其提取出来并集中到一个公共父类中以避免代码重复。首先,要识别现有代码中的不同之处,并且将不同之处分离为新的操作。最后,用一个调用这些新的操作的模板方法来替换这些不同的代码。
  3. 当需要控制子类的扩展时,模板方法只在特定点调用钩子操作,这样就只允许在这些点进行扩展。

模式的优缺点

该模式的主要优点如下。

  1. 它封装了不变部分,扩展可变部分。它把认为是不变部分的算法封装到父类中实现,而把可变部分算法由子类继承实现,便于子类继续扩展。
  2. 它在父类中提取了公共的部分代码,便于代码复用。
  3. 部分方法是由子类实现的,因此子类可以通过扩展方式增加相应的功能,符合开闭原则。

该模式的主要缺点如下。

  1. 对每个不同的实现都需要定义一个子类,这会导致类的个数增加,系统更加庞大,设计也更加抽象,间接地增加了系统实现的复杂度。

  2. 父类中的抽象方法由子类实现,子类执行的结果会影响父类的结果,这导致一种反向的控制结构,它提高了代码阅读的难度。

  3. 由于继承关系自身的缺点,如果父类添加新的抽象方法,则所有子类都要改一遍。