每周技巧 #99:非成员接口礼仪

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #99: Nonmember Interface Etiquette

原文最初作为 totw/99 发布于 2015 年 6 月 24 日。

修订于 2017-10-10。

C++ 类的接口并不局限于它的成员或它的定义。评估一个 API 时,我们必须考虑类体之外的定义;它们可能和 public 成员一样,是接口的一部分。

这些外部接口点包括模板特化(例如 hasher 或 traits)、非成员运算符重载(例如日志、关系运算符),以及其他设计为配合实参依赖查找(ADL)使用的规范非成员函数,其中最显著的是 swap()

下面用示例类 space::Key 展示其中一些:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
namespace space {
class Key { ... };

bool operator==(const Key& a, const Key& b);
bool operator<(const Key& a, const Key& b);
void swap(Key& a, Key& b);

// 标准流输出
std::ostream& operator<<(std::ostream& os, const Key& x);

// gTest 打印
void PrintTo(const Key& x, std::ostream* os);

// 新式 flag 扩展:
bool ParseFlag(const string& text, Key* dst, string* err);
string UnparseFlag(const Key& v);

}  // namespace space

HASH_NAMESPACE_BEGIN
template <>
struct hash<space::Key> {
  size_t operator()(const space::Key& x) const;
};
HASH_NAMESPACE_END

错误地制作这类扩展会带来一些重要风险,因此本文会尝试给出一些指导。

正确的命名空间

作为函数的接口点,通常设计为通过实参依赖查找找到(ADL,见 TotW 49)。运算符和一些类似运算符的函数(尤其是 swap())也设计为通过 ADL 找到。只有当函数定义在与被定制类型关联的命名空间中时,这个协议才可靠工作。关联命名空间包括其基类和类模板参数所属的命名空间。一个常见错误是把这些函数放在全局命名空间中。为了说明问题,看下面这段代码,其中 good(x)bad(x) 函数使用相同语法调用:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
namespace library {
struct Letter {};

void good(Letter);
}  // namespace library

// bad 被错误地放在全局命名空间中
void bad(library::Letter);

namespace client {
void good();
void bad();

void test(const library::Letter& x) {
  good(x);  // ok:通过 ADL 找到 'library::good'。
  bad(x);  // 糟糕:'::bad' 被 'client::bad' 隐藏。
}

}  // namespace client

注意 library::good()::bad() 的区别。test() 函数依赖于调用点外围命名空间中不存在任何叫 bad() 的函数。client::bad() 的出现会对 test 调用方隐藏 ::bad()。与此同时,无论 test() 函数外围作用域中还存在什么,good() 函数都会被找到。C++ 名称查找序列只有在从靠近调用点的词法作用域开始搜索却找不到任何名称时,才会产生全局名称。

这一切都很微妙,而这恰恰是重点。如果我们默认把函数定义在它们所操作的数据旁边,事情就简单多了。

关于类内 friend 定义的简短说明

有一种方式可以从类定义内部给类添加非成员函数。friend 函数可以直接在类内部定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
namespace library {
class Key {
 public:
  explicit Key(string s) : s_(std::move(s)) {}
  friend bool operator<(const Key& a, const Key& b) { return a.s_ < b.s_; }
  friend bool operator==(const Key& a, const Key& b) { return a.s_ == b.s_; }
  friend void swap(Key& a, Key& b) {
    swap(a.s_, b.s_);
  }

 private:
  std::string s_;
};
}  // namespace library

这些 friend 函数有一个特殊属性:它们能通过 ADL 可见。它们有点奇怪,因为它们定义在外围命名空间中,但不会出现在那里的名称查找中。这些类内 friend 定义必须拥有内联函数体,这个隐身属性才会生效。更多细节见 “Friend Definitions”

这类函数不会从脆弱调用点隐藏其命名空间中的全局函数,也不会因为同名无关函数调用而出现在诊断信息中。它们本质上会让开位置。它们定义起来也很方便,容易发现,并且可以访问类内部。最大的缺点大概是:当外围类可以从某个实参类型隐式转换而来时,它们不会被找到。

注意,访问控制(也就是 publicprivateprotected)对 friend 函数没有影响。不过,把它们放在 public 区域也许更礼貌,因为这样浏览 public API 点时更容易看到它们。

