設計模式-----開放封閉原則

開放封閉原則

在面向對象的設計中有很多流行的思想,比如說 “所有的成員變量都應該設置爲私有(Private)”,“要避免使用全局變量(Global Variables)”,“使用運行時類型識別(RTTI:Run Time Type Identification,例如 dynamic_cast)是危險的” 等等。那麼,這些思想的源泉是什麼?爲什麼它們要這樣定義?這些思想總是正確的嗎?本篇文章將介紹這些思想的基礎:開放封閉原則(Open Closed Principle)。

Ivar Jacobson 曾說過 “所有系統在其生命週期中都會進行變化,只要系統要開發一個版本以上這一點就需時刻記住。”。

All systems change during their life cycles. This must be borne in mind when developing systems expected to last longer than the first version.

那麼我們到底如何才能構建一個穩定的設計來面對這些變化,以使軟件生命週期持續的更長呢?

早在1988年Bertrand Meyer 就給出了指導建議,他創造了當下非常著名的開放封閉原則。套用他的原話:“軟件實體(類、模塊、函數等)應對擴展開放,但對修改封閉。”。

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

當一個需求變化導致程序中多個依賴模塊都發生了級聯的改動,那麼這個程序就展現出了我們所說的 “壞設計(bad design)” 的特質。應用程序也相應地變得脆弱、僵化、無法預期和無法重用。開放封閉原則(Open Closed Principle)即爲解決這些問題而產生,它強調的是你設計的模塊應該從不改變。當需求變化時,你可以通過添加新的代碼來擴展這個模塊的行爲,而不去更改那些已經存在的可以工作的代碼。

開放封閉原則(Open Closed Principle)描述

符合開放封閉原則的模塊都有兩個主要特性:

1. 它們 “面向擴展開放(Open For Extension)”。

也就是說模塊的行爲是能夠被擴展的。當應用程序的需求變化時,我們可以使模塊表現出全新的或與以往不同的行爲,以滿足新的需求。

2. 它們 “面向修改封閉(Closed For Modification)”。

模塊的源代碼是不能被侵犯的,任何人都不允許修改已有源代碼。

看起來上述兩個特性是互相沖突的,因爲通常擴展模塊行爲的常規方式就是修改該模塊。一個不能被修改的模塊通常被認爲其擁有着固定的行爲。那麼如何使這兩個相反的特性共存呢?

抽象是關鍵。

Abstraction is the Key.

在使用面向對象設計技術時,可以創建固定的抽象和一組無限界的可能行爲來表述。這裏的抽象指的是抽象基類,而無限界的可能行爲則由諸多可能衍生出的子類來表示。爲了一個模塊而篡改一個抽象類是有可能的,而這樣的模塊則可以對修改封閉,因爲它依賴於一個固定的抽象。然後這個模塊的行爲可以通過創建抽象的衍生類來擴展。

示例:Client/Server 引用

圖1 展示了一個簡單的且不符合開放封閉原則的設計。

(圖 1: 封閉的 Client)

Client 和 Server 類都是具體類(Concrete Class),所以無法保證 Server 的成員函數是虛函數。 這裏 Client 類使用了 Server 類。如果我們想讓 Client 對象使用一個不同的 Server 對象,那麼必須修改 Client 類以使用新的 Server 類和對象。

圖 2 中展示了符合開放封閉原則的相應設計。

(圖 2: 開放的 Client)

在這個示例中,AbstractServer 類是一個抽象類,幷包含一個純虛成員函數。Client 類依賴了這個抽象,但 Client 類將使用衍生的 Server 類的對象實例。如果我們需要 Client 對象使用一個不同的 Server 類,則可以從 AbstractServer 類衍生出一個新的子類,而 Client 類則依然保持不變。

示例:Shape 抽象

考慮下面這個例子。我們有一個應用程序需要在標準 GUI 窗口上繪製圓形(Circle)和方形(Square)。圓形和方形必須以特定的順序進行繪製。圓形和方形會被創建在同一個列表中,並保持適當的順序,而程序必須能夠順序遍歷列表並繪製所有的圓形和方形。

在 C 語言中,使用過程化技術是無法滿足開放封閉原則的。我們可能會通過下面代碼顯示的方式來解決該問題。

enum ShapeType {circle, square};

struct Shape 
{
    ShapeType itsType;
};

struct Square 
{
    ShapeType itsType;
    double itsSide; 
    Point itsTopLeft;
};

struct Circle 
{
    ShapeType itsType;
    double itsRadius; 
    Point itsCenter;
};

