C++ 中到底什么是”&&“ ?

最近《Effective Modern C++ 》看到了第五章,感觉这章挺有趣的,所以单独拿出来总结一下,主要是想对通用引用和右值引用相关的东西总结补充一下,感兴趣的不妨看看。

区分通用引用和右值引用

T&&”有两种不同的意思。第一种,当然是右值引用。“T&&”的另一种意思是,它既可以是右值引用,也可以是左值引用,被称为通用引用universal references)。

比如下面的例子中

void f(Widget&& param);             //右值引用
Widget&& var1 = Widget();           //右值引用
auto&& var2 = var1;                 //通用引用,auto&& 是通用引用,它的类型根据 var1 的类型来推导,var1 是一个右值引用,var2 也是一个右值引用,绑定到 var1。

template<typename T>
void f(std::vector<T>&& param);     //右值引用

template<typename T>
void f(T&& param);                  //通用引用            

上面的例子中:

auto&& var2 = var1;这是一个 通用引用(完美转发引用)。

  • auto&&通用引用,它的类型根据 var1 的类型来推导。
  • 由于 var1 是一个右值引用,auto&& 会推导为 Widget&&。因此,var2 也是一个右值引用,绑定到 var1

template<typename T> void f(T&& param);这是一个 通用引用(完美转发引用)。

  • T&& param 是一个 通用引用,它的类型根据传递给模板函数的实际类型来推导。
  • 如果传入的是右值,param 会被推导为右值引用 (T&&)。
  • 如果传入的是左值,param 会被推导为左值引用 (T&),这是通过引用折叠规则完成的。

对一个通用引用而言,类型推导是必要的,它必须恰好为“T&&”。下面几种情况都可能使一个引用失去成为通用引用的资格。

template<typename T> void f(std::vector<T>&& param);这是一个 右值引用

  • std::vector<T>&& param 是一个 右值引用,它接受一个 std::vector<T> 类型的右值。
  • 函数 f 只能接受右值类型的参数。

除此之外,即使一个简单的const修饰符的出现,也足以使一个引用失去成为通用引用的资格:

template <typename T>
void f(const T&& param);        //param是一个右值引用

对右值引用使用std::move,对通用引用使用std::forward

std::move 是一个类型转换工具,它将左值转换为右值引用,允许通过移动语义来避免不必要的对象复制。比如有时候我们希望将其资源转移给另一个对象时,你应该使用 std::move。这样可以避免复制操作,提高效率:

std::vector<int> createVector() {
    std::vector<int> v = {1, 2, 3};
    return std::move(v);  // 使用 std::move 防止复制,转移资源
}

std::forward 它通常用于完美转发(perfect forwarding)的场景中,保留了传入参数的值类别(即,是否是左值或右值)。这使得它特别适用于在函数模板中转发参数,而不改变它们的值类别。

完美转发:指的是保持参数的左值或右值性质,以便在转发时能够选择正确的构造(复制或移动)。比如下面:

template <typename T>
void wrapper(T&& arg) {
    // 使用 std::forward 保留 T&& 参数的左值或右值性质
    someFunction(std::forward<T>(arg));
}

在这个例子中,std::forward<T>(arg) 将确保传入的参数 arg 被正确地转发到 someFunction,而不引入不必要的复制或移动。

所以 move 和 forward 使用场景是不一样的,如果在通用引用上使用std::move,这可能会意外改变左值:

class Widget {
public:
    template<typename T>
    void setName(T&& newName)       //通用引用可以编译,
    { name = std::move(newName); }  //但是代码太太太差了!
    …

private:
    std::string name;
    std::shared_ptr<SomeDataStructure> p;
};

std::string getWidgetName();        //工厂函数

Widget w;

auto n = getWidgetName();           //n是局部变量

w.setName(n);                       //把n移动进w!

…                                   //现在n的值未知

上面的例子,局部变量n被传递给w.setNamen的值被移动进w.name,调用setName返回时n最终变为未定义的值。

所以对于 move 和 forward 我们尽量不要用错,但是也不要不用,否则可能会有性能上的损失,比如下面:

