TransWikia.com

Private VBA Class Initializer called from Factory #2

Code Review Asked by Cristian Buse on February 8, 2021

About

This is a follow-up of a previous question Private VBA Class Initializer called from Factory. I’ve decided to create a new question instead of answering my old question because I would like the community to review and possibly suggest improvements on the new improved code.

VBA Class methods are always called with an instance pointer but the implementation is hidden and managed by VBA. If we can manipulate that specific instance pointer then we can access methods from instance B of a class directly from instance A (of the same class type) even if those methods are declared as Private. The previous question does exactly that but in a crude, slow and unsafe way. This question will cover a better approach (in my humble view).

For example, a method of a class might look like this in VBA:

Public Sub Test(ByVal arg1)

but the method is actually implemented more like this:

Public Sub Test(this As LongPtr, ByVal arg1)

The code presented in this question replaces the this pointer with another instance pointer (directly in memory) and thus allows inter-instance calls.

The Me keyword

Before we continue, let’s clarify what the Me keyword is. Me behaves as a locally-scoped variable to any class method but in reality it is implemented as a hidden Property Get that is reading the above mentioned this pointer (which is locally scoped because it was passed as an argument to the class method). This is easy to check:

Code inside a SomeClass class:

Option Explicit

Public Sub Test()
    Dim ptr1 As LongPtr
    Dim ptr2 As LongPtr
    Dim ptr3 As LongPtr
    Dim tempPtr As LongPtr

    ptr1 = LibMemory.MemLongPtr(VarPtr(Me)) 'Me is still within scope while memory is read
    ptr2 = LibMemory.MemLongPtr(VarPtr(Me)) 'Me is still within scope while memory is read
    Debug.Assert ptr1 = ptr2 'Same address so code does not stop
    
    tempPtr = VarPtr(Me) 'Me gets out of scope after assignment
    ptr3 = LibMemory.MemLongPtr(tempPtr) 'Me is not is scope anymore
    Debug.Assert ptr1 = ptr3 'ptr3 is 0
End Sub

Code in a standard module:

Sub TestME()
    Dim c As New SomeClass
    c.Test
End Sub

By running the TestME method, the second Assert within the class instance will fail because Me got out of scope. This suggests that Me is actually a Property Get or a Function rather than a locally-scoped variable. Hence, we cannot use Me to get to this.

Notice that I am not using the well-known CopyMemory API. Instead I am using a LibMemory library that I have created specifically for this question. LibMemory can be found at CodeReview and GitHub. The CR question explains the reasoning behind the library.

The Code

First of all, we need the above-mentioned LibMemory

We also need a class that encapsulates all the logic for redirecting class instances.
Code inside InstanceRedirector class:

Option Explicit

Private Type INSTANCE_REDIRECT
    #If VBA7 Then
        swapAddress As LongPtr
        originalPtr As LongPtr
    #Else
        swapAddress As Long
        originalPtr As Long
    #End If
    targetInstance As Object 'Keep a reference until restore is called
End Type

Private this As INSTANCE_REDIRECT

'*******************************************************************************
'Redirects the instance of a class to another instance of the same class
'This method must be called from a class instance's Function (not Sub)
'
'Warning! RestoreInstance must be called before the calling function goes out
'   of scope OR this instance must be terminated so that RestoreInstance is
'   called at Class_Terminate
'
'Warning! vbArray + vbString Function return type is not supported. It would be
'   possible to find the correct address by reading memory in a loop but there
'   would be no checking available
'*******************************************************************************
#If VBA7 Then
Public Sub Redirect(ByVal funcReturnPtr As LongPtr, ByVal currentInstance As Object, ByVal targetInstance As Object)
#Else
Public Sub Redirect(ByVal funcReturnPtr As Long, ByVal currentInstance As Object, ByVal targetInstance As Object)
#End If
    Const methodName As String = "Redirect"
    '
    'Validate Input
    If currentInstance Is Nothing Or targetInstance Is Nothing Then
        Err.Raise 91, TypeName(Me) & "." & methodName, "Object not set"
    ElseIf TypeName(currentInstance) <> TypeName(targetInstance) Then
        Err.Raise 5, TypeName(Me) & "." & methodName, "Expected same interface"
    ElseIf funcReturnPtr = 0 Then
        Err.Raise 5, TypeName(Me) & "." & methodName, "Missing Func Return Ptr"
    End If
    '
    'Store original pointer
    this.originalPtr = ObjPtr(GetDefaultInterface(currentInstance))
    '
    'On x64 the shadow stack space is allocated next to the Function Return
    'On x32 the stack space has a fixed offset (found through testing)
    #If Win64 Then
        Const memOffsetNonVariant As LongLong = LibMemory.PTR_SIZE
        Const memOffsetVariant As LongLong = LibMemory.PTR_SIZE * 3
    #Else
        Const memOffsetNonVariant As Long = LibMemory.PTR_SIZE * 28
        Const memOffsetVariant As Long = LibMemory.PTR_SIZE * 31
    #End If
    '
    'Try Non-Variant func return first and then Variant if the former fails
    If Not SetSwapAddress(funcReturnPtr, memOffsetNonVariant) Then
        SetSwapAddress funcReturnPtr, memOffsetVariant
    End If
    '
    If this.swapAddress = 0 Then
        Err.Raise 5, TypeName(Me) & "." & methodName, "Invalid input or " _
        & "not called from class function or vbArray + vbString func return type"
    End If
    '
    'Keep a reference until restore is called, for extra safety
    Set this.targetInstance = GetDefaultInterface(targetInstance)
    '
    'Redirect Instance
    LibMemory.MemLongPtr(this.swapAddress) = ObjPtr(this.targetInstance)
