1. 程式人生 > >面向物件設計的七大設計原則詳解

面向物件設計的七大設計原則詳解

面向物件的七大設計原則

簡述

類的設計原則有七個,包括:開閉原則里氏代換原則迪米特原則(最少知道原則)單一職責原則介面分隔原則依賴倒置原則組合/聚合複用原則

七大原則之間的關係

七大原則之間並不是相互孤立的,彼此間存在著一定關聯,一個可以是另一個原則的加強或是基礎。違反其中的某一個,可能同時違反了其餘的原則。

開閉原則是面向物件的可複用設計的基石。其他設計原則是實現開閉原則的手段和工具。

一般地,可以把這七個原則分成了以下兩個部分:

設計目標:開閉原則、里氏代換原則、迪米特原則
設計方法:單一職責原則、介面分隔原則、依賴倒置原則、組合/聚合複用原則

一、開閉原則(The Open-Closed Principle ,OCP)

軟體實體(模組,類,方法等)應該對擴充套件開放,對修改關閉。

概念理解

開閉原則是指在進行面向物件設計中,設計類或其他程式單位時,應該遵循:

  • 對擴充套件開放(open)
  • 對修改關閉(closed) 的設計原則。

開閉原則是判斷面向物件設計是否正確的最基本的原理之一。

根據開閉原則,在設計一個軟體系統模組(類,方法)的時候,應該可以在不修改原有的模組(修改關閉)的基礎上,能擴充套件其功能(擴充套件開放)。

  • 擴充套件開放:某模組的功能是可擴充套件的,則該模組是擴充套件開放的。軟體系統的功能上的可擴充套件性要求模組是擴充套件開放的。
  • 修改關閉:某模組被其他模組呼叫,如果該模組的原始碼不允許修改,則該模組修改關閉的。軟體系統的功能上的穩定性,持續性要求模組是修改關閉的。

通過下邊的例子理解什麼是擴充套件開放和修改關閉:

左邊的設計是直接依賴實際的類,不是對擴充套件開放的。

右邊的設計是良好的設計:

  • Client對於Server提供的介面是封閉的;
  • Client對於Server的新的介面實現方法的擴充套件是開放的。

系統設計需要遵循開閉原則的原因

  1. 穩定性。開閉原則要求擴充套件功能不修改原來的程式碼,這可以讓軟體系統在變化中保持穩定。
  2. 擴充套件性。開閉原則要求對擴充套件開放,通過擴充套件提供新的或改變原有的功能,讓軟體系統具有靈活的可擴充套件性。
    遵循開閉原則的系統設計,可以讓軟體系統可複用,並且易於維護。

開閉原則的實現方法

為了滿足開閉原則的對修改關閉原則以及擴充套件開放原則,應該對軟體系統中的不變的部分加以抽象

,在面向物件的設計中,

  • 可以把這些不變的部分加以抽象成不變的介面,這些不變的介面可以應對未來的擴充套件;
  • 介面的最小功能設計原則。根據這個原則,原有的介面要麼可以應對未來的擴充套件;不足的部分可以通過定義新的介面來實現;
  • 模組之間的呼叫通過抽象介面進行,這樣即使實現層發生變化,也無需修改呼叫方的程式碼。

介面可以被複用,但介面的實現卻不一定能被複用。
介面是穩定的,關閉的,但介面的實現是可變的,開放的。
可以通過對介面的不同實現以及類的繼承行為等為系統增加新的或改變系統原來的功能,實現軟體系統的柔性擴充套件。

好處:提高系統的可複用性和可維護性。

簡單地說,軟體系統是否有良好的介面(抽象)設計是判斷軟體系統是否滿足開閉原則的一種重要的判斷基準。現在多把開閉原則等同於面向介面的軟體設計。

一個符合開閉原則的設計

需求:建立一系列多邊形。
首先,下面是不滿足開閉原則的設計方法:

