完整解釋 Monad -- 程式設計師範疇論入門

serialcoder發表於2019-01-01

如果你接觸過函數語言程式設計,你很可能遇到過 Monad 這個奇怪的名詞。由於各種神奇的原因,Monad 成了一個很難懂的概念。Douglas Crockford 曾轉述過這樣一句話來形容 Monad:

Once you understand Monad, you lose the ability to explain it to someone else.

這篇文章中,我會從使用場景出發來一步步推演出 Monad。然後,我會進一步展示一些 Monad 的使用場景,並解釋一些我從 Haskell 翻譯成 JS 的 ADT (Algebraic Data Type)。最後,我會介紹 Monad 在範疇論中的意義,並簡單介紹下範疇論。

函式組合

1. Monoid

假設你被一個奇怪的叢林部落抓住了,部落長老知道你是程式設計師,要你寫個應用,寫出來就放你走。作為一個資深碼農,你暗自竊喜,心裡想著老夫經歷了這麼多年產品經理各種變態需求的千錘百煉,沒什麼需求能難倒我!長老似乎看出了你的心思,加了一個要求:這個應用只能用純函式寫,不能有狀態機,不能有副作用!然後你崩潰了……

再假設你不知道函數語言程式設計,但你足夠聰明,你可能會發明出一個函式來滿足這個奇葩的要求。這個函式如此強大,你可能會叫它超級函式,但其實它無可避免就是一個 Monad。

接下來我們就來一步步推演出這個超級函式吧。

函式組合大家都應該非常熟悉。比如,Redux 裡面在組合中介軟體的時候會用到一個 compose 函式 compose(middleware1, middleware2)。函式組合的意思就是,在若干個函式中,依順序把前一個函式執行的結果傳個下一個函式,逐次執行完。compose 函式的簡單實現如下:

const compose = (...fns) => fns.reduce((f, g) => (...args) => f(g(...args)))
複製程式碼

函式組合是個很強大的思想。我們可以利用它把複雜問題拆解成簡單問題,把這些簡單問題逐個解決了之後,再把這些解決方案組合起來,就形成了最終的解決方案。

這裡偷個懶再舉一下我之前文章的例子吧:

// 在產品列表中找到相應產品,提取出價格,再把價格格式化
const formalizeData = compose(formatCurrency, pluckPrice, findProduct);

formalizeData(products)
複製程式碼

如果你理解了上面的程式碼,那麼恭喜你,你已經懂了 Monoid!

所謂 Monoid 可以簡單定義如下:

  • 它是一個集合 S
  • S 的元素之間有一個二元運算 x,運算的結果也屬於 S:S a x S b --> S c
  • 存在一個特殊元素 e,使得 S 中的任意元素與 e 運算,都返回此元素本身:S e x S m --> S m

同時,這個二元運算要滿足這些條件:

  • 結合律:(a x b) x c = a x (b x c), a,b,c 為 S 中元素
  • 單元律:e x a = a x e = a,e 為特殊元素,a 為 S 中任意元素

注意,上面這個定義是集合論中的定義,這裡還沒涉及到範疇論。

函式要能組合,型別簽名必須一致。如果前一個函式返回一個數字,後一個函式接受的是字串,那麼是沒辦法組合的。所以,compose 函式接受的函式都符合如下函式簽名:fn :: a -> a 也就是說函式接受的引數和返回的值型別一樣。滿足這些型別簽名的函式就組成了 Monoid,而這個 Monoid 中的特殊元素就是 identity 函式:const identity = x => x; 結合律和單元律的證明比較簡單,我就不演示了。

2. Functor

上面演示的函式組合看起來很舒服,但是實際用處還不是很大。因為 compose 接受的函式都是純函式,只適合用來計算。而現實世界沒有那麼純潔,我們要處理 IO,邏輯分支,異常捕獲,狀態管理等等。單靠簡單的純函式組合是不行的。

