本文章是由機器翻譯。

孜孜不倦的程式師

多模式 .NET,第 4 部分:物件導向

Ted Neward

在上一篇文章中,我們探討了通過過程程式設計來表示通用性和可變性,併發現了幾個有趣的“滑塊”,可通過這些滑塊將可變性引入設計中。特別是,過程思路揭示了兩種設計方法:名稱和行為可變性與演算法可變性。

隨著程式複雜性和要求的日益提高,開發人員發現自己必須想方設法使各種子系統簡單明瞭。我們發現,過程抽象並不會像我們期望的那樣進行“縮放”。隨著GUI 的面世,一種新的程式設計樣式已開始出現,許多瞭解從 Windows 3 SDK 構建 Windows 應用程式的“傳統的 SDK”樣式和 Charles Petzold 的經典“Windows 程式設計”(Microsoft Press, 1998) 的讀者都將立刻認可該樣式。該樣式本質上是一個過程,它遵循了一個特別有趣的模式。相關功能的緊密群集節點中的每個過程都以“handle”參數為中心,大多數情況下,該參數將作為第一個(或唯一)參數,或從 Create 調用或類似調用返回該參數:例如,CreateWindow、FindWindow、ShowWindow 等都以視窗控制碼 (HWND) 為中心。

當時開發人員未意識到這實際上是一種新的程式設計方式,幾年之後它才讓人覺得是一種新的模式。當然,他們事後認識到,它很明顯是物件導向的程式設計,並且這個專欄的大多數讀者已熟知其規則和理念。既然如此,我們為何不決定用寶貴的專欄篇幅來描述該主題呢?這是因為,在未將物件併入多模式設計範圍的情況下,無法完成多模式設計。

物件基礎知識

從很多方面來看,物件導向都是對繼承的運用。實現繼承是物件設計討論的主要內容,提倡者建議通過標識系統中的實體和存在通用性的地方,然後將這些通用性提升到一個基類中,創建“屬於”關係,從而構建適當的抽象(“名詞”)。學生屬於人、講師屬於人、人屬於物件等。這樣,繼承就為開發人員提供了分析通用性和可變性的新基準線。

在使用 C++ 的時期,實現繼承方法是獨立的,但隨著時間的推移和經驗的不斷累積,介面繼承已作為一種替代方式出現。實際上,將介面繼承引入設計者的工具箱可實現輕型繼承關係,聲明類型屬於一個不同的類型,但不具有父類型的行為或結構。因此,介面提供了一個用於以繼承為核心對類型進行“分組”的機制,而不會在其實現上強制施加任何特定限制。

