iT邦幫忙

2025 iThome 鐵人賽

DAY 10
0

https://ithelp.ithome.com.tw/upload/images/20250924/20168201QxZxv704Wo.png

前言

今天要介紹的是 Currying~

在日常的軟體開發中,我們可能都寫過這樣的函式:它可能接受四、五個,甚至更多的參數。而在許多不同的呼叫場景中,其中好幾個參數的值卻是固定且重複的。這不僅讓每一次的函式呼叫變得冗長繁瑣,也降低了程式碼的可讀性與可維護性。如果有一種方法,可以讓我們像組裝樂高積木一樣,預先將函式的一部分參數配置好,進而生成一個更簡潔、更專用的新函式,那該有多好?

這正是 Currying (柯里化) 想要解決的問題。Currying 是一個源自於函數式程式設計(Functional Programming)的技術。雖然它的名字來自於數學家 Haskell Curry,聽起來可能有些學術,但其核心思想卻出奇地簡單且極具實用價值。

為什麼需要 Currying?

假設我們正在開發一個前端應用程式,需要頻繁地與後端 API 進行互動。為了統一管理 API 請求,我們通常會建立一個通用的工具函式。

直覺的實作方式可能如下。這個函式接受所有必要的參數,例如 API 的 base URL、API 版本、端點(endpoint)、HTTP 方法以及可選的請求參數。

function apiRequest(baseUrl, apiVersion, endpoint, method, params) {
  const url = `${baseUrl}/${apiVersion}/${endpoint}`;
  const options = {
    method,
    //... 其他如 headers, body 的處理邏輯
  };

  console.log(`正在向 ${url} 發送一個 ${method} 請求,參數為:`, params);
  // return fetch(url, options); // 實際的 fetch 呼叫
}

這個函式本身沒有錯,但當它在整個應用程式中被大量使用時,問題便會浮現。請看以下使用範例:

// 獲取特定使用者資料
apiRequest('https://api.my-app.com', 'v2', 'users/123', 'GET', null);

// 更新使用者設定
apiRequest('https://api.my-app.com', 'v2', 'users/123/settings', 'PUT', { theme: 'dark' });

// 獲取產品列表第一頁
apiRequest('https://api.my-app.com', 'v2', 'products', 'GET', { page: 1 });

這些範例有何問題呢?它有幾個缺點:

  1. 冗長與重複:在每一次呼叫中,baseUrl ('https://api.my-app.com') 和 apiVersion ('v2') 都被重複傳遞。這些重複的字串不僅增加了程式碼的體積,也形成視覺上的噪音,讓真正變動的部分(如 endpointparams)被淹沒在樣板程式碼中,開發者很難一眼看出哪裡是變動的、該被注意的
  2. 可維護性低:如果有一天後端決定將 API 版本從 v2 升級到 v3。我們就需要在整個專案中進行「尋找與取代」,這是個容易出錯且風險很高的操作。一個小小的疏忽就可能導致應用程式的某些部分請求到錯誤的 API 版本。
  3. 缺乏抽象與表達力(分層設計不佳):每一次的 apiRequest 呼叫都是一個低階的、包含所有實作細節的指令。程式碼並沒有反映出業務邏輯的意圖。我們無法輕易地建立更高層次、更具描述性的函式,例如 getUserupdateSettings

如分層設計概念所述,在呼叫 apiRequest 時,參數包含了低層次的請求細節,也包含我們需要的業務邏輯,關注的層級不在同一層,並且 apiRequest 函式中的參數,其變動的頻率是不同的:

  • baseUrl: 在整個應用的生命週期中幾乎是靜態的
  • apiVersionmethod: 可能幾個月或幾年才會變更一次
  • endpointparams: 幾乎在每一次呼叫中都在變化

依照分層設計原則,越少變動的 baseUrl 應該在越低的層級,越常變動的 endpointparams 應該在越高的層級。

https://ithelp.ithome.com.tw/upload/images/20250924/20168201tioLL2fi5z.png
圖 1 每個參數的變更頻率不同(資料來源: 自行繪製)

這引出了一個問題:「我們能否建立一個『客製化』版本的 apiRequest 函式,讓它能夠『記住』那些通常不會改變的參數,例如 baseURLapiKey?」

我們希望達到的目標是,預先配置好函式的一部分,產生一個更專用的新函式,讓我們後續的呼叫可以更簡潔、更專注於那些真正變動的參數。這正是 Currying 要解決的問題。

