在 VBA 中向构造函数传递参数

如何构造直接向自己的类传递参数的对象?

就像这样:

Dim this_employee as Employee
Set this_employee = new Employee(name:="Johnny", age:=69)

不能做到这一点是非常恼人的,你最终用肮脏的解决方案来解决这个问题。

128139 次浏览

Here's a little trick I'm using lately and brings good results. I would like to share with those who have to fight often with VBA.

1.- Implement a public initiation subroutine in each of your custom classes. I call it InitiateProperties throughout all my classes. This method has to accept the arguments you would like to send to the constructor.

2.- Create a module called factory, and create a public function with the word "Create" plus the same name as the class, and the same incoming arguments as the constructor needs. This function has to instantiate your class, and call the initiation subroutine explained in point (1), passing the received arguments. Finally returned the instantiated and initiated method.

Example:

Let's say we have the custom class Employee. As the previous example, is has to be instantiated with name and age.

This is the InitiateProperties method. m_name and m_age are our private properties to be set.

Public Sub InitiateProperties(name as String, age as Integer)


m_name = name
m_age = age


End Sub

And now in the factory module:

Public Function CreateEmployee(name as String, age as Integer) as Employee


Dim employee_obj As Employee
Set employee_obj = new Employee


employee_obj.InitiateProperties name:=name, age:=age
set CreateEmployee = employee_obj


End Function

And finally when you want to instantiate an employee

Dim this_employee as Employee
Set this_employee = factory.CreateEmployee(name:="Johnny", age:=89)

Especially useful when you have several classes. Just place a function for each in the module factory and instantiate just by calling factory.CreateClassA(arguments), factory.CreateClassB(other_arguments), etc.

EDIT

As stenci pointed out, you can do the same thing with a terser syntax by avoiding to create a local variable in the constructor functions. For instance the CreateEmployee function could be written like this:

Public Function CreateEmployee(name as String, age as Integer) as Employee


Set CreateEmployee = new Employee
CreateEmployee.InitiateProperties name:=name, age:=age


End Function

Which is nicer.

I use one Factory module that contains one (or more) constructor per class which calls the Init member of each class.

For example a Point class:

Class Point
Private X, Y
Sub Init(X, Y)
Me.X = X
Me.Y = Y
End Sub

A Line class

Class Line
Private P1, P2
Sub Init(Optional P1, Optional P2, Optional X1, Optional X2, Optional Y1, Optional Y2)
If P1 Is Nothing Then
Set Me.P1 = NewPoint(X1, Y1)
Set Me.P2 = NewPoint(X2, Y2)
Else
Set Me.P1 = P1
Set Me.P2 = P2
End If
End Sub

And a Factory module:

Module Factory
Function NewPoint(X, Y)
Set NewPoint = New Point
NewPoint.Init X, Y
End Function


Function NewLine(Optional P1, Optional P2, Optional X1, Optional X2, Optional Y1, Optional Y2)
Set NewLine = New Line
NewLine.Init P1, P2, X1, Y1, X2, Y2
End Function


Function NewLinePt(P1, P2)
Set NewLinePt = New Line
NewLinePt.Init P1:=P1, P2:=P2
End Function


Function NewLineXY(X1, Y1, X2, Y2)
Set NewLineXY = New Line
NewLineXY.Init X1:=X1, Y1:=Y1, X2:=X2, Y2:=Y2
End Function

One nice aspect of this approach is that makes it easy to use the factory functions inside expressions. For example it is possible to do something like:

D = Distance(NewPoint(10, 10), NewPoint(20, 20)

or:

D = NewPoint(10, 10).Distance(NewPoint(20, 20))

It's clean: the factory does very little and it does it consistently across all objects, just the creation and one Init call on each creator.

And it's fairly object oriented: the Init functions are defined inside the objects.

EDIT

I forgot to add that this allows me to create static methods. For example I can do something like (after making the parameters optional):

NewLine.DeleteAllLinesShorterThan 10

Unfortunately a new instance of the object is created every time, so any static variable will be lost after the execution. The collection of lines and any other static variable used in this pseudo-static method must be defined in a module.

Another approach

Say you create a class clsBitcoinPublicKey

In the class module create an ADDITIONAL subroutine, that acts as you would want the real constructor to behave. Below I have named it ConstructorAdjunct.

Public Sub ConstructorAdjunct(ByVal ...)


...


End Sub


From the calling module, you use an additional statement


Dim loPublicKey AS clsBitcoinPublicKey


Set loPublicKey = New clsBitcoinPublicKey


Call loPublicKey.ConstructorAdjunct(...)

The only penalty is the extra call, but the advantage is that you can keep everything in the class module, and debugging becomes easier.

When you export a class module and open the file in Notepad, you'll notice, near the top, a bunch of hidden attributes (the VBE doesn't display them, and doesn't expose functionality to tweak most of them either). One of them is VB_PredeclaredId:

Attribute VB_PredeclaredId = False

Set it to True, save, and re-import the module into your VBA project.

Classes with a PredeclaredId have a "global instance" that you get for free - exactly like UserForm modules (export a user form, you'll see its predeclaredId attribute is set to true).

A lot of people just happily use the predeclared instance to store state. That's wrong - it's like storing instance state in a static class!

Instead, you leverage that default instance to implement your factory method:

[Employee class]

'@PredeclaredId
Option Explicit


Private Type TEmployee
Name As String
Age As Integer
End Type


Private this As TEmployee


Public Function Create(ByVal emplName As String, ByVal emplAge As Integer) As Employee
With New Employee
.Name = emplName
.Age = emplAge
Set Create = .Self 'returns the newly created instance
End With
End Function


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


Public Property Get Name() As String
Name = this.Name
End Property


Public Property Let Name(ByVal value As String)
this.Name = value
End Property


Public Property Get Age() As String
Age = this.Age
End Property


Public Property Let Age(ByVal value As String)
this.Age = value
End Property

With that, you can do this:

Dim empl As Employee
Set empl = Employee.Create("Johnny", 69)

Employee.Create is working off the default instance, i.e. it's considered a member of the type, and invoked from the default instance only.

Problem is, this is also perfectly legal:

Dim emplFactory As New Employee
Dim empl As Employee
Set empl = emplFactory.Create("Johnny", 69)

And that sucks, because now you have a confusing API. You could use '@Description annotations / VB_Description attributes to document usage, but without Rubberduck there's nothing in the editor that shows you that information at the call sites.

Besides, the Property Let members are accessible, so your Employee instance is mutable:

empl.Name = "Jane" ' Johnny no more!

The trick is to make your class implement an interface that only exposes what needs to be exposed:

[IEmployee class]

Option Explicit


Public Property Get Name() As String : End Property
Public Property Get Age() As Integer : End Property

And now you make Employee implement IEmployee - the final class might look like this:

[Employee class]

'@PredeclaredId
Option Explicit
Implements IEmployee


Private Type TEmployee
Name As String
Age As Integer
End Type


Private this As TEmployee


Public Function Create(ByVal emplName As String, ByVal emplAge As Integer) As IEmployee
With New Employee
.Name = emplName
.Age = emplAge
Set Create = .Self 'returns the newly created instance
End With
End Function


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


Public Property Get Name() As String
Name = this.Name
End Property


Public Property Let Name(ByVal value As String)
this.Name = value
End Property


Public Property Get Age() As String
Age = this.Age
End Property


Public Property Let Age(ByVal value As String)
this.Age = value
End Property


Private Property Get IEmployee_Name() As String
IEmployee_Name = Name
End Property


Private Property Get IEmployee_Age() As Integer
IEmployee_Age = Age
End Property

Notice the Create method now returns the interface, and the interface doesn't expose the Property Let members? Now calling code can look like this:

Dim empl As IEmployee
Set empl = Employee.Create("Immutable", 42)

And since the client code is written against the interface, the only members empl exposes are the members defined by the IEmployee interface, which means it doesn't see the Create method, nor the Self getter, nor any of the Property Let mutators: so instead of working with the "concrete" Employee class, the rest of the code can work with the "abstract" IEmployee interface, and enjoy an immutable, polymorphic object.

Using the trick

Attribute VB_PredeclaredId = True

I found another more compact way:

Option Explicit
Option Base 0
Option Compare Binary


Private v_cBox As ComboBox


'
' Class creaor
Public Function New_(ByRef cBox As ComboBox) As ComboBoxExt_c
If Me Is ComboBoxExt_c Then
Set New_ = New ComboBoxExt_c
Call New_.New_(cBox)
Else
Set v_cBox = cBox
End If
End Function

As you can see the New_ constructor is called to both create and set the private members of the class (like init) only problem is, if called on the non-static instance it will re-initialize the private member. but that can be avoided by setting a flag.

Why not this way:

  1. In a class module »myClass« use Public Sub Init(myArguments) instead of Private Sub Class_Initialize()
  2. Instancing: Dim myInstance As New myClass: myInstance.Init myArguments

First, here is a very quick summary/comparison of the baseline approach and the top three answers.

The baseline approach: These are the basic ways to construct new instances of a class:

Dim newEmployee as Employee
Dim newLunch as Lunch


'==Very basic==
Set newEmployee = new Employee
newEmployee.Name = "Cam"
newEmployee.Age = 42


'==Use a method==
Set newLunch = new Lunch
newLunch.Construct employeeName:= "Cam" food:="Salad", drink:="Tea"

Above, Construct would be a sub in the Lunch class that assigns the parameter values to an object.

The issue is that even with a method, it took two lines, first to set the new object, and second to fill the parameters. It would be nice to do both in one line.

1) The Factory class (bgusach): Make a separate class ("Factory"), with methods to create instances of any other desired classes including set-up parameters.

Possible use:

Dim f as Factory 'a general Factory object
Dim newEmployee as Employee
Dim newLunch as Lunch


Set f = new Factory
Set newEmployee = f.CreateEmployee("Bob", 25)
Set newLunch = f.CreateLunch("Bob", "Sandwich", "Soda")

When you type "f." in the code window, after you Dim f as Factory, you see a menu of what it can create via Intellisense.

2) The Factory module (stenci): Same, but instead of a class, Factory can be a standard module.

