日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當前位置: 首頁 > 前端技术 > javascript >内容正文

javascript

[译] 如何使用纯函数式 JavaScript 处理脏副作用

發布時間:2025/6/15 javascript 21 豆豆
生活随笔 收集整理的這篇文章主要介紹了 [译] 如何使用纯函数式 JavaScript 处理脏副作用 小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
  • 原文地址:HOW TO DEAL WITH DIRTY SIDE EFFECTS IN YOUR PURE FUNCTIONAL JAVASCRIPT
  • 原文作者:James Sinclair
  • 譯文出自:掘金翻譯計劃
  • 本文永久鏈接:github.com/xitu/gold-m…
  • 譯者:Gavin-Gong
  • 校對者:huangyuanzhen, AceLeeWinnie

如何使用純函數式 JavaScript 處理臟副作用

首先,假定你對函數式編程有所涉獵。用不了多久你就能明白純函數的概念。隨著深入了解,你會發現函數式程序員似乎對純函數很著迷。他們說:“純函數讓你推敲代碼”,“純函數不太可能引發一場熱核戰爭”,“純函數提供了引用透明性”。諸如此類。他們說的并沒有錯,純函數是個好東西。但是存在一個問題……

純函數是沒有副作用的函數。[1] 但如果你了解編程,你就會知道副作用是關鍵。如果無法讀取 ? 值,為什么要在那么多地方計算它?為了把值打印出來,我們需要寫入 console 語句,發送到 printer,或其他可以被讀取到的地方。如果數據庫不能輸入任何數據,那么它又有什么用呢?我們需要從輸入設備讀取數據,通過網絡請求信息。這其中任何一件事都不可能沒有副作用。然而,函數式編程是建立在純函數之上的。那么函數式程序員是如何完成任務的呢?

簡單來說就是,做數學家做的事情:欺騙。

