C++学习笔记

学习时间:2023年1月1日

学习来源:C++ primer plus 中文版第六版

1 IDE环境搭建

我使用的IDE:Clion

编译器:Visual Studio 也可以使用MinGW

image-20220929205829608

2 快速开始

2.1 一个简单的C++程序

程序源文件命名约定:不同编译器使用不同后缀名,包括.cc .cxx .cpp .cp以及.C

1
2
3
int main() {
return 0;
}

调用GNU或微软编译器

image-20220929212105735

假设 main 程序在名为 main.cpp 的文件中,可以使用如下命令来编译:

1
cl main.cpp -o main # 微软编译器采用命令 cl 来调用,调用 GNU 编译器的默认命令是g++

-o main 是编译器参数以及用来存放可执行文件的文件名。如果省略,那么编译器在 UNIX 系统下产生名为 a.out 而在 Windows 下产生名为 a.exe 的可执行文件。

1
2
[root@HongyiZeng c++]# make hello
g++ hello.cpp -o hello

2.2 初识输入输出

C++ 并没有直接定义进行输入或输出(IO)的任何语句,这种功能是由标准库提供的。

本书的大多数例子都使用了处理格式化输入和输出的 iostream 库。iostream 库的基础是两种命名为 istreamostream 的类型,分别表示输入流和输出流。

2.2.1 标准输入与输出对象

标准库定义了 4 个 IO 对象。

  • cin对象,这个对象也称为标准输入
  • cout对象,这个对象也称为标准输出
  • cerr 对象又叫作标准错误,通常用来输出警告和错误信息给程序的使用者。
  • clog 对象用于产生程序执行的一般信息。

2.2.2 程序实例

要求用户给出两个数,然后输出它们的和:

1
2
3
4
5
6
7
8
9
// 预处理提示
#include <iostream>
int main() {
std::cout << "Enter two numbers: " << std::endl;
int v1, v2;
std::cin >> v1 >> v2;
std::cout << "The sum of " << v1 << " and " << v2 << " is " << v1 + v2 << std::endl;
return 0;
}

#include <iostream>告诉编译器要使用 iostream 库。尖括号里的名字是一个头文件。程序使用库工具时必须包含相关的头文件。相当于java中的import

写入到流

1
std::cout << "Enter two numbers:" << std::endl;

<<:输出操作符,:左操作数必须是 ostream 对象,右操作数是要输出的值,当操作符是输出操作符时,结果是左操作数的值。也就是说,输出操作返回的值是输出流本身。 相当于java中的链式调用

该语句等价于:

1
2
3
4
(std::cout << "Enter two numbers:") << std::endl;
// 或者
std::cout << "Enter two numbers:";
std::cout << std::endl;

endl:操纵符,将它写入输出流时,具有输出换行的效果,并刷新与设备相关联的缓冲区。通过刷新缓冲区,用户可立即看到写入到流中的输出。

使用标准库中的名字

前缀 std:: 表明 cout 和 endl 是定义在命名空间 std 中的。使用命名空间程序员可以避免与库中定义的名字相同而引起无意冲突。因为标准库定义的名字是定义在命名空间中,所以我们可以按自己的意图使用相同的名字。

:::作用域操作符,表示使用的是定义在命名空间 std 中的 cout

读入流

1
std::cin >> v1 >> v2;

>>:输入操作符,它接受一个 istream 对象作为其左操作数,接受一个对象作为其右操作数,它从 istream 操作数读取数据并保存到右操作数中。同样也是链式调用。

等价于:

1
2
std::cin>> v1;
std::cin>> v2;

2.3 控制结构

只展示代码,不做说明。

2.3.1 while

1
2
3
4
5
6
7
8
9
10
#include <iostream>
int main() {
int sum = 0, val = 1;
while (val <= 10) {
sum += val;
++val;
}
std::cout << "Sum of 1 to 10 is " << sum << std::endl;
return 0;
}

2.3.2 for

1
2
3
4
5
6
7
8
int main() {
int sum = 0;
for (int val = 1; val <= 10; ++val) {
sum += val;
}
std::cout << "Sum of 1 to 10 is " << sum << std::endl;
return 0;
}

2.3.3 if

1
2
3
4
5
6
7
8
9
10
11
12
int main() {
int sum = 0, val;
while (std::cin >> val) {
if (val != 0) {
sum += val;
std::cout << "Sum is " << sum <<std::endl;
} else {
break;
}
}
return 0;
}

3 变量和基本类型

3.1 基本内置类型

C++定义了算术类型和空类型在内的基本数据类型。

3.1.1 算术类型

算术类型分为:整型(包括字符和布尔类型)和浮点型

算术类型的尺寸(也就是该类型数据所占的比特数)在不同机器上有所差别。下表列出了C++标准规定的尺寸的最小值,同时允许编译器赋予这些类型更大的尺寸。

image-20221222111257985

字符类型

基本的字符类型是char,占一个字节,8个比特位。

其他的字符类型可用于扩展字符集,例如wchar_tchar16_tchar32_t

其它整型

C++规定:

  • int至少和short一样大
  • long至少和int一样大
  • long long至少和long一样大

浮点型

浮点型可表示单精度、双精度和扩展精度值。

C++标准指定了一个浮点数有效位数的最小值,然而大多数编译器都实现了更高的精度。

通常,float以1个字来表示,double以2个字来表示,long double以3或4个字来表示。一般来说,类型float和double分别有7和16个有效位;类型long double则常常被用于有特殊浮点需求的硬件,它的具体实现不同,精度也各不相同。

有符号和无符号

除去布尔类型和扩展字符型,其它整型可以划分为有符号(signed,正负和0)和无符号(unsigned,0和正数)两种。

无符号类型中的所有比特位均用来存储值,例如8比特的unsigned char可以用来表示0~255

标准中并未规定有符号类型应该如何表示,但是约定了在表示范围内正值和负值的量应该平衡,例如signed char可以表示-127~127,而大多数现代计算机将实际的表示范围定为-128~127

如何选择数据类型

  • 当明确知晓数值不可能为负时,选用无符号类型。
  • 使用int执行整数运算。在实际应用中,short常常显得太小而long一般和int有一样的尺寸。如果数值超过了int的表示范围,选用long long。
  • 在算术表达式中不要使用char或bool,只有在存放字符或布尔值时才使用它们。因为类型 char在一些机器上是有符号的,而在另一些机器上又是无符号的,所以如果使用char进行运算特别容易出问题。如果你需要使用一个不大的整数,那么明确指定它的类型是signed char或者unsigned char
  • 执行浮点数运算选用double,这是因为float通常精度不够而且双精度浮点数和单精度浮点数的计算代价相差无几。事实上,对于某些机器来说,双精度运算甚至比单精度还快。long double提供的精度在一般情况下是没有必要的,况且它带来的运行时消耗也不容忽视。

3.1.2 类型转换

示例:

1
2
3
4
5
6
7
bool b = 42; // b为真
int i = b; // i = 1
i = 3.14; // i = 3
double pi = i; // pi = 3.0
// ----------------------------------
unsigned char c = -1; // c = 255
signed char c2 = 256; // c2未定义

说明:

  • 给无符号类型一个超出表示范围的值时,结果是初始值对无符号类型表示数值总数取模后的余数
    • 例如unsigned char可以表示0~255共256个数,则示例中-1 % 256 = 255
  • 给有符号类型一个超出表示范围的值时,结果是未定义的(undefined

提示:不要混用有符号类型和无符号类型

4 复合类型

4.1 命名空间

4.1.1 历史

C++ 是在C语言的基础上开发的,早期的 C++ 还不完善,不支持命名空间,没有自己的编译器,而是将 C++ 代码翻译成C代码,再通过C编译器完成编译。这个时候的 C++ 仍然在使用C语言的库,stdio.h、stdlib.h、string.h 等头文件依然有效;此外 C++ 也开发了一些新的库,增加了自己的头文件,例如:

  • iostream.h:用于控制台输入输出头文件。
  • fstream.h:用于文件操作的头文件。
  • complex.h:用于复数计算的头文件。

和C语言一样,C++ 头文件仍然以.h为后缀,它们所包含的类、函数、宏等都是全局范围的。

后来 C++ 引入了命名空间的概念,计划重新编写库,将类、函数、宏等都统一纳入一个命名空间,这个命名空间的名字就是std。std 是 standard 的缩写,意思是“标准命名空间”。

为了避免头文件重名,新版 C++ 库也对头文件的命名做了调整,去掉了后缀.h,所以老式 C++ 的iostream.h变成了iostreamfstream.h变成了fstream。而对于原来C语言的头文件,也采用同样的方法,但在每个名字前还要添加一个c字母,所以C语言的stdio.h变成了cstdiostdlib.h变成了cstdlib

可以发现,对于不带.h的头文件,所有的符号(名字)都位于命名空间 std 中,使用时需要声明命名空间 std,这是 C++ 的做法;对于带.h的头文件,没有使用任何命名空间,所有符号都位于全局作用域,这是 C 的做法。

不过现实情况和 C++ 标准所期望的有些不同,对于原来C语言的头文件,即使按照 C++ 的方式来使用,即#include <cstdio>这种形式,那么符号可以位于命名空间 std 中,也可以位于全局范围中,请看下面的两段代码。

  • 使用命名空间std
1
2
3
4
5
#include <cstdio>
int main() {
std::printf("Hello World!\n"); // 标准写法,因为库函数都在命名空间std中
return 0;
}
  • 不使用std
1
2
3
4
5
#include <cstdio>
int main(){
printf("http://c.biancheng.net\n"); // 不标准,是C的写法
return 0;
}

这两种形式在 Microsoft Visual C++ 和 GCC 下都能够编译通过,也就是说,大部分编译器在实现时并没有严格遵循C++标准,它们对两种写法都支持,程序员可以使用 std 也可以不使用。

第 1) 种写法是标准的,第 2) 种不标准,虽然它们在目前的编译器中都没有错误,但依然推荐使用第 1) 种写法,因为标准写法会一直被编译器支持,非标准写法可能会在以后的升级版本中不再支持。


虽然 C++ 几乎完全兼容C语言,C语言的头文件在 C++ 中依然被支持,但 C++ 新增的库更加强大和灵活,请尽量使用这些 C++ 新增的头文件,例如 iostream、fstream、string 等,并使用标准命名空间。

4.1.2 简介

在C++中,名称(name,或名字)可以是符号常量、变量、函数、结构、枚举、类和对象等等。工程越大,名称互相冲突性的可能性越大。另外使用多个厂商的类库时,也可能导致名称冲突。

为了避免,在大规模程序的设计中,以及在程序员使用各种各样的C++库时,这些标识符的命名发生冲突,标准 C++ 引入关键字namespace(命名空间),可以更好地控制标识符的作用域。

作用:增加标识符的使用率,不同命名空间下允许相同的标识符(c语言在同一个作用域不允许定义相同的标识符,报错:重定义,多次初始化)。

1
2
3
4
5
6
7
int a = 1;
int a = 2; // 编译不通过

