JavaScript 函數語言程式設計(三)

佯真愚發表於2018-08-12

slide 地址

四、Talk is cheap!Show me the ... MONEY!

以下內容主要參考自 Professor Frisby Introduces Composable Functional JavaScript

show-me-the-money

4.1.容器(Box)

假設有個函式,可以接收一個來自使用者輸入的數字字串。我們需要對其預處理一下,去除多餘空格,將其轉換為數字並加一,最後返回該值對應的字母。程式碼大概長這樣...

const nextCharForNumStr = (str) =>
  String.fromCharCode(parseInt(str.trim()) + 1)

nextCharForNumStr(' 64 ') // "A"
複製程式碼

因缺思廳,這程式碼巢狀的也太緊湊了,看多了“老闊疼”,趕緊重構一把...

五官太緊湊

const nextCharForNumStr = (str) => {
  const trimmed = str.trim()
  const number = parseInt(trimmed)
  const nextNumber = number + 1
  return String.fromCharCode(nextNumber)
}

nextCharForNumStr(' 64 ') // 'A'
複製程式碼

很顯然,經過之前內容的薰(xi)陶(nao),一眼就可以看出這個修訂版程式碼很不 Pointfree...

為了這些只用一次的中間變數還要去想或者去查翻譯,也是容易“老闊疼”,再改再改~

老闊疼

const nextCharForNumStr = (str) => [str]
  .map(s => s.trim())
  .map(s => parseInt(s))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))

nextCharForNumStr(' 64 ') // ['A']
複製程式碼

這次藉助陣列的 map 方法,我們將必須的4個步驟拆分成了4個小函式。

這樣一來再也不用去想中間變數的名稱到底叫什麼,而且每一步做的事情十分的清晰,一眼就可以看出這段程式碼在幹嘛。

我們將原本的字串變數 str 放在陣列中變成了 [str],這裡就像放在一個容器裡一樣。

程式碼是不是感覺好 door~~ 了?

穩

不過在這裡我們可以更進一步,讓我們來建立一個新的型別 Box。我們將同樣定義 map 方法,讓其實現同樣的功能。

const Box = (x) => ({
  map: f => Box(f(x)),        // 返回容器為了鏈式呼叫
  fold: f => f(x),            // 將元素從容器中取出
  inspect: () => `Box(${x})`, // 看容器裡有啥
})

const nextCharForNumStr = (str) => Box(str)
  .map(s => s.trim())
  .map(i => parseInt(i))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))
  .fold(c => c.toLowerCase()) // 可以輕易地繼續呼叫新的函式

nextCharForNumStr(' 64 ') // a
複製程式碼

此外建立一個容器,除了像函式一樣直接傳遞引數以外,還可以使用靜態方法 of

函數語言程式設計一般約定,函子有一個 of 方法,用來生成新的容器。

Box(1) === Box.of(1)
複製程式碼

其實這個 Box 就是一個函子(functor),因為它實現了 map 函式。當然你也可以叫它 Mappable 或者其他名稱。

不過為了保持與範疇學定義的名稱一致,我們就站在巨人的肩膀上不要再發明新名詞啦~(後面小節的各種奇怪名詞也是來源於數學名詞)。

functor 是實現了 map 函式並遵守一些特定規則的容器型別。

那麼這些特定的規則具體是什麼咧?

  1. 規則一:
fx.map(f).map(g) === fx.map(x => g(f(x)))
複製程式碼

這其實就是函式組合...

  1. 規則二:
const id = x => x

fx.map(id) === id(fx)
複製程式碼

diagram-functor

4.2.Either / Maybe

cat

假設現在有個需求:獲取對應顏色的十六進位制的 RGB 值,並返回去掉#後的大寫值。

const findColor = (name) => ({
  red: '#ff4444',
  blue: '#3b5998',
  yellow: '#fff68f',
})[name]

const redColor = findColor('red')
  .slice(1)
  .toUpperCase() // FF4444

const greenColor = findColor('green')
  .slice(1)
  .toUpperCase()
// Uncaught TypeError:
// Cannot read property 'slice' of undefined
複製程式碼

以上程式碼在輸入已有顏色的 key 值時執行良好,不過一旦傳入其他顏色就會報錯。咋辦咧?

