我们已经准备好实现circle类的完整表示,这里我们可以选择前边几节中我们喜欢的无论什么技术。面向对象规定我们需要一个构建子,或许还要一个析构子,\verb|Circle_draw()|,和一个类型描述Circle来绑定到一起。为了联系我们的方法,我们包含Circle.h并且添加如下行到4.1节中的switch中:

  1. case 'c':
  2. p = new(Circle, 1, 2, 3);
  3. break;

现在我们可以观察下面的测试程序的行为:

  1. $ circles p c
  2. "." at 1,2
  3. "." at 11,22
  4. circle at 1,2 rad 3
  5. circle at 11,22 rad 3

这个圆构建子接收到三个参数:首先是圆心的坐标,然后是半径。初始化点的部分是点构建子的工作。它取走\verb|new()|参数列表的一部分,圆构建子使用留下的参数列表,从之初始化半径。

一个子类构建子应当首先让父类构建子做把内存区域变成父类对象的初始化部分。一旦父类的构建子完成了,子类的构建子完成初始化并且把父类对象变成子类对象。

对于圆类来说,这意味着我们需要调用\verb|Point_ctor()|。就像所有动态绑定的方法一样,这个函数被声明为static并如此隐藏在Point.c中。然而,我们仍然可以通过类型描述符Point获取该函数,它在Circle.c中可用:

  1. static void * Circle_ctor (void * _self, va_list * app) {
  2. struct Circle * self =
  3. ((const struct Class *) Point) -> ctor(_self, app);
  4. self->rad = va_arg(* app, int);
  5. return self;
  6. }

为什么我们传递app的地址而不是\verb|va_list|的值本身给每个构建子现在应该清楚了:\verb|new()|调用子类构建子,子类构建子又调用父类构建子,等等。最基本的构建子是第一个实际做事情的,并且它获取传递给\verb|new()|参数最左边的参数。剩下的参数留给接下来的子类,等等如此直到最后一个,最右边的参数被最终的子类取走,也就是说,通过\verb|new()|直接调用的构建子。

销毁过程最好是刚好相反的顺序:\verb|delete()|调用子类构建子。它应当只销毁自己的资源并接着调用它的直接父类析构子,可以销毁接着的资源等等。构建发生在子类比父类晚,析构则相反,子类在父类之前,圆部分在点部分之前。然而,这里并没有什么要做的。

我们之前有写过\verb|Circle_draw()|。我们使用可见成员并且编码表示如下:

  1. struct Point {
  2. const void * class;
  3. int x, y; /* coordinates */
  4. };
  5. #define x(p) (((const struct Point *) (p)) -> x)
  6. #define y(p) (((const struct Point *) (p)) ->y)

