写点什么

高效学 C++|组合类的构造函数

作者:TiAmo
  • 2023-03-09
    江苏
  • 本文字数:6832 字

    阅读完需:约 22 分钟

高效学 C++|组合类的构造函数

设计好 MyString 类后,就可以像使用普通类型一样使用它了。例如,类的对象可以像普通的变量一样作为另一个类的数据成员。

【例 1】 MyString 类的对象作为 CStudent 类的数据成员。

1.  //MyString类的定义省略2.  //注意:保留其构造函数、析构函数、复制构造函数和赋值运算符中的输出内容
3. //file: student.h4. #pragma once5. #include"MyString.h"6. class CStudent7. {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.cpp27. #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.cpp42. #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. zhangsan16. zhangsan17. 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 A2. {3.  public:4.     void A_fun(B b); //因之前没有声明类型B,故这里试图引用B会造成编译错误5.     int i;6.  }; 7.  class B8. {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 行的原因。

发布于: 16 分钟前阅读数: 5
用户头像

TiAmo

关注

有能力爱自己,有余力爱别人! 2022-06-16 加入

CSDN全栈领域优质创作者,万粉博主;阿里云专家博主、星级博主、技术博主、阿里云问答官,阿里云MVP;华为云享专家;华为Iot专家;亚马逊人工智能自动驾驶(大众组)吉尼斯世界纪录获得者

评论

发布
暂无评论
高效学 C++|组合类的构造函数_组合_TiAmo_InfoQ写作社区