I have created a class where its children hold a necessary reference to their parent. This means, when the parent class goes out of scope in the routine which calls it, its terminate code is not called, as the children still hold a reference to it.
Is there a way of terminating the parent without having to manually terminate the children first too?
Current work around is to make the parent's terminate code (which contains code to terminate the children) public and to call it from the routine, but this is not ideal as then the parent is terminated twice (once manually, once when it leaves the caller sub's scope). But mainly I don't like having to call it manually
'Caller code
Sub runTest()
Dim testClass As parentClass
Set testClass = New parentClass
Set testClass = Nothing
End Sub
'parentClass
Private childrenGroup As New Collection
Private Sub Class_Initialize()
Dim childA As New childClass
Dim childB As New childClass
childA.Setup "childA", Me 'give name and parent reference to child class
childB.Setup "childB", Me
childrenGroup.Add childA 'add children to collection
childrenGroup.Add childB
End Sub
Public Sub Class_Terminate()
Set childrenGroup = Nothing
Debug.Print "Parent terminated"
End Sub
'childClass
Private className As String
Private parent As classParent
Public Sub Setup(itemName As String, parentObj As classParent)
Set parent = parentObj 'set the reference
className = itemName
End Sub
Public Property Get Name() As String
Name = className
End Property
Private Sub Class_Terminate()
Debug.Print Name;" was terminated" 'only called when parent terminates child
End Sub
Calling runTest() prints
childA was terminated
childB was terminated
Parent terminated
Parent terminated 'to get around this, you could just make another sub in the parent class
'to terminate its children
'but that still requires a manual call
Having reviewed your comments, I'm still not convinced you need to pass the parent into the child class. If your only reason for doing so is to create a kind of callback then you'd probably be better off passing an event delegate class to the child instead of the parent class and then simply handle the delegate's events in your parent class. As you've seen, your current structure is causing some object disposal issues and these can be avoided.
Simply create a class containing your child events. In this example I've called the class clsChildEvents:
Option Explicit
Public Event NameChange(obj As clsChild)
Public Sub NotifyNameChange(obj As clsChild)
RaiseEvent NameChange(obj)
End Sub
Now your code remains pretty much as is. The key difference is that you are passing the delegate instead of the parent to the child objects.
Parent class:
Option Explicit
Private WithEvents mChildEvents As clsChildEvents
Private mChildren As Collection
Public Sub SetUp()
Dim child As clsChild
Set child = New clsChild
child.SetUp "ChildA", mChildEvents
mChildren.Add child
Set child = New clsChild
child.SetUp "ChildB", mChildEvents
mChildren.Add child
End Sub
Private Sub mChildEvents_NameChange(obj As clsChild)
Debug.Print "New name for "; obj.Name
End Sub
Private Sub Class_Initialize()
Set mChildEvents = New clsChildEvents
Set mChildren = New Collection
End Sub
Private Sub Class_Terminate()
Debug.Print "Parent terminated."
End Sub
Child class:
Option Explicit
Private mClassName As String
Private mEvents As clsChildEvents
Public Sub SetUp(className As String, delegate As clsChildEvents)
Set mEvents = delegate
Me.Name = className
End Sub
Public Property Get Name() As String
Name = mClassName
End Property
Public Property Let Name(RHS As String)
mClassName = RHS
mEvents.NotifyNameChange Me
End Property
Private Sub Class_Terminate()
Debug.Print mClassName; " terminated."
End Sub
And then your module code:
Option Explicit
Public Sub Test()
Dim parent As clsParent
Set parent = New clsParent
parent.SetUp
Set parent = Nothing
End Sub
The immediate window output is as follows:
New name for ChildA
New name for ChildB
Parent terminated.
ChildA terminated.
ChildB terminated.
No, you can't automatically terminate the children by simply terminating the collection:
Public Sub Class_Terminate()
Set childrenGroup = Nothing
The Collection object does not clean up any object references when it is set equal to Nothing, and it doesn't have a 'RemoveAll' mechanism which does.
...And I wouldn't bet on 'Remove' doing that either, one item at a time. So you do indeed need to loop backwards through the members, in exactly the 'manual' process you're trying to avoid:
Public Sub Class_Terminate()
Dim i As Integer
For i = childrenGroup.Count To 1 Step - 1
Set childrenGroup(i) = Nothing
childrenGroup.Remove i
Next I
Set childrenGroup = Nothing
My advice would be: use a Scripting.Dictionary object instead of the VBA Collection:
Private childrenGroup As New Scripting.Dictionary
You'll need a reference to the Scripting Runtime (or to the Windows Scripting Host Object Model) and you may be surprised by the changed order of .Add Item, Key - and you will definitely be surprised by what happens when you request an item with a non-existent key.
Nevertheless, this works. The Dictionary does have a RemoveAll method, and this will clear all the references in its .Items when you call it:
Public Sub Class_Terminate()
childrenGroup.RemoveAll
You do need to call RemoveAll - it doesn't happen automatically if the dictionary goes out of scope - but that's as close as you'll get.
Note, also, that VBA runs on reference counting: if anything else has a reference to the children, the objects won't be released.
Related
I'm trying to loop a collection. I can expand and see "the elements" inside VBE but get the error:
"Object doesn't support this property or method".
In a standard module I have
Public collItems As Collection
The collection is populated inside a userform module on initialization
collItems.add cItems
cItems is an object made from New clsItems, which is the class module.
The class module consists of many userform controls like this:
Private WithEvents frm As msforms.Frame
Private WithEvents lbl As msforms.Label
Private WithEvents cmd As msforms.CommandButton
Private WithEvents txt1 As msforms.TextBox
Private WithEvents txt2 As msforms.TextBox
+++
Not sure if Private is the way to go on this.
When the userform finishes loading, a dynamic number of frames with all these textboxes inside each frame appears. It looks like a spreadsheet made from data inside MS Project. The command buttons job is to change a lot of the textbox's attributes/properties.
I want to loop the collItems collection but then I get an error.
I don't have any get or let in my class, just a single set property.
It might look stupid to add 10 unique textboxes inside the class, but that's how I made it work so far. All the form objects are given names that refer to row and column during creation and helps me identify them.
The failing code looks like this:
Sub changeBox(ByRef name As String)
For Each item in collItems.item(CLng(Replace(name, "cmd", "")))
'blabla
Next item
End Sub
This test works and shows all the elements I want to loop:
Set test = collItems.item(3) 'Meaning row 3 in userform
How do I loop my specific textboxes and change their attributes?
So now I want to loop the collItems collection and write my code. But then I get an error...
The collection holds a sequence of objects of type cItems. Thus to loop the collection items, declare an object of this type to be used as the iterator.
Note, the class members must be publicly exposed in order to be accessible outside the cItems class. Not sure how you instantiate them, but they could be exposed through a public read-only property.
Public Property Get Form() As msforms.Frame
Set Form = frm
End property
To loop:
Dim objCurrent As cItems, frm As msforms.Frame
For Each objCurrent in collItems
'here you can use the objCurrent to access the current item.
Set frm = objCurrent.Form
'...
Next objCurrent
To be able to get a control by name, create a dictionary in the cItems class to store the controls and use the key to retrieve the objects.
See an example below:
'dictionary
Private m_boxes As Object
Public Sub AddTextBox(ByVal Key As String, ByVal obj As msforms.TextBox)
m_boxes.Add Key, obj
End Sub
Public Function GetTextBox(ByVal Key As String) As msforms.TextBox
Set GetTextBox = m_boxes(Key)
End Function
Private Sub Class_Initialize()
Set m_boxes = CreateObject("Scripting.Dictionary")
End Sub
Private Sub Class_Terminate()
Set m_boxes = Nothing
End Sub
The to call it:
Dim txt As msforms.TextBox
Set txt = objCurrent.GetTextBox("txt1")
I have example clsChild in VBA which have nothing than 1 var and 1 event
Private V#
Public Event Change()
Public Property Get Value()
Value = V
End Property
Public Property Let Value(inp#)
V = inp
RaiseEvent Change
End Property
then I create another class called clsParent, which have multi instances of clsChild:
Public ListofChild()
Public Event ParentChange()
Private Sub Class_Initialize()
redim Listofchild(1 to 3)
set listofchild(1) = new clsChild
set listofchild(2) = new clsChild
set listofchild(3) = new clsChild
End Sub
How could I raise parentChange event when 1 of those instances of clsChild change (known by change event of clsChild)?
You need a WithEvents reference to each child object in your parent class in order to be able to capture and handle its events.
Public Event ParentChange()
Private WithEvents objChild As clsChild
Private Sub Class_Initialize()
Set objChild = new clsChild
End Sub
'EventHandler
Private Sub objChild_Change()
RaiseEvent ParentChange
Debug.Print "Change event handled."
End Sub
WithEvents can only be declared at module level in Class modules.
Once you prefix an object with the WithEvents keyword, the object's events are listed in the events combo in VBE. Prefixing an object with the WithEvents keyword while the object has no events, a compile error occurs.
You can find plenty of resources online. See another example here.
I was wondering if there's a way in which I can share variables between instances of separate class modules?
I have two classes:
Class 1
Class 2
Inside class 1, I have multiple global variables which I would like Class 2 to have access to once instantiated.
I could use get and set properties for each of the variables but I have about 40/50 so it just seems a bit tedious.
So, instead, I'm trying to pass the current instance of Class 1 to Class 2 using set property.
I've created a minimal example to illustrate my current efforts:
Class 1:
Public test As String
Private Sub Class_Initialize()
Call setTest
Dim b As Class2
Set b = New Class2
End Sub
Public Property Set Classed(ByRef vClass As Class1)
Set vClass = Me
End Property
Public Sub setTest(t As String)
test = "Sam"
End Sub
Class 2:
Private Sub Class_Initialize()
Dim newClass As Class1
newClass.Classed = newClass
' Want to be able to access the test String from class 1
End Sub
Obviously what I am doing at the moment is incorrect, so am wondering if someone could point out where I'm going wrong and show me how to achieve this class sharing?
Just to add: when running the code, I receive a compile error at line: newClass.Classed = newClass. Error: Invalid use of property
Not too sure but I sense a bit of a Circular Reference in your example?
What Are Circular References?
A circular reference occurs when two objects hold references to each other.
You could try an alternative by exposing a Dictionary object through your class, where the Key will be your "variable name", and the Value will hold the actual value.
An example could be:
Class1
Option Explicit
Private mList As Object
Public Property Get List() As Object
Set List = mList
End Property
Private Sub Class_Initialize()
Set mList = CreateObject("Scripting.Dictionary")
End Sub
Private Sub Class_Terminate()
Set mList = Nothing
End Sub
Implementation:
Sub ClassTest()
Dim a As Class1
Set a = New Class1
Dim b As Class1
Set b = New Class1
a.List("VarName") = "Sam" 'Set
b.List("VarName") = a.List("VarName") 'Get / Set
Debug.Print b.List("VarName") 'Get
Set a = Nothing
Set b = Nothing
End Sub
'Output
'Sam
Say I have an object, Email, one of whose properties is an object called EmailSkinner.
The EmailSkinner is instantiated in the class_initialize subroutine like this.
private sub class_initialize()
set EmailSkinner = new MyEmailSkinner
end sub
Must I explicitly set the EmailSkinner object to nothing in the class_terminate subroutine of Email?
private sub class_terminate()
set EmailSkinner = nothing
end sub
Or does this happen automatically when I set the Email object itself to nothing?
This is an interesting question. Your assumption is correct any object's you instantiate inside the scope of the parent class will be released when the parent class is released from memory.
However as with all object instantiation in VBScript (and by extension Classic ASP) there is nothing wrong with explicitly releasing objects using the Class_Terminate event.
Remember though that "scope" is important here.
If your EmailSkinner object reference is declared outside of the parent class (regardless of whether it is instantiated inside the class) the reference will remain and will require Class_Terminate() to force the object reference to be released.
Examples
Object Reference is declared inside Class scope.
Class ParentObject
Private _ChildObject
Private Sub Class_Initialize()
Set _Object = new ChildObject()
End Sub
End Class
Object Reference is declared outside Class scope (wouldn't recommend this approach).
Dim GlobalObject
Class ParentObject
Private Sub Class_Initialize()
Set GlobalObject = new ChildObject()
End Sub
'GlobalObject reference will remain so we need to
'force it to be released.
Private Sub Class_Terminate()
Set GlobalObject = Nothing
End Sub
End Class
By default, Class objects are auto destroyed, but if you create new objects outside, you will need to release them from memory .
Is always recommended that we clean memory in all scenarios .
I made a small piece of code for you to test ( I hope this would be similar to what you are trying to explain, since you didn't show us your code ) .
This code help us to check if something remains in memory after some steps of execution and declaration ( just take out the apostrophes at bottom to test the code ) :
Class EmailSkinner
public color
public size
Private Sub Class_Initialize
color = "blue"
size = 300
End Sub
End Class
Class Email
public details
public name
Private Sub Class_Initialize
Set details = New EmailSkinner '//Module Scope
End Sub
Private Sub Class_Terminate
Set details = Nothing
End Sub
End Class
Set email1 = New Email '//Global Scope
With email1
.details.color = "black"
.details.size = 400
End With
''//Take out the apostrophe to test one of the next lines
'Response.Write email1.details.color '//ASP only
'wscript.echo email1.details.color '//Wscript only
Set email1 = Nothing
If an object is contained within a collection, can that object still raise events to a parent class?
Clearly you could tell the child class a reference to the parent class, and then call a public method within the parent class within the child class, however that would result in a circular reference, which as I understand it would make it so the garbage collector would not ever get rid of either object.
Details:
I have two classes, one a person named clsPerson, and the second a custom collection class named clsPeople. clsPerson has a public boolean property named Selected. If selected is changed, I call an event SelectedChange. At that point, I need to do something in clsPeople. How can I trap the event in the custom collection class clsPeople? The person class can be changed from outside of the scope of People, otherwise I would look at another solution.
<<Class clsPerson>>
Private pSelected as boolean
Public Event SelectedChange()
Public Property Let Selected (newVal as boolean)
pSelected = newVal
RaiseEvent SelectedChange
End Property
Public Property Get Selected as boolean
Selected = pSelected
End Property
<<Class clsPeople>>
Private colPeople as Collection
' Item set as default interface by editing vba source code files
Public Property Get Item(Index As Variant) As clsPerson
Set Item = colPeople.Item(Index)
End Property
' New Enum set to -4 to enable for ... each to work
Public Property Get NewEnum() As IUnknown
Set NewEnum = colPeople.[_NewEnum]
End Property
' If selected changes on a person, do something
Public Sub ???_SelectedChange
' Do Stuff
End Sub
You can easily raise an event from a class in a collection, the problem is that there's no direct way for another class to receive events from multiples of the same class.
The way that your clsPeople would normally receive the event would be like this:
Dim WithEvents aPerson As clsPerson
Public Sub AddPerson(p As clsPerson)
Set aPerson = p ' this automagically registers p to the aPerson event-handler `
End Sub
Public Sub aPerson_SelectedChange
...
End Sub
So setting an object into any variable declared WithEvents automatically registers it so that it's events will be received by that variable's event handlers. Unfortunately, a variable can only hold one object at a time, so any previous object in that variable also gets automatically de-registered.
The solution to this (while still avoiding the problems of reference cycles in COM) is to use a shared delegate for this.
So you make a class like this:
<<Class clsPersonsDelegate>>
Public Event SelectedChange
Public Sub Raise_SelectedChange
RaiseEvent SelectedChange
End Sub
Now instead of raising their own event or all calling their parent (making a reference cycle), you have them all call the SelectedChange sub in a single instance of the delegate class. And you have the parent/collection class receive events from this single delegate object.
The Details
There are a lot of technical details to work out for various cases, depending on how you may use this approach, but here are the main ones:
Don't have the child objects (Person) create the delegate. Have the parent/container object (People) create the single delegate and then pass it to each child as they are added to the collection. The child would then assign it to a local object variable, whose methods it can then call later.
Typically, you will want to know which member of your collection raised the event, so add a parameter of type clsPerson to the delegate Sub and the Event. Then when the delegate Sub is called, the Person object should pass a reference to itself through this parameter, and the delegate should also pass it along to the parent through the Event. This does not cause reference-cycle problems so long as the delegate does not save a local copy of it.
If you have more events that you want the parent to receive, just add more Subs and more matching Events to the same delegate class.
Example Implementation
Responding to requests for a more concrete example of "Have the parent/container object (People) create the single delegate and then pass it to each child as they are added to the collection."
Here's our delegate class. Notice that I've added the parameter for the calling child object to the method and the event.
<<Class clsPersonsDelegate>>
Public Event SelectedChange(obj As clsPerson)
Public Sub RaiseSelectedChange(obj As clsPerson)
RaiseEvent SelectedChange(obj)
End Sub
Here's our child class (Person). I have replaced the original event, with a public variable to hold the delegate. I have also replaced the RaiseEvent with a call to the delegate's method for that event, passing along an object pointer to itself.
<<Class clsPerson>>
Private pSelected as boolean
'Public Event SelectedChange()'
' Instead of Raising an Event, we will use a delegate'
Public colDelegate As clsPersonsDelegate
Public Property Let Selected (newVal as boolean)
pSelected = newVal
'RaiseEvent SelectedChange'
colDelegate.RaiseSelectedChange(Me)
End Property
Public Property Get Selected as boolean
Selected = pSelected
End Property
And here's our parent/custom-collection class (People). I have added the delegate as an object vairable WithEvents (it should be created at the same time as the collection). I have also added an example Add method that shows setting the child objects delegate property when you add (or create) it to the collection. You should also have a corresponding Set item.colDelegate = Nothing when it is removed from the collection.
<<Class clsPeople>>
Private colPeople as Collection
Private WithEvents colDelegate as clsPersonsDelegate
Private Sub Class_Initialize()
Set colPeople = New Collection
Set colDelegate = New clsPersonsDelegate
End Sub
' Item set as default interface by editing vba source code files'
Public Property Get Item(Index As Variant) As clsPerson
Set Item = colPeople.Item(Index)
End Property
' New Enum set to -4 to enable for ... each to work'
Public Property Get NewEnum() As IUnknown
Set NewEnum = colPeople.[_NewEnum]
End Property
' If selected changes on any person in our collection, do something'
Public Sub colDelegate_SelectedChange(objPerson as clsPerson)
' Do Stuff with objPerson, (just don't make a permanent local copy)'
End Sub
' Add an item to our collection '
Public Sub Add(ExistingItem As clsPerson)
Set ExistingItem.colDelegate = colDelegate
colPeople.Add ExistingItem
' ... '
End Sub