组合软件: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阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容