Keep Calm and Carry On

Effective CPP

一、习惯CPP

1. C++是一个语言联合

可以将 C++ 看为四个部分的组合,第一是 C,也就是 C 语言的基本内容;第二是 Object-Oriented C++,也就是面向对象的部分;第三是 Template C++,也就是 C++ 的泛型编程;第四是 STL,是 C++ template 程序库。

2. 尽量用 const,enum,inline替换 #define

能使用编译器的地方尽量不要使用预处理器,因为类似于这样的宏 #define PI 3.14,首先会被预处理器处理,用要给数字替换掉,而不会写入文件的 symbol table,后面如果有关于这个错误信息可能编译器只能给出一个数字 3.14,而不是有意义的符号 PI。而使用常量 const double PI = 3.14替换宏,这样 PI 这个符号会记录在 symbol table 中。同样的,类似于长得像函数的宏,则用一个 inline 的函数替换。

3. 尽可能使用 const

const 出现在星号左边,表示被指物是常量,const出现在星号右侧,表示指针本身是常量,如果出现在两侧,表示被指物和指针都是常量。
关于函数的const:

  • 函数返回常量:有时候你不希望函数的返回值被修改,比如被赋值;
  • const成员函数:1. const 使得函数接口容易被理解,让人知道哪一个函数会改动对象,哪个不会;2. const对象只能调用const成员函数。
  • 当 const 和 non-const 成员函数有着一样的实现时,用 non-const 成员函数调用 const 版本可以避免代码重复,反过来则不行,因为 const 成员函数相当于承诺不改变对象,一旦调用了 non-const 成员,则无法保证这个承诺。至于为什么const对象只能调用 const 成员函数?这也是很理所应当的。
  1. 确定对象被使用前已经初始化
    未初始化变量在内核中常常会泄露信息,哪怕在应用层,也会发生无法预期的事情。赋值(assignment)和初始化(initialization)是两个不同的事情:C++规定,对象的成员变量的初始化动作发生在进入构造函数本体之前,如果成员变量没有在构造函数的初始化列表初始化,进入函数体后的赋值,看起来效果是一样的,但是他们确实是两个不同的东西。在时间上来说,初始化发生的更早。初始化的效率更高。赋值版构造函数首先调用 default constructor 为成员变量设置初始值,然后进入函数体做赋值动作,这样看来 default constructor 做的一切都白费了;而使用初始化列表的版本,则分别调用成员变量相对应的拷贝构造函数进行构造。构造函数的初始化列表中,无论成员变量出现的顺序是什么,最后都是按照变量在类中的声明顺序进行初始化的,因此为了避免迷惑,最好按声明顺序写初始化列表

二、构造/析构/赋值运算

5. C++会为你编写并调用哪些函数?

  • 空类?当你编写了一个空类,类体什么都不写,那么这个 empty class 真的是一个 empty class 吗?当编译器处理后,如果一下这些函数被调用,编译器会默默为你写:default constructor、copy constructor、copy assignment and a destructor。当然,如果你已经有了别的什么 constructor 了,编译器会觉得不需要为你生成一个 default constructor。

6. 如果不想使用编译器自动生成的函数,那么就应该明确拒绝

  1. 如果不想这个类被拷贝,也就是希望这个类的对象不支持 copy constructor 或者 copy assignment,仅仅是不声明 copy constructor 或者 operator= 是不足够的,因为编译器会为你生成一份。要实现这样的想法,关键在于编译器生成的代码是 public 的,因此你只需要将你不需要的功能声明为 private,并且不去实现他们,这样即可以阻止编译器给你生成一份,同时也能阻止对象调用这些功能。如果有用户企图调用这样的函数,链接器会报错。当然另一个办法是写一个用来被继层的基类,将基类的 copy constructor 和 copy assignment 声明为 private,然后让你的对象继承基类即可,如果不小心在 member function 或者 friend function调用这两个函数,编译器会报错。

7. 为多态的基类声明一个 virtual 析构函数

