学完基础后, 现在我们集中于面向对象的君土脚本 —— 本文首先提出了面向对象编程理论的基本观点, 然后介绍如何创建类, 以及如何创建对象.
从零开始面向对象的程序设计
首先,我们从高维度且简化的角度看看面向对象的程序是什么。我们将简单描述面向对象的程序,因为面向对象的程序概念已变得很复杂,如果完整地描述面向对象的程序将使读者难以理解。面向对象的程序的基本思想是:在程序里,我们通过使用对象去构建现实世界的模型,把原本很难(或不可)能被使用的功能简单化并提供出来,以供访问。
对象可以包含相关的数据和代码,这些数据和代码用于表示你所建造的模型是什么样子,以及拥有什么样的行为或功能。对象包(或者叫命名空间)存储(官方用语:封装)着对象的数据(常常还包括函数),使数据的组织和访问变得更容易了;对象也常用作数据存储体,用于在网络上传输数据,十分便捷。
定义一个对象模板
让我们来考虑一个简单的程序,它可以显示一个学校的学生和老师的信息.在这里我们不讨论任何程序语言,我们只讨论面向对象的程序思想.
首先,我们可以回到第一节拿到定义好属性和方法的人物
对象。对于一个人
来说,我们能在他们身上获取到很多信息(他们的住址,身高,鞋码,基因图谱,护照信息,显著的性格特征等等),然而,我们仅仅需要他们的名字,年龄,性别,兴趣这些信息. 然后,我们会基于他们的这些信息, 写一个简短的关于他们自己的介绍. 在最后我们还需要教会他们打招呼。以上的方式被称为抽象-为了我们编程的目标而利用事物的一些重要特性去把复杂的事物简单化.
在君土脚本中,我们用类/*class*/
的概念去描述一个对象——类并不完全是一个对象,它更像是一个定义对象特质的模板。
创造一个真正的对象
从上面我们创建的类
中, 我们能够基于它创建出一些对象 —— 一些拥有类
中属性及方法的对象。基于我们的人
这个类,我们可以创建出许许多多的真实的人:
当一个对象需要从类中创建出来时,我们可以实例化类得到对象, 类实例化时会调用类的构造函数. 这种创建对象的过程我们称之为实例化——实例对象被类实例化。
具体的对象
在这个例子里,我们不想要泛指的人,我们想要像老师和学生这样类型更为具体的人。在面向对象的程序里,我们可以创建基于其它类的新类,这些新的子类可以继承它们父类的数据和功能。比起复制来说这样能够使用父对象共有的功能。如果类之间的功能不同,你可以根据需要定义专用的特征。
这是非常有用的,老师和学生具有一些相同的特征比如姓名、性别、年龄,因此只需要定义这些特征一次就可以了。您可以在不同的类里分开定义这些相同的特征,这样该特征会有一个不同的命名空间。比如,一个学生的 问候
可以是 “嘿,我是[姓][名]” (例子 嘿,我是张明),老师的可能会正式一些,比如”你好, 我是[姓]老师” (例子 你好,我是李老师)。
注:多态——这个高大上的词正是用来描述多个对象拥有实现共同方法的能力。
现在可以根据子类创建对象。如:
下面我们来看看面向对象的程序理论如何应用到君土脚本实践中去的。
类和对象
让我们来看看君土脚本如何通过类声明来创建类。
一个简单的例子
让我们看看如何定义”人“这个类。在您的文件中添加以下代码:
类 人 {
姓名: 文;
构(姓名: 文) {
此.姓名 = 姓名;
}
问候() {
回 "你好, " + 此.姓名;
}
}
定 人1 = 启 人("张三");
定 人2 = 启 人("李四");
我们声明一个人
这个类。这个类有3个成员:一个叫做姓名
的属性,一个构造函数和一个问候
方法。
你会注意到,我们在引用任何一个类成员的时候都用了此
。 它表示我们访问的是类的成员。
最后一行,我们使用启/*new*/
构造了人
这个类的两个实例。 它会创建一个人
这个类的新对象,并执行构造函数初始化它。
让我们添加以下代码来使用创建的两个实例:
控制台.日志(人1.姓名);
人1.问候();
控制台.日志(人2.姓名);
人2.问候();
酷!您现在看到我们有两个对象,每一个保存在不同的命名空间里,当您访问它们的属性和方法时,您需要使用人1
或者人2
来调用它们。尽管它们有着相同的姓名
属性和 问候()
方法, 它们是各自独立的,所以相互的功能不会冲突。注意它们使用的是自己的 姓名
值,这也是使用 此
关键字的原因,它们使用的是从实参传入形参的自己的值,而不是其它的什么值。
再看看这个构造对象的语法:
定 人1 = 启 人("张三");
定 人2 = 启 人("李四");
上述代码中,关键字 启
跟着一个类名,用于告知系统我们想要创建一个对象,非常类似函数调用,并把结果保存到变量中。
创建我们最终的类
上面的例子仅仅是简单地介绍如何开始。让我们现在开始创建人
这个类。
- 移除掉您之前写的所有代码, 用如下类定义替代 —— 实现原理上,这与我们之前的例子并无二致, 只是变得稍稍复杂了些:
类 人 {
姓名: { 姓: 文, 名: 文 };
年龄: 数;
性别: 文;
兴趣: 文[];
构(姓: 文, 名: 文, 年龄: 数, 性别: 文, 兴趣: 文[]) {
此.姓名 = { 姓, 名 };
此.年龄 = 年龄;
此.性别 = 性别;
此.兴趣 = 兴趣;
}
描述() {
控制台.日志(此.姓名.姓 + 此.姓名.名 + '有' + 此.年龄 + '岁。他喜欢' + 此.兴趣[0] + ' 和 ' + 此.兴趣[1] + '。');
}
问候() {
回 "你好, 我是" + 此.姓名.姓 + 此.姓名.名;
}
}
接下来加上这样一行代码, 用来创建它的一个对象:
定 人1 = 启 人("张", "三", 32, '男', ['编程', '乒乓球']);
这样,您就可以像我们定义第一个对象一样访问它的属性和方法了:
控制台.日志(人1['年龄']);
控制台.日志(人1.兴趣[1]);
人1.描述();
// 等等
进一步的练习
首先, 尝试着写几行代码创建您自己的对象, 接着,尝试读取
与设置
对象中的成员。
此外, 我们的描述()
方法里仍有一些问题 —— 纵然您有更多的兴趣列举在兴趣
数组中, 描述
只会展示您的两个兴趣。 您能想出如何在类定义中解决这个问题吗? 您可以按照您喜欢的方式编写类(您可能需要一些条件判断和循环)。 考虑一下描述语句如何根据性别、兴趣列表中兴趣数目的不同, 显示不同的内容。
继承
在君土脚本里,我们可以使用常用的面向对象模式。 基于类的程序设计中一种最基本的模式是允许使用继承来扩展现有的类。
看下面的例子:
类 动物 {
移动(米制距离: 数 = 0) {
控制台.日志(`动物移动了${米制距离}米。`);
}
}
类 狗 承 动物 {
叫() {
控制台.日志('汪,汪');
}
}
常 狗0 = 启 狗();
狗0.叫();
狗0.移动(10);
狗0.叫();
这个例子展示了最基本的继承:类从基类中继承了属性和方法。 这里,狗
是一个派生类,通过承/*extends*/
关键字,它派生自动物
这个基类。 派生类通常被称作子类,基类通常被称作超类。
因为狗
继承了动物
的功能,因此我们可以创建一个狗
的实例,它能够叫()
和移动()
。
下面我们来看个更加复杂的例子。
类 动物 {
名: 文;
构(名0: 文) { 此.名 = 名0; }
移动(米制距离: 数 = 0) {
控制台.日志(`${此.名}移动了${米制距离}米。`);
}
}
类 蛇 承 动物 {
构(名: 文) { 先(名); }
移动(米制距离 = 5) {
控制台.日志("爬行...");
先.移动(米制距离);
}
}
类 马 承 动物 {
构(名: 文) { 先(名); }
移动(米制距离 = 45) {
控制台.日志("奔跑...");
先.移动(米制距离);
}
}
定 螣 = 启 蛇("螣蛇");
定 赤兔: 动物 = 启 马("赤兔");
螣.移动();
赤兔.移动(34);
这个例子展示了一些上面没有提到的特性。 这一次,我们使用承
关键字创建了动物
的两个子类:马
和蛇
。
与前一个例子的不同点是,派生类包含了一个构造函数,它必须调用先()
,它会执行基类的构造函数。 而且,在构造函数里访问此
的属性之前,我们一定要调用先()
。 这个是君土脚本强制执行的一条重要规则。
这个例子演示了如何在子类里可以重写父类的方法。 蛇
类和马
类都创建了移动
方法,它们重写了从动物
继承来的移动
方法,使得移动
方法根据不同的类而具有不同的功能。 注意,即使赤兔
被声明为动物
类型,但因为它的值是马
,调用赤兔.移动(34)
时,它会调用马
里重写的方法:
爬行...
螣蛇移动了5米。
奔跑...
赤兔移动了34米。
公共, 私有与受保护的修饰符
默认为显
在上面的例子里,我们可以自由的访问程序里定义的成员。 如果你对其它语言中的类比较了解,就会注意到我们在之前的代码里并没有使用显
来做修饰;例如,爪哇(Java)要求必须明确地使用显
指定成员是可见的。 在君土脚本里,成员都默认为显
, 可见的。
你也可以明确的将一个成员标记成显
。 我们可以用下面的方式来重写上面的动物
类:
类 动物 {
显 名: 文;
显 构(名0: 文) { 此.名 = 名0; }
显 移动(米制距离: 数 = 0) {
控制台.日志(`${此.名}移动了${米制距离}米。`);
}
}
理解隐
当成员被标记成隐
时,它就不能在声明它的类的外部访问。比如:
类 动物 {
隐 名: 文;
显 构(名0: 文) { 此.名 = 名0; }
}
启 动物("猫").名; // 错误: '启' 是私有的.
君土脚本使用的是结构性类型系统。 当我们比较两种不同的类型时,并不在乎它们从何处而来,如果所有成员的类型都是兼容的,我们就认为它们的类型是兼容的。
然而,当我们比较带有隐
或护
成员的类型的时候,情况就不同了。 如果其中一个类型里包含一个隐
成员,那么只有当另外一个类型中也存在这样一个隐
成员, 并且它们都是来自同一处声明时,我们才认为这两个类型是兼容的。 对于护
成员也使用这个规则。
下面来看一个例子,更好地说明了这一点:
类 动物 {
隐 名: 文;
构(名: 文) { 此.名 = 名; }
}
类 犀牛 承 动物 {
构() { 先("犀牛"); }
}
类 植物 {
隐 名: 文;
构(名: 文) { 此.名 = 名; }
}
定 动物0 = 启 动物("山羊");
定 犀牛0 = 启 犀牛();
定 植物0 = 启 植物("梅花");
动物0 = 犀牛0;
动物0 = 植物0; // 错误: 动物 与 植物 不兼容.
这个例子中有动物
和犀牛
两个类,犀牛
是动物
类的子类。 还有一个植物
类,其类型看上去与动物
是相同的。 我们创建了几个这些类的实例,并相互赋值来看看会发生什么。 因为动物
和犀牛
共享了来自动物
里的私有成员定义隐 名: 文
,因此它们是兼容的。 然而植物
却不是这样。当把植物
赋值给动物
的时候,得到一个错误,说它们的类型不兼容。 尽管植物
里也有一个私有成员名
,但它明显不是动物
里面定义的那个。
理解护
护
修饰符与隐
修饰符的行为很相似,但有一点不同,护
成员在派生类中仍然可以访问。例如:
类 人 {
护 名: 文;
构(名: 文) { 此.名 = 名; }
}
类 员工 承 人 {
隐 部门: 文;
构(名: 文, 部门: 文) {
先(名)
此.部门 = 部门;
}
显 得电梯游说() {
回 `你好, 我叫${此.名},我在${此.部门}部工作。`;
}
}
定 张三 = 启 员工("张三", "技术开发");
控制台.日志(张三.得电梯游说());
控制台.日志(张三.名); // 错误
注意,我们不能在人
类外使用名
,但是我们仍然可以通过员工
类的实例方法访问,因为员工
是由人
派生而来的。
构造函数也可以被标记成护
。 这意味着这个类不能在包含它的类外被实例化,但是能被继承。比如,
类 人 {
护 名: 文;
护 构(名: 文) { 此.名 = 名; }
}
类 员工 承 人 {
隐 部门: 文;
构(名: 文, 部门: 文) {
先(名)
此.部门 = 部门;
}
显 得电梯游说() {
回 `你好, 我叫${此.名},我在${此.部门}部工作。`;
}
}
定 张三 = 启 员工("张三", "技术开发");
定 李四 = 启 人("李四"); // 错误: '人' 的构造函数是被保护的.
固修饰符
你可以使用固
关键字将属性设置为只读的。 只读属性必须在声明时或构造函数里被初始化。
类 章鱼 {
固 名: 文;
固 脚数: 数 = 8;
构(名: 文) {
此.名 = 名;
}
}
定 章鱼0 = 启 章鱼("八条腿");
章鱼0.名 = "喷墨"; // 错误! 名 是只读的.
参数属性
在上面的例子中,我们不得不定义一个受保护的成员名
和一个构造函数参数名0
在人
类里,并且立刻给名
和名0
赋值。 这种情况经常会遇到。参数属性可以方便地让我们在一个地方定义并初始化一个成员。 下面的例子是对之前动物
类的修改版,使用了参数属性:
类 动物 {
构(隐 名: 文) { }
移动(米制距离: 数) {
控制台.日志(`${此.名} 移动了 ${米制距离}米。`);
}
}
注意看我们是如何舍弃了名0
,仅在构造函数里使用隐 名: 文
参数来创建和初始化名
成员。 我们把声明和赋值合并至一处。
参数属性通过给构造函数参数添加一个访问限定符来声明。 使用隐
限定一个参数属性会声明并初始化一个私有成员;对于显
和护
来说也是一样。
存取器
君土脚本支持通过 取器/置器 来截取对对象成员的访问。 它能帮助你有效的控制对于对象成员的访问。
下面来看如何把一个简单的类改写成使用取/*get*/
和置/*set*/
。 首先,我们从一个没有使用存取器的例子开始。
类 员工 {
全名: 文 = '';
}
定 员工0 = 启 员工();
员工0.全名 = "张三";
若 (员工0.全名) {
控制台.日志(员工0.全名);
}
我们可以随意的设置全名
,这是非常方便的,但是这也可能会带来麻烦。
下面这个版本里,我们先检查用户密码是否正确,然后再允许其修改员工信息。 我们把对全名
的直接访问改成了可以检查密码的置
方法。 我们也加了一个取
方法,让上面的例子仍然可以工作。
定 密码 = "保密密码";
类 员工 {
隐 _全名: 文 = '';
取 全名(): 文 {
回 此._全名;
}
置 全名(新名: 文) {
若 (密码 && 密码 == "保密密码") {
此._全名 = 新名;
} 别 {
控制台.日志("错误:没有验证,不能更新名字。");
}
}
}
定 员工0 = 启 员工();
员工0.全名 = "张三";
若 (员工0.全名) {
控制台.日志(员工0.全名);
}
我们可以修改一下密码,来验证一下存取器是否是工作的。当密码不对时,会提示我们没有权限去修改员工。
对于存取器有下面几点需要注意的:
首先,存取器要求你将编译器设置为输出欧算脚本5(ECMAScript 5)或更高。 不支持降级到欧算脚本3(ECMAScript 3)。 其次,只带有取
不带有置
的存取器自动被推断为固
。 这在从代码生成.d.ts
文件时是有帮助的,因为利用这个属性的用户会看到不允许够改变它的值。
静态属性
到目前为止,我们只讨论了类的实例成员,那些仅当类被实例化的时候才会被初始化的属性。 我们也可以创建类的静态成员,这些属性存在于类本身上面而不是类的实例上。 在这个例子里,我们使用静
定义原点
,因为它是所有网格都会用到的属性。 每个实例想要访问这个属性的时候,都要在原点
前面加上类名。 如同在实例属性上使用此.
前缀来访问属性一样,这里我们使用网格.
来访问静态属性。
类 网格 {
静 原点 = { 横: 0, 竖: 0 };
计算与原点距离(点: { 横: 数; 竖: 数; }) {
定 横距 = (点.横 - 网格.原点.横);
定 竖距 = (点.竖 - 网格.原点.竖);
回 算.方根(横距 * 横距 + 竖距 * 竖距) / 此.比例;
}
构(显 比例: 数) { }
}
定 网格1 = 启 网格(1.0); // 1倍 比例
定 网格2 = 启 网格(5.0); // 5倍 比例
控制台.日志(网格1.计算与原点距离({ 横: 10, 竖: 10 }));
控制台.日志(网格2.计算与原点距离({ 横: 10, 竖: 10 }));
抽象类
抽象类做为其它派生类的基类使用。 它们一般不会直接被实例化。 不同于接口,抽象类可以包含成员的实现细节。 虚/*abstract*/
关键字是用于定义抽象类和在抽象类内部定义抽象方法。
虚 类 动物 {
虚 发声(): 无;
移动(): 无 {
控制台.日志('漫游的我们...');
}
}
抽象类中的抽象方法不包含具体实现并且必须在派生类中实现。 抽象方法的语法与接口方法相似。 两者都是定义方法签名但不包含方法体。 然而,抽象方法必须包含虚
关键字并且可以包含访问修饰符。
虚 类 部门 {
构(显 名: 文) {
}
打印名称(): 无 {
控制台.日志('部门名称:' + 此.名);
}
虚 打印会议(): 无; // 必须在派生类中实现
}
类 会计部门 承 部门 {
构() {
先('会计与审计'); // 在派生类的构造函数中必须调用 先()
}
打印会议(): 无 {
控制台.日志('会计部门每周一上午10点开会。');
}
生产报告(): 无 {
控制台.日志('正在生成会计报告...');
}
}
定 部门0: 部门; // 允许创建一个对抽象类型的引用
部门0 = 启 部门(); // 错误: 不能创建一个抽象类的实例
部门0 = 启 会计部门(); // 允许对一个抽象子类进行实例化和赋值
部门0.打印名称();
部门0.打印会议();
部门0.生产报告(); // 错误: 方法在声明的抽象类中不存在
把类当做接口使用
如上一节里所讲的,类定义会创建两个东西:类的实例类型和一个构造函数。 因为类可以创建出类型,所以你能够在允许使用接口的地方使用类。
类 点 {
横: 数 = 0;
竖: 数 = 0;
}
型 三维点 承 点 {
深: 数;
}
定 三维点0: 三维点 = { 横: 1, 竖: 2, 深: 3 };
总结
这篇文章简单地介绍了一些面向对象原理 —— 这些描述还不够完整, 但它让您知道我们在这里处理什么。此外, 我们介绍如何使用类声明实现 君土脚本中的类, 以及生成对象的方法。