- 原文地址:Algebraic Effects for the Rest of Us
- 原文做者:Dan Abramov
- 譯文出自:掘金翻譯計劃
- 本文永久連接:github.com/xitu/gold-m…
- 譯者:TiaossuP
- 校對者:Fengziyin1234、Baddyo
你據說過代數效應(Algebraic Effects)麼?javascript
我第一次研究「它是什麼」以及「我爲什麼要關注它」的嘗試以失敗了結。我看了一些 PDF,但最終我更加懵逼了。(其中有一些偏學術性質的 pdf 真是催眠。)html
可是個人同事 Sebastian 總是將其稱爲咱們在 React 內部的一些工做的心智模型(Sebastian 在 React 團隊,並貢獻出了 Hooks、Suspense 等創意)。從某個角度來講,這已經成了 React 團隊內部的一個梗 —— 咱們不少討論都會以這張圖結束:前端
事實證實,代數效應是一個很酷的概念,並不像我從那些 pdf 看到得那樣可怕。若是你只是使用 React,你不須要了解它們 —— 但若是你像我同樣,對其感到好奇,請繼續閱讀。java
(免責聲明:我不是編程語言研究員、不是這個話題的權威人士,可能我這裏的介紹有錯漏,因此哪裏有問題的話,請告訴我!)react
代數效應是一個處在研究階段的編程語言特性,這意味着其不像 if、functions、async / await 同樣,你可能沒法在生產環境真正用上它,它如今只被幾個語言支持,而這幾個語言是專門爲了研究此概念而創造的。在 Ocaml 中實現代數效應的進展還處於進行中狀態。換句話說,你碰不到它(原文:Can’t Touch This)android
補充:一些人說 LISP 提供了相似的功能,因此若是你寫 LISP,就能夠在生產環境中用上該功能了。ios
想象你寫 goto
的代碼時,有人向你介紹了 if
與 for
語句,或者陷入回調地獄的你看到了 async / await
—— 是否是碉堡了?git
若是你是那種在某些編程概念成爲主流以前就樂於瞭解它們的人,那麼如今多是對代數效應感到好奇的好時機。不過這也不是必須的,這有點像 1999 年的 async / await
設想。github
這個名稱可能有點使人生畏,但這個思想其實很簡單。若是你熟悉 try / catch
塊,你會更容易大體理解代數效應。數據庫
咱們先來回顧一下 try / catch
。假設你有一個會 throw 的函數。也許它和 catch
塊之間還有不少層函數:
function getName(user) {
let name = user.name;
if (name === null) {
throw new Error('A girl has no name'); }
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
makeFriends(arya, gendry);
} catch (err) {
console.log("Oops, that didn't work out: ", err);}
複製代碼
咱們在 getName
裏面 throw
,但它「冒泡」到了離 makeFriends
最近的 catch
塊。這是 try / catch
的一個重要屬性。調用的中間層不須要關心錯誤處理。
與 C 語言中的錯誤代碼不一樣,經過 try / catch
,您沒必要手動將 error 傳遞到每一箇中間層,以避免丟失它們。它們會自動冒泡。
在上面的例子中,一旦咱們遇到錯誤,代碼就沒法繼續執行。當咱們最終進入 catch
塊時,就沒法再繼續執行原始代碼了。
完蛋了,一步出錯全盤皆輸。這太晚了。咱們頂多也就只能從失敗中恢復過來,也許還能夠經過某種方式重試咱們正在作的事情,但不可能神奇地「回到」咱們代碼剛剛所處的位置,並作點兒別的事情。但憑藉代數效應,咱們能夠。
這是一個用假想的 JavaScript 方言編寫的例子(爲了搞事,讓咱們稱其爲 ES2025),讓咱們從缺失的 user.name
「恢復」一下:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
複製代碼
(我向 2025 年在網上搜索「ES2025」並找到這篇文章的全部讀者致歉。若是將來代數效應成爲了 JavaScript 的一部分,我很樂意更新這篇文章!)
咱們使用一個假設的 perform
關鍵字代替 throw
。一樣,咱們使用假想的 try / handle
語句來代替 try / catch
。確切的語法在這裏並不重要 —— 咱們只是隨便編個語法來表達這個思想。
那麼發生了什麼?讓咱們仔細看看。
咱們 perform 了一個 effect,而不是 throw 一個 error。就像咱們能夠 throw
任何值同樣,咱們能夠將任何值傳給 perform
。在這個例子中,我傳入了一個字符串,但它能夠是一個對象,或任何其餘數據類型:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
複製代碼
當咱們 throw
了一個 error 時,引擎會在調用堆棧中查找最接近的 try / catch
error handler。相似地,當咱們 perform
了一個 effect 時,引擎會在調用堆棧中搜索最接近的 try / handle
effect handler。
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
複製代碼
這個 effect 讓咱們決定如何處理缺乏 name 的狀況。這裏的假想語法(對應錯誤處理)是 resume with
:
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
複製代碼
這但是你用 try / catch
作不到的事情。它容許咱們跳回到咱們 perform effect 的位置,並從 handler 傳回一些東西。🤯
function getName(user) {
let name = user.name;
if (name === null) {
// 1. 咱們在這裏 perform 了一個 effect:name = perform 'ask_name';
// 4. …… 而後最終回到了這裏(name 如今是「Arya Stark」了
}
return name;
}
// ...
try {
makeFriends(arya, gendry);
} handle (effect) {
// 2. 咱們跳到了handler(就像 try/catch)
if (effect === 'ask_name') {
// 3. 然而咱們能夠 resume with 一個值(這就不像 try / catch 了!)
resume with 'Arya Stark';
}
}
複製代碼
這須要你花一些時間來適應,但它在概念上與「可恢復的 try / catch
」沒有太大的不一樣。
可是請注意,代數效應要比 try / catch 更靈活,而且可恢復的錯誤只是許多可能的用例之一。我從這個角度開始介紹只是由於這是最容易理解的方式。
代數效應對異步代碼有很是有趣的價值。
在具備 async / await
的語言中,函數一般具備「顏色」。例如,在 JavaScript 中,咱們不能將 getName
標識爲異步,但不爲其調用者 makeFriends
及 makeFriends
的調用者增長 async
關鍵字。一段代碼有時須要同步、有時須要異步時,開發起來其實會比較痛苦。
// 若是咱們想在這裏加一個 async 關鍵字
async getName(user) {
// ...
}
// 那麼這裏也就必須也是 async 了……
async function makeFriends(user1, user2) {
user1.friendNames.add(await getName(user2));
user2.friendNames.add(await getName(user1));
}
// 以此類推……
複製代碼
JavaScript 的 generator 一樣相似:若是你用了 generator,那麼中間層也都得改成 generator 形式了。
那這跟代數效應有什麼關係?
讓咱們暫時忘記 async / await
並回到咱們的例子:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
resume with 'Arya Stark';
}
}
複製代碼
若是咱們的 effect handler 不能同步返回「fallback name」怎麼辦?若是咱們想從數據庫中獲取它會怎麼樣?
事實證實,咱們在 effect handler 中異步調用 resume with
,無需對 getName
和 makeFriends
作任何修改:
function getName(user) {
let name = user.name;
if (name === null) {
name = perform 'ask_name';
}
return name;
}
function makeFriends(user1, user2) {
user1.friendNames.add(getName(user2));
user2.friendNames.add(getName(user1));
}
const arya = { name: null };
const gendry = { name: 'Gendry' };
try {
makeFriends(arya, gendry);
} handle (effect) {
if (effect === 'ask_name') {
setTimeout(() => {
resume with 'Arya Stark';
}, 1000);
}
}
複製代碼
在這個例子中,咱們在 1 秒後,才調用了 resume with
。您能夠將 resume with
視爲一個只調用一次的回調。(你也能夠經過稱它爲「限定單次延續(one-shot delimited continuation)」來將其安利給你的朋友。)
如今代數效應的機制應該更清晰一些了。當咱們 throw
了一個 error 時,JavaScript 引擎會「展開堆棧(unwind the stack)」,破壞進程中的局部變量。可是,當咱們 perform
了一個 effect 時,咱們的假設引擎將使用咱們的其他函數「建立一個回調」,並用 resume with
調用它。
再次提醒:這些語法和特定的關鍵字是本文專用的。它們不是重點,重點在於理解機制自己。
值得注意的是,代數效應來自函數式編程研究。他們解決的一些問題是純函數式編程所特有的。例如,那些不容許隨意反作用的語言(好比 Haskell),你必須使用 Monads 之類的概念來將其適配到你的程序中。若是您曾閱讀過 Monad 教程,您會發現這些概念有點難以理解。代數效應有助於作更少的儀式性代碼。
這就是爲何關於代數效應的諸多討論對我來講都是晦澀難懂的。(我以前並不知道 Haskell 和它的小夥伴們)可是,我認爲,即便是像 JavaScript 這樣的非純函數式語言,代數效應仍然是一個很是強力的工具,它能夠幫你分離代碼中的「作什麼」與「怎麼作」
它們使你可以專一於寫「作什麼」的代碼:
function enumerateFiles(dir) {
const contents = perform OpenDirectory(dir);
perform Log('Enumerating files in ', dir);
for (let file of contents.files) {
perform HandleFile(file);
}
perform Log('Enumerating subdirectories in ', dir);
for (let directory of contents.dir) {
// 咱們可使用遞歸,或調用其餘具備 effect 的函數
enumerateFiles(directory);
}
perform Log('Done');
}
複製代碼
而後用一些描述「怎麼作」的代碼將其包裹起來。
let files = [];
try {
enumerateFiles('C:\\');
} handle (effect) {
if (effect instanceof Log) {
myLoggingLibrary.log(effect.message);
resume;
} else if (effect instanceof OpenDirectory) {
myFileSystemImpl.openDir(effect.dirName, (contents) => {
resume with contents;
});
} else if (effect instanceof HandleFile) {
files.push(effect.fileName);
resume;
}
}
// `files` 數組如今有全部文件了
複製代碼
這意味着還能夠將其封裝爲庫:
import { withMyLoggingLibrary } from 'my-log';
import { withMyFileSystem } from 'my-fs';
function ourProgram() {
enumerateFiles('C:\\');
}
withMyLoggingLibrary(() => {
withMyFileSystem(() => {
ourProgram();
});
});
複製代碼
與 async / await
、Generator 不一樣,代數效應不須要「中間層函數」作相應適配。咱們的 enumerateFiles
可能在 ourProgram
的很深層被調用,但只要外層有一個 effect handler 爲每個 effect 提供對應的 perform,咱們的代碼就仍然能夠工做。
Effect handler 讓咱們能夠將程序邏輯與其具體的 effect 實現分離,而無需過多的儀式性代碼或樣板代碼。例如,咱們能夠徹底重載測試中的行爲,使用假文件系統,或者用快照日誌代替 console 輸出:
import { withFakeFileSystem } from 'fake-fs';
function withLogSnapshot(fn) {
let logs = [];
try {
fn();
} handle (effect) {
if (effect instanceof Log) {
logs.push(effect.message);
resume;
}
}
// 快照觸發日誌
expect(logs).toMatchSnapshot();
}
test('my program', () => {
const fakeFiles = [/* ... */];
withFakeFileSystem(fakeFiles, () => {
withLogSnapshot(() => {
ourProgram();
});
});
});
複製代碼
由於沒有「函數顏色」(中間的代碼不須要知道 effect )而且 effect handler 是可組合的(您能夠嵌套它們),因此您可使用它們建立很是富有表現力的抽象。
因爲代數效應這一律念來自靜態類型語言,所以關於它們的大部分爭論都集中在它們如何用類型表達上。這無疑是重要的,但也可能使掌握這一律念變得具備挑戰性。這就是這篇文章根本不討論類型的緣由。可是,我應該指出,若是一個函數能夠 preform 一個 effect 的話,則能夠將其編碼到類型簽名中。因此,就不該該出現一個隨機 effect 出現,但沒法追蹤它們來自何處的狀況了。
您可能會認爲代數效應在技術上會爲靜態類型語言中的函數「賦予顏色」,由於 effect 是類型簽名的一部分。確實如此。可是,「改動中間函數的類型聲明覺得其包含新 effect」自己並非語義更改 —— 這與添加 async
或將函數轉換爲 generator 不一樣。類型推導還能夠幫助避免級聯更改。一個重要的區別是,您能夠經過提供 noop 或 mock 實現(例如,爲異步 effect 提供一個同步調用)來「填充」effect,來防止它在必要時到達外部代碼,或者將其轉換爲不一樣的 effect。
老實說,我不知道。它們很是強大,你甚至能夠說,它們可能對 JavaScript 這樣的語言來講太過強大了。
我認爲它們很是適合那些不常出現變化(mutation)、標準庫徹底擁抱 effect 的語言。若是你主要作 perform Timeout(1000)
、perform Fetch('http://google.com')
以及 perform ReadFile('file.txt')
這類工做,而且你的語言有模式匹配和靜態 effect 類型,它多是一個很是好的編程環境。
也許這種語言甚至能夠編譯成 JavaScript!
並無那麼相關。你甚至能夠說這只是一些「延伸知識」。
若是您看過我關於 Time Slicing 和 Suspense 的探討,第二部分涉及從緩存中讀取數據的組件:
function MovieDetails({ id }) {
// 若是它仍然在 fetched 狀態怎麼辦
const movie = movieCache.read(id);
}
複製代碼
(這場探討使用了略有不一樣的 API ,但不重要。)
這構建於一個名爲「Suspense」的 React 功能之上,該功能正積極地開發中,用於請求數據這種場景。固然,有趣的部分是 movieCache
中沒有數據的狀況 —— 在這種狀況下咱們須要作一些事情,由於咱們如今沒法繼續了。從技術上講,在這種狀況下,read()
調用會 throw 一個 Promise(沒錯,就是 throw 了一個 Promise —— 讓它陷入其中)。這掛起(suspends)了執行。React 捕獲到 Promise,並會記得在該 Promise 變爲 resolve 後,從新嘗試渲染組件樹。
即便這個技巧是受其啓發的,但這自己並非代數效應。不過它實現了相同的目標:調用堆棧中的偏底層的一些代碼直接觸發了偏上層的一些代碼(在這種狀況下,爲 React),而無需全部中間函數必須知道它爲 async
或 generator 。固然,咱們沒法在 JavaScript 中真正地恢復(resume)執行,但從 React 的角度來看,這跟「當 Promise resolve 時從新渲染組件樹」幾乎是一回事。當你的編程模型假設冪等時,你就能夠這麼取巧!
Hooks 是另外一個可能提醒你代數效應的例子。人們提出的第一個問題是:一個 useState
調用怎麼可能知道它所指的是哪一個組件?
function LikeButton() {
// useState 怎麼知道它在哪一個組件裏?
const [isLiked, setIsLiked] = useState(false);
}
複製代碼
我已經在這篇文章的末尾解釋了答案:React 對象(指你如今正在使用的實現(例如react-dom
))上有一個「current dispatcher」這一可變狀態。相似地,還有一個「current component」屬性指向咱們 LikeButton
的內部數據結構。這就是 useState
知道該怎麼作的緣由。
在人們習慣以前,他們經常認爲這有點「髒」,緣由很明顯。依靠共享的可變狀態讓人「感受不太對」。(旁註:您認爲 try / catch
是如何在 JavaScript 引擎中實現的?)
可是,從概念上講,您能夠將 useState()
視爲:在 React 執行組件時的一個 perform State()
effect。這將「解釋」爲何 React(調用你的組件的東西)能夠爲它提供狀態(它位於調用堆棧中,所以它能夠提供 effect handler)。實際上,實現狀態是我遇到的代數效應教程中最多見的例子之一。
固然,這並非 React 的真實工做方式,由於咱們在 JavaScript 中沒有代數效應。事實上:咱們維持當前組件時,還維持了一個隱藏字段,以及一個指向攜帶 useState 具體實現的 current dispatcher 的字段。好比出於性能優化考慮,有獨立的爲 mount 與 update 特供的 useState
實現。可是若是歸納考量這段代碼,你可能會把它們看作 effect handler。
總而言之,在 JavaScript,throw 能夠做爲 IO effects 的粗略近似(只要之後能夠安全地從新執行代碼,而且不受 CPU 限制);而具備可變的、在 try / finally
中被執行的「dispatcher」字段,能夠做爲 effect handler 的粗略近似值。
您還能夠使用 generator 來得到更高保真度的效果實現,但這意味着您必須放棄 JavaScript 函數的「透明」特性,而且您必須把各處都設置成 generator。這有點……emm
就我的而言,我對代數效應對我有多大意義感到驚訝。我一直在努力理解像 Monads 這樣的抽象概念,但代數效果忽然讓我「開竅」了。我但願這篇文章能幫助你也能對 Monads 等概念「開竅」。
我不知道他們是否會進入主流採用階段。若是它在 2025 年以前尚未被任何主流語言所採用,我想我會感到失望。請提醒我五年後再回來看看!
我相信你能夠用它們作更多的事情 —— 可是若是不用這種方式實際編寫代碼就很難理解它們的力量。若是這篇文章讓你好奇,這裏有一些你可能想要查看的資源:
許多人還指出,若是忽略「類型」這個角度的話(正如我在本文中所作的那樣),你能夠在 Common Lisp 的條件系統中找到更早的現有技術。您可能也會喜歡 James Long 的 post on continuations 這篇文章,其解釋了 call / cc
原語爲什麼也能夠做爲在用戶空間中構建可恢復異常的基礎。
若是您爲 JavaScript 相關人士找到關於代數效應的其餘有用資源,請在 Twitter 上告訴我!
若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。
掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。