組合軟件: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

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

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,748評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,165評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,595評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,633評論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,435評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,943評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,035評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,175評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,713評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,599評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,788評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,303評論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,034評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,412評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,664評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,408評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,747評論 2 370

推薦閱讀更多精彩內容