int main() {
printf("%d\n", a);
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
namespace MM {
int a = 1;
}
namespace GG {
int a = 2; // 允许
}

int main() {
cout << MM::a << endl;
cout << GG::a << endl;
return 0;
}

4.1.3 使用语法

  • 创建一个命名空间:命名空间只能在全局范围内定义
1
2
3
4
5
6
7
8
9
10
11
12
namespace A { // 指定命名空间A
// 花括号内为声明块
// 声明的实体称为命名空间成员,其中包括变量(可以带有初始化)、常量、函数(可以是定义或声明)、结构体、类、模板、命名空间(在一个命名空间中又定义一个命名空间,即嵌套的命名空间)
int a = 10;
} // 注意没有分号
namespace B { // 指定命名空间B
int a = 20;
}
void test(){
cout << "A::a : " << A::a << endl;
cout << "B::a : " << B::a << endl;
}

命名空间限定:指使用的变量名前面加上命名空间名和域解析操作符::的用法。例如A::a表示使用命名空间A中的变量a

  • 命名空间可以嵌套定义
1
2
3
4
5
6
7
8
9
10
namespace A{
int a = 10;
namespace B{
int a = 20;
}
}
void test(){
cout << "A::a : " << A::a << endl; // 10
cout << "A::B::a : " << A::B::a << endl; // 20
}
  • 无名命名空间,意味着命名空间中的标识符只能在本文件内访问,相当于给这个标识符加上了static,使得其可以作为内部连接
1
2
3
4
5
6
7
8
9
10
11
12
namespace {
int a = 10;
void func(){
cout << "hello namespace" << endl;
}
}
void test(){
cout << "a : " << a << endl; // 10
cout << "a : " << ::a << endl; // 10
func(); // hello namespace
}

  • 命名空间可以起别名
1
2
3
4
5
6
7
8
namespace Television {
int a = 10;
}

void test() {
namespace TV =Televison; //别名TV指向原名Televison,在原来出现Television的位置都可以无条件使用TV代替
cout << "a : " << TV::a << endl;
}

4.1.4 using声明

使用using声明就可以无需专门的前缀(形如命名空间::)就可以使用该命名空间里的名字。

语法格式:

1
using namespace::name;
  • 使用using 命名空间::成员名的形式

注意using声明的有效范围是从using开始到using所在的作用域结束

1
2
3
4
5
6
7
8
9
10
#include <iostream>
// using声明,当使用名字cin时,会从命名空间std中获取它,该声明全局有效
using std::cin;
int main(void) {
int i;
cin >> i;
cout << i; // 错误,没有对应的using声明
std::cout << i; // 正确
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
void func(){
// 必须重新声明,否则cout为未定义标识符
using std::cout;
cout<<"Hello World";
}
int main(){
// 只在main函数作用域中有效
using std::cout;

cout<<"Hello World";
func();
return 0;
}
  • 使用using namespace 命名空间的形式
1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
// 声明命名空间std,后续如果有未指定命名空间的符号,那么默认使用 std,代码中的 string、cin、cout 都位于命名空间 std
using namespace std;

void func(){
cout<<"Hello World"<<endl;
}

int main(){
cout<<"Hello World"<<endl;
func();
return 0;
}

4.2 数组

略,详见C语言。

C++标准模板库(STL)提供了一种数组替代品——模板类vector,而C++11新增了模板类array。这些替代品比内置复合类型数组更复杂、更灵活。

4.3 字符串

字符串是存储在内存的连续字节中的一系列字符。C++处理字符串的方式有两种。

  • 第一种来自 C 语言,常被称为C-风格字符串(C-style string)
  • 另一种基于string类库,详见4.4节

下略,详见C语言。

4.4 string类

4.4.1 简介和初始化

ISO/ANSI C++98标准通过添加 string类扩展了C++库,因此可以使用 string类型的变量(使用C++的话说是对象)而不是字符数组来存储字符串。

要使用string类,必须在程序中包含头文件string。string类位于名称空间std中,因此必须提供一条 using 编译指令,或者使用std::string来引用它。

1
2
#include <string>
using std::string;

头文件string,string.h和cstring的区别

  • string.h是c语言的库,用于处理char *类型的字符串。
  • string和cstring是c++标准库,位于std名字空间。string是c++标准库中的一个类,它实际上是basic_string模版类实例化产生的。
  • cstring兼容了过去string.h的函数,但是采用了c++的写法。

程序实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <string>
#include <stdlib.h>
using namespace std;

int main(void) {
char charr1[20]; // C风格:创建空的字符数组
char charr2[20] = "jaguar"; // C风格:创建并初始化字符数组

string str1; // 创建string空对象
string str2 = "panther"; // 创建并初始化string对象

cout << str2 << endl;
cout << "The third letter of charr2 is " << charr2[2] << endl;
cout << "The third letter of str2 is " << str2[2] << endl;

exit(0);
}
1
2
3
panther
The third letter of charr2 is g
The third letter of str2 is n

可以看出,可以使用数组表示法来访问存储在string对象中的字符。

类设计让程序能够自动处理string的大小。例如,str1的声明创建一个长度为0的string对象,但程序将输入读取到str1中时,将自动调整str1的长度:

1
cin >> str1;

这使得与使用数组相比,使用string对象更方便,也更安全。

从理论上说,可以将char数组视为一组用于存储一个字符串的char存储单元,而string类变量是一个表示字符串的实体。

4.4.2 赋值、拼接和附加

可以将一个string对象赋值给另一个string对象:

1
2
3
4
5
6
7
8
9
char charr1[20]; // 创建空的字符数组
char charr2[20] = "jaguar"; // 创建并初始化字符数组

charr1 = charr2; // error!

string str1; // 创建string空对象
string str2 = "panther"; // 创建并初始化string对象

str1 = str2; // valid!

可以使用+拼接两个字符串对象:

1
2
3
string str3 = str1 + str2;
str3 += "Hello"; // 相当于str3 = str3 + "Hello";
str1 += str2; // 相当于str1 = str1 + str2;

可以看出string简化了C语言对字符串的操作,例如上面的操作,C语言需要调用库函数strcpystrcat等。

4.4.3 长度

1
2
int len1 = str1.size();	// size()是string类的方法
int len2 = strlen(charr1); // strlen原型在string.h中

4.5 结构

4.6 联合

4.7 枚举

4.8 指针

4.8.1 补充和注意点

1
2
3
4
5
6
7
// 下面三种表达方式都是一样的
int *p;
int* p;
int * p;

// 注意:p1是int*,而p2是int
int* p1, p2;

指针的值(地址)不是整型,虽然计算机通常把地址当做整型来处理,因此,不能简单地将整数赋给指针。

1
2
3
4
int* pt;
pt = 0xB8000000; // error 类型不匹配

pt = (int *)0xB8000000; // valid

甚至有这样的做法,将地址强制转换为整型,会报warning:

1
2
int* p = (int *)1;
int m = (int)p;

4.8.2 new和delete

在C语言中,可以用库函数 malloc 来分配内存,该内存在堆空间中;在C++中仍然可以这样做,但C++还有更好的方法——new 运算符。

1
2
3
int* p = new int;
// 相当于:
int* p = (int *)malloc(sizeof(int));

对应的,与free函数功能相同的运算符是delete。注意,delete只能释放由new分配的内存。

1
2
3
4
5
6
7
int* p = new int;
*p = 1;
delete p;

int m = 1;
int* ptr = &m;
delete ptr; // error

4.8.3 动态数组

使用new来创建动态数组:

1
2
3
4
5
int size;
cin >> size;
int* psome = new int[size];
// ...
delete [] psome; //注意[]

动态数组的使用:

1
2
3
4
5
6
7
8
9
10
int* p = new int[3];
p[0] = 0;
p[1] = 1;
p[2] = 2;
cout << "p[0] is " << p[0] << endl; // 0
p += 1;
cout << "p[0] is " << p[0] << endl; // 1
p -= 1;
cout << "p[0] is " << p[0] << endl; // 0
delete [] p;

4.9 指针算术

4.9.1 数组名

数组名:通常情况下,是数组第一个元素的地址。将sizeof运用于数组名时,返回数组的大小,以字节为单位。

数组名不可以进行运算,指针可以进行运算。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char arr[] = "Hello World!";
const char* ptr = "I love you.";

cout << arr << endl; // Hello World!
cout << *arr << endl; // H 相当于*(&arr[0])
cout << *(arr + 1) << endl; // e 指针表示法
cout << arr[1] << endl; // e 数组表示法
arr += 1; // error 数组名不能进行运算

cout << ptr << endl; // I love you.
cout << *ptr << endl; // I
cout << *(ptr + 1) << endl; // 指针表示法
cout << ptr[1] << endl; // 数组表示法
ptr += 2; // valid
cout << *ptr << endl; // l

4.9.2 指针和字符串

cout提供一个字符的地址,则它将从该字符开始打印,直到遇到空字符。

1
2
const char* ptr = "I love you.";
cout << ptr << endl; // I love you.

5 循环

略。

补充:

C++11新增了一种循环:基于范围(range-based)的for循环(相当于java的增强for循环)。这简化了一种常见的循环任务:对数组(或容器类,如 vector和 array)的每个元素执行相同的操作,如下例所示:

1
2
3
4
double prices[5] = {4.99, 10.99, 6.87, 7.99, 8.49};
for(double price : prices) {
cout << price << endl;
}

6 分支

7 函数

7.1 内联函数

编译过程的最终产品是可执行程序——由一组机器语言指令组成。运行程序时,操作系统将这些指令载入到计算机内存中,因此每条指令都有特定的内存地址。计算机随后将逐步执行这些指令。有时(如有循环或分支语句时),将跳过一些指令,向前或向后跳到特定地址。

常规函数调用也使程序跳到另一个地址(函数的地址),并在函数结束时返回。执行到函数调用指令时,程序将在函数调用后立即存储该指令的内存地址,并将函数参数复制到堆栈(为此保留的内存块),跳到标记函数起点的内存单元,执行函数代码(也许还需将返回值放入到寄存器中),然后跳回到地址被保存的指令处。来回跳跃并记录跃位置意味着以前使用函数时,需要一定的开销。

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

image-20230102182710366

内联函数使用inline进行修饰,一般在头文件中定义。而一般函数在头文件中声明,在cpp中定义。

使用内联函数的时机

  • 函数本身内容比较少,代码比较短,函数功能相对简单
  • 函数被调用得频繁,不如循环中的函数

不使用内联函数的时机

  • 函数代码量多,功能复杂,体积庞大。对于这种函数,就算加上inline修饰符,编译器也不一定会满足要求,可能还是会当成一般函数处理
  • 递归函数不能使用内联函数

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;

// 内联函数
inline double square(double x) { return x * x; }

int main(void) {
double c = 13.0;
double a = square(5.0);
double b = square(4.5 + 7.5);
cout << "a = " << a << ", b = " << b << endl;
cout << "c = " << c << endl;
cout << "c squared = " << square(c++) << endl;
cout << "Now c = " << c << endl;
return 0;
}

执行结果:

1
2
3
4
a = 25, b = 144
c = 13
c squared = 169
Now c = 14

内联函数比宏更加强大,宏通过文本替换实现,而内联函数和常规函数一样,通过按值来传递参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
using namespace std;

#define SUM(x) x*x

inline int fun(int x)
{
return x * x;
}

int main()
{
int a = SUM(2 + 3);
int b = fun(2 + 3);

cout << "a = " << a << endl;
cout << "b = " << b << endl;

system("pause");
return 0;
}

执行结果:

1
2
a = 11
b = 25

原因:int a = SUM(2 + 3);相当于int a = 2 + 3 * 2 + 3;,而内联函数传入的参数为5。

为了得到正确的结果,我们应该将宏改变为:

1
#define SUM(x) ((x)*(x))

7.2 引用变量

引用变量是C++新增的一种复合类型。引用是已定义的变量的别名。例如,a = 5,如果将 b 作为 a 变量的引用,则可以交替使用 a 和 b 表示该变量(即 a 和 b 都可以表示 5)。引用有指针的作用,但它比指针使用起来更加方便。

作用:用作函数的形参。通过将引用变量用作参数,函数将使用原始的数据,而不是其副本(类似指针)。

7.2.1 创建引用变量

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

1
2
int rats;
int & rodents = rats; // 指向int的引用

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

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <iostream>
using namespace std;
int main()
{
int a = 5;
int& b = a;
cout << "a = " << a << ", a address: " << &a << endl;
cout << "b = " << b << ", b address: " << &b << endl;
b++; // 相当于a++
cout << "a = " << a << endl;
cout << "b = " << b << endl;
return 0;
}

打印结果:

1
2
3
4
a = 5, a address: 0x7ffd5d00f404
b = 5, b address: 0x7ffd5d00f404
a = 6
b = 6
  • 和指针不同,必须在声明引用时将其初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
int rats;
int & rodents;
rodents = rats; // error!

//------------------

int rats;
int & rodents = rats; // correct!

//------------------

int rats;
int * rodents;
rodents = &rats; // correct for pointer!
  • 一旦初始化引用,该引用将一直与引用的变量相关联,因此:
1
2
3
int & rodents = rats;
// 等价于:
int const * rodents = &rats;

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
int main()
{
int a = 5;
int& b = a;
cout << "a = " << a << ", a address: " << &a << endl;
cout << "b = " << b << ", b address: " << &b << endl;

int c = 10;
b = c; // 试图改变 b 的指向,从指向a变为指向c
cout << "c = " << c << ", c address: " << &c << endl;
cout << "a = " << a << ", a address: " << &a << endl;
cout << "b = " << b << ", b address: " << &b << endl;
return 0;
}

打印结果:

1
2
3
4
5
a = 5, a address: 0x7ffec654fff4
b = 5, b address: 0x7ffec654fff4
c = 10, c address: 0x7ffec654fff0
a = 10, a address: 0x7ffec654fff4
b = 10, b address: 0x7ffec654fff4

可以看出b确实变成了c,但是a也变成了c,并且a和b的地址一致,并未发生改变,实际上:

1
2
3
b = c;
// 等价于:
a = c; // 相当于给a赋值为c
  • 如果用const修饰引用,则不可改变引用;这种修饰常常用于函数参数的修饰,称为常量引用参数
1
2
3
int m = 1;
const int & n = m;
n = 1; // error!

7.2.2 将引用作为函数参数

引用经常被用作函数参数,使得函数中的变量名成为调用程序中的变量的别名。这种传递参数的方法称为按引用传递

按引用传递允许被调用的函数能够访问调用函数中的变量。C++新增的这项特性是对C 语言的超越,C语言只能按值传递。按值传递导致被调用函数使用调用程序的值的拷贝。当然,C语言也允许避开按值传递的限制,采用按指针传递的方式。

image-20230103112108728

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
void swap1(int& a, int& b);
void swap2(int* a, int* b);
void swap3(int a, int b);
int main() {
using namespace std;
int a = 5;
int b = 10;
cout << "未交换前: a = " << a << ", b = " << b << endl;
swap1(a, b);
cout << "调用引用参数函数后: a = " << a << ", b = " << b << endl;
swap2(&a, &b);
cout << "调用指针参数函数后: a = " << a << ", b = " << b << endl;
swap3(a, b);
cout << "使用传值函数后: a = " << a << ", b = " << b << endl;
return 0;
}

// 按引用传递
void swap1(int& a, int& b) {
int temp;

temp = a;
a = b;
b = temp;
}

// 按指针传递(C版本的“按引用传递”)
void swap2(int* a, int* b) {
int temp;

temp = *a;
*a = *b;
*b = temp;
}

// 按值传递
void swap3(int a, int b) {
int temp;

temp = a;
a = b;
b = temp;
}

执行结果:

1
2
3
4
未交换前: a = 5, b = 10
调用引用参数函数后: a = 10, b = 5
调用指针参数函数后: a = 5, b = 10
使用传值函数后: a = 5, b = 10

按引用传递swap1(a, b)和按值传递swap3(a, b)在调用时看起来相同,只能通过函数原型或函数定义才知道swap1()是按引用传递。

7.2.3 引用的属性和特别之处

使用基本数据类型,应采用按值传递的方式。

使用按引用传递时,要求更加严格,下面的代码不会通过编译:

1
2
3
4
5
double refcube(double& y);
int main() {
double x = 3.0;
double z = refcube(x + 4.0); // 实参应该是变量,而不是表达式x+4.0
}

7.2.4 临时变量、引用参数和const

暂略

7.2.5 将引用用于结构

引用非常适合用于结构和类。确实,引入引用主要是为了用于这些类型的,而不是基本的内置类型。

使用结构引用参数的方式与使用基本变量引用相同,只需在声明结构参数时使用引用运算符&即可,例如,假设有如下结构定义:

1
2
3
4
5
6
struct free_throws {
string name;
int made;
int attempts;
float percent;
};

在函数中将指向该结构的引用作为参数:

1
void set_pc(free_throws & ft);

如果不想让函数修改传入的结构,可以使用const修饰,使其成为常量引用参数:

1
void set_pc(const free_throws & ft);

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
//strc_ref.cpp -- using structure references
#include <iostream>
#include <string>
struct free_throws {
std::string name;
int made;
int attempts;
float percent;
};

void display(const free_throws & ft);
void set_pc(free_throws & ft);
// 该函数返回引用
free_throws & accumulate(free_throws &target, const free_throws &source);

int main() {
free_throws one = {"Ifelsa Branch", 13, 14};
free_throws two = {"Andor Knott", 10, 16};
free_throws three = {"Minnie Max", 7, 9};
free_throws four = {"Whily Looper", 5, 9};
free_throws five = {"Long Long", 6, 14};
free_throws team = {"Throwgoods", 0, 0};
free_throws dup;
set_pc(one);
display(one);
accumulate(team, one);
display(team);
// use return value as argument
display(accumulate(team, two));
accumulate(accumulate(team, three), four);
display(team);
// use return value in assignment
dup = accumulate(team,five);
std::cout << "Displaying team:\n";
display(team);
std::cout << "Displaying dup after assignment:\n";
display(dup);
set_pc(four);
// ill-advised assignment
accumulate(dup,five) = four;
std::cout << "Displaying dup after ill-advised assignment:\n";
display(dup);
return 0;
}

void display(const free_throws & ft) {
using std::cout;
cout << "Name: " << ft.name << '\n';
cout << " Made: " << ft.made << '\t';
cout << "Attempts: " << ft.attempts << '\t';
cout << "Percent: " << ft.percent << '\n';
}

// 设置percent
void set_pc(free_throws & ft) {
if (ft.attempts != 0)
ft.percent = 100.0f *float(ft.made)/float(ft.attempts);
else
ft.percent = 0;
}

// 累加attempts和made,并设置percent
free_throws & accumulate(free_throws & target, const free_throws & source) {
target.attempts += source.attempts;
target.made += source.made;
set_pc(target);
return target;
}
  • 程序说明

第一个函数调用为set_pc(one)。如果采用指针参数传递的话:

1
2
3
4
5
6
7
8
set_pcp(&one);
// ...
void set_pcp(free_throws * pt) {
if (pt -> attempts != 0)
pt -> percent = 100.0f *float(pt -> made)/float(pt -> attempts);
else
pt -> percent = 0;
}

第二个函数调用为display(one),函数原型使用了const修饰引用。下略。

  • 返回引用

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

1
2
double m = sqrt(16.0);
cout << sqrt(25.0);

在第一条语句中,sqrt函数返回值4.0被复制到一个临时位置,然后被复制给m。

在第二条语句中,sqrt函数返回值5.0被复制到一个临时位置,然后被传递给cout。

1
dup = accumulate(team, five);

如果 accumulate 返回一个结构,而不是指向结构的引用,将把整个结构复制到一个临时位置,再将这个拷贝复制给 dup。但在返回值为引用时,将直接把返回值复制到dup,其效率更高。

  • 返回引用时注意的问题

注意,应该避免返回函数终止时不再存在的内存单元的引用,同样的,也应该避免返回指向局部变量的指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const free_throws & clone(free_throws & ft) {
free_throws newguy; // 在栈中为局部变量开辟内存
newguy = ft;
return newguy; // 释放该内存区域
}

FILE *fopen(const char *filename, const char *mode) {
FILE tmp; // 局部变量

// 给结构体成员赋值初始化
tmp.xxx = xxx;
tmp.yyy = yyy;
...

return &tmp;
}

函数退出后,释放为局部变量newguy匹配的内存(该内存地址位于栈区),因此,返回的引用指向的是一个没有被分配的内存。

同理,函数退出后,释放为局部变量tmp匹配的内存(该内存地址位于栈区),因此,返回的指针指向的是一个没有被分配的内存。

为避免这种问题,可以返回一个作为参数传递给函数的引用,或者使用new出来的内存存放,此时变量的生命周期为动态内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
free_throws & accumulate(free_throws & ft) {
// 对ft的操作
// ...
return ft;
}
//-------------------
free_throws & accumulate(free_throws & ft) {
free_throws newguy = new free_throws; // 在堆区开辟动态内存
newguy = ft;
return newguy; // 该内存不会被释放
}

free_throws & m = accumulate(ft);
// ...
// 需要手动释放该内存
delete m;

7.2.6 将引用用于类对象

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

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// strquote.cpp  -- different designs
#include <iostream>
#include <string>
using namespace std;
string version1(const string & s1, const string & s2);
const string & version2(string & s1, const string & s2); // has side effect
const string & version3(string & s1, const string & s2); // bad design

int main() {
string input;
string copy;
string result;

cout << "Enter a string: ";
getline(cin, input);
copy = input;
cout << "Your string as entered: " << input << endl;
result = version1(input, "***");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;

result = version2(input, "###");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;

cout << "Resetting original string.\n";
input = copy;
result = version3(input, "@@@");
cout << "Your string enhanced: " << result << endl;
cout << "Your original string: " << input << endl;
// cin.get();
// cin.get();
return 0;
}

string version1(const string & s1, const string & s2) {
string temp;

temp = s2 + s1 + s2;
return temp;
}

const string & version2(string & s1, const string & s2) { // has side effect:修改s1引用的变量
s1 = s2 + s1 + s2;
// safe to return reference passed to function
return s1;
}

const string & version3(string & s1, const string & s2) { // bad design
string temp;

temp = s2 + s1 + s2;
// unsafe to return reference to local variable:程序试图引用已经释放的内存
return temp;
}

7.2.7 使用引用参数的时机

使用引用参数的优点:

  • 能够修改主调函数中的数据对象
  • 通过传递引用而不是整个数据对象,可以提高程序的运行速度和减少占用内存

不修改主调函数的值的函数

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

修改主调函数中数据的函数

  • 如果数据对象是内置数据类型,则使用指针
  • 如果数据对象是数组,则只能使用指针
  • 如果数据对象是结构,则使用引用或指针
  • 如果数据对象是类对象,则使用引用

7.3 默认参数

在C++中,函数的形参列表中的形参是可以有默认值的。有默认值的参数即为默认参数。在函数调用时,有默认参数可以缺省。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
char * left(const char * str, int n = 1);

// 调用
char * m = left(char * str); // 省略了n
// 相当于
char * m = left(char * str, 1);

// --------------

int fun(int n = 2);

// 调用
int m = func();
// 相当于
int m = fun(2);

注意:

  • 如果某个位置参数有默认值,那么从这个位置往后,从左向右,必须都要有默认值。
1
2
3
int harpo(int n, int m = 4, int j = 5); // valid
int harpo(int n, int m = 4, int j); // error
int harpo(int n = 3, int m = 4, int j = 5); // valid
  • 函数声明和函数实现(即函数定义),只允许其中一个有默认值,即如果函数声明有默认值,则函数实现的时候就不能有默认参数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>

using namespace std;

// 函数声明
int func(int a, int b, int c, bool is_add = true); //函数声明中设置了参数is_add的默认值

int main() {
int abs = func(10, 5, 2);

cout << "abs = " << abs << endl;

return 0;
}

// 函数实现
int func(int a, int b, int c, bool is_add = true) { //函数实现中也设置了参数is_add的默认值
if (is_add) {
return a + b + c;
}
else {
return a - b - c;
}
}

// 正确方式:
int func(int a, int b, int c, bool is_add) {
if (is_add) {
return a + b + c;
}
else {
return a - b - c;
}
}

编译报错:

1
错误	C2572	“func”: 重定义默认参数 : 参数 1	

7.4 函数重载

函数签名(函数特征标):参数的数量、类型(以及类型引用)和排列顺序三者构成函数签名。

如果两个函数的参数数目和类型相同,同时参数的排列顺序也相同,则它们的特征标相同,而变量名是无关紧要的

函数重载:函数名相同,函数签名不同的函数。例如,可以定义一组原型如下的 print 函数:

1
2
3
4
5
6
void print(const char * str,int width);
void print(double d,int width);
void print(long l,int width);
void print(int i, int width);
void print(const char *str);
void print(char *str); // 所有的print的特征标各不相同

最后两个print函数也是重载,因为一个参数是由const修饰,另一个没有。编译器将根据传入的参数是否由const修饰来决定使用哪一个函数原型。

注意,在函数签名中,类型引用等价于类型,下面同名的函数原型不是重载(函数签名相同,尽管一个参数是类型,一个是类型引用,但两者等价),因此不能共存:

1
2
3
4
5
double cube(double x);
double cube(double & x); // 特征标相同

// 调用
double y = cube(x); // 编译器无法确定调用哪一个cube函数

注意,返回类型不属于函数签名,因此下面的同名的函数原型不是重载,不能共存:

1
2
3
4
5
6
7
8
long gronk(int, float);
double gronk(int, float); // 两者特征标相同,不构成重载

long gronk(int, float);
long gronk(float, float); // 两者特征标不同,构成重载

long gronk(int, float);
double gronk(float, float); // 两者特征标不同,构成重载

使用重载的时机

仅当函数基本上执行相同的任务,但使用不同形式的数据时,才应采用函数重载。

7.5 函数模板

函数模板是通用的函数描述,也就是说,它们使用泛型来定义函数,其中的泛型可用具体的类型(如int或 double)替换。

通过将类型作为参数传递给模板,可使编译器生成该类型的函数。由于模板允许以泛型(而不是具体类型)的方式编写程序,因此有时也被称为通用编程。由于类型是用参数表示的,因此模板特性有时也被称为参数化类型。

语法格式:

1
2
template <typename T> 
返回值 函数名(参数){}
  • template:声明创建模板的关键字
  • typename:关键字,或者使用class,关键字,后面接的符号代表一种数据类型
  • T:是一个通用的数据类型,名称自定义,通常为大写字母

例如:

1
2
3
4
5
6
7
8
9
template <typename AnyType>
// 或者
template <class AnyType>
void swap(AnyType &a, AnyType &b) {
AnyType temp;
temp = a;
a = b;
b = temp;
}

代码示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// funtemp.cpp -- using a function template
#include <iostream>
// function template prototype
template <typename T> // or class T
void Swap(T &a, T &b);

int main() {
using namespace std;
int i = 10;
int j = 20;
cout << "i, j = " << i << ", " << j << ".\n";
cout << "Using compiler-generated int swapper:\n";
Swap(i,j); // generates void Swap(int &, int &)
cout << "Now i, j = " << i << ", " << j << ".\n";

double x = 24.5;
double y = 81.7;
cout << "x, y = " << x << ", " << y << ".\n";
cout << "Using compiler-generated double swapper:\n";
Swap(x,y); // generates void Swap(double &, double &)
cout << "Now x, y = " << x << ", " << y << ".\n";
return 0;
}

// function template definition
template <typename T> // or class T
void Swap(T &a, T &b) {
T temp; // temp a variable of type T
temp = a;
a = b;
b = temp;
}

函数模板调用方式有两种:显式类型推导和隐式类型推导。使用显式类型推导,参数和推导的类型必须一致。例如:

1
2
3
4
5
6
7
int a = 10;
int b = 20;
Swap(a, b); // 隐式类型推导

double c = 30.0;
double d = 40.0;
MySwap<double>(c, d); // 显式类型推导

代码示例2——重载的模板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// twotemps.cpp -- using overloaded template functions
#include <iostream>
template <typename T> // original template
void Swap(T &a, T &b);

template <typename T> // new template
void Swap(T *a, T *b, int n); // 重载的模板函数

void Show(int a[]);
const int Lim = 8;
int main() {
using namespace std;
int i = 10, j = 20;
cout << "i, j = " << i << ", " << j << ".\n";
cout << "Using compiler-generated int swapper:\n";
Swap(i,j); // matches original template
cout << "Now i, j = " << i << ", " << j << ".\n";

int d1[Lim] = {0,7,0,4,1,7,7,6};
int d2[Lim] = {0,7,2,0,1,9,6,9};
cout << "Original arrays:\n";
Show(d1);
Show(d2);
Swap(d1,d2,Lim); // matches new template
cout << "Swapped arrays:\n";
Show(d1);
Show(d2);
// cin.get();
return 0;
}

template <typename T>
void Swap(T &a, T &b) {
T temp;
temp = a;
a = b;
b = temp;
}

template <typename T>
void Swap(T a[], T b[], int n) {
T temp;
for (int i = 0; i < n; i++)
{
temp = a[i];
a[i] = b[i];
b[i] = temp;
}
}

void Show(int a[]) {
using namespace std;
cout << a[0] << a[1] << "/";
cout << a[2] << a[3] << "/";
for (int i = 4; i < Lim; i++)
cout << a[i];
cout << endl;
}

8 对象和类

面向对象编程(OOP)的重要特性:

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

8.1 基本使用

8.1.1 类的声明

将类的声明放在头文件中,实现(定义)放在源代码文件中。

代码示例stock00.h

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

#include <string>

class Stock {
private:
// 公司名
std::string company;
// 股票
long shares;
// 股票价值
double share_val;
// 股票总价值
double total_val;
// 设置股票总价值
void set_tot() { total_val = shares * share_val; }
public:
void acquire(const std::string & co, long n, double pr);
void buy(long num, double price);
void sell(long num, double price);
void update(double price);
void show();
}; // 注意有分号

#endif

8.1.2 访问控制

关键字 privatepublic 描述了对类成员的访问控制。

使用类对象的程序都可以直接访问公有部分,但只能通过公有成员函数(或友元函数)来访问对象的私有成员。例如,要修改Stock类的shares成员,只能通过Stock的成员函数。因此,公有成员函数是程序和对象的私有成员之间的桥梁,提供了对象和程序之间的接口。防止程序直接访问数据被称为数据隐藏。C++还提供了第三个访问控制关键字 protected

image-20230104124359578

8.1.3 控制对成员的访问

通常将数据项放在私有部分,成员函数放在共有部分。

访问控制的关键字缺省时,默认是private

1
2
3
4
5
6
7
class World {
float mass; // private
char names[20]; // private
public:
void tellAll(void);
// ...
};

8.1.4 实现类成员函数

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

例如:

1
void Stock::update(double price);

这种表示法意味着我们定义的update函数是Stock类的成员。这不仅将update标识为成员函数,还意味着我们可以将另一个类的成员函数也命名为update。例如:

1
void Buffoon::update();

因此,作用域解析运算符确定了方法定义对应的类的身份。我们说,标识符update具有类作用域。Stock类的其他成员函数不必使用作用域解析运算符,就可以使用update方法,这是因为它们属于同一个类,因此update是可见的。

类方法的完整名称中包括类名。我们说,Stock::update是函数的限定名;而简单的update是全名的缩写(非限定名),它只能在类作用域中使用。

代码示例

实现类方法(类成员函数),将其定义在独立的实现文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// stock00.cpp -- implementing the Stock class
// version 00
#include <iostream>
#include "stock00.h" // 包含类定义的头文件

void Stock::acquire(const std::string & co, long n, double pr) {
// 类作用域中,可以直接使用成员变量,例如这里的company,完整写法是Stock::company
company = co;
if (n < 0) {
std::cout << "Number of shares can't be negative; "
<< company << " shares set to 0.\n";
shares = 0;
}
else
shares = n;
share_val = pr;
set_tot();
}

void Stock::buy(long num, double price) {
if (num < 0) {
std::cout << "Number of shares purchased can't be negative. "
<< "Transaction is aborted.\n";
}
else {
shares += num;
share_val = price;
set_tot();
}
}

void Stock::sell(long num, double price) {
using std::cout;
if (num < 0) {
cout << "Number of shares sold can't be negative. "
<< "Transaction is aborted.\n";
}
else if (num > shares) {
cout << "You can't sell more than you have! "
<< "Transaction is aborted.\n";
}
else {
shares -= num;
share_val = price;
set_tot();
}
}

void Stock::update(double price) {
share_val = price;
set_tot();
}

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

内联方法:定义位于类声明中的函数都将自动称为内联函数,例如Stock::set_tot()

1
2
3
4
5
6
7
8
class Stock {
private:
// ...
// 内联函数
void set_tot() { total_val = shares * share_val; }
public:
// ...
};

也可以将定义和声明分离:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Stock {
private:
// ...
// 内联函数
void set_tot();
public:
// ...
};

// 另一个文件中
inline void Stock::set_tot() {
total_val = shares * share_val;
}

8.1.5 使用类对象

创建对象和使用方法:

1
2
Stock kate, joe;
kate.show(); // 调用方法

所创建的每个新对象都有自己的存储空间,用于存储其内部变量和类成员;但同一个类的所有对象共享同一组类方法,即每种方法只有一个副本

例如,假设kate和 joe都是 Stock 对象,则 kate.shares 将占据一个内存块,而joe.shares 占用另一个内存块,但kate.showjoe.show都调用同一个方法,也就是说,它们将执行同一个代码块,只是将这些代码用于不同的数据。在OOP中,调用成员函数被称为发送消息,因此将同样的消息发送给两个不同的对象将调用同一个方法,但该方法被用于两个不同的对象。

image-20230104131756935

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// usestok0.cpp -- the client program
// compile with stock.cpp
#include <iostream>
#include "stock00.h"

int main()
{
Stock fluffy_the_cat; // 创建对象
fluffy_the_cat.acquire("NanoSmart", 20, 12.50);
fluffy_the_cat.show();
fluffy_the_cat.buy(15, 18.125);
fluffy_the_cat.show();
fluffy_the_cat.sell(400, 20.00);
fluffy_the_cat.show();
fluffy_the_cat.buy(300000,40.125);
fluffy_the_cat.show();
fluffy_the_cat.sell(300000,0.125);
fluffy_the_cat.show();
return 0;
}

8.1.6 小结

  • 第一步是提供类声明。类声明类似结构声明,可以包括数据成员和函数成员。声明有私有部分,在其中声明的成员只能通过成员函数进行访问;声明还具有公有部分,在其中声明的成员可被使用类对象的程序直接访问。通常,数据成员被放在私有部分中,成员函数被放在公有部分中。
  • 第二步是实现类成员函数。可以在类声明中提供完整的函数定义,而不是函数原型,但是通常的做法是单独提供函数定义(除非函数很小)。在这种情况下,需要使用作用域解析运算符来指出成员函数属于哪个类。

8.2 构造函数和析构函数

目前的类还不能按照常规的语法来初始化类对象:

1
2
3
4
5
6
7
8
int year = 2001;
struct thing {
char*pn;
int m;
}

thing amabob={"wodget",-23}; // valid initialization
Stock hot ={"Sukie's Autos,Inc.",200,50.25}; // NO! compile error

8.2.1 声明和定义构造函数

构造函数名称与类名相同,没有返回值,其他与常规函数相同,可以重载,可以有默认参数等等。它专门用于构造新对象,将值赋给对象的数据成员。

代码示例

函数原型(声明):位于类声明的公有部分

1
Stock(const string & co, long n = 0, double pr = 0.0);

参数分别用于初始化成员companysharesshare_val

函数定义:一种可能的定义如下:

1
2
3
4
5
6
7
8
9
10
Stock::Stock(const string & co, long n, double pr) {
company = co;
if(n < 0) {
shares = 0;
} else {
shares = n;
}
share_val = pr;
set_tot(); // 设置total_val
}

注意:构造函数的参数名不能与类的成员名相同:

1
2
3
4
Stock::Stock(const string & company, long shares, double share_val) {
// ...
shares = shares; // error!
}

8.2.2 使用构造函数

调用构造函数分为两种:

  • 显式调用
1
Stock food = Stock("World Cabbage", 250, 1.25);
  • 隐式调用
1
Stock food("World Cabbage", 250, 1.25);

创建出的对象位于程序的栈区。也可以使用new与构造函数一起使用,返回的指针将指向对象开始的内存区域,内存位于堆区,具有动态生命周期:

1
2
3
4
Stock *pstock = new Stock("World Cabbage", 250, 1.25);

// 手动释放内存
delete pstock;

这种情况下,对象没有名称,但是可以使用指针来管理该对象。

8.2.3 默认构造函数

如果没有提供任何构造函数,C++将自动提供默认构造函数

1
2
3
Stock::Stock() {

}

因此,之前的代码是可行的,它将不初始化任何成员:

1
2
3
Stock joe; // 调用默认构造函数
// 相当于
Stock joe = Stock();

一旦提供了非默认构造函数,该默认构造函数将消失,因此下面的语句将出错:

1
Stock joe; // error!

定义默认构造函数的方式有两种(这两种方式不能共存):

  • 给已有构造函数的所有参数提供默认值
1
Stock(const string & co = "Error", long n = 0, double pr = 0.0);
  • 通过函数重载来定义另一个构造函数——一个没有参数的构造函数
1
Stock();

通常应该提供对所有类成员做隐式初始化的默认构造函数:

1
2
3
4
5
6
Stock::Stock() {
company = "no name";
shares = 0;
share_val = 0.0;
total_val = 0.0;
};

使用:

1
2
3
Stock first; // 隐式调用
Stock first = Stock(); // 显式调用
Stock *prelief = new Stock; // 隐式调用

8.2.4 析构函数

用构造函数创建对象后,程序负责跟踪该对象,直到其过期为止。对象过期时,程序将自动调用一个特殊的成员函数——析构函数。

析构函数完成清理工作,因此实际上很有用。例如,如果构造函数使用 new 来分配内存,则析构函数将使用 delete 来释放这些内存。析构函数可用于释放对象时构造或在对象的生命期中所获取的资源。

Stock 的构造函数没有使用 new,因此析构函数实际上没有需要完成的任务。在这种情况下,只需让编译器生成一个什么要不做的隐式析构函数即可,Stock 类第一版正是这样做的。

1
2
3
4
5
~Stock(); // 提供的默认析构函数声明

Stock::~Stock() { // 默认定义

}

析构函数的特点

  • 析构函数没有返回值和参数列表
  • 析构函数不能重载
  • 析构函数由系统自动调用,不能显式调用
  • 析构函数可以是inline函数
  • 析构函数应该设置为类的公有成员
  • 每个类有应该有一个析构函数,如果没有显式定义,那么系统会自动生成一个默认的析构函数
  • 析构函数的名称为在类名前加上~
  • 构造函数中使用了new(或者在堆区分配了动态内存),则必须提供使用delete的析构函数

析构函数调用时机

  • 静态存储类对象:程序结束时自动调用
  • 自动存储类对象:自动存储期结束时自动调用
  • new出来的对象:调用delete销毁对象时,自动调用

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <string>

class String {
public:
String( char *ch ); // Declare constructor
~String(); // and destructor.
private:
char *_text;
size_t sizeOfText;
};

// 构造函数
String::String( char *ch ) {
sizeOfText = strlen( ch ) + 1;

// Dynamically allocate the correct amount of memory.
// 动态分配内存
_text = new char[ sizeOfText ];

// If the allocation succeeds, copy the initialization string.
if( _text )
strcpy_s( _text, sizeOfText, ch );
}

// 析构函数
String::~String() {
// Deallocate the memory that was previously reserved
// for this string.
delete[] _text;
}

int main() {
String str("The piper in the glen...");

// ...
return 0;
}

str是栈中的内存(自动存储类对象),当所在的作用域main函数退出时,str被销毁,此时执行析构函数,释放成员_text占用的堆区动态内存。

8.2.5 改进Stock类

将析构函数和构造函数加入到Stock类中。

  • 头文件:类的声明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#ifndef STOCK1_H_
#define STOCK1_H_
#include <string>
class Stock {
private:
std::string company;
long shares;
double share_val;
double total_val;
void set_tot() { total_val = shares * share_val; }
public:
Stock(); // default constructor
Stock(const std::string & co, long n = 0, double pr = 0.0);
~Stock(); // noisy destructor
void buy(long num, double price);
void sell(long num, double price);
void update(double price);
void show();
};

#endif
  • 类的成员函数实现(定义)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// stock1.cpp  Stock class implementation with constructors, destructor added
#include <iostream>
#include "stock10.h"

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

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

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

// class destructor
Stock::~Stock() { // verbose class destructor
std::cout << "Bye, " << company << "!\n";
}

// other methods...
  • 列表初始化:C++11支持
1
2
3
4
5
6
7
8
Stock hot_tip = {"hello", 100, 45.0};
Stock hot_tip {"hello"};
Stock temp {}; // 调用默认构造函数

// 对应于:
Stock hot_tip = Stock("hello", 100, 45.0);
Stock hot_tip = Stock("hello");
Stock temp = Stock();
  • const成员函数:
1
2
const Stock land = Stock("Hello");
land.show(); // error!

原因在于show无法保证land对象不会被修改。

C++的解决办法是将const关键字放在函数的括号后面。

1
void show() const;

同样的,在函数定义处:

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

这样的函数称为const成员函数。

8.3 this指针

现在需要定义一个成员函数,查看两个Stock对象,并返回股价高的那个对象的引用,命名为topval

函数原型:

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

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

函数使用:

1
2
top = stock1.topval(stock2);
top = stock2.topval(stock1);

第一种格式隐式地访问 stock1,而显式地访问 stock2:第二种格式显式地访问 stock1,而隐式地访问stock2。无论使用哪一种方式,都将对这两个对象进行比较,并返回股价总值较高的那一个对象。

image-20230106093329912

函数定义:

1
2
3
4
5
6
7
const Stock & Stock::topval(const Stock & s) const {
if(s.total_val > total_val) {
return s;
} else {
return ???; // 如何返回隐式访问的对象
}
}

其中,s.total_val 是作为参数传递的对象的总值,total_val 是用来调用该方法的对象的总值。如果s.total_val大于total_val,则函数将返回指向s的引用;否则,将返回用来调用该方法的对象。问题在于,如何称呼这个对象?如果调用stock1.topval(stock2),则s是stock2 的引用(即stock2的别名),但stock1没有别名。

C++解决这种问题的方法是:使用被称为this的特殊指针。

this指针指向用来调用成员函数的对象(this 被作为隐藏参数传递给方法)。这样,函数调用stock1.topval(stock2)将this设置为stock1对象的地址,使得这个指针可用于topval方法。相当于:

1
2
3
const Stock & Stock::topval(Stock * const this, const Stock & s) const { 
// ...
}

一般来说,所有的类方法都将this指针设置为调用它的对象的地址。this指针的用const修饰的,this指针本身是不能被修改的(不能指向其他地址),但是内容是可以修改的。

因此问题解决方法为:

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

this指针的特性

  • this指针的类型:类类型 * const
  • 只能在成员函数的内部使用
  • this指针本质上其实是一个成员函数的形参,是对象调用成员函数时,将对象地址作为实参传递给this形参。所以对象中不存储this指针
  • this指针是成员函数第一个隐含的指针形参,一般情况由编译器通过ecx寄存器自动传递,不需要用户传递
  • static静态成员函数不能使用this指针。原因是静态成员函数属于类,而不属于某个对象,所以static静态成员函数压根就没有this指针

8.4 对象数组

对象数组的声明:

1
Stock mystuff[4]; // 调用默认构造函数

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

1
2
3
4
5
6
const int STKS = 3;
Stock stocks[STKS] = {
Stock();
Stock("Hello", 12, 20);
Stock("World", 12, 20);
}

过程:用花括号中的构造函数创建临时对象,然后将临时对象的内容复制到相应的元素中。

8.5 类作用域

8.5.1 概念

在类中定义的名称(如类数据成员名和类成员函数名)的作用域都为整个类,作用域为整个类的名称只在该类中是已知的,在类外是不可知的。

因此,可以在不同类中使用相同的类成员名而不会引起冲突。

另外,类作用域意味着不能从外部直接访问类的成员,公有成员函数也是如此。也就是说,要调用公有成员函数,必须通过对象:

1
2
3
Stock sleeper("Hello", 100, 0.25);
sleeper.show();
show(); // error

在类的作用域之外,类的数据和函数成员只能由对象、引用或者指针使用成员访问运算符(.)来访问。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Ik {
private:
int fuss;
public:
Ik(int f = 9) { fuss = f; } // 默认构造函数
void ViewIk() const;
}; // 类作用域

// 类作用域之外
// ------------------

// 使用作用域解析运算符指明ViewIk函数位于Ik的类作用域
void Ik::ViewIk() const {
cout << fuss << endl; // 类作用域中,fuss是可见的
}

int main() {
Ik * pik = new Ik;
Ik ee = Ik(8);
ee.ViewIk(); // 对象将ViewIk函数带入到类作用域
pik->ViewIk(); // 指向Ik的指针将ViewIk函数带入到类作用域
}

8.5.2 作用域为类的常量

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

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

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

这将创建一个名为 Months 的常量,该常量将与其他静态变量存储在一起,而不是存储在对象中。因此,只有一个Months 常量,被所有Bakery对象共享。

8.6 抽象数据类型

Stock类非常具体。然而,程序员常常通过定义类来表示更通用的概念。例如,就实现计算机专家们所说的抽象数据类型(abstract datatype,ADT)而言,使用类是一种非常好的方式。

顾名思义,ADT以通用的方式描述数据类型,而没有引入语言或实现细节。抽象数据类型是指一个数学模型以及定义在这个模型上的一组操作。抽象数据类型的定义仅仅取决于它的一组逻辑特性,而与它在计算机中的表示和实现无关。

标准格式:

1
2
3
4
5
6
7
8
ADT 抽象数据类型名 {
Data:
数据元素之间逻辑关系的定义;
Operation:
操作1;
操作2;
...
}

例如,通过使用栈,可以以这样的方式存储数据,即总是从堆顶添加或删除数据。

栈的基本操作:

  • 可创建空栈
  • 可将数据项添加到堆顶(压入)
  • 可从栈顶删除数据项(弹出)
  • 可查看栈否填满
  • 可查看栈是否为空

可以将上述描述转换为一个类声明,其中公有成员函数提供了表示栈操作的接口,而私有数据成员负责存储栈数据。类概念非常适合于ADT方法。

代码示例

  • stack.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// stack.h -- class definition for the stack ADT
#ifndef STACK_H_
#define STACK_H_

typedef unsigned long Item;

class Stack {
private:
enum {MAX = 10}; // constant specific to class
Item items[MAX]; // holds stack items
int top; // index for top stack item
public:
Stack();
bool isempty() const;
bool isfull() const;
// push() returns false if stack already is full, true otherwise
bool push(const Item & item); // add item to stack
// pop() returns false if stack already is empty, true otherwise
bool pop(Item & item); // pop top into item
};
#endif

分析:

  1. 私有部分表明,栈是使用数组实现的;而公有部分隐藏了这一点。因此,可以使用动态数组来代替数组,而不会改变类的接口。这意味着修改栈的实现后,不需要重新编写使用栈的程序,而只需重新编译栈代码,并将其与已有的程序代码链接起来即可。
  2. 接口是冗余的,因为 pop和 push返回有关栈状态的信息(满或空),而不是 void类型。在如何处理超出栈限制或者清空栈方面,这为程序员提供了两种选择。他可以在修改栈前使用isempty和isfull来查看,也可以使用 push和 pop的返回值来确定操作是否成功。
  3. 这个类不是根据特定的类型来定义栈,而是根据通用的Item类型来描述。在这个例子中,头文件使用typedef用Item代替unsigned long。如果需要 double 栈或结构类型的栈,则只需修改 typedef 语句,而类声明和方法定义保持不变。类模板(参见第14章)提供了功能更强大的方法,来将存储的数据类型与类设计隔离开来。
  • stack.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// stack.cpp -- Stack member functions
#include "stack.h"
Stack::Stack() { // create an empty stack
top = 0;
}

bool Stack::isempty() const {
return top == 0;
}

bool Stack::isfull() const {
return top == MAX;
}

bool Stack::push(const Item & item) {
if (top < MAX) {
items[top++] = item;
return true;
}
else
return false;
}

bool Stack::pop(Item & item) {
if (top > 0) {
item = items[--top];
return true;
}
else
return false;
}

9 类的使用

9.1 运算符重载

9.1.1 概念和引入

和函数重载一样,运算符重载也是一种形式的C++多态。

C++允许将运算符重载扩展到用户定义的类型,例如,允许使用+将两个对象相加。编译器将根据操作数的数目和类型决定使用哪种加法定义。

程序实例——两个时间相加

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

class Time {
private:
int hours;
int minutes;
public:
Time();
Time(int h, int m = 0);
// 分钟相加
void AddMin(int m);
// 小时相加
void AddHr(int h);
// 重置
void Reset(int h = 0, int m = 0);
// 将小时和分钟相加
const Time Sum(const Time & t) const;
void Show() const;
};
#endif

实现:

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

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

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

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

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

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

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

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

注意:函数Sum返回Time对象,而不是引用。前面说过,不要返回局部变量或临时对象的引用。此外,该方法返回时,是将sum对象复制(浅复制)一份返回,然后将局部变量sum释放。

9.1.2 加法运算符重载示例

运算符重载的形式:

1
2
3
[返回值] operator[运算符] (参数...) {
// ...
}

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

修改后的代码

1
2
3
4
5
6
7
8
9
10
11
// mytime1.h -- Time class before operator overloading
#ifndef MYTIME1_H_
#define MYTIME1_H_

class Time {
// ...
public:
// ...
Time operator+(const Time & t) const;
};
#endif
1
2
3
4
5
6
7
Time Time::operator+(const Time & t) const {
Time sum;
sum.minutes = minutes + t.minutes;
sum.hours = hours + t.hours + sum.minutes / 60;
sum.minutes %= 60;
return sum;
}

调用方式:

1
2
3
4
5
6
7
total = coding.operator+(fixing);
// 或者直接使用运算符+
total = coding + fixing; // 左侧视为调用对象,右侧视为函数的参数

a = b + c + d;
// 等价于
a = b.operator+(c.operator+(d));

9.1.3 重载限制

可以重载的运算符如下:

image-20230106143856435

image-20230106143906614

重载的限制:

  • 重载后的运算符必须至少有一个操作数是用户定义的类型,这将防止用户为标准类型重载运算符。因此,不能将减法运算符(-)重载为计算两个double值的和,而不是它们的差。虽然这种限制将对创造性有所影响,但可以确保程序正常运行。
  • 使用运算符时不能违反运算符原来的句法规则(操作数个数、优先级等)。例如,不能将求模运算符(%)重载成使用一个操作数。
  • 不能创建新的运算符
  • 以下运算符只能通过成员函数进行重载:
    • =:赋值运算符
    • ():函数调用运算符
    • []:下标运算符
    • ->:通过指针访问类成员的运算符

9.1.4 其他重载示例

例如将相减和相乘重载为时间相减和相乘。

代码示例——只列出重要代码

1
2
3
4
5
6
class Time {
// ...
public:
Time operator-(const Time & t) const;
Time operator*(double n) const;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Time Time::operator-(const Time & t) const {
Time diff;
int tot1, tot2;
tot1 = t.minutes + 60 * t.hours;
tot2 = minutes + 60 * hours;
diff.minutes = (tot2 - tot1) % 60;
diff.hours = (tot2 - tot1) / 60;
return diff;
}

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

调用:

1
2
3
4
5
6
7
8
9
time_a = time_b - time_c;
// 等价于:或者编译器认为
time_a = time_b.operator-(time_c);

time_a = time_b * 1.2;
// 即
time_a = time_b.operator*(1.2);
// 注意:不能写为:
time_a = 1.2 * time_b;

最后一行不能成立的理由是:1.2不是Time类的对象,而time_b也不是double类型。

9.2 友元

9.2.1 概念

私有成员对于类外部的所有程序部分来说都是隐藏的,访问它们需要调用一个公共成员函数,但有时也可能会需要创建该规则的一项例外。

友元是用friend关键字修饰的函数或者类,友元用来打破类的封装。

友元分为三种:

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

上面的乘法运算将Time对象和double结合在一起:

1
2
3
A = B * 2.75;
// 即
A = B.operator*(2.75);

如果将B和2.75调换顺序,由于2.75并不是Time对象,因此编译器不能使用成员函数调用来替换该表达式:

1
A = 2.75 * B; // 语义和B * 2.75一致

一种解决方法是,使用非成员函数来对乘法进行重载:

1
2
3
4
5
Time operator*(double m, const Time & t);
// ...
A = 2.75 * B;
// 被解释为
A = operator*(2.75, B);

非成员函数存在的问题是,不能访问类的私有数据,于是引入友元。

9.2.2 创建友元

创建友元函数是将函数原型放在类的声明中,并用friend修饰:

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

作用:

  • 虽然operator*()在类声明中声明,但它不是类的成员函数
  • 虽然operator*()不是类的成员函数,但具有与成员函数相同的访问权限

函数定义:注意,因为它不是成员函数,所以不用Time::限定

1
2
3
4
5
6
7
8
Time operator*(double m, const Time & t) {
Time result;
// 非成员函数使用类的私有成员
long totalminutes = hours * mult * 60 + minutes * mult;
result.hours = totalminutes / 60;
result.minutes = totalminutes % 60;
return result;
}

提示:如果要为类重载运算符,并将非类的项作为其第一个操作数,则可以用友元函数来反转操作数的顺序。

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

使用重载<<运算符来打印对象:

1
cout << trip; // trip is a Time object

之所以可以这样做,是因为<<是可被重载的C++运算符之一。

实际上,它已经被重载很多次了。最初,<<运算符是C和C++的位运算符,将值中的位左移。ostream类对该运算符进行了重载,将其转换为一个输出工具。前面讲过,cout是一个ostream对象,它是智能的,能够识别所有的C++基本类型。这是因为对于每种基本类型,ostream类声明中都包含了相应的重载的operator<<()定义。也就是说,一个定义使用int参数,一个定义使用double参数,等等。因此,要使cout能够识别Time对象,一种方法是将一个新的函数运算符定义添加到 ostream 类声明中。但修改 iostream 文件是个危险的主意,这样做会在标准接口上浪费时间。相反,通过Time类声明来让Time类知道如何使用cout

成员函数和友元函数的重载版本

如果使用Time成员函数来重载<<,则第一个操作数必须是Time对象,意味着会这样打印对象:

1
2
3
4
5
6
7
8
9
10
11
// 原型,在类中
void operator<<(ostream & os);

// 定义
void Time::operator<<(ostream & os) {
os << hours << " hours, " << minutes << " minutes";
}

trip << cout;
// 等价于
trip.operator<<(cout);

这样会让人迷惑,但通过友元函数,可以使参数反转:

1
2
3
4
5
6
7
// 原型
friend operator<<(ostream & os);

// 定义
void operator<<(ostream & os, const Time & t) {
os << t.hours << " hours, " << t.minutes << " minutes";
}

于是可以使用下面的语句打印Time对象:

1
2
3
cout << trip;
// 等价于
operator<<(cout, trip);

注意,该友元函数是Time类的友元,因为它必须访问Time类对象的私有成员,但它不是ostream的友元,因此不必修改osteam的定义。

改进版本

当打印多条语句时:

1
2
3
4
5
6
int x = 5;
int y = 6;
cout << x << y;

// 等价于
(cout << x) << y;

因此重载<<运算符函数应该返回ostream对象的引用,才能连续输出。

对于上面的友元函数版本,不能处理如下语句:

1
cout << "Trip time: " << trip << " Tuesday\n";

需要修改为:

1
2
3
ostream & operator<<(ostream & os, const Time & t) {
os << t.hours << " hours, " << t.minutes << " minutes";
}

Time类改进

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

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

};
#endif

实现:只列出重要代码

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

std::ostream & operator<<(std::ostream & os, const Time & t) {
os << t.hours << " hours, " << t.minutes << " minutes";
return os;
}

小结:对于很多运算符来说,可以选择使用成员函数或非成员函数来实现运算符重载。一般来说,非成员函数应是友元函数,这样它才能直接访问类的私有数据。

9.3 类的自动和强制类型转换

9.3.1 构造函数转换

引出:内置类型的转换

隐式转换:

1
2
3
long count = 8; // int -> long
double time = 11; // int -> double
int side = 3.33; // double -> int,会丢失精度

C++不自动转换不兼容的类型:

1
int * p = 10; //error!整数和地址不能转换

但是可以进行强制类型转换(显式转换):

1
int * p = (int *)10;

使用磅(pounds)和英石(stone)来表示重量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// stonewt.h -- definition for the Stonewt class
#ifndef STONEWT_H_
#define STONEWT_H_
class Stonewt {
private:
enum {Lbs_per_stn = 14}; // 1英石对应14磅
int stone; // 英石数
double pds_left; // 磅的分数部分
double pounds; // 磅数
public:
// 以磅初始化
Stonewt(double lbs);
// 以磅和英石初始化
Stonewt(int stn, double lbs);
// 默认构造
Stonewt();
~Stonewt();
// 以磅为单位显示重量
void show_lbs() const;
// 以英石为单位显示重量
void show_stn() const;
};
#endif

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// stonewt.cpp -- Stonewt methods
#include <iostream>
using std::cout;
#include "stonewt.h"

// 将double类型转换为(构造出)Stonewt对象
Stonewt::Stonewt(double lbs) {
stone = int (lbs) / Lbs_per_stn; // integer division
pds_left = int (lbs) % Lbs_per_stn + lbs - int(lbs);
pounds = lbs;
}

//将int和double类型转换为(构造出)Stonewt对象
Stonewt::Stonewt(int stn, double lbs) {
stone = stn;
pds_left = lbs;
pounds = stn * Lbs_per_stn +lbs;
}

Stonewt::Stonewt() { // default constructor, wt = 0
stone = pounds = pds_left = 0;
}

Stonewt::~Stonewt() { // destructor

}

// show weight in stones
void Stonewt::show_stn() const {
cout << stone << " stone, " << pds_left << " pounds\n";
}

// show weight in pounds
void Stonewt::show_lbs() const {
cout << pounds << " pounds\n";
}

类Stonewt现在可以通过将整数或浮点值转换为Stonewt对象:

1
2
Stonewt myCat;
myCat = 19.6; // 通过构造函数Stonewt(double lbs),将double转换为Stonewt

首先,程序使用构造函数创建一个临时的Stonewt对象,并用19.6作为初始化值,然后将该临时对象的内容浅复制到myCat中,然后释放临时对象,这一过程称为隐式转换,或者自动转换

只有接受一个参数的构造函数才能作为转换函数,下面的函数不能用来转换类型:

1
2
3
4
Stonewt::Stonewt(int stn, double lbs); // 有两个参数

Stonewt myCat;
myCat = 1, 19.6; // 不能这样使用

如果第二个参数有默认值,则可以,例如:

1
2
3
Stonewt::Stonewt(int stn, double lbs = 0.1);
Stonewt myCat;
myCat = 10; // // 通过构造函数Stonewt(int stn, double lbs = 0.1),将int类型转换为Stonewt类型

下面的场合也会导致隐式转换:

  • 将对象初始化为double,例如Stonewt myCat = 19.6;
  • 将 double 值传递给接受 Stonewt参数的函数时
  • 返回值被声明为Stonewt的函数试图返回double值时
  • 在上述任意一种情况下,使用可转换为double类型的内置类型(例如int)时

此外,隐式转换的特性可以通过关键字explicit关闭,这种情况下只能使用显式的强制类型转换:

1
2
3
4
5
6
explicit Stonewt(double lbs);
// ...
Stonewt myCat;
myCat = 19.6; // error!
myCat = (Stonewt) 19.6; // OK!
myCat = Stonewt(19.6); // OK!

总结:构造函数(隐式转换)只用于从某种类型到类类型的转换。

9.3.2 转换函数转换

问题:能否将类类型转换为double?

1
2
Stonewt wolf(285.7);
double host = wolf; // ??

回答:可以,但不能使用构造函数,而是使用特殊的C++运算符函数——转换函数。

转换函数是用户定义的强制类型转换。显式转换例如:

1
2
3
Stonewt wolf(285.7);
double thinker = double (wolf); // 语法格式1
double host = (double) wolf; // 语法格式2

也可以通过编译器判断来隐式转换,即通过左值的类型判断隐式转换的类型

1
2
Stonewt wolf(285.7);
double thinker = wolf; // 隐式转换为double类型

转换函数语法:

1
operator typeName(); // typeName可以是int,double等
  • 转换函数必须是类方法,由类对象来调用;
  • 转换函数不能指定返回类型;
  • 转换函数不能有参数。

修改后的声明:

1
2
3
4
5
6
7
8
9
10
11
// stonewt1.h -- revised definition for the Stonewt class
#ifndef STONEWT1_H_
#define STONEWT1_H_
class Stonewt {
// ...
public:
// 转换函数
operator int() const;
operator double() const;
};
#endif

实现:

1
2
3
4
5
6
7
Stonewt::operator int() const {
return int (pounds + 0.5);
}

Stonewt::operator double() const {
return pounds;
}

使用:

1
2
3
Stonewt poppins(9, 2.8);
double p_wt = poppins; // p_wt = 2.8,隐式转换
double p_wt = (double) popins; // 显式的强制转换

在C++11中,也可以将转换运算符声明为显式的:

1
explicit operator int() const;

这样只能采取显式的强制转换。

10 类和动态内存分配

10.1 动态内存和类

10.1.1 问题引入

一个模拟String类的但有问题的StringBad类。

  • strngbad.h
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// strngbad.h -- flawed string class definition
#include <iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_
class StringBad {
private:
char * str; // pointer to string
int len; // length of string
static int num_strings; // number of objects
public:
StringBad(const char * s); // constructor
StringBad(); // default constructor
~StringBad(); // destructor
// friend function
friend std::ostream & operator<<(std::ostream & os,
const StringBad & st);
};
#endif
  • strngbad.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// strngbad.cpp -- StringBad class methods
#include <cstring> // string.h for some
#include "strngbad.h"
using std::cout;

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

// class methods

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

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

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

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

程序分析:

在类的定义中,对静态成员进行了初始化:

1
int StringBad::num_strings = 0;

请注意,不能在类声明中初始化静态成员变量,这是因为声明描述了如何分配内存,但并不分配内存。对于静态类成员,可以在类声明之外使用单独的语句来讲行初始化,这是因为静态类成员是单独存储的,而不是对象的组成部分。请注意,初始化语句指出了类型,并使用了作用域运算符,但没有使用关键字 static。

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


测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// vegnews.cpp -- using new and delete with classes
// compile with strngbad.cpp
#include <iostream>
using std::cout;
#include "strngbad.h"

void callme1(StringBad &); // pass by reference
void callme2(StringBad); // pass by value

int main()
{
using std::endl;
{
cout << "Starting an inner block.\n";
StringBad headline1("Celery Stalks at Midnight");
StringBad headline2("Lettuce Prey");
StringBad sports("Spinach Leaves Bowl for Dollars");
cout << "headline1: " << headline1 << endl;
cout << "headline2: " << headline2 << endl;
cout << "sports: " << sports << endl;
callme1(headline1);
cout << "headline1: " << headline1 << endl;
// 按值传递,调用复制构造函数
callme2(headline2);
cout << "headline2: " << headline2 << endl;
cout << "Initialize one object to another:\n";
// 初始化对象时,调用复制构造函数
StringBad sailor = sports;
cout << "sailor: " << sailor << endl;
cout << "Assign one object to another:\n";
StringBad knot;
// 赋值运算符重载
knot = headline1;
cout << "knot: " << knot << endl;
cout << "Exiting the block.\n";
}
cout << "End of main()\n";
return 0;
}

// 引用传递
// 打印字符串
void callme1(StringBad & rsb) {
cout << "String passed by reference:\n";
cout << " \"" << rsb << "\"\n";
}

// 值传递打印字符串
void callme2(StringBad sb) {
cout << "String passed by value:\n";
cout << " \"" << sb << "\"\n";
}

打印结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Starting an inner block.
1: "Celery Stalks at Midnight" object created
2: "Lettuce Prey" object created
3: "Spinach Leaves Bowl for Dollars" object created
headlinel: Celery Stalks at Midnight headline2:Lettuce Prey
sports: Spinach Leaves Bowl for Dollars
String passed by reference:"Celery Stalks at Midnight"
headlinel: Celery Stalks at Midnight
String passed by value:"Lettuce Prey"
"Lettuce Prey" object deleted,2 left
// 第一次出现了乱码
headline2:Dü°
Initialize one object to another:
sailor: Spinach Leaves Bowl for Dollars
Assign one object to another:
3:"C++" default object created
knot: Celery Stalks at Midnight
Exiting the block. // 退出代码块,调用析构函数
"Celery Stalks at Midnight" object deleted, 2 left
"Spinach Leaves Bowl for Dollars" object deleted, 1 left
"Spinach Leaves Bowl for Doll8" object deleted, 0 left
// 第二次出现乱码
"@g" object deleted, -1 left
"-|" object deleted,-2 left
End of main()

每个对象被构造和析构一次,因此调用构造函数的次数与调用析构函数的次数相同。对象计数num_strings最后值为-2,说明不将num_strings递增的构造函数创建了两个对象。

1
StringBad sailor = sports;

这种形式的初始化等效于下面的语句:

1
StringBad sailor = StringBad(sports);

相应的构造函数原型为:

1
StringBad(const StringBad &);

当使用一个对象来初始化另一个对象时,编译器将自动生成上述构造函数(称为复制构造函数,因为它创建对象的一个副本)。自动生成的构造函数不知道需要更新静态变量num_string,因此会将计数方案搞乱。实际上,这个例子说明的所有问题都是由编译器自动生成的成员函数引起的。

10.1.2 复制构造函数

C++自动为类提供下面的成员函数:

  • 默认构造函数,如果没有定义构造函数;
  • 默认析构函数,如果没有定义;
  • 复制构造函数,如果没有定义;
  • 赋值运算符的重载函数,如果没有定义;
  • 地址运算符的重载函数,如果没有定义;

复制构造函数用于将一个对象复制(浅复制)到新创建的对象中。也就是说,它用于初始化过程中(包括按值传递参数,按值传递实际上是将实参复制一份给形参,即创建实参的一个副本),而不是常规的赋值过程中。

类的复制构造函数原型通常如下:

1
Class_name(const Class_name &);

调用时机:新建一个对象并将其初始化为同类现有对象时,复制构造函数都将被调用。

1
2
3
4
5
// 假设motto对象已被创建,以下情形都将调用复制构造函数
StringBad ditto(motto);
String metto = motto;
StringBad also = StringBad(motto);
StringBad * pStringBad = new StringBad(motto);

例如,上面的程序清单中:

1
callme2(headline2);

程序将使用复制构造函数初始化sb,即函数callme2的形参。

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

注意:隐式实现(编译器自动生成)的复制构造函数的复制是浅复制,或者说是按位复制内存。具体来说,就是将 sports 所在内存中的数据按照二进制位(Bit)复制到 sailor 所在的内存。

1
2
3
4
5
StringBad sailor = sports;
// 等价于:
StringBad sailor;
sailor.str = sports.str;
sailor.len = sports.len;

image-20230106120209739

程序出现的问题

对象计数num_strings出现了负值,原因在于callme2使用了按值传递,而默认复制构造函数没有操作对象计数。

解决办法是显式实现一个复制构造函数:

1
2
3
4
StringBad::StringBad(const StringBad & s) {
num_strings++;
// other code
}

此外,执行结果第一次出现乱码的地方:

1
headline2:Dü°

原因在于:按值传递时,出现了浅复制。

1
2
3
4
5
6
7
8
9
10
11
callme2(headline2);

void callme2(StringBad sb) {
cout << "String passed by value:\n";
cout << " \"" << sb << "\"\n";
}

// 相当于:
StringBad sb = headline2;
sb.str = headline2.str;
sb.len = headline2.len;

sb对象和headline2对象的字符指针str均指向同一个内存地址。当callme2返回时,函数栈中的sb对象销毁,调用析构函数,释放该字符指针指向的内存:

1
delete [] str; // 即:delete [] sb.str;

程序回到main函数的代码块:

1
cout << "headline2: " << headline2 << endl;

此时,headline2的字符指针指向的内存是被释放的内存,所以出现乱码。此外,退出代码块时,headline2被销毁,调用析构函数,再次释放已经释放的内存,还可能导致程序异常终止。

问题的解决

如果一个类拥有指针类型的成员变量,那么绝大部分情况下就需要深复制,因为只有这样,才能将指针指向的内容再复制出一份来,让原有对象和新生对象相互独立,彼此之间不受影响。如果类的成员变量没有指针,一般浅复制足以。

所以在复制构造函数中使用深复制

1
2
3
4
5
6
StringBad::StringBad(const StringBad & st) {
num_strings++;
len = st.len;
str = new char[len + 1]; // 新开辟内存
std::strcpy(str, st.str); // 将原始对象str的内容复制到该对象
}

image-20230106123617521

10.1.3 赋值运算符

ANSI C允许结构赋值,而C++允许类对象赋值,这是通过自动为类重载赋值运算符实现的。这种运算符的原型如下:

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

将已有的对象赋给另一个对象(注意不是在初始化时,否则调用复制构造函数)时,将使用重载的赋值运算符,隐式实现的赋值运算符也是浅复制。

1
2
StringBad knot;
knot = headline1;

为headline1调用析构函数时,显示乱码的问题和复制构造函数的浅复制问题一样。

问题的解决

解决办法是显式实现赋值运算符的重载,并提供深度复制。

注意点:

  • 由于复制对象可能引用了以前分配的数据,因此需要使用delete来释放
  • 函数应当避免将对象赋给自身;否则,给对象重新赋值前,释放内存操作可能删除对象的内容
1
2
3
4
5
6
7
8
9
10
StringBad & StringBad::StringBad=(const StringBad & st) {
if(this == &st) { // 若是自己给自己赋值,则直接返回
return *this;
}
delete [] str; // 释放之前分配的内存
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
return *this;
}

10.2 改进后的新String类

现在将StringBad类重命名为String类,并新增以下方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 返回字符串长度
int length()const{ return len;}
// 比较两个字符串大小
friend bool operator<(const String &st,const String &st2);
friend bool operator>(const String &st1,const String &st2);
friend bool operator==(const String &st,const String &st2);
// 输入字符串
friend operator>>(istream&is,String&st);
// 根据索引返回字符
char & operator[](int i);
const char & operator[](int i) const;
// 返回num_strings,静态类成员函数
static int HowMany();

10.2.1 修改后的默认构造函数

修改后的默认构造函数为:

1
2
3
4
5
String::String() {
len = 0;
str = new char[1];
str[0] = '\0';
}

注意,析构函数中的释放方式为:delete[],因此必须以new[]的方式来初始化指针或空指针。

上面的代码也可以修改为:

1
2
3
4
String::String() {
len = 0;
str = 0; // 设置空指针
}

在C++98中,字面值0可以表示数值0,也可以表示空指针(或者使用宏NULL表示,它的本质也是常量值0)。C++11中引入了新的关键字nullptr来表示空指针:

1
str = nullptr; // 也是可行的方式

注:空指针指向0,大多数系统中都将0作为不被使用的地址

10.2.2 比较成员函数

使用库函数strcmp来比较:

1
2
3
4
5
6
7
bool operator<(const String & st1, const String & st2){
if(std::strcmp(st1.str, st2.str) < 0) {
return true;
} else {
return false;
}
}

可以简化为:

1
2
3
bool operator<(const String & st1, const String & st2){
return (std::strcmp(st1.str, st2.str) < 0);
}

同理对operator>operator==的实现一致。

使用:假设answe是String类对象

1
2
3
4
5
6
7
if("love" == answer);

// 将被转换为:
if(operator==("love", answer));

// 然后将love通过构造函数String(const char * s)隐式转换:
if(operator==(String("love"), answer);

10.2.3 中括号访问字符

可以使用operator[]()来重载该运算符:

1
2
3
char & String::operator[](int i) {
return str[i];
}

则使用时:

1
2
3
4
5
6
7
8
String opera("The Magic Flute");
char c = opera[4];
// C++将查找名称与特征标与此相同的方法:
String::operator[](int i);
// 然后替换为:
char c = opera.operator[](4);
// 即:
char c = opera.str[4];

将类型声明为char &,便可以对特定元素赋值:

1
2
3
4
5
opera[0] = 'r';
// 替换为
opera.operator[](0) = 'r';
// 实际上左侧返回的是opera.str[0]的引用,即为:
opera.str[0] = 'r';

如果有常量对象:const String answer(futile);,则只有上述的重载定义时,下面的语句会报错:

1
cout << answer[1];

原因在于answer是常量,String::operator[](int i)方法无法保证是否会修改原始数据,因此需要额外提供一份针对const对象的重载定义:

1
2
3
const char & String::operator[](int i) {
return str[i];
}

10.2.4 静态类成员函数

可以将类的成员函数设置为静态的(static),此时对该函数有一定的限制:

  • 不能通过对象调用该函数
  • 静态成员函数不能使用类中的非静态数据和this指针
  • 静态类成员函数只能使用类中的静态数据

例如:

1
static int HowMany() { return num_strings }; // inline

调用:

1
int count = String::HowMany();

10.2.5 进一步重载赋值运算符

假设要讲一个常规字符串复制到String对象中,利用现有的String类,代码为:

1
2
3
4
String name;
char temp[40];
cin.getline(temp, 40);
name = temp;

最后一条语句的执行流程:

  • 首先使用String(const char *)构造函数将字符数组temp隐式转换为String类的临时对象,即String t(temp)
  • 使用赋值运算符的重载String & String::operator=(const String &),深复制临时对象tname对象中
  • 临时对象t释放,并调用析构函数

为了提高处理效率,对赋值运算符进行重载,使得能够直接使用常规的字符串:

1
2
3
4
5
6
7
String & String::operator=(const char * s) {
delete [] str;
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
return *this;
}

10.2.6 修改后的String代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// string1.h -- fixed and augmented string class definition

#ifndef STRING1_H_
#define STRING1_H_
#include <iostream>
using std::ostream;
using std::istream;

class String
{
private:
char * str; // pointer to string
int len; // length of string
static int num_strings; // number of objects
static const int CINLIM = 80; // cin input limit
public:
// constructors and other methods
String(const char * s); // constructor
String(); // default constructor
String(const String &); // 复制构造函数
~String(); // destructor
int length () const { return len; }
// 重载的运算符
String & operator=(const String &);
String & operator=(const char *);
char & operator[](int i);
const char & operator[](int i) const;
// 重载的友元运算符
friend bool operator<(const String &st, const String &st2);
friend bool operator>(const String &st1, const String &st2);
friend bool operator==(const String &st, const String &st2);
friend ostream & operator<<(ostream & os, const String & st);
friend istream & operator>>(istream & is, String & st);
// 静态成员函数
static int HowMany();
};
#endif

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
// string1.cpp -- String class methods
#include <cstring> // string.h for some
#include "string1.h" // includes <iostream>
using std::cin;
using std::cout;

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

// static method
int String::HowMany() {
return num_strings;
}

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

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

String::String(const String & st) {
num_strings++; // handle static member update
len = st.len; // same length
str = new char [len + 1]; // allot space
std::strcpy(str, st.str); // copy string to new location
}

String::~String() { // necessary destructor

--num_strings; // required
delete [] str; // required
}

// overloaded operator methods

// assign a String to a String
String & String::operator=(const String & st) {
if (this == &st)
return *this;
delete [] str;
len = st.len;
str = new char[len + 1];
std::strcpy(str, st.str);
return *this;
}

// assign a C string to a String
String & String::operator=(const char * s) {
delete [] str;
len = std::strlen(s);
str = new char[len + 1];
std::strcpy(str, s);
return *this;
}

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

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

// overloaded operator friends
bool operator<(const String &st1, const String &st2) {
return (std::strcmp(st1.str, st2.str) < 0);
}

bool operator>(const String &st1, const String &st2) {
return st2 < st1;
}

bool operator==(const String &st1, const String &st2) {
return (std::strcmp(st1.str, st2.str) == 0);
}

// simple String output
ostream & operator<<(ostream & os, const String & st) {
os << st.str;
return os;
}

// quick and dirty String input
istream & operator>>(istream & is, String & st) {
char temp[String::CINLIM];
is.get(temp, String::CINLIM);
if (is)
st = temp;
while (is && is.get() != '\n')
continue;
return is;
}

10.3 构造函数中new的注意事项

使用new初始化对象中的指针成员时应当特别小心,以下是一些注意点:

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

代码示例

在之前的String类中,有一个char *的成员str:

1
2
3
private:	
char * str;
// ...

对应的析构函数为:

1
2
3
String::~String() {
delete [] str;
}

下面的构造函数代码是错误的:

1
2
3
4
5
6
7
8
9
10
String::String() {
str = "default string"; // no new
len = strlen(str);
}

String::String(const char *) {
len = strlen(s);
str = new char; // no []
strcpy(str, s); // no room for str
}

第一个构造函数没有使用new来初始化str,对不是使用new初始化的指针使用delete时,结果将是不确定的,可以将其改造为:

1
2
3
4
5
6
7
8
9
10
String::String() {
len = 0;
str = new char[1]; // use new with []
str[0] = '\0';
}

String::String() {
len = 0;
str = 0; // 或者 str = NULL; str = nullptr;
}

第二个构造函数使用了new,但是分配的内存量和格式不正确。

最后析构函数也有要求:

1
2
3
String::~String() {
delete str; // error
}

构造函数创建的是一个字符数组,因此析构函数应该删除一个数组。

10.4 使用指向对象的指针

10.4.1 程序示例

以下代码执行效果:让用户输入10句以内的字符串,程序比较打印出字符串,然后打印出长度最小和排列顺序最前的字符串,最后随机打印出用户输入的一条字符串。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// sayings2.cpp -- using pointers to objects
// compile with string1.cpp
#include <iostream>
#include <cstdlib> // (or stdlib.h) for rand(), srand()
#include <ctime> // (or time.h) for time()
#include "string1.h"
const int ArSize = 10;
const int MaxLen = 81;
int main() {
using namespace std;
String name;
cout <<"Hi, what's your name?\n>> ";
cin >> name;

cout << name << ", please enter up to " << ArSize
<< " short sayings <empty line to quit>:\n";
String sayings[ArSize];
char temp[MaxLen]; // temporary string storage
int i;
for (i = 0; i < ArSize; i++) {
cout << i+1 << ": ";
cin.get(temp, MaxLen);
while (cin && cin.get() != '\n')
continue;
if (!cin || temp[0] == '\0') // empty line?
break; // i not incremented
else
sayings[i] = temp; // overloaded assignment
}
int total = i; // total # of lines read

if (total > 0) {
cout << "Here are your sayings:\n";
for (i = 0; i < total; i++)
cout << sayings[i] << "\n";

// use pointers to keep track of shortest, first strings
String * shortest = &sayings[0]; // initialize to first object
String * first = &sayings[0];
for (i = 1; i < total; i++) {
if (sayings[i].length() < shortest->length())
shortest = &sayings[i];
if (sayings[i] < *first) // compare values
first = &sayings[i]; // assign address
}
cout << "Shortest saying:\n" << * shortest << endl;
cout << "First alphabetically:\n" << * first << endl;

srand(time(0));
int choice = rand() % total; // 随机获取下标
// 使用new来初始化String
String * favorite = new String(sayings[choice]);
cout << "My favorite saying:\n" << *favorite << endl;
// 释放对象占用的内存
delete favorite;
}
else
cout << "Not much to say, eh?\n";
cout << "Bye.\n";
return 0;
}

10.4.2 new和delete

程序在两个层次上使用了new和delete。

首先,它使用 new 为创建的每一个对象的成员字符串分配存储空间,这是在构造函数中进行的,因此析构函数使用 delete 来释放这些内存。因为字符串是一个字符数组,所以析构函数使用的是带中括号的 delete,这样,当对象被释放时,用于存储字符串内容的内存将被自动释放。

其次,上述代码使用new来为整个对象分配内存:

1
String * favorite = new String(sayings[choice]);

即:为保存字符串地址的str指针和len成员分配内存。释放对象的内存时,由于对象是单个的,因此使用delete删除它:

1
delete favorite;

注意:这里只会释放保存str指针和len成员的内存,并不释放str指向的内存,后者由析构函数来完成。

image-20230109101838601

析构函数调用的时机参见8.2.4小节

使用new创建对象的过程:

image-20230109103023279

10.4.3 小结

使用对象的指针时,有以下动作:

  • 使用常规表示法来声明指向对象的指针
1
String * glamour;
  • 可以将指针初始化为指向已有的对象
1
String * first = &sayings[0];
  • 可以使用new初始化指针,这将创建一个新的对象
1
String * favorite = new String(sayings[choice]);
  • 对类使用new将调用对应的构造函数来初始化新创建的对象
1
2
String * gleep = new String;
String * glop = new String("Hello");

image-20230109102918883

  • 可以使用->通过指针访问类方法
1
2
3
4
String * first = &sayings[0];
if(first->length() > 5) {
// ...
}
  • 可以通过*解引用指针获取对象
1
2
3
4
5
String * first = &sayings[0];
String * second = &sayings[1];
if(*second < *first) { // <运算符重载比较两个字符串对象
// ...
}

11 类继承

11.1 问题引入

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

11.1.1 基类

基类示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// tabtenn0.h -- a table-tennis base class
#ifndef TABTENN0_H_
#define TABTENN0_H_
#include <string>
using std::string;
// simple base class
class TableTennisPlayer {
private:
string firstname;
string lastname;
bool hasTable;
public:
TableTennisPlayer (const string & fn = "none",
const string & ln = "none", bool ht = false);
void Name() const;
bool HasTable() const { return hasTable; };
void ResetTable(bool v) { hasTable = v; };
};
#endif
1
2
3
4
5
6
7
8
9
10
11
//tabtenn0.cpp -- simple base-class methods
#include "tabtenn0.h"
#include <iostream>

TableTennisPlayer::TableTennisPlayer (const string & fn,
const string & ln, bool ht) : firstname(fn),
lastname(ln), hasTable(ht) {} // 成员初始化列表语法

void TableTennisPlayer::Name() const {
std::cout << lastname << ", " << firstname;
}

使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// usett0.cpp -- using a base class
#include <iostream>
#include "tabtenn0.h"

int main ( void ) {
using std::cout;
TableTennisPlayer player1("Chuck", "Blizzard", true);
TableTennisPlayer player2("Tara", "Boomdea", false);
player1.Name();
if (player1.HasTable())
cout << ": has a table.\n";
else
cout << ": hasn't a table.\n";
player2.Name();
if (player2.HasTable())
cout << ": has a table";
else
cout << ": hasn't a table.\n";
return 0;
}

实例化对象时将C-风格的字符串作为参数,但类的构造函数是将string作为参数,原因在于string类有一个const char *作为参数的构造函数,通过隐式转换将其转换为string对象。

补充:成员初始化列表

在构造函数中对每一个成员进行初始化。方式有两种:

  • 使用初始化列表
  • 在构造函数体中进行赋值操作

一个好的原则是,能使用初始化列表的时候尽量使用初始化列表。


  • 赋值操作:先无参初始化,然后用参数赋值(默认构造+赋值重载)
1
2
3
4
5
6
7
8
9
10
11
12
13
class Entry {
public:
// 不推荐的做法
Entry(int num, const std::string & address, const std::string & name){ // 先调用无参构造函数,然后调用赋值运算符
m_num = num;
m_address = address; // 不是初始化而是赋值
m_name = name;
}
private:
int m_num;
std::string m_address;
std::string m_name;
};

过程(以m_address为例):首先为m_address调用string类的默认构造函数,进入构造函数体中,再调用赋值运算符的重载,将m_address设置为address

实际上执行的语句为:

1
2
String m_address(); // 初始化
m_address = address; // 赋值
  • 初始化列表:直接以参数初始化(复制构造)
1
2
3
4
5
6
7
8
9
10
11
12
class Entry {
public:
// 推荐
Entry(int num, const std::string & address, const std::string & name) : m_num(num),
m_address(address),
m_name(name){ // 初始化列表调用的是复制构造函数
}
private:
int m_num;
std::string m_address;
std::string m_name;
};

成员初始化列表器列表是以:开头,后跟一系列以,分隔的初始化字段。使用初始化列表少了一次调用默认构造函数的过程

过程:先调用m_address、m_name这些成员的复制构造函数初始化成员变量,然后进入Entry构造函数体中,在这里面是空的,什么也不干。

实际执行的语句为:

1
String m_address = address;

这里调用string类的复制构造函数,传入address为参数,复制到m_address中。

11.1.2 派生类

TableTennisPlayer派生一个类RatedPlayer表示成员在比赛中的得分:

1
2
3
class RatedPlayer : public TableTennisPlayer {
//...
};

public表示公有派生。使用公有派生,基类的公有成员称为派生类的公有成员;基类的私有部分也会成为派生类的一部分,但只能通过基类的公有方法和保护方法访问。

image-20230109111243108

派生类需要自己的构造函数,根据需要添加额外的成员和方法。例如:

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

11.1.3 构造函数

派生类构造函数必须使用基类构造函数。

创建派生类对象时,程序首先创建基类对象。从概念上说,这意味着基类对象应当在程序进入派生类构造函数之前被创建。C++使用成员初始化列表语法来完成这种工作。

例如,下面是第一个RatedPlayer构造函数的代码:

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

使用:

1
RatedPlayer rplayer1(1140, "Mallory", "Duck", true);

执行过程:RealPlayer构造函数将把实参"Mallory","Duck",true赋给形参fn,ln,ht,然后将这些参数作为实参传递给 TableTennisPlayer 构造函数,后者将创建一个嵌套 TableTennisPlayer 对象,并将数据”Mallory”、”Duck”和true存储在该对象中。然后,程序进入RealPlayer构造函数体,完成RealPlayer对象的创建,并将参数r的值赋给rating成员。

如果省略成员初始化列表:

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

此时程序将使用基类的默认构造函数。

对于第二个构造函数的代码:

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

由于tp的类型为TableTemnisPlayer&,因此将调用基类的复制构造函数。基类没有定义复制构造函数,将使用默认提供的。在这种情况下,执行成员复制的隐式复制构造函数是合适的,因为这个类没有使用动态内存分配(string 成员确实使用了动态内存分配,但前面说过,成员复制将使用string类的复制构造函数来复制string成员)。

11.1.4 使用派生类

要使用派生类,程序必须要能够访问基类声明。示例代码将这两种类的声明置于同一个头文件中。也可以将每个类放在独立的头文件中,但由于这两个类是相关的,所以把其类声明放在一起更合适。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// tabtenn1.h -- a table-tennis base class
#ifndef TABTENN1_H_
#define TABTENN1_H_
#include <string>
using std::string;
// 基类
class TableTennisPlayer {
private:
string firstname;
string lastname;
bool hasTable;
public:
TableTennisPlayer (const string & fn = "none",
const string & ln = "none", bool ht = false);
void Name() const;
bool HasTable() const { return hasTable; };
void ResetTable(bool v) { hasTable = v; };
};

// 派生类
class RatedPlayer : public TableTennisPlayer
{
private:
unsigned int rating;
public:
RatedPlayer (unsigned int r = 0, const string & fn = "none",
const string & ln = "none", bool ht = false);
RatedPlayer(unsigned int r, const TableTennisPlayer & tp);
unsigned int Rating() const { return rating; }
void ResetRating (unsigned int r) {rating = r;}
};
#endif

定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//tabtenn1.cpp -- simple base-class methods
#include "tabtenn1.h"
#include <iostream>

TableTennisPlayer::TableTennisPlayer (const string & fn,
const string & ln, bool ht) : firstname(fn),
lastname(ln), hasTable(ht) {}

void TableTennisPlayer::Name() const {
std::cout << lastname << ", " << firstname;
}

// 派生类构造方法
RatedPlayer::RatedPlayer(unsigned int r, const string & fn,
const string & ln, bool ht) : TableTennisPlayer(fn, ln, ht){
rating = r;
}

RatedPlayer::RatedPlayer(unsigned int r, const TableTennisPlayer & tp)
: TableTennisPlayer(tp), rating(r){
}

测试代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// usett1.cpp -- using base class and derived class
#include <iostream>
#include "tabtenn1.h"

int main ( void ) {
using std::cout;
using std::endl;
// 基类对象
TableTennisPlayer player1("Tara", "Boomdea", false);
// 派生类对象
RatedPlayer rplayer1(1140, "Mallory", "Duck", true);
rplayer1.Name(); // 派生类对象使用基类的方法
if (rplayer1.HasTable())
cout << ": has a table.\n";
else
cout << ": hasn't a table.\n";
player1.Name(); // 基类对象使用基类的方法
if (player1.HasTable())
cout << ": has a table";
else
cout << ": hasn't a table.\n";
cout << "Name: ";
rplayer1.Name();
cout << "; Rating: " << rplayer1.Rating() << endl;
// 使用基类对象初始化派生类对象
RatedPlayer rplayer2(1212, player1);
cout << "Name: ";
rplayer2.Name();
cout << "; Rating: " << rplayer2.Rating() << endl;
return 0;
}

程序输出:

image-20230109194518459

11.1.5 派生类和基类的特殊关系

  • 派生类可以使用基类的公有方法
  • 基类指针(和引用)可以在不进行显式类型转换的情况下指向(引用)派生类对象
1
2
3
4
5
6
// 派生类对象
RatedPlayer rp(1140, "Mallory", "Duck", true);
TatbleTennisPlayer & rt = rp; // 基类指针指向派生类对象
TatbleTennisPlayer * pt = rp;
rt.Name();
pt->Name();

然而基类指针或引用只能调用基类方法,不能调用派生类的方法:

1
2
rt.ResetRanking(); // error!
pt->ResetRanking(); // error!
  • 不可以将基类对象或引用赋给派生类引用和指针
1
2
3
4
// 派生类对象
TatbleTennisPlayer player("Mallory", "Duck", true);
RatedPlayer & rr = player; // error!
RatedPlayer * pr = player;

11.2 多态公有继承

RatedPlayer继承示例很简单:派生类对象使用基类的方法,而未做任何修改。然而,可能会遇到这样的情况,即希望同一个方法在派生类和基类中的行为是不同的。换句话来说,方法的行为应取决于调用该方法的对象。这种较复杂的行为称为多态——具有多种形态,即同一个方法的行为随上下文而异。

有两种重要的机制可用于实现多态公有继承:

  • 在派生类中重新定义基类的方法
  • 使用虚方法

代码示例

一个类用于表示基本支票账户Brass Account,另一个类用于表示代表 Brass Plus 支票账户,它添加了透支保护特性。也就是说,如果用户签出一张超出其存款余额的支票但是超出的数额并不是很大,银行将支付这张支票,对超出的部分收取额外的费用,并追加罚款。可以根据要保存的数据以及允许执行的操作来确定这两种账户的特征。

Brass Account支票账户的信息:

  • 客户姓名
  • 账号
  • 当前结余

执行的操作:

  • 创建账户
  • 存款
  • 取款
  • 显示账户信息

Brass Plus提供的额外信息:

  • 透支上限,默认500元
  • 透支贷款利率,默认11.125%
  • 当前的透支总额

不新增操作,但是有对两种操作的不同实现:

  • 取款操作,必须考虑透支保护
  • 显示操作必须显示Brass Plus账户的其他信息

类的声明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// brass.h  -- bank account classes
#ifndef BRASS_H_
#define BRASS_H_
#include <string>
// Brass Account Class
class Brass {
private:
// 客户姓名
std::string fullName;
// 账号
long acctNum;
// 结余
double balance;
public:
Brass(const std::string & s = "Nullbody", long an = -1, double bal = 0.0);
// 放贷
void Deposit(double amt);
// 虚方法
// 提款
virtual void Withdraw(double amt);
double Balance() const;
virtual void ViewAcct() const;
// 虚析构函数
virtual ~Brass() {}
};

//Brass Plus Account Class
class BrassPlus : public Brass {
private:
// 透支上限
double maxLoan;
// 透支的贷款利率
double rate;
// 透支总额
double owesBank;
public:
BrassPlus(const std::string & s = "Nullbody", long an = -1, double bal = 0.0, double ml = 500, double r = 0.11125);
BrassPlus(const Brass & ba, double ml = 500, double r = 0.11125);
// 虚方法
virtual void ViewAcct()const;
// 提款
virtual void Withdraw(double amt);
void ResetMax(double m) { maxLoan = m; }
void ResetRate(double r) { rate = r; };
void ResetOwes() { owesBank = 0; }
};

#endif

两个类在声明ViewAcct()Withdraw()时使用了关键字virtual,这些方法被称为虚方法(虚函数)

对于虚方法,如果方法是通过引用或指针而不是对象调用的,它将确定使用哪一种方法:

  • 如果没有使用virtual,程序将根据引用类型或指针类型选择方法
  • 如果使用了virtual,程序将根据引用或指针指向的对象的类型来选择方法

例如,如果ViewAcct()不是虚的,则:

1
2
3
4
5
6
Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct(); // 调用Brass::ViewAcct()
b2_ref.ViewAcct(); // 调用Brass::ViewAcct(),因为b2_ref的引用类型是Brass

如果ViewAcct()是虚的,则:

1
2
3
4
5
6
Brass dom("Dominic Banker", 11224, 4183.45);
BrassPlus dot("Dorothy Banker", 12118, 2592.00);
Brass & b1_ref = dom;
Brass & b2_ref = dot;
b1_ref.ViewAcct(); // 调用Brass::ViewAcct()
b2_ref.ViewAcct(); // 调用BrassPlus::ViewAcct(),因为b2_ref引用的对象是BrassPlus类型的对象

总结:在基类中应当将派生类会重新定义的方法声明为虚方法,在派生类中这些方法会自动成为虚方法,派生类中的virtual可有可无,但建议写上。

类的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
// brass.cpp -- bank account class methods
#include <iostream>
#include "brass.h"
using std::cout;
using std::endl;
using std::string;

// formatting stuff
typedef std::ios_base::fmtflags format;
typedef std::streamsize precis;
format setFormat();
void restore(format f, precis p);

// Brass methods
Brass::Brass(const string & s, long an, double bal) {
fullName = s;
acctNum = an;
balance = bal;
}

void Brass::Deposit(double amt) {
if (amt < 0)
cout << "Negative deposit not allowed; "
<< "deposit is cancelled.\n";
else
balance += amt;
}

void Brass::Withdraw(double amt) {
// set up ###.## format
format initialState = setFormat();
precis prec = cout.precision(2);
// 错误判断
if (amt < 0)
cout << "Withdrawal amount must be positive; "

<< "withdrawal canceled.\n";
else if (amt <= balance) // 提款金额小于余额,则余额相减
balance -= amt;
else // 提款金额大于余额,报错
cout << "Withdrawal amount of $" << amt
<< " exceeds your balance.\n"
<< "Withdrawal canceled.\n";
restore(initialState, prec);
}

double Brass::Balance() const {
return balance;
}

void Brass::ViewAcct() const {
// set up ###.## format
format initialState = setFormat();
precis prec = cout.precision(2);
cout << "Client: " << fullName << endl;
cout << "Account Number: " << acctNum << endl;
cout << "Balance: $" << balance << endl;
restore(initialState, prec); // Restore original format
}

// BrassPlus Methods
BrassPlus::BrassPlus(const string & s, long an, double bal,
double ml, double r) : Brass(s, an, bal) {
maxLoan = ml;
owesBank = 0.0;
rate = r;
}

BrassPlus::BrassPlus(const Brass & ba, double ml, double r)
: Brass(ba) { // uses implicit copy constructor
maxLoan = ml;
owesBank = 0.0;
rate = r;
}

// redefine how ViewAcct() works
void BrassPlus::ViewAcct() const {
// set up ###.## format
format initialState = setFormat();
precis prec = cout.precision(2);
// 调用基类的公有方法来展示基类中的私有成员
Brass::ViewAcct(); // display base portion
cout << "Maximum loan: $" << maxLoan << endl;
cout << "Owed to bank: $" << owesBank << endl;
cout.precision(3); // ###.### format
cout << "Loan Rate: " << 100 * rate << "%\n";
restore(initialState, prec);
}

// redefine how Withdraw() works
void BrassPlus::Withdraw(double amt) {
// set up ###.## format
format initialState = setFormat();
precis prec = cout.precision(2);

// 派生类使用基类的方法
double bal = Balance(); // 即Brass::Balance();
if (amt <= bal) // 提款金额小于余额,则调用基类的提款方法
Brass::Withdraw(amt);
else if ( amt <= bal + maxLoan - owesBank) {
double advance = amt - bal;
owesBank += advance * (1.0 + rate);
cout << "Bank advance: $" << advance << endl;
cout << "Finance charge: $" << advance * rate << endl;
// 调用基类的放贷方法
Deposit(advance);
Brass::Withdraw(amt);
}
else
cout << "Credit limit exceeded. Transaction cancelled.\n";
restore(initialState, prec);
}

format setFormat() {
// set up ###.## format
return cout.setf(std::ios_base::fixed,
std::ios_base::floatfield);
}

void restore(format f, precis p) {
cout.setf(f, std::ios_base::floatfield);
cout.precision(p);
}

测试1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// usebrass1.cpp -- testing bank account classes
// compile with brass.cpp
#include <iostream>
#include "brass.h"

int main() {
using std::cout;
using std::endl;

// 创建类的对象
Brass Piggy("Porcelot Pigg", 381299, 4000.00);
BrassPlus Hoggy("Horatio Hogg", 382288, 3000.00);
Piggy.ViewAcct();
cout << endl;
Hoggy.ViewAcct();
cout << endl;
cout << "Depositing $1000 into the Hogg Account:\n";
Hoggy.Deposit(1000.00);
cout << "New balance: $" << Hoggy.Balance() << endl;
cout << "Withdrawing $4200 from the Pigg Account:\n";
Piggy.Withdraw(4200.00);
cout << "Pigg account balance: $" << Piggy.Balance() << endl;
cout << "Withdrawing $4200 from the Hogg Account:\n";
Hoggy.Withdraw(4200.00);
Hoggy.ViewAcct();
return 0;
}

测试2

在测试1中,方法是通过对象(而不是指针或引用)调用的,没有使用虚方法特性。

下面来看一个使用了虚方法的例子。假设要同时管理 Brass和 BrassPlus 账户,如果能使用同一个数组来保存 Brss 和BrassPlus对象,将很有帮助,但这是不可能的。数组中所有元素的类型必须相同,而Brass和BrassPlus 是不同的类型。然而,可以创建指向 Brass 的指针数组。这样,每个元素的类型都相同,但由于使用的是公有继承模型,因此 Brass指针既可以指向 Brass 对象,也可以指向 BrassPlus对象。因此,可以使用一个数组来表示多种类型的对象。这就是多态性

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// usebrass2.cpp -- polymorphic example
// compile with brass.cpp
#include <iostream>
#include <string>
#include "brass.h"
const int CLIENTS = 4;

int main()
{
using std::cin;
using std::cout;
using std::endl;

Brass * p_clients[CLIENTS];
std::string temp;
long tempnum;
double tempbal;
char kind;

for (int i = 0; i < CLIENTS; i++) {
cout << "Enter client's name: ";
getline(cin,temp);
cout << "Enter client's account number: ";
cin >> tempnum;
cout << "Enter opening balance: $";
cin >> tempbal;
cout << "Enter 1 for Brass Account or "
<< "2 for BrassPlus Account: ";
while (cin >> kind && (kind != '1' && kind != '2'))
cout <<"Enter either 1 or 2: ";
if (kind == '1')
p_clients[i] = new Brass(temp, tempnum, tempbal);
else
{
double tmax, trate;
cout << "Enter the overdraft limit: $";
cin >> tmax;
cout << "Enter the interest rate "
<< "as a decimal fraction: ";
cin >> trate;
p_clients[i] = new BrassPlus(temp, tempnum, tempbal,
tmax, trate);
}
while (cin.get() != '\n')
continue;
}
cout << endl;
for (int i = 0; i < CLIENTS; i++)
{
p_clients[i]->ViewAcct();
cout << endl;
}

for (int i = 0; i < CLIENTS; i++)
{
delete p_clients[i]; // free memory
}
cout << "Done.\n";
return 0;
}

如果数组成员指向的是 Brass 对象,则调用 Brass::ViewAcct;如果指向的是 BrassPlus 对象,则调用BrassPlus::ViewAcct。如果Brass::ViewAcct不被声明为虚的,则在任何情况下都将调用Brass::ViewAcct

11.3 静态联编和动态联编

程序调用函数时,将使用哪个可执行代码块呢?编译器负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编(binding)

在C语言中,这非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的缘故,这项任务更复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。

  • 静态联编:编译阶段就将函数实现与函数调用关联起来;在编译过程中进行联编被称为静态联编(static binding),又称为早期联编(early binding)。然而,虚函数使这项工作变得更困难。正如在上面的程序所示的那样,使用哪一个函数是不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。
  • 动态联编:在程序执行阶段才将函数实现和调用关联;编译器必须生成能够在程序运行时选择正确的虚方法的代码,这被称为动态联编(dynamic binding),又称为晚期联编(late binding)。

注意:动态联编是针对C++的多态,C语言全部都是静态联编;

11.3.1 指针和引用类型的兼容性

通常,C++不允许将一种类型的地址赋给另一种类型的指针,也不允许一种类型的引用指向另一种类型:

1
2
3
double x = 2.5;
int * pi = &x; // error
long & rl = x; // error

然而,指向基类的引用或指针可以引用或指向派生类对象,且不必进行显式类型转换,这种转换称为向上强制转换upcasting)。该规则是is-a关系的一部分(BrassPlus is a Brass.):派生类对象都是基类对象,因为它继承了基类对象的所有数据成员和方法,所以对基类对象执行的任何操作,都适用于派生类对象:

1
2
3
BrassPlus dilly("Annie Dill", 493222, 2000);
Brass * pb = &dilly; // valid
Brass & rb = dilly; // valid

相反的过程则不行(向下强制转换)(Brass is not a BrassPlus.)。

向上强转使得基类的指针或引用可以同时指向基类或派生类的对象,此时使用该指针或引用调用方法,编译器无法知道该方法是基类还是派生类的方法,因此需要动态联编,C++使用虚成员函数来满足这种需求。

11.3.2 虚成员函数和动态联编

1
2
3
4
BrassPlus ophelia;
Brass * bp;
bp = &ophelia;
bp->ViewAcct(); // 哪个类的ViewAcct方法?

正如前面介绍的,如果在基类中没有将ViewAcct声明为虚的,则bp->ViewAcct将根据指针类型(Brass)调用Brass::ViewAcct。指针类型在编译时已知,因此编译器在编译时,可以将ViewAcct关联到Brass:ViewAcct。总之,*编译器对非虚方法使用静态联编

然而,如果在基类中将ViewAcct声明为虚的,则bp->ViewAcct根据对象类型(BrassPlus)调用BrassPlus::ViewAcct。在这个例子中,对象类型为BrassPlus,但通常只有在运行程序时才能确定对象的类型。所以编译器生成的代码将在程序执行时,根据对象类型将ViewAcct关联到Brass::ViewAcctBrassPlus::ViewAcct。总之,编译器对虚方法使用动态联编

总结:编译看左边(指针或引用类型),运行看右边(实际指向的对象类型)。这就是多态性的体现。

虚函数实现原理

通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。

虚函数表中存储了为类对象进行声明的虚函数的地址。例如,基类对象包含一个指针,该指针指向基类中所有虚函数的地址表。派生类对象将包含一个指向独立地址表的指针。如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址:如果派生类没有重新定义虚函数,该 vtbl 将保存函数原始版本的地址。如果派生类定义了新的虚函数,则该函数的地址也将被添加到vtbl中。

image-20230110120340141

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

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

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

虽然非虚函数的效率比虚函数稍高,但不具备动态联编功能。

11.3.3 虚函数的注意事项

  • 构造函数不能是虚函数
  • 析构函数应当是虚函数,通常应该给基类提供一个虚析构函数,即使它不需要析构函数

例如,Employee是基类,Singer是派生类,并添加了一个char *成员,该成员指向由new分配的内存,当Singer对象过期时,必须调用~Singer()析构函数来释放内存。

1
2
3
Employee * pe = new Singer;
//...
delete pe; // 释放pe对象

如果使用默认的静态联编,delete 语句将调用~Employee析构函数。这将释放由 Singer 对象中的Employee部分指向的内存,但不会释放新的类成员指向的内存。

但如果析构函数是虚的,则上述代码将先调用~Singer析构函数释放由 Singer 组件指向的内存,然后,调用~Employec析构函数来释放由 Employee 组件指向的内存。

这意味着,即使基类不需要显式析构函数提供服务,也不应依赖于默认构造函数,而应提供虚析构函数,即使它不执行任何操作:

1
2
3
virtual ~BaseClass() {

}
  • 友元不能是虚函数,因为友元不是类的成员函数
  • 重新定义将隐藏方法

假设有如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Dwelling {
public:
virtual void showperks(int a) const;
};

class Hovel : public Dwelling {
public:
virtual void showperks() const;
};

Hovel trump;
trump.showperks(); // valid
trump.showperks(5); // invalid,不能调基类的方法,因为被隐藏了

新定义将showperks定义为一个不接受任何参数的函数。重新定义不会生成函数的两个重载版本,而是隐藏了接受一个 int 参数的基类版本。总之,重新定义继承的方法并不是重载。如果在派生类中重新定义函数,将不是使用相同的函数特征标覆盖基类声明,而是隐藏同名的基类方法,不管参数特征标如何。

经验规则:

  • 如果重新定义继承的方法,应该确保与原来的原型完全相同

该条规则有一个例外。如果返回类型是基类引用或指针,则可以修改为指向派生类的引用或指针,这种特性被称为返回类型协变

1
2
3
4
5
6
7
8
9
class Dwelling {
public:
virtual Dwelling & build(int a) const;
};

class Hovel : public Dwelling {
public:
virtual Hovel & build(int a) const; // 不会隐藏基类的方法
};
  • 如果基类声明被重载了,应在派生类中重新定义所有的基类版本
1
2
3
4
5
6
7
8
9
10
11
12
13
class Dwelling {
public:
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;
};

class Hovel : public Dwelling {
public:
virtual void showperks(int a) const;
virtual void showperks(double x) const;
virtual void showperks() const;
};

11.4 protected保护访问控制

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

1
2
3
4
class Brass {
protected:
double balance;
};

总结:最好对类的成员数据采用私有访问控制,不要使用保护访问控制。对类的成员方法可以使用保护访问机制。

11.5 抽象基类

抽象基类(abstract base class, ABC)是将一些共性保存的类,例如一些共同的成员数据和方法,有些方法还需要派生类进行改写,这些方法通过纯虚函数提供未实现的方法,纯虚函数声明的结尾处为= 0,纯虚函数没有定义,只有声明。

代码示例

1
2
3
4
class BaseEllipse { // 纯虚函数表明了此类为抽象基类
public :
virtual double Area() const = 0; // 纯虚函数
}

一个类是抽象基类的充分必要条件是该类至少有一个纯虚函数。

不能创建抽象基类的对象,抽象基类只能用作基类,并派生出子类。

11.6 继承和动态内存分配

暂略

12 代码重用

本章将介绍其他方法实现代码重用。

  • 组合:使用这样的类成员——本身是另一个类的对象。这种方法称为包含(containment)、组合(composition)或层次化(layering)。
  • 使用私有或保护继承。通常,包含、私有继承和保护继承用于实现has-a关系,即新的类将包含另一个类的对象。例如,HomeTheater类可能包含一个BluRayPlayer对象。
  • 多重继承使得能够使用两个或更多的基类派生出新的类,将基类的功能组合在一起。
  • 类模板使我们能够使用通用术语定义类,然后使用模板来创建针对特定类型定义的特殊类。例如,可以定义一个通用的栈模板,然后使用该模板创建一个用于表示int值栈的类和一个用于表示double值栈的类,甚至可以创建一个这样的类,即用于表示由栈组成的栈。

12.1 包含对象成员的类

12.2 私有继承

12.3 多重继承

12.4 类模板

12.4.1 定义类模板

原来的类声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef unsigned long Item;
class Stack {
private:
enum {MAX = 10};
Item items[MAX];
int top;
public:
Stack();
bool isempty() const;
bool isfull() const;
bool push(const Item & item);
bool pop(Item & item);
};

采用模板时,将使用模板定义替换Stack声明,使用模板成员函数替换Stack的成员函数。和模板函数一样,模板类以下面这样的代码开头:

1
template <class Type> // Type为类型参数,可以看成变量,但赋给它们的不能是数字,只能是类型

关键字template告诉编译器,将要定义一个模板。尖括号中的内容相当于函数的参数列表。可以把关键字class看作是变量的类型名,该变量接受类型作为其值,把Type看作是该变量的名称,被称为类型参数。

这里使用class并不意味着Type必须是一个类;而只是表明Type是一个通用的类型说明符,在使用模板时,将使用实际的类型替换它。较新的C++实现允许在这种情况下使用不太容易混淆的关键字 typename 代替class:

1
template <typename Type>

可以使用自己的泛型名代替Type,其命名规则与其他标识符相同。当前流行的选项包括TType。当模板被调用时,Type将被具体的类型值(如int或string)取代。

  • 在模板定义中,可以使用泛型名来标识要存储在栈中的类型。对于Stack来说,这意味着应将声明中所有的typedef标识符Item 替换为Type。例如,
1
2
3
Item items[MAX];
// 修改为
Type items[MAX];
  • 可以使用模板成员函数替换原有类的类方法。每个函数头都将以相同的模板声明打头
1
template <typename Type>
  • 需将类限定符从Stack::改为Stack<Type>::
1
2
3
4
5
6
7
8
bool Stack::push(const Item & item) {
//...
}
// 修改为:
template <class T>
bool Stack<T>::push(const T & item) {
//...
}

修改后的代码

模板的具体实现——如用来处理string对象的栈类——被称为实例化(instantiation)或具体化(specialization)。

不能将模板成员函数放在独立的实现文件中。由于模板不是函数,它们不能单独编译。模板必须与特定的模板实例化请求一起使用。为此,最简单的方法是将所有模板信息放在一个头文件中,并在要使用这些模板的文件中包含该头文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// stacktp.h -- a stack template
#ifndef STACKTP_H_
#define STACKTP_H_
template <class Type>
class Stack {
private:
enum {MAX = 10}; // constant specific to class
Type items[MAX]; // holds stack items
int top; // index for top stack item
public:
Stack();
bool isempty();
bool isfull();
bool push(const Type & item); // add item to stack
bool pop(Type & item); // pop top into item
};

template <class Type>
Stack<Type>::Stack() {
top = 0;
}

template <class Type>
bool Stack<Type>::isempty() {
return top == 0;
}

template <class Type>
bool Stack<Type>::isfull() {
return top == MAX;
}

template <class Type>
bool Stack<Type>::push(const Type & item) {
if (top < MAX) {
items[top++] = item;
return true;
}
else
return false;
}

template <class Type>
bool Stack<Type>::pop(Type & item) {
if (top > 0) {
item = items[--top];
return true;
}
else
return false;
}
#endif

12.4.2 使用模板类

仅在程序包含模板并不能生成模板类,而必须请求实例化。为此,需要声明一个类型为模板类的对象,方法是使用所需的具体类型替换泛型名。例如,下面的代码创建两个栈,一个用于存储int,另一个用于存储string对象:

1
2
Stack<int> kernels;
Stack<string> colonels;

测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// stacktem.cpp -- testing the template stack class
#include <iostream>
#include <string>
#include <cctype>
#include "stacktp.h"
using std::cin;
using std::cout;

int main() {
Stack<std::string> st; // create an empty stack
char ch;
std::string po;
cout << "Please enter A to add a purchase order,\n"
<< "P to process a PO, or Q to quit.\n";
while (cin >> ch && std::toupper(ch) != 'Q') {
while (cin.get() != '\n')
continue;
if (!std::isalpha(ch)) {
cout << '\a';
continue;
}
switch(ch) {
case 'A':
case 'a': cout << "Enter a PO number to add: ";
cin >> po;
if (st.isfull())
cout << "stack already full\n";
else
st.push(po);
break;
case 'P':
case 'p': if (st.isempty())
cout << "stack already empty\n";
else {
st.pop(po);
cout << "PO #" << po << " popped\n";
break;
}
}
cout << "Please enter A to add a purchase order,\n"
<< "P to process a PO, or Q to quit.\n";
}
cout << "Bye\n";
return 0;
}

12.4.3 指针类模板

以刚才的模板类为例,如果传入char *为类型参数,则可能发生错误

代码示例1

1
2
3
// 测试程序
Stack<char *> st;
char * po;

这旨在用 char指针而不是string 对象来接收键盘输入。这种方法很快就失败了,因为仅仅创建指针,没有创建用于保存输入字符串的空间(程序将通过编译,但在cin试图将输入保存在某些不合适的内存单元中时崩溃)。

代码示例2

1
2
Stack<char *> st;
char po[40];

这为输入的字符串分配了空间。另外,po的类型为char*,因此可以被放在栈中。但数组完全与pop方法的假设相冲突:

1
2
3
4
5
6
7
8
9
template <class Type>
bool Stack<Type>::pop(Type & item) {
if (top > 0) {
item = items[--top]; // 这里的item为数组名
return true;
}
else
return false;
}

首先,引用变量item必须引用某种类型的左值,而不是数组名。其次,代码假设可以给item赋值。即使item能够引用数组,也不能为数组名赋值。因此这种方法失败了。

代码示例3

1
2
Stack<char *> st;
char * po = new char[40];

这为输入的字符串分配了空间。另外,po是变量(指针),因此与pop的代码兼容。然而,这里将会遇到最基本的问题:只有一个 po 变量,该变量总是指向相同的内存单元。确实,在每当读取新字符串时,内存的内容都将发生改变,但每次执行压入操作时,加入到栈中的的地址都相同。因此,对栈执行弹出操作时,得到的地址总是相同的,它总是指向读入的最后一个字符串。具体地说,栈并没有保存每一个新字符串,因此没有任何用途。


使用指针栈的方法之一是,让调用程序提供一个指针数组,其中每个指针都指向不同的字符串。把这些指针放在栈中是有意义的,因为每个指针都将指向不同的字符串。注意,创建不同指针是调用程序的职责,而不是栈的职责。栈的任务是管理指针,而不是创建指针。

修改后的程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// stcktp1.h -- modified Stack template
#ifndef STCKTP1_H_
#define STCKTP1_H_

template <class Type>
class Stack
{
private:
enum {SIZE = 10}; // default size
int stacksize;
Type * items; // holds stack items
int top; // index for top stack item
public:
explicit Stack(int ss = SIZE);
Stack(const Stack & st);
~Stack() { delete [] items; }
bool isempty() { return top == 0; }
bool isfull() { return top == stacksize; }
bool push(const Type & item); // add item to stack
bool pop(Type & item); // pop top into item
Stack & operator=(const Stack & st);
};

template <class Type>
Stack<Type>::Stack(int ss) : stacksize(ss), top(0) {
items = new Type [stacksize];
}

template <class Type>
Stack<Type>::Stack(const Stack & st) {
stacksize = st.stacksize;
top = st.top;
items = new Type [stacksize];
for (int i = 0; i < top; i++)
items[i] = st.items[i];
}

template <class Type>
bool Stack<Type>::push(const Type & item) {
if (top < stacksize) {
items[top++] = item;
return true;
}
else
return false;
}

template <class Type>
bool Stack<Type>::pop(Type & item) {
if (top > 0) {
item = items[--top];
return true;
}
else
return false;
}

template <class Type>
Stack<Type> & Stack<Type>::operator=(const Stack<Type> & st) {
if (this == &st)
return *this;
delete [] items;
stacksize = st.stacksize;
top = st.top;
items = new Type [stacksize];
for (int i = 0; i < top; i++)
items[i] = st.items[i];
return *this;
}
#endif

测试程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// stkoptr1.cpp -- testing stack of pointers
#include <iostream>
#include <cstdlib> // for rand(), srand()
#include <ctime> // for time()
#include "stcktp1.h"
const int Num = 10;
int main()
{
std::srand(std::time(0)); // randomize rand()
std::cout << "Please enter stack size: ";
int stacksize;
std::cin >> stacksize;
// create an empty stack with stacksize slots
Stack<const char *> st(stacksize);

// in basket
const char * in[Num] = {
" 1: Hank Gilgamesh", " 2: Kiki Ishtar",
" 3: Betty Rocker", " 4: Ian Flagranti",
" 5: Wolfgang Kibble", " 6: Portia Koop",
" 7: Joy Almondo", " 8: Xaverie Paprika",
" 9: Juan Moore", "10: Misha Mache"
};
// out basket
const char * out[Num];

int processed = 0;
int nextin = 0;
while (processed < Num) {
if (st.isempty())
st.push(in[nextin++]);
else if (st.isfull())
st.pop(out[processed++]);
else if (std::rand() % 2 && nextin < Num) // 50-50 chance
st.push(in[nextin++]);
else
st.pop(out[processed++]);
}
for (int i = 0; i < Num; i++)
std::cout << out[i] << std::endl;

std::cout << "Bye\n";
return 0;
}

12.4.4 数组模板和非类型参数

模板常用作容器类,这是因为类型参数的概念非常适合于将相同的存储方案用于不同的类型。为容器类提供可重用代码是引入模板的主要动机。

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
//arraytp.h  -- Array Template
#ifndef ARRAYTP_H_
#define ARRAYTP_H_

#include <iostream>
#include <cstdlib>

template <class T, int n>
class ArrayTP {
private:
T ar[n];
public:
ArrayTP() {};
explicit ArrayTP(const T & v);
virtual T & operator[](int i);
virtual T operator[](int i) const;
};

template <class T, int n>
ArrayTP<T,n>::ArrayTP(const T & v) {
for (int i = 0; i < n; i++)
ar[i] = v;
}

template <class T, int n>
T & ArrayTP<T,n>::operator[](int i) {
if (i < 0 || i >= n) {
std::cerr << "Error in array limits: " << i
<< " is out of range\n";
std::exit(EXIT_FAILURE);
}
return ar[i];
}

template <class T, int n>
T ArrayTP<T,n>::operator[](int i) const {
if (i < 0 || i >= n) {
std::cerr << "Error in array limits: " << i
<< " is out of range\n";
std::exit(EXIT_FAILURE);
}
return ar[i];
}

#endif

模板头为:

1
template <class T, int n>

关键字class指出T为类型参数,int指出n的类型为int。这种参数(指定特殊的类型而不是用作泛型名)称为非类型(non-type)或表达式(expression)参数。假设有下面的声明:

1
ArrayTP<double, 12> eggweights;

这将导致编译器定义名为 ArrayTP<double,12>的类,并创建一个类型为 ArrayTP<double,12>的eggweight对象。定义类时,编译器将使用double替换T,使用12替换n。

非类型参数的限制:

  • 可以使用整型、枚举、引用或指针,值必须是常量表达式
  • 模板代码不能修改参数的值,不能使用参数的地址

缺点:每种数组大小都将生成自己的模板,也就是说,下面的声明将生成两个独立的类声明:

1
2
ArrayTP<double, 12> eggweights;
ArrayTP<double, 13> donuts;

12.4.5 模板多功能性