[譯] 寫給你們的代數效應入門

寫給你們的代數效應入門

你據說過代數效應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 Thisandroid

補充:一些人說 LISP 提供了相似的功能,因此若是你寫 LISP,就能夠在生產環境中用上該功能了。ios

因此我爲啥關心它?

想象你寫 goto 的代碼時,有人向你介紹了 iffor 語句,或者陷入回調地獄的你看到了 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 標識爲異步,但不爲其調用者 makeFriendsmakeFriends 的調用者增長 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,無需對 getNamemakeFriends 作任何修改:

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 添加代數效應嗎?

老實說,我不知道。它們很是強大,你甚至能夠說,它們可能對 JavaScript 這樣的語言來講太過強大了。

我認爲它們很是適合那些不常出現變化(mutation)、標準庫徹底擁抱 effect 的語言。若是你主要作 perform Timeout(1000)perform Fetch('http://google.com') 以及 perform ReadFile('file.txt') 這類工做,而且你的語言有模式匹配和靜態 effect 類型,它多是一個很是好的編程環境。

也許這種語言甚至能夠編譯成 JavaScript!

全部這些都與 React 相關?

並無那麼相關。你甚至能夠說這只是一些「延伸知識」。

若是您看過我關於 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 連接。


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

相關文章
相關標籤/搜索