gitbook/现代C++编程实战/docs/517514.md

331 lines
16 KiB
Markdown
Raw Permalink Normal View History

2022-09-03 22:05:03 +08:00
# 37参数传递的正确方法和模板的二进制膨胀
你好,我是吴咏炜。
上一讲我们讨论的视图类型的对象,通常和内置类型的对象一样,是使用传值的方式来进行传参的。这种方式非常简单,也是比较推荐的 C++ 的做法,但这种方式存在对对象类型的限制。在对象比较大的时候,或者可能比较大的时候,按值传参就可能有性能问题。这包括了大部分的函数模板,除非你能预知用来实例化模板的参数。此外,还有很多对象可能是不可复制、甚至不可移动的,显然,这些对象你也不可能按值传参。此时,你就只能使用引用或指针来传参了。
## 参数传递的方式
函数的参数有入参、出参和出入参之分。入参是最常见的情况,意味着一个参数是让函数来使用的。出参表示一个参数是函数来写的,它必须是一个引用或指针,在现代 C++ 里已经较少推荐,因为返回对象(包括结构体、`pair`、`tuple` 等)往往可导致更加清晰、更加安全、同时性能也不下降的代码。出入参是一种中间情况,参数会被函数同时读和写。它也是引用或指针,常常是一个序列的对象(如 `vector``string`),里面本来就有内容,并在函数执行的过程中让函数继续往里添加内容。
对于现代 C++,非可选的出参和出入参通常使用引用方式,这样的代码写起来会更加方便。而可选的出参和出入参则一般使用指针方式,可以用空指针表示这个参数不被使用。而入参的情况就复杂多了:
* 如果一个入参是不可选,且它的类型为内置类型或小对象(可按两个指针的大小作初步估算),应当使用值传参的方式(`Obj obj`):数字类型、指针类型、视图类型一般会使用这种方式。
* 如果一个入参是不可选的,默认可使用 const 左值引用的方式(`const Obj& obj`):容器、大对象和堆上分配内存的对象一般会使用这种方式。
* 如果一个入参是可选的,则可以使用指针传参,使用空指针表示这个参数不存在(`Obj* ptr` 或 `const Obj* ptr`)。
* 如果一个入参是不可选、移动友好的,且在函数中需要产生一个拷贝,那可以使用值方式传参(`Obj obj`)。
前三种情况都比较直白,应该只有最后一种需要说明一下。使用值传参的典型情况是构造函数、赋值运算符和利用入参构造新对象的函数。我们在[第 2 讲](https://time.geekbang.org/column/article/169263)就给出过使用值传参的赋值运算符的例子。这里,我再举一个构造函数来说明一下。如果我们需要传递一个字符串给构造函数,让构造函数把它作为成员变量存下来以供后续使用和更改,那我们这个参数使用 `string` 就挺合适:
```cpp
class Obj {
public:
explicit Obj(string name)
: name_(move(name)) {}
private:
string name_;
};
```
这样写的话,如果我们传递一个左值 `string``Obj` 构造函数的话,编译器会产生一次拷贝和一次移动,把名字写到 `name_` 里,比使用 `const string&` 作为参数类型多一次移动。它的优点是当 `string` 是一个临时对象的时候(包括用户传递字符串字面量的情况),`Obj` 的构造函数会通过两次移动把名字写到 `name_` 里。这时候,如果我们使用的是 `const string&` 的话,临时构造出来的 `string` 对象就不能被移动,而是白白地构造和析构了,浪费。
当然,入参也可以是右值引用,但这对于普通的函数(移动构造函数、移动赋值运算符之外)就很少见了,因为大部分情况下没有必要要求入参必须是个临时对象。同时提供左值引用和右值引用的重载是一种可能性,但除了在追求极致优化的基础库里,一般并不值得这么做。
上面说的情况都是参数类型(`Obj`)已知的情况。对于函数模板,参数类型本身可能是一个模板参数。这种情况下,我们又应该如何处理呢?
### 转发引用
实际上,基本原则跟上面仍然是类似的,除了我们需要把参数继续往下传到另外一个函数去、并且我们不知道这个参数会如何被使用的情况。这时,我们通常会使用转发引用。
转发引用的一个典型形式是在 `make_unique`、`make_shared`、`emplace` 等函数或方法里传递未知数量和类型的参数,如:
```cpp
template <typename T,
typename... Args>
auto make_unique(Args&&... args)
{
return unique_ptr<T>(
new T(forward<Args>(args)...));
}
```
刨除不常见的 const 右值的情况,我们来具体分析一下常见的三种场景(先限定单参数的情况):
* 当给定的参数是 const 左值(如 `const Obj&`)时,`Args` 被推导为 `const Obj&`,这样,在引用坍缩后,`Args&&` 仍然是 `const Obj&`
* 当给定的参数是非 const 左值(如 `Obj&`)时,`Args` 被推导为 `Obj&`,这样,在引用坍缩后,`Args&&` 仍然是 `Obj&`
* 当给定的参数是右值(如 `Obj&&`)时,`Args` 被推导为 `Obj`,这样,`Args&&` 当然仍保持为 `Obj&&`
回顾一下,我们这里要使用 `forward` 的原因是,所有的变量都是左值,因此,如果我们要保持“右值性”,就得使用强制类型转换。`forward` 所做的事情,本质上就是 `static_cast<Args&&>(args)`右值被转换成右值引用xvalue左值仍保持为左值引用由于引用坍缩
一般而言,转发引用之后总会跟着 `forward` 的使用。反过来,如果转发引用后面没有 `forward` 的话则是非常可疑的ranges 是一种常见的例外)\[1\]。
### auto&&
转发引用的另外一种常见用法是 `auto&&`。可能的场景有:
* 在变量声明中使用
* 在泛型 lambda 表达式中使用
* 在 C++20 的函数模板参数声明中使用
我们可以写:
```cpp
auto&& x = …;
```
我们可以写:
```cpp
for (auto&& item : rng) {
}
```
我们也可以写:
```cpp
auto lambda = [](auto&& x,
auto&& y) {
// 处理并返回
};
```
到了 C++20我们还可以写
```cpp
auto process(auto&& x, auto&& y)
{
// 处理并返回
}
```
这么写着还真方便啊,也不用管参数是不是 const及到底是左值还是右值了。爽。
### 转发引用的问题
如果你真这么觉得的话,那你显然忘了我刚写的这句话了:
> 一般而言,转发引用之后总会跟着 `forward` 的使用。反过来,如果转发引用后面没有 `forward` 的话则是非常可疑的ranges 是一种常见的例外)。
我们先抛开不谈语义问题(毕竟,如果没有副作用,语义不正确在某些时候也是可以接受的……),看看这么写有什么实际问题。
拿泛型 lambda 表达式那个例子来说,它本质上相当于下面的函数对象定义:
```cpp
struct Unnamed {
template <typename T1,
typename T2>
auto operator()(T1&& x,
T2&& y) const
{
// 处理并返回
}
} lambda;
```
粗粗一看,似乎也没什么问题,是吧?
假设我们有下面的变量定义:
```cpp
int n;
long long lln;
span<const int> sp;
```
问题来了:下面的表达式会产生多少个不同的特化(实例化结果)?
```cpp
lambda(n, lln);
lambda(lln, n);
lambda(n, 1);
lambda(n, sp[0]);
lambda(sp[0], lln);
```
问题实际不难,我们只需要按照推导规则把参数类型一一填进去即可:
```cpp
Unnamed::operator()<int&, long long&>;
Unnamed::operator()<long long&, int&>;
Unnamed::operator()<int&, int>;
Unnamed::operator()<int&, const int&>;
Unnamed::operator()<const int&, long long&>;
```
所有的情况都是不同的,所以有五种不同的特化!这就意味着,至少从理论上来说,这五种不同的使用方式可能会产生五份不同的二进制代码。
### 避免不必要的转发引用
那这是不是真的会成为一个问题呢?这……取决于具体情况,尤其取决于代码是不是可以被良好地内联。作为一般的指导原则,消除不必要的特化是最简单的处理方式。
就我们目前这个具体例子来说,假设我们不修改入参 `x``y`,我们有两种不同的处理方式:
* 如果我们的参数只会是内置类型(如上面用到的 `int`、`long long` 等),我们可以按值传参
* 如果我们对参数类型和大小无法确定,那使用 const 引用会是一个不错的选择
如果把这个例子的 `auto&&` 改一下:
```cpp
auto lambda = [](const auto& x,
const auto& y) {
// 处理并返回
};
```
那我们至少可以把上面的五种特化缩减到三种了:
```cpp
Unnamed::operator()<int, long long>;
Unnamed::operator()<long long, int>;
Unnamed::operator()<int, int>;
```
注意,我主要想说明的是我们应当避免不必要的转发引用,而不是避免所有的转发引用。特别是,如果你在 `auto&&` 后面需要使用 `forward` 来进行转发的话(类似于 `forward<decltype(x)>(x)`),那转发引用的使用通常是合适的。
## 模板的二进制膨胀
模板在带来方便和性能的同时,也可能使代码产生膨胀,这是一个需要权衡的问题。我们上一讲讲到的视图类型,实际上既可能消减二进制代码,也可能增加二进制代码。
对于像 `span` 这样的类型,它明显可以消减二进制代码。如果我们的 `print` 函数的定义改成:
```cpp
template <typename T>
void print(const T& rng)
{
for (const auto& n : rng) {
cout << n << ' ';
}
cout << '\n';
}
```
那它显然可以工作,而且还非常灵活。但是,现在当我们传递 `vector<int>`、`array<int, 5>`、`array<int, 8>`、`int[5]`、`int[8]` 时,那就是五种不同的特化了。如果由于任何原因 `print` 不能内联的话,我们就会生成约五倍数量的二进制代码。
而像 `elements_view` 这样的类型就反过来潜在可能会增加二进制代码。相信你目前已经能理解这个问题,我就不展开了。不过,相对其他一些不使用视图类型的方案,它在易用性和性能方面的提升,很可能大大超过了潜在的二进制膨胀的危害。
### 通过退化消减二进制膨胀
某些二进制膨胀问题不太好解决,有一些则是很容易解决的。在像传递 `char[8]`、`int[5]` 这样的参数的场景,使用指针或者 `span`(即指针加长度)往往是一种很好的替换方案。下面,我们再来看一个很具体的例子,如何高效地实现一个通用的日志函数的传参。
为了高效地传递大对象,日志函数的对外接口可能长下面这个样子:
```cpp
template <typename... Args>
void log(log_level,
const Args&... args);
```
这里,我们用 const 左值引用传参,规避了前面说的不同引用类型的参数会带来的额外特化。但这里我们还会遇到一个常见问题:字面量 `"hello"``"world"` 被视作同一类型——`const char[6]`——但它们和 `"hi"`——`const char[3]`——就不是同一类型了。这时候,我们需要非引用方式传参时候的退化行为,把 `const char` 数组当作 `const char*` 处理。
我们可以简单地把目前的这个 `log` 函数模板重命名为 `log_impl`,而新增一个简单转发的 `log` 函数模板:
```cpp
template <typename... Args>
void log(log_level level,
const Args&... args)
{
log_impl(level,
try_decay(args)...);
}
```
这个函数够简单,一般可以内联。即使不能内联,它会带来的额外膨胀也非常小。所以,我们只需要专心实现 `try_decay` 就行了。
这里,我们就有一定的自由度来选择到底该怎么做了。我目前的策略是这样的:
* 对于可以退化为 `const char*` 的类型,强制类型转换成 `const char*`
* 对于其他数组类型,将其转变为 `span`
* 其他情况直接完美转发
代码如下:
```cpp
template <typename T>
constexpr decltype(auto)
try_decay(T&& value)
{
using decayed_type = decay_t<T>;
using remove_ref_type =
remove_reference_t<T>;
if constexpr (
is_same_v<decayed_type,
const char*>) {
return decayed_type(value);
} else if constexpr (
is_array_v<remove_ref_type>) {
return span<remove_extent_t<
remove_ref_type>>(value);
} else {
return forward<T>(value);
}
}
```
需要注意一下,使用转发引用的函数都潜在存在此类问题。所以,在 C++11 开始的新时代里,也并不是使用 `emplace_back` 一定比 `push_back` 更好,即使你正确使用、没有犯低级错误 \[2\]。
### 通过公共基类消减二进制膨胀
除了参数类型,还有一种常见的优化类模板方法的办法,就是抽取公共基类。
类模板里通常有很多方法,一般总有些是跟模板参数相关的。但是,也常常可能存在一些方法,跟模板参数没有任何关系,或者很容易就能改造成没有关系。这类方法也是模板二进制膨胀的来源之一。
想象一下,类模板 `Obj<T>` 里有方法 `CommonMethod()`。当我们用不同的类型,如 `int``char`,去实例化的时候,我们就可能会编译产生方法 `Obj<int>::CommonMethod()``Obj<char>::CommonMethod()`。这是两个无关的成员函数,因此编译器一般不会为你进行优化。在你每次实例化时,编译器都会在需要用到 `CommonMethod` 时提供一份新的代码,而不会看到不同的 `CommonMethod` 实际是一样的。我们需要显式地告诉编译器,不同的 `CommonMethod` 实际上是同一份。一种通行的做法,就是把这样的方法放到一个公用的非模板基类里去。如下所示:
```cpp
class ObjBase {
public:
void CommonMethod();
};
template <typename T>
class Obj : private ObjBase {
public:
// 如果 CommonMethod 是一个 Obj
// 需要暴露的方法
using ObjBase::CommonMethod;
};
```
`Obj` 私有继承 `ObjBase`,这是一种实现继承关系。我们让 `Obj` 可以使用 `ObjBase` 的数据成员和方法,但不允许别人通过一个 `ObjBase` 的引用或指针来访问 `Obj`。如果 `CommonMethod` 原来是一个私有方法,那 `Obj` 现在直接使用就可以了;如果 `CommonMethod` 原来是一个公开或保护方法,那我们需要在合适的位置使用 `using` 来确保它能被调用者或子类使用。
某些标准库实现里的模板类就会使用这种方法来进行优化。
## 内容小结
本讲我讨论了两个相关问题:如何传递参数,如何减少模板的二进制膨胀。使用合适的引用方式,并合理使用退化,可以让我们产出既灵活又小巧的代码。
## 课后思考
如果一个函数的调用者应该持有一个 `unique_ptr`,函数的参数应该怎么写?
为什么目前的 `try_decay` 里面需要使用 `remove_reference_t`?两个用到的地方如果直接使用 `T` 会发生什么后果?
期待你的思考,如有任何疑问,我们留言区见!
## 参考资料
\[1\] Arthur ODwyer, “Universal reference or forward reference?”. [https://quuxplusone.github.io/blog/2022/02/02/look-what-they-need/](https://quuxplusone.github.io/blog/2022/02/02/look-what-they-need/)
\[2\] Arthur ODwyer, “Dont blindly prefer `emplace_back` to `push_back`”. [https://quuxplusone.github.io/blog/2021/03/03/push-back-emplace-back/](https://quuxplusone.github.io/blog/2021/03/03/push-back-emplace-back/)