Shape.h

enumShapeType{ isCircle, isSquare};
typedef struct Shape {
	enumShapeType type
} shape;

Circle.h

typedef struct Circle {
	enumShapeType type;
	double radius;
	Point center;
} circle;
void drawCircle( circle* );

Square.h

typedef struct Square {
	enumShapeType type;
	double side;
	Point topleft;
} square;
void drawSquare( square* );

drawShapes.cpp

#include "Shape.h"
#include "Circle.h"
#include "Square.h"
void drawShapes( shape* list[], intn ) {
	int i;
	for( int i=0; i<n; i++ ) {
		shape* s= list[i];
		switch( s->type ) {
		case isSquare:
			drawSquare( (square*)s );
			break;
		case isCircle:
			drawCircle( (circle*)s );
			break;
		}
	}
}

該設計不是對擴充套件開放的,當增加一個新的圖形時:

  • Shape不是擴充套件的,需要修改原始碼來增加列舉型別
  • drawShapes不是封閉的,當其被其他模組呼叫時,如果要增加一個新的圖形需要修改switch/case

此外,該設計邏輯複雜,總的來說是一個僵化的、脆弱的、具有很高的牢固性的設計。

用開閉原則重構該設計如下圖:

此時,在該設計中,新增一個圖形只需要實現Shape介面,滿足對擴充套件開放;也不需要修改drawShapes()方法,對修改關閉。

開閉原則的相對性

軟體系統的構建是一個需要不斷重構的過程,在這個過程中,模組的功能抽象,模組與模組間的關係,都不會從一開始就非常清晰明瞭,所以構建100%滿足開閉原則的軟體系統是相當困難的,這就是開閉原則的相對性。

但在設計過程中,通過對模組功能的抽象(介面定義),模組之間的關係的抽象(通過介面呼叫),抽象與實現的分離(面向介面的程式設計)等,可以儘量接近滿足開閉原則。

二、 里氏替換原則(Liskov Substitution Principle ,LSP)

所有引用基類的地方必須能透明地使用其派生類的物件。

概念理解

也就是說,只有滿足以下2個條件的OO設計才可被認為是滿足了LSP原則:

  • 不應該在程式碼中出現if/else之類對派生類型別進行判斷的條件。

  • 派生類應當可以替換基類並出現在基類能夠出現的任何地方,或者說如果我們把程式碼中使用基類的地方用它的派生類所代替,程式碼還能正常工作。

以下程式碼就違反了LSP定義。

if (obj typeof Class1) {
    do something
} else if (obj typeof Class2) {
    do something else
}

里氏替換原則(LSP)是使程式碼符合開閉原則的一個重要保證。

同時LSP體現了:

  • 類的繼承原則:如果一個派生類的物件可能會在基類出現的地方出現執行錯誤,則該派生類不應該從該基類繼承,或者說,應該重新設計它們之間的關係。

  • 動作正確性保證:從另一個側面上保證了符合LSP設計原則的類的擴充套件不會給已有的系統引入新的錯誤。
    示例:

裡式替換原則為我們是否應該使用繼承提供了判斷的依據,不再是簡單地根據兩者之間是否有相同之處來說使用繼承。

裡式替換原則的引申意義:子類可以擴充套件父類的功能,但不能改變父類原有的功能。

具體來說:

  • 子類可以實現父類的抽象方法,但不能覆蓋父類的非抽象方法。
  • 子類中可以增加自己特有的方法。
  • 當子類的方法過載父類的方法時,方法的前置條件(即方法的輸入/入參)要比父類方法的輸入引數更寬鬆。
  • 當子類的方法實現父類的方法時(過載/重寫或實現抽象方法)的後置條件(即方法的輸出/返回值)要比父類更嚴格或相等。

下面舉幾個例子幫助更進一步理解LSP:
例:1:

Rectangle是矩形,Square是正方形,Square繼承於Rectangle,這樣一看似乎沒有問題。

