🍢

【Rust】Borrow checkerとライフタイムを理解するための簡単な実験

2024/01/10に公開

はじめに

Rust初心者が必ず躓くポイントとして、真っ先に挙がってくるのがBorrow checkerだと思います。

これは私自身、当初はコンパイラの警告通りに何となく修正をする、という事を繰り返しながらやっと理解できてきたものなのですが、

・もっと手っ取り早く理解したい
・もっと根本的に理解したい

という方を対象に簡単な実験を交えながら解説してみようと思います。

Borrow checkerの挙動を観察してみる

まずは次のようなコードを書いてみます。

fn main() {
	let mut w = vec![1,2,3];
	let v1 = w.get_mut(0);
	let v2 = w.get_mut(1);

	*v1=3;
	*v2=4;
}

このような書き方をするとコンパイルエラーになります。

これは
・可変参照は同時に一つしか存在できない
というRustのルールによるものです。

しかし、

fn main() {
	let mut w:Vec<isize> = vec![1,2,3];
	
	let v1 = w.get_mut(0);
	*v1=3;
	
	let v2 = w.get_mut(1);
	*v2=4;
}

これなら通ります。

こうなると、

可変参照は同時に一つしか存在できない

てどういう事( `_ゝ´)?

と、よくわからなくなってきます。

ですので、今からこの辺りを紐解いていってみようと思います。

一つめの疑問

処理の順番を替えるだけでなぜ上手くいくのか?というところですが、
これは

fn main() {
	let mut w:Vec<isize> = vec![1,2,3];
	
	{
		let v1 = w.get_mut(0);
		*v1=3;
	}
	{
		let v2 = w.get_mut(1);
		*v2=4;
	}
}

という変換が暗黙的に行われている、と考えられます。
一つ目のスコープを抜けた時点でv1は消滅しますので、v2は唯一の可変参照という事になります。

スコープと可変参照については公式のドキュメントにも記載されており、

https://doc.rust-jp.rs/book-ja/ch04-02-references-and-borrowing.html#可変な参照

ところが、可変な参照には大きな制約が一つあります: 特定のスコープで、ある特定のデータに対しては、 一つしか可変な参照を持てないことです。

との記載があり、同一のスコープ内では一つしか可変な参照を持てないと、解釈できます。

もう一つの疑問

そしてもう一つの疑問が、
そもそも、Vecの0番目と1番目の要素への可変参照は、それぞれ別カウントではないのか?
(言い方を変えれば、Vecの0番目と1番目の要素への参照がなぜ複数の可変参照として扱われるのか?)
というところです。

もう一つ簡単な例を挙げます。

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

	*b=2;
	*c=3;
}

この例であれば、b,cともにaに対する可変参照ですので、

可変参照は同時に一つしか存在できない

という事が完全に理解できると思います。

では、問題のこのコード

fn main() {
	let mut w:Vec<isize> = vec![1,2,3];
	
	let v1 = w.get_mut(0);
	let v2 = w.get_mut(1);

	*v1=3;
	*v2=4;
}

これがなぜ複数の可変参照として扱われるのか、というと、
これはVecの要素に対しての可変参照ではなく、Vec本体に対しての可変参照として扱われる、という事だと思います。

この事をより理解するための例を挙げてみます。

struct BorrowTest {
    	a: isize,
	b: isize,
}

impl BorrowTest {
	fn new() -> Self {
		Self { a: 0,b:0 }
	}

	pub fn get_a_mut<'a>(&'a mut self) -> &'a mut isize {
		&mut self.a
	}

	pub fn get_b_mut<'a>(&'a mut self) -> &'a mut isize {
		&mut self.b
	}
}
fn main(){
	let mut bt = BorrowTest::new();

	let v1 = bt.get_a_mut();
	let v2 = bt.get_b_mut();

	*v1=3;
	*v2=4;
}

この例の場合、BorrowTestのインスタンスであるmut btから、二つの&mutを取得していますが、これも同じようにエラーになります。

つまり、可変参照というのは元のオブジェクトに遡ってカウントされる、という事が示されています。

この事から、

可変参照は同時に一つしか存在できない

というのは、表現的に微妙というか、

・可変参照は派生させてはならない
とか
・可変参照は直列的に扱う必要がある

という表現の方がより理解しやすい気がします。
(結果的には同時に一つしか存在できない事になるのだけど)

fn main(){
	let mut bt = BorrowTest::new();
	let bt2 = &mut bt;
	let v = bt2.get_a_mut();
	*v=3;
}

この場合だと、bt2とvの二つの可変参照があるように見えます。
が、実際のところは
bt -> bt2 -> v
と直列的に繋がっているだけなので、派生はしていません。

これが、

fn main(){
	let mut bt = BorrowTest::new();

	let bt2=&mut bt;
	let v1 = bt2.get_a_mut();
	let v2 = bt2.get_a_mut();
	*v2=3;
	*v1=4;
}

とか

fn main(){
	let mut bt = BorrowTest::new();

	let bt2 = &mut bt;
	let v = bt2.get_a_mut();

	bt2.b=5;
	*v = 6;
}

というようにmutが派生して並列的に使用される状態を作るとエラーになります。

また

fn main(){
	let mut bt = BorrowTest::new();

	let v = bt.get_a_mut();
	bt.b=5;
	*v = 6;
}

のように、参照ではなく実体と可変参照の両方に並列で値を変更しようとする場合も同様に怒られます。

なぜこのような制約があるのか?

この機構こそが、Rustがメモリ安全性を謳う根拠になっているからです。

この制約を破るコードというのはRustでは普通には書けないので表現がなかなか難しいところではありますが、

例えば、

fn main(){
	let mut bt = BorrowTest::new();
	
	let hoge=Hoge::new(&bt.a);
	
	let v = bt2.get_a_mut();
	*v = 6;
}

このようにBorrowTestのメンバ変数aの参照を別のstructに渡したとします。
この場合、Hogeは.aが後で変更されるという事は想定していません。
よって、安全性が低いコードと見なされ、コンパイルエラーになるようにRustは設計されています。

ポインタを用いるC言語などでは容易にこのようなコードが書けますが、人間ではその潜在的なリスクを容易に検知する事ができません。それを肩代わりするのがRustのBorrow checkerという事になります。

ライフタイムとの関係

Borrow checkerとライフタイムは密接に関係しています。

Borrowは直訳すると「借りる」ですが、Rustでは「参照」を「借用」と呼ぶようです。
ここまで「可変参照」という用語を使ってきましたが、可変借用と可変参照は同じ意味と捉えて問題は無いと思われます。

このBorrow checkerですが、結論から言うと変数間のライフタイムを断絶してやれば迂回する事が出来ます。

struct BorrowTest {
    	a: isize,
	b: isize,
}

impl BorrowTest {
	fn new() -> Self {
		Self { a: 0,b:0 }
	}

	pub fn get_a_mut<'a>(&mut self) -> &'a mut isize {
		unsafe { &mut *(&mut self.a as *mut isize) }
	}

	pub fn get_b_mut<'a>(&mut self) -> &'a mut isize {
		unsafe { &mut *(&mut self.b as *mut isize) }
	}
}
fn main(){
	let mut bt = BorrowTest::new();

	let v1 = bt.get_a_mut();
	let v2 = bt.get_b_mut();

	*v1=3;
	*v2=4;
}

このコードでは、Borrow chekerを欺き、コンパイルが通ります。

注目すべきは

fn get_a_mut<'a>(&mut self) -> &'a mut isize

となっていて、ライフタイムがselfには付いていない事です。

ライフタイムを明示的に指定しない場合、
暗黙的に

fn get_a_mut<'a>(&'a mut self) -> &'a mut isize

となります。
これはselfとメンバ変数の寿命が同じという意味になります。

が、敢えて強制的に

fn get_a_mut<'a>(&mut self) -> &'a mut isize

とする事によって、selfとメンバ変数はライフタイム的に無関係になるわけです。

ただし、

	pub fn get_a_mut<'a>(&mut self) -> &'a mut isize {
		&mut self.a
	}

という書き方は出来ません。
selfとself.aの寿命が実際は同じという事は自明です。

それを回避するために、一旦生ポインタに変換した後に、参照に戻しています。
生ポインタになった時点でライフタイムは断絶されます。

ライフタイムが断絶されているとBorrow checkerに検知されないという事象から、Rustコンパイラはライフタイムの繋がりを辿り、複数の借用の有無をチェックしている、と推測する事ができます。

Borrow checkerとライフタイムの関係については公式ドキュメントや様々な記事で紹介されているとは思いますが、実際にライフタイムを断絶した場合の挙動と合わせて確認する事でより理解が深まると考え、この記事を書いてみました。

当記事が誰かの役に立てれば幸いです。

Discussion