何為程式碼質量?——用腦子寫程式碼

波兒菜發表於2019-07-30

引言

為什麼專案維護困難、BUG 反覆?實際上很多時候就是程式碼質量的問題。程式碼架構就像是建築的鋼筋結構,程式碼細節就像是建築的內部裝修,建築的抗震等級、簡裝或豪裝完全取決於團隊開發人員的水平。

本文是筆者對於一些程式碼質量技巧的小總結,編寫高質量程式碼的思路在任何技術棧都是基本相通的,文章內容僅代表筆者的個人看法,拋磚引玉,不喜勿噴?。

正文

1、使用 ++i 而不是 i++(單獨使用情況下)

基本型別

看這段 C 程式碼:

int i = 100;
i++;
++i;
複製程式碼

對於基本型別,無法直接窺探內部實現,但是可以通過彙編程式碼來直接觀察實現邏輯,通過 clang 編譯器轉換的彙編程式碼大致如下( i++ 和 ++i 彙編指令是相同的):

 0x100000fa4 <+20>: movl   $0x64, -0x14(%rbp)
 0x100000fab <+27>: movl   -0x14(%rbp), %edi
 0x100000fae <+30>: addl   $0x1, %edi
 0x100000fb1 <+33>: movl   %edi, -0x14(%rbp)
複製程式碼

大致邏輯:將0x64立即數寫入rbp暫存器;將rbp的值寫入edi暫存器;edi的值加一;將edi的值寫回rbp

當然,clang 編譯器對++ii++做了優化,它們的彙編程式碼看起來是相同的,但是這不能說明所有的編譯器都對++ii++一視同仁,而且也檢視不了是否編譯器做了優化工作。所以在使用基本型別的時候,對於自增的單步操作,寫++i是個好習慣(C++ STL 庫中有體現)。在日常開發中,for 迴圈可以如此寫:

for (int i = 0; i < 10; ++i) {}
複製程式碼

自定義型別

比如 swift 中的 Int,它是一個結構體,通過寫一個擴充套件來定義++運算子(實際開發中 swift 不建議這麼做,這裡只是舉個例子):

extension Int {
    static prefix func ++ (i: inout Int) -> Int {
        i += 1
        return i
    }
    static postfix func ++ (i: inout Int) -> Int {
        let tmp = i
        i += 1
        return tmp
    }
}
複製程式碼

這是程式語言中自定義++字首和字尾運算子常用的邏輯,字尾++比字首++多了一個tmp臨時變數。感謝百度工程師微博名稱 @提拉拉拉就是技術宅 指出問題,實際上 swift 中字尾++運算子有更高效的實現:

...
    defer {
      i += 1
    }
    return i
...
複製程式碼

不管是何種自定義的實現,字尾++總是有更多的邏輯表達。所以,理論上對於自定義資料型別的單步自增操作,使用字首++能略微的提高效率。

關於爭議

這一段描述引起了很多朋友的爭議,筆者簡單說明一下。

對於 C 中 Int 等自帶型別或者使用者自定義型別的字首++和字尾++運算子,當單獨使用時 (++i; i++;),不管它們上層程式碼如何實現,最終都可能通過原始碼級優化器和目的碼優化器優化成相同的彙編程式碼,但是它們畢竟是通過編譯器的優化轉化的,優化意味著時鐘週期的開銷,可能會加長編譯的時間。

既然我們能通過程式碼讓編譯器免去優化的過程,何樂不為之?

當然,這有些吹毛求疵了,當做各位看官茶餘飯後的一點樂子吧 ?。

2、巧用位運算

位運算效率很高,而且有很多巧妙的用法,這裡提出一個需求:

typedef enum : NSUInteger {
    TestEnumA = 1,
    TestEnumB = 1 << 1,
    TestEnumC = 1 << 2,
    TestEnumD = 1 << 3
} TestEnum;
複製程式碼

對於該多選列舉,如何判斷該列舉型別的變數是否是複合項?

