[譯] 如何使用純函數式 JavaScript 處理髒反作用

如何使用純函數式 JavaScript 處理髒反作用

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

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

簡單來講就是,作數學家作的事情:欺騙。前端

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

  1. 依賴注入,或者咱們也能夠叫它問題擱置
  2. 使用 Effect 函子,咱們能夠把它想象爲重度拖延[2]

依賴注入

依賴注入是咱們處理反作用的第一種方法。在這種方法中,將代碼中的不純的部分放入函數參數中,而後咱們就能夠把它們看做是其餘函數功能的一部分。爲了解釋個人意思,咱們來看看一些代碼:node

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

logSomething() 函數有兩個不純的地方:它建立了一個 Date() 對象而且把它輸出到控制檯。所以,它不只執行了 IO 操做, 並且每次運行的時候都會給出不一樣的結果。那麼,如何使這個函數變純?使用依賴注入,咱們以函數參數的形式接受不純的部分,所以 logSomething() 函數接收三個參數,而不是一個參數:python

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

而後調用它,咱們必須自行明確地傳入不純的部分:android

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

如今,你可能會想:「這樣作有點傻逼。這樣把問題變得更嚴重了,代碼仍是和以前同樣不純」。你是對的。這徹底就是一個漏洞。ios

YouTube 視頻連接:youtu.be/9ZSoJDUD_bUgit

這就像是在裝傻:「噢!不!警官,我不知道在 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() 寫測試。如今,比較一下不純和純的版本,哪一個更容易編寫測試?爲了對不純版本的函數進行測試,咱們須要一個全局 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() 函數。若是一個函數知足如下條件就能夠稱之爲純函數:

  1. 沒有明顯的反作用
  2. 引用透明。也就是說,給定相同的輸入,它老是返回相同的輸出。

讓咱們看看 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() 的做用。它看起來像是組合。咱們稍後會講到。如今,讓咱們嘗試一下:

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, 兩個函數 fg
那麼 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。因此若是你在其餘地方讀到的話,記住 chainflatMapbind 實際上是同一律唸的引用。

結合 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}');
複製代碼

咱們已經有一個模板函數了。它接收一個字符串和一個對象而且返回一個字符串。可是咱們的字符串和對象(namepattern)已經包裝到 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)));
});
複製代碼

注意,liftA2liftA3 歷來沒有提到 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]

讓咱們把這些反對意見分紅兩個問題:

  1. 函數純度真的重要嗎?
  2. 在真實場景中何時有用?

函數純度重要性

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

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

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

實際場景中的 Effect 模式

好吧。若是你正在構建一個大型的、複雜的應用程序,相似 Facebook 或 Gmail。那麼函數純度可能很重要。但若是不是大型應用呢?讓咱們考慮一個愈加廣泛的場景。你有一些數據。不僅是一點點數據,而是大量的數據 —— 數百萬行,在 CSV 文本文件或大型數據庫表中。你的任務是處理這些數據。也許你在訓練一我的工神經網絡來創建一個推理模型。也許你正試圖找出加密貨幣的下一個大動向。不管如何, 問題是要完成這項工做須要大量的處理工做。

Joel Spolsky 使人信服地論證過 函數式編程能夠幫助咱們解決這個問題。咱們能夠編寫並行運行的 mapreduce 的替代版本,而函數純度使這成爲可能。但這並非故事的結尾。固然,您能夠編寫一些奇特的並行處理代碼。但即使如此,您的開發機器仍然只有 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。正如你看到的,它和延時函數很像。咱們提早計劃好了計算。而後,一旦準備好了,發動戰爭。

總結

本文涉及了不少內容,可是咱們已經探索了兩種方法來處理代碼中的函數純度:

  1. 依賴注入
  2. Effect 函子

依賴注入的工做原理是將代碼的不純部分移出函數。因此你必須把它們做爲參數傳遞進來。相比之下,Effect 函子的工做原理則是將全部內容包裝在一個函數後面。要運行這些 Effect,咱們必須先運行包裝器函數。

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

  1. 這不是一個完整的定義,但暫時可使用。咱們稍後會回到正式的定義。

  2. 在其餘語言(如 Haskell)中,這稱爲 IO 函子或 IO 單子。PureScript 使用 Effect 做爲術語。我發現它更具備描述性。

  3. 注意,不一樣的語言對這個簡寫有不一樣的名稱。例如,在 Haskell 中,它被稱爲 pure。我不知道爲何。

  4. 在這個例子中,採用了 Fantasy Land specification for Chain 規範。

  5. 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…

  6. TensorFlow™:面向全部人的開源機器學習框架, www.tensorflow.org/,12 May 2018。

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


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

相關文章
相關標籤/搜索