9.5. 构建器模式实践

重新审视一下我们向 C++ 函数传递命名参数的方式,没有使用带有多个参数的构造函数,而是选择使用一个选项对象,其中每个参数都会明确命名:

City GreensDale(City::Options()
  .SetCenter(City::KEEP)
  .SetBuildings(3)
  .SetGuard(1)
  .SetForge()
);

现在,将注意力集中在 Options 对象本身上,特别是它的构造方式。这里的构造函数并没有直接创建一个完整的对象(否则只是把问题从 City 构造函数转移到了 Options 构造函数)。相反,可以逐块地构建这个对象。这是非常通用的设计模式 —— 构建器模式的一个具体实例。

9.5.1 构建器模式简介

构建器设计模式在决定一个对象无法仅通过构造函数,就进入“完整状态”时使用。相反,我们会编写一个辅助类,或称构建器类,来构建这些对象并将其提供给程序使用。

有读者可能会问的第一个问题是:“为什么?” —— 构造函数不就是应该做这件事吗?原因可能有多种。一个非常常见的原因是,我们使用一个更通用的对象来表示某些更具体的数据集。例如,想要一个对象来存储斐波那契数列或质数,而决定使用 std::vector 来存储。现在问题来了:std::vector 只有 STL 提供的那些构造函数,但需要确保 vector 包含正确的数值,而无法为 std::vector 编写新的构造函数。可以创建一个专门用于存储质数的特殊类,但这会导致最终拥有很多类,这些类在构造方式上各不相同,但在使用方式上却非常相似。当需要用 vector 来处理质数、奇数、平方数等时,就需要为每种数编写新的处理代码,而如果对所有情况都使用 vector,那将完全足够。另一种选择是到处都使用 vector,并在程序需要时将正确的值写入其中。这也不好:暴露并重复了大量的底层代码,而这些代码本应封装和重用(这正是我们希望为每种数列编写构造函数的原因)。

解决方案就是构建器模式:计算和存储数值的代码被封装在一个构建器类中,但构建器创建的对象是一个通用的 vector。例如,以下是一个用于生成斐波那契数列的构建器类(斐波那契数列以 1, 1 开始,之后的每个数都是前两个数的和):

// Example 08
class FibonacciBuilder {
  using V = std::vector<unsigned long>;
  V cache_ { 1, 1, 2, 3, 5 };
public:
  V operator()(size_t n) {
    while (cache_.size() < n) { // 缓存新数
      cache_.push_back(cache_[cache_.size() - 1] +
      cache_[cache_.size() - 2]);
    }
    return V{cache_.begin(), cache_.begin() + n};
  }
};

假设程序需要为某个算法获取前 n 个斐波那契数的序列(n 的值在运行时变化),可能需要多次获取这些数,有时需要比之前更大的 n,有时则需要更小的 n。所需要做的就是向构建器提请求:

FibonacciBuilder b;
auto fib10 = b(10);

可以将已知的数值在程序的某个地方缓存起来,但这会增加程序的复杂性,并且需要追踪这些值。更好的做法是将这项工作转移到,一个专门用于构造斐波那契数列,且不承担其他职责的类中 —— 也就是一个构建器。缓存斐波那契数列值得吗?可能并不太值得,但这只是一个简洁的示例:如果需要的是质数,重用已知的数值就非常有价值了(代码会更长)。

使用构建器的另一个常见原因是,构建一个对象所需的代码对于构造函数来说可能过于复杂。这种情况通常表现为:如果试图编写一个构造函数,就需要传递大量的参数。本节开头我们为 City 构建 Options 参数的方式就是一个简单的例子,其中 Options 对象本身充当了自己的构建器。构建器模式特别有用的情况包括:构建过程是条件性的,并且完全构建一个对象所需的数据在数量和类型上都依赖于某些运行时变量而变化。同样,我们的 City 例子就是一个简单的实例:没有一个 City 需要所有的构造参数,但如果没有 Options 及其(简单的)构建器,就需要为每一个参数都提供一个参数。

我们为斐波那契数列构建器所采用的方法是,C++ 中构建器模式的一种常见变体;它并不十分引人注目,但确实有效。在本章中,将看到一些实现构建器的替代方法。第一种方法推广了我们构建 Options 的方式。

9.5.2 流式构建器

我们构建 Options 对象的方式是通过方法链。每个方法都朝着构建最终对象迈出一小步。这种方法有一个通用的名称:流式接口。尽管它不仅限于设计构建器,但流式接口在 C++ 中之所以流行,主要是作为一种构建复杂对象的方式。