先假設我們有兩個純函式:

const addOne = x => x + 1
const multiplyByTwo = x => 2 * x
複製程式碼

理想狀態下是我們可以組合這兩個函式:

compose(
  addOne,
  multiplyByTwo
)(2) // => 5
複製程式碼

但是我們出於各種原因要執行一些副作用。這裡僅為了演示,就簡單化了。假設上面兩個函式在返回值之前還向控制檯列印了內容:

const impureAddOne = x => {
  console.log('add one!')
  return x + 1
}

const impureMultiplyByTwo = x => {
  console.log('multiply by two!')
  return 2 * x
}
複製程式碼

現在這兩個函式不再純潔了,我們看不順眼了。怎樣讓他們恢復純潔?很簡單,作弊偷個懶:

const lazyImpureAddOne = x => () => {
  console.log('add one!')
  return x + 1
}

// Java 程式碼看多了之後我也學會取長變數名了^_^
const lazyImpureMultiplyByTwo = x => () => {
  console.log('multiply by two!')
  return 2 * x
}
複製程式碼

修改之後的函式,提供同樣的引數,每次執行他們都返回同樣的函式,可以做到引用透明。這就叫純潔啊!

然後我們可以這樣組合這兩個偷懶函式:

composeImpure = (f, g) => x => () => f(g(x)())()

const computation = composeImpure(lazyImpureAddOne, lazyImpureMultiplyByTwo)(8)

computation() // => multiply by two!add one! 17
複製程式碼

在執行 computation 之前,我們都在寫純函式。

我知道,我知道,上面的寫法可讀性很差。這樣子寫也不可維護。我們來寫個工具函式方便我們組合這些不純潔的函式:

const Effect = f => ({
  map: g => Effect(x => g(f(x))),
  runWith: x => f(x),
})

Effect.of = value => Effect(() => value)
複製程式碼

這個 Effect 函式接受一個非純函式 f 為引數,返回一個物件。這個物件裡面的 map 方法把自身接受的非純回撥函式 g 和 Effect 的非純回撥函式組合後,將結果再塞回給 Effect。由於 map 返回的也是物件,我們需要一個方法把最終的計算結果取出來,這就是 runWith 的作用。

Effect 重現我們上一步的計算如下:

Effect(impureAddOne)
  .map(impureMultiplyByTwo)
  .runWith(2) // => add one!multiply by two! 6
複製程式碼

現在我們就可以直接用非純函式了,不用再用那麼難讀的函式呼叫了。在執行 runWith 之前,程式都是純的,任你怎麼組合和 map

如果你懂了上面的程式碼,那麼恭喜你,你已經懂了 Functor!

同樣,Functor 還要滿足一些條件:

  • 單元律:a.map(x => x) === a
  • 儲存原有資料結構(可組合):a.map(x => f(g(x))) === a.map(g).map(f)
  • 提供介面往裡面塞值:Effect.of = value => Effect(() => value)

你可以把 Functor 理解成一個對映函式,它把一個型別裡的值對映到同一個型別的其它值。比如陣列操作 [1, 2, 3].map(String) // -> ['1', '2', '3'], 對映之後資料型別一樣(還是陣列),內部結構不變。我在之前的文章中說陣列就是個 Functor,這種表述是有誤的,應該是說陣列滿足 Functor 的返回值條件。

3. Applicative

上面的 Effect 函式把非純操作都放進了一個容器裡面,這樣子做了之後,如果要對兩個獨立非純操作的結果進行運算,就會很麻煩。

比如,我們在 window 全域性讀取兩個值 x, y, 並將讀取結果求和。我知道這個例子很簡單,不用函數語言程式設計很容易做到,我只是在舉簡單例子方便理解。

假設 window 物件已經存在兩個值 {x: 1, y: 2, ...otherProps}。我們這樣取:

const win = Effect.of(window)

const xFromWindow = win.map(g => g.x)

