Backward-Compatible Refactoring
In previous articles, I discussed interfaces both conceptually and practically. So, we've discussed what interfaces are and how to use them in VBA. Now, let's talk about when to use them by going through a few scenarios where interfaces make sense. I'll cover the first scenario in this article.
- Backward-compatible refactoring
- Generic forms in a code library
- Dependency injection
- Cleaner IntelliSense
Backward-Compatible Refactoring
Let's start by discussing why this is even important.
The evolution of a code library
When I first started building a code library, I was working at the individual routine level. I had a few text files that were filled with functions and subroutines. When I needed to use one in an application, I would copy and paste it into my project.
This worked well for awhile. Then I realized that I was copying and pasting a lot of the same functions. I started organizing them into files by type: StringFunctions, DateFunctions, FormFunctions, etc. I would then save and import them as complete modules rather than individual routines.
I did this haphazardly at first. Each time I imported an updated version of a code module, I spent several minutes resolving compile errors. Sometimes it was duplicate procedure names, other times I had changed my routine signatures (i.e., the arguments I was passing back and forth).
I quickly realized that to make this new approach work, I needed to be able to easily swap out an old version of a code module from one of my programs with an updated version from my code library. To achieve this, I needed to start taking backwards compatibility as seriously as Microsoft does.
Backwards compatibility makes it work
To make a code library work it needs to introduce as little friction as possible into your programming routine. Updating a module from your code library should be no more than three steps:
- Remove the module from your program ([Alt] + [F], [R], [N])
- Import the module from your code library ([Ctl] + [M])
- Compile your program to verify nothing broke ([Alt] + [D], [L])
If step 3 fails because you are constantly breaking backwards compatibility, then you no longer have a code library. You have a mess.
I thought this was an article about interfaces
I'm getting there. One of the first objects in my code library was actually a form. It was a custom Progress Meter.
It grew in fits and starts, as code tends to do. It started out simple enough: a bit of text above two colored rectangles. One served as the background of the meter and stayed a fixed size. The other grew from left to right to indicate progress.
Over time, I added features:
- An optional vertical meter for long-running processes in nested loops.
- An optional Cancel button to allow users to stop the process.
- An optional "Time Remaining" clock that updates automatically.
- A self-updating text message showing the "# of ##" items processed.
Cruft accumulation
As also tends to happen, the "API" became inconsistent and laden with technical debt. While the two progress bars provided similar features, their properties, functions, and methods were different. I also made some early design decisions that I later regretted, like using strings to differentiate the progress bars ("horz" and "vert") rather than something like an Enum type that would provide better compile-time checking.
I really wanted to address these problems and clean up the code that I used to interact with my Progress Meters form. At the same time, I wanted to avoid breaking backwards compatibility. I had code that called my Progress Meters form in hundreds of places spread across dozens of applications. Refactoring all that code might have made me feel better, but it would have provided no value to my clients.
Instead, I wanted a way to start using cleaner code moving forward. For a long time, this seemed like an impossibility. How could I completely change the way I interact with my Progress Meters form without breaking backward compatibility? And then it dawned on me: interfaces.
Backward-compatible API redesign
To maintain backward compatibility, I would not change any of the existing code in my Progress Meters form. After all, I was happy with the functionality. It was my interaction with the form that I didn't like.
The actual fix was surprisingly simple:
- Create an interface class that supports clean code
- Implement that interface in my existing form
- Program against the interface (and not the form) moving forward
I took the opportunity to step back and re-evaluate what features I actually wanted a generic progress meter implementation to provide and to separate that from how an individual implementation might achieve them.
For example, in my initial Progress Meters form, I had two progress bars: horizontal and vertical. I realized, though, that horizontal and vertical bars were simply implementation details. What I really wanted was a "major" progress indicator and a "minor" progress indicator. I could imagine an implementation with stacked horizontal progress bars. Or an implementation that uses Access's built-in status bar as the "major" indicator and a percentage number in the status text area to represent the "minor" indicator.
Sample code
Here's the interface class I created for my progress meter. I leave it as an exercise for you, dear reader, to implement this interface in a custom progress meter form of your own.
If you're curious about my Enum naming convention, I wrote about that here.
'---------------------------------------------------------------------------------------
' Module : iProgressMeters
' Author : Mike
' Date : 8/25/2016 - 3/31/2017
' Purpose : Interface for a progress meter.
'---------------------------------------------------------------------------------------
Option Compare Database
Option Explicit
Public Text As String
Public Enum mt__MeterType
mt_Major
mt_Minor
End Enum
'Increment the current count of the meter
Public Sub IncMeter(MeterType As mt__MeterType, _
Optional NewText As String = "", _
Optional IncAmount As Long = 1)
End Sub
'Get/set the total number of items for the meter
Public Property Get Max(MeterType As mt__MeterType) As Long: End Property
Public Property Let Max(MeterType As mt__MeterType, NewMax As Long): End Property
'Get/set the current item being processed for the meter
Public Property Get Current(MeterType As mt__MeterType) As Long: End Property
Public Property Let Current(MeterType As mt__MeterType, NewCurrent As Long): End Property
'Set max number of times to increment the meter
' (e.g., if 50, the meter will only update 50 times even if the Max is 10,000 items)
Public Property Get Increments(MeterType As mt__MeterType) As Long: End Property
Public Property Let Increments(MeterType As mt__MeterType, NewIncrements As Long): End Property
'Get/set the visibility of the meter
Public Property Get MeterVisible(MeterType As mt__MeterType) As Boolean: End Property
Public Property Let MeterVisible(MeterType As mt__MeterType, NewMeterVisible As Boolean): End Property
'Get/set the text for the meter
' NOTES: # should be auto-replaced with .Current count
' ## should be auto-replaced with .Max count
Public Property Get MeterText(MeterType As mt__MeterType) As String: End Property
Public Property Let MeterText(MeterType As mt__MeterType, NewMeterText As String): End Property
'Get/set the visibility of the auto-calculated Time Remaining for the meter
Public Property Get ShowTimeRemaining(MeterType As mt__MeterType) As Boolean: End Property
Public Property Let ShowTimeRemaining(MeterType As mt__MeterType, NewShowTimeRemaining As Boolean): End Property
'Provide user a means to cancel a long running process
Public Property Get AllowUserCancel() As Boolean: End Property
Public Property Let AllowUserCancel(Value As Boolean): End Property
'Returns true if the user has canceled the process
Public Property Get UserCanceled() As Boolean: End Property
'Allow for user to "undo" an inadvertent cancel
Public Sub DismissUserCancellation(): End Sub