读书笔记 effective c++ Item 32 确保public继承建立“is-a”模型

时间:2022-06-11 16:59:52

1. 何为public继承的”is-a”关系

在C++面向对象准则中最重要的准则是:public继承意味着“is-a”。记住这个准则。

如果你实现一个类D(derived)public继承自类B(base),你在告诉c++编译器(也在告诉代码阅读者),每个类型D的对象也是一个类型B的对象,反过来说是不对的。你正在诉说B比D表示了一个更为一般的概念,而D比B表现了一个更为特殊的概念。你在主张:任何可以使用类型B的地方,也能使用类型D,因为每个类型D的对象都是类型B的对象;反过来却不对,也就是可以使用类型D的地方却不可以使用类型B:D是B,B不是D。

C++ 会为public继承强制执行这个解释。看下面的例子:

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

从日常生活中我们知道每个学生都是一个人,但并不是每个人都是学生。这正是上面的继承体系所主张的。我们期望对人来说为真的任何事情——例如一个人有出生年月——对学生来说也是真的。我们不期望对学生来说为真的任何事情——例如在一个特定的学校登记入学——对普通大众来说也是真的。人的概念比学生要更加一般化;而学生是人的一个特定类型。

在C++的领域内,需要Person类型(或者指向Person的指针或者指向Person的引用)参数的任何函数也同样可以使用Student参数(或者指针或引用):

 void eat(const Person& p);     // anyone can eat

 void study(const Student& s);            // only students study

 Person p;                            // p is a Person

 Student s;       // s is a Student

 eat(p);    // fine, p is a Person

 eat(s);    // fine, s is a Student,
// and a Student is-a Person study(s); // fine study(p); // error! p isn’t a Student

这仅对public继承来说是有效的。C++仅仅在Student公共继承自Person的时候,其行为表现才会如上面所描述的。Private继承的意义就完全变了(Item 39),protected继承是至今都让我感到困惑的东西。

2. Public继承可能误导你——例子一,企鹅不会飞

