c++拷贝剖析


前言

今日c++拷贝内容部分学习。
本文将从无到有,在初学的角度进一步学习c++拷贝构造函数


一、什么是拷贝

对于计算机来说,拷贝是指用一份原有的、已经存在的数据创建出一份新的数据,最终的结果是多了一份相同的数据。在 C++ 中,拷贝并没有脱离它本来的含义,只是将这个含义进行了“特化”。在c++中拷贝是在初始化阶段进行的,也就是用其它对象的数据来初始化新对象的内存。
知道了c++拷贝的含义,那么c++是如何定义和使用拷贝构造函数的呢,看如下例子

拷贝构造函数的定义和使用

class Student{
public:
    Student(string name = "", int age = 0, float score = 0.0f);  //普通构造函数
    Student(const Student &stu);  //拷贝构造函数(声明)
public:
    void display();
private:
    string m_name;
    int m_age;
    float m_score;
};

Student::Student(string name, int age, float score): m_name(name), m_age(age), m_score(score){ }

//拷贝构造函数(定义)
Student::Student(const Student &stu){
    this->m_name = stu.m_name;
    this->m_age = stu.m_age;
    this->m_score = stu.m_score;
   
    cout<<"Copy constructor was called."<<endl;
}

void Student::display(){
    cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}

int main(){
    Student stu1("小明", 16, 90.5);
    Student stu2 = stu1;  //调用拷贝构造函数
    Student stu3(stu1);  //调用拷贝构造函数
    stu1.display();
    stu2.display();
    stu3.display();
   
    return 0;
}

在这里插入图片描述
由此,我们可以看出拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 const 引用。并且是在初始化的对象的时候调用

看这声明的构造函数,心里不仅产生疑问?

拷贝构造函数必须是当前类的引用

如果拷贝构造函数的参数不是当前类的引用,而是当前类的对象,那么在调用拷贝构造函数时,会将另外一个对象直接传递给形参,这本身就是一次拷贝,会再次调用拷贝构造函数,然后又将一个对象直接传递给了形参,将继续调用拷贝构造函数……这个过程会一直持续下去,没有尽头,陷入死循环。
只有当参数是当前类的引用时,才不会导致再次调用拷贝构造函数,这不仅是逻辑上的要求,也是 C++ 语法的要求。

拷贝构造函数是const 引用

拷贝构造函数的目的是用其它对象的数据来初始化当前对象,并没有期望更改其它对象的数据,添加 const 限制后,这个含义更加明确了。
另外一个原因是,添加 const 限制后,可以将 const 对象和非 const 对象传递给形参了,因为非 const 类型可以转换为 const 类型。如果没有 const 限制,就不能将 const 对象传递给形参,因为 const 类型不能转换为非 const 类型,这就意味着,不能使用 const 对象来初始化当前对象了。

知道了什么是拷贝构造函数,以及拷贝构造函数的注意事项,那么在何时调用它,进行使用呢

拷贝构造函数的调用

我们直接通过代码实例来进行了解,用事实说话

class Student{
public:
    Student(string name = "", int age = 0, float score = 0.0f);  //普通构造函数
    Student(const Student &stu);  //拷贝构造函数
public:
    Student & operator=(const Student &stu);  //重载=运算符
private:
    string m_name;
    int m_age;
    float m_score;
};

Student::Student(string name, int age, float score): m_name(name), m_age(age), m_score(score){ }

//拷贝构造函数
Student::Student(const Student &stu){
    this->m_name = stu.m_name;
    this->m_age = stu.m_age;
    this->m_score = stu.m_score;
    cout<<"Copy constructor was called."<<endl;
}

//重载=运算符
Student & Student::operator=(const Student &stu){
    this->m_name = stu.m_name;
    this->m_age = stu.m_age;
    this->m_score = stu.m_score;
    cout<<"operator=() was called."<<endl;
   
    return *this;
}

Student display(Student s3){
    //cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
    return s3;
}

