VBA中的类

一. 前言

类(class), 这个话题在各种语言中的入门知识中, 是很多懂哥喜欢装神弄鬼的话题, 假如检索类相关的信息, 很容找到以下相关的内容:

  • 多态
  • 继承
  • 封装
  • 抽象
  • 对象
  • 实例
  • 方法
  • 重载
  • 对象: 对象是类的一个实例, 有状态和行为. 例如, 一条狗是一个对象, 它的状态有: 颜色, 名字, 品种; 行为有: 摇尾巴, 叫, 吃等.
  • : 类是一个模板, 它描述一类对象的行为和状态.

大量关于类的信息, 或源于Java这门语言, 或相关概念被Java发扬光大, 其他的语言的学习者在接触到类的时候, 或多或少都会受到来自于Java的影响, 各种生搬硬套, 强行移植概念, 不分场合, 强行为了实现而实现, 用牺牲代码执行效率, 可阅读性等去换取xx概念的实现....

from abc import ABC, abstractmethod

class A(ABC):
    @abstractmethod
    def say(self):
        print('a')

class B(A):
    # 假如不重写这个父类的方法, 将直接报错
    # TypeError: Can't instantiate abstract class B with abstract method say
    def say(self):
        print('b')

b = B()

b.say()

python中的抽象类为例, 上述的代码在于规范代码的书写, 例如希望子类在继承父类必须带有某个方法, 使用@abstractmethod装饰器强制要求.

上述的各种类的概念, 其都是有明确的目的性和作用以及适用场景, 而不在于是否实现类似的概念.

img

(图: 创建类模块)

相关的概念就不在这里陈述了, 在vba中讨论这些概念也没什么意义, 以下主要讨论如何实现, 以及类的各种细节.

二. 模块

img

vbe中, 提供了 5 种不同的模块.

  • 工作表模块
  • 工作簿模块
  • 窗体模块
  • 普通模块
  • 类模块

前三者可以认为是特殊的类模块, 但是需要注意的是窗体模块的模块级别变量声明(包括public声明的变量), 而不会像其他模块可以保持变量, 除非主动销毁, 其变量的生命周期为窗体关闭即销毁.

在代码作业中, 不建议将普通的代码过多写入前三者中, 尽量保持前三者的整洁干净, 只写入和操作三者相关的代码.

声明全局的public变量(非必要, 不要声明public级别的变量), 建议放置于普通模块中.

三. 为什么需要类

假设一个类是一个播放器, 有开始(play), 有暂停(pause), 有快进(forward).....

img

面向对象是一种符合人类思维习惯的编程思想. 在程序中使用对象来映射现实中的事物, 使用对象的关系来描述事物之间的联系. 面向对象指以属性和行为的观点去分析现实生活中的事物. 面向对象编程是软件产业化发展的需求. 面向对象编程的核心思想是, 将程序中的实体, 数据和功能抽象为单独的对象, 并在这些对象之间建立联系.

  • 物以类聚, 代码更为清晰.
  • 内存管控, 更好控制.
  • 状态维持/记录.

代码模块化, 堆积木式的代码组合: 易维护, 更高开发效率, 易扩展.

四. 声明变量

要充分理解类, 需要理解声明变量 (VBA) | Microsoft Learn这个东西.

img

强烈建议在编辑器中启用强制变量声明, 声明变量类型, 这是代码规范的基础.

  1. Public 语句

    使用 Public 语句声明公共模块级变量.

  2. Private 语句

    当在模块级别下使用时, Dim 语句等效于 Private 语句. 为了使代码更易于读取和解释, 你可能需要使用 Private 语句.

    因为但一个dim在模块声明时, 不容易区分, 加上private, 更容易知道这是在模块中使用的变量.

  3. Static 语句

    当使用 Static 语句而非 Dim 语句来声明过程中的变量时, 声明的变量将在该过程的两次调用之间保留其值.

    这个声明方式, 很少见, 不常用.

声明变量的作用范围

  • 函数级
  • 模块级
  • 全局
Option Explicit
Dim module_2 As Long '模块级别'
Public m_2_var As Long '全局可访问'

Sub tes()
module_2 = module_2 + 1
End Sub

Sub test()
    Dim a As Long '函数级别'
    a = a + 1
End Sub

要理解三个关键词之间的差异, 主要看变量的存活时间和外部的可访问状态.

img

Option Explicit
Dim module_2 As Long

Sub tes()
	module_2 = module_2 + 1
End Sub

当第二次执行时, module_2这个变量不是初始化的0, 而是保存了上次执行的结果1.

img

Sub test()
    Dim a As Long
    a = a + 1
End Sub
Option Explicit
Dim module_2 As Long
Public m_2_var As Long

Sub tes()
	module_2 = module_2 + 1
End Sub

Sub test()
    Dim a As Long
    a = a + 1
End Sub

当在m2模块中创建上述代码, 在m1访问, 可以看到Dim module_2 As Long这个变量并没有访问到, 实际上模块级别的dim实际上等价于private, private只是在名称上区分度更高.

img

