函式程式設計 (functional programming) — 函式組合 (Composition)

2025年5月8日

💎 加入 E+ 成長計畫 如果你喜歡我們的內容,歡迎加入 E+,獲得更多深入的軟體前後端內容

在先前的文章中,我們從函式的宣告式特點談到高階函式。在有了這些基礎後,在這期的主題文,我們會進一步來談函式組合 (composition)。

函式好在哪?

在談函式組合是什麼之前,想與讀者們先思考一個問題,當今天提到函式的好處時,大家會想到什麼呢? 相信多數人會想到模組化、可複用、好測試等好處。

讓我們用麵包工廠來做比喻,假如今天有一間工廠想用全自動化的方式來做奶油麵包。要實現這件事,通常會把流程拆分成多個不同的部分,然後以類似流水線的方式串在一起。

舉例來說,這個流水線可以切分成以下的不同部分:

  • 製作麵糰:把麵粉原料搭配水揉成做麵包的麵糰
  • 發酵:在完成麵糰後,加入發酵粉靜置發酵
  • 成形:把發酵完的麵糰,轉成一個個麵包原型
  • 烘烤:有了一個個麵包的原型後,進一步放入烤箱烘烤
  • 加入奶油:在烤完麵包後,把美味的鮮奶油擠入麵包當中

(下圖為用 ChatGPT 幫忙生成示意圖,雖然圖中的中文字有點混亂,但圖片概念很精準傳達)

麵包工廠比喻示意圖
麵包工廠比喻示意圖

以程式的角度來看,上面提到的每個步驟,都可以用函式的概念來表達。舉例來說,製作麵糰可以是一個函式,像下面這樣:

function makeDough(ingredients: Ingredients): Dough {
  // 細節忽略
}

透過函式的形式來切分,我們可以輕易地做到模組化,把不同的步驟拆成不同的模組;這樣的好處是能夠重複使用,假如之後想擴廠,同樣的 makeDough 函式就可以拿來用。同時因為各個模組切分地很清楚,所以要測試或者除錯也會變容易,因為函式的邊界很清楚,很容易找出哪邊出問題,然後針對出問題的函式來除錯,就不用在茫茫大海中找問題。

除了上面談的好處,我們也可以從「抽象化」這個角度來看函式。所謂的抽象化,是指把複雜的執行細節隱藏起來,只看做核心的概念。以上面的麵包工廠為例,在上面 makeDough 的函式內,我們用註解寫了 // 細節忽略,這就是一種抽象化。

就像要製作麵糰,我們需要有不同的機器零件、需要有不同的原料,需要有製作的工法 (例如要怎麼揉、不同階段的溫度要怎麼調整),但今天在用麵糰製作機時,完全不用去考慮那些細節,只用把原料倒進去就好。在使用makeDough 函式時,也不用關注執行細節,只要把輸入丟入,就會得到相對應的輸出。

什麼是函式組合 (function composition)?

上個段落我們談了函式的好處、抽象化讓我們能關注核心而非細節。但在上面的麵包工廠案例中,有一個很關鍵的點,是我們還沒談到的,那就是製作麵包的不同階段,可以組合在一起,讓麵包從開始製作到出爐,都能全自動完成。

就如要以函式來表達,會是像這樣:

const dough = makeDough(rawIngredients);
const loaves = shapeDough(dough);
const bread = bakeLoaves(loaves);
const butterBread = addButter(bread);

如果要用工廠流水線的角度來看,則可以變成這樣:

const butterBread = addButter(
  bakeLoaves(shapeDough(makeDough(rawIngredients)))
);

抽象一點來看,就會是:

輸入 -> 函式 1 -> 函式 2 -> 函式 3 -> 輸出

但是上面 addButter(bakeLoaves(shapeDough(makeDough(rawIngredients))))  這樣的寫法,可讀性不是太好,所以在函式程式設計中,多半會使用 compose 這個高階函式,來把多個不同的函式組合起來。

具體來說,compose 的用法會是這樣,把要組合的函式,傳入到 compose 之中,而 compose 會回傳組合出來的函式。接著如果要獲得最終的成果,直接呼叫組合出的函式即可。

// 把 addButter, bakeLoaves, shapeDough, makeDough 組合成 createButterBread
const createButterBread = compose(addButter, bakeLoaves, shapeDough, makeDough);

// 直接用 createButterBread 來做奶油麵包。
const butterBread = createButterBread(rawIngredients);

而今天如果想要做巧可力麵包,只需用把 addButter 換成 addChocolate,就能夠組合成 createChocolateBread 這個函式,因為流程中的其他部分都一樣,所以只需要替換掉不同的部分,就能組合出新的函式,其他都能重複利用,非常方便。

在看完上面的例子後,相信讀者可能會覺得,能夠理解用製作麵包例子,但是在實際工作上,可以如何運用函式組合呢? 以 ExplainThis 的網站本身來說,我們就有用上函式組合。因為 ExplainThis 網站基本是個文章為主的靜態網站,對這類網站 SEO 是重要的面相。為了做到 SEO 最佳化,需要有固定對搜尋引擎友善的格式,這時候就能把不同的函式組合起來,用在不同地方。

具體來說會是這樣:

// 把英文標題轉換成慣用標題的函式
// 例如把 what is functional programming 轉為 What Is Functional Programming
const formatTitle = compose(
  capitalizeWords, // 把每個字的字首轉成大寫
  removeExtraSpaces, // 去除掉前後多餘的空格
  toLowerCase // 先把所有字都轉成小寫
);

// 產生 slug 的函式,好的 slug 對 SEO 幫助很大
// 例如 What Is Functional Programming 轉為 what-is-functional-programming
const createSlug = compose(
  replaceSpacesWithDashes, // 把空格都用 - 替換掉
  removeSpecialCharacters, // 把特殊字移除掉,避免網址難呈現
  removeExtraSpaces, // 去除掉前後多餘的空格
  toLowerCase // 先把所有字都轉成小寫
);

// 產生文章描述的函式,讓搜尋引擎可以讀到並呈現文章的描述
const createDescription = compose(
  truncateToLength(150), // 截取一篇文章的前 150 個字作為描述
  removeExtraSpaces // 去除掉前後多餘的空格
);

可以看到,因為基本上不同的函式組合,都會需要用到 removeExtraSpacestoLowerCase 這種函式,這時候先把這類函式抽出來,然後在不同地方組起來,就能很輕易地重複利用。

函式組合的本質是什麼?

在看完上面的討論,接著讓我們來想一下,函式組合的本質是什麼?

從上面的例子可以看到,函式組合在做的事情,就是把不同的函式組成一個功能更完整的函式。把一個函式的輸出,變為下一個函式的輸入,讓資料透過這些相連的函式,逐步轉化成最終的結果。

因為把每一個部分都拆成一個範圍小的函式,所以可以獲得拆成函式的好處,包含上面提到的模組化、可複用、好測試。除此之外,所以可以很輕易地搭配出不同的組合,而不用當有新的組合時就要重寫。

閱讀更多

在了解完函式組合後,接著我們會進一步談如何實作函式組合,以及函式組合跟 reduce 這個高階函式有什麼關聯。這些點我們在 E+ 成長計畫的主題文都有更詳細談到,推薦感興趣的讀者閱讀。

本文為 E+ 成長計畫的深度內容,截取段落開放免費閱讀。歡迎加入 E+ 成長計畫閱讀完整版本 (點此了解 E+ 的詳細介紹)。

🧵 如果你想收到最即時的內容更新,可以在 FacebookInstagram 上追蹤我們