void DrawSquare(struct Square*);
void DrawCircle(struct Circle*);
typedef struct Shape *ShapePointer;

void DrawAllShapes(ShapePointer list[], int n)
{
    int i;
    for (i=0; i<n; i++)
    {
        struct Shape* s = list[i];
        switch (s->itsType)
        {
            case square:
                DrawSquare((struct Square*)s);
                break;
            case circle:
                DrawCircle((struct Circle*)s);
                break;
        }
    }
}

在這裏我們看到了一組數據結構定義,這些結構中除了第一元素相同外,其他都不同。通過第一個元素的類型碼來識別該結構是在表示一個圓形(Circle)還是一個方形(Square)。函數 DrawAllShapes 遍歷了數組中的結構指針,檢查類型碼然後調用相匹配的函數(DrawCircle 或 DrawSquare)。

這裏函數 DrawAllShapes 不符合開放封閉原則,因爲它無法保證對新的 Shape 種類保持封閉。如果我們想要擴展這個函數,使其能夠支持一個圖形列表並且包含三角形(Triangle)定義,則我們將不得不修改這個函數。事實上,每當我們需要繪製新的圖形種類時,我們都不得不修改這個函數。

當然這個程序僅僅是一個例子。在實踐中 DrawAllShapes 函數中的 switch 語句將不斷地在應用程序內的各種函數間不斷的調用,而每個函數只是少許有些不同。在這樣的應用中增加一個新的 Shape 意味着需要搜尋所有類似的 switch 語句(或者是 if/else 鏈)存在的地方,然後增加新的 Shape 功能。此外,要讓所有的 switch 語句(或者是 if/else 鏈)都有類似 DrawAllShapes 函數這樣較好的結構也是不太可能的。而更有可能的則是 if 語句將和一些邏輯運算符綁定到了一起,或者 switch 語句中的 case 子句的堆疊。因此要在所有的位置找到和理解這些問題,然後添加新的圖形定義可不是件簡單的事情。

下面這段代碼展示了符合開放封閉原則的 Cicle/Square 問題的一個解決方案。

public abstract class Shape
{
    public abstract void Draw();
}

public class Circle : Shape
{
    public override void Draw()
    {
      // draw circle on GUI
    }
}

public class Square : Shape
{
    public override void Draw()
    {
      // draw square on GUI
    }
}

public class Client
{
    public void DrawAllShapes(List<Shape> shapes)
    {
      foreach (var shape in shapes)
      {
        shape.Draw();
      }
    }
}

在這個例子中,我們創建了一個 Shape 抽象類,這個抽象類包含一個純虛函數 Draw。而 Circle 和 Square 都衍生自 Shape 類。

注意在這裏如果我們想擴展 DrawAllShapes 函數的行爲來繪製一個新的圖形種類,我們所需要做的就是增加一個從 Shape 類衍生的子類。而DrawAllShapes 函數則無需進行修改。因此DrawAllShapes 符合了開放封閉原則,它的行爲可以不通過對其修改而擴展。

在比較現實的情況中,Shape 類可能包含很多個方法。但是在應用程序中增加一個新的圖形仍然是非常簡單的,因爲所需要做的僅是創建一個衍生類來實現這些函數。同時,我們也不再需要在應用程序內查找所有需要修改的位置了。

因爲更改符合開放封閉原則的程序是通過增加新的代碼,而不是修改已存在的代碼,之前描述的那種級聯式的更改也就不存在了。

策略性的閉合(Strategic Closure)

要明白程序是不可能 100% 完全封閉的。例如,試想上面的 Shape 示例,如果我們現在決定所有的 Circle 都應該在 Square 之前先進行繪製,則 DrawAllShapes 函數將會發生什麼呢?DrawAllShapes 函數是不可能對這樣的變化保持封閉的。通常來說,無論模塊的設計有多封閉,總是有各種各樣的變化會打破這種封閉。

因此,完全閉合是不現實的,所以必須講究策略。也就是說,程序設計師必須甄別其設計對哪些變化封閉。這需要一些基於經驗的預測。有經驗的設計師會很好的瞭解用戶和所在的行業,以判斷各種變化的可能性。然後可以確定對最有可能的變化保持開放封閉原則。

使用抽象來獲取顯示地閉合

那我們該如何使 DrawAllShapes 函數對繪製邏輯中的排序的變化保持閉合呢?要記住閉合是基於抽象的。因此,爲了使 DrawAllShapes 對排序閉合,則我們需要對排序進行某種程度的抽象。上述例子中關於排序的一個特例就是某種類別的圖形需要在其他類別的圖像之前進行繪製。

