javascript
[译] 如何使用纯函数式 JavaScript 处理脏副作用
- 原文地址:HOW TO DEAL WITH DIRTY SIDE EFFECTS IN YOUR PURE FUNCTIONAL JAVASCRIPT
- 原文作者:James Sinclair
- 譯文出自:掘金翻譯計(jì)劃
- 本文永久鏈接:github.com/xitu/gold-m…
- 譯者:Gavin-Gong
- 校對(duì)者:huangyuanzhen, AceLeeWinnie
如何使用純函數(shù)式 JavaScript 處理臟副作用
首先,假定你對(duì)函數(shù)式編程有所涉獵。用不了多久你就能明白純函數(shù)的概念。隨著深入了解,你會(huì)發(fā)現(xiàn)函數(shù)式程序員似乎對(duì)純函數(shù)很著迷。他們說(shuō):“純函數(shù)讓你推敲代碼”,“純函數(shù)不太可能引發(fā)一場(chǎng)熱核戰(zhàn)爭(zhēng)”,“純函數(shù)提供了引用透明性”。諸如此類。他們說(shuō)的并沒(méi)有錯(cuò),純函數(shù)是個(gè)好東西。但是存在一個(gè)問(wèn)題……
純函數(shù)是沒(méi)有副作用的函數(shù)。[1] 但如果你了解編程,你就會(huì)知道副作用是關(guān)鍵。如果無(wú)法讀取 ? 值,為什么要在那么多地方計(jì)算它?為了把值打印出來(lái),我們需要寫(xiě)入 console 語(yǔ)句,發(fā)送到 printer,或其他可以被讀取到的地方。如果數(shù)據(jù)庫(kù)不能輸入任何數(shù)據(jù),那么它又有什么用呢?我們需要從輸入設(shè)備讀取數(shù)據(jù),通過(guò)網(wǎng)絡(luò)請(qǐng)求信息。這其中任何一件事都不可能沒(méi)有副作用。然而,函數(shù)式編程是建立在純函數(shù)之上的。那么函數(shù)式程序員是如何完成任務(wù)的呢?
簡(jiǎn)單來(lái)說(shuō)就是,做數(shù)學(xué)家做的事情:欺騙。
說(shuō)他們欺騙吧,技術(shù)上又遵守規(guī)則。但是他們發(fā)現(xiàn)了這些規(guī)則中的漏洞,并加以利用。有兩種主要的方法:
依賴注入
依賴注入是我們處理副作用的第一種方法。在這種方法中,將代碼中的不純的部分放入函數(shù)參數(shù)中,然后我們就可以把它們看作是其他函數(shù)功能的一部分。為了解釋我的意思,我們來(lái)看看一些代碼:
// logSomething :: String -> () function logSomething(something) {const dt = new Date().toIsoString();console.log(`${dt}: ${something}`);return something; } 復(fù)制代碼logSomething() 函數(shù)有兩個(gè)不純的地方:它創(chuàng)建了一個(gè) Date() 對(duì)象并且把它輸出到控制臺(tái)。因此,它不僅執(zhí)行了 IO 操作, 而且每次運(yùn)行的時(shí)候都會(huì)給出不同的結(jié)果。那么,如何使這個(gè)函數(shù)變純?使用依賴注入,我們以函數(shù)參數(shù)的形式接受不純的部分,因此 logSomething() 函數(shù)接收三個(gè)參數(shù),而不是一個(gè)參數(shù):
// logSomething: Date -> Console -> String -> () function logSomething(d, cnsl, something) {const dt = d.toIsoString();cnsl.log(`${dt}: ${something}`);return something; } 復(fù)制代碼然后調(diào)用它,我們必須自行明確地傳入不純的部分:
const something = "Curiouser and curiouser!"; const d = new Date(); logSomething(d, console, something); // ? Curiouser and curiouser! 復(fù)制代碼現(xiàn)在,你可能會(huì)想:“這樣做有點(diǎn)傻逼。這樣把問(wèn)題變得更嚴(yán)重了,代碼還是和之前一樣不純”。你是對(duì)的。這完全就是一個(gè)漏洞。
YouTube 視頻鏈接:youtu.be/9ZSoJDUD_bU
這就像是在裝傻:“噢!不!警官,我不知道在 cnsl 上調(diào)用 log() 會(huì)執(zhí)行 IO 操作。這是別人傳給我的。我不知道它從哪來(lái)的”,這看起來(lái)有點(diǎn)蹩腳。
這并不像表面上那么愚蠢,注意我們的 logSomething() 函數(shù)。如果你要處理一些不純的事情, 你就不得不把它變得不純。我們可以簡(jiǎn)單地傳入不同的參數(shù):
const d = {toISOString: () => "1865-11-26T16:00:00.000Z"}; const cnsl = {log: () => {// do nothing} }; logSomething(d, cnsl, "Off with their heads!"); // ← "Off with their heads!" 復(fù)制代碼現(xiàn)在,我們的函數(shù)什么事情也沒(méi)干,除了返回 something 參數(shù)。但是它是純的。如果你用相同的參數(shù)調(diào)用它,它每次都會(huì)返回相同的結(jié)果。這才是重點(diǎn)。為了使它變得不純,我們必須采取深思熟慮的行動(dòng)。或者換句話說(shuō),函數(shù)依賴于右邊的簽名。函數(shù)無(wú)法訪問(wèn)到像 console 或者 Date 之類的全局變量。這樣所有事情就很明確了。
同樣需要注意的是,我們也可以將函數(shù)傳遞給原來(lái)不純的函數(shù)。讓我們看一下另一個(gè)例子。假設(shè)表單中有一個(gè) username 字段。我們想要從表單中取到它的值:
// getUserNameFromDOM :: () -> String function getUserNameFromDOM() {return document.querySelector("#username").value; }const username = getUserNameFromDOM(); username; // ← "mhatter" 復(fù)制代碼在這個(gè)例子中,我們嘗試去從 DOM 中查詢信息。這是不純的,因?yàn)?document 是一個(gè)隨時(shí)可能改變的全局變量。把我們的函數(shù)轉(zhuǎn)化為純函數(shù)的方法之一就是把 全局 document 對(duì)象當(dāng)作一個(gè)參數(shù)傳入。但是我們也可以像這樣傳入一個(gè) querySelector() 函數(shù):
// getUserNameFromDOM :: (String -> Element) -> String function getUserNameFromDOM($) {return $("#username").value; }// qs :: String -> Element const qs = document.querySelector.bind(document);const username = getUserNameFromDOM(qs); username; // ← "mhatter" 復(fù)制代碼現(xiàn)在,你可能還是會(huì)認(rèn)為:“這樣還是一樣傻啊!” 我們所做只是把不純的代碼從 getUsernameFromDOM() 移出來(lái)而已。它并沒(méi)有消失,我們只是把它放在了另一個(gè)函數(shù) qs() 中。除了使代碼更長(zhǎng)之外,它似乎沒(méi)什么作用。我們兩個(gè)函數(shù)取代了之前一個(gè)不純的函數(shù),但是其中一個(gè)仍然不純。
別著急,假設(shè)我們想給 getUserNameFromDOM() 寫(xiě)測(cè)試。現(xiàn)在,比較一下不純和純的版本,哪個(gè)更容易編寫(xiě)測(cè)試?為了對(duì)不純版本的函數(shù)進(jìn)行測(cè)試,我們需要一個(gè)全局 document 對(duì)象,除此之外,還需要一個(gè) ID 為 username 的元素。如果我想在瀏覽器之外測(cè)試它,那么我必須導(dǎo)入諸如 JSDOM 或無(wú)頭瀏覽器之類的東西。這一切都是為了測(cè)試一個(gè)很小的函數(shù)。但是使用第二個(gè)版本的函數(shù),我可以這樣做:
const qsStub = () => ({value: "mhatter"}); const username = getUserNameFromDOM(qsStub); assert.strictEqual("mhatter", username, `Expected username to be ${username}`); 復(fù)制代碼現(xiàn)在,這并不意味著你不應(yīng)該創(chuàng)建在真正的瀏覽器中運(yùn)行的集成測(cè)試。(或者,至少是像 JSDOM 這樣的模擬版本)。但是這個(gè)例子所展示的是 getUserNameFromDOM() 現(xiàn)在是完全可預(yù)測(cè)的。如果我們傳遞給它 qsStub 它總是會(huì)返回 mhatter。我們把不可預(yù)測(cè)轉(zhuǎn)性移到了更小的函數(shù) qs 中。
如果我們這樣做,就可以把這種不可預(yù)測(cè)性推得越來(lái)越遠(yuǎn)。最終,我們將它們推到代碼的邊界。因此,我們最終得到了一個(gè)由不純代碼組成的薄殼,它包圍著一個(gè)測(cè)試友好的、可預(yù)測(cè)的核心。當(dāng)您開(kāi)始構(gòu)建更大的應(yīng)用程序時(shí),這種可預(yù)測(cè)性就會(huì)起到很大的作用。
依賴注入的缺點(diǎn)
可以以這種方式創(chuàng)建大型、復(fù)雜的應(yīng)用程序。我知道是 因?yàn)槲易鲞^(guò)。 依賴注入使測(cè)試變得更容易,也會(huì)使每個(gè)函數(shù)的依賴關(guān)系變得明確。但它也有一些缺點(diǎn)。最主要的一點(diǎn)是,你最終會(huì)得到類似這樣冗長(zhǎng)的函數(shù)簽名:
function app(doc, con, ftch, store, config, ga, d, random) {// 這里是應(yīng)用程序代碼 }app(document, console, fetch, store, config, ga, new Date(), Math.random); 復(fù)制代碼這還不算太糟,除此之外你可能遇到參數(shù)鉆井的問(wèn)題。在一個(gè)底層的函數(shù)中,你可能需要這些參數(shù)中的一個(gè)。因此,您必須通過(guò)許多層的函數(shù)調(diào)用來(lái)連接參數(shù)。這讓人惱火。例如,您可能需要通過(guò) 5 層中間函數(shù)傳遞日期。所有這些中間函數(shù)都不使用 date 對(duì)象。這不是世界末日,至少能夠看到這些顯式的依賴關(guān)系還是不錯(cuò)的。但它仍然讓人惱火。這還有另一種方法……
懶函數(shù)
讓我們看看函數(shù)式程序員利用的第二個(gè)漏洞。它像這樣:“發(fā)生的副作用才是副作用”。我知道這聽(tīng)起來(lái)神秘的。讓我們?cè)囍屗鞔_一點(diǎn)。思考一下這段代碼:
// fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發(fā)射核彈的代碼return 0; } 復(fù)制代碼我知道這是個(gè)愚蠢的例子。如果我們想在代碼中有一個(gè) 0,我們可以直接寫(xiě)出來(lái)。我知道你,文雅的讀者,永遠(yuǎn)不會(huì)用 JavaScript 寫(xiě)控制核武器的代碼。但它有助于說(shuō)明這一點(diǎn)。這顯然是不純的代碼。因?yàn)樗敵鋈罩镜娇刂婆_(tái),也可能開(kāi)始熱核戰(zhàn)爭(zhēng)。假設(shè)我們想要 0。假設(shè)我們想要計(jì)算導(dǎo)彈發(fā)射后的情況,我們可能需要啟動(dòng)倒計(jì)時(shí)之類的東西。在這種情況下,提前計(jì)劃如何進(jìn)行計(jì)算是完全合理的。我們會(huì)非常小心這些導(dǎo)彈什么時(shí)候起飛,我們不想搞混我們的計(jì)算結(jié)果,以免他們意外發(fā)射導(dǎo)彈。那么,如果我們將 fZero() 包裝在另一個(gè)只返回它的函數(shù)中呢?有點(diǎn)像安全包裝。
// fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發(fā)射核彈的代碼return 0; }// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {return fZero; } 復(fù)制代碼我可以運(yùn)行 returnZeroFunc() 任意次,只要不調(diào)用返回值,我理論上就是安全的。我的代碼不會(huì)發(fā)射任何核彈。
const zeroFunc1 = returnZeroFunc(); const zeroFunc2 = returnZeroFunc(); const zeroFunc3 = returnZeroFunc(); // 沒(méi)有發(fā)射核彈。 復(fù)制代碼現(xiàn)在,讓我們更正式地定義純函數(shù)。然后,我們可以更詳細(xì)地檢查我們的 returnZeroFunc() 函數(shù)。如果一個(gè)函數(shù)滿足以下條件就可以稱之為純函數(shù):
讓我們看看 returnZeroFunc()。有副作用嗎?嗯,之前我們確定過(guò),調(diào)用 returnZeroFunc() 不會(huì)發(fā)射任何核導(dǎo)彈。除非執(zhí)行調(diào)用返回函數(shù)的額外步驟,否則什么也不會(huì)發(fā)生。所以,這個(gè)函數(shù)沒(méi)有副作用。
returnZeroFunc() 引用透明嗎?也就是說(shuō),給定相同的輸入,它總是返回相同的輸出?好吧,按照它目前的編寫(xiě)方式,我們可以測(cè)試它:
zeroFunc1 === zeroFunc2; // true zeroFunc2 === zeroFunc3; // true 復(fù)制代碼但它還不能算純。returnZeroFunc() 函數(shù)引用函數(shù)作用域外的一個(gè)變量。為了解決這個(gè)問(wèn)題,我們可以以這種方式進(jìn)行重寫(xiě):
// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {function fZero() {console.log("Launching nuclear missiles");// 這里是發(fā)射核彈的代碼return 0;}return fZero; } 復(fù)制代碼現(xiàn)在我們的函數(shù)是純函數(shù)了。但是,JavaScript 阻礙了我們。我們無(wú)法再使用 === 來(lái)驗(yàn)證引用透明性。這是因?yàn)?returnZeroFunc() 總是返回一個(gè)新的函數(shù)引用。但是你可以通過(guò)審查代碼來(lái)檢查引用透明。returnZeroFunc() 函數(shù)每次除了返回相同的函數(shù)其他什么也不做。
這是一個(gè)巧妙的小漏洞。但我們真的能把它用在真正的代碼上嗎?答案是肯定的。但在我們討論如何在實(shí)踐中實(shí)現(xiàn)它之前,先放到一邊。先回到危險(xiǎn)的 fZero() 函數(shù):
// fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發(fā)射核彈的代碼return 0; } 復(fù)制代碼讓我們嘗試使用 fZero() 返回的零,但這不會(huì)發(fā)動(dòng)熱核戰(zhàn)爭(zhēng)(笑)。我們將創(chuàng)建一個(gè)函數(shù),它接受 fZero() 最終返回的 0,并在此基礎(chǔ)上加一:
// fIncrement :: (() -> Number) -> Number function fIncrement(f) {return f() + 1; }fIncrement(fZero); // ? 發(fā)射導(dǎo)彈 // ← 1 復(fù)制代碼哎呦!我們意外地發(fā)動(dòng)了熱核戰(zhàn)爭(zhēng)。讓我們?cè)僭囈淮巍_@一次,我們不會(huì)返回一個(gè)數(shù)字。相反,我們將返回一個(gè)最終返回一個(gè)數(shù)字的函數(shù):
// fIncrement :: (() -> Number) -> (() -> Number) function fIncrement(f) {return () => f() + 1; }fIncrement(zero); // ← [Function] 復(fù)制代碼唷!危機(jī)避免了。讓我們繼續(xù)。有了這兩個(gè)函數(shù),我們可以創(chuàng)建一系列的 '最終數(shù)字'(譯者注:最終數(shù)字即返回?cái)?shù)字的函數(shù),后面多次出現(xiàn)):
const fOne = fIncrement(zero); const fTwo = fIncrement(one); const fThree = fIncrement(two); // 等等… 復(fù)制代碼我們也可以創(chuàng)建一組 f*() 函數(shù)來(lái)處理最終值:
// 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); // 沒(méi)有控制臺(tái)日志或熱核戰(zhàn)爭(zhēng)。干得不錯(cuò)! 復(fù)制代碼看到我們做了什么了嗎?如果能用普通數(shù)字來(lái)做的,那么我們也可以用最終數(shù)字。數(shù)學(xué)稱之為 同構(gòu)。我們總是可以把一個(gè)普通的數(shù)放在一個(gè)函數(shù)中,將其變成一個(gè)最終數(shù)字。我們可以通過(guò)調(diào)用這個(gè)函數(shù)得到最終的數(shù)字。換句話說(shuō),我們建立一個(gè)數(shù)字和最終數(shù)字之間映射。這比聽(tīng)起來(lái)更令人興奮。我保證,我們很快就會(huì)回到這個(gè)問(wèn)題上。
這樣進(jìn)行函數(shù)包裝是合法的策略。我們可以一直躲在函數(shù)后面,想躲多久就躲多久。只要我們不調(diào)用這些函數(shù),它們理論上都是純的。世界和平。在常規(guī)(非核)代碼中,我們實(shí)際上最終希望得到那些副作用能夠運(yùn)行。將所有東西包裝在一個(gè)函數(shù)中可以讓我們精確地控制這些效果。我們決定這些副作用發(fā)生的確切時(shí)間。但是,輸入那些括號(hào)很痛苦。創(chuàng)建每個(gè)函數(shù)的新版本很煩人。我們?cè)谡Z(yǔ)言中內(nèi)置了一些非常好的函數(shù),比如 Math.sqrt()。如果有一種方法可以用延遲值來(lái)使用這些普通函數(shù)就好了。進(jìn)入下一節(jié) Effect 函子。
Effect 函子
就目的而言,Effect 函子只不過(guò)是一個(gè)被置入延遲函數(shù)的對(duì)象。我們想把 fZero 函數(shù)置入到一個(gè) Effect 對(duì)象中。但是,在這樣做之前,先把難度降低一個(gè)等級(jí)
// zero :: () -> Number function fZero() {console.log("Starting with nothing");// 絕對(duì)不會(huì)在這里發(fā)動(dòng)核打擊。// 但是這個(gè)函數(shù)仍然不純return 0; } 復(fù)制代碼現(xiàn)在我們創(chuàng)建一個(gè)返回 Effect 對(duì)象的構(gòu)造函數(shù)
// Effect :: Function -> Effect function Effect(f) {return {}; } 復(fù)制代碼到目前為止,還沒(méi)有什么可看的。讓我們做一些有用的事情。我們希望配合 Effetct 使用常規(guī)的 fZero() 函數(shù)。我們將編寫(xiě)一個(gè)接收常規(guī)函數(shù)并延后返回值的方法,它運(yùn)行時(shí)不觸發(fā)任何效果。我們稱之為 map。這是因?yàn)樗诔R?guī)函數(shù)和 Effect 函數(shù)之間創(chuàng)建了一個(gè)映射。它可能看起來(lái)像這樣:
// Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));}}; } 復(fù)制代碼現(xiàn)在,如果你觀察仔細(xì)的話,你可能想知道 map() 的作用。它看起來(lái)像是組合。我們稍后會(huì)講到。現(xiàn)在,讓我們嘗試一下:
const zero = Effect(fZero); const increment = x => x + 1; // 一個(gè)普通的函數(shù)。 const one = zero.map(increment); 復(fù)制代碼嗯。我們并沒(méi)有看到發(fā)生了什么。讓我們修改一下 Effect,這樣我們就有了辦法來(lái)“扣動(dòng)扳機(jī)”。可以這樣寫(xiě):
// 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; // 只是一個(gè)普通的函數(shù) const one = zero.map(increment);one.runEffects(); // ? 什么也沒(méi)啟動(dòng) // ← 1 復(fù)制代碼并且只要我們?cè)敢? 我們可以一直調(diào)用 map 函數(shù):
const double = x => x * 2; const cube = x => Math.pow(x, 3); const eight = Effect(fZero).map(increment).map(double).map(cube);eight.runEffects(); // ? 什么也沒(méi)啟動(dòng) // ← 8 復(fù)制代碼從這里開(kāi)始變得有意思了。我們稱這為函子,這意味著 Effect 有一個(gè) map 函數(shù),它 遵循一些規(guī)則。這些規(guī)則并不意味著你不能這樣做。它們是你的行為準(zhǔn)則。它們更像是優(yōu)先級(jí)。因?yàn)?Effect 是函子大家庭的一份子,所以它可以做一些事情,其中一個(gè)叫做“合成規(guī)則”。它長(zhǎng)這樣:
如果我們有一個(gè) Effect e, 兩個(gè)函數(shù) f 和 g
那么 e.map(g).map(f) 等同于 e.map(x => f(g(x)))。
換句話說(shuō),一行寫(xiě)兩個(gè) map 函數(shù)等同于組合這兩個(gè)函數(shù)。也就是說(shuō) Effect 可以這樣寫(xiě)(回顧一下上面的例子):
const incDoubleCube = x => cube(double(increment(x))); // 如果你使用像 Ramda 或者 lodash/fp 之類的庫(kù),我們也可以這樣寫(xiě): // const incDoubleCube = compose(cube, double, increment); const eight = Effect(fZero).map(incDoubleCube); 復(fù)制代碼當(dāng)我們這樣做的時(shí)候,我們可以確認(rèn)會(huì)得到與三重 map 版本相同的結(jié)果。我們可以使用它重構(gòu)代碼,并確信代碼不會(huì)崩潰。在某些情況下,我們甚至可以通過(guò)在不同方法之間進(jìn)行交換來(lái)改進(jìn)性能。
但這些例子已經(jīng)足夠了,讓我們開(kāi)始實(shí)戰(zhàn)吧。
Effect 簡(jiǎn)寫(xiě)
我們的 Effect 構(gòu)造函數(shù)接受一個(gè)函數(shù)作為它的參數(shù)。這很方便,因?yàn)榇蠖鄶?shù)我們想要延遲的副作用也是函數(shù)。例如,Math.random() 和 console.log() 都是這種類型的東西。但有時(shí)我們想把一個(gè)普通的舊值壓縮成一個(gè) Effect。例如,假設(shè)我們?cè)跒g覽器的 window 全局對(duì)象中附加了某種配置對(duì)象。我們想要得到一個(gè) a 的值,但這不是一個(gè)純粹的運(yùn)算。我們可以寫(xiě)一個(gè)小的簡(jiǎn)寫(xiě),使這個(gè)任務(wù)更容易:[3]
// of :: a -> Effect a Effect.of = function of(val) {return Effect(() => val); }; 復(fù)制代碼為了說(shuō)明這可能會(huì)很方便,假設(shè)我們正在處理一個(gè) web 應(yīng)用。這個(gè)應(yīng)用有一些標(biāo)準(zhǔn)特性,比如文章列表和用戶簡(jiǎn)介。但是在 HTML 中,這些組件針對(duì)不同的客戶進(jìn)行展示。因?yàn)槲覀兪锹斆鞯墓こ處?#xff0c;所以我們決定將他們的位置存儲(chǔ)在一個(gè)全局配置對(duì)象中,這樣我們總能找到它們。例如:
window.myAppConf = {selectors: {"user-bio": ".userbio","article-list": "#articles","user-name": ".userfullname"},templates: {greet: "Pleased to meet you, {name}",notify: "You have {n} alerts"} }; 復(fù)制代碼現(xiàn)在使用 Effect.of(),我們可以很快地把我們想要的值包裝進(jìn)一個(gè) Effect 容器, 就像這樣
const win = Effect.of(window); userBioLocator = win.map(x => x.myAppConf.selectors["user-bio"]); // ← Effect('.userbio') 復(fù)制代碼內(nèi)嵌 與 非內(nèi)嵌 Effect
映射 Effect 可能對(duì)我們大有幫助。但是有時(shí)候我們會(huì)遇到映射的函數(shù)也返回一個(gè) Effect 的情況。我們已經(jīng)定義了一個(gè) getElementLocator(),它返回一個(gè)包含字符串的 Effect。如果我們真的想要拿到 DOM 元素,我們需要調(diào)用另外一個(gè)非純函數(shù) document.querySelector()。所以我們可能會(huì)通過(guò)返回一個(gè) Effect 來(lái)純化它:
// $ :: String -> Effect DOMElement function $(selector) {return Effect.of(document.querySelector(s)); } 復(fù)制代碼現(xiàn)在如果想把它兩放一起,我們可以嘗試使用 map():
const userBio = userBioLocator.map($); // ← Effect(Effect(<div>)) 復(fù)制代碼想要真正運(yùn)作起來(lái)還有點(diǎn)尷尬。如果我們想要訪問(wèn)那個(gè) div,我們必須用一個(gè)函數(shù)來(lái)映射我們想要做的事情。例如,如果我們想要得到 innerHTML,它看起來(lái)是這樣的:
const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML)); // ← Effect(Effect('<h2>User Biography</h2>')) 復(fù)制代碼讓我們?cè)囍纸狻N覀儠?huì)回到 userBio,然后繼續(xù)。這有點(diǎn)乏味,但我們想弄清楚這里發(fā)生了什么。我們使用的標(biāo)記 Effect('user-bio') 有點(diǎn)誤導(dǎo)人。如果我們把它寫(xiě)成代碼,它看起來(lái)更像這樣:
Effect(() => ".userbio"); 復(fù)制代碼但這也不準(zhǔn)確。我們真正做的是:
Effect(() => window.myAppConf.selectors["user-bio"]); 復(fù)制代碼現(xiàn)在,當(dāng)我們進(jìn)行映射時(shí),它就相當(dāng)于將內(nèi)部函數(shù)與另一個(gè)函數(shù)組合(正如我們?cè)谏厦婵吹降?#xff09;。所以當(dāng)我們用 $ 映射時(shí),它看起來(lái)像這樣:
Effect(() => window.myAppConf.selectors["user-bio"]); 復(fù)制代碼把它展開(kāi)得到:
Effect(() => Effect.of(document.querySelector(window.myAppConf.selectors['user-bio']))) ); 復(fù)制代碼展開(kāi) Effect.of 給我們一個(gè)更清晰的概覽:
Effect(() =>Effect(() => document.querySelector(window.myAppConf.selectors["user-bio"])) ); 復(fù)制代碼注意: 所有實(shí)際執(zhí)行操作的代碼都在最里面的函數(shù)中,這些都沒(méi)有泄露到外部的 Effect。
Join
為什么要這樣拼寫(xiě)呢?我們想要這些內(nèi)嵌的 Effect 變成非內(nèi)嵌的形式。轉(zhuǎn)換過(guò)程中,要保證沒(méi)有引入任何預(yù)料之外的副作用。對(duì)于 Effect 而言, 不內(nèi)嵌的方式就是在外部函數(shù)調(diào)用 .runEffects()。 但這可能會(huì)讓人困惑。我們已經(jīng)完成了整個(gè)練習(xí),以檢查我們不會(huì)運(yùn)行任何 Effect。我們會(huì)創(chuàng)建另一個(gè)函數(shù)做同樣的事情,并將其命名為 join。我們使用 join 來(lái)解決 Effect 內(nèi)嵌的問(wèn)題,使用 runEffects() 真正運(yùn)行所有 Effect。 即使運(yùn)行的代碼是相同的,但這會(huì)使我們的意圖更加清晰。
// 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);}} } 復(fù)制代碼然后,可以用它解開(kāi)內(nèi)嵌的用戶簡(jiǎn)介元素:
const userBioHTML = Effect.of(window).map(x => x.myAppConf.selectors["user-bio"]).map($).join().map(x => x.innerHTML); // ← Effect('<h2>User Biography</h2>') 復(fù)制代碼Chain
.map() 之后緊跟 .join() 這種模式經(jīng)常出現(xiàn)。事實(shí)上,有一個(gè)簡(jiǎn)寫(xiě)函數(shù)是很方便的。這樣,無(wú)論何時(shí)我們有一個(gè)返回 Effect 的函數(shù),我們都可以使用這個(gè)簡(jiǎn)寫(xiě)函數(shù)。它可以把我們從一遍又一遍地寫(xiě) map 然后緊跟 join 中解救出來(lái)。我們這樣寫(xiě):
// 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();}} } 復(fù)制代碼我們調(diào)用新的函數(shù) chain() 因?yàn)樗试S我們把 Effect 鏈接到一起。(其實(shí)也是因?yàn)闃?biāo)準(zhǔn)告訴我們可以這樣調(diào)用它)。[4] 取到用戶簡(jiǎn)介元素的 innerHTML 可能長(zhǎng)這樣:
const userBioHTML = Effect.of(window).map(x => x.myAppConf.selectors["user-bio"]).chain($).map(x => x.innerHTML); // ← Effect('<h2>User Biography</h2>') 復(fù)制代碼不幸的是, 對(duì)于這個(gè)實(shí)現(xiàn)其他函數(shù)式語(yǔ)言有著一些不同的名字。如果你讀到它,你可能會(huì)有點(diǎn)疑惑。有時(shí)候它被稱之為 flatMap,這樣起名是說(shuō)得通的,因?yàn)槲覀兿冗M(jìn)行一個(gè)普通的映射,然后使用 .join() 扁平化結(jié)果。不過(guò)在 Haskell 中,chain 被賦予了一個(gè)令人疑惑的名字 bind。所以如果你在其他地方讀到的話,記住 chain、flatMap 和 bind 其實(shí)是同一概念的引用。
結(jié)合 Effect
這是最后一個(gè)使用 Effect 有點(diǎn)尷尬的場(chǎng)景,我們想要在一個(gè)函數(shù)中組合兩個(gè)或者多個(gè)函子。例如,如何從 DOM 中拿到用戶的名字?拿到名字后還要插入應(yīng)用配置提供的模板里呢?因此,我們可能有一個(gè)模板函數(shù)(注意我們將創(chuàng)建一個(gè)科里化版本的函數(shù))
// 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); }); 復(fù)制代碼一切都很正常,但是現(xiàn)在來(lái)獲取我們需要的數(shù)據(jù):
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}'); 復(fù)制代碼我們已經(jīng)有一個(gè)模板函數(shù)了。它接收一個(gè)字符串和一個(gè)對(duì)象并且返回一個(gè)字符串。但是我們的字符串和對(duì)象(name 和 pattern)已經(jīng)包裝到 Effect 里了。我們所要做的就是提升我們 tpl() 函數(shù)到更高的地方使得它能很好地與 Effect 工作。
讓我們看一下如果我們?cè)?pattern Effect 上用 map() 調(diào)用 tpl() 會(huì)發(fā)生什么:
pattern.map(tpl); // ← Effect([Function]) 復(fù)制代碼對(duì)照一下類型可能會(huì)使得事情更加清晰一點(diǎn)。map 的函數(shù)聲明可能長(zhǎng)這樣:
_map :: Effect a ~> (a -> b) -> Effect b_ 復(fù)制代碼這是模板函數(shù)的函數(shù)聲明:
_tpl :: String -> Object -> String_ 復(fù)制代碼因此,當(dāng)我們?cè)?pattern 上調(diào)用 map,我們?cè)?Effect 內(nèi)部得到了一個(gè)偏應(yīng)用函數(shù)(記住我們科里化過(guò) tpl)。
_Effect (Object -> String)_ 復(fù)制代碼現(xiàn)在我們想從 pattern Effect 內(nèi)部傳遞值,但我們還沒(méi)有辦法做到。我們將編寫(xiě)另一個(gè) Effect 方法(稱為 ap())來(lái)處理這個(gè)問(wèn)題:
// 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) {// 如果有人調(diào)用了 ap,我們假定 eff 里面有一個(gè)函數(shù)而不是一個(gè)值。// 我們將用 map 來(lái)進(jìn)入 eff 內(nèi)部, 并且訪問(wèn)那個(gè)函數(shù)// 拿到 g 后,就傳入 f() 的返回值return eff.map(g => g(f()));}} } 復(fù)制代碼有了它,我們可以運(yùn)行 .ap() 來(lái)應(yīng)用我們的模板函數(shù):
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') 復(fù)制代碼我們已經(jīng)實(shí)現(xiàn)我們的目標(biāo)。但有一點(diǎn)我要承認(rèn),我發(fā)現(xiàn) ap() 有時(shí)會(huì)讓人感到困惑。很難記住我必須先映射函數(shù),然后再運(yùn)行 ap()。然后我可能會(huì)忘了參數(shù)的順序。但是有一種方法可以解決這個(gè)問(wèn)題。大多數(shù)時(shí)候,我想做的是把一個(gè)普通函數(shù)提升到應(yīng)用程序的世界。也就是說(shuō),我已經(jīng)有了簡(jiǎn)單的函數(shù),我想讓它們與具有 .ap() 方法的 Effect 一起工作。我們可以寫(xiě)一個(gè)函數(shù)來(lái)做這個(gè):
// liftA2 :: (a -> b -> c) -> (Applicative a -> Applicative b -> Applicative c) const liftA2 = curry(function liftA2(f, x, y) {return y.ap(x.map(f));// 我們也可以這樣寫(xiě):// return x.map(f).chain(g => y.map(g)); }); 復(fù)制代碼我們稱它為 liftA2() 因?yàn)樗鼤?huì)提升一個(gè)接受兩個(gè)參數(shù)的函數(shù). 我們可以寫(xiě)一個(gè)與之相似的 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))); }); 復(fù)制代碼注意,liftA2 和 liftA3 從來(lái)沒(méi)有提到 Effect。理論上,它們可以與任何具有兼容 ap() 方法的對(duì)象一起工作。 使用 liftA2() 我們可以像下面這樣重寫(xiě)之前的例子:
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') 復(fù)制代碼那又怎樣?
這時(shí)候你可能會(huì)想:“這似乎為了避免隨處可見(jiàn)的奇怪的副作用而付出了很多努力”。這有什么關(guān)系?傳入?yún)?shù)到 Effect 內(nèi)部,封裝 ap() 似乎是一項(xiàng)艱巨的工作。當(dāng)不純代碼正常工作時(shí),為什么還要煩惱呢?在實(shí)際場(chǎng)景中,你什么時(shí)候會(huì)需要這個(gè)?
函數(shù)式程序員聽(tīng)起來(lái)很像是中世紀(jì)的僧侶似的,他們禁絕了塵世中的種種樂(lè)趣并且期望這能使自己變得高潔。
—John Hughes [5]
讓我們把這些反對(duì)意見(jiàn)分成兩個(gè)問(wèn)題:
函數(shù)純度重要性
函數(shù)純度的確重要。當(dāng)你單獨(dú)觀察一個(gè)小函數(shù)時(shí),一點(diǎn)點(diǎn)的副作用并不重要。寫(xiě) const pattern = window.myAppConfig.templates['greeting']; 比寫(xiě)下面這樣的代碼更加快速簡(jiǎn)單。
const pattern = Effect.of(window).map(w => w.myAppConfig.templates("greeting")); 復(fù)制代碼如果代碼里都是這樣的小函數(shù),那么繼續(xù)這么寫(xiě)也可以,副作用不足以成問(wèn)題。但這只是應(yīng)用程序中的一行代碼,其中可能包含數(shù)千甚至數(shù)百萬(wàn)行代碼。當(dāng)你試圖弄清楚為什么你的應(yīng)用程序莫名其妙地“看似毫無(wú)道理地”停止工作時(shí),函數(shù)純度就變得更加重要了。如果發(fā)生了一些意想不到的事,你試圖把問(wèn)題分解開(kāi)來(lái),找出原因。在這種情況下,可以排除的代碼越多越好。如果您的函數(shù)是純的,那么您可以確信,影響它們行為的唯一因素是傳遞給它的輸入。這就大大縮小了要考慮的異常范圍。換句話說(shuō),它能讓你少思考。這在大型、復(fù)雜的應(yīng)用程序中尤為重要。
實(shí)際場(chǎng)景中的 Effect 模式
好吧。如果你正在構(gòu)建一個(gè)大型的、復(fù)雜的應(yīng)用程序,類似 Facebook 或 Gmail。那么函數(shù)純度可能很重要。但如果不是大型應(yīng)用呢?讓我們考慮一個(gè)越發(fā)普遍的場(chǎng)景。你有一些數(shù)據(jù)。不只是一點(diǎn)點(diǎn)數(shù)據(jù),而是大量的數(shù)據(jù) —— 數(shù)百萬(wàn)行,在 CSV 文本文件或大型數(shù)據(jù)庫(kù)表中。你的任務(wù)是處理這些數(shù)據(jù)。也許你在訓(xùn)練一個(gè)人工神經(jīng)網(wǎng)絡(luò)來(lái)建立一個(gè)推理模型。也許你正試圖找出加密貨幣的下一個(gè)大動(dòng)向。無(wú)論如何, 問(wèn)題是要完成這項(xiàng)工作需要大量的處理工作。
Joel Spolsky 令人信服地論證過(guò) 函數(shù)式編程可以幫助我們解決這個(gè)問(wèn)題。我們可以編寫(xiě)并行運(yùn)行的 map 和 reduce 的替代版本,而函數(shù)純度使這成為可能。但這并不是故事的結(jié)尾。當(dāng)然,您可以編寫(xiě)一些奇特的并行處理代碼。但即便如此,您的開(kāi)發(fā)機(jī)器仍然只有 4 個(gè)內(nèi)核(如果幸運(yùn)的話,可能是 8 個(gè)或 16 個(gè))。那項(xiàng)工作仍然需要很長(zhǎng)時(shí)間。除非,也就是說(shuō),你可以在一堆處理器上運(yùn)行它,比如 GPU,或者整個(gè)處理服務(wù)器集群。
要使其工作,您需要描述您想要運(yùn)行的計(jì)算。但是,您需要在不實(shí)際運(yùn)行它們的情況下描述它們。聽(tīng)起來(lái)是不是很熟悉?理想情況下,您應(yīng)該將描述傳遞給某種框架。該框架將小心地負(fù)責(zé)讀取所有數(shù)據(jù),并將其在處理節(jié)點(diǎn)之間分割。然后框架會(huì)把結(jié)果收集在一起,告訴你它的運(yùn)行情況。這就是 TensorFlow 的工作流程。
TensorFlow? 是一個(gè)高性能數(shù)值計(jì)算開(kāi)源軟件庫(kù)。它靈活的架構(gòu)支持從桌面到服務(wù)器集群,從移動(dòng)設(shè)備到邊緣設(shè)備的跨平臺(tái)(CPU、GPU、TPU)計(jì)算部署。Google AI 組織內(nèi)的 Google Brain 小組的研究員和工程師最初開(kāi)發(fā) TensorFlow 用于支持機(jī)器學(xué)習(xí)和深度學(xué)習(xí)領(lǐng)域,其靈活的數(shù)值計(jì)算內(nèi)核也應(yīng)用于其他科學(xué)領(lǐng)域。
—TensorFlow 首頁(yè)[6]
當(dāng)您使用 TensorFlow 時(shí),你不會(huì)使用你所使用的編程語(yǔ)言中的常規(guī)數(shù)據(jù)類型。而是,你需要?jiǎng)?chuàng)建張量。如果我們想加兩個(gè)數(shù)字,它看起來(lái)是這樣的:
node1 = tf.constant(3.0, tf.float32) node2 = tf.constant(4.0, tf.float32) node3 = tf.add(node1, node2) 復(fù)制代碼上面的代碼是用 Python 編寫(xiě)的,但是它看起來(lái)和 JavaScript 沒(méi)有太大的區(qū)別,不是嗎?和我們的 Effect 類似,add 直到我們調(diào)用它才會(huì)運(yùn)行(在這個(gè)例子中使用了 sess.run()):
print("node3: ", node3) print("sess.run(node3): ", sess.run(node3)) #? node3: Tensor("Add_2:0", shape=(), dtype=float32) #? sess.run(node3): 7.0 復(fù)制代碼在調(diào)用 sess.run() 之前,我們不會(huì)得到 7.0。正如你看到的,它和延時(shí)函數(shù)很像。我們提前計(jì)劃好了計(jì)算。然后,一旦準(zhǔn)備好了,發(fā)動(dòng)戰(zhàn)爭(zhēng)。
總結(jié)
本文涉及了很多內(nèi)容,但是我們已經(jīng)探索了兩種方法來(lái)處理代碼中的函數(shù)純度:
依賴注入的工作原理是將代碼的不純部分移出函數(shù)。所以你必須把它們作為參數(shù)傳遞進(jìn)來(lái)。相比之下,Effect 函子的工作原理則是將所有內(nèi)容包裝在一個(gè)函數(shù)后面。要運(yùn)行這些 Effect,我們必須先運(yùn)行包裝器函數(shù)。
這兩種方法都是欺騙。他們不會(huì)完全去除不純,他們只是把它們推到代碼的邊緣。但這是件好事。它明確說(shuō)明了代碼的哪些部分是不純的。在調(diào)試復(fù)雜代碼庫(kù)中的問(wèn)題時(shí),很有優(yōu)勢(shì)。
這不是一個(gè)完整的定義,但暫時(shí)可以使用。我們稍后會(huì)回到正式的定義。 ?
在其他語(yǔ)言(如 Haskell)中,這稱為 IO 函子或 IO 單子。PureScript 使用 Effect 作為術(shù)語(yǔ)。我發(fā)現(xiàn)它更具有描述性。 ?
注意,不同的語(yǔ)言對(duì)這個(gè)簡(jiǎn)寫(xiě)有不同的名稱。例如,在 Haskell 中,它被稱為 pure。我不知道為什么。 ?
在這個(gè)例子中,采用了 Fantasy Land specification for Chain 規(guī)范。 ?
John Hughes, 1990, ‘Why Functional Programming Matters’, Research Topics in Functional Programming ed. D. Turner, Addison–Wesley, pp 17–42, www.cs.kent.ac.uk/people/staf… ?
TensorFlow?:面向所有人的開(kāi)源機(jī)器學(xué)習(xí)框架, www.tensorflow.org/,12 May 2018。 ?
- [歡迎通過(guò) Twitter 交流](twitter.com/share?url=h… to deal with dirty side effects in your pure functional JavaScript%E2%80%9D+by+%40jrsinclair)
- 通過(guò)電子郵件系統(tǒng)訂閱最新資訊
如果發(fā)現(xiàn)譯文存在錯(cuò)誤或其他需要改進(jìn)的地方,歡迎到 掘金翻譯計(jì)劃 對(duì)譯文進(jìn)行修改并 PR,也可獲得相應(yīng)獎(jiǎng)勵(lì)積分。文章開(kāi)頭的 本文永久鏈接 即為本文在 GitHub 上的 MarkDown 鏈接。
掘金翻譯計(jì)劃 是一個(gè)翻譯優(yōu)質(zhì)互聯(lián)網(wǎng)技術(shù)文章的社區(qū),文章來(lái)源為 掘金 上的英文分享文章。內(nèi)容覆蓋 Android、iOS、前端、后端、區(qū)塊鏈、產(chǎn)品、設(shè)計(jì)、人工智能等領(lǐng)域,想要查看更多優(yōu)質(zhì)譯文請(qǐng)持續(xù)關(guān)注 掘金翻譯計(jì)劃、官方微博、知乎專欄。
《新程序員》:云原生和全面數(shù)字化實(shí)踐50位技術(shù)專家共同創(chuàng)作,文字、視頻、音頻交互閱讀總結(jié)
以上是生活随笔為你收集整理的[译] 如何使用纯函数式 JavaScript 处理脏副作用的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: Linux系统篇-文件系统虚拟文件系统
- 下一篇: 作业9