每周技巧 #218:使用 FTADLE 设计扩展点

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #218: Designing Extension Points With FTADLE

原文最初作为 TotW #218 发布于 2023 年 1 月 19 日。

作者:Andy Soffer

更新于 2023 年 1 月 19 日。

快捷链接:abseil.io/tips/218

Ftadle。这是一个完全 cromulent 的词。~ Unknown

设计扩展点

假设你在开发一个名为 sketchy 的库,它可以在画布上绘图。你已经提供了绘制点、线和文本等常见事物的方式,但你还想提供一种机制,让用户可以指定如何绘制他们自己的类型。你正在设计一个扩展点

扩展点的设计目标

C++ 提供了许多定义扩展点的机制,每种都有自己的优缺点。在 C++ 中定义扩展点时,有几项考虑值得权衡:

  • 可读性:工程师理解你的库与扩展之间关系有多容易?
  • 可维护性:随着你的库及其用户需求变化,改变扩展点有多容易?
  • 依赖卫生:你的扩展点是否要求把你的库链接进用户的二进制?我们希望扩展点能与 IWYU 良好配合,因此如果某个头文件必须被包含才能让扩展机制工作,被扩展的类型就应当实际使用该头文件中的某些东西。
  • 缺少 ODR 违规:某些机制很容易让程序的不同部分对“程序含义”有互相矛盾的看法。ODR 违规永远是 bug。

FTADLE:一个名字很棒的好模式

定义扩展点时,我们建议遵循一种我们亲切称为 FTADLE1(Friend Template ADL Extension,友元模板 ADL 扩展)的模式。FTADLE 在上面列出的每项考虑上都表现不错。它高度依赖一种称为 ADL(Argument Dependent Lookup,实参依赖查找)的语言特性;当函数调用中没有命名空间限定(也就是没有 ::)时,编译器通过这个过程确定意图调用哪个函数。ADL 在技巧 #49 中有详细解释。要使用 FTADLE 模式编写扩展:

  1. 为扩展点选择一个名称,并加上项目命名空间前缀。我们的扩展用于绘图,项目位于 sketchy 命名空间,因此把扩展称为 SketchyDraw
  2. 设计一个传给 SketchyDraw 的类型,让它拥有用户需要的所有行为。在我们的例子中,这就是用户可以在其上绘制类型的 sketchy::Canvas
  3. 把你的功能实现为一个重载集。这个重载集中的一个成员会是模板,并调用你的扩展点。重载集中的非模板函数应该是基本构建块,也就是你的 API 支持的原始类型。在这个贯穿示例中,这意味着接受 sketchy::Pointsketchy::Line 类型的函数。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
namespace sketchy {
// 在画布 `c` 上绘制点 `p`。
void Draw(Canvas& c, const Point& p);

// 在画布 `c` 上绘制线段 `l`。
void Draw(Canvas& c, const Line& l);

// 对任何实现了 `SketchyDraw` 的用户自定义类型 `T`
//(见我肯定已经写好的文档),在画布 `c` 上绘制 `value`。
template <typename T>
void Draw(Canvas& c, const T& value) {
  // 不带命名空间限定调用。我们依赖 ADL 找到正确重载。
  // 关于 ADL,见技巧 #49。
  SketchyDraw(c, value);
}

}  // namespace sketchy

有了这个扩展点设计,用户现在就能让自己的类型可绘制。一个无关类型如何添加这种 Draw 功能,而不添加显式依赖?可以使用友元函数来做到。用户在自己的类型中添加一个名为 SketchyDraw、签名合适的友元函数模板,上面的模板重载就会通过 ADL 找到这个 SketchyDraw 函数。例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Triangle {
 public:
  explicit Triangle(Point a, Point b, Point c) : a_(a), b_(b), c_(c) {}

  template <typename SC>
  friend void SketchyDraw(SC& canvas, const Triangle& triangle) {
    // 注意:这是模板,尽管我们唯一预期传给 `SC` 的类型是
    // `sketchy::Canvas`。直接使用 `sketchy::Canvas` 也能工作,
    // 但会拉入一个额外依赖,而并非所有 `Triangle` 用户都需要它。
    sketchy::Draw(canvas, sketchy::Line(triangle.a_, triangle.b_));
    sketchy::Draw(canvas, sketchy::Line(triangle.b_, triangle.c_));
    sketchy::Draw(canvas, sketchy::Line(triangle.c_, triangle.a_));
  }

 private:
  Point a_, b_, c_;
};

