はじめに
Stateパターンのアンチパターンをいくつか考えたので紹介します.
Stateパターンとは
GoFのデザパタのあれです.
ここでは解説しません.
本題
他Stateへの依存
状態遷移を行う際,Stateクラス内部から次のStateを指定する必要があります.
しかし肝心のStateはどこから持ってくれば良いのでしょうか?
Singleton
Stateパターンについて調べるとよく出てくるのが,StateクラスをSingleton
にしてしまう方法です.
この方法は,Stateクラスが内部状態を持つ場合,複数のContext
が利用できなくなってしまいます.
複数のContext
は,ゲームプログラミングで例を考えるとモンスターやプレイヤーなどのStateでしょうか.
また,後述しますがStateクラスが他のStateクラスにアクセスするのはあまりよろしくありません.
新規生成
Singleton
のデメリットである,複数のContext
で利用できない,が解決出来ます.
しかし,遷移ごとにインスタンス化されるというのは,パフォーマンス上あまりよくなさそうです.
同一Context
内ならオブジェクトを再利用したいところです.
さらに,新規生成するStateクラスの依存クラスに自分自身も依存してしまうことになります.
DI
だったらContext
から次のStateを受け取ればいいのではないでしょうか?
これでContext
内でStateクラスがSingleton
のように振る舞うようになり,再利用が可能になりました.
また不要な依存クラスができてしまう問題も解決できそうです.
ただし,Stateがループする場合,コンストラクタインジェクションが不可能になるため,工夫が必要です.
これで解決なのか?
Singleton
のところで少し触れましたが,実は上記の方法ではもう一つ問題が残っています.
複雑な依存関係
遷移時にContext
へStateインスタンスを伝えるということは,そのクラスに依存していることになります.
つまり,遷移=依存になります.
遷移先として指定しているだけとは言え,依存関係が滅茶苦茶な立派なスパゲッティ状態です.
ちなみに,抽象StateをDIすればいいんじゃないの?
と思う方もいるかもしれません.
確かに依存関係はクリーンになります.
しかしその場合,State遷移の裁量権がContext
に移ることになります.
Context
がStateを注入するためです.Stateパターンとは?
(遷移先が動的に入れ替わる,みたいなことをしたいならありかも)
あと,遷移先をIStateA
のように抽象化する方法も思いつきましたが,そもそも他のStateクラスを操作できる時点でダメです.
解決方法
ここまでの方法で問題の根本的な原因は,Stateの種類とStateクラスそのものが一緒になっていることです.
StateA -> StateB
という遷移を考えましょう.StateAはContext
に,StateBに遷移することを伝えたいのですが,このときStateAにとってStateBは,StateB
という種類(Type)が欲しいのであり,StateBそのものはどうだっていいんです.
つまりどうすればいいのか.
StateTypeとしてStateの型をEnum
にしてしまいましょう.
これで全てうまくいきます(多分).
ちなみにStateTypeとStateインスタンスの紐づけはContextImpl
が行います.
しかし,これを聞いて1つ疑問が生まれるかもしれません.
いやいや...結局State遷移の裁量権はContextが持ってんじゃないの?
そうかもしれません.
でも,StateType.Aを伝えてるのにStateBに遷移するっておかしな話じゃないですか?
この場合はStateクラス自身が遷移先を明示しているので,いいんじゃないかなと思います.
各Stateの振る舞い
Stateパターンの本命と言える部分です.
しかし,ここに大きなアンチパターンを発見しました.
ここでは,ゲームを例に考えていきます.
話にならない実装例
Stateパターンと検索すると,このような実装例が出てきがちです.
class Player {
private var currentState: IState
fun Damage() {
currentState.onDamage()
}
}
interface IState {
fun onDamage()
}
class StateNormal : IState {
override fun onDamage() {
println("いてて..")
}
}
class StateInvincible : IState {
override fun onDamage() {
println("無敵だもん!")
}
}
「PlayerクラスはIStateのonDamageを呼ぶだけで場合分けをしなくていいね!」
「ポリモーフィズムだね!」
...
Stateパターン実装してみれば分かります.実際のところ,
- プレイヤーのHP
- 何の攻撃を受けたか
- ダメージの演出
- 無敵時間の処理
など,沢山の要素への依存があり,一筋縄ではいかないです.
命令的に書くな
Stateパターンと言えば、何らかのイベント(onDamageなど)に対して,Context
のメソッドを呼ぶ実装方法をよく見かけます.
しかしこれは,アンチパターンになり得ると思っています.
まずやりがちな実装を見てみましょう.先程の例をもう少し実践的にして考えます.
- NormalState,FixedStateではダメージを受けるとHPが減り,数秒間無敵状態になる
- FixedStateではノックバックはしない
- InvincibleStateではHPは減らないがノックバックだけする
これらを満たすStateクラスたちを実装してみます.
interface PlayerContext {
var hp: Int
var mp: Int
var invincibleTime: Double
var velocity: Vector
fun changeStateTo(next: StateType)
}
class StateNormal(private val context: PlayerContext) : IState {
fun onDamage() {
// 体力を減らす
context.hp -= 10
// ノックバック
context.velocity = Vector(1.0, 1.0, 1.0)
// 無敵時間
context.invincibleTime = 10.0
context.changeStateTo(StateType.Invincible)
}
}
class StateFixed(private val context: PlayerContext) : IState {
fun onDamage() {
// 体力を減らす
context.hp -= 10
// 無敵時間
context.invincibleTime = 10.0
context.changeStateTo(StateType.Invincible)
}
}
class StateInvincible(private val context: PlayerContext) : IState {
fun onDamage() {
// ノックバック
context.velocity = Vector(1.0, 1.0, 1.0)
}
}
一見すると,いい感じのStateパターンになってるように思えます.
(PlayerContextが全然カプセル化されてないですが)
しかし,Stateクラスの振る舞いは宣言的であるべきとぼくは考えています.
すなわち,Contextに対して複雑な処理をすべきではないということです.
責任が増える
上の例でStateクラスの責務をよく考えてみましょう.
ダメージを受けたときに「HPが減るか」「無敵状態になるか」「ノックバックするか」
これらの宣言だけをさせたかったんです.
しかしながら,「HPの減少量」「無敵時間の長さ」「ノックバック力」など
Stateクラスにとってどうでもいいことまで把握してしまっています.
より実践的なものなら,引数から「ダメージ量」「攻撃方向」などの情報を受け取って,「プレイヤーの装備」「防御力」からHP減少量の計算を行ったりするかもしれません.
これらはStateクラスが果たすべき責務ではありませんよね.
宣言的に
今度は,Stateクラスが宣言的になるよう実装してみます.
interface IState {
val knockBackOnDamage: Boolean
val isDamageable: Boolean
}
class StateNormal() : IState {
override val knockBackOnDamage: Boolean = true
override val isDamageable: Boolean = true
}
class StateFixed() : IState {
override val knockBackOnDamage: Boolean = false
override val isDamageable: Boolean = true
}
class StateInvincible() : IState {
override val knockBackOnDamage: Boolean = true
override val isDamageable: Boolean = false
}
Stateを利用する側は次のようになります.
class Player {
private var state: IState
private var hp: Int
private var velocity: Vector
fun damage() {
if (state.isDamageable) {
hp -= 10
}
if (state.knockBackOnDamage) {
velocity = Vector(1.0, 1.0, 1.0)
}
}
}
これで,Stateクラスの振る舞いを宣言的にすることができました.
また,「Stateごとにダメージ量やらノックバック力を変えたい」
となった場合でも,Stateの振る舞いに「ダメージ倍率」なり「ダメージ計算メソッド」なりを付け加えてやればOKです.
最後に
本質はStateパターンじゃない
ぼくはこの記事でStateパターンはこうだ!といいたいわけではありません.
ぼくの中でのオブジェクト指向に基づき,Stateパターンを考えたらこうなった,というのが趣旨となります.
ですので,オブジェクト指向の考え方を参考にしていただければなと思います.
Twitterフォローしてください
普段こういう系のツイートしてるので,興味のある方はフォローお願いします.
誰も絡んでくれなくて悲しいです.
参考