12
\$\begingroup\$

VBA has a call stack... but there's no programmatic way to tap into it, which means in order to get a stack trace for a runtime error, one has to manage it manually.

Here's some example code that demonstrates a custom CallStack class in action:

Option Explicit
Private Const ModuleName As String = "Module1"
Sub DoSomething(ByVal value1 As Integer, ByVal value2 As Integer, ByVal value3 As String)
 CallStack.Push ModuleName, "DoSomething", value1, value2, value3
 TestSomethingElse value1
 CallStack.Pop
End Sub
Private Sub TestSomethingElse(ByVal value1 As Integer)
 CallStack.Push ModuleName, "TestSomethingElse", value1
 On Error GoTo CleanFail
 Debug.Print value1 / 0
CleanExit:
 CallStack.Pop
 Exit Sub
CleanFail:
 PrintErrorInfo
 Resume CleanExit
End Sub
Public Sub PrintErrorInfo()
 Debug.Print "Runtime error " & Err.Number & ": " & Err.Description & vbNewLine & CallStack.ToString
End Sub

Running DoSomething 42, 12, "test" produces the following output:

Runtime error 11: Division by zero
at Module1.TestSomethingElse({Integer:42})
at Module1.DoSomething({Integer:42},{Integer:12},{String:"test"})

The value of this isn't so much the stack trace itself (after all the VBE's debugger has a call stack debug window), but the ability to log runtime errors along with that precious stack trace.

Here's the CallStack class - note that I opted to set its VB_PredeclaredId attribute to True so that it could be used as a globally-scoped CallStack object (similar to a C# static class). I chose to work off a Collection for simplicity, and because I didn't mind the performance penalty of using a For loop to iterate its items in reverse. I did consider using an array instead, but it seemed the boundary handling and constant resizing left a sour taste to the code: I deliberately preferred the readability and simplicity of a Collection over the For-loop performance of an array.

VERSION 1.0 CLASS
BEGIN
 MultiUse = -1 'True
END
Attribute VB_Name = "CallStack"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Private frames As New Collection
Public Sub Push(ByVal module As String, ByVal member As String, ParamArray parameterValues() As Variant)
Attribute Push.VB_Description = "Pushes a new stack frame onto the call stack. Call once at the entry point of each procedure to trace."
 Dim values() As Variant
 values = parameterValues
 frames.Add StackFrame.Create(module, member, values)
End Sub
Public Function Pop() As IStackFrame
Attribute Pop.VB_Description = "Removes the last stack frame from the top of the stack. Call once at the exit point of each traced procedure."
 Set Pop = Peek
 frames.Remove frames.Count
End Function
Public Function Peek() As IStackFrame
Attribute Peek.VB_Description = "Returns the top-most stack frame."
 Set Peek = frames(frames.Count)
End Function
Public Property Get Count() As Long
Attribute Count.VB_Description = "Gets the depth of the call stack."
 Count = frames.Count
End Property
Public Function ToString() As String
Attribute ToString.VB_Description = "Returns a String containing the stack trace."
 Dim result As String
 Dim index As Long
 For index = frames.Count To 1 Step -1
 result = result & "at " & frames(index).ToString & IIf(index = 1, vbNullString, vbNewLine)
 Next
 ToString = result
End Function

Because I wanted a "stack frame" to be essentially immutable, I only exposed it via a read-only IStackFrame interface:

VERSION 1.0 CLASS
BEGIN
 MultiUse = -1 'True
END
Attribute VB_Name = "IStackFrame"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = False
Attribute VB_Exposed = False
Option Explicit
Public Property Get ModuleName() As String
Attribute ModuleName.VB_Description = "Gets the name of the module for this instance."
End Property
Public Property Get MemberName() As String
Attribute ModuleName.VB_Description = "Gets the name of the member for this instance."
End Property
Public Property Get ParameterValue(ByVal index As Integer) As Variant
Attribute ModuleName.VB_Description = "Gets the value of the parameter at the specified index."
End Property
Public Function ToString() As String
Attribute ToString.VB_Description = "Returns a string representation of the member and its arguments."
End Function

