Access 开发框架(翻译+改编)系列之三——集合、类和垃圾回收

Access 的垃圾回收(garbage collection)跟它的健壮性相比差得太多。当我们开始使用更高级 … 继续阅读“Access 开发框架(翻译+改编)系列之三——集合、类和垃圾回收”

Access 的垃圾回收(garbage collection)跟它的健壮性相比差得太多。当我们开始使用更高级的结构,比如类(class)和集合(collection)的时候,我们可能会遇到对象没有从内存中释放的问题。如果你保存了一个指向任何对象,例如窗体,控件或自定义类的指针,并且你永远不释放这个指针,然后你关闭 Access,表面上看,你好像关闭了,但实际上,内存并没有被释放出来还给操作系统。这会导致内存泄露,以及一些古怪的问题,甚至会阻止 Access 的关闭。

假定你使用一个类,这个类可以初始化其他的类。类A实例化后赋给变量 clsA。clsA 实例化其他 10 个类,将这 10 个类的对象变量保存在集合(collection)中。当 clsA 被置为 nothing 后,一般会认为 Access 会自动的清除掉这个类中的所有变量,包含集合中的对象变量,指向记录集的变量,指向窗体和控件的变量等。不幸的是,实际情况并不总是这样。当它不是这样时,你要确保它能被清除。(翻译这段的时候,我特意在 Access 2010上做了一点测试,实际情况似乎总是能正常被清除。有兴趣的可以下载这个文件3.1 通过类创建一个窗体的10个实例,打开该文件后,可以运行宏“CreateObjA”, 实例化 clsA,clsA 在初始化的时候会创建10个Form1,将其保存在它的私有集合变量mcol中。你可以看到10个Form1的拷贝被创建并打开了。最后运行宏“ReleaseObjA”,简单的将 ObjA 置为Nothing。你会发现ObjA的10个Form1对象全部被释放,没有发生内存泄露。)

举例来讲,在我的框架中,我一直都使用类来为窗体和控件定制一些通用的特性。每个窗体的头部都定义了一个窗体类 dclsFrm 的公有变量,然后在该窗体的 OnOpen 事件中被创建和初始化,dclsFrm 初始化时,传递给它一个指向当前窗体内建类(built in class)的对象变量(Me),dclsFrm 将这个指向当前窗体内建类的指针保存在 dclsFrm 类的私有变量中,这个私有变量是在类 dclsFrm 的头部定义的。

这样做的的一个好处就是,当在函数中打开某个窗体的时候,它可以让这个窗体保持打开状态,直到这个函数退出才会被关闭。它也允许 dclsFrm 根据需要直接引用控件和窗体的属性。

注意到没有?这里有问题!问题就出在这是一个环形引用。窗体有一个指向类的对象变量,类也有一个指向窗体的变量。必须采取一点措施将类和窗体中的对象变量重置,以便让类和窗体能正确的关闭和释放内存。对象编程的一条公理就是:一个对象会一直驻留在内存中,直到最后一个指向它的对象变量被设置成 Nothing。(这里我也做了一个示例文件:3.2 环形引用无法卸载的示例下载打开该文件后,运行宏“CreateFrom1”后,代码会实例化一个Form1,然后在Form1的打开时,将Form1的头部定义的类变量实例化,将指向当前Form1的指针“Me”传递给 dclsFrm 的init 方法,该方法将该指针保存在类 dclsFrm 的私有变量mfrm中。这样就形成了一个环形引用。然后运行宏“ReleaseForm1”,你会发现Form1并没有关闭。如果将函数“ReleaseForm1”中的第一句代码取消注释,你重新运行整个环节后,你会发现Form1可以正常关闭了。)

此外,dclsFrm 有一个窗体扫描函数,可以遍历窗体的控件集合,为它扫描到的每一个控件实例化一个类。当它扫描完时,如果某个窗体上有10个控件,那么我就加载了11个类 – dclsFrm 和 10 个控件类。我将指向这些控件的指针保存在 colChildren 集合中,并以控件名作为索引关键字。这样,我就能用控件名通过遍历这个集合找到任何我想控制的控件。

现在,当窗体需要关闭时,我需要清空这个加载的类,关闭里面的所有内容并释放其占用的内存。我通过一个叫做 Term() 的方法函数来做到这一点。这个函数遍历 Children 集合,调用每个子类的 Term 方法,然后将指向这个子类的指针从 Children 集合中移除。

