跳过正文

《C++ Primer Plus》第六版笔记02

··40763 字·82 分钟
目录
《C++ Primer Plus》第六版 - 这篇文章属于一个选集。
§ 2: 本文

第八章 函数探幽
#

本章内容:

  • 内联函数;
  • 引用变量;
  • 如何按引用传递函数参数;
  • 默认参数;
  • 函数重载;
  • 函数模板;
  • 函数模板具体化。

8.1 C++ 内联函数
#

内联函数是C++为提高程序运行速度所做的一项改进。常规函数和内联函数之间的主要区别不在于编写方式,而在于C++编译器如何将它们组合到程序中。 常规函数调用也使程序跳到另一个地址(函数的地址),并在函数结束时返回。

C++内联函数提供了另一种选择。内联函数的编译代码与其他程序代码“内联”起来了。也就是说,编译器将使用相应的函数代码替换函数调用。对于内联代码,程序无需跳到另一个位置处执行代码,再跳回来。因此,内联函数的运行速度比常规函数稍快,但代价是需要占用更多内存。

要使用这项特性,必须采取下述措施之一:

  • 在函数声明前加上关键字 inline;
  • 在函数定义前加上关键字 inline;

内联函数最好都是一些很简单、行数少的函数。

8.2 引用变量
#

C++新增了一种复合类型——引用变量。引用是已定义的变量的别名(另一个名称)

8.2.1 创建引用变量
#

C++给&符号赋予另一个含义,将其用来声明引用。例如,要将 rodents作为rats变量的别名,可以这样做:

1
2
int rats;
int &rodents = rats;  // makes rodents an alias for rats

其中,& 不是地址运算符,而是类型标识符的一部分。就像声明中的 char* 指的是指向 char 的指针一样,int & 指的是指向 int 的引用。

rodents 加1将影响这两个变量。更准确地说, rodents++ 操作将一共有两个名称的变量加1。

引用看上去很像伪装表示的指针(其中,* 解除引用运算符被隐式理解)。实际上,引用还是不同于指针的。除了表示法不同外,还有其他的差别。例如,差别之一是, 必须在声明引用时将其初始化,而不能像指针那样,先声明,再赋值。

引用更接近const指针,必须在创建时进行初始化,一旦与某个变量关联起来,就将一直效忠于它。也就是说:某个变量的引用是不可更改的

引用是别名。

8.2.2 将引用用作函数参数
#

引用经常被用作函数参数,使得函数中的变量名成为调用程序中的变量的别名。这种传递参数的方法称为按引用传递。按引用传递允许被调用的函数能够访问调用函数中的变量。C++新增的这项特性是对C语言的超越,C语言只能按值传递。按值传递导致被调用函数使用调用程序的值的拷贝(参见图8.2)。

交换函数必须能够修改调用程序中的变量的值。这意味着按值传递变量将不管用,因为函数将交换原始变量副本的内容,而不是变量本身的内容。但传递引用时,函数将可以使用原始数据。另一种方法是,传递指针来访问原始数据。

8.2.3 引用的属性和特别之处
#

refcube() 函数修改了 main() 中的 x 值,而 cube() 没有,这提醒我们为何通常按值传递。变量 a 位于 cube() 中,它被初始化为 x 的值,但修改 a 并不会影响 x。但由于 refcube() 使用了引用参数,因此修改 ra 实际上就是修改 x。如果只是让函数使用传递给它的信息,而不对这些信息进行修改,同时又想使用引用,则应使用常量引用。

例如,在这个例子中,应在函数原型和函数头中使用const

1
double refcube(const double &ra);

如果要编写类似于上述示例的函数(即使用基本数值 类型),应采用按值传递的方式,而不要采用按引用传递的方式。当数据比较大(如结构和类)时,引用参数将很有用。

函数中应尽可能将引用形参声明为 const,这样好处有三个:

  • const 可以避免无意中修改数据,从而导致编程错误;
  • const 使函数能够处理 const 和非 const 实参,否则只能接受非 const 数据;
  • const 引用使函数能够正确生成并使用临时变量。

8.2.4 将引用用于结构体
#

2.为何要返回引用

下面更深入地讨论返回引用与传统返回机制的不同之处。传统返回机制与按值传递函数参数类似:计算关键字return后面的表达式,并将结果返回给调用函数。从概念上说,这个值被复制到一个临时位置,而 调用程序将使用这个值。

返回引用的函数实际上是被引用的变量的别名。

3.返回引用时需要注意的问题

返回引用时最重要的一点是,应避免返回函数终止时不再存在的内存单元引用。应避免编写如下代码:

该函数返回一个指向临时变量(newguy)的引用,函数运行完毕后 它将不再存在。同样,也应避免返回 指向临时变量的指针。

为避免这种问题,最简单的方法是,返回一个作为参数传递给函数 的引用。作为参数的引用将指向调用函数使用的数据,因此返回的引用 也将指向这些数据。

4.为何将const用于引用返回类型

8.2.5 将引用用于类对象
#

将类对象传递给函数时,C++通常的做法是使用引用。

8.2.6 对象、继承和引用
#

8.2.7 何时使用引用参数
#

使用引用参数的主要原因有两个:

  • 能够修改调用函数中的数据对象;
  • 通过传递引用而不是整个数据对象,可以提高程序的运行速度。

当数据对象较大时(如结构和类对象),第二个原因最重要。这些也是使用指针参数的原因。这是有道理的,因为引用参数实际上是基于指针的代码的另一个接口。

以下总结使用引用的原则

  • 如果数据对象很小。如内置数据类型或者小型数据结构,则按值传递;
  • 如果数据对象是数组,则使用指针,因为这是唯一的选择,并将指针声明为指向 const 的指针;
  • 如果数据对象是较大的结构体,则使用 const 指针或者 const 引用,以便提升程序的效率。这样可以节省复制结构体所需的时间和空间;
  • 如果数据对象是类对象,则使用 const 引用。类设计的语义常常要求使用引用,这是C++新增这项特性的主要原因。因此,传递类对象参数的标准方式是按引用传递

对于修改调用函数中数据的函数:

  • 如果数据对象是内置数据类型,则使用指针(不使用引用)。看到诸如:fixit(&x) 这样的代码(x是int),则很明显,该函数将要修改x;
  • 如果数据对象是数组,则只能使用指针;
  • 如果数据对象是结构体,则可以使用引用或者指针;
  • 如果数据对象是类对象,则(首选)使用引用。

8.3 默认参数
#

默认参数指的是当函数调用中省略了实参时自动使用的一个值。例如:

1
char * left(const char *str, int n=1);

对于带参数列表的函数,必须从右向左添加默认值。也就是说,要为某个参数设置默认值,则必须为它右边的所有参数提供默认值

1
2
3
int harpo(int n, int m=4, int j=5);      // Valid
int chico(int n, int m=6, int j);        // IN-Valid
int groucho(int k=1, int m=2, int n=3);  // Valid

实参按从左到右的顺序依次被赋给相应的形参,而不能跳过任何参数。

默认参数只在声明函数的时候给出,定义函数时,则不需要给出

8.4 函数重载
#

函数多态是C++在C语言的基础上新增的功能。默认参数让我们能够使用不同数目的参数调用同一个函数,而函数多态(函数重载)让我们能够使用多个同名的函数,这称为函数重载,它们完成相同的工作,但使用不同的参数列表。

函数重载的关键是函数的参数列表——也称为函数特征标 (function signature)

如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的。 C++允许定义名称相同的函数,条件是它们的特征标不同。

一些看起来彼此不同的特征标是不能共存的。例如,请看下面的两 个原型:

1
2
double cube(double x);
double cube(double &x);

可能认为可以在此处使用函数重载,因为它们的特征标看起来不同。然而,请从编译器的角度来考虑这个问题。假设有下面这样的代码:

1
cout << cube(x);

参数 xdouble x 原型和 double &x 原型都匹配,因此编译器无法确定究竟应使用哪个原型。为避免这种混乱,编译器在检查函数特征标时,将把类型引用和类型本身视为同一个特征标

请记住,是特征标(即,函数特征列表),而不是函数返回类型使得可以对函数进行重载。 例 如,下面的两个声明是互斥的:

1
2
long gronk(int n, float m);    // same signatures,
double gronk(int n, float m);  // hence not allowed

因此,C++不允许以这种方式重载gronk( )。返回类型可以不同,但特征标也必须不同

匹配函数时,并不区分const和非const变量(这就要小心了)。看下面的原型:

1
2
3
4
void dribble(char *bits);         // overloaded
void dribble(const char *cbits);  // overloaded
void dabble(char *bits);          // not overloaded
void drivel(const char *bits);    // not overloaded

8.4.1 函数重载示例
#

8.4.2 何时使用函数重载
#

虽然函数重载很吸引人,但也不要滥用。仅当函数基本上执行相同的任务,但使用不同形式的数据时,才应采用函数重载

8.5 函数模板
#

现在的C++编译器实现了C++的另一个新增特性——函数模板。函数模板是通用的函数描述,也就是说,它们使用泛型来定义函数,其中的泛型可用具体的类型(如intdouble)替换。通过将类型作为参数传递给模板,可使编译器生成该类型的函数。由于模板允许以泛型(而不是具体类型)的方式编写程序,因此有时也被称为通用编程。由于类型是用参数表示的,因此模板特性有时也被称为参数化类型(parameterized types)。

第一行指出,要建立一个模板,并将类型命名为 AnyType。关键字 templatetypename 是必需的,除非可以使用关键字 class 代替 typename。 另外,必须使用尖括号。类型名可以任意选择(这里为 AnyType),只要遵守C++命名规则即可;许多程序员都使用简单的名称,如 T

模板并不创建任何函数,而只是告诉编译器如何定义函数。需要交换int的函数时,编译器将按模板 模式创建这样的函数,并用int代替AnyType。同样,需要交换double的函数时,编译器将按模板模式创建这样的函数,并用double代替 AnyType

最终 的代码不包含任何模板,而只包含了为程序生成的实际函数。使用模板 的好处是,它使生成多个函数定义更简单、更可靠。

typename关键字使得参数AnyType表示类型这一点更为明显;然而,有大量代码库是使用关键字class开发的。在这种上下文中,这两个关键字是等价的

需要多个对不同类型使用同一种算法的函数时,可使用模板。

8.5.2 模板的局限
#

  • 下面的代码假定定义了赋 值,但如果T为数组,这种假设将不成立;
  • 下面的语句假设定义了 <,但如果T为结构,该假设便不成立;另外,为数组名定义了运算符 >,但由于数组名为地址,因此它比较的是数组的地址,而这可能不是您希望的。

总之,编写的模板函数很可能无法处理某些类型。另一方面,有时 候通用化是有意义的,但C++语法不允许这样做。

8.5.3 显式具体化
#

由于C++允许将一个结构赋给另一个结构,因此即使T是一个job结构,上述代码也适用。然而,假设只想交换 salaryfloor 成员,而不交 换 name 成员,则需要使用不同的代码,但 Swap() 的参数将保持不变 (两个 job 结构的引用),因此无法使用模板重载来提供其他的代码。

  • 对于给定的函数名,可以有非模板函数、模板函数和显式具体化模 板函数以及它们的重载版本;
  • 显式具体化的原型和定义应以 template<> 打头,并通过名称来指出 类型;
  • 具体化优先于常规模板,而非模板函数优先于具体化和常规模板。

如果有多个原型,则编译器在选择原型时,非模板版本优先于显式具体化和模板版本,而显式具体化优先于使用模板生成的版本

8.5.4 实例化和具体化
#

为进一步了解模板,必须理解术语实例化和具体化。记住,在代码中包含函数模板本身并不会生成函数定义,它只是一个用于生成函数定义的方案。编译器使用模板为特定类型生成函数定义时,得到的是模板实例(instantiation)。

函数调用Swap(i,j)导致编译器生成Swap()的一个实例,该实例使用int类型。模板并非函数定 义,但使用int的模板实例是函数定义。这种实例化方式被称为隐式实例 化(implicit instantiation),因为编译器之所以知道需要进行定义,是 由于程序调用Swap( )函数时提供了int参数。

最初,编译器只能通过隐式实例化,来使用模板生成函数定义,但现在C++还允许显式实例化(explicit instantiation)。这意味着可以直接 命令编译器创建特定的实例,如Swap<int>()。其语法是,声明所需的种类——用<>符号指示类型,并在声明前加上关键字 template

1
template void Swap<int>(int, int);  // explicit instantiation

实现了这种特性的编译器看到上述声明后,将使用Swap()模板生成 一个使用int类型的实例。也就是说,该声明的意思是“使用Swap()模板生成int类型的函数定义“。

与显式实例化不同的是,显式具体化使用下面两个等价的声明之 一:

1
2
template <> void Swap<int>(int &, int &);
template <> void Swap(int &, int &);

区别在于,这些声明的意思是“不要使用 Swap() 模板来生成函数定 义,而应使用专门为 int 类型显式地定义的函数定义”。这些原型必须有自己的函数定义。显式具体化声明在关键字 template 后包含 <>,而显式实例化没有

隐式实例化、显式实例化和显式具体化统称为具体化 (specialization)。它们的相同之处在于,它们表示的都是使用具体类 型的函数定义,而不是通用描述。

引入显式实例化后,必须使用新的语法——在声明中使用前缀templatetemplate <>,以区分显式实例化和显式具体化。通常,功能 越多,语法规则也越多。下面的代码片段总结了这些概念:

8.5.5 编译器选择使用哪个函数版本
#

