インタープリター
インタープリターは、機械語にコンパイルせずに MIR を実行するための仮想マシンです。
通常は tcx.const_eval_* 関数を介して呼び出されます。
インタープリターは、コンパイラ (コンパイル時関数評価、CTFE 用) とツール
Miri の間で共有されます。Miri は同じ仮想マシンを使用して、(unsafe な) Rust
コード内の未定義動作を検出します。
定数から始める場合:
#![allow(unused)]
fn main() {
const FOO: usize = 1 << 12;
}
rustc は、その定数が使用されるかメタデータに配置されるまで、実際には何も呼び出しません。
次のような使用箇所があるとします:
type Foo = [u8; FOO - 42];
コンパイラは、その型を使用する対象 (ローカル変数、定数、関数引数、…) を作成できるようになる前に、 配列の長さを把握する必要があります。
(この場合は空の) パラメーター環境を取得するには、
let param_env = tcx.param_env(length_def_id); を呼び出せます。
必要な GlobalId は次のとおりです。
let gid = GlobalId {
promoted: None,
instance: Instance::mono(length_def_id),
};
tcx.const_eval(param_env.and(gid)) を呼び出すと、配列長式の MIR の作成がトリガーされます。
MIR はおおよそ次のようになります。
Foo::{{constant}}#0: usize = {
let mut _0: usize;
let mut _1: (usize, bool);
bb0: {
_1 = CheckedSub(const FOO, const 42usize);
assert(!move (_1.1: bool), "attempt to subtract with overflow") -> bb1;
}
bb1: {
_0 = move (_1.0: usize);
return;
}
}
評価の前に、評価結果を格納するための仮想メモリ位置 (この場合は本質的に
vec![u8; 4] または vec![u8; 8]) が作成されます。
評価の開始時点では、_0 と _1 は
Operand::Immediate(Immediate::Scalar(ScalarMaybeUndef::Undef)) です。
これはかなり
込み入った表現です: Operand は、インタープリターのメモリ のどこかに格納されたデータ
(Operand::Indirect) か、(最適化として) インラインに格納された即値データのいずれかを表せます。
また Immediate は、単一の
(未初期化の可能性がある) スカラー値 (整数または thin pointer) か、
それら 2 つのペアのいずれかです。
この例では、単一のスカラー値は (まだ) 初期化されていません。
_1 の初期化が呼び出されると、FOO 定数の値が必要になり、
ここでは示さない tcx.const_eval_* への別の呼び出しがトリガーされます。
FOO の評価が成功すると、42 がその値 4096 から減算され、その結果が _1 に
Operand::Immediate(Immediate::ScalarPair(Scalar::Raw { data: 4054, .. }, Scalar::Raw { data: 0, .. })
として格納されます。
ペアの最初の部分は計算された値で、
2 番目の部分はオーバーフローが発生した場合に true になる bool です。
Scalar::Raw
は、このスカラー値のサイズ (バイト単位) も格納します。ここではそれを示していません。
次の文は、前述の boolean が 0 であることをアサートします。
アサーションが失敗した場合、
そのエラーメッセージがコンパイル時エラーの報告に使用されます。
失敗しないため、Operand::Immediate(Immediate::Scalar(Scalar::Raw { data: 4054, .. })) が、評価前に割り当てられた仮想メモリに格納されます。
_0 は常にその場所を直接参照します。
評価が完了した後、戻り値は op_to_const によって Operand から
ConstValue に変換されます。前者の表現は定数評価の実行中に必要なものに合わせたものであり、
ConstValue は定数評価の結果を消費するコンパイラの残りの部分のニーズに合わせた形になっています。
この変換の一部として、スカラー値を持つ型では、
結果の Operand が Indirect であっても、通常の ConstValue::Indirect ではなく、即値の
ConstValue::Scalar(computed_value) を返します。
これにより、結果の使用がはるかに効率的になり、また usize のような単純なものにアクセスするために
追加のクエリを実行する必要がないため、より便利にもなります。
同じ定数の将来の評価では、実際には インタープリターを呼び出さず、キャッシュ済みの結果を使用するだけです。
データ構造
インタープリターの外部向けデータ構造は、
rustc_middle/src/mir/interpret にあります。
これは主に、エラー enum と ConstValue および Scalar 型です。
ConstValue は、Scalar (単一の Scalar、すなわち整数または thin pointer)、Slice (パターンマッチングに必要なバイトスライスや文字列を表すため)、または Indirect のいずれかです。Indirect はそれ以外のものに使用され、仮想アロケーションを参照します。
これらのアロケーションには、tcx.interpret_interner のメソッドを介してアクセスできます。
Scalar は、何らかの Raw 整数またはポインターです。
詳細については、次のセクション を参照してください。
数値の結果を期待している場合は、eval_usize (u64 として表現できないものではパニックします) または try_eval_usize を使用できます。後者は、可能であれば Scalar を生成する Option<u64> になります。
メモリ
あらゆる種類のポインターをサポートするには、インタープリターにはポインターが指し示せる「仮想メモリ」が必要です。
これは Memory 型で実装されています。
最も単純なモデルでは、すべてのグローバル変数、スタック変数、動的アロケーションが、そのメモリ内の Allocation に対応します。
(実際にすべての MIR スタック変数にアロケーションを使用すると非常に非効率です。そのため、
小さく、かつアドレスが取得されないスタック変数には Operand::Immediate があります。
ただし、これは純粋な最適化です。)
このような Allocation は、基本的には、このアロケーション内の各バイトの値を格納する u8 のシーケンスにすぎません。
(加えて、いくつかの追加データがあります。下記参照。) すべての
Allocation には、Memory 内でグローバルに一意な AllocId が割り当てられます。
これにより、
Pointer は、AllocId (アロケーションを示す) と
アロケーション内のオフセット (ポインターがアロケーションのどのバイトを指すかを示す) のペアで構成されます。
Pointer が単なる整数アドレスではないのは奇妙に思えるかもしれませんが、
定数評価の間は、アロケーションが最終的に実際のどの整数アドレスに置かれるかを知ることはできない、という点を思い出してください。
そのため、AllocId を記号的なベースアドレスとして使用します。つまり、別個のオフセットが必要になります。
(余談ですが、
実行時のポインターもまた、単なる整数以上のものです。)
これらの割り当ては、参照と生ポインターが指す対象を持てるように存在します。
ものが割り当てられるグローバルな線形ヒープは存在せず、それぞれの
割り当て(ローカル変数、static、または(将来の)ヒープ割り当てのいずれであっても)は、
必要なサイズちょうどの小さなメモリを独自に取得します。
したがって、ローカル変数 a の割り当てへの
ポインターを持っている場合、当該ポインターを別のローカル変数 b へのポインターに
変更できるような操作は、(どれほど unsafe であっても)存在しません。
a に対するポインター演算は、そのオフセットだけを変更します。AllocId は同じままです。
しかし、これは Pointer を Allocation に格納したい場合に問題を引き起こします。
それを適切な長さの u8 の列に変換できないためです!
AllocId とオフセットを合わせると、ポインターが「見かけ上」持つサイズの 2 倍になります。
これが Allocation の relocation フィールドの目的です。Pointer のバイトオフセットは
一連の u8 として格納される一方、その AllocId は
帯域外に格納されます。
この 2 つは、Pointer がメモリから読み取られるときに再構成されます。
Allocation が必要とするもう 1 つの追加データは、どのバイトが初期化済みかを
追跡するための undef_mask です。
グローバルメモリと特殊な割り当て
Memory は評価中にのみ存在します。定数の最終値が
計算されると破棄されます。
その定数に何らかの
ポインターが含まれている場合、それらは「インターン化」され、TyCtxt の一部である
グローバルな「const eval memory」に移動されます。
これらの割り当ては残りの計算の間存続し、
最終出力にシリアライズされます(依存クレートがそれらを使用できるようにするためです)。
さらに、関数ポインターにも対応するため、TyCtxt 内のグローバルメモリは
「仮想割り当て」も含むことができます。これらは Allocation の代わりに、
Instance を含みます。
これにより、Pointer は通常のデータまたは
関数のどちらかを指すことができます。これは、関数ポインターから
生ポインターへのキャストを評価できるようにするために必要です。
最後に、グローバルメモリで使用される GlobalAlloc 型には、特定の
const または static 項目を指すバリアント Static も含まれています。
これは循環する statics をサポートするために必要です。その場合、
値のバイト列がまだ分からないため Allocation をまだ用意できない
static への Pointer が必要になります。
ポインター値とポインター型
インタープリターでよくある混乱の原因の 1 つは、ポインター値であることと
ポインター型を持つことが、完全に独立した性質であるという点です。
「ポインター値」とは、
Pointer を含み、したがってインタープリターの仮想メモリ内のどこかを指す
Scalar::Ptr を指します。
これは、単なる具体的な整数である Scalar::Raw とは対照的です。
しかし、*const T や &T のようなポインター型または参照型の変数は、
ポインター値を持っている必要はありません。整数をポインターにキャストまたは
transmute することで得られたものかもしれません。
同様に、実際の割り当てへの参照を整数にキャストまたは transmute すると、
整数型(usize)におけるポインター値
(Scalar::Ptr)が得られます。
これは問題です。なぜなら、ポインター値に対して除算のような
整数演算を意味のある形で実行することはできないからです。
解釈
定数評価の主なエントリポイントは tcx.const_eval_*
関数ですが、
rustc_const_eval/src/const_eval
には、ConstValue(Indirect であるかどうかを問わず)のフィールドにアクセスできる
追加の関数があります。
コンパイルターゲット(現時点では LLVM のみ)へ変換する場合を除き、
Allocation に直接アクセスする必要は決してないはずです。
インタープリターは、評価中の現在の定数のために仮想スタックフレームを作成することから始めます。 このガイドを執筆している時点では、定数ではローカル(名前付き)変数が許可されていないことを除けば、 定数と引数のない関数の間に本質的な違いはありません。
スタックフレームは、
rustc_const_eval/src/interpret/eval_context.rs
の Frame 型によって定義され、すべてのローカル変数のメモリ
(評価開始時は None)を含みます。
各フレームは、ルート定数または後続の const fn 呼び出しのいずれかの
評価を参照します。
別の定数の評価は単に tcx.const_eval_* を呼び出し、それによって
完全に新しく独立したスタックフレームが生成されます。
フレームは単なる Vec<Frame> であり、unsafe コードを通じてひどい小細工を行ったとしても、
Frame のメモリを実際に参照する方法はありません。
参照できる唯一のメモリは Allocation です。
インタープリターはここで、
rustc_const_eval/src/interpret/step.rs
にある step メソッドを、エラーを返すか、実行すべき文がなくなるまで呼び出します。
各文はここで、ローカルまたはローカルから参照される仮想メモリを
初期化または変更します。
これには他の定数や statics の評価が必要になる場合があり、
その場合は単に tcx.const_eval_* が再帰的に呼び出されます。