3.5 构造函数
3.5.1 构造函数
C++规定:在定义类对象时候,必须调用适当的构造函数完成对象的创建。构造函数(constructor)是与类同名的特殊成员函数,主要用来初始化对象的数据成员。其定义形式如下:
class X{ …… X(…); …… }
其中,X是类名,X()就是构造函数,它可以有参数表。构造函数的声明和定义方法与类的其他成员函数相同,可以在类的内部定义构造函数,也可以先在类中声明构造函数,然后在类外进行定义。在类外定义构造函数的形式如下:
X::X(…){ …… }
构造函数具有以下几个特点:构造函数与类同名,构造函数没有返回类型,构造函数可以被重载,构造函数由系统自动调用,不允许在程序中显式调用。
【例3-4】 一个桌子类的构造函数。
//Eg3-4.cpp #include <iostream> using namespace std; class Desk{ public: Desk(int,int,int,int); //构造函数声明 void setWeight(int w){ weight=w; } private: int weight,length,width,high; }; Desk::Desk(int ww,int l,int w,int h) { //构造函数定义 weight=ww; high=l; width=w; length=h; cout<<"call constructor !"<<endl; } void main(){ Desk d1(2,3,3,5); }
程序运行结果如下,这个输出是构造函数执行过程中产生的:
call constructor !
程序为Desk类创建了一个构造函数,它为Desk的4个数据成员提供初值。构造函数的调用时机是定义对象之后的第一时间,即构造函数是对象的第一个被调用函数。对于main()函数中Desk对象d的定义:
Desk d(2,3,3,5);
编译器可能将其扩展成:
Desk d; d.Desk::Desk(2,3,3,5);
编译器首先为对象d分配内存空间,分配完成后就立即自动调用构造函数初始化d对象的各数据成员。
在定义构造函数时,必须注意以下问题。① 构造函数不能有任何返回类型,即使void也不行;② 构造函数由系统自动调用,不能在程序中显式调用构造函数;③ 定义对象数组或用new创建动态对象时,也要调用构造函数。但定义数组对象时,必须有不需要参数的构造函数(包括无参数构造函数和所有参数有缺省值的构造函数)。④ 构造函数通常应定义为公有成员,因为在程序中定义对象时,要涉及构造函数的调用,尽管是由编译系统进行的隐式调用,但也是在类外进行的成员函数访问。
请参考上述说明,分析下面代码中标识出的错误的原因。
class Desk{ Desk(){ weight=high=width=length=0;} //无参构造函数为private public: void Desk::Desk(int ww,int l,int w,int h) { //错误,不能有返回类型 weight=ww; high=l; width=w; length=h; } void setWeight(int w){ weight=w; } private: int weight,length,width,high; }; void main(){ Desk d(2,3,3,5); //构造函数在定义对象时被调用 d.Desk(1,2,3,4); //错误,构造函数不能被显式调用 Desk a[10]; //错误,须无参构造函数,但它是private Desk *pd; Desk d; //错误,调用Desk::Desk(),但它是private pd=new Desk(1,1,1,1); //调用构造函数Desk::Desk(int,int,int,int) }
3.5.2 无参构造函数
在某些情况下,必须使用无参数的构造函数来定义对象。C++的无参数构造函数包括以下情况。
1.默认构造函数
C++规定,每个类必须有构造函数,如果一个类没有定义任何构造函数,在需要时,编译器将会为它生成一个默认构造函数,类似于下面的形式:
class X { X(){} …… }
默认构造函数是一个无参数的构造函数,负责对象的创建和初始化。如果创建的是全局对象或静态对象,则默认构造函数将对象的位模式全部设置为0(可以理解为将所有数据成员初始化为0);如果创建的是局部对象,则不会对对象的数据成员进行初始值设置。
应当注意的是:只有在类没有定义任何构造函数时,系统才会产生默认构造函数。一旦定义了任何形式的构造函数,系统就不再为类生成默认构造函数。因此,在某些情况下,必须显式定义无参构造函数,以便能够创建无参或数组对象(它们都需要调用无参构造函数)。
【例3-5】 定义point类的无参数构造函数,将point对象的数据成员初始化为0。
//Eg3-5.cpp #include <iostream> using namespace std; class point{ private: int x,y; public: point(int a,int b){ x=a; y=b;} //L1 int getx(){ return x; } int gety(){ return y; } point(){ x=0;y=0; } //L2 显式定义无参构造函数 }; point p0; //L3 point p1(1,1); //L4 调用构造函数point(int,int) void main (){ static point p2; //L5 调用构造函数point() point p3; //L6 调用构造函数point() point a[10]; //L7 调用构造函数point() point *p4; //L8 不调用任何构造函数 p4=new point; //L9 调用构造函数point() cout<<"p0: "<<p0.getx()<<","<<p0.gety()<<endl; cout<<"p1: "<<p1.getx()<<","<<p1.gety()<<endl; //L10 cout<<"p2: "<<p2.getx()<<","<<p2.gety()<<endl; cout<<"p3: "<<p3.getx()<<","<<p3.gety()<<endl; cout<<"p4: "<<p4->getx()<<","<<p4->gety()<<endl; cout<<"a[0]: "<<a[0].getx()<<","<<a[0].gety()<<endl; }
程序运行结果如下。
P0: 0,0 P1: 1,1 P2: 0,0 P3: 0,0 P4: 0,0 a[0]:0,0
在本程序中,语句L3、L5、L6、L7和L9调用无参构造函数point()完成对象的构造,如果将语句L1、L2和L4注释掉,则point类没有任何构造函数,系统会为它生成一个默认构造函数:point::point(){},以完成无参对象p0、p2、p3、*p4和数组对象a的构造。程序执行的结果如下:
P0: 0,0 P2: 0,0 P3: ?,? P4: ?,? a[0]:?,?
其中的“?”表示值未知。
如果将L2注释掉,由于L1已经定义了带参数的构造函数,编译器就不会再为point类生成默认构造函数,L3、L5、L6、L7和L9语句就无法调用无参构造函数创建p0、p2、p3、*p4等对象,程序编译时将产生错误信息“no appropriate default constructor available”。该错误是指找不到正确的无参数的构造函数。
2.缺省参数构造函数
在实际程序中,有些构造函数的参数在多数情况下都比较固定,只是有时会发生变化。对于这种类型的构造函数,可以将它们的参数定义为缺省参数,即为参数提供默认值。
【例3-6】 定义point类的缺省参数构造函数。
//Eg3-6.cpp #include <iostream> using namespace std; class point{ private: int x,y; public: point(int a=0,int b=0) { x=a; y=b;} //缺省参数构造函数 int getx() { return x; } int gety() { return y; } // point(){x=0;y=0;} // L1 }; point p1(1,1); //L2 调用point(int ,int)构造函数 void main (){ static point p2; //L3 调用point(),a、b 默认为0 point p3,a[10]; //L4 调用point(),a、b 默认为0 point *p4; p4=new point; //L5 调用point(),a、b 默认为0 …… //…… }
如果显式定义了无参数的构造函数,又定义了全部参数都有默认值的构造函数,就容易在定义对象时产生二义性。在本程序中,如果去掉语句L1的注释,语句L3、L4、L5将产生编译错误“error C2668: 'point::point' : ambiguous call to overloaded function”。因为point::point()和point::point(int a=0,int b=0)都可以定义对象p0、p2和*p4所指向的对象,系统不能确定应该调用哪个构造函数,就产生了二义性冲突错误。
3.5.3 重载构造函数
在一个类中,构造函数可以重载。与普通函数的重载一样,重载的构造函数必须具有不同的函数原型(即参数个数、参数类型或参数次序不能完全相同)。
【例3-7】 有一日期类,重载其构造函数。
//Eg3-7.cpp #include <iostream> using namespace std; class Tdate{ public: Tdate(); Tdate(int d); Tdate(int m,int d); Tdate(int m,int d,int y); …… //其他公共成员 protected: int month, day, year; }; Tdate::Tdate(){ month=4; day=1; year=1995; cout <<month <<"/" <<day <<"/" <<year <<endl; } Tdate::Tdate(int d) { month=4; day=d; year=1995; cout <<month <<"/" <<day <<"/" <<year <<endl; } Tdate::Tdate(int m,int d) { month=m; day=d; year=1995; cout <<month <<"/" <<day <<"/" <<year <<endl; } Tdate::Tdate(int m,int d,int y) { month=m; day=d; year=y; cout <<month <<"/" <<day <<"/" <<year <<endl; } void main(){ Tdate oneday; //L1 Tdate aday(); //L2,可以吗? Tdate bday1(10); //L3 Tdate bday2=10; //L4 Tdate cday(2,12); //L5 Tdate dday(1,2,1998); //L6 }
语句L1将调用构造函数Tdate(),语句L3、L4将调用构造函数Tdate(int),语句L5将调用构造函数Tdate(int ,int),L6将调用构造函数Tdate(int ,int ,int)。
语句L2不会调用任何构造函数,也不会定义任何对象。事实上,它声明了一个名为aday( )的无参数函数,该函数返回一个Tdate类型的对象。
注意:L4形式的对象定义语句“Tdate bday2=10;”调用的是构造函数Tdate::Tdate(int),该构造函数把一个int类型的整数转换成一个Tdate类型的对象,等价于“Tdate bday2(10);”。仅当类提供了只有一个参数的构造函数的情况下,才能使用L4这样的定义形式。
在一些情况下,可以用带缺省参数的构造函数来替代重载构造函数,达到相同的效果。如上面的Tdate类就可用一个带缺省参数的构造函数来替代所有的重载构造函数,如下所示:
#include <iostream.h> class Tdate{ public: Tdate(int m=4,int d=15,int y=1995){ month=m; day=d; year=y; cout <<month <<"/" <<day <<"/" <<year <<endl; } …… //其他公共成员 protected: int month, day, year; };
虽然这个Tdate类看上去很简洁,但它具有例3-7中Tdate类全部构造函数的功能。
3.5.4 拷贝构造函数
1.拷贝构造函数及指针悬挂问题
拷贝构造函数是一个特殊的构造函数,用于根据已存在的对象初始化一个建新对象。如果没有定义类的拷贝构造函数,在需要的时候,C++编译器将产生一个具有最小功能的默认拷贝构造函数,类似于下面的形式:
X::X(const X&){ }
默认拷贝构造函数以成员按位复制(bit-by-bit)的方式实现成员的复制。按位复制就是把一个对象各数据成员的值原样复制到目标对象中。在没有涉及指针类型的数据成员时,默认复制构造函数能够很好地工作。但当一个类有指针类型的数据成员时,默认拷贝构造函数常会产生指针悬挂问题。
【例3-8】 默认拷贝构造函数引起的指针悬挂问题。
//Eg3-8.cpp #include <iostream> #include<string> using namespace std; class Person{ private: char *name; int age; public: Person(char *Name,int Age); ~Person(); void setAge(int x){ age=x; } void print(); }; Person::Person(char *Name,int Age){ name=new char[strlen(Name)+1]; strcpy(name,Name); age=Age; cout<<"constructor ...."<<endl; } Person::~Person(){ cout<<"destructor..."<<age<<endl; delete name; } void Person::print(){ cout<<name<< "\t The Address of name: "<<name<<endl; } void main(){ Person p1("张勇",21); //L1 Person p2=p1; //L2 p1.setAge(1); p2.setAge(2); p1.print(); p2.print(); }
在Visual C++ 6.0环境下运行时,本程序在产生下面的输出之后就会弹出一个错误信息对话框,错误信息的大意是程序试图删除一个空指针。
constructor .... 张勇 The Address of name: 张勇 张勇 The Address of name: 张勇 destructor...2 destructor...1
从这个输出结果可以看出,程序只调用了一次构造函数,但调用了两次析构函数。输出结果的第2、3行分别是p1.print和p2.print成员函数产生的,这个输出表明p1和p2的name成员指向了同一地内存地址。
构造函数的这次调用发生在语句L1定义p1对象时,语句L2“Person p2=p1;”将调用拷贝构造函数进行p2的初始化。由于Person类没有定义拷贝构造函数,所以C++编译器将为它生成一个具有最小功能的默认拷贝构造函数,以成员按位拷贝的方式将p1各数据成员的值拷贝到p2的对应成员中。对于非指针类型的数据成员age而言,这样的复制并没有什么问题。但在复制指针成员name时就出问题了,它会将p1.name的值拷贝到p2.name中,致使p2和p1的name成员指向了同一内存地址,如图3-3左图所示。
图3-3 拷贝构造函数引起的指针悬挂问题
当遇到main( )最后的“}”时,将首先调用p2的析构函数,该函数中的语句“delete name;”将把p2.name所指向的自由存储单元归还系统,但问题是p1.name此时仍指向此存储区域,这就是所谓的“指针悬挂”问题,如图3-3右图所示。
接下来系统将调用p1的析构函数,这次语句“delete name;”就出问题了,原因是p1.name所指向的存储区域已被p2的析构函数释放了,不能再次释放。
2.定义拷贝构造函数
如果在一个类中要用一个已经存在的对象来初始化另一个对象,就会涉及拷贝构造函数的调用。如果类存在指针类型的数据成员,就应该为它提供拷贝构造函数。
【例3-9】 为例3-8的Person定义拷贝构造函数。
在例3-8的Person类中,增加拷贝构造函数的定义,如下所示。其中……表示与例3-8中的代码相同。
//Eg3-9.cpp …… class Person{ …… public: Person(const Person &p); //拷贝构造函数 …… }; Person:: Person(const Person &p){ name=new char[strlen(p.name)+1]; strcpy(name,p.name); age=p.age; cout<<"Copy constructor ...."<<endl; } …… void main(){ …… }
编译并运行该程序,这次不会有错误,将产生如下输出结果:
constructor .... Copy constructor .... 张勇 The Address of name: 张勇 张勇 The Address of name: 张勇 destructor...2 destructor...1
程序运行结果的第二行输出表明,在定义P2时调用了Person类的拷贝构造函数。
3.拷贝函数的应用说明
① 拷贝构造函数与一般构造函数一样,与类同名,没有返回类型,可以重载。
② 拷贝构造函数的参数常常是const类型的本类对象的引用。
③ 调用拷贝构造函数的时机是用已存在的对象初始化同类的新对象。至少以下几种情况会导致拷贝构造函数的调用。
class X{}; X obj1; X obj2 = obj1; //情况1:调用拷贝构造函数 X obj3(obj1); //情况2:调用拷贝构造函数 f(X o); //情况3:以对象作函数参数时,调用拷贝构造函数 X f(){ X t; …… return t; //情况4:返回类对象时会调用拷贝构造函数 }
情况1和2是用已存在的对象obj1初始化新对象,情况3和4是用对象作为函数的参数或返回值,这4种情况都会调用拷贝构造函数。把情况1误会成以下情况是不正确的:
X obj2; obj2=obj1;
这两条语句先调用无参构造函数建立obj2,再用赋值语句将obj1赋值给obj2,并不会调用拷贝构造函数。但情况1是通过拷贝构造函数初始化建立的obj2对象,不会调用赋值语句。
④ 当类具有指针类型的数据成员时,默认拷贝构造函数就可能产生指针悬挂问题,需要提供显式的拷贝构造函数。其他情况下,默认拷贝构造函数就能完成对象的创建工作了。
⑤ 对拷贝构造函数的调用常在类的外部进行,应该将它指定为类的公有成员。
3.5.5 构造函数与初始化列表
除了在函数体中通过赋值语句为数据成员赋初值外,构造函数还可以采用成员初始化列表的方式对数据成员进行初始化。在某些情况下,还必须采用初始化列表的方式才能完成成员的初始化。成员初始化列表类似于下面的形式:
构造函数名(参数表):成员1(初始值),成员2(初始值),…{ …… }
介于构造函数参数表后面的“:”与函数体{…}之间的内容就是成员初始化列表。其含义是将括号中的初始值赋给该括号前面的成员。
【例3-10】 用初始化列表初始化Tdate的month和day成员。
//Eg3-10.cpp #include <iostream> using namespace std; class Tdate{ public: Tdate(int m,int d,int y); …… //其他公共成员 protected: int month, day, year; }; Tdate::Tdate(int m,int d,int y):month(m),day(d) { year=y; cout <<month <<"/" <<day <<"/" <<year <<endl; } void main(){ Tdate bday2(10,1,2003); }
Tdate类的数据成员month、day与year的初始化方式是不同的,month、day采用初始化列表方式进行初始化,而year采用的是普通函数的初始化方式。
说明:① 构造函数初始化列表中的成员初始化次序与它们在类中的声明次序相同,与初始列表中的次序无关。如对例3-10中的类而言,下面3个构造函数是完全相同的。
Tdate::Tdate(int m,int d,int y):month(m),day(d),year(y){} Tdate::Tdate(int m,int d,int y):year(y),month(m),day(d){} Tdate::Tdate(int m,int d,int y):day(d),year(y),month(m){}
尽管三个构造函数初始化列表中的month、day和year的次序不同,但它们都是按照month→day→year的次序初始化的,这个次序是其在Tdate中的声明次序。它们在功能上与下面的构造函数等效:
Tdate::Tdate(int m,int d,int y) { month=m; year=y; day=d; }
② 构造函数初始化列表先于构造函数体中的语句执行。在一个类中,下列类成员必须采用初始化列表进行初始化:常量成员,引用成员,类对象成员以及派生类构造函数对基类构造函数的调用等。
【例3-11】 常量和引用成员的初始化。
//Eg3-11.cpp #include <iostream> using namespace std; class A{ int x,y; const int i,j; int &k; public: A(int a,int b,int c):i(a),j(b),k(c),x(y) { y=a; cout<<"x="<<x<<"\t"<<"y="<<y<<endl; cout<<"i="<<i<<"\t"<<"j="<<j<<"\t"<<"k="<<k<<endl; } }; void main(){ int m=6; A x(4,5,m); }
本程序的运行结果如下:
x=? y=4 i=4 j=5 k=6
?表示值未知。构造函数初始列表中的x(y)表示用y的值初始化x,由于列表先于构造函数体执行,所以此时尚未执行构造函数体中的“y=a;”语句,y的值未知,致使x未知。当构造函数初始列表执行完后,再执行函数体,才将参数值4赋给y。
本类的i、j、k都是引用或const成员,必须采用初始化列表的方式进行初如化,其他方式都是错误的。例如,若将A的构造函数改写为下面的初始化方式,程序将会出现编译错误。
A(int a,int b,int x){ i=a;j=b;k=x; }
作为构造函数初始列表与函数体执行次序的验证,将例3-11的构造函数改为下面的形式,其余代码不做任何修改,则x和y都将为4。此表明x(a)的确先于“y=x;”执行。
A(int a,int b,int c):i(a),j(b),k(c),x(a) { y=x; …… }