天天看點

寫給自己看的小設計3 - 對象設計通用原則之核心原則

面向對象設計核心原則

  由于對象設計的核心是類,是以下面的原則也都基本都是讨論類的設計問題,其它類型的元素都比較簡單,基本上也符合大多數這裡列出的原則。

  前面我們分析完了對象設計的基本原則,這裡我将重新溫習一下對象設計的核心原則 - 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之類的新類,然後在設定的時候設定一下就可以了。這種手段,很多人也稱為"依賴注入"。

  好了,核心原則說完了,總結一下,似乎就是一句話:"類要單純,繼承要謹慎,變化要封裝,抽象類型要多用"。

繼續閱讀