假如已有的系統中存在以下既有的業務邏輯程式碼:

void g(Rectangle& r)
{
r.SetWidth(5);
r.SetHeight(4);
assert(r.GetWidth() * r.GetHeight()) == 20);
}

則對應於擴充套件類Square,在呼叫既有業務邏輯時:

    Rectangle* square = new Square();
    g(*square);

時會丟擲一個異常。這顯然違反了LSP原則。說明這樣的繼承關係在這種業務邏輯下不應該使用。

例2:鯨魚和魚,應該屬於什麼關係?從生物學的角度看,鯨魚應該屬於哺乳動物,而不是魚類。沒錯,在程式世界中我們可以得出同樣的結論。如果讓鯨魚類去繼承魚類,就完全違背了Liskov替換原則。因為魚作為基類,很多特性是鯨魚所不具備的,例如通過腮呼吸,以及卵生繁殖。那麼,二者是否具有共性呢? 有,那就是它們都可以在水中"游泳",從程式設計的角度來說,它們都共同實現了一個支援"游泳"行為的介面。

例:3:運動員和自行車例子,每個運動員都有一輛自行車,如果按照下面設計,很顯然違反了LSP原則。

class Bike {
public:
       void Move( );
       void Stop( );
       void Repair( );
protected:
       int    ChangeColor(int );
private:
       int    mColor;
};


class Player : private Bike
{
public:
      void  StartRace( );
      void  EndRace( ); 
protected:
       int    CurStrength ( ); 
private:
        int   mMaxStrength;
        int   mAge;
};

裡式替換原則的優點

  • 約束繼承氾濫,是開閉原則的一種體現。
  • 加強程式的健壯性,同時變更時也可以做到非常好地提高程式的維護性、擴充套件性。降低需求變更時引入的風險。

重構違反LSP的設計

如果兩個具體的類A,B之間的關係違反了LSP 的設計,(假設是從B到A的繼承關係),那麼根據具體的情況可以在下面的兩種重構方案中選擇一種:

  • 建立一個新的抽象類C,作為兩個具體類的基類,將A,B的共同行為移動到C中來解決問題。

  • 從B到A的繼承關係改為關聯關係。

對於矩形和正方形例子,可以構造一個抽象的四邊形類,把矩形和正方形共同的行為放到這個四邊形類裡面,讓矩形和正方形都是它的派生類,問題就OK了。對於矩形和正方形,取width 和height 是它們共同的行為,但是給width 和height 賦值,兩者行為不同,因此,這個抽象的四邊形的類只有取值方法,沒有賦值方法。

對於運動員和自行車例子,可以採用關聯關係來重構:

class Player 
{
public:
      void  StartRace( );
      void  EndRace( ); 
protected:
       int    CurStrength ( ); 
private:
        int   mMaxStrength;
        int   mAge;
Bike * abike;
};

在進行設計的時候,我們儘量從抽象類繼承,而不是從具體類繼承。

如果從繼承等級樹來看,所有葉子節點應當是具體類,而所有的樹枝節點應當是抽象類或者介面。當然這只是一個一般性的指導原則,使用的時候還要具體情況具體分析。

在很多情況下,在設計初期我們類之間的關係不是很明確,LSP則給了我們一個判斷和設計類之間關係的基準:需不需要繼承,以及怎樣設計繼承關係。

三、 迪米特原則(最少知道原則)(Law of Demeter ,LoD)

迪米特原則(Law of Demeter)又叫最少知道原則(Least Knowledge Principle),可以簡單說成:talk only to your immediate friends,只與你直接的朋友們通訊,不要跟“陌生人”說話。

概念理解

對於面向OOD來說,又被解釋為下面兩種方式:

1)一個軟體實體應當儘可能少地與其他實體發生相互作用。

2)每一個軟體單位對其他的單位都只有最少的知識,而且侷限於那些與本單位密切相關的軟體單位。

