【Effective C++】 (六) 继承与面向对象设计

在这里插入图片描述

【六】继承与面向对象设计

条款32 : 确保public继承是"is a"的关系

Item 32: Make sure public inheritance models “is-a”.

C++面向对象程序设计中,最重要的规则便是:public继承应当是"is-a"的关系。当Derived public继承自Base时, 相当于你告诉编译器和所有看到你代码的人:BaseDerived的抽象,Derived就是一个Base,任何时候Derived都可以代替Base使用。
当然这只适合public继承,如果是private继承那是另外一回事了,见 Item 39
比如一个Student继承自Person,那么Person有什么属性Student也应该有,接受Person类型参数的函数也应当接受一个Student:

void eat(const Person& p);
void study(const Person& p);

Person p; Student s;
eat(p); eat(s);
study(p); study(s);

语言的二义性

上述例子也好理解,也很符合直觉。但有时情况却会不同,比如Penguin继承自Bird,但企鹅不会飞:

class Bird{
public:
vitural void fly();
};
class Penguin: public Bird{
// fly??
};

这时你可能会困惑Penguin到底是否应该有fly()方法。但其实这个问题来源于自然语言的二义性: 严格地考虑,鸟会飞并不是所有鸟都会飞。我们对会飞的鸟单独建模便是:

class Bird{...};
class FlyingBird: public Bird{
public:
    virtual void fly();
};
class Penguin: public Bird{...};

这样当你调用penguin.fly()时便会编译错。当然另一种办法是Penguin继承自拥有fly()方法的Bird, 但Penguin::fly()中抛出异常。这两种方式在概念是有区别的:前者是说企鹅不能飞;后者是说企鹅可以飞,但飞了会出错。
哪种实现方式好呢?Item 18 中提到,接口应当设计得不容易被误用,最好将错误从运行时提前到编译时。所以前者更好!

错误的继承

生活的经验给了我们关于对象继承的直觉,然而并不一定正确。比如我们来实现一个正方形继承自矩形:

class Rect{...};
void makeBigger(Rect& r){
    int oldHeight = r.height();
    r.setWidth(r.width()+10);
    assert(r.height() == oldHeight);
}
class Square: public Rect{...};

Square s;
assert(s.width() == s.height());
makeBigger(s);
assert(s.width() == s.height());

根据正方形的定义,宽高相等是任何时候都需要成立的。然而makeBigger却破坏了正方形的属性, 所以正方形并不是一个矩形(因为矩形需要有这样一个性质:增加宽度时高度不会变)。即Square继承自Rect是错误的做法。 C++类的继承比现实世界中的继承关系更加严格:任何适用于父类的性质都要适用于子类!
本节我们谈到的是"is-a"关系,类与类之间还有着其他类型的关系比如"has-a", "is-implemented-in-terms-of"等。这些在Item-38和Item-39中分别介绍。

条款33: 避免隐藏继承而来的名称

条款33:Avoid hiding inherited names

简单变量的作用域
这里我们先引入作用域的情况,在以下代码简单变量中,作用域是这样的:

继承类的作用域
那么继承的作用域是如何的呢,看以下代码:

我们假定derived class内的mf4实现如下:

void Derived::mf4(){
    ...
    mf2();
    ...
}

编译器看到名称mf2查找顺序如下:
先查看local作用域(也就是mf4覆盖的作用域)——>外围作用域Derived覆盖作用域——>再外围查找这里是base class的mf2——>base class所在的namespace作用域——> global作用域
注:上述箭头是在当前没有找到的情况下,进行下一步箭头操作

我们再假定:重载mf1``mf3,并添加一个新版mf3到Derived去。如下图:

这里以作用域为基础的“名称遮掩规则”并没有改变,因此base class所有名为mf1 mf3都被derived class的mf1 mf3遮掩掉了。

处理“继承而来”的遮掩行为
那如果使用才能搞定C++的“继承而来”的缺省遮掩行为:

如果Derived以private形式继承Base,而Derived唯一想继承的mf1是那个无参版本。using声明式这里就不起作用了,因为using声明式会令继承而来的某给定名称之所有同名函数在derived class都可见,这里可以使用一个简单的转交函数搞定forwarding function:


注意:

  • derived class 内名称会遮掩base class内的名称。在public继承下没有人希望如此。
  • 为了让被遮掩的名称重见天日,可使用using声明式和转交函数forwarding function

条款34:区分接口继承和实现继承

Item 34: Dirrerentiate between inheritance of interface and inheritance of implementation.

不同于Objective C或者Java,C++中的继承接口和实现继承是同一个语法过程。 当你public继承一个类时,接口是一定会被继承的(见Item32),你可以选择子类是否应当继承实现:

  • 不继承实现,只继承方法接口:纯虚函数。
  • 继承方法接口,以及默认的实现:虚函数。
  • 继承方法接口,以及强制的实现:普通函数。

一个例子

为了更加直观地讨论接口继承和实现继承的关系,我们还是来看一个例子:Rect和Ellipse都继承自Shape。