在 C++ 中,当 derived class 对象经由一个 base class 指针删除,而该 base class 带着一个 non-virtual 析构函数,这样的结果是未定义的——实际中通常会使得对象的 derived 部分没有被销毁。解决这种局部销毁对象的一个方法是:给 base class 声明一个 virtual destructor,这样当用 base class 指针删除 derived 对象时,就能销毁整个对象。
virtual 函数

  • virtual 函数的目的就是为了是的 derived class 得以被客制化,任何带有 virtual 函数的类也应该有一个 virtual 析构函数。

  • 要实现出 virtual 函数,对象必须携带某些信息,使得系统可以在运行时决定哪个 virtual 函数该调用,这个信息就是由 virtual table pointer 指向的 virtual table;virtual table 是一个由函数指针构成的数组。每一个带有 virtual 函数的 class 都有一个相应的虚表指针,当对象调用某一个 virtual 函数,实际被调用的函数取决于该对象的虚表指针指向的虚表的表项——具体指向哪个表项是由编译器决定的。

  • 上一点是 virtual 函数的实现细节,其实不是那么重要,重要的是为了存储多余的这个虚表指针,会增大对象的内存占用,因此不应该为所有的 class 的析构函数都声明为 virtual,一个经验是:只有当 class 至少包含一个 virtual 函数才为它声明一个 virtual 析构函数。

  • STL 中的 string、vector、list、set 等的析构函数都是 non-virtual 的,因此最好不要将它们当作基类。C++ 没有类似 Java 那样的 final class 的声明“禁止派生”的机制,如果有的话能将他们声明为不可派生就好了。

  • 纯虚函数:如果你希望一个类是抽象类,也就是不能创建这个类的对象,但是又没有合适的函数声明为纯虚函数,那么可以将析构函数声明为纯虚函数。

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

    // 还要提供一份定义(实现)
    A::~A() { }
  • 析构函数的运作方式:最深层派生的那个类的析构函数最想被调用,然后依次是每个基类的析构函数。

  • 类的设计如果不是为了作为基类,或者不是为了具备多态性,就不应该声明 virtual 析构函数。

8. 不要让析构函数抛出异常

如果一个被析构函数调用的函数可能抛出异常,那么析构函数应该将异常捕获,然后处理,或者结束程序,而不应该抛出。考虑如下的情况:

1
2
3
4
5
6
7
8
9
class A {
public:
// ...
~A() { ... } // 这个析构函数可能抛出异常
void do_something() {
vector<A> Avec;
//... // 函数结束后 Avec 被自动销毁
}
};

如果析构函数可以抛出异常,那么当 Avec 被销毁时,应该逐一调用容器中对象的析构函数,析构第一个对象时抛出异常,可能导致后面的对象无法析构,导致内存泄漏。

9. 不要在构造函数和析构函数中调用 virtual 函数。

当你试图在构造函数中调用一个 virtual 函数,比如现在在一个基类的构造函数中调用了本基类的虚函数,现在需要创建一个派生类对象,由于基类部分更早构造,因此在还没有创建出派生类对象的时候就会由于基类构造函数被调用而导致当中的虚函数被调用。诡异的点在于,这个时候虚函数就已经不是虚函数了,因为它被调用了。哪怕这个时候的虚函数下降到了派生类这一层,但是在基类部分还是构造的时候,派生类部分还没有开始构造的,他的成员变量都是没有初始化的,而又由于几乎任何成员函数都会用到成员变量,这不就是使用了未初始化变量了!因此C++绝对不会让这个时候的虚函数下降到派生类这一层,因此不会出现你想要的结果。

10. 令 operator= 返回一个 reference to this*

这个规则不仅仅适用于 = ,同样的适用于其他形式的赋值操作符的重载,+=,-=,/=等。

11. 要在 operator= 中处理自我赋值

有时候会出现自我赋值的情况:

1
2
A = A;
A[i] = A[j]; // 潜在的,i == j

如果没有处理自我赋值,可能由于两个指针指向同一个位置而出现空指针解引用的漏洞:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 如果this和rhs都指向同一个对象,那么rhs->table会指向被销毁的对象
A& A::operator=(const A& rhs) {
delete this->table;
this->table = new table(rhs->table);
return *this;
}

// 首先判断是否相同
A& A::operator=(const A& rhs) {
if ( this == &rhs ) return *this
delete this->table;
this->table = new table(rhs->table);
return *this;
}

// 或者先记住原来的
A& A::operator=(const A& rhs) {
table *origin = this->table;
table = new Table(rhs->table);
delete origin;
return *this;
}

12. 拷贝对象时,不要忘记每一个部分

  1. copy constructor 和 operator= 这两个拷贝函数应该要在做拷贝的时候确保拷贝了对象的所有元素,包括:1. 对象内的所有成员变量;2. base class 的所有成分;
  2. 不要试图让 copy constructor 调用 operator=,这是应为 operator= 是用在已经初始化过的对象身上的,还在构造对象的过程中,当前对象还没有被初始化;同样的也不要试图让 operator= 调用 copy constructor,这是因为相当于试图构造一个已经存在的对象,是不合理的。如果这两个函数又相同的部分,应该单独写一个 private 函数 init(),分别让他俩调用即可。

三、资源管理

所谓资源就是操作系统提供给程序适用的东西,一般来说有借有还,比如内存、文件描述符、互斥锁、图形界面中的字体、笔刷、数据库连接、网络socket等。

