9.4. 通用方法链式调用

C++ 中方法链的应用不仅限于参数传递(之前已经见过另一种应用,尽管它隐藏得很深,那就是流式 I/O)。为了在其他上下文中使用,考虑一些更通用的方法链形式会很有帮助。

9.4.1 方法链与方法级联的比较

“方法级联”这个术语在 C++ 的语境中并不常见,这是有充分原因的 —— C++ 并不真正支持它。方法级联指的是在同一个对象上连续调用一系列方法。例如,在 Dart 语言中,由于显式支持方法级联:

var opt = Options();
opt.SetA()..SetB();

代码首先在 opt 对象上调用 SetA(),然后在同一个对象上再调用 SetB()。其等效代码是:

var opt = Options();
opt.SetA()
opt.SetB();

我们之前在 C++ 中对 options 对象不就是这么做的吗?确实做到了,但忽略了一个重要的区别。

方法链中,下一个方法是作用在前一个方法的返回结果上的。这才是 C++ 中真正的链式调用:

Options opt;
opt.SetA().SetB();

这个链式调用等价于以下代码:

Options opt;
Options& opt1 = opt.SetA();
Options& opt2 = opt1.SetB();

C++ 并没有级联语法,但等价于级联操作的代码应该是这样的:

Options opt;
opt.SetA();
opt.SetB();

但这正是之前所做的,而其简写形式是相同的:

Options opt;
opt.SetA().SetB();

在这种情况下,C++ 能够实现类似级联效果的原因是:这些方法返回了对同一对象的引用。因此,仍然可以说,其等效代码是:

Options opt;
Options& opt1 = opt.SetA();
Options& opt2 = opt1.SetB();

从技术上讲,这种说法是正确的。但由于这些方法的编写方式,保证了 opt、opt1 和 opt2 都指向同一个对象。方法级联总是可以通过方法链来实现,但这会限制接口,因为所有调用都必须返回对 this 的引用。这种实现技术有时称为“通过返回自身实现的链式级联”。

一般而言,方法链并不局限于返回 self 或对象自身的引用(C++ 中的 *this)。那么,通过更通用的链式调用可以实现什么功能呢?让我们来看一下。

9.4.2 通用方法链

如果链式调用中的方法不返回对象自身的引用,应该返回一个新创建的对象。通常,这个对象是相同类型的,或者至少是同一类型层次结构中的类型(如果方法为多态)。

例如,考虑一个实现数据集合的类。它有一个方法,使用谓词(predicate,即可调用对象,一个带有 operator() 并返回 true 或 false 的对象)来过滤数据。它还有一个方法用于对集合进行排序。这些方法中的每一个都会创建一个新的集合对象,并保持原始对象不变。

现在,如果想过滤出集合中所有有效的数据,并假设有 is_valid 谓词对象,可以创建一个有效数据的排序集合:

Collection c;
... store data in the collection ...
Collection valid_c = c.filter(is_valid);
Collection sorted_valid_c = valid_c.sort();

可以使用方法链来消除中间对象:

Collection c;
...
Collection sorted_valid_c = c.filter(is_valid).sort();

在阅读了上一节之后,应该可以清楚地看出,这是一个方法链的示例,而且比之前看到的更为通用 —— 每个方法都返回一个同类型的对象,但并非同一个对象。在这个例子中,链式调用与级联调用之间的区别非常明显:如果是方法级联,则会在原始集合上进行过滤和排序操作(假设决定支持此类操作)。

9.4.3 类型层次结构中的方法链

当应用于类型层次结构时,方法链会遇到一个特定问题;假设 sort() 方法返回一个已排序的数据集合,该集合是 SortedCollection 类型的对象,而 SortedCollection 是从 Collection 类派生的。之所以使用派生类,是因为排序后可以支持高效的搜索,因此 SortedCollection 类拥有基类所没有的 search() 方法。仍然可以使用方法链,甚至可以在派生类上调用基类的方法,但这样做会中断链式调用:

// Example 05
class SortedCollection;

class Collection {
public:
Collection filter();
  // sort() 将 Collection 转换为 SortedCollection。
  SortedCollection sort();
};

class SortedCollection : public Collection {
public:
  SortedCollection search();
  SortedCollection median();
};

SortedCollection Collection::sort() {
  SortedCollection sc;
  ... 对集合进行排序 ...
  return sc;
}

Collection c;
auto c1 = c.sort().search().filter.median();

