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); });
のように,本来実行される順序でコードを書くことができ(fetch
→ writeFile
),エラー処理も .catch
メソッドによって分離できます.
なぜ Promise を Vim script で実装したのか
Vim 7.4 までは,Vim script は基本的にユーザからの入力をブロッキングして処理をするようになっていました.そのため,Vim プラグイン作者たちは vimproc という Vim script から popen や socket などを扱える C ライブラリを書いたり, CursorHold
イベントを繰り返し発火させてポーリングするなど,様々なワークアラウンドで対処してきました.
ですが,Vim 8 になり,job や channel,terminal といった,ユーザの処理をブロックしない非同期処理の機能が続々と入りだしました(一部は Neovim が先に実装したアイデアが Vim8 にポーティングされました).
これによって Vim プラグインはワークアラウンドを使わずともユーザの入力をブロックしない快適なプラグインをつくる下地を手に入れると同時に,非同期処理と戦わなければならなければならなくなりました. しかしコールバック方式では,少し処理が複雑になったりエラー処理を真面目にやると,すぐメンテナンス性が低下してしまうのは JavaScript での経験で分かっています. さらに,ちょうど同じ時期に Vim8(および後に Neovim にも)ラムダ式が入りました.
ということで,JavaScript で使われているアイデア(Promise)を Vim にポーティングしました.
使い方
vital.vim の標準モジュール入りを目指して実装しています.現在はまだプルリクの段階ですが,あとはテストとドキュメントを整備すればレビュー可能になる予定です.
今すぐ試してみたい場合は下記のようにコマンドを打てば使えるはずです.
$ # プラグイン管理プラグインなどで管理している vital.vim のリポジトリに行く $ cd /path/to/vital.vim $ git remote add rhysd https://github.com/rhysd/vital.vim.git $ git fetch rhysd $ git checkout promise
この後,:Vitalize
で Async.Promise
をプラグインに入れるか,vital#vital#new().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), '')}) " .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!"', ''}))
具体例
これだけではピンとこないかもしれないので,具体例を少し見てみましょう.ここではコードを少し簡略化して書いています.実際に使う場合はもう少し考慮しないといけないエッジケースがある場合がありますので,ご注意ください.
タイマー
一番分かりやすいタイマー機能で 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:sh
と Promise.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
の処理内で拾うことができるため,エラー処理を簡潔になります.
今回は試していませんが,Neovim のリモートプラグインへの RPC 経由の request も同様にうまく扱えると思います(request を送り,結果が返ってくる Promise オブジェクトを定義).
Vim script 版の非常に細かい JavaScript 版との違い
Vim script 版の Promise.new
と JavaScript 版の new Promise
では引数に指定された関数の発火タイミングが違います.
Vim script 版では下記の(1)〜(3)の順序で処理が行われる(引数で渡された関数が同期的に実行される)のに対し,
(1) call s:Promise.new({resolve, reject -> (2)}) (3)
JavaScript 版では下記の(1)〜(3)の順序で処理が行われます(引数で渡された関数が非同期的に実行される).
(1)
new Promise((resolve, reject) => (3))
(2)
Vim script でも s:Promise.new
の中でタイマーを噛ませば同じ処理は行えるのですが,new
は実は .then
や .all
などの中で毎回呼ばれる(詳細は下記のセクションで)ので,ただでさえ遅い Vim script を無駄に遅くしたくないという意図でこの挙動になっています.
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より引用)
例えば,次の処理を考えてみましょう.
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_fun, resolved_fun ], on_rejected: [ rejected_fun, rejected_fun ], }
よって,new Promise
で値をつくり,それに対して .then
や .catch
を呼び…という処理は実は内部で木構造を構築する処理をしています..then
を読んだ時,すでにレシーバ(=親)の Promise オブジェクトが fulfilled か rejected になっていれば,.then
または.catch
の中身は即座に実行されますが,そうでなければ子は親からのコールバックを待って pending 状態のまま親の子として .then
や .catch
のコールバックと共に登録されます.この処理を subscribe と呼んでいます.
次に,生成された Promise オブジェクトがどう resolve/reject されていくかを見てみます.
new Promise(resolve => resolve(42))
のコールバック内で,resolve(42)
が呼ばれる時に何が起きているのでしょうか.
resolve()
が呼ばれるresolve()
で引数に与えられた値が Promise の結果として保存される(これより後で.then
を追加されても大丈夫なように)- Promise の状態を fulfilled に変更します
- 親が fulfilled で終了したので,子要素の Promise オブジェクトそれぞれについて,
on_fulfilled
のほうのコールバックを呼びます - コールバックはいずれ
resolve
かreject
のどちらかを呼ぶので,それによって子要素のそれぞれで 1. からまたスタートします
このようにして,木の根から葉へと,親の状態に応じたコールバック(on_fulfilled
or on_rejected
)を実行しながら葉へと向かって非同期処理が走っていくようになっています.
なんとなくイメージは掴めましたでしょうか?もしさらに詳しく知りたければ,es6-promise を clone して各所に console.log
を挟み,実際に処理を走らせてみると様子が分かると思います.
まとめ
Vim script で ES6 Promise のライブラリを実装しました.現在はまだプルリク中ですが,いずれ(年内目標で)vital.vimで使えるようにしたいと思っています. メンテナンス性を損なうこと無く,よりユーザが快適に使えるプラグインの作成の役に立てば良いなと思っています.