从 C 语言到 C++

面向过程

标准输入输出

头文件

C 源程序通常用 printf() 函数向标准输出写入数据,用 scanf() 函数从标准输入读取数据,为此要在文件开头用 #include 预处理包含头文件 stdio.h。C++ 源程序同样可以这么做,但 C++ 有自己的一套做法。

C++ 仍然使用 #include 预编译指令来包含头文件,但是包含头文件的语法和 C 语言有很大不同。在包含 C++ 的标准头文件时不再使用扩展名 .h。在 C 语言中就有的头文件不仅省略扩展名,还要加上字符 c 作为前缀,比如 math.h 变成 cmath

1
2
#include <math.h> // 可以
#include <cmath> // 最好

标准输入输出要用到 cincout 两个变量,它们在头文件iostream 中定义。

1
#include <iosteam>

命名空间

命名空间可以解决命名冲突。变量名、函数名和类名等标识符被放置在各个命名空间中。不同命名空间中的变量可以使用同一个名字。

要使用命名空间中的变量,要借助作用域解析运算符 ::。比如要使用命名空间 std 中的变量 cin

1
std::cin;

cincout 这两个变量都定义在命名空间 std 中。其他标准头文件也都是在这个命名空间中定义变量和声明函数的。

如果用 using 预编译指令列出每个变量,就不必在每次使用变量时都加上命名空间前缀。

1
2
3
4
using std::cin;
using std::cout;

cout << "Hello, World!";

在预编译指令 using namespace 之后跟上一个命名空间,表示要使用该命名空间中的任何变量。

1
2
3
using namespace std;

// 接下来可以直接使用 cin 和 cout 等命名空间 `std` 中的任何变量

C++ 源程序通常以下面两个语句开头:

1
2
#include <iosteam>
using namespace std;

可以把第二个语句置于函数体内,这样它的作用就仅限于它所在的函数体:

1
2
3
4
5
int main()
{
using namespace std;
...
}

标准输入输出流

C++ 将程序输入输出的数据看成水流,认为数据就像水一样在流动。程序输出的数据构成输出流;输入程序的数据构成输入流。

标准输出流

std::cout 是标准输出流,要配合插入运算符 << 使用。

1
cout << "Hello World!";

cout 会根据操作数的类型以适当的格式输出该操作数的值,不必担心格式问题。

使用 cout 可以连续输出若干项。

1
cout << "Hello " << "World!"; // 输出 Hello World!

要使光标换行,除了在字符串中使用转义字符 \n 之外,还可以直接使用 endl

1
2
3
cout << endl;
// 相当于
cout << "\n";
标准输入流

std::cin 是标准输入流,要配合提取运算符 >> 使用。

1
2
int age;
cin >> age;

同样,cin 也会根据变量的类型将用户的输入适当转换后再赋给变量。

cin 支持连续给若干变量赋值。cin 总是忽略空白字符,从而把用户的输入分割成若干项,再依次赋给各个变量。

1
2
3
4
5
6
7
int year, month, day;

cin >> year >> month >> day; // 输入 2022 3 15
// 等价于 C 语言中的
scanf("%d %d %d", &year, &month, &day);

cout << year << '-' << month << '-' << day; // 输出 2022-3-15

由于 << 总是忽略空白字符,因此无法用它来获取含空白字符的字符串。为此 cin 提供了 getline() 方法,用来获取一行文本。

1
2
3
char s[10];
cin.getline(s, 10); // 输入 "Hello World!"
cout << s; // 输出 "Hello Wor"

cin 其实是一个对象。要访问对象的成员函数,用圆点运算符。

如需连续忽略若干字符,可用 ignore() 方法。它在忽略的字符达到第一个参数所指定的数量或遇到第二个参数指定的字符时返回。

1
2
3
4
char s[10];
cin.ignore(6, '\n'); // 输入 "Hello World!", 被忽略的是 "Hello "
cin >> s;
cout << s; // 输出 "World!"

原始字符串

当一个字符串中有太多字符需要转义时,就可以采用这种语法。

1
cout << R"(C:\Users\Peter\Downloads)"; // 输出 "C:\Users\Peter\Downloads"

原始字符串以 R 开头,字符序列置于小括号和双引号之中。

宽字符型

wchar_t 称为宽字符型。宽字符型是相对于传统的只占用 1 字节的字符型 char 而言的。

宽字符型的大小和平台相关:在 Windows 上是两个字节;在 Linux 上是 4 字节。

1
cout << sizeof(wchar_t); // 2 or 4

宽字符型的字符或字符串字面量要以 L 开头。

1
2
3
wchar_t wc = L'A';
wchar_t ws[] = L"Hello World!";
wcout << ws;

格式化输出

在输出浮点数时,要保留 2 位小数,只需像下面这样调用 cout 的两个成员函数。

1
2
3
4
5
6
cout.setf(ios::fixed);
cout.setf(ios::showpoint);
cout.precision(2);

double price = 78.5;
cout << price; // 输出 78.50

变量

初始化

C++ 提供了新的初始化变量的方式:将初始值置于一对小括号中,跟在变量名之后,就像函数调用那样。

1
2
3
int age(18), count(3);
// 相当于
int age = 18, count = 3;

C 语言中,将数组元素的初始值置于一对大括号中的初始化方式已不再局限于数组的初始化。

1
2
3
4
5
6
int arr[] = { 1, 2, 3 };
int age = { 18 };

// 习惯上省略中间的等号
int arr[] { 1, 2, 3 };
int age { 18 };

常量

在声明变量的语句前添加 const 修饰符,变量声明就变成常量声明。

1
const double PI = 3.14;

常量可理解为只读变量,必须初始化,但不能被改写。

空指针常量

要表示空指针,既不是用常量 NULL,也不是用数字 0,而是用常量 nullptr

1
int *ptr = nullptr;

常量 NULL 实际上就是数字 0,可以赋给 int 型变量,而常量 nullptr 则不行。

1
2
3
int n;
n = NULL; // 合法
n = nullptr; // 非法

引用

引用是指对已有变量的引用,就像给已有变量起别名。别名可以当作变量名使用。要创建变量的引用,用声明运算符 &

1
2
3
4
int age = 18;
int &ref = age; // 引用 ref 是对变量 age 的引用
ref = 19; // 等价于 age = 19
cout << age; // 输出 19

引用很像指针,唯一区别是无需取值运算符 * 就可以访问它引用的变量。引用和常量一样必须初始化且不能被改写(改成其他变量的引用)。

类型

自动推断类型

有初始值的变量可用 auto 声明,表示由编译器推断其类型。

1
2
auto age = 18;     // 自动推断为整型
auto price = 19.9; // 自动推断为浮点型

string

C++ 和 C 语言一样没有字符串型。在 C 语言中,字符串通常按字符数组处理,而在 C++ 中,通常使用 string 类。

像基本类型 int double 那样使用类名即可创建类的对象。

1
2
3
4
#include <string>
using namespace std;

string s = "Hello World!";

string 类在头文件 string 中声明,命名空间是 std

可以像访问字符数组的元素那样访问 string 对象中的字符。它的 length() 方法返回字符个数。

1
2
3
4
string s = "abcd";
for (int i = 0; i < s.length(); i++)
cout << s[i] << ' ';
// 输出 "a b c d "

at() 方法的作用和方括号相同,但它会检查索引是否合法。

1
2
3
string s = "abcd";
cout << s.at(2); // 输出 'c'
s.at(2) = 'C'; // s 变成 "abCd"

可以用 == 比较两个字符串是否相同。

1
2
3
string s = "abcd";
cout << (s == "abcd"); // 输出 1
cout << (s == "ABC"); // 输出 0

加号 + 可将 string 对象中的字符串和另一个字符或字符串拼接在一起,并返回一个新的 string 对象。