一個排序策略就是,給定任意兩個對象,可以發現哪一個應當被先繪製。因此,我們可以在 Shape 中定義一個名爲 Precedes 的方法,它可以接受另一個 Shape 作爲參數並返回一個 bool 類型的結果。如果結果爲 true 則表示接收調用的 Shape 對象應排在被作爲參數的 Shape 對象的前面。

我們可以使用重載操作符技術來實現這樣的比較功能。這樣通過比較我們就可以得到兩個 Shape 對象的相對順序,然後排序後就可以按照順序進行繪製。

下面顯示了簡單實現的代碼。

public abstract class Shape
{
    public abstract void Draw();

    public bool Precedes(Shape another)
    {
      if (another is Circle)
        return true;
      else
        return false;
    }
}

public class Circle : Shape
{
    public override void Draw()
    {
      // draw circle on GUI
    }
}

public class Square : Shape
{
    public override void Draw()
    {
      // draw square on GUI
    }
}

public class ShapeComparer : IComparer<Shape>
{
    public int Compare(Shape x, Shape y)
    {
      return x.Precedes(y) ? 1 : 0;
    }
}

public class Client
{
    public void DrawAllShapes(List<Shape> shapes)
    {
      SortedSet<Shape> orderedList =
        new SortedSet<Shape>(shapes, new ShapeComparer());

      foreach (var shape in orderedList)
      {
        shape.Draw();
      }
    }
}

這達成了排序 Shape 對象的目的,並可按照適當的順序進行排序。但我們仍然還沒有一個合適的排序抽象。以現在這種情況,單獨的 Shape 對象將不得不覆寫 Precedes 方法來指定順序。這將如何工作呢?我們需要在 Precedes 中寫什麼樣的代碼才能確保 Circle 能夠在 Square 之前繪製呢?

public bool Precedes(Shape another)
{
	if (another is Circle)
        return true;
	else
        return false;
}

可以看出,這個函數不符合開放封閉原則。無法使其對新衍生出的 Shape 子類保持封閉。每次當一個新的 Shape 衍生類被創建時,這個方法將總是被修改。

使用 “數據驅動(Data Driven)” 的方法來達成閉合

使用表驅動(Table Driven)方法能夠達成對 Shape 衍生類的閉合,而不會強制修改每個衍生類。

下面展示了一種可能的設計。

private Dictionary<Type, int> _typeOrderTable = new Dictionary<Type, int>();

private void Initialize()
{
	_typeOrderTable.Add(typeof(Circle), 2);
	_typeOrderTable.Add(typeof(Square), 1);
}

public bool Precedes(Shape another)
{
	return _typeOrderTable[this.GetType()] > _typeOrderTable[another.GetType()];
}

通過使用這種方法我們已經成功地使 DrawAllShapes 函數在一般情況下對排序問題保持封閉,並且每個 Shape 的衍生類都對新的 Shape 子類或者排序策略的修改(例如修改排序規則以使先繪製 Square)等保持封閉。

這裏仍然無法對多種 Shape 的順序保持封閉的就是表(Table)本身。但我們可以將這個表定義放置在單獨的模塊中,使表與其他模塊隔離,這樣對錶的更改則不再會對任何其他模塊產生影響。

進一步的擴展閉合

這並不是故事的尾聲。

我們可以掌控 Shape 的層級結構和 DrawAllShapes 函數對依據不同 Shape 類型的排序規則的閉合。儘管如此,Shape 的衍生類對不判斷圖形類型的排序規則是非閉合的。看起來可能我們希望可以根據更高級別的結構來對 Shape 進行排序。對這個問題的一個完整的研究已經超出了這篇文章的範圍,但是感興趣的讀者可以考慮如何實現。例如讓一個 OrderedShape 類來持有一個抽象的 OrderedObject 類,而其自身同時繼承自 Shape 和 OrderedObject 類的實現。

總結

關於開放封閉原則(Open Closed Principle)還有很多可以講的。在很多方面這個原則都是面向對象設計的核心。始終遵循該原則才能從面向對象技術中持續地獲得最大的益處,例如:可重用性和可維護性。同時,對該原則的遵循也不是通過使用一種面向對象的編程語言就能夠達成的。更確切的說,它需要程序設計師更專注於將抽象技術應用到程序中那些趨於變化的部分上。

參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章