C++Primer读书笔记

最近重读了C++ Primer这本书,比较重要的知识点记录在此,也对其中一些知识点做了延伸。

主要来源:C++ Primer、stack overflow

基础知识

变量与基本类型

基本内置类型

C++的基本内置类型包括算数类型(arithmetic type)和空类型(void)。

算数类型中,short至少为16位,int至少为16位,long至少为32位,而long long至少为64位。

windows上一般int和long均为32位,而linux 64位机器上long和long long是64位,int是32位。[1]

如何选择类型

  1. 数值不会为负时,选择无符号类型
  2. 若数值超过int,直接选用long long,因为如上所述,long也许会与int是同样大小
  3. 浮点数选double,因为精度高,且计算代价与float相差无几。

类型转换

  1. 当我们赋给无符号类型一个超出其范围的值时,结果是初始值对无符号类型可以表示的数目取模的余数。
  2. 当我们赋给带符号类型一个超出其范围的值时,结果是未定义的。

因为C、C++对于数值的表示是与CPU一致的,在实际中,表示无符号的方式就只有一种:纯二进制表示;但表示有符号的类型却有多种,如反码、补码等,无法统一,因此在C++中结果未定义。[2]

变量

  • 变量:一个具名的、可供程序操作的存储空间

  • 对象:一块能存储数据并具有某种类型的内存空间

对于C++程序员而言,这两者一般可以互换使用。

初始化

初始化不是赋值。初始化是创建变量时赋予一个初始值,而赋值是把对象当前值擦除,以一个新值替代。

  • 列表初始化:C++11新标准,用花括号初始化变量。对于内置变量,若初始值存在丢失信息的风险,会报warning,其他初始化则不会。
  • 默认初始化:当定义变量没给指定初始值时,变量会被默认初始化,其值由定义的位置决定。(任何)变量若在函数外,初始化为0;若是在函数内的内置类型变量,会不被初始化,其值未定义。而各个类自己决定初始化对象的方式,如常用的string,则会默认初始化为空串。

定义与声明

为了允许将程序拆分为多个部分来编写(模块化),C++支持分离式编译,该机制允许检查程序分割为多个文件,每个文件可以被独立编译。

由于拆分成了多个文件,需要有文件间共享代码的方法,因此C++将声明和定义分开来。声明可以有多次,但定义只能有一次。

容易弄错的声明与定义:

// declaration
extern int bar;  // extern说明该变量必须!是另一个编译单元的全局变量 但这一外部声明语句可以在本编译单元的任意处使用
class foo;

// definition
int bar;
class foo {};

// 这还是定义
extern double pi = 3.1416; // 赋了初始值,抵消了extern的作用

typedef和#define

#define是预处理器的命令,只是进行字符的替换,编译器看不到其所定义的标识符。

typedef是编译器的关键字,会进行类型的检查。

引用与指针

(引用指的是“左值引用”,指针指的是“原始指针”)

引用是对绑定的对象(只能是对象,而不能是一个字面值)起的一个别名,它本身不是一个对象。引用必须初始化,且不能重新绑定到另外一个对象。引用的类型和与之绑定的对象要严格匹配。由于引用不是一个对象,所以不能定义引用的引用。

指针也实现了对其他对象的简介访问。但与引用相比也有许多不同点:

  1. 指针本身是一个对象,允许拷贝与赋值,而且指针可以先后指向不同的对象
  2. 指针无需在定义时赋初始值,与其他内置类型一样,在块作用域内没被初始化则拥有一个不确定的值

复合类型的声明

变量的定义包括一个基本数据类型(如int)和一组声明符(声明符命名了一个变量并指定该变量为与基本数据类型有关的某种类型。如i*p&r)。在同一条定义语句中,基本类型必须只有一个,但是声明符可以不同。如:

int i = 1024, *p = &i, &r = i;  // i是整数,p是int指针,r是int引用

*&在这里是类型修饰符,作为声明符的一部分。

经常会有一种错误的观点认为,在定义语句中,类型修饰符会作用于定义的全部变量,实际并不是,仅仅作用于其后的第一个变量。

int* p1, p2; // p1是int的指针,但p2不是,p2是int类型
int *p1, *p2; // p1, p2都是int的指针

const限定符