Matrix                              //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
    lhs += rhs;
    return std::move(lhs);          //移动lhs到返回值中
}

如果在最后 return的时候直接返回 lhs,那么其实是拷贝lhs到返回值中,拷贝比移动效率低一些。

forward情况也类似,如下:

template<typename T>
Fraction                            //按值返回
reduceAndCopy(T&& frac)             //通用引用的形参
{
    frac.reduce();
    return std::forward<T>(frac);     //移动右值,或拷贝左值到返回值中
}

如果std::forward被忽略,frac就被无条件复制到reduceAndCopy的返回值内存空间。

最后提一下,由于 RVO 的存在,我们也不要以为使用 move 操作就可以提升性能,RVO 主要解决的是在函数返回一个局部对象时,编译器可能会为该对象创建一个临时副本,然后将其复制到返回值中,从而引入了不必要的复制开销。

所以当我们想要“优化”代码,把“拷贝”变为移动:

Widget makeWidget()                 //makeWidget的移动版本
{
    Widget w;
    …
    return std::move(w);            //移动w到返回值中(不要这样做!)
}

通过返回 std::move(w),你实际上告诉编译器“我想要移动这个对象”,从而避免了 RVO 的优化,可能会导致不必要的资源转移。

所以你看,在c++中,为了做性能优化其实是一件挺复杂的事情。

避免在通用引用上重载

为什么要避免使用

通用引用可以绑定到几乎所有类型的实参,包括左值和右值。这种特性使得编译器在选择重载时可能会优先选择通用引用版本,即使这并不是开发者所期望的行为。并且由于通用引用的匹配规则,编译器在解析重载时可能会产生意外的结果。举个例子,假设有以下两个函数:

template<typename T>
void foo(T&& t) { /* 通用引用 */ }

void foo(int& t) { /* 左值引用 */ }

当调用foo(5)时,编译器会选择第一个函数,因为5是一个右值,而对于左值int a; foo(a);则会选择第二个函数。

但是假如将short a; foo(a);则会选择第一个函数。有两个重载的foo。使用通用引用的那个推导出T的类型是short,因此可以精确匹配。对于int类型参数的重载也可以在short类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。

如果想要使用重载该怎么办

最简单的方式当然是放弃了,比如 go 里面就没有重载,比如我们重载了logAndAdd函数,那么可以换成logAndAddName和logAndAddNameIdx来避免重载。

第二种方法就是将通用引用改写成 const T&,因为通用引用带来的复杂推导、重载歧义等问题反而会增加维护成本。此时,直接使用const 的左值引用即可满足需求。

改写之后 const T& 可以绑定到左值和右值,并且所有实参都被看作不可修改的“左值”,所以无需区分值类别。

比如原始版本(使用通用引用):

#include <utility> // std::forward

template<typename T>
void process(T&& param) {
    // 这里可能会做完美转发
    someFunction(std::forward<T>(param));
}

改用 const T& 后:

template<typename T>
void process(const T& param) {
    // 现在 param 总是 const 引用,不再做完美转发
    // 如果只是读 param 或传入其他函数当 const 引用使用就可以
    someFunction(param); // 直接传参即可
}

这种转变缺点是效率不高,因为放弃了移动语义,但是为了避免通用引用重载歧义、易读性优先,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。

再来就是使用tag dispatch,比如下面我们有两个重载函数,但是由于通用引用的存在,会发生引用重载歧义,所以我们想要正确的让函数实现最优匹配,可以使用std::is_integral 是一个类型特征(type trait),用来在编译期判定某个类型是否是整型

template<typename T>
void logAndAdd(T&& name)
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

void logAndAdd(int idx)             //新的重载
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(nameFromIdx(idx));
}

比如我们可以改写成这样:

template<typename T>
void logAndAdd(T&& name)
{
    logAndAddImpl(
        std::forward<T>(name),
        std::is_integral<typename std::remove_reference<T>::type>()
    );
}

