Visual Basic

Tip 19: Tighten up Visual Basic's type checking.

Visual Basic itself doesn't always help you detect errors or error conditions. For example, consider the following code fragment:

Private Sub Fu(ByVal d As Date)
      .
      .
      .
  End Sub
  Call Fu("01 01 98")

Is this code legal? If you ask around, quite often you'll find that developers say no, but it is perfectly legal. No type mismatch occurs (something that worries those who suspect this is illegal).

The reason the code is legal lies in Visual Basic itself. Visual Basic knows that the Fu procedure requires a Date type argument, so it automatically tries to convert the string constant "01 01 98" into a Date value to satisfy the call. If it can convert the string constant, it will. In other words, it does this kind of thing:

' The call .
                     .
                     .
  '
  ' Call Fu("01 01 98")
  '
  ' Equates to …
  '
  Const d As String = "01 01 98"
  If IsDate(d) Then
      Dim Local_d As Date
      Local_d = CDate(d)
      Call Fu(Local_d)
  Else
      Err.Raise Number:=13
  End If

Now you see that Visual Basic can make the call by performing the cast (type coercion) for you. Note that you can even pass the argument by reference simply by qualifying the argument with the ByRef keyword, as in Call Fu(ByRef "01 01 98"). All you're passing by reference, in fact, is an anonymous variable that Visual Basic creates solely for this procedure call. By the way, all ByVal arguments in Visual Basic are passed by reference in this same fashion. That is, when it encounters a ByVal argument, Visual Basic creates an anonymous variable, copies the argument into the variable, and then passes a reference to the variable to the procedure. Interestingly, a variable passed by reference must be of the correct type before the call can succeed. (This makes perfect sense given that Visual Basic can trust itself to create those anonymous variables with the correct type; it can't trust user-written code to do the right thing, so Visual Basic has to enforce by-reference type checking strictly.)

So what's wrong with this automatic type coercion anyway? I hope you can see that the problem in the case above is that the cast is not helpful. We're passing an ambiguous date expression but receiving an actual, unambiguous date. This is because all date variables are merely offsets from December 30, 1899, and therefore unambiguous (for example, 1.5 is noon on December 31, 1899). There's no way "inside" of Fu to detect this fact and to refuse to work on the data passed. (Maybe that's how it should be? Maybe we should rely on our consumers to pass us the correct data type? No, I don't think so.)

One way to fix this [part of the] problem is to use Variants, which are some of the few things I normally encourage people to use. Have a look at this:

Call Fu("01 01 98")
  Private Sub Fu(ByVal v As Variant)
 Dim d As Date
  If vbString = VarType(v) Then
   If True = IsDate(CStr(v)) Then
    If 0 = InStr(1, CStr(v), CStr(Year(CDate(v))), 1) Then
      Err.Raise Number:=13
        Else
          d = CDate(v)
           End If
          End If
   End If
      ' Use d here…
  End Sub

The good thing about a Variant (and the bad?) is that it can hold any kind of data type. You can even ask the Variant what it's referencing by using VarType, which is very useful. Because we type the formal argument as Variant we'll receive in it a type equal to the type of the expression we passed. In the code above, VarType(v) will return vbString, not vbDate.

Knowing this, we can check the argument types using VarType. In the code above, we're checking to see if we're being passed a string expression. If the answer is yes, we're then checking to see that the string represents a valid date (even an ambiguous one). If again the answer is yes, we convert the input string into a date and then use InStr to see if the year in the converted date appears in the original input string. If it doesn't, we must have been passed an ambiguous date.

Here's that last paragraph rephrased and broken down a bit. Remember that a Date always holds an exact year because it actually holds an offset from December 30, 1899. Therefore, Year(a_Date_variable) will always give us back a full four-digit year (assuming that a_Date_variable represents a date after the year 999). On the other hand, the string that "seeds" the Date variable can hold only an offset-98 in the example. Obviously then, if we convert 98 to a Date (see Chapter 8 for more on this topic), we'll get something like 1998 or 2098 in the resulting Date variable. When converted to a string, those years are either "1998" or "2098"-neither of which appears in "01 01 98." We can say with some conviction, therefore, that the input string contains an ambiguous date expression, or even that its data type ("ambiguous date") is in error and will throw a type mismatch error.

All this date validation can be put inside a Validate routine, of course:

Private Sub Fu(ByVal v As Variant)
 Dim d As Date
Call Validate(v, d)
' Use d here _ we don't get here if there's a problem with 'v'...
 End Sub

In this Validate routine d is set to cast(v) if v is not ambiguous. If it is ambiguous, an exception is thrown. An exciting addition to this rule is that the same technique can also be applied to Visual Basic's built-in routines via Interface Subclassing.

How often have you wanted an Option NoImplicitTypes? I have, constantly. Here's how you can almost get to this situation:

Private Sub SomeSub()
 MsgBox DateAdd("yyyy", 100, "01 01 98")
End Sub
Public Function DateAdd( _
         ByVal Interval As String _
       , ByVal Number As Integer _
       , ByVal v As Variant _
               )
Call Vali_Date(v)
DateAdd = VBA.DateTime.DateAdd(Interval, Number, CDate(v))
End Function
 Private Sub Vali_Date(ByVal v As Variant)
' If 'v' is a string containing a valid date expression ...
    If vbString = VarType(v) And IsDate(CStr(v)) Then
    ' If we've got a four digit year then we're OK,
       ' else we throw an err.
         If 0 = InStr(1, CStr(v), _
            Format$(Year(CDate(v)), "0000"), 1) Then
             Err.Raise Number:=13
          End If
      End If
  End Sub

In this code, the line MsgBox DateAdd(...) in SomeSub will result in a runtime exception being thrown because the date expression being passed is ambiguous ("01 01 98"). If the string were made "Y2K Safe"-that is, 01 01 1998-the call will complete correctly. We have altered the implementation of DateAdd; you could almost say we inherited it and beefed up its type checking.

Obviously this same technique can be applied liberally so that all the VBA type checking (and your own type checking) is tightened up across procedure calls like this. The really nice thing about doing this with Visual Basic's routines is that instead of finding, say, each call to DateAdd to check that its last argument is type safe, you can build the test into the replacement DateAdd procedure. One single replacement tests all calls. In fact, you can do this using a kind of Option NoImplicitTypes.

Use this somewhere, perhaps in your main module:

#Const NoImplicitTypes = True

Then wrap your validation routines appropriately:

Private Sub Vali_Date(ByVal v As Variant)
      #If NoImplicitTypes = True Then
      ' If 'v' is…
      If …
      End If
      #End If
  End Sub

You now almost have an Option NoImplicitTypes. I say almost because we can't get rid of all implicit type conversions very easily (that's why I used "[part of the]" earlier). Take the following code, for example:

Dim d As Date
  d = txtEnteredDate.Text

Your validation routines won't prevent d from being assigned an ambiguous date when txtEnteredDate.Text is "01 01 98", but at least you're closer to Option NoImplicitTypes than you would be without the routines.

Actually, at TMS we use a DateBox control, and even that control cannot stop this sort of use. A DateBox returns a Date type, not a Text type, and it's meant to be used like this:

Dim d As Date
  d = dteEnteredDate.Date

Of course, it can still be used like this:

Dim s As String
  s = dteEnteredDate.Date

Hmm, a date in a string! But at least s will contain a non-Y2K-Challenged date.

A Not-Too-Small Aside into Smart Types, or "Smarties"

Another way to protect yourself against this kind of coercion is to use a smart type (we call these things Smarties, which is the name of a candy-coated confection) as an lvalue (the thing on the left-hand side of the assignment operator). A smart type is a type with vitamins added, one that can do something instead of doing nothing. The difference between smart types and "dumb" types is a little like the difference between public properties that are implemented using variables versus public properties implemented using property procedures. Here's some test code that we can feed back into the code above that was compromised:

Dim d As New iDate
  d = txtEnteredDate.Text

Note that we're using a slightly modified version of the code here, in which d is defined as an instance (New) of iDate instead of just Date. (Of course, iDate means Intelligent Date.) Here's the code behind the class iDate:

In a class called iDate

Private d As Date
  Public Property Get Value() As Variant
      Value = CVar(d)
  End Property
  Public Property Let Value(ByVal v As Variant)
      If vbDate = VarType(v) Then
          d = CDate(v)
      Else
          Err.Raise 13
      End If
  End Property

OK then, back to the code under the spotlight. First you'll notice that I'm not using d.Value = txtEnteredDate.Text. This is because I've nominated the Value property as the default property. (Highlight Value in the Code window, select Procedure Attributes from the Tools menu, click Advanced >> in the Procedure Attributes dialog box, and then set Procedure ID to (Default).) This is the key to smart types, or at least it's the thing that makes them easier to use. The default property is the one that's used when you don't specify a property name. This means that you can do stuff like Print Left$(s, 1) instead of having to do Print Left$(s.Value, 1). Cool, huh? Here's that test code again:

Dim d As New iDate
  d = txtEnteredDate.Text

If you bear in mind this implementation of an iDate, you see that this code raises a Type Mismatch exception because the Value Property Let procedure, to which the expression txtEnteredDate.Text is passed as v, now validates that v contains a real date. To get the code to work we need to do something a little more rigid:

Dim d As New iDate
  d = CDate(txtEnteredDate.Text)

Just what the doctor ordered. Or, in the case of a date, does this perhaps make the situation worse? One reason why you might not want to explicitly convert the text to a date is that an ambiguous date expression in txtEnteredDate.Text is now converted in a way that's hidden from the validation code in the d.Value Property Let procedure. Perhaps we could alter the code a little, like this:

Public Property Let Value(ByVal v As Variant)
      If vbString = VarType(v) And IsDate(CStr(v)) Then
          ' If we've got a four digit year then we're OK,
          ' else we throw an err.
          If 0 = InStr(1, CStr(v), _
                       Format$(Year(CDate(v)), "0000"), 1) Then
              Err.Raise Number:=13
          End If
      End If
      d = CDate(v)
  End Property

Here I've basically borrowed the code I showed earlier in this chapter which checks whether a date string is ambiguous. Now the following code works only if txtEnteredDate.Text contains a date like "01 01 1900":

Dim d As New iDate
  d = txtEnteredDate.Text

Another cool thing about Smarties is that you can use them within an existing project fairly easily, in these different ways:

  1. Add the class file(s) that implement your smart types.
  2. Use search and replace to turn dumb types into Smarties.
  3. Run your code and thoroughly exercise (exorcise) it to find your coercion woes.
  4. Use search and replace again to swap back to dumb types (if you want).

Actually, I'll come clean here-it's not always this easy to use Smarties. Let's look at some pitfalls. Consider what happens when we search for As String and replace with As New iString. For one thing we'll end up with a few procedure calls like SomeSub(s As New iString), which obviously is illegal. We'll also get some other not-so-obvious-dare I say subtle?-problems.

Say you've got SomeSub(ByVal s As iString); you might get another problem here because now you're passing an object reference by value. ByVal protects the variable that you're passing so that it cannot be altered in a called procedure (a copy is passed and possibly altered in its place). The theory is that if I have s = Time$ in the called procedure, the original s (or whatever it was called in the calling procedure) still retains its old value. And it does; however, remember that the value we're protecting is the value of the variable. In our case that's the object reference, not the object itself. In C-speak, we can't change the object pointer, but because we have a copy of the pointer, we can access and change any of the object's properties. Here's an example that I hope shows this very subtle problem.

