EffectiveCpp0x6
Inheritance and Object-Oriented Design
[toc]
我:请问什么是面向对象原则
ChatGPT:SOLID 是面向对象设计中的五个基本设计原则的首字母缩写,它们是:
- 单一职责原则(Single Responsibility Principle,SRP): 每个类或模块应该只有一个改变的理由,即一个类应该只负责一个职责。这个原则指导我们将系统分解为小而独立的部分,每个部分都有自己的职责,使得代码更加清晰、可维护和可重用。
- 开放-封闭原则(Open/Closed Principle,OCP): 软件实体(类、模块、函数等)应该对扩展开放,对修改封闭。这意味着当需要改变系统的行为时,不应该修改已有的代码,而是应该通过扩展现有的代码来实现新的行为。这个原则鼓励使用抽象和多态性来实现系统的灵活性和可扩展性。
- 里氏替换原则(Liskov Substitution Principle,LSP): 已有的对象实例可以被它的子类实例替换,而程序的行为仍然保持一致。这个原则强调子类应该符合父类的契约,保持对父类的透明性,同时可以通过子类扩展父类的行为。
- 接口隔离原则(Interface Segregation Principle,ISP): 客户端不应该依赖于它不需要使用的接口。这个原则指导我们设计精简的接口,将大接口拆分为多个小接口,使得客户端只需要依赖于它们真正需要的接口,避免了不必要的依赖关系,提高了系统的灵活性和可维护性。
- 依赖反转原则(Dependency Inversion Principle,DIP): 高层模块不应该依赖于低层模块,它们都应该依赖于抽象。抽象不应该依赖于具体实现细节,而具体实现细节应该依赖于抽象。这个原则通过将高层模块和低层模块都依赖于抽象,实现了模块间的解耦合,提高了代码的灵活性和可维护性。
32. Make sure public inheritance models “is-a”
- 里氏替换原则:即
父类
比子类
表现出更一般的概念,子类
比父类
表现出更特殊化的一般概念。如果子类对象可以替换掉父类对象,并且程序行为不变,则这种继承关系是合理的。 - 我们因此可以推出任何函数如果期望获得一个类型为Person(或pointer-to-Person 或reference-to- Person) 的实参,都也愿意接受一个Student对象(或pointer-to-student或reference-to-Student) :
1 | class Person {... }; |
企鹅和它的鸟事
我们知道大多数鸟是会飞的,而企鹅也是鸟(我也是第一次知道;)),那我们因该如何处理这个鸟类呢?
来看看有几种方法:
- 我们把企鹅与会飞的鸟分开
1 | class Bird { |
- 产生一个运行时错误
1 | void error(const std::string& msg); // 定义在别处 |
- 在编译期拒绝企鹅飞行
1 | class Bird { |
- 条款18 说过:好的接口可以防止无效的代码通过编译,因此你应该宁可采取“在编译期拒绝企鹅飞行”的设计,而不是“只在运行期才能侦测它们”的设计。
正方形与矩形
正方形应该以public形式继承矩形吗?
我:正方形应该以public形式继承矩形吗?
ChatGPT:
从面向对象设计的角度来看,正方形是否应该以公共形式继承矩形取决于所选择的模型和设计目标。
在数学上,正方形是矩形的一种特殊情况,因此从继承的角度来看,正方形确实可以被视为是矩形的一种。然而,在编程中,继承关系通常应该反映出真正的”is-a”关系。换句话说,如果子类对象在所有情况下都可以替代父类对象而不产生任何副作用,那么继承关系才是适当的。
在一般情况下,正方形与矩形之间的继承关系并不符合”is-a”关系。尽管正方形确实是一种特殊的矩形,但它们在行为和属性上可能会有所不同。例如,矩形通常具有长度和宽度两个属性,而正方形则具有相等的边长。如果以公共形式继承矩形,那么正方形将继承矩形的长度和宽度属性,这可能会导致一些不符合预期的行为。
因此,在大多数情况下,正方形不应该以公共形式继承矩形。相反,可以考虑使用组合或私有继承来实现正方形与矩形之间的关系。例如,可以将正方形类内部包含一个矩形对象,并在必要时委托给它,以实现代码的复用和灵活性。
请记住
- “public 继承“意味is-a 。适用于base classes 身上的每一件事情一定也适用于derived classes 身上,因为每一个derived class 对象也都是一个base class 对象。
33. A void hiding inherited names.
内层作用域的名称会遮掩(遮蔽)外围作用域的名称
。
1 |
|
- 那么延伸到继承上,当子类继承了父类并且父类和子类中存在同名的成员函数或成员变量时,子类会覆盖(隐藏)父类中相同名称的成员函数或成员变量。
那么,我们有什么方法来使用父类的成员变量呢?
- 通过
using声明式
1 | class Base { |
如果你继承base class 并加上重载函数,而你又希望重新定义或覆写(推翻)其中一部分,那么你必须为那些原本会被遮掩的每个名称引入一个using 声明式,否则某些你希望继承的名称会被遮掩。
- 转交函数
1 | class Base { |
请记住
- derived classes 内的名称会遮掩base classes 内的名称。在public 继承下从来没有人希望如此。
- 为了让被遮掩的名称重见天日,可使用using 声明式或转交函数(forwarding functions) 。
34. Differentiate between inheritance of interface and inheritance of implementation.
表面上直截了当的public 继承概念,经过更严密的检查之后,发现它由两部分组成:函数接口(function interfaces) 继承
和函数实现(function implementations)继承
。这两种继承的差异,很像本书导读所讨论的函数声明与函数定义之间的差异。
考虑如下函数
1 | class Shape { |
virtual
关键字用于声明可以在派生类中被重写的虚函数,它可以有默认实现。pure virtual
关键字用于声明没有实现的虚函数,它要求派生类必须提供实现。纯虚函数的存在使得基类成为一个抽象类,无法实例化。
1 | Shape *ps = new Shape; // 错误!Shape 是抽象类 |
//TODO
35. Consider alternatives to virtual functions
考虑一个健康值计算,由于每个角色计算方式不同,我们采用virtual方式
1 | class GameCharacter { |
借由Non-virtual Interface 手法实现 Template Method 模式
保留healthValue 为public 成员函数
,但让它成为non-virtual
, 并调用一个private(或protect) virtual 函数
(例如doHealthValue)进行实际工作:
1 | class GameCharacter { |
这种手法被称为NVI
手法。这个non-virtual
函数可被称为virtual
函数的外覆器
。即我们可以在计算健康值前和后做一些工作,如日志记录,互斥锁等。
借由Functions Pointers实现Strategy模式
1 | class GameCharacter; // 前向声明 |
通过Strategy设计模式
,我们可以为不同角色设置不同策略,还可以在运行时
修改策略(比如boss的不同阶段)
请记住
36. Never redefine an inherited non-virtual function.
non-virtual
函数是静态绑定的,用什么指针(引用)调用mf(),调用的版本是该指针的类型
。如base指针只能调用base的mf(),derived指针只能调用derived的mf()。virtual
函数是动态绑定的。调用的mf()函数是根据你所指对象的类型来调用对应的成员函数mf()。
正确做法
1 | class B { |
37. Never redefine a function’s inherited default parameter value.
我们能继承两种函数virtual
函数和non-virtual
函数,我们在上一节已经讨论了non-virtual
函数继承,那么接下来聊一下vtubervirtual
继承吧。
敲重点:virtual functions(虚拟函数)是 dynamically bound(动态绑定),而 default parameter values(缺省参数值)是 statically bound(静态绑定)。
静态绑定(early binding)与动态绑定(late binding)
一个 object(对象)的 static type(静态类型)就是你在程序文本中声明给它的 type(类型)。考虑这个 class hierarchy(类继承体系):
1 | // 几何形状的类 |
现在考虑这些 pointer:
1 | Shape *ps; |
ps,pc 和 pr 全被声明为 pointer-to-Shape
类型,它们的静态类型都是 Shape*。
一个对象的动态类型取决于它当前引用的对象的类型。也就是说,动态类型
表明它有怎样的行为。在上面的例子中,pc 的动态类型
是 Circle,而 pr 的 动态类型
是 Rectangle。至于 ps,它没有一个实际的 动态类型
。
动态类型
,就像它的名字所暗示的,能在程序运行中变化,特别是通过 assignments(赋值):
1 | ps = pc; // ps的动态类型现在是 Circle* |
virtual functions
是 动态绑定
,意味着被调用的特定函数取决于被用来调用它的那个对象
的动态类型
:
1 | pc->draw(Shape::Red); // calls Circle::draw(Shape::Red) |
但是当你考虑带有缺省参数值的virtual 函数,花样来了,因为就如我稍早所说,virtual 函数是动态绑定,而缺省参数值却是静态绑定
。意思是你可能会在“调用一个定义于derived class 内的virtual 函数”的同时,却使用base class 为它所指定的缺省参数值
:
1 | pr->draw(); // calls Rectangle::draw(Shape::Red)! |
可以看到使用了Shape
的缺省参数。
我:为什么c++virtual functions(虚拟函数)是 dynamically bound(动态绑定),而 default parameter values(缺省参数值)是 statically bound(静态绑定)。
GLM-4:
C++中虚拟函数(virtual functions)是动态绑定的,而缺省参数值(default parameter values)是静态绑定的,这是由于C++语言的设计和实现机制决定的。
虚拟函数的动态绑定:
- 多态性:C++的面向对象编程支持多态性,允许通过基类指针或引用调用派生类的函数。动态绑定是实现多态性的关键,它确保了在运行时能够根据对象的实际类型来调用正确的函数版本。
- 虚函数表(vtable):C++通过虚函数表来实现动态绑定。每个包含虚函数的类都有一个与之关联的虚函数表,表中包含了该类的虚函数的地址。当一个对象被创建时,它的构造函数会初始化一个指向其虚函数表的指针。当通过指针或引用调用一个虚函数时,实际调用的函数地址是从虚函数表中获取的,而不是在编译时确定的。
- 运行时类型信息(RTTI):C++的运行时类型信息允许程序在运行时查询对象的实际类型。这是通过虚函数表和类型信息实现的,它使得动态绑定成为可能。
缺省参数值的静态绑定:
- 编译时确定:缺省参数值是在编译时确定的,它们是函数签名的一部分。当编译器解析一个函数调用时,它会根据函数的声明来确定是否需要使用缺省参数值。这意味着缺省参数值与函数的静态类型相关联,而不是与对象的动态类型相关联。
- 效率考虑:静态绑定缺省参数值可以提高效率,因为编译器可以在编译时确定所有需要的参数,而不需要在运行时进行额外的查找。
- 一致性和可预测性:静态绑定缺省参数值确保了函数的行为是一致的,因为缺省参数值不会因为通过不同的指针或引用调用而改变。
由于这些原因,C++中的虚拟函数是动态绑定的,而缺省参数值是静态绑定的。这种设计允许C++在提供多态性的同时保持编译时的类型检查和效率。然而,这也意味着在使用虚函数和缺省参数时需要特别注意,以避免由于静态绑定和动态绑定的不同而产生的混淆和错误。
让我们给Rectangle
加上默认参数。
1 | class Rectangle: public Shape { |
喔欧,代码重复。更糟的是,代码重复又带着相依性:如果Shape 内的缺省参数值改变了,所有“重复给定缺省参数值”的那些derived classes 也必须改变,简直就是项目维护的灾难!
NVI!
通过nvi手法,实现以下代码。由于non-virtual 函数应该绝对不被derived classes 覆写(见条款36) ,这个设计很清楚地使得draw 函数的color 缺省参数值总是为Red。
1 | class Shape { |
请记住
- 绝对不要重新定义一个继承而来的缺省参数值,因为缺省参数值都是静态绑定,而virtual 函数——你唯一应该覆写的东西却是动态绑定。
38. Model “has-a” or “is-implemented-in-terms-of” through composition.
复合(composition) 是类型之间的一种关系,当某种类型的对象内含它种类型的对象,便是这种关系。
复合意味
has-a (有一个)
或is-implemented-in-terms-of(根据某物实现出)
- 在
应用域(application domain)
,复合意味has-a (有一个)
。在实现域(implementation domain)
,复合意味is-implemented-in-terms-of (根据某物实现出)
。
那么什么对象是应用域、实现域?
- 应用域:人、汽车、一张张视频画面等等。
- 实现域:缓冲区(buffers) 、互斥器Cmutexes) 、查找树(search trees) 等等。
下面的Person 对象由string, Address, PhoneNurnber
构成。属于应用域
,那该复合则为has-a (有一个)
。
1 | class Address { ... }; // 住址类,表示一个人的居住地 |
那么什么情况下是is-implemented-in-terms-of (根据某物实现出)
?
假设我们想重新定义一个set
,默认的set
存储一个节点需要三个指针(基于红黑树等数据结构实现)。在日常使用情况下没有问题,但是我们写出的程序要求空间比速度重要呢?例如set
不得不存储大量数据。
在这里,我们使用linked list
来实现它。
1 | template<class T> // 使用列表表示集合的正确方式 |
为什么我们不直接public继承list
?
因为如果以public继承list
,那么将会允许set
持有相同元素,这显然是不正确的,这里我们使用private继承
1 | template<class T> // 使用 list 作为 Set 的正确方式 |
请记住
- 复合(composition) 的意义和public 继承完全不同。
- 在
应用域(application domain)
,复合意味has-a (有一个)
。在实现域(implementation domain)
,复合意味is-implemented-in-terms-of (根据某物实现出)
。
39. Use private inheritance judiciously.
当以public继承时,编译器在必要时刻(为了让函数调用成功)将students 暗自转换为Persons 。
考虑如下例子:
1 | class Person { ... }; |
private继承:
- 如果classes 之间的继承关系是private, 编译器不会自动将一个derived class 对象(例如Student) 转换为一个base class 对象(例如Person) 。
- 由private base class 继承而来的所有成员,在derived class 中都会变成
private 属性
,纵使它们在base class 中原本是protected 或public 属性。 - 借用条款34 提出的术语, private 继承意味只有实现部分被继承,接口部分应略去。
Private 继承意味is-implemented-in-terms-of (根据某物实现出)
,这个事实有点令人不安,因为条款38 才刚指出复合(composition) 的意义
也是这样。你如何在两者之间取舍?
答案很简单:尽可能使用复合,必要时才使用private 继承。何时才是必要?主要是当protected 成员或virtual 函数牵扯进来的时候。其实还有一种激进情况,那是当空间方面的利害关系足以踢翻private 继承的支柱时。
假设我们需要定时对Widget
对象状态进行检测,那么继承一个Timer
类来定时实现他,当使用public继承:
1 | class Timer { |
我们不想让客户可以调用这个函数,毕竟这不是Widget
的一部分,违反条款18 的忠告:“让接口容易被正确使用,不易被误用"。改用private继承
:
1 | class Widget: private Timer { //Timer定义必须可见 |
但是private继承
相较于复合
,有点不好:
- 我们不能阻止
derived class
重新定义OnTick
。
即使基类的公有和保护成员在派生类中变为私有成员,派生类仍然可以通过重写基类中的虚函数来提供自己的实现。这是因为虚函数的覆盖是基于对象的实际类型,而不是基于继承的访问权限。因此,即使使用了私有继承,派生类仍然可以重写基类中的虚函数,以提供多态行为。
- 编译依存性较高
- //TODO
那么,让我们来看一下复合
:
1 | class Widget { |
在这种情况下,Widget子类
无法重新定义onTrick
函数。
虽然在大多数情况下复合
更适合,但是private
继承在一定情况下更适合。
考虑如下例子:
1 |
|
可以看到,空白基类也要占用一个字节的大小。而在派生子类,继承的空白基类不占大小。C++ 裁定凡是独立(非附属)对象都必须有非零大小
。
//TODO
EBO?
Use multiple inheritance judiciously
//TODO