1. 程式人生 > >如何寫出高質量的程式碼?

如何寫出高質量的程式碼?

引言

不重視程式碼質量的工程師永遠是初級工程師

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

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

正文

1、使用 ++i 而不是 i++

經常看到這樣的程式碼:

1

for (int i = 0;; i++) {}

單步自增 (或自減) 操作,最好是使用++i

而不是i++,效率略高。

大家應該都知道++i的返回值是自增過後的,而i++的返回值是自增之前的。其實從這點就可以猜測:++i內部實現應該是直接將 i 這塊記憶體 +1 然後返回,而i++需要使用一個區域性變數來儲存 i 的值,然後 i 加一,最後返回區域性變數的值(別告訴我你能先 return 再執行自增)。

如果某一種語言的i++不能作為左值,那麼也可以猜測這個區域性變數是用const修飾的。

所以,i++理論上比++i有更多的消耗,程式碼就這樣寫吧:

1

for (int i = 0;; ++i) {}

2、巧用位運算

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

1

2

3

4

5

typedef enum : NSUInteger {

TestEnumA = 1,

TestEnumB = 1 << 1,

TestEnumC = 1 << 2,

TestEnumD = 1 << 3} TestEnum;

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

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

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

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

1

2

3

4

test           0000 0100

反碼           1111 1011

補碼           1111 1100

test & (-test) 0000 0100

3、靈活使用組合運算子

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

1

2

3

bool is = ...;

if (is) a = 1;

else a = 2;

使用三目運算子:

1

2

bool is = ...;

a = is 1 2;

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

4、const 和 static 和巨集

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

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

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

1

2

3

4

.h 介面檔案

extern NSString * const BaseServer;

.m 實現檔案

NSString * const BaseServer = @;

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

5、空間換時間

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

1

2

3

4

5

6

7

8

9

10

11

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", 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 來寫,則出現頻率高的分支優先判斷,可以從整體上最大限度的減少判斷次數。

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

9、避免資料同步

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

10、合理使用區域性指標

經常會看到這種程式碼:

1

2

3

doSomething(city.school.class.jack.name,             

city.school.class.jack.age,             

city.school.class.jack.sex);

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

1

2

3

4

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、元件的設計技巧

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

1

- (instancetype)init UNAVAILABLE_ATTRIBUTE;

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

同時,在多人開發中,元件的開放方法名最好加入一些字首,便於區別,也避免方法重名,最容易導致方法重名的情況就是各種分類裡面的方法重複,會帶來意想不到的錯誤。

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轉換為數字型別時,為了相容,需要如下寫:

1

2

3

4

5

6

7

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,犧牲一些空間和時間來達到精確的計算。

總結

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

總結幾點提高程式碼質量的途徑:

  • 設計架構制定規範,經常 code review。(不要說小公司沒人陪你 review,告訴你一個人也可以 review 得不亦樂乎)

  • 多閱讀優秀的開原始碼。(希望你能判斷何為優秀