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

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继承强制执行这个解释。看下面的例子:

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

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

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

 1 void eat(const Person& p);     // anyone can eat
 2
 3 void study(const Student& s);            // only students study
 4
 5 Person p;                            // p is a Person
 6
 7
 8
 9 Student s;       // s is a Student
10
11 eat(p);    // fine, p is a Person
12
13 eat(s);    // fine, s is a Student,
14 // and a Student is-a Person
15
16 study(s);        // fine
17
18 study(p);        // error! p isn’t a Student

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

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

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

 1 class Bird {
 2 public:
 3 virtual void fly();            // birds can fly
 4
 5 ...
 6
 7 };
 8 class Penguin: public Bird {    // penguins are birds
 9
10 ...
11
12
13 };

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

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

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

 1 class Bird {
 2 ...                                       // no fly function is declared
 3
 4 };
 5 class FlyingBird: public Bird {
 6 public:
 7 virtual void fly();
 8 ...
 9 };
10 class Penguin: public Bird {
11 ...                                       // no fly function is declared
12
13
14 };

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

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

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

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

1 void error(const std::string& msg); // defined elsewhere
2 class Penguin: public Bird {
3 public:
4 virtual void fly() { error("Attempt to make a penguin fly!"); }
5 ...
6 };
7
8  

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

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

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

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

1 class Bird {
2 ...                                     // no fly function is declared
3
4 };
5 class Penguin: public Bird {
6 ...                                     // no fly function is declared
7
8
9 };

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

1 Penguin p;
2
3 p.fly();                                      // error!
4
5  

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

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

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

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

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

考虑下面的代码:

 1 class Rectangle {
 2 public:
 3   virtual void setHeight(int newHeight);
 4   virtual void setWidth(int newWidth);
 5
 6 virtual int height() const;        // return current values
 7
 8 virtual int width() const;
 9
10 ...
11
12 };
13
14
15
16 void makeBigger(Rectangle& r)   // function to increase r’s area
17
18 {
19
20 int oldHeight = r.height();
21
22
23
24 r.setWidth(r.width() + 10);   // add 10 to r’s width
25
26 assert(r.height() == oldHeight);         // assert that r’s
27
28
29
30 }                                                             // height is unchanged

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

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

 1 class Square: public Rectangle { ... };
 2
 3 Square s;
 4
 5 ...
 6
 7 assert(s.width() == s.height());
 8
 9 // this must be true for all squares
10
11 makeBigger(s);
12
13 // by inheritance, s is-a Rectangle,
14
15
16 // so we can increase its area
17 assert(s.width() == s.height()); // this must still be true
18 // 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类的每件东西必须也能应用于派生类,因为每个派生类对象是一个基类对象。

时间: 2024-10-20 04:21:18

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

读书笔记 effective c++ Item 49 理解new-handler的行为

