この記事は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によれば、

    1. 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になっています。

    1. RValueがインタプリタから到達可能な場合(つまり今後も参照される可能性がある場合)にはRVFlagのflagフィールドが使用され、kindフィールド用の1バイトのタグや、その他のフラグ類が格納されます。最下位ビットは常に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>ではNonNull>がnullにならないことが保証されているので、nullがOption::Noneに割り当てられ、Option<NonNull>は*mut RValueと同じメモリサイズになる。そしてRValueの先頭アドレスは常に8バイトアラインされているので、最下位3ビットは常に0になるはず…… しかしよく考えるとnullのビット表現の最下位ビットが0である保証が仕様上はない気がする… 詳しい人誰か教えてください。 ↩

bannerAds