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 a
和 f b
位可以被讀為 a
的函子和 b
的函子,意思是 f a
的盒子中有 a
,f b
的盒子中有 b
。
使用函子很簡單 - 只要調用 map()
即可:
const f = [1, 2, 3];
f.map(double); // [2, 4, 6]
函子定律
范疇有兩個重要的屬性:
- 恒等(Identity)
- 組合(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
代表 f
和 g
的組合。所以,從 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 再看看組合:
給出一個函子 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 + 1
、n + 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
。
函子是更高層的抽象,允許我們創建各種作用于任何數據類型的通用函數。