跳转至

程序设计基础(C++)

这是为 程序设计基础(C++) 整理的学习要点,希望能帮助你梳理思路、高效复习。

请注意: 笔记主要基于个人理解,建议与课本和官方材料结合阅读。内容难免有疏漏,欢迎随时交流指正。

祝学习顺利!

本笔记由 吴桐 整理提供。


1. 绪论

总之就是非常面向对象。

2. 简单程序设计

2.1 程序组成

1. 关键字

C++的保留关键字有:

一、基本数据类型关键字

  • bool :表示布尔类型,其值为 true 或 false。
  • char :表示字符类型,用于存储单个字符。
  • wchar_t :宽字符类型,用于存储宽字符,通常用于国际化字符处理。
  • char8_t :C++20 引入的用于表示 UTF - 8 编码的字符类型。
  • char16_t :用于表示 UTF - 16 编码的字符类型。
  • char32_t :用于表示 UTF - 32 编码的字符类型。
  • int :整数类型,用于存储整数值。
  • float :单精度浮点数类型,用于存储小数。
  • double :双精度浮点数类型,比 float 精度更高,用于存储更精确的小数。
  • void :表示无类型,用于指定函数无返回值或指针不指向任何数据类型等场景。

二、变量与常量关键字

  • auto :用于自动类型推导,编译器根据变量的初始值自动推断其类型。
  • const :用于声明常量,声明后其值不能被修改。
  • constexpr :用于指定编译时常量表达式,比 const 更严格,要求表达式在编译期间就能确定其值。
  • extern :用于声明外部变量,表示该变量在其他文件中定义,当前文件只声明用于引用。
  • static :用于声明静态变量,静态变量的存储期为整个程序的执行期间,局部静态变量的初始化只在第一次进入其作用域时进行。

三、运算符与表达式关键字

  • sizeof :用于获取数据类型或变量在内存中所占的字节数。
  • decltype :用于获取表达式的类型。

四、控制流关键字

  • if :条件判断语句,根据条件的真假执行不同的代码块。
  • else :与 if 一起使用,表示条件不满足时执行的代码块。
  • switch :多分支选择语句,根据表达式的值执行不同的代码段。
  • case :switch 语句中的分支标签,用于指定不同情况下的代码。
  • default :switch 语句中的默认分支,当没有 case 分支匹配时执行。
  • while :循环语句,当条件为真时重复执行循环体。
  • do - while :循环语句,先执行循环体,再判断条件是否继续循环。
  • for :循环语句,用于控制循环的初始化、条件判断和循环变量更新。
  • break :用于跳出循环或 switch 语句。
  • continue :用于跳过当前循环的剩余部分,直接开始下一次循环。

五、函数与作用域关键字

  • inline :用于建议编译器将函数内联,即在调用处展开函数体,以减少函数调用的开销。
  • namespace :用于定义命名空间,避免命名冲突。
  • using :用于引入命名空间中的成员到当前作用域,或者用于类型别名定义等。
  • explicit :用于防止类的构造函数和转换函数的隐式转换,要求显式调用。
  • friend :用于声明友元函数或友元类,使得友元可以访问类的私有和保护成员。

六、内存管理关键字

  • new :用于动态分配内存。
  • delete :用于释放动态分配的内存。

七、面向对象编程关键字

  • class :用于定义类,类是具有相同属性和行为的对象的集合。
  • struct :与 class 类似,用于定义结构体类型,但默认成员访问权限为 public。
  • union :用于定义联合体类型,联合体中的不同成员共享同一块内存。
  • this :指针,指向当前对象,用于访问类的成员。
  • nullptr :表示空指针常量,用于替代 NULL。
  • private :用于指定类的私有成员,只能在类内部和友元中访问。
  • protected :用于指定类的保护成员,可以被类本身、友元和派生类访问。
  • public :用于指定类的公有成员,可以被外部访问。
  • virtual :用于声明虚函数,实现多态。
  • override :用于指定派生类中的函数重写基类的虚函数,增强代码的可读性和安全性。
  • final :用于指定类不能被继承,或者虚函数不能被派生类重写。

八、异常处理关键字

  • try :用于包围可能抛出异常的代码块。
  • catch :用于捕获异常,处理不同类型的异常。
  • throw :用于抛出异常,当程序遇到错误情况时,可以通过 throw 语句将异常抛出。

九、其他关键字

  • alignas :用于指定变量或类型的对齐方式。
  • alignof :用于查询类型的对齐要求。
  • asm :用于在 C++ 程序中嵌入汇编代码。
  • thread_local :用于声明线程局部存储,每个线程对变量有自己的副本。
  • noexcept :用于指定函数不抛出异常,或者用于检查表达式是否可能抛出异常。
  • nullptr :表示空指针常量。
  • static_assert :用于在编译时期进行断言检查,如果条件不满足则编译报错。
  • typedef :用于为已有的类型定义别名。

2. 标志符

C++中标志符的命名规则有:

  • 以大写字母、小写字母或下划线(_) 开始。

  • 可以由以大写字母、小写字母、下划线 (_) 或数字 0~9 组成。

  • 大写字母和小写字母代表不同的标识符。

  • 不能是C++关键字。

3. 操作符

操作符是用于实现各种运算的符号,例如: +,-,*,/,…

4. 分隔符

分隔符用于分隔各个词法记号或程序正文,C++分隔符是:

(){} ,:;

这些分隔符不表示任何实际的操作,仅用于构造程序。

6. 空格

在程序编译时的词法分析阶段将程序正文分解为词法记号和空白。空白是空格、制表符(T ab 键产生的字符)、垂直制表符、换行符、回车符和注释的总称。 空白符用于指示词法记号的开始和结束位置,但除了这一功能之外,其余的空白将被忽略。因此,C++程序可以不必严格地按行书写,凡是可以出现空格的地方,都可以出现换行。

2.1 基本数据类型

C++的基本数据类型有 bool(布尔型)、char(字符型)、int(整型)、float(浮点型,表示实数)、double(双精度浮点型,简称双精度型)。除了 bool 型外,主要有两大类:整数和浮点数。因为 char型从本质上说也是整数类型,它是长度为 1 个字节的整数,通常用来存放字符的 A SCII 码。其中关键字 signed 和 unsigned,以及关键字short和long 被称为修饰符。

  • 用 short修饰 int时,shortint表示短整型,占 2 字节。此时 int可以省略,因此表 2-1中列出的是 short型而不是 shortint型。long 可以用来修饰 int和 double。用 long 修饰int时,long int表示长整型,占 4 字节,同样此时 int也可以省略。

  • ISO C++标准并没有明确规定每种数据类型的字节数和取值范围,它只是规定它们之间的字节数大小顺序满足:

(signed/unsigned)char≤(unsigned)short≤(unsigned)int≤(unsigned) long

  • 一般情况下,如果对一个整数所占字节数和取值范围没有特殊要求,使用(unsigned) int型为宜,因为它通常具有最高的处理效率。

  • signed 和 unsigned 可以用来修饰 char型和 int型(也包括 short 和 long),signed 表示有符号数,unsigned 表示无符号数。有符号整数在计算机内是以二进制补码形式存储的,其最高位为符号位,“0”表示“正”,“1”表示“负”。无符号整数只能是正数,在计算机内是以绝对值形式存放的。int型(也包括 short和long)在默认(不加修饰)情况下是有符号(signed)的。

  • char与 int,short,long 有所不同,ISO C++标准并没有规定它在默认(不加修饰)情况下是有符号的还是无符号的,它会因不同的编译环境而异。因此,char,signed char和 unsigned char是 3 种不同的数据类型。

  • 两种浮点类型除了取值范围有所不同外,精度也有所不同,float 可以保存 7 位有效数字,double可以保存 15 位有效数字。

  • bool(布尔型,也称逻辑型)数据的取值只能是 false(假)或 true(真)。bool 型数据所占的字节数在不同的编译系统中有可能不一样,在 V C++.N ET 2005 编译环境中bool型数据占 1 字节。

常量

1. 整型常量

以文字形式出现的整数,包括正整数、负整数和零。整型常量的表示形式有十进制、八进制和十六进制。

  • 十进制整型常量的一般形式与数学中我们所熟悉的表示形式是一样的:

  • [±]若干个 0~9的数字

  • 即符号加若干个 0~9 的数字,但数字部分不能以 0 开头,正数前边的正号可以省略。

  • 八进制整常量的数字部分要以数字0开头,一般形式为:

  • 0若干个 0~7的数字

  • 十六进制整常量的数字部分要以 0x开头,一般形式为:

  • 0x 若干个 0~9的数字及 A~F的字母(大小写均可)

  • 由于八进制和十六进制形式的整型常量一般用来表示无符号整数,所以前面不应带正负号。

  • 整型常量可以用后缀字母 L(或 l)表示长整型,后缀字母 U(或 u )表示无符号型,也可同时后缀 L 和 U(大小写无关)。

  • 例如:- 123,0123,0x5af都是合法的常量形式。

2. 实型常量

实型常量即以文字形式出现的实数,实数有两种表示形式:一般形式和指数形式。

  • 一般形式:例如,12.5,- 12.5 等。

  • 指数形式:例如,0.345E+ 2 表示 0.345×10 2,- 34.4E - 3 表示- 34.4×10 - 3。其中,字母 E 可以大写或小写。当以指数形式表示一个实数时,整数部分和小数部分可以省略其一,但不能都省略。例如:.123E - 1,12.E 2,1.E - 3 都是正确的,但不能写成E - 3 这种形式。

  • 实型常量默认为 double型,如果后缀 F(或f)可以使其成为float型,例如:12.3f。

3. 字符常量

字符常量是单引号括起来的一个字符,如:′a′,′D′,′?′,′$′等。

另外,还有一些字符是不可显示字符,也无法通过键盘输入,例如响铃、换行、制表符、回车等。通过转义表示

转义序列 含义
\a 响铃
\b 退格
\f 换页
\n 换行
\r 回车
\t 水平制表符
\v 垂直制表符
\ 反斜杠
' 单引号
" 双引号
? 问号
\ooo 八进制转义
\xhh 十六进制转义
4. 字符串常量

字符串常量简称字符串,是用一对双引号括起来的字符序列。例如:″abcd″,″China″,″T his is a string.″都是字符串常量。由于双引号是字符串的界限符,所以字符串中间的双引号就要用转义序列来表示。例如:

″Please ente r \″Yes\″o r \″No\″″

表示的是下列文字:Please ente r ″Yes″or ″No″

5. 布尔型常量

布尔型常量只有两个:false(假)和 true(真)。

变量

1. 变量的声明和定义
  • 就像常量具有各种类型一样,变量也具有相应的类型。变量在使用之前需要首先声明其类型和名称。变量名也是一种标识符,因而给变量命名时,应该遵守 2.1 节中介绍的标识符构成规则。在同一语句中可以声明同一类型的多个变量。变量声明语句的形式如下:

  • 数据类型变量名 1,变量名 2,…,变量名 n;

  • 例如,下列两条语句声明了两个int型变量和3个float型变量:

int num, total;
float v , r, h;

声明一个变量并不一定引起内存的分配,定义一个变量意味着给变量分配内存空间,用于存放对应类型的数据,变量名就是对相应内存单元的命名。

2. 变量的存储类型

变量的存储类型决定了其存储方式

  • auto存储类型:采用堆栈方式分配内存空间,属于暂时性存储,其存储空间可以被若干变量多次覆盖使用。

  • register存储类型:存放在通用寄存器中。

  • extern存储类型:在所有函数和程序段中都可引用。

  • static存储类型:在内存中是以固定地址存放的,在整个程序运行期间都有效。

符号常量

可以为常量命名,这就是符号常量。符号常量在使用之前一定要首先声明,这一点与变量很相似。常量声明语句的形式为:

const 数据类型说明符常量名=常量值;数据类型说明符 const 常量名=常量值;

例如,可以声明一个代表圆周率的符号常量:

const float PI=3.1415926;

注意符号常量在声明时一定要赋初值,而在程序中间不能改变其值。

类型转换

隐式类型转换
  • 解释 :也称为自动类型转换,是指编译器在需要的时候自动进行的类型转换。一般来说,转换的方向是从低级数据类型向高级数据类型转换,以避免数据丢失。例如,将 int 类型转换为 long 类型,或者将 char 类型转换为 int 类型等。
int a = 10;
long b = a; // int 类型自动转换为 long 类型
char c = 'A';
int d = c; // char 类型自动转换为 int 类型,d 的值为 'A' 的 ASCII 码值 65
显式类型转换
  • C 风格的强制类型转换
    • 解释:使用一对圆括号括起目标类型来强制转换数据类型。这种方法简单直接,但可能会导致一些意想不到的错误,因为编译器不会进行严格的类型检查。
double x = 3.14;
int y = (int)x; // 将 double 类型强制转换为 int 类型,y 的值为 3
  • C++ 风格的强制类型转换
    • static_cast
    • 解释 :用于基本的数据类型转换,如将 int 转换为 double,或者将指针类型在相互之间进行转换等。它不会改变数据的位模式,只是改变编译器对数据的解释方式。如果转换操作不合法或不安全,编译器会报错。
double a = 3.14;
int b = static_cast<int>(a); // 将 double 转换为 int
int c = 10;
void* ptr1 = &c;
int* ptr2 = static_cast<int*>(ptr1); // 指针类型之间的转换
  • dynamic_cast
  • 解释 :主要用于具有继承关系的类之间的指针或引用转换。它会在运行时进行类型检查,如果转换失败,会返回空指针(对于指针转换)或抛出 std::bad_cast 异常(对于引用转换)。这使得 dynamic_cast 在处理多态时非常有用。
class Animal{};
class Dog : public Animal{};
class Cat : public Animal{};

Animal* animal = new Dog();
Dog* dog = dynamic_cast<Dog*>(animal); // 转换成功,dog 指向原来的 Dog 对象
Cat* cat = dynamic_cast<Cat*>(animal); // 转换失败,cat 为 nullptr
  • const_cast
  • 解释:用于添加或移除变量的 const 或 volatile 修饰符。它可以将 const 类型转换为非 const 类型,或者反过来。这在某些特殊情况下很有用,例如,当需要调用一个修改对象的函数,但该对象被声明为 const 时。
const int a = 10;
int* b = const_cast<int*>(&a); // 移除 a 的 const 修饰符,现在可以通过 b 修改 a 的值
  • reinterpret_cast
  • 解释 :用于重新解释对象的位模式,可以将一种指针类型转换为另一种完全不相关的指针类型。这种转换通常用于低级编程或与硬件相关的操作,但使用时需要非常谨慎,因为它可能会导致未定义行为。
int a = 10;
double* b = reinterpret_cast<double*>(&a); // 将 int 类型的地址重新解释为 double 类型的指针
class Animal{};
class Dog : public Animal{};
class Cat : public Animal{};

Animal* animal = new Dog();
Dog* dog = dynamic_cast<Dog*>(animal); // 转换成功,dog 指向原来的 Dog 对象
Cat* cat = dynamic_cast<Cat*>(animal); // 转换失败,cat 为 nullptr
  • const_cast
  • 解释 :用于添加或移除变量的 const 或 volatile 修饰符。它可以将 const 类型转换为非 const 类型,或者反过来。这在某些特殊情况下很有用,例如,当需要调用一个修改对象的函数,但该对象被声明为 const 时。
const int a = 10;
int* b = const_cast<int*>(&a); // 移除 a 的 const 修饰符,现在可以通过 b 修改 a 的值
  • reinterpret_cast
  • 解释 :用于重新解释对象的位模式,可以将一种指针类型转换为另一种完全不相关的指针类型。这种转换通常用于低级编程或与硬件相关的操作,但使用时需要非常谨慎,因为它可能会导致未定义行为。
int a = 10;
double* b = reinterpret_cast<double*>(&a); // 将 int 类型的地址重新解释为 double 类型的指针

2.2 运算符与表达式

1. 算数运算符

  • C++中的算术运算符包括基本算术运算符和自增自减运算符。由算术运算符、操作数和括号构成的表达式称为算术表达式。

  • 基本算术运算符有:+(加或正号)、-(减或负号)、*(乘)、/(除)、%(取余)。其中“+”作为正号、“-”作为负号时为一元运算符,其余都为二元运算符。

  • “%”是取余运算,只能用于整型操作数,表达式 a% b 的结果是 a 被 b 除的余数。“%”的优先级与“/”相同。

  • 当“/”用于两个整型数据相除时,其结果取商的整数部分,小数部分被自动舍弃。因此,表达式 1/2 的结果为 0,这一点需要特别注意。

  • 另外,C++中的++(自增)、--(自减)运算符是使用方便的两个运算符,它们都是一元运算符。这两个运算符都有前置和后置两种使用形式,如:i++--j等。无论写成前置还是后置的形式,它们的作用都是将操作数的值增 1(减 1)后,重新写回该操作数在内存中原有的位置。所以如果变量 i原来的值是 1,计算表达式i++后,表达式的结果为 2,并且 i的值也被改变为 2。如果变量j原来的值是 2,计算表达式--j后,表达式的结果为 1,并且 j 的值也被改变为1。

注意在表达具体表达式情形下自增与自减符的前后置的不同:

int i = 1;
cout << ++i;
// 输出 2
cout << i++;
// 输出 1

2. 赋值运算符

  • = 赋值运算符,将右边的值赋给左边的变量

  • += 加赋值运算符,将左边变量与右边的值相加后赋给左边变量

  • -= 减赋值运算符,将左边变量与右边的值相减后赋给左边变量

  • *= 乘赋值运算符,将左边变量与右边的值相乘后赋给左边变量

  • /= 除赋值运算符,将左边变量与右边的值相除后赋给左边变量

  • %= 取模赋值运算符,将左边变量与右边的值取模后赋给左边变量

  • |= 按位或赋值运算符,将左边变量与右边的值按位或后赋给左边变量

  • ^= 按位异或赋值运算符,将左边变量与右边的值按位异或后赋给左边变量

  • &= 按位与赋值运算符,将左边变量与右边的值按位与后赋给左边变量

  • >>=右移赋值运算符,将左边变量右移右边的值指定位数后赋给左边变量

  • <<= 左移赋值运算符,将左边变量左移右边的值指定位数后赋给左边变量

3. 逻辑运算

C++预定义的比较运算符

  • 等于:== ,判断两个操作数是否相等。
  • 不等于:!= ,判断两个操作数是否不相等。

  • 大于:> ,判断左边操作数是否大于右边操作数。

  • 小于:< ,判断左边操作数是否小于右边操作数。

  • 大于等于:>= ,判断左边操作数是否大于等于右边操作数。

  • 小于等于:<= ,判断左边操作数是否小于等于右边操作数。

C++预定义的逻辑运算符

  • 逻辑与:&& ,表示只有两个逻辑值都为真时结果才为真。

  • 逻辑或:|| ,表示只要有一个逻辑值为真则结果为真。

  • 逻辑非:! ,表示对一个逻辑值取反。

4. 条件运算

C++中唯一的一个三元运算符是条件运算符“?”,它能够实现简单的选择功能。条件表达式的形式是:

表达式 1?表达式 2:表达式 3

其中表达式 1 必须是 bool类型,表达式 2,3 可以是任何类型,且类型可以不同。条件表达式的最终类型为 2 和 3 中较高的类型(稍后介绍类型转换时会解释类型的高与低)。

条件表达式的执行顺序为:先求解表达式 1。若表达式 1 的值为 true,则求解表达式2,表达式 2 的值为最终结果;若表达式 1 的值为 false,则求解表达式 3,表达式 3 的值为最终结果。注意,条件运算符优级高于赋值运算符,低于逻辑运算符。结合方向为自右向左。

5. sizeof运算符

  • sizeof 运算符用于计算某种类型的对象在内存中所占的字节数。该操作符使用的语法形式为:

sizeof(类型名)sizeof表达式

运算结果值为“类型名”所指定的类型或“表达式”的结果类型所占的字节数。注意在这个计算过程中,并不对括号中的表达式本身求值。

6. 位运算

  1. 按位与(&)

    • 用于将两个数的二进制形式中的每一位进行与运算。当对应位都为 1 时,结果位才为 1,否则为 0。
    • 例如:6 & 3,6 的二进制是 110,3 的二进制是 011。逐位进行与运算:
    • 第一位:1 & 0 = 0
    • 第二位:1 & 1 = 1
    • 第三位:0 & 1 = 0
    • 结果是 010,即十进制的 2。
  2. 按位或(|)

    • 将两个数的二进制形式中的每一位进行或运算。当对应位只要有一个为 1,结果位就为 1,否则为 0。
    • 例如:6 | 3,6 的二进制是 110,3 的二进制是 011。逐位进行或运算:
    • 第一位:1 | 0 = 1
    • 第二位:1 | 1 = 1
    • 第三位:0 | 1 = 1
    • 结果是 111,即十进制的 7。
  3. 按位异或(^)

    • 用于将两个数的二进制形式中的每一位进行异或运算。当对应位不同时,结果位为 1;相同时,结果位为 0。
    • 例如:6 ^ 3,6 的二进制是 110,3 的二进制是 011。逐位进行异或运算:
    • 第一位:1 ^ 0 = 1
    • 第二位:1 ^ 1 = 0
    • 第三位:0 ^ 1 = 1
    • 结果是 101,即十进制的 5。
  4. 按位取反(~)

    • 是单目运算符,用于对一个数的二进制形式中的每一位进行取反操作。将 0 变为 1,1 变为 0。
    • 例如:~3,3 的二进制是 00000011(假设是 8 位),取反后变成 11111100,转换为十进制是 -4(在补码表示法下)。
  5. 左移(<<)

    • 用于将一个数的二进制形式整体向左移动指定的位数。左边移出的位被舍弃,右边移入的位用 0 补充。
    • 例如:3 << 2,3 的二进制是 00000011,左移两位后变成 00001100,即十进制的 12。这相当于将原数乘以 2 的移位位数次方(这里移位两位,相当于乘以 4)。
  6. 右移(>>)

    • 用于将一个数的二进制形式整体向右移动指定的位数。右边移出的位被舍弃,对于无符号数,左边移入的位用 0 补充;对于有符号数,左边移入的位在有些系统中用符号位(最高位)填充(算术右移),在有些系统中用 0 填充(逻辑右移)。在 C++ 中,对于有符号数的右移操作,通常是算术右移。
    • 例如:5 >> 1,5 的二进制是 00000101,右移一位后变成 00000010,即十进制的 2。对于负数,如 -5(假设用 8 位补码表示为 11111011),右移一位后变成 11111101,即十进制的 -3,这体现了算术右移的特点,即保持符号位不变,相当于将原数除以 2 的移位位数次方并向下取整。

运算符优先级一览

优先级 运算符 结合性
1 [] () . -> ++(后置) --(后置) 左→右
2 ++(前置) --(前置) sizeof & * +(正号) -(负号) ~ ! 右→左
3 (强制转换类型) 右→左
4 .* ->* 左→右
5 * / % 左→右
6 + - 左→右
7 << >> 左→右
8 < > <= >= 左→右
9 == != 左→右
10 & 左→右
11 ^ 左→右
12 | 左→右
13 && 左→右
14 || 左→右
15 ?: 右→左
16 = *= /= %= += -= <<= >>= &= ^= |= 右→左
17 , 左→右

2.3 数据的输入输出

1. I/O流

在C++中,将数据从一个对象到另一个对象的流动抽象为“流”。流在使用前要被建立,使用后要被删除。从流中获取数据的操作称为提取操作,向流中添加数据的操作称为插入操作。数据的输入与输出是通过 I/O 流来实现的,cin 和 cout 是预定义的流类对象。cin 用来处理标准输入,即键盘输入。cout 用来处理标准输出,即屏幕输出。

<<是预定义的插入符,作用在流类对象 cout 上便可以实现最一般的屏幕输出。格式如下:

cout << 表达式 1 << 表达式 2 <<…

在输出语句中,可以串联多个插入运算符,输出多个数据项。在插入运算符后面可以写任意复杂的表达式,编译系统会自动计算出它们的值并传递给插入符。

最一般的键盘输入是将提取符作用在流类对象 cin 上。格式如下:

cin >> 表达式 1 >> 表达式 2 >> …

在输入语句中,提取符可以连续写多个,每个后面跟一个表达式,该表达式通常是用于存放输入值的变量。

2. I/O格式控制

C++ I/O 流类库提供了一些操纵符,可以直接嵌入到输入输出语句中来实现 I/O 格式控制。要使用操纵符,首先必须在源程序的开头包含 iomanip 头文件。

操纵符名 含义
dec 数值数据采用十进制表示
hex 数值数据采用十六进制表示
oct 数值数据采用八进制表示
ws 提取空白符
endl 插入换行符,并刷新流
ends 插入空字符
setprecision(int) 设置浮点数的小数位数(包括小数点)
setw(int) 设置域宽

2.4 基础算法结构

1. if 选择结构

if 结构用于根据条件执行不同的代码块。

语法:

if (条件) {
    // 条件为真时执行的代码
} else if (条件) {
    // 条件为真时执行的代码
} else {
    // 其他情况执行的代码
}

例子:

int score = 85;

if (score >= 90) {
    std::cout << "Grade: A" << std::endl;
} else if (score >= 80) {
    std::cout << "Grade: B" << std::endl;
} else if (score >= 70) {
    std::cout << "Grade: C" << std::endl;
} else {
    std::cout << "Grade: F" << std::endl;
}
// 输出:Grade: B

2. 多重选择结构

switch-case 结构用于在多个选项中选择一个执行。

语法:

switch (表达式) {
    case 值1:
        // 代码块
        break;
    case 值2:
        // 代码块
        break;
    default:
        // 默认代码块
}

例子:

int day = 3;

switch (day) {
    case 1:
        std::cout << "Monday" << std::endl;
        break;
    case 2:
        std::cout << "Tuesday" << std::endl;
        break;
    case 3:
        std::cout << "Wednesday" << std::endl;
        break;
    default:
        std::cout << "Other day" << std::endl;
}
// 输出:Wednesday

3. 循环结构

C++ 提供了多种循环结构,包括 whiledo-whilefor 循环。

3.1 while 循环

语法:

while (条件) {
    // 循环体
}

例子:

int i = 1;

while (i <= 5) {
    std::cout << i << " ";
    i++;
}
// 输出:1 2 3 4 5

3.2 do-while 循环

语法:

do {
    // 循环体
} while (条件);

例子:

int j = 1;

do {
    std::cout << j << " ";
    j++;
} while (j <= 5);
// 输出:1 2 3 4 5
3.3 for 循环

语法:

for (初始化; 条件; 更新) {
    // 循环体
}

例子:

for (int k = 1; k <= 5; k++) {
    std::cout << k << " ";
}
// 输出:1 2 3 4 5

4. 其它控制语句

在 C++ 中,breakcontinuegoto 是三种常用的控制流语句,它们可以改变程序的正常执行流程。

1. break 语句

break 语句用于立即退出最近的封闭循环(如 forwhiledo-while)或 switch 语句。

用法示例:

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 5; i++) {
        if (i == 3) {
            break; // 当 i 等于 3 时,退出循环
        }
        cout << i << " ";
    }
    cout << "\n循环结束。" << endl;
    return 0;
}

输出结果

1 2
循环结束。

switch 语句中,break 用于防止代码“贯穿”(fall-through)到下一个 case

#include <iostream>
using namespace std;

int main() {
    char grade = 'B';
    switch (grade) {
        case 'A':
            cout << "优秀" << endl;
            break;
        case 'B':
            cout << "良好" << endl;
            break;
        case 'C':
            cout << "中等" << endl;
            break;
        default:
            cout << "无效等级" << endl;
    }
    return 0;
}

输出结果

良好

2. continue 语句

continue 语句用于跳过当前循环的剩余部分,并直接进入下一次循环迭代。

用法示例:

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 5; i++) {
        if (i == 3) {
            continue; // 当 i 等于 3 时,跳过本次循环剩余部分
        }
        cout << i << " ";
    }
    cout << "\n循环结束。" << endl;
    return 0;
}

输出结果

1 2 4 5
循环结束。

3. goto 语句

goto 语句用于无条件跳转到程序中指定的标签位置。虽然 goto 是 C++ 中的一个合法语句,但它的使用通常被认为是不好的编程习惯,因为它可能导致代码难以理解和维护。在现代编程中,尽量避免使用 goto

用法示例:

#include <iostream>
using namespace std;

int main() {
    for (int i = 1; i <= 5; i++) {
        if (i == 3) {
            goto end; // 跳转到标签 "end" 的位置
        }
        cout << i << " ";
    }

end:
    cout << "\n循环结束。" << endl;
    return 0;
}

输出结果

1 2
循环结束。

总结
语句 功能描述
break 退出最近的封闭循环或 switch 语句。
continue 跳过当前循环的剩余部分,进入下一次迭代。
goto 无条件跳转到指定标签位置(不推荐使用)。

2.5 自定义数据类型

C++语言不仅有丰富的内置基本数据类型,而且允许用户自定义数据类型。自定义数据类型有:枚举类型、结构类型、联合类型、数组类型、类类型等。

typedef声明

typedef 是 C 和 C++ 中用于声明类型别名的关键字。它允许你为已有的类型定义一个新的名称(别名),从而使代码更具可读性和易用性。typedef 不会创建新的类型,只是为现有类型提供了一个新的名字。

语法:
typedef 现有类型名 别名;
作用:
  1. 简化复杂类型名称:为复杂的类型(如指针、数组、函数指针等)定义更简单的名称。
  2. 提高代码可读性:使用有意义的别名,使代码更易读。
  3. 兼容性:在不同系统或库之间提供统一的类型接口。
示例 1:为基本类型定义别名

#include <iostream>
using namespace std;

int main() {
    typedef int Integer; // 为 int 类型定义别名 Integer

    Integer a = 10;
    Integer b = 20;

    cout << "a + b = " << a + b << endl;
    return 0;
}
输出
a + b = 30
在这个例子中,Integerint 的别名,可以像使用 int 一样使用 Integer

示例 2:为指针类型定义别名

#include <iostream>
using namespace std;

int main() {
    typedef int* IntegerPointer; // 为 int* 定义别名 IntegerPointer

    IntegerPointer ptr;
    int value = 42;
    ptr = &value;

    cout << "value = " << *ptr << endl;
    return 0;
}
输出
value = 42
这里,IntegerPointerint* 的别名,用于指针操作。

示例 3:为函数指针定义别名

#include <iostream>
using namespace std;

// 函数原型
typedef int (*FunctionPointer)(int, int); // 为函数指针定义别名 FunctionPointer

int add(int a, int b) {
    return a + b;
}

int main() {
    FunctionPointer fp = add; // 使用别名定义函数指针

    cout << "Result: " << fp(5, 3) << endl;
    return 0;
}
输出
Result: 8
在这个例子中,FunctionPointer 是一个指向接受两个 int 参数并返回 int 的函数的指针。

示例 4:为结构体定义别名

#include <iostream>
using namespace std;

typedef struct {
    int x;
    int y;
} Point; // 为结构体定义别名 Point

int main() {
    Point p1 = {3, 4};
    cout << "p1.x = " << p1.x << ", p1.y = " << p1.y << endl;
    return 0;
}
输出
p1.x = 3, p1.y = 4
这里,Point 是一个结构体类型的别名,可以直接使用 Point 来声明结构体变量。

枚举类型 enum

在 C++ 中,enum(枚举类型)用于定义一组命名的整数常量。枚举类型可以提高代码的可读性和可维护性,因为它为整数常量赋予了有意义的名称。

枚举类型的定义使用 enum 关键字,后面跟枚举名称和一组枚举值,枚举值用大括号括起来,并用逗号分隔。

语法:

enum 枚举名 {
    枚举值1,
    枚举值2,
    ...
    枚举值N
};

默认情况下,枚举值会被自动赋值为整数,从 0 开始递增。你也可以显式地为枚举值指定整数值。

示例 1:基本枚举类型
#include <iostream>
using namespace std;

int main() {
    // 定义一个枚举类型
    enum Day {
        Monday,
        Tuesday,
        Wednesday,
        Thursday,
        Friday,
        Saturday,
        Sunday
    };

    // 声明枚举变量
    Day today = Tuesday;

    // 输出枚举值对应的整数
    cout << "Tuesday 的整数值是: " << today << endl;

    return 0;
}

输出

Tuesday 的整数值是: 1

在这个例子中,enum Day 定义了一个表示星期几的枚举类型。枚举值 MondaySunday 默认从 0 开始递增。

示例 2:显式赋值的枚举
#include <iostream>
using namespace std;

int main() {
    // 定义一个枚举类型,显式指定枚举值
    enum Status {
        Active = 1,
        Inactive = 0,
        Pending = 2,
        Suspended = 3
    };

    // 声明枚举变量
    Status accountStatus = Pending;

    // 输出枚举值对应的整数
    cout << "Pending 的整数值是: " << accountStatus << endl;

    return 0;
}

输出

Pending 的整数值是: 2

在这个例子中,enum Status 定义了一个表示状态的枚举类型,并显式地为每个枚举值指定了整数值。

示例 3:枚举的作用域

从 C++11 开始,可以使用 enum classenum struct 来定义枚举类型,这可以避免枚举值污染全局命名空间。

#include <iostream>
using namespace std;

int main() {
    // 定义一个枚举类
    enum class Color {
        Red,
        Green,
        Blue
    };

    // 声明枚举变量
    Color favColor = Color::Green;

    // 输出枚举值对应的整数
    cout << "Green 的整数值是: " << static_cast<int>(favColor) << endl;

    return 0;
}

输出

Green 的整数值是: 1

在这个例子中,enum class Color 定义了一个枚举类。枚举值需要通过作用域解析操作符 :: 来访问。

3. 函数

  • 一个较为复杂的系统往往需要划分为若干子系统,然后对这些子系统分别进行开发和调试。高级语言中的子程序就是用来实现这种模块划分的。C 和 C++ 语言中的子程序体现为函数。通常将相对独立的、经常使用的功能抽象为函数。函数编写好以后,可以被重复使用,使用时可以只关心函数的功能和使用方法而不必关心函数功能的具体实现。这样有利于代码重用,可以提高开发效率、增强程序的可靠性,也便于分工合作和修改维护。

3.1 函数的定义与使用

  • 调用其他函数的函数称为主调函数,被其他函数调用的函数称为被调函数。一个函数很可能既调用别的函数又被另外的函数调用,这样它可能在某一个调用与被调用关系中充当主调函数,而在另一个调用与被调用关系中充当被调函数。

1. 函数的定义

类型说明符 函数名(形式参数表) {
    函数体
}
形式参数
类型1 形参名1, 类型2 形参名2, ..., 类型n 形参名n
  • typel,type2,…,typen 是类型标识符,表示形参的类型。namel,name2,…,namen 是形参名。形参的作用是实现主调函数与被调函数之间的联系。通常将函数所处理的数据、影响函数功能的因素或者函数处理的结果作为形参。

  • 如果一个函数的形参表为空,则表示它没有任何形参,例如此前例题中的 main 函数都没有形参。main 函数也可以有形参,其形参也称命令行参数,由操作系统在启动程序时初始化。

函数的返回值和返回值类型
  • 函数可以有一个返回值,函数的返回值是需要返回给主调函数的处理结果。类型说明符规定了函数返回值的类型。函数的返回值由 return 语句给出,格式如下:
return 表达式;
  • 除了指定函数的返回值外,return 语句还有一个作用,就是结束当前函数的执行。

  • 一个函数也可以不将任何值返回给主调函数,这时它的类型标识符为 void,可以不写 return 语句,但也可以写一个不带表达式的 return 语句,用于结束当前函数的调用,格式如下:

return;

2. 函数的调用

函数的调用形式
  • 在调用之前需要声明函数。函数的定义就属于函数的声明,因此,在定义了一个函数之后,可以直接调用这个函数。但如果希望在定义一个函数前调用它,则需要在调用函数之前添加该函数的函数原型声明。函数原型声明的形式如下:
类型说明符 函数名(含类型说明的形参表);
  • 声明了函数原型之后,便可以按如下形式调用子函数:
函数名(实参列表)
  • 实参列表中应给出与函数原型形参个数相同、类型相符的实参,每个实参都是一个表达式。函数调用可以作为一条语句,这时函数可以没有返回值。函数调用也可以出现在表达式中,这时就必须有一个明确的返回值。

  • 调用一个函数时,首先计算函数的实参列表中各个表达式的值,然后主调函数暂停执行,开始执行被调函数,被调函数中形参的初值就是主调函数中实参表达式的求值结果。当被调函数执行到 return 语句,或执行到函数末尾时,被调函数执行完毕,继续执行主调函数。

  • 嵌套调用:函数允许嵌套调用。如果函数 1 调用了函数 2,函数 2 再调用函数 3,便形成了函数的嵌套调用。

  • 递归调用:函数可以直接或间接地调用自身,称为递归调用。

递归算法示例

下面是一个用递归实现汉诺塔问题的C++程序:

#include <iostream>
using namespace std;

// n:盘子的数量
// from:起始柱子
// to:目标柱子
// aux:辅助柱子
void hanoi(int n, char from, char to, char aux) {
    if (n == 1) {
        cout << "Move disk 1 from " << from << " to " << to << endl;
        return;
    }
    hanoi(n - 1, from, aux, to); // 先将n-1个盘子移到辅助柱
    cout << "Move disk " << n << " from " << from << " to " << to << endl; // 最大的盘子移到目标柱
    hanoi(n - 1, aux, to, from); // 再将n-1个盘子从辅助柱移到目标柱
}

int main() {
    int n;
    cout << "请输入盘子的数量: ";
    cin >> n;
    hanoi(n, 'A', 'C', 'B'); // A为起始柱,C为目标柱,B为辅助柱
    return 0;
}

运行示例:

请输入盘子的数量: 3
Move disk 1 from A to C
Move disk 2 from A to B
Move disk 1 from C to B
Move disk 3 from A to C
Move disk 1 from B to A
Move disk 2 from B to C
Move disk 1 from A to C

  • 递归算法的实质是将原有的问题分解为新的问题,而解决新问题时又用到了原有问题的解法。按照这一原则分解下去,每次出现的新问题都是原有问题的简化的子集,而最终分解出来的问题,是一个已知解的问题。这便是有限的递归调用。只有有限的递归调用才是有意义的,无限的递归调用永远得不到解,没有实际意义。

3. 函数的参数传递

  • 在函数未被调用时,函数的形参并不占有实际的内存空间,也没有实际的值。只有在函数被调用时才为形参分配存储单元,并将实参与形参结合。每个实参都是一个表达式,其类型必须与形参相符。函数的参数传递指的就是形参与实参结合(简称形实结合)的过程,形实结合的方式有值传递和引用传递。

  • 值传递:值传递是指当发生函数调用时,给形参分配内存空间,并用实参来初始化形参(直接将实参的值传递给形参)。这一过程是参数值的单向传递过程,一旦形参获得了值便与实参脱离关系,此后无论形参发生了怎样的改变,都不会影响到实参。

  • 引用传递:引用是一种特殊类型的变量,可以被认为是另一个变量的别名,通过引用名与通过被引用的变量名访问变量的效果是一样的。

引用传递的语法
  • 函数参数为引用类型:在函数声明和定义中,将参数的类型声明为引用类型,即在类型名后加上 & 符号。

    void modifyValue(int& num) {
        num += 10;
    }
    
    在上面的例子中,num 是一个引用参数,它指向调用者提供的整数变量。在函数内部对 num 的修改,将直接影响到调用者的变量。

  • 声明引用变量:除了作为函数参数,还可以在程序中直接声明引用变量。

    int original = 5;
    int& ref = original; // ref 是 original 的引用
    ref = 10; // original 的值变为 10
    
    在这个例子中,reforiginal 的引用,对 ref 的赋值操作直接作用于 original

引用与 const 关键字
  • const 引用:可以将引用声明为 const 引用,这样就不能通过该引用来修改原始数据。const 引用常用于函数参数,以便传递只读的大型数据结构,避免不必要的复制。
    void printValue(const int& num) {
        std::cout << num << std::endl;
        // num = 20; // 错误:num 是 const 引用,不能通过它修改原始数据
    }
    
  • 延长临时对象的生命周期const 引用还可以用于延长临时对象的生命周期,使其在函数调用结束后仍然有效。
    const int& getTempValue() {
        static int temp = 0; // 使用 static 变量避免临时对象销毁
        return temp;
    }
    
    在上面的例子中,getTempValue 函数返回一个 const 引用,指向一个静态变量 temp。这样,即使函数调用结束,temp 仍然存在,引用依然有效。

使用引用时必须注意下列问题:

  • 声明一个引用时,必须同时对它进行初始化,使它指向一个已存在的对象。

  • 一旦一个引用被初始化后,就不能改为指向其他对象。

引用也可以作为形参,如果将引用作为形参,情况便稍有不同。这是因为,形参的初始化不在类型说明时进行,而是在执行主调函数中的调用表达式时,才为形参分配内存空间,同时用实参来初始化形参。这样引用类型的形参就通过形实结合,成为了实参的一个别名,对形参的任何操作也就会直接作用于实参。

用引用作为形参,在函数调用时发生的参数传递,称为引用传递。

3.2 内联函数

  • 对于一些功能简单、规模较小又使用频繁的函数,可以设计为内联函数。内联函数不是在调用时发生控制转移,而是在编译时将函数体嵌入在每一个调用处。这样就节省了参数传递、控制转移等开销。

  • 内联函数的定义与普通函数的定义方式几乎一样,只是需要使用关键字 inline,其语法形式如下:

inline 类型说明符 函数名(形参表) {
    函数体
}
  • 需要注意的是,inline 关键字只是表示一个要求,编译器并不承诺将 inline 修饰的函数作为内联。而在现代编译器中,没有用 inline 修饰的函数也可能被编译为内联。通常内联函数应该是比较简单的函数,结构简单、语句少。

定义方式

  • 内联函数 :在函数定义时,在函数返回值类型前加上 inline 关键字进行声明。例如:
inline int add(int a, int b) {
    return a + b;
}
  • 普通函数 :按照常规的方式定义函数,不需要特殊的修饰符。例如:
int add(int a, int b) {
    return a + b;
}

函数调用机制

  • 内联函数 :在编译时,编译器会将内联函数的调用替换为该函数的函数体,也就是把内联函数的代码直接嵌入到每个调用它的地方,而不是像普通函数调用那样产生一个函数调用指令,从而避免了函数调用和返回带来的额外开销,提高了程序的运行效率。
  • 普通函数 :当调用普通函数时,程序的执行流程会从当前的位置转移到函数的代码处,执行完函数体内的代码后,再返回到调用点继续执行后续的代码,会产生一定的函数调用开销,如压栈、弹栈等操作。

函数体的大小和复杂度

  • 内联函数 :通常适用于函数体比较小且逻辑简单的函数。因为如果内联函数的代码过长或过于复杂,会导致编译后的代码体积大幅增加,可能会适得其反,影响程序的性能。
  • 普通函数 :可以处理各种复杂度的函数逻辑,不受函数体大小的限制。

编译器处理方式

  • 内联函数 :编译器会根据一定的规则和优化策略来决定是否真正将内联函数内联。即使函数被声明为内联函数,在某些情况下(如函数地址被取用、递归调用等),编译器也可能不会对其进行内联展开,而是像普通函数一样处理。
  • 普通函数 :编译器会按照常规的函数调用机制进行编译和链接。

3.3 带默认形参值的函数

  • 函数在定义时可以预先声明默认的形参值。调用时如果给出实参,则用实参初始化形参,如果没有给出实参,则采用预先声明的默认形参值。
int add(int x = 5, int y = 6) {
  return x + y;
}
  • 有默认值的形参必须在形参列表的最后,也就是说,在有默认值的形参右面,不能出现无默认值的形参。因为在函数调用中,实参与形参是按从左向右的顺序建立对应关系的。
int add(int x, int y = 5, int z = 6); //正确
int add(int x = 5, int y, int z = 6); //错误
  • 在相同的作用域内,不允许在同一个函数的多个声明中对同一个参数的默认值重复定义,即使前后定义的值相同也不行。注意,函数的定义也属于声明,这样,如果一个函数在定义之前又有原型声明,默认形参值需要在原型声明中给出,定义中不能再出现默认形参值。
int add(int x = 5, int y = 6);
int main() {
  add();
  return 0;
}
int add(int x /* = 5*/, int y /* = 6*/) {   //此处必须把默认形参注释起来
  return x + y;
}

3.4 函数重载

  • 两个以上的函数,具有相同的函数名,但是形参的个数或者类型不同,编译器根据实参和形参的类型及个数的最佳匹配,自动确定调用哪一个函数,这就是函数的重载。

  • 如果没有重载机制,那么对不同类型的数据进行相同的操作也需要定义名称完全不同的函数。C++ 允许功能相近的函数在相同的作用域内以相同函数名定义,从而形成重载。方便使用,便于记忆。

#### 例子

#include <iostream>
#include <cmath> // 用于 sqrt 函数

// 计算两个整数的和
int add(int a, int b) {
    return a + b;
}

// 计算两个浮点数的和
double add(double a, double b) {
    return a + b;
}

// 计算三个整数的和
int add(int a, int b, int c) {
    return a + b + c;
}

int main() {
    // 调用不同重载版本的 add 函数
    int sum1 = add(5, 3); // 调用 add(int, int)
    double sum2 = add(3.14, 2.5); // 调用 add(double, double)
    int sum3 = add(2, 4, 6); // 调用 add(int, int, int)

    std::cout << "Sum of two integers: " << sum1 << std::endl;
    std::cout << "Sum of two doubles: " << sum2 << std::endl;
    std::cout << "Sum of three integers: " << sum3 << std::endl;

    return 0;
}

运行结果:

Sum of two integers: 8
Sum of two doubles: 5.64
Sum of three integers: 12

解释

  • 在这个例子中,我们定义了三个重载的 add 函数:

    • 第一个 add 函数接受两个整数参数并返回它们的和。
    • 第二个 add 函数接受两个浮点数参数并返回它们的和。
    • 第三个 add 函数接受三个整数参数并返回它们的和。
  • main 函数中,根据不同的参数类型和数量,编译器会自动选择合适的重载函数进行调用。这种函数重载的机制使得函数的使用更加灵活,可以根据不同的需求提供多种参数形式的函数实现。

  • 注意重载函数的形参必须不同:个数不同或者类型不同。编译程序对实参和形参的类型及个数进行最佳匹配,来选择调用哪一个函数。如果函数名相同,形参类型也相同(无论函数返回值类型是否相同),在编译时会被认为是语法错误(函数重复定义)。

  • 当使用具有默认形参值的函数重载形式时,需要注意防止二义性。

3.5 C++ 系统函数

  • C++ 不仅允许用户根据需要自定义函数,而且 C++ 的系统库中提供了几百个函数可供程序员使用。例如:求平方根函数(sqrt)、求绝对值函数(abs)等。

  • 我们知道,调用函数之前必须先加以声明,系统函数的原型声明已经全部由系统提供了,分类保存在不同的头文件中。程序员需要做的事情,就是用 include 指令嵌入相应的头文件,然后便可以使用系统函数。例如,要使用数学函数,只要嵌入头文件 cmath。

  • 充分利用系统函数,可以大大减少编程的工作量,提高程序的运行效率和可靠性。要使用系统函数应该注意以下两点:

  • 编译环境提供的系统函数分为两类,一类是标准 C++ 的函数,另一类是非标准 C++ 的函数,它是当前操作系统或编译环境中所特有的系统函数。编程时应优先使用标准 C++ 的函数,因为标准 C++ 函数是各种编译环境所普遍支持的,只使用标准 C++ 函数的程序具有很好的可移植性。

    • 标准 C++函数很多是从标准 C 继承而来的。例 3-17 中使用的 cm ath 头文件中的前缀 c ,就用来表示它是一个继承自标准 C 的头文件,类似的头文件还有 cstd lib , cstdio,ctim e 等。标准 C 中,这些头文件的名字分别是 m ath.h,stdlib.h,stdio.h, tim e.h ,为了保持对 C 程序的兼容性,C++中也允许继续使用这些以.h 为后缀的头文件。保留这些头文件仅仅是出于兼容性考虑,在编写 C++程序时,应尽量使用不带.h 后缀的头文件。
  • 有时也需要使用一些非标准 C++ 的系统函数,例如在处理和操作系统相关的事务时,常常需要调用当前操作系统特有的一些函数。不同的编译系统提供的函数有所不同。即使是同一系列的编译系统,如果版本不同系统函数也会略有差别。因此编程者必须查阅编译系统的库函数参考手册或联机帮助,查清楚函数的功能、参数、返回值和使用方法。

3.6 深度探索

1. 运行栈与函数调用的执行

  • 运行栈工作原理

  • 函数的形参和局部变量,当调用开始时生效,当函数返回后即失效,它们有效的期间与函数调用的期间是重合的。这样,对于一组嵌套的函数调用中的一次调用,其形参和局部变量生效的时间越早,失效的时间就越晚,这刚好满足 “后进先出” 的要求。这样,很自然地,函数的形参和局部变量,可以用栈来存储,这种栈叫做运行栈。

  • 运行栈实际上是一段区域的内存空间,与存储全局变量的空间无异,只是寻址的方式不同而已。运行栈中的数据分为一个一个栈帧,每个栈帧对应一次函数调用,栈帧中包括这次函数调用中的形参值、一些控制信息、局部变量值和一些临时数据。每次发生函数调用时,都会有一个栈帧被压入运行栈中,而调用返回后,相应的栈帧会被弹出。

  • 函数调用的执行过程

  • 在将数据压入和弹出运行栈、确定要访问的形参和局部变量的地址时,都需要获得栈顶的地址,因此需要有一个专门的存储单元记录栈顶地址。在 IA-32 中,esp 寄存器就是用来记录栈顶地址的,它称为栈指针。

2. 函数声明与类型安全

  • 一个函数的原型信息(参数个数、参数类型和返回类型),并没有写在编译后的机器语言代码之中,而是全部蕴涵在了这个函数所执行的操作之中。如果在调用一个函数前必须声明函数原型,就能够避免向一个函数传递数量不正确或类型不正确的参数。因为在提供声明的情况下,执行函数调用时,若传递的参数数量和类型不正确,编译器很容易检查出来。

  • 同样地,对于函数的返回类型来说,也有类似的问题。如果一个函数的返回类型为 void,但却将它当作整型函数来调用,会是什么后果呢?通过函数调用时,主调函数依据函数原型把数量和类型正确的参数压入运行栈中,被调函数才能够读取正确的参数;被调函数依据函数原型存储类型正确的返回值,主调函数才能够得到正确的返回值。

  • C++ 比 C 更安全,原因之一就在于,C 语言允许在调用函数前只对函数进行不完整的声明 —— 只声明函数名和返回类型,而不声明参数类型,C 语言甚至允许在调用函数前根本不对函数加以声明。这有时会导致一些很隐蔽的错误发生。

4. 类与对象

4.1 面向对象程序的基本特点

4.1. 抽象

  • 一般来讲,对一个问题的抽象应该包括两个方面:数据抽象和行为抽象(或称为功能抽象、代码抽象)。前者描述某类对象的属性或状态,也就是此类对象区别于彼类对象的特征;后者描述的是某类对象的共同行为或功能特征。

  • 下面来看两个简单的例子。首先我们在计算机上实现一个简单的时钟程序。通过对时钟进行分析可以看出,需要 3 个整型数来存储时间,分别表示时、分和秒,这就是对时钟所具有的数据进行抽象。另外,时钟要具有显示时间、设置时间等简单的功能,这就是对它的行为的抽象。用 C++ 的变量和函数可以将抽象后的时钟属性描述如下:

  • 数据抽象:

int hour, minute, second;
  • 功能抽象:
void setTime(int h, int m, int s);  // 设置时间
void showTime();  // 显示时间
  • 另一个例子是对人进行抽象。通过对人类进行归纳、抽象,提取出其中的共性,可以得到如下的抽象描述:

  • 共同的属性:如姓名、性别、年龄等,它们组成了人的数据抽象部分,用 C++ 语言的变量来表达,可以是:

string name;
char gender;
int age;
  • 共同的行为:比如吃饭、行走这些生物性行为,以及工作、学习等社会性行为。这构成了人的行为抽象部分,也可以用 C++ 语言的函数表达:
void eat();
void walk();
void work();
void study();
  • 如果是为一个企业开发用于人事管理的软件,这个时候所关心的特征就不会只限于这些。除了上述人的这些共性,还要关心工龄、工资、工作部门、工作能力,以及上下级隶属关系等。由此也可以看出,对于同一个研究对象,由于所研究问题的侧重点不同,就可能产生不同的抽象结果。即使对于同一个问题,解决问题的要求不同,也可能产生不同的抽象结果。

2. 封装

  • 封装就是将抽象得到的数据和行为(或功能)相结合,形成一个有机的整体,也就是将数据与操作数据的函数代码进行有机的结合,形成 “类”,其中的数据和函数都是类的成员。例如在抽象的基础上,可以将时钟的数据和功能封装起来,构成一个时钟类。按照 C++ 的语法,时钟类的定义如下:
class Clock {
public:
    void setTime(int h, int m, int s);  // 设置时间
    void showTime();  // 显示时间
private:
    int hour, minute, second;  // 时、分、秒
};
  • 这里定义了一个名为 Clock 的类,其中的函数成员和数据成员,描述了抽象的结果。“{” 和 “}” 限定了类的边界。关键字 publicprivate 是用来指定成员的不同访问权限的,这个问题将在 4.2.2 小节中详细说明。声明为 public 的两个函数为类提供了外部接口,外界只能通过这个接口来与 Clock 类发生联系。声明为 private 的 3 个整型数据是本类的私有数据,外部无法直接访问。

  • 可以看到,通过封装使一部分成员充当类与外部的接口,而将其他成员隐蔽起来,这样就达到了对成员访问权限的合理控制,使不同类之间的相互影响减少到最低限度,进而增强数据的安全性和简化程序编写工作。

  • 将数据和代码封装为一个可重用的程序模块,在编写程序时就可以有效利用已有的成果。由于通过外部接口,依据特定的访问规则,就可以使用封装好的模块。使用时可以不必了解类的实现细节。

3. 继承

  • 现实生活中的概念具有特殊与一般的关系。例如,一般意义的 “人” 都有姓名、性别、年龄等属性和吃饭、行走、工作、学习等行为,但按照职业划分,人又分为学生、教师、工程师、医生等,每一类人又有各自的特殊属性与行为,例如学生具有专业、年级等特殊属性和升级、毕业等特殊行为,这些属性和行为是医生所不具有的。如何把特殊与一般的概念间的关系描述清楚,使得特殊概念之间既能共享一般的属性和行为,又能具有特殊的属性和行为呢?

  • 继承,就是解决这个问题的。只有继承,才可以在一般概念基础上,派生出特殊概念,使得一般概念中的属性和行为可以被特殊概念共享,摆脱重复分析、重复开发的困境。C++ 语言中提供了类的继承机制,允许程序员在保持原有类特性的基础上,进行更具体、更详细的说明。通过类的这种层次结构,可以很好地反映出特殊概念与一般概念的关系。第 7 章将详细介绍类的继承。

4. 多态

  • 面向对象程序设计中的多态是对人类思维方式的一种直接模拟,比如我们在日常生活中说 “打球”,这个 “打”,就表示了一个抽象的信息,具有多重含义。我们可以说:打篮球、打排球、打羽毛球,都使用 “打” 来表示参与某种球类运动,而其中的规则和实际动作却相差甚远。实际上这就是对多种运动行为的抽象。在程序中也是这样的,第 3 章介绍的重载函数就是实现多态性的一种手段。

  • 从广义上说,多态性是指一段程序能够处理多种类型对象的能力。在 C++ 语言中,这种多态性可以通过强制多态、重载多态、类型参数化多态、包含多态 4 种形式来实现。

  • 强制多态是通过将一种类型的数据转换成另一种类型的数据来实现的,也就是前面介绍过的数据类型转换(隐式或显式)。重载是指给同一个名字赋予不同的含义,在第 3 章介绍过函数重载,第 8 章还将介绍运算符重载。这两种多态属于特殊多态性,只是表面的多态性。

  • 包含多态和类形参数化多态属于一般多态性,是真正的多态性。C++ 中采用虚函数实现包含多态。虚函数是多态性的精华,将在第 8 章介绍。模板是 C++ 实现参数化多态性的工具,分为函数模板和类模板两种,将在第 9 章介绍。

4.2 类和对象

  • 在面向过程的结构化程序设计中,程序的模块是由函数构成的,函数将逻辑上相关的语句与数据封装,用于完成特定的功能。在面向对象程序设计中,程序模块是由类构成的。类是对逻辑上相关的函数与数据的封装,它是对问题的抽象描述。因此,后者的集成程度更高,也就更适合用于大型复杂程序的开发。

  • 4.1 节中从抽象和封装的角度引出了类的概念。对于初学者来说,不妨再从另一个更简单的角度来理解类。首先让我们回顾一下基本数据类型,比如 intdoublebool 等。当定义一个基本类型的变量时,究竟定义了什么呢?请看下面的语句:

int i;
bool b;
  • 显然定义变量 i 是用于存储 int 型数据的,变量 b 是用来存放 bool 型数据的。但是变量声明的意义不只是这个,另一个同样重要的意义常被我们忽略了,这就是限定对变量的操作。例如对 i 可以进行算术运算、比较运算等,对 b 可以进行逻辑运算、比较运算。这说明每一种数据类型都包括了数据本身的属性以及对数据的操作。

  • 无论哪一种程序语言,其基本数据类型都是有限的,C++ 的基本数据类型也远不能满足描述现实世界中各种对象的需要。于是 C++ 的语法提供了对自定义类型的支持,这就是类。类实际上相当于一种用户自定义的类型,原则上我们可以自定义无限多种新类型。因此不仅可以用 int 类型的变量表示整数,也可以用自定义类的变量表示 “时钟”、“汽车”、“几何图形” 或者 “人” 等对象。正如基本数据类型隐含包括了数据和操作,在定义一个类时也要说明数据和操作。这也正是在 4.1 节中介绍过的,通过对现实世界的对象进行数据抽象和功能(行为)抽象,得到类的数据成员和函数成员。

  • 当定义了一个类之后,便可以定义该类的变量,这个变量就称为类的对象(或实例),这个定义的过程也称为类的实例化。

1. 类的定义

  • 以时钟为例,时钟类的定义如下:
class Clock {
public:
    void setTime(int newH, int newM, int newS);  // 设置时间
    void showTime();  // 显示时间
private:
    int hour, minute, second;  // 时、分、秒
};
  • 这里,封装了时钟的数据和行为,分别称为 Clock 类的数据成员和函数成员。定义类的语法形式如下:
class 类名 {
    访问控制属性1:
        成员列表1;
    访问控制属性2:
        成员列表2;
    ...
};
  • 其中 publicprotectedprivate 分别表示对成员的不同访问权限控制,在 4.2.2 小节中将对此详细介绍。注意,在类中可以只声明函数的原型,函数的实现(即函数体)可以在类外定义,这将在 4.2.4 小节中加以介绍。

2. 类成员的访问控制

  • 类的成员包括数据成员和函数成员,分别描述问题的属性和行为,是不可分割的两个方面。为了理解类成员的访问权限,我们还是先来看钟表这个熟悉的例子。不管哪一种钟表,都记录着时间值,都有显示面板、旋钮或按钮。正如 4.1 节所述,可以将所有钟表的共性抽象为钟表类。正常使用时使用者只能通过面板查看时间,通过旋钮或按钮来调整时间。当然,修理师可以拆开钟表,但一般人最好别尝试。这样,面板、旋钮或按钮就是我们接触和使用钟表的仅有途径,因此将它们设计为类的外部接口。而钟表记录的时间值,便是类的私有成员,使用者只能通过外部接口去访问私有成员。

  • 对类成员访问权限的控制,是通过设置成员的访问控制属性而实现的。访问控制属性可以有以下 3 种:公有类型(public)、私有类型(private)和保护类型(protected)。

  • 公有类型成员定义了类的外部接口。公有成员用 public 关键字声明,在类外只能访问类的公有成员。对于时钟类,从外部只能调用 setTime()showTime() 这两个公有类型的函数成员来改变或查看时间。

  • 在关键字 private 后面声明的就是类的私有成员。如果私有成员紧接着类名称,则关键字 private 可以省略。私有成员只能被本类的成员函数访问,来自类外部的任何访问都是非法的。这样,私有成员就完全隐蔽在类中,保护了数据的安全性。时钟类中的 hourminutesecond 都是私有成员。

  • 习惯:一般情况下,一个类的数据成员都应该声明为私有成员,这样,内部数据结构就不会对该类以外的其余部分造成影响,程序模块之间的相互作用就被降低到最小。

  • 保护类型成员的性质和私有成员的性质相似,其差别在于继承过程中对产生的新类影响不同。

  • 在类的定义中,具有不同访问属性的成员,可以按任意顺序出现。修饰访问属性的关键字也可以多次出现。但是一个成员只能具有一种访问属性。例如,将时钟类写成以下形式也是正确的。

class Clock {
    int hour, minute, second;  // 私有成员(默认)
public:
    void setTime(int newH, int newM, int newS);  // 公有成员
public:
    void showTime();  // 公有成员
};
  • 习惯:在书写时通常习惯将公有类型放在最前面,这样便于阅读,因为它们是外部访问时所要了解的。

3. 对象

  • 类实际上一种抽象机制,它描述了一类事物的共同属性和行为。在 C++ 中,类的对象就是该类的某一特定实体(也称实例)。例如,将整个公司的雇员看作一个类,那么每一个雇员就是该类的一个特定实体,也就是一个对象。

  • 在第 2 章中介绍过基本数据类型和自定义类型,实际上,每一种数据类型都是对一类数据的抽象,在程序中声明的每一个变量都是其所属数据类型的一个实例。如果将类看作是自定义的类型,那么类的对象就可以看成是该类型的变量。因此在本书中,有时将普通变量和类类型的对象都统称为对象。

  • 声明一个对象和声明一个一般变量相同,采用以下的方式:

类名 对象名;
  • 就声明了一个时钟类型的对象 myClock

  • 注意:对象所占据的内存空间只是用于存放数据成员,函数成员不在每一个对象中存储副本,每个函数的代码在内存中只占据一份空间。

  • 定义了类及其对象,就可以访问对象的成员,例如设置和显示对象 myClock 的时间值。这种访问采用的是 “.” 操作符,访问数据成员的一般形式是:

对象名.数据成员名
  • 调用函数成员的一般形式是:
对象名.函数成员名(参数表)
  • 例如,访问类 Clock 的对象 myClock 的函数成员 showTime() 的方式如下:
myClock.showTime();
  • 在类的外部只能访问到类的公有成员;在类的成员函数中,可以访问到类的全部成员。

4. 类的成员函数

  • 类的成员函数描述的是类的行为,例如时钟类的成员函数 setTime()showTime()。成员函数是程序算法的实现部分,是对封装的数据进行操作的方法。
1. 成员函数的实现
  • 函数的原型声明要写在类体中,原型说明了函数的参数表和返回值类型。而函数的具体实现是写在类定义之外的。与普通函数不同的是,实现成员函数时要指明类的名称,具体形式为:
返回值类型 类名::函数成员名(参数表) {
    函数体
}
  • 例如,时钟类 Clock 的成员函数实现如下:
void Clock::setTime(int newH, int newM, int newS) {
    hour = newH;
    minute = newM;
    second = newS;
}

void Clock::showTime() {
    cout << hour << ":" << minute << ":" << second << endl;
}
  • 可以看出,与普通函数不同,类的成员函数名需要用类名来限制,例如 “Clock::showTime”。
2. 成员函数调用中的目的对象
  • 调用一个成员函数与调用普通函数的差异在于,需要使用 “.” 操作符指出调用所针对的对象,这一对象在本次调用中称为目的对象。例如使用 myClock.showTime() 调用 showTime 函数时,myClock 就是这一调用过程中的目的对象。

  • 在成员函数中可以不使用 “.” 操作符而直接引用目的对象的数据成员,例如上面的 showTime 函数中所引用的 hourminutesecond 都是目的对象的数据成员,以 myClock.showTime() 调用该函数时,被输出的是 myClock 对象的 hourminutesecond 属性。在成员函数中调用当前类的成员函数时,如果不使用 “.” 操作符,那么这一次调用所针对的仍然是目的对象。

  • 在成员函数中引用其他对象的属性和调用其他对象的方法时,都需要使用 “.” 操作符。注意,在类的成员函数中,既可以访问目的对象的私有成员,又可以访问当前类的其他对象的私有成员。

3. 带默认形参值的成员函数
  • 在第 3 章中曾介绍过带默认形参值的函数。类的成员函数也可以有默认形参值,其调用规则与普通函数相同。类成员函数的默认值,一定要写在类定义中,而不能写在类定义之外的函数实现中。有时候这个默认值可以带来很大的方便,比如时钟类的 setTime() 函数,就可以使用默认值如下:
class Clock {
public:
    void setTime(int newH = 0, int newM = 0, int newS = 0);  // 带默认参数
    void showTime();
private:
    int hour, minute, second;
};
  • 这样,如果调用这个函数时没有给出实参,就会按照默认形参值将时钟设置到午夜零点。
4. 内联成员函数
  • 我们知道,函数的调用过程要消耗一些内存资源和运行时间来传递参数和返回值,要记录调用时的状态,以便保证调用完成后能够正确地返回并继续执行。如果有的函数成员需要被频繁调用,而且代码比较简单,这个函数也可以定义为内联函数(inline function)。和第 3 章介绍的普通内联函数相同,内联成员函数的函数体也会在编译时被插入到每一个调用它的地方。这样做可以减少调用的开销,提高执行效率,但是却增加了编译后代码的长度。所以要在权衡利弊的基础上慎重选择,只有对相当简单的成员函数才可以声明为内联函数。

  • 内联函数的声明有两种方式:隐式声明和显式声明。

  • 将函数体直接放在类体内,这种方法称之为隐式声明。比如,将时钟类的 showTime() 函数声明为内联函数,可以写作:

class Clock {
public:
    void setTime(int newH, int newM, int newS);
    void showTime() {  // 隐式内联
        cout << hour << ":" << minute << ":" << second << endl;
    }
private:
    int hour, minute, second;
};
  • 为了保证类定义的简洁,可以采用关键字 inline 显式声明的方式。即在函数体实现时,在函数返回值类型前加上 inline,类定义中不加入 showTime 的函数体。请看下面的表达方式:
class Clock {
public:
    void setTime(int newH, int newM, int newS);
    void showTime();  // 声明
private:
    int hour, minute, second;
};

inline void Clock::showTime() {  // 显式内联
    cout << hour << ":" << minute << ":" << second << endl;
}
  • 效果和前面隐式表达是完全相同的。
例 4-1 时钟类的完整程序。
#include <iostream>
using namespace std;

class Clock {  // 时钟类定义
public:
    void setTime(int newH = 0, int newM = 0, int newS = 0);  // 带默认参数的成员函数
    inline void showTime();  // 显式声明内联函数
private:
    int hour, minute, second;
};

// 成员函数实现
void Clock::setTime(int newH, int newM, int newS) {
    hour = newH;
    minute = newM;
    second = newS;
}

void Clock::showTime() {  // 内联函数实现
    cout << hour << ":" << minute << ":" << second << endl;
}

int main() {
    Clock myClock;  // 定义对象
    myClock.setTime();  // 使用默认参数设置时间为0:0:0
    myClock.showTime();
    myClock.setTime(8, 30, 30);  // 设置时间为8:30:30
    myClock.showTime();
    return 0;
}
  • 请注意,这里的成员函数 setTime 是带有默认形参值的函数,有 3 个默认参数。而函数 showTime 是显式声明内联成员函数,设计为内联的原因是它的语句相当少。在主函数中,首先声明一个 Clock 类的对象 myClock,然后利用这个对象调用其成员函数。第一次调用设置时间为默认值并输出,第二次调用将时间设置为 8:30:30 并输出。程序运行的结果为:
0:0:0
8:30:30

4.3 构造函数和析构函数

  • 类和对象的关系就相当于基本数据类型与它的变量的关系,也就是一般与特殊的关系。每个对象区别于其他对象的地方主要有两个:外在的区别就是对象的名称,而内在的区别就是对象自身的属性值,即数据成员的值。就像定义基本类型变量时可以同时进行初始化一样,在定义对象的时候,也可以同时对它的数据成员赋初值。在特定对象使用结束时,还经常需要进行一些清理工作。C++ 程序中的初始化和清理工作,分别由两个特殊的成员函数来完成,它们就是构造函数和析构函数。

1. 构造函数

  • 要理解构造函数,首先需要理解对象的建立过程。为此先来看看一个基本类型变量的初始化过程:每一个变量在程序运行时都要占据一定的内存空间,在声明一个变量时对变量进行初始化,就意味着在为变量分配内存单元的同时,在其中写入了变量的初始值。这样的初始化在 C++ 源程序中看似很简单,但是编译器却需要根据变量的类型自动产生一些代码来完成初始化过程。

  • 对象的建立过程也是类似的:在程序执行过程中,当遇到对象声明语句时,程序会向操作系统申请一定的内存空间用于存放新建的对象。我们希望程序能像对待普通变量一样,在分配内存空间的同时将数据成员的初始值写入。但是不幸得很,做到这一点不那么容易,因为与普通变量相比,类的对象毕竟太复杂了,编译器不知道如何产生代码来实现初始化。因此如果需要进行对象初始化,程序员要编写初始化程序。如果程序员没有自己编写初始化程序,却在声明对象时贸然指定对象初始值,不仅不能实现初始化,还会引起编译时的语法错误。这就是为什么本书中此前的例题都没有进行对象初始化。

  • 尽管如此,C++ 编译系统在对象初始化的问题上还是替我们做了很多工作。C++ 中严格规定了初始化程序的接口形式,并有一套自动的调用机制。这里所说的初始化程序,便是构造函数。

  • 构造函数的作用就是在对象被创建时利用特定的值构造对象,将对象初始化为一个特定的状态。构造函数也是类的一个成员函数,除了具有一般成员函数的特征之外,还有一些特殊的性质:构造函数的函数名与类名相同,而且没有返回值;构造函数通常被声明为公有函数。只要类中有了构造函数,编译器就会在建立新对象的地方自动插入对构造函数调用的代码。因此我们通常说构造函数在对象被创建的时候将被自动调用。

  • 调用时无须提供参数的构造函数称为默认构造函数。如果类中没有写构造函数,编译器会自动生成一个隐含的默认构造函数,该构造函数的参数列表和函数体皆为空。如果类中声明了构造函数(无论是否有参数),编译器便不会再为之生成隐含的构造函数。在前面的时钟类例子中,没有定义与类 Clock 同名的成员函数 —— 构造函数,这时编译系统就会在编译时自动生成一个默认形式的构造函数:

Clock::Clock() {}  // 隐含的默认构造函数
  • 这个构造函数不做任何事。为什么要生成这个不做任何事情的函数呢?这是因为在建立对象时自动调用构造函数是 C++ 程序 “例行公事” 的必然行为。如果程序员定义了恰当的构造函数,Clock 类的对象在建立时就能够获得一个初始的时间值。现在将 Clock 类修改如下:
class Clock {
public:
    Clock(int newH, int newM, int newS);  // 构造函数
    void setTime(int newH, int newM, int newS);
    void showTime();
private:
    int hour, minute, second;
};
  • 构造函数的实现:
Clock::Clock(int newH, int newM, int newS) {
    hour = newH;
    minute = newM;
    second = newS;
}

下面来看一看建立对象时构造函数的作用:

int main() {
    Clock c(0, 0, 0);  // 调用构造函数,参数为0,0,0
    c.showTime();
    return 0;
}
  • 在建立对象 c 时,会调用构造函数,将实参值用作初始值。

  • 由于 Clock 类中定义了构造函数,所以编译系统就不会再为其生成隐含的默认构造函数了。而这里自定义的构造函数带有形参,所以建立对象时就必须给出初始值,用来作为调用构造函数时的实参。如果在 main 函数中这样声明对象:

Clock c;  // 错误,没有对应的构造函数

编译时就会指出语法错,因为没有给出必要的实参。

作为类的成员函数,构造函数可以直接访问类的所有数据成员,可以是内联函数,可以带有参数表,可以带默认的形参值,也可以重载。这些特征,使得我们可以根据不同问题的需要,有针对性地选择合适的形式将对象初始化成特定的状态。请看下面例子中重载的构造函数及其被调用的情况。

class Clock {
public:
    Clock();  // 默认构造函数
    Clock(int newH, int newM, int newS);  // 带参数的构造函数
    // ... 其他成员
private:
    int hour, minute, second;
};

// 构造函数实现
Clock::Clock() : hour(0), minute(0), second(0) {}  // 初始化列表

Clock::Clock(int newH, int newM, int newS) : hour(newH), minute(newM), second(newS) {}
  • 这里的构造函数有两种重载形式:有参数的和无参数的(即默认构造函数)。
委托构造函数

委托构造函数是 C++11 引入的一种构造函数写法,允许一个构造函数调用同一个类中的另一个构造函数,以简化初始化代码、避免重复。

class Example {
    int x, y;
public:
    Example() : Example(0, 0) {}           // 委托给带参构造函数
    Example(int a) : Example(a, 0) {}      // 委托给带两个参数的构造函数
    Example(int a, int b) : x(a), y(b) {}  // 主构造函数
};

2. 复制构造函数

  • 生成一个对象的副本有两种途径,第一种途径是建立一个新对象,然后将一个已有对象的数据成员值取出来,一一赋给新的对象。这样做虽然可行,但不免繁琐。我们何不使类具有自行复制本类对象的能力呢?这正是复制构造函数的功能。

  • 复制构造函数是一种特殊的构造函数,具有一般构造函数的所有特性,其形参是本类的对象的引用。其作用是使用一个已经存在的对象(由复制构造函数的参数指定),去初始化同类的一个新对象。

  • 程序员可以根据实际问题的需要定义特定的复制构造函数,以实现同类对象之间数据成员的传递。如果程序员没有定义类的复制构造函数,系统就会在必要时自动生成一个隐含的复制构造函数。这个隐含的复制构造函数的功能是,把初始值对象的每个数据成员的值都复制到新建立的对象中。因此,也可以说是完成了同类对象的复制(clone),这样得到的对象和原对象具有完全相同的数据成员,即完全相同的属性。

  • 下面是声明和实现复制构造函数的一般方法:

class 类名 {
public:
    类名(类名& 源对象);  // 复制构造函数声明
    // ... 其他成员
};

// 复制构造函数实现
类名::类名(类名& 源对象) {
    // 将源对象的数据成员一一赋给当前对象的对应数据成员
}
  • 下面请看一个复制构造函数的例子。通过水平和垂直两个方向的坐标值 XY 来确定屏幕上的一个点。点(Point)类定义如下:
class Point {
public:
    Point(int x = 0, int y = 0) : x(x), y(y) {}  // 构造函数
    Point(Point& p);  // 复制构造函数声明
    void setPoint(int x, int y);
    void showPoint();
private:
    int x, y;
};

// 复制构造函数实现
Point::Point(Point& p) : x(p.x), y(p.y) {
    cout << "复制构造函数被调用" << endl;
}

void Point::setPoint(int x, int y) {
    this->x = x;
    this->y = y;
}

void Point::showPoint() {
    cout << "(" << x << "," << y << ")" << endl;
}
  • 普通构造函数是在对象创建时被调用,而复制构造函数在以下 3 种情况下都会被调用。

  • 当用类的一个对象去初始化该类的另一个对象时。例如:

Point a(1, 2);
Point b(a);  // 调用复制构造函数
Point c = a;  // 调用复制构造函数(与上一行等价)
  • 细节:以上对 bc 的初始化都能够调用复制构造函数,两种写法只是形式上有所不同,执行的操作完全相同。

  • 如果函数的形参是类的对象,调用函数时,进行形参和实参结合时。例如:

void fun(Point p) {  // 形参是类的对象
    p.showPoint();
}

int main() {
    Point a(1, 2);
    fun(a);  // 调用函数时,实参 a 初始化形参 p,调用复制构造函数
    return 0;
}
  • 提示:只有把对象用值传递时,才会调用复制构造函数,如果传递引用,则不会调用复制构造函数。由于这一原因,传递比较大的对象时,传递引用会比传值的效率高很多。

  • 如果函数的返回值是类的对象,函数执行完成返回调用者时。例如:

Point g() {
    Point a(3, 4);
    return a;  // 返回对象时,调用复制构造函数生成临时对象
}

int main() {
    Point b;
    b = g();  // 函数返回的对象初始化临时对象,再赋值给 b
    return 0;
}
  • 为什么在这种情况下,返回函数值时,会调用复制构造函数呢?表面上函数 ga 返回给了主函数,但是 ag() 的局部对象,离开建立它的函数 g 以后就消亡了,不可能在返回主函数后继续生存(这一点在第 5 章中将详细讲解)。所以在处理这种情况时编译系统会在主函数中创建一个无名临时对象,该临时对象的生存期只在函数调用所处的表达式中,也就是表达式 “b = g()” 中。执行语句 “return a;” 时,实际上是调用复制构造函数将 a 的值复制到临时对象中。函数 g 运行结束时对象 a 消失,但临时对象会存在于表达式 “b = g()” 中。计算完这个表达式后,临时对象的使命也就完成了,该临时对象便自动消失。
例 4-2 Point 类的完整程序。
#include <iostream>
using namespace std;

class Point {
public:
    Point(int x = 0, int y = 0) : x(x), y(y) {}  // 构造函数
    Point(Point& p);  // 复制构造函数声明
    int getX() { return x; }
    int getY() { return y; }
private:
    int x, y;
};

// 复制构造函数实现
Point::Point(Point& p) : x(p.x), y(p.y) {
    cout << "复制构造函数被调用" << endl;
}

// 情况2:函数参数为类的对象
void fun1(Point p) {
    cout << "fun1: (" << p.getX() << "," << p.getY() << ")" << endl;
}

// 情况3:函数返回值为类的对象
Point fun2() {
    Point a(3, 4);
    return a;
}

int main() {
    Point a(1, 2);
    // 情况1:用对象 a 初始化对象 b
    Point b(a);
    cout << "b: (" << b.getX() << "," << b.getY() << ")" << endl;

    // 情况2:调用函数 fun1,实参为对象 a
    fun1(a);

    // 情况3:调用函数 fun2,返回值赋给对象 c
    Point c = fun2();
    cout << "c: (" << c.getX() << "," << c.getY() << ")" << endl;

    return 0;
}
  • 程序的运行结果为:
复制构造函数被调用
b: (1,2)
复制构造函数被调用
fun1: (1,2)
复制构造函数被调用
c: (3,4)
  • 注意:在有些编译环境下,上面的运行结果可能不尽相同,因为编译器有时会针对复制构造函数的调用做优化,避免不必要的复制构造函数调用。

  • 读者可能会有这样的疑问:例题中的复制构造函数与隐含的复制构造函数功能一样,都是直接将原对象的数据成员值一一赋给新对象中对应的数据成员。这种情况下还有必要编写复制构造函数吗?的确,如果情况总是这样,就没有必要特意编写一个复制构造函数,用隐含的就行。但是,记得使用复印机时有这样的情况吗:有时只需要某页的一部分,这时可以用白纸遮住不需要的部分再去复印。而且,还有放大复印、缩小复印等多种模式。在程序中进行对象的复制时,也是这样,可以有选择、有变化地复制。读者可以尝试修改一下例题程序,使复制构造函数可以构造一个与初始点有一定位移的新点。另外,当类的数据成员中有指针类型时,默认的复制构造函数实现的只能是浅复制。浅复制会带来数据安全方面的隐患,要实现正确的复制,也就是深复制,必须编写复制构造函数。关于指针类型及深复制问题将在第 6 章介绍。在第 9 章的例题中将进一步体现深复制的作用。

3. 析构函数

  • 如果在一个函数中定义了一个局部对象,那么当这个函数运行结束返回调用者时,函数中的对象也就消失了。

  • 在对象要消失时,通常有什么善后工作需要做呢?最典型的情况是:构造对象时,在构造函数中分配了资源,例如动态申请了一些内存单元,在对象消失时就要释放这些内存单元。

  • 简单来说,析构函数与构造函数的作用几乎正好相反,它用来完成对象被删除前的一些清理工作,也就是专门做扫尾工作的。析构函数是在对象的生存期即将结束的时刻被自动调用的。它的调用完成之后,对象也就消失了,相应的内存空间也被释放。

  • 与构造函数一样,析构函数通常也是类的一个公有函数成员,它的名称是由类名前面加 “~” 构成,没有返回值。和构造函数不同的是析构函数不接收任何参数,但可以是虚函数(将在第 8 章介绍)。如果不进行显式说明,系统也会生成一个函数体为空的隐含析构函数。

  • 提示:函数体为空的析构函数未必不做任何事情,这将在后面的章节中介绍组合与继承时加以说明。

  • 例如,给时钟类加入一个空的内联析构函数,其功能和系统自动生成的隐含析构函数相同。

class Clock {
public:
    // ... 其他成员
    ~Clock() {}  // 内联析构函数
private:
    int hour, minute, second;
};
  • 一般来讲,如果希望程序在对象被删除之前的时刻自动(不需要人为进行函数调用)完成某些事情,就可以把它们写到析构函数中。
例 4-3 游泳池改造预算,Circle 类。
  • 一圆形游泳池如图 4-2 所示,现在需在其周围建一圆形过道,并在其四周围上栅栏。栅栏价格为 35 元 / 米,过道造价为 20 元 / 平方米。过道宽度为 3 米,游泳池半径由键盘输入。要求编程计算并输出过道和栅栏的造价。

  • 首先对问题进行分析,游泳池及栅栏可以看作是两个同心圆,大圆的周长就是栅栏的长度,圆环的面积就是过道的面积,而环的面积是大、小圆的面积之差。可以定义一个圆类来描述这个问题:圆的半径是私有成员数据,圆类应当具有的功能是计算周长和面积。分别用两个对象来表示栅栏和游泳池,就可以得到过道的面积和栅栏的周长。利用已知的单价,便可以得到整个改建工程的预算。下面来看具体的源程序实现。

#include <iostream>
using namespace std;

const float PI = 3.14159f;  // 圆周率
const float FENCE_PRICE = 35;  // 栅栏单价:元/米
const float CONCRETE_PRICE = 20;  // 过道造价:元/平方米

class Circle {  // 圆类
public:
    Circle(float r);  // 构造函数
    ~Circle();  // 析构函数
    float circumference();  // 计算圆的周长
    float area();  // 计算圆的面积
private:
    float radius;  // 半径
};

// 构造函数
Circle::Circle(float r) : radius(r) {}

// 析构函数
Circle::~Circle() {}

// 计算周长
float Circle::circumference() {
    return 2 * PI * radius;
}

// 计算面积
float Circle::area() {
    return PI * radius * radius;
}

int main() {
    float radius;
    cout << "请输入游泳池的半径:";
    cin >> radius;

    Circle pool(radius);  // 游泳池对象
    Circle poolRim(radius + 3);  // 栅栏对象(半径增加3米)

    // 计算栅栏造价并输出
    float fenceCost = poolRim.circumference() * FENCE_PRICE;
    cout << "栅栏造价:" << fenceCost << "元" << endl;

    // 计算过道造价并输出
    float concreteCost = (poolRim.area() - pool.area()) * CONCRETE_PRICE;
    cout << "过道造价:" << concreteCost << "元" << endl;

    return 0;
}
  • 这个程序运行结果为:
请输入游泳池的半径:10
栅栏造价:2858.85元
过道造价:4335.4元
  • 分析:主程序在执行时,首先声明 3 个 float 类型变量,读入游泳池的半径后建立对象 pool,这时调用构造函数将其数据成员设置为读入的数值,接着建立第二个 Circle 类的对象 poolRim 来描述栅栏。通过这两个对象调用各自的成员函数,问题就得到了圆满的解决。

4.4 类的组合

  • 在面向对象程序设计中,可以对复杂对象进行分解、抽象,把一个复杂对象分解为简单对象的组合,由比较容易理解和实现的部件对象装配而成。

1. 组合

  • 请看下面的圆类:
class Circle {
public:
    // ... 成员函数
private:
    float radius;  // 基本类型成员
};
  • 可以看到,类 Circle 中包含着 float 类型的数据。我们已经习惯于像这样用 C++ 的基本数据类型作为类的组成部件。实际上类的成员数据既可以是基本类型也可以是自定义类型,当然也可以是类的对象。由此,便可以采用部件组装的方法,利用已有类的对象来构成新的类。这些部件类比起整体类来,要更易于设计和实现。

  • 类的组合描述的就是一个类内嵌其他类的对象作为成员的情况,它们之间的关系是一种包含与被包含的关系。例如,用一个类来描述计算机系统,首先可以把它分解为硬件和软件,而硬件包含中央处理单元(Central Processing Unit,CPU)、存储器、输入设备和输出设备,软件可以包括系统软件和应用软件。这些部分每一个都可以进一步分解,用类的观点来描述,它就是一个类的组合。图 4-3 给出了它们的相互关系。

  • 当创建类的对象时,如果这个类具有内嵌对象成员,那么各个内嵌对象将首先被自动创建。因为部件对象是复杂对象的一部分,因此,在创建对象时既要对本类的基本类型数据成员进行初始化,又要对内嵌对象成员进行初始化。这时,理解这些对象的构造函数被调用的顺序就很重要。

  • 组合类构造函数定义的一般形式为:

类名::类名(形参表) : 内嵌对象1(形参表1), 内嵌对象2(形参表2), ... {
    // 本类构造函数的其他操作
}
  • 其中,“内嵌对象 1 (形参表 1), 内嵌对象 2 (形参表 2), …” 称做初始化列表,其作用是对内嵌对象进行初始化。

  • 对基本类型的数据成员也可以这样初始化,例如,例 4-3 中 Circle 类的构造函数也可以这样写:

Circle::Circle(float r) : radius(r) {}  // 使用初始化列表初始化基本类型成员
  • 在创建一个组合类的对象时,不仅它自身的构造函数的函数体将被执行,而且还将调用其内嵌对象的构造函数。这时构造函数的调用顺序如下:

  • 调用内嵌对象的构造函数,调用顺序按照内嵌对象在组合类的定义中出现的次序。注意,内嵌对象在构造函数的初始化列表中出现的顺序与内嵌对象构造函数的调用顺序无关。

  • 执行本类构造函数的函数体。

  • 提示:如果有些内嵌对象没有出现在构造函数的初始化列表中,那么在第 (1) 步中,该内嵌对象的默认构造函数将被执行。这样,如果一个类存在内嵌的对象,尽管编译系统自动生成的隐含的默认构造函数的函数体为空,但在执行默认构造函数时,如果声明组合类的对象时没有指定对象的初始值,则默认形式(无形参)的构造函数被调用,这时内嵌对象的默认形式构造函数也会被调用。这时隐含的默认构造函数并非什么也不做。

  • 析构函数的调用执行顺序与构造函数刚好相反。析构函数的函数体被执行完毕后,内嵌对象的析构函数被一一执行,这些内嵌对象的析构函数调用顺序与它们在组合类的定义中出现的次序刚好相反。

  • 提示:由于要调用内嵌对象的析构函数,所以有时隐含的析构函数并非什么也不做。

  • 当存在类的组合关系时,复制构造函数该如何编写呢?对于一个类,如果程序员没有编写复制构造函数,编译系统会在必要时自动生成一个隐含的复制构造函数,这个隐含的函数会自动调用内嵌对象的复制构造函数,为各个内嵌对象初始化。

  • 如果要为组合类编写复制构造函数,则需要为内嵌成员对象的复制构造函数传递参数。例如,假设 C 类中包含 B 类的对象 b 作为成员,C 类的复制构造函数形式如下:

C::C(C& c) : b(c.b) {
    // 其他初始化操作
}
例 4-4 类的组合,线段(Line)类。
  • 我们使用一个类来描述线段,使用 4.3 节中 Point 类的对象来表示端点。这个问题可以用类的组合来解决,使 Line 类包括 Point 类的两个对象 p1p2,作为其数据成员。Line 类具有计算线段长度的功能,在构造函数中实现。源程序如下。
#include <iostream>
#include <cmath>  // 用于 sqrt 函数
using namespace std;

class Point {  // 点类
public:
    Point(int x = 0, int y = 0) : x(x), y(y) {
        cout << "Point 构造函数被调用" << endl;
    }
    Point(Point& p) : x(p.x), y(p.y) {
        cout << "Point 复制构造函数被调用" << endl;
    }
    ~Point() {
        cout << "Point 析构函数被调用" << endl;
    }
    int getX() { return x; }
    int getY() { return y; }
private:
    int x, y;
};

class Line {  // 线段类(组合类)
public:
    // 构造函数,使用初始化列表初始化内嵌对象
    Line(Point xp1, Point xp2) : p1(xp1), p2(xp2) {
        cout << "Line 构造函数被调用" << endl;
        double x = p1.getX() - p2.getX();
        double y = p1.getY() - p2.getY();
        len = sqrt(x * x + y * y);
    }
    // 复制构造函数
    Line(Line& l) : p1(l.p1), p2(l.p2) {
        cout << "Line 复制构造函数被调用" << endl;
        len = l.len;
    }
    ~Line() {
        cout << "Line 析构函数被调用" << endl;
    }
    double getLen() { return len; }
private:
    Point p1, p2;  // 内嵌对象成员
    double len;  // 线段长度
};

int main() {
    Point p1(1, 2), p2(4, 6);  // 创建两个点对象
    Line line(p1, p2);  // 创建线段对象
    Line line2(line);  // 复制构造线段对象
    cout << "线段长度为:" << line.getLen() << endl;
    cout << "线段2长度为:" << line2.getLen() << endl;
    return 0;
}
  • 程序的运行结果为:
Point 构造函数被调用
Point 构造函数被调用
Point 复制构造函数被调用
Point 复制构造函数被调用
Line 构造函数被调用
Point 复制构造函数被调用
Point 复制构造函数被调用
Line 复制构造函数被调用
线段长度为:5
线段2长度为:5
Line 析构函数被调用
Point 析构函数被调用
Point 析构函数被调用
Line 析构函数被调用
Point 析构函数被调用
Point 析构函数被调用
Point 析构函数被调用
Point 析构函数被调用
  • 分析:主程序在执行时,首先生成两个 Point 类的对象,然后构造 Line 类的对象 line,接着通过复制构造函数建立 Line 类的第二个对象 line2,最后输出两点的距离。在整个运行过程中,Point 类的复制构造函数被调用了 6 次,而且都是在 Line 类构造函数进行函数参数形实结合时,初始化内嵌对象时,以及复制构造 line2 时被调用的。两点的距离在 Line 类的构造函数中求得,存放在其私有数据成员 len 中,只能通过公有成员函数 getLen() 来访问。

2. 前向引用声明

  • 我们知道,C++ 的类应当先定义,然后再使用。但是在处理相对复杂的问题、考虑类的组合时,很可能遇到两个类相互引用的情况,这种情况也称为循环依赖。例如:
class A {
public:
    void f(B b);  // A 类中使用 B 类
};

class B {
public:
    void g(A a);  // B 类中使用 A 类
};
  • 这里类 A 的公有成员函数 f 的形式参数是类 B 的对象,同时类 B 的公有成员函数 g 也以类 A 的对象为形参。由于在使用一个类之前,必须首先定义该类,因此无论将哪一个类的定义放在前面,都会引起编译错误。解决这种问题的办法,就是使用前向引用声明。前向引用声明,是在引用未定义的类之前,将该类的名字告诉编译器,使编译器知道那是一个类名。这样,当程序中使用这个类名时,编译器就不会认为是错误,而类的完整定义可以在程序的其他地方。在上述程序加上下面的前向引用声明,问题就解决了。
class B;  // 前向引用声明

class A {
public:
    void f(B b);
};

class B {
public:
    void g(A a);
};
  • 使用前向引用声明虽然可以解决一些问题,但它并不是万能的。需要注意的是,尽管使用了前向引用声明,但是在提供一个完整的类定义之前,不能定义该类的对象,也不能在内联成员函数中使用该类的对象。请看下面的程序段:
class Fred;  // 前向引用声明

class Barney {
    Fred x;  // 错误:不能定义 Fred 类的对象
};

class Fred {
    Barney y;
};
  • 对于这段程序,编译器将指出错误。原因是对类名 Fred 的前向引用声明只能说明 Fred 是一个类名,而不能给出该类的完整定义,因此在类 Barney 中就不能定义类 Fred 的数据成员。因此使两个类以彼此的对象为数据成员,是不合法的。

  • 再看下面这一段程序:

class Fred;  // 前向引用声明

class Barney {
public:
    void method() {
        x->yabbaDabbaDo();  // 错误:不能在内联函数中使用 Fred 类的对象
    }
private:
    Fred* x;
};

class Fred {
public:
    void yabbaDabbaDo();
};
  • 编译器在编译时会指出错误,因为在类 Barney 的内联函数中使用了由 x 所指向的、Fred 类的对象,而此时 Fred 类尚未被完整地定义。解决这个问题的方法是,更改这两个类的定义次序,或者将函数 method() 改为非内联形式,并且在类 Fred 的完整定义之后,再给出函数的定义。

  • 注意:当使用前向引用声明时,只能使用被声明的符号,而不能涉及类的任何细节。

4.5 UML 图形标识

UML(Unified Modeling Language,统一建模语言)是由 OMG(Object Management Group,对象管理组织)于 1997 年确认并开始推行的,是最具代表性、被广泛采用的标准化语言,包含了我们所需要的所有图形标识。

1. UML 简介

  • UML 语言是一种典型的面向对象建模语言,而不是一种编程语言,在 UML 语言中用符号描述概念,概念间的关系描述为连接符号的线。

  • 面向对象建模语言应该追溯于 20 世纪 70 年代中期。20 世纪 80 年代中期,大批面向对象的编程语言问世,标志着面向对象方法走向成熟。到了 20 世纪 90 年代中期,在面向对象方法研究领域出现了一大批面向对象的分析与设计方法,并引入各种独立于语言的标识符。其中,Coad/Yourdon 方法也是最早的面向对象的分析和设计方法之一,这类方法简单易学,适合于面向对象技术的初学者使用。Booch 是面向对象方法最早的倡导者之一,他提出了面向对象软件工程的概念,该方法比较适合于系统的设计和构造。Rumbaugh 等人提出了面向对象的建模技术(OMT)方法,采用面向对象的概念。Jacobson 提出的 OOSE 方法比较适合支持商业工程和需求分析。

  • 在众多的建模语言中,用户很难明确区别不同语言之间的特征,因此很难找到一种比较适合自己应用特点的语言。另外,众多的建模语言各有千秋,尽管大多雷同,但仍存在表述上的差异,极大地妨碍了用户之间的交流。因此在客观上,极有必要寻求一种统一的建模语言。1994 年 10 月,Grady Booch 和 Jim Rumbaugh 开始致力于这方面的工作。他们首先将 Booch 方法和 OMT 方法统一起来,并于 1995 年 10 月发布了这一建模语言的公开版本,称为统一方法 UM 0.8(Unified Method)。1995 年底,OOSE 的创始人 Ivar Jacobson 加盟到这一工作。于是,经过 Booch,Rumbaugh 和 Jacobson 三人的共同努力,于 1996 年 6 月发布了新的版本,即 UML 0.9,并将 UM 重新命名为统一建模语言 UML(Unified Modeling Language)。1997 年 11 月 17 日,OMG 采纳 UML 1.1 作为基于面向对象技术的统一建模语言。到 2003 年 6 月,OMG 通过 UML 的 2.0 版本。

  • 标准建模语言 UML 的重要内容是各种类型的图形,分别描述软件模型的静态结构、动态行为及模块组织和管理。本书主要使用 UML 中的图形来描述软件中类和对象以及它们的静态关系,使用了最基本的类图(class diagram),它属于静态结构图(static structure diagrams)的一种。

2. UML 类图

  • 一个类图是由类和与之相关的各种静态关系共同组成的图形。类图展示的是软件模型的静态结构、类的内部结构以及和其他类的关系。UML 中也定义了对象图(object diagram)。静态对象图是特定对象图的一个实例,它展示的是在某一特定实际软件系统中具体状态的一个特例。类图可以包含对象,一个包含了对象而没有包含类的类图就是对象图,可以认为对象图是类图的一种特例。对象图的使用是相当有限的,因此在 UML 1.5 以后的版本中明确指出工具软件可以不实现这种图。

  • 通过类图,完全能够描述本书中介绍的面向对象的相关概念(如类、模板类等),以及它们的相互关系。类图由描述类或对象的图形标识以及描述它们之间的相互关系的图形标识组成,下面介绍具体的图形符号。

类和对象
  • 类图中最基本的是要图形化描述类,要表示类的名称、数据成员和函数成员,以及各成员的访问控制属性。

  • 在 UML 语言中,用一个由上到下分为 3 段的矩形来表示一个类。类名写在顶部区域,数据成员(数据,UML 中称为属性)在中间区域,函数成员(行为,UML 中称为操作)出现在底部区域。当然,也可以看作是用 3 个矩形上下相叠,分别表示类的名称、属性和操作。而且,除了名称这个部分外,其他两个部分是可选的,即类的属性和操作可以不表示出来,也就是说,一个写了类名的矩形就可以代表一个类。

  • 下面介绍完整表示一个类的数据成员和函数成员的方法。

  • 根据图的详细程度,每个数据成员可以包括其访问控制属性、名称、类型、默认值和约束特性,最简单的情况是只表示出它的名称,其余部分都是可选的。UML 规定数据成员表示的语法为:

[访问控制属性]名称[重数][:类型][=默认值][{约束特征}]
  • 这里必须至少指定数据成员的名称,其他都是可选的。

  • 其中,访问控制属性:可分为 Public,Private 和 Protected 三种,分别对应于 UML 中的 “+”,“-” 和 “#”。

  • 名称:是标识数据成员的字符串。

  • 重数:可以在名称后面的方括号内添加属性的重数(在一些书籍中,也称为多重性。)

  • 类型:表示该数据成员的种类。它可以是基本数据类型,例如整数、实数、布尔型等,也可以是用户自定义的类型,还可以是某一个类。

  • 默认值:是赋予该数据成员的初始值。

  • 约束特征:是用户对该数据成员性质约束的说明。例如 “{只读}” 说明它具有只读属性。

  • 比如Clock 类中,数据成员 hour 描述为:

-hour : int
  • 访问控制属性 “-” 表示它是私有数据成员,其名称为 hour,类型为 int,没有默认值和约束特性。

  • 再如下面的例子,表示某类的一个 Public 类型数据成员,名为 size,类型为 area,其默认值为(100,100)。

+size : area = (100,100)
类之间的关系

UML 类图不仅可以用来描述类的结构,还可以用来表达类之间的多种静态关系。常见的类之间的关系有:

  • 关联(Association):表示类之间的一般联系,例如“学生”和“课程”之间的选课关系。用带箭头的实线表示,箭头指向被关联的类。可以在关联线上标注多重性(如 1、0..* 等),表示一个类的对象与另一个类的对象之间的数量关系。

  • 聚合(Aggregation):表示整体与部分的关系,是一种弱的“拥有”关系。例如“班级”包含“学生”。用带空心菱形的实线表示,菱形指向整体。

  • 组合(Composition):表示强整体与部分的关系,部分不能脱离整体单独存在。例如“房间”属于“房子”,房子消失房间也不存在。用带实心菱形的实线表示,菱形指向整体。

  • 依赖(Dependency):表示一个类依赖于另一个类的定义或实现,通常是临时性的使用关系。例如函数参数或局部变量用到另一个类。用带箭头的虚线表示,箭头指向被依赖的类。

  • 继承(Generalization):表示类之间的继承关系(父类与子类),子类继承父类的属性和操作。用带空心三角箭头的实线表示,三角箭头指向父类。

  • 实现(Realization):用于接口与实现类之间,表示类实现了接口。用带空心三角箭头的虚线表示,三角箭头指向接口。

  • 通过这些关系,UML 类图能够清晰地表达系统中各个类的结构、属性、操作以及它们之间的静态关系,是面向对象分析与设计的重要工具。

UML 类图的作用

UML 类图作为面向对象建模的核心工具,具有以下作用:

  • 直观地展示系统的静态结构,帮助开发者理解和设计系统的类及其关系。
  • 便于团队成员之间的沟通和协作,统一对系统结构的认识。
  • 为后续的编码、测试和维护提供清晰的蓝图。

4.6 结构体和联合体

1. 结构体

  • 结构体是一种特殊形态的类,它和类一样,可以有自己的数据成员和函数成员,可以有自己的构造函数和析构函数,可以控制访问权限,可以继承,支持包含多态等,二者定义的语法形式也几乎一样。结构体和类的唯一区别在于,结构体和类具有不同的默认访问控制属性:在类中,对于未指定访问控制属性的成员,其访问控制属性为私有类型(private);在结构体中,对于未指定任何访问控制属性的成员,其访问控制属性为公有类型(public)。因此,在结构体的定义中,如果把公有成员放在最前面,则最前面的 “public:” 可以省去,结构体可以按照如下的语法形式定义:
struct 结构体名 {
    成员声明;
};
  • 为什么 C++ 还要引入结构体呢?原因是为了保持和 C 程序的兼容性。

  • C 语言只有结构体,而没有类,C 语言的结构体中只允许定义数据成员,不允许定义函数成员,而且 C 语言没有访问控制属性的概念,结构体的全部成员是公有的。C 语言的结构体是为面向过程的程序服务的,并不能满足面向对象程序设计的要求,因此 C++ 为 C 语言的结构体引入了成员函数、访问权限控制、继承、包含多态等面向对象的特性。但由于用 structure(struct 关键字是 structure 的缩写)一词来表示这种具有面向对象特性的抽象数据类型不再贴切,另外 C 语言中 struct 所留下的根深蒂固的影响,C++ 在 struct 之外引入了另外的关键字 ——class,并且把它作为定义抽象数据类型的首选关键字。但为了保持和 C 程序的兼容,C++ 保留了 struct 关键字,并规定结构体的默认访问控制权限为公有类型。

  • 类和结构体的并存,是由历史原因造成的,那么,在编写 C++ 程序时,是否还需要使用结构体呢?这更多地是一个代码风格问题,如果完全不使用结构体,也丝毫不会影响程序的表达能力。

  • 与类不同,对于结构体,人们习惯于将数据成员设置为公共的。有时在程序中需要定义一些数据类型,它们并没有什么操作,定义它们的目的只是将一些不同类型的数据组合成一个整体,从而方便地保存数据,这样的类型不妨定义为结构体。如果用类来定义,为了遵循 “将数据成员设置为私有” 的习惯,需要为每个数据成员编写专门的函数成员来读取和改写各个属性,反而会比较麻烦。

  • 如果一个结构体的全部数据成员都是公共成员,并且没有用户定义的构造函数,没有基类和虚函数,这个结构体的变量可以用下面的语法形式赋初值:

类型名 变量名 = {成员数据1初值, 成员数据2初值, };
  • 在语言规则上,满足以上条件的类对象也可以用同样的方式赋初值,不过由于习惯将类的数据成员设置为私有的,因此类一般不满足以上条件。通过以上形式为结构体变量初始化,是使用结构体的另一个方便之处。
例 4-7 用结构体表示学生的基本信息
#include <iostream>
#include <string>
using namespace std;

struct Student {
    string name;
    int id;
    char gender;
    int age;

    void display() {
        cout << "姓名: " << name << endl;
        cout << "学号: " << id << endl;
        cout << "性别: " << gender << endl;
        cout << "年龄: " << age << endl;
    }
};

int main() {
    Student stu = {"张三", 1001, 'M', 20};
    stu.display();
    return 0;
}
  • 运行结果:
姓名: 张三
学号: 1001
性别: M
年龄: 20

本程序中,Student 结构体中有的 name 成员是 string 类型的,string 是标准 C++ 中预定义的一个类,专用于存放字符串,将在第 6 章详细介绍。

2. 联合体

  • 有时,一组数据中,任何两个数据不会同时有效。例如,如果需要存储一个学生的各门课程成绩,有些课程的成绩是等级制的,需要用一个字符来存储它的等级,有些课程只记 “通过” 和 “不通过”,需要用一个布尔值来表示是否通过,而另一些课程的成绩是百分制的,需要用一个整数来存储它的分数,这个分数就可以用一个联合体来表示。

  • 联合体是一种特殊形态的类,它可以有自己的数据成员和函数成员,可以有自己的构造函数和析构函数,可以控制访问权限。与结构体一样,联合体也是从 C 语言继承而来的,因此它的默认访问控制属性也是公共类型的。联合体的全部数据成员共享同一组内存单元。联合体定义的语法形式如下:

union 联合体名 {
    成员声明;
};
  • 例如,成绩这个联合体可以声明如下:
union Mark {
    char grade;     // 等级制成绩
    bool passed;    // 通过与否
    int score;      // 百分制成绩
};
  • 正是由于联合体的成员共用相同的内存单元,联合体变量中的成员同时至多只有一个是有意义的。另外,不同数据单元共用相同内存单元的特性,联合体有下面一些限制:

  • 联合体的各个对象成员,不能有自定义的构造函数、自定义的析构函数和重载的复制赋值运算符(复制赋值运算符的重载将在第 8 章介绍),不仅联合体的对象成员不能有这些函数,这些对象成员的对象成员也不能有,以此类推。

  • 联合体不能继承,因而也不支持包含多态。

  • 一般只用联合体来存储一些公有的数据,而不为它定义函数成员。

  • 联合体也可以不声明名称,称为无名联合体。无名联合体没有标记名,只是声明一个成员项的集合,这些成员项具有相同的内存地址,可以由成员项的名字直接访问。

  • 例如,声明无名联合体如下:

struct Test {
    int a;
    union {
        char c;
        short s;
    };
};
  • 在程序中可以这样使用:
Test t;
t.a = 100;
t.c = 'a';  // 此时t.a的值也会被改变
  • 无名联合体通常用作类或结构体的内嵌成员,请看例 4-8。
例 4-8 使用联合体保存成绩信息,并且输出
#include <iostream>
using namespace std;

struct Student {
    int id;
    string name;
    union {
        char grade;     // 等级制成绩
        bool passed;    // 通过与否
        int score;      // 百分制成绩
    };
    char courseType;  // 课程类型:'A'等级制,'B'通过与否,'C'百分制

    void setGrade(char g) {
        courseType = 'A';
        grade = g;
    }

    void setPassed(bool p) {
        courseType = 'B';
        passed = p;
    }

    void setScore(int s) {
        courseType = 'C';
        score = s;
    }

    void display() {
        cout << "学号: " << id << ", 姓名: " << name << endl;
        cout << "课程成绩: ";
        if (courseType == 'A') {
            cout << "等级 " << grade << endl;
        } else if (courseType == 'B') {
            cout << (passed ? "通过" : "不通过") << endl;
        } else if (courseType == 'C') {
            cout << "分数 " << score << endl;
        }
    }
};

int main() {
    Student stu;
    stu.id = 1002;
    stu.name = "李四";

    stu.setScore(85);
    stu.display();

    stu.setGrade('B');
    stu.display();

    stu.setPassed(true);
    stu.display();

    return 0;
}

4.7 综合实例

1. 类的设计

  • 一个人可以有多个活期储蓄账户,一个活期储蓄账户包括账号(id)、余额(balance)、年利率(rate)等信息,还包括显示账户信息(show)、存款(deposit)、取款(withdraw)、结算利息(settle)等操作。为此,设计一个类 SavingsAccount,将 id,balance,rate 均作为其成员数据,将 show,deposit,withdraw,settle 均作为其成员函数。类图设计如图 4-13 所示。

  • 无论是存款、取款还是结算利息,都需要修改当前的余额并且将余额的变动输出,这些公共操作由私有成员函数 record 来执行。

  • 实现该类的难点在于利息的计算。由于账户的余额是不断变化的,因此不能通过余额与年利率相乘的办法来计算年利,而是需要将一年当中每天的余额累积起来再除以一年的总天数,得到一个日均余额,再乘以年利率。为了计算余额的按日累积值,SavingsAccount 引入了私有数据成员 lastDate,accumulation 和私有成员函数 accumulate。lastDate 用来存储上一次余额变动的日期,accumulation 用来存储上次计算利息以后直到最近一次余额变动时余额按日累加的值,accumulate 成员函数用来计算截至指定日期的账户余额按日累积值。这样,当余额变动时,需要做的是将变动前的余额与该余额所持续的天数相乘,累加到 accumulation 中,再修改 lastDate。

  • 为简便起见,该类中的所有日期均用一个整数来表示,该整数是一个以日为单位的相对日期,例如如果以开户日为 1,那么开户日后的第 3 天就用 4 来表示,这样通过将两个日期相减就可得到两个日期相差的天数,在计算利息时非常方便。

2. 源程序及说明

例 4-9 个人银行账户管理程序
#include <iostream>
#include <iomanip>
#include <string>
using namespace std;

class SavingsAccount {
private:
    string id;         // 账号
    double balance;    // 余额
    double rate;       // 年利率
    int lastDate;      // 上次变更余额的日期
    double accumulation;  // 余额按日累加值

    // 记录一笔账,date为日期,amount为金额,desc为说明
    void record(int date, double amount, const string& desc) {
        accumulation = accumulate(date);
        lastDate = date;
        amount = floor(amount * 100 + 0.5) / 100; // 保留两位小数
        balance += amount;
        cout << date << "\t#" << id << "\t" << desc << "\t"
             << amount << "\t" << balance << endl;
    }

    // 计算从上次记账日到指定日期的余额按日累加值
    double accumulate(int date) const {
        return accumulation + balance * (date - lastDate);
    }

public:
    // 构造函数
    SavingsAccount(const string& id, double rate)
        : id(id), balance(0), rate(rate), lastDate(1), accumulation(0) {
        cout << "开户成功,账号:" << id << ",初始余额:0" << endl;
    }

    // 存款
    void deposit(int date, double amount, const string& desc) {
        record(date, amount, "存款: " + desc);
    }

    // 取款
    bool withdraw(int date, double amount, const string& desc) {
        if (amount > balance) {
            cout << date << "\t#" << id << "\t取款失败: " << desc
                 << "\t" << amount << "\t余额不足" << endl;
            return false;
        }
        record(date, -amount, "取款: " + desc);
        return true;
    }

    // 结算利息,每年1月1日调用
    void settle(int date) {
        double interest = accumulate(date) * rate / 365;
        if (interest != 0) {
            record(date, interest, "结算利息");
        }
        accumulation = 0;
    }

    // 显示账户信息
    void show() const {
        cout << "账号:" << id << ",余额:" << balance
             << ",年利率:" << rate * 100 << "%" << endl;
    }
};

int main() {
    // 创建两个账户
    SavingsAccount sa0("1001", 0.015);
    SavingsAccount sa1("1002", 0.015);

    // 存入操作
    sa0.deposit(5, 5000, "工资");
    sa0.deposit(45, 5500, "奖金");
    sa1.deposit(25, 10000, "稿费");

    // 取出操作
    sa1.withdraw(60, 4000, "买书");

    // 结算利息(假设每年1月1日结算)
    sa0.settle(90);
    sa1.settle(90);

    // 显示账户信息
    sa0.show();
    sa1.show();

    return 0;
}
  • 运行结果:
开户成功,账号:1001,初始余额:0
开户成功,账号:1002,初始余额:0
5    #1001    存款: 工资    5000    5000
45   #1001    存款: 奖金    5500    10500
25   #1002    存款: 稿费    10000    10000
60   #1002    取款: 买书    4000    6000
90   #1001    结算利息    27.64    10527.64
90   #1002    结算利息    21.78    6021.78
账号:1001,余额:10527.64,年利率:1.5%
账号:1002,余额:6021.78,年利率:1.5%
  • 在上面程序中,首先给出了 SavingsAccount 类的定义,只有几个简短的函数实现写在了类定义中,大部分函数的实现代码写在了类定义后。在主程序中,定义了两个账户实例 sa0 和 sa1,它们的年利率都是 1.5%,随后分别在第 5 天和第 45 天向账户 sa0 存入 5000 元和 5500 元,在第 25 天向账户 sa1 存入 10000 元,在第 60 天从账户 sa1 取出 4000 元。账户开户后的第 90 天是银行的计息日,两个账户分别得到了 27.64 元和 21.78 元的利息。以账户 sa0 为例,它在第 5~45 天之间的余额为 5000 元,第 45~90 天之间的余额为 10500 元,因此它的利息是(40×5000 + 45×10500)/365×1.5% = 27.64 元。

  • 以上程序的 SavingsAccount::record 函数中使用了floor 函数,该函数是向下取整函数(在数学上称为高斯函数),用来得到不大于一个数的最大整数,声明在头文件 cmath 中。一般来说,如果需要对一个数 x 做四舍五入取整,可以通过表达式 floor (x + 0.5) 进行。而 record 函数中的表达式将原数事先乘以 100,取整完毕后再除以 100,因此原数的小数点后两位得以保留。另外,cmath 中还提供了 floor 的姊妹函数 ——ceil,该函数为向上取整函数,用来得到不小于一个数的最小整数。

  • 在开户(构造账户)、存款、取款、计息的过程中,每一笔记录都被输出出来了。最后分别输出两个账户的信息。

4.8 深度探索

1. 位域

  • 各种基本数据类型中,长度最小的 char 和 bool 在内存中占据 1 个字节的空间,但对于某些数据只需要几个二进制位即可保存,例如例 2-11 中所定义的枚举:
enum GameResult {WIN, LOSE, TIE, CANCEL};
  • 由于它只有 4 种取值,只需 2 个二进制位就可保存,而一个 GameResult 类型变量至少要占据 1 个字节(8 个二进制位),在很多编译器中,甚至还会占据更多的空间。单一变量所浪费的空间或许并不显著,但如果一个类中有多个这样的数据成员,那么它们所浪费的空间累积起来会更大。一种可以想到的解决办法是,将类中多个这样的数据成员 “打包”,让它们不必从整字节开始,而是可以只占据某些字节的某几位。为了解决这一问题,C++ 允许在类中声明位域。

  • 位域是一种允许将类中的多个数据成员打包,从而使不同成员可以共享相同的字节的机制。在类定义中,位域的定义方式为:

数据类型说明符 成员名 : 位数;
  • 程序员可以通过冒号(:)后的位数来指定为一个位域所占用的二进制位数。使用位域,有以下几点需要注意:

  • C++ 标准规定了使用这种机制用来允许编译器将不同的位域 “打包”,但这种 “打包” 的具体方式,C++ 标准并没有规定,因此不同的编译器会有不同的处理方式,不同编译器下,包含位域的类所占用的空间也会有所不同。

  • 只有 bool(布尔型)、char(字符型)、int(整型)和 enum(枚举型)的成员才能够被定义为位域。

  • 位域虽然节省了内存空间,但由于打包和解包的过程中需要耗费额外的操作,所以运行时间很有可能会增加。

  • 结构体与类的唯一区别在于访问权限,因此也允许定义位域;但联合体中,各个成员本身就共用相同的内存单元,因此没必要也不允许定义位域。

例 4-10 设计一个结构体存储学生的成绩信息。
  • 需要包括学号、年级和成绩 3 项内容,学号的范围是 0~99999999,年级分为 freshman,sophomore,junior,senior 四种,成绩包括 A,B,C,D 四个等级。

  • 分析:学号包括 27 个二进制位(2^27 = 134217728)的有效信息,而年级、成绩各包括 2 个二进制位的有效信息。如果用整型存储学号(占用 4 字节),分别用枚举类型存储年级和等级(各至少占用 1 字节),则总共至少占用 6 字节。如果采用位域,则需要 27 + 2 + 2 = 31 个二进制位,只需 4 个字节就能存下。

#include <iostream>
using namespace std;

struct Student {
    unsigned int id : 27;     // 学号,27位
    unsigned int grade : 2;   // 年级,2位(0-3)
    unsigned int score : 2;   // 成绩,2位(0-3)
};

int main() {
    Student s;
    s.id = 12345678;
    s.grade = 1;  // sophomore
    s.score = 2;  // C

    cout << "学号: " << s.id << endl;
    cout << "年级: " << s.grade << endl;
    cout << "成绩: " << s.score << endl;
    cout << "结构体大小: " << sizeof(Student) << " 字节" << endl;

    return 0;
}
  • 运行结果(分别使用 VC++.NET 2005 和 GNU C++ 4.2 编译,都可以得到以下结果,但在有些编译器下,运行结果的第一行可能会有所不同):
学号: 12345678
年级: 1
成绩: 2
结构体大小: 4 字节

2. 用构造函数定义类型转换

  • 当一个函数的返回类型为类类型时,函数调用返回后,一个无名的临时对象会被创建,这种创建不是由用户显式指定的,而是隐含发生的。事实上,临时对象也可以显式创建,方法是直接使用类名调用这个类的构造函数。例如,如果希望使用例 4-4 中定义的 Point 和 Line 两个类计算一个线段的长度,可以不创建有名的点对象和线段对象,而使用这种方式:
double length = Line(Point(1), Point(4)).getLength();
  • 这里以参数 1(以及一个默认的参数 0)调用 Point 的构造函数创建一个 Point 的临时对象,又以参数 4(以及一个默认的参数 0)调用 Point 的构造函数创建另一个 Point 的临时对象,然后以这两个 Point 的临时对象为参数调用 Line 的构造函数,创建一个 Line 的临时对象,最后以这个临时对象为目的对象,调用 Line 类的 getLine()函数,得到线段长度。

  • 注意临时对象的生存期很短,在它所在的表达式被执行完后,就会被销毁。

  • C++ 中可以通过构造函数,来自定义类型之间的转换。一个构造函数,只要可以用一个参数调用,那么它就设定了一种从参数类型到这个类类型的类型转换。由于是类型转换,所以上面一行代码,还可以写成下面两种等效形式:

double length = Line(static_cast<Point>(1), static_cast<Point>(4)).getLength();
double length = Line((Point)1, (Point)4).getLength();
double length = Line(Point(1), Point(4)).getLength();
  • 这里的类型转换操作符可以省去,因为默认情况下,类的构造函数所规定的类型转换,允许通过隐含类型转换进行。也就是说,可以写成这种形式:
double length = Line(1, 4).getLength();
  • 无论把类型转换写成哪种形式,在程序执行时,都会通过调用 Point 类的构造函数来建立 Point 类的临时对象。类型转换的结果就是这个临时对象。
只允许显式执行的类型转换
  • 然而,有时并不希望这种类型转换隐含地发生,例如,上面的写法 Line (1,4) 中,把 1 和 4 作为 Line 构造函数的两个参数的含义很不明确。如果调用 Line 构造函数时传递了类型错误的参数,而自动发生的隐含转换却会使编译系统无法将错误报告出来。因此,C++ 允许避免这种隐含转换的发生。只要在构造函数前加上 explicit 关键字,以这个构造函数定义的类型转换,只能通过显式转换的方式完成。就像这样:
class Point {
public:
    explicit Point(int x = 0, int y = 0) : x(x), y(y) {}
    // ...
};
  • 如果函数的实现与函数在类定义中的声明是分离的,那么 explicit 关键字应当写在类定义中的函数原型声明处,而不能写在类定义外的函数实现处 —— 因为 explicit 是用来约束这个构造函数被调用的方式的,属于一个类的对外接口的一部分,而是否加 explicit 关键字,与函数实现代码的生成无关。

  • 如果为 Point 的构造函数添加了 explicit 关键字,那么下面的语句就是非法的了:

Line line(1, 4); // 错误,不能隐含转换
  • 但上面的另外几种显式类型转换的写法都是合法的。

  • 如果一个构造函数可以只用一个参数调用,并且由此定义的类型转换没有明确的意义,那么应当对这个构造函数使用 explicit 关键字,避免类型转换被误用。

3. 对象作为函数参数和返回值的传递方式

  • 在函数调用时,把对象作为参数传递,需要调用复制构造函数,但这些工作具体是如何做的呢?3.6 节中曾经介绍了基本类型数据在函数调用中的传递方式,其实把它和复制构造函数的调用结合起来思考,传递对象参数的问题就不难理解了。

  • 函数调用时传递基本类型的数据是通过运行栈,传递对象也一样是通过运行栈。运行栈中,在主调函数和被调函数之间,有一块二者都要访问的公共区域,主调函数把实参值写入其中,函数调用发生后,被调函数通过读取这段区域就可得到形参值。需要传递的对象,只要建立在运行栈的这段区域上即可。传递基本类型数据与传递对象的不同之处在于,将实参值复制到这段区域上时,对于基本数据类型的参数,做一般的内存写操作即可,但对于对象参数,则需要调用复制构造函数。例如,例 4-2 之中,在 main 函数中调用下面这个函数:

void fun1(Point p) {
    // ...
}
  • 调用它时,就需要调用 Point 的复制构造函数,使用对象 b 在运行栈的传参区域上构造一个临时对象。这个对象在 main 中无名,但却在被调函数 fun1 中有名(就是 fun1 函数的参数 p)。在 main 中虽然无名,但地址却可以计算,因此编译器能够生成代码调用 Point 的复制构造函数,为这个对象初始化。对象参数的复制构造函数的调用在跳转到 fun1 函数的入口地址之前完成。

  • 有时传递对象参数时,编译器会做出适当优化,使得复制构造函数不必被调用。例如,使用 Point 型的临时对象作为 fun1 函数的参数,对它进行调用:

fun1(Point(1, 2));
  • 最直接的做法是,先构造一个 Point 类型的临时对象,再以这个对象为参数调用复制构造函数,在运行栈的传参区域上生成一个临时对象,再执行 fun1 函数的代码。但是,构造两个临时对象有一点多余,更好的做法是,直接使用 Point 类的构造函数,在运行栈的传参区域上建立临时对象,这样就免去了一次复制构造函数的调用。

  • 如果在传参时发生由构造函数所定义的类型转换,复制构造函数的调用同样可以避免。例如,如果使用下面的代码调用 f 函数:

fun1(1);
  • fun1 函数接收 Point 类型的参数,因此这需要执行从 int 型到 Point 型的隐含类型转换,而类型转换的本质是调用 Point 的构造函数来创建临时对象。由于该临时对象同样可以直接建立在运行栈的传参区域上,因此也无须再调用一次复制构造函数。

  • 下面探讨返回一个对象时,返回值的传递方式。当传递返回值时,需要创建无名的临时对象,但是这个对象具体的创建过程是怎样的呢?由于主调函数需要获得返回值,所以这个临时对象需要创建在主调函数的栈帧上,那么被调函数如何影响主调函数所创建的临时对象的值呢?

  • 有些比较老的编译器的实现办法是,将这个临时对象也创建在运行栈的传参区域上,主调函数在调用被调函数时,在运行栈上留出一段区域,被调函数可以在这段区域上创建返回的对象,返回后可以供主调函数读取。这固然是一种可行的办法,但由于这时创建临时对象的位置相对于栈指针必须是固定的,不利于有些优化(在后面将看到这一点),因此如今的大部分编译器没有采用这种方式。现在比较通行的处理方式是,由主调函数决定临时对象的创建位置,然后把临时对象的地址作为参数传递给被调函数。

  • 以例 4-2 中的 fun2 函数为例:

Point fun2() {
    Point p(3, 4);
    return p;
}
  • 为保存返回值所生成的临时对象,它的空间分配和构造函数执行这两步是分开的:空间分配在主调函数中进行,构造函数在被调函数中执行。4.3.2 小节提到的返回时对复制构造函数的调用,指的就是调用复制构造函数为这个临时对象初始化,但有时这个复制构造函数的调用也是可以省去的,例如,如果把 fun2 改写为:
Point fun2() {
    return Point(3, 4);
}
  • 最直接的实现方式是,先调用 Point 的构造函数 Point (int, int) 生成一个 fun2 内的临时对象,再以这个临时对象为参数调用 Point 的复制构造函数,生成返回值,这两步可以简化为一步,即用构造函数 Point (int, int) 直接构造出返回值,就是下面这样的形式:
return Point(3, 4);
  • 另一方面,在主调函数中,也未必一定要为返回值生成临时对象。例如,如果主调函数是这样调用 fun2 的:
Point p = fun2();
  • 这时不必为返回值生成临时对象,而可以直接用对象 p 的空间存储返回值,就是这样:
Point p;
// 被调函数直接在p的空间中构造Point(3,4)
  • 这能够省去一次复制构造函数的调用。前面提到过,如果把表示返回值的临时对象放在运行栈的传参区域上,不利于有些优化,指的就是这一项优化。按照那种实现方式,返回值的存放位置不由主调函数决定,也就无法直接将返回值存在对象 p 的空间中。

  • 通过本节的分析,应当能够看出,复制构造函数的调用次数,会因编译器的优化程度而有所差异,因此复制构造函数中一定要只完成对象复制的任务,而不要有其他能产生副作用的操作,否则程序运行结果会因编译器的优化程度而有所差异。

很抱歉,我无法直接生成 Markdown 格式的文件,但我可以为你提供第五章内容的文本,你可以根据需要将其复制到 Markdown 编辑器中。以下是第五章的数据的共享与保护的内容:

5. 数据的共享与保护

  • C++ 是适合于编写大型复杂程序的语言,数据的共享与保护机制是 C++ 的重要特性之一。本章介绍标识符的作用域、可见性和生存期的概念,以及类成员的共享与保护问题。最后介绍程序的多文件结构和编译预处理命令,即如何用多个源代码文件来组织大型程序。

5.1 标识符的作用域与可见性

  • 作用域讨论的是标识符的有效范围,可见性讨论的是标识符是否可以被引用。我们知道,在某个函数中声明的变量就只能在这个函数中起作用,这就是受变量的作用域与可见性的限制。作用域与可见性既相互联系又存在着很大差异。

1. 作用域

  • 作用域是一个标识符在程序正文中有效的区域。C++ 中标识符的作用域有函数原型作用域、局部作用域(块作用域)、类作用域和命名空间作用域。
函数原型作用域
  • 函数原型作用域是 C++ 程序中最小的作用域。在函数原型中一定要包含形参的类型说明。在函数原型声明时形式参数的作用范围就是函数原型作用域。例如,有如下函数声明:
void area(double radius);
  • 标识符 radius 的作用(或称有效)范围就在函数 area 形参列表的左右括号之间,在程序的其他地方不能引用这个标识符。因此标识符 radius 的作用域称做函数原型作用域。

  • 注意由于在函数原型的形参列表中起作用的只是形参类型,标识符并不起作用,因此是允许省去的。但考虑到程序的可读性,通常还是要在函数原型声明时给出形参标识符。

局部作用域
void fun(double a) {
    double b = a;
    if (a > 0) {
        double c = a + 1;
        // ...
    }
    // ...
}
  • 这里,在函数 fun 的形参列表中声明了形参 a,在函数体内声明了变量 b,并用 a 的值初始化 b。接下来,在 if 语句内,又声明了变量 cabc 都具有局部作用域,只是它们分别属于不同的局部作用域。

  • 函数形参列表中形参的作用域,从形参列表中的声明处开始,到整个函数体结束之处为止。因此,形参 a 的作用域从 a 的声明处开始,直到 fun 函数的结束处为止。函数体内声明的变量,其作用域从声明处开始,一直到声明所在的块结束的大括号为止。所谓块,就是一对大括号括起来的一段程序。在这个例子中,函数体是一个块,if 语句之后的分支体又是一个较小的块,二者是包含关系。因此,变量 b 的作用域从声明处开始,到它所在的块(即整个函数体)结束处为止,而变量 c 的作用域从声明处开始,到它所在的块,即分支体结束为止。具有局部作用域的变量也称为局部变量。

类作用域

类可以被看成是一组有名成员的集合,类 X 的成员 m 具有类作用域,对 m 的访问方式有如下 3 种。

  • 如果在 X 的成员函数中没有声明同名的局部作用域标识符,那么在该函数内可以直接访问成员 m。也就是说 m 在这样的函数中都起作用。
  • 通过表达式 x.m 或者 X::m 。这正是程序中访问对象成员的最基本方法。X::m 的方式用于访问类的静态成员,相关内容将在 5.3 节介绍。
  • 通过 ptr->m 这样的表达式,其中 ptr 为指向 X 类的一个对象的指针。关于指针将在第 6 章详细介绍。

C++ 中,类及其对象还有其他特殊的访问和作用域规则,在后续章节中还会深入讨论。

命名空间作用域
  • 为了介绍命名空间作用域,首先需要介绍命名空间的概念。一个大型的程序通常由不同模块构成,不同的模块甚至有可能是由不同人员开发的。不同模块中的类和函数之间有可能发生重名,这样就会引发错误。这就好像上海和武汉都有南京路,如果在缺乏上下文的情况下直接说出 “南京路”,就会产生歧义。但如果说 “上海的南京路” 或 “武汉的南京路”,歧义就会消除。命名空间起到的就是这样的作用。

  • 命名空间的语法形式如下:

namespace 命名空间名 {
    // 命名空间的成员声明和定义
}
  • 一个命名空间确定了一个命名空间作用域,凡是在该命名空间之内声明的、不属于前面所述各个作用域的标识符,都属于该命名空间作用域。在命名空间内部可以直接引用当前命名空间中声明的标识符,如果需要引用其他命名空间的标识符,需要使用下面的语法:
命名空间名::标识符
  • 有时,在标识符前总使用这样的命名空间限定会显得过于冗长,为了解决这一问题, C++ 又提供了 using 语句,using 语句有两种形式:

  • using 命名空间名::标识符;

  • using namespace 命名空间名;

  • 前一种形式将指定的标识符暴露在当前的作用域内,使得在当前作用域中可以直接引用该标识符;后一种形式将指定命名空间内的所有标识符暴露在当前的作用域内,使得在当前作用域中可以直接引用该命名空间内的任何标识符。

  • 事实上,C++ 标准程序库的所有标识符都被声明在 std 命名空间内,前面用到的 cincoutendl 等标识符皆如此,因此,前面的程序中都使用了 using namespace std。如果去掉这条语句,则引用相应的标识符需要使用 std::cinstd::coutstd::endl 这样的语法。

  • 命名空间也允许嵌套,例如:

namespace OuterNs {
    namespace InnerNs {
        // ...
    }
}
  • 引用其中的 SomeClass 类,需要使用 OuterNs::InnerNs::SomeClass 的语法形式。

  • 此外,还有两类特殊的命名空间 —— 全局命名空间和匿名命名空间。全局命名空间是默认的命名空间,在显式声明的命名空间之外声明的标识符都在一个全局命名空间中。匿名命名空间是一个需要显式声明的没有名字的命名空间,声明方式如下:

namespace {
    // 匿名命名空间的成员声明和定义
}
  • 在包含多个源文件的工程中,匿名命名空间常常被用来屏蔽不希望暴露给其他源文件的标识符,这是因为每个源文件的匿名命名空间是彼此不同的,在一个源文件中没有办法访问其他源文件的匿名命名空间。
例 5 - 1 作用域实例
#include <iostream>
using namespace std;

int i = 5;

namespace Ns {
    int j = 10;
}

int main() {
    int i = 7;
    cout << i << endl; // 输出 7

    {
        int i = 9;
        cout << i << endl; // 输出 9
    }

    cout << ::i << endl; // 输出 5

    cout << Ns::j << endl; // 输出 10

    {
        using namespace Ns;
        cout << j << endl; // 输出 10
    }

    return 0;
}

运行结果:

7
9
5
10
10
  • 在这个例子中,变量 i 具有命名空间作用域,它属于全局命名空间,其有效作用范围到文件尾才结束。在主函数开始处给这个具有命名空间作用域的变量赋初值 5,接下来在子块 l 中又声明了同名变量并赋初值 7。第一次输出的结果是 7,这是因为具有局部作用域的变量 i 把具有命名空间作用域的变量 i 隐藏了,具有命名空间作用域的变量 i 变得不可见(这是下面要讨论的可见性问题)。当程序运行到块 l 结束后,进行第二次输出时,输出的就是具有命名空间作用域的变量 i 的值 5。变量 j 也具有命名空间作用域,它被声明在命名空间 Ns 中,在主函数中通过 Ns::j 的方式引用,为其赋值,接下来在块 1 中,通过 using namespace Ns 使得该命名空间的标识符可以在该块中被直接引用,因此输出 j 时可以直接使用标识符 j

  • 具有命名空间作用域的变量也称为全局变量。

2. 可见性

  • 下面,从标识符引用的角度,来看标识符的有效范围,即标识符的可见性。程序运行到某一点,能够引用到的标识符,就是该处可见的标识符。为了理解可见性,先来看一看不同作用域之间的关系。命名空间作用域最大,接下来依次是类作用域和局部作用域。图 5 - 1 描述了作用域的一般关系。可见性表示从内层作用域向外层作用域 “看” 时能看到什么。因此,可见性和作用域之间有着密切的关系。

作用域可见性的一般规则如下:

  • 标识符要声明在前,引用在后。
  • 在同一作用域中,不能声明同名的标识符。
  • 在没有互相包含关系的不同的作用域中声明的同名标识符,互不影响。
  • 如果在两个或多个具有包含关系的作用域中声明了同名标识符,则外层标识符在内层不可见。

再看一下例 5 - 1,这是命名空间作用域与局部作用域相互包含的实例,在主函数内块 l 之外,可以引用具有命名空间作用域的变量,也就是说它是可见的。当程序运行进入块 1 后,就只能引用具有局部作用域的同名变量,具有命名空间作用域的同名变量被隐藏了。

  • 作用域和可见性的原则不只适用于变量名,也适用于其他各种标识符,包括常量名、用户定义的类型名、函数名、枚举类型的取值等。

5.2 对象的生存期

  • 对象(包括简单变量)都有诞生和消失的时刻。对象从诞生到结束的这段时间就是它的生存期。在生存期内,对象将保持它的状态(即数据成员的值),变量也将保持它的值不变,直到它们被更新为止。本节,使用对象来统一表示类的对象和一般的变量。对象的生存期可以分为静态生存期和动态生存期两种。

1. 静态生存期

  • 如果对象的生存期与程序的运行期相同,则称它具有静态生存期。在命名空间作用域中声明的对象都是具有静态生存期的。如果要在函数内部的局部作用域中声明具有静态生存期的对象,则要使用关键字 static。例如下列语句定义的变量 i 便是具有静态生存期的变量,也称为静态变量:
void fun() {
    static int i = 0;
    // ...
}
  • 局部作用域中静态变量的特点是,它并不会随着每次函数调用而产生一个副本,也不会随着函数返回而失效。也就是说,当一个函数返回后,下一次再调用时,该变量还会保持上一回的值,即使发生了递归调用,也不会为该变量建立新的副本,该变量会在每次调用间共享。

  • 在定义静态变量的同时也可以为它赋初值,例如:

static int i = 5;
  • 这表示 i 会被赋予 5 初始化,而非每次执行函数时都将 i 赋值为 5。

  • 类的数据成员也可以用 static 修饰,5.3 节将专门讨论类的静态成员。

  • 细节定义时未指定初值的基本类型静态生存期变量,会被赋予 0 值初始化,而对于动态生存期变量,不指定初值意味着初值不确定。

2. 动态生存期

  • 除了上述两种情况,其余的对象都具有动态生存期。在局部作用域中声明的具有动态生存期的对象,习惯上也称为局部生存期对象。局部生存期对象诞生于声明点,结束于声明所在的块执行完毕之时。

  • 类的成员对象也有各自的生存期。不用 static 修饰的成员对象,其生存期都与它们所属对象的生存期保持一致。

例 5 - 2 变量的生存期与可见性
#include <iostream>
using namespace std;

int i = 10;

void fun() {
    int j = 20;
    {
        int k = 30;
        cout << k << endl; // 输出 30
    }
    cout << j << endl; // 输出 20
    // cout << k << endl; // 错误,k 不可见
}

int main() {
    cout << i << endl; // 输出 10
    fun();
    return 0;
}
例 5 - 3 具有静态和动态生存期对象的时钟程序
#include <iostream>
using namespace std;

class Clock {
private:
    int hour;
    int minute;
    int second;
public:
    Clock(int h = 0, int m = 0, int s = 0) : hour(h), minute(m), second(s) {}
    void showTime() {
        cout << hour << ":" << minute << ":" << second << endl;
    }
};

int main() {
    Clock globClock(12, 0, 0); // 具有静态生存期的全局对象

    {
        Clock myClock(10, 30, 0); // 具有动态生存期的局部对象
        myClock.showTime();
    }

    globClock.showTime();
    return 0;
}

程序的运行结果为:

10:30:0
12:0:0
  • 在这个程序中,包含了具有各种作用域类型的变量和对象。其中时钟类定义中函数成员 setTime 的 3 个形参具有函数原型作用域;setTime 函数定义中的 3 个参数、对象 myClock 具有局部作用域;时钟类的数据、函数成员具有类作用域;对象 globClock 具有命名空间作用域。在主函数中,这些变量、对象及对象的公有成员都是可见的。就生存期而言,除了具有命名空间作用域的对象 globClock 具有静态生存期,与程序的运行期相同之外,其余都具有动态生存期。

5.3 类的静态成员

  • 在结构化程序设计中程序模块的基本单位是函数,因此模块间对内存中数据的共享是通过函数与函数之间的数据共享来实现的,其中包括两个途径 —— 参数传递和全局变量。

  • 面向对象的程序设计方法兼顾数据的共享与保护,将数据与操作数据的函数封装在一起,构成集成度更高的模块。类中的数据成员可以被同一类中的任何一个函数访问。这样一方面在类内部的函数之间实现了数据的共享,另一方面这种共享是受限制的,可以设置适当的访问控制属性。把共享只限制在类的范围之内,对类外来说,类的数据成员仍是隐藏的,达到了共享与隐藏两全。

  • 然而这些还不是数据共享的全部。对象与对象之间也需要共享数据。

  • 静态成员是解决同一个类的不同对象之间数据和函数共享问题的。例如,可以抽象出某公司全体雇员的共性,设计如下雇员类:

class Employee {
private:
    int empNo;
    string id;
    string name;
public:
    Employee(int eno, string id, string name) : empNo(eno), id(id), name(name) {}
    // ...
};
  • 如果需要统计雇员总数,这个数据存放在什么地方呢?若以类外的变量来存储总数,不能实现数据的隐藏。若在类中增加一个数据成员用于存放总数,必然在每一个对象中都存储一个副本,不仅冗余,而且每个对象分别维护一个 “总数”,容易造成数据的不一致性。由于这个数据应该是为 Employee 类的所有对象所共有,不属于任何一个具体对象,因此比较理想的方案是类的所有对象共同拥有一个用于存放总数的数据成员,这就是下面要介绍的静态数据成员。

1. 静态数据成员

  • 我们说 “一个类的所有对象具有相同的属性”,是指属性的个数、名称、数据类型相同,各个对象的属性值则可以各不相同,这样的属性在面向对象方法中称为 “实例属性”,在 C++ 程序中以类的非静态数据成员表示。例如上述 Employee 类中的 empNoidname 都是以非静态数据成员表示的实例属性,它们在类的每一个对象中都拥有一个复本,这样的实例属性正是每个对象区别于其他对象的特征。

  • 面向对象方法中还有 “类属性” 的概念。如果某个属性为整个类所共有,不属于任何一个具体对象,则采用 static 关键字来声明为静态成员。静态成员在每个类只有一个副本,由该类的所有对象共同维护和使用,从而实现了同一类的不同对象之间的数据共享。类属性是描述类的所有对象共同特征的一个数据项,对于任何对象实例,它的属性值是相同的。简单地说,如果将 “类” 比作一个工厂,对象是工厂生产出的产品,那么静态成员是存放在工厂中、属于工厂的,而不是属于每个产品的。

  • 静态数据成员具有静态生存期。由于静态数据成员不属于任何一个对象,因此可以通过类名对它进行访问,一般的用法是 “类名 :: 标识符”。在类的定义中仅仅对静态数据成员进行引用性声明,必须在命名空间作用域的某个地方使用类名限定定义性声明,这时也可以进行初始化。在 UML 语言中,静态数据成员通过在数据成员下方添加下划线来表示。从下面的例子中可以看到静态数据成员的作用。

例 5 - 4 具有静态数据成员的 Point
#include <iostream>
using namespace std;

class Point {
private:
    static int count; // 静态数据成员声明
    int x;
    int y;
public:
    Point(int x = 0, int y = 0) : x(x), y(y) { count++; }
    Point(const Point& p) : x(p.x), y(p.y) { count++; }
    ~Point() { count--; }
    static void showCount() { cout << count << endl; }
};

int Point::count = 0; // 静态数据成员定义和初始化

int main() {
    Point a;
    Point b = a;
    Point::showCount(); // 输出 2
    return 0;
}
  • 这个程序是由第 4 章的 Point 类修改而来,引入静态数据成员 count 用于统计 Point 类的对象个数。包含静态数据成员 countPoint 类的 UML 图形表示如图 5 - 2 所示。

程序的运行结果如下:

2
  • 上面的例子中,类 Point 的数据成员 count 被声明为静态,用来给 Point 类的对象计数,每定义一个新对象,count 的值就相应加 1。静态数据成员 count 的定义和初始化在类外进行,初始化时引用的方式也很值得注意,首先应该注意的是要利用类名来引用,其次,虽然这个静态数据成员是私有类型,在这里却可以直接初始化。除了这种特殊场合,在其他地方,例如主函数中就不允许直接访问了。count 的值是在类的构造函数中计算的,a 对象生成时,调用有默认参数的构造函数;b 对象生成时,调用复制构造函数,两次调用构造函数访问的均是同一个静态成员 count。通过对象 a 和对象 b 分别调用 showCount 函数,输出的也是同一个 count 在不同时刻的数值。这样,就实现了 ab 两个对象之间的数据共享。

  • 提示在对类的静态私有数据成员初始化的同时,还可以引用类的其他私有成员。例如,如果一个类 T 存在类型为 T 的静态私有对象,那么可以引用该类的私有构造函数将其初始化。

2. 静态函数成员

  • 在例 5 - 4 中,函数 showCount 是专门用来输出静态成员 count 的。要输出 count 只能通过 Point 类的某个对象来调用函数 showCount。在所有对象声明之前 count 的值是初始值 0。如何输出这个初始值呢?显然由于尚未声明任何对象,无法通过对象来调用 showCount。由于 count 是为整个类所共有的,不属于任何对象,因此我们自然会希望对 count 的访问也不要通过对象。现在尝试将例 5 - 4 中的主函数改写如下:
int main() {
    Point::showCount(); // 错误,普通成员函数不能通过类名调用
    return 0;
}
  • 但是不幸得很,编译时出错了,对普通函数成员的调用必须通过对象名。

  • 尽管如此, C++ 中还是可以有办法实现我们上述期望的,这就是使用静态成员函数。所谓静态成员函数就是使用 static 关键字声明的函数成员。同静态数据成员一样,静态成员函数也属于整个类,由同一个类的所有对象共同拥有,为这些对象所共享。

  • 静态成员函数可以通过类名或对象名来调用,而非静态成员函数只能通过对象名来调用。

  • 习惯虽然静态成员函数可以通过类名和对象名两种方式调用,但一般习惯于通过类名调用。因为即使通过对象名调用,起作用的也只是对象的类型信息,与所使用的具体对象毫无关系。

  • 静态成员函数可以直接访问该类的静态数据和函数成员。而访问非静态成员,必须通过对象名。请看下面的程序段:

class A {
public:
    static void func() {
        // 可以访问静态成员
        staticMember = 10;
        staticFunc();
        // 不能直接访问非静态成员
        // member = 20; // 错误
        // func(); // 错误
        // 通过对象名访问非静态成员
        A a;
        a.member = 20;
        a.func();
    }
    int member;
    static int staticMember;
    void func();
    static void staticFunc();
};

int A::staticMember = 0;

void A::func() {
    // ...
}

void A::staticFunc() {
    // ...
}
  • 可以看到,通过静态函数成员访问非静态成员是相当麻烦的,一般情况下,它主要用来访问同一个类中的静态数据成员,维护对象之间共享的数据。

  • 在 UML 语言中,静态函数成员是通过在函数成员添加 <<static>> 构造型来表征。

例 5 - 5 具有静态数据和函数成员的 Point
#include <iostream>
using namespace std;

class Point {
private:
    static int count; // 静态数据成员声明
    int x;
    int y;
public:
    Point(int x = 0, int y = 0) : x(x), y(y) { count++; }
    Point(const Point& p) : x(p.x), y(p.y) { count++; }
    ~Point() { count--; }
    static void showCount() { cout << count << endl; } // 静态函数成员
};

int Point::count = 0; // 静态数据成员定义和初始化

int main() {
    Point::showCount(); // 输出 0
    Point a;
    Point b = a;
    Point::showCount(); // 输出 2
    return 0;
}
  • 与例 5 - 4 相比,这里只是在类的定义中,将 showCount 改写为静态成员函数。于是在主函数中既可以使用类名也可以使用对象名来调用 showCount

  • 这个程序的运行输出结果与例 5 - 4 的结果完全相同。相比而言,采用静态函数成员的好处是可以不依赖于任何对象,直接访问静态数据。

5.4 类的友元

  • 将数据与处理数据的函数封装在一起,构成类,既实现了数据的共享又实现了隐藏,无疑是面向对象程序设计的一大优点。但是封装并不总是绝对的。现在考虑一个简单的例子,就是我们熟悉的 Point 类,每一个 Point 类的对象代表一个 “点”。如果需要一个函数来计算任意两点间的距离,这个函数该如何设计呢?

  • 如果将计算距离的函数设计为类外的普通函数,就不能体现这个函数与 “点” 之间的联系,而且类外的函数也不能直接引用 “点” 的坐标(私有成员),这样计算时就很不方便。

  • 那么设计为 Point 类的成员函数又如何呢?从语法的角度这不难实现,但是理解起来却有问题。因为距离是点与点之间的一种关系,它既不属于每一个单独的点,也不属于整个 Point 类。也就是说无论设计为非静态成员还是静态成员,都会影响程序的可读性。

  • 前面曾经使用类的组合,由 Point 类的两个对象组合成 Line(线段)类,具有计算线段长度的功能。但是 Line 类的实质是对线段的抽象。如果面临的问题是有许多点,并且经常需要计算任意两点间的距离,那么每次计算两点间距离都需要构造一个线段。这样既麻烦又影响程序的可读性。

  • 这种情况下,很需要一个在 Point 类外,但与 Point 类有特殊关系的函数。

class B {
private:
    int x;
public:
        B(int x) : x(x) {}
};

class A {
private:
    int y;
    B b;
public:
    A(int y, int x) : y(y), b(x) {}
    void setY(int y) { this->y = y; }
};

int main() {
    A a(1, 2);
    a.setY(3);
    // 如何访问 A 中 B 对象的 x 呢?
    return 0;
}
  • 这是类组合的情况,类 A 中内嵌了类 B 的对象,但是 A 的成员函数却无法直接访问 B 的私有成员 x。从数据的安全性角度来说,这无疑是最安全的,内嵌的部件相当于一个黑盒。但是使用起来多少有些不便,例如,按如下形式实现 A 的成员函数 set,会引起编译错误。

  • C++ 为上述这些需求提供了语法支持,这就是友元关系。

  • 友元关系提供了不同类或对象的成员函数之间、类的成员函数与一般函数之间进行数据共享的机制。通俗地说,友元关系就是一个类主动声明哪些其他类或函数是它的朋友,进而给它们提供对本类的访问特许。也就是说,通过友元关系,一个普通函数或者类的成员函数可以访问封装于另外一个类中的数据。从一定程度上讲,友元是对数据隐蔽和封装的破坏。但是为了数据共享,提高程序的效率和可读性,很多情况下这种小的破坏也是必要的,关键是一个度的问题,要在共享和封装之间找到一个恰当的平衡。

  • 在一个类中,可以利用关键字 friend 将其他函数或类声明为友元。如果友元是一般函数或类的成员函数,称为友元函数;如果友元是一个类,则称为友元类,友元类的所有成员函数都自动成为友元函数。

1. 友元函数

  • 友元函数是在类中用关键字 friend 修饰的非成员函数。友元函数可以是一个普通的函数,也可以是其他类的成员函数。虽然它不是本类的成员函数,但是在它的函数体中可以通过对象名访问类的私有和保护成员。在 UML 语言中,友元函数是通过在成员函数前方添加 <<friend>> 构造型来表征。请看下面这个例子。
例 5 - 6 使用友元函数计算两点间的距离
#include <iostream>
#include <cmath>
using namespace std;

class Point {
private:
    int x;
    int y;
public:
    Point(int x = 0, int y = 0) : x(x), y(y) {}
    friend double dist(const Point& p1, const Point& p2); // 声明友元函数
};

double dist(const Point& p1, const Point& p2) {
    return sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));
}

int main() {
    Point p1(1, 2), p2(4, 6);
    cout << dist(p1, p2) << endl; // 输出两点间的距离
    return 0;
}
  • 在介绍类的组合时,使用了由 Point 类组合构成的 Line 类计算线段的长度。本例中,将采用友元函数来实现更一般的功能:计算任意两点间的距离。屏幕上的点仍然用 Point 类描述,两点的距离用普通函数 dist 来计算。计算过程中,这个函数需要访问 Point 类的私有数据成员 xy,为此将 dist 声明为 Point 类的友元函数。本实例 Point 类的 UML 图形表示如图 5 - 4 所示。

程序的运行结果为:

5
  • Point 类中只声明了友元函数的原型,友元函数 dist 的定义在类外。可以看到在友元函数中通过对象名直接访问了 Point 类中的私有数据成员 xy,这就是友元关系的关键所在。对于计算任意两点间距离这个问题来说,使用友元与使用类的组合相比,可以使程序具有更好的可读性。当然,如果是要表示线段,无疑使用 Line 类更为恰当。这就说明,对于同一个问题,虽然从语法上可以有多个解决方案,但应该根据问题的实质,选择一种能够比较直接地反映问题域的本来面目的方案,这样的程序才会有比较好的可读性。

  • 友元函数不仅可以是一个普通函数,也可以是另外一个类的成员函数。友元成员函数的使用和一般友元函数的使用基本相同,只是要通过相应的类或对象名来访问。

2. 友元类

  • 同友元函数一样,一个类可以将另一个类声明为友元类。若 A 类为 B 类的友元类,则 A 类的所有成员函数都是 B 类的友元函数,都可以访问 B 类的私有和保护成员。声明友元类的语法形式为:
friend class 友元类名;
  • 声明友元类,是建立类与类之间的联系,实现类之间数据共享的一种途径。在 UML 语言中,两个类之间的友元关系是通过 <<friend>> 构造型依赖来表征。下面,将 5.4 节开头部分的程序段修改成如下形式,其中 B 类是 A 类的友元类,则 B 的成员函数便可以直接访问 A 的私有成员 x
#include <iostream>
using namespace std;

class A {
private:
    int x;
public:
    A(int x) : x(x) {}
    friend class B; // 声明友元类
};

class B {
public:
    void display(A& a) {
        cout << a.x << endl;
    }
};

int main() {
    A a(10);
    B b;
    b.display(a);
    return 0;
}
  • 关于友元,还有几点需要注意:第一,友元关系是不能传递的,B 类是 A 类的友元,C 类是 B 类的友元,C 类和 A 类之间,如果没有声明,就没有任何友元关系,不能进行数据共享。第二,友元关系是单向的,如果声明 B 类是 A 类的友元,B 类的成员函数就可以访问 A 类的私有、保护数据,但 A 类的成员函数却不能访问 B 类的私有、保护数据。第三,友元关系是不被继承的,如果类 B 是类 A 的友元,类 B 的派生类并不会自动成为类 A 的友元。打个比方说,就好像别人信任你,但是不见得信任你的孩子。

5.5 共享数据的保护

  • 虽然数据隐藏保证了数据的安全性,但各种形式的数据共享却又不同程度地破坏了数据的安全。因此,对于既需要共享又需要防止改变的数据应该声明为常量。因为常量在程序运行期间是不可改变的,所以可以有效地保护数据。在第 2 章介绍过简单数据类型常量。声明对象时也可以用 const 进行修饰,称之为常对象。本节介绍常对象、对象的常成员和常引用。常数组和常指针将在第 6 章介绍。

1. 常对象

  • 常对象是这样的对象:它的数据成员值在对象的整个生存期间内不能被改变。也就是说,常对象必须进行初始化,而且不能被更新。声明常对象的语法形式为:
const 类型说明符对象名;
  • 在声明常对象时,把 const 关键字放在类型名后面也是允许的,不过人们更习惯于把 const 写在前面。

例如:

const int a = 10;
  • 与基本数据类型的常量相似,常对象的值也是不能被改变的。在 C++ 的语法中,对基本数据类型的常量提供了可靠的保护。如果程序中出现了类似下面这样的语句,编译时是会出错的。也就是说,语法检查时确保了常量不能被赋值。

  • 语法如何保障类类型的常对象的值不被改变呢?改变对象的数据成员值有两个途径:一是通过对象名访问其成员对象,由于常对象的数据成员都被视同为常量,这时语法会限制不能赋值。二是在类的成员函数中改变数据成员的值,然而几乎无法预料和统计哪些成员函数会改变数据成员的值,对此语法只好规定不能通过常对象调用普通的成员函数。可是这样一来,常对象还有什么用呢?它没有任何可用的对外接口。别担心,办法还是有的,在 5.5.2 节中将介绍专门为常对象定义的常成员函数。

  • 基本数据类型的常量也可看作一种特殊的常对象。因此,后面将不再对基本数据类型的常量和类类型的常对象加以区分。

2. 用 const 修饰的类成员

常成员函数
  • 使用 const 关键字修饰的函数为常成员函数,常成员函数声明的格式如下:
类型说明符函数名参数表const;
  • const 是函数类型的一个组成部分,因此在函数的定义部分也要带 const 关键字。
  • 如果将一个对象说明为常对象,则通过该常对象只能调用它的常成员函数,而不能调用其他成员函数(这就是 C++ 从语法机制上对常对象的保护,也是常对象唯一的对外接口方式)。
  • 无论是否通过常对象调用常成员函数,在常成员函数调用期间,目的对象都被视同为常对象,因此常成员函数不能更新目的对象的数据成员,也不能针对目的对象调用该类中没有用 const 修饰的成员函数(这就保证了在常成员函数中不会更改目的对象的数据成员的值)。
  • const 关键字可以用于对重载函数的区分。例如,如果在类中这样声明:
void print();
void print() const;
  • 这是对 print 的有效重载。

  • 在 UML 语言中,常成员函数是通过在成员函数前添加 <<const>> 构造型来表征。

例 5 - 7 常成员函数举例
#include <iostream>
using namespace std;

class R {
public:
    void print() { cout << "Non-const member function" << endl; }
    void print() const { cout << "Const member function" << endl; }
};

int main() {
    R r1;
    const R r2;
    r1.print(); // 调用非 const 成员函数
    r2.print(); // 调用 const 成员函数
    return 0;
}

程序的运行结果为:

Non-const member function
Const member function

分析:在 R 类中说明了两个同名函数 print,其中一个是常函数。在主函数中定义了两个对象 ab,其中对象 b 是常对象。通过对象 a 调用的是没有用 const 修饰的函数,而通过对象 b 调用的是用 const 修饰的常函数。

习惯在适当的地方使用 const 关键字,是能够提高程序质量的一个好习惯。对于无须改变对象状态的成员函数,都应当使用 const

常数据成员
  • 就像一般数据一样,类的成员数据也可以是常量,使用 const 说明的数据成员为常数据成员。如果在一个类中说明了常数据成员,那么任何函数中都不能对该成员赋值。构造函数对该数据成员进行初始化,就只能通过初始化列表。在 UML 语言中,常数据成员是通过在数据成员类型前添加 const 类型来表征。请看下面的例子。
例 5 - 8 常数据成员举例
#include <iostream>
using namespace std;

class A {
public:
    A(int b) : a(10), b(b) {}   
    void display() const {
        cout << a << ", " << b << endl;
    }
private:
    const int a; // 常数据成员
    int b;
};

int main() {
    A obj(20);
    obj.display(); // 输出 10, 20
    // obj.a = 30; // 错误,不能修改常数据成员
    return 0;
}

运行结果:

10, 20
  • 类成员中的静态变量和常量都应当在类定义之外加以定义,但 C++ 标准规定了一个例外:类的静态常量如果具有整数类型或枚举类型,那么可以直接在类定义中为它指定常量值,例如,例 5 - 8 中可以直接在类定义中写:
static const int b = 10;
  • 这时,不必在类定义外定义 A::b,因为编译器会将程序中对 A::b 的所有引用都替换成数值 10,一般无须再为 A::b 分配空间。但也有例外,例如如果程序中出现了对 b 取地址的情况(关于取地址,将在第 6 章介绍指针时详细介绍),则必须通过专门的定义为 A::b 分配空间。由于已经在类定义中为它指定了初值,不能再在类定义外为它指定初值,即使两处给出的初值相同也不行。

3. 常引用

  • 如果在声明引用时用 const 修饰,被声明的引用就是常引用。常引用所引用的对象不能被更新。如果用常引用作形参,便不会意外地发生对实参的更改。常引用的声明形式如下:
const 类型说明符 & 引用名;
  • const 的引用只能绑定到普通的对象,而不能绑定到常对象,但常引用可以绑定到常对象。一个常引用,无论是绑定到一个普通的对象,还是常对象,通过该引用访问该对象时,都只能把该对象当作常对象。这意味着,对于基本数据类型的引用,则不能为数据赋值,对于类类型的引用,则不能修改它的数据成员,也不能调用它的非 const 的成员函数。
例 5 - 9 常引用作形参
#include <iostream>
using namespace std;

class Point {
public:
    Point(int x = 0, int y = 0) : x(x), y(y) {}
    void display() const { cout << x << ", " << y << endl; }
private:
    int x;
    int y;
};

void dist(const Point& p1, const Point& p2) {
    // 计算两点间距离
    p1.display();
    p2.display();
}

int main() {
    const Point p1(1, 2);
    Point p2(3, 4);
    dist(p1, p2);
    return 0;
}
  • 本例在例 5 - 6 的基础上修改,使其中的 dist 函数的形参以常引用方式传递。

  • 分析:由于 dist 函数中,无须修改两个传入对象的值,因此将传参方式改为传递常引用更合适,这样,调用 dist 函数时,就可以用常对象作为其参数。

  • 对于在函数中无须改变其值的参数,不宜使用普通引用方式传递,因为那会使得常对象无法被传入,采用传值方式或传递常引用的方式可避免这一问题。对于大对象来说,传值耗时较多,因此传递常引用为宜。复制构造函数的参数一般也宜采用常引用传递。

5.6 多文件结构和编译预处理命令

1. C++ 程序的一般组织结构

  • 到现在为止,已经学习了很多完整的 C++ 源程序实例,分析它们的结构,基本上都是由 3 个部分来构成:类的定义、类的成员的实现和主函数。因为所举的例子都比较小,所有这 3 个部分都写在同一个文件中。在规模较大的项目中,往往需要多个源程序文件,每个源程序文件称为一个编译单元。这时 C++ 语法要求一个类的定义必须出现在所有使用该类的编译单元中。比较好的,也是惯用的做法是将类的定义写在头文件中,使用该类的编译单元则包含这个头文件。通常一个项目至少划分为 3 个文件:类定义文件(.h 文件)、类实现文件(.cpp 文件)和类的使用文件(*.cpp,主函数文件)。对于更为复杂的程序,每一个类都有单独的定义和实现文件。采用这样的组织结构,可以对不同的文件进行单独编写、编译,最后再连接,同时可以充分利用类的封装特性,在程序的调试、修改时只对其中某一个类的定义和实现进行修改,而其余部分不用改动。现在将例 5 - 5 的程序按照这样的方法进行划分,写成如例 5 - 10 所示的形式。

  • 在多文件结构中,可以看到在两个 .cpp 的文件中都增加了一个新的 #include 语句。在使用输入输出操作时,需要使用 #include <iostream>,将系统提供的标准头文件 iostream 包含到源程序中。这里,同样需要使用语句 #include "point.h" 将自定义的头文件包含进来。C++ 中的 #include 指令的作用是将指定的文件嵌入到当前源文件中 #include 指令所在的位置,这个被嵌入的文件可以是 .h 文件,也同样可以是 .cpp 文件。

  • 指令 #include 可以有两种书写方式。“#include <文件名>” 表示按照标准方式搜索,文件位于编译环境的 include 子目录下,一般要嵌入系统提供的标准文件时采用这样的方式,如对标准头文件 iostream 的包含。另一种书写为 “#include "文件名"”,表示首先在当前目录下搜索要嵌入的文件,如果没有,再按照标准方式搜索,对用户自己编写的文件一般采用这种方式,如本例中类的定义文件 point.h

  • #include 属于编译预处理命令,稍后将对编译预处理命令做详细介绍。

  • 从图 5 - 8 可以看到,两个 .cpp 的文件被分别编译生成各自的目标文件 .obj,然后再与系统的运行库共同连接生成可执行文件 .exe。如果只修改了类的成员函数的实现部分,则只重新编译 point.cpp 并连接即可,其余的文件几乎可以连看都不用看。想一想,如果是一个语句很多、规模特大的程序,效率就会得到显著的提高。

  • 这种多文件组织技术,在不同的环境下由不同的方式来完成。在 Windows 系列操作系统下的 C++ 一般使用工程来进行多文件管理,在 UNIX 系列操作系统下一般可以用 make 工具完成。在开发程序时,还需要学习编程环境的使用。

  • 决定一个声明放在源文件中还是头文件中的一般原则是,将需要分配空间的定义放在源文件中,例如函数的定义(需要为函数代码分配空间)、命名空间作用域中变量的定义(需要为变量分配空间)等;而将不需要分配空间的声明放在头文件中,例如类声明、外部函数的原型声明、外部变量的声明(外部函数和外部变量将在 5.6.2 节中详细讨论)、基本数据类型常量的声明等。内联函数比较特殊,由于它的内容需要嵌入到每个调用它的函数之中,所以对于那些需要被多个编译单元调用的内联函数,它们的定义应当出现在头文件中。

  • 习惯如果误将分配了空间的定义写入头文件中,在多个源文件包含该头文件时,会导致空间在不同的编译单元中被分配多次,从而在连接时引发错误。

2. 外部变量与外部函数

外部变量
  • 如果一个变量除了在定义它的源文件中可以使用外,还能被其他文件使用,那么就称这个变量是外部变量。命名空间作用域中定义的变量,默认情况下都是外部变量,但在其他文件中如果需要使用这一变量,需要用 extern 关键字加以声明。请看下面的例子。
//file1.cpp
#include <iostream>
using namespace std;

int i = 42; // 定义并初始化外部变量

void printI() {
    cout << "i in file1.cpp: " << i << endl;
}

//file2.cpp
#include <iostream>
using namespace std;

extern int i; // 引用性声明,表示i在别处定义

void showI() {
    cout << "i in file2.cpp: " << i << endl;
}

//main.cpp
void printI();
void showI();

int main() {
    printI(); // 输出 i in file1.cpp: 42
    showI();  // 输出 i in file2.cpp: 42
    return 0;
}
  • 上述程序中,虽然 i 定义在源文件 1 中,但由于在源文件 2 中用 extern 关键字声明了,因此同样可以使用它。外部变量是可以为多个源文件所共享的全局变量。

  • 对外部变量的声明可以是定义性声明,即在声明的同时定义(分配内存,初始化),也可以是引用性声明(引用在别处定义的变量)。在命名空间作用域中,不用 extern 关键字声明的变量,都是定义性声明;用 extern 关键字声明的变量,如果同时指定了初值,则是定义性声明,否则是引用性声明。例如上述源文件 1 中声明变量 i 的同时也是对 i 的定义,源文件 2 中对 i 的声明只是引用性声明。外部变量可以有多处声明,但是对变量的定义性声明只能是唯一的。

外部函数
  • 在所有类之外声明的函数(也就是非成员函数),都是具有命名空间作用域的,如果没有特殊说明,这样的函数都可以在不同的编译单元中被调用,只要在调用之前进行引用性声明(即声明函数原型)即可。当然,也可以在声明函数原型或定义函数时用 extern 修饰,其效果与不加修饰的默认状态是一样的。

  • 习惯通常情况下,变量和函数的定义都放在源文件中,而对外部变量和外部函数的引用性声明则放在头文件中。

将变量和函数限制在编译单元内
  • 命名空间作用域中声明的变量和函数,在默认情况下都可以被其他编译单元访问,但有时并不希望一个源文件中定义的命名空间作用域的变量和函数被其他源文件引用。这种需求主要是出于两个原因,一是出于安全性考虑,不希望将一个只会在文件内使用的内部变量或函数暴露给其他编译单元,就像不希望暴露一个类的私有成员一样;二是,对于大工程来说,不同文件之中的、只在文件内使用的变量名很容易重名,如果将它们都暴露出来,在连接时很容易发生名字冲突。

  • 对这一问题,曾经的解决办法是,在定义这些变量和函数时使用 static 关键字。static 关键字用来修饰命名空间作用域的变量或函数时,和 extern 关键字起相反的作用,它会使得被 static 修饰的变量和函数无法被其他编译单元引用。

  • 提示目前已经介绍了 static 关键字的 3 种用法,当它用在局部作用域、类作用域和命名空间作用域时,具有不尽相同的作用。一个共同点是,凡是被 static 修饰的变量,都具有静态生存期(不管未使用 static 关键字时它们的生存期如何)。

  • 然而,2003 年发布的 ISO C++ 2.0 标准中,已经宣布不再鼓励使用这种方式,取而代之的方式是使用匿名的命名空间。在匿名命名空间中定义的变量和函数,都不会暴露给其他编译单元。请看下面的例子。

  • 应当将不希望被其他编译单元引用的函数和变量放在匿名的命名空间中。

3. 标准 C++ 库

在 C 语言中,系统函数、系统的外部变量和一些宏定义都放置在运行库(runtime library)中。C++ 的库中除继续保留了大部分 C 语言系统函数外,还加入了预定义的模板和类。标准 C++ 类库是一个极为灵活并可扩展的可重用软件模块的集合。标准 C++ 类与组件在逻辑上分为如下 6 种类型:

  • 输入输出类;
  • 容器类与 ADT(抽象数据类型);
  • 存储管理类;
  • 算法;
  • 错误处理;
  • 运行环境支持。

对库中预定义内容的声明分别存在于不同的头文件中,要使用这些预定义的成分,就要将相应的头文件包含到源程序中。当包含了必要的头文件后,就可以使用其中预定义的内容了。

  • 包含这些头文件的目的是在当前编译单元中引入所需的引用性声明,而它们的定义则以目标代码的形式存放于系统的运行库中。

  • 使用标准 C++ 库时,还需要加入下面这一条语句来将指定命名空间中的名称引入到当前作用域中:

using namespace std;
  • 如果不使用上述方法,就需要在使用 std 命名空间中的标识符时冠以命名空间名 “std::”。

  • 习惯通常情况下,using namespace 语句不宜放在头文件中,因为这会使一个命名空间不被察觉地对一个源文件开放。

4. 编译预处理

  • 在编译器对源程序进行编译之前,首先要由预处理器对程序文本进行预处理。预处理器提供了一组编译预处理指令和预处理操作符。预处理指令实际上不是 C++ 语言的一部分,它只是用来扩充 C++ 程序设计的环境。所有的预处理指令在程序中都是以 “#” 来引导,每一条预处理指令单独占用一行,不要用分号结束。预处理指令可以根据需要出现在程序中的任何位置。
#include 指令

#include 指令也称文件包含指令,其作用是将另一个源文件嵌入到当前源文件中该点处。通常用 #include 指令来嵌入头文件。文件包含指令有如下两种格式。

  • #include <文件名> :按标准方式搜索,文件位于系统目录的 include 子目录下。
  • #include "文件名" :首先在当前目录中搜索,若没有,再按标准方式搜索。

#include 指令可以嵌套使用。假设有一个头文件 myhead.h,该头文件中又可以有如下的文件包含指令:

#define#undef 指令
  • 预处理器最初是为 C 语言设计的,#define 曾经在 C 程序中被广泛使用,但 #define 能完成的一些功能,能够被 C++ 引入的一些语言特性很好地替代。

  • 在 C 语言中,用 #define 来定义符号常量,例如下列预编译指令定义了一个符号常量 PI 的值为 3.14:

#define PI 3.14
  • 在 C++ 中虽然仍可以这样定义符号常量,但是更好的方法是在类型说明语句中用 const 进行修饰。

  • 在 C 语言中,还可以用 #define 来定义带参数宏,以实现简单的函数计算,提高程序的运行效率,但是在 C++ 中这一功能已被内联函数取代。因此在这里不做过多的介绍。

  • #define 还可以定义空符号,例如:

#define MYHEAD_H
  • 定义它的目的,仅仅是表示 “MYHEAD_H 已经定义过” 这样一种状态。将该符号配合条件编译指令一起使用,可以起到一些特殊作用,这是 C++ 程序中 #define 的最常用之处。

  • #undef 的作用是删除由 #define 定义的宏,使之不再起作用。

条件编译指令
  • 使用条件编译指令,可以限定程序中的某些内容要在满足一定条件的情况下才参与编译。因此,利用条件编译可以使同一个源程序在不同的编译条件下产生不同的目标代码。例如,可以在调试程序时增加一些调试语句,以达到跟踪的目的,并利用条件编译指令,限定当程序调试好后,重新编译时,使调试语句不参与编译。常用的条件编译语句有下列 5 种形式。

  • 形式1

#if 常量表达式
    程序段 1
#else
    程序段 2
#endif
  • 如果 “标识符” 经 #define 定义过,且未经 #undef 删除,则编译程序段 1,否则编译程序段 2。如果没有程序段 2,则 #else 可以省略:

  • 形式2

#ifdef 标识符
    程序段 1
#else
    程序段 2
#endif
  • 形式3
#ifndef 标识符
    程序段 1
#else
    程序段 2
#endif
  • 形式4
#if 推导出非零值的常量表达式
    程序段 1
#else
    程序段 2
#endif
  • 形式5
#ifndef 标识符
    程序段 1
#else
    程序段 2
#endif
  • 如果 “标识符” 未被定义过,则编译程序段 1,否则编译程序段 2。如果没有程序段 2,则 #else 可以省略:
defined 操作符
  • defined 是一个预处理操作符,而不是指令,因此不要以 # 开头。defined 操作符使用的形式为:
defined标识符
  • 若 “标识符” 在此之前经 #define 定义过,并且未经 #undef 删除,则上述表达式为非 0,否则上述表达式的值为 0。下面两种写法是完全等效的。

  • 由于文件包含指令可以嵌套使用,在设计程序时要避免多次重复包含同一个头文件,否则会引起变量及类的重复定义。例如,某个工程包括如下 4 个源文件。

//main.cpp
#include "file1.h"
#include "file2.h"
int main() {
  ...
}

//file1.h
#include "head.h"
...

//file2.h  
#include "head.h"
...

//head.h
class Point {
  ...
}
  • 这时,由于 #include 指令的嵌套使用,使得头文件 head.h 被包含了两次,于是编译时系统会指出错误:类 Point 被重复定义。如何避免这种情况呢?这就要在可能被重复包含的头文件中使用条件编译指令。用一个唯一的标识符来标记某文件是否已参加过编译,如果已参加过编译,则说明该程序段是被重复包含的,编译器忽略重复部分。将文件 head.h 改写如下:
#ifndef HEAD_H
#define HEAD_H

// 原有的头文件内容

#endif
  • 在这个头文件中,首先判断标识符 HEAD_H 是否被定义过。若未定义过,说明此头文件尚未参加过编译,于是编译下面的程序段,并且对标识符 HEAD_H 进行宏定义,标记此文件已参加过编译。若标识符 HEAD_H 被定义过,说明此头文件参加过编译,于是编译器忽略下面的程序段。这样便不会造成对类 Point 的重复定义。

5.7 综合实例

在上一节,以个人银行账户管理程序为例,说明了类和成员函数的设计和应用。在本节中,将在第 4 章综合实例的基础上对程序做如下改进。

  • 在 5.2.1 和 5.3.1 两节中曾介绍了静态数据成员的概念。在本实例中,将为 SavingsAccount 类增加一个静态数据成员 total,用来记录各个账户的总金额,并为其增加相应的静态成员函数 getTotal 用来对其进行访问。
  • 在 5.3.2 节中介绍了用 const 修饰的类成员函数。在本实例中,将第 4 章综合实例程序中 SavingsAccount 类的诸如 getBalanceaccumulate 这些不需要改变对象状态的成员函数声明为常成员函数。
  • 在 5.6.1 节中介绍了 C++ 程序的一般结构。在本实例中将在第 4 章综合实例的基础上对程序结构进行调整:将 SavingsAccount 类从主函数所在的源文件中分开,建立两个新的文件 account.haccount.cpp,分别存放 SavingsAccount 类的定义和实现。

本例的类设计与第 4 章综合实例大致相同,只是在类 SavingsAccount 中增加静态数据成员 total、静态成员函数 getTotal,并把原有的部分成员函数变为了常成员函数。

例 5 - 11 个人银行账户管理程序

整个程序分为 3 个文件:account.h 是类定义头文件,account.cpp 是类实现文件,5_11.cpp 是主函数文件。

/* account.h */
#ifndef ACCOUNT_H
#define ACCOUNT_H

#include <iostream>
#include <cmath>

class SavingsAccount {
private:
    int id;
    double balance;
    double rate;
    static double total; // 静态数据成员
    int lastDate;
    double accumulation;
    void accumulate(int date);
public:
    SavingsAccount(int id, double balance, double rate);
    void deposit(double amount);
    void withdraw(double amount);
    void settle();
    double getBalance() const;
    static double getTotal(); // 静态成员函数
    void showInfo() const;
};

#endif
/* account.cpp */
#include "account.h"

double SavingsAccount::total = 0.0;

SavingsAccount::SavingsAccount(int id, double balance, double rate)
    : id(id), balance(balance), rate(rate), lastDate(0), accumulation(0) {
    total += balance;
}

void SavingsAccount::accumulate(int date) {
    int days = date - lastDate;
    if (days > 0) {
        accumulation += balance * days;
        lastDate = date;
    }
}

void SavingsAccount::deposit(double amount) {
    accumulate(date);
    balance += amount;
    total += amount;
}

void SavingsAccount::withdraw(double amount) {
    if (amount <= balance) {
        accumulate(date);
        balance -= amount;
        total -= amount;
    } else {
        cout << "Insufficient balance" << endl;
    }
}

void SavingsAccount::settle() {
    accumulate(date);
    double interest = accumulation * rate / 365;
    balance += interest;
    total += interest;
    accumulation = 0;
    lastDate = 0;
}

double SavingsAccount::getBalance() const {
    return balance;
}

double SavingsAccount::getTotal() {
    return total;
}

void SavingsAccount::showInfo() const {
    cout << "Account ID: " << id
         << ", Balance: " << balance
         << ", Rate: " << rate << endl;
}
/* 5_11.cpp */
#include "account.h"

using namespace std;

int main() {
    SavingsAccount sa1(1, 1000.0, 0.015);
    SavingsAccount sa2(2, 2000.0, 0.015);

    sa1.deposit(500.0);
    sa2.withdraw(300.0);

    cout << "Total balance of all accounts: " << SavingsAccount::getTotal() << endl;

    sa1.showInfo();
    sa2.showInfo();

    return 0;
}
  • 除了本例新增的总金额输出外,其他输出结果与上一章的综合实例完全一样。本例新增了静态数据成员 total,在 account.h 的类定义中给出了它的声明,在 account.cpp 中给出了它的定义(即为它分配空间),并将其初值设为 0。SavingsAccount::record 函数中,每当当前账户的 balance 修改时,total 也随之进行修改。由于 depositwithdrawsettle 各函数都是通过 record 函数来修改余额的,因此 total 随时都等于所有账户的余额总和。
  • 通过对 account.h 使用条件编译指令,可以避免本实例中 SavingsAccount 类被重复包含到文件中而导致编译错误。

5.8 深度探索

1. 常成员函数的声明原则

  • 本章介绍了类的常成员函数,这是一个重要的语言特性。对于那些不会改变对象状态的函数,都应当将其声明为常成员函数。那么,什么是改变对象状态呢?

  • 按照语言要求,凡是会改变非静态的成员对象值的成员函数,都不能够声明为常成员函数。但这并不意味着,凡是不会改变任何一个成员对象的值的成员函数,都不会改变对象状态。

  • 另一种意外情况也会发生,如果一个函数会改变某个成员对象的值,但它未必会改变对象状态。成员状态的改变,并不能够完全根据成员对象的值是否被改变来判定,而应当根据通过这一对象对外接口所反映出的信息来判断。如果对一个成员函数的调用,不会使得其后对该对象的接口调用的结果发生变化,那么就可以认为这个成员函数不会改变对象状态。这是从经验论角度提出的一个一般原则,对于各种有具体物理意义的对象,还应当有更具体的判别方式。比如,可以把例 4 - 4 中的 Line 类稍做改变如下:

class Line {
private:
    Point p1, p2;
    mutable double len; // 使用 mutable 关键字
public:
    Line(Point p1, Point p2) : p1(p1), p2(p2), len(0) {}
    double getLen() const {
        len = sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));
        return len;
    }
};
  • 例 4 - 4 中的 Line 类,数据成员 len 的值是在 Line 的构造函数中计算的,这里把 len 的计算放到了 getLen 函数中。这种做法的一个好处是,如果 getLen 函数不会被调用,那么可以省去计算距离的时间,但是为了避免做第二次计算,还需要将计算的结果用 len 成员变量保存起来。

  • 读者可以发现,与例 4 - 4 的 Line 类相比,本类只是实现不同,但功能完全相同。例 4 - 4 中的 getLen 函数只是将一个成员变量返回,自然不会改变对象状态,同样地,这里的 getLen 函数也不会改变 Line 对象的状态。getLen 函数只改变了 len 的值,而 Line 所表示的线段的状态,只由它的两个端点 p1p2 决定,线段的长度是依赖于它的端点位置的,len 成员只用来将线段长度暂时记录下来,使得下次无须重新计算,因此改变 len 的值不会导致对象的状态被改变。从经验论的角度说,getLen 函数是完成构造 Line 对象的唯一接口,而无论对它调用多少次,都能得到相同的结果,因此对 getLen 的调用,不会使对象的状态发生改变。

  • 这样问题就出现了。既然调用 getLen 不会改变对象状态,就应当将其声明为常成员函数,然而,语言上不允许,因为它会改变数据成员 len 的值。C++ 专为这种情况提供了一个解决办法,这需要用到一个新的关键字 —— mutable。对于这类数据成员,可以使用 mutable 关键字加以修饰,这样,即使在常成员函数中,也可以修改它们的值。上面的类可以改写成下面的形式:

class Line {
private:
    Point p1, p2;
    mutable double len; // 使用 mutable 关键字
public:
    Line(Point p1, Point p2) : p1(p1), p2(p2), len(0) {}
    double getLen() const {
        len = sqrt((p1.x - p2.x) * (p1.x - p2.x) + (p1.y - p2.y) * (p1.y - p2.y));
        return len;
    }
};
  • 使用了 mutable 关键字后,就可以将 getLen 函数声明为常成员函数了。

  • 其实 mutable 不只允许在常成员函数中修改被它修饰的数据成员,“常对象的成员对象被视为常对象” 这一语言原则,对 mutable 修饰的成员对象不适用,被 mutable 修饰的成员对象在任何时候都不会被视为常对象,这是 mutable 更一般的含义。

  • 尽管 mutable 能够很好地解决上例中的问题,但绝不能将其滥用,否则会破坏 const 形成的语言保护机制。使用 mutable 关键字一定要有的放矢,也就是说,一定确实存在需要改变一个成员对象的常成员函数,而且对该成员函数的调用确实不会改变对象状态,只有保证了这些,mutable 才能够不被滥用。

2. 代码的编译连接与执行过程

  • 本章介绍了多文件结构,读者或许对此还存有一些疑问,例如,为什么外部变量的定义性声明只能在一个编译单元中出现?为什么同一个函数的定义不能出现在多个编译单元中,但类定义却应当写在头文件中,从而被多个源文件包含?对程序编译、连接和执行的过程稍加探究,将会对解开这些疑问有所帮助。

  • 后面的叙述,皆以下面这个包括两个源文件 a.cppb.cpp 的简单程序为例。另外,不同体系结构、不同操作系统下的目标文件组织方式存在着微小差异,本节介绍的内容以工作在 IA - 32 微处理器上的 Linux 操作系统为准。

编译
  • 一个个源文件,经过编译系统的处理,生成目标文件的过程叫做编译。编译是对一个个源文件分别处理的,因此每个源文件构成了一个独立的编译单元,编译过程中不同的编译单元互不影响。a.cppb.cpp 这两个源文件经过编译后,在 Linux 下会生成 a.ob.o 两个目标文件。

  • 目标文件主要用来描述程序在运行过程中需要放在内存中的内容,这些内容包括两大类 —— 代码和数据。相应地,目标文件也分成代码段和数据段。

  • 代码段(.text)中的内容就是源文件中定义的一个个函数编译后得到的目标代码。在上例中,目标文件 a.o 的代码段中应当包含 main 函数的目标代码,而目标文件 b.o 中应当包含 func 函数的代码。无论是普通函数的代码,还是类的成员函数的代码,都放在代码段中。

  • 数据段中包含对源文件中定义的各个静态生存期对象(包括基本类型变量)的描述。数据段又分为初始化的数据段(.data)和未初始化的数据段(.bss)。其中,初始化的数据段中包括了那些在定义的同时设置了初值的静态生存期对象(通过执行构造函数的方式赋初值的不在此列)。对于这些对象,其初值被放在初始化的数据段中,这些对象在运行时占多少内存空间,在目标文件中就要提供多少空间存放它们的初值。例如,由于 b.cpp 中定义了静态生存期的整型变量 x,在 b.o 的初始化的数据段中,需要存储 x 的初值 3。

  • 其他静态生存期对象,都放在未初始化的数据段中。由于它们没有静态的初值,目标文件中不需要保留专门空间存储它们的信息,只需记录这个段的大小。b.cpp 中的变量 y 就属于该段。

  • 几个段的内容,都是在该源文件中有定义的内容,而那些只声明而未经定义的全局变量或函数并不出现在这几个段中。例如 a.cpp 中的 y 并没有出现在 a.o 的数据段中,而 func 也没有出现在 a.o 的代码段中。但是,目标文件的信息到此还不完整,例如,a.cppmain 函数中改写了变量 y 的值,但 y 是在 b.cpp 中定义的,这种不同编译单元间的相同变量或函数的联系,如何建立呢?这种联系要通过这些变量或函数的名字来建立,这些名字都存放在目标代码的符号表中。

  • 符号表是用来把各个标识符名称和它们在各段中的地址关联起来的数据结构。具体地说,符号表应当包含若干个条目,每个静态生存期对象或函数都对应于符号表中的一个条目。这个条目存储了该静态生存期对象或函数的名字和它在该目标文件中的位置,位置是通过它所在那个段以及它相对于该段段首的偏移地址来表示。例如,a.o 的符号表的 main 条目中,就要存储 maina.o 的代码段中的相对地址;b.o 的符号表中的 x 条目,则要存储 xb.o 的初始化数据段中的相对地址。此外,对于那些在编译单元中被引用但未定义的外部变量、外部函数,在符号表中也有相关的条目,但条目中只有符号名,而位置信息是未定义的。

  • 符号表中,函数并不只以它在源程序中的名字命名,函数在符号表中的名字至少要包括源程序中的函数名和参数表类型信息。因为函数可以重载,由于符号表中没有专门的类型信息,参数表信息只能在名字中有所体现,否则在目标文件中无法对函数名相同但参数表不同的函数加以区分。a.ob.o 的符号表中,func 函数的名字为 _Z4funci

  • 最后需要指出的是,目标文件代码段的目标代码中对静态生存期对象的引用和对函数的调用所使用的地址都是未定义的,因为它们的地址在连接阶段才能确定。因此,在目标文件中还需要保存一些信息,用来将目标代码中的地址和符号表中的条目建立关联,这样到连接时,通过这些信息就可以将这些指令中的地址设置为有效的地址。这些信息称为重定位信息。

连接
  • 在连接期间,需要将各个编译单元的目标文件和运行库当中被调用过的单元加以合并。运行库实际上就是一个个目标代码文件的集合,运行库的各个组成部分和 a.ob.o 这样的目标代码具有相同的结构。经过合并后,不同编译单元的代码段和两类数据段就分别合并到一起了,程序在运行时代码和静态数据需要占据的内存空间就全部已知了,因此所有代码和数据都可以被分配确定的地址了。

  • 与此同时,各个目标文件的符号表也可以被综合起来,符号表的每个条目都会有确定的地址。重定位信息这时也能发挥作用了,各段代码中未定义的地址,都可以被替换为有效地址。

  • 符号表能够被正确综合的一个前提是,对于同一个符号,只在刚好一个编译单元中有定义,而在其他编译单元中是未定义的。之所以要有这个要求,是因为合并后符号表中各符号的地址,需要根据该符号在有定义的编译单元中的相对地址来确定。如果一个符号在各个编译单元中都没有定义,那么它的地址将无从确定,这时会出现符号未定义的连接错误;而如果在多个编译单元中都有定义,那么它的地址将无所适从,这时会出现符号定义冲突的连接错误。这从一个方面说明了,为什么对于任何一个对象或函数,引用性声明可以有多个,但定义性声明有且只能有一个。

  • 连接的对象除了用户源程序生成的目标文件外,还有系统的运行库。例如,执行输入输出功能,调用 sinfabs 这类标准函数,都需要通过系统运行库。此外,系统运行库中还包括程序的引导代码。在执行 main 函数之前,程序需要执行一些初始化工作;在 main 函数返回后,需要通知操作系统程序执行完毕,这些都要由运行库中的代码来完成。

  • 连接后生成的可执行文件的主体,和目标文件一样,也是各个段的信息,只是可执行文件的代码段中所有指令的地址,都是有效地址了。符号表可以出现在可执行文件中,也可以不出现,这不会影响到程序的执行,如果可执行文件中出现了符号表,也只是对调试工具有用。

执行
  • 程序的执行,是以进程为单位的。程序的一次动态执行过程称为一个进程。进程与程序的关系,就像是一次具体的函数调用与函数的关系,程序只有在执行时才会生成进程,执行结束后进程就会消失。

  • 程序是存储在磁盘上的,在执行前,操作系统需要首先将它载入到内存中,并为它分配足够大的内存空间来容纳代码段和数据段,然后把文件中存放的代码段和初始化的数据段的内容载入其中 —— 一部分静态生存期对象的初始化就是通过这种方式完成的,这与动态生存期对象的初始化不同。例如 b.cpp 中的:

int x = 3;
  • x 的初始化在操作系统载入初始化的数据段时就已经完成了。而 a.cpp 中的下列代码:
int z;
  • z 在局部作用域中,z 的初始化,需要等到执行到这条语句时,由编译器生成的代码来完成。

  • 那些需要用构造函数来初始化的静态生存期对象又有所不同,它们的初始化,需要由编译器生成专门的代码来调用构造函数,这些代码被调用的时机也由编译器控制。命名空间作用域中的此类对象的初始化代码,一般在执行 main 函数之前,由引导代码调用;局部作用域中的此类对象,其初始化代码一般会内嵌在函数体中,并用一些静态的标志变量来标识这样的对象是否已初始化,从而保证它们的初始化代码只被执行一次。

  • 此外,操作系统还要做一些进程的初始化工作,这些工作完成后,就会跳转到程序的引导代码,开始执行程序。当程序执行结束后,引导代码会通知操作系统,操作系统会完成一些善后工作,程序的一个执行周期就这样结束了。

6. 数组、指针与字符串

  • 学习了C++基本的控制结构、函数及类的概念和应用,很多问题都可以描述和解决了。但是对于大规模的数据,尤其是相互间有一定联系的数据,或者大量相似而又有一定联系的对象,怎样表示和组织才能达到高效呢?C++的数组类型为同类型对象的组织提供了一种有效的形式。

  • C++从 C 继承来的一个重要特征就是可以直接使用地址来访问内存,指针变量便是实现这一特征的重要数据类型。应用指针,可以方便地处理连续存放的大量数据,以较低的代价实现函数间的大量数据共享,灵活地实现动态内存分配。

  • 字符数组可以用来表示字符串,这是从 C 语言继承的有效方法,但是从面向对象的观点和安全性的角度来看,用字符数组表示的字符串有不足之处,因此标准 C++ 类库中提供了 string 类,这是通过类库来扩展数据类型的一个很好的典范。

6.1 数组

  • 为了理解数组的作用,请考虑这样一个问题:在程序中如何存储和处理具有 n个整数的数列?如果 n 很小,比如 n 等于 3 时,显然不成问题,简单地声明 3 个 int变量就可以了。如果 n 为 10000,用简单 int 变量来表示这 10000 个数,就需要声明 10000 个 int 变量,其烦琐程度可想而知。用什么方法来处理这 10000 个变量呢?数组就是针对这样的问题,用于存储和处理大量同类型数据的数据结构。

  • 数组是具有一定顺序系的若干对象的集合体,组成数组的对象称为该数组的元素。数组元素用数组名与带方括号的下标表示,同一数组的各元素具有相同的类型。数组可以由除 void 型以外的任何一种类型构成,构成数组的类型和数组之间的关系,可以类比为数学上数与向量或矩阵的关系。

  • 每个元素有n 个下标的数组称为n 维数组。如果用 array 来命名一个一维数组,且其下标为从 0到 N 的整数,则数组的各元素为array[0],array[1],...,array[N]。这样一个数组可以顺序储存 N + 1 个数据,因此 N + 1 就是数组 array 的大小,数组的下标下界为 0,数组的下标上界为 N。

1. 数组的声明与使用

数组的声明

数组属于自定义数据类型,因此在使用之前首先要进行类型声明。声明一个数组类型,应该包括以下几个方面。

  1. 确定数组的名称。
  2. 确定数组元素的类型。
  3. 确定数组的结构(包括数组维数,每一维的大小等)。

数组类型声明的一般形式为:

数据类型标识符[常量表达式 1][常量表达式 2]...;

  • 数组中元素的类型是由"数据类型"给出,这个数据类型,可以是整型、浮点型等基本类型,也可以是结构体、类等用户自定义类型。数组的名称由"标识符"指定。

  • "常量表达式 1"、"常量表达式2"、......称为数组的界,必须是在编译时就可求出的常量表达式,其值必须为正整数。数组的下标用来限定数组的元素个数、排列次序和每一个元素在数组中的位置。一个数组可以有多个下标,有 n 个下标的数组称为 n 维数组。数组元素的下标个数称为数组的维数。声明数组时,每一个下标表达式表示该维的下标个数(注意:不是下标上界)。数组元素个数是各下标表达式的乘积。例如:

int b[10];
  • 表示 b 为 int型数组,有 10 个元素:b[0]~b[9],可以用于存放有 10 个元素的整数序列。
int a[5][3];
  • 表示 a为int型二维数组,其中第一维有 5 个下标(0~4),第二维有 3 个下标(0~2),数组元素的个数为15,可以用于存放5行3列的整型数据表格。值得注意的是数组下标的起始值是0。对于上面声明的数组a,第一个元素是 a[0][0],最后一个元素是a[4][2]。也就是说,每一维的下标都是从0 开始的。
数组的使用
  • 使用数组时,只能分别对数组的各个元素进行操作。数组的元素是由下标来区分的。对于一个已经声明过的数组,其元素的使用形式为:
    数组名[下标表达式1][下标表达式2]...
    
  • 其中,下标表达式的个数取决于数组的维数,N 维数组就有N 个下标表达式。

数组中的每一个元素都相当于一个相应类型的变量,凡是允许使用该类型变量的地方,都可以使用数组元素。可以像使用一个整型变量一样使用整型数组的每一个元素。同样,每一个类类型数组的元素也可以和一个该类的普通对象一样使用。在使用过程中需要注意如下两点。

  1. 数组元素的下标表达式可以是任意合法的算术表达式,其结果必须为整数。

  2. 数组元素的下标值不得超过声明时所确定的上下界,否则运行时将出现数组越界错误。

例 6-1 数组的声明与使用。
#include <iostream>
using namespace std;
int main() {
    int a[10], b[10];
    for (int i = 0; i < 10; i++) {
        a[i] = i * 2 - 1;
        b[10 - i - 1] = a[i];
    }
    for (const auto &e:a) //范围for循环,输出a中每个元素
        cout << e << " ";
    cout << endl;
    for (int i = 0; i <10; i++) //下标迭代循环,输出b中每个元素
        cout << b[i] << " ";
    cout << endl;
    return 0;
}
  • 程序中,定义了两个有10个元素的一维数组 a和 b,使用 for 循环对它们赋值,在引用 b 的元素时采用了算术表达式作为下标。程序运行之后,将 -1,1,3,...,17分别赋给数组a的元素a[0],a[1],...,a[9],b中元素的值刚好是a中元素的逆序排列。

  • 如果把两个循环控制语句for(int i= 0;i < 10;i++)改写为for(int i= 1;i <= 10;i++),在编译和连接过程中都不会有错,但最后运行时,不仅不会得到正确结果,而且有可能产生意想不到的错误,这就是一个典型的数组越界错误。

  • 如果发生了数组越界,运行时有时会得到提示,但有时却得不到任何提示,不可预期的结果会悄悄发生。

2. 数组的存储与初始化

数组的存储
  • 数组元素在内存中是顺序、连续存储的。数组元素在内存中占据一组连续的存储单元,逻辑上相邻的元素在物理地址上也是相邻的。一维数组是简单地按照下标的顺序,连续存储的。多维数组的元素也是顺序、连续存储的,其存储次序的约定非常重要。

  • 元素的存储次序问题关系到对数组做整体处理时,以什么样的顺序对数组元素进行操作。C++中很多操作都与数组元素的存储顺序相关,如数组初始化、函数间的数据传递等。

  • 一个一维数组可以看作是数学上的一个列向量,各元素是按下标从小到大的顺序连续存放在计算机内存单元中。例如,数组声明语句:

int arr[5];
  • 声明了一个有 5 个元素的一维 int 型数组,可以看作是列向量arr[0],arr[1], arr[2],arr[3],arr[4]

  • 一个二维数组可以看作是数学上的一个矩阵,第一个下标称为行标,第二个下标称为列标。例如,数组声明语句

int m[2][3]
- 声明了一个二维数组,相当于一个 2 行 3 列的矩阵:

\[ \begin{bmatrix} m(1,1) & m(1,2) & m(1,3) \\ m(2,1) & m(2,2) & m(1,3) \end{bmatrix} \]
  • 但是在 C++中,数组元素每一维的下标是从 0 开始的,因此在程序中,它就被表示为:
\[ \mathbf{M} = \begin{bmatrix} m[0][0] & m[0][1] & m[0][2] \\ m[1][0] & m[1][1] & m[1][2] \end{bmatrix} \]
  • 其中,元素 m[1][0],行标为 1,列标为 0,表示矩阵第 2 行第 1 个元素。二维数组在内存中是按行存放的,即先放第 1 行,再放第 2 行......每行中的元素是按列下标由小到大的次序存放,这样的存储方式也称为行优先存储。

  • 提示 C++中二维数组被当作一维数组的数组。例如,int m[2][3]所定义的 m,可以看作是这样一个数组,它的大小是 2 ,它的每一个元素都是一个大小为3、类型为int 的数组。由于数组的每个元素都要存放在连续的空间中,因此二维数组自然会按行优先的顺序存储。下面介绍的多维数组亦如此。

  • 同样,对多维数组,也是采取类似方式顺序存放,可以把下标看作是一个计数器,右边为低位,每一位都在上下界之间变化。当某一位计数超过上界,就向左进一位,本位及右边各位回到下界。可以看出,最左一维下标值变化最慢,而最右边一维(最后一维)下标值变化最快,其他各维下标值变化情况以此类推。值得特别注意的是下界是0,上界是下标表达式的值减1。例如,数组声明语句

int m[2][3][4];

声明了一个三维数组。

数组的初始化
  • 数组的初始化就是在声明数组时给部分或全部元素赋初值。对于基本类型的数组,初始化过程就是给数组元素赋值;对于对象数组,每个元素都是某个类的一个对象,初始化就是调用该对象的构造函数。关于对象数组,稍后将详细介绍。

声明数组时可以给出数组元素的初值,例如:

int a[3] = {1, 1, 1};
  • 表示声明了一个具有3个元素的int型数组,数组的元素a[0],a[1],a[2]的值都是1。声明数组时如果列出全部元素的初值,可以不用说明元素个数,下面的语句和刚才的语句完全等价:
int a[] = {1, 1, 1};
  • 当然,也可以只对数组中的部分元素进行初始化,比如声明一个有 5个元素的浮点型数组,给前 3个元素分别赋值1.0,2.0和 3.0,可以写为:
float a[5] = {1.0, 2.0, 3.0};
  • 这时,数组元素的个数必须明确指出,对于后面两个不赋值元素,也不用做任何说明。初始化只能针对所有元素或者从起始地址开始的前若干元素,而不能间隔赋初值。

  • 当指定的初值个数小于数组大小时,剩下的数组元素会被赋予 0值。若定义数组时没有指定任何一个元素的初值,对于静态生存期的数组,每个元素仍然会被赋予0值;但对于动态生存期的数组,每个元素的初值都是不确定的。

  • 多维数组的初始化也遵守同样的规则。此外,如果给出全部元素的初值,第一维的下标个数可以不用显式说明,例如:

int a[2][3]={1,0,0,0,1,0};

等价于

int a[][3]={1, 00, 0, 1, 0};
  • 多维数组可以按第一维下标进行分组,使用花括号将每一组的数据括起来。对于二维数组,可以分行用花括号括起来。下面的写法与上面的语句完全等效:
int a[2][3]={{1, 00},{0, 1, 0}};
  • 此外,数组也可以被声明为常量,例如:
const float fa[5]={1.0, 2.0, 3.0};
  • 它表明fa数组中每个元素都被当作常量对待,也就是说它们的值在初始化后皆不可以改变。声明为常量的数组,必须给定初值。

3. 数组作为函数参数

  • 数组元素和数组名都可以作为函数的参数以实现函数间数据的传递和共享。可以用数组元素作为调用函数时的实参,这与使用该类型的一个变量(或对象)作实参是完全相同的。

  • 如果使用数组名作函数的参数,则实参和形参都应该是数组名,且类型要相同。和普通变量作参数不同,使用数组名传递数据时,传递的是地址。形参数组和实参数组的首地址重合,后面的元素按照各自在内存中的存储顺序进行对应,对应元素使用相同的数据存储地址,因此实参数组的元素个数不应该少于形参数组的元素个数。如果在被调函数中对形参数组元素值进行改变,主调函数中实参数组的相应元素值也会改变,这是值得特别注意的一点。

例6-2 使用数组名作为函数参数
  • 下列程序在主函数中初始化一个矩阵并将每个元素都输出,然后调用子函数,分别计算每一行的元素之和,将和直接存放在每行的第一个元素中,返回主函数之后输出各行元素的和。
#include <iostream>
using namespace std;
void rowSum(int a[][4], int nRow) {    //计算二维数组A每行元素的值的和,nrow是行数
    for (int i = 0; i < nRow; i++) {
        for (int j = 1; j < 4; j++)
            a[i][0] += a[i][j];
    }
}
int main() {    //主函数
    int table[3][4] = { {1, 2, 3, 4}, {2, 3, 4, 5}, {3, 4, 5, 6} };//声明并初始化数组
    for (int i = 0; i < 3; i++) { //输出数组元素
        for (int j = 0; j < 4; j++)
            cout << table[i][j] << "   ";
        cout << endl;
    }
    rowSum(table, 3);   //调用子函数,计算各行和
    for (int i = 0; i < 3; i++) //输出计算结果
        cout << "Sum of row " << i << " is " << table[i][0] << endl;
    return 0;
}

程序运行的结果为:

1 2 3 4
2 3 4 5
3 4 5 6
sum of row 0 is 10
sum of row 1 is 14
sum of row 2 is 18
  • 仔细分析程序的输出结果,在子函数调用之前,输出的 table[i][0] 分别为1,2,3,而调用完成之后 table[i][0] 为10,14和18,也就是说在子函数中对形参元素的操作结果直接影响到函数实参的相应元素。

  • 把数组作为参数时,一般不指定数组第一维的大小,即使指定,也会被忽略。

4. 对象数组

  • 数组的元素不仅可以是基本数据类型,也可以是自定义类型。例如,要存储和处理某单位全体雇员的信息,就可以建立一个雇员类的对象数组。对象数组的元素是对象,不仅具有数据成员,而且还有函数成员。因此,和基本类型数组相比,对象数组有一些特殊之处。

  • 声明一个一维对象数组的语句形式是:

类名数组名[常量表达式];
  • 与基本类型数组一样,在使用对象数组时也只能引用单个数组元素。每个数组元素都是一个对象,通过这个对象,便可以访问到它的公有成员,一般形式是:
 数组名[下标表达式].成员名
  • 第4章曾详细介绍了使用构造函数初始化对象的过程。对象数组的初始化过程,实际上就是调用构造函数对每一个元素对象进行初始化的过程。如果在声明数组时给每一个数组元素指定初始值,在数组初始化过程中就会调用与形参类型相匹配的构造函数,例如:
Location a[2] = {Location(1,2), Location(3,4)};
  • 在执行时会先后两次调用带形参的构造函数分别初始化 a[0]和 a[1]。如果没有指定数组元素的初始值,就会调用默认构造函数,例如:
Location a[2] = {Location(1,2)};
  • 在执行时首先调用带形参的构造函数初始化a[0],然后调用默认构造函数初始化a[1]。

  • 如果需要建立某个类的对象数组,在设计类的构造函数时就要充分考虑到数组元素初始化时的需要:当各元素对象的初值要求为相同的值时,应该在类中定义默认构造函数;当各元素对象的初值要求为不同的值时,需要定义带形参(无默认值)的构造函数。

  • 当一个数组中的元素对象被删除时,系统会调用析构函数来完成扫尾工作。

例 6-3 对象数组应用举例
//6-3.cpp
#include "Point.h"
#include <iostream>
using namespace std;

int main() {
    cout << "Entering main..." << endl;
    Point a[2];
    for(int i = 0; i < 2; i++)
        a[i].move(i + 10, i + 20);
    cout << "Exiting main..." << endl;
    return 0;
}

//Point.h
#ifndef _POINT_H
#define _POINT_H

class Point {   //类的定义
public: //外部接口
    Point();
    Point(int x, int y);
    ~Point();
    void move(int newX,int newY);
    int getX() const { return x; }
    int getY() const { return y; }
    static void showCount();    //静态函数成员
private:    //私有数据成员
    int x, y;
};

#endif  //_POINT_H

//Point.cpp
#include <iostream>
#include "Point.h"
using namespace std;

Point::Point() {
    x = y = 0;
    cout << "Default Constructor called." << endl;
}

Point::Point(int x, int y) : x(x), y(y) {
    cout << "Constructor called." << endl;
}

Point::~Point() {
    cout << "Destructor called." << endl;
}

void Point::move(int newX,int newY) {
    cout << "Moving the point to (" << newX << ", " << newY << ")" << endl;
    x = newX;
    y = newY;
}

运行结果:

Entering main...
Default Constructor called.
Delault Constructor called.
Moving the point to (10, 20)
Moving the point to (11, 21)
Exiting main...
Destructor called.
Destructor called.

5. 程序实例

例 6-4 利用 Point类进行点的线性拟合

1.简单分析

点的线性拟合是一般实验数据处理最常用的方法。下面考虑一个用 n 个数据点拟合成直线的问题,直线模型为

\[ y(x)= ax+ b \]

这个问题称为线性回归。设变量 y 随自变量 x 变化,给定 n 组观测数据\((x_i,y_i)\),用直线来拟合这些点,其中a,b是直线的斜率和截距,称为回归系数。

为确定回归系数,通常采用最小二乘法,即要使下式达到最小。

\[ Q = \sum_{i = 0}^{n - 1} (y_{i} - (a x_{i} + b))^{2} \]

根据极值原理,a 和b 满足下列方程:

\[ \frac{\partial Q}{\partial a} = 2 \sum_{i = 0}^{n - 1} (y_{i} - (a x_{i} + b)) (- x_{i}) = 0 \\ \frac{\partial Q}{\partial b} = 2 \sum_{i = 0}^{n - 1} (y_{i} - (a x_{i} + b)) (- 1) = 0 \]

解得:

\[ a = \frac{L_{xy}}{L_{xx}} = \frac{\sum_{i = 0}^{n - 1} (x_{i} - \bar{x})(y_{i} - \bar{y})}{\sum_{i = 0}^{n - 1} (x_{i} - \bar{x})^{2}} \\ b - \bar{y} - a \bar{x} \]

最终可以得到直线方程 \(y(x)= ax + b\)

对于任何一组数据,都可以用这样的方法拟合出一条直线,但是有些数据点远离直线,而有些数据点就很接近于直线,这就需要有一个判据。相关系数是对所拟合直线的线性程度的一般判据,它可以判断一组数据线性相关的密切程度,定义为:

\[ r = \frac{L_{xy}}{\sqrt{L_{xx} L_{yy}}} \]

其中 $$ L_{yy} = \sum_{i = 0}^{n - 1} (y_{u} - \bar{y})^{2} $$

r的绝对值越接近于1,表示数据的线性关系越好,直线关系的数据 r=1。相关系数接近于0,表明数据的直线关系很差,或者二者根本就不是线性关系。因此,直线拟合之后,通常要计算相关系数,用来衡量直线关系程度。

在本例的程序中,利用第 4章给出的Point类为基础,使用该类对象的数组来存储数据点,加入一个该类的友元函数来进行拟合计算,计算的结果为a,b 和表示近似程度的r。

整个程序分为两个文件,Point类的头文件Point.h和程序主函数所在文件6_4.cpp。

//6_4.cpp
#include "Point.h"
#include <iostream>
#include <cmath>
using namespace std;

//直线线性拟合,points为各点,nPoint为点数
float lineFit(const Point points[], int nPoint) {
    float avgX = 0, avgY = 0;
    float lxx = 0, lyy = 0, lxy = 0;
    for(int i = 0; i < nPoint; i++) { //计算x、y的平均值
        avgX += points[i].getX() / nPoint;
        avgY += points[i].getY() / nPoint;
    }
    for(int i = 0; i < nPoint; i++) { //计算Lxx、Lyy和Lxy
        lxx += (points[i].getX() - avgX) * (points[i].getX() - avgX);
        lyy += (points[i].getY() - avgY) * (points[i].getY() - avgY);
        lxy += (points[i].getX() - avgX) * (points[i].getY() - avgY);
    }
    cout << "This line can be fitted by y=ax+b." << endl;
    cout << "a = " << lxy / lxx << "  ";    //输出回归系数a
    cout << "b = " << avgY - lxy * avgX / lxx << endl;  //输出回归系数b
    return static_cast<float>(lxy / sqrt(lxx * lyy));   //返回相关系数r
}

int main() {
    Point p[10] = { Point(6, 10), Point(14, 20), Point(26, 30), Point(33, 40),
                    Point(46, 50), Point(54, 60), Point(67, 70), Point(75, 80),
                    Point(84, 90), Point(100, 100) };   //初始化数据点
    float r = lineFit(p, 10);   //进行线性回归计算
    cout << "Line coefficient r = " << r << endl;   //输出相关系数
    return 0;
}

//Point.h
#ifndef _POINT_H
#define _POINT_H

class Point {   //Point类的定义
public: //外部接口
    Point(float x = 0, float y = 0) : x(x), y(y) { }
    float getX() const { return x; }
    float getY() const { return y; }
private:    //私有数据成员
    float x, y;
};
#endif  //_POINT_H

3.运行结果

程序运行的结果为:

This line can be fitted by y=ax+b.
a = 0.97223 b = 5.90237
Line coefficient r = 0.998193

4.分析与说明

程序主函数首先声明一个 Point类类型的数组,用来存放需要拟合的点。在数组声明的同时,对数组进行了初始化,这种初始化是逐个调用Point类的构造函数来完成的。接着调用Point类的友元函数lineFit进行线性回归计算,最后输出回归系数a ,b和线性系数 r。

lineFit函数的第一个参数是一个常量数组,它使得在lineFit函数中,数组points的每一个元素都被当作常对象,因而不会改变其内容。

这个程序的缺点是可以处理的数据点数是固定的,由 Point类对象数组的大小决定,这在实际使用中是一个很大的缺憾,在以后的章节中,会对本程序进行改造,以适应任意多个数据对的处理。

6.2 指针

指针是C++从C中继承过来的重要数据类型,它提供了一种较为直接的地址操作手段。正确地使用指针,可以方便、灵活而有效地组织和表示复杂的数据结构。动态内存分配和管理也离不开指针。同时,指针也是C++的主要难点。为了理解指针,首先要理解关于内存地址的概念。

1. 内存空间的访问方式

  • 计算机的内存储器被划分为一个个存储单元。存储单元按一定的规则编号,这个编号就是存储单元的地址。地址编码的基本单位是字节,每个字节由8个二进制位组成,也就是说每个字节是一个基本内存单元,有一个地址。计算机就是通过这种地址编号的方式来管理内存数据读写的准确定位的。

  • 在C++程序中如何利用内存单元存取数据呢?一是通过变量名,二是通过地址。程序中声明的变量要占据一定的内存空间,例如,对于一些常见的32 位系统,short 型占 2 字节,lo ng 型占4字节。具有静态生存期的变量在程序开始运行之前就已经被分配了内存空间。具有动态生存期的变量,是在程序运行时遇到变量声明语句时被分配内存空间的。在变量获得内存空间的同时,变量名也就成了相应内存空间的名称,在变量的整个生存期内都可以用这个名字访问该内存空间,表现在程序语句中就是通过变量名存取变量内容。但是,有时使用变量名不够方便或者根本没有变量名可用,这时就需要直接用地址来访问内存单元。例如,在不同的函数之间传送大量数据时,如果不传递变量的值,只传递变量的地址,就会减少系统开销,提高效率。如果是动态分配的内存单元(将在3中介绍),则根本就没有名称,这时只能通过地址访问。

  • 在 C++中有专门用来存放内存单元地址的变量类型,这就是指针类型。

2. 指针变量的声明

  • 指针也是一种数据类型,具有指针类型的变量称为指针变量。指针变量是用于存放内存单元地址的。

  • 指针也是先声明后使用,声明指针的语法形式是:

数据类型* 标识符
  • 其中"*"表示这里声明的是一个指针类型的变量。数据类型可以是任意类型,指的是指针所指向的对象(包括变量和类的对象)的类型,这说明了指针所指的内存单元可以用于存放什么类型的数据,称之为指针的类型。例如,语句
int *ptr;
  • 定义了一个指向int型数据的指针变量,这个指针的名称是ptr,专门用来存放int型数据的地址。

  • 读者也许有这样的疑问:为什么在声明指针变量时要指出它所指的对象是什么类型呢?为了理解这一点,首先要思考一下:当在程序中声明一个变量时声明了什么信息?也许我们所意识到的只是声明了变量需要的内存空间,但这只是一方面;另一个重要的方面就是限定了对变量可以进行的运算及其运算规则。例如,有如下语句:

short i;
  • 它定义了 i是一个 short类型的变量,这不仅意味着它需要占用 2个字节的内存空间,而且规定了i可以参加算术运算、关系运算等运算以及相应的运算规则。

  • 在稍后介绍指针的运算时,读者会看到指针变量的运算规则与它所指的对象类型是密切相关的,声明指针时需要明确指出它用于存放什么类型数据的地址。指针可以指向各种类型,包括基本类型、数组(数组元素)、函数、对象,同样也可以指向指针。

3. 与地址相关的运算"*"和"&"

  • C++提供了两个与地址相关的运算符------"*"和"&"。"*"称为指针运算符,也称解析(dereference),表示获取指针所指向的变量的值,这是一个一元操作符。例如,*ptr 表示指针ptr所指向的int型数据的值。"&"称为取地址运算符,也是一个一元操作符,用来得到一个对象的地址,例如,使用&i就可以得到变量 i的存储单元地址。

  • 必须注意,"*"和"&"出现在声明语句中和执行语句中其含义是不同的,它们作为一元运算符和作为二元运算符时含义也是不同的。一元运算符"*"出现在声明语句中,在被声明的变量名之前时,表示声明的是指针,例如:

int * p;    //声明 p 是一个 int 型指针
  • "*"出现在执行语句中或声明语句的初始化表达式中作为一元运算符时,表示访问指针所指对象的内容,例如:
cout << * p;    //输出指针 p 所指向的内容
  • "&"出现在变量声明语句中位于被声明的变量左边时,表示声明的是引用,例如:
int &rf;    //声明一个 int 型的引用 rf
  • "&"在给变量赋初值时出现在等号右边或在执行语句中作为一元运算符出现时,表示取对象的地址,例如:
int a, b;
int * pa, * pb = &b;
pa = &a;

4. 指针的赋值

  • 定义了一个指针,只是得到了一个用于存储地址的指针变量,但是变量中并没有确定的值,其中的地址值是一个不确定的数,也就是说,不能确定这时候的指针变量中存放的是哪个内存单元的地址。这时候指针所指的内存单元中有可能存放着重要数据或程序代码,如果盲目去访问,可能会破坏数据或造成系统的故障。因此定义指针之后必须先赋值,然后才可以引用。与其他类型的变量一样,对指针赋初值也有两种方法,如下所述:

  • 在定义指针的同时进行初始化赋值。语法形式为:

    存储类型数据类型 *指针名 = 初始地址;
    

  • 在定义之后,单独使用赋值语句。赋值语句的语法形式为:
    指针名 = 地址;
    
  • 如果使用对象地址作为指针的初值,或在赋值语句中将对象地址赋给指针变量,该对象必须在赋值之前就声明过,而且这个对象的类型应该和指针类型一致。也可以使用一个已经赋值的指针去初始化另一个指针,这就是说,可以使多个指针指向同一个变量。

  • 对于基本类型的变量、数组元素、结构成员、类的对象,可以使用取地址运算符&来获得它们的地址,例如使用& i来取得 int型变量 i的地址。

  • 一个数组,可以用它的名称来直接表示它的起始地址。数组名称实际上就是一个不能被赋值的指针,即指针常量。例如下面的语句:

int a[10];
int * ptr = a;
  • 首先定义一个具有10个int类型数据的数组a,然后定义int类型指针ptr,并用数组名表示数组首地址来初始化指针。

  • 下面通过一个例子来回顾一下关于指针的知识。

例 6-5 指针的定义、赋值与使用
#include <iostream>
using namespace std;
int main() {
    int i;          //定义int型数i
    int *ptr = &i;  //取i的地址赋给ptr
    i = 10;         //int型数赋初值
    cout << "i = " << i << endl;            //输出int型数的值
    cout << "*ptr = " << *ptr << endl;  //输出int型指针所指地址的内容
    return 0;
}

程序运行的结果是:

i = 10
*ptr = 10

  • 下面来分析一下程序的运行情况。程序首先定义了一个 int类型变量i,然后定义了一个 int类型指针 ptr,并用取地址操作符求出 i的地址作为指针ptr的初值,再给 int类型变量 i赋初值 10。

  • 程序中两次出现*ptr,它们是具有不同含义的。第一次是在指针声明语句中,标识符前面的"*"表示被声明的标识符是指针;第二次是在输出语句中,是指针运算符,是对指针所指向的变量间接访问。

  • 关于指针的类型,还应该注意以下几点:

  • 可以声明指向常量的指针,此时不能通过指针来改变所指对象的值,但指针本身可以改变,可以指向另外的对象。例如:
int a;
const int  * p1 = &a;
int b;
p1 = &b;    //合法
* p1 = 1;   //会报错,不能通过 p1 改变指向的对象
  • 使用指向常量的指针,可以确保指针所指向的常量不被意外更改。如果用一般指针存放常量的地址,编译器就不能确保指针所指的对象不被更改。

  • 可以声明指针类型的常量,这时指针本身的值不能被改变。例如:

int * const p2 = &a;
p2 = &b;    //错误,此时不能改变指针本身
  1. 一般情况下,指针的值只能赋给相同类型的指针。但是有一种特殊的 void 类型指针,可以存储任何类型的对象地址,就是说任何类型的指针都可以赋值给 void类型的指针变量。经过使用类型显式转换,通过void类型的指针便可以访问任何类型的数据。
例 6-6 void 类型指针的使用

//6_6.cpp
#include <iostream>
using namespace std;

int main() {
//! void voidObject;    错,不能声明void类型的变量
    void *pv;   //对,可以声明void类型的指针
    int i = 5;
    pv = &i;    //void类型指针指向整型变量
    int *pint = static_cast<int *>(pv); //void类型指针赋值给int类型指针
    cout << "*pint = " << *pint << endl;
    return 0;
}
程序运行的结果是:

* pint = 5
  • 提示 void 指针一般只在指针所指向的数据类型不确定时使用。

5. 指针运算

  • 指针是一种数据类型。与其他数据类型一样,指针变量也可以参与部分运算,包括算术运算、关系运算和赋值运算。对指针赋值的运算在前面已经介绍过了,本节介绍指针的算术运算和关系运算。

  • 指针可以和整数进行加减运算,但是运算规则是比较特殊的。前面介绍过声明指针变量时必须指出它所指的对象是什么类型。这里将看到指针进行加减运算的结果与指针的类型密切相关。比如有指针p1 和整数 n1,p1+ n1 表示指针 p1 当前所指位置后方第n1 个数的地址,p1- n1表示指针 p1 当前所指位置前方第 n1个数的地址。"指针++"或"指针--"表示指针当前所指位置下一个或前一个数据的地址。

*(p1+ n1)表示 p1 当前所指位置后方第 n1 个数的内容,它也可以写作 p1[n1],这与*(p1+ n1)的写法是完全等价的,同样,*(p1-n1)也可以写作 p1[- n1]

  • 一般来讲,指针的算术运算是和数组的使用相联系的,因为只有在使用数组时,才会得到连续分布的可操作内存空间。对于一个独立变量的地址,如果进行算术运算,然后对其结果所指向的地址进行操作,有可能会意外破坏该地址中的数据或代码。因此,对指针进行算术运算时,一定要确保运算结果所指向的地址是程序中分配使用的地址。

  • 指针算术运算的不慎使用会导致指针指向无法预期的地址,从而造成不确定的结果,因此指针的算术运算一定要慎用。

  • 指针变量的关系运算指的是指向相同类型数据的指针之间进行的关系运算。如果两个相同类型的指针相等,就表示这两个指针是指向同一个地址。不同类型的指针之间或指针与非 0 整数之间的关系运算是毫无意义的。但是指针变量可以和整数 0 进行比较,0专用于表示空指针,也就是一个不指向任何有效地址的指针,在后面的例子中将会使用。

  • 给指针赋值的方法已经详细地介绍过,这里要强调的是赋给指针变量的值必须是地址常量(如数组名)或地址变量,不能是非 0 的整数,但可以给一个指针变量赋值为 0,这时表示该指针是一个空指针,不指向任何地址。例如:

int * p;    //声明一个int型指针p
p = 0;      //将p设置为空指针,不指向任何地址
::: pic figure_0221_0428 :::

  • 细节空指针也可以用 NULL 来表示,例如
int * p = NULL;
  • NULL 是一个在很多头文件中都有定义的宏,被定义为 0。

  • 为什么有时需要用这种方法将一个指针设置为空指针呢?这是因为有时在声明一个指针时,并没有一个确定的地址值可以赋给它,当程序运行到某个时刻才会将某个地址赋给该指针。这样,从指针变量诞生起到它具有确定的值之前这一段时间,其中的值是不确定的。如果误用这个不确定的值作为地址去访问内存单元,将会造成不可预见的错误。因此在这种情况下便首先将指针设置为空。

  • 如果不便于用一个有效地址给一个指针变量赋初值,那么应当用 0作为它的初值,从而避免指向不确定地址的指针出现。

6. 用指针处理数组元素

  • 指针加减运算的特点使得指针特别适合于处理存储在一段连续内存空间中的同类数据。而数组恰好是具有一定顺序关系的若干同类型变量的集合体,数组元素的存储在物理上也是连续的,数组名就是数组存储的首地址。这样,便可以使用指针来对数组及其元素进行方便而快速的操作。例如,下列语句
int array[5];
  • 声明了一个存放 5 个 int类型数的一维数组,数组名 array 就是数组的首地址(第一个元素的地址),即 array 和&array[0]相同。数组中 5 个整数顺序存放,因此,通过数组名这个地址常量和简单的算术运算就可以访问数组元素。数组中下标为 i 的元素就是*(数组名+ i),例如,* array 就是 array[0]*(array+3)就是数组元素 array[3]

  • 把数组作为函数的形参,等价于把指向数组元素类型的指针作为形参。例如,下面 3 个写法,出现在形参列表中都是等价的。

void f(int p[]);
void f(int p[3]);
void f(int * p);
例 6-7 设有一个 int型数组 a,有 10 个元素。用 3 种方法输出各元素

程序 1:使用数组名和下标。

//6_7_1.cpp
#include <iostream>
using namespace std;

int main() {
    int a[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
    for (int i = 0; i < 10; i++)
        cout << a[i] << "  ";
    cout << endl;
    return 0;
}

程序 2:使用数组名和指针运算。

//6_7_2.cpp
#include <iostream>
using namespace std;

int main() {
    int a[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
    for (int i = 0; i < 10; i++)
        cout << *(a+i) << "  ";
    cout << endl;
    return 0;
}

程序 3:使用指针变量。

//6_7_3.cpp
#include <iostream>
using namespace std;

int main() {
    int a[10] = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 0 };
    for (int *p = a; p < (a + 10); p++)
        cout << *p << "  ";
    cout << endl;
    return 0;
}

上述 3 个程序的运行结果都是一样的,结果如下:

1 2 3 4 5 6 7 8 9 0

7. 指针数组

  • 如果一个数组的每个元素都是指针变量,这个数组就是指针数组。指针数组的每个元素都必须是同一类型的指针。

  • 声明一维指针数组的语法形式为:

    数据类型 *数组名[下标表达式]
    

  • 下标表达式指出数组元素的个数,数据类型确定每个元素指针的类型,数组名是指针数组的名称,同时也是这个数组的首地址。例如,下列语句
int * pa[3];
  • 声明了一个 int类型的指针数组 pa,其中有 3 个元素,每个元素都是一个指向int类型数据的指针。

  • 由于指针数组的每个元素都是一个指针,必须先赋值后引用,因此,声明数组之后,对指针元素赋初值是必不可少的。

例 6-8 利用指针数组输出单位矩阵

单位矩阵是主对角线元素为 1,其余元素为 0 的矩阵,本例是一个 3 行 3 列的单位矩阵。

#include <iostream>
using namespace std;
int main() {
    int line1[] = { 1, 0, 0 };  //定义数组,矩阵的第一行
    int line2[] = { 0, 1, 0 };  //定义数组,矩阵的第二行
    int line3[] = { 0, 0, 1 };  //定义数组,矩阵的第三行

    //定义整型指针数组并初始化
    int *pLine[3] = { line1, line2, line3 };

    cout << "Matrix test:" << endl; //输出单位矩阵
    for (int i = 0; i < 3; i++) {   //对指针数组元素循环
        for (int j = 0; j < 3; j++)     //对矩阵每一行循环
            cout << pLine[i][j] << " ";
        cout << endl;
    }
    return 0;
}
  • 在程序中,定义了一个具有 3个元素的 int类型指针数组,并在定义的同时用line1, line2,line3 三个数组的首地址为这 3 个元素初始化,使每个元素分别指向矩阵的一行,矩阵的每一行都用一个数组存放,然后通过指针数组的元素来访问存放矩阵数据的 int型数组。程序的输出结果为:
Matrix test:
1,0,0
0,1,0
0,0,1
  • 上例中的 pLine[i][j]*(pLine[i]+ j)等价,即先把指针数组 pLine 所存储的第i个指针读出,然后读取它所指向的地址后方的第j个数。它在表示形式上与访问二维数组的元素非常相似,但在具体的访问过程上却不大一样。

  • 二维数组在内存中是以行优先的方式按照一维顺序关系存放的。因此对于二维数组,可以将其理解为一维数组的一维数组,数组名是它的首地址,这个数组的元素个数就是行数,每个元素是一个一维数组。例如,声明一个二维int类型数组:

int array2[3][3] = {{11,12,13}, {21,22,23}. {31,32,33}};
  • array2[0]是一个长度为 3 的一维数组,当array2[0]在表达式中出现时,表示一个指向该一维数组首地址的整型指针,这和一维数组的数组名表示指向该数组首地址的指针是一样的道理,所以可以用*(array2[0])来表示array2[0][0],用*(array2[0]+ 1)来表示array2[0][1]。上例中的 pLine[i][j]与这里的array2[i][j]的不同之处在于,对于pLine来说,pLine[i]的值需要通过读取指针数组pLine的第i个元素才能得到,而 array2[i]的值是通过二维数组 array2 的首地址计算得到的,内存中并没有一个指针数组来存储array2[i]的值。

  • 尽管指针数组与二维数组存在本质的差异,但二者具有相同的访问形式,可以把二维数组当作指针数组来访问,例如下面的程序。

例 6-9 二维数组举例
#include <iostream>
using namespace std;

int main() {
    int array2[3][3]= { { 11, 12, 13 }, { 21, 22, 23 }, { 31, 32, 33 } };
    for(int i = 0; i < 3; i++) {
        for(int j = 0; j < 3; j++)
            cout << *(*(array2 + i) + j) << " ";    //逐个输出二维数组第i行元素值
        cout << endl;
    }
    return 0;
}

程序的输出结果为:

11 12 13
21 22 23
31 32 33

通过数组元素的地址可以输出二维数组元素,形式如下:

*(*(array2 + i) + j)
  • 这就是 array2 数组的第 i 行j 列元素,对应于使用下标表示的array2[i][j]

  • 对多维数组,在形式上同样可以当作相应维数减 1 的一个多维指针数组,读者如果感兴趣,可以以三维数组为例自行分析。

8. 用指针作为函数参数

  • 当需要在不同的函数之间传送大量数据时,程序执行时调用函数的开销就会比较大。这时如果需要传递的数据是存放在一个连续的内存区域中,就可以只传递数据的起始地址,而不必传递数据的值,这样就会减少开销,提高效率。C++的语法对此提供了支持:函数的参数不仅可以是基本类型的变量、对象名、数组名或引用,而且可以是指针。如果以指针作为形参.在调用时实参将值传递给形参,也就是使实参和形参指针变量指向同一内存地址。这样在子函数运行过程中,通过形参指针对数据值的改变也同样影响着实参指针所指向的数据值。

  • C++中的指针是从 C 语言继承过来的。在 C 语言中,以指针作为函数的形参有三个作用:第一个作用是使实参与形参指针指向共同的内存空间,以达到参数双向传递的目的,即通过在被调函数中直接处理主调函数中的数据而将函数的处理结果返回其调用者。这个作用在 C++中已经由引用实现了,这一点在第 3 章中介绍用引用作为函数参数时已经详细介绍过。第二个作用,就是减少函数调用时数据传递的开销。这一作用在C++中有时可以通过引用实现,有时还是需要使用指针。以指针作形参的第三个作用,是通过指向函数的指针传递函数代码的首地址,这个问题将在稍后介绍。

  • 如果函数体中不需要通过指针改变指针所指向对象的内容,应在参数表中将其声明为指向常量的指针,这样使得常对象被取地址后也可作为该函数的参数。

  • 在设计程序时,当某个函数中以指针或引用作为形参都可以达到同样目的,使用引用会使程序的可读性更好些。

例 6-10 读入 3 个浮点数,将整数部分和小数部分分别输出

程序由主函数和一个进行浮点数分解的子函数组成,浮点数在子函数中分解之后,将整数部分和小数部分传递回主函数中输出。可以想象,如果直接使用整型和浮点型变量,形参在子函数中的变化根本就无法传递到主函数,因此采用指针作为函数的参数。

#include <iostream>
using namespace std;

//将实数x分成整数部分和小数部分,形参intpart、fracpart是指针
void splitFloat(float x, int *intPart, float *fracPart) {
    *intPart = static_cast<int>(x); //取x的整数部分
    *fracPart = x - *intPart;       //取x的小数部分
}

int main() {
    cout << "Enter 3 float point numbers:" << endl;
    for(int i = 0; i < 3; i++) {
        float x, f;
        int n;
        cin >> x;
        splitFloat(x, &n, &f);  //变量地址作为实参
        cout << "Integer Part = " << n << " Fraction Part = " << f << endl;
    }
    return 0;
}

程序中的 splitFloat函数采用了两个指针变量作为参数,主函数在调用过程中使用变量的地址作为实参。形实结合时,子函数的 intPart 的值就是主函数 int 型变量 n 的地址。因此,子函数中改变*intPart的值,其结果也会直接影响到主函数中变量 n 的值。fracPart和浮点数 f 也有类似的关系。

程序的运行结果为:

Enter 3 float point numbers:
4.7
Integer Part = 4 Fraction Part = 0.7
8.913
Integer Part = 8 Fraction Part = 0.913
-4.751
Integer Part = -4 Fraction Part = -0.7518

在这个程序中,使用引用作为形参也可以达到同样目的,请读者自己尝试将例 6-10的程序改为使用引用作函数的参数。

9. 指针型函数

  • 除了 void 类型的函数之外,函数在调用结束之后都要有返回值,指针也可以是函数的返回值。当一个函数的返回值是指针类型时,这个函数就是指针型函数。使用指针型函数的最主要目的就是要在函数结束时把大量的数据从被调函数返回到主调函数中。而通常非指针型函数调用结束后,只能返回一个变量或者对象。

  • 指针型函数的一般定义形式是:

数据类型 *函数名(参数表) {
    函数体
}
  • 数据类型表明函数返回指针的类型;函数名和"*"标识了一个指针型的函数;参数表中是函数的形参列表。

10. 指向函数的指针

  • 在程序运行时,不仅数据要占据内存空间,执行程序的代码也被调入内存并占据一定的空间。每一个函数都有函数名,实际上这个函数名就表示函数的代码在内存中的起始地址。由此看来,调用函数的通常形式"函数名(参数表)"的实质就是"函数代码首地址(参数表)"。

  • 函数指针就是专门用来存放函数代码首地址的变量。在程序中可以像使用函数名一样使用指向函数的指针来调用函数。也就是说一旦函数指针指向了某个函数,它与函数名便具有同样的作用。函数名在表示函数代码起始地址的同时,也包括函数的返回值类型和参数的个数、类型、排列次序等信息。因此在通过函数名调用函数时,编译系统能够自动检查实参与形参是否相符,用函数的返回值参与其他运算时,能自动进行类型一致性检查。

  • 声明一个函数指针时,也需要说明函数的返回值、形式参数列表,其一般语法如下:

数据类型*函数指针名)(形参表
  • 数据类型说明函数指针所指函数的返回值类型;第一个圆括号中的内容指明一个函数指针的名称;形参表则列出了该指针所指函数的形参类型和个数。

  • 由于对函数指针的定义在形式上比较复杂,如果在程序中出现多个这样的定义,多次重复这样的定义会相当烦琐,一个很好的解决办法是使用typedef。例如:

typedef int (*DoubleIntFunction) (double);

这声明了DoubleIntFunction 为"有一个double 形参、返回类型为 int 的函数的指针"类型的别名。下面,需要声明这一类型的变量时,可以直接使用:

DoubleIntFunction funcPtr;
  • 这声明了一个具有该类型的名称为 funcPtr 的函数指针。用 typedef 可以很方便地为复杂类型起别名。

  • 函数指针在使用之前也要进行赋值,使指针指向一个已经存在的函数代码的起始地址。一般语法为:

函数指针名=函数名;
  • 等号右边的函数名所指出的必须是一个已经声明过的、和函数指针具有相同返回类型和相同形参表的函数。在赋值之后,就可以通过函数指针名来直接引用这个指针指向的函数。
例 6-11 函数指针实例
//6_11.cpp
#include <iostream>
using namespace std;

void printStuff(float) {
    cout << "This is the print stuff function." << endl;
}

void printMessage(float data) {
    cout << "The data to be listed is " << data << endl;
}

void printFloat(float data) {
    cout << "The data to be printed is " << data << endl;
}

const float PI = 3.14159f;
const float TWO_PI = PI * 2.0f;

int main() {    //主函数
    void (*functionPointer)(float); //函数指针
    printStuff(PI);
    functionPointer = printStuff;   //函数指针指向printStuff
    functionPointer(PI);    //函数指针调用
    functionPointer = printMessage; //函数指针指向printMessage
    functionPointer(TWO_PI);    //函数指针调用
    functionPointer(13.0);  //函数指针调用
    functionPointer = printFloat;   //函数指针指向printFloat
    functionPointer(PI);    //函数指针调用
    printFloat(PI);
    return 0;
}

程序运行结果为:

This is the print stuff function.
This is the print stuff function.
The data to be listed is 6.28318
The data to be listed is 13
The data to be printed is 3.14159
The data to be printed is 3.14159
  • 本例的程序中声明了一个 void 类型的函数指针,在主函数运行过程中,通过赋值语句使这个指针分别指向函数 printStuff,printMessage 和 printFloat,然后通过函数指针来实现对函数的调用。

11. 对象指针

对象指针的一般概念
  • 和基本类型的变量一样,每一个对象在初始化之后都会在内存中占有一定的空间。因此,既可以通过对象名,也可以通过对象地址来访问一个对象。虽然对象同时包含了数据和函数两种成员,与一般变量略有不同,但是对象所占据的内存空间只是用于存放数据成员的,函数成员不在每一个对象中存储副本。对象指针就是用于存放对象地址的变量。对象指针遵循一般变量指针的各种规则,声明对象指针的一般语法形式为:
类名 *对象指针名;

例如:

Point *pointPtr;
Point pl;
pointPtr = &pl;
  • 就像通过对象名来访问对象的成员一样,使用对象指针一样可以方便地访问对象的成员,语法形式为:
对象指针名 -> 成员名
  • 这种形式与"(*对象指针名).成员名"的访问形式是等价的。
例 6-12 使用指针来访问 Point类的成员
#include <iostream>
using namespace std;

class Point {   //类的定义
public: //外部接口
    Point(int x = 0, int y = 0) : x(x), y(y) { }    //构造函数
    int getX() const
    {
        return this->x;
    }
    int getY() const
    {
        return y;
    }
private:    //私有数据
    int x, y;
};

int main() {    //主函数
    Point a(4, 5);  //定义并初始化对象a
    Point* p1 = &a; //定义对象指针,用a的地址将其初始化
    cout << p1->getX() << endl; //利用指针访问对象成员
    cout << a.getX() << endl;   //利用对象名访问对象成员
    return 0;
}
  • 对象指针在使用之前,也一定要先进行初始化,让它指向一个已经声明过的对象,然后再使用。通过对象指针,可以访问到对象的公有成员。
this指针
  • this指针是一个隐含于每一个类的非静态成员函数中的特殊指针(包括构造函数和析构函数),它用于指向正在被成员函数操作的对象。

  • this指针实际上是类成员函数的一个隐含参数。在调用类的成员函数时,目的对象的地址会自动作为该参数的值,传递给被调用的成员函数,这样被调函数就能够通过this指针来访问目的对象的数据成员。对于常成员函数来说,这个隐含的参数是常指针类型的。

  • 每次对成员函数的调用都存在一个目的对象,this 指针就是指向这个目的对象的指针。回顾一下在例 6.4 中使用Point类数组来解决线性回归问题时的情况:在数组中,有多个 Point类的对象,使用数组下标来标识它们。对于每个对象,执行 getX 函数获取它的横坐标时,所使用的语句是:

return x;
  • 系统需要区分每次执行这条语句时被赋值的数据成员到底是属于哪一个对象,使用的就是这个this指针,对于系统来讲,每次调用都相当于执行的是:
return this x;
  • this指针明确地指出了成员函数当前所操作的数据所属的对象。实际过程是,this指针是成员函数的一个隐含形参,当通过一个对象调用成员函数时,系统先将该对象的地址通过该参数传递给成员函数,成员函数对对象的数据成员进行操作时,就隐含使用了this指针。

  • this是一个指针常量,对于常成员函数,this同时又是一个指向常量的指针。在成员函数中,可以使用*this来标识正在调用该函数的对象。

  • 当局部作用域中声明了与类成员同名的标识符时,对该标识符的直接引用代表的是局部作用域中所声明的标识符,这时为了访问该类成员,可以通过this指针。

指向类的非静态成员的指针
  • 类的成员自身也是一些变量、函数或者对象等,因此也可以直接将它们的地址存放到一个指针变量中,这样,就可以使指针直接指向对象的成员,进而可以通过这些指针访问对象的成员。

  • 指向对象成员的指针使用前也要先声明,再赋值,然后引用。因此首先要声明指向该对象所在类的成员的指针。声明指针语句的一般形式为:

类型说明符 类名::*指针名
类型说明符 (类名::*指针名) (参数表)
  • 声明了指向成员的指针之后,需要对其进行赋值,也就是要确定指向类的哪一个成员。对数据成员指针赋值的一般语法形式为:
指针名 = &类名::数据成员名;
  • 注意对类成员取地址时,也要遵守访问权限的约定,也就是说,在一个类的作用域之外不能够对它的私有成员取地址。

  • 对于一个普通变量,用"&"运算符就可以得到它的地址,将这样的地址赋值给相应的指针就可以通过指针访问变量。但是对于类的成员来说问题就要稍微复杂些。类的定义只确定了各个数据成员的类型、所占内存大小以及它们的相对位置,在定义时并不为数据成员分配具体的地址。因此经上述赋值之后,只是说明了被赋值的指针是专门用于指向哪个数据成员的,同时在指针中存放该数据成员在类中的相对位置(即相对于起始地址的地址偏移量),当然通过这样的指针现在并不能访问什么。

  • 由于类是通过对象而实例化的,在声明类的对象时才会为具体的对象分配内存空间,这时只要将对象在内存中的起始地址与成员指针中存放的相对偏移结合起来就可以访问到对象的数据成员了。访问数据成员时,这种结合可通过以下两种语法形式实现:

    对象名.*类成员指针名
    
    对象指针名 -> *类成员指针名
    

  • 成员函数指针在声明之后要用以下形式的语句对其赋值:
    指针名 = &类名::函数成员名;
    
  • 注意常成员函数与普通成员函数具有不同的类型,因此能够被常成员函数赋值的指针,需要在声明时明确写出const关键字。

  • 一个普通函数的函数名就表示它的起始地址,将起始地址赋给指针,就可以通过指针调用函数。类的成员函数虽然并不在每个对象中复制一份副本,但是由于需要确定this指针,因而必须通过对象来调用非静态成员函数。因此经过上述对成员函数指针赋值以后,也还不能用指针直接调用成员函数,而是需要首先声明类的对象,然后用以下形式的语句利用指针调用成员函数:

    对象名.*类成员指针名)(参数表
    
    对象指针名 -> *类成员指针名)(参数表
    

  • 成员函数指针的声明、赋值和使用过程中的返回值类型、函数参数表一定要互相匹配。
例 6-13 访问对象的公有成员函数的不同方式
#include <iostream>

using namespace std;

class Point {    //类的定义
public:    //外部接口
    Point(int x = 0, int y = 0) : x(x), y(y) {}    //构造函数
    int getX() const { return x; }    //返回x
    int getY() const { return y; }    //返回y
private:    //私有数据
    int x, y;
};

int main() {    //主函数
    Point a(4, 5);    //定义对象A
    Point *p1 = &a;    //定义对象指针并初始化
    int (Point::*funcPtr)() const = &Point::getX;    //定义成员函数指针并初始化
    cout << (a.*funcPtr)() << endl;        //(1)使用成员函数指针和对象名访问成员函数
    cout << (p1->*funcPtr)() << endl;    //(2)使用成员函数指针和对象指针访问成员函数
    cout << a.getX() << endl;            //(3)使用对象名访问成员函数
    cout << p1->getX() << endl;            //(4)使用对象指针访问成员函数

    return 0;
}
  • 例 6-13 的程序只给出了主函数部分,类的定义部分可以参看例6-12。本例中,声明了一个 Point类的对象a,分别通过对象名、对象的指针和函数成员的指针对对象 a的公有成员函数 getX 进行访问,程序运行输出的结果都是a的私有数据成员 x 的值4。请注意分析对象指针及成员指针的不同用法。
指向类的静态成员的指针
  • 对类的静态成员的访问是不依赖于对象的,因此可以用普通的指针来指向和访问静态成员。下面对例 5-4 稍做修改,形成例 6-14 和例 6-15 的程序,用于说明如何通过普通指针变量访问类的静态成员。
例 6-14 通过指针访问类的静态数据成员
//6_14.cpp
#include <iostream>
using namespace std;

class Point {   //Point类定义
public: //外部接口
    Point(int x = 0, int y = 0) : x(x), y(y) { //构造函数
        count++;
    }
    Point(const Point &p) : x(p.x), y(p.y) {    //拷贝构造函数
        count++;
    }
    ~Point() {  count--; }
    int getX() const { return x; }
    int getY() const { return y; }
    static int count;   //静态数据成员声明,用于记录点的个数

private:    //私有数据成员
    int x, y;
};

int Point::count = 0;   //静态数据成员定义和初始化,使用类名限定

int main() {    //主函数实现
    int *ptr = &Point::count;   //定义一个int型指针,指向类的静态成员
    Point a(4, 5);  //定义对象a
    cout << "Point A: " << a.getX() << ", " << a.getY();
    cout << " Object count = " << *ptr << endl; //直接通过指针访问静态数据成员

    Point b(a); //定义对象b
    cout << "Point B: " << b.getX() << ", " << b.getY();
    cout << " Object count = " << *ptr << endl;     //直接通过指针访问静态数据成员

    return 0;
}
例 6-15 通过指针访问类的静态函数成员
#include <iostream>
using namespace std;

class Point {   //Point类定义
public: //外部接口
    Point(int x = 0, int y = 0) : x(x), y(y) { //构造函数
        count++;
    }
    Point(const Point &p) : x(p.x), y(p.y) {    //拷贝构造函数
        count++;
    }
    ~Point() {  count--; }
    int getX() const { return x; }
    int getY() const { return y; }

    static void showCount() {       //输出静态数据成员
        cout << "  Object count = " << count << endl;
    }

private:    //私有数据成员
    int x, y;
    static int count;   //静态数据成员声明,用于记录点的个数
};

int Point::count = 0;   //静态数据成员定义和初始化,使用类名限定

int main() {    //主函数实现
    void (*funcPtr)() = Point::showCount;   //定义一个指向函数的指针,指向类的静态成员函数

    Point a(4, 5);  //定义对象A
    cout << "Point A: " << a.getX() << ", " << a.getY();
    funcPtr();  //输出对象个数,直接通过指针访问静态函数成员

    Point b(a); //定义对象B
    cout << "Point B: " << b.getX() << ", " << b.getY();
    funcPtr();  //输出对象个数,直接通过指针访问静态函数成员

    return 0;
}

6.3 动态内存分配

  • 虽然通过数组,可以对大量的数据和对象进行有效的管理,但是很多情况下,在程序运行之前,并不能够确切地知道数组中会有多少个元素。就拿线性回归的例子来讲,如果每次实验得到的数据对个数并不相同而且差别很大,Point类的对象数组到底声明为多大,就是一个很头疼的问题。如果数组声明得很大,比如有 200 个元素,但只需要处理10个点,就会造成很大的浪费;如果数组比较小,又影响对大量数据的处理。在C++中,动态内存分配技术可以保证程序在运行过程中按照实际需要申请适量的内存,使用结束后还可以释放,这种在程序运行过程中申请和释放的存储单元也称为堆对象,申请和释放过程一般称为建立和删除。

  • 在C++程序中建立和删除堆对象使用两个运算符:new 和 delete。

  • 运算符 new 的功能是动态分配内存,或者称为动态创建堆对象,语法形式为:

    new 数据类型初始化参数列表;
    

  • 该语句在程序运行过程中申请分配用于存放指定类型数据的内存空间,并根据初始化参数列表中给出的值进行初始化。如果内存申请成功,new 运算便返回一个指向新分配内存首地址的类型的指针,可以通过这个指针对堆对象进行访问;如果申请失败,会抛出异常(有关异常,将在第 12 章介绍)。

  • 如果建立的对象是一个基本类型变量,初始化过程就是赋值,例如:

int *point;
point= new int2;
  • 动态分配了用于存放 int类型数据的内存空间,并将初值 2 存入该空间中,然后将首地址赋给指针 point。

  • 对于基本数据类型,如果不希望在分配内存后设定初值,可以把括号省去,例如:

    int *point = new int;
    

  • 如果保留括号,但括号中不写任何数值,则表示用 0 对该对象初始化,例如:
    int *point = new int();
    
  • 如果建立的对象是某一个类的实例对象,就是要根据初始化参数列表的参数类型和个数调用该类的构造函数。

  • 在用 new 建立一个类的对象时,如果该类存在用户定义的默认构造函数,则"new T "和"new T()"这两种写法的效果是相同的,都会调用这个默认构造函数。但若用户未定义默认构造函数,使用"new T "创建对象时,会调用系统生成的隐含的默认构造函数;使用"new T()"创建对象时,系统除了执行默认构造函数会执行的那些操作外,还会为基本数据类型和指针类型的成员用 0 赋初值,而且这一过程是递归的。也就是说,如果该对象的某个成员对象也没有用户定义的默认构造函数,那么对该成员对象的基本数据类型和指针类型的成员,同样会被以 0 赋初值。

  • 运算符 delete用来删除由 new 建立的对象,释放指针所指向的内存空间。格式为:

    delete 指针名;
    

  • 如果被删除的是对象,该对象的析构函数将被调用。对于用 new 建立的对象,只能使用 delete进行一次删除操作,如果对同一内存空间多次使用 delete 进行删除将会导致运行错误。

  • 用 new 分配的内存,必须用 delete 加以释放,否则会导致动态分配的内存无法回收,使得程序占据的内存越来越大,这叫做"内存泄漏"。

例 6-16 动态创建对象
#include <iostream>
using namespace std;
class Point {
public:
    Point() : x(0), y(0) {
        cout<<"Default Constructor called."<<endl;
    }
    Point(int x, int y) : x(x), y(y) {
        cout<< "Constructor called."<<endl;
    }
    ~Point() { cout<<"Destructor called."<<endl; }
    int getX() const { return x; }
    int getY() const { return y; }
    void move(int newX, int newY) {
        x = newX;
        y = newY;
    }
private:
    int x, y;
};

int main() {
    cout << "Step one: " << endl;
    Point *ptr1 = new Point;//动态创建对象,没有给出参数列表,因此调用缺省构造函数
    cout << (*ptr1).getX() << endl;
    delete ptr1;    //删除对象,自动调用析构函数

    cout << "Step two: " << endl;
    ptr1 = new Point(1,2);  //动态创建对象,并给出参数列表,因此调用有形参的构造函数
    delete ptr1;    //删除对象,自动调用析购函数

    return 0;
}
  • 使用运算符 new 也可以创建数组类型的对象,这时需要给出数组的结构说明。用 new 运算符动态创建一维数组的语法形式为:
    new 类型名[数组长度];
    
  • 其中数组长度指出了数组元素个数,它可以是任何能够得到正整数值的表达式。

  • 用 new 动态创建一维数组时,在方括号后仍然可以加小括号"()",但小括号内不能带任何参数。是否加"()"的区别在于,不加"()",则对数组每个元素的初始化,与执行"new T "时所进行初始化的方式相同;加"()",则与执行"new T()"所进行初始化的方式相同。例如,如果这样动态生成一个整型数组:

int *p = new int[10] ();
  • 则可以方便地为动态创建的数组用0 值初始化。

  • 如果是用 new 建立的数组,用 delete删除时在指针名前面要加"[]",格式如下:

    delete[] 指针名;
    

例6-17 动态创建对象数组

#include <iostream>
using namespace std;
class Point {
public:
    Point() : x(0), y(0) {
        cout << "Default Constructor called." << endl;
    }

    Point(int x, int y) : x(x), y(y) {
        cout << "Constructor called." << endl;
    }

    ~Point() {
        cout << "Destructor called." << endl;
    }

    int getX() const { return x; }

    int getY() const { return y; }

    void move(int newX, int newY) {
        x = newX;
        y = newY;
        cout << x << y << endl;
    }

private:
    int x, y;
};

int main() {
    Point* ptr = new Point[2];    //创建对象数组
    ptr -> move(5, 10);        //通过指针访问数组元素的成员
    (ptr + 1) -> move(15, 20);    //通过指针访问数组元素的成员
    cout << "Deleting..." << endl;
    delete[] ptr;    //删除整个对象数组
    return 0;
}
运行结果如下:
Default Constructor called.
Default Constructor called.
Deleting...
Destructor called.
Destructor called.

  • 这里利用动态内存分配操作实现了数组的动态创建,使得数组元素的个数可以根据运行时的需要而确定。但是建立和删除数组的过程使得程序略显烦琐,更好的方法是将数组的建立和删除过程封装起来,形成一个动态数组类。

  • 另外,在动态数组类中,通过类的成员函数访问数组元素,可以在每次访问之前检查一下下标是否越界,使得数组下标越界的错误能够及早被发现。这种检查,可以通过C++的assert来进行。assert的含义是"断言",它是标准C++的cassert头文件中定义的一个宏,用来判断一个条件表达式的值是否为true,如果不为true,则程序会中止,并且报告出错误,这样就很容易将错误定位。一个程序一般可以以两种模式编译------调试(debug)模式和发行(release)模式,assert只在调试模式下生效,而在发行模式下不执行任何操作,这样兼顾了调试模式的调试需求和发行模式的效率需求。

  • 由于 assert 只在调试模式下生效,一般用 assert 只是检查程序本身的逻辑错误,而用户的不当输入造成的错误,则应当用其他方式加以处理。

例 6-18 动态数组类

本实例中创建的动态数组类ArrayOfPoints与Point类存在着使用关系。

#include <iostream>
#include <cassert>
using namespace std;
class Point {
public:
    Point() : x(0), y(0) {
        cout<<"Default Constructor called."<<endl;
    }
    Point(int x, int y) : x(x), y(y) {
        cout<< "Constructor called."<<endl;
    }
    ~Point() { cout<<"Destructor called."<<endl; }
    int getX() const { return x; }
    int getY() const { return y; }
    void move(int newX, int newY) {
        x = newX;
        y = newY;
    }
private:
    int x, y;
};
//动态数组类
class ArrayOfPoints {
public:
    ArrayOfPoints(int size) : size(size) {
        points = new Point[size];
    }
    ArrayOfPoints(ArrayOfPoints &aop)
    {
        size = aop.size;
        points = new Point[size];
        for(int i = 0; i < size; ++i)
            points[i] = aop.element(i);
    }
    ~ArrayOfPoints() {
        cout << "Deleting..." << endl;
        delete[] points;
    }
    //获得下标为index的数组元素
    Point &element(int index) {
        assert(index >= 0 && index < size); //如果数组下标越界,程序中止
        return points[index];
    }
private:
    Point *points;  //指向动态数组首地址
    int size;       //数组大小
};

int main() {
    int count;
    cout << "Please enter the count of points: ";
    cin >> count;
    ArrayOfPoints points(count);    //创建对象数组
    points.element(0).move(5, 0);   //通过访问数组元素的成员
    points.element(1).move(15, 20); //通过类访问数组元素的成员
    return 0;
}

运行结果如下:

Please enter the number of points: 2
Default Constructor called.
Default Constructor called.
Deleting...
Destructor called.
Destructor called.
  • 在main()函数中,只是建立一个 ArrayOfPoints类的对象,对象的初始化参数size指定了数组元素的个数,创建和删除对象数组的过程都由ArrayOfPoints类的构造函数和析构函数完成。这虽然使 main()函数更为简洁,但是对数组元素的访问形式"points.element(0)"却显得啰唆。如果希望像使用普通数组一样,通过下标操作符"[]"来访问数组元素,就需要对下标操作符进行重载。

  • 用new 操作也可以创建多维数组,形式如下:

    new 类型名 T[数组第 1 维长度][数组第 2维长度]...;
    

  • 其中数组第 1 维长度可以是任何结果为正整数的表达式,而其他各维数组长度必须是结果为正整数的常量表达式。如果内存申请成功,new 运算返回一个指向新分配内存的首地址的指针,但不是 T 类型指针,而是一个指向 T 类型数组的指针,数组元素的个数为除最左边一维外各维下标表达式的乘积。例如,下列语句
float *fp;
fp = new float[10][25][10];
  • 便会产生错误。这是因为,在这里 new 操作产生的是指向一个 25×10 的二维float类型数组的指针,而fp是一个指向float型数据的指针。正确的写法应该是:
float (*cp) [25][10];
cp = new float[10][25][10];
  • 如此得到的指针 cp,既可以作为指针使用,也可以像一个三维数组名一样使用,请看如下程序。

例 6-19 动态创建多维数组。

#include <iostream>
using namespace std;
int main() {
    //float (*cp)[9][8] = new float[8][9][8];
    float*** cp = new float** [8];
    for (int i = 0; i < 8; i++) {
        *(cp + i) = new float* [9];
        for (int j = 0; j < 9; j++) {
            *(*(cp + i) + j) = new float [8];
            for (int k = 0; k < 8; k++) {
                //以指针形式数组元素
                *(*(*(cp + i) + j) + k) = static_cast<float>(i * 100 + j * 10 + k);
            }
        }
    }
    for (int i = 0; i < 8; i++) {
        for (int j = 0; j < 9; j++) {
            for (int k = 0; k < 8; k++) {
                //将指针cp作为数组名使用,通过数组名和下标访问数组元素
                cout << cp[i][j][k] << "  ";
            }
            cout << endl;
            delete[] *(*(cp + i) + j);
        }
        cout << endl;
        delete[] *(cp + i);
    }
    delete[] cp;
    return 0;
}

6.4 用 vector创建数组对象

  • 数组是继承于C语言的一种表示群体数据的方法,具有简单、高效的优点,但无论是静态数组,还是用new动态创建的数组,都难以检测下标越界的错误,在实际应用中常常造成困扰。例6-18提供了一个很好的例子,它通过将动态数组封装成一个类,允许在调试状态下访问数组元素时检查下标越界的错误。然而,它只能表示Point类型的动态数组,若要处理其他类型的动态数组,还需创建新的动态数组类,这是很烦琐的重复性工作。事实上,C++标准库也提供了被封装的动态数组---vector,而且这种被封装的数组可以具有各种类型,这就使我们免去了那些重复性工作。vector不是一个类,而是一个类模板。用vector定义动态数组的形式为:
vector<元素类型> 数组对象名(数组长度);
  • 尖括号中的类型名表示数组元素的类型。数组长度是一个表达式,表达式中可以包含变量。例如,下面定义了一个大小为 10 的int型动态数组对象 arr:
int x = 10;
vector<int> arr(x);
  • 细节与普通数组不同的是,用 vector 定义的数组对象的所有元素都会被初始化。如果数组的元素类型为基本数据类型,则所有元素都会被以 0 初始化;如果数组元素为类类型,则会调用类的默认构造函数初始化。因此如果以此形式定义的vector动态数组,需要保证作为数组元素的类具有默认构造函数。另外,初值也可以自己指定,但只能为所有元素指定相同初值,形式为:
    vector<元素类型> 数组对象名数组长度,元素初值;
    
  • 对 vector 数组对象元素的访问方式,与普通数组具有相同的形式:
    数组对象名[下标表达式]
    
  • 但是 vector 数组对象的名字表示的就是一个数组对象,而非数组的首地址,因为数组对象不是数组,而是封装了数组的对象。

  • vector定义的数组对象具有一个重要的成员函数 size(),它会返回数组的大小。下面通过一个例子来展示 vector 的用法。

例 6-20 vector应用举例
#include <iostream>
#include <vector>
using namespace std;

//计算数组arr中元素的平均值
double average(const vector<double> &arr) {
    double sum = 0;
    for (unsigned i = 0; i < arr.size(); i++)
        sum += arr[i];
    return sum / arr.size();
}

int main() {
    unsigned n;
    cout << "n = ";
    cin >> n;

    vector<double> arr(n);  //创建数组对象
    cout << "Please input " << n << " real numbers:" << endl;
    for (unsigned i = 0; i < n; i++)
        cin >> arr[i];

    cout << "Average = " << average(arr) << endl;
    return 0;
}

运行结果如下:

n = 5
Please input 5 real numbers:
1.2 3.1 5.3 7.9 9.8
Average = 5.46
  • 本例中,在主函数里创建了动态数组对象 arr,然后通过键盘输入的方式为数组元素赋值,再调用 average 函数计算数组元素的平均值。

  • vector还具有很多其他强大的功能,例如它的大小可以扩展。

6.5 深复制与浅复制

  • 虽然之前已经介绍过复制构造函数,但是在此前大多数简单例题中都不需要特别编写复制构造函数,隐含的复制构造函数足以实现对象间数据元素的一一对应复制。因此,读者对于编写复制构造函数的必要性,可能一直存在疑问。其实隐含的复制构造函数并不总是适用的,因为它完成的只是浅复制。什么是浅复制呢?我们还是通过下面这个例题来说明。
例 6-21 对象的浅复制

这里仍以 ArrayOfPoints 类来实现动态数组,在 main()函数中利用默认的复制构造函数建立两组完全相同的点,然后观察程序的效果。

#include <iostream>
#include <cassert>
using namespace std;
class Point {
public:
    Point() : x(0), y(0) {
        cout << "Default Constructor called." << endl;
    }
    Point(int x, int y) : x(x), y(y) {
        cout << "Constructor called." << endl;
    }
    ~Point() { cout << "Destructor called." << endl; }
    int getX() const { return x; }
    int getY() const { return y; }
    void move(int newX, int newY) {
        x = newX;
        y = newY;
    }
private:
    int x, y;
};
class ArrayOfPoints {
public:
    ArrayOfPoints(int size) : size(size) {
        points = new Point[size];
    }
    ArrayOfPoints(const ArrayOfPoints &aop) : size(aop.size), points(aop.points){}
    ~ArrayOfPoints() {
        cout << "Deleting..." << endl;
        delete[] points;
    }
    //获得下标为index的数组元素
    Point& element(int index) {
        assert(index >= 0 && index < size); //如果数组下标越界,程序中止
        return points[index];
    }
private:
    Point* points;  //指向动态数组首地址
    int size;       //数组大小
};
int main() {
    int count;
    cout << "Please enter the count of points: ";
    cin >> count;
    ArrayOfPoints pointsArray1(count);      //创建对象数组
    pointsArray1.element(0).move(5, 10);
    pointsArray1.element(1).move(15, 20);

    ArrayOfPoints pointsArray2 = pointsArray1; //创建对象数组副本
    cout << "Copy of pointsArray1:" << endl;
    cout << "Point_0 of array2: " << pointsArray2.element(0).getX() << ", "
        << pointsArray2.element(0).getY() << endl;
    cout << "Point_1 of array2: " << pointsArray2.element(1).getX() << ", "
        << pointsArray2.element(1).getY() << endl;

    pointsArray1.element(0).move(25, 30);
    pointsArray1.element(1).move(35, 40);
    cout << "After the moving of pointsArray1:" << endl;
    cout << "Point_0 of array2: " << pointsArray2.element(0).getX() << ", "
        << pointsArray2.element(0).getY() << endl;
    cout << "Point_1 of array2: " << pointsArray2.element(1).getX() << ", "
        << pointsArray2.element(1).getY() << endl;

    return 0;
}
运行结果如下:

Please enter the number of points: 2
Default Constructor called.
Default Constructor called.
Copy of pointsArray1:
Point_0 of array2: 5, 10
Point_1 of array2: 15, 20
After the moving of pointsArray1:
Point_0 of array2: 25, 30
Point_1 of array2: 35, 40
Deleting...
Destructor called.
Destructor called.
Deleting...
  • 在一些编译环境下,接下来程序会出现异常,也就是运行错误。原因在哪里呢?首先来看一看上面的输出结果,程序中pointsArray2 是从 pointsArrayl 复制来的,它们的初始状态当然是一样的,但是当程序通过 move 函数移动 pointsArrayl 中的第一组点之后, pointsArray2 中的第二组点也被移动到了同样的位置。这就说明两组点之间存在着某种必然的联系,而这种联系并不是我们所期望的,也许这就是程序最终出错的根源吧。

  • 这里建立对象 pointsA rray2 时调用的是默认的复制构造函数,实现对应数据项的直接复制。

  • 浅复制还有更大的弊病,在程序结束之前 pointsArrayl 和 pointsArray2 的析构函数会自动被调用,动态分配的内存空间会被释放。由于两个对象共用了同一块内存空间,因此该空间被两次释放,于是导致运行错误。解决这一问题的方法是编写复制构造函数,实现"深复制"。例 6-22 是实现深复制的程序。

例 6-22 对象的深复制

#include <iostream>
#include <cassert>
using namespace std;
class Point {
public:
    Point() : x(0), y(0) {
        cout << "Default Constructor called." << endl;
    }
    Point(int x, int y) : x(x), y(y) {
        cout << "Constructor called." << endl;
    }
    ~Point() { cout << "Destructor called." << endl; }
    int getX() const { return x; }
    int getY() const { return y; }
    void move(int newX, int newY) {
        x = newX;
        y = newY;
    }
private:
    int x, y;
};
class ArrayOfPoints {
public:
    ArrayOfPoints(const ArrayOfPoints& v);
    ArrayOfPoints(int size) : size(size) {
        points = new Point[size];
    }

    ~ArrayOfPoints() {
        cout << "Deleting..." << endl;
        delete[] points;
    }
    //获得下标为index的数组元素
    Point& element(int index) {
        assert(index >= 0 && index < size); //如果数组下标越界,程序中止
        return points[index];
    }
private:
    Point* points;  //指向动态数组首地址
    int size;       //数组大小
};
ArrayOfPoints::ArrayOfPoints(const ArrayOfPoints& v) {
    size = v.size;
    points = new Point[size];
    for (int i = 0; i < size; i++)
        points[i] = v.points[i];
}
int main() {
    int count;
    cout << "Please enter the count of points: ";
    cin >> count;
    ArrayOfPoints pointsArray1(count);      //创建对象数组
    pointsArray1.element(0).move(5, 10);
    pointsArray1.element(1).move(15, 20);

    ArrayOfPoints pointsArray2 = pointsArray1; //创建对象数组副本
    cout << "Copy of pointsArray1:" << endl;
    cout << "Point_0 of array2: " << pointsArray2.element(0).getX() << ", "
        << pointsArray2.element(0).getY() << endl;
    cout << "Point_1 of array2: " << pointsArray2.element(1).getX() << ", "
        << pointsArray2.element(1).getY() << endl;

    pointsArray1.element(0).move(25, 30);
    pointsArray1.element(1).move(35, 40);
    cout << "After the moving of pointsArray1:" << endl;
    cout << "Point_0 of array2: " << pointsArray2.element(0).getX() << ", "
        << pointsArray2.element(0).getY() << endl;
    cout << "Point_1 of array2: " << pointsArray2.element(1).getX() << ", "
        << pointsArray2.element(1).getY() << endl;

    return 0;
}
- 从这次的运行结果可以看出,程序实现的是深复制:移动 pointsArray1 中的点不再影响 pointsArray2 中的点,而且程序结束前分别释放 pointsArray1 和pointsArray2 中的内存空间,也不再引起错误。

6.6 字符串

  • 与 C 语言一样,在 C++ 的基本数据类型变量中没有字符串变量。那么如何存储和处理字符串数据呢?在 C 语言中是使用字符型数组来存放字符串的,C++程序中也仍然可以沿用这种办法。不仅如此,标准C++库中还预定义了strin g 类。本节就来介绍这两种方法。

1. 用字符数组存储和处理字符串

  • 第 2 章中介绍过,字符串常量是用一对双引号括起来的字符序列。例如,"abcd","China","This is a string."都是字符串常量。它在内存中的存放形式是,按串中字符的排列次序顺序存放,每个字符占一个字节,并在末尾添加\0作为结尾标记。这实际上是一个隐含创建的类型为char的数组,一个字符串常量就表示这样一个数组的首地址。因此,可以把字符串常量赋给字符串指针,由于常量值是不能改的,应将字符串常量赋给指向常量的指针,例如:
const char *STRING1 = "This is a string.";

这时,可以直接对 STRIN G1 进行输出,例如:

cout << STRING1;
  • 字符串变量也可以用类似方式来表示。如果创建一个char数组,每个元素存放字符串的一个字符,在末尾放置一个\,便构成了C++字符串。它的存储方式与字符串常量无异,但由于它是程序员创建的数组,因此可以改写其内容,因而这就是字符串变量而非常量了。这时要注意,用于存放字符串的数组其元素个数应该不小于字符串的长度(字符个数)加1。对字符数组进行初始化赋值时,初值的形式可以是以逗号分隔的 ASCII 码或字符常量,也可以是整体的字符串常量(这时末尾的\0是隐含的)。下面列出的语句都可以创建一个初值为"program"的字符串变量,3 种写法是等价的。
char str[8] = {'p', 'r', 'o', 'g', 'r', 'a', 'm', '\0'};
char str[8] = "program";
char str[] = "program";
  • 尽管对用字符数组表示的字符串进行初始化还比较容易、直观,但进行许多其他字符串操作时却比较麻烦。执行很多字符串操作需要借助 cstring 头文件中的字符串处理函数。例如将一个字符串的内容复制到另一个字符串需要用 strcpy函数,按辞典顺序比较两个的大小需要用 strcmp 函数,将两个字符串连接起来需要用 strcat 函数。另外,当字符串长度很不确定时,需要用 new 来动态创建字符数组,最后还要用delete释放,这些都相当烦琐。C++对这些烦琐的操作进行了封装,形成了 string 类,可以更加方便地操作字符串。

2. string 类

  • 使用数组来存放字符串,调用系统函数来处理字符串,毕竟显得不方便,而且数据与处理数据的函数分离也不符合面向对象方法的要求。为此,C++标准类库将面向对象的串的概念加入到 C++语言中,预定义了字符串类(string 类)。string类提供了对字符串进行处理所需要的操作。使用 string 类需要包含头文件 string。string 类封装了串的属性并提供了一系列允许访问这些属性的函数。

  • 下面简要介绍一下 string 类的构造函数、几个常用的成员函数和操作。为了简明起见,函数原型是经过简化的,与头文件中的形式不完全一样。读者如果需要详细了解,可以查看编译系统的联机帮助。

构造函数的原型
string();
string(const string &rhs);
string(const char *s);
string(const string &rhs, unsigned int pos, unsigned int n);    //从rhs中的位置pos开始取n个字符,用来初始化string类对象
string(const char *s, unsigned int n);  //用指针s所指向的字符串中的前n个字符初始化string类的对象
string(unsigned int n, char c);     //将参数c中的字符重复n次,用来初始化string类对象
  • 由于 string 类具有接收 const char*类型的构造函数,因此字符串常量和用字符数组表示的字符串变量都可以隐含地转换为 string 对象。例如,可以直接使用字符串常量对 string对象初始化:
string str = "Hello World!";
string 类的操作符
  • string类提供了丰富的操作符,可以方便地完成字符串赋值(内容复制)、字符串连接、字符串比较等功能。表 6-1 列出了 string 类的操作符及其说明。
操作符 描述 示例
= 赋值操作符 s2 = s1
+ 连接操作符 s3 = s1 + s2
+= 连接赋值操作符 s1 += " World"
[ ] 下标操作符 s[0]
== 比较操作符(等于) if (s1 == s2)
!= 比较操作符(不等于) if (s1 != s2)
< 比较操作符(小于) if (s1 < s2)
> 比较操作符(大于) if (s1 > s2)
<= 比较操作符(小于等于) if (s1 <= s2)
>= 比较操作符(大于等于) if (s1 >= s2)
  • 之所以能够通过上面的操作符来操作 string 对象,是因为 string 类对这些操作符进行了重载。

  • 这里所说的对两串大小的比较,是依据字典顺序的比较。设有两字符串 s1 与 s2,二者大小的比较规则如:

  • 如果 s1 与 s2 长度相同,且所有字符完全相同,则 s1= s2。
  • 如果 s1 与 s2 所有字符不完全相同,则比较第一对不相同字符的 ASCII 码,较小字符所在的串为较小的串。
  • 如果 s1 的长度 n1 小于 s2 的长度 n2,且两字符串的前 n1 个字符完全相同,则s1 < s2。
常用成员函数功能简介
  • string类的成员函数有很多,每个函数都有多种重载形式,这里只举例列出其中一小部分,对于其他函数和重载形式就不一一列出了,读者在使用时可以查看联机帮助。在下面的函数说明中,将成员函数所属的对象称为"本对象",其中存放的字符串称为"本字符串"。
string append (const char *s);  //将字符串s添加在string尾部
string assign (const char *s);  //将s所指向的字符串赋值给本对象
int compare (const string &str) const;  //比较本string与str的大小,本string小时返回负数,本string大时返回正数,相等时返回0
string &insert (unsigned int p0, const char *s);    //将s所指向的字符串插入在本串中位置p0之前
string substr (unsigned int pos, unsigned int n) const; //取本string中位置pos开始的n个字符,构成新的string对象返回
unsigned int find (const basic_string &str) const;  //查找并返回str在本string中第一次出现的位置
unsigned int length() const;    //返回本string的长度
void swap (string &str);    //将本string与str中的字符串进行交换
例 6-23 string 类应用举例
#include <string>
#include <iostream>
using namespace std ;

//根据value的值输出true或false,title为提示文字
inline void test(const char *title, bool value) {
    cout << title << " returns " << (value ? "true" : "false") << endl;
}

int main() {
    string s1 = "DEF";
    cout << "s1 is " << s1 << endl;

    string s2;
    cout << "Please enter s2: ";
    cin >> s2;
    cout << "length of s2: " << s2.length() << endl;

    //比较运算符的测试
    test("s1 <= \"ABC\"", s1 <= "ABC");
    test("\"DEF\" <= s1", "DEF" <= s1);
    //连接运算符的测试
    s2 += s1;
    cout << "s2 = s2 + s1: " << s2 << endl;
    cout << "length of s2: " << s2.length() << endl;
    return 0;
}

运行结果:

s1 is DEF
Please enter s2: 123
length of s2: 3
s1 <= "ABC" returns false
"DEF" <= s1 returns true
s2 = s2 + s1: 123DEF
length of s2: 6
  • 上面的例子中,直接使用 cin的">>"操作符从键盘输入字符串,以这种方式输入时,空格会作为输入的分隔符。例如,如果从键盘输入字符串"123 ABC",那么被读入的字符串实际上是"123","ABC"将在下一次从键盘输入字符串时被读入。

  • 如果希望从键盘读入字符串,直到行末为止,不以中间的空格作为输入的分隔符,可以使用头文件 string 中定义的 getline。例如,如果将上面的代码中输入 s2 的语句改为下列语句,就能达到这一目的。

getline(cin, s2);
  • 这时,如果从键盘输入字符串"123 ABC",那么整个字符串都会被赋给 s2。这实际表示输入字符串时只以换行符作为分隔符。getline还允许在输入字符串时增加其他分隔符,使用方法是把可以作为分隔符的字符作为第 3 个参数传递给 getline。例如,使用下面的语句,可以把逗号作为分隔符。
getline(cin, s2, ',');
例 6-24 用 getline 输入字符串

#include <iostream>
#include <string>
using namespace std;

int main() {
    for (int i = 0; i < 2; i++) {
        string city, state;
        getline(cin, city, ',');
        getline(cin, state);
        cout << "City: " << city << "   State: " << state << endl;
    }
    return 0;
}
运行结果:
Beijing, China
City: Beijing   State:  China
New York, United States
City: New York   State:  United States

6.7 深度探索

1. 指针与引用

  • 引入引用概念时,曾经将引用介绍为其他变量的别名。但是对于一个确定的引用来说,它可能在不同的时候表示不同变量的别名,因此一定要在内存中为引用本身分配空间,来标识它所引用的变量。在程序运行时,变量只能依靠地址来区别,因此,只有通过存储被引用变量的地址,在运行时才能准确定位被引用的变量。引用本身所占用的内存空间中,存储的就是被引用变量的地址,这和指针变量所存储的内容具有相同的性质。

  • 指针是 C 语言本身就有的一个特性,C++在继承了 C 语言指针的同时,引入了引用。指针存储的是地址,这一点无论在语言概念上,还是在运行时的实现机制上,都是一致的,所以说指针是一种底层的机制。引用则是一种较高层的机制,在语言概念上它是另一变量的"别名",把地址这一概念隐藏起来了,但在引用运行时的实现机制中,还不得不借助于地址。二者可以说是殊途同归,差异主要是语言形式上的,最后都是靠存储地址来实现的。

  • 除了语言形式上的差异外,引用与指针的一个显著区别是,普通指针可以多次被赋值,也就是说可以多次更改它所指向的对象,而引用只能在初始化时指定被引用的对象,其后就不能更改。因此,引用的功能,与一个指针常量差不多。

  • 需要指出的是,虽然在"读取 v 的地址"这一用途上 p 和& r是等价的,但 p 和& r却具有不同的含义,p 可以再被取地址,而 &r 则不行。也就是说,引用本身(而非被引用的对象)的地址是不可以获得的,引用一经定义后,对它的全部行为,全是针对被引用对象的,而引用本身所占用的空间则被完全隐藏起来了。因此,引用的功能还是要比指针常量略差一点。

  • 注意只有常引用,而没有引用常量,也就是说,不能用 T & const 作为引用类型。这是因为引用只能在初始化时指定它所引用的对象,其后则不能再更改,这使得引用本身(而非被引用的对象)已经具有常量性质了。

  • 可以肯定地说,用引用能实现的功能,用指针都可以实现。那么C++为什么还要引入引用这一特性呢?

  • 指针是一种底层机制,正因为其底层,所以使用起来很灵活,功能强大。然而,灵活不一定好用。例如,如果希望使用指针作为函数参数达到数据双向传递或减少传递开销的目的,则在主调函数中需要用"&"操作符传递参数,在被调函数中需要用"*"操作符来访问数据,程序会显得烦琐。而且在这类用途之下,指针的算术运算尽管不需要使用,但如果不慎被误用,则编译器并不会给出提示,这样会造成不必要的麻烦,指针的赋值运算亦是如此。虽然将指针设为常量可避免赋值运算被误用,但那又会使参数表变得烦琐,可读性降低。为了能够满足更加方便、安全地处理数据双向传递,减少参数传递开销这样的简单需求,C++对指针做了简单的包装,引入了引用。读者可以比较一下下面两段等价的代码,就可看出它们的繁简。

//使用指针
void swap(int *const pa, int *const pb) {
    int temp = *pa;
    *pa = *pb;
    *pb = temp;
}
int main() {
    ...
    swap(&a, &b);
    ...
}
//使用引用
void swap(int &ra, int &rb) {
    int temp = ra;
    ra = rb;
    rb = temp;
}
int main() {
    ...
    swap(a, b);
    ...
}
  • 对于数据参数传递、减少大对象的参数传递开销这两个用途来说,引用可以很好地代替指针,使用引用比指针更加简洁、安全。其中,如果仅仅是为了后一个目的,一般来说应当使用常引用作为参数。

  • 但有些时候,引用还是不能替代指针,这时还需要使用指针。这样的情况主要包括以下几种。

  • 如果一个指针所指向的对象,需要用分支语句加以确定,或者在中途需要改变它所指向的对象,那么在它初始化之后需要为它赋值,而引用只能在初始化时指定被引用的对象,所以不能胜任。

  • 有时一个指针的值可能是空指针,例如当把指针作为函数的参数类型或返回类型时,有时会用空指针表达特定的含义(有兴趣的读者可以查看 ctime 头文件中的 time 函数),而没有空引用之说,这时引用不能胜任。

  • 使用函数指针,由于没有函数引用,所以函数指针无法被引用替代。

  • 用 new 动态创建的对象或数组,需要用指针来存储它的地址。

  • 以数组形式传递大批量数据时,需要用指针类型接收参数。

  • 对于后面两种情况,指针并非完全不能用引用代替。例如,可以这样写:

T &s = *(new T());
delete &s;
  • 但是这样显得很不自然,矫揉造作,有为用引用而用引用之嫌,这种用法应当避免。

2. 指针的安全性隐患及其应对方案

  • 指针这样一种底层机制,虽然为程序带来了很大的灵活性,但灵活意味着较少的约束,因此对指针的不慎使用,常常会带来一些安全性问题。本节把这些可能发生的安全性问题分为 3 大类,一一加以分析,并指出应对方案。
地址安全性
  • 我们通常使用的变量,其地址是由编译器分配的,引用变量时,编译器会使用适当的地址,由编译器保证所引用的地址是分配给这个变量的有效地址,而不会访问到不允许访问的地址,也不会访问到其他变量的地址。而使用指针时,指针所存储的地址是由程序在运行时确定的。如果程序没有给指针赋予确定的有效地址,就会造成地址安全性隐患。

  • 如果一个指针未赋初值就被使用,就会造成地址安全性隐患。而且,一个具有动态生存期的普通变量如果不赋初值就被使用,程序的结果将是不确定的,同样会造成地址安全性隐患,在这一点上,指针并没有特殊性。

  • 造成地址安全性隐患的另一个原因是指针的算术运算。首先,指针算术运算的用途,一定要限制在通过指向数组中某个元素的指针,得到指向同一个数组中另一个元素的指针。指针算术运算的其他用法,都会得到不确定的结果。

  • 即使把指针的算术运算限制在这一用途之内,仍然存在地址安全性隐患,最典型的问题就是数组下标越界。无论是大小固定的静态数组,还是用 new 分配的动态数组,访问数组中除了第一个元素外的其他元素,都要通过对首地址指针进行算术运算。算术运算后得到的地址,如果超出了数组的空间范围,就会发生错误。这类错误往往不易查出,例如,由于对数组 a 进行下标越界的访问而导致 b 变量的值不慎被改写,则一般只能直接观察到 b 变量值的异常,而如果想找出这一异常是由对a的不当访问造成的,则需费一番周折了。

  • 解决这一安全隐患的办法是,尽量不直接通过指针来使用数组,而是使用封装的数组(如 6.4 节介绍的 vector),这样使得数组下标越界的错误很容易被检测出。即使直接使用数组,也应当像例 6-18 那样,在访问数组元素前检查数组下标。

类型安全性
  • 对于普通变量来说,由于每个变量都有明确的类型,每个变量又都有明确的地址,因此编译器保证只把每段内存单元中存储的数据当作同一种类型来处理。例如存进去的时候是用浮点数的格式,读出来并参加运算时,也一定被当成浮点数。唯一的例外是联合体。而使用指针时,由于指针允许做类型的隐含或显式转换,安全问题就出现了。

  • 基本数据类型和类类型也都有类型转换的情况,为什么没有类型安全性问题呢?那是因为它们所做的转换是基于内容的转换。例如:

int i = 2;
float x = static_cast<float> (i);
  • 整型的 2 和双精度浮点型的 2,在内存中是由不同的二进制序列表示的。不过,在执行 static_cast<float> (i)这一操作时,编译器会生成目标代码,将 i 的整型的二进制表示,转换成浮点型的二进制表示,这种转换叫做基于内容的转换。但是有指针参加的转换,情况就不大一样了,例如:
int i = 2;
float *p = reinterpret_cast<float*> (&i);
  • reinterpret_cast是和 static_cast并列的一种类型转换操作符,它可以将一种类型的指针转换为另一种类型的指针,这里把 int 类型的 & i转换为 float 类型。这个转换是怎么进行的呢?无论是int类型的指针,还是float类型的指针,存储的都是一个地址,它们的区别只是相应地址中的数据被解释为不同类型而已。因此,这里的类型转换,无外乎就是把&i得到的地址值直接作为转换结果,并为这一结果赋予 float* 类型。这种转换的结果就是,p 作为浮点型指针,却指向了整型变量i。如果通过 p 访问整型变量 i,所执行的操作只能是针对浮点型的,这能不出问题吗?
  • reinterpret_cast 不仅可以在不同类型对象的指针之间转换,还可以在不同类型函数的指针之间、不同类数据成员的指针之间、不同类函数成员的指针之间、不同类型的引用之间相互转换。reinterpret_cast的转换过程,在C++标准中未明确规定,会因编译环境而异。C++标准只保证用reinterpret_cast操作符将 A 类型的 p 转换为 B 类型 q ,再用 reinterpret_cast操作符将 B 类型的 q 转换为 A 类型的 r后,应当有(p==r)成立。
  • reinterpret_cast所做的转换,一般只用于帮助实现一些非常底层的操作,在绝大多数情况下,用reinterpret_cast在不同类型的指针之间转换的行为,都是应当避免的。C++之所以要将reinterpret_cast 所能执行的转换操作和 static_cast分开,就是因为reinterpret_cast具有很大的危险性和不确定性,而static_cast基本上是安全的和确定的。
  • 但是,static_cast也并非绝对安全。即使不用reinterpret_cast,类型安全性仍然存在,这是因为有void指针的存在。任何类型的指针都可以隐含地转换为void 指针,例如:
int i = 2;
void *vp = &i;
  • 这两条语句本身没有安全性问题,因为 void指针在语义上就是所指向对象内容的数据类型不确定的指针,因此它可以指向任何类型的对象。然而,通过void 指针不能对它所指向的对象进行任何操作,在执行操作前,还需先将 void指针转换为具体类型的指针。C++不允许 void指针到具体类型指针的转换隐含地发生,这种转换需要显式地进行,但是无须借助于reinterpret_cast操作符,用 static_cast操作符即可。例如:
int *p = static_cast<int *> (vp);
  • C 语言允许 void 指针隐含地转换为其他任何类型的指针,而C++规定这种情况只能显式转换,这是 C++与 C 相比的一个安全之处。

  • 如果用 static_cast将 void 指针转换为指针原来的类型,那么这是一种安全的转换,否则仍然是不安全的。例如:

float *p2 = static_cast<float *> (vp);
  • 与reinterpret_cast相同的问题又发生了。因此,static_cast也不是绝对安全的,在对void指针使用 static_cast时如使用不当就会有不安全的情况出现。因此,void指针要慎用。

  • 有很多从标准 C 继承而来的函数会使用 void 指针作为参数和返回值,例如将一段内存空间设为一个固定值(memset)、比较两段内存空间(memcmp)、复制一段内存空间(memcpy)、动态分配一段内存空间(malloc)、释放动态分配的内存空间(free)等,这些操作都是不管具体的数据类型,把不同类型的数据当作无差别的二进制序列。其中,动态内存管理的函数(malloc,free 等)已经可以被 C++的 new ,delete关键字全面替代,而直接内存操作的函数(memset,memcmp,memcpy等)只能针对对象的二进制表示进行处理,不符合面向对象的要求,一般不用,至多对一些基本数据类型的数组使用。

  • void 指针的另一个用途在于,有时一个指针可能会指向不同类型的对象,void指针只起一定的传递作用,最终使用该指针时,还需要根据情况将指针还原为它原先的类型。不过,这样的需求,很多都可以用继承、多态(将在第 7 章和第 8 章介绍)的方式加以处理。如果实在无法处理,那么一定要保证用static_cast操作符将 void指针转换为正确的类型,而不能是其他类型,这样就能够保证指针类型的安全性。

  • 总结起来,保证指针类型安全性的办法有以下几种:

  • 除非非常特殊的底层用途,reinterpret_cast不要用。

  • 继承标准C 的涉及 void指针的函数,一般不要用,至多对一些基本数据类型及其数组使用。

  • 如果一定需要用 void 指针,那么用 static_cast将 void指针转换为具体类型的指针时,一定要转换为最初的类型(即当初转换到该 void指针的指针类型)。

  • 在这里,C++标准中几种分工明确的类型转换操作符static_cast,reinterpret_cast等,与旧风格的类型转换形式相比,其优势就显示了出来。无论是相对安全的static_cast,还是不安全的reinterpret_cast,都使用相同的转换形式,安全和不安全的行为会变得不易区分。

堆对象的管理
  • 通常使用的局部变量,在运行栈上分配空间,空间分配和释放的过程是由编译器生成的代码控制的,一个函数返回后相应的空间会自行释放;而静态生存期变量,其空间的分配是由连接器完成的,它们占用的空间大小始终是固定的,在运行过程中无须释放。然而,用 new 在程序运行时动态创建的堆对象,则必须由程序用delete显式删除。如果动态生成的对象不再需要使用也不用 delete删除,会使得这部分空间始终不能被其他对象利用,造成内存资源的泄漏。

  • "用 new 创建的对象,必须用delete删除"这一原则,虽然说起来很简单,但在复杂的程序中,实践起来却没那么容易。如果一个堆对象的指针,只被一个对象直接访问,而不会传递给其他对象,那么就比较容易处理。然而情况并非都如此简单,有时常常会出现一个堆对象的指针被传递给多个对象的情况,这时,在什么时候、由哪个对象负责删除该堆对象,就成了问题。

  • 对于这一问题,最关键的是明确每个堆对象的归属问题,也就是说,一个堆对象应当由哪个对象、哪个函数负责删除。一般来说,最理想的情况是,一个堆对象是由哪个类的成员函数创建的,就在这个类的成员函数中被删除。例如例6-18 中,ArrayOfPoints 类的构造函数创建了 Point数组,赋给了成员变量points,由 ArrayOfPoints类的析构函数负责删除它。如果遵循这一原则,则堆对象的建立和删除都只是一个类的局部问题,不涉及其他类,因此问题简单许多。

  • 然而,有时确实需要在不同类之间转移堆对象的归属。例如,如果一个函数需要返回一个对象,为了避免复制构造函数因传递返回值被调用(因为大对象的复制构造会有较大开销),可以在函数内用new建立该对象,再将该对象的地址返回,但这就要求调用这个函数的类确保这个返回的堆对象最后被删除。每当遇到这种情况,都应当在函数的注释中明确指出,函数的调用者应当负责删除函数所返回的堆对象。这实际上是类的对外接口约定的一部分,不过能否正确履行不由编译器来检查,而需完全由编程者来保证。

  • 解决动态对象的管理问题,也可以借助于共享指针。共享指针是一种具有指针行为的特殊的类,它会在指向一个堆对象的所有指针都不再有效时,自动将其删除。虽然使用共享指针要付出一定的效率代价,但安全性很好,容易使用。

3. const_cast的应用

  • 之前曾经介绍过,旧风格的类型转换操作符,可以用static_cast, reinterpret_cast和 const_cast三者之一或其中两者的组合加以描述。static_cast已经介绍得很多,它用来进行比较安全的、基于内容的数据类型转换,而reinterpret_cast在 6.8.2节中也介绍了,它是一种底层的、具有很大危险性和不确定性的数据类型转换。还剩下一种类型转换操作符const_cast尚未介绍,本节将对它及其用途进行简单介绍。

  • 通俗地说,const_cast可以用来将数据类型中的const属性去除。它可以将常指针转换为普通指针,将常引用转换为普通引用。例如下面的代码:

void foo(const int *cp) {
  int *p = const_cast<int *> (cp);
    (*p)++;
}
  • 该代码使用 const_cast将 cp类型中的 const去除,将常指针 cp 转换为普通指针 p ,然后通过 p 修改它所指向的变量。这是一个逻辑有些混乱的程序,因为函数 foo 通过将参数cp声明为常指针,承诺不会通过 cp 修改任何变量的值,而它却出尔反尔,最终还是改变了 cp所指向的内容。

  • 注意 const_cast 只用于将常指针转换为普通指针,将常引用转换为普通引用,而不用来将常对象转换为普通对象,因为这是没有意义的。因为对象(而非引用)的转换会生成对象的副本,而使用常对象本来就可以直接生成普通对象的副本。例如:

const int i = 5;
int j = i;
  • 这里把常量 i 赋给变量 j 是无须任何转换的。不过常对象可以用 const_cast 转换为普通引用,这是因为从对象到引用的转换是隐含的,常对象可以隐含地转换为常引用,而常引用又可用const_cast转换为普通引用。

  • 可见,const_cast 很容易被滥用,破坏对数据的保护,因此它是不安全的,所以相对安全的static_cast不具备去除 const的功能。不过,虽然 const_cast 是不安全的,但并不意味着它是一无是处的,在某些固定场合适当地使用它,可以是安全的。

  • 请回顾一下例 6-18 的 ArrayOfPoints类,这个类的接口实际上是有缺陷的。由于ArrayOfPoints 的 element成员函数不是常成员函数,所以无法通过 ArrayOfPoints的常对象、常指针和常引用去访问 element 成员函数,这样并不合理。通过ArrayOfPoints 的常对象、常指针和常引用对数组元素的访问,只要保证不修改数组元素的值,应当是被允许的。如何解决这个问题呢?一个可行的做法是对element函数进行重载,提供一个供常对象使用的重载函数,新增函数的代码应当是这样的:

const Point &element(int index) constage {
  assert (index >= 0 && index < size);
  return points[index];
}
  • 这样,如果通过常对象、常指针或常引用调用 element函数,上面的函数就会被调用,返回一个 Point对象的常引用,返回类型中的const保证了数组元素不被修改;而用普通对象、普通指针或普通引用调用 element函数时,被调用的是另一个版本,可以返回 Point的普通引用,使得数组元素能够被修改。这样的接口就更加完备了。

  • 然而,两个版本中的 element函数中的代码几乎是一模一样的。这里的代码只有两行还好办一些,如果代码更长,恐怕会更加麻烦了。当修改一个函数中的代码时,另一个也要跟着修改。这种编码方法肯定是不明智的。这时,const_cast就可以真正派上用场了。可以将非 const的 element函数修改如下:

Point &element(int index) {
  return const_cast<Point &> (static_cast<const ArrayOfPoints *>(this) -> element (index));
}
  • 它先用 static_cast将this指针转换为常指针。由于从普通指针向常指针的转换是安全的,所以可以用static_cast执行。将 this显示转换为常指针,是为了调用那个常成员函数 element(否则会递归调用自身,并无限递归下去)。直到完成对常成员函数 element的调用,一切都是安全的。得到常引用类型的结果后,使用 const_cast将其转换为普通引用,就是最后的结果。只有最后这一步,用了不能保证是安全转换的const_cast,但在这里却是安全的,因为这种转换所能达到的唯一后果是允许主调函数通过返回的引用修改数组元素,而既然element并不是常成员函数,它当然可以把这种权利给主调函数。

  • 这样一改,就可以使得以后这一 element重载函数不用再加以维护,一切对常成员函数 element的修改都能反映到其中来,同时又丝毫没有破坏const的作用。这就是 const_cast的安全用法。

  • 请读者思考一下,为什么不能够反过来用,也就是说,为什么不能够把函数的内容写在普通成员函数中,而由常成员函数去调用普通成员函数?

7. 继承与派生

  • 派生新类的过程一般包括吸收已有类的成员、调整已有类成员和添加新的成员 3 个步骤。本章围绕派生过程,着重讨论不同继承方式下的基类成员的访问控制问题、添加构造函数和析构函数;接着还将讨论在较为复杂的继承关系中,类成员的唯一标识和访问问题;最后给出类的继承实例------全选主元高斯消去法求解线性方程组和一个小型公司的人员信息管理系统。

7.1 类的继承与派生

1. 继承关系举例

  • 分类树反映了派生关系,最高层是抽象程度最高的,是最具有普遍和一般意义的概念,下层具有了上层的特性,同时加入了自己的新特征,而最下层是最为具体的。在这个层次结构中,由上到下,是一个具体化、特殊化的过程;由下到上,是一个抽象化的过程。上下层之间的关系就可以看作是基类与派生类的关系。

  • 所谓继承就是从先辈处得到属性和行为特征。类的继承,是新的类从已有类那里得到已有的特性。从另一个角度来看这个问题,从已有类产生新类的过程就是类的派生。类的继承与派生机制允许程序员在保持原有类特性的基础上,进行更具体、更详细的修改和扩充。由原有的类产生新类时,新类便包含了原有类特征,同时也可以加入自己所特有的新特性。原有的类称为基类或父类,产生的新类称为派生类或子类。派生类同样也可以作为基类派生新的类,这样就形成了类的层次结构。类的派生实际是一种演化、发展过程,即通过扩展、更改和特殊化,从一个已知类出发建立一个新类。通过类的派生可以建立具有共同关键特征的对象家族,从而实现代码的重用,这种继承和派生的机制对于已有程序的发展和改进,是极为有利的。

2. 派生类的定义

  • 在 C++中,派生类的一般定义语法为:

    class 派生类名: 继承方式 基类名1, 继承方式 基类名2, ... , 继承方式 基类名n {
        派生类声明;
    }
    

  • 例如,假设基类 Base1 和 Base2是已经定义的类,下面的语句定义了一个名为Derived 的派生类,该类从基类 Base1 和 Base2 派生而来。

    class Derived: public Base1, private Base1 {
    public: 
        Derived();
        ~Derived();
    }
    

  • 定义中的"基类名"(如Base1,Base2)是已有的类的名称,"派生类名"是继承原有类的特性而生成的新类的名称(如Derived)。一个派生类,可以同时有多个基类,这种情况称为多继承,这时的派生类同时得到了多个已有类的特征。上述例子就是一个多继承实例。一个派生类只有一个直接基类的情况,称为单继承。单继承可以看作是多继承的一个最简单的特例,多继承可以看作是多个单继承的组合,它们之间的很多特性是相同的,我们的学习首先从简单的单继承开始。

  • 在派生过程中,派生出来的新类也同样可以作为基类再继续派生新的类,此外,一个基类可以同时派生出多个派生类。也就是说,一个类从父类继承来的特征也可以被其他新的类所继承,一个父类的特征,可以同时被多个子类继承。这样,就形成了一个相互关联的类的家族,有时也称做类族。在类族中,直接参与派生出某类的基类称为直接基类,基类的基类甚至更高层的基类称为间接基类。比如由"交通工具"类派生出"汽车"类,"汽车"类又派生出"卡车"类,则"汽车"类是"卡车"类的直接基类,"交通工具"类是"汽车"类的直接基类,而"交通工具"类可以称为"卡车"类的间接基类。

  • 在派生类的定义中,除了要指定基类外,还需要指定继承方式。继承方式规定了如何访问从基类继承的成员。在派生类的定义语句中,每一个"继承方式",只限定紧随其后的基类。继承方式关键字为:public,protected和private,分别表示公有继承、保护继承和私有继承。如果不显式地给出继承方式关键字,系统的默认值就认为是私有继承(private)。类的继承方式指定了派生类成员以及类外对象对于从基类继承来的成员的访问权限,这将在7.2 节详细介绍。

  • 前面的例子中对 Base1 是公有继承,对 Base2是私有继承,同时声明了派生类自己新的构造函数和析构函数。

  • 派生类成员是指除了从基类继承来的所有成员之外,新增加的数据和函数成员。这些新增的成员,正是派生类不同于基类的关键所在,是派生类对基类的发展。当重用和扩充已有的代码时,就是通过在派生类中新增成员来添加新的属性和功能。可以说,这就是类在继承基础上的进化和发展。

3. 派生类生成过程

  • 在C++程序设计中,进行了派生类的定义之后,给出该类的成员函数的实现,整个类就算完成了,可以由它来生成对象进行实际问题的处理。仔细分析派生新类这个过程,实际是经历了3个步骤:吸收基类成员、改造基类成员、添加新的成员。面向对象的继承和派生机制,其最主要目的是实现代码的重用和扩充。因此,吸收基类成员就是一个重用的过程,而对基类成员进行调整、改造以及添加新成员就是原有代码的扩充过程,二者是相辅相成的。下面以1.节中提出的个人银行账户管理系统为例分别对这几个步骤进行解释。基类 Account和派生类 CreditAccount定义如下,类的实现部分暂时略去,在7.7节中将列出解决这个问题的完整程序。

::: pic figure_0271_0535 :::

::: pic figure_0272_0536 :::

  1. 吸收基类成员
  2. 在C++的类继承中,第一步是将基类的成员全盘接收,这样,派生类实际上就包含了它的全部基类中除构造和析构函数之外的所有成员。在派生过程中构造函数和析构函数都不被继承,这一点将在7.3 节中详细介绍。在这里,派生类 CreditA ccount继承了基类Account中除构造和析构函数之外的所有非静态成员:id,balance,record函数,error 函数,getId 函数,getBalance 函数,show函数。经过派生过程这些成员便存在于派生类之中。

  3. 改造基类成员

  4. 对基类成员的改造包括两个方面,一个是基类成员的访问控制问题,主要依靠派生类定义时的继承方式来控制,将在7.2节中详细介绍。另一个是对基类数据或函数成员的覆盖或隐藏,覆盖的概念将在第8章介绍,而隐藏就是简单地在派生类中声明一个和基类数据或函数同名的成员,例如这个例子中的show()。如果派生类声明了一个和某基类成员同名的新成员(如果是成员函数,则参数表也要相同,参数不同的情况属于重载),派生的新成员就隐藏了外层同名成员。这时在派生类中或者通过派生类的对象,直接使用成员名就只能访问到派生类中声明的同名成员,这称做同名隐藏。在这里派生类CreditAccount中的 show()函数就隐藏了基类 Account中的同名函数。

  5. 添加新的成员

  6. 派生类新成员的加入是继承与派生机制的核心,是保证派生类在功能上有所发展的关键。可以根据实际情况的需要,给派生类添加适当的数据和函数成员,来实现必要的新增功能。在这里派生类CreditA ccount中就添加了数据成员 acc,credit,rate 和 fee。

  7. 由于在派生过程中,基类的构造函数和析构函数是不能被继承的,因此要实现一些特别的初始化和扫尾清理工作,就需要在派生类中加入新的构造和析构函数。例如派生类CreditAccount的构造函数 CreditAccount()。

7.2 访问控制

  • 派生类继承了基类的全部数据成员和除了构造、析构函数之外的全部函数成员,但是这些成员的访问属性在派生的过程中是可以调整的。从基类继承的成员,其访问属性由继承方式控制。

  • 基类的成员可以有 public(公有)、protected(保护)和 private(私有)三种访问属性。基类的自身成员可以对基类中任何一个其他成员进行访问,但是通过基类的对象,就只能访问该类的公有成员。

  • 类的继承方式有 public(公有继承)、protected(保护继承)和 private(私有继承)三种。不同的继承方式,导致原来具有不同访问属性的基类成员在派生类中的访问属性也有所不同。这里说的访问来自两个方面:一是派生类中的新增成员访问从基类继承的成员;二是在派生类外部(非类族内的成员),通过派生类的对象访问从基类继承的成员。下面分别进行讨论。

1. 公有继承

  • 当类的继承方式为公有继承时,基类的公有成员和保护成员的访问属性在派生类中不变,而基类的私有成员不可直接访问。也就是说基类的公有成员和保护成员被继承到派生类中访问属性不变,仍作为派生类的公有成员和保护成员,派生类的其他成员可以直接访问它们。在类族之外只能通过派生类的对象访问从基类继承的公有成员,而无论是派生类的成员还是派生类的对象都无法直接访问基类的私有成员。
例 7-1 Point类公有继承

Point类是在前面的章节中多次使用的类。在这个例子中,将从Point类派生出新的Rectangle(矩形)类。矩形是由一个点加上长、宽构成。矩形的点具备了 Point类的全部特征,同时,矩形自身也有一些特点,这就需要在继承 Point类的同时添加新的成员。

下面先来看程序的头文件部分。

//Point.h
#ifndef _POINT_H
#define _POINT_H
class Point {   //基类Point类的定义
public: //公有函数成员
    void initPoint(float x = 0, float y = 0) { this->x = x; this->y = y;}
    void move(float offX, float offY) { x += offX; y += offY; }
    float getX() const { return x; }
    float getY() const { return y; }
private:    //私有数据成员
    float x, y;
};

#endif //_POINT_H
//Rectangle.h
#ifndef _RECTANGLE_H
#define _RECTANGLE_H
#include "Point.h"
class Rectangle: public Point { //派生类定义部分
public: //新增公有函数成员
    void initRectangle(float x, float y, float w, float h) {
        initPoint(x, y); //调用基类公有成员函数
        this->w = w;
        this->h = h;
    }
    float getH() const { return h; }
    float getW() const { return w; }
private:    //新增私有数据成员
    float w, h;
};
#endif //_RECTANGLE_H
  • 这里首先定义了基类 Point。派生类 Rectangle继承了 Point类的全部成员(隐含的默认构造和析构函数除外),因此在派生类中,实际所拥有的成员就是从基类继承过来的成员与派生类新定义成员的总和。继承方式为公有继承,这时,基类中的公有成员在派生类中的访问属性保持原样,派生类的成员函数及对象可以访问到基类的公有成员(例如在派生类函数成员initRectangle 中直接调用基类的函数initPoint),但是无法访问基类的私有成员(例如基类的x,y)。基类原有的外部接口(例如基类的 getX()和 getY()函数)变成了派生类外部接口的一部分。当然,派生类自己新增的成员之间都是可以互相访问的。

  • Rectangle类继承了 Point类的成员,也就实现了代码的重用,同时通过新增成员,加入了自身的独有特征,达到了程序的扩充。

程序的主函数部分如下:

#include <iostream>
#include <cmath>
#include "Rectangle.h"
using namespace std;
int main() {
    Rectangle rect; //定义Rectangle类的对象
    rect.initRectangle(2, 3, 20, 10);   //设置矩形的数据
    rect.move(3,2); //移动矩形位置
    cout << "The data of rect(x,y,w,h): " << endl;
    cout << rect.getX() <<", "  //输出矩形的特征参数
         << rect.getY() << ", "
         << rect.getW() << ", "
         << rect.getH() << endl;
    return 0;
}

程序的运行结果是:

The data of rect(x, y, w, h):
5, 5, 20, 10

  • 主函数中首先声明了一个派生类的对象rect,对象生成时调用了系统所产生的默认构造函数,这个函数的功能是什么都不做。然后通过派生类的对象,访问了派生类的公有函数initRectangle,move 等,也访问了派生类从基类继承来的公有函数 getX(),getY()。这样我们看到了,从一个基类以公有方式产生了派生类之后,在派生类的成员函数中,以及通过派生类的对象如何访问从基类继承的公有成员。

2. 私有继承

  • 当类的继承方式为私有继承时,基类中的公有成员和保护成员都以私有成员身份出现在派生类中,而基类的私有成员在派生类中不可直接访问。也就是说基类的公有成员和保护成员被继承后作为派生类的私有成员,派生类的其他成员可以直接访问它们,但是在类族外部通过派生类的对象无法直接访问它们。无论是派生类的成员还是通过派生类的对象,都无法直接访问从基类继承的私有成员。

  • 经过私有继承之后,所有基类的成员都成为了派生类的私有成员或不可直接访问的成员,如果进一步派生的话,基类的全部成员就无法在新的派生类中被直接访问。因此,私有继承之后,基类的成员再也无法在以后的派生类中直接发挥作用,实际是相当于中止了基类功能的继续派生,出于这种原因,一般情况下私有继承的使用比较少。

例 7-2 Point类私有继承
  • 这个例子所面对的问题和例 7-1相同,只是采用不同的继承方式,即在继承过程中对基类成员的访问权限设置不同。程序类的定义部分如下:
//Point.h
#ifndef _POINT_H
#define _POINT_H
class Point {   //基类Point类的定义
public: //公有函数成员
    void initPoint(float x = 0, float y = 0) { this->x = x; this->y = y;}
    void move(float offX, float offY) { x += offX; y += offY; }
    float getX() const { return x; }
    float getY() const { return y; }
private:    //私有数据成员
    float x, y;
};
#endif //_POINT_H

//Rectangle.h
#ifndef _RECTANGLE_H
#define _RECTANGLE_H
#include "Point.h"
class Rectangle: private Point {    //派生类定义部分
public: //新增公有函数成员
    void initRectangle(float x, float y, float w, float h) {
        initPoint(x, y); //调用基类公有成员函数
        this->w = w;
        this->h = h;
    }
    void move(float offX, float offY) { Point::move(offX, offY); }
    float getX() const { return Point::getX(); }
    float getY() const { return Point::getY(); }
    float getH() const { return h; }
    float getW() const { return w; }
private:    //新增私有数据成员
    float w, h;
};
#endif //_RECTANGLE_H
  • 同样,派生类 Rectangle继承了 Point类的成员,因此在派生类中,实际所拥有的成员就是从基类继承来的成员与派生类新成员的总和。继承方式为私有继承,这时,基类中的公有和保护成员在派生类中都以私有成员的身份出现。派生类的成员函数及对象无法访问基类的私有成员(例如基类的x, y)。派生类的成员仍然可以访问到从基类继承过来的公有和保护成员(例如在派生类函数成员initRectangle中直接调用基类的函数initPoint),但是在类外部通过派生类的对象根本无法直接访问到基类的任何成员,基类原有的外部接口(例如基类的getX()和getY()函数)被派生类封装和隐蔽起来。当然,派生类新增的成员之间仍然可以自由地互相访问。

  • 在私有继承情况下,为了保证基类的一部分外部接口特征能够在派生类中也存在,就必须在派生类中重新声明同名的成员。这里在派生类Rectangle中,重新声明了 move, getX ,getY等函数,利用派生类对基类成员的访问能力,把基类的原有成员函数的功能照搬过来。这种在派生类中重新声明的成员函数具有比基类同名成员函数更小的作用域,因此在调用时,根据同名隐藏的原则,自然会使用派生类的函数。

程序的主函数部分和例 7-1 完全相同,但是执行过程有所不同。

#include <iostream>
#include <cmath>
#include "Rectangle.h"
using namespace std;
int main() {
    Rectangle rect; //定义Rectangle类的对象
    rect.initRectangle(2, 3, 20, 10);   //设置矩形的数据
    rect.move(3,2); //移动矩形位置
    cout << "The data of rect(x,y,w,h): " << endl;
    cout << rect.getX() <<", "  //输出矩形的特征参数
         << rect.getY() << ", "
         << rect.getW() << ", "
         << rect.getH() << endl;
    return 0;
}
- 例 7-1 主函数最大的不同是:本例的 Rectangle 类对象rect调用的函数都是派生类自身的公有成员,因为是私有继承,它不可能访问到任何一个基类的成员。同例7-1 Point类公有继承相比较,本例对程序修改的只是派生类的内容,基类和主函数部分根本没有做过任何改动,读者也可以看到面向对象程序设计封装性的优越性。Rectangle类的外部接口不变,内部成员的实现做了改造,根本就没有影响到程序的其他部分,这正是面向对象程序设计重用与可扩充性的一个实际体现。程序运行的结果同例7-1。

3. 保护继承

  • 保护继承中,基类的公有成员和保护成员都以保护成员的身份出现在派生类中,而基类的私有成员不可直接访问。这样,派生类的其他成员就可以直接访问从基类继承来的公有和保护成员,但在类外部通过派生类的对象无法直接访问它们。

  • 比较私有继承和保护继承可以看出,实际上在直接派生类中,所有成员的访问属性都是完全相同的。但是,如果派生类作为新的基类继续派生时,二者的区别就出现了。假设Rectangle类以私有方式继承了 Point 类后,Rectangle 类又派生出 Square类,那么Square类的成员和对象都不能访问间接从 Point类中继承来的成员。如果Rectangle类是以保护方式继承了 Point类,那么Point类中的公有和保护成员在 Rectangle类中都是保护成员。Rectangle类再派生出 Square类后,Point类中的公有和保护成员被Square类间接继承后,有可能是保护的或者是私有的(视从Rectangle到Square的派生方式不同而不同)。因而,Square类的成员有可能可以访问间接从 Point类中继承来的成员。从继承的访问规则,可以看到类中保护成员的特征。如果 Point类中含有保护成员,对于建立 Point类对象的模块来讲,保护成员和该类的私有成员一样是不可访问的。如果 Point类派生出子类 Rectangle,则对于 Rectangle 类来讲,保护成员与公有成员具有相同的访问特性。换句话来说,就是 Point 类中的保护成员有可能被它的派生类访问,但是决不可能被其他外部使用者(比如程序中的普通函数、与Point类平行的其他类等)访问。这样,如果合理地利用保护成员,就可以在类的复杂层次关系中在共享与成员隐蔽之间找到一个平衡点,既能实现成员隐蔽,又能方便继承,实现代码的高效重用和扩充。

下面再通过两个简单的例子对上述问题进行深入说明。假定某一个类 A有保护数据成员 x,我们来讨论成员 x的访问特征。

基类 A 的定义为:

class A {
protected:
    int x;
};
如果主函数为:
int main() {
    A a;
    a.x = 5;
}
- 程序在编译阶段就会出错,错误原因就是在建立 A类对象的模块------主函数中试图访问A类的保护成员,这是不允许的,因为该成员的访问规则和 A类的私有成员是相同的。这就说明在建立 A 类对象a的模块中是无法访问 A类的保护成员的,在这种情况下,保护成员和私有成员一样得到了很好的隐蔽。如果 A 类以公有方式派生产生了 B 类,则在 B 类中,A类保护成员和该类的公有成员一样是可以访问的。例如:

class A {
protected:
    int x;
};
class B: public A {
public:
    void function();
};
void B::function() {
    x = 5;
}
  • 在派生类 B 的成员函数 function 内部,是完全可以访问基类的保护成员的。注意如果 B 是 A 的派生类,B 的成员函数只能通过 B 的对象访问 A 中定义的protected 成员,而不能通过 A 的对象访问 A 的 protected 成员。

7.3 类型兼容规则

  • 类型兼容规则是指在需要基类对象的任何地方,都可以使用公有派生类的对象来替代。通过公有继承,派生类得到了基类中除构造函数、析构函数之外的所有成员。这样,公有派生类实际就具备了基类的所有功能,凡是基类能解决的问题,公有派生类都可以解决。类型兼容规则中所指的替代包括以下的情况:

  • 派生类的对象可以隐含转换为基类对象。

  • 派生类的对象可以初始化基类的引用。

  • 派生类的指针可以隐含转换为基类的指针。

  • 在替代之后,派生类对象就可以作为基类的对象使用,但只能使用从基类继承的成员。

  • 如果 B 类为基类,D 为 B 类的公有派生类,则 D 类中包含了基类 B 中除构造、析构函数之外的所有成员。这时,根据类型兼容规则,在基类 B 的对象可以出现的任何地方,都可以用派生类 D 的对象来替代。在如下程序中,b1 为 B 类的对象,d1 为 D 类的对象。

class B {...}
class D: public B {...}
B b1, *pb1;
D d1;

这时:

  1. 派生类对象可以隐含转换为基类对象,即用派生类对象中从基类继承来的成员,逐个赋值给基类对象的成员:
b1 = d1;
  1. 派生类的对象也可以初始化基类对象的引用:
B &rb = d1;
  1. 派生类对象的地址也可以隐含转换为指向基类的指针:

    pb1 = &d1;
    

  2. 由于类型兼容规则的引入,对于基类及其公有派生类的对象,可以使用相同的函数统一进行处理。因为当函数的形参为基类的对象(或引用、指针)时,实参可以是派生类的对象(或指针),而没有必要为每一个类设计单独的模块,大大提高了程序的效率。这正是C++的又一重要特色,即之后要学习的多态性。可以说,类型兼容规则是多态性的重要基础之一。下面来看一个例子,例中使用同样的函数对同一个类族中的对象进行操作。

例 7-3 类型兼容规则实例

本例中,基类 Base1 以公有方式派生出 Base2 类,Base2 类再作为基类以公有方式派生出 Derived 类。基类 Base1 中定义了成员函数 display(),在派生类中对这个成员函数进行了隐藏。

程序代码如下:

#include <iostream>
using namespace std;

class Base1 { //基类Base1定义
public:
    void display() const { cout << "Base1::display()" << endl; }
};

class Base2 : public Base1 { //公有派生类Base2定义
public:
    void display() const { cout << "Base2::display()" << endl; }
};

class Derived : public Base2 { //公有派生类Derived定义
public:
    void display() const { cout << "Derived::display()" << endl; }
};

void fun(Base1* ptr) { //参数为指向基类对象的指针
    ptr->display(); //"对象指针->成员名"
}

int main() {    //主函数
    Base1 base1;    //声明Base1类对象
    //base1.display();
    Base2 base2;    //声明Base2类对象
    //base2.display();
    Derived derived;    //声明Derived类对象
    //derived.display();
    //derived.Base2::display();

    fun(&base1);    //用Base1对象的指针调用fun函数
    fun(&base2);    //用Base2对象的指针调用fun函数
    fun(&derived);  //用Derived对象的指针调用fun函数

    return 0;
}

  • 这样,通过"对象名.成员名"或者"对象指针->成员名"的方式,就应该可以访问到各派生类中承自基类的成员。虽然根据类型兼容原则,可以将派生类对象的地址赋值给基类 Base1 的指针,但是通过这个基类类型的指针,却只能访问到从基类继承的成员。

  • 在程序中,声明了一个形参为基类 Base1 类型指针的普通函数 fun,根据类型兼容规则,可以将公有派生类对象的地址赋值给基类类型的指针,这样,使用 fun 函数就可以统一对这个类族中的对象进行操作。程序运行过程中,分别把基类对象、派生类 Base2 的对象和派生类Derived 的对象赋值给基类类型指针 p,但是,通过指针 p,只能使用继承下来的基类成员。也就是说,尽管指针指向派生类 Derived 的对象,fun 函数运行时通过这个指针只能访问到 Derived 类从基类 Base1 继承过来的成员函数 display,而不是 Derived 类自己的同名成员函数。因此,主函数中 3 次调用函数 fun 的结果是同样的------访问了基类的公有成员函数。

  • 通过这个例子可以看到,根据类型兼容规则,可以在基类对象出现的场合使用派生类对象进行替代,但是替代之后派生类仅仅发挥出基类的作用。在第8章,将学习面向对象程序设计的另一个重要特征------多态性。多态的设计方法可以保证在类型兼容的前提下,基类、派生类分别以不同的方式来响应相同的消息。

7.4 派生类的构造和析构函数

  • 继承的目的是为了发展,派生类继承了基类的成员,实现了原有代码的重用,这只是一部分,而代码的扩充才是最主要的,只有通过添加新的成员,加入新的功能,类的派生才有实际意义。在前面的例子中,已经学习了派生类一般成员的添加,本节着重讨论派生类的构造函数和析构函数的一些特点。由于基类的构造函数和析构函数不能被继承,在派生类中,如果对派生类新增的成员进行初始化,就必须为派生类添加新的构造函数。但是派生类的构造函数只负责对派生类新增的成员进行初始化,对所有从基类继承下来的成员,其初始化工作还是由基类的构造函数完成。同样,对派生类对象的扫尾、清理工作也需要加入新的析构函数。

1. 构造函数

  • 定义了派生类之后,要使用派生类就需要声明该类的对象。对象在使用之前必须初始化。派生类的成员对象是由所有基类的成员对象与派生类新增的成员对象共同组成。因此构造派生类的对象时,就要对基类的成员对象和新增成员对象进行初始化。基类的构造函数并没有继承下来,要完成这些工作,就必须给派生类添加新的构造函数。派生类对于基类的很多成员对象是不能直接访问的,因此要完成对基类成员对象的初始化工作,需要通过调用基类的构造函数。派生类的构造函数需要以合适的初值作为参数,其中一些参数要传递给基类的构造函数,用于初始化相应的成员,另一些参数要用于对派生类新增的成员对象进行初始化。在构造派生类的对象时,会首先调用基类的构造函数,来初始化它们的数据成员,然后按照构造函数初始化列表中指定的方式初始化派生类新增的成员对象,最后才执行派生类构造函数的函数体。

  • 派生类构造函数的一般语法形式为:

派生类名::派生类名(参数表): 基类名(基类初始化参数表), ... , 成员对象名(成员对象初始化参数表), ... {
    派生类构造函数体;
}
  • 这里,派生类的构造函数名与类名相同。在构造函数的参数表中,需要给出初始化基类数据和新增成员对象所需要的参数。在参数表之后,列出需要使用参数进行初始化的基类名和成员对象名及各自的初始化参数表,各项之间使用逗号分隔。

  • 当一个类同时有多个基类时,对于所有需要给予参数进行初始化的基类,都要显式给出基类名和参数表。对于使用默认构造函数的基类,可以不给出类名。同样,对于成员对象,如果是使用默认构造函数,也不需要写出对象名和参数表。

  • 下面来讨论什么时候需要声明派生类的构造函数。如果对基类初始化时,需要调用基类的带有形参表的构造函数时,派生类就必须声明构造函数,提供一个将参数传递给基类构造函数的途径,保证在基类进行初始化时能够获得必要的数据。如果不需要调用基类的带参数的构造函数,也不需要调用新增的成员对象的带参数的构造函数,派生类也可以不声明构造函数,全部采用默认的构造函数,这时新增成员的初始化工作可以用其他公有函数来完成。当派生类没有显式的构造函数时,系统会隐含生成一个默认构造函数,该函数会使用基类的默认构造函数对继承自基类的数据初始化,并且调用类类型的成员对象的默认构造函数,对这些成员对象初始化。

  • 派生类构造函数执行的一般次序如下:

  • 调用基类构造函数,调用顺序按照它们被继承时声明的顺序(从左向右)。

  • 对派生类新增的成员对象初始化,调用顺序按照它们在类中声明的顺序。

  • 执行派生类的构造函数体中的内容。

  • 构造函数初始化列表中基类名、对象名之间的次序无关紧要,它们各自出现的顺序可以是任意的,无论它们的顺序怎样安排,基类构造函数的调用和各个成员对象的初始化顺序都是确定的。

例 7-4 派生类构造函数举例(多继承、含有内嵌对象)

这是一个具有一般性特征的例子,有 3 个基类 Base1,Base2 和Base3。其中Base3只有一个默认的构造函数,其余两个基类的成员只是一个带有参数的构造函数。类Derived由这 3 个基类经过公有派生而来。派生类新增加了 3个私有对象成员,分别是 Base1, Base2 和 Base3 类的对象。程序如下:

#include <iostream>
using namespace std;
class Base1 {   //基类Base1,构造函数有参数
public:
    Base1(int i) { cout << "Constructing Base1 " << i << endl; }
};
class Base2 {   //基类Base2,构造函数有参数
public:
    Base2(int j) { cout << "Constructing Base2 " << j << endl; }
};
class Base3 {   //基类Base3,构造函数无参数
public:
    Base3() { cout << "Constructing Base3 *" << endl; }
};
class Derived: public Base2, public Base1, public Base3 {
//派生新类Derived,注意基类名的顺序
public: //派生类的公有成员
    Derived(int a, int b, int c, int d): Base1(a), member2(d), member1(c), Base2(b) { }
    //注意基类名的个数与顺序,//注意成员对象名的个数与顺序
private:    //派生类的私有成员对象
    Base1 member1;
    Base2 member2;
    Base3 member3;
};

int main() {
    Derived obj(1, 2, 3, 4);
    return 0;
}
  • 下面来仔细分析派生类构造函数的特点。因为基类及内嵌对象成员都具有非默认形式的构造函数,所以派生类中需要显式声明一个构造函数,这个派生类构造函数的主要功能就是初始化基类及内嵌对象成员。按照前面所讲过的规则,派生类的构造函数定义为:
    Derived(int a, int b, int c, int d): Base1(a), member2(d), member1(c), Base2(b) { }
  • 构造函数的参数表中给出了基类及内嵌成员对象所需的全部参数,在冒号之后,分别列出了各个基类及内嵌对象名和各自的参数。这里,有两个问题需要注意:首先,这里并没有列出全部基类和成员对象,由于 Base3 类只有默认构造函数,不需要给它传递参数,因此,基类 Base3 以及 Base3 类成员对象 member3 就不必列出。其次,基类名和成员对象名的顺序是随意的。这个派生类构造函数的函数体为空,可见实际上只是起到了传递参数和调用基类及内嵌对象构造函数的作用。

  • 程序的主函数中只是声明了一个派生类 Derived 的对象 obj,生成对象 obj 时调用了派生类的构造函数。下面来考虑 Derived 类构造函数的执行情况,它应该是先调用基类的构造函数,然后调用内嵌对象的构造函数。基类构造函数的调用顺序是按照派生类定义时的顺序,因此应该是先 Base2 ,再 Base1 ,最后 Base3;而内嵌对象的构造函数调用顺序应该是按照成员在类中声明的顺序,应该是先 Base1,再 Base2,最后 Base3。程序运行的结果也完全证实了这种分析。

  • 派生类构造函数定义中,并没有显式列出基类 Base3 和 Base3 类的对象 m em ber3,这时系统就会自动调用该类的默认构造函数。如果一个基类同时声明了默认构造函数和带有参数的构造函数,那么在派生类构造函数声明中,既可以显式列出基类名和相应的参数,也可以不列出,程序员可以根据实际情况的需要来自行安排。

2. 复制构造函数

  • 当存在类的继承关系时,复制构造函数该如何编写呢?对一个类,如果程序员没有编写复制构造函数,编译系统会在必要时自动生成一个隐含的复制构造函数,这个隐含的复制构造函数会自动调用基类的复制构造函数,然后对派生类新增的成员对象一一执行复制。

  • 如果要为派生类编写复制构造函数,一般需要为基类相应的复制构造函数传递参数。例如,假设 Derived 类是 Base 类的派生类,Derived 类的复制构造函数形式如下:

Derived::Derived(const Derived &v): Base(v) {...} 
  • 对此,读者未免困惑:Base类的复制构造函数参数类型应该是 Base 类对象的引用,怎么这里用 Derived 类对象的引用 v 作为参数呢?这是因为类型兼容规则在这里起了作用:可以用派生类的对象去初始化基类的引用。因此当函数的形参是基类的引用时,实参可以是派生类的对象。

3. 析构函数

  • 派生类的析构函数的功能是在该类对象消亡之前进行一些必要的清理工作。析构函数没有类型,也没有参数,和构造函数相比情况略微简单些。

  • 在派生过程中,基类的析构函数也不能继承下来,如果需要析构的话,就要在派生类中声明新的析构函数。派生类析构函数的声明方法与没有继承关系的类中析构函数的声明方法完全相同,只要在函数体中负责把派生类新增的非对象成员的清理工作做好就够了,系统会自己调用基类及对象成员的析构函数来对基类及对象成员进行清理。但它的执行次序和构造函数正好完全相反,首先执行析构函数的函数体,然后对派生类新增的类类型的成员对象进行清理,最后对所有从基类继承来的成员进行清理。这些清理工作分别是执行派生类析构函数体、调用类类型的派生类对象成员所在类的析构函数和调用基类析构函数。对这些析构函数的调用次序,与对构造函数的调用次序刚好完全相反。

  • 在本章前面的所有例子中,都没有显式声明过某个类的析构函数,这种情况下,编译系统会自动为每个类都生成一个默认的析构函数,并在对象生存期结束时自动调用。这样自动生成的析构函数的函数体是空的,但并非不做任何事情,它会隐含地调用派生类对象成员所在类的析构函数和调用基类的析构函数。

例 7-5 派生类析构函数举例(多继承、含有嵌入对象)

下面对例7-4进行改造,分别给所有基类加入析构函数。程序如下:

#include <iostream>
using namespace std;

class Base1 {   //基类Base1,构造函数有参数
public:
    Base1(int i) { cout << "Constructing Base1 " << i << endl; }
    ~Base1() { cout << "Destructing Base1" << endl; }
};

class Base2 {   //基类Base2,构造函数有参数
public:
    Base2(int j) { cout << "Constructing Base2 " << j << endl; }
    ~Base2() { cout << "Destructing Base2" << endl; }
};

class Base3 {   //基类Base3,构造函数无参数
public:
    Base3() { cout << "Constructing Base3 *" << endl; }
    ~Base3() { cout << "Destructing Base3" << endl; }
};

class Derived: public Base2, public Base1, public Base3 {
//派生新类Derived,注意基类名的顺序
public: //派生类的公有成员
    Derived(int a, int b, int c, int d): Base1(a), member2(d), member1(c), Base2(b) { }
    //注意基类名的个数与顺序,注意成员对象名的个数与顺序
private:    //派生类的私有成员对象
    Base1 member1;
    Base2 member2;
    Base3 member3;
};

int main() {
    Derived obj(1, 2, 3, 4);
    return 0;
}
程序的运行结果如下:

Constructing Base2 2
Constructing Base1 1
Constructing Base3 *
Constructing Base1 3
Constructing Base2 4
Constructing Base3 *
Destructing Base3
Destructing Base2
Destructing Base1
Destructing Base3
Destructing Base1
Destructing Base2
  • 程序中,给3个基类分别加入了析构函数,派生类没有做任何改动,仍然使用的是由系统提供的默认析构函数。主函数也保持原样。程序在执行时,首先执行派生类的构造函数,然后执行派生类的析构函数。构造函数部分已经讨论过了,派生类默认的析构函数又分别调用了成员对象及基类的析构函数,这时的次序刚好和构造函数执行时完全相反。

  • 程序运行时的输出验证了我们的分析。关于派生类构造函数和析构函数的问题,这里只通过一个简单的例子说明了语法规则,在以后的实例中还会结合使用情况继续进行讨论。

7.5 派生类成员的标识与访问

  • 围绕类派生吸收基类成员、改造基类成员和添加新成员的过程,我们学习了C++中继承与派生的语法规则。经过类的派生,就形成了一个具有层次结构的类族。这一节,主要讨论派生类使用过程中的一些问题,也就是标识和访问派生类及其对象的成员(包括了基类中继承来的部分和新增的部分)问题。

  • 在派生类中,成员可以按访问属性划分为以下 4 种:

  • 不可访问的成员。这是从基类私有成员继承而来的,派生类或是建立派生类对象的模块都没有办法访问到它们,如果从派生类继续派生新类,也是无法访问的。

  • 私有成员。这里可以包括从基类继承过来的成员以及新增加的成员,在派生类内部可以访问,但是建立派生类对象的模块中无法访问,继续派生,就变成了新的派生类中的不可访问成员。

  • 保护成员。可能是新增也可能是从基类继承过来的,派生类内部成员可以访问,建立派生类对象的模块无法访问,进一步派生,在新的派生类中可能成为私有成员或者保护成员。

  • 公有成员。派生类、建立派生类的模块都可以访问,继续派生,可能是新派生类中的私有、保护或者公有成员。

  • 在对派生类的访问中,实际上有两个问题需要解决:第一是唯一标识问题,第二是成员本身的属性问题,严格讲应该是可见性问题。我们只能访问一个能够唯一标识的可见成员。如果通过某一个表达式能引用的成员不只一个,称为有二义性。二义性问题,也就是本节讨论的唯一标识问题。

1. 作用域分辨符

作用域分辨符,就是我们经常见到的"::",它可以用来限定要访问的成员所在的类的名称。一般的使用形式是:

类名::成员名    //数据成员
类名::成员名(参数表)    //函数成员
  • 下面来看看作用域分辨符在类族层次结构中是如何唯一标识成员的。

  • 对于在不同的作用域声明的标识符,可见性原则是:如果存在两个或多个具有包含关系的作用域,外层声明了一个标识符,而内层没有再次声明同名标识符,那么外层标识符在内层仍然可见;如果在内层声明了同名标识符,则外层标识符在内层不可见,这时称内层标识符隐藏了外层同名标识符,这种现象称为隐藏规则。

  • 在类的派生层次结构中,基类的成员和派生类新增的成员都具有类作用域。二者的作用范围不同,是相互包含的两个层,派生类在内层。这时,如果派生类声明了一个和某个基类成员同名的新成员,派生的新成员就隐藏了外层同名成员,直接使用成员名只能访问到派生类的成员。如果派生类中声明了与基类成员函数同名的新函数,即使函数的参数表不同,从基类继承的同名函数的所有重载形式也都会被隐藏。如果要访问被隐藏的成员,就需要使用作用域分辨符和基类名来限定。

  • 对于多继承情况,首先考虑各个基类之间没有任何继承关系,同时也没有共同基类的情况。最典型的情况就是所有基类都没有上级基类。如果某派生类的多个基类拥有同名的成员,同时,派生类又新增这样的同名成员,在这种情况下,派生类成员将隐藏所有基类的同名成员。这时使用"对象名.成员名"或"对象指针->成员名"方式可以唯一标识和访问派生类新增成员,基类的同名成员也可以使用基类名和作用域分辨符访问。但是,如果派生类没有声明同名成员,"对象名.成员名"或"对象指针->成员名"方式就无法唯一标识成员。这时,从不同基类继承过来的成员具有相同的名称,同时具有相同的作用域,系统仅仅根据这些信息根本无法判断到底是调用哪个基类的成员,这时就必须通过基类名和作用域分辨符来标识成员。下面是一个程序实例。

  • 细节如果子类中定义的函数与父类的函数同名但具有不同的参数数量或参数类型,不属于函数重载,这时子类中的函数将使父类中的函数隐藏,调用父类中的函数必须使用父类名称来限定。只有在相同的作用域中定义的函数才可以重载。

例7-6 多继承同名隐藏举例(1)

在下面的程序中,定义了基类 Base1 和 Base2,由基类 Base1,Base2 共同公有派生产生了新类 Derived。两个基类中都声明了数据成员 var和函数 fun,在派生类中新增了同名的两个成员。这时的 Derived 类共含有 6 个成员,而这 6 个成员只有两个名字。

现在来分析对派生类的访问情况。派生类中新增的成员具有更小的类作用域,因此,在派生类及建立派生类对象的模块中,派生类新增成员隐藏了基类的同名成员,这时使用"对象名.成员名"的访问方式,就只能访问到派生类新增的成员。对基类同名成员的访问,只能通过基类名和作用域分辨符来实现,也就是说,必须明确告诉系统要使用哪个基类的成员。源序如下:

#include <iostream>
using namespace std;

class Base1 {   //定义基类Base1
public:
    int var;
    void fun() { cout << "Member of Base1" << endl; }
};

class Base2 {   //定义基类Base2
public:
    int var;
    void fun() { cout << "Member of Base2" << endl; }
};

class Derived: public Base1, public Base2 { //定义派生类Derived
public:
    int var;    //同名数据成员
    void fun() { cout << "Member of Derived" << endl; } //同名函数成员
};

int main() {
    Derived d;
    Derived *p = &d;

    d.var = 1;  //对象名.成员名标识
    d.fun();    //访问Derived类成员

    d.Base1::var = 2;   //作用域分辨符标识
    d.Base1::fun(); //访问Base1基类成员

    p->Base2::var = 3;  //作用域分辨符标识
    p->Base2::fun();    //访问Base2基类成员

    return 0;
}

在主函数中,创建了一个派生类的对象d,根据隐藏规则,如果通过成员名称来访问该类的成员,就只能访问到派生类新增加的两个成员,从基类继承过来的成员由于处于外层作用域而被隐藏。这时,要想访问从基类继承来的成员,就必须使用类名和作用域分辨符。程序中后面两组语句就是分别访问由基类 Base1,Base2 继承来的成员。

  • 通过作用域分辨符,就明确地唯一标识了派生类中由基类所继承来的成员,达到了访问的目的,解决了成员被隐藏的问题。

  • 如果在例 7-6中,派生类没有声明与基类同名的成员,那么使用"对象名.成员名"就无法访问到任何成员,来自Base1,Base2类的同名成员具有相同的作用域,系统根本无法进行唯一标识,这时就必须使用作用域分辨符。

如果例 7-6 中的程序中派生类不增加新成员,改为如下形式:

class Derived: public Base1, public Base2 {};
  • 程序其余部分保持原样,主函数中"对象名.成员名"的访问方式就会出错,因为不知道应该访问Base1还是Base2的函数。

  • 如果希望 d.var 和 d.fun()的用法不产生二义性,可以使用 using 关键字加以澄清。例如:

class Derived: public Base1, public Base2 {
public:
    using Base1::var;
    using Base2::fun;
};
- 这样,d.var 和 d.fun()都可以明确表示对 Base1 中相关成员的引用了。using的一般功能是将一个作用域中的名字引入到另一个作用域中,它还有一个非常有用的用法:将using用于基类中的函数名,这样派生类中如果定义同名但参数不同的函数,基类的函数不会被隐藏,两个重载的函数将会并存在派生类的作用域中。例如:

class Derived2: public Base1 {
public:
    using Base1::fun;
    void fun(int i) {...}
}
  • 这时,使用 Derived2 的对象,既可以直接调用无参数的 fun 函数,又可以直接调用带 int型参数的 fun 函数。

  • 上面讨论多继承时,假定所有基类之间没有继承关系,如果这个条件得不到满足会出现什么情况呢?如果某个派生类的部分或全部直接基类是从另一个共同的基类派生而来,在这些直接基类中,从上一级基类继承来的成员就拥有相同的名称,因此派生类中也就会产生同名现象,对这种类型的同名成员也要使用作用域分辨符来唯一标识,而且必须用直接基类来进行限定。

  • 先来看一个例子。有一个基类Base0,声明了数据成员var0 和函数fun0,由Base0公有派生产生了类 Base1 和 Base2,再以 Base1,Base2 作为基类共同公有派生产生了新类Derived。在派生类中不再添加新的同名成员(如果有同名成员,同样遵循隐藏规则),这时的Derived1 类,就含有通过 Base1,Base2 继承来的基类 Base0 中的同名成员var0 和fun0。

  • 现在来讨论同名成员 var0 和 fun0 的标识与访问问题。间接基类 Base0的成员经过两次派生之后,通过不同的派生路径以相同的名字出现于派生类Derived1 中。这时如果使用基类名 Base0 来限定,同样无法表明成员到底是从 Base1 还是 Base2 继承过来。因此必须使用直接基类 Base1 或者 Base2 的名称来限定,才能够唯一标识和访问成员。请看下面的源程序。

例 7-7 多继承同名隐藏举例(2)
#include <iostream>
using namespace std;

class Base0 {   //定义基类Base0
public:
    int var0;
    void fun0() { cout << "Member of Base0" << endl; }
};

class Base1 : public Base0 {    //定义派生类Base1
public: //新增外部接口
    int var1;
};

class Base2 : public Base0 {    //定义派生类Base2
public: //新增外部接口
    int var2;
};

class Derived : public Base1, public Base2 {    //定义派生类Derived
public: //新增外部接口
    int var;
    void fun() { cout << "Member of Derived" << endl; }
};

int main() {    //程序主函数
    Derived d;          //定义Derived类对象d
    d.Base1::var0 = 2;  //使用直接基类
    d.Base1::fun0();
    d.Base2::var0 = 3;  //使用直接基类
    d.Base2::fun0();
    cout << &d.Base1::var0 << ", " << &d.Base2::var0;
    return 0;
}
  • 在程序主函数中,创建了一个派生类的对象d,如果只通过成员名称来访问该类的成员 var0 和fun0,系统就无法唯一确定要引用的成员。这时,必须使用作用域分辨符,通过直接基类名来确定要访问的从基类继承来的成员。

  • 这种情况下,派生类对象在内存中就同时拥有成员 var0 的两份同名副本。对于数据成员来讲,虽然两个 var0 可以分别通过 Base1 和 Base2 调用 Base0 的构造函数进行初始化,可以存放不同的数值,也可以使用作用域分辨符通过直接基类名限定来分别进行访问,但是在很多情况下,我们只需要一个这样的数据副本。同一成员的多份副本增加了内存的开销。C++中提供了虚基类技术来解决这一问题。

  • 上例中,其实 Base0 类的函数成员 fun0 的代码始终只有一个副本,之所以调用 fun0 函数时仍然需要用基类名 Base1 或 Base2 加以限定,是因为调用非静态成员函数总是针对特定的对象,执行函数调用时需要将指向该类的一个对象的指针作为隐含的参数传递给被调函数来初始化 this 指针。上例中,Derived 类的对象中存在两个 Base0 类的子对象,因此调用 fun0 函数时,需要使用 Base1 或 Base2 加以限定,这样才能明确针对哪个 Base0 对象调用。

2. 虚基类

  • 当某类的部分或全部直接基类是从另一个共同基类派生而来时,在这些直接基类中从上一级共同基类继承来的成员就拥有相同的名称。在派生类的对象中,这些同名数据成员在内存中同时拥有多个副本,同一个函数名会有多个映射。可以使用作用域分辨符来唯一标识并分别访问它们,也可以将共同基类设置为虚基类,这时从不同的路径继承过来的同名数据成员在内存中就只有一个副本,同一个函数名也只有一个映射。这样就解决了同名成员的唯一标识问题。

  • 虚基类的声明是在派生类的定义过程中进行的,其语法形式为:

    class 派生类名virtual 继承方式基类名
    

  • 上述语句声明基类为派生类的虚基类。在多继承情况下,虚基类关键字的作用范围和继承方式关键字相同,只对紧跟其后的基类起作用。声明了虚基类之后,虚基类的成员在进一步派生过程中和派生类一起维护同一个内存数据副本。
例 7-8 虚基类举例

本例还是考虑前面提到的问题。有一个基类 Base0,声明了数据成员 var0 和函数fun0,由 Base0 公有派生产生了类 Base1 和 Base2,与例 7-7 不同的是派生时声明 Base0 为虚基类,再以 Base1,Base2 作为基类共同公有派生产生了新类 Derived。在派生类中不再添加新的同名成员(如果有同名成员,同样遵循隐藏规则),这时的 Derived 类中,通过Base1,Base2 两条派生路径继承来的基类 Base0 中的成员 var0 和 fun0 只有一份副本。

使用了虚基类之后,在派生类 Derived 中只有唯一的数据成员 var0。在建立 Derived类对象的模块中,直接使用"对象名.成员名"方式就可以唯一标识和访问这些成员。

#include <iostream>
using namespace std;

class Base0 {   //定义基类Base0
public:
    int var0;
    void fun0() { cout << "Member of Base0" << endl; }
};

class Base1 : virtual public Base0 {    //定义派生类Base1
public: //新增外部接口
    int var1;
};

class Base2 : virtual public Base0 {    //定义派生类Base2
public: //新增外部接口
    int var2;
};

class Derived : public Base1, public Base2 {    //定义派生类Derived
public: //新增外部接口
    int var;
    void fun() { cout << "Member of Derived" << endl; }
};

int main() {    //程序主函数
    Derived d;  //定义Derived类对象d
    d.var0 = 2; //直接访问虚基类的数据成员
    d.fun0();   //直接访问虚基类的函数成员
    cout << &d.Base1::var0 << ", " << &d.Base2::var0 << endl;
    cout << &d.var0;
    return 0;
}

注意,虚基类声明只是在类的派生过程中使用了 virtual 关键字。在程序主函数中,创建了一个派生类的对象d,通过成员名称就可以访问该类的成员 var0 和 fun0。

比较一下使用作用域分辨符和虚基类技术的例 7-7 和例 7-8,前者在派生类中拥有同名成员的多个副本,分别通过直接基类名来唯一标识,可以存放不同的数据、进行不同的操作,后者只维护一份成员副本。相比之下,前者可以容纳更多的数据,而后者使用更为简洁,内存空间更为节省。具体程序设计中,要根据实际问题的需要来选用合适的方法。

3. 虚基类及其派生类构造函数

  • 在例 7-8中,虚基类的使用显得非常方便、简单,这是由于该程序中所有类使用的都是编译器自动生成的默认构造函数。如果虚基类声明有非默认形式的(即带形参的)构造函数,并且没有声明默认形式的构造函数,事情就比较麻烦了。这时,在整个继承关系中,直接或间接继承虚基类的所有派生类,都必须在构造函数的成员初始化表中列出对虚基类的初始化。例如,如果例 7-8 中的虚基类声明了带参数的构造函数,程序就要改成如下形式:
#include <iostream>
using namespace std;

class Base0 {   //定义基类Base0
public:
    Base0(int var): var0(var) {}
    int var0;
    void fun0() { cout << "Member of Base0" << endl; }
};

class Base1 : virtual public Base0 {    //定义派生类Base1
public: //新增外部接口
    Base1(int var): Base0(var) {}
    int var1;
};

class Base2 : virtual public Base0 {    //定义派生类Base2
public: //新增外部接口
    Base2(int var): Base0(var) {}
    int var2;
};

class Derived : public Base1, public Base2 {    //定义派生类Derived
public: //新增外部接口
    Derived(int var): Base0(var), Base1(var), Base2(var) {}
    int var;
    void fun() { cout << "Member of Derived" << endl; }
};

int main() {    //程序主函数
    Derived d;  //定义Derived类对象d
    d.var0 = 2; //直接访问虚基类的数据成员
    d.fun0();   //直接访问虚基类的函数成员
    cout << &d.Base1::var0 << ", " << &d.Base2::var0 << endl;
    cout << &d.var0;
    return 0;
}
  • 在这里,读者不免会担心:建立 Derived 类对象 d 时,通过 Derived 类的构造函数的初始化列表,不仅直接调用了虚基类构造函数 Base0 ,对从 Base0 继承的成员 varO 进行了初始化,而且还调用了直接基类 Base1 和 Base2 的构造函数 Base1()和 Base2(),而Base1()和Base2()的初始化列表中也都有对基类 Base0 的初始化。这样,对于从虚基类继承来的成员 varO 岂不是初始化了 3 次?对于这个问题,C++编译器有很好的解决办法,我们完全不必担心,可以放心地像这样写程序。下面就来看看C++编译器处理这个问题的策略。为了叙述方便,将建立对象时所指定的类称为当时的最远派生类。例如上述程序中,建立对象d时,Derived就是最远派生类。建立一个对象时,如果这个对象中含有从虚基类继承来的成员,则虚基类的成员是由最远派生类的构造函数通过调用虚基类的构造函数进行初始化的。而且,只有最远派生类的构造函数会调用虚基类的构造函数,该派生类的其他基类(例如,上例中的Base1 和 Base2 类)对虚基类构造函数的调用都自动被忽略。

  • 构造一个类的对象的一般顺序是:

  • 如果该类有直接或间接的虚基类,则先执行虚基类的构造函数。

  • 如果该类有其他基类,则按照它们在继承声明列表中出现的次序,分别执行它们的构造函数,但构造过程中,不再执行它们的虚基类的构造函数。

  • 按照在类定义中出现的顺序,对派生类中新增的成员对象进行初始化。对于类类型的成员对象,如果出现在构造函数初始化列表中,则以其中指定的参数执行构造函数,如未出现,则执行默认构造函数;对于基本数据类型的成员对象,如果出现在构造函数的初始化列表中,则使用其中指定的值为其赋初值,否则什么也不做。

  • 执行构造函数的函数体。

7.6 深度探索

1. 组合与继承

  • 在4.4节讨论了类的组合,即一个类内嵌其他类的对象,本章又详细介绍了类的继承,它们是通过已有类来构造新类的两种基本方式,它们都使得已有对象成为新对象的一部分,从而达到代码复用的目的。那么,什么时候应当用组合,什么时候应当用继承呢?用继承时,又如何在公有继承、保护继承和私有继承之间做出选择呢?

  • 组合和继承其实反映了两种不同的对象关系。组合反映的是"有一个"(has-a)的关系。如果类 B 中存在一个类 A 的内嵌对象,表示的是每一个 B 类型的对象都"有一个"A类型的对象。A 类型的对象与 B 类型的对象是部分与整体的关系。B 类的对象虽然包括 A 类对象的全部内容(数据),但本身并不包括 A 类对象的接口。因为一般 A 类的对象会作为 B 类的私有成员存在,这样 B 类中内嵌的 A 类对象的对外接口会被 B 类隐藏起来,这些接口只能被 B 类所用,而不会直接作为 B 类的对外接口。

class Engine {
public:
  void work();
  ...
};
class Wheel {
public:
  void roll();
  ...
};
class Automobile {
public:
  void move();
  ...
private:
  Engine engine;
  Wheel wheels[4];
  ...
};
  • 上面的代码,通过组合的方式,描述了一辆汽车(Automobile)有一个发动机(Engine),一辆汽车有 4 个轮子(Wheel),汽车是整体,发动机与轮子都是部分。发动机有运转的功能,轮子有转动(roll)的功能,这些功能可以为作为整体的汽车所用,但汽车不再具有这样的功能,汽车通过对发动机、轮子等功能的整合,具备了自己的功能------移动(mo ve)。

  • 继承与组合有所不同。使用最为普遍的公有继承,反映的是"是一个"(is-a)的关系。如果类 A 是类 B 的公有基类,那么这表示每一个 B 类型的对象都"是一个" A 类型的对象。B 类型的对象与 A 类型的对象是特殊与一般的关系。7.3 节介绍的"在需要基类对象的任何地方,都可以使用公有派生类的对象来替代"这一原则,正是由这一点决定的,这就好比,白马是马,马是可以用来骑的,因此白马也是可以用来骑的。

  • 通过公有继承,B 类的对象不仅包含了 A 类对象的全部数据(这一点类似于组合),而且还包含了 A 类对象的全部接口(这一点又与组合不同)。请看下面的示例。

class Truck: public Automobile {
public:
    void load();
    void dump();
private:
    ...
};
class Pumper: public Automobile {
public:
    void water();
private:
    ...
};
  • 一个卡车(Truck),是一个汽车(Automobile),一个消防车(Pumper),也是一个汽车。卡车和消防车都是特殊的汽车,它们都具有汽车的功能------移动(move)。汽车能做的事情,它们也能做。除了具有移动的功能外,卡车还可以装货(load)、卸货(dump),消防车还具有喷水(water)的功能。

  • 一辆汽车中有一个发动机,不会有人认为一辆汽车是一个发动机;一辆卡车是一辆汽车,不会有人认为一辆卡车中有一辆汽车。分清了"整体一部分"之间的"有一个"(has-a)关系,和"特殊-一般"之间的"是一个"(is-a)关系,就能够在组合和公有继承这二者之间做出恰当选择。

  • 有时两个类间的关系会很明显,有时不那么明显,这时要根据程序的实际需要,在组合和公有继承之间做出选择。例如例7-1 中,Rectangle(矩形)类继承了Point(点)类,因此可以说矩形是一个点吗?显然不是。不过不妨这样理解:任何几何图形,如果不考虑它的大小、形状,只考虑它的位置,就都可以抽象成一个点(对比一下物理学中把有质量的物体当作质点的抽象行为,就不难理解这种抽象了),因此任何表示平面图形的对象都可以认为是一个特殊的Point类型对象。另一方面,如果采用组合 P oint类型对象的方式来构造 Rectangle类,例如:

class Rectangle {
public:
    void Recctangle(float x, float y, float w, float h): x(x), y(y), w(w), h(h) {}
    float getH() const { return h; }
    float getW() const { return w; }
private:
    Point p;
    float w, h;
};
  • 无论用继承 Point类的方式来定义 R ectangle类,还是用组合 Point类型对象的方式来定义 Rectangle类,都是有道理的,究竟用继承还是组合,要看程序的实际需要。如果程序中需要把这种图形抽象成一个个点,那么采用继承的方式实现Rectangle类就是必要的,否则完全可以用组合。

  • 至于私有继承和保护继承,由于它们不满足 7.3 节所介绍的类型兼容性规则,因此它们并不表示"是一个"(is-a)的关系,不具备公有继承由类型兼容性带来的灵活性;另一方面,它们又不如组合好用,例如通过组合的方式可以定义多个对象,而继承则不可。因此私有继承和保护继承一般较少使用。

2. 派生类对象的内存布局

  • 7.3节介绍的类型兼容规则,使得一个派生类的指针可以被隐含转换为基类的指针。这意味着,一个类型的指针,既可能指向该类型的对象,又可能指向它的派生类的对象,也就是说它所指向对象的类型是不确定的。调用一个类的成员函数时,调用的目的对象也是以指针的形式(this指针)作为参数传递给被调函数的,一个函数在执行中得到的this指针所指向的对象类型也是不确定的。那么,是什么机制保证了,在类型不确定的情况下,也可以通过这些指针访问到正确的数据呢?这就和派生类对象的内存布局有关了。派生类对象的内存布局需满足的要求是,一个基类指针,无论其指向基类对象,还是派生类对象,通过它来访问一个基类中定义的数据成员,都可以用相同的步骤。理解了这一要求,就能理解下面介绍的派生类内存布局的合理性了。

  • 对象的内存布局问题,并不是C++标准中明确规定的,不同的编译器可以有不同的实现。本节将介绍一种很多编译器在使用的、最为自然的和容易理解的对象内存布局方式。

  • 单继承的情况

  • 单继承的情况比较简单。考虑下面的情况:

class Base {...};
class Derived : public Base {...};
  • 那么在 Derived 类的对象中,Derived 从 Base 继承来的数据成员,全部放在前面,与这些数据成员在 Base 类的对象中放置的顺序保持一致,Derived 类新增的数据成员全部放在后面,如图 7-11 所示。如果出现了从 Derived 指针到 Base 指针的隐含转换,例如:
    Base * pba= new Base ();
    Derived * pd= new Derived ();
    Base * pbb=pd
    
  • 在 pd 赋给 pbb的过程中,指针值不需要改变。pba 和 pbb 这两个 Base类型的指针,虽然指向的对象具有不同的类型,但任何一个 Base数据成员到该对象首地址都具有相同的偏移量,因此使用 Base指针 pba 和 pbb访问 Base类中定义的数据成员时,可以采用相同的方式,而无须考虑具体的对象类型。

  • 多继承的情况

  • 多继承的情况就要比单继承稍微复杂一些。考虑下面的继承关系:

    class Base1 {...};
    class Base2 {...};
    class Derived: public Basel, public Base2 {...};
    

  • Derived 类继承了 Base1 类和 Base2 类,在 Derived类的对象中,前面依次存放的是从 Base1 类和 Base2 类继承而来的数据成员,其顺序与它们在 Base1 类和 Base2 类的对象中放置的顺序保持一致,Derived 类新增的数据放在它们的后面,如图 7-12 所示。Base如果出现了从 D erived 指针到 Base1 指针或 Base2 指针的隐含转换,例如:
    Base1 * pbla= new Base1();
    Base2 * pb2a= new Base2();
    Derived * pd= new Derived();
    Base1 * pblb= pd;
    Base2 * pb2b=pd;
    
  • 将pd赋给pblb指针时,与单继承时的情形相似,只需要把地址复制一遍即可。但将 pd 赋给pb2b指针时,则不能简单地执行地址复制操作,而应当在原地址的基础上加一个偏移量,使pb2b 指针指向 Derived 对象中 Base2 类的成员的首地址。这样,对于同为Base2类型指针的 pb2a 和 pb2b 来说,它们都指向 Base2 中定义的、以相同方式分布的数据成员。

  • 通过上面的介绍,我们应当认识到一个与直观不符的结论------指针转换并非都保持原先的地址不变,地址的算术运算可能在指针转换时发生。但这又不是简单的地址算术运算,因为如果Derived 指针的值为 0,则转换后的 Base2 指针值也应为 0,而非一个非 0 值。因此,在将 Derived 类型指针转换为 Base2 类型指针时,执行的操作是,先判断原指针所存地址是否为 0,如果是 0,则以 0 作为转换后的指针值,否则以原地址加上一个偏移量后得到转换后的指针值。

  • 虚拟继承的情况

  • 虚拟继承的情况更加复杂,考虑下面的继承关系:

    class Base0{...};
    class Base1: virtual publiC BaseO {...};
    class Base2: virtual public BaseO {...};
    class Derived: public Base1, public Base2 {...};
    

  • Base1 类型指针和 Base2 类型指针都可以指向 Derived 对象,而且通过这两类指针都可以访问 Base0 类中定的数据成员,但这些数据成员在 Derived 对象中只有一份。如果按照普通多继承情况下的布局,无论如何安排,都无法兼顾"通过 Base1 类型指针访问Derived 对象中的 Base0 的数据成员"和"通过 Base2 类型指针访问 Derived 对象中的Base0 的数据成员"这两个要求。因此,只能通过间接的方式来确定 Base1 对象、Base2 对象和 Derived 对象中 Base0 数据成员的位置。具体的解决办法有多种,因编译器而异。一种比较容易理解的布局方式是,在 Base1 类型对象和 Base2 类型对象中都增加一个隐含的指针,这个指针指向 Base0 中定义的数据成员的首地址。Derived 类同时继承了Base1 类和 Base2 类,因此要把两个类中的隐含指针分别继承下来,但由于 Derived 类中的 Base0 类数据成员只有一份,因此 Derived 类型对象中的这两个隐含指针指向相同的地址。通过 Base1 类型指针和 Base2 类型指针访问 Base0 类的数据成员时,都通过指针来间接访问,这样就解决了上述矛盾。Base0 类数据成员放置的位置倒不是很重要,一般来说可以放在最后。

如果发生了下面的指针赋值:

BaseO * pb0a= new BaseO();
Base1 * pb1a= new Base1();
Base2 * pb2a= new Base2();
Derived * pd= new Derived();
Base1 * pb1b=pd;
Base2 * pb2b= pd;
BaseO * pbOb=pb1b;
- 将 pd 赋给 pb1b 和 pb2b指针时,与普通多继承时的情形相似。对于 pb1a 与 pb1b这两个 Base1 类型的指针而言,它们指向不同类的对象,而且其中的 Base0 类的数据成员到这两个指针具有不同的偏移量,但指向 Base0 成员的隐含指针相对于 pb1a 和 pb1b 两个指针值的位置是相同的,因此能够通过相同的方式取得这个隐含的 BaseO指针值,进而通过相同的步骤访问到 Base0 类的数据成员,而无须考虑具体的类型。pb2a 与 pb2b 这两个类型相同却指向不同类型对象的指针,情况也是类似的。当把 pb1b 指针赋给 pb0b指针时,不能再按照将 pd 指针赋给 pb2b指针时的那种将地址值加上偏移量的方式。因为 pb1b 指针可能指向 Base1 对象或 Derived 对象,在这两种情况下,这个偏移量是不同的。这里的执行方式是,通过 pb1b 指针找到隐含的指向 Base0 类数据成员的指针,将该指针值读出,赋给 pb0b指针。这是"指针类型转换时不止是复制地址"的又一例。

3. 基类向派生类的转换及其安全性问题

  • 派生类指针可以隐含转换为基类指针,之所以允许这种转换隐含发生,是因为它是安全的转换。而基类指针要想转换为派生类指针,则转换一定要显式地进行。例如:
Base *pb = new Derived; // 隐含转换,安全
Derived *pd = static_cast<Derived*>(pd);  // 显式转换
  • void指针和具体类型指针的关系,与基类指针和派生类指针的关系,具有一定的可比性。void 指针可以指向任何类型的对象,因此 void 类型指针和具体类型的指针具有一般与特殊的关系;基类指针可以指向任何派生类的对象,因此基类指针和派生类指针也具有一般和特殊的关系。C++对这两种关系也采取相同的处理方式:从特殊的指针转换到一般的指针是安全的,因此允许隐含转换;从一般的指针转换到特殊的指针是不安全的,因此只能显式地转换。

  • 对于引用来说,情况亦如此,例如:

Derived d;
Base& rb = d;   //用Derived对象初始化一个Base类型的引用
Derived &rb = static_cast<Derived&>(rb);    //将Base类型的引用rb显式地转换为Derived类型的
  • 这里有几个问题需要做如下说明:

  • 基类对象一般无法被显式转换为派生类对象,也就是说,下面的写法是不合法的,除非Derived 类有接收 Base类型(或它的引用类型)参数的构造函数。

    Base b ;
    Derived d = static_case<Derived> (b);
    

  • 指针和引用的转换,只涉及创建新的指针或引用,而不涉及创建新的对象,而以类类型本身(并非它的指针类型或引用类型)转换的目的类型,则需要创建新的对象,创建对象就一定要调用构造函数,Derived 中只要没有显式定义接收 Base类型参数的构造函数,这种转换就没有办法执行。

  • 而从派生类对象到基类对象的转换之所以能够执行,是因为基类对象的复制构造函数接收一个基类引用的参数,而用派生类对象是可以给基类引用初始化的,因此基类的复制构造函数可以被调用,转换就能够发生。

  • 执行基类向派生类的转换时,一定要确保被转换的指针和引用所指向或引用的对象符合转换的目的类型。例如,在下面的转换中:

Derived *pd = static_cast<Derived*>(pb); 
  • 一定要保证 pb 所指向的对象具有 Derived 类型,或者是 Derived 类型的派生类。如果不满足这个条件,例如,如果上面的 pb 指针是这样得到的:
Base *pb = new Base();
  • pb 指向的是 Base类型的对象,而非 Derived类型的对象,这样转换后得到的 pd 指向对象的实际类型也是 Base,通过 pd 访问 Derived 类型特有的数据时,就会出现不确定性的错误。因此,对基类向派生类转换的不慎使用,会导致不安全的后果。

  • 引用与指针的实现方式一致,因此目的类型为引用的转换亦如此。

  • 在多重继承情况下,执行基类指针到派生类指针的显式转换时,有时需要将指针所存储的地址值进行调整后才能得到新指针的值。在7.8.2节介绍过将派生类指针隐含转换为基类指针,那实际上是一个刚好相反的过程。

  • 但是,如果 A 类型是 B 类型的虚拟基类,虽然 B 类型的指针可以隐含转换为 A 类型指针,但 A 类型指针却无法通过 static_cast隐含转换为 B 类型的指针。

  • 之所以不允许这种转换的发生,是因为 B 类型和 B 类型的派生类中,A 类型的数据的位置可能会有所不同。例如 7.8.2 节所举的虚继承例子中,Base1 对象和D erived 对象中,Base0 类型数据就处于不同的位置,一个 Base0 指针既能指向 Base1 对象,又能指向 Base1 对象,如果把 Base0 指针转换为 Base1 指针,难以计算出转换后的地址。

  • 引用与指针的实现方式一致,因此目的类型为引用的转换亦如此。

  • 如果指针转换过程中涉及 void 指针类型,即使最初的指针类型和最后的指针类型是兼容的,但只要最初和最后的类型不完全相同,转换的结果就可能是不正确的。例如,如果Derived 类公共继承了 Base1 类和 Base2 类,那么:

Derived* pd = new Derived();
void *pv = pd;
Base2 *pb = static_cast<Base2*>(pv);
  • 这一转换的结果,与直接将 pd 转换为 Base2 指针的结果是不一样的。因为正确的转换中,将 pd 转换为 Base2 指针,需要在原地址上增加一个偏移量,但这里的每步转换都涉及 void 指针,这个偏移量始终没有加上。

  • 这提醒我们,使用 void 指针时,前后的类型一定要严格相同。如果前后关系仅仅满足继承关系上的兼容性,是不可靠的。当然,最好不用void指针。

  • 通过上面几点可以看出,用 static-cast 执行涉及类类型指针或引用的转换时,有很多不安全因素,而且还存在一些限制。第8章将介绍基类向派生类转换的更安全、更灵活的方式------dynamic_cast,不过使用它要付出一定的效率代价。

8. 多态性

  • 面向对象程序设计的真正优势不仅仅在于继承,还在于将派生类对象当基类对象一样处理的功能。支持这种功能的机制就是多态和动态绑定。

8.1 多态性概述

  • 多态是指同样的消息被不同类型的对象接收时导致不同的行为。所谓消息是指对类的成员函数的调用,不同的行为是指不同的实现,也就是调用了不同的函数。事实上,在程序设计中经常在使用多态的特性。最简单的例子就是运算符,使用同样的加号"+",就可以实现整型数之间、浮点数之间、双精度浮点数之间的加法,以及这几种数据类型混合的加法运算。同样的消息------相加,被不同类型的对象------变量接收后,不同类型的变量采用不同的方式进行加法运算。如果是不同类型的变量相加,例如浮点数和整型数,则要先将整型数转换为浮点数,然后再进行加法运算,这就是典型的多态现象。

1. 多态的类型

  • 面向对象的多态性可以分为4类:重载多态、强制多态、包含多态和参数多态。前面两种统称为专用多态,而后面两种称为通用多态。之前学习过的普通函数及类的成员函数的重载都属于重载多态。本章还将讲述运算符重载,上述加法运算分别使用于浮点数、整型数之间就是重载的实例。强制多态是指将一个变元的类型加以变化,以符合一个函数或者操作的要求,前面所讲的加法运算符在进行浮点数与整型数相加时,首先进行类型强制转换,把整型数变为浮点数再相加的情况,就是强制多态的实例。

  • 包含多态是类族中定义于不同类中的同名成员函数的多态行为,主要是通过虚函数来实现。参数多态与类模板(将在第9章中介绍)相关联,在使用时必须赋予实际的类型才可以实例化。这样,由类模板实例化的各个类都具有相同的操作,而操作对象的类型却各不相同。本章介绍的重点是重载和包含两种多态类型,函数重载在第 3 章和第 4章曾做过详细的介绍,这里主要介绍运算符重载。虚函数是介绍包含多态时的关键内容。

2. 多态的实现

  • 多态从实现的角度来讲可以划分为两类:编译时的多态和运行时的多态。前者是在编译的过程中确定了同名操作的具体操作对象,而后者则是在程序运行过程中才动态地确定操作所针对的具体对象。这种确定操作的具体对象的过程就是绑定(binding)。绑定是指计算机程序自身彼此关联的过程,也就是把一个标识符名和一个存储地址联系在一起的过程;用面向对象的术语讲,就是把一条消息和一个对象的方法相结合的过程。按照绑定进行的阶段的不同,可以分为两种不同的绑定方法:静态绑定和动态绑定,这两种绑定过程中分别对应着多态的两种实现方式。

  • 绑定工作在编译连接阶段完成的情况称为静态绑定。因为绑定过程是在程序开始执行之前进行的,因此有时也称为早期绑定或前绑定。在编译、连接过程中,系统就可以根据类型匹配等特征确定程序中操作调用与执行该操作代码的关系,即确定了某一个同名标识到底是要调用哪一段程序代码。有些多态类型,其同名操作的具体对象能够在编译、连接阶段确定,通过静态绑定解决,比如重载、强制和参数多态。

  • 和静态绑定相对应,绑定工作在程序运行阶段完成的情况称为动态绑定,也称为晚期绑定或后绑定。在编译、连接过程中无法解决的绑定问题,要等到程序开始运行之后再来确定。包含多态操作对象的确定就是通过动态绑定完成的。

8.2 运算符重载

  • C++中预定义的运算符的操作对象只能是基本数据类型。实际上,对于很多用户自定义类型(比如类),也需要有类似的运算操作。例如,下面的程序段定义了一个复数类:
class Complex {
public:
    Complex(double r = 0.0, double i = 0.0): real(r), imag(i) {}
    void display() const;
private:
    double real;
    double imag;
};
  • 接下来,如果需要对a和b进行加法运算,该如何实现呢?我们当然希望能使用"+"运算符,写出表达式"a+b",但是编译的时候却会出错,因为编译器不知道该如何完成这个加法。这时候就需要自己编写程序来说明"+"在作用于Complex类对象时,该实现什么样的功能,这就是运算符重载。运算符重载是对已有的运算符赋予多重含义,使同一个运算符作用于不同类型的数据时导致不同的行为。

  • 运算符重载的实质就是函数重载。在实现过程中,首先把指定的运算表达式转化为对运算符函数的调用,将运算对象转化为运算符函数的实参,然后根据实参的类型来确定需要调用的函数,这个过程是在编译过程中完成的。

1. 运算符重载的规则

  • 运算符重载的规则如下:

  • C++中的运算符除了少数几个之外,全部可以重载,而且只能重载C++中已经有的运算符。

  • 重载之后运算符的优先级和结合性都不会改变。
  • 运算符重载是针对新类型数据的实际需要,对原有运算符进行适当的改造。一般来讲,重载的功能应当与原有功能相类似,不能改变原运算符的操作对象个数,同时至少要有一个操作对象是自定义类型。

  • C++标准规定,有些操作符是不能重载的,它们是类属关系运算符"."、成员指针运算符"*"、作用域分辨符"::"和三目运算符"?:"。前面两个运算符保证了C++中访问成员功能的含义不被改变。作用域分辨符的操作数是类型,而不是普通的表达式,也不具备重载的特征。

  • 运算符的重载形式有两种,即重载为类的非静态成员函数和重载为非成员函数。运算符重载为类的成员函数的一般语法形式为:

返回类型 operator运算符(形参表) {
    函数体;
}
- 运算符重载位非成员函数的一般语法形式为:
返回类型 operator运算符(形参表) {
    函数体;
}

  • 返回类型指定了重载运算符的返回值类型,也就是运算结果类型;operator是定义运算符重载函数的关键字;运算符即是要重载的运算符名称,必须是C++中可重载的运算符,比如要重载加法运算符,这里就写"+";形参表中给出重载运算符所需要的参数和类型。

  • 当以非成员函数形式重载运算符时,有时需要访问运算符参数所涉及类的私有成员,这时可以把该函数声明为类的友元函数。

  • 当运算符重载为类的成员函数时,函数的参数个数比原来的操作数个数要少一个(后置"++","--"除外);当重载为非成员函数时,参数个数与原操作数个数相同。两种情况的参数个数有所差异的原因是,重载为类的成员函数时,第一个操作数会被作为函数调用的目的对象,因此无须出现在参数表中,函数体中可以直接访问第一个操作数的成员;而重载为非成员函数时,运算符的所有操作数必须显式通过参数传递。

  • 运算符重载的主要优点就是可以改变现有运算符的操作方式,以用于类类型,使得程序看起来更加直观。

2. 运算符重载为成员函数

  • 运算符重载实质上就是函数重载,重载为成员函数,它就可以自由地访问本类的数据成员。实际使用时,总是通过该类的某个对象来访问重载的运算符。如果是双目运算符,左操作数是对象本身的数据,由this指针指出,右操作数则需要通过运算符重载函数的参数表来传递;如果是单目运算符,操作数由对象的this指针给出,就不再需要任何参数。下面分别介绍这两种情况。

  • 对于双目运算符 B,如果要重载为类的成员函数,使之能够实现表达式 oprd1 B oprd2,其中oprd1 为 A 类的对象,则应当把 B 重载为 A 类的成员函数,该函数只有一个形参,形参的类型是 oprd2 所属的类型。经过重载之后,表达式 oprd1 B oprd2 就相当于函数调用 oprd1.operator B (oprd2)。

  • 对于前置单目运算符U,如"-"(负号)等,如果要重载为类的成员函数,用来实现表达式 U oprd,其中 oprd 为 A 类的对象,则 U 应当重载为 A类的成员函数,函数没有形参。经过重载之后,表达式 U oprd 相当于函数调用oprd.operator U ()。

  • 再来看后置运算符"++"和"--",如果要将它们重载为类的成员函数,用来实现表达式oprd++或oprd--,其中oprd为A类的对象,那么运算符就应当重载为A类的成员函数,这时函数要带有一个整型(int)形参。重载之后,表达式oprd++和 oprd--就相当于函数调用 oprd.operator++(0)和oprd.operator--(0)。这里的 int类型参数在运算中不起任何作用,只是用于区别后置++、--与前置++、--。

例 8-1 复数类加减法运算重载为成员函数形式

这个例子是重载复数加减法运算,是一个双目运算符重载为成员函数的实例。复数的加减法所遵循的规则是实部和虚部分别相加减,运算符的两个操作数都是复数类的对象。因此,可以把"+","-"运算重载为复数类的成员函数,重载函数只有一个形参,类型同样也是复数类对象。

#include <iostream>
using namespace std;

class Complex { //复数类定义
public: //外部接口
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) { }  //构造函数
    Complex operator + (const Complex &c2) const;   //运算符+重载成员函数
    Complex operator - (const Complex &c2) const;   //运算符-重载成员函数
    void display() const;   //输出复数
private:    //私有数据成员
    double real;    //复数实部
    double imag;    //复数虚部
};  

Complex Complex::operator + (const Complex &c2) const { //重载运算符函数实现
    return Complex(real + c2.real, imag + c2.imag); //创建一个临时无名对象作为返回值
}

Complex Complex::operator - (const Complex &c2) const { //重载运算符函数实现
    return Complex(real - c2.real, imag - c2.imag); //创建一个临时无名对象作为返回值
}

void Complex::display() const {
    cout << "(" << real << ", " << imag << ")" << endl;
}

int main() {    //主函数
    Complex c1(5, 4), c2(2, 10), c3;    //定义复数类的对象
    cout << "c1 = "; c1.display();
    cout << "c2 = "; c2.display();
    c3 = c1 - c2;   //使用重载运算符完成复数减法  c3=c1.operator -(c2)
    cout << "c3 = c1 - c2 = "; c3.display();
    c3 = c1 + c2;   //使用重载运算符完成复数加法
    cout << "c3 = c1 + c2 = "; c3.display();
    return 0;
}
  • 在本例中,将复数的加减法这样的运算重载为复数类的成员函数,可以看出,除了在函数声明及实现的时候使用了关键字operator之外,运算符重载成员函数与类的普通成员函数没有什么区别。在使用的时候,可以直接通过运算符、操作数的方式来完成函数调用。这时,运算符"+","-"原有的功能都不改变,对整型数、浮点数等基本类型数据的运算仍然遵循C++预定义的规则,同时添加了新的针对复数运算的功能。"+"这个运算符,作用于不同的对象上,就会导致完全不同的操作行为,具有了更广泛的多态特征。

  • 本例重载的"+","-"函数中,都是创建一个临时的无名对象作为返回值。以加法为例:

return Complex(real + c2.real, imag + c2.imag)
  • 这是临时对象语法,它的含义是:调用 Complex 构造函数创建一个临时对象并返回它。当然,也可以按如下形式返回函数值:
Complex::Complex operator+(const Complex &c2) const {
    Complex c(real + c2.real, imag + c2.imag);
    return c;
}

程序输出的结果为:

c1 = (5, 4)
c2 = (2, 10)
c3 = c1 - c2 = (3, -6)
c3 = c1 + c2 = (7, 14)
例 8-2 将单目运算符"++"重载为成员函数形式

本例是将单目运算符重载为类的成员函数。在这里仍然使用时钟类的例子,单目运算符前置++和后置++的操作数是时钟类的对象,可以把这些运算符重载为时钟类的成员函数。对于前置单目运算符,重载函数没有形参,对于后置单目运算符,重载函数有一个int型形参。

#include <iostream>
using namespace std;
class Clock {   //时钟类声明定义
public: //外部接口
    Clock(int hour = 0, int minute = 0, int second = 0);
    void showTime() const;
    Clock& operator ++ ();  //前置单目运算符重载
    Clock operator ++ (int);    //后置单目运算符重载
private:    //私有数据成员
    int hour, minute, second;
};

Clock::Clock(int hour/* = 0 */, int minute/* = 0 */, int second/* = 0 */) { //构造函数
    if (0 <= hour && hour < 24 && 0 <= minute && minute < 60 && 0 <= second && second < 60) {
        this->hour = hour;
        this->minute = minute;
        this->second = second;
    } else
        cout << "Time error!" << endl;
}

void Clock::showTime() const {  //显示时间函数
    cout << hour << ":" << minute << ":" << second << endl;
}

Clock & Clock::operator ++ () { //前置单目运算符重载函数
    second++;
    if (second >= 60) {
        second -= 60;
        minute++;
        if (minute >= 60) {
            minute -= 60;
            hour = (hour + 1) % 24;
        }
    }
    return *this;
}

Clock Clock::operator ++ (int) {    //后置单目运算符重载
    //注意形参表中的整型参数
    Clock old = *this;
    ++(*this);  //调用前置“++”运算符
    return old;
}

int main() {
    Clock myClock(23, 59, 59);
    cout << "First time output: ";
    myClock.showTime();
    cout << "Show myClock++:    ";
    (myClock++).showTime();
    cout << "Show ++myClock:    ";
    (++myClock).showTime();
    return 0;
}
运行结果:
First time output: 23:59:59
Show myClock++:    23:59:59
Show ++myClock:    0:0:1
在本例中,把时间自增前置++和后置++运算重载为时钟类的成员函数,前置单目运算符和后置单目运算符的重载最主要的区别就在于重载函数的形参。语法规定,前置单目运算符重载为成员函数时没有形参,而后置单目运算符重载为成员函数时需要有一个int型形参。

对于函数参数表中并未使用的参数,C++允许不给出参数名。本例中重载的后置++运算符的int型参数在函数体中并未使用,纯粹是用来区别前置与后置,因此参数表中可以只给出类型名,没有参数名。

3. 运算符重载为非成员函数

  • 运算符也可以重载为非成员函数。这时,运算所需要的操作数都需要通过函数的形参表来传递,在形参表中形参从左到右的顺序就是运算符操作数的顺序。如果需要访问运算符参数对象的私有成员,可以将该函数声明为类的友元函数。

  • 不要机械地将重载运算符的非成员函数声明为类的友元函数,仅在需要访问类的私有成员或保护成员时再这样做。如果不将其声明为友元函数,该函数仅依赖于类的接口,只要类的接口不变化,该函数的实现就无须变化;如果将其声明为友元函数,该函数会依赖于类的实现,即使类的接口不变化,只要类的私有数据成员的设置发生了变化,该函数的实现就需要变化。

  • 对于双目运算符 B,如果要实现 oprd1 B oprd2,其中 oprd1 和 oprd2中只要有一个具有自定义类型,就可以将 B 重载为非成员函数,函数的形参为oprd1 和 oprd2。经过重载之后,表达式 oprd1 B oprd2 就相当于函数调用operator B(oprd1,oprd2)。

  • 对于前置单目运算符 U ,如"-"(负号)等,如果要实现表达式 Uoprd,其中oprd 具有自定义类型,就可以将 U 重载为非成员函数,函数的形参为oprd。经过重载之后,表达式U oprd 相当于函数调用 operator U (oprd)。

  • 对于后置运算符++和--,如果要实现表达式 oprd++ 或 oprd--,其中oprd为自定义类型,那么运算符就可以重载为非成员函数。这时函数的形参有两个,一个是oprd,另一个是int类型形参。第二个参数是用于与前置运算符函数相区别的。重载之后,表达式oprd ++和 oprd-- 就相当于函数调用 operator++(oprd,O) 和 operator--(oprd,0)。

例 8-3 以非成员函数形式重载 Complex 的加减法运算和"<<"运算符
  • 本例将运算符"+","-"重载为非成员函数,并将其声明为 Complex 类的友元函数,使之实现复数加减法。本例所针对的问题和例 8-1完全相同,运算符的两个操作数都是复数类的对象,因此重载函数有两个复数对象作为形参。

  • 另外,本例重载了"<<"运算符,可以对 cout使用"<<"操作符来输出一个Complex对象,使输出变得更加方便、直观。

#include <iostream>
using namespace std;

class Complex { //复数类定义
public: //外部接口
    Complex(double r = 0.0, double i = 0.0) : real(r), imag(i) { }  //构造函数
    friend Complex operator + (const Complex &cc1, const Complex &cc2); //运算符+重载
    friend Complex operator - (const Complex &cc1, const Complex &cc2); //运算符-重载
    friend ostream & operator << (ostream &out, const Complex &c);      //运算符<<重载
private:    //私有数据成员
    double real;    //复数实部
    double imag;    //复数虚部
};  

Complex operator + (const Complex &cc1, const Complex &cc2) {   //重载运算符函数实现
    return Complex(cc1.real + cc2.real, cc1.imag + cc2.imag);
}

Complex operator - (const Complex &cc1, const Complex &cc2) {   //重载运算符函数实现
    return Complex(cc1.real - cc2.real, cc1.imag - cc2.imag);
}

ostream & operator << (ostream &out, const Complex &c) {    //重载运算符函数实现
    out << "(" << c.real << ", " << c.imag << ")";
    return out;
}

int main() {    //主函数
    Complex c1(5, 4), c2(2, 10), c3;    //定义复数类的对象
    cout << "c1 = " << c1 << endl;
    cout << "c2 = " << c2 << endl;
    c3 = c1 - c2;   //使用重载运算符完成复数减法
    cout << "c3 = c1 - c2 = " << c3 << endl;
    c3 = c1 + c2;   //使用重载运算符完成复数加法
    cout << "c3 = c1 + c2 = " << c3 << endl;
    return 0;
}
  • 将运算符重载为类的非成员函数,就必须把操作数全部通过形参的方式传递给运算符重载函数。"<<"操作符的左操作数为 ostream 类型的引用,ostream 是cout类型的一个基类,右操作数是 Com plex 类型的引用,这样在执行 cout << c1 时,就会调用operator << (cout,c1)。该函数把通过第一个参数传入的ostream 对象以引用形式返回,这是为了支持形如"cout << c1 << c2"的连续输出,因为第二个"<< "运算符的左操作数是第一个"<<"运算符的返回结果。和例 8-1 相比,主函数中"+","-"的用法没有改动,而对Complex对象的输出更加直观了,程序运行的结果完全相同。

  • 运算符的两种重载形式各有千秋。成员函数的重载方式更加方便,但有时出于以下原因,需要使用非成员函数的重载方式。

  • 要重载的操作符的第一个操作数不是可以更改的类型,例如上例中"<<"运算符的第一个操作数的类型为 ostream,是标准库的类型,无法向其中添加成员函数。

  • 以非成员函数形式重载,支持更灵活的类型转换。例如例 8-3中,可以直接使用5.0+ c1,因为 Complex 的构造函数使得实数可以被隐含转换为 Complex 类型。这样5.0 + c1 就会以operator+(Complex(5.0),c1)的方式来执行,c1+ 5.0 也一样,从而支持了实数和复数的相加,很方便也很直观;而以成员函数重载时,左操作数必须具有Complex类型,不能是实数(因为调用成员函数的目的对象不会被隐含转换),只有右操作数可以是实数(因为右操作数是函数的参数,可以隐含转换)。

  • 这里,只介绍了几个简单运算符的重载,还有一些运算符,如"[]"、"="、类型转换等,进行重载时有一些与众不同的情况。考虑到对于初学者而言,难以一下子接受太多新内容,所以暂且不讲。

8.3 虚函数

  • 在7.7节中介绍了一个个人银行账户管理的简单程序,其中遗留了一个问题:如何利用一个循环结构依次处理同一类族中不同类的对象。现在将例7-10 中的主函数改写为如下形式,看看运行时会出现什么情况。
#include "account.h"
#include <iostream>
using namespace std;

int main() {
    Date date(2008, 11, 1); // 起始日期
    // 建立几个账户
    SavingsAccount sa1(date, "S3755217", 0.015);
    SavingsAccount sa2(date, "02342342", 0.015);
    CreditAccount ca(date, "C5392394", 10000, 0.0005, 50);
    Account *accounts[] = { &sa1, &sa2, &ca };
    const int n = sizeof(accounts) / sizeof(Account *); // 账户总数

    for (int i = 0; i < n; i++) {
        accounts[i]->show();
        cout << endl;
    }
    return 0;
}

运行结果为
2008-11-1 # S3755217 created
2008-11-1 # 02342342 created
2008-11-1 # C5392394 created
S3755217 Balance: 0
02342342 Balance: 0
C5392394 Balance: 0
  • 从 show 函数的输出结果可以看出,无论accounts[i]指向的是哪种类型的实例,通过accounts[i] -> show() 调用的函数都是 Account类中定义的show 函数。而我们希望根据accounts[i]所指向的实例类型决定哪个 show 函数被调用,当i == 2 时被调用的应当是 CreditAccount类中定义的show函数。这一问题如何解决呢?这就要应用虚函数来实现多态性。

  • 虚函数是动态绑定的基础。虚函数必须是非静态的成员函数。虚函数经过派生之后,在类族中就可以实现运行过程中的多态。

  • 根据赋值兼容规则,可以使用派生类的对象代替基类对象。如果用基类类型的指针指向派生类对象,就可以通过这个指针来访问该对象,问题是访问到的只是从基类继承来的同名成员。解决这一问题的办法是:如果需要通过基类的指针指向派生类的对象,并访问某个与基类同名的成员,那么首先在基类中将这个同名函数说明为虚函数。这样,通过基类类型的指针,就可以使属于不同派生类的不同对象产生不同的行为,从而实现运行过程的多态。

1. 一般虚函数成员

一般虚函数成员的声明语法是:

virtua1 函数类型函数名(形参表);
  • 这实际上就是在类的定义中使用virtual关键字来限定成员函数,虚函数声明只能出现在类定义中的函数原型声明中,而不能在成员函数实现的时候。

  • 运行过程中的多态需要满足3个条件,第一是类之间满足赋值兼容规则,第二是要声明虚函数,第三是要由成员函数来调用或者是通过指针、引用来访问虚函数。如果是使用对象名来访问虚函数,则绑定在编译过程中就可以进行(静态绑定),而无须在运行过程中进行。

  • 虚函数一般不声明为内联函数,因为对虚函数的调用需要动态绑定,而对内联函数的处理是静态的,所以虚函数一般不能以内联函数处理。但将虚函数声明为内联函数也不会引起错误。

例 8-4 虚函数成员

这个程序是由例 7-3"类型兼容规则实例"改进而来。在基类 Base1 中将原有成员display()声明为虚函数,其他部分都没有做任何修改。与例 7-3不同的是,在使用基类类型的指针时,它指向哪个派生类的对象,就可以通过它访问那个对象的同名虚成员函数。

#include <iostream>
using namespace std;

class Base1 { //基类Base1定义
public:
    virtual void display() const;   //虚函数
};
void Base1::display() const {
    cout << "Base1::display()" << endl;
}

class Base2 : public Base1 { //公有派生类Base2定义
public:
    virtual void display() const;   //覆盖基类的虚函数
};
void Base2::display() const {
    cout << "Base2::display()" << endl;
}

class Derived : public Base2 { //公有派生类Derived定义
public:
    virtual void display() const;   //覆盖基类的虚函数
};
void Derived::display() const {
    cout << "Derived::display()" << endl;
}

void fun(Base1* ptr) { //参数为指向基类对象的指针
    ptr->display(); //"对象指针->成员名"
}

int main() {    //主函数
    Base1 base1;    //定义Base1类对象
    Base2 base2;    //定义Base2类对象
    Derived derived;    //定义Derived类对象

    fun(&base1);    //用Base1对象的指针调用fun函数
    fun(&base2);    //用Base2对象的指针调用fun函数
    fun(&derived);  //用Derived对象的指针调用fun函数

    return 0;
}
- 程序中类 Base1,Base2 和 Derived属于同一个类族,而且是通过公有派生而来,因此满足赋值兼容规则。同时,基类Base1 的函数成员display()声明为虚函数,程序中使用对象指针来访问函数成员。这样绑定过程就是在运行中完成,实现了运行中的多态。通过基类类型的指针就可以访问到正在指向的对象的成员,这样,能够对同一类族中的对象进行统一的处理,抽象程度更高,程序更简洁、更高效。

  • 在本程序中,派生类并没有显式给出虚函数声明,这时系统就会遵循以下规则来判断派生类的一个函数成员是不是虚函数:

  • 该函数是否与基类的虚函数有相同的名称。

  • 该函数是否与基类的虚函数有相同的参数个数及相同的对应参数类型。

  • 该函数是否与基类的虚函数有相同的返回值或者满足赋值兼容规则的指针、引用型的返回值。

  • 如果从名称、参数及返回值 3个方面检查之后,派生类的函数满足了上述条件,就会自动确定为虚函数。这时,派生类的虚函数便覆盖了基类的虚函数。不仅如此,派生类中的虚函数还会隐藏基类中同名函数的所有其他重载形式。

  • 用指向派生类对象的指针仍然可以调用基类中被派生类覆盖的成员函数,方法是使用"::"进行限定。例如,例8-4 中如果把fun 函数中的 ptr -> display() 改为ptr -> Base1::display(),无论 ptr所指向对象的动态类型是什么,最终被调用的总是Base1 类的display()函数。在派生类的函数中,有时需要先调用基类被覆盖的函数,再执行派生类特有的操作,这时就可以用"基类名::函数名(...)"来调用基类中被覆盖的函数。

  • 派生类覆盖基类的成员函数时,既可以使用 virtual关键字,也可以不使用,二者没有差别。很多人习惯于在派生类的函数中也使用 virtual 关键字,因为这样可以清楚地提示这是一个虚函数。

  • 当基类构造函数调用虚函数时,不会调用派生类的虚函数。假设有基类 Base和派生类Derived,两个类中有虚成员函数 virt(),在执行派生类 Derived的构造函数时,需要首先调用Base 类的构造函数。如果Base::Base()调用了虚函数 virt(),则被调用的是 Base::virt(),而不是Derived::virt()。这是因为当基类被构造时,对象还不是一个派生类的对象。同样,当基类被析构时,对象已经不再是一个派生类对象了,所以如果Base::~Base()调用了 virt(),则被调用的是 Base::virt(),而不是 Derived::virt()。

  • 只有虚函数是动态绑定的,如果派生类需要修改基类的行为(即重写与基类函数同名的函数),就应该在基类中将相应的函数声明为虚函数。而基类中声明的非虚函数,通常代表那些不希望被派生类改变的功能,也是不能实现多态的。一般不要重写继承而来的非虚函数(虽然语法对此没有强行限制),因为那会导致通过基类指针和派生类的指针或对象调用同名函数时,产生不同的结果,从而引起混乱。

  • 在重写继承来的虚函数时,如果函数有默认形参值,不要重新定义不同的值。原因是:虽然虚函数是动态绑定的,但默认形参值是静态绑定的。也就是说,通过一个指向派生类对象的基类指针,可以访问到派生类的虚函数,但默认形参值却只能来自基类的定义。

  • 最后,需强调的是,只有通过基类的指针或引用调用虚函数时,才会发生动态绑定。例如,如果将例8-4 中的 fun 函数的参数类型设定为 Base1 而非 Base1 *,那么 3 次 fun函数的调用中,被执行的函数都会是Base1::display()。这是因为,基类的指针可以指向派生类的对象,基类的引用可以作为派生类对象的别名,但基类的对象却不能表示派生类的对象。例如:

Derived d;  // 定义一个派生类对象
Base *ptr = &d;  // 基类的指针指向派生类的对象
Base &ref = d;   // 基类的引用作为派生类对象的别名
Base b = d; //  调用Base1的复制构造函数用d初始化b,不会发生动态绑定,b的类型是Base,而不是Derived
  • 这里,Base b = d 会用 Derived 类型的对象 d 为 Base 类型的对象 b初始化,初始化时使用的是 B a se 的复制构造函数。由于复制构造函数接收的是Base 类型的常引用, Derived 类型的d符合类型兼容性规则,可以作为参数传递给它。由于执行的是 Base的复制构造函数,只有 B ase类型的成员会被复制,Derived类中新增的数据成员既不会被复制,也没有空间去存储,因此生成的对象是基类 Base的对象。这种用派生类对象复制构造基类对象的行为称做对象切片。这时,如果用b调用 B ase类的虚函数,调用的目的对象是对象切片后得到的 Base对象,与 Derived 类型的 d 对象全无关系,对象的类型很明确,因此无须动态绑定。

2. 虚析构函数

  • 在C++中,不能声明虚构造函数,但是可以声明虚析构函数。析构函数没有类型,也没有参数,和普通成员函数相比,虚析构函数情况略为简单些。

  • 虚析构函数的声明语法为:

virtual ~ClassName() {}
  • 如果一个类的析构函数是虚函数,那么由它派生而来的所有子类的析构函数也是虚函数。析构函数设置为虚函数之后,在使用指针引用时可以动态绑定,实现运行时的多态,保证使用基类类型的指针就能够调用适当的析构函数针对不同的对象进行清理工作。

  • 简单来说,如果有可能通过基类指针调用对象的析构函数(通过delete),就需要让基类的析构函数成为虚函数,否则会产生不确定的后果。

例 8-5 虚析构函数举例

首先,请看一个没有使用虚析构函数的程序。

#include <iostream>
using namespace std;

class Base {
public:
    ~Base();
};
Base::~Base() {
    cout << "Base destructor" << endl;
}

class Derived : public Base {
public:
    Derived();
    ~Derived();
private:
    int* p;
};
Derived::Derived() {
    p = new int(0);
}
Derived::~Derived() {
    cout << "Derived destructor" << endl;
    delete p;
}

void fun(Base* b) {
    delete b;
}

int main() {
    Base* b = new Derived();
    fun(b);
    return 0;
}
- 运行时输出信息为:
Base destructor
- 这说明,通过基类指针删除派生类对象时调用的是基类的析构函数,派生类的析构函数没有被执行,因此派生类对象中动态分配的内存空间没有得到释放,造成了内存泄漏。也就是说派生类对象成员p所指向的内存空间,在对象消失后既不能被本程序继续使用也没有被释放。对于内存需求量较大、长期连续运行的程序来说,如果持续发生这样的错误是很危险的,最终将导致因内存不足而引起程序终止。

  • 避免上述错误的有效方法就是将析构函数声明为虚函数:
#include <iostream>
using namespace std;

class Base {
public:
    virtual ~Base();
};
Base::~Base() {
    cout << "Base destructor" << endl;
}

class Derived : public Base {
public:
    Derived();
    virtual ~Derived();
private:
    int *p;
};
Derived::Derived() {
    p = new int(0);
}
Derived::~Derived() {
    cout << "Derived destructor" << endl;
    delete p;
}

int main() {
    Base *basePtrs[10];
    for (int i = 0; i < 10; i++)
    {
        if (i % 2 == 0)
            basePtrs[i] = new Derived();
        else
            basePtrs[i] = new Base();
    }
    for (int i = 0; i < 10; i++)
    {
        delete basePtrs[i];
    }
    return 0;
}
  • 这时运行时的输出信息为:

Derived destructor
Base destructor
Base destructor
Base destructor
Base destructor
Derived destructor
Base destructor
Base destructor
Base destructor
Derived destructor
- 这说明派生类的析构函数被调用了,派生类对象中动态申请的内存空间被正确地释放了。这是由于使用了虚析构函数,实现了多态。

8.4 纯虚函数与抽象类

  • 抽象类是一种特殊的类,它为一个类族提供统一的操作界面。抽象类是为了抽象和设计的目的而建立的。可以说,建立抽象类,就是为了通过它多态地使用其中的成员函数。抽象类处于类层次的上层,一个抽象类自身无法实例化,也就是说我们无法定义一个抽象类的对象,只能通过继承机制,生成抽象类的非抽象派生类,然后再实例化。

  • 抽象类是带有纯虚函数的类。为了学习抽象类,下面先来了解纯虚函数。

1. 纯虚函数

  • 在7.7节中介绍的个人银行账户管理程序中遗留的另一个问题是,如何将不同派生类中的 deposit,withdraw 和 settle 这些用来执行相同类型操作的函数之间建立起联系。通过 8.3节的学习,会很容易想到在基类 Account中也声明几个具有相同原型的函数,并且将它们作为虚函数,这样派生类中的这几个函数就是对基类相应函数的覆盖,通过基类的指针调用这些函数时,派生类的相应函数将被实际调用。然而,基类并不知道该如何处理deposit,withdraw 和settle这些操作,无法给出有意义的实现。对于这种在基类中无法实现的函数,能否在基类中只说明函数原型用来规定整个类族的统一接口形式,而在派生类中再给出函数的具体实现呢?在C++中提供了纯虚函数来实现这一功能。

  • 纯虚函数是一个在基类中声明的虚函数,它在该基类中没有定义具体的操作内容,要求各派生类根据实际需要给出各自的定义。纯虚函数的声明格式为:

    virtual 函数类型函数名参数表=0;
    

  • 实际上,它与一般虚函数成员的原型在书写格式上的不同就在于后面加了"=0"。声明为纯虚函数之后,基类中就可以不再给出函数的实现部分。纯虚函数的函数体由派生类给出。

  • 基类中仍然允许对纯虚函数给出实现,但即使给出实现,也必须由派生类覆盖,否则无法实例化。在基类中对纯虚函数定义的函数体的调用,必须通过"基类名::函数名(参数表)"的形式。如果将析构函数声明为纯虚函数,必须给出它的实现,因为派生类的析构函数体执行完后需要调用基类的纯虚函数。

  • 注意纯虚函数不同于函数体为空的虚函数:纯虚函数根本就没有函数体,而空的虚函数的函数体为空;前者所在的类是抽象类,不能直接进行实例化,而后者所在的类是可以实例化的。它们共同的特点是都可以派生出新的类,然后在新类中给出虚函数新的实现,而且这种新的实现可以具有多态特征。

2. 抽象类

  • 带有纯虚函数的类是抽象类。抽象类的主要作用是通过它为一个类族建立一个公共的接口,使它们能够更有效地发挥多态特性。抽象类声明了一个类族派生类的共同接口,而接口的完整实现,即纯虚函数的函数体,要由派生类自己定义。

  • 抽象类派生出新的类之后,如果派生类给出所有纯虚函数的函数实现,这个派生类就可以定义自己的对象,因而不再是抽象类;反之,如果派生类没有给出全部纯虚函数的实现,这时的派生类仍然是一个抽象类。

  • 抽象类不能实例化,即不能定义一个抽象类的对象,但是可以定义一个抽象类的指针和引用。通过指针或引用,就可以指向并访问派生类的对象,进而访问派生类的成员,这种访问是具有多态特征的。

例 8-6 抽象类举例
  • 这个程序是由例 8-4 改进而来。在基类 Base1 中将成员display()声明为纯虚函数,这样,基类 Base1 就是一个抽象类。我们无法定义 Base1 类的对象,但是可以定义 Base1 类的指针和引用。Base1 类经过公有派生产生了 Base2 类,Base2 类作为新的基类又派生出Derived类。使用抽象基类 Base1 类型的指针,当它指向某个派生类的对象时,就可以通过访问该对象的虚成员函数。
#include <iostream>
using namespace std;

class Base1 { //基类Base1定义
public:
    virtual void display() const = 0;   //纯虚函数
};

class Base2: public Base1 { //公有派生类Base2定义
public:
    void display() const;   //覆盖基类的虚函数
};
void Base2::display() const {
    cout << "Base2::display()" << endl;
}

class Derived: public Base2 { //公有派生类Derived定义
public:
    void display() const;   //覆盖基类的虚函数
};
void Derived::display() const {
    cout << "Derived::display()" << endl;
}

void fun(Base1 *ptr) { //参数为指向基类对象的指针
    ptr->display(); //"对象指针->成员名"
}

int main() {    //主函数
    Base2 base2;    //定义Base2类对象
    Derived derived;    //定义Derived类对象
    fun(&base2);    //用Base2对象的指针调用fun函数
    fun(&derived);  //用Derived对象的指针调用fun函数
    return 0;
}
  • 程序中类 Base1,Base2 和 Derived 属于同一个类族,抽象类 Base1 通过纯虚函数为整个类族提供了通用的外部接口语义。通过公有派生而来的子类给出了纯虚函数的具体实现,因此是非抽象类,可以定义派生类的对象,同时根据赋值兼容规则,抽象类 Base1 类型的指针也可以指向任何一个派生类的对象。在 fun 函数中通过基类 Basc1 的指针 ptr就可以访问到 ptr指向的派生类 Base2 和 Derived 类对象的成员。这样就实现了对同一类族中的对象进行统一的多态处理。

  • 而且,程序中派生类的虚函数并没有用关键字virtual显式说明,因为它们与基类的纯虚函数具有相同的名称、参数及返回值,由系统自动判断确定其为虚函数。在派生类的display函数原型声明中使用 virtual也是没有错的。

8.5 深度探索

1. 多态类型与非多态类型

  • C++的类类型分为两类------多态类型和非多态类型。多态类型是指有虚函数的类类型,非多态类型是指所有的其他类型。之所以要将这两种类型加以区别,一是因为二者具有不一样的语言特性,二是由于在设计过程中,有关这两种类型的原则和理念有所不同。

  • 基类的指针可以指向派生类的对象。如果该基类是多态类型,那么通过该指针调用基类的虚函数时,实际执行的操作是由派生类决定的。从这个意义上讲,派生类只是继承了基类的接口,但不必继承基类中虚函数的实现,对基类虚函数的调用可以反映派生类的特殊性。

  • 设计多态类型的一个重要原则是,把多态类型的析构函数设定为虚函数。请看下面的程序(Bread 和 Icecream 都是 Food 的派生类):

Food *f;
if (weatherIsHot()) {
    f = new Icecream();
} else {
    f = new Chocolate();
}
eat(f);
delete f;
  • 上面根据不同的情况(天气的冷热)来决定创建不同类型的对象(冰淇淋或巧克力),但它们都具有相同的基类(食物),所以可以在堆上创建不同类型的对象而且都用基类指针f来追踪和访问,最终也需要通过 f来删除该对象。必须将 Food 的析构函数声明为虚函数,才能够避免通过指针删除一个对象时发生不确定行为。

  • 对于非多态类型来说,如果将其作为基类,虽然这时它的指针可以指向派生类的对象,但通过该指针所做的操作,只能是基类本身的操作。派生类不仅继承了基类的接口,还完全继承了基类的实现。通过基类指针(对于引用亦如此,不再赘述)调用基类成员函数,只能体现基类特性,继承的作用在这里打了很大的折扣,因为对虚函数的覆盖是继承所能提供的最大便利。凡是能通过继承非多态的基类实现的派生类,一般都能够用组合的方式加以实现。另一方面,由于基类不具有虚析构函数,对它进行派生时,删除派生类的堆对象只能通过派生类的指针,而通过基类指针删除时会出现不确定性问题。删除一个对象时所发生的行为会因指向它的指针类型的不同而有所不同,这很容易引起混乱。因此,对非多态类的公有继承,应当慎重,而且一般没有太大必要。

  • 非多态类型的行为是完全静态的,不如多态类型灵活,但也有其优点------由于每个成员函数的实现都是静态绑定的,函数的执行效率更高,而且不需要分配额外的空间保存实现动态绑定所需的信息(具体情况将在8.7.3节中介绍)。一般而言,如果一个函数的执行方式十分明确,不需要任何特殊处理,不希望派生类提供特殊的实现,就应将它声明为非虚函数。如果一个类的所有函数都具有这个特点,就把这个类作为非多态类型。将一个类型设计为非多态类型,一般意味着不希望其他类对它进行公有继承。换句话说,如果需要其他类对其进行公有继承,则应当将其设计为多态类型。如果基类的每个普通的成员函数都不宜让派生类覆盖,则应至少为它声明一个虚析构函数。

  • 例如例 8-2 的 Complex类,它的主要操作------加法、减法、输出,其执行方式都是相当明确的。只要一个对象是复数,这些操作的执行方式都是普遍适用的,那么它的成员函数都没有被覆盖的需要。另外,作为 Complex 类来说,也没有必要通过继承它的方式来实现"更特殊的Complex类"的必要。因此把 Complex设计为一个非多态类型是比较合适的。

2. 运行时类型识别

  • 基类的指针可以指向派生类的对象,通过这样的指针(引用的情况亦如此,不再赘述),虽然可以利用多态性来执行派生类提供的功能,但这仅限于调用基类中声明的虚函数。如果希望对于一部分派生类的对象,调用派生类中引入的新函数,则无法通过基类指针进行。解决办法之一是用7.8.3 节介绍的static_cast,执行基类指针向派生类指针的显式转换,但那是一种不太安全的转换,只能在指针所指向对象的类型明确的情况下执行。若对象类型与转换的目的类型不兼容(即对象类型不是转换的目的类型及其派生类),则程序会发生不确定的行为。而有时只有在运行时才能知道指针所指对象的实际类型是什么,这就需要在运行时对对象的具体类型进行识别。C++提供了两种运行时类型识别的机制,下面分别加以介绍。
用dynamic_cast执行基类向派生类的转换
  • dynam ic_cast是与 static_cast,const_cast,reinterpret_cast并列的 4 种类型转换操作符之一。它可以将基类的指针显式转换为派生类的指针,或将基类的引用显式转换为派生类的引用。但与static_cast不同的是,它执行的不是无条件的转换,它在转换前会检查指针(或引用)所指向对象的实际类型是否与转换的目的类型兼容,如果兼容转换才会发生,才能得到派生类的指针(或引用),否则:

  • 如果执行的是指针类型的转换,会得到空指针。

  • 如果执行的是引用类型的转换,会抛出异常。

  • 另外,转换前类型必须是指向多态类型的指针,或多态类型的引用,而不能是指向非多态类型的指针或非多态类型的引用,这是因为C++只为多态类型在运行时保存用于运行时类型识别的信息。这从另一个方面说明了非多态类型为什么不宜被公有继承。

  • 当原始类型为多态类型的指针时,目的类型除了是派生类指针外,还可以是void指针,例如 dynam ic_cast< void *> (p)。这时所执行的实际操作是,先将 p 指针转换为它所指向的对象的实际类型的指针,再将其转换为 void指针。换句话说,就是得到p所指向对象的首地址(请注意,在多继承存在的情况下,基类指针存储的地址未必是对象的首地址)。

例 8-9 dynamic_cast用法示例
#include <iostream>
using namespace std;

class Base {
public:
    virtual void fun1() { cout << "Base::fun1()" << endl; }
    virtual ~Base() { }
};

class Derived1: public Base {
public:
    virtual void fun1() { cout << "Derived1::fun1()" << endl; }
    virtual void fun2() { cout << "Derived1::fun2()" << endl; }
};

class Derived2: public Derived1 {
public:
    virtual void fun1() { cout << "Derived2::fun1()" << endl; }
    virtual void fun2() { cout << "Derived2::fun2()" << endl; }
}; 

void fun(Base *b) {
    b->fun1();
    Derived1 *d = dynamic_cast<Derived1 *>(b);  //尝试将b转换为Derived1指针
    if (d != 0) d->fun2();  //判断转换是否成功
}

int main() {
    Base b;
    fun(&b);
    Derived1 d1;
    fun(&d1);
    Derived2 d2;
    fun(&d2);
    return 0;
}
  • 由于 fun1 函数是基类 Base中定义的函数,通过 Base类的指针 b 可以直接调用fun1()函数。fun2 函数是派生类 Derived1 中引入的新函数,只能对 Derived1 和 Derived2 类的对象调用。因此在fun 函数中,需要用dynamic_cast将 Base指针 b转换为 Derived指针d,并根据转换结果是否为空指针来判断转换是否成功,只有转换成功了,才调用fun2 函数。d1 是 Derived1 类型的对象,对指向 d1的指针执行转换,自然能够成功得到 D erived1 类型的指针;d2 是 Derived2 类型的对象,由于 D erived2 是 D erived1 的派生类,对指向 d2 的指针执行转换,也能够成功得到 Derived1 类型的指针。
用typeid获取运行时类型信息
  • typeid 是 C++的一个关键字,用它可以获得一个类型的相关信息。它有两种语法形式:
    typeid 表达式
    
    typeid 类型说明符
    
  • typeid既可以作用于一个表达式,从而得到这个表达式的类型信息,也可以直接作用于一个类型的说明符。例如:
typeid(5 + 3)   // 得到表达式5 + 3的结果类型
typeid(int)     // 得到int类型
  • 通过 typeid 得到的是一个 type_info 类型的常引用。type_info 是C++标准库中的一个类,专用于在运行时表示类型信息,它定义在 typeinfo 头文件中。type_info 类有一个名为 name 的函数,用来获得类型的名称。其原型如下:
const char* name() const;
  • 此外,它还重载了"=="和"!="操作符,使得两个 type_info 对象之间可以进行比较,从而判定两个类型是否相同。

  • 如果 typeid 所作用于的表达式具有多态类型,那么这个表达式会被求值,用typeid得到的是用于描述表达式求值结果的运行时类型(动态类型)的 type_info对象的常引用。而如果表达式具有非多态类型,那么用 typeid 得到的是表达式的静态类型,由于这个静态类型在编译时就能确定,这时表达式不会被求值。因此,虽然 typeid 可以作用于任何类型的表达式,但只有它作用于多态类型的表达式时,进行的才是运行时类型识别,否则只是简单的静态类型信息的获取。

例 8-10 typeid 用法示例
//8_10.cpp
#include <iostream>
#include <typeinfo>
using namespace std;

class Base {
public:
    virtual ~Base() { }
};
class Derived: public Base { };

void fun(Base *b) {
    //得到表示b和*b类型信息的对象
    const type_info &info1 = typeid(b);
    const type_info &info2 = typeid(*b);
    cout << "typeid(b): " << info1.name() << endl;
    cout << "typeid(*b): " << info2.name() << endl;
    if (info2 == typeid(Base))  //判断*b是否为Base类型
        cout << "A base class!" << endl;
}

int main() {
    Base b;
    fun(&b);
    Derived d;
    fun(&d);
    return 0;
}

运行时输出信息为:

typeid(b): P4Base
typeid(*b): 4Base
A base class!
typeid(b): P4Base
typeid(*b): 7Derived
  • 该例中,由于 b 的类型是 Base,而 Base 是多态类型,所以用 typeid(b)得到的是 b指针所指向对象的具体类型,因此两次调用 fun函数时得到了不同结果。虽然 b 是指向多态类型的指针,但指针类型本身不是多态类型,因此两次调用fun函数时,用 typeid(b)得到的是相同的结果。

  • 本例中还直接对类型名 Base使用 typeid,将其与 typeid(*b)的结果比较时,只有第一次用指向 B a se实例的指针调用 fun函数时,比较的结果才为 tru e;第二次传入的 d 对象虽然也具有 Base类型,但它最特殊的类型是 D erived,因此与 typeid(Base)的比较结果为false。以此可以看出,使用 typeid只能判断一个对象是否为某个具体类型,而不会把它的子类型也包括在内。如要达到判断一个对象是否为某个类型或其子类型的目的,还是用dynam ic_cast更方便。

  • C++标准中并没有明确规定 type_info 对象的 name()成员函数所返回字符串的构造方式,因此各个编译器的实现会有所不同。

  • 运行时类型识别机制提供了很大的灵活性,然而使用这一机制时也需要付出一定的效率代价,因此不能把它作为一种常规手段。多数情况下,派生类的特殊性是可以通过在基类中定义虚函数加以体现的,运行时类型检查只是一种辅助性手段,在必要时才使用。

3. 虚函数动态绑定的实现原理

  • 通过指针、引用来调用一个虚函数,实际被调用的函数到运行时才能确定,似乎有一种神秘感,使得我们不能够用思考普通函数的方式去思考虚函数。对于初学者来说,对虚函数保持着这种神秘感倒也无妨,只要清楚它的用法就好,但如果想深究一步,也并不困难。

  • 动态绑定的关键是,在运行时决定被调用的函数,这个要求很容易让我们想起第6章曾经介绍过的函数指针(6.2.10节介绍过函数指针,6.2.11节介绍过指向类的非静态成员的指针)。一个函数指针可以被赋予不同函数的入口地址,如果通过函数指针去调用函数,实际被调用的函数一般到了运行时才能确定,因此通过函数指针去调用函数,也是一种动态绑定,是一种由源程序进行显式控制的动态绑定。而虚函数的动态绑定,一些控制细节被隐藏了起来,由编译器自动处理了。

  • 编译器实现虚函数的动态绑定的细节,并没有在C++标准中规定,因此会因编译器而异。下面以下列代码为例,进行讨论。

class Base {
public:
    virtual void f();
    virtual void g();
private:
    int i;
};
class Derived: public Base {
public:
    virtual void f();
    virtual void h();
private:
    int j;
};
  • 一种最直接的处理方式是,在每个对象中,除了存储数据成员外,还为每个虚函数设置一个函数指针,分别存放这些虚函数对应的代码的入口地址。由于派生类也要继承这些虚函数的接口,因此也保留这些指针,而把派生类中引入的新的数据成员和函数指针置于从基类继承下来的数据成员和函数指针之后(即保持7.8.2节所述的对象布局)。在各个类的构造函数中,为各个函数指针初始化,使得基类对象中的函数指针指向为基类定义的函数,派生类对象中的函数指针指向派生类覆盖后的函数。在通过指针或引用进行函数调用时,先读取相应的函数指针,再通过函数指针调用相应的函数。由于 Derived 类覆盖了 Base 类的 f 函数,因此 Base 对象和 Derived 对象中为 f 函数设置的指针,指向不同的函数代码;Derived 类未覆盖Base类的 g 函数,因此两个类的对象中为g函数设置的指针,指向相同的函数代码;至于h函数,它是派生类新增的函数,只有 Derived 对象中有为它准备的指针。

  • 这种方式存在一个致命的问题------占用的额外空间太大,例如每个 Base对象要占用两个指针的额外空间,每个 Derived 对象要占用 3 个指针的额外空间。这并不是被广泛采用的方法,之所以要先将它提出,是因为它简单、直接、易于理解,只要在此基础上再向前走一步,就能得到更好的处理方法了。

  • 上述处理方式实际在对象中保存了大量的重复信息,例如不同的 Derived 类型的对象所保存的这 3个指针都是一样的------它们都分别指向Derived::fBase::gDerived::h的入口地址。因此,这些指针都可以只保存一份,它们构成一个表,称为虚表(virtual table),每个对象中不再保存一个个函数指针,而是只保存一个指向这个虚表首地址的指针------虚表指针(vptr)。这样,每个多态类型的对象只需要占用一个指针的额外空间,虽然虚表本身还要占用空间,但每个多态类型只有一个虚表,这一部分空间不会因新对象的创建而有所增加。

  • 每个类各有一个虚表,虚表的内容是由编译器安排的。派生类的虚表中,基类声明的虚函数对应的指针放在前面,派生类新增的虚函数的对应指针放在后面,这样一个虚函数的指针在基类虚表和派生类虚表中具有相同的位置。每个多态类型的对象中都有一个指向当前类型的虚表的指针,该指针在构造函数中被赋值。当通过基类的指针或引用调用一个虚函数时,就可以通过虚表指针找到该对象的虚表,进而找到存放该虚函数的指针的虚表条目。将该条目中存放的指针读出后,就可获得应当被调用的函数的入口地址,然后调用该虚函数,虚函数的动态绑定就是这样完成的。

  • 执行一个类的构造函数时,首先被执行的是基类的构造函数,因此构造一个派生类的对象时,该对象的虚表指针首先会被指向基类的虚表。只有当基类构造函数执行完后,虚表指针才会被指向派生类的虚表,这就是基类构造函数调用虚函数时不会调用派生类的虚函数的原因。

  • 在多继承时,情况会更加复杂,因为每个基类都有各自的虚函数,每个基类也会有各自的虚表,这样继承了多个基类的派生类需要多个虚表(或一个虚表分为多段,每个基类的虚表指针指向其中一段的首地址)。

  • 事实上,一个类的虚表中存放的不只是虚函数的指针,用于支持运行时类型识别的对象的运行时类型信息也需要通过虚表来访问。只有多态类型有虚表,因此只有多态类型支持运行时类型识别。