Public继承和”is-a”是等价的听起来简单,但有时候你的直觉会误导你。举个例子,企鹅是鸟这是个事实,鸟能飞也是事实。如果尝试用C++表示,将会产生下面的代码:

 class Bird {
public:
virtual void fly(); // birds can fly ... };
class Penguin: public Bird { // penguins are birds ... };

我们突然陷入了麻烦,因为这个继承体系表明了企鹅会飞,我们知道这不是真的。发生了什么?

2.1 处理上述问题的方法一——更加精确的建模,不定义fly

在这种情况下,我们是一种不精确语言——英语——的受害者。当我们说鸟能飞,我们并没有说所有的鸟都能飞,通常情况下只有有这个能力的才行。如果更加精确一些,我们能够识别出有一些不能飞的鸟的种类,就可以使用如下的继承体系,它更好的模拟了现实:

 class Bird {
... // no fly function is declared };
class FlyingBird: public Bird {
public:
virtual void fly();
...
};
class Penguin: public Bird {
... // no fly function is declared };

这个继承体系比原来的设计更加忠于现实。

关于这些家禽的事情还没有完,因为对于一些软件系统来说,没有必要对能飞和不能飞的鸟进行区分。如果你的应用更加关注鸟嘴和鸟的翅膀而对会不会飞漠不关心,最开始的两个类的继承体系就足够了。这反应了一个简单的事实:没有一个理想的设计适用于所有软件最好的设计取决于需要系统去做什么,无论是现在还是将来。如果你的应用没有与飞相关的知识,并且永远也不会有,对能不能飞不做区分或许是一个完美并且有效的设计决策。事实上,能够区分它们的设计或许更可取,因为你尝试为其建模的这种区分有一天可能会从世界上消失。

2.2 处理上述问题的方法二——产生运行时错误

有另外一个学派来处理我上面所描述的“所有的鸟能飞,企鹅是鸟,企鹅不能飞”问题。就是重新为企鹅定义fly函数,但是让其产生运行时错误:

 void error(const std::string& msg); // defined elsewhere
class Penguin: public Bird {
public:
virtual void fly() { error("Attempt to make a penguin fly!"); }
...
};

上面所说的可能会和你想的不一样,能够辨别它们很重要。上面的代码并没有说,“企鹅不能飞。”而是说,“企鹅能飞,但是它们如果尝试这么做会是一个错误”。

2.3 区分二者的不同——编译期错误和运行时错误

你如何才能说出它们的不同?从错误被检测出来的时间点看,“企鹅不能飞“这个禁令能够被编译器强制执行,但是如果违反“企鹅尝试飞行是一个错误”这个规则只能够在运行时能够被检测出来。

为了表示“企鹅不能飞”这个限制,你要确保对Penguin对象来说没有这样的函数被定义:

 class Bird {
... // no fly function is declared };
class Penguin: public Bird {
... // no fly function is declared };

如果你尝试让企鹅飞起来,编译器会谴责你的行为:

 Penguin p;

 p.fly();                                      // error!

这同产生运行时错误的方法有着很大的不同。如果你使用运行时报错的方法,编译器对p.fly的调用不会说一句话。Item 18解释了好的接口应该在编译期就能够阻止无效代码,所以比起只能在运行时才能侦测出来错误的设计,你应该更加喜欢在编译期就能拒绝企鹅飞翔的设计。

3. Public继承可能误导你——例子二,矩形和正方形

可能你会做出让步是因为你对鸟类学知识的匮乏,但是你能够依靠你对初步几何的精通,对吧?矩形和正方形会有多复杂呢?

现在回答这个简单的问题:正方形类应该public继承自长方形类么?

你会说“当然应该!每个人都知道正方形是一个矩形,反之却不成立。”再真不过了,至少是在学校里面。但是我认为我们已经不在学校里面了。

考虑下面的代码:

 class Rectangle {
public:
virtual void setHeight(int newHeight);
virtual void setWidth(int newWidth); virtual int height() const; // return current values virtual int width() const; ... }; void makeBigger(Rectangle& r) // function to increase r’s area { int oldHeight = r.height(); r.setWidth(r.width() + ); // add 10 to r’s width assert(r.height() == oldHeight); // assert that r’s } // height is unchanged

很清楚,断言永远不会出错,makeBigger只会修改r的宽度。高度永远不会被修改。

现在考虑下面的代码,使用public继承,可以使正方形被当作矩形处理:

 class Square: public Rectangle { ... };

 Square s;

 ...

 assert(s.width() == s.height());

 // this must be true for all squares

 makeBigger(s);

 // by inheritance, s is-a Rectangle,

 // so we can increase its area
assert(s.width() == s.height()); // this must still be true
// for all squares

很清楚的是第二个断言永远不能失败。根据定义,一个正方形的宽度和高度应该一样。

但是现在我们有一个问题。我们怎么才能使下面的断言一致呢?

  • 在调用makeBigger之前,s的高度和宽度是一样的;
  • 在makeBigger里面,s的宽度被改变了,但是高度却没有;
  • makeBigger返回之后,s的高度和宽度仍然相同。(注意s被按引用传递给makeBigger,所以makeBigger修改了s本身,而不是s的拷贝)

欢迎来到public继承的精彩世界,你在其它领域学习而来的直觉(包括数学),使用起来可能和你想要的不一样。上面例子的基本的难点在于适用于矩形的东西(宽度独立于高度被修改)却不适用于正方形(长宽必须相同)。但是public继承主张适用于基类对象的任何东西同样适用于派生类对象。对于长方形和正方形的情况(还有Item38中涉及到的sets和lists的例子),这个主张不再适用,所以使用public继承来为其建模是不正确的。编译器可能会让你这么做,但是正如我们刚刚看到的,我们不能够确保代码的行为是正确的。这也是每个程序员必须要学到的:编码编译通过了不代表它能工作。

4. 使用public继承要有新的洞察力

这些年里使用面向对象设计的时候软件上的直觉会让你失败,不要烦躁。这些知识仍然有价值,现在你的设计兵工厂中又添加了可供替换的继承,你必须用新的洞察力来扩大你的直觉,指导你合适的使用继承。当一些人向你展示长达几页的函数时,你会想起企鹅继承自鸟类或者正方形继承自长方形这些让你感觉有趣的事情。它可能是处理事情的正确方法,只是不是特别像。

5. 其它两种类关系

“is-a”关系不是存在类之间的仅有的关系。另外两个普通的类之间的关系是“has-a”和“is-implemented-in-terms-of”。这些关系在Item38和Item39中被介绍。C++设计出现错误并非不常见,因为其他重要的类关系有可能不正确的被建模为”is-a”,所以你应该确保能明白这些关系之间的区别,并且知道C++中如何最好的塑造它们。

6. 总结

Public继承意味着“is-a”.应用于base类的每件东西必须也能应用于派生类,因为每个派生类对象是一个基类对象。