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 the Assert
line in the ShowBug
method and you can see in the Locals window that local variables got their values changed out of nowhere:
where ptr1 is equal to ObjPtr(c)
. The more variables are used inside the NewEnum
method (including Optional parameters) the more ptrs in the ShowBug
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 the CustomCollection
. 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 the ShowBug
method is exited and then returned before the invocation of the Property Get EnumVariant
inside the EnumHelper
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 a 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
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 the For Each
line.