某种程度也可以认为普通模块是类模块的一种特殊存在.

  • 函数级, 用完就自动销毁, 外部完全不可访问.
  • 模块级, 用完, 需要手动销毁, 模块内可以随意访问, 外部不可访问.
  • 全局, 用完, 需要手动销毁.

五. 声明语句

函数语句 (VBA) | Microsoft Learn

这里只讨论声明sub, function, property部分, 不讨论API声明的其他部分.

Public 可选. 指示 Function 过程可由所有模块中的所有其他过程访问. 如果在包含 Option Private 的模块中使用, 那么该过程不能在项目的外部使用.
Private 可选. 指示 Function 过程仅能由声明它的模块中的其他过程访问.
Friend 可选. 仅在类模块中使用. 指示 Function 过程在整个项目中可见, 但是对于对象实例的控制器不可见.
  • friend

    在窗体模块或类模块中修改过程的定义以使其可从外部的, 但属于定义此类的项目的一部分的模块中进行调用. 标准模块中无法使用 Friend 过程.

    最后提一下Friend关键字, 虽然在VBA中几乎没有什么用, 但如果有一天你要制作ActiveX部件, 可能会用到它. 之所以要有Friend关键字, 是因为类的私有部分在类模块外是不可见的, 但有时却需要从外面访问这些私有部分, 这时, 可以使用Friend关键字使属性和方法成为" 友元成员" . 友元成员在本工程中相当于Public, 但在工程外, 它仍是Private .

Public, Friend, 这主要用于一些相对复杂场景, 如从A工作簿访问B工作簿的代码,或者如上的控件的开发中.

5.1 Property

  • Get, 读取属性
  • Let, 设置普通属性, 不能以这种方式设置属性为对象set c.o = obj
  • Set, 设置属性为Object, 以这种方式设置属性set c.o = obj

六. 示例

Private fso As Object       ' file_object
Private log_file As String  ' file_path
Private levels() As Variant ' 日志记录的等级
private f As Object         ' textstream

' 日志记录
Sub log(ByVal text As String, Optional ByVal level As String = "error")
    Dim i As Long
    ' 返回textstream
    If Len(level) > 0 Then
        Dim flag As Boolean
        For i = 0 To 3
            If level = levels(i) Then
                flag = True
                Exit For
            End If
        Next
        If flag = False Then level = "error"
    Else
        level = "error"
    End If
    f.WriteLine CStr(Now()) + ", " + level + " : " + text
End Sub

Private Sub Class_Initialize()
    levels = Array("debug", "error", "info", "warning")
    log_file = ThisWorkbook.Path + "\log_record.txt"
    Set fso = CreateObject("Scripting.FileSystemObject")
    Set f = fso.OpenTextFile(log_file, 8, True)
End Sub

Private Sub Class_Terminate()
    log_file = ""
    f.Close
    Set f = Nothing
    Set fso = Nothing
End Sub

上述的代码是实现一个简易的日志记录.

其作业流程是这样的:

  1. 当主程序运行前, 先初始化这个日志类模块.

    Set f = fso.OpenTextFile(log_file, 8, True), 这个 f object, 将以模块级别的变量进行保存.

  2. 状态维持: 打开日志文件后, 直到主程序运行结束, 这个日志文件一直处于打开的状态, 不需要每次写入日志都重复打开一次日志文件

  3. 内存控制: 当主程序运行结束, 日志文件关闭, 释放资源

    f.Close
    Set f = Nothing
    

一个完整的示例

img

Private status As Boolean
Private control As Long
Private e_obj As Object, f_obj As Object
Private log_lev

' (参数)枚举
Enum log_level
    normal = 0
    Error = 1
    warning = 2
End Enum

' 自定义数据结构
Private Type my_data
    date As Long
    name As String
End Type

' 在参数中提示输入的类型
Function g(ByVal c_val As log_level) As Long
    log_lev = c_val
    g = log_lev + 1
End Function

Function h(ByVal c_date As Long, ByVal name As String) As Boolean
    Dim md As my_data
    md.date = c_date
    md.name = name
End Function

' 外部可访问方法
Sub a()
Dim strx(1) As String
End Sub

' 内部方法
Private Sub b()

End Sub

' 外部可访问函数
Function check() As Boolean

End Function

' 内部函数
Private Function abc() As Boolean

End Function

' 外部可访问属性, 但是不能设置
Property Get c() As Boolean
    c = status
End Property

' 外部可设置属性
Property Let d(ByVal c_val As Long)
    control = c_val
End Property
' 外部可访问属性
Property Get d() As Long
    d = control
End Property

' 外部可设置对象
Property Set e(ByRef c_val As Object)
    Set e_obj = c_val
End Property

Property Let f(ByRef c_val As Object)
    Set f_obj = c_val
End Property

' 类对象初始化
Private Sub Class_Initialize()
    control = 0
End Sub

' 类对象销毁
Private Sub Class_Terminate()
    Set f_obj = Nothing
End Sub

一个类, 通常应当具有:

  • 方法, 执行
  • 属性, 执行控制或状态记录与反馈.

七. 事件

event, 事件在编写代码中是非常重要的组成部分.

假设存在 N 多个按钮, 当点击其中的任意按钮时, 需要根据按钮的名称做出不同的响应.

img

document.onclick = (e) => console.log(e);

JavaScript中的click事件监听, 当鼠标点击页面的任意位置, 都可以捕捉到这个点击的事件, 然后根据点击的事件来执行特定的动作.

img

  • 窗体模块
Option Explicit

Private sets As New Collection

Private Sub UserForm_Initialize()
Dim mycls As myclass
Dim r

' 需要将对应的按钮绑定到一个新的class对象, 使用较为麻烦
For Each r In Me.Controls
    If TypeName(r) = "CommandButton" Then
        Set mycls = New myclass
        Set mycls.my_cmd = r
        sets.Add mycls
    End If
Next
Set mycls = Nothing
End Sub

Private Sub UserForm_Terminate()
    Set sets = Nothing
End Sub
  • myclass类模块
Option Explicit

Private WithEvents mycmd As CommandButton

Private Sub mycmd_Click()
    Debug.Print mycmd.Caption
End Sub

Property Set my_cmd(ByRef cm As Object)
    Set mycmd = cm
End Property

7.1 Excel层级事件

img

创建一个针对excel整个程序的监听, 打开任意的文件, 都打印出文件的名称.

  • app_spy 类模块
Option Explicit

Private WithEvents app As Excel.Application

Property Let set_app(ByRef obj As Object)
    Set app = obj
End Property

Private Sub app_WorkbookOpen(ByVal Wb As Workbook)
    Debug.Print Wb.Name
End Sub

Private Sub Class_Terminate()
    Set app = Nothing
End Sub

  • workbook模块
Option Explicit

Private app As app_spy

Private Sub Workbook_Open()
    Set app = New app_spy
    app.set_app = ThisWorkbook.Application
End Sub

将文件保存为xlam加载项.

img

类模块, 使得vba在操作excel的各个方面上获得更大的自由度.

八. Implements

先不理会Implements这个单词的中文翻译如何, 但是只要检索vba继承, 就会出来一堆号称继承的示例.

img

但是当检索英文资料的时候, 会得到相对清晰而准确的描述: vba不支持继承.

img

正如开篇所言, 类相关的内容, 在中文环境下由于多种原因出现各种混乱的描述.

回来看这个单词的含义:

implement

  • **v.**实施; 执行; 落实(政策); 使生效
  • **n.**工具; 器具; 〈英〉【法】履行(契约等)
  • Web实现; 实现接口; 抽象类是否可实现

img

(图: pycharm菜单)

Java implements 关键字 (w3schools.cn)

implements 关键字用于实现interface接口.

interface 关键字用于声明仅包含抽象方法的特殊类型的类.

要访问接口方法, 接口必须由另一个具有implements关键字( 而不是 extends) 的类"实现"( 类似于继承) . 接口方法的主体由"implement"类提供.

8.1 继承?

img

简而言之, 继承的一大特点或者说好处在于: 提高代码的复用.

下面以python为例:

>>> class A:
...     status = []
...     def say(self, s_val, mode):
...         print('hello: ' + s_val)
...         self.status.append({s_val: mode})
...
>>> class B(A):
...     ...
...
>>> class C(A):
...     ...
...
>>> b = B()
>>> b.say('b', True)
hello: b
>>> print(b.status)
[{'b': True}]
>>> c = C()
>>> c.say('c', False)
hello: c
>>> print(c.status)
[{'b': True}, {'c': False}]

B, C 均继承 A 类, 共享了A中的say()(方法) 和 status(属性), 这就是类继承的好处的简单示例, 代码复用.

在回来看vba中提供的Implements

img

  • 常规模块
Option Explicit

Sub test()
Dim x(1) As father_module
Dim i As Long

Set x(0) = New son_a
Set x(1) = New son_b

For i = 0 To 1
    x(i).say
Next

End Sub
  • father_module, ("父类")类模块
Option Explicit

Sub say()
    '不需要任何代码'
End Sub
  • son_a, ('子类')类模块
Option Explicit

Implements father_module

Private Sub father_module_say()

    Debug.Print "inherit + a"

End Sub
  • son_b, ("子类")类模块
Option Explicit

Implements father_module

Private Sub father_module_say()

    Debug.Print "inherit + b"

End Sub

可以和上述的python的类继承代码进行比较, vba 中的Implements是否带有 "继承" 的味道? (是否实现代码的复用这一重要特性?)

img

当删除了其中子类中的"继承"自父类的方法, 就会出现上述的报错.

这不就是最开篇提及python中的类@abstractmethod抽象方法, 子类必须实现继承自父类的方法.

8.2 小结

显然Implements是抽象类方法, 虽然也带有了"继承"的特性, 但是和特性的关系不大, 强行将之和其他语言中的继承套用在vba上不是很合适.

从上面的内容可以知道, 抽象类方法对于管理代码, 标准化函数的命名显然具有强制性.

九. 总结

img