class Shape{
public:
// 纯虚函数
virtual void draw() const = 0;
// 不纯的虚函数,impure...
virtual void error(const string& msg);
// 普通函数
int id() const;
};
class Rect: public Shape{...};
class Ellipse: public Shape{...};

纯虚函数draw()使得Shape成为一个抽象类,只能被继承而不能创建实例。一旦被public继承,它的成员函数接口总是会传递到子类。

  • draw()是一个纯虚函数,子类必须重新声明draw方法,同时父类不给任何实现。
  • id()是一个普通函数,子类继承了这个接口,以及强制的实现方式(子类为什么不要重写父类方法?参见 Item 33)。
  • error()是一个普通的虚函数,子类可以提供一个error方法,也可以使用默认的实现。

因为像ID这种属性子类没必要去更改它,直接在父类中要求强制实现!

危险的默认实现

默认实现通常是子类中共同逻辑的抽象,显式地规约了子类的共同特性,避免了代码重复,方便了以后的增强,也便于长期的代码维护。 然而有时候提供默认实现是危险的,因为你不可预知会有怎样的子类添加进来。例如一个Airplane类以及它的几个Model子类:

class Airplane{
public:
virtual void fly(){
    // default fly code
}
};
class ModelA: public Airplane{...};
class ModelB: public Airplane{...};

不难想象,我们写父类Airplane时,其中的fly是针对ModelA和ModelB实现了通用的逻辑。如果有一天我们加入了ModelC却忘记了重写fly方法:

class ModelC: public Airplane{...};
Airplane* p = new ModelC;
p->fly();

虽然ModelC忘记了重写fly方法,但代码仍然成功编译了!这可能会引发灾难。。这个设计问题的本质是普通虚函数提供了默认实现,而不管子类是否显式地声明它需要默认实现。

安全的默认实现

我们可以用另一个方法来给出默认实现,而把fly声明为纯虚函数,这样既能要求子类显式地重新声明一个fly,当子类要求时又能提供默认的实现。

class Airplane{
public:
virtual void fly() = 0;
protected:
void defaultFly(){...}
}
class ModelA: public Airplane{
public:
virtual void fly(){defaultFly();}
}
class ModelB: public Airplane{
public:
virtual void fly(){defaultFly();}
}

这样当我们再写一个ModelC时,如果自己忘记了声明fly()会编译错,因为父类中的fly()是纯虚函数。 如果希望使用默认实现时可以直接调用defaultFly()。
注意defaultFly是一个普通函数!如果你把它定义成了虚函数,那么它要不要给默认实现?子类是否允许重写?这是一个循环的问题。。

优雅的默认实现

上面我们给出了一种方法来提供安全的默认实现。代价便是为这种接口都提供一对函数:fly, defaultFly, land, defaultLand, … 有人认为这些名字难以区分的函数污染了命名空间。他们有更好的办法:为纯虚函数提供函数定义。
确实是可以为纯虚函数提供实现的,编译会通过。但只能通过Shape::draw的方式调用它。

class Airplane{
public:
virtual void fly() = 0;
};
void Airplane::fly(){
    // default fly code
}

class ModelA: public Airplane{
public:
virtual void fly(){
    Airplane::fly();
}
};

上述的实现和普通成员函数defaultFly并无太大区别,只是把defaultFly和fly合并了。 合并之后其实是有一定的副作用的:原来的默认实现是protected,现在变成public了。在外部可以访问它:

Airplane* p = new ModelA;
p->Airplane::fly();

在一定程度上破坏了封装,但Item 22我们提到,protected并不比public更加封装。 所以也无大碍,毕竟不管defaultFly还是fly都是暴露给类外的对象使用的,本来就不能够封装。
注意:

  • 接口继承和实现继承不同。在public继承下,derived class总是继承base class的接口
  • pure virtual函数只具体指定接口继承
  • impure virtual函数具体指定接口继承及缺省实现继承
  • non-virtual函数具体指定接口继承以及强制性实现继承

条款 35 考虑virtural函数以外的其他替代设计

补 俩个 设计模式 然后改
Item 35: Consider alternatives to virtual functions.
比如你在开发一个游戏,每个角色都有一个healthValue()方法。很显然你应该把它声明为虚函数,可以提供默认的实现,让子类去自定义它。 这个设计方式太显然了你都不会考虑其他的设计方法。但有时确实存在更好的,本节便来举几个替代的所涉及方法。

  • 非虚接口范式(NVI idiom)可以实现模板方法设计模式(Template Method),用非虚函数来调用更加封装的虚函数。
  • 用函数指针代替虚函数,可以实现策略模式。
  • 用tr1::function代替函数指针,可以支持所有兼容目标函数签名的可调用对象。
  • 用另一个类层级中的虚函数来提供策略,是策略模式的惯例实现。

NVI实现模板方法模式

模板方法设计模式:我们知道实现某个业务的步骤,但具体算法需要子类分别实现。
使用非虚接口(Non-Virtual Interface Idiom)可以实现模板方法模式。比如上面的healthValue声明为普通函数,它调用一个私有虚函数doHealthValue来实现。 实现起来是这样的:

class GameCharacter{
public:
// 子类不应重新定义该方法,见Item 36
int healthValue() const{
    // do sth. before
    int ret = doHealthValue();
    // do sth. after
    return ret;
}
private:
// 子类可以重新定义该方法
virtual int doHealthValue() const{
    // 默认实现
}
}

NVI Idiom的好处在于,在调用doHealthValue前可以做一些设置上下文的工作,调用后可以清除上下文。 比如在调用前给互斥量(mutex)加锁、验证前置条件、类的不变式;调用后给互斥量解锁、验证后置条件、类的不变式等。
上述C++代码也有奇怪的地方,你可能已经发现了。doHealthValue在子类中是不可调用的,然而子类却重写了它。 但C++允许这样做是有充分理由的:父类拥有何时(when)调用该接口的权利;子类拥有如何(how)实现该接口的权利。
有时为了继承实现方式,子类虚函数会调用父类虚函数,这时doHealthValue就需要是protected了。 有时(比如析构函数)虚函数还必须是public,那么就不能使用NVI了。

函数指针实现策略模式

上述的NVI随是实现了模板方法,但事实上还是在用虚函数。我们甚至可以让healthValue()完全独立于角色的类,只在构造函数时把该函数作为参数传入。

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;
}

这便实现了策略模式。可以在运行时指定每个对象的生命值计算策略,比虚函数的实现方式有更大的灵活性:

  • 同一角色类的不同对象可以有不同的healthCalcFunc。只需要在构造时传入不同策略即可。
  • 角色的healthCalcFunc可以动态改变。只需要提供一个setHealthCalculator成员方法即可。

我们使用外部函数实现了策略模式,但因为defaultHealthCalc是外部函数,所以无法访问类的私有成员。 如果它通过public成员便可以实现的话就没有任何问题了,如果需要内部细节:
我们只能弱化GameCharacter的封装。或者提供更多public成员,或者将defaultHealthCalc设为friend。 弱化的封装和更灵活的策略是一个需要权衡的设计问题,取决于实际问题中动态策略的需求有多大。

tr1::function实现策略模式

C++ std::tr1::function使用-CSDsN博客

如果你已经习惯了模板编程,可能会发现函数指针实现的策略模式太过死板。 为什么不能接受一个像函数一样的东西呢(比如函数对象)?为什么不能是一个成员函数呢?为什么一定要返回int而不能是其他兼容类型呢?
tr1中给出了解决方案,使用tr1::function代替函数指针!tr1::function是一个对象, 他可以保存任何一种类型兼容的可调用的实体(callable entity)例如函数对象、成员函数指针等。 看代码:
现在tr1在C++11标准中已经被合并入std命名空间啦(叫做多态函数对象包装器),不需要std::tr1::function了,可以直接写std::function。

class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);

class GameCharacter{
public:
typedef std::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCaracter(HealthCalcFunc hcf = defaultHealthCalc): healthCalcFunc(hcf){}
int healthValue() const{
    return healthFunc(*this);
}
private:
HealthCalcFunc healthFunc;
};

注意std::function的模板参数是int (const GameCharacter&),参数是GameCharacter的引用返回值是int, 但healthCalcFunc可以接受任何与该签名兼容的可调用实体。即只要参数可以隐式转换为GameCharacter返回值可以隐式转换为int就可以。 用function代替函数指针后客户代码可以更加灵活:

// 类型兼容的函数
short calcHealth(const GameCharacter&);
// 函数对象
struct HealthCalculator{
int operator()(const GameCharacter&) const{...}
};
// 成员函数
class GameLevel{
public:
float health(const GameCharacter&) const;
};

无论是类型兼容的函数、函数对象还是成员函数,现在都可以用来初始化一个GameCharacter对象:

GameCharacter evil, good, bad;
// 函数
evil(calcHealth);                       
// 函数对象
good(HealthCalculator());
// 成员函数
GameLevel currentLevel;
bad(std::bind(&GameLevel::health, currentLevel, _1));

最后一个需要解释一下,GameLevel::health接受一个参数const GameCharacter&, 但事实上在运行时它是需要两个参数的,const GameCharacter&以及this。只是编译器把后者隐藏掉了。 那么std::bind的语义就清楚了:首先它指定了要调用的方法是GameLevel::health,第一个参数是currentLevel, this是_1,即&currentLevel(细节略过啦!,这里的重点在于成员函数也可以传入!)。
如果你写过JavaScript你会发现这就是Function.prototype.bind嘛!

经典的策略模式

可能你更关心策略模式本身而不是上述的这些实现,现在我们来讨论策略模式的一般实现。 在UML表示中,生命值计算函数HealthCalcFunc应当定义为一个类,拥有自己的类层级。 它的成员方法calc应当为虚函数,并在子类可以有不同的实现。
image.png
实现代码可能是这样的:

class HealthCalcFunc{
public:
virtual int calc(const CameCharacter& gc) const;
};
HealthCalcFunc defaultHealthCalc;
class GameCharacter{
public:
explicit GameCharacter(HealthCalcFunc *phcf = &defaultHealthCalc): pHealthCalc(phcf){}
int healthValue() const{
    return pHealthCalc->calc(*this);
}
private:
HealthCalcFunc *pHealthCalc;
};

