RustでLLVM-C APIへの高レベル抽象インターフェースを考える
LLVMはC APIを提供しており、C++以外のプログラミング言語からも比較的簡単に利用できる。
Rustではllvm-sysというC APIのバインディングが既にあるので、C APIを単に利用するのであればこのクレートを依存に加えればすぐに利用できる。しかし llvm-sys
はC APIの純粋なバインディングなので、Rustのコードから使用するには貧相であり、なにより アンセーフ である。 *-sys
は crates.ioの命名規約の一種で、高レベルな抽象は別途提供すべきとされている。
LLVMでは既にinkwellやllvm-irのような高レベルな抽象インターフェースを提供するクレートが存在するが、Rustの学習も兼ねて自分で書いてみた。
Context
LLVMで最初に作られて最後に破棄されるコンテキスト(Context
)を抽象化するのは簡単で、以下のようにnewtypeを設けて Drop
実装で自動的に破棄されるようにできる。
// 生のLLVMContextRefを包む
pub struct Context(LLVMContextRef);
impl Context {
pub fn new() -> Self {
Context(unsafe { LLVMContextCreate() })
}
pub fn as_ptr(&self) -> LLVMContextRef {
self.0
}
}
impl Drop for Context {
fn drop(&mut self) {
// Contextがもはや使用されなくなったので、生のLLVMContextRefが指すLLVM側のリソースを解放する
// LLVM C APIではリソースの解放を行うAPIの命名に `Dispose` が用いられている
unsafe { LLVMContextDispose(self.as_ptr()) };
}
}
Type
LLVMの型(Type
)はどうだろうか? 例えばLLVMの double
型を表現する LLVMTypeRef
は、関数 LLVMTypeRef LLVMDoubleTypeInContext(LLVMContextRef)
によって得ることができる。これを素直に実装する:
pub struct Type(LLVMTypeRef);
impl Type {
pub fn double(context: &Context) -> Self {
Type(unsafe { LLVMDoubleTypeInContext(context.as_ptr()) })
}
}
一見すると問題なさそうなコードだが、ここで参照している LLVMTypeRef
は LLVMのコンテキスト上で管理されているリソース なので、 Context
がdropされるとLLVMのコンテキストは Dispose
され、 LLVMTypeRef
は死んだ参照になってしまう。Rustではこれはセーフなコードとは言えない。
let double_ty = {
// コンテキストが生成され
let context = Context::new();
// そのコンテキスト上のdouble型を得たが
let ty = Type::double(&context);
// スコープを抜けることでLLVM側ではコンテキストごと破棄されるにも関わらずtyが生き残ってしまう!
ty
};
ではどうするか。
- Rustのレベルで参照カウンタ
Rc
を使ってContext
とType
の関係を管理する - Rustのライフタイムを利用する
- unsafeマークする
今回の場合は、LLVMのレベルで型はコンテキストに属しており、 LLVMDoubleTypeInContext
の返す型もコンテキスト上に都度作成しているのではなく Context
が保持している既定のdouble型への参照を得ている形となっているので、(2)が適切そうだ。このような情報はしばしばC APIでは抜け落ちて暗黙のものとなっているので、C++ APIのリファレンスやLLVM自体のリファレンスマニュアルから読み取ったり考える必要があった。
(2)のパターンで実装すると以下のようになる:
pub struct Type<'ctx> {
inner: LLVMTypeRef,
parent: PhantomData<&'ctx Context>,
}
impl<'ctx> Type<'ctx> {
// Typeはこのコンテキストへの借用が有効な範囲('ctx)で利用できる
pub fn double(context: &'ctx Context) -> Self {
Type {
inner: unsafe { LLVMDoubleTypeInContext(context.as_ptr()) },
parent: PhantomData,
}
}
}
Type
を得るには Context
への借用を必要とする。得られた Type
はその借用のライフタイム 'ctx
を含んでおり、 この借用のライフタイムの終端より後に Type
を使用するコードがプログラム中に含まれる場合はコンパイルエラーとして通知してくれる。
Value
LLVMの値 (Value
) はどうなるか。
LLVMはライブラリの性質上、複雑なツリー構造 (LLVM-IR) を構築・操作するための各種APIからなり、これらAPIにはこのツリー構造に基づいた様々な暗黙の取り決めがある。例えば LLVMDeleteFunction
は関数自身をモジュールから消去するので、この関数自身を指す LLVMValueRef
や関数内の LLVMBasicBlockRef
, 引数を表す LLVMValueRef
等は消去以降利用してはならなくなる。
Rustの所有権・借用・ライフタイムの検査はあくまで静的な、保守的なものなので、これらの取り決めが全て常に満たされるような静的な定義を与えるのは難しく、またそうしたAPIの使い勝手も良くないだろう。 Context
に Module
があり、 Module
に Function
があり、 Function
に BasicBlock
があり...といった構造全てを静的にトラックしようとすると型がライフタイムまみれになる。かといって、中途半端にトラックした上で例えば basic_block.parent()
のようなAPIを提供しても、もとの Function
がどこまで有効なのかは型に残っておらず、知る術がない、といったことになる。
今回の Value
実装(value.rs) では以下のようにしてみた:
-
Value<'ctx, 'p>
はコンテキスト'ctx
と親となる要素'p
のライフタイムを持つ- コンテキストグローバルな値(nullptr等)は
Value<'ctx, 'ctx>
として得られる - モジュールグローバルな値は
&'m Module<'ctx>
からValue<'ctx, 'm>
として得られる
- コンテキストグローバルな値(nullptr等)は
-
Value
はCopy
とし、Function
のappend_block
やBasicBlock
へのValue
の追加などは'p
を小さくしない - 代わりに
Function
やBasicBlock
の消去メソッドerase_from_parent
をunsafe
で定義してしまう
このようにすると、ライブラリとしては以下のようなデザインになる。
-
Value
は常に借用として提供される- 例えばモジュール以下の
Value
は、モジュールの借用のライフタイムの範囲において自由に生成して移動できる
- 例えばモジュール以下の
-
Value
はtyped-arenaのようにappend-only、つまり生成と追加のみ- 削除には
unsafe
が必要で、削除される定義への参照(借用)が残らないことをライブラリの利用者側が保証する必要がある
- 削除には
自分が利用してみた範囲では erase
系のAPIはほとんど呼ぶ機会が無かったので、このあたりが丁度よい落としどころとなった。
雑感
Rustらしい抽象インターフェースを提供するならば、C APIでは潰れて暗黙になっていることが多い所有の関係や借用の関係を洗い出すのが望ましいことがわかった。
もちろん unsafe
を用いて直接C APIを叩いていくこともできるが、Rustらしい安全な抽象インターフェースが用意できれば内部のC APIもいくらか安心して利用することができる。
Type
や Value
のライフタイムの問題の他にも、LLVMのC APIには安全でない操作が多々存在する。例えば、C APIでは全て LLVMTypeRef
や LLVMValueRef
に潰れているが、C++ API側の Type
や Value
は細かな継承によって振る舞いが加えられているので、 LLVMGetElementType(LLVMTypeRef Ty)
を double
型で呼び出すことはできない、など。今回の実装(types.rs)ではトレイトを駆使しているが、場合によっては型レベルでの表現はせず単に assert
を挟むなど、いい塩梅を見出してやれるといいかなと思う。
Discussion