12
\$\begingroup\$

Finally, Parameterised Constructors are possible in VBA.

Background:

One rainy day, I came up with an idea: take a static class and expose one property as default (like it was done in the Reverse for each loop for the .Item() property. This time however, I thought I'll make a PieceOfMe() Get Property the default which is capable of

Pros

  • simple to implement and use

  • Parameterised Constructors ;)

Cons - as far as I am concern at this point...

  • Default member as well as PieceOfMe() doesn't support a full intelli-sense. It shows the expected parameters count but it doesn't tell what type etc. I think that's fixable though haven't had time yet to play with it.

  • No way to hide PieceOfMe() from intelli-sense and Object Browser

  • Always have to export your class (or write it in) to a text editor and add attributes etc.


Let's go!

Constructor.cls

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

Private myName as String

Private Sub Class_Initialize()
    myName = Cstr(Int((100) * rnd + 1))
End Sub

Public Property Get Name() as String
    Name = myName
End Property

Public Property Get PieceOfMe(Optional value as String) As Constructor
    Attribute Item.VB_UserMemId = 0
    myName = value
    Set PieceOfMe = Me
End Property

This class basically is the best to start off with:

  • it's made static by VB_PredeclaredId = True

  • Private myName as String is private because I wanted my encapsulation to only allow the default member -> PieceOfMe() to assign a value via the parameter passed.

  • Optional value as String that's now the way to assign the myName variable

  • Attribute Item.VB_UserMemId = 0 makes it the default member

*By combining two concepts - making the class static and exposing a Get Property as default I am allowed now to use syntax like: ...Constructor("strParam")...

  • ...Get PieceOfMe(...) as Constructor - the as Constructor to have the property return an instance of the Constructor - in other words: to return itself which is Set PieceOfMe = Me

Testing!

Copy-paste the above into a text editor and save it as Constructor.cls. Start Excel, then VBE and import file -> Constructor.cls. Also add a Module1

Module1

Option Explicit

' vba4all.com
Public Enum TestType
    eStatic
    eInstance
    [_Min] = eStatic
    [_Max] = eInstance
End Enum

Sub Main()

    ' Debug.Print Constructor
    ' *NOTE  : fails because Constructor's default GET returns an instance of Constructor
    ' Constructor.Name = "newName"
    ' *NOTE2  : Explicit assignment of the .Name property not possible (removed to improve the class encapsulation)
    '         : To assign a new name you have to use the default property i.e Set T = T("New Name")

    Dim testOption As TestType
    Dim i As Long

    ' * I am only presenting 2 tests so the Enum thingy is a bit of an overkill
    For i = TestType.[_Min] To TestType.[_Max]
        testOption = i

        ' reset static defualt
        Set Constructor = New Constructor

        ' Singleton pattern - as a VBA static class
        If testOption = eStatic Then
            Debug.Print "***STATIC"

            ' whenever calling this it will always return the defualt (random number)
            Debug.Print Constructor.Name, "Default call to Constructor.Name"
            ' unless it has been changed by one of the following calls throughout the life of the Excel application
            '   *this call pretty much means that if you ever change the default this call will always reflect the last change

            '  ****
            '   *** SETTING THE DEFAULT OR AN INSTANCE BY CALLING THE CLASS NAME AND PASSING PARAMETERS
            Set Constructor = Constructor("foo")
            Debug.Print Constructor.Name, "Set Constructor = Constructor(""foo"") and debug call Constructor.Name"

            '   ** however, you can reset the Constructor.Name (ask for a new, default by calling
            Set Constructor = New Constructor
            Debug.Print Constructor.Name, "Set Constructor = new Constructor -> [reset] assigned a new random"

        ' Working with INSTANCES
        Else
            Debug.Print "***INSTANCES"

            Debug.Print Constructor.Name, "Constructor.Name -> Current static default [reseted]"

            ' create an new variable of Constructor type
            Dim T As Constructor

            ' Assing to the currently stored value
            Set T = Constructor
            Debug.Print T.Name, "Set T = Constructor -> Should equal the current default"

            ' Create a new Constructor instance ( grabs the new random number )
            Set T = New Constructor
            ' or
            'Dim T1 As New Constructor  - same behaviour (grabs new random) but one line declaration
            Debug.Print T.Name, "Set T = new Constructor - > new random for this instance"
            ' *NOTE - it's possible that this number is the same as previous because the Random Range is 100

            ' *** PARAMETARISED CONSTRUTOR
            ' new instance calling the static name and passing a parameter
            ' that changes the default as well as the instance
            Dim T1 As Constructor
            Set T1 = Constructor("fooInst")

            Debug.Print T1.Name, "T1 = Constructor(""fooInst"") -> assigned through the static class"
            Debug.Print Constructor.Name, "Constructor.Name -> static is now equal to what T1 is"

            ' another separate instance using NEW Constructor
            Dim T2 As New Constructor
            Debug.Print T2.Name, "Dim T2 as New Test = just to verify, a new instance via a NEW Constructor"
            Debug.Print Constructor.Name, "Constructor.Name -> did NOT change, still equal to T1 due to the last assignment"

            ' changing instance via another instance
            Set T1 = T2
            Debug.Print T1.Name, "T1 result of: Set T1 = T2"
            Debug.Print T2.Name, "T2 result of: Set T1 = T2 -> T1 and T2 are equal"

            ' changing instance via another instance + PARAMETER
            Set T1 = T2("both")
            Debug.Print T1.Name, "T1.Name -> due to: Set T1 = T2(""both"")"
            Debug.Print T2.Name, "T2.Name -> due to: Set T1 = T2(""both"") - > T1 and T2 are now equal as T1 points to the T2"
            Debug.Print Constructor.Name, "Constructor.Name -> did NOT change, still equal to itself because it hasn't been used by other instances"

        End If
    Next

