C++ 11降本增效,左值引用、右值引用、拷贝构造、移动构造、拷贝赋值、移动赋值、完美转发全拿捏

C++作为编程语言大哥大,为满足高性能、安全性等要求,在C++ 11中添加了很多新概念。该篇文章主要阐述C++11如何通过引用、拷贝/移动构造、拷贝/移动赋值,来实现程序降本增效。本文观点均为个人理解,如有错误敬请指教。

在C++中,struct/class的实例内存一般都分配在栈(stack)中;函数在传参/结果返回时,会在实参到形参、返回值到接受变量间进行N次值拷贝。如果这些实例在栈中占有较大的内存空间,高效的传递这些实例做参数的需求变得急迫起来。

本文所有内容均围绕解决该需求而展开,思考以下程序代码:

//该程序演示函数调用时,实参被复制到形参的事实
typedef struct {
    int a;
    int b;
} Array_2;

void func(Array_2 arr){
    arr.a = arr.a+arr.b;
    cout << "a = "<< arr.a << endl;
};
int main()
{
    Array_2 a ={1,10};
    func(a);
    cout << "-----------------------"<<endl;
    cout << "a = "<< a.a << endl;
    cout << endl;
    return 0;
}

输出:

从输出结果可以看出,调用函数时实参a被完整复制了一份到形参arr,实参a与arr在程序中拥有不同的内存空间。如下图:

函数调用过程的数据传递可简化成:main中创建a => 复制main中实参a到函数形参arr => 函数功能执行(使用形参arr)

 

在C/C++中,默认的函数非指针参数传递都是上述方式(数组、指针除外)。这种传参方式在基本数据类型如:int、bool、double,类、结构体数据空间很少的情况下工作良好。

如将Array_2修改如下:

typedef struct {
    int a[1000] ={1,2,3,4,5};
} Array_2;

现在Arry_2的字段(属性)a变为长度1000的数组,按照上面的方式进行函数传参,理论上工作l量将增加了1000/2=500倍。

在旧C/C++开发中,可通过将函数参数指针化来优化上面的情况:

void func(Array_2* arr){ //函数参数为Array_2的指针
    for(int i = 0; i< 5; i++){
        arr->a[i] += 1;
    }
    for(int i = 0; i< 5; i++){
        cout << "arr.a["<< i<<"]="<< arr->a[i] << " ";
    }
    cout << endl;
};


int main()
{
    Array_2 a ;
    func(&a);   //将a的地址做参数传递给函数
    cout << "-----------------------"<<endl;
    for(int i = 0; i< 5; i++){
        cout << "a.a["<< i<<"]="<< a.a[i] << " ";
    }
    cout << endl;
    return 0;

} 

输出:

这里取a的地址值复制传递到函数形参中,并未对a的内存进行复制传递。因此需要栈(stack)上大内存对象参与函数运算时,传递对象的指针更加高效。过程大体如下:

然而指针在使用上存在较多问题,如:使用指针要取地址(&)、解引用(*)不够便捷,必须判断否为空,指针同时还存在类型强转的安全隐患,......为了解决这些问题,C++11中引入了这里要说的第一个新概念。

一、引用(&,也被称为左值引用)

引用:引用是对象的别名(C#、Java中处处是引用,引用早已成为常识),让Coder可以方便的达到使用指针一样的效果(C/C++初学者可以把引用当作安全指针来理解)。

使用引用需要满以下要求:

  • 使用 类型& 来声明一个引用变量
  • 变量声明时必须被赋值
  • 引用确定后无法再修改其引用指向
    int a = 10;
    int b = 20;
    int& c = a;                 //定义引用c为a的别名
    //int& d = 30; 这是错误的,引用必须被赋值一个变量,不能时常量或函数返回值
    cout << "c=" << c << endl;  //输出c,即输出a
    c = 15;                     //修改c的值为15,即修改a的值
    cout << "a=" << a << endl;  
    c = b;                      //c引用a的引用关系在定义后不能再次修改,此处仅是将c的值改成b的值
    cout << "a=" << a << endl;

输出:

从上面的程序与运行结果可以看出, 引用也是一种类型(与指针是一样的),也是用来定义变量并引用(指向)具体对象。

引用(&)为什么又被称为左值引用?

还是看上面的程序:int a = 10是一个赋值语句,=左边a被称为左值,=右边的10被称为右值。int& c声明的变量c只能引用 赋值语句中 位于=左边的 左值,所以被称为左值引用。

下面用引用优化指针:

void func(Array_2& arr){ //函数参数为Array_2的引用
    for(int i = 0; i< 5; i++){
        arr->a[i] += 1;
    }
    for(int i = 0; i< 5; i++){
        cout << "arr.a["<< i<<"]="<< arr->a[i] << " ";
    }
    cout << endl;
};

int main()
{
    Array_2 a ;
    Array_2& b = a; //定义引用
    func(b);   //将引用b做参数传递给函数
    cout << "-----------------------"<<endl;
    for(int i = 0; i< 5; i++){
        cout << "a.a["<< i<<"]="<< a.a[i] << " ";
    }
    cout << endl;
    return 0;
} 

输出:

从给函数传参角度来看,引用实现了指针的全部功能,并且语法更加简便。这在编码层面上实现了降本(编码更简单、理解更直观),相比原始实/形参完全拷贝传递实现了增效。

上述调用可优化如下:

Array_2 a ={1,10};
func(a);  //将a传递给arr,相当于func(Array_2& arr = a)

C++ 11 搞出引用(&)这个骚操作,本质还是为了函数调用传参时直接将实参传入函数内部。

二、右值引用(&&)

 下面是函数返回值接收代码:

class MyData{
public:
    MyData(int i = 0){
        fill(d,d+5,i);//设置数组元素默认值
        cout << "create instrance "<< this <<endl;
    }
    MyData(const MyData& other){
        cout << "copy other "<< &other <<" -> this "<< this <<endl;
    }
    ~MyData(){
        cout << "release instrance " << this << endl;
    }
    void haHa(){
        cout << "haha" << endl;
    }
    void show(){
        cout << "show: ";
        for(int i = 0; i < 5; i++){
            cout << d[i] <<" ";
        }
        cout << endl;
    }
private:
    int d[5];
};

MyData createData(){
    MyData d;
    return d;
}
int main()
{
    MyData a = createData();
    a.haHa();
    return 0;
}

输出:

观察函数返回值接收代码输出:函数调用createDate时执行了构造函数(输出:create instrance 0x61fe5c),然后在return d中临时对象tmp(0x61feac)通过拷贝构造复制了d的值,之后释放d(0x61fe5c)。接下来将临时对象tmp(0x61feac)复制到 main中的a(0x61fe98),之后释放临时对象tmp(0x61feac),然后再调用a.haHa()输出,最后释放掉a(0x61fe98)。

函数返回值接收过程中的数据传递大体可简化如下:

函数功能执行(创建d) => 复制函数执行结果tmp到main中a => main中使用a

细心的朋友应该已发现上面的过程,与“一章节”中的函数调用参数传递过程是逆向关系。一章节函数调用参数如下:

main中创建a => 复制main中实参a到函数形参arr => 函数功能执行(使用形参arr)

 

在“一章节”中,函数调用中使用“引用”实现了实参直接传入函数,从而完成了程序的降本增效;如果这里也能使用“引用”将函数执行结果tmp直接传回到main中a,那不也能省取一个复制过程。看下图

确实有这么一个“引用”可以实现我们的需求,它叫做:右值引用(&&),先看程序及执行输出:

//仅需调整main中的代码
int main()
{
    MyData&& a = createData();//注意这里
    cout << &a<<":";//输出右值引用的地址
    a.haHa();
    return 0;
}

输出:

 那什么是右值引用?其实它是“一章节”左值引用(&)反向。

左值引用变量 引用的是 赋值操作时=号 左边的东西

右值引用变量 引用的是 赋值操作时=号 右边的东西

如下代码示例:

MyData getData(){
    MyData d;
    return d;
}

int main(){
    int a = 10;
    int&& b = a;//错误,a时左值
    int && c = 10;//正确,10在赋值时只能位于=右边
    MyData e = getData();
    MyData&& f = getData();//ok
}

总结:

左/右值引用:数据类型+左值引用(&)/右值引用(&&),也是一种类型(如:int& x = a声明int&类型变量x并赋值,int&& y = 10声明int&&类型变量y并赋值),只是左/右值引用的变量分别是赋值号(=)左/右两边的对象。

到这里可以得到 C++ 11中变量由:值类型(int、MyData)+ 值类别(左/右)+ 值 组成

 

三、引用(左值、右值)的使用

1右值引用接收函数返回值,如“二章节”代码所示:

int main()
{
    MyData&& a = createData();//注意这里
    cout << &a<<":";//输出右值引用的地址
    a.haHa();
    return 0;
}

右值引用(&&)接收函数返回值,为了将函数执行结果直接传回给调用者。这样处理临时对象(右值),避免了不必要的临时对象拷贝,程序执行更加高效了。

注意:createData返回的临时对象,被右值引用(&&)类型变量a接管;这样在变量a的生命周期内,随时可以使用a指向的值(a.haHa)。即右值引用变量接收函数返回值,扩展了函数返回临时对象的作用域。

2、做普通函数参数

2.1、左值引用:将外部实参变量直接传入函数内部,避免复制传参提升效率

void test1(MyData& d){
   cout << "left ref:" d.show(); }
int main(){
    MyData data;
    test1(data); //调用左值引用t,将data直接传入函数内部
    retun 0;
}

2.2、右值引用:将外部临时对象(表达式结果也是临时对象)直接传入函数内部,避免复制传参提升效率

void test2(MyData&& d){
if()    cout
<< "rigth ref:" d.show(); }
int main(){
    test2(MyData()); //调用右值引用,传入MyData()默认构造函数的结果
    test2(MyData()+MyData());//调用右值引用test2函数 接受表达式(expression),这里假设MyData中operator+返回MyData对象
    retun 0;
}

3、做类 构造函数/赋值操作重载 参数

 修改MyData类如下:

class MyData{
public:
    //普通构造函数
    MyData(int size = 5){
        this->size = size;
        d = new int[size];
        std::fill(d,d+size,size);
        cout << "Create this:"<< this <<endl;
    }
    //拷贝构造函数
    MyData(const MyData& other):size(other.size){
        d = new int[size];
        std::copy(other.d,other.d+size,d);
        cout << "Copy constructor. copy other:"<< &other << "->this:" << this << endl;
    }
    //拷贝赋值运算
    MyData& operator=(const MyData& other){
        if(this != &other){
            size = other.size;
            d = new int[size];
            std::copy(other.d,other.d+size,d);
            cout << "Copy assignment. copy other:"<< &other << "->this:" << this << endl;
        }
        return *this;
    }
    //移动构造函数
    MyData(MyData&& other) noexcept:size(other.size),d(other.d){
        other.size = 0;
        other.d = nullptr;
        cout << "Move constructor. move other:"<< &other << "->this:" << this << endl;
    }
    //移动赋值运算
    MyData& operator=(MyData&& other) noexcept{
        if(this != &other){
            size = other.size;
            d = other.d;
            other.size = 0;
            other.d = nullptr;
            cout << "Move assignment.  move other:"<< &other << "->this:" << this << endl;
        }
        return *this;
    }
    //析构函数
    ~MyData(){
        cout << "release instrance " << this;
        if(nullptr != d){
            cout << " delete size:"<<size;
            delete[] d;
            size = 0;
        }
        cout << endl;
    }
    void show(){
        cout << "show:";
        if(nullptr == d){
            cout << "empty......"<<endl;
            return;
        }

        for(int i = 0; i < size; i++){
            cout << d[i] <<" ";
        }
        cout << endl;
    }
private:
    int size;
    int* d;
};

其他:

MyData createData(){
    MyData d(5);
    d.show();
    return d;
}

3.1、在类中 拷贝构造/赋值操作,都以 左值引用 做参数。首先传递效率肯定高,第二意图明显:在构造函数/赋值操作中克隆一份与参数完全相同的对象(内存空间一致、数据一样)。

注意:const来修饰引用对象,确保了函数不能修改传入的左值对象。

3.2、在类中 移动构造/赋值操作,都以 右值引用 做参数。首先传递效率肯定高,第二意图明显:右值作为临时对象,在构造函数/赋值操作中抢占右值的资源,即能省去开辟空间操作、又能节内存省空间、临时对象(右值)还没意见(因为它马上就要被释放掉)。如下:

int main()
{
    MyData c(createData());
    c.show();
    return 0;
}

输出:

 上面程序中的出现了多个临时对象(MyData d,函数返回值tmp),因为MyData类中加入了移动构造,从而使临时对象的资源转移(被抢占)表现的更加自然高效。

这种应用在STL的容器操作中很常见,如:

    vector<MyData> datas(3);
    
    MyData a(5);
    datas.push_back(a);
    MyData b(6);
    datas.push_back(b);
    MyData c(5);
    datas.push_back(c);
    
    datas.push_back(MyData(5));
    datas.push_back(MyData(6));
    datas.push_back(MyData(7));

上面的2类向vector中添加元素的操作,无论时代码量还是性能,第2类都完胜。

4、使用左右值引用面临的问题:

预测下面代码输出结果:

int main()
{
    MyData&& a = createData();//可简化为MyData a(createData())

MyData b(a);//

return 0;
}

预测输出:MyData&& a = createData()使用右值引用a接收函数返回值(之前已经实验过,只看后面程序),a作为右值引用在MyData b(a)中应该调用MyData的移动构造函数。

输出:

输出上面结果的原因,MyData&& a =createMyData()虽被定义为MyData类型的右值引用变量,但程序调用a变量时,只会把它当作普通引用变量来用(MyData&即左值引用,因为是无论左/右值引用,都可调用a.show,所以根本无需在实际使用过程中区分)。这个就是:引用变量无法携带值类别信息。

在MyData类中,重载的拷贝构造与移动构造函数与引用变量的值类别(左还是右)密切相关;又因引用变量无法携带值类别信息,那如何才能在程序中按我们的心意来调用不同的构造函数呢?如下:

int main()
{
    MyData a(10);
    MyData b(a);//我想使用拷贝构造来创建b
    
    MyData&& c = createData();
    MyData d(c);//我想使用移动构造函数
    
    return 0;
}

这个问题可以使用std::move(变量)来解决,如下:


输出:

 std::move(变量):将变量转换为右值,更多关于move的内容请自行查找。

注意:

右值与右值引用的区别。右值如:9,ture,右值引用:则是变量,并且只能被赋右值。

左值与左值引用都是变量

有关拷贝赋值、移动赋值其原理与对应构造相同,不在引申,大体参考以下代码:

int main()
{
    MyData a(3);
    MyData b(5);
    b = a;
    cout << "---------------------------------------" << endl;
    MyData c;
    c = std::move(a);
    cout << "---------------------------------------" << endl;
    MyData&& d = std::move(c);
    d.show();
    return 0;
}

输出:

四、完美转发

如上MyData类有2个与引用值类别相关的构造函数,作为代码使用者,在哪个时机调用哪个构造函数必须要记清楚。

如果能建立一个工厂函数,不论用户传入的是哪个值类别的引用参数,工厂方法都能根据传入的引用参数,来构建并返回MyData对象,岂不美哉!

现有以下代码做个测试:

template<typename T>
MyData createData(T&& arg) {
    return MyData(std::forward<T>(arg));
}
int main()
{
    MyData a;
    //......
    //......
    //......
    MyData&& b = createData(a); 
//......
MyData&& c = createData(std::move(a));
return 0;
}

输出:

 上述程序实现了我们工厂方法设想,上面的程序相比之前MyData对象创建方式,有了以下升级:

  • 通过工厂函数将MyData对象创建的接口进行了统一
  • 自动选择合适的构造函数进行MyData对象的创建

代码分析:

template<typename T>             //首先这是个模板函数
MyData createData(T&& arg) {   //T&&是通用引用
    return MyData(std::forward<T>(arg)); //forward<T>(arg)参数转发
}

T&&被称为通用引用(要与模板结合使用),它解决了普通引用(&,&&)变量无法携带 值类别 信息的问题。通用引用由 T+&& 组成,在T中同时保存着 值类型+值类别 信息,然后再与 && 进行组合。

简单记忆:T&&会将完整形参信息通过forword<T>(arg),传递给被调函数,这就是转发。

具体过程如下:

MyData a;// a变量是个左值

MyData&& b = createData(a);// 调用工厂函数

进行推导:

T &&,如果a被推导为MyData,  则结果为MyData&&   ,将MyData&& arg = a,无法将左值绑定到右值引用arg。

T &&,如果a被推导为MyData&,则结果为MyData&&&,折叠引用得MyData&, MyData& arg = a,将a作为左值绑定到左值引用arg,OK。

T &&,a不可能推导为MyData&&。

执行:return MyData(std::forward<T>(arg));//推导结果为:return MyData(std::forward<MyData&>(arg)),执行MyData拷贝构造函数。

MyData&& c = createData(std::move(a));

进行推导:(注意:std::move(a)是右值,如:9)

T &&,如果std::move(a)被推导为MyData,  则结果为MyData&&   ,将MyData&& arg = std::move(a),将std::move(a)绑定到右值引用arg,OK。

T &&,如果std::move(a)被推导为MyData&,则结果为MyData&&& ,折叠引用得MyData& arg = std::move(a), 无法将右值绑定到左值引用arg。

T &&,std::move(a)是右值,不可能被推导为MyData&&右值引用。

执行:return MyData(std::forward<T>(arg));//推导结果为:return MyData(std::forward<MyData>(arg)),执行MyData移动构造函数。

现在看来,这个工厂函数很完美的完成了转发工作,因此被称为完美转发。以上仅是完美转发的一个demo。

posted @ 2025-05-15 11:26  DW039  阅读(73)  评论(0)    收藏  举报