暫且不提條件判斷和各種奇技淫巧的錯誤處理。我們們來先看看函式式的解決方案~

函式式將錯誤處理抽象成一個 Either 容器,而這個容器由兩個子容器 RightLeft 組成。

// Either 由 Right 和 Left 組成

const Left = (x) => ({
  map: f => Left(x),            // 忽略傳入的 f 函式
  fold: (f, g) => f(x),         // 使用左邊的函式
  inspect: () => `Left(${x})`,  // 看容器裡有啥
})

const Right = (x) => ({
  map: f => Right(f(x)),        // 返回容器為了鏈式呼叫
  fold: (f, g) => g(x),         // 使用右邊的函式
  inspect: () => `Right(${x})`, // 看容器裡有啥
})

// 來測試看看~
const right = Right(4)
  .map(x => x * 7 + 1)
  .map(x => x / 2)

right.inspect() // Right(14.5)
right.fold(e => 'error', x => x) // 14.5

const left = Left(4)
  .map(x => x * 7 + 1)
  .map(x => x / 2)

left.inspect() // Left(4)
left.fold(e => 'error', x => x) // error
複製程式碼

可以看出 RightLeft 相似於 Box

  • 最大的不同就是 fold 函式,這裡需要傳兩個回撥函式,左邊的給 Left 使用,右邊的給 Right 使用。
  • 其次就是 Leftmap 函式忽略了傳入的函式(因為出錯了嘛,當然不能繼續執行啦)。

現在讓我們回到之前的問題來~

const fromNullable = (x) => x == null
  ? Left(null)
  : Right(x)

const findColor = (name) => fromNullable(({
  red: '#ff4444',
  blue: '#3b5998',
  yellow: '#fff68f',
})[name])

findColor('green')
  .map(c => c.slice(1))
  .fold(
    e => 'no color',
    c => c.toUpperCase()
  ) // no color
複製程式碼

從以上程式碼不知道各位讀者老爺們有沒有看出使用 Either 的好處,那就是可以放心地對於這種型別的資料進行任何操作,而不是在每個函式裡面小心翼翼地進行引數檢查。

4.3.Chain / FlatMap / bind / >>=

假設現在有個 json 檔案裡面儲存了埠,我們要讀取這個檔案獲取埠,要是出錯了返回預設值 3000。

// config.json
{ "port": 8888 }

// chain.js
const fs = require('fs')

const getPort = () => {
  try {
    const str = fs.readFileSync('config.json')
    const { port } = JSON.parse(str)
    return port
  } catch(e) {
    return 3000
  }
}

const result = getPort()
複製程式碼

so easy~,下面讓我們來用 Either 來重構下看看效果。

const fs = require('fs')

const Left = (x) => ({ ... })
const Right = (x) => ({ ... })

const tryCatch = (f) => {
  try {
    return Right(f())
  } catch (e) {
    return Left(e)
  }
}

const getPort = () => tryCatch(
    () => fs.readFileSync('config.json')
  )
  .map(c => JSON.parse(c))
  .fold(e => 3000, c => c.port)
複製程式碼

啊,常規操作,看起來不錯喲~

不錯你個蛇頭...!

以上程式碼有個 bug,當 json 檔案寫的有問題時,在 JSON.parse 時會出錯,所以這步也要用 tryCatch 包起來。

但是,問題來了...

返回值這時候可能是 Right(Right('')) 或者 Right(Left(e))(想想為什麼不是 Left(Right('')) 或者 Left(Left(e)))

也就是說我們現在得到的是兩層容器,就像俄羅斯套娃一樣...

要取出容器中的容器中的值,我們就需要 fold 兩次...!(若是再多幾層...)

dog

因缺思廳,所以聰明機智的函式式又想出一個新方法 chain~,其實很簡單,就是我知道這裡要返回容器了,那就不要再用容器包了唄。

...

const Left = (x) => ({
  ...
  chain: f => Left(x) // 和 map 一樣,直接返回 Left
})

const Right = (x) => ({
  ...
  chain: f => f(x),   // 直接返回,不使用容器再包一層了
})

const tryCatch = (f) => { ... }

