本文为学习《Effective C++》各个条款之后的一点概要式的总结。

条款 2 尽量以 const, enum, inline 替代#define

  • 宁可用编译器替代预处理器。以#define 定义的记号是不会记录到符号表中的;
  • #define 没有封装性可言。
  • enum hack。enum {tmp=5};对应的tmp一定在编译器就可以得到并且不会导致非必要的内存分配。

条款 3 尽可能使用 const

  • 调用 const 成员函数以实现孪生 non-const 成员函数。通过使用const_caststatic_cast来达到目的,优点是避免了代码重复。
  • 调用 non-const 成员函数实现 const 成员函数是错误的。因为这破坏了 const 的语义约束。

条款 5 了解 C++默认编写并调用哪些函数

  • 如果自定义了需要实参的构造函数,则编译器不会自动生成 default ctor
  • 如果 class 内部包含有带有&引用类型或者const常量类型,则编译器不会自动生成 copy assignment;因为编译器不知道该怎么处理

条款 7 为多态基类声明 virtual 析构函数

  • 每一个带有 virtual 函数的 class 都拥有一个指向 virtual table 的指针,virtual table 中包含了所有对应 virtual 函数的函数指针
  • 不要尝试继承任何标准库容器(比如std::string),因为它们都没有 virtual dtor。这会导致未定义行为
  • 没有多态性质的 base class 也不要声明 virtual dtor,比如说boost::noncopyable,virtual 并无必要,且浪费空间的
  • 如果明确了一个类具有多态性质,且作为 base class 使用,则应该声明 virtual dtor

条款 8 别让异常逃离析构函数

  • 绝对不能让 dtor 吐出异常,因为很可能会造成资源泄露。对于有可能在 dtor 中发生的异常,应该将其吞下或者提前终止程序
  • 更合适的做法是为客户代码提供一个接口,使得客户有机会去处理可能发生的异常

条款 11 在 operator=中处理“自我赋值”

核心其实就是不能让指针指向一个未获取的资源;存在 3 类方法,各有各的优势

  • 赋值之前先比较 lhs 和 rhs 的地址是否相同,如果相同,则直接返回;
  • 先记住之前本身的资源(可以设一个pOrigin指针指向旧资源),随后拷贝一份 rhs 的资源,并令 lhs 指向新资源,最后再释放掉 lhs 的旧资源(即delete pOrigin);
  • copy and swap。先拷贝 rhs 指向的资源,再令 lhs 指向的资源和这份拷贝之后的资源进行交换;
  • (虽然挺绕的,但是可以这样进行总结:形如const &的入参,是无法知晓它到底是不是一个新的资源,而operator=的语义就是要赋予新的资源,那么如果才能确保是新的资源呢?要么就是重新复制一份,要么就将&引用符号去掉,其实也是一次复制)

条款 12 复制对象是勿忘其每一个成分

  • 自行编写 copy ctor 或者 operator=是一项重大的责任,因为要考虑到各种细节。而也正是因为这样的原因,当自行编写时,编译器会认定你是一个足够强大的程序员,因此不会对自定义 copy ctor 和 operator=的不好的地方做出任何警告;
  • 确保每一个成员变量都被正确拷贝;
  • 当目标是 derived class 时,其 base class 的成员变量也要被正确拷贝。这需要通过调用 base class 的 copy ctor 和 operator=来实现;
  • 切记,copy ctor 和 operator=不能相互调用。这从语义上就行不通

条款 14 在资源管理类中小心 copying 行为

对 RAII 对象执行复制,是需要万分小心的行为,因为它涉及到的资源的最佳处理方式不甚相同;常见的方式包括:

  • 禁止复制。很多情况下这是比较科学的做法,因为行为表现的像指针这样的数据类型是不应该重复进行 delete 的;如果不禁止复制,则必须做到对指涉到的资源也进行复制;
  • 引用计数。不多说了,就是智能指针那一套;

条款 15 在资源管理类中提供对原始资源的访问

  • 诸如std::shared_ptrstd::unique_ptr都会提供get()成员函数来访问其指涉的底层资源;这不是破坏封装性,而仅仅是一种接口风格;
  • 访问底层资源的接口,一般而言就两种:①get()这样的成员函数,②隐式转换。一般来说还是 ① 更好一点,因为更安全;

条款 16 以独立语句将 newed 对象置入智能指针

  • 本条款在《Effective Modern C++》中也有讲述;
  • 核心的一点就是在单条语句内,编译器是有着重新编排执行顺序的自由的;
  • 因此,诸如std::shared_ptr<XXX> sp(new XXX);这样的语句应该单独成句,而不应该嵌入到其他语句中;
  • 其实现代 C++的话,更好的做法是使用std::make_shared或者std::make_unique;它们使用完美转发,且很安全;

