In VBA, if you want an iterable Range
object like in Python, you do something like this. However that approach involves building the entire range in one go:
Set mCollection = New Collection Dim i As Long For i = startValue To endValue mCollection.Add i Next
...which is bad if you want to make a really big range, as it takes ages and a lot of memory to build that collection. That's what generators are for; they generate the next item in the sequence as you loop.
Now if you want a class to be iterable, it has to return a [_NewEnum]
, which is done with the Set
keyword. That tells me that a For...Each
loop only requires a reference to an Enum
, since the Set
keyword only assigns pointers to a returned variable, rather than the actual value.
That gives scope for a bit of juggling:
For...Each
(henceforth "the Iterator") requires bit of memory that gives directions to the supplied [_NewEnum]
; a reference to the enum object's pointer[_NewEnum]
pointer from an encapsulated collection whenever it wantsIn other words:
For...Each
loop, my class returns a variable whose value is the pointer to one Enum. The variable resides in memory at a location given by VarPtr(theVariable)
ObjPtr()
of the second enum.If that theory is correct, then the For Each
loop would now hold a reference to a different value for [_NewEnum]
, so would do something different.
Here's how I tried to do it:
NumberRange
Class ModuleNote: must be imported to preserve attributes.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "NumberRange"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Private Type TRange
encapsulated As Collection
isGenerator As Boolean
currentCount As Long
maxCount As Long
currentEnum As IUnknown
End Type
Private this As TRange
Public Sub fullRange(ByVal count As Long)
'generate whole thing at once
Dim i As Long
this.isGenerator = False
For i = 1 To count
this.encapsulated.Add i
Next i
End Sub
Public Sub generatorRange(ByVal count As Long)
'generate whole thing at once
this.isGenerator = True
this.currentCount = 1
this.maxCount = count
this.encapsulated.Add this.currentCount 'initial value for first enumeration
End Sub
Public Property Get NewEnum() As IUnknown
Attribute NewEnum.VB_UserMemId = -4
'Attribute NewEnum.VB_UserMemId = -4
Set this.currentEnum = this.encapsulated.[_NewEnum]
Set NewEnum = this.currentEnum
End Property
Public Sub generateNext()
'This method is what should overwrite the current variable
If this.isGenerator And this.currentCount < this.maxCount Then
this.currentCount = this.currentCount + 1
replaceVal this.encapsulated, this.currentCount
updateObject VarPtr(this.currentEnum), this.encapsulated.[_NewEnum]
Else
Err.Raise 5, Description:="Method reserved for generators"
End If
End Sub
Private Sub Class_Initialize()
Set this.encapsulated = New Collection
End Sub
Private Sub replaceVal(ByRef col As Collection, ByVal newval As Long)
If col.count Then
col.Remove 1
End If
col.Add newval
End Sub
Contains a standard method for making the full thing in one go, or a generator method, to be used in conjunction with generateNext
in the loop. Might be an off-by-one error in there but that's not important right now.
These methods have only been tested on my 32 bit system. Might work on both though (with the conditional compilation).
Option Explicit
Private Declare PtrSafe Sub CopyMemory Lib "kernel32" Alias "RtlMoveMemory" (dest As Any, _
source As Any, ByVal bytes As Long)
Public Sub updateObject(ByVal variableAddress As LongPtr, ByVal replacementObject As Variant)
#If VBA7 And Win64 Then
Const pointerLength As Byte = 8
#Else
Const pointerLength As Byte = 4
#End If
CopyMemory ByVal variableAddress, ObjPtr(replacementObject), pointerLength
End Sub
That last line is the important one; it says to copy the object pointer of the supplied object ObjPtr(replacementObject)
to the location of a specific variable ByVal variableAddress
, the ByVal
here signalling that we're talking about the memory of the variable itself, not a reference to the variable. The fact that the variable already contains an object pointer doesn't matter
Sub testGenerator()
Dim g As New NumberRange
g.generatorRange 10
Dim val
For Each val In g
Debug.Print val
g.generateNext
Next val
End Sub
If it's working then this should print numbers 1 to 10. But right now it's getting out of the loop after one go.
So why isn't this working? I think I've followed all the steps I outlined. I think the memory updater is working as intended, but I'm not certain as I can't query the ObjPtr()
of the enum which the Iterator is currently using. Perhaps For...Each
just doesn't like being interrupted! Any thoughts on how to achieve the desired behaviour welcome!
Ps. Save often, watch out for crashes!
Bonus test method for the memory writer:
Public Sub testUpdater()
'initialise
Dim initialEnumeration As Object, newEnumeration As Object 'represent a [_NewEnum]
Set initialEnumeration = CreateObject("System.Collections.ArrayList")
Dim i As Long
For i = 1 To 5
initialEnumeration.Add i
Next i
'initialEnumeration pointers are what we want to change
iterateObjPrinting "initialEnumeration at Start:", initialEnumeration
'make some obvious change
Set newEnumeration = initialEnumeration.Clone()
newEnumeration(4) = 9
iterateObjPrinting "newEnumeration before any copy:", newEnumeration
'update the first one in place
updateObject VarPtr(initialEnumeration), newEnumeration
iterateObjPrinting "initialEnumeration after copy", initialEnumeration
End Sub
Private Sub iterateObjPrinting(ByVal message As String, ByVal obj As Variant)
Dim val, result As String
For Each val In obj
result = result & " " & val
Next val
Debug.Print message, Trim(result)
End Sub
A seriously 1337 hacker named DEXWERX wrote up the deep magic in 2017. I adapted DEXWERX's code to this situation, and provide a working example here. The pieces are:
MEnumerator
: A tweaked version of DEXWERX's code. This makes an IEnumVARIANT
by assembling it in memory from scratch!IValueProvider
: A straight-VBA interface that your generator should implement. The IEnumVARIANT
created by MEnumerator
will call methods on an IValueProvider
instance to get the elements to return.NumberRange
: The generator class, which implements IValueProvider
.Following are test code to paste into VBA, and the cls
and bas
files to import.
I put this in ThisDocument
.
Option Explicit
Sub testNumberRange()
Dim c As New NumberRange
c.generatorTo 10
Dim idx As Long: idx = 1
Dim val
For Each val In c
Debug.Print val
If idx > 100 Then Exit Sub ' Just in case of infinite loops
idx = idx + 1
Next val
End Sub
IValueProvider.cls
Save this to a file and import it into the VBA Editor.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "IValueProvider"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
' IValueProvider: Provide values.
Option Explicit
Option Base 0
' Return True if there are more values
Public Function HasMore() As Boolean
End Function
' Return the next value
Public Function GetNext() As Variant
End Function
NumberRange.cls
Save this to a file and import it into the VBA Editor. Note that the NewEnum
function now merely delegates to the NewEnumerator
function in MEnumerator
. Instead of using a collection, this overrides the IValueProvider_HasMore
and IValueProvider_GetNext
methods for use by MEnumerator
.
Also note that I made everything zero-based for consistency.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "NumberRange"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Option Base 0
' === The values we're actually going to return ===================
Implements IValueProvider
Private Type TRange
isGenerator As Boolean
currentCount As Long
maxCount As Long
End Type
Private this As TRange
Private Function IValueProvider_GetNext() As Variant
IValueProvider_GetNext = this.currentCount 'Or try Chr(65 + this.currentCount)
this.currentCount = this.currentCount + 1
End Function
Private Function IValueProvider_HasMore() As Boolean
IValueProvider_HasMore = this.isGenerator And (this.currentCount <= this.maxCount)
End Function
' === Public interface ============================================
Public Sub generatorTo(ByVal count As Long)
this.isGenerator = True
this.currentCount = 0
this.maxCount = count - 1
End Sub
' === Enumeration support =========================================
Public Property Get NewEnum() As IEnumVARIANT
Attribute NewEnum.VB_UserMemId = -4
'Attribute NewEnum.VB_UserMemId = -4
Set NewEnum = NewEnumerator(Me)
End Property
' === Internals ===================================================
Private Sub Class_Initialize()
' If you needed to initialize `this`, you could do so here
End Sub
MEnumerator.bas
Save this to a file and import it into the VBA Editor. The IEnumVARIANT_Next
calls the IValueProvider
methods and forwards them to VBA. The NewEnumerator
method builds the IEnumVARIANT
.
Attribute VB_Name = "MEnumerator"
' Modified by cxw from code by http://www.vbforums.com/member.php?255623-DEXWERX
' posted at http://www.vbforums.com/showthread.php?854963-VB6-IEnumVARIANT-For-Each-support-without-a-typelib&p=5229095&viewfull=1#post5229095
' License: "Use it how you see fit." - http://www.vbforums.com/showthread.php?854963-VB6-IEnumVARIANT-For-Each-support-without-a-typelib&p=5232689&viewfull=1#post5232689
' Explanation at https://stackoverflow.com/a/52261687/2877364
'
' MEnumerator.bas
'
' Implementation of IEnumVARIANT to support For Each in VB6
'
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Option Explicit
Private Type TENUMERATOR
VTablePtr As Long
References As Long
Enumerable As IValueProvider
Index As Long
End Type
Private Enum API
NULL_ = 0
S_OK = 0
S_FALSE = 1
E_NOTIMPL = &H80004001
E_NOINTERFACE = &H80004002
E_POINTER = &H80004003
#If False Then
Dim NULL_, S_OK, S_FALSE, E_NOTIMPL, E_NOINTERFACE, E_POINTER
#End If
End Enum
Private Declare Function FncPtr Lib "msvbvm60" Alias "VarPtr" (ByVal Address As Long) As Long
Private Declare Function GetMem4 Lib "msvbvm60" (Src As Any, Dst As Any) As Long
Private Declare Function CopyBytesZero Lib "msvbvm60" Alias "__vbaCopyBytesZero" (ByVal Length As Long, Dst As Any, Src As Any) As Long
Private Declare Function CoTaskMemAlloc Lib "ole32" (ByVal cb As Long) As Long
Private Declare Sub CoTaskMemFree Lib "ole32" (ByVal pv As Long)
Private Declare Function IIDFromString Lib "ole32" (ByVal lpsz As Long, ByVal lpiid As Long) As Long
Private Declare Function SysAllocStringByteLen Lib "oleaut32" (ByVal psz As Long, ByVal cblen As Long) As Long
Private Declare Function VariantCopyToPtr Lib "oleaut32" Alias "VariantCopy" (ByVal pvargDest As Long, ByRef pvargSrc As Variant) As Long
Private Declare Function InterlockedIncrement Lib "kernel32" (ByRef Addend As Long) As Long
Private Declare Function InterlockedDecrement Lib "kernel32" (ByRef Addend As Long) As Long
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Public Function NewEnumerator(ByRef Enumerable As IValueProvider) As IEnumVARIANT
' Class Factory
''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''''
Static VTable(6) As Long
If VTable(0) = NULL_ Then
' Setup the COM object's virtual table
VTable(0) = FncPtr(AddressOf IUnknown_QueryInterface)
VTable(1) = FncPtr(AddressOf IUnknown_AddRef)
VTable(2) = FncPtr(AddressOf IUnknown_Release)
VTable(3) = FncPtr(AddressOf IEnumVARIANT_Next)
VTable(4) = FncPtr(AddressOf IEnumVARIANT_Skip)
VTable(5) = FncPtr(AddressOf IEnumVARIANT_Reset)
VTable(6) = FncPtr(AddressOf IEnumVARIANT_Clone)
End If
Dim this As TENUMERATOR
With this
' Setup the COM object
.VTablePtr = VarPtr(VTable(0))
.References = 1
Set .Enumerable = Enumerable
End With
' Allocate a spot for it on the heap
Dim pThis As Long
pThis = CoTaskMemAlloc(LenB(this))
If pThis Then
' CopyBytesZero is used to zero out the original
' .Enumerable reference, so that VB doesn't mess up the
' reference count, and free our enumerator out from under us
CopyBytesZero LenB(this), ByVal pThis, this
DeRef(VarPtr(NewEnumerator)) = pThis
End If
End Function
Private Function RefToIID$(ByVal riid As Long)
' copies an IID referenced into a binary string
Const IID_CB As Long = 16& ' GUID/IID size in bytes
DeRef(VarPtr(RefToIID)) = SysAllocStringByteLen(riid, IID_CB)
End Function
Private Function StrToIID$(ByRef iid As String)
' converts a string to an IID
StrToIID = RefToIID$(NULL_)
IIDFromString StrPtr(iid), StrPtr(StrToIID)
End Function
Private Function IID_IUnknown() As String
Static iid As String
If StrPtr(iid) = NULL_ Then _
iid = StrToIID$("{00000000-0000-0000-C000-000000000046}")
IID_IUnknown = iid
End Function
Private Function IID_IEnumVARIANT() As String
Static iid As String
If StrPtr(iid) = NULL_ Then _
iid = StrToIID$("{00020404-0000-0000-C000-000000000046}")
IID_IEnumVARIANT = iid
End Function
Private Function IUnknown_QueryInterface(ByRef this As TENUMERATOR, _
ByVal riid As Long, _
ByVal ppvObject As Long _
) As Long
If ppvObject = NULL_ Then
IUnknown_QueryInterface = E_POINTER
Exit Function
End If
Select Case RefToIID$(riid)
Case IID_IUnknown, IID_IEnumVARIANT
DeRef(ppvObject) = VarPtr(this)
IUnknown_AddRef this
IUnknown_QueryInterface = S_OK
Case Else
IUnknown_QueryInterface = E_NOINTERFACE
End Select
End Function
Private Function IUnknown_AddRef(ByRef this As TENUMERATOR) As Long
IUnknown_AddRef = InterlockedIncrement(this.References)
End Function
Private Function IUnknown_Release(ByRef this As TENUMERATOR) As Long
IUnknown_Release = InterlockedDecrement(this.References)
If IUnknown_Release = 0& Then
Set this.Enumerable = Nothing
CoTaskMemFree VarPtr(this)
End If
End Function
Private Function IEnumVARIANT_Next(ByRef this As TENUMERATOR, _
ByVal celt As Long, _
ByVal rgVar As Long, _
ByRef pceltFetched As Long _
) As Long
Const VARIANT_CB As Long = 16 ' VARIANT size in bytes
If rgVar = NULL_ Then
IEnumVARIANT_Next = E_POINTER
Exit Function
End If
Dim Fetched As Long
Fetched = 0
Dim element As Variant
With this
Do While this.Enumerable.HasMore
element = .Enumerable.GetNext
VariantCopyToPtr rgVar, element
Fetched = Fetched + 1&
If Fetched = celt Then Exit Do
rgVar = PtrAdd(rgVar, VARIANT_CB)
Loop
End With
If VarPtr(pceltFetched) Then pceltFetched = Fetched
If Fetched < celt Then IEnumVARIANT_Next = S_FALSE
End Function
Private Function IEnumVARIANT_Skip(ByRef this As TENUMERATOR, ByVal celt As Long) As Long
IEnumVARIANT_Skip = E_NOTIMPL
End Function
Private Function IEnumVARIANT_Reset(ByRef this As TENUMERATOR) As Long
IEnumVARIANT_Reset = E_NOTIMPL
End Function
Private Function IEnumVARIANT_Clone(ByRef this As TENUMERATOR, ByVal ppEnum As Long) As Long
IEnumVARIANT_Clone = E_NOTIMPL
End Function
Private Function PtrAdd(ByVal Pointer As Long, ByVal Offset As Long) As Long
Const SIGN_BIT As Long = &H80000000
PtrAdd = (Pointer Xor SIGN_BIT) + Offset Xor SIGN_BIT
End Function
Private Property Let DeRef(ByVal Address As Long, ByVal Value As Long)
GetMem4 Value, ByVal Address
End Property
I can't tell you how to fix it, but I can tell you why. This is too long for a comment :) .
You are exporting a Collection
enumerator for your own use. The straight-Collection
version of testGenerator
has the same behaviour:
Option Explicit
Sub testCollection()
Dim c As New Collection
Dim idx As Long: idx = 1
Dim val
c.Add idx
For Each val In c
Debug.Print val
c.Add idx
If idx > 100 Then Exit Sub ' deadman, to break an infinite loop if it starts working!
idx = idx + 1
Next val
End Sub
This code prints 1
and then exits the For Each
loop.
I believe the updateObject
call is not doing what you expect. The following is based on my own knowledge, and also this forum post. When the For Each
loop begins, VBA gets an IUnknown
from _NewEnum
. VBA then calls QueryInterface
on the IUnknown
to get its own IEnumVARIANT
pointer into the single, reference-counted enumerator object. As a result, the For Each
has its own copy of the enumerator.
Then, when you call updateObject
, it changes the contents of this.currentEnum
. However, that is not where the For Each
loop is actually looking. As a result, replaceVal()
is modifying a collection while it is being iterated over. The VB.NET docs have something to say on this subject. I suspect the behaviour of VB.NET was inherited from VBA, since it matches what you are seeing. Specifically:
The enumerator object returned by
GetEnumerator
[ofSystem.Collections.IEnumerable
] normally doesn't let you change the collection by adding, deleting, replacing, or reordering any elements. If you change the collection after you have initiated aFor Each...Next
loop, the enumerator object becomes invalid ...
Therefore, you may have to roll your own IEnumerator
implementation rather than reusing that from Collection
.
Edit I found this link suggesting that you need to implement IEnumVARIANT
, which VBA won't do natively (edit but can be made to do, as shown above!). I haven't tried the information at that link myself, but pass it along in case it's helpful.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With