如你所见,如果每个类都有一个类似的结构,任何类卸载前,先调用它的 Term 方法,这个Term 方法遍历并卸载它的 Children 集合中的每一个对象。这样,一个类可能有10个子类,每个子类又可能有10个子子类,每个子子类……不管你继续往下走多深……既然调用父类的 Term 方法会首先调用子类的 Term , 调用子类的 Term 会调用子子类的 Term 方法,一直到最底层的类被卸载,然后这个链接会像涟漪一样返回到最初的父类。调用父类的 Term 方法最终将会有效的卸载掉其所包含的所有子类。

因为这个原因,我们势必要严格定义类的结构,让它有效提供加载和卸载的功能。它必须是永远的,自动的发生。

是的。说的没错。

事情不是这样的。就像上面提到的环形引用,或者一个指向控件的对象变量不能被卸载,导致 Access 不能将其父窗体关闭,所以这个类永远也卸载不掉,卸载链条的涟漪永远不会发生。

混乱发生了。我们需要帮助我们解决故障的工具。在这一点上,我要感谢Shamil Salakhedinov 的工具和想法。在我刚开始学习使用类的时候,我从他那里受益颇丰。

当我开始使用类的时候,我遇到的首要问题之一就是如何跟踪记载类的加载和卸载。加载一个类,当你用完之后,而不卸载它,会导致内存泄露,类所占用的内存不会还给操作系统。有时 Access 会在关闭时将内存释放出来,偶尔它会导致 Access 无法正确的关闭,这不仅会导致内存泄露,也会导致一些其他的问题。这些鬼魂一样的 Access 实例在 Windows 98 下是看不见的,据我所知,Access 2000 之前的版本中,这些实例是无法被关闭的。Access 2000 以及之后的版本,你可以使用任务管理器来关闭它。这两种方法都是丑陋的。因此,我们跟踪记录类的加载和卸载是非常关键的。

在我当前的框架中,我使用一个长整形变量,每个类在加载时,将这个长整形变量递增1。这是我的第一个工具,从 Shamil 那里拿过来的示例代码。

Private mlngObjCounter As Long      '系统中所有类的实例的计数

Public Sub IncObjCounter()
    mlngObjCounter = mlngObjCounter + 1
End Sub

Public Sub DecObjCounter()
    mlngObjCounter = mlngObjCounter - 1
End Sub

这段代码很有用,至少它告诉你加载的对象的总数。我决定我真的想知道加载对象的名字。为了做到这一点,我添加了一个集合,在其中,我添加了类的名字,当类卸载的时候,将类的名字在集合中移除掉。

Private mlngObjCounter As Long      '系统中所有类的实例的计数
Public mcolObjNames As Collection

Public Sub IncObjCounter(strObjName As String)
    mlngObjCounter = mlngObjCounter + 1
    mcolObjNames.Add strObjName
End Sub

Public Sub DecObjCounter(strObjName As String)
    mlngObjCounter = mlngObjCounter - 1
    mcolObjNames.Remove strObjName
End Sub

然后我可以用一个函数将集合中的名字全部读书来:

Public Function ObjNames() As String
On Error GoTo Err_ObjNames

    Dim strName As Variant
    Dim str As String
    For Each strName In mcolObjNames
        If Len(str) > 0 Then
            str = str & "; " & vbCrLf & strName
        Else
            str = strName
        End If
    Next strName
    ObjNames = str

End Function

这段代码工作非常好,我在系统中用了它很长一段时间。

对于新的框架,我决定将一个实际指向这个对象的指针也保存下来,而不仅仅只是保存对象的名字。这个类调用一个函数,在对象加载的时候,该函数将一个指向该对象的指针保存在集合中,另一个函数在该对象卸载的时候将它的指针移除掉。这样做了之后,我就有了一个单独的地方,在这个地方我可以查看和控制任何对象。我们要处理的问题之一是“指向这个类的指针存储在哪里”?换句话讲,类可能在窗体代码中被加载,那么指针就存储在窗体的头部变量中;或者类在某函数的一段代码中被加载,那么指针就存储在这个函数的当前变量中;亦或类在另一个类中被加载,那么指针就这个类的集合变量中。如果你想查看这个类,执行一个方法,检查一个属性等,你如何得到一个指向该类的指针呢?通过将指向所有加载类的指针存储在一个集合中,我们可以:

  • 通过集合的Count属性,可以获得所有加载的类的总数
  • 通过遍历集合,可以获取其中的对象名字列表
  • 通过指向类的指针,可以根据需要来控制其对象

因为这些,我已经决定使用这种方法。