条款 19 设计 class 犹如设计 type

不多说了,在编写类代码的时候多看看本条款,思考条款中列出的问题;

条款 23 宁以 non-member、non-friend 替换 member 函数

  • 要理解这个条款,就得明确 namespace 的作用:① 可以跨越多个源码文件;② 在实现类似于 utility 所提供的功能时,更具有优势(因为语义更清晰);③ 在提供了所需功能基础上达到编译依赖最低封装性最好
  • 书中所举的例子:任务是调用 class 中的三个成员函数。那么方法大致为两种:① 再写一个成员函数,内容就是调用那三个函数;② 将新的函数放在 class 的外部(非成员函数),但位于同一个 namespace 中;
  • 基于上面所陈述的原因,使用第二个方法是更好的方式

条款 24 若所有参数皆需要类型转换,请为此采用 non-member 函数

  • member 函数的反面是 non-member,而不是 friend;friend 在 OOP 中能避免则避免,因为太破坏封装性了
  • 只有当参数被置于参数列时,这个参数才是隐式类型转换的合格参与者;也就是说,当调用成员函数时,lhs 实际上没有被置于参数列中,而是 this

条款 26 尽可能延后变量定义式的出现时间

  • 应该尽可能在要用到某个变量的时候才去定义它(这很显然嘛)
  • 关于循环体中的变量的下述两种定义方式,一般情况下,除非明确知道赋值操作的消耗小于构造加析构的时候才使用第一种;因为第一种方式扩大了变量的生命期;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 第一种
{
...
Weight tmp;
for(int i = 0; i < N; ++i){
tmp = Weight(i);
}
}
// 第二种
{
for(int i = 0; i < N; ++i){
Weight tmp= Weight(i);
...
}
}

条款 29 为“异常安全”而努力是值得的

  • 所谓的异常安全函数,其实就是发生异常也不会导致资源泄露数据败坏;包括三类:
    • 基本保证:如果函数发生异常,则对应的对象不一定还能还原为调用前的状态,但至少保证还是正常可用的;
    • 强烈保证:即使函数发生异常,对象还是能够还原为原来的状态,即只有两种状态:成功调用不调用;这通常通过 copy-and-swap 来实现,即先将原来的对象复制一个副本,随后对副本执行相应的改变,如果执行成功,则原对象和副本执行 swap;如果发生异常,原对象也未发生任何改变
    • 不抛掷(nothrow)保证:即保证函数不发生异常;这通常办不到。。。只要涉及到了动态内存的分配,都是有可能发生异常的
  • 可以看出,级别越高,其实实现是越困难的,并且带来的开销也会越高;因此,应该挑选的是现实可实施下的最高等级
  • 异常安全性是遵循木桶原理的,只要函数调用了等级较低的函数,那么它的异常安全性也会降低

条款 30 透彻了解 inlining 的里里外外

  • inline在大多数 C++程序中都是编译器行为;
  • inline仅仅是一个申请,并不保证一定会内联;
  • 是否真正内联还取决于函数的调用方式;(如果以函数指针进行调用,那么就不可能被内联了);
  • inline的优势是避免调用开销,但也存在以下问题:
    • 代码膨胀:毕竟,如果在多处都调用了该函数,那么就会有多份该函数体的副本;
    • 编译依赖:如果inline函数发生了改变,那么所有客户代码都必须重新编译;反之,如果不是内联的,那么仅仅重新链接一下就行

条款 31 将文件间的编译依存关系降至最低

C++中降低文件间的编译依赖,主要就是两种手段:handle class以及interface class

  • 如果客户代码所使用的的头文件中,直接包含的是要使用的 class 的具体实现(包括各个函数定义),那么就形成了依赖关系;
  • 所谓依赖关系,就是指,只要一个 class 改变了一点点实现,那么所有使用它的客户代码都需要重新编译;
  • handle class
    • 所谓的 handle class,实际上意味着一个负责声明的 class 和一个负责具体实现的 class(假设为class Widgetclass WidgetImpl);两者的接口全部一致,而客户代码使用的是class Widget
    • class Widget 中不对任何方法进行具体实现,只声明类接口;且涉及到非基本类型的自定义类型成员变量(比如此处的class WidgetImpl),都使用前置声明(智能)指针来进行指涉;
    • 标准库组件无需也不应该被前置声明;直接#include就行;
    • 这样一来,class Widget 的头文件中不会#include任何其他的头文件(除了标准库);而这,_也就杜绝了客户代码对除了 class Widget 头文件之外的文件产生任何依赖_;
    • 至于 class Widget 的接口实现,则在其.cpp 文件中去#include "WidgetImpl",然后调用 class WidgetImpl 的接口即可;
  • interface class
    • 即类似于 Java 中的 interface,不过实现方式是定义成虚基类;面向派生谱系的多态技术;

