Cri*_*use 20 excel 64-bit vba enumeration custom-collection
几个月前我在 VBA 中发现了一个错误,但找不到合适的解决方法。这个错误真的很烦人,因为它限制了一个很好的语言功能。
使用自定义集合类时,通常希望有一个枚举器,以便可以在For Each
循环中使用该类。这可以通过添加以下行来完成:
Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
Run Code Online (Sandbox Code Playgroud)
紧接在函数/属性签名行之后:
'@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
Run Code Online (Sandbox Code Playgroud)
标准模块中的代码:
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
Run Code Online (Sandbox Code Playgroud)
通过运行该Main
方法,代码将停Assert
在该ShowBug
方法的行上,您可以在Locals窗口中看到局部变量的值突然发生了变化:
其中 ptr1 等于ObjPtr(c)
。NewEnum
方法内部使用的变量越多(包括可选参数),ShowBug
方法中的ptr就会被写入值(内存地址)。
不用说,删除方法内的局部ptr变量ShowBug
肯定会导致应用程序崩溃。
一行行单步执行代码时,不会出现这个bug!
有关错误的更多信息
该错误与实际Collection
存储在CustomCollection
. 调用 NewEnum 函数后立即写入内存。因此,基本上执行以下任何操作都无济于事(已测试):
Optional
参数IUnknown
而不是IEnumVariant
Function
声明为Property Get
Friend
或这样的关键字Static
让我们尝试上面提到的第 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
Run Code Online (Sandbox Code Playgroud)
并将用于测试的代码更改为:
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
Run Code Online (Sandbox Code Playgroud)
运行Main
产生相同的错误。
解决方法
我发现的可靠方法可以避免该错误:
调用一个方法(基本上是离开ShowBug
方法)然后回来。这需要在For Each
执行该行之前发生(之前意味着它可以在同一方法中的任何位置,不一定是之前的确切行):
Sin 0 'Or VBA.Int 1 - you get the idea
For Each v In c
Next v
Run Code Online (Sandbox Code Playgroud)
缺点:容易忘记
做一个Set
声明。它可能在循环中使用的变体上(如果没有使用其他对象)。与上面的第 1 点一样,这需要在For Each
执行该行之前发生:
Set v = Nothing
For Each v In c
Next v
Run Code Online (Sandbox Code Playgroud)
或者甚至通过使用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
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)
和调用代码:
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
Run Code Online (Sandbox Code Playgroud)
显然,保留的 DISPID 已从CustomCollection
类中删除。
优点:迫使For Each
上的.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
Run Code Online (Sandbox Code Playgroud)
现在是一个调用例程:
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
Run Code Online (Sandbox Code Playgroud)
显然,这个例子有点勉强,但有一个包含“子”对象的自定义集合的“父”对象是很常见的,“父”可能想要做一些涉及部分或全部“子”的操作。
在这种情况下,很容易忘记Set
在该For Each
行之前执行语句或方法调用。
怎么了
看起来堆栈帧是重叠的,尽管它们不应该重叠。在ShowBug
方法中有足够多的变量可以防止崩溃,并且变量的值(在调用者子例程中)被简单地更改,因为它们引用的内存也被另一个堆栈帧(被调用的子例程)使用调用堆栈的顶部。
我们可以通过向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
Run Code Online (Sandbox Code Playgroud)
以及标准 .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
Run Code Online (Sandbox Code Playgroud)
NewEnum
返回值的地址显然位于方法的ptr0
和ptr9
变量之间的内存地址ShowBug
。所以,这就是为什么变量从任何地方获取值的原因,因为它们实际上来自NewEnum
方法的堆栈帧(如对象的 vtable 的地址或IEnumVariant
接口的地址)。如果变量不存在,那么崩溃很明显,因为内存的更关键部分被覆盖(例如方法的帧指针地址ShowBug
)。由于该NewEnum
方法的堆栈框架较大(例如,我们可以添加局部变量以增加大小),因此调用堆栈中顶部堆栈框架和下方堆栈框架之间共享的内存越多。
如果我们使用问题中描述的选项解决该错误,会发生什么?只需Set v = Nothing
在该For Each v In c
行之前添加一个,结果为:
显示前一个值和当前值(蓝色边框),我们可以看到NewEnum
返回位于方法的ptr0
和ptr9
变量之外的内存地址ShowBug
。似乎使用变通方法正确分配了堆栈帧。
如何For Each
调用NewEnum
每个 VBA 类都派生自IDispatch(而IDispatch又派生自 IUnknown)。
当For Each...
在一个对象上调用循环时,该对象的IDispatch::Invoke
方法被调用,其dispIDMember
值为 -4。VBA.Collection 已经有这样的成员,但对于 VBA 自定义类,我们标记我们自己的方法,Attribute NewEnum.VB_UserMemId = -4
以便 Invoke 可以调用我们的方法。
Invoke
如果For Each
行中使用的接口不是从IDispatch
. 相反,IUnknown::QueryInterface
首先调用并请求 IDispatch 接口。在这种情况下Invoke
显然只有在返回 IDispatch 接口后才会调用。这就是为什么For Each
在声明的对象上使用As IUnknown
不会导致错误的原因,无论它是通过ByRef
还是全局或类成员自定义集合。尽管我们看不到它,但它只是使用问题中提到的解决方法 1(即调用另一种方法)。
挂钩调用
我们可以用Invoke
我们自己的方法替换非 VB方法,以便进一步研究。在标准.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, Source 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, Source As Any, ByVal Length As LongPtr)
#Else
Private Declare Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (Destination As Any, Source 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
Run Code Online (Sandbox Code Playgroud)
我们运行Main2
方法(标准 .bas 模块)来产生错误:
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!", vbInformation, "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
Run Code Online (Sandbox Code Playgroud)
请注意,需要更多的虚拟 ptr 变量来防止崩溃,因为堆栈框架IDispatch_Invoke
更大(因此,内存重叠更大)。
尽管NewEnum
由于方法的挂钩,代码从未到达该方法,但仍会发生相同的错误Invoke
。堆栈帧再次被错误分配。
再次,Set v = Nothing
在For Each v In c
结果之前添加一个:
堆栈帧已正确分配(带绿色边框)。这表明问题不在于NewEnum
方法,也不在于我们的替换Invoke
方法。在我们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!", vbInformation, "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
Run Code Online (Sandbox Code Playgroud)
该错误根本不会发生。这Main2
与使用我们自己的 hooked运行有Invoke
什么不同?在这两种情况下DISP_E_MEMBERNOTFOUND
都返回并且不NewEnum
调用任何方法。
好吧,如果我们并排查看之前显示的调用堆栈:
我们可以看到非 VBInvoke
没有作为单独的“非基本代码”条目推送到 VB 堆栈上。
显然,该错误仅在调用 VBA 方法(通过原始非 VB Invoke 或我们自己的 IDispatch_Invoke 调用 NewEnum)时才会发生。如果调用非 VB 方法(如原始的 IDispatch::Invoke 没有跟随 NewEnum),则不会发生Main3
上述错误。For Each...
在相同情况下在 VBA 集合上运行时也不会发生错误。
错误原因
正如上面所有的例子所表明的那样,这个错误可以总结为以下几点:
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
)。也可以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
Run Code Online (Sandbox Code Playgroud)
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
Run Code Online (Sandbox Code Playgroud)
你只需要打电话 For Each v in c.NewEnum
尽管EnumHelper
在任何实现自定义集合类的项目中,该类都是一个额外的类,但它也有一些优点:
Attribute [MethodName].VB_UserMemId = -4
到任何其他自定义集合类。这对于没有安装RubberDuck('@Enumerator
注释)的用户更有用,因为他们需要导出、编辑 .cls 文本文件并为每个自定义集合类导入回ItemsEnum
和一个KeysEnum
。双方For Each v in c.ItemsEnum
并For Each v in c.KeysEnum
会工作EnumHelper
之前Invoke
将调用公开类的方法For Each v in c.NewEnum
而是使用,For Each v in c
您只会得到一个运行时错误,无论如何都会在测试中发现该错误。当然,您仍然可以通过将 的结果传递c.NewEnum
给另一个方法来强制崩溃,然后该方法ByRef
需要For Each
在任何其他方法调用或Set
语句之前执行 a 。你极不可能这样做EnumHelper
项目中可能拥有的所有自定义集合类使用相同的类 归档时间: |
|
查看次数: |
612 次 |
最近记录: |