const yFromWindow = win.map(g => g.y)
複製程式碼

xFromWindowyFromWindow 返回的都是一個 Effect 容器,我們需要給這個容器新新增一個方法,以便將兩個容器裡層的值進行計算。

const Effect = f => ({
  map: g => Effect(x => g(f(x))),
  runWith: x => f(x),
  ap: other => Effect(x => other.map(f(x)).runWith()),
})
複製程式碼

然後,我們提供一個相加函式 add:

const add = x => y => x + y
複製程式碼

接下來藉助這個 ap 函式,我們可以進行計算了:

xFromWindow
  .map(add)
  .ap(yFromWindow)
  .runWith() // => 3
複製程式碼

由於這種先 map 再 ap 的操作很普遍,我們可以抽象出一個工具函式 liftA2:

const liftA2 = (f, m1, m2) => m1.map(f).ap(m2)
複製程式碼

然後可以簡化點寫了:

liftA2(add, xFromWindow, yFromWindow).runWith() // => 3;
複製程式碼

注意運算函式必須是柯里化函式。

新增 ap 方法之後的 Effect 函式除了是 Functor,還是 Applicative Functor。這部分完全看程式碼還不是很好懂。如果你不理解上面的程式碼,沒有關係,它並不影響你理解 Monad。另外,不用糾結於本文程式碼裡的具體實現。不同的 Applicative 的 ap 方法實現都不一樣,可以多看幾個。Applicative 是介於 Functor 和 Monad 之間的資料型別,不提它就不完整了。

Applicative 要滿足下面這些條件:

  • Identity: A.of(x => x).ap(v) === v
  • Homomorphism: A.of(f).ap(A.of(x)) === A.of(f(x))
  • Interchange: u.ap(A.of(y)) === A.of(f => f(y)).ap(u)

4. Monad (!!!)

假設我們要從 window 全域性讀取配置資訊,此配置資訊提供目標 DOM 節點的類名 userEl;根據這個類名,我們定位到 DOM 節點,取出內容,然後列印到控制檯。啊,讀取全域性物件,讀取 DOM,控制檯輸出,全是作用,好可怕…… 我們先用之前定義的 Effect 試試看行不行:

// DOM 讀取和控制檯列印的行為放進 Effect
const $ = s => Effect(() => document.querySelector(s))
const log = s => Effect(() => console.log(s))

Effect.of(window)
  .map(win => win.userEl)
  .map($)
  .runWith() //由於上一個 map 裡層也返回了 Effect,這裡需要抹平一層
  .map(e => e.innerHTML)
  .map(log)
  .runWith()
  .runWith()
複製程式碼

勉強能做到,但是這樣子先 maprunWith 實在太繁瑣了,我們可以再給 Effect 新增一個方法 chain:

const Effect = f => ({
  map: g => Effect(x => g(f(x))),
  runWith: x => f(x),
  ap: other => Effect(x => other.map(f(x)).runWith()),
  chain: g =>
    Effect(f)
      .map(g)
      .runWith(),
})
複製程式碼

然後這樣組合:

Effect.of(window)
  .map(win => win.userEl)
  .chain($)
  .map(e => e.innerHTML)
  .chain(log)
  .runWith();
複製程式碼

線上 Demo 見這裡

Voila! 我們發現了 Monad!

在寫上面的程式碼的時候我還是覺得逐行解釋程式碼比較繁瑣。我們先不管程式碼具體實現,從函式簽名開始看 Monad 是怎麼回事。

讓我們回到 Monoid。我們知道函式組合的前提條件是型別簽名一致。fn :: a -> a. 但在寫應用時,我們會讓函式除了返回值之外還幹其他事。這裡不管具體幹了哪些事,我們可以把這些行為扔到一個黑盒子裡(比如剛剛寫的 Effect),然後函式簽名就成了 fn :: a -> m a。m 指的是黑盒子的型別,m a 意思是黑盒子裡的 a. 這樣操作之後,Monoid 介面不再滿足,函式不能簡單組合。