const getPort = () => tryCatch(
    () => fs.readFileSync('config.json')
  )
  .chain(c => tryCatch(() => JSON.parse(c))) // 使用 chain 和 tryCatch
  .fold(
    e => 3000,
    c => c.port
  )
複製程式碼

其實這裡的 LeftRight 就是單子(Monad),因為它實現了 chain 函式。

monad 是實現了 chain 函式並遵守一些特定規則的容器型別。

在繼續介紹這些特定規則前,我們先定義一個 join 函式:

// 這裡的 m 指的是一種 Monad 例項
const join = m => m.chain(x => x)
複製程式碼
  1. 規則一:
join(m.map(join)) === join(join(m))
複製程式碼
  1. 規則二:
// 這裡的 M 指的是一種 Monad 型別
join(M.of(m)) === join(m.map(M.of))
複製程式碼

這條規則說明了 map 可被 chainof 所定義。

m.map(f) === m.chain(x => M.of(f(x)))
複製程式碼

也就是說 Monad 一定是 Functor

Monad 十分強大,之後我們將利用它處理各種副作用。但別對其感到困惑,chain 的主要作用不過將兩種不同的型別連線(join)在一起罷了。

diagram-monad

4.4.半群(Semigroup)

定義一:對於非空集合 S,若在 S 上定義了二元運算 ○,使得對於任意的 a, b ∈ S,有 a ○ b ∈ S,則稱 {S, ○} 為廣群。

定義二:若 {S, ○} 為廣群,且運算 ○ 還滿足結合律,即:任意 a, b, c ∈ S,有 (a ○ b) ○ c = a ○ (b ○ c),則稱 {S, ○} 為半群。

舉例來說,JavaScript 中有 concat 方法的物件都是半群。

// 字串和 concat 是半群
'1'.concat('2').concat('3') === '1'.concat('2'.concat('3'))

// 陣列和 concat 是半群
[1].concat([2]).concat([3]) === [1].concat([2].concat([3]))
複製程式碼

雖然理論上對於 <Number, +> 來說它符合半群的定義:

  • 數字相加返回的仍然是數字(廣群)
  • 加法滿足結合律(半群)

但是數字並沒有 concat 方法

沒事兒,讓我們來實現這個由 <Number, +> 組成的半群 Sum。

const Sum = (x) => ({
  x,
  concat: ({ x: y }) => Sum(x + y), // 採用解構獲取值
  inspect: () => `Sum(${x})`,
})

Sum(1)
  .concat(Sum(2))
  .inspect() // Sum(3)
複製程式碼

除此之外,<Boolean, &&> 也滿足半群的定義~

const All = (x) => ({
  x,
  concat: ({ x: y }) => All(x && y), // 採用解構獲取值
  inspect: () => `All(${x})`,
})

All(true)
  .concat(All(false))
  .inspect() // All(false)
複製程式碼

最後,讓我們對於字串建立一個新的半群 First,顧名思義,它會忽略除了第一個引數以外的內容。

const First = (x) => ({
  x,
  concat: () => First(x), // 忽略後續的值
  inspect: () => `First(${x})`,
})

First('blah')
  .concat(First('yoyoyo'))
  .inspect() // First('blah')
複製程式碼

咿呀喲?是不是感覺這個半群和其他半群好像有點兒不太一樣,不過具體是啥又說不上來...?

這個問題留給下個小節。在此先說下這玩意兒有啥用。

const data1 = {
  name: 'steve',
  isPaid: true,
  points: 10,
  friends: ['jame'],
}
const data2 = {
  name: 'steve',
  isPaid: false,
  points: 2,
  friends: ['young'],
}
複製程式碼

假設有兩個資料,需要將其合併,那麼利用半群,我們可以對 name 應用 First,對於 isPaid 應用 All,對於 points 應用 Sum,最後的 friends 已經是半群了...

const Sum = (x) => ({ ... })
const All = (x) => ({ ... })
const First = (x) => ({ ... })

const data1 = {
  name: First('steve'),
  isPaid: All(true),
  points: Sum(10),
  friends: ['jame'],
}
const data2 = {
  name: First('steve'),
  isPaid: All(false),
  points: Sum(2),
  friends: ['young'],
}

