Visual Basic itself doesn't always help you detect errors or error conditions. For example, consider the following code fragment:
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 11111w2213l 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 .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")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)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()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 = TrueThen wrap your validation routines appropriately:
Private Sub Vali_Date(ByVal v As Variant)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:
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 DateOf course, it can still be used like this:
Dim s As StringHmm, 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 ([email protected]).
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 iDateNote 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 DateOK 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 iDateIf 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 iDateJust 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)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 iDateAnother 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 |
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 iStringLook 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 iIntegerNormally 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
The constants vbObjectiInteger, vbObjectiSingle, etc. are defined publicly and initialized on program start-up like this:
Public Sub main()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()In a testing module:
Public Function IntegerToReal(ByVal vI As Variant) As DoubleHere 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.
|