几个月前我在 VBA 中发现了一个错误,但找不到合适的解决方法。这个错误真的很烦人,因为它限制了一个很好的语言功能。
使用自定义集合类时,希望有一个枚举器以便可以在For Each
循环中使用该类是很常见的。这可以通过添加以下行来完成:
Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
在函数/属性签名行之后立即通过:
- 导出类模块,在文本编辑器中编辑内容,然后重新导入
- 在函数签名上方使用Rubberduck注解
'@Enumerator
,然后进行同步
不幸的是,在 x64 上,使用上述特性会导致写入错误的内存,并在某些情况下导致应用程序崩溃(稍后讨论)。
重现错误
CustomCollection
班级:
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "CustomCollection"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Private m_coll As Collection
Private Sub Class_Initialize()
Set m_coll = New Collection
End Sub
Private Sub Class_Terminate()
Set m_coll = Nothing
End Sub
Public Sub Add(v As Variant)
m_coll.Add v
End Sub
Public Function NewEnum() As IEnumVARIANT
Attribute NewEnum.VB_UserMemId = -4
Set NewEnum = m_coll.[_NewEnum]
End Function
标准模块中的代码:
Option Explicit
Sub Main()
#If Win64 Then
Dim c As New CustomCollection
c.Add 1
c.Add 2
ShowBug c
#Else
MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled"
#End If
End Sub
Sub ShowBug(c As CustomCollection)
Dim ptr0 As LongPtr
Dim ptr1 As LongPtr
Dim ptr2 As LongPtr
Dim ptr3 As LongPtr
Dim ptr4 As LongPtr
Dim ptr5 As LongPtr
Dim ptr6 As LongPtr
Dim ptr7 As LongPtr
Dim ptr8 As LongPtr
Dim ptr9 As LongPtr
'
Dim v As Variant
'
For Each v In c
Next v
Debug.Assert ptr0 = 0
End Sub
通过运行该Main
方法,代码将停在Assert
该方法的行上ShowBug
,您可以在Locals窗口中看到,局部变量的值不知从何处发生了变化:
其中 ptr1 等于. 方法中使用的变量越多(包括可选参数),方法中的 ptrs 越多,写入的值(内存地址)就越多。
ObjPtr(c)
NewEnum
ShowBug
不用说,删除方法中的本地ptr变量ShowBug
肯定会导致应用程序崩溃。
逐行单步执行代码时,不会出现此错误!
更多关于这个错误
该错误与实际Collection
存储在CustomCollection
. 调用 NewEnum 函数后立即写入内存。因此,基本上执行以下任何操作都无济于事(经过测试):
- 添加
Optional
参数 - 从函数中删除所有代码(参见下面的代码)
- 声明为
IUnknown
而不是IEnumVariant
- 而不是
Function
声明为Property Get
- 在方法签名中使用类似
Friend
or的关键字Static
- 将 DISPID_NEWENUM 添加到 Get 的Let或Set对应项,甚至隐藏前者(即,将 Let/Set 设为私有)。
让我们尝试上面提到的第 2 步。如果CustomCollection
变成:
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "CustomCollection"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Public Function NewEnum() As IEnumVARIANT
Attribute NewEnum.VB_UserMemId = -4
End Function
并且用于测试的代码更改为:
Sub Main()
#If Win64 Then
Dim c As New CustomCollection
ShowBug c
#Else
MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled"
#End If
End Sub
Sub ShowBug(c As CustomCollection)
Dim ptr0 As LongPtr
Dim ptr1 As LongPtr
Dim ptr2 As LongPtr
Dim ptr3 As LongPtr
Dim ptr4 As LongPtr
Dim ptr5 As LongPtr
Dim ptr6 As LongPtr
Dim ptr7 As LongPtr
Dim ptr8 As LongPtr
Dim ptr9 As LongPtr
'
Dim v As Variant
'
On Error Resume Next
For Each v In c
Next v
On Error GoTo 0
Debug.Assert ptr0 = 0
End Sub
运行Main
会产生相同的错误。
解决方法
我发现避免该错误的可靠方法:
调用一个方法(基本上是离开
ShowBug
方法)然后回来。这需要在For Each
执行该行之前发生(之前意味着它可以在同一方法中的任何位置,不一定是之前的确切行):Sin 0 'Or VBA.Int 1 - you get the idea For Each v In c Next v
缺点:容易忘记
做一个
Set
声明。它可能在循环中使用的变体上(如果没有使用其他对象)。与上面的第 1 点一样,这需要在For Each
执行该行之前发生:Set v = Nothing For Each v In c Next v
甚至通过使用
Set c = c
Or 将集合设置为自身,将c参数传递ByVal
给ShowBug
方法(作为 Set,调用 IUnknown::AddRef)
缺点:容易忘记使用一个单独的
EnumHelper
类,它是唯一用于枚举的类:VERSION 1.0 CLASS BEGIN MultiUse = -1 'True END Attribute VB_Name = "EnumHelper" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = False Attribute VB_Exposed = False Option Explicit Private m_enum As IEnumVARIANT Public Property Set EnumVariant(newEnum_ As IEnumVARIANT) Set m_enum = newEnum_ End Property Public Property Get EnumVariant() As IEnumVARIANT Attribute EnumVariant.VB_UserMemId = -4 Set EnumVariant = m_enum End Property
CustomCollection
会成为:VERSION 1.0 CLASS BEGIN MultiUse = -1 'True END Attribute VB_Name = "CustomCollection" Attribute VB_GlobalNameSpace = False Attribute VB_Creatable = False Attribute VB_PredeclaredId = False Attribute VB_Exposed = False Option Explicit Private m_coll As Collection Private Sub Class_Initialize() Set m_coll = New Collection End Sub Private Sub Class_Terminate() Set m_coll = Nothing End Sub Public Sub Add(v As Variant) m_coll.Add v End Sub Public Function NewEnum() As EnumHelper Dim eHelper As New EnumHelper ' Set eHelper.EnumVariant = m_coll.[_NewEnum] Set NewEnum = eHelper End Function
和调用代码:
Option Explicit Sub Main() #If Win64 Then Dim c As New CustomCollection c.Add 1 c.Add 2 ShowBug c #Else MsgBox "This bug does not occur on 32 bits!", vbInformation, "Cancelled" #End If End Sub Sub ShowBug(c As CustomCollection) Dim ptr0 As LongPtr Dim ptr1 As LongPtr Dim ptr2 As LongPtr Dim ptr3 As LongPtr Dim ptr4 As LongPtr Dim ptr5 As LongPtr Dim ptr6 As LongPtr Dim ptr7 As LongPtr Dim ptr8 As LongPtr Dim ptr9 As LongPtr ' Dim v As Variant ' For Each v In c.NewEnum Debug.Print v Next v Debug.Assert ptr0 = 0 End Sub
显然,保留的 DISPID 已从
CustomCollection
类中删除。优点:强制
For Each
on.NewEnum
函数而不是直接自定义集合。这避免了由错误引起的任何崩溃。缺点:总是需要额外的
EnumHelper
课程。很容易忘记在行中添加.NewEnum
(For Each
只会触发运行时错误)。
最后一种方法(3)之所以有效,是因为在c.NewEnum
执行该ShowBug
方法时退出,然后在调用类Property Get EnumVariant
内部之前返回EnumHelper
。基本上方法(1)是避免错误的方法。
这种行为的解释是什么?能否以更优雅的方式避免此错误?
编辑
通过CustomCollection
ByVal 并不总是一种选择。考虑一个Class1
:
Option Explicit
Private m_collection As CustomCollection
Private Sub Class_Initialize()
Set m_collection = New CustomCollection
End Sub
Private Sub Class_Terminate()
Set m_collection = Nothing
End Sub
Public Sub AddElem(d As Double)
m_collection.Add d
End Sub
Public Function SumElements() As Double
Dim v As Variant
Dim s As Double
For Each v In m_collection
s = s + v
Next v
SumElements = s
End Function
现在是一个调用例程:
Sub ForceBug()
Dim c As Class1
Set c = New Class1
c.AddElem 2
c.AddElem 5
c.AddElem 7
Debug.Print c.SumElements 'BOOM - Application crashes
End Sub
显然,这个例子有点勉强,但是有一个包含“子”对象的自定义集合的“父”对象是很常见的,并且“父”可能想要执行一些涉及部分或全部“子”的操作。
在这种情况下,很容易忘记在行Set
前执行语句或方法调用For Each
。