Access 开发框架(翻译+改编)系列之七——类模板

终于,我们到了该要面对类模板的时候。为了让类提供故障排查工具,和调试帮助的功能,我们需要为类添加统一的接口(I … 继续阅读“Access 开发框架(翻译+改编)系列之七——类模板”

终于,我们到了该要面对类模板的时候。为了让类提供故障排查工具,和调试帮助的功能,我们需要为类添加统一的接口(Interface)。如果你记得系列之三——集合、类和垃圾回收中,我讨论过类的一些问题,特别是那些引用窗体和控件的类。除非非常小心仔细,我们可能会在卸载类的时候遇到麻烦,从而导致内存泄露,甚至Access都无法关闭。现在是时候好好讨论这些问题了。

这里,请大家保持更多的耐心,我真的不想因为要讨论的这些问题和代码,失去任何读者。但是我知道你确实需要一点时间来适应这些内容。

我将讨论一个叫模板的类(Template class), 我们以后创建的任何新的类都将复制这个模板类,所有的必要的接口结构都在这里,然后粘贴到新的地方,作为建立新类的基础,并在该基础上添加新类特有的部分。你可以在示例文件7.1类模板中看到模板类的所有代码。

模块注释和开头部分(Header)

我们要做的第一件事情,是为类添加一个标准的头部注释,我将一些常见的信息放在里面,比如类的作者、创建日期、版权信息等。这个区域用来为这个类做注释,解释类的一些行为,这个类的是用来做什么的,以及一些其他的你想放在此处的说明。帮助你自己一周之后再回来看到这个类时,能很快想起来你做了什么。

Option Compare Database
Option Explicit

'.============================================================================
'.Copyright 2017 Jasoftiger 版权所有.
'.QQ        :185437290
'.QQ群      :113700839
'.============================================================================
'.请不要删除以上注释。模块中所有其他注释可以被删除。
'.----------------------------------------------------------------------------
'.描述      :
'.
'.作者      : Jasoftiger
'.创建日期  : 2017-05-18
'.修改历史  :
'.
'.评论      :
'
'.----------------------------------------------------------------------------
'.附加注释
'.
'.----------------------------------------------------------------------------
'.用法说明:
'

在这之后,我为常量和变量添加了标准的代码块结构,以及一些预定义的常量。

'.----------------------------------------------------------------------------
'以下常量和变量用于实现框架接口
'*+框架常量声明
Private Const mcblnDebugPrint As Boolean = False
Private Const mcstrModuleName As String = "clsTemplate"
'*-框架常量声明

常量mcblnDebugPrint是一个开关,用来决定是否将本模块中的调试语句打印到立即窗口。为此,我们将调用一个名为assDebugPrint的函数,而不是直接使用Debug.Print语句。我们将这个常量传递给这个函数,函数根据常量的值来决定是否将调试语句打印到立即窗口。如果将常量mcblnDebugPrint设置为True,那么本模块中的所有调试语句都将打印到立即窗口;如果设置为False的话,就不会打印。

另外一个常量mcstrModuleName将类模块在数据库窗口,模块项下的名字明文存储起来。(译者注:获取类名还有另外一种简便的方式TypeName(Me),不知道原作者为何不用这种方式,当然使用前“Me”必须已经被实例化了。

接下来是类的变量声明:

'*+框架变量声明
'指向创建该类实例的对象 (该类的父类)
Private mobjParent As Object

'子集合用来存储该类实例引用的所有子对象(子类实例),这些子对象需要被实例化和销毁
'通常这些用于控件,比如TAB控件,或者记录选择控件,但是他们也可额能是商业规则类等
Private mcolChildren As Collection

'通过模块名和随机数形成
Public mstrInstanceName As String

'简单的名字,用于关键字等
Public mstrName As String
'*-框架变量声明

此刻,我们也需要讨论一下,我们用来“自动”创建和销毁类的一些过程,毕竟,这个模板类的整个开头部分就是用来做这个的。你将在这儿添加自己的变量,但是到目前为止,你看到的内容都是框架的接口。

类很少单独存在于框架之中。类经常使用子类,子类也经常有它们自己的子类。窗体类dclsFrm就是一个很好的例子,dclsFrm可以使用组合框类dclsCbo,组合框类dclsCbo可能会使用从属类dclsDepObj(下一系列将介绍)。非常明显,窗体类可能会通过控件扫描函数,实例化一打控件类,这些指向控件类对象的指针被保存在集合中。一个组合框可能有一个或多个从属类对象,以便基于组合框的值,筛选自己的数据,如此一来,组合框控件就有了从属类dclsDepObj,该从属类对象保存的指针指向那些从属于该组合框的控件。

所有这些使用类的类,祈求有一个统一的接口界面。这样,每一个类都把指向自己父类的指针保存到mobjParent对象变量中。

'*+框架变量声明
'指向创建该类实例的对象 (该类的父类)
Private mobjParent As Object

dclsFrm是dclsCbo对象的父对象。dclsCbo是dclsDepObj的父对象。窗体将“Me”传递给dclsFrm,dclsFrm将“Me”传递给dclsCbo,DclsCbo将“Me”传递给dclsDepObj等等。“Me”在这三种情况下都是指向当前类的指针。“Me”在窗体里,指的就是窗体的内建类(built-in class),“Me”在dclsFrm里,指的就是dclsFrm的实例。

通过向子类传递一个指向父对象的指针,任何子类都能获得它需要的父对象的任何属性和方法。实际上,为父对象添加的一些属性和方法,可能就是用于子类调用的。

此外,每个类都有一个子集合,我们命名它为mcolChildren。mcolChildren保存了指向子对象的指针。

'子集合用来存储该类实例引用的所有子对象(子类实例),这些子对象需要被实例化和销毁
'通常这些用于控件,比如TAB控件,或者记录选择控件,但是他们也可额能是商业规则类等
Private mcolChildren As Collection

类的实例名mstrInstranceName是根据模块名mcstrModuleName和其他信息组成。我最后选择了“线性命名”的方法,也就是说,每个类使用它的父类的名字加上它自己的名字。这种方法使得我们非常容易的知道任何一个给定的类名具体代表什么。

'通过模块名和随机数形成
Public mstrInstanceName As String

最后,还有一个简单的名字变量

'简单的名字,用于关键字等
Public mstrName As String
'*-框架变量声明

当类被保存在它的父对象的子集合中时,这个简单的名字变量将通常作为关键字来使用。换句话来说,类总是被存储在它的父对象的子集合中,这样,当父对象被卸载清除的时候,父对象就能清除掉它所有的子对象。集合中用作关键字的,通常是象控件名那样简单的名字。我们这样做是为了方便使用一些可用的信息(比如控件名)来索引到这对象。

 

常量和变量声明

我喜欢将开头部分的内容组织起来,让所有常量都在一起,然后是所有的变量,再然后是类可能需要抛出的自定义的事件。如果一个类变得非常复杂之后,将功能性的信息片段组合在一起,这样做应该会更方便查找一些。总的来说,这是关于组织和有条理的编写代码的方法选择,只要你按照一定的规则去编码,总会比那些没有规则的编码方式要好得多。

'.----------------------------------------------------------------------------
'以下常量和变量用于实现类的功能
'*+自定义常量声明
'
'*-自定义常量声明


'*+自定义变量声明
'
'Private WithEvents mfrm As Form     '传入的窗体指针
'*-自定义变量声明


'
'在此处定义类将抛出的任何事件
'*+自定义事件声明
'Public Event MyEvent(status As Integer)
'*-自定义事件声明

 

类的实例化和卸载

接下来,我将类的开头部分定义的所有对象的实例化语句都放入了类的初始化事件Initialize中。Initialize是类的两个固有事件之一(另一个事件是Terminate)。当Set语句实例化类,Initialize事件就被触发了,类就开始在内存中加载自己。

'.----------------------------------------------------------------------------
'以下函数和子程序用于实现框架
'*+ 类的实例化/卸载接口
Private Sub class_Initialize()
On Error GoTo ErrorHandler

    assDebugPrint "Initialize " & mcstrModuleName, DebugPrint
    Set mcolChildren = New Collection
    
ExitProcedure:
On Error Resume Next

Exit Sub
ErrorHandler:
    MsgBox Err.Description, , "Error in Sub clsTemplate.Class_Initialize"
    Resume ExitProcedure
    Resume 0    '用于调试
End Sub

Init()通常是类被调用的第一个方法,我们在这里将类的父对象传递进来,换句话说,将哪个对象实例化了该类,那么该对象就是该类的父对象,将父对象传递进来后,类对象与父类对象的链接就形成了。

'实例化类
Public Sub Init(ByRef robjParent As Object)

    Set mobjParent = robjParent
    '如果父对象有一个子集,那就将自己放在其中
    assDebugPrint "Init " & mstrInstanceName, DebugPrint
    
End Sub

当指向类的最后一个指针被设置为Nothing时,类的卸载事件Terminate就被触发了。

Private Sub Class_Terminate()
On Error Resume Next

    assDebugPrint "Terminate " & mcstrModuleName, DebugPrint
    Term
    
End Sub

Term()是类的一个方法,它通常是被父对象(实例化类的对象)的清除代码所调用。例如,dclsFrm为每个控件实例化了一个控件类(dclsCtlTextBox),将指向这些控件类的指针保存在子集(mcolChildren)中。当窗体关闭的时候,dclsFrm将遍历这个子集,调用每个控件类的Term方法,然后将控件类的指针从子集中删除。

同时,我也在类的Terminate事件中调用Term方法。任何情况下,当最后一个指向类对象的指针被设置为Nothing的时候,该类对象将自动卸载。这样,理论上来讲,只用将指向类的指针设置为Nothing,类的Terminate事件就会被触发,然后事件代码中的Term方法就被执行了。

这看起来有点“重复工作”了。是的,一点没错。我之所以这样做,是因为在一些情况下,我们很有可能为类对象设置了一个指向其自身引用(Set mobjParent=Me),这样一来,在类对象内部,就永远有一个指向该类对象的指针。如果我在其他类中也有一个指向该类对象的指针,然后我将这个指针设置为Nothing,然后,发现了吗?这个类对象根本不会被释放,因为还存在最后一个指向它的指针,位于它的开头部分(mobjParent)。然而,如果我调用Term方法,我就将它所有保存的指针清除掉了,指向自身的指针也被清除掉了,这样,当我将外部的指向该类对象的指针设置为Nothing的时候,类对象就能正常的被卸载了!

这种情况虽然只是偶尔出现,但是只要有出现的可能,为了保险起见,还是调用类的Term()方法,然后设置类对象的指针为Nothing,触发类的Terminate事件,Term()被再次调用。我们可以设置一个静态变量来帮我们记录Term()方法是否已经被执行,这样我们就不用多次运行。

'清楚掉所有的类指针
Public Sub Term()
On Error Resume Next

    Static blnRan As Boolean
    If blnRan Then Exit Sub
    blnRan = True
    assDebugPrint "Term() " & mcstrModuleName, DebugPrint
    Set mobjParent = Nothing
    Set mcolChildren = Nothing
    
End Sub
'*- 类的实例化/卸载接口

译者注:原作者对“重复工作”的解释,存在疑点。如果一个类对象的mobjParent指向其自身,则必须“显式”调用Term方法,才能将该指针清除,Terminate事件中的Term代码是执行不了的,因为类对象的最后一个指针并未清除。对“重复工作”合理的解释,应该是:在不存在环形引用的情况下,如果类的使用者未按照约定的方式去销毁类对象,也即并未先执行Term,而直接Set类对象的最后一个指针为Nothing,类对象的Terminate事件被触发,Term被调用,清除类对象的子集中的子对象。若不在Terminate事件中调用Term,会导致子对象无法被清除。

 

标准属性和方法

我们已经讨论了开头部分和类的实例化和卸载接口,接下来,我们要讨论标准的属性和方法。显然,我们需要让其他类(或者其他开发者)获得开头部分的数据。模块名ModuleName,实例名InstanceName,以及普通的名字Name,这些都需要作为类的属性,给到类的使用者读取。为了在编码时,智能感知窗口能将这些属性组织在一起,我将Name放在了属性名的前面。

'获取类模块的名字
Property Get NameModule() As String
    NameModule = mcstrModuleName
End Property

Public Property Get Name() As String
    Name = mstrName
End Property

Public Property Get NameInstance() As String
    NameInstance = mstrInstanceName
End Property
Public Property Let NameInstance(strName As String)
    mstrInstanceName = strName
End Property

最后,我们还需要有获取父对象和子集的属性。

'*+父对象,子集合外部接口
'获取父对象指针
Public Property Get parent() As Object
    Set parent = mobjParent
End Property

'获取子集对象
Public Property Get Chilren() As Collection
    Set Chilren = mcolChildren
End Property
'*-父对象,子集合外部接口

以上就是框架类模板的全部代码。框架中的每一个类都包含上述所有代码,这样才能给我们一个非常标准的编程界面,我称之为框架接口。这些与框架相关的代码与类要实现的功能几乎是没有任何关系的,它存在的唯一目的,就是让我们以一个标准的方式来创建和销毁类对象。

如果一个类不能被卸载掉,在某种情况下,会导致Access程序无法关闭退出,从这一点上来讲,框架接口设计所发挥的作用就变得非常关键。

类的功能代码

最后是类的功能代码部分。我觉得用以下的结构去安排,会更加有组织一点。我喜欢将相似的事物放到一起,当然,每个人都有自己喜欢的方式,这取决于各自的想法。是否参照,取决于你自己。

'.----------------------------------------------------------------------------
'以下函数沉没开头部分声明的WithEvents对象的事件
'*+ Form WithEvent 接口
'*- Form WithEvent 接口

'.----------------------------------------------------------------------------
'以下函数/子程序用于实现类的功能
'*+私有类函数,子程序
'*-私有类函数,子程序
'*+公有类方法,属性
'*-公有类方法,属性

将框架类接口应用到已有的类上

我将会按照上面介绍的框架接口的标准,修改以前系列中创建的所有的类。我们需要花一点时间来适应类中的这些框架接口相关的代码。

总结

框架变得更加复杂了,框架中有很多类,类会实例化其他的类,在子集合中创建自己的子对象,子对象又可能会实例化子子对象,形成一条对象链。类对象销毁自己的时候,先会销毁自己的子集合中的子类对象,子类对象被销毁前,会先销毁子子类对象,子子类对象销毁自己前,会。。。。。。你看懂了吗?类对象销毁时,销毁的是一整条对象链。为了让类对象按照这个逻辑来创建和销毁自己,我们必须有一个系统上的安排,让它来帮助我们自动做到这一点,否则,我们将陷入混乱不堪的局面。本系列所讲的所有内容,就是尝试去标准化类的创建和销毁,以一种我们可以依靠的相同方式,不管我们实例化的是什么类。