您的位置:首页 > 编程语言 > C语言/C++

Effective C++ 第二版 35)公有继承 36)接口继承和实现继承

2013-11-06 10:05 267 查看
继承和面向对象设计

设计和实现类的层次结构与C语言有根本不同;

C++提供了多种面向对象构造部件: 公有, 保护, 私有基类; 虚拟和非虚拟基类; 虚拟和非虚拟成员函数; 这些部件互相联系, 而且和C++其他部分互相作用;

C++中很多不同部件好像在做相同的事:

e.g. 设计一组具有共同特性的类, 是该使用继承使所有类都派生于一个共同基类, 还是使用模板使得它们都从共同的代码框架中产生? 类A的实现要用到类B, 是让A拥有一个B的数据成员, 还是让A私有继承于B? 设计一个标准库中没有提供的, 类型安全的同族容器类, 使用模板, 还是为某个"自身用普通void*指针来实现"的类建立类型安全的接口?

本章的条款集中解释: C++不同部件的真正含义; e.g. 公有继承意味着"是一个Is-a"; 虚函数的含义是"接口必须被继承"; 非虚函数的含义是"接口和实现都要被继承";

条款44总结了C++面向对象构造部件间的对应关系和含义, 可以作为简明参考;

条款35 使公有继承体现"是一个"的含义

C++面向对象编程规则之一: 公有继承意味着"是一个 Is-a";

当写下类D/Derived从类B/Base公有继承时, 意味着类型D的每一个对象也是类型B的一个对象; 反之不成立; B表示比D更广泛的概念, D表示B更特定的概念; 任何可以使用类型B对象的地方, 类型D对象也可以使用; 反之, 如果需要一个类型D的对象, 类型B的对象不适用; 每个D 是一个Is-a B, 反之不成立;

>每个学生是人, 但并非每个人都是学生; 任何人都有年龄, 但不一定有当前班级; 人是广泛概念, 学生是特定类型的人;

C++中, 任何一个参数为Person类型的参数(或指针/引用)可以实际取一个Student对象(或指针/引用):
只是公有继承才有这样的使用; 私有继承不一样(条款42);

Note Student是一个Is-a Person不代表Student的数组是一个Is-a Person数组;

公有继承和'是一个'的等价关系看似简单, 但在实际应用中可能并不那么直观; e.g. 企鹅是鸟类, 鸟会飞:

按直觉写出的代码造成了困惑, 原因是语言(中文表达)的不严密, 鸟会飞并不是指所有的鸟都会飞;

>这样的层次就更接近现实逻辑;

在有的软件系统中, 把企鹅继承于鸟是完全合适的; 例如程序只和鸟的嘴, 翅膀有关系而不涉及到飞的功能, 那最初的设计就合适; 这反映了一个简单的事实: 没有任何一种设计可以理想到适用于任何软件; 好的设计是和软件系统现在和将来要完成的功能密不可分的(M32); 如果程序不涉及飞, 以后也不会, 那让Penguin派生自Bird就非常合理; 甚至比区别飞的设计还好, 因为在设计层次中增加多余的类是糟糕的设计, 就像在类之间制定了错误的继承关系;

另一个处理方法:
>产生一个运行错误; 解释型语言如Smalltalk喜欢采用这类方法;

区别: "企鹅不会飞"是编译器给出的提示; "企鹅飞是一种错误"只能在运行时检测到;

为了表示"企鹅不会飞", 就不要再Penguin中定义fly():

>如果企鹅想飞, 编译器就会报错: Penguin::fly() -- Error;

使用Smalltalk的方式, 编译器完全没有反应; C++的处理方法和Smalltalk不同, 而且, 在编译时检测错误比在运行时检测错误有某些技术上的优点;(条款46)

e.g. 正方形Square, 矩形Rectangle;

>assert永远不会失败, 高度没有被修改;

>这里的断言也不会失败;

问题: 调用makeBigger()前, s的宽和高相等; makeBigger()内, s的宽度改变, 高度未变; makeBigger()返回后, s的高度和宽度又相等[直觉的理论上来说];(s是引用传递)

对矩形适用的规则(宽度和高度没联系)不适用于正方形(宽度和高度必须相等); 但公有继承表示: 对基类对象适用的任何东西也适用于派生类对象; 在矩形和正方形的例子(以及条款40中set的例子)中, 这个原则不适用; 编译器不会报错, 但程序的逻辑上有冲突; [觉得没啥冲突,
完美体现了继承, 只是正方形的限制大于矩形--长宽必须相等, 而不是矩形的规则不适用正方形, 因为矩形没有限制长宽必须不等, 而是长宽之间关系没有限制条件]

'是一个Is-a' 的关系不是类之间的唯一关系; 另两种常见关系是'有一个Has-a' 和'用...来实现'; 条款40/42; 当这两种关系被错误地表示成'是一个Is-a'的情况会导致错误的设计, 一定要确保理解这些关系的区别;

条款36 区分接口继承和实现继承

公有继承由两个部分组成: 函数接口的继承和函数实现的继承;

作为类的设计者, 有时希望派生类只继承成员函数的接口(声明), 有时候希望派生类同时继承函数的接口和实现, 但允许派生类改写实现; 有时则希望同时继承接口和实现, 不允许派生类改写;

e.g. 图形程序中的几何形状;

>纯虚函数draw使得Shape成为抽象类, 用户不能创建Shape类的实例, 只能创建其派生类实例;

从Shape公有继承的类都受到Shape的影响: 成员函数的接口被继承; 公有继承的含义是"是一个Is-a", 所以对基类成立的事实必须对派生类也成立; 如果一个函数适用于某个类, 也必须使用于它的子类;

>Shape声明了三个函数; draw绘制对象; error被其他成员函数调用, 报告出错信息; objectID返回当前对象唯一整数标识符(条款17); draw是纯虚函数; error是简单虚函数; objectID是非虚函数;

虚函数draw必须在派生类中重新声明, 在抽象类中往往没有定义[可以有]: 定义纯虚函数的目的在于, 使派生类仅仅继承函数的接口; Shape类无法为Shape::draw提供一个合理的缺省实现; 例如, 绘制椭圆的算法和绘制矩形的算法不同;

Note 可以为纯虚函数提供定义; C++编译器不会报错, 但调用它的唯一方式是通过类名完整地指明调用:

>一般这么做没什么作用...可以被应用为一种机制: 为简单的(非纯)虚函数提供"更安全"的缺省实现;

Note 声明一个除了纯虚函数外什么[数据]也不包含的类叫协议类Protocol class, 为派生类提供函数接口, 完全没有实现;

简单虚函数[非纯虚]一般提供了实现, 派生类可以选择改写或不该写; 声明简单函数使派生类继承函数的接口和缺省实现;

>Shape::error接口: 每个类必须提供一个出错时可以被调用的函数, 每个类可以按合适的方式处理错误; 也可以借助Shape类提供的缺省出错处理函数;

为简单虚函数同时提供函数声明和缺省实现是危险的: e.g. XYZ航空公司有两种飞机A型, B型, 飞行方式完全一样;

>Airplane::fly是virtual的, 表明所有飞机必须支持fly, 不同型号的飞机原则上对fly有不同实现; 为了避免ModelA和ModelB写重复代码, 缺省的行为是继承自Airplane::fly;

典型的面向对象设计, 两个类项有共同特性fly, 所以fly被转移到基类, 让子类继承这个特性; 这个设计使得共性清晰, 避免代码重复, 易于维护;

假设XYZ公司引进新机型C, C型的飞行方式和A, B不一样, 程序员增加了一个类, 但是忘了重新定义fly:

然后试图让C对象像A或B那样飞行:
问题在于ModelC可以不用明确声明, 就可以继承这一行为;

Solution: 为子类提供缺省行为, 同时只是在子类需要的时候才给他们; 切断虚函数的接口和缺省实现之间的联系:

>Airplane::fly变成了纯虚函数, 提供了飞行的接口, 缺省实现作为独立函数defaultFly的形式存在;
>ModelA和ModelB想执行缺省行为的话, 只需要简单地在他们的fly函数体中对defaultFly进行一个内联调用(条款33);
>对于ModelC类, 因为纯虚函数的特性, 被强迫实现自己版本的fly, 就不会出现无意间继承不正确的fly行为的可能;

这个方法可能还有"拷贝粘贴"出错的问题[程序员的粗心], 但比之前的设计安全可靠;

>Airplane::defaultFly声明为protected, 因为它是实现细节, 使用Airplane的用户只关心飞机可以飞, 不关心是如何实现的; Airplane::defaultFly是非虚函数, 没有子类会重新定义这个函数; 如果声明为virtual, 又会出现有些子类忘记重新定义它的问题;

一些声音反对将接口和缺省实现作为单独函数分开, 认为这样至少会污染类的名字空间, 有太多相近的函数名称在扩散; 然而他们还是赞同接口和缺省实现应该分离;

Solution: 纯虚函数必须在子类中重新声明, 但它还是可以在基类中有自己的实现;

>区别: 纯虚函数Airplane::fly的函数体取代了独立的函数Airplane:;defaultFly;

本质上说, fly被分成了两个基本部分: 声明说明了他的接口(派生类必须实现), 定义说明了他的缺省行为(派生类可以使用, 需要明确地请求); 但是将fly和defaultFly合并后, 就不能在为两个函数设定不同保护级别了: protected的defaultFly实现部分变成了public;

最后, 讨论下Shape的非虚函数objectID; 当一个成员函数为非虚函数时, 他在派生类中的行为就应该一致; 非虚函数表明了特殊性--不变性, 不管派生类有多么特殊, 他的行为不会改变;

Note 声明非虚函数的目的在于, 使派生类继承函数的接口和强制性实现;

Shape::onejctID说明每个Shape对象有一个函数用来产生对象的标识符, 并且对象标识符的产生方式总是一样的; 派生类无法改变, 非虚函数不能在派生类重新定义(条款37);

理解纯虚, 简单虚, 非虚函数在声明上的区别, 可以明确指定你想让派生类继承的方式: 接口, 接口和缺省实现还是接口和强制实现; 声明成员函数时慎重选择, 避免错误;

常犯的错误:

1) 所有的函数都声明为非虚; 这样派生类没有特殊化的余地, 非虚析构也会导致问题(条款14); 如果是设计的类不准备作为基类, 这样的情况是合理的(M34),
可以专门声明一组非虚成员函数;

如果担心虚函数开销, 参照80-20定律: 在一个典型的程序中, 80%的运行时间都花在执行20%的代码上; 平均起来看, 80%的函数调用可以用虚函数, 而且他们不会对程序整体性能带来影响; 担心虚函数开销之前, 不如把注意力集中在会真正带来影响的20%的代码上[优化];

2) 所有的函数都声明为虚函数; 有时是没错, 比如协议类Protocol class; 但是对一些函数不能再派生类中重定义的情况, 要确定声明为非虚函数;

e.g. 基类B; 派生类D; 成员函数mf:

>有时必须将mf声明为非虚函数才能保证一切都按照预期的方式工作(不变性);
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: