Yabutan 技術ブログ > Rust enum型のメモリサイズについて

Rust enum型のメモリサイズについて

#Rust

2022-11-26

この記事では、Rustのenum型がどのようなメモリ構造をしているのかの知識と、
enum型を扱う際のメモリ量の注意点、その回避方法を紹介します。

enum型のメモリレイアウト

enum MyEnum {
	A,             // 0 byte
	B(u32),        // 4 byte
	C([u32; 10]),  // 40 byte
}

上記のように宣言したMyEnum型を、下記のようにa,b,cの変数でそれぞれ定義した場合に、
各変数のメモリサイズはどうなるでしょうか?

let a = MyEnum::A;
let b = MyEnum::B(100);
let c = MyEnum::C([1; 10]);

私の環境では、答えはa,b,cの変数、全てが メモリサイズ 44byte になります。

環境によって数字は少し異なる可能性はありますが、a,b,cはすべて同じメモリサイズになります。
しかも、一番容量の大きい、C([u32; 10]) のサイズ + 数バイト となります。

これは、enum型は内部的には下記のような構造体で表現され、
各要素の中で一番大きい値が入るだけのメモリサイズを確保しているためです。

enum型の内部構造イメージ

struct MyEnum {
	var: A, B, or C,  // 最大要素に合わせたメモリサイズ
	tag: u8,          // tagによって、varの型認識を変える
}

varは最大メモリサイズを確保しておいて、tagによって、var型の扱いを変えています。
こういったタグによって、値の型認識を変えるものを、tagged union、 または variant などといいます。

  • A: 0 byte
  • B: 4 byte
  • C: 40 byte ←確保されるメモリサイズ

あと、Rustの構造体は、メモリレイアウトが最小になるように、
フィールドを自動で並び変えてから、アライメントに合わせてパディングを行います。
そのため、サイズによっては、enum型は実際より数バイト余分にメモリ確保されます。

今回の場合だと、
(var: 40byte) + (tag: 1byte) + (padding: 3byte) で、44 byte ということになります。

MyEnumのメモリレイアウト

メモリサイズを調べる方法

Rustでメモリサイズを調べるには、std::mem モジュール配下の関数を使います。

  • std::mem::size_of 指定の型サイズ
  • std::mem::size_of_val 指定変数の型サイズ

出力される単位はバイトです。(1byte = 8bit)

dbg!(std::mem::size_of::<bool>());
// ==> std::mem::size_of::<bool>() = 1

dbg!(std::mem::size_of::<u8>());
// ==> std::mem::size_of::<u8>() = 1

dbg!(std::mem::size_of::<u16>());
// ==> std::mem::size_of::<u16>() = 2

dbg!(std::mem::size_of::<u32>());
// ==> std::mem::size_of::<u32>() = 4

dbg!(std::mem::size_of::<u64>());
// ==> std::mem::size_of::<u64>() = 8

dbg!(std::mem::size_of::<u128>());
// ==> std::mem::size_of::<u128>() = 16

dbg!(std::mem::size_of::<String>());
// ==> std::mem::size_of::<String>() = 24

dbg!(std::mem::size_of::<Vec<u8>>());
// ==> std::mem::size_of::<Vec<u8>>() = 24

先ほどのMyEnum型を、これらの関数で調べると、44 byteになっています。

dbg!(std::mem::size_of::<MyEnum>());
// ==> std::mem::size_of::<MyEnum>() = 44

dbg!(std::mem::size_of_val(&a));
// ==> std::mem::size_of_val(&a) = 44

dbg!(std::mem::size_of_val(&b));
// ==> std::mem::size_of_val(&b) = 44

dbg!(std::mem::size_of_val(&c));
// ==> std::mem::size_of_val(&c) = 44

配列や、Vecでのenum型に注意すること。

これまでで述べたように、enum型は tagged union な構造をしている為、
大きいサイズの要素を持つ enum型を使う場合には注意が必要です。
特に、配列やVecで扱うときには、確保したメモリの大半が無駄になる可能性もあります。

配列の場合