These two work the same:

  Private Sub cmdTest_Click()
        Dim s As New iString
        s = Time$
        Call SomeSub(s)
        MsgBox s
    End Sub
    Sub SomeSub(ByRef s As iString)
        s = s & " " & Date$
        MsgBox s
    End Sub
  Private Sub cmdTest_Click()
      Dim s As String
      s = Time$
      Call SomeSub(s)
      MsgBox s
  End Sub
  Sub SomeSub(ByRef s As String)
      s = s & " " & Date$
      MsgBox s
  End Sub

The assignment to s in both versions of SomeSub affects each instance of s declared in cmdTest_Click.

These two don't work the same:

 Private Sub cmdTest_Click()
        Dim s As New iString
        s = Time$
        Call SomeSub(s)
        MsgBox s
    End Sub
    Sub SomeSub(ByVal s As iString)
        s = s & " " & Date$
        MsgBox s
    End Sub
Private Sub cmdTest_Click()
      Dim s As String
      s = Time$
      Call SomeSub(s)
      MsgBox s
  End Sub
  Sub SomeSub(ByVal s As String)
      s = s & " " & Date$
      MsgBox s
  End Sub

The assignment to s in the SomeSub on the left still affects the instance of s declared in the cmdTest_Click on the left.

Let me again run through why this is. This happens because we're not passing the string within the object when we pass an iString; we're passing a copy of the object reference. Or, if you like, we're passing a pointer to the string. So it doesn't matter whether we pass the object by reference or by value-the called procedure has complete access to the object's properties.

You also cannot change iString to String in the procedure signature (if you did, you would defeat the purpose of all this, for one thing) and still pass ByRef because you're effectively trying to pass off an iString as a String, and you'll get a type mismatch.

Another area where you'll have problems is in casting (coercion). Consider this:

Private Function SomeFunc(s As iString) As iString
      SomeFunc = s
  End Function

Look OK to you? But it doesn't work! It can't work because = s, remember, means = s.Value-a String-and that's not an iString as implied by the assignment to SomeFunc. There's no way Visual Basic can coerce a String into an iString reference. (Maybe this is good because it's pretty strongly emphasized.) Could we coerce a String into an iString reference if we wrote a conversion operator (CiStr, for example)? Yes, but that would be overkill because we've already got a real iString in the preceding code. What we need to do is change the code to Set SomeFunc = s. Set is the way you assign an object pointer to an object variable. Anyway, it's simply a semantics change and so should be rejected out of hand. What we need is some way to describe to the language how to construct an iString from a String and then assign this new iString-not using Set-to the function name. (This is all getting us too close to C++, so I'll leave this well alone, although you might want to consider where you'd like Visual Basic to head as a language).

Anyway, you can see that this is getting messy, right? The bottom line is that you can do a good job of replacing dumb types with Smarties, but it's usually something that's best done right from the start of a project. For now, let's look at something that's easier to do on existing projects: another slant on type enforcement.

Type Checking Smarties How do you determine whether you're dealing with an As Object object or with a Smartie? Easy-use VarType. Consider this code; does it beep?

Dim o As New iInteger
  If vbObjectiInteger = VarType(o) Then Beep

Normally all object types return the same VarType value (vbObject or 9), so how does VarType know about Smarties (assuming that vbObjectiInteger hasn't also been defined as 9)? Simple; see Tip 4. We subclass VarType and then add the necessary intelligence we need for it to be able to differentiate between ordinary objects and Smarties. For example, VarType might be defined like this

Public Function VarType(ByVal exp As Variant) _
As Integer ' vbVarType++
 Select Case VBA.VarType(exp)
   Case vbObject:
         Select Case TypeName(exp)
       Case "iInteger"
             VarType = vbObjectiInteger
         Case "iSingle"
              VarType = vbObjectiSingle
       Case Else
    VarType = VBA.VarType(exp)
   End Select
 Case Else
VarType = VBA.VarType(exp)
End Select
End Function

The constants vbObjectiInteger, vbObjectiSingle, etc. are defined publicly and initialized on program start-up like this:

Public Sub main()
 vbObjectiInteger = WinGlobalAddAtom(CreateGUID)
 vbObjectiSingle  = WinGlobalAddAtom(CreateGUID)
' Etc…
DoStartup
 End Sub

WinGlobalAddAtom is an alias for the API GlobalAddAtom. This Windows API creates a unique value (in the range &HC000 through &HFFFF) for every unique string you pass it, and hopefully there will be no future clashes with whatever VarType will return. (So we have a variable constant: variable in that we don't know what GlobalAddAtom will return when we call it for the first time, but constant in that on subsequent calls GlobalAddAtom will return the same value it returned on the first call). It's basically a hash-table "thang." I want a unique value for each Smartie type I use, so I must pass a unique string to GlobalAddAtom. I create one of these by calling the CreateGUID routine documented in my Chapter 7, "Minutiae: Some Stuff About Visual Basic." This routine always returns a unique GUID string (something like C54D0E6D-E8DE-11D1-A614-0060806A9738), although in a pinch you could use the class name. The bottom line is that each Smartie will have a unique value which VarType will recognize and return!

Why not just use any old constant value? Basically I want to try to be less predictable (clashable with) and more unique, although one downside is this: because I cannot initialize a constant in this way, those vbObjectiInteger and others are variables and could be reassigned some erroneous values later in our code. Actually, that's a lie because they cannot be reassigned a new value. Why not? Because they're Smarties, too. To be precise, they're another kind of Smartie-Longs that can have one-time initialization only. (See Chapter 7 for the code that implements them.)

You might also want to consider whether to enforce at least strict type checking on procedure call arguments and set up some kind of convention within your coding standards whereby parameters are received as Variants (as outlined earlier), tested, and then coerced into a "correct" local variable of the desired type. Another advantage of this scheme is that it mandates a "fast pass by value" handling of arguments and thus can be used indirectly to reduce coupling. It's fast because it's actually a pass by reference!

In the following code, note that despite passing n to Fu by reference (which is the default passing mechanism, of course) we cannot alter it in Fu (if we're disciplined). This is because we work only in that routine on the local variable, d.

In a form (say):

Private Sub cmdTest_Click()
      Dim n As Integer
      n = 100
      Call Fu(n)
  End Sub
  Public Sub Fu(vD As Variant)
      Dim d As Single
      d = IntegerToReal(vD)
      ' Use d safely ...
  End Sub

In a testing module:

Public Function IntegerToReal
(ByVal vI As Variant) As Double
      #If True = NoImplicitTypes Then
          Select Case VarType(vI)
              Case vbInteger, vbLong:
                  IntegerToReal = CDbl(vI)
              Case Else
                  Err.Raise VBErrTypeMismatch
          End Select
      #Else
          IntegerToReal = CDbl(vI)
      #End If
  End Function

Here we're implying that our coding standards mandate some type checking. We're allowing integers (both Long and Short) to be implicitly coerced into either Singles or Doubles. Therefore, if we call Fu as Call Fu(100), we're OK. But if we call it as, say, Call Fu("100"), this will fail (if NoImplicitTypes is set to -1 in code using #Const, or in the IDE using the Project Properties dialog box). Note that d in Fu is defined as a Single but that IntegerToReal is returning a Double. This is always OK because an integer will always fit in to a Single; that is, we won't overflow here at all. To speed up the code, perhaps during the final build, you can simply define NoImplicitTypes as 0, in which case the routine forgoes type checking.

Of course, depending on your level of concern (or is that paranoia?), you can turn this up as much as you like. For instance, you could refuse to convert, say, a Long integer to a Single/Double. You're limited only to whatever VarType is limited to, meaning that you can detect any type as long as VarType does.