目录

Effective C++(第3版)精读总结(三)

改善程序与设计的55个具体做法(Rule26-40)

前言

这本C++经典著作,本人看的是中文版,侯捷老师翻译的,精读分析并实践推敲后,整理成博客记录下来,非常适合有一定开发经验的朋友们阅读。

Effective-C++总结系列分为四部分,本文为第三部分,涉及原书第5~6章,内容范围Rule26~40。为方便书写,Rule26简写为R26

由于原书在C++11之前写成,有些现代C++特性不会提及,所以会根据本人开发经验新增一些个人感悟👉By the way环节。


CH5. 实现

R26 尽可能延后变量定义式的出现时间

  • 尽可能延后变量定义式的出现,可增加程序清晰度和效率

    1. 定义后,在使用前就遭遇抛异常

      这种情况,如果是定义了对象ObjectA a,便白白地浪费了对象a的构造和析构成本。

    2. 不只是延后变量定义到使用时,而是尽量延后到能给它初值时

      结合上述第1,2点,考虑以下代码的合理性:

      1
      2
      3
      4
      5
      6
      7
      8
      
      std::string encryptPassword(const std::string& password){
          if(password.length() < 8){
              throw std::logic_error("Password is too short");
      	}// 考虑1:在异常之后定义变量
          std::string encrypted(password);//考虑2:定义延后至变量能赋初值的时机
          encrypt(encrypted);
          return encrypted;
      }
      
    3. 思考变量定义是否该在循环内

      方法A:定义于循环外 方法B:定义于循环内
      Widget w;
      for(int i = 0 ; i< n; ++i){
          w = foo(i);
         // other...
      }
      for(int i = 0 ; i< n; ++i){
          Widget w(foo(i));
         // other...
      }
      1个构造+1个析构+n个赋值 n个构造+n个析构

究竟是A还是B方法好,取决于 一个赋值成本和一组构造+析构成本,这2者之间,如果是赋值成本低,那么A更好,否则B更好。


R27 尽量少做转型动作

通常的转型是可能会这样写:函数风格的int(expression)或者C风格的(int)expression,这都被成为“旧式转型 ”。

  • 在C++中,有4种新式转型操作符:

    1. const_cast (expression)

      作用是移除变量的常量性(cast away the constness),是唯一有此能力的操作符。

    2. dynamic_cast(expression)

      作用是“安全向下转型”(safe downcasting),决定某个对象是否属于某继承体系。耗费重大运行成本(原因:需要查询RTTI信息,而且不同编译器实现的方法和效率有所不同)。

    3. reinterpret_cast(expression)

      执行低级转型,实际结果取决于编译器,移植性差。(比如int* 转为int)要清楚自己在做什么,慎用。

    4. static_cast(expression)

      强迫隐式类型转换,代替C风格的"旧式转换"。也可以给变量加上const特性。

    新式转换的好处:很容易在代码找到“类型系统在何处转变或破坏”;对const特性的严控,让类型系统更健壮。

  • 派生类里直接调用基类成员函数时,不要用转型

    1
    2
    3
    4
    5
    6
    
    class SpecialWindow:public Window{
    public:
      virtual void onResize(){
          Window::onResize();//不要使用 static_cast<Window>(*this).onResize();
      }  
    };
    
  • 关于dynamic_cast需要注意的

    1. 如何替代和避免

      一般是持有一个Base *pBase,但是指向的是DerivedObj,于是转型为pDerived。可以这样修改:

      修改设计,窄化类型,持有一个pDerived即可;或者将想做的事放到虚函数中,利用多态去完成。

    2. 避免串联

      避免下方这样的代码,一连串的dynamic_cast:

      1
      2
      3
      4
      5
      6
      7
      
      class Window{ ... };
      // 定义子类 SpecialWindow1,SpecalWindow2,SpecialWindow3
      Window* winPtr;
      // 省略winPtr的其他操作 ... 
      if(SpecialWindow1 *psw1 = dynamic_cast<SpecialWindow1*>(winPtr)){ ... }
      else  if(SpecialWindow2 *psw2 = dynamic_cast<SpecialWindow2*>(winPtr)){ ... }
      else  if(SpecialWindow3 *psw3 = dynamic_cast<SpecialWindow3*>(winPtr)){ ... }
      

      这样的代码又大又慢,每次继承体系有所改变,代码就需要重新检阅判断。这样的代码应该用“基于virtual函数调用”取代它。

By the way

C++11 起,针对智能指针 shared_ptr 的转型,推出了另外 4 个模版函数:

  • std::static_pointer_cast

    函数的原型声明为:

    1
    2
    
    template< class T, class U > 
    std::shared_ptr<T> static_pointer_cast( const std::shared_ptr<U>& r ) noexcept;
    

    含义以及应用场景与 static_cast 类似,比如子类型指针转为父类型:

    1
    2
    3
    
    auto basePtr = std::make_shared<Base>();
    auto derivedPtr = std::make_shared<Derived>();
    basePtr = std::static_pointer_cast<Base>(derivedPtr);
    

    或许我们会有疑问,是否有必要用这个函数进行转型呢,如下实现不是一样的吗:

    1
    
    basePtr = std::shared_ptr<Base>(static_cast<Base*>(derivedPtr.get()));
    

    🤔 当然不一样static_cast<T*>((U*)nullptr)是未定义行为,而且就语法描述上来看,哪个更简洁不言自明。

    另外,自 C++20 起支持右值引用,也就是如下形式:

    1
    2
    
    template< class T, class U > 
    std::shared_ptr<T> static_pointer_cast( std::shared_ptr<U>&& r ) noexcept;
    
  • std::dynamic_pointer_cast

    含义以及应用场景与 dynamic_cast 类似,用法传参与 std::static_pointer_cast 类似,且自 C++20 起支持右值引用,不赘述。

  • std::const_pointer_cast:与前 2 者类似,不赘述。

  • std::reinterpret_pointer_cast:与前 3 者类似,不赘述。