End Sub

'*******************************************************************************
'Finds and sets the swap address (address of the instance pointer on the stack)
'*******************************************************************************
#If VBA7 Then
Private Function SetSwapAddress(ByRef funcReturnPtr As LongPtr, ByRef memOffset As LongPtr) As Boolean
#Else
Private Function SetSwapAddress(ByRef funcReturnPtr As Long, ByRef memOffset As Long) As Boolean
#End If
    #If VBA7 Then
        Dim tempPtr As LongPtr
    #Else
        Dim tempPtr As Long
    #End If
    '
    tempPtr = LibMemory.UnsignedAddition(funcReturnPtr, memOffset)
    #If Win64 Then
    #Else
        tempPtr = UnsignedAddition(MemLongPtr(tempPtr), PTR_SIZE * 2)
    #End If
    If LibMemory.MemLongPtr(tempPtr) = this.originalPtr Then
        this.swapAddress = tempPtr
        SetSwapAddress = True
    End If
End Function

'*******************************************************************************
'Returns the default interface for an object
'Casting from IUnknown to IDispatch (Object) forces a call to QueryInterface for
'   the IDispatch interface (which knows about the default interface)
'*******************************************************************************
Private Function GetDefaultInterface(obj As IUnknown) As Object
    Set GetDefaultInterface = obj
End Function

'*******************************************************************************
'Restores the original instance pointer at the previously used swap address
'*******************************************************************************
Public Sub Restore()
    If this.swapAddress = 0 Or this.originalPtr = 0 Then Exit Sub
    LibMemory.MemLongPtr(this.swapAddress) = this.originalPtr
    this.swapAddress = 0
    this.originalPtr = 0
    Set this.targetInstance = Nothing
End Sub

'*******************************************************************************
'Extra safety in case .Restore is not called
'*******************************************************************************
Private Sub Class_Terminate()
    Restore
End Sub

Notice that this code has a lot of checks in place compared to the previous CR question which was only reading memory in a loop until a specific value was found. Moreover, the exact location is found using predefined memory offsets.

Demo

Consider a Class1 which has VB_PredeclaredId set to True:

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "Class1"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
'@PredeclaredId
Option Explicit

Private m_id As Long

Public Function Factory(ByVal newID As Long) As Class1
    Dim c As New Class1
    '
    With New InstanceRedirector
        .Redirect VarPtr(Factory), Me, c
        Init newID
        .Restore 'This can be ommited within a With New block (Class_Terminate calls it anyway)
    End With
    Set Factory = c
End Function
Private Sub Init(newID As Long)
    m_id = newID
End Sub

Public Function Factory2(ByVal newID As Long) As Class1
    Dim c As New Class1
    '
    c.Init2 newID
    Set Factory2 = c
End Function
Public Sub Init2(newID As Long)
    m_id = newID
End Sub

Public Property Get ID() As Long
    ID = m_id
End Property

The Factory method uses a private Init method while the Factory2 uses a public Init2 method.

Quick speed test in a standard module:

Option Explicit

Sub TestFactorySpeeds()
    Const loopsCount As Long = 100000
    Dim i As Long
    Dim t As Double
    '
    t = Timer
    For i = 1 To loopsCount
        Debug.Assert Class1.Factory2(i).ID = i
    Next i
    Debug.Print "Public  Init (seconds): " & VBA.Round(Timer - t, 3)
    '
    t = Timer
    For i = 1 To loopsCount
        Debug.Assert Class1.Factory(i).ID = i
    Next i
    Debug.Print "Private  Init (seconds): " & VBA.Round(Timer - t, 3)
End Sub

In general the Private method seems to be only 3x slower but the benefit of accessing private methods seems to be worth the speed loss.

Notes

Obviously, the demo above shows how to create a Factory that is using a Private Init but the approach presented here allows any class instance (Predeclared or not) to access private methods within other instances of the same class type. A good idea provided by @TinMan in the previous question is to create a class Clone method.

The return value of the class function where the redirection is used does not necessarily need to be an Object. It can be any data type (except an Array of String i.e. vbArray + vbString VarType). For return types of Array type, the VarPtrArray function from the LibMemory library can be used for the first argument when calling InstanceRedirector.Redirect.

The class method where the redirection is used/called needs to be a Function and not a Sub because the function return will be allocated on the call stack and we can use it’s address to find the actual memory location of the class instance pointer.

On x64 the instance pointer is allocated on the call stack immediately after the function return while on x32 there is a fixed offset and a redirection. If the function return type is of type Variant then the offsets are increased by two ptr positions on x64 (i.e. + 16 bytes) and three ptr positions on x32 (i.e. + 12 bytes).

Question(s)

I would be very grateful for suggestions that could improve the code in any way (speed, readability, structure, naming etc.).

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP