--------------------------------------------------------------------------------
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. (See Chapter 8 for more discussion about this, and see the companion CD for a demonstration.) 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.
Might Microsoft add such an Option NoImplicitTypes in the future? Send them e-mail asking for it if you think it's worthwhile (mswish@microsoft.com).
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:
Add the class file(s) that implement your smart types.
Use search and replace to turn dumb types into Smarties.
Run your code and thoroughly exercise (exorcise) it to find your coercion woes.
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