Skip to main content
Code Review

Return to Revisions

3 of 7
Added reaction to feedback.
Mark.R
  • 231
  • 2
  • 7

Immutable Object class in VBA – Creatable only through constructor and not via "New" keyword

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 using ClassName.Make, or on another instance of the class using ObjectName.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.


###Edit

Thanks for all the feedback

###Indentation

I’m obviously not getting any converts here :). When code blocks start to add up in a module I find this style much easier to skim through for the procedure I’m after – any line with the code in the first four columns has a name. The procedure separator helps in the same way, but I’ve grown to find this indentation and turning the procedure separators off makes the screen feel less cluttered. I’ll take the consensus advice and revert back to "standard" for a bit – see how I get on.

###Instance Variables I took out my usual use of a UDT to store private variables thinking it was non-standard and would cause a distraction. Similarly, I normally use an enum for error numbers, but didn’t want to clutter up the code and cause a distraction for reviewers. Ironically it seems I’ve actually created the distraction I was hoping to avoid on both counts. Lesson learnt.

###Explaining my design decisions

Not using a separate project

Putting classes in a separate project is simple way to ensure they’re not creatable. It has the benefit of throwing a compiler error when you try to New a class. My annoyance was that this was necessary – I shouldn’t have to push a class out into another project to ensure it’s not creatable. The behaviour of a class should be encapsulated within the class, not reliant upon creating a dependency between project files. And links between files are liable to break if a colleague starts to change the names of folders or similar.

If it’s important that a class is immutable then it’s just as important, if not more so, that it’s immutable when writing or extending the project in which that class lives. While I’m initially developing a Framework/Add-in that exposes this class it’s still important that I can’t mutate or New these objects, and it’s particularly important when I revisit the Framework to update or extend it in the future and I may have forgotten which classes are supposed to be immutable.

Not using Friend

Unless you’re willing to encapsulate each immutable class in its own project, then the class will end up included in a project with other code that can consume it, and I wanted these classes to be immutable wherever they were put. It’s for this reason that I didn’t use Friend methods. Friend methods are good when you are happy for them to be called inside the project. Here I wanted to stop a call from within the same project too. What I really wanted was a modifier where only an object of the same or derived classes can access that method – I understand some languages provide a Protected modifier for this purpose.

Not using a separate interface which is immutable

I’ve been using something similar to Mat’s IPoint interface up until now, but I was becoming uncomfortable with it for a couple of reasons, ultimately leading me to explore the solution above.

  1. It essentially amounts to have having a factory class for each class - I’ve suddenly doubled my number of class modules. The property explorer is telescoping in length, most of my interfaces only have one implementation and half my class modules exist to do only one thing. It all just felt bloated. Admittedly, this is somewhat superficial and perhaps if the project explorer enabled modules to be arranged into folders I wouldn’t care so much. Still, I was hoping to find a way to collapse these two class modules into one if possible.
  2. If I can cast an object to another type and mutate it then ultimately, it’s not an immutable object - it’s a mutable object cast to an immutable type. Any consumer of IPoint within the same project can cast it to Point and mutate it. I wanted to prevent myself, or any other future developer working on the project, from having that option.

Obviously, the type of solution Mat suggests is easier to follow, and arguably that’s the most important thing. Maybe I’m worrying too much about trying to control things that don’t need to be controlled.

Mark.R
  • 231
  • 2
  • 7
lang-vb

AltStyle によって変換されたページ (->オリジナル) /