本周小贴士#99:非成员接口规范

作为totw/99最初发表于2015年6月24日

修订于2017年10月10日

C++类的接口不受限于其成员或其定义。当评估一个API时,我们必须考虑类主体之外的定义,这些定义可以像公有成员一样作为其接口的一部分。

这些外部接口点包括模板特化,像hash或trait,非成员运算符重载(例如日志记录,关系),还有设计用于参数依赖查找(ADL)的其他典型非成员函数,最显著的swap()。

下面针对一些示例类space::Key来说明其中的一些:

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);

}  // 命名空间 space

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

不正确地进行此类扩展会带来一些重要的风险,因此本文将尽力提供一些指导。

合适的命名空间

函数的接口点通常被设计为通过参数依赖查找(ADL,参见TotW49)来找到。运算符和一些类运算符的函数(特别是swap())是被设计为通过ADL来找到的。在函数定义在与自定义类型相关的命名空间中时,此协议才能可靠的工作。相关联的命名空间包括它的基类和类模板参数的命名空间。为了阐述这个问题,请考虑下面的代码,其中用相同的语法调用good(x)和bad(x)函数:

namespace library {
struct Letter {};

void good(Letter);
}  // 命名空间library

// 糟糕的是不恰当地放进了全局命名空间
void bad(library::Letter);

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

void test(const library::Letter& x) {
  good(x);  // 可行: 'library::good'通过ADL被找到
  bad(x);  // 哎哟: '::bad'被'client::bad'.隐藏
}

}  // 命名空间client

注意library::good()和::bad()之间的区别。test()函数依赖于在包括调用点命名空间中不存在任何称为bad()的函数。client::bad()的出现对test调用者隐藏了::bad()。同时,无论test()函数的封闭作用域中还存在什么,good()函数都会被找到。如果从相近的语法作用域到调用点名称搜索未能找到任何名字,那么C++名称查找序列将只产生一个全局名称。

这一切都非常微妙,这真的是关键点。如果我们默认在函数操作的数据旁边定义函数,那么一切都非常简单。

关于类中的友元定义的快速说明

有一种方法从类定义中向类添加非成员函数。友元函数可以直接定义在类中。

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_;
};
}  // 命名空间library

这些友元函数有一个只能通过ADL可见的特殊属性。它们有点奇怪,因为它们它们定义在封闭的命名空间中,但是没有出现在名称查找那里。这些类内的友元定义必须具有内联主体才能启用隐秘属性。有关更多详细信息,参见“友元定义”。

此类函数不会隐藏来自其命名空间中的脆弱调用点中的全局函数,也不会出现在同名的不相关调用的诊断信息中。本质上来讲,它们远离这种方式。它们也是易于定义,易于发现,并且可以访问内部类。最大的缺点可能是,在封闭类隐式转换参数的情况下,它们不会被找到。

请注意,访问(即公共,私有和保护)对友元函数没有影响,但是无论怎样,将它们放在公共部分可能是优雅的,这样在阅读公共API时更明显。

适当的源位置

为了避免违反单一定义规则(ODR),类型接口上的任何自定义都应该出现在它们不会被意外多次定义的地方。这通常意味着,它们应该与相同头文件中的类型封装在一起。在*_test.cc文件或"utilities"头文件中添加这种非成员自定义是不合适的,它可以被忽略。强制编译器去查看自定义,你将更有可能发现违规行为。

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

模板特化同样如此。模板特化可以与主模板定义封装在一起,或与它特化的类型封装在一起。针对偏特化或具有多个参数,这是一个判决调用。在实践中,哪个调用点更好通常是非常清晰的。最重要的是,特化不应该隐藏在客户代码中:它应该与模板和相关的用户定义一样可见。

何时定制

不要在测试中自定义的类的行为。这是危险的,不幸的是非常普遍。测试源文件不能免于这些危险,应该遵循与生产代码相同的规则。我们在*_test.cc文件中发现了许多不合适的运算符,这些运算符的目的是“编译EXPECT_EQ”或其他一些实际问题。不幸的是,它们依然是ODR风险(如果没有违规),并且会让库维护变得更困难。这些流氓定义甚至可能妨碍库维护,因为在上游添加这些操作符会破坏需要这些并定义它们自己的测试。对于测试,有一些需要更多努力的替代方案。

请注意,ADL从类型的原始声明点开始工作。Typedef、类型别名、别名模板和不创建类型的using声明,对ADL没有影响。这会使得为自定义找到合适的位置有点棘手,但是无可奈何,那就这么做吧。

不要扩展接口到由prtobuf生成的类型。这是另一种常见的陷阱。你可能拥有.proto文件,但是这并不意味着你拥有它生成的C++ API,并且你的扩展可能阻碍对生成C++ API的改进。这是ODR风险,因为你无法保证引入的头文件生成时,你的扩展是被看到。

定义T时,很可能想定义像std::vector或std::pair<T,T>这类模板的行为。尽管你的自定义可能是优先的,并且可以做你想做的,但你可能会与定义在更广泛的类模板上的其他预期自定义模板相冲突。

可以为原生指针定义一些自定义。在某些情况下,提供与T有关的T*的自定义是很有吸引力的。不建议这样做。它是危险的,因为自定义可能与指针的预期通用行为(如它们记录、交换和比较的方式)发生冲突。最好不要管指针。

当你受困时应该做什么

遵循这些准则可能是有挑战的。在C++代码中,能够看到许多不适当的重载和特化,这些是由一小部分根本原因引起的。以下部分列出成功的解决方案。如果你遇到无法使用的库API,请所向有者发送说明以了解如何添加适当的自定义钩子。通用API应该可以在不破坏这些接口封包原则下使用。

使用 EXPECT_EQ 等测试类型。

诱惑:EXPECT_EQ 需要 operator== 和 operator<< 或 GoogleTest 的 PrintTo。

解决方法:使用 MATCHER_P 编写轻量级 gmock 匹配器,而不是完全依赖 EXPECT_EQ 等。

解决方法:创建您真正拥有的本地(这是必不可少的)包装器类型并提供对这些类型的自定义,可能使用简单的继承作为快捷方式。

使用 T 作为容器键

诱惑:容器默认仿函数类型可能依赖于 operator<、operator== 和 hash。

解决方法:使用更多自定义比较器或自定义哈希。为关联容器类型使用更多 typedef 以从客户端代码中隐藏这些详细信息。

T 的日志容器

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

解决方法:不要尝试直接记录容器。

带走

一个类型的行为并不完全由它的类定义来定义。非成员定义和特化也有贡献。您可能需要继续阅读那个右大括号才能真正了解类的工作原理。

请注意添加这些自定义的时间和地点是安全的。添加不适当的定义可能会使您的代码暂时正常工作,但是你可能给下面其他工程师添加脆弱和维护障碍。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值