面向對象設計核心原則
由于對象設計的核心是類,是以下面的原則也都基本都是讨論類的設計問題,其它類型的元素都比較簡單,基本上也符合大多數這裡列出的原則。
前面我們分析完了對象設計的基本原則,這裡我将重新溫習一下對象設計的核心原則 - SOLID原則。幾乎所有的設計模式都可以看到這些原則的影子。
單一職責原則(SRP):做一個專一的人
單一職責原則的全稱是Single Responsibility Principle,簡稱是SRP。SRP原則的定義很簡單:
即不能存在多于一個導緻類變更的原因。簡單的說就是一個類隻負責一項職責。
讓一個類僅負責一項職責,如果一個類有多于一項的職責,這是比較脆弱的設計。因為一旦某一項職責發生了改變,需要去更改代碼,那麼有可能會引起其他職責改變。所謂牽一發而動全身,這顯然是我們所不願意看到的,是以我們會把這個類分拆開來,由兩個類來分别維護這兩個職責,這樣當一個職責發生改變,需要修改時,不會影響到另一個職責。
做且隻做好一件事,這一條原則其實不僅僅适用于對象,也同樣适用于函數,變量等一切程式設計元素。當然在商業模式中,将一件事做到極緻就是成功,我個人覺得這一條也還是成立的。
随便舉個例子,如果大家有深入研究過疊代器的思想的話,那其實就是把存儲資料和周遊資料的職責分開了,集合隻負責實作存儲資料的功能,而疊代器完成周遊資料的功能。
再說我看到過的一個例子:說有一個輔助類CommonUtil,在這裡面提供了所有不能歸入其他子產品的輔助方法,它的結構如下:
public class CommonUtil
{
#region Canvas Helpers
public void M1() { }
//...
#endregion
#region Screen Helpers
public void M2() { }
//...
#endregion
#region Size Helpers
public void M3() { }
//...
#endregion
#region Data Helpers
public void M4() { }
//...
#endregion
}
各位,你覺得這個類寫的怎麼樣?
這裡面放進了各種不同類型的輔助方法,每個子產品有輔助方法需要找地方放的時候,人們都是自覺的找到了這個類,于是這個類在每個Release中都不斷有新成員加入,于是最終變成了一個龐然大物,使用的時候,光看函數清單都夠大家喝一壺了。
我的想法是,為什麼不拆分成4個小類,每個類專門負責某一類型輔助功能呢?
開放封閉原則(OCP):改造世界大部分不是破壞原來的秩序
開放封閉原則全稱是Open Closed Principle, 簡稱OCP, 該原則的定義是:
軟體實體應該是可擴充,而不可修改的。也就是說,對擴充是開放的,而對修改是封閉的。
這條原則是所有面向對象原則的核心。
軟體設計所追求的第一個目标就是封裝變化、降低耦合,而開放封閉原則正是對這一目标的最直接展現。其它的原則或多或少都是為了這個目标而努力的,例如以Liskov替換原則實作最佳的、正确的繼承層次,就能保證不會違反開放封閉原則。
軟體設計所最求的第二個目标就是重用。這個是繼承機制的核心動力。由于通常來說抽象的東西最穩定,最不容易變化,是以抽象與繼承是實作開閉原則強大的工具,但不是唯一工具,後面我們會說到實作開閉原則的另一個更加強大,更加靈活的工具:組合。
一言以蔽之,繼承與組合是封裝變化,降低耦合的不二法門。能否合理的使用繼承群組合是展現一名碼農水準高低的又一标準。
在實際的代碼中,添加新的功能一般意味着新的對象,一個好的設計也意味着這個新的修改不要大幅度波及現有的對象。這一條了解起來最簡單,實施起來卻是最困難。無數的模式和解耦方法都是為了達到這個目的而誕生的。
看一個經典的例子:
public class Component
{
public enum Status
{
None,
Installed,
Uninstalled
}
Status m_status = Status.None;
void Do()
{
switch (m_status)
{
case Status.None:
Console.WriteLine("Error...");
break;
case Status.Installed:
Console.WriteLine("Hello!");
break;
case Status.Uninstalled:
Console.WriteLine("Error...");
break;
default:
break;
}
}
}
我們這裡定義了一個元件,使用者動态加載,加載完了以後程式就可以用了,為了處理友善,我們給元件定義了一些狀态,在不同的狀态下,這個元件有不同的行為,于是就有了上面的代碼:enum定義狀态,函數中使用switch實作路由。
使用switch分支是一種經典的做法,當元件的狀态類型不存在變化的可能時,該段代碼無可挑剔,堪稱完美。
可是在實際項目中,過了一階段,我們發現元件的狀态不夠,比如說我們需要處理元件還未配置時的行為,于是我們在枚舉中加了一個狀态:Configured,然後在switch中加了一個分支。
又過了一階段,我們又發現還需要處理元件還未初始化時的行為,于是我們在枚舉中又加了一個狀态:Initialized,然後在switch中加了一個分支。
至于以後是否還需要别的狀态,我們目前不得而知,應該說還是有可能的。
上面這個行為是嚴重違反開閉原則的,這個不用多講了吧。那麼如何改進呢?使用我們最強大的工具吧:使用繼承或/群組合封裝變化點。
這裡我們分析一下,該元件存在變化的地方就是元件的狀态,這是一個變化點,對于變化點,對于變化點不要手軟,封印它。
public class ComponentStaus
{
public virtual void Do() { }
}
public class ComponentNone : ComponentStaus
{
public override void Do() { Console.WriteLine("Error..."); }
}
public class ComponentInitialized : ComponentStaus
{
public virtual void Do() { Console.WriteLine("Hello!"); }
}
public class Component
{
ComponentStaus m_status = new ComponentNone();
public void ChangeStatus(ComponentStaus newStatus)
{
m_status = newStatus;
}
public void Do()
{
m_status.Do();
}
}
在上面的例子中,我們發現了變化點,然後抽象出一個基類放在那,然後使用繼承機制,讓子類去演繹變化。當我們需要添加新的狀态Configured的時候,我們隻要添加一個新的子類ComponentConfigured,讓它從ComponentStaus繼承,并重寫Do方法即可。使用的時候,在合适的時機(如事件進行中)把該子類的執行個體傳給Component就可以了,當然也有可能是Component自己處理事件或方法時自己修改該狀态執行個體。
能看到開閉原則的影子嗎?(當然,不要妄想對修改完全封閉,這個是不可能的,就像元件之間零依賴是不可能的一樣)
裡氏替換原則(LSP):長大後,我就成了你
裡氏替換原則全稱Liskov Substitution Principle,簡稱 LSP,它的定義是:
任何基類可以出現的地方,子類一定可以出現。
LSP原則是繼承複用的基石,隻有當派生類可以替換掉基類,軟體的功能不受到影響時,基類才能真正被複用,而派生類也能夠在基類的基礎上增加新的行為。
LSP原則保證了繼承的正确實作。它希望子類不要破壞父類的接口成員。一旦破壞了,就如果人與人之間破壞合同一樣,有時候會很糟糕。
這個原則看起來也很容易,但是卻也很容易和現實中的概念混淆,看個經典的小例子:長方形與正方形問題。
在我們國小學數學的時候,就知道正方形是特殊的長方形,于是寫代碼的時候,自然的正方形類就繼承自長方形了,代碼如下:
public class Program
{
static void Main(string[] args)
{
Rectangle rect = new Rectangle();
rect.setWidth(100);
rect.setHeight(20);
Console.WriteLine(rect.Area == 100 * 20);
Rectangle squ = new Square();
rect.setWidth(100);
rect.setHeight(20);
Console.WriteLine(squ.Area == 100 * 20);
}
}
class Rectangle
{
public double m_width;
public double m_height;
public virtual void setWidth(double width) { m_width = width; }
public virtual void setHeight(double height) { m_height = height; }
public double Area
{
get { return m_width * m_height; }
}
}
class Square : Rectangle
{
public override void setWidth(double width)
{
m_width = width;
m_height = width;
}
public override void setHeight(double height)
{
m_width = height;
m_height = height;
}
}
很顯然輸入的不是兩個True,根本原因就在于正方形隻有長的概念,而沒有長方形所期望的寬的概念,是以長方形中定義了正方形根本沒有的東西,也就是說長方形不應該是正方形的基類。
當一個基類出現了其子類不想要的接口成員時,繼承關系必然是欠缺考慮的繼承,也必然是違反LSP原則的。這個時候要麼把想辦法把基類的那個成員抽象出去,要麼子類再選擇從合适的基類繼承。記住這個思路,在下一個原則我們還會再相見。
此外,當我在小孩玩橡皮鴨子的時候,常常在想:橡皮鴨子能從鴨子繼承嗎?你覺得呢?
接口分離原則(ISP):不要一口吃成胖子
接口分離原則全稱interface segregation principle,簡稱ISP,它的定義是:
不能強迫使用者去依賴那些他們不使用的接口。換句話說,使用多個專門的接口比使用單一的總接口總要好。
這一原則與單一職責原則息息相關,它們對于高内聚的追求是一緻的,但是它更加強調了接口的高内聚性。
看個例子,我們有一個服務接口,是這麼定義的:
interface IService
{
void GetUser();
void RegisterUser();
void LoadProducts();
void AddProduct();
void AcceptRequest();
void SendResponse();
}
因為是面向所有Client的,是以這個接口提供了所有Client需要的方法,比如使用者的操縱,産品的操作,資料傳輸的一些操作,每個Client都可能用到其中的一部分服務。
這個設計運作很好,服務端提供一個類Service實作這個接口,而Client,它通過某些網絡服務方式擷取到這個接口IService就可以了,然後直接調用相關方法就可以了。
先說第一點,這個接口違反了單一職責原則,一個字,"拆"。
再說第二點,每個類型的Client隻處理一種對象,比如有的Client,如工資系統隻處理User,而倉庫系統隻處理Product,接口的其它方法對它們沒用,還是一個字,"拆"。
于是得到下列接口:
interface IUser
{
void GetUser();
void RegisterUser();
}
interface IProduct
{
void LoadProducts();
void AddProduct();
}
interface IPeer
{
void AcceptRequest();
void SendResponse();
}
class Service : IUser, IProduct, IPeer {}
這樣拿到代理對象後,想處理使用者的Client,将該對象轉換成IUser即可,想處理産品的轉換成IProduct即可。
同樣的,試想一下,如果某一天某Service隻提供有關使用者的服務,在原先的設計中會怎麼樣?
依賴倒置原則(DIP):抽象的藝術才有生命力
依賴倒置原則全稱Dependence Inversion Principle,簡稱DIP,它的定義有3點含義:
1、高層子產品不應該依賴低層子產品,兩者都應該依賴于抽象(抽象類或接口)
2、抽象(抽象類或接口)不應該依賴于細節(具體實作類)
3、細節(具體實作類)應該依賴抽象
總結起來,這個原則說的就是每個類與别的類互動時,盡量隻使用滿足接口規範的抽象類。為啥?因為抽象類實作細節幾乎沒有,沒什麼需要變化的。這一條深刻揭示了抽象的生命力,抽象的對象才是最有表達能力的對象,因為它通常是“無形”的,可以随時填充相關的細節。
直接看一個例子:
public class Program
{
static void Main(string[] args)
{
UI layer = new UI();
layer.SetDataAccessor(new XmlDataAccessor());
layer.Do();
}
}
class UI
{
DataAccessor m_accessor;
public void SetDataAccessor(DataAccessor accessor) { m_accessor = accessor; }
public void Do()
{
m_accessor.GetUser();
}
}
interface DataAccessor
{
void GetUser();
void RegisterUser();
}
class XmlDataAccessor : DataAccessor
{
public void GetUser() { }
public void RegisterUser() { }
}
這裡上遊的元件UI依賴的是DataAccessor這樣的接口,而不是依賴各種具體的子類,如XmlDataAccessor,這樣當想使用其他的資料庫存儲資料的時候,隻要增加新的DatabaseDataAccessor之類的新類,然後在設定的時候設定一下就可以了。這種手段,很多人也稱為"依賴注入"。
好了,核心原則說完了,總結一下,似乎就是一句話:"類要單純,繼承要謹慎,變化要封裝,抽象類型要多用"。