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.

16 KiB

37参数传递的正确方法和模板的二进制膨胀

你好,我是吴咏炜。

上一讲我们讨论的视图类型的对象,通常和内置类型的对象一样,是使用传值的方式来进行传参的。这种方式非常简单,也是比较推荐的 C++ 的做法,但这种方式存在对对象类型的限制。在对象比较大的时候,或者可能比较大的时候,按值传参就可能有性能问题。这包括了大部分的函数模板,除非你能预知用来实例化模板的参数。此外,还有很多对象可能是不可复制、甚至不可移动的,显然,这些对象你也不可能按值传参。此时,你就只能使用引用或指针来传参了。

参数传递的方式

函数的参数有入参、出参和出入参之分。入参是最常见的情况,意味着一个参数是让函数来使用的。出参表示一个参数是函数来写的,它必须是一个引用或指针,在现代 C++ 里已经较少推荐,因为返回对象(包括结构体、pairtuple 等)往往可导致更加清晰、更加安全、同时性能也不下降的代码。出入参是一种中间情况,参数会被函数同时读和写。它也是引用或指针,常常是一个序列的对象(如 vectorstring),里面本来就有内容,并在函数执行的过程中让函数继续往里添加内容。

对于现代 C++,非可选的出参和出入参通常使用引用方式,这样的代码写起来会更加方便。而可选的出参和出入参则一般使用指针方式,可以用空指针表示这个参数不被使用。而入参的情况就复杂多了:

  • 如果一个入参是不可选,且它的类型为内置类型或小对象(可按两个指针的大小作初步估算),应当使用值传参的方式(Obj obj):数字类型、指针类型、视图类型一般会使用这种方式。
  • 如果一个入参是不可选的,默认可使用 const 左值引用的方式(const Obj& obj):容器、大对象和堆上分配内存的对象一般会使用这种方式。
  • 如果一个入参是可选的,则可以使用指针传参,使用空指针表示这个参数不存在(Obj* ptrconst Obj* ptr)。
  • 如果一个入参是不可选、移动友好的,且在函数中需要产生一个拷贝,那可以使用值方式传参(Obj obj)。

前三种情况都比较直白,应该只有最后一种需要说明一下。使用值传参的典型情况是构造函数、赋值运算符和利用入参构造新对象的函数。我们在第 2 讲就给出过使用值传参的赋值运算符的例子。这里,我再举一个构造函数来说明一下。如果我们需要传递一个字符串给构造函数,让构造函数把它作为成员变量存下来以供后续使用和更改,那我们这个参数使用 string 就挺合适:

class Obj {
public:
  explicit Obj(string name)
    : name_(move(name)) {}
  

private:
  string name_;
};

这样写的话,如果我们传递一个左值 stringObj 构造函数的话,编译器会产生一次拷贝和一次移动,把名字写到 name_ 里,比使用 const string& 作为参数类型多一次移动。它的优点是当 string 是一个临时对象的时候(包括用户传递字符串字面量的情况),Obj 的构造函数会通过两次移动把名字写到 name_ 里。这时候,如果我们使用的是 const string& 的话,临时构造出来的 string 对象就不能被移动,而是白白地构造和析构了,浪费。

当然,入参也可以是右值引用,但这对于普通的函数(移动构造函数、移动赋值运算符之外)就很少见了,因为大部分情况下没有必要要求入参必须是个临时对象。同时提供左值引用和右值引用的重载是一种可能性,但除了在追求极致优化的基础库里,一般并不值得这么做。

上面说的情况都是参数类型(Obj)已知的情况。对于函数模板,参数类型本身可能是一个模板参数。这种情况下,我们又应该如何处理呢?

转发引用

实际上,基本原则跟上面仍然是类似的,除了我们需要把参数继续往下传到另外一个函数去、并且我们不知道这个参数会如何被使用的情况。这时,我们通常会使用转发引用。

转发引用的一个典型形式是在 make_uniquemake_sharedemplace 等函数或方法里传递未知数量和类型的参数,如:

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&>;

所有的情况都是不同的,所以有五种不同的特化!这就意味着,至少从理论上来说,这五种不同的使用方式可能会产生五份不同的二进制代码。

避免不必要的转发引用

那这是不是真的会成为一个问题呢?这……取决于具体情况,尤其取决于代码是不是可以被良好地内联。作为一般的指导原则,消除不必要的特化是最简单的处理方式。

就我们目前这个具体例子来说,假设我们不修改入参 xy,我们有两种不同的处理方式:

  • 如果我们的参数只会是内置类型(如上面用到的 intlong 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()。当我们用不同的类型,如 intchar,去实例化的时候,我们就可能会编译产生方法 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