在javascript中使用純函式處理副作用

leslee同學發表於2019-05-24

在javascript中使用純函式處理副作用

今天給大家帶來一片譯文, 詳情請點選這裡.可能在牆內哦

開始了, 如果你點開這篇文章, 就證明你已經開始涉及函數語言程式設計了, 這距離你知道純函式的概念不會很久. 如果你繼續下去, 你就知道純函式是真重要, 沒有它你將寸步難行.你可能聽過這樣的話: "純函式讓你理清你的程式碼", "純函式不可能會引起副作用","純函式式引用透明的". 這些話沒錯, 純函式是個好東西, 不過它還是存在著一些問題...

純函式的概念

一個純函式是一個沒有副作用的函式, 但是如果你對程式設計有所瞭解, 你就知道副作用是不可避免的. 我們把π計算到一百位但是又沒有人能去記住它(最強大腦就忽略不計了) 所以我們要在某個地方把他列印出來, 我們就需要寫一個 console 或者把資料傳入印表機, 或者把他給某個能展示他的東西; 我們要把資料放進資料庫, 我們需要閱讀輸入裝置的資料, 需要從網路上獲取資訊, 這些都是副作用. 但是呢, 函數語言程式設計主要靠的又是純函式, 那麼我們如何去用函數語言程式設計去管理副作用呢?

簡單的回答是: 幹數學家乾的事情(障眼法) 表面上, 數學家們技術上沿著規則來進行研究, 但是他們又會從那些規則中找到一個漏洞, 並且會奮力的把這些漏洞放大到能讓一隻大象走過去.

有兩個主要的漏洞來幹這個事情

  1. 依賴注入 dependency injection
  2. 使用 effect functor (名詞: 只能意會, 不能言傳)

依賴注入

依賴注入是我們處理副作用的第一種方法, 在這個例子中, 我們在程式碼中引入一些不純的東西,比如, 列印一個字串;

// logSomething :: String -> String      
// 上面是函式的一種描述, logSomething 是函式的名字, 後面一個是引數型別, 一個是返回值型別
function logSomething(something) {
    const dt = (new Date())toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}
複製程式碼

我們的 logSomething 有兩個不純的東西, 一個是我們建立了一個 Date 例項, 一個是我們執行了 console.log, 我們不僅僅是執行了 IO 操作, 而且每次執行這個函式, 都會有不同的結果. 所以, 你要怎麼把這個函式變成純函式呢? 用依賴注入, 我們把不純的源頭都變成函式的引數, 所以, 我們的這個函式需要用 3 個引數, 修改後的程式碼如下;

// logSomething: Date -> Console -> String -> *
function logSomething(date, console, something) {
    const dt = date.toISOString();
    console.log(`${dt}: ${something}`);
    return something;
}
複製程式碼

然後我們可以執行他了, 但是我們必須要明確的知道哪一個引數會引起副作用.

const something = "Curiouser and curiouser!"
const d = new Date();
logSomething(d, console, something);
複製程式碼

現在你可能會想, "這也太傻了吧! 我們做的事情, 只是把問題轉移到上一級, 我們的這個函式依然是不純的啊.... " yes that right 確實, 好像並沒有什麼卵用.

這就好比假裝不知道: "oh 不 長官, 我完全不知道在 console 上呼叫 log() 會引發 IO 操作, 這是別人傳給我的一個引數, 我甚至都不知道這個物件是從哪裡來的"; 這看起來有點蹩腳.

雖然是這樣, 但是這並沒有看起來的那麼愚蠢, 注意下我們的 logSomething 函式中的下面這一點, 如果你想讓他做一些不純的東西, 你不得不讓他是不純的. 我們只需要很簡單的傳入一個不同的引數, 他就可以變成純的.

const d = {toISOString: () => '1865-11-26T16:00:00.000Z'};
const cnsl = {
    log: () => {
        // do nothing
    },
};
logSomething(d, cnsl, "Off with their heads!");
複製程式碼

現在我們的函式除了返回一個字串, 什麼東西也沒做, 但是他是一個純函式, 如果你使用同樣的引數去呼叫這個函式, 它總會返回同樣的值, 這就是重點. 要讓它不純, 我們需要刻意的去做, 或者換一種方式去做, 這個函式的所有依賴都已經在引數中了, 他不會接受任何像 console, Date 這樣的全域性物件, 這讓一切都很清晰.

還有一個很重要的點, 我們可以傳入一個函式到我們原來不純的函式, 讓我們來看另外一個例子, 想象一下我們在 form 表單的某處有一個 username, 我們可能回這樣取得它的值:

// getUserNameFromDOM :: () -> String
function getUserNameFromDOM() {
    return document.querySelector('#username').value;
}

const username = getUserNameFromDOM();
username;
複製程式碼

在這個例子中, 我們嘗試去檢索 DOM 去獲取資料, 這是不純的, 因為 document 它是一個在任何地方任何時候都可以改變的全域性變數, 如果要讓我們這個函式變純, 我們可以把全域性物件 document 作為一個函式的引數傳遞進來, 但是,我們還可以直接把 querySelector() 傳進來啊

// getUserNameFromDOM :: (String -> Element) -> String
function getUserNameFromDOM($) {
    return $('#username').value;
}

// qs :: String -> Element
const qs = document.querySelector.bind(document);

const username = getUserNameFromDOM(qs);
username;
複製程式碼

現在你可能還是會想, 這還是很傻啊, 我們只是把不純的程式碼從 getUserNameFromDom 轉移了出去, 副作用還在啊, 它並沒有消失, 這看起來他除了讓程式碼更長, 更多以外, 並沒有什麼實質性的作用了, 原本我們只有一個不純的函式, 現在我們有了兩個函式, 其中一個還是不純的...

再忍受我一下, 想象一下我們要給 getUserNameFromDOM() 寫一個測試用例, 現在, 我們來對比一下純的版本和不純的版本, 哪一個更容易被測試? 為了讓不純的版本工作起來, 我們需要一個 document 的全域性物件, 最重要的是, 他還要有一個 id 是 username 的標籤, 如果我在瀏覽器外對他進行測試, 那麼我就需要引入 jsDOM 或者 無頭瀏覽器(headless browser), 所有的這些都只是為了測試這麼一個小小的函式, 但是如果使用純的版本, 我可以直接這樣來測試:

const qsStub = () => ({value: 'mhatter'});
const username = getUserNameFromDOM(qsStub);
// 斷言 (接觸測試框架比較少的小夥伴可以瞭解一下 jest)
assert.strictEqual('mhatter', username, `Expected username to be ${username}`);
複製程式碼

這不意味著你不應該在真是的瀏覽器上新建一個測試, 但是現在, getUserNameFromDOM() 是完全的可預測的, 如果我們總是傳遞 qsStub, 那麼這個函式總是會返回 mhatter, 我們把不可預測轉移到了另外一個函式 qs

如果我們需要, 我們可以把不可預測推到更遠的函式上, 最終, 我們會把他推到我們程式碼的邊緣, 對應於函式棧, 就是推到最後一個函式, 如果你要構建一個很大的 APP, 那麼可預測性會變得越發的重要.

依賴注入的缺點

構建一個龐大的, 複雜的應用是可能的, 因為原作者自己就搞了一個, 測試會變得更容易, 這使得所有的函式依賴都會明確, 但是這還是有一些缺點, 最主要的一個就是, 要傳遞的引數真的太多了...

function app(doc, con, ftch, store, config, ga, d, random) {
    // Application code goes here
 }

app(document, console, fetch, store, config, ga, (new Date()), Math.random);
複製程式碼

也沒有那麼不好是吧... 但是如果你的引數有 穿針 (穿針名詞解釋: 在我的家鄉,沒有碰到籃筐就進的球, 叫穿針球)問題. 你可能在函式棧比較接近低的位置需要一些引數, 那麼你就需要把這些引數一層一層的往下傳, 這是很苦惱的, 例如, 你可能需要傳遞一些資料穿透5箇中間函式, 而那些函式並沒有使用到這些資料, 這些資料只是為了第六個函式準備的(穿針), 這還不算嚴重, 畢竟他的依賴關係還是很清晰,但是這還是很苦惱, 然而, 我們還有另外一種方法來避免副作用

lazy functions (延遲函式)

讓我們來看一下函式式程式設計師使用的第二個漏洞, 這個漏洞是這樣的: "當一個副作用還沒有發生(執行)的時候, 他不是一個副作用." 聽起來很神祕, 我也這麼覺得, 那讓我們來看看程式碼, 讓他更清晰.

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here (這裡會執行一些副作用的程式碼, 發射火箭)
    return 0;
}
複製程式碼

我知道, 這是一個沒有什麼技術含量的例子, 如果我們需要一個 0 我們可以直接寫一個 0 ...

但是我們來舉一個例子, 我知道我們不可能用 javascript 來發射一支火箭, 但是這可以幫助我們說明這個情況, 那麼現在, 就讓我們用javascript 來發射一支火箭, 這是一段不純的程式碼, 他列印了一條資訊, 還發射了一支火箭. 想象我們想得到那個0, 再想象一個場景,我們想要在火箭發射之後要計算一些東西, 我們可能需要啟動一個計數器, 或者類似的東西, 我們需要在火箭發射的時候非常的專注, 我們不希望火箭的意外發射會影響到我們的計算, 那麼, 如果我們把 fZero() 包裹在另一個僅僅只是返回它的函式上,會發生什麼. 像是一種安全的包裹

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here
    return 0;
}

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    return fZero;
}
複製程式碼

我可以執行 returnZeroFunc() 任意多次, 只要我不執行他返回的函式, 那麼我們的火箭就不會發射, 理論上, 我的計算就是安全的.

const zeroFunc1 = returnZeroFunc();
const zeroFunc2 = returnZeroFunc();
const zeroFunc3 = returnZeroFunc();
// No nuclear missiles launched. 沒有火箭發射
複製程式碼

現在, 我們來定義一個純函式, 然後更詳細的審查 returnZeroFunc() 如果一個函式式純的, 他有以下的兩點特徵

  1. 它沒有副作用
  2. 引用透明, 意味著, 給它相同的引數, 它總會返回相同的值.

讓我們來審查一下 returnZeroFunc() 它有副作用嗎? 它只是把函式返回了, 除非你繼續執行它返回的函式, 不然它不會發射火箭, 所以在這, 它沒有副作用.

那它引用透明嗎? 傳相同的引數, 會返回相同的值嗎? oh 他總是返回同一個函式, 在這, 他引用是透明的, 我們可以測試一下

zeroFunc1 === zeroFunc2; // true
zeroFunc2 === zeroFunc3; // true
複製程式碼

但, 它還不是完全純, 因為他引用了外部的一個變數, 但是我們可以這樣來改寫它

// returnZeroFunc :: () -> (() -> Number)
function returnZeroFunc() {
    function fZero() {
        console.log('Launching nuclear missiles');
        // Code to launch nuclear missiles goes here
        return 0;
    }
    return fZero;
}
複製程式碼

現在我們的函式純了, 但是我們每次返回的函式都不是同一個函式, 因為, 每一次執行都會重新定義一個 fZero, 這是 javascript 給我們開的一個玩笑, 不過並沒有什麼大礙

這是一個優雅的小漏洞, 但是, 我們可以在實際的專案中使用這樣的程式碼嗎? 答案是肯定的, 但是在我們要把他引入實際程式碼之前, 我們來把目標放得更長遠一點, 回到我們的不純的 fZero() 函式.

// fZero :: () -> Number
function fZero() {
    console.log('Launching nuclear missiles');
    // Code to launch nuclear missiles goes here
    return 0;
}
複製程式碼

我們來使用一下 fZero() 返回的 0 , 但是我們不發射裡面的火箭. 我們會新建一個函式, 這個函式會攜帶著 fZero() 返回的 0, 然後給它加 1

// fIncrement :: (() -> Number) -> Number
function fIncrement(f) {
    return f() + 1;
}

fIncrement(fZero);
複製程式碼

臥槽, 我們意外的發射了火箭... 我們再來一次, 這次我們不會返回一個數字(number), 我會回返回一個會返回數字的函式 這裡其實就是上面的函式的安全包裹(延遲函式)

// fIncrement :: (() -> Number) -> (() -> Number)
function fIncrement(f) {
    return () => f() + 1;
}

fIncrement(fZero);
複製程式碼

oh, 火箭不發射了, 我們繼續, 有了這兩個函式, 我們可以構建一系列的函式區做我們想要做的事情,.;

const fOne   = fIncrement(fZero);
const fTwo   = fIncrement(one);
const fThree = fIncrement(two);
// And so on…
複製程式碼

我們還可以建立一推使用上面函式的函式來做一些更高階的事情.

// fMultiply :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fMultiply(a, b) {
    return () => a() * b();
}

// fPow :: (() -> Number) -> (() -> Number) -> (() -> Number)
function fPow(a, b) {
    return () => Math.pow(a(), b());
}

// fSqrt :: (() -> Number) -> (() -> Number)
function fSqrt(x) {
    return () => Math.sqrt(x());
}

const fFour = fPow(fTwo, fTwo);
const fEight = fMultiply(fFour, fTwo);
const fTwentySeven = fPow(fThree, fThree);
const fNine = fSqrt(fTwentySeven);
// No console log or thermonuclear war. Jolly good show!
複製程式碼

你知道我在這裡都幹了什麼嗎? 我想做的任何的事情, 都是從那個 fZero() 返回的0開始的, 我把所有的計算邏輯都寫好了, 我的火箭還是沒有發射, 我們可以通過呼叫最終的函式拿到我們最後的值, 並且發射火箭, 這裡有個數學理論, 叫'isomorphism', 感興趣的可以去看一下.

如果這裡還不是很明白, 我們可以換一種說法, 我們可以把這當作是我們想要獲得的那個數字跟 0 的一種對映關係, 我們可以通過一系列的操作, 把 0 對映成我們想要的那個值, 而且這個關係是一一對應的, 我們通過這個操作, 只能獲取得那個值, 因為都是純函式. 這聽起來很興奮.

包裹著那些函式的函式是一個合法的策略, 只要我們想, 我們可以一直讓函式隱藏在最後一步, 只要我們不呼叫實際執行的函式, 他們理論上全是純的, 而且不會發射任何的火箭. 在那些純的程式碼中, 我們最終還是需要副作用的(不然怎麼發射火箭), 把所有的一切都包裹在一個函式裡面, 可以讓我們精確的控制管理好那些副作用, 當那些副作用發生的時候, 我們可以精確的知道他發生了, 但是, 宣告那麼多得函式管理起來是很痛苦的, 而且我們還要為每一個函式都建立一個被包裹的版本, 我們需要一些像 Math.sqrt() 這樣的 javascript 語言內建的完美的函式去幹這個事情, 如果我們有一種方式, 可以用我們的延遲值去使用那些普通函式, 這就太好了. 現在讓我們來引入 effect functor (名詞不翻譯, 請大家意會)

the effect functor

從我們的目的出發, effect functor 不過就是一個持有我們的延遲函式的物件, 所以我們會把 fZero() 放進去, 但是, 在我們幹這個事情之前, 我們來看一個簡單一點的例子.

// 這是我們的延遲函式
// zero :: () -> Number
function fZero() {
    console.log('Starting with nothing');
    // Definitely not launching a nuclear strike here.
    // But this function is still impure.
    return 0;
}
複製程式碼

現在, 我們來建立一個可以為我們新建 effect 物件的構造器(工廠函式, 不用使用new)

// Effect :: Function -> Effect
function Effect(f) {
    return {};
}
複製程式碼

目前為止, 東西並不多, 任何 javascript 開發者都能看懂這個程式碼, 很簡單, 現在, 我們來搞一些有用的東西, 我們現在把 Effect 跟我們普通的 fZero() 函式一起使用, 我們來寫一個帶著普通的函式的方法, 最終我們會把他應用到我們的延遲值上, 但是, 我們不會觸發 副作用 ,我們把他叫做 map 這是因為, 他會在常規的函式和 Effect 函式之間建立一種對映的關係, 這看起來可能會是這樣的.

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(g(f(x))
        } 
    };
}
複製程式碼

如果你不是函數語言程式設計的新手, 而且你很專注的看到這裡, 你可能會發現, 這跟 compose 很像, 我們會在稍後來說這個問題, 現在我們來試試這樣幹

const zero = Effect(fZero);
const increment = x => x + 1; // A plain ol' regular function.
const one = zero.map(increment);
複製程式碼

嗯哼, 我們現在還沒有方法知道我們幹了什麼, 我們想在進階一下, 給 Effect 加一個方法

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
    }
}

const zero = Effect(fZero);
const increment = x => x + 1; // Just a regular function.
const one = zero.map(increment);

one.runEffects();
// ⦘ Starting with nothing
// ← 1
複製程式碼

我們可以一直呼叫 Effect 的 map 方法

const double = x => x * 2;
const cube = x => Math.pow(x, 3);
const eight = Effect(fZero)
    .map(increment)
    .map(double)
    .map(cube);

eight.runEffects();
// ⦘ Starting with nothing
// ← 8
複製程式碼

現在, 事情變得有趣了, 我們把這個東西叫 functor, 所有有 map() 方法的 Effect 都是, 它有一些規則, 這些規則不是限制什麼你不能做, 而是限制了什麼你可以做的, 就像是特權, 因為 Effect 只是 functor 的一種, 他們其中的一個規則是 composition rule 他看起來像是這樣的.

如果我們有一個 Effect 叫 e 和兩個函式叫 f 和 g 那麼 e.map(g).map(f) 等於 e.map(f(g(x)))

換一種說法, 連續的執行兩個 map 等於 合併兩個函式,執行一次map 意味著, Effect 可以做這樣做這個事情

const incDoubleCube = x => cube(double(increment(x)));
// If we're using a library like Ramda or lodash/fp we could also write:
// const incDoubleCube = compose(cube, double, increment);
const eight = Effect(fZero).map(incDoubleCube);
複製程式碼

當我們這樣做的時候, 我們保證,這個和上面的 三個map 的版本得到的結果是一樣的, 我們可以用這個去重構我們的程式碼而且不會破壞原有的程式碼, 我們甚至可以通過交換兩個函式的順序來提升效能. 現在我們再來做一些進階.

建立 Effect 的捷徑

我們的 Effect 構造器帶著一個函式做引數, 這很方便, 因為多數的副作用都是在一個函式中發生的, 例如, Math.random() console.log 和類似的這些函式, 要是我們想在 Effect 中放一個 其他型別的值呢?(例如放一個 物件), 想象一下, 我們需要在瀏覽器的 window 物件上繫結一個配置物件, 我們想獲取他的值, 但是, 它是一個全域性的物件, 它可能在任何時候被修改, 這是副作用, 我們可以寫一個方法來讓建立 Effect 的方式變得更豐富.

// 這是一個靜態的方法
// of :: a -> Effect a
Effect.of = function of(val) {
    return Effect(() => val);
}
複製程式碼

為了讓你們知道這是多麼的有用, 想象一下我們現在在一個 web app 上, 這個應用有一些固定的功能, 例如,文章列表 還有作者的資訊, 但是, 這個應用的 HTML 內容會根據不同的使用者而線上更新, 我們是一個聰明的工程師, 我們決定把他們的資訊的位置記錄在一個全域性的配置物件上,那麼我們就可以總是找到他們,像這個樣子

window.myAppConf = {
    selectors: {
        'user-bio':     '.userbio',
        'article-list': '#articles',
        'user-name':    '.userfullname',
    },
    templates: {
        'greet':  'Pleased to meet you, {name}',
        'notify': 'You have {n} alerts',
    }
};
複製程式碼

現在, 使用我們的捷徑方法, 我們可以把我們想要的值放進 Effect 裡面

const win = Effect.of(window);
userBioLocator = win.map(x => x.myAppConf.selectors['user-bio']);
// ← Effect('.userbio')
// 現在我們得到的是一個 Effect 然後裡面裝著一個函式 () => '.userbio'
// 一會會回到這裡繼續講解
複製程式碼

巢狀 Effect 和 扁平 Effect

Effect 的對映可以讓我們走很長的路, 但是有的時候, 我們會對映一個返回 Effect 的函式, 這就尷尬了. 比如, 我們現在想真的找到上面的那個選擇器的 DOM 節點, 我們就需要另外一個不純的API document.querySelector() 噢 又是一個副作用, 所以我們打算把他放進 一個返回 Effect 的函式中

// $ :: String -> Effect DOMElement
function $(selector) {
    return Effect.of(document.querySelector(s));
}
複製程式碼

現在, 如果我們想把這個 $ 和上面的 userBioLocator 一起使用(他們為什麼要一起使用不用解釋吧...), 我們需要使用map

const userBio = userBioLocator.map($);
// ← Effect(Effect(<div>))
複製程式碼

到了這一步就有點尷尬了, 如果我們想要訪問那個 div 我們就要繼續 map一個函式, 而那個函式裡面 還要再次 map 才可以得到我們真正想要的值, (Effect 巢狀了) 如果我們想要訪問div的 innerHTML 那麼程式碼可能是這樣的.

const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML));
// ← Effect(Effect('<h2>User Biography</h2>'))
複製程式碼

現在我們來從新的捋一捋思路, 我們回到 userBio 那一步, 會有點繁瑣, 但是能讓我們更清晰的看看他是怎麼巢狀的. 我們上面的 Effect('.userbio') 這個描述可能有點迷惑, 它其實是下面這樣的

ps: 下面這個過程還有疑惑的請在掘金平臺評論區回覆, 我看見會回答.

 Effect(() => '.userbio')
複製程式碼

其實我們還可以繼續的展開,

Effect(() => window.myAppConf.selectors['user-bio']);
複製程式碼

我們的 map 相當於將 引數裡的函式, 跟 Effect 內部保管的函式相合並, 所以當我們傳入一個 $ 的時候, 他就變成這個樣子的

Effect(() => $(window.myAppConf.selectors['user-bio']));
複製程式碼

把 $ 展開我們得到

Effect(
    () => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio']))
);
複製程式碼

現在我們再把 Effect.of 展開

Effect(
    () => Effect(
        () => document.querySelector(window.myAppConf.selectors['user-bio'])
    )
);
複製程式碼

see 巢狀了, 然而, 副作用還是保持在內部的Effect 它並沒有影響到外部的 Effect 可以說外部的 Effect 已經毫無作用了

Join

為什麼我要把它展開, 因為我想把這個 巢狀的 Effect 給扁平了, 讓它變成一個 Effect , 而且是在不觸發副作用的條件下把它扁平掉, 不知道你想到了沒有, 我們現在已經有一個方法可以獲取到 Effect 的值了, 是的, 就是 runEffects, 我們可以直接在外部的Effect 行執行 runEffects 就可以拿到內部的 Effect, 但是, 我們的 runEffects 最初是用來執行我們的延遲值函式的, 而延遲值會觸發副作用, 那不是有歧義了嗎, 因為我們預設 runEffects 會觸發副作用, 但是我們的 扁平化 是不觸發副作用的, 所以我們需要新建另外一個函式, 來幹相同的事情.這會讓我們清楚的看函式呼叫就知道, 我們實際幹了啥. 即使, 這兩個函式是一模一樣的

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        },
        join(x) {
            return f(x);
        }
    }
}
複製程式碼

我現在可以用這個來 扁平掉 我上面的那個巢狀例子了

const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .map($)
    .join()
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')
複製程式碼

Chain

想上面的這種, map 完了以後馬上就 join 的寫法會有很多, 這就好像是捆綁的操作, 所以我們可以給他們來一個 快捷方式, 這讓我們可以很安全的一直 map jion map join.

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        },
        join(x) {
            return f(x);
        },
        chain(g) {
            return Effect(f).map(g).jion()
        }
    }
}
複製程式碼

我們把這個函式叫 chain (具有鏈子的意思, 而且標準也規定了這個名字, 你們可以去查閱) 是因為他可以把兩個 Effect 給連線在一起. 現在來看看我們重構過的程式碼

const userBioHTML = Effect.of(window)
    .map(x => x.myAppConf.selectors['user-bio'])
    .chain($)
    .map(x => x.innerHTML);
// ← Effect('<h2>User Biography</h2>')
複製程式碼

不幸的是, 其他的函式式程式設計師, 會用他們自己的命名, 如果你們會閱讀到他們的文章, 可能回對你造成一點困惑, 有時候他會叫 flatMap 熟悉吧, 我記得 rxjs 就是使用的這個, 還有一些庫會使用 bind 所以當你們看到這些詞的時候注意一下, 他們其實是相同的概念.

結合 Effect

使用 Effect 的時候還有一個場景可能會比較尷尬, 就是我們需要用一個函式去組合多個 Effect 的時候. 例如, 當我們要在 DOM 節點中獲取使用者名稱然後把它插入到我們配置的模板(template)中, 所以我們需要一個這樣的操作模板的函式

// tpl :: String -> Object -> String
const tpl = curry(function tpl(pattern, data) {
    return Object.keys(data).reduce(
        (str, key) => str.replace(new RegExp(`{${key}}`, data[key]),
        pattern
    );
});
// 不知道curry的都要去了解一下哦, 很重要的一個概念
複製程式碼

一切都很好, 現在來獲取我們的使用者名稱了

const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});
// ← Effect({name: 'Mr. Hatter'});

const pattern = win.map(w => w.myAppConfig.templates('greeting'));
// ← Effect('Pleased to meet you, {name}');
複製程式碼