The IStackFrame interface is implemented by the StackFrame class, which also has a VB_PredeclaredId attribute set to True, so that I could call its Create factory method in CallStack as I would a constructor - the instance members (e.g. the Create method, and Self accessor and Property Let mutators) aren't accessible to client code that only sees it through the IStackFrame interface:

VERSION 1.0 CLASS
BEGIN
 MultiUse = -1 'True
END
Attribute VB_Name = "StackFrame"
Attribute VB_GlobalNameSpace = False
Attribute VB_Creatable = False
Attribute VB_PredeclaredId = True
Attribute VB_Exposed = False
Option Explicit
Implements IStackFrame
Private Type TStackFrame
 ModuleName As String
 MemberName As String
 values As Collection
End Type
Private this As TStackFrame
Public Function Create(ByVal module As String, ByVal member As String, ByRef parameterValues() As Variant) As IStackFrame
Attribute Create.VB_Description = "Creates a new instance of an object representing a stack frame, i.e. a procedure call and its arguments."
 With New StackFrame
 .ModuleName = module
 .MemberName = member
 Dim index As Integer
 For index = LBound(parameterValues) To UBound(parameterValues)
 .AddParameterValue parameterValues(index)
 Next
 Set Create = .Self
 End With
End Function
Public Property Get Self() As IStackFrame
Attribute Self.VB_Description = "Gets a reference to this instance."
 Set Self = Me
End Property
Public Property Get ModuleName() As String
Attribute ModuleName.VB_Description = "Gets/sets the name of the module for this instance."
 ModuleName = this.ModuleName
End Property
Public Property Let ModuleName(ByVal value As String)
 this.ModuleName = value
End Property
Public Property Get MemberName() As String
Attribute ModuleName.VB_Description = "Gets/sets the name of the member for this instance."
 MemberName = this.MemberName
End Property
Public Property Let MemberName(ByVal value As String)
 this.MemberName = value
End Property
Public Property Get ParameterValue(ByVal index As Integer) As Variant
Attribute ModuleName.VB_Description = "Gets the value of the parameter at the specified index."
 ParameterValue = this.values(index)
End Property
Public Sub AddParameterValue(ByRef value As Variant)
Attribute AddParameterValue.VB_Description = "Adds the specified parameter value to this instance."
 this.values.Add value
End Sub
Private Sub Class_Initialize()
 Set this.values = New Collection
End Sub
Private Sub Class_Terminate()
 Set this.values = Nothing
End Sub
Private Property Get IStackFrame_MemberName() As String
 IStackFrame_MemberName = this.MemberName
End Property
Private Property Get IStackFrame_ModuleName() As String
 IStackFrame_ModuleName = this.ModuleName
End Property
Private Property Get IStackFrame_ParameterValue(ByVal index As Integer) As Variant
 IStackFrame_ParameterValue = this.values(index)
End Property
Private Function IStackFrame_ToString() As String
 Dim result As String
 result = this.ModuleName & "." & this.MemberName & "("
 Dim index As Integer
 Dim value As Variant
 For Each value In this.values
 index = index + 1
 result = result & "{" & TypeName(value) & ":"
 If IsObject(value) Then
 result = result & ObjPtr(value)
 ElseIf IsArray(value) Then
 result = result & "[" & LBound(value) & "-" & UBound(value) & "]"
 ElseIf VarType(value) = vbString Then
 result = result & Chr$(34) & value & Chr$(34)
 Else
 result = result & CStr(value)
 End If
 result = result & "}" & IIf(index = this.values.Count, vbNullString, ",")
 Next
 result = result & ")"
 IStackFrame_ToString = result
End Function

The Create factory method takes a "normal" array for parameter values - it's meant to be used by the CallStack class, not by client/user code. The user code API takes a ParamArray parameter instead, so that the parameter values can simply be enumerated without any other required code; this allows CallStack.Push to be the first executable line of code in every procedure of the user's code.

Of course, manually managing the stack trace means it's the user code's responsibility to ensure every method pushes itself into the stack, and pops itself out at every exit point: bad error handling, or careless refactorings, and the custom call stack starts telling lies - it's somewhat inherently brittle, but the ability to log errors with a detailed stack trace seems to outweight the additional maintenance cost.

