目黒.vim #16 に参加して Vim script に const を実装するパッチを書いた

あいにくの雨でしたが,Meguro.vim #16 に参加しました.

目黒.vimVim に関したり関しなかったりする作業をするもくもく会で,今回は Vim:const を追加するパッチを書きました.

github.com

:const コマンドの機能

Vim script では変数を :let で定義・代入します.ですが,

  • 新しい変数定義時も代入時も同じ :let を使う
  • if などで新しいスコープが作成されない(動的スコープ)

により,意図せず既存の変数を上書きしてしまうケースがあります.

" Define variable
let i = 0

if some_condition
    " In heavily nested or big statements...
    let i = 1 " Unexpectedly using the same name variable
endif

echo i
" => 1 (expected 0)

意図せず変数が上書きされないようにするためのコマンドとして :lockvar があります.他のプラグインなどから勝手に上書きされないようにグローバル変数をロックするのに使われたりはしますが,ローカル変数に使うほどのお手軽さはありませんでした.

  • 毎回ロックするのは面倒だし忘れる
  • 余分に1コマンド実行するコスト(パースおよび解釈)がかかる

一方,JavaScript では ES2015 から const が追加されました.これは JavaScript の変数定義をレキシカルスコープにし,参照を変更不能にする構文です.

const number = 42;
number = 99;  // TypeError: invalid assignment to const `number'

Vim script でも内部的に :lockvar の実装を使えば,これと似たような実装ができると思い, 変更しない変数の定義に :let の代替として使える :const を実装しました.:let の代わりに :const を使うだけなので,別途 :lockvar を使うのに比べ面倒ではありません.また,変数の定義と同時にロックするので別コマンドで実行するのに比べてコストが遥かに安いです(実際,ただフラグを立てるだけ).

" Define locked variable
const i = 0

if some_condition
    let i = 1 " Error! `i` is locked
endif

echo i
" => 0 (expected)

リストの分解など複雑な定義式も :let と同様に使えます.

const [a, b, c] = [1, 2, 3]
const [x; xs] = [1, 2, 3]

さらに,:const は安全のため,既存の変数を上書きできないようになっています.もし既存の変数をロックしたい場合は従来どおり :lockvar を使ってください.

let i = 1
const i = 2 " E995 cannot modify existing variable

:const でロックする深さは 1 です(:lockvar 1 相当).なので例えばリストを定義した時,要素まではロックされません.

const l = [1]
call add(l, 2)
echo l
" => [1, 2]

なぜ再帰的に全ての値をロックする :lockvar! 相当にしないのかというと,

let a = 1
let l = [a]
lockvar! l " 再帰的にロックするので a もロックしてしまう
let a = 2 " エラー!a はロックされているので変更できない

のように意図せず変数をロックしてしまう可能性があるからです.また,ネストが深い辞書やリストをロックすると深さに比例したコストがかかってしまいます.

その他,注意する点は

  • その性質上,環境変数$FOO)やオプション値(&filetype)には :const は使えません(E996
  • :unlockvar で無理やり :const で定義した変数のロックを解除することが可能です
  • JavaScriptconst はスコープがレキシカルですが,Vim script の :const はスコープがダイナミックのままです
  • JavaScript 処理系はコードのパース時に const 定義された変数への再代入を検知してエラーにできますが,Vim スクリプト処理系は素朴なインタープリタなので :const で定義された変数が実際に書き換えられようとするまでエラーになりません

:const コマンドの実装

diff はこちら. src/eval.cex_const() を定義し, src/ex_cmds.h に追加したいコマンドを定義して ex_const 関数ポインタを登録しておくと,:constex_const() が呼ばれます.

ちなみに新しいコマンドを追加した時は make cmdidxs でコマンド検索表である src/ex_cmdidxs.h を再生成する必要があります.

:const は変数をロックする以外は :let と同じ挙動のため,実装を :let と共有しています.定義した変数をロックするかどうかのフラグを :let の実装関数に追加し,フラグが立っている場合は変数定義時に di_flagsDI_FLAGS_LOCK フラグと di_tv.v_lockVAR_LOCKED フラグを立てるだけです.複合代入(+= など)や環境変数への代入やオプション変数への代入のパスはフラグが立っているとエラーにします.

テストの作成方法は src/testdir/README.txt に書いてありますが,src/testdir 内に test_const.vim を作成し,src/testdir/Make_all.mak をちょっといじるだけでかなり簡単に実装できます.test_const.vim 内に Test_* な名前の関数を定義しておくと, make test_const でテストが実行できます.

:const コマンドのデバッグ

変数に値をセットする関数 set_var_lval() で,一時的に読み先の文字に NUL をセットしてあとで復帰している部分にエラーチェックを追加した際に復帰処理を追加するのを忘れてエンバグしてしまいました(const [a] = ... の解釈途中で Internal error).

Vim では標準出力はエディタが使っているので printf() などで出力することは基本的にできません.デバッグ方法は src/README.md に書いてあり,

  • デバッグプリントを ch_log() で出しておき,デバッグのための Vim 起動時に :call ch_logfile('log.txt', 'w') のようにしてファイルにデバッグログを吐く.タイミング問題を追う時などに便利
  • :packadd termdebug し,:TermdebugVim の中で Vim を起動して gdb を使ってデバッグする.Macgdb を使うには実行ファイルをコード署名する必要があり若干面倒ですが,大変便利です

vim/vim へのプルリク

ドキュメントを書いて,パッチを GitHubvim/vim リポジトリにプルリクします.

github.com

新機能の実装の場合,取り込まれるかどうかは作者の Bram さん次第なのでどうかなと思ったのですが,サクッとマージしてもらえました(Vim では変更をパッチとして取り込むので,GitHub のマージ機能を使っていません).Bram さんは普段 JavaScript や TypeScript を書くらしく,同じことを考えたことがあったらしいです.Vim script の新機能を Bram さんに説明したいときは JavaScript を引き合いに出すと良いのかもしれません.

あまりに速くマージされたので,いくつか細かいミスに後から気付き追加のパッチを送りました.

github.com