Effective C++ 读书笔记(十二)
条款三十四:区分接口继承和实现继承
public继承由两部分组成:函数接口继承和函数实现继承。这两者的差异很像函数声明和函数定义之间的差异。
作为类的设计者,我们有时希望派生类只继承成员函数的接口(也就是函数声明),有时候希望能够继承接口和实现,但又希望能够覆写所继承的实现。有时候有希望继承函数的接口和实现并且不允许覆盖任何东西。
为了感受上述差异,我们看下面的例子
class Shape{
public:
virtual void draw()const=0;
virtual void error(const string &msg);
int objectID()const;
};
class Rectangle:public Shape{...};
class Ellipse:public Shape{...};
Shape是一个抽象类,用户不能创建其实体,只能创建其派生类实体。尽管如此,Shape还是强烈的影响到了public继承它的派生类。因为:
成员函数的接口总会被继承。因此,某个函数如果可施行与某类身上,那么一定可以施行于其派生类身上。
Shape类有三个成员函数。draw于某个隐喻的视屏中画出当前对象。error让那些需要报导某个错误的成员函数调用。第三个是objectId用于返回每个对象独一无二的id。其中draw是纯虚函数,error是虚函数,objectId是非虚函数。这都有什么样的暗示呢?
先考虑纯虚函数draw
class Shape{
public:
virtual void draw()const=0;
};
纯虚函数有两个最突出的特性:它们必须被任何继承了它们的具体类重新声明,而它们在抽象类中通常没有定义。这两个性质放在一起会发现:
声明一个纯虚函数的目的是为了让派生类只继承函数接口。
这对Shape::draw是在正常不过的事了,因为所有的Shape对象都应该是可绘制的。但shape类无法为此函数提供合理的实现,毕竟每个图形的绘制都是不一样的。Shape::draw声明式是对派生类设计者说你必须提供一个draw函数,但我不干涉你如何实现。
令人意外的是,我们可以为纯虚函数提供定义。也就是说我们可以为Shape::draw提供一份实现代码,但调用它的唯一途径是调用时明确指出类名称。
Shape *ps=new Shape; //错误,Shape是抽象的
Shape *ps1=new Rectangle;
Shape *ps2=new Ellipse;
ps1->draw();//调用 Rectangle::draw
ps2->draw();//调用 Ellipse::draw
ps1->Shape::draw();//调用Shape::draw
ps2->Shape::draw();//调用Shape::draw
一般而言这项性质用途有限,但是它可以实现一种机制,它可以提供一种机制,为非纯虚函数提供更平常更安全的缺省实现。
虚函数和纯虚函数有些不同,派生类继承其函数接口,但虚函数会提供一份实现代码,派生类可能覆写它。
声明一个虚函数的目的是让派生类继承该函数的接口和缺省实现。
看Shape::error的例子
class Shape{
public:
virtual void error(const string& msg);
};
其接口表示,每个类都必须支持一个error函数,但每个类可以自由处理。即你必须支持一个error函数,但若不想自己写,可以使用Shape的缺省版本。
但是,允许虚函数同时指定函数声明和函数缺省行为,却有可能造成危险。我们考虑XYZ公司设计的飞行继承体系。该公司只有A和B两种飞机,两者都以相同模式飞行。因此设计出这样的继承体系:
class Airport{...}//机场
class Airplane{
public:
virtual void fly(const Airport& destination);
};
void Airplane::fly(const Airport& destination)
{
//飞往指定目的地
}
class ModelA:public Airplane{...};
class ModelB:public Airplane{...};
为了表示所有飞机都能飞,并阐明不同飞机原则上需要不同的实现代码,fly被声明为虚函数。为了避免modelA和modelB中撰写相同代码,缺省行为由Airplane::fly提供,它同时被modelA和modelB继承。这是一个很好的继承体系。
现在,XYZ决定购买一种新式C飞机。C型和AB两种飞行方式不同。
XYZ公司程序员在继承体系中针对C添加了一个class,但其忘记重新定义fly函数
class ModelC:public Airplane{...};//未声明fly
现在代码中有诸如此类的动作
Airport PDX(...) //机场
Airplane *pa=new ModelC;
pa->fly(PDX);
这将酿成大灾难:这个程序试图以ModelA和ModelB的飞行方式来飞ModelC。
问题不在Airplane::fly有缺省行为,而在于ModelC在未明确说出我要的情况下就继承了该缺省行为。幸运的是我们可以轻易做到提供缺省实现给派生类,但除非他们明白要求否则免谈。这种方法在于切断虚函数接口和缺省实现之间的连接。下面是一种做法
class Airplane{
public:
virtual void fly(const Airport& destination)=0;
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
//缺省行为,飞往指定目的地
}
这里Airplane::fly已经被改为一个纯虚函数,只提供接口。其缺省行为也出现在Airplane类中,但此次以独立函数defaultFly的姿态出现。若想使用缺省实现,可以在其fly函数中对defaultFly做一个inline调用
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);
}
};
现在ModelC class不可能意外继承不正确的fly实现代码了,因为纯虚函数将迫使Model C必须提供自己的fly版本:
class ModelC:public Airplane{
public:
virtual void fly(const Airport& destination)
};
void ModelC::fly(const Airport& destination)
{
将C飞机开往目的地
}
这个方案并非绝对安全,程序员可能还是因为copy代码而带来麻烦,但其绝对比原来的设计值得信赖。至于defaultFly,其是一个protected,因为它是Airplane及其派生类的实现细目。
另外Airplane::defaultFly是一个非虚函数,这很重要,因为没有任何一个派生类应该重新定义此函数。
有些人反对以不同的函数分别提供接口和缺省实现,像上述的fly和defaultfly那样,他们关心因过度雷同的函数名称而引起的class命名空间污染问题。但他们也同意,接口和缺省实现应该分开,这个矛盾该如何解决?我们可以利用纯虚函数必须在派生类中重新生命,但它们也拥有自己的实现这一事实,如下:
class Airplane{
public:
virtual void fly(const Airport& destination)=0;
};
void Airplane::fly(const Airport& destination)
{
//缺省行为,飞往指定目的地
}
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)
};
void ModelC::fly(const Airport& destination)
{
将C飞机开往目的地
}
这几乎和全面的设计一样,只不过纯虚函数Airplane::fly替换了独立函数Airplane::defaultfly。本质上,这里的fly分为两个部分,声明部分表现接口。定义部分表现出缺省行为。将其合并丧失了两个函数有不同保护级别的机会,protected函数变为了public。
最后,我们看看Shape的非虚函数的objectID:
class Shape{
public:
int objectID()const;
};
如果成员函数是个非虚函数,意味着其不打算在派生类中有不同行为。实际上非虚函数所表现的不变性凌驾于其特异性,因为它表示派生类无论多么特异化,它的行为都不可以改变。
- 声明非虚函数的目的是为了令派生类继承函数的接口及一份强制实现。
我们可以把objectID的声明想作是每个shape对象都有一个用来生产对象识别码的函数:此识别码总是采用相同计算方法,由objectID决定,任何派生类都不应该尝试改变其行为。所以非虚函数绝不该在派生类中重新定义。
纯虚函数、虚函数、非虚函数之间的差异使你可以精确指定派生类想要继承的东西:只继承接口,或是继承接口和一份缺省实现、或是继承接口和一份强制实现。所以我们声明成员函数时,必须慎重选择。如果你确实履行,应该能避免经验不足的类设计者最常犯的两个错误。
第一个错误是将所有函数声明为非虚函数。这使得派生类没有充裕的空间进行特化工作。实际上任何一个类作为基类,都会拥有若干虚函数。
如果你关心virtual函数的成本,听一听80-20准则:一个典型的程序80%的时间花费在20%代码身上。这个法则很重要,它意味着,平均而言你的函数调用中可以有80%是虚函数而不冲击函数的大体效率,所以我们应该将重心放在那20%代码的身上。
另一个常见错误是是将所有成员声明为虚函数。有时候这是正确的,例如条款31中的接口。然而某些函数不应该在派生类中重新定义,那些函数应该被声明为非虚函数。
总结:
- 接口继承和实现继承不同。在public继承下,派生类总是继承基类的接口。
- 纯虚函数只具体指定接口继承。
- 虚函数具体指定接口继承以及缺省实现的继承。
- 非虚函数具体指定接口继承以及强制实现继承。
条款三十五:考虑虚函数以外的的其他选择
假设有一个游戏,游戏角色有其健康状态。提供一个成员函数healthValue,其返回一个整数,表示健康程度。由于不同人物以不同方式计算其健康程度,所以应该将该函数设为虚函数。
class GameCharacter{
public:
virtual int healthValue()const;
};
healthValue未被声明为纯虚函数,说明其将来会有一个计算血量的缺省算法。
这个设计很明确,但从某个角度来说反而成为了它的弱点。让我们考虑其他解法。
藉由Non-Virtual Interface接口手法实现Template Method模式
我们将从一个有趣的思量流派开始,该流派主张虚函数应该几乎总是private。其拥护者建议,较好的设计是保留healthValue为public成员函数,调用一个private virtual函数为其工作:
class GameCharacter{
public:
int healthValue()const
{
int val=doHealthValue();
...
return val;
}
private:
virtual int doHealthValue()const
{
}
};
这段代码中直接在类定义式内呈现成员函数本体。如条款30所言,它们也就暗自成为了inline,本处只为方便阅读,无特殊用意。
这一设计就是令客户通过public non-virtual函数间接调用private virtual函数,成为non-virtual interface(NVI)手法。它是Template Method设计模式的一个独特表现形式。这个非虚函数称为虚函数的外敷器。
NVI的一个优点是保证虚函数在进行真正工作被调用。这意味着外敷器确保在调用虚函数之前设定好适当的场景,在调用结束后清理场景。场景包括:锁定互斥器、验证函数先决条件等。
NVI手法涉及在派生类内重新定义private virtual函数。即重新定义若干个派生类并不调用的函数!这并不矛盾,重新定义virtual函数表示某些事如何被完成,调用虚函数则表示它何时被完成。
在NVI手法下没有必要让虚函数一定得是private。某些类继承体系要求派生类在虚函数时限内必须调用其基类的对应兄弟,而为了让这样的调用合法,虚函数必须是protected,有些虚函数甚至一定得是public(基类虚析构),这么一来就不能实施NVI手法了。
藉由Function Pointers实现Strategy模式
NVI手法对public virtual函数而言是一个有趣的代替方法,但毕竟我们还是使用虚函数来计算每个人的健康指数。另一个更戏剧性的设计主张:人物健康指数的计算与人物类型无关,这样的计算完全不需要人物这个成分。例如我们可能要求每个人物的构造函数接受一个指针,指向一个健康计算函数,我们可以调用这个函数进行实际计算:
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设计模式的简单应用。拿它和基于GameCharacter继承体系内之virtual函数的做法比较,它提供了某些有趣的弹性:
- 同一人物类型的不同实体可以有不同的健康计算函数。
class EvilBadGuy:public GameCharacter{
public:
explicit EvilBadGuy(HealthCalcFunc hcf=defaultHealthCalc):GameCharacter(hcf)
{...}
};
int loseHealthQuickly();//计算方式1
int loseHealthSlowly();//计算方式2
EvilBadGuy ebg(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly);//相同类型的人物对应不同的健康计算方式
- 某已知人物的健康计算函数可以在运行期来变更。
上述意味着健康计算函数不属于GameCharacter继承体系的成员函数。这代表健康计算函数没有使用GameCharacter的私有成员。但如果健康计算函数内使用了GameCharacter的私有成员,那就会有问题。
要解决上述问题,一般需要弱化class的封装,例如利用友元函数。是否利用函数指针,其利弊还需自己权衡。
藉由tr1::function完成Strategy模式
基于函数指针的作法有一些限制,比如为什么健康指数计算必须是函数,而不能是某样像函数的东西(如函数对象)呢?如果一定是个函数,为什么不能是个成员函数?为什么一定需要返回int而不是其他可转换为int的东西呢?
如果我们使用类型为tr1::function的对象,这些约束就全都挥发不见了。像条款54中所说,这样的对象持有任何可调用物(函数指针、函数对象、成员函数指针),只要其签名式兼容于需求端。以下刚才的设计可以改为tr1::function:
class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
class GameCharacter{
public:
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf=defaultHealthCalc):healthFunc(hcf)
{}
int healthValue()const
{return healthFunc(*this);}
private:
HealthCalcFunc healthFunc;
};
在这里,HealthyFunc是一个typedef,用来表现tr1::function的某个具现体,意味着该具现体的行为像是一般的函数指针。
让我们仔细看HealthyFunc的typedef:
std::tr1::function<int (const GameCharacter&)>
这里tr1::function具现体的目标签名式以不同颜色标识。这个签名代表的函数是接受一个const GameCharacter的引用并返回一个int。这个tr1::function类型产生的对象可以持有任何与此签名式兼容的可调物。兼容的含义是这个可调物的参数可被隐式转换为const GameCharacter&,而其返回值类型可以被隐式转换为int。
和函数指针的方法类似。唯一不同点是如今的GameCharacter持有的是tr1::function对象,相当于一个指向函数的泛化型指针。这个改变看起来这么小,但可以给客户在制定健康计算函数这件事带来更多的弹性。
short calcHealth(const GameCharacter&);//其返回short而非int
struct HealthCalculator{
int operator()(const GameCharactor&)const{
...}
};
class GameLevel{
public:
float health(const GameCharactor&)const;
...
};
class EvilBadGuy:public GameCharacter{
...
};
class EyeCandyCharacter:public GameCharacter{
...
};
EvilBadGuy ebg1(calcHealth); //人物1,使用某个函数计算健康指数
EyeCandyCharacter ecc1(HealthCalculator());//人物2,使用某个函数对象计算健康指数
GameLevel currentLevel;
EvilBadGuy ebg2(
std::tr1::bind(&GameLevel::health,currentLevel,_1));//人物3,使用成员函数计算健康指数
为了计算ebg2的健康指数,应该使用GameLevel类的成员函数health。GameLevel::health宣称自己需要接受一个参数,实际它接受两个参数,因为它也获得一个隐式参数GameLevel,也就是this。然而GameCharacters的健康计算函数只接受一个参数,若我们使用GameLevel::health作为ebg2的健康计算指数,我们必须转换,使其只接受一个参数。在本例中我们使用GameLevel currentLevel作为ebg2健康计算函数所需的参数(this),于是我们将currentLevel绑定为GameLevel对象,让它在每次GameLevel::health被调用来计算ebg2的健康时被使用,这就是tr1::bind的作用:它指出ebg2的健康计算函数应该总是以currentLevel作为GameLevel的对象。
若以tr1::function替换函数指针,我们将因此允许客户在计算人物健康指数时使用任何兼容的可调用物。
古典的strategy模式
古典的strategy模式将healthCalcFunc继承体系独立出来
class GameCharacter;
class HealthFunc{
public:
virtual int calc(const GameCharacter& gc)const
{...}
...
};
HealthFunc defaultHealthCalc;
class GameCharacter{
public:
explicit GameCharacter(HealthCalcFunc* hcf=&defaultHealthCalc):healthFunc(hcf)
{}
int healthValue()const
{return phealthFunc->calc(*this);}
private:
HealthCalcFunc* phealthFunc;
};
快速复习一下本条款中所提到的替换策略
- 使用Non-Virtual Interface手法。它以public non-virtual成员函数包裹较低访问性的虚函数。
- 将虚函数替换为函数指针成员变量。
- 以tr1::function成员变量替代虚函数。
- 将继承体系的虚函数替换为另一个继承体系的虚函数。
总结
- 虚函数的代替方案包括NVI手法及strategy设计模式的多种形式。
- 将机能从成员函数移到class外部函数,带来的一个缺点是,非成员函数无法访问类的非公有成员
- tr1::function对象的行为就像一般函数指针。这样的对象可接纳与给定目标签名式兼容的所有可调物。
条款三十六:绝不重新定义继承而来的非虚函数
classD由classB以public继承派生而来,classB定义一个public成员函数mf,其参数和返回值不重要均设为void。
class B{
public:
void mf();
...
};
class D:public B{
};
D x;
以下行为
B* pb=&x; //获得一个指针指向x
pb->mf(); //由该指针调用mf
异于以下行为
D* pD=&x;//获得一个指针指向x
pD->mf();//由该指针调用mf
你可能会相当惊讶,两者都通过对象x调用成员函数mf,其对象相同,函数也相同,所以行为也应该相同?
但事实可能并不是这样。若mf是一个非虚函数而D定义有自己的mf版本,那就不是如此。原因是非虚函数 B::mf和D::mf都是静态绑定。意思是由于pb被声明为指向B的指针,那么通过pb调用的非虚函数永远是B所定义的版本,既使pb指向一个派生类的对象。
但另一方面,虚函数是动态绑定,所以不受这个问题的困扰,若mf是个虚函数,无论通过pb还是pd都会访问D::mf。
若在classD中重新定义继承自classB的非虚函数mf,D对象很可能展现精神分裂的不一致现象。当mf被调用,任何一个D对象都有可能表现B或D的行为;决定因素在于指向对象的指针,引用和指针类似。
- 适用于B对象的每一件事也适用于D对象(is-a)。
- B的派生类一定会继承mf的借口和实现,因为mf是一个非虚函数。
若D重新定义mf,则会出现矛盾。若D必须要实现mf,那么就不能public继承,若非要public继承,那么应该将mf声明为虚函数。
总结
- 绝对不要重新定义继承而来的非虚函数。