本文章是由機器翻譯。

孜孜不倦的程式師

多模式 .NET,第 2 部分

Ted Neward

我在上一篇文章(即本系列的第一部分,位址為 msdn.microsoft.com/magazine/ff955611)中曾提到,Microsoft .NET Framework 的兩種核心語言 C# 和 Visual Basic 是多模式語言,這一點與作為 C# 語法前身或 Visual Basic 概念前身的 C++ 一樣。 多模式語言的使用可能會造成困惑和困難,特別是不同模式的用途不明確時。

通用性與可變性

但在我們開始分別剖析這些語言的不同模式之前,腦海中卻浮現出一個更重要的問題:確切地講,我們在設計一個軟體系統時到底是要做些什麼? 模組性、擴展性、簡易性,等等 — 暫時先忘記這些作為“最終結果”的目標,更多地關注一下語言的方法問題。 我們究竟要如何建立所有這些“最終結果”目標?

James O. Coplien 在他的“Multi-Paradigm Design for C++”(Addison-Wesley Professional,1998 年)中為我們給出了答案:

當我們進行抽象思考時,會抑制細節並強調共通點。 好的軟體抽象要求我們從方方面面很好地理解問題,以瞭解所需的相關專案之間的共性,並瞭解因專案不同而變化的細節。 所需的專案統稱為一個系列,體系結構和設計的範圍正是這些系列,而不是各個應用程式。 無論系列成員是模組、類、函數、進程還是類型,我們都可以使用通用性/可變性模型,它適用于任何模式。 通用性和可變性是大多數設計技術的核心所在。

想一想傳統的物件模式。 作為物件導向的開發人員,我們一直以來學習的都是在系統中“標識名詞”並尋找組成某個特定實體的內容,例如,查找系統中與作為“教師”有關的所有內容,然後將它們放入一個名為 Teacher 的類中。 但是,如果幾個“名詞”具有重疊且相關的行為(例如,某個“學生”的某些資料和操作與某個“人”重疊,但又存在明顯的區別),我們所學的方法是應該將通用性提升為一個基類,然後通過繼承使各種類型相互關聯,而不是複製通用代碼。 換言之,就是將通用性集中到一個類中,通過從該類延伸並引入變體來捕獲可變性。 找到系統內的通用性和可變性,表示它們,形成設計的重點。

通用性常常難以明確標識,不是因為我們無法識別它們,而是因為它們的識別太容易而且太直觀,所以反而很難找出它們。 例如,如果我說“車輛”,您的腦海中會閃出怎樣的影像? 如果我們對一組人進行這一實驗,那麼每個人都會得出不同的影像,但這些影像將具有許多通用性。 當然,如果我們開始羅列人們想像的各種車輛,不同種類的可變性就會開始浮現出來,並自動歸類(但願如此),這樣我們還是能得到車輛中的一組通用性。

正可變性和負可變性

可變性可以分為兩種基本形式,其中一種很容易識別,另一種則困難得多。 正可變性是可變性以添加到基本通用性的形式出現的情況。 例如,假設需要抽象的是一則消息,如 SOAP 消息或電子郵件。 如果我們指定 Message 類型包含標頭和正文,並讓不同種類的消息使用該類型作為通用性,那麼這時正可變性就是在其標頭中帶有某個特定值(比如帶有其發送日期/時間)的消息。 這在語言結構中通常可以輕鬆捕獲 — 例如,在物件導向模式中,要創建一個添加對發送日期/時間的支援的 Message 子類就比較簡單。

但負可變性就複雜得多了。 可以推斷出,負可變性即刪除或否定通用性的某個方面 — 有標頭但沒有正文的 Message(例如消息傳遞基礎結構所使用的確認消息)就是負可變性的一種形式。 而且您大概也猜到了,在語言結構中捕獲這種可變性是個難題 — C# 和 Visual Basic 都沒有刪除基類中聲明的成員的功能。 在這種情況下,我們也只好從 Body 成員返回 null 或不返回任何值,這無疑會導致所有需要使用 Body 的代碼(例如在 Body 上運行 CRC 以確保其傳輸正確的驗證常式)發生混亂。

(有趣的是,XML 架構類型在其架構驗證定義中提供了主流程式設計語言尚未提供的負可變性,這也是 XML 架構定義與程式設計語言不能匹配的一個地方。 無論這是否會成為某些尚未成形的程式設計語言中即將推出的功能,也不管它是不是一件好事,都是一個比啤酒更有意思的討論話題。)

在許多系統中,通常都會使用用戶端級別的顯式代碼構造處理負可變性,也就是說,要由 Message 類型的使用者在檢查 Body 之前進行一定的 if/else 測試以確定它是什麼種類的 Message,這使得人們在 Message 系列中付出的努力變得幾乎毫無意義。 設計中出現太多負可變性通常是導致開發人員需要“全部丟掉重新開始”的根本原因。

綁定通用性與可變性