template<typename T>                            //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type)    
{
    auto now = std::chrono::system_clock::now();
    log(now, "logAndAdd");
    names.emplace(std::forward<T>(name));
}

std::string nameFromIdx(int idx);           //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
  logAndAdd(nameFromIdx(idx)); 
}

logAndAdd传递一个布尔值给logAndAddImpl表明是否传入了一个整型类型,通过 std::true_typestd::false_type 来判断应该调用哪个函数。上面的例子中还用到了std::remove_reference,它的作用是去掉类型中的引用修饰符得到正确的类型:若 TU&U&&,则 std::remove_reference<T>::type 得到 U;如果 T 本身不是引用类型,则结果还是 T 本身。

在这个设计中,类型std::true_typestd::false_type是“标签”(tag),在logAndAdd内部将重载实现函数的调用“分发”(dispatch)给正确的重载。

再来就是基于 std::enable_if 的重载,如下实现了两个版本的重载函数:

// ----------------------
// (A) 针对整型参数的重载
// ----------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 是整型时有效
    typename = std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& idx)
{
    cout << "for int" << endl;
}

// ------------------------
// (B) 针对非整型参数的重载
// ------------------------
template<
    typename T,
    // 使用 std::enable_if 使该重载仅在 T 不是整型时有效
    typename = std::enable_if_t<!std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& name)
{
    cout << "for universal reference" << endl;
}

int main()
{
    logAndAdd("Alice");             // 非整型,调用重载 (B)
    std::string bob = "Bob";
    logAndAdd(bob);                 // 非整型,调用重载 (B)

    logAndAdd(42);                   // 整型,调用重载 (A)  

    return 0;
}

通过 SFINAE 条件:

std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>

如果形参是整型类型(如 int, long,无论左值还是右值),匹配这个重载。否则就调用另外一个。

关于引用折叠reference collapsing

在C++中引用的引用是非法的,比如下面的写法,编译器会报错:

int x;
…
auto& & rx = x; //error: 'rx' declared as a reference to a reference

但是我们上面用了很多这样的例子:

template<typename T>
void func(T&& param);       //同之前一样

Widget w;                   //一个变量(左值)
func(w);                                    //用左值调用func;T被推导为Widget&

它并没有因为被推导成了 Widget& && param编译器报错。因为编译器会通过引用折叠把T推导的类型带入模板变成Widget& param,也就是说这时候 func 里面传入的是一个左值引用。

存在引用折叠是为了适配“完美转发”这种灵活的泛型编程需求。在模板中使用如 T&&(或者 auto&&)这类通用引用的时候,我们把左值传给 T&& 时,需要推导出的类型为 T&(左值引用);把一个右值传给 T&& 时,需要推导出的类型为 T&&(右值引用)。

由于这个推导会自动在类型上再套一层引用,所以不可避免会产生 T& &T&& &T& &&T&& && 这类“引用的引用”。若没有引用折叠规则,这些“引用的引用”将无法在语言中被直接表示。

C++ 标准中定义的引用折叠规则可总结为以下四条(这里的 & 代表左值引用,&& 代表右值引用):

  1. T& & 折叠为 T&
  2. T& && 折叠为 T&
  3. T&& & 折叠为 T&
  4. T&& && 折叠为 T&&

其中可以看出,只要有一个左值引用(&)参与,就会最终折叠成左值引用;只有当纯右值引用(T&& &&)相叠时,才会保留为右值引用。

可以用简单的测试代码来查看引用折叠的结果:

#include <type_traits>
#include <iostream>
#include <string>

template <typename T>
void testReference(T&& x) {
    using XType = decltype(x); // 注意这里的 x 是函数形参
    // 现在 XType 才是真正的形参类型,比如 int&、int&&

    std::cout << "XType is "
              << (std::is_reference<XType>::value ? "reference " : "non-reference ")
              << (std::is_lvalue_reference<XType>::value ? "&" : "")
              << (std::is_rvalue_reference<XType>::value ? "&&" : "")
              << std::endl;
}

