原文連結:https://dmitripavlutin.com/the-art-of-writing-small-and-plain-functions/?utm_source=codropscollective
譯者:阿里雲-也樹
隨著軟體應用的複雜度不斷上升,為了確保應用穩定且易擴充,程式碼質量就變的越來越重要。
不幸的是,包括我在內的幾乎每個開發者在職業生涯中都會面對質量很差的程式碼。這些程式碼通常有以下特徵:
- 函式冗長,做了太多事情
- 函式有副作用並且很難理解和除錯排錯
- 含糊的函式/變數命名
- 程式碼脆弱,一個小改動會意外地破壞應用的其它元件
- 缺乏測試的覆蓋
這些話聽起來非常常見:“我不明白這部分程式碼怎麼工作的”,“這程式碼太爛了”,“這程式碼太難改了”等等。
有一次我現在的同事因為在之前的團隊處理過難以維護的Ruby 編寫的 REST API 而辭職,他是接手了之前開發團隊的工作。在修復現有的 bug 時會創造新的 bug,新增新的特性也會創造一系列新的 bug,而客戶也不想以更好的設計去重構應用,因而我的同事做了辭職這個正確的決定。
這樣的場景時有發生,我們能做些什麼呢?
需要牢記於心的是:僅僅讓應用可以執行和關注程式碼質量是不同的。一方面你需要滿足應用的功能,另一方面你需要花時間確認是否任意的函式沒有包含太多職責、是否所有函式都使用了易理解的變數和函式名並且是否避免了函式的副作用。
函式(包括物件的方法)是讓應用執行的小齒輪。首先你應該專注於它們的結構和編寫,而下面這篇文章闡述了編寫清晰易懂且容易測試的函式的最佳實踐。
函式需要“小”
要避免編寫職責冗雜的龐大函式,而需要將它們分離成很多小函式。龐大的函式就像黑盒子一樣,很難理解和修改,尤其在測試時更加捉襟見肘。
想象一個場景:一個函式需要返回一個陣列、map 或者普通物件的“重量”。“重量”由屬性值計算得到。規則如下:
null
或者undefined
計為1
- 基礎型別的資料計為
2
- 物件或者函式型別的資料計為
4
舉個例子:陣列 [null, 'Hello World', {}]
的重量計算為: 1
(null
) + 2
(字串型別) + 4
(物件) = 7
Step 0: 最初的龐大函式
讓我們從最壞的情況開始,所有的邏輯都寫在一個龐大的 getCollectionWeight()
函式裡。
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 允許我們利用 const
來定義只讀的的變數,所以可以建立有含義的常量來取代魔數。
讓我們建立 getWeightByType()
函式並且改善一下 getCollectionWeight()
函式:
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()
函式包含了收集值的邏輯,它的程式碼量會快速增長。
讓我們從程式碼中抽象出一些函式,比如獲取 map 型別的資料的函式 getMapValues()
和獲取普通物件型別資料的函式 getPlainObjectValues()
。再看看新的改進版:
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 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()
函式,包含區分資料集合型別的判斷邏輯:
function getCollectionValues(collection) {
if (collection instanceof Array) {
return collection;
}
if (collection instanceof Map) {
return getMapValues(collection);
}
return getPlainObjectValues(collection);
}
複製程式碼
getCollectionWeight()
函式會變得十分簡單,因為它唯一要做的事情就是從 getCollectionValues()
中獲取集合的值,然後執行累加操作。
你也可以建立一個獨立的 reduce 函式:
function reduceWeightSum(sum, item) {
return sum + getWeightByType(item);
}
複製程式碼
因為理想情況下 getCollectionWeight()
中不應該定義匿名函式。
最終我們最初的龐大函式被拆分成下面這些函式:
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行,小則優。
你現在可能會問我一個合情合理的問題:“我不想為每一行程式碼都建立函式,有沒有一個標準讓我不再繼續拆分函式?”這就是下一章節的主題。
函式應該是簡練的
讓我們稍作休息,思考一個問題:軟體應用究竟是什麼?
每個應用都是為了完成一系列的需求。作為開發者,需要把這些需求分解為可以正確執行特定任務的小元件(名稱空間,類,函式,程式碼塊)。
一個元件包含了其它更小的元件。如果你想要編寫一個元件,需要通過抽象程度比它低一層級的元件來建立。
換句話講:你需要把一個函式分解為多個步驟,這些步驟的抽象程度需要保持在同一層級或者低一層級。這樣可以在保證函式簡練的同時踐行“做一件事,並且做好”的原則。
為什麼分解是必要的?因為簡練的函式含義更加明確,也就意味著易讀和易改。
讓我們看一個例子。假設你想要編寫函式實現只儲存陣列中的素數,移除非素數。函式通過以下方式執行:
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]
複製程式碼
在 getOnlyPrime()
函式中有哪些低一層級的抽象步驟?接下來系統闡述:
使用
isPrime()
函式過濾陣列中的數字。
需要在這個層級提供 isPrime()
函式的細節嗎?答案是否定的。因為 getOnlyPrime()
函式會有不同層級的抽象步驟,這個函式會包含許多的職責。
既然腦子裡有了最基礎的想法,讓我們先完成 getOnlyPrime()
函式的內容:
function getOnlyPrime(numbers) {
return numbers.filter(isPrime);
}
getOnlyPrime([2, 3, 4, 5, 6, 8, 11]); // => [2, 3, 5, 11]
複製程式碼
此時 getOnlyPrime()
函式非常簡潔。它包含了一個獨立層級的抽象:陣列的 .filter()
方法和 isPrime()
函式。
現在是時候向更低的層級抽象了。
陣列方法是 .filter()
直接由 JavaScript 引擎提供的,原樣使用即可。ECMA標準中精確地描述了它的功能。
現在我們來研究 isPrime()
函式的具體實現:
為了實現檢查一個數字
n
是否為素數的功能,需要確認是否從2
到Math.sqrt(n)
的任意數字都可以整除n
。
理解了這個演算法(效率不高,但簡便起見)後,來完成 isPrime()
函式的程式碼:
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()
函式小而精煉。它僅僅保留了必需的低一層級的抽象。
如果你遵照讓函式簡練化的原則,複雜函式的可讀性可以大大提升。每一層級的精確抽象和編碼可以防止編寫出一大堆難以維護的程式碼。
使用簡明扼要的函式名稱
函式名稱應該簡明扼要,不應過於冗長或者簡短。理想情況下,函式名稱應該在不對程式碼刨根問底的情況下清楚反映出函式的功能。
函式名稱應該使用駝峰式命名法,以小寫字母開頭:addItem()
, saveToStore()
或者 getFirstName()
。
因為函式代表了動作,函式名稱應該至少包含一個動詞。比如:deletePage()
, verifyCredentials()
。獲取或者設定屬性值時,使用標準的 set
和 get
字首:getLastName()
或者 setLastName()
。
避免編寫含混的函式名,比如 foo()
, bar()
, a()
, fun()
等等。這些名稱沒有意義。
如果函式小而清晰,名稱簡明扼要,程式碼就可以像散文一樣閱讀。
結論
當然,上面提供的示例十分簡單。真實的應用中會更加複雜。你可能會抱怨僅僅為了抽象出一個層級而編寫簡練的函式是沉悶乏味的任務。但是如果從專案開始之初就正確實踐的話就不會是一件困難的事。
如果應用已經有很多函式擁有太多職責,你會發現很難理解這些程式碼。在很多情況下,不大可能在合理的時間完成重構的工作。但是至少從點滴做起:盡你所能抽象一些東西。
最好的解決辦法當然是從一開始就正確的實現應用。不僅要在實現需求上花費時間,同樣應該像我建議的那樣:正確組織你的函式,讓它們小而簡練。
三思而後行。(Measure seven times, cut once)
ES2015 實現了一個很棒的模組系統,清晰地建議出分割函式是好的實踐。
記住永遠值得投資時間讓程式碼變得簡練有組織。在這個過程中,你可能覺得實踐起來很難,可能需要很多練習,也可能回過頭來修改一個函式很多次。
但沒有比一團亂麻的程式碼更糟的了。
譯者注
文章作者提出的 small function
的觀點可能會讓初學者產生一點誤解,在我的理解裡,更準確的表述應該是從程式碼實現功能的邏輯層面抽象出更小的功能點,將抽象出的功能點轉化為函式來為最後的業務提供組裝的零件。最終的目的依然是通過解耦邏輯來提高程式碼的擴充性和複用性,而不能僅僅停留在視覺層面的”小“,單純為了讓函式程式碼行數變少是沒有意義的。