第17章 特殊成员函数
C++定义了几种只能作为类成员函数说明的函数,它们称为“特殊成员”函数。这些函数影响着给定类对象创建、删除、拷贝以及转换成其它类型对象的方法。这些函数的另一个重要的特性是它们可以由编译器隐含调用。
这些特殊的函数在下表中简要描述:
* 构造函数
* 析构函数
* 临时对象
* 转换
* new和delete运算符
* 用特殊成员函数进行初始化
* 拷贝类对象
所有上面列出的项目在每个类中可以由用户自定义。
特殊成员函数同其它成员函数一样遵循相同的访问规则。这些访问规则在第10章“成员访问控制”中描述。表11.1总结了成员函数和友元函数的行为。
表11.1 函数行为总结
函数类型 | 函数是否从基类中继承 | 能否为虚拟函数 | 能否有返回值 | 成员函数还是友元函数 | 如果用户不提供此函数,编译器能否生成 |
构造函数 | 否 | 否 | 否 | 成员函数 | 是 |
拷贝构造函数 | 否 | 否 | 否 | 成员函数 | 是 |
析构函数 | 否 | 是 | 否 | 成员函数 | 是 |
转换 | 是 | 是 | 否 | 成员函数 | 否 |
赋值(=) | 否 | 是 | 是 | 成员函数 | 是 |
new | 是 | 否 | void* | 静态成员函数 | 否 |
delete | 是 | 否 | void | 静态成员函数 | 否 |
其它成员函数 | 是 | 是 | 是 | 成员函数 | 否 |
友元函数 | 否 | 否 | 是 | 友元函数 | 否 |
与类名称具有一样名称的成员函数是构造函数。构造函数不能有返回值,甚至不能有return语句。说明一个有返回值的构造函数是错误的,取构造函数的地址也是错误的。
如果一个类有构造函数,在程序中每个该类类型的对象在使用之前由此构造函数进行初始化(有关初始化的更多信息参见本章后面的“用特殊成员函数进行初始化”)。
构造函数是在对象的创建点上被调用的。创建对象可以是:
* 全局对象(文件范围或外部链接的)。
* 在一个函数或者小的封闭块中的局部变量。
* 用new运算符创建的动态对象。new操作在程序的堆或自由存储区中分配一个对象。
* 因显式调用构造函数而创建的临时对象(详见本章后面的“临时对象”)。
* 因编译器隐含调用构造函数而创建的临时对象(详见本章后面的“临时对象”)。
* 其它类的数据成员。在创建类类型的对象时,若此类类型由其它类类型变量组成,将会引起该类中每个对象的创建。
* 一个类的基类子对象。创建派生类类型的对象时会引起基类构件的创建。
构造函数的作用
一个构造函数执行各种任务,但对于程序员来说,这些任务是不可见的,你甚至可以不必为构造函数写任何代码。这些任务都同建立一个完全的、正确的类类型对象实例有关。
在MS C++中(同样也在很多其它C++中)一个构造函数:
* 初始化对象的虚拟基指针(vbptr)。如果该类是由虚拟基类派生出的,则这一步要执行。
* 按说明的顺序调用基类和成员的构造函数。
* 初始化对象的虚拟函数指针(vfptr)。如果该类有或者继承了虚拟函数,则这一步要执行,虚拟函数指针指向类的虚拟函数表(v-table),并且使虚拟函数的调用同代码正确绑定(binding)。
* 在构造函数体中执行可选的代码。
当构造函数结束以后,所分配的存储器就是一个给定类类型的对象。因为构造函数执行这些步骤,故虚拟函数的“迟后绑定”形态可以在虚拟函数的调用点得以解决,构造函数也要构造基类以及构造组合对象(作为数据成员的对象),迟后绑定是C++实现对象的多态行为的机制。
说明构造函数的规则
构造函数具有同类名相同的名称。只要遵守重载函数的规则(有关详情参见第12章“重载”),可以说明多个构造函数。
语法
类名称(参量说明表opt) cv-修饰符表opt
C++定义了两种类型的构造函数,缺省的和拷贝的构造函数。如表11.2所述。
表11.2 缺省的和拷贝构造函数
构造函数的种类 | 参量 | 目的 |
缺省构造函数 | 可以无参量调用 | 构造一个类类型的缺省对象 |
拷贝构造函数 | 可以接受对同种类类型的引用作为唯一参量 | 拷贝类类型的对象 |
缺省构造函数不要参量即可调用,但你可以说明一个带有参量表的缺省构造函数,只要让所有的参量有缺省值即可。同样,拷贝构造函数必须接受同一类类型的引用作为唯一参量。但可以提供更多的参量,只要后续的参量具有缺省值即可。
如果你不提供任何构造函数,编译器会试图生成一个缺省的构造函数。同样,如果你没有提供拷贝构造函数,编译器也会试图产生一个。编译器产生的构造函数视为公有的成员函数。如果你说明一个拷贝构造函数,而其第一个参量是一个对象而不是一个引用,则会产生错误。
编译器生成的缺省构造函数建立对象(初始化vftables和vbtables,如前面所述),并调用基类及成员的缺省构造函数,但不会采取其它的行动。基类和成员的构造函数只要存在,是可访问的,并且是无二义性的就会被调用。
编译器生成的拷贝构造函数建立一个对象,并且采用一个成员方式的拷贝来复制要被拷贝的对象的内容。如果基类或成员的构造函数存在,则它们将被调用,否则,就采取位方式的拷贝。
如果所有的基类和该类类型的成员类具有接受一个常量参量的构造函数,则编译器生成的拷贝构造函数接受一个唯一的参量的类型是const type&;否则,编译器生成的拷贝构造函数接受的唯一参量的类型是type &。
你可以用一个构造函数去初始化一个const和volatile对象,但构造函数本身不能说明为const和volatile的。对于构造函数唯一合法的存储类型inline,对于构造函数使用任何其它的存储类修饰符,包括__declspec关键字都会引起错误。构造函数和析构函数除了__stdcall外不能说明为其它的调用约定。
派生类是不能继承基类中的构造函数的。当一个派生类的对象在创建的时候,它是从基类构件开始构造的,然而才进入派生类构件。编译器使用每个基类的构造函数作为完整对象初始化的一部分(除了虚拟派生有所不同,参见本章后面的“初始化基类”)。
显式地调用构造函数
在程序中,为创建给定类型的对象,可以显式地调用构造函数。例如:创建两个Point型对象的描述一条线段的端点,可以写如下代码:
DrawLine(Point(13,22),Point(87,91);
创建了两个Point对象,传递给DrawLine函数,并在该表达式(函数调用)结束后拆除。
另外一个显式地调用构造函数的情况是在一个初始化中:
Point pt=Point(7,11);
创建了一个Point类型的对象,并用接受两个整形参量的构造函数进行初始化。如前面的两个例子中,通过显式地调用构造函数创建的对象是无名的对象,并在它们所创建的表达式中是有生存期的。在本章后面的“临时对象”中将对这一点进行深入的讨论。
在构造函数内调用成员函数和虚拟函数
在构造函数里面调用任何成员函数通常是很安全的,因为在执行第一行用户代码之前,对象已经完全建立起来了(已经初始化了虚表等等)。但是当成员函数调用了其抽象基类的虚拟成员函数时,在构造函数和析构函数调用此成员函数存在着潜在的不安全性。
构造函数可以调用虚拟函数。在调用虚拟函数时,被调用的是在构造函数自身的类中定义的函数(或者从其基类中继承的函数)。下面的例子显示了一个虚拟函数在一个构造函数中被调用时发生的情况。
#include <iostream.h> class Base { public: Base();//缺省构造函数 virtual void f(); //虚拟成员函数 }; Base::Base() { cout<<"Constructing Base sub-object/n "; f();//在构造函数中调用虚拟成员函数 } void Base::f() { cout<<"called Base::f()/n"; } class Derived:public Base { public: Derived();//缺省构造函数 void f(); //该类虚拟函数的实现 }; Derived::Derived() { cout<<"constructing Derived object/n"; } void Derived::f() { cout<<"Called Derived::f()/n"; } void main() { Derived d; }
在上面的程序运行的时候,Derived d的说明会引发下列一系列事件:
1. 类Derived的构造函数(Derived::Derived)被调用。
2. 在进入到Derived类的构造体之前,基类Base的构造函数被调用。
3. Base::Base调用函数f,它是一个虚拟函数。通常被调用的函数会是Derived::f,因为对象d是Derived类型的对象。但因为Base::Base是一个构造函数,此时的对象是一个Derived类型的对象,故Base::f将会被调用。
构造函数与数组
数组的构造只能使用缺省的构造函数。缺省构造函数要么不接受任何参量,要么对于它的所有参量都有缺省的值。数组通常是按升序来构造的,该数组的每一个成员的初始化都是使用同一构造函数。
构造的次序
对于派生类或其成员数据是类类型的类,构造发生的顺序有助于你理解,在任一给定的构造函数中你能够使用对象的哪一部分。
构造与继承
一个派生类的对象是从基类到派生类通过按次序为每个类调用构造函数来构造的。
每个类的构造函数能仅依赖于被完全构造好的它的基类。
有关初始化的完整描述,包括初始化的顺序,见本章后面的“初始化基类及成员”。
构造与组合类型
含有类类型数据成员的类称为组合类。当创建一个组合类类型的对象时,含有类的构造函数在该类的构造函数之前调用。
有关这种情况的初始化,见本章后面的“初始化基类及成员”。
析构函数是“反向”的构造函数。它们在对象被销毁(回收)时调用。设计一个函数为类的析构函数只要在类名之前加上(~)号。例如,类string的析构函数是~string()。
析构函数通常是在一个对象不再需要时,完成“清除”。考虑一下下面类string的说明:
#include <string.h> class String { public: String(char *ch); //说明构造函数 ~String(); //以及析构函数 private: char *_text; };
//定义构造函数
String::String (char *ch) { //动态分配正确数量的存储器 _text=new char[strlen(ch)+1]; //如果分配成功,则拷贝初始化字符串 if(_text) strcpy(_text,ch); }
//定义析构函数String::~String() { //回收先前为此字符串保留的存储器 delete[] _text; }
在前面的代码上。析构函数String::~String 使用delete运算符回收(deallocate)动态分配给text的存储空间。
说明析构函数
析构函数与类名称有相同的名称,并且前缀有~号。
语法
~类名称()
或
类名称::~类名称()
语法的第一种形式用于析构函数说明或定义于类说明之中;第二种形式用于析构函数定义于类说明之外。
有几条规则约束着析构函数的说明。析构函数:
* 不能接受参量
* 不能说明有任何返回类型(包括void)l不能用return语句返回值
* 不能说明为const,volatile或static,但析构函数可以因说明为const,volatile或static的对象的析构而被调用。
* 可以说明为虚拟的。使用虚析构函数,你可以折除对象而不必知道该对象的类型。由于使用虚拟函数机制,将调用该对象的正确的析构函数。注意,在一个抽象类中,析构函数可以说明为纯虚拟函数。
使用析构函数
当下列事件发生时将调用析构函数:
* 一个用new运算符分配的对象显式地使用delete运算符回收。在用delete运算符对一个对象进行回收时,释放的存储器是“最外派生对象”的(most derived object),或者是一个完整对象的而不是代表基类的子对象。对于“最外派生对象”的回收只是在同虚拟析构函数一起工作时才有保证的。回收可能在多重继承的情形下失败,因为在此情形下,类类型信息并不同实际对象所信赖的类型相一致。
* 一个在块范围中的局部(自动)对象超出了其范围。
* 临时对象生存期的结束。
* 全局或静态成员还存在,但程序已经结束。
* 用析构函数的全限定名来显式地调用析构函数(详情见本章后面的“显式的析构函数的调用”)。
前面列表中所描述的情况保证了所有的对象可以用用户自定义的方法折除。如果一个基类或者数据成员有一个可访问的析构函数,同时如果一个派生类没有说明析构函数,编译器会生成一个。编译器产生的析构函数调用基类的析构函数和派生类成员的析构函数。缺省的析构函数是公有的(有关访问属性的详情见第10章“成员访问控制”中的“基类访问说明”)。
析构函数可以自由地调用类成员函数以及访问类成员数据。当在析构函数中调用虚函数时,该函数是在当前正被折除的类的函数(有关详情见下一节,“析构的次序”)。
使用析构函数有两条原则,第一是不能取析构函数的地址。第二是派生类不能继承它的基类的析构函数。相反,如前面所解释的,它们总是覆盖了基类的析构函数。
析构的次序
当某个对象超出了其范围或被删除时,在其完整的析构中会发生如下一些系列的事件:
1. 调用该类的析构函数,执行析构函数体的代码。
2. 非静态成员按它出现在类说明中的反序调用其析构函数。在构造函数中使用的可选成员初始化表的运用不会影响构造或析构的次序(有关初始化成员的详情见本章后面的“初始化基类和成员”)。
3. 非虚拟基类的析构函数按其说明的反序调用。
4. 虚拟基类的析构函数按其说明的反序调用。
非虚拟基类的析构函数
非虚拟基类的析构函数是按基类名说明的反序调用的。考虑下面的类说明:class MultInherit: public Base1,public Base2...
在上面的例子中,Base2的析构函数在Base1的析构函数之前调用。
虚拟基类的析构函数
虚拟基类的析构函数是按它们出现在有向无环图中的反序被调用的(按深度优先,从左到右,后序遍历)。如图11.1所示描绘了一幅继承图。
下面列出了图11.1中各类的类头:
class A
class B
class C:virtual public A,virtual public B
class D:virtual public A,virtual public B
class E:public C,public D,virtual public B
对于类型E的对象要确定虚拟基类析构函数的顺序。编译器用如下算法编一个列表:
1. 从图中的最深点开始(在此例中是E点)遍历左子图。
2. 而左一直遍历到所有的结点都被访问,记下当前结点的名称。
3. 再次访问前一个结点(向下并向右)去证实正在标记的结点是否是一个虚拟基类。
4. 如果被标记的结点是一个虚拟基类,则搜索该列表看是否该结点已经在列表之中了。如果被标记的结点不是一个虚拟基类,则忽略它。
5. 如果该标记的结点还不在列表中,则把它加入到列表的尾部。
6. 返回到图的上一层,并沿着右边的下一条路径遍历图。
7. 转向2继续。
8. 当本结点所有可能遍历的路径用完后,记下该结点。
9. 转向3继续。
10. 继续该过程直到底部的结点再次成为当前结点。
因此对于类E,析构的顺序是:
1. 非虚拟基类E。
2. 非虚拟基类D。
3. 非虚拟基类C。
4. 虚拟基类B。
5. 虚拟基类A。
这一过程产生一个单一条目的有序列表,类名不会出现两次。一旦该列表建成以后,按反序遍历该列表,从最后到最前将调用每个类的析构函数。
当一个类的构造函数和析构函数依赖于其它正在被创建的构件或要长期被保留的构件时,例如,(在图11.1中)当A的析构函数(若其代码的执行)依赖于B的仍然存在时,或反过来构造和析构的次序是非常重要的。
在继承图中,类之间的这种交互依赖具有固有的危险性,因为其后派生的类会改变最后的路径,与此相连也改变了构造和析构的次序。
显式的析构函数的调用
显式地调用析构函数通常是不必要的,但它们在执行放在绝对地址中的对象是很有用的。这些对象通常是用带有一个位置参量的用户自定义的new运算符分配的。delete操作不能回收这一存储器,因为它不是从自由存储区中分配的(有关详情见本章后面的“new和delete运算符”)。然而一个对析构函数的调用能够完成合适的清除工作。要显式调用类String的对象s析构函数,可采用下列语句之一:
s.String::~String();//非虚拟的调用
ps->String::~String(); //非虚拟的调用
s.~String();//虚拟调用
ps->~string(); //虚拟调用
在上面的代码中显示的显式调用析构函数的符号可以直接使用,而无视是否该类型定义了一个析构函数。这使你能用这种显式的调用,而又不必了解该类型是否定义了一个析构函数。如果一个析构函数未定义,则对该析构函数的显式调用不会产生效果。
在某些情况下,对于编译器来说必须创建临时对象。因以下原因要创建这些临时变量:
* 要用一个不同于正被初始化的引用类型的另一类型去初始化一个常量引用。
* 要保存一个函数返回的用户自定义类型的返回值。如果用户程序没有把返回值拷贝到另一对象中时,这一临时对象才会被创建:
UDT Func1(); //说明一个返回用户自定义类型的函数
...
Func1(); //调用Func1函数,但忽略其返回值
//创建一个临时对象以保存返回值
因为返回值并未拷贝到其它对象,故创建一个临时对象。一个要创建临时对象的更普遍的情形是要调用重载运算符函数的表达式求值中。这些重载的运算符返回用户自定义的类型的值(通常不拷贝到其它对象之中)。考虑表达式:
ComplexResult=Complex1+Complex2+Complex3
求出表达式Complex1+Complex2的值,结果存放在临时对象中。接着求表达式temporary+complex3的值,并把结果存放到complexresult中(假定赋值运算符没有被重载)。
* 存一个造型转换为用户自定义类型的转换结果。当一个给定类型的对象显式地转换为用户自定义类型时,新的对象是作为临时对象来构造的。
临时对象有一个生存期,在其创建点和拆除点之间有定义。任何一个创建了多个临时对象的表达式最后是按创建它们的相反的顺序拆除的。析构的发生点如表11.3所示。
表11.3 临时对象的析构点
创建临时对象的原因 | 析构点 |
表达式求值的结果 | 所有因为表达式求值的结果而创建的临时对象在表达式求值结束时(也即分号处)或者在控制表达式(for、if、while、do和switch语句)结束时被拆除。 |
内置的(非重载的) 符(||和&&) | 在右操作数结束之后。在这一析构点,所有因右逻辑运算操作数求值而创建的临时对象被拆除。 |
初始化常量引用 | 如果一个初始化符并不是同要被初始化的引用有相同的l值类型,则创建基于此对象类型的临时对象并用此初始化表达式初始化。这一临时对象在同它绑定在一起的引用对象被拆除以后马上拆除。 |
给定类类型的对象可以转换为其它类型的对象。这可以按以下来完成:
从源类类型构造一个目标类类型的对象,并把结果拷贝到目标对象。这一过程称为构造函数式转换。对象也可以由用户提供的转换函数转换。
当标准转换(在第3章“标准转换”中描述)不能完成从一个给定类型转换到一个类类型时,编译器会选择用户自定义的转换以帮助完成这一工作。除了显式的类型转换,转换还发生在:
* 一个初始化表达式同被初始化的对象有不同的类型。
* 在函数调用中使用的参量类型同函数说明中所说明的参量不匹配 。
* 从函数中返回的对象的类型同函数说明中说明的返回类型不匹配。
* 两个表达式操作数必须有同样的类型。
* 一个控制复述或选择语句的表达式需要从提供的表达式中得到不同的类型。
用户自定义的转换仅用于它没有二义性时,否则会产生一个错误消息。在使用点上将检查二义性,因而,如果可以引起二义性的特征未被使用,只能标明一个类有潜在的二义性,并不会产生任何错误。尽管有很多情形会引起二义性,下面两条是最会引起二义性的原因。
* 一个使用多重继承派生的类,没有说清楚从哪一个基类选择此转换(见第9章“派生类”中的“二义性”)。
* 一个显式的类型转换运算符和为此同一目的构造函数同时存在(见本章后面“转换函数”)。
* 构造函数方式的转换和转换函数方式的转换两者都要遵循第10章“成员访问控制”中所描述的访问控制规则。访问控制仅在转换发现无二义性以后才进行检测。
转换构造函数
可以用单一参量调用的构造函数是用于把参量类型转变成类类型的转换。这种构造函数称为转换构造函数。考虑下面的例子:
class Point { public; Point(); Point(int); ... };
有时需要一种转换,但在类中又不存在转换构造函数。这种转换不能由构造函数完成,编译器不会寻找可以完成该转换的中间类型。比如:假设存在着一个从Point类型到Rect类型的转换以及一个从int到Point类型的转换,但编译器不会通过构造一个中间的Point类型对象来提供一个从int到Rect类型的转换。
转换和常量
尽管内置类型的常量如(int,long和double)可以出现在表达式中,但类类型的常量是不允许的(部分是因为,类通常用来表示复杂的对象,用符号不便于表示)。但是如果提供了对内置类型进行转换的转换构造函数,这些内置类型的常量可以用于表达式,并且转换会引出正确的行为。如下例子,一个Money类具有从long和double类型的转换:
class Money { public; Money(long) Money(double) ... Money operator+(const Money&); //重载加法运算符 };
因此,像下面的表达式可以说明常量值:
Money AccountBalance=37.89;
Money NewBalance=AccountBalance+14L;
第二个例子引起了重载加法运算符的调用(在下一章中讨论)。两个例子都会令编译器在表达式中使用常量以前把它们转换成Money类型。
转换构造函数的缺点
因为编译器会隐含地选择一个转换构造函数,故你将放弃对具体调用函数的控制。如果保留全面的控制是很重要的,就不要说明任何接受单一参量的构造函数。相反,定义一个“帮助”函数以完成这些转换,看如下代码:
#include <stdio.h> #include <stdlib.h> //说明Money类 class Money { public; Money(); //定义只能显式调用的转换函数 static Money Convert(char * ch) {return Money(ch);}; static Money Convert(double d) {return Money(d);}; void Print (){printf("/n%f",_amount);} private: Money(char * ch) {_amount=atof(ch);} Money(double d) {_amount=d;} double _amount; }; void main() { //完成一个从char*到Money 的转换 Money Acct=Money::Convert("57.29"); Acct.Print(); //完成一个从double到Money的转换 Acct=Money::Convert(33.29); Acct.Print(); }
在上面的代码中,转换构造函数是私有的,并且不能在类型转换中使用。但它们可以显式地通过调用Convert函数来激活。因为Convert函数是静态的,它们的访问不需要特别的对象。
转换函数
前一节中介绍,用构造函数进行转换中一种类型的对象可以隐含地转换成特殊的类类型。这一节介绍一种方法,通过它可以提供一个显式的转换方法把给定的类类型转换成其它类型。从某种类类型的转换经常是使用转换函数完成的。转换函数使用下面的语法:
语法
转换函数名称:
operator 转换类型名称()
转换类型名称:
类型指示符表 指针运算符opt
下面的例子说明了一个转换函数把Money类型转换成double类型:
class Money { public: Money(); operator double() { return _amount;} private: double _amount; };
一旦给出了前面的类说明,则可以编写下面的代码:
Money Account;
..
.double CashOnHand=Account;
把CashOnHand用Account进行初始化会引起从类型Account到double的转换。转换函数通常被称为“造型运算符”。 因为它们(如同构造函数的叫法)是在造型类型转换时调用的函数。下面的例子使用了造型或显式的类型转换打印一个Money型对象的当前值:
cout <<(double)Account<<endl;
转换函数可以在派生类中被继承。转换运算符仅仅隐藏基类中转换完全相同类型的转换运算符。因此一个用户自定的operator int函数不会隐藏一定义于基类中的operator short函数。
在进行隐含转换时仅有一个用户自定义的类型转换函数适用。如果没有显式的定义转换函数,编译器不会寻找一个中间类型以通过它进行对象的转换。如果需要的转换引起了二义性,则会产生一个错误。在多个用户自定义的转换可以适用,或在用户自定义的转换和内置的转换同时存在时产生二义性。
下面的例子示例了一个存在潜在二义性的类说明:
#include <string.h> class String { //定义从char * 类型转换的构造函数 String(char *) {strcpy(_text,s);} //定义char * 的转换 operator char* ( ) {return_text,} int operator==(const String& s) {return !strcmp(_text,s._text);} private: char _text[80]; }; int main() { String s("abcd"); char *ch="efg"; //使编译器选择一个转换 return s==ch; }
在表达式s==ch上,编译器有两种选择,并且没有办法决定哪一种更好。它可以使用构造函数把ch转换为一个String类型的对象,然后采用用户自定义的operator==进行比较。
然而它也可以把s转换成一个char* 型的指针(使用转换函数),然后采用指针之间的比较。
因为两种方法之中没有哪一个更好一些,编译器也不能决定比较表达式的意义, 故而产生一个错误。
说明转换函数的规则
下面四条规则适用于说明转换构造函数(参见转换函数语法):
* 类,枚举和typedef名不能在类型指示符表中说明,因而下面的代码会产生错误:
operator struct String {char string_storage;}();
相反,结构String的说明应先于转换函数的说明。
* 转换函数不能带参量,说明参量会引起错误。
* 转换函数已说明了转换类型名称的返回类型;故为转换函数说明任何返回类会产生错误。
* 转换函数可以说明为虚拟的。
C++支持使用new和delete运算符动态分配和回收对象,这些操作从一个称为“自由区”的池中为对象分配存储器。new运算符调用operator new函数,delete运算符调用operator delete函数。
operator new函数
当编译器在程序中碰到如下的一个语句时,它就翻译为一个对operator new的调用。
char *pch=new char[BUFFER_SIZE]
对于分配0字节存储区的要求,operator new返回一个不同对象的指针(也就是说,反复调用operator new会返回不同的指针)。如果没有足够的存储器满足分配的要求,缺省时,通常operator new返回NULL。当然你可以通过写一个定制的例外处理例程来改变这种缺省的行为。然后调用_set_new_handler运行时间库函数并把你的例外处理例程函数的名称作为其参量。有关恢复机制的细节参见下一节“处理无足够存储器的条件”。
operator new的两种范围在表11.4中描述。
表11.4 operator new函数的范围
运算符 | 范围 |
::operator new | 全局的 |
class_name::operator new | 类的 |
operator new函数的第一个参量必须是类型size_t(在STDDEF.H中定义),其返回值总是void*。
在用new运算符分配内部数据类型对象时,或者分配不包含用户自定义型operator new函数的类类型对象时,以及分配任意类型的数组时,调用的是全局operator new函数。当operator new用在定义operator new函数的类类型对象对,调用的是类的operator new函数。
为类定义的operator new函数是一个静态成员函数(因此它不可能是虚拟的)。它对于该类的对象来说隐藏了全局的operator new函数。研究下面的情况,其中new用于分配存储器,并把存储器设为给定的值:
#include <malloc.h> #include <memory.h> class Blanks { public: Blanks() { } void* operator new(size_t stAllocateBlock,char chInit); }; void * Blanks::operator new(size_t stAllocateBlock,char chInit); { void * pvTemp=malloc(stAllocateBlock); if (pvTemp!=0) memset (pvTemp,chInit,stAllocateBlock); return pvTemp; };
对于不同的Blanks类型的对象来说,全局operator new函数被隐藏了。因此下面的代码分配一个Blanks型的对象并初始化为0xa5:
int main() { Blanks *a5=new (0Xa5) Blanks; return a5!=0; };
在括号中提供给new的参量是传递给Blank::operator new作为chInit参量的。然而全局operator new函数是隐藏了的,故按如下调用全局operator new的代码会引起错误。
Blanks *SomeBlanks=new Blanks:
在以前的编译器版本中,非类类型和所有的数组(无论它们是否是类类型数组)使用new运算符分配存储器总是使用全局operator new函数。
从Visual C++5.0开始,编译器开始在类说明中支持成员数组new和delete运算符。
例如:
class X { public: void * operator new[](size_t); void Operator delete[](void*) };
void f(){
X *pX=new X[5],
delete []pX;}
处理无足够存储器条件
对于失败的存储器分配可以采用如下的代码:
int *pi=new int[BIG_NUMBER]; if (pi==0) { cerr<<"Insufficient memory"<<endl; return -1; }
当然有其它的办法处理失败的存储器分配请求,即写一个定制的例程去处理这种失败。然后通过调用_set_new_handler运行函数注册你的处理例程。这种方法在下一节介绍。
使用_set_new_handler
在某些情况下,可以在分配存储器中采取一些矫正的办法使分配要求可得以满足。为在全局operator new函数失败时获得控制,使用_set_new_handler函数(在NEW.H中定义)如下:
#include
#include /
/定义一个在new分配存储器失败时调用的函数
int MyNewHandler(size_t size) { clog<<"Allocation failed.Coalescing heap."<<endl; //调用一个工具函数以恢复一些堆的空间 return CoalesceHeap(); } void main() { //把new失败处理函数设为MyNewHandler _set_new_handler(MyNewHandler); int *pi=new int[BIG_NUMBER]; }
在上面的例子中,main函数的第一个语句是把new的处理函数设为MyNewHandler。第二条语句是使用new运算符分配一大块存储器。当分配失败的时候,控制转向MyNewHandler。
传递给MyNewHandler的参量是要求分配的存储器的字节大小。从MyNewHandler返回的是一个标识以表明是否要再进行一次分配操作。非0值表示应再进行一次,而0值则表示分配失败了。MyNewHandler打印一条警号消息并采取矫正的步骤。如果MyNewHandler返回一个非0值,new运算符再试图分配一次;当MyNewHandler返回0值时,new操作分配企图,把一个0值返回给程序。
_set_new_handler返回的是一个老的new处理函数的地址。因此如果一个新的new处理函数只在短时间内使用,则老的处理函数可以按如下代码还原:
#include <new.h>
..._
PNH old_handler=_set_new_handler(MyNewHandler);
//要求使用MyNewHandler的代码
...//重新安装以前的处理函数_set_new_handler(old_handler);用0参量调用_set_new_handler会引起移去new处理函数,也就是没有缺省的处理函数了。
你可以用任何名称说明new处理函数,但它必须是一个返回int型的函数(非0表示new处理函数继续,0表示失败)。
如果提供了一个用户自定义的operator new函数,该new处理函数不会在失败时自动调用。
_set_new_handler的函数原型以及_PNH在NEW.H中定义:
_PNH_set_new_handler(_PNH);
类型_PNH是一个指向函数的指针,该函数的类型size_t作为唯一的参量并返回int型。
operator delete函数
用new操作动态分配的存储器可以用delete操作释放。delete运算符调用operator delete函数把存储器释放回可用的存储区池中。使用delete操作也会引起类的析构函数的调用(如果有的话)。
operator delete函数也有全局和类范围的两种。对于给定的类只能为其定义一个operator delete函数;一旦定义以后,它会隐藏全局operator delete函数。全局的operator delete函数总是可以为任意类型的数组所调用。
全局operator delete函数在说明中带一个void类型的单一参量,它含有要释放对象的指针。它的返回类型是void(operator delete函数不能有返回值)。类成员operator delete函数有两种形式:
void operator delete(void *);
void operator delete(void *,size_t);
对于给定的类只能提供上述两种不同形式中的一种,第一种形式就像全局operator delete一样工作;第二种形式有两个参量。第一个参量是要释放存储器块的指针,第二个参量是要释放的存储器字节大小。当一个基类中的operatordelete函数用于释放派生类对象时,第二种形式特别有用。
operator delete函数是静态的,因而它不能是虚拟的,operator delete函数必须遵循第10章“成员访问控制”中描述的访问控制。
下面的例子显示了用户设计的operator new和operator delete函数用于记录分配和释放存储器:
#include <iostream.h> #include <stdlib.h> int fLogMemory=0;//是否记录(0=否;非0=是) int cBlocksAllocated=0; //分配的块数 //用户自定义的new操作 void *operator new(size_t stAllocateBLock) { static fInOpNew=0,//保护标志 if(fLogMemory && !fInOpNew) { fInOpNew=1; clog<<"Memory Block"<<++cBlocksAllocated <<"allocated for"<<stAllocateBlcok <<"bytes/n"; fInOpNew=0; } return malloc(stAllocateBlock); }
//用户定义的operator_deletevoid operator delete(void *pvMem) { static fInOpDelete=0; //保护标志 if (fLogMemory &&!fInOpDelete) { fInOpDelete=1; clog<<"Memory block"<<--cBlocksAllocated <<"deallocated/n"; fInOpDelete=0; } free (pvMem); } int main (int argc,char*argv[]) { fLogMemory=1; //打开log标识 if (argc>1) for (int i=0; i<atoi(argv[1]);++i ) { char *pMem=new char[10]; delete [] pMem; }; return cBlocksAllocated; }
上面的代码可以用来检查“存储器漏损”,即从自动堆中分配以后就没有释放的存储器。为完成这一检查,全局new和delete操作被重新定义以统计分配和释放的存储器。
从Visual C++5.0开始,编译器在类说明中支持成员数组new和delete操作,例如:
class X { public: void * operator new[] (size_t); void operatot delete[](void*); }; void f() { X* pX=new X[5]; delete[] pX; }
这一节描述使用特殊成员函数的初始化,它是下面一些初始化论题的扩展。
* 第7章“说明”中的“初始化集合”,描述了如何初始化非类类型数组以及简单类类型对象。这些简单的类类型不可有私有的及保护的成员,它们也不能有基类。
* 构造函数。它解释了如何使用特殊的构造函数初始化类类型对象。
缺省的初始化方法是执行一个位方式的拷贝,把初始化器拷贝给要被初始化的对象。这种技术仅适用于:
* 内部类型的对象。例如:
int i=100;
* 指针。例如:
int iint *pi=&i;
* 引用。例如:
String sFileName("FILE.DAT");
String &rs=sFileName;
* 类型对象,该类不可有保护的或私有的成员,不能有虚拟函数也不能有基类,例如:
struct Point { int x,y; }
Point pt={10,20}; //static storage calss only
通过说明构造函数(有关说明这种函数的详情见本章开始的“构造函数”),类可以说明更多精确的初始化。如果一个对象的类型是有构造函数的类类型,该对象一定会被初始化,否则一定存在着缺省的构造函数。没有特别初始化的对象会激活类的缺省构造函数。
显式的初始化
C++支持两种形式的显式初始化。
* 支持在括号中提供初始表:
String sFileName("FILE.DAT);
在括号中的初始化器表的项是作为类构造函数的参量。这种形式的初始化使得对一个对象使用多个值进行初始化成为可能,并且也可以同new运算符联合使用。如:
Rect *pRect=new Rect(10,15,24,97);
* 支持使用等号初始化语法提供单一初始化器,例如:
String sFileName="FILE.DAT";
尽管前面的例子同第一种形式中的String例子以同样的方式工作,但该语法并不适用于同自由堆中分配的对象一起使用。在等号右边的单一表达式是作为类的拷贝构造函数的参量,因此它必须是能够转换成类类型的某种类型。
注意因为在初始化的上下文中等号(=)同赋值号是不同的,故重载operator=对于初始化没有影响。
等号初始化语法同函数式语法是不同的,尽管在大多数情况下产生的代码是相同的。它们之间的不同在于:当使用等号语法,编译器的行为就像发生了如下的顺序事件:
* 创建一个同要被初始化的对象同一类型的临时对象。
* 把临时对象拷贝到要被初始化的对象之中。
在编译器执行这些步骤之前,构造函数必须是可访问的。尽管编译器在大多数情况下可以忽略临时对象的创建和拷贝步骤,然而一个不可访问的拷贝构造函数会引起等号初始化的失败。考虑下面的例子:
class anInt { anInt (const anInt&); //私有拷贝构造函数 public: anInt(int); //公有的int构造函数 };
...
anInt myInt=7; //破坏了访问控制,试图引用私有拷贝构造函数
anInt myInt(7); //正确;设有调用拷贝构造函数
在函数调用中,传值形式的类型参量以及传值形式的返回对象用下面的代码概念上地进行初始化。
type-name name=value
例如:
String s="C++";
因此,参量类型必须是可以转换为作为参量进行传递的类类型。该类的拷贝构造函数,以及自定义转换运算符或接受实际参量的构造函数必须是公有的。
在使用new运算符的表达式中,从自由堆中分配的对象概念性地用以下形式进行初始化:
type-name name(initializer1,initializer2,...iniatializern)
例如:
String *ps=new String("C++");
对于基类构件和类成员对象的初始化也是概念性地用这种方法初始化的(详见本章后面的“初始化基类及成员”)。
初始化数组
如果一个类有一个构造函数,此类的数组由一构造函数进行初始化。若初始化器表中的项数小于数组元素的个数,则缺省构造函数将用于剩下的数组元素。若此类并未定义缺省构造函数,则初始化器表必须是完全的,也就是数组中的每一个元素必须有一个初始化器。
考虑定义了两个构造函数的Point类:
class Point { public: Point();//缺省构造函数 Point(int,int); //从两个int型的构造函数 ... }
一个Point对象数组可以按如下说明:
Point aPoint[3]=
{ Point(3,3) //使用int,int构造函数
}
aPoint数组的第一个元素使用构造函数Point(int,int)来构造,剩余的两个元素使用缺省构造函数来构造。
静态成员数组(无论是否为const)都可以在它们的定义中(在类说明之外)进行初始化。例如:
class WindowColors { public: static const char *rgszWindowPartList[7]; ... };
const char * WindowColors::rgszWindowPartList[7]=
{ "Active Title Bar","Inactive Title Bar", "Title Bar Text", "Menu Bar", "Menu Bar Text", "Window Background", "Frame" };
初始化静态对象
全局静态对象是按它们出现在源代码中的次序进行初始化,它们按此相反的次序拆除。然而,交叉转换单元中的初始化的次序依赖于连接器如何组织目标文件,析构的次序仍然按对象创建次序的相反次序发生。
局部静态对象的初始化发生在当它们第一次出现在程序流中时,而且它们在程序结束时也是按此相反的次序被拆除的。局部静态对象的析构仅发生在程序流中发现此对象,且此对象被初始化时。
初始化基类及成员
一个派生类的对象是由代表每个基类的构件以及对于此特殊类唯一的一个构件组成。含有成员对象的类对象也可包含其它类的实例。这一节描述当这种类型的类对象在创建时,这些构件对象是如何初始化的。
为了完成初始化,得运用构造函数初始化或ctor初始化语法。
语法
ctor-初始化器
成员初始化器表
成员初始化器表:
成员初始化器
成员初始化器,成员初始化器表
成员初始化器:
完整的类名称(表达式表opt)
标识符(表达式表opt)
用于构造函数中的这一语法,在下一节“初始化成员对象”和“初始化基类”中更详尽介绍。
初始化成员对象
类可以含有类类型成员对象,但要保证满足对这些成员对象的初始化,并要满足如下之一的条件。
* 所含对象的类不要求有构造函数。
* 所含对象的类有一可访问的缺省构造函数。
* 包容类的构造函数显式初始化所含的对象。
下面的例子给出了如何完成这样的初始化:
//说明一个Point类
class Point { public: Point (int x,int y) {_x=x;_y=y;} private: int _x,_y; }
//说明一个包含有Point类型对象的rectangle类
class Rect { public: Rect (int x1,int y1,int x2,int y2); private: Point _topleft,_bottomright; }
//定义类Rect的构造函数,这一构造函数显式地初始化类Point对象
Rect :: Rect(int x1,int y1,int x2, int y2):
_topleft(x1,y1),_bottomright(x2,y2)
{
}
在前面例子中显示的Rect类中含有两个Point类成员对象。它的构造函数显式地初始化了对象_topleft和_bottomright。注意跟在构造函数的后括号后面的是冒号。跟在冒号后面的是成员名和参量,该参量是用来初始化类对象的。
警告:在构造函数中说明的成员初始化顺序不会影响这些成员被构造的次序。成员是按它们在类说明中的说明顺序被构造的。
引用及常量成员对象必须用成员初始化语法(在“初始化基类及成员”中描述) 来初始化。没有其它的办法来初始化这些对象。
初始化基类
直接基类的初始化同成员对象的初始化以同样的方式进行。考虑下面的代码:
//说明类窗口
class Window { public: Windodw (Rect rSize); ... }
//说明类DialogBox直接派生于类Window
class DialogBox:public Window { public: DialogBox(Rect rSize); ... }
//定义DialogBox构造函数。这一构造函数显式地初始化Window子对象
DialogBox:: DialogBox(Rect rSize):Window(rSize)
{
}
注意在DialogBox的构造函数中,Windows基类是使用参量rSize进行初始化的。这一初始化由要初始化的基类名称组成,基类名称后跟随的是由圆括号括起来的传递给基类构造函数的参量表。
在基类的初始化中,不代表基类构件的子对象的对象称为一个“完整对象”。该“完整对象”的类视为该对象的“最外派生类”(most derived)。
代表虚拟基类的子对象是由“最外派生类”的构造函数进行初始化的。这意味着只要说明了虚拟基类的派生类,则最外派生类必须显式初始化虚拟基类,否则虚拟基类必须有一个缺省的构造函数。在最外派生类以外的其它类的构造函数中出现的虚拟基类的初始化将被忽略。
尽管对基类的初始化通常限于直接基类的初始化,但一个类的构造函数可以初始化一个非直接的虚拟基类。
基类及成员的初始化次序
基类和成员对象按如下次序进行初始化:
1. 虚拟基类的初始化按它们出现在有向无环图中的次序。有关使用有向无环图去构造一个唯一子对象列表的详情见第9章“派生类”中的“虚拟基类”(注意,这些子对象的析构是按反序遍历该列表)。有关如何遍历有向无环图的情况见本章前面的“析构的次序”。
2. 非虚拟基类按它们在类说明中出现的次序进行初始化
3. 成员对象的初始化是按它们在类中说明的次序进行初始化。
在构造函数的成员初始化器表中说明的成员初始化器和基类初始化器的次序不会影响到基类及成员对象进行初始化的次序。
初始化的范围
对基类和成员对象的初始化定值在与其说明在一起的构造函数的范围内,因此,它们能够隐含地引用类成员。
两种操作会引起对象的拷贝:
* 赋值。在一个对象的值被赋给另一个对象时,第一个对象是被拷贝给第二个对象的。因此:
Point a,b;
...
a=b;
会引导起b的值拷贝给a。
* 初始化。初始化发生在说明一个新对象时,发生在给函数传递参量的值时,也生在以传值形式从函数返回参量时。
程序员可以为类类型对象定义拷贝的意义。考虑下面的代码:
TextFile a,
b;a.Open("FILE1.DAT");
b.Open("FILE2.DAT");
b=a;
上面的代码可以意味着:“把FILE1.DAT的内容拷贝到FILE2中”;或者它也可意味着:“忽略FILE2.DAT,并把b作为FILE1.DAT的第二个句柄”。程序员有责任为每个类赋以合适的拷贝语义。
拷贝由下列两种方法完成:
* 赋值(使用赋值运算符,operator=)
* 初始化(使用拷贝构造函数)(有关拷贝构函数的详情见本章开头的“说明构造函数的规则”)。
任一给定的类可以实现一种或以上两种拷贝方式。如果两种方法都未实现,赋值将作为一个成员方式(memberwise)赋值,初始化也作为成员方式初始化。在本章后面会讨论有关“成员方式的赋值和初始化”的细节。
拷贝构造函数带有一个类型class-name&的参量,其中class-name是定义拷贝构造函数的类的名称。例如:
class Window { public: Window (const Window&); //定义拷贝构造函数 ... };
注意:只要可能,拷贝构造函数的参量都应该是const class-name&。这可以防止拷贝构造函数无意中修改了要拷贝的对象。这也使得拷贝const对象成为可能。
编译器生成的拷贝
编译器生成的拷贝构造函数,就像用户自定义的拷贝构造函数,带有一个“对类类型引用”的参量类型。只有一个例外是:当所有的基类及成员类都说明有拷贝构造函数,并且该函数带有一个具有const class-name& 型的参量时。在这种情况下,编译器生成的拷贝构造函数的参量是const型。
当拷贝构造函数的参量不是const型时,通过拷贝一个const对象进行初始化会产生一个错误。但反过来就不同:如果参量类型是const型的,通过拷贝一个非const型对象进行初始化不会产生错误。
关于const,编译器产生的赋值运算符也遵循同样的方式。它们带有一个唯一的class-name&类型的参量,除非赋值运算符在所有的基类及成员类中采用的参量类型是const class-name& 。在这种情况下,编译器为该类产生的赋值运算符采用count型参量。
注意:当虚拟基类由拷贝构造函数(无论是编译器产生的,还是自定义的)进行初始化时,它们仅在被构造时初始化一次。
这些含义同拷贝构造函数很相似。当参量类型不是const型时,从一个const型对象赋值会产生一个错误。反之则不同:如果一个const型值赋给一个非const型值,赋值可以进行。
有关重载赋值运算符的更多的情况见第12章“重载”中的“赋值”。
成员方式的赋值与初始化
缺省的赋值和初始化的方法分别是“成员方式”的赋值和“成员方式的初始化”。
“成员方式的赋值”由从一个对象到另一个对象的拷贝过程组成。每次赋值一个成员,就像每个成员单独赋值一样,“成员方式的初始化”也是由从一个对象拷贝到另一个对象的拷贝过程组成。每次初始化一个对象,就像是每个成员单独被初始化。这两种方式的主要区别是:成员方式赋值激活的是每个成员的赋值运算符(operator=),而成员方式初始化激活的是每个成员的拷贝构造函数。成员方式的赋值仅由说明为如下方式的赋值运算符来实现的。
type& type::operator=([const|volatile]type&)
如果下列任何条件存在,则成员方式赋值的缺省运算符不能出现:
* 某成员类中有const成员。
* 某成员类有引用成员。
* 某成员类或其基类有一个私有的赋值运算符(operator=)。
* 某成员类或其基类没有赋值运算符(operator=)。
如果一个类或其基类有一个私有的拷贝构造函数或者是下列任何条件存在,则该类的对于成员方式初始化的缺省构造函数不可能生成:
* 某成员类有const成员。
* 某成员类有引用成员。
* 某成员类或其基类有一个私有的拷贝构造函数。
* 某成员类或基类没有拷贝构造函数。
对于一个给定的类缺省的赋值运算符和拷贝函数总是已经说明了的。除非下面的两个条件都得以满足,否则它们是未定义的。
* 该类并没有为这一拷贝提供自定义的函数
* 程序要求提供此函数。如果碰到一个要求成员方式拷贝的赋值运算符或者初始化符,或者取了类的operator=函数的地址则这一要求是存在的。
若上述两个条件都没有满足,则编译器不必为缺省的赋值运算符和拷贝构造函数产生代码(MSC的编译器采用优化可以去掉这些代码)。尤其当类说明了一个自定义的operator=函数,并以一个对类类型的引用为参量,则不会产生缺省的赋值运算符函数。若类说明了一个拷贝构造函数,则也不会产生缺省的拷贝构造函数。
因此对于给定的类A,下面的说明总是存在的。
//拷贝构造函数和赋值操作的隐含说明
A::A(const A&)A&
A::operator=(const A&)
按上面的标准,这一定义只是在需要的时候才要提供。上面例子中的拷贝构造函数被认为是类的公有成员函数。
缺省的赋值运算符使得一给定类的对象可以赋值给其公有基类型的对象。考虑下面的代码:
class Account { public: //公有成员函数 .. private: double _balance; }; class Checking:public Account { private: int _fOverdraftProtect; };
...
Account account;
Checking checking;
account=checking;
在上面的例子中,选用的赋值运算符是Account:operator=。因为缺省的operator=函数带有一个类型Acconut&的参量,checking的Account型子对象拷贝给account;而fOverdraftProtect没有拷贝。