Swiftの値型中心によるStateパターン実装
Swiftでは、値型( 構造体 (struct) ) を中心としたプログラミングが推奨されており、殆どの標準ライブラリも構造体で実装されています。
実際、値型中心に開発することで、Value Semanticsによる不具合の削減や性能面で大きな効果を上げることが出来るのですが、いざ、複雑な実装を値型中心のまま進めようとすると、オブジェクト指向開発では存在しなかった煩わしさ(特にStateパターンの実装)に気づくかと思います。
私も挫折してclassベースに戻った経験があります。
StateパターンとStorategyパターンの違い
Stateパターンについてのみ言及しましたが、似た存在であるStorategyパターンは、値型中心であってもほぼ従来のハードルで実現できます。これらの違いについて説明します。
StateパターンとStorategyパターンは、クラス図では全く同じ記述になります。
ところが、シーケンス図で表現すると、大きく異なることが分かります。
Storategyパターンでは、Client側がどのStorategyを用いるかを把握し且つ、一度設定したStorategyを変更するケースがほぼ無いのに対し、StateパターンではClient側がどのStateを持っているか把握していない可能性があるのと、Contextの生成後、Stateが変更される前提で考慮しておく必要があります。
この差が、実装の際大きなハードルとなって立ちはだかります。
値型中心にるStorategyパターンの実装
まずは、値型中心によるStorategyパターンの実装例を見ていきます。
他のオブジェクト指向言語における依存性注入部分で、総称型など曖昧な部分について型の具象化が要るなど若干の違いがあるものの、使い勝手に大きな差はないと思います。
protocol SomeStorategy {
func action()
}
struct StorategyA: SomeStorategy {
func action() {
print("A")
}
}
struct StorategyB: SomeStorategy {
func action() {
print("B")
}
}
struct Context<S: SomeStorategy> {
private let storategy: S
init(storategy: S) {
self.storategy = storategy
}
func doProcess() {
self.storategy.action()
}
}
// Aをprint
Context(storategy: StorategyA()).doProcess()
// Bをprint
Context(storategy: StorategyB()).doProcess()
Steteパターンにおける問題点
Storategyパターンと全く同じ方法で実装した場合、Stateを変更する箇所でコンパイルエラーとなります。
protocol SomeState {
func behave()
}
struct StateA: SomeState {
func behave() {
print("A")
}
}
struct StateB: SomeState {
func behave() {
print("B")
}
}
struct Context<S: SomeState> {
var state: S
init(initialState: S) {
self.state = initialState
}
func doProcess() {
self.state.behave()
}
}
var context = Context(initialState: StateA())
context.doProcess() // Aをprint
context.state = StateB() // ここでコンパイルエラー
context.doProcess()
型推論を使用せずに記述すれば、エラーとなる理由が分かりやすいかと思います。
var context: Context<StateA> = Context(initialState: StateA())
context.state = StateB()
コンパイルエラーを解消するには、ContextのstateプロパティをassociateTypeではなく、protocolを型として扱う存在型(Existential Type)で定義する必要があります。
struct Context {
var state: any SomeState
init(initialState: any SomeState) {
self.state = initialState
}
func doProcess() {
self.state.behave()
}
}
このExistential Typeですが、「Heart of Swift」の「Existential Type と Existential Container」で記述されているように、予期せぬ大きなオーバーヘッドとなり得るため、可能な限り避けて通りたいところです。
❌ 大きな負荷
let state: any SomeState = 何らかの構造体
⭕️ クラスベースの場合、オーバーヘッドなし
protocol SomeState: AnyObject {
func behave()
}
let state: any SomeState = 何らかのクラスのインスタンス
上記から、値型中心でStateパターンを実装する際のポイントは、Existential Typeを避けて、Stateの入れ替えによる振る舞いの変更を実現するという点になるかと思います。
振る舞いの定義に関数型を使う方法
振る舞いの定義をprotocolではなく関数型で行うことで、Contextから総称型が無くなるため、Context生成時の具象化も不要になり、且つ、Existential Typeへの変換も行う必要がなくなります。
※実践する際、Stateの表現はenumで行うことが殆どなので、以降の記述ではenumの使用前提で進めます。
struct StateBehavor {
let behave: () -> Void
init (
behave: @escaping () -> Void
) {
self.behave = behave
}
}
let BehavorA = StateBehavor {
print("A")
}
let BehavorB = StateBehavor {
print("B")
}
enum SomeState {
case StateA
case StateB
var behavor: StateBehavor {
switch self {
case .StateA:
return BehavorA
case .StateB:
return BehavorB
}
}
}
struct Context {
var state: SomeState
init(initialState: SomeState) {
self.state = initialState
}
func doProcess() {
self.state.behavor.behave()
}
}
var context: Context = Context(initialState: .StateA)
context.doProcess() // Aをprint
context.state = .StateB
context.doProcess() // Bをprint
比較的シンプルな実装で済みますが、関数型の場合、実行する関数はクロージャーとして予め具象化されるため、
- 関数の引数に総称型が指定出来ない
- 関数の返り値にOpaqueType(some型)が指定できない
という制約があります。
protocol SomeValue {
var val: String { get }
}
struct StateBehavor {
let behave: <V: SomeValue>(val: V) -> Void // エラー
init (
behave: @escaping <V: SomeValue>(val: V) -> Void // エラー
) {
self.behave = behave
}
}
protocol SomeReturn {
var val: String { get }
}
struct StateBehavor {
let behave: () -> some SomeReturn // エラー
init (
behave: @escaping () -> some SomeReturn // エラー
) {
self.behave = behave
}
}
これらは、いずれもコンパイルエラーとなります。
特に、引数に総称型を指定出来ない部分は、Existential Typeを経由せずにprotocolを引数として渡すことが出来なくなってしまうため、使い所が限定される大きな制約となります。
デリゲートパターンと併用する方法
上記、関数型による実装パターンの課題を解決するには、振る舞いの定義をprotocolに戻すことになりそうです。
protocolで抽象化された振る舞いをExistential Typeを経由せずに利用するには、総称型が定義された関数で受け取る必要があるため、
振る舞いを受け取るためのレシーバー関数を含むデリゲートprotocolを別に定義し、Context側がStateから振る舞いを受け取る際にそのデリゲートprotocolを渡すことで解決します。
protocol StateBehavor {
func behave()
}
struct BehavorA: StateBehavor {
func behave() {
print("A")
}
}
struct BehavorB: StateBehavor {
func behave() {
print("B")
}
}
protocol StateBehaviorReceiver {
func receive<B: StateBehavor>(
_ behavor: B
)
}
enum SomeState {
case StateA
case StateB
func behavor<Receiver: StateBehaviorReceiver>( _ receiver: Receiver ) {
switch self {
case .StateA:
receiver.receive(BehavorA())
return
case .StateB:
receiver.receive(BehavorB())
return
}
}
}
struct Context: StateBehaviorReceiver {
var state: SomeState
init(initialState: SomeState) {
self.state = initialState
}
func doProcess() {
self.state.behavor(self)
}
func receive<B: StateBehavor>(
_ behavor: B
) {
behavor.behave()
}
}
var context: Context = Context(initialState: .StateA)
context.doProcess() // Aをprint
context.state = .StateB
context.doProcess() // Bをprint
若干迂遠な感じがしますが、このあたりが現実的な解なのかなと…
StateBehavorを拡張して、総称型で定義された引数を受け取る関数を追加することも問題なく行えます。
protocol SomeValue {
var val: String { get }
}
protocol SomeReturn {
var val: String { get }
}
protocol StateBehavor {
associatedtype R: SomeReturn
func behave()
func behave2<V: SomeValue>( val: V ) -> R
}
最後に
SwiftUIのViewのように、resultBuilderを使ってどうにか簡略化出来ないか考えてみましたが、私には無理でした。( switch文を省略出来ないことも無いのですが、コンパイル時にエラーチェックが出来なかったり。) 良い方法があれば教えて頂けると幸いです。