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

104
你如何直接向自己的类传递参数来构建对象?
就像这样:
Dim this_employee as Employee
Set this_employee = new Employee(name:="Johnny", age:=69)

无法做到这一点非常令人恼火,最终你只能采用不太优雅的解决方案来解决问题。


2
http://codereview.stackexchange.com/questions/67825/parameterised-constructors - user2140173
1
对于不可变性,可以使用类内的私有初始化函数和工厂方法:从工厂调用的私有 VBA 类初始化器 - Cristian Buse
对上面的评论进行跟进。现在,在此repo中支持私有类初始化器。该方法名为RedirectInstance,需要从私有函数中调用。与类工厂结合使用可以实现不可变性。 - Cristian Buse
8个回答

135

最近我使用了一个小技巧,取得了很好的效果。我想与那些经常与VBA打交道的人分享。

1.- 在每个自定义类中实现一个公共的初始化子程序。我在所有的类中都称其为InitiateProperties。这个方法必须接受你想要发送到构造函数的参数。

2.- 创建一个名为factory的模块,并创建一个公共函数,函数名称为"Create"+类名,参数与构造函数相同。此函数必须实例化您的类,并调用第(1)点中解释的初始化子程序,传递接收到的参数。最后返回实例化和初始化的方法。

例如:

假设我们有一个自定义类Employee。与前面的示例一样,需要使用姓名和年龄进行实例化。

这是InitiateProperties方法。m_name和m_age是要设置的私有属性。

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

    m_name = name
    m_age = age

End Sub

现在在工厂模块中:

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

最后,当您想要实例化一个员工时

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

特别适用于您有多个类的情况。只需在模块工厂中为每个类放置一个函数,并通过调用 factory.CreateClassA(arguments)factory.CreateClassB(other_arguments) 等来实例化。

编辑

正如 stenci 指出的那样,您可以通过避免在构造函数中创建局部变量来使用更简洁的语法来完成相同的操作。例如,CreateEmployee 函数可以这样编写:

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

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

End Function

哪个更好。


2
不错的解决方案!虽然我可能会将其重命名为factory.CreateEmployee以减少歧义... - Peter Albert
2
工厂模块相比于每个类中的构造方法有什么好处呢?例如,您可以调用 Set employee_obj = New Employee 然后 employee_obj.Construct "Johnny", 89,这样构建过程就在类内部发生了。只是好奇。 - Dick Kusleika
3
你好,我看到一些好处。可能它们有些重叠。首先,你像在任何普通的面向对象编程语言中一样使用构造函数,这增强了代码的清晰度。每次实例化一个对象时,你可以保存那行初始化对象的代码,这可以让你少写一些代码,同时也避免忘记初始化对象。最后,在程序中少了一个概念,从而降低了复杂性。 - bgusach
2
你可以使用私有变量来跟踪它。你可以定义 Class_Initialize,然后在那里定义一个变量 m_initialized = false。当你进入 InitiateProperties 方法时,检查 m_initialized 是否为 false,如果是,则继续并在最后将其设置为 true。如果它为 true,则引发错误或不执行任何操作。如果再次调用 InitiateProperties 方法,它将为 true,对象的状态不会改变。 - bgusach
4
抱歉打扰了,但是最后一段话是错误的,那不是更好的代码。把函数返回机制(赋值给标识符)视为声明的局部变量,至少是具有误导性和混淆性的,而不是更好的(看起来像递归调用,是吗?)。如果您想要更简洁的语法,请使用模块属性,就像我的答案所示。额外的好处是,您可以避免那个“工厂包”模块,它负责创建几乎所有东西的实例,并且在任何规模较大的项目中都会变得混乱。 - Mathieu Guindon
显示剩余4条评论

47

我使用一个包含每个类的一个或多个构造函数的工厂模块,每个构造函数调用每个类的Init成员。

例如,一个Point类:

Class Point
Private X, Y
Sub Init(X, Y)
  Me.X = X
  Me.Y = Y
End Sub
一个 Line
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

