Object Oriented Programming (2)
本文部分内容节选自Effective C++ by Scott Meyers 和 UML面向对象设计基础 by Meilir Page-Jones。
2 继承
关于继承,最重要的一个规则是:公共继承(public inheritance)意味着“是一种(is a)”的关系。私有继承(private inheritance)意味着“根据某物实现(implemented-in-term-of)”。至于保护继承(protected inheritance),则似乎没有人知道它代表什么含义。
2.1 公共继承
如果你令一个子类(Derived)class D以public形式继承了基类(Base)class B,你便是告诉了编译器(以及你的程序的读者和客户)说,每一个型别为D的对象同时也是一个型别为B的对象,反之并不成立。你的意思是 B 比 D表现出更一般化的概念,而D比B表现出更特殊化的的概念。你主张B对象派得上用场得地方,D对象也一样可以派上用场。为了保证类型一致性,要保证:
- 子类的不变式(invariant)至少跟基类一样强。
- 抗变性原则-每个子类操作的前置条件不应该强于基类操作的前置条件。
- 协变性原则-每个子类操作的后置条件至少和基类操作的后置条件一样强。
- 闭合行为原则(closed behavior)-子类从基类中继承的行为必须满足子类的不变式。
请思考“更大的取值范围”是更强的条件还是更弱的条件?
public 和 isa 之间的对等关系听起来简单,但事情的发展并不总是如此直接而明确。有时候你的直觉会误导你。举个例子,企鹅(pengiun)是一种鸟,鸟可以飞,如果我们天真的把这种关系以C++描述出来,结果如下:
class Bird
{
public:
virtual void fly(); // 鸟可以飞
…
};
class Pengiun : public Bird
{
…
};
突然我们遇上了麻烦,因为这个继承体系说企鹅可以飞,而我们知道那不是真的。怎么回事?在这个例子中,我们成了不严谨语文下的一个牺牲品。当我们说鸟可以飞的时候,我们真正的意思并不是说所有的鸟都可以飞,我们是指一般的鸟都有飞行的能力。如果严谨一点,我们应该承认一个事实:有数种鸟是不会飞的。下面的继承关系可以模塑出较佳的真实性:
class Bird
{
… // 没有声明(declare)fly函数
}
class FlyingBird : public Bird
{
public:
virtual void fly();
…
}
class NonFlyingBird : public Bird
{
… // 没有声明(declare)fly函数
}
class Pengiun : public NonFlyingBird
{
…// 没有声明(declare)fly函数
}
即便如此,此刻我们仍然没有完全处理好这些“鸟”事,因为“企鹅是一只鸟”对某些软件系统而言可能是适当的,尤其如果你的应用软件忙着处理鸟喙和鸟翅,完全没有管到飞行之事,那么原先的继承体系也可以良好运作。虽然这可能令人恼火,却反应出一个事实,那就是“可适用所有软件”的完美设计事不存在的。所谓最佳设计,取决于这个系统希望做什么事,包括现在与未来。如果你的程序对于飞行一无所知,而且也不打算未来对飞行“有所知”,那么让Pengiun成为Bird的子类(derived class),不失为一个完美而且有效的设计。事实上它可能比“在能飞与不能飞之间做出区分”更受欢迎,因为这样的区分在你企图模塑的世界中并不存在。为继承体系增加多余的classes,就像在classes之间构造不正确的继承关系一样,都是不良设计。
还有一种方法可以处理这个问题,那就是为企鹅重新定义(define)fly函数,令它产生一个运行时期的错误。
void error(const sttring& msg);
class Pendiun : public Bird
{
public:
virtual void fly(){error(“Pengiun can not fly”)}
}
但是,这里所展现的某些意义与你想象的可能完全不同。这里所说的并不是“企鹅不能飞”, 而是说“企鹅可以飞,但尝试这么做是一种错误”。这其中的差异在于,从“错误被侦测出来”的时间点来看,“企鹅不能飞”这个限制可以在编译时期反映出来,但“尝试让企鹅飞,是一种错误”这个信息,只有在执行时期才能显现。
或许你会说,你对于鸟类实在缺乏直觉,但是你的几何学学的不错,那么请你回答一个问题,正方形类应该公共继承长方形类吗?或者一个更大众化的问题,能够装桔子的篮子应该公共继承能够装水果的篮子吗?
2.2 保护继承
正如之前所说的,对于保护继承(protected inheritance),似乎没有人知道它代表什么含义。
2.3 私有继承
如果classes之间的继承关系是private,那么编译器通常不会自动将一个derived class object转换为一个bass class object,这和公共继承的情况相反。而且由private base class继承而来的所有member,在derived class中都会变成private属性,纵使他们在base class中原本是public或者protected属性。如果你让class D 以private方式继承class B,这么做的原因是,你想采用已经撰写于class B内的某些程序代码,而不是因为B和D对象有任何概念关系存在。所以私有继承纯粹是一种实现上的技术,与观念无涉。
私有继承意味着“根据某物实现(implemented-in-term-of)”,这个事实可能有点令人不安,因为layering技术也是这样,如何在这两者之间作出取舍呢?答案很简单:尽可能使用layering,必要时才使用私有继承。何为“必要”?即当protected member和/或 虚拟函数牵涉进来的时候。因为只有通过继承,才能使用protected members 和重定义虚拟函数。
2.4 区分继承和模板
考虑以下两个设计问题:
- 假设你想设计一个堆栈(Stack),每个堆栈都必须存放相同的数据类型,但是却需要实现针对很多种数据类型的堆栈,比如int,string等等。
- 假设你是一个猫迷,打算设计一些classes来表现猫咪,但是不同血统的猫咪有不同的性格,但是就像任何爱猫的人士所知,猫咪的行为主要就是吃和睡。也就是说不同血统的猫咪有自已一套吃和睡的本领。
这两个问题的规格听起来十分类似,但是他们却导出完全不同的软件设计。为什么?答案必须考虑到“每一个class的行为”和“被处理之对象的型别”之间的关系。面对堆栈和猫咪,你都必须处理各种不同的型别(堆栈内含型别为T的对象,猫咪则有T血统),但是你必须问自己一个问题:型别T会影响class的行为吗?如果不影响,那么可以使用模板(template)。如果影响,那么必须使用继承机制和虚拟函数(virtual functions)。
下面是你可能定义的一个以linked-list实现的堆栈class,假设堆栈内的对象型别为T:
template<class T>
class Stack
{
public:
Stack();
~Stack();
void push(const T& object);
T pop();
bool empty() const;
private:
struct StackNode
{
T data;
StackNode * next;
StackNode(const T& newData, StackNode * nextNode) : data(newData), next(nextNode) // data(newDate) 使用了T的拷贝构造函数
{}
};
};
关于Stack class 的实现部分就不在此描述了,惟一有趣的是,你可以在不知道T为何物的情况下撰写每个成员函数,除了“可调用T的拷贝构造函数(copy constructor)这个假设之外,堆栈的行为何T没有任何牵连。这正是模板类的特征:行为与型别无关。但是为什么继承不适用于Stack class 呢?假设Stack这么实现:
class Stack
{
public:
virtual void push(const ??? object) = 0;
virtual ??? pop() = 0;
};
棘手的问题出现了,对于虚拟函数push和pop,你打算为他们声明什么样的型别呢?记住,每个子类都必须重新声明(declare)它所继承的虚拟函数,并使用于基类(base class)相同的参数型别和返回值型别。不幸的是,int型别的堆栈要push和pop 的是int型的对象,而一个string堆栈,要push和pop的是string型别的对象。Stack class 要如何声明其纯虚拟函数使得其子类既要产生出int堆栈又产生出string堆栈呢?答案是不可能,这就是继承不适用于用来产生堆栈的原因。也许你企图用泛型指针来骗过编译器,但是事实上泛型指针在此帮不上什么忙,不过泛型指针倒是可以协助解决另外一个问题:关于模板类的效率问题。
但是,焦点转移到猫咪身上,为什么模板不适用于猫咪呢?请重新阅读猫咪的规格,注意其需求是“不同血统的猫咪有自已一套吃和睡的本领”。这意味着你必须为每一种猫咪实现不同的行为。你不能只写一个函数就统辖所有的猫咪,你能做的是为“每一种猫咪都必须实现”的函数设计其接口,而且传递接口的惟一方法就是声明纯虚拟函数。
现在我们解决了堆栈和猫咪,要旨如下:
- 模板类应该用来产生一群classes,其中对象型别不会影响class的函数行为
- 继承应该用于一群classes身上,其中对象型别会影响class的函数行为。
将以上两点融入内在思想中,你便可以掌握继承和模板之间正确的选择之道。
关于之前提到的“泛型指针在此帮不上什么忙”这个话题,在此展开讨论一下。为了避免因为使用模板而造成的程序代码膨胀的现象,我们决定利用泛型指针来重新实现Stack class,如下:
class GenericStack
{
public:
GenericStack();
~GenericStack();
void push(void * object);
void * pop();
bool empty() const;
private:
struct StackNode
{
void * data;
StackNode * next;
StackNode(void * newData, StackNode * nextNode) : data(newData), next(nextNode)
{}
};
StackNode * top;
GenericStack(const GenericStack& rhs);
GenericStack& operator=(const GenericStack& rhs);
};
这个GenericStack class存储的是指针而非对象(其实Stack class也可以完美的运用在GenericStack class身上,惟一需要改变的是把T 改为 void *)。而GenericStack本身几乎没有什么利用价值――它太容易被误用了。例如GenericStack的客户可以错误地将一个string对象的指针push到一个本打算用来方式int 指针的堆栈中。毕竟对void * 参数而言,指针就是指针,没有分别。为了重拾型别安全(type safe), 你可以为GenericStack产生一个接口类(interface classes),用来厉行强型别检查(strong typing)。如下:
class IntStack
{
public:
void push(int * intPtr){ s.push(intPtr); }
int * pop() { return static_cast<int *>(s.pop()); }
bool empty() const {return s.empty(); }
private:
GenericStack s;
};
但是万一潜在的客户不了解这一点呢?如果他们误以为使用GenericStack会更有效率,或者他们认为只有胆小鬼才需要强型别检查呢?如何阻止他们绕过IntStack,直接奔向GenericStack的怀抱?以下是私有继承发挥作用的时机了:
class GenericStack
{
protected: // 之前是public
GenericStack();
~GenericStack();
void push(void * object);
void * pop();
bool empty() const;
};
GenericStack s; // 错误,因为其构造函数是protected
class IntStack : private GenericStack
{
public:
void push(int * intPtr){ GenericStack::push(intPtr); }
int * pop() { return static_cash<int *>( GenericStack::pop()); }
bool empty() const {return GenericStack::empty(); }
};
IntStack is; // 没有问题
在GenericStack之上建立型别安全的接口类,是一种十分精巧的作战策略,但说道要以手动的方式完成所有的interface class, 又令人头疼不已。幸运的是你不必那么做。你可以利用模板自动产生他们。下边这个模板类就可以利用虚拟继承产生出型别安全的Stack 接口:
template<class T>
class Stack : private GenericStack
{
public:
void push(T * objectPtr){ GenericStack::push(objectPtr); }
T * pop() { return static_cash< T *>( GenericStack::pop()); }
bool empty() const {return GenericStack::empty(); }
}
这是一段令人惊艳的代码――虽然可能你一时不能会意。由于这个模板类,编译器会自动产生你所需要的任意个数的接口类,这些类都是型别安全的。而且客户也不能绕过那些接口类而使用GenericStack。由于每一个接口类的成员函数都被隐式地声明为inline,所以使用这些类不会增加执行期成本,产生出来的代码跟直接调用GenericStack的效率一样(前提是编译器接受inline请求)。
2.5 多继承
2.5.1 二义性
关于C++的多重继承,有一个勿庸置疑的事实是:它打开了潘多拉的盒子,释放出一大堆单继承中并不存在的复杂性。其中最根本的复杂性就是二义性
(ambiguity)。如果子类从一个以上的的基类继承了同一个member名称,那么对该名称的任何取用都会造成二义性的问题:你必须明白说出你要得
是哪个member。例如:
class Lottery
{
public:
virtual int draw();
};
class GraphicalObject
{
public:
virutal int draw();
};
class LotterySimulation : public Lottery, public GraphicalObject
{
… // 未定义draw
};
LotterySimulation *pls = new LotterySimulation;
pls->draw(); // 错误,二义性
pls->Lottery::draw(); // 没问题
pls->GraphicalObject::draw(); // 没问题
最后两行看起来很笨拙,但至少可以有效运作。不幸的是这样的笨拙很难消除。纵使其中的一个继承而来的draw函数是private属性(因而无法被外界调 用),二义性的情况还是存在(请思考C++为什么这么规定? 其中一个原因是,假如在这种情况下不会引起二义性,但是设想父类的开发人员在某天修改了存取限制,将private改成public,那么会令他意想不到的是这个修改会引起别的代码因为二义性而无法通过编译!)。对于虚拟函数, 如果明白指定一个class之后,这个函数调用也不再具有虚拟特性了。此外,虽然Lottery和GraphicalObject的draw函数被声明为 虚拟函数,但是子类却不可以分别重新定义它们。这是一个十分严重的问题,严重到足以构成改变语言规格的理由。ARM(The Annotated C++ Reference Manual)就讨论过“允许继承而来的虚拟函数被重新命名”的可能性,不过随后发现,只要增加一对新的class,就可以绕过这个问题:
class AuxLottery : public Lottery
{
public:
virtual int lotteryDraw() = 0;
virtual int draw() { return lotteryDraw(); }
}
class AuxGraphicalObject : public GraphicalObject
{
public:
virtual int graphicalObjectDraw() = 0;
virtual int draw() { return graphicalObjectDraw (); }
}
你应该牢记这个战术,其中满载这纯虚拟函数,一般虚拟函数,inline函数的灵巧运用。这个战术可以有效运作,但是导入的两个类AuxLottery和 AuxGraphicalObject,他们既不符合问题域(problem domain)的抽象性,也不符合实现域(implementation domain)的抽象性。他们的存在只是作为一个实现上的装置(device),如此而已。你知道,好的软件是“与装置无关”的。
2.5.2 虚拟基类
考虑下面一个继承体系
class B {…};
class C {…};
class D: public B, public C {…};
它有一个令人烦恼的倾向,就是会演化成这样
class A {…};
class B : virtual public A{…};
class C : virtual public A {…};
class D: public B, public C {…};
当今世界,钻石可能是女性的好朋友,也可能不是。不过一个钻石形的继承体系绝对不会是你的好朋友。如果你产生了一个上述那样的继承体系,你会立刻面对一个问题:是否要让A成为一个虚拟基类(virtual base class)?就务实而言,答案几乎总是“应该”;除非你希望D对象内含多份A data member。事有两面,如果将A声明为B和C的虚拟基类,那么也同时增加了执行时的成本,因为虚拟基类通常是以“对象指针”来实现的,还有,不必我提醒,在内存中的对象布局取决于编译器(如果有兴趣了解C++的内存布局,推荐阅读《深度搜索C++对象模型》)。当A是一个非虚拟基类时,典型的内存布局如下:
A part |
B part |
A part |
C part |
D part |
当A是一个虚拟基类时,某些编译器会为D对象做出如下内存布局:
B part |
A pointer |
C part |
A pointer |
D part |
A part |
因此上述B和C将A声明为一个虚拟基类。不幸的是,在你定义B和C的时候,你可能并不知道是否会有任何class决定同时继承他们两个。如果必须知道这一点才能把B和C正确地定义好,那么实在说不过去。就算不必决定A是否必须成为B和C的虚拟基类,你还是必须在基类中决定那些函数是虚拟。但此间有个关键差异。虚拟函数有一个定义完好的高阶(high level)意义,它和非虚拟函数的定义完好的高阶定义有显著的差别;至于“决定一个基类是否应该成为虚拟(virtual)或者非虚拟(nonvirtual)”,则缺乏一种定义完好的高阶意义,该决定通常取决于整个继承体系的结构,因此在整个继承体系尚未明朗之前,无法做出决定。
2.5.3 Protocol class
所谓protocol class,它用来为子类指定接口,它本身没有data member,没有构造函数(contructor),只有一个虚拟析构函数(virtual destructor)和一组代表接口的纯虚拟函数(类似于Java语言中的interface)。这个class的用户必须以指针(pointers)和引用(references)形式来使用,因为该类无法被实体化。此外使用protocol class还有助于降低文件间的编译依赖性(请思考Java语言中是怎么降低文件之间的编译依赖性的?)。
推荐阅读
-
PHP学习记录之面向对象(Object-oriented programming,OOP)基础【类、对象、继承等】
-
jQuery使用$.extend(true,object1, object2);实现深拷贝对象的方法分析
-
libiconv.so.2 cannot open shared object file: No such file or directory
-
libmatio.so.2: cannot open shared object file: No such file or directory
-
使用 Object.defineProperty (vue2)和 Proxy(vue3)实现Vue双向数据绑定
-
Coursera-Algorithms,Part I-Robert Sedgewick-Programming Assignment 2
-
面向对象编程Object-Oriented和装饰器decorator
-
cv2报错ImportError: libXrender.so.1: cannot open shared object file 解决
-
荐 夯实基础,彻底掌握js的核心技术(二):面向对象编程(Object Oriented Programming)
-
python经典书籍推荐:Python面向对象编程指南 : Mastering Object-oriented