Rustのすごいところを、ゲーム開発を例として紹介します。
対象読者:
- C++ 中級者レベル
- Rust 初心者
- Rust に興味がある人
ある開発現場での会話
デバッグ担当「アプリがクラッシュすることがあるみたいです」
エンジニア「え、どういう時に落ちるの?」
デバッグ担当「デバッグでは再現しません。ユーザーさんの声を見てると、○○バトルの時に落ちやすいみたいですね」
エンジニア「うーん、再現方法がわからないと、調査のしようが無いんだよね・・」
デバッグ担当「うーん・・」
エンジニア「うーん・・」
そして時は流れていった。
C++で大規模なプログラムを書いたことのある人なら、こんな経験、ありますよね!?
こんな状況が起こった時、何かの条件で、どこかでメモリ破壊が起こっている、とは想像できるのですが、どこで、どうやってそれが起こるのか、調べるのはすごく大変です。
なぜメモリ破壊が起こるのか。それは、あるメモリ領域が、いつどこから変更されるのかを、誰も把握できないからだ。(チームで開発するならなおさら!)
それなら、メモリの変更をコンパイラが把握できるようにしよう、というのが、Rustです。
メモリが危険なプログラムをC++とRustで書いてみる
例として、RPGなどで良くある、あるキャラが他のキャラを召喚するという処理を書いてみます。
C++
まずはC++で書いてみましょう。
#pragma once
class GameObjectManager;
//ゲームキャラなどの各種オブジェクトを表現するクラス
class GameObject {
public:
GameObject();
//一定時間毎に呼ばれる更新処理
void Update(GameObjectManager* manager);
//召喚できるかを判定する
bool CanSummon();
};
#include "game_object.hpp"
#include "game_object_manager.hpp"
GameObject::GameObject()
{
}
void GameObject::Update(GameObjectManager* manager)
{
//召喚可能だったら、マネージャに召喚を依頼
if(CanSummon()) {
manager->SummonObject();
}
}
bool GameObject::CanSummon() {
//召喚可能かを判定するロジック
return true;
}
#pragma once
#include "game_object.hpp"
#include <vector>
//ゲーム内に存在するGameObjectを管理するクラス
class GameObjectManager {
private:
std::vector<GameObject> _game_objects;
public:
GameObjectManager();
//一定時間毎に呼ばれる更新処理
void Update();
//キャラを召喚する
void SummonObject();
};
#include "game_object_manager.hpp"
GameObjectManager::GameObjectManager()
{
}
void GameObjectManager::Update() {
//全てのオブジェクトをUpdate
for(GameObject& obj : _game_objects) {
obj.Update(this);
}
}
void GameObjectManager::SummonObject() {
//新しいオブジェクトを生成し、vectorに追加
_game_objects.emplace_back(GameObject());
}
オブジェクト指向で素直に書くと、こんな感じになるでしょうか。あるキャラ(GameObject)が、召喚可能と判断すると、マネージャークラスを通じてキャラを召喚、_game_objectsに追加する。完璧ですね!
しかしこのコード、落とし穴があるのです。実際に動かしてみると、キャラが一定数増えた後、誰かが召喚をすると、しばらくしてプログラムがクラッシュしたりするのです。でも、必ずこうすればクラッシュする、と決まっていないのです。
一体この謎のエラーの原因はなんでしょうか?処理の流れを追ってみましょう。
GameObjectManager::Update()が呼ばれる
_game_objectsそれぞれについて
{
GameObject::Update()を呼ぶ
一定の条件で、GameObjectManager::SummonObject()が呼ばれる
_game_objectsに新しいGameObjectを追加する。
}
よく見ると、_game_objectsのループ中に、_game_objectsに追加していることがわかります。
std::vectorは可変長の配列で、メモリの許す限り、データを追加することができます。
vector内部では、一定数のメモリ領域を予約していますが、その予約した領域を越えるデータを
追加しようとした時、新たにメモリを確保しなおし、内部のデータを全部そこに移動するんですね。
もし、GameObjectManager::SummonObject()の時に、メモリの再配置が行われた場合、
GameObjectManager::Update()で使っていたイテレータは、もう無効なアドレスを指すことになってしまいます。
無効になったイテレータの参照先が、別の処理によって確保された、違う用途のメモリ領域になることもあります。そうするとメモリ破壊が起こり、何かのきっかけでプログラムがクラッシュします。
問題は、このような潜在的な問題に、ある程度経験のあるプログラマでなければ、気づけないということです。
Rustで書いてみる
さて、上記のようなコードをRustで書いてみると、どうなるでしょうか。
本稿の読者の方は、Rust初心者だと思いますので、コード一つ一つに解説をつけています。
//Rustには class はなく structのみ
struct GameObject {
}
//GameObjectの実装
impl GameObject {
//selfはC++で言うthisのこと
//&は参照を意味する
//mutはミュータブル(変更可能)であることを意味する
//GameObjectのupdateは自身の状態を変化させるので、&mut self とする
//さらに、自身を管理するGameObjectManagerに対して、何か変更を加える可能性があるので、&mut GameObjectManager となる
fn update(&mut self, manager: &mut GameObjectManager) {
if self.can_summon() {
manager.summon_object();
}
}
//召喚可能かを返す関数
//selfの中身を変更することはないので、mutは付けない
fn can_summon(&self) -> bool {
//selfを使って何か状態をチェック
true //セミコロンを付けずに書くと return true; の意味になる
}
}
struct GameObjectManager {
//VecはC++で言うstd::vector
game_objects: Vec<GameObject>
}
impl GameObjectManager {
//自身の持つ変数に変更を加えるので、&mut self
fn update(&mut self) {
//各GameObjectのミュータブルな参照を取ってupdateを呼ぶ
//C++と違い、参照を得る際にも & が必要
for o in &mut self.game_objects {
o.update(self);
}
}
//召喚
//game_objectsに変更を加えるので&mut self
fn summon_object(&mut self) {
self.game_objects.push(GameObject{});
}
}
mutという見慣れないキーワードができました。
mutとはmutableのことで、簡単に言うと「これからこのオブジェクトを変更するよ、という意味です。C++のconstの逆みたいなものか、と思うかも知れませんが、そうでもありません。
詳しくは割愛しますが、ここで理解してもらいたいのは、 Rustでは、オブジェクトのミュータビリティ(変更を加える事)をコンパイラが理解して、危険な変更はできないようにチェックしてくれる 、ということです。
このプログラムをコンパイルしてみると、以下のようなエラーが出力されました。
error[E0499]: cannot borrow `*self` as mutable more than once at a time
--> src/main.rs:27:22
|
26 | for o in &mut self.game_objects {
| ----------------------
| |
| first mutable borrow occurs here
| first borrow used here, in later iteration of loop
27 | o.update(self);
| ^^^^ second mutable borrow occurs here
error: aborting due to previous error
For more information about this error, try `rustc --explain E0499`.
順番に見ていきましょう。
error[E0499]: cannot borrow `*self` as mutable more than once at a time
翻訳すると、自身のミュータブル参照を一度に2回以上取得することはできません。
となるでしょうか。(borrowとは、&を使って参照を得ることです)
そして、エラーの詳細が出ています。(とってもわかりやすい!)
26 | for o in &mut self.game_objects {
| ----------------------
| |
| first mutable borrow occurs here
まずここで、最初のミュータブル参照が起こっている、とのことです。自身のメンバ変数へのミュータブル参照を得ることは、自身へのミュータブル参照を得ることにもなるんですね。
そして、
27 | o.update(self);
| ^^^^ second mutable borrow occurs here
ここが2回目のミュータブル参照だと。
GameObject::updateは、GameObjectManagerへのミュータブル参照を取っていますからね。
Rustでは、参照に関して、以下のどちらかの状態であることをチェックします。
- ミュータブル参照が、1つだけある状態。この場合はイミュータブル(mutの付かない)参照も含めて、他の参照が1つでもあってはならない。
- イミュータブル参照が、複数ある状態(ミュータブル参照はなし)
この条件に合わない参照がなされていた場合は、コンパイルエラーとなるんですね。
もう少しわかりやすく言うと、
- 誰かが書き込んでいる時は、他の人は一切触っちゃだめ。見てもだめ。
- 見るだけなら、複数同時に見るのはOK
こんな感じでしょうか。
この制限を課すことで、メモリの安全性を保証している訳です。
さて、コンパイルが通りません。困りましたね。
要は、GameObjectが、自身を管理するGameObjectManagerに変更を加える、という設計がそもそもダメな様です。実際、前述のC++の方では、この設計をしたことで、メモリ破壊が起きてしまいました。
そう、C++ではランタイムエラーとなるところを、Rustではコンパイルエラーにしてくれるのです!
ランタイムエラーとコンパイルエラー、バグの調査がどちらが楽かは、明らかですよね!
Rustすごい!
これが、筆者がRustをお勧めする理由の一つです。
Rustを勉強していると、C++ってどうしてこんなにも、なんでもありなんだ!と思う様になります。
まるで、信号も車線もない道路の様。怖くて走れません。
Rustを実際に業務で使うことがなくても、Rustの考え方を学ぶことで、C++や他のプログラミング言語を書くときも、メモリ安全性を考慮して書けるようになります。
この機会に、Rustを勉強してみてはいかがでしょうか?