还有一个 Factory 模块:

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

这种方法的一个好处是使得在表达式中使用工厂函数变得容易。例如,可以做类似以下的事情:

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

或:

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

这很简洁:工厂仅对所有对象执行非常少的操作,只需创建和调用每个creator的一个Init函数。

而且它相当面向对象: Init函数是在对象内部定义的。

编辑

我忘记添加了,这使我能够创建静态方法。例如,我可以这样做(在使参数可选后):

NewLine.DeleteAllLinesShorterThan 10

每次执行时都会创建一个新的对象实例,因此任何静态变量都将丢失。在这个伪静态方法中使用的行集合和任何其他静态变量必须定义在模块中。


2
这个比被选中的答案更干净。 - Mickey Perlstein
自从上次玩VBA已经很久了,但是... 1:你如何从“工厂”的子程序中获取构造对象?“Sub”定义不包含返回值。 2:即使我错过了一点,你的“工厂”做的事情基本上和我的一样:创建一个对象(我分两步完成,你的语法明显更短),调用“Init”/“InitiateProperties”方法,在我的情况下,明确返回。 - bgusach
1
@ikaros45 他们应该是“Function”,而不是“Sub”,我已经编辑了帖子,谢谢。是的,它与你的代码相同,只是以一种更易于管理的方式组织(在我看来),随着每个类的数量和每个类的“构造函数”的数量增加。 - stenci
2
是的,组织结构完全相同,但我同意你的方式更加简洁。这意味着相同的功能,但每个构造函数可以节省两行代码,非常好。如果你不介意,我会使用你的语法更新我的代码。 - bgusach

42

当您导出类模块并在记事本中打开文件时,您会注意到在顶部附近有许多隐藏的属性(VBE不显示它们,并且也不公开调整大多数属性的功能)。其中一个是VB_PredeclaredId

Attribute VB_PredeclaredId = False

设置为True,保存并重新导入模块到您的VBA项目中。

PredeclaredId类具有一个“全局实例”,您可以免费获取 - 就像UserForm模块一样(导出用户表单,您将看到其predeclaredId属性已设置为true)。

许多人都只是愉快地使用预声明实例来存储状态。 这是错误的 - 它就像在静态类中存储实例状态!

相反,您可以利用默认实例来实现您的工厂方法:

[Employee类]

'@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

有了那个,你可以做到这一点:

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

Employee.Create 是在使用 默认实例 进行操作,也就是说它被视为该 类型 的成员,并且只能从默认实例中调用。

问题是,以下情况同样是合法的:

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

这很糟糕,因为现在你有一个令人困惑的API。你可以使用'@Description注释/ VB_Description 属性来记录用法,但是如果没有Rubberduck,编辑器中没有任何东西可以显示调用站点上的信息。

此外,Property Let成员是可访问的,因此你的Employee实例是可变的:

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

关键是让你的类实现一个接口,只公开需要公开的内容:

[IEmployee类]

Option Explicit

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

现在你需要让 Employee实现 IEmployee 接口 - 最终的类可能会像这样:

[Employee 类]

'@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

注意现在Create方法返回的是接口,并且该接口没有暴露Property Let成员?现在调用代码可以这样写:

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

因为客户端编码是针对接口编写的,所以empl公开的唯一成员是由IEmployee接口定义的成员,这意味着它看不到Create方法、Self访问器或任何Property Let修改器:因此,代码的其余部分可以使用"抽象"的IEmployee接口而不是与"具体"的Employee类一起工作,并享受一个不可变的、多态的对象。