int main() {
    int i = 0;
    const int ci = 0;
    // 1) 左值 int
    testReference(i);       // T 推断为 int&,故 T&& 折叠为 int&
    // 2) 右值 int
    testReference(10);      // T 推断为 int,  故 T&& 折叠为 int&&
    // 3) 左值 const int
    testReference(ci);      // T 推断为 const int&, T&& -> const int&
    // 4) 右值 const int
    testReference(std::move(ci)); // T 推断为 const int, T&& -> const int&&
    return 0;
}

完美转发失效的情况

花括号

比如我们有 fwd 的函数,利用fwd模板,接受任何类型的实参,并转发得到的任何东西。

template<typename... Ts>
void fwd(Ts&&... params)            //接受任何实参
{
    f(std::forward<Ts>(params)...); //转发给f
}

假定f这样声明:

void f(const std::vector<int>& v);

在这个例子中,用花括号初始化调用f通过编译,

f({ 1, 2, 3 });         //可以,“{1, 2, 3}”隐式转换为std::vector<int>

但是传递相同的列表初始化给fwd不能编译

fwd({ 1, 2, 3 });       //错误!不能编译

因为对f的直接调用(例如f({ 1, 2, 3 })),编译器看看调用地传入的实参,看看f声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。但是 fwd 是个模版,所以不能这样调用。

0或者NULL作为空指针

传递0或者NULL作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int)而不是指针类型。结果就是不管是0还是NULL都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr而不是0或者NULL

仅有声明的整型static const数据成员

当一个编译时常量被用作 纯值,如传递给函数、用于模板参数等,编译器可能不会为其分配实际的存储空间,只在需要时进行内联优化。这意味着这些常量没有独立的地址可供引用。

然而,当你尝试取这些常量的地址时,编译器需要一个实际的存储位置。如果该常量没有被定义在某个存储位置(例如,静态成员变量在类外未定义),链接器将找不到该符号,从而产生链接错误。

比如在类中定义整型static const数据成员:

class Widget {
public:
    static const std::size_t MinVals = 28;  //MinVal的声明
    …
};

想象下ffwd要转发实参给它的那个函数)这样声明:

void f(std::size_t val);

我们尝试通过fwd调用f会报错:

fwd(Widget::MinVals);       //ld: symbol(s) not found for architecture arm64
c++: error: linker command failed with exit code 1

要能够安全地取编译时常量的地址,需要确保这些常量有实际的存储空间。一种方式是提供类外定义:

#include <cstddef>

class Widget {
public:
    static const std::size_t MinVals = 28; // 类内声明和初始化
};

// 类外定义
const std::size_t Widget::MinVals;

void f(std::size_t val) {}

template<typename T>
void fwd(T&& param) {
    f(std::forward<T>(param));
}

int main() {
    fwd(Widget::MinVals);
    const std::size_t* ptr = &Widget::MinVals; // 现在可以安全取地址
}

还有就是从 C++11 开始,使用 constexpr 可以在一定条件下避免需要类外定义,因为 constexpr 变量默认具有内联性质,编译器会为其分配存储空间:

#include <cstddef>

class Widget {
public:
    static constexpr std::size_t MinVals = 28; // 使用 constexpr
};

再来就是从 C++17 开始,可以使用 inline 关键字:

class Widget {
public:
    inline static const std::size_t MinVals = 28; // 使用 inline
};

重载函数

如果我们试图使用函数模板转发一个有重载的函数,也是会报错的,译器不知道哪个函数应被传递。如下f被定义为可以传递函数指针:

void f(int (*pf)(int));

现在假设我们有了一个重载函数,processVal

int processVal(int value);
int processVal(int value, int priority);

报错:

fwd(processVal);                    //错误!那个processVal?

但是我们可以给函数重命名来解决这个问题:

using ProcessFuncType =                          
    int (*)(int);

ProcessFuncType processValPtr = processVal;     //指定所需的processVal签名

fwd(processValPtr);                             //可以
fwd(static_cast<ProcessFuncType>(workOnVal));   //也可以

Reference

《Effective Modern C++ 》