14.2. 模板方法模式详解

模板方法模式是一种常见的实现算法的方式,该算法的整体结构是预先确定的,但其实现的某些细节需要进行定制。如果你正在思考的解决方案大致是这样的 —— 首先,执行 X,然后执行 Y,最后执行 Z,但具体如何执行 Y 取决于处理的数据 —— 那么你所想的正是模板方法模式。

作为一种允许程序行为动态改变的模式,模板方法与策略模式有些相似。关键区别在于,策略模式在运行时改变整个算法,而模板方法则允许定制算法的特定部分。本节讨论后者,而关于前者的详细内容,将在单独的第16章中进行探讨。

14.2.1 C++ 中的模板方法

模板方法模式在面向对象语言中都很容易实现。C++ 的实现使用了继承和虚函数。这与泛型编程中的 C++ 模板没有关系。这里的“模板”指的是算法的框架实现:

// Example 01
class Base {
public:
  bool TheAlgorithm() {
    if (!Step1()) return false; // 步骤 1 失败
    Step2();
    return true;
  }
};

这里的“模板”指的是算法的结构 —— 所有实现都必须首先执行步骤1,该步骤可能会失败。如果发生失败,整个算法即视为失败,后续不再执行操作。如果步骤1成功,则必须执行步骤2。根据设计,步骤2不会失败,当步骤2完成,整个算法的计算即视为成功。

TheAlgorithm() 方法是公有的,但非虚 —— 从 Base 派生的类都将它作为其接口的一部分,但不能重写其行为。派生类可以重写的是步骤1和步骤2的具体实现,但必须遵守算法模板的限制:步骤1可能会失败,并必须通过返回 false 来表示失败,而步骤2则不能失败:

// Example 01
class Base {
public:
  ...
  virtual bool Step1() { return true };
  virtual void Step2() = 0;
};

class Derived1 : public Base {
public:
  void Step2() override { ... 执行工作 ... }
};

class Derived2 : public Base {
public:
  bool Step1() override { ... 检查前置条件 ... }
  void Step2() override { ... 执行工作 ... }
};

在前面的例子中,重写可能失败的步骤1是可选的,默认实现很简单;它什么都不做,也永远不会失败。步骤2则必须由每个派生类实现 —— 没有默认实现,声明为纯虚函数。

你可以看到,整体的控制流程 —— 即框架 —— 保持不变,但它为可定制的选项提供了占位符,框架本身可能还会提供默认实现,这种流程称为“控制反转”。传统的控制流中,具体的实现决定了计算流程和操作顺序,并调用库函数或其他底层函数来实现必要的通用算法。而在模板方法模式中,是框架调用定制代码中的具体实现。

14.2.2 模板方法的应用

使用模板方法有很多原因,它用于控制哪些部分可以子类化,哪些不可以 —— 这与一般的多态重写不同,在一般多态重写中,整个虚函数都可以被替换,基类决定了哪些部分可以重写,哪些不可以。模板方法的另一个常见用途是避免代码重复。在这种情况下,可以通过以下方式得出使用模板方法的结论。假设从常规的多态性开始 —— 一个虚函数及其重写。例如,考虑一个游戏的回合制战斗系统的简单设计:

// Example 02
class Character {
public:
  virtual void CombatTurn() = 0;
protected:
  int health_;
};

class Swordsman : public Character {
bool wielded_sword_;
public:
  void CombatTurn() override {
    if (health_ < 5) { // 生命值过低,濒死状态
      Flee();
      return;
    }
    if (!wielded_sword_) {
      Wield();
      return; // 拔剑需要一整回合
    }
    Attack();
  }
};

class Wizard : public Character {
  int mana_;
  bool scroll_ready_;
public:
  void CombatTurn() override {
    if (health_ < 2 ||
    mana_ == 0) { // 生命值过低或法力耗尽
      Flee();
      return;
    }
    if (!scroll_ready_) {
      ReadScroll();
      return; // 阅读卷轴需要一整回合
    }
    CastSpell();
  }
};

请注意这段代码的高度重复性 —— 所有角色在自己的回合都可能被迫脱离战斗,然后必须花一个回合准备进入战斗,只有在准备就绪且足够强壮的情况下,才能使用其攻击能力。如果发现这种模式反复出现,这强烈暗示着可能需要使用模板方法。使用模板方法后,战斗回合的整体顺序是固定的,但每个角色如何推进到下一步,以及到达后具体做什么,则由各个角色自身决定:

// Example 03
class Character {
public:
  void CombatTurn() {
    if (MustFlee()) {
      Flee();
      return;
    }
    if (!Ready()) {
      GetReady();
      return; // 准备动作需要一整回合
    }
    CombatAction();
  }

  virtual bool MustFlee() const = 0;
  virtual bool Ready() const = 0;
  virtual void GetReady() = 0;
  virtual void CombatAction() = 0;

protected:
  int health_;
};

现在,每个派生类只需实现对该类独有的代码部分:

// Example 03
class Swordsman : public Character {
bool wielded_sword_;
public:
  bool MustFlee() const override { return health_ < 5; }
  bool Ready() const override { return wielded_sword_; }
  void GetReady()override { Wield(); }
  void CombatAction()override { Attack(); }
};

