EffectiveC++ 第6章 继承与面向对象设计

时间:2022-11-03 21:30:00

我根据自己的理解,对原文的精华部分进行了提炼,并在一些难以理解的地方加上了自己的“可能比较准确”的「翻译」。

Chapter 6 继承与面向对象设计

Inheritance and Object-Oriented Design


条款32: 确定你的public继承塑模出is-a关系

以C++进行面向对象编程,最重要的一个规则是:public inheritance(公开继承)意味着”is-a”(是一种)的关系。请牢记这个规则!

如果你令class D(Derived)以public形式继承class B(Base),也就意味着每一个类型为D的对象同时也是一个类型为B的对象,且B比D表现出更一般化的概念,而D比B表现出更特殊化的概念。你主张“B对象可能派上用场的任何地方,D对象一样可以派上用场”,因为每一个D对象都是一种(一个)B对象。反之若你需要一个D对象,B对象无法效劳,因为虽然每个D对象是一个B对象,反之并不成立。

考虑以下例子:

class Person {...};
class Student: public Person {...};

根据生活经验,每个学生都是人,但并非每个人都是学生,这便是继承体系的主张——人的概念比学生更一般化,学生是人的一种特殊形式。

承上所述,任何函数若期望一个Person类型的实参,便也能接受一个Student对象

void eat(const Person& p); //任何人都吃东西

void study(const Student& s); //学生才会到校学习

Person p; //p是人

Student s; //s是学生

eat(p); //没问题,人吃东西

eat(s); //没问题,学生也是人,得吃东西

study(s); //没问题,s是学生

study(p); //错,p不是学生

这些论点只对public继承成立,而private继承意义与此完全不同,至于protected继承,是一种十分令人困惑的东西。

public继承和is-a之间的等价关系听起来很简单,但有时直觉会误导你。比如,企鹅(penguin)是一种鸟,这是事实。鸟会飞,这也是事实。若我们天真地以C++描述这层关系:

class Bird{
public:
virtual void fly(); //鸟会飞
...
};
class Penguin: public Bird{
...
};

然而企鹅不会飞,这太真实了!!

当我们说鸟会飞的时候,我们真正的意思是「只是一般的鸟都有飞行能力」。若谨慎一点,我们应承认有数种鸟不会飞。下面我们来塑模较佳的真实性:

class Bird{
...
};
class FlyingBird: public Bird{
public:
virtual void fly();
...
};
class Penguin: public Bird{
...
}; //这样的继承体系似乎比原先设计更真实一些

另一种做法是为企鹅类重写fly,令它产生一个运行时错误:

void error(const std::string& msg); //定义于某处
class Penguin: public Bird{
public:
virtual void fly() { error("Attempt to make a penguin fly!"); }
};

这里不是说「企鹅不会飞」,而是说「企鹅会飞,但尝试这么做是错误的」。

如何描述上述两种做法的差异?从错误被侦查出来的时间点看,“企鹅不会飞”这一限制可由编译器强制实施,但若违反“企鹅尝试飞行,是一种错误”,只有运行期才能检测出。

为表现「企鹅不会飞」的限制,你不能为Penguin定义fly函数:

class Bird{
... //未声明fly
};
class Penguin: public Bird{
... //未声明fly
};

现在你企图让企鹅飞,编译器会指出错误:

Penguin p;
p.fly(); //error!

条款18曾说过,好的接口可防止无效的代码通过编译,因此你应宁可采取“在编译期拒绝企鹅飞行”的设计。

public继承主张,能够施行于base class对象身上的每件事情,也可以施行于dervied class对象身上,但比如某些可施行于矩形(例如宽度可独立于其高度被外界修改)却不可施行于正方形身上(宽高应总是一样)。所以,这种主张在矩形与正方形之间无法保持: 代码通过编译并不表示就可以正确运作。

请记住:

  • “public继承”意味着is-a。适用于base classes身上的每一件事情一定也适用于derived classes身上,因为每个dervied class对象也是一个base class对象。

条款33: 避免遮掩继承而来的名称

考虑作用域(scope)。

看下面的代码:

int x;  //global变量
void someFunc()
{
double x; //local变量
std::cin>>x;
}

这个cin读取语句指涉的是local变量x,不是global变量x—————内层作用域的名称会掩盖外围作用域的名称。

现在导入继承。我们知道,当位于一个dervied class成员函数内指涉(refer to)base class内的某物(可能是成员函数,typedef或成员变量)时,编译器可找出我们说指涉的东西,因为dervied class继承了base class里的所有东西。实际运作方式是,dervied class作用域被嵌套在base class作用域内:

class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf2();
void mf3();
...
};
class Dervied: public Base{
public:
virtual void mf1();
void mf4();
...
};

假设dervied class内的mf4实现码部分像这样:

void Derived::mf4()

{



mf2()

}

编译器首先查找local,也就是mf4覆盖的作用域,没找到mf2。于是查找外围,也就是class Derived覆盖的scope,还是没找到名为mf2的东西。继续往外围移动,来到了class Base,在那儿找到了mf2函数。假设Base内还是没有,编译器会查找含Base的那个namespace的作用域,没有的话最后查找global作用域。

对于之前的例子,这次让我们在Base中重载mf1和mf3,并在Dervied中添加一个新版mf3:

class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int); //重载
virtual void mf2();
void mf3();
void mf3(double);//重载
...
};
class Dervied: public Base{
public:
virtual void mf1();
void mf3(); //覆盖
void mf4();
...
};

“名称遮掩规则”并未改变,因此Base里的所有mf1和mf3都被dervied里的mf1和mf3覆盖掉了:

Derived d;
int x;
...
d.mf1(); //OK
d.mf1(x); //错误,Derived::mf1覆盖了Base::mf1
d.mf2(); //OK
d.mf3(); //OK
d.mf3(x); //错误,Derived::mf3覆盖了Base::mf3

如你所见,即使Base和Derived里的函数参数类型不同,上述规则同样适用,且无论是virtual还是non-virtual也适用

这些规则背后的理由是防止你在程序库或应用框架(application framework)内建立新的derived class时附带地从疏远的base class继承重载函数。 然而有时你确实就是想继承重载函数。实际上如果你正使用public继承但又不继承那些基类的重载函数,就违反了base和derived class之间的is-a关系,然而条款32说过is-a时public继承的基石。因此你总会想推翻(override)C++对“继承而来的名称”的默认遮掩行为。

这时,你可以用using声明达成目标:

class Base{
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int); //重载
virtual void mf2();
void mf3();
void mf3(double);//重载
...
};
class Dervied: public Base{
public:
using Base::mf1; //让Base内名为mf1和mf3的所有东西
using Base::mf3; //在Deribed作用域内可见(public)
virtual void mf1();
void mf3(); //覆盖
void mf4();
...
};

现在,继承机制可以正常运作:

Derived d;
int x;
...
d.mf1(); //OK
d.mf1(x); //OK
d.mf2(); //OK
d.mf3(); //OK
d.mf3(x); //OK

以上的例子告诉我们:若你希望继承base class并加上重载函数,而你还希望重新定义或覆写(推翻)其中一部分,你便必须为那些原本会被遮盖的每个名称引入一个using声明式,否则它们会被覆盖

而有时你并不想继承base class里的所有函数,但是这在公有继承里是不现实的,因为它违反了base与derived之间的is-a关系。 然而在private继承下,你的想法可能是有意义的。

假设Derived私有继承了Base,Derived欲继承mf1无参版本。using声明在这里没用,因为它会令继承来的某给定名称的所有同名函数都在Derived中可见。 这时可以用一个简单的转交函数:

class Base{
public:
virtual void mf1() = 0;
virtual void mf1(int);
... //与前同
};
class Dervied: public Base{
public:
virtual void mf1() //转交函数
{ Base::mf1(); } //暗自成为inline
...
};
...
Derived d;
int x;
d.mf1(); //OK,调用Derived::mf1()
d.mf1(x); //错误,Base::mf1()被掩盖了

请记住:

  • derived classes内的名称会掩盖base classes内的名称。在public继承下从来没有人希望如此。
  • 为让被掩盖的名称重见天日,可用using声明式或转交函数。

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

表面上直接的public继承概念,经严密的检查后,发现它由两部分组成:函数接口(function interfaces)继承和函数实现(function implementations)继承。

身为class设计者,有时你希望derived class仅继承成员函数的接口(声明);有时你希望同时继承函数的接口和实现,但又能够覆写(override)它们所继承的实现;有时你又希望同时继承接口和实现,且不允许覆写任何东西。

让我们考虑一个展现绘图程序中各种几何形状的class的继承体系来体现上述的差异:

class Shape{
public:
virtual void draw() const = 0;
virtual void error(const std::string& msg);
int objectID() const;
...
};
class Rectangle: public Shape {...};
class Ellipse: public Shape {...};

由于含有一个pure virtual函数draw,Shape成为一个抽象class。所以客户不能创建Shape class的实体对象,只能创建其derived classes的实体对象。 尽管如此,Shape还是强烈影响了所有以public形式继承它的derived classes ,因为:

  • 成员函数的接口总是会被继承。如条款32所说,public意味着is-a,所以对base class为真的事情一定对其derived classes为真。因此若某函数可施行于某class身上,一定也可施行于其derived class身上。

此例中,draw这个pure virtual函数最突出的两个特性是: 必须被任何「继承了它们」的具象class重新声明,且它们在抽象class中通常没定义

  • 声明一个pure virtual目的是让derived class仅继承函数接口。

Shape class无法对draw函数提供默认实现,它其实就是想告诉自己的继承者:“你必须提供一个draw函数,但我不干涉你怎么实现它”。

所以你可以为纯虚函数提供定义,也就是为Shape::draw供应一份实现代码,它的唯一途径是”调用时明确指出class名称”:

Shape* ps = new Shape; //错误,Shape是抽象的
Shape* ps1 = new Rectangle; //OK
ps1->draw(); //Rectangle::draw
Shape* ps2 = new Ellipse; //OK
ps2->draw(); //Ellipse::draw
ps1->Shape::draw(); //Shape::draw
ps2->Shape::draw(); //Shape::draw

一般而言这项性质用途有限。但稍后你讲看到它可以实现一种机制,为简朴的impure (非纯) virtual函数提供更平常安全的默认实现。

对于impure virtual函数,一如往常,derived classes继承其base class的函数接口,但impure virtual函数会在base class里提供一份实现代码,derived classes可能会覆写(override)它:

  • 声明简朴的impure virtual函数的目的,是让derived classes继承改函数的接口和默认实现。

假设Shape类里有一个error函数:

class Shape{
public:
virtual void error(const std::string& msg);
...
};

这个error接口表示,每个class都必须支持“出现错误时可调用”的函数,但每个class可自己选择如何处理错误。假若某derived class不想针对错误做出任何特殊行为,就可以退回到它的base class Shape,使用Shape提供的默认处理动作。

但这种设计可能会造成危险。让我们讨论航空公司XYZ设计的飞机继承体系:该公司只有A和B型两种飞机,两者以相同方式飞行。因此XYZ设计出这样的继承体系:

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实现”,Airplane:: fly被声明为virtual。然而为了避免在ModelA与ModelB中撰写相同代码,默认飞行行为由Airplane::fly提供,它同时被ModelA与ModelB继承。

