深入理解JavaScript函數語言程式設計
JavaScript函數語言程式設計
大家都知道JavaScript
可以作為面向物件
或者函式式
程式語言來使用,一般情況下大家理解的函數語言程式設計
無非包括副作用
、函式組合
、柯里化
這些概念,其實並不然,如果往深瞭解學習會發現函數語言程式設計
還包括非常多的高階特性,比如functor
、monad
等。國外課程網站egghead
上有個教授(名字叫Frisby)基於JavaScript
講解的函數語言程式設計
非常棒,主要介紹了box
、semigroup
、monoid
、functor
、applicative functor
、monad
、isomorphism
等函數語言程式設計相關的高階主題內容。整個課程大概30節左右,本篇文章主要是對該課程的翻譯與總結,有精力的強烈推薦大家觀看原課程ofollow,noindex">Professor Frisby Introduces Composable Functional JavaScript
。
1. 使用容器(Box
)建立線性資料流
普通函式是這樣的:
function nextCharForNumberString (str) { const trimmed = str.trim(); const number = parseInt(trimmed); const nextNumber = number + 1; return String.fromCharCode(nextNumber); } const result = nextCharForNumberString(' 64'); console.log(result); // "A"
如果藉助Array,可以這樣實現:
const nextCharForNumberString = str => [str] .map(s => s.trim()) .map(s => parseInt(s)) .map(i => i + 1) .map(i => String.fromCharCode(i)); const result = nextCharForNumberString(' 64'); console.log(result); // ["A"]
這裡我們把資料str
裝進了一個箱子(陣列),然後連續多次呼叫箱子的map
方法來處理箱子內部的資料。這種實現已經可以感受到一些奇妙之處了。再看一種基本思想相同的實現方式,只不過這次我們不借助陣列,而是自己實現箱子:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), toString: () => `Box(${x})` }); const nextCharForNumberString = str => Box(str) .map(s => s.trim()) .map(s => parseInt(s)) .map(i => i + 1) .map(i => String.fromCharCode(i)); const result = nextCharForNumberString(' 64'); console.log(String(result)); // "Box(A)"
至此我們自己動手實現了一個箱子。連續使用map
可以組合一組操作,以建立線性的資料流。箱子中不僅可以放資料,還可以放函式,別忘了函式也是一等公民:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), toString: () => `Box(${x})` }); const f0 = x => x * 100; // think fo as a data const add1 = f => x => f(x) + 1; // think add1 as a function const add2 = f => x => f(x) + 2; // think add2 as a function const g = Box(f0) .map(f => add1(f)) .map(f => add2(f)) .fold(f => f); const res = g(1); console.log(res); // 103
這裡當你對一個函式容器呼叫map
時,其實是在做函式組合。
2. 使用Box
重構命令式程式碼
這裡使用的Box
跟上一節一樣:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), toString: () => `Box(${x})` });
命令式moneyToFloat
:
const moneyToFloat = str => parseFloat(str.replace(/\$/g, ''));
Box
式moneyToFloat
:
const moneyToFloat = str => Box(str) .map(s => s.replace(/\$/g, '')) .fold(r => parseFloat(r));
我們這裡使用Box
重構了moneyToFloat
,Box
擅長的地方就在於將巢狀表示式轉成一個一個的map
,這裡雖然不是很複雜,但卻是一種好的實踐方式。
命令式percentToFloat
:
const percentToFloat = str => { const replaced = str.replace(/\%/g, ''); const number = parseFloat(replaced); return number * 0.01; };
Box
式percentToFloat
:
const percentToFloat = str => Box(str) .map(str => str.replace(/\%/g, '')) .map(replaced => parseFloat(replaced)) .fold(number => number * 0.01);
我們這裡又使用Box
重構了percentToFloat
,顯然這種實現方式的資料流更加清晰。
命令式applyDiscount
:
const applyDiscount = (price, discount) => { const cost = moneyToFloat(price); const savings = percentToFloat(discount); return cost - cost * savings; };
重構applyDiscount
稍微麻煩點,因為該函式有兩條資料流,不過我們可以藉助閉包:
Box
式applyDiscount
:
const applyDiscount = (price, discount) => Box(price) .map(price => moneyToFloat(price)) .fold(cost => Box(discount) .map(discount => percentToFloat(discount)) .fold(savings => cost - cost * savings));
現在可以看一下這組程式碼的輸出了:
const result = applyDiscount('$5.00', '20%'); console.log(String(result)); // "4"
如果我們在moneyToFloat
和percentToFloat
中不進行拆箱(即fold
),那麼applyDiscount
就沒必要在資料轉換之前先裝箱(即Box
)了:
const moneyToFloat = str => Box(str) .map(s => s.replace(/\$/g, '')) .map(r => parseFloat(r)); // here we don't fold the result out const percentToFloat = str => Box(str) .map(str => str.replace(/\%/g, '')) .map(replaced => parseFloat(replaced)) .map(number => number * 0.01); // here we don't fold the result out const applyDiscount = (price, discount) => moneyToFloat(price) .fold(cost => percentToFloat(discount) .fold(savings => cost - cost * savings)); const result = applyDiscount('$5.00', '20%'); console.log(String(result)); // "4"
3. 使用Either
進行分支控制
Either
的意思是兩者之一,不是Right
就是Left
。我們先實現Right
:
const Right = x => ({ map: f => Right(f(x)), toString: () => `Right(${x})` }); const result = Right(3).map(x => x + 1).map(x => x / 2); console.log(String(result)); // "Right(2)"
這裡我們暫且不實現Right
的fold
,而是先來實現Left
:
const Left = x => ({ map: f => Left(x), toString: () => `Left(${x})` }); const result = Left(3).map(x => x + 1).map(x => x / 2); console.log(String(result)); // "Left(3)"
Left
容器跟Right
是不同的,因為Left
完全忽略了傳入的資料轉換函式,保持容器內部資料原樣。有了Right
和Left
,我們可以對程式資料流進行分支控制。考慮到程式中經常會存在異常,因此容器通常都是未知型別RightOrLeft
。
接下來我們實現Right
和Left
容器的fold
方法,如果未知容器是Right
,則使用第二個函式引數g
進行拆箱:
const Right = x => ({ map: f => Right(f(x)), fold: (f, g) => g(x), toString: () => `Right(${x})` });
如果未知容器是Left
,則使用第一個函式引數f
進行拆箱:
const Left = x => ({ map: f => Left(x), fold: (f, g) => f(x), toString: () => `Left(${x})` });
測試一下Right
和Left
的fold
方法:
const result = Right(2).map(x => x + 1).map(x => x / 2).fold(x => 'error', x => x); console.log(result); // 1.5
const result = Left(2).map(x => x + 1).map(x => x / 2).fold(x => 'error', x => x); console.log(result); // 2
藉助Either
我們可以進行程式流程分支控制,例如進行異常處理、null
檢查等。
下面看一個例子:
const findColor = name => ({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'})[name]; const result = findColor('red').slice(1).toUpperCase(); console.log(result); // "FF4444"
這裡如果我們給函式findColor
傳入green
,則會報錯。因此可以藉助Either
進行錯誤處理:
const findColor = name => { const found = {red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name]; return found ? Right(found) : Left(null); }; const result = findColor('green') .map(c => c.slice(1)) .fold(e => 'no color', c => c.toUpperCase()); console.log(result); // "no color"
更進一步,我們可以提煉出一個專門用於null
檢測的Either
容器,同時簡化findColor
程式碼:
const fromNullable = x => x != null ? Right(x) : Left(null); // [!=] will test both null and undefined const findColor = name => fromNullable({red: '#ff4444', blue: '#3b5998', yellow: '#fff68f'}[name]);
4. 利用chain
解決Either
的巢狀問題
看一個讀取配置檔案config.json
的例子,如果位置檔案讀取失敗則提供一個預設埠3000
,命令式程式碼實現如下:
const fs = require('fs'); const getPort = () => { try { const str = fs.readFileSync('config.json'); const config = JSON.parse(str); return config.port; } catch (e) { return 3000; } }; const result = getPort(); console.log(result); // 8888 or 3000
我們使用Either
重構:
const fs = require('fs'); const tryCatch = f => { try { return Right(f()); } catch (e) { return Left(e); } }; const getPort = () => tryCatch(() => fs.readFileSync('config.json')) .map(c => JSON.parse(c)) .fold( e => 3000, obj => obj.port ); const result = getPort(); console.log(result); // 8888 or 3000
重構後就完美了嗎?我們用到了JSON.parse
,如果config.json
檔案格式有問題,程式就會報錯:
SyntaxError: Unexpected end of JSON input
因此需要針對JSON
解析失敗做異常處理,我們可以繼續使用tryCatch
來解決這個問題:
const getPort = () => tryCatch(() => fs.readFileSync('config.json')) .map(c => tryCatch(() => JSON.parse(c))) .fold( left => 3000, // 第一個tryCatch失敗 right => right.fold( // 第一個tryCatch成功 e => 3000, // JSON.parse失敗 c => c.port ) );
這次重構我們使用了兩次tryCatch
,因此導致箱子套了兩層,最後需要進行兩次拆箱。為了解決這種箱子套箱子的問題,我們可以給Right
和Left
增加一個方法chain
:
const Right = x => ({ chain: f => f(x), map: f => Right(f(x)), fold: (f, g) => g(x), toString: () => `Right(${x})` }); const Left = x => ({ chain: f => Left(x), map: f => Left(x), fold: (f, g) => f(x), toString: () => `Left(${x})` });
當我們使用map
,又不想在資料轉換之後又增加一層箱子時,我們應該使用chain
:
const getPort = () => tryCatch(() => fs.readFileSync('config.json')) .chain(c => tryCatch(() => JSON.parse(c))) .fold( e => 3000, c => c.port );
5. 命令式程式碼使用Either
實現舉例
const openSite = () => { if (current_user) { return renderPage(current_user); } else { return showLogin(); } }; const openSite = () => fromNullable(current_user) .fold(showLogin, renderPage);
const streetName = user => { const address = user.address; if (address) { const street = address.street; if (street) { return street.name; } } return 'no street'; }; const streetName = user => fromNullable(user.address) .chain(a => fromNullable(a.street)) .map(s => s.name) .fold( e => 'no street', n => n );
const concatUniq = (x, ys) => { const found = ys.filter(y => y ===x)[0]; return found ? ys : ys.concat(x); }; const cancatUniq = (x, ys) => fromNullable(ys.filter(y => y ===x)[0]) .fold(null => ys.concat(x), y => ys);
const wrapExamples = example => { if (example.previewPath) { try { example.preview = fs.readFileSync(example.previewPath); } catch (e) {} } return example; }; const wrapExamples = example => fromNullable(example.previewPath) .chain(path => tryCatch(() => fs.readFileSync(path))) .fold( () => example, preivew => Object.assign({preview}, example) );
6. 半群
半群是一種具有concat
方法的型別,並且該concat
方法滿足結合律。比如Array
和String
:
const res = "a".concat("b").concat("c"); const res = [1, 2].concat([3, 4].concat([5, 6])); // law of association
我們自定義Sum
半群,Sum
型別用來求和:
const Sum = x => ({ x, concat: o => Sum(x + o.x), toString: () => `Sum(${x})` }); const res = Sum(1).concat(Sum(2)); console.log(String(res)); // "Sum(3)"
繼續自定義All
半群,All
型別用來級聯布林型別:
const All = x => ({ x, concat: o => All(x && o.x), toString: () => `All(${x})` }); const res = All(true).concat(All(false)); console.log(String(res)); // "All(false)"
繼續定義First
半群,First
型別鏈式呼叫concat
方法不改變其初始值:
const First = x => ({ x, concat: o => First(x), toString: () => `First(${x})` }); const res = First('blah').concat(First('ice cream')); console.log(String(res)); // "First(blah)"
7. 半群舉例
這裡先佔位,回頭再補充。
const acct1 = Map({ name: First('Nico'), isPaid: All(true), points: Sum(10), friends: ['Franklin'] }); const acct2 = Map({ name: First('Nico'), isPaid: All(false), points: Sum(2), friends: ['Gatsby'] }); const res = acct1.concat(acct2); console.log(res);
8. monoid
半群滿足結合律,如果半群還具有么元(單位元),那麼就是monoid。么元與其他元素結合時不會改變那些元素,可以用公式表示如下:
e・a = a・e = a
我們將半群Sum
升級實現為monoid只需實現一個empty
方法,呼叫改方法即可得到該monoid的么元:
const Sum = x => ({ x, concat: o => Sum(x + o.x), toString: () => `Sum(${x})` }); Sum.empty = () => Sum(0); const res = Sum.empty().concat(Sum(1).concat(Sum(2))); // const res = Sum(1).concat(Sum(2)).concat(Sum.empty()); console.log(String(res)); // "Sum(3)"
接著我們繼續將All
升級實現為monoid:
const All = x => ({ x, concat: o => All(x && o.x), toString: () => `All(${x})` }); All.empty = () => All(true); const res = All(true).concat(All(true)).concat(All.empty()); console.log(String(res)); // "All(true)"
如果我們嘗試著將半群First
也升級為monoid就會發現不可行,比如First('hello').concat(…)
的結果恆為hello
,但是First.empty().concat(First('hello'))
的結果就不一定是hello
了,因此我們無法將半群First
升級為monoid。這也說明monoid一定是半群,但是半群不一定是monoid。半群需要滿足結合律,monoid不僅需要滿足結合律,還必須存在么元。
9. monoid舉例
Sum(求和):
const Sum = x => ({ x, concat: o => Sum(x + o.x), toString: () => `Sum(${x})` }); Sum.empty = () => Sum(0);
Product(求積):
const Product = x => ({ x, concat: o => Product(x * o.x), toString: () => `Product(${x})` }); Product.empty = () => Product(1); const res = Product.empty().concat(Product(2)).concat(Product(3)); console.log(String(res)); // "Product(6)"
Any(只要有一個為true
即返回true
,否則返回false
):
const Any = x => ({ x, concat: o => Any(x || o.x), toString: () => `Any(${x})` }); Any.empty = () => Any(false); const res = Any.empty().concat(Any(false)).concat(Any(false)); console.log(String(res)); // "Any(false)"
All(所有均為true
才返回true
,否則返回false
):
const All = x => ({ x, concat: o => All(x && o.x), toString: () => `All(${x})` }); All.empty = () => All(true); const res = All(true).concat(All(true)).concat(All.empty()); console.log(String(res)); // "All(true)"
Max(求最大值):
const Max = x => ({ x, concat: o => Max(x > o.x ? x : o.x), toString: () => `Max(${x})` }); Max.empty = () => Max(-Infinity); const res = Max.empty().concat(Max(100)).concat(Max(200)); console.log(String(res)); // "Max(200)"
Min(求最小值):
const Min = x => ({ x, concat: o => Min(x < o.x ? x : o.x), toString: () => `Min(${x})` }); Min.empty = () => Min(Infinity); const res = Min.empty().concat(Min(100)).concat(Min(200)); console.log(String(res)); // "Min(100)"
10. 使用foldMap
對集合彙總
假設我們需要對一個Sum
集合進行彙總,可以這樣實現:
const res = [Sum(1), Sum(2), Sum(3)] .reduce((acc, x) => acc.concat(x), Sum.empty()); console.log(res); // Sum(6)
考慮到這個操作的一般性,可以抽成一個函式fold
。用node
安裝immutable
和immutable-ext
。immutable-ext
提供了fold
方法:
const {Map, List} = require('immutable-ext'); const {Sum} = require('./monoid'); const res = List.of(Sum(1), Sum(2), Sum(3)) .fold(Sum.empty()); console.log(res); // Sum(6)
也許你會覺得fold
接受的引數應該是一個函式,因為前面幾節介紹的fold
就是這樣的,比如Box
和Right
:
Box(3).fold(x => x); // 3 Right(3).fold(e => e, x => x); // 3
沒錯,不過fold
的本質就是拆箱。前面對Box
和Right
型別拆箱是將其值取出來;而現在對集合拆箱則是為了將集合的彙總結果取出來。而將一個集合中的多個值彙總成一個值就需要傳入初始值Sum.empty()
。因此當你看到fold
時,應該看成是為了從一個型別中取值出來,而這個型別可能是一個僅含一個值的型別(比如Box
,Right
),也可能是一個monoid集合。
我們繼續看另外一種集合Map
:
const res = Map({brian: Sum(3), sara: Sum(5)}) .fold(Sum.empty()); console.log(res); // Sum(8)
這裡的Map
是monoid集合,如果是普通資料集合可以先使用集合的map
方法將該集合轉換成monoid集合:
const res = Map({brian: 3, sara: 5}) .map(Sum) .fold(Sum.empty()); console.log(res); // Sum(8)
const res = List.of(1, 2, 3) .map(Sum) .fold(Sum.empty()); console.log(res); // Sum(6)
我們可以把這種對普通資料型別集合呼叫map
轉換成monoid型別集合,然後再呼叫fold
進行資料彙總的操作抽出來,即為foldMap
:
const res = List.of(1, 2, 3) .foldMap(Sum, Sum.empty()); console.log(res); // Sum(6)
11. 使用LazyBox
延遲求值
首先回顧一下前面Box
的例子:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), toString: () => `Box(${x})` }); const res = Box(' 64') .map(s => s.trim()) .map(s => parseInt(s)) .map(i => i + 1) .map(i => String.fromCharCode(i)) .fold(x => x.toLowerCase()); console.log(String(res)); // a
這裡進行了一系列的資料轉換,最後轉換成了a
。現在我們可以定義一個LazyBox
,延遲執行這一系列資料轉換函式,直到最後扣動扳機:
const LazyBox = g => ({ map: f => LazyBox(() => f(g())), fold: f => f(g()) }); const res = LazyBox(() => ' 64') .map(s => s.trim()) .map(s => parseInt(s)) .map(i => i + 1) .map(i => String.fromCharCode(i)) .fold(x => x.toLowerCase()); console.log(res); // a
LazyBox
的引數是一個引數為空的函式。在LazyBox
上呼叫map
並不會立即執行傳入的資料轉換函式,每呼叫一次map
待執行函式佇列中就會多一個函式,直到最後呼叫fold
扣動扳機,前面所有的資料轉換函式一觸一發,一個接一個的執行。這種模式有助於實現純函式。
12. 在Task
中捕獲副作用
本節依然是討論Lazy特性,只不過基於data.task
庫,該庫可以通過npm安裝。假設我們要實現一個發射火箭的函式,如果我們這樣實現,那麼該函式顯然不是純函式:
const launchMissiles = () => console.log('launch missiles!'); // 使用console.log模仿發射火箭
如果使用data.task
可以藉助其Lazy特性,延遲執行:
const Task = require('data.task'); const launchMissiles = () => new Task((rej, res) => { console.log('launch missiles!'); res('missile'); });
顯然這樣實現launchMissiles
即為純函式。我們可以繼續在其基礎上組合其他邏輯:
const app = launchMissiles().map(x => x + '!'); app .map(x => x + '!') .fork( e => console.log('err', e), x => console.log('success', x) ); // launch missiles! // success missile!!
呼叫fork
方法才會扣動扳機,執行前面定義的Task
以及一系列資料轉換函式,如果不呼叫fork
,Task
中的console.log
操作就不會執行。
13. 使用Task
處理非同步任務
假設我們要實現讀檔案,替換檔案內容,然後寫檔案的操作,命令式程式碼如下:
const fs = require('fs'); const app = () => fs.readFile('config.json', 'utf-8', (err, contents) => { if (err) throw err; constnewContents = contents.replace(/8/g, '6'); fs.writeFile('config1.json', newContents, (err, success) => { if (err) throw err; console.log('success'); }) }); app();
這裡實現的app
內部會丟擲異常,不是純函式。我們可以藉助Task
重構如下:
const Task = require('data.task'); const fs = require('fs'); const readFile = (filename, enc) => new Task((rej, res) => fs.readFile(filename, enc, (err, contents) => err ? rej(err) : res(contents))); const writeFile = (filename, contents) => new Task((rej, res) => fs.writeFile(filename, contents, (err, success) => err ? rej(err) : res(success))); const app = () => readFile('config.json', 'utf-8') .map(contents => contents.replace(/8/g, '6')) .chain(contents => writeFile('config1.json', contents)); app().fork( e => console.log(e), x => console.log('success') );
這裡實現的app
是純函式,呼叫app().fork
才會執行一系列動作。再看看data.task
官網的順序讀兩個檔案的例子:
const fs = require('fs'); const Task = require('data.task'); const readFile = path => new Task((rej, res) => fs.readFile(path, 'utf-8', (error, contents) => error ? rej(error) : res(contents))); const concatenated = readFile('Task_test_file1.txt') .chain(a => readFile('Task_test_file2.txt') .map(b => a + b)); concatenated.fork(console.error, console.log);
14. Functor
Functor是具有map
方法的型別,並且需要滿足下面兩個條件:
fx.map(f).map(g) == fx.map(x => g(f(x))) fx.map(id) == id(fx), where const id = x => x
以Box
型別為例說明:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), inspect: () => `Box(${x})` }); const res1 = Box('squirrels') .map(s => s.substr(5)) .map(s => s.toUpperCase()); const res2 = Box('squirrels') .map(s => s.substr(5).toUpperCase()); console.log(res1, res2); // Box(RELS) Box(RELS)
顯然Box
滿足第一個條件。注意這裡的s = > s.substr(5).toUpperCase()
其實本質上跟g(f(x))
是一樣的,我們完全重新定義成下面這種形式,不要被形式迷惑:
const f = s => s.substr(5); const g = s => s.toUpperCase(); const h = s => g(f(s)); const res = Box('squirrels') .map(h); console.log(res); // Box(RELS)
接下來我們看是否滿足第二個條件:
const id = x => x; const res1 = Box('crayons').map(id); const res2 = id(Box('crayons')); console.log(res1, res2); // Box(crayons) Box(crayons)
顯然也滿足第二個條件。
15. 使用of
方法將值放入Pointed Functor
pointed functor是具有of
方法的functor,of
可以理解成使用一個初始值來填充functor。以Box
為例說明:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), inspect: () => `Box(${x})` }); Box.of = x => Box(x); const res = Box.of(100); console.log(res); // Box(100)
這裡再舉個functor的例子,IO functor:
const R = require('ramda'); const IO = x => ({ x, // here x is a function map: f => IO(R.compose(f, x)), fold: f => f(x) // get out x }); IO.of = x => IO(x);
IO是一個值為函式的容器,細心的話你會發現這就是前面的值為函式的Box
容器。藉助IO functor,我們可以純函式式的處理一些IO操作了,因為讀寫操作就好像全部放入了佇列一樣,直到最後呼叫IO內部的函式時才會扣動扳機執行一系列操作,試一下:
const R = require('ramda'); const {IO} = require('./IO'); const fake_window = { innerWidth: '1000px', location: { href: "http://www.baidu.com/cpd/fe" } }; const io_window = IO(() => fake_window); const getWindowInnerWidth = io_window .map(window => window.innerWidth) .fold(x => x); const split = x => s => s.split(x); const getUrl = io_window .map(R.prop('location')) .map(R.prop('href')) .map(split('/')) .fold(x => x); console.log(getWindowInnerWidth()); // 1000px console.log(getUrl()); // [ 'http:', '', 'www.baidu.com', 'cpd', 'fe' ]
16. Monad
functor可以將一個函式作用到一個包著的(這裡“包著”意思是值存在於箱子內,下同)值上面:
Box(1).map(x => x + 1); // Box(2)
applicative functor可以將一個包著的函式作用到一個包著的值上面:
const add = x => x + 1; Box(add).ap(Box(1)); // Box(2)
而monod可以將一個返回箱子型別的函式作用到一個包著的值上面,重點是作用之後包裝層數不增加:
先看個Box
functor的例子:
const Box = x => ({ map: f => Box(f(x)), fold: f => f(x), inspect: () => `Box(${x})` }); const res = Box(1) .map(x => Box(x)) .map(x => Box(x)); // Box(Box(Box(1))) console.log(res); // Box([object Object])
這裡我們連續呼叫map
並且map
時傳入的函式的返回值是箱子型別,顯然這樣會導致箱子的包裝層數不斷累加,我們可以給Box
增加join
方法來拆包裝:
const Box = x => ({ map: f => Box(f(x)), join: () => x, fold: f => f(x), inspect: () => `Box(${x})` }); const res = Box(1) .map(x => Box(x)) .join() .map(x => Box(x)) .join(); console.log(res); // Box(1)
這裡定義join
僅僅是為了說明拆包裝這個操作,我們當然可以使用fold
完成相同的功能:
const Box = x => ({ map: f => Box(f(x)), join: () => x, fold: f => f(x), inspect: () => `Box(${x})` }); const res = Box(1) .map(x => Box(x)) .fold(x => x) .map(x => Box(x)) .fold(x => x); console.log(res); // Box(1)
考慮到.map(...).join()
的一般性,我們可以為Box
增加一個方法chain
完成這兩步操作:
const Box = x => ({ map: f => Box(f(x)), join: () => x, chain: f => Box(x).map(f).join(), fold: f => f(x), inspect: () => `Box(${x})` }); const res = Box(1) .chain(x => Box(x)) .chain(x => Box(x)); console.log(res); // Box(1)
17. 柯里化
這個非常簡單,直接舉例,能看懂這些例子就明白柯里化了:
const modulo = dvr => dvd => dvd % dvr; const isOdd = modulo(2); // 求奇數 const filter = pred => xs => xs.filter(pred); const getAllOdds = filter(isOdd); const res1 = getAllOdds([1, 2, 3, 4]); console.log(res1); // [1, 3] const map = f => xs => xs.map(f); const add = x => y => x + y; const add1 = add(1); const allAdd1 = map(add1); const res2 = allAdd1([1, 2, 3]); console.log(res2); // [2, 3, 4]
18. Applicative Functor
前面介紹的Box
是一個functor,我們為其新增ap
方法,將其升級成applicative functor:
const Box = x => ({ ap: b2 => b2.map(x), // here x is a function map: f => Box(f(x)), fold: f => f(x), inspect: () => `Box(${x})` }); const res = Box(x => x + 1).ap(Box(2)); console.log(res); // Box(3)
這裡Box
內部是一個一元函式,我們也可以使用柯里化後的多元函式:
const add = x => y => x + y; const res = Box(add).ap(Box(2)); console.log(res); // Box([Function])
顯然我們applicative functor上呼叫一次ap
即可消掉一個引數,這裡res
內部存的是仍然是一個函式:y => 2 + y
,只不過消掉了引數x
。我們可以連續呼叫ap
方法:
const res = Box(add).ap(Box(2)).ap(Box(3)); console.log(res); // Box(5)
稍加思考我們會發現對於applicative functor,存在下面這個恆等式:
F(x).map(f) = F(f).ap(F(x))
即在一個儲存值x
的functor上呼叫map(f)
,恆等於在儲存函式f
的functor上呼叫ap(F(x))
。
接著我們實現一個處理applicative functor的工具函式liftA2
:
const liftA2 = (f, fx, fy) => F(f).ap(fx).ap(fy);
但是這裡需要知道具體的functor型別F
,因此藉助於前面的恆等式,我們繼續定義下面的一般形式liftA2
:
const liftA2 = (f, fx, fy) => fx.map(f).ap(fy);
試一下:
const res1 = Box(add).ap(Box(2)).ap(Box(4)); const res2 = liftA2(add, Box(2), Box(4)); // utilize helper function liftA2 console.log(res1); // Box(6) console.log(res2); // Box(6)
當然我們也可以定義類似的liftA3
,liftA4
等工具函式:
const liftA3 = (f, fx, fy, fz) => fx.map(f).ap(fy).ap(fz);
19. Applicative Functor舉例
首先來定義either
:
const Right = x => ({ ap: e2 => e2.map(x), // declare as a applicative, here x is a function chain: f => f(x), // declare as a monad map: f => Right(f(x)), fold: (f, g) => g(x), inspect: () => `Right(${x})` }); const Left = x => ({ ap: e2 => e2.map(x), // declare as a applicative, here x is a function chain: f => Left(x), // declare as a monad map: f => Left(x), fold: (f, g) => f(x), inspect: () => `Left(${x})` }); const fromNullable = x => x != null ? Right(x) : Left(null); // [!=] will test both null and undefined const either = { Right, Left, of: x => Right(x), fromNullable };
可以看出either
既是monad又是applicative functor。
假設我們要計算頁面上除了header
和footer
之外的高度:
const $ = selector => either.of({selector, height: 10}); // fake DOM selector const getScreenSize = (screen, header, footer) => screen - (header.height + footer.height);
如果使用monod
的chain
方法,可以這樣實現:
const res = $('header') .chain(header => $('footer').map(footer => getScreenSize(800, header, footer))); console.log(res); // Right(780)
也可以使用applicative
實現,不過首先需要柯里化getScreenSize
:
const getScreenSize = screen => header => footer => screen - (header.height + footer.height); const res1 = either.of(getScreenSize(800)) .ap($('header')) .ap($('footer')); const res2 = $('header') .map(getScreenSize(800)) .ap($('footer')); const res3 = liftA2(getScreenSize(800), $('header'), $('footer')); console.log(res1, res2, res3); // Right(780) Right(780) Right(780)
20. Applicative Functor之List
本節介紹使用applicative functor實現下面這種模式:
for (x in xs) { for (y in ys) { for (z in zs) { // your code here } } }
使用applicative functor重構如下:
const {List} = require('immutable-ext'); const merch = () => List.of(x => y => z => `${x}-${y}-${z}`) .ap(List(['teeshirt', 'sweater'])) .ap(List(['large', 'medium', 'small'])) .ap(List(['black', 'white'])); const res = merch(); console.log(res);
21. 使用applicatives處理併發非同步事件
假設我們要發起兩次讀資料庫的請求:
const Task = require('data.task'); const Db = ({ find: id => new Task((rej, res) => setTimeOut(() => { console.log(res); res({id: id, title: `Project ${id}`}) }, 5000)) }); const report = (p1, p2) => `Report: ${p1.title} compared to ${p2.title}`;
如果使用monad
的chain
實現,那麼兩個非同步事件只能順序執行:
Db.find(20).chain(p1 => Db.find(8).map(p2 => report(p1, p2))) .fork(console.error, console.log);
使用applicatives重構:
Task.of(p1 => p2 => report(p1, p2)) .ap(Db.find(20)) .ap(Db.find(8)) .fork(console.error, console.log);
22. [Task] => Task([])
假設我們準備讀取一組檔案:
const fs = require('fs'); const Task = require('data.task'); const futurize = require('futurize').futurize(Task); const {List} = require('immutable-ext'); const readFile = futurize(fs.readFile); const files = ['box.js', 'config.json']; const res = files.map(fn => readFile(fn, 'utf-8')); console.log(res); // [ Task { fork: [Function], cleanup: [Function] }, //Task { fork: [Function], cleanup: [Function] } ]
這裡res
是一個Task
陣列,而我們想要的是Task([])
這種型別,類似promise.all()
的功能。我們可以藉助traverse
方法使Task
型別從數組裡跳到外面:
[Task] => Task([])
實現如下:
const files = List(['box.js', 'config.json']); files.traverse(Task.of, fn => readFile(fn, 'utf-8')) .fork(console.error, console.log);
23. {Task} => Task({})
假設我們準備發起一組http請求:
const fs = require('fs'); const Task = require('data.task'); const {List, Map} = require('immutable-ext'); const httpGet = (path, params) => Task.of(`${path}: result`); const res = Map({home: '/', about: '/about', blog: '/blod'}) .map(route => httpGet(route, {})); console.log(res); // Map { "home": Task, "about": Task, "blog": Task }
這裡res
是一個值為Task
的Map
,而我們想要的是Task({})
這種型別,類似promise.all()
的功能。我們可以藉助traverse
方法使Task
型別從Map
裡跳到外面:
{Task} => Task({})
實現如下:
Map({home: '/', about: '/about', blog: '/blod'}) .traverse(Task.of, route => httpGet(route, {})) .fork(console.error, console.log); // Map { "home": "/: result", "about": "/about: result", "blog": "/blod: result" }
24. 型別轉換
本節介紹一種functor如何轉換成另外一種functor。例如將either
轉換成Task
:
const {Right, Left, fromNullable} = require('./either'); const Task = require('data.task'); const eitherToTask = e => e.fold(Task.rejected, Task.of); eitherToTask(Right('nightingale')) .fork( e => console.error('err', e), r => console.log('res', r) ); // res nightingale eitherToTask(Left('nightingale')) .fork( e => console.error('err', e), r => console.log('res', r) ); // err nightingale
將Box
轉換成Either
:
const {Right, Left, fromNullable} = require('./either'); const Box = require('./box'); const boxToEither = b => b.fold(Right); const res = boxToEither(Box(100)); console.log(res); // Right(100)
你可能會疑惑為什麼boxToEither
要轉換成Right
,而不是Left
,原因就是本節討論的型別轉換需要滿足該條件:
nt(fx).map(f) == nt(fx.map(f))
其中nt
是natural transform的縮寫,即自然型別轉換,所有滿足該公式的函式均為自然型別轉換。接著討論boxToEither
,如果前面轉換成Left
,我們看下是否還能滿足該公式:
const boxToEither = b => b.fold(Left); const res1 = boxToEither(Box(100)).map(x => x * 2); const res2 = boxToEither(Box(100).map(x => x * 2)); console.log(res1, res2); // Left(100) Left(200)
顯然不滿足上面的條件。
再看一個自然型別轉換函式first
:
const first = xs => fromNullable(xs[0]); const res1 = first([1, 2, 3]).map(x => x + 1); const res2 = first([1, 2, 3].map(x => x + 1)); console.log(res1, res2); // Right(2) Right(2)
前面的公式表明,對於一個functor
,先進行自然型別轉換再map
等價於先map
再進行自然型別轉換。
25. 型別轉換舉例
先看下first
的一個用例:
const {fromNullable} = require('./either'); const first = xs => fromNullable(xs[0]); const largeNumbers = xs => xs.filter(x => x > 100); const res = first(largeNumbers([2, 400, 5, 1000]).map(x => x * 2)); console.log(res); // Right(800)
這種實現沒什麼問題,不過這裡將large numbers的每個值都進行了乘2的map
,而我麼最後的結果僅僅需要第一個值,因此借用自然型別轉換公式我們可以改成下面這種形式:
const res = first(largeNumbers([2, 400, 5, 1000])).map(x => x * 2); console.log(res); // Right(800)
再看一個稍微複雜點的例子:
const {Right, Left} = require('./either'); const Task = require('data.task'); const fake = id => ({ id, name: 'user1', best_friend_id: id + 1 }); // fake user infomation const Db = ({ find: id => new Task((rej, res) => res(id > 2 ? Right(fake(id)) : Left('not found'))) }); // fake database const eitherToTask = e => e.fold(Task.rejected, Task.of);
這裡我們模擬了一個數據庫以及一些使用者資訊,並假設資料庫中只能夠查到id
大於2的使用者。
現在我們要查詢某個使用者的好朋友的資訊:
Db.find(3) // Task(Right(user)) .map(either => either.map(user => Db.find(user.best_friend_id))) // Task(Either(Task(Either)))
如果這裡使用chain
,看一下效果如何:
Db.find(3) // Task(Right(user)) .chain(either => either.map(user => Db.find(user.best_friend_id))) // Either(Task(Either))
這樣呼叫完之後也有有問題:容器的型別從Task
變成了Either
,這也不是我們想看到的。下面我們藉助自然型別轉換重構一下:
Db.find(3) // Task(Right(user)) .map(eitherToTask) // Task(Task(user))
為了去掉一層包裝,我們改用chain
:
Db.find(3) // Task(Right(user)) .chain(eitherToTask) // Task(user) .chain(user => Db.find(user.best_friend_id)) // Task(Right(user)) .chain(eitherToTask) .fork( console.error, console.log ); // { id: 4, name: 'user1', best_friend_id: 5 }
26. 同構(isomorphrism)
這裡討論的同構不是“前後端同構”的同構,而是一對滿足如下要求的函式:
from(to(x)) == x to(from(y)) == y
如果能夠找到一對函式滿足上述要求,則說明一個數據型別x
具有與另一個數據型別y
相同的資訊或結構,此時我們說資料型別x
和資料型別y
是同構的。比如String
和[char]
就是同構的:
const Iso = (to, from) =>({ to, from }); // String ~ [char] const chars = Iso(s => s.split(''), arr => arr.join('')); const res1 = chars.from(chars.to('hello world')); const res2 = chars.to(chars.from(['a', 'b', 'c'])); console.log(res1, res2); // hello world [ 'a', 'b', 'c' ]
這有什麼用呢?我們舉個例子:
const filterString = (str1, str2, pred) => chars.from(chars.to(str1 + str2).filter(pred)); const res1 = filterString('hello', 'HELLO', x => x.match(/[aeiou]/ig)); console.log(res1); // eoEO const toUpperCase = (arr1, arr2) => chars.to(chars.from(arr1.concat(arr2)).toUpperCase()); const res2 = toUpperCase(['h', 'e', 'l', 'l', 'o'], ['w', 'o', 'r', 'l', 'd']); console.log(res2); // [ 'H', 'E', 'L', 'L', 'O', 'W', 'O', 'R', 'L', 'D' ]
這裡我們藉助Array
的filter
方法來過濾String
中的字元;藉助String
的toUpperCase
方法來處理字元陣列的大小寫轉換。可見有了同構,我們可以在兩種不同的資料型別之間互相轉換並呼叫其方法。
27. 實戰
課程最後三節的實戰例子見:實戰 。