相關文章連接
難免的尷尬:代碼依賴
在浩瀚的代碼世界中,有着無數的對象,跟人和人之間有社交關系一樣,對象跟對象之間也避免不了接觸,所謂接觸,就是指一個對象要使用到另外對象的屬性、方法等成員。現實生活中一個人的社交關系復雜可能並不是什么不好的事情,然而對於代碼中的對象而言,復雜的"社交關系"往往是不提倡的,因為對象之間的關聯性越大,意味着代碼改動一處,影響的范圍就會越大,而這完全不利於系統重構和后期維護。所以在現代軟件開發過程中,我們應該遵循"盡量降低代碼依賴"的原則,所謂盡量,就已經說明代碼依賴不可避免。
有時候一味地追求"降低代碼依賴"反而會使系統更加復雜,我們必須在"降低代碼依賴"和"增加系統設計復雜性"之間找到一個平衡點,而不應該去盲目追求"六人定理"那種設計境界。
注:"六人定理"指:任何兩個人之間的關系帶,基本確定在六個人左右。兩個陌生人之間,可以通過六個人來建立聯系,此為六人定律,也稱作六人法則。
12.1 從面向對象開始
在計算機科技發展歷史中,編程的方式一直都是趨向於簡單化、人性化,"面向對象編程"正是歷史發展某一階段的產物,它的出現不僅是為了提高軟件開發的效率,還符合人們對代碼世界和真實世界的統一認識觀。當說到"面向對象",出現在我們腦海中的詞無非是:類,抽閑,封裝,繼承以及多態,本節將從對象基礎、對象擴展以及對象行為三個方面對"面向對象"做出解釋。
注:面向對象中的"面向"二字意指:在代碼世界中,我們應該將任何東西都看做成一個封閉的單元,這個單元就是"對象"。對象不僅僅可以代表一個可以看得見摸得着的物體,它還可以代表一個抽象過程,從理論上講,任何具體的、抽象的事物都可以定義成一個對象。
12.1.1 對象基礎:封裝
和現實世界一樣,無論從微觀上還是宏觀上看,這個世界均是由許許多多的單個獨立物體組成,小到人、器官、細胞,大到國家、星球、宇宙, 每個獨立單元都有自己的屬性和行為。仿照現實世界,我們將代碼中有關聯性的數據與操作合並起來形成一個整體,之后在代碼中數據和操作均是以一個整體出現,這個過程稱為"封裝"。封裝是面向對象的基礎,有了封裝,才會有整體的概念。

圖12-1 封裝前后
如上圖12-1所示,圖中左邊部分為封裝之前,數據和操作數據的方法沒有相互對應關系,方法可以訪問到任何一個數據,每個數據沒有訪問限制,顯得雜亂無章;圖中右邊部分為封裝之后,數據與之關聯的方法形成了一個整體單元,我們稱為"對象",對象中的方法操作同一對象的數據,數據之間有了"保護"邊界。外界可以通過對象暴露在外的接口訪問對象,比如給它發送消息。
通常情況下,用於保存對象數據的有字段和屬性,字段一般設為私有訪問權限,只准對象內部的方法訪問,而屬性一般設為公開訪問權限,供外界訪問。方法就是對象的表現行為,分為私有訪問權限和公開訪問權限兩類,前者只准對象內部訪問,而后者允許外界訪問。
1 //Code 12-1 2 class Student //NO.1 3 { 4 private string _name; //NO.2 5 private int _age; 6 private string _hobby; 7 public string Name //NO.3 8 { 9 get 10 { 11 return _name; 12 } 13 } 14 public int Age 15 { 16 get 17 { 18 return _age; 19 } 20 set 21 { 22 if(value<=0) 23 { 24 value=1; 25 } 26 _age = value; 27 } 28 } 29 public string Hobby 30 { 31 get 32 { 33 return _hobby; 34 } 35 set 36 { 37 _hobby = value; 38 } 39 } 40 public Student(string name,int age,string hobby) 41 { 42 _name = name; 43 _age = age; 44 _hobby = hobby; 45 } 46 public void SayHello() //NO.4 47 { 48 Console.WriteLine(GetSayHelloWords()); 49 } 50 protected virtual string GetSayHelloWords() //NO.5 51 { 52 string s = ""; 53 s += "hello,my name is " + _name + ",\r\n", 54 s += "I am "+_age + "years old," + "\r\n"; 55 s += "I like "+_hobby + ",thanks\r\n"; 56 return s; 57 } 58 }
上面代碼Code 12-1將學生這個人群定義成了一個Student類(NO.1處),它包含三個字段:分別為保存姓名的_name、保存年齡的_age以及保存愛好的_hobby字段,這三個字段都是私有訪問權限,為了方便外界訪問內部的數據,又分別定義了三個屬性:分別為訪問姓名的Name,注意該屬性是只讀的,因為正常情況下姓名不能再被外界改變;訪問年齡的Age,注意當給年齡賦值小於等於0時,代碼自動將其設置為1;訪問愛好的Hobby,外界可以通過該屬性對_hobby字段進行完全訪問。同時Student類包含兩個方法,一個公開的SyaHello()方法和一個受保護的GetSayHelloWords()方法,前者負責輸出對象自己的"介紹信息",后者負責格式化"介紹信息"的字符串。Student類圖見圖12-2:

