VBA standard module for IEnumVARIANT interface implementation — no typelib required, early binding via IEnumerator.
Implements the full IEnumVARIANT interface (Next, Skip, Reset, Clone) in a standard module using AddressOf and a heap-allocated vtable. Items are retrieved one by one via the IEnumerator interface, which the iterable Class must implement.
For the late-binding variant (no interface required, uses
CallByName) see vba-enumerator-late-binding.
For Eachwithout a typelib — pure VBA, no external dependencies- Early binding — items retrieved via
IEnumerator.Item(index), direct vtable call, no dispatch overhead - Nested loops — works correctly for nested
For Eachwith mixed objects and mixed enumerators - Ascending and descending —
FirstandLastcan be in either order - Fast variant copy — uses a Variant ByRef construct (~5× faster than
VariantCopyAPI); switch to API mode with#Const API = True - Full COM lifecycle —
QueryInterface,AddRef,Release,Cloneall correctly implemented - x86 / x64 compatible via
LongPtrand#If Win64 - Pure VBA, zero dependencies, Rubberduck-friendly annotations
| File | Type | Description |
|---|---|---|
IEnumerator.cls |
Interface | Defines First, Last, Item — implement this in your iterable Class |
Enumerator.bas |
Module | Enumerate(iterable) — the main entry point |
CEnumTestEarly.cls |
Example | Simple iterable Class implementing IEnumerator |
EnumTestEarly.bas |
Example | For Each tests and performance timings |
Each file has a corresponding _WithAttributes version (e.g. Enumerator_WithAttributes.bas) with Rubberduck annotations removed and VB attributes baked in. Import the _WithAttributes files if you are not using Rubberduck.
Note:
EnumTestEarly.basuses aStopwatchmodule for timing measurements. Remove or replace those calls if you do not have it.
| Member | Description |
|---|---|
Enumerate(iterable) |
Returns a synthetic IEnumVARIANT for the iterable object. Raises an error if iterable is Nothing or does not implement IEnumerator. |
| Method | Description |
|---|---|
First() |
Returns the index of the first item |
Last() |
Returns the index of the last item |
Item(index) |
Returns the item at the given index |
1. Implement IEnumerator in your Class:
Implements IEnumerator
Private Function IEnumerator_First() As Long
IEnumerator_First = 1
End Function
Private Function IEnumerator_Last() As Long
IEnumerator_Last = this.Count
End Function
Private Function IEnumerator_Item(ByVal index As Long) As Variant
If VBA.IsObject(this.Items(index)) Then
Set IEnumerator_Item = this.Items(index)
Else
IEnumerator_Item = this.Items(index)
End If
End Function2. Add the Enumerate function:
'@Enumerator
Public Function Enumerate() As IEnumVARIANT
Set Enumerate = Enumerator.Enumerate(Me)
End Function3. Use For Each:
Dim obj As MyClass
Set obj = New MyClass
' ... populate obj ...
Dim v As Variant
For Each v In obj
Debug.Print v
NextTimings for n = 10,000 items (Immediate Window):
| Method | Time (ms) |
|---|---|
Custom enumerator — For Each (VarByRef) |
2.89 |
Custom enumerator — For Each (API) |
16.38 |
VB Collection — For Each |
0.24 |
VB Array — For Each |
0.13 |
VB Array — For i |
0.07 |
The overhead versus a native VB Collection is primarily the early-bound IEnumerator.Item(i) vtable call per element. The VarByRef variant copy is ~5× faster than the VariantCopy API.
| Directive | Default | Description |
|---|---|---|
#Const API |
False |
Copy Variants via Variant ByRef construct (fast) or VariantCopy API (slow) |
#Const API = True uses VarCopyToPtr (VariantCopy from oleaut32). #Const API = False uses the Variant ByRef construct — approximately 5× faster. See performance table above.
VBA's For Each requires the iterable object to expose IEnumVARIANT via _NewEnum. Rather than using a typelib to define IEnumVARIANT, this module:
- Allocates a block of heap memory (
CoTaskMemAlloc) large enough to hold the enumerator state (TENUMUDT) - Builds a vtable of function pointers (
AddressOf) for the seven COM methods:QueryInterface,AddRef,Release,Next,Skip,Reset,Clone - Writes the vtable pointer into the first field of
TENUM— making it a valid COM object - Overwrites the return value of
Enumeratewith the heap pointer — returning the synthetic object asIEnumVARIANT - Keeps the iterable object alive via a
Static Collectionkeyed by the heap address, compensating for reference count changes when the localTENUMgoes out of scope
Based on work by Dexter Freivald (32-bit, late binding) and ideas from Hardcore Visual Basic 5.0 by Bruce McKinney.
Private Type TENUM
pvTable As LongPtr ' MUST be first — COM reads vtable pointer at offset 0
IEnum As IEnumerator ' early-bound reference to the iterable object
nRef As Long ' COM reference count
First As Long ' index of first item
Last As Long ' index of last item
Current As Long ' index of current position
Step As Long ' +1 (ascending) or -1 (descending)
End TypepvTable is first because COM requires a pointer to the vtable at offset 0 of any COM object. IEnum As IEnumerator is early-bound — the compiler emits a direct vtable call to .Item(i) rather than a late-bound IDispatch.Invoke, which is the dominant cost in IEnumVARIANT_Next. Step is derived from First/Last at construction time; ascending and descending enumeration share the same code paths throughout.
Static vTable(0 To 6) As LongPtr
If vTable(0) = vbNullPtr Then
vTable(0) = VBA.CLngPtr(AddressOf IUnknown_QueryInterface)
...
vTable(6) = VBA.CLngPtr(AddressOf IEnumVARIANT_Clone)
End IfThe Static array persists for the lifetime of the VBA session. vTable(0) = vbNullPtr is the once-only sentinel — subsequent calls to Enumerate reuse the same vtable without rebuilding it. Slots 0–2 are the three IUnknown methods; slots 3–6 are the four IEnumVARIANT methods.
CopyMemory ByVal VarPtr(Enumerate), MemoryBlock, vbSizeLongPtrEnumerate returns IEnumVARIANT. VBA stores the return value as an object pointer at VarPtr(Enumerate). Overwriting those bytes with the heap block address makes VBA believe that address is a valid COM object — which it is, because the vtable pointer sits at offset 0. This is why Enumerate carries '@Ignore NonReturningFunction; the return value is set by raw memory write, not by a Set Enumerate = ... assignment.
CopyMemory ByVal MemoryBlock, obj, LenB(obj) copies the TENUM struct to the heap as raw bytes — it copies the IEnum pointer without calling AddRef. When obj goes out of scope at function exit, VBA calls Release on obj.IEnum, which could destroy the iterable even though the heap block still holds a raw (untracked) copy of the pointer.
KeepAlive compensates:
Set KeepAlive(MemoryBlock) = obj.IEnum ' hold one tracked referenceA Static Collection inside the property holds the reference, keyed by the heap block address. When IUnknown_Release sees nRef = 0, it removes the entry and frees the block:
Set KeepAlive(VarPtr(obj)) = Nothing ' release — iterable may now be destroyed
CoTaskMemFree VarPtr(obj) ' free heap blockThe same pattern applies in IEnumVARIANT_Clone.
For i = obj.Current To obj.Last Step obj.Step
CopyVarByRef rgVar, obj.IEnum.Item(i), VarByRef.vt, VarByRef.ref
NumberFetched = NumberFetched + 1
If NumberFetched = celt Then Exit For
rgVar = rgVar + vbSizeVariant
Next
obj.Current = obj.Current + NumberFetched * obj.StepPer iteration: one early-bound .Item(i) call and one Variant copy to the destination address. rgVar is advanced by vbSizeVariant (16 bytes x86 / 24 bytes x64) for multi-element fetches; VBA's For Each always requests one item at a time (celt = 1), so the inner Exit For fires immediately. obj.Current is updated in one step after the loop.
Select Case True
Case celt = 0: IEnumVARIANT_Skip = S_OK
Case celt < 0: IEnumVARIANT_Skip = E_INVALIDARG
Case celt <= (obj.Step * (obj.Last - obj.Current) + 1)
obj.Current = obj.Current + celt * obj.Step
IEnumVARIANT_Skip = S_OK
Case Else
obj.Current = obj.Last + VBA.Sgn(obj.Step)
IEnumVARIANT_Skip = S_FALSE
End Selectobj.Step * (obj.Last - obj.Current) + 1 gives the remaining item count for both ascending (Step = 1) and descending (Step = -1) without a branch. obj.Current is only mutated after the bounds check. For overshoot, obj.Current = obj.Last + VBA.Sgn(obj.Step) places current one step past the end — the same post-exhaustion state that Next leaves.
' UDT assignment AddRefs IEnum — no Set needed
Dim Copy As TENUM: Copy = obj
Copy.nRef = 1Copy = obj copies all fields. VBA automatically calls AddRef on the embedded IEnum As IEnumerator during the UDT assignment, so KeepAlive has exactly the one tracked reference it needs for the clone. The clone starts at nRef = 1 regardless of the original's count, and captures the enumeration position at the moment of cloning.
Private Type CONSTRUCT
vt As Variant
ref As Variant
End Type
Private VarByRef As CONSTRUCTInitializeVarByRef sets vt to VT_INTEGER | VT_BYREF pointing at ref. CopyVarByRef writes a Variant to any address without an API call; CopyLngByRef uses the same mechanism (vbLong | VT_BYREF) for pceltFetched. Both helpers receive vt and ref as ByRef Variant parameters rather than accessing VarByRef directly, avoiding a global UDT indirection per call.
' Const IID_IUnknown As String = "{00000000-0000-0000-C000-000000000046}"
IsIID_IUnknown = (id.Data1 = &H0) And (id.Data2 = &H0) And ... And (id.Data4(7) = &H46)Direct integer comparison on the GUID UDT fields — no string parsing. The commented Const documents the expected GUID string without runtime cost.
#If Win64 Then
vbSizeLongPtr = 8
vbSizeVariant = 24
#Else
vbSizeLongPtr = 4
vbSizeVariant = 16
#End IfA Variant is 16 bytes on x86 and 24 bytes on x64. All CopyMemory sizes and pointer arithmetic use these constants — no hard-coded values appear in the code.
MIT © 2025 Vincent van Geerestein