对于函数重载、函数模板和函数模板重载,C++需要(且有)一个 定义良好的策略,来决定为函数调用使用哪一个函数定义,尤其是有多 个参数时。这个过程称为重载解析(overloading resolution):

  • 第1步:创建候选函数列表。其中包含与被调用函数的名称相同的函数和模板函数。
  • 第2步:使用候选函数列表创建可行函数列表。这些都是参数数目 正确的函数,为此有一个隐式转换序列,其中包括实参类型与相应 的形参类型完全匹配的情况。例如,使用float参数的函数调用可以 将该参数转换为double,从而与double形参匹配,而模板可以为 float生成一个实例;
  • 第3步:确定是否有最佳的可行函数。如果有,则使用它,否则该 函数调用出错。

接下来,编译器必须确定哪个可行函数是最佳的。它查看为使函数 调用参数与可行的候选函数的参数匹配所需要进行的转换。通常,从最 佳到最差的顺序如下所述:

  • 完全匹配,但常规函数优先于模板;
  • 提升转换(例如,char和shorts自动转换为int,float自动转换为 double);
  • 标准转换(例如,int转换为char,long转换为double);
  • 用户定义的转换,如类声明中定义的转换。

8.5.6 模板函数的发展
#

8.6 总结
#

C++扩展了C语言的函数功能。通过将 inline 关键字用于函数定义, 并在首次调用该函数前提供其函数定义,可以使得C++编译器将该函数视为内联函数。也就是说,编译器不是让程序跳到独立的代码段,以执行函数,而是用相应的代码替换函数调用(相当于复制进去)。只有在函数很短时才能采用内联方式。

引用变量是一种伪装指针,它允许为变量创建别名(另一个名称)。引用变量主要被用作处理结构和类对象的函数的参数。

C++原型让您能够定义参数的默认值。如果函数调用省略了相应的参数,则程序将使用默认值;如果函数调用提供了参数值,则程序将使用这个值(而不是默认值)。只能在参数列表中从右到左提供默认参数。

函数的特征标是其参数列表。程序员可以定义两个同名函数,只要其特征标不同。这被称为函数多态或函数重载。

第九章 内存模型和名称空间
#

本章内容包括:

  • 单独编译;
  • 存储持续性、作用域和链接性;
  • 定位(placement)new运算符;
  • 名称空间。

9.1 单独编译
#

第1章介绍过,可以单独编译文件,然后将它们链接成可执行的程序。通常,C++编译器既编译程序,也管理链接器。如果只修改了一个文件,则可以只重新编译该文件,然后将它与其他文件的编译版本链接,这使得大程序的管理更便捷。

例如,UNIX和Linux系统都具有make程 序,可以跟踪程序依赖的文件以及这些文件的最后修改时间。运行make 时,如果它检测到上次编译后修改了源文件,make将记住重新构建程序 所需的步骤。

例如但需要将一个结构体在多个文件中复用时,与其将结构体声明加入到每一个文件 中,不如将其放在头文件中,然后在每一个源代码文件中包含该头文件。这样,要修改结构体声明时,只需在头文件中做一次改动即可。另外,也可以将函数原型放在头文件中。因此,可以将原来的程序分成三 部分。

  • 头文件:包含结构体声明和使用这些结构体的函数的声明;
  • 源代码文件:包含与结构体有关的函数代码;
  • 源代码文件:包含调用与结构体相关的函数代码;

这是一种非常有用的组织程序的策略。例如,如果编写另一个程序 时,也需要使用这些函数,则只需包含头文件,并将函数文件添加到项 目列表或make列表中即可。另外,这种组织方式也与OOP方法一致。一个文件(头文件)包含了用户定义类型的定义;另一个文件包含操纵用 户定义类型的函数的代码。这两个文件组成了一个软件包,可用于各种程序中

不要将函数定义或变量声明放到头文件中。这样做通常会引来麻烦,因为,如果在头文件包含了一个函数的定义,然后在(同一个程序的)另外两个文件中分别包含了该头文件,那么同一个程序中将包含同一个函数的两个定义,除非函数是内联的,否则这将出错。

头文件常包含的内容:

  • 函数原型(也叫函数声明);
  • 使用#defineconst定义的符号常量;
  • 结构体声明;
  • 类声明;
  • 模板声明;
  • 内联函数。

将结构体声明放在头文件中是可以的,因为它们不创建变量,而只是在源代码文件中声明结构体变量时,告诉编译器如何创建该结构体变量。同样,模板声明也不是将被编译的代码,它们指示编译器如何生成与源代码中函数调用相匹配的函数定义。被声明为const的常量和内联函数有特殊的链接属性(稍后将介绍),因此可以将其放在头文件中,而不会引 起问题。

在包含头文件时,我们使 用"coordin.h",而不是 <coodin.h>。如果文件名包含在尖括号中,则 C++编译器将在存储标准头文件的主机系统的文件系统中查找;但如果文件名包含在双引号中,则编译器将首先查找当前的工作目录或源代码目录(取决于编译器),如果没有找到, 则将在标准位置查找。因此在包含自己的头文件时,应使用引号而不是 尖括号

在同一个文件中只能将同一个头文件包含一次。记住这个规则很容易,但很可能在不知 情的情况下将头文件包含多次。例如,可能使用包含了另外一个头文件的头文件。有一种标准的C/C++技术可以避免多次包含同一个头文件。它是基于预处理器编译指令#ifndef(即if not defined)的。#ifndef-#define-#endif 语句的这种方法并不能防止编译器将文件包含两次,而只是让它忽略除第一次包含之外的所有内容。大多数标准C和C++头文件都使用这种防护 (guarding)方案。否则,可能在一个文件中定义同一个结构体、函数、类等两次,这将导致编译错误。

9.2 存储持续性、作用域和链接性
#

存储类别如何影响信息在文件间的共享?

  • 自动存储持续性:在函数定义中声明的变量(包括函数参数)的存 储持续性为自动的。在程序开始执行其所属的函数或代码块时 被创建,在执行完函数或代码块时,它们使用的内存被释放;
  • 静态存储持续性:在函数定义外定义的变量和使用关键字static定义 的变量的存储持续性都为静态。它们在程序整个运行过程中都存在;
  • 线程存储持续性(C++11):如果变量是使用关键字thread_local声明的,则其生命 周期与所属的线程一样长;
  • 动态存储持续性:用new运算符分配的内存将一直存在,直到使用 delete运算符将其释放或程序结束为止。这种内存的存储持续性为动态,有时被称为自由存储(free store)或堆(heap)。

9.2.1 作用域和链接
#

作用域(scope)描述了名称在文件(翻译单元)的多大范围内可见。链接性(linkage)描述了名称如何在不同单元间共享。链接性为外部的 名称可在文件间共享,链接性为内部的名称只能由一个文件中的函数共 享。自动变量的名称没有链接性,因为它们不能共享。

作用域为局部的变量只在定义它的代码 块中可用。代码块是由花括号括起的一系列语句。例如函数体就是代码块,但可以在函数体中嵌入其他代码块。作用域为全局(也叫文件作用域)的变量在定义位置到文件结尾之间都可用。自动变量的作用域为局部,静态变量的作用域是全局还是局部取决于它是如何被定义的。在函数原型作用域(function prototype scope)中使用的名称只在包含参数列表的括号内可用(这就是为什么这些名称是什么以及是否出现都不重要的原因)。在类中声明的成员的作用域为整个类。在名 称空间中声明的变量的作用域为整个名称空间

9.2.2 自动存储持续性
#

在默认情况下,在函数中声明的函数参数和变量的存储持续性为自 动,作用域为局部,没有链接性。

如果在代码块中定义了变量,则该变量的存在时间和作用域将被限 制在该代码块内。例子:

1.自动变量的初始化 2.自动变量和栈

由于自动变量的数目随函数的开始和结束而增减,因此程序必须 在运行时对自动变量进行管理。常用的方法是留出一段内存,并将其视为栈,以管理变量的增减。之所以被称为栈,是由于新数据被象征性地放在原有数据的上面(也就是说,在相邻的内存单元中,而不是在同一 个内存单元中),当程序使用完后,将其从栈中删除。

3.寄存器变量 这旨在提高访问变量的速度。

9.2.3 静态持续变量
#

要想创建链接性为外部的静态持续变量,必须在代码块的外面声明它; 要创建链接性为内部的静态持续变量,必须在代码块的外面声明它,并使用 static 限定符; 要创建没有链接性的静态持续变量,必须在代码块内声明它,并使用 static 限定符。

正如前面指出的,所有静态持续变量(上述示例中的globalone_filecount)在整个程序执行期间都存在。在 funct1() 中声明的变量 count 的作用域为局部,没有链接性,这意味着只能在funct1() 函数中使用它,就像自动变量 llama 一样。然而,与llama 不同的是,即使在 funct1() 函数没有被执行时,count 也留在内存中。globalone_file 的作用域都为整个文件,即在从声明位置到文件结尾的范围内都可以被使 用。具体地说,可以在 main()funct1()funct2() 中使用它们。由于 one_file 的链接性为内部,因此只能在包含上述代码的文件中使用它; 由于 global 的链接性为外部,因此可以在程序的其他文件中使用它。

应少用静态外部变量,如使用最好都声明为内部连接性的静态变量。

9.2.4 静态持续性、外部链接性
#

链接性为外部的变量通常简称为外部变量,它们的存储持续性为静 态,作用域为整个文件。外部变量是在函数外部定义的,因此对所有函 数而言都是外部的。

1.单定义规则 C++有“单定义规则”(One Definition Rule,ODR),该规则指出, 变量只能有一次定义。请注意,单定义规则并非意味着不能有多个变量的名称相同。例 如,在不同函数中声明的同名自动变量是彼此独立的,它们都有自己的 地址。

9.2.5 静态持续性、内部链接性
#

将static限定符用于作用域为整个文件的变量时,该变量的链接性将 为内部的。在多文件程序中,内部链接性和外部链接性之间的差别很有 意义。链接性为内部的变量只能在其所属的文件中使用;但常规外部变 量都具有外部链接性,即可以在其他文件中使用。

在多文件程序中,可以在一个文件(且只能在一个文件)中定义一个外部变量。使用该变量的其他文件必须使用关键字 extern 声明它。

可使用外部变量在多文件程序的不同部分之间共享数据;可使用链接性为内部的静态变量在同一个文件中的多个函数之间共享数据(名称空间提供了另外一种共享数据的方法)。另外,如果将作用域为整个文 件的变量变为静态的,就不必担心其名称与其他文件中的作用域为整个 文件的变量发生冲突。

9.2.6 静态存储持续性、无链接性
#

这种变量是这样创建的,将static限定符用于在代码块中定义的变 量。在代码块中使用static时,将导致局部变量的存储持续性为静态的。 这意味着虽然该变量只在该代码块中可用,但它在该代码块不处于活动 状态时仍然存在。因此在两次函数调用之间,静态局部变量的值将保持 不变。(静态变量适用于再生——可以用它们将瑞士银行的秘密账号传 递到下一个要去的地方)。另外,如果初始化了静态局部变量,则程序 只在启动时进行一次初始化。以后再调用函数时,将不会像自动变量那样再次被初始化。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// static.cpp -- using a static local variable
#include <iostream>
// constants
const int ArSize = 10;

// function prototype
void strcount(const char * str);

int main()
{
    using namespace std;
    char input[ArSize];
    char next;

    cout << "Enter a line:\n";
    cin.get(input, ArSize);
    while (cin)
    {
        cin.get(next);
        while (next != '\n')    // string didn't fit!
            cin.get(next);      // dispose of remainder
        strcount(input);
        cout << "Enter next line (empty line to quit):\n";
        cin.get(input, ArSize);
    }
    cout << "Bye\n";
// code to keep window open for MSVC++
/*
cin.clear();
    while (cin.get() != '\n')
        continue;
    cin.get();
*/
    return 0;
}

void strcount(const char * str)
{
    using namespace std;
    static int total = 0;        // static local variable
    int count = 0;               // automatic local variable

    cout << "\"" << str <<"\" contains ";
    while (*str++)               // go to end of string
        count++;
    total += count;
    cout << count << " characters\n";
    cout << total << " characters total\n";
}

注意,在这个程序中,由于数组长度为10,因此程序从每行读取的字符数都不超过 9个。另外还需要注意的是,每次函数被调用时,自动变量count都被重 置为0。然而,静态变量total只在程序运行时被设置为0,以后在两次函 数调用之间,其值将保持不变,因此能够记录读取的字符总数。

9.2.7 说明符和限定符
#

  1. 再谈const

在C++(但不是在C语言)中,const限定符对默认存储类型稍有影响。在默认情况下全局变量的链接性为外部的,const全局变量的链接性为内部的。也就是说,在C++看来,全局 const 定义就像使用了 static 说明符一样。

C++修改了常量类型的规则,让程序员更轻松。例如,假设将一组 常量放在头文件中,并在同一个程序的多个文件中使用该头文件。那 么,预处理器将头文件的内容包含到每个源文件中后,所有的源文件都 将包含类似下面这样的定义:

1
2
const int fingers = 10;
const char *waring = "wak!";

如果全局const声明的链接性像常规变量那样是外部的,则根据单定 义规则,这将出错(幸亏不是)。

由于外部定义的const数据的链接性为内部的,因此可以在所有文件中使用相同的声明。

9.2.8 函数和链接性
#

和变量一样,函数也有链接性,虽然可选择的范围比变量小。和C 语言一样,C++不允许在一个函数中定义另外一个函数,因此所有函数 的存储持续性都自动为静态的,即在整个程序执行期间都一直存在。在 默认情况下,函数的链接性为外部的,即可以在文件间共享。实际上, 可以在函数原型中使用关键字extern来指出函数是在另一个文件中定义 的,不过这是可选的。还可以使用关键字static将函数的链接性设置为内部的,使之只能在一个文件中使用,但必须同时在原型和函数定义中使用该关键字。

