You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

331 lines
16 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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/)