🍢

【Rust】Boxの使いどころ

2023/12/23に公開

Rustの基本的な要素であるBoxですが、よく考えてみると中々に奥深く、また、ドキュメントを読むだけでは表面的な理解にとどまってしまうため、どのような場合に使用すべきか、というところを分かりやすく掘り下げて書いてみようと思います。

Boxって何?どこで使うの?

まずは公式ドキュメントを見てみましょう。
https://doc.rust-jp.rs/rust-by-example-ja/std/box.html

Rustにおいて、すべての値はデフォルトでスタックに割り当てられます。Box<T>を作成することで、値を ボックス化 、すなわちヒープ上に割り当てることができます。ボックスとは正確にはヒープ上におかれたTの値へのスマートポインタです。

という事が書かれています。

この辺りのスタックやヒープという用語は、C/C++などのように、恒常的にメモリを直接的に操作するような言語を扱ったことが無ければピンとこないかもしれません。

ですので、まずはスタックというものについて改めて考えてみる事にします。

スタックとは?

「すべての値はデフォルトでスタックに割り当てられます。」との事ですので、それと異なる挙動となるらしいBoxというものを理解するには、そもそもスタックというのは何なのか?というところを理解する必要がありそうです。

スタックというのは、レキシカルスコープ毎に積み上げられていくメモリ領域、というイメージが近いと思います。

fn main(){
	let a=1;
	{
		let b=1;
	}
	let c=1;
}

このようなコードがあった場合に、スタックの状態は次のように変化します。

2行目(let a=1;)
a

4行目(let b=1;)
b
a

5行目(})
a

6行目(let c=1;)
c
a

5行目のスコープを抜けたときに、変数bはもはやどこからも参照されず不要となりますので、メモリからも削除されるべきです。
このように、スコープを失った場合にメモリから削除する、という事が、このスタック構造の場合は、上に積まれているものを削除するだけでよいので合理的です。

また、上記の例では書いていませんが、関数を呼び出して戻ってくるときも、{}で囲んだ場合と同じような状態になるはずです。

ヒープとは?

Boxを使うと、スタックではなくヒープ上に割り当てる、と記載されています。
ヒープとは、スタックとは関係ないところに確保される領域のことです。
具体的にどこなのかは、OSが適当に割り当ててくれるので、プログラマが気にする必要はありません。

Boxをいつ使うのか?

正直、使うケースは少ないと思います。
スタックに積めばいいものをわざわざヒープに確保する事は無駄です。

しかし、使った方が良い、というか使わなければいけないケースというのもまれにあります。

まず一つ目が、traitオブジェクトを関数の戻り値として返すような場合です。

fn hoge()->Box<dyn t>

このような場合、戻り値のtはtrait tを実装したオブジェクトとなるので、実行時にサイズが決定されます。
スタックに値を積む場合、コンパイル時にサイズが決定されている必要があります。
ところが、Boxを使うとヒープ上に確保されたメモリへの参照(=ポインタ)のサイズとなるため、コンパイル時に(スタックに積むべき)サイズが固定となります。

つまり、Boxを使うとコンパイル時にサイズが決定できる、という性質があるため、このようにtraitオブジェクトを返す用途以外にも、trait境界にSizedが指定されている引数を渡すような場合にBoxで包む事で渡す事ができるようなったりします。

よくある?のが、(ジェネリックではない)struct内にdynでtraitオブジェクトを持たせたい場合、つまりstruct内のオブジェクトを実行時に動的に変える必要があるケース、例えば

struct Hoge{
	fuga:Box<dyn Fuga>
}

というような事をしたい場合です。
Boxを使わなければ、Hoge全体がSizedではなくなってしまうので、trait境界でSizedが要求される場合に、Hogeオブジェクトを渡せない、という事態が発生します。
Boxを使えばSizedになってくれるので、そういう事態も防ぐことができます。

他に、Boxを使った方が良いケースはとして、値を生成した後にメモリアドレスが変わってほしくない場合です。
Rustではメモリアドレスを意識しないといけないケース自体が稀だと思うのですが、C/C++などで作られたライブラリの関数を呼び出す場合に、ポインタ(メモリアドレス)を渡す場合があります。

例えば、

struct Hoge{
	a:Fuga
}
impl Hoge{
	fn new()-> Hoge{
		let a=Fuga::new();
		c_func(&a as *const Fuga as *mut c_void);//ここでCのポインタに変換して渡す
		Hoge{
			a
		}
	}
}
fn main(){
	let hoge=Hoge::new();
}

このようなコードを書くと、new()を抜けた時点でaのアドレスが変わり、
c_funcが、かつてのaのアドレスを参照した時点でACCESS_VIOLATIONが発生するでしょう。

なぜこれでaのアドレスが変わってしまうのか?

これはスタックの仕組みをよく考えれば当然の事で、
Hoge::new()の中でaが一旦確保されますが、new()内でスタックに積まれた変数はnewの呼び出し元に戻った時点で一旦すべて消えます。
そして、Hogeオブジェクトがスタックに新たに積まれることになり、別のアドレスに移動させられているのです。

Boxを使えばこのような事を防ぐことができます。

struct Hoge{
	a:Box<Fuga>
}
impl Hoge{
	fn new()-> Hoge{
		let a=Box::new(Fuga::new());
		c_func(a.deref() as *const Fuga as *mut c_void);//ここでCのポインタに変換して渡す
		Hoge{
			a
		}
	}
}
fn main(){
	let hoge=Hoge::new();
}

このように書き直せば、Boxの中身はヒープ上に確保されるため、スタック内での値の移動という状況から逃れることができます。

Discussion