const concatObj = (obj1, obj2) => Object.entries(obj1)
  .map(([ key, val ]) => ({
    // concat 兩個物件的值
    [key]: val.concat(obj2[key]),
  }))
  .reduce((acc, cur) => ({ ...acc, ...cur }))

concatObj(data1, data2)
/*
  {
    name: First('steve'),
    isPaid: All(false),
    points: Sum(12),
    friends: ['jame', 'young'],
  }
*/
複製程式碼

4.5.么半群(Monoid)

么半群是一個存在單位元(么元)的半群。

半群我們都懂,不過啥是單位元?

單位元:對於半群 <S, ○>,存在 e ∈ S,使得任意 a ∈ S 有 a ○ e = e ○ a

舉例來說,對於數字加法這個半群來說,0就是它的單位元,所以 <Number, +, 0> 就構成一個么半群。同理:

  • 對於 <Number, *> 來說單位元就是 1
  • 對於 <Boolean, &&> 來說單位元就是 true
  • 對於 <Boolean, ||> 來說單位元就是 false
  • 對於 <Number, Min> 來說單位元就是 Infinity
  • 對於 <Number, Max> 來說單位元就是 -Infinity

那麼 <String, First> 是么半群麼?

顯然我們並不能找到這樣一個單位元 e 滿足

First(e).concat(First('steve')) === First('steve').concat(First(e))

這就是上一節留的小懸念,為何會感覺 First 與 Sum 和 All 不太一樣的原因。

格嘰格嘰,這兩者有啥具體的差別麼?

其實看到么半群的第一反應應該是預設值或初始值,例如 reduce 函式的第二個引數就是傳入一個初始值或者說是預設值。

// sum
const Sum = (x) => ({ ... })
Sum.empty = () => Sum(0) // 單位元

const sum = xs => xs.reduce((acc, cur) => acc + cur, 0)

sum([1, 2, 3])  // 6
sum([])         // 0,而不是報錯!

// all
const All = (x) => ({ ... })
All.empty = () => All(true) // 單位元

const all = xs => xs.reduce((acc, cur) => acc && cur, true)

all([true, false, true]) // false
all([])                  // true,而不是報錯!

// first
const First = (x) => ({ ... })

const first = xs => xs.reduce(acc, cur) => acc)

first(['steve', 'jame', 'young']) // steve
first([])                         // boom!!!
複製程式碼

從以上程式碼可以看出么半群比半群要安全得多,

4.6.foldMap

1.套路

在上一節中么半群的使用程式碼中,如果傳入的都是么半群例項而不是原始型別的話,你會發現其實都是一個套路...

const Monoid = (x) => ({ ... })

const monoid = xs => xs.reduce(
    (acc, cur) => acc.concat(cur),  // 使用 concat 結合
    Monoid.empty()                  // 傳入么元
)

monoid([Monoid(a), Monoid(b), Monoid(c)]) // 傳入么半群例項
複製程式碼

所以對於思維高度抽象的函式式來說,這樣的程式碼肯定是需要繼續重構精簡的~

2.List、Map

在講解如何重構之前,先介紹兩個炒雞常用的不可變資料結構:ListMap

顧名思義,正好對應原生的 ArrayObject

3.利用 List、Map 重構

因為 immutable 庫中的 ListMap 並沒有 empty 屬性和 fold 方法,所以我們首先擴充套件 List 和 Map~

import { List, Map } from 'immutable'

const derived = {
  fold (empty) {
    return this.reduce((acc, cur) => acc.concat(cur), empty)
  },
}

List.prototype.empty = List()
List.prototype.fold = derived.fold

Map.prototype.empty = Map({})
Map.prototype.fold = derived.fold

// from https://github.com/DrBoolean/immutable-ext
複製程式碼

這樣一來上一節的程式碼就可以精簡成這樣:

List.of(1, 2, 3)
  .map(Sum)
  .fold(Sum.empty())     // Sum(6)

List().fold(Sum.empty()) // Sum(0)

Map({ steve: 1, young: 3 })
  .map(Sum)
  .fold(Sum.empty())     // Sum(4)

Map().fold(Sum.empty())  // Sum(0)
複製程式碼

4.利用 foldMap 重構