13. 以对象的形式管理资源

有一个这样的场景:一个类A,一个工厂函数 createA(),以及一个使用 A 对象的函数 f():

1
2
3
4
5
6
7
8
9
10
class A { ... };
A* createA() {
...
return ptr_to_objectA;
}
void f() {
A *a = createA();
...
delete a; // 在退出函数前应该负责将对象a删除
}

如果函数 f() 中在 delete 语句之前存在 return 语句或者 goto 语句,那么这个函数可能有机会永远不会执行最后的 delete 语句,造成内存泄漏。也许良好的编程习惯可以避免这个问题,但是如果是一份在维护中的代码,可能后面还是会有人不了解基本的安全特性,在中间添加 return 语句等,还是很容易出现问题。为了保证资源总是被回收,将资源放进对象中,当控制流离开函数 f(),对象的析构函数会自动释放这些资源,确保资源被释放。智能指针 auto_ptr 正是这种思想的产物,当 auto_ptr 指向的对象离开了作用域,对象的析构函数就会被调用。auto_ptr 使用的一个问题就是不要让多个 auto_ptr 指向同一个对象,因为这会使得一个对象被删除多次发生 double free。因此 auto_ptr 有一个性质:如果通过 copy constructor 或者 copy assignment 拷贝 auto_ptr,原来的指针指向 nullptr,复制得到的 auto_ptr 会获得资源的唯一拥有权。
正因为如此,auto_ptr 不是一个更好的资源管理者,shared_ptr 才是,因为shared_ptr 允许多个指向指向同一个资源。shared_ptr 采用引用计数的方法记录指向资源的指针的数量,类似于 JVM 的垃圾回收机制,但是还是无法打破环状引用的问题。

14. 在资源管理类中小心 coping 行为

  • 有时候你不想资源对象被复制,那么就禁止资源对象使用 copy constructor 或者 assignment(上面有一条,声明为private);
  • 有时候你希望资源的最后一个使用者用完后销毁资源,那么可以使用引用计数法;

15. 在资源管理类中提供访问指向资源的函数

就类似于 shared_ptr 和 auto_ptr 的 get() 函数,返回一个指向内容的原始指针,这种方式是显式转换,会更安全,但是隐式转换会更方便。

16. 成对使用 new 和 delete 时要采用相同的形式

这里的意思是编译器有时候不知道你传递的要 delete 的指针指向的是单一对象还是一个对象的数组,因此程序员要告诉编译器,当你 new 的时候使用了 [] ,那么 delete 的时候也要使用 [],否则都不用。operator delete 的工作分为两部分,第一部分是调用对象的析构函数析构,第二部分是才是释放内存。

17. 用独立的语句将 new 出来的对象存储到智能指针

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 某个函数func()
// 某个对象A
// 某个指向A的智能指针
// 某个处理对象A的函数processA(std::shared_ptr<A> ap, int f);

// 如果这样调用函数,直接编译不通过,因为std::shared_ptr的构造函数需要一个 raw pointer,而且是一个 explicit 的构造
// 函数,不能隐式转换
processA(new A, func());

// 这样调用可以通过编译
// 但是可能会出现内存泄漏, 因为这个函数的参数事实上分为三步
// 1. 调用 func()
// 2. 执行 new A
// 3. 调用 shared_ptr 构造函数

// 问题在于这三个的执行顺序可能是未知的,就是不确定的,可能会有这么一个执行序列:2 1 3
// 如果在调用 func() 的时候出现异常,new A 返回的指针将会遗失,因为还没来得及放入到 shared_ptr 中
// 最后导致无法被释放,内存泄漏
processA(std::shared_ptr<A>(new A), func());


// 这样分开写很安全:
std::shared_ptr<A> pa(new A);
process(pa, func());

四、设计和声明

C++ 接口(interface)设计声明的一些准则。

18. 让接口容易被正确使用,不易被误用

理论上来说:一旦某个用户使用了某个接口但是没有获得他预期的行为,那么这个代码就不应该通过编译,一旦通过了编译,接口的行为就应该是用户需要的。(很理想)

  • 定义新的数据类型

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    class Date {
    public:
    // 容易被误用
    Date(int month, int day, int year);
    ...
    };

    struct Day {
    explicit Day(int d) : day(d){ }
    int day;
    };
    // Month、Year类似

    Class Date {
    public:
    Date(const Month &m, const Day &d, const Year& y);
    };

    // 可以限制使用
    Date d(Month(3), Day(3), Year(2020));
  • 限制类型内什么能做,什么不能做,也就是使用 const 修饰

