分支語句在編程語言中有著舉足輕重的地位。有一定工作年限的程序員,通常遇到過這樣一段代碼,它有數百行,包含了十幾甚至二十幾個分支,嵌套多達五六層甚至十多層,各種 if ... else ...
語句交叉在一起。最可恨的是,每個分支上有著詳盡的注釋,你花了二十分鐘,仔細閱讀了每條注釋,最后發現這些注釋根本不能夠告訴你它要做什么,你依然一頭霧水。甚至有些注釋還TM是錯的,和代碼邏輯根本不一致。
這樣的代碼一般是老系統長期維護造成的。隨著用戶的需求不斷地變更,維護它的程序員不得不在代碼上無限地增加 if
。當然也有“大?!笨梢砸婚_始便寫出這么復雜的分支邏輯。
廢話說了很多,本文的目的在于歸納關于分支語句的林林總總。探討如何寫出漂亮的分支邏輯。
1. 重構
一個正常的程序員是不會痛罵 if A then B
這樣的分支的,大多數分支代碼一開始確實也長得如此。然而,隨著時間的推移,需求的不斷變更,分支代碼通??雌饋硎沁@樣的:
if (!(A < 10 || B > 5) && A > 10 || B < 20 && C || C > 100) {
if (...) {
// ...
}
else if (...) {
if () ....
}
...
}
else if (B > 20 && C < 50) {
// ...
}
else {
// ...
}
如果你愿意做一個有節操的程序員,在上面的代碼繼續發臭腐爛、直至必須重寫前,你應該對其重構。我認為重構應該優先保證那些公開的API方法流程清晰易讀、抽象層次一致、職責單一,具體的實現細節交由私有方法處理。
1.1. 改進復雜的表達式
進入分支的條件邏輯如果過于復雜,通常代碼會很難維護。此時可考慮以下手段改進:
1.1.1. 條件反轉
通常形如 if [OK] then ... else ...
的處理邏輯更符號人類的思維習慣,所以對于邏輯“非”優先的分支,可以考慮進行反轉。例如將:
if (!(level > 4 && score > 90)) {
reject();
} else {
accept();
}
修改為:
if (level > 4 && score > 90) {
accept();
} else {
reject();
}
1.1.2. 使用解釋變量
將表達式中邏輯相關的部分,提取出變量,并給予一個合適的名字。此時變量的命名必須能準確表述表達式的含義。例如將:
if (inputs.split(",")[0].equals("admin")
&& DB.get("password").equals(inputs.split(",")[1])) {
doSomeThing();
}
修改為:
boolean isAdmin = inputs.split(",")[0].equals("admin");
boolean hasCertified = DB.get("password").equals(inputs.split(",")[1]);
if (isAdmin && hasCertified) {
doSomeThing();
}
1.1.3. 分解分支條件
如果分支條件中存在多種邏輯交織,可以考慮按層級將其分解。例如將:
if (gender == MALE && age > 15) {
// ...
} else if (gender == MALE && age > 20) {
// ...
} else if (gender == FEMALE && age > 18) {
// ...
} else if (gender == FEMALE && age > 25) {
// ...
}
修改為:
if (gender == MALE) {
if (age > 15) {
// ...
} else if (age > 20) {
// ...
}
} else if (gender == FEMALE) {
if (age > 18) {
// ...
} else if (age > 25) {
// ...
}
}
這種分解的副作用是有可能增加圈復雜度,要酌情適當使用。
1.1.4. 提取函數
有時你在維護一個公共API方法,它里面有一個極其復雜的條件表達式。為了保證主干代碼的清晰簡潔,你可以將這個表達式暫且簡單的移到獨立函數中,再酌情考慮重構此表達式。例如將:
public void Foo() {
// ...
if (A > B && B < 100 || C == D && ! E ...) {
}
// ...
}
修改為:
public void Foo() {
// ...
if (isReady(...)) {
}
// ...
}
private boolean isReady(...) {
return ...
}
1.2. 改進執行過程
1.2.1. 提取函數
從 if
、then
、else
三個段落中分別提煉出獨立的函數。這樣做的好處是,可以將“要做的事情”獨立出來,從來突出條件邏輯。該做法非常適合這種情況:當你想更清楚地表明每個分支的作用,并且突出每個分支的原因時。例如將:
if (A && B || C) {
// 此處數十行代碼
} else if (...) {
// 此處數十行代碼
} else {
// 此處數十行代碼
}
上面各分支中的代碼累加起來可能多達百行。盡管這個樣例看起來很簡單,真實的代碼往往會讓維護者的閱讀壓力很大,可以修改為:
if (A && B || C) {
doThingA();
} else if (...) {
doThingB();
} else {
doThingC();
}
1.2.2. 使用空對象模式
有時你需要再三檢查某對象是否為 null
,并對空對象做出相同的響應:
// Foo.java
public class Foo {
public void foo() {
User user = Factory.getUser(id);
if (user != null) {
Bar bar = Factory.getBar(user.getName());
if (bar != null) {
Foobar fb = Factory.getFoobar();
if (fb != null) {
// ...
}
}
} else {
System.out.println("User is null!");
}
}
}
// User.java
public class User {
public String getName() {
return "zhangsan";
}
}
// Factory.java
public class Factory {
public static User getUser(int id) {
if (id == 0) {
return null;
}
return new User();
}
}
此時可以將 null 值替換為空對象。重構過程如下:
- 為源類建立一個子類,使其行為就像是源類的 null 版本。在源類和 null 子類中都加上
isNull()
函數,前者的isNull()
應該返回false
,后者的isNull()
返回true
。 - 編譯。
- 找出所有“索求源對象卻獲得一個null”的地方。修改這些地方,使它們改而獲得一個空對象。
- 找出“將源對象與null做比較”的地方。修改這些地方,使它們調用
isNull()
函數。 - 編譯、測試。
- 找出這樣的程序點:如果對象不是null,做A動作,否則做B動作。
- 對于每一個上述地點,在 null 類中覆寫A動作,使其行為和B動作相同。
- 使用上述被覆寫的動作,然后刪除“對象是否等于null”的條件測試。編譯并測試。
重構后的參考代碼如下:
public interface User {
public boolean isNull();
public String getName();
}
public class RealUser implements User {
public boolean isNull() {
return false;
}
public String getName() {
return "zhangsan";
}
}
public class NullUser implements User {
public boolean isNull() {
return true;
}
public String getName() {
System.out.println("User is null!");
return "";
}
}
public class Factory {
public static User getUser(int id) {
if (id == 0) {
return new NullUser();
}
return new RealUser();
}
}
public class Foo {
public void foo() {
User user = Factory.getUser(id);
Bar bar = Factory.getBar(user.getName());
// ...
}
}
C#
、Go
、 swift
等從語言級別直接提供 NullObject 模式,你可以很方便的使用或改進它。
另外,還可以在不修改 User
代碼的前提下,引入標識接口來識別空對象。例如增加一個:
interface Null {}
它不定義任何函數。然后讓空對象實現它:
class NullUser extends User implements Null {
// ...
}
此時便可以通過 instanceof
操作符來檢查對象是否為 null.
1.3. 改進條件調度
1.3.1. 使用 衛語句
避免不必要的嵌套
條件表達式通常有2種表現形式。第一:所有分支都屬于正常行為。第二:條件表達式提供的答案中只有一種是正常行為,其他都是不常見的情況。這2類條件表達式有不同的用途。如果2條分支都是正常行為,就應該使用形如
if ... else ...
的條件表達式;如果某個條件極其罕見,就應該單獨檢查該條件,并在該條件為真時立刻從函數中返回。這樣的單獨檢查常常被稱為衛語句(guard clause)。
可以先將程序邏輯中不符合條件的情況優先過濾掉,以保證主體代碼的清晰簡單。例如將:
if (age >= 18) {
doSomeThing();
}
改造為:
if (age < 18) {
return;
}
doSomeThing();
1.3.2. 合并表達式
如果有一系列條件測試都得到相同的結果,將這些測試合并為一個表達式,并將這個條件表達式提煉為一個獨立函數。例如將:
if (A && B) {
doThingA();
} else if (A && C) {
doThingA();
} else {
doThingB();
}
改造為:
if (readyToA()) {
doThingA();
} else {
doThingB();
}
1.3.3. 表驅動法
有時,測試條件可以使用數據表的形式驅動。例如以下代碼:
if [$month -eq 1]; then
day=31
elif [$month -eq 2]; then
day=28
elif [$month -eq 3]; then
day=31
elif [$month -eq 4]; then
day=30
elif [$month -eq 5]; then
day=31
elif [$month -eq 6]; then
day=30
elif [$month -eq 7]; then
day=31
elif [$month -eq 8]; then
day=31
elif [$month -eq 9]; then
day=30
elif [$month -eq 10]; then
day=31
elif [$month -eq 11]; then
day=30
elif [$month -eq 12]; then
day=31
fi
可以改寫為:
days=(31 28 31 30 31 30 31 31 30 31 30 31)
day=days[$month]
又如:
var bar;
if (foo === 'it') {
bar = 'a';
} else if (foo === 'is') {
bar = 'b';
} else if (foo === 'not') {
bar = 'c';
} else if (foo === 'too') {
bar = 'd';
} else if (foo === 'bad') {
bar = 'e';
} else {
bar = 'f';
}
可以修改為:
var bar = {
'it': 'a',
'is': 'b',
'not': 'c',
'too': 'd',
'bad': 'e'
}[foo] || 'f';
利用以上方法結合函數式編程方式,在 JavaScript 中可以創造出極其精簡靈活的代碼。
同樣,還可以使用 map
或多層 map 來簡化分支語句:
male := make(map[string]string)
female := make(map[string]string)
...
m := map[string]map {
"MALE": male,
"FEMALE": female,
}
if m, ok := m["MALE"]; ok {
m["..."] ...
} else {
...
}
表驅動法的核心思路是將各判斷條件放置到表結構中,將判斷邏輯轉化為查找表的邏輯,從而減化客戶端代碼,使代碼條理更加清晰。使用時應該重點關注表結構的設計。
1.3.4. 使用多態
當條件分支邏輯是用來判定類型、代碼執行邏輯的抽象層次一致且較為復雜時,可考慮使用多態。例如以下代碼(注:示例代碼結構其實很簡單,通常不需要修改,希望你能夠理解它背后所代表的復雜的結構):
public int getLegNumbers(String name) {
switch(name) {
case "chicken":
return 2;
case "frog":
return 4;
case "crab":
return 8;
case "centipede":
return 70;
}
}
可以重構為:
// 客戶端代碼:
public int getLegNumbers(Animal animal) {
return animal.legs();
}
// 基于多態的重構
public interface Animal {
legs();
}
public class Chicken implements Animal {
public int legs() {
return 2;
}
}
public class Frog implements Animal {
public int legs() {
return 4;
}
}
public class Crab implements Animal {
public int legs() {
return 8;
}
}
public class Centipede implements Animal {
public int legs() {
return 70;
}
}
有時你還需要:
- 使用策略模式或狀態模式取代類型代碼;
- 使用命令模式替換條件調度邏輯。
1.3.5. 使用鴨子類型
除了使用多態做類型泛化,也可以使用鴨子類型約束行為方法。它可以忽略掉你必須對類型所做的判斷,而只關心程序中的行為邏輯的抽象。例如:
type Chicken struct {
legs int
}
func NewChicken() *Chicken {
chicken := Chicken{
legs: 2,
}
return &chicken
}
func (c *Chicken) Legs() int {
return c.legs
}
...
客戶端代碼只需要約束鴨子類型,不需要關注實現細節,甚至不需要關注是不是 Animal
,只要有 Legs
,哪怕你是一張桌子:
type LegsNumber interface {
Legs() int
}
func LegNumbers(anything LegsNumber) {
return anything.Legs()
}
重構過程你應該把握好“度”的問題,并不是當你遇到A情況時,將其重構為B情況即為最佳實踐。通常脫離應用場景是無法談最佳實踐的。例如當你過分地依賴多態或其它設計模式時,很可能因為引入過多的類,將原本簡單清晰的代碼變得更晦澀。你應該努力做到讓代碼閱讀者每次接收到的信息保持相同的抽象層次,比如一個業務流程控制方法中,應該只能看到流程的控制邏輯以及執行哪些流程,而不應該出現流程處理的細節。
2. 性能優化
過早的優化是萬惡之源。 -- Donald Knuth
首先,千萬不要為了你以為的那么一丁點性能提升,就以犧牲代碼可讀性為代價而做性能優化!其次,你要認清你所謂的性能提升10倍,是將 1 毫秒變成了 0.1 毫秒,還是將 10 秒變成了 1 秒。性能優化應該關注產生性能瓶頸的部分。
通常優化工作應該在高層次上來做,很少會出現優化一個分支語句這種極端的場景。但為了文章的完整性,仍然總結了一些和分支相關的優化技巧。結構良好的代碼通常和編程語言的關系不是那么緊密,但是性能優化往往會和編程語言緊密關聯。優化工作應該總是結合運行時的具體環境來做,以下僅僅是一些思路總結。
假設有這樣一段代碼:
if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else if (value == 2) {
return result2;
} else if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else if (value == 5) {
return result5;
} else if (value == 6) {
return result6;
} else if (value == 7) {
return result7;
} else if (value == 8) {
return result8;
} else if (value == 9) {
return result9;
} else {
return result10;
}
2.1 將條件按頻率倒排
實際業務中,如果 value
為 9
的情況經常出現,則應該將該判斷放在最前面。
if (value == 9) {
return result9;
} else if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else if (value == 2) {
return result2;
} else if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else if (value == 5) {
return result5;
} else if (value == 6) {
return result6;
} else if (value == 7) {
return result7;
} else if (value == 8) {
return result8;
} else {
return result10;
}
2.2 拆分分支
如果沒有明顯的頻率,則可考慮拆分成多個分支。其實和上面一樣,核心思路是減少分支判斷的次數:
if (value < 6) {
if (value < 3) {
if (value == 0) {
return result0;
} else if (value == 1) {
return result1;
} else {
return result2;
}
} else {
if (value == 3) {
return result3;
} else if (value == 4) {
return result4;
} else {
return result5;
}
}
} else {
if (value < 8) {
if (value == 6) {
return result6;
} else {
return result7;
}
} else {
if (value == 8) {
return result8;
} else if (value == 9) {
return result9;
} else {
return result10;
}
}
}
2.3 使用 switch 語句
多重條件判斷時推薦 switch
語句,通常編譯器更容易針對它做優化。而像 JavaScript 中,其性能隨解釋引擎不同表現參差不齊。
上面的例子很容易改造成 switch
語句,不再給出示例代碼。
2.4 使用表查詢
參見“重構”中的“表驅動法”。當條件判斷數量眾多,且這些條件能用數字或字符串等離散值來表示時,通常可以進行類似的優化。使用表結構,不僅能提高代碼可讀性,也能提升效率。
2.5 Duff 策略
這條和分支沒有直接關系,是快速循環的技巧。由于會用到 switch
語句,也放在此處備查。
首先要了解這樣一個事實:對于絕大多數語言,將循環展開后效率往往更高。例如:
var i = values.length;
while (i--) {
process(values[i]);
}
比如數組中有 5 項,展開后的執行速度更快:
process(values[0]);
process(values[1]);
process(values[2]);
process(values[3]);
process(values[4]);
Duff 策略由 Tom Duff 首先在 C 語言中提出。它是一種展開循環的構想,通過限制循環次數來減少循環開銷。這里給出 JavaScript 實現的示例代碼(因為這種技巧在 JavaScript 中更實用):
var iterations = Math.ceil(values.length / 8);
var startAt = values.length % 8;
var i = 0;
do {
switch(startAt) {
case 0: process(values[i++]);
case 7: process(values[i++]);
case 6: process(values[i++]);
case 5: process(values[i++]);
case 4: process(values[i++]);
case 3: process(values[i++]);
case 2: process(values[i++]);
case 1: process(values[i++]);
}
startAt = 0;
} while (--iterations > 0);