每周技巧 #148:重载集
本节阅读量:本文翻译自 Abseil 官网的 Tip of the Week #148: Overload Sets。
原文最初作为 TotW #148 发布于 2018 年 5 月 3 日。
更新于 2020 年 4 月 6 日。
快捷链接:abseil.io/tips/148
与电子信息共同生活的影响之一,是我们习惯性地生活在信息过载状态中。总有比你能处理的更多。—— Marshall McLuhan
在我看来,C++ 风格指南中最有力量、最有洞察力的句子之一是这句:“只有当读者查看调用点时,不需要先弄清楚具体调用的是哪个重载,也能很好地理解正在发生什么,才使用重载函数(包括构造函数)。”
表面上看,这是一条相当直接的规则:只有不会让读者困惑时才重载。然而,它的影响实际上相当显著,并触及现代 API 设计中的一些有趣问题。先定义“重载集”这个术语,然后看一些例子。
什么是重载集?
非正式地说,重载集是一组名称相同、但参数数量、类型和/或限定符不同的函数。(所有血淋淋的细节见重载决议。)不能基于函数返回类型重载;编译器必须能只根据函数调用本身判断要调用重载集中的哪个成员,而不依赖返回类型。
|
|
类似字符串的参数
回想我最早在 Google 使用 C++ 的经历,我几乎可以肯定遇到的第一个重载是这种形式:
|
|
这种形式的重载美妙之处在于,它以非常明显的方式同时满足规则的字面要求和精神。这里没有行为差异:两种情况下,我们都接收某种类似字符串的数据,而内联转发函数清楚表明重载集中每个成员的行为完全相同。
事实证明,这一点至关重要,也容易被忽视,因为 Google C++ 风格指南没有显式这么表述:如果重载集成员的文档化行为不同,用户就隐含地必须知道实际调用的是哪个函数。想让他们在不弄清楚调用哪个重载的情况下也能“很好地理解正在发生什么”,唯一办法是让重载集中每个条目的语义相同。
所以,上面类似字符串的例子能成立,是因为它们语义相同。借用 C++ Core Guidelines 中的例子,我们不希望看到这样的东西:
|
|
希望命名空间差异足以消除这类函数实际形成重载集的歧义。根本上说,这会是糟糕设计,具体原因是 API 应该在重载集层面被理解和记录,而不是在组成它的单个函数层面。
StrCat
StrCat() 是 Abseil 最常见的例子之一,用来展示重载集常常是 API 设计的正确粒度。多年间,StrCat() 接收的参数数量以及表达该参数数量的形式都发生过变化。很多年前,StrCat() 是一组不同元数的函数。现在,从概念上说,它是一个可变参数模板函数……不过出于优化原因,小参数数量的版本仍然作为重载提供。它实际上从来都不是单个函数;我们只是从概念上把它当成一个实体。
这是重载集的良好用法:把参数数量编码进函数名会令人厌烦且冗余,而从概念上说,传给 StrCat() 的东西有多少并不重要;它永远都是“转换成字符串并拼接”的工具。
参数 sink
标准库使用、并且在许多泛型代码中出现的另一种技术,是在传递将要被存储的值时,对 const T& 和 T&& 进行重载:也就是 value sink。考虑 std::vector::push_back():
|
|
值得思考这个重载集的来源:push_back() API 最初出现时,包含 push_back(const T&),这是一种便宜(且安全)的参数传递方式。C++11 加入了 push_back(T&&) 重载,作为一种优化,用于值是临时对象,或者调用方通过写出 std::move() 承诺不再干涉该参数的情况。即使被移动后的对象可能处于不同状态,这些重载对 vector 用户仍提供相同语义,因此我们认为它们是设计良好的重载集。
换句话说,& 和 && 限定符表示该重载适用于左值还是右值表达式;如果你有 var 或 var& 实参,就会得到 & 重载;但如果你有临时对象,或者对表达式执行了 std::move(),就会得到 && 重载。(更多移动语义见 技巧 #77。)
有趣的是,这些重载在语义上等同于一个方法:push_back(T),但在某些情况下可能稍微更高效。当函数体成本相对于调用 T 的移动构造函数成本很低时,这种效率才主要重要;这对容器和泛型可能成立,但在许多其他上下文中不太可能。我们通常建议,如果你需要 sink 一个值(存储到对象中、修改参数等),为了简单性和可维护性,只提供接收 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::optional<T>,它是底层类型至多一个值的包装器。和 vector::operator[] 一样,访问 optional::value() 时也存在 const 和非 const 重载。不过,optional 更进一步,还基于“值类别”(粗略地说,对象是否为临时对象)提供 value() 重载。因此,const 和值类别的完整成对组合如下:
|
|
尾随的 & 和 && 应用于隐式 *this 参数,就像方法上的 const 限定一样,并且表示接受左值或右值实参,如上面参数 sink 所述。但重要的是,在这种情况下你其实不需要理解移动语义也能理解 optional::value()。如果你从临时对象中请求值,就会得到仿佛它本身也是临时对象的值。如果你从 const 引用中请求值,就会得到该值的 const 引用。以此类推。
拷贝与移动
对类型来说,最重要的重载集通常是构造函数集合,尤其是拷贝构造函数和移动构造函数。正确完成时,拷贝和移动在术语的所有意义上都构成重载集:读者不应该需要知道选择了哪个重载,因为新构造对象的语义在两种情况下都应该相同(假设两个构造函数都存在)。标准库正越来越明确地体现这一点:移动被假定为拷贝的优化,你不应该依赖任何给定操作中具体发生了多少次移动或拷贝。
结论
重载集在概念上是个简单想法,但如果理解不好,很容易被滥用:不要创建那种让任何人可能需要知道选中了哪个函数的重载。但使用得当时,重载集为 API 设计提供了强大的概念框架。思考 API 设计时,理解风格指南对重载集描述中的微妙之处非常值得。