1
2
3
4
5
string firstName = "Alexandar";
string lastName = "Peter";
string fullName = firstName + ' ' + lastName; // 拼接字符

cout << "My name is " + fullName; // 拼接字符串
Method Description
empty() 若包含的是空字符串,则返回 true
substr(pos, len) 返回 len 个字符,从 pos 开始
erase(pos, len) 删除 len 个字符,从 pos 开始
insert(pos, str) pos 处插入字符串 str
find(str) 查找子字符串 str, 找不到则返回 string::npos

强制类型转换

C++ 支持 3 种强制类型转换:

  • C 语言风格
  • 函数风格
  • 类型转换运算符

C 语言风格也就是 C 语言中强制类型转换的方法。

1
2
double f(1.5);
int n = (int)f;

函数风格是 C++ 早期强制类型转换的方法。

1
2
double f(1.5);
int n = double(f);

类型转换运算符共有 4 个,它们的应用场景不同。static_cast 适用于编译阶段的强制类型转换。

1
2
double f(1.5);
int n = static_cast<double>(f);

枚举

枚举类型是需要事先规定变量所有可能取值的类型。在声明新的枚举类型时,要在一对大括号中列出所有可能的取值,这些取值本质上是一组 int 型的全局常量。

1
enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN };

上述语句声明了一个新的枚举类型 Weekday,该类型的变量的取值只能是在大括号中列出的七个常量之一。

枚举常量的值可以指定。默认情况下,枚举常量的值依次递增,第一个枚举常量的默认值是 0。

1
2
3
enum Color { RED = 0, GREEN = 1, BLUE = 2 };
// 等价于
enum Color { RED, GREEN, BLUE };

不同的枚举常量可以使用同一个值。

1
enum Month { JAN = 31, FEB = 28, MAR = 31 }; // 合法
强枚举

枚举常量都是全局常量,故不能出现同名的枚举常量,就算是用在不同的枚举中。

1
2
3
4
enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN };
Weekday today = MON; // MON 是全局常量

enum Workday { MON, TUE, WED, THU, FRI }; // 非法, 常量重复定义

另外,由于枚举常量本质上是整型常量,因而一个枚举常量可以直接和整数比较而无需做显式转换。

1
2
3
4
5
6
7
enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN };

Weekday today = MON;
if (today > 4) // 合法
cout << "Today is a weekend day." << endl;
else
cout << "Today is a workday." << endl;

强枚举则不具备上述两个特性:强枚举常量不会自动转换为整型,要和整数比较,必须显示转换;强枚举常量不是全局常量,要引用它们,必须借助运算符 ::。要声明强枚举,在 enum 后插入关键字 class

1
2
3
4
5
6
7
enum class Weekday { MON, TUE, WED, THU, FRI, SAT, SUN };

Weekday today = Weekday::MON; // 须借助运算符 `::`
if (static_cast<int>(today) > 4) // 须显示转换
cout << "Today is a weekend day." << endl;
else
cout << "Today is a workday." << endl;

类型别名

给现有的类型起一个别名,在 C 语言中用的是 typedef 关键字,在 C++ 中用的是 using 关键字。

1
2
3
4
5
6
7
typedef unsigned int uint;
// 等价于
using uint = unsigned int;

typedef int (*pAdd)(int a, int b);
// 等价于
using pAdd = int (*)(int a, int b);

实际上,typedef 关键字之后就是一个声明语句,只不过变量名被解释为类型的别名。

函数

重载函数名

在 C++ 中,不同的函数可以使用同一个名称。也就是说,允许存在同名函数。但它们的参数必须有所不同。实际调用的函数将根据实参的类型和数量自动匹配。

1
2
3
4
5
6
7
8
9
double ave(double n1, double n2)
{
return (n1 + n2) / 2.0;
}

double ave(double n1, double n2, double n3)
{
return (n1 + n2 + n3) / 3.0;
}

除法运算符的两种用法就是重载的例子,它是由编译器实现的。

传引用调用

引用可以作为函数的形参。在这种情况下,函数可以修改实参的值(实参必须是变量)。

1
2
3
4
5
6
7
8
9
10
11
12
void fn(int &n)
{
n = 123;
}

int main()
{
int age;
fn(age); // 变量 age 被修改
cout << age; // 输出 123
return 0;
}

默认实参

函数的参数可设置默认值。

1
2
3
4
5
6
7
8
9
10
void showNumber(int num = 123) // 参数 num 的默认值为 123
{
cout << num;
}

int main()
{
showNumber(); // 输出 123
return 0;
}

有默认实参的形参应置于参数列表的末尾。

lambda 表达式

lambda 表达式是匿名函数的表达式。lambda 表达式的完整形式包含 5 个部分:

1
[ variables ]( params ) options -> ret { statement; }
  • [ variables] 捕获列表
  • ( params ) 参数列表
  • options 选项列表
  • ret 返回类型
  • { statement; } 函数体

lambda 表达式的参数列表、选项列表和返回类型都可以省略,调用方式与普通函数相同。最简单的 lambda 表达式仅由一对中括号和函数体组成:

1
2
auto fn = [] { return "Hello, World!"; };
std::cout << fn(); // "Hello, World!"

在 lambda 表达式的函数体中可以访问表达式所在作用域中的哪些变量由捕获列表决定:

  • [] 不能访问任何变量
  • [&] 引用所有变量
  • [=] 拷贝所有变量
  • [ foo, &bar ] 拷贝变量 foo,引用 bar 变量

在 lambda 表达式内部不能修改它从外部捕获的变量。如果需要修改捕获的变量,需要添加 mutable 选项。

1
2
3
int data = 1;
auto fn = [data] () mutable -> int { return ++data; };
std::cout << fn(); // 2

数组

数组本质上是只读指针变量。

新的 for 语句

遍历数组一般采用 for 循环。C++ 提供了一种新的 for 语句,可以省去数组下标。

1
2
3
4
int arr[] = { 1, 2, 3, 4, 5};
for (int v: arr) {
cout << v;
}

数组 arr 的元素会依次赋给变量 v

如果要改写数组元素,可将 v 定义成引用。

1
2
3
for (int &v: arr) {
v++;
}

数组作为函数参数

数组可以作为实参,但这不会拷贝数组的元素,只是传递数组的地址。

1
2
3
void fn(int arr[]);
// 等价于
void fn(int *arr);

数组形参本质上是指针变量(不是只读的)。

要禁止函数修改数组的元素,可用 const 关键字修饰形参的类型。

1
2
3
4
5
void fn(const int arr[]) // arr 的元素是只读变量
{
arr[0] = 1; // 非法
arr = nullptr; // 合法
}

要禁止函数改变形参的指向,只能将形参定义为只读指针变量。

1
2
3
4
5
void fn(int *const arr) // arr 是只读指针变量
{
arr[0] = 1; // 合法
arr = nullptr; // 非法
}

指针

指针变量的引用

不仅可以定义普通变量的引用,还可以定义指针变量的引用。

1
2
3
4
int v = 18;
int *p = &v; // p 指向 v
int *&r = p; // r 引用 p
cout << *r; // 19

动态变量

动态变量没有标识符,只能通过指针引用。动态变量用 new 关键字声明,返回值是变量的地址。

1
2
3
4
int *p;
p = new int; // p 指向 int 型动态变量
*p = 42;
cout << *p; // 42

用来分配给动态变量的内存空间称为 自由存储空间。如果没有足够的内存空间,new 语句会抛出异常。如果不捕获这个异常,程序将终止。

不再使用的动态变量要用 delete 关键字销毁,以便回收它占用的内存空间。

1
delete p;

动态变量被销毁后,指针变量的值会变成不确定的值,就像未初始化的变量一样,称为 悬虚指针

像下面这样在函数中声明的变量就是 自动变量,因为它们是随着函数调用自动创建、随着函数返回自动销毁的。