int main(){
    //stu1、stu2、stu3都会调用普通构造函数Student(string name, int age, float score)
    Student stu1("小明", 16, 90.5);
    Student stu2("王城", 17, 89.0);
    Student stu3("陈晗", 18, 98.0);
   
    Student stu4 = stu1;  //调用拷贝构造函数Student(const Student &stu)
    stu4 = stu2;  //调用operator=()
    stu4 = stu3;  //调用operator=()
   
    Student stu5;  //调用普通构造函数Student()
    stu5 = stu1;  //调用operator=()
    stu5 = stu2;  //调用operator=()
    
    //函数形参,返回值调用
	stu5=display(stu5); 
       
    return 0;
}

在这里插入图片描述
通过代码,以及运行结果中我们可以看到拷贝 构造函数主要在以下方面运行:
(1)将其它对象作为实参
(2)在创建对象的同时赋值
(3) 函数的形参为类类型
(4)函数返回值为类类型

当然也有特例,如下:

Student func(){
    Student s("小明", 16, 90.5);
    return s;
}

Student stu = func();

看到如下
在这里插入图片描述
按理说,这应该执行两次构造函数,但运行结果就显示执行了一次构造函数,此时我们不仅心里产生疑问到底哪里出了问题呢。其实这是编辑器的问题。这是因为,现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率。在现代编译器上,只会调用一次拷贝构造函数,或者一次也不调用,例如在 VS2010 下会调用一次拷贝构造函数,在 GCC、Xcode 下一次也不会调用。

接下来,我们在学习c++拷贝时,也来了解下编译器的返回值优化技术

编译器返回值优化

返回值优化(Return value optimization,缩写为RVO)是C++的一项编译优化技术。它最大的好处是在于: 可以省略函数返回过程中复制构造函数的多余调用,解决 “C++ 中长久以来为人们所诟病的临时对象的效率问题”。

首先我们看下正常函数的调用过程:

class RVO
{
public:
  RVO(){printf("I am in constructor\n");}
  RVO (const RVO& c_RVO) {printf ("I am in copy constructor\n");}
  ~RVO(){printf ("I am in destructor\n");}
  int mem_var;      
};
RVO MyMethod (int i)
{
  RVO rvo;
  rvo.mem_var = i;
  return (rvo);
}
int main()
{
  RVO rvo;
  rvo=MyMethod(5);
}

其中非常关键的地方在于对MyMethod函数的编译处理。

RVO MyMethod (int i)
{
  RVO rvo;
  rvo.mem_var = i;
  return (rvo);
}

如果没有返回值优化这项技术,那么实际上的代码应该是编译器处理后的代码应该是这样的:

RVO MyMethod (RVO &_hiddenArg, int i) 
{
  RVO rvo; 
  rvo.RVO::RVO(); 
  rvo.member = i ;  
  _hiddenArg.RVO::RVO(rvo); 
  return;
  rvo.RVO::~RVO(); 
}

(1)首先,编译器会偷偷地引入一个参数RVO & _hiddernArg,该参数用来引用函数要返回的临时对象,换句话说,该临时对象在进入函数栈之前就已经建立,该对象已经拥有的所属的内存地址和对应的类型;但对应内存上的二进制电位状态尚未改变,即尚未初始化。
以上涉及到一点变量的概念。变量本质上是一个映射单位,每个映射单位里都有三个元素:变量名、类型、内存地址。变量名是一个标识符。当要对某块内存写入数据时,程序员使用相应的变量名进行内存的标识,而地址这个元素就记录了这个内存的地址位置。而相应类型则告诉编译器应该如何解释此地址所指向的内存,因为本质上,内存上有的仅仅只是两种不同电位的组合而已。因而变量所对应的地址所标识的内存的内容叫做此变量的值。
(2)RVO rvo; 这里我们创建一个变量——RVO类的对象rvo;计算机将圈定一块内存地址为该变量使用,并声明类型,告诉编译器以后要怎么解释这块内存。
(3)rvo.RVO::RVO(); 但是以上操作尚未改变该内存上的 二进制的电位状态;改变电位状态的工作由rvo对象的构造函数完成。
(4)_hiddenArg.RVO::RVO(rvo); 用rvo对象来调用 临时对象 的拷贝构造函数 来对临时对象进行构造
rvo.RVO::~RVO(); 函数返回结束; 析构函数内部定义的所有对象。

