通常,最好是在程序运行时(而不是编译时)确定诸如使用多少内存等问题。为此,通常的方法是使用string类,它可以处理内存管理的细节,但这样就没有机会更深入地学习内存管理了,因此,这里将直接对问题发起攻击。除非同时执行一系列额外的步骤,如扩展类析构函数、使所有的构造函数与new析构函数协调一致、编写额外的类方法来帮助正确完成初始化和赋值,否则在类构造函数中使用new将导致新问题。
动态内存和类
C++ 使用 new 和 delete 运算符来动态控制内存,在类中使用这些运算符将导致许多新的编程问题。在这种情况下,析构函数将是必不可少的,而不再是可有可无的。有时候,还必须重载赋值运算符,以保证程序正常运行。
静态类成员
静态类成员有个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,类的所有对象共享同一个静态成员。。这对于所有类对象都具有相同值的类私有数据是非常方便的。
注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。
int StringBad::num_strings = 0;
初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字 static。
初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。但如果静态成员是const整数类型或枚举型,则可以在类声明中初始化。
字符串并不保存在对象中。字符串单独保存在堆内存中,对象仅保存了指出到哪里去查找字符串的信息。
不能这样做:
str = s;
这只保存了地址,而没有创建字符串副本。
指针成员指向new分配的内存,当对象过期时,指针也将过期,但指针指向的内存仍被分配,除非使用delete将其释放。删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存,因此,必须使用析构函数,在析构函数中使用delete语句来确保对象过期时,由构造函数使用 new 分配的内存被释放。
警告:在构造函数中使用 new 来分配内存时,必须在相应的析构函数中使用 delete 来释放内存。如果使用 delete[] 来分配内存,则应使用 delete[] 来释放内存。
特殊成员函数
C++自动提供了下面这些成员函数:
- 默认构造函数,如果没有定义构造函数;
- 默认析构函数,如果没有定义;
- 复制构造函数,如果没有定义;
- 赋值运算符,如果没有定义;
- 地址运算符,如果没有定义。
更准确一点,对于最后三个函数的定义,如果程序使用对象的方式要求这样,编译器才自动生成。
1. 默认构造函数
如果没有提供任何构造函数,C++将创建默认构造函数。默认构造函数类似于创建一个常规自动变量,它的值在初始化时是未知的。
如果定义了构造函数,C++将不会定义默认构造函数。
如果希望在创建对象时不显式地对它进行初始化,则必须显式地定义默认构造函数。这种函数没有任何参数,但可以使用它来设置特定的值。
带参数的构造函数也可以是默认构造函数,只要所有参数都有默认值,但这时要注意二义性的问题。
2. 复制构造函数
复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程(好包括按值传递参数),而不是常规的赋值过程中。类的复制构造函数原型通常如下:
Class_name(const Class_name &);
它接受一个指向类对象的常量引用作为参数。
对于复制构造函数,需要知道两点:何时调用和有何功能。
-
何时调用复制构造函数
新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。这在很多情况下都可能发生,最常见的情况是将新对象显式地初始化为现有的对象。例如,假设 motto 时一个 StringBad 对象,则下面 4 种声明都将调用复制构造函数:StringBad ditto(motto); // calls StringBad(const StringBad &) StringBad metoo = motto; // calls StringBad(const StringBad &) StringBad also = StringBad(motto); // calls StringBad(const StringBad &) StringBad * pStringBad = new StringBad(motto); // calls StringBad(const StringBad &)
第2种和第3种声明可能会使用复制构造函数直接创建 metoo 和 also,也可能使用复制构造函数生成一个临时的对象,然后将该临时对象的内容赋给 metoo 和 also,这取决于具体的实现。最后一种声明使用 motto 初始化一个匿名对象,并将该匿名对象的地址赋给 pStringBad 指针。每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象或函数返回对象时,都将使用复制构造函数。编译器生成临时对象时,也将使用复制构造函数。例如,将3个 Vector 对象相加时,编译器可能生成临时的 Vector 对象来保存中间结果。何时生成临时对象随编译器而异,但无论哪种编译器,当按值传递和返回对象时,都将调用复制构造函数。
由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。
-
默认的复制构造函数的功能
默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。(成员复制也称为浅复制),复制的是成员的值。在程序清单 12.3 中,下述语句:
StringBad sailor = sports;
与下面的代码等效(只是由于私有成员是无法访问的,因此这些代码不能通过编译):
StringBad sailor; sailor.str = sports.str; sailor.len = sports.len;
如果成员本身就是类对象,则将使用这个类的复制构造函数来复制成员对象。静态函数(如 num_strings)不受影响,因为它们属于整个类,而不是各个对象。
如果类中包含值在新对象被创建时将发生变化的静态数据成员,则应该提供一个显式复制构造函数来处理计数问题。
如果复制的不是字符串,而是指向字符串的指针,则调用复制构造函数后,将得到两个指向同一个字符串的指针。当operator<<()函数使用指针来显式字符串时,这并不会出问题,但当析构函数被调用时,这将引发问题。析构函数 StringBad 释放 str 指针指向的内存因此释放sailor的效果如下:
delete [] sailor.str;
sailor.str 指针指向的字符串占用的内存将被delete所释放。
然后释放 sports 的效果如下:delete [] sports.str;
sports.str 指向的内存已经被 sailor 的析构函数释放,这将导致不确定的、可能有害的后果。后果是不确定的,一种可能是生成一个未知的字符串,这通常是内存管理不善的后果。另一种后果,试图释放内存两次可能导致程序异常终止。
-
定义一个显式复制构造函数以解决问题
解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写 String 的复制构造函数:StringBad::StringBad(const StringBad &){ num_strings++; len = st.len; str = new char[len+1]; std::strcpy(str, st.str); cout << num_strings << ": \" " << str << "\" object created\n"; }
必须定义复制构造函数的原因在于,一些类成员是使用 new 初始化的、指向数据的指针,而不是数据本身。
警告:如果类中包含了使用new初始化的指针成员,应当定义一个复制构造函数,以复制指向的数据,而不是指针,这被称为深度复制。复制的另一种形式只是复制指针值,千夫指仅浅浅地复制指针信息,而不会深入“挖掘”以复制指针引用的结构。
3. 赋值运算符
ANSI C 允许结构赋值,而 C++ 允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:
Class_name & Class_name::operator=(const Class_name &);
它接受并返回一个指向类对象的引用。例如,StringBad类的赋值运算符的声明如下:
StringBad & StringBad::operator=(const StringBad &);
-
赋值运算符的功能以及何时使用它
将已有的对象赋给另一个对象时,将使用重载的赋值运算符:StringBad headline1("Celery Stalks at Midnight"); ... StringBad knot; knot = headline1; // assignment operator invoked
初始化对象时,并不一定为使用赋值运算符:
StringBad metoo = knot; // use copy constructor, possibly assignment, too
这里,metoo 是一个新创建的对象,被初始化为 knot 的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用=运算符时也允许调用赋值运算符。
与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制。如果成员本身就是类对象,则程序将使用为这个类定义的赋值运算符来复制该成员,但静态数据成员不受影响。
-
赋值的问题出在哪里?
knot = headline1;
为knot调用析构函数时,将显示下面的信息:
"Celery Stalks at Midnight" object deleted, 2 left
为 Headline1 调用析构函数时,显示如下消息(有些实现方式在此之前就异常终止了);
"-|" object deleted, -2 left
出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制的问题,即导致 headline1.str 和 knot.str 指向相同的地址。因此,当对 knot 调用析构函数时,将删除字符串“Celery Stalks at Midnight”; 当对 headline1 调用析构函数时,将试图删除前面已经删除的字符串。正如前面指出的,试图删除已经删除的数据导致的结果是不确定的,因此可能改变内存中的内容,导致程序异常终止。要指出的是,如果操作结果是不确定的,则执行的操作将随编译器而异,包括显示独立声明,或释放隐藏文件占用的硬盘空间。当然,编译器开发人员通常不会花时间添加这样的行为。
-
解决赋值的问题
对于由于默认赋值运算符不合适而导致的问题,解决办法是提供赋值运算符(进行深度复制)定义。其实先与复制构造函数相似,但也有一些差别。- 由于目标对象可能引用了以前分配的数据,所以函数应使用 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) return *this; delete [] str; len = st.len; str = new char [len+1]; std::strcpy(str, st.str); return *this; }
代码首先检查自我复制,这是通过查看赋值运算符右边的地址(&st) 是否与接受对象的地址(this)相同来完成的。如果相同,程序将返回*this,然后结束。
如果地址不同,函数将释放 str 指向的内存,这是因为稍后将把一个新字符串的地址赋给 str。如果不首先使用delete运算符,则上述字符串将保留在内存中,而且由于程序中不再包含指向该字符串的指针,这些内存将被浪费掉。
接下来的操作与复制构造函数相似,即为新字符串分配足够的内存空间,然后将赋值运算符右边的对象中的字符串复制到新的内存单元中。
上述操作完成后,程序返回*this并结束。
赋值操作不创建新的对象,因此不需要调整静态数据成员 num_strings 的值。
将前面介绍的赋值构造函数和赋值运算符添加到 StringBad 类中后,所有的问题都解决了。