標籤:

這個例子中的if else也要重構掉嗎?

今天在群裡面看到有人說用if else不好,他貼了下面的圖。我覺得這個例子很爛,一個很簡單的方法,被重構的一眼看不懂在幹嗎。

然後他又舉了個例子。貼了下面的代碼

然後我覺得例子裡面最多說if條件重複了。邏輯也挺簡單的,也清晰。這樣也挺好,最主要是我想不到還有什麼更好的寫法。然後讓他說說更好的寫法。

他就貼出了這個:

我覺得這樣把問題搞複雜了。然後他覺得我應該看看函數式編程的思想。說函數是變成都用Pattern Matching,函數式編程我只是看過Haskell和Scala。我覺得Pattern Matching不是就是有點類似於switch嗎,只是更強大。

問題來了,上面到底哪種方式更好呢?或者還有更好的方式重構這個代碼?真不能用這這麼多if else嗎?我個人覺得這個方法不複雜,代碼邏輯清晰,讓人一眼看懂。也還好。而且我也不喜歡很多if else,但是我想不到更好的寫法了。第二種方式我覺得完全是為了玩花活而玩的。

真心請教!

====================================================================

更新一下,書上的重構應該寫成

function doSomething(a) {
var lookup = {x: doX, y: doY}, def = doZ;
(lookup[a] || def)();
}

stackoverflow上有人用Haskell給出了他的解法。寫成js就是這樣。我個人覺得確實是很好的重構方案,可以少打很多代碼(我提問時抄原始代碼大於小於號寫錯了好幾次),減少出錯幾率,邏輯也很清楚,修改起來很方便, 加點注釋說明下可能更好。

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 );

但是消除魔幻數字更徹底的方式是基於約定,將token與具體的實現邏輯基於約定進行自動的匹配,去除無意義的match代碼,更徹底的消除魔幻數字:

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";

}

如果我增加一個國家,我需要在數組裡面加個數據

那麼接下來,我就可以剝離這個數據與邏輯的關係了。

&

重構到此為止,這樣的好處在哪裡?

1) 代碼本身的優勢

  • 邏輯和數據區分一目了然
  • 關係表可以更換,比如國家表格可以是多語言的,中文版表格,英文版表格,日語版表格,以及單元測試中,可以注入測試表格。
  • 在單元測試中,邏輯必須測試,而數據無需測試。

可以想像如果沒有表格法,弄個多語言,要寫多少語句。

2)數據來源的靈活性

接下來我們再來看看這個國家表格數據來源,如果是數據表格:

  • 來自代碼
  • 來自配置
  • 來自INI
  • 來自資料庫
  • 來自WEB API

等等,只要數據能轉化成數組即可。

而邏輯,必須寫死在代碼中,無法靈活地重新定義。

3)數據輸入修改的成本與風險

我們想想,聘用一個不懂編程,但培訓一下就會用後台的客服便宜,還是會一個懂系統開發人員便宜?

如果這個是數據,是來自於資料庫的,那麼基本上公司的任何有許可權的人在後台把這個映射表填一下,就能正常工作了。這個耗費與風險幾乎可以忽略不計。 如果數據來自第三方API,如果第三方添加修改了數據,你也是基本放心的。

但是如果這個是邏輯本身,那麼只能是這個系統開發人員進行修改,構建,然後經過一系列的測試,進行專業部署流程,使得這個功能在產品上運行,是個耗費與風險是不言而喻的。另外考慮到多人開發,開發風格不統一的話,那麼開發成本和代碼審查就不可避免了

4) 數據格式的強制性和代碼風格的隨意性

在現實工程中,多人開發一個功能很常見。這裡就有一個多人代碼風格的問題了。你如何確保別人的代碼中的邏輯一定對呢?

對於數據來說,一但數據格式被代碼確定後,數據格式就是強制性的了。

比如這個例子,無論是誰,加幾個美國的數據也只能這樣加:

& "CHN",
"America"=&> "USA",
"Japan"=&> "JPN",
"US"=&> "USA",
"United States of America"=&> "USA",
"美國"=&> "USA",
];

就算原始數據結構的花樣豐富,最終數據必須格式化成如此。

然而如果是邏輯的話,開發人員一多,邏輯方法就可能發生變化。你可能指望對方這樣寫代碼:

&

然而,對方可能會這樣寫:

&

後來多了一個日本的需求,又交給另外不同的人去寫,說不定最後如此:

&

這樣寫,都沒有錯,然而風格卻大相徑庭。就是在多人合作編程過程中,無法控制所有人的風格,如果需要統一風格,必須依靠代碼審查和大量修改,這需要大量資源和成本。

另外,就是因為如此,所有和if else相關的邏輯在單元測試中必須進行一次測試,才能確保邏輯代碼的正確性,保證邏輯區域會正確運行;而如果是數據,由於數據格式的可控性,無需對數據進行測試。

由此可見:

  • 在多人開發的項目中,邏輯無序而不可控;數據格式有序而極易控制
  • 在單元測試中,邏輯區塊必須進行測試,否則無法確保其正確性;而數據本身無需測試
  • 邏輯代碼越少,邏輯複雜度就越少;邏輯代碼越多,邏輯複雜度就越高

總結:

所有編程書籍上面都是從語言本身來解釋重構的;我在這裡是通過項目實踐過程中現實意義來解釋這個重構。重構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)

……再看了一遍發現似乎寫的有點偏了,太過於通用了……如果是靜態的區間用打表方式就夠了,最後的Haskell的方式其實投機取巧了,因為用的是1+lastIndex來進行計算數量的,正相當於隱式認為了:

得分段數量和分數值呈線性關係……

實際中數量和得分應該沒關係(或者提供一個函數來控制,理想應該就是正態分布了,逃……)


如果把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:重構 |