【六】继承与面向对象设计
条款32 : 确保public继承是"is a"的关系
Item 32: Make sure public inheritance models “is-a”.
C++
面向对象程序设计中,最重要的规则便是:public
继承应当是"is-a
"的关系。当Derived public
继承自Base
时, 相当于你告诉编译器和所有看到你代码的人:Base
是Derived
的抽象,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,即¤tLevel(细节略过啦!,这里的重点在于成员函数也可以传入!)。
如果你写过JavaScript你会发现这就是Function.prototype.bind嘛!
经典的策略模式
可能你更关心策略模式本身而不是上述的这些实现,现在我们来讨论策略模式的一般实现。 在UML表示中,生命值计算函数HealthCalcFunc应当定义为一个类,拥有自己的类层级。 它的成员方法calc应当为虚函数,并在子类可以有不同的实现。
实现代码可能是这样的:
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
很方便地生成新的策略。
总结 :
条款 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.
不要重写父类函数的默认参数。 因为虽然虚函数的是动态绑定的,但默认参数是静态绑定的。只有动态绑定的东西才应该被重写。
静态绑定与动态绑定
静态绑定是在编译期决定的,又称早绑定(early binding
);
动态绑定是在运行时决定的,又称晚绑定(late binding
)。
举例来讲,Rect
和Circle
都继承自Shape
,Shape
中有虚方法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
"的关系。 这时用组合更加合适,Set
以List
来实现的。
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继承:
- 当Widget需要访问
Timer
的protected
成员时。因为对象组合后只能访问public
成员,而private
继承后可以访问protected
成员。 - 当Widget需要重写
Timer
的虚函数时。比如上面的例子中,由于需要重写onTick
单纯的对象组合是做不到的。
对象组合
我们知道对象组合也可以表达"is-implemented-in-terms-of
"的关系, 上面的需求当然也可以使用对象组合的方式实现。但由于需要重写(override
)Timer
的虚函数,所以还是需要一个继承关系的:
class Widget {
private:
class WidgetTimer: public Timer {
public:
virtual void onTick() const;
};
WidgetTimer timer;
};
内部类WidgetTimerpublic继承自Timer,然后在Widget中保存一个WidgetTimer对象。 这是public继承+对象组合的方式,比private继承略为复杂。但对象组合仍然拥有它的好处:
- 你可能希望禁止Widget的子类重定义onTick。在Java中可以使用finel关键字,在C#中可以使用sealed。 在C++中虽然没有这些关键字,但你可以使用public继承+对象组合的方式来做到这一点。上述例子便是。
- 减小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,库的开发者可以用它来减小对象大小 (对象尺寸最小化)。
条款 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)。如图:
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。
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也是不合适的,它有代价:
- 虚继承类的对象会更大一些;
- 虚继承类的成员访问会更慢一些;
- 虚继承类的初始化更反直觉一些。继承层级的最底层(most derived class)负责虚基类的初始化,而且负责整个继承链上所有虚基类的初始化。
基于这些复杂性,Scott Meyers对于多继承的建议是:
- 如果能不使用多继承,就不用他;
- 如果一定要多继承,尽量不在里面放数据,也就避免了虚基类初始化的问题。
接口类
这样的一个不包含数据的虚基类和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