基本型とメモリレイアウト

17 分で読了
このページの目次

レイアウトを理解するには、3つのことを区別する必要がある。型が許可する値、今回のコンパイルで採用されるストレージ方式、言語または標準ライブラリが保証する表現である。size_of の出力は現在のターゲットを観察するのに役立つが、すべての観察結果をクロスバージョン ABI として扱うことはできない。

サイズはアライメントと等しくない

型サイズ注意点
u8/i8、u16/i16、u32/i32、u64/i64、u128/i128それぞれ 1、2、4、8、16 バイトアライメントは依然としてターゲットに依存する
f32、f644、8 バイトNaN などの値は比較セマンティクスに影響する
bool1 バイト有効な値は false/true のみであり、任意の u8 ではない
char4 バイトUnicode スカラー値であり、任意の u32 ではない
usize/isizeターゲットのアドレス幅に対応するサイズクロスプラットフォームなファイルフィールドには適さない
()0 バイト、アライメント 1ゼロサイズだからといって、すべての ZST がアライメント 1 になるわけではない

固定幅整数のサイズは固定されているが、アライメントがサイズと等しいとは限らない。例えば、ある 32 ビットターゲットでは u64 のアライメントは 4 である。Sized 型へのポインタのサイズとアライメントは usize と同じだが、DST ポインタのメタデータはまた別の表現である。型レイアウト

char はサロゲートペアを除外した Unicode コードポイント、すなわち Unicode スカラー値である。ユーザーにとっての1つのグリフは複数のスカラー値で構成される可能性がある。'あ' は char として 4 バイト、UTF-8 テキストとして 3 バイトを占める。文字列の詳細については 文字列とパス を参照。

整数オーバーフロー:意図に応じて操作を選択する

通常の算術演算でオーバーフローチェックを行うかどうかは、コンパイル設定によって制御される。デバッグ/リリースの名前だけで絶対的に区別することはできない。予測可能な戦略が必要な場合は、checked、wrapping、saturating、または overflowing メソッドを直接呼び出す。

fn main() {
    assert_eq!(255u8.checked_add(1), None);
    assert_eq!(255u8.wrapping_add(1), 0);
    assert_eq!(255u8.saturating_add(1), 255);
    assert_eq!(255u8.overflowing_add(1), (0, true));
    assert_eq!(i32::MIN.checked_div(-1), None);
    assert_eq!(10i32.checked_div(0), None);
}

整数のゼロ除算や、符号付き最小値の -1 での除算も考慮する必要がある。加算のオーバーフローチェックを無効にしたからといって、すべての整数演算が安全にラップアラウンドするわけではない。定数評価におけるエラーは、コンパイル時に診断される可能性もある。演算子

数値変換:表現可能な範囲も型契約の一部

as は「数値が収まることを確認する」チェックではない。整数の狭義化(狭小化)は上位ビットを切り捨て、符号付き/符号なし変換には明確なビット解釈ルールがある。ファイルの長さが u64 から来ており、ローカルで usize を使用している場合は、失敗可能な変換をまず行い、その後でビジネスロジックと割り当ての上限をチェックする必要がある。usize に変換できるからといって、その分のメモリを割り当ててよいわけではない。

fn main() {
    let count = 300u16;
    assert_eq!(count as u8, 44);
    assert!(u8::try_from(count).is_err());
    assert_eq!(u32::from(255u8), 255);
    assert_eq!((-1i8) as u8, 255);
    // 数値からバイトへの変換は明確なエンディアンを使用し、現在の CPU に依存しない。
    let bytes = 300u32.to_be_bytes();
    assert_eq!(bytes, [0, 0, 1, 44]);
    assert_eq!(u32::from_be_bytes(bytes), 300);
}

浮動小数点数にも、通常の数学的直感には沿わないが有効な値がある。NaN は未初期化メモリではなく、有効な浮動小数点数である。partial_cmp は None を返す可能性がある。ソートに total_cmp を使用する場合、指定された全順序が使用されるため、通常の == のセマンティクスとは完全に一致しない。例えば、正のゼロと負のゼロを区別する。NaN、無限大、負のゼロを許可するかどうかは、ビジネスの境界で個別に定義する必要がある。数値変換、f64::total_cmp