R28 避免返回handles指向对象内部成分

这里的handles(号码牌)包括指向对象内部的指针迭代器引用

以下讲述当返回对象内部的handles时,存在的2个问题:

  • 可能会破坏封装性

    考虑一个场景:public函数返回一个private成员的非const引用,就让外界有了修改private的机会,破坏了封装性。

    此时,需要将非const引用改为const引用,只读属性。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    struct Pixel{
        float r,g,b;
    }
    class Image{
    public:  //注意:后面这const只保证成员变量vPixPtrs_不改
        Pixel& GetThePixel(int idx) const{  
            return  vPixPtrs_[i];}//隐患:其实外部调用者仍能直接修改Pixel的rgb值
        // 上一句的返回值应该改为 "const Pixel&"
    private:
        std::vector<std::shared_ptr<Pixel>> vPixPtrs_;
    }
    
  • 可能会引起“空悬handles”

    即使用const解决了封装性的问题,因为很容易出现“handles比其所指对象更长寿”,可能存在对象已析构,但handles还留存的问题。尤其是临时变量的析构,不太容易察觉。

    1
    2
    3
    4
    5
    6
    
    class SceneGraph{ ... };
    const Image CaptureImage(const SceneGraph& graph);
    //那么调用方可能会这样使用
    SceneGraph Grap;
    //下面这句之后,Image临时对象被销毁,pPix指向一个不存在的对象
    const Pixel *pPix = &(CaptureImage(Grap).GetThePixel(0));
    

    注意,CaptureImage的确能返回一个临时Image对象,能成功调用GetThePixel,但这句结束后,临时对象立马会被销毁,造成空悬现象,或叫虚吊(dangling) !


R29 为“异常安全”而努力是值得的

异常安全”是指,当异常抛出时,代码依然能做到如下2点:

  1. 不泄露任何资源

    包括内存资源,锁资源。

  2. 不允许数据败坏

    不会因为异常而导致空悬指针等未定义行为。

考虑下方的示例代码,(如果new Imagestd::bad_alloc异常)则会同时违背了上述2条:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PrettyMenu{
private:
    Mutex mutex_; //互斥器
    Image* bgImage_ = nullptr;
    int imageChangeCnt_ = 0;
public:
	void PrettyMenu::changeBackground(std::ifstream& imgSrc){
        lock(&mutex_);// 这个可以改为RAII的锁,来保证异常安全
        delete bgImage_;
        ++imageChangeCnt_;
        //这里new Image抛异常,导致无法解锁;且bgImage_指向资源已经释放,空悬指针
        bgImage_ = new Image(imageSrc_);
        unlock(&mutex_);
    }
};

异常安全的3个等级

