目录

条款32~40 继承与面向对象设计

条款32~40 继承与面向对象设计

前言

  1. 这一大章条款 主要是说的继承与面向对象设计,说实话我觉得没有看这一大章的必要,去看本博客的《设计模式》章节
  2. 设计模式这个名字太高大上,其实他就是可复用的面向对象设计,没错,你在学设计模式其实就是相当于学面向对象
  3. 设计模式也是基于面向对象设计原则的,所以你不懂设计模式,不懂面向对象设计原则,我觉得你看完这一章作用也不大
  4. 懂设计模式的希望你看本章节会有一些新的体会,其实每个条款都是对应着面向对象设计原则,同时也有很多设计模式可以解决这些条款的问题
  5. 没有错,在本大章节,我就是设计模式吹!!!

条款32 确保public继承是is-a关系

  1. “is-a"的概念

    • 以 C++进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味 “is-a”(是一种)的关系

    • 如果你令 class D以 public 形式继承 class B,你便是告诉编译器:

      • 每一个类型为D的对象同时也是一个类型为B的对象,反之不是
      • B对象可使用的地方,D对象一样可以使用,反之不是
    • 下面的Student类 public 继承 Person类

      1
      2
      
      class Person {};
      class Student :public Person {};
      

      任何获得类型为Person(pointer-to-Person或reference-to-Person)的实参,都可以接受一个Student(pointer-to-Student或reference-to-Student)对象

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      
      void eat(const Person& p);
      void study(const Student& s);
      
      int main()
      {
          Person p;
          Student s;
      
          eat(p);    //正确
          eat(s);    //正确
          study(s);  //正确
          study(p);  //错误
      
          return 0;
      }
      
    • 上面的规则只对"public"继承才成立哦~,“private"“protected"不成立

  2. 设计正确的继承模型

    • 鸟可以飞,企鹅也是一种鸟。于是我们可能设计下面错误的继承模型

      • 企鹅虽然属于鸟类,但是企鹅不会飞
      • 设计中,我们错误的将鸟类中的fly()虚函数派生给了Penguin类
      1
      2
      3
      4
      5
      6
      7
      8
      
      //鸟类
      class Bird {
      public:
          virtual void fly();
      };
      
      //企鹅,也继承了fly()虚函数
      class Penguin : public Bird {};
      
    • 我们应该修改上面的代码,下面才是合适的模型,学过设计模式知道抽象思想的,其实就是基于抽象类再抽象了一层

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      15
      
      //鸟类
      class Bird {
          //无fly()函数
      };
      
      //会飞的鸟类
      class FlyingBird :public Bird {
      public:
          virtual void fly();
      };
      
      //企鹅不会飞
      class Penguin :public Bird {
      
      };
      
  3. 以“编译期”确认关系代替“运行期”确认关系

    • 还是基于上述 鸟和企鹅的例子

      企鹅不会飞,但是我们仍然让Bird定义fly()函数,然后让Penguin继承于Bird,与上面不同的是,我们让Penguin在执行fly()函数的时候报出一个错误(运行期执行)

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      
      class Bird {
      public:
          virtual void fly();
      };
      
      void error(const std::string& msg);
      class Penguin :public Bird {
      public:
          virtual void fly() {
              error("Attempt to make a penguin fly!");
          }
      };
      

      上面的代码是在运行期检查这种错误的,下面我们设计让编译器在编译的时候检查出企鹅不会飞这种错误

      1
      2
      3
      
      class Bird {
          //无fly()函数
      };
      

      class Penguin :public Bird { //… };

      Penguin p; p.fly();

      1
      2
      3
      4
      5
      6
      
      
      *这个问题的关键是:并不是所有的鸟都会飞,因此Bird不应该暴露Fly接口*
      
      *所以还是再抽象一个会飞的鸟类接口,我觉得是可以的,但是也随之暴露一个问题了,面向对象设计原则有一个**类应该是单一职责**,如果不是单一职责,那么子类的数目就会急剧膨胀了*
      
      *其实知道装饰模式和桥模式,这里就可以利用 **组合**去优化,但是那是设计模式的知识点了,大家可以看《[装饰模式](https://vlicecream.github.io/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E8%A3%85%E9%A5%B0%E6%A8%A1%E5%BC%8F/)》《[桥模式](https://vlicecream.github.io/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F-%E6%A1%A5%E6%A8%A1%E5%BC%8F/)》*
      
  • 再考虑矩形和正方形,从几何角度讲,正方形是一种矩形。从软件设计角度讲,正方形是矩形吗?应该使用public继承吗?

    思考:对矩形可以单独设置宽度,而不影响高度。但是对于正方形,设置宽度,要求高度随之变化,否者就不是正方形了。因此不能使用public继承

Summary

  1. public继承意味is-a
    • 适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每一个derived class对象也都是一个bass class 对象
  2. 在类的设计上 其实要蛮下一番心思的(这其实就是题外话了,推荐本博客《设计模式》专题)

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

  1. C++基类和派生类的作用域为嵌套关系,同时存在作用域屏蔽规则,例如:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    class Base{
    public:
        void fun();
        ...
    private:
        int a;
        ...
    }
    class Derived:public Base{
        ...
    }
    

    那么Derived和Base之间的作用域关系就像这样

    https://raw.githubusercontent.com/vlicecream/cloudImage/main/data/202303111858185.png

    如果Derived中没有定义a和fun,那么对在Derived作用域内对a的fun的使用将会由内而外直至全局作用域逐层查找;

    如果Derived中定义了a和fun,那么会使用Derived中的a和fun,但是如果Derived中a和fun的定义如果像这样:

    1
    2
    3
    4
    5
    6
    7
    8
    
    class Derived:public Base{
    public:
        void fun(int);
        string a;
        ...
    private:
        ...
    }
    

    此时如果在Derived内存在如下语句:

    1
    2
    
    a=1;
    fun();
    

    都会编译不通过,因为由于名字屏蔽,Base的a和fun在Derived中将不可见,这就是作用域屏蔽规则.因此派生类对基类函数的重写将不是overload & override,而是隐藏

  2. 在采用public继承时,如果派生类重写基类函数,名字屏蔽会使得基类中同名函数在派生类中不可见

    如果使基类的同名函数在派生类中仍然可见,可以使用using声明式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    
    class Derived:public Base{
    public:
        using Base::fun;
        void fun(int);
        ...
    private:
        string a;
        ...
    }
    

    如果并不想继承Base类所有的fun函数(private继承中可能出现),则可以使用"转交函数”(forwarding function)的方法

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    
    class Derived:private Base{
    public:
        void fun(){
            Base::fun();
        }
        void fun(int);
        ...
    private:
        string a;
        ...
    }
    
  3. 此条款主要讲的也是违反了面向对象原则之一(里氏替换原则-我们应该去重写,而不是隐藏)

Summary

  1. 我们要小心谨慎 不能隐藏了父类函数
  2. 可以使用类名作用域决定调用父类还是子类的函数

条款34 接口继承与实现继承

说实话 这个思想我是真觉得你应该去看设计模式,光看这一个条款你可能不理解接口继承与实现继承,说到底其实就是一个抽象接口的思想,但是实现起来可是有一番难度的

本条款其实就是介绍了三种虚函数的好坏而已,同时看到这里大家有没有想起来《条款31》呀~

好,进入主题

  1. 继承中接口的处理方式

    • 作为类的设计者,对于基类的成员函数可以大致做下面三种方式的处理:

      ​ ① 纯虚函数:基类定义一个纯虚函数,然后让派生类去实现

      ​ ② 非纯虚的virtual虚函数:基类定义一个非纯虚的virtual虚函数,然后让派生类去重写覆盖(override)

      ​ ③ 普通的成员函数:基类定义一个普通的成员函数,并且不希望派生类去隐藏

    • 本文依次介绍上面这三种设计的原理。下面定义一个类,作为本文讲解的基础:

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      
      - class Shape {
        public:
            virtual void draw()const = 0; //纯虚函数
            virtual void error(const std::string& msg); //非纯虚函数
            int objectID()const; //普通成员函数
        };
      
      class Rectangle :public Shape {};
      
      class Ellipse :public Shape {};
      
  2. 纯虚函数

    • 这是文章开始提到的第一种情况:派生类只继承基类的成员函数的接口(纯虚函数),派生类自己实现纯虚函数

    • 纯虚函数的一些特征:

      ① 拥有纯虚函数的类不能实例化 ② 拥有纯虚函数的类,其派生类必须实现该纯虚函数

      1
      2
      3
      4
      5
      6
      7
      
      class Shape {
      public:
          virtual void draw()const = 0; //纯虚函数
      };
      
      class Rectangle :public Shape {};
      class Ellipse :public Shape {};
      
    • 其中涉及纯虚函数的目的为:

      • Shape是所有图形类的基类,其提供一个draw()的画图函数,但是由于其派生类(矩形、圆等)的画图方式都是不一样的,因此无法为draw()函数提供一种默认缺省行为,因此Shape将draw()定义为纯虚函数, 让其派生类去自动实现
       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 Shape {
      public:
          virtual void draw()const = 0;
      };
      
      class Rectangle :public Shape {
      public:
          virtual void draw()const {
              std::cout << "Rectangle" << std::endl;
          }
      };
      class Ellipse :public Shape {
      public:
          virtual void draw()const {
              std::cout << "Ellipse" << std::endl;
          }
      };
      
      int main()
      {
          //Shape *ps = new Shape;    //错误,不能实例化
          Shape *ps1 = new Rectangle;
          Shape *ps2 = new Ellipse;
      
          ps1->draw(); //调用Rectangle::draw()
          ps2->draw(); //调用Ellipse::draw()
      
          return 0;
      }
      
  3. 非纯虚的virtual虚函数

    • 先来看一个virtual函数的演示案例

      假设某航天公司设计一个飞机继承体系,该公司现在只有A型和B型两种飞机,代码如下

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      class Airport {}; //机场
      
      class Airplane {  //飞机的基类
      public:
          virtual void fly(const Airport& destination) {
              //飞机飞往指定的目的地(默认行为)
          }
      };
      
      //A、B两个派生类
      class ModelA :public Airplane {};
      class ModelB :public Airplane {};  
      // ModelB 哈哈哈 让我想起最近的一个新车啥车型 主持人说 - "ma de b" 笑死
      

      fly()函数被声明为virtual函数,因为A和B两个飞机具有相同的默认飞行行为,因此在Airplane类的fly()函数中定义这种默认飞行行为,然后让A和B继承。这样的好处是:

      ​ ① 将所有性质搬到到base class中,然后让两个class继承

      ​ ② 避免代码重复,并提升未来的强化能力,减缓长期维护所需的成本

    • 但是万一有一个ModelC,不使用这个fly()呢,吃瘪了吧,所以要把虚函数改成纯虚函数

      展示第一种修改方法

       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 Airport {}; //机场
      class Airplane {
      public:
          virtual void fly(const Airport& destination) = 0;
      protected:
          void defaultFly(const Airport& destination) {
              //飞机飞往指定的目的地(默认行为)
          }
      };
      
      class ModelA :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              defaultFly(destination);
          }
      };
      class ModelB :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              defaultFly(destination);
          }
      };
      
      class ModelC :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              //C型飞机不可以使用默认飞行行为,因此定义自己的飞行方式
          }
      };
      

      现在C型飞机,或者别的添加的飞机就不会意外继承默认的飞行行为了(因为我们将默认的飞行行为封装到一个defualtFly函数中了),自己可以在fly()中定义飞行行为了

      注意,在A和B的类的fly()函数中,对defaultFly()做了一个inline调用(见条款30,inline和virtual函数之间的交互关系)

      第二种修改方法

      上面我们将fly()接口和实现(defaultFly()函数)分开来实现,有些人可能会反对这样做,因为这样会因过度雷同的函数名称而引起class命名空间污染

      如果不想将上述两个行为分开,那么可以为纯虚函数进行定义,在其中给出defaultFly()函数的相关内容。例如:

       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
      
      class Airport {}; //机场
      class Airplane {
      public:
          //实现纯虚函数
          virtual void fly(const Airport& destination) = 0 {
              //飞机飞往指定的目的地(默认行为)
          }
      };
      
      class ModelA :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              Airplane::fly(destination);
          }
      };
      class ModelB :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              Airplane::fly(destination);
          }
      };
      
      class ModelC :public Airplane {
      public:
          virtual void fly(const Airport& destination) {
              //定义自己的飞行方式
          }
      };
      

      这个设计实现的功能和上面的演示案例是一样的,只不过在派生类的fly()函数中用纯虚函数Airplane::fly替换了独立函数Airplane::defaultFly

      这种合并行为丧失了“让两个函数享有不同保护级别”的机会:例如上面的defaultFly()函数从protected变为了public

  4. 普通的成员函数

    • 最后来看看Airplane的普通成员函数

      1
      2
      3
      4
      5
      6
      7
      
      class Shape {
      public:
          int objectID()const; //普通成员函数,不希望派生类隐藏
      };
      
      class Rectangle :public Shape {};
      class Ellipse :public Shape {};
      
    • 设置普通的成员函数的目的:

      • 意味着基类不希望派生类去隐藏这个成员函数
      • 实际上一个普通的成员函数所表现的不变性凌驾其特异性,因为它表示不论派生类变得多特特异化,它的行为都不可以改变
    • 在上面的代码中:

      • 每个Shape对象都有一个用来产生对象识别码的函数
      • 此识别码总是采用相同计算方法,该方法有Shape::objectID的定义式决定,任何派生类都不应该尝试改变其行为
    • 由于普通成员函数代表的意义是不变性凌驾特异性,所以它绝不该在派生类中被重新定义(这也是条款36所讨论的一个重点)

