VBA Collection with case specific keys - vba

I need to store class items in vba such that the collection can be string indexed and iterated like a normal collection. But the keys need to be case specific. To clarify I need this behavior:
classWaz:
...
Private mName As String
...
Public Property Get Name() As String
Name=mName
End Property
Public Property Let Name(RHS As String)
mName=RHS
End Property
...
Sub DoIt()
Dim d As Desideratum, foo As classWaz, bar As classWaz, iter As classWaz
Set d = New Desideratum '<- The thing I need - a collection with case specific keys
Set foo = New classWaz
foo.Name = "foo"
Set bar = New classWaz
bar.Name = "bar"
d.Add Item:=foo, Key:="baz"
d.Add Item:=bar, Key:="BAZ"
For Each iter In d
Debug.Print iter.Name
Next
'Should print
' foo
' bar
Set iter = d("baz")
Debug.Print iter.Name
'Should print
' foo
End Sub
The setup is that I have code using Collection that extensively uses these idioms. But I realized as I was testing that my use case requires case specific indexing and Collection doesn't support this.
I've tried Dictionary, but this doesn't appear to support class items. .Items() also returns an array, so a different iteration idiom would be needed. And I'm not aware of any way to force Collection to use vbCompareBinary. Even using Option Compare Binay, which is the default anyway.
I can think of a few workarounds; like having classes that had Collection typed properties, instead have methods GetWaz(wazName As String) As classWaz and an un-keyed GetWazes() As Collection. But this would be a lot of work I'd like to avoid if I can.
Thanks

I don't see the issue with using a Dictionary:
Sub DoIt()
' Requires reference to Microsoft Scripting Runtime
Dim d As Scripting.Dictionary
Set d = New Scripting.Dictionary
Dim foo As classWaz, bar As classWaz, iter As classWaz
Set foo = New classWaz
foo.Name = "foo"
Set bar = New classWaz
bar.Name = "bar"
d.Add Key:="baz", Item:=foo
d.Add Key:="BAZ", Item:=bar
Dim i As Long
For i = LBound(d.Items) To UBound(d.Items)
Debug.Print d.Items(i).Name
Next
Set iter = d("baz")
Debug.Print iter.Name
End Sub
Caveat: the Scripting.Dictionary is not available on macOS, though you might consider this drop-in replacement.

Related

How to put an array into a collection in VBA?

This is my first foray into VBA.
How can I put an array into a collection? I am making a collection with keys which contains reference data. It gets made once, and then used throughout my program. Everything I've found is either reading data in from a sheet in Excel, taking user input, or just putting in a couple of previously defined arrays; I want to do the initialisation of the arrays in the add call to the collection, as they aren't going to be used anywhere else.
I want to do something along the lines of
Dim c As Collection
Set c = New Collection
c.Add((1,"B"), "B")
c.Add((1.014,"FeC"), "Fe")
... 'about 100 lines
I can then retrieve the data by key, rather then having a hard to maintain If/ElseIf.
In Python, I would do
c = {"B": (1,"B"), "Fe": (1.014,"FeC"), ...}
A strange day. Two questions where I can use the same answer (see Convert 2 collections to a dictionary).
The basic advice I would give is to use a Scripting.Dictionary if only for the reason that you can get arrays back out using the Keys and Items Methods.
Here is the answer reproduced from the reference above
Inserting two collections (or any combination of an array or collection) into a dictionary is essentially boilerplate code. The best way to deal with boilerplate code is to put it in the object so that scripting dictionary would end up with a Method called 'AddPairs'.
In VBA you can't do this directly. Instead, you have to use a Wrapper, which is a term used for putting an object inside another object and using pass through methods to use the inner object. The class below 'wDictionary', shows how to Wrap the Scripting.Dictionary object to add the functionality you desire, plus an additional method which does what you want in reverse.
The AddPairs Method allows collections or Arrays to be used for the Keys and Items so, assuming you are now using wCollection you can write
pairs.AddPairs rembs, cols
The wDictionary also has a 'Pairs' method. The pairs method returns an array in the same way as the 'Items' and 'Keys' methods, but, each Item is an array of three Items, the Index, Key and Item. If you've been programming for a while, you'll understand the utility of the Pairs method.
Save the code below as a .cls file and then import it into your project. Replace references to Scripting.Dictionary with wDictionary (or New wDictionary if you are using CreateObject)
The code below is provided as an example. I haven't run any tests but I have done Rubberduck code inspections to ensure that there are no obvious errors.
VERSION 1.0 CLASS
BEGIN
MultiUse = -1 'True
END
Attribute VB_Name = "wDictionary"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = True
Option Explicit
Private Type State
Host As Scripting.Dictionary
End Type
Private s As State
Private Sub Class_Initialize()
Set s.Host = New Scripting.Dictionary
End Sub
Public Sub Add(ByRef Key As Variant, ByRef Item As Variant)
s.Host.Add Key, Item
End Sub
' An Enhancement - The Keys and Items variants must support For each and (Index) or defaultmemeber .Item(Index)
Public Sub AddPairs(ByVal Keys As Variant, ByRef Items As Variant)
Dim myItemsIndex As Long
Dim myLastItemsIndex As Long
If TypeName(Items) = "Collection" Then
myItemsIndex = 1
myLastItemsIndex = Items.Count
Else
myItemsIndex = LBound(Items)
myLastItemsIndex = UBound(Items)
End If
Dim myKey As Variant
For Each myKey In Keys
If myItemsIndex > myLastItemsIndex Then
Exit For
Else
s.Host.Add myKey, Items(myItemsIndex)
myItemsIndex = myItemsIndex + 1
End If
Next
End Sub
Public Property Get CompareMode() As VbCompareMethod
CompareMode = s.Host.CompareMode
End Property
Public Property Let CompareMode(ByVal RHS As VbCompareMethod)
s.Host.CompareMode = RHS
End Property
Public Property Get Count() As Long
Count = s.Host.Count
End Property
Public Function Exists(ByRef Key As Variant) As Boolean
Exists = s.Host.Exists(Key)
End Function
'#DefaultMember
Public Property Get Item(ByRef Key As Variant) As Variant
Attribute Item.VB_UserMemId = 0
If VBA.IsObject(s.Host(Key)) Then
Set Item = s.Host(Key)
Else
Item = s.Host(Key)
End If
End Property
Public Property Let Item(ByRef Key As Variant, ByVal RHS As Variant)
s.Host(Key) = RHS
End Property
Public Property Set Item(ByRef Key As Variant, ByVal RHS As Variant)
Set s.Host(Key) = RHS
End Property
Public Function Items() As Variant
Items = s.Host.Items
End Function
Public Function Keys() As Variant
Keys = s.Host.Keys
End Function
' An enhancement For Each myItem in myDictionary.Pairs return an array containing an index, key and value (items 0,1,2 respectively)
Public Function Pairs() As Variant
Dim myPairs As Variant
ReDim myPairs(0 To s.Host.Count - 1)
Dim myIndex As Long
myIndex = 0
Dim myKey As Variant
For Each myKey In s.Host
myPairs(myIndex) = Array(myIndex, myKey, s.Host(myKey))
myIndex = myIndex + 1
Next
Pairs = myPairs
End Function
Public Property Let Key(ByRef Key As Variant, ByVal NewKey As Variant)
s.Host.Key(Key) = NewKey
End Property
Public Sub Remove(ByRef Key As Variant)
s.Host.Remove Key
End Sub
Public Sub RemoveAll()
s.Host.RemoveAll
End Sub
Update
Of course, the above is quite tedious to write, even though you any have to do the base wrapping once. This is where twinBasic (the up and coming replacement for VB/VBA) has a definite edge. In twin basic the whole code presented above can be condensed to
Class wDictionary
Implements Scripting.dictionary Via Host = New scripting.dictionary
' An enhancement For Each myItem in myDictionary.Pairs return an array containing an index, key and value (items 0,1,2 respectively)
Public Function Pairs() As Variant
Dim myPairs As Variant
ReDim myPairs(0 To s.Host.Count - 1)
Dim myIndex As Long = 0
Dim myKey As Variant
For Each myKey In Host
myPairs(myIndex) = Array(myIndex, myKey, s.Host(myKey))
myIndex += 1
Next
Return myPairs
End Function
' An Enhancement - The Keys and Items variants must support For each and (Index) or defaultmemeber .Item(Index)
Public Sub AddPairs(ByVal Keys As Variant, ByRef Items As Variant)
Dim myItemsIndex As Long
Dim myLastItemsIndex As Long
If TypeName(Items) = "Collection" Then
myItemsIndex = 1
myLastItemsIndex = Items.Count
Else
myItemsIndex = LBound(Items)
myLastItemsIndex = UBound(Items)
End If
Dim myKey As Variant
For Each myKey In Keys
If myItemsIndex > myLastItemsIndex Then
Exit For
Else
Host.Add myKey, Items(myItemsIndex)
myItemsIndex += 1
End If
Next
End Sub
End Class
What's more, twinBasic makes it absolutely trivial to convert wCollection to an activeX.dll so that you can just add a reference to wCOllection just as you do for Scripting.DIctionary etc.