注意到 mapfold 這兩步操作,從邏輯上來說是一個操作,所以我們可以新增 foldMap 方法來結合兩者。

import { List, Map } from 'immutable'

const derived = {
  fold (empty) {
    return this.foldMap(x => x, empty)
  },
  foldMap (f, empty) {
    return empty != null
      // 么半群中將 f 的呼叫放在 reduce 中,提高效率
      ? this.reduce(
          (acc, cur, idx) =>
            acc.concat(f(cur, idx)),
          empty
      )
      : this
        // 在 map 中呼叫 f 是因為考慮到空的情況
        .map(f)
        .reduce((acc, cur) => acc.concat(cur))
  },
}

List.prototype.empty = List()
List.prototype.fold = derived.fold
List.prototype.foldMap = derived.foldMap

Map.prototype.empty = Map({})
Map.prototype.fold = derived.fold
Map.prototype.foldMap = derived.foldMap

// from https://github.com/DrBoolean/immutable-ext
複製程式碼

所以最終版長這樣:

List.of(1, 2, 3)
  .foldMap(Sum, Sum.empty()) // Sum(6)
List()
  .foldMap(Sum, Sum.empty()) // Sum(0)

Map({ a: 1, b: 3 })
  .foldMap(Sum, Sum.empty()) // Sum(4)
Map()
  .foldMap(Sum, Sum.empty()) // Sum(0)
複製程式碼

4.7.LazyBox

下面我們要來實現一個新容器 LazyBox

顧名思義,這個容器很懶...

雖然你可以不停地用 map 給它分配任務,但是隻要你不呼叫 fold 方法催它執行(就像 deadline 一樣),它就死活不執行...

const LazyBox = (g) => ({
  map: f => LazyBox(() => f(g())),
  fold: f => f(g()),
})

const result = LazyBox(() => ' 64 ')
  .map(s => s.trim())
  .map(i => parseInt(i))
  .map(i => i + 1)
  .map(i => String.fromCharCode(i))
  // 沒有 fold 死活不執行

result.fold(c => c.toLowerCase()) // a
複製程式碼

4.8.Task

1.基本介紹

有了上一節中 LazyBox 的基礎之後,接下來我們來建立一個新的型別 Task。

首先 Task 的建構函式可以接收一個函式以便延遲計算,當然也可以用 of 方法來建立例項,很自然的也有 mapchainconcatempty 等方法。

與眾不同的是它有個 fork 方法(類似於 LazyBox 中的 fold 方法,在 fork 執行前其他函式並不會執行),以及一個 rejected 方法,類似於 Left,忽略後續的操作。

import Task from 'data.task'

const showErr = e => console.log(`err: ${e}`)
const showSuc = x => console.log(`suc: ${x}`)

Task
  .of(1)
  .fork(showErr, showSuc) // suc: 1

Task
  .of(1)
  .map(x => x + 1)
  .fork(showErr, showSuc) // suc: 2

// 類似 Left
Task
  .rejected(1)
  .map(x => x + 1)
  .fork(showErr, showSuc) // err: 1

Task
  .of(1)
  .chain(x => new Task.of(x + 1))
  .fork(showErr, showSuc) // suc: 2
複製程式碼

2.使用示例

接下來讓我們做一個發射飛彈的程式~

const lauchMissiles = () => (
  // 和 promise 很像,不過 promise 會立即執行
  // 而且引數的位置也相反
  new Task((rej, res) => {
    console.log('lauchMissiles')
    res('missile')
  })
)

// 繼續對之前的任務新增後續操作(duang~給飛彈加特技!)
const app = lauchMissiles()
  .map(x => x + '!')

// 這時才執行(發射飛彈)
app.fork(showErr, showSuc)
複製程式碼

3.原理意義

上面的程式碼乍一看好像沒啥用,只不過是把待執行的程式碼用函式包起來了嘛,這還能吹上天?

還記得前面章節說到的副作用麼?雖然說使用純函式是沒有副作用的,但是日常專案中有各種必須處理的副作用。

所以我們將有副作用的程式碼給包起來之後,這些新函式就都變成了純函式,這樣我們的整個應用的程式碼都是純的~,並且在程式碼真正執行前(fork 前)還可以不斷地 compose 別的函式,為我們的應用不斷新增各種功能,這樣一來整個應用的程式碼流程都會十分的簡潔漂亮。