const对象仅在文件内有效,若多个文件中出现了同名,其实等同于在不同文件中分别定义了独立的变量。如果想在文件间共享,则不管是声明还是定义都添加extern关键字。

// file1.cpp 定义一个常量,允许被其他文件访问
extern const int bufSize = 100;
// file1.h
extern const int bufSize; // 与file1.cpp是同一个

对常量的引用

对常量的引用不能用于修改其绑定的对象,但其绑定的对象不一定是一个常量(只是引用“自以为是”罢了)。

int i = 42;
int &r1 = i;
const int &r2 = i;
r1 = 0; // 正确,i被修改为0
r2 = 0; //错误 不能通过r2修改i

而如果是一个常量,那么引用只能是对常量的引用。

const int i = 42;
const int &r1 = i; // 正确
int &r2 = i; // 错误, 否则可以通过r2修改i

指针和const

  • 指向常量的指针,可以不初始化,不能用于改变其所指对象的值(与引用相同,也是“自以为是”)
  • 常量指针,必须初始化,初始化后它的值(所存对象的地址)不能再改变

顶层const与底层const

  • 顶层const:可以表示任意对象是常量
  • 底层const:表示与指针和引用这类复合类型的基本类型部分是常量

一般一个类型要么是顶层const,要么是底层const,但指针既可以是顶层const,也可以是底层const。

常量指针,指针本身是常量,是顶层const;而指向常量的指针,指针本身不是常量,但而指向的对象是常量(指针自以为是),所以是底层const。

这两个概念在拷贝与赋值时十分重要。

在拷贝时,顶层const不受影响:

const int ci = 5;
int i = ci; // 正确
int *const cp = &i;
int *p = cp; // 正确

因为拷贝操作并不会改变被拷贝对象的值,所以是不是常量都无所谓。

但是底层const的限制不能忽视。当执行对象的拷贝操作时,拷入和拷出的对象都必须具有相同的底层const资格,或者能够相互转换,一般来说只有非常量可以转换成常量。

const int *cp = &i;
int *p = cp; // 错误,因为cp是底层const,而p不是 有可能会通过p修改cp所指向的值
cp = p; // 正确, int*可以转成const int*

字符串与向量

string

getline

getline参数是一个输入流和一个string对象,返回流参数。

每次读取一行,换行符会被读进来,所读的内容存入string对象,但是不存换行符

所以我们一般输出时会自行加一个endl:

string line;
while(getline(cin, line))
    cout << line << endl;

处理字符

cctype头文件中,常用的有:

  • isalpha(c) 字母
  • isdigit(c) 数字
  • isalnum(c) 字母或数字
  • isspace(c) 空白
  • ispunct(c) 标点符号

字符串拼接

operator+是拷贝两个字符串,生成新的字符串,复杂度为O(length(oldStr+newStr))operator+=是调用append函数,而append函数复杂度为O(length(newStr))

append的实现:根据文档,没有标准的复杂度保证,而实现是类似vector的insert函数。

stringvector<char>

string是模板类basic_string的char特例,源码定义如下:

typedef basic_string<char> string;

而basic_string类中,有三个主要的成员变量,分别是分配空间的首尾地址和当前最后一个元素的地址,这与vector是一样。因此,string和vector<char>在数据结构上是一致的,只不过string还提供了额外的对于字符串的操作,如转为c-style字符串的data()函数,还重载了输入输出运算符等等。

vector

reserve和resize

resize分配内存,且调用对应的构造函数构造对象,会添加或删除元素达到对应的大小,会修改size,增加元素时会修改capacity

reserve分配内存,不构造对象,只会影响capacity,不会影响size

值初始化

如果只提供vector容纳的数量,而略去了初始值,那么库会给一个值初始化的初值赋给容器中所有元素。

如果元素是内置类型,会自动设为0,如果是某种类类型,则又类默认初始化。

vcector和string的增长

vector和string都是分配连续的内存空间,元素是连续存储的。因此当没有空间容纳新元素时,我们必须重新分配一片内存空间,将已有元素移到新空间,添加元素,然后释放旧空间。如果每次添加元素都分配新空间,效率会很低。因此,vector和string的实现通常会分配比新的空间需求更大的空间,预留这些空间作为备用。