9.2.9 语言链接性
#

在 C++中,同一个名称可能对应多个函数,必须将这些函数翻译为不同的 符号名称。因此,C++编译器执行名称矫正或名称修饰(参见第8 章),为重载函数生成不同的符号名称。例如,可能将spiff(int)转换 为 _spoff_i,而将 spiff(double,double) 转换为 _spiff_d_d。这种方法被称为C++语言链接(C++ language linkage)。

9.2.10 存储方案和动态分配
#

动态内存由运算符 newdelete 控制,而不是由作用域和链接性规则控制。因此,可以在一个函数中分配动态内存,而在另一个函数中将其释放。与自动内存不同,动态内存不是LIFO,其分配和释放顺序要取决于 newdelete 在何时以何种方式被使用。通常,编译器使用三块独立的内存:一块用 于静态变量(可能再细分),一块用于自动变量,另外一块用于动态存 储。

9.3 名称空间
#

名称可以是变量、函数、结构体、枚举、类以及类和结构体的成员。C++标准提供了名称空间工具,以便更好地控制名称的作用域。

9.3.1 传统的C++名称空间
#

C++关于全局变量和局部变量的规则定义了一种名称空间层次。每 个声明区域都可以声明名称,这些名称独立于在其他声明区域中声明的 名称。在一个函数中声明的局部变量不会与在另一个函数中声明的局部 变量发生冲突。

9.3.2 新的名称空间特性
#

C++新增了这样一种功能,即通过定义一种新的声明区域来创建命 名的名称空间,这样做的目的之一是提供一个声明名称的区域。一个名 称空间中的名称不会与另外一个名称空间的相同名称发生冲突,同时允 许程序的其他部分使用该名称空间中声明的东西。

名称空间可以是全局的,也可以位于另一个名称空间中,但不能位于代码块中。因此,在默认情况下,在名称空间中声明的名称的链接性 为外部的(除非它引用了常量)。

1.using声明和using编译指令

using声明由被限定的名称和它前面的关键字using组成:

1
using Jill::fetch;        // a using declaration

using 声明将特定的名称添加到它所属的声明区域中。例如 main() 中的 using 声明 Jill::fetchfetch 添加到 main() 定义的声明区域中。完成该声明后,便可以使用名称 fetch 代替 Jill::fetch

using声明使一个名称可用,而using编译指令使所有的名称都可 用。using编译指令由名称空间名和它前面的关键字using namespace组成,它使名称空间中的所有名称都可用,而不需要使用作用域解析运算符。在全局声明区域中使用using编译指令,将使该名称空间的名称全局可用。

2.using编译指令和using声明之比较

一般说来,使用using声明比使用using编译指令更安全,这是由于它只导入指定的名称。如果该名称与局部名称发生冲突,编译器将发出 指示。using 编译指令导入所有名称,包括可能并不需要的名称。如果与局部名称发生冲突,则局部名称将覆盖名称空间版本,而编译器并不会发出警告。另外,名称空间的开放性意味着名称空间的名称可能分散在多个地方,这使得难以准确知道添加了哪些名称。

3.名称空间的其他特性 可以将名称空间声明进行嵌套。

这里,flame指的是element:🔥:flame。同样,可以使用下面的 using编译指令使内部的名称可用。

4.未命名的名称空间

可以通过省略名称空间的名称来创建未命名的名称空间。

该名称空间中声明的名称的潜在作用域为:从声明点到该声明区域末尾,这与全局变量相似。然而,这种名称空间没有名称,因此不能显式地使用 using 编译指令或 using 声明来使它在其他位置都可用。也就是说,不能在未命名名称空间所属文件之外的其他文件中,使用该名称空间中的名称。这相当于是一个链接性为内部的静态变量的替代品。

9.3.4 名称空间及其前途
#

使用名称空间的原则:

  • 使用在已命名的名称空间中声明的变量,而不是使用外部全局变量;
  • 使用在已命名的名称空间中声明的变量,而不是使用静态全局变量;
  • 如果开发了一个函数库或类库,将其放在一个名称空间中;
  • 仅将编译指令using作为一种将旧代码转换为使用名称空间的权宜之计;
  • 不要在头文件中使用 using 编译指令。首先,这样做掩盖了要让哪些名称可用;另外,包含头文件的顺序可能影响程序的行为。如果非要使用编译指令 using,应将其放在所有预处理器编译指令 #include 之后;
  • 导入名称时,首选使用作用域解析运算符或using声明的方法;
  • 对于using声明,首选将其作用域设置为局部而不是全局。

9.4 总结
#

C++提倡在开发程序时使用多个文件。一种有效的组织策略 是,使用头文件来定义用户类型,为操纵用户类型的函数提供函数原 型;并将函数定义放在一个独立的源代码文件中。头文件和源代码文件 一起定义和实现了用户定义的类型及其使用方式。

C++的存储方案决定了变量保留在内存中的时间(储存持续性)以 及程序的哪一部分可以访问它(作用域和链接性)。

动态内存分配和释放是使用new和delete进行的,它使用自由存储区 或堆来存储数据。调用new占用内存,而调用delete释放内存。程序使用 指针来跟踪这些内存单元。

名称空间允许定义一个可在其中声明标识符的命名区域。这样做的 目的是减少名称冲突,尤其当程序非常大,并使用多个厂商的代码时。可以通过使用作用域解析运算符、using声明或using编译指令,来使名 称空间中的标识符可用。

第十章 对象和类
#

本章内容包括:

  • 过程性编程和面向对象编程;
  • 类概念;
  • 如何定义和实现类;
  • 公有类访问和私有类访问;
  • 类的数据成员;
  • 类方法(类成员函数);
  • 创建和使用类对象;
  • 类的构造函数和析构函数;
  • const 成员函数;
  • this 指针;
  • 创建对象数组;
  • 类作用域;
  • 抽象数据类型。

面向对象编程(OOP)是一种特殊的、设计程序的概念性方法,OOP的最终特性是:

  • 抽象;
  • 封装和数据隐藏;
  • 多态;
  • 继承;
  • 代码的可重用性。

为了实现这些特性并将它们组合在一起,C++所做的最重要的改进 是提供了类。

10.1 过程性编程和面向对象编程
#

采用过程性编程方法时,首先考虑要遵循的步骤,然后考虑 如何表示这些数据。 如果换成一位OOP程序员,又将如何呢?首先考虑数据——不仅要 考虑如何表示数据,还要考虑如何使用数据。 用户与数据交互的方式有三种:初始化、更新和报告——这就是用户接口。

总之,采用OOP方法时,首先从用户的角度考虑对象——描述对象 所需的数据以及描述用户与数据交互所需的操作。完成对接口的描述 后,需要确定如何实现接口和数据存储。最后,使用新的设计方案创建 出程序。

10.2 抽象和类
#

生活中充满复杂性,处理复杂性的方法之一是简化和抽象。

10.2.1 类型是什么
#

首先,倾向于根 据数据的外观(在内存中如何存储)来考虑数据类型。但是稍加思索就会 发现,也可以根据要对它执行的操作来定义数据类型。总之,指定基本类型完成了三项工作:

  • 决定数据对象需要的内存数量;
  • 决定如何解释内存中的位(longfloat在内存中占用的位数相同,但将它们转换为数值的方法不同);
  • 决定可使用数据对象执行的操作或方法。

10.2.2 C++中的类
#

类是一种将抽象转换为用户定义类型的C++工具,它将数据表示和 操纵数据的方法组合成一个整洁的包。

定义类时,一般来说,类规范由两个部分组成:

  • 类声明:以数据成员的方式描述数据部分,以成员函数(称为方法)的方式描述共有接口;
  • 类方法定义:描述如何类成员函数。

简单地说,类声明提供了类的蓝图,而方法定义则提供了细节。例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// stock00.h -- Stock class interface
// version 00
#ifndef STOCK00_H_
#define STOCK00_H_

#include <string>

class Stock  // class declaration
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    void acquire(const std::string & co, long n, double pr);
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};    // note semicolon at the end

#endif

首先, C++关键字class指出这些代码定义了一个类设计。

1.访问控制

关键字 privatepublic 也是新的,它们描述了对类成员的访问控制。使用类对象的程序都可以直接访问公有部分,但只能通过公有成员 函数(或友元函数,参见第11章)来访问对象的私有成员。

因此,公有成员函 数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏(参见图10.1)。C++还提 供了第三个访问控制关键字protected,第13章介绍类继承时将讨论该关 键字。

类设计尽可能将公有接口与实现细节分开。公有接口表示设计的抽 象组件。**将实现细节放在一起并将它们与抽象分开被称为封装。**数据隐藏(将数据放在类的私有部分中)是一种封装,将实现的细节隐藏在私 有部分中,就像Stock类对set_tot() 所做的那样,也是一种封装。封装的 另一个例子是,将类函数定义和类声明放在不同的文件中。

OOP是一种编程风格,从某种程度说,它用于任何一种语言中。

数据隐藏不仅可以防止直接访问数据,还让开发者(类的用户)无需了解数据是如何被表示的。从使用类的角度看,使用哪种方 法没有什么区别。所需要知道的只是各种成员函数的功能;也就是说,需要知道成员函数接受什么样的参数以及返回什么类型的值。原则是将实现细节从接口设计中分离出来。如果以后找到了更好的、实现数据表 示或成员函数细节的方法,可以对这些细节进行修改,而无需修改程序接口,这使程序维护起来更容易。

2.控制对成员的访问:公有还是私有

无论类成员是数据成员还是成员函数,都可以在类的公有部分或私 有部分中声明它。但由于隐藏数据是OOP主要的目标之一,因此数据项 通常放在私有部分,组成类接口的成员函数放在公有部分;否则,就无 法从程序中调用这些函数。

10.2.3 实现类成员函数
#

还需要创建类描述的第二部分:为那些由类声明中的原型表示的成 员函数提供代码。成员函数定义与常规函数定义非常相似,它们有函数 头和函数体,也可以有返回类型和参数。但是它们还有两个特殊的特征:

  • 定义成员函数时,使用作用域解析运算符 :: 来标识函数所属的类;
  • 类方法可以访问类的 private 组件。

例如:

1
2
3
void Stock::update(double price) {
    ....
}

作用域解析运算符确定了方法定义对应的类的身份。

类方法的第二个特点是,方法可以直接访问类的私有成员,如同访问一个已经声明好的常用变量一样。例如,show( ) 方法可以使用这样的代码:

1
2
3
4
5
6
7
8

void Stock::show()
{
    std::cout << "Company: " << company
              << "  Shares: " << shares << '\n'
              << "  Share Price: $" << share_val
              << "  Total Worth: $" << total_val << '\n';
}

其中,company、shares等都是Stock类的私有数据成员。

另外,类声明常将短小的成员函数作为内联函数在头文件中随类声明一起定义。

内联函数的特殊规则要求在每个使用它们的文件中都对其进行定 义。确保内联定义对多文件程序中的所有文件都可用的、最简便的方法是:将内联定义放在定义类的头文件中

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和 类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一 个副本。例如,假设 katejoe 都是 Stock 对象,则 kate.shares 将占据一个 内存块,而 joe.shares 占用另一个内存块,但kate.show()joe.show() 都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。在OOP中,调用成员函数被称为发送消息,因此将 同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。

10.2.4 使用类
#

使用类与使用基本的内置类型(如int和char)尽可能相同。要创建类对象,可以声明类变量,也可以使用 new 为类对象分配存储空间。可以将对象作为函数的参数和返回值,也可以将一个对象赋给另一个。

要使用新类型,最关键的是要了解成员函数的功能,而不必考虑 其实现细节。

10.2.5 修改实现
#

10.2.6 小结
#

指定类设计的第一步是提供类声明。类声明类似结构声明,可以包 括数据成员和函数成员。声明有私有部分,在其中声明的成员只能通过 成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用 类对象的程序直接访问。通常,数据成员被放在私有部分中,成员函数 被放在公有部分中,因此典型的类声明的格式如下:

10.3 类的构造函数和析构函数
#

C++的目标之一是让使用类对象就像使用标准类型一样。

一般来说,最好是在创建对象时对它进行初始化。

就Stock类当前的实现而言,gift对象的company成员是没有值的。 类设计假设用户在调用任何其他成员函数之前调用acquire( ),但无法强 加这种假设。避开这种问题的方法之一是在创建对象时,自动对它进行 初始化。为此,C++提供了一个特殊的成员函数——类构造函数,专门 用于构造新对象、将值赋给它们的数据成员。名称与类名相同。例如,Stock类一个可能的构造函数是名为Stock( )的成员函 数。构造函数的原型和函数头有一个有趣的特征——虽然没有返回值, 但没有被声明为void类型。实际上,构造函数没有声明类型

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// stock10.h <96> Stock class declaration with constructors, destructor added
#ifndef STOCK1_H_
#define STOCK1_H_
#include <string>
class Stock
{
private:
    std::string company;
    long shares;
    double share_val;
    double total_val;
    void set_tot() { total_val = shares * share_val; }
public:
    Stock();        // default constructor
    Stock(const std::string & co, long n = 0, double pr = 0.0);  // reload constructor;
    ~Stock();       // noisy destructor
    void buy(long num, double price);
    void sell(long num, double price);
    void update(double price);
    void show();
};

#endif

程序声明对 象时,将自动调用构造函数。

10.3.1 声明和定义构造函数
#

通常定义两个构造函数:一个默认空参数,在未提供显式初始值时,用来创建对象;另一个则提供对私有变量做初始化的参数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// stock1.cpp <96> Stock class implementation with constructors, destructor added
#include <iostream>
#include "stock10.h"

// constructors (verbose versions)
Stock::Stock()        // default constructor
{
    std::cout << "Default constructor called\n";
    company = "no name";
    shares = 0;
    share_val = 0.0;
    total_val = 0.0;
}

Stock::Stock(const std::string & co, long n, double pr)
{
    std::cout << "Constructor using " << co << " called\n";
    company = co;

    if (n < 0)
    {
        std::cout << "Number of shares can't be negative; "
                   << company << " shares set to 0.\n";
        shares = 0;
    }
    else
        shares = n;
    share_val = pr;
    set_tot();
}

10.3.2 使用构造函数
#

C++提供了两种使用构造函数来初始化对象的方式。第一种方式是 显式地调用构造函数:

1
2

Stock food = Stock("World Cabbage", 250, 1.25);

另一种方式是隐式地调用构造函数:

1
Stock garment("Furry Mason", 50, 2.5);

这种格式更紧凑,它与下面的显式调用等价。

1
Stock garment = Stock("Furry Mason", 50, 2.5);

每次创建类对象(包括使用 new 动态分配内存)时,C++都使用类构造函数。

1
Stock *pstock = new Stock("Electrosgock Games", 18. 19.0);

这条语句创建一个Stock对象,将其初始化为参数提供的值,并将 该对象的地址赋给pstock指针

但无法使用对象来调用构造函数,因为在构造函数构造出对象之 前,对象是不存在的。因此构造函数被用来创建对象,而不能通过对象来调用。

10.3.3 默认构造函数
#

默认构造函数是在未提供显式初始值时,用来创建对象的构造函数。如果没有提供任何构造函数,则C++将自动提供默认构造函数。它是默认构造函 数的隐式版本,不做任何工作。对于Stock类来说,默认构造函数可能 如下:

1
Stock::Stock() {};

当且仅当没有定义任何构造函数时,编译器才会提供默 认构造函数。为类定义了构造函数后,程序员就必须为它提供默认构造 函数。如果提供了非默认构造函数(如Stock(const char * co, int n, double pr)),但没有提供默认构造函数,则下面的声明将出错:

1
Stock stock1;   // 没有合适的构造函数用于构造对象

定义默认构造函数的方式有两种。一种是给已有构造函数的所 有参数提供默认值:

1
Stock(const string &co = "Error", int n=0, double pr=0);

另一种方式是通过函数重载来定义另一个构造函数——一个没有参 数的构造函数:

1
Stock();

用户定义的默认构造函数通常给所有成员提供隐式初始值。

10.3.4 析构函数
#

用构造函数创建对象后,程序负责跟踪该对象,直到其过期为止。 对象过期时,程序将自动调用一个特殊的成员函数,该函数称为析构函数。析构函数很有用,用于对象过期时的完成清理工作。例 如,如果构造函数使用new来分配内存,则析构函数将使用delete来释放 这些内存。Stock的构造函数没有使用new,因此析构函数实际上没有需 要完成的任务。在这种情况下,只需让编译器生成一个什么要不做的隐式析构函数即可。

析构函数的名称很特殊:在类名前加上~

因此,Stock 类的析构函数为 ~Stock()。另外,和构造函数一样,析构函数也可以没有返回值和声明类型。与构造函数不同的是,析构函数没有参数,因此 Stock 析构函数的原型 必须 是这样的:

1
~Stock();

什么时候应调用析构函数呢?这由编译器决定,通常不应在代码中 显式地调用析构函数。如果创建的是静态存储类对象,则其析构函数将在程序结束 时自动被调用。如果创建的是自动存储类对象,则其析构函数将在程序执行完代码块时(该对象是在其中定义 的)自动被调用。如果对象是通过new创建的,则它将驻留在栈内存或 自由存储区中,当使用delete来释放内存时,其析构函数将自动被调 用。最后,程序可以创建临时对象来完成特定的操作,在这种情况下,程序将在结束对该对象的使用时自动调用其析构函数。

10.3.5 改进 Stock
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// usestok1.cpp -- using the Stock class
// compile with stock10.cpp
#include <iostream>
#include "stock10.h"

int main()
{
  {
    using std::cout;
    cout << "Using constructors to create new objects\n";
    Stock stock1("NanoSmart", 12, 20.0);            // syntax 1
    stock1.show();
    Stock stock2 = Stock ("Boffo Objects", 2, 2.0); // syntax 2
    stock2.show();

    cout << "Assigning stock1 to stock2:\n";
    stock2 = stock1;
    cout << "Listing stock1 and stock2:\n";
    stock1.show();
    stock2.show();

    cout << "Using a constructor to reset an object\n";
    stock1 = Stock("Nifty Foods", 10, 50.0);    // temp object
    cout << "Revised stock1:\n";
    stock1.show();
    cout << "Done\n";
  }
    // std::cin.get();
    return 0;
}

下面的语句表明可以将一个对象赋给同类型的另一个对象:

1
stock2 = stock1;

在默认情况下,将一个对象赋给同类型的另一个对象时,C++将源对象的每个数据成员的内容复制到目标对象中相应的数据成员中

构造函数不仅仅可用于初始化新对象。例如,该程序的main( ) 中包含下面的语句:

1
stock1 = Stock("Nifty Foods", 10, 50.0);

stock1对象已经存在,因此这条语句不是对stock1进行初始化,而 是将新值赋给它。这是通过让构造程序创建一个新的、临时的对象,然后将其内容复制给 stock1 来实现的。临时对象复制完成之后,程序调用析构函数,删除该临时对象。

输出表明,下面两条语句有根本性的差别:

1
2
Stock stock2 = Stock("Boffo Objects", 2, 2.0);
stock1 = Stock("Nifty Foods", 10, 50.0);

第一条语句是初始化,它创建有指定值的对象,可能会创建临时对象(也可能不会);第二条语句是赋值。像这样在赋值语句中使用构造函数总会导致在赋值前创建一个临时对象

如果既可以通过初始化,也可以通过赋值来设置对象的值,则应采用初始化方式。通常这种 方式的效率更高。

6.const 成员函数

请看下面的代码片段:

1
2
const Stock land = Stock("KP", 0, 0.0);
land.show();

对于当前的C++来说,编译器将拒绝第二行。这是什么原因呢?因为 show() 的代码无法确保调用对象不被修改。

我们以前通过将函数参数声明为 const 引用或指向 const 的指针来解决这种问题。但这里存在语法问题:show() 方法没有任何参数。相反,它所使用的对象是由方法调用隐式地提供。

需要一种新的语法来保证函数不会修改调用对象。C++的解决方法是将 const 关键字放在函数的括号后面。也就是说,show() 声明应像这样:

1
void show() const;         // promise not to change invoking object

同样,函数定义的开头应像这样:

1
2
3
void stock::show() const {
    ...
}

以这种方式声明和定义的类函数被称为 const 成员函数,从而确保函数内不会修改调用对象。

10.3.6 构造函数和析构函数小结
#

构造函数是一种特殊的类成员函数,在创建类对象时被调用。构造 函数的名称和类名相同,但通过函数重载,可以创建多个同名的构造函 数,条件是每个函数的特征标(参数列表)都不同。另外,构造函数没 有声明类型。通常,构造函数用于初始化类对象的成员,初始化应与构 造函数的参数列表匹配。

默认构造函数没有参数,因此如果创建对象时没有进行显式地初始 化,则将调用默认构造函数。如果程序中没有提供任何构造函数,则编 译器会为程序定义一个默认构造函数;否则,必须自己提供默认构造函 数。默认构造函数可以没有任何参数;如果有,则必须给所有参数都提供默认值。

当对象被删除时,程 序将调用析构函数。每个类都只能有一个析构函数。析构函数没有返回 类型(连 void 都没有),也没有参数,其名称为类名称前加上 ~

如果构造函数使用了 new,则必须提供使用 delete 的析构函数。

10.4 this 指针
#

有时候类方法可能涉及到两个对象,在这种情况下需要使用 this 指针。

如何将方法的答案传回给调用程序呢?最直接的方法是让方法返回一个引用,该引用指向股价总值较高的对象。因此,用于比较的类方法 topval 的原型如下:

1
const Stock & topval(const Stock &s) const;

该函数隐式地访问一个对象,而显式地访问另一个对象,并返回其 中一个对象的引用。括号中的const 表明,该函数不会修改被显式地访问的对象;而括号后的 const 表明,该函数不会修改被隐式地访问的对象。 由于该函数返回了两个 const 对象之一的引用,因此返回类型也应为 const 引用。

比较之后,返回引用时有一个问题需要解决:

C++解决这种问题的方法是:使用被称为 this 的特殊指针。this 指针指向用来调用成员函数的对象(this被作为隐藏参数传递给方法)。这样,函数调用 stock1.topval(stock2)this 设置为 stock1 对象的地址,使得这个指针可用于 topval() 方法。

一般来说,所有的类方法都将 this 指针设置为调用它的对象的地址。而 topval() 中的 total_val 只不过是 this->total_val 的简写。

1
2
3
4
5
6
7
const Stock & Stock::topval(const Stock & s) const
{
    if (s.total_val > total_val)
        return s;
    else
        return *this;
}

10.5 对象数组
#

声明对象数组的方法与 声明标准类型数组相同。

1
Stock mystuff[4];

可以用构造函数来初始化数组元素。在这种情况下,必须为每个元 素调用构造函数:

1
2
3
4
5
6
7
/ create an array of initialized objects
    Stock stocks[4] = {
        Stock("NanoSmart", 12, 20.0),
        Stock("Boffo Objects", 200, 2.0),
        Stock("Monolithic Obelisks", 130, 3.25),
        Stock("Fleep Enterprises", 60, 6.5)
    };

这里的代码使用标准格式对数组进行初始化:用括号括起的、以逗 号分隔的值列表。其中,每次构造函数调用表示一个值。如果类包含多 个构造函数,则可以对不同的元素使用不同的构造函数。

10.6 类作用域
#

在类中定义的名称(如类数据成员名和类成员函数名)的作用域都 为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可 知的。因此,可以在不同类中使用相同的类成员名而不会引起冲突。

总之,在类声明或成员函数定义中,可以使用未修饰的成员名称 (未限定的名称),就像sell( ) 调用 set_tot() 成员函数时那样。构造函数名称在被调用时,才能被识别,因为它的名称与类名相同。在其他情况下,使用类成员名时,必须根据上下文使用直接成员运算符、间接成员运算符 -> 或作用域解析运算符 ::

10.6.1 作用域为类的常量
#

有时候,使符号常量的作用域为类很有用。例如,类声明可能使用 字面值30来指定数组的长度,由于该常量对于所有对象来说都是相同 的,因此创建一个由所有对象共享的常量是个不错的主意。你以为可以这样:

1
2
3
4
5
class Bakery {
private:
    const int Months = 12;
    double costs[Months];
}

这是不行的!! 因为声明类只是描述了对象的形式,并没有创建对象。

C++提供了另一种在类中定义常量的方式——使用关键字 static

1
2
3
4
5
class Bakery {
private:
    static const int Months = 12;
    double costs[Months];
}

10.6.2 作用域内枚举(C++11)
#

10.7 抽象数据类型
#

10.8 总结
#

面向对象编程强调的是程序如何表示数据。使用OOP方法解决编程问题的第一步是根据它与程序之间的接口来描述数据,从而指定如何使 用数据。然后,设计一个类来实现该接口。

通常,将类声明分成两部分组成,这两部分通常保存在不同的文件 中。类声明(包括由函数原型表示的方法)应放到头文件中。定义成员 函数的源代码放在方法文件中。这样便将接口描述与实现细节分开了。

类是用户定义的类型,对象是类的实例

每个对象都存储自己的数据,而共享类方法。

如果希望成员函数对多个对象进行操作,可以将额外的对象作为参数传递给它。如果方法需要显式地引用调用它的对象,则可以使用 this 指针。this 指针被设置为调用对象的地址,因此*this 是该对象的别名。

第十一章 使用类
#

本章内容包括:

  • 运算符重载;
  • 友元函数;
  • 重载 << 运算符,以便用于输出;
  • 状态成员;
  • 使用 rand() 生成随机值;
  • 类的自动转换和强制类型转换;
  • 类转换函数。

学习C++的难点之一是需要记住大量的东西,但在拥有丰富的实践 经验之前,根本不可能全部记住这些东西。从这种意义上说,学习 C++ 就像学习功能复杂的字处理程序或电子制表程序一样。任何特性都不可怕,但多数人只掌握了那些经常使用的特性,如查找文本或设置为 斜体等。您可能在那里曾经学过如何生成替换字符或者创建目录,除非经常使用它们,否则这些技能可能根本与日常工作无关。也许,学习本 章知识的最好方法是,在我们自己开发的C++程序中使用其中的新特性。

11.1 运算符重载
#

运算符重载是一种形式的 C++ 多态。 运算符 重载将重载的概念扩展到运算符上,允许赋予C++运算符多种含义。实 际上,很多C++(也包括C语言)运算符已经被重载。例如,将 * 运算符用于地址,将得到存储在这个地址中的值;但将它用于两个数字时,得到的将是它们的乘积。C++根据操作数的数目和类型来决定采用哪种操 作。

重载运算符可使代码看起来更自然。

要重载运算符,需使用被称为运算符函数的特殊函数形式。运算符函数的格式为:operatorop(argument-list)

例如,operator +() 重载 + 运算符,operator *() 重载 * 运算符。op 必须是有效的C++运算符,不能虚构一个新的符号。例如,不能有 operator@() 这样的函数,因为C++中没有 @ 运算符。然而,operator 函数可以重载 [] 运算符,因为 [] 是数组索引运算符。