这种设计的确可以避免撰写重复代码,减缓长期维护所需成本

但是现在,假设XYZ公司决定购买一种新式C型飞机,它与A与B有一些不同————飞行方式不同。

XYZ的程序员针对C添加了一个class,但由于他们忘了为其重写fly函数:

class ModelC: public Airplane{

... //未声明fly函数

};

然后代码中这种操作:

Airport PDX(...);
Airplane* pa = new ModelC;
...
pa->fly(PDX); //实际调用Airplane::fly

这将酿成大祸:此程序试图以ModelA或ModelB的飞行方式来飞ModelC。

Luckily,我们可以切断virtual函数接口和其默认实现之间的连接:

class Airplane{
public:
virtual void fly(const Airport& destination)=0;
...
protected:
void defaultFly(const Airport& destination);
};
void Airplane::defaultFly(const Airport& destination)
{
//默认行为,将飞机飞至指定目的地。
}

此时我们已经将Airplane::fly改成了一个pure virtual函数,仅提供飞行接口。默认实现也在Airport中,但仅以独立函数defaultFly的形式出现。若想用默认实现(例如ModelA,ModelB),可以在其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实现了,因为Airplane中的纯虚函数强迫它提供自己的fly版本:

class ModelC: public Airplane{
public:
virtual void fly(const Airplane& destination);
...
}; void ModelC::fly(const Airport& destination)
{
//C的特殊实现
}

但过度雷同的函数名称会引起class命名空间污染。唔,我们可以利用pure class也可拥有自己的实现这一事实。下面便是Airplane继承体系如何给pure virtual函数一份定义:

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函数被分割为两个部分:声明部分表现为接口,定义部分表现出默认实现(derived classes可用,但仅在它们明确申请要用默认实现才行)。

最后,看看Shape class的non-virtual函数objectID:

class Shape{
public:
int objectID() const;
...
};

声明为公有的非虚函数,意味着它不打算在derived classes中有不同行为。

你可以把Shape::objectID的声明想做是:「每个Shape对象都有一个用来产生对象识别码的函数;此识别码总是采用相同计算方法,由Shape ::objectId的定义式决定,任何derived class都不应改变其行为」。由于non-virtual函数代表的意义是不变性凌驾特异性,所以它绝不该在derived class中被重新定义,这也是条款36讨论的一个重点。


条款35: 考虑virtual函数以外的其它选择

假设你在写一个游戏,你决定提供一个成员函数healthValue,返回一个整数,表示游戏人物的健康程度,它将返回一个整数。对于不同人物可能以不同的方式计算他们的健康指数,将此函数声明为virtual似乎很明确:

class GameCharacter

public:

virtual int healthValue() const;

...

};

healthValue未被声明为pure virtual,暗示我们有一个计算健康指数的默认实现。

这的确相当明确的设计。但为了帮你跳脱面向对象设计路上的常轨,让我们考虑其它解法。

由 Non-Virtual Interface手法实现 Template Method模式:

有一种流派,主张virtual函数应总是private。对于此例,他们建议应保留healthValue为public成员函数,但让它成为non-virtual,并调用一个private virtual函数进行实际工作:

class GameCharacter{
public:
int healthValue() const{ //derived classes不重新定义它
... //做一些事前工作
int retVal = doHealthValue(); //真正实现
... //做一些事后工作
return retVal;
}
...
private:
virtual int doHealthValue() const{ //derived classes可覆写
... //默认实现,计算健康指数
}
};

这一基本设计,也就是“令客户通过public non-virtual函数间接调用private virtual函数”,称为non-virtual interface(NVI)手法。它是所谓Template Method设计模式(与C++的templates无关联)的独特表现形式。我们在这里将non-virtual函数叫做virtual函数的外覆器。

NVI手法的优点在于,它让non-virtual外覆器(此例为healthValue)确保得以在virtual函数被调用之前设定好适当场景,并在调用结束后清理场景。“事前工作”可包含锁定互斥器、制造运转日志记录项、验证class的约束条件等等;“事后工作”可包括互斥器解除锁定、验证函数的事后条件等等。但如果你让客户直接调用virtual函数,就没办法做好这些事

由Function Pointers实现Strategy模式

另一个戏剧性设计主张 “人物健康指数的计算与人物类型无关”,也就是说这个计算完全不需要“人物”这个成分。 例如我们可能会要求每个人物的构造函数接受一个指针,指向一个健康计算函数,我们可以调用它进行实际计算:

class GameCharacter;  //前置声明

int defaultHealthCalc(const GameCharacter& gc); //函数默认实现

class GameCharacter{
public:
typedef int (*HealthCalcFunc)(const GameCharacter&); //定义了一种HealthCalcFunc类型的函数指针。
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};

这个做法是常见的Strategy设计模式的简单应用,它提供了某些有趣弹性:

  • 同一人物类型之不同实体可有不同健康计算函数:
class EvilBadGuy: public GameCharacter{
public:
explicit EvilBadGuy(HealthCalcFunc hcf = defaultHealthCalc)
:GameCharacter(hcf)
{ ... }
...
};
int loseHealthQuickly(const GameCharacter&);
int loseHealthSlowly(const GameCharacter&); EvilBadGuy ebg1(loseHealthQuickly);
EvilBadGuy ebg2(loseHealthSlowly);
  • 某已知人物之健康指数计算函数可在运行期变更。例如GameCharacter可提供一个成员函数setHealthCalculator,用来替换当前的健康指数计算函数。

若人物的健康可纯粹根据该人物public接口得来的信息计算,上面的方法没任何问题;但如果需要non-public信息计算,就有问题了。

