本文我们讨论一个很小的问题:不同的函数参数类型都在什么情况下使用?

参数分类

比如我们有一个类型 T(让问题先简单点,此处 T 并非范型),那么要在函数中传入一个该类型的参数,共有几种可能?我们可以分一下类:

  • 修饰符:有 const 修饰、无 const 修饰
  • 是否引用:值、左值引用、右值引用

按照这个分类,我们总共能得出 5 种参数类型

const修饰是否引用参数类型
const T
左值引用const T&
T
左值引用T&
右值引用T&&

const T&& 是没有意义的

接下来介绍这些参数类型的应用场景

转移所有权的情况

假设需要将参数的生命周期转移到函数内,那么大多数情况下,直接使用 T 作为参数类型即可。

例如我们有类型 A,其构造时会接受并持有一个 std::vector<int> 参数。那么参数类型应当为 std::vector<int>

class A {
public:
    A(std::vector<int> v) : v_(std::move(v)) {}

private:
    std::vector_<int> v_;
};

我们考虑两种构造类型 A 对象的情况。其一是我们在传入 std::vector<int> 对象后不会再使用该对象。这时我们可以采取移动语义。

std::vector<int> v;
// ...
A a(std::move(v));

其二是我们在传入 std::vector<int> 对象后还会使用该对象。这是我们需要采取复制语义。

std::vector<int> v;
// ...
A a(v);

但不管怎么样,使用 T 作为参数类型都可以处理。

使用 const T 同样可以接受移动和复制两种语义。但问题在于使用 const T 后无法继续进行移动。只能调用复制构造函数。这限制了 const T 的使用

class A {
public:
    A(const std::vector<int> v)
        : v_(std::move(v)) // 总是复制构造
        {}

private:
    std::vector_<int> v_;
};

转移所有权中还存在一种特殊的情况:函数中可能会移动所有权,但也可能不移动所有权。例如下面的代码

std::vector<int> v = {1, 2, 3};
bool ok = may_or_maynot_move(std::move(v));
assert(ok ^ !v.empty());

其中函数 may_or_maynot_move() 可能会移动传入的 v。如果移动了,那么返回 true,此时 v 变为空;如果没有移动,那么返回 falsev 不为空。这种情况不能使用 T 作为参数类型。因为若使用的话,在调用函数的那一刻,v 就会因为调用了移动构造函数而变为空。

对于这种情况,需要使用 T&& 作为参数类型。may_or_maynot_move() 函数的大致构造如下。

bool may_or_maynot_move(std::vector<int> &&v) {
    bool need_move = ...;
    if (need_move) {
        move_func(std::move(v)); // 避免引用折叠
    }
    return need_move;
}

参数只读的情况

如果不需要传入的参数的所有权,只是希望读取参数的数据的话,需要使用 const T&const T& 可以同时接受左值和右值两种情况。不过虽然 const T& 可以接受右值,但是并不会调用移动构造函数,所以右值所引用的对象在函数调用过程中不会发生变化。这只是为了简化将函数返回值直接作为参数的情况。

当如,如果参数大小较小,更好的方式是直接使用 T

size_t func(const std::vector<int> &v) {
    return v.size();
}

std::vector<int> v = {1, 2, 3};

func(v); // 3
func(std::move(v)); // 3
func(v); // 3

现在有一个函数 gen_vec() 返回 std::vector<int>。假设 const T& 不接受右值,那么我们只能这样写

std::vector<int> v = gen_vec();
func(v);

因为 const T& 接受右值,所以我们可以简化掉中间的临时变量

func(gen_vec());

参数作为返回的情况

为了获取函数的执行结果,同时避免使用返回值导致的开销,可以使用 T& 类型的参数。很平常的情况。

void func(std::vector<int> &v) {
    v.push_back(1);
}

不同于 const T&T& 只能接受一个左值。因为右值意味着生命周期将尽,对这样的数据进行修改是没有意义的。

模板类型推导的情况

对于模板来说,如果显式指定了模板参数,那么如何选择参数类型与之前小节的内容没有区别。但如果需要考虑隐式类型推导,情况会变得更加复杂。

还是转移所有权的情况。此时我们是否能选择 T 作为参数类型呢?我们考虑下面的例子。

template<typename T>
void func(T v) {
    func2(std::move(v)); // type 1
    func2(v); // type 2
}

此处我们选择了 T 作为参数类型,但如果我们想要将该参数继续传递到其他函数中时,就会发现一个问题:我们不知道应该选择复制语义还是移动语义。此处 T 的类型是不确定的,其所代表的类型可能是 copy-only 的,或是 move-only 的。因此在此处我们不论选择复制还是移动均不合适。

这里的正确选择是使用 T&&(单独的模板参数 T 加上 &&)。但在当前情况下,这里的 T&& 不代表右值引用,而是通用引用(Universal Reference)。其语义是:如果传入的参数是左值引用,那么 T&& 替换为左值引用,如果传入的参数是右值引用,那么 T&& 替换为右值引用。

但是这样并不能解决问题,因为引用折叠的存在,右值引用作为参数传递时默认为左值引用,需要通过 std::move 将其转为右值引用。因此我们还需要一个类似于 std::move 的操作:如果是左值引用,依然是左值引用;如果是右值引用,依然是右值引用。这样的行为称为完美转发(Perfect Forwarding),由 std::forward 实现。

template<typename T>
void func(T &&v) {
    func2(std::forward<T>(v)); // 完美转发
}

引用折叠的存在是有道理的。因为将右值引用再次传递到另一个函数中时,这个右值的生命期并不一定会结束在另一个函数中。因此需要显式指定移动语义。

void func(int&& a) {
    func2(a); // 调用 func2 后,a 的生命周期并不一定结束,因此 func2() 所传入的应当为左值引用
    func3(std::move(a)); // 采取移动语义,a 的生命期在 func3() 内结束
}

通过 T&& 结合 std::forward,我们相当于将选择左值还是右值的决定权交给了更上层的函数调用方。函数调用方将决定以复制语义还是移动语义传入该参数。

一个特殊情况是构造函数的模板类型推导。在 C++17 之前,构造函数中如果使用了类模板的模板参数的话,无法根据传入的参数类型自动推导模板参数。例如下面的例子。

template<typename T>
class B {
public:
    B(T v) {}
};

B b(std::vector<int>{1, 2, 3}); // C++17 之前无法编译

C++17 之后则支持了通过构造函数进行类型推导,但是构造函数中使用 T&& 并不表示通用引用,而只是右值引用。以下面的代码为例,其中的 T&& 只能接受右值引用,并将 T 推导为 std::vector<int>。如果传入一个左值引用,那么编译器将无法找到匹配的构造函数。

template<typename T>
class C {
public:
    C(T &&v) : v_(std::forward<T>(v)) {}
private:
    T v_;
};

std::vector<int> v = {1, 2, 3};
C c1(v); // Bad
C c2(std::move(v)); // Good