【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. beforeint ret = doHealthValue();// do sth. afterreturn 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 eatPerson 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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

【uniapp】部分图标点击事件无反应

比如&#xff1a;点击这个图标在h5都正常&#xff0c;在小程序上无反应 css&#xff1a;也设置z-index&#xff0c;padding 页面上也试过click.native.stop.prevent"changePassword()" 时而可以时而不行&#xff0c; 最后发现是手机里输入键盘的原因&#xff0c;输…

大型养殖场需要哪些污水处理设备

大型养殖场是一个涉及环境保护和可持续发展的关键行业&#xff0c;对于处理养殖场产生的污水有着明确的要求和标准。为了确保污水得到有效处理和处理效果达到国家排放标准&#xff0c;大型养殖场需要配备一系列污水处理设备。以下是几种常见的污水处理设备&#xff1a; 1. 水解…

Python入门指南之基本概率和语法基础

文章目录 一、基本概念二、控制流三、函数四、模块五、数据结构六、面向对象的编程七、输入输出八、异常九、Python标准库关于Python技术储备一、Python所有方向的学习路线二、Python基础学习视频三、精品Python学习书籍四、Python工具包项目源码合集①Python工具包②Python实战…

快速排序演示和代码介绍

快速排序的核心是(以升序为例)&#xff1a;在待排序的数据中指定一个数做为基准数&#xff0c;把所有小于基准数的数据放到基准数的左边&#xff0c;所有大于基准数的数据放在右边&#xff0c;这样的话基准数的位置就确定了&#xff0c;然后在两边的数据中重复上述操作

使用 Pinia 的五个技巧

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

2 使用React构造前端应用

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

冷链运输车辆GPS定位及温湿度管理案例

1.项目背景 项目名称&#xff1a;山西冷链运输车辆GPS定位及温湿度管理案例 项目需求&#xff1a;随着经济发展带动物流行业快速发展&#xff0c;运输规模逐步扩大&#xff0c;集团为了适应高速发展的行业现象&#xff0c;物流管理系统的完善成了现阶段发展的重中之重。因此&…

eNSP-直连通信实验

实验拓扑&#xff1a; 实验需求&#xff1a; 1. 按照图中的设备名称&#xff0c;配置各设备名称 2. 按照图中的IP地址规划&#xff0c;配置IP地址 3. 测试R1与R2是否能ping通 4. 测试R2与R3是否能ping通 5. 测试R1与R3是否能ping通 实验步骤&#xff1a; 1. 加入设备&…

Astute Graphics 2023(ai创意插件合集)

Astute Graphics 2023是一家专注于图形编辑软件的公司&#xff0c;以制作高质量、功能强大的图像编辑工具而闻名。如Poser Pro、Poser 3D、Smart Shapes、Astute Sketch Pro等。 Astute Graphics的软件具有以下特点&#xff1a; 强大的图像编辑功能&#xff1a;Astute Graphi…

E-R图与关系模式

1. E-R模型 英文全称&#xff1a;Entity-relationship model&#xff0c;即实体关系模型 把现实世界的 实体模型通过建模转换为信息世界的概念模型&#xff0c;这个概念模型就是E-R模型 2. 数据库设计流程 一般设计数据库分为三个步骤 把现实世界的实体模型&#xff0c;通…

大数据湖及应用平台建设解决方案:PPT全39页,附下载

关键词&#xff1a;大数据湖建设&#xff0c;集团大数据湖&#xff0c;大数据湖仓一体&#xff0c;大数据湖建设解决方案 一、大数据湖定义 大数据湖是一个集中式存储和处理大量数据的平台&#xff0c;主要包括存储层、处理层、分析层和应用层四个部分。 1、存储层&#xff…

2. OpenHarmony源码下载

OpenHarmony源码下载(windows, ubuntu) 现在的 OpenHarmony 4.0 源码已经有了&#xff0c;在 https://gitee.com/openharmony 地址中&#xff0c;描述了源码获取的方式。下来先写下 windows 的获取方式&#xff0c;再写 ubuntu 的获取方式。 获取源码前&#xff0c;还需要的准…

Linux之进程替换

创建子进程的目的 创建子进程的第一个目的是让子进程执行父进程对应的磁盘代码中的一部分, 第二个目的是让子进程想办法加载磁盘上指定的程序,让子进程执行新的代码和程序 一是让子进程执行父进程代码的一部分, 比如&#xff1a; 1 #include<stdio.h> 2 #include<…

数据分析基础之《matplotlib(2)—折线图》

一、折线图绘制与保存图片 1、matplotlib.pyplot模块 matplotlib.pyplot包含了一系列类似于matlab的画图函数。它的函数作用于当前图形&#xff08;figure&#xff09;的当前坐标系&#xff08;axes&#xff09; import matplotlib.pyplot as plt 2、折线图绘制与显示 展示城…

【实用】mysql配置 及将线上数据导入本地 问题解决及记录

[ERR] 1292 - Incorrect datetime value: ‘0000-00-0000:00:00‘ for column ‘BIRTH_DATE‘ at row 1 此问题是mysql当前配置不支持日期为空&#xff0c;或者为‘0000-00-0000:00:00‘得情况 1、直接在数据库执行 # 修改全局 set global.sql_mode ONLY_FULL_GROUP_BY,STR…

“老人护工-预约上门” 技术支持网址

我们是一家为新加坡华人提供老人护工上门服务的软件。您可以选择满意的护工&#xff0c;然后提交联系人的信息&#xff0c;我们会在规定的时间安排护工人员上门&#xff0c;然后您线下支付所需的费用 如果您在使用过程中有什么问题&#xff0c;您可以随时联系我们。 邮箱地址&…

Docker部署Vue+Springboot项目

一、部署Springboot项目 1.1先将本地的java项目打成jar包。 再右上角进行maven操作。 1.2将jar包上传到服务器当中。 先再目录/home 下创建一个文件夹&#xff08;classRoom&#xff09;用于存放后端打镜像时需要的文件。 如果是服务器的话可以直接将文件拖拽到想要转移的地方…

基准电压源的工作原理和作用是什么(高精度电压源)

基准电压源是一种能够提供固定、稳定的直流电压输出的电源设备。它广泛应用于精密仪器、测试设备、通信设备等领域&#xff0c;是实现精确电压测量和校准的重要工具。本文将为您介绍基准电压源的工作原理和作用。 一、基准电压源的工作原理 基准电压源采用了高精度的电路设计和…

Sam Altman回归OpenAI,新董事会成员曝光!

11月22日下午&#xff0c;OpenAI在社交平台宣布&#xff0c;在原则上已达成协议&#xff0c;让 Sam Altman重返 OpenAI担任首席执行官&#xff0c;并重组董事会。稍后会公布更详细的内容。 初始董事会成员包括前Salesforce联合首席执行官Bret Taylor&#xff08;担任主席&…

鸿蒙开发-ArkTS 语言

鸿蒙开发-ArkTS 语言 1. 初识 ArkTS 语言 ArkTS 是 HarmonyOS 优选主力开发语言。ArkTS 是基于 TS(TypeScript)扩展的一门语言&#xff0c;继承了 TS 的所以特性&#xff0c;是TS的超集。 主要是扩展了以下几个方面&#xff1a; 声明式UI描述和自定义组件&#xff1a; ArkTS允…