条款 33 避免遮掩继承而来的名称

  • C++应对派生谱系中的函数调用,归根结底就是以名称为准进行匹配;
  • 无论是变量还是函数,是重载还是重写,是否是虚函数,甚至也无论函数的参数列表是什么形式,都没有任何关系;编译器只要在当前的域中找到了对应的名称,就直接结束匹配;
  • 这意味着:如果 base class 中定义了一组重载函数,而后又在 derived class 中定义了一个同名的函数,那么当用 derived class 类型(或引用、指针)来调用这个名称的函数时,基类的重载函数统统被覆盖
  • 克服这个问题的方法:在派生类中加入 using 声明:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Base{
public:
// 重载函数
void f(int);
void f();

};

class Derived : public Base {
public:
using Base::f; // OK,基类的重载函数不会被覆盖了
void f(int, int);
};

  • 如何实现仅继承部分基类接口?很简单,使用private 继承+转接函数
    • 所谓的转接函数就是派生类中的公共接口,但这些公共接口只是去调用基类的函数;
    • 基类因为被 private 继承了,所以其所有接口也就被隐藏了;

条款 34 区分接口继承和实现继承

  • 在继承谱系中,虚函数,纯虚函数,普通函数之间的根本区别就是对待接口继承实现继承的方式不同;
  • 纯虚函数:只继承接口
  • 虚函数:继承接口和一份缺省实现
  • 普通函数:继承接口和一份强制实现======》
    • 这意味着任何 derived class 都不应该重新定义 base class 中的普通函数;
    • 条款 36 就是在陈述这一点;本质上就是因为普通函数实施的是静态绑定,相同的对象会因为其指针或引用的类型的不同而执行不同的函数体(有可能是基类的函数体,也可能是派生类的函数体);这造成了不确定性;

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

  • 虽然虚函数实行的是动态绑定,但虚函数(实际上是任何函数)中的参数缺省值却是静态绑定的;
  • 这意味着函数的参数缺省值不应该被重新定义;理由还是一样的,这会因为指针或引用的类型不同而造成不确定性;
  • 如果需要为虚函数定义参数缺省值,则更好的做法是:
    • 定义一个普通函数,有缺省值;
    • 实际的虚函数变为 private,且无缺省值;
    • 使用普通函数去调用虚函数;
    • 这样就避免了代码在派生谱系中的依赖性;

条款 38 通过复合塑膜出 has-a 或“根据某物实现”

  • 关键就是理解复合(Composition)二字;复合包含应用域和实现域两种关系;
  • 应用域:即把一个 class 作为组件;比如说class People的一个组件是class PhoneNumber;这就是所谓的 has-a 关系;
  • 实现域:即某个 class 需要通过另一个 class 进行实现,但两者并不存在完美的继承关系;比如说通过一个std::vector<int>来实现一个class Stack<int>;这就是所谓的 Is-implemented-int-terms-of 关系

条款 39 明智而审慎地使用 private 继承

  • private 继承并不具备“软件设计”层面的意义,其仅仅是一种“软件实现”的技术;
  • 条款 38 中已经阐述过”Is-implemented-in-terms-of”关系,事实上,private 继承也是这种意义;
  • “private 继承”和“复合”的区别就在于:
    • 一般情况下,能使用复合就使用复合;
    • 只有当明确是 Is-implemented-in-terms-of 关系的同时,需要重写基类的虚函数或者访问 protect 变量时,才使用 private 继承;因为这是复合无法做到的;
    • EBO(empty-base-optimization):C++中一个空类的 size 不等于 0,而是 1;而继承一个空类不会加大 size;这就是 private 的另一个优势;