構造体レイアウト:フィールドのオフセットにパディングが必要な理由

aパディング 3Bb:4Bc:2Bパディング 2B01481012repr(C) の例:フィールド間のパディングと末尾のパディングこの図は u32 のアライメントが 4、u16 のアライメントが 2 であることを仮定しています。他のターゲットでは、独自の ABI に従って計算する必要があります。
use std::mem::{align_of, offset_of, size_of};

#[repr(C)]
struct Header { a: u8, b: u32, c: u16 }

fn main() {
    println!("size={} align={} offsets={},{},{}",
        size_of::<Header>(), align_of::<Header>(),
        offset_of!(Header, a), offset_of!(Header, b), offset_of!(Header, c));
    if align_of::<u32>() == 4 && align_of::<u16>() == 2 {
        assert_eq!(size_of::<Header>(), 12);
        assert_eq!(offset_of!(Header, b), 4);
        assert_eq!(offset_of!(Header, c), 8);
    }
}

repr(C) は宣言順にフィールドをアライメントし、総サイズを構造体のアライメントに合わせて埋める。末尾のパディングにより、配列内の次の構造体もアライメントされる。デフォルトの repr(Rust) はフィールドの宣言順を保証せず、常に最小のレイアウトを選択するとも約束しない。

repr(C) はシリアライズフォーマットではない。パディングバイトは未初期化である可能性があり、エンディアンやターゲット ABI は依然として異なる。フィールド自体が C 互換型ではない場合もある。構造体全体をバイトとして直接読み取って、安全なプロトコルエンコーディングであると考えるのは誤りである。

オフセットから配列のステップサイズとフィールドの並べ替えを導出する

図の Header において、a はオフセット 0 を占める。b は 4 バイトアライメントを要求するため、オフセット 4 から始まる。c はオフセット 8 に続く。最後のフィールドはオフセット 10 で終了するが、構造体の最大アライメントは 4 であるため、総サイズは 12 に埋められる。配列の2番目の要素は 12 から始まり、その b は 16 に位置し、依然としてアライメントを満たす。

repr(C) のフィールド宣言順序を変更できる場合、フィールドを b、c、a の順に並べ替えると、これらのアライメント仮定の下でオフセットは 0、4、6 になり、総サイズは 8 になる。これは空間を節約するが、ABI とフィールドのオフセットを同時に変更するため、すでに公開されている外部インターフェースに対してこのような最適化を密に行うことはできない。

外側の repr(C) は、内部の Rust struct の表現を再帰的に書き換えるものではない。2つの型のサイズとアライメントが同じだからといって、有効な値の集合、デストラクタの動作、呼び出し ABI が同じであるわけではない。これらの条件は個別に確認する必要がある。Rust レイアウトルール

repr(transparent)、align、packed が何を変えるか

表現効果導出できない結論
repr(C)対応する C のレイアウトルールに従ってフィールドを整理するすべての内部フィールドが自動的に C ABI 型になる
repr(transparent)制約を満たすラッパーは、ラップされたフィールドのレイアウトと ABI を継承する任意の多フィールドラッパーが許可される、またはビジネス意味が自動的に同じになる
repr(align(N))型のアライメント要件を高める実際のオブジェクトが必ず特定の固定ハードウェアキャッシュラインを独占する
repr(packed) / packed(N)フィールドと構造体のアライメント制約を緩和する通常の参照が未アラインフィールドを指すことができる

packed で最も発生しやすいのは暗黙の借用の問題である。フィールドを値として読み取ることは合法であっても、&packed.field を作成して未アライン参照を作ることは合法ではない。また、いくつかのフォーマットやアサートマクロも暗黙に借用を行う。ローカル変数にコピーしてから借用するか、ポインタ読み取りが本当に必要な場合は &raw const と read_unaligned を使用し、初期化、範囲、所有権の要件を同時に満たす。