c++标准其实并没有指定要扩展内容多少,是依赖于实现的。各个实现也只是为了实现标准中所说的push_back(vector的)要摊还O(1),比如g++编译器是2倍,而vs的编译器是1.5倍。

迭代器

我们可以使用下标运算符来访问容器,但迭代器是一种更通用的访问机制。

如果容器为空,begin和end返回的是同一个迭代器,都是尾后迭代器。

在修改容器容量的循环中,不要使用迭代器,因为原本的迭代器会失效。

迭代器的运算

支持加减一个整数,支持在同一个容器中的迭代器大小比较。

支持同一个容器中两个迭代器的减法运算iter1-iter2,但不支持加法运算iter1+iter2

样例:迭代器版本的二分搜索

auto beg = text.begin();
auto end = text.end();
auto mid = beg + (end - beg) / 2;
while (mid != end && *mid != sought) {
    if (sought < *mid) {
        end = mid;
    } else {
        beg = mid +1;
    }
    mid = beg + (end - beg) / 2;
}

为什么求mid是beg + (end-beg)/2而不是(beg+end)/2?

  1. 如果是下标访问,beg+end或许会溢出
  2. 如果是迭代器版本,迭代器并不支持迭代器相加的运算

数组

数组的维度在编译时必须已知,即维度是常量表达式(字面值,常量表达式初始化的const对象,constexpr类型变量)

字符数组

当使用字符串字面值初始化字符数组时,一定要注意字符串字面值的结尾还有一个空字符会被自动添加。

char a1[] = {'c', '+', '+'} // 正确 字符字面值初始化,大小为3
char a2[] = "C++"  // 正确 字符串字面值初始化 大小为4
char a3[6] = "Daniel"  // 错误,没位置存放空字符\0

不允许拷贝与赋值

int a1[] = a; // 错误 不允许一个数组初始化另一个数组

指针和数组

使用数组的时候编译器一般会把它转换为数组首元素的指针。

string *p = strs; // 等价于string *p = &strs[0]
void testArr(int a[]) // a等价于&a[0],是指针
// 但注意定义数组时,数组名的类型仍然是数组
int a[3] = {1, 2, 3}; // a为数组类型
decltype(a) a2 = {0, 1, 2}; // 正确
a2 = p; // 错误 不能将指针赋给数组

表达式

递增递减运算符

除非必须,否则不用后置版本。

前置版本是将运算对象加1(或减1),然后返回改变后的对象,是左值;而后置版本是先暂存当前对象,然后加1(或减1),返回暂存的对象,是右值。如果我们不需要修改前的值,那么暂存的对象就是一种浪费。

混用解引用和递增运算符

这是一种常见的简洁写法,值得学习。

auto beg = v.begin();
while (beg != v.end() && *beg > 0)
    cout << *beg++ << endl;

后置递增运算符的优先级高于解引用运算符。因此*beg++等价于*(beg++)。最终,这条语句输出beg开始指向的那个元素,并将指针向前移动一个位置。

sizeof运算符

sizeof返回一个表达式或类型名所占的字节数,编译时计算,返回的是常量表达式。

它并不实际计算其运算对象的值,如:

  1. sizeof(*p) p可以是一个无效的指针,因为无需真的解引用指针
  2. sizeof(Sales_data::revenue) 可以使用作用域运算符获取类成员的大小,因为无需真的获取该成员

注意:对于stl的容器,sizeof返回的是与容器类相关的一个常量值。比如:vector是24, unordered_set

是56。

显式类型转换

When should static_cast, dynamic_cast, const_cast and reinterpret_cast be used?

形式如下:

cast-name<type>(expression);
  • static_cast:只要不包含底层const,对于有明确定义的类型转换就可以使用。尤其是把一个较大的算术类型转换为较小的类型时,这会告诉编译器我们知道且不在乎该精度损失。

  • const_cast:只能改变运算对象的底层const。

  • reinterpret_cast十分危险):为运算对象的位模式提供重新的解释,如:

    int *ip;
    char *pc = reinterpret_cast<char*>(ip);
  • dynamic_cast:用于将基类的指针或引用安全地转换成派生类的指针或引用。如果转换目标是指针类型且转换失败了,则返回0,而如果转换目标是引用类型且转换失败了,抛出bad_cast异常。

旧式的强制类型转换

