Goals for the class
- Create Immutable objects – i.e. only Getters – no Setters
- Object creation only possible through a constructor, not via
New
keyword, to ensure that no objects are created without a valid state. - Keep the constructor method in the same code module as the class itself.
Other common solutions for VBA constructors
Creating a global Factory class/module that provides constructors for all creatable objects, as suggested in this post. This can be arduous to maintain, creates a dependency between the modules and arguably violates encapsulation/single responsibility principal. Does not prevent use of
New
keyword to create objects.Creating a Factory class for each class Provides more encapsulation, less dependancy and narrower responsibility, but again does not prevent use of
New
keyword and soon results in a proliferation of types.Providing a constructor in the class itself and make it available through a predeclared instance using the VB_PredeclaredId attribute, as discussed in this post. Better, but still allows use of
New
keyword, and does not prevent access to the (potentially invalid) state of the predeclared instance.
Proposed Solution
- Use the predeclared instance of a class as the “Factory Instance". Only this predeclared instance may create other instances of the class.
- Provide a constructor method (I use the name
Make
) in the class module itself. It can be called on the Factory Instance usingClassName.Make
, or on another instance of the class usingObjectName.Make
. Other instances delegate creation to the Factory Instance and return a new object – it doesn’t alter their own state. - Each time a new instance is initialised it checks if it is being made by the Factory Instance, otherwise throws a runtime error – i.e. use of
New
keyword is not allowed. - An attempt to access the state of the Factory Instance returns a runtime error.
Implementation
IMaker.cls - Interface
Option Explicit
Public Property Get IsMaking() As Boolean
End Property
Provides an interface for objects to query the Factory Instance to check if their creation was requested. Put in a separate interface so that it does not show up in the public members of the class. Same interface can be used by all classes that use this pattern.
Point.cls - Edited in Notepad so that
Attribute VB_PredeclaredId = True
Example class with two properties: X and Y. Verbose comments added for illustrative purposes
Option Explicit
Private Const CLASS_NAME As String = "Point"
Private Maker As IMaker
Private IsMaking As Boolean
Private X_ As Double
Private Y_ As Double
Implements IMaker
Private Sub Class_Initialize()
' Check Instance isn't the Factory Instance (which can be created without checks)
If Not Me Is Point Then
' Create a reference to the Factory Instance, cast as type IMaker
Set Maker = Point
' Throw Runtime error if creation wasn't requested by Factory Instance
If Not Maker.IsMaking Then ThrowError_AttemptToCreateInstanceOutsideOfConstructor
End If
End Sub
Public Function Make(ByVal X As Double, ByVal Y As Double) As Point
If Me Is Point Then ' Object is Factory Instance
' Allow new instances to be created
IsMaking = True
' Create a new instance
With New Point
' Pass parameters to its constructor and return new object
Set Make = .Make(X, Y)
End With
' Disallow new instances to be created
IsMaking = False
ElseIf Maker.IsMaking Then ' Object is new instance being created by the Factory Instance
' Set state and return self
X_ = X
Y_ = Y
Set Make = Me
Else
' Delegate creation of new object to Factory Instance and return new object
Set Make = Point.Make(X, Y)
End If
End Function
Public Property Get X() As Double
' Disallow access to state of Factory Instance
If Me Is Point Then ThrowError_AttemptToAccessPredeclaredInstance
' Return state
X = X_
End Property
Public Property Get Y() As Double
' Disallow access to state of Factory Instance
If Me Is Point Then ThrowError_AttemptToAccessPredeclaredInstance
' Return state
Y = Y_
End Property
Private Property Get IMaker_IsMaking() As Boolean
' Indicate whether new instances of class can be created or not - only ever set to True by Factory Instance
IMaker_IsMaking = IsMaking
End Property
Private Sub ThrowError_AttemptToCreateInstanceOutsideOfConstructor()
Err.Raise VBA.vbObjectError + 513, CLASS_NAME, "Cannot create instance of " & CLASS_NAME & " via New"
End Sub
Private Sub ThrowError_AttemptToAccessPredeclaredInstance()
Err.Raise VBA.vbObjectError + 514, CLASS_NAME, "Cannot access state of predeclared instance of " & CLASS_NAME
End Sub
Example Usage
Sub ExampleUsage()
Dim Point1 As Point, Point2 As Point
Set Point1 = Point.Make(1, 2) ' Creates new object via Factory Instance
Set Point2 = Point1.Make(3, 4) ' Creates new object via existing object
Set Point1 = Point1.Make(5, 6) ' Creates new object and assigns to Point1 - does not change state of previous object
Set Point1 = New Point ' Throws Runtime Error
Dim Point3 As New Point ' Does not throw Error - Instantiation only occurs on first use
Debug.Print ObjPtr(Point3) ' Throws Runtime Error
Debug.Print Point.X ' Throws Runtime Error - cannot access state of Predeclared instance
Point1.X = 5 ' Throws Compiler Error - only GET methods provided
End Sub
Drawbacks
This is obviously quite a bit of “boilerplate” for a simple object, but VBA’s lack of features seems to require it. I would welcome suggestions to streamline it. Putting a check in each method that the object isn’t the Factory Instance obviously adds overhead and verbosity – can be omitted if you don’t care too much about code accessing the state of the predeclared instance.