確定通用性和可變性的時間因模式而異,一般來講,我們能夠綁定這些決定的時間越接近運行時,就會使客戶和使用者獲得對系統演變和總體的更多控制權。 在討論某個特定模式或模式中某種特定技術時,識別可變性出現的以下四種“綁定時間”非常重要:

  1. 源時間。 這是編譯器啟動前的時間,這時開發人員(或某個其他實體)正在創建最終將送入編譯器的原始檔案。 代碼生成技術(如 T4 範本引擎)以及較低級別的 ASP.NET 系統用於源時間綁定。
  2. 編譯時。 顧名思義,這種綁定發生在編譯器傳遞原始程式碼以將其譯為編譯位元組碼或可執行 CPU 指令的過程中。 有很多決策都在這裡最終確定,但並非所有決策,我們稍後將會看到。
  3. 連結/載入時。 在程式載入和運行時,根據所載入的具體模組(對於 .NET,是程式集;對於本機 Windows 代碼,是 DLL),會出現另外一個可變點。 應用於整個程式級別時,這通常稱為外掛程式式或外接式體系結構。
  4. 運行時。 在程式執行過程中,可能會根據使用者輸入和決策捕獲某些可變性,還可能根據基於這些決策/輸入執行(甚至生成)的不同代碼進行捕獲。

在大多數情況下,設計過程都需要從上述“綁定時間”開始向後進行,以確定哪些語言結構可以滿足需求;例如,使用者或許希望能夠在運行時添加/刪除/修改可變性(這樣我們就不必重新經歷編譯週期或重新載入代碼),這就意味著無論設計者採用什麼模式,該模式都必須支援運行時可變性綁定。

挑戰

我在上一篇文章中給讀者留下了一個問題:

作為一個練習,請考慮以下情況:.NET Framework 2.0 引入了泛型(參數化類型)。 為什麼呢? 從設計的角度來看,它們起什麼作用呢? (為準確起見,諸如“它為我們提供類型安全的集合”的答案並沒有抓住要點 - Windows Communication Foundation 以明顯不僅僅是為了提供類型安全的集合的方式大量使用了泛型。)

稍微深入一點來看,請看圖 1 中 Point 類的(部分)實現,該實現顯示一個笛卡爾 X/Y 點,就像螢幕上或更傳統圖形中的圖元座標。

圖 1 Point 類的部分實現

Public Class Point
  Public Sub New(ByVal XX As Integer, ByVal YY As Integer)
    Me.X = XX
    Me.y = YY
  End Sub

  Public Property X() As Integer
  Public Property y() As Integer

  Public Function Distance(ByVal other As Point) As Double
    Dim XDiff = Me.X - other.X
    Dim YDiff = Me.y - other.y
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
If Me.GetType() = obj.GetType() Then
      Dim other As Point = obj
      Return other.X = Me.X And other.y = Me.y
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.y)
  End Function

End Class

這部分實現本身其實沒什麼可令人興奮的。 實現的其餘部分並不是我們討論的焦點,因此留給讀者去想。

請注意,以上 Point 實現在利用點的方式上做了一些假設。 例如,點的 X 和 Y 元素是整數,就是說這個 Point 類不能表示小數點(如 (0.5,0.5) 處的點)。 這種決定最初或許可以接受,但將來必然會出現要求能夠表示“小數點”的請求(無論出於何種原因)。 這時開發人員就會遇到了一個有趣的問題:如何體現這種新的需求?

從基本理論出發,我們就會祈禱著“哦上帝,千萬別發生這種事”,並創建一個使用浮點型成員而不是整型成員的新 Point 類,然後觀察事態發展(請參閱圖 2;請注意 PointD 是“Point-Double”的縮寫形式,表示它使用 Double 類型)。 很明顯,這兩種 Point 類型存在大量概念重疊。 根據設計的通用性/可變性理論,這表示我們需要設法捕獲通用部分並允許可變性。 傳統的物件導向方法會讓我們通過繼承實現這一點,即將通用性提升到一個基類或介面 (Point) 中,然後在子類(比如 PointI 和 PointD)中實現該通用性。

圖 2 使用浮點型成員的新 Point 類

Public Class PointD
  Public Sub New(ByVal XX As Double, ByVal YY As Double)
    Me.X = XX
    Me.y = YY
  End Sub

  Public Property X() As Double
  Public Property y() As Double

  Public Function Distance(ByVal other As Point) As Double
    Dim XDiff = Me.X - other.X
    Dim YDiff = Me.y - other.y
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
If Me.GetType() = obj.GetType() Then
      Dim other As PointD = obj
      Return other.X = Me.X And other.y = Me.y
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.y)
  End Function

End Class