朋友圈的確定
“朋友”條件:

  1. 當前物件本身(this)
  2. 以參量形式傳入到當前物件方法中的物件
  3. 當前物件的例項變數直接引用的物件
  4. 當前物件的例項變數如果是一個聚集,那麼聚集中的元素也都是朋友
  5. 當前物件所建立的物件

任何一個物件,如果滿足上面的條件之一,就是當前物件的“朋友”,否則就是“陌生人”。

迪米特原則的優缺點

迪米特原則的初衷在於降低類之間的耦合。由於每個類儘量減少對其他類的依賴,因此,很容易使得系統的功能模組功能獨立,相互之間不存在(或很少有)依賴關係。

迪米特原則不希望類直接建立直接的接觸。如果真的有需要建立聯絡,也希望能通過它的友元類來轉達。因此,應用迪米特原則有可能造成的一個後果就是:系統中存在大量的中介類,這些類之所以存在完全是為了傳遞類之間的相互呼叫關係,這在一定程度上增加了系統的複雜度

例如,購房者要購買樓盤A、B、C中的樓,他不必直接到樓盤去買樓,而是可以通過一個售樓處去了解情況,這樣就減少了購房者與樓盤之間的耦合,如圖所示。

違反迪米特原則的設計與重構

下面的程式碼在方法體內部依賴了其他類,這嚴重違反迪米特原則

class Teacher { 
public: 
 void command(GroupLeader groupLeader) { 
	   list<Student> listStudents = new list<Student>; 
	   for (int i = 0; i < 20; i++) { 
	        listStudents.add(new Student()); 
	   } 
	   groupLeader.countStudents(listStudents); 
} 
}

方法是類的一個行為,類竟然不知道自己的行為與其他類產生了依賴關係(Teacher類中依賴了Student類,然而Student類並不在Teacher類的朋友圈中,一旦Student類被修改了,Teacher類是根本不知道的),這是不允許的。

正確的做法是:

class Teacher { 
public:
 void command(GroupLeader groupLeader) { 
	        groupLeader.countStudents(); 
  } 
}

class GroupLeader { 
private:
list<Student> listStudents; 
public:
GroupLeader(list<Student> _listStudents) { 
	        this.listStudents = _listStudents; 
} 
void countStudents() { 
	    cout<<"女生數量是:" <<listStudents.size() <<endl; 
   } 
}

使用迪米特原則時要考慮的

  • 朋友間也是有距離的

一個類公開的public屬性或方法越多,修改時涉及的面也就越大,變更引起的風險擴散也就越大。因此,為了保持朋友類間的距離,在設計時需要反覆衡量:是否還可以再減少public方法和屬性,是否可以修改為private等。

**注意:**迪米特原則要求類“羞澀”一點,儘量不要對外公佈太多的public方法和非靜態的public變數,儘量內斂,多使用private、protected等訪問許可權。

  • 是自己的就是自己的

如果一個方法放在本類中,既不增加類間關係,也對本類不產生負面影響,就放置在本類中。

四、單一職責原則

永遠不要讓一個類存在多個改變的理由。

換句話說,如果一個類需要改變,改變它的理由永遠只有一個。如果存在多個改變它的理由,就需要重新設計該類。

單一職責原則原則的核心含意是:只能讓一個類/介面/方法有且僅有一個職責。

為什麼一個類不能有多於一個以上的職責?

如果一個類具有一個以上的職責,那麼就會有多個不同的原因引起該類變化,而這種變化將影響到該類不同職責的使用者(不同使用者):

  • 一方面,如果一個職責使用了外部類庫,則使用另外一個職責的使用者卻也不得不包含這個未被使用的外部類庫。
  • 另一方面,某個使用者由於某個原因需要修改其中一個職責,另外一個職責的使用者也將受到影響,他將不得不重新編譯和配置。
    違反了設計的開閉原則,也不是我們所期望的。

