Preemptive reading
dbaplus社群 · 新書搶讀-01期-
編者有言:如何選對技能進階好書,一直是個有點糾結的難題。為了幫助大家搶讀新鮮内容,把握重要技術能力,dbaplus社群全新開啟新書搶讀欄目,助力大家的技術進階之路。

本文将帶你搶先閱讀第四章:無處不在的耦合
(PS:文末有驚喜哦~)
内容概覽
一、耦合的種類
- 資料之間的耦合
- 函數之間的耦合
- 資料和函數之間的耦合
- 耦合的複雜度關系
二、耦合中既有敵人也有朋友
-
關于招式:
不要吝啬頒發“結婚證”
七個葫蘆娃合成一個金剛娃
三、壞耦合的原因
- 刻舟求劍
- “談戀愛”是個危險的行為
- 侵占公共資源
- 需求變化——防不勝防
四、解耦的原則
- 讓子產品邏輯獨立而完整
- 讓連接配接橋梁堅固而相容
五、總結
作者介紹
餘葉,現任IBM架構師,曾就職于是德科技和中國航信。愛代碼,愛思考。研究過已經死亡的MFC,還精通步入頹勢的.NET,之後又在方興未艾的iOS領域做架構師,順勢玩了玩Java,了解了服務端開發。不知不覺中,前端也積累了一定的經驗。越做越雜之後,有了個體面的稱呼“遮羞”:全棧工程師。
每當評論代碼的時候,我們經常聽到這有耦合啊,那要解耦啊,耳朵都聽出繭子了。可真被問到什麼是耦合時,可能就愣住了。也難怪,這确實不太容易了解。
很多時候對某樣東西了解困難,常常是因為對概念沒有了解。如果給耦合下個通俗一點的定義,我認為可以是耦合代表各種元素之間的依賴性和相關性。而且耦合在代碼裡無處不在。
一、耦合的種類
耦合的種類一直很少被人談及。本文站在資料和代碼的角度進行總結,它共有三大類。
1、資料之間的耦合
這個最簡單。假如在 Person 類裡有兩個成員變量,例如:
其中 name 和 age 被框在了同一個類裡面,它們就産生了耦合:當你通路 person.name 時,就知道隔壁一定還有一個 person.age 資料。
2、函數之間的耦合
同理,如果兩個函數處于同一個類中,它們也會有相關性,例如:
其中 person.GetName 和 person.GetAge 這兩個功能一定是同時存在的。如果兩個函數之間有調用,即使不在同一個類,也肯定有耦合,例如:
那麼,DriveCar 函數就和 FillFuel 函數産生了耦合。如果 FillFuel 函數出錯,也會導緻
DriveCar 函數出錯。
3、資料和函數之間的耦合
資料和函數之間的耦合形式及其變種是最複雜的,這裡列舉幾個案例:
案例 1
我們常見的控制型代碼:
那麼,到底是執行Hire 還是執行Reject 呢?具體的執行流程還是取決于person.isEligible這個 bool 類型的資料。
案例 2
這裡給出一個再形象點的例子:
你跳我就跳,你不跳我也不跳。我的行為緊緊耦合在你的行為之上。
案例 3
這裡給出一個隐藏更深的例子:
總之,業務需求是在執行 PlayMusic 之前,必須要先執行一遍 PowerOn 函數。表面上是 PlayMusic 對 PowerOn 有依賴性,是函數之間的耦合,但背後的原因是:
這兩個函數是通過 this.isPowerOn 這個資料進行溝通的。這本質上還是資料和函數之間的耦合。
4、總結
上面針對每一種耦合形式僅僅舉了一個例子,這其實是不夠的。耦合的形式多種多樣,更有複合型的耦合。其他部分會陸續介紹很多耦合的例子。
從複雜程度而言,三種耦合的複雜度是依次遞增的:
- 資料之間的耦合較簡單;
- 函數之間的耦合較複雜;
- 資料和函數之間的耦合最多變、最複雜。
二、耦合中既有敵人也有朋友
可能我們平時過于強調解耦,是以很多人誤以為耦合是個貶義詞,都是不好的。這裡要着重澄清一下:
其實大部分的耦合是業務邏輯的要求,是為了滿足正當的需求所産生的。
這樣的耦合正是我們所需要的。前文介紹的所有的耦合,都是反映業務需求的。現實中,還有很多耦合是系統或者底層子產品的限制所緻,例如:
這種耦合雖然不是使用者的需求,但也是合理的。我們必須通過代碼反映出來。
不是每一種耦合都是有害的,區分耦合的敵我關系非常重要。對耦合要一分為二地看:世界上既有好耦合,也有壞耦合:
- 好耦合:很多耦合對應着業務需求或者系統限制,這種耦合是合理的,我們将其稱為“好耦合”。對于好耦合,我們有時還要強化它們:将隐式的變成顯式的,将松散的變成内聚的。實際上,在我們的代碼中,絕大部分耦合都是好耦合,是朋友。
- 壞耦合:對于那些考慮不足或缺乏經驗造成的預料之外的耦合,我們稱為“壞耦合”,是敵人,要盡量剔除。
這裡介紹兩個很具代表性的、針對好耦合強化元素之間關聯的例子。
招法一:不要吝啬頒發“結婚證”
在界面上的不同位置要顯示多種不同的圖形,如三角形、正方形等,這裡所有的資訊濃縮在下面兩個數組裡:
- 一個是 shape 數組:{"三角形", "正方形", "長方形", "菱形"}。
- 一個是 position 數組:{point1, point2, point3, point4}。
兩個數組的元素個數是一樣多的,它們是一對一的關系。比如,第一個 position 就是第一個 shape 的位置資訊。那麼代碼如下:
這樣做友善但不好。它會為以後的修改埋藏隐患。因為兩個數組元素之間的對應關系,并沒有得到正式承認。這好比是兩個人在一起生活(沒有領結婚證),就以為結婚了,但其實是不會受到法律保護的。
一旦在某個數組中插入或删除一條資料,就會輕易導緻兩個數組的對應關系徹底亂套。
那麼如何讓它們變成強關聯,好比頒發結婚證一樣呢?
可以封裝到散清單裡,其中每個 key 代表 shape 類型,value 就是 position 資訊。這樣它們之間的對應關系就徹底綁定了,例如:
這個例子是将隐式的對應關系變成了顯式的對應關系,強化了耦合。就像之前是隐婚,現在光明正大地結婚了。
招法二:七個葫蘆娃合成一個金剛娃
執行個體如下:在每個界面中都有一個按鈕,其大小一樣,長度和高度分别是 25 像素和 16 像素。
很多人初始這樣寫:将 25 和 16 這兩個資料放在第一個頁面中,然後順勢複制到每個頁面中。
于是每個頁面都有 25 和 16 這兩個常量。
這是不好的!顯然按鈕的大小可能會經常調整,一旦修改,需要修改所有頁面的 25。
大家要切記:資料之間若存在相關性,一定要有展現!應該定義兩個全局變量,代替所有頁面裡的相關數字。這個變量就是合并後的金剛娃:
static readonly int width = 25;
static readonly int height = 16;
width 替換所有的 25,height 替換所有的 16,而且這兩個全局變量本身會透露出一條隐含業務需求:所有頁面的按鈕大小是一緻的,而這個值就是我。
此外,我能很友善地控制所有的按鈕大小,一旦需要修改,改一處即可。而常量 25 和 16 再多都不能表達這個資訊,它們的力量是分散的。
如果資料丢失了必要的相關性,後期的維護也容易出 bug。這個例子是将松散的聯系變成了内聚的聯系。
招法總結
對待耦合,我們不能光談解耦。其實強化耦合,讓它們高内聚,也是優化耦合的主要任務之一。
大部分耦合其實是我們需要的,耦合并不是貶義詞。如果你不能接受,可以用好聽點的名稱代替:相關性。它們本質上是一樣的。
三、壞耦合的原因
我們不需要的、不應該存在的耦合,或者說不靈活、不能面向将來變化的耦合,都是怎麼來的呢?這裡列舉幾種造成錯誤耦合的最主要的原因:
1、刻舟求劍
我們在國小課本裡曾學過“刻舟求劍”的故事,不了解一個人怎麼會這麼傻。其實這樣的傻事,有些程式員每天都在上演。
案例 1
假如你每天起床依賴于自家鬧鐘,這樣做很合适。但如果有人早起是依賴于鄰居每天早上唱歌,而且是在沒有告知鄰居的情況下,就匪夷所思了。相關代碼如下:
因為鄰居的行為是不受你控制的,一旦他不唱了,你就睡過頭了。
案例 2
先後執行存錢和取錢的操作,相關代碼如下:
void SaveMoney(float money); // 步驟一
void WithdrawMoney(float money); // 步驟二
如果有假币出現,那麼存錢函數 SaveMoney 就提前處理了,并不會存進去。
但這導緻取錢函數 WithdrawMoney 從來沒有遇到過假錢,而它并沒有處理假錢的能力,也一直沒被發現!一旦業務允許先透支取錢,那麼 WithdrawMoney 函數很可能把假币給使用者。
根本原因就是長久以來,WithdrawMoney 函數一直依賴于 SaveMoney 函數去處理假币。一旦失去了這個保護傘, 它自己的邏輯缺陷便暴露出來了。
這個例子具有普遍性。
在航天事故中有個理論:任何一個大事故的産生,背後都有 300 個小事故,而每個小事故背後,又有若幹事故。當所有的小事故湊巧同時打開的時候,大事故就來了。
是以每個依賴鄰居的行為,都無意識地制造了一個小事故,這種事故的特點是它并不容易一次性地測試出來,它像有些病毒一樣,發作是有潛伏期的。它的個體危害不算大,但整個身體充滿了這種小病毒,那麼身體遲早要被擊垮。
要解決這類耦合,有一個非常行之有效的方法:單元測試。雖然這種耦合表面上不是 bug,因為業務暫時都能通過,但通過單元測試,很容易發現這裡存在潛在的 bug。
2、“談戀愛”是個危險的行為
如果有兩個資料,你中有我,我中有你,形成雙向依賴,這種“談戀愛”的方式是危險的,因為一方要分手,會給另一方造成麻煩。
而暗戀是美好的,他隻是默默注視着,卻從不打擾你, 一旦遇到了變更,也能輕易地更換注視目标。“輕輕的我走了,正如我輕輕的來”,你也從來沒有感受到變化,自然就不會有困擾。
舉例:
這裡的 StudentModel 和 StudentController 本來屬于 MVC 架構裡不同層級的對象, 它們形成的雙向依賴,糾纏在一起,很難分手。
它們耦合在一起,對适應将來的變化是不利的。而理論上,model 層并不應該知道 controller 的具體細節。那麼,如何将它們轉為單相思呢?後續我們會慢慢介紹。
3、侵占公共資源
假如一個公共變量,你錯誤地修改了它,則直接影響到所有使用它的人。這種耦合導緻的錯誤可能是非常可怕的。
我們熟知的多線程程式設計其實就是最典型的“影響公共資源”的耦合場景。多線程程式設計為什麼那麼難?本質上它是耦合複雜度的最極端展現。比如死鎖,那等于耦合到了極緻,完全成了一團亂麻,無法剝開了。
對付這種耦合,我們需要盡量做到公共資源是不可變的,或者操作它的途徑非常有限、可控。
4、需求變化——防不勝防
前幾種壞耦合都屬于在同一時間線發生的結構性耦合,接下來介紹的耦合屬于另外一種情況,是跨越不同時間線産生的耦合,也就是需求發生了變化而導緻的壞耦合。
我們說每一個好耦合都對應真實的需求,但如果需求本身改變了,那麼好耦合也就變成了壞耦合。但“需求變化”的含義太大了,可以分為很多種類。
- 有的是硬性的需求變化。比如,你開發好了一個“五子棋”遊戲,結果老闆告訴你“五子棋” 大家不太喜歡,還是改成“圍棋”吧,此時和“五子棋”相關的代碼都要改。
- 有的是軟性的需求變化。它所帶來的影響可能更多是我們自身缺乏遠見造成的。比如,全世界有很多大城市的老城區,你會發現那裡的街道太窄,小區也沒有足夠的停車位。因為人們沒有預料到十幾年後這裡居然人會這麼多,車會這麼多,而拆除重建的代價是巨大的。
這種情況是很普遍的,造成這點的現實原因有很多:比如很多時候,寫第一遍的代碼,往往迫于項目進度的壓力,先做個簡陋版本,拿到第一期經費再說。随後重構的話也能承擔,因為程式員知道:我們比建築勞工幸運,軟體的重構成本要比拆房重建低多了。
重構的目标應該是:重構後,能一次性解決可預見的問題,即對某一個具體需求的重構有足夠的遠見。
如果你對一個停車場擴容,沒兩年,停車場又飽和了,這是很不好的。
我們要一邊疊代開發,一邊重構。重構也算是疊代開發的任務,因為随着項目體積越來越大, 我們也需要更好的架構支撐自己。否則,積重難返,大廈很難繼續往上累加。
一般項目在疊代開發的時候,有兩分精力是放在使用者看不到的内部優化中,八分精力放在新需求的開發上,這樣整個産品的品質在持續疊代中才能有很好的保障。
四、解耦的原則
每一個子產品好比大海裡的一座孤島,需要橋梁和其他孤島連接配接,那麼通過多少橋梁相連呢?
隻要有需求對應,那麼建立多少橋梁都沒有問題。但我們經常會無意地建立很多埋在水面之下的隐形橋梁,并沒有與之對應的需求,這是壞耦合。
如何破除這些隐形橋梁,強化子產品間的連接配接,請看接下來介紹的兩個解耦原則。
1、讓子產品邏輯獨立而完整
我們做人要求人格獨立而完整,代碼也一樣,盡量讓每個子產品的邏輯獨立而完整。解耦的根本目的是拆除元素之間不必要的聯系,一個核心原則就是讓每個子產品的邏輯獨立而完整。這裡有兩個含義:
- 對内有完整的邏輯,而所依賴的外部資源盡可能是不變量。
- 對外展現的特性也是“不變量”(或者盡可能做到不變量),讓别人可以放心地依賴我。
充分做到了這一點,元素間很多不必要的聯系會自然消失。如何做到獨立而完整,這個話題實在是太大了,而且手段很多,并沒有一個特别标準的流程。本節中,我們隻研究一種最容易上手的方法——如何讓單個函數的邏輯獨立而完整。
有的函數光明磊落,它和外界資料的溝通僅限于函數的參數和傳回值,那麼這種函數給人的感覺可以用兩個字形容:靠譜。
它把自己所需要的資料都明确辨別在參數清單裡,把自己能提供的全集中在傳回值裡。如果你需要的某項資料不在參數裡,你就會依賴上别人,因為你多半需要指名道姓地标明某個第三方來特供;同理,如果你提供的資料不全在傳回值和參數裡,别人會依賴上你。
有的函數讓人覺得神秘莫測,規律難尋:它所需要的資料不全部展現在參數清單裡,有的隐藏在函數内部,這種不可靠的變量行為很難預測;它的産出也不集中在傳回值,而可能是修改了藏在某個不起眼角落裡的資源。
這樣的函數需要人們在使用過程中和它不斷地磨合,才能掌握它的特性。
前者使用起來放心,而且是可移植、可複用的,後者使用時需要小心翼翼,而且很難移植。下面看兩個案例是如何做到邏輯獨立而完整的:
案例 1
在每個資料庫操作函數中,都有一對 db.open 和 db.close 語句。例如 updatePersons函數的代碼如下:
可是在 update 函數裡面也有資料庫的操作,卻無須 sharedDB 的 open 和 close 語句。
這是因為 update 是在 updatePersons 函數中調用的,而 sharedDB 在 updatePersons 函數中已經被打開了,也将在 updatePersons 函數中關閉,是以 update 的實作為:
如果 update(Person person)是一個private 函數,這沒有太大不妥;如果 update 是一個 public 函數,那麼如此實作是不合格的:很明顯,它裡面并沒有打開資料庫的操作,是以它的 ExecuteSQL 語句依賴于“sharedDB 處于 opened 狀态”這個條件。
但這個限制是隐形的, 使用者并沒有得到有效的提示。如何解決或優化這個問題呢?我們不妨将資料庫資源參數化:
之後對 update 的調用變成了:
這樣做的好處如下:
- 多了一個 DBConnection 類型的參數,逼迫别人要傳進來一個資料庫連接配接變量。
- 參數名 openedDB 已經明确指明了該 DB 的特性,能給使用者有效的提示:傳進來的需要是已經處于 open 狀态的資料庫連接配接。
從此,update(Person person, DBConnection openedDB)函數已經無須調用者專門關注它被使用的前提隐含條件,因為它自身的對外資訊描述得足夠清楚了。一旦具備了這個特征, 它就初步具備了可移植性。
是以,當程式員對一個類或一個方法的使用需要額外的記憶時,這不是好代碼。我們要盡可能地讓代碼遠離那些隐含的前提條件。這樣程式員在使用的時候,才不會覺得處處是坑。
案例 2
這個案例稍微長一些,但并不難,是以請耐心地一步一步跟着我的節奏去看。例如,一個人要讀書:
如果這個人沒有眼鏡,即 this.MyGlasses 變量為 null,直接調用 person.ReadBook(book);會出現異常,怎麼辦呢?
優化一:通過屬性注入
于是打個更新檔邏輯吧,在 ReadBook 之前先給他配副眼鏡:
person.MyGlasses = new Glasses(); // 先為person 配副眼鏡
person.ReadBook(book);
如上,加上了 person.MyGlasses = new Glasses();這行代碼(别看它簡單,人家也有專業叫法,叫作屬性注入),這個 bug 就解決了。可解決得不夠完美,因為這要求每個程式員都需要記住調用 person.ReadBook(book)之前,先進行屬性注入:
person.MyGlasses = new Glasses();
這很容易出問題。因為 ReadBook 是一個 public 函數,使用上不應該有隐式的限定條件。
如今,“看書”依賴于“眼鏡”的存在是個剛性的業務需求,是以這個耦合是沒辦法消除的。我們能做的是要減輕程式員的記憶負擔,無須強行記住“每次調用 ReadBook(book),還必須先初始化 person.MyGlasses”這麼一個坑。
這種問題相信每個人都遇到過,如何優化呢?
優化二:通過構造函數的注入
我們可以為 Person 的構造函數添加一個 glasses 參數:
這樣,每當程式員去建立一個 Person 的時候,都會被逼着去建立一個 Glasses 對象。程式員再也不用記憶一些額外需求了。這樣邏輯便實作了初步的自我完善。
當 Person 類建立得多了,會發現構造函數的注入會帶來如下問題:
因為 Person 中的很多其他函數行為,如吃飯、跑步等,其實并不需要眼鏡,而喜歡讀書的人畢竟是少數,是以person.ReadBook(book);這句代碼的調用次數少得可憐。
為了一個偏僻的 ReadBook 函數, 就要讓每個 Person 都必須配一副眼鏡(無論他讀不讀書),這不公平。也對,我們應該讓各自的需求各自解決。
那麼,還有更好的方法嗎?下面介紹的“優化三”進一步解決了這個問題。
優化三:通過普通成員函數的注入
于是可以進行下一步修改:恢複為最初的無參構造函數,并單獨為 ReadBook 函數添加一個 glasses 參數:
對該函數的調用如下:
person.ReadBook(book, new Glasses());
這樣隻有需要讀書的人,才會被配一副眼鏡了,實作了資源的精确配置設定。
可是呢,現在每次讀書時都需要配一副新眼鏡:new Glasses(),還是太浪費了,其實隻需要一副就夠了。
優化四:封裝注入
好吧,每次取自己之前的眼鏡最符合現實需求:
person.ReadBook(book, person.MyGlasses);
這又回到了最初的問題:person.MyGlasses 參數可能為空,怎麼辦?
幹脆讓 person.MyGlasses 封裝的 get 函數自己去解決這個邏輯吧:
對 ReadBook 函數的調用如下:
person.ReadBook(book);
這樣每次讀書時,就會複用同一副眼鏡了,也不會影響 person 的其他函數。最終的這段 ReadBook 代碼是最具移植性的,稱得上獨立而完整。
可以看到,從優化一到優化四,繞了一圈,每一步修改都非常小,每一步都是解決一個小問題,可能每一步遇到的新問題是之前并沒有預料到的。
優化一到優化三分别是 3 種依賴注入的手段:屬性注入、構造函數注入和普通函數注入。它們并沒有優劣之分,隻有應用場合之分,這裡我們是用一個案例将它們串起來介紹了。
同時大家通過這個小小的例子也可以體會到:寫精益求精的代碼,是需要工匠精神的。
讓每一個子產品獨立而完整,其内涵是豐富的。它把自己所需要的東西全列在清單上,讓外界提供,自己并不私藏。
這意味着和外界的關聯是單向的,這樣每個子產品都變得規規矩矩,容易被使用。如果子產品要被替換,拿掉時也不會和周圍子產品藕斷絲連。
那麼,問題來了,如果都做“縮頭烏龜”不去關聯别人,可是那麼多的聯系總得有人去實作, 誰去實作呢?最好讓專門管理“橋梁”的子產品去實作,這就涉及到了下一個解耦原則。
2、讓連接配接橋梁堅固而相容
前面說了,子產品好比孤島,孤島之間需要橋梁去連接配接。而我們需要這些橋梁堅固(具有不變性),還可以相容各種島嶼(具有相容性)。
這個原則太重要了,尤其要減少前文所提到的“需求變化”所帶來的影響,主要就是靠橋梁的品質來應付。
解決這種耦合是需要架構師提前預判的,我們要盡量讓變化落在島嶼上,而不是橋梁上。因為更換橋梁的成本要更高,風險要更大!
指導原則說完了,還有哪些具體招數呢?書中會有更多方法介紹。
五、總結
耦合不是貶義詞,它的本質是相關性。如果符合業務需求,反映底層系統限制,就是好耦合; 否則,就需要解耦。
解耦的手法多種多樣,需要不斷地積累。
—To be continued—
Special Thanks
在本文微信訂閱号(dbaplus)評論區留下足以引起共鳴的真知灼見,小編将在下周四(4月18日)中午12點,根據留言精彩程度選出3位幸運讀者,送出本文的優質圖書一本~
特别鳴謝@圖靈教育為本專欄推薦優質圖書。
進階是一種常态,思維是一種技能
想在職業發展道路上少走彎路
不妨來這些技術盛會學點獨家技能
↓↓掃碼可了解更多詳情及報名↓↓
2019 Gdevops全球靈活運維峰會-北京站