#[repr(C, packed)]
struct Packed { kind: u8, count: u32 }

fn main() {
    let packet = Packed { kind: 1, count: 300 };
    let copied = packet.count; // Copy フィールドを値として読み取り、&u32 を作成しない。
    assert_eq!(copied, 300);
    let ptr = &raw const packet.count;
    // SAFETY: ptr はまだ生存しており、初期化された u32 フィールドを指している。read_unaligned はアライメントを要求しない。
    // u32 は Copy であるため、読み取りは二重解放や他の所有権の重複問題を引き起こさない。
    let read_back = unsafe { ptr.read_unaligned() };
    assert_eq!(read_back, 300);
    assert_eq!(packet.kind, 1);
    assert_eq!(std::mem::size_of::<Packed>(), 5);
}

これは packed がローカルレイアウトに与える影響のみを説明しており、プロトコルデコーディングに代わるものではない。フィールド内の整数は依然としてホストの数値表現に従い、外部データは不正または不足している可能性がある。read_unaligned はアライメント要件を緩和するだけで、任意のアドレス、未初期化値、または非 Copy 所有権のコピーを安全にするものではない。read_unaligned

フィールドとパディングをバイトで見る

切り上げはアドレスの制約から生じます。各フィールドの開始位置と配列の間隔は、それぞれのアラインメントの倍数です。点はデータではなく余白で、初期化済みとも限りません。宣言順を変えると余白を減らせますが、外部に見える配置も変わります。

図を準備しています
フィールドとパディングをバイトで見る

repr(C) は各フィールドと最終サイズを切り上げて整列し、並べ替えは ABI も変えます。

配列の引数渡し:所有権セマンティクスと実際のコピーを分離する

[T; N] の長さは型に含まれ、レイアウトは連続しており、サイズは size_of::<T>() * N である。配列は本質的にスタック上にあるわけではない。Box、静的ストレージ、または他のオブジェクトの中にも存在しうる。

T: Copy の場合、配列は Copy される。[String; N] のような非 Copy な配列を値で渡すと、所有権が移動する。コンパイラは機械レベルでの移動を最適化できるが、move を「要素ごとの Clone」と記述したり、必ず一度の全配列 memcpy が実行されると保証したりすることはできない。

任意の長さのデータの読み取り専用アクセスが必要な場合は、&[T] が便利であることが多い。配列を受信側に責任を持たせて渡す必要がある場合は、値渡しには明確な意味がある。選択はまず所有権に依存し、その後で大きな値の渡しのオーバーヘッドを測定する。

ZST にも型、アライメント、動作がある

ゼロサイズ型(ZST)の値はフィールドデータのためにバイトを占める必要はないが、メソッド、Drop 動作、アライメント要件を持つことができる。複数の ZST が同じアドレスを持つ可能性があり、アドレスで論理的なオブジェクトを識別することはできない。

use std::sync::atomic::{AtomicUsize, Ordering};
static DROPS: AtomicUsize = AtomicUsize::new(0);
struct Marker;
impl Drop for Marker {
    fn drop(&mut self) { DROPS.fetch_add(1, Ordering::Relaxed); }
}
fn main() {
    assert_eq!(std::mem::size_of::<Marker>(), 0);
    { let _markers = [Marker, Marker]; }
    assert_eq!(DROPS.load(Ordering::Relaxed), 2);
}

したがって、「ZST はランタイムに完全に存在しない」という主張は成立しない。Vec<ZST> には長さごとのデストラクタセマンティクスがあり、要素のストレージが通常の意味での非ゼロ割り当てを必要としない場合でも同様である。

niche:内部で使われていない表現の利用

参照は null になれず、NonZeroU32 は 0 になれない。これらの使用されていない表現は、Option の None をエンコードするために使用できる。標準ライブラリは、一連の型の Option が元の型と同じサイズ、アライメント、呼び出し ABI を持つことを明確に保証している。このリストを基準とするべきである。Option 表現の保証