免责声明:非常重要的是,你要意识到,类不会真的从内存中卸载,除非最后一个指向类的指针被设置为 nothing。这样,如果你实例化一个类,将指向这个类的指针存储在集合中,同时也在窗体头部(举例来说)的变量存储这个指针。这个类是不会卸载的,除非你在集合和窗体中删除掉了指向这个类的指针。如果你的代码在别的地方也保存了指向这个类的指针,这个类也是不会卸载的,除非这3个指针都被设置为nothing。我的类的 init() 和 term() 方法将指针保存在对象跟踪记录类的集合中,同时指针也被保存在当前类的的父类的 child 集合中(详见后续系列),当类卸载的时候,它会将这2个地方的指针清除掉。然而,如果你的代码在另外的地方保存了指向这个类对象的某个变量,我也不知道它,无法将其清除,那么这个类将一直无法卸载,直到你将你的指针设置成nothing。

一旦到了我们要使用这些功能的时候,我将创建一个类 clsInstanceStack 为框架提供对象追踪记录的服务。

(本篇讨论的是建立一个框架必须要解决的对象释放的问题,看不懂也没有关系。从下一篇开始往后,将从最基础的类的介绍开始,详细讨论创建一整套框架的每一个细节问题。有兴趣了解的请留言。另外网站也开通了QQ登陆功能。免去了注册的烦恼^_^)

用VBA代码生成VBA代码

如果将来,计算机能自己编写代码了,那程序员就都失业了。 哈哈,开玩笑。这篇文章跟人工智能一点关系都没有。引入正 … 继续阅读“用VBA代码生成VBA代码”

如果将来,计算机能自己编写代码了,那程序员就都失业了。

哈哈,开玩笑。这篇文章跟人工智能一点关系都没有。引入正题前,先讲一点题外话作为引子。

先从 VBA 的错误处理谈起。稍微专业一点的 VBA 代码子程序,都包含错误处理代码。大概看起来像这个样子(示例1):

Sub Demo1()
On Error GoTo ErrorHandler
    '你的代码从这里开始

ExitProcedure:
    On Error Resume Next
    '退出前的善后工作,例如释放对象等

Exit Sub
ErrorHandler:
    MsgBox Err.Description, vbCritical, "Error From Demo1"
    Resume ExitProcedure
End Sub

这是一个最简单的错误捕捉结构,代码第2行告诉程序,发生错误,转到第11行,然后通过信息对话框显示这个错误的描述给用户,用户看完这个描述,点“确定”按钮,程序转到去执行第6行的代码。第6行代码告诉程序,如果再次发生错误,直接忽略掉,继续执行后续的代码,直到遇到“Exit Sub”后,结束整个子程序。

在示例1中,遇到任何错误,都执行相同的错误处理办法。这也是我早期一直使用的结构。

我目前使用的结构是下面的样子(示例2):

Public Sub Demo2()
    On Error GoTo ErrorHandler
    '你的代码

ExitProcedure:
    On Error Resume Next
    '退出前的善后工作,例如释放对象等

Exit Sub
ErrorHandler:
    Select Case Err.Number
    '预期的错误代码处理方式

    Case Else	'预期以外的错误处理
        Call UnexpectedError(Err.Number, Err.Description, _
                               Err.Source & ".代码模块名.Demo2")
    End Select
End Sub

示例2中,你可以将预期中的各种错误,用不同的方式去处理。预期以外的错误,用一个叫做 UnexpectedError 的函数去统一处理。我使用这个函数,将所有预期意外的错误都记录了到一个错误表中,存储到 Access 前台客户端,在用户退出客户端时,将该用户的记录数据传递到后台数据中。这样我就得到所有用户的意外错误。然后去分析到底发生了什么事情,进而进一步去改进自己的程序。

关于VBA的错误处理相关的知识和技巧还有很多,不过这不是本文要谈论的重点。


楼差点盖歪了。现在进入主题。

话说这种错误处理的结构,好用确实是好用。但是,给你写的每一个函数,子程序,例如100个,都套用这种结构,这可是一件不小的体力活。你注意到没有?示例1中的“Error from Demo1”和示例2中的“.代码模块名.Demo2”,你虽然可以复制粘贴大部分的结构,但是这2块,是需要将当前的子程序的名字和所在的代码模块的名字写入进来的。这意味着你复制粘贴完之后,还需要修改这个2个地方。

如果你觉得才2个地方而已,你打字速度快的话,2秒就解决了。好吧,为避免不愉快发生,下面的内容你可以不用看了。

我时常在想,“码农”这个词汇的意味着什么。它至少意味着一点,就是你做的事情没啥技术含量,肯吃苦的人都能干。你复制粘贴100次,花200秒的时间,这件事情谁不会干?

如果不甘心做“码农”,想从这件非常机械的事情中解放出来的话,恭喜你,下面的内容就是为你而写。(哎,这文章写的真累。。。前戏做得有点太久。。。)


我们直奔主题。

让VBA代码直接去生成这个错误捕捉结构!

