iT邦幫忙

2025 iThome 鐵人賽

DAY 8
0
Software Development

數學老師學函數式程式設計 - 以fp-ts啟航系列 第 8

Day 08. 今天來一些咖哩 - Currying

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20250831/20118113BLkCodRJyL.png

Curry(柯里)

單引數函數

假設我們有一個函數需要3個參數,通常我們會以這種形式寫我們的函數:

function sum(a, b, c) {
    return a + b + c;
};

我們也可以將函數寫成這種形式(我們很少用這種格式寫函數,這邊是刻意的,強調每層都回傳一個函數,直到最後才回傳數值):

function currySum(a: number) {
    return function(b: number) {
        return function(c: number) {
            return a + b + c;
        };
    };
}
const result = currySum(1)(2)(3); // result = 6

程式碼中有兩個函數,一個是sum,它接受三個參數並返回它們的和;另一個是currySum,它接受一個參數a,並返回一個新的函式,這個新的函式接受另一個參數b,,也返回一個新函數,接受一個參數c,並返回值a + b + c
當我們需要計算1 + 2 + 3時,我們可以使用currySum(1)(2)(3)來獲得結果6。
這種函數的寫法稱為Curry,由Haskell Curry命名,將我們所要建立的函數,每次只給予一個參數,並回傳一個新的函數,而當所有參數都提供了,才會計算出我們的函數值。
上面程式碼中的的寫法有些繁瑣,我們可以使用箭頭函數來簡化它,這樣的寫法更簡潔且易於理解參數傳遞的順序:

const sum3 = (a: number, b: number, c: number): number => a + b + c;
const currySum3 =
  (a: number) =>
  (b: number) =>
  (c: number): number =>
    a + b + c;
const result = currySum3(1)(2)(3);
console.log(result); // result = 6

Curry的實際應用

Currying在實際開發中有很多應用場景,例如:
我們想要計算銀行貸款的利息,假設我們有一個函數calculateInterest,它接受二個參數:本金(principal)、利率(rate);同時,我們有三家銀行A、B、C,它們提供的利率分別為1.2%、1.3%、1.4%,我們可以使用Currying來簡化這個函數的使用:

const calculateInterest = (rate: number) => (principal: number) =>
  principal * rate;
const calculateBankAInterest = calculateInterest(0.012);
const calculateBankBInterest = calculateInterest(0.013);
const calculateBankCInterest = calculateInterest(0.014);
const bankAInterest = calculateBankAInterest(10000); // 120
const bankBInterest = calculateBankBInterest(10000); // 130
const bankCInterest = calculateBankCInterest(10000); // 140

Curry函數的好處

  1. 延遲執行(Lazy Evaluation)Currying 允許「部分應用」(Partial Application),即先傳入部分參數生成一個新函數,剩餘參數可以稍後提供,這在需要動態生成函數或延遲計算時非常有用。
  2. 提高函數的組合性,在後面的章節中我們會介紹函數組合(composition)的概念,Currying可以讓我們更容易地組合函數。

在一些函數式程式設計語言中,Currying是語言的核心特性之一,例如Haskell和Scala等語言都支持Currying,也就是所有函數每次都只接受一個參數,即便你定義這個函數為多參數函數。Javascript雖然不是純函數式程式設計語言,但也可以使用Currying(柯里化)來實現類似的功能。

實作Curry函數

在函數式程式設計時,為了組合程式方便,我們都會以單引數的Curry風格設計函數;但是有時候我們會引用其它函數庫,這些函式庫未必會以Curry的形式設計函數,因此我們需要實作一個Curry函數,它可以將我們原本的函數轉換為一個可以接受任意數量參數的新函數,並返回一個函數,而這個函數可以再接受剩餘任意數量的參數,直到所有參數都已經傳遞才回傳最後的函數值。下面是一個簡單的javascript實作:

const curry = (fn) => {
  const curriedFn = (...args) =>
    args.length >= fn.length
      ? fn(...args) // 回傳最後答案
      : (x) => curriedFn(...args, x); // 再回團一個單參數函數,遞迴呼叫。
  return curriedFn;
}
const add = (a: number, b: number, c: number) => a + b + c;
// 使用curry來柯里化add函數,柯里化後的函數可以接受任意數量的參數
const curriedAdd = curry(add);
console.log(curriedAdd(1)(2)(3)); //6

程式說明:

  1. curry的輸入是一個函數fn,其輸出將fn柯里化的函數。
  2. 我們要將函數fn轉換為curriedFn再回傳,如果curriedFn的參數數量大於fn的參數數量,表示目前的參數數量已經足夠,將所有參數丟給fn計算並回傳答案(遞迴函數的基礎情況);否則便傳回一個單參數函數,這個函數輸出是遞迴呼叫curriedFn,但是curriedFn的參數量比上一層curriedFn多一個(curriedFn的遞迴性質)。
  3. 經過柯里化(curried)的函數每次可以接受一個參數。
  4. curry函數的型別設計比較技巧,我們留在後面討論。