现在我们可以使用访问宏在\verb|Circle_draw()|中:

  1. static void Circle_draw (const void * _self) {
  2. const struct Circle * self = _self;
  3. printf("circle at %d,%d rad %d\n",
  4. x(self), y(self), self->rad);

\verb|move()|被静态绑定并且从点的实现中继承下来。我们推断出圆的实现,通过定义类型描述只在Circle.c中全局可见:

  1. static const struct Class _Circle = {
  2. sizeof(struct Circle), Circle_ctor, 0, Circle_draw
  3. };
  4. const void * Circle = & _Circle;

看起来我们有一个切实可行的策略来分发程序文本在类接口中的实现,表示和实现文件,圆和点的例子没有表现一个问题:如果一个动态绑定的方法例如\verb|Point_draw()|没有在子类中重写,子类描述符需要指向父类中函数的实现。而这里函数名被声明为static,所以选择子不能被避免。我们将会看到一个干净的解决方案给这个问题在第六章。作为一个临时方式,我们将避免使用static在这个情况下,声明函数头只在子类的实现文件中,并且使用这个函数名来初始化子类的类型描述。

总结

父类和子类的对象在行为上相似但不是相同。子类对象一般有更复杂和丰富的方法—-它们是特殊化的父类对象。

我们从一份父类对象的表示的副本开始子类对象的表示,也就是说,一个子类对象通过添加成员到父类对象的末位来表示。

子类继承父类的方法:因为子类对象的开始部分看起开就是父类对象,我们可以向上类型转换并把指向子类对象的指针当作指向父类对象的指针传给父类方法。为了避免强制类型转换,我们声明所有的方法参数为\verb|void *|作为通用指针。

继承可以看作是多态的基本形势:父类方法接受不同类型的对象,也就是它本身的类型和所有子类。然而,因为对象都看起来象父类,这些方法仅仅作用在每个对象的父类部分,并且无差异的作用于不同的类。

动态绑定方法可以被子类继承或者重写—-这取决于子类中使用了什么样的函数指针在类型描述中。因此,如果一个动态绑定的方法被一个对象调用,我们总是搜索属于对象真类的方法即使这个指针被向上转换到一些父类。如果一个动态绑定的方法被继承,它只能操作子类对象的父类部分,因为它不知道子类中有什么。如果一个方法被重写,子类版本可以访问整个对象,并且它可以调用它对象的父类方法通过显式的使用父类类型描述。

特别的,构建子应当调用父类构建子直到祖先,如此每个子类构建子只处理它自己的类扩展到它的父类表示。每个子类析构子应当移除子类的资源然后调用父类析构子等等直到祖先。构建从祖先到最后子类,析构顺序相反。

我们的策略有点问题,一般的我们不应该调用动态绑定的方法从构建子因为对象可能没有被完全初始化。在构建子调用之前,\verb|new()|插入最终类型描述到对象中。因此,如果一个构建子对一个对象调用一个动态绑定的方法,它将没有必要到达同一个类的方法作为构建子。安全的技术将会是构建子调用方法通过同一个类的内部名称,也就是说,对点来说调用\verb|Points_draw()|而不是\verb|draw()|。

为了鼓励信息隐藏,我们用三个文件实现一个类。接口文件包含抽象数据类型描述,表示文件包含对象的结构,实现文件包含方法和初始化类型描述的代码。一个接口文件包含父类接口文件并被实现文件和应用程序包含。表示文件包含父类表示文件并只被实现包含。

父类的成员不应当在子类直接被引用。相反的,我们可以提供静态绑定的访问和可能具有的修改方法对每个成员,或者我们可以添加适当的宏到父类的表示文件中。功能标记使得更容易的使用文本编辑器或者调试器来扫瞄可能的信息泄漏或者不变量的摧毁。

是或者具有—继承vs聚集

我们对于一个圆的表示包含了点的表示作为struct Circle的第一部分:

  1. struct Circle { const struct Point _; int rad; };

然而,我们志愿决定不直接访问这个成员。相反的,当我们想要继承,我们向上类型转换从Circle回到Point并且处理struct Point的初始化在那里。

这里有另一种表示圆的方式:它可以包含一个点作为聚集。我们可以只通过指针处理对象,也就是说,它不能从Point继承并重用它的方法。它可以使用点的方法到它的点的成员;它只是不能使用点方法到自身。

如果一个语言具有明确的继承语法,区别就更加明显了。相似的表示在C++中如下:

  1. struct Circle : Point { int rad; }; // inheritance
  2. struct Cicle2 {
  3. struct Point point; int rad; // aggregate
  4. };

在C++中我们没必要只作为指针访问对象。

继承,也就是说,从父类做一个子类,聚集,也就是说,包含一个对象作为其他对象的成员,提供了非常相似的功能。在一个特别的设计中使用那种途径可以通过是它或者具有它来测试:如果一个新类的对象只是像其他类的对象,我们应当使用继承来实现新类;如果一个新类的对象具有一个其他类的对象作为它的状态的一部分,我们应当使用聚集。

只要提到我们的点,一个圆只是一个大的点,这就是为什么我们使用继承在制作圆。一个矩形是一个不清楚的例子:我们可以通过一个引用点和边长来描述它,或者我们可以使用对角线或者三个角的终点。只有一个参考点是一个矩形角某种奇特的点;其他的表示导致聚集。在我们的算术表达式,我们可以使用继承来从一个一元到一个二元运算符节电,但是那将会违背测试。

多重继承

由于我们使用ANSI-C,所以我们不能隐藏继承的事实就是包含一个结构体在另一个的开始。向上类型转换是子类使用父类方法的关键。向上类型转换从圆回到点通过转换结构体开始的地址;地址的值没有发生变化。

如果我们在其他的结构体中包含两个或者更多的结构体,并且我们想要通过操作地址做一些向上类型转换,我们可以称这样的结果为多重继承:一个对象可以表现得如同它属于多个其他类。优点是我们不必要考虑非常小心的设计继承关系—-我们可以快速的把类扔在一起并且继承需要的。缺点就是,明显的,在我们可以重用父类的方法之前,必须要进行地址操作。

事情很快就会变得迷惑不清。考虑一个文本和一个矩形,每个都有一个继承的引用点。我们可以把它们扔在一起作为一个按钮—-唯一的问题是按钮应当继承一个还是两个引用点。C++允许任意一种方法步法在构建和向上类型转换中。

我们使用ANSI-C做每件事情的方式都有一个好处—-它不会混淆继承的事实—-多重或者其他—-总是通过包含发生。包含,也可以通过聚集完成。并不是完全清楚多重继承给程序员帮助更多而不是复杂语言定义和增加实现负担。我们将保持事情的简单并且继续使用简单继承。第十四章中将会展示一个多重继承的原则,库合并,总是可以通过聚集和消息传递实现。

练习

图形编程提供了许多继承机会:一个点和一个边长定义一个正方形,一个点和一对偏移定义了一个矩形,一条线段,或者一个椭圆;一个点和一个数组偏移对定义一个多边形甚至花键。在我们处理所有这些类之前,我们可以制作更具智能的点通过添加文本和相关位置,或者通过引入颜色或者其他可视属性。

把\verb|move()|作为动态绑定是困难的,但是可能是有趣的:锁定的对象可以决定保持它们的点的引用不变并只移动它们的份额。

继承可以在许多领域中发现:集合,包和其他集合例如列表,栈,队列等等。字符串,原子和具有变量名和值的是其他的类。

父类可以被用于包装算数。如果我们假定存在动态绑定方法来比较和交换一个集合的元素基于一些正的索引值,我们可以实现一个父类包含一个排序算法。子类需要实现比较和交换它门的对象在数组中,但是它们继承排序的能力。