[Swift]Value-type なモデルを使った UNDO の実装(その2: Value-type で モデルを作成)

Value-type を意識して、モデルを作っていきます。

環境&対象

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

  • macOS Big Sur 11.2.2
  • Xcode 12.4
  • iOS 14.4

value-type で作るモデル

WWDC のビデオとサンプルを確認して、value-type のモデルを使うことで、UNDO/REDO の実装がシンプルになりそうなことが見えてきたので、実際に 作っていきます。

value-type ??

Swift の基本型である Int や Double だけでなく、Array 等も すでに value-semantics の型になっています。つまり、代入時にコピーされるということです。

Swift で使える全ての型が value-type ではないので、モデルを作る時に、気をつけなくてはいけません。

例えば、NSArray は reference-semantics な型です。

value-type でモデルを作る(TODOItem)

例によって、TODO アプリを作る予定なので、以下のような struct を要素 TODOItem として定義します。

TODOItem

struct TODOItem: Hashable, Equatable {
    enum Priority: Int, Equatable, CaseIterable, Identifiable, CustomStringConvertible {
        case lowestPriority = 0, lowerPriority, middlePriority, higherPriorily, highestPriority
        var id: Priority { self }
        var description: String {
            switch self {
            case .lowestPriority:
                return "Lowerst"
            case .lowerPriority:
                return "Lower"
            case .middlePriority:
                return "Middle"
            case .higherPriorily:
                return "Higher"
            case .highestPriority:
                return "Highest"
            }
        }
    }
    var id: UUID
    var title: String
    var priority: Priority

    init(_ title: String, _ priority: TODOItem.Priority) {
        self.id = UUID()
        self.title = title
        self.priority = priority
    }
}

TODOItem としては、「タイトル」と「優先度」を持ちます。内部管理用として、id を持っています。

いずれも struct な型であり、value-type です。

モデルを作る(TODOItems)

アプリでは、TODOItem を複数もつ予定なので、TODOItem を複数持つ struct を定義します。

example

struct TODOItems {
    var itemsDic: [UUID: TODOItem]

    init(_ itemsDic: [UUID: TODOItem] = Dictionary()) {
        self.itemsDic = itemsDic
    }
}

TODOItem を そのまま配列 でもつのではなく、[UUID : TODOITem] という Dictionary 型で保持することにしてみます。

配列で持つ方法も考えられますが、以下を考慮し、Dictionary にしています。

value-type の モデルで気をつけるべき点 と Dictionary を採用した理由

Value 型を配列で持つときに、気をつけるべき点があります。それは、

「配列の中の要素を変更したい時に、配列から要素を取り出してしまうと 取り出した瞬間にコピーになってしまう。つまり、以降の変更は取り出した要素の変更となり、配列中の要素の変更にはならない」

ということです。

value 型は、受け渡しの時にコピーが行われ、意図しない変更が行われないという点は良い点でもあるのですが、変更を意図するときには、コピーが発生しているという点に気をつけなければいけません。

reference 型の変更

var targetItem = items[index]
targetItem.title = newTitle

つまり、上記のようなコードでは、コピーした targetItem を変更しているのであり、items[index] にある要素については、変更されないということです。

配列中の要素を変更したい時には、以下のようにしないと変更されません。

value 型の変更

items[index].title = newTitle

つまり 配列に保存されている要素を変更する時には、改めて、配列中の index を算出し、その index を使用して変更を行うことが必要となります。

このことは、該当要素の index を探す回数が増えることにつながると考え、index 検索のコストを安くするという目的で、Dictionary を採用しました。

# Dictionary は key を使っての value 取得は、(Dictionary のもつ) 要素数に影響されず、固定時間で取得できるという特徴を持ちます。

# 対象が 100要素程度では、いずれにしても誤差かもしれません・・・

ここまでで TODOItems は、TODOItem 含め すべて value-type で構成されています。

以降では、テストを作りながら、CRUD の API 詳細を決めていきます。CRUD 以外のAPIは 必要となった時に 追加していく方針です。

テスト設計/実装

TODOItems をテストするということで、TODOItemsTests というクラスを作って テストを記述していきます。
作るテストは以下です。

  • TODOItems モデルの作成をテスト(作成でき、要素数 0 であること)
  • TODOItems から [TODOItem] を取得する(配列として取得できること)
  • TODOItems から TODOItem を取得する(id 指定で、TODOItem を取得できること)
  • TODOItem を追加する(追加でき、保存されている TODOItem が作成時に指定したものと一致すること)
  • TODOItem を削除する(削除できること)

TODOItem の取得は、追加・削除のテストで使われるので、暗にテストされるかたちになります。

CRUD それぞれについて、以下のような メソッドを想定してます

  • 配列で取得 → items:[TODOItem]
  • UUID を使って取得 → item(withID:)
  • TODOItem を追加 → add(_ TODOItem)
  • TODOItem を削除 → remove(_ TODOItem)

以下が、TODOItems モデル作成と TODOItem 追加のテストコードです。

