洛译小筑

别来无恙,我的老友…
随笔 - 45, 文章 - 0, 评论 - 172, 引用 - 0
数据加载中……

[ECPP读书笔记 条目32] 确保公共继承以“A是一个B”形式进行

威廉 德门特在他的著作《几人留守几人眠》中讲述了这样一个故事:他在课堂上尝试让他的学生记住课程中最重要的那一部分。他和他的学生讲,据说普通的英国学生仅仅能记得黑斯廷斯战役发生于1066年。德门特强调,如果有一个学生只记住了一点点,他(她)记住的便是1066这个年号。德门特继续讲,对于他的课堂上的学生,只有几条核心的信息,其中还包括一条有趣的结论——“安眠药最终会导致失眠”。他请求他的学生们一定要记住这些核心信息,即使把课堂中讨论的所有其他的内容都忘光也可以,整个学期他都为学生反复重复这些核心信息。

在学期末,期末考试的最后一道题是“请写下这一学期中让你铭记一生的一件东西。”当德门特阅卷的时候,差点儿没昏过去。几乎所有的学生不约而同地写下了“1066”。

因此,我“诚惶诚恐”地向各位讲述C++面向对象编程中最为重要(没有之一)的一条原则:公共继承意味着“A是一个B”关系。这条原则一定要深深的印在脑子里。

如果你编写了B类(Base,基类),并编写了由其派生出的D类(Derived,派生类),那么你就告诉了C++编译器(以及代码的读者),每一个D类型的对象同时也是B类型的,但是反过来不成立。你要表达的是:B表示比D更加一般化的内容,而D则表示比B更加具体化的内容。另外我们还强调如果一个B类型的对象可以在某处使用时,那么D类型的对象一定可以在此使用。这是因为D类型的每个对象一定是B类型的。反之,如果某一刻你需要一个D类型的对象,那么一个B类型的对象则不一定能满足要求:每个D都是一个B,但反之不然。

C++严格按上述方式解释公共继承。请参见下面的示例:

class Person {...};

class Student: public Person {...};

我们从生活的经验中可以得知:每个学生都是一个人,但是并不是每个人都是学生。上面的代码精确的体现了这一层次结构。我们期望“人”的每一条属性对“学生”都适用(比如人有出生日期,学生也有)。但是对学生能成立的属性对于一般的人来说并不一定成立(比如一个学生被某所大学录取了,但不是每个人都会去上大学)。人的概念比学生的概念更加宽泛,而学生是一类特殊的人。

在C++领域中,一切需要使用Person类型参数(或指向Person的指针或引用)的函数同样能够接受Student对象(或指向Student的指针或引用):

void eat(const Person& p);        // 人人都会吃饭

 

void study(const Student& s);     // 只有学生会学习

 

Person p;                          // p是一个人

Student s;                         // s是一个学生

 

eat(p);                            // 正确,p是一个人

 

eat(s);                            // 正确, s是一个学生,

                                   // 同时一个学生是一个人

 

study(s);                          // 正确

 

study(p);                          // 错误!p不一定是学生

这一点仅仅在公共继承的情况下成立。只有在Student是由Person公共派生而来时,C++才会按刚才描述的情景运行。私有继承将引入一个全新的议题(我们在条目39中讨论)。受保护的继承我至今也没有弄明白,暂且不谈。

公共继承和“A是一个B”的等价性听上去很简单,但是有时你的直觉会误导你。比如说,“企鹅是鸟”这是千真万确的,同时,“鸟会飞”也是不争的事实。如果我们在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 {

 

  ...                              // 不声明任何飞行函数

 

};

这一层次结构比原先设计的更加忠实于我们所了解的世界。

到目前为止,上文的飞禽问题尚未彻底明了,因为在一些软件系统中,区分鸟类是否可以飞行这项工作是没有意义的,如果你的程序主要是关于鸟类的喙和翅膀,而与飞行没有什么关系,那么原先的2个类的层次结构就可以满足要求了。这里也很清晰的反映出了这一哲理:凡事并不存在一劳永逸的解决方案。对软件系统而言,最好的设计一定会考虑到这个系统是用来做什么的,无论是现在还是未来,如果你的程序对飞行的问题一无所知,并且也不准备去了解,那么忽略飞行特性的设计方案很可能就是完美的。事实上,这样做要比将两者区分开的设计方案更好些,因为你正在模拟的世界中很可能不会存在这一机制。

对于解决上文中的“白马非马”问题,还存在另外一个思考方法。那就是为企鹅重新定义fly函数,从而让其产生一个运行时错误:

void error(const std::string& msg);    // 定义的内容在其他地方

 

class Penguin: public Bird {

public:

  virtual void fly() { error("尝试让一只企鹅飞行!”);}

 

  ...

 

};

一定要认识到:这样做不一定能达到预期效果。因为这并不是说“企鹅不会飞”,而是说“企鹅会飞,但是在它尝试飞行时出错了”。这一点很重要。

如何找出两者的区别呢?我们从捕获错误的时机入手:“企鹅不会飞”的指令可以由编译器做出保证,但是对于“企鹅在尝试飞行时出错”这一规则的违背只能够在运行时捕获。

为了表达这一约束,“企鹅不能飞行——句号”,你要确认企鹅对象一定没有飞行函数定义:

class Bird 

  ...                              // 不声明任何飞行函数

};

 

class Penguin: public Bird {