熟悉策略模式的人一眼就能看出来上述代码是策略模式的经典实现。可以通过继承HealthCalcFunc很方便地生成新的策略。
总结 :
image.png
image.png

条款 36 不要重写(重新定义)继承来的(noo-vitrual)非虚函数

Item 36: Never redefine an inherited non-virtual function.

我们还是在讨论public继承,比如Derived继承自Base。如果Base有一个非虚函数func,那么客户会倾向认为下面两种调用结果是一样的:

Derived d;
Base* pb = &d;
Derived* pd = &d;
// 以下两种调用应当等效
pb->func();
pd->func();

为什么要一样呢?因为public继承表示着"is-a"的关系,每个Derived对象都是一个Base对象(Item 32 确保public继承是"is a"的关系)。
然而重写(override)非虚函数func将会造成上述调用结果不一致:

class Base{
public:
void func(){}
};
class Derived: public Base{
public:
void func(){}   // 隐藏了父类的名称func,见Item 33
};

因为pb类型是Base*,pd类型是Derived*,对于普通函数func的调用是静态绑定的(在编译期便决定了调用地址偏移量)。 总是会调用指针类型定义中的那个方法。即pb->func()调用的是Base::func,pd->func()调用的是Derived::func。
当然虚函数不存在这个问题,它是一种动态绑定的机制。
在子类中重写父类的非虚函数在设计上是矛盾的:

  • 一方面,父类定义了普通函数func,意味着它反映了父类的不变式。子类重写后父类的不变式不再成立,因而子类和父类不再是"is a"的关系。
  • 另一方面,如果func应当在子类中提供不同的实现,那么它就不再反映父类的不变式。它就应该声明为virtual函数。

条款 37 绝不要重新定义继承父类函数的(缺省参数值)默认参数

Item 37: Never redefine a function’s inherited default parameter value.

image.png
不要重写父类函数的默认参数。 因为虽然虚函数的是动态绑定的,但默认参数是静态绑定的。只有动态绑定的东西才应该被重写。

静态绑定与动态绑定

静态绑定是在编译期决定的,又称早绑定(early binding);
动态绑定是在运行时决定的,又称晚绑定(late binding)。
举例来讲,RectCircle都继承自ShapeShape中有虚方法draw。那么:

Shape* s1 = new Shape;
Shape* s2 = new Rect;
Shape* s3 = new Circle;
s1->draw();     // s1的静态类型是Shape*,动态类型是Shape*
s2->draw();     // s2的静态类型是Shape*,动态类型是Rect*
s3->draw();     // s3的静态类型是Shape*,动态类型是Circle*

在编译期是不知道应该调用哪个draw的,因为编译期看到的类型都是一样的:Shape*。 在运行时可以通过虚函数表的机制来决定调用哪个draw方法,这便是动态绑定。

静态绑定的默认参数

虚函数是动态绑定的,但为什么参数是静态绑定的呢?这是出于运行时效率的考虑,如果要动态绑定默认参数,则需要一种类似虚函数表的动态机制。 所以你需要记住默认参数的静态绑定的,否则会引起困惑。来看例子吧:

Class Shape{
    public:
    virtual void draw(int top = 1){
        cout<<top<<endl;
    }
};
class Rect: public Shape{
public:
virtual void draw(int top = 2){ // 赋予不同的缺省参数值 
    cout<<top<<endl;
}
};

class Circle: public Shape{
public:
virtual void draw(int top){ // 赋予不同的缺省参数值 
    cout<<top<<endl;
}
};


Rect* rp = new Rect;
Shape* sp = rp;
Circle* cp = new Circle;

sp->draw();  // 调用 Shape::draw()
rp->draw();  // 调用 Rect::draw()
cp->draw();  // 调用 Shape::draw()    一样缺省  但是调用基类的func   各出一半的力气 !!

在Rect中重定义了默认参数为2,上述代码的执行结果是这样的: 输出 1 2 1
默认参数的值只和静态类型有关,是静态绑定的。

最佳实践

为了避免默认参数的困惑,请不要重定义默认参数。但当你遵循这条规则时却发现及其蛋疼:

class Shape{
public:
virtual void draw(Color c = Red) const = 0;
};
class Rect: public Shape{
public:
virtual void draw(Color c = Red) const;
};

代码重复(相依性)!如果父类中的默认参数改了,我们需要修改所有的子类。所以最终的办法是:避免在虚函数中使用默认参数。可以通过 Item 35 的NVI范式来做这件事情:

class Shape{
public:
    void draw(Color c = Red) const{
        doDraw(color);
    }
private:
	virtual void doDraw(Color c) const = 0;
};

class Rect: public Shapxe{
...
private:
	virtual void doDraw(Color c) const;     // 虚函数没有默认参数啦!
};

我们用普通函数定义了默认参数,避免了在动态绑定的虚函数上定义静态绑定的默认参数。
如标题所见, 你唯一应该覆写的东西 —— 动态绑定

