VBA standard module for IEnumVARIANT interface implementation — no typelib required, dispatch binding via DispCallFunc.
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 IDispatch::Invoke called directly through DispCallFunc, so the iterable Class does not need to implement any interface.
For the early-binding variant (fastest, requires
IEnumeratorinterface) see vba-enumerator. For the late-binding variant (no interface required, usesCallByName) see vba-enumerator-late-binding.
For Eachwithout a typelib — pure VBA, no external dependencies- No interface required — items retrieved via
IDispatch::Invoke; the iterable Class exposes any indexed property or method - Nested loops — works correctly for nested
For Eachwith mixed objects and mixed enumerators - 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 - DISPID resolved once —
IDispatch::GetIDsOfNamescalled at construction time only; no name lookup in the hot path - x86 / x64 compatible via
LongPtrand#If Win64 - Pure VBA, zero dependencies, Rubberduck-friendly annotations
| File | Type | Description |
|---|---|---|
EnumerateDispatch.bas |
Module | Enumerate(iterable, callback, count [, base]) — the main entry point |
Each file has a corresponding _WithAttributes version (e.g. EnumerateDispatch_WithAttributes.bas) with Rubberduck annotations removed and VB attributes baked in. Import the _WithAttributes files if you are not using Rubberduck.
| Member | Description |
|---|---|
Enumerate(iterable, callback, count [, base]) |
Returns a synthetic IEnumVARIANT for the iterable object. callback is the name of the indexed property or method (e.g. "Item"). count is the number of items. base is the first index (default 1). Raises an error if iterable is Nothing or callback is not callable on iterable. |
1. Expose an indexed property in your Class:
Public Property Get Item(ByVal index As Long) As Variant
If VBA.IsObject(this.Items(index)) Then
Set Item = this.Items(index)
Else
Item = this.Items(index)
End If
End Property2. Add the Enumerate function:
'@Enumerator
Public Function Enumerate() As IEnumVARIANT
Set Enumerate = EnumerateDispatch.Enumerate(Me, "Item", this.Count)
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) |
27.90 |
Custom enumerator — For Each (API) |
26.66 |
VB Collection — For Each |
0.21 |
VB Array — For Each |
0.14 |
VB Array — For i |
0.08 |
The dominant cost is DispCallFunc dispatch per element — slower than the early-bound IEnumerator.Item(i) call in vba-enumerator (2.89 ms) and slightly slower than VBA.CallByName in vba-enumerator-late-binding (19.58 ms). The DISPID is resolved once at construction; the hot path pays only the DispCallFunc call overhead per element. The VarByRef and API variant copy paths are nearly equal for this variant because the pceltFetched write is skipped by For Each (pceltFetched = NULL).
| Directive | Default | Description |
|---|---|---|
#Const API |
False |
Write pceltFetched via Variant ByRef construct (fast) or CopyMemory API |
#Const API = True uses CopyMemory to write pceltFetched. #Const API = False uses the Variant ByRef construct. VBA's For Each always passes pceltFetched = NULL, so neither branch executes in normal use — the difference is negligible.
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 - Resolves the DISPID for the callback property once via
IDispatch::GetIDsOfNamesand stores it inTENUM.dispid - 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
caller As Object ' late-bound reference to the iterable object
dispid As Long ' DISPID resolved once at construction — no BSTR management
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
End TypepvTable is first because COM requires a pointer to the vtable at offset 0 of any COM object. caller As Object is a late-bound reference — no interface is required. dispid As Long holds the DISPID resolved at construction time; unlike the late-binding version there is no BSTR to allocate or free. There is no Step field — this module is ascending-only.
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. 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 caller pointer without calling AddRef. When obj goes out of scope at function exit, VBA decrements the reference count on obj.caller, which could destroy the iterable. KeepAlive compensates:
Set KeepAlive(MemoryBlock) = iterable ' hold one tracked referenceA Static Collection inside the property holds the reference, keyed by the heap block address. When IUnknown_Release sees nRef = 0:
Set KeepAlive(VarPtr(obj)) = Nothing ' release tracked reference
CoTaskMemFree VarPtr(obj) ' free heap blockThe same pattern applies in IEnumVARIANT_Clone.
hr = DispCallFunc(ObjPtr(obj), 5 * vbSizeLongPtr, CC_STDCALL, VT_I4, 5, vt(0), pv(0), dummy)IDispatch::GetIDsOfNames sits at vtable slot 5 (byte offset 5 * vbSizeLongPtr). The DISPID is written to a Static dispid As Long whose address is stored in pv(4) during first-call initialisation. A Static Init pattern (If Init = False Then) builds the argument arrays once; subsequent calls only update the two dynamic arguments (Names(0) and var(0) for the local iid). Resolve is called once per Enumerate invocation — never in the hot path.
hr = DispCallFunc(ObjPtr(obj), 6 * vbSizeLongPtr, CC_STDCALL, VT_I4, 8, vt(0), pv(0), dummy)IDispatch::Invoke sits at vtable slot 6 (byte offset 6 * vbSizeLongPtr). DispCallFunc marshals the eight Invoke arguments from the var/vt/pv Static arrays and writes the result VARIANT directly to rgVar — the destination address in IEnumVARIANT_Next. Per iteration the Adjust section updates four dynamic arguments (arg, var(0), var(1), var(5)) before the call; the Static arrays are not rebuilt.
Const slot As Long = 6
Const oVft As LongPtr = slot * vbSizeLongPtr ' 24 on x86 / 48 on x64DispCallFunc's oVft parameter is a byte offset from the start of the vtable. The IDispatch vtable layout (zero-based):
| Slot | Method |
|---|---|
| 0 | QueryInterface |
| 1 | AddRef |
| 2 | Release |
| 3 | GetTypeInfoCount |
| 4 | GetTypeInfo |
| 5 | GetIDsOfNames |
| 6 | Invoke |
Named Const values rather than inline arithmetic make the intent clear and keep the code correct on both platforms without a conditional.
For i = obj.Current To obj.Last
If Invoke(obj.caller, obj.dispid, i, rgVar) < 0 Then
IEnumVARIANT_Next = E_FAIL
Exit Function
End If
NumberFetched = NumberFetched + 1
If NumberFetched = celt Then Exit For
rgVar = rgVar + vbSizeVariant
Next
obj.Current = obj.Current + NumberFetchedPer iteration: one Invoke call (one DispCallFunc internally) that writes the result directly to rgVar. 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.Last - obj.Current + 1
obj.Current = obj.Current + celt
IEnumVARIANT_Skip = S_OK
Case Else
obj.Current = obj.Last + 1
IEnumVARIANT_Skip = S_FALSE
End Selectobj.Current is only mutated after the bounds check — state is never corrupted on an invalid or overshooting call. celt = 0 is an explicit fast-path. On overshoot, Current is placed one step past Last, matching the post-exhaustion state that Next leaves.
' UDT assignment AddRefs caller — dispid is Long, no extra management needed.
Dim Copy As TENUM: Copy = obj
Copy.nRef = 1Copy = obj copies all fields. VBA automatically calls AddRef on the embedded caller As Object during the UDT assignment. dispid is a plain Long — no BSTR allocation or independent copy is needed (contrast with the late-binding version which requires SysAllocString for the clone's callback string). The clone starts at nRef = 1 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. CopyLngByRef uses this mechanism (vbLong | VT_BYREF) to write pceltFetched without a CopyMemory API call. Unlike the late-binding version, CopyVarByRef is not needed — item retrieval goes through Invoke which writes the result VARIANT directly to rgVar via DispCallFunc.
' 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, pointer arithmetic, and oVft constants use these values — no hard-coded numbers appear in the code.
MIT © 2025 Vincent van Geerestein