嗨~ 歡迎閱讀第 47 期的 ExplainThis 全端雙週報。
相信有些人在一些面試考古題中,看過「什麼是高階函式? 為什麼要用高階函式?」等相關問題。在這期的雙週報,我們將來探討這個問題。
高階函式不只是面試會被問,在日常工作上,也很常出現,推薦還不熟的讀者要花些時間理解。
什麼是高階函式? 使用高階函式有什麼好處?
如果要直接背誦這題的回答,可以回說「高階函式 (Higher order function) 是指可以接受另一個函式作為參數、或者會回傳一個函式作為結果的函式」。
而當要進一步回說「為什麼要用高階函式」時,則可以回說「透過高階函式,程式碼的可讀性可以提升,且能夠減少不必要的重複,同時也能夠容易除錯」。
假如你過去對高階函式沒有太多概念,推薦不要直接背這兩個回答。以下我們會透過多數人很熟悉的高階函式 map
、 reduce
以及 filter
作為範例切入,讓讀者們理解高階函式在做什麼,同時感受到高階函式的好處。
map
在多數的程式語言中,都有 map
這個內建的高階函式。map
函式在做的事情很簡單,一般來說會有兩個參數,一個參數是函式,一個是某個可迭代的東西 (例如陣列),然後 map
會迭代過這個可迭代的東西,然後在每次迭代時把值套到函式當中。
這樣講起來可能有點抽象,讓我們實際來看例子。以 Python 的內建 map
函式,官方文件的定義是 return an iterator that applies function to every item of iterable, yielding the results. 基本上與上面對 map
的定義是相同的。
讓我們實際來看可以怎麼在 Python 中用 map
l = [-1, -2, -3]
print(list(map(abs,l))) # [1, 2, 3]
以上面的例子來說,核心重點是 map(abs,l)
,這邊 map
接收的第一個引數是 abs
是 Python 內建把數字轉成絕對值得函式,第二個則是 [-1, -2, -3]
這個可迭代的列表,所以會把 -1
、-2
、-3
分別套到 abs
中,獲得絕對值。因此當我們把 map
的結果再透過 list
轉成列表後引出來,就會獲得 [1, 2, 3]
。
再來看看多數前後端工程師都熟悉的 JavaScript,雖然 map
在 JavaScript 當中是 Array
物件的方法,但其概念仍屬於高階函式的範疇。
在 MDN 上的範例如下
const array1 = [1, 4, 9, 16];
// Pass a function to map
const map1 = array1.map((x) => x * 2);
console.log(map1);
// Expected output: Array [2, 8, 18, 32]
可以看到,如上面對高階函式的定義中談到高階函式是「接受另一個函式作為參數」的函式,這邊的 map
接收了 (x) => x * 2
這個函式,因此可被歸類在高階函式當中。而也如前面對 map
的定義,在 JavaScript 的 map
會迭代過陣列,然後把傳入的函式套到陣列中的每個元素。因此,這邊原本的 array1
是 [1, 4, 9, 16]
,在每個元素都套上 (x) => x * 2
,就會獲得 [2, 8, 18, 32]
如何實作 map
在了解完 map
這個高階函式後,假如我們想要實作一個最陽春版本的 map
,可以怎麼做呢?
讓我們看到下面這個最陽出的版本 (僅處理陣列,不考量其他可迭代物)。這個 map
會接收一個陣列以及函式,而在 map
裡面會宣告一個 result
陣列,然後迭代過陣列時,把每個元素 item
丟到 callback
函式中,在把取得的結果放到 result
,最後回傳 result
即可。
// map 會接受一個陣列,以及一個回呼函式
function map(arr, callback) {
// 先宣告一個最後要回傳的陣列
const result = [];
// 用 for 迴圈迭代過陣列
for (let i = 0; i < arr.length; i++) {
const item = arr[i]
// 把每個陣列元素丟到 callback 函式,並把結果放到 result 當中
result.push(callback(item));
}
return result;
}
重新理解高階函式的好處
在了解完 map
在做什麼,以及簡單實作一個陽春版的 map
後,讓我們重訪「為什麼要用高階函式? 」這個問題。還記得,上面提到高階函式的好處在於「程式碼的可讀性可以提升,且能夠減少不必要的重複,同時也能夠容易除錯」。
以 map
的例子來說,當今天有「要迭代過陣列,然後用同樣邏輯轉換陣列中的元素」的需求時,就可以直接用 map
,不需用每次都重新寫類似的程式碼。與此同時,可以把核心焦點放在最重要的邏輯上。當今天要維護的時候,也只需要去改最核心的邏輯即可,這樣維護起來會簡單很多。
上面談到的 map
是屬於「接收函式」的類型,還記得前面談到,高階函式的定義當中,除了「接收函式」之外,第二個定義是「當某個函式回傳另一個函式」,這也會被稱為高階函式。
讓我們來看一個具體的例子,是 LeetCode 2623 題,也是很常會在面試中出現的考題 memoize
函式的實作。這個函式會把過去已經運算過的輸出存起來,如果之後有同樣的輸入,未來就不用再運算一次,而是直接從快取拿之前算過的。
具體來說,memoize 會接收一個函式,然後回傳一個帶有記憶化 (快取) 的函式。對輸出的函式來說,如果某個輸入已經被運算過,就不會重複運算,直接從快取回傳即可。
// 舉例來說,有個 sum 函式,會回傳兩個參數的相加
const sum = (a, b) => a + b;
// 今天如果被 memoize 後獲得 memoizedSum,會有以下的作用
const memoizedSum = memoize(sum);
memoizedSum(2, 2) // 4 這是經過運算的
memoizedSum(2, 2) // 4 這是直接從快取拿的,不用再次運算
可以看到,透過 memoize
這個高階函式,不管什麼樣的函式,如果想要記憶化,都不需用重新寫一遍,而是可以直接用 memoize
來包即可,讓可讀性提高,同時減少不必要的重複。
備註:想了解這個 memoize
如何實作,可以參考 ExplainThis 寫過的解答
閱讀更多
上面透過 map
來討論高階函式是什麼、為什麼要用。關於高階函式的更多內容,包含常見的 reduce
以及 filter
,還有實務上運用高階函式的案例,都在 E+ 成長計畫的主題文有更深入的解說。
除了高階函式這個常見的面試題,E+ 成長計畫上個月也上架了《軟體工程師求職全攻略》 這堂長 8 小時 57 分鐘的課程,收錄了過去我們協助讀者們求職的精華重點,從如何寫履歷,到如何準備行為、技術面試,以及在過程中遇到挫折時如何調整心態。
有興趣的讀者歡迎加入 E+,在求職過程一起加油~
本期推薦
近期生成式 AI 工具普及下,許多工程師大量使用相關工具。推薦讀者們在使用時,不要忘了保持思考。《New Junior Developers Can’t Actually Code》 一文有很精闢的觀察 (連結)
除了保持細節外,持續培養品味也是 AI 時代特別重要的。《Developing Taste》 一文有很精闢的觀點,來談工程師可以如何培養品味(連結)
上一期分享了 ExplainThis 成員 Li 的面試心得(連結),這週 Li 也分享了一篇七千多字的資深工程師面試準備心得 (連結)
在過去,確保程式碼中沒有多餘、未清理的程式碼,會對維護者比較友善,因為能減少不必要的認知負擔。在 AI 時代,保持乾淨、沒有多餘的程式碼庫,能夠減少 AI 在掃過整個程式碼庫時,有不必要的輸入放到脈絡中。許多編譯語言有編譯器幫忙檢查,而如果用 JavaScript 這類不經編譯器的語言,則可以用 Knip 這類工具(連結)
過去兩週 React 社群有一個爭議極大的討論,是 React 團隊棄用 CRA 但在官方文件,卻沒有推薦基於 Vite 的做法。在這個爭議的討論看到覺得最欣賞的一個推文,是 Vue 與 Vite 的作者 Evan You 發的,他談到即使 Vue 與 React 走的方向不同,但是 Vite 作為生態圈中重要的一環,會盡所能支援 React 的需要,非常佩服這種思考格局 (連結)
最近看到的一個很酷的專案 Calculating Empires,用視覺化的方式呈現歷史上重大的科技突破,看到這個專案時,油然而生感謝前人所做的,同時感謝自己身在這個世代 (連結)
近年來 a11y 越來越受到重視,在 principles-of-web-accessibility 這個 GitHub 開源專案中 (連結),談了 a11y 的重要原則
文末彩蛋
感謝讀到文末的你,這邊分享這週讀到一句很有感的話。是先前訂閱 The Steve Jobs Archive 電子報 (連結) 看到的。 賈伯斯當年談到,人們會用不同的方式表達內心的感激,而他認為「打造美好並分享」是表達對人類感激的一種方式。身為軟體工程師,願我們能持續為世界打造更多美好!
There’s lots of ways to be, as a person. And some people express their deep appreciation in different ways. But one of the ways that I believe people express their appreciation to the rest of humanity is to make something wonderful and put it out there.
— Steve Jobs
## 什麼是高階函式。舉一些例子好了。
Clojure 的 constantly, partial, apply 是比 map, filter, reduce 相對少見的高階函式。
constantlly 會傳回一個可以接受任何引數、但是傳回固定值的函式。
```
(map (constantly 9) [1 2 3])
user=> (9 9 9)
````
partial 可以偏特化函式,即它可以接受一個函式做為引數,並且將該函式之引數固定為特定的值之後,再加以傳回。在下方的例子裡,partial 接受 + 做為引數,將 1 做為值來偏特化 + 。
```
user=> (def add-one (partial + 1))
#'user/add-one
user=> (add-one 2)
;=> 3
```
apply 可以接受一個函式做為第一引數、一個陣列做為第二引數,而 apply 可以將第一引數的函式『套用』(apply) 到陣列上,即陣列裡的每一個元素都會依序成為第一引數的函式的引數。
```
(apply max [1 2 3])
;;=> 3
;; which is the same as
(max 1 2 3)
;;=> 3
```
## 使用高階函式有什麼好處?
一個關鍵的好處在於它讓 what/how 分離了。當你使用高階函式時,你的語意停留在指定了 what,而你把實作細節的 how 留給了高階函式處理。而這個 what/how 分離是軟體設計之中一個關鍵的技巧。
很多時候,軟體 layer 與 layer 之間往往很難做到乾淨的切分。比方說,即使表面上看起來 A module 只依賴於 B module 的 3 個函式。然而,一旦仔細去看 A module 的實作時,往往又發現,不只是這樣子,A module 依賴的除了 B module 的 3 個函式之外,還有 B module 的 傳回的 XX 值,值必定是 OO 型態之類的。
上述的現象很常見,而一旦有了這種現象,往往 B module 也無法做成 library 日後繼續使用。
而如果要讓軟體可以有複用性,就要考慮設計時讓 what/how 分離。如果 A module 只處理 what,而 B module 只處理 how ,這種設計方式,就很有機會讓 B module 有複用性。
what/how 分離的技巧非常地抽象。
你天天用高階函式,反複地去體會何謂 what/how 分離,直到有一天,許許多多的 what/how 分離都再也難逃你的法眼,最後,你也終於進入了信手捻來都會寫出了 what/how 分離的設計的境界。
參考:https://replware.substack.com/p/e48