1
int age = 18;

动态数组

new 关键字除了可以定义动态变量,还可以定义动态数组。

1
2
3
int size;
cin >> size;
int *p = new int[size]; // 动态数组的大小在程序运行期间才确定

比起动态变量,销毁动态数组的 delete 语句多了一对方括号。

1
delete[] p;

断言

要验证是否满足某个条件,可用 assert() 宏,如果条件不满足,程序就会终止,并输出断言失败 Assertion failed! 的提示和其他信息。assert() 宏在头文件 cassert 中定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <cassert>

int fact(int n)
{
assert(n > 0);
if (n == 1) return 1;
return n * fact(n - 1);
}

int main()
{
cout << fact(-1); // Assertion failed!
return 0;
}

可以禁用 assert() 宏,只需在包含头文件 cassert 之前定义 NDEBUG 宏。

1
2
#define NDEBUG
#include <cassert>

静态断言

静态断言在编译阶段就起作用。如果条件不满足,编译就会终止,并输出提示。

1
2
static_assert(sizeof(int) > 4, "integer too small");
// error: static assertion failed: integer too small

静态断言的条件只能包含常量。

面向对象

流、文件 IO

流是一个对象,包含一个字节序列。流入程序的字节序列构成输入流;流出程序的字节序列构成输出流。cin 是来自键盘的输入流;cout 是发往屏幕的输出流。

读取文件

ifstreamofstream 分别是输入文件流和输出文件流,它们在头文件 fstream 中声明,命名空间是 std

1
2
#include <fstream>
using namespace std;

要借助输入文件流读取文件的内容,要先创建一个输入文件流对象,再调用它的 open() 方法打开文件。之后,就可以用提取运算符 >> 读取文件的内容了。不再需要读取流对象时,要调用它的 close() 方法关闭文件。

1
2
3
4
5
6
7
ifstream fin;
fin.open("infile.dat"); // 打开文件

int num;
fin >> num;

fin.close(); // 关闭文件

在上面的例子中,若已经到达文件末尾,则表达式 fin >> num 的值为 false。这就是说,该表达式可以作为循环控制条件。

1
2
3
4
int next, sum = 0;
while (fin >> next) {
sum += next;
}

另一种判断文件是否到达末尾的方式是调用 eof() 方法,它在到达文件末尾时返回 true

1
2
3
while (! fin.eof()) {
...
}

文件有可能打开失败,比如文件不存在。想知道上一个操作是否失败,调用 fail() 方法。

1
2
3
4
if (fin.fail()) {
cout << "Opening failed\n";
exit(1);
}

exit() 函数用来终止程序,唯一的参数将成为程序的退出码。

写入文件

输出文件流的用法类似。如果要打开的文件不存在,就会自动创建一个。

1
2
3
4
ofstream fout;
fout.open("outfile.dat");
fout << "Hello World!";
fout.close();

程序写入文件的内容会成为文件的全部内容。带有两个参数的 open() 方法可以改变这种行为。这需要用到定义在命名空间 ios 中的一系列常量,比如 iso::app 表示将新的内容追加到文件末尾。这些常量经过按位与运算可以合并起来。

1
fout.open("outfile.dat", ios::app);

流作为函数参数

流可以作为函数参数,但必须是传引用调用。

1
2
3
4
5
6
7
8
9
10
void sayHi(ostream &out)
{
out << "Hello World!";
}

int main()
{
sayHi(cout);
return 0;
}

字符 IO

任何输入输出流都拥有下表列出的成员函数。

Method Description Example
get() 从输入流读取一个字符 get(c)
put() 把一个字符放入输出流
putback() 把一个字符放回输入流

格式化输出

前面为了在输出带有 2 位小数的浮点数,调用了 cout 的两个成员函数。

1
2
3
cout.setf(ios::fixed);
cout.setf(ios::showpoint);
cout.precision(2);

调用 setf() 方法是为了设置标志。ios::fixed 表示不使用科学计数法;ios::showpoint 表示始终显示为带有小数点的小数。precision() 方法的参数表示要保留几位小数。

Flag Description
ios::showpos 在正数前面添加正号 +
ios::left 左对齐,配合 width 方法使用

setf() 方法设置的标志可用 unsetf() 方法撤销。

width() 方法用来设置内容的最小宽度,调用一次,生效一次。

1
2
3
4
5
6
7
8
9
cout.width(4);
cout << 1; // 输出 " 1"

cout.width(4);
cout.setf(ios::left);
cout << 1; // 输出 "1 "

cout.setf(ios::showpos);
cout << 1; // 输出 "+1"

输出流都拥有上述成员函数。

操纵元

用来换行的 endl 就是操纵元,而不是常量。要使用其它操纵元,要包含头文件 iomanip。操纵元是定义在命名空间 std 中的。

1
2
#include <iomanip>
using namespace std;

要用操纵元,只需把它置于插入运算符 << 之后,就像要输出它一样。操纵元是通过调用某个成员函数来实现它的作用的。比如操纵元 setw() 会调用 width() 方法。

1
2
3
4
cout << setw(4) << 1; // 输出 "   1"
// 等价于
cout.width(4);
cout << 1;

操纵元 setprecision() 会调用 precision() 方法。

1
2
3
4
5
6
7
cout.setf(ios::fixed);
cout.setf(ios::showpoint);

cout << setprecision(2) << 1.0; // 输出 "1.00"
// 等价于
cout.precision(2);
cout << 1.0;

标准 string

string 类有两个构造函数。string 对象不存储字符串的结束标志 \0

1
2
3
4
5
string s1;

string s2("Hello");
// 等价于
string s2 = "Hello";

getline() 函数

头文件 string 声明了一个 getline() 函数(有别于 cingetline() 方法),它可从输入流获取一行文本并保存到 string 对象中。

1
2
3
string line;
getline(cin, line); // 输入 "Hello World!"
cout << line; // 输出 "Hello World!"

标准库重载了 getline() 函数。带有三个参数的 getline() 函数可以指定行结束符(默认是 \n)。

1
2
3
4
string line, s;
getline(cin, line, '!') >> s; // 输入 "Hello World! Good day"
cout << line << endl; // 输出 "Hello World"
cout << s << endl; // 输出 "Good"

getline() 的返回值是第一个参数的引用。故:

1
2
3
4
getline(cin, line, '!') >> s;
// 等价于
getline(cin, line, '!');
cin >> s;

转换成 C 字符串

字符串会自动转换成 string 对象,所以可以把一个字符串赋给 string 对象。string 对象不会自动转换成字符串,所以不能把一个 string 对象赋给字符数组,或者作为字符串函数的参数。要得到 string 对象中的字符串,可以调用它的 c_str() 方法,它返回字符串的指针。

1
2
3
4
string s1 = "HelloWorld!";
char s2[6];
strncpy(s2, s1.c_str(), 5);
cout << s2; // 输出 "Hello"

解析成数字

头文件 string 声明了四个函数,它们不仅可以从字符串解析数字,还可以从 string 对象解析数字。

1
2
3
string s1 = "98";
int num = stoi(s1);
cout << num; // 98

stoi() stol() stof() stod() 分别将字符串转换成 int long float double 型。

to_string() 函数能够返回任意类型的数字的字符串表示。

1
2
3
int num = 123;
string s1 = to_string(num);
cout << s1; // "123"

向量

向量在头文件 vector 中声明,命名空间是 std

1
2
#include <vector>
using namespace std;

向量可看作长度可变的数组。声明 int 型向量:

1
vector<int> v;

向量是 C++ 标准模板库的一部分。向量是一个对象。上例中,vector<int> 是一个类,而 v 是它的一个对象。

元素的初始化和读写方式和数组一样。向量的 size() 方法返回元素个数,即向量长度。

