返回介绍

13.8 类设计回顾

发布于 2024-10-08 23:14:11 字数 12915 浏览 0 评论 0 收藏 0

C++可用于解决各种类型的编程问题,但不能将类设计简化成带编号的例程。然而,有些常用的指导原则,下面复习并拓展前面的讨论,以介绍这些原则。

13.8.1 编译器生成的成员函数

第 12 章介绍过,编译器会自动生成一些公有成员函数——特殊成员函数。这表明这些特殊成员函数很重要,下面回顾其中的一些。

1.默认构造函数

默认构造函数要么没有参数,要么所有的参数都有默认值。如果没有定义任何构造函数,编译器将定义默认构造函数,让您能够创建对象。例如,假设 Star 是一个类,则下述代码需要使用默认构造函数:

自动生成的默认构造函数的另一项功能是,调用基类的默认构造函数以及调用本身是对象的成员所属类的默认构造函数。

另外,如果派生类构造函数的成员初始化列表中没有显式调用基类构造函数,则编译器将使用基类的默认构造函数来构造派生类对象的基类部分。在这种情况下,如果基类没有构造函数,将导致编译阶段错误。

如果定义了某种构造函数,编译器将不会定义默认构造函数。在这种情况下,如果需要默认构造函数,则必须自己提供。

提供构造函数的动机之一是确保对象总能被正确地初始化。另外,如果类包含指针成员,则必须初始化这些成员。因此,最好提供一个显式默认构造函数,将所有的类数据成员都初始化为合理的值。

2.复制构造函数

复制构造函数接受其所属类的对象作为参数。例如,Star 类的复制构造函数的原型如下:

在下述情况下,将使用复制构造函数:

  • 将新对象初始化为一个同类对象;
  • 按值将对象传递给函数;
  • 函数按值返回对象;
  • 编译器生成临时对象。

如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数。也就是说,新对象的每个成员都被初始化为原始对象相应成员的值。如果成员为类对象,则初始化该成员时,将使用相应类的复制构造函数。

在某些情况下,成员初始化是不合适的。例如,使用 new 初始化的成员指针通常要求执行深复制(参见 baseDMA 类示例),或者类可能包含需要修改的静态变量。在上述情况下,需要定义自己的复制构造函数。

3.赋值运算符

默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初始化混淆了。如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值:

默认赋值为成员赋值。如果成员为类对象,则默认成员赋值将使用相应类的赋值运算符。如果需要显式定义复制构造函数,则基于相同的原因,也需要显式定义赋值运算符。Star 类的赋值运算符的原型如下:

赋值运算符函数返回一个 Star 对象引用。baseDMA 类演示了一个典型的显式赋值运算符函数示例。

编译器不会生成将一种类型赋给另一种类型的赋值运算符。如果希望能够将字符串赋给 Star 对象,则方法之一是显式定义下面的运算符:

另一种方法是使用转换函数(参见下一节中的“转换”小节)将字符串转换成 Star 对象,然后使用将 Star 赋给 Star 的赋值函数。第一种方法的运行速度较快,但需要的代码较多,而使用转换函数可能导致编译器出现混乱。

第 18 章将讨论 C++11 新增的两个特殊方法:移动构造函数和移动赋值运算符。

13.8.2 其他的类方法

定义类时,还需要注意其他几点。下面的几小节将分别介绍。

1.构造函数

构造函数不同于其他类方法,因为它创建新的对象,而其他类方法只是被现有的对象调用。这是构造函数不被继承的原因之一。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在。

2.析构函数

一定要定义显式析构函数来释放类构造函数使用 new 分配的所有内存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需要析构函数,也应提供一个虚析构函数。

3.转换

使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。例如,下述 Star 类的构造函数原型:

将可转换的类型传递给以类为参数的函数时,将调用转换构造函数。例如,在如下代码中:

第二条语句将调用 Star::operator = (const Star *)函数,使用 Star::star(const char *)生成一个 Star 对象,该对象将被用作上述赋值运算符函数的参数。这里假设没有定义将 char *赋给 Star 的赋值运算符。

