作为TotW#148最初发表于2018年5月3日
由Titus Winters创作
使用电气信息的影响之一是我们习惯性地生活在信息过载状态下。 总有更多超出您所能应付的。 - 马歇尔·麦克卢汉
我认为,C++风格指南中最强大,最有见解的句子之一是:“只有在查看调用点的读者可以很好的了解什么正在发生,而不必首先弄清楚哪个重载被调用,才使用重载函数(包括构造函数)”。
从表面上看,这是一个非常简单的规则:仅用于重载不会引起读者的迷惑。但是,这种后果实际上是相当重要的,并涉及现代API设计中的一些有趣的问题。 首先,让我们定义“重载集”一词,然后让我们查看一些示例。
重载集是什么?
非正式来讲,重载集是一组函数,它们有相同的名字,却在参数的类型或限定符上,数量上不同。(有关详情参见重载解决方案)你不能依据函数的返回值进行重载-编译器必须能够通过函数的调用来判断要调用的重载集,而不是返回类型。
int Add(int a, int b);
int Add(int a, int b, int c); // 参数的个数能不同
// 返回类型可能会不同,只要选定的重载是唯一的,仅从参数(类型和个数)来识别
float Add(float a, float b);
// 但是如果两个返回类型有相同的签名,它们不能构建一个合适的重载集.
// 下面的代码将无法编译在以上的重载下
int Add(float a, float b); // 糟糕——不能在返回值上重载
字符串类参数
回想一下我在Google的对C++最早的经历,我几乎可以肯定的是,我遇到的第一批重载是如下形式:
void Process(const std::string& s) { Process(s.c_str()); }
void Process(const char*);
这种形式的重载的奇妙之处在于,他们以非常明显的方式符合这种规则的文字与精神。这里没有行为上的差异:在两种情况下,我们都接受某种形式的字符串数据,并且内联转发函数非常清晰地展现出重载集中每个成员的行为的相同性。
事实证明,这至关重要,且易于忽略,因为Google C++风格指南并未明确表示:如果重载集的成员记录行为有所不同,则用户隐含地必须知道哪个函数实际上被调用了。确认他们拥有“正在发生的好主义”而不弄明白哪一个重载正在被调用的唯一方法是,是否重载集中每项的语义相同。
因此,上面的字符串示例正常工作,是因为它们具有相同的语义。从C++核心指南中借用一个示例,我们不想看到类似的东西:
// 移除车库入口的障碍物
void open(Gate& g);
// 打开文件
void open(const char* name, const char* mode ="r");
命令空间的差异有希望使这些函数消除实际形成重载集。从根本上讲,这不是一个好的设计,特别是因为应该在重载集上理解和记录API,而不是在构成其的个体函数上。
StrCat
Strcat()是最常见的ABSEIL示例之一,证明重载集通常是API设计的正确粒度。 多年来,strcat()已更改其接受的参数数量,以及用来表达该参数计数的形式。 几年前,Strcat()是一组具有不同参数的函数。 现在,从概念上讲,这是一个变异模板函数……尽管出于优化原因,小规模的参数数量仍然作为重载。 它实际上从来都不是一个单一函数—— 我们只是从概念上将其视为一个实体。
这是一个重载集的很好的应用——编码函数名中的参数计数是很烦人和冗余的,从概念上讲,多少东西传递给StrCat()——它始终是“转换为字符串并连接“的工具,这并不重要。
参数接收器
标准使用并在许多通用代码中应用的另一种技术是,当传递的值将被存储时,在const T&和T&&上进行重载:一个值接收器。考虑std::vector::push_back():
void push_back(const T&);
void push_back(T&&);
值得考虑一下这种重载集的起源:当push_back() API首次出现时,它包含push_back(const T&),传递参数是开销小(且安全的)。使用C++11的push_back(T&&)重载是对临时值的优化,或者调用者承诺不通过std::move干扰参数。即便来自对象的移动可能处于不同的状态,这些对象仍然为vector的用户提供相同的语义,因此我们认为它们是一个良好设计的重载集。
换句话说,&和&&限定器表示该重载是否可用于左值或右值表达式;如果你有一个变量或值参数,你将获取&重载,但如果你的表达式上有一个临时变量或执行了std::move,那么将获取&&重载 。(有关移动语义,请参见贴士#77)
有趣的是,这些重载在语义上和单一方法——push_back(T)是相同的,但是在某些情况下可能略微更有效率。当函数的主机代价更小时,与调用T的移动构造函数相比,这种效率是重要的——对于容器和通用代码而言,但是在许多情况下不太可能。我们通常建议,如果你需要下放一个值(存储在对象,易变参数等),则只需要提供接受T(或const T&)的单个函数,更简洁和更可维护。只有在你编写非常高性能的通用代码时,才有可能有差异性。请参见贴士#77和贴士#117.
重载存取器
对于类(尤其是容器和包装器)上的方法,有时为存取器提供重载是很有价值的。标准库中提供了许多很棒的示例——我们将仅考虑vector::operator[]和optional::value()。
在vector::operator[]的情况下,存在两个重载:一个const,一个非const,因此分别返回const和非const的引用。这完美地契合了我们的定义;用户不需要知道哪个被调用。语义是相同的,不同点仅在const上——如果你有一个非const vector,则会获得非const的引用,如果你有const vector,则会获得const引用。换句话说:重载集纯粹是在转发vector的const属性,但是API是一致的——它只是给你这提示元素。
在C++17中,我们添加了std::opional,一个最多用于一个基础类型的包装器。就像vector::operator[],当访问optional::value()时,也存在const和非const重载。然而,optional更进一步,并根据值类型来提供value()重载(大致来说,不论对象是否为临时的)。因此,const和值类别的完整组合对象就像:
T& value() &;
const T & value() const &;
T&& value() &&;
const T&& value() const &&;
&和&&应用于隐式*this参数,就像const限定方法,并在上面的参数转换中指示左值和右值参数。但是,重要的是,在这种情况下,你并不需要理解移动语义来理解optional::value()。如果你从临时对象上寻求一个值,那么你就会获取临时对象本身。如果你从const-ref中获取值,那么你会获取值的const-ref。依此类推。
拷贝和移动
对于类型而言,最重要的重载集是它们的一系列的构造函数,尤其是拷贝和移动构造函数。拷贝和移动,执行正确,在术语的意义构成一个重载:读者不需要知道选择了哪个重载,因为新近的构造对象在语义上是相同的(假定两个构造函数均存在)。标准库对此愈发明确:假定移动是对副本的优化,你不应该依赖于在任何给定的操作中进行了多少移动或拷贝的细节。
结论
重载集在概念上是一个简单的想法,但在不太理解的情况下容易被滥用 - 不要产生任何人可能需要知道选择了哪个功能的重载。但是如果使用得当,重载集为 API 设计提供了一个强大的概念框架。在考虑 API 设计时,了解风格指南对重载集描述的微妙之处非常值得您花时间。