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を勉強してみてはいかがでしょうか?

广告
将在 10 秒后关闭
bannerAds