Brief Notes of 《Effective C++》

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

条款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的步骤...);
  • copy and swap。先拷贝rhs指向的资源,再令lhs指向的资源和这份拷贝之后的资源进行交换;

条款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 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 尽可能延后变量定义式的出现时间

  • 应该尽可能在要用到某个变量的时候才去定义它(这很显然嘛)
  • 关于循环体中的变量的下述两种定义方式,一般情况下,除非明确知道赋值操作的消耗小于构造加析构的时候才使用第一种;因为第一种方式扩大了变量的生命期;
// 第一种
{
  ...
  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声明:
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就是在陈述这一点;本质上就是因为普通函数实施的是静态绑定,相同的对象会因为其指针或引用的类型的不同而执行不同的函数体(有可能是基类的函数体,也可能是派生类的函数体);这造成了不确定性(另一方面,单个基类指针,即使指向不同类型的派生类,其调用普通函数时,也只会执行基类函数体,造成了程序错误);
  • 注:C++的虚函数模型在二进制兼容性(ABI)方面的负面影响是极大的。如果一个程序会设计为一个动态库,客户代码对其进行加载调用,如果后续动态库进行了升级,在某个类中加入了新的虚函数,那么如果客户代码不重新编译的话,会直接调用不同的函数,造成错误,因为客户代码在编译结束以后,就直接以虚表指针加偏移的形式去调用函数,而动态库的各个函数的偏移可能在升级之后就完全改变了。

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

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

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

  • 关键就是理解复合(Composition)二字;复合包含应用域和实现域两种关系;
  • 应用域:即把一个class作为组件;比如说class People的一个组件是class PhoneNumber;这就是所谓的has-a关系;
  • 实现域:即某个class需要通过另一个class进行实现,但两者并不存在完美的继承关系;比如说通过一个std::vector来实现一个class Stack;这就是所谓的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::value_type表示的就是iteT类型的迭代器所指涉的元素类型;萃取器的优势在于任何类型的迭代器(甚至是原生指针)都能萃取出想要的特征;

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

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

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

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

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

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

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

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

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

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

TmpDemo 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就派上用场了,以下代码就是完整的实例。
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 
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 
std::new_handler NewHandlerHelper::myHandler = nullptr; // static变量要记得初始化

class Widget : public NewHandlerHelper { // 自己继承自己,虽然看起来很奇怪,但实际上是行得通的;本质上只是让不同的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以外还包括其他的参数;

你可能感兴趣的:(Brief Notes of 《Effective C++》)