异常安全的函数,有3个等级的异常安全保证,会满足三者之一

  • 基本承诺

    如果异常被抛出,程序内的任何事务仍然保持在有效状态下,也没有任何数据败坏。比如上例中如果抛异常,会另外添加实现,使bgImage_持有某个默认图像,或保持原值,让程序继续有效运行

  • 强烈保证

    如果异常被抛出,程序状态不改变。这样的函数要么成功,要么退回到执行前的状态。

    上述案例则应该会被修改成如下形式:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    class PrettyMenu{
    private:
        std::shared_ptr<Image> bgImage_; //RAII避免了异常发生时的资源泄漏和数据败坏
        //... 省略其他成员
    public:
    	void PrettyMenu::changeBackground(std::ifstream& imgSrc){
            CLock ml(&mutex_);//RAII封装的Lock类,详细可参考 阅读总结(二)-Rule14
            bgImage_.reset(new Image(imgSrc));//若new失败,则不会reset
            ++imageChangeCnt_;//把事情做完再++count
        }
    };
    
  • 不抛异常

    在原书中,这个“No Throw”不是绝对不抛异常,而是一旦意外抛异常,就会调用unexpected函数进而abort(例如int doSomething() throw();//空白的异常明细)。

    By the way

使用copy-and-swap保障异常安全

copy-and-swap技术:先拷贝一份想修改的对象,等修改彻底完成后(过程中不抛异常),再与原对象交换

为了更形象展示这一过程,使用pIml手法对bgImage_封装一下:

1
2
3
4
struct ImgPimpl{ //选用struct而非Class:方便;最后被private成员形式使用,封装性不用担心
   std::shared_ptr<Image> bgImage_;
   int imageChangeCnt_ = 0;
};

那么PrettyMenu类可以改为如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class PrettyMenu{
private:
    Mutex mutex_;
    std::shared_ptr<ImgPimpl> pImpl_;
public:  //构造略
	void PrettyMenu::changeBackground(std::ifstream& imgSrc){
        using std::swap;   //参考 阅读总结(二)-Rule25
        CLock ml(&mutex_);//RAII封装的Lock类,详细可参考总结(二)-Rule14
        std::shared_ptr<ImgPimpl> pNewCopy(new ImgPimpl(*pImpl_));
        pNewCopy->bgImage_.reset(new Image(imgSrc)); //修改副本
        ++pNewCopy->imageChangeCnt_; 
        
        swap(pImpl_,pNewCopy);//改完之后swap
    }    
};

异常安全的连带影响(side effects)

函数提供的“异常安全保证等级”只取决于其调用的各个子函数的“最不安全者”

考虑以下函数SomeFunc代码:

1
2
3
4
5
6
void SomeFunc{
    ... // 对local状态做一份副本
    f1();
    f2();
    ...  //将修改后的状态置换过来
}

分析:显然copy-and-swap在尽力强烈保证异常安全,但是,f1或者f2的异常安全如果比较低,那么可能需要单独对f1,f2进行copy-and-swap,来尝试保证“强烈异常安全”;即使如此,如果f1能成功做了修改,但是f2修改失败了并回退,那么f1、f2整体看起来还是“改了一部分”。

这也告诫我们,如果引入了异常不安全的旧代码,那么这种特性会波及其他代码。

就设计者而言,只能根据实际情况,尽可能保证“异常安全”,选择3个异常安全等级之一实施。


R30 透彻了解inline的里里外外

inline是C++的关键字,表示内联函数。直接在对应位置展开代码,免去函数调用的开销,难以避免 “代码膨胀”问题。

使用inline时应该注意以下问题:

  • inline只是对编译器的申请

    inline只是对编译器的申请/建议,不是强制命令,编译器有权利 对其认为不适合inline的函数拒绝inline。 (原书说:如果编译器拒绝,通常它会给出warning信息。实际本人实测VS2017没看到)

    inline有2种申请方式

    1. 函数定义 时使用关键字inline显式强调
    2. 实现在Class内的成员函数或friend函数,属于隐式inline
  • inline和Template没有任何必然联系

    虽然有不少简短的Template函数是带有inline(例如下方的std::max),但不是必然为之,两者没有因果关系

    1
    2
    3
    4
    
    template<typename T>
    inline const T& std::max(const T& a, const T& b){ //可以申请inline,但不是必须申请
        return a < b ? b : a;
    }
    
  • 编译器拒绝复杂函数进行inline

    复杂函数的inline会带来较严重的“代码膨胀”问题,并且可能会更慢,因为增加了运行时的“额外换页”行为,降低了指令cache命中率。

    1. inline函数内不要出现循环或递归

    2. 虚函数也不适合做inline

      因为inline是编译期间决定的事,而虚函数是运行时决定的事,两者就不是同一个场景的。

    3. 构造/析构函数也不适合做inline

    ​ 编译器可能会在构造/析构函数内部做精妙复杂的异常处理;以及在继承体系下,Base类函数体到处inline膨胀。

  • 以函数指针形式的调用通常不能inline

    对绝大多数编译器而言,是否inline是compile阶段决定的事情,少数编译器放到了link阶段。

    讨论大多数情况:需要在编译时得知inline的本体,而函数指针办不到,示例代码如下:

    1
    2
    3
    4
    5
    
    inline void f() {...} //假设编译器有意愿inline “对f的调用”
    void (* pf)() = f;//pf 执行f
    ...
    f();     //这个调用将被inlined,因为是个正常调用
    pf();     //这个很可能不被inlined
    
  • 过度inline对调试和发布带来困难

    inline是代码嵌入与展开,而非函数调用,所以某些编译器不支持inline的单步Debug(就像宏展开一样不支持调试);另外,inline只要已修改,涉及调用它的代码全都要编译,如果是non-inline则可能只需要重新link即可。


R31 将文件间的编译依赖关系降至最低

C++的Class定义式包括了成员变量,假设某成员变量是类对象ClassObjA a,如果ClassObjA类的内部实现发生了改变(哪怕这个ClassObjA.h内只在某处加了一个空格 ),那么include了ClassObjA.h的所有.h文件.cpp文件都会重新编译

👆这就是由“编译依赖”关系带来的问题。

使用 PIMPL 手法(pointer to implementation)可以很好地分离声明和定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
//Person.h文件  不需要include "PersonImpl.h"和"BirthDay.h"
class PersonImpl;
class BirthDay;// 前置声明代替include
class Person {
public:// 构造函数这里放实现无所谓,因为是函数指针,不需知道PersonImpl本体实现
    Person(const std::shared_ptr<PersonImpl>& pImpl);//构造函数的实现也放到cpp里去
    const BirthDay& GetBirthDay();// 注意:这里只声明,把实现部分放到cpp里
    std::string GetName();//实现细节由pImpl_转发实现
private:
    std::shared_ptr<PersonImpl> pImpl_;
};
//Person.cpp文件  
#include "PersonImpl.h" //在"PersonImpl.h"文件内include那个"BirthDay.h"
#include "Person.h"

注意:Impl用法,相关的头文件里不要放置任何函数实现代码

上述做法可以让使用Person类的客户不需要再关心PersonImpl以及BirthDay的实现细节了,做到了“接口与实现分离”,关键点在于把“定义的依赖性” 换成 $\Rightarrow$ “声明的依赖性”。

这里突出了如何最小化编译依赖性的本质:让头文件尽可能自我满足,如果不行,也要依赖于其他文件的声明式而非定义式。

具体到设计策略上,有以下几种做法:

  • 如果能用object references 或 object pointers完成任务,就别用objects

    如果要定义某类型的objects,就要使用定义式,指针和应用则可以只用声明式。

  • 尽量以class声明式替换class定义式

    函数声明种的Class类型可以只用声明式,即使以by-object-value形式传值也是如此。

  • 为声明式和定义式提供不同的文件

    比如Date类,分为只包含声明式的"Datefwd.h"和包含定义式的"Date.h",那么使用时用声明式头文件代替前置声明,在需要应用代码client.cpp里include定义式头文件。这种方式在标准库里采用较多,参考<iosfwd>和<sstream>,<fstream>,<streambuf>等。

By the way
  • C/C++可以使用编译器预处理指令#pragma message,打印出该文件是否参与此次编译,以及参与编译时被哪个文件所依赖。

    1
    2
    3
    
    //比如可以在 testEffective.h 文件内加上这句,就能在编译输出信息里看到打印信息,观察到依赖关系
    #pragma message("testEffective.h 参与重新编译")
    //还可以用于测试某些宏是否真的生效,有时IDE的高亮显示不准确而令人生疑。
    
  • 关于头文件include的其他编写规范,可以参考:Google-C++风格指南:1.头文件

使用 Interface Class 也能做到接口和实现的真正分离:

这种方式常见于输出动态库给到客户使用,客户能见到接口定义和使用,但无法看到内部实现。

用法较为常见,不赘述,直接show-code

 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
//VirtualPerson.h   //和 lib文件一起提供给到客户
class VirtualPerson { 
public: //create的返回值还可以根据需求,换成RAII的智能指针
    static VirtualPerson * create(int level, int salary);
    virtual int Level() = 0;
    virtual int Salary() = 0;
    virtual ~VirtualPerson();// avoid memory leak 
};
//VirtualPerson.cpp  //源码不提供给客户,而是编译好的二进制 lib文件
VirtualPerson* VirtualPerson::create(int level, int salary) {
    if (level == 3) { //工厂方法,还可以生成其他子类
        return new Engineer(level, salary); 
    }
    return nullptr;
}
VirtualPerson::~VirtualPerson() { }
// Engineer.h  //Engineer也是参与编译到 lib文件中
class Engineer: public VirtualPerson {
public:
    Engineer(int level,int salary);
    virtual int Level() ;
    virtual int Salary() ;
private:
    int level_ = 3;
    int salary_ = 0;
};

CH6. 继承与面向对象设计

R32 确定你的public继承塑造出is-a关系

原书标题:Make sure public inheritance models “is-a”. 侯捷老师翻译为“塑模”,我个人更愿意称为“塑造”。

  • “public继承”意味着is-a

    is-a,即“是一种”,就是说,适用于Base Class身上的每一件事,也一定适用于Derived Class身上。

By the way

面向设计对象的设计有著名的5大原则, SOLID 原则,每个字母分别代表一种原则:

  • S–单一责任原则(SRP) –Single Responsibility Principle

  • O–开放封闭原则(OCP)– Open-Closed Principle

  • L–里式替换原则(LSP)– Liskov Substitution Principle

  • I –- 接口分离原则(ISP)–Interface Segregation Principle

  • D–-依赖倒置原则(DIP)– Dependency Inversion Principle

更详细的叙述可以参考:腾讯云:SOLID原则

本节条款的中心思想即里氏替换原则一个对象出现的地方都可以由其子类代替并且不会出错

继承关系有时候听起来很好理解,比如 Class Student: public Person 理所应当,但有时也会导致误解。比如企鹅属于鸟类,但企鹅不会飞,那么基类Bird::Fly方法又当如何处理,下方满足设计意图:

1
2
3
4
5
6
7
8
class Bird{   //... 不声明Fly()方法
};
class FylingBird:public Bird{
public: virtual void Fly();
};
class Penguin: public Bird{ //... 不声明Fly()方法
};
// 注:如果Bird类以及子类都不考虑Fly()方法,那么Penguin直接继承于Bird即可

还有另一种场景,父类和子类对于同一个方法的数据修改规则不同,导致了继承体系的缺陷。

比如Class Square:public Rectangle $\rightarrow$ 正方形继承于长方形,但是考虑这样一个类外方法:

1
2
3
4
5
void makeBigger(Rectangle& r){  //普通非成员函数
    int oldHeight = r.height();
    r.setWidth(r.width() + 10);//如果r是Squqre,可能内部自动就长宽一起变了
    assert(r.height() == oldHeight);//这个assert对于正方形就不合适了,贸然去除又违背设计本意
}

应对上述这样的情况,就需要修改设计或修改继承体系了。


R33 避免遮掩由继承得来的名称

首先,什么是名称的遮掩,通俗地说,是指由于作用域不同带来的变量名覆盖。考虑下方代码:

1
2
3
4
5
int x = 10;
void someFunc(){
    double x = 0.1;
    std::cout<<"x is" << x << std::endl; // local作用域找到了x,直接覆盖全局的x,输出0.1
}

那么如果将继承体系考虑进来呢:Derived的作用域会覆盖Base的作用域,包括virtual和non-virtual。考虑下方代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
class Base {
public:
    virtual void mf1() = 0;
    virtual void mf1(int x) { std::cout << "Base::mf1():x =" << x << std::endl; }
    virtual void mf2() { std::cout << "Base::mf2()\n"; }
    void mf3() { std::cout << "Base::mf3()\n"; }
    void mf3(int x) { std::cout << "Base::mf3():x =" << x << std::endl; }
    virtual ~Base() {}
};
class Derived :public Base {
public:
    virtual void mf1() { std::cout << "Derived::mf1()\n"; }
    void mf3(){ std::cout << "Derived::mf3()\n"; }
};

很明显存在名称遮掩的问题,Derived的mf1,mf3会遮掩子类的所有同名函数,测试结果如下:

1
2
3
4
5
6
7
Derived d;
d.mf1();      //OK,输出: Derived::mf1()
//d.mf1(100);  编译报错,因为名称被遮掩
d.mf2();      //OK,输出: Base::mf2()
d.mf3();      //OK,输出: Derived::mf3()
//d.mf3(300);  编译报错,同理
d.Base::mf3(300); //OK,输出: Base::mf3():x =300 . 但是不太建议这么写,丑!!
  • 为解决上述问题,可以采用using声明式或转发函数

    1. using 声明式

      可以使用using声明式,让Derived可以忽略名称遮掩,看到Base作用域内的函数。可以让上方代码的“编译报错”消失,正常调用d.mf1(100)和d.mf3(300)。

      1
      2
      3
      4
      5
      6
      
      class Derived :public Base {
      public: //修改本节内容中上方代码的Derived类的声明
          using Base::mf1;
          using Base::mf3; //这2个using使得Base类作用域内所有mf1,mf3函数都可见
          // ... 其他,略
      }
      
    2. 转发函数(forwarding function)

      应用场景:在private继承下,强调的是继承实现而非继承接口,如果想在子类的成员中调用父类函数,此时可以通过函数转发来实现。

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      
      class Derived: private Base{ //改写本节上方代码,注意,是私有继承
      public:
          virtual void mf1(){
              Base::mf1(); //拿到了父类的函数实现
              // ... 该函数其他部分
          }
      };
      // 应用代码
      Derived d; 
      d.mf1();    //调用成功,Derived::mf1
      d.mf1(100);  //编译失败
      

      当然,public继承也能使用转发函数,写出d.Base::mf3(300); 这样的代码。但是,一来public继承理应遵循"is-a"规则,using声明拿到所有被遮掩的接口;二来明显代码不美观。


R34 区分接口继承和实现继承

当一个子类Derived继承于父类Base,那么要时刻清楚,对于类中的成员函数,是想继承父类的接口,还是想继承父类的实现。

  • 对于Public继承,接口总是会被继承

    基于“is-a”的关系,作用于父类的任何事情也一定要适用于子类。

  • 声明纯虚函数(pure-virtual)的目的是让子类只继承函数接口

    对于纯虚函数,子类必须重新实现该接口。注意,父类可以选择性给出纯虚函数的实现,但是一般不会给。

  • 隐患:从非纯虚函数(impure-virtual)同时继承接口和缺省实现

    非纯虚函数,可以让子类选择是否重新实现该接口。那么,如果子类是有必要重写,但是忘记写了却默默用父类版本,便事与愿违了。

    举例:父类Airplane有子类PlaneModelA、PlaneModelB、PlaneModelC,其中C型飞机不同于AB型,是新式飞机:

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Airport {...};//机场类,实现略
    class Airplane{
    public:
        virtual void fly(const Airport& destination);//父类还会给出默认的fly实现
    };
    class PlaneModelA: public Airplane{ ... }; // 不重写fly,继承父类的fly实现
    class PlaneModelB: public Airplane{ ... }; // B和A一样
    class PlaneModelC: public Airplane{ ... }; //新型飞机,本来要重写fly,结果忘了
    

    那么这个隐患该如何解决 呢,也就是说,在实现C型飞机类时别忘了fly方法?

    核心思想是“切断virtual函数接口和其默认实现之间的连接”。

    1. 方法1:设置fly为纯虚函数,并新增一个defaultyFly方法

      注意细节:defaultFly方法要设置为protected属性的non-virtual函数,代码如下:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      class Airplane{
      public:
          virtual void fly(const Airport& destination) = 0;// 父类不给出实现
      protected:
          void defaultFly(const Airport& destination){ ... }//默认的fly实现
      };
      class PlaneModelA: public Airplane{
      public:   //纯虚接口,子类必须给出实现
          virtual void fly(const Airport& destination){
              defaultFly(destination); //调用父类的缺省实现
          }
      };//PlaneModelB 和 PlaneModelA 类似,略
      class PlaneModelC: public Airplane{ ...};// 重写fly方法
      

      这样写还有个好处:fly()和defaultFly()享有不同的保护级别。

    2. 方法2: 父类的默认实现塞到纯虚接口fly中

      这样就不需要定义defaultFly方法了,因为子类必须实现fly方法,对于A 型、B型飞机,子类fly()转发一次父类的fly()即可,C类飞机实现新式的fly()。缺点是让原本在defaultFly内的实现内容暴露在外了(指public属性)。

      By The Way:

      个人认为,这方法2还有个缺点:它让虚基类的纯虚接口承载了接口实现,不够纯粹(比如需要输出给到客户,应该只继承接口)。

  • non-virtual函数具体指定接口继承和强制性实现继承

    如果成员函数是non-virtual,表示它不打算在子类中有不同的行为,或者说,不变性凌驾于特异性。对应地,绝不应该在子类中重写non-virtual函数。


R35 考虑virtual函数的替代选择

假设这样一个场景:设计一款游戏,不同人物以不同方式计算生命值,那么$\Longrightarrow$ 设计继承体系,子类共同继承父类的public-virtual方法healthValue(),子类各自重新实现healthValue()接口。

😄很好,中规中矩,那么,有没有其他方式呢?

  • NVI手法(non-virtual interface)实现Template Method模式

    思路就是父类定义个non-virtual的public方法healthValue(),调用virtual的private方法healthValueImpl。子类直接重写healthValueImpl,达到类似模版方法设计模式的效果。

    父类GameCharacter设计如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    class GameCharacter{//构造函数和虚析构均略去
    public:
        int healthValue()const{ //ps:方便展示,而写在了头文件里,成了inline
            std::cout<< "Do prepare works...\n";//事前,如加锁,写log,验证条件等 
            int retVal = healthValueImpl();
            std::cout<< "\nDo post works...\n";//事后,如解锁,更新数据
            return retVal;
        }
    private:
        virtual int healthValueImpl() const{
            int val=0;
            std::cout << "default caculate process... GetValue:" << val;//随后进行计算,过程略
            return val;
        }
    };
    

    子类GoodGuyBadGuy设计如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    class GoodGuy:public GameCharacter{
    private:
        virtual int healthValueImpl() const{
            int val = 60;
            std::cout << "goodGuy caculate ... GetValue: " << val << " ";//过程略
            return val;
        }
    };
    class BadGuy:public GameCharacter{
    private:
        virtual int healthValueImpl() const{
            int val = 80;
            std::cout << "badGuy caculate ... GetValue: " << val << " ";//过程略
            return val;
        }
    };
    

    应用端代码如下:

    1
    2
    3
    4
    
    std::shared_ptr<GameCharacter> pGood = std::make_shared<GoodGuy>();
    pGood->healthValue(); //得到60
    std::shared_ptr<GameCharacter> pBad = std::make_shared<BadGuy>();
    pBad->healthValue();//得到80
    
  • 用函数指针实现Strategy模式

    主体思想是添加一个函数指针为private成员变量pFunc,这个函数通过外部传入,从而实现不同的行为。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    class GameCharacter;//forward declaration
    int defaultHealthCalc(const GameCharacter& gc);//默认算法实现
    class GameCharacter{
    public:
      typedef int (*HealthCalcFunc)(const GameCharacter& gc);
      explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc):calcFunc_(hcf){}//传入函数指针,自定义实现
      int healthValue()const{
        return calcFunc_(*this);
      }
    private:
      HealthCalcFunc calcFunc_ = nullptr;
    };
    

    这个设计有2个有趣的设计弹性

    1. 即使同一个人物类型的不同实体,允许拥有不同的生命值计算方法;
    2. 某个人物对象的生命值计算方法,在其生命期内可以任意修改,只要添加一个set方法即可;
  • 用std::function实现Strategy模式

    private成员变量由上文的函数指针替换成std::function对象,相当于是指向函数的泛化指针。就更具设计弹性了。std::function可以传入函数指针、仿函数、std::bind函数对象。GameCharacter的类实现修改为:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    class GameCharacter{
    public:
        typedef std::function<int (const GameCharacter&)>  healthCalcFunc;
        explicit GameCharacter(healthCalcFunc hcf = defaultHealthCalc):healthValueImpl_(hcf){}
        int healthValue()const{
            std::cout<< "Do prepare works...\n";
            int retVal = healthValueImpl_(*this); //⚠️ 这里改了
            std::cout<< "\nDo post works...\n";
            return retVal;
        }
    private:
        healthCalcFunc healthValueImpl_ = nullptr;
    };
    
    1. 传入函数指针

      1
      2
      3
      4
      
       gameChashort quickHurtHealthCalc(const GameCharacter2& gc);//返回值不是int,可隐式转换;实现略去
      //应用端代码如下:
      GameCharacter quickGuy(quickHurtHealthCalc);
      quickGuy.healthValue();//内部调用quickHurtHealthCalc
      
    2. 传入仿函数

      仿函数:即函数对象,而且重载了operator() 。

      1
      2
      3
      4
      5
      6
      7
      8
      
      struct HealthCalculator{//
          int operator()(const GameCharacter2& gc) const{
              return 180;//省略实现
          }
      };
      //应用端代码如下:
      GameCharacter functorGuy( (HealthCalculator()) );//用括号将仿函数括起来
      functorGuy.healthValue();
      
    3. 传入std::bind函数对象

      std::bind是函数对象模板,接收一个函数指针f和若干函数入参得到fObj,调用fObj等同于调用带参数的f。本例代码如下:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
      class GameLevel{
      public: //用类内函数作为函数指针f
          float health(const GameCharacter& gc) const{
              return -20.3;
          }
      };
      //应用端代码如下:
      GameLevel  curLevel;
      GameCharacter levelGuy(std::bind(&GameLevel::health, curLevel,std::placeholders::_1));
      levelGuy.healthValue();//内部调用等价于curLevel.health(leveGuy);
      
