C++自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数
- 默认析构函数,如果没有定义
- 复制构造函数,如果没有定义
- 赋值运算符,如果没有定义
- 地址运算符,如果没有定义
另有移动构造函数和移动赋值运算符
将一个对象赋给另一个对象,编译器将提供赋值运算符的定义,地址运算符的定义,自动生成复制构造函数,因为它创建对象的一个副本。
1.默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。例如,假如定义了一个Klunk类,但没有提供任何构造函数,则编译器将提供下述默认构造函数:
Klunk::Klunk(){} //implicit default constructor
也就是说,编译器将提供一个不接受任何参数,也不执行任何操作的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用构造函数:
Klunk lunk; // invokes default constructor
默认构造函数使Lunk类似于一个常规的自动变量,也就是说,它的值在初始化是未知的。
如果定义了构造函数,C++将不会定义默认构造函数。如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种构造函数没有任何参数,但可以使用它来设置特定的值:
Klunk::Klunk() //explicit default constructor
{
klunk_ct = 0;
...
}
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值。例如,Klunk类可以包含下述内联构造函数:
Klunk(int n = 0) {klunk_ct = n;}
但只能有一个默认构造函数。也就是说,不能这样做:
Klunk() {klunk_ct = n;} // constructor #1
Klunk(int n = 0) {klunk_ct = n;} // ambiguous constructor #2
这为何有二义性呢?请看下面两个声明:
Klunk kar(10); // clearly matches Klunk(int n)
Klunk bus; // could match either constructor
第二个声明既与构造函数#1(没有参数)匹配,也与构造函数#2(使用默认参数0)匹配。这将导致编译器发出一条错误信息。
2.复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:
Class_name(const Class_name &);
它接受一个指向类对象的常量引用作为参数。例如,String类的复制构造函数的原型如下:
StringBad (const StringBad &);
对于复制构造函数,需要知道两点:何时调用和有何功能。
3.何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时, 复制构造函数都将被调用。 这在很多情况下都可能发生, 最常见的情况是将新对象显式地初始化为现有的对象。 例如, 假设motto是一个StringBad对象, 则下面4种声明都将调用复制构造函数:
其中中间的2种声明可能会使用复制构造函数直接创建metoo和also, 也可能使用复制构造函数生成一个临时对象, 然后将临时对象的内容赋给metoo和also, 这取决于具体的实现。 最后一种声明使用motto初始化一个匿名对象, 并将新对象的地址赋给pstring指针。
每当程序生成了对象副本时, 编译器都将使用复制构造函数。 具体地说, 当函数按值传递对象(如程序清单12.3中的callme2()) 或函数返回对象时, 都将使用复制构造函数。 记住, 按值传递意味着创建原始变量的一个副本。 编译器生成临时对象时, 也将使用复制构造函数。 例如, 将3个Vector对象相加时, 编译器可能生成临时的Vector对象来保存中间结果。 何时生成临时对象随编译器而异, 但无论是哪种编译器, 当按值传递和返回对象时, 都将调用复制构造函数。 具体地说, 程序清单12.3中的函数调用将调用下面的复制构造函数:
callme2(headline2);
程序使用复制构造函数初始化sb——callme2()函数的StringBad型形参。
由于按值传递对象将调用复制构造函数, 因此应该按引用传递对象。 这样可以节省调用构造函数的时间以及存储新对象的空间。
4.默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员( 成员复制也称为浅复制) , 复制的是成员的值。 在程序清单12.3中, 下述语句:
程序输出如下:
StringBad sailor = sports;
与下面的代码等效( 只是由于私有成员是无法访问的, 因此这些代码不能通过编译) :
StringBad sailor;
StringBad.str = sports.str;
StringBad.len = sports.len;
如果成员本身就是类对象, 则将使用这个类的复制构造函数来复制成员对象。 静态函数( 如num_strings) 不受影响, 因为它们属于整个类, 而不是各个对象。 图12.2说明了隐式复制构造函数执行的操作。
二、回到Stringbad: 复制构造函数的哪里出了问题
现在介绍程序清单12.3的两个异常之处(假设输出为该程序清单后面列出的) 。 首先, 程序的输出表明, 析构函数的调用次数比构造函数的调用次数多2, 原因可能是程序确实使用默认的复制构造函数另外创建了两个对象。 当callme2()被调用时, 复制构造函数被用来初始化callme2()的形参, 还被用来将对象sailor初始化为对象sports。 默认的复制构造函数不说明其行为, 因此它不指出创建过程, 也不增加计数器num_strings的值。 但析构函数更新了计数, 并且在任何对象过期时都将被调用, 而不管对象是如何被创建的。 这是一个问题, 因为这意味着程序无法准确地记录对象计数。 解决办法是提供一个对计数进行更新的显式复制构造函数:
StringBad::StringBad(const String & s)
{
num_strings++;
...//import stuff to go here
}
提示:
如果类中包含这样的静态数据成员, 即其值将在新对象被创建时发生变化, 则应该提供一个显式复制构造函数来处理计数问题。
第二个异常之处更微妙, 也更危险, 其症状之一是字符串内容出现乱码:
原因在于隐式复制构造函数是按值进行复制的。 例如, 对于程序清单12.3, 隐式复制构造函数的功能相当于:
sailor.str = sport.str;
这里复制的并不是字符串, 而是一个指向字符串的指针。 也就是说, 将sailor初始化为sports后, 得到的是两个指向同一个字符串的指针。 当operator <<()函数使用指针来显示字符串时, 这并不会出现问题。 但当析构函数被调用时, 这将引发问题。 析构函数StringBad释放str指针指向的内存, 因此释放sailor的效果如下:
delete [] sailor.str; // delete the string that ditto.str points to
sailor.str指针指向“Spinach Leaves Bowl for Dollars”,因为它被赋值为sport.str,而sport.str指向的正是上述字符串。所以delete语句将释放字符串“Spinach Leaves Bowl for Dollars”占用的内存。
然后, 释放sports的效果如下:
delete [] sport.str; // effect is undefined
sports.str指向的内存已经被sailor的析构函数释放, 这将导致不确定的、 可能有害的后果。 程序清单12.3中的程序生成受损的字符串, 这通常是内存管理不善的表现。
另一个症状是, 试图释放内存两次可能导致程序异常终止。 例如,Microsoft Visual C++ 2010(调试模式) 显示一个错误消息窗口, 指出“Debug Assertion Failed!”; 而在Linux中, g++ 4.4.1显示消息“double free or corruption”并终止程序运行。 其他系统可能提供不同的消息, 甚至不提供任何消息, 但程序中的错误是相同的。
1. 定义一个显式复制构造函数以解决问题
解决类设计中这种问题的方法是进行深度复制(deep copy) 。 也就是说, 复制构造函数应当复制字符串并将副本的地址赋给str成员, 而不仅仅是复制字符串地址。 这样每个对象都有自己的字符串, 而不是引用另一个对象的字符串。 调用析构函数时都将释放不同的字符串, 而不会试图去释放已经被释放的字符串。 可以这样编写String的复制构造函数:
StringBad::StringBad(const StringBad & st)
{
num_strings++; //handle static member update
len = st.len; //same length
str = new char [len + 1]; //allot space
std::strcpy(str,st.str); //copy string to new location
cout << num_string << ":\" "<< str
<< "\"object created\n";//For Your Information
}
必须定义复制构造函数的原因在于, 一些类成员是使用new初始化的、 指向数据的指针, 而不是数据本身。 图12.3说明了深度复制。
警告:
如果类中包含了使用new初始化的指针成员, 应当定义一个复制构造函数, 以复制指向的数据, 而不是指针, 这被称为深度复制。 复制的另一种形式(成员复制或浅复制) 只是复制指针值。 浅复制仅浅浅地复制指针信息, 而不会深入“挖掘”以复制指针引用的结构。
三 Stringbad的其他问题: 赋值运算符
并不是程序清单12.3的所有问题都可以归咎于默认的复制构造函数, 还需要看一看默认的赋值运算符。 ANSIC允许结构赋值, 而C++允许类对象赋值, 这是通过自动为类重载赋值运算符实现的。 这种运算符的原型如下:
Class_name & Class_name::operator = (const Class_name &);
它接受并返回一个指向类对象的引用。 例如, StringBad类的赋值运算符的原型如下:
StringBad & StringBad::operator = (const StringBad &);
1. 赋值运算符的功能以及何时使用它
- 将已有的对象赋给另一个对象时, 将使用重载的赋值运算符:
StringBad headline1("Celery Stalks at Midnight");
...
StringBad knot;
knot = headline1; // assignment operator invoked
初始化对象时,并不一定会使用赋值运算符:
StringBad metoo = knot; // use copy constructor,possibly assignment ,too
这里,metoo是一个新创建的对象,被初始化为knot的值, 因此使用复制构造函数。 然而, 正如前面指出的, 实现时也可能分两步来处理这条语句: 使用复制构造函数创建一个临时对象, 然后通过赋值将临时对象的值复制到新对象中。 这就是说, 初始化总是会调用复制构造函数, 而使用=运算符时也可能调用赋值运算符。
与复制构造函数相似, 赋值运算符的隐式实现也对成员进行逐个复制。 如果成员本身就是类对象, 则程序将使用为这个类定义的赋值运算符来复制该成员, 但静态数据成员不受影响。
2. 赋值的问题出在哪里
- 程序清单12.3将headline1赋给knot:
knot = headline1;
为knot调用析构函数时, 将显示下面的消息:
为Headline1调用析构函数时, 显示如下消息( 有些实现方式在此之前就异常终止了) :
出现的问题与隐式复制构造函数相同: 数据受损。 这也是成员复制的问题, 即导致headline1.str和knot.str指向相同的地址。 因此, 当对knot调用析构函数时, 将删除字符串“Celery Stalks at Midnight”; 当对headline1调用析构函数时, 将试图删除前面已经删除的字符串。 正如前面指出的, 试图删除已经删除的数据导致的结果是不确定的, 因此可能改变内存中的内容, 导致程序异常终止。 要指出的是, 如果操作结果是不确定的, 则执行的操作将随编译器而异, 包括显示独立声明( Declaration of Independence) 或释放隐藏文件占用的硬盘空间。 当然, 编译器开发人员通常不会花时间添加这样的行为。
3. 解决赋值的问题
对于由于默认赋值运算符不合适而导致的问题, 解决办法是提供赋值运算符( 进行深度复制) 定义。 其实现与复制构造函数相似, 但也有一些差别。
- 由于目标对象可能引用了以前分配的数据, 所以函数应使用delete[]来释放这些数据。
- 函数应当避免将对象赋给自身; 否则, 给对象重新赋值前, 释放内
存操作可能删除对象的内容。 - 函数返回一个指向调用对象的引用。
通过返回一个对象, 函数可以像常规赋值操作那样, 连续进行赋值, 即如果S0、 S1和S2都是StringBad对象, 则可以编写这样的代码:
S0 = S1 = S2;
使用函数表示法时, 上述代码为:
S0.operator=(s1.operator=(S2));
因此, S1.operator=(S2) 的返回值是函数S0.operator=()的参数。因为返回值是一个指向StringBad对象的引用, 因此参数类型是正确的。
下面的代码说明了如何为StringBad类编写赋值运算符:
StringBad & StringBad::operator=(const StringBad & st)
{
if (this == &st) // object assigned to itself
return *this; // all done
delete [] str; // free old string
len = st.len;
str = new char [len + 1]; //get sapce for new string
std::strcpy(str,st.str); //copy the string
return *this; // return reference to invoking object
}
代码首先检查自我复制, 这是通过查看赋值运算符右边的地址(&s) 是否与接收对象(this) 的地址相同来完成的。 如果相同, 程序将返回*this, 然后结束。 第10章介绍过, 赋值运算符是只能由类成员函数重载的运算符之一。
如果地址不同, 函数将释放str指向的内存, 这是因为稍后将把一个新字符串的地址赋给str。 如果不首先使用delete运算符, 则上述字符串
将保留在内存中。 由于程序中不再包含指向该字符串的指针, 因此这些内存被浪费掉。
接下来的操作与复制构造函数相似, 即为新字符串分配足够的内存空间, 然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。
上述操作完成后, 程序返回*this并结束。
赋值操作并不创建新的对象, 因此不需要调整静态数据成员num_strings的值。
将前面介绍的复制构造函数和赋值运算符添加到StringBad类中后,所有的问题都解决了。 例如, 下面是在完成上述修改后, 程序输出的最后几行:
现在, 对象计数是正确的, 字符串也没有被损坏。