この記事はRust Advent Calendar 2021の7日目の記事です。
TLDR
Rustのunionについて、自作Ruby処理系の宣伝を交えつつ紹介します。結論としては、unionは危ないのであんまり使わない方がよい。
自己紹介
こんにちは、Rustで ruruby と称するRubyインタプリタを作っているmonochromeといいます。言語処理系が好きで、普段はプログラミング言語処理系が好きな人の集まりSlackあたりでウロウロしております。
ruruby のGitHubレポジトリは下記です。
https://github.com/sisshiki1969/ruruby
プログラミング言語処理系が好きな人の集まりSlack の入り口は下記。プログラミング言語処理系が好きな方ならどなたでも参加できます。
https://prog-lang-sys-ja-slack.github.io/wiki/
ruruby はRuby(ここではC言語で書かれたCRuby =「みんなが使っているRuby」を指します)並みの高速性を目標として開発しています。処理系の種別としては、CRubyと同様にRubyコードを仮想マシンコードへコンパイルして実行する、仮想マシンインタプリタ(VMインタプリタ)です。現時点でのベンチマークの結果を下記に掲載しています。
https://github.com/sisshiki1969/ruruby/wiki/Benchmarks
今回はインタプリタ作成の上で必要となったRustのunsafeな機能の一つとしてunion
を紹介し、実際にどのように使っているかを説明します。
union in Rust
union
はRust 1.19.0で安定化された機能で、struct
やenum
のようなRust組み込みのデータ構造です。
Rust Blog: What's in 1.19.0 stable
unionのRFC
union
はenum
に似ていますが、内部にバリアントの種類を示すタグを持ちません。
定義は
union MyUnion {
f1: u32,
f2: f32,
}
こんな形をしています。大文字始まりのバリアントを列挙するenum
と違い、struct
と同様の文法です1。違うのはC言語のunion
と同様、全てのフィールドが同一のメモリ領域に割り当てられることです。上の例だとf1
とf2
は同じメモリ領域を指します2。
let u = MyUnion { f1: 1 };
u.f1 = 5;
let value = unsafe { u.f1 };
1行目のコンストラクタ・2行目の代入はsafeですが、フィールドの読み出しはunsafeな操作になります。union
ではデータの内部にタグがないため、どのフィールドとして扱うのが正しいのかという情報がコンパイラから見えないからです3。
上記の例だとu.f2 = 0.0
と代入した後、誤ってu.f1
で読み出そうとすると意図しない値になります4。数値であれば「意図しない値」で済みますが、Box
やVec
など、ポインタとして読み出した場合には大変よろしくない事態になります。
また、Rust特有の注意点としてフィールド書き換えの際のデストラクタ処理(drop)の問題があります。enum
やstruct
では値を書き換える際、Rustコンパイラが古い方の値を破棄(drop)するコードを自動的に挿入します。union
の場合は古い値がどのフィールドなのかをコンパイラが知ることができないため、自動的なdropが安全に行えません。このため、現状ではunion
のフィールドはCopy
な値か、そうでない場合にはManuallyDrop
構造体でラップして、自動的なdropを行わないことをコンパイラに明示する必要があります。
std::mem::ManuallyDrop
Tracking issue for RFC 2514, "Union initialization and Drop"
以上の事情でunion
内のデータのデストラクタ処理はプログラマの責任となり、Drop
トレイトを自分で実装するか、手動でfreeするなどして適切に処理する必要があります。
で、何が嬉しいのか
Rustの特徴は堅固な静的型システムを持ち、所有権の概念でメモリ安全性を確保しつつ、高速に動作するコードを生成できることだと思っています。enum
は上記の特徴をすべて備えている一方、union
のようなunsafeだらけの機能はRustの長所を損なうもののように見えます。union
導入の動機としては、上記のRFCによれば、
- C言語の
union
との相互運用性 - enumに比べ、よりメモリ効率の良いデータ構造を実装できる
といった点にあります。以下、ruruby でどう使っているかみていきます。
使用例
Rubyは静的型システムを持たないオブジェクト指向言語です。ruruby ではRubyオブジェクトはRValue
という64バイトの構造体に格納されています5。RValue
は多岐にわたる種類のオブジェクトを表現するため、全オブジェクト共通のclass
・var_table
の他、オブジェクトの種類(≒クラス)固有の内部データを保持するkind
フィールドを持ちます。
/// ヒープアロケートされたRubyオブジェクト
pub struct RValue {
// 8バイト 下から2バイト目に、kindフィールドがどのObjKindかを示すタグ
// が格納される
flags: RVFlag,
// 8バイト オブジェクトのクラス 今回は関係なし
class: Module,
// 8バイト インスタンス変数テーブル 今回は関係なし
var_table: Option<Box<ValueTable>>,
// 40バイト オブジェクトの内部データ
pub kind: ObjKind,
}
pub union RVFlag {
// 生きているオブジェクトの場合:kindフィールドがどのObjKindかを示すタグ
flag: u64,
// 回収されたオブジェクトの場合:ガベージコレクタがリンクリスト用に使用
next: Option<NonNull<RValue>>,
}
#[repr(C)]
pub union ObjKind {
pub float: f64,
pub module: ManuallyDrop<ClassInfo>,
pub string: ManuallyDrop<String>,
pub array: ManuallyDrop<ArrayInfo>,
// 以下略
}
以下、RValueの構造をみていきます。
まず、最初のflags
フィールドがRVFlag
という名前のunion
になっています。
-
RValue
がインタプリタから到達可能な場合(つまり今後も参照される可能性がある場合)にはRVFlag
のflag
フィールドが使用され、kind
フィールド用の1バイトのタグや、その他のフラグ類が格納されます。最下位ビットは常に1となります。 -
RValue
がインタプリタからたどれない場合(つまり今後参照されることがない場合)にはガベージコレクタが回収してデストラクタ処理を行い、RVFlag
のnext
フィールドとして次の回収済みRValue
へのポインタを書き込みます。この値は最下位ビットが必ず0になります。6 -
最下位ビットをみることで、上記の1.か2.かが判定できます。
次に、kind
フィールドもunion
です。このフィールドの識別に使用されるタグ(1バイト)は下記のように定義され、flags.flag
に格納されます。
impl ObjKind {
pub const FLOAT: u8 = 3;
pub const MODULE: u8 = 5;
pub const CLASS: u8 = 20;
pub const STRING: u8 = 6;
pub const ARRAY: u8 = 7;
// 以下略
}
例えば、RValue
をタグに応じてStringオブジェクトとして扱い、内部のStringの参照を取得する関数は下記のようになっています。
// 安全にアクセスするためにまずkind()関数でタグを取得して分岐する
match self.kind() {
ObjKind::STRING => {
// 中のデータを取り出す
let s: &String = unsafe { &*self.kind.string };
// なんか処理する
}
// 以下略
}
impl RValue {
// タグを取り出す関数
fn kind(&self) -> u8 {
let flag = unsafe { self.flags.flag };
// うっかりGC用のポインタを読まないように最下位ビットが1か確認
assert!(flag & 0b1 == 1);
(flag >> 8) as u8
}
// ガーベジコレクタから呼ばれるデストラクタ
fn free(&mut self) {
unsafe {
match self.kind() {
ObjKind::MODULE => ManuallyDrop::drop(&mut self.kind.module),
ObjKind::CLASS => ManuallyDrop::drop(&mut self.kind.module),
ObjKind::STRING => ManuallyDrop::drop(&mut self.kind.string),
ObjKind::ARRAY => ManuallyDrop::drop(&mut self.kind.array),
// 以下略
_ => {}
}
self.flags.next = None;
self.var_table = None;
}
}
}
まとめ
Rustのunion
を概説し、自作処理系でどのように使っているかを例として挙げました。union
は危険なうえに使うのが面倒なので、よほどのことがない限りはenum
を使いましょう。
-
Rustでは
struct
やenum
はいわゆる予約語(Strict keywords)で変数名や関数名には使えない。が、union
は"Weak" keywordsとなっていて、変数名にも関数名にも使える。参考:The Rust reference: Keywords ↩ -
厳密には
union
のメモリレイアウトは"unspecified"なのでf1
とf2
が同一のメモリアドレスに割り当てられる保証はない(ような気がする)。レイアウトを明示したい場合は#[repr(C)]
アトリビュートを使用する。 ↩ -
例示したコードは正しく動作するが、一般的には
union
のデータがどのフィールドとして書き込まれたかは静的に確定できず、誤ったフィールドとして読み込んでしまうと大惨事になる。 ↩ -
意図的にf32で書き込んだものをu32で読み出す、という場合もある。 ↩
-
RValue
という構造体名はCRubyに合わせて名付けた。ちなみにCRubyのRValue
は(64ビットアーキテクチャの場合)40バイト。なおRValue
は常にヒープ上に確保されるが、Vec
やBox
とは異なりRustのアロケータではなく、ruruby 本体にある独自実装アロケータにより生成される。不要となったRValue
のデストラクタ処理は独自実装のガベージコレクタが行う。 ↩ -
Option<NonNull<RValue>>
ではNonNull<RValue>>
がnullにならないことが保証されているので、nullがOption::None
に割り当てられ、Option<NonNull<RValue>>
は*mut RValue
と同じメモリサイズになる。そしてRValue
の先頭アドレスは常に8バイトアラインされているので、最下位3ビットは常に0になるはず…… しかしよく考えるとnullのビット表現の最下位ビットが0である保証が仕様上はない気がする… 詳しい人誰か教えてください。 ↩