如果按照常規的思路,就需要逐項判斷是否包含,時間複雜度最差為O(n)。而使用位運算可以這麼寫:

TestEnum test = ...;
if (test == (test & (-test))) {
    //不是複合項
}
複製程式碼

實際上就是通過負數二進位制的一個特性來判斷,看如下分析便一目瞭然:

test           0000 0100
反碼           1111 1011
補碼           1111 1100
test & (-test) 0000 0100
複製程式碼

3、靈活使用組合運算子

不明白有些工程師為什麼排斥組合運算子,他們喜歡這麼寫:

bool is = ...;
if (is) a = 1;
else a = 2;
複製程式碼

使用三目運算子:

bool is = ...;
a = is ? 1 : 2;
複製程式碼

其他組合運算子比如 ?: %=等,靈活的使用它們可以讓程式碼更加的簡潔清晰。

4、const 和 static 和巨集

static可以讓變數進入靜態區,提高變數生命週期至程式結束。值得注意的是,檔案中最外層(#include下)的變數本身就是在靜態區的,而這種情況使用static是為了變數的私有化。

const 修飾的變數在常量區不可變,是在編譯階段處理;巨集是在預編譯階段執行巨集替換。所以頻繁使用 const 修飾的變數不會產生額外的記憶體,而所有使用巨集的地方都可能開闢記憶體,況且,預編譯階段的大量巨集替換會帶來一定的時間消耗。

所以筆者的建議是,能用常量的不用巨集,比如一個網路請求的 url:

.h 介面檔案
extern NSString * const BaseServer;
.m 實現檔案
NSString * const BaseServer = @"https://...";
複製程式碼

值得注意的是,const 是修飾右邊記憶體,所以這裡是想要BaseServer字串指標指向的內容不可變,而不是*BaseServer內容不可變。

5、空間換時間

在很多場景中,可以犧牲一定的空間來降低時間複雜度,為了程式的高效執行,工程師可以自行判斷是否值得,下面舉一個程式碼例子,判斷字串是否有效:

BOOL notEmpty(NSString *str) {
    if (!str) return NO;
    static NSSet *emptySet;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        emptySet = [NSSet setWithObjects:@"", @"(null)", @"null", @"<null>", @"NULL", nil];
    });
    if ([emptySet containsObject:str]) return NO;
    if ([str isKindOfClass:NSNull.class]) return NO;
    return YES;
}
複製程式碼

使用一個 hash 來提高匹配效率,這在資料較少時可能體現不出優勢,甚至會讓效率變低,但是在資料量稍大的時候優勢就明顯了,而且這樣寫可以避免大量的if-elseif等判斷,邏輯更清晰。

值得注意的是,此處使用static來提升區域性變數emptySet的生命週期,而不是將這句程式碼寫在方法體外面。在變數宣告時,一定要明確它的使用範圍,限定合適的作用域。

6、容器型別的合理選擇

比如在 C++ 中,若不需要鍵值對的 hash ,就使用set而不是map;若不需要排序的集合就使用unordered_set而不是set

歸根結底也是對時間複雜度的考慮,選擇容器型別時,一定要選擇“剛好”能滿足需求的,能用更“簡單”效率更高的容器就不用“複雜”效率更低的容器。

7、初始化不要交給編譯器

對於變數的使用,儘量在類或結構體初始化方法中對其賦初值,而不要依賴於編譯器。因為在可見的未來,不管是編譯器的更新或是程式碼跨平臺移植,這些變數的初始值都不會受編譯器影響。

8、多分支結構處理

這是一個老生常談的東西了,多分支結構儘量使用 switch 而不是大量的 if - else if 語句,若非要用 if - else if 來寫,則出現頻率高的分支優先判斷,可以從整體上最大限度的減少判斷次數,從而降低 jump 指令的使用頻率。

不要小看這些少量的效率提升,放大到整個專案也是有不小的收益。

若想更優雅的處理分支結構,可以使用策略模式,將多種分支情況視作多種策略。

9、避免資料同步