一般而言,唯一能解决 「以non-member函数访问class的non-public成分」的办法为:

弱化class的封装性。例如class可将那个non-member函数变为friends,或是为其实现的某部分提供public访问函数(其它部分宁可隐藏起来)。运用函数指针替换virtual函数,优点是否足以弥补缺点(可能必须降低封装性),是你必须举一反三的。

由tr1::function完成Strategy模式

为什么计算健康指数的东西必须是个函数,而不是某种”像函数的东西“呢?若一定得是函数,为什么不能够是个成员函数?为什么必须返回int而不是任何可被转换为int的类型呢?

如果我们不再像前列一样使用函数指针(healthFunc),而是该用一个类型为tr1::function的对象,那么这些约束就都没了:

class GameCharacter;
int defaultHealthCalc(const GameCharacter& gc);
classs GameCharacter{
public:
//HealthCalcFunc可以是任何“可被调用物”,并接受任何兼容于GameCharacter
//之物,返回任何兼容于int的东西:
typedef std::tr1::function<int (const GameCharacter&)> HealthCalcFunc;
explicit GameCharacter(HealthCalcFunc hcf = defaultHealthCalc)
: healthFunc(hcf)
{}
int healthValue() const
{ return healthFunc(*this); }
...
private:
HealthCalcFunc healthFunc;
};

正如你见,HealthCalcFunc是个typedef:

std::tr1::function<int (const GameCharacter&)>

这个参数签名代表目标函数是“接受一个指向GameCharacter的引用,并返回int”。这个tr1::function类型(也就是我们定义的HealthCalcFunc类型)产生的对象可持有任何与此参数签名式兼容的可调用物(callable entity)。 所谓兼容,意味着这个可调用物的参数可悲隐式转换为const GameCharacter&,而且其返回类型可被隐式转换为int类型.

这个设计和前一个模式相比较,唯一不同的是GameCharacter持有一个tr1::function对象,相当于一个指向函数的泛化指针。它的惊人弹性会在客户欲指定健康计算函数上发挥出来:

short calcHealth(const GameCharacter&);
//健康计算函数,注意其返回类型不是int
struct HealthCalculator{ //为计算健康设计的函数对象
int operator() (const GameCharacter&) const
{ ... }
}; class GameLevel{
public:
float health(const GameCharacter&) const;
//计算健康的成员函数,注意其返回类型不是int
...
}
class EvilBadGuy: public GameCharacter{
... //同前
};
class EyeCandyCharacter: public GameCharacter{
...
//另一个人物类型,假设构造函数与EvilBadGuy相同
}; EvilBadGuy ebg1(calcHealth); //人物1使用某-函数-计算健康指数 EyeCandyCharacter ecc1(HealthCalculator()); //人物2使用某-函数对象-计算健康指数 GameLevel currentLevel;
...
EvilBadGuy ebg2{ //人物3使用某-成员函数-计算健康指数
std::tr1::bind(&GameLevel::health,
currentLevel,_1) //详见以下
};

古典的Strategy模式

传统(典型)的Strategy做法会将健康计算函数做成一个分离的继承体系中的virtual

成员函数,下面是对应的骨干代码:

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

体系中的EvilBadGuy和EyeCandyCharacter都是derived classes,每个GameCharacter对象都内含一个指针,指向来自HealthCalcFunc继承体系的对象。

这个解法的吸引力在于,熟悉标准Strategy模式的人很容易辨认它,而且它还提供「讲一个既有的健康算法纳入使用」的可能性—————只要为HealthCalcFunc继承体系添加一个derived class即可

摘要

本条款初衷是,当你为解决问题而寻找某设计方案,不妨考虑virtual函数的替代方案。下面快速重点复习提到的几个方案:

  • 使用non-virtual interface(NVI)手法,那是Template Method设计模式的一种特殊形式。它以public non-virtual成员函数包裹较低访问性(private或protected)的virtual函数。

  • 将virtual函数替换为“函数指针成员变量”,这是Strategy设计模式的一种分解表现形式。

  • 以tr1::function成员变量替换virtual函数,因而允许使用任何可调用物搭配一个兼容于需求的签名式。这也是Strategy模式的某种形式。

  • 将继承体系内的virtual函数替换为另一个继承体系内的virtual函数。这是Strategy设计模式的传统实现手法。


条款36: 绝不重新定义继承而来的non-virtual函数

假设有这样的继承体系:

class B{
public:
void mf();
...
}; class D: public B{ ... }

若面对一个D类型的对象x:

D x;

如果告诉你以下行为:

B* pB = &x; //获得一个基类指针指向x

pB->mf(); //经由该指针调用mf

异于以下行为:

D* pD = &x; //获得一个指针指向x

pB->mf(); //经由该指针调用mf

你可能会比较惊讶,两者都通过x调用mf,行为应该都相同啊!

你的直觉是对的,但C++就是这么神奇。但是,若mf是一个non-virtual函数且D定义有自己的mf版本,那么就不会发生上面的情况:

class D: public B{
public:
void mf(); //遮掩(hides)了B::mf,见条款33
...
};
pB->mf(); //调用B::mf
pD->mf(); //D::mf

造成此行为的原因是:non-virtual函数如同此例的B::mf和D ::mf都是静态绑定(statically bound,见条款37)。意味着,pB被声明为一个pointer-to-B,通过pB调用的non-virtual函数永远是B所定义的版本,即使pB指向一个类型为B的派生类class的对象。

但另一方面,virtual函数是动态绑定(dynamically bound,见条款37),所以它们不受此困扰。意味着若mf是虚函数,不论通过pB还是pD调用mf,都会导致调用D::mf,因为pB和pD真正指的都是一个类型为D的对象。