条款 38 通过复合模型数模出 has-a 或 根据某物实出现

Item 38: Model “has-a” or “is-implemented-in-terms-of” through composition.

  • 一个类型包含另一个类型的对象时,我们这两个类型之间是组合关系。组合是比继承更加灵活的软件复用方法。 Item 32 确保public继承是"is a"的关系 提到 :
  • public继承的语义是"is-a"的关系。对象组合也同样拥有它的语义:
  • 就对象关系来讲,组合意味着一个对象拥有另一个对象,是"has-a"的关系 (复合模型);
  • 就实现方式来讲,组合意味着一个对象是通过另一个对象来实现的,是"is-implemented-in-terms-of"的关系。 (eg set 利用 list实现)

拥有 has-a

拥有的关系非常直观,比如一个Person拥有一个name:

class Person{
public:
	string name;
};

以…实现 is-implemented-in-terms-of

假设你实现了一个List链表,接着希望实现一个Set集合。因为你知道代码复用总是好的,于是你希望Set能够继承List的实现。 这时用public继承是不合适的,List是可以有重复的,这一性质不适用于Set,所以它们不是"is-a"的关系。 这时用组合更加合适,SetList来实现的。

template<class T>                   // the right way to use list for Set
class Set {
public:
    bool member(const T& item) const;
    void insert(const T& item);
    void remove(const T& item);
    std::size_t size() const;
private:
	std::list<T> rep;                 // representation for Set data
};

Set的实现可以很大程度上重用List的实现,比如member方法:

template<typename T> bool Set<T>::member(const T& item) const {
    return std::find(rep.begin(), rep.end(), item) != rep.end();
}

复用List的实现使得Set的方法都足够简单,它们很适合声明成inline函数(见Item 30)。

条款 39 明智而谨慎地使用 private 继承

Item 39: Use private inheritance judiciously.

Item 32提出public继承表示"is-a"的关系,这是因为编译器会在需要的时候将子类对象隐式转换为父类对象。 然而private继承则不然:

class Person { ... };
class Student: private Person { ... };     // inheritance is now private
void eat(const Person& p);                 // anyone can eat

Person p;                                  // p is a Person
Student s;                                 // s is a Student
eat(p);                                    // fine, p is a Person
eat(s);                                    // error! a Student isn't a Person

Person可以eat,但Student却不能eat。这是private继承和public继承的不同之处:

  • 编译器不会把子类对象转换为父类对象
  • 父类成员(即使是public、protected)都变成了private

子类继承了父类的实现,而没有继承任何接口(因为public成员都变成private了)。 因此private继承是软件实现中的概念,与软件设计无关。 private继承和对象组合类似,都可以表示"is-implemented-in-terms-with"的关系。那么它们有什么区别呢? 在面向对象设计中,对象组合往往比继承提供更大的灵活性,只要可以使用对象组合就不要用private继承。

private继承

我们的Widget类需要执行周期性任务,于是希望继承Timer的实现。 因为Widget不是一个Timer,所以我们选择了private继承:

class Timer {
public:
	explicit Timer(int tickFrequency);
	virtual void onTick() const;          // automatically called for each tick
};
class Widget: private Timer {
private:
	virtual void onTick() const;           // look at Widget usage data, etc.
};

在Widget中重写虚函数onTick,使得Widget可以周期性地执行某个任务。为什么Widget要把onTick声明为private呢? 因为onTick只是Widget的内部实现而非公共接口,我们不希望客户调用它(Item 18 指出接口应设计得不易被误用)。
private继承的实现非常简单,而且有时只能使用private继承:

  1. 当Widget需要访问Timerprotected成员时。因为对象组合后只能访问public成员,而private继承后可以访问protected成员。
  2. 当Widget需要重写Timer的虚函数时。比如上面的例子中,由于需要重写onTick单纯的对象组合是做不到的。

对象组合