Public Enum BldType1
    jhFunction = 1                         '所建立的子程序类型是函数
    jhsub = 2                          '做建立的使子程序
End Enum
Public Enum BldType2
    jhPublic = 1                         '所建立的子程序是公有
    jhPrivate = 2                          '所建立的子程序是私有
End Enum

'该子程序在当前活动模块的最下面添加一段以参数命名的函数结构
Public Sub bldfunc(ByRef lstrFunctionName As String, ByVal ltype1 As BldType1, ByVal ltype2 As BldType2)
    On Error GoTo ErrorHandler
    Dim objModule As Access.Module
    Set objModule = Application.Modules.Item(Application.VBE.ActiveCodePane.CodeModule.Parent.Name)
    With objModule
        .InsertLines .CountOfLines + 1, ""
        .InsertLines .CountOfLines + 1, IIf(ltype2 = jhPublic, "Public ", "Private ") & _
                IIf(ltype1 = jhFunction, "Function ", "Sub ") & lstrFunctionName & "() " & IIf(ltype1 = jhFunction, "As Boolean", "")
        .InsertLines .CountOfLines + 1, "    On Error GoTo ErrorHandler"
        .InsertLines .CountOfLines + 1, "    "
        .InsertLines .CountOfLines + 1, "    '程序代码从这儿开始"
        .InsertLines .CountOfLines + 1, "    "
        .InsertLines .CountOfLines + 1, "    "
        If ltype1 = jhFunction Then .InsertLines .CountOfLines + 1, "    " & lstrFunctionName & "=True"
        .InsertLines .CountOfLines + 1, "ExitProcedure:"
        .InsertLines .CountOfLines + 1, "    On Error Resume Next"
        .InsertLines .CountOfLines + 1, "    '清理退出代码"
        .InsertLines .CountOfLines + 1, IIf(ltype1 = jhFunction, "Exit Function", "Exit Sub")
        .InsertLines .CountOfLines + 1, "ErrorHandler:"
        If ltype1 = jhFunction Then .InsertLines .CountOfLines + 1, "    " & lstrFunctionName & "=False"
        .InsertLines .CountOfLines + 1, "    Select Case Err.Number"
        .InsertLines .CountOfLines + 1, "    '预期中的错误"
        .InsertLines .CountOfLines + 1, "    Case Else"
        .InsertLines .CountOfLines + 1, "        Call UnexpectedError(Err.Number, Err.Description, Err.Source & " & """." & .Name & "." & lstrFunctionName & """)"
        .InsertLines .CountOfLines + 1, "    End Select"
        .InsertLines .CountOfLines + 1, "    Resume ExitProcedure"
        .InsertLines .CountOfLines + 1, IIf(ltype1 = jhFunction, "End Function", "End Sub")
    
'        DoCmd.Save acModule, .Name ': Debug.Print objModule.Name
    End With
ExitProcedure:
    On Error Resume Next
    '清理退出代码
    Set objModule = Nothing
Exit Sub
ErrorHandler:
    Select Case Err.Number
    '预期中的错误
    Case Else
        Call UnexpectedError(Err.Number, Err.Description, Err.Source & ".basFrameCodeBuildAssist.bldfunc")
    End Select
    Resume ExitProcedure
End Sub

将上述代码复制粘贴到一个标准模块中,然后进入到你想添加新子程序的模块(不论是窗体模块,还是标准模块,亦或是类模块),然后在立即窗口中,键入

bldfun “你的子程序名”, jhSub, jhPrivate

立刻就生成了你想要的结果。


本来文章应该就结束了。不过感觉话还没说完。

  1. VBE对象让你能控制你的代码编写环境。它除了可以帮你自动写代码,应该还能干更多的事情,我目前没更多深入,如果你有,欢迎分享。
  2. 用代码生成代码,这种能力,除了上述的生成错误捕捉结构外,你还能想到别的用途吗?我有一块代码,可以为表建立对应的类模块,为3层数据访问结构(表 <-> 对象 <-> 用户界面)中的对象创建类模块。你绝对不愿意为你的每张业务表,都手写200多行代码,特别是这些类结构都拥有统一的对内和对外接口的时候。

后记:有读者反馈说代码复制下来后,运行报错,我看了一下,确实是的。因为代码中并没有函数UnexpectedError的定义代码。当我准备把UnexpectedError的代码贴出来时,又发现代码中又调用了另外两个函数myMessageBox和GetUserName,而且还引用了项目级的条件编译常量CC_DEBUG1,外加一个表usysTblRunTimeErrorLog。为了简化,将其中一些内容重新修改了。现上传文件供大家下载用VBA代码生成VBA代码