深入學習javascript函數語言程式設計

iamswf發表於2018-12-19

大家都知道JavaScript可以作為物件導向或者函式式程式語言來使用,一般情況下大家理解的函數語言程式設計無非包括副作用函式組合柯里化這些概念,其實並不然,如果往深瞭解學習會發現函數語言程式設計還包括非常多的高階特性,比如functormonad等。國外課程網站egghead上有個教授(名字叫Frisby)基於JavaScript講解的函數語言程式設計非常棒,主要介紹了boxsemigroupmonoidfunctorapplicative functormonadisomorphism等函數語言程式設計相關的高階主題內容。整個課程大概30節左右,本篇文章主要是對該課程的翻譯與總結,有精力的強烈推薦大家觀看原課程 Professor Frisby Introduces Composable Functional JavaScript 。課程最後有個小實踐專案大家可以練練手,體會下這種不同的程式設計方式。 這裡提前宣告下,本個課程裡面介紹的monad高階特性不見得大家都在專案中能用到,不過可以拓寬下知識面,另外也有助於學習haskell這類純函數語言程式設計

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, ''));
複製程式碼

BoxmoneyToFloat

const moneyToFloat = str =>
    Box(str)
    .map(s => s.replace(/\$/g, ''))
    .fold(r => parseFloat(r));
複製程式碼

我們這裡使用Box重構了moneyToFloatBox擅長的地方就在於將巢狀表示式轉成一個一個的map,這裡雖然不是很複雜,但卻是一種好的實踐方式。

命令式percentToFloat

const percentToFloat = str => {
  const replaced = str.replace(/\%/g, '');
  const number = parseFloat(replaced);
  return number * 0.01;
};
複製程式碼

BoxpercentToFloat

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稍微麻煩點,因為該函式有兩條資料流,不過我們可以藉助閉包:

BoxapplyDiscount

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"
複製程式碼

如果我們在moneyToFloatpercentToFloat中不進行拆箱(即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)"
複製程式碼

這裡我們暫且不實現Rightfold,而是先來實現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完全忽略了傳入的資料轉換函式,保持容器內部資料原樣。有了RightLeft,我們可以對程式資料流進行分支控制。考慮到程式中經常會存在異常,因此容器通常都是未知型別RightOrLeft

接下來我們實現RightLeft容器的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})`
});
複製程式碼

測試一下RightLeftfold方法:

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); // 'error'
複製程式碼

藉助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,因此導致箱子套了兩層,最後需要進行兩次拆箱。為了解決這種箱子套箱子的問題,我們可以給RightLeft增加一個方法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,
        preview => Object.assign({preview}, example)
    );
複製程式碼

6. 半群

半群是一種具有concat方法的型別,並且該concat方法滿足結合律。比如ArrayString

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安裝immutableimmutable-extimmutable-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就是這樣的,比如BoxRight

Box(3).fold(x => x); // 3
Right(3).fold(e => e, x => x); // 3
複製程式碼

沒錯,不過fold的本質就是拆箱。前面對BoxRight型別拆箱是將其值取出來;而現在對集合拆箱則是為了將集合的彙總結果取出來。而將一個集合中的多個值彙總成一個值就需要傳入初始值Sum.empty()。因此當你看到fold時,應該看成是為了從一個型別中取值出來,而這個型別可能是一個僅含一個值的型別(比如BoxRight),也可能是一個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以及一系列資料轉換函式,如果不呼叫forkTask中的console.log操作就不會執行。

13. 使用Task處理非同步任務

假設我們要實現讀檔案,替換檔案內容,然後寫檔案的操作,命令式程式碼如下:

const fs = require('fs');

const app = () =>
	fs.readFile('config.json', 'utf-8', (err, contents) => {
      if (err) throw err;
      const  newContents = 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可以將一個返回箱子型別的函式作用到一個包著的值上面,重點是作用之後包裝層數不增加:

先看個Boxfunctor的例子:

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)
複製程式碼

當然我們也可以定義類似的liftA3liftA4等工具函式:

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。

假設我們要計算頁面上除了headerfooter之外的高度:

const $ = selector =>
	either.of({selector, height: 10}); // fake DOM selector

const getScreenSize = (screen, header, footer) =>
	screen - (header.height + footer.height);
複製程式碼

如果使用monodchain方法,可以這樣實現:

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}`;
複製程式碼

如果使用monadchain實現,那麼兩個非同步事件只能順序執行:

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是一個值為TaskMap,而我們想要的是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' ]
複製程式碼

這裡我們藉助Arrayfilter方法來過濾String中的字元;藉助StringtoUpperCase方法來處理字元陣列的大小寫轉換。可見有了同構,我們可以在兩種不同的資料型別之間互相轉換並呼叫其方法。

27. 實戰

課程最後三節的實戰例子見:實戰

相關文章