  ...                              // 不声明任何飞行函数

};

现在,如果你尝试让一个企鹅飞行,那么编译器将对你的侵犯行为做出抗议:

Penguin p;

p.fly();                           // 错误!

如果你适应了“产生运行时错误”的方法,上文代码的行为则与你所了解的大相径庭。使用上文中的方法,编译器不会对p.fly的调用做出任何反应。条目18中解释了好的接口设计能够防止非法代码得到编译。因此你最好使用在编译室拒绝企鹅尝试飞行的设计方案,而不是仅仅在运行时捕获错误。

可能你承认你的鸟类学知识并不丰富,但对于初级几何你还是有信心的吧,让我们拿长方形和正方形再举一个例子,这没有什么复杂的吧?

好,请回答一个简单的问题:Square(正方形)类是否应该公共继承自Rectangle(长方形)类?


你会说:“当然可以了!正方形就是一个长方形,这地球人都知道。但反过来就不成立了。”这一点至少在学校里是正确的,但我们都已经不是小学生了,请考虑下面的代码:

class Rectangle {

public:

  virtual void setHeight(int newHeight);

  virtual void setWidth(int newWidth);

 

  virtual int height() const;      // 返回当前值

  virtual int width() const;

 

  ...

};

 

void makeBigger(Rectangle& r)     // 增加r面积的函数

{

  int oldHeight = r.height();

 

  r.setWidth(r.width() + 10);      // r的宽增加10

 

  assert(r.height() == oldHeight); // 断言r的高不变

}

显然地,这里的判断永远不会失败,makeBigger仅仅改变了r的宽,它的高始终没有改变。

现在请观察下面的代码,其中使用了公共继承,从而使得正方形得到与长方形一致的待遇:

class Square: public Rectangle {...};

 

Square s;

...

 

assert(s.width() == s.height());  // 这对所有的正方形都成立

 

makeBigger(s);                     // 根据继承关系,s是一个长方形

                                   // 因此我们可以增加它的面积

 

assert(s.width() == s.height());  // 对所有的正方形也应成立

第二次判断同样不应该出错,这也是十分明显的。因为正方形的定义要求长宽值永远相等。

但是现在我们又遇到了一个问题,我们如何协调下面的断言呢?

在调用makeBigger之前,s的高与宽相等;

makeBigger内部,s的宽值该变了,但是高没有;

makerBigger返回后,s的高与宽又相等了。(请注意:s是通过引用传入makeBigger中的,因此makeBigger改变的是s本身,而不是s的副本。)

欢迎来到公共继承的美妙世界。在这里,你在其他领域(包括数学)所积累的经验也许不会按部就班地奏效。这种情况下最基本的问题就是:一些对长方形可用的属性(它的长、宽可以分别修改),对于正方形而言并不适用(它的长、宽必须保持一致)。但是,公共继承要求对基类成立的一切对于派生类同样应该能够成立——一切的一切!这种情况下,长方形和正方形(以及条目38中集合、线性表)的实例都会遇到问题。因此,使用公共继承来构建它们之间的关系显然是错误的。编译器会允许你这样做,但是就像我们刚刚所看到的一样,我们无法确保代码是否能够按要求运行。这件事每一位程序员一定深有体会(往往比其他行业的人要深得多),因为许多情况下代码能够通过编译并不意味着它能够正常运行。

在我们投入面向对象程序设计的怀抱时,多年积累的编程经验难道成为了我们的绊脚石吗?这一点你无需顾虑。旧有的知识依然是宝贵的,只是既然你已经把继承的概念添加进你大脑中的设计方案库中,你就应该以全新的眼光来开拓自己的感官世界,从而使你在面对包含继承的程序时不会迷失方向。假如有人向你展示了一个几页长的程序,你也可以从企鹅继承自鸟类、正方形继承自长方形这些示例所包含的理念中,找出同样有趣的东西。这有可能是完成工作的正确途径,只是这个可能性并不大。

类间的关系并不仅限于“A是一个B”关系。另外还存在两个内部类关系,它们是:“A拥有一个B”、“A是以B的形式实现的”。这些关系将在条目38和条目39中讲解。由于人们往往会将上面两种关系其中之一错误地构造成“A是一个B”,因此随之带来的C++设计错误比比皆是,所以你应该确保对于这些关系之间的区别有着充分的理解,这样你才能在C++中分别对这些关系做出最优秀的构造。

时刻牢记

公共继承意味着A是一个B的关系。对于基类成立的一切都应该适用于派生类,因为派生类的对象就一个基类对象。

posted on 2008-03-17 22:55 ★ROY★ 阅读(2036) 评论(2)  编辑 收藏 引用 所属分类: Effective C++

评论

# re: 【读书笔记】[Effective C++第3版][第32条] 确保公共继承以“A是一个B”形式进行  回复  更多评论   

终于更新了,好久没更新哦。呵呵
2008-03-18 18:32 | 酷勤网

# re: 【读书笔记】[Effective C++第3版][第32条] 确保公共继承以“A是一个B”形式进行  回复  更多评论   


承蒙您关照
期待您批评
2008-03-18 22:03 | ★ROY★

只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   知识库   博问   管理