如何使用純函式式 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()
寫測試。現在,比較一下不純和純的版本,哪個更容易編寫測試?為了對不純版本的函式進行測試,我們需要一個全域性 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()
的作用。它看起來像是組合。我們稍後會講到。現在,讓我們嘗試一下:
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、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。