如果Employee类实现了IEmployee接口,那么调用代码不应该是Dim empl as Employee吗?否则按照你的写法会出现运行时错误。 - PP8
@Jose 因为类 Implements IEmployee,所以 Dim empl As IEmployee 可以正常工作。 - Mathieu Guindon
4
@Matheiu Guindon - 我不是想垃圾邮件,但我又回到了这篇文章,已经过去将近3个月了。自那以后,我一遍又一遍地阅读了你在OOP上的橡皮鸭博客,并且现在这个答案对我来说已经完全明白了。我简直不能相信我之前在评论中问的问题。 - PP8
很棒的答案!顺便说一句:我发现几乎所有的开发人员(因为匈牙利命名法不酷),都会极力避免在其标识符中包含(常量/变量/过程/模块)类型名称,以至于他们会使用不同的首字母大小写(顺便说一下,这是有史以来最糟糕的主意),在区分相同名称的类型和变量时,在大小写敏感的语言中,或者在大小写不敏感的语言中,违反了自己不使用非标准缩写的更新风格(就像你所做的那样)。也许在90年代之前,即自动完成之前,这是“值得”的,但自那以后,没有借口不将完整的类型名称作为后缀包含在内。 “代码阅读远比编写重要。” - Tom
1
@Tom FWIW,VBIDE自动完成实际上仍停留在1997年,但无论如何,如果我今天编写这个答案/代码(它已经有5年了),我可能会在所有地方使用PascalCase标识符名称。我同意empl是一个笨拙的前缀,但是关于使用完整类型名称作为后缀的您的观点,我可能会为相关类的名称或接口的不同实现使用此方法,但不适用于变量或属性:empl将是Employee As IEmployee,而emplName将是Name。话虽如此,您的评论有点难以阅读,但还是谢谢!;-) - Mathieu Guindon
显示剩余5条评论

2

使用这个技巧

Attribute VB_PredeclaredId = True

我找到了另一种更紧凑的方法:
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

可以看到,New_构造函数被调用来创建和设置类的私有成员(类似于init),唯一的问题是,如果在非静态实例上调用它,它将重新初始化私有成员。但可以通过设置标志来避免这种情况。


0

首先,这是一个基本的概述/比较基线方法和前三个答案。

基线方法:这些是构建类的新实例的基本方法:

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"

以上,Construct将是Lunch类中的一个子程序,它将参数值分配给一个对象。

问题在于,即使有方法,仍需要两行代码,第一行用于设置新对象,第二行用于填充参数。很希望可以在一行中完成两个步骤。

1) 工厂类(bgusach):创建一个单独的类(“工厂”),其中包括创建任何其他所需类的实例以及设置参数的方法。

可能的使用:

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")

当您在代码窗口中键入“f.”时,在Dim f as Factory之后,您会看到Intellisense可以创建什么的菜单。

2)工厂模块(stenci):与类相同,但工厂可以是标准模块。

可能的用途:

Dim newEmployee as Employee
Dim newLunch as Lunch

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

换句话说,我们只需在类外部创建一个函数来使用参数创建新对象。这样,您就不必创建或引用工厂对象。您也无法从通用工厂类中获得实时智能感知。
3)全局实例(Mathieu Guindon):在这里,我们返回使用对象创建类,但保持要制作的类。如果您在外部文本编辑器中修改类模块,则可以在创建对象之前调用类方法。
可能的用途:
Dim newEmployee as Employee
Dim newLunch as Lunch

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

这里的MakeNew是一个类似于通用工厂类中的CreateEmployeeCreateLunch函数,只不过它在要创建的类中,因此我们不必指定它将创建哪个类。

这种第三种方法具有令人着迷的“自我创建”的外观,这是由全局实例允许的。

其他想法:自动实例化、克隆方法或父集合类 通过自动实例化(Dim NewEmployee as new Employee,注意单词“new”),您可以实现类似于全局实例的功能,而无需进行设置过程:

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

Dim语句中使用"new",NewEmployee对象会在调用其方法之前被隐式创建。 Construct是Employee类中的一个子程序,就像基线方法一样。[1]

自动实例化存在一些问题;有些人讨厌它,有些人则为之辩护。2 为了将自动实例化限制为一个原型对象,您可以向类中添加一个MakeNew函数,就像我在全局实例方法中使用的那样,或者稍微修改一下,改为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

在这里,可以使用可选参数设置Clone方法Function Clone(optional employeeName, optional employeeAge),如果没有提供参数,则使用调用对象的属性。

