从 C 语言到 C++
面向过程
标准输入输出
头文件
C 源程序通常用 printf()
函数向标准输出写入数据,用 scanf()
函数从标准输入读取数据,为此要在文件开头用 #include
预处理包含头文件 stdio.h
。C++ 源程序同样可以这么做,但 C++ 有自己的一套做法。
C++ 仍然使用 #include
预编译指令来包含头文件,但是包含头文件的语法和 C 语言有很大不同。在包含 C++ 的标准头文件时不再使用扩展名 .h
。在 C 语言中就有的头文件不仅省略扩展名,还要加上字符 c
作为前缀,比如 math.h
变成 cmath
。
1 |
标准输入输出要用到 cin
和 cout
两个变量,它们在头文件iostream
中定义。
1 |
命名空间
命名空间可以解决命名冲突。变量名、函数名和类名等标识符被放置在各个命名空间中。不同命名空间中的变量可以使用同一个名字。
要使用命名空间中的变量,要借助作用域解析运算符 ::
。比如要使用命名空间 std
中的变量 cin
:
1 | std::cin; |
cin
和 cout
这两个变量都定义在命名空间 std
中。其他标准头文件也都是在这个命名空间中定义变量和声明函数的。
如果用 using
预编译指令列出每个变量,就不必在每次使用变量时都加上命名空间前缀。
1 | using std::cin; |
在预编译指令 using namespace
之后跟上一个命名空间,表示要使用该命名空间中的任何变量。
1 | using namespace std; |
C++ 源程序通常以下面两个语句开头:
1 |
|
可以把第二个语句置于函数体内,这样它的作用就仅限于它所在的函数体:
1 | int main() |
标准输入输出流
C++ 将程序输入输出的数据看成水流,认为数据就像水一样在流动。程序输出的数据构成输出流;输入程序的数据构成输入流。
标准输出流
std::cout
是标准输出流,要配合插入运算符 <<
使用。
1 | cout << "Hello World!"; |
cout
会根据操作数的类型以适当的格式输出该操作数的值,不必担心格式问题。
使用 cout
可以连续输出若干项。
1 | cout << "Hello " << "World!"; // 输出 Hello World! |
要使光标换行,除了在字符串中使用转义字符 \n
之外,还可以直接使用 endl
。
1 | cout << endl; |
标准输入流
std::cin
是标准输入流,要配合提取运算符 >>
使用。
1 | int age; |
同样,cin
也会根据变量的类型将用户的输入适当转换后再赋给变量。
cin
支持连续给若干变量赋值。cin
总是忽略空白字符,从而把用户的输入分割成若干项,再依次赋给各个变量。
1 | int year, month, day; |
由于 <<
总是忽略空白字符,因此无法用它来获取含空白字符的字符串。为此 cin
提供了 getline()
方法,用来获取一行文本。
1 | char s[10]; |
cin
其实是一个对象。要访问对象的成员函数,用圆点运算符。
如需连续忽略若干字符,可用 ignore()
方法。它在忽略的字符达到第一个参数所指定的数量或遇到第二个参数指定的字符时返回。
1 | char s[10]; |
原始字符串
当一个字符串中有太多字符需要转义时,就可以采用这种语法。
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 | wchar_t wc = L'A'; |
格式化输出
在输出浮点数时,要保留 2 位小数,只需像下面这样调用 cout
的两个成员函数。
1 | cout.setf(ios::fixed); |
变量
初始化
C++ 提供了新的初始化变量的方式:将初始值置于一对小括号中,跟在变量名之后,就像函数调用那样。
1 | int age(18), count(3); |
C 语言中,将数组元素的初始值置于一对大括号中的初始化方式已不再局限于数组的初始化。
1 | int arr[] = { 1, 2, 3 }; |
常量
在声明变量的语句前添加 const
修饰符,变量声明就变成常量声明。
1 | const double PI = 3.14; |
常量可理解为只读变量,必须初始化,但不能被改写。
空指针常量
要表示空指针,既不是用常量 NULL
,也不是用数字 0,而是用常量 nullptr
。
1 | int *ptr = nullptr; |
常量 NULL
实际上就是数字 0
,可以赋给 int
型变量,而常量 nullptr
则不行。
1 | int n; |
引用
引用是指对已有变量的引用,就像给已有变量起别名。别名可以当作变量名使用。要创建变量的引用,用声明运算符 &
。
1 | int age = 18; |
引用很像指针,唯一区别是无需取值运算符 *
就可以访问它引用的变量。引用和常量一样必须初始化且不能被改写(改成其他变量的引用)。
类型
自动推断类型
有初始值的变量可用 auto
声明,表示由编译器推断其类型。
1 | auto age = 18; // 自动推断为整型 |
string
类
C++ 和 C 语言一样没有字符串型。在 C 语言中,字符串通常按字符数组处理,而在 C++ 中,通常使用 string
类。
像基本类型
int
double
那样使用类名即可创建类的对象。
1 |
|
string
类在头文件 string
中声明,命名空间是 std
。
可以像访问字符数组的元素那样访问 string
对象中的字符。它的 length()
方法返回字符个数。
1 | string s = "abcd"; |
at()
方法的作用和方括号相同,但它会检查索引是否合法。
1 | string s = "abcd"; |
可以用 ==
比较两个字符串是否相同。
1 | string s = "abcd"; |
加号 +
可将 string
对象中的字符串和另一个字符或字符串拼接在一起,并返回一个新的 string
对象。
1 | string firstName = "Alexandar"; |
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 | double f(1.5); |
函数风格是 C++ 早期强制类型转换的方法。
1 | double f(1.5); |
类型转换运算符共有 4 个,它们的应用场景不同。static_cast
适用于编译阶段的强制类型转换。
1 | double f(1.5); |
枚举
枚举类型是需要事先规定变量所有可能取值的类型。在声明新的枚举类型时,要在一对大括号中列出所有可能的取值,这些取值本质上是一组 int
型的全局常量。
1 | enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN }; |
上述语句声明了一个新的枚举类型 Weekday
,该类型的变量的取值只能是在大括号中列出的七个常量之一。
枚举常量的值可以指定。默认情况下,枚举常量的值依次递增,第一个枚举常量的默认值是 0。
1 | enum Color { RED = 0, GREEN = 1, BLUE = 2 }; |
不同的枚举常量可以使用同一个值。
1 | enum Month { JAN = 31, FEB = 28, MAR = 31 }; // 合法 |
强枚举
枚举常量都是全局常量,故不能出现同名的枚举常量,就算是用在不同的枚举中。
1 | enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN }; |
另外,由于枚举常量本质上是整型常量,因而一个枚举常量可以直接和整数比较而无需做显式转换。
1 | enum Weekday { MON, TUE, WED, THU, FRI, SAT, SUN }; |
强枚举则不具备上述两个特性:强枚举常量不会自动转换为整型,要和整数比较,必须显示转换;强枚举常量不是全局常量,要引用它们,必须借助运算符 ::
。要声明强枚举,在 enum
后插入关键字 class
。
1 | enum class Weekday { MON, TUE, WED, THU, FRI, SAT, SUN }; |
类型别名
给现有的类型起一个别名,在 C 语言中用的是 typedef
关键字,在 C++ 中用的是 using
关键字。
1 | typedef unsigned int uint; |
实际上,typedef
关键字之后就是一个声明语句,只不过变量名被解释为类型的别名。
函数
重载函数名
在 C++ 中,不同的函数可以使用同一个名称。也就是说,允许存在同名函数。但它们的参数必须有所不同。实际调用的函数将根据实参的类型和数量自动匹配。
1 | double ave(double n1, double n2) |
除法运算符的两种用法就是重载的例子,它是由编译器实现的。
传引用调用
引用可以作为函数的形参。在这种情况下,函数可以修改实参的值(实参必须是变量)。
1 | void fn(int &n) |
默认实参
函数的参数可设置默认值。
1 | void showNumber(int num = 123) // 参数 num 的默认值为 123 |
有默认实参的形参应置于参数列表的末尾。
lambda 表达式
lambda 表达式是匿名函数的表达式。lambda 表达式的完整形式包含 5 个部分:
1 | [ variables ]( params ) options -> ret { statement; } |
[ variables]
捕获列表( params )
参数列表options
选项列表ret
返回类型{ statement; }
函数体
lambda 表达式的参数列表、选项列表和返回类型都可以省略,调用方式与普通函数相同。最简单的 lambda 表达式仅由一对中括号和函数体组成:
1 | auto fn = [] { return "Hello, World!"; }; |
在 lambda 表达式的函数体中可以访问表达式所在作用域中的哪些变量由捕获列表决定:
[]
不能访问任何变量[&]
引用所有变量[=]
拷贝所有变量[ foo, &bar ]
拷贝变量foo
,引用bar
变量
在 lambda 表达式内部不能修改它从外部捕获的变量。如果需要修改捕获的变量,需要添加 mutable
选项。
1 | int data = 1; |
数组
数组本质上是只读指针变量。
新的 for
语句
遍历数组一般采用 for 循环。C++ 提供了一种新的 for 语句,可以省去数组下标。
1 | int arr[] = { 1, 2, 3, 4, 5}; |
数组 arr
的元素会依次赋给变量 v
。
如果要改写数组元素,可将 v
定义成引用。
1 | for (int &v: arr) { |
数组作为函数参数
数组可以作为实参,但这不会拷贝数组的元素,只是传递数组的地址。
1 | void fn(int arr[]); |
数组形参本质上是指针变量(不是只读的)。
要禁止函数修改数组的元素,可用 const
关键字修饰形参的类型。
1 | void fn(const int arr[]) // arr 的元素是只读变量 |
要禁止函数改变形参的指向,只能将形参定义为只读指针变量。
1 | void fn(int *const arr) // arr 是只读指针变量 |
指针
指针变量的引用
不仅可以定义普通变量的引用,还可以定义指针变量的引用。
1 | int v = 18; |
动态变量
动态变量没有标识符,只能通过指针引用。动态变量用 new
关键字声明,返回值是变量的地址。
1 | int *p; |
用来分配给动态变量的内存空间称为 自由存储空间。如果没有足够的内存空间,new
语句会抛出异常。如果不捕获这个异常,程序将终止。
不再使用的动态变量要用 delete
关键字销毁,以便回收它占用的内存空间。
1 | delete p; |
动态变量被销毁后,指针变量的值会变成不确定的值,就像未初始化的变量一样,称为 悬虚指针。
像下面这样在函数中声明的变量就是 自动变量,因为它们是随着函数调用自动创建、随着函数返回自动销毁的。
1 | int age = 18; |
动态数组
new
关键字除了可以定义动态变量,还可以定义动态数组。
1 | int size; |
比起动态变量,销毁动态数组的 delete
语句多了一对方括号。
1 | delete[] p; |
断言
要验证是否满足某个条件,可用 assert()
宏,如果条件不满足,程序就会终止,并输出断言失败 Assertion failed!
的提示和其他信息。assert()
宏在头文件 cassert
中定义。
1 |
|
可以禁用 assert()
宏,只需在包含头文件 cassert
之前定义 NDEBUG
宏。
1 |
静态断言
静态断言在编译阶段就起作用。如果条件不满足,编译就会终止,并输出提示。
1 | static_assert(sizeof(int) > 4, "integer too small"); |
静态断言的条件只能包含常量。
面向对象
流、文件 IO
流是一个对象,包含一个字节序列。流入程序的字节序列构成输入流;流出程序的字节序列构成输出流。cin
是来自键盘的输入流;cout
是发往屏幕的输出流。
读取文件
类 ifstream
和 ofstream
分别是输入文件流和输出文件流,它们在头文件 fstream
中声明,命名空间是 std
。
1 |
|
要借助输入文件流读取文件的内容,要先创建一个输入文件流对象,再调用它的 open()
方法打开文件。之后,就可以用提取运算符 >>
读取文件的内容了。不再需要读取流对象时,要调用它的 close()
方法关闭文件。
1 | ifstream fin; |
在上面的例子中,若已经到达文件末尾,则表达式 fin >> num
的值为 false
。这就是说,该表达式可以作为循环控制条件。
1 | int next, sum = 0; |
另一种判断文件是否到达末尾的方式是调用 eof()
方法,它在到达文件末尾时返回 true
。
1 | while (! fin.eof()) { |
文件有可能打开失败,比如文件不存在。想知道上一个操作是否失败,调用 fail()
方法。
1 | if (fin.fail()) { |
exit()
函数用来终止程序,唯一的参数将成为程序的退出码。
写入文件
输出文件流的用法类似。如果要打开的文件不存在,就会自动创建一个。
1 | ofstream fout; |
程序写入文件的内容会成为文件的全部内容。带有两个参数的 open()
方法可以改变这种行为。这需要用到定义在命名空间 ios
中的一系列常量,比如 iso::app
表示将新的内容追加到文件末尾。这些常量经过按位与运算可以合并起来。
1 | fout.open("outfile.dat", ios::app); |
流作为函数参数
流可以作为函数参数,但必须是传引用调用。
1 | void sayHi(ostream &out) |
字符 IO
任何输入输出流都拥有下表列出的成员函数。
Method | Description | Example |
---|---|---|
get() |
从输入流读取一个字符 | get(c) |
put() |
把一个字符放入输出流 | |
putback() |
把一个字符放回输入流 |
格式化输出
前面为了在输出带有 2 位小数的浮点数,调用了 cout
的两个成员函数。
1 | cout.setf(ios::fixed); |
调用 setf()
方法是为了设置标志。ios::fixed
表示不使用科学计数法;ios::showpoint
表示始终显示为带有小数点的小数。precision()
方法的参数表示要保留几位小数。
Flag | Description |
---|---|
ios::showpos |
在正数前面添加正号 + |
ios::left |
左对齐,配合 width 方法使用 |
setf()
方法设置的标志可用 unsetf()
方法撤销。
width()
方法用来设置内容的最小宽度,调用一次,生效一次。
1 | cout.width(4); |
输出流都拥有上述成员函数。
操纵元
用来换行的 endl
就是操纵元,而不是常量。要使用其它操纵元,要包含头文件 iomanip
。操纵元是定义在命名空间 std
中的。
1 |
|
要用操纵元,只需把它置于插入运算符 <<
之后,就像要输出它一样。操纵元是通过调用某个成员函数来实现它的作用的。比如操纵元 setw()
会调用 width()
方法。
1 | cout << setw(4) << 1; // 输出 " 1" |
操纵元 setprecision()
会调用 precision()
方法。
1 | cout.setf(ios::fixed); |
标准 string
类
string
类有两个构造函数。string
对象不存储字符串的结束标志 \0
。
1 | string s1; |
getline()
函数
头文件 string
声明了一个 getline()
函数(有别于 cin
的 getline()
方法),它可从输入流获取一行文本并保存到 string
对象中。
1 | string line; |
标准库重载了 getline()
函数。带有三个参数的 getline()
函数可以指定行结束符(默认是 \n
)。
1 | string line, s; |
getline()
的返回值是第一个参数的引用。故:
1 | getline(cin, line, '!') >> s; |
转换成 C 字符串
字符串会自动转换成 string
对象,所以可以把一个字符串赋给 string
对象。string
对象不会自动转换成字符串,所以不能把一个 string
对象赋给字符数组,或者作为字符串函数的参数。要得到 string
对象中的字符串,可以调用它的 c_str()
方法,它返回字符串的指针。
1 | string s1 = "HelloWorld!"; |
解析成数字
头文件 string
声明了四个函数,它们不仅可以从字符串解析数字,还可以从 string
对象解析数字。
1 | string s1 = "98"; |
stoi()
stol()
stof()
stod()
分别将字符串转换成 int
long
float
double
型。
to_string()
函数能够返回任意类型的数字的字符串表示。
1 | int num = 123; |
向量
向量在头文件 vector
中声明,命名空间是 std
。
1 |
|
向量可看作长度可变的数组。声明 int
型向量:
1 | vector<int> v; |
向量是 C++ 标准模板库的一部分。向量是一个对象。上例中,vector<int>
是一个类,而 v
是它的一个对象。
元素的初始化和读写方式和数组一样。向量的 size()
方法返回元素个数,即向量长度。
1 | vector<int> v = { 11, 12, 13 }; |
向量的使用存在两条限制:只能顺序添加新元素;添加新元素只能用 push_back()
方法。
1 | vector<int> v = { 11, 12, 13 }; |
向量有一个带有一个参数的构造函数,可指定元素个数,初始值为 0。
1 | vector<int> v(3); |
向量的 capacity()
方法返回向量的容量。不同于向量的长度,向量的容量还算上未初始化的元素,反映了向量实际占用内存。
1 | vector<int> v(3); |
可用 reserve()
方法增大容量。
1 | vector<int> v(3); |
可以 resize()
方法改变长度。如果长度变小,超出的元素将被直接丢弃。
1 | vector<int> v(3); |
从结构到类
在 C++ 中,结构的成员可以是函数。在成员函数中,要访问成员变量,直接用变量名即可。
1 | struct Date |
在 C 语言中,定义结构变量也要用到 struct
关键字,在 C++ 中则不用。
1 | Date today = { 2022, 3, 20 }; |
浅拷贝
一个结构变量可以赋给另一个同一类型的结构变量。
1 | Date d1 = { 2022, 03, 20 }; |
这种拷贝每一个成员变量的值的行为称为 浅拷贝。
类定义
在 C++ 中,结构类型是特殊的类。特殊之处在于,结构的成员都是公开的。也就是说,任何函数都可以访问结构的成员。而类的成员有可能是私有的、受保护的。也就是说,类的某些成员只有某些函数可以访问。
将结构定义中的关键字 struct
替换成 class
,结构定义就变成类定义。
1 | class Date |
对象不能像结构变量那样用一对大括号来初始化成员变量。对象有其他初始化成员变量的方式。
1 | Date today = { 2022, 3, 20 }; // 非法 |
习惯上,类定义只包含函数声明。在定义成员函数时,要在函数名前面添加类名作为 类型限定符,两者用 作用域解析运算符 ::
隔开。
1 | class Date |
成员默认都是私有的。在成员列表中插入标号可以改变成员的访问权限。标号 private
protected
public
分别表示私有成员、受保护成员和公开成员。只有成员函数可以访问私有成员和受保护成员(不区分是不是同一个对象)。
1 | class Date |
对象也可以浅拷贝,并且私有成员和受保护成员也会被拷贝。
构造函数
函数名是类名且没有返回值的公开方法被视为构造函数。构造函数用来完成对象的初始化工作。
1 | class Date |
类的定义要以分号
;
结尾。
要调用构造函数,只需在变量名后面跟上参数列表。
1 | Date today(2022, 3, 20); |
初始化区域
在构造函数的参数列表之后、函数体之前的区域称为初始化区域。初始化区域用来初始化成员变量。上例的构造函数可改写成:
1 | Date::Date(int y, int m, int d): year(y), month(m), day(d) {} |
显式调用构造函数
可以显式调用构造函数,它将返回一个新的对象。
1 | Date today = Date(1949, 10, 1); |
在调用构造函数时,也可以用 new
关键字。
1 | Date *today = new Date(1949, 10, 1); |
重载方法名
成员函数的名称也可以重载。重载构造函数名可以提供多种初始化对象的方式。
1 | class Date |
默认构造函数
不带参数的构造函数称为默认构造函数。变量名后面没有跟上参数列表就会调用默认构造函数。
如果没有定义构造函数,编译器就会自动生成一个什么都不做的默认构造函数。如果定义了构造函数,编译器就不再提供默认构造函数。除非存在默认构造函数,否则变量名后面必须跟上参数列表。
声明类数组时会调用默认构造函数,因此要作为数组基类型的类必须拥有默认构造函数。
1 | Date date[3]; |
构造函数委托
构造函数可以调用另一个构造函数。上例的默认构造函数可改写成:
1 | Date::Date(): Date(1997, 5, 20) {} |
继承
可以通过继承现有的类来定义新的类。被继承的类称为 基类,新的类称为 派生类。
1 | class Foo { ... }; |
如果派生类的构造函数没有显式调用基类的构造函数,编译器会让派生类调用基类的默认构造函数。此时,如果基类没有默认构造函数,编译就无法通过。
1 | Bar::Bar() {} |
析构函数
函数名是带有 ~
前缀的类名且没有参数和返回值的公开方法被视为析构函数。析构函数用来释放内存,避免内存泄漏。每个类最多只能有一个析构函数。
1 | class Date |
用 const
修饰成员函数
要禁止成员函数修改成员变量,在函数声明的末尾插入 const
,在函数定义的初始化区域插入 const
。
1 | class Point |
如果用 const
修饰的成员函数调用了另一个成员函数,则后者也必须用 const
修饰。
1 | int Point::getX() const |
友元函数
友元函数不是成员函数,但它和成员函数一样能访问私有成员变量。友元函数要像成员函数那样在类的定义中声明,并且开头要加上关键字 friend
。
1 | class Point |
友元函数的定义不需要包含类型限定符,只能像普通函数那样直接调用。
1 | Point p; |
重载运算符
可以用运算符 ==
来比较两个 string
对象中的字符串是否相同。这是重载运算符 ==
带来的便利。
运算符本质上是函数,它们的操作数就是函数的参数。重载运算符实际上就是重载函数名。重载运算符可以让运算符支持更多类型的操作数。重载运算符时,函数名是带有关键字 operator
作为前缀的运算符,比如 operator==
。
1 | class Point |
重载运算符 ==
后,就可以用它来比较两个 Point
对象是否相同。
1 | Point A(1, 3), B(1, 3), C(2, 4); |
重载运算符不能改变参数的数量。也就是说,不能把一元运算符变成二元运算符。
用于自动类型转换的构造函数
涉及对象的自动类型转换,要依靠只有一个参数的构造函数。
1 | class Account |
上面的例子重载了运算符 >
,使它可以用来比较两个 Account
对象,但不能用来比较一个 Account
对象和一个整数。
1 | Account a, b; |
要支持第二种比较,只需添加一个单参数构造函数。这相当于告诉编译器如何将一个整数转换成 Account
对象,也就实现了整数到 Account
对象的自动类型转换。
1 | class Account |
这种自动类型转换不仅适用于运算符,也适用于所有需要做类型转换的地方,比如函数的参数。
1 | class Account |
重载插入运算符
前文已经多次使用插入运算符来显示信息。插入运算符可以构成表达式链。这是因为插入运算符和 +
==
等运算符一样也有一个返回值,那就是它的第一个操作数。
1 | cout << "Hello " << "World!"; |
在上面的例子中,第一个插入运算符会返回 cout
,因此对于第二个插入运算符来说,它的左操作数是 cout
。
重载插入或提取运算符的函数都必须返回第一个参数的引用。
1 | class Account |
拷贝构造函数
类的拷贝构造函数是只有一个参数的构造函数,这个参数必须是一个对象的引用,对象的类型就是这个类,通常用 const
关键字修饰。
1 | class String |
拷贝构造函数的任务是构造实参的一个完整的、独立的拷贝。在上面的例子中,不能简单地将 s.p
赋给 p
,那样会造成两个对象的 p
指向同一个字符串。
1 | String::String(const String &s) // 拷贝构造函数 |
创建一个完整的、独立的拷贝称为 深拷贝。
有了拷贝构造函数,就可以在声明语句中用一个对象来初始化另一个对象。
下面语句先调用构造函数 String(const char s[])
将字符串 "Peter"
转换成 String
匿名对象,再调用拷贝构造函数,用匿名对象完成 firstName
的初始化。
1 | String firstName = "Peter"; // 输出 "copied" |
实际上,此处由于 g++ 的优化并不会调用拷贝构造函数。添加
-fno-elide-constructors
选项可以禁用这种优化。
注意区分声明语句和赋值语句。赋值语句永远不会调用拷贝构造函数。
1 | String s1 = s2; // 声明语句 |
声明语句也并非一定会调用拷贝构造函数。只有变量名和初始值之间有等号时,才会调用拷贝构造函数。
1 | String a("Peter"); // 只发生自动类型转换 |
拷贝构造函数的用途
拷贝构造函数是包含指针成员的类必不可少的构造函数,它和用于自动类型转换的构造函数一样,是由编译器根据需要调用的。有三个情形会调用拷贝构造函数:
- 情形一:用一个对象来初始化另一个对象
- 情形二:对象作为函数参数
- 情形三:对象作为函数返回值
1 | void foo(String s) |
重载赋值运算符
虽然赋值运算符也是二元运算符,但它只需要右操作数作为参数。赋值运算符必须是类的成员。
1 | class String |
赋值运算符的任务和拷贝构造函数一样,要构造实参的一个完整的、独立的拷贝。
1 | void String::operator=(const String &right) |
下面第二个语句先将 "Peter"
转换成 String
对象,再将这个对象赋给 firstName
。
1 | String firstName = ""; |
实际上,上述定义存在一个 Bug。如果赋值运算符的左、右操作数是同一个,那么 p
就是 right.p
,在调用 strcpy()
之前,right.p
已被销毁。要解决这个 Bug,只需增加一个 if
语句,判断左、右操作数是不是同一个,如果是,就立即返回,什么都不做。
1 | void String::operator=(const String &right) |
重载方括号
方括号运算符和赋值运算符一样,必须是类的成员,它应该返回元素的引用。
1 | class String |
命名空间
创建命名空间
将代码置于 命名空间分组 中,就可以将代码中声明的变量名、函数名、类名等标识符放到指定命名空间中。
1 | namespace ns1 { |
上面的例子包含两个命名空间分组,它们都把标识符放到命名空间 ns1
中。
不属于任何命名空间分组的代码中声明的标识符自动放到 全局命名空间 中。整个程序无需限定符就可以直接用全局命名空间中的标识符。
限定名称
要使用命名空间中 ns1
中的标识符,用 using
指令。
1 | using namespace ns1; |
有了这条指令,就可以直接使用命名空间中 ns1
中的任何标识符。
不用这条指令也能使用命名空间中的标识符,但每次都要用到作用域解析运算符 ::
。比如要使用命名空间中 ns1
中的变量 a
。
1 | ns1::a = 0; |
如果用 using
指令提前说明,就可以省去前缀 ns1::
。
1 | using ns1::a; |
无名命名空间
每个文件都是一个编译单元,每个编译单元都有一个无名命名空间。要把名称放到无名命名空间也要使用命名空分组,但是命名空间的具体名称要省略。
1 | namespace |
在编译单元内部可直接使用无名命名空间中的名称。
无名命名空间通常用来放置模块内部使用的辅助函数。
1 | namespace |
命名空间别名
可以用 namespace
指令给命名空间起别名。
1 | namespace fs = std::filesystem; |
继承
派生类会继承基类的成员变量和成员函数,包括私有成员。
1 | class Foo |
基类的构造函数
派生类不会继承基类的构造函数,但可以显示调用基类的构造函数。派生类默认会调用基类的默认构造函数。
1 | class Foo |
私有成员的继承
派生类会继承私有成员,但是派生类新增的成员函数不能直接访问私有成员。
1 | class Foo |
受保护成员的继承
派生类会继承受保护成员,派生类新增的成员函数可以直接访问受保护成员。对其他函数来说,受保护成员相当于私有成员。
1 | class Foo |
三种成员对各种函数的可见性如下表所示:
Base member | Base method | Drived method | Other function |
---|---|---|---|
私有成员 | Y | N | N |
受保护成员 | Y | Y | N |
公开成员 | Y | Y | Y |
重定义成员函数
派生类不仅可以原封不动地继承基类的成员函数,还可以对它们进行重定义。
1 | class Foo |
参数列表要和基类保持一致才是重定义,否则就变成重载。
重定义了成员函数的派生类仍然可以调用基类的版本,只是要借助作用域解析运算符。
1 | Bar bar; |
不继承的函数
派生类不会继承基类的拷贝构造函数、赋值运算符和析构函数。
派生类在定义自己的拷贝构造函数和赋值运算符时,通常要先调用基类的。
1 | class Bar: public Foo |
派生类的析构函数会自动调用基类的析构函数。
1 | class Foo |
多态性
虚函数
虚函数是在基类中声明和定义、在派生类中重定义的成员函数,在某种意义上是可以先使用再定义的成员函数。虚函数的声明要用 virtual
关键字修饰。
1 | class A |
如果基类的成员函数调用了虚函数,而且派生类重定义了这个虚函数,那么基类的成员函数就会调用重定义的版本。在派生类中重定义虚函数就称为 重写。
重写和重定义
如果基类的成员函数调用的不是虚函数,那么基类的成员函数就永远不会调用重定义的版本。
派生类 B
重定义了基类 A
的两个成员函数 f1()
和 f2()
,基类的另一个成员函数 fn()
调用了这个两个成员函数。由于 f1()
是虚函数,因此 fn()
调用的是 B::f1()
,而 f2()
不是虚函数,所以 fn()
调用的是 A::f2()
。
1 | class A |
派生类转换成基类
派生类的对象可以自动转换成基类的对象,但反过来不行。
1 | class A {}; |
虽然派生类的对象可以转换成基类的对象,但是转换过程中会丢失派生类新增的成员,包括在派生类中重写的虚函数。
1 | class A |
将派生类的对象赋给基类的变量会丢失数据,将派生类的指针转换成基类的指针则不会。也就是说,派生类的成员仍然存在,重写的虚函数仍然可用。然而,基类的指针仍然不能用来访问派生类才有的成员,不过可以用基类的指针调用虚函数,而在虚函数中可以访问派生类的成员。
1 | A *pA = new B; // 不会丢失数据 |
虚析构函数
在用 delete
关键字释放派生类对象占用的内存时,如果用的是基类的指针,而且析构函数不是虚函数,派生类的析构函数就不会被调用。
1 | class A |
因此,只有析构函数是虚函数时,派生类的析构函数才会被调用。派生类的析构函数会进一步调用基类的析构函数,从而回收所有内存。
1 | class A |
异常
抛出异常和捕获异常
在 try
语句块中可用 throw
语句抛出异常,抛出的异常可被 catch
语句块捕获。
1 | int a, b; |
catch
语句块通常称为异常处理程序。throw
语句抛出的异常的类型必须和 catch
语句块要捕获的异常相同才会被捕获。如果没有合适的 catch
语句块,程序就会终止。
异常类
抛出的异常通常是异常类的对象。不同的异常类用来标识不同的异常。
1 | class DivideByZero |
小括号中的参数可以省略,只保留类型名。
多个 catch
语句块
一个 try
语句块后面可以跟上多个 catch
语句块,以便处理多种异常。
1 | try { |
小括号中只有 ...
的 catch
语句块是默认的 catch
语句块,它可以捕获任意类型的异常,应该作为最后一个 catch
语句块。
throw
列表
在一个函数中可以只抛出异常而不捕获异常。这种情况下,主调函数要负责捕获异常。
1 | int divide(int a, int b) |
函数应该在参数列表之后的 throw
列表中列出可能抛出的异常类。
1 | int divide(int a, int b) throw (DivideByZero) |
有多个异常,则用逗号隔开,如 throw (out_of_range, length_error, bad_alloc)
。
泛型编程
模板
模板是函数定义或类定义的模板。这类定义可以用模板的参数作为类型名。
函数模板
fn()
函数用于交换两个 int
型变量的值。实际上,只要两个变量的类型相同,都可以用这个函数的算法来交换它们的值。变量的类型不应该被固定为 int
。
1 | void fn(int &a, int &b) |
借助模板就可以根据需要确定变量的类型。
模板以关键字 template
开头,后跟一对尖括号,即参数列表。类型参数用关键字 class
修饰。
1 | template<class T> |
调用模板定义的函数和调用其他函数没什么区别。很多编译器不支持模板函数声明。
类模板
类模板的语法和函数模板没什么区别。类模板的类型参数的用法和函数模板一致。
1 | template<class T> |
在声明该类的对象时,类名之后要跟上类型实参的列表。
1 | Stack<int> s; |
成员函数的定义同样要用函数模板,此时函数名之前的类型限定符之后也要跟上类型实参的列表,列表中的类型实参可以是模板的类型参数。
1 | template<class T> |
标准模板库
标准模板库(Standard Template Library,STL)包含栈、队列等数据结构的实现。STL 中的类都是模板类,通常称为 容器类,因其用来容纳数据。Vector
类就是一种容器类。
迭代器
迭代器是在遍历数据结构时用来定位每个数据项的变量。数组下标和指针是最简单的迭代器。
1 | int arr[3]; |
复杂的迭代器是一个对象,但它用起来就像一个指针。每种容器类都有相应的迭代器类。容器类的 begin()
方法返回定位到第一个元素的迭代器,end()
方法返回一个标识,表示最后一个元素之后的位置。
1 |
|
可以用 auto
声明迭代器。
1 | for (auto p = v.begin(); p != v.end(); p++) { |
可以把迭代器当作指针使用。
1 | auto p = v.begin(); |
注意,p[2]
或 *(p+2)
返回新的迭代器而不修改原来的迭代器。
并非所有迭代器都支持 ++
--
[]
三种运算。迭代器根据所支持的操作可分为三种:
- 正向迭代器,支持
++
- 双向迭代器,支持
++
--
- 随机访问迭代器,支持
++
--
[]
这三种迭代器都可以进一步分为常量迭代器和可变迭代器。它们的区别在于允不允许修改数据项。
1 | vector<int>::const_iterator p = v.begin(); |
逆向遍历
支持双向迭代器的容器类的 rbegin()
方法返回定位到最后一个元素的迭代器,rend()
方法返回一个标识,表示第一个元素之前的位置。
1 | vector<int>::reverse_iterator rp; |
容器
顺序容器
STL 中的顺序容器有向量 vector
、双向链表 list
和双端队列 deque
三种。
1 | list<int> l; |
顺序容器的常用成员函数如下表所示:
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_type
是size()
返回的类型,std::list<int>::value_type
是元素的类型。
1 | list<int>::value_type value = l.front(); |
栈和队列
容器类栈 stack
和队列 queue
的默认基础容器是 deque
。要改变基础容器,需提供两个类型参数。
1 | stack<int, vector<int>> s; |
不管基础容器是什么,它们的用法是不变的。
栈的成员函数见下表:
Method | Description |
---|---|
size() |
返回元素个数 |
empty() |
判断栈是否为空 |
top() |
返回栈顶元素的引用 |
push(elem) |
入栈 |
pop() |
出栈 |
队列的成员函数见下表:
Method | Description |
---|---|
size() |
返回元素个数 |
empty() |
判断队列是否为空 |
back() |
返回队尾元素的引用 |
push(elem) |
入队 |
front() |
返回队头元素的引用 |
pop() |
出队 |
它们的 pop()
方法都没有返回值。
集合
集合 set
要求每个元素都是独一无二的。插入元素时会忽略已经存在的元素。
1 | set<char> s; |
集合的常用成员函数见下表:
Method | Description |
---|---|
size() |
返回元素个数 |
empty() |
判断集合是否为空 |
insert(elem) |
插入新元素 elem |
erase(elem) |
删除值为 elem 的元素 |
erase(iterator) |
删除迭代器 iterator 所定位的元素 |
find(elem) |
查找值为 elem 的元素,返回定位到该元素的迭代器 |
映射
映射 map
用来存储键值对。键名必须都是独一无二的。声明映射,要提供两个类型参数,一个指定键名的类型,一个指定键值的类型。
1 |
|
映射使用 pair
对象来存储每一个键值对。pair
对象有两个公开成员变量,first
对应键名,second
对应键值。pair
类的头文件是 utility
。
映射用来插入新键值对的 insert()
方法要求键值对是一个 pair
对象。
1 |
|
insert()
方法返回一个新的表示插入结果的 pair
对象。若插入成功,则该 pair
对象的 second
为 true
,而 first
是定位到新键值对的迭代器。
1 | auto p = r.first; // 迭代器 |
可以把映射当作数组使用。不过 []
返回值是键值。
1 | pair<string, string> user("admin", "123456"); |
映射的常用成员函数见下表:
Method | Description |
---|---|
size() |
返回键值对个数 |
empty() |
判断映射是否为空 |
insert(pair) |
插入新键值对 |
erase() |
根据键名删除键值对 |
find() |
根据键名查找键值对,返回迭代器 |
容器的初始化和遍历
可用 初始值列表 来初始化容器。初始值列表是将初始值置于大括号中。
1 | set<string> colors = { "red", "green", "blue" }; |
遍历容器常用 auto
和 for
语句。
1 | for (auto &p: colors) { |
专题
安全的数组
库 array
提供的模板类 std::array
实现了安全的数组。它有两个类型参数,一个指定元素类型,一个指定数组长度。size()
方法返回数组长度。元素的初值都为 0。
1 | std::array<int, 5> arr = { 1, 2, 3 }; |
正则表达式
正则表达式是用来描述模式字符串的一种方式。库 regex
为正则表达式提供支持。类 regex
的构造函数需要一个正则表达式作为参数。要用正则表达式,需借助原始字符串字面值。
1 | string pattern = R"(bbccc)"; |
regex_match()
函数用模式匹配目标字符串,只有目标字符串和模式匹配时才会返回 true
;regex_search()
函数用模式匹配子串,只要目标字符串有一个子串和模式匹配就会返回 true
。
1 | string line; |
regex_search()
函数的返回值是布尔型,不能反映子串的位置。要知道每个子串的位置,要用正则表达式迭代器。类 sregex_iterator
的构造函数的参数包括对字符串首尾的引用和正则表达式,它的默认构造函数返回一个表示结束的迭代器。
1 | regex regExp(R"(\(?\d{3}\)?(-|\s)\d{3}(-|\s)\d{4})"); |
多线程
多线程编程要包含头文件 thread
。thread
类的对象代表一个线程,它的构造函数的参数包括线程要调用的函数以及要传递给该函数的参数。要等待线程结束,调用它们的 join()
方法。
1 |
|
在线程调用的函数中,可用 get_id()
方法获取线程的 ID。
最终,两个线程的输出有可能相互打断。这是因为上下文切换有可能发生在线程的输出过程中。更致命的是,如果两个线程修改同一个全局变量,变量的值最终是不确定的。
1 | int n = 0; |
要避免两个线程相互干扰,要使用互斥锁。互斥锁用来锁定一段代码,防止两个线程同时调用这段代码。库 mutex
提供的类 mutex
实现了互斥锁。
1 |
|
智能指针
智能指针是对象的包装器,用来包装占用了自由存储空间的对象。它自动维护对象的引用计数,并在对象的引用计数归零时自动释放对象占用的内存空间。
1 |
|
文件系统
库 filesystem
是 C++17 新增的标准库。头文件是 filesystem
,命名空间是 std::filesystem
。
1 |
|
它由一系列函数和类组成。例如,类 path
用于表示路径,函数 exists()
用于检查路径表示的文件是否存在。
1 | fs::path mypath = "./include/main.h"; |
path
类的 filename()
方法返回文件名;extension()
方法返回扩展名;parent_path()
方法返回父级目录名;make_preferred()
方法用于统一目录分隔符,在 Windows 上统一为 \
;在类 Unix 上统一为 /
。
1 | std::wcout << "filename: " << mypath.filename().c_str(); // main.h |