1. new-handler介绍 当操作符new不能满足内存分配请求的时候,它就会抛出异常.很久之前,它会返回一个null指针,一些旧的编译器仍然会这么做.你仍然会看到这种旧行为,但是我会把关于它的讨论推迟到本条款结束的时候. 1.1 调用set_new_handler来指定全局new-handler 在operator new由于不能满足内存分配要求而抛出异常之前,它会调用一个客户指定的叫做new-handler的错误处理函数.(这也不是完全正确的.Operator new的真正行为更加复杂.

读书笔记 effective c++ Item 44 将与模板参数无关的代码抽离出来

1. 使用模板可能导致代码膨胀 使用模板是节省时间和避免代码重用的很好的方法.你不需要手动输入20个相同的类名,每个类有15个成员函数,相反,你只需要输入一个类模板,然后让编译器来为你实例化20个特定的类和300个你需要的函数.(只有在被使用的情况下类模版的成员函数才会被隐式的实例化,所以只有在300个函数被实际用到的情况下才会生成300个成员函数.)函数模板同样吸引人.你不用手动实现许多函数,你只需要实现一个函数模板,然后让编译器来做余下的事情. 然而在有些时候,如果你不小心,使用模板会导致代

读书笔记 effective c++ Item 45 使用成员函数模板来接受“所有兼容类型”

智能指针的行为像是指针,但是没有提供加的功能.例如,Item 13中解释了如何使用标准auto_ptr和tr1::shared_ptr指针在正确的时间自动删除堆上的资源.STL容器中的迭代器基本上都是智能指针:当然,你不能通过使用“++”来将链表中的指向一个节点的内建指针移到下一个节点上去,但是list::iterator可以这么做. 1. 问题分析——如何实现智能指针的隐式转换 真正的指针能够做好的一件事情是支持隐式转换.派生类指针可以隐式转换为基类指针,指向非const的指针可以隐式转换成为

读书笔记 effective c++ Item 38 通过组合(composition)为 “has-a”或者“is-implemented-in-terms-of”建模

1. 什么是组合(composition)? 组合(composition)是一种类型之间的关系,这种关系当一种类型的对象包含另外一种类型的对象时就会产生.举个例子: 1 class Address { ... }; // where someone lives 2 3 class PhoneNumber { ... }; 4 class Person { 5 public: 6 ... 7 private: 8 std::string name; // composed object 9 10

读书笔记 effective c++ Item 43 了解如何访问模板化基类中的名字

1. 问题的引入——派生类不会发现模板基类中的名字 假设我们需要写一个应用,使用它可以为不同的公司发送消息.消息可以以加密或者明文(未加密)的方式被发送.如果在编译阶段我们有足够的信息来确定哪个信息会被发送到哪个公司,我们可以使用基于模板的解决方案: 1 class CompanyA { 2 public: 3 ... 4 void sendCleartext(const std::string& msg); 5 void sendEncrypted(const std::string&

读书笔记 effective c++ Item 47 使用traits class表示类型信息

STL主要由为容器,迭代器和算法创建的模板组成,但是也有一些功能模板.其中之一叫做advance.Advance将一个指定的迭代器移动指定的距离: 1 template<typename IterT, typename DistT> // move iter d units 2 void advance(IterT& iter, DistT d); // forward; if d < 0, 3 // move iter backward 从概念上来说,advance仅仅做了it

读书笔记 effective c++ Item 35 考虑虚函数的替代者

1. 突破思维——不要将思维限定在面向对象方法上 你正在制作一个视频游戏,你正在为游戏中的人物设计一个类继承体系.你的游戏处在农耕时代,人类很容易受伤或者说健康度降低.因此你决定为其提供一个成员函数,healthValue,返回一个整型值来表明一个人物的健康度.因为不同的人物会用不同的方式来计算健康度,将healthValue声明为虚函数看上去是一个比较明显的设计方式: 1 class GameCharacter { 2 public: 3 4 virtual int healthValue()

读书笔记 effective c++ Item 53 关注编译器发出的警告

许多程序员常常忽略编译器发出的警告.毕竟,如果问题很严重,它才将会变成一个error,不是么?相对来说,这个想法可能在其它语言是无害的,但是在C++中,我敢打赌编译器的实现者对于对接下来会发生什么比你有更好的理解.例如,下面的错误是每个人都时不时会犯的: 1 class B { 2 public: 3 virtual void f() const; 4 }; 5 class D: public B { 6 public: 7 virtual void f(); 8 }; 1. 你对警告的理解可能

读书笔记 effective c++ Item 51 实现new和delete的时候要遵守约定

Item 50中解释了在什么情况下你可能想实现自己版本的operator new和operator delete,但是没有解释当你实现的时候需要遵守的约定.遵守这些规则并不是很困难,但是它们其中有一些并不直观,所以知道这些规则是什么很重要. 1. 定义operator new的约定 1.1 约定列举 我们以operator new开始.实现一个一致的operator new需要有正确的返回值,在没有足够内存的时候调用new-handling函数(见Item 49),并且做好准备处理没有内存可分配