C++右值引用,移动语义与完美转发详解

 
Category: C_C++

写在前面

总结一下深入理解C++11这本书的第三章第三节, 右值引用部分.

文中全部代码可以参考我在GitHub上传的部分:

  1. Learn_C_Cpp/c++11-14/Depth_understanding_of_C++11/chap3/move-semantic-perfect-forward at main · Apocaly-pse/Learn_C_Cpp (github.com);
  2. Learn_C_Cpp/c++11-14/rvalue-ref-move at main · Apocaly-pse/Learn_C_Cpp (github.com);

右值引用在新标准之后可以说是极大提高了C++的性能, 将以前只能完全拷贝的构造方法变成了一种只传递地址的引用, 在时间和空间的利用率上都有很大提高.

同时参考:

  1. CPPprimer
  2. 侯捷C++11 video
  3. 现代C++核心特性解析

问题引入: 指针成员与拷贝构造

拷贝构造函数

#include <iostream>
using namespace std;
class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) {}
    // 拷贝构造函数,从堆中分配内存,并用*h.d初始化
    /* HasPtrMem(const HasPtrMem& h) : d(new int(*h.d)) {} */
    ~HasPtrMem() { delete d; }
    int* d;
};
int main() {
    HasPtrMem a;
    HasPtrMem b(a);
    cout << *a.d << endl; // 0
    cout << *b.d << endl; // 0
}
/* 0 */
/* 0 */
/* 3-16-shallow-copy-with-mem-leak.out(63434,0x103138580) malloc: *** error for
 * object 0x60000366c040: pointer being freed was not allocate */
/* d */
/* 3-16-shallow-copy-with-mem-leak.out(63434,0x103138580) malloc: *** set a
 * breakpoint in malloc_error_break to debug */

/* a.d和b.d都指向了同一块堆内存。因此在main作用域结束的时候,a和b的析构函数纷纷被调用
 * 当其中之一完成析构之后(比如b),那么a.d就成了一个“悬挂指针”(dangling
 * pointer),因为其不再指向有效的内存了。那么在该悬挂指针上释放内存就会造成严重的错误。
 */

解注释掉上面的第七行, 就不会出现malloc错误了.

这正是拷贝构造函数.

通过下面的例子可以看出拷贝构造调用的次数(需要加上取消编译器优化的选项-fno-elide-constructors)