By the way

其实这里传入C++11新增的Lambda表达式作为std::function也是可以的,而且更方便,示例代码如下:

1
2
3
4
5
  GameCharacter  lamGuy([&](const GameCharacter& gc){
     std::cout<< "value is " << 75 << std::endl;//具体计算略
	   return 75;
	   });
  lamGuy.healthValue();

关于Strategy设计模式更多参考:


R36 绝不重新定义继承而来的non-virtual函数

  • 由于名称遮掩,不要重新定义继承而来的non-virtual函数

    看个反例:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    class B{
    public:
        void mf(){
           std::cout << "B::mf()"; 
        }
    }
    class D: public B{
    public:
        void mf(){//重新定义mf(),违反了Rule33
            std::cout << "D::mf()"; 
        }
    };
    

    那么看这样的应用代码:

    1
    2
    3
    4
    5
    6
    
    D d;
    D* pD = &d;
    B* pB = &d;
    d.mf();    // 输出 D::mf()
    pD -> mf();// 输出 D::mf()
    pB -> mf();// 输出 B::mf()
    

    这就很诡异了!都通过对象d调用成员函数mf,调用结果应该相同。

    另外一点,出于public继承的“is-a”特性,这样重新定义non-virtual函数也是对"is-a“的严重违背


R37 绝不重新定义继承而来的缺省入参值