例如,請考慮面向規範物件的示例,它是可繪製(如果只是象徵性地)到螢幕的幾何形狀的層次結構:

    class Rectangle
    {
      public int Height { get; set; }
      public int Width { get; set; }
      public void Draw() { Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
    }
    class Circle
    {
      public int Radius { get; set; }
      public void Draw() { Console.WriteLine("Circle: {0}r", Radius); }
    }

各個類之間的通用性表明超類在此處很適用,可避免在每個可繪製的幾何形狀重複通用性:

abstract class Shape
{
  public abstract void Draw();
}
  
class Rectangle : Shape
{
  public int Height { get; set; }
  public int Width { get; set; }
  public override void Draw() { 
    Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
}
class Circle : Shape
{
  public int Radius { get; set; }
  public override void Draw() { Console.WriteLine("Circle: {0}r", Radius); }
  }

目前為止未出現什麼問題 - 大多數開發人員到目前為止未對已執行的操作提出任何問題。 遺憾的是,問題總是會在不經意間出現。

再次介紹 Liskov

以下就是所謂的 Liskov 替換原則:繼承自另一個類型的任何類型必須對該類型是完全可替換的。 或者,借用最初描述該原則的話:“使 q(x) 成為有關類型為 T 的物件 x 的可驗證屬性, 而 q(y) 應成為有關類型為 S 的物件 y 的可驗證屬性,其中 S 是 T 的子類型。”

在實踐中,這意味著 Rectangle 的任何特定派生(如 Square 類)必須確保它遵守由基提供的同一行為保證。 由於 Square 實際是一個 Height 和 Width 始終確保相同的 Rectangle,因此像圖 1 中的示例一樣編寫 Square 似乎是合理的。

圖 1 派生 Square

class Rectangle : Shape
{
  public virtual int Height { get; set; }
  public virtual int Width { get; set; }
  public override void Draw() { 
    Console.WriteLine("Rectangle: {0}x{1}", Height, Width); }
}
class Square : Rectangle
{
  private int height;
  private int width;
  public override int Height { 
    get { return height; } 
    set { Height = value; Width = Height; } 
  }
  public override int Width {
    get { return width; }
    set { Width = value; Height = Width; }
  }
}

請注意,Height 和 Width 屬性此時都是虛擬的,這是為了避免在 Square 類中重寫它們時出現任何意外的陰影或切片行為。 至此不會有什麼問題。

緊接著,一個 Square 會傳入一個方法中,此方法採用一個 Rectangle 並將它“增大”(圖形極客有時會調用“轉換”):

class Program
{
  static void Grow(Rectangle r)
  {
    r.Width = r.Width + 1;
    r.Height = r.Height + 1;
  }
  static void Main(string[] args)
  {
    Square s = new Square();
    s.Draw();
    Grow(s);
    s.Draw();
  }
}

圖 2 顯示了調用此代碼所獲得的最終結果,該結果不是您可能的預期結果。

圖 2 調用 Grow 代碼獲得的意外結果

細心的讀者可能已猜到,此處的問題在於,每個屬性實現都假定被單獨調用,從而必須單獨操作以確保始終施加有關 Square 的 Height==Width 這一限制。但 Grow 代碼假定傳入的是 Rectangle,它完全不知道實際傳入的是 Square(按照預期!),並會按照完全適用于 Rectangle 的方式操作。

問題的核心是什麼呢?正方形不是長方形。就算它們有很多相似之處,但最終正方形的限制不適用於長方形(順便說一下,對於橢圓和圓也是如此),嘗試根據一個物件對另一個物件進行建模是錯誤的。嘗試從 Rectangle 繼承 Square,因為它允許我們重用一些代碼,但這是一個錯誤的假設。實際上,我甚至建議在這兩個類型的 Liskov 替換原則被證實正確之前,任一類型都絕不應使用繼承來推動重用。

該示例並不是一個新示例 - Robert “Uncle Bob” Martin (bit.ly/4F2R6t) 在 90 年代中期與 C++ 開發人員交談時曾討論過 Liskov 和該示例。通過使用介面來描述關係可部分解決一些像這樣的問題,但對於這一特定情況不起作用,因為 Height 和 Width 仍是單獨屬性。

這種情況有解決方案嗎?還真沒有,沒有一個可以同時保留“Square 從 Rectangle 派生”關係的解決方案。最佳解決辦法是使 Square 成為 Shape 的直接後代,並完全棄用繼承方法:

 

class Square : Shape
{
  public int Edge { get; set; }
    public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
    static void Main(string[] args)
    {
      Square s = new Square() { Edge = 2 };
      s.Draw();
      Grow(s);
      s.Draw();
    }
}

當然,我們現在的問題是根本無法將 Square 傳入 Grow,似乎那裡有一個潛在的代碼重用關係。 我們可以使用轉換操作將 Square 的視圖提供為 Rectangle,來從一個方面解決此問題,如圖 3 所示。

圖 3 轉換操作

class Square : Shape
{
  public int Edge { get; set; }
  public Rectangle AsRectangle() { 
    return new Rectangle { Height = this.Edge, Width = this.Edge }; 
  }
  public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
  static void Grow(Rectangle r)
  {
    r.Width = r.Width + 1;
    r.Height = r.Height + 1;
  }
  static void Main(string[] args)
  {
    Square s = new Square() { Edge = 2 };
    s.Draw();
    Grow(s.AsRectangle());
    s.Draw();
  }
}

這樣做很有用 – 只不過操作起來有點難。 我們還可以使用 C# 轉換運算子工具更輕鬆地將 Square 轉換為 Rectangle,如圖 4 所示。

圖 4 C# 轉換運算子工具

class Square : Shape
{
  public int Edge { get; set; }
  public static implicit operator Rectangle(Square s) { 
    return new Rectangle { Height = s.Edge, Width = s.Edge }; 
  }
  public override void Draw() { Console.WriteLine("Square: {0}x{1}", Edge, Edge); }
}
class Program
{
  static void Grow(Rectangle r)
  {
    r.Width = r.Width + 1;
    r.Height = r.Height + 1;
  }
  static void Main(string[] args)
  {
    Square s = new Square() { Edge = 2 };
    s.Draw();
    Grow(s);
    s.Draw();
  }
}

雖然此方法可能與預期的方法有明顯的不同,但它提供了與以前相同的用戶端角度,而且不存在早期實現中出現的問題,如圖 5 所示。

圖 5 使用 C# 轉換運算子工具獲得的結果

實際上,我們有一個不同的問題 – 在 Grow 方法修改要傳入的 Rectangle 之前,它看上去未執行任何操作,這在很大程度上是因為它修改的是 Square 的副本,而不是最初的 Square。我們可以通過以下方式修復該問題:通過讓轉換運算子將 Rectangle 的一個包含機密引用的新子類返回此 Square 實例,以便對 Height 和 Width 屬性所做的修改將依次返回並修改 Square 的邊...但在那之後,我們就會回到原來的問題!

無法得到滿意的結果

好萊塢電影的結局必須符合觀眾的預期,不然的話,票房收入就很難得到保證。我不是電影製作人,因此在任何情況下,我都無需向本專欄的讀者呈現令其滿意的結果。以下是其中的一種情況:嘗試將原始代碼保留在適當位置,並使其完全用於創建越來越高級的技巧。可能的解決方案是,直接將 Grow 或 Transform 方法移動到 Shape 層次結構上或僅使 Grow 方法返回修改後的物件,而不是修改傳入的物件(我們將在另一個專欄中談論此內容),簡而言之,我們無法將原始代碼保留在適當的位置並使所有內容正常運行。

所有這些旨在明確展示一點,即物件導向的開發人員可輕鬆使用繼承對通用性和可變性進行建模,可能這樣說有點誇張。記住,如果您選擇使用繼承軸來捕獲通用性,則要避免像這樣的細小 Bug,您必須確保此通用性貫穿于整個層次結構。

另請記住,繼承始終是正可變性(添加新的欄位或行為),對繼承中的負可變性進行建模(Square 嘗試執行的操作)幾乎總是會遵循 Liskovian 規則帶來災難。確保所有基於繼承的關係包含正通用性,並且所有操作應是正常的。祝您工作愉快!

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

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