# 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 auto make_unique(Args&&... args) { return unique_ptr( new T(forward(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)`,右值被转换成右值引用(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 auto operator()(T1&& x, T2&& y) const { // 处理并返回 } } lambda; ``` 粗粗一看,似乎也没什么问题,是吧? 假设我们有下面的变量定义: ```cpp int n; long long lln; span sp; ``` 问题来了:下面的表达式会产生多少个不同的特化(实例化结果)? ```cpp lambda(n, lln); lambda(lln, n); lambda(n, 1); lambda(n, sp[0]); lambda(sp[0], lln); ``` 问题实际不难,我们只需要按照推导规则把参数类型一一填进去即可: ```cpp Unnamed::operator(); Unnamed::operator(); Unnamed::operator(); Unnamed::operator(); Unnamed::operator(); ``` 所有的情况都是不同的,所以有五种不同的特化!这就意味着,至少从理论上来说,这五种不同的使用方式可能会产生五份不同的二进制代码。 ### 避免不必要的转发引用 那这是不是真的会成为一个问题呢?这……取决于具体情况,尤其取决于代码是不是可以被良好地内联。作为一般的指导原则,消除不必要的特化是最简单的处理方式。 就我们目前这个具体例子来说,假设我们不修改入参 `x` 和 `y`,我们有两种不同的处理方式: * 如果我们的参数只会是内置类型(如上面用到的 `int`、`long long` 等),我们可以按值传参 * 如果我们对参数类型和大小无法确定,那使用 const 引用会是一个不错的选择 如果把这个例子的 `auto&&` 改一下: ```cpp auto lambda = [](const auto& x, const auto& y) { // 处理并返回 }; ``` 那我们至少可以把上面的五种特化缩减到三种了: ```cpp Unnamed::operator(); Unnamed::operator(); Unnamed::operator(); ``` 注意,我主要想说明的是我们应当避免不必要的转发引用,而不是避免所有的转发引用。特别是,如果你在 `auto&&` 后面需要使用 `forward` 来进行转发的话(类似于 `forward(x)`),那转发引用的使用通常是合适的。 ## 模板的二进制膨胀 模板在带来方便和性能的同时,也可能使代码产生膨胀,这是一个需要权衡的问题。我们上一讲讲到的视图类型,实际上既可能消减二进制代码,也可能增加二进制代码。 对于像 `span` 这样的类型,它明显可以消减二进制代码。如果我们的 `print` 函数的定义改成: ```cpp template void print(const T& rng) { for (const auto& n : rng) { cout << n << ' '; } cout << '\n'; } ``` 那它显然可以工作,而且还非常灵活。但是,现在当我们传递 `vector`、`array`、`array`、`int[5]`、`int[8]` 时,那就是五种不同的特化了。如果由于任何原因 `print` 不能内联的话,我们就会生成约五倍数量的二进制代码。 而像 `elements_view` 这样的类型就反过来潜在可能会增加二进制代码。相信你目前已经能理解这个问题,我就不展开了。不过,相对其他一些不使用视图类型的方案,它在易用性和性能方面的提升,很可能大大超过了潜在的二进制膨胀的危害。 ### 通过退化消减二进制膨胀 某些二进制膨胀问题不太好解决,有一些则是很容易解决的。在像传递 `char[8]`、`int[5]` 这样的参数的场景,使用指针或者 `span`(即指针加长度)往往是一种很好的替换方案。下面,我们再来看一个很具体的例子,如何高效地实现一个通用的日志函数的传参。 为了高效地传递大对象,日志函数的对外接口可能长下面这个样子: ```cpp template 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 void log(log_level level, const Args&... args) { log_impl(level, try_decay(args)...); } ``` 这个函数够简单,一般可以内联。即使不能内联,它会带来的额外膨胀也非常小。所以,我们只需要专心实现 `try_decay` 就行了。 这里,我们就有一定的自由度来选择到底该怎么做了。我目前的策略是这样的: * 对于可以退化为 `const char*` 的类型,强制类型转换成 `const char*` * 对于其他数组类型,将其转变为 `span` * 其他情况直接完美转发 代码如下: ```cpp template constexpr decltype(auto) try_decay(T&& value) { using decayed_type = decay_t; using remove_ref_type = remove_reference_t; if constexpr ( is_same_v) { return decayed_type(value); } else if constexpr ( is_array_v) { return span>(value); } else { return forward(value); } } ``` 需要注意一下,使用转发引用的函数都潜在存在此类问题。所以,在 C++11 开始的新时代里,也并不是使用 `emplace_back` 一定比 `push_back` 更好,即使你正确使用、没有犯低级错误 \[2\]。 ### 通过公共基类消减二进制膨胀 除了参数类型,还有一种常见的优化类模板方法的办法,就是抽取公共基类。 类模板里通常有很多方法,一般总有些是跟模板参数相关的。但是,也常常可能存在一些方法,跟模板参数没有任何关系,或者很容易就能改造成没有关系。这类方法也是模板二进制膨胀的来源之一。 想象一下,类模板 `Obj` 里有方法 `CommonMethod()`。当我们用不同的类型,如 `int` 和 `char`,去实例化的时候,我们就可能会编译产生方法 `Obj::CommonMethod()` 和 `Obj::CommonMethod()`。这是两个无关的成员函数,因此编译器一般不会为你进行优化。在你每次实例化时,编译器都会在需要用到 `CommonMethod` 时提供一份新的代码,而不会看到不同的 `CommonMethod` 实际是一样的。我们需要显式地告诉编译器,不同的 `CommonMethod` 实际上是同一份。一种通行的做法,就是把这样的方法放到一个公用的非模板基类里去。如下所示: ```cpp class ObjBase { public: void CommonMethod(); }; template 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 O’Dwyer, “‘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 O’Dwyer, “Don’t 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/)