这里说的缺省入参,指的是函数入参的默认值,在重写带有缺省入参的virtual函数时,不要修改那个默认参数的默认值。

原因:virtual函数为动态绑定特性,而缺省参数值是静态绑定特性。修改后会造成一些令人费解的现象。

请看下方反面教材

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
enum class Color {
    RED = 0,GREEN ,BLUE 
};
class Shape {
public:  // 🎉:父类默认入参是RED
    virtual void draw(Color col = Color::RED) {
        std::cout << "Shape:col is " << int(col) << std::endl;
    }
};
class Rectangle : public Shape{ // 🎉:子类类修改默认入参为GREEN
    virtual void draw(Color col = Color::GREEN) {
        std::cout << "Rectangle:col is " << int(col) << std::endl;
    }
};

那么当出现典型应用场景Base* pB=new Derived时,就会造成“父类子类各出一半力”的情形:

1
2
Shape *pRec = new Rectangle();
pRec->draw(); // 输出:Rectangle:col is 0  (0是RED)

结果确实调用子类draw,但是默认入参取的是基类的 RED,而非子类的GREEN。

那怎么修改合适呢,都带默认参数,且子类父类相同?带来一个耦合问题,如果父类改了,所有子类都得改。

正如Rule35提到的NVI(non-virtual interface)手法,此处便是绝佳的应用场景$\Longrightarrow$ draw方法改为默认参数的non-virtual,把virtual函数放到private里去,代码修改如下:

1
2
3
4
5
6
7
8
class Shape {
public:   //子类继承该默认入参的non-virtual接口,别重写
    void draw(Color col = Color::RED) {
        drawImpl(col);
    }
private: //纯虚函数是强制子类重写,看具体情况,impure-virtual也行 
    virtual void drawImpl(Color col) = 0;//子类重写这个drawImpl
};

R38 通过复合塑造出has-a或"根据某物实现出”

原书标题:Model “has-a” or “is-implemented-in-terms-of” through composition,同Rule32,侯捷老师翻译为“塑模”。

复合关系(composition)是一种常见的类关系,当某种类型的对象内含有它种类型的对象时,便是此种关系。

复合关系分为2种:"has-a" 和 “is-implemented-in-terms-of"。

  • has-a”关系:

    指的是应用域部分,不参与内的具体各项实现。是一种单纯的完备对象的包含关系,比如Person类有Address、PhoneNumber、Job等类型的成员变量,又或是Image类有Buffer、Mutexx、SearchTree等类型的成员。

  • is-implemented-in-terms-of“关系:
    指的是实现域部分,参与类的各类实现,比如数据结构的设计中,想用现有的 std::list来实现Set类,这样可能效率不高(通常更具效率的实现是采用平衡查找树 ),但是可行。
    📌:此处不能让Set以public继承于std::list,因为list允许重复元素,而Set不行,不满足“is-a”关系。
    正确实现部分代码示例如下:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    template<class T>
    class Set{
    public:
        bool contains(const T& item)const{
            return std::find(rep_.begin(),rep_.end(),item) != rep_.end();  
        }
        void insert(const T& item){
            if(!contains(item)) rep_.push_back(item);
        }
        void remove(const T& item);// 实现略
        std::size_t size() const;  // 实现略
    private:
        std::list<T> rep_; //用来表述Set的数据
    };
    