在带一个参数的构造函数原型中使用 explicit 将禁止进行隐式转换,但仍允许显式转换:

要将类对象转换为其他类型,应定义转换函数(参见第 11 章)。转换函数可以是没有参数的类成员函数,也可以是返回类型被声明为目标类型的类成员函数。即使没有声明返回类型,函数也应返回所需的转换值。下面是一些示例:

应理智地使用这样的函数,仅当它们有帮助时才使用。另外,对于某些类,包含转换函数将增加代码的二义性。例如,假设已经为第 11 章的 Vector 类型定义了 double 转换,并编写了下面的代码:

编译器可以将 ius 转换成 double 并使用 double 加法,或将 20.2 转换成 veotor(使用构造函数之一)并使用 vector 加法。但除了指出二义性外,它什么也不做。

C++11 支持将关键字 explicit 用于转换函数。与构造函数一样,explicit 允许使用强制类型转换进行显式转换,但不允许隐式转换。

4.按值传递对象与传递引用

通常,编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数需要时间,复制大型对象比传递引用花费的时间要多得多。如果函数不修改对象,应将参数声明为 const 引用。

按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义为接受基类引用参数的函数可以接受派生类。这在本章前面介绍过(同时请参见本章后面的“虚方法”一节)。

5.返回对象和返回引用

有些类方法返回对象。您可能注意到了,有些成员函数直接返回对象,而另一些则返回引用。有时方法必须返回对象,但如果可以不返回对象,则应返回引用。来具体看一下。

首先,在编码方面,直接返回对象与返回引用之间唯一的区别在于函数原型和函数头:

其次,应返回引用而不是返回对象的的原因在于,返回对象涉及生成返回对象的临时副本,这是调用函数的程序可以使用的副本。因此,返回对象的时间成本包括调用复制构造函数来生成副本所需的时间和调用析构函数删除副本所需的时间。返回引用可节省时间和内存。直接返回对象与按值传递对象相似:它们都生成临时副本。同样,返回引用与按引用传递对象相似:调用和被调用的函数对同一个对象进行操作。

然而,并不总是可以返回引用。函数不能返回在函数中创建的临时对象的引用,因为当函数结束时,临时对象将消失,因此这种引用将是非法的。在这种情况下,应返回对象,以生成一个调用程序可以使用的副本。

通用的规则是,如果函数返回在函数中创建的临时对象,则不要使用引用。例如,下面的方法使用构造函数来创建一个新对象,然后返回该对象的副本:

如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象。例如,下面的代码按引用返回调用函数的对象或作为参数传递给函数的对象:

6.使用 const

使用 const 时应特别注意。可以用它来确保方法不修改参数:

可以使用 const 来确保方法不修改调用它的对象:

这里 const 表示 const Star * this,而 this 指向调用的对象。

通常,可以将返回引用的函数放在赋值语句的左侧,这实际上意味着可以将值赋给引用的对象。但可以使用 const 来确保引用或指针返回的值不能用于修改对象中的数据:

该方法返回对 this 或 s 的引用。因为 this 和 s 都被声明为 const,所以函数不能对它们进行修改,这意味着返回的引用也必须被声明为 const。

注意,如果函数将参数声明为指向 const 的引用或指针,则不能将该参数传递给另一个函数,除非后者也确保了参数不会被修改。

13.8.3 公有继承的考虑因素

通常,在程序中使用继承时,有很多问题需要注意。下面来看其中的一些问题。

1.is-a 关系

要遵循 is-a 关系。如果派生类不是一种特殊的基类,则不要使用公有派生。例如,不应从 Brain 类派生出 Programmer 类。如果要指出程序员有大脑,应将 Brain 类对象作为 Programmer 类的成员。

在某些情况下,最好的方法可能是创建包含纯虚函数的抽象数据类,并从它派生出其他的类。