我们知道对象组合也可以表达"is-implemented-in-terms-of"的关系, 上面的需求当然也可以使用对象组合的方式实现。但由于需要重写(overrideTimer的虚函数,所以还是需要一个继承关系的:

class Widget {
private:
    class WidgetTimer: public Timer {
   		public:
    		virtual void onTick() const;
    };
	WidgetTimer timer;
};

内部类WidgetTimerpublic继承自Timer,然后在Widget中保存一个WidgetTimer对象。 这是public继承+对象组合的方式,比private继承略为复杂。但对象组合仍然拥有它的好处:

  1. 你可能希望禁止Widget的子类重定义onTick。在Java中可以使用finel关键字,在C#中可以使用sealed。 在C++中虽然没有这些关键字,但你可以使用public继承+对象组合的方式来做到这一点。上述例子便是。
  2. 减小Widget和Timer的编译依赖。如果是private继承,在定义Widget的文件中势必需要引入#include"timer.h"。 但如果采用对象组合的方式,你可以把WidgetTimer放到另一个文件中,在Widget中保存WidgetTimer的指针并声明WidgetTimer即可, 见Item 31

EBO特性

我们讲虽然对象组合优于private继承,但有些特殊情况下仍然可以选择private继承。 需要EBO(empty base optimization)的场景便是另一个特例。 由于技术原因,C++中的独立空对象也必须拥有非零的大小,请看:

class Empty {}; 
class HoldsAnInt {
private:
    int x;
    Empty e;        
};

Empty e是一个空对象,但你会发现sizeof(HoldsAnInt) > sizeof(int)。 因为C++中独立空对象必须有非零大小,所以编译器会在Empty里面插入一个char,这样Empty大小就是1。 由于字节对齐的原因,在多数编译器中HoldsAnInt的大小通常为2*sizeof(int)。更多字节对齐和空对象大小的讨论见Item 7。 但如果你继承了Empty,情况便会不同:

class HoldsAnInt: private Empty {
private:
	int x;
};

这时sizeof(HoldsAnInt) == sizeof(int),这就是空基类优化(empty base optimization,EBO)。 当你需要EBO来减小对象大小时,可以使用private继承的方式。
继承一个空对象有什么用呢?虽然空对象不可以有非静态成员,但它可以包含typedef, enum, 静态成员,非虚函数 (因为虚函数的存在会导致一个徐函数指针,它将不再是空对象)。 STL就定义了很多有用的空对象,比如unary_function, binary_function等。

总结

  • private继承的语义是"is-implemented-in-terms-of",通常不如对象组合。但有时却是有用的:比如方法protected成员、重写虚函数。
  • 不同于对象组合,private继承可以应用EBO,库的开发者可以用它来减小对象大小 (对象尺寸最小化)。

image.png

条款 40 明智而审慎地使用多重继承

Item 40: Use multiple inheritance judiciously.

多继承(Multiple Inheritance,MI)是C++特有的概念,在是否应使用多继承的问题上始终争论不断。一派认为单继承(Single Inheritance,SI)是好的,所以多继承更好; 另一派认为多继承带来的麻烦更多,应该避免多继承。本文的目的便是了解这两派的视角。具体从如下三个方面来介绍:

  • 多继承比单继承复杂,引入了歧义的问题,以及虚继承的必要性;
  • 虚继承在大小、速度、初始化/赋值的复杂性上有不小的代价,当虚基类中没有数据时还是比较合适的;
  • 多继承有时也是有用的。典型的场景便是:public继承自一些接口类,private继承自那些实现相关的类。

歧义的名称

多继承遇到的首要问题便是父类名称冲突时调用的歧义。如:

class A{
public:
	void func();
};
class B{
private:
	bool func() const;
};
class C: public A, public B{ ... };

C c;
c.func();           // 歧义!

c.B::func(); // 没有歧义  需要明确指出  但是B::func 是 private的 

多继承菱形

当多继承的父类拥有更高的继承层级时,可能产生更复杂的问题比如多继承菱形(deadly MI diamond)。如图:
image.png

class File{};
class InputFile: public File{};
class OutputFile: public File{};
class IOFile: public InputFile, public OutputFile{};

这样的层级在C++标准库中也存在,例如basic_ios, basic_istream, basic_ostream, basic_iostream。

image.png
IOFile的两个父类都继承自File,那么File的属性(比如filename)应该在IOFile中保存一份还是两份呢? 这是取决于应用场景的,就File::filename来讲显然我们希望它只保存一份,但在其他情形下可能需要保存两份数据。 C++还是一贯的采取了自己的风格:都支持!默认是保存两份数据的方式。如果你希望只存储一份,可以用virtual继承:

class File{};
class InputFile: virtual public File{};
class OutputFile: virtual public File{};
class IOFile: public InputFile, public OutputFile{};

可能多数情况下我们都是希望virtual的方式来继承。但总是用virtual也是不合适的,它有代价:
image.png

  • 虚继承类的对象会更大一些;
  • 虚继承类的成员访问会更慢一些;
  • 虚继承类的初始化更反直觉一些。继承层级的最底层(most derived class)负责虚基类的初始化,而且负责整个继承链上所有虚基类的初始化。

基于这些复杂性,Scott Meyers对于多继承的建议是:

  1. 如果能不使用多继承,就不用他;
  2. 如果一定要多继承,尽量不在里面放数据,也就避免了虚基类初始化的问题。

接口类

这样的一个不包含数据的虚基类和Java或者C#提供的Interface有很多共同之处,这样的类在C++中称为接口类, 我们在Item 31中介绍过。一个Person的接口类是这样的:

class IPerson {
public:
    virtual ~IPerson();
    virtual std::string name() const = 0;
    virtual std::string birthDate() const = 0;
};

由于客户无法创建抽象类的对象,所以必须以指针或引用的方式使用IPerson。 需要创建实例时客户会调用一些工厂方法,比如:

shared_ptr<IPerson> makePerson(DatabaseID personIdentifier);

同时继承接口类与实现类

在Java中一个典型的类会拥有这样的继承关系:

public class A extends B implements IC, ID{}

继承B通常意味着实现继承,继承IC和ID通常意味着接口继承。在C++中没有接口的概念,但我们有接口类! 于是这时就可以多继承:

class CPerson: public IPerson, private PersonInfo{};

PersonInfo是私有继承,因为Person是借助PersonInfo实现的。 Item 39提到对象组合是比private继承更好的实现继承方式。 但如果我们希望在CPerson中重写PersonInfo的虚函数,那么就只能使用上述的private继承了(这时就是一个合理的多继承场景)。
现在来设想一个需要重写虚函数的场景: 比如PersonInfo里面有一个print函数来输出name, address, phone。但它们之间的分隔符被设计为可被子类定制的:

class PersonInfo{
public:
void print(){
    char d = delimiter();
    cout<<name<<d<<address<<d<<phone;
}
virtual char delimiter() const{ return ','; }
};

CPerson通过private继承复用PersonInfo的实现后便可以重写delimiter函数了:

class CPerson: public IPerson, private PersonInfo{
public:
virtual char delimiter() const{ return ':'; }
...
};

至此完成了一个合理的有用的多继承(MI)的例子。

总结

我们应当将多继承视为面向对象设计工具箱中一个有用的工具。相比于单继承它会更加难以理解, 如果有一个等价的单继承设计我们还是应该采用单继承。但有时多继承确实提供了清晰的、可维护的、合理的方式来解决问题。 此时我们便应该理智地使用它。

  • 多继承比单继承复杂,引入了歧义的问题,以及虚继承的必要性;
  • 虚继承在大小、速度、初始化/赋值的复杂性上有不小的代价,当虚基类中没有数据时还是比较合适的;
  • 多继承有时也是有用的。典型的场景便是:public继承自一些接口类,private继承自那些实现相关的类。

参考 :
https://zhuanlan.zhihu.com/p/536534500
https://zhuanlan.zhihu.com/p/63609476
http://gapex.web.fc2.com/c_plusplus/book/EffectiveC3rdEdition.pdf
https://harttle.land/effective-cpp.html

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/180773.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

设计师不能忽视的几个宝藏图标设计工具

在这个快速变化的时代&#xff0c;设计师对创新和实用工具的需求越来越大。这就要求我们及时跟上潮流&#xff0c;不断探索和尝试最新、最有价值的图标设计工具。只有这样&#xff0c;我们才能在竞争激烈的设计市场中脱颖而出。以下是我们精心挑选的2024年值得一试的图标设计工…

Linux C++ 服务器端这条线怎么走?一年半能做出什么?

Linux C 服务器端这条线怎么走&#xff1f;一年半能做出什么&#xff1f; 既然你是在校学生&#xff0c;而且编程语言和数据结构的基础还不错&#xff0c;我认为应该在《操作系统》和《计算机体系结构》这两门课上下功夫&#xff0c;然后才去读编程方面的 APUE、UNP 等书。 最…

ffmpeg播放器实战(播放器流程)

1.流程图 1.main窗口创建程序窗口 程序窗口构造函数执行下面内容 2.开启播放 3.开启解码 4.开启渲染 5.反馈给ui 本文福利&#xff0c; 免费领取C音视频学习资料包学习路线大纲、技术视频/代码&#xff0c;内容包括&#xff08;音视频开发&#xff0c;面试题&#xff0c;FFmpeg…

vue中怎么根据选择的名称 生成印章图片

项目中需要根据选择的印章名称&#xff0c;动态生成印章 &#xff0c;印章下方显示当前的日期 代码如下 <template><div><label for"name">选择名称&#xff1a;</label><select id"name" v-model"selectedName">…

virtualbox 扩展磁盘后在win10 虚拟机看不到新扩展的空间

造成标题中问题的原因是&#xff0c;扩展的是win10.vdi 的空间&#xff0c;虚拟机使用使用的下边那个以uuid命名的空间&#xff0c;将这个磁盘的虚拟分配空间也调整到150G . 然后在win10的磁盘管理里就可以看到新加的空间了。之后再点相应的盘进行扩展卷操作即可。

Android跨进程传图片或者大数据(解决TransactionTooLargeException)

跨进程传图片方案 直接intent传bitmap使用文件读写intent传递自定义binder&#xff0c;binder中传递image使用网络传输 一、直接intent传bitmap 优势 使用简单 劣势 相关代码可能有侵入性&#xff0c;必须在四大组件中接收。 intent传递数据的总大小是1MB&#xff0c;其中…

SA实战 ·《SpringCloud Alibaba实战》第14章-服务网关加餐:SpringCloud Gateway核心技术

大家好,我是冰河~~ 一不小心《SpringCloud Alibaba实战》专栏都更新到第14章了,再不上车就跟不上了,小伙伴们快跟上啊! 在《SpringCloud Alibaba实战》专栏前面的文章中,我们实现了用户微服务、商品微服务和订单微服务之间的远程调用,并且实现了服务调用的负载均衡。也基…

geemap学习笔记012:如何搜索Earth Engine Python脚本

前言 本节主要是介绍如何查询Earth Engine中已经集成好的Python脚本案例。 1 导入库 !pip install geemap #安装geemap库 import ee import geemap2 搜索Earth Engine Python脚本 很简单&#xff0c;只需要一行代码。 geemap.ee_search()使用方法 后记 大家如果有问题需…

前端处理返回数据为数组对象且对象嵌套数组并重名的数据,合并名称并叠加数据

前端处理返回数据为数组对象且对象嵌套数组并重名的数据&#xff0c;合并名称并叠加数据 var newList[]; var table{}; var dataObj{}; var finalList[]; var tableData[{brName:营业部,dateStr:2023-11-23,tacheArr:[{dealCnt:20,tacheName:奔驰}]},{brName:营业部,dateStr:2…

想自学软件测试?一般人我还是劝你算了吧。。。

&#x1f4e2;专注于分享软件测试干货内容&#xff0c;欢迎点赞 &#x1f44d; 收藏 ⭐留言 &#x1f4dd; 如有错误敬请指正&#xff01;&#x1f4e2;交流讨论&#xff1a;欢迎加入我们一起学习&#xff01;&#x1f4e2;资源分享&#xff1a;耗时200小时精选的「软件测试」资…

python+pytest接口自动化:token关联登录这样做,阿里p8都直呼牛逼!!!

在PC端登录公司的后台管理系统或在手机上登录某个APP时&#xff0c;经常会发现登录成功后&#xff0c;返回参数中会包含token&#xff0c;它的值为一段较长的字符串&#xff0c;而后续去请求的请求头中都需要带上这个token作为参数&#xff0c;否则就提示需要先登录。 这其实就…

如何在Linux系统上检测GPU显存和使用情况?

如何在Linux系统上检测GPU显存和使用情况&#xff1f; 在Linux系统上&#xff0c;你可以使用一些命令行工具来检测GPU显存和使用情况。以下是一些常用的方法&#xff1a; 1. 使用nvidia-smi&#xff08;仅适用于NVIDIA GPU&#xff09; 如果你使用的是NVIDIA的显卡&#xff0…

小猪优版的前世今生:从籍籍无名到行业瞩目,再到骤变的风暴中心

1. 前世&#xff1a;籍籍无名到行业新星的崛起 小猪优版在初创时期&#xff0c;并不被大众所知。然而&#xff0c;它凭借对短视频行业的深度洞察&#xff0c;以及独特的商业模式&#xff0c;开始在这个领域崭露头角。它提供了一个平台&#xff0c;不仅助力内容创作者更好地展现…

1688商品详情数据接口(1688.item_get)

1688商品详情数据接口是一种程序化的接口&#xff0c;通过这个接口&#xff0c;商家或开发者可以使用自己的编程技能&#xff0c;对1688平台上的商品信息进行查询、获取和更新。这个接口允许商家根据自身的需求&#xff0c;获取商品的详细信息&#xff0c;例如价格、库存、描述…

中医馆管理系统预约小程序效果如何

人们生活水平提升的同时&#xff0c;无论是工作压力还是自然压力&#xff0c;都给身体带来了一些损伤&#xff0c;如各科常见病、多发病、慢性病及疑难杂症等。中医具有治未病的优势&#xff0c;因此对患者而言&#xff0c;找中医诊治是一个很好的选择&#xff0c;而无论中医院…

财报解读:电商GMV增长30%后,快手将坚守本地生活?

快手逐渐讲好了其高质量成长的故事。 根据财报&#xff0c;快手三季度业绩超出预期&#xff0c;其中&#xff0c;营收279.5亿元&#xff0c;同比增长20.8%&#xff1b;调整后净利润31.7亿元&#xff0c;同比扭亏为盈。 而联系市场环境来看&#xff0c;三季度广告、电商市场较…

2 使用React构造前端应用

文章目录 简单了解React和Node搭建开发环境React框架JavaScript客户端ChallengeComponent组件的主要结构渲染与应用程序集成 第一次运行前端调试将CORS配置添加到Spring Boot应用使用应用程序部署React应用程序小结 前端代码可从这里下载&#xff1a; 前端示例 后端使用这里介…

【C语言】函数(三):为什么要有函数声明

目录 前言函数定义函数声明为什么要有函数声明原因1&#xff1a;分模块编程原因2&#xff1a;静态库文件 前言 在上文中已经介绍了函数调用&#xff0c;分为传值调用和传址调用&#xff0c;以及嵌套调用和链式访问。在本文中将介绍函数的声明和定义&#xff0c;以及为什么要有函…

使用 Pinia 的五个技巧

在这篇文章中&#xff0c;想与大家分享使用 Pinia 的五大技巧。 以下是简要总结&#xff1a; 不要创建无用的 getter在 Option Stores 中使用组合式函数&#xff08;composables&#xff09;对于复杂的组合式函数&#xff0c;使用 Setup Stores使用 Setup Stores 注入全局变量…

SQL零基础入门教程,贼拉详细!贼拉简单! 速通数据库期末考!(十二)

SUM&#xff08;&#xff09;求和函数 SUM() 函数返回数值列的总和。 语法&#xff1a; SELECT SUM(column_name) FROM table_name;column_name:字段名&#xff08;必须是数值字段&#xff09; table_name&#xff1a;表名 示例&#xff1a; 查询每位同学的总分&#xff1a…