早期C++有两种强制类型转换的形式:

  • 函数形式:type(expr)
  • C语言风格:(type)expr

旧式的强制类型转换包含现代显式类型转换的前三种形式,但是由于从形式上来说不那么清晰明了,一旦转换过程出现问题,追踪起来更为困难。

函数

数组形参

数组的两个特殊性质对我们作用在数组上的函数有影响:

  1. 不能拷贝数组。因此我们无法以值传递的方式使用数组参数。
  2. 使用数组时常会将其转换为指针。因此我们为一个函数传递数组时,传递的实际是数组首元素的指针。

对于一个数组参数,我们可以有多种形式

void print(int*);
void print(int []);
void print(int [10]); // 这里的维度只是期望,实际不一定是10

虽然有不同的表现形式,但其实唯一形参都是int *,即编译器只检查传入的参数是否是int *

int i = 0;
print(&i); // 所以这也是能编译通过的

数组引用形参

f(int (&arr)[10]); // 正确, 读的方式:从里往外读,从右往左读
f(int& arr[10]); // 错误,这样是引用的数组,而引用不是对象,不能存于数组

传递多维数组

当传递数组为参数时,实际传的是数组首元素的指针。当我们传多维数组,也就是数组的数组时,数组首元素即一个数组,所以实际传的是一个指向数组的指针。数组第二维及以后的维度都是数组类型的一部分,不能省略,所以我们应该这样传:

void print(int (*matrix)[10], int rowSize);  // 第二维是类型的一部分,因此要制定维度,这里是10

成员函数与this指针

以一个成员函数的调用为例:

total.isbn()

当我们调用成员函数时,其实是在替某个对象调用它。如果该成员函数指向了该类的某个成员变量,则它隐式地指向调用该函数对象的成员变量。

成员函数通过一个名为this的额外的隐式参数来访问调用它的那个对象。当我们调用一个成员函数时,用请求该函数的对象地址初始化this,如调用上述函数,则可以等价地认为编译器重写为

Sales_data::isbn(&total);

在成员函数内部,我们可以直接使用该对象的成员,而无需成员访问运算符,也正是因为this所指的就是这个对象。所以我们使用bookNo,其实相当于this->bookNo

因为this总是指向“这个”对象,所以它是一个常量指针

const成员函数

这里const的作用是修改隐式this指针的类型

默认情况下,this的类型是指向类类型非常量版本的常量指针,即T *const,则我们不能将其绑定到一个常量对象上,这样我们也就不能在一个常量对象上调用普通的成员函数了。

而const成员函数则可以将this转换为指向常量的常量指针const T *const,这样常量对象也就能调用const成员函数了。

构造函数

构造函数不能声明为const

当我们创建一个const类对象时,直到构造函数完成初始化过程,对象才真正获得了“常量”属性,因此构造函数在const对象构造过程中是可以向其写值的,那么也就不能声明为const。

合成默认构造函数

如果我们没有显式地定义构造函数,那么编译器会为我们隐式地定义一个默认构造函数,称为合成默认构造函数,它按以下规则初始化类的成员:

  • 如果存在类内初始值(在定义时赋初值,如int a = 3),用它初始化成员
  • 否则,默认初始化成员

如果类内含有内置类型或者复合类型(指针,引用等)的成员,只有当这些成员全都被赋予了类内初始值,这个类才适合使用合成默认构造函数,不然这些成员的值是未定义的。

构造函数初始化列表

当某个数据成员被构造初始化列表忽略时,它将以合成默认构造函数的方式隐式初始化。

从const成员函数返回*this

因为const成员函数中this是一个const T *const类型的指针,指向常量的指针返回指向的对象必须是常量,不让修改。因此,返回的*this应该是一个常量引用const T&

基于const的重载

因为非常量版本的函数对于常量对象是不可用的, 因此我们只能在一个常量对象上调用const成员函数;另一方面,虽然可以在非常量对象上调用常量函数,但显然非常量版本是一个更好的匹配。

class Foo {
public:
    Foo &display(std::ostream &os) {
        do_display(os);
    }
    const Foo &display(std::ostream &os) const {
        do_display(os);
        return *this;
    }
private:
    void do_display(std::ostream &os) {  // 使用私有功能函数,减少代码重复
        ...
    }
};