流式构建器依赖于方法链:构建器类的每个成员函数都为正在构建的对象做出贡献,并返回构建器自身,以便工作可以继续进行。

例如,以下是一个 Employee 类(可能用于某个工作场所的数据库):

// Example 09
class Employee {
  std::string prefix_;
  std::string first_name_;
  std::string middle_name_;
  std::string last_name_;
  std::string suffix_;
  friend class EmployeeBuilder;
  Employee() = default;
public:
  friend std::ostream& operator<<(std::ostream& out,
                                  const Employee& e);
};

我们稍后还会向这个类添加更多数据,但已经拥有足够多的数据成员,使得使用单个构造函数变得困难(太多同类型的参数)。可以使用一个带有 Options 对象的构造函数,但更重要的是,预计在构建对象的过程中需要进行一些计算:例如可能需要验证某些数据,员工记录的其他部分也可能是条件性的 —— 比如两个字段不能同时设置,某个字段的默认值依赖于其他字段等。因此,开始为这个类设计一个构建器。

EmployeeBuilder 需要首先创建一个 Employee 对象,然后提供多个链式调用的方法来设置该对象的不同字段,最后将构建完成的对象交付出去。这其中可能涉及一些错误检查,或影响多个字段的更复杂操作,但一个基本的构建器看起来像这样:

// Example 09
class EmployeeBuilder {
  Employee e_;
public:
  EmployeeBuilder& SetPrefix(std::string_view s) {
    e_.prefix_ = s; return *this;
  }

  EmployeeBuilder& SetFirstName(std::string_view s) {
    e_.first_name_ = s ; return *this;
  }

  EmployeeBuilder& SetMiddleName(std::string_view s) {
    e_.middle_name_ = s; return *this;
  }

  EmployeeBuilder& SetLastName(std::string_view s) {
    e_.last_name_ = s; return *this;
  }

  EmployeeBuilder& SetSuffix(std::string_view s) {
    e_.suffix_ = s; return *this;
  }

  operator Employee() {
    assert(!e_.first_name_.empty() &&
    !e_.last_name_.empty());
    return std::move(e_);
  }
};

在整个设计过程中,必须做出几个关键决策。

第一, 将 Employee::Employee() 构造函数设为私有,只有像 EmployeeBuilder 这样的友元类才能创建 Employee 对象。这一设计确保了程序中不会出现部分初始化或无效的 Employee 对象:获取 Employee 实例的唯一途径是通过构建器。这通常是更安全的选择。但有时也需要能够默认构造对象(例如,为了在容器中使用,或用于许多序列化/反序列化的实现),这时私有构造函数就会带来限制。

第二, 构建器在构建完成前一直持有正在构建的对象(emp)。这是常见的做法,但必须小心,确保每个构建器对象只使用一次。如果重复使用,可能会导致构建出状态不一致的对象。当然,也可以提供一种方式来重新初始化构建器(例如添加 reset() 方法)。当构建器需要执行大量计算,而这些计算结果可以用于构建多个对象时,这种可重用的构建器就非常有价值。

第三, 为了构造一个 Employee 对象,需要先构造一个构建器对象:

Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
;

另一种常见的做法是提供一个静态函数 Employee::create() 来构造一个构建器,构建器自身的构造函数设为私有,并通过友元机制允许特定类或函数访问。

正如在“类型层次结构中的方法链”一节中提到的,链式调用的方法并不一定都返回同一类的引用。如果 Employee 对象具有内部结构,例如分别用于家庭地址、工作地点等的子记录,也可以采用更结构化的构建器设计。

这里的目标是设计一个接口,使得使用端代码看起来像这样:

// Example 09
Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
  .Job()
    .SetTitle("Safety Inspector")
    .SetOffice("Sector 7G")
  .Address()
    .SetHouse("742")
    .SetStreet("Evergreen Terrace")
    .SetCity("Springfield")
  .Awards()
    .Add("Remorseless Eating Machine")
;

为了实现,需要实现一个具有共同基类的构建器层次结构:

// Example 09
class JobBuilder;
class AwardBuilder;
class AbstractBuilder {
protected:
  Employee& e_;
public:
  explicit AbstractBuilder(Employee& e) : e_(e) {}
  operator Employee() {
    assert(!e_.first_name_.empty() &&
      !e_.last_name_.empty());
    return std::move(e_);
  }
  JobBuilder Job();
  AddressBuilder Address();
  AwardBuilder Awards();
};