我們已經有模板函式了, 它需要兩個引數, 一個字串(模板), 一個物件(config物件), 然後返回一個字串. 但是我們的字串和物件都包裹在 Effect 裡面, 所以我們只能在 Effect 內部傳遞引數給 tpl

現在我們來看一下在 pattern 上把 tpl 函式傳入 map 裡面會發生什麼

pattern.map(tpl)
// ← Effect([Function])
複製程式碼

別混亂啊, 這裡要好好捋一捋, 我們傳入了 tpl 而tpl 是一個 curry 過的函式, 這個函式在接受到他的兩個引數之前, 他是不會執行的, 也就是說 我們的 pattern.map() 返回的 Effect 是一個包裹了 tpl('Pleased to meet you, {name}', ?) 的 Effect , 他還需要一個配置物件才會返回他真正想要返回的值.

現在, 我們需要把config 物件傳進 Effect 裡面的那個 已經具有一個引數的 tpl 函式了, 但是我們好像還沒有方法去幹這個事情, 我們現在來建立一個(我們把這個方法叫 ap).

// Effect :: Function -> Effect
function Effect(f) {
    return {
        map(g) {
            return Effect(x => g(f(x)));
        },
        runEffects(x) {
            return f(x);
        }
        join(x) {
            return f(x);
        }
        chain(g) {
            return Effect(f).map(g).join();
        }
        ap(eff) {
             // If someone calls ap, we assume eff has a function inside it (rather than a value).
            // We'll use map to go inside off, and access that function (we'll call it 'g')
            // Once we've got g, we apply the value inside off f() to it
            // 我們預設傳進來的是一個 Effect 
            return eff.map(g => g(f()));
        }
    }
}
複製程式碼

好好的看一看這個函式, 好好理解一下, 這個不好解釋, 展開了就懂了.

現在我們可以使用一下這個函式了

const win = Effect.of(window);
const name = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str}));

const pattern = win.map(w => w.myAppConfig.templates('greeting'));

const greeting = name.ap(pattern.map(tpl));
// ← Effect('Pleased to meet you, Mr Hatter')
複製程式碼

我們已經達到了我們的目的, 但還是有一點不足, 我發現 ap() 有時候會有點尷尬, 就是很難去記住我的函式要 map 了一個引數才可以傳進去 ap(), 如果我忘記了這個函式的引數的順序那我就GG了, 這有一個方法, 大多時候, 我們會把普通函式提升到全應用的級別, 就是, 我有一個普通的函式, 我想讓它跟與一個擁有 ap() 方法的 Effect 的類似的東西一起工作, 我們可以寫一個函式為我們做這個事情.

// liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c)
const liftA2 = curry(function liftA2(f, x, y) {
    return y.ap(x.map(f));
    // We could also write:
    //  return x.map(f).chain(g => y.map(g));
});
複製程式碼

我們把他叫 liftA2 是因為他 提升了具有兩個引數的函式, 我們可以類似的建立一個 liftA3.

// liftA3 :: (a -> b -> c -> d) -> (Applicative a -> Applicative b -> Applicative c -> Applicative d)
const liftA3 = curry(function liftA3(f, a, b, c) {
    return c.ap(b.ap(a.map(f)));
});
複製程式碼

注意一下, 我們這裡並沒有涉及到 Effect 我剛才說了, 是一個與擁有 ap() 的 Efect 類似的東西, 這些函式可以與任何擁有 ap() 方法的物件一起工作

我們可以用我們的 liftA2 來重寫我們上面的程式碼

const win = Effect.of(window);
const user = win.map(w => w.myAppConfig.selectors['user-name'])
    .chain($)
    .map(el => el.innerHTML)
    .map(str => ({name: str});

const pattern = win.map(w => w.myAppConfig.templates['greeting']);

const greeting = liftA2(tpl)(pattern, user);
// ← Effect('Pleased to meet you, Mr Hatter')
複製程式碼

完了嗎?

到了這裡, 你可能會覺得為了避免這樣或者那樣的副作用, 我們可是煞費苦心了. 但是這又怎麼樣呢, 當你意識到他的好處的時候, 這樣點點的麻煩完全OJBK

就到這裡吧, 原文下面還有一段引申, 不過難度有點深(關於計算機的, 機器學習的一些描述), 我也沒懂(其實上面我也是勉強看懂了, 收益確實良多)... 就不翻譯了...

相關文章