Vim script で ES6 Promise 実装した

Vim Advent Calendar 2017 の19日目の記事です.Vim script で ES6 の Promise を実装した話を書きます.

もし Vim script が分からなくても,最後の章「Promise の実装の詳細」は Vim script とは独立した内容になっているので,Promise の実装に興味があれば読んでみてください.

TL;DR

実装はこちら

Promise とは

ES6 (ES2015) で ECMAScript に標準に導入された非同期処理を扱うためのライブラリです. これを使うことにより,「未来のある時点でいずれ値を持つ」値を扱うことができ,ES5 まではコールバックで扱う必要があった非同期処理を逐次処理的な書き方で書きつつ,エラーも一貫した方法で扱えます.

例えば,ネットワークのどこかから取ってきたデータをファイルに保存する処理は,ネットワークからの fetch とファイルへの保存の2回の非同期処理を挟むので

fetch('https://example.com', function (err, data) {
    if (err) {
        console.log('Error', err);
        return;
    }
    writeFile('data.txt', data, function (err) {
        if (err) {
            console.log('Error', err);
            return;
        }
        console.log('Done!');
    });
});

のようにネストしたコールバックとエラーハンドリングで処理がぶつ切りになってしまうのですが,Promise を使うと

fetch('https://example.com').then(function (data) {
  return writeFile('data.txt', data);
}).then(function () {
  console.log('Done!');
}).catch(function (err) {
  console.log('Error', err);
});

のように,本来実行される順序でコードを書くことができ(fetchwriteFile),エラー処理も .catch メソッドによって分離できます.

なぜ Promise を Vim script で実装したのか

Vim 7.4 までは,Vim script は基本的にユーザからの入力をブロッキングして処理をするようになっていました.そのため,Vim プラグイン作者たちは vimproc という Vim script から popen や socket などを扱える C ライブラリを書いたり, CursorHold イベントを繰り返し発火させてポーリングするなど,様々なワークアラウンドで対処してきました.

ですが,Vim 8 になり,jobchannelterminal といった,ユーザの処理をブロックしない非同期処理の機能が続々と入りだしました(一部は Neovim が先に実装したアイデアが Vim8 にポーティングされました).

これによって Vim プラグインワークアラウンドを使わずともユーザの入力をブロックしない快適なプラグインをつくる下地を手に入れると同時に,非同期処理と戦わなければならなければならなくなりました. しかしコールバック方式では,少し処理が複雑になったりエラー処理を真面目にやると,すぐメンテナンス性が低下してしまうのは JavaScript での経験で分かっています. さらに,ちょうど同じ時期に Vim8(および後に Neovim にも)ラムダ式が入りました.

ということで,JavaScript で使われているアイデア(Promise)を Vim にポーティングしました.

使い方

vital.vim の標準モジュール入りを目指して実装しています.現在はまだプルリクの段階ですが,あとはテストとドキュメントを整備すればレビュー可能になる予定です. master にマージされました!標準で利用することができます

:VitalizeAsync.Promiseプラグインに入れるか,vital#vital#import('Async.Promise') でインポートすることで試せます.

API はそれなりに ECMAScript と互換なので,ECMAScript の Promise を知っている人はほぼドキュメントを見なくても使うことができると思います.

MDN の Promise のドキュメントを見ていただいたほうが分かりやすいかもしれません. 詳細な仕様が知りたい!というマッチョな方はECMA-262の仕様を見ていただいても良いでしょう.

let s:Promise = vital#vital#new().import('Async.Promise')

" Promise な値を生成する
let p = s:Promise.new({resolve -> resolve(42)})
let p = s:Promise.new({_, reject -> reject('error!')})

" resolve する値が定数のときの省略形(上記と同じ)
let p = s:Promise.resolve(42)
let p = s:Promise.reject('error!')

" .then() で非同期な処理に順番を付ける (20 と出力される)
let p1 = s:Promise.new({resolve -> resolve(10)})
           \.then({i -> i + 10}).then({i -> execute('echom i', '')})

" .catch() で例外を補足できる
let p2 = s:Promise.new({-> execute('throw "ERROR!"')})
           \.catch({err -> execute('echom string(err)', '')})
" Output: { 'exception' : 'ERROR!', 'throwpoint' : '...' }

" .all() で待ち合わせ
call s:Promise.all([p1, p2]).then({-> execute('echom "Both p1 and p2 done!"', '')})

" .race() でどれか1つ少なくとも完了
call s:Promise.race([p1, p2]).then({-> execute('echom "Either p1 or p2 done!"', ''}))

詳細は help ドキュメントを参照してください.

具体例

これだけではピンとこないかもしれないので,具体例を少し見てみましょう.ここではコードを少し簡略化して書いています.実際に使う場合はもう少し考慮しないといけないエッジケースがある場合がありますので,ご注意ください.

タイマー

一番分かりやすいタイマー機能で Promise を使ってみます. Vim script の timer は指定したミリ秒時間後にコールバックで指定した処理を遅らせられる機能です. ですので,下記のようにして wrap して Promise オブジェクトを返す関数をつくることで,一定秒数後に発火する処理を Promise を使って書けるようになります.

let s:Promise = vital#vital#new().import('Async.Promise')

function! s:wait(ms)
  return s:Promise.new({resolve -> timer_start(a:ms)})
endfunction

call s:wait(500).then({-> execute('echo "After 500ms"', '')})

Next tick

タイマーのコールバックは Vim が入力待ちの時(busy でない時)に発火するとドキュメントに明記されています. よって,他にもっと大切な処理がある場合はそちらを優先し,優先度の低い処理は Vim が入力待ちになるまで遅らせるのに使えます.

function! s:next_tick()
  return s:Promise.new({resolve -> timer_start(0, resolve)})
endfunction

call s:next_tick()
  \.then({-> 'ここで優先度の低い処理をする'})
  \.catch({err -> execute('echom ' . string(err), '')})

タイマーの待ち時間を 0 に指定しているので,Vim が入力待ちになった(=優先度の高い処理が完了した)直後に .then で指定した処理が行われます.

ジョブ

Promise が最も威力を発揮するのはjob機能だと思います.job は非同期にコマンドを実行し,コールバックで channel を通してコマンドと標準入出力のやりとりをします.

ここでは job を wrap し,非同期にコマンドを実行する処理を Promise で書くとどうなるかを見てみます.

let s:V = vital#vital#new()
let s:Promise = s:V.import('Async.Promise')

function! s:read_to_buf(buf, chan) abort
  for part in ['err', 'out']
    let out = ''
    while ch_status(a:chan, {'part' : part}) ==# 'buffered'
      let out .= ch_read(a:chan, {'part' : part}) . "\n"
    endwhile
    let a:buf[part] = out
  endfor
endfunction

function! s:sh(...) abort
  let cmd = join(a:000, ' ')
  let buf = {}
  return s:Promise.new({resolve, reject -> job_start(cmd, {
              \   'close_cb' : {ch ->
              \     s:read_to_buf(buf, ch)
              \   },
              \   'exit_cb' : {ch, code ->
              \     code ? reject(buf.err) : resolve(buf.out)
              \   },
              \ })})
endfunction

ここでは非同期にシェルコマンドを実行する s:sh() 関数を定義しています.

s:read_to_buf() は与えられた channel からバッファされた標準入出力と標準エラー出力を読み出し,引数 buf で与えられた辞書に保存するヘルパー関数です.

一番肝なのは return s:Promise.new(...) の部分です.close_cb はコマンドからの出力が終わり channel が閉じられるタイミングで発火するので,ここでコマンドからの出力を読んでおきます.さらにコマンドの実行が終わるタイミングで発火する exit_cbステータスコードを受け取り,0(成功)なら標準出力と共に Promise を resolve,非0(失敗)なら Promise を reject します.

