4
特殊成员函数:默认构造器,拷贝构造器,销毁器和赋值运算符
by Danny Kale
- 简介
- 构造器
- 从类的构造器调用对象的成员函数
- 不需要的构造器(Trivial Constructors)
- 避免在构造器中重复一样的代码
- 默认构造器是必需的吗?
- 对STL容器的适用性
- 什么时候不需要默认构造器?
- 基本类型的构造器
- 显式的构造器
- 阻止不想要的对象实例化
- 使用成员初始化列表
- 隐含申明的默认构造器的异常说明
- 拷贝构造器
- 模仿虚构造器
- 赋值运算符
- 什么时候需要用户写拷贝构造器和赋值运算符呢?
- 实现拷贝构造器和赋值运算符
- 阻止对象拷贝
- 销毁器
- 构造器和销毁器必须是最小的
- 总结
简介
对象是面向对象程序设计的基本抽象单元。广义的讲,对象是一片内存区域。在类创建的时候就决定了类的属性。一般的每一个类都有四个特殊成员函数:默认构造器,拷贝构造器,赋值运算符和销毁器。如果程序员不显式申明这些成员,编译器隐含的申明他们。这一章总揽的特殊成员函数的语法和他们在类设计和实现中的规则。这一章也考察了有效使用特殊成员函数的技术和制导方针。
构造器
构造器用于初始化对象。默认构造器可以不带任何参数的调用。如果没有用户定义的类构造器,如果类不包括const或引用数据成员,编译器会隐含的申明一个默认构造器。
隐含申明的默认构造器是类的inline public成员;它在创建这种类型对象时执行初始化操作。但是,注意,这些操作不包括对用户申明的数据类型的初始化,或分配空闲内存。例如
class C
{
private:
int n;
char *p;
public:
virtual ~C() {}
};
void f()
{
C obj; // 1隐含定义的构造器被调用
}
程序员在类C中没有申明一个构造器——隐含的构造器被编译器申明定义,以在行1中创建类C的一份实例。合成的构造器不初始化成员数据n和p,之后也不会分配内存。在对象obj创建之后这些数据没有确定的值。
这是因为合成的默认构造器只是执行编译器构造一个对象必须的初始化操作——不是程序员的初始化操作。在这种情况下,C是一个多态的类。这种类型的对象有一个指向类虚函数列表的指针。这个虚指针由隐含定义的构造器初始化。
其他由隐含构造器执行的所需的操作是调用基类的构造器和调用构造器内部对象的构造器。如果程序员没有定义构造器,编译器就不会申明隐含 构造器。例如
class C
{
private:
int n;
char *p;
public:
C() : n(0), p(NULL) {}
virtual ~C() {}
};
void f2()
{
C obj; // 1 用户定义的构造器被调用
}
现在对象obj的数据成员被初始化了,因为用户定义的构造器初始化了他们。但是,注意,用户定义的构造器只初始化了成员n和p。显然,虚指针也必须被初始化——否则,程序将有问题。但是什么时候虚指针的初始化发生呢?编译器增大了用户定义的构造器,它在用户代码之前插入了附加代码以执行初始化虚指针的操作。
从类的构造器调用对象的成员函数
因为虚指针在构造器中的任何用户代码之前被初始化,所以在构造器中调用成员函数是安全的(无论是虚函数还是非虚函数)。这保证了调用的虚函数是当前对象定义的(或来自基类,如果在当前类中没有覆盖虚函数)。然而,基类的构造器中派生类的虚函数是不会被执行的。例如
class A
{
public:
virtual void f() {}
virtual void g() {}
};
class B: public A
{
public:
void f () {} //覆盖了overriding A::f()
B()
{
f(); //调用B::f()
g(); //在B中g()没有被覆盖,因此调用A::g()
}
};
class C: public B
{
public:
void f () {} //覆盖了B::f()
};
请注意,如果对象的成员函数使用了对象的数据成员,首先初始化这些成员数据是程序员的责任——通常方便的方法是成员初始化列表(member-initialization list)(成员初始化列表等一会讨论)。例如
class C
{
private:
int n;
int getn() const { cout<<n<<endl; }
public:
C(int j) : n(j) { getn(); } //正确:n在调用getn()之前初始化
};
不需要的构造器(Trivial Constructors)
如你所见,编译器会为每一个类或结构合成一个默认构造器,如果用户没有定义构造器的话。但是在有些情况,这样一个构造器是
不需要的:
class Empty {}; //类没有基类,虚函数
//或内部对象
struct Person
{
int age;
char name[20];
double salary;
};
int main()
{
Empty e;
Person p;
p.age = 30;
return 0;
}
程序可以实例化Empty和Person对象而不需要构造器。在这种情况下,显式申明的构造器就是
trivial,这意味着程序不需要一个构造器来创建类的实例。当下列情况成立时,类的构造器被认为是不需要的:
类没有虚函数并没有虚基类。
类的所有直接基类都有不需要的构造器。
类中所有的内部类都有不需要的构造器。
可以看到Empty和Person都完全符合这些条件;因此,他们都有不需要的构造器。编译器不会自动合成不需要的构造器,因此产生的代码在尺寸和速度上和C编译器产生代码有一样的效率。
避免在构造器中重复一样的代码
为类定义多个构造器是很普通的。例如,string类可以定义一个接受const char *参数的构造器,另一个接受size_t类型的参数以扩展string的初始化能力,当然还有默认构造器。
class string
{
private:
char * pc;
size_t capacity;
size_t length;
enum { DEFAULT_SIZE = 32};
public:
string(const char * s);
string(size_t initial_capacity );
string();
//...其他成员函数和重载运算符
};
三种构造器都单独执行自己的初始化操作。虽然如此,一些相同的任务——比如分配内存并初始化分配的内存,或赋值给反映分配内存容量的变量——在每一个构造器中都会执行。替代在构造器中重复相同代码的作法是,将相同的代码放在一个非公用的成员函数中。这个函数被每个构造器调用。好处是短的编译时间和更简单的维护:
class string
{
private:
char * pc;
size_t capacity;
size_t length;
enum { DEFAULT_SIZE = 32};
//下面的函数被每一个用户定义的构造器调用
void init( size_t cap = DEFAULT_SIZE);
public:
string(const char * s);
string(size_t initial_capacity );
string();
//...其他成员函数和重载运算符
};
void string::init( size_t cap)
{
pc = new char[cap];
capacity = cap;
}
string::string(const char * s)
{
size_t size = strlen (s);
init(size + 1); //为兼容以NULL结尾的字符串留出空间
length = size;
strcpy(pc, s);
}
string::string(size_t initial_capacity )
{
init(initial_capacity);
length=0;
}
string::string()
{
init();
length = 0;
}
默认构造器是必需的吗?
类可以没有默认构造器。例如
class File
{
private:
string path;
int mode;
public:
File(const string& file_path, int open_mode);
~File();
};
类File有一个用户定义的接受两个参数的构造器。用户定义的构造器的存在阻止类隐含默认构造器的合成。因为程序员也没有定义默认构造器,类File没有默认构造器。没有默认构造器的类限制了使用类的使用。例如,当对象数组初始化的时候,每个数组成员的默认构造器——并且只有默认构造器——被调用。因此,如果你不使用完整的初始化列表,你不能实例化数组:
File folder1[10]; //错误,数组需要默认构造器
File folder2[2] = { File("f1", 1)}; //错误,f2[1]也需要
//一个默认构造器
File folder3[3] = { File("f1", 1), File("f2",2), File("f3",3) }; //OK,
//完整的初始化数组
存储没有默认构造器的对象到STL容器时同样的困难将产生:#include <vector>
using namespace std;
void f()
{
vector <File> fv(10); //错误,File没有默认构造器
vector <File> v; //OK
v.push_back(File("db.dat", 1)); //OK
v.resize(10); //错误,File没有默认构造器
v.resize(10, File("f2",2)); //OK
}
类File是故意缺少默认构造器的吗?可能。或许程序员认为File数组是不需要的,因为数组的每一个对象需要不同的路径和打开方式。然而,缺乏默认构造器影响对大多数类十分重要的通用性。
对STL容器的适用性
为了作为STL容器中的一个元素,对象必须有公用的一个拷贝构造器,一个赋值运算符,和销毁器(详细参见第十章“STL和泛型程序设计”)。
默认的构造器也是一些STL容器操作的需要,如你在前面的例子中看到的。
许多操作系统将同一目录下的文件作为一个文件对象的链表存储。由于忽略File的默认构造器,程序员严重影响了File的用户实现一个象std::list<File>的文件系统的能力。
对于象File这样的类——它的构造器必须用用户提供的值初始化对象,它仍然可以定义一个默认构造器。默认构造器可以从一个连续数据库文件中读取必要的路径和打开方式,以代替从构造器的参数中获得。
什么时候不需要默认构造器?
尽管如此,默认构造器有些时候也是不需要的。一个孤立对象就是这样一个例子。因为孤立有且仅有一个实例,建议你通过使默认构造器不能使用以阻止创建孤立对象的数组和容器。例如
#include<string>
using namespace std;
int API_getHandle(); //系统API函数
class Application
{
private:
string name;
int handle;
Application(); //使默认构造器不可用
public:
explicit Application(int handle);
~Application();
};
int main()
{
Application theApp( API_getHandle() ); //ok
Application apps[10]; //错误,默认构造器不可用
}
类Application没有默认构造器;因此,创建Application对象的数组和容器是不可能的。这种情况下,缺乏默认构造器是有意的(程序员仍需要保证Application有且仅有一个实例被创建。但是,使默认构造器不可用也是实现细节之一)。
基本类型的构造器
象char,int和float这样的基本类型也有构造器就象用户定义类一样。你可以通过显式调用
他们的默认构造器来初始化变量:
int main()
{
char c = char();
int n = int ();
return 0;
}
显式调用默认构造器返回的值等于类的0。换句话说,
char c = char();
等价于
char c = char(0);
当然,初始化基本类型为别的值也是可能的:
float f = float (0.333);
char c = char ('a');
通常,你使用短的记号:
char c = 'a';
float f = 0.333;
可是,语言的这个扩展使程序员能在模板中统一的对待基本类型和用户定义类型。通过运算符new动态分配的基本类型变量能以同样的方式初始化:
int *pi= new int (10);
float *pf = new float (0.333);
显式的构造器
默认情况下,只带一个参数的构造器是一个将参数转换成类对象的隐含转换运算符(见第三章“运算符重载”)。考虑下面具体的例子:
class string
{
private:
int size;
int capacity;
char *buff;
public:
string();
string(int size); //构造器和隐含转换运算符
string(const char *); //构造器和隐含转换运算符
~string();
};
类string有三个构造器:一个默认构造器,一个接受int的构造器和一个从const char *构造字符串的构造器。第二个构造器用于创建一个只有指定大小缓冲区的空string对象。然而,在这种string类中,自动类型转换是不明确的。将一个int转换成string对象是不明智的,尽管构造器这样做是正确的。考虑下面的:
int main()
{
string s = "hello"; //OK,将一个C字符串转换成string对象
int ns = 0;
s = 1; // 1,程序员故意写了ns = 1!?
}
在表达式s= 1;中,程序员只是简单的将变量ns,写错为s。一般编译器检测到了类型不一致,并且发出了错误的消息。一般情况,编译器检测到类型不一致并且发出错误消息。但是,在这之前,编译器首先搜索允许这种表达式的用户定义转换;结果,它发现了接受int的构造器。因此,编译器按程序员写的解释了表达式s= 1;。
s = string(1);
你可能在调用一个接受string参数的函数时与到相同的情况。下面的例子即可能是一种含糊的编码风格也可能是程序员的排字错误。然而,导致隐含转换的类string的构造器将悄悄的被通过:
int f(string s);
int main()
{
f(1); //没有一个显式的构造器,
//这个调用将被扩展成:f ( string(1) );
//这是故意的还是程序员的笔误?
}
为了避免这种隐含的转换,接受一个参数的构造器需要申明为explicit:
class string
{
//...
public:
explicit string(int size); //避免隐含转换
string(const char *); //隐含转换
~string();
};
一个explicit的构造器不象一个隐含的转换运算符,前者使编译器在这种情况下能检测到排字错误:
int main()
{
string s = "hello"; //OK,将C字符串转换成string对象
int ns = 0;
s = 1; //编译期错误;编译期检测到了排字错误
}
所有的构造器为什么不自动申明为explicit呢?在有些情况下,自动类型转换很有用而且工作良好。一个好的例子就是string的第三个构造器:
string(const char *);
隐含的将const char *转换成string对象的类型转换运算符使的上面的代码可以写成:
string s;
s = "Hello";
编译器隐含的转换成
string s;
//伪C++ 代码:
s = string ("Hello"); //建立临时对象并将它赋值给s
另一方面,如果你将构造器申明为explicit,你必须使用显式的类型转换:
class string
{
//...
public:
explicit string(const char *);
};
int main()
{
string s;
s = string("Hello"); //现在需要显式的转换
return 0;
}
遗留的众多C++代码依赖于构造器的隐含转换。C++标准化委员会意识到这一点。为了不让现有的代码报废,隐含转换被保留了。但是,新的关键字,explicit,被引如到语言中,以便让程序员在不想要的时候避免隐含转换。作为规则,只有一个参数的构造器需要被申明为explicit。当需要隐含类型转换时,构造器可以用作隐含转换运算符。
阻止不想要的对象实例化
有时阻止程序员从特定类实例化对象是十分有用的。例如,一个只用来被继承的基类。一个protected构造器禁止创建类的实例,但是不阻止派生类产生实例:
class CommonRoot
{
protected:
CommonRoot(){}//该类不需要实例
virtual ~CommonRoot ();
};
class Derived: public CommonRoot
{
public:
Derived();
};
int main()
{
Derived d; //OK,d的构造器访问
//基类的保护成员
CommonRoot cr; //编译期错误:尝试访问
//CommonRoot的保护成员
}
通过申明纯虚函数同样可以达到禁止类实例化的效果。但是,使用纯虚函数增加了运行时间和空间的开销。当不需要纯虚函数时,你可以使用protected构造器。
使用成员初始化列表
构造器可以有一个用来初始化类数据成员的成员初始化列表(简称mem-initialization)。例如
class Cellphone //1:mem-init
{
private:
long number;
bool on;
public:
Cellphone (long n, bool ison) : number(n), on(ison) {}
};
Cellphone的构造器也可以写成如下形式:
Cellphone (long n, bool ison) //2 在构造器内部初始化数据成员
{
number = n;
on = ison;
}
Cellphone构造器的两种形式没有本质的不同。这是由编译器对成员初始化列表的处理过程决定的。编译器扫描成员初始化列表,然后在构造器的用户代码之前插入初始化代码。因此,第一个例子中的构造器被编译器扩展成第二个例子中的构造器。虽然如此,有下面四种情况的时候,选择成员初始化列表还是在构造器内部初始化有重大意义:
const成员的初始化
引用成员的初始化
要传递参数给基类或内部对象的构造器
成员对象的初始化
在1、2、3种情况下,成员初始化列表是强制性的;在第四种,它是可选择的。考虑下面的具体例子。
const数据成员
类的const数据成员,包括基类和内部对象的const成员,必须在成员初始化列表中初始化。
class Allocator
{
private:
const int chunk_size;
public:
Allocator(int size) : chunk_size(size) {}
};
引用数据成员
引用数据成员必须在成员初始化列表中初始化。
class Phone;
class Modem
{
private:
Phone & line;
public:
Modem(Phone & ln) : line(ln) {}
};
调用基类或成员对象时传递参数
当构造器必须传递到基类的构造器后内部对象的构造器时,必须使用成员初始化列表。
class base
{
private:
int num1;
char * text;
public:
base(int n1, char * t) {num1 = n1; text = t; } //非默认构造器
};
class derived : public base
{
private:
char *buf;
public:
derived (int n, char * t) : base(n, t) //传递参数到基类构造器
{ buf = (new char[100]);}
};
内部对象
考虑下面的例子:
#include<string>
using std::string;
class Website
{
private:
string URL
unsigned int IP
public:
Website()
{
URL = "";
IP = 0;
}
};
类Website有一个类型为std::string的内部变量。语法规则并没有强制必须使用成员初始化列表类初始化这个成员。但是,与在构造器内部初始化相比,选择成员初始化列表能带来较大的性能提升。为什么呢?在构造器内部初始化是非常低效的,因为需要构造成员URL;从""将构造一个临时的std::string对象,然后在赋值给URL。之后,临时对象将被销毁。另一方面,使用成员初始化列表将避免创建和销毁临时对象(使用成员初始化列表的性能提升将在第十二章“优化你的代码”中进一步讨论)。
成员初始化列表的顺序必须符合类成员申明的顺序
由于初始化内部对象的两种形式有性能差距,有些程序员排斥在构造器内部初始化对象——甚至对基础类型也是如此。但是必须注意,成员初始化列表中对象的顺序必须和他们在类中申明的顺序相同。这是因为编译器按类成员申明的顺序转化列表,不管程序员指定的顺序。例如:
class Website
{
private:
string URL; //1
unsigned int IP; //2
public:
Website() : IP(0), URL("") {} //以倒序初始化
};
在成员初始化列表里,成员先初始化IP然后是URL,尽管IP在URL之后申明。编译器将成员初始化列表的顺序转换成类中成员申明的顺序。在这种情况下,倒序是无害的。但是当需要依赖成员初始化列表的顺序的时候,这种转换可能带来意想不到的问题。例如
class string
{
private:
char *buff;
int capacity;
public:
explicit string(int size) :
capacity(size), buff (new char [capacity]) {}//未定义的行为
};
string构造器的成员初始化列表不是类中成员申明的顺序。因此,编译器将列表转换成
explicit string(int size) :
buff (new char [capacity]), capacity(size) {}
成员capacity指定了要new分配的内存大小;但是大小并没有初始化。这种情况的结果是未知的。有两种方法消除这个缺陷:改变成员的申明顺序使capacity在buff之前申明,或将buff的初始化移到构造器内部。
隐含申明的默认构造器的异常说明
隐含申明的默认构造器有一个exception specification(异常说明)(异常说明在第六章“异常处理”讨论)。隐含申明的默认构造器的异常说明包括构造器直接调用的特殊成员函数可能抛出的异常。例如
struct A
{
A(); //可能抛出任何类型的异常
};
struct B
{
B() throw(); //不允许抛出任何异常
};
struct C : public B
{
//隐含申明的C::C() throw;
}
struct D: public A, public B
{
//隐含申明的D::D();
};
类C中隐含申明的构造器不允许抛出任何类型的异常,因为它直接调用类B的构造器,而后者不允许抛出任何异常。另一方面,类D中隐含申明的构造器允许抛出任何异常,因为它直接调用了类A和B的构造器。因为类A的构造器允许抛出任何类型的异常,所以类D的隐含构造器有一个一样的异常说明。换句话说,如果构造器直接调用的任何函数允许抛出任何异常的话,D的隐含申明的构造器就允许抛出任何异常;如果构造器直接调用的任何函数不允许抛出任何异常的话,它就不允许抛出任何异常。象你即将看到的,这条规则适用于其他隐含申明的特殊成员函数。
拷贝构造器
拷贝构造器用于从其他对象来初始化一个对象。如果构造器的第一个参数是C&、 const C&、volatile C&、或const volatile C&,且没有其他参数或其他参数有默认值,那么构造器就是类C的拷贝构造器。如果没有拷贝构造器,编译器会隐含申明一个。如果类C的所有基类都有第一个参数是自身对象的引用的拷贝构造器,并且类C所有的非静态内部对象都有接受自身const对象引用的拷贝构造器,那么隐含申明的拷贝构造器是一个类的inline public成员,有如下形式
C::C(const C&);
否则,隐含申明的拷贝构造器是下面的类型:
C::C(C&);
隐含申明的拷贝构造器有一个异常说明。拷贝构造器的异常申明包括拷贝构造器直接调用的其他特殊成员函数可能抛出的任何异常。
隐含定义的拷贝构造器
如果类没有虚函数、没有虚基类或它的直接基类和内部对象只有不需要的拷贝构造器,那么类的拷贝构造器就是不需要的。编译器隐含定义的拷贝构造器由本类型的对象的一个拷贝来初始化本类型的一个对象(或从其派生类对象的拷贝来初始化)。隐含申明的拷贝构造器执行从参数指定的对象拷贝到本对象以完成初始化的过程,如下面的例子:
#include<string>
using std::string;
class Website //没有用户定义的拷贝构造器
{
private:
string URL;
unsigned int IP;
public:
Website() : IP(0), URL("""") {}
};
int main ()
{
Website site1;
Website site2(site1); //调用隐含申明的拷贝构造器
}
程序员没有为类Website申明拷贝构造器。因为Website有一个std::string类型的内部对象,这个对象恰好有用户定义的拷贝构造器,所以编译器为类Website隐含定义了一个拷贝构造器,并且使用它将对象site1拷贝到site2。合成的拷贝构造器首先调用std::string的构造器,然后以位拷贝的方式将site1拷贝到site2。
有时初学者被鼓励为自己的类定义四种特殊成员函数。就象在类Website中看到的一样,这不仅没有必要,而且有时甚至是不受欢迎的。合成的拷贝构造器(以及赋值运算符,就象你即将看到的)已经“正确工作”了。他们自动调用基类和子对象的构造器,他们初始化虚指针(如果它存在),而且他们执行基本类型的位拷贝。在许多情况下,这完全符合程序员的要求。此外,编译器自动生成的拷贝构造器比用户的代码更有效率,因为它总是使用最优化原则,而用户代码不可能总是使用这些原则。
实现需要的初始化
象普通的构造器一样,拷贝构造器——无论是隐含申明的还是用户定义的——都被编译器扩展了,插入了调用基类和内部对象拷贝构造器的代码。但是,保证虚基对象只被拷贝一次。
模仿虚构造器
与普通函数不一样的是,构造器必须在编译期知道它对象的确切类型,以便能正确构造它。因此,构造器不能被申明为virtual。尽管如此,创建对象时不知道对象类型是很常见的。最简单的模仿虚构造器的方法是通过定义返回本类对象的虚函数。例如
class Browser
{
public:
Browser();
Browser( const Browser&);
virtual Browser* construct()
{ return new Browser; } //虚默认构造器
virtual Browser* clone()
{ return new Browser(*this); } //虚拷贝构造器
virtual ~Browser();
//...
};
class HTMLEditor: public Browser
{
public:
HTMLEditor ();
HTMLEditor (const HTMLEditor &);
HTMLEditor * construct()
{ return new HTMLEditor; }//虚默认构造器
HTMLEditor * clone()
{ return new HTMLEditor (*this); } //虚拷贝构造器
virtual ~HTMLEditor();
//...
};
成员函数clone()和construct()的多态性使你能用正确的类型初始化新对象,而不必知道源对象的确切类型。
void create (Browser& br)
{
br.view();
Browser* pbr = br.construct();
//...使用pbr和br
delete pbr;
}
pbr被赋予右边对象的指针——不管是Browser还是任何从Browser公共派生的任何类。注意对象没有删除它创建的新对象;这是用户的职责。如果这样做,繁殖对象的生存周期将不依赖于它的创造者的生存周期——在是使用这种技术的一个显著的安全问题。
虚成员函数的Covariance
虚构造器的实现依赖于C++最近的叫做虚函数的covariance的改进。代理虚函数必须匹配标志和其所代理函数的返回类型。这个限制在近来被放宽了,以使代理虚函数的返回类型随其所属类的类型变化。因此,公共基类的返回类型可以变成派生类的类型。covariance仅适用于指针和引用。
警告:请注意有些编译器还不支持虚成员函数的covariance。
赋值运算符
用户定义的类C赋值运算符是一个非静态的,非模板成员的,只接受类型为C、C&、const C&、volatile C&或const volatile C&的成员函数。
隐含定义的赋值运算符
如果用户没有定义类的赋值运算符,编译器会隐含申明一个。隐含申明的赋值运算符是类的inline public成员。如果类C的每一个基类都有都有第一个参数为基类const对象引用的的赋值运算符,如果类C的所有非静态内部对象都有第一个参数是本身类型const对象引用的赋值运算符,那么隐含申明的赋值运算符有如下形式:
C& C::operator=(const C&);
否则,隐含申明的赋值运算符是下面类型:
C& C::operator=(C&);
隐含申明赋值运算符有异常说明。异常说明包括所有赋值运算符直接调用特殊成员函数可能抛出的异常。如果赋值运算符是隐含申明的,如果类没有虚函数或虚基类,如果它的所属类的直接基类和内部对象有不需要的赋值运算符,那么这个赋值运算符就是不需要的。
赋值运算符的模拟继承
因为程序员不申明赋值运算符赋值运算符就是隐含申明的,基类的赋值运算符总是被派生类的赋值运算符所隐藏。为了在派生类中扩展——而不是代理——赋值运算符,你必须先显式调用基类的赋值运算符,然后在增加派生类需要的操作。例如
class B
{
private:
char *p;
public:
enum {size = 10};
const char * Getp() const {return p;}
B() : p ( new char [size] ) {}
B& operator = (const C& other);
{
if (this != &other)
strcpy(p, other.Getp() );
return *this;
}
};
class D : public B
{
private:
char *q;
public:
const char * Getq() const {return q;}
D(): q ( new char [size] ) {}
D& operator = (const D& other)
{
if (this != &other)
{
B::operator=(other); //先显式调用基类的赋值运算符
strcpy(q, (other.Getq())); //增加扩展
}
return *this;
}
};
什么时候需要用户写拷贝构造器和赋值运算符呢?
合成的拷贝构造器和赋值运算符执行成员级别的拷贝。对大多数用法来说这是需要的。但是,它对于包含指针、引用、句柄的类来说这是灾难性的。在很多情况下,你必须定义拷贝构造器和赋值运算符以避免混淆。当相同的资源被不同的对象同时使用时,混淆发生类。例如
#include <cstdio>
using namespace std;
class Document
{
private:
FILE *pdb;
public:
Document(const char *filename) {pdb = fopen(filename, "t");}
Document(FILE *f =NULL) : pdb{}
~Document() {fclose(pdb);} //错误,没有定义拷贝构造器
//或赋值运算符
};
void assign(Document& d)
{
Document temp("letter.doc");
d = temp; //混淆;d和temp指向同一个文件
}//temp's销毁器关闭了d正在使用的文件
int main()
{
Document doc;
assign(doc);
return 0;
//doc现在使用的文件已被关闭了。
}}//天啊!doc的销毁器被调用,又关闭了‘letter.doc’一边
因为类Document没有实现拷贝构造器和赋值运算符,所以编译器隐含的申明他们。但是合成的拷贝构造器和赋值运算符导致混淆。试图打开或关闭一个文件两次是未定义的行为。解决这个问题的方法是定义恰当的拷贝构造器和赋值运算符。然而请注意,混淆来自语言的底层构造(在这里是文件指针),因此内部fstream对象可以自动执行必要的检查。这时用户定义的拷贝构造器和赋值运算符时不必要的。当用string对象代替暴露的char指针时,会发生同样的问题。如果你在类Website中使用char指针而不是std::string,你同样面对混淆的问题。
实现拷贝构造器和赋值运算符
另一个可以从前面的例子中得出的总结是,无论什么时候你都自己定义拷贝构造器和赋值运算符。你只定义一个的话,编译器创建另一个——但是它不会如你所愿的工作。
“Big Three Rule”还是“Big Two Rule”?
著名的“Big Three Rule”说:如果类需要任何三个重要成员函数(拷贝构造器、赋值运算符和销毁器)的一个,那么它也必须要其他两个。一般的,这条规则适用于需要动态分配内存的类。但是,许多其他的类仅需要两个(拷贝构造器和赋值运算符)用户定义的重要函数;销毁器并不重是必需的。考虑下面的例子:
class Year
{
private:
int y;
bool cached; //对象是否缓存?
public:
//...
Year(int y);
Year(const Year& other) //缓存不能被拷贝
{
y = other.getYear();
}
Year& operator =(const Year&other) //缓存不能被拷贝
{
y = other.getYear();
return *this;
}
int getYear() const { return y; }
};//类Year不需要销毁器
类 Year没有动态分配内存,在它的存在期内也没又获得其他资源。所以不需要销毁器。但是,类需要用户定义的拷贝构造器和赋值运算符以保证成员 cached的值不被拷贝,因为这个值是被对象分别处理的。
当用需要户定义的拷贝构造器和赋值运算符时,避免自己赋值给自己和指针混淆是十分重要的。通常,完全实现其中的一个就行了,另一个可以按第一个的方法定义。例如
#include <cstring>
using namespace std;
class Person
{
private:
int age;
char * name;
public:
int getAge () const { return age;}
const char * getName() const { return name; }
//...
Person (const char * name = NULL, int age =0) {}
Person & operator= (const Person & other);
Person (const Person& other);
};
Person & Person::operator= (const Person & other)
{
if (&other != this) //警惕自己赋值给自己
{
size_t len = strlen( other.getName());
if (strlen (getName() ) < len)
{
delete [] name; //释放当前缓冲区
name = new char [len+1];
}
strcpy(name, other.getName());
age = other.getAge();
}
return *this;
}
Person::Person (const Person & other)
{
*this=other; //OK,用户定义的赋值运算符被调用
}
阻止对象拷贝
有些情况下希望禁止用户拷贝或赋值到一个新对象。你可以通过显式定义拷贝构造器和赋值运算符为private来禁止这两种行为:
class NoCopy
{
private:
NoCopy& operator = (const NoCopy& other) { return *this; }
NoCopy(const NoCopy& other) {/*..*/}
public:
NoCopy() {}
//...
};
void f()
{
NoCopy nc; //正确,调用默认拷贝构造器 fine, default constructor called
NoCopy nc2(nc); //错误;试图调用一个私有的拷贝构造器
nc2 = nc; //also a compile time error; operator= is private
}
销毁器
销毁器销毁对象。它不接受参数也没有返回类型(甚至没有void)。const和volatile限制在对象销毁后不在适用;因此,销毁器不能被const、volatile、或const volatile对象调用。如果类没有用户定义的销毁器,编译器会隐含申明一个。隐含申明的销毁器是类的inline public成员,并且有异常说明。异常说明包括它直接调用的特殊成员函数可能抛出的所有异常。
如果销毁器是隐含申明的,如果类的全部直接基类和内部对象都有不需要的销毁器,那么类的销毁器就是不需要的。否则,销毁器就是必需的。销毁器调用直接基类和成员对象的销毁器。调用以他们构造顺序的相反顺序进行。所有的销毁器以他们的名字调用,忽略任何可能的派生类的虚代理销毁器。例如
#include <iostream>
using namespace std;
class A
{
public:
virtual ~A() { cout<<"destroying A"<<endl;}
};
class B: public A
{
public:
~B() { cout<<"destroying B"<<endl;}
};
int main()
{
B b;
return 0;
};
程序显示
destroying B
destroying A
这是因为编译器将用户定义的类B销毁器变化成
~B()
{
//用户代码在下面
cout<<"destroying B"<<endl;
//编译器插入的伪C++代码
this->A::~A(); //销毁器使用他们的名字调用
}
尽管类A的销毁器是虚函数,但是插入类B销毁器中的调用静态的(通过名字调用函数绕过动态绑定机制)
显式销毁器调用
在下列情况中销毁器隐含的被调用:
程序终止时对静态对象
创建局域对象的代码块结束时对局域对象
临时对象结束生存时对临时对象
使用new分配的对象,在使用delete释放时
异常导致的堆栈溢出时,对引起异常的对象
销毁器也可以显式的调用。例如:
class C
{
public:
~C() {}
};
void destroy(C& c)
{
c.C::~C(); //显式的销毁器调用
}
销毁器也可以被类的成员函数在对象中显式调用:
void C::destroy()
{
this->C::~C();
}
特别的,对于通过placement new运算符创建的对象来说,显式调用销毁器是必要的(placement new在第十一章“内存管理”中讨论)。
伪销毁器
你知道,基本类型有构造器。另外,基本类型也有pseudo销毁器。构造伪销毁器的唯一目的是为了适应泛型算法和容器的需要。它是对对象没有实际作用的no-op代码。如果你检查编译器为伪销毁器产生的汇编代码,你可能发现编译器简单的忽略了他们。下面的例子演示伪销毁器的调用:
typedef int N;
int main()
{
N i = 0;
i.N::~N(); //伪销毁器调用
i = 1; //i不受调用伪销毁器的影响
return 0;
}
变量i定义并初始化。在接下来的语句中,非class类型N的销毁器被显式调用,但是它对对象本身没有任何影响。象基本类型的构造器一样,伪销毁器使用户不必知道给出的类型是否真的有销毁器,就可以调用销毁器。
纯虚销毁器
不象普通成员函数,当它在派生类中被重新定义时,虚构造器不能被代替。恰当的说,它被扩展了。最底层的销毁器首先调用它基类的销毁器;然后再执行自己。因此,当你申明纯虚销毁器时,你可能碰到编译期错误,或更糟的运行期错误。基于这种考虑,纯虚销毁器是纯虚函数的例外——他们必需被定义。你可以不申明销毁器为纯虚的,仅使它是虚函数。但是,这对于设计的安全性来说是不需要的。你可以喜欢两个词,只要迫使纯虚的接口有销毁器——而且这样不会引起运行期崩溃。它是怎么做的呢?
首先,抽象类仅包括一个纯虚销毁器的申明:
class Interface
{
public:
virtual void Open() = 0;
virtual ~Interface() = 0;
};
在类外面定义的话,纯虚销毁器必须这样定义:
Interface::~Interface()
{} //纯虚销毁器的定义;必须总是空的
构造器和销毁器必须是最小的
当你设计类时,记住它可能作为派生类的基类。它也可能作为一个更大类的成员对象。与其他成员函数可以被代替或完全不被调用不同,基类的构造器和销毁器自动被调用。迫使它的派生类和包容它的类做其不需要做的事不是一个好主意,但是必须接受这种强迫。换句话说,构造器和销毁器只需做构造和销毁对象的基本工作。示范一个具体的例子:支持持久化的string类不需要在构造器中打开/关闭文件。这样的工作留给专职的成员函数。当新的派生类——存储固定长度字符串的ShortString——创建时,它的构造器就不需要被迫执行基类的构造器强加的文件I/O。
总结
构造器、拷贝构造器、赋值运算符和销毁器自动完成创建、拷贝和销毁对象时的烦人的操作。C++中构造器和销毁器的对称在面向对象程序设计语言中是不常见的,并且他们是高级设计术语的基础(象你在下一章“面向对象的编程和设计”中看到的)。
每一个C+对象拥有程序员申明的或编译器隐含申明的4个成员函数。隐含申明的特殊成员函数可能是不需要的,这意味着编译器不会定义它。合成的特殊成员函数只执行实现类必须的操作。用户写的特殊成员函数也被编译器自动扩展——保证基类和内部子对象正确的被初始化——然后在虚指针中加入相应的项。基本类型也有构造器和伪销毁器,这是为了泛型程序设计的需要。
在许多情况下,合成构造器工作正确。当默认行为不正确时,程序员就必须自己显式的定义一个或多个特殊成员函数。然而在一般情况下,用户写代码的需要来自有高层接口的低层数据结构,而且可能是设计上的问题。申明构造器为explicit保证它不被当作隐含的转换运算符。
成员初始化列表对于const成员和引用数据成员是必需的,要传递参数给基类或内部子对象时它也是必需的。在其他情况下,成员初始化列表是可选的,但能提高性能。构造器和赋值运算符可以用来控制对象的实例化和对象的拷贝。销毁器可以显式的调用。申明为纯虚的销毁器必需定义。