Possible use:

Dim newEmployee as Employee
Dim newLunch as Lunch


Set newEmployee = CreateEmployee("Jan", 31) 'a function
Set newLunch = CreateLunch("Jan", "Pizza", "JuiceBox")

In other words, we just make a function outside the class to create new objects with parameters. This way, you don't have to create or refer to a Factory object. You als don't get the as-you-type intellisense from the general factory class.

3) The Global Instance (Mathieu Guindon): Here we return to using objects to create classes, but sticking to the class-to-be-made. If you modify the class module in an external text editor you can call class methods before creating an object.

Possible use:

Dim newEmployee as Employee
Dim newLunch as Lunch


Set newEmployee = newEmployee.MakeNew("Ace" 50)
Set newLunch = newLunch.MakeNew("Ace", "Burrito", "Water")

Here MakeNew is a function like CreateEmployee or CreateLunch in the general factory class, except that here it is in the class-to-be-made, and so we don't have to specify what class it will make.

This third approach has a fascinating "created from itself" appearance to it, permitted by the global instance.

Other ideas: Auto-instancing, a Clone method, or a Parent Collection Class With auto instancing (Dim NewEmployee as new Employee, note the word "new"), you can achieve something similar to the global instance without the setup process:

Dim NewEmployee as new Employee
NewEmployee.Construct("Sam", 21)

With "new" in the Dim statement, the NewEmployee object is created as an implied pre-step to calling its method. Construct is a Sub in the Employee class, just like in the baseline approach.[1]

There are some issues with auto instancing; some hate it, a few defend it.2 To restrict auto-instancing to one proto-object, you could add a MakeNew function to the class as I used with the Global Instance approach, or revise it slightly as Clone:

Dim protoEmployee as new Employee 'with "new", if you like


'Add some new employees to a collection
Dim someNames() as Variant, someAges() as Variant
Dim someEmployees as Collection
someNames = array("Cam", "Bob", "Jan", "Ace")
someAges = array(23, 45, 30, 38)
set someEmployees = new Collection


for i = 0 to 3
someEmployees.Add protoEmployee.Clone(someNames(i), someAges(i))
next

Here, the Clone method could be set up with optional parameters Function Clone(optional employeeName, optional employeeAge)and use the properties of the calling object if none are supplied.

Even without auto-instancing, a MakeNew or Clone method within the class itself can create new objects in one line, once you create the proto-object. You could use auto-instancing for a general factory object in the same way, to save a line, or not.

Finally, you might want a parent class. A parent class could have methods to create new children with parameters (e.g., with Employees as a custom collection, set newEmployee = Employees.AddNew(Tom, 38)). For a lot of objects in Excel this is standard: you can't create a Worksheet or a Workbook except from its parent collection.

[1]One other adjustment relates to whether the Construct method is a Sub or a Function. If Construct is called from an object to fill in its own properties, it can be a Sub with no return value. However, if Construct returns Me after filling in the parameters, then the Factory methods/functions in the top 2 answers could leave the parameters to Construct. For example, using a factory class with this adjustment could go: Set Sue = Factory.NewEmployee.Construct("Sue", "50"), where NewEmployee is a method of Factory that returns a blank new Employee, but Construct is a method of Employee that assigns the parameters internally and returns Me.