前一篇,我们详细解析了C++的左值,右值和引用等话题。在C++中,引用和拷贝其实是一个性能话题中的此消彼长的存在。我们前文简述引用的问题,我们本篇会讨论有关C++拷贝的话题。
拷贝从字面上的理解就是将一个对象的全部成员或部分成员从内存的一个地方拷贝到内存的另一个地方,其实就是内存数据副本的创建过程。我们在本篇会谈论到C++拷贝操作中两个问题点
- 浅度拷贝(shallow copy)
- 深度拷贝(deep copy)
拷贝内存数据通常是为了修改其副本,而不影响原始的内存数据。但对于读取内存数据的情况下,例如:在函数调用的过程中,参数的传递应尽量避免不必要的参数拷贝。因为拷贝需要时间。
理解拷贝操作
首先,我们考虑一个非常简单的例子,变量j和变量k分别具有不同的内存地址,而k=j这个简单的赋值语句其实就是将j的内存地址所指的位置中的内存值(右值23)拷贝给变量k的内存地址所值的内存中。这个过程就是拷贝,。
int main(void){
int j=23;
int k=j;
}
用户自定义类型的对象拷贝的工作方式相同的
typedef struct{
char name[10];
int age;
} Person;
int main(void){
Person p1={"Kitty",13};
Person p2=p1;
}
因此,我们来稍微归纳一下上面两个例子的拷贝操作的一些特征
- 1)对象拷贝的实质是“源”对象和“目标”对象分别是拥有两个独立的内存区域,也就是其变量有两个不同的内存地址。
- 2)源对象和目标对象的类型相同,或最起码要相容。
- 3)源对象和目标对象的内容数据都具有相同的数据副本。
- 4)修改其中一个对象副本的数据,不会影响另一个对象的副本。
符合上面特征(尤其是第三点)的拷贝操作,称为深度拷贝。
我们对上面的例子更深入一步,如下示例我们通过分配堆来初始化一个Person对象,该对象的堆地址有指针变量p1所持有。
请对比一下前面总结深度复制的四点特征,本示例的拷贝有什么差异?它属于什么类型的拷贝?
typedef struct{
char name[10];
int age;
} Person;
int main(void){
Person *p1=new Person();
p1->name="Lisa";
p1->age=13;
Person *p2=p1;
delete p1;
}
这个示例之所以有些复杂,首先,请读者理解一下这句话:"Person类的指针变量p2和p1的他们本身变量的内存地址(栈中地址)是不一样的,但p1和p2所指向的堆内存地址是一致的。"
备注:如果你毫无概念或似懂非懂,我先叫停吧。请先搞清楚C指针是什么一回事。再来理解C++拷贝的问题,笔者呕血推荐《征服C指针》和《深入理解C指针》两本书中的其中一本
扯远了,上面的指针变量p2仅拷贝了指针变量p1所指向的堆内存地址,所以上文才说,p2和p1所指向的堆内存地址是一致的.换句话说,只要修改p2或p1所指向的堆内存中的数据,都会影响另一个Person对象指针。
显然,无法满足前面的特征4的拷贝操作,称为浅度拷贝(Shellow Copy),通常这一概念是用来考察带有指针类型的对象拷贝操作。只要使用指针的情况下,你拷贝该指针存储的的内存地址,而不是拷贝该内存地址位置中的实际内存数据。
此时,在分析拷贝操作的同时,又可以套用一个非常重要的概念数据对象有两个基本属性。我们拿p1和p2来说吧。只要对变量或表达式的分析,用数据对象来分析是万试万灵的。
- 存储地址(Storage Address):对于p1就是其栈中的局部变量地址(p2同理)
- 数据值(Data Value) 对于p1来说,就是其变量值(右值)就是所指向的堆内存地址。(p2同理)
复杂的示例
本篇会通过实现一个通用类型的自定义String类为例子,讲述C++拷贝的工作方式,以及在那些情况下我们正确使用拷贝,和不需要拷贝时如何避免不必要的拷贝。
Ok,下面是一个山寨版的String类,下面有一个非常重大的bug,你们知道是什么吗?首先它是作为一个反例,我们下文会对这个例子做逐步修进,让其可以成为一个可以上得了项目的自定义工具类。其实这个示例会涵盖很多C++的知识点,我们在文章的结束会梳理一遍
class String
{
private:
char *d_tmp;
unsigned int d_size;
public:
String(const char *str)
{
d_size = strlen(str);
d_tmp = new char[d_size+1];
memcpy(d_tmp, str, d_size);
d_tmp[d_size]=0;
};
~String()
{
delete[] d_tmp;
}
char& operator[](unsigned int idx){
if(idx>=0 && idx<=d_size-1){
return d_tmp[idx];
}
}
char* c_str(){
if(d_tmp!=nullptr){
return d_tmp;
}
}
friend std::ostream &operator<<(std::ostream &, const String &);
};
std::ostream &operator<<(std::ostream &stm, const String &str)
{
stm << str.d_tmp;
return stm;
}
调用示例1
目前这个调用示例看似是没问题的
int main(void)
{
String s = "IT-dog";
std::cout<<s<<std::endl;
}
这个调用示例2,你会发现什么问题吗?
int main(void)
{
String s = "IT-dog";
String s2=s;
std::cout<<s<<std::endl;
std::cout<<s2<<std::endl;
printf("%p\n",s.c_str());
printf("%p\n",s2.c_str());
}
我们不妨在上面的调用示例2中的main方法前,全局重载new[]和delete[]操作符函数,用于跟踪编译器对类String的内存操作,这样会更易发现一些问题
static uint32_t s_allocCount = 0;
void *operator new[](size_t size)
{
s_allocCount++;
std::cout << "第" << s_allocCount << "次- 堆分配 "
<< size << " bytes" << std::endl;
return malloc(size);
}
//即便是测试代码,也要养成有new必有delete的好习惯
void operator delete[](void *p)
{
std::cout << "释放" << p << std::endl;
free(p);
}
然后我们再次调用示例2的程序输出如下
首先,根据前述,我们这里String对象s1和s2的关系其实都持有同样的副本,也就是如下图所示,它们的内部私有char指针都持有相同,s2只是简单地从s1的各个数据成员中获得相同一份变量值的副本而已,这本身就是浅度拷贝的本质。
浅度拷贝的明确定义:在没有显式定义拷贝构造函数的情况下,相同类型的两个对象,源对象通过赋值操作符直接拷贝其数据成员的变量值给目标对象对应的数据成员,使得两个对象的数据成员都获得各自一份相同的数据副本。
上面示例2的结果显示,String对象的内存malloc操作仅发生一次,其堆内存地址是0x564767a99280,但最后的内存释放对该堆地址发生了两次的内存释放,这显然会造成内存泄漏。
因此对于含有指针变量的用户自定义类型的对象浅度拷贝会有两个不可忽视的副作用
- 首先,对其中一个对象的指针变量副本执行的解引并修改指针变量副本指向的堆内存数据,那么参与浅度拷贝的其他对象副本也会同时被修改。
- 其次,对其中一个对象面临垃圾回收的时候会造成内存泄漏。因为参与浅度拷贝的其他的对象副本的指针变量仍然指向刚被释放的内存空间。
深度拷贝
对于存在指针变量的用户自定义类型的多个对象的拷贝操作,我们希望目标对象从源对象的数据成员获得拷贝时,**尤其对于目标对象的指针类型的数据成员,我们希望拷贝能够完成如下操作。
- 目标对象的指针类型的数据成员能够拥有一个由new操作符内存分配后返回唯一的内存地址。
- new分配的堆内存空间从源对象对应指针类型的数据成员所指向的堆内存中那里拷贝内容数据。
此时我们要实现上面所说深度拷贝,我们需要用到C++中的copy构造函数,在上面的示例中,当String对象的s2(目标对象)尝试从s1(源对象)执行拷贝时,C++编译器会调用String类中的copy构造函数,因此我们需要在String类中显式定义一个copy构造函数。请详细看String类实现的ver2.0版本
class String
{
private:
char *d_tmp;
unsigned int d_size;
public:
//普通的自定义构造函数
String(const char *str){...};
//copy构造函数
String(const String& oth):
d_size(oth.d_size)
{
d_tmp = new char[d_size+1];
memcpy(d_tmp, oth.d_tmp, d_size+1);
};
~String(){
delete[] d_tmp;
}
char& operator[](unsigned int idx){...}
char* c_str(){...}
friend std::ostream &operator<<(std::ostream &, const String &);
};
std::ostream &operator<<(std::ostream &stm, const String &str)
{
stm << str.d_tmp;
return stm;
}
因此当你尝试为新创建的String类变量的s2分配一个同类型的s1,在显式定义了copy构造函数的情况下,将源对象赋值给同类型的目标对象时,C++编译器优先调用类中的copy构造函数,这种拷贝操作叫“深度拷贝”
当我们尝试调用前面的调用示例2的运行结果
深度拷贝的内存示意图如下,C++编译器会为目标对象s2,根据copy构造函数的执行新的内存分配,因此s1和s2的char指针分别不同的堆内存区域,因此当你再次执行调用示例的时候,2次的malloc对应两次的free。
拷贝函数的性能问题
OK,我们上文阐述了有关拷贝和copy构造函数后,我们需要正视一个问题就是拷贝函数的副作用会对你的程序的性能大大降低的。因此我们从一个示例中来引入这些问题。
我们下面有一个打印字符串的函数,可能对const关键字有所了解的读者,其实就知道我要描述的问题。
void display_string(String str){
std::cout<<str<<std::endl;
}
同时我们修改ver2.0版本的String类
class String{
....
public:
//copy构造函数
String(const String& oth):
d_size(oth.d_size)
{
std::cout<<"Copy构造函数调用..."<<std::endl;
d_tmp = new char[d_size+1];
memcpy(d_tmp, oth.d_tmp, d_size+1);
};
....
}
调用示例代码3
int main(void)
{
String s1= "IT-dog";
String s2=s1;
display_string(s1);
display_string(s2);
}
我们从运行输出可以知道,第1次和第2次的malloc是在main函数中分别是初始化String对象s1和s2的深度拷贝,这两次的拷贝操作是不可避免的,而第3次和第4次的malloc操作,由于main函数中两次调用了display_string()函数,而display_string函数本身的参数类型是按值传参,我们知道按值传参会导致不必要的拷贝操作,因此后两次的malloc其实是多余的.
因此我们避免不必要的拷贝操作,其实主要责任落在函数的参数类型的设计上,我以前的一篇文章已经提到了常量引用的操作原理,它主要在函数者调用被调用者函数的参数传递的过程中,直接引用调用者的变量本身,从而避免了被调用函数的参数拷贝。
我们只要将display_string函数的的参数修改成对String类型的引用就可以将malloc操作减少了2次,以及free内存释放也相应减少2次,const关键字的使用只要目的是防止被调用者函数篡改调用者函数中的变量值。
void display_string(const String& str){
std::cout<<str<<std::endl;
}
总结
我们全文由浅到深介绍了C++拷贝的原理,以及copy构造函数的使用方法,在同一个上下文中,需要多个相同类型的数据副本,我们需要使用深度拷贝,因为深度拷贝避免了浅度拷贝可能带来的内存泄漏问题。而当我们尝试使用带有copy构造函数的用户自定义类型的对象作为函数的参数时,我们应当尽量养成使用常量引用来修饰我们的用户自定义类型。
后记
读者如果关注我的软件写作风格的话,应该要清楚我的写作思路
- 《C++中的左值,右值和引用》,主要解析C++编译器底层有关引用的操作,为什么我要将左右值和引用放在一起讨论。不论是哪门语言都不能将左右值问题当作编程语言的基础语法来误导读者,因为它这本身是一个很复杂的问题。而是读者应该要充分了理解变量的本质以及一定指针等相关知识后,才涉猎左右值的相关知识。这个问题是学习任何一门静态语言中的坎。我没心思写那些那些千遍一律的基础软文。读者如果还是不理解变量的话,请参考其他C++基础教程。
- C++中存在左值引用,右值引用等这些概念,基于这些概念衍生的是std::move构造函数或叫move原语。前篇和本篇是为后面要介绍的将要介绍的话题打好基础的。后面介绍的内容我将打算设定收费文章。如果你对我的文章对你的C++学习有好处的话,敬请期待,谢谢!!