class Wizard : public Character {
int mana_;
bool scroll_ready_;
public:
  bool MustFlee() const override { return health_ < 2 || mana_ == 0; }
  bool Ready() const override { return scroll_ready_; }
  void GetReady() override { ReadScroll(); }
  void CombatAction() override { CastSpell(); }
};

这段代码的重复性大大减少了,但模板方法的优势并不仅仅体现在代码美观上。假设在游戏的下一个版本中,我们添加了治疗药水,并且在每个回合开始时,每个角色都可以饮用一瓶药水。现在,想象一下需要逐一修改每个派生类,添加类似 if (health_ < ... 某个类特定的值 ... && potion_count_ > 0) ... 的代码。如果设计中已经使用了模板方法,那么饮用药水的逻辑只需编写一次,而不同的类只需实现其使用药水的具体条件以及饮用后的后果即可。在阅读完本章之前,不要急于实现这个解决方案,这并不是最佳的 C++ 代码。

14.2.3 前置条件、后置条件与动作

模板方法的另一个常见用途是处理前置条件、后置条件或相关操作。在类型层次结构中,前置条件和后置条件通常用于验证:在执行过程中的时刻,接口所提供的抽象的设计不变式都不会被具体实现所破坏。这种验证机制天然地适用于模板方法的设计:

// Example 04
class Base {
public:
  void VerifiedAction() {
    assert(StateIsValid());
    ActionImpl();
    assert(StateIsValid());
  }
  virtual void ActionImpl() = 0;
};

class Derived : public Base {
public:
  void ActionImpl() override { ... 实际实现 ...}
};

不变式是指对象在对使用端可见时必须满足的条件,即在调用成员函数之前或返回之后。成员函数本身通常需要暂时打破这些不变式,但在将控制权交还给调用者之前,必须恢复类的正确状态。假设前面示例中的类会跟踪已执行的操作数量。每个操作在开始时和完成时都会记录,这两个计数必须相同:当操作启动,就必须在控制权返回给调用者之前完成。当然,在 ActionImpl() 成员函数内部,这个不变式会打破,此时操作正在进行中:

// Example 04
class Base {
  bool StateIsValid() const {
    return actions_started_ == actions_completed_;
  }

protected:
  size_t actions_started_ = 0;
  size_t actions_completed_ = 0;

public:
  void VerifiedAction() {
    assert(StateIsValid());
    ActionImpl();
    assert(StateIsValid());
  }
  virtual void ActionImpl() = 0;
};

class Derived : public Base {
public:
  void ActionImpl() override {
    ++actions_started_;
    ... 执行特定动作 ...
    ++actions_completed_;
  }
};

当然,关于前置和后置条件的实际实现都必须考虑一些因素。首先,某些成员函数可能有额外的不变式,只能在对象处于特定受限状态时才能调用,这类函数需要测试特定的前置条件。其次,尚未考虑操作因错误而中止的可能性(这可能涉及也可能不涉及抛出异常)。一个设计良好的错误处理机制必须保证在发生此类错误后,类的不变式不会被破坏。在我们的例子中,失败的操作可能被完全忽略(需要将已启动操作的计数减一),或者不变式可能需要更复杂一些:所有已启动的操作最终必须以完成或失败告终,因此需要同时统计完成和失败的操作数量:

// Example 05
class Base {
  bool StateIsValid() const {
    return actions_started_ ==
      actions_completed_ + actions_failed_;
  }
protected:
  size_t actions_started_ = 0;
  size_t actions_completed_ = 0;
  size_t actions_failed_ = 0;
  ...
};

class Derived : public Base {
public:
  void ActionImpl() override {
    ++actions_started_;
    try {
      ... p执行特定动作 - 可能会抛出异常 ...
      ++actions_completed_;
    } catch (...) {
      ++actions_failed_;
    }
  }
};

在实际程序中,必须确保失败的事务不仅被正确计数,还要被正确处理(通常需要进行回滚)。最后,在并发程序中,对象在成员函数执行期间不能被观察到这一假设不再成立,类不变式的整个主题变得更为复杂,并与线程安全保证紧密交织。在软件设计中,一个人眼中的不变式可能是另一个人眼中的定制点。有时,主体代码保持不变,但紧接其前和紧接其后的操作则取决于具体的应用场景。这种情况下,可能不会验证不变式,而是执行初始化和最终操作:

// Example 06
class FileWriter {
public:
  void Write(const char* data) {
    Preamble(data);
    ... 将data写入文件 ...
    Postscript(data);
  }
  virtual void Preamble(const char* data) {}
  virtual void Postscript(const char* data) {}
};

class LoggingFileWriter : public FileWriter {
public:
  using FileWriter::FileWriter;
  void Preamble(const char* data) override {
    std::cout << "Writing " << data << " to the file" <<
    std::endl;
  }
  void Postscript (const char*) override {
    std::cout << "Writing done" << std::endl;
  }
}

当然,前置和后置条件与开始和结束操作完全可以结合使用 —— 基类可以在主要实现前后包含多个“标准”成员函数调用。

虽然这段代码能够完成任务,但它仍然存在一些缺陷,我们即将在后续内容中说明这些问题。