職責的劃分

既然一個類不能有多個職責,那麼怎麼劃分職責呢?

Robert.C Martin給出了一個著名的定義:所謂一個類的一個職責是指引起該類變化的一個原因。

如果你能想到一個類存在多個使其改變的原因,那麼這個類就存在多個職責。

SRP違反例:

class Modem {
		   void dial(String pno);    //撥號
           void hangup();        //結束通話
           void send(char c);    //傳送資料
           char recv();        //接收資料
};

乍一看,這是一個沒有任何問題的介面設計。
但事實上,這個介面包含了2個職責:第一個是連線管理(dial,hangup);另一個是資料通訊(send,recv)。
很多情況下,這2個職責沒有任何共通的部分,它們因為不同的理由而改變,被不同部分的程式呼叫。所以它違反了SRP原則。

下面的類圖將它的2個不同職責分成2個不同的介面,這樣至少可以讓客戶端應用程式使用具有單一職責的介面:

讓 ModemImplementation實現這兩個介面。我們注意到,ModemImplementation又組合了2個職責,這不是我們希望的,但有時這又是必須的。通常由於某些原因,迫使我們不得不繫結多個職責到一個類中,但我們至少可以通過介面的分割來分離應用程式關心的概念。

事實上,這個例子一個更好的設計應該是這樣的,如圖:

例如,考慮下圖的設計。

Retangle類具有兩個方法,如圖。一個方法把矩形繪製在螢幕上,另一個方法計算矩形的面積。

有兩個不同的Application使用Rectangle類,如上圖。一個是計算幾何面積的,Rectangle類會在幾何形狀計算方面給予它幫助。另一Application實質上是繪製一個在舞臺上顯示的矩形。

這一設計違反了單一職責原則。Rectangle類具有了兩個職責,第一個職責是提供一個矩形形狀幾何資料模型;第二個職責是把矩形顯示在螢幕上。

對於SRP的違反導致了一些嚴重的問題。首先,我們必須在計算幾何應用程式中包含核心顯示物件的模組。其次,如果繪製矩形Application發生改變,也可能導致計算矩形面積Application發生改變,導致不必要的重新編譯,和不可預測的失敗。

一個較好的設計是把這兩個職責分離到下圖所示的兩個完全不同的類中。這個設計把Rectangle類中進行計算的部分移到GeometryRectangle類中。現在矩形繪製方式的改變不會對計算矩形面積的應用產生影響了。

使用單一職責原則的理由

單一職責原則從職責(改變理由)的側面上為我們對類(介面)的抽象的顆粒度建立了判斷基準:在為系統設計類(介面)的時候應該保證它們的單一職責性。

降低了類的複雜度、提高類的可讀性,提高系統的可維護性、降低變更引起的風險

五、 介面分隔原則(Interface Segregation Principle ,ISP)

不能強迫使用者去依賴那些他們不使用的介面。

概念理解

換句話說,使用多個專門的介面比使用單一的總介面總要好。

它包含了2層意思:

  • 介面的設計原則:介面的設計應該遵循最小介面原則,不要把使用者不使用的方法塞進同一個接口裡。如果一個介面的方法沒有被使用到,則說明該介面過胖,應該將其分割成幾個功能專一的介面。

  • 介面的依賴(繼承)原則:如果一個介面a繼承另一個介面b,則介面a相當於繼承了介面b的方法,那麼繼承了介面b後的介面a也應該遵循上述原則:不應該包含使用者不使用的方法。 反之,則說明介面a被b給汙染了,應該重新設計它們的關係。

如果使用者被迫依賴他們不使用的介面,當介面發生改變時,他們也不得不跟著改變。換而言之,一個使用者依賴了未使用但被其他使用者使用的介面,當其他使用者修改該介面時,依賴該介面的所有使用者都將受到影響。這顯然違反了開閉原則,也不是我們所期望的。

