代碼腐化的原因
Code is read far more times than it's written
軟件開(kāi)發(fā)的成本也大都發(fā)生在第一次交付之后估灿。分析曾經(jīng)重構(gòu)過(guò)的幾個(gè)項(xiàng)目發(fā)現(xiàn)备徐,在項(xiàng)目的最初,代碼也都還算是眉清目秀窟却,但隨著時(shí)間的推移,由于需求的不斷變更君躺,代碼逐漸演化成了一個(gè)邏輯迷宮犹撒,一個(gè)維護(hù)的焦油坑横腿。
需求變更
殺死一個(gè)程序員不用槍颓屑,改三次需求就可以了辙培。每一次需求的變更都使代碼更復(fù)雜一點(diǎn),假以時(shí)日邢锯,為了應(yīng)對(duì)不斷變化的需求扬蕊,代碼變得越來(lái)越復(fù)雜。下文通過(guò)一個(gè)咖啡制作的例子來(lái)演示代碼腐化的過(guò)程丹擎。
沖泡速溶咖啡
原始需求
為客戶泡制速溶咖啡尾抑,制作過(guò)程分為三步:
- 倒入咖啡粉
- 加入沸水
- 攪拌
void makeCoffee() {
pourCoffeePowder();
pourBoilingWater();
stir();
}
新需求,制作奶咖
void makeCoffee(bool isMilkCoffee) {
pourCoffeePowder();
pourBoilingWater();
if(isMilkCoffee) {
pourMilk();
}
stir();
}
新需求蒂培,加糖
void makeCoffee(bool isMilkCoffee, bool isSweetTooth再愈,CoffeeType type) {
pourCoffeePowder();
pourBoilingWater();
if(isMilkCoffee) {
pourMilk();
}
if(isSweetTooth) {
addSugar();
}
stir();
}
新需求,可以選擇咖啡口味
const int MAX_WATER_MACHINE_COUNT = 10;
void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
if (type == CAPPUCCINO) {
pourCappuccinoPowder();
}
else if (type == BLACK) {
pourBlackPowder();
}
else if (type == MOCHA) {
pourMochaPowder();
}
else if (type == LATTE) {
pourLattePowder();
}
else if (type == ESPRESSO) {
pourEspressoPowder();
}
pourBoilingWater();
if (isMilkCoffee) {
pourMilk();
}
if (isSweetTooth) {
addSugar();
}
stir();
}
應(yīng)對(duì)沒(méi)有開(kāi)水的異常
const int MAX_WATER_MACHINE_COUNT = 10;
void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
if (type == CAPPUCCINO) {
pourCappuccinoPowder();
}
else if (type == BLACK) {
pourBlackPowder();
}
else if (type == MOCHA) {
pourMochaPowder();
}
else if (type == LATTE) {
pourLattePowder();
}
else if (type == ESPRESSO) {
pourEspressoPowder();
}
bool hasBoilingWater = false;
while (!hasBoilingWater) {
for (int i = 0; i < MAX_WATER_MACHINE_COUNT; i++) {
if (isBoiling(i) {
hasBoilingWater = true;
break;
}
}
dawdling();
}
pourBoilingWater();
if (isMilkCoffee) {
pourMilk();
}
if (isSweetTooth) {
addSugar();
}
stir();
}
隨著時(shí)間的推移护戳,會(huì)有更多的需求增加進(jìn)來(lái)翎冲,譬如:
- 需要支持售賣現(xiàn)磨咖啡
- 需要提供更多的咖啡類型
- 需要提供給用戶更多的口味選擇
- 需要制作冰咖啡
- 需要拉花
另外,在現(xiàn)實(shí)的工程中我們不得不不考慮更多的異常
- 各種材料都存在售空的可能媳荒,每一個(gè)函數(shù)都有失敗的可能抗悍,都需要返回狀態(tài)碼來(lái)返回執(zhí)行結(jié)果,而函數(shù)的調(diào)用方則需要檢查函數(shù)的返回值钳枕,根據(jù)返回值的不同決定具體的邏輯分支缴渊。
- 更合理的機(jī)制是,我們?cè)谛枰谟脩魷?zhǔn)備下單的那一刻起就判斷出制作咖啡所需要的各種材料是不是都有現(xiàn)貨鱼炒,而不是在沖泡過(guò)程中發(fā)現(xiàn)原材料缺失衔沼。
- 如果有多個(gè)收銀員,還要考慮多線程資源共享的問(wèn)題昔瞧。
可以預(yù)見(jiàn)指蚁,隨著需求的變更,我們的函數(shù)會(huì)越來(lái)越長(zhǎng)自晰,越來(lái)越復(fù)雜凝化,它最終將會(huì)從三段式的優(yōu)雅表達(dá),演化成一個(gè)if else交織的邏輯謎團(tuán)缀磕。
問(wèn)題分析
分析前面的代碼缘圈,我們發(fā)現(xiàn)劣光,比較最初的版本袜蚕,這些代碼中加入了更多的細(xì)節(jié)已滿足不斷增加的需求。但是從整體上看绢涡,它只完成了四件事:
- 倒咖啡粉牲剃,會(huì)有不同的選擇
- 倒開(kāi)水,要應(yīng)對(duì)沒(méi)有開(kāi)水的異常
- 調(diào)味雄可,根據(jù)需求加糖或者加奶
- 攪拌
重構(gòu)
代碼是程序員用來(lái)溝通的工具凿傅,代碼應(yīng)該反應(yīng)程序員的意圖缠犀。既然我們想要通過(guò)四個(gè)步驟完成咖啡的沖泡,代碼就應(yīng)該清晰地體現(xiàn)這種邏輯聪舒。按照這個(gè)思路辨液,我們把這四件事分別封裝到四個(gè)子函數(shù)里,得到代碼如下:
void makeCoffee(bool isMilkCoffee, bool isSweetTooth, CoffeeType type) {
pourCoffeePowder(type);
pourWater();
flavor(isMilkCoffee, isSweetTooth);
stir();
}
void pourCoffeePowder(CoffeeType type) {
if (type == CAPPUCCINO) {
pourCappuccinoPowder();
}
else if (type == BLACK) {
pourBlackPowder();
}
else if (type == MOCHA) {
pourMochaPowder();
}
else if (type == LATTE) {
pourLattePowder();
}
else if (type == ESPRESSO) {
pourEspressoPowder();
}
}
const int MAX_WATER_MACHINE_COUNT = 10;
void pourWater() {
bool hasBoilingWater = false;
while (!hasBoilingWater) {
for (int i = 0; i < MAX_WATER_MACHINE_COUNT; i++) {
if (isBoiling(i) {
hasBoilingWater = true;
break;
}
}
dawdling();
}
pourBoilingWater();
}
void flavor(bool isMilkCoffee, bool isSweetTooth) {
if (isMilkCoffee) {
pourMilk();
}
if (isSweetTooth) {
addSugar();
}
}
按這個(gè)思路重構(gòu)完成后箱残,我們的代碼又變得干凈簡(jiǎn)潔了滔迈。
拆分帶來(lái)的好處
更清晰的表達(dá)
拆分后的版本像文章給出章節(jié)目錄一樣列出了程序的框架,每個(gè)子函數(shù)給出了具體的實(shí)現(xiàn)細(xì)節(jié)被辑。這種分層次的表達(dá)方式燎悍,使閱讀者可以從梗概到細(xì)節(jié)的了解函數(shù)的意圖,為閱讀者有選擇地忽略某些具體的實(shí)現(xiàn)細(xì)節(jié)提供了可能盼理。而面對(duì)拆分前的版本谈山,閱讀者只能在一堆邏輯細(xì)節(jié)中反向推導(dǎo)出程序的梗概。
更好的應(yīng)對(duì)變化
衡量一個(gè)設(shè)計(jì)好壞的標(biāo)準(zhǔn)是宏怔,當(dāng)變化來(lái)臨的時(shí)候奏路,設(shè)計(jì)被破壞的程度。假定有個(gè)新的需求臊诊,需要支持新的Coffee
類型思劳。
在拆分前的版本里,我們需要在makeCoffee
這個(gè)大函數(shù)中找到根據(jù)類型選擇咖啡粉的那幾行代碼妨猩,在此基礎(chǔ)上加入新的邏輯潜叛。由于這個(gè)新需求的引入,makeCoffee
發(fā)生了修改壶硅,我們需要對(duì)整個(gè)整個(gè)函數(shù)做回歸測(cè)試威兜,以保證我們的修改沒(méi)有破壞原有的功能。
而拆分后的版本中庐椒,我們只需要修改pourCoffeePowder
數(shù)椒舵,也只需要針對(duì)這個(gè)函數(shù)做回歸測(cè)試。而其他的邏輯可以被完全重用约谈。
通過(guò)函數(shù)拆分笔宿,縮小了變化發(fā)生時(shí)軟件修改的范圍,使盡量多的軟件得到重用棱诱,降低了開(kāi)發(fā)的成本泼橘。
更好的復(fù)用
越是獨(dú)立的功能越是容易被復(fù)用。譬如如果我們有一個(gè)makeTea
的需求迈勋,pourWater
的邏輯就可以被復(fù)用炬灭。
除非要提取的函數(shù)本身是一個(gè)獨(dú)立的概念,能封裝實(shí)現(xiàn)細(xì)節(jié)靡菇,更清晰的表達(dá)意圖重归,否則不要僅僅為了可能的復(fù)用而提取函數(shù)米愿。因?yàn)槲覀冇肋h(yuǎn)無(wú)法預(yù)測(cè)下一個(gè)需求,因而無(wú)法預(yù)測(cè)哪一段邏輯有可能被復(fù)用鼻吮。
背后的原則
單一職責(zé)(Single Responsibility Principle
)
所謂單一職責(zé)育苟,是指一個(gè)實(shí)體應(yīng)該有且僅有一個(gè)職責(zé)。
首先請(qǐng)大家思考一個(gè)問(wèn)題椎木,拆分前makeCoffee
函數(shù)是不是單一職責(zé)的宙搬?答案看上去是肯定的,畢竟它只完成了一個(gè)功能:制作咖啡拓哺。
在回答這個(gè)問(wèn)題之前勇垛,我們需要先明確一個(gè)概念,什么是職責(zé)士鸥。在這里闲孤,援引Uncle Bob
在PPP
里的定義:所謂職責(zé),就是變化的原因烤礁。
軟件設(shè)計(jì)的學(xué)問(wèn)就是對(duì)復(fù)雜度管理的學(xué)問(wèn)讼积,管理問(wèn)題域的本質(zhì)復(fù)雜度,盡量降低因?yàn)樵O(shè)計(jì)與實(shí)現(xiàn)而帶來(lái)的偶發(fā)復(fù)雜度脚仔。而衡量設(shè)計(jì)優(yōu)劣的標(biāo)準(zhǔn)就是應(yīng)對(duì)變化的能力勤众。當(dāng)變化來(lái)臨時(shí),我們希望能盡可能地重用原來(lái)的代碼鲤脏。換句話說(shuō)们颜,是希望盡量少的代碼受到變化的影響。我們通過(guò)類猎醇、函數(shù)等方式來(lái)封裝變化窥突,努力讓一個(gè)變化的影響盡量局部。如果當(dāng)變化發(fā)生時(shí)硫嘶,所有相關(guān)的改動(dòng)都發(fā)生在一處阻问,那么這個(gè)職責(zé)就是高內(nèi)聚的。譬如沦疾,用戶要求提供更多的咖啡類型称近,只需要擴(kuò)充pourCoffeePowder
即可。pourCoffeePowder
封裝了咖啡類型這一變化方向哮塞,因?yàn)榭Х阮愋偷脑鰟h而導(dǎo)致的修改全部會(huì)發(fā)生在這個(gè)函數(shù)中刨秆。
讀者可能會(huì)有疑問(wèn),如果我們把所有的實(shí)現(xiàn)都放在一個(gè)函數(shù)里彻桃,需求變化引起的所有改動(dòng)也都在這一個(gè)函數(shù)內(nèi)坛善,這樣的函數(shù)是不是也是高內(nèi)聚的晾蜘?必須指出高內(nèi)聚有兩層含義:
- 關(guān)聯(lián)緊密的事物應(yīng)該被放在一起
- 只有關(guān)聯(lián)緊密的事物才應(yīng)該被放在一起
從需求的角度分析邻眷,用戶要求支持更多的咖啡粉的選擇眠屎,必須要做的修改應(yīng)該僅僅是與咖啡粉選擇相關(guān)的邏輯。除此之外其他的更改肆饶,都是在應(yīng)對(duì)因設(shè)計(jì)改衩、實(shí)現(xiàn)而帶來(lái)的偶發(fā)復(fù)雜度。也正是因?yàn)橐粋€(gè)簡(jiǎn)單的需求的變化驯镊,需要在龐大的code base中到處做修改才導(dǎo)致了系統(tǒng)的僵化性(Rigidity
)葫督,導(dǎo)致系統(tǒng)的維護(hù)及擴(kuò)充變得困難。
拆分前的makeCoffee
函數(shù)里所包含的職責(zé):
- 根據(jù)類型選咖啡粉板惑。變化方向:咖啡類型的增刪
- 取水橄镜。變化方向:面臨缺水,需找可用的熱水冯乘。這里邊有可以延伸出其他的變化洽胶,譬如水桶空了,需要換水裆馒,甚至需要打電話訂水姊氓;可能需要冰水
- 根據(jù)用戶選擇,加糖或牛奶喷好。變化方向翔横,其他口味需求,可能需要加肉桂粉梗搅、拉花禾唁、花形等選擇
- 攪拌。變化方向:攪拌的力度
- 制作咖啡的四步流程无切。變化方向:流程上的變化蟀俊,可能需要先攪拌,后加糖
因此订雾,拆分前的makeCoffee
是單一功能的肢预,卻不是單一職責(zé)的。它涵蓋了整個(gè)咖啡制作過(guò)程的所有細(xì)節(jié)洼哎,它的穩(wěn)定性依賴于制作咖啡的所有細(xì)節(jié)烫映。任何一個(gè)細(xì)節(jié)的變化都會(huì)引起這個(gè)函數(shù)的變化。
SLAP(Single Layer of Abstraction Principle)
函數(shù)應(yīng)該拆的多小噩峦,哪些邏輯應(yīng)該平鋪锭沟,哪些邏輯應(yīng)該用子函數(shù)封裝?答案就在于SLAP
识补,即單一抽象層次原則族淮。這個(gè)原則要求在一個(gè)函數(shù)里平鋪的語(yǔ)句應(yīng)該在同一個(gè)抽象層次、同一個(gè)概念層級(jí)。
仍然是makeCoffee
的例子祝辣,倒熱水和攪拌處于同一個(gè)概念層級(jí)贴妻。但拿起杯子,小心地按下飲水機(jī)出水按鈕蝙斜,等待熱水慢慢盛滿四分之三水杯就處于更低的概念層級(jí)名惩,它是怎樣倒熱水的具體描述,與攪拌不在同一個(gè)邏輯層次孕荠。
代碼要表達(dá)的是程序員的意圖娩鹉,函數(shù)拆分的有效方法是,用自然語(yǔ)言來(lái)描述函數(shù)的功能稚伍。要實(shí)現(xiàn)這個(gè)功能需要哪些步驟弯予,或者分為哪些情況。函數(shù)中的每一行代碼都是直接表達(dá)這些步驟或者分類嗎个曙?如果不是熙涤,它們是不是某個(gè)步驟或情況的具體實(shí)現(xiàn)?能否作為單獨(dú)的問(wèn)題提取成函數(shù)困檩?這個(gè)簡(jiǎn)單的技巧可以讓我們理順函數(shù)的邏輯祠挫,最終寫出符合SLAP
原則的代碼。
可讀性是一種主觀的評(píng)判
在實(shí)際的工作中悼沿,經(jīng)常會(huì)被問(wèn)到提取函數(shù)到底是提高了還是降低了代碼的可讀性等舔。可讀性的衡量標(biāo)準(zhǔn)是閱讀糟趾、理解代碼時(shí)所花費(fèi)的時(shí)間慌植。提取函數(shù),并用一個(gè)合適的名字來(lái)命名义郑,為閱讀者忽略實(shí)現(xiàn)細(xì)節(jié)蝶柿,站在更高的抽象層次上理解問(wèn)題提供了可能。
但如果代碼中充斥著大量的詞不達(dá)意的命名非驮、甚至誤導(dǎo)性的命名交汤,譬如:
void checkForContinue(bool shouldContinue) {
if (shouldContinue) {
abort();
}
}
在這種情況下,閱讀者就不敢忽略任何實(shí)現(xiàn)細(xì)節(jié)劫笙,不得不去查看任何一個(gè)函數(shù)的具體實(shí)現(xiàn)芙扎。拆分成小函數(shù),尤其是函數(shù)長(zhǎng)度僅為幾行的函數(shù)反而降低了可讀性填大。
編程是一種匠藝
編程是一門匠藝戒洼,只有不斷的打磨,不斷地把事情做完美允华,才有可能獲得技藝上的提升圈浇。在實(shí)際工作中寥掐,由于種種約束,我們不得不采用不那么優(yōu)雅的方法來(lái)快速交付磷蜀,以更低的成本交付召耘。(這是不得不為之的妥協(xié),很多時(shí)候也是非常正確的選擇蠕搜,因?yàn)檐浖拈_(kāi)發(fā)的最終目標(biāo)是在軟件的整個(gè)生命周期內(nèi)怎茫,以最低的成本及時(shí)交付最大的價(jià)值收壕,技術(shù)卓越是達(dá)到這一目標(biāo)的手段而不是目標(biāo)本身妓灌。當(dāng)然,這個(gè)期限很重要蜜宪,考量的是軟件的整個(gè)生命周期)
一個(gè)優(yōu)秀的程序員虫埂,必須有能力給出合理的優(yōu)雅的實(shí)現(xiàn),想清楚兩中方案之間的優(yōu)劣圃验,審時(shí)度勢(shì)的做出合理的選擇掉伏。讓重要的技術(shù)決策是深思熟慮之后的理性選擇,而不是因技能缺失而不得不為的拙劣拼湊澳窑。
代碼腐化的應(yīng)對(duì)策略
要達(dá)到高內(nèi)聚低耦合的目標(biāo)斧散,就需要分離那些變的與不變的部分,把受某個(gè)變化影響的相關(guān)數(shù)據(jù)與邏輯封裝起來(lái)摊聋,讓變化對(duì)現(xiàn)有的設(shè)計(jì)影響最小鸡捐。
隨著需求的增加,新的變化方向不斷地被引入麻裁,原先高內(nèi)聚的模塊的邏輯有一部分受這個(gè)變化的影響箍镜,有一部分不受這個(gè)變化的影響,變得不再高內(nèi)聚煎源,這時(shí)候需要把原有的模塊做相應(yīng)的拆分色迂。
另外一種情況是,隨著需求的增加手销,發(fā)現(xiàn)原來(lái)分布在不同模塊的部分邏輯都受相同的變化的影響歇僧,它們之間有更強(qiáng)的內(nèi)在關(guān)聯(lián)。這時(shí)候也需要把模型及邏輯做相應(yīng)地調(diào)整锋拖。而這一過(guò)程馏慨,也就是領(lǐng)域模型發(fā)現(xiàn)與完善的過(guò)程。
總之姑隅,要想防止代碼的腐化写隶,需要根據(jù)新的需求,不斷地調(diào)整我們的架構(gòu)與實(shí)現(xiàn)讲仰,使他始終保持在一個(gè)合理的狀態(tài)慕趴,而不是遷就原有的架構(gòu)與實(shí)現(xiàn),為了實(shí)現(xiàn)新的功能不斷地做艱難地適配,讓架構(gòu)和實(shí)現(xiàn)變得越來(lái)越晦澀難懂冕房。