Lazy Object / Weak Reference

Sometimes a class needs to hold a reference to the object that “owns” it – i.e. the object that created it. When this happens, the owner object often needs to hold a reference to all the “child” objects it creates. If we say Class1 is the “parent” and Class2 is the “child”, we get something like this:

'Class1
Option Explicit
Private children As VBA.Collection

Public Sub Add(ByVal child As Class2)
    Set child.Owner = Me
    children.Add child
End Sub

Private Sub Class_Initialize()
    Set children = New VBA.Collection
End Sub

Private Sub Class_Terminate()
    Debug.Print TypeName(Me) & " is terminating"
End Sub

And Class2 might look like this:

'Class2
Option Explicit
Private parent As Class1

Public Property Get Owner() As Class1
    Set Owner = parent
End Property

Public Property Set Owner(ByVal value As Class1)
    Set parent = value
End Property

Private Sub Class_Terminate()
    Debug.Print TypeName(Me) & " is terminating"
End Sub

The problem might not be immediately apparent to untrained eyes, but this is a memory leak bug – this code produces no debug output, despite the Class_Terminate handlers:

'Module1
Option Explicit

Public Sub Test()
    Dim foo As Class1
    Set foo = New Class1
    foo.Add New Class2
    Set foo = Nothing
End Sub

Both objects remain in memory and outlive the Test procedure scope! Depending on what the code does, this could easily go from “accidental sloppy object management” to a serious bug leaving a ghost process running, with Task Manager being the only way to kill it! How do we fix this?

Not keeping a reference to Class1 in Class2 would fix it, but then Class2 might not be working properly. Surely there’s another way.

Suppose we abstract away the very notion of holding a reference to an object. Suppose we don’t hold an object reference anymore, instead we hold a Long integer that represents the address at which we’ll find the object pointer we’re referencing. To put it in simpler words, instead of holding the object itself, we hold a ticket that tells us where to go find it when we need to use it. We can do this in VBA.

First we define an interface that encapsulates the idea of an object reference – IWeakReference, that simply exposes an Object get-only property:

'@Description("Describes an object that holds the address of a pointer to another object.")
'@Interface
Option Explicit

'@Description("Gets the object at the held pointer address.")
Public Property Get Object() As Object
End Property

Then we implement it with a WeakReference class. The trick is to use CopyMemory from the Win32 API to take the bytes at a given address and copy them into an object reference we can use and return.

For an easy-to-use API, we give the class a default instance by toggling the VB_PredeclaredId attribute, and use a factory method to create and return an IWeakReference given any object reference: we take the object’s object pointer using the ObjPtr function, store/encapsulate that pointer address into a private instance field, and implement the IWeakReference.Object getter such that if anything goes wrong, we return Nothing instead of bubbling a run-time error.

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "WeakReference"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Implements IWeakReference

#If Win64 Then
Private Declare PtrSafe Sub CopyMemory Lib "kernel32.dll" Alias "RtlMoveMemory" (hpvDest As Any, hpvSource As Any, ByVal cbCopy As LongPtr)
#Else
Private Declare Sub CopyMemory Lib "kernel32.dll" Alias "RtlMoveMemory" (hpvDest As Any, hpvSource As Any, ByVal cbCopy As Long)
#End If

Private Type TReference
#If VBA7 Then
    Address As LongPtr
#Else
    Address As Long
#End If
End Type

Private this As TReference

'@Description("Default instance factory method.")
Public Function Create(ByVal instance As Object) As IWeakReference
    With New WeakReference
        .Address = ObjPtr(instance)
        Set Create = .Self
    End With
End Function

Public Property Get Self() As IWeakReference
    Set Self = Me
End Property

#If VBA7 Then
Public Property Get Address() As LongPtr
#Else
Public Property Get Address() As Long
#End If
    Address = this.Address
End Property

#If VBA7 Then
Public Property Let Address(ByVal Value As LongPtr)
#Else
Public Property Let Address(ByVal Value As Long)
#End If
    this.Address = Value
End Property

Private Property Get IWeakReference_Object() As Object
' Based on Bruce McKinney's code for getting an Object from the object pointer:

#If VBA7 Then
    Dim pointerSize As LongPtr
#Else
    Dim pointerSize As Long
#End If

    On Error GoTo CleanFail
    pointerSize = LenB(this.Address)

    Dim obj As Object
    CopyMemory obj, this.Address, pointerSize

    Set IWeakReference_Object = obj
    CopyMemory obj, 0&, pointerSize

CleanExit:
    Exit Property