1
2
3
4
vector<int> v = { 11, 12, 13 };
v[2] = 14;
cout << v[2]; // 14
cout << v.size(); // 3

向量的使用存在两条限制:只能顺序添加新元素;添加新元素只能用 push_back() 方法。

1
2
3
vector<int> v = { 11, 12, 13 };
v.push_back(14); // 相当于 v[3] = 14
cout << v[3]; // 14

向量有一个带有一个参数的构造函数,可指定元素个数,初始值为 0。

1
2
3
vector<int> v(3);
cout << v.size(); // 3
cout << v.capacity(); // 3

向量的 capacity() 方法返回向量的容量。不同于向量的长度,向量的容量还算上未初始化的元素,反映了向量实际占用内存。

1
2
3
4
vector<int> v(3);
v.push_back(0);
cout << v.size(); // 4
cout << v.capacity(); // 6

可用 reserve() 方法增大容量。

1
2
3
4
vector<int> v(3);
v.reserve(10);
cout << v.size(); // 3
cout << v.capacity(); // 10

可以 resize() 方法改变长度。如果长度变小,超出的元素将被直接丢弃。

1
2
3
vector<int> v(3);
v.resize(6);
cout << v.size(); // 6

从结构到类

在 C++ 中,结构的成员可以是函数。在成员函数中,要访问成员变量,直接用变量名即可。

1
2
3
4
5
6
7
8
9
10
struct Date
{
void output()
{
cout << year << '-' << month << "-" << day;
};
int year;
int month;
int day;
};

在 C 语言中,定义结构变量也要用到 struct 关键字,在 C++ 中则不用。

1
Date today = { 2022, 3, 20 };

浅拷贝

一个结构变量可以赋给另一个同一类型的结构变量。

1
2
3
4
5
6
7
8
9
10
Date d1 = { 2022, 03, 20 };
Date d2;

d2 = d1;
// 等价于
d2.year = d1.year;
d2.month = d1.month;
d2.day = d1.day;

d2.output(); // 2022-3-20

这种拷贝每一个成员变量的值的行为称为 浅拷贝

类定义

在 C++ 中,结构类型是特殊的类。特殊之处在于,结构的成员都是公开的。也就是说,任何函数都可以访问结构的成员。而类的成员有可能是私有的、受保护的。也就是说,类的某些成员只有某些函数可以访问。

将结构定义中的关键字 struct 替换成 class,结构定义就变成类定义。

1
2
3
4
5
6
7
8
9
10
class Date
{
void output()
{
cout << year << '-' << month << "-" << day;
};
int year;
int month;
int day;
};

对象不能像结构变量那样用一对大括号来初始化成员变量。对象有其他初始化成员变量的方式。

1
Date today = { 2022, 3, 20 }; // 非法

习惯上,类定义只包含函数声明。在定义成员函数时,要在函数名前面添加类名作为 类型限定符,两者用 作用域解析运算符 :: 隔开。

1
2
3
4
5
6
7
8
9
10
11
12
class Date
{
void output(); // 成员函数声明
int year;
int month;
int day;
};

void Date::output() // 成员函数定义
{
cout << year << '-' << month << "-" << day;
}

成员默认都是私有的。在成员列表中插入标号可以改变成员的访问权限。标号 private protected public 分别表示私有成员、受保护成员和公开成员。只有成员函数可以访问私有成员和受保护成员(不区分是不是同一个对象)。

1
2
3
4
5
6
7
8
9
class Date
{
public: // 以下是公开成员
void output();
private: // 以下是私有成员
int year;
int month;
int day;
};

对象也可以浅拷贝,并且私有成员和受保护成员也会被拷贝。

构造函数

函数名是类名且没有返回值的公开方法被视为构造函数。构造函数用来完成对象的初始化工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Date
{
public:
Date(int y, int m, int d); // 带有 3 个参数的构造函数
...
};

Date::Date(int y, int m, int d)
{
year = y;
month = m;
day = d;
}

类的定义要以分号 ; 结尾。

要调用构造函数,只需在变量名后面跟上参数列表。

1
2
Date today(2022, 3, 20);
today.output(); // 2022-3-20

初始化区域

在构造函数的参数列表之后、函数体之前的区域称为初始化区域。初始化区域用来初始化成员变量。上例的构造函数可改写成:

1
Date::Date(int y, int m, int d): year(y), month(m), day(d) {}

显式调用构造函数

可以显式调用构造函数,它将返回一个新的对象。

1
2
Date today = Date(1949, 10, 1);
today.output(); // 1949-10-1

在调用构造函数时,也可以用 new 关键字。

1
2
Date *today = new Date(1949, 10, 1);
today->output(); // 1949-10-1

重载方法名

成员函数的名称也可以重载。重载构造函数名可以提供多种初始化对象的方式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Date
{
public:
Date(); // 不带参数的构造函数
Date(int y, int m, int d); // 带有 3 个参数的构造函数
...
};

Date::Date(): year(1997), month(5), day(20) {}

Date::Date(int y, int m, int d): year(y), month(m), day(d) {}

Date birthday; // 要调用不带参数的构造函数, 就不要在变量名后面跟上参数列表
birthday.output(); // 1997-5-20

Date today(2022, 3, 20);
today.output(); // 2022-3-20

默认构造函数

不带参数的构造函数称为默认构造函数。变量名后面没有跟上参数列表就会调用默认构造函数。

如果没有定义构造函数,编译器就会自动生成一个什么都不做的默认构造函数。如果定义了构造函数,编译器就不再提供默认构造函数。除非存在默认构造函数,否则变量名后面必须跟上参数列表。

声明类数组时会调用默认构造函数,因此要作为数组基类型的类必须拥有默认构造函数。

1
Date date[3];

构造函数委托

构造函数可以调用另一个构造函数。上例的默认构造函数可改写成:

1
Date::Date(): Date(1997, 5, 20) {}

继承

可以通过继承现有的类来定义新的类。被继承的类称为 基类,新的类称为 派生类

1
2
class Foo { ... };
class Bar: public Foo { ... }; // Bar 继承 Foo

如果派生类的构造函数没有显式调用基类的构造函数,编译器会让派生类调用基类的默认构造函数。此时,如果基类没有默认构造函数,编译就无法通过。

1
2
3
Bar::Bar() {}
// 相当于
Bar::Bar(): Foo() {}

析构函数

函数名是带有 ~ 前缀的类名且没有参数和返回值的公开方法被视为析构函数。析构函数用来释放内存,避免内存泄漏。每个类最多只能有一个析构函数。

1
2
3
4
5
6
7
class Date
{
public:
~Date() { // 析构函数
cout << "destroyed";
};
};

const 修饰成员函数

要禁止成员函数修改成员变量,在函数声明的末尾插入 const,在函数定义的初始化区域插入 const

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point
{
public:
int getX() const;
private:
int x = 1;
int y = 0;
};

int Point::getX() const
{
x = 0; // 非法
return x;
}

如果用 const 修饰的成员函数调用了另一个成员函数,则后者也必须用 const 修饰。

1
2
3
4
5
int Point::getX() const
{
fn(); // 成员函数 `fn()` 也要用 `const` 修饰
return x;
}

友元函数

友元函数不是成员函数,但它和成员函数一样能访问私有成员变量。友元函数要像成员函数那样在类的定义中声明,并且开头要加上关键字 friend

1
2
3
4
5
6
7
8
9
10
11
12
13
class Point
{
public:
friend int getX(Point &point); // 友元函数的声明
private:
int x = 1;
int y = 0;
};

int getX(Point &point) // 友元函数的定义
{
return point.x;
}

友元函数的定义不需要包含类型限定符,只能像普通函数那样直接调用。

1
2
Point p;
cout << getX(p); // 1

重载运算符

可以用运算符 == 来比较两个 string 对象中的字符串是否相同。这是重载运算符 == 带来的便利。