11.2 计算时间:一个运算符重载示例
#

定义一个 Time 类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// mytime0.h -- Time class before operator overloading
#ifndef MYTIME0_H_
#define MYTIME0_H_

class Time
{
private:
    int hours;
    int minutes;
public:
    Time();
    Time(int h, int m = 0);
    void AddMin(int m);
    void AddHr(int h);
    void Reset(int h = 0, int m = 0);
    Time Sum(const Time & t) const;
    void Show() const;
};
#endif

该类的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include "mytime0.h"

Time::Time()
{
    hours = minutes = 0;
}

Time::Time(int h, int m )
{
    hours = h;
    minutes = m;
}

void Time::AddMin(int m)
{
    minutes += m;
    hours += minutes / 60;
    minutes %= 60;
}

void Time::AddHr(int h)
{
    hours += h;
}

void Time::Reset(int h, int m)
{
    hours = h;
    minutes = m;
}

Time Time::Sum(const Time & t) const
{
    Time sum;
    sum.minutes = minutes + t.minutes;
    sum.hours = hours + t.hours + sum.minutes / 60;
    sum.minutes %= 60;
    return sum;
}

void Time::Show() const
{
    std::cout << hours << " hours, " << minutes << " minutes";
}

来看一下 Sum() 函数的代码。注意参数是引用,但返回类型却不是 引用。将参数声明为引用的目的是为了提高效率。如果按值传递 Time 对象,代码的功能将相同,但传递引用,速度将更快,使用的内存将更少。

然而,返回值不能是引用。因为函数将创建一个新的Time对象 (sum),来表示另外两个Time对象的和。返回对象(如代码所做的那 样)将创建对象的副本,而调用函数可以使用它。然而,如果返回类型 为 Time &,则引用的将是 sum 对象。但由于 sum 对象是局部变量,在函数结束时将被删除,因此引用将指向一个不存在的对象。使用返回类型 Time 意味着程序将在删除 sum 之前构造它的拷贝,调用函数将得到该拷贝。

不要返回指向局部变量或临时对象的引用。函数执行完毕后,局部变量和临时对象将消失, 引用将指向不存在的数据。

但这种 sum 时间的方法看起来很傻。

11.2.1 添加加法运算符
#

将Time类转换为重载的加法运算符很容易,只要将Sum() 的名称改为 operator +() 即可。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// mytime1.h -- Time class before operator overloading
#ifndef MYTIME1_H_
#define MYTIME1_H_

class Time
{
private:
    int hours;
    int minutes;
public:
    Time();
    Time(int h, int m = 0);
    void AddMin(int m);
    void AddHr(int h);
    void Reset(int h = 0, int m = 0);
    Time operator+ (const Time & t) const;
    void Show() const;
};
#endif

类定义中改为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// mytime1.cpp  -- implementing Time methods
#include <iostream>
#include "mytime1.h"
...
Time Time::operator+(const Time & t) const
{
    Time sum;
    sum.minutes = minutes + t.minutes;
    sum.hours = hours + t.hours + sum.minutes / 60;
    sum.minutes %= 60;
    return sum;
}
...

将该方法命令改为 operator +() 后,就可以使用运算符表示法:

1
total = coding + fixing;
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
// usetime1.cpp -- using the second draft of the Time class
// compile usetime1.cpp and mytime1.cpp together
#include <iostream>
#include "mytime1.h"

int main()
{
    using std::cout;
    using std::endl;
    Time planning;
    Time coding(2, 40);
    Time fixing(5, 55);
    Time total;

    total = coding + fixing;
    cout << "coding + fixing = ";
    total.Show();
    cout << endl;

    return 0;
}

11.2.2 重载限制
#

多数C++运算符(参见表11.1)都可以用这样的方式重载。重载的运算符(有些例外情况)不必是成员函数,但必须至少有一个操作数是 用户定义的类型。下面详细介绍C++对用户定义的运算符重载的限制:

  • 1.重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符;
  • 2.使用运算符时不能违反运算符原来的句法规则。例如不要将 - 重载为加法运算。同样,不能修改运算符的优先级;
  • 3.不能创建新运算符。例如,不能定义 operator **() 函数来表示求幂;
  • 4.不能重载下面的运算符:

  • 5.表11.1中的大多数运算符都可以通过成员或非成员函数进行重 载,但下面的运算符只能通过成员函数进行重载。

11.2.3 其他重载运算符
#

还有一些其他的操作对 Time 类来说是有意义的。例如,可能要将两个时间相减或将时间乘以一个因子,这需要重载减法和乘法运算符。这 和重载加法运算符采用的技术相同,即创建 operator –()operator *() 方法。也就是说,将下面的原型添加到类声明中:

1
2
Time operator- (const time & t) const;
Time operator* (double n) const;

修改类定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mytime2.cpp  -- implementing Time methods
#include <iostream>
#include "mytime2.h"

...
Time Time::operator-(const Time & t) const
{
    Time diff;
    int tot1, tot2;
    tot1 = t.minutes + 60 * t.hours;
    tot2 = minutes + 60 * hours;
    diff.minutes = (tot2 - tot1) % 60;
    diff.hours = (tot2 - tot1) / 60;
    return diff;
}

Time Time::operator*(double mult) const
{
    Time result;
    long totalminutes = hours * mult * 60 + minutes * mult;
    result.hours = totalminutes / 60;
    result.minutes = totalminutes % 60;
    return result;
}
...

11.3 友元
#

通常,公有类方法提 供唯一的访问途径,但是有时候这种限制太严格,以致于不适合特定的 编程问题。在这种情况下,C++提供了另外一种形式的访问权限:友 元。友元有3种:

  • 友元函数;
  • 友元类;
  • 友元成员函数。

通过让函数成为类的友元,可以赋予该函数与类的成员函数相同的访问权限。

需要友元的原因是为了解决这样一个问题:

对于非成员重载运算符函数来说,运算符表达式左边的操作数对应于运算符函数的第一个参数,运算符表达式右边的操作数对应于运算符函数的第二个参数。而原来的成员函数则按相反的顺序处理操作数,也就是说,double值乘以Time值。

使用非成员函数可以按所需的顺序获得操作数(先是double,然后 是Time),但引发了一个新问题:非成员函数不能直接访问类的私有数据,至少常规非成员函数不能访问。然而,有一类特殊的非成员函数可以访问类的私有成员,它们被称为友元函数。

11.3.1 创建友元
#

创建友元函数的第一步是将其原型放在类声明中,并在原型声明前 加上关键字 friend

1
friend Time operator* (double m, const Time & t);

该原型意味着下面两点:

  • 虽然 operator *() 函数是在类声明中声明的,但它不是成员函数,因 此不能使用成员运算符来调用;
  • 虽然 operator *() 函数不是成员函数,但它与成员函数的访问权限相同。

第二步是编写函数定义。因为它不是成员函数,所以不要使用 Time:: 限定符。另外,不要在定义中使用关键字 friend,定义应该如下:

1
2
3
4
5
6
7
Time operator*(double mult) {
    Time result;
    long totalminutes = hours * mult * 60 + minutes * mult;
    result.hours = totalminutes / 60;
    result.minutes = totalminutes % 60;
    return result;
}

有了上述声明和定义后,语句:A = 2.75 * B 就被自动转换为 A = operator*(2.75, B)

总之,类的友元函数是非成员函数,但访问权限与成员函数相同

11.3.2 常用的友元:重载 << 运算符
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// mytime3.h -- Time class with friends
#ifndef MYTIME3_H_
#define MYTIME3_H_
#include <iostream>

class Time
{
private:
    int hours;
    int minutes;
public:
    Time();
    Time(int h, int m = 0);
    void AddMin(int m);
    void AddHr(int h);
    void Reset(int h = 0, int m = 0);
    Time operator+(const Time & t) const;
    Time operator-(const Time & t) const;
    Time operator*(double n) const;
    friend Time operator*(double m, const Time & t)
        { return t * m; }   // inline definition
    friend std::ostream & operator<<(std::ostream & os, const Time & t);

};
#endif

代码中对 friend Time operator*(double m, const Time & t) 的重载定义非常棒,是一个相当聪明的做法!它不是重新写一段代码,而是调用已重载的 Time operator*(double n) const 成员函数。

11.4 重载运算符:作为成员函数还是非成员函数
#

一般来说,非成员函数应是友元函数,这样它才能直接访 问类的私有数据。加法运算符需要两个操作数。对于成员函数版本来说,一个操作数 通过this指针隐式地传递,另一个操作数作为函数参数显式地传递;对 于友元版本来说,两个操作数都作为参数来传递。

对于某些运算符来说(如前所述),成员函数是唯一合法的选择。在其他情况下,这两种格式没有太大的区别。有时,根据类设计,使用非成员函数版本可能更好(尤其是为类定义类型 转换时)。

11.5 再谈重载:一个矢量类
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
    // subtract Vector b from a
    Vector Vector::operator-(const Vector & b) const
    {
        return Vector(x - b.x, y - b.y);
    }

    // reverse sign of Vector
    Vector Vector::operator-() const
    {
        return Vector(-x, -y);
    }

operator-( )有两种不同的定义。这是可行的,因为它们的特征标不同(两个矢量相减,或者单个矢量取反)。可以定义−运算符的一元和二元版本,因为C++提供了该运 算符的一元和二元版本。对于只有二元形式的运算符(如除法运算符),只能将其重载为二元运算符。

因为运算符重载是通过函数来实现的,所以只要运算符函数的特征标不同,使用的运算符数 量与相应的内置C++运算符相同,就可以多次重载同一个运算符。

11.6 类的自动转换和强制类型转换
#

11.7 总结
#

一般来说,访问私有类成员的唯一方法是使用类方法。C++使用友 元函数来避开这种限制。要让函数成为友元,需要在类声明中声明该函 数,并在声明前加上关键字friend。

C++扩展了对运算符的重载,允许自定义特殊的运算符函数,这种 函数描述了特定的运算符与类之间的关系。运算符函数可以是类成员函数,也可以是友元函数(有一些运算符函数只能是类成员函数)。

最常见的运算符重载任务之一是定义«运算符,使之可与cout一起 使用,来显示对象的内容。要让ostream对象成为第一个操作数,需要将 运算符函数定义为友元;要使重新定义的运算符能与其自身拼接,需要 将返回类型声明为 ostream &

然而,如果类包含这样的方法,它返回需要显示的数据成员的值, 则可以使用这些方法,无需在operator<<()中直接访问这些成员。在这种情况下,函数不必(也不应当)是友元。

C++允许指定在类和基本类型之间进行转换的方式。首先,任何接 受唯一一个参数的构造函数都可被用作转换函数,将类型与该参数相同 的值转换为类。如果将类型与该参数相同的值赋给对象,则C++将自动调用该构造函数。

要将类对象转换为其他类型,必须定义转换函数,指出如何进行这种转换。转换函数必须是成员函数。

第十二章 类和动态内存分配
#

本章内容包括:

  • 对类成员使用动态内存分配;
  • 隐式和显示复制构造函数;
  • 隐式和显示重载赋值运算符;
  • 在构造函数中使用 new 所必须完成的工作;
  • 使用静态类成员;
  • 将定位 new 运算符用于对象;
  • 使用指向对象的指针;
  • 实现队列抽象数据类型(ADT)。

对类使用 newdelete 将影响构造函数和析构函数的设计以及运算符的重载。

12.1 动态内存和类
#

12.1.1 复习示例和静态类成员
#

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// strngbad.h -- flawed string class definition
#include <iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_
class StringBad
{
private:
    char * str;                // pointer to string
    int len;                   // length of string
    static int num_strings;    // number of objects
public:
    StringBad(const char * s); // constructor
    StringBad();               // default constructor
    ~StringBad();              // destructor
// friend function
    friend std::ostream & operator<<(std::ostream & os,
                       const StringBad & st);
};
#endif

对这个声明,需要注意的有两点。首先,它使用char指针(而不是 char数组)来表示姓名。这意味着类声明没有为字符串本身分配存储空 间,而是在构造函数中使用new来为字符串分配空间。这避免了在类声 明中预先定义字符串的长度。

其次,将num_strings成员声明为静态存储类。静态类成员有一个特点:无论创建了多少对象,程序都只创建一个静态类变量副本。也就是说,StringBad 类的所有对象共享同一个 num_strings。假设创建了10个 StringBad 对象,将有10个 str 成员和10个 len 成员,但只有一个共享的 num_strings 成员(参见图12.1)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// strngbad.cpp -- StringBad class methods
#include <cstring>                    // string.h for some
#include "strngbad.h"
using std::cout;

// initializing static class member
int StringBad::num_strings = 0;

// class methods

// construct StringBad from C string
StringBad::StringBad(const char * s)
{
    len = std::strlen(s);             // set size
    str = new char[len + 1];          // allot storage
    std::strcpy(str, s);              // initialize pointer
    num_strings++;                    // set object count
    cout << num_strings << ": \"" << str
         << "\" object created\n";    // For Your Information
}

StringBad::StringBad()                // default constructor
{
    len = 4;
    str = new char[4];
    std::strcpy(str, "C++");          // default string
    num_strings++;
    cout << num_strings << ": \"" << str
         << "\" default object created\n";  // FYI
}

StringBad::~StringBad()               // necessary destructor
{
    cout << "\"" << str << "\" object deleted, ";    // FYI
    --num_strings;                    // required
    cout << num_strings << " left\n"; // FYI
    delete [] str;                    // required
}

std::ostream & operator<<(std::ostream & os, const StringBad & st)
{
    os << st.str;
    return os;
}

