作为#218最初发表于2023年1月19日
由Andy Soffer创作
FTADLE. 一个如此合理的词。——佚名
设计扩展点
假设你在开发一个名为sketchy的库,用于在画布上绘图。你已经提供了一些常见物体的绘制方法,如点、线和文本,但你希望能够提供一种机制,让用户自己指定如何绘制他们自己的类型。你正在设计一个扩展点。
为扩展点设计目标
C++提供了许多定义扩展点的机制,每种机制都有其优点和缺点。在C++中定义扩展点时,有几个值得考虑的因素:
-
可读性 - 工程师容易理解您的库和扩展之间的关系吗?
-
可维护性 - 当您的库和库的用户需求发生变化时,更改扩展点容易吗?
-
依赖清洁度 - 您的扩展点是否需要将您的库链接到用户的二进制文件中?我们希望确保扩展点与"Include What You Use"(IWYU)策略兼容,所以如果扩展机制需要包含一个头文件,那么扩展的类型应该实际上使用了该头文件中的内容。
-
避免ODR(One Definition Rule,唯一定义原则)违规 - 某些机制可能会导致程序的不同部分对程序意义产生矛盾的观点。ODR违规始终是一个错误。
FTADLE: 一个拥有很棒名字的好模式
在定义扩展点时,我们建议遵循一种被我们亲切地称为FTADLE1(Friend Template ADL Extension)的模式。FTADLE在上述每个考虑因素上表现出色。它主要依赖于一种称为ADL(Argument Dependent Lookup)的语言特性;这是编译器在函数调用没有使用命名空间限定符(即没有::)时确定所需函数的过程。ADL在Tip #49中详细解释了。要使用FTADLE模式编写扩展,请按照以下步骤进行:
-
为您的扩展点选择一个名称,并以您项目的命名空间作为前缀。我们的扩展是用于绘图的,而我们的项目位于sketchy命名空间中,所以我们将称之为SketchyDraw。
-
设计一个传递给SketchyDraw的类型,该类型具有用户所需的所有行为。在我们的例子中,这是用户可以在其上绘制其类型的sketchy::Canvas。
-
将功能实现为重载集。该重载集的一个成员将是一个模板,并调用您的扩展点。重载集中的非模板函数应该是基本构建块;即API支持的原始类型。在我们的示例中,这意味着接受sketchy::Point和sketchy::Line类型的函数。
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的详细信息,请参见Tip #49。
SketchyDraw(c, value);
}
} // namespace sketchy
通过设计这个扩展点,现在用户可以使他们的类型具备可绘制的功能。即使一个不相关的类型想要添加这个Draw功能而不带来显式依赖,我们也可以利用友元函数来实现。只需在他们的类型中添加一个名为SketchyDraw的友元函数模板,并具有适当的函数签名,上面的模板重载将使用ADL来找到SketchyDraw函数。例如:
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的任何哈希容器使您的类型可哈希化。有关详细信息,请参阅Tip #152。
-
AbslStringify扩展点允许您在多个Abseil库中进行打印操作,包括日志记录、absl::StrCat、absl::StrFormat和absl::Substitute等。
应该避免什么
一些常见的扩展点机制未能达到我们的设计目标。虚函数、在编译时检查成员函数和模板特化各自都有其脆弱之处,如下所讨论。
虚函数
虚函数和类层次结构可能是最为熟悉的机制,但它们通常过于僵硬。由于基类和所有派生类需要同时更新,因此它们几乎无法进行重构。我们很少在第一次尝试中就能完美设计出来,因此有一个可以随后改变的设计是明智的选择。
除了僵硬性外,类层次结构还迫使用户依赖您的代码。在sketchy的情况下,即使只有部分二进制文件需要使用该依赖项,用户也必须依赖sketchy代码。FTADLE确保只有那些需要执行sketchy操作的二进制文件才需要支付这个代价。
非模板友元扩展点也是如此(例如std::ostream的operator<<)。希望实现operator<<的每个类都必须包含定义std::ostream的标准库头文件之一(例如,)。这意味着(除非进行优化),无论是否使用operator<<,std::ostream的代码都将被编译并链接到二进制文件中,这可能增加了编译时间和二进制文件大小的额外成本。
成员函数
通过一些模板技巧,您可以检查类是否具有特定名称(甚至是签名)的方法。然而,名称可能会误导。
// 要求图像具有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::hash和std::less如何进行特化的方式。
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。
C++有着发音几乎相近的丰富传统缩写词,包括RAII、IFNDR、CRTP和SFINAE。我们将FTADLE发音为“fftah-dill”(类似于“battle”,但将’b’替换为“raft”末尾的音),但我们鼓励您以让您感到最愉悦的方式发音。