// 用法:
void DrawTriangles(sketchy::Canvas& canvas, absl::Span<const Triangle> triangles) {
  for (const Triangle& triangle : triangles) {
    sketchy::Draw(canvas, triangle);
  }
}

注意:库用户永远不应直接调用 ADL 扩展点 SketchyDraw。相反,库应提供一个类似 sketchy::Draw 的函数,代表用户调用这个扩展点。

其他例子

FTADLE 模式已经在几个常见库中使用。

  • AbslHashValue 扩展点允许你的类型被 Abseil 的任意哈希容器哈希。细节见技巧 #152
  • AbslStringify 扩展点允许你用许多 Abseil 库打印类型,包括日志、absl::StrCatabsl::StrFormatabsl::Substitute

应避免什么

一些常见扩展点机制达不到我们的设计目标。虚函数、在编译期检查成员函数、模板特化都各自脆弱,下面逐一讨论。

虚函数

虽然虚函数和类层次结构可能最熟悉,但它们往往过于僵硬。它们几乎无法重构,因为基类和所有派生类都需要同步更新。我们很少第一次就把设计做对,因此拥有一个以后可改变的设计是谨慎的。

除了僵硬之外,类层次结构会强制用户依赖你。在 sketchy 的例子中,即使只有某些二进制想使用这个依赖,用户也必须依赖 sketchy 代码。FTADLE 确保只有需要做 sketchy 相关事情的二进制才付出这个成本。

非模板 friend 扩展点也是如此(例如 std::ostreamoperator<<)。每个想实现 operator<< 的类都必须包含定义 std::ostream 的某个标准库头文件(例如 <ostream><iostream>)。这意味着(不考虑优化时)无论 operator<< 是否被使用,std::ostream 的代码都会被编译并链接进二进制,可能增加编译时间和二进制大小。

成员函数

借助一些模板技巧,你可以检查一个类是否拥有某个特定名称(甚至特定签名)的方法。不过,名称可能会误导。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 要求 image 有一个 `draw()` 成员函数。
template <typename Image>
void DisplayImage(const Image& image) {
  image.draw();
}

class Cowboy {
 public:
  // 从枪套中拔枪。
  void draw();
};

int main() {
  Cowboy c;
  DisplayImage(c);  // 糟糕,不是我们想要的那个 "draw"。
}

使用 FTADLE 模式时,扩展点带有项目命名空间前缀,从而减轻意外符合要求的问题。

模板特化

另一种常见但危险的技术是使用模板特化。std::hashstd::less 就是这样被特化的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
namespace std {

template<>
struct hash<MyType> {
  size_t operator()(const MyType& m) const {
    return HashCombine(std::hash<>()(m.foo()), std::hash<>()(m.bar()));
  }
};

}  // namespace std

除了需要更多样板代码,这种技术也很容易造成 ODR 违规。虽然并不十分常见,但在不同翻译单元中为同一类型提供不同特化,甚至把同一定义写两次,都是 ODR 违规。更常见的是,如果这种特化只在某些翻译单元可见而在其他翻译单元不可见,元编程技术对“是否有可用哈希函数?”这个问题会产生不同答案,这同样是 ODR 违规。

除此之外,打开一个你不拥有的命名空间通常也是坏实践(原因之一就是会导致 ODR 违规)。我们应该设计 API 来避免坏实践,以免无意鼓励危险做法。

结论

FTADLE 扩展点模式可读、可维护、能缓解 ODR 违规,并避免添加依赖。如果你的库需要扩展点,强烈推荐 FTADLE。

每周技巧 #215:使用 `AbslStringify()` 将自定义类型字符串化

上一节

每周技巧 #224:避免使用 `vector.at()`

下一节

  1. C++ 有丰富的、几乎可发音的缩写传统,包括 RAIIIFNDRCRTPSFINAE。我们把 FTADLE 读作 “fftah-dill”(类似 “paddle”,但把开头的 “p” 换成 “raft” 末尾的音),不过也鼓励你按最能带来快乐的方式发音。 ↩︎