圖12-2 Student類圖
注:上文中將類的成員訪問權限只分為兩個部分,一個對外界可見,包括public;另一種對外界不可見,包括private、protected等。
注意類與對象的區別,如果說對象是代碼世界對現實世界中各種事物的一一映射,那么類就是這些映射的模板,通過模板創建具體的映射實例:

圖12-3 對象實例化
我們可以看到代碼Code 12-1中的Student類既包含私有成員也包含公開成員,私有成員對外界不可見,外界如需訪問對象,只能調用給出的公開方法。這樣做的目的就是將外界不必要了解的信息隱藏起來,對外只提供簡單的、易懂的、穩定的公開接口即可方便外界對該類型的使用,同時也避免了外界對對象內部數據不必要的修改和訪問所造成的異常。
封裝的准則:
封裝是面向對象的第一步,有了封裝,才會有類、對象,再才能談繼承、多態等。經過前人豐富的實踐和總結,對封裝有以下准則,我們在平時實際開發中應該盡量遵循這些准則:
1)一個類型應該盡可能少地暴露自己的內部信息,將細節的部分隱藏起來,只對外公開必要的穩定的接口;同理,一個類型應該盡可能少地了解其它類型,這就是常說的"迪米特法則(Law of Demeter)",迪米特法則又被稱作"最小知識原則",它強調一個類型應該盡可能少地知道其它類型的內部實現,它是降低代碼依賴的一個重要指導思想,詳見本章后續介紹;
2)理論上,一個類型的內部代碼可以任意改變,而不應該影響對外公開的接口。這就要求我們將"善變"的部分隱藏到類型內部,對外公開的一定是相對穩定的;
3)封裝並不單指代碼層面上,如類型中的字段、屬性以及方法等,更多的時候,我們可以將其應用到系統結構層面上,一個模塊乃至系統,也應該只對外提供穩定的、易用的接口,而將具體實現細節隱藏在系統內部。
封裝的意義:
封裝不僅能夠方便對代碼對數據的統一管理,它還有以下意義:
1)封裝隱藏了類型的具體實現細節,保證了代碼安全性和穩定性;
2)封裝對外界只提供穩定的、易用的接口,外部使用者不需要過多地了解代碼實現原理也不需要掌握復雜難懂的調用邏輯,就能夠很好地使用類型;
3)封裝保證了代碼模塊化,提高了代碼復用率並確保了系統功能的分離。
12.1.2 對象擴展:繼承
封裝強調代碼合並,封裝的結果就是創建一個個獨立的包裝件:類。那么我們有沒有其它的方法去創建新的包裝件呢?
在現實生活中,一種物體往往衍生自另外一種物體,所謂衍生,是指衍生體在具備被衍生體的屬性基礎上,還具備其它額外的特性,被衍生體往往更抽象,而衍生體則更具體,如大學衍生自學校,因為大學具備學校的特點,但大學又比學校具體,人衍生自生物,因為人具備生物的特點,但人又比生物具體。

圖12-4 學校衍生圖
如上圖12-4,學校相對來講最抽象,大學、高中以及小學均可以衍生自學校,進一步來看,大學其實也比較抽象,因為大學還可以有具體的本科、專科,因此本科和專科可以衍生自大學,當然,抽象和具體的概念是相對的,如果你覺得本科還不夠具體,那么它可以再衍生出來一本、二本以及三本。
在代碼世界中,也存在"衍生"這一說,從一個較抽象的類型衍生出一個較具體的類型,我們稱"后者派生自前者",如果A類型派生自B類型,那么稱這個過程為"繼承",A稱之為"派生類",B則稱之為"基類"。
注:派生類又被形象地稱為"子類",基類又被形象地稱為"父類"。
在代碼12-1中的Student類基礎上,如果我們需要創建一個大學生(College_Student)的類型,那么我們完全可以從Student類派生出一個新的大學生類,因為大學生具備學生的特點,但又比學生更具體:
1 //Code 12-2 2 class College_Student:Student //NO.1 3 { 4 private string _major; 5 public string Major 6 { 7 get 8 { 9 return _major; 10 } 11 set 12 { 13 _major = value; 14 } 15 } 16 public College_Student(string name,int age,string hobby,string major) :base(name,age,hobby) //NO.2 17 { 18 _major = major; 19 } 20 protected override string GetSayHelloWords() //NO.3 21 { 22 string s = ""; 23 s += "hello,my name is " + Name + ",\r\n", 24 s += "I am "+ Age + "years old, and my major is " + _major + ",\r\n"; 25 s += "I like "+ Hobby + ", thanks\r\n"; 26 return s; 27 } 28 }
如上代碼Code 12-2所示,College_Student類繼承Student類(NO.1處),College_Student類具備Student類的屬性,比如Name、Age以及Hobby,同時College_Student類還增加了額外的專業(Major)屬性,通過在派生類中重寫GetSyaHelloWords()方法,我們重新格式化"個人信息"字符串,讓其包含"專業"的信息(NO.3處),最后,調用College_Student中從基類繼承下來的SayHello()方法,便可以輕松輸出自己的個人信息。
我們看到,派生類通過繼承獲得了基類的全部信息,之外,派生類還可以增加新的內容(如College_Student類中新增的Major屬性),基類到派生類是一個抽象到具體的過程,因此,我們在設計類型的時候,經常將通用部分提取出來,形成一個基類,以后所有與基類有種族關系的類型均可以繼承該基類,以基類為基礎,增加自己特有的屬性。