正确的源码位置

为了避免违反单一定义规则(ODR),对类型接口的任何定制都应该出现在不容易被意外定义多次的位置。这通常意味着它们应该和类型一起打包在同一个头文件中。不适合在 *_test.cc 文件里添加这种非成员定制,也不适合把它放在旁边某个容易被忽略的“utilities”头文件里。强迫编译器看到这个定制,你就更可能捕捉到违规。

作为非成员扩展的函数重载(包括运算符重载),应该声明在定义其某个实参的头文件中。

模板特化也是如此。模板特化可以和主模板定义打包在一起,也可以和被特化的类型打包在一起。对于偏特化或多参数特化,需要做判断。实践中通常很清楚哪个位置更好。重要的是,特化不应该隐藏在客户端代码中:它应该像涉及的模板和用户定义类型一样可见。

何时定制

不要在测试中定制一个类的行为。这很危险,而且遗憾的是很常见。测试源码文件并不免疫这些危险,也应该遵循和生产代码相同的规则。我们在 *_test.cc 文件中发现了许多不合适的运算符,它们的编写意图是“让 EXPECT_EQ 编译”或其他务实考虑。不幸的是,它们仍然是 ODR 风险(如果还不是违规),并可能让库维护变得困难。这些野生定义甚至可能阻碍库维护,因为在上游添加这些运算符会破坏那些需要它们并自己定义了它们的测试。对于测试,可以用稍微多一点努力换取替代方案。

注意,ADL 从类型的原始声明点开始工作。Typedef、类型别名、别名模板和 using 声明不会创建类型,也不会影响 ADL。这可能让找到定制的正确放置位置有点棘手,但这无可避免,所以照做就好。

不要增强 protobuf 生成类型的接口。这是另一个常见陷阱。你可能拥有 .proto 文件,但这并不意味着你拥有它生成的 C++ API;你的增强可能阻塞对生成 C++ API 的改进。这是 ODR 风险,因为你无法确保每次包含生成头文件时都能看到你的增强。

定义 T 时,也许会想为 std::vector<T>std::pair<T,T> 这样的模板定义行为。虽然你的定制可能优先并按预期工作,但你可能会和针对更广泛类模板定义的其他预期定制发生冲突。

可以为原始指针定义某些定制。在某些情况下,你可能会想在为 T 提供这些定制的同时,也为 T* 提供定制。不建议这样做。这很危险,因为该定制可能和指针的预期普通行为冲突(例如它们如何被记录日志、交换或比较)。最好不要碰指针。

卡住时该怎么办

遵循这些指导可能很有挑战性。C++ 代码中看到的许多不合适重载和特化,都是由一小组根本原因驱动的。下面是部分列表以及成功的变通方案。如果你遇到无法配合使用的库 API,请给所有者发消息,看看能否添加合适的定制钩子。常见 API 应该可以使用,而不需要破坏这些接口打包指导。

EXPECT_EQ 等测试类型

诱惑: EXPECT_EQ 需要 operator==,以及 operator<< 或 GoogleTest 的 PrintTo

变通方案: 使用 MATCHER_P 编写轻量 gmock matcher,而不是完全依赖 EXPECT_EQ 等。

变通方案: 创建你确实拥有的 local(这一点至关重要)wrapper 类型,并在这些类型上提供定制;必要时可以用平凡继承作为捷径。

T 用作容器键

诱惑: 容器默认函子类型可能依赖 operator<operator==hash<T>

变通方案: 使用更多自定义比较器或自定义 hasher。为你的关联容器类型使用更多 typedef,把这些细节从客户端代码中隐藏起来。

记录 T 的容器日志

诱惑: 为标准容器定义 operator<< 重载。

变通方案: 不要尝试直接记录容器日志。

要点

一个类型的行为并不完全由它的类定义决定。非成员定义和特化也会参与其中。你可能需要读过那个右花括号之后,才能真正理解一个类如何工作。

请注意何时何处可以安全添加这些定制。添加不合适的定义也许能让你的代码暂时工作,但你可能正在为之后的其他工程师增加脆弱性和维护障碍。

每周技巧 #94:调用点可读性和 bool 参数

上一节

每周技巧 #101:返回值、引用和生命周期

下一节