19. 设计 class 犹如设计 type

  • 新的 type 应该被怎么创建和销毁?
    决定构造函数和析构函数甚至是内存分配和释放函数(operator new, operator new[], operator delete, operator delete[]);
  • 对象的初始化和对象的赋值有什么区别?
    决定 构造函数 和 operator= 行为,初始化和赋值对应的是不同的函数;
  • 新 type 如果被 pass by value 意味着什么?
    决定了 copy constructor 的行为,pass by value 会调用 拷贝构造函数
  • 什么是新 type 的合法值?
    对类的成员变量来说,通常只有某些数值集合是有效的,该是 unsigned int 的时候就不该是 int,同时成员函数,特别是构造函数,赋值操作符重载,setter 都要进行必要的错误检查;
  • 新 type 需要继承自什么类,或者需要被继承吗?
    决定是不是被其他类束缚,特别是其他类的函数是 virtual 或者 non-virtual 都有不同的影响,后者则需要考虑好析构函数是不是要声明为 virtual;
  • 新 type 需要什么样的转换?
    如果你希望 T1 可以被隐式转换为 T2,那么久要在 class T1 内部写一个类型转换函数 operator T2,或者在 class T2 内写一个非 explicit 的可被单一实参调用的构造函数。
  • 什么样的操作对新 type 来说是合理的?
    决定声明哪些成员函数,那些是成员函数。
  • 新 type 有哪些成员变量?
    哪些成员变量是 public,哪些是 protected,那些是 private 的。
  • 新 type 是不是很一般化?
    或许你不是想声明一个新 type ,而是想定义一个 type 家族,那么应该声明一个新的 class template。

20. 尽量用 pass-by-reference-to-const 替换 pass-by-value

默认情况下 C++ 是以 by value 的方式传递参数,这样函数获得的参数实际是实参的一个副本,类似的 caller 获得的也是返回值的一个副本,这些副本是由对象 copy constructor 复制的,函数执行结束后,复制得到的副本会被析构,这样就多了一次构造,一次析构,使得 overhead 较高。
这条规则不适合内置类型和 STL 的迭代器和函数对象,对这些家伙来说,pass-by-value 往往更合适,这是因为编译器一般将迭代器实现为指针(比如vector的迭代器就是 raw pointer),无论是按值传递还是按引用传递,传递的数据量都一样多,但是如果按引用传递,在获取数据的时候还需要解引用才能获取到最终的数据。

21. 必须返回对象时,不要想着返回对象的引用

当返回 pointer 或者 reference 指向一个 local stack 对象,这个对象会被销毁,造成 user after free;
当返回 pointer 或者 reference 指向一个 head-based 对象,无法保证这个对象由谁来销毁,容易造成内存泄漏;
当返回 pointer 或者 reference 指向一个 local static 对象,会造成多个指针都指向同一个对象;
因此,有些构造或者析构的成本在安全面前还是难以避免的,直接返回一个对象就好了。

22. 将成员变量声明为 private

  • 理由1:一致性原则,如果有 public 的成员变量,那么用户需要浪费声明去判断是个成员是函数还是变量,到底要不是加个括号呢,真是个恶心人的问题。保持一致性即可,凡是对象能够访问的都是函数!
  • 理由2:更加精确的控制成员变量,如果成员变量是 public 的,那么谁都可以访问(读写),那么就无法设计不同权限的成员变量(只读,只写,读写),这其实很要必要;
  • 理由3:封装性!封装性的意思就是将某些东西封装起来,使得看得到的人少了(只有 member function 和 friend function 可以访问),那么当要修改代码,就不会造成那么大面积的修改。

23. 宁愿使用 non-member non-friend 函数,而不是 member 函数

面向对象要求数据应该尽可能封装,那么是 member 函数还是 non-member non-friend 函数的封装性强呢?封装性愈强,就意味着能够看到的人愈少,我们需要封装性的原因正是减少看得到的人,使得一旦我们改变 class 的代码,只影响有限的用户。比如,一旦成员变量不是 private 了,那么有无限的函数可以访问成员变量,一旦要删除、更名成员变量,影响的范围会很大。因此相比于 member function,non-member non-friend 的封装性更强,因为后者没有增加能够访问 class 内部数据的函数。
另一方面,你不想让这个函数成为某个类的 member function,也不意味着这个函数应该是另一个类的 member function。这和 Java 不同,Java 习惯于将所有函数定义在 class 中。更好的做法是和 STL 类似:将所有的 non-member 放置于同一个命名空间 STL 中,同一个命名空间是可以分为不同的文件的,比如头文件 等,但是 class 却不行,class 必须整体定义!如此来说,命名空间对于扩展性有更大便利性。

24. 如果所有参数都需要进行类型转换,那么此函数应该声明为 non-member 函数