總而言之,介面分隔原則指導我們:

  1. 一個類對一個類的依賴應該建立在最小的介面上

  2. 建立單一介面,不要建立龐大臃腫的介面

  3. 儘量細化介面,介面中的方法儘量少

違反ISP原則的設計與重構

下面我們舉例說明怎麼設計介面或類之間的關係,使其不違反ISP原則。

假如有一個Door,有lock,unlock功能,另外,可以在Door上安裝一個Alarm而使其具有報警功能。使用者可以選擇一般的Door,也可以選擇具有報警功能的Door。

有以下幾種設計方法:

ISP原則的違反例一:在Door接口裡定義所有的方法。

但這樣一來,依賴Door介面的CommonDoor卻不得不實現未使用的alarm()方法。違反了ISP原則。

ISP原則的違反例二:在Alarm介面定義alarm方法,在Door介面定義lock,unlock方法,Door介面繼承Alarm介面。

跟方法一一樣,依賴Door介面的CommonDoor卻不得不實現未使用的alarm()方法。違反了ISP原則。

遵循ISP原則的例一:通過多重繼承實現

在Alarm介面定義alarm方法,在Door介面定義lock,unlock方法。介面之間無繼承關係。CommonDoor實現Door介面,AlarmDoor有2種實現方案:

1)同時實現Door和Alarm介面。

2)繼承CommonDoor,並實現Alarm介面。

第2)種方案更具有實用性。

這樣的設計遵循了ISP設計原則。

遵循ISP原則的例二:通過關聯實現

在這種方法裡,AlarmDoor實現了Alarm介面,同時把功能lock和unlock委讓給CommonDoor物件完成。

這種設計遵循了ISP設計原則。

介面分隔原則的優點和適度原則

  • 介面分隔原則從對介面的使用上為我們對介面抽象的顆粒度建立了判斷基準:在為系統設計介面的時候,使用多個專門的介面代替單一的胖介面。

  • 符合高內聚低耦合的設計思想,從而使得類具有很好的可讀性、可擴充套件性和可維護性。

  • 注意適度原則,介面分隔要適度,避免產生大量的細小介面。

單一職責原則和介面分隔原則的區別

單一職責強調的是介面、類、方法的職責是單一的,強調職責,方法可以多,針對程式中實現的細節;

介面分隔原則主要是約束介面,針對抽象、整體框架。

六、 依賴倒置原則(Dependency Inversion Principle ,DIP)

A. 高層模組不應該依賴於低層模組,二者都應該依賴於抽象
B. 抽象不應該依賴於細節,細節應該依賴於抽象 C.針對介面程式設計,不要針對實現程式設計。

概念理解

依賴:在程式設計中,如果一個模組a使用/呼叫了另一個模組b,我們稱模組a依賴模組b。

高層模組與低層模組:往往在一個應用程式中,我們有一些低層次的類,這些類實現了一些基本的或初級的操作,我們稱之為低層模組;另外有一些高層次的類,這些類封裝了某些複雜的邏輯,並且依賴於低層次的類,這些類我們稱之為高層模組。

依賴倒置(Dependency Inversion)
面向物件程式設計相對於面向過程(結構化)程式設計而言,依賴關係被倒置了。因為傳統的結構化程式設計中,高層模組總是依賴於低層模組。

問題的提出:
Robert C. Martin氏在原文中給出了“Bad Design”的定義:

  1. 系統很難改變,因為每個改變都會影響其他很多部分。

  2. 當你對某地方做一修改,系統的看似無關的其他部分都不工作了。

  3. 系統很難被另外一個應用重用,因為很難將要重用的部分從系統中分離開來。

導致“Bad Design”的很大原因是“高層模組”過分依賴“低層模組”。

