13.4. C++中类工厂模式的变体

C++ 中基本的工厂模式有许多变体,用于应对特定的设计需求和约束。在本节中,我们将探讨其中的几种。这绝不是 C++ 中工厂类模式的详尽列表,但理解这些变体将帮助读者掌握如何结合本书中学到的技术,以应对与对象工厂相关的各种设计挑战。

13.4.1 多态复制

目前,我们讨论的工厂模式都是作为对象构造函数的替代方案 —— 无论是默认构造函数还是带参数的构造函数,但类似的模式也可以应用于复制构造函数 —— 我们有一个对象,想要创建它的副本。

这在许多方面是一个类似的问题 —— 可以通过基类指针访问一个对象,并希望调用它的复制构造函数。正如之前讨论过的,原因之一是编译器需要知道分配多少内存,实际的构造函数调用必须在静态确定的类型上进行。然而,引导我们调用特定构造函数的控制流,可以在运行时确定,这再次需要应用工厂模式。

我们将用于实现多态复制的工厂方法与上一节中的单位工厂示例有些相似 —— 实际的构造必须由每个派生类完成,而派生类知道要构造的对象类型。基类实现控制流,规定要构造某个对象的副本,而派生类则定制构造部分:

// Example 16
class Base {
public:
  virtual Base* clone() const = 0;
};

class Derived : public Base {
public:
  Derived* clone() const override {
    return new Derived(*this);
  }
};

Base* b0 = ... 从某处获取对象 ...
Base* b1 = b->clone();

可以使用 typeid 操作符(可能结合本章前面使用过的名称还原函数),来验证指针 b1 确实指向一个 Derived 对象。

我们刚刚通过继承实现了多态复制。在第6章中,看到了另一种复制对象的方法,这些对象的类型在构造时是已知的,但之后丢失(或擦除)了。这两种方法在本质上并没有根本区别:在实现类型擦除复制时,自己构建了一个虚表。而在本章中,让编译器来完成了这项工作。在特定情况下选择哪种实现方式,主要取决于其周围代码的其他需求。

我们再次使用了协变返回类型,因此受限于只能使用原始指针。假设想改用返回 unique_ptr,由于只有指向基类和派生类的原始指针才会视为协变类型,因此必须始终返回指向基类的 unique_ptr:

class Base {
public:
  virtual std::unique_ptr<Base> clone() const = 0;
};

class Derived : public Base {
public:
  std::unique_ptr<Base> clone() const override {
    return std::unique_ptr<Base>(new Derived(*this));
  }
};

std::unique_ptr<Base> b(... make an object ...);
std::unique_ptr<Base> b1 = b->clone();

许多情况下,这并不是一个严重的限制。然而,有时它可能导致不必要的转换和类型转换。如果返回精确类型的智能指针非常重要,接下来将考虑这种模式的另一种实现方式。

13.4.2 CRTP 工厂与返回类型

唯一能让派生类的工厂复制构造函数返回 std::unique_ptr<Derived> 的方法,是让基类的虚 clone() 方法也返回相同的类型。但这几乎是不可能的,至少在有多个派生类的情况下是如此 —— 对于每个派生类,都希望 Base::clone() 的返回类型是该派生类,但 Base::clone() 只能有一个!

是这样吗?在 C++ 中,我们有一种简单的方法可以“一变多” —— 模板。如果对基类进行模板化,就可以让每个派生类的基类部分返回正确的类型。但要做到,需要基类以某种方式知道将要从它派生出的类的类型。当然,这也是一种已有模式 —— 在 C++ 中称为“奇异递归模板模式”(CRTP),第8章中已经探讨过。现在,我们可以将 CRTP 与工厂模式结合起来:

// Example 18
template <typename Derived> class Base {
public:
  virtual std::unique_ptr<Derived> clone() const = 0;
};

class Derived : public Base<Derived> {
public:
  std::unique_ptr<Derived> clone() const override {
    return std::unique_ptr<Derived>(new Derived(*this));
  }
};

std::unique_ptr<Derived> b0(new Derived);
std::unique_ptr<Derived> b1 = b0->clone();

自动返回类型使得编写此类代码显著地更加简洁。在本书中,我们通常不使用,以便清楚地表明哪个函数返回什么类型。

基类 Base 的模板参数是继承自它自身的类之一,因此采用了这样的命名。可以使用静态断言来强制执行此限制:

template <typename Derived> class Base {
public:
  virtual std::unique_ptr<Derived> clone() const = 0;
  Base() {
    static_assert(std::is_base_of_v<Base, Derived>;
  }
};

我们必须将静态断言隐藏在类的构造函数中(或在成员函数内部),因为在其类声明本身内部,类型 Derived 不完整。

由于基类 Base 现在已经知道了派生类的类型,甚至不再需要 clone() 方法为虚了:

// Example 19
template <typename Derived> class Base {
public:
  std::unique_ptr<Derived> clone() const {
    return std::unique_ptr<Derived>(
      new Derived(*static_cast<const Derived*>(this)));
  }
};

class Derived : public Base<Derived> { ... };

我们实现的这种方法存在显著的缺点。首先,必须将基类变为模板,在通用代码中不再拥有一个统一的指针类型(或者广泛地使用模板)。其次,如果从 Derived 类进一步派生出更多子类,这种方法将失效,基类的模板无法追踪第二层派生 —— 仅知道直接实例化 Base 模板的那个派生类。总体而言,除非在某些特定场景下,返回精确类型比返回基类类型更为重要,否则不推荐使用此方法。

另一方面,这种实现方式也有一些吸引人的特性值得保留。特别是,消除了每个派生类都需要重复编写 clone() 函数的问题,转而利用模板自动生成这些函数。在下一节中,将展示如何保留 CRTP 实现中的这一有用特性,即使需要放弃通过模板技巧,将协变返回类型的概念扩展到智能指针上。

13.4.3 CRTP 实现工厂

虽然 CRTP 有时用作一种设计工具,但它同样很可能用作一种实现技术。现在,我们将重点放在使用 CRTP 来避免在每个派生类中手动编写 clone() 函数。这不仅仅是为了减少代码输入量 —— 代码写得越多,尤其是大量相似、通过复制和修改得到的代码,出错的可能性就越高。我们已经了解了如何使用 CRTP 自动为每个派生类生成 clone() 的版本。但我们并不想为此放弃一个通用的(非模板的)基类。实际上,只要将克隆功能委托给一个专门处理此任务的特殊基类即可:

// Example 20
class Base {
public:
  virtual Base* clone() const = 0;
};

template <typename Derived> class Cloner : public Base {
public:
  Base* clone() const {
    return new Derived(*static_cast<const Derived*>(this));
  }
};

class Derived : public Cloner<Derived> {
...
};

Base* b0(new Derived);
Base* b1 = b0->clone();

这里为了简单起见,又回到了返回原始指针的方式,也可以返回 std::unique_ptr<Base>。但不能返回 Derived*,因为在解析 Cloner 模板时,还不知道 Derived 总是继承自 Base。这种设计允许通过 Cloner 间接地从 Base 派生任意数量的类,并且再也不需要编写另一个 clone() 函数。不过,仍然存在一个限制:如果从 Derived 再派生出另一个类,将无法正确复制。在许多设计中,这并不是一个问题 —— 应当避免深层次的继承关系,并使所有类都属于两种类型之一:从不实例化的抽象基类,以及从这些基类之一派生的具体类,但绝不从另一个具体类派生。

13.4.4 工厂与构建器

目前为止,我们主要使用的是工厂函数,或者更广义地说,像 Lambda 表达式这样的函子。但在实际应用中,也同样可能需要一个工厂类。这种情况通常发生于构造对象所需的运行时,信息比仅仅一个类型标识符和几个参数要复杂得多的时候。这些原因也正是我们可能选择使用构建器模式来创建对象的理由,因此工厂类也可以看作是一个带有工厂方法的构建器类,用于创建具体的对象。本章前面提到的 Unit 工厂就是这种模式的一个例子:Building 类及其所有派生类都充当了 unit 对象的构建器(而且这些 building 对象本身是由另一个工厂创建的事实,进一步说明了即使是简单的代码,也很少能归结为单一的设计模式)。然而,在那种情况下,使用工厂类还有一个特殊的原因:每个派生的 building 类都会自行构造其对应的 unit 对象。

现在,让我们考虑一个更常见的使用工厂类的场景:决定构造哪个类以及如何构造的运行时数据整体较为复杂,并且需要编写相当多的非简单代码。虽然使用一个工厂函数和一些全局对象可以来处理所有这些,但这将是一个糟糕的设计,缺乏内聚性和封装性。这样的设计容易出错且难以维护。更好的做法是,将所有相关的代码和数据封装到一个类或少数几个相关的类中。

例子中,我们将解决一个非常常见(并且仍然极具挑战性)的序列化/反序列化问题。在我们的场景中,有多个对象都派生自同一个基类。希望实现一个框架,能够通过将这些对象写入文件来序列化它们,然后从该文件中恢复(反序列化)这些对象。在本章的最后一个示例中,将结合之前学到的多种方法来设计和实现这个工厂。

首先,从基类开始。该基类将使用我们之前学过的动态类型注册机制,它还将声明一个纯虚的 Serialize() 函数,每个派生类都需要实现该函数,以将自身序列化到文件中:

// Example 21
class SerializerBase {
  static size_t type_count;
  using Factory = SerializerBase* (*)(std::istream& s);
  static std::vector<Factory> registry;

protected:
  virtual void Serialize(std::ostream& s) const = 0;

public:
  virtual ~SerializerBase() {}
  static size_t RegisterType(Factory factory) {
    registry.push_back(factory);
    return type_count++;
  }
  static auto Deserialize(size_t type, std::istream& s) {
    Factory factory = registry[type];
    return std::unique_ptr<SerializerBase>(factory(s));
  }
};

std::vector<SerializerBase::Factory> SerializerBase::registry;
size_t SerializerBase::type_count = 0;

派生类都需要实现 Serialize() 函数,并注册反序列化函数:

// Example 21
class Derived1 : public SerializerBase {
  int i_;
public:
  Derived1(int i) : i_(i) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << i_ << std::endl;
  }
  static const size_t type_tag;
};

