十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

VBA类属性只能赋值一次:三种实现方案与踩坑实录

VBA类属性只能赋值一次:三种实现方案与踩坑实录 又到了“二师兄”系列的时间。这一讲要解决的问题说难不难说简单也不简单怎么让一个 VBA 类的属性只能被赋值一次。说白了就是属性一旦拥有了第一个值之后任何人都改不动它。放到真实场景里这种需求太常见了——对象的编号、创建时间、员工工号、法号、序列号都属于“生下来就定死了”的数据后面流程里只能读、不能写。我最早接到这类需求是在做一个 Excel 绩效统计工具的时候。表的行数、每个人的员工编号、报表生成时间这些字段如果被后面的宏误改一次整个统计结果就废了。当时我就想如果能像写 C# 时用 readonly 关键字那样给 VBA 属性加一道锁就好了。可惜 VBA 没有这个原生能力只能靠我们自己设计。这一讲就完整拆解这种“只写一次”属性的实现思路、完整代码以及我踩过的那些坑。1. 先搞懂 VBA 类属性的读写机制1.1 三个 Property 关键字的角色在 VBA 的类模块里属性不像 Python 或 Java 那样直接暴露字段而是通过 Property 关键字对外提供读写接口。很多新手刚接触时会被这套东西绕晕其实拆开看就三类Property Get读属性值类比“查户口”只读不改。Property Let给普通数据类型String、Long、Boolean 等赋值的入口。Property Set给对象引用赋值的入口本质上也是一种赋值但触发的是 Set 语句。正常情况下这三个方法不是必须同时出现。你只写了 Property Get那么这个属性就是只读的你只写了 Property Let就是只写不读你想读写都能做就 Get 和 Let 同时写。类内部通常用模块级私有变量保存真正的数据例如 类模块clsDemo Private m_sName As String Public Property Get Name() As String Name m_sName End Property Public Property Let Name(ByVal vNewValue As String) m_sName vNewValue End Property这样外部代码就可以用obj.Name abc来赋值用Debug.Print obj.Name来读取。数据始终存在私有变量m_sName里不让外部直接碰。1.2 为什么默认情况下属性可以反复赋值很多人第一次接触类属性时会误以为属性背后有什么“赋值次数限制”其实默认情况下一次都没有。Property Let 不过是一个普通方法每次执行赋值语句时都会被调用方法内部只是简单地把新值存进私有变量旧值直接覆盖掉。举个生活例子这就像你把自己房间的钥匙交给管理员管理员手里有一本登记册每次有人来登记他就把登记册上的名字擦掉重写。没有人告诉他“同一个格子只能填一次”他当然会配合每一次登记。VBA 属性默认就是这样来者不拒。所以要让属性只能赋值一次必须由我们自己在赋值入口处加逻辑判断而且这个判断要足够可靠不能指望“大家约定好不去改”。1.3 类属性赋值要过的三道“检查关”如果想彻底搞明白这个案例背后的原理建议把一次属性赋值拆成三个层级来看编译检查VBA 在编译阶段会先确认对象确实有这个属性属性类型是否匹配以及赋值语句是否需要 Set 关键字。对象属性漏写 Set 会直接编译报错普通属性多写 Set 同样报错。运行时类型转换检查如果右值类型和属性参数类型不一致VBA 会尝试做隐式转换。比如属性定义为 Long你传一个字符串“123”它能转过来但传“abc”就会在运行时弹出“类型不匹配”。逻辑检查这是我们需要自己补的关卡。VBA 没有内置 readonly 机制所以“只允许赋值一次”这种业务规则只能写进属性方法里。第三道检查就是我们这一讲要重点实现的内容。2. 三种让属性“只写一次”的实现思路2.1 方案一布尔标志位守卫这是最直观、也最常用的一种。核心思路是类内部增加一个 Boolean 型私有变量初始值为 False。在 Property Let 或 Property Set 的开头先判断这个标志位如果已经是 True说明赋值过了直接拒绝如果还是 False则执行赋值并置为 True。 类模块clsOnlyOnceFlag Private m_sCode As String Private m_bCodeSetted As Boolean Public Property Get Code() As String Code m_sCode End Property Public Property Let Code(ByVal vNewValue As String) If m_bCodeSetted Then Err.Raise vbObjectError 1001, clsOnlyOnceFlag.Code, Code 属性只能赋值一次 End If m_sCode vNewValue m_bCodeSetted True End Property这里有一个关键选择第二次赋值时是静默退出还是抛错误我强烈建议抛错误。如果直接Exit Property调用方会以为赋值成功了后面的数据结构已经变了但实际值没变这种“无声失败”在调试时极其痛苦。抛一个业务错误程序会在第一次非法赋值时就停下来反而能快速定位问题。2.2 方案二把写入口收窄成初始化方法方案一虽然能实现功能但有一个问题外部看起来这个属性仍然是可写的只是运行时会抛错。如果代码里有人习惯性写一行obj.Code 新值编译期一点反应没有非要运行到那一行才炸。更好的做法是从数据结构上“堵死”这条路属性只留 Property Get不再提供 Property Let 和 Property Set另加一个 Init 方法作为唯一写入口。 类模块clsOnlyOnceInit Private m_sCode As String Public Property Get Code() As String Code m_sCode End Property Public Sub InitCode(ByVal vNewValue As String) If Len(m_sCode) 0 Then Err.Raise vbObjectError 1002, clsOnlyOnceInit.InitCode, Code 已经初始化不能重复设置 End If m_sCode vNewValue End Sub这段代码用Len(m_sCode) 0作为判断条件严格来说也不算完美因为如果业务上允许 Code 的合法值就是空字符串那就没法区分“没赋值”和“赋值成了空串”。更稳妥的做法仍然是配一个标志位Private m_bInited As Boolean这种方式最大的优点是调用方不可能写成obj.Code xxx因为根本没有 Let/Set编译阶段就会报错。这在多人协作的项目里非常有用相当于把“只能赋值一次”这个规则写进了编程接口本身。2.3 方案三利用集合或字典的 Key 唯一性做变体有的读者喜欢用 VBA 的 Dictionary 来管理类内部数据这时候“只赋值一次”的逻辑可以换一种写法判断 Key 是否已经存在。 类模块clsDictOnce Private m_dictProps As Object Private Sub Class_Initialize() Set m_dictProps CreateObject(Scripting.Dictionary) End Sub Public Property Get Code() As String If m_dictProps.Exists(Code) Then Code m_dictProps(Code) End If End Property Public Property Let Code(ByVal vNewValue As String) If m_dictProps.Exists(Code) Then Err.Raise vbObjectError 1003, clsDictOnce.Code, Code 已经存在 End If m_dictProps.Add Code, vNewValue End Property本质上还是“有没有赋过值”这套逻辑只不过把私有变量的存在性判断换成字典 Key 的存在性判断。优点是如果你要管理几十个只写一次属性不用给每个属性都配一个标志位缺点是字典操作相对慢一点而且 Scripting.Dictionary 在 WPS 里的支持情况也需要注意。如果属性数量少我更倾向于方案一或方案二。2.4 三种方案怎么选我把这三个方案的取舍整理成一张表方便大家按项目情况直接套用方案可读性对调用方侵入性编译期阻止适合场景布尔标志位中等容易理解无外部仍写属性赋值否属性少、需要快速改造成本低初始化方法高语义清晰高外部要改调用方式是新写类、多人协作、规则必须强约束字典/集合变体较低逻辑绕无否大量动态属性需要统一管理我个人在实际项目里的偏好是这样的如果是新写的类优先用方案二把写入口做成 Init 方法语义清楚编译期就能发现问题如果是在一个老项目里给现有类加约束类已经被几十处调用外部全部改成 Init 方法成本太高那就先上方案一用标志位守住赋值入口。方案三更多是应对“属性集合本身是动态生成”的需求常规场景不要为了炫技强行用字典。3. 完整实操“二师兄”的法号从开放到封闭3.1 需求定义哪个属性要锁哪个不锁为了让这个案例更具象我直接沿用一个带点故事性的业务模型。现在要做一个表示“二师兄”角色的类clsPigsy类里包含法号一旦确定不能再改。这是本次锁定的核心属性。等级随时可以调整比如打怪升级。经验值随时可以调整等级和经验值都是普通可写属性。为什么法号要锁因为“悟能”这个法号是角色的身份标识如果后面某个模块不小心把法号改成“天蓬”整个报表里的角色身份就混乱了。这里我用的是“法号”这个有仪式感的词放到实际项目里就是“编号”“主键”“创建人”“生成时间”这一类的字段道理完全一样。3.2 类模块完整代码与关键注释类模块clsPigsy的完整代码如下。这里我采用方案二的升级版属性只读写入口只有InitFaHao同时内部补一个标志位防止用空字符串蒙混过关。 类模块clsPigsy 二师兄角色类法号只能赋值一次 Option Explicit Private m_sFaHao As String Private m_bFaHaoSet As Boolean 等级、经验值普通可写字段 Public Level As Long Public Exp As Long 法号只读 Public Property Get FaHao() As String FaHao m_sFaHao End Property 唯一的法号赋值入口 Public Sub InitFaHao(ByVal sName As String) If m_bFaHaoSet Then Err.Raise vbObjectError 2001, clsPigsy.InitFaHao, 法号只能设置一次当前法号为 m_sFaHao End If If Len(sName) 0 Then Err.Raise vbObjectError 2002, clsPigsy.InitFaHao, 法号不能为空 End If m_sFaHao sName m_bFaHaoSet True End Sub这里有几个细节值得展开说。第一Level和Exp直接用 Public 字段没有走 Property 封装。因为当前需求就是简单可读写减少多余代码。但如果你预判到以后可能要给等级增加“不能低于 0”之类的校验就应该现在改成 Property否则后面改动面会变大。第二InitFaHao里先判断标志位再判断空字符串顺序不要反。假设一个外部代码先调用InitFaHao 如果先判断空字符串会抛“法号不能为空”但标志位还是 False调用方可能接着调用InitFaHao 悟能就成功赋值了。这反而不符合“只能赋值一次”的完全封闭语义。先判断标志位一旦你调用过 InitFaHao哪怕传的是空串后续也全部拒绝。第三错误号用了vbObjectError 2001。VBA 里vbObjectError是一个很大的负偏移量加几十、上百避免和系统内置错误号冲突。这个习惯对大型工具很重要不然你抛一个 1004用户看到“应用程序定义或对象定义错误”根本不知道是哪里来的。3.3 调用方测试代码验证只写一次效果类模块写完赶紧写一个测试过程验证效果。这一步千万别省VBA 类的设计期和运行期行为差异经常超出预期不实测你不知道什么时候就踩了坑。在普通模块里写Public Sub TestPigsy() Dim oPigsy As clsPigsy Set oPigsy New clsPigsy 正常赋值 oPigsy.InitFaHao 悟能 Debug.Print 第一次赋值后的法号 oPigsy.FaHao 第二次赋值应该失败 On Error Resume Next oPigsy.InitFaHao 天蓬 If Err.Number 0 Then Debug.Print 第二次赋值失败错误信息 Err.Description End If On Error GoTo 0 Debug.Print 第二次赋值后的法号仍是 oPigsy.FaHao 等级和经验值可随意变 oPigsy.Level 10 oPigsy.Exp 1000 Debug.Print 等级 oPigsy.Level 经验值 oPigsy.Exp End Sub运行后立即窗口输出第一次赋值后的法号悟能 第二次赋值失败错误信息法号只能设置一次当前法号为悟能 第二次赋值后的法号仍是悟能 等级10经验值1000注意测试代码里的On Error Resume Next这是故意的。因为InitFaHao第二次调用会抛错误如果不在调用方捕获整个测试过程会中断。Err.Number 0判断成功捕获了业务错误之后使用Err.Description打印错误信息最后用On Error GoTo 0恢复默认错误处理。3.4 初始化事件与默认值的正确姿势业务上还有一个常见需求创建对象后必须马上有法号或者创建时可以指定默认法号。很多人第一反应是写 Class_InitializePrivate Sub Class_Initialize() InitFaHao 悟能 End Sub这段代码看起来合理实际运行也没问题但要知道背后发生了什么InitFaHao是类自己的方法它内部会把m_bFaHaoSet置为 True。外部拿到对象后再调用InitFaHao就会被告知“只能设置一次”。这符合预期。真正容易出问题的场景是你希望创建一个“尚未起法号”的二师兄法号暂时留空等流程走到某一步再初始化。这种情况下Class_Initialize 里就不应该给任何默认法号。否则调用方根本没有机会在后续步骤里设置。更隐蔽的一个问题是如果把 Class_Initialize 设计成必须传入参数才能创建对象VBA 不给类构造函数传参数所以这种需求要么用工厂方法Public Function CreatePigsy(sName As String) As clsPigsy要么用 InitFaHao 在后面对接。我的习惯是类本身允许一个无参创建对象外部通过 InitFaHao 完成初始化如果一定要保证“创建时必有法号”就在标准模块里封装工厂函数不要让调用方直接 New。Public Function CreatePigsyWithFaHao(ByVal sName As String) As clsPigsy Dim oPigsy As clsPigsy Set oPigsy New clsPigsy oPigsy.InitFaHao sName Set CreatePigsyWithFaHao oPigsy End Function这样既绕开了 VBA 无法自定义构造函数的限制又把“创建即初始化”的规则收敛到一个入口。4. 仅有代码还不够常见坑与排查技巧4.1 标志位被复制引发的“假初始化”这种坑我用过三次才彻底记住。当一个类对象被复制或者被放进集合、数组时如果复制逻辑直接把私有变量全拷贝过去标志位也会被复制。举例来说你写了一个自定义方法Clone复制clsPigsy对象Public Function Clone() As clsPigsy Dim oNew As clsPigsy Set oNew New clsPigsy oNew.Level Me.Level oNew.Exp Me.Exp oNew.m_sFaHao Me.m_sFaHao oNew.m_bFaHaoSet Me.m_bFaHaoSet Set Clone oNew End Function这段代码里的m_sFaHao和m_bFaHaoSet是同一模块内的私有变量类内部访问没问题。但复制后新对象 oNew 的m_bFaHaoSet已经是 True外部再调用oNew.InitFaHao就会失败。这在逻辑上其实还算合理——法号本来就该跟着本体一起复制。但如果你希望复制出来的新对象“暂时还无法号需要重新初始化”这个复制逻辑就破坏了业务规则。解决办法是复制时重置标志位oNew.m_sFaHao vbNullString oNew.m_bFaHaoSet False或者更稳妥一点不复制法号字段让新对象重新走 InitFaHao。具体怎么选要看业务需要确保“法号全局唯一”那就不能复制如果只是希望新对象能拿到法号副本那就复制且保持锁定。关键是复制逻辑要显式处理标志位别依赖默认行为。4.2 对象属性Set与数组属性的特例法号是 String 类型用 Property Let 没问题。但很多时候要锁的“身份标识”是一个对象比如二师兄的“师父”属性。这时候必须用 Property Set不是 Property Let。 类模块clsPigsy 追加 Private m_oMaster As Object Private m_bMasterSet As Boolean Public Property Get Master() As Object Set Master m_oMaster End Property Public Property Set Master(ByVal oNewValue As Object) If m_bMasterSet Then Err.Raise vbObjectError 2003, clsPigsy.Master, 师父只能设置一次 End If Set m_oMaster oNewValue m_bMasterSet True End Property注意两点。第一Property Set 方法名和 Property Get 同名但参数传递用的是对象引用。第二外部代码对对象属性赋值时必须写 Set 关键字Set oPigsy.Master oShifu。漏掉 Set 会编译错误。再说数组属性。VBA 的 Property Let 没法直接接收整个数组因为数组默认按引用传递而 Let 参数是无引用语义的。如果法号要支持一组值比如“曾用名集合”实现思路就不是 Let 一个数组进去而是在类内部维护一个数组通过 Add 方法逐一加入同时锁定“是否还能继续加”。还有一个常见做法让属性返回数组再用 Init 方法传数组。给一个参考片段Private m_arrNames() As String Private m_bNameLocked As Boolean Public Sub InitNames(ByRef sNames() As String) If m_bNameLocked Then Err.Raise vbObjectError 2004, clsPigsy.InitNames, 曾用名集合已锁定 End If m_arrNames sNames m_bNameLocked True End Sub Public Property Get Names() As String() Names m_arrNames End Property数组赋值m_arrNames sNames在 VBA 中是整体复制速度尚可。关键还是标志位守住 InitNames 入口。4.3 类内部绕过后门私有变量别乱动这是我踩过最离谱的坑之一。类内部写了一个AddExp方法顺手在里面加了一句m_sFaHao 天蓬当时只是为了调试方便结果发布给业务人员后每次升级角色等级法号就变成“天蓬”数据源头全乱了。排查了整整一个下午才发现是类内部自己绕过了 InitFaHao。这个教训说明两件事。第一类内部所有对私有变量的赋值都应该通过同一个写入口。哪怕是类自身的方法也不要直接改m_sFaHao而是调用InitFaHao。这就把“写”这个操作收口到一处后续要加任何规则都只用改一个地方。第二代码评审时专门搜一下类初始化后的私有变量赋值。用 VBA 的话对m_sFaHao直接赋值的地方应该只有一个就是InitFaHao内部。如果搜出多个就是设计有问题。4.4 错误处理与调试的节奏前面提到了Err.Raise抛出业务错误这里再细化一下错误处理的节奏。很多人会问属性方法内部到底要不要写 On Error Resume Next我的答案是不要。属性方法应该保持纯净要么成功赋值要么抛出错误不要吞掉异常。错误处理应该放在调用方因为调用方才清楚“这个错误怎么处理”。如果在属性方法里吞掉错误调用方拿到一个“没赋值也没报错”的幽灵状态后续代码会继续用错误数据运行后果更严重。调试时我通常会在第二次赋值被拦截的分支里加一行Debug.Print 尝试重复设置法号调用来源, TypeName(Me)这样在立即窗口里能看到哪一步触发了重复赋值。配合堆栈分析基本能定位到责任人。如果有条件还可以在调用方临时改成On Error GoTo ErrorHandler oPigsy.InitFaHao 天蓬 ErrorHandler: Debug.Print 错误序号, Err.Number Debug.Print 错误来源, Err.Source Debug.Print 错误描述, Err.Description错误来源Err.Source如果设置为clsPigsy.InitFaHao一眼就能看出是哪个类的哪个方法抛的比只看 Description 定位更快。4.5 WPS 与 Excel 宿主下的实操提示现在不少人用 WPS 表格跑 VBA插件装好后类模块、属性这些语法和 Excel 基本一致。我自己在 WPS 上跑过上述代码只要不是用 Windows API 之类的扩展功能类属性这套完全没问题。但有一个细微差异WPS 的 VBA 调试体验比 Excel 弱一些断点和立即窗口功能偶尔会抽风。我的建议是在 WPS 环境下开发时多用Debug.Print打印中间结果少依赖断点逐步跟踪。另外如果代码里要用Scripting.Dictionary注意确保 WPS 版本能正常绑定 Microsoft Scripting Runtime否则会报“用户定义类型未定义”。还有一个小技巧在 WPS 里写类模块时属性方法的名字尽量不要和类名相同否则可能触发奇怪的编译错误。比如类名clsPigsy属性名就别叫Pigsy。这种问题在 Excel 里偶尔也会出现只是 WPS 报错信息更晦涩干脆从命名上规避。5. 最后一章里没说完的“锁”外收获这一讲主要围绕“属性只能赋值一次”展开但如果把视野放大一点你会发现它其实是一个更通用设计思想的开始给可变世界加一点“不可变约束”。我实际项目里用这个思路修过一个特别典型的大 bug。有一个自动化报表流程总共七个模块接力生成 Excel 文件。前两个模块负责算数据后面几个模块负责排版输出。结果有一次业务方反馈说导出文件的标题栏变了。一查才发现中间某个模块为了临时存储中间变量随手写了objReport.Title 临时标题把最开始设置的正式标题覆盖了。加上只写一次约束后这行代码一运行就直接报错几秒钟就锁定了罪魁祸首再也不用靠肉眼逐行查逻辑。这就是“锁”的另一层价值它不只是防止越权修改更是给系统提供了一个故障探测点。非法赋值越早暴露问题定位越快。另外这个思路还可以继续延伸。如果你有多个属性都要“只写一次”可以考虑做一个通用的接口基类用组合的方式统一管理标志位。VBA 没有真正的继承但可以用一个独立的clsOncePropertyManager帮助类内部维护一个属性名字典所有需要只写一次的类都持有这个辅助对象通过TrySetValue(FaHao, value)来判断。这种封装减少重复代码但复杂度会上升适合类特别多、规则高度统一的项目。从“二师兄”这个角色出发我们已经完成了法号从“开放可改”到“封闭只写一次”的转变。类属性这个看似基础的概念真正埋点设计起来坑也不少。希望这一讲能帮你少走几步弯路。下次继续聊“二师兄”下一步的成长也就是类与类之间怎么协作我们到时候见。
返回列表