CleanFail:
    Set IWeakReference_Object = Nothing
    Resume CleanExit
End Property

Now Class2 can hold an indirect reference to Class1, like this:

'Class2
Option Explicit
Private parent As IWeakReference

Public Property Get Owner() As Class1
    Set Owner = parent.Object
End Property

Public Property Set Owner(ByVal Value As Class1)
    Set parent = WeakReference.Create(Value)
End Property

Private Sub Class_Terminate()
    Debug.Print TypeName(Me) & " is terminating"
End Sub

Now Module1.Test produces the expected output, and the memory leak is fixed:

Class1 is terminating
Class2 is terminating

Advertisements

14 thoughts on “Lazy Object / Weak Reference”

  1. Good article!
    But please tell me, why do you on the one hand use “#If Win64” and on the other “#If VBA7” to distinguish between “Long” and “LongPtr”?

    Like

    1. TBH the conditional compilation isn’t bullet-proof here; a 64-bit Windows running VBA6 probably wouldn’t be able to compile it, precisely because of the “#If Win64” check you’re highlighting (PtrSafe only exists in VBA7). Anyway the idea is to make sure we’re using the correct size for the object pointer; hard-coding a Long wouldn’t work with all possible configurations.

      Like

  2. Just a thought; because your collection (children) has a link/reference to Class2, the terminate events won’t be fired. They will be fired when you first set your collection to Nothing before setting foo to Nothing

    Like

    1. In this example, yes. I’ll triple-check again, but the reason I wrote this (perhaps easily abused) class was precisely because of a scenario where nulling the parent reference in the child instance, did not untie the knot; that case was an event provider custom class wrapping a dynamic MSForm control, with the form having a private WithEvents reference to the child class. I took it that explicitly nulling references wasn’t consistently reliable and proceeded to make the object references indirect like this, and the problem was resolved – I’ll probably make a follow-up post, or edit this one later, when I have a more “real-world” piece of example code for it… on the other hand, making the reference explicitly “weak” removes the need to explicitly set it to Nothing, so.. I’m not sure whether to consider it abuse or over-complexifying the situation, vs. making such intertwined references safe to use without needing to think of nulling them.

      Like

  3. FYI, the only Win32 API functions (of all the ones that I use) that require “#If Win64” are “GetWindowLong” & “SetWindowLong” (within the “#If VBA7” condition). Otherwise, “#If VBA7” is the main difference because it introduced “LongPtr”.

    Like

  4. Q. What is the difference between using ‘CopyMemory obj, 0&, pointerSize” or replacing it with with “Set obj = nothing”?…and why does it crash Excel (after class1 terminates)?

    Like

    1. Interesting – I haven’t experienced any crashes with the code exactly as it is, on both 32 and 64 bit hosts, although I did toy a bit with that code: omitting the last CopyMemory call immediately crashes everything. I figured there was a reason Bruce McKinney did it that way, so I left it alone – wouldn’t “Set obj = Nothing” confuse the reference-counting though? The object wasn’t created by normal means, it shouldn’t be destroyed by normal means either. Are you saying *this code* crashes? What version+bitness of Excel are you using, in what OS+bitness? Or is it “Set obj = Nothing” that’s crashing?

      Like

      1. Using “Set obj = Nothing” crashed Excel every time, but Bruce’s method was fine. I’m using Win 7(64bit)/Excel 2013(32-bit).

        I had to add a few lines of code, so that I could access the “Owner” property of the child, to actually trigger “IWeakReference_Object” to run. This included a “Property Get Item(ByVal Index As Long) As Object” in Class1.

        Like

  5. Tried using this with a reference to a Workbook in a class that is cached, in order to avoid a phantom VBE reference hanging around when the Wb is closed. It works great for that purpose, but a subsequent test of the reference leads to a crash instead of a clean failure. Any suggestions?
    Am using 64-bit Office 365.

    Like

    1. Hmm, I intended this to use with custom VBA classes, which don’t involve COM objects that are owned by the host application… there is likely something else going on here. I’d be curious to see the original code with the ghost instance.. are you working in Excel or creating an Excel.Application object? Are you accessing VBE objects? Is the VBE Extensibility library referenced? It’s very easy to make “ghost” objects with chained member calls under these circumstances. With a good MCVE, that would make a great question on Stack Overflow!

      Like

  6. I made some dummy classes and test cases that illustrate the problem. Will be a detailed SO question, but I agree an interesting one, so will post one at some point later. What does MCVE stand for though??

    Like

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google+ photo

You are commenting using your Google+ account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s