小例子背后的大道理:從DIP中“倒置”的含義說接口
選開燈做例子,是因為這個例子既常見又簡單,而且潛在的需求多樣。對于最簡單的燈,從功能上講,按下燈上的開關(guān),燈就開了。
用代碼實現(xiàn)這樣一個有開關(guān)功能的燈,也是一件很容易的事情。
- public class Light
- {
- public void TurnOn() { Console.WriteLine("Light Turn On"); }
- public void TurnOff() { Console.WriteLine("Light Turn Off"); }
- }
代碼1
一個具有開關(guān)功能的燈就完成了。這個燈,功能完備、也滿足當(dāng)下的需求。一切美好。
直到有一天,有個客戶說,燈上的開關(guān)壞了,能不能換一個?我才意識到這個燈的設(shè)計有問題——它的開關(guān)是換不了的。一面給用戶解釋,一面考慮著把燈和開關(guān)分開。
咱也是學(xué)過設(shè)計模式的人,知道要面向接口編程,絕不應(yīng)該簡單地把Light類拆解成Light和Switcher兩個類。因為Switcher不應(yīng)該依賴于具體實現(xiàn),于是寫出了下面的代碼。
- namespace Me.Lighting
- {
- public interface ILightable
- {
- void ShowLight();
- void HideLight();
- }
- public class Light : ILightable
- {
- public void ShowLight() { Console.WriteLine("Light Turn On"); }
- public void HideLight() { Console.WriteLine("Light Turn Off"); }
- }
- }
- namespace Me.Switch
- {
- using Me.Lighting;
- public class Switcher
- {
- public ILightable Light { get; set; }
- public void TurnOn() { Light.ShowLight(); }
- public void TurnOff() { Light.HideLight(); }
- }
- }
代碼 2
這個設(shè)計,不僅分離了燈和開關(guān),甚至可以讓這個開關(guān)靈活地控制要開關(guān)哪個燈。只要在開關(guān)前設(shè)置一下就可以,多方便。我自信滿滿地遷入了代碼。
事實也證明這樣的設(shè)計是成功的,產(chǎn)品的靈活設(shè)計得到了用戶的認(rèn)可,銷量直線上升。
親,請看下代碼,在不使用什么別的設(shè)計模式的前提下,您覺得代碼2有什么問題?無論是什么角度的都可以(當(dāng)然,可能您的角度不是本文討論的重點),最好先回復(fù)下留個底,別事后諸葛。
如果您一眼看到了問題,請直接閱讀DIP那一節(jié)。
公司壯大之后 ,開始考慮向收音機(jī)行業(yè)進(jìn)軍。而且公司希望,這種靈活的設(shè)計可以沿用下去,收音機(jī)和燈的開關(guān)應(yīng)該可以通用,對用戶而言,都是撥那么一下。
我聽到這個信息也是相當(dāng)興奮,但是當(dāng)我開始著手寫代碼時,發(fā)現(xiàn)一些壞味道,開關(guān)依賴于ILightable 接口,那么我的收音機(jī)不得不寫成這個樣子才能與現(xiàn)有的開關(guān)兼容。
- public class Radio : ILightable
- {
- public void ShowLight() { Console.WriteLine("Play radio"); }
- public void HideLight() { Console.WriteLine("Stop radio"); }
- }
代碼3
雖然可以工作,但是這是嚴(yán)重的壞味道。因為如果有一天,燈的接口變化,我卻要連收音機(jī)的代碼一起改。這種情況絕不應(yīng)該出現(xiàn)。且不用把LSP(Liskov替換原則)搬出來說教,很顯然Radio其實并沒有完成ILightable所定義的功能——發(fā)光。無論從哪個角度講都是錯的。
一個可行的設(shè)計是,讓開關(guān)支持收音機(jī)的開啟和停止。像下面這樣。
- namespace Me.Radio
- {
- public interface IRadio
- {
- void Play();
- void Stop();
- }
- public class Radio : IRadio
- {
- public void Play() { Console.WriteLine("Play radio"); }
- public void Stop() { Console.WriteLine("Stop radio"); }
- }
- }
- namespace Me.Switch
- {
- using Me.Lighting;
- using Me.Radio;
- public class Switcher
- {
- public ILightable Light { get; set; }
- public IRadio Radio { get; set; }
- public void TurnOn()
- {
- if (Light != null) Light.ShowLight();
- else if (Radio != null) Radio.Play();
- }
- public void TurnOff() { Light.HideLight(); }
- }
- }
代碼4
我看來看去都覺得這個代碼太惡心了,因為Switcher的實現(xiàn)方式違反了OCP(開放—封閉原則),如果這樣發(fā)展下去,公司的產(chǎn)品越豐富,這坨代碼就越難以維護(hù)。我的末日也就越近。
于是我的考慮Switcher的設(shè)計是不是有問題,我已經(jīng)用上面向接口編程了,為什么還是有問題呢?
我把代碼發(fā)給了我的導(dǎo)師,一個設(shè)計Guru,他看完之后哭笑著說,你的基本功很扎實,理論知識也很全面,可惜卻缺乏一定的經(jīng)驗。面向接口編程沒有錯,但是更重要的是模型的建立。
簡單而言,你的開關(guān)的依賴關(guān)系錯了。問你一個問題你就明白了,開關(guān)為什么要依賴ILightable呢?但是好在你有一定的設(shè)計基礎(chǔ),知道要提取出一個接口,所以要改成正確的設(shè)計也非常容易。你只需要把ILightable這個接口的名字改成ISwitchable,再把接口方法名字改下,并把它與Switcher放一起就行了。
聽罷,我恍然大悟。原來接口的名字和位置,也會給使用者帶來如此大的困擾。在先進(jìn)的開發(fā)工具的幫助下,瞬間就完成了這個簡單的重命名和移動操作?,F(xiàn)在的代碼像這個樣子了。
- namespace Me.Lighting
- {
- using Me.Switch;
- public class Light : ISwitchable
- {
- public void TurnOn() { Console.WriteLine("Light Turn On"); }
- public void TurnOff() { Console.WriteLine("Light Turn Off"); }
- }
- }
- namespace Me.Radio
- {
- using Me.Switch;
- public class Radio : ISwitchable
- {
- public void TurnOn() { Console.WriteLine("Play radio"); }
- public void TurnOff() { Console.WriteLine("Stop radio"); }
- }
- }
- namespace Me.Switch
- {
- public interface ISwitchable
- {
- void TurnOn();
- void TurnOff();
- }
- public class Switcher
- {
- public ISwitchable Switchee { get; set; }
- public void TurnOn() { Switchee.TurnOn(); }
- public void TurnOff() { Switchee.TurnOff(); }
- }
- }
代碼5
注意:這個代碼與之前有問題的代碼2,只是各種名稱上的變化。結(jié)構(gòu)上一點兒沒變。
以后有新的產(chǎn)品,也只需要實現(xiàn)ISwitchable接口,就可以支持這個開關(guān)了。之前的失敗設(shè)計,看似與這個設(shè)計相差無幾,但是其中蘊含的設(shè)計思想天差地遠(yuǎn),也正是在這種地方,才更能體現(xiàn)出設(shè)計師間的差距。這一種設(shè)計所體現(xiàn)的,即是DIP(依賴倒置原則),的表現(xiàn)之一,接口應(yīng)當(dāng)被其使用者所擁有,而非其實現(xiàn)者。1
具體問題解決了,還需要把整個問題抽象一下,從本質(zhì)上了解一下DIP的含義。(我會盡量清楚,可能會有些啰嗦,但這比在回復(fù)里爭論要舒坦得多。)
假設(shè)有如下所示的類圖。假設(shè)我們要把這種關(guān)系解耦合。
圖1
注:圖1中的User表示使用者(調(diào)用者),而不是用戶的意思。
我說“假設(shè)要解耦合”,是因為在嘗試解耦這種依賴關(guān)系之前,應(yīng)該先確定有沒有解耦的必要。這種關(guān)系在代碼中比比皆是,如果把所有的依賴都解耦,不僅工作量大、帶不來任何好處,而且引入了不必要的復(fù)雜度,最終演變成了過度設(shè)計,增加了編碼成本和維護(hù)成本。(我已經(jīng)被人罵怕了,怕不說清楚這一點,總要有人跳出來說我濫用模式,說這種關(guān)系要不要解耦要看情況,云云。都是好意,我也心領(lǐng)了,謝謝。但被人假設(shè)狗屁不通,總不太舒服。)
明確某個依賴關(guān)系是否需要被分解,是一件很復(fù)雜的事情,個人覺得并沒有什么準(zhǔn)則能讓你輕松地做出這個判斷。因為幾乎所有的依賴,在一句經(jīng)典的“我以后可能會換一種方式實現(xiàn)它”面前,都變得似乎需要被解耦。這種理由,聽上去合理,其實是狗屁。換一種方式實現(xiàn)它,并不意味著要用一個接口來抽象它,接口是用來抽象并解耦依賴關(guān)系的,應(yīng)該被用在:同時存在多個實現(xiàn)、實現(xiàn)未知或需要模塊化的情況下(還有一種情況,是方便多人開發(fā)時工作內(nèi)容的解耦,但我還沒有想明白,引入接口來達(dá)到這個目的是否合適:因管理需要導(dǎo)致的復(fù)雜度上升。所以先不討論這種情況)。
具體解釋一下,“同時存在多個實現(xiàn)”的意思。以IComparable接口為例,很多數(shù)據(jù)類(比如DTO)大都實現(xiàn)了這個接口,因為上層的功能(比如排序)依賴類的對象有相互比較的能力,同時每個類的實現(xiàn)方式又都不一樣,即所謂的同時存在多個實現(xiàn)。
所以,對于需要“換一種方式實現(xiàn)它”的情況,大可以把原來的代碼刪除然后重新寫一個。
有句話叫“拿著錘子,看什么都像釘子”。了解一項技術(shù),不僅僅要了解他能做什么,更要了解這個技術(shù)適用在什么地方。所以千萬別今天聽了解耦的概念覺得很前衛(wèi),第二天就去把所有的類都提取出個接口。多數(shù)情況當(dāng)然不會這么夸張,但濫用其實就在一念之間。
我承認(rèn),上面解釋也許正確,但沒什么用。懂的人懂,不懂的還是不懂;所以我還是舉些接口有問題的壞味道吧。
最常見的接口壞味兒包括:(注意,總可以找到反例,所以一開始就說了,沒有準(zhǔn)則,總要具體問題具體分析,但是如果使用接口的原因是如下幾種之下,我覺得應(yīng)該再仔細(xì)考慮一下)
-
為了提取出某一個類所提供的Public方法。接口應(yīng)該用來抽象依賴,而不是抽象實現(xiàn)。后面再解釋。你想知道或控制一個類有哪些Method的方法有很多,但是引入一個接口,不僅達(dá)不到你的目的,還引入了復(fù)雜度——每當(dāng)你要加一個方法,都要修改兩個地方,一個是接口,一個是實現(xiàn)。
-
接口抽象出來了,但是和實現(xiàn)放了一起,或者根本沒用到這個接口。比如,如果你寫出了:
Interface f = new Implementation();
這樣的代碼,而且這個接口只被這樣用過,那或許需要考慮一下使用這個接口的用法了。我并不是指你需要一個依賴注入的框架。但是這至少看上去不太對勁,像是為了使用接口而提取出了這個接口。
-
接口中包含了互不相關(guān)的方法。如果某個方法出現(xiàn)在這個接口里會讓人覺得驚訝,那這個接口就是有問題的。不能因為有兩個以上的類都有這個方法,所以就提取出來了。要看這兩個方法有沒有關(guān)系,還要看上層是不是一定會同時依賴這兩個方法。使用者使用接口中的方法時,應(yīng)該全部都用得到。如果沒全用到,可能需要考慮一下這個接口劃分的是否合理?的粒度是不是太粗了?還是把接口當(dāng)成了Common Service Host來用了?
同一張類圖的不同解釋——真假DIP
扯得有點兒遠(yuǎn)了。回來繼續(xù)正題,考慮如何把User和Implementation解耦合。所有人都知道,解耦的方法是:
- 定義接口I
- Implementation實現(xiàn)接口I
- User使用接口I,則不是Implementation。
這個描述已經(jīng)很細(xì)了,而且畫出來的類圖也是唯一的。但是很可惜,這個描述是不明確的,有歧義的。
代碼2和代碼5都符合這個描述,但是其實是不同的設(shè)計。用圖來描述會更清楚一些。
圖2
圖3
或許有人一看到學(xué)術(shù)派的設(shè)計圖就興奮起來,一眼就看出有一個設(shè)計是有問題的。但是當(dāng)你看到代碼2時,你有一眼看出問題嗎?到你自己的項目代碼中,你能一眼看出問題嗎?問題總是出現(xiàn)在“混亂”中,簡化成圖2、圖3這樣,只要知道DIP的人,恐怕都能看出問題。但到項目中,那就是另一回事兒了。就像多數(shù)人都很鄙視國家組織的“軟考”,考得再好,也不表示有相當(dāng)?shù)脑O(shè)計水平。這種簡化了的問題和考題一樣,也許能明白,但是能在該用的時候記得用,并不是個容易的事兒。
我來解釋一下,其中根本的區(qū)別在于誰依賴誰。至于誰持有接口,只是表象。從邏輯上,調(diào)用方很明顯地依賴著實現(xiàn)方,因為實現(xiàn)方才是功能的實現(xiàn)者,沒有實現(xiàn)方,調(diào)用方就工作不了。但是在圖3的設(shè)計中,其設(shè)計意圖是,實現(xiàn)方要實現(xiàn)的功能,由調(diào)用方來決定,而不是實現(xiàn)方實現(xiàn)了什么,調(diào)用方就用什么。也就是說,要讓實現(xiàn)方依賴調(diào)用方。這,就是DIP(依賴倒置原則)的含義。其具體表現(xiàn)就是,調(diào)用方定義并持有接口。
從概念上來講,DIP的定義如下2:
- 高層次的模塊不應(yīng)該依賴于低層次的模塊,他們都應(yīng)該依賴于抽象。
- 抽象(Abstractions)不應(yīng)該依賴于實現(xiàn)(details),實現(xiàn)應(yīng)該依賴于抽象。
目前在網(wǎng)上找到的對DIP的解釋,多數(shù)都停留在第一項,即模塊依賴抽象上,都沒有解釋清楚“倒置”這個詞的含義。希望本文中的圖2和圖3解釋清楚了“倒置”的含義。從概念上來講,“抽象不應(yīng)該依賴于實現(xiàn)”,就是要求“倒置”。因為如果像圖2那種思路,從實現(xiàn)中抽象出接口,那么這個接口就是依賴于實現(xiàn)的。重復(fù)一下之前說過的:接口,應(yīng)該是對依賴的抽象,而不是對實現(xiàn)(底層功能)的抽象,這就是所謂的倒置。(這里的依賴的含義是,調(diào)用者所需要的功能,而不是實現(xiàn)者實現(xiàn)了的功能。)
另外,還是這個類圖,還有一種常見的組織形式。像下面這樣。
圖4
從箭頭的方向上來看,這個更倒置。但是模塊的細(xì)分,箭頭方向的顛倒,并不意味著這個設(shè)計真的是倒置的。這要取決于抽象層中的接口,是與圖2中的接口定位一致呢?還是與圖3中的接口定位一致?單純地把接口放在抽象層里,就和單純地定義一個接口,卻沒有地方用到它一樣沒有意義。
所以說,清楚地表達(dá)一個設(shè)計,并能讓人確切地明白你的設(shè)計。其實是一件非常不容易的事情??赡馨裊ML的所有功能都用上,才能做到這一點。僅僅畫個框框、線線、寫倆字兒,是很容易讓人誤會的。開會的時候有人解釋著還好,如果寫出的文檔如果是這樣,對新手而言還不如沒有,因為基本上一定會被誤解。
我猜不少人看到這里會很想問,知道“倒置”到底是什么意思有個鳥用?有好的創(chuàng)意去開發(fā)項目才是正經(jīng)事兒,把項目按時保質(zhì)地做出來才是正經(jīng)事兒,老子按時下班才是正經(jīng)事兒。
首先,我非常同意!然后,回答這個問題,這個每個人的個性使然。就像天天研究吃什么健康有個鳥用?中國的食品安全都保證不了,還健康?!但是就是有人就好這口,不是么?而且,我在這里只是解釋DIP,也并沒有說做的項目里,都要符合DIP啊。項目管理和架構(gòu)是很靈活的,不是幾個P就可以規(guī)范的起來的。有時候,直接找個開源的產(chǎn)品一搭,多快好省,一個P也用不著。如果非要給出個理由,我想恬不知恥地說句,追求卓越。(好吧,根本原因是,我喜歡得瑟,但是又不喜歡被明白人罵成豬頭,所以我選擇先搞明白了再去得瑟。)
但是我還是要說說了解這個原則的好處,不然寫這文章不是打自己臉么?了解依賴倒置的意義,并不限于設(shè)計,還在于思想上的轉(zhuǎn)變。理解這個原則之后,你會發(fā)現(xiàn)自己明明已經(jīng)把這個原則用上了,比如做需求分析的時候,肯定是問用戶想要什么,而不是我們能做到什么。
這個原則在協(xié)作上也有用處。請回想一下,在工作中,是否遇到過上層開發(fā)人員等下層開發(fā)接口的情況呢?如果遇到過,當(dāng)時有沒有想過,這個依賴關(guān)系是不是反了呢?其實,應(yīng)該是下層模塊的開發(fā)者依賴上層開發(fā)者呀。上層開發(fā)者定義好他依賴的接口,下層開發(fā)者來實現(xiàn),同時,因為接口已經(jīng)定義好了,上層也不用等下層開發(fā)者,完全可以用些Mock框架進(jìn)行測試嘛。但是,如果讓下層開發(fā)者定義接口,顯然上層開發(fā)者就必須等,Mock類也寫不了。
關(guān)于這個原則,我還見到過更廣義,更天下大同的解釋。在客戶關(guān)系上,我們常見的依賴是開發(fā)者依賴客戶,客戶說什么我們就得做什么,一點主動權(quán)都沒有。于是有人就把依賴倒置的原則拿來,說,應(yīng)該讓客戶依賴開發(fā)者!大有,“我們說什么,客戶就聽什么!”的派頭。到底哪個依賴是倒置的我就不在這兒爭了,因為我覺得這完全不是依賴的方向性問題。而是店大欺客還是客大欺店的問題。如果你在IBM、在SAP、在四大,你可以讓客戶聽你的。如果你在一個小屁公司,或者客戶是政府部門,你倒置個試試?
自此之后,一切安好。
直到有一天,又有一個用戶,他的燈上的開關(guān)也壞了,然后他試著把另外一家廠商的開關(guān)裝了上去,卻發(fā)現(xiàn)打不開燈。用戶抱怨道,他的這個開關(guān)可是按國際標(biāo)準(zhǔn)實現(xiàn)的,我們的燈具應(yīng)該支持這種標(biāo)準(zhǔn)開關(guān)。
如果有可能,我們一定會讓這個燈支持這個國際標(biāo)準(zhǔn)。可是燈已經(jīng)賣出去了,出廠的千千萬萬個燈都召回的代價也很大。
這個燈的設(shè)計,又要做出怎樣的變化呢?
原文鏈接:http://www.cnblogs.com/nankezhishi/archive/2012/05/26/dip.html
【編輯推薦】