#include <iostream>
using namespace std;
class HasPtrMem {
public:
    HasPtrMem() : d(new int(0)) { cout << "Construct: " << ++n_cstr << endl; }
    HasPtrMem(const HasPtrMem& h) : d(new int(*h.d)) {
        cout << "Copy construct: " << ++n_cptr << " " << hex << h.d << endl;
    }
    ~HasPtrMem() { cout << "Destruct: " << ++n_dstr << endl; }
    int* d;
    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
HasPtrMem GetTemp() { return HasPtrMem(); }
int main() { HasPtrMem a = GetTemp(); }

// 两次拷贝构造, 两份不同的堆内存(但是相同的数据). 数据量大时开销很大
/* :!clang++ 3-18-record-call-ctor-count.cpp -fno-elide-constructors -std=c++11
 * &&./a.out */
/* Construct: 1 */
/* Copy construct: 1 0x6000003ac040 */
/* Destruct: 1 */
/* Copy construct: 2 0x6000003ac050 */
/* Destruct: 2 */
/* Destruct: 3 */

一共出现了两次拷贝构造过程.

常量左值引用

#include <iostream>
using namespace std;
struct Copyable {
    Copyable() {}
    Copyable(const Copyable &o) { cout << "Copied" << endl; }
};
Copyable ReturnRvalue() { return Copyable(); }
void AcceptVal(Copyable) {}
void AcceptRef(const Copyable &) {}
int main() {
    cout << "Pass by value: " << endl;
    AcceptVal(ReturnRvalue()); // 临时值被拷贝传入
    cout << "Pass by reference: " << endl;
    AcceptRef(ReturnRvalue()); // 临时值被作为引用传递
}
// 编译选项:g++ 3-3-5.cpp -fno-elide-constructors
/* :!g++ 3-20-const-ref-reduce-consume.cpp -fno-elide-constructors -std=c++11
 * &&./a.out */
/* Pass by value: */
/* Copied */
/* Copied */
/* Pass by reference: */
/* Copied */

在这个例子中, 通过采用常量左值引用, 可以减少拷贝构造的调用次数, 但是写起来十分麻烦, 需要为

左值/右值和移动语义

基本定义和区别

一般来说左值就是变量, 例如指针变量, 整型变量等; 右值就是临时变量, 例如整数, 浮点数字面量等, 但是这样区分在C++11 中其实是不合适的, 来看现代C++核心特性解析中的解释:

在C++中所谓的左值一般是指一个指向特定内存的具有名称的值 (具名对象),它有一个相对稳定的内存地址,并且有一段较长的生命周期。而右值则是不指向稳定内存地址的匿名值(不具名对象), 它的生命周期很短,通常是暂时性的。基于这一特征,我们可以用取地址符&来判断左值和右值,能取到内存地址的值为左值,否则为右值。

自增运算符

但是上面的描述也不能对左值和右值进行很好的判断, 下面的自增运算符的例子解释了这一点:

void t1() {
    int x{};
    int *p = &x++; // error: Cannot take the address of an rvalue of type 'int'
    int *q = &++x;
}

这就要考虑前置++和后置++的区别了:

++x(前置++)

这种方法是先赋值再运算, 本质上是两条语句, 即

x += 1;
return x;

所以返回的仍是左值.

x++(后置++)

这种方法是先运算再赋值, 本质上是三条语句, 即:

int y(x); // 生成对x的临时复制
++x; // 这里用到了前置++
return y;

这时候返回的是一个临时对象, 那么这就是一个右值, 直接取地址是不行的.

函数返回值

对于函数的返回值来说, 也是如此.

int x = 1;
int get_val() { return x; }
void set_val(int val) {
    int *p = &val;
    x = val;
}

void t2() {
    int y = get_val();
    int *p = &get_val(); // error: Cannot take the address of an rvalue of type 'int'
    set_val(5);
}

上面的第四行虽然在函数调用时候传入右值, 但是可以取地址, 因为其作为函数的形式参数之后变成了左值;

但是对于第十行来说, 函数get_val返回的虽然是左值, 但是经过函数返回之后称为了一个临时变量(右值), 这是因为函数返回的并不是x本身, 而是变量的临时复制. (与x++类似)

字符串字面量

这个算是一个小坑, 因为传统的字面量都是不可取地址的(右值), 但是字符串字面量除外, 原因是字符串中含有一个指针变量, 该变量指向了为字符串分配的内存空间, 所以是可以取地址的.

表达式的值类别

值类别是表达式的属性, 所以左值和右值的概念实际上暗指的都是表达式.

值类别是C++11标准中新引入的概念,具体来说它是表达式的一种属性,该属性将表达式分为3个类别,它们分别是左值(lvalue)、纯右值(prvalue)和将亡值(xvalue)

三者的主要关系如下图所示:

                      expression(表达式)
                         /        \
                        /          \
               glvalue(泛左值)     rvalue(右值)
                  /     \           /      \
                 /       \         /        \
         lvalue(左值)    xvalue(将亡值)     prvalue(纯右值)
  1. 泛左值: 通过评估能够确定对象/位域或函数的标识的表达式(具名对象)
  2. 纯右值: 通过评估能够用于初始化对象和位域, 或者能够计算运算符操作数的值的表达式
  3. 将亡值: 属于泛左值的一种, 表示资源可以被重用的对象和位域, 将亡值可以是即将被销毁的变量(值), 或者经过右值引用转换而产生.

产生将亡值的途径

强制类型转换(转为右值引用)

static_cast<X&&>(x1);

临时量实质化

指的是纯右值转换到临时对象的过程。每当纯右值出现在一个需要泛左值的地方时,临时量实质化都会发生,也就是说都会创建一个临时对象并且使用纯右值对其进行初始化,这也符合纯右值的概念,而这里的临时对象就是一个将亡值。

一个例子:

struct X {
    int a;
};
void t3() {
    int b = X().a;
    cout << b << endl;
    cout << X().a << endl; // 事实上这样也可以
}

虽然X()是纯右值, 访问成员变量a需要一个泛左值, 这里会发生临时量实质化, 将X()转为将亡值, 最后才访问a.

C++17之前, 临时变量是纯右值, 只有转为右值引用才是将亡值.

移动构造函数

由于传统的拷贝构造浪费资源, 于是C++11就引入了一种称为移动构造函数的方法, 只分配一块内存, 移动构造通过内存的方法使刚才分配的内存占为己用, 代码如下:

#include <iostream>
using namespace std;
class HasPtrMem {
public:
    HasPtrMem() : d(new int(3)) { cout << "Construct: " << ++n_cstr << endl; }
    HasPtrMem(const HasPtrMem& h) : d(new int(*h.d)) {
        cout << "Copy construct: " << ++n_cptr << endl;
    }
    HasPtrMem(HasPtrMem&& h) : d(h.d) { // 移动构造函数
        h.d = nullptr;                  // 将临时值的指针成员置空
        cout << "Move construct: " << ++n_mvtr << endl;
    }
    ~HasPtrMem() {
        delete d;
        cout << "Destruct: " << ++n_dstr << endl;
    }
    int* d;
    static int n_cstr;
    static int n_dstr;
    static int n_cptr;
    static int n_mvtr;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_cptr = 0;
int HasPtrMem::n_mvtr = 0;
HasPtrMem GetTemp() {
    HasPtrMem h;
    cout << "Resource from " << __func__ << ": " << hex << h.d << endl;
    return h;
}
int main() {
    HasPtrMem a = GetTemp();
    cout << "Resource from " << __func__ << ": " << hex << a.d << endl;
}
// 编译选项:g++ -std=c++11 3-3-4.cpp -fno-elide-constructors
/* :!g++ 3-19-move-ctor.cpp -fno-elide-constructors -std=c++11 &&./a.out */
/* Construct: 1 */
/* Resource from GetTemp: 0x600000018040 */
/* Move construct: 1 */
/* Destruct: 1 */
/* Move construct: 2 */
/* Destruct: 2 */
/* Resource from main: 0x600000018040 */
/* Destruct: 3 */

使用移动构造函数可以很好的解决资源的重复分配问题, 在后面的例子中也会发现移动语义的强大之处.

上面代码的第十行为什么要将h.d的值置为nullptr呢?

原因在于, 右值引用的语法在移动构造函数中, 完成了”偷”堆内存这件事, 本质上是将本对象d指向h.d所指的内存, 这样操作过后, 作用域结束, 会调用析构函数来释放h.d(所指向的)内存(临时对象立即析构), 如果此时不将其置为nullptr, 那么此时好不容易”偷”来的内存就会被析构函数释放, 那么d就成为dangling pointer, 会导致运行时错误.

左值转为右值(std::move)

通过std::move()实现左值表达式转为右值表达式. 在标准库的utility头文件中定义.

其实本质上就是static_cast实现的, 只不过std::move()包含了模版类型推导, 用起来比较直观.

// llvm 源码
template <class _Tp>
_LIBCPP_NODISCARD_EXT inline _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR typename remove_reference<_Tp>::type&&
move(_Tp&& __t) _NOEXCEPT {
  typedef _LIBCPP_NODEBUG typename remove_reference<_Tp>::type _Up;
  return static_cast<_Up&&>(__t); // 这行是最重要的
}

std::move的最大作用是让左值强制转换为右值, 然后就可以通过右值引用使用该值, 以使用移动语义.

void t1() {
    int a = 10;
    int &&b = a; // error: Rvalue reference to type 'int' cannot bind to lvalue of type 'int'
    int &&b = std::move(a);
}

需要注意, 被std::move转化的左值, 其生命周期没有随着左右值的转化而发生改变.

也就是说, 被std::move()转化的左值变量不会立即被析构.

下面是一个反面的例子: (其中错误使用std::move()会导致运行时错误)

#include <iostream>
using namespace std;
class Moveable {
public:
    Moveable() : i(new int(3)) {}
    ~Moveable() { delete i; }
    Moveable(const Moveable& m) : i(new int(*m.i)) {}
    Moveable(Moveable&& m) : i(m.i) { m.i = nullptr; }
    int* i;
};
int main() {
    Moveable a;
    Moveable c(std::move(a)); // 会调用移动构造函数
    // runtime error: load of null pointer of type 'int'
    // Moveable c((Moveable())); // 会调用移动构造函数, 这样是正确的
    cout << *a.i << endl;     // 解引空指针
}

移动构造函数的定义没有问题, 但是调用中用到了move, a本来是左值变量, 通过move将其转换为右值, 这样a.i就被设为nullptr了, 之后的解引用空指针会导致runtime error.

实际上, 类内的移动构造函数操作的是左值变量a.

下面是一个正确使用std::move的例子:

#include <iostream>
using namespace std;
class HugeMem {
public:
    HugeMem(int size) : sz(size > 0 ? size : 1) { c = new int[sz]; }
    ~HugeMem() { delete[] c; }
    HugeMem(HugeMem&& hm) : sz(hm.sz), c(hm.c) { hm.c = nullptr; }
    int sz;
    int* c;
};

class Moveable {
public:
    Moveable() : i(new int(3)), h(1024) {}
    ~Moveable() { delete i; }
    Moveable(Moveable&& m)
        : i(m.i), h(std::move(m.h)) { // 强制转为右值,以调用移动构造函数
        m.i = nullptr;
    }
    int* i;
    HugeMem h;
};
Moveable GetTemp() {
    Moveable tmp = Moveable();
    cout << hex << "Huge Mem from " << __func__ << " @" << tmp.h.c
         << endl; // Huge Mem from GetTemp @0x603030
    return tmp;
}
int main() {
    Moveable a(GetTemp());
    cout << hex << "Huge Mem from " << __func__ << " @" << a.h.c
         << endl; // Huge Mem from main @0x603030
}
// 编译选项:g++ -std=c++11 3-3-7.cpp -fno-elide-constructors

可以看到, 新版的Moveable移动构造函数中包含了move, 这样可以调用HugeMem类的移动构造函数, 因为这里m.h是一个临时变量(即便m.h是左值, 因为可以取地址, 这里应该称其为: 将亡值), 所以就可以对其move, 以延长其生命周期.

如果在Moveable中不对m.h进行move操作, 那么由于其左值的特性, 在构造Moveable的时候就会调用HugeMem的拷贝构造函数来构造Moveable的成员h, 这样的话移动语义就不能成功向类成员传递, 从而导致资源的消耗(使用了拷贝构造而不是移动构造).

来看下面的代码, 首先为HugeMem实现拷贝构造:

    HugeMem(const HugeMem& hm) : c(new int[hm.sz]) {
        cout << "call copy ctor\n";
    }

然后去掉std::move, 采用同样的取消编译优化参数, 得到结果如下:

// 若不使用move, 那么必然调用copy ctor, 情况如下(编译需要加上参数):
call copy ctor
Huge Mem from GetTemp @0x150009800
call copy ctor
call copy ctor
Huge Mem from main @0x12fe00000

这里就看出了资源的使用出现了重复拷贝的情况, 并且两份内存两份空间, 内容是一样的.

左值引用和右值引用

下面这个表格很好地说明了左值/右值引用与常量/非常量之间的区别和用途.

引用类型\可引用的值类型 非常量左值 常量左值 非常量右值 常量右值 用途
非常量左值引用      
常量左值引用 万能引用,拷贝构造
非常量右值引用       移动语义,完美转发
常量右值引用    

右值引用存在的问题

由于移动语义一定会修改临时变量的值, 那么声明移动构造函数的参数为常量右值引用就会使临时变量常量化, 成为一个常量右值, 这就导致临时变量的引用无法修改, 最终导致无法实现移动语义.

一定不要用常量右值引用实现移动构造函数.

完美交换

该例子取自深入理解C++11, 是非常经典的移动语义用途.

通过move, 可以在不使用额外内存空间的情况下完成两值交换, 函数中仅进行了指针的移动, 并没有申请额外内存, 以实现高性能的交换函数.

不过, 这个函数高性能的前提是值类型T可以移动, 如果T类型不可移动而只能拷贝, 那么拷贝语义会拿来完成交换, 这就是普通的交换语句了, 会出现内存申请与释放.

在移动语义的支持下, 仅通过一个通用的模板, 就可能写出更高效的交换函数, 这是泛型编程加持下C++11的一种重要语法.

#include <iostream>
using namespace std;

template <typename T>
void perfect_swap(T& a, T& b) {
    T tmp(std::move(a));
    a = std::move(b);
    b = std::move(tmp);
}

int main() {
    int a = 10, b = 20;
    cout << a << " " << b << endl;
    perfect_swap(a, b);
    cout << a << " " << b << endl;
    return 0;
}

移动赋值函数

这部分参考了CPPprimer

仍以上面实现了移动构造函数的类HasPtrMem为例, 要实现其移动赋值函数, 如下:

#include <iostream>
using namespace std;
class HasPtrMem {
public:
    HasPtrMem() : d(new int(3)) { cout << "Construct: " << ++n_cstr << endl; }
    HasPtrMem& operator=(HasPtrMem&& h) noexcept { // 移动赋值函数
        // 检测自赋值
        if (this != &h) {
            delete d;      // 释放已有对象
            d = h.d;       // 完成赋值
            h.d = nullptr; // 将h置于可析构状态
            cout << "Move assignment: " << ++n_mvas << endl;
        }
        return *this;
    }
    ~HasPtrMem() {
        delete d;
        cout << "Destruct: " << ++n_dstr << endl;
    }
    int* d;
    static int n_cstr;
    static int n_dstr;
    static int n_mvas;
};
int HasPtrMem::n_cstr = 0;
int HasPtrMem::n_dstr = 0;
int HasPtrMem::n_mvas = 0;
int main() {
    HasPtrMem a, b;
    b = std::move(a);
    cout << "Resource a from " << __func__ << ": " << hex << a.d << endl;
    cout << "Resource b from " << __func__ << ": " << hex << b.d << endl;
}

所得的结果:

:!clang++ c++11-14/Depth_understanding_of_C++11/chap3/move-semantic-perfect-forward/3-19-1-move-assignment.cpp -fno-elide-constructors -std=c++
11 && ./a.out
Construct: 1
Construct: 2
Move assignment: 1
Resource a from main: 0x0
Resource b from main: 0x600001c4c040
Destruct: 1
Destruct: 2

可以看出, 移动赋值进行了一次, 并且资源被顺利回收了: 出现了0x0(空指针)

判断类型的可移动性

通过type_traits头文件的一些有用的模板函数, 可以很方便地得出某一类型是否可移动.

struct P {
    P() = default;
    P(P&&) noexcept {};
};
struct Q {
    Q() = default;
    Q& operator=(Q&&) = default;
};

template <typename T>
void test_moveable(T t) {
    cout << is_move_constructible<T>::value << endl;
    cout << is_move_assignable<T>::value << endl;
    cout << is_trivially_move_constructible<T>::value << endl;
    cout << is_trivially_move_assignable<T>::value << endl;
    cout << is_nothrow_move_constructible<T>::value << endl;
    cout << is_nothrow_move_assignable<T>::value << endl;
}
void t2() {
    /* test_moveable(int()); */
    test_moveable(P());
    /* test_moveable(Q()); */
}

关于异常

对移动构造函数来说, 抛出异常是危险的. 举个前面的例子, 在h.d=nullptr之前抛出异常的话会导致d成为空悬指针, 后续可能很难找到这种内存错误, 所以一定要保证移动构造函数不抛出异常, 通过noexcept来实现(其实就是一个提示), 这样在抛出异常之后会出现libc++abi: terminating, 事实就是不写noexcept关键字也会抛出异常…(应该是书上写错了)

或者用std::move_if_noexcept模板函数代替move, 可以在类的移动构造函数没有noexcept修饰时返回一个左值引用, 从而使变量可以使用拷贝语义.

一个例子:

#include <iostream>
#include <utility>
using namespace std;
struct Maythrow {
    Maythrow() {}
    Maythrow(const Maythrow&) {
        std::cout << "Maythorow copy constructor." << endl;
    }
    Maythrow(Maythrow&&) { std::cout << "Maythorow move constructor." << endl; }
};
struct Nothrow {
    Nothrow() {}
    Nothrow(Nothrow&&) noexcept {
        std::cout << "Nothorow move constructor." << endl;
    }
    Nothrow(const Nothrow&) {
        std::cout << "Nothorow move constructor." << endl;
    }
};
int main() {
    Maythrow m;
    Nothrow n;
    Maythrow mt = move_if_noexcept(m); // Maythorow copy constructor.
    Nothrow nt = move_if_noexcept(n);  // Nothorow move constructor.
    return 0;
}

完美转发(perfect forwarding)

定义: 在函数模板中, 完全依照模板的参数的类型, 将参数传递给函数模板中调用的另一个函数.

例如:

template <typename T>
void Forwarding(T t) { // 仅是一个函数模板
    Perfect_Forwarding(t); // 真正执行的函数
}

上面例子中, 参数并没发生变化, 即, 传入左值则实际执行函数中用到的也是左值(右值同理).

不完美转发: 通过常量左值引用实现

void Perfect_Forwarding(int t) {}
template <typename T>
void Forwarding(const T &t) { // 仅是一个函数模板
    Perfect_Forwarding(t); // 真正执行的函数
}

虽然对转发函数来说, 接受参数的能力提高, 但是目标函数的接受上出了问题.

引用折叠

通过C++11的引用折叠的方式来完成完美转发.

typedef const int T;
typedef T& TR;
TR& v = 1;
TR类型定义 声明v的类型 v的实际类型
T& TR A&
T& TR& A&
T& TR&& A&
T&& TR A&&
T&& TR& A&
T&& TR&& A&&

规则:

  1. 定义中出现了左值引用, 引用折叠优先将其折叠为左值引用.
  2. 当转发函数的实参是类型X的一个左值引用, 模版参数被推导为X&, 右值引用为X&&.

标准库函数std::forward

// llvm 源码
template <class _Tp>
_LIBCPP_NODISCARD_EXT inline _LIBCPP_INLINE_VISIBILITY _LIBCPP_CONSTEXPR _Tp&&
forward(typename remove_reference<_Tp>::type& __t) _NOEXCEPT {
  return static_cast<_Tp&&>(__t);
}

下面是一个例子:

#include <iostream>
using namespace std;
void RunCode(int &&m) { cout << "rvalue ref" << endl; }
void RunCode(int &m) { cout << "lvalue ref" << endl; }
void RunCode(const int &&m) { cout << "const rvalue ref" << endl; }
void RunCode(const int &m) { cout << "const lvalue ref" << endl; }
template <typename T>
void PerfectForward(T &&t) {
    // RunCode(std::forward<T>(t));
    RunCode(static_cast<T &&>(t)); // 强制类型转换同样可以完成完美转发
}
int main() {
    int a{};
    int b{};
    const int c = 1;
    const int d = 0;
    PerfectForward(a);            // lvalue ref
    PerfectForward(std::move(b)); // rvalue ref
    PerfectForward(c);            // const lvalue ref
    PerfectForward(std::move(d)); // const rvalue ref
}
// 编译选项:g++ -std=c++11 3-3-9.cpp

用途: 记录函数的参数传递情况

#include <iostream>
using namespace std;
template <typename T, typename U>
void PerfectForward(T&& t, U& Func) {
    cout << t << "\tforwarded..." << endl;
    // Func(std::forward<T>(t));
    Func(static_cast<T&&>(t));
}
void RunCode(double&& m) {}
void RunHome(double&& h) {}
void RunComp(double&& c) {}
int main() {
    PerfectForward(1.5, RunComp); // 1.5      forwarded...
    PerfectForward(8, RunCode);   // 8         forwarded...
    PerfectForward(1.5, RunHome); // 1.5      forwarded...
}
// 编译选项: g++ -std=c++11 3-3-10.cpp