但我們還是要組合。

其實很簡單,在組合之前把黑盒子裡的值提升一層就行了。最終我們實現的組合其實是這樣:fn :: m a -> (a -> m b) -> m b. 這個簽名裡,函式 fn 接受黑盒子裡的 a 為引數,再接受一個函式為引數,這個函式的入參型別是 a,返回型別是黑盒子裡的 b。最終,外層函式返回的型別是黑盒子裡的 b。這個就是 chain 函式的型別簽名。

fn :: a -> m a 簽名裡面的箭頭叫 Kleisli Arrow,其實就是一種特殊的函式。Kleisli 箭頭的組合叫 Kleisli Composition,這也是 Ramda 裡面 composeK 函式的來源。這裡先了解一下,等下還會用到這個概念。

Monad 要滿足的一些定律如下:

  • Left identity: M.of(a).chain(f) === f(a)
  • Right identity: m.chain(M.of) === m
  • Associativity: m.chain(f).chain(g) === m.chain(x => f(x).chain(g))

很多人誤解 JS 裡面的 Promise 就是個 Monad,我之前也有這樣的誤解,但後來想明白了。按照上面的定律來看檢查 Promise:

Left identity:

Promise.resolve(a).then(f) === f(a)
複製程式碼

看起來滿足。但是如果 a 是個 Promise 呢?要處理 Promise,那 f 應該符合符合這個函式的型別簽名:

const f = p => p.then(n => n * 2)
複製程式碼

來試一下:

const a = Promise.resolve(1)
const output = Promise.resolve(a).then(f)
// output :: RejectedPromise TypeError: p.then is not a function
複製程式碼

報錯的原因是,a 在傳給 f 之前,就已經被 resolve 掉了。

Right identity:

p.then(x => Promise.resolve(x)) === p
複製程式碼

滿足。

Associativity:

p.then(f).then(g) === p.then(x => f(x).then(g))
複製程式碼

和左單元律一樣,只有當 f 和 g 接受的引數不為 Promise,上面才成立。

所以,Monad 的三個條件,Promise 只符合一條。

更多 ADT

上面演示的 Effect 函式,和我之前文章《不完整解釋 Monad 有什麼用》 裡面演示的 IO 函式是同一個 ADT,它是用來處理程式中的作用的。函數語言程式設計中還有很多不同用處的 ADT,比如,處理非同步的 Future,處理狀態管理的 State,處理依賴注入的 Reader 等。關於為什麼這個 Monad 是代數資料型別,Monad 和大家熟知的代數有什麼關係,這裡不展開了,有興趣進一步瞭解的話可以參考 Category Theory for Programmers 這本書。

這裡再展示兩個 ADT,Reader 和 State,比較它們 chain 和 ap 的不同實現,對比 Monadic bind 函式型別簽名 chain :: m a -> (a -> m b) -> m b,思考下它們是怎樣實現 Monad 的。

1. Reader

const Reader = computation => {
  const map = f => Reader(ctx => f(computation(ctx)))

  const contramap = f => Reader(ctx => computation(f(ctx)))

  const ap = other => Reader(ctx => computation(ctx)(other.runWith(ctx)))

  const chain = f => {
    return Reader(ctx => {
      const a = computation(ctx)
      return f(a).runWith(ctx)
    })
  }

  const runWith = computation

  return Object.freeze({
    map,
    contramap,
    ap,
    chain,
    runWith,
  })
}

Reader.of = x => Reader(() => x)
複製程式碼

題外話補充下,上面這種叫“冰凍工廠”的工廠函式寫法,是我個人偏好。這樣寫會有一定效能和記憶體消耗問題。用 Class 效能更好,看你選擇。

