【C++ Primer Plus】C++11 深入理解右值、右值引用和完美转发

1. 右值引用和移动语义

1.1 左值和右值

  • 左值 local value:存储在内存中、有明确存储地址(可寻址)的数据(x、y、z)
  • 右值 read value:不一定可以寻址,例如存储于寄存器中的数据;通常字面量都是右值,除了字符串常量(1、3)
int x = 1;
int y = 3;
int z = x + y;

  对于x++和++x虽然都是自增操作,但x++编译器首先生成一份临时值,然后对x自增,最后返回临时内容,所以x++是右值;++x是对x递增后返回自身,所以++x是左值

x++;    // 右值引用
++x;    // 左值引用
int *p1 = &x++;    // 右值引用所以编译失败
int *p2 = &++x;    // 左值引用可以编译成功

1.2 左值引用和右值引用

  • 左值引用:必须引用一个左值。
  • 常量左值引用:可以引用左值或右值。可以引用右值的原理是延长右值的生命周期,但这种引用存在一个问题,常量左值引用导致无法修改对象内容。 
int &x1 = 7;         // 非常量左值引用 编译错误
const int &x = 11;   // 语句结束后,11的生命周期被延长了
const int x = 11;    // 语句结束后,11立刻被销毁
  • 右值引用:引用右值且只能引用右值的方法
int &&k = 11;    // 右值引用(延长右值的生命周期)

   对于数字的表示可能不太清晰,因为数字本身就有些虚无缥缈,下面用一个类的例子来更好解释右值引用的优势,可以减少复制构造来优化性能(但实际上编译器会帮我们优化)

#include <iostream>
using namespace std;
class MyClass {
public:
    char* pc;
    MyClass();
    MyClass(const MyClass& myclass);
    ~MyClass() { cout << "dtor" << endl; delete pc; }
    void show() { cout << "Show: " << pc << endl; }
};

MyClass::MyClass() {
    cout << "ctor" << endl;
    pc = new char[10];
    for (int i = 0; i < 10; i++)
        pc[i] = 'c';
}

MyClass::MyClass(const MyClass& myclass) {
    cout << "copy ctor" << endl;
    pc = myclass.pc;    // **浅复制**, 编译器不优化则返回值调用赋值构造函数, 接下来析构原对象的时候这里内存会指向空内存!!!
    for (int i = 0; i < 10; i++)
        pc[i] = myclass.pc[i];
}

MyClass make_myclass() {
    MyClass mc;    // 1.构造函数     3.析构函数, 析构这里的mc对象(此时如果是浅复制,新的对象指向的内存也将为空, 引发指针异常)
    return mc;     // 2.拷贝函数, 返回拷贝的对象
}

int main() {
    MyClass&& mc = make_myclass();    // 返回值的生命周期被延长
    mc.show();
    cout << endl;
    MyClass mc2 = make_myclass();    // 再次调用拷贝构造函数
    mc2.show();
}
  • 优化前:MyClass&& mc = make_myclass(); 调用后
    • ctor: 调用mc的构造函数
    • copy ctor:返回值时调用mc的拷贝函数(因为没有重写移动构造函数,所以返回值时才会调用拷贝构造函数)
    • dtor: 返回值后将mc销毁调用析构函数(注意这里的复制构造函数会存在问题:如果是浅复制,此时销毁的对象会把堆区内存销毁导致新的对象空引用,所以还是强调必须重写复制构造函数)
    • 由于右值引用,延长了右值的生命周期
    • dtor: main函数结束再调用一次析构函数

  • 编译器优化后:MyClass&& mc = make_myclass(); 注意其实编译器优化会解决一些潜在的问题,当然我们只要有堆内存操作都必须重写拷贝构造函数,就解决了这个问题,详情看代码注释
    • ctor: 调用构造函数
    • dtor: 调用析构函数

  • MyClass mc = make_myclass(); 该函数调用后
    • ctor: 调用mc的构造函数
    • copy ctor: 返回值 调用mc的拷贝函数
    • dtor: 返回值后销毁mc 调用析构函数
    • copy ctor: 为了构造mc2 调用构造函数
    • dtor: 销毁返回值临时变量 调用析构函数
    • dtor: main函数结束后再调用一次析构函数

