美文网首页
Brief Notes of 《Effective C++》

Brief Notes of 《Effective C++》

作者: 老杜振熙 | 来源:发表于2021-01-27 22:21 被阅读0次

    本文为学习《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<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 尽可能延后变量定义式的出现时间

    • 应该尽可能在要用到某个变量的时候才去定义它(这很显然嘛)
    • 关于循环体中的变量的下述两种定义方式,一般情况下,除非明确知道赋值操作的消耗小于构造加析构的时候才使用第一种;因为第一种方式扩大了变量的生命期;
    // 第一种
    {
      ...
      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<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是一个继承谱系中的各种类型。显然,单一的成员函数是解决不了问题的。

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

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

    • 该条款和条款24的思想是一致的,也就是当函数的所有参数都涉及隐式转换时,它最好是一个非成员函数(因为this是无法转换的);
    • 和条款24的不同之处在于,本条款涉及到的是模板类;即,某个函数的各个参数是模板类型;
    • 很显然,这种函数也需要定义为非成员函数;
    • 不同之处在于:因为涉及到了模板,那么在进行函数模板的模板参数推导时,绝对无法进行隐式转换,比如说对于如下的代码,直接调用int ans = addFunc(tmp, 3);是无法通过编译的,因为这涉及到了从3TmpDemo<T>(3)的隐式转换;而这在函数模板参数推导中是绝对禁止的;
    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就派上用场了,以下代码就是完整的实例。
    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以外还包括其他的参数;

    相关文章

      网友评论

          本文标题:Brief Notes of 《Effective C++》

          本文链接:https://www.haomeiwen.com/subject/ujcdaktx.html