问题描述
几个月前,我在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等于ObjPtr(c)
。在NewEnum
方法中使用的变量(包括可选参数)越多,在ShowBug
方法中的ptrs写入的值(内存地址)就越多。
不用说,删除ShowBug
方法内的本地 ptr 变量肯定会导致应用程序崩溃。
有关该错误的更多信息
该错误与存储在Collection
中的实际CustomCollection
无关。调用NewEnum函数后,将立即写入内存。因此,基本上执行以下任何一项操作都无济于事(经过测试):
- 添加
Optional
个参数 - 从函数中删除所有代码(请参见下面显示此代码的代码)
- 声明为
IUnkNown
而不是IEnumVariant
- 代替
Function
声明为Property Get
- 在方法签名中使用
Friend
或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!","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
将集合设置为自身
或者,将 c 参数ByVal
传递给ShowBug
方法(作为Set,将调用IUnknonw :: 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!","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
函数上的.NewEnum
而不是直接自定义集合。这样可以避免由错误引起的崩溃。缺点:总是需要额外的
EnumHelper
类。很容易忘记在.NewEnum
行中添加For Each
(只会触发运行时错误)。
最后一种方法(3)之所以起作用,是因为在执行c.NewEnum
类内的ShowBug
之前,Property Get EnumVariant
方法被执行后,将退出EnumHelper
方法。基本上,方法(1)是避免bug的一种方法。
对此行为的解释是什么?可以更优雅地避免此错误吗?
编辑
通过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
语句或方法调用。
解决方法
发生了什么
看起来 stack frames 是重叠的,尽管它们不应该重叠。在 ShowBug
方法中有足够多的变量可以防止崩溃,并且变量的值(在调用者子例程中)被简单地更改,因为它们引用的内存也被添加的另一个堆栈帧(被调用的子例程)使用/push 到调用堆栈的顶部。
我们可以通过向问题中的相同代码添加几个 Debug.Print
语句来测试这一点。
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
Debug.Print "The NewEnum return address " & VarPtr(NewEnum) & " should be outside of the"
Set NewEnum = m_coll.[_NewEnum]
End Function
以及标准 .bas 模块中的调用代码:
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(ByRef 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.Print VarPtr(ptr9) & " - " & VarPtr(ptr0) & " memory range"
Debug.Assert ptr0 = 0
End Sub
NewEnum
返回值的地址显然位于 ptr0
方法的 ptr9
和 ShowBug
变量之间的内存地址。所以,这就是变量从哪里获取值的原因,因为它们实际上来自 NewEnum
方法的堆栈帧(例如对象的 vtable 的地址或 IEnumVariant
接口的地址)。如果变量不存在,那么崩溃很明显,因为内存的更关键部分被覆盖(例如 ShowBug
方法的帧指针地址)。由于 NewEnum
方法的堆栈框架较大(例如,我们可以添加局部变量以增加大小),因此调用堆栈中顶部堆栈框架和下方堆栈框架之间共享的内存越多。>
如果我们使用问题中描述的选项解决该错误,会发生什么?只需在 Set v = Nothing
行前添加一个 For Each v In c
,结果为:
同时显示前一个值和当前值(蓝色边框),我们可以看到 NewEnum
返回位于 ptr0
和 ptr9
变量之外的内存地址{1}} 方法。似乎使用变通方法正确分配了堆栈帧。
NewEnum
如何调用 For Each
每个 VBA 类都派生自 IDispatch(而后者又派生自 IUnknown)。
当对一个对象调用 NewEnum
循环时,该对象的 For Each...
方法被调用,其中 IDispatch::Invoke
等于 -4。 VBA.Collection 已经有这样的成员,但对于 VBA 自定义类,我们用 dispIDMember
标记我们自己的方法,以便 Invoke 可以调用我们的方法。
Attribute NewEnum.VB_UserMemId = -4
行中使用的接口不是从 Invoke
派生的,则不会直接调用 For Each
。相反,首先调用 IDispatch
并请求 IDispatch 接口。在这种情况下 IUnknown::QueryInterface
显然被称为
只有在 IDispatch 接口返回后。这就是为什么在声明为 Invoke
的对象上使用 For Each
不会导致错误的原因,无论它是传递 As IUnknown
还是全局或类成员自定义集合。尽管我们看不到它,但它只是使用了问题中提到的解决方法 1(即调用另一个方法)。
挂钩调用
我们可以用我们自己的方法替换非 VB ByRef
方法,以便进一步调查。在标准的 Invoke
模块中,我们需要以下代码来挂钩:
.bas
然后我们运行 Option Explicit
#If Mac Then
#If VBA7 Then
Private Declare PtrSafe Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any,Source As Any,ByVal Length As LongPtr) As LongPtr
#Else
Private Declare Function CopyMemory Lib "/usr/lib/libc.dylib" Alias "memmove" (Destination As Any,ByVal Length As Long) As Long
#End If
#Else 'Windows
'https://msdn.microsoft.com/en-us/library/mt723419(v=vs.85).aspx
#If VBA7 Then
Public Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any,ByVal Length As LongPtr)
#Else
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any,ByVal Length As Long)
#End If
#End If
#If Win64 Then
Private Const PTR_SIZE As Long = 8
#Else
Private Const PTR_SIZE As Long = 4
#End If
#If VBA7 Then
Private newInvokePtr As LongPtr
Private oldInvokePtr As LongPtr
Private invokeVtblPtr As LongPtr
#Else
Private newInvokePtr As Long
Private oldInvokePtr As Long
Private invokeVtblPtr As Long
#End If
'https://docs.microsoft.com/en-us/windows/win32/api/oaidl/nf-oaidl-idispatch-invoke
Function IDispatch_Invoke(ByVal this As Object _,ByVal dispIDMember As Long _,ByVal riid As LongPtr _,ByVal lcid As Long _,ByVal wFlags As Integer _,ByVal pDispParams As LongPtr _,ByVal pVarResult As LongPtr _,ByVal pExcepInfo As LongPtr _,ByRef puArgErr As Long _
) As Long
Const DISP_E_MEMBERNOTFOUND = &H80020003
'
Debug.Print "The IDispatch::Invoke return address " & VarPtr(IDispatch_Invoke) & " should be outside of the"
IDispatch_Invoke = DISP_E_MEMBERNOTFOUND
End Function
Sub HookInvoke(obj As Object)
If obj Is Nothing Then Exit Sub
#If VBA7 Then
Dim vTablePtr As LongPtr
#Else
Dim vTablePtr As Long
#End If
'
newInvokePtr = VBA.Int(AddressOf IDispatch_Invoke)
CopyMemory vTablePtr,ByVal ObjPtr(obj),PTR_SIZE
'
invokeVtblPtr = vTablePtr + 6 * PTR_SIZE
CopyMemory oldInvokePtr,ByVal invokeVtblPtr,PTR_SIZE
CopyMemory ByVal invokeVtblPtr,newInvokePtr,PTR_SIZE
End Sub
Sub RestoreInvoke()
If invokeVtblPtr = 0 Then Exit Sub
'
CopyMemory ByVal invokeVtblPtr,oldInvokePtr,PTR_SIZE
invokeVtblPtr = 0
oldInvokePtr = 0
newInvokePtr = 0
End Sub
方法(标准 .bas 模块)来产生错误:
Main2
请注意,需要更多的虚拟 ptr 变量来防止崩溃,因为 Option Explicit
Sub Main2()
#If Win64 Then
Dim c As Object
Set c = New CustomCollection
c.Add 1
c.Add 2
'
HookInvoke c
ShowBug2 c
RestoreInvoke
#Else
MsgBox "This bug does not occur on 32 bits!","Cancelled"
#End If
End Sub
Sub ShowBug2(ByRef c As CustomCollection)
Dim ptr00 As LongPtr
Dim ptr01 As LongPtr
Dim ptr02 As LongPtr
Dim ptr03 As LongPtr
Dim ptr04 As LongPtr
Dim ptr05 As LongPtr
Dim ptr06 As LongPtr
Dim ptr07 As LongPtr
Dim ptr08 As LongPtr
Dim ptr09 As LongPtr
Dim ptr10 As LongPtr
Dim ptr11 As LongPtr
Dim ptr12 As LongPtr
Dim ptr13 As LongPtr
Dim ptr14 As LongPtr
Dim ptr15 As LongPtr
Dim ptr16 As LongPtr
Dim ptr17 As LongPtr
Dim ptr18 As LongPtr
Dim ptr19 As LongPtr
'
Dim v As Variant
'
On Error Resume Next
For Each v In c
Next v
Debug.Print VarPtr(ptr19) & " - " & VarPtr(ptr00) & " range on the call stack"
Debug.Assert ptr00 = 0
End Sub
的堆栈框架更大(因此,内存重叠更大)。
尽管由于 IDispatch_Invoke
方法的挂钩,代码从未到达 NewEnum
方法,但仍会出现相同的错误。堆栈帧再次被错误分配。
同样,在 Invoke
之前添加一个 Set v = Nothing
结果为:
堆栈帧分配正确(绿色边框)。这表明问题不在于 For Each v In c
方法,也不在于我们的替换 NewEnum
方法。在调用我们的 Invoke
之前发生了一些事情。
最后一个例子。考虑一个空白(没有代码)类 IDispatch_Invoke
。如果我们在以下代码中运行 Class1
:
Main3
该错误根本不会发生。这与使用我们自己的钩子 Option Explicit
Sub Main3()
#If Win64 Then
Dim c As New Class1
ShowBug3 c
#Else
MsgBox "This bug does not occur on 32 bits!","Cancelled"
#End If
End Sub
Sub ShowBug3(ByRef c As Class1)
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
Debug.Assert ptr0 = 0
End Sub
运行 Main2
有何不同?在这两种情况下,都返回 Invoke
并且没有调用 DISP_E_MEMBERNOTFOUND
方法。
好吧,如果我们并排查看前面显示的调用堆栈:
我们可以看到非 VB NewEnum
没有作为单独的“非基本代码”条目推送到 VB 堆栈上。
显然,该错误仅在调用 VBA 方法(通过原始非 VB Invoke 调用 NewEnum 或我们自己的 IDispatch_Invoke)时才会发生。如果调用非 VB 方法(如原始 IDispatch::Invoke 没有跟在 NewEnum 之后),则不会像上面的 Invoke
那样发生错误。在相同情况下在 VBA 集合上运行 Main3
时也不会出现错误。
错误原因
正如上面所有的例子所表明的,这个错误可以总结如下:For Each...
调用 For Each
,后者又调用 IDispatch::Invoke
,而堆栈指针还没有随着 NewEnum
堆栈帧的大小增加。因此,两个帧(调用方 ShowBug
和被调用方 ShowBug
)使用相同的内存。
解决方法
强制正确递增堆栈指针的方法:
- 直接调用另一个方法(在
NewEnum
行之前)例如For Each
- 间接调用另一个方法(在
Sin 1
行之前):- 通过传递参数
For Each
调用IUnknown::AddRef
- 使用
ByVal
接口调用IUnknown::QueryInterface
- 使用
stdole.IUnknown
语句,该语句将调用Set
或AddRef
或两者(例如Release
)。也可以根据源和目标接口调用Set c = c
- 通过传递参数
正如问题的 EDIT 部分所建议的,我们并不总是有可能传递自定义集合类 QueryInterface
,因为它可能只是一个全局变量,或者类成员,我们需要记住在执行 ByVal
之前执行伪 Set
语句或调用另一个方法。
解决方案
我仍然找不到比问题中提出的更好的解决方案,所以我只是将代码复制到这里作为答案的一部分,并稍作调整。
For Each...
类:
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
Public Property Get Self() As EnumHelper
Set Self = Me
End Property
现在会变成这样:
CustomCollection
您只需要使用 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
With New EnumHelper
Set .EnumVariant = m_coll.[_NewEnum]
Set NewEnum = .Self
End With
End Function
尽管 For Each v in c.NewEnum
类是任何实现自定义集合类的项目都需要的额外类,但它也有几个优点:
- 您永远不需要将
EnumHelper
添加到任何其他自定义集合类。这对于没有安装 RubberDuck(Attribute [MethodName].VB_UserMemId = -4
注释)的用户更有用,因为他们需要导出、编辑 .cls 文本文件并为每个自定义集合类导入 - 您可以为同一个类公开多个 EnumHelper。考虑一个自定义字典类。您可以同时拥有
'@Enumerator
和ItemsEnum
。KeysEnum
和For Each v in c.ItemsEnum
都可以使用 - 您永远不会忘记使用上述解决方法之一,因为将在
For Each v in c.KeysEnum
调用成员 ID -4 之前调用公开EnumHelper
类的方法 - 您不会再遇到崩溃了。如果您忘记使用
Invoke
调用而是使用For Each v in c.NewEnum
,您只会得到一个运行时错误,无论如何都会在测试中发现该错误。当然,您仍然可以通过将For Each v in c
的结果传递给另一个方法c.NewEnum
来强制崩溃,然后该方法需要在任何其他方法调用或ByRef
语句之前执行For Each
.你极不可能这样做 - 显而易见但值得一提的是,您可以为项目中可能拥有的所有自定义集合类使用相同的
Set
类