經常會有一些需求,對一系列的資料有很多額外的操作,比如選擇、刪除、篩選、搜尋等。程式碼設計時,要儘量將所有的操作狀態都快取到同一個資料模型中,而不是使用多個容器資料結構來處理,我們應該儘量避免資料同步防止出錯。

10、合理使用區域性指標

經常會看到這種程式碼:

doSomething(city.school.class.jack.name,
             city.school.class.jack.age,
             city.school.class.jack.sex);
複製程式碼

當同一個變數的呼叫過深且使用頻繁時,可以使用一個區域性指標來處理:

Person *jack = city.school.class.jack;
doSomething(jack.name,
             jack.age,
             jack.sex);
複製程式碼

相對於指標變數所佔用的空間來說,程式碼的簡潔和美觀度稍顯重要一點。

11、避免濫用單例

單例作為一種設計模式應用非常廣泛,在移動端開發中,有些開發者利用它來實現非快取傳值,筆者認為這是一個錯誤的做法,使用單例傳值的時候你需要管理單例中的資料何時釋放與更新,可能會引發資料錯亂。

單例存在的意義應該是持久化資料,而非傳值,切勿為了方便濫用單例。

12、避免濫用繼承

繼承本身和解耦思想有些衝突,程式碼設計中要儘量避免過深的繼承關係,因為子類與父類的耦合將無法真正剝離。過深的繼承關係會增加除錯的困難程度,並且若繼承關係設計有缺陷,修改越深的類影響面將會越廣,可能帶來災難性的後果。

可以使用分類的方式(裝飾模式)做一些通用配置,然後在具體類中簡潔的呼叫一次方法;也可以使用 AOP 思想,hook 住生命週期方法無侵入配置(比如簡單埋點)。

比如 iOS 開發中,可能會有開發者喜歡寫一套基類,實際上只是基於系統的類做了小量的配置,比如BaseViewControllerBaseViewBaseModelBaseViewModel,甚至是BaseTableViewCell。控制器基類可以對棧和導航欄做一些配置,還是有一點使用意義,至於其它的筆者感覺就是過度設計,其實很大意義上BaseViewController也沒有存在的必要。

記住:過多的基類並不是程式碼規範,那是你囚禁其他開發者的牢籠。

13、避免過度封裝

提取方法應該遵守單一職責原則,但若功能本身就是很少的一兩句程式碼可能就沒必要額外提取了。在保證程式碼清晰的情況下,很多時候提取邏輯也是需要酌情考慮的。

有見過開發者使用一套所謂的簡潔配置 UI 的框架,不過就是將 UI 控制元件的屬性封裝成鏈式語法之類的,用起來有種快一些的錯覺,殊不知這就是過度封裝的典範。

封裝的意義在於簡潔的解決一類問題,而非少敲那幾個字母,過度封裝只會增加其他開發者閱讀你程式碼的成本。

比如業界知名的 Masonry,使用它時比原生的 layout 快了不止 10 倍,而且程式碼很簡潔易懂,極大的提高了開發效率。

14、避免過多程式碼塊巢狀

當程式碼中出現大量的 if - else 巢狀、閉包巢狀時,會讓程式碼難以閱讀。出現這種情況可以從以下幾個方面處理:

  • 優化邏輯減少巢狀情況,去除多餘的分支
  • 提取方法,簡化分支處理程式碼
  • 建立策略類,利用策略模式優化邏輯

15、時刻注意空值和越界

寫某塊程式碼中,要時刻注意空值和越界的處理,比如給NSDictionary插入空值會崩潰,從NSArray越界取值會崩潰,這些情況要時刻考慮到。

當然,可能有人會說有方法可以全域性避免崩潰。實際上筆者不是很贊同這種做法,這可能會讓新手開發者永遠發現不了自己程式碼的漏洞。

16、時刻注意程式碼的呼叫時機和頻率

當你寫一塊程式碼時,需要習慣性的思考兩個問題:這塊程式碼的共有變數會被多執行緒訪問從而存在安全問題麼?這塊程式碼可能會在一個 RunLoop 迴圈中呼叫很頻繁麼?