然而,這種嘗試又會帶來同樣有趣的問題。 首先,X 和 Y 屬性需要與之關聯的類型,但兩個不同子類中的可變性關係到如何存儲 X 和 Y 座標,以及進而如何呈現給使用者。 設計者可能會始終選擇最大/範圍最廣/最全面的表示形式(在本例中為 Double),但這麼做意味著失去了只能具有整數值的點這一選項,而且還破壞了繼承本應實現的所有功能。 另外,由於兩個繼承 Point 的實現選擇現在通過繼承相互關聯,因此按推測可以互換,這樣就能將 PointD 傳遞到 PointI Distance 方法中,但這並不一定可取。 另外,(0.0, 0.0) 的 PointD 是否等同于(即等於)(0,0) 的 PointI? 所有這些問題都必須予以考慮。

即使設法解決或控制住了這些問題,還會出現其他問題。 以後我們可能需要一個接受超出整數範圍的值的 Point。 或者只接受絕對正值(表示原點位於左下角)。 這些不同需求中的每一種都意味著必須創建新的 Point 子類。

暫時後退一步來看,最初的需求是重複使用 Point 實現的通用性,並允許組成 Point 的值的類型/表示形式中的可變性。 在理想情況下,根據所使用的圖表類型,我們應該能夠在創建 Point 時選擇表示形式,它會將自己表示為完全不同的類型,這正是泛型的作用。

不過,這麼做也會帶來一個問題:編譯器堅持沒必要為“Rep”類型定義“+”和“-”運算子,因為它認為我們需要在這裡放置任何可能的類型(Integer、Long、String、Button、DatabaseConnection 或是任何能想到的其他類型),而這種變數顯然太大了點。 所以,我們還是需要採用對可用“Rep”類型的泛型約束的形式,表示可以在此處使用的類型的某種通用性(請參閱圖 3)。

图 3 类型的泛型约束

Public Class GPoint(Of Rep As {IComparable, IConvertible})
  Public Sub New(ByVal XX As Rep, ByVal YY As Rep)
    Me.X = XX
    Me.Y = YY
  End Sub

  Public Property X() As Rep
  Public Property Y() As Rep

  Public Function Distance(ByVal other As GPoint(Of Rep)) As Double
    Dim XDiff = (Me.X.ToDouble(Nothing)) - (other.X.ToDouble(Nothing))
    Dim YDiff = (Me.Y.ToDouble(Nothing)) - (other.Y.ToDouble(Nothing))
    Return System.Math.Sqrt((XDiff * XDiff) + (YDiff * YDiff))
  End Function

  Public Overrides Function Equals(ByVal obj As Object) As Boolean
    ' Are these the same type?
If Me.GetType() = obj.GetType() Then
      Dim other As GPoint(Of Rep) = obj
      Return (other.X.CompareTo(Me.X) = 0) And (other.y.CompareTo(Me.Y) = 0)
    End If
    Return False
  End Function

  Public Overrides Function ToString() As String
    Return String.Format("({0},{1}", Me.X, Me.Y)
  End Function

End Class

在這種情況下會施加兩種約束:一種用於確保所有“Rep”類型均可轉換為雙精度值(以計算兩個點之間的距離),另一種用於確保能夠對組成的 X 和 Y 值進行比較以確定它們是否大於/等於/小於對方。

這時,採用泛型的理由就很明顯了:因為它們為設計提供一種不同的可變性“軸”,這個軸與傳統的基於繼承的軸截然不同。這樣,設計者就可以將實現表示為通用性,而將實現所使用的類型表示為可變性。

請注意,此實現假定可變性出現于編譯時,而不是連結/載入時或運行時,因此,如果使用者想要或需要在運行時指定 Point 的 X/Y 成員的類型,則需要另一個解決方案。

未完待續!

如果說所有軟體設計是對通用性和可變性的大範圍運用,那麼瞭解多模式設計的需要就變得顯而易見:每種不同模式提供實現這種通用性/可變性的不同方式,混合不同模式會產生混亂並導致不得不全部重寫。當我們試圖將頭腦中的三維結構映射至四維和五維時,人腦就會開始出現混亂,同樣,軟體中可變性的維度過多也會產生混亂。

在接下來的幾篇文章中,我將著眼于 C# 和 Visual Basic 所支援的每種模式(主要有結構化模式、物件導向模式、元程式設計模式、功能化模式和動態模式)如何提供捕獲通用性並允許可變性的功能。完成上述內容後,我們會研究怎樣以有趣的方式對其中一些模式進行組合,使您的設計更加模組化、更具擴展性更高、更易於維護,以及具備其他種種優勢。

祝您工作愉快!

Ted Neward 是 Neward &Associates 的負責人,這是一家專門研究 .NET Framework 企業系統和 Java 平臺系統的獨立公司。他曾寫過 100 多篇文章,是 C# 領域最優秀的專家之一;是 INETA 發言人;並且著作或合著過十幾本書,包括《Professional F# 2.0》(Wrox,2010 年)。此外,他還定期提供諮詢和指導。Reach him at ted@tedneward.com 向他提問或諮詢,也可以訪問他的博客 blogs.tedneward.com.

衷心感謝以下技術專家對本文的審閱: Anthony Green