程式中可能會遇到某個函式對外部環境有依賴。用純函式的寫法,我們可以把這個依賴同時傳進函式。這樣子,函式簽名就是 fn :: (a, e) -> b。e 代表外部環境。這個簽名不符合我們前面提到的 a -> m b. 我們到現在還只提到了一次函式柯里化,這個時候再一次要用柯里化了。柯里化後,有依賴的函式型別簽名是 fn :: a -> (e, b), 你可能認出來了,中間那個箭頭就是 Kleisli Arrow。

假設我們有一段程式的多個模組依賴了共同的外部環境。要做到引用透明,我們必須把這個環境傳進函式。但是每一個模組如果都接受外部環境為多餘引數,那這些模組是沒辦法組合的。Reader 幫我們解決這個問題。

來寫個簡單程式,執行這個程式時輸出“你好,xx ... 再見,xx”。xx 由執行時的引數決定。

const concat = x => y => y.concat.call(y, x)

const greet = greeting => Reader(name => `${greeting}, ${name}`)

const addFarewell = farewell => str =>
  Reader(name => `${str}${farewell}, ${name}`)

const buildSentence = greet('你好')
  .map(concat('...'))
  .chain(addFarewell('再見'))

buildSentence.runWith('張三')
// => 你好, 張三...再見, 張三
複製程式碼

上面這個例子過於簡單。輸出一個字串用一個函式就行,用不了解構和組合。但是,我們可以很容易擴充套件想象,如果 greetaddFarewell 是很複雜的模組,必須拆分,此時組合的價值就出現了。

在學習 Reader 時,我發現一篇很不錯的文章。這篇文章大開腦洞,用 Reader 實現 React 裡面的 Context。有興趣可以瞭解下。The Reader monad and read-only context

2. State

// 這個寫法你可能不習慣。
// 這是 K Combinator,Ramda 裡面對應函式是 always, Haskell 裡面是 const
const K = x => y => x

const State = computation => {
  const map = f =>
    State(state => {
      const prev = computation(state)
      return { value: f(prev.value), state: prev.state }
    })

  const ap = other =>
    State(state => {
      const prev = computation(state)
      const fn = prev.value
      return other.map(fn).runWith(prev.state)
    })

  const chain = fn =>
    State(state => {
      const prev = computation(state)
      const next = fn(prev.value)
      return next.runWith(prev.state)
    })

  const runWith = computation

  const evalWith = initState => computation(initState).value

  const execWith = initState => computation(initState).state

  return Object.freeze({
    map,
    ap,
    chain,
    evalWith,
    runWith,
    execWith,
  })
}

const modify = f => State(state => ({ value: undefined, state: f(state) }))

State.get = (f = x => x) => State(state => ({ value: f(state), state }))

State.modify = modify

State.put = state => modify(K(state))

State.of = value => State(state => ({ value, state }))
複製程式碼

State 裡層最終返回的值由物件構成,物件裡面包含了此時計算結果,以及當前的應用狀態。

再舉個簡單的例子。假設我們根據某狀態數字進行計算,首先我們在這個初始狀態上加某個數字,然後我們把狀態 + 1, 再把新的狀態和前一步的計算相乘,算出最終結果。同樣,例子很簡單,但已經包含了狀態管理的核心。來看程式碼:

const add = x => y => x + y

const inc = add(1)

const addBy = n => State.get(add(n))

const multiplyBy = a => State.get(b => b * a)

const incState = n => State.modify(inc).map(K(n))

addBy(10)
  .chain(incState)
  .chain(multiplyBy)
  .runWith(2) // => {value: 36, state: 3}
複製程式碼

上面最後一步組合,每個函式型別簽名一致,a -> m b, 構成 kleisli 組合,我們還可以用工具函式改進一下寫法:

const composeK = (...fns) =>
  fns.reduce((f, g) => (...args) => g(...args).chain(f))

const calculate = composeK(
  multiplyBy,
  incState,
  addBy
)

calculate(10).runWith(2) // => {value: 36, state: 3}
複製程式碼