const size_t Derived1::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
    int i; s >> i; return new Derived1(i); });

只有派生类自身才掌握其状态信息、为重建对象需要保存哪些内容以及如何进行保存。在我们的示例中,序列化操作始终在 Serialize() 函数中完成,而反序列化则在注册到类型注册表的 Lambda 表达式中完成,这两者必须相互一致。存在一些基于模板的技巧可以确保这种一致性,但这些技巧与我们现在研究的工厂构建无关。

序列化的部分已经处理好了 —— 只需在拥有的对象上调用 Serialize() 即可:

std::ostream S ... – 根据需要构建流
Derived1 d(42);
d.Serialize(S);

反序列化本身并不特别困难(大部分工作由派生类完成),但其中仍包含足够的样板代码,足以证明使用工厂类的合理性。一个工厂对象将读取整个文件,并反序列化(即重新创建)其中记录的所有对象。当然,对于这些对象的存放位置有多种选择方案。由于我们正在构造的这些对象类型在编译时是未知的,因此必须通过基类指针来访问它们。例如,可以将它们存储在一个 unique 指针容器中:

// Example 21
class DeserializerFactory {
  std::istream& s_;
public:
  explicit DeserializerFactory(std::istream& s) : s_(s) {}
  template <typename It>
  void Deserialize(It iter) {
    while (true) {
      size_t type;
      s_ >> type;
      if (s_.eof()) return;
      iter = SerializerBase::Deserialize(type, s_);
    }
  }
};

该工厂逐行读取整个文件。首先,仅读取类型标识符(每个对象在序列化时都必须写入该标识符)。根据该标识符,将剩余的反序列化过程分派给为相应类型注册的正确函数。工厂使用插入迭代器(例如:后插迭代器)将所有反序列化的对象存储到容器中:

// Example 21
std::vector<std::unique_ptr<SerializerBase>> v;
DeserializerFactory F(S);
F.Deserialize(std::back_inserter(v));

通过这种方法,可以处理从 SerializerBase 派生的类,只要能够找到将其写入文件并恢复它的方式,就可以处理更复杂的状态,以及具有多个参数的构造函数:

// Example 21
class Derived2 : public SerializerBase {
  double x_, y_;
public:
  Derived2(double x, double y) : x_(x), y_(y) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << x_ << " " << y_ << std::endl;
  }
  static const size_t type_tag;
};

const size_t Derived2::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
    double x, y; s >> x >> y;
    return new Derived2(x, y);
});

只要知道特定对象应该如何再次构造,同样可以轻松地处理具有多个构造函数的类:

// Example 21
class Derived3 : public SerializerBase {
  bool integer_;
  int i_ {};
  double x_ {};
public:
  Derived3(int i) : integer_(true), i_(i) {...}
  Derived3(double x) : integer_(false), x_(x) {...}
  void Serialize(std::ostream& s) const override {
    s << type_tag << " " << integer_ << " ";
    if (integer_) s << i_; else s << x_;
    s << std::endl;
  }
  static const size_t type_tag;
};

const size_t Derived3::type_tag =
  RegisterType([](std::istream& s)->SerializerBase* {
  bool integer; s >> integer;
  if (integer) {
    int i; s >> i; return new Derived3(i);
  } else {
    double x; s >> x; return new Derived3(x);
  }
});

C++ 中的工厂模式还有许多其他变体。如果理解了本章的解释并跟随示例进行了实践,那么这些替代方案对说应该都很容易理解。