一個良好的設計應該是系統的每一部分都是可替換的。如果“高層模組”過分依賴“低層模組”,一方面一旦“低層模組”需要替換或者修改,“高層模組”將受到影響;另一方面,高層模組很難可以重用。

問題的解決:

為了解決上述問題,Robert C. Martin氏提出了OO設計的Dependency Inversion Principle (DIP) 原則。

DIP給出了一個解決方案:在高層模組與低層模組之間,引入一個抽象介面層。

High Level Classes(高層模組) --> Abstraction Layer(抽象介面層) --> Low Level Classes(低層模組)

抽象介面是對低層模組的抽象,低層模組繼承或實現該抽象介面。

這樣,高層模組不直接依賴低層模組,而是依賴抽象介面層。抽象介面也不依賴低層模組的實現細節,而是低層模組依賴(繼承或實現)抽象介面。

類與類之間都通過抽象介面層來建立關係。

依賴倒置原則的違反例和重構

示例:考慮一個控制熔爐調節器的軟體。該軟體從一個IO通道中讀取當前的溫度,並通過向另一個IO通道傳送命令來指示熔爐的開或者關。

溫度調節器的簡單演算法:

  const byte THERMONETER=0x86;
  const byte FURNACE=0x87;
  const byte ENGAGE=1;
  const byte DISENGAGE=0;

  void Regulate(double minTemp,double maxTemp)
  {
     for(;;)
     {
        while (in(THERMONETER) > minTemp)
           wait(1);
        out(FURNACE,ENGAGE);
        
        while (in(THERMONETER) < maxTemp)
           wait(1);
        out(FURNACE,DISENGAGE);
     }
  }

演算法的高層意圖是清楚的,但是實現程式碼中卻夾雜著許多低層細節。這段程式碼根本不能重用於不同的控制硬體。

由於程式碼很少,所以這樣做不會造成太大的損害。但是,即使是這樣,使演算法失去重用性也是可惜的。我們更願意倒置這種依賴關係。

圖中顯示了 Regulate 函式接受了兩個介面引數。Thermometer 介面可以讀取,而 Heater 介面可以啟動和停止。Regulate 演算法需要的就是這些。這就倒置了依賴關係,使得高層的調節策略不再依賴於任何溫度計或者熔爐的特定細節。該演算法具有很好的可重用性。

通用的調節器演算法:

  void Regulate(Thermometer t, Heater h, double minTemp,
     double maxTemp)
  {
    for(;;)
    {
       while (t.Read() > minTemp)
          wait(1);
       h.Engate();

       while (t.Read() < maxTemp)
          wait(1);
       h.Disengage();
    }
  }

怎麼使用依賴倒置原則

1. 依賴於抽象

  • 任何變數都不應該持有一個指向具體類的指標或引用。

如:

class class1{
class2* cls2 = new class2();
}
class class2{
.......
}
  • 任何類都不應該從具體類派生。

2. 設計介面而非設計實現

  • 使用繼承避免對類的直接繫結

  • 抽象類/介面: 傾向於較少的變化;抽象是關鍵點,它易於修改和擴充套件;不要強制修改那些抽象介面/類

例外:

有些類不可能變化,在可以直接使用具體類的情況下,不需要插入抽象層,如:字串類

3. 避免傳遞依賴

  • 避免高層依賴於低層

  • 使用繼承和抽象類來有效地消除傳遞依賴

依賴倒置原則的優點

可以減少類間的耦合性、提高系統穩定性,提高程式碼可讀性和可維護性,可降低修改程式所造成的風險。

七、 組合/聚合複用原則(Composite/Aggregate Reuse Principle ,CARP)

儘量使用組合/聚合,不要使用類繼承。

概念理解

即在一個新的物件裡面使用一些已有的物件,使之成為新物件的一部分,新物件通過向這些物件的委派達到複用已有功能的目的。就是說要儘量的使用合成和聚合,而不是繼承關係達到複用的目的。

組合和聚合都是關聯的特殊種類。

