Rust のメモリレイアウトなどの話

はじめに

以前 twitter で書いてたことを整理していなかったので、改めて見直し&整理をしようとしていたのですが、その過程で知らなかった事があれこれ出てきたので合わせてまとめてみました。

Representation

割と重大な事が書いてありました。

C など他言語から考えると、構造体のフィールド定義は上から順番にメモリが配置されるものですが、 Rust では struct, enum, union ではフィールドの再配置 (Representation) が行われます。

このうち Default Representation の "repr(Rust)" (とコード上で記述する事はないですが、便宜上そう表現するようです) ではメモリ効率を考慮して Rust コンパイラが適切な順番に並べ替えをするようです。

これに対し C Representation の "repr(C)" は C との連携のため、 C の仕様に準拠できるように再配置されます。つまり並び替えがされません。

実際にこの二つを比較してみます。

pub struct StructI1 {
    a: i8,
    b: i16,
    c: i32,
}

#[repr(C)]
pub struct StructI2 {
    a: i8,
    b: i16,
    c: i32,
}

let a = StructI1 { a: 7, b: 8, c: 9 };
let b = StructI2 { a: 7, b: 8, c: 9 };

これをデバッガから a (StructI1) と b (StructI2) の内容を観察してみます。

f:id:tan-y:20200919013222p:plain

右上の StructI1 は 値が 9, 8, 7 となっていて順番が入れ替わっています。一方 repr(C) を付与した StructI2 は 7, 8, 9 とそのままの順番になっています。

null pointer optimization

  • std::mem::size_of::<&i64>
  • std::mem::size_of::<Option::<&i64>>

この二つのサイズが同じになるのも repr(Rust) の再配置の一環で "null pointer optimization" という仕組みになります。 repr(Rust) のドキュメント によると

  • 値のもつ Rust の列挙型の場合、アイテム (variant) とデーター (data) で構成される
  • Option のようなデーターの持たないもの (None) とデーターを持つがそのデーターが null にならないもの (Some(&i64) など) の 2 種類で構成されている enum の場合、 null 値が variant の代わりになる (区別ができる) ので variant の分の領域を省略する

となります。また、これは Option の特例的な最適化ではなく、同じような構成にすれば独自に定義した enum でも適用されます。

また、 Option:: の T が参照だけではなく、関数ポインタ、 Box 、 Vec 、 String など null を持たない参照 (ポインタ) 型が該当します。この "null を持たない" の条件は内部管理が Unique 型で行っているものが該当しているようです。

一方、 * (ポインタ) は null がありえるので該当しません。

  • std::mem::size_of::<*const i64>
  • std::mem::size_of::<Option::<*const i64>>

この二つはサイズが異なります。

packed / align

どちらも Representation でレイアウト調整を指示するものです。 packed が使用領域を詰める方向に調整し、 align はアライメントを合わせる (使用領域を広げる) 方向に調整する違いがあります。一般的には repr(C) と合わせて使うかなと思いますが Default Representation の場合も有効です。

enum の representations

enum の representation は C と "Primitive representations" が使えます。

repr(C) は「C 言語と互換性を持つ」なので int 型と同じサイズになるかと思いますが、 C の int 型は処理系依存なのでちょっと曖昧な感じがします。

"Primitive representations" は "repr(整数型名)" と記述します (repr(i32) など) 。 C++ の scoped enum など幅が明確な場合はこちらを使用した方がよいかと思います。

これらはデーターを持たせない enum で有効で、データーを持たせると指定は無視され enum の Default Representation が適用されます。

関数ポインタの扱い

ドキュメントには見当たらなかったのですが下記のような感じでした。

  • 関数は関数ポインタ変数に格納できる
  • 関数ポインタは null を持てない
  • 関数ポインタは普通のポインタにキャストできる
fn sum(a:i32, b:i32) -> i32 {
    a + b
}

let p = sum as *const();
  • 普通のポインタから関数ポインタへのキャストは transmute で行う (null がありえるので Option にした方が望ましい)
let f = std::mem::transmute::<_, Option::<fn(i32, i32) -> i32>>(p);
  • Rust の関数ポインタと C の関数ポインタは同じ (呼び出し規約を合わせる必要はある)
  • trait object は fat pointer なので関数ポインタとは互換性がない (参照: トレイトオブジェクト)

おわりに

推測交じりの記事になってしまいましたが、現時点の理解でのまとめということで。

Default Representation は具体的な挙動がよくわからないのでコンパイラのバージョン間の互換性とか若干不安になるのですが、

ABI は安定してないみたいなので気にしたら負けっぽいです。ライブラリも原則ソースコードからのビルドですし特に問題ない。どうしても気にするなら Rust 内であっても repr(C) を使用する、ということですね。