本文以 JavaScript 為例,介紹了該如何優化函式,使函式清晰易讀,且更加高效穩定。
軟體的複雜度一直在持續增長。程式碼質量對於保證應用的可靠性、易擴充套件性非常重要。
然而,幾乎每一個開發者,包括我自己,在職業生涯中都見過低質量的程式碼。這東西就是個坑。低質量程式碼具備以下極具殺傷力的特點:
- 函式超級長,而且塞滿了各種亂七八糟的功能。
- 函式通常有一些副作用,不僅難以理解,甚至根本沒法除錯。
- 含糊的函式、變數命名。
- 脆弱的程式碼:一個小的變更,就有可能出乎意料的破壞其他應用元件。
- 程式碼覆蓋率缺失。
它們聽起來基本都是:“我根本沒法理解這段程式碼是如何工作的”,“這段程式碼就是一堆亂麻”,“要修改這一段程式碼實在太難了” 等等。
我就曾遇到過這樣的情況,我的一個同事由於無法繼續將一個基於Ruby 的 REST API 做下去,繼而離職。這個專案是他從之前的開發團隊接手的。
修復現有的 bug ,然後引入了新的 bug,新增新的特性,就增加了一連串新 bug,如此迴圈(所謂的脆弱程式碼)。客戶不希望以更好的設計重構整個應用,開發人員也做出明智的選擇——維持現狀。
好吧,這種事兒經常發生,而且挺糟糕的。那我們能做點什麼呢?
首先,需要謹記於心:只是讓應用運轉起來,和盡心保證程式碼質量是兩個完全不同的事。一方面,你需要實現產品需求。但是另一方面,你應該花點時間,確保函式功能簡單、使用易讀的變數和函式命名,避免函式的副作用等等。
函式(包括物件方法)是讓應用運轉起來的齒輪。首先你應當將注意力集中在他們的結構和整體佈局上。這篇文章包括了一些非常好的示例,展示如何編寫清晰、易於理解和測試的函式。
1. 函式應當很小,非常小
避免使用包含大量的功能的大函式,應當將其功能分割為若干較小的函式。大的黑盒函式難於理解、修改,特別是很難測試。
假設這樣一個場景,需要實現一個函式,用於計算 array、map 或 普通 JavaScript 物件的權重。總權重可通過計算各成員權重獲得:
- null 或者 未定義變數計 1 點。
- 基本型別計 2 點。
- 物件或函式計 4 點。
例如,陣列 [null, ‘Hello World’, {}] 的權重這樣計算:1(null) + 2(string 是基本型別) + 4(物件) = 7。
Step 0: 最初的大函式
我們從最糟的例項開始。所有的邏輯都被編碼在函式 getCollectionWeight()
中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
function getCollectionWeight(collection) { let collectionValues; if (collection instanceof Array) { collectionValues = collection; } else if (collection instanceof Map) { collectionValues = [...collection.values()]; } else { collectionValues = Object.keys(collection).map(function (key) { return collection[key]; }); } return collectionValues.reduce(function(sum, item) { if (item == null) { return sum + 1; } if (typeof item === 'object' || typeof item === 'function') { return sum + 4; } return sum + 2; }, 0); } let myArray = [null, { }, 15]; let myMap = new Map([ ['functionKey', function() {}] ]); let myObject = { 'stringKey': 'Hello world' }; getCollectionWeight(myArray); // => 7 (1 + 4 + 2) getCollectionWeight(myMap); // => 4 getCollectionWeight(myObject); // => 2 |
問題顯而易見,getCollectionWeight() 函式超級長,而且看起來像一個裝滿“意外”的黑盒子。可能你也發現了,第一眼根本就搞不明白它要幹什麼。再試想一下,應用裡有大把這樣的函式。
在工作中遇到這樣的程式碼,就是在浪費你的時間和精力。反之,高質量的程式碼不會令人不適。高質量程式碼中,那些精巧、自文件極好的函式非常易於閱讀和理解。
Step 1:根據型別計算權重,拋棄那些“迷之數字”。
現在,我們的目標是:把這個巨型函式,拆分為較小的、獨立的、可重用的一組函式。第一步,將根據型別計算權重的程式碼提取出來。這個新的函式命名為 getWeight()。
我們再看看這幾個“迷之數字”: 1, 2, 4。在不知道整個故事背景的前提下,僅靠這幾個數字提供不了任何有用的資訊。幸好 ES2015 允許定義靜態只讀引用,那你就能簡單的創造幾個常量,用有意義的名稱,替換掉那幾個“迷之數字”。(我特別喜歡“迷之數字”這個說法:D)
我們來新建一個較小的函式 getWeightByType(),並用它來改進 getCollectionWeight():
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
// Code extracted into getWeightByType() function getWeightByType(value) { const WEIGHT_NULL_UNDEFINED = 1; const WEIGHT_PRIMITIVE = 2; const WEIGHT_OBJECT_FUNCTION = 4; if (value == null) { return WEIGHT_NULL_UNDEFINED; } if (typeof value === 'object' || typeof value === 'function') { return WEIGHT_OBJECT_FUNCTION; } return WEIGHT_PRIMITIVE; } function getCollectionWeight(collection) { let collectionValues; if (collection instanceof Array) { collectionValues = collection; } else if (collection instanceof Map) { collectionValues = [...collection.values()]; } else { collectionValues = Object.keys(collection).map(function (key) { return collection[key]; }); } return collectionValues.reduce(function(sum, item) { return sum + getWeightByType(item); }, 0); } let myArray = [null, { }, 15]; let myMap = new Map([ ['functionKey', function() {}] ]); let myObject = { 'stringKey': 'Hello world' }; getCollectionWeight(myArray); // => 7 (1 + 4 + 2) getCollectionWeight(myMap); // => 4 getCollectionWeight(myObject); // => 2 |
看起來好多了,對吧? getWeightByType()
函式是一個獨立的元件,僅僅用於決定各型別的權重值。而且它是可複用的,你可以在其他任何函式中使用它。
getCollectionWeight() 稍微瘦了點身。
WEIGHT_NULL_UNDEFINED
, WEIGHT_PRIMITIVE
還有 WEIGHT_OBJECT_FUNCTION
都是具備自文件能力的常量,通過它們的名字就可以看出各型別的權重。你就不需要猜測 1、2、4 這些數字的意義。
Step 2: 繼續切分,使之具備擴充套件性
然而,這個升級版依然有不足的地方。假如你打算對一個 Set,甚至其他使用者自定義集合來實現權值計算。getCollectionWeight() 會快速膨脹,因為它包含了一組獲得權值的具體邏輯。
讓我們將獲得 maps 權重的程式碼提取到 getMapValues()
,將獲得基本 JavaScript 物件權值的程式碼則放到 getPlainObjectValues() 中。看看改進後的版本吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function getWeightByType(value) { const WEIGHT_NULL_UNDEFINED = 1; const WEIGHT_PRIMITIVE = 2; const WEIGHT_OBJECT_FUNCTION = 4; if (value == null) { return WEIGHT_NULL_UNDEFINED; } if (typeof value === 'object' || typeof value === 'function') { return WEIGHT_OBJECT_FUNCTION; } return WEIGHT_PRIMITIVE; } // Code extracted into getMapValues() function getMapValues(map) { return [...map.values()]; } // Code extracted into getPlainObjectValues() function getPlainObjectValues(object) { return Object.keys(object).map(function (key) { return object[key]; }); } function getCollectionWeight(collection) { let collectionValues; if (collection instanceof Array) { collectionValues = collection; } else if (collection instanceof Map) { collectionValues = getMapValues(collection); } else { collectionValues = getPlainObjectValues(collection); } return collectionValues.reduce(function(sum, item) { return sum + getWeightByType(item); }, 0); } let myArray = [null, { }, 15]; let myMap = new Map([ ['functionKey', function() {}] ]); let myObject = { 'stringKey': 'Hello world' }; getCollectionWeight(myArray); // => 7 (1 + 4 + 2) getCollectionWeight(myMap); // => 4 getCollectionWeight(myObject); // => 2 |
現在再來看 getCollectionWeight()
函式,你會發現已經比較容易明白它的機理,看起來就像一段有趣的故事。
每一個函式的簡單明瞭。你不需要花費時間去挖掘程式碼,理解程式碼的工作。這就是清新版程式碼該有的樣子。
Step 3: 優化永無止境
就算到了現在這種程度,依然有很大優化的空間!
你可以建立一個獨立的函式 getCollectionValues()
,使用 if/else 語句區分集合中的型別:
1 2 3 4 5 6 7 8 9 |
function getCollectionValues(collection) { if (collection instanceof Array) { return collection; } if (collection instanceof Map) { return getMapValues(collection); } return getPlainObjectValues(collection); } |
那麼, getCollectionWeight()
應該會變得異常純粹,因為它唯一的工作:用 getCollectionValues() 獲得集合中的值,然後依次呼叫求和累加器。
你也可以建立一個獨立的累加器函式:
1 2 3 |
function reduceWeightSum(sum, item) { return sum + getWeightByType(item); } |
理想情況下 getCollectionWeight() 函式中不應該定義函式。
最後,最初的巨型函式,已經被轉換為如下一組小函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function getWeightByType(value) { const WEIGHT_NULL_UNDEFINED = 1; const WEIGHT_PRIMITIVE = 2; const WEIGHT_OBJECT_FUNCTION = 4; if (value == null) { return WEIGHT_NULL_UNDEFINED; } if (typeof value === 'object' || typeof value === 'function') { return WEIGHT_OBJECT_FUNCTION; } return WEIGHT_PRIMITIVE; } function getMapValues(map) { return [...map.values()]; } function getPlainObjectValues(object) { return Object.keys(object).map(function (key) { return object[key]; }); } function getCollectionValues(collection) { if (collection instanceof Array) { return collection; } if (collection instanceof Map) { return getMapValues(collection); } return getPlainObjectValues(collection); } function reduceWeightSum(sum, item) { return sum + getWeightByType(item); } function getCollectionWeight(collection) { return getCollectionValues(collection).reduce(reduceWeightSum, 0); } let myArray = [null, { }, 15]; let myMap = new Map([ ['functionKey', function() {}] ]); let myObject = { 'stringKey': 'Hello world' }; getCollectionWeight(myArray); // => 7 (1 + 4 + 2) getCollectionWeight(myMap); // => 4 getCollectionWeight(myObject); // => 2 |
這就是編寫簡單精美函式的藝術!
除了這些程式碼質量上的優化之外,你也得到不少其他的好處:
- 通過程式碼自文件,getCollectionWeight() 函式的可讀性得到很大提升。
- getCollectionWeight() 函式的長度大幅減少。
- 如果你打算計算其他型別的權重值,getCollectionWeight() 的程式碼不會再劇烈膨脹了。
- 這些拆分出來的函式都是低耦合、高可複用的元件,你的同事可能希望將他們匯入其他專案中,而你可以輕而易舉的實現這個要求。
- 當函式偶發錯誤的時候,呼叫棧會更加詳細,因為棧中包含函式的名稱,甚至你可以立馬發現出錯的函式。
- 這些小函式更簡單、易測試,可以達到很高的程式碼覆蓋率。與其窮盡各種場景來測試一個大函式,你可以進行結構化測試,分別測試每一個小函式。
- 你可以參照 CommonJS 或 ES2015 模組格式,將拆分出的函式建立為獨立的模組。這將使得你的專案檔案更輕、更結構化。
這些建議可以幫助你,戰勝應用的複雜性。
原則上,你的函式不應當超過 20 行——越小越好。
現在,我覺得你可能會問我這樣的問題:“我可不想將每一行程式碼都寫為函式。有沒有什麼準則,告訴我何時應當停止拆分?”。這就是接下來的議題了。
2. 函式應當是簡單的
讓我們稍微放鬆一下,思考下應用的定義到底是什麼?
每一個應用都需要實現一系列需求。開發人員的準則在於,將這些需求拆分為一些列較小的可執行元件(名稱空間、類、函式、程式碼塊等),分別完成指定的工作。
一個元件又由其他更小的元件構成。如果你希望編寫一個元件,你只能從抽象層中低一級的元件中,選取需要的元件用於建立自己的元件。
換言之,你需要將一個函式分解為若干較小的步驟,並且保證這些步驟都在抽象上,處於同一級別,而且只向下抽象一級。這非常重要,因為這將使得函式變得簡單,做到“做且只做好一件事”。
為什麼這是必要的?因為簡單的函式非常清晰。清晰就意味著易於理解和修改。
我們來舉個例子。假設你需要實現一個函式,使陣列僅保留素數(2, 3, 5, 7, 11 等等),移除非素數(1, 4, 6, 8 等等)。函式的呼叫方式如下:
1 |
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11] |
如何用低一級抽象的若干步驟實現 getOnlyPrime() 函式呢?我們這樣做:
為了實現 getOnlyPrime() 函式, 我們用 isPrime() 函式來過濾陣列中的數字。
非常簡單,只需要對數字陣列執行一個過濾函式 isPrime() 即可。
你需要在當前抽象層實現 isPrime() 的細節嗎?不,因為 getOnlyPrime() 函式會在不同的抽象層實現一些列步驟。否則,getOnlyPrime() 會包含過多的功能。
在頭腦中謹記簡單函式的理念,我們來實現 getOnlyPrime() 函式的函式體:
1 2 3 4 |
function getOnlyPrime(numbers) { return numbers.filter(isPrime); } getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11] |
如你所見, getOnlyPrime() 非常簡單,它僅僅包含低一級抽象層的步驟:陣列的 .filter() 方法和 isPrime()
函式。
現在該進入下一級抽象。
陣列的 .filter()
方法由 JavaScript 引擎提供,我們直接使用即可。當然,標準已經準確描述了它的行為。
現在你可以深入如何實現 isPrime() 的細節中了:
為了實現 isPrime() 函式檢查一個數字 n 是否為素數,只需要檢查 2 到 Math.sqrt(n) 之間的所有整數是否均不能整除n。
有了這個演算法(不算高效,但是為了簡單起見,就用這個吧),我們來為 isPrime() 函式編碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function isPrime(number) { if (number === 3 || number === 2) { return true; } if (number === 1) { return false; } for (let divisor = 2; divisor <= Math.sqrt(number); divisor++) { if (number % divisor === 0) { return false; } } return true; } function getOnlyPrime(numbers) { return numbers.filter(isPrime); } getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11] |
getOnlyPrime() 很小也很清晰。它只從更低一級抽象中獲得必要的一組步驟。
只要你按照這些規則,將函式變的簡潔清晰,複雜函式的可讀性將得到很大提升。將程式碼進行精確的抽象分級,可以避免出現大塊的、難以維護的程式碼。
3. 使用簡練的函式名稱
函式名稱應該非常簡練:長短適中。理想情況下,名稱應當清楚的概括函式的功用,而不需要讀者深入瞭解函式的實現細節。
對於使用駱駝風格的函式名稱,以小寫字母開始: addItem(),
saveToStore()
或者 getFirstName()
之類。
由於函式都是某種操作,因此名稱中至少應當包含一個動詞。例如 deletePage(),
verifyCredentials()
。需要 get 或 set 屬性的時候,請使用 標準的 set
和 get
字首:getLastName()
或 setLastName()
。
避免在生產程式碼中出現有誤導性的名稱,例如 foo(),
bar(),
a(),
fun()
等等。這樣的名稱沒有任何意義。
如果函式都短小清晰,命名簡練:程式碼讀起來就會像詩一樣迷人。
4. 總結
當然了,這裡假定的例子都非常簡單。現實中的程式碼更加複雜。你可能要抱怨,編寫清晰的函式,只在抽象上一級一級下降,實在太沒勁了。但是如果從專案一開始就開始你的實踐,就遠沒有想象中複雜。
如果應用中已經存在一些功能繁雜的函式,希望對它們進行重構,你可能會發現困難重重。而且在很多情況下,在合理的時間內是不可能完成的。但千里之行始於足下:在力所能及的前提下,先拆分一部分出來。
當然,最正確的解決方案應該是,從專案一開始就以正確的方式實現應用。除了花一些時間在實現上,也應該花一些精力在組建合理的函式結構上:如我們所建議的——讓它們保持短小、清晰。
成竹在胸,落筆有神.
ES2015 實現了一個非常棒的模組系統,它明確建議,小函式是優秀的工程實踐。
記住,乾淨、組織良好的程式碼通常需要投入大量時間。你會發現這做起來有難度。可能需要很多嘗試,可能會迭代、修改一個函式很多次。
然而,沒有什麼比亂麻一樣的程式碼更讓人痛心的了,那麼這一切都是值得的!