请记住,表示 is-a 关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。另外,反过来是行不通的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象。这种显式类型转换(向下强制转换)可能有意义,也可能没有,这取决于类声明(参见图 13.4)。

2.什么不能被继承

构造函数是不能继承的,也就是说,创建派生类对象时,必须调用派生类的构造函数。然而,派生类构造函数通常使用成员初始化列表语法来调用基类构造函数,以创建派生对象的基类部分。如果派生类构造函数没有使用成员初始化列表语法显式调用基类构造函数,将使用基类的默认构造函数。在继承链中,每个类都可以使用成员初始化列表将信息传递给相邻的基类。C++11 新增了一种让您能够继承构造函数的机制,但默认仍不继承构造函数。

析构函数也是不能继承的。然而,在释放对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数。如果基类有默认析构函数,编译器将为派生类生成默认析构函数。通常,对于基类,其析构函数应设置为虚的。

赋值运算符是不能继承的,原因很简单。派生类继承的方法的特征标与基类完全相同,但赋值运算符的特征标随类而异,这是因为它包含一个类型为其所属类的形参。赋值运算符确实有一些有趣的特征,下面介绍它们。

3.赋值运算符

如果编译器发现程序将一个对象赋给同一个类的另一个对象,它将自动为这个类提供一个赋值运算符。这个运算符的默认或隐式版本将采用成员赋值,即将原对象的相应成员赋给目标对象的每个成员。然而,如果对象属于派生类,编译器将使用基类赋值运算符来处理派生对象中基类部分的赋值。如果显式地为基类提供了赋值运算符,将使用该运算符。与此相似,如果成员是另一个类的对象,则对于该成员,将使用其所属类的赋值运算符。

正如多次提到的,如果类构造函数使用 new 来初始化指针,则需要提供一个显式赋值运算符。因为对于派生对象的基类部分,C++将使用基类的赋值运算符,所以不需要为派生类重新定义赋值运算符,除非它添加了需要特别留意的数据成员。例如,baseDMA 类显式地定义了赋值,但派生类 lackDMA 使用为它生成的隐式赋值运算符。

然而,如果派生类使用了 new,则必须提供显式赋值运算符。必须给类的每个成员提供赋值运算符,而不仅仅是新成员。HasDMA 类演示了如何完成这项工作:

将派生类对象赋给基类对象将会如何呢?(注意,这不同于将基类引用初始化为派生类对象。)请看下面的例子:

这将使用哪个赋值运算符呢?赋值语句将被转换成左边的对象调用的一个方法:

其中左边的对象是 Brass 对象,因此它将调用 Brass ::operator =(const Brass &)函数。is-a 关系允许 Brass 引用指向派生类对象,如 Snips。赋值运算符只处理基类成员,所以上述赋值操作将忽略 Snips 的 maxLoan 成员和其他 BrassPlus 成员。总之,可以将派生对象赋给基类对象,但这只涉及基类的成员。

相反的操作将如何呢?即可以将基类对象赋给派生类对象吗?请看下面的例子:

上述赋值语句将被转换为如下所示:

左边的对象是 BrassPlus 对象,所以它调用 BrassPlus ::operator=(const BrassPlus &)函数。然而,派生类引用不能自动引用基类对象,因此上述代码不能运行,除非有下面的转换构造函数:

与 BrassPlus 类的情况相似,转换构造函数可以接受一个类型为基类的参数和其他参数,条件是其他参数有默认值:

如果有转换构造函数,程序将通过它根据 gp 来创建一个临时 BrassPlus 对象,然后将它用作赋值运算符的参数。

另一种方法是,定义一个用于将基类赋给派生类的赋值运算符:

该赋值运算符的类型与赋值语句完全匹配,因此无需进行类型转换。

总之,问题“是否可以将基类对象赋给派生对象?”的答案是“也许”。如果派生类包含了这样的构造函数,即对将基类对象转换为派生类对象进行了定义,则可以将基类对象赋给派生对象。如果派生类定义了用于将基类对象赋给派生对象的赋值运算符,则也可以这样做。如果上述两个条件都不满足,则不能这样做,除非使用显式强制类型转换。