let arr: [MyEnum; 10] = [
    MyEnum::A,
    MyEnum::B(1),
    MyEnum::A,
    MyEnum::C([1; 10]),
    MyEnum::B(2),
    MyEnum::B(3),
    MyEnum::C([2; 10]),
    MyEnum::A,
    MyEnum::C([3; 10]),
    MyEnum::A,
];

dbg!(std::mem::size_of_val(&arr));
// ==> std::mem::size_of_val(&arr) = 440

MyEnum型が44byteで、10個の配列となるため、メモリサイズは440byteになる。
実サイズが小さい、AやB要素だけの配列だったとしても、 440byteのメモリは必要になってしまいます。

Vecの場合

let list = vec![
    MyEnum::A,
    MyEnum::B(1),
    MyEnum::A,
    MyEnum::C([1; 10]),
    MyEnum::B(2),
    MyEnum::B(3),
    MyEnum::C([2; 10]),
    MyEnum::A,
    MyEnum::C([3; 10]),
    MyEnum::A,
];
dbg!(std::mem::size_of_val(&list));
// ==> std::mem::size_of_val(&list) = 24

let mem_size: usize = list.iter().map(std::mem::size_of_val).sum();
dbg!(mem_size);
// ==> mem_size = 440

MyEnum型が44byteで、10個のリスト要素となるため、ヒープメモリのサイズは440byteになります。
Vec変数自体は、24byteですが内部で確保されるヒープサイズが440byteです。

配列メモリイメージ

グレーの部分は実際には使っていませんが、メモリ確保されています。

Boxをつかって、配列の肥大化を回避する。

こういうときに役立つのがBoxです。
メモリサイズの大きい要素CをBoxでラップしてあげることで、回避できます。

enum MyEnum {
    A,                 // 0 byte
    B(u32),            // 4 byte
    C(Box<[u32; 10]>), // 8 byte <-- Boxでラップする!
}

dbg!(std::mem::size_of::<MyEnum>());
// ==> std::mem::size_of::<MyEnum>() = 16

Box自体は8byte、要求するアライメントサイズも8byteなので、
(var: 8byte) + (tag: 1byte) + (padding: 7byte) で、修正後のMyEnum型は16 byte ということになります。

MyEnumのメモリレイアウト

let arr: [MyEnum; 10] = [
    MyEnum::A,
    MyEnum::B(1),
    MyEnum::A,
    MyEnum::C(Box::new([1; 10])), // <-- Boxでラップ!
    MyEnum::B(2),
    MyEnum::B(3),
    MyEnum::C(Box::new([2; 10])), // <-- Boxでラップ!
    MyEnum::A,
    MyEnum::C(Box::new([3; 10])), // <-- Boxでラップ!
    MyEnum::A,
];

dbg!(std::mem::size_of_val(&arr));
// ==> std::mem::size_of_val(&arr) = 160

先ほどの例と同じ配列で、メモリサイズは 160byteになりました。
が、これは配列自体のメモリだけなので、Boxで確保した40byte x 3 = 120byte 分もヒープ領域の方にはあります。
それでも、160 byte + 120 byte = 280 byte なので、もともとの440byteから、メモリ使用量を減らすことができています。
もし、配列がすべてC要素で埋め尽くされている場合ですと、逆にBoxを使った分、余計にメモリは使ってしまいますが、 配列にAやB要素が多くなるにつれて、無駄になっていたメモリは少なくなります。

改修後の配列メモリイメージ

まとめ

  • enum型は、メモリサイズが一番大きい要素を基準にメモリ確保する。
  • enum型は、tagged union 構造になっていて、tag値で内部メモリの型認識を切り替えている。
  • 配列やVecは、無駄な領域の影響が顕著になりやすい。
  • Result, Optionなどもenum型なので同様。 Option::::None でも実はTのメモリサイズ分は確保されている。
  • 大きめのenum要素を定義する際は、Boxでラップする事を検討する。
  • std::mem::size_of などでメモリサイズを調べることができる。
  • 実はenum要素のサイズが大きくなると、Clippyが警告してくれたりします。

    large size difference between variants the entire enum is at least XXX bytes