第一章 让自己习惯C++
条款一:视C++为一个语言联邦
最初,C++仅是C加上了一些面象对象的特性。经历多年的发展,随着大量特性与功能的加入,使得C++成为了一个无可匹敌的工具。但也是这些特性与功能,会让我们困惑于,我们应如何使用它们,该如何理解这样一个复杂的语言呢?
最简单的方法就是将C++视为一个相关语言的组成的一个联邦,对于C++来说,它大致由四个部分组成:
- C语言
- 作为C++的基础。C++中的区块、语句、预处理器、内置数据类型、数组、指针等皆是来自于C。部分特性展示:
1
2
3
double blance[5] = {1,3,4,5,6};
int *p;
- 作为C++的基础。C++中的区块、语句、预处理器、内置数据类型、数组、指针等皆是来自于C。部分特性展示:
- Object-Oriented C++
- 在初始C with Classes,缺乏真正的面向对象思想。因此加入了:class(包括构造与析构)、封装(encapulation)、继承(inheritance)、多态(polymorphism)…等,从而符合面向对象设计的古典守则。部分特性展示:
1
2
3
4
5
6
7
8
9
10
11
12
13// 类
class test {
public:
test();
~test();
}
// 继承
class demo : public test {
// 封装
private:
int k
}
- 在初始C with Classes,缺乏真正的面向对象思想。因此加入了:class(包括构造与析构)、封装(encapulation)、继承(inheritance)、多态(polymorphism)…等,从而符合面向对象设计的古典守则。部分特性展示:
- Template C++
- 这是C++ 泛型编程部分,它们威力强大。它们也带来了新的编程范型,也就是template metaprogramming(模板元编程)。部分特性展示:
1
2
3
4
5template <typename T>
T add (T a,T b) {return a + b;}
template <typename T>
class test<T> {}
- 这是C++ 泛型编程部分,它们威力强大。它们也带来了新的编程范型,也就是template metaprogramming(模板元编程)。部分特性展示:
- STL
- 这是Template 程序库,它由六大组件构成。它们有着严格的要求,与它们工作时,我们也必须遵守它们的条约。六大组件图:
C++语言的高效编程需要根据使用它的不同部分不断变化的。
- 这是Template 程序库,它由六大组件构成。它们有着严格的要求,与它们工作时,我们也必须遵守它们的条约。六大组件图:
条款二:尽量以const、enum、inline替换#define
#define属于一个从C继承而来的特性,那么说明我们对它还是存在一定的需求的。但是对于现代版本的C++来说,它存在了一些我们不得不去替换它的原因。
- #define会盲目替换目标码
- 可以使用一个常量来替换宏定义,这样就会避免出现多个目标码的情况。
1
2
3
替换为
const double AspectRatio = 2;
- 常量字符串最好使用std::string 替换 char *
- char * 仅是声明了一个char指针指向字符串,C++中提供了专门处理字符串的类型即std::string。非必要则默认使用std::string。
1
2
3const char* const a = "abc";
//最好替换为
const std::string test("abc");
- #define 不提供任何的封装性
- 由于#define不重视作用域,可以被随意调用,因此它不具备封装性。我们可以使用static const为class创建专属变量,限制作用域实现封装。
1
2
3
4
5class test {
private:
// static 限制作用域
static const int demo = 5;
};
- #define无法阻止reference或pointer指向某个常量
- 可以通过enum来实现这个约束。
- 形似函数的宏会带来意想不到的错误
- 使用inline替换,它遵守作用域与访问规则。
条款三:尽可能使用const
const是一件奇妙的关键字,它给所有被修饰对象添加了一个约束,即不可被改动。编译器会严格的帮你执行这个约束直至程序结束。
使用const修饰变量,我们常用的两种方法:Top-level const 与 Low-level const;
- 顶层const (Top-level const)
1
2
3int a = 1;
// 顶层const
int* const b = &a;- const所修饰的变量本身是一个常量无法修改,指的是指针。
- 底层const(Low-level const)
1
2
3
4int a = 2;
// 底层const,它存在两种写法(根据习惯)
int const *b = &a;
const int *b = &a;- const所修饰的变量所指向的对象是一个常量,指的是变量。
根据const的性质,合理的使用const。有利于我们侦测代码中的错误,避免一些类似于”==”打成”=”的低级错误。
使用好const也是编写高效代码的关键其原因有二:第一,它使得class接口比较容易理解,清晰的知道什么可以改动,什么不可以;第二,使得操作const对象成为可能。
在const与non-const有着同等的实现时,我们最好使用non-const版本去调用const版本。倘若我们使用const版本调用non-const版本,我们岂不是违背了const的性质。当然有人说,可以使用转型不就可以了。但是转型之中又存在了安全性与破坏类型系统的风险,因此,使用non-const版本去调用const版本就成了优选。这样还成功避免了代码重复。
条款四: 确定对象被使用前已被初始化
读取未初始化值将会导致某些意想不到的行为,以及之后一些痛苦的调试过程。对于内置类型我们只能进行手工的初始化,对此之外的初始化操作全都落到了构造函数的身上。
对于构造函数初始化,我们常用的两种方式:赋值初始化、初始化列表。
- 赋值初始化
- 一个伪初始化,可能创建临时对象,并调用operator=();
1
2
3
4
5
6
7
8
9class test {
public:
test(int num);
private:
int len;
};
test::test(int num) {
len = num;
}
- 一个伪初始化,可能创建临时对象,并调用operator=();
- 成员列表初始化
- 单纯的初始化,编译器会一一操作初始化列表,在任何显式用户代码之前。
1
2
3
4
5
6
7class test {
public:
test(int num);
private:
int len;
};
test::test():len(2) {}
- 单纯的初始化,编译器会一一操作初始化列表,在任何显式用户代码之前。
对于成员列表初始化操作,对于成员排列顺序应和它们在class中的声明次序相同。当然,如果忘记了排序,编译器也会对列表进行一个重新排序。
为了免除”跨编译单元的初始化次序”问题,我们需要使用local static对象替换non-local static对象。其原因是:C++保证函数中local static对象会在”该函数被调用期间”首次遇上该对象的定义式时被初始化。
第二章 构造、析构、赋值运算
条款五:了解C++ 默默编写并调用哪些函数
当我们创建了一个空类时,它真的是空类?当我们在编译运行时,编译器会为我们创建一个copy函数、一个assignment操作符、一个析构函数还有一个默认构造函数。这些类型都是public类型且它们都是内联(inline)里面。当然,仅在它们被需要的时候,都会被创建出来。这样就像如下的代码:
1 | class Empty { |
这些默认函数都提供了哪些功能呢?接下来我们将对它们进行一一分析
首先对于copy构造与assignment操作符的作用仅是把每一个non-static(class 中的成员)成员变量拷贝到目标对象。
对于析构函数,它们仅仅是将对象释放掉。由于是编译器提供,所以它的默认属性是virtualness,在多重继承中这可会导致内存泄漏之类的麻烦问题
默认的构造函数被构造出来,仅仅是为了让满足编译器需要。它们并不会被直接合成出来,只有被需要时都会被合成。它也不会显式设定class的init值。
条款六:若不想使用编译器自动生成的函数,就该明确拒绝
在一个系统中,若是每个数据都是独有的。那么对于编译器的copy构造与copy assignment操作符,就应该明确拒绝。
但是如何拒绝编译器提供的版本呢?大师提供了一个思路,那么就是在类的private中将它们声明出来,以此来明确拒绝编译器提供的版本。
1 |
|
但是对于class 的member function 与友元函数,依旧可以调用private函数。所以最好的办法,使用继承的办法。将copy构造与copy assignment操作符写入基类,用以防止子类调用它们。*
条款七: 为多态基类声明virtual 析构函数
在条款五中,我们提及了virtualness的虚拟函数会导致灾难的。这节我们将深入理解这个背后的原因。
当deirved class经由一个base class指针被删除,而被base class带着一个non-virtual析构函数,其结果未有定义——实际执行时通常发生的是对象的derived成分没被销毁。
如果派生类中含有基类的成员,但是调用析构函数后。诡异的事情发生了,派生类中的基类对象被销毁了,但是对于派生类对象没有被销毁。这就产生了局布销毁的对象,这也是导致资源泄漏、败坏数据结构的源头。给base class 析构加上虚函数,在delete父类指针时,会先调用子类的析构函数,再调用父类析构函数。
当然,对析构使用虚函数仅在多重继承使用。如果是在单继承,随意的使用虚函数会额外的增加编译器的负担。
条款八: 别让异常逃离析构函数
如果我们在析构中捕获异常,如果程序正常结束,一切OK。但是如果调用导致异常,析构将会传播这个异常,将程序带向一个不归路。对于这个问题,我们提供了两个办法来解决。
如果出现异常就通过abort()结束程序。强制结束,也就避免了异常从析构传播出去,提前阻止的不明确行为的发生。
1 | test::~test() { |
一般来说,将异常吞掉会是一个馊主意。但是这种行为可比草率结束程序或发生不明确行为带来的风险好。
对于异常处理,我们最好是提供一个普通函数(非在析构函数中)来执行这个操作。
条款九:绝不在构造与析构过程中调用virtual 函数
1 | class Transation { |
对于b来说,作为子类初始化时。一定会对基类成员进行先初始化,但是在基类构造函数最后一行调用了virtual logTransaction,这也是引发错误的点。那么这里就导致b使用了错误版本的logTransaction()。这直接导致的结果是,virtual 将不再是virtual 函数,这也是一条通往不归路的直达列车。
但是如何确保derived class 对象不会调用错误的函数版本? 确保构造与析构都没有调用virtual 函数,而它们调用的所有函数也都服从这个约束。
条款十:今operator= 返回一个reference to * this
为了实现连续赋值,赋值操作符必须返回一个reference指向操作符的左侧实参;其标准的赋值形式,同样也适用于所有的赋值相关的运算,代码如下:
1 | Widget& operator=(const Widget& rhs) { |
条款十一:在operator= 中处理”自我赋值”
自我赋值的存在,会在你不注意时引诱你进入一个”在停止使用资源之前意外释放了它”的陷阱。我们以下面代码为例,进行一个理解:
1 | class Bitmap {...}; |
* this与rhd可能是同一个对象。那么不仅删除了当前对象的bitmap,也删除了rhs的bitmap。那么最后 * this 发现自己指向一个已被删除的对象。
为了阻止这种错误,我们可以在这个最前面添加一个”证同测试”,达到检测的目的。具体如这条语句所示:if (this == &rhs) return * this;。由于证同测试使用次数较低,因此,我们使用copy and swap
技术。其实现手法如下:
1 | class Widget { |
条款十二: 复制对象时勿忘其每一个成分
如果为class添加一个成员变量,你必须同时修改copying函数,同时也需要修改class中的构造函数。
我们在编写一个copying函数,我们就需要确保:1、复制所有local 成员变量,2、调用所有base classes内的适当的copying 函数。由于大量的copying函数几近相似,有人为了避免代码的重复,就会在一个copying 函数中调用另一个copying函数,这样可能还是无法达到你的目标。对于copy assignment操作符调用copying 函数以及它的反调用,都是不合理且没有意义的。这里最好的办法就是,新建一个成员函数供两者调用,这样就可以安全消除代码的重复问题了。