December 1999 |
by Francesco Balena
Reprinted with permission from Visual Basic Programmer's Journal, December 1999, Volume 9, Issue 12, Copyright 1999, Fawcette Technical Publications, Palo Alto, CA, USA. To subscribe, call 1-800-848-5523, 650-833-7100, visit www.vbpj.com, or visit The Development Exchange.
Visual Basic won't be considered a full-fledged object-oriented programming language until it supports true constructors and inheritance, among other features. However, this doesn't prevent you from enforcing bulletproof encapsulation with constructor methods of your own creation. Coupling a good constructor scheme with well-designed property procedures lets you ensure no one can misuse the classes you spend your time crafting so carefully.
|
In this article, I'll show you how to enforce better encapsulation by means of property procedures, as well as how to work around VB's lack of native constructor methods. Following the guidelines laid out in this article will enable you to create more robust classes that actively protect themselves from incorrect usage and indirectly increase their reusability in other projects.
Let's begin by reviewing what the VB language gives you to encapsulate data inside a class module. For example, assume you want to create a CInvoice class that exposes properties such as Number and InvDate. Your first implementation of this class might implement such properties as Public variables:
Public Number As Long
Public InvDate As Date
The problem with this approach is that any client can assign incorrect values to the properties without raising an error:
Dim inv As New CInvoice
inv.Number = -1
Rejecting invalid invoice numbers explicitly requires wrapping a property procedure around a private member variable:
' Member variable for Number property
Private m_Number As Long
Property Get Number() As Long
Number = m_Number
End Property
Property Let Number(newValue As Long)
If newValue <= 0 Then
Err.Raise 1001, , _
"Invalid invoice number"
End If
m_Number = newValue
End Property
The correct way to deal with invalid values is to raise an error with the Err.Raise method; fight the temptation to display an error message. Remember that a class's ultimate destiny is to become an ActiveX component, in which case your message box faces various possible fates. For example, it might be covered by other windows if the component is compiled as a separate ActiveX EXE project run locally, or it might be displayed on another machine if the component is run remotely through DCOM. Your message box might even be suppressed if the component is compiled with the Unattended Execution option, as is the case with Microsoft Transaction Server (MTS) and Internet Information Server (IIS) components.
Read and Write Not Always
an Option
Client applications shouldn't be allowed to read and write all properties. For example, the Total property of the CInvoice class should be read-only because it's evaluated by summing the value of each product listed in the invoice. You can implement read-only properties by omitting the Property Let procedure:
Private m_Total As Currency
Property Get Total() As Currency
Total = m_Total
End Property
A more frequent aim is to expose properties as read-only when manipulated from outside your projectthat is, from apps that use your component through COMyet expose them as read/write properties when manipulated from inside your component. This enables other modules in your ActiveX project to assign a value to such properties freely. You can create properties that are read/write from inside the project and read-only from outside it by using the Friend attribute for the Property Let procedure, while leaving the Public attribute (or omitting the visibility attribute) for the Property Get procedure:
' this procedure can be called only
' from inside the current project
Friend Property Let Total _
(newValue As Currency)
' always trap invalid values
If newValue <= 0 Then
Err.Raise 1002, , _
"Invalid invoice total"
End If
m_Total = newValue
End Property
In theory, you can also create a write-only property by omitting its Property Get procedure and providing only its Property Let procedure (or Property Set, if the property returns an object). But you should avoid write-only properties in practice because they seem unnatural to VB developers. For example, assume you want to implement a Password property that can be assigned but not read back: You would use a SetPass-word method rather than implement a Property Let Password without its Property Get counterpart:
Private m_Password As String
Sub SetPassword(newPassword As String)
m_Password = newPassword
End Sub
Write-once/read-many properties present yet another variation on this theme. For example, the Number property of the CInvoice class should reject multiple assignments because the property might partake in relationships with other objects, such as a COrder object. Or, the Number property might serve as the key to reference the invoice in a database record. Visual Basic doesn't provide a native way to implement a write-once/read-many property, but it's easy to implement one through code in its Property Let procedure:
Property Let Number(newValue As Long)
Static Initialized As Boolean
If Initialized Then
Err.Raise 999, , "Number can " _
& "be assigned only once"
End If
' trap invalid values (omitted)
' ...
m_Number = newValue
Initialized = True
End Property
Say Hello to Constructors
A well-designed set of property procedures ensures that a client application can only transform the object's internal data from one valid state to another valid state, but this isn't enough to ensure that the class is robust enough for real-world applications. In fact, nothing prevents a client app from using the object immediately after creating it, when no properties have been assigned:
Dim inv As CInvoice
Set inv = New CInvoice
' Number contains an invalid value
Print inv.Number ' Displays "0"
Other more mature object-oriented languages offer a way to work around this issue: constructor methods. A constructor is a special method the client must invoke to create an instance of the class. A class's author defines the constructor's syntax, so it's possible to force the client to pass all the properties required to create the object in a valid state. If VB supported constructors, you might create a CInvoice object using this syntax:
Dim inv As CInvoice
Set inv = New CInvoice(Number:=1, _
InvDate:=#10/1/99#)
Unfortunately, it doesn't, so I'll show you how you can simulate this missing capability. Begin by extending the class module with a method that lets the client code correctly set all the required properties at once. For example, I usually implement an Init method in all my class modules:
' In the CInvoice class
Friend Sub Init(Number As Long, _
InvDate As Date)
Me.Number = Number
Me.InvDate = InvDate
End Sub
The Me keyword prevents name conflicts between properties and arguments with the same name. It also forces the execution of the Property Let procedures, which in turn validate the values being passed to the Init procedure.
If any Property Let procedure fails with an error, the error is reported to the Init procedure, and eventually, to the client code. A client can exploit this method to initialize an instance of the class through more concise code:
' client code in the same project
Dim inv As CInvoice
Set inv = New CInvoice
inv.Init 1, #10/1/99#
You can also improve this initialization pattern by providing support for properties not strictly required to create the object in a consistent state:
' In the CInvoice class
Friend Sub Init(Number As Long, _
InvDate As Date, _
Optional Employee As Variant)
Me.Number = Number
Me.InvDate = InvDate
' Employee isn't a required
' property
If Not IsMissing(Employee) Then
Me.Employee = Employee
End If
End Sub
Optional arguments make the Init method even more appealing to programmers who use the class, because they reduce the number of lines of code it takes to initialize an object.
This approach has several advantages, but its limitations are obvious: You can encourage developers to use the Init method to correctly initialize an object immediately after creating the object, but you can't force them to do so.
One solution is to add a Boolean variable to the class. You set this variable to True in the Init method, then test it inside all property and method procedures in the class module, raising an error if it ever returns False. However, this approach proves impractical because it forces you to insert code in each and every Public procedure in the class module. Fortunately, there is a better solution.
Create Objects
With Factory Methods
Add a BAS module to the current project, press F4 to bring up the Properties window, name the module Factory, then add this code:
Function New_CInvoice(Number As Long, _
InvDate As Date) As CInvoice
Set New_CInvoice = New CInvoice
New_CInvoice.Init Number, InvDate
End Function
Note that you don't need a local object variable because you can use the name of the function as a variable inside the scope of the procedure. Also, the New_CInvoice function knows nothing about the CInvoice classin fact, it delegates all validation chores to the class's Init method.
This new function lets you create new instances of the class using a syntax that closely resembles real constructors:
' In the client code
Dim inv As CInvoice
Set inv = New_CInvoice(1, #10/1/99#)
If the client provides incorrect values for any argument, the error bubbles up from the innermost Property Let procedures to the Init method, then to the New_CInvoice function, which immediately destroys the CInvoice instance created internally. This means the client code never receives a reference to a CInvoice object if an error occurs.
These pseudoconstructor functions make the code look more object-oriented, but they still suffer from the same problem as the Init method because the client code can easily bypass them and create an instance of the class directly. However, you can ensure that the only legal way to create a CInvoice object is through the New_CInvoice method with only a small amount of additional code. The trick: Use a private variable in the BAS module and encapsulate it in a function or in a Property Get procedureyes, you can use property procedures, even in BAS modules. This property procedure ensures the variable is read-only from outside the module; it also ensures that the variable's value is reset to False after being read:
' In the Factory.Bas module
Dim m_Initializing As Boolean
Property Get Initializing() As Boolean
Initializing = m_Initializing
m_Initializing = False
End Property
Next, set this variable to True in the New_CInvoice function, just before creating the new instance of the CInvoice class:
Function New_CInvoice(Number As Long, _
InvDate As Date) As CInvoice
m_Initializing = True
Set New_CInvoice = New CInvoice
If Initializing Then
Err.Raise 998,, "Missing code " _
& " in CInvoice Initialize"
End If
New_CInvoice.Init Number, InvDate
End Function
Finally, check the variable's value from within the Class_Initialize event procedure of the class being created:
Private Sub Class_Initialize()
If Not Initializing() Then
Err.Raise 999, ,"CInvoice " _
& " created without a " _
& " constructor method"
End If
End Sub
Reading the variable indirectly resets it to False. Note that referencing the Initializing property from within the CInvoice class breaks the self-containment of the class itself, because it now depends on the Factory.bas module. This means you must include the BAS module when you reuse the class in another projectwhich is no big deal because you should do this anyway if you want to take advantage of the constructor mechanism.
Better Encapsulation
These constructors guarantee better encapsulation by trying to create an instance of the CInvoice class without passing through the New_CInvoice pseudocon-structor method:
Dim inv As New CInvoice
inv.Number = 1 ' shows an error msg
Figure 1. Build a Class That Protects Itself. Click here. |
One piece of good news: You can provide true constructor methods if you make your classes available to the outside world through COM. An even better piece of news: Implementing this technique takes only a minute or two, and it doesn't require you to change a single line of code in the clients that use the class.
First, change the Instancing attribute of the CInvoice class to 2-PublicNotCreatable. This prevents clients from using the New keyword with it. The only way for a client to create a CInvoice object is through the constructor methods you provide.
Figure 2. Put GlobalMultiUse to Good Use. Click here. |
' In the FactoryClass module
Function New_CInvoice(Number As Long, _
InvDate As Date) As CInvoice
Set New_CInvoice = Factory. _
New_CInvoice(Number, InvDate)
End Function
The New_CInvoice method can delegate to the procedure with the same name in the Factory.bas module without any name ambiguity simply by using the module's name as a prefix, as in Factory.New_CInvoice.
The GlobalMultiUse setting ensures that a client VB app can access the Public members of the FactoryClass class without explicitly instancing an object of that type. This means a client can create a CInvoice object through COM with this code:
' In the client code
Dim inv As CInvoice
Set inv = New_CInvoice(1, #10/1/99#)
You might recall that this is the same code you use to create the object from within the component's project. In other words, you can recycle the code written previously even after moving it to another project. Now that's code reuse!
Francesco Balena is editor in chief of Visual Basic Journal, VBPJ's Italian licensee, coauthor of Platinum Edition Using Visual Basic 5> (Que), author of Programming Visual Basic 6.0 (Microsoft Press), and a frequent speaker at VBITS conferences. Contact Francesco at fbalena@infomedia.it or visit his Web site at www.vb2themax.com.