确切滴说,当mf被调用,任何一个D对象表现的行为,取决于“指向该对象的指针”当初的声明类型。References也会展现和指针一样难以理解的行为,因为引用本身相当于常量指针罢了。

结论很明显:任何情况下都不该重新定义一个继承而来的non-virtual函数。


条款37: 绝不重新定义继承而来的默认参数值

你只能继承两种函数:virtual和non-virtual。然而重新定义一个继承而来的non-virtual永远是错误的,所以我们可以安全地将本条款的讨论局限于“继承一个带有默认参数值的virtual函数”

virtual函数系动态绑定(dynamically),而 默认参数值却是静态绑定 (statically bound)。

这很重要!(为了正式记录在案,我们声明一下:静态绑定又名前期绑定,early binding; 动态绑定又名后期绑定,late binding)

对象所谓的静态类型(static type),就是它在程序中被声明时采用的类型。考虑以下的继承体系:

class Shape{
public:
enum ShapeColor { Red,Green,Blue };
//所有形状必须提供一个函数绘出自己
virtual void draw(ShapeColor color = Red) const = 0;
...
};
class Rectangle: public Shape{
public:
virtual void draw(ShapeColor color = Green) const;
//注意,继承类使用了不同的默认参数值,这很糟糕
...
};
class Circle: public Shape{
public:
virtual void draw(ShapeColor color) const;
// 请注意,以上这么写,则当客户以对象调用此函数,一定要指定参数值。
// 因为静态编译下这个函数并不从其Base继承默认参数值
// 但若以指针或引用调用此函数,可以不指定参数值
// 因为动态绑定下这个函数会从其base继承默认参数值
};

有了上面的体系,考虑以下代码:

Shape* ps; //静态类型为Shape*

Shape* pc = new Circle; //静态类型为Shape*

Shape* pr = new Rectangle; //静态类型为Shape*

ps,pc和pr都被声明为指向Shape类型的指针,所以它们都以它为静态类型。注意,不论它们真正指向什么,它们的静态类型都是Shape*

对象所谓动态类型(dynamic type)指“目前所指对象的类型”。也就是说,动态类型可以表现出一个对象会有什么行为。本例中,pc的动态类型是Circle*,pr的动态类型是Rectangle *。ps没有动态类型,因为它尚未指向任何对象。

动态类型亦如其名,可在程序执行过程中改变(通常经由赋值动作)

ps = pc; //ps的动态类型变为Circle*

ps = pr; //ps的动态类型变为Rectangle*

Virtual函数也系动态绑定而来,意思是调用一个virtual函数时,究竟调用哪一份函数实现代码,取决于发出调用的那个对象的动态类型。

pc->draw(Shape::Red); //调用Circle::draw(Shape::Red)

pr->draw(Shape::Red); //调用Rectangle::draw(Shape::Red)

这确实是老调重弹::你当然已经了解virtual函数。但当考虑带有默认参数值的virtual函数,花样来了,正如我们稍早所了解:

virtual函数是动态绑定,而默认参数值却是静态绑定

假设你调用一个derived class内的virtual函数,却使用了base class为它指定的默认参数值:

pr->draw(参数值为空,使用默认参数); //调用Rectangle::draw(Shape::Red)

此例中,pr动态类型为Rectangle* ,所以调用的是Rectangle版本的virtual函数。因为没有填写参数值,所以直觉上我们认为函数会使用Rectangle::draw的默认参数值GREEN。然而pr的静态类型是Shape* ,所以此调用所需的默认参数值来自Shape class。多奇怪啊!

以上事实即使把指针换成references引用也一样,原因是引用本身相当于常量指针

C++为什么以这种乖张的形式运作?答案在于运行期效率,如果默认参数值也采用动态绑定,会降低运行效率。所以C++做了这样的取舍。

若你遵守了规则,同时提供默认参数值给base和derived classes的用户,会发生什么?

class Shape{
public:
enum ShapeColor {Red, Greem, Blue };
virtual void draw(ShapeColor color = Red) const = 0;
...
}; class Rectangle: public Shape{
public:
virtual void draw(ShapeColor color = Red) const;
...
};

两个问题。一是代码重复。二是,若要遵守规定,如果Shape内默认参数改变,重复给定默参数的derived classes也必须改变,否则它们会导致“重复定义一个继承而来的默认参数值”

聪明的做法是替代设计,条款35列了不少virtual函数的替代设计,其中之一是NVI手法:令base class内的一个public non virtual函数调用private virtual函数,后者可悲derived classes重新定义。

class Shape{
public:
enum ShapeColor { Red, Green, Blue };
void draw(ShapeColor color = Red) const
{
doDraw(color); //调用virtual
}
...
private:
virtual void doDraw(ShapeColor color) const = 0;
// 真正工作在此完成
}; class Rectangle: public Shape{
public:
...
private:
virtual void doDraw(ShapeColor color) const; //注意,不指定默认参数
...
};

由于non-virtual函数绝不应被derived classes覆写(条款36),这个设计很清楚地使得draw函数的color默认参数总是Red。


条款38: 通过符合塑模出has-a或“根据某物实现出”

复合(composition)是类型之间的一种关系,当某种类型的对象内含其它种类型的对象,便是这种关系。例如:

class Address { ... };
class PhoneNumber { ... };
class Person{
public:
...
private:
std::string name;
Address address;
PhoneNumber voiceNumber;
PhoneNumber faxNumber;
};

上例中Person对象由string、Address、PhoneNumber构成。复合还有一些同义词,包括layering(分层),containment(内含),aggregation(聚合)和embedding(内嵌)。