如果一个函数不适合做 member 函数,那么他的反面不应该是 friend 函数,而是 non-member 函数,不能应为该函数不能成为 member 就自动让他成为 friend,因为有时候这个函数其实仅仅通过对象的 public 成员就可以完成功能,因此能不成为 friend 就没有必要。

25. 如何写一个不跑出异常的 swap 函数?

STL中的实现:

1
2
3
4
5
6
7
8
namespace std {
template<typename T>
void swap(T& a, T&b) {
T temp(a);
a = b;
b = temp;
}
}

这个实现看起来没什么问题, 可以将 a 和 b 的内容互换。但是如果 a 和 b 对象中包含的大量的其他对象, 比如包含着一个 vector。std::swap() 会每个对象都复制。这个时候你最好能够自己写一个 swap 函数。

五、实现

26. 尽可能延后变量定于的出现时间

因为只要一个变量被定义就必然要有构造的消耗,有些变量可能并不会一定被用到, 比如函数被提前返回, 或者抛出异常而导致这个变量可能不会被用到。此外,由于对象直接用值初始化(非默认构造函数)的效率比先用默认构造函数构造对象再为对象成员赋值要高, 因此不仅仅要 延后变量定义的时间,最好能够延后到能够确定给要定义的对象初始实参为止。

27. 尽量少做类型转换(cast)

C风格的转型:

1
2
(T)expression
T(expression) // 函数风格的转型

C++提供了四种新的转换方法

1
2
3
4
const_cast<T>(expression)
dynamic_cast<T>(expression)
reinterpret_cast<T>(expression)
static_cast<T>(expression)

const_cast:将对象的常量性质溢出(cast away the constness)
dynamic_cast:安全向下转型,用来决定某对象是属于某继承体系的某个类型。
reinterpret_cast:执行低级转换,例如将一个 pointer to int 转换为一个 int;
static_cast:用来进行强制隐式转换,例如将 non-const 对象转换为 const 对象(反之不行,只有 const_cast 可以),将 double 转为 int,将 pointer-to-base 转为 pointer-to-derived等。
C风格的转换也还能用,但是尽量使用C++新的转换方法,因为一旦出现类型转换错误,无论是使用工具还是人工阅读代码都能更快定位。有人以为转换其实什么都没有做,仅仅是告诉编译器把某种类型当作另一种类型。但是这种观点是错误的,因为转换真的令编译器编译出运行期间执行的代码,比如很简单的整型和浮点型就是不同的存储方法。dynamic_cast 的效率通常来说很低,因为在向下转型的过程中,需要不断地比较类名, 比如一个五层继承体系地类中做向下转换就需要比较字符串五次。如果必须要向下做转型,应尽量避免,先想办法使用其他方案。

28. 避免返回 handles 指向对象的内部成分

比如:一个成员函数返回一个执行内部某个数据的引用!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Point {
public:
Point(int x, int y);
void setX(int _x);
void setY(int _y);
...
};

struct RectData {
Point up_left;
Point down_right;
};

class Rectangle {
...
// 这两个函数返回执行内部数据的引用
Point& upLeft() const { return pData->up_left; }
Point& downRight() const { return pData->down_right; }
private:
std::shared_ptr<RectData> pData;
};

让我们来看看如果一个 const 函数返回了执行内部数据的引用(reference,指针,迭代器都是指向实际对象的,都是所谓的handles),至于为什么会返回一个引用?因为返回引用可以减少一次拷贝构造函数调用,提高效率。想法可能是好的,但是十分危险。const 函数的本意是这个函数不会修改对象的内容,但是由于返回了内部数据的引用,这个引用可以调用 setX(), setY(), 进而修改的对象的数据。也就是说,返回一个指向内部数据的 handle 事实上降低了对象的封装性。

29. 异常安全性???

一个例子:有一个函数用来改变背景,且被应用到多线程环境中,当异常被抛出时,资源可能会被泄漏(没有释放申请的资源),或者数据被破坏(一个指针指向一个被删除的对象)。这两个点也是带有异常安全性的函数在异常被抛出时出现的问题。对于资源泄漏问题,可以像条款13说的一样用对象的形式管理资源,当离开函数可以自动释放资源。

30. inline 函数

在编译阶段,inline函数使用函数的定义体来替代函数调用语句,降低函数调用花费的时间,比如函数调用栈的维护和恢复等。当然,不用想也知道,这么做会让程序的目标文件更大,而且哪怕你写了inline,这也对编译器来说也仅仅是一个建议,编译器如果觉得函数体太大而不利于作为inline处理,就会作为普通的函数处理。需要注意的是:

  1. inline和预处理的宏不一样,宏是强制性的内联展开,可能会污染所有的命名空间和代码。
  2. 所有在class中定义的成员函数都是默认inline的,不用显式使用inline关键字;
  3. 虚函数不允许被inline
    inline函数一般被放置在头文件中,而且templates一般也会被放置于头文件,但是这两者没有强关联性,不是说templates就是inline的。