範疇論介紹

Monad 有一個“臭名昭著”的定義,是這樣:

A monad is just a monoid in the category of endofunctors, what's the problem?

我見過這句話的中文翻譯。但是這種“鬼話”不管翻不翻譯都差不多的表達效果,我覺得還是不用翻譯了。很多人看到這句話不去查出處和上下文,就以此為據來批評 FP 社群故弄玄虛,我感到很無奈。

這句話出自這篇文章 Brief, Incomplete and Mostly Wrong History of Programming Languages. 這篇文章用戲謔調侃的方式把所有主流程式語言黑了一個遍。上面那句話是用來黑 Haskell 的。本來是句玩笑,結果就以訛傳訛了。

上面那句話的原始出處是範疇論的奠基之作 Categories for the Working Mathematician 原話更拗口:

All told, a monad in X is just a monoid in the category of endofunctors of X, with product × replaced by composition of endofunctors and unit set by the identity endofunctor.

注意書名,那是給數學家看的,不是給程式設計師看的。你看不懂很正常,看不懂還要罵這些學術泰斗裝逼就是你的不對了。

範疇論背景

首先,說明下我數學學得差,我接下來要講的名詞我知道是在研究什麼,再深入細節我就不知道了。

大家知道數學有很多分支,比如集合論,邏輯學,型別論(Type Theory) 等等。後來,有些數學家發現,如果用足夠抽象的概念工具去考察這些分支,其實他們都在講同樣的東西。橋接這些概念的工具是 isomorphism (同構)。isomorphic 就是在物件之間可以來回轉換,每次轉換沒有資訊丟失。比如,在邏輯學裡面研究的某個問題,可能和型別論裡面研究是同一個問題,只要兩者之間能形成 isomorphism。

統一數學各分支的理論就是範疇論。範疇論需要足夠抽象,避免細節,才能在相差巨大的各數學分支之間發現同構。這也是為什麼範疇論必須要用一些生僻的希臘詞根合成詞。因為它實在太抽象了,很難找到現有的詞彙去對應它裡面的一些概念。混用詞彙肯定會導致誤解。

再後來,FP 祖師爺之一 Haskell Curry,和另一個數學家一起發現了 Curry–Howard Isomorphism。這個理論證明了 proofs as programs, 就是說寫電腦程式(當然是函式式)和寫邏輯證明是一回事,兩者形成同構。再後來,這個理論被擴充套件了一下,成了 Curry–Howard-Lambek Isomorphism, 就是說邏輯學,程式函式,和範疇論,三者之間形成同構。

看了上面的理論背景,你應該明白了為什麼函數語言程式設計要從範疇論裡面獲取理論資源。

什麼是範疇 (Category)

範疇其實是很簡單的一個概念。範疇由一堆(這個量詞好難翻譯,我見過 a bunch, a collection, 但是不能說 a set)物件,以及物件之間的關係構成。我分兩部分介紹。

物件 (Object): 範疇論裡面的物件和程式設計裡面的物件是兩回事。範疇中的物件沒有屬性,沒有結構,你可以把它理解為不可描述的點。

箭頭 (arrow, morphism, 兩個詞說的是同一個東西, 我後面就用箭頭了): 連線物件,表示物件之間的關係。同樣,箭頭也是一個沒有結構沒有屬性的一種 primitive。它只說明瞭物件之間存在關係,並不能說明是什麼關係。

物件和箭頭要構成一個範疇,還要滿足這兩個條件:

  • 單元律。每個物件至少有一個箭頭能從自己出發回到自身。
  • 結合律。如果物件 a 和 b 之間存在箭頭 f,物件 b 和 c 之間存在箭頭 g,則必然存在箭頭 h 由 a 到 c,h 就是 f 和 g 的組合。

可以看出範疇論的起點真的非常簡單。很難想象基於這麼簡單的概念能構建出一個完整的數學理論。

