7. 事件、多形和繼承

上一章,我們提到OOP的基礎以及如何使用物件來發展更簡潔且健全的應用系統。而本章我們探討更深入、進階的主題-例如多形(polymorphism)、次要介面(secondary interfaces)、事件、繼承和物件階層。將類別和物件分為兩個不同的章節,反映出OOP特性的歷史發展:多數基本的特性在 第六章 已提到,且在微軟Visual Basic 4就已經有的功能,而這一章大致著重在Visual Basic 5加強且Visual Basic 6沒啥改變的部分。

事件
 

在Visual Basic 4的版本,當物件被建立和釋放時,類別事件只包含Visual Basic執行函式庫所引發內在的Class_Initialize和Class_Terminate事件;而到了Visual Basic 5和Visual Basic 6,類別可在外部引發事件,如控制項和表單。此能力加強了類別模組的能力,尤其當考慮個別和可重用模組時,可讓應用程式的類別模組盡可能的緊密結合在一起。

事件和程式可重用性
 

在提到類別模組如何撰寫事件讓外部程式呼叫,且用戶端程式如何引發 事件類別模組之前,讓我們解釋為什麼事件對程式碼的再使用(reuse)是如此重要。為了闡述此概念,筆者會以一個類別模組作為假設,此類別模組主要的任務是複製一連串檔案,且隨時通知呼叫者有關動作的進度。在沒有使用事件情況下,有兩個方法可以執行這樣一個程式,但這兩個方法都不符合我們的需求:

事件可以提供到上述問題一個最佳解決方法:

事件的語法
 

實作類別模組的事件和使用用戶端模組的事件是個簡單的過程。圖7-1顯示實作事件的過程。筆者用CFileOp類別作為範例,如同前面提到的,此類別可複製多個檔案。


 

圖7-1 實作類別模組中的事件。

宣告事件
 

要在用戶端包含一個事件,必須在宣告區中包含一個Event敘述。這一敘述告訴外界有關事件的名稱及引數。例如,CFileOp類別有以下這個事件:

Event FileCopyComplete(File As String, DestPath As String)

這引數的語法沒有任何特別之處,事實上可以宣告Visual Basic支援的任一型態的引數,包括物件、集合和列舉(Enum)值。

觸發事件
 

執行RaiseEvent敘述,類別將會觸發事件,此敘述指定事件名稱和實際引數。再者,於觀念上與呼叫程序並沒有不同,微軟IntelliSense可以選擇事件名稱和其引數的值。CfileOp類別的寫法如下:

RaiseEvent FileCopyComplete "c:\bootlog.txt", "c:\backup"

這是在類別模組中必須要做的部分。接下來看看用戶端需要做什麼。

宣告用戶端模組中的物件
 

若在表單中或類別模組中寫程式,想要從物件中取得事件,必須在模組的宣告區中用WithEvents關鍵字宣告物件的參照:

' You can use Public, Private, or Dim, as needed.
Dim WithEvents FOP As CfileOp

WithEvents事件的相關注意資訊:

補抓事件
 

至此,Visual Basic已有回應事件時所需的所有資訊。實際上,在程式編輯視窗的下拉清單左邊,會看到使用WithEvents宣告的變數出現在列表中,連同已經存在表單上的所有控制項。選擇它,且自右側的下拉清單選擇想要的事件(在這個範例中,僅有一個事件)。就像控制項的事件,Visual Basic會自動建立程序範本,接下來必須要做的就是寫程式:

Private Sub Fop_FileCopyComplete(File As String, DestPath As String)
    MsgBox "File " & File & " has been copied to " & DestPath
End Sub

第一個完整的範例應用
 

現在已解釋所有語法細節,也該是完成CFileOp類別的完整性的時候了,要使它能夠複製一個或多個檔案,且回應給呼叫者。一開始的範例程式提供許多以事件為基礎且複雜、有趣的設計技巧。

CFileOp類別模組
 

建立一個類別模組,命名為CFileOp。這類別有些屬性,允許用戶端決定哪些檔案應該被複製(FileSpec、Path和Attributes屬性),並有一個開始實際複製過程的方法。此類別也有一個FileCopyComplete事件。

' The CFileOp class module
Event FileCopyComplete(File As String, DestPath As String)
Private m_FileSpec As String
Private m_Filenames As Collection
Private m_Attributes As VbFileAttribute

Property Get FileSpec() As String
    FileSpec = m_FileSpec
End Property
Property Let FileSpec(ByVal newValue As String)
    ' Reset the internal Collection if a new file specification is given.
    If m_FileSpec <> newValue Then
        m_FileSpec = newValue
        Set m_Filenames = Nothing
    End If
End Property
Property Get Path() As String
    Path = GetPath(m_FileSpec)
End Property
Property Let Path(ByVal newValue As String)
    ' Get current file specification, and then substitute just the path.
    FileSpec = MakeFilename(newValue, GetFileName(FileSpec))
End Property
Property Get Attributes() As VbFileAttribute
    Attributes = m_Attributes
End Property
Property Let Attributes(ByVal newValue As VbFileAttribute)
    ' Reset the internal Collection only if a new value is given.
    If m_Attributes <> newValue Then
        m_Attributes = newValue
        Set m_Filenames = Nothing
    End If
End Property
' Holds the list of all the files that match FileSpec,
' plus any other file added by the client code (read-only property)
Property Get Filenames() As Collection
    ' Build the file list "on demand", and only if necessary.
    If m_Filenames Is Nothing Then ParseFileSpec
    Set Filenames = m_Filenames
End Property
' Parses a file specification and attributes and adds
' the resulting filename to the internal m_Filenames Collection
Sub ParseFileSpec(Optional FileSpec As Variant, _
    Optional Attributes As VbFileAttribute)
    Dim file As String, Path As String
    ' Provide a default for arguments.
    If IsMissing(FileSpec) Then
        ' In this case, we need a file specification.
        If Me.FileSpec = "" Then Err.Raise 1001, , "FileSpec undefined"
        FileSpec = Me.FileSpec
        Attributes = Me.Attributes
    End If
    ' Create the internal Collection if necessary.
    If m_Filenames Is Nothing Then Set m_Filenames = New Collection
    Path = GetPath(FileSpec)
    file = Dir$(FileSpec, Attributes)
    Do While Len(file)
        m_Filenames.Add MakeFilename(Path, file)
        file = Dir$
    Loop
End Sub
Sub Copy(DestPath As String)
    Dim var As Variant, file As String, dest As String
    On Error Resume Next
    For Each var In Filenames
        file = var
        dest = MakeFilename(DestPath, GetFileName(file))
        FileCopy file, dest
        If Err = 0 Then
            RaiseEvent FileCopyComplete(file, DestPath)
        Else
            Err.Clear
        End If
    Next
End Sub
' Support routines that parse a filename. They are used internally
' but are also exposed as Public for convenience.
Sub SplitFilename(ByVal CompleteName As String, Path As String, _
    file As String, Optional Extension As Variant)
    Dim i As Integer
    ' Assume there isn't any embedded path.
    Path = "": file = CompleteName
    ' Backward search for a path delimiter
    For i = Len(file) To 1 Step -1
        If Mid$(file, i, 1) = "." And Not IsMissing(Extension) Then
            ' We have found an extension, and the caller asked for it.
            Extension = Mid$(file, i + 1)
            file = Left$(file, i - 1)
        ElseIf InStr(":\", Mid$(file, i, 1)) Then
            ' Paths don't have a trailing backslash.
            Path = Left$(file, i)
            If Right$(Path, 1) = "\" Then Path = Left$(Path, i - 1)
            file = Mid$(file, i + 1)
            Exit For
        End If
    Next
End Sub
Function GetPath(ByVal CompleteFileName As String) As String
    SplitFilename CompleteFileName, GetPath, ""
End Function
Function GetFileName(ByVal CompleteFileName As String) As String
    SplitFilename CompleteFileName, "", GetFileName
End Function
Function MakeFilename(ByVal Path As String, ByVal FileName As String, _
    Optional Extension As String) As String
    Dim result As String
    If Path <> "" Then
        ' Path might include a trailing backslash.
        result = Path & IIf(Right$(Path, 1) <> "\", "\", "")
    End If
    result = result & FileName
    If Extension <> "" Then
        ' Extension might include a dot.
        result = result & IIf(Left$(Extension, 1) = ".", ".", "") _
            & Extension
    End If
    MakeFilename = result
End Function

現在各位應該對類別模組的架構很清楚,所以接下來僅解釋一些細微的細節。當傳遞值給FileSpec或者Attribution屬性時,類別會重設內部的m_Filenames集合變數。當最後Filenames Public屬性被參照時-從類別模組外部或內部-相對的Property Get程序會確認檔案列表是否該再次建立,若需要的話,會觸發ParseFileSpec方法。這方法對類別模組而言是私有的,但是使它公開化可增加某些彈性,如同在 <過濾輸入日期> 一節中所提到的。至此,對於Copy方法而言,一切都準備就緒,只需要DestPath引數來知道檔案將被複製到哪裡,和何處觸發FileCopyComplete事件回到用戶端。其他函數-SplitFilename、GetPath、GetFilename等等-是用來分析檔案和路徑的支援程序。然而因為對用戶端程式有用,因而他們被宣告為Public。

用戶端表單模組
 

增加一個表單模組到專案中,並新增一些控制項,如圖7-2:


 

圖7-2 CfileOp範例程式設計時期的最初版本

使用以下的程式可幫助決定控制項要用什麼名字(或者可以載入光碟片上的程式)。筆者習慣對控制項使用明白的名稱,所以您不該對每個控制項的函數有任何問題。以下是表單模組中的程式。

' The client Form1 module
Dim WithEvents Fop As CFileOp

Private Sub Form_Load()
    ' WithEvents objects can't be auto-instancing.
    Set Fop = New CFileOp
End Sub
Private Sub cmdParse_Click()
    Dim file As Variant
    InitFOP
    lstFiles.Clear
    For Each file In Fop.Filenames
        lstFiles.AddItem file
    Next
    picStatus.Cls
    picStatus.Print "Found " & Fop.Filenames.count & " files.";
End Sub
Private Sub cmdCopy_Click()
    InitFOP
    Fop.Copy txtDestPath.Text
End Sub
' A useful routine shared by many procedures in the form
Private Sub InitFOP()
    Fop.FileSpec = txtFilespec
    Fop.Attributes = IIf(chkHidden, vbHidden, 0) + _
        IIf(chkSystem, vbSystem, 0)
End Sub
' Trapping events from CFileOp class
Private Sub Fop_FileCopyComplete(File As String, DestPath As String)
    picStatus.Cls
    picStatus.Print "Copied file " & File & " ==> " & DestPath;
End Sub

沒有任何事情比實際追蹤更能了解事件實際的運作。設定一些中斷點,輸入來源和目的地合理路徑,按下分析或複製按鍵(小心不要覆蓋到您需要的檔案),按下F8看看程式流程。

改善範例程式
 

CFileOp類別模組是一段不錯的程式,可新增許多新特性加以改善它。大多數的事實證明,這些新技巧可用事件來實作。

過濾輸入的資料
 

在第一版,CfileOp類別簡單地分析指派給FileSpec屬性的值,建立被複製的檔案列表,依據Attributes屬性值。但用戶端程式沒有辦法過濾出特殊的檔案,例如暫存檔或備份檔,或特定名稱的檔案。幸好事件有彈性,只須增加一個新的事件宣告到類別中即可:

' In the declaration section of the CFileOp class module
Event Parsing(file As String, Cancel As Boolean)

且在ParseFileSpec程序內新增一些敘述(粗體字表示):

' ... inside the ParseFileSpec routine
        Dim Cancel As Boolean
        Do While Len(file)
            Cancel = False
            RaiseEvent Parsing(file, Cancel)
            If Not Cancel Then 
                m_Filenames.Add MakeFilename(Path, file)
            End If
            file = Dir$
        Loop

善用用戶端新事件是很容易的。假設想要在複製過程中排除暫存檔。所必須做的是,當類別即將複製不感興趣的檔案時,補抓Parsing事件且設定Cancel參數為True,程式碼如下:

' In the client form module
Private Sub Fop_Parsing(file As String, Cancel As Boolean)
    Dim ext As String
    ' GetExtension is a handy method exposed by CFileOp.
    ext = LCase$(Fop.GetExtension(file))
    If ext = "tmp" Or ext = "$$$" Or ext = "bak" Then Cancel = True
End Sub

處理多個檔案
 

這與事件無關,但筆者只是想要示範當想要擴展類別特性時,一個謹慎設計的類別模組如何簡化您的工作。因為類別有個宣告為Public的ParseFileSpec方法,用戶端可任意呼叫此程序來增加無關的檔名。

' Prepare to copy EXE files, using the standard FileSpec property.
Fop.FileSpec = "C:\Windows\*.exe"
' But also copy all executable files from another directory.
Fop.ParseFileSpec "C:\Windows\System\*.Exe", vbHidden
Fop.ParseFileSpec "C:\Windows\System\*.Com", vbHidden

CfileOP類別模組總是在用戶端程式中引發Parsing事件是這方式的最大優點,因此不管他們如何被加到內部,總是有機會來排除檔名。另一個彈性設計的範例為ParseFileSpec程序有搜尋多個檔案的能力。ParseFileSpec不能直接依靠模組層級變數,所以可以簡單地增加一些敘述使它變成更有力的遞迴:

' Create the internal Collection if necessary.
        If m_Filenames Is Nothing Then Set m_Filenames = New Collection
        ' Support for semicolon delimited multiple file specifications
        Dim MultiSpecs() As String, i As Integer
        If InStr(FileSpec, ";") Then
            MultiSpecs = Split(FileSpec, ";")
            For i = LBound(MultiSpecs) To UBound(MultiSpecs)
                ' Recursive call to this routine
                ParseFileSpec MultiSpecs(i)
            Next
            Exit Sub
        End If
        Path = GetPath(FileSpec)
        ' And so on....

因為FileSpec屬性內部是使用ParseFileSpec程序,它會自動繼承接受用分號界定檔案的能力。書附光碟上的類別模組即是以這一技巧為基礎。


預先通知事件
 

到目前為止,已看到複製動作之後FileCopyComplete事件立即被觸發,因為其暗示用戶端程式,在類別模組內某些事情已發生。一個彈性的類別甚至會在動作發生前通知用戶端。換句話說,就是一個WillCopyFile事件:

Enum ActionConstants
    foContinue = 1
    foSkip
    foAbort
End Enum
Event WillCopyFile(file As String, DestPath As String, _
    Action As ActionConstants)

筆者使用一個標準的布林Cancel引數,但列舉值會增加更多彈性。在實際複製前,Copy方法中會先觸發WillCopyFile事件。以下是修正過的程序,修正或增加的部分以粗體表示:

Sub Copy(DestPath As String)
    Dim var As Variant, file As String, dest As String
    Dim Action As ActionConstants
    On Error Resume Next
    For Each var In Filenames
        file = var
        dest = MakeFilename(DestPath, GetFileName(file))
        Action = foContinue
        RaiseEvent WillCopyFile(file, dest, Action)
        If Action = foAbort Then Exit Sub
        If Action = foContinue Then
            FileCopy file, dest
            If Err = 0 Then
                RaiseEvent FileCopyComplete(file, GetPath(dest))
            Else
                Err.Clear
            End If
        End If
    Next
End Sub

要善用這一新事件,用戶端表單模組用一個確認的CheckBox控制項來加強之,若被選擇的話,則讓使用者控制複製過程。幸好有WillCopyFile,所以可用少量的敘述來實作新的特性:

Private Sub Fop_WillCopyFile(File As String, DestPath As String, _
    Action As ActionConstants)
    ' Exit if user isn't interested in file-by-file confirmation.
    If chkConfirm = vbUnchecked Then Exit Sub
    Dim ok As Integer
    ok = MsgBox("Copying file " & File & " to " & DestPath & vbCr _
        & "Click YES to proceed, NO to skip, CANCEL to abort", _
        vbYesNoCancel + vbInformation)
    Select Case ok
        Case vbYes: Action = foContinue
        Case vbNo: Action = foSkip
        Case vbCancel: Action = foAbort
    End Select
End Sub

可以用預先通知事件的機制取得更好的結果,比允許或防止處理過程的完整性方法來得好。實際上,多數或所有事件型態的引數是以參照傳遞,以此來通知呼叫者。通常與用KeyAscii引數傳遞給標準控制項的KeyPress事件程序是相似的。例如,可決定所有的BAK檔案應該被複製到不同的目錄下:

' Inside the WillCopyFile event procedure (in the client)...
        If LCase$(Fop.GetExtension(file)) = "bak" Then
            DestPath = "C:\Backup"
        End If

通知用戶端錯誤狀況
 

多數情況下,對於類別回傳錯誤訊息給用戶的最好方法是使用標準的Err.Raise方法。這允許用戶端取得事情錯誤的定義確認。然而,當一類別透過事件與用戶端聯繫時,有一些方式可替代Err.Raise方法。例如,若CfileOp類別不能複製某特定檔案時,整個複製程序應該被暫停嗎?不用說,只有用戶端程式知道答案,所以告訴它是正確的-當然是用事件:

Event Error(OpName As String, File As String, File2 As String, _
    ErrCode As Integer, ErrMessage As String, Action As ActionConstants)

我已增加一個通用的OpName引數以便相同Error事件可被類別模組中的所有方法共享。在Copy方法增加對此新事件的支援需要些技巧:

' Inside the Copy method in the CFileOp class module...
FileCopy File, dest
If Err = 0 Then
    RaiseEvent FileCopyComplete(File, DestPath)
Else
    Dim ErrCode As Integer, ErrMessage As String
    ErrCode = Err.Number: ErrMessage = Err.Description
    RaiseEvent Error("Copy", File, DestPath, ErrCode, _
        ErrMessage, Action)
    ' Report the error to the client if user aborted the process.
    If Action = foAbort Then
        ' You need to cancel error handling, otherwise the Err.Raise
        ' method won't return the control to the client.
        On Error GoTo 0           
        Err.Raise ErrCode, , ErrMessage
    End If
Err.Clear
End If

目前用戶端有能力補抓錯誤並決定如何處理他們。例如「 錯誤編號76-找不到路徑」意思是不論來源或目的都是無效的,所以沒有任何動作繼續運作:

Private Sub Fop_Error(OpName As String, File As String, File2 As String, _
    ErrCode As Integer, ErrMessage As String, Action As ActionConstants)
    If ErrCode = 76 Then
        MsgBox ErrMessage, vbCritical
        Action = foAbort
    End If
End Sub

這程式沒有測試OpName引數:這是故意省略,因為相同的程式可以管理類別中所有方法觸發的錯誤。也要注意類別傳遞ErrCode和ErrMessage兩參照,且用戶端可任意修改他們:

' Use a custom error scheme for this client.
    If OpName = "Copy" Then 
        ErrCode = ErrCode + 1000: ErrMessage = "Unable to Copy"
    ElseIf OpName = "Move" Then
        ErrCode = ErrCode + 2000: ErrMessage = "Unable to Move"
    End If
    Action = foAbort

通知用戶端進度
 

通知使用者有關處理過程的進度對事件而言是最平常的使用。每個預先通知和後面通知事件可被認為是處理過程在運作的訊號,所以Progress事件可能看來好像是多餘的。但若還有另一個事件,程式可用來通知使用者關於進度,例如使用狀態列來顯示工作完成度的話,可提供用戶更好的服務。此技巧是只當實際百分比改變時才觸發事件,所以程式無須持續更新使用介面:

Event ProgressPercent(Percent As Integer)

在完成包含ProgressPercent事件的類別後,瞭解到可將此事件的大部分邏輯程式放在通用程序中,其可在所有類別模組中重用:

Private Sub CheckProgressPercent(Optional NewValue As Variant, _
    Optional MaxValue As Variant)
    Static Value As Variant, Limit As Variant
    Static LastPercent As Integer
    Dim CurrValue As Variant, CurrPercent As Integer
    If Not IsMissing(MaxValue) Then
        Limit = MaxValue
        If IsMissing(NewValue) Then Err.Raise 9998, , _
            "NewValue can't be omitted in the first call"
        Value = NewValue
    Else
        If IsEmpty(Limit) Then Err.Raise 9999, , "Not initialized!"
        Value = Value + IIf(IsMissing(NewValue), 1, NewValue)
    End If
    CurrPercent = (Value * 100) \ Limit
    If CurrPercent <> LastPercent Or Not IsMissing(MaxValue) Then
        LastPercent = CurrPercent
        RaiseEvent ProgressPercent(CurrPercent)
    End If
End Sub

CheckProgressPercent程序的架構有點被扭曲,因為它必須對許多可能引數的預設值而負責。可用兩個、一個引數或不使用任何引數來呼叫它。當想要重新設定其內部計數器Value和Limit時,可用兩個引數來呼叫它。當只是要遞增Value時,可只用一個引數來呼叫它。最後,當要對Value每次遞增1時,則不用任何引數。此彈性方案簡化類別的方法如何引入此程序,且多數情況下,只需要兩行敘述來引發Progress事件:

' In the Copy method 
On Error Resume Next
CheckProgressPercent 0, Filenames.Count     ' Reset internal counters.
For Each var In Filenames
    CheckProgressPercent                    ' Increment by 1.
    File = var
    ...

CheckProgressPercent程序是最佳化的,只當百分比確實改變時,才觸發ProgressPercent事件。這容許在用戶端撰寫程式碼不需要去追蹤改變:

Private Sub Fop_ProgressPercent(Percent As Integer)
    ShowProgress picStatus, Percent
End Sub
' A reusable routine that prints to a generic PictureBox
Private Sub ShowProgress(pic As PictureBox, Percent As Integer, _
    Optional Color As Long = vbBlue)
    pic.Cls
    pic.Line (0, 0)-(pic.ScaleWidth * Percent / 100, _
        pic.ScaleHeight), Color, BF
    pic.CurrentX = (pic.ScaleWidth - pic.TextWidth(CStr(Percent) _
        & " %")) / 2
    pic.CurrentY = (pic.ScaleHeight - pic.TextHeight("%")) / 2
    pic.Print CStr(Percent) & " %";
End Sub

在書附光碟上的CfileOp類別包括許多待改進的地方,例如對Move和Delete命令的支援,且包含Parsing事件,在分析過程中讓用戶端過濾特定檔案(請看圖7-3)。


 

圖7-3 CfileOp這一版本支援多重文件規格、萬用字元、額外的檔案命令、百分比進度條和個別檔案運作的完全控制。

多重播送(Multicasting)
 

最後,尚未提到的是以WithEvents為基礎的事件機制,其與COM以及Visual Basic本身的表單和控制項所觸發的所有事件相容。

此機制也就是事件多重播送(event Multicasting)。這意思是物件可以將事件的觸發到所有指向同一物件的用戶端。這樣解釋似乎有些模糊。

就您所知道的,表單模組總可以從自己本身的控制項中來引發事件。在Multicasting前,最好是補抓父表單的模組控制項事件。使用事件可能仍是最好的,但並非是唯一的。事實上,可以宣告一個明確的物件變數,讓它指向某特定控制項,且使用它來補抓控制項的事件。不論它是否被宣告,Multicasting機制確保變數會接受事件的通知。這表示可移動變數到程式的另一個模組(或另一個表單、類別、或除了標準BAS模組之外的其他地方)中,並仍會對控制項的所有事件做反應。

驗證TextBox控制項的類別
 

讓我們瞧瞧這對Visual Basic的程式設計師有什麼意義。為了表現Multicasting,僅需要一個非常簡單的CTextBxN類別模組,目的是排除在TextBox控制項中的任何非數字鍵。

Public WithEvents TextBox As TextBox

Private Sub TextBox_KeyPress(KeyAscii As Integer)
    Select Case KeyAscii
        Case 0 To 31                   ' Accept control chars.
        Case 48 To 57                  ' Accept digits.
        Case Else
            KeyAscii = 0               ' Reject anything else.
    End Select
End Sub

要測試此類別,請建立一個表單,放置一個TextBox控制項在表單上,然後增加以下的程式:

Dim Amount As CtextBxN
Private Sub Form_Load()
    Set Amount = New CTextBxN
    Set Amount.TextBox = Text1
End Sub

執行程式,然後試著在Text1鍵入一個非數字鍵。在幾次測試後,將會發現CtextBxN類別補抓所有自Text1引發的所有KeyPress事件,且處理代表Form1模組的確認程式。當有其他數字型欄位在表單中,這技巧會變得很有用,例如,一個新的Text2控制項,其需要百分比的值。

Dim Amount As CTextBxN, Percentage As CtextBxN
Private Sub Form_Load()
    Set Amount = New CTextBxN
    Set Amount.TextBox = Text1
    Set Percentage = New CTextBxN
    Set Percentage.TextBox = Text2
End Sub

替代在表單模組中建立不同的事件程序,每個確認按鍵對應不同的TextBox控制項,您可以將CtextBxN類別的確認邏輯封裝起來,重複使用。可以對Form1中的所有欄位這樣做,就像對應用程式中的任一表單的任何數量的欄位般。這叫做可重用程式!

改善CTextBxN類別
 

Multicasting的好處是不會使您忘記CtextBxN是個一般的類別模組,其可用屬性和方法來改進。例如,讓我們增加三個新屬性,其會使得類別更為有用: IsDecimal是布林值屬性,若是True,允許十位數; FormatMask是一個字串,當焦點離開控制項時用來格式化數字; SelectOnEntry是個布林值屬性,當控制項取得焦點時,描述是否目前值應該被強調。以下是CtextBxN類別的最新程式碼:

Public WithEvents TextBox As TextBox
Public IsDecimal As Boolean
Public FormatMask As String
Public SelectOnEntry As Boolean
Private Sub TextBox_KeyPress(KeyAscii As Integer)
    Select Case KeyAscii
        Case 0 To 31                   ' Accept control chars.
        Case 48 To 57                  ' Accept digits.
        Case Asc(Format$(0.1, "."))    ' Accept the Decimal separator.
            If Not IsDecimal Then KeyAscii = 0
        Case Else
            KeyAscii = 0               ' Reject anything else.
    End Select
End Sub
Private Sub TextBox_GotFocus()
    TextBox.Text = FilterNumber(TextBox.Text, True)
    If SelectOnEntry Then 
        TextBox.SelStart = 0
        TextBox.SelLength = Len(TextBox.Text)
    End If
End Sub
Private Sub TextBox_LostFocus()
    If Len(FormatMask) Then 
        TextBox.Text = Format$(TextBox.Text, FormatMask)
    End If
End Sub
' Code for FilterNumber is omitted. (See Chapter 3.)

使用新屬性是很好的事。只要在Form_Load程序中就可以看到,且應用更好用的TextBox控制項。

' In the Form_Load event procedure
Amount.FormatMask = "#,###,###"
Amount.SelectOnEntry = True
Percentage.FormatMask = "0.00"
Percentage.IsDecimal = True
Percentage.SelectOnEntry = True

傳遞用戶端事件到收納器
 

因為CtextBxN是個一般的類別模組,它甚至可宣告且引發自訂的事件。此能力是很有趣的:這類別從原始的表單中「 截取」控制項的事件,然後傳遞其他事件給此表單。為了證明此概念,筆者會告訴您如何將驗證Min和Max屬性方式增加到類別中。在一般的程式中,於父表單的Validate事件中表現驗證動作(請看 第三章 )。但現在可補抓事件並預先處理新的自訂屬性:

' In the CTextsBxN class module
Event ValidateError(Cancel As Boolean)
Public Min As Variant, Max As Variant

Private Sub TextBox_Validate(Cancel As Boolean)
    If Not IsEmpty(Min) Then
        If CDbl(TextBox.Text) < Min Then RaiseEvent ValidateError(Cancel)
    End If
    If Not IsEmpty(Max) Then
        If CDbl(TextBox.Text) > Max Then RaiseEvent ValidateError(Cancel)
    End If
End Sub

若類別發現一個超出範圍的錯誤,僅在原本的表單上引發ValidationError,傳以Cancel引數作為參照。因此可決定是否真要在用戶端表單模組取消此動作,就像在正常環境下:

' Now Percentage must be declared using WithEvents.
Dim WithEvents Percentage As CTextBxN
Private Sub Form_Load()
    ' ...
    Percentage.Min = 0
    Percentage.Max = 100
End Sub
' ...
Private Sub Percentage_ValidateError(Cancel As Boolean)
    MsgBox "Invalid Percentage Value", vbExclamation
    Cancel = True
End Sub

另一方面,可以在類別模組中設定Cancel為True,讓用戶端程式有機會重設其為False。這些只是細微事情。最重要是現在已完全掌握控制項內部所發生的事,且在自身表單中用最少量的程式來處理,因為大多數的邏輯是封裝在類別模組。

從數個控制項中捕捉事件
 

現在知道如何用控制項模組來補抓控制項事件,可延伸此技術到數個控制項。例如,可補抓一個TextBox控制項和一個ScrollBar控制項的事件來模擬那些小的上下按鈕。或可重建第三章的可捲動表單範例,建立CscrollForm類別模組來補抓表單與其兩個捲動軸事件。此外,我還會把重點放於新鮮有趣的事情。在底下範例中,筆者將證明透過Multicasting來建立即時移動位置的欄位有多容易。這範例有點複雜,但筆者相信最後您會很樂意花時間來研究它。

筆者建立的CTextBoxCalc類別模組可以從五個不同的TextBox控制項(獨立的欄位)補抓Change事件,且使用其能力來更新表單上另一個TextBox的內容(相依欄位),而不用在主程式中介入。為建立一個總稱的可計算欄位,筆者需要對用戶端程式設計一個方法,必須每次獨立控制項觸發Change事件時重新計算。為達到這一效果,類別有個可接受一個陣列參數的SetExpression方法。每參數可以是個控制項、數字或代表四種數學運算中之一的字串參照。例如,以下的程式碼:

' Example of client code that uses the CtextBoxCalc class
' txtTax and txtGrandTotal depend on txtAmount and txtPercent.
Dim Tax As New CtextBoxCalc, GrandTotal As New CtextBoxCalc
' Link the class to the control on which the result is to be displayed.
Set Tax.TextBox = txtTax
' Set the expression "Amount * Percent / 100".
Tax.SetExpression txtAmount, "*", txtPercent, "/", 100
' Create a GrandTotal calculated field, equal to "Amount + Tax".
Set GrandTotal.TextBox = txtGrandTotal
GrandTotal.SetExpression txtAmount, "+", txtTax

複雜的CTextBoxCalc類別大都是需要分析傳給SetExpression方法的引數。這裡有四個數學運算,可從左到右來計算序。(例如,"2+3*4"為20而非14),換句話說,一個完整的類別模組僅有80行的程式:

' The complete source code for CTextBoxCalc class
Public TextBox As TextBox
Public FormatMask As String
' We can trap events from max 5 TextBox controls.
Private WithEvents Text1 As TextBox
Private WithEvents Text2 As TextBox
Private WithEvents Text3 As TextBox
Private WithEvents Text4 As TextBox
Private WithEvents Text5 As TextBox
' Here we store the arguments passed to SetExpression.
Dim expression() As Variant

Sub SetExpression(ParamArray args() As Variant)
    Dim i As Integer, n As Integer
    ReDim expression(LBound(args) To UBound(args)) As Variant
    For i = LBound(args) To UBound(args)
        If IsObject(args(i)) Then
            ' Objects must be stored as such, using Set.
            Set expression(i) = args(i)
            If TypeName(args(i)) = "TextBox" Then
                n = n + 1
                If n = 1 Then Set Text1 = args(i)
                If n = 2 Then Set Text2 = args(i)
                If n = 3 Then Set Text3 = args(i)
                If n = 4 Then Set Text4 = args(i)
                If n = 5 Then Set Text5 = args(i)
            End If
        Else
            ' Store number and strings without the Set keyword.
            expression(i) = args(i)
        End If
    Next
End Sub
' Here we actually evaluate the result.
Sub EvalExpression()
    Dim i As Integer, opcode As Variant
    Dim value As Variant, operand As Variant
    On Error GoTo Error_Handler
    For i = LBound(expression) To UBound(expression)
        If Not IsObject(expression(i)) And VarType(expression(i)) _
            = vbString Then
                opcode = expression(i)
        Else
            ' This works with numbers and Text (default) properties alike.
            operand = CDbl(expression(i))
            Select Case opcode
                Case Empty: value = operand
                Case "+": value = value + operand
                Case "-": value = value - operand
                Case "*": value = value * operand
                Case "/": value = value / operand
            End Select
            opcode = Empty
        End If
    Next
    If Len(FormatMask) Then value = Format$(value, FormatMask)
    TextBox.Text = value
    Exit Sub
Error_Handler:
    TextBox.Text = ""
End Sub
' Here we trap events from the independent fields.
Private Sub Text1_Change()
    EvalExpression
End Sub
' ... Text2-Text5 Change procedures .... (omitted)

類別可補抓最多五個獨立TextBox控制項的事件,但表示式可能僅表示一個或兩個。沒關係:若一個WithEvents變數沒辦法被指派且保持Nothing,它只是無用的,且不在類別引發事件而已。雖然沒有用但也沒有任何傷害。

執行隨書光碟的範例程式,看看如何培育一個相似的空白表格程式的表單,其接受兩組欄位的資料,且自動地更新其他兩個欄位(請參考圖7-4)。相同的應用程式展示了CtextBxn和CtextBoxCalc類別。


 

圖7-4 藉由可重用的外部類別方法,可以建立包含即時移動欄位的聰明表單。

Multicasting的陷阱
 

在應用系統中利用事件Multicating特性是對它最好的證明。您可嘗試看看。不過在您實作前,有些是您該注意的。

Dim WithEvents TextBox As TextBox
Private Sub Form_Load()
    ' Raises a Type Mismatch run-time error.
    Set TextBox = Text1(0)
End Sub

此錯誤防止您從控制項陣列中動態建立新的控制項,然後使用Multicasting補抓事件。不幸的是,並沒有任何已知的解決方法可以處理這個問題。但很奇怪的,若設為WithEvents變數的控制項是由Visual Basic所製作的ActiveX控制項,這一錯誤並不會顯示。

多形
 

多形為不同物件擁有相似的屬性和方法的能力。多形物件最明顯且最簡易的例子是Visual Basic本身的控制項,他們大部分共享屬性和方法名稱。當想到某種可用於多數物件和控制項的通用程序的話,多形的優點是明顯的。

' Change the BackColor property for all the controls on the form.
Sub SetBackColor(frm As Form, NewColor As Long)
    Dim ctrl As Control
    On Error Resume Next            ' Account for invisible controls.
    For Each ctrl In frm.Controls
        ctrl.BackColor = NewColor
    Next
End Sub

平衡多形
 

可利用多形的好處來撰寫更好的程式。這一節,筆者會解釋兩個非常明顯的類別:有多形引數的程序和有多形方法的類別。

多形程序
 

多形程序依據傳遞給它的引數可做不同的事情。在上一節中,我通常暗自使用這一觀念,例如,當撰寫使用Variant引數來處理不同型態陣列的規則時。讓我們看看現在如何應用這一觀念來撰寫更彈性的類別。筆者將說明一個簡單的CRectangle類別,此類別有一些簡單的程序(Left、Top、Width、Height、Color和FillColor)和一個Draw方法。這裡有類別模組的原始程式碼:

' In a complete implementation, we would use property procedures.
Public Left As Single, Top As Single
Public Width As Single, Height As Single
Public Color As Long, FillColor As Long    

Private Sub Class_Initialize()
    Color = vbBlack
    FillColor = -1              ' -1 means "not filled"
End Sub
' A pseudoconstructor method
Friend Sub Init(Left As Single, Top As Single, Width As Single, Height As _
    Single, Optional Color As Variant, Optional FillColor As Variant)
    ' .... code omitted for brevity
End Sub
' Draw this shape on a form, a picture box, or the Printer object.
Sub Draw(pic As Object)
    If FillColor <> -1 Then
        pic.Line (Left, Top)-Step(Width, Height), FillColor, BF
    End If
    pic.Line (Left, Top)-Step(Width, Height), Color, B
End Sub

為求精簡,所有屬性皆設定為Public變數,但在真正的應用上會確實使用屬性程序來執行驗證規則。然而,這一類別的真正焦點是Draw方法,其預期一個物件引數。意即我們可以在任何支援Line方法的物件上顯示矩形:

Dim rect As New Crect
' Create a white rectangle with a red border.
rect.Init 1000, 500, 2000, 1500, vbRed, vbWhite
' Display it wherever you want.
If PreviewMode Then
    rect.Draw Picture1          ' A picture box
Else
    rect.Draw Printer           ' A printer
End If

多形的第一個形式雖然有趣,但卻是有限制的。事實上,此一特殊的情況下,我們不能做太多事,因為表單、PictureBox控制項和印表機是唯一用特殊語法支援Line方法的物件。一個重要問題點是用戶端應用系統可用此能力來精簡其程式。

多形類別
 

當建立多個類別模組且以一個方式選擇其屬性和方法的名稱,確保他們之中有一個完整的或部分多形時,多形的真正力量會顯現出來。例如,可以建立一個CEllipse類別,其完全是Crectangle類別的多形,雖然這兩個類別有執行上的差異:

' The CEllipse class
Public Left As Single, Top As Single
Public Width As Single, Height As Single
Public Color As Long, FillColor As Long    

Private Sub Class_Initialize()
    Color = vbBlack
    FillColor = -1             ' -1 means "not filled"
End Sub
' Draw this shape on a form, a picture box, or the Printer object.
Sub Draw(pic As Object)
    Dim aspect As Single, radius As Single
    Dim saveFillColor As Long, saveFillStyle As Long
    aspect = Height / Width
    radius = IIf(Width > Height, Width / 2, Height / 2)
    If FillColor <> -1 Then
        saveFillColor = pic.FillColor
        saveFillStyle = pic.FillStyle
        pic.FillColor = FillColor
        pic.FillStyle = vbSolid
        pic.Circle (Left + Width / 2, Top + Height / 2), radius, Color, _
            , , aspect
        pic.FillColor = saveFillColor
        pic.FillStyle = saveFillStyle
    Else
        pic.Circle (Left + Width / 2, Top + Height / 2), radius, Color, _
            , , aspect
    End If
End Sub

也可以建立相對於CRectangle的部分多形的類別。例如,CLine類別支援Draw方法和Color屬性,但對於它的其他成員卻使用不一樣的名稱。

' The CLine class
Public X As Single, Y As Single
Public X2 As Single, Y2 As Single
Public Color As Long

Private Sub Class_Initialize()
    Color = vbBlack
End Sub
' Draw this shape on a form, a picture box, or the Printer object.
Sub Draw(pic As Object)
    pic.Line (X, Y)-(X2, Y2), Color
End Sub

現在您有三個多形類別,其相對有Draw方法和Color屬性。這允許建立一個非常相似於CAD應用的第一個版本,命名為Shapes,如圖7-5所示。藉由使用陣列或集合來記錄圖形,以便可以快速地重繪。為保持用戶端程式盡可能精簡和描述,也可以在分離的BAS模組中定義許多方法(因為對於我們的目的無關,所以在此沒有說明)。


 

圖7-5 使用多形的繪圖。
' This is a module-level variable.
Dim Figures As Collection

Private Sub Form_Load()
    CreateFigures
End Sub
Private Sub cmdRedraw_Click()
    RedrawFigures
End Sub
' Create a set of figures.
Private Sub CreateFigures()
    Set Figures = New Collection
    Figures.Add New_CRectangle(1000, 500, 1400, 1200, , vbRed)
    Figures.Add New_CRectangle(4000, 500, 1400, 1200, , vbCyan)
    Figures.Add New_CEllipse(2500, 2000, 1400, 1200, , vbGreen)
    Figures.Add New_CEllipse(3500, 3000, 2500, 2000, , vbYellow)
    Figures.Add New_CRectangle(4300, 4000, 1400, 1200, , vbBlue)
    Figures.Add New_CLine(2400, 1100, 4000, 1100, vbBlue)
    Figures.Add New_CLine(1700, 1700, 1700, 4000, vbBlue)
    Figures.Add New_CLine(1700, 4000, 3500, 4000, vbBlue)
End Sub
' Redraw figures.
Sub RedrawFigures()
    Dim Shape As Object
    picView.Cls
    For Each Shape In Figures
        Shape.Draw picView
    Next
End Sub

雖然完全的多形總是完美的,當物件僅有一些共同的屬性時,仍然可以使用許多相關的技巧。例如可以快速將Figures集合的內容轉到一串wire-framed物件中。

On Error Resume Next     ' CLine doesn't support the FillColor property.
For Each Shape In Figures
    Shape.FillColor = -1
Next

增加複雜度到這初始的範例是很簡單的。例如,使用Move和Zoom方法,可能增加對於移動和放大物件的支援。這裡有對於CRectangle類別這些方法的可能實作:

' In CRectangle class module...
' Move this object.
Sub Move(stepX As Single, stepY As Single)
    Left = Left + stepX
    Top = Top + stepY
End Sub

' Enlarge or shrink this object on its center.
Sub Zoom(ZoomFactor As Single)
    Left = Left + Width * (1 - ZoomFactor) / 2
    Top = Top + Height * (1 - ZoomFactor) / 2
    Width = Width * ZoomFactor
    Height = Height * ZoomFactor
End Sub

對於這程式來說有關Cellipse類別的執行是唯一的,因為它是完整的Crectangle的多形,因此有Left、Top、Width和Height屬性。同樣地,CLine類別支援Move和Zoom兩個方法,即使他們的實作是不同的(隨書光碟上有更詳細的內容)。

圖7-6顯示一個改進的Shapes簡易程式,其允許移動和放大物件。下列程式為關於表單的按鈕的程式:

Private Sub cmdMove_Click(Index As Integer)
    Dim shape As Object
    For Each shape In Figures
        Select Case Index
            Case 0: shape.Move 0, -100    ' Up
            Case 1: shape.Move 0, 100     ' Down
            Case 2: shape.Move -100, 0    ' Left
            Case 3: shape.Move 100, 0     ' Right
        End Select
    Next
    RedrawFigures
End Sub
Private Sub cmdZoom_Click(Index As Integer)
    Dim shape As Object
    For Each shape In Figures
        If Index = 0 Then
            shape.Zoom 1.1                ' Enlarge
        Else
            shape.Zoom 0.9                ' Reduce
        End If
    Next
    RedrawFigures
End Sub

若想要了解多形可以為您的程式做到什麼地步,只要想想用其他方法必須撰寫多少行程式來解決這簡單的程式工作就可以理解了。當然可以應用這技巧來處理更多的商業物件,包含文件、支票、訂單、客戶、員工、產品等等。


 

圖7-6 更多關於多形圖形有趣的事

多形和延遲繫結(late binding)
 

我尚未談到多形的深度概念。到目前為止在所有多形例子中,共同最重要的特徵是可以撰寫多形程式,只因為您使用一般的物件變數。例如,在Draw方法中pic引數被宣告為物件,就像在先前程式中所有的Click程序的Shape變數。可以使用Variant變數來記錄物件參照,但是這觀念是一樣的:透過延遲繫結可做到多形。

回想第六章,延遲繫結有許多缺點,很嚴重是效能的問題-它甚至比早期繫結慢百倍的時間-且是個不太健全的程式。根據程式的特別片段,這些缺點可以簡單地抵銷從多形中獲得的所有效益。幸好,Visual Basic提供此問題的一個解決方法-一個極佳的解決方法。為了瞭解它如何運作,須對介面的概念有所瞭解。

使用介面
 

當開始在程式中使用多形時,得瞭解您正邏輯地將物件的所有屬性和方法細分成不同群組。例如,CRectangle、CEllipse和CLine類別有一些共同的成員(Draw、Move和Zoom )。真正的物件,包括一打或甚至成百的屬性和方法,建立他們並非困難,這是必須的。一群相關屬性和方法稱為介面。

在Visual Basic 4,任何物件只可以有一個介面,主要介面。從第五版開始,Visual Basic的類別模組可包括一個或多個次要介面。這完全看您需要如何組織物件導向程式。而且將看到這一革新包含許多其他效益。

建立一個次要介面
 

在Visual Basic 5和Visual Basic 6,次要介面的定義需要建立一個不同的類別模組。這模組不包含任何可執行的程式,只有屬性和方法的定義。基於此,它通常稱為抽象類別(abstract class)。一旦使用任何Visual Basic模組,需要給它一個名字。習慣上為了將介面名稱與類別名稱區別,大都以I字母作為開頭。

回到我們的迷你CAD例子:讓我們來建立一個介面,收集Draw、Move和Zoom方法-對所有繪圖的共同成員。這將會是Ishape介面。為了增加某些特性,筆者也加入Hidden屬性。

' The IShape class module
Public Hidden As Boolean

Sub Draw(pic As Object)
    ' (Empty comment to prevent automatic deletion of this routine)
End Sub
Sub Move(stepX As Single, stepY As Single)
    '
End Sub
Sub Zoom(ZoomFactor As Single)
    '
End Sub

說明

當程式執行時,可能需要在所有方法的內部增加註解來防止編輯器自動刪除空程序。


這一類別沒有包含任一可執行的敘述,且只為Ishape介面做服務。所有要關心的是屬性和方法的名稱、他們的引數和每個的型態。基於相同的理由,不需要建立成對的屬性程序,因為一個簡單的公開變數通常就足夠的。只有兩個情況下可能需要明確的屬性程序:

  • 想要指定唯讀的屬性;這一情況下,明確地省略Property Let或Property Set程序。
     
  • 想要指定變數屬性從未傳回物件:此情況,包含Property Get和Property Let程序但省略Property Set程序。
     

介面並未包含事件宣告。當使用CLS模組作為定義次要介面的抽象類別時,Visual Basic才考慮公開屬性和方法。

實作介面
 

下一步驟是讓Visual Basic知道CRectangle、CEllipse和CLine類別有Ishape介面。藉由在每個類別模組宣告區增加Implements關鍵字。

' In the CRectangle class module
Implements Ishape

宣告包含介面的類別只是工作的一半罷了,因為現在必須實際實作此介面。換句話說,必須撰寫當物件的任一成員被引入時,Visual Basic將要執行的程式。程式編輯器可幫您建立每一個體的程序樣版。這一機制類似於對事件樣板:在下拉式清單的最左邊,選擇介面的名稱(一旦從Implements敘述移動時,它會即刻出現在清單中),且在下拉式清單的最右邊選擇一個方法或屬性的名稱,見圖7-7。注意這和事件有極大不同:當實作介面時,必須建立所有列於此下拉清單中的所有程序。若不這樣的話,Visual Basic不會執行應用程式。基於此,最快方式是在下拉清單的最右邊選擇所有選項來建立所有的程序樣版,然後再增入程式碼。注意所有的名稱都有IShape_字首,這解決已經存在於模組中的方法名稱衝突,而所有的程序也被宣告為私有的。這是必要的,因為若他們是公開的,他們會出現在主要的介面中。注意Hidden屬性會產生一個成對的屬性程序。

撰寫實際的程式
 

為了完成介面的實作,必須在程序樣版內撰寫程式。若不如此做,程式雖可執行但物件將不會對Ishape介面有所反應。

介面也可說是合同:若實作介面,表示您暗自同意對此介面的所有屬性和方法的反應,在某種程度上遵守介面規則。在這一情況下,會被期待於Draw方法表示顯示物件、Move方法移動物件等。若失敗的話,會破壞介面合同且只有您會受到責難。


 

圖7-7 讓編輯器為您建立程序樣版。

讓我們看看在CRectangle類別中可以如何實作IShape介面。在這一例子中,已經有顯示、移動和刻畫物件的程式碼-在主要的介面中稱為Draw、Move、Zoom方法。然而,次要介面的其中之一的目標是避免主要介面中多餘的成員。因此,應該從CRectangle主要介面中刪除Draw、Move、Zoom方法,並移除IShape介面內部的程式碼:

' A (private) variable to store the IShape_Hidden property
Private Hidden As Boolean

Private Sub IShape_Draw(pic As Object)
    If Hidden Then Exit Sub
    If FillColor >= 0 Then
        pic.Line (Left, Top)-Step(Width, Height), FillColor, BF
    End If
    pic.Line (Left, Top)-Step(Width, Height), Color, B
End Sub
Private Sub IShape_Move(stepX As Single, stepY As Single)
    Left = Left + stepX
    Top = Top + stepY
End Sub
Private Sub IShape_Zoom(ZoomFactor As Single)
    Left = Left + Width * (1 - ZoomFactor) / 2
    Top = Top + Height * (1 - ZoomFactor) / 2
    Width = Width * ZoomFactor
    Height = Height * ZoomFactor
End Sub
Private Property Let IShape_Hidden(ByVal RHS As Boolean)
    Hidden = RHS
End Property
Private Property Get IShape_Hidden() As Boolean
    IShape_Hidden = Hidden
End Property

在這完成了CRectangle類別IShape介面的實作。這裡筆者不會說明關於CEllipse和CLine的程式碼,因為本質上是相同,可以在書附光碟上瀏覽它。

存取次要介面
 

存取新介面是很簡單的。所必須要做的是宣告一個IShape類別的變數且指派一個物件給它。

' In the client code ...
Dim Shape As IShape    ' A variable that points to an interface
Set Shape = Figures(1) ' Get the first figure in the list.
Shape.Draw picView     ' Call the Draw method in the IShape interface.

在之前的程式碼中Set命令是有點讓人訝異的,因為您可能會認為指派會有型態不合的錯誤。相反地,此程式可運作,因為編譯器可以弄清Figures(1)物件(在這特定的範例程式中的CRectangle物件)支援IShape介面且一個有效的指標可以被傳回並安全地儲存在Shape變數中。好似Visual Basic查詢原始的CRectangle物件,「 您支援IShape介面嗎?」若是,則指派可以完成,相反地會產生一個錯誤。這一運作稱為QueryInterface或簡寫為QI。


說明

在 第六章 ,您學到類別總是有個VTable,Vtable持有所有程序的位址。實作次要介面的類別伴隨次要Vtable架構,當然指向這次要介面的程序。當一個QI命令被嘗試於次要介面時,目標變數的回傳值是實體資料區域內部的一個記憶體的位址,依次握有這次要Vtable架構的位址(看圖7-8)。這一機制使Visual Basic用相同的低層次核心規則處理主要和次要的介面。



 

圖7-8 次要介面和VTable架構(與圖6-8比較)。

QueryInterface是一個對稱的運算且Visual Basic讓您可由兩個方向進行指派。

Dim Shape As IShape, Rect As CRectangle
' You can create a CRectangle object on the fly.
Set Shape = New CRectangle
Set Rect = Shape                 ' This works.
Rect.Init 100, 200, 400, 800     ' Rect points to primary interface.
Shape.Move 30, 60                ' Shape points to its IShape interface.
' Next statement proves that both variables point to the same instance.
Print Rect.Left, Rect.Top        ' Displays "130" and "260"

精鍊用戶端程式碼
 

若也在CEllipse和CLine類別實作IShape介面,會看到使用Shape變數可以呼叫這三個類別任一個內部的程式碼。換句話說,用一個特別型態的變數做多形,因此現在可以使用早期繫結。

當兩個或多個類別共享一個介面時,相對於這一特殊的介面而言,他們互相成為多形。這一技巧讓您加快Shape程式且同時使它更為健全。真正令人驚訝的是藉由把一行程式放在原始的用戶端程式碼可以達到所有事:

Sub RedrawFigures()
    Dim shape As IShape         ' Instead of "As Object"
    picView.Cls
    For Each shape In Figures
        shape.Draw picView
    Next
End Sub

使用這一方法可以獲取的效能好處是非常大的。這一特別的程序花費多數的時間在製圖,所以它的速度改善可能被忽略。然而大部分的時間,會看到其差異。

運用VBA關鍵字
 

在跳進另一個完美的OOP主題前,讓我們看看一些VBA關鍵字如何表現,當指向次要介面的物件變數被應用時。

 Set關鍵字 可以自由地指派物件變數給其他,雖然他們是不同的型態。唯一要考慮的是原始的物件(指派的右邊)必須實作目標的類別(指派的左邊)作為次要介面。反過來也是可能的-這是,當原始的類別是被目標類別實作的介面。兩種情況下,記住指派一個參照到相同物件。

 TypeName函數 傳回物件變數所指向物件的原始類別名稱。例如,以下的程式:

Dim rect As New CRectangle, shape As Ishape
Set shape = rect
Print TypeName(shape)     ' Displays "CRectangle", not "Ishape"!

 TypeOf...Is敘述 測試物件是否支援一給定介面。在這一例子中,可測試主要和次要介面:

Dim rect As New CRectangle, shape As Ishape
Set shape = rect
' You can pass a variable and test a secondary interface.
If TypeOf rect Is IShape Then Print "OK"          ' Displays "OK"
' You can also pass a variable pointing to a secondary interface
' and test the primary interface (or a different secondary interface).
If TypeOf shape Is CRectangle Then Print "OK"     ' Displays "OK"

在 第六章 ,筆者建議您使用TypeName替代TypeOf...Is敘述。當處理主要介面時,這是正確的,但當測試次要介面時實際需要TypeOf...Is。

 I s關鍵字 在 第六章 ,筆者解釋Is運算元比引入物件變數的內容簡單。這是真的,但只當比較持有指向主要介面指標的變數時:當比較不同型態的物件變數,Visual Basic夠聰明能理解他們是否指向相同的實體資料區塊,即使存於變數的值是不同的:

Set shape = rect
Print (rect Is shape)               ' Displays "True".

存取次要介面的支援函數
 

當更深入了解次要介面後,很快地會發現自己寫了許多的程式碼只是為了取得物件的次要介面。這通常只需要宣告一個給定型態的變數且執行一個Set命令即可。可發現在BAS模組中寫個簡單的函數是很方便的:

Function QI_IShape(shape As IShape) As Ishape
    Set QI_IShape = shape
End Function

例如,看看如何在CRectangle物件的Ishape介面中引入Move方法:

QI_IShape(rect).Move 10, 20

多數的情況下,甚至當指派多個屬性或多個方法時,並不需要一個暫存的變數。

With QI_IShape(rect)
    .Move 10, 20
    .Zoom 1.2
End With

繼承
 

介紹完封裝和多形後,繼承是所有成熟的OOPL第三個主要的特性。在 第六章 ,筆者描述繼承是什麼且對程式設計師多有用。筆者也要告訴您-遺憾地-Visual Basic並沒有支援繼承。本節,筆者會解釋如何補救這一不足。

回到Shapes簡單的程式。這一次,撰寫一個CSquare類別模組,它增加對畫方形的支援。因為這一類別如此相似於CRectangle,實際上這是簡單的工作:僅複製CRectangle程式碼到CSquare模組,然後在適當的地方編輯它。例如,因為一個方形只不過是一個長寬相等的矩形,您可以使Width和Height兩者屬性指向相同的私人變數。

這一解決方法有點不符合要求,因為我們複製CRectangle類別的程式碼。若稍後發現此CRectangle類別有錯誤,必須記得CSquare模組中改正它,如同從CRectangle繼承的所有其他類別。若Visual Basic支援真正的繼承,我們僅應該宣告CSquare類別從CRectangle繼承所有它的屬性和方法,然後我們只要著重一些相異之處。而在目前的Visual Basic版本中,這是不可能的。換句話說,繼承的觀念如此迷人且大有可為,您應該會看他第二眼。如我將顯示的,可以用些技巧來模擬繼承。

透過委任(Delegation)的繼承
 

模擬繼承的技巧就叫做委任。這一概念很簡單:因為大部分在CSquare(繼承的類別)所需要的邏輯在CRectangle(基礎類別)中被具體化,在CSquare的程式碼可以簡單地詢問一個CRectangle物件代表他自己做這份工作。

基本委任技巧
 

所以藉由在CSquare內部宣告一個私有的CRectangle物件且傳遞給他所有CSquare不想要直接處理的呼叫,可達成此技巧。這些呼叫包含所有的方法和屬性所有唯讀/唯寫的操作。這裡有個技巧可以執行:

' The CSquare Class
' This is the Private instance of the CRectangle class.
Private Rect As CRectangle

Private Sub Class_Initialize()
    ' Create the private variable for doing the delegation.
    Set Rect = New CRectangle
End Sub
' A simple pseudoconstructor for ease of use
Friend Sub Init(Left As Single, Top As Single, Width As Single, _
    Optional Color As Variant, Optional FillColor As Variant)
    ...
End Sub
' The delegation code
Property Get Left() As Single
    Left = Rect.Left
End Property
Property Let Left(ByVal newValue As Single)
    Rect.Left = newValue
End Property
Property Get Top() As Single
    Top = Rect.Top
End Property
Property Let Top(ByVal newValue As Single)
    Rect.Top = newValue
End Property
Property Get Width() As Single
    Width = Rect.Width
End Property
Property Let Width(ByVal newValue As Single)
    ' Squares are rectangles whose Width = Height.
    Rect.Width = newValue
    Rect.Height = newValue
End Property

Property Get Color() As Long
    Color = Rect.Color
End Property
Property Let Color(ByVal newValue As Long)
    Rect.Color = newValue
End Property
Property Get FillColor() As Long
    FillColor = Rect.FillColor
End Property
Property Let FillColor(ByVal newValue As Long)
    Rect.FillColor = newValue
End Property

無可否認地,對這樣一個簡單的工作來說是太多的程式碼,但您不應該忘記我們在這裡與玩物似的物件一起運作。在一個真正的程式中,基礎類別可能包含百或千行的程式碼。在這樣的情況中,被委任所需要的相對地少數行是絕對無關緊要的。

支援第二種介面
 

雖然CSquare類別功能很強了,但它仍不知道如何重劃自己。若CRetangle類別包含基本的介面有Draw、Move和Zoom方法-如同在第一版的Shapes程式-這將已是小孩的遊戲了。遺憾地,我們將Draw方法從CRectangle的主要介面移到它的IShape第二介面。基於此,為了委任此一方法,我們首先需要取得介面的參照。

' In the CSquare class
Private Sub IShape_Draw(pic As Object)
    Dim RectShape As IShape
    Set RectShape = Rect        ' Retrieve the IShape interface.
    RectShape.Draw pic          ' Now it works!
End Sub

既然CSquare類別生命期間需要多次Rect的IShape介面參照,可以加速執行且藉由建立模組層次REctShape變數來減少程式碼的數量。

' CSquare also supports the IShape interface. 
Implements IShape

' This is the private instance of the CRectangle class.
Private Rect As CRectangle
' This points the Rect's IShape interface.
Private RectShape As IShape

Private Sub Class_Initialize()
    ' Create the two variables for doing the delegation.
    Set Rect = New CRectangle
    Set RectShape = Rect
End Sub
' ... code for Left, Top, Width, Color, FillColor properties ...(omitted)
' The IShape interface
Private Sub IShape_Draw(pic As Object)
    RectShape.Draw pic
End Sub
Private Property Let IShape_Hidden(ByVal RHS As Boolean)
    RectShape.Hidden = RHS
End Property
Private Property Get IShape_Hidden() As Boolean
    IShape_Hidden = RectShape.Hidden
End Property
Private Sub IShape_Move(stepX As Single, stepY As Single)
    RectShape.Move stepX, stepY
End Sub
Private Sub IShape_Zoom(ZoomFactor As Single)
    RectShape.Zoom ZoomFactor
End Sub

Subclassing基礎類別
 

當透過委任的繼承可以簡單地讓任何成熟的OOP程式設計師所使用,執行期間在所發生的完整控制項中的事實有數個優點。例如,當用戶端觸發繼承類別中的方法時,有數個選擇:

最後兩個情況,程式碼有時候被說成是基礎類別的子集合。它使用基礎類別對於那些有用的,但也可執行某些事前和事後處理的程式碼,這些程式碼對繼承類別增加強度。即使這一概念有些類似,但不要將它跟控制項或視窗子集合搞混,它是完全不同(且更進步)的程式技巧,讓您修正標準視窗控制項的行為(附錄會提到子集合的類別)。

VBA語言的子類別
 

您也許不知道VBA可讓您來細分他本身。如您所知道,Visual Basic被認為是Visual Basic函式庫和VBA語言的加總。這些函式庫總是出現在 References 對話方塊中,不能如同其他外部的函式庫般被刪除。然而,即使您不能刪除他們,就Visual Basic的分析器而言,在您自己的程式中使用的名稱比被定義在外部函式庫(包括VBA函式庫)的名稱有較高的優先權。要了解筆者所說的,增加這一簡單的程序到一個標準的BAS模組。

' An IIf replacement that accepts just one argument 
' If FalsePart is omitted and the expression is False, it returns Empty.
Function IIf(Expression As Boolean, TruePart As Variant, _
    Optional FalsePart As Variant) As Variant
    If Expression Then
        IIf = TruePart
    ElseIf Not IsMissing(FalsePart) Then
        IIf = FalsePart
    End If
End Function

您可以呼叫純粹的VBA敘述,即使正在分類他們,提供您指定VBA函式庫的名稱:

Function Hex(Value As Long, Optional Digits As Variant) As String
    If IsMissing(Digits) Then
        Hex = VBA.Hex(Value)
    Else
        Hex = Right$(String$(Digits, "0") & VBA.Hex(Value), Digits)
    End If
End Function

總是要試著維持新函數的語法與原始的VBA函數相容,以便不會破壞已存在的程式碼。

警告:這一技巧可能引起問題,特別當您在團隊內作業,且並非大家都了解這技巧時。在某種程度上藉由相容的語法可以處理這問題,但當您的同事要去維護或修正您的程式碼時,這並不能解決問題。基於此,得考慮定義新的函數,有著不同的名稱和語法,以便讓程式碼不會含糊。

繼承和多態
 

若完全從另一個類別繼承一個類別模組-就是,在繼承的類別中實作基礎類別的所有方法-結束兩個非常相似的模組,通常使用一個物件變數來平衡其多態性且簡化程式碼。換句話說,您知道不需要求助延遲繫結(物件變數)來獲得多態性的所有優點,因為第二介面總會提供一個更好的選擇。

實作基礎類別作為介面
 

為了說明這一觀念,CSquare類別可實作CRectangle介面:

' In the Csquare class module
Implements IShape
Implements CRectangle

' The primary and the IShape interface are identical... (omitted).... 
' This is the secondary CRectangle interface.
Private Property Let CRectangle_Color(ByVal RHS As Long)
    Rect.Color = RHS
End Property
Private Property Get CRectangle_Color() As Long
    CRectangle_Color = Rect.Color
End Property
Private Property Let CRectangle_FillColor(ByVal RHS As Long)
    Rect.FillColor = RHS
End Property
Private Property Get CRectangle_FillColor() As Long
    CRectangle_FillColor = Rect.FillColor
End Property
' The rect's Height property is replaced by the Width property.
Private Property Let CRectangle_Height(ByVal RHS As Single)
    rect.Width = RHS
End Property
Private Property Get CRectangle_Height() As Single
    CRectangle_Height = rect.Width
End Property
Private Property Let CRectangle_Left(ByVal RHS As Single)
    Rect.Left = RHS
End Property
Private Property Get CRectangle_Left() As Single
    CRectangle_Left = Rect.Left
End Property
Private Property Let CRectangle_Top(ByVal RHS As Single)
    Rect.Top = RHS
End Property
Private Property Get CRectangle_Top() As Single
    CRectangle_Top = Rect.Top
End Property
Private Property Let CRectangle_Width(ByVal RHS As Single)
    Rect.Width = RHS
End Property
Private Property Get CRectangle_Width() As Single
    CRectangle_Width = Rect.Width
End Property

在CRectangle介面,正使用前面相同的委任技術,所以實際上類別模組的組織並沒有太大的移動。然而,這方法的好處在應用系統中看得到,應用系統正使用一個單一變數和透過早期繫結,可指向CRectangle或CSquare物件。

Dim figures As New Collection
Dim rect As CRectangle, Top As Single

' Create a collection of rectangles and squares.
figures.Add New_CRectangle(1000, 2000, 1500, 1200)
figures.Add New_CSquare(1000, 2000, 1800)
figures.Add New_CRectangle(1000, 2000, 1500, 1500)
figures.Add New_CSquare(1000, 2000, 1100)

' Fill them, and stack them one over the other using early binding!
For Each rect In figures
    rect.FillColor = vbRed
    rect.Left = 0: rect.Top = Top
    Top = Top + rect.Height
Next

增加可執行的程式碼到抽象的類別
 

當我介紹抽象類別作為定義介面的意義時,我說抽象類別從不包含可執行的程式碼,而只做介面的定義。不過先前的例子顯示使用相同的類別模組作為介面,且同時在其內部使用程式碼是完美且合法的。

CRectangle類別是此技巧有點複雜的應用,因為它如同一般類別般運作,可作為繼承的基礎類別、也可在其他類別實作的介面。當開始認識物件時,這方法將變成自然的。

繼承的好處
 

繼承是個極佳的OOP技巧,讓程式設計師以最小的力氣取得新的類別。當正建立數個相似於另一個的類別時,透過委任來模擬真實繼承是另一件好事,即使花費較多程式心力,但總應該考慮它,因為繼承讓您可再利用程式和邏輯,增強一個更好的封裝,且簡化程式碼維護:

  • 後繼類別不需要知道基礎類別內部如何運作。所要關心的是基礎類別所涵蓋的介面。後繼類別可把基礎類別當成是種黑盒子,接受輸入和傳回結果。若基礎類別是健全的且有極佳的封裝,繼承的類別可以安心地使用且也將會繼承其健全。
     
  • 一個連續的black box方法是您甚至可以不需要原始程式碼而從類別"繼承",例如,一個物件加入一個外部的library。
     
  • 若稍後修正基礎類別一或多個程序的內部實作-典型的修正錯誤或增加效能-所有繼承的類別將會繼承這改進,而不必編輯其程式。只當改變基礎類別的介面、新增屬性和方法或刪除存在的屬性和方法時,才需要修正繼承類別的程式碼。若對於程式碼的簡易維護有興趣,這是個很好的主題。
     
  • 不需要在繼承類別中進行驗證,因為在基礎類別已執行了。若有錯誤發生,透過繼承的類別廣傳,且最終會到用戶端。用戶端接收這錯誤好似它是在繼承的類別中產生,意即繼承不影響用戶端的錯誤如何被管理和更正。
     
  • 所有實際的資料被儲存在基礎類別內,而非繼承類別。換句話說,並沒有複製資料,且後繼類別只在進行委任時才需要額外的物件參照。
     
  • 呼叫基礎類別的程式碼會增加輕微的效能。但這負荷通常很少的。以233-MHz機器為基準,每秒可輕鬆地執行約1.5百萬委任呼叫(在已編譯的程式碼中)。這比每次呼叫需百萬分之一秒還少。在多數情況下,這負荷不被注意,特別是在複雜方法中。
     

物件階層
 

目前為止,已說明如何將複雜的邏輯機制作成類別,並可在現行的應用程式或未來的專案中重複使用這些機制。但之前所見到的都是單獨的類別,專門用來解決特定的程式問題。物件真正的力量是使用他們來建立更大的合作結構,稱之為物件階層。

物件間的關係
 

若要把許多物件聚集成較大的結構,需要一種建立其關係的方法。

一對一關係
 

在OOP的世界裡,建立兩個物件的關係是很簡單的,只需要提供一物件的物件屬性指向下一個即可。例如,一個傳統的CInvoice物件可能有包含Customer屬性(其指向Customer物件),與兩個屬性,SendForm與ShipTo。此兩屬性皆指向CAddress物件。

' In the CInvoice class module
Public Customer As CCustomer           ' In a real app, these would
Public SendFrom As CAddress            ' be implemented as pairs
Public ShipTo As CAddress              ' of property procedures.

這段程式宣告此類別能支援這些關係。真正建立這些關係是在執行時,當指定非Nothing的參考給這些屬性時。

Dim inv As New CInvoice, cust As CCustomer
inv.Number = GetNextInvoiceNumber()    ' A routine defined somewhere else
' For simplicity, let's not worry about how the CUST object is created.
Set cust = GetThisCustomer()           ' This returns a CCustomer object.
Set inv.Customer = cust                ' This creates the relationship.
' You don't always need an explicit variable.
Set inv.SendFrom = GetFromAddress()    ' This returns a CAddress object,
Set inv.ShipTo = GetToAddress()        ' as does this one.

一旦關係建立後,便可開始進行由VBA所提供無限的可行性,與撰寫簡潔與美麗的程式碼。

' In the CInvoice class module
Sub PrintHeader(obj As Object)
    ' Print the invoice on a form, PictureBox, or the Printer.
    obj.Print "Number " & Number
    obj.Print "Customer: " & Customer.Name
    obj.Print "Send From: " & SendFrom.CompleteAddress
    obj.Print "Ship To: " & ShipTo.CompleteAddress
End Sub

因為在大部分狀況下,ShipTo的地址會與客戶的地址相同,於是可以給予一合適的預設值給此屬性。只需要在宣告區刪除Public ShipTo成員,並增加下列程式碼即可:

Private m_ShipTo As Caddress
Property Get ShipTo() As CAddress
    If m_ShipTo Is Nothing Then
        Set ShipTo = Customer.Address
    Else
        Set ShipTo = m_ShipTo
    End If
End Property
Property Let ShipTo(newValue As CAddress)
    Set m_ShipTo = newValue
End Property

因為沒有碰觸到類別的介面,程式的其他部分(包含類別本身的內外部),會毫無問題地持續執行。

一旦關係設定後,即使修改這些複雜的物件也無法使其無效。在CInvoice範例中,即使設定cust變數為Nothing,或使其超出視界,皆有相同的效果─Visual Basic不會摧毀CCustomer實體,因此Invoice與Customer的關係仍將如往常般。這並非魔術,這是因為物件的實體只在所有參考到他的物件變數皆設為Nothing後才會被釋放。於此例中,CInvoive類別的Customer屬性會保持Ccustomer實體仍存在著,直到設定Customer屬性為Nothing或Cinvoice物件本身才會被摧毀。不需要明白地在CInvoice類別的Class_Terminate事件中設定Customer屬性為Nothing:當物件被釋放時,Visual Basic會在進行釋放記憶體空間前,自動地將其所有的物件屬性皆設為Nothing。此項動作會影響所有被引用物件的引用數,當引用數為0時則物件會被摧毀。在較大的物件階層裡,常發生刪除一個物件導致連鎖性的清除記憶體動作。很幸運地,您不需要擔心這些,因為這是Visual Basic的事,與您無關。

一對多關係
 

當對物件建立一對多關係時,會變得有點複雜。例如,CInvoice類別可能需要指向多個產品描述。底下讓我們來分析如何有效地解決這個難題。

對此物件導向試驗,需要一個輔助的類別,CInvoiceLine,其存放關於產品,訂購數量與單價等資訊。底下為其非常簡單的實作,並未做任何驗證。隨書光碟的版本也有個建構元,Description屬性,與其他特性,但只需要三個變數與一個屬性程序來開始即可:

' A workable CinvoiceLine class module
Public Qty As Long
Public Product As String
Public UnitPrice As Currency

Property Get Total() As Currency
    Total = Qty * UnitPrice
End Property

基本上,您可選擇兩種方式來實作這種一對多關係:可使用物件陣列,或使用集合。陣列的解法是較繁瑣的:

' We can't expose arrays as Public members.
Private m_InvoiceLines(1 To 10) As CInvoiceLine

Property Get InvoiceLines(Index As Integer) As CInvoiceLine
    If Index < 1 Or Index > 10 Then Err.Raise 9   ' Subscript out of range
    Set InvoiceLines(Index) = m_InvoiceLines(Index)
End Property
Property Set InvoiceLines(Index As Integer, newValue As CInvoiceLine)
    If Index < 1 Or Index > 10 Then Err.Raise 9   ' Subscript out of range
    Set m_InvoiceLines(Index) = newValue
End Property
' In the client code
' (Assumes that we defined a constructor for the CInvoiceLine class)
Set inv.InvoiceLine(1) = New_CInvoiceLine(10, "Monitor ZX100", 225.25)
Set inv.InvoiceLine(2) = New_CInvoiceLine(14, "101-key Keyboard", 19.99)
' etc.

物件陣列有許多難題,特別是因為當您不確定需要多少CInvoiceLine項目時,就無法有效地使用他們。事實上,筆者建議只在您確定相關物件的數量時,才使用此方法。

集合的解法便大有可為,因為其限制相關物件的數量,且因為其允許自然,像OOP的語法於程式碼中。此外,可宣告集合為Public成員,如此一來在類別模組的程式碼會變得較簡單:

' In the CInvoice class
Public InvoiceLines As New Collection

' In the client code (no need to keep track of line index)
inv.InvoiceLines.Add New_CInvoiceLine(10, "Monitors ZX100", 225.25)
inv.InvoiceLines.Add New_CInvoiceLine(14, "101-key Keyboards", 19.99)

使用集合可改善其他方式CInvoice類別的程式碼。看看底下的程式是多麼簡單便可秀出發票的每一行:

Sub PrintBody(obj As Object)
    ' Print the invoice body on a form, PictureBox, or the Printer.
    Dim invline As CInvoiceLine, Total As Currency
    For Each invline In InvoiceLines
        obj.Print invline.Description
        Total = Total + invline.Total
    Next
    obj.Print "Grand Total = " & Total
End Sub

然而,此解法有個很大的缺點。其導致CInvoice類別完全受到設計師的支配。為了證明筆者所說的,試試下列程式碼:

inv.InvoiceLines.Add New CCustomer          ' No error!

當然,這並不驚訝:集合物件存放其值於變數中,所以他們可接受任何您給予的東西。這看似無錯的指令卻侵蝕CInvoice類別的強壯,且完全毀壞我們的努力。我們必須容忍嗎?

集合類別
 

問題的解法有種為集合類別,許多您用Visual Basic寫的特殊類別,其相當接近集合物件。既然您以了解其實作方法,便可建立其方法與檢查哪些該加到集合中。如您所見,其實您不需要接觸客戶端的程式。

集合類別是所謂繼承的應用。集合類別持有指向私有集合變數的參考,並包含指向外界的類似介面,因此客戶端程式可與真正的集合產生互動。為了增強Cinvoice範例,您需要特別的CInvoiceLines集合類別。(習慣上集合類別的名稱是基本類別的複數型態。)現在想必您已熟悉繼承,應該對於下列的程式碼應可了解:

' The private collection that holds the real data
Private m_InvoiceLines As New Collection

Sub Add(newItem As CInvoiceLine, Optional Key As Variant, _
    Optional Before As Variant, Optional After As Variant)
    m_InvoiceLines.Add newItem, Key
End Sub
Sub Remove(index As Variant)
    m_InvoiceLines.Remove index
End Sub
Function Item(index As Variant) As CInvoiceLine
    Set Item = m_InvoiceLines.Item(index)
End Function
Property Get Count() As Long
    Count = m_InvoiceLines.Count
End Property

需要做兩件事好讓CInvoiceLines集合類別完美地模仿標準的集合:必須提供預設項目與計數

讓Item成為預設成員
 

當使用集合物件時,程式設計師常會省略Item成員名稱。為了在您的集合類別中支援此種特性,只需要讓Item為類別的預設成員即可,方法為從工具選單的程序屬性選項中,選擇最上方下拉式選單的項目,展開對話方塊,在ProcID欄位中輸入0。或可於下拉式清單中選擇(預設)。, 第六章 對此程序有深入的解說。

對列舉的支援
 

若集合類別未支援For Each敘述的話,相信其無法贏得Visual Basic開發者的心。Visual Basic可讓您支援它,不過方法有點隱密。首先在類別模組中加入下列程序:

Function NewEnum() As Iunknown
    Set NewEnum = m_InvoiceLines.[_NewEnum]
End Function

然後打開程序屬性對話方塊。選擇 NewEnum 成員,指定其值為 -4 ,然後點選隱藏此成員核選鈕,接著關閉此對話方塊。


說明

要了解此詭異的技術如何運作需要熟悉OLE,特別是IEnumVariant介面。撇開細節不談,只要知道當物件可使用For Each方式時,它必須包含一個額外的計數物件。OLE規定類別必須透過ProcID為-4的函數來提供計數物件,Visual Basic呼叫相對的函數並使用傳回的計數物件來處理迴圈。

不幸地,無法使用一般的Visual Basic程式來產生計數物件,但可借用私有集合物件所包含的計數物件。集合物件包含其計數器,使用一種隱藏的方法稱為 _NewEnum,其在VBA是不合法的名稱,因此必須被中括號所括起來。此外,Dictionary物件並未包含任何公開的計數物件,因此無法在您的集合類別中使用他們。


測試集合類別
 

現在讓CInvoice類別使用您新的CInvoiceLines類別來替代標準的Collection物件以改善它。

' In the declaration section of Cinvoice
Public InvoiceLines As New CinvoiceLines

CInvoiceLines類別檢查傳給其Add方法的物件型態足以讓CInvoice類別成為安全的物件。有趣的是,不需要改變程式碼,不論是類別內或外。只要按下F5看看就知道了。

改善集合類別
 

倘若集合類別只改善程式結構的話,那應已足夠了。然而真正有趣的事才剛開始。既然您已可完成控制類別,便可用新方法或修改既有的方法來改善之。例如,當元素不存在時,可讓Item方法傳回Nothing,代替原本引發錯誤的結果。

Function Item(index As Variant) As CInvoiceLine
    On Error Resume Next
    Set Item = m_InvoiceLines.Item(index)
End Function

或可加入Exists函數如下列:

Function Exists(index As Variant) As Boolean
    Dim dummy As CInvoiceLine
    On Error Resume Next
    Set dummy = m_InvoiceLines.Item(index)
    Exists = (Err = 0)
End Function

也可提供Clear方法:

Sub Clear()
    Set m_InvoiceLines = New Collection
End Sub

這些自定的成員皆可廣泛運用,通常可將之實作於您所寫的大部分集合類別中。屬於特定集合類別的方法與屬性毫無疑問是有趣的。

' Evaluate the total of all invoice lines.
Property Get Total() As Currency
    Dim result As Currency, invline As CInvoiceLine
    For Each invline In m_InvoiceLines
        result = result + invline.Total
    Next
    Total = result
End Property
' Print all invoice lines.
Sub PrintLines(obj As Object)
    Dim invline As CInvoiceLine
    For Each invline In m_InvoiceLines
        obj.Print invline.Description
    Next
End Sub

這些新成員簡化了主要類別的程式結構:

' In the CInvoice class
Sub PrintBody(obj As Object)
    InvoiceLines.PrintLines obj
    obj.Print "Grand Total = " & InvoiceLines.Total
End Sub

當然,程式的總量沒變,只是將之分散在不同的邏輯中。每個物件只反映於其內發生的事件。在真正的專案中,這種方法對於程式測試,重用與維護有很大的幫助。

加入實際的建構者
 

集合類別提供了物件導向設計所不能欠缺的:真正的建構者。之前筆者已說明建構方法的缺乏是Visual Basic的一大缺點。

若將集合類別運用在基本類別上─如CInvoiceLines相對於CInvoiceLine般─可藉由加入一個方法來建立建構者,此建立新的基本物件,且以一個步驟將之加到集合內。在大部分情況下,這種運作有很大的意義。例如,CInvoiceLine物件在CInvoiceLines集合外可能會有很大的運作領域。原來這樣的建構者只是Add方法的變形罷了:

Function Create(Qty As Long, Product As String, UnitPrice As Currency) _
    As CInvoiceLine
    Dim newItem As New CInvoiceLine ' Auto-instancing is safe here.
    newItem.Init Qty, Product, UnitPrice
    m_InvoiceLines.Add newItem
    Set Create = newItem            ' Return the item just created.
End Function
' In the client code
inv.InvoiceLines.Create 10, "Monitor ZX100", 225.25
inv.InvoiceLines.Create 14, "101-key Keyboard", 19.99

Add與Create方法的不同在於後者會傳回新加到集合內的物件,而Add不會。這會大幅簡化您的程式。例如,假設CinvoiceLine物件支援兩種新屬性,Color與Notes。兩者皆為可選擇的,且他們皆未包含在Create方法的參數中。但您仍可設定他們,藉由某種簡潔且有效率的語法,如下:

With inv.InvoiceLines.Create(14, "101-key Keyboard", 19.99)
    .Color = "Blue"
    .Notes = "Special layout"
End With

依照問題的不同,可用Add與Create方法來建立集合類別,或只使用其中一種。然而重要的是,若要保留Add方法於集合內,需要加入一些驗證程式。在大部分情況下─並非總是如此─只需要讓類別自己驗證即可,如下程式:

Sub Add(newItem As CinvoiceLine)
    newItem.Init newItem.Qty, newItem.Product, newItem.UnitPrice
    ' Add to the collection only if no error was raised.
    m_InvoiceLines.Add newItem, Key
End Sub

若將內部類別包進其父集合類別時,有一點對於開發者是很重要的,就是有意或無意地加入無用的物件至此系統內。他們最差的情況是建立無關緊要的CInvoiceLine物件,但不會把它加到受保護的CInvoice物件。

完整的階層
 

一旦了解如何建立有效率的集合類別時,建立複雜強大的物件階層,例如那些知名如Microsoft Word、Microsoft Excel、DAO、RDO、ADO等內的物件就毫無困難了。您已知道正確的方向只需要在了解細節即可。讓我來說明一些當建立階層時會遇上的問題,並說明如何解決。

類別靜態資料
 

當建立複雜的階層時,常會遇到下列問題:一個類別的所有物件如何共享變數?例如,若CInovice類別能正確地在其Class_Initialize事件中設定其Number屬性,而讓其成為唯讀屬性的話,那真是太棒了。這會改善類別的正確性,因為其保證沒有兩張發票有相同的號碼。若在類別模組中能夠定義類別的靜態變數的話,這表示變數是被此類別的所有物件所共享,那這個問題就可以很快地解決。但現在VBA語言並未提供此功能。

關於此問題簡單且易懂的解法為使用一廣域變數在BAS模組內,但這會破壞類別的封裝性,因為任何人皆可修改此變數。任何其他類似的方法─如將值存在檔案內、資料庫內,或註冊區內等─皆面臨相同的問題。幸運的是,解法是很簡單的:使用一個父集合類別來收集共享相同值的類別的所有實體。不只可解決此問題,還可為此基本類別本身提供更強的建構者。在CInvoice範例程式中,可建立CInvoices集合類別:

' The CInvoices Collection class
Private m_LastInvoiceNumber As Long
Private m_Invoices As New Collection

' The number used for the last invoice (read-only)
Public Property Get LastInvoiceNumber() As Long
    LastInvoiceNumber = m_LastInvoiceNumber
End Property
' Create a new CInvoice item, and add it to the private collection.
Function Create(InvDate As Date, Customer As CCustomer) As CInvoice
    Dim newItem As New CInvoice
    ' Don't increment the internal variable yet!
    newItem.Init m_LastInvoiceNumber + 1, InvDate, Customer
    ' Add to the internal collection, using the number as a key.
    m_Invoices.Add newItem, CStr(newItem.Number)
    ' Increment the internal variable now, if no error occurred.
    m_LastInvoiceNumber = m_LastInvoiceNumber + 1
    ' Return the new item to the caller.
    Set Create = newItem
End Function
' Other procedures in the CInvoices collection class ... (omitted)

同樣地,可建立一個CCustomers集合類別(在此沒有顯示),其建立並管理所有在此應用程式內的CCustomer物件。現在您的程式用安全的方法來建立CInvoice與CCustomers物件了:

' These variables are shared in the application.
Dim Invoices As New CInvoices
Dim Customers As New CCustomers

Dim inv As CInvoice, cust As CCustomer
' First create a customer.
Set cust = Customers.Create("Tech Eleven, Inc")
cust.Address.Init "234 East Road", "Chicago", "IL", "12345"
' Now create the invoice.
Set inv = Invoices.Create("12 Sept 1998", cust)

現在可藉由建立最上層的類別,名為CCompany(其包含所有集合成為屬性),來完成此項工作:

' The CCompany class (the company that sends the invoices)
Public Name As String 
Public Address As CAddress
Public Customers As New CCustomers
Public Invoices As New CInvoices
' The next two collections are not implemented on the companion CD.
Public Orders As New COrders           
Public Products As New CProducts

當將類別用此方法來封裝時,會有許多優點。假設您老闆要您加入支援多重公司的功能,這件事並不容易,但可藉由建立新的CCompanies集合類別來讓事情便得較少負荷。既然CCompany物件是被分離開來的,於是可重用此完整模組,而無須考慮其邊際效應。

往後指標(Backpointers)
 

當處理階層時,一個獨立的物件常會需要存取其父物件﹔例如,查詢其中之一屬性或呼叫其方法。最自然的方式是加入一個BackPointer。BackPointer是個參考到其父物件的明顯物件。它可以是Public屬性也可是Private變數。

這如何搭配我們的發票範例程式呢?當要列印一張發票本身時,若還有其他已付款的發票時,需要告知客戶警告訊息。為了達成此目標,CInvoice類別必須掃過其父CInvoices集合,因此需要一個指標。定義上,此BackPointer命名為Parent或Collection,但您仍可取別的名字。若要讓此指標成為Puclic,讓他為唯讀是必要的條件,最少對專案外而言是這樣的。(否則,任何人皆可把發票自CInvoices集合中分離。)可讓指標的Property Set函示加上Friend來達成這件事:

' In the CInvoice class
Public Paid As Boolean
Private m_Collection As CInvoices         ' The actual backpointer

Public Property Get Collection() As CInvoices
    Set Collection = m_Collection
End Property
Friend Property Set Collection(newValue As CInvoices)
    Set m_Collection = newValue
End Property

父Cinvoices集合類別現在為設定這BackPointer的原由,此BackPointer為在Create建構方法內所形成的。

' Inside the CInvoices' Create method (rest of the code omitted)
newItem.Init m_LastInvoiceNumber + 1, InvDate, Customer
Set newItem.Collection = Me

現在CInvoice類別知道如何讓頑強的客戶來付款,如您於圖7-9所示,程式碼如下:

Sub PrintNotes(obj As Object)
    ' Print a note if customer has other unpaid invoices. 
    Dim inv As CInvoice, Found As Long, Total As Currency
    For Each inv In Collection
        If inv Is Me Then
            ' Don't consider the current invoice!
        ElseIf (inv.Customer Is Customer) And inv.Paid = False
		Then
            Found = Found + 1
            Total = Total + inv.GrandTotal
        End If
    Next
    If Found Then
        obj.Print "WARNING: Other " & Found & _
            " invoices still waiting to be paid ($" & Total & ")"
    End If
End Sub


 

圖7-9 別讓基礎的使用者介面愚弄了您:有八個物件一同運作來提供達成穩健的發票程式。

循環參照(Circular reference)當兩個物件互相指向彼此(不論是直接或間接)時會有循環參照的問題。發票階層並沒有循環參照,除非加入Collection BackPointer到CInvoice類別。造成循環參照的問題是兩個發票物件會互相無限制地把持住,。這並不驚訝,他只是管理物件存在的相同老問題罷了。

在這個例子,除非我們提出適當的方法,否則這兩個物件的參照計數不會減為0,即使主程式已結束亦然。亦即必須放棄部分記憶體,直到應用程式結束且等到Visual Basic將所有記憶空間歸還給Windows。這不只是浪費記憶體:在許多舊的階層裡,整個系統的強固性常依靠Class_Terminate事件內的程式(例如,將屬性值存回資料庫)。當應用程式結束時,Viausl Basic正確地呼叫所有仍存活著物件的Class_Terminate事件,在主程式關閉其自己的檔案後,這仍可能會發生。如此一來會造成資料庫損毀。

現在我已警告您所有循環參照可能的後果,但還有:Viausl Basic並未對此問題提出最終的解決方法。您有兩個解決一半的方法,兩個都不合格:首先避免循環參照,在應用程式摧毀物件參照前,手動解開所有循環參照。

在發票範例,若讓內在的CInvoice類別使用公用變數來存取其父集合,可避免BackPointer。但這是不太好的習慣,可能會破壞類別封裝與程式的強固性。第二個解法─手動解開所有循環參照─通常是很困難的,當處理複雜的階層時。尤其,這需要加入許多錯誤處理程式,只是為了確定沒有物件變數會自動地被Visual Basic設為被Nothing,在您有機會解決所有存在的循環參照前。

唯一一個好消息是這問題可被解決,但它需要較深入且低階的程式技巧,架構在Weak Object Pointers上。此技巧超出本書範圍,因此這裡不會講述之。然而,可看一下書附CD的CInvoice類別。裡頭有#If的部分為使用正規且虛弱的物件指標。您可能需要複習一下物件如何存於記憶體內,且物件變數是什麼,但在程式內的註解有助於您了解這些程式碼在幹麻。若要在程式中使用這些技巧,請務必了解他,因為當用此低階技術來操作物件時,任何一個錯誤皆會導致系統當掉!

類別建構增益集
 

Visual Basic 6有個嶄新的Class Builder增益集。這是個用來設計類別階層結構,建立新類別與集合類別,與定義其介面─包含屬性,方法與事件等的主要工具。最新版本增加了對於計數屬性與任意資料型態的額外參數的支援,以及一些小的改進。

Class Builder增益集於安裝Visua Basic 6時會安裝進去,使用時只需要開啟增益集管理對話方塊,並雙按VB6類別建立工具即可。當關閉視窗時,於增益集選單有個新的項目可執行此工具。


 

圖7-10 Class Builder增益集。Child類別(在此為CPoint)總是為其Parent類別(CLine)的屬性。

使用Class Builder增益集是相當容易,在此將不詳細說明如何建立新類別與其屬性和方法。其使用者介面相當清楚,因此使用它應不會有太大問題。底下有幾個對您有益的重點:

Class Builder的缺點是無法控制其產生的程式碼。例如,其對於參數與變數使用特別的命名法則,且增加許多您可能會想要刪掉的無用註解。另外一點就是,一旦開始在專案中使用它後,當要加一個新類別時都會被強迫引用他─否則就不會把新類別正確地置於階層中。即使有這些限制,運用Class Builder建立階層仍是很簡單的!

本章包含了物件導向的觀念。若想要有良好設計的軟體與可重用程式碼,您會同意OOP是個迷人的技術。然而使用Visual Basic,了解類別與物件如何運作是必須的,不論運用在資料庫、Client/Server、COM與Internet程式設計方面皆是。