4.私有成员与保护成员

对派生类而言,保护成员类似于公有成员;但对于外部而言,保护成员与私有成员类似。派生类可以直接访问基类的保护成员,但只能通过基类的成员函数来访问私有成员。因此,将基类成员设置为私有的可以提高安全性,而将它们设置为保护成员则可简化代码的编写工作,并提高访问速度。Stroustrup 在其《The Design and Evolution of C++》一书中指出,使用私用数据成员比使用保护数据成员更好,但保护方法很有用。

5.虚方法

设计基类时,必须确定是否将类方法声明为虚的。如果希望派生类能够重新定义方法,则应在基类中将方法定义为虚的,这样可以启用晚期联编(动态联编);如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了这样的意思:您不希望它被重新定义。

请注意,不适当的代码将阻止动态联编。例如,请看下面的两个函数:

第一个函数按引用传递对象,第二个按值传递对象。

现在,假设将派生类参数传递给上述两个函数:

show( ) 函数调用使 rba 参数成为 BrassPlus 对象 buzz 的引用,因此,rba.ViewAcct( ) 被解释为 BrassPlus 版本,正如应该的那样。但在 inadequate( ) 函数中(它是按值传递对象的),ba 是 Brass(const Brass &)构造函数创建的一个 Brass 对象(自动向上强制转换使得构造函数参数可以引用一个 BrassPlus 对象)。因此,在 inadequate( ) 中,ba.ViewAcct( ) 是 Brass 版本,所以只有 buzz 的 Brass 部分被显示。

6.析构函数

正如前面介绍的,基类的析构函数应当是虚的。这样,当通过指向对象的基类指针或引用来删除派生对象时,程序将首先调用派生类的析构函数,然后调用基类的析构函数,而不仅仅是调用基类的析构函数。

7.友元函数

由于友元函数并非类成员,因此不能继承。然而,您可能希望派生类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将,派生类引用或指针转换为基类引用或指针,然后使用转换后的指针或引用来调用基类的友元函数:

也可以使用第 15 章将讨论的运算符 dynamic_cast<>来进行强制类型转换:

鉴于第 15 章将讨论的原因,这是更佳的强制类型转换方式。

8.有关使用基类方法的说明

以公有方式派生的类的对象可以通过多种方式来使用基类的方法。

  • 派生类对象自动使用继承而来的基类方法,如果派生类没有重新定义该方法。
  • 派生类的构造函数自动调用基类的构造函数。
  • 派生类的构造函数自动调用基类的默认构造函数,如果没有在成员初始化列表中指定其他构造函数。
  • 派生类构造函数显式地调用成员初始化列表中指定的基类构造函数。
  • 派生类方法可以使用作用域解析运算符来调用公有的和受保护的基类方法。
  • 派生类的有元函数可以通过强制类型转换,将派生类引用或指针转换为基类引用或指针,然后使用该引用或指针来调用基类的友元函数。

13.8.4 类函数小结

C++类函数有很多不同的变体,其中有些可以继承,有些不可以。有些运算符函数既可以是成员函数,也可以是友元,而有些运算符函数只能是成员函数。表 13.1(摘自《The Annotated C++ Reference Manual》)总结了这些特征,其中 op=表示诸如+=、*=等格式的赋值运算符。注意,op=运算符的特征与“其他运算符”类别并没有区别。单独列出 op=旨在指出这些运算符与=运算符的行为是不同的。

表 13.1 成员函数属性

函数

能否继承

成员还是友元

默认能否生成

能否为虚函数

是否可以有返回类型

构造函数

成员

析构函数

成员

=

成员

&

任意

转换函数

成员

( )

成员

[]

成员

->

成员

op=

任意

new

静态成员

void*

delete

静态成员

void

其他运算符

任意

其他成员

成员

友元

友元

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据
    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文