《Agile Principles, Patterns, and Practices in C#》
by Micah Martin; Robert C. Martin
前段時間聽同事說,傳言面試者如果知道SOLID五大原則,工資可以翻一倍。我趕緊跑去查了查這五大原則,決定背下來!哈哈,開個玩笑,其實很多面試官可能自己都不知道這五大原則,而且即使知道這些原則,在開發過程中嚴格按照這些原則寫的人就更少了。我只覺得這個傳言說明這五大原則很重要,它們是我們能夠寫出更clean更易擴展和更易維護的代碼的基礎。
廢話少說,先列出這五大原則:
S:The Single-Responsibility Principle (SRP)
O:The Open/Closed Principle (OCP)
L:The Liskov Substitution Principle (LSP)
I:The Interface Segregation Principle (ISP)
D:The Dependency-Inversion Principle (DIP)
單一職責原則 The Single-Responsibility Principle (SRP)
The Single-Responsibility Principle: A class should have only one reason to change.
單一職責原則,就是說一個類僅有一個引起它變化的原因。雖然這一原則明確是在說類的設計,但是實際中在一個模塊或者一個方法上同樣適用。
例如,我們有一個Rectangle
類,它有兩個方法,其中一個是把矩形畫在屏幕上,另一個則是計算它的面積。有兩個不同的應用會用到Rectangle
類,其中一個是用來做幾何計算的,它需要知道矩形的面積,但是不會需要畫出它。另一個應用則是繪圖,它需要把矩形畫出來。那么上面設計的Rectangle
類就違反了單一職責的原則(Violates SRP)。
違反這一原則會有什么問題呢?第一,單純做計算的應用程序并沒有用到任何GUI
的東西,但是由于它用到了Rectangle
,所以需要引用GUI
。既然被引用了,那么GUI
就需要跟隨計算應用程序被編譯和部署。第二,如果繪圖應用程序的變化需要引起Rectangle
的改變,那么計算應用程序也需要重新編譯、測試和部署,否則可能引起不可預測的問題。
也有人把它解釋為只做一件事情,雖然也說得通,但是這并不是作者的本意。這里的職責并不是負責的事情,而是‘A reason for change’。
An axis of change is an axis of change only if the changes occur. It is not wise to apply SRP—or any other principle, for that matter—if there is no symptom。也就是說不要過度解讀這個原則,一個類也可以做不止一件事情,只要讓它改變的原因只有一個就好。
例如,下面是Modem
接口,它是否違反SRP就看你怎么用它了。
public interface Modem
{
public void Dial(string pno);
public void Hangup();
public void Send(char c);
public char Recv();
}
如果從所做的事情來看的話,這里Modem
做了兩件事:1)管理連接,Dial
和Hangup
方法;2)數據通訊,Send
和Recv
方法。
但是這兩個職責應該被分到兩個類中嗎?那就取決于應用系統需要如何改變了。如果應用系統不會對這兩個職責做不同的改變,那也就不需要對它們進行拆分了。
開放閉合原則 The Open/Closed Principle (OCP)
The Open/Closed Principle (OCP): Software entities (classes, modules, functions, etc.) should be open for extension but closed for modification.
所有的系統在其生命周期里都會改變。如果我們期望自己的系統能夠活過第一個版本,那么開發的時候一定要謹記這句話。需求會變是正常的,好的系統不會拒絕變化,只會需要添加code或者修改很少的code就能支持這些變化。
開放閉合原則同樣適用于類、模塊和方法等,它強調對擴展開放,對修改閉合。看起來說了兩點實際上就是一點:為了適應新的需求,盡量不要修改原始代碼,而是擴展原有的代碼。
要遵循這一原則,最好的辦法就是抽象(abstract),最常用的設計模式方法有策略模式(Strategy)和模板方法(Template Method)。例如,Client
類依賴于Server
類,但是一旦將來換一個Server
,則Client
里面涉及到Server
的地方全部要改變。這個時候就可以用到Strategy
。抽象一個ClientInterface
,Client
依賴于這個抽象,對于這個抽象的實現則可以是不同的Server
對應不同的策略。與Strategy
類似,Template Method
也是需要一個抽象,然后在這個抽象的基礎上派生出一些類來做具體的實現。不同的是模板方法在這個抽象中定義了一個抽象方法和一個模板業務方法,模板業務方法會調用抽象方法。抽象方法的具體實現在繼承這個抽象類的具體子類中。
下面用一個常用于說明多態的Shape
來舉例說明OCP。
我們目前有兩種類型的Shape
,分別是Circle
和Square
,現在需要一個方法來畫出所有的圖形。下面是一個違反OCP的實現。
//shape.h
enum ShapeType {circle, square};
struct Shape
{
ShapeType itsType;
};
//circle.h
struct Circle
{
ShapeType itsType;
double itsRadius;
Point itsCenter;
};
void DrawCircle(struct Circle*);
//square.h
struct Square
{
ShapeType itsType;
double itsSide;
Point itsTopLeft;
};
void DrawSquare(struct Square*);
//drawAllShapes.cc
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;
}
}
}
為什么說它違反了OCP呢?因為如果我們要再添加一個新的Shape
,則需要修改DrawAllShapes
和ShapeType
,以及重新編譯所有引用ShapeType
的地方。如果我們按照上述的抽象思想去寫Shape
,那么可以做到新增一種Shape
時只需要新加一個Shape
的派生類即可。
public interface Shape
{
void Draw();
}
public class Square : Shape
{
public void Draw()
{
//draw a square
}
}
public class Circle : Shape
{
public void Draw()
{
//draw a circle
}
}
public void DrawAllShapes(IList shapes)
{
foreach(Shape shape in shapes)
shape.Draw();
}
There is no model that is natural to all contexts!當然,也會有一些新的需求對上述系統依舊需要做一些修改,比如說,我們現在要求在輸出所有的Shape
時按照先Square
再Circle
的順序畫出來。Robert C. Martin很喜歡一句話,“Fool me once, shame on you. Fool me twice, shame on me.”。面對一些我們不得不對老的code做出一些修改的時候,我們除了痛罵怎么會有這么變態的需求的同時,可以多想想,如果再來一些這么變態的需求,我現在的這種修改方式可以支持嗎?
比如說,對于上面的例子,我們現在要求先畫Square
再畫Circle
。你可能覺得這個修改很簡單啊,我在DrawAllShapes
里面先拿出Square
來繪制,再來畫Circle
就好了。這就大錯特錯了,又違反了OCP。這樣如果再加入其它的Shape
的話,你還是得修改DrawAllShapes
。要解決這個問題,還是得抽象。先畫A再畫B,不就是順序的問題嗎?那我們可以讓Shape
實現IComparable
,這樣畫圖的時候先對這些圖形進行排序不就可以了嗎?
public interface Shape : IComparable
{
void Draw();
}
public void DrawAllShapes(ArrayList shapes)
{
shapes.Sort();
foreach(Shape shape in shapes)
shape.Draw();
}
不幸的是,這種方法依然違反了OCP,因為在每個Shape
的派生類中都要實現CompareTo
,而CompareTo
肯定需要知道所有的其他子類。
public class Circle : Shape
{
public int CompareTo(object o)
{
if(o is Square)
return -1;
else
return 0;
}
}
那么,我們就要從Data-Driven Approach的角度來考慮這個問題了,對于這個例子,我們可以用一個table-driven的方法來處理。我們用一個額外的類來實現Shape
之間的比較,而只有這里會需要一個Shape
所有派生類的列表。這樣新增一個派生類就只需要修改這個列表。另外,為了讓增加Shape
的派生類不需要重新編譯舊的代碼,我們可以把ShapeComparer
類與Shape
相關的模塊分離。
public class ShapeComparer : IComparer
{
private static Hashtable priorities = new Hashtable();
static ShapeComparer()
{
priorities.Add(typeof(Circle), 1);
priorities.Add(typeof(Square), 2);
}
private int PriorityFor(Type type)
{
if(priorities.Contains(type))
return (int)priorities[type];
else
return 0;
}
public int Compare(object o1, object o2)
{
int priority1 = PriorityFor(o1.GetType());
int priority2 = PriorityFor(o2.GetType());
return priority1.CompareTo(priority2);
}
}
里氏替換原則 The Liskov Substitution Principle (LSP)
The Liskov Substitution Principle: Subtypes must be substitutable for their base types.
里氏替換原則的內容是,子類型必須能夠替換它的基類型。OCP的實現機制是抽象和多態,而它們的關鍵是繼承。LSP所強調的就是繼承的實現規則。
首先,看一下違反LSP會怎樣?如果違反LSP,類繼承就會混亂,如果子類作為一個參數傳遞給參數為基類的方法,將會出現未知行為;如果違反LSP,適用于基類的單元測試將不能成功用于測試子類。
假如說我們不能保證LSP,即子類不一定能夠替換它的基類,那么我們來看看上一節中關于Shape
的例子。如果不保證LSP,那么DrawAllShapes
就得按照下面的方式寫,這樣就違反了OCP。所以說,A violation of LSP is a latent violation of OCP。
public static void DrawAllShapes(Shape s)
{
if(s.type == ShapeType.square)
(s as Square).Draw();
else if(s.type == ShapeType.circle)
(s as Circle).Draw();
}
然而,現實中很多違反LSP的情況并不像上例這么明顯。比如說,我們有一個基類是Rectangle
,我們在它的基礎上派生出Square
。一般說派生類滿足IS-A(Square is a rectangle)就可以,理論上看也確實滿足。但是Rectangle
類中有height
和width
,Square
類中要求它們必須相等,那么我們可以在Square
中對它們的賦值操作進行重寫,即任何對height
和width
的set
操作都會設置它們倆為同一個值。但是如果用戶有一個下面這樣的方法來使用Rectangle
,則這里的設計就違反了LSP。
void g(Rectangle r)
{
r.Width = 5;
r.Height = 4;
if(r.Area() != 20)
throw new Exception("Bad area!");
}
接口分離原則 The Interface Segregation Principle (ISP)
The Interface Segregation Principle: Clients should not be forced to depend on methods they do not use.
接口分離原則指出,客戶端不應該被迫依賴于它不會用到的方法。
我曾經遇到過這樣一個應用場景,在類P1
,P2
和P3
中都需要一些config,這些config需要一些其他的操作來獲取,而原本這些config的獲取散落在P1,P2和P3處理邏輯中間。這導致對獲取config相關的代碼改動時會影響到毫不相關的處理邏輯。所以,我們希望把這些config decouple出來,讓P1
,P2
和P3
依賴于一個抽象的config,而具體獲取config的方法則實現在這個抽象的派生類中。這是下面我們會介紹的另一個原則DIP,但是我要說的是,我當時就試圖寫一個IConfigure
把P1
,P2
和P3
中用到的接口全部定義了,然后再對應的去寫幾個Configure
類來實現IConfigure
的一部分。當時只覺得這樣寫就可以只寫一個接口了,可是它卻需要變得非常“胖”,很顯然違背了ISP。
下面來看一個如何實現接口分離的例子。
假如我們原本有一個Door
接口,其中提供了Lock()
、Unlock()
和IsOpen()
方法。現在我們要新寫一個TimedDoor
類,當門打開的時間超過一定的限制就要報警。那么我們可以寫一個TimeClient
接口來定義TimeOut
的機制,然后為了讓TimedDoor
繼承TimeClient
,我們讓它的基類Door
直接引用TimeClient
,如下圖1所示。它顯然違反了ISP,因為并不是所有的Door
都會需要TimeClient
。
一種解決方案是,在TimedDoor
和TimeClient
之間提供一個Adapter,這樣TimedDoor
用委托的方式去調用TimeClient
,而不需要在它的基類Door
上去顯示引用TimeClient
。(如圖2所示)
另一種解決方案,則是直接讓TimedDoor
實現多個接口。(如下圖3所示)
依賴倒置原則 The Dependency-Inversion Principle (DIP)
The Dependency-Inversion Principle: A). High-level modules should not depend on low-level modules. Both should depend on abstractions. B). Abstractions should not depend upon details. Details should depend upon abstractions.
依賴倒置原則說的是, 高層模塊不應該依賴低層模塊,兩者都應該依賴其抽象;抽象不應該依賴細節,細節應該依賴于抽象。
為什么要考慮依賴倒置呢?如果我們違反了這一原則,修改低層模塊會影響高層模塊,甚至迫使高層模塊(policy decisions and business models)做出相應的修改。另外,我們在低層模塊上的復用已經做得很好了,但是若高層模塊依賴于低層模塊,那么高層模塊是很難復用的。
我們來看看下面這個例子。如圖4所示,high-level的Policy
層依賴于low-level的Mechanism
層,而low-level的Mechanism
層又依賴于detailed-level的Utility
層。這樣由于依賴的傳遞性,就導致了Policy
層依賴于Utility
層。
為了去除這種依賴,我們可以在兩層之間引入一個抽象接口,如下圖5所示。這樣Policy
層不再依賴于Mechanism
層,而是依賴于PolicyServiceInterface
。Mechanism
層會實現PolicyServiceInterface
,那這會不會導致Mechanism
層需要依賴Policy
層呢?其實不然,PolicyServiceInterface
雖然在圖中放在了Policy
層,但是它是一個獨立的接口,不涉及任何細節,我們甚至可以把它單獨放進一個package里面。
下面再來看一個DIP的例子。
我們有Button
類和Lamp
類,其中Button
感知外部環境,收到Pull
消息后決定用戶是否按了這個按鈕;而Lamp
則用于影響外部環境,它會接收Turn On/Off
消息,根據消息來決定是開燈還是關燈。
下面是一個違反DIP的寫法,這里直接讓Button
依賴于Lamp
。這樣做的壞處就是Lamp
如果有改變,這個Button
類也得跟著改變,而且這個Button
根本沒法重用,它只能用來控制Lamp
的開關。
public class Button
{
private Lamp lamp;
public void Poll()
{
if (/*some condition*/)
lamp.TurnOn();
else lamp.TurnOff();
}
}
對于這個例子,我們首先需要找到其中的抽象。這里的抽象就是根據用戶的動作來決定狀態on/off。它可以既與Button
無關,又與Lamp
無關。所以我們可以定義一個ButtonServer
(或者考慮到要與Button無關,叫做SwitchableDevice
)的接口,這個接口可以讓Button
控制來開關某個東西。然后讓Lamp
實現這個接口,Button
就可以用來控制這個燈了,如果再換成一個東西實現ButtonServer
,Button
依然適用。
好啦!五大原則我就講完了,工資能不能翻倍就看你們自己了!值得一提的是,光背住上面的條款可沒有用哦,只有真正理解了這樣設計帶來的好處,或者說只有在實踐中違反了上述原則并付出了慘痛的代價,才會對上述原則有深刻的體會。Anyway,首先記住這幾大原則,寫代碼和review別人的代碼時多想想我們有沒有違反這些原則,你一定會有收獲的!