[譯]Functor 與 Category (軟體編寫)(第六部分)

吳曉軍發表於2017-04-18

[譯]Functor 與 Category (軟體編寫)(第六部分)

Smoke Art Cubes to Smoke — MattysFlicks — (CC BY 2.0) (譯註:該圖是用 PS 將煙霧處理成方塊狀後得到的效果,參見 flickr。))

注意:這是 “軟體編寫” 系列文章的第六部分,該系列主要闡述如何在 JavaScript ES6+ 中從零開始學習函數語言程式設計和組合化軟體(compositional software)技術(譯註:關於軟體可組合性的概念,參見維基百科 Composability)。後續還有更多精彩內容,敬請期待!
<上一篇 | << 返回第一章

所謂 functor(函子),是能夠對其進行 map 操作的物件。換言之,functor 可以被認為是一個容器,該容器容納了一個值,並且暴露了一個介面(譯註:即 map 介面),該介面使得外界的函式能夠獲取容器中的值。所以當你見到 functor,別被其來自範疇學的名字唬住,簡單把他當做個 “mappable” 物件就行。

“functor” 一詞源於範疇學。在範疇學中,一個 functor 代表了兩個範疇(category)間的對映。簡單說來,一個 範疇 是一系列事物的分組,這裡的 “事物” 可以指代一切的值。對於編碼來說,一個 functor 通常代表了一個具有 .map() 方法的物件,該方法能夠將某一集合對映到另一集合。

上文說到,一個 functor 可以被看做是一個容器,比如我們將其看做是一個盒子,盒子裡面容納了一些事物,或者空空如也,最重要的是,盒子暴露了一個 mapping(對映)介面。在 JavaScript 中,陣列物件就是 functor 的絕佳例子(譯註:[1,2,3].map(x => x + 1)),但是,其他型別的物件,只要能夠被 map 操作,也可以算作是 functor,這些物件包括了單值物件(single valued-objects)、流(streams)、樹(trees)、物件(objects)等等。

對於如陣列和流等其他這樣的集合(collections)來說,.map() 方法指的是,在集合上進行迭代操作,在此過程中,應用一個預先指定的函式對每次迭代到的值進行處理。但是,不是所有的 functor 都可以被迭代。

在 JavaScript 中,陣列和 Promise 物件都是 functor(Promise 物件雖然沒有 .map() 方法,但其 .then() 方法也遵從 functor 的定律),除此之外,非常多的第三方庫也能夠將各種各樣的一般事物給轉換成 functor(譯註:大名鼎鼎的 Bluebird 就能將非同步過程封裝為 Promise functor)。

在 Haskell 中,functor 型別被定義為如下形式:

fmap :: (a -> b) -> f a -> f b複製程式碼

fmap 接受一個函式引數,該函式接受一個引數 a,並返回一個 b,最終,fmap 完成了從 f af b 的對映。f af b 可以被讀作 “一個 a 的 functor” 和“一個 b 的 functor”,亦即 f a 這個容器容納了 af b 這個容器容納了 b

使用一個 functor 是非常簡單的,僅需要呼叫 map() 方法即可:

const f = [1, 2, 3];
f.map(double); // [2, 4, 6]複製程式碼

Functor 定律

一個範疇含有兩個基本的定律:

  1. 同一性(Identity)
  2. 組合性(Composition)

由於 functor 是兩個範疇間的對映,其就必須遵守同一性和組合性,二者也構成了 functor 的基本定律。

同一性

如果你將函式(x => x)傳入 f.map(),對任意的一個 functor ff.map(x => x) == f

const f = [1, 2, 3];
f.map(x => x); // [1, 2, 3]複製程式碼

組合性

functor 還必須具有組合性:F.map(x => f(g(x))) == F.map(g).map(f)

函式組合是將一個函式的輸出作為另一個函式輸入的過程。例如,給定一個值 x及函式 f 和函式 g,函式的組合就是 (f ∘ g)(x)(通常簡寫為 f ∘ g,簡寫形式已經暗示了 (x)),其意味著 f(g(x))

很多函數語言程式設計的術語都源於範疇學,而範疇學的實質即是組合。初看範疇學,就像初次進行高臺跳水或者乘坐雲霄飛車,慌張,恐懼,但是並不難完成。你只需明確下面幾個範疇學基礎要點:

  • 一個範疇(category)是一個容納了一系列物件及物件間箭頭(->)的集合。
  • 箭頭只是形式上的描述,實際上,箭頭代表了態射(morphismms)。在程式設計中,態射可以被認為是函式。
  • 對於任何被箭頭相連線的物件,如 a -> b -> c,必須存在一個 a -> c 的組合。
  • 所有的箭頭表示都代表了組合(即便這個物件間的組合只是一個同一(identity)箭頭:a->c)。所有的物件都存在一個同一箭頭,即存在同一態射(a -> a)。