Currying 的之前與之後

Currying 之前

如前一節所示,未經處理的 apiRequest 函式有一個「全有或全無」的狀況:我們必須在每次呼叫時,一次性提供所有五個參數。這種設計缺乏彈性,導致了程式碼的重複。

Currying 之後:建立一個可重用的 API 層

現在我們來引入 Currying,重構先前的 apiRequest 函式。

// 使用 Currying 重新設計的 API 請求函式
const curriedApiRequest = baseUrl => apiVersion => method => endpoint => params => {
  const url = `${baseUrl}/${apiVersion}/${endpoint}`;
  const options = {
    method,
    //...
  };

  console.log(`正在向 ${url} 發送一個 ${method} 請求,參數為:`, params);
  // return fetch(url, options);
};

curriedApiRequest 乍看有點複雜,有一堆箭頭和參數,但它其實是一系列只接受單一參數的箭頭函式鏈,這種結構讓它擁有更高的靈活性。

打造專屬的 API 工具

透過逐步傳入參數,我們可以像工廠流水線一樣,生產出適合某些特定情境的函式,解決前面提到的問題(分層設計不足、大家都擠在一起的問題)。

  1. 固定 baseUrlapiVersion
    我們可以建立一個專門服務於當前 API 版本的服務層。這可以一次性地固定 baseUrlapiVersion,避免重複傳入相同參數,並將設定集中管理。
const apiServiceV2 = curriedApiRequest('https://api.my-app.com')('v2');

apiServiceV2 現在是一個新的函式,它透過閉包「記得」了 baseUrlapiVersion,並且正在等待下一個參數:method

  1. 建立特定 HTTP 方法的處理器

基於 apiServiceV2,我們可以進一步建立常用的 HTTP 方法的專用函式,讓後續使用更方便。

const getFromApi = apiServiceV2('GET');
const putToApi = apiServiceV2('PUT');

getFromApiputToApi 現在是更專門的函式,如果要以分層設計來說,他們的層級又比 apiServiceV2 更高了,現在它們分別等待著 endpoint 參數。

  1. 定義高階的業務邏輯函式

最後我們可以用這些處理器來定義能直接反映業務行為的函式。

const getUser = userId => getFromApi(`users/${userId}`)(null);
const updateUserSettings = (userId, settings) => putToApi(`users/${userId}/settings`)(settings);
const getProducts = page => getFromApi('products')({ page });
  1. 最終成果
    現在來看看最初的那些 API 呼叫,在新架構下會是什麼樣子:
getUser('123');
updateUserSettings('123', { theme: 'dark' });
getProducts(1);

所以 Currying 是什麼?

Currying 定義:一次只處理一個參數

柯里化 (Currying) 是一個將接受多個參數的函式,轉換為一系列只接受單一參數的函式的過程。

換句話說,它將一個 f(a, b, c) 的函式呼叫,轉變為 f(a)(b)(c) 這樣的鏈式呼叫。每一次的函式呼叫都只處理一個參數,並回傳一個等待下一個參數的新函式,直到所有參數都提供完畢,最後一個函式才會回傳最終的計算結果。  

https://ithelp.ithome.com.tw/upload/images/20250924/20168201qXgOrGexU4.png
圖 2 Currying 後的函式轉變(資料來源: 自行繪製)

用最簡單也最常見的 add 函式來闡明這個轉換過程:

// 一般的 add 函式
const add = (a, b) => a + b;

// add 函式的柯里化版本
const curriedAdd = a => b => a + b;

// 使用柯里化版本
const result = curriedAdd(5)(3); // 8

這裡的關鍵在於 curriedAdd(5),它並沒有立刻進行計算,而是回傳了一個全新的函式:b => 5 + b。這個新函式透過閉包 (Closure) 的特性,「記住」了 a 的值是 5。直到我們呼叫這個新函式並傳入 3 時,相加的計算才真正發生。

部分應用 (Partial Application)

Currying 如此實用是因為它讓我們能輕易地實現部分應用 (Partial Application)。部分應用指的是固定函式中的一個或多個參數,進而產生一個更特化的新函式的行為。  

Currying 正是實現部分應用的天然途徑。每當我們只提供部分參數時,我們就在進行部分應用,創造出一個個等待其餘參數的新函式。

const addTen = curriedAdd(10); // 部分應用,固定了參數 a 為 10
const increment = curriedAdd(1); // 部分應用,固定了參數 a 為 1

console.log(addTen(3));    // 13
console.log(increment(99)); // 100

