iOS
enum
Swift
StatePattern

swiftでenumでstateパターン

swiftでstateパターン(enum)

有限の状態を内包するときには、振る舞いをenumで列挙するタイプのstateパターンが良いと思っている。
swiftは一工夫しないとまともに書けないので、私の今のところのベストコードをメモっている。

環境

  • xcode 9.2
  • swift4

問題

swiftのenumは要素ごとに振る舞いを定義できない

普通にenumを使って処理を分岐すると、メソッド毎に毎回switch文を使うハメになる。
今回は、振る舞い用のクラスを別に作り以上することでswitch文を一箇所に集中させることができた。

swiftのenumにはrawValueという値の対応付ができるが、独自クラスには対応していない

正確に言うと、RawRepresentableプロトコルを実装したクラスであればRawValueに指定できる。
しかし、結局そちらのクラスでSwitchi分を書くだけで無駄なので使えない。

コード

Mario.swift
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)
    }
}
MarioState.swift
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)
        }
    }
}
MarioStateRawValue.swift
protocol MarioStateRawValue: CustomStringConvertible {
    func onHit(context: Mario)
}
MarioStateNormal
extension Mario.State {
    enum Normal: MarioStateRawValue {
        case shared

        var description: String {
            return "スーパー"
        }

        func onHit(context: Mario) {
            context.state = .small
        }
    }
}
MarioStateSmall
extension Mario.State {
    enum Small: MarioStateRawValue {
        case shared

        var description: String {
            return "ちび"
        }

        func onHit(context: Mario) {
            context.isDead = true
        }
    }
}
Main.swift
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を渡す

こんなのをやってみた

LeakMario.swift
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を渡すしかなさそう。