聚合表示整體和部分的關係,表示“擁有”。組合則是一種更強的“擁有”,部分和整體的生命週期一樣。

組合的新的物件完全支配其組成部分,包括它們的建立和湮滅等。一個組合關係的成分物件是不能與另一個組合關係共享的。

組合是值的聚合(Aggregation by Value),而一般說的聚合是引用的聚合(Aggregation by Reference)。

在面向物件設計中,有兩種基本的辦法可以實現複用:第一種是通過組合/聚合,第二種就是通過繼承。

什麼時候才應該使用繼承

只有當以下的條件全部被滿足時,才應當使用繼承關係:

  • 1)派生類是基類的一個特殊種類,而不是基類的一個角色,也就是區分"Has-A"和"Is-A"。只有"Is-A"關係才符合繼承關係,"Has-A"關係應當用聚合來描述。

  • 2)永遠不會出現需要將派生類換成另外一個類的派生類的情況。如果不能肯定將來是否會變成另外一個派生類的話,就不要使用繼承。

  • 3)派生類具有擴充套件基類的責任,而不是具有置換掉(override)或登出掉(Nullify)基類的責任。如果一個派生類需要大量的置換掉基類的行為,那麼這個類就不應該是這個基類的派生類。

  • 4)只有在分類學角度上有意義時,才可以使用繼承。

總的來說:

如果語義上存在著明確的"Is-A"關係,並且這種關係是穩定的、不變的,則考慮使用繼承;如果沒有"Is-A"關係,或者這種關係是可變的,使用組合。另外一個就是隻有兩個類滿足里氏替換原則的時候,才可能是"Is-A" 關係。也就是說,如果兩個類是"Has-A"關係,但是設計成了繼承,那麼肯定違反里氏替換原則。

錯誤的使用繼承而不是組合/聚合的一個常見原因是錯誤的把"Has-A"當成了"Is-A" 。"Is-A"代表一個類是另外一個類的一種;"Has-A"代表一個類是另外一個類的一個角色,而不是另外一個類的特殊種類。

看一個例子:

如果我們把“人”當成一個類,然後把“僱員”,“經理”,“學生”當成是“人”的派生類。這個的錯誤在於把 “角色” 的等級結構和 “人” 的等級結構混淆了。“經理”,“僱員”,“學生”是一個人的角色,一個人可以同時擁有上述角色。如果按繼承來設計,那麼如果一個人是僱員的話,就不可能是學生,這顯然不合理。

正確的設計是有個抽象類 “角色”,“人”可以擁有多個“角色”(聚合),“僱員”,“經理”,“學生”是“角色”的派生類。

通過組合/聚合複用的優缺點

優點:

    1. 新物件存取子物件的唯一方法是通過子物件的介面。
    1. 這種複用是黑箱複用,因為子物件的內部細節是新物件所看不見的。
    1. 這種複用更好地支援封裝性。
    1. 這種複用實現上的相互依賴性比較小。
    1. 每一個新的類可以將焦點集中在一個任務上。
    1. 這種複用可以在執行時間內動態進行,新物件可以動態的引用與子物件型別相同的物件。
    1. 作為複用手段可以應用到幾乎任何環境中去。

缺點: 就是系統中會有較多的物件需要管理。

通過繼承來進行復用的優缺點

優點:

  • 新的實現較為容易,因為基類的大部分功能可以通過繼承的關係自動進入派生類。
  • 修改和擴充套件繼承而來的實現較為容易。

缺點:

  • 繼承複用破壞封裝性,因為繼承將基類的實現細節暴露給派生類。由於基類的內部細節常常是對於派生類透明的,所以這種複用是透明的複用,又稱“白箱”複用。

  • 如果基類發生改變,那麼派生類的實現也不得不發生改變。

  • 從基類繼承而來的實現是靜態的,不可能在執行時間內發生改變,沒有足夠的靈活性。