這個例子中的if else也要重構掉嗎?
今天在群裡面看到有人說用if else不好,他貼了下面的圖。我覺得這個例子很爛,一個很簡單的方法,被重構的一眼看不懂在幹嗎。
然後他又舉了個例子。貼了下面的代碼然後我覺得例子裡面最多說if條件重複了。邏輯也挺簡單的,也清晰。這樣也挺好,最主要是我想不到還有什麼更好的寫法。然後讓他說說更好的寫法。他就貼出了這個:我覺得這樣把問題搞複雜了。然後他覺得我應該看看函數式編程的思想。說函數是變成都用Pattern Matching,函數式編程我只是看過Haskell和Scala。我覺得Pattern Matching不是就是有點類似於switch嗎,只是更強大。問題來了,上面到底哪種方式更好呢?或者還有更好的方式重構這個代碼?真不能用這這麼多if else嗎?我個人覺得這個方法不複雜,代碼邏輯清晰,讓人一眼看懂。也還好。而且我也不喜歡很多if else,但是我想不到更好的寫法了。第二種方式我覺得完全是為了玩花活而玩的。真心請教! ====================================================================更新一下,書上的重構應該寫成 stackoverflow上有人用Haskell給出了他的解法。寫成js就是這樣。我個人覺得確實是很好的重構方案,可以少打很多代碼(我提問時抄原始代碼大於小於號寫錯了好幾次),減少出錯幾率,邏輯也很清楚,修改起來很方便, 加點注釋說明下可能更好。
function doSomething(a) {
var lookup = {x: doX, y: doY}, def = doZ;
(lookup[a] || def)();
}
function rank(score) {
return 1 + _.findLastIndex(
[-Infinity, 7, 9, 11, 30, 45, 60, 75, 90],
function(val){
return score &>= val;
})
}
首先回答問題:
這樣做是有意義的。
但是提問者和舉例子的人理解都有偏差。
首先是提問者說的改寫後邏輯反而不清晰了,這裡有兩個原因,一是對函數式和聲明式編程的不熟悉,下意識的希望控制所有的代碼邏輯和細節,缺乏高層思維。其次就是這些例子脫離實際,重寫的也不夠徹底。
那麼簡單來說說這樣的重構為何有意義?
首先某高票答案的說法基本完全無法認同,無論何種分支代碼代碼最終必然是條件跳轉,分支只能從一種形式轉換為另一種形式這種說法毫無意義,因為這裡的重構完全與分支無關。
這裡重構的意義在於把數據剝離出來!
雖然我覺得理解這個對於初學者而言有一定的難度,但是要理解這樣的重構,還是必須從這個層面來討論。function doSomething( a )
{
if ( a === "x" )
doX();
else if ( a === "y" )
doY();
else
doZ();
}
function doSomething(a) {
var lookup = {x: doX, y: doY}, def = doZ; //數據
(lookup[a] || def)(); //實現
}
這個重構的目的其實是在於,把數據從實現中剝離出來。
為什麼要這麼做?
原因一:
相較於實現邏輯,數據變更的可能性更大,例如我需要增加一個a==="h"的時候也執行doX,那麼直接修改數據就比修改if ... else要方便。在強類型設計語言中,強數據類型可以確保數據的完整性,修改數據比修改代碼要安全得多。這也是為什麼我們做一個程序有一堆配置的原因,因為相較於修改代碼,配置的修改,要安全得多。原因二:
分析和閱讀一段代碼的時候,很多時候是有側重面的,有時候側重於數據,有時候側重於邏輯。假設我們有這樣一個需求,當某某值小於100時,就如何如何。那這個裡面的100就是數據,當需求變更為某某值小於200時,才如何如何,那麼我們關注的點在於這個數據的修改。而不是整個邏輯的修改,數據的剝離,有助於我們更快的發現修改點和修改代碼。事實上通常用於消除魔幻數字的named data,本質上也是從實現中剝離數據的一種方式。
命名數據的例子:var maxAge = 100;
//...
if ( age &> maxAge )
error( "invalid age." );
var actiontoken_insert = 0;
//...
if ( action === actiontoken_insert )
insert( data );
dataActions[action]( data );
原因三:
數據和實現分別可以重用,而數據和實現的重用方式和邏輯卻不盡相同。數據從實現邏輯中剝離,能夠為接下來的消除重複代碼做好準備工作。以上是這樣做為什麼是有意義的。
然後說這個例子中的不妥之處。首先第一個例子很明顯這個a==="x",a==="y"中的x和y也是一種魔幻數字,只不過變成了字元串這種更高級的形式了。
所以我們需要去解決的是消滅掉這段邏輯,從數據源頭把x和y這種魔幻數字給幹掉,變成更有意義的表達形式,然後再通過約定來直接消滅掉中間的match的過程,省略掉mapping數據。
第二個例子中將數據提取出來後,可以明顯的看出來這是一個連續的區間,既然是連續區間,那麼寫成很多個數組顯然是錯誤的,應當進一步將數據合併成為一個數組,再將實現邏輯剝離為通用函數,改為函數調用:
var ranges = [7, 9, 11, 30, 45, 60, 75, 90]
var currentRange = getRange( score, ranges );
問題太關注於具體案例了,我先抽象成為一個問題:把if-else的代碼風格改成表格驅動法的意義在哪裡?
表格驅動的意義在於:邏輯和數據分離。
這一點@Ivony已經提過了,但是現實意義我想這裡補充一下。
在程序中,添加數據和邏輯的方式是不一樣的,成本也是不一樣的。簡單的說,數據的添加是非常簡單,低成本和低風險的;而邏輯的添加是複雜,高成本和高風險的。
用PHP舉個例子吧,比如說,國家簡寫轉換,給一個國家全名,轉換成國家簡寫,用if-else法就寫成:如果我要增加一個國家,那麼我要多加一個else if語句,那麼我就是增加了一條邏輯&
& "CHN",
"America"=&> "USA",
"Japan"=&> "JPN",
];
if(in_array($country, array_keys($countryList))) {
return $countryList[$country];
}
return "Other";
}
如果我增加一個國家,我需要在數組裡面加個數據
那麼接下來,我就可以剝離這個數據與邏輯的關係了。重構到此為止,這樣的好處在哪裡?&
- 邏輯和數據區分一目了然
- 關係表可以更換,比如國家表格可以是多語言的,中文版表格,英文版表格,日語版表格,以及單元測試中,可以注入測試表格。
- 在單元測試中,邏輯必須測試,而數據無需測試。
可以想像如果沒有表格法,弄個多語言,要寫多少語句。
2)數據來源的靈活性接下來我們再來看看這個國家表格數據來源,如果是數據表格:- 來自代碼
- 來自配置
- 來自INI
- 來自資料庫
- 來自WEB API
等等,只要數據能轉化成數組即可。
而邏輯,必須寫死在代碼中,無法靈活地重新定義。
3)數據輸入修改的成本與風險
我們想想,聘用一個不懂編程,但培訓一下就會用後台的客服便宜,還是會一個懂系統開發人員便宜?如果這個是數據,是來自於資料庫的,那麼基本上公司的任何有許可權的人在後台把這個映射表填一下,就能正常工作了。這個耗費與風險幾乎可以忽略不計。 如果數據來自第三方API,如果第三方添加修改了數據,你也是基本放心的。
但是如果這個是邏輯本身,那麼只能是這個系統開發人員進行修改,構建,然後經過一系列的測試,進行專業部署流程,使得這個功能在產品上運行,是個耗費與風險是不言而喻的。另外考慮到多人開發,開發風格不統一的話,那麼開發成本和代碼審查就不可避免了
4) 數據格式的強制性和代碼風格的隨意性
在現實工程中,多人開發一個功能很常見。這裡就有一個多人代碼風格的問題了。你如何確保別人的代碼中的邏輯一定對呢?對於數據來說,一但數據格式被代碼確定後,數據格式就是強制性的了。比如這個例子,無論是誰,加幾個美國的數據也只能這樣加:& "CHN",
"America"=&> "USA",
"Japan"=&> "JPN",
"US"=&> "USA",
"United States of America"=&> "USA",
"美國"=&> "USA",
];
就算原始數據結構的花樣豐富,最終數據必須格式化成如此。
然而如果是邏輯的話,開發人員一多,邏輯方法就可能發生變化。你可能指望對方這樣寫代碼:&
然而,對方可能會這樣寫:
&
後來多了一個日本的需求,又交給另外不同的人去寫,說不定最後如此:
這樣寫,都沒有錯,然而風格卻大相徑庭。就是在多人合作編程過程中,無法控制所有人的風格,如果需要統一風格,必須依靠代碼審查和大量修改,這需要大量資源和成本。 另外,就是因為如此,所有和if else相關的邏輯在單元測試中必須進行一次測試,才能確保邏輯代碼的正確性,保證邏輯區域會正確運行;而如果是數據,由於數據格式的可控性,無需對數據進行測試。&
- 在多人開發的項目中,邏輯無序而不可控;數據格式有序而極易控制
- 在單元測試中,邏輯區塊必須進行測試,否則無法確保其正確性;而數據本身無需測試
- 邏輯代碼越少,邏輯複雜度就越少;邏輯代碼越多,邏輯複雜度就越高
- 邏輯與數據分離
- 邏輯修改成本巨大,數據修改成本極小
- 邏輯修改風險巨大,數據修改風險極小
- 數據來源靈活,數據改變靈活
第一個例子只有兩個分支的時候,可以不重構。但分支變多的時候,需要重構。
第二個例子確實需要重構,那種重構方式叫表格驅動。表格驅動最開始可能不習慣,但其實是很好讀很很容易修改的。
區間不要寫成(a, b),而應該寫成 [a, b),也就是說第一個數據的判斷不要使用 &<, 而應該使用 &<=:
if (score &> 74 score &< 90)
修改成:
if (score &>= 75 score &< 90)
使用[a, b) 作為區間的形式,可以將一串數自然分隔。比如將[a, c), 用 b 分成兩段,就變成:
[a, b)
[b, c)
前一個區的最後數字,剛好等於的後一個區的前面數字。另外,b - a 剛好等於數字個數。這樣分區會更自然,不容易出錯。
另外這種區間的判斷,應該將變數放在中間,將其從小到大排序,就更容易看出關係。
if (75 &<= score score &< 90)
整體將各個 if 的順序從小到大排列,添加上 Infinity 作為柵欄,這樣就修改成了:
var startNumber;
if (-Infinity &<= score score &< 7) {
startNumber = 1;
}
else if (7 &<= score score &< 9) {
startNumber = 2;
}
else if (9 &<= score score &< 11) {
startNumber = 3;
}
else if (11 &<= score score &< 30) {
startNumber = 4;
}
else if (30 &<= score score &< 45) {
startNumber = 5;
}
else if (45 &<= score score &< 60) {
startNumber = 6;
}
else if (60 &<= score score &< 75) {
startNumber = 7;
}
else if (75 &<= score score &< 90) {
startNumber = 8;
}
else if (90 &<= score score &< Infinity) {
startNumber = 9;
}
添加 Infinity 使得判斷的形式更統一。這時就算不抽取成表格驅動,也可以將判斷抽成函數:
if (isInRange(score, 7, 9))
更進一步,就是將分隔點抽取出來。
[-Infinity, 7, 9, 11, 30, 45, 60, 75, 90, Infinity]
就演化成表格驅動了。
你朋友本意是好的,但他的代碼有點小問題,就是沒有按大小順序排列區間,讀起來不容易看出區間之間的聯繫。另外因為區間的後一個數字是下一個區間的前一個數字,有個數字就是重複的,只需要抽取分隔點就成了。
因為 C 系列的語言數組從 0 開始數數。日常中往往從 1 開始數數。這樣可以在 0 的位置預先填一個柵欄值,消除代碼跟日常的不同。這樣就變成了。
function rank(score) {
var t = [-Infinity, 7, 9, 11, 30, 45, 60, 75, 90, Infinity]
return t.findIndex(function(val) {
return score &< val;
});
}
其實這種隱式用數組的索引作為結果也不是很好的,最好還是將其顯式寫出來。
如果是c,可以加宏,把if else改寫成宏。
如果分支很多的時候,重構後的實現可能反而更清晰一些。另一方面,重構後的實現每一個鍵對應的是一個函數,者為將來更進一步的重構打下了基礎。例如下面的例子:
function client(int actionName) {
// 前置處理
switch (actionName) {
case "a":
preA();
break;
case "b":
preB();
break;
case "c":
...
}
// do somethings
switch (actionName) {
case "a":
postA();
break;
case "b":
...
}
}
重構為下面的代碼,就會更清晰,可維護性更高:
var actionMap = {
a: {pre: preA, post: postA},
b: {pre: preB, post: postB},
c: {pre: preC, post: postC}
};
function client(actionName) {
var actionInfo = actionMap[actionName];
actionInfo.pre();
// Do somethings.
actionInfo.post();
}
如果只是為了學習如何重構,那就玩一下咯。。
一般的if else是沒有辦法刪除的,無論你將其轉為switch語句,還是多態——你只能將其從一種形式轉換為另外一種形式。
在複雜的情況下應該抽象成那樣子會更容易理解。
但是在這個case里,這裡的if else都只是單層,沒有必要為了重構而去重構。相反地,在這個例子里,最需要重構的不是if語句,而是Magic Number。為7,9,11,30,45,60,75,90賦予一個變數值,會比重構if-else更有意義。像這裡的分數,不會有極端的需求出現,每個level都會在一定的範圍裡面。
所以,我覺得這裡的if-else反而是容易理解。在這裡,我更想去反對這樣的做法。一般的團隊都不會有Code Diff、Code Review,這樣做會導致浪費更多的時間——不僅是你寫的時間,還有別人閱讀代碼的時間。基本贊同@huang phodal ,並且要補充,score的條件判斷並非是magic number,因為我們很容易看出其意義。而且score與starNumber的對應可能變化嗎?如果不太可能變化,就沒有必要提取出來。因為現有的代碼並沒有複雜到看不清楚其對應關係。
通過lookup表來分派代碼的方式適用於需要動態性、可擴展性的場合,但不是沒有代價的。代碼多了一層間接,其實理解成本是略微上升的。更重要的是使得代碼的靜態分析變困難——比如圈複雜度的分析,也使得無法應用某些性能優化(像分支順序調整)。
他的這些例子都過於簡單以至於很難想像需要動態性和可擴展性。舉這樣失敗的例子也說明作者並沒有真正明白代碼的可維護性。這個在嵌入式編程裡面,不論是效率還是代碼尺寸,都有很好的作用的。
void avrisp_cmd(const uint8_t *buf, uint16_t len)
{
uint8_t cmd = *buf;
switch (cmd)
{
case 1:
// cmd_sign_on
break;
case 2:
// cmd_get_param
break;
// ...
default:
// cmd_unknown
break;
}
}
這樣的代碼既冗長,又難以維護,而且內存消耗(所有代碼塊共用同一個棧框)、時間消耗(代碼塊地址用的是 O(n) 的線性搜索)都成問題。如果這麼做:
#define HANDLER(x) void x(const uint8_t *buf, uint16_t len)
typedef HANDLER((*handler_t))
const PROGMEM handler_t handlers[256] = {NULL, cmd_sign_on, cmd_get_param. /* ... */};
HANDLER(cmd_unknown);
void avrisp_cmd(const uint8_t *buf, uint16_t len)
{
uint8_t cmd = *buf;
handler_t handler = (handler_t)pgm_read_ptr((uint16_t)*handlers[cmd]);
(handler ? handler : cmd_unknown)(buf, len);
}
看上去是不是簡單多了?handlers 變數的聲明又兼作 handler 函數列表了,內存(每個功能的代碼塊分開)、時間(指針引用 O(1) 效率)都大大減少了。
脫離實際情況談重構,這是在犯罪。
有的地方經常要橫向擴展。
有的地方升級經常會變動。有的地方要適配多個模塊。。。。。。想清楚,重構的目的是什麼在動手,不遲。
為了重構而重構,除了引入bug外大抵只能去茶館和孔先生聊,促使他漲紅臉了吧。或許我是實用主義者吧。if else 多的時候這個辦法很有用。這個辦法的本質是將後續擴展時的 「改代碼」 變成了 「改配置」。---寫代碼,是固化了的邏輯。改配置,是無限定製的可能。---假設要製造一枚cpu,如果把所有想要的功能都集成進去(把所有 if else 都寫好)固然是看起來容易讀懂了,但是當你想改的時候,就沒辦法為它編程了;如果我為cpu做個指令集(查找表、配置等..),那這個cpu一開始就沒辦法幹活,並且看起來既難用又難懂,但是它卻獲得了無限的潛力。
程序員不喜歡做重複的事情,所以會覺得這種if else「醜陋」,於是就去「抽象」,然後代碼其實是變「複雜」了。正是在這種過程中,編程語言才變成了普通人理解困難的語言,啊哈。
第一個回答其實挺對,沒必要重構,但是要把數字變成常量值比較好,方便後期的調整。仔細看書,他是為了減複雜度,馬馬虎虎吧。換成查找也沒有減複雜度啊,騙騙工具騙騙自己吧。至於題主這種代碼越少等同於越簡單的,沒入門
…不都是這樣寫的嗎,假如我有100個區間呢…這不叫重構,這叫寫的太少..
而且,如果想要構造一個對於等長或者簡單的可公式計算距離的區間(比如等比增長的區間距離),加入一個function參數,輸入index,輸出計算後的區間距離length,更好擴展(等距就直接返回常數number就行),JavaScript的動態性和函數對象讓這個非常好寫,不支持函數傳參的語言可能需要定義兩個類來處理長度變換和求值。
簡單實現……區間為左閉右開,可以自定義區間間距生成函數和數量生成函數function rank(score, intervals) {
var obj = intervals.find(function(e, i, arr) {
var left = arr[i-1] ? arr[i-1].k : Infinity;
var right = e.k;
return score &> left score &<= right;
});
return obj ? obj.v : -1; //if not find, return -1
}
// genLength(index),genNum(index),index start from 1
function genInterval(start, step, genLength, genNum) {
var arr = [{k: start,v: genNum(0)}];
for (i=1; i&<=step; i++) {
var last = arr[i-1].k;
var length = genLength(last);
var obj = {};
obj.k = last + length; //save the score of right intervals
obj.v = genNum(i); //save the num
arr.push(obj);
}
return arr;
}
var intervals = genInterval(0, 10, (i) =&> 10, (i) =&> i);
//(0,10]=&>1,[11,20]=&>2,...(90,100]=&>10
var a = rank(60,intervals);
//6
console.log(a);
var ratio = genInterval(1, 10, (i) =&> i, (i) =&> 2*i);
//(1,2]=&>2,(2,4]=&>4,(4,8]=&>6...(512,1024]=&>20
var b = rank(1024,ratio);
//20
console.log(b)
如果把if else寫死了, 等到要添加更多的條件分支的時候,就得改寫方法了,更不要說,如果條件範圍是用戶輸入之後作為參數傳進來的情況。
但重構以後就不同了,條件範圍是數組,需求變了只要改改數組就可以了。另外程序的可復用性也更強了。看上去寫了額外的代碼,然而事實上會減少很多的工作量。有一些答案提到了重構為了加強可讀性,這點當然沒錯。但是其實除了可讀性和效率,重構也希望增強可維護性和可擴展性。
同樣用score這個例子,用lookup表格顯然優於多個if。比如重構後,range是可以被解耦出來單獨維護的(可以專門寫一個參數配置文件,裡面放上所有這類的參數?)。並且,用lookup表格,range是可以輕易在運行時被改變(在某些條件下判斷機制發生了改變?),這點原有的ifelse很難做到。反正應該把switch用查找表代替
重構之後可以把數據範圍提到一個便於修改的地方,假設有一百個這種代碼,找死你(其實也挺快的
這個函數以後還能重用(說說罷了
所以說項目不大,重構用於排錯的時間遠大於省下的時間垃圾代碼,分配對象不要錢啊?查表不要錢啊?
論switch的重要性…if else疊合不用switch?switch是專門給這種情況做的啊
根據之前開發的經驗最後就會被改成這樣了,別人是否一眼看得懂...我愛莫能助。
會做到這一步的重構肯定是業務上的需求 var doSomethingFactory = function(lookup, def) {
return function(x) {
try {
return (lookup[x] || def)();
} catch (e) { /* error handling */ }
};
};
推薦閱讀:
TAG:重構 |