Essential C++ 学习笔记
1 C++ 编程基础
1.1 如何撰写 C++ 程序
1.2 对象的定义与初始化
对象初始化有两种方法
int num_tries = 0; // 使用 assignment 运算符(=)进行初始化
int num_right(0); // 构造函数初始化
对比:
-
使用 assignment 初始化只能用于内置类型单一值初始化,而如果初始化只能用多个值的话,必须使用构造函数初始化
#include <complex> complex<double> purei(0, 7);
-
当“内置数据类型”与“程序员自行定义的 class 类型”具备不同初始化语法时,无法编写出一个 template 使它同时支持“内置类型”与“class类型”。让语法统一,可以简化 template 的设计。
1.3 撰写表达式
1.3.1 算术运算符
+ 加法运算
- 减法运算
* 乘法运算
/ 除法运算
% 取余数
两个整数相除会产生另一个整数(商)。小数点后的部分被舍弃,没有四舍五入。
1.3.2 三目运算符
expr ? expr为true则执行这里 : expr为false执行这里
1.3.3 复合赋值
+=, -=, *=, /=, %= 等
1.3.4 自增 自减
前置方式:用前加/减,比如 ++cnt,--cnt
后置方式:用后加/减,比如 cnt++,cnt--
1.3.5 关系运算符
任何关系运算符的求值结果为 bool 值 (true/false)
== 相等
!= 不等
< 小于
> 大于
<= 小于等于
>= 大于等于
1.3.6 逻辑运算符
&& AND逻辑运算 左右两个表达式结果皆为true时其求值结果为true
|| OR逻辑运算 左右两个表达式有一个为true时其求值结果为true
! NOT逻辑运算 如果表达式结果为true,其求值结果为false,反之同理
AND逻辑运算符和 OR 逻辑运算符都是短路运算符,如果通过左边的表达式的求值结果已经能确定整个表达式的求值结果,则右边的表达式不会被求值。
1.3.7 运算符的优先级
逻辑运算符 NOT
算术运算符 (*, /, %)
算术运算符 (+, -)
关系运算符 (>, <, <=, >=)
关系运算符 (==, !=)
逻辑运算符 (AND)
逻辑运算符 (OR)
赋值运算符
例:判断偶数 ! ival % 2
实际上为 (!ival) % 2
,应该写成 !(ival % 2)
1.4 条件语句和循环语句
前置知识:一个表达式求值的真假:
-
表达式返回的值是 bool 值 (true/false)
-
其他,C++ 会认为所有非0的值为 true,0值为false
- 数值类型的0值为
0
- 指针类型的0值为
nullptr / NULL
- 常量字符串类型的0值为
""
补充:C++ 中赋值语句表达式的求值结果是被赋的值,比如a=3
的求值结果为3。
- 数值类型的0值为
1.4.1 条件语句
1.4.1.1 if 语句
if 后括号内的条件表达式如果求值为 true,则之后的语句/语句块便会被执行
if () {
}
if () {
} else {
}
if () {
} else if {
} else if {
} else {
}
if 语句也是可以嵌套的
1.4.1.2 switch 语句
switch (expr) {
case 'a':
cout << 'a' << endl;
break;
case 'b':
cout << 'b' << endl;
break;
default:
cout << 'c' << endl;
break;
}
如果不加 break 的话,则其后的语句都会被执行,如果不匹配任何 case 则会执行 default 后的语句。
1.4.2 循环语句
1.4.2.1 while 循环
while (cin >> word) { // 如果读取失败则结果为 false
}
1.4.2.2 do-while 循环
do {
} while (expr);
1.4.2.3 for 循环
for (init-statement; condition; expression)
statement
其中 init-statement, condition, expression 都可以被省略,但是分号要保留。
1.5 array 和 vector
array 和 vector 都是连续存放变量的容器 (container) 类型
,可以通过变量在容器中的位置也就是索引来获取变量
array(数组) 是内置的数据类型,而 vector 则是由标准库提供。
array 示例:
const int seq_size = 18;
int pell_seq[seq_size]; // 数组的大小必须是常量表达式,也就是一个不需要再运行时求值的表达式
vector 示例:
#include <vector>
const int seq_size = 18;
vector<int> pell_seq(seq_size);
无论 array 还是 vector 我们都可以指定容器中的某个位置,进而访问该位置上的元素。索引操作 (indexing) 是通过下标运算符 ([]
) 达成的。
注意:容器的第一个元素的索引为0
1.6 指针
指针中存放的是变量的内存中的地址,所以指针就是地址
int * p;
int
表明该地址下所存变量的类型
*
表明该变量是指针
p
变量的名称
一个未指向任何对象的指针,其地址为 0 。有时候会被称为 NULL
指针。
1.6.1 取址
获取一个变量的内存地址用取址运算符(&
)
int *pi = &ival;
1.6.2 提领 / 解引用(dereference)
其实就是取得“位于该指针所指内存地址上”的对象,方法是在指针前使用 *
int val = *pi;
因为 NULL 其值为 0,没有指向任何对象,所以不能提领,所以在对对象进行提领时,最好判断指针的地址是否为0
if (pi && *pi != 1024)
*pi = 1024;
1.7 文件的读写
要对文件进行读写操作,需要包含 fstream 头文件
#include <fstream>
1.7.1 写文件
打开一个文件用于输出
// 文件不存在会新建,存在则会覆盖
ofstream outfile("seq_data.txt");
以追加模式打开
ofstream outfile("seq_data.txt", ios_base:app);
判断是否打开成功
// 如果文件未能成功打开,则 ofstream 对象的求值结果为 false
if (!outfile)
cerr << "Can not open the file!\n";
1.7.2 读文件
以读取模式 (input mode) 打开 infile
ifstream infile("seq_data.txt");
判断是否打开成功
// 如果文件未能打开成功,则 ifstream 对象的求值结果为 false
if (!infile)
cerr << "Can not open the file!\n";
读取文件示例
ifstream infile("seq_data.txt");
if (!infile) {
cerr << "Can not open the file!\n";
} else {
string name;
while (infile >> name) {
// do something
}
}
infile >> name
的返回值是从 infile
读取到的 class object。一旦读到文件末尾,对读入 class object 的求值结果就会是 false
1.7.3 读写同一个文件
fstream iofile("seq_data.txt", ios_base::in|ios_base::app);
if (!iofile) {
cerr << "Can not open the file!\n";
}
else {
iofile.seekg(0);
}
注意:
- 如果以追加模式打开文档,文件位置会位于末尾,
seekg()
将iofile
重新定位至文件起始处。 - 由于此文件是以追加模式打开,任何写入操作都会将数据添加在文件末尾。
2 面向过程的编程风格
2.1 编写函数
函数的四个部分
- 返回类型
- 函数名
- 参数列表
- 函数体
函数的声明要包括 1~3, 参数列表可以只写类型,目的是让编译器知道这个函数的存在
bool fibon_elem(int, int&);
函数的定义要包括 1~4
bool fibon_elem(int pos, int &elem) {
// implementation
}
2.2 调用函数
调用函数时,要向函数传递使用的参数,参数传递有两种方式
- 传值 (by value)
- 传引用 (by reference)
传值时,会将传递的对象复制一份,函数内对参数的修改不会影响传入对象的值。
传引用时,函数中对该对象进行的任何操作,都相当于是对传入的对象进行间接操作,也就是说会改变传入对象的值。
如果我们想在函数内部改变传入对象的值,可以使用传引用,也可以传值的方式传入对象的地址也就是指针,然后通过地址对对象进行操作
引用
引用类型,用起来感觉比较像给变量起别名
pointer 和 reference 的区别:
- pointer 可能(也可能不)指向某个实际对象,当提领 pointer 时,一定要先去顶其值不为0
- reference 必定会代表某个对象,所以不需要做此检查
作用域及范围
相关概念
- 存储期 (storage duration)/范围 (extent): 对象的存活时期
- 作用域 (scope): 对象在程序内的存活区域,对象在其作用于外不可见
- file scope: 声明在所有函数外对象所具有的作用域,具备 static extent(该对象的内存在
main()
开始执行之前便已分配好,可以一直存在至程序结束)。 - local scope: 声明在函数内的变量具有的作用域
除了 static 对象,函数内定义的对象,只存在于函数执行期间。
内置类型的对象,如果定义在 file scope 内,必定被初始化为 0。但如果被定义于 local scope,那么除非指定其初值,否则不会被初始化。
动态内存管理
local scope 和 file scope 中的对象都由操作系统自动管理。第三种存储期形式为 dynamic extent(动态范围)。其内存由系统的空闲空间(heap memory)分配,必须由程序员自动管理:
- 分配 new
- 释放 delete
默认情况下,由 heap 分配而来的对象,皆未经过初始化,可以手动对单个对象进行初始化,但是C++ 没有提供任何语法让我们得以从 heap 分配数组的同时为其元素设定初值。
从 heap 分配的对象具有 dynamic extent,因为它们是在运行通过 new 分配,因此可以持续存活,直到 delete 释放。
int *pi = new int(1024); // 初始化为 1024
int *pia = new int[24]; // 分配数组,无法设置初值
delete pi; // 无需检测 pi 是否非0,编译器会自动检查
delete [] pia;
2.3 默认参数
默认参数的两个规则
- 默认值的解析操作由最右边开始进行。如果我们为某个参数提供了默认值,那么这一参数右侧的所有参数都必须也具有默认值才行。
- 默认值只能指定一次,可以在函数声明处,也可以在函数定义处,但不能在两个地方都指定。
关于第二点,为了更高的可见性,默认值一般放在函数的声明处,而非定义处。
2.4 局部静态变量
局部静态变量使用 static
关键字
局部静态变量,在函数调用结束后,不会被释放。
2.5 inline 函数
将函数声明为 inline,表示编译器在每个函数调用点上,将函数的内容展开,省去了调用函数的开销。
inline 常用于代码短,所从事计算不复杂,常被调用的函数,一般不用于递归函数。
2.6 函数重载
函数重载 (function overloading): 参数列表不相同(参数类型不同或参数个数不同)的两个或多个函数可以拥有相同函数名称。
编译器无法通过返回值类型来区分两个具有相同名称的函数。
为什么需要函数重载:
将一组实现代码不同但工作内容相似的函数加以重载,可以让用户更容易使用这些函数。如果没有重载机制,我们就得为每个函数提供不同的名称。
2.7 函数模板
函数示例:
template<typename T>
T max(T a, T b) {
return a > b ? a : b;
}
好处是,我们不用为每一种类型单独写一个函数了。
一般而言,如果函数具备多种实现方式,我们可将它重载(overload),其实每份提供的是相同的通用服务。如果我们希望让程序代码主体不变,仅仅改变其中用到的数据类型,可以通过 function template 达到目的。
2.8 函数指针
函数指针,必须指明其所指函数的范围类型及参数列表,比如
cont vector<int>* *seq_ptr(int); // 几乎是对的
为了让 seq_ptr 被视为一个指针,必须以小括号改变运算优先级
cont vector<int>* (*seq_ptr)(int);
由函数指针指向的函数,其调用方式和一般函数相同
获取函数地址只需要提供函数名即可:
seq_ptr = pell_seq; // 将 pell_seq() 的地址赋值给 seq_ptr
2.9 设定头文件
使用头文件的原因:
在调用
seq_elem()
之前,必须先声明以使程序知道它的存在。如果它被五个程序文件调用,就必须进行五次声明操作。为了不用分别在五个文件中声明seq_elem()
,我们把函数声明放在头文件中,并在每个程序代码文件内 include 这些函数声明。
头文件扩展名:
头文件的扩展名,习惯上是
.h
。标准库例外,它们没有扩展名。
为什么不能把函数的定义放在头文件里:
函数的定义只能有一份,但是声明可以有多份。因为同一个程序的多个代码文件可能都会包含这个头文件,所以不能把函数的定义放在头文件里。
“只能定义一份”的规则有个例外:inline 函数的定义
为了能够展开 inline 函数的内容,在每个回调用点上,编译器都得取得其定义,所以 inline 函数的定义必须放在头文件中
在 file scope 内定义的对象,如果可能被多个文件访问,就应该被声明于头文件中。但是下面的声明是不对的:
const int seq_cnt = 6;
const vector<int>* (*seq_array[seq_cnt])(int);
因为这是定义而非声明,可以用 extern
关键字表示是声明变量。
const int seq_cnt = 6;
extern const vector<int>* (*seq_array[seq_cnt])(int);
为什么 seq_cnt
不需要加上关键字 extern
:
const object 就和 inline 函数一样,是“一次定义”规则下的例外。const object 的定义只要一出文件之外便不可见。所以我们可以在多个程序代码文件中加以定义,不会导致任何错误。
总的来说,头文件是在“一次定义规则”下用来声明变量/函数的,可以跨 file scope 访问变量/函数。在“一次定义规则”下有两个例外 inline 函数和 const object
3 泛型编程风格
3.0 STL 的两种组件
STL 主要由两种组件构成:
- 容器,包括 vector、list、set、map等;
- 用以操作这些容器的所谓泛型算法(generic algorithm),包括
find()
、sort()
、replace()
、merge()
等。
容器有两类
- 顺序性容器,比如 vector, list
- 关联容器,比如 map, set
泛型算法的性质
- 与操作元素的类型无关
- 与容器类型无关
泛型算法通过 function template 技术,达到“与操作对象的类型相互独立”的目的。而实现与容器无关的诀窍,就是不要直接在容器身上操作,而是借由一对 iterator(first 和 last),标示我们要进行迭代的元素范围。
3.1 指针的算数运算
指针的下标操作就是将指针的地址加上索引,产生出某个索引,然后再被提领,返回元素值。
在指针的算数运算中,会把“指针所指类型的大小”考虑进去
比如 int* p = 1000; 则 p + 2 = 1008,因为 int 类型占四个字节
int ia[8] = {1, 1, 2, 3, 5, 8, 13, 21};
int *pi = find(ia, ia + 8, ia[3]);
我们传入第二个地址,表示出数组最后一个元素的下一个地址。这合法吗?
是的,不过倘若我们企图对此地址进行读取或写入,那就不合法。如果我们仅仅是将该地址拿来和其他元素的地址进行比较,那就完全不会有任何问题。
3.2 Iterator(泛型指针)
3.2.1 获取 iterator
begin()
获取指向第一个元素的 iteratorend()
获取指向最后一个元素的 iterator
vector<string> svec;
vector<string>::iterator iter = svec.begin();
// const iterator 不允许修改 iterator 指向的元素
vector<string> cs_vec;
vector<string>::const_iterator iter = cs_vec.begin();
3.2.2 iterator 的基本操作
iterator 的操作与指针类似。
遍历容器
for (auto iter = container.begin(); iter != container.end(); ++iter) {
cout << *iter << endl;
}
通过 iterator 取得元素值:提领
auto elm = *iter;
通过 iterator 调用元素所提供的操作
int n = iter->size();
3.3 所有容器的共通操作
下列为所有容器类(以及 string 类)的共通操作
- equality(
==
)和 inequality(!=
),返回 true 或 false - assignment(
=
)运算符,将某个容器复制给另一容器 empty()
会在容器无任何元素时返回 true,否则返回 falsesize()
返回容器内目前持有的元素个数clear()
删除所有元素begin()
返回一个 iterator,指向容器的第一个元素end()
返回一个 iterator,指向容器的最后一个元素的下一位置insert()
将单一或某个范围内的元素插入容器erase()
将容器内单一元素或某个范围内元素删除
insert()
和 erase()
的行为视容器本身为顺序性(sequential)容器或关联(associative)容器而有所不同。
3.4 顺序性容器
顺序性容器用来维护一组排列有序、类型相同的元素。
顺序性容器主要有
- vector
- list
- deque
其对应的头文件分别为 #include <vector>
#include <list>
#include <deque>
有五种方法定义顺序性容器:
1 产生空的容器
list<string> slist;
vector<int> ivec;
2 产生特定大小的容器。每个元素都以其默认值作为初值。
list<int> ilist(1024);
vector<string> svec(32);
3 产生特定大小的容器,并为每个元素指定初值。
vector<int> ivec(10, -1);
list<string> slist(16, "unassigned");
4 通过一对 iterator 产生容器。
int ia[8] = {1, 1, 2, 3, 5, 8, 13, 21};
vector<int> fib(ia, ia+8);
5 根据某个容器产生出新容器。复制原容器内的元素,作为新容器的初值。
list<string> slist;
// 填充 slist...
list<string> slist2(slist); // 将 slist 复制给slist2
顺序性容器的通用操作:push_back, pop_back(),front(), back()
除此之外,list 和 deque(但不包括 vector)还提供了 push_front() 和 pop_front()
pop_back() 和 pop_front() 不会返回被删除的元素
通用插入 insert()
iterator insert(iterator position, elemType value)
void insert(iterator position, int count, elemType value)
void insert(itertor1 position, iterator2 first, iterator2 last)
iterator insert(iterator position)
通用删除 erase()
iterator erase(iterator position)
iterator erase(iterator first, iterator last)
list 类型不支持 iterator 的偏移运算,下面的写法是错误的
slist.erase(it1, it1+num_tries); // 错误
3.5 泛型算法
要使用泛型算法,首先得包含对应的 algorithm 头文件
#include <algorithm>
四种可能用到的泛型搜索算法
find()
遍历查找binary_search
二分查找,要求有序count()
search()
对比某个容器内是否存在某个子序列
获取容器内的最大值:max_element()
3.6 设计泛型算法
Function Object
这类 class 对 function call 运算符做了重载操作,如此一来可是 function object 被当成一般函数来使用。
P85
Function Object Adaptor
P86
3.7 Map
头文件:#include <map>
map<string, int> words_count;
string word;
while (cin >> word) {
++words_count[word];
}
其中 words_count[word]
,如果 word 不在 map 内,它变为因此放到map内,并获得默认的0值。
一般查询一个值是否在 map 内不用索引的方式,因为查询后会把值放在 map 内,更常用的两种方式
if(words_count.find(word) != words_count.end())
if(words_count.count(word)
, 任何一个 key 在 map 中最多只有一份
3.8 Set
头文件:#include <set>
判断一个元素是否在 set 中的两种方式
- find() 返回值不为 end() iterator
- count() 值不为 0
插入元素 insert(ival)
插入单个元素insert(vec.begin(), vec.end())
插入多个元素
泛型算法中与 set 有关的算法
set_intersection(), set_union(), set_difference(), set_symmetric_difference()。
3.9 Iterator Inserter
下面的函数将 vec 复制了一份
vector<int> temp(vec.size());
copy(vec.begin(), vec.end(), temp.begin());
copy()
接受两个 iterator,标示出复制范围。第三个 iteraotr 指向复制行为的目的地(也是个容器)的第一个元素,后续元素会被一次填入。确保“目标容器”拥有足够空间以放置每个即将到来的元素,这是程序员的责任。如果我们不确定这件事,可以使用 inserter
,以插入模式取代默认的 asssignment 行为。
inserter 头文件:#include <iterator>
back_inserter()
会以容器的push_back()
函数取代 assignment 运算符。inserter()
会以容器的insert()
函数取代 assignment 运算符。front_inserter()
会以容器的push_front()
函数取代 assignment 运算符。
注意: 这些 adapter 不能用在 array 上。
3.10 iostream Iterator
头文件:#include <iterator>
ifstream in_file("input_file.txt");
ofstream out_file("output_file.txt");
if (!in_file || !out_file) {
return -1;
}
istream_iterator<string> is(in_file);
istream_iterator<string> eof; // 空iterator 代表 eof
vector<string> text;
copy(is, eof, back_inserter(text));
sort(text.begin(), text.end());
ostream_iterator<string> os(out_file, " ");
copy(text.begin(), text.end(), os);
4 基于对象的编程风格
一般而言,class 由两部分组成:一组公开的(public)操作函数和运算符,以及一组私有的(private)实现细节。
- 这些操作符和运算符称为 class 的 member function(成员函数),并代表这个 class 的公开接口。身为 class 的用户,只能访问其公开接口。
- class 的 private 实现细节可由 member function 的定义以及与此 class 相关的任何数据组成
4.1 实现一个 Class
class 的声明以关键字 class
开始,其后接一个 class 名称
class 的定义由两部分组成:
-
class 声明
-
紧跟在声明后的主体,主体由一对大括号括住,并以分号结尾。
-
主体内的两个关键字
public
和private
,用来标识每个块的“member 访问权限”。- public member 可以在程序的任何地方被访问
- private member 只能在 member function 或是 class friend 内被访问
-
class Stack {
public:
bool push(const string&);
bool pop(string &elem);
bool peek(string &elem);
bool empty();
bool full();
int size() { return _stack.size(); }
private:
vector<string> _stack;
}
所有 member function 都必须在 class 主体内进行声明。至于是否要同时进行定义,可自由决定。
- 如果要在 class 主体内定义,这个member function 会被自动视为 inline 函数。
- 如果要在 class 主体外定义,必须使用特殊语法,用来分辨该函数所属的 class。如果系统该函数为 inline,应该在最前面指定关键字 inline。
inline bool Stack::empty() {
return _stack.empty();
}
bool Stack::pop(string &elem) {
if (empty()) return false;
elem = _stack.back();
_stack.pop_back();
return true;
}
其中 Stack::empty()
表明 empty()
是 Stack
class 的一个 member。
class scope resolution(类作用域解析)运算符: class 名称后的两个冒号(::
)
- 对于 inline 函数而言,无论在函数体内定义还是在函数体外定义,都要被放入头文件中;
- non-inline member function 应该放在程序代码文件中定义,该文件通常和 class 同名,其后接着扩展名 .c、.cc、.cpp 或 .cxx。
4.2 构造函数和析构函数
4.2.1 constructor
构造函数是一种特别的初始化函数,会在 class object 定义的时候调用。
Constructor 的函数名必须与 class 名称相同。语法规定,constructor 不应指定返回类型,也不用返回任何值,可以被重载。
通过 constuctor 进行初始化
class Triangular {
public:
Triangular(); // default constructors
Triangular(int len);
Triangular(int len, int beg_pos);
}
Triangular t; // 会调用无参构造函数
Triangular t2(10, 3); // 会调用第三个构造函数
Triangular t3 = 8; // 会调用第二个构造函数
注意: Triangular t3 = 8;
不会调用 assignment operator!
调用无参数的构造函数不能使用下面的方法:
Triangular t5();
因为会将 t5
定义为一个参数列表为空的函数,返回类型是 Triangular
。
最简单的 constructor 是所谓的 default constructor。它不需要任何参数。这意味着两种情况
- 第一,它不接受任何参数
- 第二,它为每个参数都提供了默认值,这种情况更常见。
如果两种情况同时存在会报错,因为编译器不知道要调用哪个构造函数。
Member Initialization List(成员初始化列表)
Triangular::Triangular(const Triangular &rhs)
: _length(rhs._length), _beg_pos(rhs._beg_pos), _next(rhs.beg_pos-1)
{ }
通过这种方法初始化,会调用 member class object 的 constructor,而第一种方法是通过 assignment 的形式。
copy constructor
当我们以某个 class object 作为另一个 object 的初值,例如:
Triangular tri1(8);
Triangular tri2 = tri1; // 会调用 constructor 而不是 assignment!!!
class data member 会被一次复制
但是如果 class member 中有指针类型,就不能简单的把指针给复制过来,这样的话,两个 class 的 指针 member 会指向同一片地址,正确的做法是应该对指针所指的数据进行复制,比如下面这个 Matrix 类(Matrix 类的具体定义见 4.2.2)
Matrix::Matrix(const Matrix &rhs) : _row(rhs._row), _col(rhs._col) {
int elem_cnt = _row * _col;
_pmat = new double[elem_cnt];
for (int ix = 0; ix < elem_cnt; ++ix)
_pmat[ix] = rhs._pmat[ix];
}
4.2.2 destructor
如果一个 class object 有 destructor,则其 object 结束生命的时候,便会自动调用 destructor。
destructor 主要用来释放在 constructor 中或对象生命周期中分配的资源。
destructor 的名称为:class 名称再加上 ~
前缀。它绝不会有任何返回值,也没有任何参数。由于其参数列表是空的,所以也不可能被重载。
class Matrix {
public:
Matrix(int row, int col) : _row(row), _col(col) {
_pmat = new double[row * cols];
}
~Matrix() {
delete [] _pmat;
}
private:
int _row, _col;
double *_pmat;
}
4.3 mutable 和 const
4.3.1 const
如果我们希望一个 member function 不会改变 class object 的内容,就必须在 member function 身上标注 const
,在定义和声明中都要指定 const。
class Triangular {
public:
// const member function
int length() const {return _length;}
int beg_pos() const {return _beg_pos;}
int elem(int pos) const;
// non-const member function
bool next(int &val);
void next_reset() {_next = _beg_pos - 1;}
private:
int _length;
int _beg_pos;
int _next;
static vector<int> _elems;
}
4.3.2 mutable
如果一个 class object 中的变量被声明为 mutable,则 const member function 就可以对其进行修改。
class Triangular {
public:
// 对 mutalbe 变量 _next 的修改不会影响到 const
void next_reset() const { _next = _beg_pos - 1;}
private:
mutable int _next;
}
4.4 this 指针
this 指针在 member function 内用来指向其调用者。
比如下面这个复制函数,返回的是调用者本身。
Triangular& Triangular::copy(const Triangular &rhs) {
if (this != &rhs) {
_length = rhs._length;
_beg_pos = rhs._beg_pos;
_next = rhs._beg_pos - 1;
}
return *this;
}
要以一个对象复制出另一个对象,先确定两个对象是否相同是个好习惯。
4.5 静态 类成员
4.5.1 Static Data Member
static(静态) data member 用来表示唯一的、可共享的 member。它可以在同一类的所有对象中访问。
下面的例子,声明了_elems
是 Triangular class 的一个 static data member:
class Triangular {
pubic:
// ...
private:
static vector<int> _elems;
}
对于 class 而言,static data member 只有唯一的一份实体,因此我们必须在程序代码文件中提供其清楚的定义。
// 以下代码放在程序代码文件中,例如 Triangular.cpp
vector<int> Triangular::_elems;
4.5.2 Static Member Function(静态成员函数)
一般情况下,member function 必须通过其类的某个对象来调用。这个对象会被绑定至该 member function 的 this
指针。通过存储与每个对象的 this 指针, member function 才能够访问每个对象中的 non-static data memer。
member function 只有在不访问任何 non-static member 的条件下才能够被声明为 static。
当我们在 class 主体外部进行 member function 的定义时,无须重复加上关键字 static(这个规则也适用于 static data member)。
调用静态成员变量时,使用 class scope 运算符即可
4.6 运算符函数
iterator class 要定义 !=
, *
, ++
等运算符,可通过运算符函数来实现。
运算符函数的名称是 operator
后面跟上运算符
class Triangular_iterator {
public:
Triangular_iterator(int index) :_index(index - 1) {}
bool operator==(const Triangular_iterator&) const;
bool operator!=(const Triangular_iterator&) const;
int operator*() const;
Triangular_iterator& operator++(); // 前置版
Triangular_iterator operator++(int); // 后置版
}
后置版本的参数列表原本也应该是空的,然后重载规则要求,参数列表必须独一无二。因此C++语言想出一个变通方法,要求后置版得有一个 int 参数。编译器会自动为后置版产生一个 int 参数(其值必为0)。
任何一个运算符如果和另一个运算符性质相反,我们通常会以后者实现出前者
inline bool Triangular_iterator::
operator!=(const Triangular_iterator &rhs) const {
return !(*this == rhs);
}
运算符重载的规则见:P120
Non-member 运算符的参数列表中,一定会比相应的 member 运算符多出一个参数,也就是 this 指针。对于 member 运算符而言,这个 this 指针隐式代表做操作数。
嵌套类型
typedef
可以为某个类型设定另一个不同的名称。其通用形式为
typedef exsiting_type new_name
4.7 friend
如果一个function/class B 要访问另一个 class A的 private member,则必须在 A 中声明 B 为 friend
class A {
friend class B; // 声明 clss B 为 A 的 friend
friend int C(); // 声明函数 C 为 A 的 friend
friend void D::E(); // 声明 class D 中的函数 E 为 A 的 friend
}
但是,我们一般不会直接访问一个类的 private member,而是让类提供具有 public 访问权限的 inline 函数来进行相关操作。
4.8 copy assignment operator
default memberwise copy(默认的成员逐一复制)
和拷贝构造函数一样,我们要考虑指针的问题。
4.9 function object
当编译器在编译的过程中遇到函数调用,例如:
lt(ival);
- lt 可能是函数名
- lt 可能是函数指针
- lt 可能是一个提供了 funciton call 运算符的 function object
如果 lt 是一个 class object,编译器会在内部将此语句转换为
lt.operator(ival);
对比于 subscript 运算符仅能接受一个参数,function call 能接受人一个是的参数。
实现一个 function object只需要定义function call运算符函数(operator()
)即可,例如:
inline bool LessThan::operator()(int value) const {
return value < _val;
}
4.10 重载 iostream 运算符
ostream& operator<<(ostream &os, const Triangular &rhs) {
// something
return os;
}
istream& operator>>(istream &is, const Trangular &rhs) {
// something
return is;
}
4.11 指针,指向 Class Member Function
pointer to member function 于 pointer to non-member function 类似,不过在声明时候要指定指向哪一个 class。例如:
void (num_sequence::*pm)(int) = 0;
声明了一个名为 pm 的指针,指向 num_sequence 的 member function,后者的返回类型必须是 void, 参数类型为 int。pm 的初始值为 0,表示目前并不指向任何一个 member function。
如果每次都这样写,有点复杂,可以用 typedef 进行简化
typedef void (num_sequence::*PtrType)(int);
PtrType pm = 0;
不同于普通 function,为了取得某个 member function 的地址,我们对函数名称应用 address-of(取址)运算符,且函数名称前必须先以 class scope 加以限定。
PtrType pm = &num_sequence::fibonacci;
Pointer to member funtion 和 pointer to funtion 的一个不同点是,前者必须通过同一类对象加以调用,而该对象便是此 member function 内的 this
指针所指的对象。
num_sequence ns;
num_sequence *pns = &ns;
PtrType pm = &num_sequence::fibonacci;
通过 ns 调用函数
// 与 ns.fibonacci(pos) 效果相同
(ns.*pm)(pos);
其中 .*
是个 pointer to member selection 运算符,必须为它加上外围的小括号,才能正常工作。
也可以通过 pns 调用
// 与 pns->fibonacci(pos) 效果相同
(pns->*pm)(pos);
针对 pointer to clss object 工作的 pointer to member selection 运算符是 ->*
。
5 面向对象编程风格
5.1 面向对象编程概念
面向对象编程概念的两项最主要特质是:继承(inheritance)和多态(polymorphism)。
- 继承使我们得以将一群相关的类组织起来,并让我们得以分享其间的共通数据和操作行为
- 多态让我们在这些类之上进行编程时,可以如同操控单一个体,而非相互独立的类,并赋予我们更多弹性来加入或移除任何特定类。
5.1.1 继承
继承机制定义了父子关系。父类定义了所有子类共通的共有接口和私有实现。每个子类都可以增加或覆盖继承而来的东西,以实现其自身独特行为。
相关概念:
- 基类(base class):父类
- 派生类(derived class):子类
- 继承体系(inheritance hierarchy):父类和子类之间的关系
5.1.2 多态
在面向对象应用程序中,我们会间接利用指向抽象基类的 pointer 或 reference 来操作系统中的各对象,而不直接操作各个实际对象。这让我们得以在不变动旧有程序的前提下,加入或移除任何一个派生类。
多态:让基类的 pointer 或 reference 得以十分透明地指向任何一个派生类对象。
动态绑定(dynamic binding):只有在运行的时候我们才能知道一个 pointer 或 reference 指向哪个派生类。
5.2 面向对象编程思维
默认情况下,member function 的解析(resolution)皆在编译的静态地进行。若要令其在运行时动态进行,我们就得在它的声明前加上关键字
virtual
。
5.3 不带继承的多态
这样做极费工夫,尤其事后的维护更是工程浩大。
5.4 定义一个抽象基类
-
定义抽象类的第一个步骤就是找出所有子类共通的操作行为。
-
设计抽象类的下一步,便是设法找出哪些操作行为与类型相关(type-dependent)——也就是说,有哪些操作行为必须根据不同的派生类而有不同的实现方式。这些操作行为应该成为整个类继承体系中的虚函数(virtual function)。
-
设计抽象基类的第三步,便是试着找出每个操作行为的访问层级(access level)。
- 如果某个操作行为应该让一般程序皆能访问,我们应该将它声明为 public
- 如果某个操作行为在基类之外不需要被用到,我们就将他声明为 private。即使是该基类的派生类也无法访问基类中的 priavte member。
- 第三种访问层级,是所谓的 protected。这种层级可以让派生类方位,却不允许一般程序使用。
纯虚函数:
每个虚函数,要么得有其定义,要么可设为“纯”虚函数(pure virtual function)——如果对于该类而言,这个虚函数并无实质意义的话,将虚函数赋值为 0,意思便是令它为一个纯虚函数。
virtual void gen_elems(int pos) = 0;
任何类如果声明有一个(或多个)纯虚函数,那么由于其接口的不完整性(纯虚函数没有函数定义),程序无法为他产生任何对象。这种类只能作为派生类的子对象使用,而且前提是这些派生类必须为所有虚函数提供确切的定义。
根据一般规则,凡基类定义有一个(或多个)虚函数,应该要将其 destructor 声明为
virtual
。但不建议将其设为纯虚函数
5.5 定义一个派生类
派生类有两部分组成:
- 一是基类构成的子对象(subobject),由基类的 non-static data member组成
- 二是派生类的部分,由派生类的 non-static data member 组成。
class Fibonacci: public num_sequence {
public:
// ...
};
派生类的名称之后紧跟这冒号、关键字 public,以及基类名称。
在类之外对虚函数进行定义时,不必指明关键字 virtual
对于 non-virua如果要在派生类中使用继承来的那份member而不是派生类本身的member,可以使用 class scope 运算符加以限定。
如果在基类的 print 函数前加上 virutal,在运行时会动态判断调用哪个函数,所以会输出 "this is derive class",如果没加,则会输出"this is base class"。
#include <iostream>
using namespace std;
class Base {
public:
virtual void print() {
cout << "this is base class" << endl;
}
};
class Derive : public Base {
public:
void print() {
cout << "this is derive class" << endl;
}
};
int main() {
Base* ptr = new Derive;
ptr->print();
}
也就是说,在基类和派生类中提供同名的 non-virtual 函数,则在通过多态调用函数(通过基类的指针或引用)时,总是会调用基类的函数,这并不是我们想要的。
书中关于 check_integrity() 的设计:
当派生类欲检查其自身状态的完整性时,已实现完成的基类缺乏足够的知识。而我们知道,根据不完整信息所完成的实现,可能也是不完整的。这和“宣称实现与类型相关,因而必须将它声明为 virtual”的情况并不相同。
5.6 运用继承体系
使用基类的指针或引用,再根据指向的派生类去执行相关的操作。
5.7 基类应该多么抽象
5.8 初始化、析构、复制
5.9 在派生类中定义一个虚函数
如果我们决定覆盖基类所提供的虚函数,那么派生类提供的新定义,其函数原型必须完全符合基类所声明的函数原型,包括:参数列表,返回类型,常量性。
“返回类型必须完全符合”这一规则有个例外——当基类的虚函数返回某个基类形式(通常是 pointer 或 refernece)时,派生类中的同名函数便可以返回该基类所派生出来的类型。
虚函数的静态解析
有两种情况,虚函数机制不会出现预期行为:
- (1) 基类的 constructor 内和 desctructor 内
- (2) 当我们使用的是基类的对象,而非基类对象的 pointer 或 reference 时
在派生类中,为了覆盖基类的某个虚函数,而进行声明操作时,不一定得加上 virtual 关键字。编译器会根据两个函数的原型声明,决定某个函数是否会覆盖其基类中的同名函数。
5.10 运行时的类型鉴定机制
每个类都有一份 what_am_i()
函数,另一种设计方法,便是只提供一份 what_am_i()
函数,令各派生类通过继承机制加以复用。
一种可能的做法是为 num_sequence
增加一个 string
member,并令每个派生类的 constructor 都将自己的类名作为参数。
另一种实现便是利用所谓的 typeid
运算符,这是所谓的运行时类型鉴定机制(Run-Time Type Identification,RTTI)的一部分,由程序语言支持。
#include <typeinfo>
inline const char* num_seqence::
what_am_i() const {
return typeid(*this).name();
}
dynamic_cast 也是一个 RTTI 运算符,他会进行运行时检验操作,如果失败则返回0。
6 以 template 进行编程
6.1 被参数化的类型
template 机制帮助我们将类定义中“与类型相关”和“独立于类型之外”的两部分分离开来。
需要使用 template parameter list 限定 class template 的场景:除了class template 及其 member 的定义中的其他场合。
6.2 Class Template 的定义
template <typename elemType>
inline BinaryTree<elemType>:: // 在 class 定义范围之外(所以需要限定类型)
BinaryTree() : _root(0) // 在 class 定义范围之内
{}
6.3 Template 类型参数的处理
实际运用中,不论内置类型或 class 类型,都可能被指定为 class template 的实际类型。我建议,将所有的 template 类型参数视为“class 类型”来处理。这意味着我们会把它声明为一个
const
reference,而非以 by value 方式传送。
推荐在 constructor 的 member initialization list 内为每个类型参数进行初始化操作:
template<typename valType>
inline BTnode<valType>::BTnode(const valType &val) : _val(val) {
_cnt = 1;
_lchild = rchild = 0;
}
而不选择在 constructor 函数体内进行:
template<typename valType>
inline BTnode<valType>::BTnode(const valType &val) {
_val = val; // 不建议这样,因为它可能是 class 类型
_cnt = 1;
_lchild = rchild = 0;
}
6.4 实现一个 Class Template
6.5 一个以 Function Template 完成的 Output 运算符
6.6 常量表达式与默认参数值
以表达式作为 template 参数。这种 template 参数在 C++ Primer 一书中称为“非类型参数”。
Template 参数并不是非得某种类型(type)不可,也可以用常量表达式(constant expression)作为 template 参数。
template <int len>
class num_sequence {
public:
num_sequence(int beg_pos=1);
// ...
};
template <int len>
class Fibonacci : public num_sequence<len> {
public:
Fibonacci(int beg_pos=1) : num_sequence<len>(beg_pos) {}
// ...
};
当我们产生 Fibonacci 对象,像这样:
Fibonacci<16> fib1;
Fibonacci<16> fib2(17);
也可以提供默认值
template <int len, int beg_pos=1>
output 运算符的 function template 定义
template <int len, int beg_pos>
ostream & operator<<(ostream &os, const num_sequence<int len, int beg_pos> &ns) {
return ns.print(os);
}
6.7 以 Template 参数作为一种设计策略
template <typename elemType>
class LessThan {
public:
LessThan(const elemType &val) : _val(val) {}
bool operator()(const elemType &val) const {
return val < _val;
}
void val(const elemType &newval) { _val = newval; }
elemType val() const { return _val;}
private:
elemType _val;
};
上述情况没有考虑到用户所提供的类型是否有 <
运算符的定义,解决方法是提供一个 less-than 运算符,其默认值是 less<elemType>
template <typename elemType, typename BinaryComp = less<elemType> >
6.8 Member Template Function
可以将 member function 定义成 template 形式,不过不用指定 template的类型。
class PrintIt {
public:
PrintIt(ostream &os) : _os(os) {}
template <typename elemType>
void print(const elemType &elem, char delimiter = '\n') {
_os << elem << delimiter;
}
private:
ostream &_os;
};
int main() {
PrintIt to_standard_out(cout);
to_standard_out.print("Hello");
to_standard_out.print(1024);
string my_string("i and a string");
to_standard_out.print(my_string);
return 0;
}
7 异常处理
7.1 抛出异常
C++ 通过 throw
关键字抛出异常,异常是某种对象。
throw 42;
throw "panic: no buffer!";
throw iterator_overflow; // iterator_overflow 是个类
7.2 捕获异常
用 try
关键字来执行可能抛出异常的语句,然后可以利用单条或一连串的 catch
子句来捕获被抛出的异常,如果异常的类型相符,则会执行 catch 中的语句。
同一个类型的异常不能出现两次,因为会被第一个捕获。
catch 前必须有 try。
try {
// 可能会抛出异常
}
catch (int erro) { // int 类型的异常
// 处理异常
}
catch (string err) { // string 类型的异常
}
捕获任何类型的异常,使用 ...
try {
//...
}
catch (...) {
// 处理异常
}
7.3 提炼异常
在遇到异常时,如果该异常没有被 catch 则当前函数会被停止,然后会沿着函数调用链一路回溯,直到异常被 catch。如果一直回到了 main() 函数还没有被 catch,便会调用标准库提供的 terminate()
——中断整个程序的执行。
7.4 局部资源管理
下面的代码无法保证资源最终一定会被释放掉(process 可能会出现异常)
extern Mutex m;
void f() {
int *p = new int;
m.acquire();
process(p);
m.release();
delete p;
}
一个容易想到的解决方法: try catch,但是资源释放的代码需要在 try 和 catch 中都写一遍。且,捕获异常、释放资源、重新抛出异常,这些操作会使异常处理程序的搜寻时间进一步延长。此外,程序代码本身也变得更复杂了。
另一种有效的方法:
Resource Acquisition Is Initialization (RAII):在初始化阶段即进行资源请求,或资源请求即初始化。
对对象而言,初始化操作发生于 constructor 内,资源的请求也应该在 constructor 内完成。资源释放则应该在 destructor 内完成。
#include <memory>
void f() {
auto_ptr<int> p(new int);
MutexLock ml(m);
process(p);
// p 和 ml 的 destructor 会在此处被自动调用
}
class MutexLock {
public:
MutexLock(Mutex m) : _lock(m) {
lock.acquire();
}
~MutexLock() { lock.release() }
private:
Mutex &_lock;
};
- 如果 process 执行无误,则局部变量 p 和 ml 的 destructor 则会在函数结束前被自动调用。
- 如果 process 抛出异常,C++会保证在异常处理机制终结某个函数前,调用函数中所有局部变量的 destructor
ps: auto_ptr
是标准库提供的 class template,它的 destructor 会调用 delete 释放内存。使用时需要 #include <memory>
。
7.5 标准异常
如果 new 表达式无法分配足够的空间,会返回 bad_alloc(是一个类),如果我们想要操作 bad_alloc 异常对象,它提供了哪些操作呢?
标准库定义了一套异常体系(exception class hierarchy),其根部是名为 exception 的抽象基类。
exception 的头文件:#include <exception>
exception 声明有一个 what()
虚函数,返回一个 const char *