31. 将文件的编译依赖关系降到最低

类似于java的,对于一个类分割位两个类,一个只提供接口,另一个负责实现该接口,比如一个叫Person,另一个叫PersonImpl。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <string>
#include <memory>

class PersonImpl;
class Date;
class Address;
// 这些都是前置声明,对于当前的类(接口)person来说,哪怕这些前置声明修改了
// 也不需要重新编译person类,因为在编译器在编译person类时不需要这些类的实现
// 仅仅知道这些类是用户自定义的类就够了,在链接阶段再去找到他们的实现即可
// 因此他们的变化不会导致person类要重新编译

class Person {
public:
Person(const std::string& name, const Date& birthday, const Address& addr);
std::string name() const;
std::string address() const;
...
private:
std::tr1::shared_ptr<PersonImpl> pimpl; 一个指向Person的实现类的指针,是pointer to implementation的意思
};

这样写就可以实现“接口和实现的分离”,那些前置声明的class的任何修改都不会需要Person重新编译,而且由于用户无法看到Person的实现细节,也不会写出取决于Person实现细节的代码来,进一步降低耦合性。这种分离的关键在用声明的依赖性替换定义的依赖性

六、继承与面向对象

32. 确定你的public继承是一种is-a关系

以C++进行面向对象编程,最终要的一个规则就是:public继承意味着is-a关系,这就意味着如果class D以public的形式继承了class B,就是告诉读者或者编译器,每一个D对象同时也是一个B对象,反之不成立。B更加一般化,而D更加特殊化。类之间的关系除了is-a之外还有has-a(有一个)以及is-implemented-in-terms-of(根据某物实现出)。

33. 避免覆盖继承而来的名称

我们知道,内层作用域的名称会覆盖外围作用域的同名名称,因为编译器先在内层作用域搜索一个名字。

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
27
28
29
class base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};

class derived: public base {
public
virtual void mf1();
void mf3();
void mf4();
...
};


// 在这里derived中的名字mf1和mf3会覆盖所有的base中的同名函数,哪怕参数不用。比如这样的调用将会是错误
derived d;
int x;
double y;
...

d.mf1(x); // 错误,derived::mf1覆盖了base::mf1
d.mf3(y); // 错误,derived::mf3覆盖了base::mf3

为了让Base class内名为mf1和mf3的所有东西都在Derived作用域中可见,可以使用using声明。

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
27
28
29
30
31
class base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);
...
};

class derived: public base {
public
using Base::mf1;
using Base::mf3;
virtual void mf1();
void mf3();
void mf4();
...
};


// 在这里derived中的名字mf1和mf3会覆盖所有的base中的同名函数,哪怕参数不用。比如这样的调用将会是错误
derived d;
int x;
double y;
...

d.mf1(x); // 错误,derived::mf1覆盖了base::mf1
d.mf3(y); // 错误,derived::mf3覆盖了base::mf3

34. 区分接口继承和实现继承

  1. pure virtual函数只具体指定接口继承,通常来说它没有实现,会使得基类是一个抽象类,无法创建对象,它必须被任何继承了基类的派生类实现。这就导致声明一个pure virtual函数的目的就是为了让Derived class只继承函数接口(因为都没有实现)。
  2. impure virtual函数的目的则是为了让derived class继承该函数的接口和默认实现。一般来说,impure virtual函数会有一份实现代码,derived class可能override这个函数,也可以使用函数的缺省定义。
  3. non-virtual函数则是为了让derived class继承函数的接口以及一份强制性的实现,这些实现无法被派生类改变。

35. 考虑virtual函数以外的其他选择

  1. Non-Virtual Interface实现Template Method模式
    在这种实现中,虚函数是private的,用一个non-virtual函数去调用private的虚函数,着用虚函数可以在derived class中重新定义。这样的优点是在那个non-virtual函数中,调用private的虚函数前后可以做一些事前工作以及一些事后工作,比如锁定互斥锁、生成日志项、验证函数先决条件、解锁互斥锁等等。这些都是直接调用虚函数无法做到的。
  2. 好吧还以其他的一些方案,反正虚函数替换方案还是挺多的,但是各有优缺点。

36. 绝对不要重新定义继承的来的non-virtual函数

其实这很简单,因为如果在派生类重新定义了一个非virtual函数,那么基类的这个函数就会被覆盖。与虚函数的动态绑定不同,非虚函数是静态绑定的,对于同一个对象,用执行基类的指针和指向派生类的指针调用函数会得到不同的行为,这是我们所不希望的。因此,如题!