隐式的类类型转换

如果构造函数只接受一个实参,则它实际上定义了转换为此类类型的隐式转换机制。

假如类Sales_item定义了以下函数

Sales_item(std::string);
void combine(Sales_item);

那么以下代码是正确的:

Sales_item item ...
    ...
string null_book = "9-999-999";
item.combine(nullbook);

因为string类型可以隐式转换为Sales_itme类型。

但是隐式转换只允许一步类型转换,所以以下代码是错误的:

item.combine("9-999-999"); // 需要两步,(1)把const char*转为string (2)把string转为Sales_item

explicit抑制隐式转换

构造函数使用explicit关键字阻止隐式转换。

该关键字只对有一个实参的构造函数有效,需要多个实参的构造函数不能用于隐式转换,也就不需要该关键字。只能在类内声明时加,类外定义时不应重复。

explicit构造函数只能用于直接初始化!

发生隐式转换的一种情况是我们执行拷贝形式的初始化时(使用=),如果该构造函数是explicit,则无法进行该隐式转换。

Sales_data item(null_book); // 正确:直接初始化
Sales_data item = null_book // 错误 无法完成隐式转换

IO库

常用的IO库设施:

  • istream(输入流)类型
  • ostream(输出流)类型
  • cin, 一个istream对象,从标准输入读取数据
  • cout, 一个ostream对象,向标准输出写入数据
  • cerr,一个ostream对象,用于输出程序错误信息,写入标准错误
  • >>运算符(提取运算符),用于从一个istream对象读取输入
  • <<运算符(插入运算符),用于向一个ostream对象写入输出

IO类

IO对象不能拷贝和赋值!

流的条件状态

由于流可能处于错误状态,因此代码通常应该在使用流前检查它是否是良好的。确定一个流对象的状态最简单的方法就是将它作为一个条件来使用:

while (cin >> word)
    // 流状态良好

查询流的状态

IO库定义了一个iostate类型,作为位集合使用。

  • badbit表示系统级错误,不可恢复
  • failbit是可恢复错误,流还可以使用,如期望读取数值却读到了字符串等错误
  • 文件结束,eofbit和failbit都会被置位
  • goodbit值为0表示流状态良好

我们前面将流当做条件使用的代码其实就相当于!fail()

文件输入输出

文件常用操作:

  • fstream fstrm(filename); 创建一个文件流对象,并绑定一个文件,自动调用open函数打开它
  • fstrm.open(filename) 打开并绑定文件filename
  • fstrm.close()关闭与fstrm绑定的文件
  • fstrm.is_open() 返回一个bool值,指出与fstrm关联的文件是否成功打开

文件的打开与关闭

open失败,failbit会被置位。因为调用open可能失败,因此检测open是否成功是个好习惯,可以检查流对象的有效性或调用is_open()函数。

对一个已经打开的文件流调用open会失败,而且为了将文件流关联到另一个文件,必须首先关闭已经关联的文件。

当一个fstream对象被销毁时,会自动调用close函数。

文件模式

in
out
app每次写操作前定位到文件末尾
ate打开文件后立即定位到文件末尾
trunc截断文件
binary二进制读写
  1. 只有out被设定,trunc才能被设定
  2. 只要trunc没被设定,app就可以设定

string流

  • istringstream
  • ostringstream
  • stringstream

从string中读写数据,就像string是个IO流一样。

istringsteam常用形式

string line, word;
while (getline(cin, line)) {
    istringstream record(line);
    while (record >> word) {
        ...
    }
}

ostringstream常用形式

当我们想逐步构造输出,希望最后一起打印时,ostringstream很有用。

比如一个人有很多电话号码,我们希望确定一个人所有有效的电话号码后再一起输出。

for (const auto &nums : entry.phones) {
    if (valid(nums)) {
        formatted <<  " " << format(nums);
    }
}
os << entry.name << " " formatted.str() << endl;  // stringstream的str成员函数是返回内部string的拷贝

动态内存与指针

能不用指针就不用指针,可以值传递或者引用,如果一定要用指针,先考虑智能指针,原始指针很少用。何时使用指针:Why should I use a pointer rather than the object itself?

程序使用动态内存的三种原因:

  1. 不知道自己需要使用多少对象(容器类)
  2. 不知道对象的准确类型(多态)
  3. 需要在多个对象间共享数据(使用shared_ptr共享底层数据)