side-effects

4.非同步巢狀示例

以下程式碼做了 3 件事:

  1. 讀取 config1.json 中的資料
  2. 將內容中的 8 替換成 6
  3. 將新內容寫到 config2.json 中
import fs from 'fs'

const app = () => (
  fs.readFile('config1.json', 'utf-8', (err, contents) => {
    if (err) throw err

    const newContents = content.replace(/8/g, '6')

    fs.writeFile('config2.json', newContents, (err, _) => {
      if (err) throw err

      console.log('success!')
    })
  })
)
複製程式碼

讓我們用 Task 來改寫一下~

import fs from 'fs'
import Task from 'data.task'

const cfg1 = 'config1.json'
const cfg2 = 'config2.json'

const readFile = (file, enc) => (
  new Task((rej, res) =>
    fs.readFile(file, enc, (err, str) =>
      err ? rej(err) : res(str)
    )
  )
)

const writeFile = (file, str) => (
  new Task((rej, res) =>
    fs.writeFile(file, str, (err, suc) =>
      err ? rej(err) : res(suc)
    )
  )
)

const app = readFile(cfg1, 'utf-8')
  .map(str => str.replace(/8/g, '6'))
  .chain(str => writeFile(cfg2, str))

app.fork(
  e => console.log(`err: ${e}`),
  x => console.log(`suc: ${x}`)
)
複製程式碼

程式碼一目瞭然,按照線性的先後順序完成了任務,並且在其中還可以隨意地插入或修改需求~

4.9.Applicative Functor

1.問題引入

Applicative Functor 提供了讓不同的函子(functor)互相應用的能力。

為啥我們需要函子的互相應用?什麼是互相應用?

先來看個簡單例子:

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

add(Box.of(2))(Box.of(3)) // NaN

Box(2).map(add).inspect() // Box(y => 2 + y)
複製程式碼

現在我們有了一個容器,它的內部值為區域性呼叫(partially applied)後的函式。接著我們想讓它應用到 Box(3) 上,最後得到 Box(5) 的預期結果。

說到從容器中取值,那肯定第一個想到 chain 方法,讓我們來試一下:

Box(2)
  .chain(x => Box(3).map(add(x)))
  .inspect() // Box(5)
複製程式碼

成功實現~,BUT,這種實現方法有個問題,那就是單子(Monad)的執行順序問題。

我們這樣實現的話,就必須等 Box(2) 執行完畢後,才能對 Box(3) 進行求值。假如這是兩個非同步任務,那麼完全無法並行執行。

別慌,吃口藥~

2.基本介紹

下面介紹下主角:ap~:

const Box = (x) => ({
  // 這裡 box 是另一個 Box 的例項,x 是函式
  ap: box => box.map(x),
  ...
})

Box(add)
  // Box(y => 2 + y) ,咦?在哪兒見過?
  .ap(Box(2))
  .ap(Box(3)) // Box(5)
複製程式碼

運算規則

F(x).map(f) === F(f).ap(F(x))

// 這就是為什麼
Box(2).map(add) === Box(add).ap(Box(2))
複製程式碼

3.Lift 家族

由於日常編寫程式碼的時候直接用 ap 的話模板程式碼太多,所以一般通過使用 Lift 家族系列函式來簡化。

// F 該從哪兒來?
const fakeLiftA2 = f => fx => fy => F(f).ap(fx).ap(fy)

// 應用運算規則轉換一下~
const liftA2 = f => fx => fy => fx.map(f).ap(fy)

liftA2(add, Box(2), Box(4)) // Box(6)

// 同理
const liftA3 = f => fx => fy => fz => fx.map(f).ap(fy).ap(fz)
const liftA4 = ...
...
const liftAN = ...
複製程式碼

4.Lift 應用

  • 例1
// 假裝是個 jQuery 介面~
const $ = selector =>
  Either.of({ selector, height: 10 })

const getScreenSize = screen => head => foot =>
  screen - (head.height + foot.height)

liftA2(getScreenSize(800))($('header'))($('footer')) // Right(780)
複製程式碼
  • 例2
