iT邦幫忙

2021 iThome 鐵人賽

DAY 6
1
Modern Web

Javascript 從寫對到寫好系列 第 6

Day 6 - Function 時空旅行 (1) - 參數優化

前言

Array 跟 Object 兩兄弟的故事告一段落了,接著是 Object 在外面養(?)的另外一個兄弟 - function

浪漫一點來說,function 其實就像是另一個平行宇宙,從 function 的呼叫處一躍而下,穿越蟲洞到了另一個與世隔絕的小空間,而在這個空間發生的事、定義的變數,(基本上)不會影響到原來的時空。

所以我稱進入 function 是一場「時空旅行」,好啦可能時間還是照常流動(同步),不過起碼空間是換了一個 context。

今天我們先來聊聊,要進入時空旅行之前,總是要帶一些乾糧或行李,我們把它稱作:

✅ 參數

蛤?function 的參數太基本了吧!呼叫的時候一個一個丟進去,使用的時候一個一個取出來,同學你不是來騙讚的吧!?

嘿啊,我也知道 function 帶參數是基本中的基本了,所以這個問題應該改成:

參數怎麼帶比較「好」

✔ 參數一個一個帶會遇到的問題

我們舉個例會比較清楚:

// 查詢網銀交易的紀錄
/*
 * userId       : 使用者 id
 * startDate    : 查詢區間開始
 * endDate      : 查詢區間結束
 * searchKeyword: 搜尋備註關鍵字
 * type         : 'deposit'(存款) 或 'withdraw'(提款)
*/
const queryTransaction = (userId, startDate, endDate, searchKeyword, type) => {
    // 實作到 DB query 資料的部分
};

queryTransaction('612b06609ea3b35614c0edbd', '2021-09-16', '2021-10-15', '同事代墊', 'deposit');

上面的例子可以看出,當一個 function 的帶入參數很多時,很容易會遇到這些問題:

  • 呼叫時,看不出每個參數各自代表什麼意義,需要跑到 function 定義的位置才知道。
  • 某個參數是選填時,如上例,如果使用者不想特別「搜尋備註關鍵字」,就還得刻意填個 null 之類的。
  • 函式定義如果要修改參數的順序,就要把所有用到這個函式的地方改過一次。

✔ 參數過多就組成 Object 傳進去吧

稍微改進一下會變這樣:

// 查詢網銀交易的紀錄
/*
 * userId       : 使用者 id
 * startDate    : 查詢區間開始
 * endDate      : 查詢區間結束
 * searchKeyword: 搜尋備註關鍵字
 * type         : 'deposit'(存款) 或 'withdraw'(提款)
*/
const queryTransaction = ({ userId, startDate, endDate, searchKeyword, type }) => {
    // 實作到 DB query 資料的部分
};

queryTransaction({
    userId: '612b06609ea3b35614c0edbd', 
    startDate: '2021-09-16', 
    endDate: '2021-10-15', 
    searchKeyword: '同事代墊', 
    type: 'deposit'
});

可以發現是在函式定義的地方做了小改進,把原本的 5 個參數,塞成一個 object,變成 1 個參數,再透過 object destructuring 變回 5 個,函式定義的地方如果看不太懂,可以這樣理解:

const queryTransaction = ({ userId, startDate, endDate, searchKeyword, type }) => {
    // 實作到 DB query 資料的部分
};

// 上下兩段是一樣的效果

const queryTransaction = (option) => {
    const { userId, startDate, endDate, searchKeyword, type } = option;
    // 實作到 DB query 資料的部分
};

如果是上述的版本,因為是帶入一個 object 當作參數

  1. 我可以幫每個參數「命名」,就可以知道每個參數代表什麼。
  2. object 內的 key 是沒有順序性的,所以即便我把 userId 搬到 type 的後面,也不用去改函式呼叫的地方。
  3. 如果我今天不想要「搜尋備註關鍵字」,那就直接不要帶這組 key/value 即可,完全讓它變成一個 optional 的選項,不用特地 null

以上三點就把剛才上面提到的三個問題都解決了,變成更有彈性,且即便後人接手 refactor,也比較不會因為參數的增減、順序而造成 bug

✔ 再優化!必要參數與選填參數區隔開

可以想像我現在是帶著一個後背包出門,裡面裝了我所有的旅行用品,但外表看起來就是一個後背包,所以裡面少帶了什麼其實不一定會發現

所以我們把後背包裡面,比較重要的東西拿出來握在手上,告訴自己一定要手上有東西才可以出門,有點像是出門先喊「手機、鑰匙、錢包!」一樣

因此這邊可以再做一個小優化,因為上述提到的三個問題,都比較是因為參數過多,而且有一些其實是選填參數所造成的。

因此我可以只把選填參數包成 object,而像 userId 這種肯定是必填的欄位,要避免其它同事不小心漏掉,就可以直接用原本的方式放在前面:

const queryTransaction = (userId, option) => {
    const { startDate, endDate, searchKeyword, type } = option;
    // 實作到 DB query 資料的部分
};

const option = {
    startDate: '2021-09-16', 
    endDate: '2021-10-15', 
    searchKeyword: '同事代墊', 
    type: 'deposit'
};
queryTransaction(
    '612b06609ea3b35614c0edbd', 
    option
);

參考其它套件的類似做法

fetch 發送 request 的時候,必要的欄位是 URL,其它都放在 option

const fetchOption = {
    method: 'POST',
    body: JSON.stringify(data), 
    headers: {
      'user-agent': 'Mozilla/4.0 MDN Example',
      'content-type': 'application/json'
    }
};
fetch('這裡是 URL', fetchOption)
     .then(response => response.json())

mongoose 連線 DB 時,必要的欄位是 URL,其它都放在 option

const mongooseOption = {
  ssl: true,
  autoIndex: true,
  serverSelectionTimeoutMS: 5000,
};
mongoose.createConnection('這裡是 URL', mongooseOption);

Fuse 搜尋資料時,必要的欄位是 dataList,其它都放在 option

const fuseOption = {
    isCaseSensitive: true,
    threshold: 0.6,
    distance: 100,
    keys: ['name', 'price', 'note'],
};
new Fuse([這裡放 dataList], fuseOption);

以上都是關於帶入參數的方式,進行了一些優化,過程中也發現這也不是唯一解,因為關於「參數過多」,有時候是因為這個 function 「能做的事太多了」。

可以把 function 切割成多個「目標不同」的 function,重新給予命名,然後根據需要狀況呼叫,就可以不用那麼多參數了,這點可以過兩天再來討論。

✔ 不要修改帶入的參數

沒錯,時空旅行就是單純去旅行就好,背包裡的行李不要變質。。。

翻成白話就是,「不會有任何一個參數,會因為執行了這個 function,而產生任何變化」。

這是之後會提到的 Functional Programming 的其中一個重點,也就是避免像下面這種 side effects:

const arr = ['Jack', 'Allen', 'Alice', 'Susan'];
const sortArr = () => {
    arr.sort();
};

console.log(arr);
sortArr();
console.log(arr);

執行結果

['Jack', 'Allen', 'Alice', 'Susan']
["Alice", "Allen", "Jack", "Susan"]

這種狀況通常會在 function 最開頭,先把參數拷貝一份,然後用拷貝的資料來修改,保持正本 read-only:

const arr = ['Jack', 'Allen', 'Alice', 'Susan'];
const sortArr = () => {
    const copiedArr = [ ...arr ];
    copiedArr.sort();
};

console.log(arr);
sortArr();
console.log(arr);

執行結果

['Jack', 'Allen', 'Alice', 'Susan']
['Jack', 'Allen', 'Alice', 'Susan']

這個問題只會出現在參數是 non-primitive 的時候

因為如果是把 primitive 變數當作參數,會是 call by value 的方式,丟一個拷貝後的副本進去 function,不管怎麼改都不會影響到正本。

而 non-primitive 則是 call by reference,會直接把正本丟到 function 裡面,如果改了就會連帶影響外面。

主要是為了避免不可預期的 bug,因為如果每次執行這個 function 都會造成外面的變數變化,那代表如果這個 function 出現問題寫錯了,就會造成其它地方也著火,到時會很難釐清問題點到底在哪,對於 unit testing 也是相當不利的。

不要修改帶入的參數。。。的例外

前面提到,不要修改參數的原因是怕「不可預期」的狀況,反之,如果是「可預期」的,那麼直接修改參數其實是效能更好的哦!

最常見的例子就是前兩天介紹的 Array 組合技 reduce,當初要做「Array 轉換成 Object」的例子時,就直接塞新的 property 給 reduce 內的那個 prev (第7行):

const arr = [
    { id: 'item1', name: 'TV', price: 13500 },
    { id: 'item2', name: 'washing machine', price: 8200 },
    { id: 'item3', name: 'laptop', price: 25000 },
];
const resultObject = arr.reduce((prev, curr) => {
    prev[curr.id] = curr;
    return prev;
}, {});
console.log(resultObject);

可以直接進行參數修改的原因是,reduce 裡面的這個 function,本身是「可預期的」,因為我們已經把初始值定為 {},所以不管這個 reduce 執行多少遍,裡面的 function 都是從 {} 開始跑,永無例外。

在這種非常確定可以直接修改的情況下,「直接修改」比起「先拷貝再修改」的效能還快得多,因為拷貝本身真的很吃效能(尤其 deep copy),有興趣可以參考這篇,有實測數據可以參考。

結語

function 的參數,簡單可以很簡單,難起來居然也像這樣可以獨立一篇出來討論了!

但無論如何,今天討論的都不是如何把程式寫「對」,最上面第一塊程式碼區塊,就已經是可以正常運作的了。

差別只在於,如何在運作正確之餘,讓程式規模更容易擴充、除錯,這些都將是未來設計更大、更複雜程式會遇到的難題。

背好背包
穿越銀河
下一站是全然獨立的異世界

參考資料

Spread v.s Assign


上一篇
Day 5 - 陣列與物件的進化 - Set & Map
下一篇
Day 7 - Function 時空旅行 (2) - 拆解與命名
系列文
Javascript 從寫對到寫好30

1 則留言

0
TD
iT邦新手 4 級 ‧ 2021-09-21 21:13:33

還有一種是收集剩下的參數 :p

const myFun = (first, second, ...rest) => {
  console.log(first, second, rest)
}


myFun(1,2,3,4,5,6)  // 1 2 [3, 4, 5, 6]
s941407 iT邦新手 5 級 ‧ 2021-10-05 01:29:19 檢舉

沒錯~~~當函式被呼叫的時候,會產生一個帶有索引特性的 arguments 物件,而且可以用 Array.from() 轉成陣列。

我要留言

立即登入留言