第四章 继承——代码重用和改进
4.1 一个超级类——点
我们将在这章以一个基本的画图程序作为开始。这里是是我们乐意拥有的其中一个类的快速测试如下:
#include "Point.h"
#include "new.h"
int main (int argc, char ** argv)
{
void * p;
while (* ++ argv)
{
switch (** argv) {
case 'p':
p = new(Point, 1, 2);
break;
default:
continue;
}
draw(p);
move(p, 10, 20);
draw(p);
delete(p);
}
return 0;
}
对于每一个命令参数以字符 p 开始,我们获得一个新的绘图的点,移动这个点到某处,从新绘制,并且删除。标准化C语言不包含图形化输出标准的函数:然而,如果我们坚持产生一幅图片,我们能够发表文本,对于这个文本Kernighan 的图片 [Ker82] 能够理解:
$ points p
"." at 1,2
"." at 11,22
坐标对于测试是无关紧要的——从商业和面向对象的说法解释:“点就是一则消息。”
我们用这个点能做些什么呢?new() 将产生一个点,并且构造器期望着初始化坐标作为进一步的参数传进 new() 。通常,delete() 将回收我们的点并且按照惯例调用析构器。
draw() 安排点被显示出来。由于我们希望与其他图形对象协同工作——因此在测试程序中会有switch——对于draw() 我们将提供动态连接。
move() 通过传递一系列参数来改变点的坐标。如果我们实现每一个图形对象,这些对象都与它涉及的点关联,我们将能够通过简单的应用这个点的move() 方法来移动它。因此,对于move() 在不需要动态连接的情况下我们应该可以做。
4.2 超级类的实现——点
在Point.h 中,抽象数据类型包含如下:
extern const void * Point; /* new(Point, x, y); */
void move (void * point, int dx, int dy);
我们能够重复利用第二章的new 文件,尽管我们删除了很多方法并且对new.h 文件增加了draw() 方法:
void * new (const void * class, ...);
void delete (void * item);
void draw (const void * self);
在new.r 中类型描述 struct Class 应该与在new.h 中声明的方法相关联:
struct Class {
size_t size;
void * (* ctor) (void * self, va_list * app);
void * (* dtor) (void * self);
void (* draw) (const void * self);
};
选择器draw() 在new.c 中实现。它将代替如differ() 在2.3 节介绍的选择器,并且以相同的风格编写代码:
void draw (const void * self)
{
const struct Class * const * cp = self;
assert(self && * cp && (* cp) -> draw);
(* cp) -> draw(self);
}
这些预备工作完成后,我们将转去做真正的工作去写Point.c,对点的实现。在此,面向对象帮助我们精确的鉴别出我们需要做什么:我们必须对表示式做出决定并实现构造器,析构器,动态链接方法draw() 和静态链接方法move() ,这些都是基本的函数。如果我们坚持二维,笛卡尔坐标,我们选择如下明确的表示:
struct Point {
const void * class;
int x, y; /* coordinates */
};
构造器必须初始化坐标 .x 和 .y ——现在一个绝对的例程如下:
static void * Point_ctor (void * _self, va_list * app)
{
struct Point * self = _self;
self -> x = va_arg(* app, int);
self -> y = va_arg(* app, int);
return self;
}
现在的结果是我们并不需要析构器,因为在delete() 之前没有资源需要回收。在Point_draw() 函数中,我们以一种图片能够识别的方式打印当前的坐标:
static void Point_draw (const void * _self)
{
const struct Point * self = _self;
printf("\".\" at %d,%d\n", self -> x, self -> y);
}
这样照顾到所有的动态连接方法,并且我们能够定义类型描述符,在此一个空的指针代表一个不存在的析构器:
static const struct Class _Point = {
sizeof(struct Point), Point_ctor, 0, Point_draw
};
const void * Point = & _Point;
move() 不是动态连接的,因此我们省略static 使得它作用域能够超出Point.c并且我们不给它加类名前缀Point :
void move (void * _self, int dx, int dy)
{
struct Point * self = _self;
self -> x += dx, self -> y += dy;
}
与在new.c 中的动态连接相结合,这就得出了Point.c中点的实现。
4.3 继承——环
一个环形仅仅是一个大的点:此外对于中心坐标它需要一个半径。画法有点不同,但是移动只需要我们改变中心坐标。
这就是我们能够正常的为我们的文本编辑器和演择源代码重用而做好准备的地方。我们对点的实现做一个拷贝并且改变环与点不同的地方。Struct Circle 获取其他额外的组成:
int rad;
这部分组成在构造器中初始化
self->rad=va_arg(*app,int);
并且在Circle_draw() 中使用:
printf("circle at %d,%d rad %d\n",
self —> x, self —> y, self —> rad);
我们在move() 中有点迷惑。对于一个点和一个环必要的动作是相同的:对于坐标部分我们需要增加转移参数。然而,在一种情况,move() 工作于struct Point ,在另外一种情况,它工作与struct Circle 。如果move() 是动态连接的,我们需要提供两个不同的函数去做相同的事情。但是,会有更好的方式,考虑一下点和环表示的层:
struct Point struct Circle
图片显示每一个环都以一个点开始。如果我们分配一个struct Circle 通过增加到struct Point的结尾,我们可以向move() 函数中传递一个环,因为表示式的初始化部分看起来仅仅像点,而move() 方法期望接到收点,并且点仅仅是move() 方法能够改变的。这里是一个合理的方式确保对环的初始化部分总看起来像点:
struct Circle { const struct Point _; int rad; };
我们让派生的结构体以一个我们要扩展的基结构体的拷贝而开始。信息隐藏要求我们决不直接的访问基结构体;因此,我们使用几乎不可见的下划线作为它的名字并且把它声明为const避开粗心的指派。
这就是简单的继承的全部:一个子类从一个超类(或者基类)继承仅仅通过扩充表示超类的结构体。
由于子类对象(一个环)的表示就像一个超类对象(一个点)的表示一样动身。环总能够佯装成一个点——在一个环的表示的初始化地址处的确是一个点的表示。
向move() 中传递一个环是完全确定的:子类继承了超类的方法,因为这些方法仅在子类的表示上操作,这些子类的表示和超类的表示是相同的, 而这些方法原先就在超类上写好了。传递一个环就像传递一个点意味着把struct Circle* 转换成struct Point* 。我们将把这样的操作看成一个从子类到超类的上抛——在标准化C语言中,它能够使用明确的转换操作符来实现或者通过中间的void* 的值。
这通常是不佳的,然而,传递一个点到一个函数专为环如,Circle_draw(): 如果一个点原先就是一个环,从struct Point* 转换成struct Circle* 仅仅是可允许的。我们称这样的从超类到子类的转换为下抛——这也要求明确的转换或void*值,并且它仅仅对于指针,对于对象能够使用,指针,对象在子类的开始做转换。
对于动态连接方法如draw() ,这种情形是不同的。让我们再次看先前的图片,这次完全明确类型描述符如下 :
4.4 连接和继承
move() 不是动态连接的并且不使用动态连接方法做工作。然而我们能够传递一个指针和环到move() 中,它的确不是一个多肽的函数:move() 对于不同的对象不会做不同的处理,它总是增加参数到坐标,忽略其他与坐标相依附的。
当我们上抛从一个环到一个点时,我们没有改变环的状态,换句话说,即使我们把环的struct Circle 表示当成一个点的struct Point ,我们不会改变它的内容。结果,把环视为点作为一个类型描述符仍然拥有Circle,因为点在它的 .class 部分并没有改变。draw()是一个选择器函数,即,它将会使用无论传入什么样的参数作为自身,去处理被 .class 所指示的类型描述符,并且调用在这里存储的画图方法。
一个子类继承它的超类的静态链接的方法——这些方法操作子类对象的部分,这些子类对象是已经在超类对象上呈现的。一个子类能够选择支持它自己的方法代替它的超类的动态连接方法。如果继承,即,若没有重写,超类动态的连接的方法就像静态连接的方法一样的起作用并且修改子类对象的超类的部分内容。如果重写,子类他自己的动态连接方法的版本访问子类对象所有的表示,即,对于一个环,draw() 将会调用Circle_draw() 方法,此方法能够考虑到半径当画环的时候。
4.5 静态和动态连接
一个子类继承了它的超类的静态链接的方法并且选择性的继承或重写动态连接的方法。考虑对于move() 和draw()的声明如下:
void move(void* point, int dx,int dy);
void draw(const void* self);
我们不能够从这两个声明中发现连接,尽管对于move() 的实现能够直接的工作,然而draw() 仅仅是一个选择器函数在运行时跟踪动态连接。不同点就是我们声明一个静态链接方法就像move() 在Point.h 中作为抽象数据类型接口的一部分,且我们声明一个动态连接方法就像draw() 携带内存管理接口在new.h 中,因为迄今为止我们已经决定在new.c 中实现数据选择器。
静态链接会更加有效率因为C编译器能够使用直接的地址调用子程序,但是对于一个函数如move() 对于子类不能被重写。动态连接在间接调用的扩展上更加便捷——我们已经对调用选择器函数如draw()的额外开销作了决定,检查参数,定位,调用正确的方法。我们丢弃了检查并且使用macro* 像如下减少了额外开销:
#define draw(self) ((*(struct Class**)self)->draw(self));
但是如果他们的参数有负面的影响宏会引发问题并且对于宏并没有明确的技术用于操作可变参数列表。此外,宏需要struct Class 的声明,此struct Class 到目前为止对于类的实现已经可用而不是对于整个程序。
不幸的是当我们设计超类时,我们还需要决定很多事情。但是函数调用方法是不会改变的,它会占用很多文本编辑,更可能的会在许多类中,把一个函数的定义从静态转换到动态连接,反之亦然。从第七章开始我们将使用一个简单的预处理去简化编码,即使如此连接转换也是极易出错的。
带着这种怀疑,与静态链接相比决定动态连接可能会更好点即使它效率较低。通用函数能提供一个有用的概念性的抽象并且他们倾向于减少我们需要在项目过程中记忆的函数名的数量。如果,实现所有要求的类后,我们发现其实动态连接方法从来没有被重写,通过其单一的实现去替代它的选择器并且甚至在struct Class 中浪费它的位置与扩展类型描述和更正所有的初始化相比麻烦会更少。
4.6 可见度和访问函数
我们现在可以尝试着实现Circle_draw() 。基于“need to know ”这样的规则信息隐藏要求我们对于每个类使用3个文件。Circle.h 包含抽象数据类型接口;对于一个子类它包含了超类的接口文件以便于这样的声明使得继承的方法可用:
#include "Point.h"
extern const void* Circle; /*new(Circle,x,y,rad)*/
接口文件Circle.h 被应用程序代码所包含并且对于类的实现;它避免了多次包含所引发的错误。
一个环的表示在第二个头文件中声明,Circle.r 。对于子类它包含了超类的表示文件以便于我们能够通过扩展超类派生出子类的表示:
#include "Point.r"
struct Circle{const struct Point _;int rad;};
子类需要超类的表示去实现继承:struct Circle 包含了一个const struct Point。这个点确定不是只读的——move() 将改变它的坐标——但是const 限定词防止了意外的覆盖它的组成部分。表示文件Circle.r 仅仅被类的实现所包含;仍然受到多重调用的保护。
最终,对一个环的实现对于类,对于对象管理,被在包含接口和表示文件的原文件Circle.c 中所定义:
#include "Circle.h"
#include "Circle.r"
#include "new.h"
#include "new.r"
static void Circle_draw(const void * _self)
{
const struct Circle* self=_self;
printf("circle at %d rad %d\n",self->_.x,self->_.y,self->rad);
}
在Circle_draw() 中,对于环我们通过子类部分使用“可见的名字”_. 来读取点部分。从信息隐藏的角度看这并不是一个好的注意。然而读取坐标值不应该产生重大的问题,我们决不能确保在其他情形下,一个子类的实现不去直接的欺骗和修改它的父类的一部分,因此带着其不变量去玩一场浩劫。
效率要求一个子类能直接的访问到其超类的组成部分。信息隐藏和可维护性原则要求一个超类从它的子类上尽可能好的隐藏对它自己的表示。如果我们后面做出选择,我们应该能够提供对这些子类被允许查看超类所有组成部分访问函数,并且对于这些组成部分提供更正函数,即,便要子类去做修改。
访问和修改函数时静态链接的方法。如果我们对于超类在表示文件中声明了他们,超类仅包含在子类的实现中,我们可以使用宏,如果宏使用每个参数仅以此则副作用没有问题。作为一个例子,在Point.r 中,我们定义了下面的访问宏macros:*
#define x(p) (((const struct Point*)(p))->x)
#define y(p) (((const struct Point*)(p))->y)
这些宏对于任何以struct Point 开始对象能够被应用于一个指针,也就是说,对于对象,从我们的点的任何子类。这项技术即为,上抛我们的点到超类并引用我们感兴趣的部分。const 在抛得过程中对结果的分配。如果const 被忽略
#define x(p) (((struct Point*)(p))->x)
一个宏调用x(p) 产生一个能成为分配的目标的l-value,一个好点的修改函数最好是一个宏的定义
#define set_x(p,v) (((struct Point*)(p))->x=(v))
此定义产生一个分配。
在子类实现的外部对于访问和修改函数我们仅仅使用静态链接的方法。我们不能够求助于宏,因为对于宏引用超类的内部表示是不可见的。对于包含进应用程序的信息隐藏并不提供表示文件Point.r 而实现。
宏定义揭示了,然而,一旦一个类的表示可用,信息隐藏能够被很容易的击败。这里有一个方式更好的隐藏struct Point 。在超类的实现中,我们使用正常的定义:
struct Point{
const void* class;
int x,y;
};
对于子类的实现我们提供下面的看起来不透明的版本:
struct Point{
const char _[sizeof(struct {const void* class; int x,y;})];
};
这个结构体像先前拥有相同的大小,但是我们不能够读取也不能够写它的组成部分因为他们被隐藏在一个匿名的内部结构中。重点是这两种声明必须包含相同的组成部分的声明并且这在没有与处理器的情况下是很难维持的。
4.7 子类的实现——环
我们已经做好了些完整实现的准备,我们可以选择先前部分介绍的我们最喜欢的技术。面向对象规定我们需要一个构造器,可能的话还会有一个析构器,Circle_draw(),和类型描述Circle 都绑定在一起。以便于练习我们的方法,我们包含了Circle.h 并增加了下面的行在4.1 部分的程序中做测试:
case 'c':
p=new(Circle,1,2,3);
break;
现在我们能够观察到下面的测试程序的表现:
$ circles p c
"." at 1,2
"." at 11,12
circle at 1,2 rad 3
circle at 11,22 rad 3
环的构造函数接收3个参数:第一个参数为环的点的坐标接下来是半径。初始化点部分是点的构造器的工作。 它会处理部分new() 参数列表的参数。环的构造器从它的初始化半径的地方携带保留的参数列表。
一个子类的构造器首先应该允许超类做部分初始化,这部分初始化把清晰地内存带进超类对象。一旦超类构造器构造完成,子类构造器完成初始化并把超类对象带进子类对象中。
对于环,意味着我们需要调用Point_ctor() 。像其他所有动态链接一样,这个函数被声明为static ,因此隐藏在Point.c 的内部。然而,我们仍然能够通过在Circle.c 中可用的类型描述符来 Point 获得此函数。
static void * Circle_ctor (void * _self, va_list * app)
{
struct Circle * self =
((const struct Class *) Point) —> ctor(_self, app);
self —> rad = va_arg(* app, int);
return self;
}
这里应该很清楚为什么我们传递参数的地址app 列表指针到每个构造器而不是va_list 的值本身:new()调用子类的构造器,此构造器调用超类的构造器,等等。最超级的构造器是第一个将去实际的作一些事情,并且会捡起传进new() 的最左边的参数列表。保留的参数对于下一个子类是可用的,等等知道最后,最右边的参数被最终的子类所使用,也就是说,被new()所直接的调用的构造器所调用。
构造器以严格的相反的次序是最好的组织:delete() 调用子类的析构器。它首先应该销毁它自己的资源接下来调用直接的超类的析构器,这个析构器可直接的销毁下一个资源集等等。构造是先发生在子类之前的父类上的。析构则是相反,子类要先于父类,即,环部分要先于点部分。这里,然而,什么也不需要做。
我们先前已经让Circle_draw() 工作了,我们使用可见部分,并且编码表示文件Point.r 如下:
struct Point {
const void * class;
int x, y; /* coordinates */
};
#define x(p) (((const struct Point *)(p)) -> x)
#define y(p) (((const struct Point *)(p)) -> y)
现在我们可以对于Circle_draw() 使用访问宏:
static void Circle_draw (const void * _self)
{
const struct Circle * self = _self;
printf("circle at %d,%d rad %d\n",x(self), y(self), self —> rad);
}
move() 拥有静态链接并且被从点的实现上继承。我们得出结论环的实现是通过定义仅仅全局可见Circle.c 的部分内容:
static const struct Class _Circle = {
sizeof(struct Circle), Circle_ctor, 0, Circle_draw
};
const void * Circle = & _Circle;
然而,在接口,表示式,实现文件之间似乎我们有一个可行的分配程序文本实现类的策略,点和环的例子还没有显现出一个问题:如果一个动态连接的方法如Point_draw() 在子类中没有被重写,子类的类型描述符需要指向在父类实现的函数。函数名,然而在这里被定义成static,因此选择器是不能够被规避的。我们将在第六章看到一个清晰地解决此问题的方法。作为暂时的权衡,我们在这种情况下可以避免对static 的使用,仅仅在子类的实现文件中声明函数的头,对于子类并且使用函数名去初始化类型描述。
4.8 总结
超类的对象和子类是相似的,但是在表现形式上并不相同。子类正常情况下会有更详尽的陈述更多的方法——他们被超类对象的版本专用指定。
我们使用超类对象的表示的拷贝来作为子类对象表示的开始,即,子类对象通过把它的组成部分增加到超类对象的末尾被表示。
一个子类继承了超类的方法:因为一个子类对象的起始部分看起来像超类对象,我们可以上抛并且看到一个指向子类对象的指针作为一个指向我们能够传递超类方法的超类对象。为了避免显性转换,我们使用void*作为通用指针来声明所有方法的参数。
继承可以被看成一个多态机制的根本形式:一个超类方法接受不同类型,它自己的类和所有子类命名的对象。然而因为对象都佯装成超类对象,方法仅仅在每个对象的超类部分起作用,并且它将,因此从不同的类对于对象不会起不同的作用。
动态链接方法能够从一个超类继承或在子类中重写——对于子类通过无论何种函数的指针被放进类型描述符来决定。因此,对于一个对象如果动态链接方法被调用,我们总能够访问属于对象真正的类的方法即使指针上抛到一些超类上。如果动态链接方法被继承,它只能在子类对象的超类部分起作用,因为它的确不知道子类的存在。如果一个方法被重写,子类的版本能够访问整个对象,他甚至可以通过显性的超类的类型描述符的使用来调用它关联的超类的所有方法。
特别注意,对于超类的表示,构造器首先回调超类的构造器直到最终的祖先以便于每个子类的构造器仅仅处理它自己的对类的扩展。每个超类析构器应该先删除它的子类的资源然后调用超类的析构器等等直到最终的祖先。构造器的调用顺序是从祖先到最终的子类,析构器的发生则正好是相反的顺序。
我们的策略还是有点小毛病的:在通常情况下我们不应该从一个构造器中调用动态链接方法,因为对象也许并没有完全被初始化好。在构造器被调用之前new() 把最终的类型描述符插入到一个对象中,作为一个构造器在相同的类中是没有必要的访问方法的。安全的技术是在相同的类中对于构造器通过内部的名字来调用方法,也就是说,对于点,我们调用Points_draw()而不是draw() 。
为了鼓励信息隐藏,我们使用了三个文件对类的实现。接口文件包含了抽象的数据类型描述,表示文件包含了对象的结构,实现文件包含了方法和初始化类型描述的代码。一个接口文件包含了超类接口文件并且被实现和任何应用所包含。一个表示文件包含了超类的表示文件并且仅仅被实现所包含。
超类的部分不应该直接的在子类中被引用。相反,对于每个部分我们能够既提供静态链接访问和尽可能的修改方法,也能对于超类的表示文件增加适当的宏。函数符号使得使用文本编辑器或调试器去跟踪可能的信息泄露或不变量的破坏更简单。
4.9 是或有吗?——继承对集合
作为struct Circle 我们对环的表示包含了对点的表示:
struct Circle { const struct Point _; int rad; };
但是,我们自然绝冬不去直接的访问者部分。相反,当我们想要继承我们从Circle 上抛到Point 并且在这里处理struct Point 的初始化。
这里有另外一个表示环的方式:它能包含一个点作为一个集合。我们能够仅仅通过指针来处理对象;因此这样的一个环的表示看起来就像如下所示:
struct Circle2 { struct Point * point; int rad; };
这个环一点也不像一个点,也就是说,它不能够从Point 所继承并且重用它的方法。然而,它能够把点的方法应用到点的部分;它仅仅不能把点的方法用于它自己。
如果一种语言对于继承有明确的符号,差异就会更加明显,相似的表示在C++ 中会有如下的表示:
struct Circle:Point{int rad;}; //inheritance
struct Circle2{ struct Point point;int rad}; //aggregate
在C++ 中作为一个指针我们是不必要访问对象的。
继承,即,从超类来建立子类,而集合,即,把对象的一部分作为另外一个对象的一部分,提供非常相似的功能。这些应用在特殊的设计中通常被所 is-it-or-has-it?的测试所决定:如果一个新类的一个对象仅仅像一些其他类的对象,我们应该使用继承来实现新的类;如果一个新类有一个其他类作为它的状态的一部分对象,我们应该建立集合。
到我们的点所关注的,一个环仅仅是一个大的点,这就是为什么我们使用继承来做一个环的原因。一个方形是一个不明确的例子:我们能通过一个参考点和边的长度来描述它,或我们能够使用端点的对角线或甚至三个角来描述。仅仅带参考点是方形的几分花哨点;其他表示通向集合。 在我们的算术表达式中,我们已经使用了继承从单目到双目操作节点,但是这已经充分的违背了测试。
4.10 多重继承
因为我们使用平凡的标准化C语言。我们不能够隐藏这样的事实——继承意味着在另一个结构的开始包含一个结构体。利用上抛是在子类的对象上重复利用超类方法的关键所在。通过投掷一个结构体起始的地址完成一个从环岛到点的上抛;指针的值并没有改变。
如果我们在其他结构中包含两个及以上的结构体,并且如果我们愿意在上抛期间做一些地址的处理,我们可以称这样的结果为多重继承:一个对象能够像它属于几个类一样了表现。优点似乎是我们不必很仔细的设计继承的关系——我们可以很快的把类仍到一起并且继承我们希望继承的任何东西。缺点是,显然,在我们能够重用方法之前我们得有地址处理机制。
事情能够实际的很快让我们感到迷惑。思考一个文本,一个方形,每一个都有一个继承的引用点。我们能够把他们一起扔到一个按钮上——仅仅存在的问题希望这个按钮应该继承一个或两个引用点。――――――――――――――――――――――
我们使用标准化C语言拥有很大的优点:它会使这样的事实很明显,即,继承——多重或其他总是伴随着包含而进行。包含,然而也能作为集合被实现。与复杂化语言定义和增加过量实现相比多重继承对于程序员来说要做的更多,这一点也不清晰。我们将使得事情变得简单兵器只做简单的继承。第14章将首要展示多重继承的使用,库的合入能够被集合和消息转换所实现。