107
82

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Rustのすごいところを実例を用いて紹介します

Last updated at Posted at 2019-08-30

Rustのすごいところを、ゲーム開発を例として紹介します。

対象読者:

  • C++ 中級者レベル
  • Rust 初心者
  • Rust に興味がある人

ある開発現場での会話

デバッグ担当「アプリがクラッシュすることがあるみたいです」
エンジニア「え、どういう時に落ちるの?」
デバッグ担当「デバッグでは再現しません。ユーザーさんの声を見てると、○○バトルの時に落ちやすいみたいですね」
エンジニア「うーん、再現方法がわからないと、調査のしようが無いんだよね・・」
デバッグ担当「うーん・・」
エンジニア「うーん・・」

そして時は流れていった。

C++で大規模なプログラムを書いたことのある人なら、こんな経験、ありますよね!?
こんな状況が起こった時、何かの条件で、どこかでメモリ破壊が起こっている、とは想像できるのですが、どこで、どうやってそれが起こるのか、調べるのはすごく大変です。

なぜメモリ破壊が起こるのか。それは、あるメモリ領域が、いつどこから変更されるのかを、誰も把握できないからだ。(チームで開発するならなおさら!)

それなら、メモリの変更をコンパイラが把握できるようにしよう、というのが、Rustです。

メモリが危険なプログラムをC++とRustで書いてみる

例として、RPGなどで良くある、あるキャラが他のキャラを召喚するという処理を書いてみます。

C++

まずはC++で書いてみましょう。

game_object.hpp

#pragma once

class GameObjectManager;

//ゲームキャラなどの各種オブジェクトを表現するクラス
class GameObject {
public:

	GameObject();

	//一定時間毎に呼ばれる更新処理
    void Update(GameObjectManager* manager);

	//召喚できるかを判定する
	bool CanSummon();
};
game_object.cpp
#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;
}
game_object_managar.hpp
#pragma once

#include "game_object.hpp"
#include <vector>

//ゲーム内に存在するGameObjectを管理するクラス
class GameObjectManager {
private:
    std::vector<GameObject> _game_objects;
public:

	GameObjectManager();

	//一定時間毎に呼ばれる更新処理
    void Update();

    //キャラを召喚する
    void SummonObject();

};
game_object_managar.cpp
#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を勉強してみてはいかがでしょうか?

107
82
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
107
82

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?