int StringBad::num_strings = 0; 这条语句将静态成员 num_strings 的值初始化为零。注意:不能在类声明中初始化静态成员变量,这是因为类声明中只描述如何分配内存,但并不分配内存

可以在类声明之外使用单独的语句来进行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。

另外,初始化是在方法文件中,而不是在类声明文件中进行的,这是因为类声明位于头文件中,程序可能将头文件包括在其他几个文件中。如果 在头文件中进行初始化,将出现多个初始化语句副本,从而引发错误。

上述代码中,strlen()返回字符串长度,但不包括末尾的空字符,因此构造函数将len加1,使分配的内存能够存储包含空字符的字符串。

删除对象可以释放对象本身占用的内存,但并不能自动释放属于对象成员的指针指向的内存。因此,必须使用析构函数。在析构函数中使用delete语句可确保对象过期 时,由构造函数使用new分配的内存被释放。

在构造函数中使用 new 来分配内存时,必须在相应的析构函数中使用 delete 来释放内存。如果 使用 new[](包括中括号)来分配内存,则应使用delete[](包括中括号)来释放内存。

StringBad的第一个版本有许多故意留下的缺陷,是一个很糟糕的类(找到该类的错误之处,甚至可以作为一道困难的编程题),这些缺陷使得输出是不确定的。例如,有些 编译器无法编译它。虽然输出的具体内容有所差别,但基本问题和解决方法(稍后将介绍) 是相同的。

程序输出结果:

callme2() 按值(而不是按引用)传递 headline2,结果表明这是一个严重的问题。

首先,将 headline2 作为函数参数来传递从而导致析构函数被调用。这是因为函数的参数 sb 是一个临时变量,当函数调用结束后会释放这个临时变量,从而导致析构函数被调用,糟糕的源头在于析构函数中恰巧就释放了字符串。 其次,虽然按值传递可以防止原始参数被修改,但实际上函数已使原始 字符串无法识别,导致显示一些非标准字符(显示的文本取决于内存中 包含的内容)。

因为自动存储对象被删除的顺序与创建顺序相反,所以最先删除的 3个对象是knotssailorsport。删除knotssailor时是正常的,但在删 除sport时,Dollars变成了Doll8(或其他)。对于sport,程序只使用它来初始化 sailor,但这种操作修改了 sports(这又可以做一道题)。

具体而言,程序中 Stringbad sailor = sports; 这个语句既不是调用默认构造函数也不是调用参数为 const char* 的构造函数,而是等价于 StringBad sailor=StringBad(sports); ,又因为sports的类型为StringBad,因此与之相应的构造函数原型应该是 StringBad(const String &);但这个构造函数在StringBad类中没有显式声明更没有定义。这时当我们使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。但自动生成的复制构造函数不知道需要更新静态变量num_string,因此会将计数方案搞乱(这就是复制对象带来的问题)。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的。

最后被删除的两个对象(headline2headline1)已经无法识别。

12.1.2 特殊成员函数
#

StringBad类的问题是由特殊成员函数引起的。这些成员函数是自动定义的,就StringBad而言,这些函数的行为与类设计不符。具体地说, C++自动提供了下面这些成员函数:

  • 默认构造函数;
  • 默认析构函数;
  • 复制构造函数;
  • 复制预运算符;
  • 地址运算符。

更准确地说,编译器将生成上述最后三个函数的定义——如果程序 使用对象的方式要求这样做。例如,如果您将一个对象赋给另一个对 象,编译器将提供赋值运算符的定义。

结果表明,StringBad类中的问题是由隐式复制构造函数和隐式赋值运算符引起的。

1.默认构造函数

默认情况下,编译器将提供一个不接受任何参数,也不执行任何操作 的构造函数(默认的默认构造函数),这是因为创建对象时总是会调用 构造函数。

如果定义了构造函数,C++将不会定义默认构造函数。

2.复制构造函数

复制构造函数用于将一个对象复制到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数),而不是常规的赋值过程(那赋值的时候怎么办?赋值靠赋值运算符=,见12.1.4),原型是:Class_name(const Class_name &)。它接受一个指向类对象的常量引用作为参数。

对于复制构造函数,需要知道两点:何时调用和有何功能。

3.何时调用复制构造函数

新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。 最常见的情况是将新对象显式地初始化为现有的对象。

其中中间的2种声明可能会使用复制构造函数直接创建metoo和 also,也可能使用复制构造函数生成一个临时对象,然后将临时对象的 内容赋给metoo和also,这取决于具体的实现。最后一种声明使用motto 初始化一个匿名对象,并将新对象的地址赋给pStringBad指针。

每当程序生成了对象副本时,编译器都将使用复制构造函数。具体地说,当函数按值传递对象(如程序清单12.3中的 callme2())或函数返回对象时,都将使用复制构造函数。记住,按值传递意味着创建原始变量的一个副本。编译器生成临时对象时,也将使用复制构造函数。

何时生成临时对象随编译器而异,但无论是哪种编译器,当按值传递和返回对象时,都将调用复制构造函数

由于按值传递对象将调用复制构造函数,因此应该按引用传递对象。这样可以节省调用构造函数的时间以及存储新对象的空间。

4.默认的复制构造函数的功能

默认的复制构造函数逐个复制非静态成员(成员复制也称为浅复制),复制的是成员的值。

12.1.3 回到 Stringbad:复制构造函数的哪里出了问题
#

第一个异常,num_string是负值。当callme2()被调用时,复制构造函数被用来初始化 callme2() 的形参,还被用来将对象 sailor 初始化为对象 sports。默认的复 制构造函数不说明其行为,因此它不指出创建过程,也不增加计数器 num_strings的值。但析构函数更新了计数,并且在任何对象过期时都将 被调用,而不管对象是如何被创建的。程序的输出表明,析构函数的调用次数比构造函数的调用次数多2。

第二个异常之处更微妙,也更危险,其症状之一是字符串内容出现乱码。原因在于隐式复制构造函数是按值进行复制的。例如,对于程序清 单12.3,隐式复制构造函数的功能相当于:sailor.str = sport.str;。这里复制的并不是字符串,而是一个指向字符串的指针。也就是 说,将sailor初始化为sports后,得到的是两个指向同一个字符串的指 针。当operator «()函数使用指针来显示字符串时,这并不会出现问 题。但当析构函数被调用时,这将引发问题。析构函数StringBad释放str 指针指向的内存,因此释放sailor的效果如下:

sports.str指向的内存已经被 sailor 的析构函数释放,这将导致不确定 的、可能有害的后果。

1.定义一个显式复制构造函数以解决问题

解决类设计中这种问题的方法是进行深度复制(deep copy)。也就是说,复制构造函数应当复制字符串并将副本的地址赋给str成员,而不仅仅是复制字符串地址。这样每个对象都有自己的字符串,而不是引用 另一个对象的字符串。调用析构函数时都将释放不同的字符串,而不会试图去释放已经被释放的字符串。可以这样编写String的复制构造函数:

1
2
3
4
5
6
7
String::String(const char * s)     // construct String from C string
{
    len = std::strlen(s);          // set size
    str = new char[len + 1];       // allot storage
    std::strcpy(str, s);           // initialize pointer
    num_strings++;                 // set object count
}

12.1.4 Stringbad的其他问题:赋值运算符
#

并不是程序清单12.3的所有问题都可以归咎于默认的复制构造函 数,还需要看一看默认的赋值运算符。C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符 的原型如下:

Class_name & Class_name::operator=(const Class_name &);

它接受并返回一个指向类对象的引用。

1.赋值运算符的功能以及何时使用它

将已有的对象赋给另一个对象时,将使用重载的赋值运算符。初始化对象时,并不一定会使用赋值运算符,而更可能是调用复制构造函数。

1
StringBad metoo = knot; 

这里,metoo 是一个新创建的对象,被初始化为 knot 的值,因此使用复制构造函数。然而,正如前面指出的,实现时也可能分两步来处理这条语句:使用复制构造函数创建一个临时对象,然后通过赋值将临时 对象的值复制到新对象中。这就是说,初始化总是会调用复制构造函数,而使用 = 运算符时也可能调用赋值运算符。

与复制构造函数相似,赋值运算符的隐式实现也对成员进行逐个复制,静态成员不受影响。

2.赋值的问题出在哪里

程序清单12.3将headline1赋给knot:knot=headline1;。为knot调用析构函数时,正常。为Headline1调用析构函数时,异常。出现的问题与隐式复制构造函数相同:数据受损。这也是成员复制 的问题,即导致headline1.str和knot.str指向相同的地址

  1. 解决赋值的问题

解决办法是提供赋 值运算符(进行深度复制)定义,这与复制构造函数相似,但也有 一些差别:

  • 由于目标对象可能引用了以前分配的数据,所以函数应使用delete [] 来释放这些数据;
  • 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内 存操作可能删除对象的内容
  • 函数返回一个指向调用对象的引用(注意是引用,不是值也不是指针)。
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// assign a String to a String
String & String::operator=(const String & st)
{
    if (this == &st)
        return *this;
        
    delete [] str;
    len = st.len;
    str = new char[len + 1];
    std::strcpy(str, st.str);
    return *this;
}

赋值操作并不创建新的对象,因此不需要调整静态数据成员 num_strings 的值。

12.2 改进后的新String类
#

首先,添加前面介绍过的复制构造函数和赋值运算符,使类 能够正确管理类对象使用的内存。其次,由于您已经知道对象何时被创 建和释放,因此可以让类构造函数和析构函数保持沉默,不再在每次被 调用时都显示消息。另外,也不用再监视构造函数的工作情况,因此可以简化默认构造函数,使之创建一个空字符串,而不是“C++”。

新默认构造函数中:

1
2
3
4
5
6
7
String::String()                   // default constructor
{
    len = 4;
    str = new char[1];
    str[0] = '\0';                 // default string
    num_strings++;
}

为什么代码为 str=new char[1]; 而不是 str = new char;,这两个方式相同,但区别在于前者和类析构函数兼容,而后者不兼容。这是析构函数:

1
2
3
4
5
String::~String()                     // necessary destructor
{
    --num_strings;                    // required
    delete [] str;                    // required
}

operator> 的重载很妙啊,直接利用了 operator< 的重载结果:

12.2.3 使用中括号表示法访问字符
#

在C++中,两个中括号组成一个运算符——中括号运算符,可以使 用方法operator 来重载该运算符。通常,二元C++运算符(带两个操作 数)位于两个操作数之间,例如2 +5。但对于中括号运算符,一个操作数位于第一个中括号的前面,另一个操作数位于两个中括号之间。例如:city[0] 中,city是第一个操作数,[]是运算符,0是第二个操作数。

在重载时,C++将区分常量和非常量函数的特征标。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// read-write char access for non-const String
char & String::operator[](int i)
{
    return str[i];
}

// read-only char access for const String
const char & String::operator[](int i) const
{
    return str[i];
}

有了上述定义后,就可以读/写常规String对象了;而对于const String对象,则只能读取其数据。

重载要注意同时考虑对const 和非 const 变量进行。

也可以修改内容:

1
2
String means("might");
means[0] = 'r';   // 这一句相当于 means.str[0] = 'r', 但 str 是私有成员,实际是没法在成员函数之外这样使用的。

12.2.4 静态类成员函数
#

首先,不能通过对象调用静态成员函数;实际上,静态成员函数甚 至不能使用this指针。

其次,由于静态成员函数不与特定的对象相关联,因此只能使用静 态数据成员。

12.2.5 进一步重载赋值运算符
#

重载»运算符提供了一种将键盘输入行读入到String对象中的简单 方法。它假定输入的字符数不多于String::CINLIM的字符数,并丢弃多 余的字符。在if条件下,如果由于某种原因(如到达文件尾或get(char *, int) 读取的是一个空行)导致输入失败,istream对象的值将置为 false。

12.3 在构造函数中使用new时应注意的事项
#

使用new初始化对象的指针成员时必须特别小心:

  • 如果在构造函数中使用new来初始化指针成员,则应在析构函数中 使用delete;
  • new和delete必须相互兼容。new对应于delete,new[ ]对应于delete[ ];
  • 如果有多个构造函数,则必须以相同的方式使用new,要么都带中 括号,要么都不带。因为只有一个析构函数,所有的构造函数都必 须与它兼容。然而,可以在一个构造函数中使用new初始化指针, 而在另一个构造函数中将指针初始化为空(0或C++11中的 nullptr),这是因为delete(无论是带中括号还是不带中括号)可以用于空指针;
  • 应定义一个复制构造函数,通过深度复制将一个对象初始化为另一 个对象;

  • 应当定义一个赋值运算符,通过深度复制将一个对象复制给另一个对象;

具体地说,该方法应完成这些操作:检查自我赋值的情况,释放成员指针以前指向的内存,复制数据而不仅仅是数据的地址,并返回一个指向调用对象的引用

12.3.1 应该和不应该
#

12.3.2 包含类成员的类的逐成员复制
#

如果您将一个 Magazine对象复制或赋值给另一个Magazine对象,逐成员复制将使用成 员类型定义的复制构造函数和赋值运算符。也就是说,复制成员title 时,将使用String的复制构造函数,而将成员title赋给另一个Magazine对 象时,将使用String的赋值运算符,依此类推。

12.4 有关返回对象的说明
#

当成员函数或独立的函数返回对象时,有几种返回方式可供选择。 可以返回指向对象的引用、指向对象的const引用或const对象

12.4.1 返回指向const对象的引用
#

这里有三点需要说明。首先,返回对象将调用复制构造函数,而返回引用不会,所以版本2效率更高。其次,引用指向的对象应该在调用函数执行时存在。第 三,v1和v2都被声明为const引用,因此返回类型必须为const,这样才匹配