new delete

在C++中,如果需要自己直接管理内存,就需要使用new和delete这两个关键字。

new

new表达式在自由空间构造一个对象,并返回该对象的指针。默认情况下,该对象是默认初始化的, 如果在类型名后加一个空括号则是值初始化,加参数则是调用对应有参数的构造函数(类)。

int *p = new int; // 默认初始化
int *p = new int(); // 值初始化
int *p = new int(5); // 指定初始化为5,若是类则调用对应有参数的构造函数

动态分配的const对象

const int *pc = new const int(1024);

delete

与new类似,delete也执行两个操作:

  1. 销毁给定指针所指对象
  2. 释放对应内存

与malloc free的区别

最主要的区别就是new delete在分配和释放内存之余还会构造和销毁对象,而malloc和free只会分配和释放内存。而且new和delete的操作符,可以被合法重载,而malloc和free不行。

智能指针

智能指针就是包装着原始指针的类,自行管理动态内存,无须担心内存泄漏。

现代C++中,原始指针不能与动态内存管理相关,这一工作应交给智能指针来做。

A raw pointer should only be used as a “view” and not in any way involved in ownership.

智能指针和原始指针的选用详见:When to used shared_ptr and when to use raw pointers?

shared_ptr的拷贝和赋值

我们可以认为每个shared_ptr都有一个关联的计数器, 通常称为引用计数。

当我们拷贝一个shared_ptr时,对应计数器都会递增,如用一个shared_ptr初始化另一个shared_ptr,或将其作为参数传递,或是作为函数返回值。当我们给shared_ptr赋予一个新值或是其被销毁,计数器就会递减。

shared_ptr和new结合使用

除了使用make_shared初始化shared_ptr,我们还可以使用new返回的指针初始化智能指针。但注意:接受指针参数的构造函数是explicit的,也就是说只能直接初始化,而不支持隐式转换

shared_ptr<int> p(new int(42));  // 正确
shared_ptr<int> p = new int(42); // 错误

unique_ptr

一个unique_ptr“拥有”它所指向的对象,与shared_ptr不同,某个时刻只能有一个unique_ptr指向一个给定对象。使用new进行初始化。

不能拷贝或者赋值unique_ptr,但是可以通过rest或release转移指针的所有权。

weak_ptr

weak_ptr是“弱”共享对象,指向shared_ptr管理的对象,不会改变其引用计数,而且即使有weak_ptr指向对象,对象也可以释放。

用处:

  1. 检测一个对象是否还存在,解决空悬指针的问题[3]
int *ptr = new int(10);
int *ref = ptr;
delete ptr; // 此时ref和ptr都是空悬指针,指向未定义的数据
// 为了解决这一问题,我们可以使用shared_ptr和weak_ptr
shared_ptr<int> sptr = make_shared<int>(10);
weak_ptr<int> weak = sptr;
sptr.reset(new int);   // 转移指向的对象,释放原对象(如果引用计数为1)
*sptr = 5;
if (auto temp = weak.lock()) // 如果指向对象的引用计数为0,返回空shared_ptr,否则返回一个指向该对象的shared_ptr
    cout << *temp << endl;
else 
    cout << "weak is expired" << endl;
  1. 解决shared_ptr循环引用的问题
// 若让二叉树的节点一个成员变量shared_ptr指向其父节点,则存在循环引用,会造成内存泄漏
class TreeNode {
    public:
        shared_ptr<TreeNode> left;
        shared_ptr<TreeNode> right;
        shared_ptr<TreeNode> parent;
        TreeNode(int val):val(val) {
            cout << "Constructor" << endl;
        }
        ~TreeNode() {
            cout << "Destructor" << endl;
        }
    private:
        int val;

};

int main()
{
    shared_ptr<TreeNode> ptr = make_shared<TreeNode>(4);
    ptr->left = make_shared<TreeNode>(2);
    ptr->left->parent = ptr;
    ptr->right = make_shared<TreeNode>(5);
    ptr->right->parent = ptr;
    cout << "ptr: " << ptr.use_count() << endl;
    cout << "ptr->left: " << ptr->left.use_count() << endl;
    cout << "ptr->right: " << ptr->right.use_count() << endl;
    return 0;
}
// 输出为
Constructor
Constructor
Constructor
ptr: 3
ptr->left: 1
ptr->right: 1