圖12-5 College_Student類繼承圖
有的時候,一種類型只用於其它類型派生,從來不需要創建它的某個具體對象實例,這樣的類高度抽象化,我們稱這種類為"抽象類",抽象類不負責創建具體的對象實例,它包含了派生類型的共同成分。除了通過繼承某個類型來創建新的類型,.NET中還提供另外一種類似的創建新類型的方式:接口實現。接口定義了一組方法,所有實現了該接口的類型必須實現接口中所有的方法:
1 //Code 12-3 2 interface IWalkable 3 { 4 void Walk(); 5 } 6 class People:IWalkable 7 { 8 //… 9 public void Walk() 10 { 11 Console.WriteLine("walk quickly"); 12 } 13 } 14 class Dog:IWalkable 15 { 16 //… 17 public void Walk() 18 { 19 Console.WriteLine("walk slowly"); 20 } 21 }
如上代碼Code 12-3所示,People和Dog類型均實現了IWalkable接口,那么它們必須都實現IWalkable接口中的Walk()方法,見下圖12-6:

圖12-6 接口繼承
繼承包括兩種方式,一種為"類繼承",一種為"接口繼承",它們的作用類似,都是在現有類型基礎上創建出新的類型,但是它們也有區別:
1)類繼承強調了族群關系,而接口繼承強調通用功能。類繼承中的基類和派生類屬於祖宗和子孫的關系,而接口繼承中的接口和實現了接口的類型並沒有這種關系。
2)類繼承強調"我是(Is-A)"的關系,派生類"是"基類(注意這里的"是"代表派生類具備基類的特性),而接口繼承強調"我能做(Can-Do)"的關系,實現了接口的類型具有接口中規定的行為能力(因此接口在命名時均以"able"作為后綴)。
3)類繼承中,基類雖然較抽象,但是它可以有具體的實現,比如方法、屬性的實現,而接口繼承中,接口不允許有任何的具體實現。
繼承的准則:
繼承是面向對象編程中創建類型的一種方式,在封裝的基礎上,它能夠減少工作量、提高代碼復用率的同時,快速地創建出具有相似性的類型。在使用繼承時,請遵循以下准則:
1)嚴格遵守"里氏替換原則",即基類出現的地方,派生類一定可以出現,因此,不要盲目地去使用繼承,如果兩個類沒有衍生的關系,那么就不應該有繼承關系。如果讓貓(Cat)類派生自狗(Dog)類,那么很容易就可以看到,狗類出現的地方,貓類不一定可以代替它出現,因為它兩根本就沒有抽象和具體的層次關系。
2)由於派生類會繼承基類的全部內容,所以要嚴格控制好類型的繼承層次,不然派生類的體積會越來越大。另外,基類的修改必然會影響到派生類,繼承層次太多不易管理,繼承是增加耦合的最重要因素。
3)繼承強調類型之間的通性,而非特性。因此我們一般將類型都具有的部分提取出來,形成一個基類(抽象類)或者接口。
12.1.3 對象行為:多態
"多態"一詞來源於生物學,本意是指地球上的所有生物體現出形態和狀態的多樣性。在面向對象編程中多態是指:同一操作作用於不同類的實例,將產生不同的執行結果,即不同類的對象收到相同的消息時,得到不同的結果。
多態強調面向對象編程中,對象的多種表現行為,見下代碼Code 12-4:
1 //Code 12-4 2 class Student //NO.1 3 { 4 public void IntroduceMyself() 5 { 6 SayHello(); 7 } 8 protected virtual void SayHello() 9 { 10 Console.WriteLine("Hello,everyone!"); 11 } 12 } 13 class College_Student:Student //NO.2 14 { 15 protected override void SayHello() 16 { 17 base.SayHello(); 18 Console.WriteLine("I am a college student…"); 19 } 20 } 21 class Senior_HighSchool_Student:Student //NO.3 22 { 23 protected override void SayHello() 24 { 25 base.SayHello(); 26 Console.WriteLine("I am a senior high school student…"); 27 } 28 } 29 class Program 30 { 31 static void Main() 32 { 33 Console.Title = "SayHello"; 34 Student student = new Student(); 35 student.IntroduceMyself(); //NO.4 36 student = new College_Student(); 37 student.IntroduceMyself(); //NO.5 38 student = new Senior_HighSchool_Student(); 39 student.IntroduceMyself(); //NO.6 40 Console.Read(); 41 } 42 }
如上代碼Code 12-4所示,分別定義了三個類:Student(NO.1處)、College_Student(NO.2處)、Senior_HighSchool_Student(NO.3處),后面兩個類繼承自Student類,並重寫了SayHello()方法。在客戶端代碼中,對於同一行代碼"student.IntroduceMyself();"而言,三次調用(NO.4、NO.5以及NO.6處),屏幕輸出的結果卻不相同:

圖12-7 多態效果
如上圖12-7所示,三次調用同一個方法,不同對象有不同的表現行為,我們稱之為"對象的多態性"。從代碼Code 12-4中可以看出,之所以出現同樣的調用會產生不同的表現行為,是因為給基類引用student賦值了不同的派生類對象,並且派生類中重寫了SayHello()虛方法。
對象的多態性是以"繼承"為前提的,而繼承又分為"類繼承"和"接口繼承"兩類,那么多態性也有兩種形式:
1)類繼承式多態;
類繼承式多態需要虛方法的參與,正如代碼Code 12-4中那樣,派生類在必要時,必須重寫基類的虛方法,最后使用基類引用調用各種派生類對象的方法,達到多種表現行為的效果:
2)接口繼承式多態。
接口繼承式多態不需要虛方法的參與,在代碼Code 12-3的基礎上編寫如下代碼:
1 //Code 12-5 2 class Program 3 { 4 static void Main() 5 { 6 Console.Title = "Walk"; 7 IWalkable iw = new People(); 8 iw.Walk(); //NO.1 9 iw = new Dog(); 10 iw.Walk(); //NO.2 11 Console.Read(); 12 } 13 }
如上代碼Code 12-5所示,對於同一行代碼"iw.Walk();"的兩次調用(NO.1和NO.2處),有不同的表現行為:

圖12-8 接口繼承式多態
在面向對象編程中,多態的前提是繼承,而繼承的前提是封裝,三者缺一不可。多態也是是降低代碼依賴的有力保障,詳見本章后續有關內容。
12.2 不可避免的代碼依賴
本書前面章節曾介紹過,程序的執行過程就是方法的調用過程,有方法調用,必然會促使對象跟對象之間產生依賴,除非一個對象不參與程序的運行,這樣的對象就像一座孤島,與其它對象沒有任何交互,但是這樣的對象也就沒有任何存在價值。因此,在我們的程序代碼中,任何一個對象必然會與其它一個甚至更多個對象產生依賴關系。
12.2.1 依賴存在的原因
"方法調用"是最常見產生依賴的原因,一個對象與其它對象必然會通信(除非我們把所有的代碼邏輯全部寫在了這個對象內部),通信通常情況下就意味着有方法的調用,有方法的調用就意味着這兩個對象之間存在依賴關系(至少要有其它對象的引用才能調用方法),另外常見的一種產生依賴的原因是:繼承,沒錯,繼承雖然給我們帶來了非常大的好處,卻也給我們帶來了代碼依賴。依賴產生的原因大概可以分以下四類:
1)繼承;
派生類繼承自基類,獲得了基類的全部內容,但同時,派生類也受控於基類,只要基類發生改變,派生類一定發生變化:

圖12-9 繼承依賴
上圖12-9中,B和C繼承自A,A類改變必然會影響B和C的變化。
2)成員對象;
一個類型包含另外一個類型的成員時,前者必然受控於后者,雖然后者的改變不一定會影響到前者:

圖12-10 成員對象依賴
如上圖12-10,A包含B類型的成員,那么A就受控於B,B在A內部完全可見。
注:成員對象依賴跟組合(聚合)類似。
3)傳遞參數;
一個類型作為參數傳遞給另外一個類型的成員方法,那么后者必然會受控於前者,雖然前者的改變不一定會影響到后者:

圖12-11 傳參依賴
如上圖12-11,A類型的方法Method()包含一個B類型的參數,那么A就受控於B,B在A的Method()方法可見。
4)臨時變量。
任何時候,一個類型將另外一個類型用作了臨時變量時,那么前者就受控於后者,雖然后者的改變不一定會影響到前者:
1 //Code 12-6 2 class A 3 { 4 public void DoSomething() 5 { 6 //… 7 } 8 } 9 class B 10 { 11 public void DoSomething() 12 { 13 //… 14 A a = new A(); 15 a.DoSomething(); 16 //… 17 } 18 }
如上代碼Code 12-6,B的DoSomething()方法中使用了A類型的臨時對象,A在B的DoSomething()方法中局部范圍可見。
通常情況下,通過被依賴者在依賴者內部可見范圍大小來衡量依賴程度的高低,原因很簡單,可見范圍越大,說明訪問它的概率就越大,依賴者受影響的概率也就越大,因此,上述四種依賴產生的原因中,依賴程度按順序依次降低。
12.2.2 耦合與內聚
為了衡量對象之間依賴程度的高低,我們引進了"耦合"這一概念,耦合度越高,說明對象之間的依賴程度越高;為了衡量對象獨立性的高低,我們引進了"內聚"這一概念,內聚性越高,說明對象與外界交互越少、獨立性越強。很明顯,耦合與內聚是兩個相互對立又密切相關的概念。
注:從廣義上講,"耦合"與"內聚"不僅適合對象與對象之間的關系,也適合模塊與模塊、系統與系統之間的關系,這跟前面講"封裝"時強調"封裝"不僅僅指代碼層面上的道理一樣。
"模塊功能集中,模塊之間界限明確"一直是軟件設計追求的目標,軟件系統不會因為需求的改變、功能的升級而不得不大范圍修改原來已有的源代碼,換句話說,我們在軟件設計中,應該嚴格遵循"高內聚、低耦合"的原則。下圖12-12顯示一個系統遵循該原則前后:

