怎樣的升級才能面對需求的改變卻可以保持相對穩定,從而使得系統可以在第一個版本以后不斷推出新的版本呢?
開放-封閉原則(The Open-Closed Principle, OCP)為我們提供了指引。
軟件實體(類、模塊、函數等)應該是可以擴展的,但是不可修改的。
如果程序中一處改動就會產生連鎖反應,導致一系列相關模塊的改動,那么設計就具有僵化性的臭味。
OCP建議我們應該對系統進行重構,這樣以后對系統再就行那樣的改動時,就不會導致更多的改動。
如果正確地應用OCP,那么以后再進行同樣的改動時,就只需要添加新的代碼,而不必改動已經正常運行的代碼。
1. 描述
遵循開發-封閉原則設計出的模塊具有兩個主要的特征:
(1)對於擴展是開放的Open for extension
這意味着模塊的行為是可以擴展的。當應用的需求改變時,可以對模塊進行擴展,使其具有滿足那些改變的新行為。換句話說,可以改變模塊的功能。
(2)對於更改是封閉的Closed for modification
對模塊行為進行擴展時,不必改動模塊的源代碼或二進制代碼。模塊的二進制可執行版本,無論是可鏈接的庫、DLL或Java的jar文件,都無需改動。
怎樣可能在不改動模塊源代碼的情況下去更改它的行為呢?
怎樣才能在無需對模塊進行改動的情況下就改變它的功能呢?
2. 關鍵是抽象
在C++、Java或其他任何的OOPL中,可以創建出固定卻能夠描述一組任意個可能行為的抽象體。這個抽象體就是抽象基類。而這一組任意個可能的行為則表現為可能的派生類。
模塊可以操作一個抽象體,由於模塊依賴於一個固定的抽象體,所以它對於更改可以是關閉的。同時,通過從這個抽象體派生,也可以擴展此模塊的行為。
不遵循OCP的設計。Client類和Server類都是具體類。Client類使用Server類。如果希望Client對象使用另外一個不同的服務器對象,那么就必須要把Client類中使用Server類的地方更改為新的服務器類。
上圖展示了一個針對上述問題的遵循OCP的設計。在這個設計中,ClientInterface類是一個擁有抽象成員函數的抽象類。Client類使用這個抽象類;
然而Client類的對象卻使用Server類的派生類的對象。如果希望Client對象使用一個不同的服務器類,只需要從ClientInterface類派生一個新的類,無需對Client類做任何修改。
Client需要實現一些功能,它可以使用ClientInterface抽象接口去描繪那些功能。ClientInterface的子類型可以以任何它們所選擇的方式去實現這個接口。這樣,就可以通過創建ClientInterface的新的子類型的方式去擴展、更改Client中指定的行為。
為何把抽象接口命名為ClientInterface。為何不把它命名為AbstractServer?
因為抽象類和它們的客戶的關系要比和實現它們的類的關系更密切一些。
Policy類具有一組是實現了某種策略的公有函數。與Client類的函數類似,這些策略函數使用一些抽象接口描繪了一些要完成的功能。
不同的是,在這個結構中,這些抽象接口是Policy類本身的一部分。這些函數在Policy的子類型中實現。這樣,可以通過從Policy類派生出新類的方式,對Policy中指定的行為進行擴展或更改。
Template Method模式:既開放由封閉的典型。
3. Shape應用程序
在一個標准的GUI上繪制圓和正方形的應用程序。圓和正方形要按照特定的順序繪制。
創建一個列表,列表由按照適當的順序排列的圓和正方形組成,程序遍歷該列表,依次繪制出每個圓和正方形。
class Point { double x; double y; } class Shape { ShapeType itsType; } class Circle extends Shape { double itsRadius; Point itsCenter; } class Square extends Shape { double itsSide; Point itsTopLeft; } enum ShapeType { circle, square } public class Ocp { void drawAllShapes(Shape[] list, int n) { for (int i = 0; i < n; i++) { Shape sh = list[i]; switch (sh.itsType) { case square: drawSquare((Square)sh); break; case circle: drawCircle((Circle)sh); break; default: break; } } } void drawSquare(Square square) { System.out.println(square); } void drawCircle(Circle circle) { System.out.println(circle); } }
drawAllShapes函數不符合OCP,因為它對於新的形狀類型的添加不是封閉的。如果希望這個函數能夠繪制包含有三角形的列表,就必須得修改這個函數。事實上,沒增加一種新的形狀類型,都必須要更改這個函數。
同時,在進行上述改動時,必須要在ShapeType里面增加一個新的成員,由於所有不同種類的形狀都依賴於這個enum的聲明,所以我們必須要重新編譯所有的形狀模塊。並且也必須要重新編譯所有依賴於Shape類的模塊。
下面代碼展示了符合OCP的解決方案。
class Point { double x; double y; } abstract class Shape { ShapeType itsType; public abstract void draw(); } class Circle extends Shape { double itsRadius; Point itsCenter; @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square } public class Ocp { void drawAllShapes(Shape[] list, int n) { for (int i = 0; i < n; i++) { list[i].draw(); } } }
可以看出,如果想要擴展程序中drawAllShapes函數的行為(對擴展開放),使之能夠繪制一種新的形狀,只需要增加一個新的Shape的派生類。drawAllShapes函數並不需要改變(對修改封閉)。
這樣drawAllShapes就符合OCP,無需改動自身代碼,就可以擴展它的行為。
假如增加Triangle類對於這里展示的任何模塊完全沒有影響。為了能夠處理Triangle類,需要要改動系統中的某些部分,但是這里展示的所有代碼都無需改動。
上面的例子其實並非是100%封閉的,如果要求所有的圓必須在正方形之前繪制,那么程序中的drawAllShapes函數會怎樣?
drawAllShapes函數無法對這種變化做到封閉。
3.1 預測變化和“貼切的”結構
如果預測到了這種變化,那么就可以設計一個抽象來隔離它。
這就導致了一個麻煩的結果,一般而言,無論模塊是多么的“封閉”,都會存在一些無法對之封閉的變化,沒有對於所有的情況都貼切的模型。
既然不可能完全封閉,那么就必須由策略地對待這個問題。也就是說,設計人員必須對於他設計的模型應該對哪種變化封閉做出選擇。
他必須先猜測出最可能發生的變化種類,然后構造抽象來隔離這些變化。
同時,遵循OCP的代價也是昂貴的。創建正確的抽象是要花費開發時間和精力的。同時,那些抽象也增加了軟件設計的復雜性。開發人員有能力處理的抽象的數量也是有限的。
顯然,希望把OCP的應用限定在可能會發生的變化上。
3.2 使用抽象獲得顯式封閉
用戶要求在繪制正方形之前先繪制所有的圓,我們希望可以隔離以后所有的同類變化。
怎樣才能使得drawAllShapes函數對於繪制順序的變化時封閉的呢?請記住封閉是建立在抽象的基礎之上的。因此,為了讓drawAllShapes對於繪制順序的變化四封閉的,需要一種“順序抽象體”。
這個抽象體定義了一個抽象接口,通過這個抽象接口可以表示任何可能的排序策略。
class Point { double x; double y; } abstract class Shape implements Comparable<Shape>{ ShapeType itsType; public abstract void draw(); public int precedes(Shape sh) { if (sh.itsType == ShapeType.square) { return 1; } else { return -1; } } @Override public int compareTo(Shape sh) { return precedes(sh); } } class Circle extends Shape { double itsRadius; Point itsCenter; public Circle(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; public Square(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square } public class Ocp { static void drawAllShapes(Shape[] list, int n) { Arrays.sort(list); for (int i = 0; i < n; i++) { list[i].draw(); } } public static void main(String[] args) { Shape[] list = new Shape[5]; list[0] = new Circle(ShapeType.circle); list[1] = new Square(ShapeType.square); list[2] = new Circle(ShapeType.circle); list[3] = new Square(ShapeType.square); list[4] = new Circle(ShapeType.circle); drawAllShapes(list, 5); } }
顯然precedes函數以及所有Shape類的派生類中的precedes函數都不符合OCP。沒有辦法使得這些函數對於Shape類的新派生類做到封閉。每次創建一個新的Shape類的派生類時,所有的precedes函數都需要改動。
class Point { double x; double y; } abstract class Shape implements Comparable<Shape> { ShapeType itsType; public abstract void draw(); public int precedes(Shape sh) { int thisIdx = -1; int argIdx = -1; for (int i = 0; i < ShapeType.SORT_SHAPE_TYPE.length; i++) { ShapeType shapeType = ShapeType.SORT_SHAPE_TYPE[i]; if (shapeType == this.itsType) { thisIdx = i; } if (shapeType == sh.itsType) { argIdx = i; } } return thisIdx - argIdx; } @Override public int compareTo(Shape sh) { return precedes(sh); } } class Circle extends Shape { double itsRadius; Point itsCenter; public Circle(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } class Square extends Shape { double itsSide; Point itsTopLeft; public Square(ShapeType shapeType) { itsType = shapeType; } @Override public void draw() { System.out.println(this); } } enum ShapeType { circle, square; public static final ShapeType[] SORT_SHAPE_TYPE = {square, circle}; } public class Ocp { static void drawAllShapes(Shape[] list, int n) { Arrays.sort(list); for (int i = 0; i < n; i++) { list[i].draw(); } } public static void main(String[] args) { Shape[] list = new Shape[5]; list[0] = new Circle(ShapeType.circle); list[1] = new Square(ShapeType.square); list[2] = new Circle(ShapeType.circle); list[3] = new Square(ShapeType.square); list[4] = new Circle(ShapeType.circle); drawAllShapes(list, 5); } }
通過這種方法,成功做到了一般情況下drawAllShapes函數對於順序問題的封閉,也使得每個Shape派生類對於新的Shape派生類的創建或基於類型的Shape對象排序規則的改變是封閉的。
對於不同的Shape的繪制順序的變化不封閉的唯一部分就是ShapeType對象。
4. 結論
OCP都是面向對象設計的核心所在。遵循這個原則可以帶來面向對象技術所聲稱的巨大好處(靈活性、可用性以及可維護性)。
然而,並不是說只要使用一種面向對象語言就是遵循了這個原則。正確的做法是,開發人員應該僅僅對程序中呈現出頻繁變化的那些部分作出抽象。