Rustで状態管理や相互参照をどうするか

2020-03-31

Rustの習作としてゲームを作ろうとしていて、その際に状態をどうやって管理したらいいかにつまづいている。 なんかうまい方法や決まったイディオム・知見などがあるんじゃないかと思うんだけどうまく探せてないのか、把握してないのでどうしたらいいのかを考えていた。

例えば敵がプレイヤーの位置に向かってくる、とかいった場合にプレイヤーの座標を取得する必要があるんだけどどうするか。 問題としては

  • 敵からプレイヤー情報をどうやって参照するか

に加えて、Rustでは

  • 敵の動作のためミュータブル参照を取っている状態で、プレイヤー情報の参照が取れるのか

ということがあると思う。

敵からプレイヤー情報をどうやって参照するか

例えばC++やC#ではゲーム全体を管理する GameManager みたいなクラスをシングルトンにして、そいつに問い合わせる形にでもするかと思う。 あまり行儀がよいとはいえないが、まあ望みは実現できる。

Rustだとシングルトンをグローバル変数で実現することになるのではないかと思うけど、そうなるとシングルトンは扱いにくいんじゃないかと思う。

ということで、ひとまずまっとうに更新関数に引数として渡すことにしてみる。 GameManager がゲームの情報として PlayerEnemy を保持しているとして:

pub struct GameManager {
pub player: Player,
pub enemy: Enemy,
}

pub struct Player {
pub pos: Pos,
}

pub struct Enemy {
pub pos: Pos,
}

#[derive(Clone, Copy)]
pub struct Pos {
pub x: i32,
pub y: i32,
}

GameManagerupdateEnemyupdatePlayer の参照を渡す:

impl GameManager {
pub fn update(&mut self) {
self.enemy.update(&self.player);
}
}

impl Enemy {
pub fn update(&mut self, player: &Player) {
let player_pos = &player.pos;
...
}
}

RefCellを使用する

一応上の方法で動かせるけど、敵の更新時にプレイヤーの位置だけじゃなく、 ステージ数によってとか残り時間によってとかいろいろ参照したい状態が追加・変更されたりした場合に 引数を変更するとなると呼び出し元も変更する必要があって対応が辛い。 じゃあってんで、ゲーム情報全体 &GameManager を受け渡してみる:

impl GameManager {
pub fn update(&mut self) {
self.enemy.update(self); // <= error[E0502]: cannot borrow `self.enemy` as mutable because it is also borrowed as immutable
}
}

impl Enemy {
pub fn update(&mut self, game_manager: &GameManager) {
let player_pos = &game_manager.player.pos;
...
}
}

しかしこれは self.enemy のミュータブル参照と self のイミュータブル参照が同時には取れないのでコンパイルエラーが発生する。 どう回避したらいいかというと、 RefCell を使用する。 RefCell はコンパイル時じゃなくて実行時に借用を判定するようにして、制限を緩めることができる:

use std::cell::RefCell;

pub struct GameManager {
pub player: RefCell<Player>, // <=
pub enemy: RefCell<Enemy>, // <=
}

impl GameManager {
pub fn update(&mut self) {
self.enemy.borrow_mut().update(self); // <= borrow_mut() で実行時に借用する
}
}

impl Enemy {
pub fn update(&mut self, game_manager: &GameManager) {
let player_pos = game_manager.player.borrow().pos;
...
}
}

これによって GameManager が管理する Enemy のミュータブル参照に自分を渡せるようになる。

疎結合にする(トレイト・ジェネリクスを使用する)

EnemyGameManager を渡してしまうのはガチガチの密結合でよくない。 これを回避するにはトレイトを使用する:

pub trait GameInfoHandler {
fn player_pos(&self) -> Pos;
}

GameManager で上記のトレイトを実装し:

impl GameInfoHandler for GameManager {
fn player_pos(&self) -> Pos {
self.player.borrow().pos
}
}

Enemy 側ではジェネリクスで受け取る:

impl Enemy {
pub fn update<T: GameInfoHandler>(&mut self, handler: &T) {
let player_pos = handler.player_pos();
...
}
}

こうすることで EnemyGameManager に依存しなくなり、柔軟さが増す。

自分で責任を取る

RefCell を使うのがまっとうな方法だとは思うんだけど、実行時に借用チェックが行われることになる。 大したコストではないことはわかってるんだがどうしても気になるし、毎度 borrow_mut() とかするのもメンドイし、あらゆるものを RefCell にしなきゃならなくなってしまう。 シングルスレッドだし同時にいじらないことは自分で責任持つよ! ということで unsafe に手を出してみる。

生ポインタを直接使用する

生ポインタを取得し、参照に変換することで借用チェッカから逃れることができる。 Enemy のミュータブル参照を生ポインタ経由で取得:

pub struct GameManager {
pub player: Player,
pub enemy: Enemy, // <= RefCell をやめて生で持っていた元の状態に戻す
}

impl GameManager {
pub fn update(&mut self) {
let raw_ptr = &mut self.enemy as *mut Enemy; // <= ここで &mut self.enemy を借用しているけど
let enemy: &mut Enemy = unsafe { &mut *raw_ptr };
enemy.update(self); // <= &self が使える
}
}

すると self.enemy のミュータブル参照は raw_ptr の取得時だけとなり、 self のイミュータブル参照はコンパイルエラーにならずに動かすことができる。

Enemy のミュータブル参照ではなく逆に GameManager のイミュータブル参照を生ポインタ経由させても動く:

impl GameManager {
pub fn update(&mut self) {
let raw_ptr = self as *const Self;
let handler: &Self = unsafe { &*raw_ptr };
self.enemy.update(handler);
}
}

ミュータブルは正規に借用してイミュータブルを自己責任で取得するこちらのほうが心理的には安心かもしれない。

生ポインタのキャストを毎度書くのも手間なので、 peep などという便利関数を作ってやるといいかもしれない:

pub unsafe fn peep<'a, T>(t: &T) -> &'a mut T {
&mut *(t as *const T as *mut T)
}

impl GameManager {
pub fn update(&mut self) {
let handler: &Self = unsafe { peep(self) };
...
}
}

UnsafeCell

生ポインタを直接触ってもいいんだけど、 UnsafeCell というものがある。 get メソッドでミュータブルな生ポインタが取得できる:

use std::cell::UnsafeCell;

pub struct GameManager {
pub player: Player,
pub enemy: UnsafeCell<Enemy>, // <=
}

impl GameManager {
pub fn update(&mut self) {
let enemy: &mut Enemy = unsafe { &mut *self.enemy.get() }; // <=
enemy.update(self);
}
}

やってることは生ポインタを自分で触るのと変わりはない

欲をいえば開発時は RefCell を使用して、リリース時は UnsafeCell に切り替えるようなことができればいいんだけど、実装方法が分からなかった…。

締め

unsafe といっても「安全を保証しない」というだけで、Rustではマルチスレッドで動作させる場合も安全であることを保証できない、というだけで一概に危険というわけではないのではないか。 などと自分を納得させて、とりあえず unsafe を使ってでも望みのプログラムを書けるようになるのが先決だな、と思った。

  • 実際のところ、ミュータブル1つとイミュータブル複数の借用を使用する場合にシングルスレッドで問題が起こることってあるんだろうか…?

リンク