Is there anything in the implementation (or interface / API) that doesn't look right? Any room for improvement? Simplification? Any oversight?

asked Jul 26, 2016 at 5:53
\$\endgroup\$
5
  • 1
    \$\begingroup\$ Saw this and thought it could use some activity - why did you use integer on some of those? \$\endgroup\$ Commented Sep 1, 2016 at 20:16
  • 2
    \$\begingroup\$ @Raystafarian most likely just habit. Should probably be a Byte, anyone writing a VBA procedure with 255+ arguments has worse problems than not having a programmatically accessible stack trace =) \$\endgroup\$ Commented Sep 1, 2016 at 20:52
  • \$\begingroup\$ @Mat's Mug: Maybe I miss something, but can you tell me how to get this use case running with your solution? Imagine the following procedure call stack, all of them use your CallStack.Push (and Pop) methods but only Sub1 and Sub3 have an error handler: Sub1 -> Sub2 -> Sub3 -> Sub4 -> Sub5 Now in Sub5 there will raise an error. Sub1 is the last stand for displaying/logging the error for sure. But what if we want to handle an error in Sub3 successfully? How can we pop Sub4 and Sub5from the call stack before going on without losing the call stack of Sub1 to Sub3? \$\endgroup\$ Commented Jun 14, 2017 at 12:46
  • \$\begingroup\$ @UnhandledException to be honest this was more of an experiment than anything else; managing a call stack is really the job of the runtime, doing it manually quickly becomes a nightmare, even with an easy-to-use stack... as you're noting. \$\endgroup\$ Commented Jun 14, 2017 at 13:30
  • \$\begingroup\$ @Mat's Mug thanks for reply. Yes, you're right, trying to have a kind of call stack is somewhere between really much effort and a nightmare. It's a pity, I liked your approach but unfortunately, as mentioned, even this nice solution lacks in detail. \$\endgroup\$ Commented Jun 14, 2017 at 13:53

2 Answers 2

2
\$\begingroup\$

Background

Out of interest regarding my above comment, I enhanced your solution a bit.

Here is a quote of my above comment:

Maybe I miss something, but can you tell me how to get this use case running with your solution?

Imagine the following procedure call stack, all of them use your CallStack.Push (and Pop) methods but only Sub1 and Sub3 have an error handler:

Sub1 -> Sub2 -> Sub3 -> Sub4 -> Sub5.

Now in Sub5 there will raise an error. Sub1 is the last stand for displaying/logging the error for sure.

But what if we want to handle an error in Sub3 successfully?

How can we pop Sub4 and Sub5 from the call stack before going on without losing the call stack of Sub1 to Sub3?

Target

I want to be able to 'resync' the CallStack object to a current method in case of a successful local error handling in a different position of the call stack.

Additionally I created a possibility to clear the CallStack object together with the Err object.


Changes

In your CallStack class I added two new methods Syncand Clear and also guard clauses to Pop and Peek.

CallStack

Public Function Pop() As IStackFrame
 If Count() = 0 Then Exit Function
 Set Pop = Peek
 frames.Remove frames.Count
End Function
Public Function Peek() As IStackFrame
 If Count() = 0 Then Exit Function
 Set Peek = frames(frames.Count)
End Function
Public Sub Sync(ByVal module As String, ByVal member As String, ParamArray parameterValues() As Variant)
 If Count() = 0 Then Exit Sub
 Dim values() As Variant
 values = parameterValues
 Do Until Peek().ToString() = StackFrame.Create(module, member, values).ToString()
 Pop
 Loop
End Sub
Public Sub Clear()
 Set frames = New Collection
 Err.Clear
End Sub

Usage sample

Test module

Sub Sub1()
 CallStack.Push ModuleName, "Sub1"
 On Error GoTo CleanFail
 Sub2
CleanExit:
 CallStack.Pop
 Exit Sub
CleanFail:
 PrintErrorInfo
 CallStack.Clear
 Resume CleanExit
End Sub
Private Sub Sub2()
 CallStack.Push ModuleName, "Sub2"
 Sub3
 CallStack.Pop
 Exit Sub
End Sub
Private Sub Sub3()
 CallStack.Push ModuleName, "Sub3"
 On Error GoTo CleanFail
 Sub4
CleanExit:
 CallStack.Pop
 Exit Sub
CleanFail:
 Select Case Err.Number
 '// Handle error 4711 locally, sync the call stack and resume
 Case 4711:
 '// Really fix Error 4711 here...
 CallStack.Sync ModuleName, "Sub3"
 PrintErrorInfo '// Output just for testing now.
 Resume
 '// ReRaise every other error
 Case Else:
 Err.Raise Err.Number
 End Select
End Sub
Private Sub Sub4()
 CallStack.Push ModuleName, "Sub4"
 Sub5
 CallStack.Pop
 Exit Sub
End Sub
Private Sub Sub5()
 CallStack.Push ModuleName, "Sub5"
 '// Sample 1:
 Dim l As Long
 l = 1 / 0
 '// Sample 2:
 'Err.Raise 4711, "MySource", "MyDescription"
 CallStack.Pop
 Exit Sub
End Sub

By (un)commenting the different sample Code in Sub5 we can simulate two different situations:

  1. The error bubbles up the whole call stack to Sub1, we will print out and clear the call stack.

  2. The error can be handled in Sub3, so we sync the call stack to method Sub3 and go on with our programm there.


Output with Sample code 1 in Sub5

Runtime error 11: Division by Zero

at Module1.Sub5()

at Module1.Sub4()

at Module1.Sub3()

at Module1.Sub2()

at Module1.Sub1()

Output with Sample code 2 in Sub5

Runtime error 4711: MyDescription

at Module1.Sub3()

at Module1.Sub2()

at Module1.Sub1()

answered Jun 15, 2017 at 17:10
\$\endgroup\$
1
  • \$\begingroup\$ Saw just now, that I left over some Exit Subs in Sub2, Sub4 and Sub5 which are cruft. \$\endgroup\$ Commented Jun 15, 2017 at 19:08
2
\$\begingroup\$

The IStackFrame_ToString implementation is overkill. While the parameter types and values are extremely useful in specific error-handling scenarios, outputting them as standard part of the stack trace doesn't look right:

Runtime error 11: Division by zero
at Module1.TestSomethingElse({Integer:42})
at Module1.DoSomething({Integer:42},{Integer:12},{String:"test"})

Would feel less cluttered and easier to read as:

Runtime error 11: Division by zero
at Module1.TestSomethingElse
at Module1.DoSomething

Therefore, I'd implement it simply as such:

Private Function IStackFrame_ToString() As String
 IStackFrame_ToString = this.ModuleName & "." & this.MemberName
End Function

And then let the client's error-handling code Peek at the stack trace and output/log parameter values when they are deemed relevant. After all, the pointer address of an object isn't really useful beyond "is it 0 or anything else" (ObjPtr(Nothing) returns 0, which is indeed useful when you're up against an object reference not set runtime error 91) - the actual address in itself is... meaningless junk, especially since these values are pretty much single-use (e.g. after executing Set foo = New Bar, the value returned by ObjPtr(foo) will be different at every execution).


Let's go wild here. The range of valid values for an Integer is -32,768 to 32,767. I can't imagine a procedure taking -12 arguments, and I'm not sure one with over 255 arguments would even compile - so Integer is definitely overkill for the index of ParameterValue:

Public Property Get ParameterValue(ByVal index As Integer) As Variant
Attribute ModuleName.VB_Description = "Gets the value of the parameter at the specified index."
 ParameterValue = this.values(index)
End Property

The only unsigned integer type in VBA is Byte, ranging from 0 to 255; it also happens to be the smallest available integer type. I'd most probably want to strangle whoever wrote a procedure taking 255 arguments, and I'm not sure why but if there's a limit to the number of arguments that a VBA procedure can take, 255 seems a likely possible number. So Integer could be harmlessly replaced with Byte wherever it's used to iterate parameters (e.g. in Create) or access them (e.g. ParameterValue).

The values collection will be able to hold more than that though, so there should be some code to validate the inputs and trap a runtime error in CallStack.Push... because you definitely don't want your call stack to be the source of an error!

answered Sep 1, 2016 at 22:18
\$\endgroup\$

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.