今日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 对象来初始化当前对象了。
知道了什么是拷贝构造函数,以及拷贝构造函数的注意事项,那么在何时调用它,进行使用呢
我们直接通过代码实例来进行了解,用事实说话
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;
}
通过代码,以及运行结果中我们可以看到拷贝 构造函数主要在以下方面运行:
(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。
②拷贝构造时,多出来一个对象的指针指向空间,所以引用计数要加一。
引用计数,智能指针就是这么做的,可以参考它
推荐一个零声学院免费公开课程,个人觉得老师讲得不错,
分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,
fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,
TCP/IP,协程,DPDK等技术内容,点击立即学习:服务器课程