理解函數式編程中的函數組合--Monoids(二)

使用函數式語言來創建領域模型--類型組合
理解函數式編程語言中的組合--前言(一)html

理解函數式編程中的函數組合--Monoids(二)

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

在正式講解這些概念以前,我想提一下「行話」這一現象,其實不光是函數式編程領域,OO設計裏也有很多「行話」或者說「術語「,例如,」依賴注入「, 」多態「, 」橋接模式「,這些詞你們聽着都不陌生,源於你們對OO的長期實踐。可是若是摒棄偏見,理解並靈活應用這些概念並非一蹴而就的。有時候你以爲簡單,只是由於更熟悉而已。github

這篇文章爲你們介紹《範疇論》裏的一個基礎概念-Monoids(注意,不是Monad)。另外本文的例子都經過TypeScript來描述,另外本文的術語都會保持英文名稱,由於這些術語翻譯爲漢語價值不大,另外保持英文名稱也方便你們搜索相關介紹。編程

Monoids

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

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

可聚合的事物

1 + 2 = 3

這行數學運算能夠描述爲:兩個「數字」經過「相加」運算,獲得了一個結果,其結果任然爲「數字」。dom

"a" + "b" = "ab"

上面這行運算能夠描述爲:兩個"字符「經過」拼接「操做,獲得了一個結果,其結果任然爲」字符串「。
若是咱們將上面的這兩個運算進一步泛化,就會獲得類下面的模式(pattern):編程語言

  • 有兩個事物
  • 兩個事物可以經過一種組合方式將他們組合起來
  • 獲得的事物跟以前的類型是一致的

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

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來展現一些實例。

相關文章
相關標籤/搜索