如果你有一個函式 g,該函式接受一個引數 a 並且返回一個 b,另一個函式 f 接受一個 b 並返回一個 c。那麼,必然存在一個函式 h,其代表了 fg 的組合。而 a -> c 的組合,就是 f ∘ g(讀作f 緊接著 g),進而,也就是 h(x) = f(g(x))。函式組合的方向是由右向左的,這也就是就是 f ∘ g 常被叫做 f 緊接著 g 的原因。

函式組合是滿足結合律的,這就意味著你在組合多個函式時,免去了新增括號的煩惱:

h∘(g∘f) = (h∘g)∘f = h∘g∘f複製程式碼

讓我們再看一眼 JavaScript 中組合律:

給定一個 functor,F

const F = [1, 2, 3];複製程式碼

下面的兩段是等效的:

F.map(x => f(g(x)));

// 等效於......

F.map(g).map(f);複製程式碼

譯註:functor 中函式組合的結合率可以被理解為:對 functor 中儲存的值使用組合後的函式進行 map,等效於先後對該值用不同的函式進行 map。

Endofunctors(自函子)

一個 endofunctor(自函子)是一個能將一個範疇對映回相同範疇的 functor。

一個 functor 能夠完成任意範疇間對映: F a -> F b

一個 endofunctor 能夠完成相同範疇間的對映:F a -> F a

在這裡,F 代表了一個 functor 型別,而 a 代表了一個範疇變數(意味著其能夠代表任意的範疇,無論是一個集合,還是一個包含了某一資料型別所有可能取值的範疇)。

而一個 monad 則是一個 endofunctor,先記住下面這句話:

“monad 是 endofunctor 範疇的 monoids(么半群),有什麼問題?”(譯註:這句話的出處在該系列第一篇已有提及)

現在,我們希望第一篇提及的這句話能在之後多一點意義,monoids(么半群)及 monad 將在之後作介紹。

自定義一個 Functor

下面將展示一個簡單的 functor 例子:

const Identity = value => ({
  map: fn => Identity(fn(value))
});複製程式碼

顯然,其滿足了 functor 定律:

// trace() 是一個簡單的工具函式來幫助審查內容
// 內容
const trace = x => {
  console.log(x);
  return x;
};

const u = Identity(2);

// 同一性
u.map(trace);             // 2
u.map(x => x).map(trace); // 2

const f = n => n + 1;
const g = n => n * 2;

// 組合性
const r1 = u.map(x => f(g(x)));
const r2 = u.map(g).map(f);

r1.map(trace); // 5
r2.map(trace); // 5複製程式碼

現在,你可以對存在該 functor 中的任何資料型別進行 map 操作,就像你對一個陣列進行 map 時那樣。這簡直太美妙了。

上面的程式碼片展示了 JavaScript 中 functor 的簡單實現,但是其缺失了 JavaScript 中常見資料型別的一些特性。現在我們逐個新增它們。首先,我們會想到,假如能夠直接通過 + 操作符操作我們的 functor 是不是很好,就像我們在數值或者字串物件間使用 + 號那樣。

為了使該想法變現,我們首先要為該 functor 物件新增 .valueOf() 方法 —— 這可被看作是提供了一個便捷的渠道來將值從 functor 盒子中取出。

const Identity = value => ({
  map: fn => Identity(fn(value)),

  valueOf: () => value,
});

const ints = (Identity(2) + Identity(4));
trace(ints); // 6

const hi = (Identity('h') + Identity('i'));
trace(hi); // "hi"複製程式碼

現在程式碼更漂亮了。但是如果我們還想要在控制檯審查 Identity 例項呢?如果控制檯能夠輸出 "Identity(value)" 就太好了,為此,我們只需要新增一個 .toString() 方法即可(譯註:亦即過載原型鏈上原有的 .toString() 方法):

toString: () => `Identity(${value})`,複製程式碼

程式碼又有所進步。現在,我們可能也想 functor 能夠滿足標準的 JavaScript 迭代協議(譯註:MDN - 迭代協議)。為此,我們可以為 Identity 新增一個自定義的迭代器:

  [Symbol.iterator]: () => {
    let first = true;
    return ({
      next: () => {
        if (first) {
          first = false;
          return ({
            done: false,
            value
          });
        }
        return ({
          done: true
        });
      }
    });
  },複製程式碼

現在,我們的 functor 還能這樣工作:

// [Symbol.iterator] enables standard JS iterations:
const arr = [6, 7, ...Identity(8)];
trace(arr); // [6, 7, 8]複製程式碼

假如你想借助 Identity(n) 來返回包含了 n+1n+2 等等的 Identity 陣列,這非常容易:

const fRange = (
  start,
  end
) => Array.from(
  {length: end - start + 1},
  (x, i) => Identity(i + start)
);複製程式碼

譯註:MDN -- Array.from()