說他們欺騙吧,技術上又遵守規則。但是他們發現了這些規則中的漏洞,并加以利用。有兩種主要的方法:

  • 依賴注入,或者我們也可以叫它問題擱置
  • 使用 Effect 函子,我們可以把它想象為重度拖延[2]
  • 依賴注入

    依賴注入是我們處理副作用的第一種方法。在這種方法中,將代碼中的不純的部分放入函數參數中,然后我們就可以把它們看作是其他函數功能的一部分。為了解釋我的意思,我們來看看一些代碼:

    // logSomething :: String -> () function logSomething(something) {const dt = new Date().toIsoString();console.log(`${dt}: ${something}`);return something; } 復制代碼

    logSomething() 函數有兩個不純的地方:它創建了一個 Date() 對象并且把它輸出到控制臺。因此,它不僅執行了 IO 操作, 而且每次運行的時候都會給出不同的結果。那么,如何使這個函數變純?使用依賴注入,我們以函數參數的形式接受不純的部分,因此 logSomething() 函數接收三個參數,而不是一個參數:

    // logSomething: Date -> Console -> String -> () function logSomething(d, cnsl, something) {const dt = d.toIsoString();cnsl.log(`${dt}: ${something}`);return something; } 復制代碼

    然后調用它,我們必須自行明確地傳入不純的部分:

    const something = "Curiouser and curiouser!"; const d = new Date(); logSomething(d, console, something); // ? Curiouser and curiouser! 復制代碼

    現在,你可能會想:“這樣做有點傻逼。這樣把問題變得更嚴重了,代碼還是和之前一樣不純”。你是對的。這完全就是一個漏洞。

    YouTube 視頻鏈接:youtu.be/9ZSoJDUD_bU

    這就像是在裝傻:“噢!不!警官,我不知道在 cnsl 上調用 log() 會執行 IO 操作。這是別人傳給我的。我不知道它從哪來的”,這看起來有點蹩腳。

    這并不像表面上那么愚蠢,注意我們的 logSomething() 函數。如果你要處理一些不純的事情, 你就不得不把它變得不純。我們可以簡單地傳入不同的參數:

    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!" 復制代碼

    現在,我們的函數什么事情也沒干,除了返回 something 參數。但是它是純的。如果你用相同的參數調用它,它每次都會返回相同的結果。這才是重點。為了使它變得不純,我們必須采取深思熟慮的行動。或者換句話說,函數依賴于右邊的簽名。函數無法訪問到像 console 或者 Date 之類的全局變量。這樣所有事情就很明確了。

    同樣需要注意的是,我們也可以將函數傳遞給原來不純的函數。讓我們看一下另一個例子。假設表單中有一個 username 字段。我們想要從表單中取到它的值:

    // getUserNameFromDOM :: () -> String function getUserNameFromDOM() {return document.querySelector("#username").value; }const username = getUserNameFromDOM(); username; // ← "mhatter" 復制代碼

    在這個例子中,我們嘗試去從 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; // ← "mhatter" 復制代碼

    現在,你可能還是會認為:“這樣還是一樣傻啊!” 我們所做只是把不純的代碼從 getUsernameFromDOM() 移出來而已。它并沒有消失,我們只是把它放在了另一個函數 qs() 中。除了使代碼更長之外,它似乎沒什么作用。我們兩個函數取代了之前一個不純的函數,但是其中一個仍然不純。

    別著急,假設我們想給 getUserNameFromDOM() 寫測試?,F在,比較一下不純和純的版本,哪個更容易編寫測試?為了對不純版本的函數進行測試,我們需要一個全局 document 對象,除此之外,還需要一個 ID 為 username 的元素。如果我想在瀏覽器之外測試它,那么我必須導入諸如 JSDOM 或無頭瀏覽器之類的東西。這一切都是為了測試一個很小的函數。但是使用第二個版本的函數,我可以這樣做:

    const qsStub = () => ({value: "mhatter"}); const username = getUserNameFromDOM(qsStub); assert.strictEqual("mhatter", username, `Expected username to be ${username}`); 復制代碼

    現在,這并不意味著你不應該創建在真正的瀏覽器中運行的集成測試。(或者,至少是像 JSDOM 這樣的模擬版本)。但是這個例子所展示的是 getUserNameFromDOM() 現在是完全可預測的。如果我們傳遞給它 qsStub 它總是會返回 mhatter。我們把不可預測轉性移到了更小的函數 qs 中。

    如果我們這樣做,就可以把這種不可預測性推得越來越遠。最終,我們將它們推到代碼的邊界。因此,我們最終得到了一個由不純代碼組成的薄殼,它包圍著一個測試友好的、可預測的核心。當您開始構建更大的應用程序時,這種可預測性就會起到很大的作用。

    依賴注入的缺點

    可以以這種方式創建大型、復雜的應用程序。我知道是 因為我做過。 依賴注入使測試變得更容易,也會使每個函數的依賴關系變得明確。但它也有一些缺點。最主要的一點是,你最終會得到類似這樣冗長的函數簽名:

    function app(doc, con, ftch, store, config, ga, d, random) {// 這里是應用程序代碼 }app(document, console, fetch, store, config, ga, new Date(), Math.random); 復制代碼

    這還不算太糟,除此之外你可能遇到參數鉆井的問題。在一個底層的函數中,你可能需要這些參數中的一個。因此,您必須通過許多層的函數調用來連接參數。這讓人惱火。例如,您可能需要通過 5 層中間函數傳遞日期。所有這些中間函數都不使用 date 對象。這不是世界末日,至少能夠看到這些顯式的依賴關系還是不錯的。但它仍然讓人惱火。這還有另一種方法……

    懶函數

    讓我們看看函數式程序員利用的第二個漏洞。它像這樣:“發生的副作用才是副作用”。我知道這聽起來神秘的。讓我們試著讓它更明確一點。思考一下這段代碼:

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發射核彈的代碼return 0; } 復制代碼

    我知道這是個愚蠢的例子。如果我們想在代碼中有一個 0,我們可以直接寫出來。我知道你,文雅的讀者,永遠不會用 JavaScript 寫控制核武器的代碼。但它有助于說明這一點。這顯然是不純的代碼。因為它輸出日志到控制臺,也可能開始熱核戰爭。假設我們想要 0。假設我們想要計算導彈發射后的情況,我們可能需要啟動倒計時之類的東西。在這種情況下,提前計劃如何進行計算是完全合理的。我們會非常小心這些導彈什么時候起飛,我們不想搞混我們的計算結果,以免他們意外發射導彈。那么,如果我們將 fZero() 包裝在另一個只返回它的函數中呢?有點像安全包裝。

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發射核彈的代碼return 0; }// returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {return fZero; } 復制代碼

    我可以運行 returnZeroFunc() 任意次,只要不調用返回值,我理論上就是安全的。我的代碼不會發射任何核彈。

    const zeroFunc1 = returnZeroFunc(); const zeroFunc2 = returnZeroFunc(); const zeroFunc3 = returnZeroFunc(); // 沒有發射核彈。 復制代碼

    現在,讓我們更正式地定義純函數。然后,我們可以更詳細地檢查我們的 returnZeroFunc() 函數。如果一個函數滿足以下條件就可以稱之為純函數:

  • 沒有明顯的副作用
  • 引用透明。也就是說,給定相同的輸入,它總是返回相同的輸出。
  • 讓我們看看 returnZeroFunc()。有副作用嗎?嗯,之前我們確定過,調用 returnZeroFunc() 不會發射任何核導彈。除非執行調用返回函數的額外步驟,否則什么也不會發生。所以,這個函數沒有副作用。

    returnZeroFunc() 引用透明嗎?也就是說,給定相同的輸入,它總是返回相同的輸出?好吧,按照它目前的編寫方式,我們可以測試它:

    zeroFunc1 === zeroFunc2; // true zeroFunc2 === zeroFunc3; // true 復制代碼

    但它還不能算純。returnZeroFunc() 函數引用函數作用域外的一個變量。為了解決這個問題,我們可以以這種方式進行重寫:

    // returnZeroFunc :: () -> (() -> Number) function returnZeroFunc() {function fZero() {console.log("Launching nuclear missiles");// 這里是發射核彈的代碼return 0;}return fZero; } 復制代碼

    現在我們的函數是純函數了。但是,JavaScript 阻礙了我們。我們無法再使用 === 來驗證引用透明性。這是因為 returnZeroFunc() 總是返回一個新的函數引用。但是你可以通過審查代碼來檢查引用透明。returnZeroFunc() 函數每次除了返回相同的函數其他什么也不做。

    這是一個巧妙的小漏洞。但我們真的能把它用在真正的代碼上嗎?答案是肯定的。但在我們討論如何在實踐中實現它之前,先放到一邊。先回到危險的 fZero() 函數:

    // fZero :: () -> Number function fZero() {console.log("Launching nuclear missiles");// 這里是發射核彈的代碼return 0; } 復制代碼

    讓我們嘗試使用 fZero() 返回的零,但這不會發動熱核戰爭(笑)。我們將創建一個函數,它接受 fZero() 最終返回的 0,并在此基礎上加一:

    // fIncrement :: (() -> Number) -> Number function fIncrement(f) {return f() + 1; }fIncrement(fZero); // ? 發射導彈 // ← 1 復制代碼

    哎呦!我們意外地發動了熱核戰爭。讓我們再試一次。這一次,我們不會返回一個數字。相反,我們將返回一個最終返回一個數字的函數:

    // fIncrement :: (() -> Number) -> (() -> Number) function fIncrement(f) {return () => f() + 1; }fIncrement(zero); // ← [Function] 復制代碼

    唷!危機避免了。讓我們繼續。有了這兩個函數,我們可以創建一系列的 '最終數字'(譯者注:最終數字即返回數字的函數,后面多次出現):

    const fOne = fIncrement(zero); const fTwo = fIncrement(one); const fThree = fIncrement(two); // 等等… 復制代碼

    我們也可以創建一組 f*() 函數來處理最終值:

    // 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); // 沒有控制臺日志或熱核戰爭。干得不錯! 復制代碼

    看到我們做了什么了嗎?如果能用普通數字來做的,那么我們也可以用最終數字。數學稱之為 同構。我們總是可以把一個普通的數放在一個函數中,將其變成一個最終數字。我們可以通過調用這個函數得到最終的數字。換句話說,我們建立一個數字和最終數字之間映射。這比聽起來更令人興奮。我保證,我們很快就會回到這個問題上。

    這樣進行函數包裝是合法的策略。我們可以一直躲在函數后面,想躲多久就躲多久。只要我們不調用這些函數,它們理論上都是純的。世界和平。在常規(非核)代碼中,我們實際上最終希望得到那些副作用能夠運行。將所有東西包裝在一個函數中可以讓我們精確地控制這些效果。我們決定這些副作用發生的確切時間。但是,輸入那些括號很痛苦。創建每個函數的新版本很煩人。我們在語言中內置了一些非常好的函數,比如 Math.sqrt()。如果有一種方法可以用延遲值來使用這些普通函數就好了。進入下一節 Effect 函子。

    Effect 函子

    就目的而言,Effect 函子只不過是一個被置入延遲函數的對象。我們想把 fZero 函數置入到一個 Effect 對象中。但是,在這樣做之前,先把難度降低一個等級

    // zero :: () -> Number function fZero() {console.log("Starting with nothing");// 絕對不會在這里發動核打擊。// 但是這個函數仍然不純return 0; } 復制代碼

    現在我們創建一個返回 Effect 對象的構造函數

    // Effect :: Function -> Effect function Effect(f) {return {}; } 復制代碼

    到目前為止,還沒有什么可看的。讓我們做一些有用的事情。我們希望配合 Effetct 使用常規的 fZero() 函數。我們將編寫一個接收常規函數并延后返回值的方法,它運行時不觸發任何效果。我們稱之為 map。這是因為它在常規函數和 Effect 函數之間創建了一個映射。它可能看起來像這樣:

    // Effect :: Function -> Effect function Effect(f) {return {map(g) {return Effect(x => g(f(x)));}}; } 復制代碼

    現在,如果你觀察仔細的話,你可能想知道 map() 的作用。它看起來像是組合。我們稍后會講到?,F在,讓我們嘗試一下:

    const zero = Effect(fZero); const increment = x => x + 1; // 一個普通的函數。 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; // 只是一個普通的函數 const one = zero.map(increment);one.runEffects(); // ? 什么也沒啟動 // ← 1 復制代碼

    并且只要我們愿意, 我們可以一直調用 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(); // ? 什么也沒啟動 // ← 8 復制代碼

    從這里開始變得有意思了。我們稱這為函子,這意味著 Effect 有一個 map 函數,它 遵循一些規則。這些規則并不意味著你不能這樣做。它們是你的行為準則。它們更像是優先級。因為 Effect 是函子大家庭的一份子,所以它可以做一些事情,其中一個叫做“合成規則”。它長這樣:

    如果我們有一個 Effect e, 兩個函數 f 和 g
    那么 e.map(g).map(f) 等同于 e.map(x => f(g(x)))。

    換句話說,一行寫兩個 map 函數等同于組合這兩個函數。也就是說 Effect 可以這樣寫(回顧一下上面的例子):

    const incDoubleCube = x => cube(double(increment(x))); // 如果你使用像 Ramda 或者 lodash/fp 之類的庫,我們也可以這樣寫: // const incDoubleCube = compose(cube, double, increment); const eight = Effect(fZero).map(incDoubleCube); 復制代碼

    當我們這樣做的時候,我們可以確認會得到與三重 map 版本相同的結果。我們可以使用它重構代碼,并確信代碼不會崩潰。在某些情況下,我們甚至可以通過在不同方法之間進行交換來改進性能。

    但這些例子已經足夠了,讓我們開始實戰吧。

    Effect 簡寫

    我們的 Effect 構造函數接受一個函數作為它的參數。這很方便,因為大多數我們想要延遲的副作用也是函數。例如,Math.random() 和 console.log() 都是這種類型的東西。但有時我們想把一個普通的舊值壓縮成一個 Effect。例如,假設我們在瀏覽器的 window 全局對象中附加了某種配置對象。我們想要得到一個 a 的值,但這不是一個純粹的運算。我們可以寫一個小的簡寫,使這個任務更容易:[3]

    // of :: a -> Effect a Effect.of = function of(val) {return Effect(() => val); }; 復制代碼

    為了說明這可能會很方便,假設我們正在處理一個 web 應用。這個應用有一些標準特性,比如文章列表和用戶簡介。但是在 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.of(),我們可以很快地把我們想要的值包裝進一個 Effect 容器, 就像這樣

    const win = Effect.of(window); userBioLocator = win.map(x => x.myAppConf.selectors["user-bio"]); // ← Effect('.userbio') 復制代碼

    內嵌 與 非內嵌 Effect

    映射 Effect 可能對我們大有幫助。但是有時候我們會遇到映射的函數也返回一個 Effect 的情況。我們已經定義了一個 getElementLocator(),它返回一個包含字符串的 Effect。如果我們真的想要拿到 DOM 元素,我們需要調用另外一個非純函數 document.querySelector()。所以我們可能會通過返回一個 Effect 來純化它:

    // $ :: String -> Effect DOMElement function $(selector) {return Effect.of(document.querySelector(s)); } 復制代碼

    現在如果想把它兩放一起,我們可以嘗試使用 map():

    const userBio = userBioLocator.map($); // ← Effect(Effect(<div>)) 復制代碼

    想要真正運作起來還有點尷尬。如果我們想要訪問那個 div,我們必須用一個函數來映射我們想要做的事情。例如,如果我們想要得到 innerHTML,它看起來是這樣的:

    const innerHTML = userBio.map(eff => eff.map(domEl => domEl.innerHTML)); // ← Effect(Effect('<h2>User Biography</h2>')) 復制代碼

    讓我們試著分解。我們會回到 userBio,然后繼續。這有點乏味,但我們想弄清楚這里發生了什么。我們使用的標記 Effect('user-bio') 有點誤導人。如果我們把它寫成代碼,它看起來更像這樣:

    Effect(() => ".userbio"); 復制代碼

    但這也不準確。我們真正做的是:

    Effect(() => window.myAppConf.selectors["user-bio"]); 復制代碼

    現在,當我們進行映射時,它就相當于將內部函數與另一個函數組合(正如我們在上面看到的)。所以當我們用 $ 映射時,它看起來像這樣:

    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"])) ); 復制代碼

    注意: 所有實際執行操作的代碼都在最里面的函數中,這些都沒有泄露到外部的 Effect。

    Join

    為什么要這樣拼寫呢?我們想要這些內嵌的 Effect 變成非內嵌的形式。轉換過程中,要保證沒有引入任何預料之外的副作用。對于 Effect 而言, 不內嵌的方式就是在外部函數調用 .runEffects()。 但這可能會讓人困惑。我們已經完成了整個練習,以檢查我們不會運行任何 Effect。我們會創建另一個函數做同樣的事情,并將其命名為 join。我們使用 join 來解決 Effect 內嵌的問題,使用 runEffects() 真正運行所有 Effect。 即使運行的代碼是相同的,但這會使我們的意圖更加清晰。

    // 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() 這種模式經常出現。事實上,有一個簡寫函數是很方便的。這樣,無論何時我們有一個返回 Effect 的函數,我們都可以使用這個簡寫函數。它可以把我們從一遍又一遍地寫 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).join();}} } 復制代碼

    我們調用新的函數 chain() 因為它允許我們把 Effect 鏈接到一起。(其實也是因為標準告訴我們可以這樣調用它)。[4] 取到用戶簡介元素的 innerHTML 可能長這樣:

    const userBioHTML = Effect.of(window).map(x => x.myAppConf.selectors["user-bio"]).chain($).map(x => x.innerHTML); // ← Effect('<h2>User Biography</h2>') 復制代碼

    不幸的是, 對于這個實現其他函數式語言有著一些不同的名字。如果你讀到它,你可能會有點疑惑。有時候它被稱之為 flatMap,這樣起名是說得通的,因為我們先進行一個普通的映射,然后使用 .join() 扁平化結果。不過在 Haskell 中,chain 被賦予了一個令人疑惑的名字 bind。所以如果你在其他地方讀到的話,記住 chain、flatMap 和 bind 其實是同一概念的引用。

    結合 Effect

    這是最后一個使用 Effect 有點尷尬的場景,我們想要在一個函數中組合兩個或者多個函子。例如,如何從 DOM 中拿到用戶的名字?拿到名字后還要插入應用配置提供的模板里呢?因此,我們可能有一個模板函數(注意我們將創建一個科里化版本的函數)

    // 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); }); 復制代碼

    一切都很正常,但是現在來獲取我們需要的數據:

    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}'); 復制代碼

    我們已經有一個模板函數了。它接收一個字符串和一個對象并且返回一個字符串。但是我們的字符串和對象(name 和 pattern)已經包裝到 Effect 里了。我們所要做的就是提升我們 tpl() 函數到更高的地方使得它能很好地與 Effect 工作。

    讓我們看一下如果我們在 pattern Effect 上用 map() 調用 tpl() 會發生什么:

    pattern.map(tpl); // ← Effect([Function]) 復制代碼

    對照一下類型可能會使得事情更加清晰一點。map 的函數聲明可能長這樣:

    _map :: Effect a ~> (a -> b) -> Effect b_ 復制代碼

    這是模板函數的函數聲明:

    _tpl :: String -> Object -> String_ 復制代碼

    因此,當我們在 pattern 上調用 map,我們在 Effect 內部得到了一個偏應用函數(記住我們科里化過 tpl)。

    _Effect (Object -> String)_ 復制代碼

    現在我們想從 pattern Effect 內部傳遞值,但我們還沒有辦法做到。我們將編寫另一個 Effect 方法(稱為 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) {// 如果有人調用了 ap,我們假定 eff 里面有一個函數而不是一個值。// 我們將用 map 來進入 eff 內部, 并且訪問那個函數// 拿到 g 后,就傳入 f() 的返回值return eff.map(g => g(f()));}} } 復制代碼

    有了它,我們可以運行 .ap() 來應用我們的模板函數:

    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() 有時會讓人感到困惑。很難記住我必須先映射函數,然后再運行 ap()。然后我可能會忘了參數的順序。但是有一種方法可以解決這個問題。大多數時候,我想做的是把一個普通函數提升到應用程序的世界。也就是說,我已經有了簡單的函數,我想讓它們與具有 .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));// 我們也可以這樣寫:// 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))); }); 復制代碼

    注意,liftA2 和 liftA3 從來沒有提到 Effect。理論上,它們可以與任何具有兼容 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') 復制代碼

    那又怎樣?

    這時候你可能會想:“這似乎為了避免隨處可見的奇怪的副作用而付出了很多努力”。這有什么關系?傳入參數到 Effect 內部,封裝 ap() 似乎是一項艱巨的工作。當不純代碼正常工作時,為什么還要煩惱呢?在實際場景中,你什么時候會需要這個?

    函數式程序員聽起來很像是中世紀的僧侶似的,他們禁絕了塵世中的種種樂趣并且期望這能使自己變得高潔。

    —John Hughes [5]

    讓我們把這些反對意見分成兩個問題:

  • 函數純度真的重要嗎?
  • 在真實場景中什么時候有用?
  • 函數純度重要性

    函數純度的確重要。當你單獨觀察一個小函數時,一點點的副作用并不重要。寫 const pattern = window.myAppConfig.templates['greeting']; 比寫下面這樣的代碼更加快速簡單。

    const pattern = Effect.of(window).map(w => w.myAppConfig.templates("greeting")); 復制代碼

    如果代碼里都是這樣的小函數,那么繼續這么寫也可以,副作用不足以成問題。但這只是應用程序中的一行代碼,其中可能包含數千甚至數百萬行代碼。當你試圖弄清楚為什么你的應用程序莫名其妙地“看似毫無道理地”停止工作時,函數純度就變得更加重要了。如果發生了一些意想不到的事,你試圖把問題分解開來,找出原因。在這種情況下,可以排除的代碼越多越好。如果您的函數是純的,那么您可以確信,影響它們行為的唯一因素是傳遞給它的輸入。這就大大縮小了要考慮的異常范圍。換句話說,它能讓你少思考。這在大型、復雜的應用程序中尤為重要。

    實際場景中的 Effect 模式

    好吧。如果你正在構建一個大型的、復雜的應用程序,類似 Facebook 或 Gmail。那么函數純度可能很重要。但如果不是大型應用呢?讓我們考慮一個越發普遍的場景。你有一些數據。不只是一點點數據,而是大量的數據 —— 數百萬行,在 CSV 文本文件或大型數據庫表中。你的任務是處理這些數據。也許你在訓練一個人工神經網絡來建立一個推理模型。也許你正試圖找出加密貨幣的下一個大動向。無論如何, 問題是要完成這項工作需要大量的處理工作。

    Joel Spolsky 令人信服地論證過 函數式編程可以幫助我們解決這個問題。我們可以編寫并行運行的 map 和 reduce 的替代版本,而函數純度使這成為可能。但這并不是故事的結尾。當然,您可以編寫一些奇特的并行處理代碼。但即便如此,您的開發機器仍然只有 4 個內核(如果幸運的話,可能是 8 個或 16 個)。那項工作仍然需要很長時間。除非,也就是說,你可以在一堆處理器上運行它,比如 GPU,或者整個處理服務器集群。

    要使其工作,您需要描述您想要運行的計算。但是,您需要在不實際運行它們的情況下描述它們。聽起來是不是很熟悉?理想情況下,您應該將描述傳遞給某種框架。該框架將小心地負責讀取所有數據,并將其在處理節點之間分割。然后框架會把結果收集在一起,告訴你它的運行情況。這就是 TensorFlow 的工作流程。

    TensorFlow? 是一個高性能數值計算開源軟件庫。它靈活的架構支持從桌面到服務器集群,從移動設備到邊緣設備的跨平臺(CPU、GPU、TPU)計算部署。Google AI 組織內的 Google Brain 小組的研究員和工程師最初開發 TensorFlow 用于支持機器學習和深度學習領域,其靈活的數值計算內核也應用于其他科學領域。

    —TensorFlow 首頁[6]

    當您使用 TensorFlow 時,你不會使用你所使用的編程語言中的常規數據類型。而是,你需要創建張量。如果我們想加兩個數字,它看起來是這樣的:

    node1 = tf.constant(3.0, tf.float32) node2 = tf.constant(4.0, tf.float32) node3 = tf.add(node1, node2) 復制代碼

    上面的代碼是用 Python 編寫的,但是它看起來和 JavaScript 沒有太大的區別,不是嗎?和我們的 Effect 類似,add 直到我們調用它才會運行(在這個例子中使用了 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 復制代碼

    在調用 sess.run() 之前,我們不會得到 7.0。正如你看到的,它和延時函數很像。我們提前計劃好了計算。然后,一旦準備好了,發動戰爭。

    總結

    本文涉及了很多內容,但是我們已經探索了兩種方法來處理代碼中的函數純度:

  • 依賴注入
  • Effect 函子
  • 依賴注入的工作原理是將代碼的不純部分移出函數。所以你必須把它們作為參數傳遞進來。相比之下,Effect 函子的工作原理則是將所有內容包裝在一個函數后面。要運行這些 Effect,我們必須先運行包裝器函數。

    這兩種方法都是欺騙。他們不會完全去除不純,他們只是把它們推到代碼的邊緣。但這是件好事。它明確說明了代碼的哪些部分是不純的。在調試復雜代碼庫中的問題時,很有優勢。

  • 這不是一個完整的定義,但暫時可以使用。我們稍后會回到正式的定義。 ?

  • 在其他語言(如 Haskell)中,這稱為 IO 函子或 IO 單子。PureScript 使用 Effect 作為術語。我發現它更具有描述性。 ?

  • 注意,不同的語言對這個簡寫有不同的名稱。例如,在 Haskell 中,它被稱為 pure。我不知道為什么。 ?

  • 在這個例子中,采用了 Fantasy Land specification for Chain 規范。 ?

  • 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?:面向所有人的開源機器學習框架, www.tensorflow.org/,12 May 2018。 ?

    • [歡迎通過 Twitter 交流](twitter.com/share?url=h… to deal with dirty side effects in your pure functional JavaScript%E2%80%9D+by+%40jrsinclair)
    • 通過電子郵件系統訂閱最新資訊

    如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改并 PR,也可獲得相應獎勵積分。文章開頭的 本文永久鏈接 即為本文在 GitHub 上的 MarkDown 鏈接。


    掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、后端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。

    《新程序員》:云原生和全面數字化實踐50位技術專家共同創作,文字、視頻、音頻交互閱讀

    總結

    以上是生活随笔為你收集整理的[译] 如何使用纯函数式 JavaScript 处理脏副作用的全部內容,希望文章能夠幫你解決所遇到的問題。

    如果覺得生活随笔網站內容還不錯,歡迎將生活随笔推薦給好友。