早速,まずは試しに ls -l コマンドに使ってみましょう.

call s:sh('ls', '-l')
  \.then({out -> execute('echom ' . string('Output: ' . out), '')})
  \.catch({err -> execute('echom ' . string('Error: ' . err), '')})

これだけで,コマンドを非同期に実行して,その出力に対して何か処理をするというコードが,しかも逐次的に書けるようになりました.

コマンドが失敗した場合は .catch() で指定した処理が発動します.

もう少し複雑な例を見てみます. 4つのリポジトリを一気に clone し,全て完了したタイミングで何か処理をしたいとします.ですが,ネットワークの状態やリポジトリのサイズなどにより,どのリポジトリの clone が最後に完了するかは分かりません.

call s:Promise.all([
\   s:sh('git', 'clone', 'https://github.com/thinca/vim-quickrun.git'),
\   s:sh('git', 'clone', 'https://github.com/tyru/open-browser-github.git'),
\   s:sh('git', 'clone', 'https://github.com/easymotion/vim-easymotion.git'),
\   s:sh('git', 'clone', 'https://github.com/rhysd/clever-f.vim.git'),
\]).then({-> exeute('echom "All repositories were successfully cloned!"', '')})
  \.catch({err -> execute('echom "Failed to clone: " . ' string(err), '')})

s:shPromise.all を使えば,これだけで OK です.

.then の中の処理は4つのリポジトリの clone が全て完了した時点で発火します.また,4つのリポジトリのうち,1つでも clone が失敗すれば,.catch の中の処理が呼ばれてエラー処理を行うことができ,.then の中の処理は呼ばれません.

次の例は,タイムアウト付きのコマンド実行です. 特定のコマンドを実行したい,しかし時間がかかりすぎるときはエラーにするか,別の処理をしたいというケースです

call s:Promise.race([
\   s:sh('git', 'clone', 'https://github.com/vim/vim.git').then({-> v:false}),
\   s:wait(10000).then({-> v:true}),
\]).then({timed_out -> execute(timed_out ? 'Timeout!' : 'Cloned!', '')})

上記で定義した,一定ミリ秒待ってから発火する Promise を返す関数 s:wait() と,Promise.raceを使います.

Vimリポジトリを clone する処理をする Promise オブジェクトと,10秒間待つ Promise オブジェクトのうち,速いほうで resolve されるので,10秒内に clone できなければ待たずに次の処理にいく,ただしタイムアウトしたかどうかは引数 timed_out で知ることができるという処理になっています.

このように,繰り返さない非同期処理(未来のある時点以降は値を持つような処理)を組み合わせる際に,Promise は非常に強力な道具であることが分かりました.また,最後に .catch でエラー処理をぶら下げておけば,それまでのうちどこでエラーが起きても最終的に .catch の処理内で拾うことができるため,エラー処理が簡潔になります.

最後にさらに実践的な例を見てみます. GitHub API を叩いて,Vimリポジトリのうちもっとも :+1: リアクションが多い issue の URL を取得してみましょう.

まずは GitHub Search API を呼ぶ関数をつくってみます.

let s:HTTP = s:V.import('Web.HTTP')
function! s:github_issues(query) abort
    let q = s:HTTP.encodeURIComponent(a:query)
    let url = 'https://api.github.com/search/issues?q=' . q
    return s:sh('curl', url).then({data -> json_decode(data).items})
endfunction

vital.vimWeb.HTTP モジュールを使って検索クエリをエンコードし,先ほど定義した s:sh() 関数と curl でリクエストを送り,結果を json_decode 組み込み関数でパースして返すだけです.たった6行ですね.

次にこれを使って API を使ってみます.

call s:github_issues('repo:vim/vim sort:reactions-+1')
    \.then({issues -> execute('echom ' . string(issues[0].url), '')})
    \.catch({err -> execute('echom ' . string('ERROR: ' . err), '')})

合計たった9行で実装できました.エラーも .catch で処理できています.ちなみに最も👍の多い issue は #1735 でした.