TODOItemsTests

final class TODOItemsTests: XCTestCase {
    func test_modelInitialize_initialize_shouldBeSuccess() throws {
        // (1) TODOItems を作成
        let sut = TODOItems()
        // (2) 作成できていることをテスト
        XCTAssertNotNil(sut)
        // (3) 作成直後は要素数が0であることをテスト
        XCTAssertEqual(sut.items.count, 0)
    }

    func test_createItem_fromScratch_shouldBeCreated() throws {
        // (1) TODOItems を作成
        var sut = try XCTUnwrap(TODOItems())
        XCTAssertEqual(sut.items.count, 0)

        // (2) TODOItem を作成
        let newItem1 = TODOItem("title1", .lowestPriority)
        // (3) TODOItem を TODOItems に追加
        sut.add(newItem1)
        // (4) TODOItems の持つ TODOItem の総数が1となったことをテスト
        XCTAssertEqual(sut.items.count, 1)

        // (2') TODOItem を作成
        let newItem2 = TODOItem("title2", .higherPriorily)
        // (3') TODOItem を TODOItems に追加
        sut.add(newItem2)
        // (4') TODOItems の持つ TODOItem の総数が2となったことをテスト
        XCTAssertEqual(sut.items.count, 2)

        // (5) TODOItems から id 指定で要素を取得
        let retrievedItem1 = try XCTUnwrap(sut.item(withID: newItem1.id))
        // (6) TODOModels から取得した TODOItem の中身が一致することを確認
        XCTAssertEqual(retrievedItem1, newItem1)
        // (5') TODOItems から id 指定で要素を取得
        let retrievedItem2 = try XCTUnwrap(sut.item(withID: newItem2.id))
        // (6') TODOModels から取得した TODOItem の中身が一致することを確認
        XCTAssertEqual(retrievedItem2, newItem2)
    }
}

まだ add や items, item(withID:) を実装していないので、コンパイルエラーになってしまいますが、テストコードは上記のような感じです。

# 実装中に困った時には、修正していきます。

モデル実装

TODOItems に items, add, item(withID:) を以下のように実装しました。

TODOItems

struct TODOItems {
    var itemsDic: [UUID: TODOItem]
    // var tags: [UUID: Tag]

    init(_ itemsDic: [UUID: TODOItem] = Dictionary()) {
        self.itemsDic = itemsDic
    }

    var items: [TODOItem] {
        itemsDic.map { $0.value }
    }

    func item(withID id: UUID) -> TODOItem? {
        itemsDic[id]
    }

    mutating func add(_ item: TODOItem) {
        itemsDic[item.id] = item
    }
}

上記実装で、テスト結果が緑になります。

MEMO
テストをパスしたタイミングで、テストコード中の重複コードをまとめたくなりますが、まとめるのは少し大変です。

モデルが value-type で構成されているので 関数の引数で受け渡しすると作られたコピーが渡されてしまうため、渡した先での変更となり、テストできなくなってしまいます。

テスト設計/実装(続)

以下は、remove のテストコードです。

test_removeItem_addThenRemove_shouldBecomeZeroItems

    func test_removeItem_addThenRemove_shouldBecomeZeroItems() throws {
        // (1) TODOItems を作成
        var sut = try XCTUnwrap(TODOItems())
        // (2) TODOItem を追加
        let newItem1 = TODOItem("title1", .lowestPriority)
        sut.add(newItem1)
        XCTAssertEqual(sut.items.count, 1)
        // (3) 追加した TODOItem を削除
        sut.remove(newItem1)
        // (4) TODOItems から削除できたことを確認
        XCTAssertEqual(sut.items.count, 0)
    }

モデル実装(続)

remove を実装します。

example

struct TODOItems {
    var itemsDic: [UUID: TODOItem]
    // var tags: [UUID: Tag]

    init(_ itemsDic: [UUID: TODOItem] = Dictionary()/*, _ tags: [UUID: Tag] = Dictionary() */) {
        self.itemsDic = itemsDic
        // self.tags = tags
    }

    var items: [TODOItem] {
        itemsDic.map { $0.value }
    }

    func item(withID id: UUID) -> TODOItem? {
        itemsDic[id]
    }

    mutating func add(_ item: TODOItem) {
        itemsDic[item.id] = item
    }
    mutating func remove(_ item: TODOItem) {
        // (1) id が一致する最初の要素の index を探し
        guard let index = itemsDic.firstIndex(where: { key, _ -> Bool in
            key == item.id
        }) else { return }
        // (2) itemsDic から削除
        itemsDic.remove(at: index)
    }
}

上記の実装で、先ほど追加した remove のテストもパスします。

まとめ:value-type でモデルを作る時のポイント

value-type でモデルを作る時のポイント
  • モデルを構成する要素に reference-type を入れ込まないように気をつける
  • 受け渡しするとコピーが発生していることに気をつける。

次回は、ViewModel を作成し、UNDO/REDO ができる ベースを作ります。

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

コメントを残す

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