12.4.2 返回指向非const对象的引用
#

两种常见的返回非 const 对象情形是,重载赋值运算符以及重载与 cout 一起使用的 << 运算符。前者这样做旨在提高效率,而后者必须这样做。

Operator«()的返回值用于串接输出:

1
2
String s1("Good stuff");
cout << s1 << " is comming!";

operator<<(cout, s1)的返回值成为一个用于显示字符串“is coming!”的对象。返回类型必须是 ostream &,而不能仅仅是 ostream如果使用返回类型ostream,将要求调用ostream类的复制构造 函数,而ostream没有公有的复制构造函数

12.4.3 返回对象
#

如果被返回的对象是被调用函数中的局部变量,则不应按引用方式返回它,因为在被调用函数执行完毕时,局部对象将调用其析构函数。因此,当控制权回到调用函数时,引用指向的对象将不再存在。在这种情况下,应返回对象而不是引用

12.4.4 返回 const 对象
#

前面的Vector::operator+( )定义有一个奇异的属性,它旨在让您能够 以下面这样的方式使用它:net = force1 + force2;

然而,这种定义也允许您这样使用它:

force1 + force2 = net;
cout << (force1 + force2 = net).magval() << endl;

这提出了三个问题。为何编写这样的语句?这些语句为何可行?这 些语句有何功能?首先,没有要编写这种语句的合理理由,但并非所有代码都是合理的

这种代码之所以可行,是因为复制构造函数将创建一个临时 对象来表示返回值。因此,在前面的代码中,表达式force1 + force2的结 果为一个临时对象。在语句1中,该临时对象被赋给net;在语句2和3 中,net被赋给该临时对象。使用完临时对象后,将把它丢弃。

总之,如果方法或函数要返回局部对象,则应返回对象,而不是指 向对象的引用。在这种情况下,将使用复制构造函数来生成返回的对 象。如果方法或函数要返回一个没有公有复制构造函数的类(如ostream类)的对象,它必须返回一个指向这种对象的引用。最后,有些方法和 函数(如重载的赋值运算符)可以返回对象,也可以返回指向对象的引 用,在这种情况下,应首选引用,因为其效率更高。

12.5 使用指向对象的指针
#

12.5.1 再谈newdelete
#

12.5.2 指针和对象小结
#

  • 使用常规表示法来声明指向对象的指针;
  • 可以将指针初始化为指向已有的对象;
  • 可以使用new来初始化指针,这将创建一个新的对象;
  • 对类使用new将调用相应的类构造函数来初始化新创建的对象。

  • 可以使用->运算符通过指针访问类方法;
  • 可以对对象指针应用解除引用运算符*来获得对象。

12.5.3 再谈定位new运算符
#

12.6 复习各种技术
#

12.6.1 重载«运算符
#

要重新定义 « 运算符,以便将它和cout一起用来显示对象的内 容,请定义下面的友元运算符函数:

12.6.2 转换函数
#

要将单个值转换为类类型,需要创建原型如下所示的类构造函数:

1
class_name(type_name value);

要将类转换为其他类型,需要创建原型如下所示的类成员函数:

operator type_name();

虽然该函数没有声明返回类型,但应返回所需类型的值。

使用转换函数时要小心。可以在声明构造函数时使用关键字 explicit,以防止它被用于隐式转换。

12.6.3 其构造函数使用new的类
#

12.7 队列模拟
#

这里不记录了。

对于const数据成员,必须在执行到构造函数体之前,即创建对象时进行初始化。C++提供了一种特殊的语法来完成上 述工作,它叫做成员初始化列表(member initializer list)。成员初始化 列表由逗号分隔的初始化列表组成(前面带冒号)。它位于参数列表的 右括号之后、函数体左括号之前。如果数据成员的名称为 mdata,并需 要将它初始化为val,则初始化器为mdata(val)

只有构造函数可以使用这种初始化列表语法。如上所示,对于const 类成员,必须使用这种语法。另外,对于被声明为引用的类成员,也必须使用这种语法

这是因为引用与const数据类似,只能在被创建时进行初始化。对于 简单数据成员(例如front和items),使用成员初始化列表和在函数体中 使用赋值没有什么区别。

【注意】: 这种格式只能用于构造函数; 必须用这种格式来初始化非静态const数据成员; 必须用这种格式来初始化引用数据成员。

不能将成员初始化列表语法用于构造函数之外的其他类方法

如果我们不希望复制构造函数被调用,也不允许赋值运算,可以这样做:

这是一种禁用方法的技巧,同时可以作为一种暂时不编写这两个函数的预防措施:与其将来面对无法预料的运行故障,不如得到一个易于跟踪的编译错误,指出这些方法是不可访问的。另外,在定义其对象不允许 被复制的类时,这种方法也很有用。

还有没有其他影响需要注意呢?当然有。当对象被按值传递(或返 回)时,复制构造函数将被调用。然而,如果遵循优先采用按引用传递 对象的惯例,将不会有任何问题。另外,复制构造函数还被用于创建其 他的临时对象,但Queue定义中并没有导致创建临时对象的操作,例如 重载加法运算符。

12.8 总结
#

本章介绍了定义和使用类的许多重要方面。其中的一些方面是非常 微妙甚至很难理解的概念。

在类构造函数中使用new,也可能在对象过期 时引发问题。如果对象包含成员指针,同时它指向的内存是由new分配 的,则释放用于保存对象的内存并不会自动释放对象成员指针指向的内 存。因此在类构造函数中使用new类来分配内存时,应在类析构函数中 使用delete来释放分配的内存。这样,当对象过期时,将自动释放其指 针成员指向的内存。

如果对象包含指向new分配的内存的指针成员,则将一个对象初始 化为另一个对象,或将一个对象赋给另一个对象时,也会出现问题。

在 默认情况下,C++逐个对成员进行初始化和赋值,这意味着被初始化或 被赋值的对象的成员将与原始对象完全相同。如果原始对象的成员指向 一个数据块,则副本成员将指向同一个数据块。当程序最终删除这两个 对象时,类的析构函数将试图删除同一个内存数据块两次,这将出错。解决方法是:定义一个特殊的复制构造函数来重新定义初始化,并重载赋值运算符

这样,旧对象和新对象都将引用独立 的、相同的数据,而不会重叠。由于同样的原因,必须定义赋值运算 符。对于每一种情况,最终目的都是执行深度复制,也就是说,复制实际的数据,而不仅仅是复制指向数据的指针。

C++允许在类中包含结构、类和枚举定义。这些嵌套类型的作用域为整个类,这意味着它们被局限于类中,不 会与其他地方定义的同名结构、类和枚举发生冲突。

C++为类构造函数提供了一种可用来初始化数据成员的特殊语法。 这种语法包括冒号和由逗号分隔的初始化列表,被放在构造函数参数的 右括号后,函数体的左括号之前。每一个初始化器都由被初始化的成员 的名称和包含初始值的括号组成。从概念上来说,这些初始化操作是在 对象创建时进行的,此时函数体中的语句还没有执行。语法如下:

queue(int qs): qsize(qs), items(0), front(NULL), rear(NULL) {}

如果数据成员是非静态const成员或引用,则必须采用这种格式,但 可将C++11新增的类内初始化用于非静态const成员。

第十三章 类继承
#

本章内容包括:

  • is-a 关系的继承;
  • 如何以公有方式从一个类派生出另一个类;
  • 保护访问;
  • 构造函数成员初始化列表;
  • 向上和向下强制转换;
  • 虚成员函数;
  • 早期(静态)联编与晚期(动态)联编;
  • 抽象基类;
  • 纯虚函数;
  • 何时以及如何使用公有继承。

面向对象编程的主要目的之一是提供可重用的代码。开发新项目, 尤其是当项目十分庞大时,重用经过测试的代码比重新编写代码要好得多。另外,必须考虑的细节越少, 便越能专注于程序的整体策略。

继承是一种非常好的概念,其基本实现非常简单。

13.1 一个简单的基类
#

从一个类派生出另一个类时,原始类称为基类,继承类称为派生类

13.1.1 派生一个类
#

与其从零开始,不如从 TableTennisClass类派生出一个类。首先将RatedPlayer类声明为从 TableTennisClass类派生而来:

class RetedPlayer: public TableTennisPlayer {}

冒号指出RatedPlayer类的基类是TableTennisplayer类。上述特殊的 声明头表明TableTennisPlayer是一个公有基类,这被称为公有派生。

派生类对象包含基类对象。使用公有派生,基类的公有成员将成为派生类 的公有成员;基类的私有部分也将成为派生类的一部分,但只能通过基 类的公有和保护方法访问

上述代码完成了哪些工作呢?Ratedplayer对象将具有以下特征:

  • 派生类对象存储了基类的数据成员(派生类继承了基类的实现);
  • 派生类对象可以使用基类的方法(派生类继承了基类的接口)。

需要在继承特性中添加什么呢?

  • 派生类需要自己的构造函数。
  • 派生类可以根据需要添加额外的数据成员和成员函数。

构造函数必须给新成员(如果有的话)和继承的成员提供数据

在 第一个RatedPlayer构造函数中,每个成员对应一个形参;而第二个 Ratedplayer构造函数使用一个TableTennisPlayer参数,该参数包括 firstname、lastname和hasTable。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// simple derived class
class RatedPlayer : public TableTennisPlayer
{
private:
    unsigned int rating;
public:
    RatedPlayer (unsigned int r = 0, const string & fn = "none",
                 const string & ln = "none", bool ht = false);
    RatedPlayer(unsigned int r, const TableTennisPlayer & tp);
    unsigned int Rating() const { return rating; }
    void ResetRating (unsigned int r) {rating = r;}
};

13.1.2 构造函数:访问权限的考虑
#

派生类不能直接访问基类的私有成员,而必须通过基类方法进行访问。具体地说,派生类构造函数需要使用基类构造函数。

创建派生类对象时,程序首先创建基类对象。

从概念上说,这意味 着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员 初始化列表语法来完成这种工作。例如,下面是第一个RatedPlayer构造 函数的代码:

1
2
3
4
5
6
// RatedPlayer methods
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
     const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht)
{
    rating = r;
}

其中: TableTennisPlayer(fn,ln,ht) 是成员初始化列表。它是可执行的 代码,调用TableTennisPlayer构造函数。

1
2
3
4
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
     const string & ln, bool ht) {
    rating = r;
}

如果省略成员初始化列表,情况将如何呢?首先必须创建基类对象,如果不调用基类构造函数,程序将使用默 认的基类构造函数,因此上述代码与下面等效:

1
2
3
4
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
     const string & ln, bool ht) : TableTennisPlayer() {
    rating = r;
}

除非要使用默认构造函数,否则应显式调用正确的基类构造函数

如果愿意,也可以对派生类成员使用成员初始化列表语法。在这种 情况下,应在列表中使用成员名,而不是类名。所以,第二个构造函数 可以按照下述方式编写:

1
2
3
4
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
     const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht), rating(r) {
    rating = r;
}

有关派生类构造函数的要点如下:

  • 首先创建基类对象;
  • 派生类构造函数应通过成员初始化列表将基类信息传递给基类构造函数;
  • 派生类构造函数应初始化派生类新增的数据成员。

派生类对象过期时,程序将首先调用派生类析构函数,然后再调用基类析构函数。

13.1.3 使用派生类
#

要使用派生类,程序必须要能够访问基类声明。既可以将两种类的声明置于同一个头文件中。也可以将每个类放在独立的头文件 中,如果两个类是相关的,把它们的类声明放在一起更合适。

13.1.4 派生类和基类之间的特殊关系
#

第一,派生类对象可以使用基类的方法,条件是方法不是私有的; 第二,基类指针可以在不进行显式类型转换的情况下指向派生类对象(基类指针可以直接指向派生类,神奇); 第三,基类引用可以在不进行显式类型转换的情况下引用派生类对象(基类引用可以直接应用派生类,神奇)。

不过,基类指针或引用只能用于调用基类方法,因此,不能使用rtpt来调用派生类的ResetRanking方法。

通常,C++要求引用和指针类型与赋给的类型匹配,但这一规则对 继承来说是例外

然而,这种例外只是单向的,不可以将基类对象和地 址赋给派生类引用和指针。

上述规则是有道理的。允许基类引用隐式地引用派生类对象,等于是可以使用基类引用为派生类对象调用基类的方法。因为派生类 继承了基类的方法,所以这样做不会出现问题。

而如果可以将基类对象赋 给派生类引用,将发生什么情况呢?派生类引用能够为基对象调用派生 类方法,这样做将出现问题。例如,将RatedPlayer::Rating( )方法用于 TableTennisPlayer对象是没有意义的,因为TableTennisPlayer对象没有 rating成员。

基类引用和指针可以指向派生类对象,将出现一些很有趣的结果。其中之一是基类中引用定义的函数或指针参数可用于派生类对象

13.2 继承:is-a关系
#

派生类和基类之间的特殊关系是基于C++继承的底层模型的。实际 上,C++有3种继承方式:公有继承、保护继承和私有继承。公有继承 是最常用的方式,它建立一种is-a关系,即派生类对象也是一个基类对象,任何可以对基类对象执行的操作,也可以对派生类对象执行

因为派生类可以添加特性,所以,将这种关系称为 is-a-kind-of(是一种)关系可能 更准确,但是通常使用术语 is-a。

13.3 多态公有继承
#

派生类对象使用基类的方法,而未做 任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类 和基类中的行为是不同的,这称为多态公有继承。有两种重要的机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法;
  • 使用虚方法(关键字 virtual),然后在各自类中对该函数编写相关定义即可。

虚函数的这种行为非常方便。方法在基类中被声明为虚的后,它在派生类中将自动成为虚方法。

