Creating a Container Property in a VBA Class which returns Indexed Items (Excel VBA 2003) - excel

I started learning VBA for my job at the end of last summer, and I can proudly say this is the first time I haven't be able to find the answer on Google. I started teaching myself about Classes this week, and I have come across a situation where I would like to be able to identify an "indexed property" for my class.
Since that probably isn't the clearest explanation, here is a hypothetical example:
The class which I have created for my super awesome sandwich shop (clsSASS) contains properties for Calories, Weight in Grams, Price, and Ingredients. The first three are variables with very straight forward let and get statements. I.E.:
Public pCal As Integer
Public Property Get Calories() As Integer
Calories= pCal
End Property
Public Property Let Calories(Value As Integer)
pCal = Value
End Property
Ingredients however is designed to contain, in order of entry, the list of ingredients. My initial instinct was to do something like this:
Public pIngd As Collection
Public Property Get Ingredients(Value As Integer) As Collection
Ingredients = pIngd(Value)
End Property
Public Property Set Ingredients(Object As Collection)
Set pIngd = Object
End Property
So if Bacon were the first ingredient in the list (and let's be honest it always would be), something like clsNewSandwich.Ingredients(1) would return the string 'Bacon'.
The problem arose when I added a container property to a class, and then couldn't figure out how to identify the individual items in the container. So this may just be a simple syntax issue that has nothing to do with classes whatsoever.
Many Thanks!
*edited for clarity/continuity

OK - I will retract my advice about always naming let/set and Get the same, since in this case you cannot, since the "input" and "output" types are not the same. So, in the sample below I've named the property which just returns one ingredient as Ingredient
Class "clsSASS":
Dim pIngd As Collection
Property Set Ingredients(c As Collection)
Set pIngd = c
End Property
Property Get Ingredient(v As Integer) As String
Ingredient = pIngd(v)
End Property
Regular module:
Sub Tester()
Dim c As New Collection
Dim s As New clsSASS
c.Add "bacon"
c.Add "lettuce"
c.Add "tomato"
Set s.Ingredients = c
Debug.Print s.Ingredient(1) 'bacon
Debug.Print s.Ingredient(2) 'lettuce
Debug.Print s.Ingredient(3) 'tomato
End Sub

Related

How can I create a proper Collection in VBA?

I am trying to convert a large 3 dimensioned Array into a series of class modules. I have each next class stored as an array in the previous class. It goes like Brand -> Products -> Lots.
I have successfully created this interaction and can access them by name like:
Sub test()
Dim MyBrand As Brand
Set MyBrand = New Brand
MyBrand.Name = "Company1"
MyBrand.AddProduct "Shoes"
MyBrand.Products("Shoes").AddLot "240502"
MsgBox MyBrand.Products("Shoes").Lots(0) 'Correctly Displays "240502"
End Sub
But then I wanted to create an object group that can save multiple Brand objects and access them like Brands("Company1").
If I used an array inside a class module, I'd end up with Brands.Brand("Company1").
If I used a Collection, I'd have to use indexes like Brands(1).
Is there a way to create a proper object group so that I can mimic the syntax of groups like Application.Workbooks and refer to members by Name?
A lot of the magic behind custom collections depends on hidden attributes that you cannot edit from within the VBE; you need to export (and remove from the project when prompted) the class module, edit its magic member attributes in Notepad/Notepad++, save changes, and then re-import the module into the project.
That's obviously tedious and error-prone, but there's a (much) better way.
In order to support this:
Set shoesProduct = MyBrand.Products("Shoes")
You can define Products as a Dictionary and call it a day, but then encapsulation as a concept is... well, taking a beating here (whether the internal collection is a Dictionary, a Collection, or a .NET ArrayList should typically be an implementation detail that the rest of the code doesn't need to care about).
I suspect the Brand class has too many responsibilities and "is" the product collection; best practices would be to have the Brand.Products property defined as follows:
Public Property Get Products() As Products
So you'll want to have a Products class (very much like the Workbook.Worksheets and Workbook.Sheets properties both return a Sheets collection object) that encapsulates a private, module-level VBA.Collection field (possibly keyed, but you can't access or iterate the keys of a collection).
The Products custom collection class needs an Item default property (the name Item is a convention); the implementation just pulls the item from the private encapsulated Collection:
'#DefaultMember
Public Property Get Item(ByVal Index As Variant) As Product
Set Item = ThePrivateCollection.Item(Index)
End Property
If you are using Rubberduck, this #DefaultMember annotation/comment is going to trigger an inspection result about the annotation and the corresponding hidden attribute(s) being "out of sync"; right-click that inspection result and pick "Adjust attribute values" to have Rubberduck generate the hidden code for you and deal with the annoying export/delete-edit-reimport cycle.
Otherwise, you'll want to manually edit the hidden VB_UserMemId member attribute that makes it the class' default member:
Public Property Get Item(ByVal Index As Variant) As Product
Attribute Item.VB_UserMemId = 0
Set Item = ThePrivateCollection.Item(Index)
End Property
And with that, MyBrand.Products("Shoes") becomes equivalent to MyBrand.Products.Item("Shoes").
Perhaps you want to iterate all the products in the collection, too?
For Each Product In MyBrand.Products
Debug.Print Product.Name
Next
In order to do this, you need a special "enumerator" member that forwards the enumerator from the encapsulated collection:
'#Enumerator
Public Property Get NewEnum() As IUnknown
Set NewEnum = ThePrivateCollection.[_NewEnum]
End Property
Again, Rubberduck annotations greatly simplify doing this, but everything Rubberduck does, you can also do manually if you like:
Public Property Get NewEnum() As IUnknown
Attribute NewEnum.VB_UserMemId = -4
Set NewEnum = ThePrivateCollection.[_NewEnum]
End Sub
And now For Each iteration works for your custom object collection!
If a Lot was more than just a String value (i.e. an actual object type), then the Product class could use a Lots custom collection too - but since a Lot is really just a String value (or is it?), then Product can simply encapsulate a Dictionary, and have a Lots property that exposes the Items array:
Public Property Get Lots() As Variant
Lots = ThePrivateLotsDictionary.Items
End Property
Note, that's simpler than using a Collection, because with a collection you'd need to iterate it and copy each item to an array in order to return the items without exposing the collection itself (exposing Lots() As Collection makes the AddLot member completely redundant).
As for the Brands collection itself, heed Tim Williams' advice and use a Dictionary data structure.
You can use a Scripting.Dictionary with Name as the key:
Sub test()
Dim MyBrand As Brand
Dim Brands As Object
Set Brands = CreateObject("scripting.dictionary")
Set MyBrand = New Brand
MyBrand.Name = "Company1"
MyBrand.AddProduct "Shoes"
MyBrand.Products("Shoes").AddLot "240502"
Brands.Add MyBrand.Name, MyBrand
MsgBox Brands("Company1").Products("Shoes").Lots(0)
End Sub

Create a loopable container class in VBA

I have been trying to clean up my code a bit and make it more similar to the Excel object model, and I was wondering if it is possible to create a "loopable" container class in VBA, e.g. similar to how you can do:
Dim Sheet As Worksheet
For Each Sheet In ThisWorkbook.Worksheets
' ...
Next Sheet
I want this functionality for my own container.
Say I create my own class called Container which contains items of some class ItemType (this can simply be an empty class for this example):
' Class Container
' The container contains items of a class I will call ItemType
Private Type MContainer
Items As Collection ' Could also be implemented in terms of an array
End Type
Private This As MContainer
Public Property Get Item(ByVal Index As Long) As ItemType
Attribute Item.VB_UserMemId = 0 'Makes it so I can access elements like a Collection
Set Item = This.Items(Index)
End Property
Public Function Add() As ItemType
This.Items.Add
Set Add = This.Items(This.Items.Count)
End Function
Private Sub Class_Initialize()
Set This.Items = New Collection
End Sub
I then want to loop through the items in my container with the For Each..., but this doesn't work. See the following example for how I ideally want it to work:
Public Sub MyMethod()
Dim Stuff As New Container
Stuff.Add
Dim Element As ItemType
For Each Element In Stuff ' <- This will not work
' Do something
Next Element
End Sub
The final For loop is what I am looking at making work. Is this possible? Basically the issue is that I can't call For Each on my Container class similar to how you can with e.g. the Excel.Sheets class. Is this possible to achieve in VBA?
For Each iteration requires a special member attribute value to work, and a NewEnum property or function returning an IUnknown.
Every collection class that can be iterated with a For Each loop has a hidden [_NewEnum] member (the square brackets are required for accessing the hidden member, since the underscore prefix is illegal for an identifier in VBA.
Tweaking module and member attributes isn't possible to do directly in the VBE, so you need to remove/export the module, modify it in e.g. Notepad++, save the changes, then re-import it into your project.
Or, have Rubberduck (disclaimer: I contribute to this open-source project) do it for you, using annotations (aka "magic comments"):
'#Enumerator
'#Description("Gets an enumerator that iterates through the internal object collection.")
Public Property Get NewEnum() As IUnknown
Set NewEnum = this.Items.[_NewEnum]
End Function
'#DefaultMember
'#Description("Gets/sets the element at the specified index.")
Public Property Get Item(ByVal index As Long) As ItemType
Set Item = this.Items(index)
End Property
Then parse the project (Ctrl+`) and bring up the Inspection Results toolwindow (Ctrl+Shift+i) - there should be a number of "Missing Attribute" results under "Rubberduck Opportunities":
Click "Fix all occurrences in module" in the bottom pane, to synchronize the hidden attributes with the annotation comments.
If you have "Missing Annotation" results, Rubberduck has determined that a module/member has a non-default value for a given attribute, and is able to similarly add an annotation comment that surfaces/documents it with a comment.
The Code Explorer (Ctrl+R), the Rubberduck toolbar, and the VBE's own Object Browser (F2) will display the contents of the VB_Description attribute, so #Description annotations are particularly useful to have on any public procedure.
Object Browser:
Code Explorer:
Rubberduck toolbar:
Add this to your class
Public Function NewEnum() As IUnknown
Attribute NewEnum.VB_UserMemId = -4
Set NewEnum = Items .[_NewEnum]
End Function
An alternative approach to this issue is not to use a Collection but a Scripting.Dictionary. One of the advantages of a scripting dictionary is that it can return arrays of the keys and items of the dictionary. Iterating over an array in VBA is a trivial exercise.

Use ObjPtr(Me) to return the Name of a Custom Class Instance?

I understand that ObjPtr will return the address of an object in memory and that it points to a structure called IUNKNOWN and that there is some kind of Interface definition encoded in that to expose the Object structure, but I couldn't figure out how to determine the interfaces for a VBA Custom Class Object and how to use that to return the Name property of an Object.
It's more "nice to have" than essential, but I just want to know the name of an object instance at run time so that I can include it in my trace messages.
Can anyone explain how to do this or, better yet direct me to a reference so I can figure it out?
EDIT
To re-state my aim:
To make a custom class objects that is able to figure out the name of its particular instance.
For example
Dim oObject1 as Class1, oObject2 as Class1
Set oObject1 = New Class1
Set oObject2 = New Class1
Debug.Print oObject1.instanceName & " " & oObject2.instanceName
In the immediate window:
oObject1 oObject2
Is this possible in VBA?
If VBA runtime has a Symbol Table - since it is interpretive I think maybe it does - and I had a way of exposing it, then I could make a Property Get procedure to access the symbol Table and search on the Address - ObjPtr(Me) - to return the semantic name of the instance of the class.
I'm pretty sure this is a dumb question but, hopefully, the process of realising its a dumb question is helpful to my understanding.
Example of a Symbol Table
Address Type Name
00000020 a T_BIT
00000040 a F_BIT
00000080 a I_BIT
20000004 t irqvec
20000008 t fiqvec
2000000c t InitReset
20000018 T _main
20000024 t End
Take NO for an answer. It's not possible to return an instance name as a String literal in VBA.
I still don't understand the reason you may want to do that... Anyway
The easiest way to know each instance code name would be to create a property for a class that stores the actual name. This would only expose the name as a String property and not an actual reference to the object - it already has a reference - itself!
So create a class module
Class1
Option Explicit
Public MyName as String
and in Module1 all it takes is
Option Explicit
Sub Main()
Dim c As Class1
Set c = New Class1
c.MyName = "c"
Debug.Print c.MyName
End Sub
And there you go :)
Another way would be to create a Dictionary to store both KEY/VALUE pairs.
Sub Main()
Dim c As Class1
Set c = New Class1
Dim dict As Object
Set dict = CreateObject("Scripting.Dictionary")
dict.Add "c", c
Debug.Print dict.Exists("c")
End Sub
Now, it's possible to actually do what you want but it would be a really ugly way. Here's how as I am not going to demonstrate.
You would create an instance of a custom class. Using ObjPtr you can get it's reference in memory. Then you would need a mechanism that scans your module code line by line and finds names of all variables you've dimensioned. Once you retrieve a list of all variables you would need a mechanism which tries to create an instance of the same type (class). Once you get past that point you could try to myNewObj = c ("c" would be the obj instance) programmatically. If that succeed then you would do ObjPt for both and match their addresses in memory - you get a match you know the variable name. Grree please do not do it that way :P
TypeName(obj) will return the Type of any variable in VBA:
Dim c as Class1
set c = new Class1
Debug.print TypeName(c) '==> "Class1"
FYI, I've also historically wanted to access the symbol table also. The idea was to get local variables from the previous scope by name. In that way you could make string interpolation:
a = "World"
Debug.Print StringInterp("Hello ${a}")
https://github.com/sancarn/VBA-STD-Library/blob/master/docs/VBAMemoryAnalysis.txt
https://github.com/sancarn/VBA-STD-Library/blob/master/docs/VBAMemoryAnalysis2.txt
No luck making a general function yet.

Excel VBA: Why "Sheets" collection is not a "Collection"?

A simple question on Excel VBA: If "Sheets" object is a collection, why the following sentence returns false? (entered at the Inmediate panel)
debug.print TypeOf Sheets Is Collection
Falso
I have discovered it because I made a function that takes a Collection as parameter. The function works with variables that I have declared as Collection, but doesn't work with other collections (such as Sheets collection).
Perhaps VBA is failing in inheritance and/or polymorphism?
Edit: As JosieP and mehow have explained, the Sheets collection doesn't inherits from the Collection class/interface, so it is not a Collection, and can't be used polymorphically as a Collection.
So now the question is: Why Sheets isn't a subclass of Collection? Why they haven't inherited from Collection? It should be a piece of cake, given the methods/properties already implemented into Sheets. As I can see, the Collection class/interface only requires 4 methods:
Add
Count
Item
Remove
And Sheets already implements 4 similar methods/properties:
Add
Count
Item
Delete
So the mapping would be trivial. They could have converted Sheets collection into a Collection easily.
Edit 2: I thank mehow for the suggestion on asking MS, but I won't do it, since they will probably say "it is not a design bug, it is a feature" :). More food for thinking: Run the following code in a new workbook:
Sheets.Add.name = "sh2"
Sheets.Add.name = "sh3"
Dim col As Collection
Set col = New Collection
col.Add Sheets(1)
col.Add Sheets(2)
col.Add Sheets(3)
For Each ele In col
Debug.Print "ele in col: " & ele.name
Next ele
For Each ele In Sheets
Debug.Print "ele in Sheets: " & ele.name
Next ele
It is strange to me that both "col" and "Sheets" can be iterated the same way, with a "for each" loop, but they don't share a common interface. If I had to design the classes, the hierarchy would be:
iIterable (interface)
|
|
iCollection (interface)
/ \
/ \
/ \
Collection cSheets (classes)
and Sheets would be an object (instance) of cSheets class.
In this way, the function I made (that currently takes a Collection parameter), would take a iCollection parameter, so it could work both with a Collection instance and with Sheets.
Sheets is a collection of Object type and not an object of Collection type.
Like JosieP said
the Sheets class does not inherit from the VBA Collection nor does it
implement a Collection interface (there is no Remove method for
example)
should be enough to understand.
MSDN reference
Sub Main()
Dim c As Collection
Set c = New Collection
Debug.Print VarType(c), TypeOf c Is Collection
Debug.Print VarType(Sheets), TypeOf Sheets Is Object
End Sub
To confirm open the Object Browser with F2 and type find Sheets
Address your second question to Microsoft. But bare in mind that Collection can be empty while the Sheets always needs to have at least one Sheets or Chart object.
Lots of things depend on the Sheets collection object in Excel that's why I think it had to be a separate class. I can't see nothing wrong by Implementing ICollection interface and providing a separate implementation but like I said ask Microsoft ( good question )
The for each loop is possible because each VBA class has attributes. Not all attributes are visible via the IDE but they do exist ( more of VB6 general knowledge than just reference to VBA ).
Ok, now to answer your third question. You can iterate over a collection because it implements IUnknown interface NewEnum() (enumarable method). You specify the default iterating object over a collection of your type ( in Sheets case its Sheets collection ).
See this link for a clearer explanation of the enumerable methods.
See this answer to better understand how the iteration is possible.

VBA Passing Object into another Objects Collection

I have a set of owners, who each have their own set of opportunities.
I have two class modules, ClmOpportunity which has a bunch of properties, and ClmOwner which has a single name property and a Collection storing ClmOpportunity Objects:
Public name As Variant
Private opps As New collection
Public Function addOpportunity(opp As ClmOpportunity)
opp.ID = opps.Count + 1
opps.Add opp, opps.Count + 1
End Function
These owner objects are also being stored in a collection in my main module. When I try to use the function addOpportunity as shown below:
Dim item As New ClmOpportunity
item.name = "test"
owners.item(overallOwner).addOpportunity (item)
I get the error:
"object doesn't support this property or method"
I am quite new to VBA and I don't understand why this is, I am passing in a ClmOpportunity, so it should be fine right?
Any help would be greatly appreciated!
You don't use parentheses if there's no return value...
owners.item(overallOwner).addOpportunity item
...then you'll get a "type mismatch" error because a collection expects a string value as a key, so you'll need to adjust your addOpportunity function (which should probably be a Sub if you don't intend adding a returned value)

Resources