运算符本质上是函数,它们的操作数就是函数的参数。重载运算符实际上就是重载函数名。重载运算符可以让运算符支持更多类型的操作数。重载运算符时,函数名是带有关键字 operator 作为前缀的运算符,比如 operator==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Point
{
public:
Point(int x1, int y1): x(x1), y(y1) {};
friend bool operator==(Point &a, Point &b);
private:
int x = 1;
int y = 0;
};

bool operator==(Point &A, Point &B)
{
return A.x == B.x && A.y == B.y;
}

重载运算符 == 后,就可以用它来比较两个 Point 对象是否相同。

1
2
3
Point A(1, 3), B(1, 3), C(2, 4);
cout << (A == B); // 1
cout << (A == C); // 0

重载运算符不能改变参数的数量。也就是说,不能把一元运算符变成二元运算符。

用于自动类型转换的构造函数

涉及对象的自动类型转换,要依靠只有一个参数的构造函数。

1
2
3
4
5
6
7
8
9
10
11
12
class Account
{
public:
friend bool operator>(Account &a, Account &b);
private:
int total = 0;
};

bool operator>(Account &a, Account &b)
{
return a.total > b.total;
}

上面的例子重载了运算符 >,使它可以用来比较两个 Account 对象,但不能用来比较一个 Account 对象和一个整数。

1
2
3
Account a, b;
a > b; // 合法
a > 0; // 非法

要支持第二种比较,只需添加一个单参数构造函数。这相当于告诉编译器如何将一个整数转换成 Account 对象,也就实现了整数到 Account 对象的自动类型转换。

1
2
3
4
5
6
class Account
{
public:
Account(int money = 0): total(money) {};
...
};

这种自动类型转换不仅适用于运算符,也适用于所有需要做类型转换的地方,比如函数的参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Account
{
public:
Account(int money = 0): total(money) {};
int total = 0;
};

void output(Account &account)
{
cout << account.total;
}

output(99); // 合法, 99 自动转换成 Account 对象

重载插入运算符

前文已经多次使用插入运算符来显示信息。插入运算符可以构成表达式链。这是因为插入运算符和 + == 等运算符一样也有一个返回值,那就是它的第一个操作数。

1
cout << "Hello " << "World!";

在上面的例子中,第一个插入运算符会返回 cout,因此对于第二个插入运算符来说,它的左操作数是 cout

重载插入或提取运算符的函数都必须返回第一个参数的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Account
{
public:
friend ostream& operator<<(ostream &out, Account &account);
friend istream& operator>>(istream &in, Account &account);
private:
int total = 0;
};

ostream& operator<<(ostream &out, Account &account)
{
out << '$' << account.total;
return out;
}

istream& operator>>(istream &in, Account &account)
{
in >> account.total;
return in;
}

Account account;
cin >> account; // 输入 99
cout << account; // 输出 $99

拷贝构造函数

类的拷贝构造函数是只有一个参数的构造函数,这个参数必须是一个对象的引用,对象的类型就是这个类,通常用 const 关键字修饰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class String
{
public:
String(const char s[]);
String(const String &s);
~String();
private:
char *p;
};

String::String(const char s[]) // 用于自动类型转换的构造函数
{
cout << "cast" << endl;
p = new char[strlen(s) + 1];
strcpy(p, s);
}

String::String(const String &s) // 拷贝构造函数
{
...
}

String::~String()
{
delete[] p;
}

拷贝构造函数的任务是构造实参的一个完整的、独立的拷贝。在上面的例子中,不能简单地将 s.p 赋给 p,那样会造成两个对象的 p 指向同一个字符串。

1
2
3
4
5
6
String::String(const String &s) // 拷贝构造函数
{
cout << "copied" << endl;
p = new char[strlen(s.p) + 1];
strcpy(p, s.p);
}

创建一个完整的、独立的拷贝称为 深拷贝

有了拷贝构造函数,就可以在声明语句中用一个对象来初始化另一个对象。

下面语句先调用构造函数 String(const char s[]) 将字符串 "Peter" 转换成 String 匿名对象,再调用拷贝构造函数,用匿名对象完成 firstName 的初始化。

1
String firstName = "Peter"; // 输出 "copied"

实际上,此处由于 g++ 的优化并不会调用拷贝构造函数。添加 -fno-elide-constructors 选项可以禁用这种优化。

注意区分声明语句和赋值语句。赋值语句永远不会调用拷贝构造函数。

1
2
3
4
String s1 = s2; // 声明语句

String s1; // 声明语句
s1 = s2; // 赋值语句

声明语句也并非一定会调用拷贝构造函数。只有变量名和初始值之间有等号时,才会调用拷贝构造函数。

1
2
3
4
5
String a("Peter");      // 只发生自动类型转换
String c { "Peter" }; // 只发生自动类型转换

String b = "Peter"; // 发生自动类型转换并调用拷贝构造函数
String d = { "Peter" }; // 发生自动类型转换并调用拷贝构造函数

拷贝构造函数的用途

拷贝构造函数是包含指针成员的类必不可少的构造函数,它和用于自动类型转换的构造函数一样,是由编译器根据需要调用的。有三个情形会调用拷贝构造函数:

  1. 情形一:用一个对象来初始化另一个对象
  2. 情形二:对象作为函数参数
  3. 情形三:对象作为函数返回值
1
2
3
4
5
6
7
8
9
10
11
12
void foo(String s)
{
...
}

String bar()
{
return "bar";
}

foo("foo"); // 输出 "copied"
bar(); // 输出 "copied"

重载赋值运算符

虽然赋值运算符也是二元运算符,但它只需要右操作数作为参数。赋值运算符必须是类的成员。

1
2
3
4
5
6
7
8
9
10
11
class String
{
public:
...
void operator=(const String &right);
};

void String::operator=(const String &right)
{
...
}

赋值运算符的任务和拷贝构造函数一样,要构造实参的一个完整的、独立的拷贝。

1
2
3
4
5
6
7
void String::operator=(const String &right)
{
cout << "assigned" << endl;
delete[] p;
p = new char[strlen(right.p) + 1];
strcpy(p, right.p);
}

下面第二个语句先将 "Peter" 转换成 String 对象,再将这个对象赋给 firstName

1
2
String firstName = "";
firstName = "Peter"; // 输出 "assigned"

实际上,上述定义存在一个 Bug。如果赋值运算符的左、右操作数是同一个,那么 p 就是 right.p,在调用 strcpy() 之前,right.p 已被销毁。要解决这个 Bug,只需增加一个 if 语句,判断左、右操作数是不是同一个,如果是,就立即返回,什么都不做。

1
2
3
4
5
6
7
void String::operator=(const String &right)
{
if (this == &right) return; // this 是预定义指针变量, 保存了对象的地址
delete[] p;
p = new char[strlen(right.p) + 1];
strcpy(p, right.p);
}

重载方括号

方括号运算符和赋值运算符一样,必须是类的成员,它应该返回元素的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class String
{
public:
...
char& operator[](int index);
};

char& String::operator[](int i)
{
cout << "accessed" << endl;
return p[i];
}

String firstName = "Peter";
firstName[0] = 'p'; // 输出 "accessed"
cout << firstName[0]; // 输出 "accessed" 和 'p'

命名空间

创建命名空间

将代码置于 命名空间分组 中,就可以将代码中声明的变量名、函数名、类名等标识符放到指定命名空间中。

1
2
3
4
5
6
7
namespace ns1 {
int a, b;
}

namespace ns1 {
char c;
}

上面的例子包含两个命名空间分组,它们都把标识符放到命名空间 ns1 中。

不属于任何命名空间分组的代码中声明的标识符自动放到 全局命名空间 中。整个程序无需限定符就可以直接用全局命名空间中的标识符。

限定名称

要使用命名空间中 ns1 中的标识符,用 using 指令。