条款 40 明智而审慎地使用多重继承

  • 总的来说,多重继承还是有用的,但却是也存在很多的限制;
  • 条款中所涉及的“虚继承”概念是比较重要的:
    • 多重继承很可能会发生所谓的菱形继承:即某一个基类和某一个派生类之间存在多条继承路径;
    • 如果使用非虚继承的话,派生类将会保存同一个基类的多个副本;但实际上一份副本就足够了;这造成了空间浪费;更糟糕的则是因为多份副本导致的命名冲突;
    • 虚继承是解决这个问题的唯一方法;它使得派生类可以只保留基类的一份副本;
    • 但虚继承也有自己的缺点:最突出的就是加大了运行时消耗;因为采取虚继承的话,class 的 size 和内存模型就只能在运行期才能知晓了;(C++中虚函数、虚继承内存模型 - 知乎 (zhihu.com)

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

  • 基于模板的泛型编程其实也隐含着“接口”的概念,但是是隐式的。这和派生谱系中的接口机制有很大不同;
  • 隐式接口是基于:必须满足模板代码中隐含的一组约束。比如书中给出的例子:if(w.size() > 10 && w != someNastyWidget){...}w的类型为typename T,那么就必须满足:if 中给出的表达式能够转换为 bool 类型。
  • 所谓的编译器多态就是:编译器根据隐式接口去决定需要(生成)调用哪一个重载函数以及具现化模板。

条款 42 了解 typename 的双重意义

  • 当用于模板参数的时候,typaname 和 class 没有区别;
  • 如果某个名称是嵌套从属名称(nested-dependent-names),即它的性质(是变量名还是类型名)需要由模板参数来决定,那么如果它确实是一个类型名的话,就需要加上typename;(因为编译器不知道它到底是什么东西);
  • 萃取器:即 traits,通过模板以及模板偏特化技术,将传递进去的类型的一些相关特征给萃取出来。比如说typename std::iterator_traits<iteT>::value_type表示的就是 iteT 类型的迭代器所指涉的元素类型;萃取器的优势在于任何类型的迭代器(甚至是原生指针)都能萃取出想要的特征;

条款 43 学习处理模板化基类的名称

  • 模板化基类(templatized-base-class):也就是说继承来的基类是一个模板,其具体是哪一个类暂时无法确定;
  • 当模板化类继承自一个模板化基类时,编译器就默认基类中的所有名称是无法得知的;除非显式指出
  • 编译器之所以这样做,是因为由于模板偏特化以及全特化的存在,使得模板化基类不一定会拥有模板中所写的所用名称;
  • 显示指出的方法有 3 类:使用this->name;使用using BaseClass<T>::name;;显式调用BaseClass<T>::name;其中,第 3 种方法会丧失动态绑定特性,因此不是很推荐;

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

  • 如果模板类中的某些函数与模板参数没有关系,那么多个具现化的实体类则会拥有相同的函数体,这无疑使得目标码变得冗余;
  • 更好的做法是将这些与模板参数无关的代码抽离出来,变成基类代码或者其他,然后不同的模板的具现化 class 去共同调用这些相同的代码(此时这些代码就只有一份实体了);
  • 当然,这样也会存在一定问题。简而言之,谁好谁坏,还是得由具体的运行环境去决定;

条款 45 运用成员函数模板接受所有兼容类型

比如对于如下的一个模板类,很多时候,我们可能需要使用TmpDemo<int>去初始化一个tmpDemo<double>对象。这完全是合理的,但问题是,在模板编程的世界里,TmpDemo<int>TmpDemo<double>是完全没有任何关系的。或者可以直接在模板类中定义这样一个构造函数,但如果遭遇了其他的需求呢?比如说 int 变为了 char,又或者,现在的 typename 是一个继承谱系中的各种类型。显然,单一的成员函数是解决不了问题的。

1
2
3
4
template <typename T>
class TmpDemo{
// ...
};
  • 成员模板函数是解决这个问题的唯一方法;在成员函数中再声明 typename,来让编译器来处理各种需求;
  • 泛化构造函数是成员模板函数的一种,它解决的是通过TmpDemo<U>来初始化TmpDemo<T>的问题;
  • 即使声明了泛化构造函数,也还是要去自定义拷贝构造函数,这一点需要注意;

条款 46 需要类型转换时请为模板定义非成员函数

  • 该条款和条款 24 的思想是一致的,也就是当函数的所有参数都涉及隐式转换时,它最好是一个非成员函数(因为 this 是无法转换的);
  • 和条款 24 的不同之处在于,本条款涉及到的是模板类;即,某个函数的各个参数是模板类型;
  • 很显然,这种函数也需要定义为非成员函数;
  • 不同之处在于:因为涉及到了模板,那么在进行函数模板的模板参数推导时,绝对无法进行隐式转换,比如说对于如下的代码,直接调用int ans = addFunc(tmp, 3);是无法通过编译的,因为这涉及到了从3TmpDemo<T>(3)的隐式转换;而这在函数模板参数推导中是绝对禁止的;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <typename T>
class TmpDemo{
public:
TmpDemo(const T& num){value = num;}
private:
T num;
};

template <typename T>
const T addFunc(const TmpDemo<T> &t1, const TmpDemo<T> &t2){
return t1.num * t2.num;
}

TmpDemo<int> tmp(2);
  • 解决方法就是把非成员函数定义在模板类的内部,并声明为 friend。因为模板类会将 typename 信息进行硬编码,就可以直接进行转换了。

条款 49 了解 new-handler 的行为

  • new-handler:一个函数指针类型typedef void (*new_handler) ( );,并对应一个 global 的函数指针,由用户通过new_handler std::set_new_handler(new_handler p)填充其值(可能会有系统默认值);当 new 无法分配出足够的空间时,系统就会在抛出异常之前先调用这个函数;
  • 通常情况下,拥有以下几种行为的 new-handler 是更好的:
    • ① 可以使得下一次调用 new 时有更大概率成功;这可以通过预先分配一块大内存,随后每次调用 new-handler 时归还部分内存;
    • ② 安装其他 new-handler 和卸载本地的 new-handler:各个 class 有可能会定义自己的 new-handler,因此最好的做法是 new 不同的 class 的时候,调用各自不同的 new-handler,并在调用完毕后将 new-handler 进行恢复;
    • ③ 抛出std::bad_alloc或者直接退出exit()std::abort()
  • 如何实现方式 ②?答:自定义operator new以及使用基于 CRTP(curiously recursive template pattern)的模板技术
    • 为一个需要设置 new-handler 的 class 自定义一个 operator new 和 set_new_handler,而在 operator new 内部的流程就是:先调用std::set_new_handler设置自己的 new-handler,随后调用系统的 new,再之后就是恢复 new-handler 到系统原本的值了;
    • 由于设置恢复完全适配于一个 RAII,因此更优秀的做法便是再设置一个资源管理类,在构造函数内保存之前的 new-handler,并在析构函数内恢复之前的 new-handler;
    • 接下来就是考虑这样一个问题了,如果不同的 class 都需要自定义 new-handler 的话,而又由于自定义 new-handler 其实是一套完全一致的流程,除了各自的 new-handler 不一样;因此 CRTP 就派上用场了,以下代码就是完整的实例。
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
32
33
34
35
class HandleHolder{
public:
HandleHolder(const HandleHoldr &) = delete; // 禁止拷贝
HandleHolder &operator=(const HandleHolder &) = delete;

HandleHolder(std::new_handler p): oldHandler(p) {}
~HandleHolder(){std::set_new_handler(oldHandler);}
private:
std::new_handler oldHandler;
};

template <typename T>
class NewHandlerHelper{ // 此处没有定义自己的set_new_handler了,感觉没有必要
public:
NewhandlerHelper(std::new_handler p): myHandler(p) {}
static void *operator new(size_t size) throw(std::bad_alloc){ // 每个class对应一个operator new
HandleHolder tmp(std::set_new_handler(myHandler)); // std::set_new_handler会返回之前的new-handler
return ::operator new(size);
// tmp被析构,new-handler也就得以恢复
}

private:
static std::new_handler myHandler; // 每个class对应一个new_handler
};

template <typename T>
std::new_handler NewHandlerHelper<T>::myHandler = nullptr; // static变量要记得初始化

class Widget : public NewHandlerHelper<Widget> { // 自己继承自己,虽然看起来很奇怪,但实际上是行得通的;本质上只是让不同的class拥有不同的myHandler
/**
* ...
* Widge只要在构造函数处给NewHandlerHelper提供自己的new-handler即可
* ...
*/
};

条款 52 写了placement new也要写placement delete

  • 当代码中使用new表达式之后,发生了两件事情:
    • ① 调用void *operator new(size_t size)来获取一块原始内存(raw memory);
    • ② 调用 class 的 ctor 以构造对应的对象
  • 因为有两个步骤的存在,因此,如果在第 2 个阶段发生了异常,就有可能产生内存泄漏;
  • 为了避免可能的内存泄漏,当发生上述情况时,由系统来负责回收对应的内存;
  • 这就引出了一个问题,系统如何知道应该调用哪一个版本的 delete 呢?系统的原则是,使用和operator new参数列表一致的operator delete
  • 因此就有了本条条款的原则:定义了一个 placement new,就需要定义对应的 placement delete;所谓的 placement 就是参数列表除了size_t以外还包括其他的参数;