對於第一個問題,可能需要使用“鎖”來保證執行緒安全,而鎖的選擇有一些技巧,比如整形使用原子自增保證執行緒安全:OSAtomicIncrement32();呼叫耗時短的程式碼使用dispatch_semaphore_t更高效;可能存在重複獲取鎖時使用遞迴鎖處理......

對於第二個問題,只需要在合適的地方加入自動釋放池 (autoreleasepool) 避免記憶體峰值過高就行了。

17、減少介面程式碼複用、增加功能程式碼的複用

對於大前端來說,介面是專案中重要的組成部分,而有時候設計師給的圖中,不同介面有很多相同的元素,看起來一模一樣,所以很多工程師偷懶直接複用介面了。

在這裡,筆者建議儘量少的複用介面,寧願選擇複製一份。

試想,目前版本兩個介面相同,你複用了它,當下個版本其中一個介面要調整一下,這時你繼續偷懶,加入一些判斷來區分邏輯,下一次迭代又增加了差異,你又偷懶加入判斷邏輯...... 最終你會發現,這個介面裡面已經邏輯爆炸了,拆分成兩個介面將變得異常困難。

而對於功能程式碼,筆者是提倡多提取,多複用,切記命名規範和適當的註釋。

18、遵守迪米特原則

在封裝一些小元件時,一定要形成習慣,不想暴露給使用者的屬性和方法不要寫在介面檔案中,甚至於某些延續父類的方法不想使用者使用,可以如下處理:

- (instancetype)init UNAVAILABLE_ATTRIBUTE;
複製程式碼

當然,不用擔心元件內部如何獲取父類特性,可以通過[super init]來處理。

OC 開發中,可以使用獨立的延展檔案來做“知識隔離”,因為獨立的延展檔案和當前類是一體的,會在編譯期決議。在需要的地方匯入這個延展檔案就能正常使用,而對未匯入的檔案進行“隔離”。

19、快取機制的設計

不管是任何技術棧的快取機制設計,都需要一套快取淘汰演算法,使用最廣泛的淘汰演算法就是 LRU,即是最近最少使用淘汰演算法,開發者需要嚴格的控制磁碟快取和記憶體快取的空間佔用。

在 iOS 開發中,可以使用 YYCache 來處理快取機制,該框架的原始碼剖析可見筆者部落格:YYCache 原始碼剖析:一覽亮點

還有一點需要提出的是磁碟快取的位置問題。iOS 裝置沙盒中有 Documents、Caches、Preferences、tmp 等資料夾,其中 Documents 和 Preferences 會被 iCloud 同步。

Documents 適合儲存比較重要的資料;Caches 適合儲存大量且不那麼重要的資料,比如圖片快取、網路資料快取啥的;tmp 儲存臨時檔案,重啟手機或者記憶體告急時會被清理;Preferences 是偏好設定,適合儲存比較個性化的資料。

值得注意的是,NSUserDefaults是儲存在 Preferences 下的檔案,發現有很多開發者為了偷懶頻繁的使用NSUserDefaults做任意資料的磁碟快取,這是一個很不合理的做法,用處不大且大量的資料一般快取在 Caches 中,就算是從技術角度考慮,NSUserDefaults是以 .plist 形式儲存的,不適合大資料儲存。

20、合理選擇數字型別

軟體工程師應該清楚自己編寫的程式碼是執行在 32 位還是 64 位的系統上,並且瞭解程式語言對於各種數字型別的定義。

在 iOS 領域,CGFloat在 32 位系統中為 float 單精度,64 位系統中為 double 雙精度,當將一個NSNumber轉換為數字型別時,為了相容,需要如下寫:

NSNumber *number = ...;
CGFloat result = 0;
#if CGFLOAT_IS_DOUBLE
      result  = number.doubleValue;
#else
      result  = number.floatValue;
#endif
複製程式碼

在使用不同數字型別時,需要考慮數字型別的表示範圍,比如能用short處理的就不要用long int