// 若将指向父节点的指针改为weak_ptr则可以解决这一问题
class TreeNode {
    public:
        shared_ptr<TreeNode> left;
        shared_ptr<TreeNode> right;
        weak_ptr<TreeNode> parent;
        TreeNode(int val):val(val) {
            cout << "Constructor" << endl;
        }
        ~TreeNode() {
            cout << "Destructor" << endl;
        }
    private:
        int val;

};
// 其余不变, 输出为:
Constructor
Constructor
Constructor
ptr: 1
ptr->left: 1
ptr->right: 1
Destructor
Destructor
Destructor

面向对象程序设计

继承

派生类的声明

声明中包含类名,但是不能包含它的派生列表。

class Derived : public Base;  // 错误
class Derived; // 正确

静态类型与动态类型

当我们使用存在继承关系的类型时,我们就需要将一个变量的静态类型和动态类型区分开来。

静态类型是在编译时已知的,是声明时确定的类型;动态类型则是实际在内存的对象的类型,运行时才可知。

比如我们知道item的静态类型是Base&,但是它动态类型依赖于绑定的实参,动态类型直到运行时调用函数才会知道,也许是Base,也许是Derived

虚函数

在C++中,基类将类型相关的函数与派生类不做改变直接继承的函数区分对待,若基类希望派生类各自定义自己的版本,则声明这些函数为虚函数

当用指针或引用调用虚函数时,该调用将被动态绑定,即调用函数的版本由运行时调用的对象决定。

如果派生类没有覆盖基类的某个虚函数,则派生类会直接继承基类的版本。

C++的多态性

编译时多态

即函数重载,相同的函数名,类型或个数不同的参数。

运行时多态

对于类中定义的虚函数,我们使用基类的引用或指针调用它时,将发生动态绑定,直到运行时我们才确定调用函数的版本。这其实是基于引用或指针的静态类型和动态类型不同这一事实。

重载与重写的区别

  1. 继承。有类的继承才有重写,重载不需要继承
  2. 函数签名。重写函数必须有相同的函数名与参数,而重载函数需要有相同的函数名,不同的参数
  3. 函数作用域。重写的函数有不同的作用域(不同类中),而重载的函数有相同的作用域。
  4. 函数的行为。重写是子类函数希望做与父类函数有些不同的工作,而重载是根据不同的参数来进行不同的工作。

异常处理

基础与常用异常处理

在真实程序中,应该把业务逻辑代码和用户交互的代码分离开来。因此,当遇到问题时,最好不要直接输出错误信息,而是应该抛出异常,让对应专门的代码处理问题,如下:

if (item1.isbn() != item2.isbn())
    throw std::runtime_error("Data must refer to same isbn");

runtime_error是运行时不符合预期时可用。

异常抛出后,需要try语句块来进行处理。其通用语法为

try {
    program-statements
} catch (exception-declaration) {
    handle-statements
} catch(...) {
	...   
}

让我们用一个典例来加以说明:

while (cin >> item1 >> item2) {
    try {
        // 添加item的代码,若两个item的isbn不同,抛出runtime_error
    } catch (runtime_error err) {
        cout << err.what() << "\nTry Again? Enter y or n" << endl;
        char c;
        cin >> c;
        if (!cin || c == 'n')
            break;
    }
}

每个标准库类都定义了一个what成员函数,用于输出错误信息,该错误信息就是之前构造该异常对象时的初始化参数。因此这里会输出Data must refer to same isbn

由于输入的isbn不同,我们提示用户是否重新输入,若输入的不是字符(!cin)或输入为n则跳出,不然重新输入。

寻找处理代码

寻找处理代码的过程与函数调用链刚好相反。当异常被抛出时,首先搜索该异常的函数,若没找到匹配的catch则终止该函数,并在调用该函数的函数中继续寻找,以此类推,沿着程序的执行路径逐层回退,直到找到合适类型的catch语句为止。

如果最终都没有找到匹配的catch语句,则程序转到名为terminate的标准库函数,执行该函数,导致程序非正常退出。

异常的行为

异常中断了程序的正常流程

参考