在这个例子中,链式调用暂时是有效的:能够对一个 Collection 进行排序,对排序结果进行搜索,并对搜索结果进行过滤。sort() 调用作用于 Collection 并返回一个 SortedCollection。search() 调用需要一个 SortedCollection,因此能按预期工作。filter() 调用需要一个 Collection;该方法可以在 SortedCollection 这样的派生类上调用,但返回结果仍然是一个 Collection。随后链式调用中断了:median() 调用需要一个 SortedCollection,但 filter() 实际上将其转换回了 Collection。无法向 median() 说明该对象实际上是一个 SortedCollection(除非进行强制类型转换)。

多态性或虚函数在这里无济于事;首先,需要在基类中为 search() 和 median() 定义虚函数,即使并不打算在基类中支持这些功能,只有派生类才支持。不能将其声明为纯虚函数,因为 Collection 是具体类,而包含纯虚函数的类都是抽象类,无法实例化该类的对象。可以让这些函数在运行时中止,但这至少会将编程错误(在未排序的集合中搜索)的检测从编译时推迟到运行时。更糟糕的是,它甚至无法解决问题:

class SortedCollection;

class Collection {
public:
  Collection filter();
  // 转换 Collection 为 SortedCollection
  SortedCollection sort();
  virtual SortedCollection median();
};

class SortedCollection : public Collection {
public:
  SortedCollection search();
  SortedCollection median() override;
};

SortedCollection Collection::sort() {
  SortedCollection sc;
  ... 对集合进行排序 ...
  return sc;
}

SortedCollection Collection::median() {
  cout << "Collection::median called!!!" << endl;
  abort();
  return {}; // 需要返回一些东西
}

Collection c;
auto c1 = c.sort().search().filter().median();

这行不通,因为 Collection::filter 返回的是对象的一个副本,而不是对它的引用。它返回的对象是基类 Collection。如果在 SortedCollection 对象上调用该方法,会从派生对象中提取出基类部分并返回。如果将 filter() 也设为虚函数,并在派生类中重写,就能解决这个问题(尽管代价是需要在派生类中重写基类的每一个函数),那么还会遇到另一个意外 —— 虚函数必须具有相同的返回类型,除非是协变返回类型。对基类和派生类的引用属于协变返回类型,但以值方式返回的类本身则不属于协变返回类型。

如果返回的是对象引用,这个问题就不会发生。然而,只能返回对当前调用对象的引用;如果在方法体内创建一个新对象并返回对该对象的引用,那将是一个悬空引用,该临时对象在函数返回的瞬间就会销毁,结果是未定义行为(程序很可能会崩溃)。另一方面,如果总是返回对原始对象的引用,则根本无法将它的类型从基类变为派生类。

C++ 解决这个问题的方法涉及使用模板和一种奇特的设计模式。事实上,这种模式如此令人费解,以至于“奇特”这个词甚至出现在它的名字中 —— 奇异递归模板模式(CRTP)。本书中有一个专门的章节来介绍 CRTP 模式。该模式在我们这个案例中的应用相对直接:基类需要从其函数中返回正确的类型,但它无法做到,不知道这个类型是什么。解决方案是 —— 将正确的类型作为模板参数传递给基类。为了实现,基类必须是一个类模板:

template <typename T> class Collection {
public:
  Collection() {}
  T filter(); // "*this" 实际上是 T 类型,而非 Collection 类型
  T sort() {
    T sc; // 创建新的已排序集合
    ...
    return sc;
  }
};

class SortedCollection :
public Collection<SortedCollection> {
public:
  SortedCollection search();
  SortedCollection median();
};

Collection<SortedCollection> c;
auto c1 = c.sort().search().filter().median();

这里的链式调用开始时与初始示例类似:sort() 在 Collection 上调用并返回一个 SortedCollection,然后 search() 作用于 SortedCollection 并返回另一个 SortedCollection,接着调用 filter()。这一次,基类 Collection 中的 filter() 知道了对象的真实类型,Collection 本身是根据派生对象的类型实例化的模板。因此,filter() 可以作用于集合,但返回的对象类型与初始集合相同 —— 两者都是 SortedCollection 对象。最后,median() 需要一个 SortedCollection,并且成功得到了它。

这是一个复杂的解决方案。虽然它有效,但其复杂性表明,当对象类型需要在链式调用过程中发生改变时,应谨慎使用方法链。这背后有充分的理由:改变对象类型本质上不同于调用一系列方法。这是一个更重大的事件,或许应该明确表达出来,并且新对象应该拥有自己的独立名称。

现在,我们已经了解了什么是方法链,接下来看看它在其他哪些地方可能有用。