Type mismatch trying to set data in an object in a collection

I am getting Runtime Error 13 when trying to update an object stored in a collection. Here is a minimal example.
The class (Class2) of the objects to be stored in the collection.
Option Explicit
Private pHasA As Boolean
Private pHasB As Boolean
Private pSomeRandomID As String
Property Get HasA() As Boolean
HasA = pHasA
End Property
Property Get HasB() As Boolean
HasB = pHasB
End Property
Property Let HasA(propValue As Boolean)
pHasA = propValue
End Property
Property Let HasB(propValue As Boolean)
pHasB = propValue
End Property
Property Let RandomID(propValue As String)
pSomeRandomID = propValue
End Property
Sub SetHasValues(key As String)
Select Case key
Case "A"
pHasA = True
Case "B"
pHasB = True
End Select
End Sub
Minimal code that reproduces the error:
Option Explicit
Private Sub TestCollectionError()
Dim classArray As Variant
Dim classCollection As Collection
Dim singleClass2Item As Class2
Dim iterator As Long
classArray = Array("A", "B", "C")
Set classCollection = New Collection
For iterator = LBound(classArray) To UBound(classArray)
Set singleClass2Item = New Class2
singleClass2Item.RandomID = classArray(iterator)
classCollection.Add singleClass2Item, classArray(iterator)
Next iterator
Debug.Print "Count: " & classCollection.Count
singleClass2Item.SetHasValues "A" ' <-- This code works fine.
Debug.Print "New Truth values: " & singleClass2Item.HasA, singleClass2Item.HasB
For iterator = LBound(classArray) To UBound(classArray)
classCollection(classArray(iterator)).RandomID = classArray(iterator)
classCollection(classArray(iterator)).SetHasValues classArray(iterator) '<-- Type mismatch on this line.
Next iterator
'***** outputs
'''Count: 3
'''New Truth values: True False
' Error dialog as noted in the comment above
End Sub
While the code above appears a little contrived, it is based on some real code that I am using to automate Excel.
I have searched for answers here (including the following posts), but they do not address the simple and non-ambiguous example that I have here. The answers that I have found have addressed true type mismatches, wrong use of indexing or similar clear answers.
Retrieve items in collection (Excel, VBA)
Can't access object from collection
Nested collections, access elements type mismatch
This is caused by the fact, that the parameter of your procedure SetHasValues is implicitely defined ByRef.
Defining it ByVal will fix your problem.
#ADJ That's annoying, but perhaps the example below will allow you to start making a case for allowing RubberDuck.
I've upgraded your code using ideas and concepts I've gained from the rubberduck blogs. The code now compiles cleanly and is (imho) is less cluttered due to fewer lookups.
Key points to note are
Not relying on implicit type conversions
Assigning objects retrieved from collections to a variable of the type you are retrieving to get access to intellisense for the object
VBA objects with true constructors (the Create and Self functions in class2)
Encapsulation of the backing variables for class properties to give consistent (and simple) naming coupled with intellisense.
The code below does contain Rubberduck Annotations (comments starting '#)
Updated Class 2
Option Explicit
'#Folder("StackOverflowExamples")
'#PredeclaredId
Private Type Properties
HasA As Boolean
HasB As Boolean
SomeRandomID As String
End Type
Private p As Properties
Property Get HasA() As Boolean
HasA = p.HasA
End Property
Property Get HasB() As Boolean
HasB = p.HasB
End Property
Property Let HasA(propValue As Boolean)
p.HasA = propValue
End Property
Property Let HasB(propValue As Boolean)
p.HasB = propValue
End Property
Property Let RandomID(propValue As String)
p.SomeRandomID = propValue
End Property
Sub SetHasValues(key As String)
Select Case key
Case "A"
p.HasA = True
Case "B"
p.HasB = True
End Select
End Sub
Public Function Create(ByVal arg As String) As Class2
With New Class2
Set Create = .Self(arg)
End With
End Function
Public Function Self(ByVal arg As String) As Class2
p.SomeRandomID = arg
Set Self = Me
End Function
Updated test code
Private Sub TestCollectionError()
Dim classArray As Variant
Dim classCollection As Collection
Dim singleClass2Item As Class2
Dim my_item As Variant
Dim my_retrieved_item As Class2
classArray = Array("A", "B", "C")
Set classCollection = New Collection
For Each my_item In classArray
classCollection.Add Item:=Class2.Create(my_item), key:=my_item
Next
Debug.Print "Count: " & classCollection.Count
Set singleClass2Item = classCollection.Item(classCollection.Count)
Debug.Print "Initial Truth values: " & singleClass2Item.HasA, singleClass2Item.HasB
singleClass2Item.SetHasValues "A" ' <-- This code works fine.
Debug.Print "New Truth values: " & singleClass2Item.HasA, singleClass2Item.HasB
For Each my_item In classArray
Set my_retrieved_item = classCollection.Item(my_item)
my_retrieved_item.RandomID = CStr(my_item)
my_retrieved_item.SetHasValues CStr(my_item)
Next
End Sub
The 'Private Type Properties' idea comes from a Rubberduck article encapsulating class variable in a 'This' type. My take on this idea is to use two type variable p and s (Properties and State) where p holds the backing variables to properties and s hold variables which represent the internal state of the class. Its not been necessary to use the 'Private Type State' definition in the code above.
VBA classes with constructors relies on the PredeclaredID attribute being set to True. You can do this manually by removing and saving the code, using a text editor to set the attributer to 'True' and then reimporting. The RUbberDuck attribute '#PredeclaredId' allows this to be done automatically by the RubberDuck addin. IN my own code the initialiser for class2 would detect report an error as New should not be used when Classes are their own factories.
BY assigning and intermediate variable when retrieving an object from a class (or even a variant) you give Option Explicit the best change for letting you n=know of any errors.
An finally the Rubberduck Code Inspection shows there are still some issues which need attention

Variables refer to the same instance

Within my learning curve I play around with converting List and IEnumerable between each other.
What I am surprised with is that after executing EditMyList procedure MyIEnumerable contains the same data for each DBTable object as MyList. However I have modified MyList only, without assigning it to MyIEnumerable once List has been modified.
Can you explain what happened here and why MyList and MyEInumerable refer to the same instance?
Public Class DBTable
Public Property TableName As String
Public Property NumberOfRows As Integer
End Class
Public Sub EditMyList
Dim MyList As New List(Of DBTable)
MyList.Add(New DBTable With {.TableName = "A", .NumberOfRows = 1})
MyList.Add(New DBTable With {.TableName = "B", .NumberOfRows = 2})
MyList.Add(New DBTable With {.TableName = "C", .NumberOfRows = 3})
Dim MyIEnumerable As IEnumerable(Of DBTable) = MyList
For Each item In MyList
item.NumberOfRows += 10
Next
End Sub
UPDATE: string case where at the end b is not equal to a. String is also reference type, so assigning one variable to other one we shall copy just reference. However at the end there is different result than in the first example (explained by #Sefe)
Dim a As String
Dim b As String
a = "aaa"
b = "bbb"
a = b
' At this point a and b have the same value of "bbb"
a = "xxx"
' At this point I would expect a and b equal to "xxx", however a="xxx" but b="bbb"
A List is a reference type. That means it is created on the heap and your MyList variable contains just a reference (sometimes incorrectly called "pointer") to the list. When you assign MyList to MyEnumerable you don't copy the whole list, you just copy the reference. That means all changes you make to the (the one) list, is reflected by all the references.
If you want a new list you need to create it. You can use the list constructor:
Dim MyIEnumerable As IEnumerable(Of DBTable) = New List(Of DBTable)(MyList)
Since you don't need a list, but an IEnumerable you can also call the list's ToArray method:
Dim MyIEnumerable As IEnumerable(Of DBTable) = MyList.ToArray
You can also use LINQ:
Dim MyIEnumerable As IEnumerable(Of DBTable) = MyList.ToList
As far as the behavior of String is concerned, strings in .net are immutable. That means once created, they can not be changed. String operations (for example concatinations) will always create new strings. In other words: the copy operation you have to do manually for your lists is done automatically for strings. That's why you see similar behavior for strings as for value types.
Also, the assignment operation in your question would also still behave the same if strings were mutable. When you assign a = "xxx", you update the reference of afrom "bbb" to "xxx". That however does not affect b, which still keeps its old reference.
Use ToList() extension method for creating another List
Dim newCollection = MyList.ToList()
But notice that instances of DBTable still will reference to the same items
For creating "full" copy you need create new instances of DBTable for every item in the collection
Dim newCollection = MyList.Select(Function(item)
return new DBTable
{
.TableName = item.TableName,
.NumberOfRows = item.NumberOfRows
}
End Function).ToList()
For Each item in MyList
item.NumberOfrows += 10 ' will not affect on the newCollection items
Next

VBA - Is it possible to pass an Object's property as an argument in a method?

I'm pretty used to VBA, not that much for Objects though and I'm hitting a wall right now...
My config class has almost 100 properties, so I'll not spam them here as the details doesn't really matter for my question.
I hoped to code a duplicate function, to create multiple objects from one and then assign different values for a specific property of each new objects (add new elements to the configurations, so it generates new configs), that would look like this :
Public Function Duplicate(SrcCfg As Config, PropertyName As String, Properties As String) As Collection
Dim Cc As Collection, _
Cfg As Config, _
TotalNumber As Integer, _
A() As String
Set Cc = New Collection
A = Split(Properties, "/")
TotalNumber = UBound(A)
For i = 0 To TotalNumber
'Create a copy of the source object
Set Cfg = SrcCfg.Copy
'Set the property for that particular copy
Cfg.PropertyName = A(i)
'Add that copy to the collection
Cc.Add ByVal Cfg
Next i
Duplicate = Cc
End Function
But I'm not sure that a collection is the best output (as I'll take the results and incorporate them into another master collection), so I'm open to suggestions.
And I'm pretty sure that we can't pass a Property as an argument (I spent quite some times looking for a solution for this...) and I don't know what to do about it as this would be super practical for me. So if there is a solution or a workaround, I'll gladly try it!
Here is the rest of my methods :
Friend Sub SetConfig(SrcConfig As Config)
Config = SrcConfig
End Sub
Public Function Copy() As Config
Dim Result As Config
Set Result = New Config
Call Result.SetConfig(Config)
Set Copy = Result
End Function
Final code to duplicate object :
Working smoothly :
Private Cfg As Config
Friend Sub SetConfig(SrcConfig As Config)
Set Cfg = SrcConfig
End Sub
Public Function Copy() As Config
Dim Result As Config
Set Result = New Config
Call Result.SetConfig(Cfg)
Set Copy = Result
End Function
Public Function Duplicate(PropertyName As String, Properties As String) As Collection
Dim Cc As Collection, _
Cfg As Config, _
TotalNumber As Integer, _
A() As String
Set Cc = New Collection
A = Split(Properties, "/")
TotalNumber = UBound(A)
For i = 0 To TotalNumber
'Create a copy of the source object
Set Cfg = Me.Copy
'Set the property for that particular copy
CallByName Cfg, PropertyName, VbLet, A(i)
'Add that copy to the collection
Cc.Add Cfg
Next i
Set Duplicate = Cc
End Function
You actually got it right, including the types (String).
Just replace your
Cfg.PropertyName = A(i)
with
CallByName Cfg, PropertyName, vbLet, A(i)
The property name must be passed as a string, not a reference or a lambda or anything, so no type safety or compiler aid here. You will have a runtime error if you misspell the name.
As for the return type, VBA does not have lists, so a collection is generally fine, but because in your particular case you know in advance how many objects you will be returning, you can declare an array:
Dim Cc() As Config
ReDim Cc(1 to TotalNumber)
You could declare an array in any case, but if you didn't know the total number, you'd be reallocating it on every iteration.

Infinite amount of Collection VBA

Here is the screen of my problem which is infinite amount of collections.
I want the collection be added to object property just once. Not like this:
http://postimg.org/image/o6da95j0f/
(screen showing the problem with "watch" of collection in VBA
Public Sub testCollections()
Dim index As Long
index = 1
Dim OJsonElement As JsonElement
Dim newColl As New Collection
Dim str As String
Call addColl(OJsonElement, newColl)
For Each OJsonElement In newColl
Debug.Print "THE NAME IS:" & OJsonElement.name
Next OJsonElement
End Sub
Function addColl(obj1 As JsonElement, nextCollection As Collection)
Dim i As Long
Set nextCollection = New Collection
Set obj1 = New JsonElement
Set obj1.valueCollection = nextCollection
obj1.name = "CityName"
obj1.value = "type"
nextCollection.Add obj1
'obj1.ValueType = nextCollection
'nextCollection.Add nextCollection
End Function
Class:
Public name As String
Public nameCollection As Collection
Public value As Variant
Public ValueType As String
Public valueCollection As Collection
I don't really understand well your code, but I will limit to explain you why it happens what you see in your watcher. The line:
Set obj1.valueCollection = nextCollection
is adding the new collection into the obj1 property valueCollection. Then, two lines after, you say:
nextCollection.Add obj1
which means you're adding the obj1 into its own property, so creating an infinite nesting. I'd like to help you but for that I'd need to understand what you want to reach with your code. But sticking to your request I want the collection be added to object property just once, I would just suggest you to remove the line nextCollection.Add obj1, which (at least from the perspective of who doesn't know the project purpose) does not seem to do anything useful but an infinite nesting.