37. 绝不重新定义继承而来的缺省参数值

对于继承而来的带有缺省参数值的virtual函数,虽然virtual函数是动态绑定,但是缺省的参数是却是静态绑定。
静态类型:变量在程序中被声明时使用的类型,比如基类指针,它的动态类型是真正指向的对象的类型;
动态类型:目前所指向的对象的类型,动态类型决定了调用一个virtual函数时究竟调用的哪一份实现代码;

38. 通过复合塑模出“has-a”或者“根据某物实现出”关系

所谓复合就是某种类型对象内部某含另一种对象,就是has-a关系。

1
2
3
4
5
6
7
8
9
10
11
12
class Address { ... };
class Phone { ... };
...

Class Person {
private:
std::string name;
Address addr;
Phone phone_number;
public:
...
}

is-implemented-in-terrms-of指的就是在实现域,用某种对象实现出另一种对象。

39. 谨慎使用private继承

对于public继承,由于关系是一种is-a关系,因此必要时候,比如为了函数能够成功调用,编译器可以将派生类转换为基类。
private继承:

  1. 编译器不会自动将一个派生对象转换为一个基类对象;
  2. 由private base class继承而来的所有成员在derived class都会编程private属性,即使他们在基类中原本是protected或者public属性。
    private有什么意思呢?事实上就软件设计层面来说,private没有什么意思,它的意义仅仅在于软件实现层面。当class D以private形式继承class B,意味着为了采用class B内某些已经实现妥当的特性,而不是因为B对象和D对象有什么观念上的练习。private继承仅仅是一种实现技术,这也正是为什么继承自一个private base的每样东西都是private的,因为他们仅仅是实现的细节。同时private继承意味着部分继承,因为接口都略去了。重要的一点:private也意味着is-implemented-in-terms-of,这个实现也可以用符合来实现。

40. 谨慎使用多重继承

到底是单继承还是多继承?
一旦使用了多重继承,那么就有可能从一个以上的base class继承相同名字。

七、模板与泛型编程

泛型编程(generic program)能让人们写出的代码和处理的对象类型彼此独立,而且C++ template机制自身是图灵完备的,能够用来计算任何可计算的值。

41. 了解隐式接口和编译器多态

面向对象编程总是以显式接口(explicit interfaces)和运行时多态(runtime polymorphic)解决问题。Template以及泛型编程和面向对象不一样,泛型编程中,显式接口和运行时多态依然存在,但是重要性降低了,隐式接口(implicit interface)和编译器多态(compile-time polymorphism)有更重要的位置。

1
2
3
4
5
6
7
8
template<typename T>
void doProcess(T& w) {
if ( w.size() > 10 && w != someNastyWidget ) {
T temp(w);
temp.normalize();
temp.swap(w);
}
}
  1. 在这里参数w应该支持什么接口呢?看起来必须支持size、normalize、和swap成员函数copy构造函数、不等于比较符,这些表达式便是T必须支持的一组隐式接口。
  2. 凡是涉及到w的任何函数调用,都可能造成template的实例化,这样才能使调用成功。这些行为发生在编译器,“以不同的template参数实例化function templates”会导致调用不同的函数,这就是所谓的编译期多态。编译期多态和运行时多态不同,他们之间的差异类似于“哪一个重载函数被调用”(发生在编译期)和“哪一个虚函数被绑定”(发生在运行时)的差异。

一般来说,显式接口由函数签名(函数名、参数类型、返回类型)来构成,而隐式接口不基于函数签名,而是由会导致调用不同的函数,这就是所谓的编译期多态。编译期多态和运行时多态不同,他们之间的差异类似于“哪一个重载函数被调用”(发生在编译期)和“哪一个虚函数被绑定”(发生在运行时)的差异。

一般来说,显式接口由函数签名(函数名、参数类型、返回类型)来构成,而隐式接口不基于函数签名,而是由有效表达式(valid expression)构成。

42. typename的双重意义

在template的声明种,class和typename有什么不同?答案是没有什么不一样!
有些类型是嵌套类型,但是你有时候也不知道它到底是不是一个类型,它还有可能是一个static成员变量!

1
2
3
4
5
6
7
8
9
template<typename C>
void func(const C& containter) {
C::const_iterator* x;
...
}
// 这看起来像是在声明一个指针变量x,但是如果const_iterator是个静态成员变量呢?只是名字刚好叫这个!这或许很荒唐,但是如果有人就是这么荒唐,这就变成了一个乘法运算了!
// 为了让这个声明合理,必须告诉C++编译器说,这个const_iterator是一个类型,只需要在前面使用关键字typename即可
typename C::const_iterator * x;
// 这种方式仅仅用来验证嵌套从属类型,其他的名字不该这样使用。

