相關文章連線
難免的尷尬:程式碼依賴
在浩瀚的程式碼世界中,有著無數的物件,跟人和人之間有社交關係一樣,物件跟物件之間也避免不了接觸,所謂接觸,就是指一個物件要使用到另外物件的屬性、方法等成員。現實生活中一個人的社交關係複雜可能並不是什麼不好的事情,然而對於程式碼中的物件而言,複雜的"社交關係"往往是不提倡的,因為物件之間的關聯性越大,意味著程式碼改動一處,影響的範圍就會越大,而這完全不利於系統重構和後期維護。所以在現代軟體開發過程中,我們應該遵循"儘量降低程式碼依賴"的原則,所謂儘量,就已經說明程式碼依賴不可避免。
有時候一味地追求"降低程式碼依賴"反而會使系統更加複雜,我們必須在"降低程式碼依賴"和"增加系統設計複雜性"之間找到一個平衡點,而不應該去盲目追求"六人定理"那種設計境界。
注:"六人定理"指:任何兩個人之間的關係帶,基本確定在六個人左右。兩個陌生人之間,可以透過六個人來建立聯絡,此為六人定律,也稱作六人法則。
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)。我們在軟體設計時追求的目標是:高內聚、低耦合。