R39 明智而审慎地使用private继承

“明智而审慎”的意思是👉当考虑了其他方案对比后,仍然觉得private继承是最合适的,才使用它。

首先明确private继承的2个特性:

  1. 编译器不会自动将一个derived-class对象隐式转换为base-class对象(函数入参时);
  2. 继承而来的成员,在derived-class中都会变成private属性;

private继承的意义:意味着implemented-in-terms-of,在类关系设计上没有太大意义,只看重软件实现。

考虑以下使用private的2个应用场景:

  • derived-class想继承base-class的某public接口实现,但又想隐藏此接口

    考虑如下应用场景:对于一个已知的类Widget,想用另一个已知的计时类Timer辅助性能分析,在尽量小改动已有代码的情况下,如何启用Timer?

    private继承做法:让Widget类private继承于Timer,重写父类Timer的onTick函数。

    具体代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    class Timer{
    public:
        explicit Timer(int tickFrequency);
        virtual void onTick() const;//定时器滴答一次,自动被调用一次
    };
    class Widget:private Timer{
    private: //😋 private继承而来的所有成员都是private属性
        virtual void onTick() const;//查看并记录Widget数据,资源等
    };
    

    该问题除了上方的private继承,能不能用其他方案替代private继承呢?

    👉 👉“public继承+复合”替代private继承:在Widget内部嵌套定义private属性的新类WidgetTimer:private Timer,即可同样启用Timer且隐藏了Timer。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Widget{
    private:
        class WidgetTimer:public Timer{// 类内嵌套定义
        public:
            virtual void onTick() const;
        };
        WidgetTimer wTimer_;
    };
    

    ⭐⭐ WidgetTimer也可以不定义在Widget类内,类内只放WidgetTimer* 和WidgetTimer的前置声明,完全解耦合,降低编译依赖性。而这样的设计自由度是单纯的private继承不具备的。

  • 空白基类最优化(EBO,empty base optimization)

    ⚡值得一提:空类(Empty Class)是指不含non-static数据成员和virtual-func的类。

    空类的size会被C++强制要求至少为1,通常是用1个char占位。如果让Empty-Class作为数据成员,因为内存对齐而导致Derived-Class浪费内存。

    示例代码 👇:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    
    class Empty { // 空类,1字节. 不含non-static数据,不含virtual
     	void privteFoo() { std::cout << "private non-virtual."; 	}
    public:
        typedef char*  pChar;
        typedef void(*pFuncReadData)(std::string url);
        enum class clolr {
            red,green,blue
        };
        void foo() { std::cout << "public non-virtual!"; }
        static int count ;//static 数据也不属于class实体
    };
    class HoldsIntsAndEmpty { //内存对齐后12字节
        int x_;   // 4字节
        Empty e_; // 1字节
        int y_;   // 4字节
    };
    class HoldsInts:private Empty {//使用EBO,类大小8字节
        int x_;//4字节
        int y_;//4字节
    };
    

    EBO优化可以减少Derived-Class的内存大小,注意EBO只适用于单继承