End Sub

Immediate Window output in my case:

enter image description here

This could have been a lot shorter I know.... I wanted it to be longer because I have added a lot of comments to help you understand different scenarios. Feel free to set breakpoints and step-through if the output in the Immediate Window is not enough.


More (Optional) Parameters

That's quite easy just create a new file, name it MultipleParams.cls and copy-paste the below example

MultipleParams.cls

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

Private myName as String
Private myNumber as Long

Private Sub Class_Initialize()
    myName = Cstr(Int((100) * rnd + 1))
End Sub

Public Property Get Name() as String
    Name = myName
End Property

Public Property Get PieceOfMe(Optional newName as String, Optional theNumber as Long) As MultipleParams
    Attribute PieceOfMe.VB_UserMemId = 0
    myName = newName
    myNumber = theNumber
    Set PieceOfMe = Me
End Property

Test it

Sub Main()

    Dim multiParams As MultipleParams
    Set multiParams = MultipleParams("the Name", 1)

End Sub

ParamArray

ParramArrayConstructor.cls

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

Private Sub Class_Initialize()
    myName = Cstr(Int((100) * rnd + 1))
End Sub

Public Property Get Name() as String
    Name = myName
End Property

Public Property Get PieceOfMe(paramArray arr() as Variant) As ParamArrayConstructor
    Attribute PieceOfMe.VB_UserMemId = 0
    ' handle the paramArray
    Dim v as Variant
    For each v in arr
        Debug.? v
    Next
    Set PieceOfMe = Me
End Property

Test it:

Sub Main()

    Dim param As ParamArrayConstructor
    Set param = ParamArrayConstructor("oh", "my", "lord", "paramarray", "works too!")

End Sub
\$\endgroup\$
6
\$\begingroup\$

My assumption is that your goal is to mimick constructors in a language which lacks them

If I understand correctly, this is a freaking sweet idea! If the class need the default member for anything else, why WOULDN'T you want a constructor?

I have to agree with @MatsMug that the examples you've got are somewhat confusing - the class is named Constructor and the constructor is named PieceOfMe? I'd suggest a more concrete example, like the CircleShape class below.

I'd advise the removal of the randomized parameter and the use of the static instance for anything but constructing new instances. They seem unrelated to the underlying concept, which is mimicking constructors in a language that lacks them. In fact, it seems more like writing Set myCircle = CircleShape (assigns the static, named instance of CircleShape to myCircle) when you intended to call the empty constructor Set myCircle = CircleShape() (calls Constructor with none of the optional parameters, creating a new defaulted instance) would be a pitfall to avoid.


