RustのREPL "evcxr" を使ってみた

概要

evcxrはRustのパーサーとコンパイラを外部から呼び出すことでREPLを実現している。セミブラックボックス的なアプローチにも関わらずなかなかの完成度で、今後が期待できる。

evcxr

evcxrは最近公開されたRustのREPL (GitHub, redditのpos)である。また、Jupyterのカーネルもあるため、Jupyter Notebook上でRustを書くこともできる。

Evaluation Context for Rust とのことで、 ev + cx + r と思えば覚えられる。

インストール方法

コマンドラインは以下で入る。

cargo install evcxr_repl

Jupyter版については公式サイトを参照するとよい。

基本的な動作を試してみる。

$ evcxr
Welcome to evcxr. For help, type :help
>> :help
:vars             List bound variables and their types
:opt              Toggle optimization
:explain          Print explanation of last error
:clear            Clear all state, keeping compilation cache
:dep              Add dependency. e.g. :dep regex = "1.0"

Mostly for development / debugging purposes:
:last_compile_dir Print the directory in which we last compiled
:timing           Toggle printing of how long evaluations take
:last_error_json  Print the last compilation error as JSON (for debugging)
:internal_debug   Toggle various internal debugging code
>>

: がメタコマンド。今のところ終了コマンドはないが、Ctrl+CやCtrl+Dで終了できる。

>> let s = String::from("Hello, ");
>> s
"Hello, "
>> s.push_str("world!");
   ^ cannot borrow mutably
cannot borrow immutable local variable `s` as mutable

エラーに色がついて、年季のわりに凝っている。

>> let mut s = s;
>> s.push_str("world!");

シャドーイングも普通にできる。

1行ずつコンパイルしているため体感がかなり遅い。

>> s
"Hello, world!"
>> s
"Hello, world!"

式はborrowして表示しているっぽい。

>> drop(s);
>> s
   ^ not found in this scope
cannot find value `s` in this scope

dropするとなくなる。

>> let s = String::from("ABC");
>> if false { drop(s) };
>> s
   ^ not found in this scope
cannot find value `s` in this scope

実際にはdropされていなくても、同じコンパイルエラーの条件に落ちるときは、なくなった扱いになる。

>> let s = String::from("foo");
>> let z = &s;
            ^ borrowed value does not live long enough
`s` does not live long enough
Values assigned to variables in Evcxr cannot contain references (unless they're static)
>> { let z = &s; }
()

行をまたいで参照を渡すことはできないが、スコープを切ってやればできる。

>> use std::io::{self, Read, Write, BufRead};
>> use std::fs::File;
>> use std::io::BufReader;
>> let f = File::open("/proc/cpuinfo").unwrap();
>> let mut f = BufReader::new(f);
>> let mut line = String::new();
>> f.read_line(&mut line).unwrap();
>> line
"processor\t: 0\n"

インポートは行をまたいで有効。

シリアライズできない値も持ち越せる。

>> extern crate rand;

extern crate を宣言すると、いい感じに依存関係を追加してくれる。依存関係のコンパイルをするので、時間はかかる。

ここで、手元に evcxr-test というlibrary crateを作ってみる。内容はこんな感じ。

pub fn fib(n: u32) -> u32 {
    (0..n).fold((0, 1), |(a, b), _| (b, a + b)).0
}

これは以下のようにして追加できた。evcxrのtemporary directory上にある Cargo.toml に依存を追加するような仕組みになっているっぽい。

>> :dep evcxr-test = { path = "/home/qnighy/workdir/evcxr-test" }
>> evcxr_test::fib(40)
102334155

ただ、 path はevcxrのtmpdirからの相対パスのため、現状ではほぼ絶対パスしか使えないようだ。

さっき作ったライブラリを書き換えてみる。

pub fn fib(n: u32) -> u32 {
    (0..n).fold((0, 1), |(a, b), _| (b, a + b)).0
}

pub fn foo(n: u32) -> u32 {
    n
}

:dep でリロードしてみる。

>> :dep evcxr-test = { path = "/home/qnighy/workdir/evcxr-test" }
>> evcxr_test::foo(40)
40

リロードできた。

>> struct Foo(u32);
Items currently need to be explicitly made pub along with all fields of structs.
>> pub struct Foo(u32);
Items currently need to be explicitly made pub along with all fields of structs.
>> pub struct Foo(pub u32);
>> let x = Foo(42);
>> let y: Foo = x;

アイテム定義は持ち越されるが、プライベートな定義が含まれてはいけない。(フィールドも含めて)

>> pub struct Foo(pub u32);
>> let z: Foo = y;
                ^ expected struct `user_code_29::Foo`, found struct `user_code_26::Foo`
mismatched types

同じ名前をもう一度定義したときは、別のものとして扱われる。シャドーイングしたかのような動作になる。

仕組み

HOW_IT_WORKSという文書があった。Rustで外部REPLを作ろうと思ったらこうなるだろう、という妥当な設計になっている。

  • シリアライズできない値も持ち越したい。また、パニックやunsafeによるsegfaultなどもある程度ハンドリングしたい。
    • REPLコードを実行するための子プロセスをひとつ立ち上げて、そこに順次ライブラリをロードしていく形になる。
    • 親プロセスは新しい行が来たら syn でパースして、必要なスタブをくっつけてからコンパイルする。
    • コンパイルされた行 (単独のライブラリになっている) を子プロセスにロードさせて、実行させる。式だったら結果を Debug で表示する。
  • 各行を、あたかも前の行の続きであるかのように実行させるには、インポートとグローバル定義、ローカル定義を引き継ぐ必要がある。
    • インポートは単に、過去に実行した extern crateuse を全部覚えておいて、毎回リプレイすればよい。
    • グローバル定義は、過去に定義したものを覚えておいて、前に実行したときの定義を引き継ぐような use を生成する。
      • 行をまたぐと別のモジュールになってしまうため、privateな定義は直感的に振る舞わない。今はそういう定義を丸ごと禁止している。
    • ローカル定義の型定義は、変数名と型名を String の対として (親プロセスが) 持っておく。
    • ローカル定義の実体は、 HashMap<String, Box<dyn Any + 'static>> という評価文脈として (子プロセスが) 持っておく(おそらくこれが名前の由来)。
      • そのため、参照は持ち越せない。
    • 実際に生成されるコードは、文脈を受け取って文脈を返す関数になる。
      • 文脈を受け取る部分は、親プロセス側が知っている型定義に基づいてdowncastする let が挿入される。
      • 文脈を返す部分は、その行の let に基づいて、構文的に返しうる変数を全て挿入するような処理が生成される。これはコンパイルエラーになる可能性があるが、とりあえずコンパイルしてから判断する。
      • その変数が 'static でないためにエラーになった場合は、その旨をユーザーに報告して以上。
      • その変数がmove outされている可能性があるという趣旨のエラーだった場合は、その変数の挿入処理を削除して再試行する。

rustcにはコンパイル結果をJSONで返す仕組みがあるため、コンパイルエラーに基づく処理は意外と堅牢に動作するらしい。

感想

思ったよりよく出来ている。REPLの用途の一つはライブラリ制作時のちょっとした確認用だが、そのための機能は少々足りていないと感じた。具体的にはtmpdirを使うと、起動するたびに依存ライブラリの再コンパイルが必要なのと、手元のライブラリの参照が面倒なのがネックになる。まさにその問題がTODOに書かれているので今後に期待。

redditの作者コメントによると2ヶ月ちょいでここまでできたとのことなので、今後の発展でかなり良くなる可能性はありそう。