型のサイズ

Essential
最終更新: タグ: Rust, メモリ

let x: u32 = 42; と書くとき、Rustは x のために何バイトを確保すべきかをコンパイル時に、無条件に知る必要がある。この要件を型のサイズ(size)と呼び、スタックフレーム、配列のストライド、memcpy 呼び出し、FFI境界など、メモリレイアウトのほぼすべての基盤となっている。

std::mem::size_of でサイズを照会する

std::mem::size_of::<T>()T のサイズをバイト単位で usize として返す。const 関数なので定数式の中で使える。

use std::mem;

fn main() {
    println!("{}", mem::size_of::<u8>());    // 1
    println!("{}", mem::size_of::<i32>());   // 4
    println!("{}", mem::size_of::<f64>());   // 8
    println!("{}", mem::size_of::<bool>());  // 1
    println!("{}", mem::size_of::<char>());  // 4
    println!("{}", mem::size_of::<usize>()); // 64ビットターゲットでは 8
    println!("{}", mem::size_of::<()>());    // 0
}

型ではなく手元の値がある場合は、代わりに mem::size_of_val(&x) を使う。

プリミティブ型のサイズ

下の表は標準的なプリミティブ型をまとめたものだ。サイズはプリミティブ型で学んだビット幅から直接導かれる。

サイズ(バイト)備考
u8, i81
u16, i162
u32, i32, f324
u64, i64, f648
u128, i12816
usize, isize4 または 8ポインタ幅に一致
bool1有効なビットパターンは 0x000x01 のみ
char4Unicodeスカラー値を格納
()0ユニット型はデータを持たない

usizeisize は、ターゲットプラットフォームのポインタと同じ幅だ。

構造体のサイズ:フィールドにパディングを加えたもの

構造体のサイズは各フィールドのサイズの単純な合計ではない。コンパイラはフィールド間に不可視のパディング(padding)バイトを挿入して、各フィールドが必要なアドレス境界で始まるようにする。その境界がフィールドのアライメント(alignment)だ — 詳細は次のチェックポイントで扱う。

use std::mem;

struct Foo {
    a: u8,   // 1 バイト
    b: u32,  // 4 バイト
    c: u16,  // 2 バイト
}

fn main() {
    println!("{}", mem::size_of::<Foo>()); // 7 ではなく 12
}

余分な5バイトはコンパイラが各フィールドのアライメント要件を満たすために追加するパディングだ。正確なレイアウトは型のアライメントで説明する。

列挙型のサイズ:判別子プラス最大バリアント

Rustの列挙型は本質的にタグ付き共用体だ。サイズは次のようになる。

size(enum)=size(discriminant)+size(largest variant)\text{size(enum)} = \text{size(discriminant)} + \text{size(largest variant)}

に加えて、全体をアライメントするために必要なパディングだ。判別子(discriminant)は、どのバリアントが有効かを識別する整数だ。

use std::mem;

enum Message {
    Quit,                  // データなし
    Move { x: i32, y: i32 }, // 8 バイトのデータ
    Write(String),         // 64ビットでは 24 バイト
}

fn main() {
    println!("{}", mem::size_of::<Message>()); // 少なくとも 24 + 判別子バイト
}

Option<&T> のニッチ最適化

コンパイラは参照がnullにならないことを知っている(ビットパターン 0x0…0 は無効だ)。そのため、その禁じられたビットパターンを None の判別子として使う。結果として Option<&T>&T と完全に同じサイズになる。

use std::mem;

fn main() {
    assert_eq!(
        mem::size_of::<Option<&u32>>(),
        mem::size_of::<&u32>(),  // 64ビットではどちらも 8 バイト
    );
}

これをニッチ最適化(niche optimisation)と呼ぶ — コンパイラが型内の無効なビットパターンを利用して判別子をタダで格納する。同じテクニックが Option<Box<T>>Option<fn()>、その他のnullにならないポインタ型に適用される。

動的サイズ型(DST)

意図的にコンパイル時サイズを持たない型がある。

  • [T]T 値のスライス。長さは型の一部ではない
  • dyn Trait — トレイトオブジェクト。具体的な型は実行時にしかわからない

size_of::<[u8]>() はコンパイルエラーになる。参照と一緒に size_of_val を使う。

use std::mem;

fn print_slice_size(s: &[u32]) {
    println!("{}", mem::size_of_val(s)); // 長さ * 4
}

fn main() {
    print_slice_size(&[1, 2, 3]); // 12
}

動的サイズ型(DST)へのファットポインタ(fat pointer)— &[T] または &dyn Trait — は既知のサイズを持つ。ポインタ幅二つ分だ(ポインタ + 長さ、またはポインタ + vtableポインタ)。

use std::mem;

fn main() {
    println!("{}", mem::size_of::<&[u32]>());    // 16: ptr + len
    println!("{}", mem::size_of::<&dyn std::fmt::Display>()); // 16: ptr + vtable
}

Sized トレイトと ?Sized

コンパイル時サイズを持つすべての型は暗黙的に Sized マーカートレイトを実装する。DSTは実装しない。デフォルトでは、ジェネリックな型パラメータは Sized を要求する。

fn wrap<T>(x: T) -> Box<T> {   // T: Sized は暗黙的
    Box::new(x)
}

T: ?Sized と書くことでその要件をオプトアウトし、T = [u8]T = dyn Trait を受け取れる関数にできる。?Sized は標準ライブラリの中でよく見かける(例:Box<T: ?Sized>Rc<T: ?Sized>)。

まとめ

  • std::mem::size_of::<T>()T のコンパイル時バイトサイズを返す。値しか手元にない場合は size_of_val(&x) を使う。
  • プリミティブ型のサイズはビット幅に一致する。() はゼロバイトだ。
  • 構造体のサイズはフィールドサイズの合計にアライメントのためのコンパイラ挿入パディングを加えたものだ。
  • 列挙型のサイズは判別子プラス最大バリアントだ。ニッチ最適化により Option<&T>&T と同じサイズになることがある。
  • DST([T]dyn Trait)はコンパイル時サイズを持たない。それらへのファットポインタはポインタ幅二つ分だ。
  • Sized はすべてのジェネリックパラメータへの暗黙の境界だ。?Sized はそれを解除する。