今回は試していませんが,Neovim のリモートプラグインへの RPC 経由の request も同様にうまく扱えると思います.(request を送り,結果が返ってくる Promise オブジェクトを定義)

Vim script 版の JavaScript 版との違い

例外が投げられた時の処理

JavaScript とは違い,Vim script では文字列を例外として throw します.:catch 節内では throw された文字列を v:exception で取りますが,例外が発生した位置はそれとは別に v:throwpoint で取得します. このため,例外が投げられた時に単純に v:exceptionreject すると例外の発生位置が取れなくなってしまいます.そのため,Async.Promise では throw された文字列で reject する代わりに {'exception' : v:exception, 'throwpoint' : v:throwpoint} という辞書で reject するようになっています.

" Output: { 'exception' : 'ERROR!', 'throwpoint' : '...' }
call s:Promise.new({-> execute('throw "ERROR!"')})
     \.catch({err -> execute('echom string(err)', '')})

これによって .catch() の中では例外の中身と例外が発生した場所の両方を知ることができます. s:Promise.new() の中で reject() を呼んだ場合や .then() または .catch() の戻り値に reject された Promise の値を指定した時など, 例外が投げられる以外の原因で Promise が reject されたときは JavaScript 版と同じです.

" Output: 'ERROR!'
call s:Promise.new({_, reject-> reject('ERROR!')})
     \.catch({err -> execute('echom string(err)', '')})

.then()や.catch()の発火のタイミング

Vim script 版の .then/.catchJavaScript 版の .then/.catch では引数に指定された関数の発火タイミングが違います.

Vim script 版では下記の(1)〜(3)の順序で処理が行われる(引数で渡された関数が同期的に実行される)のに対し,

(1)
call s:Promise.new({resolve, reject -> (2)}).then({-> (3)})
(4)

JavaScript 版では下記の(1)〜(3)の順序で処理が行われます(引数で渡された関数が非同期的に実行される).

(1)
new Promise((resolve, reject) => (2)).then({-> (4)})
(3)