use std::mem::size_of;
use std::num::NonZeroU32;
fn main() {
    assert_eq!(size_of::<Option<&u8>>(), size_of::<&u8>());
    assert_eq!(size_of::<Option<NonZeroU32>>(), size_of::<NonZeroU32>());
    println!("Option<bool> observed size: {}", size_of::<Option<bool>>());
    println!("nested Option reference observed size: {}", size_of::<Option<Option<&u8>>>());
}

Option<bool> は余分なビットパターンを利用して圧縮できることが多いが、None の具体的なバイト値を 2 に固定してはならない。ネストされた Option では、外側の None、Some(None)、Some(Some(_)) を区別する必要があるため、内側にすでに存在する null ポインタ表現をそのまま再利用することはできない。

表現の最適化は、値があるかどうかを判断する必要がないことを意味するものではなく、無効な bool や無効な参照の作成を許可するものでもない。メモリサイズ、制御フローのコスト、有効な値の制約はそれぞれ理解する必要がある。

外部バイトと有効な値:無効な値を先に作ってからチェックしない

外部バイト切れた入力・範囲外・不正値明示的なデコードと検証長さ、エンディアン、範囲、UTF-8有効な Rust 値型の不変条件が成立しているバイトから Rust 値へ:レイアウトが一致した後も、有効な値の制約を満たす必要があるu8 の 256 種類のビットパターン ≠ bool の2つの有効な値MaybeUninit は未初期化のストレージを保持することを許可しているが、assume_init 時の有効性の証明を免除するものではない。

bool は 1 バイトを占めるが、有効な表現は 0 と 1 のみである。char は 4 バイトを占めるが、サロゲートペアのコードポイントは有効な char ではない。参照には、非空に加えて、アライメント、ライフタイム、アクセス可能なメモリなどの制約がある。unsafe でまずバイトをこれらの型に強制変換してから、それらが合法かどうかをチェックするのは、チェックを遅すぎた位置で行うことになっている。未定義動作と有効な値

より直接的な方法は、最も弱い保証のバイトから始め、段階的により強力な型を構築することである。以下では、5 バイトのワイヤーレコードを規定する。最初のバイトは 0/1 の有効無効、後の4バイトはビッグエンディアンの uint32 カウントである。そのワイヤー長はプロトコルで規定されており、任意の Rust 構造体の size とは無関係である。

#[derive(Debug, PartialEq)]
struct Record { enabled: bool, count: u32 }

fn decode(input: &[u8]) -> Result<Record, &'static str> {
    if input.len() != 5 { return Err("length"); }
    let enabled = match input[0] {
        0 => false,
        1 => true,
        _ => return Err("invalid bool"),
    };
    let count = u32::from_be_bytes(input[1..5].try_into().map_err(|_| "length")?);
    if count > 1000 { return Err("business limit"); }
    Ok(Record { enabled, count })
}
fn main() {
    assert_eq!(decode(&[1, 0, 0, 1, 44]), Ok(Record { enabled: true, count: 300 }));
    assert_eq!(decode(&[2, 0, 0, 0, 0]), Err("invalid bool"));
    assert_eq!(decode(&[1, 0]), Err("length"));
    assert_eq!(decode(&[1, 0, 0, 3, 233]), Err("business limit"));
    assert_eq!(char::from_u32(0xd800), None);
}

MaybeUninit<T> は、有効な T が存在することがまだ保証されていないストレージを表すために使用される。すべての必要なフィールドが正しく初期化された後にのみ、T を取得できる。全ゼロのメモリはすべての T の有効な値ではない。特に参照、NonZero、および特定の enum でそうである。MaybeUninit<T> と T のサイズとアライメントの関係は、Option<MaybeUninit<T>> が T の元の niche 最適化を引き継げることを意味しない。MaybeUninit

レイアウトコードを読む際には、次のように順に問いかけることができる。現在のターゲットのみを観察しているか、約束された表現に依存しているか、有効な値を構築したか、所有権を満たしているか、クロスプラットフォームのワイヤーエンコーディングが必要か。5つの答えがそれぞれ明確であれば、「メモリサイズが同じである」ということだけで安全を証明するよりもはるかに信頼性が高い。