Game Programming Patterns をやる
Introduction
本書の解説の構成
- Intent: パターンが解決しようとする問題の観点から、そのパターンを簡潔に説明する
- Motivation: パターンを適用する問題例を紹介する
- Pattern: パターンの重要な部分を抽出し説明する (教科書的な説明)
- When to Use It: どのような場合に有効で、どのような場合に避けた方が良いのかを説明する
- Sample Code: 具体的な実装を解説する
- See Also: 他のパターンとの関係性を説明する
ソフトフェアアーキテクチャとは?
本書では、良いソフトウェアアーキテクチャを、「ある課題を解決しようとした時に、コードの理解・追加・修正が容易であるコード」と捉えている。このようなアーキテクチャを実現するために、コードの結合 (依存関係) をなくすこと (decoupling) が重要だと述べている。
その一方で、このような良いアーキテクチャをつくることは現実的には簡単なことではなく、コストがかかる & 本来の目的を見失いがちであることを説明している。自分は、結構良いコードを書くことにこだわりがちだが、設計の本でそれを注意してくれるのはありがたい。未来を予測するのは非常に難しく、今後の拡張性のために追加した柔軟性が現在の開発のコストになることは自分も実感している。自分の理想としては、難しいものは決定を遅延し、継続的な改善の場を設けるが重要だと思っている。継続的な場を設けるために自分はちゃんと動いていきたいし、PO/PMとコミュニケーションしたいと思っている。
さらに、良いアーキテクチャのための柔軟性は、しばしばパフォーマンスや実装速度とトレードオフになることがあることも説明している。これについては、ゲームのように常にベストな状況が何なのかを探し続けるのが大事と述べている。また、筆者は妥協点として、最初は柔軟性を重視し、後にパフォーマンスのための最適化をしていくことを提案している。
導入としては、全般的によいアーキテクチャを探求することのメリットを押し出しつつも、そのトレードオフの部分もしっかり説明していて、設計にこだわりすぎる自分への自戒となった内容だった。あとは、複雑なものを単純に解決する面白さとか楽しさとかを述べていたのもよかった。自分がエンジニアをやっている動機と近いと感じる。
Command
Commands are an object-oriented replacement for callbacks.
コマンドパターンは、コールバックのオブジェクト指向版のようなもの。処理の詳細の実行と処理の呼び出しを分離することで、多様な処理の実行を柔軟に記述することができる。
特に、複数の連続した処理を行いながら処理の巻き戻し (undo) を行いたい時などは便利。
Flyweight
同一のオブジェクトを扱う際に既存のインスタンスを再利用する
同じ値を持つデータクラスを何回もインスタンス生成するのはパフォーマンス的に良くないので、1つのインスタンスの参照を利用する考え方。不変な値を扱うときに便利。
「Enum に対応するデータは次のような Switch 文書く必要があって辛い」→「データクラスを作ろう。データが不変の場合は、1つのインスタンスの参照をなるべく使おう (毎回、インスタンス生成する必要なし) 」という流れの話になっている。
int World::getMovementCost(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return 1;
case TERRAIN_HILL: return 3;
case TERRAIN_RIVER: return 2;
// Other terrains...
}
}
bool World::isWater(int x, int y)
{
switch (tiles_[x][y])
{
case TERRAIN_GRASS: return false;
case TERRAIN_HILL: return false;
case TERRAIN_RIVER: return true;
// Other terrains...
}
}
Observer
イベントを通知者 (Subject) が 観測者 (Observer) に伝達する仕組み。コード間の結合度を下げるのに便利。
observer を配列で持たせるとメモリの動的な割り当てが必要になるので、Linked List を使った回避方法を紹介していて、なるほどとなった。
実際の実装では、observer / subject を削除する際に考慮することが多いので注意する必要があると説明している。例えば、observerを削除する際には subject が保持している参照を全て削除する必要があったり、subject を削除する際には observer に通知の監視が終わったことを知らせる必要があったり、双方向の参照が設計上必要になるケースが多い。不必要な observer (listener) が残り続けることでメモリリークが発生する「lapsed listener problem」と呼ばれる問題がよく起きていたことを取り上げながら、GCがある言語も正しく observer / subject の登録・破棄を管理する大切さを説明している。
章の最後には data binding の話も出てきて、React / Redux などと深い関係があるパターンだということを認識できた。
イベント駆動での欠点として処理の追いにくさを挙げている。イベント駆動設計全般について言えそうだけど、実際はどうやって克服している?
- アプリケーションにとって意味のあるイベントだけだと読みやすくなる
- 使いすぎはやめよう。1対1対応しているものなどは直接処理を呼べばよい
- 単方向にしか流れないイベントだとわかりやすくなりそう
- flux などは単方向かつ一箇所に集まる
Prototype
デザインパターンと言語に取り込まれたPrototype の2種類を紹介している。
デザインパターンの Prototype は、単にクラスのインスタンスを生成するのではなく、あるインスタンスを元に新しいインスタンスを生成するパターン。インスタンスの生成にコストがかかる場合などに使われる。
プログラミング言語 (Self, JavaScript) における Prototype は、特定のフィールドやメソッドを呼び出す際、自身の親を探すような特徴を持っている。この考え方は、JSONデータのようなデータモデリングの際にも、データの重複を排除するためなどに利用できる。理由についてはあんまり詳しく書かれてなかった気もするが、Prototype の挙動はユーザーに複雑性を押し付けておりコードを書くのは大変と筆者は述べている。(継承のことをいっているのであれば、クラスもそこまで変わらない気もするが...)
JSで Prototype がなくなっていったのはなぜ?
- クラスが使われるようになった
- 既存のグローバルなAPIを拡張するために使われる場合が多かったけど、使う必要がなくなったり型との相性が悪くなった
- 背景にはモジュールシステムの成熟もありそう (Prototype の頃は npm すらなかった)
Singleton
シングルトンの定義
Ensure a class has one instance, and provide a global point of access to it.
シングルトンはグローバル値だと述べていて、グローバル値の悪いところは次の3つだと述べている。
- コードを理解しにくくする
- コード間の結合度が上がる
- 並列処理と相性が悪く、不具合が起きやすい
グローバル変数をなるべく作らないのは、コーディングの鉄則。この章の初めから、筆者もシングルトン/グローバル値を使わないことをかなり強く推奨している。JSだと、グローバル変数にとどまらず、再代入できる let で定義される変数もなるべく扱わないようにするのもデファクトになっている。シングルトンを無くす方法としては、次の4つの方法を提案している。
- 必要な依存 (インスタンス) は関数の引数から渡すようにする
- なるべく関数に直接関係のあるものを渡すべきなのは注意する
- アプリケーションのベースとなるクラスにインスタンスを持たせる
- シングルトンを1つのクラスにまとめる (既存のクラスにまとめる)
- まとめた先のクラスの結合度が上がったりするデメリットもある
- Service Locator パターン (後で紹介する) を利用する
- グローバルアクセスを提供するだけのクラスを定義して利用する
シングルトンを適応したい場合の多くはシングルトンを用いる必要はなく、後述のSubclass Sandbox や Service Locator パターンなどを検討できると紹介している。
自分も今までの経験では、シングルトンを使ったのは時間がかかるデータの取得を行う関数を複数回呼ばないようにするために使ったケースのみだけである。
あとは、Rust ではそもそも mutable なグローバル変数を言語仕様として素直に実装できないようになっていたのは面白かった。愚直に実装すると unsafe を使う必要があるので、 once_all などを使って実装するのが一般的になっている。
アプリケーション全体で共有したい値ってどんなものがある?
- CLIツールとかだと引数で受け取った設定とか
- アプリケーションだと、logger やエラーハンドリング周りとか
State
有限オートマトンは、次のような特徴を持つオートマトン (コンピュータの状態、遷移をモデル化したもの) だと説明している。
- 有限個の決まった状態のうち、ある時点では1つの状態しかをとらない
- 各状態は遷移のパターンをもち、各遷移のパターンに特定の入力が対応する
この有限オートマトンを実装する際に State パターンを利用できる。まず最初に思い浮かぶ実装方法は、次のように状態を enum で定義し、各 enum で起きる処理を switch 文で記載するもの。
enum State
{
STATE_STANDING,
STATE_JUMPING,
....
};
void Heroine::handleInput(Input input)
{
switch (state_)
{
case STATE_STANDING:
// write some operations
case STATE_JUMPING:
....
}
}
自分もこんな感じで実装するな〜と思ってコードを読んだ。(というか実際にアプリチュートリアルの実装はこうなった気がする。)しかしこのままだと、各 case 内の実装が膨らんできてコードの見通しが徐々に悪くなってくる。そこで、State パターンを利用する。State パターンでの実装は、各 State を表すクラスを作成し、そのクラス内に case 内でおこなっていた処理を実装する。(コードを見てもらう方がいい)
有限オートマトンがモデリングとして適している場合は、以下のような特徴をもつアプリケーションである。
- 内部状態によって振る舞いが変化するような実体がある
- 内部状態が比較的少数の異なる選択肢のうちの1つに厳密に分けることができる。
- 時間の経過や一連の入力に反応し、各状態に変化する
有限オートマトンをモデリングする際は、なるべく状態を独立させ複数の状態遷移図に分離することを意識するのが良いと指摘している。また状態の履歴をもつためには、状態をスタックにもつプッシュダウンオートマトンを使う必要があるとも説明している。
感想
- フロントエンドでも使えそう
- 有限オートマトンでの状態管理はいいな〜と思っているので、XState 試したい
- https://xstate.js.org/
- chakura-ui が作っている zag ってのもあるらしい
- https://github.com/chakra-ui/zag
Double Buffer
ダブルバッファが必要になるケースの紹介として、まずは Tearing について説明している。Tearing とは、グラフィックディスプレイにおいて、各ピクセルへのデータの書き込みが終わる前に、読み出しが終わってしまう問題のこと。つまり、状態の変更中にその状態が読み込まれるという問題について考えている。
ダブルバッファでは、書き込み用の状態と読み出し用の状態を2つ用意することでこの問題を解決する。
グラフィックディスプレイの例
- まずピクセルへ書き込みは書き込み用の状態に行われる
- 書き込みが完了すると、以下のどちらかの方法で状態を更新する
2.1. 書き込み用の状態を読み出し用の状態にコピーする
2.2. 書き込み用の状態と読み込み用の状態を入れ替える。 - そしてピクセルの読み出しは、常に読み出し用の状態から行う
2の操作は状態の読み書きをどちらもブロッキングする処理であるため、ポインタをswapさせるなどなるべく高速に行うようにきをつける必要がある。
感想
- ダブルバッファが解決する問題は結構いろんなところでありそう
- 本文でもお互いに影響を与え合う要素が存在する場合で利用できる例も取り上げていた
- 自分がパッと思いついたのとしてはDB操作、トランザクションによる分離レベルの話に近い気がする
Game Loop
ゲームループは、ユーザーの入力処理やハードウェアの処理速度とレンダリング処理を切り離して管理するパターン。話の内容からも、このパターンはゲームに特化した話だと感じた。
ハードウェアやOSと結構密な関係があるので、通常は1から実装することはなく、レンダリングエンジンの処理に任せるのが一般的と述べている。 Web アプリの世界でも、ユーザーの入力とレンダリングは全てブラウザが内部で処理するので、同じくあんまり考えることはなさそう。あとは、イベントループの話も共通点のある話だと感じた。ただ、ブラウザの場合はほとんどなさそうだが、ゲームだと要件によってはレンダリングエンジンを自作する必要があるかもしれない...
この章は、かなり低レイヤーの話だったのでコード実装はしない。
Unity のライフサイクル。React のライフサイクルが大したことないように見える
Update method
複数の独立した状態をまとめて保持し、一度に更新するパターン。途中のUMLのクラス図を見てもわかる通りパターンの実装はとてもシンプルで、そもそもこれはデザインパターンと呼ぶものなのか?という感じはする。 (ゲームエンジンにおいては非常に重要なパターンらしい。)
JS でゲームエンジンを実装している pixijs の update 関数。何かしらの状態を更新するリスナーを linked list で保持していて、更新の際にはイベントをディスパッチしている。
Byte code
ゲームが実装されている言語が低レベルすぎる or コンパイルが非常に遅いなどの場合に、独自の仮想マシンを実装するパターンのこと。振る舞いを独自の仮想マシンへの命令として記述する。
仮想マシンの実装と聞くとかなり大変に聞こえるが、機能を削ぎ落とせば実装が可能だと筆者は説明している。サポートする命令セット、スタックに保持する値のデータ形式など、目的に合わせて最小限の実装にするように薦めている。
本で紹介されているスタックを使った仮想マシンの実装は、以前 Rust で実装したのでイメージしやすかった。
感想
- デザパタの話の中にいきなりインタープリタとか構文木とかの話が出てきてびっくりした
- 振る舞いを実装するために、わざわざ仮想マシンを実装する発想はなかった
- ゲームエンジンとかだとスクリプト言語を組み込むこともあるらしい
- https://zenn.dev/karamawanu/scraps/2d84115b73763b
Subclass sandbox
基底となるクラスが振る舞いを定義するためのサンドボックスメソッドとユーティリティメソッドを提供し、基底クラスの継承先で実際の振る舞い実装するパターン。メソッドに Protected を付与して、定義したクラスと継承先のクラスでしかメソッドにアクセスできないようにするのが、このパターンのポイントらしい。
継承の基本的なユースケースなのでパターンと言われると少し驚いたが、本書でもパターンと言えるほどの仕組みがなく様々な実装の選択肢を考える必要があると述べている。
- ユーティリティ関数の数
- 普通に実装するとどんどん数が増え、子クラスとの結合度も高くなる
- 関数が増えてきたら、関数を別クラスに切り出してそのインスタンスを保持するようにする
- 基底クラスでのデータ取得
- コンストラクタ引数で受け取る方法だと、全ての子クラスのコンストラクタ引数を変更する必要がある
- データ取得は基底クラスの別関数に切り出し、その関数内でクラスのメンバ変数を初期化する
Rust で protected ってそもそもあるんだっけ?みたいなことを思って辿り着いた記事
覚えておきたいこと
- カプセル化について
- C++ はクラスメンバのアクセス指定子での制御。クラスの内と外のレイヤーで制御する。
- Rust はモジュール単位での制御。モジュールの内と外のレイヤーで制御する。
- 可視性の制御は、クラスとその周辺機能を開発する開発者と、それを利用する開発者の間にあるべき (クラス内の話では基本的にはない)
- Rust のモジュールシステムの構造は、ファイルシステムの構造に準拠する
- 親のディレクトリにあるモジュールから子のディレクトリにあるモジュールへのアクセスは原則できない
-
pub(super)
: 親モジュールから可視にできる -
pub(crate)
: クレート内部のみから可視にできる -
#[crate_type = "dylib"]
: シンボルテーブルには pub を付けた物だけが残る
- 継承について
- 継承の目的 = コードの再利用
- C++ は、inteface を implements することで実現。クラスの多重継承は禁止されている
- Rust は interface 相当の機能はトレイトで実現。クラス継承の機能はなく、委譲を使うことが推奨されてる
- Rust のトレイトは継承が可能
- 委譲とは
- 同じメソッドを実装するために、そのメソッドを持つ内部オブジェクトに委ねるような手法
- 引数を転送するだけのボイラープレートを書く必要があるため面倒なことも多いことからマクロが作られたりしている
- ポリモーフィズム
- 共通の振る舞いを実装し、呼び出し側からは同じように扱える
- C++はテンプレートやオーバロードを使って実現
- Rust はジェネリクスとトレイトオブジェクトを使って実現
- ジェネリクスとトレイトオブジェクト
- https://doc.rust-jp.rs/book-ja/ch17-02-trait-objects.html の説明がわかりやすい
- ジェネリクスだと
components
の配列の中身が全て同じインスタンスに制限されてしまう- 例えば Draw をトレイト境界で実装した TextButton インスタンスに制限される
- トレイトオブジェクトだと
components
の配列の中身は異なるインスタンスも許容される
// ジェネリクス
pub struct Screen<T: Draw> {
pub components: Vec<T>,
}
// トレイトオブジェクト
pub struct Screen {
pub components: Vec<Box<Draw>>,
}
可視性の制御は、クラスとその周辺機能を開発する開発者と、それを利用する開発者の間にあるべき
クラスの内 or 外とかだと、Private なメソッドをテストしたいみたいな時に不便だよなと思う。ここから、Private メソッドのテスト不要・必要みたいな話になる... 本質はそこではない気がするのに。ここらへんは、Rust はよくできていて同じモジュール内であればテストを書くことできるようになっている。
先程のカプセル化の部分で流した protected ですが、これは継承先から継承元のメンバーが見えるようにする仕組みです。
ですが、クラスの継承関係は実際のところ関心の分離のためのレイヤーとしてはあまり適切ではありません。例えばライブラリ開発者が作ったクラスをライブラリ利用者が継承することができます
Protected が推奨されない理由はここらへんにありそう
TypeObject
型を意味するインスタンス (型オブジェクト) を型付けしたいクラスに持たせることで動的な型付けを行うパターン。解決する問題は複数のオブジェクトでのデータと振る舞いの共有で、継承と同じである。
実行時に型が決まるので、クラスに関連するデータの変更による再コンパイルを回避できる。柔軟で多様な振る舞いをもつクラスを定義したいが、クラスの継承など利用した場合にコンパイル時間が長すぎるなどの問題があるときに使われる。
このパターンを使う上で重要な点は次の通り。また、基本的にクラス継承と同等のことをやっているので、多重継承のようなことはやらない方が良い。
- 型オブジェクトはカプセル化する
- 型オブジェクトは変更しない (immutableにする)
Rust だと継承ができないので委譲によってデータや振る舞いの再利用を行うが、委譲と型オブジェクトはほとんど同じパターンに見える。
PCのスペックの向上などでコードのちょっとした変更による再コンパイル時間はかなり短くなっているから、そこまで利用する必要はなくなっていそう。
Component
機能や処理を共有するときに、それぞれの処理や機能をドメインごとに集約したクラスを定義し、そのインスタンスをコンテナクラスに持たせるようにするパターン。クラスの継承などを使ってもよいが、共有したい処理や機能が増えるほど多重継承などの問題が出てくる。(例:菱形継承問題) クラスが肥大化した時に、ドメインごとにクラスを分離する用途として利用できる。
このパターンでは、コンポーネント間のやり取りの実装方法を考える必要がある。本の中では、次の2つの方法が紹介されている。
- コンテナクラスにコンポーネントがやり取りするデータなどを持たせる
- コンテナクラスの共有データの挙動がわかりにくくなる
- お互いのコンポーネントは分離できる
- コンポーネント同士が参照する
- コンポーネント間で直接やり取りをするので処理がわかりやすくなる
- お互いのコンポーネントは強く結合する
これもパターンと認識していなくても、よくやりそうなパターン。クラスがそこまで肥大になってない場合などでは、無理にこのパターンを使わなくてもよいと筆者が述べているのも良かった。
ただ、やっぱり現実問題だとお互いのコンポーネント同士がやり取りするケースがよくあるので、そのようなケースに対する明確な対処方法がないのはやっぱりかあとなってしまった。紹介されている2つの実装方法はどちらもメリデメがはっきりしているので、臨機応変に選択する必要がありそう。
Event Queue
イベントの送信元は、キューにイベントを登録する。イベントの受信側は、キューからFIFOでイベントを処理する。Observer パターンと似ているが、イベントの処理のタイミングが異なる。Observer パターンはイベンントの処理が同期的な一方で、イベントキューはイベントの処理が非同期的になる。非同期的な処理が可能なことで、イベントの送信元のスレッドをブロックしなくなったり、リクエストをまとめて処理できたりなどのメリットがある。
実装で注意しなきゃいけないポイントとしては次のような点が挙げられている。
- キューに保存する値。
- イベント or メッセージのどちらかを保存することになる
- イベントに対するリスナーの数
- 複数のリスナーが登録されている場合はフィルタリングなどが必要になるときもある
- フィードバックループ
- イベントのループが起きないように注意する必要がある
キューの実装に使われるデータ構造のリングバッファが紹介されていた。忘れていたので復習になった。
FIFOを続けていると、すぐにメモリーの端に到達し,データの追加が出来なくなってしまいます。そこで、データを追加したり取り出したりする毎に,データの列を移動させることも考えらます。しかし、それでは計算量が増加して効率的ではありません。そこで、これを防ぐために,リングバッファと言うものが考えられました。
Service Locator
公開したい操作や機能のインターファイスを定義し、そのインターフェイスをもとに具象クラス (サービス) の提供を行うパターンのこと。ロケータとは、具象クラス提供するクラスのことである。とある機能を提供する際に具象クラスに依存しない提供ができ、コードの結合度を下げることができる。
シングルトンに類似しているパターンで、グローバルに共有したい機能があるが結合度を下げておきたい場合に利用できる。またシングルトンと同様に、オブジェクトを渡すなどできる限り使わない方法を考えるとよいと書かれている。
利用する際の注意点としては、ロケータがサービスを常に保持していないことに注意する必要がある。サービスがまだロケータに渡されていない場合は、null を返す方法や処理を停止する方法などがある。
Data Locality
実行速度が有利になるようにメモリ内でのデータの持ち方を工夫するパターン。具体的には、ポインタを持つのではなく具象クラスのインスタンスを直接持たせることで、メモリへのアクセスを高速化する。
ポインタを使わないので、インタフェースを使った動的ディスパッチやポリモーフィズムなどの振る舞いの柔軟性を持たせることが難しくなる。結合度が上がりコードも複雑になりがちなので、実行速度を本当に優先する時に使うべきだと書かれている。
動的ディスパッチは遅いので、Rust でも Enum をなるべく使った方が良いよねみたいな話も見つけたんだった。動的ディスパッチは Box を使わなきゃいけなかったりで少し難しいので、このレベルで最適化することはなさそうかも。
Dirty Flag
依存関係のあるデータについて、データを要求された時に依存元が更新されている時に初めて処理を行うパターン。親データが更新されてなければ、以前に処理した値をそのまま返す。
やっていることはキャッシュに近いので、速度は改善するがメモリは消費するようになる。また、処理にかかる時間が長い場合には、どのタイミングで更新を行うかは考える必要がある。キャッシュ更新のタイミングを考えるとの同じ。
Angular で使われていると最後に書かれていたけど、React でも使われているかもなと感じる。あとで調べてみると面白いかも。
Angular ではフォームの値が変更されているかどうかを表すフラグとして利用されていた。
react hooks form でも似たような値を使っているっぽい。submit用のボタンの有効/無効化の判定などに利用する。
Object Pool
あるオブジェクトを利用する際に、毎回オブジェクトを生成するのではなく、あらかじめ再利用が可能なオブジェクトを生成&プールしておき、そこから利用する方法。オブジェクト生成の実行コストを削減でき、一度に利用できるオブジェクト数もプールされているオブジェクト数に制限されるので、使用されるメモリも制限できる。オブジェクトを頻繁に生成するような処理に使うと効果がある。
このパターンは、GCがやっているようなメモリ管理を自分達で管理する必要があるため、実装が大変になることも多い。(オブジェクトの再利用時の初期化など) また、使用されるメモリを制限できる一方で、制限しすぎると処理が止まる原因となったりもするので注意が必要である。
ゲームなどでは、メモリの使用量が予測できると嬉しい場面が結構あるらしい。このパターンもキャッシュにちかいなーと感じる。
JSでの実装での解説。ここでも linked list を使ってデータを保持している。
React でも以前は使われていたっぽい。
Spatial Partition
物体の空間的な配置に基づくデータ構造を用いることで、近くの物体の探索などを効率化するパターン。空間の分割の仕方は、格子や四分木など用途によって様々である。
このパターンは、位置情報の検索がかなりの頻度で起き、それがボトルネックになっているような場合に有効である。ゲームの攻撃の当たり判定などが良い例。
全体的な感想
具体例についてはゲームの内容しかないが、kobaさんに言われた通り、パターンが解決する問題やパターンを利用する際の注意点などは一般的に役立つ内容だった。パターンの良いところ (=解決する問題) とよくないところ (=パターンの注意点・問題点)などを両方取り上げるのがこの本の良いところ。個人的には、「Design Patterns Revisited」や「Decoupling Patterns」の章は特に馴染みのある内容が多かった。
この本を読んでいて気になる点とすれば、OOP ベースでガッツリ解説されているところ。今回はサンプルコードを Rust で書いたが、Rust は古くからの OOP とはかなり書き味が違うので、サンプルコード通りに書けないところも多かった。最近は、OOPがベースとなった言語がそこまで流行ってないこともあって、OOPじゃない場合にどう実装するのか?みたいな観点を自分で補いながら読む必要があった。