A concrete example, the CircleShape class with a default Constructor method:

VERSION 1.0 CLASS
BEGIN
  MultiUse = -1  'True
END
Attribute VB_Name = "CircleShape"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Compare Database

Private m_Radius As Double
Private Const PI = 3.1415926535898

Public Property Get Constructor(Optional Radius As Double = 1) As CircleShape
Attribute Constructor.VB_UserMemId = 0
'This is the default member
    Set Constructor = New CircleShape
    Constructor.Radius = Radius
End Property

Public Property Get Radius() As Double
    Radius = m_Radius
End Property

Public Property Let Radius(value As Double)
    m_Radius = value
End Property

Public Property Get Area() As Double
    Area = m_Radius * PI
End Property

And testing the class:

Public Sub Main()
    Dim myCircle As CircleShape

    Set myCircle = CircleShape(4.75)
    Debug.Print "4.75 inch circle area: " & myCircle.Area

    Set myCircle = CircleShape()
    Debug.Print "Unit circle area: " & myCircle.Area
End Sub

I implemented the other two examples as a RectangleShape(Optional Length As Double = 0, Optional Width As Double = 0 and HyperCubeShape(ParamArray arr() As Variant) with the intent of HyperCubeShape(numberOfDimensions As Long, lengthDim1 As Double, lengthDim2 As Double, ...) to test them out and they work as advertised. Can't wait to see if you can figure out a way of intellisensing the ParamArray!

\$\endgroup\$
  • \$\begingroup\$ That is one serious pitfall too. =( \$\endgroup\$ – RubberDuck Oct 25 '14 at 0:16
  • \$\begingroup\$ Thanks for feedback. Yeah the random number isn't a must - I only added it to make the testing easier. The original template only really consists of the default getter which is supposed to mimic the constructor. \$\endgroup\$ – user28366 Oct 25 '14 at 17:03
5
\$\begingroup\$

I think PieceOfMe is an outright bad name. The member should be called Constructor, but since the template class is called Constructor, I'd call it Ctor. I like that the name is pretty much irrelevant though, but since the property getter will show up in the object browser, it's definitely best to give it a meaningful name... especially since the object browser will also show that member as being the type's default property.

Semantically, what you have here is an initializer method, implemented in a property getter - an interesting idea, but possibly quite confusing, since constructor-like syntax isn't something a VBA maintainer will see often.


If PieceOfMe is a constructor, then why bother initializing myName with a random value?

Private Sub Class_Initialize()
    myName = Cstr(Int((100) * rnd + 1))
End Sub

The getter for the Name property would be expected to return vbNullString until the instance is "constructed". If this code is intended to be used as a template code file (i.e. saved in a standalone .cls code file and imported into a VBA project whenever needed), then whoever is using it will systematically remove the Class_Initialize handler: I'd remove it altogether.

This would probably be more practical as a template:

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

'TODO: Rename class and change return type accordingly.
Public Property Get Ctor() As Constructor
    Attribute Item.VB_UserMemId = 0
    'TODO: Assign private fields from parameter values.
    Set Ctor = Me
End Property

Now as you've stated:

No way to hide PieceOfMe() from intelli-sense and Object Browser

VBA doesn't allow hidden members, as attribute VB_MemberFlags = "40" gets completely ignored unfortunately - that's another good reason to name the default getter with a proper name.

It's also what makes this API confusing: PieceOfMe is a full-fledged member of the type, whether you want it or not. Nothing can possibly keep the client code from calling it on an existing, already initialized instance... and the way you have it, doing that would actually assign the property value of, say, Name, even though on the surface it seems Name is a get-only property.

Consider this passing test:

Option Explicit
Private Assert As New RetailCoderVBE.AssertClass

Public Sub TestConstructor()

    Dim foo As Constructor
    Dim fooPtr As Long
    Dim barPtr As Long

    Set foo = Constructor("foo")
    fooPtr = ObjPtr(foo)
    Assert.AreEqual foo.Name, "foo"

    Set foo = Constructor("bar")
    barPtr = ObjPtr(foo)
    Assert.AreEqual foo.Name, "bar"

    Assert.AreEqual fooPtr, barPtr

End Sub

Constructor.PieceOfMe boils down to being a property getter with side-effects... I'm not very sure I like this.

If Constructor("foo") returned a new instance, then the above test would fail at Assert.AreEqual fooPtr, barPtr, and the Name property would effectively be a get-only property as advertised.

But the ultimate deception isn't even about the property.

By making Constructor a "static class", and returning Me in PieceOfMe, it now happens that PieceOfMe will always return the same object reference - the reference pointing to the Constructor's default instance. It's a singleton!

These two failing tests prove it:

Option Explicit
Private Assert As New RetailCoderVBE.AssertClass

Public Sub Test1()

    Dim foo As Constructor
    Dim bar As Constructor

    Set foo = Constructor("foo")
    Set bar = Constructor("bar")

    Assert.AreNotSame foo, bar, "both objects are the same instance."

End Sub

Public Sub Test2()

    Dim foo As Constructor
    Dim bar As Constructor

    Set foo = Constructor("foo")
    Set bar = Constructor("bar")

    Assert.AreNotEqual foo.Name, bar.Name, "both [foo] and [bar] objects have the same name."

End Sub

test results

\$\endgroup\$
  • 2
    \$\begingroup\$ valid points. I agree that PieceOfMe should have been named differently...Constructor makes more sense. The Name getter was there only to provide a way of testing it. There are some tests I have overlooked before posting but I was so excited to share the idea I forgot a few bits here and there :) \$\endgroup\$ – user28366 Oct 25 '14 at 16:59
4
\$\begingroup\$

Some very good points were made already. The most important of which, in my opinion, being the very subtle difference between these lines of code.

Dim multiParams As MultipleParams

' creates a new instance via constructor
Set multiParams = MultipleParams("the Name", 1)

' creates a new instance with defaults 
Set multiParams = MultipleParams()

' sets equal to the global default instance
Set multiParams = MultipleParams

This means you must supply a valid way to create an instance if no parameters are passed. (They are Optional after all.) I find it kind of moot however, because it's just too dang easy to get accidentally get the global default instance of the class by mistake. This will make for buggy code.

I'm going to offer an alternative solution to faking constructors. I stumbled on it when I was learning how to create an instance of a class stored in another VBA project. Microsoft recommends creating a regular *.bas module called Provider with a bunch of factory methods named something like New_Foo. It looks something like this.

Dim bar As MyVbaProject.Foo
Set bar = MyVbaProject.Provider.New_Foo

So... Yeah... Let's forget that and use vbPredeclaredId = True to create a global default instance. Just like you've already done. Only instead of a property that returns a new instance, we'll use a function. I like to call mine Create.

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

Private myName as String
Private myNumber as Long

Private Sub Class_Initialize()
    myName = Cstr(Int((100) * rnd + 1))
End Sub

Public Property Get Name() as String
    Name = myName
End Property

Public Function Create(newName As String, Optional theNumber As Long) As MultipleParams

    Dim result As New MultipleParams

    result.myName = newName
    result.myNumber = theNumber

    Set Create = result
End Function

Note that we're no longer locked into Optional parameters. We can require the ones that are required.

The client code now looks something like this.

Dim multiParams As MultipleParams

' creates a new instance via constructor
Set multiParams = MultipleParams.Create("the Name", 1)

' uses a default for the number, but still requires name
Set multiParams = MultipleParams.Create("the Name")

' this code LOOKS wrong now
Set multiParams = MultipleParams

As I commented, using this method to fake a constructor makes setting a variable to the default instance looks wrong and that's half the battle. We want buggy code to look wrong. We need it to stand out from the thousands of lines of boring code that works just fine.

\$\endgroup\$
  • \$\begingroup\$ thanks for feedback. I have to admit that i overlooked the Set multiParams = MultipleParams() vs Set multiParams = MultipleParams.. that's a very good point! \$\endgroup\$ – user28366 Oct 25 '14 at 16:54
  • \$\begingroup\$ I wouldn't have noticed if @blackhawk hadn't pointed it out. I just wanted to provide an alternative. \$\endgroup\$ – RubberDuck Oct 25 '14 at 16:56

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy