8.2 设计Diksam中的类
在成功将源文件分割后,接下来就要考虑类的设计了。
8.2.1 超简单的面向对象入门
设计类一定会考虑到数组(array)。但是,考虑到本书的目标读者是掌握C语言,并具有一定代码阅读能力的程序员,因此,我觉得这里突然开始类和面向对象的话题好像不太合适。另外,面向对象的相关用语在不同语言中也不尽相同 [4],因此,本节将要讲解的是包含用语含义在内的一些简单的面向对象概念。
Diksam的面向对象与Java、C++和C#相同,都是基于类的面向对象。类(class)近似于C中的结构体类型,但是与其关联的动作(函数)可以保存在类中,被称为 方法 (method),数据成员被称为字段(field)。
class Point {
double x;
double y;
// 定义Point的方法print()
void print() {
println("(x, y)..(" + this.x + ", " + this.y + ")");
}
}
方法以 p.print()的形式调用。如果是C语言的话,第1个参数要传入指向 Point的指针。但是,在面向对象的语言中,每个类都拥有不同的命名空间,其优势在于(与C语言相比)无需特别注意命名。
之前说到了类“近似于C中的结构体”,在C语言中声明结构体的类型时,并不会为其创建内存空间。与此相同,Diksam中也需要使用 new来创建内存空间,相当于C 语言中的 malloc() 操作。被new 创建出来的叫作对象(object)或者实例(instance)。
Point p = new Point();
Diksam中的类全部属于引用类型,因此上面代码中的 p相当于C语言中的指针。但是,如果要引用字段或者方法,不是使用 ->而是使用 .(和Java等语言相同)。
在Diksam这样的面向对象语言中,可以使用 继承 (inheritance)的方式为类添加字段或者方法。例如,在制作一个二维图形绘制工具时,要定义一个代表图形的类 Shape。在 Shape中保存着“颜色”等所有图形都共有的属性。又如,“开始和结束坐标”是直线(Line)中特有的数据。因此,如果在定义 Line的时候继承 Shape,那么 Line将既具有 Shape的“颜色”属性,又具有自己特有的“开始和结束坐标”属性。
与 此 相 似,crowbar 和 Diksam 的 源 代 码 中, 在 Expression 结 构 体和 Statement 结构体中的实现方法是“使用枚举类型区分不同种类(数据类型),将每个种类的数据保存在共用体中”。可见,在C语言中想要实现这个功能,必须在程序员的层面“约定”,但是,对于面向对象的语言来说,它本身就能够显式地提供此功能。
如 此一来,在Line继承Shape的时候,Shape被称为Line的超类,Line被称为Shape 的子类。根据不同语言超类也叫作父类(parent class)和基类(base class),子类也叫作孩子类(child class)和派生类(derived class)。另外也有“把类沿着超类方向追溯到的所有类称为祖先(ancestor),并把子类方向的所有类称为子孙(descendant)”的说法。
子类的引用通常可以赋值给类型为父类的变量,也就是说 Line 或者 Circle(圆)可以赋值给 Shape。例如,有一个 Shape类型的数据,其中可以保存 Line、 Circle、 Rectangle(矩形)等图形。
然后,为了描述数组中的所有图形,为 Shape 添加 draw() 方法(可以不实现)。
// abstract和virtual的话题将在后面叙述
abstract class Shape {
(中间省略)
//声明没有实现的draw()方法
abstract virtual void draw();
}
在每个子类中 覆盖 (override)这个方法。
// 继承了Shape的Line类的定义
class Line : Shape {
(中间省略)
override void draw() {
//Line的描绘处理
}
}
基于上面这些代码,数组中保存的Shape将以如下方式依次调用 draw()方法,如果是 Line 的话调用 Line 的描绘方法,如果是 Circle 的话则调用 Circle的描绘方法。这种特性被称为多态(polymorphism)。
Shape[] shape_array;
// 假设已经设置过shape_array的值了
for (i = 0; i < shape_array.size(); i++) {
shape.draw();
}
与在crowbar和Diksam中的做法相似,C语言中如果也使用枚举类型和共用体实现“模拟继承”的话,就必须进行“根据枚举类型用switch case判断分支”的处理了(比如Diksam 的 fix_expression 就包含一个巨大的 switch case)。编写 switch case 本身倒是不成问题,问题在于当枚举类型的种类增加的时候,就不得不去修改分散于各处的 swith case。特别是 Shape,当增加图形种类的需求越来越强烈的时候,这个问题就变得尤为突出了。在使用了多态后,即使图形的种类增加了,在上述示例代码中也没有必要修改 Shape的 draw()方法的调用位置,也就不必再次进行编译了 [5]。
就像上面说到的,子类的引用通常可以赋值给类型为超类的变量(此处发生的自动类型转换被称为 向上转型 ,即up cast),但是这并不意味着超类的引用能够赋值给子类的变量。 Line必然是 Shape,但 Shape不一定是 Line(说不定是 Circle 或 Rectangle 呢)。然而,在Diksam、Java、C#、C++ 中都有强制将 Shape 转换(即 向下转换 ,down cast)为 Line 的手段(在向下转换时有可能会发生错误)。
在Diksam、Java和C#中,除了类之外,还有 接口 (interface)的概念。它类似于只有方法的声明的类。
例如, Line和 Circle可能会有在调试时显示坐标等信息的需求。如果只考虑 Shape的话,为 Shape添加 print()方法并由各图形进行覆盖就可以达到目的了,但是如果考虑到下面的情况呢?
想要制作一个void debug_write (Printable obj)函数以便控制程序“只在调试模式时输出”。
传递给这个函数的参数对象也许和 Shape之间并没有任何继承关系。
这种情况下就可以使用接口了。
interface Printable {
void print();
}
有了上面的接口后,各个类都可以对其进行实现,每个类都会有“ print自己的内容”这个功能的通用接口(实际的编写方法请参考8.2.4节)。
Diksam中的类只能进行单继承(single inheritance),即一个类只能对应一个超类。这种方式在Java和C#中相同。但是,在C++中是允许多继承(multiple inheritance)的。
关于接口,Diksam是允许多继承的。这一点也和Java、C#相同(C++允许类的多继承,因此一般情况下不会出现接口)。
基于类的面向对象大概就介绍到这里,没有介绍到的部分请大家参考市面上其他的参考书吧。
8.2.2 类的定义和实例创建
Diksam中类的设计基本上采用了C++、Java、C#的方式,虽然更像是由C++派生出的类型的语言,但是也有意地改变了一些地方。
作为类定义的典型例子,首先让我们考虑一下在二维坐标上保存一个点的类 Point(代码清单8-2)。
代码清单8-2 Point类的定义
1: require diksam.lang;
2:
3: public class point {
4: private double x;
5: private double y;
6:
7: double get_x() {
8: return this.x;
9: }
10: void set_x(double x) {
11: this.x = x;
12: }
13: // 由于篇幅限制,省略get_y(),set_y()
14:
15: void print() {
16: println("x.." + this.x + ",y.." +this.y);
17: }
18: // 默认的构造函数的名称是“initialize”
19: constructor initialize(double x, double y) {
20: this.x = x;
21: this.y = y;
22: }
23: }
24:
25: // 创建Point的实例
26: Point p = new Point(10, 20);
27:
28: // 显示p的内容
29: p.print();
虽然看上去和Java等语言的类差不多,但是Diksam的类有以下这些不同点。
1. 引用成员时必须使用this.
看了第8行应该能明白,作为返回值返回成员变量(字段) x时写作 this. x。在Java等语言中虽然可以用这种写法,但如果只写 x也可以引用到这个字段。可是在Diksam中,不显式地写上 this.的话就不能引用属于类自身的字段和方法。这里是有意为之。
类是多个方法的集合体,有时候也可能会制作一个相当巨大的类。虽然在Java等语言中的做法是为了能够简单地引用到字段,但是随着类的增大,字段会慢慢呈现出全局变量的样子。
例如。 java.awt.Component的源代码大约有8500行(JDK5.0),在其中能够自由地引用像 x、 y这么短名字的属性,这对我来说简直就是自杀行为。
因此在Diksam中,即使是在类内部引用类的成员,也必须使用 this.。
这里采用了和Python一样的语法,可能有些人讨厌这样,但是我挺喜欢的。
2. 成员的访问修饰符只有 public 和 private ,没有protected
在Diksam中 public是从包外部可以访问的意思。第3行的 class 前面附加的 public 就是这个意思。第4~5行为成员设定的 private 是不能从本类以外访问的意思。如果同时加上 public 和 private 的话,就只有当前包内可以引用。
而且,通过代码清单8-2就应该能理解,在Diksam中类的访问修饰符 protected是不存在的。这样做的原因将在后面详细介绍,但是基本的思路是,不要让因为别人有可能会制作子类,成为放松访问限制的借口。
3. 构造方法要使用 constructor 修饰符
构造方法(constructor)是在创建类的实例时调用的方法,通常会在里面编写类的初始化处理。
Java、C++、C#等语言的构造方法必须和类名相同,可以使用方法重载(method overload)实现多个构造方法。方法重载就是多个方法名称相同,但参数的数量和类型不同,内部处理也不同(但编程语言会认为它们是一个函数) [6]。
但是,方法重载是混乱的根源 [7],毕竟不是什么都能用重载来解决的。例如,在代码清单8-2 的 Point 类中,将直角坐标系的 x,y 传递给构造方法。但是如果是使用极坐标的应用程序,恐怕就要给构造函数传入θ(偏角)和ρ(极径)了。但不论是直角坐标系的 x,y,还是极坐标的θ、ρ,都是两个 double 的组合,因此在方法重载上无法区分(这个例子中指出了后面将要介绍的OOSC[8]的问题)。总之,“构造方法要和类的名字相同”这种设计本身,我认为是不合理的。
因此在Diksam中为构造方法加上了 constructor关键字,在 new的时候可以指定任意的构造方法。
代码清单8-2的第26行省略了方法名。这种情况下,会直接调用默认构造方法 initialize()(第19行)。如果第26行写成下面这样:
Point p = new Point.myinit(x, y);
就会直接调用被指定的 myinit()方法代替原来的 initialize()方法。
在定义类的时候,如果没有定义任何的构造方法的话,编译器会自动添加一个默认构造方法(default constructor),如下所示:
public virtual override constructor initialize() {
super.initialize(); ←只有在有超类的时候才有这行语句
}
4. 没有 static 的字段和方法
在Java、C++和C#中,使用 static关键字可以定义一个与实例无关的字段或者方法。与实例无关的意思就是,除了访问域的问题外,其他方面与全局变量或者函数完全相同。在Diksam中一般都会写成全局变量或者函数,因此抛弃了 static的字段或者方法。
8.2.3 继承
说起类当然会提到继承。代码清单8-3就是Diksam中继承的示例代码。 Point2继承了 Point1并重写了 print()方法。
代码清单8-3 Diksam中的继承
1: require diksam.lang;
2:
3: // 不使用abstract关键字的类不能被继承
4: abstract class Point1 {
5: double x;
6: double y;
7:
8: // 不使用virtual关键字的方法不能被重写
9: virtual void print() {
10: println("x.." + this.x + ", y.." + this.y);
11: }
12: // 构造方法不使用virtual也不能被重写
13: virtual constructor initialize(double x, double y) {
14: this.x = x;
15: this.y = y;
16: }
17: }
18:
19: // 继承时没有使用Java的extends关键字,而是使用了C++/C#的":"
20: class Point2 : Point1 {
21: // 进行重写的时候要使用override关键字
22: override void print() {
23: println("override: x.." + this.x + ", y.." + this.y);
24: }
25: // 构造方法也可以被继承和重写
26: override constructor initialize(double x, double y) {
27: this.x = x + 10;
28: this.y = y + 10;
29: }
30: }
31:
32: // 给Point1的变量p赋值为Point2的实例
33: Point1 p = new Point2(5, 10);
34:
35: // 由于方法被重写了,所以调用的是
36: // Point2的print()方法
37: p.print();
代码清单8-3的执行结果如下:
overrided: x..15.000000, y..20.000000
通过这样一个结果,能看出如下特征:
1. 方法默认为non virtual
在第8行为想要被重写的 print()方法添加了virtual关键字。在Diksam中类会被默认定义为不可被继承(在Java中为final)的,只有显式地添加关键字virtual,方法才可以被继承。这里的设计和C++、C#一致。
关于这点我想还存在很多异议,详细内容我会在后面的章节中说明。
2. 重写时必须使用 override 关键字
第21行,Point2重写Point1的 print()方法时在前面添加了override关键字(与C#相似)。如果不加override,又定义了和超类同名的方法的话,就会发生错误。
这点的根据是“重写必须显式进行”的原则,还得到了一个附带的好处,就是如果误定义了函数名,会发生编译错误。
3. 使用 :继承
在第18行中定义了继承 Point1的 Point2类。之所以没有使用Java的关键字 extends,是因为不想让继承的关键字太长,因此使用了C++/C#的“:”。
4. 构造方法也可以被继承和重写
在Java、C++、C#中,构造方法都是与类名相同的,因此构造方法不能被继承(但是可以通过 super() 调用)。另外,从语法角度讲也不可能重写构造方法。
但是,在Diksam中可以任意决定构造方法的名字,我认为能够重写构造方法也不是坏事 [8]。因此,在Diksam中构造方法也可以被继承和重写。基于这个设计,就可以把在超类中定义的构造方法看作是默认实现。
5. 只有abstract的类可以被继承
第4行,为类 Point1 添加了 abstract 关键字。添加了 abstract 的类(抽象类)只是为了被继承而存在的类,抽象类本身不能被实例化。
因此,在Diksam中抽象类以外的类(具象类,concrete class)不能被继承。
对于这个限制,可能大多数人会持否定的态度。但是,这个设计并不是为了让实现起来更简单而妥协的结果,是有意为之。至于为什么要这么做,我会在后面的章节中说明。
8.2.4 关于接口
与Java、C#一样,Diksam的类也只能单继承。能够多继承的只有接口(代码清单8-4)。
代码清单8-4 接口的定义
1: require diksam.lang;
2:
3: interface Printable {
4: void print();
5: }
6:
7: class Point : Printable {
8: double x;
9: double y;
10:
11: override void print() {
12: println("x.." + this.x + ", y.." + this.y);
13: }
14: constructor initialize(double x, double y) {
15: this.x = x;
16: this.y = y;
17: }
18: }
19:
20: Printable printable = new Point(10, 20);
21:
22: printable.print();
在这个例子中,定义了一个具有 print() 方法的接口 Printable,并且在 Point类中对其进行了实现。
在多继承的时候,使用逗号分隔多个接口名。此处的设计和C#一样(其实,除了没有 implements 关键字之,其他的和Java 都差不多),没有什么特别的创新。
但是,在Diksam中,接口是不能继承接口的。因为接口间的继承通过在实现类中继承多个接口就能达到一样的效果,所以这在现阶段还不是必须要做的事情。
8.2.5 编译与接口
在Diksam 中,如果 require 了只有某个函数签名声明的.dkh 文件,那么也可以编译通过。接下来,在函数执行的时候会加载定义了其实现的.dkm文件,并通过动态编译功能进行编译。
对于类的方法来说,并没有特别地提供这种机制,但是使用接口也可以实现设计和实现的分离。
首先,在.dkh文件中实现定义接口和返回接口对象的函数(代码清单8-5)。
代码清单8-5 classsub.dkh
1: // 在.dkh文件中定义接口
2: public interface ClassSub {
3: public void print();
4: }
5:
6: // 定义实现接口的返回接口对象的函数
7: ClassSub create_class_sub();
然后,在对应的.dkm文件中,定义实现接口的类以及返回类实例(通过 new)的函数(实现在.dkh文件中的所有签名声明)。
这样一来,在调用 create_class_sub()函数的时候就会发生动态加载。在此之前, classsub.dkm都不会被加载。
8.2.6 Diksam怎么会设计成这样?
本节将介绍Diksam的面向对象为什么会设计成现在这样。如果只想了解实现方法的人可以跳过本节。
C++为我们提供了以下这些不错的参考。
C++的方法默认为non virtual。这是一个略微提升效率却牺牲了类扩展性的万恶设计。
在 C++中创建一个类时要尽量在方法前面加上protected virtual。
面向对象的圣经之作《Object-Oritented Software Construction》(简称OOSC[8])中对C++作了如下评价:
在进行声明的时候是否必须使用 virtual的意思就是,必须要有明确的约束策略(静态约束还是动态约束)。而这个策略违反了开放/闭锁原则。(中间省略)
C2(不明确标示 "virtual"的时候,默认使用静态约束)更加恶劣,很难看出这种做法在语言设计上的正当性。正如上面说到的,让静态约束和动态约束拥有不同的含义,这个选择本身就是错误的,在此基础上还要进行默认选择,更是错上加错了。
真是个灾难。
在C++ 之后问世的Java 改成了默认 virtual(在non virtual 的时候要使用 final)。
然而,在Java之后出现的C#中又改回了默认non virtual。关于这点,我(通过网络等途径)也听过不少严厉的批评,但C#的作者Anders Hejlsberg作出了如下回应:
There are two schools of thought about virtual methods. The academic school of thought says, “Everything should be virtual, because I might want to override it someday.” The pragmatic school of thought, which comes from building real ap-plications that run in the real world, says, “We’ve got to be real careful about what we make virtual.”
翻译:
在virtual方法的问题上存在两派。学术派主张“任何事物都应该是virtual的。因为有一天我可能需要重写它”。与此相对,实用派主张构建在真实世界中运行真实的应用,他们认为“在进行virtual的时候,本来就应该引起注意”。
说到这里我想起来,《OOSC》的作者Bertrand Meyer是大学教授。
方法的重写,替换了一部分在超类中实现的行为。如果不是从一开始就决定了要替换的目标方法,而是随意地重写方法的话,那么在之后超类进行升级等改变时,就无法保证子类的正常运行。总而言之,应该慎重对待这种随便给别人的类打补丁的行为。
基于以上这些,更进一步地让我有了“一般情况下,类应该默认为不可继承”的想法。
下面我要介绍一下在这个问题上“实用派”的一些想法。
《Effective Java中文版(第2版)》[9]第15章中“要么专门为继承而设计并给出说明文档,要么禁止继承”说道:
这不仅仅是理论性的问题,如果不是专门为继承而设计并给出相应文档,又非 final的具象类,一旦修改了内部,就要承受与其所有子类关联的地方都有可能发生bug的后果。
这个问题的最佳解决方案是,对于那些并非为了安全地进行子类化而设计和编写文档的类,禁止其子类化。
《设计模式:可复用面向对象软件的基础》[10]日文第1版p.31
继承使子类获得了父类里实现的详细内容,因此说“继承破坏了封装性的概念”[Syn86]。子类的实现和父类的实现有着紧密的联系,因此改变父类的实现会对子类产生非常强烈的影响。
(中间省略)
对于这点有一个挽救的方法,就是只继承抽象类。
对于“只继承抽象类”这个方法,其实在Diksam中已经实现了。
举个例子,在UNIX的经典GUI工具包Motif的类层级中,PushButton(常被称为GUI的按钮)是 Label的子类。Label具有显示一个字符串的功能,在定义了继承Label的 PushButton时,可以复用 Label中的一些实现。
但是,后来出现的Java的AWT中, Label和 Button之间就不存在继承关系了。并且,在Swing中作为 JButton、 JMenuItem、 JToggleButton的超类都引入了 AbstractButton抽象类。
这样虽然会减少复用,却让随意地继承 Label(像Motif)的方式成为了过去。如果有通用的部分,就明确地定义抽象类,这种做法可以说是非常现代化的方式。
从我的自身经验来讲,从零开始设计的部分,我从来没有想过要它继承具象类。实际上在使用现存的C++类库 [9]时,会有“这个方法如果是virtual的就要重写”的想法。这样一来,(对头文件来说)直接参考源代码去研究重写的时候,会在不经意间陷入程序库的实现中。这与“把字段写成 public”的想法一样,属于不健康的想法。
顺着这个思路,那么“仅子类可以访问”这个访问修饰符 protected也可以不需要。
至少在C++、Java和C#的思路中,访问修饰符是为了对自己(或自己的团队)之外的开发人员隐藏实现而使用的(应该可以作为佐证的是,如果是同一个类,那么即使是不同的实例,也可以相互引用 private 成员)。如果是这样的话,也就没有理由减弱子类的访问限制了。但如果是自己创建的子类的话,可以在包范围内进行访问(与Java一样,Diksam也将包范围作为默认项)。如果想要别人也可以创建子类的话,那就必须要公开了 [10]。
本节虽然介绍了不少内容,但仅代表我个人观点。而且我一直认为自由地决定设计方案是语言作者的特权,因此大家在将来制作属于自己的编程语言时,也可以采用你们自己认为最好的方式。
8.2.7 数组和字符串的方法
在7.3.4节中介绍过,在Diksam book_ver.0.2中没有取得数组大小和字符串长度的方法(method)。在引入了类的概念后,现在我们来实现这个方法。
要实现的方法如表8-1所示。
表中的 T表示数组元素的类型,也就是说,可以使用 insert()插入使用代码 double[] a;声明的数组,但只能是 double型(这是当然的啦)。
表8-1 数组和字符串的方法
(续)
但由于在Diksam中数组和字符串并不是类,因此不能创建出继承它们的类。
8.2.8 检查类的类型
Diksam和Java一样使用 instanceof运算符。
instanceof运算符用于判断某个实例是否属于某个类。
例如,继承了 Shape的 Line和 Circle。
Shape shape = new Line();
上面的语句给 shape 赋值了 Line 的实例,此时 shape instanceof Line返回真。当然, shape instanceof Circle返回假。
但是,虽然 shape instanceof Line 返回真,但是返回真的也不仅限于 Line 的实例。假设 Line 存在子类 ArrowLine,并且 shape 实际也指向了 ArrowLine 的话, shape instanceof Line 还是会返回真。这就是instanceof运算符“判断某个实例是否属于某个类”的含义。
instanceof 也可以用于判断实例是否实现了某个接口。 obj instanceof Printable如果为真,说明 obj实现了 Printable。
8.2.9 向下转型
Diksam可以将类向下转型。
Java、C#等语言也可以向下转型,像下面这段代码。
Shape shape = new Line();
…
Line line = (Line)shape;
只是,前置这种转换运算符的书写方式,在成员、数组、方法调用堆叠了很多层的时候,如果要进行转换,视线必须要回到左边。另外,在转型后如果要再次引用其转型后的结果的话,外面必须要加上括号。
//取得[piyo.foo[i]. bar.fuga[j]. getObj()]的对象,
//向下转型为Bazz,
//并取出其成员hoge。
Hoge hoge = ((Bazz)piyo.foo[i]. bar.fuga[j]. getObj()).hoge;
C#中存在后置转型运算符 as,但是由于优先级低,因此还是要使用括号。
由于这点不是很方便,因此在Diksam中使用了后置的转型运算符。书写方式为 ::类型名:>。上面的例子在Diksam中写成了如下代码。
Hoge hoge = piyo.foo[i]. bar.fuga[j]. get_obj()::Hoge:>.hoge;
另外,也可以实现从接口向下转型到接口。下面的例子中, Hoge 在实现了 Serializable的情况下可以成功转型。
Printable p = new Hoge();
Serializable s = p::Serializable:>;
这种做法在类的继承关系上并不存在“向下”转换。而且这个例子叫作“向下转换”是否贴切也是个问题 [11]。