总结一下一般的函数调用过程中的变量生成传递:
(1)在函数的栈中创建一个名为rvo的对象
(2)关键字 return 后的rvo 表示的用变量rvo来构造需要返回的临时对象
(3)函数返回结束,析构掉在函数内建立的所有对象
(4)继续表达式rvo=MyMethod(5);里的操作
(5)语句结束,析构临时对象

这里,在函数栈里创建的对象rvo在函数MyMethod返回时就被析构,其唯一的操作仅仅是调用函数的返回对象、即所谓的临时对象的复制构造函数,然后就被析构了。特别的,如果对象rvo是一个带有大量数据的变量,那么这一次变量的创建与销毁的开销就不容小觑。

但是,如果开启了返回值优化,那么当编译器识别出了 return后的返回对象rvo和函数的返回对象的类型一致,就会对代码进行优化 。编译器转而会将二者的直接关联在一起,意思就是,对rvo的操作就相当于直接对 临时对象的操作,因而编译器处理后的代码应该是这样的:

RVO MyMethod(RVO &_hiddenArg, int i)
{
  _hiddenArg.RVO::RVO();
  _hiddenArg.member = i;
  Return
}

可以发现,优化后的函数依然可以处理相同的工作,但是省略掉了一次复制构造。

了解了这些疑惑,我们知道c++拷贝是分为浅拷贝和深拷贝的,它们之间有什么区别呢

二、浅拷贝和深拷贝

上来先简单介绍下两者基本概念,然后再从代码上进行一些学习。
浅拷贝: 其实就是对类型数据进行按位复制内存,像我们平常写程序,那些默认的拷贝行为就是浅拷贝。
深拷贝: 当类持有其它资源时,例如动态分配的内存、指向其他数据的指针等,对这些资源一并进行拷贝的行为就是深拷贝。
概念说的再多,也不如直接上代码,来的简单通畅

class Array{
public:
    Array(int len);
    Array(const Array &arr);  //拷贝构造函数
    ~Array();
public:
    int operator[](int i) const { return m_p[i]; }  //获取元素(读取)
    int &operator[](int i){ return m_p[i]; }  //获取元素(写入)
    int length() const { return m_len; }
private:
    int m_len;
    int *m_p;
};

Array::Array(int len): m_len(len){
    m_p = (int*)calloc( len, sizeof(int) );
}

Array::Array(const Array &arr){  //拷贝构造函数
    this->m_len = arr.m_len;
    this->m_p = (int*)calloc( this->m_len, sizeof(int) );
    memcpy( this->m_p, arr.m_p, m_len * sizeof(int) );
}

Array::~Array(){ free(m_p); }

//打印数组元素
void printArray(const Array &arr){
    int len = arr.length();
    for(int i=0; i<len; i++){
        if(i == len-1){
            cout<<arr[i]<<endl;
        }else{
            cout<<arr[i]<<", ";
        }
    }
}

int main(){
    Array arr1(10);
    for(int i=0; i<10; i++){
        arr1[i] = i;
    }
   
    Array arr2 = arr1;
    arr2[5] = 100;
    arr2[3] = 29;
   
    printArray(arr1);
    printArray(arr2);
   
    return 0;
}

显式地定义了拷贝构造函数,它除了会将原有对象的所有成员变量拷贝给新对象,还会为新对象再分配一块内存,并将原有对象所持有的内存也拷贝过来。这样做的结果是,原有对象和新对象所持有的动态内存是相互独立的,更改一个对象的数据不会影响另外一个对象。

由上可见,c++的深拷贝需要把各种动态资源都要拷贝一份,由此可见,执行一次深拷贝,代价是很大的。为了避免这种开销,c++也设计出了另外一种拷贝方式,写时拷贝。

三、写时拷贝

C++中的写时拷贝技术是通过“引用计数”来实现的。也就是说,在每次分配内存时,会多分配4个字节,用来记录有多少个指针指向该内存块。当有新的指针指向该内存块时,就将它的“引用计数”加1;当要释放该空间时,就将相应的“引用计数”减1。当“引用计数”为0时,就释放该内存。当某个指针要修改内存中的内容时再为这个指针分配自己的空间。

如下流程展示:
①构造对象时,new出来的空间当然只被_str指向,所以引用计数初始化为1。

②拷贝构造时,多出来一个对象的指针指向空间,所以引用计数要加一。

引用计数,智能指针就是这么做的,可以参考它

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值