即使没有自动实例化,在类内部的MakeNewClone方法可以一行代码创建新对象,一旦创建了原型对象。您还可以以同样的方式将自动实例化用于通用工厂对象,以节省一行代码,或者不使用。

最后,您可能需要一个父类。父类可以具有创建带参数的新子项的方法(例如,使用自定义集合作为Employees,set newEmployee = Employees.AddNew(Tom, 38))。对于Excel中的许多对象,这是标准做法:您无法从其父集合中创建工作表或工作簿。

[1]另一个调整与Construct方法是Sub还是Function有关。如果Construct从对象中调用以填充其自身属性,则可以是没有返回值的Sub。但是,如果Construct在填充参数后返回Me,则前两个答案中的工厂方法/函数可以将参数留给Construct。例如,使用具有此调整的工厂类可以进行如下操作:Set Sue = Factory.NewEmployee.Construct("Sue", "50"),其中NewEmployeeFactory的一个方法,返回一个空的新员工,但ConstructEmployee的一个方法,它在内部分配参数并返回Me


0

再增加一种方法。

我提出的解决方案是,如果使用New SomeUserClass而不是工厂函数,则会引发运行时错误。这样,您的公开成员函数可以在执行其代码之前操作对象已完全初始化的状态。

基本上,您无法将参数传递给类的初始化程序,因此使用如下代码 somevar = New SomeUserClass("Some Initializer") 是行不通的。使用公共创建函数是将初始化程序参数引入其中的一个好方法,但它仍然允许使用somevar = New SomeUserClass而不受惩罚。在这种情况下,您可以设置一个已初始化标志,并在每个函数中进行检查,但这也很繁琐。

' Factory.bas

Option Explicit

Private m_globalInitializer As Variant

' This could also just be a function
Public Property Get GlobalInitializer() As Variant
    If IsEmpty(m_globalInitializer) Then _
        Err.Raise 5, , "The factory function must be used to create an instance of this class"
        ' Error #5 is 'Invalid procedure call or argument'

    ' Coerce any type of value to be returned
    If IsObject(m_globalInitializer) Then
        Set GlobalInitializer = m_globalInitializer
    Else
        GlobalInitializer = m_globalInitializer
    End If

    ' Make this getter a one-time use
    m_globalInitializer = vbEmpty
End Property

Public Function CreateSomeUserClass(ByVal somearg1 As String, _
    ByVal somearg2 As Object) As SomeUserClass

    ' You can set m_globalInitializer to anything your class initializer expects
    m_globalInitializer = Array(somearg1, somearg2)

    ' This is the only place this is allowed
    Set CreateSomeUserClass = New SomeUserClass 
End Function

' SomeUserClass.cls

Option Explicit

Private m_somevar1 As String
Private m_somevar2 As Object

Private Sub Class_Initialize()
    Dim args() As Variant
    args = GlobalInitializer ' This clears the global initializer as well
        ' allowing other objects to be factory created

    m_somevar1 = args(0)
    Set m_somevar2 = args(1)

    ' Continue rest of initialization, including calling other factories
    ' that use the GlobalInitializer idiom.
End Sub

如果在工厂函数之外调用New方法,将会导致运行时错误。在使用New之前,没有任何方式可以设置私有变量m_globalInitializer,因此你的初始化参数是强制执行的。

-2

另一种方法

假设您创建了一个名为clsBitcoinPublicKey的类

在类模块中创建一个额外的子程序,它的行为就像您希望真正的构造函数一样。下面我将其命名为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(...)

唯一的惩罚是额外的调用,但优点是您可以将所有内容保留在类模块中,并且调试变得更加容易。

1
除非我漏看了什么,否则这就像每次实例化任何对象时手动调用我的“InitiateProperties”一样,而这正是我想要避免的。 - bgusach

-3
为什么不这样做:
1. 在类模块“myClass”中使用Public Sub Init(myArguments)而不是Private Sub Class_Initialize() 2. 实例化:Dim myInstance As New myClass: myInstance.Init myArguments

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接