问题描述
几个月前我在 VBA 中发现了一个错误,但找不到合适的解决方法.这个错误真的很烦人,因为它限制了一个很好的语言功能.
当使用自定义集合类时,通常需要一个枚举器,以便该类可以在 For Each
循环中使用.这可以通过添加以下行来完成:
Attribute [MethodName].VB_UserMemId = -4 '保留的DISPID_NEWENUM
紧接在函数/属性签名行之后:
- 导出类模块,在文本编辑器中编辑内容,然后重新导入
- 在函数签名上方使用
其中 ptr1 等于ObjPtr(c)
.NewEnum
方法中使用的变量越多(包括可选参数),ShowBug
方法中的 ptr 就会被写入值(内存地址).不用说,删除
ShowBug
方法中的局部 ptr 变量肯定会导致应用程序崩溃.逐行单步执行代码时,不会出现这个bug!
关于错误的更多信息
该错误与存储在
CustomCollection
中的实际Collection
无关.调用 NewEnum 函数后立即写入内存.因此,基本上执行以下任何操作都无济于事(已测试):- 添加
可选
参数 - 从函数中删除所有代码(见下面的代码)
- 声明为
IUnknown
而不是IEnumVariant
- 而不是
Function
声明为Property Get
- 在方法签名中使用
Friend
或Static
等关键字 - 将 DISPID_NEWENUM 添加到 Get 的 Let 或 Set 对应项,甚至隐藏前者(即,将 Let/Set 设为私有)).
让我们尝试上面提到的第 2 步.如果
CustomCollection
变成:版本 1.0 类开始MultiUse = -1 '真结尾属性 VB_Name = CustomCollection"属性 VB_GlobalNameSpace = False属性 VB_Creatable = False属性 VB_PredeclaredId = False属性 VB_Exposed = False选项显式公共函数 NewEnum() 作为 IEnumVARIANT属性 NewEnum.VB_UserMemId = -4结束函数
并将用于测试的代码改为:
Sub Main()#如果 Win64 那么Dim c As New CustomCollection显示错误#别的MsgBox "32 位上不会出现此错误!", vbInformation, "Cancelled"#万一结束子子 ShowBug(c 作为 CustomCollection)Dim ptr0 As LongPtr将 ptr1 调暗为 LongPtrDim ptr2 As LongPtrDim ptr3 As LongPtrDim ptr4 As LongPtrDim ptr5 As LongPtrDim ptr6 As LongPtr将 ptr7 调暗为 LongPtr将 ptr8 调暗为 LongPtr将 ptr9 调暗为 LongPtr'Dim v As Variant'出错时继续下一步对于每个 v In c下一个出错时转到 0调试.断言 ptr0 = 0结束子
运行
Main
会产生相同的错误.解决方法
我发现的避免错误的可靠方法:
调用一个方法(基本上离开
ShowBug
方法)然后回来.这需要在For Each
行执行之前发生(before 意味着它可以在同一方法中的任何地方,不一定是之前的确切行):Sin 0 '或 VBA.Int 1 - 你懂的对于每个 v In c下一个
缺点:容易忘记
执行
Set
语句.它可能在循环中使用的变体上(如果没有使用其他对象).正如上面的第 1 点,这需要在For Each
行执行之前发生:设置 v = 无对于每个 v In c下一个
或者甚至通过使用
Set c = c
将集合设置为自身或者,将 c 参数ByVal
传递给ShowBug
方法(作为 Set,调用 IUnknown::AddRef)
缺点:容易忘记使用单独的
EnumHelper
类,它是唯一用于枚举的类:版本 1.0 类开始MultiUse = -1 '真结尾属性 VB_Name = EnumHelper"属性 VB_GlobalNameSpace = False属性 VB_Creatable = False属性 VB_PredeclaredId = False属性 VB_Exposed = False选项显式私有 m_enum 作为 IEnumVARIANT公共属性集 EnumVariant(newEnum_ As IEnumVARIANT)设置 m_enum = newEnum_最终财产公共属性 Get EnumVariant() As IEnumVARIANT属性 EnumVariant.VB_UserMemId = -4设置 EnumVariant = m_enum最终财产
CustomCollection
将变成:版本 1.0 类开始MultiUse = -1 '真结尾属性 VB_Name = CustomCollection"属性 VB_GlobalNameSpace = False属性 VB_Creatable = False属性 VB_PredeclaredId = False属性 VB_Exposed = False选项显式私有 m_coll 作为集合私有子类_Initialize()设置 m_coll = 新集合结束子私有子类_Terminate()设置 m_coll = 无结束子公共子添加(v 作为变体)m_coll.Add v结束子公共函数 NewEnum() 作为 EnumHelperDim eHelper 作为新的 EnumHelper'设置 eHelper.EnumVariant = m_coll.[_NewEnum]设置 NewEnum = eHelper结束函数
和调用代码:
选项显式子主()#如果 Win64 那么Dim c As New CustomCollectionc.加1c.加2显示错误#别的MsgBox "32 位上不会出现此错误!", vbInformation, "Cancelled"#万一结束子子 ShowBug(c 作为 CustomCollection)Dim ptr0 As LongPtr将 ptr1 调暗为 LongPtrDim ptr2 As LongPtrDim ptr3 As LongPtrDim ptr4 As LongPtrDim ptr5 As LongPtrDim ptr6 As LongPtr将 ptr7 调暗为 LongPtr将 ptr8 调暗为 LongPtr将 ptr9 调暗为 LongPtr'Dim v As Variant'对于每个 v 在 c.NewEnum调试.打印 v下一个调试.断言 ptr0 = 0结束子
显然,保留的 DISPID 已从
CustomCollection
类中删除.优点:在
.NewEnum
函数上强制For Each
而不是直接自定义集合.这避免了由错误引起的任何崩溃.缺点:总是需要额外的
EnumHelper
类.很容易忘记在For Each
行中添加.NewEnum
(只会触发运行时错误).
最后一种方法 (3) 有效,因为当
c.NewEnum
被执行时,ShowBug
方法被退出,然后在调用Property Get EnumVariant 之前返回
在EnumHelper
类中.基本上方法(1)是避免错误的方法.这种行为的解释是什么?能否以更优雅的方式避免此错误?
编辑
传递
CustomCollection
ByVal 并不总是一个选项.考虑一个Class1
:选项显式私有 m_collection 作为 CustomCollection私有子类_Initialize()设置 m_collection = New CustomCollection结束子私有子类_Terminate()设置 m_collection = 无结束子公共子 AddElem(d As Double)m_collection.Add d结束子公共函数 SumElements() As DoubleDim v As VariantDim s As Double对于 m_collection 中的每个 vs = s + v下一个总和元素 = s结束函数
现在是一个调用例程:
Sub ForceBug()Dim c As Class1设置 c = 新类 1c.AddElem 2c.AddElem 5c.AddElem 7Debug.Print c.SumElements 'BOOM - 应用程序崩溃结束子
显然,这个例子有点勉强,但有一个父母"是很常见的.包含子"的自定义集合的对象对象和父"可能想做一些涉及部分或全部孩子"的操作.
在这种情况下,很容易忘记在
For Each
行之前执行Set
语句或方法调用.解决方案发生了什么
看起来
NewEnum
返回值的地址显然位于ShowBug 的
方法.所以,这就是变量从哪里获取值的原因,因为它们实际上来自ptr0
和ptr9
变量之间的内存地址NewEnum
方法的堆栈帧(例如对象的 vtable 的地址或IEnumVariant代码>界面).如果变量不存在,那么崩溃很明显,因为内存的更关键部分被覆盖(例如
ShowBug
方法的帧指针地址).由于NewEnum
方法的堆栈帧较大(例如,我们可以添加局部变量以增加大小),因此调用堆栈中顶部堆栈帧和下方堆栈帧之间共享的内存越多.如果我们使用问题中描述的选项解决错误会发生什么?只需在
For Each v In c
行之前添加一个Set v = Nothing
,结果为:同时显示前一个值和当前值(蓝色边框),我们可以看到
NewEnum
返回位于ptr0
和之外的内存地址
变量.似乎使用变通方法正确分配了堆栈帧.ShowBug
方法的 ptr9如果我们在
NewEnum
内部中断,调用堆栈看起来像这样:For Each
如何调用NewEnum
每个 VBA 类都派生自
尽管由于
Invoke
方法的挂钩,代码从未到达NewEnum
方法,但仍会出现相同的错误.堆栈帧再次被错误分配.同样,在
For Each v In c
之前添加一个Set v = Nothing
结果为:堆栈帧分配正确(绿色边框).这表明问题不在于
NewEnum
方法,也不在于我们的替换Invoke
方法.在调用我们的Invoke
之前发生了一些事情.如果我们打破我们的
IDispatch_Invoke
,调用堆栈看起来像这样:最后一个例子.考虑一个空白(没有代码)类
Class1
.如果我们在下面的代码中运行Main3
:选项显式子 Main3()#如果 Win64 那么Dim c As New Class1ShowBug3 c#别的MsgBox "32 位上不会出现此错误!", vbInformation, "Cancelled"#万一结束子Sub ShowBug3(ByRef c As Class1)Dim ptr0 As LongPtr将 ptr1 调暗为 LongPtrDim ptr2 As LongPtrDim ptr3 As LongPtrDim ptr4 As LongPtrDim ptr5 As LongPtrDim ptr6 As LongPtr将 ptr7 调暗为 LongPtr将 ptr8 调暗为 LongPtr将 ptr9 调暗为 LongPtr'Dim v As Variant'出错时继续下一步对于每个 v In c下一个调试.断言 ptr0 = 0结束子
该错误根本不会发生.这与使用我们自己挂钩的
Invoke
运行Main2
有何不同?在这两种情况下都返回DISP_E_MEMBERNOTFOUND
并且没有调用NewEnum
方法.好吧,如果我们并排查看之前显示的调用堆栈:
我们可以看到非 VBInvoke
没有作为单独的非基本代码"被推送到 VB 堆栈上.入口.显然,该错误仅在调用 VBA 方法(通过原始非 VB Invoke 调用 NewEnum 或我们自己的 IDispatch_Invoke)时才会发生.如果调用非 VB 方法(如原始的 IDispatch::Invoke 没有跟随 NewEnum),则不会像上面的
Main3
那样发生错误.在相同情况下在 VBA 集合上运行For Each...
时也不会出现错误.错误原因
正如上面所有的例子所表明的,这个错误可以总结如下:
For Each
调用IDispatch::Invoke
依次调用NewEnum
而堆栈指针还没有随着ShowBug 的大小增加
栈帧.因此,两个帧(调用方ShowBug
和被调用方NewEnum
)使用相同的内存.解决方法
强制正确递增堆栈指针的方法:
- 直接调用另一个方法(在
For Each
行之前)例如罪 1
- 间接调用另一个方法(在
For Each
行之前):- 通过传递参数
ByVal
调用 - 使用
stdole.IUnknown
接口调用IUnknown::QueryInterface
- 使用
Set
语句,该语句将调用AddRef
或Release
或两者(例如Set c = c
).也可以根据源和目标接口调用QueryInterface
IUnknown::AddRef
- 通过传递参数
正如问题的 EDIT 部分所建议的,我们并不总是有可能传递自定义集合类
ByVal
,因为它可能只是一个全局变量,或类成员,我们需要记住在执行For Each...
之前执行一个虚拟的Set
语句或调用另一个方法.解决方案
我仍然找不到比问题中提出的更好的解决方案,所以我只是将代码复制到这里作为答案的一部分,并稍作调整.
EnumHelper
类:版本 1.0 类开始MultiUse = -1 '真结尾属性 VB_Name = EnumHelper"属性 VB_GlobalNameSpace = False属性 VB_Creatable = False属性 VB_PredeclaredId = False属性 VB_Exposed = False选项显式私有 m_enum 作为 IEnumVARIANT公共属性集 EnumVariant(newEnum_ As IEnumVARIANT)设置 m_enum = newEnum_最终财产公共属性 Get EnumVariant() As IEnumVARIANT属性 EnumVariant.VB_UserMemId = -4设置 EnumVariant = m_enum最终财产公共属性 Get Self() 作为 EnumHelper设置自我=我最终财产
CustomCollection
现在会变成这样:选项显式私有 m_coll 作为集合私有子类_Initialize()设置 m_coll = 新集合结束子私有子类_Terminate()设置 m_coll = 无结束子公共子添加(v 作为变体)m_coll.Add v结束子公共函数 NewEnum() 作为 EnumHelper使用新的 EnumHelper设置 .EnumVariant = m_coll.[_NewEnum]设置 NewEnum = .Self结束于结束函数
你只需要调用
For Each v in c.NewEnum
尽管
EnumHelper
类是任何实现自定义集合类的项目都需要的额外类,但它也有几个优点:- 您永远不需要将
Attribute [MethodName].VB_UserMemId = -4
添加到任何其他自定义集合类.这对于没有安装 RubberDuck 的用户更有用('@Enumerator
注释),因为他们需要导出、编辑 .cls 文本文件并为每个自定义集合类导入回 - 您可以为同一个类公开多个 EnumHelper.考虑一个自定义字典类.您可以同时拥有
ItemsEnum
和KeysEnum
.For Each v in c.ItemsEnum
和For Each v in c.KeysEnum
都可以工作 - 您永远不会忘记使用上述解决方法之一,因为将在
Invoke
调用成员 ID -4 之前调用公开EnumHelper
类的方法 - 您不会再遇到崩溃了.如果您忘记使用
For Each v in c.NewEnum
调用,而是使用For Each v in c
,您只会得到一个运行时错误,无论如何都会在测试中发现该错误.当然,您仍然可以通过将c.NewEnum
的结果传递给另一个方法ByRef
来强制崩溃,然后该方法需要执行For Each
在任何其他方法调用或Set
语句之前.你极不可能这样做 - 显而易见但值得一提的是,您可以为项目中可能拥有的所有自定义集合类使用相同的
EnumHelper
类
I have found a bug in VBA a few months ago and was unable to find a decent workaround. The bug is really annoying as it kind of restricts a nice language feature.
When using a Custom Collection Class it is quite common to want to have an enumerator so that the class can be used in a
For Each
loop. This can be done by adding this line:Attribute [MethodName].VB_UserMemId = -4 'The reserved DISPID_NEWENUM
immediately after the function/property signature line either by:
- Exporting the class module, editing the contents in a text editor, and then importing back
- Using Rubberduck annotation
'@Enumerator
above the function signature and then syncronizing
Unfortunately, on x64, using the above-mentioned feature, causes the wrong memory to get written and leads to the crash of the Application in certain cases (discussed later).
Reproducing the bug
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
Code in a standard module:
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
By running the
Main
method, the code will stop on theAssert
line in theShowBug
method and you can see in the Locals window that local variables got their values changed out of nowhere:
where ptr1 is equal toObjPtr(c)
. The more variables are used inside theNewEnum
method (including Optional parameters) the more ptrs in theShowBug
method get written with a value (memory address).Needless to say, removing the local ptr variables inside the
ShowBug
method would most certainly cause the crash of the Application.When stepping through code line by line, this bug will not occur!
More on the bug
The bug is not related with the actual
Collection
stored inside theCustomCollection
. The memory gets written immediately after the NewEnum function is invoked. So, basically doing any of the following is not helping (tested):- adding
Optional
parameters - removing all code from within the function (see below code showing this)
- declaring as
IUnknown
instead ofIEnumVariant
- instead of
Function
declaring asProperty Get
- using keywords like
Friend
orStatic
in the method signature - adding the DISPID_NEWENUM to a Let or Set counterpart of the Get, or even hiding the former (i.e. make the Let/Set private).
Let us try step 2 mentioned above. If
CustomCollection
becomes: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
and the code used for testing is changed to:
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
running
Main
produces the same bug.Workaround
Reliable ways, that I found, to avoid the bug:
Call a method (basically leave the
ShowBug
method) and come back. This needs to happen before theFor Each
line is executed (before meaning it can be anywhere in the same method, not necessarily the exact line before):Sin 0 'Or VBA.Int 1 - you get the idea For Each v In c Next v
Cons: Easy to forget
Do a
Set
statement. It could be on the variant used in the loop (if no other objects are used). As in point 1 above, this needs to happen before theFor Each
line is executed:Set v = Nothing For Each v In c Next v
or even by setting the collection to itself with
Set c = c
Or, passing the c parameterByVal
to theShowBug
method (which, as Set, does a call to IUnknown::AddRef)
Cons: Easy to forgetUsing a separate
EnumHelper
class that is the only class ever used for enumerating: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
would become: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
and the calling code:
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
Obviously, the reserved DISPID was removed from the
CustomCollection
class.Pros: forcing the
For Each
on the.NewEnum
function instead of the custom collection directly. This avoids any crash caused by the bug.Cons: always needing the extra
EnumHelper
class. Easy to forget to add the.NewEnum
in theFor Each
line (would only trigger a runtime error).
The last approach (3) works because when
c.NewEnum
is executed theShowBug
method is exited and then returned before the invocation of theProperty Get EnumVariant
inside theEnumHelper
class. Basically approach (1) is the one avoiding the bug.What is the explanation for this behavior? Can this bug be avoided in a more elegant way?
EDIT
Passing the
CustomCollection
ByVal is not always an option. Consider aClass1
: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
And now a calling routine:
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
Obviously, the example is a bit forced but it is quite common to have a "parent" object containing a Custom Collection of "child" objects and the "parent" might want to do some operation involving some or all of the "children".
In this case it would be easy to forget to do a
Set
statement or a method call before theFor Each
line.解决方案What is happening
It appears that the stack frames are overlapping although they should not. Having enough variables in the
ShowBug
method prevents a crash and the values of the variables (in the caller subroutine) are simply changed because the memory they refer to is also used by another stack frame (the called subroutine) that was added/pushed later at the top of the call stack.We can test this by adding a couple of
Debug.Print
statements to the same code from the question.The
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
And the calling code, in a standard .bas module:
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
By running
Main
I get something like this in the Immediate Window:The address of the
NewEnum
return value is clearly at a memory address in between theptr0
andptr9
variables of theShowBug
method. So, that is why the variables get values out of nowhere, because they actually come from the stack frame of theNewEnum
method (like the address of the object's vtable or the address of theIEnumVariant
interface). If the variables would not be there, then the crash is obvious as more critical parts of memory are being overwritten (e.g. the frame pointer address for theShowBug
method). As the stack frame for theNewEnum
method is larger (we can add local variables for example, to increase the size), the more memory is shared between the top stack frame and the one below in the call stack.What happens if we workaround the bug with the options described in the question? Simply adding a
Set v = Nothing
before theFor Each v In c
line, results into:Showing both previous value and the current one (bordered blue), we can see that the
NewEnum
return is at a memory address outside of theptr0
andptr9
variables of theShowBug
method. It seems that the stack frame was correctly allocated using the workaround.If we break inside the
NewEnum
the call stack looks like this:How
For Each
invokesNewEnum
Every VBA class is derived from IDispatch (which in turn is derived from IUnknown).
When a
For Each...
loop is called on an object, that object'sIDispatch::Invoke
method is called with adispIDMember
equal to -4. A VBA.Collection already has such a member but for VBA custom classes we mark our own method withAttribute NewEnum.VB_UserMemId = -4
so that Invoke can call our method.Invoke
is not called directly if the interface used in theFor Each
line is not derived fromIDispatch
. Instead,IUnknown::QueryInterface
is called first and asked for the IDispatch interface. In this caseInvoke
is obviously calledonly after IDispatch interface is returned. Right here is the reason why usingFor Each
on an Object declaredAs IUnknown
will not cause the bug regardless if it is passedByRef
or if it is a global or class member custom collection. It simply uses workaround number 1 mentioned in the question (i.e. calls another method) although we cannot see it.Hooking Invoke
We can replace the non-VB
Invoke
method with one of our own in order to investigate further. In a standard.bas
module we need the following code to hook: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
and we run the
Main2
method (standard .bas module) to produce the bug: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
By running the above, I get:
The same bug occurs although the code never reaches the
NewEnum
method due to the hooking of theInvoke
method. The stack frame is again wrongfully allocated.Again, adding a
Set v = Nothing
before theFor Each v In c
results into:The stack frame is allocated correctly (bordered green). This indicates that the issue is not with the
NewEnum
method and also not with our replacementInvoke
method. Something is happening before ourInvoke
is called.If we break inside our
IDispatch_Invoke
the call stack looks like this:One last example. Consider a blank (with no code) class
Class1
. If we runMain3
in the following code: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
The bug simply does not occur. How is this different from running
Main2
with our own hookedInvoke
? In both casesDISP_E_MEMBERNOTFOUND
is returned and noNewEnum
method is called.Well, if we look at the previously shown call stacks side by side:
we can see that the non-VBInvoke
is not pushed on the VB stack as a separate "Non-Basic Code" entry.Apparently, the bug only occurs if a VBA method is called (either NewEnum via the original non-VB Invoke or our own IDispatch_Invoke). If a non-VB method is called (like the original IDispatch::Invoke with no following NewEnum) the bug does not occur as in
Main3
above. No bug occurs when runningFor Each...
on a VBA Collection within the same circumstances either.The bug cause
As all the above examples suggest, the bug can be summarized with the following:
For Each
callsIDispatch::Invoke
which in turn callsNewEnum
while the stack pointer has not been incremented with the size of theShowBug
stack frame. Hence, same memory is used by both frames (the callerShowBug
and the calleeNewEnum
).Workarounds
Ways to force the correct incrementation of the stack pointer:
- call another method directly (before the
For Each
line) e.g.Sin 1
- call another method indirectly (before the
For Each
line):- a call to
IUnknown::AddRef
by passing the argumentByVal
- a call to
IUnknown::QueryInterface
by using thestdole.IUnknown
interface - using a
Set
statement which will call eitherAddRef
orRelease
or both (e.g.Set c = c
). Could also callQueryInterface
depending on the source and target interfaces
- a call to
As suggested in the EDIT section of the question, we don't always have the possibility to pass the Custom Collection class
ByVal
because it could simply be a global variable, or a class member and we would need to remember to do a dummySet
statement or to call another method beforeFor Each...
is executed.Solution
I still could not find a better solution that the one presented in the question, so I am just going to replicate the code here as part of the answer, with a slight tweak.
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
would now become something like: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
You would just need to call with
For Each v in c.NewEnum
Although, the
EnumHelper
class would be an extra class needed in any project implementing a custom collection class, there are a couple of advantages as well:- You would never need to add the
Attribute [MethodName].VB_UserMemId = -4
to any other custom collection class. This is even more useful for users that do not have RubberDuck installed ('@Enumerator
annotation), as they would need to export, edit the .cls text file and import back for each custom collection class - You could expose multiple EnumHelpers for the same class. Consider a custom dictionary class. You could have an
ItemsEnum
and aKeysEnum
at the same time. BothFor Each v in c.ItemsEnum
andFor Each v in c.KeysEnum
would work - You would never forget to use one of the workarounds presented above as the method exposing the
EnumHelper
class would be called beforeInvoke
is calling member ID -4 - You would not get crashes anymore. If you forget to call with
For Each v in c.NewEnum
and instead useFor Each v in c
you would just get a runtime error which would be picked up in testing anyway. Of course you could still force a crash by passing the result ofc.NewEnum
to another methodByRef
which would then need to execute aFor Each
before any other method call orSet
statement. Highly unlikely you would ever do that - Obvious but worth mentioning, you would use the same
EnumHelper
class for all the custom collection classes you might have in a project
这篇关于x64 自定义类上的 For Each 枚举错误的文章就介绍到这了,希望我们推荐的答案对大家有所帮助,也希望大家多多支持!
- 添加