[Swift][SwiftUI] actor を使って、LifeGame を作る(その1: actor でモデルを作る)

     
LifeGame を作っていく過程で、SwiftUI / Swift を理解していきます。特に、actor の使い方にフォーカスしていきます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Ventura 13.2 Beta
  • Xcode 14.2

全てを作成してから記事を書いていないので、途中でバージョンアップがあるかもしれません。

LifeGame

LifeGame とは以下のようなルールのもと 世代を進めていき、進化を眺める(?)ゲームです。

・セルは、「生きている」「死んでいる」の2つの状態のいずれかを持つ。
・セルは、時間が進むにつれて、「死んだり」「生きたり」する
・セルは、周囲の8つのセルの状態に応じて、「死んだり」「生きたり」する
・セルの誕生死滅ルール
・生きているセルの周りに、2つ もしくは 3つの生きたセルがあると、そのセルは 次の世代でも生きている
・生きているセルの周りに、1つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代で死滅する
・死んでいるセルの周りにちょうど3つの生きたセルがあると、そのセルに次の世代で誕生する
・死んでいるセルの周りに、2つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代でも死滅したまま

# あるセルの周りのセルとは、周囲8個のセルのことです。
詳細は、Wikipediaで。

設計概要

LifeGame を実装していくのに、おおよその方針を決めておきます。

・MVVM(Model-View-ViewModel) をベースのアーキテクチャとする
・Model は、actor を使う
・TDD(Test Driven Development) ですすめたいので、最初にテストコードを書く

そのほかにも決め事が必要かもしれませんが、おいおい(?) 決めていきます。

MVVM の責務分割

MVVM ならおおよそ自明かもしれませんが、あらためて 役割を明記していきます。

Model :
– ボード全体および各セルの状態管理
– 次世代へ移行させるための計算及び状態の保持

ViewModel:
– View へ表示する情報を提供する
– Model の情報が更新された時には、View へ提供する情報も更新する
– View からの操作リクエストに対応する
– 操作リクエストによっては、Model に適切な依頼を行う

View:
– ViewModel の情報を表示する
– ユーザー操作を受け取り ViewModel へ渡す

MVVM を構成するクラス

ざっくりですが、以下のような構成を予想(?) してます。

# prefix に LifeGame を省略した LG を付与してます。

Model:
  actor LGBoard
ボードの持つセルを管理し、進化するときの処理も行います。
進化途中に、並行的にセルの状態を取得されると不整合が発生しそうなので、actor を使って実装します。
  class LGCell
セルの状態を管理し、進化時に必要となる次世代の状態も管理します。

ViewModel: class LGViewModel
1つの View しかないので、 1つの ViewModel とします。
LGBoard への参照を持ち、View に必要な情報を提供します。

View:
LGAppView: LifeGame アプリの rootView です。
LGBoardView: (セルを含め)ボードを表示する View です
LGOperationView: 操作用ボタンを持つ View です。

LGAppView には、1つのLGBoardView 1つのLGOperationView が含まれる予定です。

Model を作る

最初に宣言した通り、テストから書いていきます。

LGCell

まずは、1つ1つのセルを表現する LGCell を作ります。最初に テストを書きます。

Iteration1:initializer

まずは、状態指定で初期化することができるかを確認します。

その後、初期状態が指定した状態であること、次世代状態が、(何も設定していないと) nil であることを確認します。

final class LGCell_Tests: XCTestCase {
    func test_cell_initialize_alive() async throws {
        let sut = LGCell(state: .alive)
        XCTAssertEqual(sut.state, .alive)
        XCTAssertEqual(sut.nextState, nil)
    }
    func test_cell_initialize_dead() async throws {
        let sut = LGCell(state: .dead)
        XCTAssertEqual(sut.state, .dead)
        XCTAssertEqual(sut.nextState, nil)
    }
}

状態 を Bool 型で扱うこともできますが、enum として用意しています。

enum LGCellState {
    case alive, dead
}

LGCell についての実装は、以下のようにしてみました。

class LGCell {
    var state: LGCellState
    var nextState: LGCellState?

    init(state: LGCellState) {
        self.state = state
        self.nextState = nil
    }

特に、難しいことはしていませんが、テストはパスします。

更なる機能追加はあとにすることにして、ボードのテスト/実装に移ります。

LGBoard

ボードを作る必要があると思うので、ボードを初期化するコードのテストから書いていきます。

ボードは、LGBoard という名前の actor として作ることを予定して テストを作っていきます。

Iteration1: initializer

まずは、初期化できること(返り値が nil になっていないこと)をテストします。

ボードの大きさを引数で渡して初期化できると、使いやすい気(?)がするので、そのような initializer にしています。

import XCTest

final class LGBoard_Tests: XCTestCase {
    func test_board_initialize() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        XCTAssertNotNil(sut)
    }
}
MEMO

おおよそのクラス構成だけを決めて、詳細なAPI については設計せずに進めていますが、テストを先に書くことで、使いにくい API になってしまうことを避けることができるハズです。あまりアピールされていませんが、TDD で書くことのメリットの1つだと思います。
(色々な意味で)テストを書きにくいコードは使いにくいコードです。

ということで、次は、LGBoard のコードを書きます。

actor LGBoard {
    let numX: Int
    let numY: Int
    init(numX: Int, numY: Int) {
        self.numX = numX
        self.numY = numY
    }
}

actor で作りました。actor で作ることで、並行してアクセスされたときに、不整合な状態になってしまうことを防ぐことができます。

上記のコードを追加して、テストを実行すると、成功します。

まだ、ボード情報には、X,Y の数しか持っていませんが・・・・ とりあえず、成功は成功です。

せっかくなので、numX, numY が設定されている値になっているかを確認するテストも追加してみます。
先ほどのコードを修正して以下のようなコードにしました。

    func test_board_initialize() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        XCTAssertNotNil(sut)
        XCTAssertEqual(sut.numX, 8)
        XCTAssertEqual(sut.numY, 8)
    }

簡単にテスト通過するかと思いきやエラーになります。エラーメッセージは、”Actor-isolated property ‘numX’ can not be referenced from a non-isolated autoclosure” です。

これは、LGBoard が actor として宣言されていることからくるものです。
(試しに、LGBoard を class へ変更してみるとこのエラーはなくなります。)

このエラーは、外部から actor 内に宣言されている プロパティへアクセスするときには、actor の context からしか 同期アクセスできないという性質からきています。
もともと、オブジェクト内の変更できる要素に対して並行的にアクセスされて起こる不整合は データ競合といわれ、その問題こそが、actor を使って防ぎたい問題です。

ですが、この numX, numY は、let で定義されている Int 型です。つまり 初期化時に指定された後には、変更されないということです。ですので、複数から 同時並行的にアクセスされても、不整合は発生しません。

このようなプロパティについては、nonisolated という修飾子 を付与することで、並行アクアセスされても良いということを Swift コンパイラに教えることができます。
ということで、 numX, numY には、nonisolated を付与します。

以下はそのような宣言を付与した LGBoard です。

actor LGBoard {
    nonisolated let numX: Int
    nonisolated let numY: Int
    init(numX: Int, numY: Int) {
        self.numX = numX
        self.numY = numY
    }
}

このように変更してテストを実行すると、テストをパスすることがわかります。

まずは、LGBoard を定義して、ボードのサイズを記憶することができました。

iteration2: セル状態へのアクセス

ボードのサイズを覚えることはできたので、次は 各セルの状態を保つようにしていきます。

テストから書きたいので、どのようにアクセスしたいかを考えることが必要となります。
2つ考えることが必要であることに気づきます。
・どのように初期設定するか
・各セルの値をどのように取得するか

初期設定は、セルを1つ1つ設定したいことは少ない気がします。closure を渡して、その closure が (x,y) に対して、設定したい状態を返す というような設定方法にします。

取得方法は、引数で渡された (x,y) にある LGCell の status/ nextStatusを返すことにします。
LGCell は、class で実装しているので、この class を外部に渡してしまうと変更されてしまうかも知れず、data race の原因になるかもしれません。
ですので LGCell を LGBoard の外に渡さないように気をつけた API にしています。

上記をテストするためには、以下のようなテストコードになりそうです。

    func test_board_setup() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { x,y in
            return .alive
        }

        for index in [(0,0), (7,1), (7,6)] {
            let status = await sut.status(index)
            XCTAssertEqual(status, .alive)
        }
    }

すべてを .alive と設定して、いくつかのセルで .alive になっていることを確認しています。

MEMO

let status = await sut.status(index)
XCTAssertEqual(status, .alive)
は、
XCTAssertEqual(await sut.status(index), .alive)
と書きたくなりますが、XCTAssert は、concurrency をサポートしていないので、上記のように、複数行に分けて書く必要があります。

テストをかけたので、LGBoard 側の実装をしていきます。

現時点では、テストではすべてのセルを .alive に設定していることを”知っている” ので、とりあえず、何も記録せず、問い合わせがあった時に、.alive を返しています。

actor LGBoard {
    nonisolated let numX: Int
    nonisolated let numY: Int
    init(numX: Int, numY: Int) {
        self.numX = numX
        self.numY = numY
    }

    func setup(_ closure: (Int,Int) -> LGCellState) {
        for x in 0..<numX {
            for y in 0..<numY {
                let value = closure(x,y)
                // store value into somewhere with (x,y)
            }
        }
    }

    func status(_ index: (Int,Int)) -> LGCellState {
        return .alive
    }
}

iteration3: セルの保持

これで、さきほど書いたテストは通ります。

ですが、もちろんこのままで 各セルに指定された値を捨ててしまっていて困ることになるので、指定された状態を LGCell に保持させるようにしていきます。

まずは、テストを書きます。
市松模様に設定するようにして、その通りに設定されているかをテストします。

    func test_board_setup() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { x,y in
            return ((x+y) % 2 == 0) ? .alive : .dead
        }

        for index in [(0,0), (7,1), (7,7)] {
            let status = await sut.status(index)
            XCTAssertEqual(status, .alive)
        }
        for index in [(0,1), (0,7), (7,0), (7,6)] {
            let status = await sut.status(index)
            XCTAssertEqual(status, .dead)
        }
    }

現在の実装は、どのセルに対しても .alive を返すようにしているので、テストは失敗します。(当たり前です)

このテストをパスするためには、きちんと指定値を保持する実装が必要になります。

保持する方法はいろいろありますが、以下では、Dictionary を使った実装をしています。

Dictionary の key には (x,y) の位置を使い、 value に LGCell を持っています。

なお、Dictionary の key になるためには、Hashable であることが必要であるため、x, y を保持するための struct CellIndex も作りました。

struct CellIndex: Hashable {
    let x: Int
    let y: Int
}

actor LGBoard {
    nonisolated let numX: Int
    nonisolated let numY: Int

    var cells:Dictionary<CellIndex, LGCell> = [:]
    init(numX: Int, numY: Int) {
        self.numX = numX
        self.numY = numY
    }

    func setup(_ closure: (Int,Int) -> LGCellState) {
        for x in 0..<numX {
            for y in 0..<numY {
                let state = closure(x,y)
                let index = CellIndex(x: x, y: y)
                let cell = LGCell(state: state)
                cells[index] = cell
            }
        }
    }

    func status(_ index: (Int,Int)) -> LGCellState? {
        let cellIndex = CellIndex(x: index.0, y: index.1)
        return cells[cellIndex]?.state
    }
}

このような状態を取得する API で悩ましいのが、範囲外の値を指定された時の振る舞いですが、今回の実装では、nil を返すようにしています。

そのためのテストも追加しました。
意味的には、範囲外にアクセスされても、nil を返し exception を発生しないことをテストしています。

    func test_board_setup_checkOutside() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { index in
            return ((index.x + index.y) % 2 == 0) ? .alive : .dead
        }

        let status = await sut.status((10, 9))
        XCTAssertNil(status)
    }

Refactoring

ここまでコードを書いてきて、急に必要になった要素があったりして、すこしコードが複雑化してきました。

ここで一旦 Refactoring してコードをきれいにします。

テストを書いてきているので、テストが通る限り(?) 変更しても OK です。

CellIndex を全体に適用

LGBoard の実装途中に、Dictionary の Key として使うために、CellIndex を定義しました。

せっかくなので、全体に適用し、現在、(Int,Int) で受けているところを CellIndex を使用するように変更します。

以下は、refactoring したテストコードです。

    func test_board_initialize() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        XCTAssertNotNil(sut)
        XCTAssertEqual(sut.numX, 8)
        XCTAssertEqual(sut.numY, 8)
    }
    func test_board_setup() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { index in
            return ((index.x + index.y) % 2 == 0) ? .alive : .dead
        }

        for index in [CellIndex(x: 0, y: 0), CellIndex(x: 7, y: 1), CellIndex(x: 7, y: 7)] {
            let status = await sut.status(index)
            XCTAssertEqual(status, .alive)
        }
        for index in [CellIndex(x: 0, y: 1), CellIndex(x: 0, y: 7), CellIndex(x: 7, y: 0), CellIndex(x: 7, y: 6)] {
            let status = await sut.status(index)
            XCTAssertEqual(status, .dead)
        }
    }

    func test_board_setup_checkOutside() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { index in
            return ((index.x + index.y) % 2 == 0) ? .alive : .dead
        }

        let status = await sut.status(CellIndex(x: 10, y: 9))
        XCTAssertNil(status)
    }

以下は、refactoring した LGBoard です。

actor LGBoard {
    nonisolated let numX: Int
    nonisolated let numY: Int

    var cells:Dictionary<CellIndex, LGCell> = [:]
    init(numX: Int, numY: Int) {
        self.numX = numX
        self.numY = numY
    }

    func setup(_ closure: (CellIndex) -> LGCellState) {
        for x in 0..<numX {
            for y in 0..<numY {
                let index = CellIndex(x: x, y: y)
                let state = closure(index)
                let cell = LGCell(state: state)
                cells[index] = cell
            }
        }
    }

    func status(_ index: CellIndex) -> LGCellState? {
        return cells[index]?.state
    }
}

上記のようにコードを refactoring しても、テストはパスしていますので、動作は変わっていないことがわかります。

世代を進めるための準備

LGBoard に進化する機能を追加していきます。LifeGame の世代をすすめるためのルールは以下の通りです。
・セルの誕生死滅ルール
・生きているセルの周りに、2つ もしくは 3つの生きたセルがあると、そのセルは 次の世代でも生きている
・生きているセルの周りに、1つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代で死滅する
・死んでいるセルの周りにちょうど3つの生きたセルがあると、そのセルは次の世代で誕生する
・死んでいるセルの周りに、2つ以下 もしくは 4つ以上の生きたセルがあると、そのセルは 次の世代でも死滅したまま

ですので、LGBoard に進化を実装するには、以下のような機能が必要となります。
・セルの周囲の 生きているセルの数を数える(ボード外部分は、死んでいるとして扱う)
・現在のセルの状態と周囲の生きているセルの数から、次世代の状態を決定する
・セルの状態を次世代の状態に設定する

それぞれのセルについて個別に次世代の状態を決めてしまうと、評価タイミングによって、あるセルは現在の状態、あるセルはすでに次世代の状態になっている というような混在した状況になってしまいます。
世代が混在した状態では正しく次世代に進化することができなくなってしまうため、タイミングを揃えて、一斉に次世代に遷移させることが必要となります。

次世代への遷移を2つのステップに分けて行うようにします。
・周囲の状況を確認して、次世代の状態を決定する(nextState に設定する)
・次世代の状態に遷移する(nextState を State に反映する)

周囲の生きているセルを数える

周囲の生きているセルの数を数えるメソッドを考えます。

着目しているセルの CellIndex を渡すことで、生きているセルの数を Int で受け取るようにします。

テストは以下のようになります。(これまでも使っている、格子状の board での結果をチェックします)

    func test_board_countAlive() async throws {
        let sut = LGBoard(numX: 8, numY: 8)
        await sut.setup { index in
            return ((index.x + index.y) % 2 == 0) ? .alive : .dead
        }

        let nbr11 = await sut.countAliveInNbrCells(center: CellIndex(x: 1, y: 1))
        XCTAssertEqual(nbr11, 4)
        let nbr12 = await sut.countAliveInNbrCells(center: CellIndex(x: 1, y: 2))
        XCTAssertEqual(nbr12, 4)

        let nbr00 = await sut.countAliveInNbrCells(center: CellIndex(x: 0, y: 0))
        XCTAssertEqual(nbr00, 1)
        let nbr01 = await sut.countAliveInNbrCells(center: CellIndex(x: 0, y: 1))
        XCTAssertEqual(nbr01, 3)    }

ボード周辺部のセルについても 正しく計算できているかを確認しています。

MEMO

すべてのセルについて計算が正しいか確認するのも1つの方法ですが、以下の理由で、一部のセルだけをテストしています。

計算するときには、大きくは2種類の状態が考えられます。
・周囲にセルがきちんと存在している
・周囲の一部のセルは存在しないので、存在しないセルは死亡しているセルとして扱って計算する

厳密には、周囲の一部のセルが存在しないケースは、”どのセルが存在しないのか”でそれぞれのケースが考えられますが、
上記のテストでは、周囲にセルがある/周囲の一部のセルがない という2種類のケースをカバーしています。

また、さらに厳密に考えると、異なる大きさのボードでもテストが必要なはずですが、今回は 8×8 のサイズでのテストだけをしています。

このテストをパスするために、実装していきます。

まず、周囲のセルをどのように扱うのがよいかが 最初の悩みどころです。

今回は、CellIndex に nbrIndexes:[CellIndex] という周囲のセルの CellIndex の配列を返すメソッドを作ることで、簡単に周囲のセルを取得できるようにしてみました。

struct CellIndex: Hashable {
    let x: Int
    let y: Int

    var north    : CellIndex { CellIndex(x: x-1, y: y  ) }
    var northWest: CellIndex { CellIndex(x: x-1, y: y-1) }
    var      west: CellIndex { CellIndex(x: x  , y: y-1) }
    var southWest: CellIndex { CellIndex(x: x+1, y: y-1) }
    var south    : CellIndex { CellIndex(x: x+1, y: y  ) }
    var southEast: CellIndex { CellIndex(x: x+1, y: y+1) }
    var      east: CellIndex { CellIndex(x: x  , y: y+1) }
    var northEast: CellIndex { CellIndex(x: x-1, y: y+1) }

    var nbrIndexes:[CellIndex] {
        [north, northWest, west, southWest,
         south, southEast, east, northEast]
    }
}

上下左右の CellIndex についても、north, northWest, … と 東西南北を使ったアクセスを用意して、より 読みやすくしてみています。

周囲のセルへのアクセスができるようになれば、周囲のセルの状態を確認しつつカウントすることは難しくありません。

actor LGBoard {
    // ...
    func countAliveInNbrCells(center: CellIndex) -> Int {
        var numOfAlive = 0
        for index in center.nbrIndexes {
            guard let cell = cells[index] else { continue }
            if cell.state == .alive {
                numOfAlive += 1
            }
        }
        return numOfAlive
    }
    // ...

なお、通常であれば、cells へは、 async なアクセスしかできませんが、actor 内部からのアクセスなので、countAliveInNbrCells からは sync でアクセスできています。

代わりに外部からの countAliveInNbrCells へのアクセスは、async になるのは、テストコードからもわかります。

次回以降

ここまで、プログラムっぽい(?) 実装は、皆無ですが、今回はここまでです。

次回以降で、次のような機能を実装していきます。

– ViewModel/View を実装
– 現在の状態から、次世代を計算
– 計算されている次世代へ進化
– LGBoard に進化する機能を実装

まとめ

LifeGame のモデルを actor を使って作成しました。

LifeGame のモデルを actor を使って作成
  • 並行アクセスでデータ競合が発生しそうであれば、actor を使うことを検討する
  • actor 内のプロパティは、actor の context からでないと同期的にアクセスできない
  • データ競合が発生しないとわかっているプロパティは、nonisolated 指定することで、actor context 外からでも同期的にアクセスできるよう指定できる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です