16 KiB
37|参数传递的正确方法和模板的二进制膨胀
你好,我是吴咏炜。
上一讲我们讨论的视图类型的对象,通常和内置类型的对象一样,是使用传值的方式来进行传参的。这种方式非常简单,也是比较推荐的 C++ 的做法,但这种方式存在对对象类型的限制。在对象比较大的时候,或者可能比较大的时候,按值传参就可能有性能问题。这包括了大部分的函数模板,除非你能预知用来实例化模板的参数。此外,还有很多对象可能是不可复制、甚至不可移动的,显然,这些对象你也不可能按值传参。此时,你就只能使用引用或指针来传参了。
参数传递的方式
函数的参数有入参、出参和出入参之分。入参是最常见的情况,意味着一个参数是让函数来使用的。出参表示一个参数是函数来写的,它必须是一个引用或指针,在现代 C++ 里已经较少推荐,因为返回对象(包括结构体、pair
、tuple
等)往往可导致更加清晰、更加安全、同时性能也不下降的代码。出入参是一种中间情况,参数会被函数同时读和写。它也是引用或指针,常常是一个序列的对象(如 vector
和 string
),里面本来就有内容,并在函数执行的过程中让函数继续往里添加内容。
对于现代 C++,非可选的出参和出入参通常使用引用方式,这样的代码写起来会更加方便。而可选的出参和出入参则一般使用指针方式,可以用空指针表示这个参数不被使用。而入参的情况就复杂多了:
- 如果一个入参是不可选,且它的类型为内置类型或小对象(可按两个指针的大小作初步估算),应当使用值传参的方式(
Obj obj
):数字类型、指针类型、视图类型一般会使用这种方式。 - 如果一个入参是不可选的,默认可使用 const 左值引用的方式(
const Obj& obj
):容器、大对象和堆上分配内存的对象一般会使用这种方式。 - 如果一个入参是可选的,则可以使用指针传参,使用空指针表示这个参数不存在(
Obj* ptr
或const Obj* ptr
)。 - 如果一个入参是不可选、移动友好的,且在函数中需要产生一个拷贝,那可以使用值方式传参(
Obj obj
)。
前三种情况都比较直白,应该只有最后一种需要说明一下。使用值传参的典型情况是构造函数、赋值运算符和利用入参构造新对象的函数。我们在第 2 讲就给出过使用值传参的赋值运算符的例子。这里,我再举一个构造函数来说明一下。如果我们需要传递一个字符串给构造函数,让构造函数把它作为成员变量存下来以供后续使用和更改,那我们这个参数使用 string
就挺合适:
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
等函数或方法里传递未知数量和类型的参数,如:
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 的函数模板参数声明中使用
我们可以写:
auto&& x = …;
我们可以写:
for (auto&& item : rng) {
…
}
我们也可以写:
auto lambda = [](auto&& x,
auto&& y) {
// 处理并返回
};
到了 C++20,我们还可以写:
auto process(auto&& x, auto&& y)
{
// 处理并返回
}
这么写着还真方便啊,也不用管参数是不是 const,及到底是左值还是右值了。爽。
转发引用的问题
如果你真这么觉得的话,那你显然忘了我刚写的这句话了:
一般而言,转发引用之后总会跟着
forward
的使用。反过来,如果转发引用后面没有forward
的话,则是非常可疑的(ranges 是一种常见的例外)。
我们先抛开不谈语义问题(毕竟,如果没有副作用,语义不正确在某些时候也是可以接受的……),看看这么写有什么实际问题。
拿泛型 lambda 表达式那个例子来说,它本质上相当于下面的函数对象定义:
struct Unnamed {
template <typename T1,
typename T2>
auto operator()(T1&& x,
T2&& y) const
{
// 处理并返回
}
} lambda;
粗粗一看,似乎也没什么问题,是吧?
假设我们有下面的变量定义:
int n;
long long lln;
span<const int> sp;
问题来了:下面的表达式会产生多少个不同的特化(实例化结果)?
lambda(n, lln);
lambda(lln, n);
lambda(n, 1);
lambda(n, sp[0]);
lambda(sp[0], lln);
问题实际不难,我们只需要按照推导规则把参数类型一一填进去即可:
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&&
改一下:
auto lambda = [](const auto& x,
const auto& y) {
// 处理并返回
};
那我们至少可以把上面的五种特化缩减到三种了:
Unnamed::operator()<int, long long>;
Unnamed::operator()<long long, int>;
Unnamed::operator()<int, int>;
注意,我主要想说明的是我们应当避免不必要的转发引用,而不是避免所有的转发引用。特别是,如果你在 auto&&
后面需要使用 forward
来进行转发的话(类似于 forward<decltype(x)>(x)
),那转发引用的使用通常是合适的。
模板的二进制膨胀
模板在带来方便和性能的同时,也可能使代码产生膨胀,这是一个需要权衡的问题。我们上一讲讲到的视图类型,实际上既可能消减二进制代码,也可能增加二进制代码。
对于像 span
这样的类型,它明显可以消减二进制代码。如果我们的 print
函数的定义改成:
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
(即指针加长度)往往是一种很好的替换方案。下面,我们再来看一个很具体的例子,如何高效地实现一个通用的日志函数的传参。
为了高效地传递大对象,日志函数的对外接口可能长下面这个样子:
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
函数模板:
template <typename... Args>
void log(log_level level,
const Args&... args)
{
log_impl(level,
try_decay(args)...);
}
这个函数够简单,一般可以内联。即使不能内联,它会带来的额外膨胀也非常小。所以,我们只需要专心实现 try_decay
就行了。
这里,我们就有一定的自由度来选择到底该怎么做了。我目前的策略是这样的:
- 对于可以退化为
const char*
的类型,强制类型转换成const char*
- 对于其他数组类型,将其转变为
span
- 其他情况直接完美转发
代码如下:
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
实际上是同一份。一种通行的做法,就是把这样的方法放到一个公用的非模板基类里去。如下所示:
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
2