Writing Readable Code for VBA – Try* pattern
Lately, I’ve been finding myself using the Try
pattern more and more. I really like this pattern because it makes for much more readable code. This is especially important when programming in a mature programming language like VBA where the error handling is intertwined with the control flow. Generally, I find any procedures that relies on error handling as a control flow to be harder to follow.
Scenario
Let’s start with an example. DAO object model is a perfect candidate because of how it works. See, all DAO objects has Properties
collection, which contains Property
objects. However, anyone is able to add custom property. In fact, Access will add several properties to various DAO objects. Therefore, we may have a property that might not exist and must handle both the case of changing an existing property’s value and the case of appending a new property.
Let’s use Subdatasheet
property as an example. By default, all tables created via Access UI will have the property set to Auto
, but we might not want that. But if we have tables that are created in code or some other way, it might not have the property. So we can start with an initial version of the code to update all tables’ property and handle both the cases.
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" On Error GoTo ErrHandler Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. Set prp = tdf.Properties(SubDatasheetPropertyName) If prp.Value <> NewValue Then prp.Value = NewValue End If End If End If Continue: Next ExitProc: Exit Sub ErrHandler: If Err.Number = 3270 Then Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp Resume Continue End If MsgBox Err.Number & ": " & Err.Description Resume ExitProc End Sub
The code will probably work. However, to understand it, we probably have to diagram some flow chart. The line Set prp = tdf.Properties(SubDatasheetPropertyName)
could potentially throw an error 3270. In this case, the control jumps to the error handling section. We then create a property and then resume at a different point of the loop using the label Continue
. There are some questions…
- What if 3270 is raised on some other line?
- Suppose that the line
Set prp =...
doesn’t throw error 3270 but actually some other error? - What if while we are inside the error handler, another error happens when executing the
Append
orCreateProperty
? - Should this function even be showing a
Msgbox
? Think about functions that are supposed to work on something on behalf of forms or buttons. If the functions show a message box, then exit normally, the calling code has no idea that something has gone wrong and might continue doing things it shouldn’t be doing. - Can you glance at the code and understand what it does immediately? I can’t. I have to squint at it, then think about what should happen under the case of an error and mentally sketch the path. That’s not easy to read.
Add a HasProperty
procedure
Can we do better? Yes! Some programmers already recognize the problem with using error handling like I illustrated and wisely abstracted this out into its own function. Here’s a better version:
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If Not HasProperty(tdf, SubDatasheetPropertyName) Then Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp Else If tdf.Properties(SubDatasheetPropertyName) <> NewValue Then tdf.Properties(SubDatasheetPropertyName) = NewValue End If End If End If End If Next End Sub Public Function HasProperty(TargetObject As Object, PropertyName As String) As Boolean Dim Ignored As Variant On Error Resume Next Ignored = TargetObject.Properties(PropertyName) HasProperty = (Err.Number = 0) End Function
Instead of mixing up the execution flow with the error handling, we now have a function HasFunction
which neatly abstracts out the error-prone check for a property that may not exist. As a consequence, we don’t need complex error handling/execution flow that we saw in the first example. This is a big improvement and makes for somewhat readable code. But…
- We have one branch that uses the variable
prp
and we have another branch that usestdf.Properties(SubDatasheetPropertyName)
that in fact refers to the same property. Why are we repeating ourselves with two different ways to reference the same property? - We are handling the property quite a lot. The
HasProperty
has to handle the property in order to find out if it exists then simply returns aBoolean
result, leaving it up to the calling code to again try and get the same property again to change the value. - Similarly, we are handling the
NewValue
more than necessary. We either pass it in theCreateProperty
or set theValue
property of the property. - The
HasProperty
function implicitly assumes that the object has aProperties
member and calls it late-bound, which means it’s a runtime error if a wrong kind of object is provided to it.
Use TryGetProperty
instead
Can we do better? Yes! That’s where we need to look at the Try pattern. If you’ve ever programmed with .NET, you probably have seen methods like TryParse
where instead of raising an error on failure, we can set up a condition to do something for success and something else for failure. But more importantly, we have the result available for success. So how would we improve on the HasProperty
function? For one thing, we should return the Property
object. Let’s try this code:
Public Function TryGetProperty( _ ByVal SourceProperties As DAO.Properties, _ ByVal PropertyName As String, _ ByRef OutProperty As DAO.Property _ ) As Boolean On Error Resume Next Set OutProperty = SourceProperties(PropertyName) If Err.Number Then Set OutProperty = Nothing End If On Error GoTo 0 TryGetProperty = (Not OutProperty Is Nothing) End Function
With few changes, we’ve scored few big wins:
- The access to
Properties
is no longer late-bound. We do not have to hope that an object has a property namedProperties
and it’s ofDAO.Properties
. This can be verified at the compile-time. - Instead of just a
Boolean
result, we can also get the retrievedProperty
object, but only on the success. If we fail, theOutProperty
parameter will beNothing
. We still will use theBoolean
result to help with setting the up flow as you will see shortly. - By naming our new function with
Try
prefix, we are indicating that this is guaranteed to not throw an error under normal operating conditions. Obviously, we can’t prevent out of memory errors or something like that but at that point, we have much bigger problems. But under the normal operating condition, we’ve avoided tangling up our error handling with the execution flow. The code now can be read from top to bottom without any jumping forth or back.
Note that by convention, I prefix the “out” property with Out
. That helps make it clear that we are supposed to pass in the variable to the function uninitialized. We are also expecting that the function will initialize the parameter. That will be clear when we look at the calling code. So, let’s set up the calling code.
Revised calling code using TryGetProperty
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If TryGetProperty(tdf, SubDatasheetPropertyName, prp) Then If prp.Value <> NewValue Then prp.Value = NewValue End If Else Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp End If End If End If Next End Sub
The code is now a bit more readable with the first Try pattern. We’ve managed to reduce the handling of the prp
. Note that we pass the prp
variable into the <codeTryGetProperty. If it returns true
, the prp
will be initialized with the property we want to manipulate. Otherwise, the prp
remains Nothing
. We can then use the CreateProperty
to initialize the prp
variable.
We also flipped the negation so that the code becomes easier to read. However, we haven’t really reduced the handling of NewValue
parameter. We still have another nested block to check the value. Can we do better? Yes! Let’s add another function:
Adding TrySetPropertyValue
procedure
Public Function TrySetPropertyValue( _ ByVal SourceProperty As DAO.Property, _ ByVal NewValue As Variant_ ) As Boolean If SourceProperty.Value = PropertyValue Then TrySetPropertyValue = True Else On Error Resume Next SourceProperty.Value = NewValue On Error GoTo 0 TrySetPropertyValue = (SourceProperty.Value = NewValue) End If End Function
Because we are guaranteeing that this function won’t throw an error when changing the value, we call it TrySetPropertyValue
. More importantly, this function helps encapsulate all the gory details surrounding changing the property’s value. We have a way to guarantee that the value is the value we expected it to be. Let’s look at how the calling code will be changed with this function.
Updated calling code using both TryGetProperty
and TrySetPropertyValue
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. If TryGetProperty(tdf, SubDatasheetPropertyName, prp) Then TrySetPropertyValue prp, NewValue Else Set prp = tdf.CreateProperty(SubDatasheetPropertyName , dbText, NewValue) tdf.Properties.Append prp End If End If End If Next End Sub
We’ve eliminated an entire If
block. We now can simply read the code and immediately that we are trying to set a property value and if something goes wrong, we just keep moving on. That is much easier to read and the name of the function is self-describing. A good name makes it less necessary to look up the definition of the function in order to understand what it is doing.
Creating TryCreateOrSetProperty
procedure
The code is more readable but we still have that Else
block creating a property. Can we do better still? Yes! Let’s think about what we need to accomplish here. We have a property that may or may not exist. If it doesn’t, we want to create it. Whether it existed already or not, we need it to be set to a certain value. So what we need is a function that will either create a property or update the value if it already exists. To create a property, we must call CreateProperty
which unfortunately is not on the Properties
but rather different DAO objects. Thus, we must late bind by using Object
data type. However, we can still provide some runtime checks to avoid errors. Let’s create a TryCreateOrSetProperty
function:
Public Function TryCreateOrSetProperty( _ ByVal SourceDaoObject As Object, _ ByVal PropertyName As String, _ ByVal PropertyType As DAO.DataTypeEnum, _ ByVal PropertyValue As Variant, _ ByRef OutProperty As DAO.Property _ ) As Boolean Select Case True Case TypeOf SourceDaoObject Is DAO.TableDef, _ TypeOf SourceDaoObject Is DAO.QueryDef, _ TypeOf SourceDaoObject Is DAO.Field, _ TypeOf SourceDaoObject Is DAO.Database If TryGetProperty(SourceDaoObject.Properties, PropertyName, OutProperty) Then TryCreateOrSetProperty = TrySetPropertyValue(OutProperty, PropertyValue) Else On Error Resume Next Set OutProperty = SourceDaoObject.CreateProperty(PropertyName, PropertyType, PropertyValue) SourceDaoObject.Properties.Append OutProperty If Err.Number Then Set OutProperty = Nothing End If On Error GoTo 0 TryCreateOrSetProperty = (OutProperty Is Nothing) End If Case Else Err.Raise 5, , "Invalid object provided to the SourceDaoObject parameter. It must be an DAO object that contains a CreateProperty member." End Select End Function
Few things to note:
- We were able to build up on the earlier
Try*
function we defined, which helps cut down on the coding of the function’s body, allowing it to focus more on the creation in the case there is no such property. - This is necessarily more verbose due to the additional runtime checks, but we are able to set it up so that errors do not alter the execution flow and we can still read from top to bottom with no jumping.
- Instead of throwing a
MsgBox
out of nowhere, we useErr.Raise
and return a meaningful error. The actual error handling is delegated to the calling code which can then decide whether to show a messagebox to the user or do something else. - Because of our careful handling and providing that the
SourceDaoObject
parameter is valid, all the possible path guarantees that any problems with creating or setting an existing property’s value will be handled and we will get afalse
result. That affects the calling code as we will see shortly.
Final version of the calling code
Let’s update the calling code to use the new function:
Public Sub EditTableSubdatasheetProperty( _ Optional NewValue As String = "[None]" _ ) Dim db As DAO.Database Dim tdf As DAO.TableDef Dim prp As DAO.Property Const SubDatasheetPropertyName As String = "SubdatasheetName" Set db = CurrentDb For Each tdf In db.TableDefs If (tdf.Attributes And dbSystemObject) = 0 Then If Len(tdf.Connect) = 0 And (Not tdf.Name Like "~*") Then 'Not attached, or temp. TryCreateOrSetProperty tdf, SubDatasheetPropertyName, dbText, NewValue End If End If Next End Sub
That was quite an improvement in the readability. In the original version, we would have to scrutinize over a number of If
blocks and how error handling alters the flow of execution. We would have to figure out what exactly the content was doing to conclude that we are trying to get a property or create it if it doesn’t exist and have it be set to a certain value. With the current version, it’s all there in the name of the function, TryCreateOrSetProperty
. We now can see what the function is expected to do.
Conclusion
You might be wondering, “but we added much more functions and much more lines. Isn’t that a lot of work?” It is true that in this current version, we defined 3 more functions. However, you can read each single function in isolation and still easily understand what it should do. You also saw that the TryCreateOrSetProperty
function could build up on the 2 other Try*
functions. That means we have more flexibility in assembling together the logic.
So if we write another function that does something with the property of objects, we don’t have to write it all over nor do we copy-paste the code from the original EditTableSubdatasheetProperty
into the new function. After all, the new function might need some different variants and thus require a different sequence. Finally, keep in mind that the real beneficiaries are the calling code that needs to do something. We want to keep the calling code fairly high-level without being mired in details which can be bad for maintenance.
You can also see that the error handling is significantly simplified, even though we used On Error Resume Next
. We no longer need to look up the error code because in the majority of the case, we are only interested in whether it succeeded or not. More importantly, the error handling did not change the execution flow where you have some logic in the body and other logic in the error handling. The latter is a situation we definitely want to avoid because if there is an error within the error handler, then the behavior can be surprising. It’s best to avoid that from being a possibility.
It’s all about abstraction
But the most important score we win here is the level of abstraction we can now attain. The original version of EditTableSubdatasheetProperty
contained a lot of low-level details about the DAO object really isn’t about the core goal of the function. Think about days where you’ve seen a procedure that are hundreds of line long with deeply nested loops or conditions. Would you want to debug that? I don’t.
So when I see a procedure, the first thing I really want to do is rip out the parts into their own function, so that I can raise the level of abstraction for that procedure. By forcing ourselves to push the level of abstraction, we also can avoid large classes of bugs where the cause is to one change in part of the mega-procedure has unintended ramifications for the other parts of the procedures. When we are calling functions and passing parameters, we also reduce the possibility of unwanted side-effects interfering with our logic.
Hence why I love the “Try*” pattern. I hope you find it useful for your projects, too.