// List 的笛卡爾乘積
List.of(x => y => z => [x, y, z].join('-'))
  .ap(List.of('tshirt', 'sweater'))
  .ap(List.of('white', 'black'))
  .ap(List.of('small', 'medium', 'large'))
複製程式碼
  • 例3
const Db = ({
  find: (id, cb) =>
    new Task((rej, res) =>
      setTimeout(() => res({ id, title: `${id}`}), 100)
    )
})

const reportHeader = (p1, p2) =>
  `Report: ${p1.title} compared to ${p2.title}`

Task.of(p1 => p2 => reportHeader(p1, p2))
  .ap(Db.find(20))
  .ap(Db.find(8))
  .fork(console.error, console.log) // Report: 20 compared to 8

liftA2
  (p1 => p2 => reportHeader(p1, p2))
  (Db.find(20))
  (Db.find(8))
  .fork(console.error, console.log) // Report: 20 compared to 8
複製程式碼

4.10.Traversable

1.問題引入

import fs from 'fs'

// 詳見 4.8.
const readFile = (file, enc) => (
  new Task((rej, res) => ...)
)

const files = ['a.js', 'b.js']

// [Task, Task],我們得到了一個 Task 的陣列
files.map(file => readFile(file, 'utf-8'))
複製程式碼

然而我們想得到的是一個包含陣列的 Task([file1, file2]),這樣就可以呼叫它的 fork 方法,檢視執行結果。

為了解決這個問題,函數語言程式設計一般用一個叫做 traverse 的方法來實現。

files
  .traverse(Task.of, file => readFile(file, 'utf-8'))
  .fork(console.error, console.log)
複製程式碼

traverse 方法第一個引數是建立函子的函式,第二個引數是要應用在函子上的函式。

2.實現

其實以上程式碼有 bug...,因為陣列 Array 是沒有 traverse 方法的。沒事兒,讓我們來實現一下~

Array.prototype.empty = []

// traversable
Array.prototype.traverse = function (point, fn) {
  return this.reduce(
    (acc, cur) => acc
      .map(z => y => z.concat(y))
      .ap(fn(cur)),
    point(this.empty)
  )
}
複製程式碼

看著有點兒暈?

不急,首先看程式碼主體是一個 reduce,這個很熟了,就是從左到右遍歷元素,其中的第二個引數傳遞的就是么半群(monoid)的單位元(empty)。

再看第一個引數,主要就是通過 applicative functor 呼叫 ap 方法,再將其執行結果使用 concat 方法合併到陣列中。

所以最後返回的就是 Task([foo, bar]),因此我們可以呼叫 fork 方法執行它。

4.11.自然變換(Natural Transformations)

1.基本概念

自然變換就是一個函式,接受一個函子(functor),返回另一個函子。看看程式碼熟悉下~

const boxToEither = b => b.fold(Right)
複製程式碼

這個 boxToEither 函式就是一個自然變換(nt),它將函子 Box 轉換成了另一個函子 Either

那麼我們用 Left 行不行呢?

答案是不行!

因為自然變換不僅是將一個函子轉換成另一個函子,它還滿足以下規則:

nt(x).map(f) == nt(x.map(f))
複製程式碼

natural_transformation

舉例來說就是:

const res1 = boxToEither(Box(100))
  .map(x => x * 2)
const res2 = boxToEither(
  Box(100).map(x => x * 2)
)

res1 === res2 // Right(200)
複製程式碼

即先對函子 a 做改變再將其轉換為函子 b,是等價於先將函子 a 轉換為函子 b 再做改變。

顯然,Left 並不滿足這個規則。所以任何滿足這個規則的函式都是自然變換

2.應用場景

1.例1:得到一個陣列小於等於 100 的最後一個數的兩倍的值

const arr = [2, 400, 5, 1000]
const first = xs => fromNullable(xs[0])
const double = x => x * 2
const getLargeNums = xs => xs.filter(x => x > 100)

first(
  getLargeNums(arr).map(double)
)
複製程式碼

根據自然變換,它顯然和 first(getLargeNums(arr)).map(double) 是等價的。但是後者顯然效能好得多。

再來看一個更復雜一點兒的例子:

2.例2:找到 id 為 3 的使用者的最好的朋友的 id