我们仍然从 EmployeeBuilder 开始,它负责构造 Employee 对象;其余的构建器都持有对该主构建器的引用,并通过在 AbstractBuilder 上调用相应的成员函数,可以为同一个 Employee 对象切换到不同类型的构建器。尽管 AbstractBuilder 作为所有其他构建器的基类,但它没有纯虚函数(或其他虚函数):正如我们之前所看到的,运行时多态在方法链中并不是特别有用:

class EmployeeBuilder : public AbstractBuilder {
  Employee employee_;
public:
  EmployeeBuilder() : AbstractBuilder(employee_) {}
  EmployeeBuilder& SetPrefix(std::string_view s){
    e_.prefix_ = s; return *this;
  }
  ...
};

为了添加工作信息,我们切换到 JobBuilder:

// Example 09
class JobBuilder : public AbstractBuilder {
public:
  explicit JobBuilder(Employee& e) : AbstractBuilder(e) {}
  JobBuilder& SetTitle(std::string_view s) {
    e_.title_ = s; return *this;
  }
  ...
  JobBuilder& SetManager(std::string_view s) {
    e_.manager_ = s; return *this;
  }

  JobBuilder& SetManager(const Employee& manager) {
    e_.manager_ = manager.first_name_ + " " +
    manager.last_name_;
    return *this;
  }

  JobBuilder& CopyFrom(const Employee& other) {
    e_.manager_ = other.manager_;
    ...
    return *this;
  }
};

JobBuilder AbstractBuilder::Job() {
  return JobBuilder(e_);
}

当获得了 JobBuilder,其所有链式方法都会返回对自身的引用;当然,JobBuilder 也是一个 AbstractBuilder,因此可以随时切换到其他类型的构建器,例如 AddressBuilder。可以通过 JobBuilder 的前向声明来声明 AbstractBuilder::job() 方法,但其具体实现必须延迟到 JobBuilder 类型本身定义之后。

这个例子中,也了解了构建器模式的灵活性,这种灵活性如果仅使用构造函数将很难实现。例如,定义员工经理的方式有两种:可以提供经理的姓名,或者使用另一个员工记录的引用来设置。此外,可以从另一个员工的记录中复制工作场所信息,然后仍使用 set 方法修改那些不同的字段。

其他构建器(如 AddressBuilder)的实现方式可能与此类似。但也可能存在不同的构建器。例如,一名员工可能拥有任意数量的奖项:

// Example 09
class Employee {
  ... name, job, address, etc ...
  std::vector<std::string> awards_;
};

相应的构建器需要反映它,为对象添加信息的特性:

// Example 09
class AwardBuilder : public AbstractBuilder {
public:
  explicit AwardBuilder(Employee& e) : AbstractBuilder(e)
  {}
  AwardBuilder& Add(std::string_view award) {
    e_.awards_.emplace_back(award); return *this;
  }
};

AwardBuilder AbstractBuilder::Awards() {
  return AwardBuilder(e_);
}

我们可以根据需要多次调用 AwardsBuilder::addAward() 来为特定的 Employee 对象构建完整的奖项列表。

以下是构建器在实际使用中的示例。对于不同的员工,我们可以使用不同的方式来提供所需的信息:

Employee Barry = EmployeeBuilder()
  .SetFirstName("Barnabas")
  .SetLastName("Mackleberry")
;

我们可以使用一个员工记录来为另一个员工添加经理姓名:

Employee Homer = EmployeeBuilder()
  .SetFirstName("Homer")
  .SetMiddleName("J")
  .SetLastName("Simpson")
  .Job()
    .SetTitle("Safety Inspector")
    .SetOffice("Sector 7G")
    .SetManager(Barry) // Writes "Barnabas Mackleberry"
  .Address()
    .SetHouse("742")
    .SetStreet("Evergreen Terrace")
    .SetCity("Springfield")
  .Awards()
    .Add("Remorseless Eating Machine")
;

可以在员工之间复制雇佣记录:

Employee Lenny = EmployeeBuilder()
  .SetFirstName("Lenford")
  .SetLastName("Leonard")
  .Job()
    .CopyFrom(Homer)
;

某些字段(例如名字和姓氏)是可选的,构建器会在记录完成前检查其完整性,然后才允许访问(参见上面的 AbstractBuilder::operator Employee())。其他字段,例如姓名后缀,则是可选的:

Employee Smithers = EmployeeBuilder()
  .SetFirstName("Waylon")
  .SetLastName("Smithers")
  .SetSuffix("Jr") // 只有当需要的时候才用
;

流式的构建器是一种强大的 C++ 模式,适用于构造具有多个组件的复杂对象,特别是当对象的部分内容是可选的时候。然而,对于包含大量高度结构化数据的对象,这种模式可能会变得相当冗长,所以也存在其他替代方案。

9.5.3 隐式构建器

我们已经见过一种情况,其中构建器模式在没有专门的构建器对象的情况下使用:所有用于传递命名参数的 Options 对象都充当了自己的构建器。我们还将看到另一种版本,因为这里根本没有显式的构建器对象。这种设计特别适合构建嵌套的层次化数据,例如 XML 文件。我们将通过演示其在构建(高度简化)HTML 写入器中的应用来说明。

在这种设计中,HTML 元素将由对应的类来表示:一个类用于 <p> 标签,另一个用于 <ul> 标签等。所有这些类都继承自一个共同的基类 HTMLElement:

class HTMLElement {
public:
  const std::string name_;
  const std::string text_;
  const std::vector<HTMLElement> children_;
  HTMLElement(std::string_view name, std::string_view text)
    : name_(name), text_(text) {}
  HTMLElement(std::string_view name, std::string_view text,
              std::vector<HTMLElement>&& children)
    : name_(name), text_(text),
      children_(std::move(children)) {}
  friend std::ostream& operator<<(std::ostream& out,
    const HTMLElement& element);
};

当然,一个HTML元素包含的内容还有很多,但必须保持简单。此外,基础元素允许无限嵌套:元素都可以拥有一个子元素的数组,每个子元素又可以拥有自己的子元素,以此类推。因此,打印一个元素是递归进行的:

std::ostream& operator<<(std::ostream& out,
                         const HTMLElement& element) {
  out << "<" << element.name_ << ">\n";
  if (!element.text_.empty())
    out << " " << element.text_ << "\n";
  for (const auto& e : element.children_) out << e;
  out << "</" << element.name_ << ">" << std::endl;
  return out;
}

为了添加子元素,必须提供一个 std::vector,然后移动到 HTMLElement 对象中。右值引用参数将是一个临时值或 std::move 的结果。然而,不会自己手动将子元素添加到vector中:这是由特定元素(如 <P>、<UL> 等)的派生类来完成的任务。具体的元素类可以在语法不允许的情况下,阻止添加子元素,并对HTML元素的其他字段施加限制。

那么这些具体的类是什么样子的呢?较简单的类看起来像这样:

class HTML : public HTMLElement {
public:
  HTML() : HTMLElement("html", "") {}
  HTML(std::initializer_list<HTMLElement> children) :
    HTMLElement("html", "", children) {};
};

这个 HTML 类表示 <html> 标签。像 Body、Head、UL、OL 以及更多类似的类,都是以完全相同的方式编写的。<P> 标签的类 P 也类似,只是它不允许嵌套对象,只有一个接受文本参数的构造函数。非常重要的一点是,这些类不能添加数据成员;必须仅初始化基类 HTMLElement 对象,不做其他事情。如果再次查看基类,原因就显而易见了:存储的是一个 HTMLElement 子对象的vector。然而,无论这些对象是如何构造的 —— 作为 HTML、UL 或其他类型 —— 现在都仅仅是 HTMLElement 对象,其他的数据都将丢失。

可能还注意到,HTMLElement 构造函数的 vector 参数是从一个 std::initializer_list 参数初始化的。这种转换是由编译器隐式完成的,即从构造函数参数列表中进行转换:

// Example 10
auto doc = HTML{
  Head{
    Title{"Mary Had a Little Lamb"}
  },
  Body{
    P{"Mary Had a Little Lamb"},
    OL{
      LI{"Its fleece was white as snow"},
      LI{"And everywhere that Mary went"},
      LI{"The lamb was sure to go"}
    }
  }
};

该语句首先调用构造一个 HTML 对象,使用了两个参数。它们是 Head 和 Body 对象,但编译器会将其转换为 HTMLElement,并放入 std::initializer_list<HTMLElement> 中。然后该列表用来初始化一个 vector,再移动到 HTML 对象的 children_ 数组中。Head 和 Body 对象本身都有子对象,其中一个(OL)还有它自己的子对象。

需要注意的是,如果想添加其他构造函数参数,情况会变得有些棘手,不能将普通参数与初始化列表元素混合使用。例如,在实现 LI 类时就会遇到这个问题。根据目前所学的内容,实现这个类最直接的方法如下所示:

//Example 10
class LI : public HTMLElement {
public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  LI(std::string_view text,
      std::initializer_list<HTMLElement> children) :
    HTMLElement("li", text, children) {}
};

不幸的是,不能像这样调用这个构造函数:

//Example 10
LI{"A",
  UL{
    LI{"B"},
    LI{"C"}
  }
}

第一个参数应该是 "A",第二个参数(以及如果有更多的参数)应该进入初始化列表。但这样是行不通的:通常情况下,要形成一个初始化列表,必须用花括号 {...} 将元素序列括起来。只有当整个参数列表匹配一个初始化列表时,这些花括号才可以省略。当某些参数不属于初始化列表时,就必须明确地写出花括号:

//Example 10
LI{"A",
  {UL{ // Notice { before UL!
    LI{"B"},
    LI{"C"}
  }} // And closing } here
}

如果不想写花括号,就必须稍微修改一下构造函数:

//Example 11
class LI : public HTMLElement {
public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  template <typename ... Children>
  LI(std::string_view text, const Children&... children) :
    HTMLElement("li", text,
          std::initializer_list<HTMLElement>{children...})
  {}
};

我们不再使用初始化列表参数,而是使用一个参数包,并显式地将其转换为初始化列表(然后该列表再转换为数组)。当然,这个参数包可以接受任意数量、任意类型的参数,而不仅仅是 HTMLElement 及其派生类。但如果类型不匹配,转换为初始化列表时就会失败。如果想遵循这样的编程实践:即未因实例化失败而被剔除的模板,其函数体内部不应产生编译错误,那么就必须将参数包中的类型限制为 HTMLElement 的派生类。这可以通过 C++20 的概念轻松实现:

// Example 12
class LI : public HTMLElement {
public:
  explicit LI(std::string_view text) :
    HTMLElement("li", text) {}
  LI(std::string_view text,
    const std::derived_from<HTMLElement>
    auto& ... children) :
  HTMLElement("li", text,
        std::initializer_list<HTMLElement>{children...})
  {}
};

如果不使用 C++20 但仍希望限制参数类型,应该回顾一下本书的第7章。

通过使用参数包作为构造初始化列表的中间手段,可以避免多余的花括号,从而像这样编写 HTML 文档:

// Examples 11, 12
auto doc = HTML{
  Head{
    Title{"Mary Had a Little Lamb"}
  },
  Body{
    P{"Mary Had a Little Lamb"},
    OL{
      LI{"Its fleece was white as snow"},
      LI{"And everywhere that Mary went"},
      LI{"The lamb was sure to go"}
    },
    UL{
      LI{"It followed her to school one day"},
      LI{"Which was against the rules",
        UL{
          LI{"It made the children laugh and play"},
          LI{"To see a lamb at school"}
        }
      },
      LI{"And so the teacher turned it out"}
    }
  }
}

这看起来确实像是构建器模式的一种应用:尽管最终的构造是通过单次调用 HTMLElement 构造函数完成的,但该构造函数只是将一个已经构造好的子元素vector移动到其最终位置。真正的构造过程是分步进行的,就像所有构建器所做的那样。但构建器对象在哪里呢?实际上并没有一个显式的构建器对象。部分构建器的功能是由所有派生对象(如 HTML、UL 等)提供的。看起来似乎代表了相应的 HTML 结构,但实际上并非如此:在整个文档构造完成后,我们所拥有的只是 HTMLElement 对象。这些派生对象仅用于构建文档。其余的构建代码则由编译器在执行参数包、初始化列表和vector之间的各种隐式转换时生成。顺便一下,稍具优化能力的编译器都会消除所有中间的复制操作,直接将输入字符串复制到最终存储它们的vector中。

当然,这只是一个非常简化的示例。实际应用中,需要为 HTML 元素存储更多数据,并为开发者提供一种初始化这些数据的方法。我们可以将这种隐式构建器方法与流式接口相结合,为所有 HTMLElement 对象提供一种简便的方式来添加诸如 style、type 等可选值。

现在,已经了解了三种不同的构建器设计:一种是使用单个构建器对象的“传统”构建器,一种是采用方法链的流式构建器,还有一种是使用多个小型构建辅助对象并严重依赖编译器生成代码的隐式构建器。当然,还有其他设计,但它们大多是上述方法的变体或组合。我们对构建器模式的研究就告一段落了。