圖12-12 高內聚、低耦合
如上圖12-12所示,"高內聚、低耦合"強調對象與對象之間(模塊與模塊之間)盡可能多地降低依賴程度,每個對象(或模塊,下同)盡可能提高自己的獨立性,這就要求它們各自負責的功能相對集中,代碼結構由"開放"轉向"收斂"。
"職責單一原則(SRP)"是提高對象內聚性的理論指導思想之一,它建議每個對象只負責某一個(一類)功能。
12.2.3 依賴造成的"尷尬"
如果在軟件系統設計初期,沒有合理地降低(甚至避免)代碼間的耦合,系統開發后期往往會遇到前期不可預料的困難。下面舉例說明依賴給我們造成的"尷尬"。
假設一個將要開發的系統中使用到了數據庫,系統設計階段確定使用SQL Server數據庫,按照"代碼模塊化可以提高代碼復用性"的原則,我們將訪問SQL Server數據庫的代碼封裝成了一個單獨的類,該類只負責訪問SQLServer數據庫這一功能:
1 //Code 12-7 2 class SQLServerHelper //NO.1 3 { 4 //… 5 public void ExcuteSQL(string sql) 6 { 7 //… 8 } 9 } 10 class DBManager //NO.2 11 { 12 //… 13 SQLServerHelper _sqlServerHelper; //NO.3 14 public DBManager(SQLServerHelper sqlServerHelper) 15 { 16 _sqlServerHelper = sqlServerHelper; 17 } 18 public void Add() //NO.4 19 { 20 string sql = ""; 21 //… 22 _sqlServerHelper.ExcuteSQL(sql); 23 } 24 public void Delete() //NO.5 25 { 26 string sql = ""; 27 //… 28 _sqlServerHelper.ExcuteSQL(sql); 29 } 30 public void Update() //NO.6 31 { 32 string sql = ""; 33 //… 34 _sqlServerHelper.ExcuteSQL(sql); 35 } 36 public void Search() //NO.7 37 { 38 string sql = ""; 39 //… 40 _sqlServerHelper.ExcuteSQL(sql); 41 } 42 }
如上代碼Code 12-7所示,定義了一個SQL Server數據庫訪問類SQLServerHelper(NO.1處),該類專門負責訪問SQL Server數據庫,如執行sql語句(其它功能略),然后定義了一個數據庫管理類DBManager(NO.2處),該類負責一些數據的增刪改查(NO.4、NO.5、NO.6以及NO.7處),同時該類還包含一個SQLServerHelper類型成員(NO.3處),負責具體SQL Server數據庫的訪問。SQLServerHelper類和DBManager類的關系見下圖12-13:

圖12-13 依賴於具體
如上圖12-13所示,DBManager類依賴於SQLServerHelper類,后者在前者內部完全可見,當DBManager需要訪問SQL Server數據庫時,可以交給SQLServerHelper類型成員負責,到此為止,這兩個類型合作得非常好,但是,現在如果我們對數據庫的需求發生變化,不再使用SQL Server數據庫,而要求更改使用MySQL數據庫,那么我們需要做些什么工作呢?和之前一樣,我們需要定義一個MySQLHelper類來負責MySQL數據庫的訪問,代碼如下:
1 //Code 12-8 2 class MySQLHelper 3 { 4 //… 5 public void ExcuteSQL(string sql) 6 { 7 //… 8 } 9 }
如上代碼Code 12-8,定義了一個專門訪問MySQL數據庫的類型MySQLHelper,它的結構跟SQLServerHelper相同,接下來,為了使原來已經工作正常的系統重新適應於MySQL數據庫,我們還必須依次修改DBManager類中所有對SQLServerHelper類型的引用,將其全部更新為MySQLHelper的引用。如果只是一個DBManager類使用到了SQLServerHelper的話,整個更新工作量還不算非常多,但如果程序代碼中還有其它地方使用到了SQLServerHelper類型的話,這個工作量就不可估量,除此之外,我們這樣做出的所有操作完全違背了軟件設計中的"開閉原則(OCP)",即"對擴展開放,而對修改關閉"。很明顯,我們在增加新的類型MySQLHelper時,還修改了系統原有代碼。
出現以上所說問題的主要原因是,在系統設計初期,DBManager這個類型依賴了一個具體類型SQLServerHelper,"具體"就意味着不可改變,同時也就說明兩個類型之間的依賴關系已經到達了"非你不可"的程度。要解決以上問題,需要我們在軟件設計初期就做出一定的措施,詳見下一小節。
12.3 降低代碼依賴
上一節末尾說到了代碼依賴給我們工作帶來的麻煩,還提到了主要原因是對象與對象之間(模塊與模塊,下同)依賴關系太過緊密,本節主要說明怎樣去降低代碼間的依賴程度。
12.3.1 認識"抽象"與"具體"
其實本書之前好些地方已經出現過"具體"和"抽象"的詞眼,如"具體的類型"、"依賴於抽象而非具體"等等,到目前為止,本書還並沒有系統地介紹這兩者的具體含義。
所謂"抽象",即"不明確、未知、可改變"的意思,而"具體"則是相反的含義,它表示"確定、不可改變"。我們在前面講"繼承"時就說過,派生類繼承自基類,就是一個"抽象到具體"的過程,比如基類"動物(Animal)"就是一個抽象的事物,而從基類"動物(Animal)"派生出來的"狗(Dog)"就是一個具體的事物。抽象與具體的關系如下圖12-14:

圖12-14 抽象與具體的相對性
注:抽象與具體也是一個相對的概念,並不能說"動物"就一定是一個抽象的事物,它與"生物"進行比較,就是一個相對具體的事物,同理"狗"也不一定就是具體的事物,它跟"哈士奇"進行比較,就是一個相對抽象的概念。
在代碼中,"抽象"指接口、以及相對抽象化的類,注意這里相對抽象化的類並不特指"抽象類"(使用abstract關鍵字聲明的類),只要一個類型在族群層次中比較靠上,那么它就可以算是抽象的,如上面舉的"動物(Animal)"的例子;"具體"則指從接口、相對抽象化的類繼承出來的類型,如從"動物(Animal)"繼承得到的"狗(Dog)"類型。代碼中抽象與具體的舉例見下表12-1:
表12-1 抽象與具體舉例
| 序號 |
抽象 |
具體 |
說明 |
| 1 |
Interface IWalkable { void Walk(); } |
class Dog:IWalkable { public void Walk() { //… } } |
IWalkable接口是"抽象",實現IWalkable接口的Dog類是"具體"。 |
| 2 |
class Dog:IWalkable { public void Walk() { //… } } |
class HaShiQi:Dog { //… } |
Dog類是"抽象",繼承自Dog類的HaShiQi類則是"具體"。 |
如果一個類型包含一個抽象的成員,比如"動物(Animal)",那么這個成員可以是很多種類型,不僅可以是"狗(Dog)",還可以是"貓(Cat)"或者其它從"動物(Animal)"派生的類型,但是如果一個類型包含一個相對具體的成員,比如"狗(Dog)",那么這個成員就相對固定,不可再改變。很明顯,抽象的東西更易改變,"抽象"在降低代碼依賴方面起到了重要作用。
12.3.2 再看"依賴倒置原則"
本書前面章節在講到"依賴倒置原則"時曾建議我們在軟件設計時:
1)高層模塊不應該直接依賴於低層模塊,高層模塊和低層模塊都應該依賴於抽象;
2)抽象不應該依賴於具體,具體應該依賴於抽象。
抽象的事物不確定,一個類型如果包含一個接口類型成員,那么實現了該接口的所有類型均可以成為該類型的成員,同理,方法傳參也一樣,如果一個方法包含一個接口類型參數,那么實現了該接口的所有類型均可以作為方法的參數。根據"里氏替換原則(LSP)"介紹的,基類出現的地方,派生類均可以代替其出現。我們再看本章12.2.3小節中講到的"依賴造成的尷尬",DBManager類型依賴一個具體的SQLServerHelper類型,它內部包含了一個SQLServerHelper類型成員,DBManager和SQLServerHelper之間產生了一個不可變的綁定關系,如果我們想將數據庫換成MySQL數據庫,要做的工作不僅僅是增加一個MySQLHelper類型。假設在軟件系統設計初期,我們將訪問各種數據庫的相似操作提取出來,放到一個接口中,之后訪問各種具體數據庫的類型均實現該接口,並使DBManager類型依賴於該接口:
1 //Code 12-9 2 interface IDB //NO.1 3 { 4 void ExcuteSQL(string sql); 5 } 6 class SQLServerHelper:IDB //NO.2 7 { 8 //… 9 public void ExcuteSQL(string sql) 10 { 11 //… 12 } 13 } 14 class MySQLHelper:IDB //NO.3 15 { 16 //… 17 public void ExcuteSQL(string sql) 18 { 19 //… 20 } 21 } 22 class DBManager //NO.4 23 24 { 25 //… 26 IDB _dbHelper; //NO.5 27 public DBManager(IDB dbHelper) 28 { 29 _dbHelper = dbHelper; 30 } 31 public void Add() //NO.6 32 { 33 string sql = ""; 34 35 //… 36 37 _dbHelper.ExcuteSQL(sql); 38 39 } 40 41 public void Delete() //NO.7 42 { 43 string sql = ""; 44 //… 45 _dbHelper.ExcuteSQL(sql); 46 } 47 public void Update() //NO.8 48 { 49 string sql = ""; 50 //… 51 _dbHelper.ExcuteSQL(sql); 52 } 53 public void Search() //NO.9 54 { 55 string sql = ""; 56 //… 57 _dbHelper.ExcuteSQL(sql); 58 } 59 }
如上代碼Code 12-9所示,我們將訪問數據庫的方法放到了IDB接口中(NO.1處),之后所有訪問其它具體數據庫的類型均需實現該接口(NO.2和NO.3處),同時DBManager類中不再包含具體SQLServerHelper類型引用,而是依賴於IDB接口(NO.5處),這樣一來,我們可以隨便地將SQLServerHelper或者MySQLHelper類型對象作為DBManager的構造參數傳入,甚至我們還可以新定義其它數據庫訪問類,只要該類實現了IDB接口,
1 //Code 12-10 2 class OracleHelper:IDB //NO.1 3 { 4 //… 5 public void ExcuteSQL(string sql) 6 { 7 //… 8 } 9 } 10 class Program 11 { 12 static void Main() 13 { 14 DBManager dbManager = new DBManager(new OracleHelper()); //NO.2 15 } 16 }
如上代碼Code 12-10,如果系統需要使用Oracle數據庫,只需新增OracleHelper類型即可,使該類型實現IDB接口,不用修改系統其它任何代碼,新增加的OracleHelper能夠與已有代碼合作得非常好。
修改后的代碼中,DBManager不再依賴於任何一個具體類型,而是依賴於一個抽象接口IDB,見下圖12-15:

圖12-15 依賴於抽象
如上圖12-15,代碼修改之前,DBManager直接依賴於具體類型SQLServerHelper,而代碼修改后,DBManager依賴於一個"抽象",也就是說,被依賴者不確定是誰,可以是SQLServerHelper,也可以是其它實現了IDB的任何類型,DBManager與SQLServerHelper之間的依賴程度降低了。
理論上講,任何一個類型都不應該包含有具體類型的成員,而只應該包含抽象類型成員;任何一個方法都不應該包含有具體類型參數,而只應該包含抽象類型參數。當然這只是理論情況,軟件系統設計初期就已確定不會再改變的依賴關系,就不需要這么去做。
注:除了上面說到的將相同部分提取出來放到一個接口中,還有時候需要將相同部分提取出來,生成一個抽象化的基類,如抽象類。接口強調相同的行為,而抽象類一般強調相同的屬性,並且用在有族群層次的類型設計當中。
12.3.3 依賴注入(DI)
當兩個對象之間必須存在依賴關系時,"依賴倒置"為我們提供了一種降低代碼依賴程度的思想,而"依賴注入(Dependency Injection)"為我們提供了一種具體產生依賴的方法,它強調"對象間產生依賴"的具體代碼實現,是對象之間能夠合作的前提。"依賴注入"分以下三種(本小節代碼均以12.3.2小節中的代碼為前提):
(1)構造注入(Constructor Injection);
通過構造方法,讓依賴者與被依賴者產生依賴關系,
1 //Code 12-11 2 class DBManager 3 { 4 //… 5 IDB _dbHelper; 6 public DBManager(IDB dbHelper) //NO.1 7 { 8 _dbHelper = dbHelper; 9 } 10 public void Add() 11 { 12 string sql = ""; 13 //… 14 _dbHelper.ExcuteSQL(sql); 15 } 16 //… 17 } 18 class Program 19 { 20 static void Main() 21 { 22 DBManager manager = new DBManager(new SQLServerHelper()); //NO.2 23 DBManager manager2 = new DBManager(new MySQLHelper()); //NO.3 24 DBManager manager3 = new DBManager(new OracleHelper()); //NO.4 25 } 26 }
如上代碼Code 12-11所示,DBManager中包含一個IDB類型的成員,並通過構造方法初始化該成員(NO.1處),之后可以在創建DBManager對象時分別傳遞不同的數據庫訪問對象(NO.2、NO.3以及NO.4處)。
通過構造方法產生的依賴關系,一般在依賴者(manager、manager2以及manager3)的整個生命期中都有效。
注:雖然不能創建接口、抽象類的實例,但是可以存在它們的引用。
(2)方法注入(Method Injection);
通過方法,讓依賴者與被依賴者產生依賴關系,
1 //Code 12-12 2 class DBManager 3 { 4 //… 5 public void Add(IDB dbHelper) //NO.1 6 { 7 string sql = ""; 8 //… 9 dbHelper.ExcuteSQL(sql); 10 } 11 //… 12 } 13 class Program 14 { 15 static void Main() 16 { 17 DBManager manager = new DBManager(); 18 //… 19 manager.Add(new SQLServerHelper()); //NO.2 20 //… 21 manager.Add(new MySQLHelper()); //NO.3 22 //… 23 manager.Add(new OracleHelper()); //NO.4 24 } 25 }
如上代碼Code 12-12所示,在DBManager的方法中包含IDB類型的參數(NO.1處),我們在調用方法時,需要向它傳遞一些訪問數據庫的對象(NO.2、NO.3以及NO.4處)。
通過方法產生的依賴關系,一般在方法體內部有效。
(3)屬性注入(Property Injection)。
通過屬性,讓依賴者與被依賴者產生依賴關系,
1 //Code 12-13 2 class DBManager 3 { 4 //… 5 IDB _dbHelper; 6 public IDB DBHelper //NO.1 7 { 8 get 9 { 10 return _dbHelper; 11 } 12 set 13 { 14 _dbHelper = value; 15 } 16 } 17 public void Add() 18 { 19 string sql = ""; 20 //… 21 _dbHelper.ExcuteSQL(sql); 22 } 23 //… 24 } 25 class Program 26 { 27 static void Main() 28 { 29 DBManager manager = new DBManager(); 30 //… 31 manager.DBHelper = new SQLServerHelper(); //NO.2 32 //… 33 manager.DBHelper = new MySQLHelper(); //NO.3 34 //… 35 manager.DBHelper = new OracleHelper(); //NO.4 36 //… 37 } 38 }
如上代碼Code 12-13所示,DBManager中包含一個公開的IDB類型屬性,在必要的時候,可以設置該屬性(NO.2、NO.3以及NO.4處)的值。
通過屬性產生的依賴關系比較靈活,它的有效期一般介於"構造注入"和"方法注入"之間。
注:在很多場合,三種依賴注入的方式可以組合使用,即我們可以先通過"構造注入"讓依賴者與被依賴者產生依賴關系,后期再使用"屬性注入"的方式更改它們之間的依賴關系。"依賴注入(DI)"是以"依賴倒置""為前提的。
12.4 框架的"代碼依賴"
12.4.1 控制轉換(IoC)
"控制轉換(Inversion Of Control)"強調程序運行控制權的轉移,一般形容在軟件系統中,框架主導着整個程序的運行流程,如框架確定了軟件系統主要的業務邏輯結構,框架使用者則在框架已有的基礎上擴展具體的業務功能,為此編寫的代碼均由框架在適當的時機進行調用。
"控制轉換"改變了我們對程序運行流程的一貫認識,程序不再受開發者控制,

圖12-16 程序控制權的轉移
如上圖12-16所示,框架負責調用開發者編寫的代碼,框架控制整個程序的運轉。
注:"控制轉換(IoC)、依賴倒置(DIP)以及依賴注入(DI)是三個不同性質的概念,"控制轉換"強調程序控制權的轉移,注重軟件運行流程;"依賴倒置"是一種降低代碼依賴程度的理論指導思想,它注重軟件結構;"依賴注入"是對象之間產生依賴關系的一種具體實現方式,它注重編程實現。筆者認為有的書籍將三者做相等或者相似的比較是不准確的。
通常,又稱"控制轉換(IoC)"為"好萊塢原則(Hollywood Principle)",它建議框架與開發者編寫代碼之間的關系是:"Don't call us,we will call you.",即整個程序的主動權在框架手中。
12.4.2 依賴注入(DI)對框架的意義
框架與開發者編寫的代碼之間有"調用"與"被調用"的關系,所以避免不了依賴的產生,"依賴注入"是框架與開發者編寫代碼之間相結合的一種方式。任何一個框架的創建者不僅僅要遵循"依賴倒置原則",使創建出來的框架與框架使用者之間的依賴程度最小,還應該充分考慮兩者之間產生依賴的方式。
注:"框架創建者"指開發框架的團隊,"框架使用者"指使用框架開發應用程序的程序員。
12.5 本章回顧
本章首先介紹了面向對象的三大特征:封裝、繼承和多態,它們是面向對象的主要內容。之后介紹了面向對象的軟件系統開發過程中不可避免的代碼依賴,還提到了不合理的代碼依賴給我們系統開發帶來的負面影響,有問題就要找出解決問題的方法,隨后我們從認識"具體"和"抽象"開始,逐漸地了解可以降低代碼依賴程度的具體方法,在這個過程中,"依賴倒置(DIP)"是我們前進的理論指導思想,"高內聚、低耦合"是我們追求的目標。
12.6 本章思考
1.簡述"面向對象"的三大特征。
A:從對象基礎、對象擴展以及對象行為三個方面來講,"面向對象(OO)"主要包含三大特征,分別是:封裝、繼承和多態。封裝是前提,它強調代碼模塊化,將數據以及相關的操作組合成為一個整體,對外只公開必要的訪問接口;繼承是在封裝的前提下,創建新類型的一種方式,它建議有族群關系的類型之間可以發生自上而下地衍生關系,處在族群底層的類型具備高層類型的所有特性;多態強調對象的多種表現行為,它是建立在繼承的基礎之上的,多態同時也是降低代碼依賴程度的關鍵。
2.簡述"面向抽象編程"的具體含義。
A:如果說"面向對象編程"教我們將代碼世界中的所有事物均看成是一個整體——"對象",那么"面向抽象編程"教我們將代碼中所有的依賴關系都建立在"抽象"之上,一切依賴均是基於抽象的,對象跟對象之間不應該有直接具體類型的引用關系。"面向接口編程"是"面向抽象編程"的一種。
3."依賴倒置原則(DIP)"中的"倒置"二字作何解釋?
A:正常邏輯思維中,高層模塊依賴底層模塊是天經地義、理所當然的,而"依賴倒置原則"建議我們所有的高層模塊不應該直接依賴於底層模塊,而都應該依賴於一個抽象,注意這里的"倒置"二字並不是"反過來"的意思(即底層模塊反過來依賴於高層模塊),它只是說明正常邏輯思維中的依賴順序發生了變化,把所有違背了正常思維的東西都稱之為"倒置"。
4.在軟件設計過程中,為了降低代碼之間的依賴程度,我們遵循的設計原則是什么?我們設計的目標是什么?
A:有兩大設計原則主要是為了降低代碼依賴程度,即:單一職責原則(SRP)和依賴倒置原則(DIP)。我們在軟件設計時追求的目標是:高內聚、低耦合。