Hindley-Milner型別簽名

Hindley-Milner(HM)型別系統是一種經典的型別推論系統,由 Roger Hindley 和 Robin Milner 分別獨立發現,後來由 Damas 完善。它廣泛應用於函數式程式設計語言(如 Haskell、ML、OCaml)中,用於靜態型別檢查和自動型別推論。

雖然 TypeScript 不是純 HM 系統,但是 HM 系統和Curry風格的typescript型別簽名十分類似,例如我們的柯里化的calculateInterest函數,我們定義它的型別簽名(Type Signature),並且將calculateInterest重寫成下列形式:

type CalculateInterest = (rate: number) => (principal: number) => number;
const calculateInterest: CalculateInterest = rate => principal => principal * rate

而Hindley-Milner的型別簽名則是如下型式:

calculateInterest :: Number -> Number -> Number

除了沒有參數名稱以及將"=>"符號轉換為"->"符號,兩者的型別簽名大同小異,在本文之後的系列文章,如果沒有替函數特別定義typescript型別簽名,我們便會以註解HM的方式寫下它的型別簽名如下:

// calculateInterest :: Number -> Number -> Number
const calculateInterest = (rate: number) => (principal: number) =>
  principal * rate;

而對於一些使用泛型定義的typescript型別簽名,則使用下列的規則轉換

  • "::"符號之後表示函數的型別簽名
  • a, b, c 表示參數型別(泛型)
  • -> 表示函數輸入輸出方向
  • [a] 表示 a 型別的陣列

以下是一些typescript常用函數的HM註解實例。

// identity :: a -> a
const identity = <A>(x: A): A => x;

// constant :: a -> b -> a
const constant = <A, B>(a: A) => (b: B): A => a;

// map :: (a -> b) -> [a] -> [b]
const map = <A, B>(f: (a: A) => B) => (as: A[]): B[] => as.map(f);

// filter :: (a -> Bool) -> [a] -> [a]
const filter = <A>(predicate: (a: A) => boolean) => (as: A[]): A[] => as.filter(predicate);

// reduce :: (b -> a -> b) -> b -> [a] -> b
const reduce = <A, B>(f: (acc: B, a: A) => B, initial: B) => (as: A[]): B => as.reduce(f, initial);

Type for curry

最後,我們來討論我們的curry函數的型名簽名。curry函數的輸入是任意函數,其型別如下:

type FN = (...args: any[]) => any;

我們先設計curry函數回傳型別是單參數函數,這個函數的回傳型別視參數剩餘陣列的數量而定,Curry必須遞迴設計.如果只剩一個參數,則輸出型別是原函數的回傳型別;若超過二個(含二個),則需遞迴呼叫Curry,它有兩個型別參數P和R,P是剩餘參數陣列型別,R則是代表被轉換的原函數輸出型別。

type Curry<P, R> = P extends [infer H]
  ? (arg: H) => R // 只有一個參數
  : P extends [infer H, ...infer T] // 2個或2個以上參數
  ? (arg: H) => Curry<[...T], R>
  : never; // 沒有參數

如果我們將原來的curry函數直接以下列方式註解型別,typescript會報錯。

const curry = <P extends any[], R>(fn: FN): Curry<P, R> => {
  const curriedFn = (...args: any[]) =>
    args.length >= fn.length
      ? fn(...args) // 回傳最後答案
      : (x: any) => curriedFn(...args, x);
  return curriedFn;
}

報錯的原因是Curry<P, R>的輸出是函數型別,而函數的參數數量是不定的,如果要避免報錯,可以採用typescript函數多載的方式宣告型別簽名和實作。

function curry<P extends any[], R>(fn: (...args: P) => R): Curry<P, R>;
function curry(fn: FN) {
  const curriedFn = (...args: any[]) =>
    args.length >= fn.length
      ? fn(...args) // 回傳最後答案
      : (x: any) => curriedFn(...args, x);
  return curriedFn;
}

而curry函數的HM型別簽名則如下:
curry :: ((a, b, c, ...) -> r) -> (a -> b -> c ...-> r)

今日小結

今天介紹了在函數式程式設計很重要的技巧柯里化(currying),它讓我們的函數可以一個一個接收參數,讓我們函數更靈活更容易重新使用而達到部分使用的目的,此外柯里化也可以達到延遲計算的效果,因為要等到所有參數都齊全才會計算函數值,其間都只是回傳另一個函數。更重要的是,curry化可以讓函數式程式設計的核心-函數的複合進行的更容易,這也是我們明天討論的主題。今日的分享就到這邊告一段落,明天再見。


上一篇
Day 07. 宣告式程式風格 - Javascript Array
系列文
數學老師學函數式程式設計 - 以fp-ts啟航8
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言