x64 自定义上的 For Each 枚举错误 类
Bug with For Each enumeration on x64 Custom Classes
几个月前我在 VBA 中发现了一个错误,但无法找到合适的解决方法。这个错误真的很烦人,因为它限制了一个很好的语言功能。
使用自定义集合 Class 时,通常需要一个枚举器,以便 class 可以在 For Each
循环中使用。这可以通过添加以下行来完成:
Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
紧接在 function/property 签名行之后:
- 正在导出 class 模块,在文本编辑器中编辑内容,然后导入回来
- 在函数签名上方使用Rubberduck注解
'@Enumerator
然后同步
不幸的是,在 x64 上,使用 above-mentioned 功能会导致写入错误的内存,并在某些情况下导致应用程序崩溃(稍后讨论)。
重现错误
CustomCollection
class:
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
方法,代码会在ShowBug
方法中停在Assert
行,可以在中看到Locals window 局部变量的值突然改变了:
其中 ptr1 等于 ObjPtr(c)
。 NewEnum
方法中使用的变量越多(包括可选参数),ShowBug
方法中写入值(内存地址)的指针越多。
不用说,删除 ShowBug
方法中的局部 ptr 变量肯定会导致应用程序崩溃。
逐行单步执行代码时,不会出现此错误!
有关错误的更多信息
该错误与存储在 CustomCollection
中的实际 Collection
无关。调用 NewEnum 函数后立即写入内存。因此,基本上执行以下任何操作都无济于事(已测试):
- 添加
Optional
参数
- 从函数中删除所有代码(见下面的代码)
- 声明为
IUnknown
而不是 IEnumVariant
- 而不是
Function
声明为 Property Get
- 在方法签名中使用
Friend
或 Static
等关键字
- 将 DISPID_NEWENUM 添加到 Let 或 Set 对应 Get,甚至隐藏前者(即使 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
行之前发生(before 意味着它可以在同一方法中的任何地方,不一定是之前的确切行):
Sin 0 'Or VBA.Int 1 - you get the idea
For Each v In c
Next v
缺点:容易忘记
做一个Set
语句。它可以在循环中使用的变体上(如果没有使用其他 objects)。正如上面的第 1 点,这需要在执行 For Each
行之前发生:
Set v = Nothing
For Each v In c
Next v
甚至通过 Set c = c
将集合设置为自身
或者,将 c 参数 ByVal
传递给 ShowBug
方法(作为 Set,调用 IUnknown::AddRef)
缺点:容易忘记
使用单独的 EnumHelper
class 这是唯一用于枚举的 class:
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
class 中删除。
优点:在 .NewEnum
函数上强制 For Each
而不是直接自定义集合。这避免了由错误引起的任何崩溃。
缺点:总是需要额外的 EnumHelper
class。很容易忘记在 For Each
行中添加 .NewEnum
(只会触发运行时错误)。
最后一种方法 (3) 有效,因为当执行 c.NewEnum
时, ShowBug
方法退出,然后在 EnumHelper
中调用 Property Get EnumVariant
之前返回class。基本上方法 (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
显然,该示例有点勉强,但“parent”object 包含“child”object 的自定义集合是很常见的s 和“parent”可能想做一些涉及部分或全部“children”的操作。
在这种情况下,很容易忘记在 For Each
行之前执行 Set
语句或方法调用。
发生了什么事
看起来 stack frames 是重叠的,尽管它们不应该重叠。在 ShowBug
方法中有足够的变量可以防止崩溃并且变量的值(在调用者子例程中)被简单地更改,因为它们引用的内存也被另一个堆栈帧(被调用的子例程)使用 added/pushed 稍后在调用堆栈的顶部。
我们可以通过向问题中的相同代码添加几个 Debug.Print
语句来测试这一点。
CustomCollection
class:
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
通过 运行ning Main
我立即得到了这样的东西 Window:
NewEnum
return 值的地址显然位于 ShowBug
方法的 ptr0
和 ptr9
变量之间的内存地址。所以,这就是为什么变量不知从哪里获取值的原因,因为它们实际上来自 NewEnum
方法的堆栈帧(比如 object 的 vtable 的地址或 IEnumVariant
界面)。如果变量不在那里,那么崩溃是显而易见的,因为内存的更多关键部分正在被覆盖(例如 ShowBug
方法的帧指针地址)。由于 NewEnum
方法的堆栈帧较大(例如,我们可以添加局部变量以增加大小),调用堆栈中顶部堆栈帧和下方堆栈帧之间共享的内存越多。
如果我们使用问题中描述的选项解决错误,会发生什么情况?只需在 For Each v In c
行前添加一个 Set v = Nothing
,结果为:
同时显示之前的值和当前值(蓝色边框),我们可以看到 NewEnum
return 位于 ptr0
和 [=23 之外的内存地址=] ShowBug
方法的变量。似乎使用变通方法正确分配了堆栈帧。
For Each
如何调用NewEnum
每个 VBA class 都派生自 IDispatch(后者又派生自 IUnknown)。
当在 object 上调用 For Each...
循环时,调用 object 的 IDispatch::Invoke
方法时 dispIDMember
等于 -4 . VBA.Collection 已经有这样的成员,但是对于 VB 自定义的 classes,我们用 Attribute NewEnum.VB_UserMemId = -4
标记我们自己的方法,以便 Invoke 可以调用我们的方法。
如果 For Each
行中使用的接口不是从 IDispatch
派生的,则不会直接调用 Invoke
。相反,首先调用 IUnknown::QueryInterface
并询问 IDispatch 接口。在这种情况下 Invoke
显然被称为
只有在 IDispatch 接口被 returned 之后。这就是为什么在声明为 As IUnknown
的 Object 上使用 For Each
不会导致错误的原因,无论它是通过 ByRef
还是全局或 class会员定制collection。它只是使用问题中提到的解决方法 1(即调用另一种方法),尽管我们看不到它。
挂钩调用
我们可以用我们自己的方法替换 non-VB 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, 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
我们 运行 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
请注意,需要更多虚拟 ptr 变量来防止崩溃,因为 IDispatch_Invoke
的堆栈帧更大(因此,内存重叠更大)。
尽管由于 Invoke
方法的挂钩,代码从未到达 NewEnum
方法,但仍会出现相同的错误。堆栈帧再次被错误分配。
同样,在 For Each v In c
之前添加一个 Set v = Nothing
结果为:
堆栈帧分配正确(带绿色边框)。这表明问题不在于 NewEnum
方法,也不在于我们的替换 Invoke
方法。在调用我们的 Invoke
之前发生了一些事情。
如果我们在 IDispatch_Invoke
内部中断,调用堆栈如下所示:
最后一个例子。考虑一个空白(没有代码)class 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
这个错误根本就没有发生。这与 运行ning Main2
与我们自己的 hooked Invoke
有何不同?在这两种情况下,DISP_E_MEMBERNOTFOUND
都被 return 编辑并且没有调用 NewEnum
方法。
好吧,如果我们并排查看前面显示的调用堆栈:
我们可以看到 non-VB Invoke
没有作为单独的“Non-Basic 代码”条目推送到 VB 堆栈。
显然,只有在调用 VBA 方法(通过原始 non-VB Invoke 或我们自己的 IDispatch_Invoke 调用 NewEnum)时才会出现该错误。如果调用了 non-VB 方法(就像没有后续 NewEnum 的原始 IDispatch::Invoke 一样),则不会出现上述 Main3
中的错误。 运行ning For Each...
on a 时没有错误发生VBA Collection同样情况下也可以。
错误原因
正如以上所有示例所暗示的,该错误可总结如下:
For Each
调用 IDispatch::Invoke
,后者又调用 NewEnum
,而堆栈指针尚未随着 ShowBug
堆栈帧的大小递增。因此,两个框架(调用者 ShowBug
和被调用者 NewEnum
)使用相同的内存。
解决方法
强制堆栈指针正确递增的方法:
- 直接调用另一个方法(在
For Each
行之前),例如Sin 1
- 间接调用另一个方法(在
For Each
行之前):
- 通过传递参数
ByVal
调用 IUnknown::AddRef
- 使用
stdole.IUnknown
接口调用IUnknown::QueryInterface
- 使用
Set
语句,该语句将调用 AddRef
或 Release
或两者(例如 Set c = c
)。也可以根据源和目标接口调用 QueryInterface
正如问题的 EDIT 部分所建议的,我们并不总是有可能通过自定义 Collection class ByVal
因为它可能只是一个全局变量,或者是一个 class 成员,我们需要记住在执行 For Each...
之前做一个伪 Set
语句或调用另一个方法。
解决方案
我仍然找不到比问题中提出的解决方案更好的解决方案,因此我将在此处复制代码作为答案的一部分,并稍作调整。
EnumHelper
class:
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
class 在任何实施自定义 collection class 的项目中都需要额外的 class,但有几个还有优点:
- 您永远不需要将
Attribute [MethodName].VB_UserMemId = -4
添加到任何其他自定义 collection class。这对于没有安装 RubberDuck('@Enumerator
注释)的用户更有用,因为他们需要导出、编辑 .cls 文本文件并为每个自定义 collection 导入回class
- 您可以为同一个 class 公开多个 EnumHelper。考虑一个自定义字典 class。您可以同时拥有
ItemsEnum
和 KeysEnum
。 For Each v in c.ItemsEnum
和 For Each v in c.KeysEnum
都可以
- 您永远不会忘记使用上述解决方法之一,因为在
Invoke
调用成员 ID -4[=234 之前将调用公开 EnumHelper
class 的方法=]
- 你不会再崩溃了。如果您忘记使用
For Each v in c.NewEnum
调用,而是使用 For Each v in c
,您只会得到一个 运行 时间错误,无论如何都会在测试中发现。当然,您仍然可以通过将 c.NewEnum
的结果传递给另一个方法 ByRef
来强制崩溃,然后需要在任何其他方法调用或 Set
语句之前执行 For Each
.你极不可能那样做
- 显而易见但值得一提的是,您将对项目中可能拥有的所有自定义 collection class 使用相同的
EnumHelper
class
由于没有足够的代表,我无法添加评论,也无法使用聊天部分,因为它被冻结了,但我想补充一点,我遇到了一些听起来非常相似的东西,虽然我还没有测试这里提供的任何解决方案,它似乎确实是同一个错误。
我试着在这里描述它:
我希望测试也能为我解决这个问题,如果是这样,我衷心感谢你调查这个问题并提供解决方法,否则这意味着代码无法移植到 64 位 VBA.
几个月前我在 VBA 中发现了一个错误,但无法找到合适的解决方法。这个错误真的很烦人,因为它限制了一个很好的语言功能。
使用自定义集合 Class 时,通常需要一个枚举器,以便 class 可以在 For Each
循环中使用。这可以通过添加以下行来完成:
Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
紧接在 function/property 签名行之后:
- 正在导出 class 模块,在文本编辑器中编辑内容,然后导入回来
- 在函数签名上方使用Rubberduck注解
'@Enumerator
然后同步
不幸的是,在 x64 上,使用 above-mentioned 功能会导致写入错误的内存,并在某些情况下导致应用程序崩溃(稍后讨论)。
重现错误
CustomCollection
class:
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
方法,代码会在ShowBug
方法中停在Assert
行,可以在中看到Locals window 局部变量的值突然改变了:
其中 ptr1 等于 ObjPtr(c)
。 NewEnum
方法中使用的变量越多(包括可选参数),ShowBug
方法中写入值(内存地址)的指针越多。
不用说,删除 ShowBug
方法中的局部 ptr 变量肯定会导致应用程序崩溃。
逐行单步执行代码时,不会出现此错误!
有关错误的更多信息
该错误与存储在 CustomCollection
中的实际 Collection
无关。调用 NewEnum 函数后立即写入内存。因此,基本上执行以下任何操作都无济于事(已测试):
- 添加
Optional
参数 - 从函数中删除所有代码(见下面的代码)
- 声明为
IUnknown
而不是IEnumVariant
- 而不是
Function
声明为Property Get
- 在方法签名中使用
Friend
或Static
等关键字 - 将 DISPID_NEWENUM 添加到 Let 或 Set 对应 Get,甚至隐藏前者(即使 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
行之前发生(before 意味着它可以在同一方法中的任何地方,不一定是之前的确切行):Sin 0 'Or VBA.Int 1 - you get the idea For Each v In c Next v
缺点:容易忘记
做一个
Set
语句。它可以在循环中使用的变体上(如果没有使用其他 objects)。正如上面的第 1 点,这需要在执行For Each
行之前发生:Set v = Nothing For Each v In c Next v
甚至通过
Set c = c
将集合设置为自身 或者,将 c 参数ByVal
传递给ShowBug
方法(作为 Set,调用 IUnknown::AddRef)
缺点:容易忘记使用单独的
EnumHelper
class 这是唯一用于枚举的 class: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
class 中删除。优点:在
.NewEnum
函数上强制For Each
而不是直接自定义集合。这避免了由错误引起的任何崩溃。缺点:总是需要额外的
EnumHelper
class。很容易忘记在For Each
行中添加.NewEnum
(只会触发运行时错误)。
最后一种方法 (3) 有效,因为当执行 c.NewEnum
时, ShowBug
方法退出,然后在 EnumHelper
中调用 Property Get EnumVariant
之前返回class。基本上方法 (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
显然,该示例有点勉强,但“parent”object 包含“child”object 的自定义集合是很常见的s 和“parent”可能想做一些涉及部分或全部“children”的操作。
在这种情况下,很容易忘记在 For Each
行之前执行 Set
语句或方法调用。
发生了什么事
看起来 stack frames 是重叠的,尽管它们不应该重叠。在 ShowBug
方法中有足够的变量可以防止崩溃并且变量的值(在调用者子例程中)被简单地更改,因为它们引用的内存也被另一个堆栈帧(被调用的子例程)使用 added/pushed 稍后在调用堆栈的顶部。
我们可以通过向问题中的相同代码添加几个 Debug.Print
语句来测试这一点。
CustomCollection
class:
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
通过 运行ning Main
我立即得到了这样的东西 Window:
NewEnum
return 值的地址显然位于 ShowBug
方法的 ptr0
和 ptr9
变量之间的内存地址。所以,这就是为什么变量不知从哪里获取值的原因,因为它们实际上来自 NewEnum
方法的堆栈帧(比如 object 的 vtable 的地址或 IEnumVariant
界面)。如果变量不在那里,那么崩溃是显而易见的,因为内存的更多关键部分正在被覆盖(例如 ShowBug
方法的帧指针地址)。由于 NewEnum
方法的堆栈帧较大(例如,我们可以添加局部变量以增加大小),调用堆栈中顶部堆栈帧和下方堆栈帧之间共享的内存越多。
如果我们使用问题中描述的选项解决错误,会发生什么情况?只需在 For Each v In c
行前添加一个 Set v = Nothing
,结果为:
同时显示之前的值和当前值(蓝色边框),我们可以看到 NewEnum
return 位于 ptr0
和 [=23 之外的内存地址=] ShowBug
方法的变量。似乎使用变通方法正确分配了堆栈帧。
For Each
如何调用NewEnum
每个 VBA class 都派生自 IDispatch(后者又派生自 IUnknown)。
当在 object 上调用 For Each...
循环时,调用 object 的 IDispatch::Invoke
方法时 dispIDMember
等于 -4 . VBA.Collection 已经有这样的成员,但是对于 VB 自定义的 classes,我们用 Attribute NewEnum.VB_UserMemId = -4
标记我们自己的方法,以便 Invoke 可以调用我们的方法。
For Each
行中使用的接口不是从 IDispatch
派生的,则不会直接调用 Invoke
。相反,首先调用 IUnknown::QueryInterface
并询问 IDispatch 接口。在这种情况下 Invoke
显然被称为
只有在 IDispatch 接口被 returned 之后。这就是为什么在声明为 As IUnknown
的 Object 上使用 For Each
不会导致错误的原因,无论它是通过 ByRef
还是全局或 class会员定制collection。它只是使用问题中提到的解决方法 1(即调用另一种方法),尽管我们看不到它。
挂钩调用
我们可以用我们自己的方法替换 non-VB 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, 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
我们 运行 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
请注意,需要更多虚拟 ptr 变量来防止崩溃,因为 IDispatch_Invoke
的堆栈帧更大(因此,内存重叠更大)。
尽管由于 Invoke
方法的挂钩,代码从未到达 NewEnum
方法,但仍会出现相同的错误。堆栈帧再次被错误分配。
同样,在 For Each v In c
之前添加一个 Set v = Nothing
结果为:
堆栈帧分配正确(带绿色边框)。这表明问题不在于 NewEnum
方法,也不在于我们的替换 Invoke
方法。在调用我们的 Invoke
之前发生了一些事情。
如果我们在 IDispatch_Invoke
内部中断,调用堆栈如下所示:
最后一个例子。考虑一个空白(没有代码)class 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
这个错误根本就没有发生。这与 运行ning Main2
与我们自己的 hooked Invoke
有何不同?在这两种情况下,DISP_E_MEMBERNOTFOUND
都被 return 编辑并且没有调用 NewEnum
方法。
好吧,如果我们并排查看前面显示的调用堆栈:
我们可以看到 non-VB Invoke
没有作为单独的“Non-Basic 代码”条目推送到 VB 堆栈。
显然,只有在调用 VBA 方法(通过原始 non-VB Invoke 或我们自己的 IDispatch_Invoke 调用 NewEnum)时才会出现该错误。如果调用了 non-VB 方法(就像没有后续 NewEnum 的原始 IDispatch::Invoke 一样),则不会出现上述 Main3
中的错误。 运行ning For Each...
on a 时没有错误发生VBA Collection同样情况下也可以。
错误原因
正如以上所有示例所暗示的,该错误可总结如下:
For Each
调用 IDispatch::Invoke
,后者又调用 NewEnum
,而堆栈指针尚未随着 ShowBug
堆栈帧的大小递增。因此,两个框架(调用者 ShowBug
和被调用者 NewEnum
)使用相同的内存。
解决方法
强制堆栈指针正确递增的方法:
- 直接调用另一个方法(在
For Each
行之前),例如Sin 1
- 间接调用另一个方法(在
For Each
行之前):- 通过传递参数
ByVal
调用 - 使用
stdole.IUnknown
接口调用IUnknown::QueryInterface
- 使用
Set
语句,该语句将调用AddRef
或Release
或两者(例如Set c = c
)。也可以根据源和目标接口调用QueryInterface
IUnknown::AddRef
- 通过传递参数
正如问题的 EDIT 部分所建议的,我们并不总是有可能通过自定义 Collection class ByVal
因为它可能只是一个全局变量,或者是一个 class 成员,我们需要记住在执行 For Each...
之前做一个伪 Set
语句或调用另一个方法。
解决方案
我仍然找不到比问题中提出的解决方案更好的解决方案,因此我将在此处复制代码作为答案的一部分,并稍作调整。
EnumHelper
class:
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
class 在任何实施自定义 collection class 的项目中都需要额外的 class,但有几个还有优点:
- 您永远不需要将
Attribute [MethodName].VB_UserMemId = -4
添加到任何其他自定义 collection class。这对于没有安装 RubberDuck('@Enumerator
注释)的用户更有用,因为他们需要导出、编辑 .cls 文本文件并为每个自定义 collection 导入回class - 您可以为同一个 class 公开多个 EnumHelper。考虑一个自定义字典 class。您可以同时拥有
ItemsEnum
和KeysEnum
。For Each v in c.ItemsEnum
和For Each v in c.KeysEnum
都可以 - 您永远不会忘记使用上述解决方法之一,因为在
Invoke
调用成员 ID -4[=234 之前将调用公开EnumHelper
class 的方法=] - 你不会再崩溃了。如果您忘记使用
For Each v in c.NewEnum
调用,而是使用For Each v in c
,您只会得到一个 运行 时间错误,无论如何都会在测试中发现。当然,您仍然可以通过将c.NewEnum
的结果传递给另一个方法ByRef
来强制崩溃,然后需要在任何其他方法调用或Set
语句之前执行For Each
.你极不可能那样做 - 显而易见但值得一提的是,您将对项目中可能拥有的所有自定义 collection class 使用相同的
EnumHelper
class
由于没有足够的代表,我无法添加评论,也无法使用聊天部分,因为它被冻结了,但我想补充一点,我遇到了一些听起来非常相似的东西,虽然我还没有测试这里提供的任何解决方案,它似乎确实是同一个错误。
我试着在这里描述它:
我希望测试也能为我解决这个问题,如果是这样,我衷心感谢你调查这个问题并提供解决方法,否则这意味着代码无法移植到 64 位 VBA.