条款32曾说public继承带有is-a(是一种)的意义。复合有两个意义,它意味着has-a(有一个)或is-implemented-in-terms-of(根据某物实现出)。

程序中的对象其实相当于你所塑造的世界中的事物,例如人、汽车、一张张视频画面等。这样的对象属于应用域(application domain)部分。其它对象则纯粹是实现细节上的人工制品,像是缓冲区(buffers)、互斥器(mutexes)、查找树(search trees)等,这些对象相当于你软件等实现域(implementation domain)。当复合发生于应用域内的对象之间,表现出has-a的关系;发生于实现域内则是表现is-implemented-in-terms-of的关系

上述的Person class示范了has-a关系。Person有一个名称,一个地址,以及语音和传真两个电话号码。 你不会说「人是一个名称」或「人是一个地址」, 你会说「人有一个名称」和「人有一个地址」。

然而不好区分的是is-a和is-implemented-in-terms-of。

假设你需要一个template,希望造出一组classes用来表示由不重复对象组成的sets。由复用你想到了采用标准库提供的set template。

不幸的是,由于sets通常以平衡查找树(balanced search trees)实现,使它们在查找、安插、移除元素时保证拥有对数时间效率,这往往招致「每个元素耗用三个指针的额外开销」,也就是空间换速度。但如果你的程序是空间比速度重要呢?似乎这样你终究得写个自己的template。

实现sets的方法很多,其中一种是底层采用linked list,标准程序库里正好有它,所以我们决定复用它。

我们决定让Set template继承std::list。也就是让Set继承list:

template<typename T>

class Set: public std::list<T> { ... };

但是public继承意味着如果D是一种B,则对B为真的事情对D也都为真。但list可内含重复元素。因此“Set是一种list”并不为真,因为对list对象为真的某些事情对Set对象并不为真,因为对list对象为真的某些事情对set对象并不为真。

所以目前我们知道public继承并不适合塑模它们。正确的做法是,仅用一个list对象将一个Set实现出来:

template<class T>
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; //用来表述Set的数据
};

只要你熟悉STL,你可以让Set大量依赖list及其标准库其它部分提供的机能。

template<typename T>
bool Set<T>::member(const T& item) const
{
return std::find(rep.begin(),rep.end(),item) != rep.end();
}
template<typename T>
void Set<T>::insert(const T& item)
{
if(!member(item)) rep.push_back(item);
}
template<typename T>
void Set<T>::remove(const T& item)
{
typename std::list<T>::iterator it = //见条款42对typename的讨论
std::find(rep.begin(),rep.end(),item);
if(it != rep.end()) rep.erase(it);
}
template<typename T>
std::size_t Set<T>::size() const
{
return rep.size();
}

这些函数很简单,适合称为inlining候选人,但请参考条款30的建议。

请记住

  • 复合(composition)的意义和public继承完全不同
  • 在应用域(application domain),复合意味has-a(有一个)。在实现域(implementation domain),复合意味is-implemented-in-terms-of(根据某物实现出)

条款39: 明确而审慎地使用private继承

请考虑下面的代码:

class Person { ... };
class Student: private Person { ... };
void eat(const Person& p);
void study(const Student& s); Person p; //p是人
Student s; //s是学生 eat(p); //ok,p是人,会吃
eat(s); //报错??难道学生不是人?

显然 private 继承不意味着is-a关系,那它意味什么?

通过上面的代码你可以得知:

如果classes之间的继承关系是private,编译器不会自动将derived class对象(Student)转换为一个base class对象(Person)。这和public继承的情况不同,也就是通过s调用eat失败的原因。

第二条规则是,由private base class继承来的所有成员,不管它们之前是什么属性,在derived class全会变成private属性

Private继承意味 implemented-in-terms-of(根据某物实现出)。你若让class D以private形式继承class B,本质上是为了采用class B已具备的某特性,并不是因为B和D存在任何观念上的关系,所以private继承是一种纯粹的实现技术

若D以private形式继承B,意思是D对象根据B对象实现而得,再没有其他意涵了。private继承在软件“设计”上没有意义,其意义仅及软件实现层面。

其实条款38指出的复合(composition)的意义也近似,如何取舍呢?答案是尽量使用复合,必要时用private。

何时必要呢? 主要是当protected成员和/或virtual函数牵扯进来时 。另外一种激进情况,稍后讨论。

假设我们的程序涉及Widget class,并决定修改它,让它记录每个成员函数被调用次数。为完成这个任务,需要设定定时器。

在C++的宝库中,我们找到了Timer这个class:

class Timer{
public:
explicit Timer(int tickFrequency);
virtual void onTick() const; //定时器每滴答一次,
//此函数就自动被调用一次
};

一个Timer对象,可调整为需要的频率滴答,每次滴答就调用onTick虚函数,我们只需要重写这个函数,让它能取出Widget的状态,perfect.

为了能重写 onTick() 虚函数,Widget需继承Timer。 但public继承此时并不恰当,因为Widget和Timer没有任何关联,一个Widget对象并不是一个Timer。 Widget的客户总不能对一个Widget调用onTick吧,因为观念上那并不是Widget接口的一部分。

所以必须以private继承Timer:

class Widget: private Timer{
private:
virtual void onTick() const; //查看Widget的数据..
...
};

Timer的public onTick因此在Widget内变为private。再说一次,将onTick放进public接口会误导客户端以为可以调用它。

这种private继承是不错,但绝非必要。可以用复合(composition)取代之。只要在Widget内声明一个嵌套式private class,后者以public形式继承Timer并重写onTick,再将这样一种类型的对象放在Widget内:

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

