理解函數語言程式設計中的函式組合--Monoids(二)

richiezhang發表於2021-03-08

使用函式式語言來建立領域模型--型別組合
理解函數語言程式設計語言中的組合--前言(一)

理解函數語言程式設計中的函式組合--Monoids(二)

繼上篇文章引出《範疇論》之後,我準備通過幾篇文章,來介紹函數語言程式設計語言中的若干"行話",例如Functor, Applicative, Monad。如果給這些名字一個通俗的名稱,我覺得Combinator(組合子)比較形象一些,組合子可以將函式組合起來。我在一篇文章中還看到過一個另一個通俗的說法--“膠水函式”,簡而言之就是如果兩個函式與不能夠直接組合,那麼就可以通過一種像膠水一樣的函式,把兩個函式粘接起來,從而達到組合函式的目的。

在正式講解這些概念之前,我想提一下“行話”這一現象,其實不光是函數語言程式設計領域,OO設計裡也有不少“行話”或者說“術語“,例如,”依賴注入“, ”多型“, ”橋接模式“,這些詞大家聽著都不陌生,源於大家對OO的長期實踐。但是如果摒棄偏見,理解並靈活應用這些概念並不是一蹴而就的。有時候你覺得簡單,只是因為更熟悉而已。

這篇文章為大家介紹《範疇論》裡的一個基礎概念-Monoids(注意,不是Monad)。另外本文的例子都通過TypeScript來描述,另外本文的術語都會保持英文名稱,因為這些術語翻譯為漢語價值不大,另外保持英文名稱也方便大家搜尋相關介紹。

Monoids

首先Monoids一詞來源於數學家,翻譯成中文沒有任何意義,你不會從中文翻譯裡面得到任何關於Monoids含義的線索,如果非要給他一箇中文翻譯,我會翻譯為”可聚合的事物"。當你理解了Monoids, 你就會發現在生活中,處處存在著Monoids。 只不過數學家善於歸納總結,給與了這一類事物一個確切的定義和相應的定律。

讓我們還原一下數學家發現這類事物的場景:

可聚合的事物

1 + 2 = 3

這行數學運算可以描述為:兩個“數字”通過“相加”運算,得到了一個結果,其結果任然為“數字”。

"a" + "b" = "ab"

上面這行運算可以描述為:兩個"字元“通過”拼接“操作,得到了一個結果,其結果任然為”字串“。
如果我們將上面的這兩個運算進一步泛化,就會得到類下面的模式(pattern):

  • 有兩個事物
  • 兩個事物能夠通過一種組合方式將他們組合起來
  • 得到的事物跟之前的型別是一致的

這個規律能夠運用在非數字或者字串之外的其他事物上面嗎?假如這種事物可以通過某種方式組合到一起,是不是就能夠符合這一規律呢?
錢算不算?

type Money = {
  amount: number
};

const a: Money = { amount: 10.2 }
const b: Money = { amount: 2.8 }
const c: Money = { amount: a.amount + b.amount }

你如果熟悉DDD中提到的ValueObject,你可以將這模式應用在很多事物(ValueObject)上。
為什麼這個模式要強調組合之後的事物跟之前的型別是一致的(closure)?
因為你可以把這個模式推廣到list上
換句話說,如果一個二元運算如果返回的型別跟之前一致,就可以把這個操作符應用到一個list上,這個函式叫做reduce。

[0, 2, 3, 4].reduce((acc, val) => acc + val);
["a", "b", "c", "d"].reduce((acc, val) => acc + val);

MapReduce大家應該都不陌生,為什麼叫Map? 因為需要將資料轉化為Monoids, 為什麼要Reduce? 因為需要聚合資料。

結合律

實際上,只符合上面的模式,還不能稱之為為Monoids, 確切的說叫做Magma。我們小學數學都學習過結合律(Associative),注意不是交換律(commutative),例如:

(1 + 2) + 3 = 1 + (2 + 3) = 6

結合律說左右組合順序不重要,得到的結果都是一樣的,這一定律實際上對事物組合的運算子做出了限制,例如,針對數字運算,乘法符合結合律嗎?

(1 * 2) * 3 = 1 * (2 * 3) = 6

答案是符合,那麼除法和減法呢?

(1 - 2) - 3 != 1 - (2 - 3)
(1 % 2) % 3 != 1 % (2 % 3)

除法和減法不符合結合律,為什麼結合律這麼重要?
因為當順序不是問題的時候,平行計算和累加就顯得輕而易舉。因為執行順序不是問題,你就可以把計算量分配到若干個機器上,然後累加結果。或者說今天計算了任務的30%,等明天啟動任務的時候接著計算,而不需要重新計算整個資料集。

Identity元素

目前為止,得到的事物叫Semigroups,只差最後一個條件便可稱之為Monoids。看下面的運算:

1 + 0 = 1
"a" + "" = "a"

有什麼規律呢?針對數字和”加法“運算,任何數字加0,得到的結果跟之前一樣。針對字串和”加法“運算,任何字串和”空字串“拼接起來,得到的結果也跟之前一樣。
對於數字和”乘法“運算來說,0元素是1:

10 * 1 = 10

對於list而言,0元素是空list:

const a = [1, 2, 3]
const b: number[] = []
const c = [...a, ...b]

expect(c).toEqual(a);

數學家把這個類似於0一樣的元素稱之為identity元素,為什麼需要identity元素呢?
試想一下如何對一個空陣列做reduce?

const a: number[] = [];
const result = a.reduce((acc, val) => acc + val);

這行程式碼會報錯,reduce函式會抱怨你沒有提供一個初始值,而這個不影響計算結果的初始值,實際就是identity元素。

const result = a.reduce((acc, val) => acc + val, 0);

大部分語言把提供初始值的函式稱之為fold函式。不過fold的基礎並不是Monoids, 而是Catamorphisms,在此不再細說。

Monoids定律

數學家將上面的三個規律定義為三個定律(laws):

  • 定律1 (Closure): 兩個事物合併後總能得到相同型別的事物。
  • 定律2 (Associativity): 當組合一組事物時,組合的順序不會影響結果(不是交換律的那種順序)。
  • 定律3 (Identity element): 有一個0元素,任何事物跟0元素合併之後的結果不變。
    用數學家的話說,凡是符合上面三個定律的事物被稱之為Monoids。符合定律1的叫做Magma, 同時符合定律1和定律2的稱之為Semigroups。

用一句話概括,Monoids是一個能夠滿足結合律,擁有Identity元素的二元運算。如果用程式碼來定義,大概如下:

interface Monoid<A> {
  readonly concat: (x: A, y: A) => A
  readonly empty: A
}

結合律則要滿足下面的等式:

concat(x, concat(y, z)) = concat(concat(x, y), z)

上面用來描述Monoids的方式,在函數語言程式設計語言裡叫做type classes。嚴格來說,TypeScript原生並不支援type classes,也不支援Higher Kinded Types(HKT), 上面的例子只是我們用interface來模擬了一個type classes定義。
對於原生支援type classes的語言,例如Haskell, Monoid被定義為:

class Monoid m where  
    mempty :: m  
    mappend :: m -> m -> m  
    mconcat :: [m] -> m  
    mconcat = foldr mappend mempty

讓我們對這個定義做個簡單分析,首先,m這種型別可以作為Monoid例項,只要符合:

  • mempty代表Identty 元素
  • mappend代表一個函式,接受兩個相同型別的引數,然後返回一個型別也一樣的值,可以理解為二元操作符
  • mconcat是一個函式,接受一組monoid值,然後聚合為一個值。它擁有一個預設實現,使用mappend操作符和mempty作為預設值,來fold一個列表

可以看出Haskell裡面的的type class基本跟我們在TypeScript裡用interfaced定義出來的type class差不多,實際上是不是原生支援Type classes,並不影響TypeScript可以作為一門函數語言程式設計語言,類似的語言還有F#等。

函式也可以是Monoids

大家要明白《範疇論》的抽象程度很高,Monoid並不單單指我們在文章中提到的字串,數字之類,它可以是宇宙中的任何符合Monoids law的事物,這個事物也可以是函式。在TypeScript裡,定義一個具有一個引數和返回型別的函式如下:

type func = <T1, T2>(x: T1) => T2

這個函式的簽名如下:

T1 -> T2

在一個函式a->b中,如果b是monoid,那麼這個函式也是一個monoid。也就是說函式簽名相同的兩個函式是可以組合的。相關過程我不再證明,在Haskell裡,這樣的一條規則可以被描述為:

instance Monoid b => Monoid (a -> b)

特別的,當函式是一個monoid並且其輸入型別和輸出型別一致時,被稱為Endomorphism monoid。

type func = <T>(x: T) => T

Monoid實戰

如果說“數字”再加上"加法"操作符就是Monoid, 那麼通過reduce就可以輕而易舉的將一堆數字累加起來。讓我們看一個稍微複雜的例子,例如在購物車裡,每個商品都可以用下面的型別來表示:

type OrderLine = {
  id: number,
  name: string,
  quality: number,
  total: number
}

用命令式的思想來彙總總價,通常就是一個for迴圈,然後累加結果。不過,你應該想到,Monoid就是用來解決資料的累加問題,我們可以通過reduce解決問題,你可能會想到這樣做:

const total = orderLines.reduce((acc, val) => acc.total + val.total)

這行程式碼會報錯,編譯器會抱怨你在reduce函式裡傳入的高階函式簽名不符合要求,因為你傳入的函式簽名如下:

OrderLine -> OrderLine -> number

這個函式不符合Monoid定律,即返回型別不是一個OrderLine型別。Reduce期望你傳入的函式型別簽名為:

OrderLine -> OrderLine -> OrderLine

我們只需要將這個高階函式的返回型別也定義為OrderLine即可,即:

const addTwoOrderLines = (line1: OrderLine, line2: OrderLine): OrderLine => (
  {
    name: "total",
    quality: line1.quality + line2.quality,
    total: line1.total + line2.total
  }
)
const total = orderLines.reduce(addTwoOrderLines)

結束語

本文通過描述Monoid帶大家進入函數語言程式設計和《範疇論》的世界,為了進一步用程式碼實現這些例子,我在接下來的文章中還會引入fp-ts,從而通過TypeScript來展示一些例項。

相關文章