柯里化將函式變成了一座「可配置的計算工廠」。curriedAdd 本身就是這座工廠。當我們呼叫 curriedAdd(10) 時,我們並不是在要求工廠立刻產出結果,而是在下訂單,要求工廠生產一台全新的、功能更專一的「加 10 機器」——也就是 addTen 函式。這台新機器隨後可以被我們在各處重複使用。

關於 Currying 和部分應用的精確定義與關係,等等會再說明,先再來看幾個範例。

範例:建立可配置的篩選器

假設我們有一個使用者列表,需要根據不同條件進行篩選。

const users = [
  { id: 1, name: 'Alice', status: 'active' },
  { id: 2, name: 'Bob', status: 'inactive' },
  { id: 3, name: 'John', status: 'active' },
  { id: 4, name: 'Jane', status: 'pending' },
];

// 篩選器工廠:柯里化函式
// 接收 屬性(prop) -> 返回一個接收 值(value) 的函式 -> 再返回一個接收 物件(obj) 的函式
const filterBy = prop => value => obj => obj[prop] !== value;

// 從工廠生產專用篩選器(我們的「機器」)
const filterOutInactive = filterBy('status')('inactive');
const filterOutJohn = filterBy('name')('John');

// 將專用篩選器用於 Array.prototype.filter
const activeUsers = users.filter(filterOutInactive);
const nonJohnUsers = users.filter(filterOutJohn);

// 輸出
console.log("Active users:", activeUsers);
/*
Active users: [
  { id: 1, name: 'Alice', status: 'active' },
  { id: 3, name: 'John', status: 'active' },
  { id: 4, name: 'Jane', status: 'pending' }
]
*/

console.log("Non-John users:", nonJohnUsers);
/*
Non-John users: [
  { id: 1, name: 'Alice', status: 'active' },
  { id: 2, name: 'Bob', status: 'inactive' },
  { id: 4, name: 'Jane', status: 'pending' }
]
*/

透過一個通用的 filterBy 工廠,我們創造出了具有明確業務邏輯、可讀性高的 filterOutInactivefilterOutJohn 函式。

範例:處理資料轉換

假設我們需要從使用者列表中提取所有的名字或狀態。

const users = [
  { id: 1, name: 'Alice', status: 'active' },
  { id: 2, name: 'Bob', status: 'inactive' },
  { id: 3, name: 'John', status: 'active' },
  { id: 4, name: 'Jane', status: 'pending' },
];

// 屬性提取器工廠
const prop = key => obj => obj[key];

// 生產專用的「提取器」
const getName = prop('name'); // prop('name') 代表要取得未來傳入的某物件的 name 屬性的值
const getStatus = prop('status');

// 將提取器用於 Array.prototype.map
const userNames = users.map(getName); // ['Alice', 'Bob', 'John', 'Jane']
const userStatuses = users.map(getStatus); // ['active', 'inactive', 'active', 'pending']

users.map(getName) 和傳統寫法 users.map(user => user.name) 的對比。前者更簡潔,也更具宣告性。我們不是在告訴 map「如何」去取得名字(提供一個匿名函式),而是在告訴它「用什麼工具」去取得名字(提供一個預先配置好的 getName 函式)。

這個看似微小的改進,其背後隱藏一個觀念:柯里化函式的參數順序非常重要。在 propfilterBy 的例子中,我們都遵循了一個「資料最後 (data-last)」的原則。也就是說,最常變動的資料(例如 mapfilter 每次迭代傳入的物件 obj)被放在參數列表的最後一位。而那些用於配置的、相對固定的參數(如 keypropvalue)則放在前面。

仔細看一下 filterBy 的參數順序安排,可以看到最常變動的參數它放在最右邊:
https://ithelp.ithome.com.tw/upload/images/20250924/20168201NsVC5eDgcW.png
圖 3 filterBy 參數採用資料最後 (data-last)的原則(資料來源: 自行繪製)

再以 getName 舉例,最常變動的資料 user 會在最後傳入,以下簡單示意:

getName = prop('name')

users ---- map(getName) ----> ['Alice', 'Bob', 'John']

             │
             └─── (obj) 最後才傳入

這種「資料最後」的設計,使得透過部分應用創建的函式(如 getName)能夠完美地契合像 mapfilter 這類高階函式的需求,因為這些高階函式期望接收一個只接受單一參數(當前迭代的元素)的函式。

手寫 Currying 函式!