42. 处理模板化基类内的名称

考虑这么一个场景:有不同的公司,编写一个程序发信息到不同的公司

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
27
28
29
class CompareA{
public:
void sendMsg(const std::string& msg);
};
class CompareB{
public:
void sendMsg(const std::string& msg);
};
class CompareC{
public:
void sendMsg(const std::string& msg);
};

...

class MsgInfor{ ... };

template<typename Company>
class MsgSender{
public:
...
void sendMessage(const MsgInfo& info) {
std::string msg;
// 生产信息
Compare c;
c.sendMsg(msg);
}
...
};

现在我们希望每次发信息前后都要记录一下日志,那么可以写一个类来做这个事情:

1
2
3
4
5
6
7
8
9
template<typename Company>
class LoggingMsgSender: public MsgSender<Company> {
public:
void sendMessageWithLog(const MsgInfo& info) {
// 发送前写log
sendMessage(infor); // 编译错误:找不到SendMessage;
// 发送后写log
}
};

为什么会找到基类的SendMessage呢?我们也没有在派生类用一个同名的函数覆盖的基类的函数!这是因为编译器看不到这个基类的函数,问题在于当编译器遇到派生类 template class LoggingMsgSender 时,并不知道它继承什么样的类,因为这其中有个参数 Company,不到后来的实例化是不会知道基类class MsgSender是看起来像什么的,因此没法办知道它是否有sendMessage这个函数。
有三种方法可以解决这个问题:

  1. 在调用函数之前加上“this->”

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename Company>
    class LoggingMsgSender: public MsgSender<Company> {
    public:
    void sendMessageWithLog(const MsgInfo& info) {
    // 发送前写log
    this->sendMessage(infor); // 编译通过
    // 发送后写log
    }
    };
  2. 使用using 声明,类似派生类同名函数覆盖基类,虽然解决的问题不同,在同名函数问题中就是被覆盖了,这个的问题是编译器不去基类作用域查找名字。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    template<typename Company>
    class LoggingMsgSender: public MsgSender<Company> {
    public:
    using MsgSender<Company>::sendMessage;
    void sendMessageWithLog(const MsgInfo& info) {
    // 发送前写log
    sendMessage(infor); // 编译通过,编译器会去基类作用与查找
    // 发送后写log
    }
    };
  3. 直接指出被调用的函数位于基类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    template<typename Company>
    class LoggingMsgSender: public MsgSender<Company> {
    public:
    void sendMessageWithLog(const MsgInfo& info) {
    // 发送前写log
    MsgSender<Companey>::sendMessage(infor); // 编译通过
    // 发送后写log
    }
    };

44. 将与参数无关的代码抽离templates

-

….
这一章我还有一些没有看
….

八、定制new和delete
多线程环境下的内存管理比较难,由于heap是一个可以被改动的全局性资源,多线程系统疯狂访问这一类资源的竞争条件会使得在没有适当的同步机制的情况下造成管理heap的数据结构被破坏。operator new 和 operator delete函数对应,而operator new[] 和 operator delete[] 对应。此外,STL容器所使用的head内存是由容器所拥有的分配器对象管理的,而不是被new和delete直接管理。

49. 了解new-handler的行为

当operator new无法满足某一个内存分配需求时,它就会抛出异常,在抛出异常之前会先调用一个用户指定的错误处理函数,这就时所谓的new-handler。为了指定这个用以处理内存不足的函数,用户必须调用中的set_new_handler。可以这么简单使用:

1
2
3
4
5
6
7
8
9
void outOfMemory() {
std::cerr<< "of out memory" << std::endl;
std::abort();
}

int main() {
std::set_new_handler(outOfMemory);
int * p = new int[1000000000000000L];
}

值得注意的是,当operator new无法满足内存申请时,他会不断调用new-handler函数,知道能够找到足够的内存。因此一个设计良好的new-handler函数必须要做以下的事情:

  1. 让更多内存可被使用:
    尽量使下一次分配动作可能成功,一个做法是,程序一开始执行就分配一大块内存,然后再new-handler被调用时将他们释放掉。
  2. 安装另一个new-handler
    如果目前这个new-handler无法取得更多内存,当它知道另一个new-handler也许可以,那么可以安装另一个new-handler替代自己,只需要调用set_new_handler即可。
  3. 卸载new-handler
    将nullptr指针传递给set_new_handler,分配不成功就会抛出异常。
  4. 不返回
    通常调用abort或者exit

九、杂项

53. 不要轻视编译器警告

如果问题严重,编译器应该给一个错误信息,而不是一个警告信息不是吗?你是不是觉得警告信息可以被忽视?不可以!