接口 (純粹的抽象類)
什么是接口
一個 Java 接口( interface )是 一些方法特征的集合 。一個 接口只有方法的特征,而沒有方法的實現 ,因此 這些方法在不同的地方被實現時,可以具有完全不同的行為 。接口還可以定義 public 的常量。
接口其實是一種純粹的抽象類。
常談到的接口可以分為二種不同的含義:第一種是指 Java 語言中的接口,這是一種 Java 語言中存在的結構,具有特定的語法和結構,並使用關鍵字 interface 來定義;另一種 僅僅是指一個類所具有的方法的特征集合 ,是一種邏輯上的抽象。前者叫做“ Java 接口”,后者就叫做“接口”。
一個方法的特征僅包括方法的名字,參數的個數、類型、順序(實質上就是參數列表),而不包括方法的返回類型與所拋出的異常。
重載(也叫過載)時只與方法特征有關,但重寫(也叫覆寫)是會進一步檢查兩個方法的返回類型和拋出的異常是否相同。
接口與類的區別
最重要的區別是,接口僅僅描述方法與特征,而不給出方法的實現;而 類不僅給出方法的特征,而且給出方法的實現 。因此, 接口把方法的特征與實現分離 ,這種分離,體現在 接口常常代表一個角色 ,它包裝與該角色相關的操作與屬性,而 實現這個接口的類便是扮演這個角色的演員 。一個角色可以由不同的演員來演(即可以由不同類來實現),而不同的演員之間除了扮演一個共同的角色之外,並不要求有任何其他的共同之處。
為什么要使用接口
1、 實現多繼承
2、 提高系統的 靈活性、可擴展性、可插入性 、 可復用性、可維護性 、可測試性
抽象類
什么是抽象類
抽象類僅提供一個類的部分實現。抽象類可以有實例變量,構造函數,可以同時有抽象方法和具體方法。
一個抽象類不會有實例。 模板方法模式就是抽象類和子類的一種應用 。
抽象類的用途
抽象類通常代表一個抽象概念,它是一個繼承的出發點。
1、 抽象類是用來繼承 的
要求繼承都是從抽象類開始,而所 有的具體子類都不應該被繼承 。
在一個以繼承關系形成的等級結構里面,樹葉節點均應當是具體類,而樹枝節點均應當是抽象類(或是 Java 接口)。
2、 抽象類應當擁有盡可能多的共同代碼
在一個從抽象類到多個具體類的繼承關系中,共同的代碼應盡量移動到抽象類中。在一個繼承等級結構中,共同的代碼應當盡量向等級結構的上方移動。
一個對象從超類繼承而來的代碼,在不使用時不會造成對象資源的浪費。這樣把共同的代碼盡量抽取到頂級抽象類中,可以保證最大限度的復用。
3、 抽 象類應當擁有盡可能少的數據
與代碼的移方向相反的是,數據的移動方向是從抽象類到具體類,也即從繼承的等級結構的高端向等級結構的低端移動。
一個對象的數據不論是否使用都會占用資源,因此數據應當盡量話到具體類或者等級結構的低端。
4、 盡量不要重寫基類的方法
如果基類是一個抽象類,並且這個方法已經實現了,子類盡里不要重寫。要時刻記住:抽象類是用來繼承的,而不是用來重寫的。
什么時候才應當使用繼承復用
繼承代表“一般 / 特殊化”關系,其中基類代表一般,派生類代表特殊,派生類將基類特殊化或擴展化。只有滿足以下全部條件時才使用繼承關系:
l 子類是超類的一個特殊種類 ,而不是超類的一個角色,也就是要區分“ Has-A ”與“ Is-A ”兩種關系的不同。“ Has-A ”應該使用聚合關系來描述,而只有“ Is-A ”才符合繼承關系。
l 永遠不會出現需要將子類變成另一個類的子類的情況,如果不確定一個類在將來會不會成為另一個類的子類時,請不要繼承當前這個類。
l 子類有擴展超類的責任,而不是具有置換掉( Override )或注銷掉超類的責任 。如果子類需要大量地轉換超類的行為,那么這個子類就不應該成為超類的子類。
l 只有在分類學角度上有意義時,才可以使用繼承。
接口與抽象類的區別
1) 從繼承角度方面來看 ,一個類只能繼承一個抽象類,但可以實現多個接口。
2) 從類的修改角度方面來看: 在於 抽象類可以提供某些方法的部分實現 ,而 接口則不可以 ,這也大概是抽象類的唯一的優點。
如果向一個抽象類加入一個新的具體方法,那么所有的子類一下子就都得到了這個新的具體方法,而 Java 接口做不到這一點,如果向一個 Java 接口加入一個新方法的話,所有實現這個接口的類就不能通過編譯了,這顯然是 Java 接口的一個缺點,這也從另一方面說明了 抽象類在后繼版本中演變的過程會比接口要容易得多 。
3) 從代碼重構的角度上講 , 將一個單獨的 Java 具體類重構成一個實現 Java 接口是很容易的 ,只需要聲明一個 Java 接口,並將重要的方法添加到接口聲明中,然后在具體定義語句后面加上一個合適的 implements 關鍵字即可;而 為一個具體類添加一個 Java 抽象作為抽象類型卻不是那么容易 ,因為這個具體類可能已經有了一個超類。這樣一來,這樣新定義的抽象類只好繼續向上移動,變成這個超類的超類,這樣,最后這個新定義的抽象類必定處於整個類型等級結構的最上端,從而使等級結構中的所有成員都會受到影響。
4) Java 接口是定義混合類型的理想工具 。所謂混合類型就是在一個類的主類型之外的次要類型。一個混合類型表明一個類不僅僅具有某個主類型的行為,而且具有其他次要行為。比如 HashMap 就具有多種類型 ,它的主類型是 Map ,表示它是一種映射。它還實現了 Cloneable ,表示該類可以安全的被克隆。另外它還實現了 Serializable 接口,表示這個類可以被序列化。
5) 接口是多功能組合的重要手段 。 將一個龐大的接口按照單一職責原則拆分成多個小的接口后,實現者可以根據需求將某些接口組合起來形成一個個不同功能的類 , 這些創建出來的類是符合接口隔離原則的,所以也不會造成接口污染 。如果你 采用抽象類,將會引起類爆炸風險 ,因為每種功能組合你都得要提供一個抽象類,而接口則不需要,我們只需要提供這些職責單一的接口,然后由用戶自己去自由地組合。
聯合使用接口和抽象類
一般來說,要想在 公開的接口增加方法,而不破壞實現這個接口的所有現有類,這是不可能的 ,除非一開始就讓某個類實現接口的時候,也繼承抽象類。
由於 抽象類具有提供缺省實現的優點 ,而接口具有其他所有的優點,所以聯合使用就是一個很好的選擇。如果一個具體類直接實現這個接口的話,它就必須自行實現所有的接口;相反,如果它繼承自抽象類的話,它可以省去一些不必要的方法,因為它可以 從抽象類中自動得到這些方法的缺省實現 。如果需要向接口加入一個新的方法的話,那么只要同時向這個抽象類加入這個方法的一個具體實現就可以了,因為所有繼承自這個抽象類的子類都會從這個抽象類得到這個具體方法。這其實就是 一種缺省適配器模式 。在 API 中也是用了這種缺省適配模式,比如 Collection 與 AbstractCollection 、 List 與 AbstractList 、 Set 與 AbstractSet 、 Map 與 AbstractMap 。再來看看 HashSet 的類圖,從類圖我們可以看出它聯合使用了接口和抽象類, ArrayList 、 HashMap 也一樣:
在 SSH 框架中我們也會這么做。
設計原則
單一職責原則( SRP )——內聚性
定義
單一職責原則的定義: 就一個類頁言,應該僅有一個引起它變化的原因 ( There should never be more than one reason for a class to change )。
單一職責原則要求一個接口或類只有一個原因引起變化,也就是 一個接口或類只有一個職責,它就是負責一件事情 。
內聚性:一個模塊的組成元素之間的功能相關性。
示例:用戶管理
用戶的屬性與用戶的行為沒有分開,應該把用戶的信息抽取成一個 BO ( Bussiness Object ,業務對象),把行為抽取成一個 Biz ( Business Logic ,業務邏輯),下面采用單一職責原則來分成兩個職責單一的接口:
上面圖中 UserInfo 其實還是實現了兩個接口,又把兩個職責整合在一個類中了,這又不是有兩個原因會引起修改啊,但要注意的是我們是面向接口編程,我們對外公布的是接口而不是實現類,只要接口不變,對系統是沒有影響的,實現類是可以隨時更換。
在實際的使用用中,我們更傾向於使用兩個不同的接口與類或接口:一個是 IUserBo ,一個是 IUserBiz :
單一職責原則不僅僅只適合於類,還適合於方法。
示例: Modem
我們把職責定義為“變化的原因”。如果你能夠想到多於一個的動機去改變一個類,那么這個類就具有多於一個的職責。有時,我們很難注意到這一點。我們習慣於以組的形式(如一個流程,撥號、掛斷、發送數據、接收數據)去考慮職責。如 Modem 接口,大多數人會認為這個接口看起來非常合理。該接口所聲明的 4 個函數確實是調制解調器所具有的功能(不一定具有這樣的功能就應該把它們設計在一個接口中,而是分離接口后要所需要再組合必要的接口)。
interface Modem{
public void dial(Strng pno);// 撥號
public void hangup();// 掛機
public void send(char c);// 發送數據
public void recv();// 接收數據
}
然而,該接口中卻顯示出兩個職責,第一個是連接管理( dial 、 hangup ),第二個是數據通信( send 、 recv )。這兩個職責應該被分開嗎?這依賴於應用程序變化的方式,如果應用程序的變化會影響連接函數的簽名,那么這個設計就具有僵化性,因為調用 send 和 recv 類必須要重新編譯、部署,在這種情況下,我們應該分開。
另一方而,如果應用程序的變化方式總是導致這兩個職責同時變化,那么就不必分離它們,實際上,分離它們就會具有不必要的復雜性。
清注意,上面圖中我把兩個職責都耦合進 Modem Implementation 類中。這不是所希望的,但是或許是必要的。常常會有一些和硬件或者操作系統的細節有關的原因,迫使我們把不願耦合在一起的東西耦合在了—起。然而,對於應用的其余部分來說,通過分離它們的接口,我們已經解耦了概念。
我們可以把 Modem Implementation 類看作是一個雜湊物,或者一個瑕疵。然而,請注意所有的依賴關系都和它無關。誰也不需要依賴於它。除了 main 外,誰也不需要知道它的存在。因為我們是針對接口在編程。
結論
SRP 是所有原則中層簡單之一的原則,也是最難正確運用的之—。我們會自然地把職責結合在一起。軟件設計真正要做的許多內容,就是發現職責並把那些職責相互分離。事實上,我們將要論述的其余原則都會以這樣或那樣的方式回到這個問題上。
“開 - 閉”原則( OCP )—— 抽象應對一切的變化
經典力學的基石是牛頓三大定律 ( 靜止或勻速運動、加速運動、作用力與反作用力 ),而面 向對象設計的第一塊基石,便是“開 - 閉”原則 。
通過擴展的方式來修改現有類 。
只要是面向對象編程,不管是什么語言,在開發進都會提及開閉原則。
定義
Open-Closed Principle : Software entities should be open for extension, but closed for modification. (一個軟件實體應當 對擴展開放,對修改關閉 。)
這個原則說的是,在設計一個模塊的時候,應當使這個模塊可以在不被修改的前提下被擴展,換而言之,應當可以在不必修改現有源碼的情況下改變這個模塊的行為。
如果程序中的一處改動就會產生連鎖反應,導致—系列相關模塊的改動,那么設計就具有僵化性的臭味。如果正確地應用 OCP ,那么以后再進行同樣的改動時,就只需要添加新的代碼,而不必改動已經正常運行的代碼。
遵循開閉原則設計出的模塊具有兩個主要的特征。它們是:
1 、“對擴展是開放的”。這意味着模塊的行為是可以擴展的,當應用的需求改變時,我們可以對模塊進行擴展,使其具有滿足那些改變的新行為。換句話說,我們可以改變模塊的能。
2 、“對於更改是封閉的”。對模塊行為進行擴展時,不必改動模式的源代碼或者二進制代碼。模塊的二進制可執行版本,無論是可鏈接的庫、 DLL 或者 Java 的 ..jar 文件,都無需改動。
與其他原則之間的關系
開閉原則是最基礎的一個原則,其他原則都是開閉原則的具體做法,也就是說,其他原則是指導設計的工具和方法,而開閉原則才是其精神領袖 。開閉原則是目標,而其他原則是達到這個目標的手段。要遵循開閉原則,則需要使用其他原則來實施。
“開-閉”原則與其他原則的是目標與手段的關系,“開-閉”是目標,而達到這一目標的手段是遵循其他原則。
為什么要使用開閉原則
開閉原則提高了系統的可維護性(可擴展性 - 增、靈活性 / 可修改性 - 修、可插入性 - 替、可測試性)、可復用性。
示例:書店打折
public interface IBook {// 書籍接口
// 書籍有名稱
public String getName();
// 書籍有售價
public int getPrice();
// 書籍有作者
public String getAuthor();
}
public class NovelBook implements IBook {// 小說
// 書籍名稱
private String name;
// 書籍的價格
private int price;
// 書籍的作者
private String author;
// 通過構造函數傳遞書籍數據
public NovelBook(String _name,int _price,String _author){
this.name = _name;
this.price = _price;
this.author = _author;
}
// 獲得作者是誰
public String getAuthor() {
return this.author;
}
// 書籍叫什么名字
public String getName() {
return this.name;
}
// 獲得書籍的價格
public int getPrice() {
return this.price;
}
}
public class BookStore {// 場景
private final static ArrayList<IBook> bookList = new ArrayList<IBook>();
// 靜態模塊初始化,項目中一般是從持久層初始化產生
static{
bookList.add(new NovelBook(" 天龍八部 ",3200," 金庸 "));
bookList.add(new NovelBook(" 巴黎聖母院 ",5600," 雨果 "));
bookList.add(new NovelBook(" 悲慘世界 ",3500," 雨果 "));
bookList.add(new NovelBook(" 金、瓶、梅 ",4300," 蘭陵笑笑生 "));
}
// 模擬書店買書
public static void main(String[] args) {
NumberFormat formatter = NumberFormat.getCurrencyInstance();
formatter.setMaximumFractionDigits(2);
System.out.println("------------ 書店買出去的書籍記錄如下: ---------------------");
for(IBook book:bookList){
System.out.println(" 書籍名稱: " + book.getName()+"\t 書籍作者: " + book.getAuthor()+ "\t 書籍價格: " + formatter.format(book.getPrice()/100.0)+" 元 ");
}
}
}
現要求所有 40 元以上的書籍 9 折銷售,其他的 8 折銷售。我們該如何應付這樣的一個需求變化呢?有以下三種可以選擇:
1、 修改接口。在 IBook 上新增加一個方法 getOffPrice() ,專門用於進行打折處理。但這樣修改的后果就是 NovelBook 要修改, BookStore 中的 main 方法也要修改,同時 IBook 作為接口應該是穩定的,接口的修改將是大范圍的修改,所以,該方案否決。
2、 修改實現類。雖然沒有直接修改接口,不需要修改其他子類,也不需要修改 BookStore 中的 main 方法,看起來是一個不錯的方案。但是, getPrice 是一個已開放了的方法,你不知道會有多少人調用這個方法,直接修改它會引起所有調用該方法的類都所到影響,比如有的調用者就是需要書的原價怎么?如果真允許這樣修改,所有調用該方法都需要重新測試,所以還是否決。
3、 通過擴展實現變化。增加一個子類 OffNovelBook ,重寫 getPrice 方法,這只需要修改高層次的模塊(這里也就是 static 靜態模塊區)換成新的 OffNovelBook 的對象即可。不會對其他地方造成影響,這是好的方案。
OffNovelBook 類繼承了 NovelBook ,並重寫了 getPrice 方法,不用修改現有實現。
public class OffNovelBook extends NovelBook {// 打折小說
public OffNovelBook(String _name,int _price,String _author){
super(_name,_price,_author);
}
// 覆寫銷售價格
@Override
public int getPrice(){
// 原價
int selfPrice = super.getPrice();
int offPrice=0;
if(selfPrice>4000){ // 原價大於 40 元,則打 9 折
offPrice = selfPrice * 90 /100;
}else{
offPrice = selfPrice * 80 /100;
}
return offPrice;
}
}
public class BookStore {
…
static{// 只需修改這里
bookList.add(new OffNovelBook(" 天龍八部 ",3200," 金庸 "));
…
}
// 模擬書店買書
…
}
注,開閉原則對擴展開放,對修改關閉,並不意味着不做任何修改,低層模塊的修改,必然會引起高層模塊進行修改(上面是需求范圍內的修改,完全沒有影響其他模塊),我們要做的就是盡量減少這種修改所帶來的影響。
開閉原則對測試的影響
這是以打折以前對 NovelBook 進行測試的測試代碼:
public class NovelBookTest extends TestCase {//NovelBook 的測試類
private String name = " 平凡的世界 ";
private int price = 6000;
private String author = " 路遙 ";
private IBook novelBook = new NovelBook(name,price,author);
// 測試 getPrice 方法
public void testGetPrice() {
// 原價銷售,判斷輸入和輸出的值是否相等進行斷言
super.assertEquals(this.price, this.novelBook.getPrice());
}
}
加入打折小說后類 OffNovelBook 后,我們只需要為它單獨提供一個測試類就可以了,而不需要修改已經的測試代碼:
public class OffNovelBookTest extends TestCase {{// 打折小說的測試類
private IBook below40NovelBook = new OffNovelBook(" 平凡的世界 ",3000," 路遙 ");
private IBook above40NovelBook = new OffNovelBook(" 平凡的世界 ",6000," 路遙 ");
// 測試低於 40 元的數據是否是打 8 折
public void testGetPriceBelow40() {
super.assertEquals(2400, this.below40NovelBook.getPrice());
}
// 測試大於 40 的書籍是否是打 9 折
public void testGetPriceAbove40(){
super.assertEquals(5400, this.above40NovelBook.getPrice());
}
}
怎樣做到“開-閉”原則
怎樣在能在不改動模塊源代碼的情況下去更改它的行為呢?怎樣才能在無需對象模塊進行改動的情況下就改變它的功能呢?
l 抽象化是關鍵
首先,抽象層是穩定的,抽象層預見了所有的可能擴展,在任何擴展情況下都不會改變。然后,在程序中都要求是針對接口或抽象類進行編程,而不是實現,這樣就約束了你不能在實現類中添加抽象類中沒有的方法(即使你添加了,抽象層也沒有調用)。如果做到了這兩點,那么系統的抽象層與現有的實現不需要修改,從而滿足了“開-閉”原則的第二條:對修改關閉;
從抽象層導出一個或多個新的具體類就可以改變系統的行為,因此系統的設計對擴展是開放的,從而滿足了“開-閉”原則的第一條:對擴展開放。
l 對可變性的封裝(還是抽象?)
找出系統中可能會變化或不穩定的點,將這些點抽取出來,封裝到一個接口或抽象類中,創建穩定的抽象層。在封裝的過程我們要注意兩點:第一,將相同的變化封裝到一個接口或抽象類中(這不就是單一職責原則嗎?),第二,將不同的變化封裝到不同的接口或抽象類中,而不應該將它混合到一起。類圖的繼承結構一般都不要超過兩層,不然就意味着將兩種不同的可變性混合在一起了。
“對可變性的封裝原則”實際上是設計模式的主題。換而言之,所有的設計模式都是對不同的可變性的封裝,從而使系統在不同的角度上達到“開-閉”原則的要求。
里氏代換原則( LSP )
從“開 - 閉”原則中可以看出面向對象設計的關鍵是抽象,從抽象化到具體化需要使用繼承關系,而是否滿足繼承關系則需要使用里氏代換原則( Liskov Substation principle )來驗證。
定義
所有基類(泛指接口與抽象類、還有具體類也可)出現的地方,子類都可以出現。
嚴格表達是:如果對每一個類型的 T1 的對象 o1 ,都有類型為 T2 的對象 o2 ,使得以 T1 定義的所有程序 P 在所有的對象 o1 都代換成 02 時,程序 P 的行為沒有變化,那么類型 T2 是類型 T1 的子類型。換言這,一個軟件實體如果使用的是一個基類的話,那么一定適用於子類,而且它根本不能察覺出基類對象和子類對象的區別。
Java 語言對象里氏代換的支持
在編譯期間, Java 語言編譯器會檢查一個程序是否符合里氏代換原則。當然這純粹是語法上的檢查,不會去檢查業務邏輯是否違反這個法則。在語言上 Java 作了這樣的檢查:子類必須包括全部的基類接口,而且實現時接口訪問權限只能放寬,否則編譯器會出錯。從里氏代換角度來看這個問題,就不難得出答案了,因為客戶端完全有可能調用超類的公開方法,如果以子類代替,如果子類這個方法是私有,客戶端就不能調用了,它們又是父子關系,又不能代換,顯然這是違反里氏代換原則的, Java 編譯器就不會讓這樣的程序過關。
在平時我們的參數、變量、及返回類型一般都定義成了 或抽象類,這時其實就已經用到了這個原則了。
Java 語言對里氏代換支持的局限
Java 編譯器不能檢查一個系統的業務邏輯與實現是否滿足里氏代換法則。一個其名的例子,就是“正方形類型是滯是長方形的子類”的問題。所以說 Java 只在語法上為我們把了第一道關,如果我們將兩個互不相干的類構建成父子關系,在編譯期間可以通過,但在業務邏輯上就會出現問題,而業務邏輯上的檢查這道關,就只能由我們自己去把握了,這也是里氏代換最難以把握的一點,后面我們會針對此點進行講解。
從代碼重構的角度理解
兩種重構方式
里氏代換原則講的是基類與子類的關系,只有當這種關系(這里主要是針對業務邏輯上的關系)存在時,里氏代換關系才存在,反之則不存在。如果有兩個具體類 A 和 B 之間的關系違反了里氏代換原則,則可以要所具體情況可以在下面的兩種重構方案中選擇一種進行重構即可解決:
1、 創建一個新的抽象類 C ,作為兩個具體類的超類,將 A 和 B 的共同行為移動到 C 中,從而解決 A 和 B 行為不完全一致的問題,如:
2、 從 B 到 A 的繼承關系改寫為委派關系,如下圖:
長方形和正方形問題
正方形是否是長方形(類似的,圓是否是橢圓),先看看它們的類:
public class Rectangle {// 長方形
private long width;
private long height;
public void setWidth ( long width ) {
this .width = width ;
}
public long getWidth () {
return this .width;
}
public void setHeight ( long height ) {
this .height = height ;
}
public long getHeight () {
return this .height;
}
}
public class Square {// 正方形
private long side;
public void setSide ( long side ) {
this .side = side ;
}
public long getSide () {
return side;
}
}
因為這個正方形類不是長方形類的子類(而且也不可能成為長方形類的子類,為什么請看后面),因此, Rectangle 類與 Square 類之間不存在里氏代換關系。
正方形不可以作為長方形的子類
為什么不可以,我們從反面着手,先假設可以。正方形重新設計如下:
public class Square extends Rectangle {// 正方形是一個長方形
private long side;
public void setWidth ( long width ) {
setSide( width );
}
public long getWidth () {
return getSide();
}
public void setHeight ( long height ) {
setSide( height );
}
public long getHeight () {
return getSide();
}
public long getSide () {
return side;
}
public void setSide ( long side ) {
this .side = side ;
}
}
public class SmartTest {// 測試
public void resize (Rectangle r ) {
while ( r .getHeight() <= r .getWidth()) {// 正方形會出問題
r .setWidth( r .getWidth() + 1);
}
}
}
這樣,只要 width 或 height 被賦值,那 么 width 和 height 會同時被賦值,從而使長形的和寬總是相等。這看上去正方形確實是一種長方形,但是如果將 Square 傳進 SmartTest 的 resize 時,應用到這里將會出問題,正方形運行時會出現計算溢出的問題。原因就是正方形沒有高與寬之分,一旦需求要區分高與寬時,就會出現這種設計上的錯誤,很顯示從這個需求點出發就會發現它們根本不是父子關系。換言這,里氏代換原則被破壞了,因此 Square 不應當做為 Rectangle 的子類。
再次重構
public interface Quadrangle{// 形狀,只抽取出兩讀的方法
public long getWidth();
public long getHeight();
}
public class Rectangle implements Quadrangle {// 長方形
private long width;
private long height;
public void setWidth(long width) {
this.width = width;
}
public long getWidth(){
return this.width;
}
public void setHeight(long height){
this.height = height;
}
public long getHeight(){
return this.height;
}
}
public class Square implements Quadrangle {// 正方形
private long side;
public void setSide(long side){
this.side = side;
}
public long getSide(){
return side;
}
public long getWidth(){
return getSide();
}
public long getHeight(){
return getSide();
}
}
這樣 SmartTest 就不可能破壞了。那么破壞里氏代換的問題在這里是怎樣避免的呢?關鍵在於基類 Quadrangle 沒有賦值方法,因此 Quadrangle 不可能用於 SmartTest ,而只能是 Rectangle ,因此里氏代換不可能被破壞。
忠告
如果子類不能完整地實現父類的業務(如上面需要完成調整長方形的寬到長時,正方形就難以完成),或者父類的某些方法在子類中已經發生“畸變”,則建議斷開父了關系,采用依賴、關聯或將兩者抽取出共同的接口來代替原來的繼承。
依賴倒轉原則( DIP )
定義
Dependency inversion principle :
1 、 高層模塊不應該依賴底層模塊,兩者都應該依賴於抽象層 。
2 、抽象(接口或抽象類)不應該依賴於細節(實現)。
3 、(實現)細節應該依賴於抽象(接口或抽象類)。
最精簡的定義就是: 針對接口編程,不要針對實現編程 。
針對接口編程的意思就是說,應當使用接口和抽象類進行變量的類型聲明、參數的類型聲明、方法的返回類型聲明,以及數據類型轉換等;不要針對實現編程的意思是說,不應當使用具體類進行變量的類型聲明、參數的類型聲明、方法的返回類型聲明,以及數據類型轉換等。要保證這一點,一個具體類應當只實現接口和抽象類中聲明過的方法,而不應當給出多余的方法。
抽象是穩定的,細節是變化的,所以我們應該依賴於抽象。
什么是“倒轉”
什么是“倒轉”,要想理解它,我們看看正置吧。依賴正置就是類間的依賴是實實在在的實現類間的依賴,也就是 面向實現編程,這也是正常人的思維方式 ,我要開奔馳就依賴奔馳,要使用蘋果筆記本就使用蘋果筆記本。而編寫程序需要的是對現實世界的事物進行抽象,抽象的結果就是有了抽象類和接口,比如寶馬與奔馳抽象成小汽車,蘋果筆記本與 IBM 筆記本抽象成筆記本,然后我們的程序就依賴於這些抽象,這代替了人們傳統思維中的具體事物間的依賴,“倒轉”就是從這里產生的。用圖來表示就是這樣的:
“倒轉”另類理解
在以往我們開發時,為了使常用代碼可以復用,一般都會把常用代碼寫成函數庫,這樣我們在做新項目時,去調用這些封裝好的底層函數就可以了。比如項目大多數要訪問數據庫,所以我們就把訪問數據庫的代碼寫成了函數,封裝成方法,每次做新項目時就去調用這些函數。這也就叫做高層模式依賴底層模式。這樣問題就可能會出現,有時客戶希望使用不同的數據庫或存儲方式(如 Hibernate , jdbc ),這時就出現麻煩了。我們希望再次復用這些高層模塊,但高層模式都與低層的訪問數據庫綁定在一起了,沒辦法復用這些高層模塊,這是非常糟糕的。而如果不管高層還是低層模塊,它們都依賴於抽象,具體一點就是接口或抽象類,只要接口是穩定的,那么實現的修改不會影響到其他的模塊,這就使得無論高層模塊還是底層模塊都可以很容易地被復用。
這里再舉個易懂的例子:電腦的主板與插在上面的內存、 CPU 、硬盤、顯卡、網卡等,主板就像是這里的底層模塊,而內存、 CPU 、硬盤、顯卡、網卡等就是高層模塊,它們都是以插槽連接的,不論壞了主板還是內存、 CPU 、硬盤、顯卡、網卡,都沒有任何關系,把壞了部件換下來就可以了,只在各個廠商生產的部件都遵循標准插槽。所以這里的插槽就像 Java 里的接口或抽象類一樣。
面向對象設計的標志
依賴倒轉其實可以說是面向對象設計的標志,用哪種語言來編寫程序不重要,如果編寫時考慮的都是如何針對抽象編程而不是針對細節編程,即程序中所有的依賴關系都是終止於抽象類或者是接口,那就是面向對象的設計,反之那就是過程化的設計了 。
三種耦合關系
依賴即耦合。依賴倒轉原則指類與類之間是通過接口或抽象類來耦合。
1、 零耦合:兩個類沒有耦合關系
2、 具體耦合:具體耦合發生在兩個具體的類之間
3、 抽象耦合:抽象耦合關系發生在一個具體和一個抽象類(或者是 Java 接口)之間
示例:張三開奔馳,就可以開寶馬
public class Driver {// 司機
public void drive(Benz benz){
benz.run();
}
}
public class Benz {// 奔馳
public void run(){
System.out.println(" 奔馳汽車開始運行 ...");
}
}
public class Client {// 場景
public static void main(String[] args) {
Driver zhangSan = new Driver();
Benz benz = new Benz();
// 張三開奔馳車
zhangSan.drive(benz);
}
}
上面的程序好好的,運行時沒有問題。但將來某天張三要換一輛寶馬怎么辦?除非重寫 Driver 的 drive 方法。由於司機過度依賴於實現(具體就是某個品牌的汽車),這就導致了后期需求變化后難以修改的問題。
現在我們將奔馳與寶馬抽象成汽車,當然司機也最好抽象一把也應對可能的變化:
public interface IDriver {// 司機接口
// 是司機就應該會駕駛汽車
public void drive(ICar car);
}
public interface ICar {// 汽車接口
// 是汽車就應該能跑
public void run();
}
public class Driver implements IDriver{// 司機
// 司機的主要職責就是駕駛汽車
public void drive(ICar car){
car.run();
}
}
public class Benz implements ICar{// 奔馳
// 汽車肯定會跑
public void run(){
System.out.println(" 奔馳汽車開始運行 ...");
}
}
現在張三可以開寶馬了:
public class Client {
public static void main(String[] args) {
IDriver zhangSan = new Driver();
//ICar benz = new Benz();
ICar bmw = new BMW();
// 張三開寶馬
zhangSan.drive(bmw);
}
}
組合 / 聚合復用原則( CARP )
組合 / 聚合復用原則( Composition/Aggregation Reuse Principle )經常又叫合成復用原則( Composition Reuse Principle 或 CRP )。綜是在一個新的對象里使用已有的對象,使之成為新對象的一部分,新的對象通過向這些對象的委派達到復用已有功能的目的。
該原則另一個簡短的表述:盡量使用組合 / 聚合,不要使用繼承。
組合 / 聚合區別
聚合表示一種弱的“擁有”關系,體現的是 A 對象可以包含 B 對象,但 B 對象不是 A 對象的一部分;
合成則是一種強的“擁有”關系,體現了嚴格的部分和整體的關系,部分和整體的生命周期一樣,一般一個合成的多重性不能超過 1 ,換言這,一個合成關系中的成分對象是不能與另一個合成關系共享的。一個成分對象在同一個時間內只能屬於一個合成關系。
組合 / 聚合復用與繼承復用的區別
有兩種復用的方式:組合 / 聚合復用或繼承復用。
組合 / 聚合復用方式可以在運行期內動態的改變,具有很好的靈活性。
繼承是在編譯時就發生了的,所以無法在運行時改變,沒有足夠的靈活性。
由於子類會繼承父類所有非私有的東西(好比愛她就接受她的一切),如果繼承下來的實現不適合解決新的問題,則子類必須重寫,這樣最終還是限制了復用。
繼承會破壞封裝特性,因為繼承將超類的實現細節暴露給了子類。
如果超類的實現發生改變時,那么子類的實現也會跟着發生改變。
與里氏代換原則區別
“ Is-A ”是嚴格的分類學意義上的定義,意思是一個類是另一個類的“一種”。而“ Has-A ”則不同,它表示某個角色擁有某一項責任。
里氏代換原則表述的是“ Is-A ”關系,而組合 / 聚合復用表述的是“ Has-A ”關系。
類庫中的反例
類庫有幾個明顯違反組合 / 聚合復用原則的例子,其中著名的就是 Stack 和 Properties ,一個 Stack 不是一個 Vector ,所以 Stack 應該設計成 Vector 的子類。同樣, Properties 與不是一個 Hashtable 。
由於 Properties 是 Hashtable 的子類,因此,客戶端可以直接使用超類的行為。但是客戶端完全可以通過 Hashtable 提供的行為加入任意類型的健和值,繞過 Properties 的接口,並導致 Properties 的內部矛盾和崩潰。這樣看來, Properties 其實僅僅是有一些 Hashtable 的屬性罷了,換言這,這是一個“ Has-A ”的關系,而不是一個“ Is-A ”。
迪米特法則( LOD )
迪米特法則( Law of Demeter )也稱最少知識原則( Least Knowledge Principle,LKP ),就是說,一個對象應該對象其他對象有最少的了解。
各種不同的表述:
1、 只與直接的朋友通信。
2、 不要跟“陌生人”說話。
3、 每個軟件單位對其他的單位都只有最少的知識,而且局限於那些與本單位密切相關的軟件單位。
4、 我的知識(實現細節)你知道得越少越好。
狹義的迪米特法則
狹義的迪米特則要求一個對象僅僅與其朋友發生相互作用。
如果兩個類不必彼此直接通信,那么這兩個類就不應當發生直接的相互作用。如果其中的一個類需要調用另一個類的某個方法的話,可以通過第三者轉換。
朋友類的定義
1、 當前對象本身( this )
2、 方法的輸入輸出參數中的類
3、 成員變量的直接引用對象
4、 集合成員中的元素類
5、 當前對象所創建的對象
不滿足迪米特法則的系統
Someone 與 Friend 是朋友,而 Friend 與 Stranger 是朋友,從類圖也可以清楚的看到這一點:
public class Someone {
//Friend 出現在參數中,是朋友
public void operation1 (Friend friend ){
// Stranger 出現在方法,但不是自己創建的,所以為陌生人
Stranger stranger = friend .provide();
stranger.operation3();
}
}
public class Friend {
//Stranger 為成員,所以是朋友
private Stranger stranger = new Stranger();
public void operation2 (){}
public Stranger provide (){
return stranger;
}
}
顯然, Someone 的方法 operation1() 不滿足迪米特法則。為什么呢?因為這個方法引用了 Stranger 對象,而 Stranger 對象不是 Someone 的朋友。
使用迪米特法則進行重構
public class Someone {
public void operation1 (Friend friend ){
// 不與陌生人說話,通過朋友轉換
friend .forward();
}
}
public class Friend {
private Stranger stranger = new Stranger();
public void operation2 (){}
public void forward (){
stranger.operation3();// 調用朋友的方法
}
}
這樣一來,使得系統內部的耦合度降低,在系統的某個類需要修改時,僅僅會直接影響到這個類的“朋友”們,而不會直接影響到其余的類。
狹義的迪米特法則的缺點
會在系統里產生大量的小方法,散落在系統的各個角落,這些方法僅僅是轉發的調用,因此與系統的業務邏輯無關。
與依賴倒轉原則互補使用
為了克服狹義的迪米特法則的缺點,可以使用依賴倒轉原則將陌生人抽象出來,使它與抽象類發生耦合:
“某人”現在與一個抽象角色建立了朋友關系,這新的好處是“朋友”可以隨時將具體“陌生人”換掉。只要新的具體“陌生人”具有相同的抽象類型,那么“某人”就無法區分他們,這就允許“隨行人”的具體要實現可以獨立於“某人”而變化了。
這樣個人認為只是從某種意義上降低耦合,並沒有完全消除陌生人。但這樣又有一個好外就是減少了大量與業務邏輯無關的小的跳轉方法
迪米特法則與模式
門面模式、調停模式都用到了迪米特法則
廣義的迪米特法則
廣義的迪米特法則是指對信息的隱藏,即封裝。在設計時需要注意以下幾點:
1、 在類的划分上,應當創建有弱耦合的類。類之間的耦合越弱,就越有利於復用。
2、 在類的結構設計上,每個類都以是降低訪問權限,不要公開自己的屬性,而是提供訪問方法。
3、 在類的設計上,只要有可能,一個類應當設計成不變類。
4、 在對其他類的引用,一個對象對其對象的引用應當降到最低。
廣義的迪米特法則在類設計上的體現
1、 優先考慮將一個類設置成不變類。
2、 盡量降低一個類的訪問權限。默認的包訪問權限類只能從當前類庫中訪問,我們可以自由地刪除一個包私有的類,受到影響的客戶端必定都在這個庫內部,但 public 類一旦發布,則不刪除。
3、 謹慎使用 Serializable 。濫用會導致版本不兼容,另外會引起安全漏洞。
4、 盡量降低成員的訪問權限。盡量將成員設計成 private 的或 package-private 。將一個方法從 private 或 package-private 改成 protected 或 public ,意味着它的訪問權限有了巨大的變化。一旦一個方法被設置成為 protected ,這個方法就可以被位於另一個庫(客戶端所在的庫)中的子類訪問;如果設置成 public ,那么就會被所有的類訪問。這對於提供商來說,需要承諾不能改變這個方法的特征。
5、 取代 C Struct 。不要聲明像 C 中的 Struct 的類:
public calss Point{
public int x;
public int y;
}
這是一個退化了的類,因為這個類沒有提供數據的封裝,這種設計是錯誤的,因為一旦某天數據結構發生變化,它將不能修改,沒有演化的空間了,而最好的做法是將它們設計成 private 的后再提供相應的訪問方法。
廣義的迪米特法則在代碼層次上的實現
限制局部變量的有效范圍。在需要一個變量的時候才聲明它,可以有效地限制局部變量的有效范圍。
迪米特法則對類的耦合提出了明確的要求,其包含以下幾點含義:
1、 只和朋友交流,不要跟陌生人說話(減少對其他類的耦合,不是什么人都能做你的朋友)
實例:老師叫體育委員清(親)一下全班女生。
public class Teacher {// 老師
// 老師給體育委員發布命令:清一下女生
public void commond(GroupLeader groupLeader){// 這里 GroupLeader 是唯一的朋友
//List 是陌生人,但屬於調用類庫,這是編程最基本的要求
List<Girl> listGirls = new ArrayList();//Girl 是陌生人,不應該與她交流
// 初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
// 上面的細節完全不用關注
// 告訴體育委員開始執行清查任務
groupLeader.countGirls(listGirls);
}
}
public class GroupLeader {// 體育委員
// 有清查女生的工作
public void countGirls(List<Girl> listGirls){
System.out.println(" 女生數量是: "+listGirls.size());
}
}
public class Girl {// 女生 }
public class Client {// 場景
public static void main(String[] args) {
Teacher teacher= new Teacher();
// 老師發布命令
teacher.commond(new GroupLeader());
}
}
下面去掉 Teacher 對 Girl 的依賴關系:
源碼修改如下:
public class Teacher {// 老師
// 老師對學生發布命令 , 清一下女生
public void commond(GroupLeader groupLeader){
// 告訴體育委員開始執行清查任務
groupLeader.countGirls();
}
}
public class GroupLeader {// 體育委員
private List<Girl> listGirls;
// 傳遞全班的女生進來
public GroupLeader(List<Girl> _listGirls){
this.listGirls = _listGirls;
}
// 有清查女生的工作
public void countGirls(){
System.out.println(" 女生數量是: "+this.listGirls.size());
}
}
public class Client {// 場景類
public static void main(String[] args) {
// 產生一個女生群體
List<Girl> listGirls = new ArrayList<Girl>();
// 初始化女生
for(int i=0;i<20;i++){
listGirls.add(new Girl());
}
Teacher teacher= new Teacher();
// 老師發布命令
teacher.commond(new GroupLeader(listGirls));
}
}
類與類之間的關系是建立在類之間的,而不是在方法中,因此一個方法盡量不引入非本類朋友的類,當然, JDK API 提供的類除外。
2、 朋友之間要保持一定的距離(減少與朋友類的過度耦合)
實例:軟件安裝向導。
// 按照步驟執行的業務邏輯類
public class Wizard {
private Random rand = new Random(System.currentTimeMillis());
// 第一步
public int first(){
System.out.println(" 執行第一個方法 ...");
return rand.nextInt(100);
}
// 第二步
public int second(){
System.out.println(" 執行第二個方法 ...");
return rand.nextInt(100);
}
// 第三個方法
public int third(){
System.out.println(" 執行第三個方法 ...");
return rand.nextInt(100);
}
}
// 業務組裝類,負責調用各個步驟
public class InstallSoftware {
public void installWizard(Wizard wizard){
int first = wizard.first(); // 調用第一步
// 根據 first 返回的結果,看是否需要執行 second
if(first>50){
int second = wizard.second();// 如果第一步成員,進行第二步安裝
if(second>50){
int third = wizard.third();// 如果第一步成員,進行第三步安裝
}
}
}
}
分析: Wizard 把太多的方法暴露給 InstallSoftware 類,兩者的朋友關系太近了,耦合關系變得非常緊。如果要將 Wizard 類中的 first 方法返回值的類型則 int 改為 boolean ,就需要修改 InstallSoftware 類,從而把修改變更的風險擴散開了。下面我們對它進行重構,只提供了個 public 方法 installWizard ,由這個方法實現安裝步驟調用的細節, InstallSoftware 只需直接調用 Wizard 的 installWizard 方法即可,重構后的類圖:
一個類公開的 public 屬性或方法越多,修改時涉及的面積也就越大,變更引起的風險擴散也就越大。因此我們在定義方法與變量時,盡量首先定義為 privte 類型,如果不行再開始一級級向上開放。
盡量不要對象外公布太多的 public 方法和非靜態的 public 變量,盡量內斂,多使用 private 、 package-private 、 protected 訪問權限。這樣提高類的內部高內聚性,同時降低了類之間的耦合性。
3、 是自己的就是自己的
在實際的應用會經常出現這樣的一個方法:放在本類也可,放在其他類中也沒有錯,那怎么決定呢?原則:如果一個方法入在本類中后,並沒有增加與其他類間的依賴關系,也對本類沒產生負面影響,就放在本類中吧。
最佳實踐:
迪米特法則的核心觀念就是類間解耦,弱耦合,只有弱耦合以后,類的復用率才可以提高。其要求的結果就是產生了大量的中轉或跳轉類,導致系統的復雜性抽提高,同時降低了可維護性。所以用進一定要衡量,既做到讓結構清晰,又要做到高內聚低耦合。
如果一個類跳轉兩次以上才能訪問到另一個類,就需要想辦法進行重構了,為什么是兩次以上呢?因為一個系統的成功不僅僅是一個標准或是原則就能決定的,有非常多的外在因素決定,跳轉次數越多,系統越復雜,所以只要跳轉不超過兩次都是可以忍受的,這需要具體問題具體分析。
不遵守原則是不對的,嚴格執行就是“過猶不及”。
顯然,遵守接口隔離原則與迪類特法則,會使一個軟件系統在功能擴展的過程當中,不會將修改的壓力傳遞到其他的對象。
接口隔離原則( ISP )
接口隔離原則講的是:使用多個專門的接口比使用一個總的接口要好。換言這,從一個客戶類角度來看,一個類對另外一個類的依賴性應當是建立的在最小的接口之上。
定義
Interface Segregation principle :應當為客戶端提供盡可能小的單獨的接口,而不要提供大的總接口,即提供職責單一的接口。
1、 客戶端不應該被強迫去依賴於它們不需要的接口 。
2、 類間的 依賴關系應該建立在最小的接口上 。
第一點講的是,客戶端需要什么接口我們就提供什么接口,把不需要的接口剔除掉,那就需要對接口進行細化,保證其純潔性。第二點其實與第一點表達的主旨是一樣的,只是一個事物的兩種不同描述。
與單一職責原則的不同
上面兩點可以概括為一句話:建立專門的接口,不要建立臃腫龐大的接口,或更進一步講: 接口盡量細化,同時接口中的方法盡量少 。這與單一職責原則不是相同嗎?錯,接口隔離原則與單一職責原則的審視角度是不相同的, 單一職責要求的是類和接口的職責單一,即功能單一,注重的是“功能”,它是從功能上的划分;而接口隔離原則是從“服務”的角度來看的,它要求的是“服務”專一,是從服務的角度來划分的 。
假如現在有很多個個方法,包括 m1 、 m2 、 p1 、 p2 等等,它們都是完成同一功能職責,所以 Service 接口從職責的角度來看是允許的。但是 Client1 現在只需要或只允許使用 m1 、 p2 兩個方法, Client2 只需要或只允許使用 m2 、 p2 兩個方法, Client3 只需要或只允許使用 m1 、 p1 、 p2 ,那么我們就不應該將 Service 接口提供給客戶端,而需要創建專門的接口來為它們提供專一的服務如下圖如示:
上圖中如果 Service 提給不同的客戶端時,由於提供的扣口龐大臃腫,會造成接口污染。
接口是一個系統里的業務表現,只有深入了解業務邏輯,最好的接口自然會出自你的手。
根據經驗和常識決定接口的粒度大小,接口粒度太小,導致接口數據劇增,太大,靈活性降低,無法提供定制服務。