我一開始試著在範疇論中來解釋 Monad,以失敗告終。要介紹的拗口名詞太多了,一篇文章根本講不完。所以本文會折中一下,還是用集合論的視角來解釋一下範疇論概念。(範疇論的單個物件可以對應成一個集合,但是範疇論禁止談論集合元素,所有關於物件的知識都由箭頭和組合推理出來,所以很頭疼。)

還記得我們是用集合來定義 Monoid 的吧?Monoid 其實就是一個只有一個物件的範疇。範疇和範疇之間的對映叫 Functor。如果一個 Functor 把範疇對映回自身,那麼這個 Functor 就叫 Endofunctor。Functor 和 Functor 之間的對映叫 Natural Transformation. 函數語言程式設計其實只處理一個範疇,就是資料型別(Types)。所以,我們前面提到的 Functor 也是 Endofunctor。

回到前面 Monad 中 chain 的型別簽名:

chain :: m a -> (a -> m b) -> m b

可以看出 Monad 是把一個型別對映回自身(m a -> m b),那麼它就是一個 Endofunctor。

再看看 Monad 中所運用的 Natural Transformation。還是看 chain 的簽名,前半部分 m a -> (a -> m b) 執行之後,型別簽名是 m (m b), 然後再和後面的連起來,就是 m (m b) -> m b. 這其實就是把一個 functor (m (m b)) 對映到另一個 Functor (m b)。m (m b) -> m b 看起來是不是很眼熟?一個 Functor 和自己組合,形成同一個範疇裡的 Functor,這種組合就是 Monoid 啊!我們一開始定義的 Monoid 中的二元運算,在 Monad 中其實就是 Natural Transformation。

那麼,再回到這一部分開始時的定義:

A monad is just a monoid in the category of endofunctors.

有沒有好理解一點?

為什麼要這樣寫程式

這篇文章的目的不是鼓勵你在你的程式碼中消滅狀態機,消滅副作用,我自己都做不到的。我司後端是用 Java 寫的,如果我告訴後端同事 “Yo,你的程式裡不能出現狀態機哦……”,怕是會被哄出辦公室的。那麼,為什麼要了解這些知識?

電腦科學中有兩條截然相反的路徑。一條是自下而上,從底層指令開始往上抽象(優先考慮效能),逐漸靠近數學。比如,一開始的 Unix 作業系統是用匯編寫的,後來發現用匯編寫程式太痛苦了,需要一些抽象,所以出現了高階語言 C,再後來由於各種編寫應用的需求,出現了更高階的語言如 Python 和 JavaScript。另一條路徑是自上而下的,直接從數學開始(Lambda 演算),不考慮效能和硬體狀況,按需逐漸減少抽象。前一條路徑明顯佔了主流,代表語言是 Fortran, C, C++, Pascal, 和 Java 等。後面一條路徑不夠實用,比較小眾,代表語言是 Algo, LISP 和 Haskell 等。

這兩個陣營肯定是有爭論的。前者想勸後者從良:你別扔給我這麼多函式,我沒法不影響效能情況下處理那麼多垃圾回收和函式呼叫!後者也想叫醒前者:不要過早深入硬體細節,你會把自己鎖定在無法逆轉的設計錯誤上!兩者分道揚鑣了 60 多年,這些年總算開始融合了。比如,新出現的程式語言如 Scala,Kotlin,甚至系統程式語言 Rust,都大量借鑑了函數語言程式設計的思想。

學些高階抽象還能幫助你更容易理解一些看起來很複雜的概念。轉述一個例子。C++ 程式設計裡面最高的抽象是模板超程式設計(Template Meta Programming),據說很難懂。但是據 Bartosz Milewski 的解釋,之所以這個概念難懂,是因為 C++ 的語言設計不適合表達這些抽象。如果你會 Haskell,就會發現其實一行程式碼就完成了。

本文還發表在 Lambda Academy


參考:

相關文章