1. 总览 Vim9-class-overview
时髦的叫法叫 "面向对象编程"。这个主题有大量的学习材料。本文档假定你已了解相关
基本概念,着重说明 Vim9 脚本提供的功能。还有一些如何有效使用这些功能有用的提
示。
最基本的项目是对象:
- 对象保存状态。它包含一或多个变量,每个变量可带值。
- 对象提供函数,用于访问和操作状态。这些函数在 "对象上" 调用,这和传统的分离数
据以及操作数据的代码的方式有所区隔。
- 对象有定义良好的界面,包括有类型的成员变量和成员函数。
- 对象由类创建,所有这样的对象有相同的界面。运行时不能改变,界面并非动态。
对象只能由类创建。类提供了:
- new() 方法,构造函数,返回属于该类的一个对象。此方法在类名上调用:
MyClass.new()。
- 所有该类的对象共享的状态: 类变量 (类成员)。
- 类的层次结构,即超类和子类,继承。
界面用于指定对象的属性:
- 对象可声明多个它实现的界面。
- 可以相同方式调用实现相同界面的不同对象。
类的层次结构支持单一继承。其它的需要可用界面实现。
类模型
可以任何方式构造类的模型。留心你要做什么,不要试图为现实世界建模。这有点混淆,
尤其老师在课堂上会用现实事物来解释类关系,你可能会因此认为你的模型应反映现实世
界。不需要!模型应匹配你的目的。
要记得组合 (一个对象包含另一对象) 常常优于继承 (一个对象扩展另一对象)。不要浪
费时间去找最优的类模型。或者浪费时间讨论到底正方形是一种矩形还是矩形是一种正方
形。这无关紧要。
2. 简单类 Vim9-simple-class
从简单的例子开始: 保存文本位置的类 (更有效的做法稍后会讲):
class TextPosition
this.lnum: number
this.col: number
def new(lnum: number, col: number)
this.lnum = lnum
this.col = col
enddef
def SetLnum(lnum: number)
this.lnum = lnum
enddef
def SetCol(col: number)
this.col = col
enddef
def SetPosition(lnum: number, col: number)
this.lnum = lnum
this.col = col
enddef
endclass
object Object
new() 方法创建此类的一个对象:
var pos = TextPosition.new(1, 1)
可直接访问对象成员 "lnum" 和 "col":
echo $'文本位置为 ({pos.lnum}, {pos.col})'
E1317 E1327
如果你有使用其它面向对象语言的经验,肯定会注意
到 Vim 里对象成员引用时总是用
"this." 前缀。这和 Java 和 TypeScript 这些语言不同。此命名惯例容易找到对象成
员。相反地,不带 "this." 前缀的变量就是非对象成员。
成员写访问
现在试试直接修改对象成员:
pos.lnum = 9
E1335
这会报错!这是因为缺省对象成员可以读但不可写。所以 TextPosition 类要为此提供一
个方法:
pos.SetLnum(9)
对象成员可读但不可写是最常见和安全的方法。通常读值没有问题,但设置值时会有副作
用,需要特别处理。此例中,SetLnum() 方法可以检查行号是否合法,不然可以报错或者
选择最接近的合法值。
:public E1331
如果你不担心副作用而想随时修改对象成员,可标记其为 public:
public this.lnum: number
public this.col: number
现在你就不需要 SetLnum()、SetCol() 和 SetPosition() 方法了,直接设置
"pos.lnum" 不再报错。
E1334
试图设置尚不存在的对象成员会报错:
pos.other = 9
E1334: Object member not found: other
私有成员
E1332 E1333
另一方面,如果不想让对象成员直接可读,可标记其为私有。这是通过给名字加上下划线
前缀完成的:
this._lnum: number
this._col number
你现在需要提供读取私有成员值的方法。常称为 getter。推荐使用 "Get" 开头:
def GetLnum(): number
return this._lnum
enddef
def GetCol() number
return this._col
enddef
此例不甚有用,还不如干脆声明成员为公共。但如果可以对值进行检查,就会有用些。例
如,限制行号不超过总行数:
def GetLnum(): number
if this._lnum > this._lineCount
return this._lineCount
endif
return this._lnum
enddef
简化 new() 方法
许多构造函数接受对象成员的值作为参数。常常会见到以下模式:
class SomeClass
this.lnum: number
this.col: number
def new(lnum: number, col: number)
this.lnum = lnum
this.col = col
enddef
endclass
不但要写大段代码,还要重复提供每个成员的类型。因为这种模式很常见,提供了以下编
写 new() 的快捷方式:
def new(this.lnum, this.col)
enddef
这里的语义很容易理解: 以带 "this." 的对象成员名作为 new() 参数,意味着调用
new() 时提供的值赋值到该对象成员。此机制来自于 Dart 语言。
组合使用这种使用 new() 的方法和标记公共成员,会得到和最初例子等价但简短得多的
类定义:
class TextPosition
public this.lnum: number
public this.col: number
def new(this.lnum, this.col)
enddef
def SetPosition(lnum: number, col: number)
this.lnum = lnum
this.col = col
enddef
endclass
构造新对象的顺序是:
1. 分配内存,并清零内存。所有值初始为零/假值/空值。
2. 为每个声明时带初始器的的成员进行表达式计算并赋值。按照成员在类中定义的顺序
依次进行。
3. 对使用 "this.name" 形式的 new() 方法的参数进行赋值。
4. 执行 new() 方法的本体。
如果类扩展了某父类,进行同样的操作。在第二步中先初始父类的成员。无需为父类调用
"super()" 或 "new()"。
3. 类成员和函数 Vim9-class-member
:static E1337 E1338
用 "static" 声明类成员。使用时名字前没有前缀:
class OtherThing
this.size: number
static totalSize: number
def new(this.size)
totalSize += this.size
enddef
endclass
E1340 E1341
因为按本名使用,不允许覆盖函数参数名或局部变量名。
和对象成员一样,可通过下划线名字前缀来使访问私有,也可用 "public" 前缀来标识
其为公共:
class OtherThing
static total: number # 任何人都可读,只有类可写
static _sum: number # 只有类可读写
public static result: number # 任何人可读写
endclass
class-function
也可用 "static" 声明类函数。它们不能访问对象成员,不能使用 "this" 关键字。
class OtherThing
this.size: number
static totalSize: number
# 清除总大小,返回清除之前的值。
static def ClearTotalSize(): number
var prev = totalSize
totalSize = 0
return prev
enddef
endclass
在类内部,可以按名字直接调用该函数,类的外部则必须使用类名前缀:
OtherThing.ClearTotalSize()
。
4. 使用抽象类 Vim9-abstract-class
抽象类构成了至少一个的子类的基底。类模型常常需要若干类共享相同的属性,但带这些
属性的类缺乏足够的状态以创建对象。子类必须先扩展抽象类,加入缺失的状态和/或方
法,然后才能用于创建对象。
例如,Shape 类可以保存 color 和 thickness。不能直接创建 Shape 对象,因为缺少何
种形状的信息。Shape 类函数构成了 Square 和 Triangle 类的基底,后两者就可以创建
对象了。例如:
abstract class Shape
this.color = Color.Black
this.thickness = 10
endclass
class Square extends Shape
this.size: number
def new(this.size)
enddef
endclass
class Triangle extends Shape
this.base: number
this.height: number
def new(this.base, this.height)
enddef
endclass
抽象类和普通类的定义方式相同,但不能用 new() 方法。 E1359
5. 使用界面 Vim9-using-interface
上面 Shape、Square 和 Triangle 的例子如果提供了计算对象面积的方法会更有用。为
此,我们可以创建 HasSurface 界面,指定一个返回数值的方法 Surface()。本例在上例
基础上进行扩展:
abstract class Shape
this.color = Color.Black
this.thickness = 10
endclass
interface HasSurface
def Surface(): number
endinterface
class Square extends Shape implements HasSurface
this.size: number
def new(this.size)
enddef
def Surface(): number
return this.size * this.size
enddef
endclass
class Triangle extends Shape implements HasSurface
this.base: number
this.height: number
def new(this.base, this.height)
enddef
def Surface(): number
return this.base * this.height / 2
enddef
endclass
如果类声明会实现某界面,类中必须出现该界面的所有项目,且类型相同。
E1348 E1349
界面名可用做类型:
var shapes: list<HasSurface> = [
Square.new(12),
Triangle.new(8, 15),
]
for shape in shapes
echo $'面积是 {shape.Surface()}'
endfor
6. 更多类的细节 Vim9-class Class class
定义类
:class :endclass :abstract
类定义出现在 :class 和 :endclass 之间。整个类在一个脚本文件中定义。定义之
后不能为类新增内容。
只能在一个 Vim9 脚本文件中定义类。 E1316
不能在函数内部定义类。
一个脚本文件中可以定义多个类。最好只导出一个主类。不过,定义类型、枚举和帮助类
还是常用的。
可有前缀 :abstract 关键字,也可用 :export 。这样就有以下组合:
class ClassName
endclass
export class ClassName
endclass
abstract class ClassName
endclass
export abstract class ClassName
endclass
E1314
类名应使用驼峰式。必须以大写字母开头。这样可以避免和内建类型冲突。
E1315
类名之后可选若干项目。每种只能出现一次。可以任何顺序出现,但建议以下顺序:
extends ClassName
implements InterfaceName, OtherInterface
specifies SomeInterface
E1355
每个成员和函数名只能用一次。不能定义同名但带不同类型参数的函数。
类扩展
extends
类可以扩展其它类。 E1352 E1353 E1354
基本的想法是在已有的类上加上新的属性。
被扩展类称为 "基类" 或 "超类"。新类称为 "子类"。
基类的对象成员被子类全盘接收。不可覆盖 (和一些其它的语言不同)。
E1356 E1357 E1358
基类的对象方法可以覆盖。签字 (参数、参数类型和返回类型) 必须完全相同。可通过
"super." 前缀调用基类提供的方法。
其它对象方法被子类全盘接收。
类函数,包含 "new" 开始的函数,和对象方法一样可以覆盖。可通过类名 (用于类函数)
前缀或 "super." 前缀调用基类的函数。
和其它语言不同,不需要调用基类的构造函数。事实上,也不可以调用。如果子类需要基
类执行的某种初始化,把相关逻辑放在一个对象方法里,在每个构造函数里调用该方法。
如果基类不提供 new() 函数,会自动创建一个。此函数不被子类接收。子类可以定义自
己的 new() 函数,同样地,如果没有,会自动创建一个。
实现界面的类
implements E1346 E1347
类可以实现一个或多个界面。"implements" 关键字只能出现一次 E1350 。
可指定多个界面,以逗号分隔。每个界面名只能出现一次。 E1351
定义界面的类
specifies
类可以通过一个命名的界面来声明自己的界面、对象成员和方法。这样就不需要单独指定
界面了,很多语言,尤其是 Java,就是如此。
类中的项目
E1318 E1325 E1326
类内部,即 :class 和 :endclass 之间,可出现以下项目:
- 对象成员声明:
this._memberName: memberType
this.memberName: memberType
public this.memberName: memberType
- 构造方法:
def new(arguments)
def newName(arguments)
- 对象方法:
def SomeMethod(arguments)
E1329
对象成员必须指定类型。最好的方法是显式指定 ": {type}
"。简单类型也可用初始器,
形如 "= 123",Vim 可以推定类型为数值。更复杂的类型不要这样做,也不能用于不完整
的类型。如:
this.nameList = []
这里指定了列表,但项目类型未知。最好这样:
this.nameList: list<string>
这里不需要初始化,列表缺省已为空。
E1330
有些类型不能使用,如 "void"、"null" 和 "v:none"。
定义界面
:interface :endinterface
界面在 :interface 和 :endinterface 间定义。可加上前缀 :export :
interface InterfaceName
endinterface
export interface InterfaceName
endinterface
E1344
界面可声明对象成员,和类一样,但没有初始器。
E1345
界面可用 :def 声明方法,包括参数和返回类型,但没有本体,也没有 :enddef 。示
例:
interface HasSurface
this.size: number
def Surface(): number
endinterface
界面名必须以大写字母开始。 E1343
"Has" 前缀方便判断这是界面名,并提示了它提供的功能。
只能在 Vim9 脚本文件中定义界面。 E1342
缺省构造函数
如果定义类时未提供 new() 方法,会自动生成一个。此缺省构造函数会为每个对象按照
它们出现的顺序分别提供一个参数。如你的类是这样:
class AutoNew
this.name: string
this.age: number
this.gender: Gender
endclass
那么缺省构造函数就会是:
def new(this.name = v:none, this.age = v:none, this.gender = v:none)
enddef
缺省值 "= v:none" 使参数可选。所以,也可不带任何参数地调用 new()
。不赋值而使
用对象成员的缺省值。下面是更有用的带缺省值的例子:
class TextPosition
this.lnum: number = 1
this.col: number = 1
endclass
如果构造函数要强制提供参数,必须自己编写。例如,要上面的 AutoNew 类必须提供
name,可这样定义构造函数:
def new(this.name, this.age = v:none, this.gender = v:none)
enddef
E1328
注意
除了这里的 "v:none" 之外,不能使用其它缺省值。要初始化对象成员,在其声明
的地方提供初始值。这样你只须在一个地方寻找缺省值。
缺省构造函数中使用所有的对象成员,包括私有访问的那些。
如果类扩展其它类,被扩展类的对象成员会先出现。
多个构造函数
通常每个类只有一个 new() 构造函数。如果常常需要传递相同的参数,可简化代码,把
这些参数放到另一个构造函数里。例如,如果经常使用黑色:
def new(this.garment, this.color, this.size)
enddef
...
var pants = new(Garment.pants, Color.black, "XL")
var shirt = new(Garment.shirt, Color.black, "XL")
var shoes = new(Garment.shoes, Color.black, "45")
要避免每次重复相同的颜色,可加入另一构造函数,包含此颜色:
def newBlack(this.garment, this.size)
this.color = Color.black
enddef
...
var pants = newBlack(Garment.pants, "XL")
var shirt = newBlack(Garment.shirt, "XL")
var shoes = newBlack(Garment.shoes, "9.5")
注意
此方法名必须以 "new" 开始。如果没有叫 "new()" 的方法,会加入缺省构造函
数,即使有其它的构造函数也是如此。
7. 类型定义 Vim9-type :type
类型定义为类型规格给定名称。例如:
:type ListOfStrings list<string>
TODO: 更多说明
8. 枚举 Vim9-enum :enum :endenum
枚举是从给定值列表中选取一个的类型。示例:
:enum Color
White
Red
Green
Blue
Black
:endenum
TODO: 更多说明
9. 理据
Vim9 类的绝大多数选择来自流行和新开发的语言,如 Java、TypeScript 和 Dart。语
法按照 Vim 脚本的工作方式进行了调整,如使用 endclass
而不是用花括号包围整个
类。
面向对象语言的很多常用构造是多年前此类型的编程方法尚在幼年时形成的,后来发现并
非理想。但这些构造已广为使用,无法再改变。而 Vim 完全有做出不同选择的自由,因
为类是全新的功能。和 "老" 语言相较,我们可以使语法更简单也更统一。变化不宜太
大,跟你熟悉的已有语言仍然要大体相像。
一些近期开发的语言加入了很多花哨的功能,我们 Vim 不需要。但有些好的想法我们也
想采用。这样,我们最终的选择以常用语言的共同语法为基底,删除不好的功能,加入新
的容易理解且相当不错的功能。
我们做决定的主要原则是:
- 尽量简单。
- 没有惊奇,和其它语言做的大体相同。
- 避免过去的错误。
- 脚本作者不需要查询帮助就可以理解如何工作,绝大多数语法应一目了然。
- 保持统一。
- 服务目标是一般大小的插件,而不是巨型项目。
构造函数使用 new()
许多语言的构造函数使用类名。缺点是此名字常常很长。而且类改名时构造函数也要跟着
改名。不是大问题,但总是个缺点。
其它语言,如 TypeScript,使用特定名字,如 "constructor()",这会好一些。不过使
用 "new" 或 "new()" 来创建新对象和 "constructor()" 好象关系不大。
对 Vim9 脚本而言,为所有的构造函数使用相同的方法名看来是正确的选择,而叫它
new() 使调用者和被调用的方法之间的关系非常明确。
构造函数不能重载
Vim 脚本里,不管是老式还是 Vim9 脚本,函数不能重载。这意味着不能有相同的函数
名但带不同类型的参数。因此,只能有一个 new() 构造函数。
Vim9 脚本使重载成为可能,因为参数有类型。但这很快变得复杂。看到 new() 调用
时,要先检查参数的类型,以确定多个 new() 方法里实际要调用哪个。而这需要检视相
当多的代码。如其中一个参数是方法的返回值,那么就要先找到该方法,来查看它返回什
么类型。
相反地,每个构造函数必须有以 "new" 开始的不同名字。这样就可以有带不同参数的多
个构造函数,也容易看到使用的是哪个构造函数。也可以进行适当地参数类型检查。
方法不能重载
和构造函数同样的原因: 参数类型不总是显而易见的,要判定实际调用的是哪个方法因而
常有困难。最好给方法不同的名字,这样类型检查会如你所愿。这样会排除多态性,但我
们本来就不怎么需要。
单一继承和界面
一些语言支持多重继承。有些情况这很有用,但类如何工作的规则因而非常复杂。相反
地,使用界面来声明支持何种特性就简单得多。非常流行的 Java 语言就是这么做的,对
Vim 也应该够用了。"尽量简单" 原则在此适用。
显式声明类支持某个界面,会容易看清类的设计用途。这样也可适当地进行类型检查。界
面改变时,也会检查任何声明实现了该界面的类,确定已作了相应的修改。因为类有某界
面的方法就假定它实现了该界面的机制是很脆弱的,会导致微妙的问题,我们不这么做。
在所有地方使用 "this.member"
不同编程语言中的对象成员有不同的访问方式,取决于出现的位置。有时需要 "this."
前缀避免歧义。通常的声明可以没有 "this."。这很不一致,有时会引起混淆。
一个常见的问题是构造函数里参数名和对象成员名相同。在函数本体里访问这些成员需要
"this." 前缀,而访问其它成员却不需要,常常省略。这造成了带 "this." 的成员和不
带的混用,非常不统一。
Vim9 类里总是使用 "this." 前缀。包括成员的声明。简单且统一。阅读类内部的代码
时,可以直接看清哪些变量引用指向对象成员,哪些不是。
使用类成员
使用 "static member" 来声明类成员很常见,没什么新奇的。 Vim9 脚本里类成员可通
过名字直接访问。就像函数里使用脚本局部变量一样。因为对象成员访问时必须带上
"this" 前缀,是何种成员可快速判断。
TypeScript 在类成员前加上类名前缀,即使在类内部也是如此。这有两个问题: 类名可
能颇长,会占据相当空间,而且类改名时这些地方也要跟着改名。
声明对象及类成员
我们面临的主要选择是变量声明是否使用 "var"。
TypeScript 不用:
class Point {
x: number;
y = 0;
}
仿照此风格,Vim 对象成员可以这样声明:
class Point
this.x: number
this.y = 0
endclass
有些用户指出,这看来更像是赋值而不是声明。加上 "var" 改变了这一点:
class Point
var this.x: number
var this.y = 0
endclass
为此,也需要用 "static" 关键字来声明类成员。这时可以选择省略 "var":
class Point
var this.x: number
static count = 0
endclass
用的话,可以放在 "static" 之前:
class Point
var this.x: number
var static count = 0
endclass
也可以放在 "static" 之后:
class Point
var this.x: number
static var count = 0
endclass
这与 "static def Func()" 更一致些。
是否使用 "var" 没有明显优劣。我们不用的两个主要原因是:
1. TypeScript、Java 和其它流行语言不用。
2. 少点拥挤。
用 "ClassName.new()" 来构造对象
许多语言使用 "new" 操作符来创建对象,这有点奇怪,因为构造函数定义为带参数的方
法,而不是命令。TypeScript 也有 "new" 关键字,但方法名却是 "constructor()",很
难看清两者的关联。
Vim9 脚本里构造方法叫做 new(),也使用 new() 调用,简单直接。其它语言使用
"new ClassName()" 而没有 ClassName() 方法,反而是名为 ClassName 的类里带其它名
字的方法。很混淆。
对象成员缺省读访问
许多用户评论道对象成员的访问规则不对称。好吧,这是有意的。修改值和读取值是很不
同的操作。读操作没有副作用,可以进行任何次数而不影响对象本身。修改值则可能有很
多副作用,甚至会有连锁反应,影响到其它对象。
新增对象成员时,人们一般想不到这么多,类型对就够了。值通常在 new() 方法里赋
值。所以缺省读访问在绝大多数情况下 "应该就很好"。直接修改时会有报错,提醒你是
不是真地要这么干。写代码时这可以帮助少点错误。
加下划线使对象成员私有
对象成员私有时,只可在类 (和子类) 中读取和修改,不能在类外使用。前加下划线是这
么做的简单办法。若干编程语言建议如此。
如果要改变想法,使对象成员在类外也可访问,只要在所有的地方删除下划线。因为名字
只出现在类 (和子类) 中,应该容易找到和修改。
反过来就难很多: 可以简单地在对象成员前加上下划线使之私有,但所有其它地方你要自
己追踪到和修改。可能要提供 "set" 方法调用。这也反映了现实中的问题,拿走权限需
要在所有使用该权限的地方下功夫。
替代方案会用 "private" 关键字,就像 "public" 在反方向修改访问那样。好吧,不这
么做只是为了减少关键字数量。
没有保护对象成员
有些语言提供了若干方法控制对象成员的访问。最有名的是 "protected",其意义随语言
有所不同。其它的还有 "shared"、"private" 甚至 "friend"。
这些规则使生活变麻烦。在许多人在同一个复杂的代码上操作的项目里,容易出错而这些
规则可能有其合理性。尤其是重整或其它类模型上的修改。
Vim 脚本目标是为了插件设计,编写者只有一个人或小团队。复杂的规则只会让事情更复
杂,规则提供的额外安全性并非必要。我们还是简单点,不要去指定访问的细节了。
10. 以后再做
newSomething() 构造函数可以调用另一个构造函数吗?如果是的话,有什么限制?
想法:
- 类的泛型: `class <Tkey, Tentry>`
- 函数的泛型: `def <Tkey>
GetLast(key: Tkey)`
- 织入: 不确定有没有用,为简单先不列入。
看来是好的新增特性:
- 用于测试: Mock 机制
一种将来会提供的重要的类是 "Promise"。因为 Vim 是单线程的,和异步操作连接是允
许插件实现非用户阻塞式功能的自然方式。这将是调用回调和处理超时以及错误的统一方
法。
vim:tw=78:ts=8:noet:ft=help:norl: