Promise 不夠中立

方應杭在飢人谷發表於2018-03-31

原文:https://staltz.com/promises-are-not-neutral-enough.html

原文作者 Staltz 是 cyclejs 和 callbag 的核心開發者。賀師俊對本文進行了全面的反駁,反駁文在此


Promise 產生的問題影響了 JS 的整個生態系統!本文將對其中一些問題進行闡述。

上面這句話可能讓你認為我被 Promise 折磨得心情極差,對著電腦罵髒話,於是打算在網上發洩一通。實際上並不是的,我今早剛泡好咖啡,就有人在 Twitter 上問我對 Promise 的看法,我才寫下了這篇文章。我當時一遍喝咖啡一遍思考,然後向他回覆了幾條微博。一些人回覆說最好能寫成部落格,於是就有了這篇文章。

Promise 的主要目的是表示一個終將會得到的值(下文簡稱最終值)。這個值可能會在下一個 event loop 中得到,也可能會在幾分鐘後得到。還有很多其他原語可以達到相同的目的,比如回撥、C# 中的任務、Scala 中的 Future,RxJS 中的 Observable 等。JS 中的 Promise 只是這些原語中的一個而已。

雖然這些原語都能實現這個目的,但是 JS 的 Promise 是一個太過 opinionated (譯註:opinionated 是主觀臆斷的意思,這裡表示不恰當的、強加觀點的)的方案,它造成了很多奇怪的問題。這些問題又會引發 JS 語法和生態系統中的其他問題。我認為 Promise 不夠中立,其 opinionated 表現在下面四個地方:

  • 立即執行而不是延遲執行
  • 不可中斷
  • 無法同步執行
  • then() 其實是 map() 和 flatMap() 的混合體

立即執行,而不是延遲執行

當你建立一個 Promise 例項的時候,任務就已經開始執行了,比如下面程式碼:

console.log('before');
const promise = new Promise(function fn(resolve, reject) {
  console.log('hello');
  // ...
});
console.log('after');
複製程式碼

你會在控制檯裡依次看到 before、hello 和 after。這是因為你傳遞給 Promise 的函式 fn 是被立即執行的。我把 fn 單獨擰出來你可能就看得更清晰一些了:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before');
const promise = new Promise(fn); // fn 是立即執行的!
console.log('after');
複製程式碼

所以說 Promise 會立即執行它的任務。注意在上面的程式碼中,我們甚至還沒使用這個 Promise 例項,也就是沒有使用過 promise.then() 或 promise 的其他 API。僅僅是建立 Promise 例項就會立即執行 Promise 裡的任務。

理解這一點很重要,因為

  1. 有的時候你不想 Promise 裡的任務立刻開始執行
  2. 有時候你會想要一個可複用的非同步任務,但是 Promise 卻只會執行一次任務,因此一旦 Promise 例項被建立,你就沒法複用它了。

通常解決這個問題的辦法就是把 Promise 例項化的過程寫在一個函式裡:

function fn(resolve, reject) {
  console.log('hello');
  // ...
}

console.log('before');
const promiseGetter = () => new Promise(fn); // fn 沒有立即執行
console.log('after');
複製程式碼

由於函式是可以在後面呼叫的,所以用一個「返回 Promise 例項的函式」(下文簡稱為 Promise Getter)就解決了我們的問題。但是另一個問題來了,我們不能簡單地用 .then() 把這些 Promise Getter 連起來(譯註:原文說得不夠清晰,我不太理解作者的意圖)。為了解決這個問題,大家的做法一般是給 Promise Getter 寫一個類似 .then() 的方法,殊不知這就是在解決 Promise 的複用性問題和鏈式呼叫問題。比如下面程式碼:

// getUserAge 是一個 Promise Getter
function getUserAge() {
  // fetch 也是一個 Promise Getter
  return fetch('https://my.api.lol/user/295712')
    .then(res => res.json())
    .then(user => user.age);
}
複製程式碼

所以說 Promise Getter 其實更利於組合和複用。這是因為 Promise Getter 可以延遲執行。如果 Promise 一開始就設計成延遲執行的,我們就不用這麼麻煩了:

const getUserAge = betterFetch('https://my.api.lol/user/295712')
  .then(res => res.json())
  .then(user => user.age);
複製程式碼

(譯者注:也上面程式碼執行完了之後,fetch 任務還沒開始)

我們可以呼叫 getUserAge.run(cb) 來讓任務執行(譯註:很像 Rx.js)。如果你多次呼叫 getUserAge.run,多個任務就都會執行,最後你會得到多個最終值。不錯!這樣一來我們既能複用 Promise,又能做到鏈式呼叫。(譯註:這是針對 Promise Getter 說的,因為 Promise Getter 能複用,卻不能鏈式呼叫)

延遲執行比立即執行更通用,因為立即執行無法重複呼叫,而延遲執行卻可以多次呼叫。延遲執行對呼叫次數沒有任何限制。

所以我認為立即執行比延遲執行更 opinionated(譯註:opinionated 是貶義詞)。C# 中的 Task 跟 Promise 很像,只不過 C# 的 Task 是延遲執行的,而且 Task 有一個 .start() 方法,Promise 卻沒有。

我打個比方吧,Promise 既是菜譜又是做出來的菜,你吃菜的時候必須把菜譜也吃掉,這不科學。

不可中斷

一旦你建立了一個 Promise 例項,Promise 裡的任務就會馬上執行,更悲催的是,你無法阻止的執行。所以你現在還想建立一個 Promise 例項嗎?這是一條不歸路。

我認為 Promise 的「不可中斷」跟它的「立即執行」特性密切相關。這裡用一個不錯的例子來說明:

var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
複製程式碼

假設我們可以使用 promiseB.cancel() 來中斷任務,請問 promiseA 的任務應該被中斷嗎?也許你認為可以中斷,那就再看看下面這個例子:

var promiseA = someAsyncFn();
var promiseB = promiseA.then(/* ... */);
var promiseC = promiseA.then(/* ... */);
複製程式碼

這個時候如果我們可以用 promiseB.cancel() 來中斷任務,promiseA 的任務就不應該被中斷,因為 promiseC 依賴了 promiseA。

正是由於「立即執行」,Promise 任務中斷的向上傳播機制才變得複雜起來。一個可能的解決辦法是引用計數,不過這種方案有很多邊界情況甚至 bug。

如果 Promise 是延遲執行的,並提供 .run 方法,那麼事情就變得簡單了:

var execution = promise.run();

// 一段時間後
execution.cancel();
複製程式碼

promise.run() 返回的 execution 就是任務的回溯鏈,鏈上的每一個任務都分別建立了自己的 execution。 如果我們呼叫 executionC.cancel(),那麼 executionA.cancel() 就會被自動呼叫,而 executionB 有它自己的一個 executionA,跟 executionC 的 executionA 互不相干。所以可能同時有多個 A 任務在執行,這並不會造成什麼問題。

如果你想避免多個 A 任務都在執行,你可以給 A 任務新增一個共享方法,也就是說我們可以「選擇性地使用」引用計數,而不是「強制使用」引用計數。注意「選擇性地使用」和「強制使用」的區別,如果一個行為是「選擇性地使用」的,那麼它就是中立的;如果一個行為是「強制使用」的,那麼它就是 opinionated 的。

回到那個奇怪的菜譜的例子,假設你在一個餐廳點了一盤菜,但是一分鐘後你又不想吃這盤菜了,Promise 的做法就是:不管你想不想吃,都會強行把菜塞進你的喉嚨裡。因為 Promise 認為你點了菜就必須吃(不可中斷)。

無法同步執行

Promise 的設計策略中,允許最早的 resolve 時機是進入下一個 event loop 階段之前(譯註:請參考 process.nextTick),以方便解決同時建立多個 Promise 例項時產生的競態問題。

console.log('before');
Promise.resolve(42).then(x => console.log(x));
console.log('after');
複製程式碼

上面程式碼會依次列印出 'before' 'after' 和 42。不管你如何構造這個 Promise 例項,你都沒有辦法使 then 裡的函式在 'after' 之前列印 42。

最後的結果就是,你可以把同步程式碼寫成 Promise,但是卻沒有辦法把 Promise 改成同步程式碼。這是一個人為的限制,你看回撥就沒有這個限制,我們可以把同步程式碼寫成回撥,也可以把回撥改成同步程式碼。以 forEach 為例:

console.log('before');
[42].forEach(x => console.log(x));
console.log('after');
複製程式碼

這個程式碼會一次列印出 'before' 42 和 'after'。

由於我們不可能把 Promise 重新改寫成同步程式碼,所以一旦我們在程式碼裡使用了 Promise,就使得它周圍的程式碼都變成了基於 Promise 的程式碼(譯註:不是很理解這為什麼就叫做基於 Promise 的程式碼),即使這樣做沒意義。

我能理解非同步程式碼讓周圍的程式碼也非同步,但是 Promise 卻強制讓同步程式碼周圍的程式碼也變成非同步的。這就是 Promise 的又一個 opinionated 之處。一箇中立的方案不應該強制資料的傳遞方式是同步或是非同步。

我認為 Promise 是一種「有損抽象」,類似於「有失真壓縮」,當你把東西放在 Promise 裡,然後把東西從 Promise 裡拿出來,這東西就跟以前不一樣了。

想象你在一個連鎖快餐店裡點了一個漢堡,服務員立即拿出一個做好的漢堡遞給你,但是把手伸過去接卻發現這個伺服器死死地抓住這個漢堡不給你,他只是看著你,然後開始倒數 3 秒鐘,然後他才鬆手。你拿到你的漢堡走出快餐店,想逃離這個詭異的地方。莫名其妙啊,他們就是想讓你在拿餐之前等一會,還說是以防萬一。

then() 其實是 map() 和 flatMap() 的混合體

當傳遞一個回撥給 then 的時候,你的回撥函式可以返回一個常規的值,也可以返回一個 Promise 例項。有趣的是,兩種寫法的效果一模一樣。

Promise.resolve(42).then(x => x / 10);
// 效果跟下面這句話一致
Promise.resolve(42).then(x => Promise.resolve(x / 10));
複製程式碼

為了防止 Promise 套 Promise 的情況,then 內部遇到返回值是常規的值就轉換成 Promise 例項(譯註:這就是 map,參見 hax 對 map 的解釋 Promise<T>.then(T => U): Promise<U>),遇到 Promise 例項就直接使用(譯註:這就是 flatMap,Promise<T>.then(T => Promise<U>): Promise<U>)。

從某種程度上說,這麼做對你是有幫助的,因為如果你對其中的細節不是很瞭解它會自動幫你搞定。假設 Promise 其實是可以提供 map、flatten 和 flatMap 方法的,我們卻只能使用 then 方法來搞定所有需求。你看到 Promise 的限制了嗎?我被限制只能使用 then,一個會做一些自動轉換的簡化版 API,我想做更多控制都是不可能的。

很久之前,Promise 剛被引入 JS 社群的時候,一些人有想過為 Promise 新增 map 和 flatMap 方法,詳情你可以在這篇討論裡看到。不過參與語法制定的人以 category theory 和函數語言程式設計等理由反駁了這些人。

我不想在這篇文章裡對函數語言程式設計討論太多,我只說一點:如果不遵循數學的話,就基本不可能創造出一箇中立的程式設計原語。數學並不是一門與實際程式設計不相關的學科,數學裡的概念都是有實際意義的,所以如果你不想你創造出來的東西出現自相矛盾的情況的話,也許你應該多瞭解一些數學。

這篇討論的主要焦點就是為什麼不能讓 Promise 有 map、flatMap 和 concat 這些方法。很多其他的原語都有這些方法,比如陣列,另外如果你用過 ImmutableJS 你會發現它也有這些方法。map、flatMap 和 concat 真的很好用。

想象一下,我們寫程式碼的時候只管呼叫 map、flatMap 和 concat 即可,不用管它到底是什麼原語,是不是很爽。只要輸入源有這些方法即可。這樣一來測試就會很方便,因為我可以直接把陣列作為 mock 資料(譯註:而不需要去構造一些 HTTP 請求)。如果程式碼中使用了 ImmutableJS 或生產環境中的非同步 API,那麼測試環境中只要用陣列來模擬就夠了。函數語言程式設計中說的「泛型」「type class 程式設計」和 monad 等都有類似的意思,說的是我們可以給不同的原語以一批相同的方法名。如果一個原語的方法名是 concat 另一個原語的方法名是 concatenate,但是實質上它們做的是幾乎相同的事情,就很令人討厭了。

所以為什麼不把 Promise 理解成跟陣列差不多的概念,有 concat、map 等方法。Promise 基本上可以被 map,所以就給 Promise 新增 map 方法吧;Promise 基本上可以被 chain,所以就給 Promise 新增上 flatMap 方法吧。

不幸的是現實不是這樣的,Promise 把 map 和 flatMap 擠到 then 裡面,並加了一些自動轉換邏輯。這麼做只是因為 map 和 flapMap 看起來很類似,他們認為寫成兩個方法有點多此一舉。

總結

好吧,Promise 也能工作,你可以用 Promise 搞定你的業務而且一切都執行良好。沒必要驚慌。Promise 只是看起來有點怪異了,而且真不幸它還很 opinionated。他們強加給 Promise 一些在某些時候毫無意義的規則。這麼做問題不大,因為我們可以很容易的繞過這些規則。

Promise 很難複用,沒關係我們可以用額外的函式搞定; Promise 不能被中斷,沒關係我們可以讓那些本該中斷的任務繼續執行,不就是浪費了一些資源而已嘛。

真煩人,我們總是要給 Promise 做一些修修補補; 真煩人,現在新出的 API 都是基於 Promise 的,我們甚至給 Promise 發明了一個語法糖:async/await。

所以接下來幾年我們都要忍受 Promise 的這些怪異之處。如果我們一開始就把延遲執行考慮到 Promise 裡,也許 Promise 就是另外一番光景了。

如果 Promise 的設計初期就是從數學角度思考會是什麼樣子?這裡我給出兩個例子:fun-taskavenir,這兩個庫都是延遲執行的,所以有很多共同點,不同點主要體現在命名和方法可訪問性上。這兩個庫都比 Promise 更不 opinionated,因為它們:

  1. 延遲執行
  2. 允許同步
  3. 允許中斷

Promise 是被發明的,不是被發現的。最好的原語都是被發現的,因為這些原語是中立的,所以我們無法反駁它們。例如,圓就是這樣一個無法被反駁的數學概念,所以說是人類發現了圓,而不是發明了圓。由於圓是中立的,沒有被強加任何主觀的限制,所以你沒有辦法反駁一個圓。而且,圓,無處不在。

相關文章