c++拷贝剖析

文章目录

  • 前言
  • 一、什么是拷贝
      • 拷贝构造函数的定义和使用
      • 拷贝构造函数必须是当前类的引用
      • 拷贝构造函数是const 引用
      • 拷贝构造函数的调用
      • 编译器返回值优化
  • 二、浅拷贝和深拷贝
  • 三、写时拷贝


前言

今日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;
}

c++拷贝剖析_第1张图片
由此,我们可以看出拷贝构造函数只有一个参数,它的类型是当前类的引用,而且一般都是 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<
    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;
}

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

当然也有特例,如下:

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

Student stu = func();

看到如下
c++拷贝剖析_第3张图片
按理说,这应该执行两次构造函数,但运行结果就显示执行了一次构造函数,此时我们不仅心里产生疑问到底哪里出了问题呢。其实这是编辑器的问题。这是因为,现代编译器都支持返回值优化技术,会尽量避免拷贝对象,以提高程序运行效率。在现代编译器上,只会调用一次拷贝构造函数,或者一次也不调用,例如在 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。

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

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

推荐一个零声学院免费公开课程,个人觉得老师讲得不错,
分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
TCP/IP,协程,DPDK等技术内容,点击立即学习:服务器课程

你可能感兴趣的:(c++,c++,开发语言,算法)