John W. Colby
jwcolby at colbyconsulting.com
Sun Mar 28 13:38:37 CST 2004
Framework Troubleshooting unload problems As I have mentioned, potentially the biggest problem with programming objects is getting them all unloaded reliably. In the best case this is easy and everything just works. Unfortunately we all know that eventually we will run into the worst case. In our case, the worst case is a class that holds a pointer to a form. The class fails to unload, the form never truly closes, Access appears to close but doesnt, and a HUGE memory leak just occurred. Windows may refuse to close, particular the Win98 variants. Nasty stuff. How can this happen? As we know the framework uses a form class. The form class dimensions a form object WithEvents, a form object is passed in and saved in a variable in the class header. Each control class is passed a form object and stores the pointer in the control class header. We just set up the scenario. Now suppose some smart developer wants to sink events from dclsFrm. Yes, dclsFrm raises events to inform the native form class that things have occurred. So the developer dimensions a dclsFrm variable and grabs a pointer to the class from the forms variable. The new variable can be declared WithEvents, and sink the dclsFrm events which can be pretty useful. However, the user now tries to close the form. The form appears to close, however there is a pointer to the dclsFrm instance for that form that is not set to nothing, and voila, trouble! These kinds of problems are really not uncommon in class programming. Any object that broadcasts events can have pointers to it in multiple locations, all sinking those events. A class (or indeed any object) doesnt truly close (unload from memory) until the last variable holding a pointer to the object is set to nothing. Not clearing out that last instance pointer will absolutely prevent the object from unloading and may cause the nightmare scenario described in the second paragraph. The answer is not to avoid object programming, but to be aware of the potential problems and to build a tool to assist us in at least knowing that objects are still referenced when we believe they should be shut down. Pointers to objects (forms, controls and classes) can be stored in variables of type variant and object, in variables of the specific type of the object being stored, and in collections (which are if Im not mistaken just variables in a special stack like structure.). It would be convenient if we could pass a variable that we wanted a pointer to the class stored in to the class itself. In other words, tell the class to store a pointer to yourself in this variable. This would allow the class itself to then place a pointer to each such variable in a collection inside the class. Now the class could clean up EVERY pointer to itself just by iterating that collection and setting each variable = nothing, then removing the variable from its collection. Unfortunately this just isnt an option that can be enforced and without the enforcement it becomes more trouble than it is worth. The point of this discussion is to familiarize the reader with why the problem exists and how it really does become a problem, and that there is no easy solution. If there is no easy solution, then at least we need to know when objects are failing to unload. If the developer is aware, and uses some basic troubleshooting techniques on a regular basis as the application is being developed, we can stay ahead of the problem. Building a system that loads the framework with 30 or 40 class instances, plus a dozen forms with several hundred class instances, and discovering that there are 70 class instances not unloading can cause a mild panic. However if we have some method of knowing every single instance loaded, we can load / unload objects and watch the object count left in memory, print out object name lists etc and discover where things are not unloaded. Knowing that, we can then go find the problem and solve it. Thus we might build a test for the SysVar class (for example) where we load it all by itself, without the framework. Make absolutely sure that all class instances loaded by the SysVar system unload correctly before we allow the Framework class to automatically load / unload the SysVar class. Even then, we would want to load the framework, then unload it and examine the class instance stack to see what is left loaded. Dont go on until you can load / unload the framework and leave nothing in memory. Of course in order to do this we need a tool. In my previous versions of the framework I used a module with a set of private variables, a handful of collections and code to increment / decrement counts and store class instance names. While this worked, it had issues. In order to address these issues I am designing a class (of course) with all of the structures and code required to allow us to track classes loading and unloading. This class will hold an actual pointer to every class instance. I do this for a variety of reasons, first of which is that the variable holding the original pointer to the class may be difficult to find unless we can get at the class instance itself. Just knowing the name of the class instance is often unhelpful. The first thing we need to do is allow every class instance to register itself as it loads and unregister itself as it unloads. It turns out to be easy to do this, just call a register function in Init() and an unregister function in Term(). We could have the class call a function passing a pointer to itself (the class instance) and have that function save the pointer into a stack. This is actually quite useful since we can now get at the class instance and run methods, query properties etc. However if you think about it for a minute, the Terminate() event of a class does not fire until the class unloads, thus if we try to save an actual pointer to the class we end up in a catch 22 where just setting the original pointer to the class to nothing doesnt cause the class to unload since we have our saved pointer. Calling the class term and having the class unload its saved pointer works just fine, but then if the original pointer is never set to nothing, the class never unloads. Further if there are other pointers to the class out there the class never unloads. However we think it unloaded because the pointer isnt in the instance stack any more. For this reason we cant simply use a class instance pointer stack by itself. In fact, if we do use an instance stack, then we MUST call the class term() before setting the class variable to nothing or the class won t unload for the reason discussed above. In fact, while I like the instance stack (collection of pointers to each instance) it is often not strictly necessary. If you dont want it we will provide a method of not using that piece using a SysVar to turn on/off the instance stack. So even if we are going to use an instance pointer stack, we also need another tool to determine whether we really truly unloaded the class instance. Since we know that the class Terminate() event will not run unless it is specifically called inside the class (dont do that!) or the class actually unloads we can build another stack of the class instance names and have the Terminate() event remove its instance name from this stack. Thus we have the very useful tool of a stack of actual pointers to every class instance (that we havent called the term() method of), and we have a backup tool of a stack of class instance names. The stack of names will only be cleared out by the class instances unloading, thus it is the failsafe that tells us when we have indeed unloaded everything. If you followed this whole discussion, go to the head of the class. What were really saying is that we want to build a collection of pointers to class instances, but also a collection of just the names of the instances. The collection of the names will be the final judge of what got unloaded since the only way to remove a name from that collection is the Terminate of a given instance removing it. If we expose the count of these two collections, and the count differs, we have problems. We tried to unload something, but failed to really set the last pointer to it to nothing. The pointer to it was removed from the pointer collection but not from the name collection. Is all this really necessary? Well, yes. We can get along without the pointer collection, it is really a nicety. The collection of instance names however is a must it tells the truth about what doesnt unload and we really want to know that! Implementation To implement the system for tracking, I built clsInstanceStack that will be instantiated one time, before the framework class. The class has all of the default properties and methods of any other class (uses clsTemplate). This class must be initialized and ready to go before anything else so that all other classes can use it to register themselves. Framework Initialization code: In the header of basFWInit we dimension a new variable mclsInstanceStack. Option Compare Database Option Explicit Private mclsInstanceStack As clsInstanceStack 'A single instance of the troubleshooting class. Private mclsFramework As clsFramework 'The framework foundation class Private mblnFWInitialized As Boolean 'A boolean to tell us that we have already initialized In FWInit we instantiate and initialize the new class first. ' 'The init function for the framework ' Public Function FWInit() If mblnFWInitialized = False Then ' mblnFWInitialized = True Randomize Set mclsInstanceStack = New clsInstanceStack mclsInstanceStack.Init Nothing Set mclsFramework = New clsFramework mclsFramework.Init Nothing End If End Function In FWTerm we terminate the new class last so that all other classes have unloaded before this one will. ' 'The term function for the framework ' Public Function FWTerm() mclsFramework.Term Set mclsFramework = Nothing mclsInstanceStack.Term Set mclsInstanceStack = Nothing mblnFWInitialized = False End Function We also need a random number generator that we can use to generate random numbers of a fixed length. ' 'Returns a long integer random number between lngUpperBound and lngLowerBound ' Function Random(lngUpperBound As Long, lngLowerBound As Long) As Long Random = Int((lngUpperBound - lngLowerBound + 1) * Rnd + lngLowerBound) End Function And we need a function to call that will empty collections of objects, calling the objects Term event. There will be cases where we have collections of object pointers that dont belong to the class holding the pointer, thus we need to be able to call the objects term or not as the situation requires. ' 'Empties out a collection containing class instances ' Public Function ColEmpty(col As Collection, Optional blnCallTerm As Boolean = True) On Error GoTo Err_ColEmpty On Error Resume Next While col.Count > 0 'If we are supposed to call term() do so If blnCallTerm Then col(1).Term End If On Error GoTo Err_ColEmpty col.Remove 1 Wend exit_ColEmpty: Exit Function Err_ColEmpty: Select Case Err Case 91 'Collection empty Resume exit_ColEmpty Case Else MsgBox Err.Description, , "Error in Function clsSysVars.colEmpty" Resume exit_ColEmpty End Select Resume 0 '.FOR TROUBLESHOOTING End Function And of course we need a function to return a pointer to the troubleshooting class. Public Function cIS() As clsInstanceStack Set cIS = mclsInstanceStack End Function This is all code found in basFWInit. clsInstanceStack: The instance stack class header has two collections for holding the name and a pointer to the class. 'A name for the class module Private Const mcstrModuleName As String = "clsInstanceStack" 'A name for the class instance Private mstrInstanceName As String 'Collections for the name and the class pointers Private mcolName As Collection 'Holds name string for each class loaded Private mcolPtr As Collection 'Holds a pointer to every class instantiated 'We want to be able to turn off this logging Dim blnEnblPtrStack As Boolean Dim blnEnblNameStack As Boolean Initialize sets these two collections. Private Sub Class_Initialize() Set mcolName = New Collection Set mcolPtr = New Collection End Sub Init() registers this class in its own collections. Public Sub Init(ByRef robjParent As Object) blnEnblPtrStack = True blnEnblNameStack = True 'Register this class in the troubleshooting collections cIS.Register Me End Sub Terminate() removes the name from the name collection. If this happens, then we really have properly removed the instance from memory. Private Sub Class_Terminate() 'Remove this class' name from the troubleshooting class cIS.NameDel mstrInstanceName End Sub Term() removes the pointer to the class from the pointer collection. This method is called from the owner of the class when it is trying to shut down the class instance. Public Sub Term() Static blnRan As Boolean 'The term may run more than once so 'remove this class' pointer from the troubleshooting pointer class cIS.PtrDel Me End Sub A property allows getting and setting the InstanceName variable in the class header. Public Property Get NameInstance() As String NameInstance = mstrInstanceName End Property Public Property Let NameInstance(strName As String) mstrInstanceName = strName End Property The code below is really the guts of this troubleshooting class. First we need to be able to get counts of each collection. ' 'Gets a count of pointers of classes instantiated Public Property Get PtrCnt() As Long PtrCnt = mcolPtr.Count End Property 'Gets a count of names of classes instantiated Property Get NameCnt() As Long NameCnt = mcolName.Count End Property The two methods called from each classes Term() or Terminate(). ' 'Removes a class from the collection 'Called from each class' Term() ' Public Function PtrDel(obj As Object) On Error Resume Next mcolPtr.Remove (obj.NameInstance) End Function ' 'Removes a class name from the collection 'Called from each class' Terminate() event ' Public Function NameDel(strName As String) On Error Resume Next 'We may have the name stack disabled mcolName.Remove strName End Function If you ever want to get a pointer to a specific class that is loaded. 'return a pointer to the object Public Function Ptr(strName As String) As Object On Error Resume Next 'We may have the pointer stack turned off Set Ptr = mcolPtr(strName) End Function These two functions return strings of the names of all the objects stored in the collection. Public Function PtrNames() As String On Error GoTo Err_PtrNames Dim obj As Object Dim str As String If blnEnblPtrStack Then For Each obj In mcolPtr If Len(str) > 0 Then str = str & "; " & vbCrLf & obj.NameInstance Else str = obj.NameInstance End If Next obj End If PtrNames = str Exit_PtrNames: Exit Function Err_PtrNames: MsgBox Err.Description, , "Error in Function basClassGlobal.PtrNames" Resume Exit_PtrNames Resume 0 '.FOR TROUBLESHOOTING End Function Public Function Names() As String On Error GoTo Err_Names Dim str As String Dim strName As Variant If blnEnblNameStack Then For Each strName In mcolName If Len(str) > 0 Then str = str & "; " & vbCrLf & strName Else str = strName End If Next strName End If Names = str Exit_Names: Exit Function Err_Names: MsgBox Err.Description, , "Error in Function clsInstanceStack.Names" Resume Exit_Names Resume 0 '.FOR TROUBLESHOOTING End Function Below is the Register function that saves the class pointers and names in the respective collection. Note: We want to be able to switch this stuff on and off by using SysVars but the SysVar class is a Service Class loaded by the Framework class. Thus since this loads first SysVars arent directly available to control this functionality. We therefore use Booleans that we just set true in the init() of this class. Further we then provide two methods that can set these Booleans, which will be used by the Framework class to turn on/off this troubleshooting processing using SysVars. Public Property Let EnblPtrStack(lblnEnblPtrStack As Boolean) blnEnblPtrStack = lblnEnblPtrStack End Property Public Property Let EnblNameStack(lblnEnblNameStack As Boolean) blnEnblNameStack = lblnEnblNameStack End Property What that means though is that the functionality cannot be turned off until the Framework loads and the SysVar classes load, thus all of those classes will load into the collections even if the SysVars later turn off this functionality. If you want to prevent loading the Framework class and SysVars into this troubleshooting class you need to set the Booleans = FALSE in init() of this class. You can then turn on the logging for all classes after the SysVars load. The downside is that you cant see any service classes load that the framework class may load. 'THIS FUNCTION GENERATES A UNIQUE NAME FOR THIS INSTANCE OF THIS CLASS USING THE 'CLASS MODULE NAME AND A RANDOM VALUE AND LOGS THIS INSTANCE IN THE TWO COLLECTIONS 'THE POINTER COLLECTION AND THE NAME COLLECTION. ' 'This function will check to see if the instance name is already in place, i.e. 'if the calling class has its own method of generating an instance name. If it 'is in place (length > 0) then we will attempt to save the name in the InstanceName 'in the name collection keyed on the instance name. If it saves we will then save 'the pointer in the pointer collection, also keyed on the instance name. ' 'If it fails to save, or if the len(InstanceName) = 0 then this function will build 'a name for the class instance. ' 'The algorithm for building a new name will be to take the parent's instance name '(if it exists), add the instances MODULE name, and try to save that. If the save 'fails, then a fixed length 9 digit random number is added to the whole and attempted 'to save. ' Public Function Register(obj As Object) On Error GoTo Err_Register Dim strInstanceName As String If blnEnblNameStack Or blnEnblPtrStack Then 'if the instance name is not already initialized by the calling class, attempt 'to build an instance name from the parent lineage plus module name If Len(obj.NameInstance) = 0 Then On Error Resume Next strInstanceName = obj.Parent.NameInstance & ":" & obj.NameModule 'if the err <> 0 then the parent did not exist so just go with the object 'module name. If Err <> 0 Then strInstanceName = obj.NameModule End If obj.NameInstance = strInstanceName Else strInstanceName = obj.NameInstance End If 'attempt to add the ame of the object to the name collection On Error Resume Next mcolName.Add item:=strInstanceName, key:=strInstanceName 'if the err <> 0 then the name collided with an existing object so add a random number 'it is remotely possible to get a random number that collides as well so loop if that 'happens and select another random number While Err <> 0 Err.Clear strInstanceName = obj.NameInstance & ":" & Random(999999, 100000) mcolName.Add item:=strInstanceName, key:=strInstanceName If Err <> 0 Then MsgBox Error$ Wend 'When we finally have a unique instance name (it stores in the name collection) If blnEnblNameStack Then 'store that instance name back into the object InstanceName proeprty. obj.NameInstance = strInstanceName End If If blnEnblPtrStack Then 'Now add the object into the framework object tracking system mcolPtr.Add obj, strInstanceName End If End If Exit_Register: On Error Resume Next Exit Function Err_Register: Select Case Err Case 0 'insert Errors you wish to ignore here Resume Next ' Case 91 'no parent exists ' PtrAdd obj ' Resume Exit_Register ' Case 2465 'no child collection exists in the parent object ' PtrAdd obj ' Resume Exit_Register Case Else 'All other errors will trap Beep MsgBox Err.Description, , "Error in function basClassGlobal.Register" Resume Exit_Register End Select Resume 0 'FOR TROUBLESHOOTING End Function That is really all there is to the troubleshooting class, two collections to hold name strings and pointers to the classes loading and a few methods to register the classes (if enabled) and return strings of names and counts of the collections. Framework code: Having done that we need to modify the framework class to read the sysvars and pass them along to this class. We must load the SysVar class and initialize it, then we can read the two Enbl SysVars out and pass them to the methods of the cIS troubleshooting class. Public Sub Init(ByRef robjParent As Object) mclsSV.Init Nothing, gfwcnn, "usystblFWSysVars" cIS.EnblPtrStack = SV("EnblPtrStack") cIS.EnblNameStack = SV("EnblNameStack") End Sub Generic class code: In order to use this troubleshooting system any class must have some specific things. First it must have a constant that is the class module name. It must also have a string that will hold the instance name. In the class header: Private Const mcstrModuleName As String = "dclsFrm" Private mstrInstanceName As String The class terminate() event remove its own name from the name collection. Private Sub Class_Terminate() 'Remove this class' name from the troubleshooting class cIS.NameDel mstrInstanceName End Sub The Init() method must call the register method to place its name in the name collection and its pointer in the pointer collection (if enabled by the SysVars). Public Sub Init(ByRef robjParent As Object, lfrm As Form) 'Register this class in the troubleshooting collections cIS.Register Me End Sub Term() must call the method that removes the class pointer from the pointer collection. Public Sub Term() cIS.PtrDel Me End Sub There must be a property to read the module name. Property Get NameModule() As String NameModule = mcstrModuleName End Property And we need a property to read and set the InstanceName. Public Property Get NameInstance() As String NameInstance = mstrInstanceName End Property Public Property Let NameInstance(strName As String) mstrInstanceName = strName End Property Thats pretty much it. As a class instantiates its Init() method calls the Register method of the troubleshooting class, passing a pointer to itself. If the storage of the name and /or pointer are enabled by the SysVars, Register will try to save the name and/or pointer keyed on the name. If that fails Register() will add a random number to the end of the name and try again until the save works. It is possible to build your own InstanceName if you wish. I do this in the control classes by taking the parent objects instance name (the forms instance name) adding a : and then the name of the control. Function Init(ByRef robjParent As Object, lfrm As Form, lcbo As ComboBox, lintDataType As Integer) 'Pass in a pointer to a specific control Set mfrm = lfrm Set mcbo = lcbo 'Save that pointer to a private variable here in the class mstrInstanceName = mobjParent.NameInstance & ":" & mcbo.Name 'Register this class in the troubleshooting collections cIS.Register Me End Function In the case where the form only loads one time this works without modification and I get names that just make sense such as frmPeopleV5:txtDOB. If the form is loaded more than once, not common but possible, then the form class would end up with a random number embedded in it and the same control would have a name something like frmPeopleV5:832735:txtDOB. Obviously two controls on the same form can never have the same name so we never have an issue there. It is often helpful when it comes time to troubleshoot things to have names that make sense if you can ensure that they will be unique. You really don t need to worry however as the Register() method will add a random number as needed to force the name to be unique, and then saves that name back into the class InstanceName variable. To test the system: Open frmTestClsInstanceStack. The name collection will be displayed with all of the names of all classes loaded so far, including classes for the form and control on this form. Go to the database window and load another form, perhaps frmPeopleV5. Switch back to frmTestClsInstanceStack and press the Read class names button at the bottom of the form. Notice more class names in the text box. Close that form you just opened, switch back to frmTestClsInstanceStack and press Read Class Names. Notice that the class names for all classes loaded by the form you just closed are now gone. Summary Classes can cause memory leaks and shutdown problems in Access if not unloaded correctly, particularly if the class holds a pointer to a physical object such as a form, control or record set. Since the whole point of many of our classes is to wrap such physical objects in a class so that we can extend the functionality of that object, we run a very real risk of causing such problems inadvertently. The troubleshooting class that I have described gives us some tools to troubleshoot these problems should they arise. As you design your application, or even your framework, it is imperative that you stop after designing every class and test the load and unload of that class and all its instances. By looking at the count of the names collection and the pointer collection as well as the actual names of the objects still in the names collection after we think our classes have unloaded, we can determine if problems exist, and if so at least which classes have not unloaded correctly. Using the system described in this article, if the name is in the name collection then the object didnt unload. I cant really help you discover why it didnt unload, but I can at least tell you that it didnt so you can go looking for the cause. John W. Colby www.ColbyConsulting.com