// 假 api
const fakeApi = (id) => ({
  id,
  name: 'user1',
  bestFriendId: id + 1,
})

// 假 Db
const Db = {
  find: (id) => new Task(
    (rej, res) => (
      res(id > 2
        ? Right(fakeApi(id))
        : Left('not found')
      )
    )
  )
}
複製程式碼
// Task(Either(user))
const zero = Db.find(3)

// 第一版
// Task(Either(Task(Either(user)))) ???
const one = zero
  .map(either => either
    .map(user => Db
      .find(user.bestFriendId)
    )
  )
  .fork(
    console.error,
    either => either // Either(Task(Either(user)))
      .map(t => t.fork( // Task(Either(user))
        console.error,
        either => either
            .map(console.log), // Either(user)
      ))
  )
複製程式碼

黑人問號4合一

這是什麼鬼???

肯定不能這麼幹...

// Task(Either(user))
const zero = Db.find(3)

// 第二版
const two = zero
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
    .chain(user => Db
      .find(user.bestFriendId) // Task(Either(user))
    )
    .chain(either => either
      .fold(Task.rejected, Task.of) // Task(user)
    )
  )
  .fork(
    console.error,
    console.log,
  )
複製程式碼

第二版的問題是多餘的巢狀程式碼。

// Task(Either(user))
const zero = Db.find(3)

// 第三版
const three = zero
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
  )
  .chain(user => Db
    .find(user.bestFriendId) // Task(Either(user))
  )
  .chain(either => either
    .fold(Task.rejected, Task.of) // Task(user)
  )
  .fork(
    console.error,
    console.log,
  )
複製程式碼

第三版的問題是多餘的重複邏輯。

// Task(Either(user))
const zero = Db.find(3)

// 這其實就是自然變換
// 將 Either 變換成 Task
const eitherToTask = (e) => (
  e.fold(Task.rejected, Task.of)
)

// 第四版
const four = zero
  .chain(eitherToTask) // Task(user)
  .chain(user => Db
    .find(user.bestFriendId) // Task(Either(user))
  )
  .chain(eitherToTask) // Task(user)
  .fork(
    console.error,
    console.log,
  )

// 出錯版
const error = Db.find(2) // Task(Either(user))
  // Task.rejected('not found')
  .chain(eitherToTask)
  // 這裡永遠不會被呼叫,被跳過了
  .chain(() => console.log('hey man'))
  ...
  .fork(
    console.error, // not found
    console.log,
  )
複製程式碼

4.12.同構(Isomorphism)

同構是在數學物件之間定義的一類對映,它能揭示出在這些物件的屬性或者操作之間存在的關係。

簡單來說就是兩種不同型別的物件經過變形,保持結構並且不丟失資料。

具體怎麼做到的呢?

其實同構就是一對兒函式:tofrom,遵守以下規則:

to(from(x)) === x
from(to(y)) === y
複製程式碼

這其實說明了這兩個型別都能夠無損地儲存同樣的資訊。

1. 例如 String[Char] 就是同構的。

// String ~ [Char]
const Iso = (to, from) => ({ to, from })

const chars = Iso(
  s => s.split(''),
  c => c.join('')
)

const str = 'hello world'

chars.from(chars.to(str)) === str
複製程式碼

這能有啥用呢?

const truncate = (str) => (
  chars.from(
    // 我們先用 to 方法將其轉成陣列
    // 這樣就能使用陣列的各類方法
    chars.to(str).slice(0, 3)
  ).concat('...')
)

truncate(str) // hel...
複製程式碼

2. 再來看看最多有一個引數的陣列 [a]Either 的同構關係

// [a] ~ Either null a
const singleton = Iso(
  e => e.fold(() => [], x => [x]),
  ([ x ]) => x ? Right(x) : Left()
)

const filterEither = (e, pred) => singleton
  .from(
    singleton
      .to(e)
      .filter(pred)
  )

const getUCH = (str) => filterEither(
  Right(str),
  x => x.match(/h/ig)
).map(x => x.toUpperCase())

getUCH('hello') // Right(HELLO)

getUCH('ello') // Left(undefined)
複製程式碼

參考資料

相關文章

以上 to be continued...

相關文章