但是,如果你想上面的操作方式能夠應用於任何 functor,該怎麼辦?假如我們規定了每種資料型別對應的例項必須有一個關於其建構函式的引用,那麼你可以這樣改造之前的邏輯:

const fRange = (
  start,
  end
) => Array.from(
  {length: end - start + 1},

  // 將 `Identity` 變更為 `start.constructor`
  (x, i) => start.constructor(i + start)
);

const range = fRange(Identity(2), 4);
range.map(x => x.map(trace)); // 2, 3, 4複製程式碼

假如你還想知道一個值是否在一個 functor 中,又怎麼辦?我們可以為 Identity 新增一個靜態方法 .is() 來進行檢測,另外,我們也順便新增了一個靜態的 .toString() 方法來告知這個 functor 的種類:

Object.assign(Identity, {
  toString: () => 'Identity',
  is: x => typeof x.map === 'function'
});複製程式碼

現在,我們整合一下上面的程式碼片:

const Identity = value => ({
  map: fn => Identity(fn(value)),

  valueOf: () => value,

  toString: () => `Identity(${value})`,

  [Symbol.iterator]: () => {
    let first = true;
    return ({
      next: () => {
        if (first) {
          first = false;
          return ({
            done: false,
            value
          });
        }
        return ({
          done: true
        });
      }
    });
  },

  constructor: Identity
});

Object.assign(Identity, {
  toString: () => 'Identity',
  is: x => typeof x.map === 'function'
});複製程式碼

注意,無論是 functor,還是 endofunctor,不一定需要上述那麼多的條條框框。以上工作只是為了我們在使用 functor 時更加便捷,而非必須。一個 functor 的所有需求只是一個滿足了 functor 定律 .map() 介面。

為什麼要使用 functor?

說 functor 多麼多麼好不是沒有理由的。最重要的一點是,functor 作為一種抽象,能讓開發者以同一種方式實現大量有用的,能夠操縱任何資料型別的事物。例如,如果你想要在 functor 中值不為 null 或者不為 undefined 前提下,構建一串地鏈式操作:

// 建立一個 predicte
const exists = x => (x.valueOf() !== undefined && x.valueOf() !== null);

const ifExists = x => ({
  map: fn => exists(x) ? x.map(fn) : x
});

const add1 = n => n + 1;
const double = n => n * 2;

// undefined
ifExists(Identity(undefined)).map(trace);
// null
ifExists(Identity(null)).map(trace);

// 42
ifExists(Identity(20))
  .map(add1)
  .map(double)
  .map(trace)
;複製程式碼

函數語言程式設計一直探討的是將各個小的函式進行組合,以建立出更高層次的抽象。假如你想要一個更通用的,能夠工作在任何 functor 上的 map() 方法,那麼你可以通過引數的部分應用(譯註:即 偏函式)來完成。

你可以使用自己喜歡的 curry 化方法(譯註:Underscore,Lodash,Ramda 等第三方庫都提供了 curry 化一個函式的方法),或者使用下面這個之前篇章提到的,基於 ES6 的,充滿魅力的 curry 化方法來實現引數的部分應用:

const curry = (
  f, arr = []
) => (...args) => (
  a => a.length === f.length ?
    f(...a) :
    curry(f, a)
)([...arr, ...args]);複製程式碼

現在,我們可以自定義 map() 方法:

const map = curry((fn, F) => F.map(fn));

const double = n => n * 2;

const mdouble = map(double);
mdouble(Identity(4)).map(trace); // 8複製程式碼

總結

functor 是能夠對其進行 map 操作的物件。更進一步地,一個 functor 能夠將一個範疇對映到另一個範疇。一個 functor 甚至可以將某一範疇對映回相同範疇(例如 endofunctor)。

一個範疇是一個容納了物件和物件間箭頭的集合。箭頭代表了態射(也可理解為函式或者組合)。一個範疇中的每個物件都具有一個同一態射(x -> x)。對於任何連結起來的物件 A -> B -> C,必存在一個 A -> C 的組合。

總之,functor 是一個極佳的高階抽象,能然你建立各種各樣的通用函式來操作任何的資料型別。

未完待續……

接下來

想學習更多 JavaScript 函數語言程式設計嗎?

跟著 Eric Elliott 學 Javacript,機不可失時不再來!

[譯]Functor 與 Category (軟體編寫)(第六部分)

Eric Elliott“編寫 JavaScript 應用” (O’Reilly) 以及 “跟著 Eric Elliott 學 Javascript” 兩書的作者。他為許多公司和組織作過貢獻,例如 Adobe SystemsZumba FitnessThe Wall Street JournalESPNBBC 等 , 也是很多機構的頂級藝術家,包括但不限於 UsherFrank Ocean 以及 Metallica

大多數時間,他都在 San Francisco Bay Area,同這世上最美麗的女子在一起。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章