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

GavinGong發表於2018-08-26

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

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

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

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

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

  1. 依賴注入,或者我們也可以叫它問題擱置
  2. 使用 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() 寫測試。現在,比較一下不純和純的版本,哪個更容易編寫測試?為了對不純版本的函式進行測試,我們需要一個全域性 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前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章