第七章 VimL 面向对象编程
7.3 自定义类的组织管理
在上一节已经讲叙了如何利用 VimL 语法实现面向对象编程的基本原理,本节进一步讨论 在实践中如何更好地使用 VimL 面向对象编程。关键是如何定义类与使用类,如何管理与 组织类代码使之更整洁。因为从某种意义讲,面向对象并不是什么新的编程技术,而是抽 象思考问题的哲学,以及代码管理的方法论。
笔者在 github 托管了一个有关 VimL 面向对象编程的项目 vimloo,可作为一个实现范例。本节就介绍这 个 vimloo 项目的基本思路,不过该项目代码有可能继续更新维护与优化,故本节教程所 采用的示例代码为求简单,不尽与实际项目相同。
每个类独立于一个自动加载文件
在上一节的示例代码中,我们定义了一个名为 class
的类。因为彼时只关注实现原理 ,并未指定相关代码应保存何处。你可以放在任一个脚本中,甚至也可以粘贴入命令行, 也能起到演示之用。
如果你想用 VimL 实现一个规划不太大的(插件)功能,又想用到字典的对象特征,想在 单文件中实现全部(或大部分)功能,那么也着实可以就像是上节的示例那样,在单文件 中定义类然后使用类。但是,既然想到要用面向对象的设计,那么一般地每个类都应该是 相对独立完整的功能单元。这时,将类的定义代码提取出来放在独立的文件中就更合适了 ,这也可以达到隐藏类实现细节的目的,在其他需要使用对象的地方,只需创建相应类的 对象,调用该类对象所支持的方法即可。
简言之,要区分类的实现者与使用者(尽管很多时候这是同一个程序员的工作)。在 VimL 中,如果要将类的定义代码单独存于一个文件中,最适合的地方应该就是 autoload/
子目录下的自动加载文件了。因为它可以让用户从任意地方调用,并且只在 真正需要用到时才加载类定义代码。
于是,将上节的 class
类定义稍作修改,保存于某个 &rtp
(如 ~/.vim
)的 autoload/class.vim
文件中:
" File: ~/.vim/autoload/class.vim
let s:class = {}
let s:class.name = 'class'
let s:class.version = 1
function! s:class.string() abort dict
return self.name
endfunction
function! s:class.number() abort dict
return self.version
endfunction
function! s:class.disp() abort dict
echo self.string() . ':' . self.number()
endfunction
主要是将定义的类(字典)名字改为 s:class
,使之成为局部于脚本的变量。这样在不 同文件中定义的不同类也都能用相同的字面名字 s:class
而互不冲突。该变量名的选 用是任意的,在不同类文件中选用不同变量名也可以,只要在随后定义类的属性与方法也 都用相应的字典变量名即可。但这里的建议是,为求风格统一,每个类文件定义的类字典 变量都取名为 s:class
。
在这个 class.vim
中定义的类没打算做什么实际工作,因此只(貌似随意地)定义了 两个属性与几个方法。当然,你也可以将 string()
与 number()
方法想象为类型转 换方法,用于在必要时如何将一个对象转为字符串或数字的表示法。
使用自动加载函数处理类方法
现在 class.vim
文件中定义的 s:class
类只能在该文件中访问,这显然是不够的。 为了达到分离类定义与类使用的设计原意,我们还得在 class.vim
提供一些公有接口 让外界使用类。自动加载函数就是一个很好的选择,因为它既是全局函数,又通过 #
前缀限定了“伪作用域”。例如,添加以下函数:
" File: ~/.vim/autoload/class.vim
function! class#class() abort
return s:class
endfunction
function! class#new() abort
let l:obj = copy(s:class)
return l:obj
endfunction
function! class#isobject(that) abort
return a:that.name ==# s:class.name
endfunction
先看 class#class()
这个略有奇怪的函数命名。class#
前缀部分是对应 class.vim
文件名路径的,class()
可认为是该函数的基础名字。它的作用很简单( 也很关键),就是返回当前文件定义的类 s:class
,使外界有个途径能使用这个类。这 就是个取值函数,也可命名为 getclass()
或许可更易理解。
class#new()
函数就是用于创建一个新对象。我们使用一个类时,第一步往往就是新建 对象,这就只要调用 class#new()
就可以了。如果之前尚未加载类 class
的定义, 就会按自动加载机制加载 class.vim
,也就完成其内 s:class
的定义。普通用户一 般情况下根本用不到 class#class()
获取 s:class
的定义,除非想动态修改类定义 (慎重)。如果真的想向用户完全隐藏类定义,不提供 class#class()
函数即可,只 提供 class#new()
让用户能创建对象好了。
所以才将创建对象的函数定义为 class#new()
而非像上节那样的方法 s:class.new()
, 让用户直接上手创建对象,而不必关心类定义是否已加载。其次也是由于 VimL 只能按复 制式创建对象,如果把 s:class.new()
方法也复制到对象中,是很没必要的,甚至还 可能被误用。
至于 class#isobject()
,用于判断一个对象是否属于本文件所定义的类。在某些应用 中,先作类型判断是有意义的甚至是必要的。这里暂且先用类的 name
属性来标记一个 类,因此为了保证类名的唯一性,name
属性的取值也按自动加载函数的规则取文件名 路径(即如 class#class()
函数的前缀部分)。如果在某个深层子目录中定义的类, 如 autoload/topic/subject.vim
文件内定义的 s:class
类名属性就应该是 topic#subject
。当然了,另有一个建议,由于 VimL 的大多数脚本都未必是类定义文 件,为了更明确表示它是个类文件,可将更多实用的类都统一放在 class/
子目录下, 如 autoload/class/topic/subject.vim
,如果其类名就是 class#topic#subject
。 严格地讲,class#isobject()
要稳健地执行,还应判断所传入的参数 a:that
是否 字典类型,以及是否有 name
这个属性。
然后,可以根据需要设计更多的函数。这有两种选择,如果是操作对象的方法,应存入 s:class
字典,如 s:class.method()
。如果它不适合用作对象的方法属性,而着重 与类型有关,可定义为自动加载函数,如 class#func()
。
区分类属性与对象属性
从前面的章节讨论中,我们意识到类属性与对象属性可以是两个不同的概念,这是值得优 化的一个方向。尤其是 VimL 中若用简单粗暴的全复制方式创建对象,把那些通用的属性 复制到每个对象中,显然是个浪费。例如上一小节的类名属性 name
,尤其是深层目录 的类文件,像 class#topic#subject
这样的字符串已经不短了,每创建一个新对象都 保存这样一个属性值,似乎很不值了。
但另一方面,在类定义字典中保存类名属性也是有意义的,因其关联了文件路径,也可据 此间接调用方式文件内的自动加载函数。所以,最好是能限定类名属性不被复制到新建对 象中。因此为了区分,约定将类属性的命名加两个下划线,如 _name_
。这样,某些具 体的对象也可能需要自己的 name
属性,也不致键名冲突。
按这种思路,我们再试写另一个类文件:
" File: ~/.vim/autoload/class/subclass.vim
let s:class = {}
let s:class._name_ = 'class#subclass'
let s:class._version_ = 1
" Todo: 其他对象属性预设
let s:class.value = 0
function! class#subclass#class() abort
return s:class
endfunction
function! class#subclass#new() abort
let l:obj = Copy(s:class) " Todo: 另外定制的特殊“复制”函数
return l:obj
endfunction
之前定义在 autoload/class.vim
文件中名为 class
的类,不妨当作整个自定义 VimL 类系统的通用基类。在实际工作中一般不会直接用到 class
类及其实例对象。所 以我们开始设计实际可用的子类,建议将所有实用类归于 class/
子目录下。以上也仅 是个说明示例,故类名简单取为 subclass
,按自动加载机制,其全名则是 class#subclass
。
这个类文件的基本框架与之前类似,只不过将原来的类属性改名为 _name_
与 _version_
。属于该类的对象的属性名,不加下划线,比如 value
。然后创建对象的 #new()
函数,显然不能直接用 copy()
或 deepcopy()
内置函数了。这个辅助的 特殊复制函数需要另外实现,不过将其命名为 Copy()
或 SpecialCopy()
就显得有 点蠢了。联想到之前的 class#new()
函数,既然一般没必要创建 class
顶层基类的 实例对象,不妨将 class.vim
内定义的函数改为公共基础设施函数。于是修改如下:
" File: ~/.vim/autoload/class/subclass.vim
function! class#subclass#new(...) abort
let l:obj = class#new(s:class, a:000)
return l:obj
endfunction
这里,只是将当前文件定义的类 s:class
与任意参数 a:000
传给 class#new()
基础设施函数,然后也是返回所创建的对象。至于 class#new()
的具体实现,略复杂 ,请参考 vimloo 项目的 autoload/class.vim
。这里只说明它主要做的几件事:
一是分析 s:class
的键,过滤掉带下划线前后缀的属性名,只把普通属性复制到对象 实例中。如上例的 class#subclass
类,由 #new()
创建出的对象只有 value
属性。
二是给每个新建对象添加唯一一个特殊属性,名为 _class_
,就是对 s:class
的引 用。这样每个对象都能知道自己所属的类了,在有必要时可访问这个类字典获得其他信息 。而且保存类字典的引用,比保存类名字符串在安全性与效率性上都好得多。然后,判断 一个对象是否属性本类的函数也能利用该属性,可大约修改如下:
" File: ~/.vim/autoload/class/subclass.vim
function! class#subclass#isobject(that) abort
" is 是操作符,相当于 == 用于比较相同的引用
return type(a:that) = type({}) && get(a:that, '_class_', {}) is s:class
endfunction
其实还有第三个隐藏事件,这只在每个类创建第一个对象时发生。为了避免每次创建对象 都要作第一步的分析过滤 s:class
的键名,class#new()
会在第一次记忆这个结果 ,保存在一个特殊键 s:class._object_
中。这是向用户隐藏的第一个实例,用户新建 使用到的实例是直接从这个实例深拷贝的(deepcopy()
)。我们可以将其视为这个类的 “长子”,是其他实际干活的小弟们的楷模。
控制继承与多层继承
然后讨论 vimloo 项目对继承的实现。首先不要惊讶于命名学上的选用。因为前文已经说 明,继承与实例化一样底层都是通过复制实现的。既然创建新对象是用 #new()
函数, 那么创建新子类就用个相对的单词 #old()
。
假设要从 subclass
继承一个类 subsubclass
,类文件保存于 class/subsubclass.vim
。 当然你也可保存于 class/subclasss/subsubclass.vim
文件中,只是名字略长。这里 要指出的是,文件系统的目录层次,未必要强求与类的继承链一一对应,那也会有其他麻 烦,仅从文件管理角度看,将相关主题的类文件放在一个目录中就能接受了。
要实现这个继承关系,有两点需要改动。一是在 subsubclass.vim
中创建 s:class
时不再初始化为空字典,而是调用 subclass#old()
返回的字典;二就是要在 subclass.vim
中实现 subclass#old()
函数,描述如何将自己这个类继承(复制) 给子类。代码框架如下:
" File: ~/.vim/autoload/class/subsubclass.vim
let s:class = subclass#old()
let s:class._name_ = 'class#subsubclass'
let s:class._version_ = 1
" Todo: 其他类属性与方法
function! class#subsubclass#new(...) abort
let l:obj = class#new(s:class, a:000)
return l:obj
endfunction
" File: ~/.vim/autoload/class/subclass.vim
" 其他沿用,添加 #old() 方法
function! class#subclass#old(...) abort
let l:class = class#old(s:class)
return l:class
endfunction
可见 subsubclass.vim
的类定义框架与之前的 subclass.vim
很是类似,只有第一 行初始化 s:class
的不同。甚至创建对象的 #new()
方法的写法也完全一样,因为 把复制的细节都提炼到 class#new()
这个通用设施上了。用户可直接上手调用 class#subsubclass#new()
方法创建对象,按 VimL 自动加载机制,subsubclass.vim
、 subclass.vim
与 class.vim
这三个脚本文件都会触发加载。
至于继承函数 class#subclass#old()
与实例化函数 class#subclass#old()
也类似 ,将复制的细节委托通用的 class#old()
函数处理。它也是分析过滤 s:class
的键 ,将必要的键复制给子类,并在子类字典中添加一个特殊键 _mother_
引用自身类字典 。(具体实现代码就不帖了,看 vimloo 项目源码)
如果要让 subclass
继承自 class
,也可修改 subclass.vim
中对 s:class
的 创建语句 let s:class = class#old()
。因为 class#new()
与 class#old()
函数 接收可变参数,一般将其第一个参数视为类定义字典,即其他类文件中的 s:class
,当 然也可以是类名字符串,根据类名可获取其 s:class
字典;如果没有参数时,就用 class.vim
文件本身的 s:class
类字典。不过,由于在 class.vim
的 s:class
在实践中实在乏善可陈,在第二版(_version_ = 2
)时,无参调用 class#old()
会 快速返回空字典 {}
。自定义的顶层类没有母类(基类),或 _mother_
属性为空。
因此,vimloo 实现的类体系,可类比“母系社会”来理解。从一个母类中有两种繁衍,“女 儿”是子类,主要用途就是继续繁衍;“儿子”是实例化对象,就是用来实际工作干活的。 子类中通过 _mother_
属性记录母类的联系,实例中是 _class_
属性。由于实际工 作中可能需要许多同质的实例对象,故而还设置了一个隐藏的 _object_
长子监管。这 套机制用于描绘单继承应该足够清晰易懂。
能用单继承解决的问题,尽量避免多重继承。不过 vimloo 也实现了多重继承的支持。每 个类的 _mother_
属性虽然只记录了唯一的母类,但也允许有其他基类,有两种“其他 基类”。一种叫 _master_
(意为“师父”),只继承其方法,不继承其数据;另一种叫 _father_
(意为“父亲”),只继承其数据,不继承其方法。每个类的 _master_
与 _father_
属性(如果有),都是数组,即可以是多个来自其他类文件定义的 s:class
。 只不过这些“其他基类”的属性,都不会直接导入当前文件的 s:class
中,只有当创建 对象实例时(如 s:class._object_
长子),才会分析这些类的键名,将必要的键复制 下来。
也可以通过形象的比喻来理解这个模型。如一位母亲抚育孩子,额外聘请多位老师教孩子 其他技艺,这是可理解的(相当于某些语言的接口方法),不过母亲本身未必要掌握这些 技能,她的目的是孩子们能学会就可以了。当然了,另一方面,也允许多个“父亲”,这思 想有点危险啊,最好避而不用吧。
构造函数与析构函数
重新审视一下创建对象的 #new()
方法,其流程应该要包含以下三步工作:
- 复制类字典
- 初始化对象属性
- 返回对象
其中,第一步与第三步的工作,对于每个类而言,都几乎是一样的,所以在 vimloo 中将 其提炼为 class#new()
函数,可为每自定义类处理通用事务。但是第二步的初始化, 显然是每个类有独立需求的。因此,建议每个类文件再写个 #ctor()
函数专司初始化 ,这就叫做构造函数。
仍以上文的 subclass
为例,将其创建函数与构造函数并列展示如下:
" File: ~/.vim/autoload/class/subclass.vim
function! class#subclass#new(...) abort
let l:obj = class#new(s:class, a:000)
return l:obj
endfunction
function! class#subclass#ctor(this, ...) abort
if a:0 > 0
let a:this.value = a:1
endif
endfunction
理论上,#ctor()
函数内的初始化代码插入到 #new()
函数中也是可以的。不过为了 保持 #new()
函数的简单统一,同时为了支持其他间接创建对象的需要,故将构造函数 #ctor()
独立出来。需要注意的是,#ctor()
函数不是由当前类文件的 #new()
函 数直接调用的,而是间接由通用的 class#new()
函数调用。不过可变参数 ...
的意 义在这两个函数之间保持一致,即 #ctor()
内的 a:1
与 #new()
内的 a:1
是 相同意义的参数。在构造函数 #ctor()
中,对象已经被创建出来,第一个参数 a:this
就代表这个刚创建的对象。构造函数一般不由用户直接调用,也不必返回值,只要在创建 函数 #new()
中返回对象即可。
一般情况下,在自定义类文件中,建议同时提供创建函数与构造函数,各司其职。但是构 造函数不是必须的,尤其是对象属性很少,或能接受每个对象都采用相同的初始值。甚至 创建函数也不是必须的,因为也能从通用的 class#new()
函数中创建指定类的对象。 例如,以下两个语句是等效的:
: let obj = class#subclass#new(100)
: let obj = class#new('class#subclass', [100])
显然,使用类文件自己特定版本的 #new()
函数创建对象更简洁,意义更明确。不过通 用的 class#new()
函数也适用于在程序运行需要动态创建不同类别的对象的情况。如 果传入的第一个参数是类名字符串,则相应的类文件中必须定义 #class()
函数(上例 就是 class#subclass#class()
) 才能获取其类定义 s:class
。此外,要让 class#new()
能正确调用构造函数,也依赖于类字典 s:class
保存了类名属性 _name_
。
对于子类的构造函数,写起来略为复杂些。因为你肯定期望能复用基类(母类)的构造函 数初始化继承自母类的那部分数据属性。class.vim
提供了一个 class#Suctor()
函 数用于获取一个类的母类的构造函数(引用)。于是 subsubclass
的构造函数可写成 如下形式:
" File: ~/.vim/autoload/class/subsubclass.vim
function! class#subsubclass#ctor(this, ...) abort
let l:Suctor = class#Suctor(s:class)
call call(l:Suctor, extend([a:this], a:000))
" Todo: 子类的其他对象属性初始化
endfunction
其中,call()
内置函数的用法不算简单,请参考文档 :h call()
。如果你确知母类 的构造函数没有做什么实质性的初始化工作(甚至未提供构造函数),也可以省去调用母 类构造函数的步骤。如果硬编码调用母类的构造函数,如 class#subclass#ctor()
, 也不是不可以,但显然太过僵硬了,且写法上也未必比利用 class#Suctor()
省多少。 在上例中,直接将所有的参数 a:000
传给母类的构造函数处理。在实践中,可能只需 要部分参数传给母类,如果这部分参数正好是可变参数的前面几个,那么直接传 a:000
也可能是正常的。在其他其他情况下,可能要对参数作某些预处理再传给母类的构造函数 。
在那些没有自动回收垃圾机制的面向对象语言(如 C++)中,与构造函数相应地,还有析 构函数。VimL 脚本语言显然是能自动回收垃圾的,不须由程序员作此负担。不过 VimL 在处理有环引用(如双向链表、树、图等复杂结构)中,垃圾回收会有滞后。为此,也可 以在自定义类文件中写个“析构函数”,命名为 #dector()
,用于打断对象内部的环引用。 当确实用不到一个对象时(往往是在函数末尾),调用 class#delete(object)
,它会 自动调用相应类文件的 #dector()
方法,然后当这个对象离开作用域时,就能立即被 回收了。vim 也有个内置的函数 garbagecollect()
可触发立即回收垃圾,但它可能要 用到搜索判断环引用的复杂算法。如程序员能帮它的回收机制打断环引用,也应是善事, 尽管这是可选的,不是必须的。
类的外包与简化使用
有了以上讨论的 vimloo 提供的面向对象功能,我们就能根据具体的功能需要,设计自定 义的类(体系)了,然后创建对象完成实际的工作。
不过还有个小问题,就是类名可能太长,书写不便。假如有这么个类,全名是 class#long#path#topic#subject
。用户在使用这个类时,每次创建对象都得调用 class#long#path#topic#subject#new()
函数。这已经算麻烦的了,如果以后想重构, 想对类重命名或移动存放目录路径,那每个创建对象的地方都还得作相应修改,那就不仅 麻烦,也更易遗漏出错了。
为此,vimloo 再提供一个 class#use()
函数。先直接看用法示例:
" File: ~/.vim/autoload/class/long/path/topic/subject.vim
" 正常类定义,略
function! class#long#path#topic#subject#use(...) abort
return class#use(s:class, a:000)
endfunction
" File: ~/.vim/vimllearn/useclass.vim
let s:CPack = class#long#path#topic#subject#use()
" 或
" let s:CPack = class#use('class#long#path#topic#subject')
function! s:foo() abort
let l:obj = s:CPack.new()
" Todo:
endfunction
function! s:bar() abort
let l:obj = s:CPack.new()
" Todo:
endfunction
简言之,class#use()
创建会创建一个字典,默认情况下有以下几个键:
class
:就是引用在类文件中定义的类字典s:class
new
:函数引用,相关类文件的创建函数#new()
isobject
:函数引用,相关类文件的创建函数#isobject()
就是将某个类定义及两个最重要的自动加载函数(的引用)打包在另一个字典中,可以提 供额外参数(函数名列表,不含 #
路径前缀)指定打包其他的自动加载函数,但 class
是不需要指定,必然被打包在其内的。由于这仅是作了一层简单的包装,提供给外部使用 ,故简称为“外包”机制。注意类的方法(如 s:class.method()
)是不需要外包的,因 为那是通过之后创建的实例对象访问的。
通过这种外包,用户代码就可大为简化了。例如可以在脚本开始将要用到的类的外包保存 在一个脚本局部变量,如 s:CPack
,然后在该脚本内就可以用 s:CPack.new()
创建该 类的对象了。这是自动加载函数的引用,同样可以触发相关类文件的自动加载。如果此后 类名发生了修改,或者就是想试用另一个类,也只要修改开始的一处代码而已。甚至在创 建子类时,也可以利用外包书写,如:
let s:CPack = class#long#path#topic#subject#use()
let s:class = class#old(s:CPack.class)
" 等效于
" let s:class = class#long#path#topic#subject#old()
另外要提示的是,class#use()
函数会记录已经被外包使用的类。所以在正常运行时, 每个类只会创建一个外包,在多个脚本中使用同一个类的外包时,并不会增加额外的开销 。
类文件框架自动生成
从以上内容可感知,创建一个自定义类文件,有着大致相似的框架,主要包含以下几部分 内容:
- 创建
s:class
字典,可以是简单的空字典或继承其他类; - 为
s:class
增加数据属性键,可用初始值约定数据类型; - 为
s:class
创建字典函数,用作类的方法; - 提供一些必要的自动加载函数。
为了节省键盘录入字符的工作,vimloo 也提供了一些命令,用于根据模板文件生成类定 义文件的基本框架。这可节省 VimL 类开发者的大量工作,通过命令生成基础代码(甚至 可以再自定义映射,一键生成)后,只要再填充必要的类定义实现即可。
:ClassNew {name}
当前目录在某个autoload/
或其子目录时可用,提供一个文件 名参数,将新建一个.vim
文件,并根据该文件名创建一个类。:ClassAdd
当正在编辑autoload/
或其子目录下的某个.vim
文件时,用该命 令向当前文件添加一个类定义。:ClassPart {option}
与:ClassAdd
类似,但只根据选项生成部分代码,而非全 部代码,用于补遗。
类定义的框架模板文件位于 vimloo 项目的 autoload/tempclass.vim
,这也是一个符合 VimL 语法的脚本,同时也是个五脏俱全的类定义文件。该文件的每个段落开始有行注释 ,注释行末尾是类似 -x
的选项字符串,其中若小写字母表示默认生成这段代码,大写 字母表示不生成这段代码。但以上命令可附加额外选项覆盖默认行为,多个选项字母拼在 一起当作一个参数传入。
若使用时还遇到疑问,请参考 vimloo 项目的说明文档或帮助文档。