复制控制主要用于用现有的对象去构造一个新的对象,一般通过复制构造函数和赋值操作符实现。如果类没有定义这两个操作方法,那么编译器会为类自动合成(如果编译器认为有必要)。自动合成的复制构造函数和赋值操作符会把各个成员变量的值进行“简单”地拷贝操作,当类成员当中有指针变量时,对指针变量进行简单的复制会造成意想不到后果,本文主要针对类的复制过程当中有指针变量涉及的问题及其解决办法进行介绍。
1、复制构造函数
复制构造函数(copyconstructor)是一种特殊的构造函数,具有单个形参,该形参(通常也是const)是该类引用。当定义一个新对象并用一个同类型的对象对它进行初始化时,将显式使用复制构造函数,当将该类对象传递给函数或函数返回该类型的对象时,将隐式的使用复制构造函数。如果没有类没有定义复制构造函数,由编译自动为类合成复制构造函数。
1.复制构造可以编译器隐式调用,具体可分为以下几种情况:
(1) 根据另一个同类型的对象显示或隐式的初始化一个对象,通过一个同类对象初始化一个新对象是调用其复制构造函数完成的。
(2) 一个对象,将它作为实参传给一个函数,如果函数(包括成员函数)的参数为类类形的,那么调用过程中先通过复制构造函数构造临时副本,把参数传入函数的栈区,等函数调用返回时再调用类的析构函数,销毁临时副本对象。
(3) 从函数返回一个复制对象,与实参传入函数相同。
(4) 初始化顺序容器的元素。复制构造函数可用于初始化顺序容器中的元素,如vector<Classname>svec(10);编译器使用Classname的默认构造函数创建一个临时对象,后再用复制构造函数来初始化svec元素,每元素重复这个过程。
(5) 根据元素初始化式列表初始化数组
定义类类形数组时,如果没有为类类形数组提供初始化元素,则由默认构造函数初始化一个临时对象后,再用复制构造函数初始化数组元素,如果指定了适当的类型元素,则通过复制构造函数初始化元素。
class Book
{
public:
Book(string name,int num):m_strName(name),m_nNum(num),
m_ptPrice(new double(10.0))
{
cout<<m_strName<<" Constructor"<<endl;
}
Book(string name = "default"):m_strName(name)
{
m_nNum = 100;
m_ptPrice = new double(999);
cout<<m_strName<<" Constructor"<<endl;
}
Book(const Book &b)
{
m_nNum = b.m_nNum;
m_strName = b.m_strName;
m_ptPrice = new double(*b.m_ptPrice);
cout<<m_strName<<" copy constructor..."<<endl;
}
const Book operator=(const Book b)
{
m_nNum = b.m_nNum;
m_strName = b.m_strName;
m_ptPrice = new double(*b.m_ptPrice);
cout<<m_strName<<" operator= call..."<<endl;
return *this;
}
void DisplayBookInf()
{
cout<<"BookName: "<<m_strName
<<" Num: "<<m_nNum<<" Price: "<<*m_ptPrice<<endl;
}
~Book()
{
cout<<m_strName<<" Dstructor"<<endl;
}
public:
string m_strName;
int m_nNum;
double *m_ptPrice;
};
用户程序如下:
int _tmain(int argc, _TCHAR* argv[])
{
cout<<"语句:Book b1(\"c++ primer\",100);"<<endl;
Book b1("c++ primer",100);//两个参数的构造函数调用
cout<<"语句:Book b2 = b1;"<<endl;
Book b2 = b1;//复制构造函数调用
cout<<"语句: b3(b2);"<<endl;
Book b3(b2);//复制构造函数调用
cout<<"语句:Book b4 = \"thinking in c++\";"<<endl;
Book b4 = "thinking in c++";//一个参数构造函数调用
cout<<"语句:GetBook(b1)"<<endl;
GetBook(b1);//复制构造函数与析构函数调用各调用两次
cout<<"TotalValue(b4) "<<endl;
TotalValue(b4);//引用调用不会有构造函数与析构函数调用
cout<<"语句:vector<Book> vec(2)"<<endl;
vector<Book> vec(2);//先调用默认构造函数,再调用复制构造函数,两次
cout<<"语句:BookArrary bArray[2] = {Book(\"The C++ Programming Language\"),b4};"<<endl;
Book bArray[2] = {Book("The C++ Programming Language"),b4};
cout<<"语句:b4 = b1;"<<endl;
b4 = b1;//因为Book重载了"="所以这里调用的是operator =(const Book b)
cout<<"对象析构;"<<endl;
return 0;
}
2 类成员变量含有指针的复制
如果我们的类没有定义复制构造函数,编译器则会自动合成一个复制构造函数。该复制构造函数在上面所分析的各种情况中被调用。默认的复制构造函数把一个类对象的成员变量逐个到另外一个类对象中。如果该对象有指针指向动态内存分配,则通过复制把指针变量的数值复制给新对象相应的指针变量,将会造成两个类对象同时指向同一个内存空间,如果其中有一个对象已经析构,则其指针变量指向的内存空间就会销毁,那么另外一个对象指针成员会指向已经销毁的空间,造成该对象的指针变量悬空,指向非法的空间。
如何解决上述问题呢,一般可以通过两种方法来实现,一是通过深度复制,即在类的复制构造函数中为类的指针变量动态分配新的内存空间,另一是使用智能指针。
2.1 深度复制
如果Book类没有定义复制构造函数,则会通过合成构造函数进制简单的复制赋值操作,也就是说,在复制构造后,其成员指针成员变量指向同一个内存块了,当一个对象被析构后,这该内存块也会被释放回收,造成对象b2的成员指针成野指针,即指向一个已经被释放的内存块。
深度复制就是把对象的指针成员所指向的内存空间全盘拷贝一份,给新的对象,复制构造定义如下。
Book(const Book &b)
{
m_nNum = b.m_nNum;
m_strName = b.m_strName;
m_ptPrice = new double(*b.m_ptPrice);//通过操作符new为指针分配内存空间
}
在我们的book类当中为其定义了复制构造函数,并在该函数中为类的指针变量重新分配了内存空间,这样在复制构造中就不会将成将造成指针悬空问题。
通过深度复制可以解决指针悬空问题,但也有个缺点:如果动态分配的空间很大的话,通过复制构造定义多个对象则会造成很大空间消耗,且复制的速度慢,在程序的运行效率上会变慢。因为它们动态内存空间里的数据都是一样的,所以可能通过另外一种方法解决上述问题:智能指针。
2.2 智能指针
智能指针的本质就是对成员指针所指向的内存块进行相应的引用计数,用于防止被莫名释放。
class String;// 前向声明
class UseCount
{
friend class String;// 友元类型,方便访问其私有成员变量
char *psz;// 指向内存块的指针
int count;// 使用计数
UseCount(char*p):psz(p),count(1){}
~UseCount(){ delete psz;}
};
class String
{
public:
String(char *psz);
String(const String &origStr);
String &operator=(const String &orig);
void OutputStr();
~String();
private:
UseCount *ptr;
};
在String 类当中,其构造函数接收一个指向已经分配好的char 型的内存块的指针用于构造String 类当中的ptr计数类成员,该成员类中又有一个成员指针指向char的内存块。
String(char *psz):ptr(new UseCount(psz)){}
在复制构造函数当中,是将一个类的对象去构造另外一个对象,所以要把指向计数类的指针赋值给新的对象,同时要将String 类当中的计数类中计数值加1。
String(const String &origStr):ptr(origStr.ptr)
{
++ptr->count;
}
在赋值操作当中,相比于复制构造函数,还需要判断位于左值的对象的引用计数是否为1,如果为1则说明左值对象是最后一个引用该内存块的对象,所以在被右值赋值后,应该释放内存块,即执行delete操作。
赋值操作函数定义如下:
String &operator=(const String &orig)
{
++orig.ptr->count;
if (--ptr->count == 0)
{
delete ptr;
}
ptr = orig.ptr;
}
String 类析构函数
在析构函数中,必须考虑的是在执行本函数是,本对象成员所指向的内存块是引用数是否为1,如果为1则说明本对象的成员指针是唯一一个指向该内存块的指针,即该内在块的引用数为1,所以应当在构造时释放该内存块。
~String()
{
if (--ptr->count == 0)
{
delete ptr;
}
}
使用引用计数类实现智能指针最重要的是理解,在复制构造和赋值操作后,对象成员指针所指向的内存块是同一块内存块,所以在智能指针类当中的,复制构造,赋值操作及析构函数中都应该对计数值进行相应的计数和内存的释放。
在应用程序中:
int _tmain(int argc, _TCHAR* argv[])
{
char buf[50] = "SCU618@wangxiaoliang";
char *p = new char[50];
memcpy(p,buf,sizeof(buf));// 拷贝内存块内容到p指向的内存空间
String str1(p);
String str2(str1);// 复制构造函数调用
str2.OutputStr();
String str3 = str1;// 赋值操作符调用
str3.OutputStr();
return 0;
}
通过语句:String str1(p); 内存布局如下。
String str3 = str1;赋值操作符调用。
从上图可以看出,最先构造的str1构建了一个计数类成员对象,该对象有一个指向内存块的指针和一个引用计数值,当前该类成员的计数值为1。String 类在其它对象通过对象str1进行复制构造或者赋值操作后,它们的计数类成员都指向同一个计数类对象,即str1构造的计数类对象,同时进行相应的引用计数操作。在很多情况下,复制函数的定义是必要的,然后在有些类需要完成禁止复制操作,如iostream类就不允许复制,但如果类不显示定义复制构造函数的话,编译器会为类合成一个默认复制构造函数,所以为禁止复制,应该把复制构造函数定义为私有的,声明不对其定义。在C/C++中只函数只声明不定义是合法的,使用任何未定义的函数都会导致链接失败。
参考文献《C++ Primer》