1.3 移动语义

  上面其实已经用到了移动语义,移动语义主要就是解决C++复制构造对性能的影响。但也存在问题,例如移动构造函数运行过程中发生了异常,这会造成源对象和目标对象都不完整。这里再用一个例子说明,该Useless类内有一个元素个数为 n 的 char 数组,静态变量 ct 记录了对象个数。(下面的流程基于未优化-fno-elide-constructors编译,否则编译器会自己优化掉移动构造函数的部分)

  • Useless one(20, 'o'); 调用 int char 构造函数
  • Useless one(20, 'c'); 调用 int char 构造函数
  • Useless three(one + two);
    • one + two 调用 operator+ 运算符重载,在内部调用 int 构造函数构造了对象 temp
    • 返回值时调用移动语义构造函数(未优化的情况下,实际上因为重写了移动构造函数这里才会调用),夺走 temp 里指针指向的内容并把它的指针设置为空,这样它在销毁时不会把堆区内存清空
    • 临时对象 temp 被销毁
class Useless {
public:
    int n;            // 元素个数
    char* pc;        // 数据指针
    static int ct;    // 对象个数
    void ShowObject()const;
    Useless(int k);
    Useless(int k, char ch);
    Useless(Useless&& f);        // 移动构造
    ~Useless();
    Useless operator+(const Useless& f)const;
    void ShowData() const;
};
int Useless::ct = 0;
Useless::Useless(int k) :n(k) {
    printf("int 参数的构造函数; 对象个数为: %d\n", ++ct);
    pc = new char[n];
    ShowObject();
}
Useless::Useless(int k, char ch) :n(k) {
    printf("int char参数的构造函数; 对象个数为: %d\n", ++ct);
    pc = new char[n];
    for (int i = 0; i < n; i++){
        pc[i] = ch;
    }
    ShowObject();
}
Useless::Useless(Useless&& f) :n(f.n) {
    printf("移动构造函数; 对象个数为: %d\n", ++ct);
    pc = f.pc;
    f.pc = nullptr;
    f.n = 0;
    ShowObject();
}

Useless::~Useless() {
    printf("析构函数调用; 元素个数为: %d\n", --ct);
    ShowObject();
    delete[] pc;
}

Useless Useless::operator+(const Useless& f)const {
    printf("进入 operator+\n");
    Useless temp = Useless(n + f.n);
    for (int i = 0; i < n; i++)
        temp.pc[i] = pc[i];
    for (int i = n; i < temp.n; i++)
        temp.pc[i] = f.pc[i - n];
    printf("离开 operator+\n");
    return temp;
}

void Useless::ShowObject() const {
    printf("元素个数: %d, 数据地址: %x\n", n, (void*)pc);
}

void Useless::ShowData()const {
    if (n == 0)
        printf("元素个数为空\n");
    else
        for (int i = 0; i < n; i++)
            printf("%c ", pc[i]);
    printf("\n");
}

int main()
{
    Useless one(20, 'o');    // int char 构造函数 对象个数1
    printf("\n");
    Useless two(20, 'c');    // int char 构造函数 对象个数2
    printf("\n");
    Useless three(one + two);    // 1. operator+ 调用 int 构造函数 对象个数3;  2. operator+ 返回右值, 调用移动构造函数(减少了复制的次数) 对象个数4;  3.临时对象被销毁 对象个数3
    printf("\n");
    printf("object one: \n");
    one.ShowData();
    printf("object two: \n");
    two.ShowData();
    printf("object three: \n");
    three.ShowData();
    printf("\n");
}

1.4 强制移动

  移动构造函数和移动赋值运算符都必须使用右值,但如果让他们使用左值就需要一些特殊处理

Useless choices[10];
Useless best;
int pick = 5;
best = chioces[pick];    // 由于这里是左值,所以会调用普通的复制赋值运算符

  可以使用C++11头文件utility中提供的move函数来实现将左值转换为右值,但是注意右值的字段会被夺走,并且必须定义了移动赋值运算符或移动构造函数

#include <iostream>
#include <utility>
#include <vector>
#include <string>
int main()
{
    std::string str = "Hello";
    std::vector<std::string> v;
    //调用常规的拷贝构造函数,新建字符数组,拷贝数据
    v.push_back(str);
    std::cout << "After copy, str is \"" << str << "\"\n";
    //调用移动构造函数,掏空str,掏空后,最好不要使用str
    v.push_back(std::move(str));
    std::cout << "After move, str is \"" << str << "\"\n";
    std::cout << v[0] << ", " << v[1] << "\n";
}

2. 万能引用

  很多时候我们希望传递的是一个引用而非通过拷贝构造传递,这可以提高程序效率;但仅仅通过fn(className& c)来传递引用会导致不能传递右值,fn(const className& c)又会导致传递进来的参数不能被修改,所以提出了万能引用的概念

2.1 引用折叠

  万能引用实际上就是发生了类型推导,如果源对象是一个左值,则推导出左值引用;如果源对象是一个右值,则推导出右值引用。

void foo(int &&i) {}    // i为右值引用
template<class T> void bar(T &&t) {}    // t为万能引用
template<class T> void bar(vector<T> &&t) {}    // 非万能引用,必须是直接的T
int get_val() {return 5;}
int &&x = get_val();    // x 为右值引用
auto &&y = get_val();   // y 为万能引用

  C++11 通过一套引用叠加推导的规则来实现万能引用——引用折叠,可以注意到实际类型是左值引用,则最终类型一定是左值引用;只有引用类型是一个非引用类型或者右值引用,最后推导出来的才是一个右值引用

  通过下面几行代码理解引用折叠,首先是C++11规定的展开时的定义

  • 实参类型为T的左值, 则模板 T 展开为 T&
    • 此时Test形参的类型为 T& &&,经过折叠后为 T& 左值引用
    • 此时static_cast<T&>(t) 将t转为左值引用,所以调用左值引用的函数
  • 实参类型为T的右值, 则模板 T 展开为 T 
    • 此时Test形参的类型为 T &&,所以为右值引用
    • 此时static_cast<T&&>(t) 将t转为右值引用,所以调用右值引用的函数
#include <iostream>
void process(int& i) {
    std::cout << "左值引用" << std::endl;
}
void process(int&& i) {
    std::cout << "右值引用" << std::endl;
}
template<class T>
void Test(T&& t) {
    process(static_cast<T&&>(t));
}
int main() {
    int a = 1;
    Test(a);    // C++11规定 实参类型为T的左值, 则模板T展开为int&
    Test(1);    // C++11规定 实参类型为T的右值, 则模板T展开为int
}

2.2 完美转发

  通过 std::forward<T>() 可以实现完美转发,不论左值还是右值都可以通过引用的方式传参,提高程序运行的效率。下面给出了一个完美转发的例子,打印了 T 的实际类型,并通过修改 t 的值实现了修改 a 的值(传入左值即左值引用),同样如果传入类的右值一样是右值引用。

#include <iostream>
template<class T>
void show_Type(T&& t) {
    std::cout << "is int&: " << std::is_same_v<T, int&> << std::endl;
    std::cout << "is int : " << std::is_same_v<T, int> << std::endl;
    t = 10;
}

template<class T>
void perfect_forwarding(T&& t) {
    show_Type(std::forward<T>(t));
}

int main()
{
    int a = 5;
    perfect_forwarding(5);    // 该参数在不同函数间始终以右值引用方式传递
    perfect_forwarding(a);    // 该参数在不同函数间始终以左值引用方式传递
    std::cout << a;           // 由于以引用的方式传递, 这里内存中的数值也一样会修改
}

 3. move 和 forward

  • std::move接受一个对象,并允许您将其视为临时对象(右值)。尽管这不是语义要求,但是通常,接受对右值的引用的函数会使它无效。当看到时std::move,表明该对象的值以后不应再使用,但是仍然可以分配一个新值并继续使用它
  • std::forward有一个用例:将模板化的函数参数(在函数内部)转换为用于传递它的调用方的值类别(左值或右值)。这允许将右值参数作为右值传递,并将左值作为左值传递,这是“完美转发”的方案
posted @ 2023-12-02 14:27  imXuan  阅读(499)  评论(0编辑  收藏  举报