同時,數字型別的精度問題往往困擾著新手開發者。不管是單精度 (float) 還是雙精度 (double) 它們都是基於浮點計數實現的,包含了符號域、指數域、尾數域,而在計算機的理解裡數字就是二進位制,所以浮點數基於二進位制的科學計數法形如:1.0101 * 2^n ,這可不像十進位制那樣方便的表示十進位制小數,比如在十進位制中使用 10^-1 輕鬆的表示十進位制的 0.1 ,而二進位制方式卻無法實現(試想 2 的幾次方等於十進位制的 0.1 ?),所以浮點數只能用最大限度的近似值表示這些無法精確表示的小數。

比如寫一句程式碼 float f = 0.1;打一個斷點可以看到它實際的值是:f = 0.100000001

和浮點計數相對的是定點計數,定點計數比較直觀,比如:10.0101 ,它的弊端就是對於有效位數過多的數字,需要大量的空間來儲存。所以為了儲存空間的高效利用,使用最廣泛的仍然是“不夠精確”的基於浮點計數的單精度和雙精度型別。

然而,在一些特定場景下,定點計數仍然能發揮它的優勢,比如金錢計算

對於金錢計算的處理,往往都是要求絕對準確的,所以在很多語言中都有基於定點計數的資料型別,比如 Java 中的 BigDecimal、Objective-C 中的 NSDecimalNumber,犧牲一些空間和時間來達到精確的計算。

21、使用裝飾模式的誤區

使用裝飾模式時,通常情況下不應該修改當前類的演算法。

比如 OC 中的分類,它為功能的新增提供了優雅的實現方式,但是開發者應該注意,不應該在分類裡面重寫當前類已經有了的方法。

因為在執行期,OC 分類中的方法是會自動插入類的方法列表,訊息呼叫機制會找到最靠前的方法而忽略掉該類本有的方法,這可能會出現很多異常情況且不易排查。

所以在使用裝飾模式時,要儘量不做可能影響其他業務的邏輯,比如 iOS 中“時髦”的 hook 技術,應該儘量少用。

為了防止在寫分類時一不小心過載了已有方法(可能是其它分類的方法),應該為分類方法都加上一個有辨識度的字首,比如-()mj_ 、-()custom_

我們應該儘量避免用上帝視角去寫程式碼。

22、巧用區域性閉包

經常會有一些需求,比如某段動畫可以選擇是否執行,可以如下處理:

    void (^animationsBlock)(void) = ^{
        ...
    };
    void (^completionBlock)(BOOL) = ^(BOOL x){
        ...
    };
    if (duration <= 0) {
        animationsBlock();
        completionBlock(YES);
    } else {
        [UIView animateWithDuration:duration animations:animationsBlock completion:completionBlock];
    }
複製程式碼

建立兩個棧區的 Block,若需要動畫就傳入 -animateWithDuration: 系列方法,若不需要動畫 Block 就不用被拷貝到堆區,而是直接呼叫。這樣處理還有一個好處就是不用重複寫兩個 Block 中的業務邏輯了,避免格外的方法封裝。

總結

程式碼技巧都是實踐加思考總結出來的,在程式碼編寫過程中,開發者需要時刻明白自己的程式碼是幹什麼的,不要隨意的複製程式碼。同時,開發者需要有演算法思維和工程思維,力求使用高效率和高可維護的程式碼來實現業務。

筆者最後總結幾點提高程式碼質量的途徑:

  • 設計架構制定規範,經常 code review。(不要說小公司沒人陪你 review,一個人也可以 review 得不亦樂乎)
  • 多閱讀優秀的開原始碼。(希望你能判斷何為優秀?)
  • 找一家技術驅動的公司。(一切以工時定貢獻的公司都是耍流氓,殊不知高效程式碼設計能減少相當多工作量)
  • 找到有能力打你臉的人,並和 TA 成為朋友。(相信我,技術人員要經常被打擊才能茁壯成長?)

相關文章