Vim script でも .then の中でタイマーを噛ませば同じ処理は行えるのですが,頻繁に呼ばれるので,ただでさえ遅い Vim script を無駄に遅くしたくないという意図でこの挙動になっています.(最終的には要ベンチマーク

ご指摘をいただいて修正しました.@azu_reさん,ありがとうございました.

この違いはプルリクのブランチの最新で撤廃されました.現在は .then.catch が呼ばれた段階でレシーバの Promise オブジェクトがすでに settled になっていても,引数に渡された関数は timer_start をはさんで非同期に実行されることが保証されるようになりました.これによって,実行は .then.catch の単位で行われることが保証され,ユーザの入力を優先するようになります.ベンチマークを取ったところ timer_start をはさんでも1回の実行は(手元の環境で)1ms 未満と問題無さそうだったため処理を変更しました.

Promise の実装の詳細

実装はこちら

ここからは Promise がどういったもので,どのように実装されているのかを説明します.Vim script に限らない話になっています.このライブラリは es6-promise という polyfill ライブラリ(ES5 まででも ES6 Promise を使えるようにするための shim)の実装を参考にしています.

Promise の内部状態の遷移

まずは,Promise が内部でどうやって非同期処理の状態を管理しているかについて説明します.

Promise には内部的に 'pending', 'fulfilled', 'rejected' という3つの状態のうちいずれかを持っています.

  • pending: 処理が未完了の状態
  • fulfilled: 処理がエラー無く完了した状態
  • rejected: 処理中にエラーが発生した状態

pending から fulfilled または rejected へは一方向の遷移になります.つまり,Promise オブジェクトが生成された直後の状態は pending であり,その後 fulfilled か rejected のどちらかに遷移するか,または永遠に pending のままになります.この pending から fulfilled/rejected への遷移処理は publish と呼ばれ,fulfilled か rejected になった状態を settled と呼んでいます.(下図はMDNより引用

f:id:rhysd:20171219000507p:plain

例えば,次の処理を考えてみましょう.

const p1 = new Promise(resolve => resolve('OK') /* (2) */);
const p2 = p1.then(() => { throw 'OOPS'; /* (3) */ });
const p3 = p2.catch(reason => console.log('ERROR:', reason) /* (4) */);
const p4 = p3.then(() => 'END' /* (5) */);
/* (1) */

処理は上記の (1)〜(5) の順番に実行されます.

表にすると,各 Promise の値 p1, p2, p3, p4 の状態は下記のように遷移していきます.

実行フェーズ p1 p2 p3 p4
(1) pending pending pending pending
(2) fulfilled pending pending pending
(3) fulfilled rejected pending pending
(4) fulfilled rejected fulfilled pending
(5) fulfilled rejected fulfilled fulfilled

p1 から p2 に順番に Promise の遷移が伝搬している様子がわかると思います.

Promise 内部のデータ構造

内部では .then などで Promise の値がネストするたびに,Promise の値は自分の次に処理されるべき Promise の値を子として持ちます.なので,実は Promise は内部的には木構造になっています.

上記の例のように Promise は直列に処理を行うことが多いので,直感的には単方向リストでは?と思うかもしれません.ですが,実は次のような場合がありえます.

const p1 = new Promise(resolve => resolve(42));
const p2 = p1.then(() => 10);
const p3 = p1.then(() => 20);

この場合,Promise のツリーはイメージとしては

{
  result: 42 /*p1*/,
  children: [
    { result: 10 /*p2*/, children: [] },
    { result: 20 /*p3*/, children: [] },
  ]
}

となります.実際には各 Promise の値は上記の3状態のいずれかを表すメンバーと,子要素の resolve された時または reject された時に呼ばれるコールバック関数を配列で保持しています.

Promise {
    state: 'pending',
    result: undefined,
    children: [ Promise{...}, Promise{...} ],
    on_fulfilled: [ resolved_fun1, resolved_fun2 ],
    on_rejected: [ rejected_fun1, rejected_fun2 ],
}

よって,new Promise で値をつくり,それに対して .then.catch を呼び…という処理は実は内部で木構造を構築する処理をしています..then を読んだ時,すでにレシーバ(=親)の Promise オブジェクトが fulfilled か rejected になっていれば,.thenまたは.catchの中身は即座に実行されますが,そうでなければ子は親からのコールバックを待って pending 状態のまま親の子として .then.catch のコールバックと共に登録されます.この処理を subscribe と呼んでいます.

次に,生成された Promise オブジェクトがどう resolve/reject されていくかを見てみます.

new Promise(resolve => resolve(42)) のコールバック内で,resolve(42) が呼ばれる時に何が起きているのでしょうか.

  1. resolve() が呼ばれる
  2. resolve() で引数に与えられた値が Promise の結果として保存される(これより後で .then を追加されても大丈夫なように)
  3. Promise の状態を fulfilled に変更します
  4. 親が fulfilled で終了したので,子要素の Promise オブジェクトそれぞれについて,on_fulfilled のほうのコールバックを呼びます
  5. コールバックはいずれ resolvereject のどちらかを呼ぶので,それによって子要素のそれぞれで 1. からまたスタートします

このようにして,木の根から葉へと,親の状態に応じたコールバック(on_fulfilled or on_rejected)を実行しながら葉へと向かって非同期処理が走っていくようになっています.

なんとなくイメージは掴めましたでしょうか?もしさらに詳しく知りたければ,es6-promise を clone して各所に console.log を挟み,実際に処理を走らせてみると様子が分かると思います.

まとめ

Vim script で ES6 Promise のライブラリを実装しました.現在はまだプルリク中ですが,いずれ(年内目標で)vital.vimで使えるようにしたいと思っています. メンテナンス性を損なうこと無く,よりユーザが快適に使えるプラグインの作成の役に立てば良いなと思っています.