1
using namespace ns1;

有了这条指令,就可以直接使用命名空间中 ns1 中的任何标识符。

不用这条指令也能使用命名空间中的标识符,但每次都要用到作用域解析运算符 ::。比如要使用命名空间中 ns1 中的变量 a

1
ns1::a = 0;

如果用 using 指令提前说明,就可以省去前缀 ns1::

1
2
3
using ns1::a;

a = 0;

无名命名空间

每个文件都是一个编译单元,每个编译单元都有一个无名命名空间。要把名称放到无名命名空间也要使用命名空分组,但是命名空间的具体名称要省略。

1
2
3
4
namespace
{
...
}

在编译单元内部可直接使用无名命名空间中的名称。

无名命名空间通常用来放置模块内部使用的辅助函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
namespace
{
void fn(); // 函数声明
}

namespace ns1
{
class cls1 // 类定义
{
...
};
}

namespace
{
void fn() { // 函数定义
...
}
}

命名空间别名

可以用 namespace 指令给命名空间起别名。

1
namespace fs = std::filesystem;

继承

派生类会继承基类的成员变量和成员函数,包括私有成员。

1
2
3
4
5
6
7
8
9
10
11
12
class Foo
{
public:
int f1() { return v1; };
private:
int v1 = 123;
};

class Bar: public Foo { ... };

Bar bar;
cout << bar.f1(); // 123

基类的构造函数

派生类不会继承基类的构造函数,但可以显示调用基类的构造函数。派生类默认会调用基类的默认构造函数。

1
2
3
4
5
6
7
8
class Foo
{
public:
Foo() { cout << "Foo() called"; };
...
};

Bar bar; // "Foo() called"

私有成员的继承

派生类会继承私有成员,但是派生类新增的成员函数不能直接访问私有成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Foo
{
...
private:
void f2() { cout << "f2() called"; };
int v1 = 123;
};

class Bar: public Foo
{
public:
void b1()
{
v1 = 0; // 非法
f2(); // 非法
};
};

受保护成员的继承

派生类会继承受保护成员,派生类新增的成员函数可以直接访问受保护成员。对其他函数来说,受保护成员相当于私有成员。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Foo
{
...
protected:
void f3() { cout << "f3 called"; };
int v2 = 456;
};

class Bar: public Foo
{
public:
void b2()
{
v2 = 0; // 合法
f3(); // 合法
};
};

三种成员对各种函数的可见性如下表所示:

Base member Base method Drived method Other function
私有成员 Y N N
受保护成员 Y Y N
公开成员 Y Y Y

重定义成员函数

派生类不仅可以原封不动地继承基类的成员函数,还可以对它们进行重定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Foo
{
public:
int f1() { return v1; };
private:
int v1 = 123;
protected:
int v2 = 456;
};

class Bar: public Foo
{
public:
int f1() { return v2; }; // 重定义
};

Bar bar;
cout << bar.f1(); // 456

参数列表要和基类保持一致才是重定义,否则就变成重载。

重定义了成员函数的派生类仍然可以调用基类的版本,只是要借助作用域解析运算符。

1
2
3
Bar bar;
cout << bar.Foo::f1(); // 123
cout << bar.f1(); // 456

不继承的函数

派生类不会继承基类的拷贝构造函数、赋值运算符和析构函数。

派生类在定义自己的拷贝构造函数和赋值运算符时,通常要先调用基类的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Bar: public Foo
{
public:
Bar(const Bar &bar);
void operator=(const Bar &right);
};

Bar::Bar(const Bar &bar): Foo(bar) // 调用基类的拷贝构造函数, 此处存在自动类型转换
{
...
};

void Bar::operator=(const Bar &right)
{
Foo::operator=(right); // 调用基类的赋值运算符
...
};

派生类的析构函数会自动调用基类的析构函数。

1
2
3
4
5
6
7
8
9
class Foo
{
public:
~Foo() { cout << "~Foo() called"; };
};

class Bar: public Foo {};

Bar bar; // "~Foo() called"

多态性

虚函数

虚函数是在基类中声明和定义、在派生类中重定义的成员函数,在某种意义上是可以先使用再定义的成员函数。虚函数的声明要用 virtual 关键字修饰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class A
{
public:
void fn();
virtual void f1(); // 虚函数的声明
};

class B: public A
{
public:
virtual void f1(); // 虚函数的声明
};

void A::fn() { f1(); } // 基类的另一个成员函数调用了虚函数
void A::f1() { cout << "A::f1"; } // 虚函数的定义
void B::f1() { cout << "B::f1"; } // 重定义虚函数

B b;
b.fn(); // "B::f1"

如果基类的成员函数调用了虚函数,而且派生类重定义了这个虚函数,那么基类的成员函数就会调用重定义的版本。在派生类中重定义虚函数就称为 重写

重写和重定义

如果基类的成员函数调用的不是虚函数,那么基类的成员函数就永远不会调用重定义的版本。

派生类 B 重定义了基类 A 的两个成员函数 f1()f2(),基类的另一个成员函数 fn() 调用了这个两个成员函数。由于 f1() 是虚函数,因此 fn() 调用的是 B::f1(),而 f2() 不是虚函数,所以 fn() 调用的是 A::f2()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class A
{
public:
void fn();
virtual void f1(); // 声明
void f2(); // 声明
};

class B: public A
{
public:
virtual void f1(); // 声明
void f2(); // 声明
};

void A::fn() {
f1(); // "B::f1"
f2(); // "A::f2"
}
void A::f1() { cout << "A::f1"; } // 定义
void A::f2() { cout << "A::f2"; } // 定义

void B::f1() { cout << "B::f1"; } // 重写
void B::f2() { cout << "B::f2"; } // 重定义

派生类转换成基类

派生类的对象可以自动转换成基类的对象,但反过来不行。

1
2
3
4
5
6
7
8
class A {};
class B: public A {};

void f1(A a) {}
void f2(B b) {}

f1( B() ); // 合法
f2( A() ); // 非法

虽然派生类的对象可以转换成基类的对象,但是转换过程中会丢失派生类新增的成员,包括在派生类中重写的虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class A
{
public:
virtual int fn();
int a1 = 123;
};

class B: public A
{
public:
virtual int fn();
int b1 = 456;
};

int A::fn() { return a1; } // 定义
int B::fn() { return b1; } // 重写

A a = B(); // 会丢失数据
a.b1; // 非法
cout << a.fn(); // 123, 没有调用重写的版本

将派生类的对象赋给基类的变量会丢失数据,将派生类的指针转换成基类的指针则不会。也就是说,派生类的成员仍然存在,重写的虚函数仍然可用。然而,基类的指针仍然不能用来访问派生类才有的成员,不过可以用基类的指针调用虚函数,而在虚函数中可以访问派生类的成员。

1
2
3
A *pA = new B;    // 不会丢失数据
pA->b1; // 非法
cout << pA->fn(); // 456, 会调用重写的版本

虚析构函数

在用 delete 关键字释放派生类对象占用的内存时,如果用的是基类的指针,而且析构函数不是虚函数,派生类的析构函数就不会被调用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A
{
public:
~A();
};

class B: public A
{
public:
~B();
};

A::~A() { cout << "A::~A"; }
B::~B() { cout << "B::~B"; }

A *pA = new B;
delete pA; // "A::~A", 没有调用 B 类的析构函数

因此,只有析构函数是虚函数时,派生类的析构函数才会被调用。派生类的析构函数会进一步调用基类的析构函数,从而回收所有内存。

1
2
3
4
5
6
7
8
9
class A
{
public:
virtual ~A();
};

...

delete pA; // "B::~BA::~A", 调用了 B 类的析构函数

异常

抛出异常和捕获异常

try 语句块中可用 throw 语句抛出异常,抛出的异常可被 catch 语句块捕获。