curry 函式是如何形成的?手動將每個函式都寫成 a => b => c =>... 的形式很麻煩也不夠實際、無法重複使用,在實務中,我們通常會使用一個通用的 curry 輔助函式,它可以將任何一個普通的多參數函式,自動轉換為具有柯里化特性的函式。

以下是一個 curry 函式實作,這是一個嚴格一次只收一個參數的函式:

// curry 接受函式 fn 作為參數,此 fn 函式接收多個參數並回傳 c
function curry(fn) {
  // 獲取原始函式預期的參數數量
  const arity = fn.length;

  return (function nextCurried(prevArgs) {
    return function curried(nextArg) {
      const args = [...prevArgs, nextArg];

      if (args.length >= arity) {
        // 如果參數收齊了,就執行原始函式
        return fn(...args);
      } else {
        // 否則,繼續回傳一個等待下一個參數的函式
        return nextCurried(args);
      }
    };
  })([]); // 初始傳入一個空陣列來存放參數
}

一步步拆解這個函式的運作機制如下:

  1. const arity = fn.length;:透過 fn.length 屬性得知原始函式 fn 總共需要多少個參數,JavaScript Function 有個 length 屬性可得知此函式所需的參數數量
  2. return (function nextCurried(prevArgs) {... })():這裡使用了一個立即執行函式 (IIFE) 來啟動整個過程。prevArgs 用於儲存每次傳入的參數,初始值為一個空陣列,代表沒有傳入任何參數。
  3. return function curried(nextArg):這是 curry 函式真正返回的、供外部呼叫的函式,他是一個擁有部分應用參數的函式,並且正在等待接收一個新的參數 nextArg
  4. const args = [...prevArgs, nextArg];:它將之前已經收集到的參數 prevArgs 和這次新傳入的 nextArg 合併成一個新的陣列 args
  5. if (args.length >= arity):檢查目前蒐集到的參數數量是否已經滿足原始函式的要求。
  6. return fn(...args);:如果參數已全部到齊,就用 ... 展開運算子將所有參數傳給原始函式 fn 並執行,回傳最終結果。
  7. return nextCurried(args);:如果參數還不夠,則遞迴式地呼叫 nextCurried,並將當前已收集的參數 args 傳給它。這會回傳一個新的 curried 函式,這個新函式透過閉包鎖定了當前的 args,並繼續等待下一個參數的到來。

以上這個 curry 函式十分嚴謹,一次只能接收一個參數,因此有個比較寬鬆的 curry 版本,稱為 loose curry

Loose curry

傳統嚴謹的 curry 函式一次只能傳一個參數,像是 add(1)(1) 這樣,但如果我想通用一點,不論是 addC(1)(1) 或是 addC(1, 1) 都可以執行呢?那就是 loose curry。
許多函式庫(如 Ramda、lodash/fp)提供了更具彈性的 looseCurry 版本,它允許在任何一步傳入多個參數。

// looseCurry 的核心差異在於對 nextArgs 的處理
function looseCurry(fn, arity = fn.length) {
    return (function nextCurried(prevArgs){
        // 注意這裡的...nextArgs,它允許一次傳入多個參數
        return function curried(...nextArgs){
            const args = [...prevArgs,...nextArgs];
            if(args.length >= arity){
                return fn(...args);
            }
            return nextCurried(args)
        }
    })()
}

這代表如果我們有一個 const add = (x, y, z) => x + y + z;,經過 looseCurry 處理後,add(1)(2, 3)add(1, 2)(3)add(1)(2)(3) 這些呼叫方式都是有效的,提供了更大的便利性。

另外以下是 《mostly-adequate-guide》這本書所寫的 currying 實作,也屬於比較寬鬆的 looseCurry 版本。

// curry 接受函式 fn 作為參數,此 fn 函式接收多個參數並回傳 c
function curry(fn) {
  const arity = fn.length; // 取得原始函式 fn 需要的參數數量

  return function $curry(...args) { // 回傳 $curry 新函式,args 會拿到傳給 $curry 的所有參數
    if (args.length < arity) { // 如果傳給 $curry 的參數數量少於原函式需要的參數數量,就回傳部分應用的函式
      return $curry.bind(null, ...args); // bind 會建立一個新函式,其中 ...args 已經被綁定為該新函式的前幾個參數。null 參數是設置函式執行時的 this 值,這裡可先暫時忽略
    }

    return fn.call(null, ...args); // 如果已經蒐集到足夠的參數,就用這些參數呼叫原始函數 fn 並回傳結果
    // .call(null, ...args) 執行原函式,null 是 this 綁定,...args 展開所有蒐集到的參數
  };
}

