设计好MyString类后,就可以像使用普通类型一样使用它了。例如,类的对象可以像普通的变量一样作为另一个类的数据成员。
【例1】 MyString类的对象作为CStudent类的数据成员。
1. //MyString类的定义省略
2. //注意:保留其构造函数、析构函数、复制构造函数和赋值运算符中的输出内容
3. //file: student.h
4. #pragma once
5. #include"MyString.h"
6. class CStudent
7. {
8. public:
9. CStudent() { cout << "CStudent的默认构造函数被调用" << endl; }
10. CStudent(int num, const MyString & name,
const MyString & major, double score);
11. ~CStudent() { cout << "CStudent的析构函数被调用" << endl; }
12. void set_number(int num) { number = num; }
13. int get_number(){ return number; }
14. MyString & set_name(const MyString & name);
15. MyString & get_name() { return name; }
16. MyString & set_major(const MyString & major);
17. MyString & get_major() { return major; }
18. void set_score(double score) { this->score = score; }
19. double get_score() { return score; }
20. private:
21. int number;
22. MyString name;
23. MyString major;
24. double score;
25. };
26. //file: student.cpp
27. #include"student.h"
28. CStudent::CStudent(int num, const MyString &name,
const MyString &major, double score)
29. {
30. number = num;
31. this->name = name;
32. this->major = major;
33. this->score = score;
34. cout << "CStudent的有参构造函数被调用" << endl;
35. }
36. MyString & CStudent::set_name(const MyString & name)
37. {
38. this->name = name;
39. return this->name;
40. }
41. //file: main.cpp
42. #include"student.h"
43. #include<iostream>
44. using namespace std;
45. int main()
46. {
47. MyString name("zhangsan"), major("computer");
48. CStudent stu(1, name, major, 100), stu2;
49. CStudent stu3(stu);
50. stu2 = stu3;
51. cout << stu.get_name().get_string() << endl;
52. cout << stu2.get_name().get_string() << endl;
53. return 0;
54. }
其输出如下:
1. MyString的有参构造函数被调用
2. MyString的有参构造函数被调用
3. MyString的默认构造函数被调用
4. MyString的默认构造函数被调用
5. MyString的赋值运算符函数被调用
6. MyString的赋值运算符函数被调用
7. CStudent的有参构造函数被调用
8. MyString的默认构造函数被调用
9. MyString的默认构造函数被调用
10. CStudent的默认构造函数被调用
11. MyString的复制构造函数被调用
12. MyString的复制构造函数被调用
13. MyString的赋值运算符函数被调用
14. MyString的赋值运算符函数被调用
15. zhangsan
16. zhangsan
17. CStudent的析构函数被调用
18. MyString的析构函数被调用
19. MyString的析构函数被调用
20. CStudent的析构函数被调用
21. MyString的析构函数被调用
22. MyString的析构函数被调用
23. CStudent的析构函数被调用
24. MyString的析构函数被调用
25. MyString的析构函数被调用
26. MyString的析构函数被调用
27. MyString的析构函数被调用
对于例1,在定义CStudent类时使用了MyString类,比如其数据成员name是MyString类型的,也就是说MyString类的对象name作为CStudent的数据成员。这样,对于编写CStudent类的程序员来说,只需要知道MyString类的用法就行了,而不需要再去考虑如动态内存分配等细节,因而大大减轻了程序员的工作量。不过,类毕竟与普通的数据类型不同,因而就带来了一些问题。下面结合程序的输出,分析程序的运行过程如下:
(1)输出的第1、2行是程序第47行中构造name和major时产生的。
(2)程序第48行会调用CStudent类的有参构造函数构造对象stu、调用CStudent的默认构造函数构造stu2,而输出中的第7行才是CStudent的有参构造函数中输出的信息、第10行才是CStudent的默认构造函数输出的信息,因此输出的第3行至第10行都是因程序第48行产生的输出。这些输出表明,在CStudent的有参构造函数执行之前,先调用了两次MyString类的默认构造函数,然后调用了两次MyString类的赋值运算符函数;在执行CStudent的默认构造函数之前,先调用了两次MyString类的默认构造函数。然而,在CStudent类的有参构造函数中没有看到调用MyString类的默认构造函数初始化内嵌对象name和major的地方,那么两次调用MyString类的默认构造函数是怎么发生的?同理,在CStudent类的默认构造函数的实现中也没有显式调用MyString类的默认构造函数初始化内嵌对象name和major的地方,那么两次调用MyString类的默认构造函数是怎么发生的?这就需要介绍构造函数的初始化列表了。另外,在CStudent类的有参构造函数中的语句“this->name = name; this->major = major;”中直接使用了类的内嵌对象name和major(由此两次调用MyString类的赋值运算符函数,产生输出的第5和第6行),这说明这两个对象在进入该构造函数之前就已经构造完毕。既然在进入CStudent类的构造函数之前就能调用MyString类的构造函数初始化name和major,那么能不能通过传递CStudent类的有参构造函数中的参数name和major来调用MyString类的复制构造函数初始化CStudent类的成员对象name和major呢?这样做还可以省去在CStudent类的有参构造函数中对它们的赋值,即省去两次调用MyString类的赋值运算符函数的过程。
(3)程序第49行是调用CStudent类的复制构造函数,但例1中没有设计该函数,因此执行的是编译器自动提供的复制构造函数;程序第50行是一个赋值运算,由于例1中也没有为CStudent设计赋值运算符函数,因此编译器自动提供了默认的赋值运算符函数。显然程序第51行和第52行产生的输出为输出中的第15行和第16行,因此程序第49行和第50行产生的输出为输出中的第11行至第14行:显示调用了两次MyString类的复制构造函数和两次赋值运算符函数。根据输出的第15行和第16行的内容相同,且程序运行正常,可以判断编译器自动提供的复制构造函数和赋值运算符是正确的。那么,编译器自动提供的复制构造函数和赋值运算符函数是什么样的?
(4)例1中设计了CStudent类的析构函数,但在其函数体中只有一条输出语句。这是因为CStudent类中没有涉及动态内存分配,因此不涉及回收堆内存的问题。注意,MyString类型的对象name和major涉及了堆内存,不过回收其堆内存的工作由MyString类的析构函数完成。从程序中可以看出,对象的析构顺序为:依次析构对象stu3、stu2和stu,然后析构对象major和name。整个析构过程产生了输出中的第17行至第27行,其中析构stu3产生了输出中的第17行至第19行。那么,析构组合类对象stu3为什么是这样的一个过程?
下面会解释上面提出的问题。不过,在此之前,先介绍一下类的前向引用声明问题,因为这个问题在定义组合类时经常会用到。
在C++语言中,使用基本数据类型的变量时需要遵循先声明后引用的规则。与此类似,在定义新的类型时也要遵循这一规则。例如在例1中,在定义CStudent类之前,先通过预编译指令引入了MyString类的定义(例1的第5行)。在声明一个类之前就试图使用这个类则会出现编译错误,如例2所示。
【例2】 在声明一个类之前就试图使用这个类则会出现编译错误。
1. class A
2. {
3. public:
4. void A_fun(B b); //因之前没有声明类型B,故这里试图引用B会造成编译错误
5. int i;
6. };
7. class B
8. {
9. public:
10. void B_fun(A a);
11. int j;
12. };
在例2中,在类A的定义中引用了类B。然而,B类还没有被声明,所以会造成编译错误。解决办法是进行前向类型声明,比如在声明A之前加入声明语句“class B;”。
进行了类的前向声明之后,仅能保证声明的符号可见,但在给出类的具体定义之前,并不能涉及类的具体内容,如下面的程序。
class B;
class A
{
public:
int A_fun(B b){ return b.j; } //在给出B的具体定义之前涉及了其
//具体内容,所以会出现编译错误
int i;
};
class B
{
public:
int B_fun(A a);
int j;
};
在上面的程序中,类A的函数A_fun()试图访问对象b的数据成员j,即试图引用B类的具体内容。然而,在此之前,类B的具体定义尚未给出,所以会出现编译错误。解决办法是将该函数的实现写在类外并且在类B的完整定义之后。
类似地,在给出类的完整定义之前,不能定义类的对象,因为定义类的对象就会涉及对象的构造,从而会涉及类的具体内容,如下面的程序。
class B;
class A
{
public:
int A_fun(B b);
B m_b; //在给出类B的完整定义之前定义B的对象会造成编译错误
A m_a; //在类A的定义内部定义A的对象会造成编译错误
};
class B
{
public:
int B_fun(A a);
int j;
};
在上面的程序中,类A试图定义B的对象m_b和A的对象m_a,然而此时类B和类A的定义都不完整,因而会造成编译错误。解决办法是:首先把类B的完整定义放到类A的定义之前;其次,在类A中不能定义类A的对象,只能定义类A的指针,如下面的程序。
class A; //因为定义类B时引用了类A,所以需要做前向声明
class B
{
public:
int B_fun(A a);
int j;
};
class A
{
public:
int A_fun(B b){ return b.j; } //前面已有类B的完整定义,故该语句正确
B m_b; //前面已有类B的完整声明,故此处能够定义类B的对象
A* m_pa; //永远不能在类定义中定义自身的对象,可以定义自身的指针
};
01、组合类的构造函数
如前所述,在CStudent类的有参构造函数中可以直接使用内嵌的对象name,这就意味着该对象在程序执行CStudent类的有参构造函数之前就已经调用了MyString的构造函数完成了初始化。为了解释这个问题,就需要介绍初始化列表的概念了。
类的构造函数都带有一个初始化列表,主要作用是为初始化类的数据成员提供一个机会。如果在设计构造函数时没有在初始化列表中给出数据成员的初始化方式,则编译器会采用数据成员的默认的初始化方式——对于类的对象来说就是调用其默认的构造函数——进行初始化,且初始化列表中的内容会在执行构造函数之前执行。这就是在上面例1中的CStudent类的有参构造函数中可以使用其成员对象name的原因。
一般地,带初始化列表的构造函数的形式如下(仅以写在类的声明内部为例;写在类的声明外部与此相似,只是需要在函数名前加上类名和域作用符):
class 类名
{
public:
类名(): 初始化数据成员1, 初始化数据成员2, ...
{
}
...
};
以写在类的声明外部为例,CStudent类的有参构造函数可以写成如下形式。
CStudent::CStudent(int num, const MyString & name,
const MyString & major, double score)
: number(num), name(name), major(major), score(score)
{
cout << "CStudent的有参构造函数被调用" << endl;
}
其中初始化列表中的第一个name是CStudent的数据成员,第二个name是构造函数中的参数。在这个实现中,由于在初始化列表中使用复制构造函数初始化了name和major,所以在CStudent的构造函数内部就不需要再次为成员name和major赋值了。另外,基本数据类型number和score也可以在初始化列表中初始化,但要注意不能写成类似于“number = num”的形式。
另外,需要说明的是构造函数的调用顺序。由于初始化列表的存在,在调用组合类的构造函数之前会先调用其成员对象的构造函数,且当有多个成员对象时,C++语言规定按照成员对象在组合类声明中出现的顺序依次构造,而与它们在初始化列表中出现的顺序无关。例如,虽然name和major在上述构造函数的初始化列表中出现的顺序与在下面构造函数的初始化列表中出现的顺序不同,但在执行时都是先初始化name再初始化major,程序如下:
CStudent::CStudent(int num, const MyString & name,
const MyString & major, double score)
: number(num), major(major), name(name), score(score)
{
cout << "CStudent的有参构造函数被调用" << endl;
}
最后要强调的是,初始化列表可以省去——此时使用数据成员的默认方式初始化,但不意味着没有初始化列表。例如例1中,CStudent的默认构造函数实际的实现形式为在初始化列表中调用MyString的默认构造函数初始化name和major,但基本数据类型的成员number和score没有初始化,程序如下:
CStudent() : name(), major()
{
cout << "CStudent的默认构造函数被调用" << endl;
}
例1中CStudent的有参构造函数实际的实现形式中的初始化列表与上面的类似:仅在初始化列表中使用MyString类的默认构造函数初始化数据成员name和major,没有初始化number和score,程序如下:
CStudent::CStudent(int num, const MyString &name,
const MyString &major, double score) : name(), major()
{
number = num;
this->name = name;
this->major = major;
this->score = score;
cout << "CStudent的有参构造函数被调用" << endl;
}
显然,这个实现中,为初始化name和major需要调用两次MyString类的默认构造函数和两次赋值运算符函数。因此,充分利用初始化列表还可以减少函数调用的次数,提高程序的运行效率。
02、组合类的析构函数
对于CStudent类来说,其析构函数没有多少特殊的地方:其要完成的功能主要是负责该类数据成员的清理。在CStudent类中,由于数据成员没有用到堆内存(对象name和major用到了,但它们由MyString类负责处理),所以不需要专门为它编写析构函数。
不过,对于组合类的析构函数也有需要说明的地方,那就是当组合类的对象超出生存期时析构函数的调用顺序问题。这里只需要遵循一个原则:析构函数的调用顺序与构造函数的调用顺序完全相反。如果把对象的初始化过程比喻为按照严格规程生产一台机器的过程,那么显然需要先按照设定的规程生产各个零部件(相当于调用作为数据成员的对象的构造函数),然后调试整台机器(相当于调用组合类的构造函数);当需要拆卸机器时,需要按照完全相反的顺序拆卸(相当于调用各部分的析构函数),否则就无法拆卸开来。对于CStudent类的对象,调用析构函数的顺序是:调用CStudent类的析构函数析构CStudent类的对象,然后调用MyString类的析构函数析构对象major,最后调用MyString类的析构函数析构对象name。
03、组合类的复制构造函数
正象普通的复制构造函数一样,如果没有编写它,编译器就会自动提供一个,并且其完成的功能就是实现对应数据成员的复制。比如,在例1中没有给出CStudent类的复制构造函数,因此编译器会自动提供一个如下形式的复制构造函数——注意在初始化列表中调用了MyString类的复制构造函数来初始化name和major。
class CStudent
{
public:
CStudent(const CStudent & stu);
...
};
CStudent::CStudent(const CStudent & stu) : number(stu.number),
name(stu.name), major(stu.major), score(stu.score)
{
}
如果明确给出了复制构造函数的定义,则编译器就不再提供默认的实现,因此关于复制构造函数的一切都需要程序员负责——一定要在初始化列表中使用复制构造函数初始化对象成员,比如下面这个实现就不太好。
CStudent::CStudent(const CStudent & stu)
{
number = stu.number;
name = stu.name;
major = stu.major;
score = stu.score;
}
这个实现没有明确给出初始化列表,但这并不意味着没有初始化列表,而是意味着在初始化列表中采用默认的形式对数据成员初始化,即name和major的初始化是通过调用MyString的默认构造函数——而不是复制构造函数——实现的,而基本数据类型的成员number和score没有初始化。也正因为如此,在上面的实现中需要分别为各数据成员赋值,否则将不能正确完成CStudent对象的复制。
04、组合类的赋值运算符
当没有为类提供赋值运算符函数时,编译器会自动提供一个赋值运算符函数,其完成的功能就是对数据成员逐一赋值:对于基本数据类型就是按位赋值,对于对象成员就是调用其赋值运算符函数进行赋值。在CStudent类中,虽然其对象成员name和major使用了堆内存,但因为已经为MyString类提供了实现深复制的赋值运算符函数,因此,编译器为CStudent类自动提供的赋值运算符函数能够正确运行,其实现形式如下:
CStudent & CStudent::operator=(const CStudent & stu)
{
if (this != &stu) //防止自赋值
{
number = stu.number;
name = stu.name; //调用MyString类的赋值运算符函数
major = stu.major; //调用MyString类的赋值运算符函数
score = stu.score;
}
return *this;
}
在例1中,第50行调用了CStudent类的赋值运算符函数。根据上述编译器为CStudent类自动提供的赋值运算符函数的形式,在函数实现中两次调用MyString类的赋值运算符函数。这正是第50行的程序产生了输出中的第13行和第14行的原因。