組合軟件:6. 函子和範疇

https://www.zcfy.cc/article/functors-amp-categories-javascript-scene-medium-2698.html

組合軟件:6. 函子和範疇

原文鏈接: medium.com

一個函子(Functor)是可以映射的某個事物。也就是說,函子是一個帶有接口的容器,這個接口可以用於將一個函數應用到容器內的值。看到函子(functor)這個詞時,就應該想到可映射

術語函子來自範疇論。在範疇論中,函子是範疇之間的映射。粗略地講,範疇(Category)是一組事物,這裏每個事物都可以是任何值。在代碼中,函子有時候被表示爲一個帶有 .map() 方法的對象,這個 .map() 方法用來將一組值映射爲另一組值。

函子爲其內部的零到多個事物提供了一個盒子,以及一個映射接口。數組就是函子的一個不錯的例子,但是很多其它類型的對象也可以被映射,包括單值對象、流、樹、對象等等。

對集合(數組、流等)而言,.map() 通常會遍歷集合,並且將指定函數應用到集合中的每個值,但是並非所有函子都可以迭代。

在 JavaScript 中,數組和 Promise 都是函子(.then() 是遵從函子定律的),不過有很多庫也可以把各種其它事物轉換爲函子。

在 Haskell中,函子類型被定義爲:

fmap :: (a -> b) -> f a -> f b

給出一個函數,該函數有一個參數 a,並返回一個 b 和一個有零到多個 a在其中的函子:fmap 返回一個其中有零到多個 b 的盒子。f af b 位可以被讀爲 a 的函子和 b 的函子,意思是 f a 的盒子中有 af b 的盒子中有 b

使用函子很簡單 - 只要調用 map() 即可:

const f = [1, 2, 3];
f.map(double); // [2, 4, 6]

函子定律

範疇有兩個重要的屬性:

  1. 恆等(Identity)
  2. 組合(Composition)

既然函子是範疇之間的映射,那麼函子就必須遵從恆等和組合。二者在一起稱爲函子定律。

恆等

如果將恆等函數(x => x)傳遞給 f.map(),這裏 f 是任何函子,那麼結果應該等價於 f(即與 f 有相同含義):

const f = [1, 2, 3];
f.map(x => x); // [1, 2, 3]

組合

函子必須遵從組合定律:F.map(x => f(g(x))) 等同於 F.map(g).map(f)

函數組合就是將一個函數應用到另一個函數的結果上,例如,給出一個 x 和函數 f 以及 g,組合 (f ∘ g)(x)(通常簡寫爲 f ∘ g -- (x) 被隱含)即指 f(g(x))

很多函數式編程術語都來自於範疇學,範疇學的精髓就是組合。範疇學是最開始很可怕,但是很簡單,就像從跳水板跳下或者坐過山車一樣。如下是範疇學基礎的幾個要點:

  • 一個範疇是對象以及對象之間箭頭的一個集合(這裏對象從字面上可以是指任何東西)。
  • 箭頭被稱爲態射(morphism)。態射可以被認爲就是函數,並且可以在代碼中表示爲函數。
  • 對於任何連接的對象組,a -> b -> c,必定有一個組合可以直接從 a -> c
  • 所有箭頭都可以被表示爲組合(即使它只是一個帶有對象的恆等箭頭的組合)。一個範疇中的所有對象都有恆等箭頭。

假設有函數 g,該函數有一個參數 a,並返回 b;還有另一個函數 f,該函數有一個參數 b,並返回一個 c;那麼就一定還有一個函數 h 代表 fg 的組合。所以,從 a -> c 的組合就是組合 f ∘ gfg 之後)。於是,h(x) = f(g(x))。函數組合是從右向左組合,而不是從左向右,這就是爲什麼 f ∘ g 經常被稱 fg 之後。

組合是可結合的。這基本上意味着在組合多個函數(如果你覺得喜歡,也可以稱爲態射)時,不需要圓括號:

h∘(g∘f) = (h∘g)∘f = h∘g∘f

下面我們用 JavaScript 再看看組合:

給出一個函子 F

const F = [1, 2, 3];

如下的語句都是等同的:

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

// 等同於...

F.map(g).map(f);

自函子

自函子(endofunctor)是一個將範疇映射回自身的函子。

一個函子可以將一個範疇映射到另一個範疇:F a -> F b

一個自函子將一個範疇映射到同一個範疇:F a -> F a

這裏 F 代表一種函子類型,a 代表一個範疇變量(意思是它可以表示任何範疇,包括一個集合或者一個同一類數據類型的所有可能值的範疇)。

一個單子(monad)就是一個自函子。記住:

“一個單子(Monad)說白了不過就是自函子(Endofunctor)範疇上的一個幺半羣(Monoid)而已,這有什麼難以理解的?”

希望這個引證開始變得更好懂點。我們稍後將開始接觸幺半羣和單子。

創建你自己的函子

如下是一個函子的簡單示例:

const Identity = value => ({  map: fn => Identity(fn(value))});

正如你所見,它滿足函子定律:

// 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

現在我們就可以映射任何數據類型,就跟映射數據一樣。很不錯!

這跟在 JavaScript 中創建函子一樣簡單,不過 JavaScript 中缺失一些我們想要的數據類型的特性。下面我們就添加這些特性。如果 + 運算符可以對數字和字符串值都起作用,那是不是很酷?

要讓這玩意兒生效的話,我們要做的就是實現 .valueOf() -- 這個方法也看起來像將值從函子中打開的一種簡便方法:

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: () => `Identity(${value})`,

酷!我們可能還應該啓用標準 JS 迭代協議。我們可以通過添加一個自定義的迭代器來實現:

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

現在下面的代碼就可以運行了:

// [Symbol.iterator] 啓用標準 JS 迭代
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)
);

對,不過如果我們想讓這可以作用於任何函子該怎麼辦?如果有一個規定說,一個數據類型的每個實例必須有一個對其構造器的引用,該怎麼辦?可以這樣做:

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

如果我們想測試看看一個值是否是一個函子該怎麼辦?我們可以在 Identity上添加一個靜態方法來檢測。這樣做時,我們應該插入一個靜態的 .toString()

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'
});

注意,要成爲一個函子或者自函子,並不需要所有這些額外的東西。這只是爲了方便。對於函子來說,所有我們所需要的就是符合函子定律的一個 .map()接口。

爲什麼要用函子?

函子之所以牛叉,是有很多原因的。最重要的是,它們是一種抽象,我們可以用它們以作用於任何數據類型的方式來實現很多有用的事情。比如,如果我們想啓動一連串操作,但是這些操作要排除掉函子內值爲 undefined 或者 null 的,該怎麼辦呢?

// 創建斷言
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;

// 什麼都沒有發生...
ifExists(Identity(undefined)).map(trace);
// 依然是什麼都沒有發生...
ifExists(Identity(null)).map(trace);

// 42
ifExists(Identity(20))
  .map(add1)
  .map(double)
  .map(trace)
;

當然,函數式編程都是組合小函數,來創建更高層的抽象。如果我們想有一個可以作用域任何函子的通用映射該怎麼辦?通過這種方式,我們可以偏應用參數來創建新函數。

很簡單。撿起你喜歡的自動柯里化,或者就使用之前的這個魔咒:

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

總結

函子是我們可以映射的事物。更具體地說,一個函數是從範疇到範疇的一個映射。一個函子甚至可以將一個範疇映射到同一範疇(即,自函子)。

範疇是對象的集合,對象之間有箭頭。箭頭代表態射(即函數,即組合)。範疇中的每個對象都有一個恆等態射(x => x)。對於任何對象鏈 A ->B -> C,必然存在組合 A -> C

函子是更高層的抽象,允許我們創建各種作用於任何數據類型的通用函數。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章