Summery

  1. 接口继承和实现继承不同。在public继承之下,derived classes总是继承base class的接口
  2. pure virtual函数只具体hiding接口继承
  3. impure virtaul函数具体指定接口继承及缺省实现继承
  4. non-virtual函数具体指定接口继承以及强制性实现继承
  5. 题外话,是不是光看这个条款还是不懂接口继承和实现继承到底是啥,对吧

条款35 考虑virtual函数以外的选择

  1. 一般做法

    • 我们都玩过游戏,在砍杀游戏中,我们假定使用成员函数healthValue,它会返回一个整数,表示人物的健康程度。将其设置为virtual似乎是再明白不过的做法

      1
      2
      3
      4
      5
      
      class GameCharacter {
      public:
        virtual int healthValue() const;
        ...
      }
      
  2. Non-Virtual Interface手法 实现 Template Method模式

    • 这里是不是不懂Teamplate Method模式,这可不是c++的 template 哦,不懂就去看《设计模式-模板方法

    • 有一种流派,它主张virtual函数应该几乎总是private。这个流派的拥护者建议,较好的设计是保留healthValuepublic成员函数,但让它成为non-virtual,并调用一个private virtual函数

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      14
      
      class GameCharacter {
      public:
        int healthValue() const {
          ...
          int retValue = doHealthValue;
          ...
          return retValue;
        }
        ...
      private:
        virtual int doHealthValue() const { // derived classes 可重新定义它
          ...  // 缺省算法,计算健康指数
        }
      }
      
  3. Function Pointers实现 Strategy 模式

    • 这里是不是不懂Strategy模式,不懂就去看《设计模式-策略模式

    • 另一种流派设计主张”人物健康指数的计算与人物类型无关“,这样计算完全不需要”人物“这个成分

      例如我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,而我们可以调用该函数进行实际计算

       1
       2
       3
       4
       5
       6
       7
       8
       9
      10
      11
      12
      13
      
      class Gamecharacter; // 前置声明
      // 以下函数就是计算健康指数的缺省算法
      int defaultHealthCalc(const GameCharacter& gc);
      class GameCharacter {
      public:
        typedef int (*HealthCalcFunc) (const GameCharacter&);
        explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
          : healthFunc(hcf) { }
        int healthValue() const { return healthFunc(*this); }
        ...
      private:
        HealthCalcFunc healthFunc;
      }
      

      相比于之前做法,该Strategy设计模式的简单应用,它提供了某些有趣弹性