R40 明智而审慎地使用多重继承

多重继承(multiple inheritance)是指继承一个以上的父类。但是这些父类应该避免拥有共同的祖父类,会形成比较麻烦的“菱形继承”(或者叫钻石继承)。

  • 多重继承的成本以及副作用

    上面说“菱形继承”比较麻烦,主要原因是如果祖父类如果拥有某个成员变量x,那么2个父类分别public形式继承了x,到了目标子类就有了2份x。

    解决问题的办法是虚继承(virtual inheritance),如此,上述子类只有一份x。为保证虚继承的正确性,编译器在背后需要付出更多代价,可能造成子类内存更大或运行速度更慢。

    👉如果存在菱形继承,那么祖父类尽量不要持有数据成员。

    虚继承示例代码如下:

    1
    2
    3
    4
    
    class File{...};  //祖父类最好不要持有non-static数据成员
    class InputFile: virtual public File{...};
    class OutputFile: virtual public File{...};
    class IOFile:public InputFile,public OutputFile{...};
    
  • 应用场景:public继承接口+private继承实现

    思考这样的应用场景,PersonBase类是虚基类,RealPerson是目标子类(需要继承接口),但是获取name和birthDate信息的函数在另一个PersonInfo类都有了现成的实现(只需要简单修改该实现)。

    两者结合后,即让RealPerson类public继承于PersonBase,private继承于PersonInfo。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    class PersonBase {
    public:
        virtual ~PersonBase(){}
        virtual std::string name() const = 0;
        virtual std::string birthDate() const = 0;
    };
    class PersonInfo {
    public:
        virtual  ~PersonInfo() {}
        explicit PersonInfo(int pID):id_(pID) {}
        virtual const char* theName() const{
            static char value[1024];
            static const char* exampleName = "Luka";// 计算过程略,用固定字符串替代
            std::strcpy(value, valueDelimLeft());   // 获取左界定符
            std::strcat(value, exampleName);
            std::strcat(value, valueDelimRight());// 获取右界定符
            return value;
        }
        virtual const char* theBirthDate() const { return "1990-1-1"; }
        virtual const char* valueDelimLeft() const { return "["; };
        virtual const char* valueDelimRight() const { return "]"; };
    private:
        int id_ = 0;
    };
    

    多重继承的代码为👇:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    class RealPerson :public PersonBase, private PersonInfo {//多重继承
    public:
        explicit RealPerson(int pID) :PersonInfo(pID) {} // 委托构造
        virtual std::string name() const{ //实现必要的虚基类Person的pure-virtual成员函数
            return PersonInfo::theName(); 
        }
        virtual std::string birthDate() const { 
            return PersonInfo::theBirthDate(); 
        }
    private:
        virtual const char* valueDelimLeft() const { return ""; };//重写界定符函数
        virtual const char* valueDelimRight() const { return ""; };
    };
    

    最后应用端代码:

    1
    2
    
    RealPerson rPerson(613);
    std::cout << rPerson.name(); //输出Luka ,而不是[Luka]
    

    可以看到,多重继承体系完美解决该问题。

回到本节开头,明智和审慎的意思是👉即使多重继承可以用单继承方案替代解决,思考后,如果多重继承依然是最简洁、最易维护、最合理的做法,那就选择它。