有两个理由说服你选择这种设计:

  • 首先,你或许会设计Widget的dervied classes,但同时你想阻止它们重写Widget的onTick虚函数。所以如果Widget即使以private继承自Timer,以上想法也不能实现。(条款35曾说过,可重新定义virtual函数),即使它们不能调用它。 但若WidgetTimer是Widget内一个私有成员并继承Timer,Widget的之类将无法取用WidgetTimer,因此无法继承或重写onTick。 若你曾经以Java,C Sharp实现过此能力(Java的final和C#的sealed),现在你知道如何在C++中模拟它了。

  • 第二,你或许希望Widget的编译依存性降至最低。若Widget继承Timer,编译Widget时Timer的定义必须可见,所以定义Widget的文件必须#include Timer.h。 但如果WidgetTimer移出Widget之外而Widget内含一个WidgetTimer指针,Widget就只需带一个简单的WidgetTimer声明式,不再需要include与Timer有关的东西。

之前说过,有一种激进情况涉及空间最优化,这会促使你选择私有继承: 这种情况适用于你处理的class不带任何数据。这种class无non-static成员变量,无virtual函数(因为这种函数的存在会为每个对象带来一个vptr,见条款7),也没有virtual base classes(这种base classes会导致空间上额外开销,见条款40)。 于是这种empty classes对象不使用任何空间。

然而由于技术上的理由,C++决定,凡是独立对象的大小均 > 0 。若你这么做:

class Empty {}; //无任何数据,所以其对象应不使用内存

class HoldsAnInt{ //应只需一个int空间

private:

int x;

Empty e; //应不需任何内存

};

你会发现 sizeof(HoldsAnInt) > sizeof(int) 。咦,一个Empty成员变量竟然需要内存? 这是因为面对“大小为0的独立对象”,C++通常默默安插一个char到空对象内。而且齐位需求(alignment,条款50)会造成编译器为类似HoldsAnInt这样的class加上一些衬垫,这样HoldsAnInt对象可能不止获得一个char大小。

但这种约束不适用于子类对象中的基类成分,因为它们并非独立,若你是继承Empty:

class HoldsAnInt: private Empty{

private:

int x;

};

几乎能确定 sizeof(HoldsAnInt) == sizeof(int) 。这便是所谓的EBO(empty base optimization; 空白基类最优化)。如果你的程序开发非常注重空间利用,你应该重视EBO。值得注意的是,EBO一般只在单继承下可行。

但回到根本,大多数类并非empty,所以EBO很少称为私有继承的理由。所以只要可能,尽量选择复合的设计。「明智而审慎地使用peivate继承」意味,在考虑过其它方案后,若任觉得私有继承是“表现两个类之间的关系”的最佳办法,才用它。

请记住

  • 私有继承意味 implemented-in-terms-of (根据某物实现出)。它通常比复合的级别低。但当子类需访问基类的protected成员,或需重写继承的虚函数,这种设计合理。

  • 私有继承可使empty base最优化,这可能很重要。



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

涉及到多继承(multiple inheritance: MI),有人认为单继承(single inheritance: SI)好,多继承更好;另一阵营主张SI是好的,但MI不值得使用。

需要清楚的是,当运用了MI,程序有可能从一个以上的base classes继承相同「名称」(如同名函数、typedef等),那将导致较多歧义。看下面例子:

class BorrowableItem{			// 图书馆允许借出的东西
public:
void checkOut(); // 离开时进行检查
...
};
class ElectronicGadget{ // 小电器工具类
private:
bool checkOut() const; //工具执行自我检查
...
};
class MP3Player: // 注意此处的多继承
public BorrowableItem, // MP3能被某些图书馆借出
public ElectronicGadget
{ ... }; MP3Player mp;
mp.checkOut(); // 歧义!调用的是哪个checkOut?

很明显,此例中对checkOut的调用是模棱两可的,即使两个函数中只有一个是public(即BorrowableItem的)。

在看到是否有个函数可取用之前,C++首先确认此函数是否为最佳匹配;找出最佳匹配后才检验其可取用性。

然而本例中两个checkOut函数有相同的匹配程度,没有所谓最佳匹配。

为解决这个歧义,你必须明白指出你要调用的是哪个base class里的函数:

mp.BorrowableItem::checkOut(); //ok

mp.ElectronicGadget::checkOut(); //private,not ok

有一种多继承叫钻石型多继承:

EffectiveC++ 第6章 继承与面向对象设计

class File {...};

class InputFile: public File {...};

class OutputFIle: public File {...};

class IOFile: public InputFile,

public OutputFile

{...};

就像你看到的,若base class和某个derived class,比如本例的File和IOFile,之间有一条以上的相通路线,你必须面对一个问题:

是否打算让base class内的成员变量经由每一条路径被复制?假设File class有个成员变量fileName,那么IOFile内该有多少笔这个名称的数据呢?从某个角度说,IOFile从其每一个base class继承一份,所以其对象内应有两份fileName变量。但从另一个角度说,常识告诉我们,IOFile对象只该有一个fileName。

而C++两种都支持,且默认做法是执行复制。但如果你只想继承某个base class的成员,你必须令那个带有此数据的class(也就是File)成为一个_virtual base class_。 做法是,你必须令所有直接继承它的classes采用“virtual 继承”:

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

EffectiveC++ 第6章 继承与面向对象设计

C++标准程序库内含一个多重继承体系,其结构如上图,只不过其中的类为class templates(类模版),名称分别是basic_iso、basic_istream、basic_ostream和basic_iostream,而非此处的File、InputFile、OutputFile和IOFile。

从正确行为观点看,public继承应该总是virtual。virtual继承能避免继承得来的成员变量重复,但后果是,经编译器处理,使用virtual继承的那些classes所产生的对象往往比non-virtual classes体积大;访问virtual base classes成员变量也比non-virtual慢。很多细节因编译器不同而异, 但清楚的是你得为virtual继承付出代价

virtual继承还有其它成本。支配 virtual base classes 初始化的规则比 non-virtual bases 的情况远为复杂且不直观。virtual base的初始化责任由继承体系中最底层(most derived)class负责,这暗示了:

  • classes若派生自virtual bases而需要初始化,必须认知其virtual bases——不论那些bases多远

  • 当一个新的derived class加入继承体系中,它必须承担其virtual bases(不论直接还是间接)的初始化责任

我们的忠告是,非必要不要用virtual bases,平常请使用non-virtual。 第二,若你必须使用,尽可能避免在virtual base classes中放置数据。这么一来你就不需担心这些classes身上的初始化和赋值带来的诡异事件了。

下面来看看一个塑膜“人”的C++ interface class:

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

根据学习C++的经历,可知IPerson必须以 pointers 和 references来编写程序,因为抽象类无法实例化对象。可以用factory functions(工厂函数)将“派生自IPerson的具象classes”实例化:

//factory function,根据一个独一无二的数据库ID创建一个Person对象。
//条款18告诉你为什么返回类型不是原始指针。
std::tr1::shared_ptr<IPerson> makePerson(DatabaseID personIdentifier); //这个函数从使用者手里取得一个数据库ID
DatabaseID askUserForDatabaseID(); DatabaseID id(askUserForDatabaseID());
std::tr1::shared_ptr<IPerson> pp(makePerson(id));//创建一个对象支持IPerson接口 ...

makePerson函数要想创建对象,一定有某些派生自IPerson的具象class。

假设它叫CPerson。和所有具象class一样,CPerson必须提供继承自IPerson的pure virtual(纯虚函数)的实现。我们能从无到有写出实现,但更好的是利用既有组件,后者做了大部分必要事情。例如,假设有个既有的数据库相关class,提供CPerson所需的实质:

class PersonInfo{
public:
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const;
virtual const char* theBirthDate() const;
...
private:
virtual const char* valueDelimOpen() const; //详下
virtual const char* valueDelimClose() const;
...
};

PersonInfo被设计用来协助以各种格式打印数据库字段,每个字段值的起始点和结束点以特殊字符串为界。默认的头尾界限是中括号,例如字段值 “Ring-tailed Lemur”将被格式化为:

[Ring-tailed Lemur]

若有特殊需要,两个虚函数 valueDelimOpen()valueDelimClose() 允许derived classes设定它们自己的头尾界限符。PersonInfo成员函数将调用这些虚函数,把适当的界限符添加到它们的返回值上。以Person::theName为例,代码看起来是这样:

const char* PersonInfo::valueDelimOpen() const
{
return "["; //默认起始符
}
const char* PersonInfo::valueDelimClose() const
{
return "]";
}
const char* PersonInfo::theName() const
{
//保留缓冲区给返回值使用,由于缓冲区是static,因此会被自动初始化为“全都是0”
static char value[Max_Formatted_Field_Value_Length];
//写入起始符
std::strcpy(value,valueDelimOpen());
这里的操作是将value内的字符添附到这个对象的name成员变量中
std::strcat(value,valueDelimClose());
return value;
}

先不管theName的老旧设计(特别是它竟使用固定大小的static缓冲区),theName调用valueDelimOpen产生起始符号,然后产生name值,之后调用valueDelimClose。这两个函数都是虚函数,所以theName返回结果不仅取决于PersonInfo,还取决于PersonInfo派生的classes。

假设IPerson的name和birthDate两函数返回未经修饰的值。比如名为”Homer”的IPerson对象调用name会返回”Homer”而不是”[Homer]”。

事实是,PersonInfo刚好有若干函数可帮助CPerson比较容易实现出来。因此它们的关系是 is-implemented-in-terms-of(根据某物实现出),这种关系的实现一般使用复合(条款38)或private继承(条款39)。条款39指出通常复合是受欢迎设计,但若需重新定义virtual函数,需使用private继承。

所以此处使用private继承,但也必须实现IPerson接口,那需得以public继承才能完成。这导致一个合理的应用:将”public继承自某接口”和”private继承自某实现”结合在一起:

class IPerson{                          //这个class指出需实现的接口
public:
virtual ~IPerson();
virtual std::string name() const = 0;
virtual std::string birthDate() const = 0;
};
class DatebaseID() {...} //稍后使用,细节不重要 class PersonInfo{
explicit PersonInfo(DatabaseID pid);
virtual ~PersonInfo();
virtual const char* theName() const;
virtual const char* theBirthDate() const;
virtual const char* valueDelimOpen() const;
virtual const char* valueDelimClose() const;
...
}; class CPerson: public IPerson,private PersonInfo{ //多继承!
public:
explicit CPerson(DatabaseID pid): PersonInfo(pid) { }
std::string name() const override; // 实现
{ return PersonInfo::theName(); }
std::string birthDate() const override // 实现
{ return PersonInfo::theBirthDate(); }
private:
const char* valueDelimOpen() const { return ""; }; //重写
const char* valueDelimClose() const { return ""; }
};

在UML图中这个设计看起来是这样的:

EffectiveC++ 第6章 继承与面向对象设计

这个例子告诉我们,多继承也有它的合理用途。

请记住:

  • 多继承比单继承复杂得多,它可能导致新的歧义,以及对virtual继承的需求
  • virtual继承会增加大小、速度、初始化、赋值复杂度等成本。若virtual base classes不带任何数据,将是最具实用价值的情况
  • 多继承的一个正当用途是涉及”public继承自某接口”和”private继承自某实现”的结合

END