swiftでstateパターン(enum)
有限の状態を内包するときには、振る舞いをenumで列挙するタイプのstateパターンが良いと思っている。
swiftは一工夫しないとまともに書けないので、私の今のところのベストコードをメモっている。
環境
- xcode 9.2
- swift4
問題
swiftのenumは要素ごとに振る舞いを定義できない
普通にenumを使って処理を分岐すると、メソッド毎に毎回switch文を使うハメになる。
今回は、振る舞い用のクラスを別に作り以上することでswitch文を一箇所に集中させることができた。
swiftのenumにはrawValueという値の対応付ができるが、独自クラスには対応していない
正確に言うと、RawRepresentableプロトコルを実装したクラスであればRawValueに指定できる。
しかし、結局そちらのクラスでSwitchi分を書くだけで無駄なので使えない。
コード
class Mario: CustomStringConvertible {
var description: String {
return "\(state)マリオ"
}
var state: State = .normal {
willSet { print("\(self) → ", terminator: "") }
didSet { print("\(self)") }
}
var isDead = false {
didSet {
if isDead {
print("\(self)は死んだ")
}
}
}
deinit {
print("\(self)は開放された…")
}
func onHit() {
print("\(self)は敵に当たった")
state.onHit(context: self)
}
}
extension Mario {
enum State: CustomStringConvertible {
case normal
case small
var rawValue: MarioStateRawValue {
switch self {
case .normal:
return Normal.shared
case .small:
return Small.shared
}
}
var description: String {
return rawValue.description
}
func onHit(context: Mario) {
rawValue.onHit(context: context)
}
}
}
protocol MarioStateRawValue: CustomStringConvertible {
func onHit(context: Mario)
}
extension Mario.State {
enum Normal: MarioStateRawValue {
case shared
var description: String {
return "スーパー"
}
func onHit(context: Mario) {
context.state = .small
}
}
}
extension Mario.State {
enum Small: MarioStateRawValue {
case shared
var description: String {
return "ちび"
}
func onHit(context: Mario) {
context.isDead = true
}
}
}
let mario = Mario()
while !mario.isDead {
mario.onHit()
}
↓
スーパーマリオは敵に当たった
スーパーマリオ → ちびマリオ
ちびマリオは敵に当たった
ちびマリオは死んだ
ちびマリオは開放された…
コードを読むに当たっての周辺知識
CustomStringConvertible
var description: String { get }
を(多分)定義している組み込みのプロトコル。
コイツを実装すると文字列展開にそのまま突っ込んだときの文字列表現を指定できるよ。
extension Xxxx { class Yyyy { ... } }
私が好んで使っている、SwiftでのNameSpace表現。公式機能ではない。
実はタダのNestedClassだが、extensionを用いると別ファイルから定義可能なので、好んで使っている。
少し残念な点は、privateが使えないことと、ファイル名はユニークにしないとxcodeが起こるのでファイル名をフルネームにしなくてはいけないところ。
enum Xxxx { case shared }
単なるenumを用いたsingletonパターン。StateのEnumとは関係ない。
知ってる人は少ない気がする。なのでなれるまでは逆に読みにくい気もする。
プロトコル名がフルネーム
本当に本当に残念なことにswiftはNestedProtocolを未だサポートしてくれない。
試したけどダメだったこと
AssociatedValueを用いてcontextを渡す
こんなのをやってみた
class Mario {
var state: State! // 相互参照するときはImplicitlyUnwrappedOptionalにするよ
init() {
self.state = .normal(context: self)
}
enum State {
case normal(context: Mario)
func onHit() {
switch self {
case .normal(let context):
// 処理 context.state = .small(context: context)
break
}
}
}
}
weak var leakCheck: Mario?
do {
let mario = Mario()
leakCheck = mario
}
assert(leakCheck == nil, "メモリリーク発生")
メモリリーク発生である。
MarioはStateを参照し、StateはContextとしてMarioを参照するので、循環参照が発生してメモリリークも発生する。
通常、このようなケースは、StateはMarioをunowned参照するべきだが、残念なことにenumのAssociatedValuesには不可能のようだ。
おとなしく、メソッドの引数でcontextを渡すしかなさそう。