Currying 與 部分應用 (Partial Application)

這裡稍微補充 Currying 和部分應用的關係與差異,以下為兩者比較精確的定義:

  • 柯里化 (Currying):是一個非常具體的轉換過程,它總是將一個多參數函式轉換為一個一元函式 (unary function) 的鏈。也就是說,鏈中的每一個函式都嚴格只接受一個參數。  
  • 部分應用 (Partial Application):是一個更廣泛的概念,指的是為一個函式預先填入任意數量(一個或多個)的參數,並返回一個等待剩餘參數的新函式。這個新函式的參數數量可以大於一

以下表格比較兩者差異。

特性 (Feature) 柯里化 (Currying) 部分應用 (Partial Application)
目標 (Goal) 將多參數函式轉換為一元函式鏈 固定函式的一個或多個參數
回傳函式的參數數量 永遠是 1 (直到最後返回結果) 任意數量 (等於原始函式剩下的參數)
範例 (Example) f(a, b, c) 變成 f(a)(b)(c) f(a, b, c, d) 透過 bind 或其他方式變成 g(c, d)

總結來說,柯里化是將函式「完全分解」成一系列單一步驟;而部分應用則是「部分執行」一個函式。在 JavaScript 的實踐中,我們使用的 curry 輔助函式通常是「寬鬆」的,它讓我們在享受柯里化鏈式呼叫的優雅語法的同時,也獲得了部分應用的靈活性。

Currying 與純函式

Currying 本身是一種函式轉換技術,它無法保證函式的純度。然而,當柯里化與純函式 (Pure Functions) 結合時,它的威力會被放大到極致,兩者可說是天作之合。

純函式具有兩個核心特徵 :  

  • 相同的輸入永遠產生相同的輸出。
  • 沒有任何可觀察的副作用 (Side Effects)。

Currying 和純函式的結合之所以強大,原因在於:

  • 可預測性的傳遞:如果原始函式(例如 add(a, b))是純函式,那透過柯里化衍生出的任何部分應用的函式(例如 const addTen = curriedAdd(10))也必然是純函式。addTen(3) 的結果將永遠是 13,不會受到任何外部狀態的影響,也不會影響任何外部狀態。  
  • 引用透明性 (Referential Transparency):由於衍生出的函式也是純的,我們可以安心地在程式碼的任何地方用 13 來替換 addTen(3),而不會改變程式的整體行為。這使得程式碼的推理、重構和除錯變得極其簡單。
  • 極佳的可測試性:測試 addTen 這樣的函式變得非常容易,因為我們只需要關心輸入和輸出的對應關係,完全不必模擬或擔心任何外部環境。  

因此,我們希望 Currying 的原始函式最好是純函式,才能讓後續衍生出的函式都是純函式,建立更可靠、更好維護的軟體基礎。

小結

以下是透過三個問題來總結 Currying(柯里化)。

為什麼我們需要柯里化?

為了解決程式碼中重複的問題。當我們需要反覆呼叫一個函式,且其中某些參數總是相同時(例如 API 的 baseURLapiKey),傳統寫法會變得非常繁瑣且難以維護。柯里化讓我們能「記住」這些固定參數,產生更專用的新函式,避免重複。

柯里化前後的區別是什麼?

  • 之前:我們使用一個「全有或全無」的函式,每次呼叫都必須提供所有參數,例如 fetchData('https://...', 'key', 'users', 101)。程式碼冗長,意圖不明確。
  • 之後:我們將一個通用的函式,逐步配置成專用工具。最終的呼叫變得極其簡潔且富有表達力,例如 getUser(101)。不僅提升了可讀性,更將配置與執行分離,提高可維護性。

柯里化到底是什麼?

它是一種函式轉換技術,將一個接受多個參數的函式 f(a, b, c),轉變為一系列只接受單一參數的函式鏈 f(a)(b)(c) 。其核心價值在於實現部分應用 (Partial Application),讓我們能預先固定部分參數,將一個泛用函式變成一座「可配置的計算工廠」,隨時生產出我們需要的、更特化的新函式 。  

Reference


上一篇
[Day 09] First-Class Functions 和 Higher-Order Functions (2):map 與 reduce
下一篇
[Day 11] 函數組合(Function Composition)
系列文
30 天的 Functional Programming 之旅12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言