第四点是,基类声明了一个虚析构函数。这样做是为了确保释放派 生对象时,按正确的顺序调用析构函数。

为何需要虚析构函数?

在程序清单13.10中,使用delete释放由new分配的对象的代码说明 了为何基类应包含一个虚析构函数。

如果析构函数不是虚的,则将只调用对应于指针类型的析构函数。对于 程序清单13.10,这意味着只有Brass的析构函数被调用,即使指针指向 的是一个BrassPlus对象。如果析构函数是虚的,将调用相应对象类型的 析构函数。因此,如果指针指向的是BrassPlus对象,将调用BrassPlus的 析构函数,然后自动调用基类的析构函数。因此,使用虚析构函数可以 确保正确的析构函数序列被调用。

对于程序清单13.10,这种正确的行为并不是很重要,因为析构函数没有执行任何操作。然而,如果 BrassPlus包含一个执行某些操作的析构函数,则Brass必须有一个虚析构函数,即使该析构函数不执行任何操作。

13.4 静态联编和动态联编
#

程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这 个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函 数名联编(binding)。在编译过程中进行联 编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。正如在程序清单 13.10所示的那样,使用哪一个函数是不能在编译时确定的,因为编译 器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程 序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。

13.4.1 指针和引用类型的兼容性
#

将派生类引用或指针转换为基类引用或指针被称为向上强制转换 (upcasting),这使公有继承不需要进行显式类型转换。该规则是is-a 关系的一部分。

相反的过程——将基类指针或引用转换为派生类指针或引用——称为向下强制转换(downcasting),如果不使用显式类型转换,则向下强制转换是不允许的。原因是is-a关系通常是不可逆的。派生类可以新增 数据成员,因此使用这些数据成员的类成员函数不能应用于基类。

对于使用基类引用或指针作为参数的函数调用,将进行向上转换。

13.4.2 虚成员函数和动态联编
#

编译器对虚方法使用动态联编。在大多数情况下,动态联编很好,因为它让程序能够选择为特定类 型设计的方法。

1.为什么有两种类型的联编以及为什么默认为静态联编

如果动态联编让您能够重新定义类方法,而静态联编在这方面很 差,为何不摒弃静态联编呢?原因有两个——效率和概念模型。

首先来看效率。为使程序能够在运行阶段进行决策,必须采取一些 方法来跟踪基类指针或引用指向的对象类型,这增加了额外的处理开销 (稍后将介绍一种动态联编方法)。

接下来看概念模型。在设计类时,可能包含一些不在派生类重新定 义的成员函数。例如,Brass::Balance( )函数返回账户结余,不应该重新 定义。不将该函数设置为虚函数,有两方面的好处:首先效率更高;其次,指出不要重新定义该函数。这表明,仅将那些预期将被重新定义的 方法声明为虚的。

2.虚函数的工作原理

编译器处理虚函数的方法是:给每个对象添加一个隐藏成 员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚 函数表(virtual function table,vtbl)。虚函数表中存储了为类对象进行 声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类 中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指 针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地 址;如果派生类没有重新定义虚函数,该vtbl将保存函数原始版本的地 址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl 中(参见图13.5)。注意,无论类中包含的虚函数是1个还是10个,都 只需要在对象中添加1个地址成员,只是表的大小不同而已。

调用虚函数时,程序将查看存储在对象中的vtbl地址,然后转向相 应的函数地址表。如果使用类声明中定义的第一个虚函数,则程序将使 用数组中的第一个函数地址,并执行具有该地址的函数。如果使用类声 明中的第三个虚函数,程序将使用地址为数组中第三个元素的函数。

总之,使用虚函数时,在内存和执行速度方面有一定的成本,包括:

  • 每个对象都将增大,增大量为存储地址的空间;
  • 对于每个类,编译器都创建一个虚函数地址表(数组);
  • 对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址;

13.4.3 有关虚函数注意事项
#

  • 在基类方法的声明中使用关键字virtual可使该方法在基类以及所有 的派生类(包括从派生类派生出来的类)中是虚的;
  • 如果使用指向对象的引用或指针来调用虚方法,程序将使用为对象类型定义的方法,而不使用为引用或指针类型定义的方法。这称为 动态联编。这种行为非常重要,因为这样基类指针或引 用可以指向派生类对象;
  • 如果定义的类将被用作基类,则应将那些要在派生类中重新定义的 类方法声明为虚的;
  • 构造函数不能是虚函数;
  • 析构函数应当是虚函数,除非类不用做基类。这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作;

给类定义一个虚析构函数并非错误,即使这个类不用做基类;这只是一个效率方面的问题。

  • 友元不能是虚函数,因为友元不是类成员,而只有成员才能是虚函 数。
  • 如果派生类没有重新定义函数,将使用该函数的基类版本。如果派 生类位于派生链中,则将使用最新的虚函数版本,例外的情况是基类版本是隐藏的。

13.5 访问控制:protected
#

关键字protectedprivate 相似,在类外只能用公有类成员来访问protected部分中的类成员。privateprotected之间的区别只有在基类派生的类中才会表现出来。派生类的成员可以直接访问基类的保护成员,但不能直接访问基类的私有成员。因此,对于外部世界来说, 保护成员的行为与私有成员相似;但对于派生类来说,保护成员的行为与公有成员相似。

13.6 抽象基类
#

C++通过使用纯虚函数(pure virtual function) 提供未实现的函数。纯虚函数声明的结尾处为=0。

1
virtual double Area() const = 0; // a pure virtual function

当类声明中包含纯虚函数时,则不能创建该类的对象。这里的理念 是,包含纯虚函数的类只用作基类。要成为真正的ABC,必须至少包含 一个纯虚函数。原型中的=0使虚函数成为纯虚函数。

在原型中使用=0指出类是一个抽象基类,在类中可以不定义该函数。

【ABC理念】如果要设计类继承层次,则只能将那些不会被用作基类的类设计 为具体的类。这种方法的设计更清晰,复杂程度更低。

可以将ABC看作是一种必须实施的接口。ABC要求具体派生类覆盖 其纯虚函数——迫使派生类遵循ABC设置的接口规则。这种模型在基于 组件的编程模式中很常见,在这种情况下,使用ABC使得组件设计人员 能够制定“接口约定”,这样确保了从ABC派生的所有组件都至少支持 ABC指定的功能。

13.7 继承和动态内存分配
#

继承是怎样与动态内存分配(使用new和delete)进行互动的呢?例如,如果基类使用动态内存分配,并重新定义赋值和复制构造函数,这将怎样影响派生类的实现呢?

13.7.1 第一种情况:派生类不使用new
#

假设基类使用了动态内存分配,基类中包含了构造函数使用new时需要的特殊方法:析构函数、复 制构造函数和重载赋值运算符。如例子中的 baseDMA

现在,从 baseDMA 派生出 lackDMA 类,而后者不使用 new,也未包含其他一些不常用的、需要特殊处理的设计特性。

1
2
3
4
5
6
class lacksDMA: public baseDMA {
private:
    char color[40];
public:
    ...
}

是否需要为lackDMA类定义显式析构函数、复制构造函数和赋值运算符呢?不需要。

首先,来看是否需要析构函数。如果没有定义析构函数,编译器将 定义一个不执行任何操作的默认构造函数。实际上,派生类的默认构造 函数总是要进行一些操作:执行自身的代码后调用基类析构函数。因为 我们假设lackDMA成员不需执行任何特殊操作,所以默认析构函数是合 适的。

接着来看复制构造函数。第12章介绍过,默认复制构造函数执行成 员复制,这对于动态内存分配来说是不合适的,但对于新的lacksDMA 成员来说是合适的。因此只需考虑继承的baseDMA对象。要知道,成员 复制将根据数据类型采用相应的复制方式,因此,将long复制到long中 是通过使用常规赋值完成的;但复制类成员或继承的类组件时,则是使 用该类的复制构造函数完成的。所以,lacksDMA类的默认复制构造函 数使用显式baseDMA复制构造函数来复制lacksDMA对象的baseDMA部 分。因此,默认复制构造函数对于新的lacksDMA成员来说是合适的, 同时对于继承的baseDMA对象来说也是合适的。

对于赋值来说,也是如此。类的默认赋值运算符将自动使用基类的 赋值运算符来对基类组件进行赋值。因此,默认赋值运算符也是合适的。

13.7.2 第二种情况:派生类使用new
#

在这种情况下,必须为派生类定义显式析构函数、复制构造函数和 赋值运算符。

总之,当基类和派生类都采用动态内存分配时,派生类的析构函 数、复制构造函数、赋值运算符都必须使用相应的基类方法来处理基类 元素。这种要求是通过三种不同的方式来满足的。对于析构函数,这是 自动完成的;对于构造函数,这是通过在初始化成员列表中调用基类的 复制构造函数来完成的;如果不这样做,将自动调用基类的默认构造函 数。对于赋值运算符,这是通过使用作用域解析运算符显式地调用基类 的赋值运算符来完成的。

13.7.3 使用动态内存分配和友元的继承示例
#

13.8 类设计回顾
#

13.8.1 编译器生成的成员函数
#

编译器会自动生成一些公有成员函数——特殊成员 函数。这表明这些特殊成员函数很重要:

  1. 默认构造函数,要么没有参数,要么所有的参数都有默认值。如果没 有定义任何构造函数,编译器将定义默认构造函数,让我们能够创建对象;
  2. 复制构造函数,接受其所属类的对象作为参数。如果程序没有使用(显式或隐式)复制构造函数,编译器将提供原型,但不提供函数定义;否则,程序将定义一个执行成员初始化的复制构造函数;
  3. 赋值运算符,默认的赋值运算符用于处理同类对象之间的赋值。不要将赋值与初 始化混淆了。如果语句创建新的对象,则使用初始化;如果语句修改已有对象的值,则是赋值

13.8.2 其他的类方法
#

定义类时,还需要注意其他几点:

  1. 构造函数。构造函数不同于其他类方法,因为它创建新的对象,而其他类方法 只是被现有的对象调用。这是构造函数不被继承的原因之一。继承意味着派生类对象可以使用基类的方法,然而,构造函数在完成其工作之前,对象并不存在
  2. 析构函数。一定要定义显式析构函数来释放类构造函数使用new分配的所有内 存,并完成类对象所需的任何特殊的清理工作。对于基类,即使它不需 要析构函数,也应提供一个虚析构函数;
  3. 转换。使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换。将可转换的类型传递给以类为参数的函数时,将调用转换构造函数。在带一个参数的构造函数原型中使用 explicit将禁止进行隐式转换, 但仍允许显式转换:

要将类对象转换为其他类型,应定义转换函数(参见第11章)。

  1. 按值传递对象与传递引用。编写使用对象作为参数的函数时,应按引用而不是按值来传递对象。这样做的原因之一是为了提高效率。按值传递对象涉及到生成 临时拷贝,即调用复制构造函数,然后调用析构函数。调用这些函数需 要时间,复制大型对象比传递引用花费的时间要多得多。如果函数不修 改对象,应将参数声明为const引用。按引用传递对象的另外一个原因是,在继承使用虚函数时,被定义 为接受基类引用参数的函数可以接受派生类;
  2. 返回对象和返回引用。有些成员函数直接返回对 象,而另一些则返回引用。有时方法必须返回对象,但如果可以不返回对象,则应返回引用。如果函数返回在函数中创建的临时对象,则不要使用引用,而是返回该对象。如果函数返回的是通过引用或指针传递给它的对象,则应按引用返回对象

  1. 使用const。 使用const来确保方法不修改调用它的对象。如果函数将参数声明为指向const的引用或指针,则不能将该 参数传递给另一个函数,除非后者也确保了参数不会被修改(即,确保const对const)。

13.8.3 公有继承的考虑因素
#

  1. is-a 关系。表示is-a关系的方式之一是,无需进行显式类型转换,基类指针就可以指向派生类对象,基类引用可以引用派生类对象。另外, 反过来是行不通的,即不能在不进行显式类型转换的情况下,将派生类指针或引用指向基类对象;
  2. 什么不能被继承:构造函数不能继承;析构函数不能继承;赋值运算符不能继承;
  3. 赋值运算符;
  4. 私有成员与保护成员;
  5. 虚方法。如果希望派生类 能够重新定义方法,则应在基类中将方法定义为虚的;如果不希望重新定义方法,则不必将其声明为虚的,这样虽然无法禁止他人重新定义方法,但表达了这样的意思:您不 希望它被重新定义;
  6. 析构函数;
  7. 友元函数。由于友元函数并非类成员,因此不能继承。然而,您可能希望派生 类的友元函数能够使用基类的友元函数。为此,可以通过强制类型转换将派生类引用或指针转换为基类引用或指针,然后使用转换后的指针 或引用来调用基类的友元函数:

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

13.8.4 类函数小结
#

C++类函数有很多不同的变体,其中有些可以继承,有些不可以。 有些运算符函数既可以是成员函数,也可以是友元,而有些运算符函数 只能是成员函数。

13.9 总结
#

继承通过使用已有的类(基类)定义新的类(派生类),使得能够 根据需要修改编程代码。基类的析构函数通常应当是虚的。

如果要将类用作基类,则可以将成员声明为保护的,而不是私有 的,这样,派生类将可以直接访问这些成员。

可以考虑定义一个ABC:只定义接口,而不涉及实现。例如,可以 定义抽象类Shape,然后使用它派生出具体的形状类,如Circle和 Square。ABC必须至少包含一个纯虚方法,可以在声明中的分号前面加 上=0来声明纯虚方法。

《C++ Primer Plus》第六版 - 这篇文章属于一个选集。
§ 2: 本文