如何寫出高質量的程式碼?
引言
不重視程式碼質量的工程師永遠是初級工程師
為什麼專案維護困難、BUG 反覆?實際上很多時候就是程式碼質量的問題。程式碼架構就像是建築的鋼筋結構,程式碼細節就像是建築的內部裝修,建築的抗震等級、簡裝或豪裝完全取決於團隊開發人員的水平。
本文是筆者對於一些程式碼質量技巧的小總結,編寫高質量程式碼的思路在任何技術棧都是基本相通的,文章內容僅代表筆者的個人看法,拋磚引玉,不喜勿噴。
正文
1、使用 ++i 而不是 i++
經常看到這樣的程式碼:
1 |
|
單步自增 (或自減) 操作,最好是使用++i
i++
,效率略高。
大家應該都知道++i
的返回值是自增過後的,而i++
的返回值是自增之前的。其實從這點就可以猜測:++i
內部實現應該是直接將 i 這塊記憶體 +1 然後返回,而i++
需要使用一個區域性變數來儲存 i 的值,然後 i 加一,最後返回區域性變數的值(別告訴我你能先 return 再執行自增)。
如果某一種語言的i++
不能作為左值,那麼也可以猜測這個區域性變數是用const
修飾的。
所以,i++
理論上比++i
有更多的消耗,程式碼就這樣寫吧:
1 |
|
2、巧用位運算
位運算效率很高,而且有很多巧妙的用法,這裡提出一個需求:
1 2 3 4 5 |
|
對於該多選列舉,如何判斷該列舉型別的變數是否是複合項?
如果按照常規的思路,就需要逐項判斷是否包含,時間複雜度最差為O(n)。而使用位運算可以這麼寫:
TestEnum test = ...; if (test == (test & (-test))) { //不是複合項 }
實際上就是通過負數二進位制的一個特性來判斷,看如下分析便一目瞭然:
1 2 3 4 |
|
3、靈活使用組合運算子
不明白有些工程師為什麼排斥組合運算子,他們喜歡這麼寫:
1 2 3 |
|
使用三目運算子:
1 2 |
|
其他組合運算子比如 ?:
%=
等,靈活的使用它們可以讓程式碼更加的簡潔清晰。
4、const 和 static 和巨集
static
可以讓變數進入靜態區,提高變數生命週期至程式結束。值得注意的是,檔案中最外層(#include下)的變數本身就是在靜態區的,而這種情況使用static
是為了變數的私有化。
const 修飾的變數在常量區不可變,是在編譯階段處理;巨集是在預編譯階段執行巨集替換。所以頻繁使用 const 不會產生額外的記憶體,而所有使用巨集的地方都可能開闢記憶體,況且,預編譯階段的大量巨集替換會帶來一定的時間消耗。
所以筆者的建議是,能用常量的不用巨集,比如一個網路請求的 url:
1 2 3 4 |
|
值得注意的是,const 是修飾右邊記憶體,所以這裡是想要BaseServer
字串指標指向的內容不可變,而不是*BaseServer
內容不可變。
5、空間換時間
在很多場景中,可以犧牲一定的空間來降低時間複雜度,為了程式的高效執行,工程師可以自行判斷是否值得,下面舉一個程式碼例子,判斷字串是否有效:
1 2 3 4 5 6 7 8 9 10 11 |
|
使用一個 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 |
|
當同一個變數的呼叫過深且使用頻繁時,可以使用一個區域性指標來處理:
1 2 3 4 |
|
相對於指標變數所佔用的空間來說,程式碼的簡潔和美觀度稍顯重要一點。
11、避免濫用單例
單例作為一種設計模式應用非常廣泛,在移動端開發中,有些開發者利用它來實現非快取傳值,筆者認為這是一個錯誤的做法,使用單例傳值的時候你需要管理單例中的資料何時釋放與更新,可能會引發資料錯亂。
單例存在的意義應該是快取資料,而非傳值,切勿為了方便濫用單例。
12、避免濫用繼承
繼承本身和解耦思想有些衝突,程式碼設計中要儘量避免過深的繼承關係,因為子類與父類的耦合將無法真正剝離。過深的繼承關係會增加除錯的困難程度,並且若繼承關係設計有缺陷,修改越深的類影響面將會越廣,可能帶來災難性的後果。
可以使用分類的方式做一些通用配置,然後在具體類中簡潔的呼叫一次方法;也可以使用 AOP 思想,hook 住生命週期方法無侵入配置(比如埋點)。
比如 iOS 開發中,可能會有開發者喜歡寫一套基類,實際上只是基於系統的類做了小量的配置,比如BaseViewController
、BaseView
、BaseModel
、BaseViewModel
,甚至是BaseTableViewCell
。控制器基類可以對棧和導航欄做一些配置,還是有一點使用意義,至於其它的筆者感覺就是過度設計,其實很大意義上BaseViewController
也沒有存在的必要。
記住:過多的基類並不是程式碼規範,那是你囚禁其他開發者的牢籠。
13、避免過度封裝
提取方法的原則是功能單一性,但若功能本身就是很少的一兩句程式碼可能就沒必要額外提取了。在保證程式碼清晰的情況下,很多時候提取邏輯也是需要酌情考慮的。
有見過開發者使用一套所謂的簡潔配置 UI 的框架,不過就是將 UI 控制元件的屬性封裝成鏈式語法之類的,用起來有種快一些的錯覺,殊不知這就是過度封裝的典範。
封裝的意義在於簡潔的解決一類問題,而非少敲那幾個字母,過度封裝只會增加其他開發者閱讀你程式碼的成本。
比如業界知名的 Masonry,使用它時比原生的 layout 快了不止 10 倍,而且程式碼很簡潔易懂,極大的提高了開發效率。
14、避免過多程式碼塊巢狀
比如程式碼中大量的 if - else 巢狀判斷,大量的巢狀迴圈,大量的閉包巢狀。
出現這種情況首先要考慮的是分支結構處理是否多餘?迴圈是否可以優化時間複雜度?當排除這些可優化項過後,可以做一些方法提取減少大量的程式碼塊巢狀,方便閱讀。
15、時刻注意空值和越界
寫某塊程式碼中,要時刻注意空值和越界的處理,比如給NSDictionary
插入空值會崩潰,從NSArray
越界取值會崩潰,這些情況要時刻考慮到。
當然,可能有人會說有方法可以全域性避免崩潰。實際上筆者不是很贊同這種做法,這可能會讓新手開發者永遠發現不了自己程式碼的漏洞。
16、時刻注意程式碼的呼叫時機和頻率
當你寫一塊程式碼時,需要習慣性的思考兩個問題:這塊程式碼的共有變數會被多執行緒訪問從而存在安全問題麼?這塊程式碼可能會在一個 RunLoop 迴圈中呼叫很頻繁麼?
對於第一個問題,可能需要使用“鎖”來保證執行緒安全,而鎖的選擇有一些技巧,比如整形使用原子自增保證執行緒安全:OSAtomicIncrement32()
;呼叫耗時短的程式碼使用dispatch_semaphore_t
更高效;可能存在重複獲取鎖時使用遞迴鎖處理...
對於第二個問題,只需要在合適的地方加入自動釋放池 (autoreleasepool) 避免記憶體峰值就行了。
17、減少介面程式碼複用、增加功能程式碼的複用
對於大前端來說,介面是專案中重要的組成部分,而有時候設計師給的圖中,不同介面有很多相同的元素,看起來一模一樣,所以很多工程師偷懶直接複用介面了。
在這裡,筆者建議儘量少的複用介面,寧願選擇複製一份。
試想,目前版本兩個介面相同,你複用了它,當下個版本其中一個介面要調整一下,這時你繼續偷懶,加入一些判斷來區分邏輯,下一次迭代又增加了差異,你又偷懶加入判斷邏輯...... 最終你會發現,這個介面裡面已經邏輯爆炸了,拆分成兩個介面將變得異常困難。
而對於功能程式碼,筆者是提倡多提取,多複用,切記命名規範和適當的註釋。
18、元件的設計技巧
在封裝一些小元件時,一定要形成習慣,不想暴露給使用者的屬性和方法不要寫在介面檔案中,甚至於某些延續父類的方法不想使用者使用,可以如下處理:
1 |
|
當然,不用擔心元件內部如何獲取父類特性,可以通過[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 |
|
在使用不同數字型別時,需要考慮數字型別的表示範圍,比如能用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 得不亦樂乎)
-
多閱讀優秀的開原始碼。(希望你能判斷何為優秀