1
2
3
4
5
6
7
8
9
int a, b;
try {
cin >> a >> b;
if (b <= 0)
throw b;
cout << a / b;
} catch (int e) {
cout << "b = " << e;
}

catch 语句块通常称为异常处理程序。throw 语句抛出的异常的类型必须和 catch 语句块要捕获的异常相同才会被捕获。如果没有合适的 catch 语句块,程序就会终止。

异常类

抛出的异常通常是异常类的对象。不同的异常类用来标识不同的异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DivideByZero
{
public:
DivideByZero(string msg);
string getMessage();
private:
string message;
};

DivideByZero::DivideByZero(string msg): message(msg) {}
string DivideByZero::getMessage() { return message; }

try {
...
if (b <= 0)
throw DivideByZero("divide by zero");
...
} catch (DivideByZero e) {
cout << e.getMessage();
}

小括号中的参数可以省略,只保留类型名。

多个 catch 语句块

一个 try 语句块后面可以跟上多个 catch 语句块,以便处理多种异常。

1
2
3
4
5
6
7
8
9
10
11
try {
...
} catch (out_of_range e) {
...
} catch (length_error e) {
...
} catch (bad_alloc e) {
...
} catch (...) { // 小括号中的 `...` 并不是说要省略什么
...
}

小括号中只有 ...catch 语句块是默认的 catch 语句块,它可以捕获任意类型的异常,应该作为最后一个 catch 语句块。

throw 列表

在一个函数中可以只抛出异常而不捕获异常。这种情况下,主调函数要负责捕获异常。

1
2
3
4
5
6
7
8
9
10
11
12
13
int divide(int a, int b)
{
if (b <= 0)
throw DivideByZero("divide by zero");
return a / b;
}

try {
...
cout << divide(a, b);
} catch (DivideByZero e) {
cout << e.getMessage();
}

函数应该在参数列表之后的 throw 列表中列出可能抛出的异常类。

1
2
3
4
5
6
int divide(int a, int b) throw (DivideByZero)
{
if (b <= 0)
throw DivideByZero("divide by zero");
return a / b;
}

有多个异常,则用逗号隔开,如 throw (out_of_range, length_error, bad_alloc)

泛型编程

模板

模板是函数定义或类定义的模板。这类定义可以用模板的参数作为类型名。

函数模板

fn() 函数用于交换两个 int 型变量的值。实际上,只要两个变量的类型相同,都可以用这个函数的算法来交换它们的值。变量的类型不应该被固定为 int

1
2
3
4
5
6
void fn(int &a, int &b)
{
int t = a;
a = b;
b = t;
}

借助模板就可以根据需要确定变量的类型。

模板以关键字 template 开头,后跟一对尖括号,即参数列表。类型参数用关键字 class 修饰。

1
2
3
4
5
6
7
8
9
10
11
template<class T>
void fn(T &a, T &b)
{
T t = a;
a = b;
b = t;
}

int a = 1, b = 2;
fn(a, b);
cout << a << b; // 21

调用模板定义的函数和调用其他函数没什么区别。很多编译器不支持模板函数声明。

类模板

类模板的语法和函数模板没什么区别。类模板的类型参数的用法和函数模板一致。

1
2
3
4
5
6
7
8
9
10
11
12
template<class T>
class Stack
{
public:
Stack();
~Stack();
void push(T elem);
T pop();
private:
T top;
vector<T> *pStack;
};

在声明该类的对象时,类名之后要跟上类型实参的列表。

1
2
3
4
5
6
7
8
Stack<int> s;

s.push(1);
s.push(2);
s.push(3);
cout << s.pop(); // 3
cout << s.pop(); // 2
cout << s.pop(); // 1

成员函数的定义同样要用函数模板,此时函数名之前的类型限定符之后也要跟上类型实参的列表,列表中的类型实参可以是模板的类型参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
template<class T>
Stack<T>::Stack(): top(0), pStack(new vector<T>) {}

template<class T>
Stack<T>::~Stack() { delete pStack; }

template<class T>
void Stack<T>::push(T elem)
{
if (top < pStack->size())
(*pStack)[top] = elem;
else
pStack->push_back(elem);
top++;
}

template<class T>
T Stack<T>::pop() { return (*pStack)[--top]; }

标准模板库

标准模板库(Standard Template Library,STL)包含栈、队列等数据结构的实现。STL 中的类都是模板类,通常称为 容器类,因其用来容纳数据。Vector 类就是一种容器类。

迭代器

迭代器是在遍历数据结构时用来定位每个数据项的变量。数组下标和指针是最简单的迭代器。

1
2
3
4
int arr[3];
for (int i = 0; i < 3; i++) {
...
}

复杂的迭代器是一个对象,但它用起来就像一个指针。每种容器类都有相应的迭代器类。容器类的 begin() 方法返回定位到第一个元素的迭代器,end() 方法返回一个标识,表示最后一个元素之后的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include <iostream>
#include <vector>
using std::cout;
using std::vector;

vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);

vector<int>::iterator p;
for (p = v.begin(); p != v.end(); p++) {
cout << *p; // 123
}

可以用 auto 声明迭代器。

1
2
3
for (auto p = v.begin(); p != v.end(); p++) {
cout << *p;
}

可以把迭代器当作指针使用。

1
2
3
auto p = v.begin();
cout << p[2]; // 3
cout << *p; // 1

注意,p[2]*(p+2) 返回新的迭代器而不修改原来的迭代器。

并非所有迭代器都支持 ++ -- [] 三种运算。迭代器根据所支持的操作可分为三种:

  • 正向迭代器,支持 ++
  • 双向迭代器,支持 ++ --
  • 随机访问迭代器,支持 ++ -- []

这三种迭代器都可以进一步分为常量迭代器和可变迭代器。它们的区别在于允不允许修改数据项。

1
2
vector<int>::const_iterator p = v.begin();
p[2] = 0; // 非法, p 是常量迭代器
逆向遍历

支持双向迭代器的容器类的 rbegin() 方法返回定位到最后一个元素的迭代器,rend() 方法返回一个标识,表示第一个元素之前的位置。

1
2
3
4
vector<int>::reverse_iterator rp;
for (rp = v.rbegin(); rp != v.rend(); rp++) {
cout << *rp; // 321
}

容器

顺序容器

STL 中的顺序容器有向量 vector、双向链表 list 和双端队列 deque 三种。

1
2
3
4
5
6
7
8
list<int> l;
l.push_back(1);
l.push_back(2);
l.push_back(3);

for (auto p = l.begin(); p != l.end(); p++) {
cout << *p; // 321
}

顺序容器的常用成员函数如下表所示:

Method Description
push_back(elem) 在表尾插入新元素 elem
push_front(elem) 在表头插入新元素 elem
size() 返回元素个数
begin() 返回定位到第一个元素的迭代器
rbegin() 返回定位到最后一个元素的迭代器
end() 返回最后一个元素之后的位置的标识
rend() 返回第一个元素之前的位置的标识
insert(iterator, elem) 在迭代器 iterator 所定位的元素之前插入新元素 iterator
erase(iterator) 删除迭代器 iterator 所定位的元素并返回定位到下一个元素的迭代器
clear() 清空
front() 返回第一个元素的引用,相当于 *(c.begin())

只有列表能保证迭代器不会因为删除或插入新元素而发生错位。

所有模板类都定义了两个类型:std::list<int>::size_typesize() 返回的类型,std::list<int>::value_type 是元素的类型。

1
2
list<int>::value_type value = l.front();
list<int>::size_type size = l.size();
栈和队列

容器类栈 stack 和队列 queue 的默认基础容器是 deque。要改变基础容器,需提供两个类型参数。

1
2
3
4
5
6
7
8
stack<int, vector<int>> s;
s.push(1);
s.push(2);
s.push(3);

