DJの小站

记录生活,发现自己

0%

Effective C++ 继承与面向对象设计

一、确定public继承塑造出is-a关系

  1. public继承意味着”is - a”,适用于base classes身上每一件事都适用于derived classes。

  2. 若derived class与base class有差别,但语义上derived class确实归于base class,则考虑改善设计,举例:

    1
    2
    3
    4
    5
    6
    class Brid{
    virtual void fly() = 0;
    }
    class Penguin : public Brid{
    ....
    }

    企鹅确实属于鸟类,但企鹅并不会飞,这时有两种选择:

    一是选择不给Bridfly()函数,将Brid派生为飞鸟和不会飞的鸟,再让企鹅去继承不会飞的鸟类,这样在编译期写Penguin.fly()就会报错。

    二是选择使Penguin重写fly(),使得若是有控制流进入这个函数就报错,注意,这并不是说企鹅不能飞,而是,企鹅能飞,但那么做是一种错误。

    两种方式都可以解决问题,一般而言更推荐第一种,因为好的接口应防止无效代码通过编译!

  3. public继承下,绝不重新定义继承来的non-virtual函数,因为是is - a关系

二、避免遮掩继承而来的名称

  1. 根据作用域准则,C / C++ 程序在寻找一个变量 / 函数时,总是先在local作用域中寻找,找不到再去包含local的,更大作用域中寻找,依次类推,若是直到global作用域还找不到匹配的变量 / 函数,就会报错。

  2. 基于1,当我们在子类中重写出一个和父类同名的接口时,父类的对应接口会被覆盖掉,即使是父类的重载接口也一样会被覆盖:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    class Base{
    public:
    virtual void mf1();
    virtual void mf1(int);
    virtual void mf2();
    void mf3();
    void mf3(double);
    };
    class Derived : public Base{
    public:
    virtual void mf1(); //覆写
    void mf3();
    void mf4();
    };

    Derived d;
    d.mf1(); // ok,调用的是Derived的mf1
    d.mf1(10); // error,Derived::mf1遮掩了Base::mf1
    d.mf2(); //ok,调用的是Base类的mf2
    d.mf3(); //ok,调用Derived中的mf3
    d.mf3(10.0) //error,Derived::mf3遮掩了Base::mf3
  3. 当子类中重写了父类的接口,却又想调用父类接口时,有两种方法:

    1. 使用using使父类接口暴露在子类中,但需注意的是,这会使所有父类同名重载接口暴露:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      class Derived : public Base{
      public:
      using Base::mf1;
      using Base::mf3;
      virtual void mf1(); //覆写
      void mf3();
      void mf4();
      };
      d.mf3(); //ok,使用Derived接口(虽然Base的mf3()也暴露了,但这里遵循作用域准则,先小后大
      d.mf3(1); //ok,使用Base的mf3(double)接口
    2. 当你只想使得某个特定的接口或者部分接口暴露时,使用一个转发函数:

      1
      2
      3
      4
      5
      6
      class Derived : private Base{
      public:
      void mf3(){
      Base::mf3();
      }
      };

三、区分接口继承和实现继承

  1. 接口继承和实现继承不同,public继承下,derived类总是继承base类的接口

  2. pure virtual函数只具体指定接口继承(派生类必须继承这个接口)

  3. impure virtual函数指定接口继承,并提供一份缺省实现继承

  4. non-virtual函数同时指定接口和强制的实现继承,一般认为它不应该被重新定义

  5. tips : pure-virtual函数可以被实现:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    class Shape{
    public:
    void func() = 0;
    };
    class Rectangle : public Shape{
    ...
    };
    void Shape::func(){
    ...
    }

    Shape* ps = new Shape;
    Shape* ps1 = new Rectangle;
    ps -> func(); //error
    ps1 -> Shape::func(); //ok

四、考虑virtual函数外的其他选择

在RPG游戏中,我们需要计算一个角色扣血情况,由于有不同的角色与NPC,所以我们可以很自然地想到将扣血函数在一个基类中写为虚函数:

1
2
3
4
class GameCharater{
public:
virtual int healthValue() const;
};

而除了虚函数以外,有一些手段能达到与虚函数相仿的目的:

  1. 藉由Non - Virtual Interface实现Template Method 模式

    它通过一个public non-virtual成员函数包裹较低访问性的virtual函数,其中virtual函数的访问级应为private或protected,否则没有意义

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class GameCharater{
    public:
    int healthValue() const{
    ... // 前置操作
    int retVal = doHealthValue();
    ... // 后置操作
    }
    private:
    virtual doHealthValue() const;
    } ;

    基类采用NVI手法,在子类中重新定义virtual函数即可(调用包含virtual函数的普通成员函数是多态)。

  2. 藉由function pointers实现的Strategy模式(策略模式)

    将virtual函数替换成函数指针成员变量,这是Strategy模式的一种表现形式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    class GameCharater;
    int defaultHealthCalc(const GameCharater& gc);
    class GameCharater{
    public:
    using HealthCalcFunc = int(*)(const GameCharacter&); //使用using定义函数指针
    typedef int(HealthCalcFunc *)(const GameCharacter&); //使用typedef定义函数指针
    explicit GameCharater(HealthCalcFunc hcf = defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc healthFunc;
    };

    使用这种方式有两个好处:

    一是可以使同一个人物对象可以有不同的接口一样的函数

    二是可以使调用函数在运行期变更

    但它的缺点也比较明显,因为本该被成员解决的问题需要被外部函数解决,这无疑降低了类的封装性

  3. 藉由std::function实现的Strategy模式(策略模式)

    在作者写这本书时,function还在tr1中,如今在CXX1x中已成为std中的成员,std::function与function pointer效果类似,但它提供了封装和对不同函数等效物的支持,它可以以函数对象,函数,成员函数等作为参数,从而具备了更惊人的弹性:

    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
    class GameCharater;
    int defaultHealthCalc(const GameCharater& gc);
    class GameCharater{
    public:
    using HealthCalcFunc = std::function<int(const GameCharater&)>;
    explicit GameCharater(HealthCalcFunc hcf = defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc healthFunc;
    };

    //不同返回值的函数
    short calcHealth(const GameCharacter&);

    //函数对象
    struct HEalthCalculator{
    int operator() (const GameCharater&) const{
    ...
    }
    };

    //类中public函数
    class GameLevel{
    public:
    float health(const GameCharater&) const;
    };

    //上述三者都可以充当一个类函数指针传入function:
    GameCharater gc1(calcHealth);
    GameCharater gc2(HEalthCalculator());
    GameCharater gc2(std::bind(&GameLevel::health, std::placeholders::_1))
  4. 古典Strategy模式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    class GameCharater;
    class HealthCalcFunc{
    public:
    virtual int calc(const GameCharater& gc);
    };
    HealthCalcFunc defaultHealthCalc;
    class GameCharater{
    public:
    explicit GameCharater(HealthCalcFunc* hcf = &defaultHealthCalc):
    healthFunc(hcf){}
    int healthValue() const{
    return healthFunc(*this);
    }
    private:
    HealthCalcFunc* healthFunc;
    };

五、绝不重新定义继承来的参数缺省值

所谓静态类型,是指变量在声明时所采用的类型,在编译期就被决定好,而动态类型,是指当前所指的对象的类型,直到运行期才决定好:

1
2
3
4
5
6
7
8
9
10
class Shape{
enum colors{RED, GREEN, BLUE};
public:
void draw(int color = RED);
};
class Circle : public Shape{
...
};
Shape *b = new Circle;
//b的静态类型:Base*,动态类型:Derived*

在派生类中重新定义继承而来的缺省参数值是静态绑定!所以当你这样定义,在运行期往往会出现出人意料的结果:

1
2
3
4
5
6
7
class Circle : public Shape{
public:
void draw(int color = GREEN);
};

Shape *b = new Circle;
b->draw(); //本想令color是Circle中定义的GREEN,但这里由于函数参数是静态绑定,所以color是Shape中定义的RED!

而且,在派生类中重写一遍参数缺省值也是不可以的,这意味着你修改基类的缺省值时,所有子类的缺省值也得被改一遍。

六、通过复合塑造出“has - a”

  1. 复合意味着某个类的成员是另一个类,而public继承则意味着某个类是父类的特例
  2. 在应用域,复合意味着“has - a”,即有一个其它类型的成员对象,在实现域,复合意味着“根据某物实现出”
  3. 要注意区分“is - a”和根据某物实现出,比如,你想使用std::list来构建一个自己的myset,如果使用public继承,语义和行为上就是“mysetstd::list的一个特例,但这是不正确的,事实上,我们想要的是“根据某物实现出”,即将std::list写为myset的一个成员。

七、明智而谨慎地使用private继承

  1. 为什么私有代表了has-a关系

    首先明确一点,public继承之所以代表is - a关系,是因为父类向外界提供的一切接口(public),子类都能代为提供,故而子类表现得像是一个特殊的父类;而使用私有继承,基类的公有成员和保护成员都将成为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们。因此私有继承提供的特性与复合相同:获得实现,但不获得接口。所以,私有继承也可以用来实现has-a关系。

  2. 何时使用私有继承,何时使用复合

    由于既可以使用复合,也可以使用私有继承来建立has-a关系。大多数c++程序员倾向于前者。不过私有继承所提供的特性确实比包含多。例如,假设需要访问has - a关系类的护成员,由于复合关系不在继承体系中,所以不能访问,而private继承就可以。
    另一种需要使用私有继承的情况是需要重新定义虚函数。派生类可以重新定义虚函数,但包含类不能。使用私有继承,重新定义的函数将只能在类中使用,而不是公有的。

  3. 空白基类最优化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    //使用复合
    class Empty{
    //没有成员,只有函数
    };
    class Util_1{
    int x;
    Empty e;
    };
    sizeof(Util_1) == (1 + 4) != sizeof(int);

    //使用继承
    class Util_2 : private Empty{
    int x;
    }
    sizeof(Util_2) == 4 == sizeof(int)

八、明智而谨慎地使用多重继承

  1. C++支持多重继承,基本继承方式:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Base1{
    public:
    void func();
    }
    class Base2{
    public:
    void func();
    }
    class Derived : public Base1, public Base2{
    ...
    }

    当继承而来的多个父类中有同名可访问成员时,会出现歧义,解决歧义的方法是说明它在哪个作用域中,例:

    1
    2
    Derived d;
    d.Base1::func();
  2. 多重继承使用时会出现菱形继承的问题

    菱形继承最经典的例子就是C++中stream的例子了,这里以文件流来举例:

    1
    2
    3
    4
    5
    6
    7
    class File{
    public:
    int* file_handle;
    }
    class InputFile : public File;
    class OutputFile : public File;
    class IOFile : public InputFile , public OutputFile;

    如代码所示,这个继承体系是一个菱形继承,假设IOFile对象想访问File对象的成员file_handle,那么它该访问的是从InputFile而来的还是OutputFile而来的成员呢,这又引发了歧义。编译器的一种可能的处理方法是,给两条路径来的成员都申请一块内存!也就是说,这种情况下,IOFile中有两个file_handle成员,那么一个IOFile竟然等效于两个File!这是不对的:

    1
    2
    3
    4
    IOFile fd;
    std::cout << &fd -> InputFile::file_handle << std::endl;
    std::cout << &fd -> OutputFile::file_handle << std::endl;
    //手动试一下会发现两者内存位置不同

    所以,我们需要虚继承来解决这个问题:

    1
    2
    3
    4
    5
    6
    7
    class File{
    public:
    int* file_handle;
    }
    class InputFile : virtual public File;
    class OutputFile : virtual public File;
    class IOFile : public InputFile , public OutputFile;

    而进行虚继承,必会付出虚函数表指针的空间代价和查找虚函数的时间代价,是否使用虚继承及虚函数应看具体情况