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

56 篇文章 0 订阅
本文探讨了C++中的公有继承如何体现“是一个”关系,强调了公有继承带来的类型转换能力和“是一个”的逻辑。同时,区分了接口继承和实现继承,解释了纯虚函数、简单虚函数和非虚函数在接口和实现上的不同作用,以帮助开发者更好地设计类层次结构。
摘要由CSDN通过智能技术生成

继承和面向对象设计

设计和实现类的层次结构与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, 反之不成立;

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

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

C++中, 任何一个参数为Person类型的参数(或指针/引用)可以实际取一个Student对象(或指针/引用):

1
2
3
4
5
6
7
8
void  dance( const  Person& p);  // 任何人可以跳舞
void  study( const  Student& s);  // 只有学生才学习
Person p;  // p 是一个人
Student s;  // s 是一个学生
dance(p);  // 正确,p 是一个人
dance(s);  // 正确,s 是一个学生,// 一个学生"是一个"人
study(s);  // 正确
study(p);  // 错误! p 不是学生

只是公有继承才有这样的使用; 私有继承不一样(条款42);

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

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

1
2
3
4
5
6
7
8
class  Bird {
public :
     virtual  void  fly();  // 鸟会飞
...
};
class  Penguin: public  Bird {  // 企鹅是鸟
...
};

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class  Bird {
...  // 没有声明fly 函数
};
class  FlyingBird:  public  Bird {
public :
     virtual  void  fly();
...
};
class  NonFlyingBird:  public  Bird {
...  // 没有声明fly 函数
};
class  Penguin:  public  NonFlyingBird {
...  // 没有声明fly 函数
};

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

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


另一个处理方法:

1
2
3
4
5
6
void  error( const  string& msg);  // 在别处定义
class  Penguin:  public  Bird {
public :
     virtual  void  fly() { error( "Penguins can't fly!" ); }
...
};

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

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

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

1
2
3
4
5
6
7
8
9
class  Bird {
...  // 没有声明fly 函数
};
class  NonFlyingBird:  public  Bird {
...  // 没有声明fly 函数
};
class  Penguin:  public  NonFlyingBird {
...  // 没有声明fly 函数
};

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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 的高度未变
}

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

1
2
3
4
5
6
class  Square:  public  Rectangle { ... };
Square s;
...
assert (s.width() == s.height());  // 这对所有正方形都成立
makeBigger(s);  // 通过继承,s "是一个" 矩形// 所以可以增加它的面积
assert (s.width() == s.height());  // 这还是对所有正方形成立

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

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

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

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


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

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

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

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

1
2
3
4
5
6
7
8
9
class  Shape {
public :
     virtual  void  draw()  const  = 0;
     virtual  void  error( const  string& msg);
     int  objectID()  const ;
...
};
class  Rectangle:  public  Shape { ... };
class  Ellipse:  public  Shape { ... };

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

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

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

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

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

1
2
3
4
5
6
7
Shape *ps =  new  Shape;  // 错误! Shape 是抽象的
Shape *ps1 =  new  Rectangle;  // 正确
ps1->draw();  // 调用 Rectangle::draw
Shape *ps2 =  new  Ellipse;  // 正确
ps2->draw();  // 调用 Ellipse::draw
ps1->Shape::draw();  // 调用Shape::draw
ps2->Shape::draw();  // 调用Shape::draw

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

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

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

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


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

1
2
3
4
5
6
7
8
9
10
11
12
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 { ... };

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

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

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

1
2
3
class  ModelC:  public  Airplane {
...  // 没有声明fly 函数
};

然后试图让C对象像A或B那样飞行:

1
2
3
4
Airport JFK(...);  // JFK 是纽约市的一个机场
Airplane *pa =  new  ModelC;
...
pa->fly(JFK);  // 调用 Airplane::fly!

问题在于ModelC可以不用明确声明, 就可以继承这一行为; 

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

1
2
3
4
5
6
7
8
9
10
11
class  Airplane {
public :
     virtual  void  fly( const  Airport& destination) = 0;
...
protected :
     void  defaultFly( const  Airport& destination);
};
void  Airplane::defaultFly( const  Airport& destination)
{
//飞机飞往某一目的地的缺省代码
}

>Airplane::fly变成了纯虚函数, 提供了飞行的接口, 缺省实现作为独立函数defaultFly的形式存在; 

1
2
3
4
5
6
7
8
9
10
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); }
...
};

>ModelA和ModelB想执行缺省行为的话, 只需要简单地在他们的fly函数体中对defaultFly进行一个内联调用(条款33);

1
2
3
4
5
6
7
8
9
class  ModelC:  public  Airplane {
public :
     virtual  void  fly( const  Airport& destination);
...
};
void  ModelC::fly( const  Airport& destination)
{
//ModelC 飞往某一目的地的代码
}

>对于ModelC类, 因为纯虚函数的特性, 被强迫实现自己版本的fly, 就不会出现无意间继承不正确的fly行为的可能;

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

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


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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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)
{
//ModelC 飞往某一目的地的代码
}

>区别: 纯虚函数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:

1
2
3
4
D *pd =  new  D;
B *pb = pd;
pb->mf();  // 通过基类指针调用mf
pd->mf();  // 通过派生类指针调用mf

>有时必须将mf声明为非虚函数才能保证一切都按照预期的方式工作(不变性);

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值