cout << s.top(); // 3
s.pop();
cout << s.top(); // 2

不管基础容器是什么,它们的用法是不变的。

栈的成员函数见下表:

Method Description
size() 返回元素个数
empty() 判断栈是否为空
top() 返回栈顶元素的引用
push(elem) 入栈
pop() 出栈

队列的成员函数见下表:

Method Description
size() 返回元素个数
empty() 判断队列是否为空
back() 返回队尾元素的引用
push(elem) 入队
front() 返回队头元素的引用
pop() 出队

它们的 pop() 方法都没有返回值。

集合

集合 set 要求每个元素都是独一无二的。插入元素时会忽略已经存在的元素。

1
2
3
4
5
6
7
8
9
10
11
12
set<char> s;
s.insert('a');
s.insert('b');
s.insert('b'); // 忽略
s.insert('c');

auto p = s.find('b');
cout << *p; // 'b'

for (auto p = s.begin(); p != s.end(); p++) {
cout << *p; // abc
}

集合的常用成员函数见下表:

Method Description
size() 返回元素个数
empty() 判断集合是否为空
insert(elem) 插入新元素 elem
erase(elem) 删除值为 elem 的元素
erase(iterator) 删除迭代器 iterator 所定位的元素
find(elem) 查找值为 elem 的元素,返回定位到该元素的迭代器
映射

映射 map 用来存储键值对。键名必须都是独一无二的。声明映射,要提供两个类型参数,一个指定键名的类型,一个指定键值的类型。

1
2
3
4
5
6
#include <string>
#include <map>
using std::string;
using std::map;

map<string, string> passwd;

映射使用 pair 对象来存储每一个键值对。pair 对象有两个公开成员变量,first 对应键名,second 对应键值。pair 类的头文件是 utility

映射用来插入新键值对的 insert() 方法要求键值对是一个 pair 对象。

1
2
3
4
5
#include <utility>
using std::pair;

pair<string, string> user("admin", "123456");
auto r = passwd.insert(user);

insert() 方法返回一个新的表示插入结果的 pair 对象。若插入成功,则该 pair 对象的 secondtrue,而 first 是定位到新键值对的迭代器。

1
2
3
auto p = r.first; // 迭代器
auto u = *p; // 键值对
cout << u.first << u.second; // admin123456

可以把映射当作数组使用。不过 [] 返回值是键值。

1
2
3
4
pair<string, string> user("admin", "123456");
passwd.insert(user);
// 等价于
passwd["admin"] = "123456";

映射的常用成员函数见下表:

Method Description
size() 返回键值对个数
empty() 判断映射是否为空
insert(pair) 插入新键值对
erase() 根据键名删除键值对
find() 根据键名查找键值对,返回迭代器
容器的初始化和遍历

可用 初始值列表 来初始化容器。初始值列表是将初始值置于大括号中。

1
2
3
4
5
6
7
set<string> colors = { "red", "green", "blue" };

map<string, string> passwd = {
{ "admin", "123456" },
{ "Peter", "2333" },
{ "John", "666" }
};

遍历容器常用 autofor 语句。

1
2
3
4
5
6
7
for (auto &p: colors) {
cout << p; // blue green red
}

for (auto &p: passwd) {
cout << p.first; // John Peter admin
}

专题

安全的数组

array 提供的模板类 std::array 实现了安全的数组。它有两个类型参数,一个指定元素类型,一个指定数组长度。size() 方法返回数组长度。元素的初值都为 0。

1
2
3
4
5
std::array<int, 5> arr = { 1, 2, 3 };
cout << arr.size(); // 3
for (int &elem: arr) {
cout << elem; // 12300
}

正则表达式

正则表达式是用来描述模式字符串的一种方式。库 regex 为正则表达式提供支持。类 regex 的构造函数需要一个正则表达式作为参数。要用正则表达式,需借助原始字符串字面值。

1
2
string pattern = R"(bbccc)";
regex regExp(pattern);

regex_match() 函数用模式匹配目标字符串,只有目标字符串和模式匹配时才会返回 trueregex_search() 函数用模式匹配子串,只要目标字符串有一个子串和模式匹配就会返回 true

1
2
3
4
string line;
getline(cin, line); // 输入 "abbccc"
cout << regex_match(line, regExp); // 输出 0
cout << regex_search(line, regExp); // 输出 1

regex_search() 函数的返回值是布尔型,不能反映子串的位置。要知道每个子串的位置,要用正则表达式迭代器。类 sregex_iterator 的构造函数的参数包括对字符串首尾的引用和正则表达式,它的默认构造函数返回一个表示结束的迭代器。

1
2
3
4
5
6
7
8
9
10
11
12
13
regex regExp(R"(\(?\d{3}\)?(-|\s)\d{3}(-|\s)\d{4})");
string text = "Call me at me desk phone (907) 867-5309 \
or my cell phone 907-350-3491.";

sregex_iterator p(text.begin(), text.end(), regExp);
sregex_iterator end;

while (p != end) {
cout << p->str() << endl;
p++;
}
// (907) 867-5309
// 907-350-3491

多线程

多线程编程要包含头文件 threadthread 类的对象代表一个线程,它的构造函数的参数包括线程要调用的函数以及要传递给该函数的参数。要等待线程结束,调用它们的 join() 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <thread>
using std::thread;
using std::this_thread::get_id;

void fn(int a)
{
cout << get_id() << ": " << a << endl;
}

int main()
{
thread t1(fn, 123);
thread t2(fn, 456);
t1.join();
t2.join();
return 0;
}

在线程调用的函数中,可用 get_id() 方法获取线程的 ID。

最终,两个线程的输出有可能相互打断。这是因为上下文切换有可能发生在线程的输出过程中。更致命的是,如果两个线程修改同一个全局变量,变量的值最终是不确定的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int n = 0;

void fn()
{
for (int i = 0; i < 30000; i++) n++;
}

int main()
{
thread t1(fn);
thread t2(fn);
t1.join();
t2.join();
cout << n; // n 的值不确定, 每次运行都会有不同的结果
return 0;
}

要避免两个线程相互干扰,要使用互斥锁。互斥锁用来锁定一段代码,防止两个线程同时调用这段代码。库 mutex 提供的类 mutex 实现了互斥锁。

1
2
3
4
5
6
7
8
9
10
11
#include <mutex>
using std::mutex;

mutex me;

void fn()
{
me.lock();
...
me.unlock();
}

智能指针

智能指针是对象的包装器,用来包装占用了自由存储空间的对象。它自动维护对象的引用计数,并在对象的引用计数归零时自动释放对象占用的内存空间。

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

class Node
{
public:
~Node()
{
cout << "destroyed";
}
};

void fn()
{
shared_ptr<Node> p(new Node);
}

int main()
{
fn(); // "destroyed"
cout << "called";
return 0;
}

文件系统

filesystem 是 C++17 新增的标准库。头文件是 filesystem,命名空间是 std::filesystem

1
2
3
#include <filesystem>

namespace fs = std::filesystem;

它由一系列函数和类组成。例如,类 path 用于表示路径,函数 exists() 用于检查路径表示的文件是否存在。

1
2
3
4
fs::path mypath = "./include/main.h";
if (!fs::exists(mypath)) {
std::cerr << "No such file or directory\n";
}

path 类的 filename() 方法返回文件名;extension() 方法返回扩展名;parent_path() 方法返回父级目录名;make_preferred() 方法用于统一目录分隔符,在 Windows 上统一为 \;在类 Unix 上统一为 /

1
2
3
4
std::wcout << "filename: " << mypath.filename().c_str();       // main.h
std::wcout << "extension: " << mypath.extension().c_str(); // .h
std::wcout << "parent_path: " << mypath.parent_path().c_str(); // ./include
std::wcout << mypath.make_preferred().c_str(); // .\include\main.h