javascript
【JS】446- 你不知道的 map
本文來自【前端早讀課】,內容不錯,推薦給大家。
前言
今日早讀文章由酷家樂@Gloria投稿分享。
正文從這開始~~
作為前端工程師,你肯定用過Array.prototype.map方法。
如果你聽說過Ramda,它也提供了和Array.prototype.map方法類似的map方法。
但是這個map背后的東西可以讓你看到另外一個世界,我相信,如果你不想了解Ramda,也能從這篇文章中有所收獲。
下面我們進入到例子。
簡單的使用
像下面這樣使用這個函數。
R.map(x => x + 1, [1, 2, 3]); // [2, 3, 4]除了數組外它還可以作用于Object:
R.map(x => x + 1, {a: 1, b: 2, c: 3}); // {a: 2, b: 3, c: 4}你以為就完了嗎?它還能作用于函數:
R.map(x => x + 1, a => a + 1); // a => (a+1)+1哇,作用于函數真的是沒想到,那還能作用于其它奇奇怪怪的東西嗎?
當然可以,有很多東西從某種維度上講都是同一類東西,關鍵R.map的維度是什么呢?
先別講什么亂七八糟的,接下來咱們來看一看官方文檔上都有哪些描述.
文檔上都說了啥
接收一個函數和一個 functor, 將該函數應用到 functor 的每個值上,返回一個具有相同形態的 functor。
Ramda 為 Array 和 Object 提供了合適的 map 實現,因此 R.map 適用于 [1, 2, 3] 或 {x: 1, y: 2, z: 3}。
若第二個參數自身存在 map 方法,則調用自身的 map 方法。
若在列表位置中給出 transfomer,則用作 transducer 。
函數也是 functors,map 會將它們組合起來(相當于 R.compose)。
行了,除了2,3能看懂,其它都是啥??!!functor??transfomer??transducer??
我們找到Ramda的源碼,看看這個map究竟都有哪些魔法?
看看ramda源碼
隱去了一些不需要了解的邏輯,下面是代碼:
var map = _dispatchable(['fantasy-land/map', 'map'], _xmap, function map(fn, functor) { /*ramda默認處理邏輯*/ switch(Object.prototype.toString.call(functor)) { case'[object Function]': returnfunction() { return fn.call(this, functor.apply(this, arguments)); }; case'[object Object]': return _reduce(function(acc, key) {acc[key] = fn(functor[key]); return acc; }, {}, keys(functor)); default: return _map(fn, functor); } });先說說_dispatchable的邏輯:
function _dispatchable(methodNames, xf, fn): Function_dispatchable返回的函數作為R.map的處理過程
接收 3 個參數:methodNames(方法名數組),xf(transformer),fn(默認的ramda實現)
如果 methodNames 中的方法名存在于傳進 R.map方法的最后一個參數f上,則將該方法作為處理過程 (如 f 是數組,則使用默認的處理過程)
如果最后一個參數 f 是transformer,處理結果則是:一個新的transformer
如果以上3,4說的情況都沒有,則使用Ramda的默認處理過程(第一個代碼塊注釋處)
總體看下來R.map有3種處理策略(按照優先級從上到下):
最后一個參數f上出現在 methodNames 中的方法
根據最后一個參數 f 返回新的 transformer
Ramda默認處理邏輯
默認的處理邏輯就不再展開了,比較容易明白,先說說2,1放在后面講。
transduce
進入正題之前,拋開ramda,看一個簡單的栗子:
const add = (a, b) => a + b; [1,2,3,4].reduce(add, 0); // 10計算出一個數組中所有數字的和。
現在如果要對每個數字+1,再求和:
const add = (a, b) => a + b; const plusOne = a => a + 1; [1,2,3,4].map(plusOne).reduce(add, 0); // 14上面的代碼會遍歷數組兩次,雖然代碼寫起來省事了,如果數據量比較大,這個做法看起來就有些笨拙了。但是又不能改寫add方法,萬一別的地方也用到了add。
想辦法只遍歷一次:結合add和plusOne生成一個新的函數addNPlusOne:
const addNPlusOne = (acc, value) => add(acc, plusOne(value)); [1,2,3,4].reduce(addNPlusOne, 0); // 14嗯,解決了。但是還不夠通用,將add視為reducer,plusOne視為對value的預處理函數fn,通過結合fn和reducer生成一個新的reducer提供給reduce
const makeMapReducer = fn => reducer => (acc, value) => reducer(acc, fn(value)); const addNPlusOne = makeMapReducer(plusOne)(add); [1,2,3,4].reduce(addNPlusOne); // 14transducer
makeMapReducer(plusOne)就是一個transducer。
在之前的基礎上:如果需要先篩選出小于等于2的數值,然后再給每一項+1,最后統計出數組中所有數的和。
需要再添加一個filterTransducer:
const makeFilterReducer = fn => reducer => (acc, value) => fn(value)? reducer(acc, value) : acc; const filterTransducer = makeFilterReducer(a => a <= 2); const addNPluslteTwo = filterTransducer(addNPlusOne); [1,2,3,4].reduce(addNPlusltTwo); // 5好了,也就是說如果你不使用任何第三方庫,這個生成transducer的函數需要你自己去實現。
在Ramda中
在Ramda中你可以這樣實現上面的栗子:
R.transduce(R.map(a => a+1), (acc, value) => acc + value, 0, [1,2,3,4]); // 14 R.transduce(R.pipe(R.map(a => a+1),R.filter(a => a <= 2), ), (acc, value) => acc+value, 0, [1,2,3,4]); // 5再簡化一點:
R.transduce(R.map(R.inc), R.add, 0, [1,2,3,4]); // 14 R.transduce(R.pipe( R.map(R.inc),R.filter(R.gte(2)), ), R.add, 0, [1,2,3,4]); // 5之前的例子,我們自己實現了transducer。
而對于ramda來說,很多作用于數組的api都會有默認的生成transducer的實現,比如map,filter,find等等api。
好了,好像扯遠了,我們再回到R.map上,看一看這里的transformer是啥意思。
根據最后一個參數f返回新的transformer
回到開始的話題
當你調用R.transduce的時候,它會把第二個參數R.add,轉化為一個對象,這個對象上存在方法@@transducer/step,這個方法返回的是R.add(acc, value)。存在方法@@transducer/step的對象就叫做transformer。
其實你可以這樣理解:transformer是一個函數的載體,transformer['@@transducer/step']就是這個函數。
好了,如果當R.map的第二個參數是一個transformer的時候:
// _xwrap是ramda內部函數,用于將函數轉為transformer R.map(R.inc)(_xwrap(R.add)) // 跟下面是等價的 R.map(R.inc, _xwrap(R.add))R.map(R.inc)其實就是上面我們說的transducer(transducer還能組合起來,不再展開了,有興趣的同學可以加群討論)
transducer + transformer = transformer,所以上面兩行代碼返回的結果依然是一個transformer,這個transformer的@@transducer/step方法最終效果是下面這樣:
XMap.prototype['@@transducer/step'] = function(acc, value) { return R.add(acc, R.inc(value)); };這個transformer代表的就是最終的reducer函數的容器
R.transduce(R.map(R.inc), R.add, 0, [1,2,3,4]); // 與下面是等價的 const xf = R.map(R.inc)(_xwrap(R.add)); R.reduce(xf['@@transducer/step'], 0, [1,2,3,4]);總結一下
為了減少遍歷次數,用transduce替代reduce,把之前reduce過程的前置操作比如map,filter,find等操作在一次遍歷中完成。
為了實現這個transduce,以及在其上map,filter,find這種操作的可組合性,引入了transducer+transformer的概念。
這個transducer的概念最早是在Clojure里出現,有興趣的同學可以看看:https://video.tudou.com/v/XMjMxNTY2MDgzNg==.html?__fr=oldtd
fantasyland/map
最后一個參數?f上出現在?methodNames中的方法
根據最后一個參數?f返回新的?transformer
ramda默認處理邏輯
既然第2點講完了,開始這篇文章的最后一部分,這一部分與上面講的transducer沒有任何關系,這一部分也是本文想著重介紹的。
var map = _dispatchable(['fantasy-land/map', 'map'],...)從上面R.map的實現中可以看到,傳入_dispatchable的methodsName中,第一個方法名是fantasyland/map。
如果R.map(fn, obj),obj上有fantasyland/map方法,則R.map(fn, obj)等價于 obj['fantasyland/map'](fn)。
那么methodsName中另一個map和這個fantasyland/map有啥區別?為啥還有這么長的一個名字?
fantasyland規范
其實fantasyland/map這個名字是有特殊含義的,fantasyland/map沒有特定的實現,不過,如果你要實現這么一個方法,你需要遵循fantasyland規范。
所謂的fantasyland規范,其實就是一個文檔,這個文檔里規定了一些代數結構在javascript里實現的約束
Fantasy Land Specificationaka "Algebraic JavaScript Specification"
如果你在大學有接觸過《離散數學》的話,其中的一些概念會在這個規范中有具體的javascript定義,比如:二元關系(等價關系,全序關系),群,半群。當然,除了這3類數據結構,還有范疇以及在基礎代數結構上衍生出來的其它結構。
類型簽名
接下去我們會著重看一下與fantasy-land/map相關的定義,不過,在此之前有一些簡單的類型簽名,需要提前了解一下(下面的類型簽名解釋,是個人翻譯版本,如果你有興趣,可以直接看github上英文原版的解釋):
:: :“a屬于類型b”
e :: t:可以理解成:“e屬于類型t”
true :: Boolean:“ true 屬于 Boolean 類型”
42 :: Integer,Number :“42既屬于 Integer 也屬于 Number 類型”
通過類型構造函數可以構造一個新的類型
類型構造函數接受0個或多個參數
Array 就是一個類型構造函數,它接受一個類型作為參數
Array String 是存放著字符串的數組,像這幾個數組都是屬于 Array String :[],['foo', 'bar', 'baz']
Array(Array String) 是存放著數組的數組,存放的數組里面又存放著字符串,像這幾個數組都是屬于 Array(Array String):[],[[], []],[[], ['foo'], ['bar`, 'baz']]
小寫字母是類型變量
類型變量可以代表任何類型,除非用胖箭頭(下面有介紹)對它做類型約束
->(箭頭)函數的類型構造函數
-> 是一個中綴類型構造函數,這個類型構造函數接受兩個參數,箭頭左邊的參數是輸入類型,右邊的參數是輸出類型
-> 可以接受0個或多個輸入類型作為左邊的參數。語法:() ->,中的多個類型以“ , ”分隔。一元函數輸入參數旁邊的括號可以省略,比如:String -> Boolean,(String, String) -> Boolean
String -> Array String 對應一類函數:接受一個 String 類型的參數,然后返回一個類型為 Array String 的值
String -> Array String -> Array String 代表著一類函數:接受一個類型為String的輸入,輸出一個類型為 Array String -> Array String 的函數,這個輸出的函數接受一個類型為 Array String 的參數,輸出類型為 Array String 的值
(String, Array String) -> Array String代表著一類函數:接受兩個參數,第一個是String 類型,第二個是 Array String 類型,輸出類型為 Array String 的值
() -> Number 代表著一類函數:不接受輸入,返回一個類型為 Number 的值
~>(波浪箭頭)方法的類型構造函數
當一個函數是一個對象的屬性時,它被叫做這個對象上的“方法”。所有的“方法”都擁有一個隱含的參數類型-所在對象的類型
a ~> a -> a 代表著一類方法:是類型為 a 的對象上的方法,且這個方法接受一個類型為a 的參數,返回一個類型為 a 的值
=>(胖箭頭)胖箭頭用來對類型變量做類型約束
比如有這么一個方法 a ~> a -> a ,在這個方法的類型簽名中,a 可以代表任何類型。Semigroup a => a ~> a -> a,而這個類型簽名中就對類型變量 a 做了類型約束,使得類型 a 必須滿足類型類 Semigroup 。當一個類型滿足一個類型類的意思是,這個類型實現了所有類型類指定的函數/方法。
就拿這次我們要說的fantasy-land/map舉例:
fantasy-land/mapfantasy-land/map解析
先不管下面這部分
Functoru'fantasy-land/map' is equivalent to u (identity)u'fantasy-land/map') is equivalent to u'fantasy-land/map''fantasy-land/map' (composition)
直接看規范中對fantasy-land/map的定義:
fantasy-land/map :: Functor f => f a ~> (a -> b) -> f bFunctor是一個類型類,f 必須滿足 Functor, f a 代表了以 f 作為類型構造函數,類型 a 作為構造參數生成的類型,比如 Array String,代表字符串數組,Array 就是 f ,它滿足Functor類型類。
如果一個對象,是Functor實例(具體的值)。那么這個對象上需要存在一個名為 fantasy-land/map 的方法,這個方法必須接受一個函數作為參數:
u['fantasy-land/map'](f) // 舉個例子 [1,2,3]['fantasy-land/map'](f)f 必須是一個函數
如果 f 不是一個函數,fantasy-land/map 的行為是不確定的
f 可以返回任何類型的值
不應該檢測 f 的返回類型
fantasy-land/map 方法,必須返回一個相同的Functor(比如 [1,2,3]'fantasy-land/map'?必須返回也一個數組:Array)
其實可以類比 Array.prototype.map 方法,只是換了個名字而已。
那么說了這么多,Functor 是個什么東東?除了 Array 以外,還有什么是 Functor ?
其實 Function 也是 Functor ,驚喜嗎?
不賣關子了,Functor 的中文名是“函子”,接下來講講“函子”。
啥是函子
“函子”是范疇論中的概念,所以,在準備完全理解“函子”之前,你需要明白啥是“范疇”?
范疇
其實,在生活中,無處不充斥著范疇,只不過范疇論把這些東西抽象成了數學結構。
范疇此一概念代表著一堆數學實體和存在于這些實體間的關系。--維基百科
范疇的定義其實很簡單,就是實體的集合+實體間的關系。
那么什么是“實體”?這取決于你怎么看。
從集合的角度來說,實體是 a set of values ,首先它得是一個集合(set),其次,這個集合是由有好多的值組成(value)。
還是比較抽象,再具體一點,比如:一個類型可被看作為值的集合(a set of values),類型與類型之間的關系就是函數,所以一堆類型+類型之間的函數,就是范疇。
比如有下面這些函數:
fn1 :: Number-> String const fn1 = (a: number) => `${a}1`; fn2 :: String-> Boolean const fn2 = (a: string) => a === '1'; ...這些函數都是定義在Number和String上的映射關系。Number,String和Boolean,以及它們之間的映射關系,構成下面這個范疇
范疇在范疇論中,圖片中的 NUMBER , STRING 和 BOOLEAN 叫做“對象”(Object),fn1 和 fn2 叫做“態射”(Morphism), fn2 * fn1 叫做“態射復合”, NUMBER -> NUMBER 叫做單位態射。
明白什么是范疇之后,接下來說一說我們的主角:函子
函子
先來看看維基上的解釋:
在范疇論中,函子是范疇間的一類映射。函子也可以解釋為小范疇范疇內的態射。--維基百科
范疇和范疇也會有映射關系,如果把范疇視作一個對象時,函子就是范疇之間的態射。然后組成了一個范疇的范疇。
舉個例子:考慮一個基礎類型的范疇A,一個數組范疇B。
兩個范疇思考以下幾個問題:
Number 和 Array?之間的關系
String 和 Array?之間的關系
Number 到 String 的態射與 Array?到 Array?的態射的關系
之前介紹過 Array 是類型構造函數:
將 Number 傳進 Array ,構造出 Array
將 String 傳進 Array ,構造出 Array
可通過 Array 上的 map 方法會保持 Number -> String 映射到?Array<Number>->Array<String>
再回顧一下上文對函子的定義:
在范疇論中,函子是范疇間的一類映射。
上面例子中,范疇A到范疇B的映射其實就是類型構造函數 Array ,所以說, Array 就是函子。
函子這里省去了對公式上的定義的match,爭取大家對這個概念有感性的認識,如果想知道函子嚴謹的定義,可以看這里
回到fantasy-land/map
了解了函子的感性定義之后,回到嚴謹的規范上來。
之前解析 fantasy-land/map 的時候,有個定義一直沒有提及,就是 Functor , fantasy-land/map 在文檔中的位置其實是Functor的子標題,現在再來回顧一下。
Functor 1. u['fantasy-land/map'](a => a) is equivalent to u (identity) 2. u['fantasy-land/map'](x => f(g(x))) is equivalent to u['fantasy-land/map'](g)['fantasy-land/map'](f) (composition)通過對比函子的公式定義,解析Functor需滿足的條件(F即函子):
保持著單位態射(id即單位態射,idX即對象X上的單位態射)
保持著態射的復合
總結一下fantasyland規范中對函子的定義
如果實現一個函子,你需要在函子上實現 fantasy-land/map 方法,這個方法的類型簽名應該是這樣的:
fantasy-land/map :: Functor f => f a ~> (a -> b) -> f b函子實例調用方法 fantasy-land/map 時,需同時保持單位態射和態射的復合。
結尾
這篇文章不知不覺寫得有些長了,從Ramda文檔->源碼->transducer->fantasyland規范->范疇論->函子,算是自己完整的探索過程,希望能夠帶給你一些不一樣的東西。
參考文章
JavaScript玩轉Clojure大法之Transducer
Wikipedia 范疇論
Wikipedia 函子
關于本文作者:@Gloria原文:https://zhuanlan.zhihu.com/p/96059965
▼
原創系列推薦
▼
1. JavaScript 重溫系列(22篇全)
2. ECMAScript 重溫系列(10篇全)
3. JavaScript設計模式 重溫系列(9篇全)
4.?正則 / 框架 / 算法等 重溫系列(16篇全)
5.?Webpack4 入門(上)||?Webpack4 入門(下)
6.?MobX 入門(上)?||??MobX 入門(下)
7.?59篇原創系列匯總
回復“加群”與大佬們一起交流學習~
總結
以上是生活随笔為你收集整理的【JS】446- 你不知道的 map的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 张博涵清华大学_看了清华大学“神仙打架”
- 下一篇: 毅力就是下一番苦功