👌

KeyPathとCasePathsを丁寧に解説してみる

2024/09/09に公開

モチベーション

enum UserAction {
  case home(String)
  case settings(Int)
}

UserActionというenumがあったときに、[UserAction]からUserAction.homeのindexが欲しいケースがあるとします。
そのときに以下のように実装してました。

let actions: [UserAction] = [.home("home"), .settings(1)]
guard let index = actions.firstIndex(where: {
    if case .home = $0 { return true }
    return false
}) else {
    return
}

そしたら、CasePathsライブラリを使えば以下のようにすっきりシンプルに書けるとレビューでアドバイスをもらい、学ぼうとしたのがこの記事のきっかけになります。

guard let index = actions.firstIndex(where: {
    $0[case: \.home] != nil
}) else {
    return
}

また、CasePathsを理解するには必ずKeyPathも必要になるので、順を追って説明していこうと思います。

KeyPathとは

ある型のプロパティの値ににアクセスする参照(パス)です。
https://developer.apple.com/documentation/swift/keypath

Key,valueの関係になっているようなものを型安全にvalueの値を取得できるわけです。

KeyPathの歴史的背景

初期のプロパティアクセス (Objective-C)

Swiftが登場する以前、Objective-CのKVC (Key-Value Coding)がプロパティへの間接的なアクセスをサポートしていました。KVCでは文字列でプロパティ名を指定して値にアクセスできましたが、文字列ベースであるため、間違ったプロパティ名を指定してもコンパイル時にはエラーが発生せず、実行時にクラッシュする危険性がありました。

Swiftの登場と型安全性

Swiftは型安全性を強く重視しており、プロパティアクセスにも型安全なアプローチが求められました。KVCのような動的アクセスではなく、静的に型を保証できるメカニズムが必要だったため、Key Pathの導入が検討されました。

Key Pathの導入 (Swift 4)

Swift 4でKey Pathが導入され、文字列ベースのアクセスの代わりに、型安全でコンパイル時にチェックされるKey Pathが提供されました。これにより、プロパティに安全にアクセスし、かつコードのリファクタリングや変更にも強くなりました。

KeyPathの種類

Key Path 種類 特徴 アクセスの種類 型消去 適用対象 用途
AnyKeyPath 完全に型消去されたKey Path。具体的な型情報は保持しない。 読み取りのみ 完全消去 構造体/クラス 型情報が不要な場合、またはKey Path自体を操作するために使用。
PartialKeyPath 型が部分的に消去された読み取り専用Key Path。プロパティの型が不明な場合でもアクセス可能。 読み取りのみ 部分的 構造体/クラス プロパティの型が未知のときに使われる。
KeyPath 読み取り専用のKey Path。型のプロパティにアクセス可能。 読み取りのみ なし 構造体/クラス プロパティの値を安全に読み取る場合に使用。
WritableKeyPath 読み書き可能なKey Path。構造体やクラスのプロパティに対して値を変更できる。 読み取り・書き込み なし 構造体/クラス プロパティの値を変更する必要がある場合に使用。
ReferenceWritableKeyPath クラス専用の読み書き可能なKey Path。参照型のプロパティに対して読み書きが可能。 読み取り・書き込み なし クラス クラスのプロパティに対して読み書き可能な場合に使用。

順に表の上のものを継承していっています。

KeyPathの表現方法

では具体的にどう扱うのかここで紹介していきます。

ほとんどこちらの公式ドキュメントを参考にしています。
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/expressions/#Key-Path-Expression

以下、key pathを使って値を取得する方法になります。

struct SomeStructure {
    var someValue: Int
}

let s = SomeStructure(someValue: 12)
let pathToProperty = \SomeStructure.someValue
let value = s[keyPath: pathToProperty]
// value is 12

型指定していれば  \SomeClass.somePropertyの代わりに\.somePropertyと表現することもできます。

サプスクリプトとしてもkey pathが使えます。

let greetings = ["hello", "hola", "bonjour", "안녕"]
let myGreeting = greetings[keyPath: \[String].[1]]
// myGreeting is 'hola'

Key pathをうまく使うことでネストされた値にアクセスすることができます

let interestingNumbers = ["prime": [2, 3, 5, 7, 11, 13, 17],
                          "triangular": [1, 3, 6, 10, 15, 21, 28],
                          "hexagonal": [1, 6, 15, 28, 45, 66, 91]]
print(interestingNumbers[keyPath: \[String: [Int]].["prime"]] as Any)
// Prints "Optional([2, 3, 5, 7, 11, 13, 17])"
print(interestingNumbers[keyPath: \[String: [Int]].["prime"]![0]])
// Prints "2"
print(interestingNumbers[keyPath: \[String: [Int]].["hexagonal"]!.count])
// Prints "7"
print(interestingNumbers[keyPath: \[String: [Int]].["hexagonal"]!.count.bitWidth]) // bitWidthは2進数
// Prints "64"

mapの中でもKeyPathは使えます。

struct Task {
    var description: String
    var completed: Bool
}
var toDoList = [
    Task(description: "Practice ping-pong.", completed: false),
    Task(description: "Buy a pirate costume.", completed: true),
    Task(description: "Visit Boston in the Fall.", completed: false),
]

// 同じ結果になります。$0がない分少しシンプルに見える
let descriptions = toDoList.filter(\.completed).map(\.description)
let descriptions2 = toDoList.filter { $0.completed }.map { $0.description }

KeyPathの使い所

KeyPahtの表現方法が分かったところで、具体的な使い道をこちらのPerson構造体を使って考えてみます。

class Person {
    var name: String
    var age: Int

    init(name: String, age: Int) {
        self.name = name
        self.age = age
    }
}

let people = [
    Person(name: "Alice", age: 30),
    Person(name: "Bob", age: 24),
    Person(name: "Charlie", age: 28)
]

KeyPathを使った汎用例

上記のような構造体とその配列があったとして、その構造体のプロパティを取得したいケースを考えてみます。
その場合、extractPropertyのようにKeyPathを引数にすることで汎用的な関数として扱うことができます。

// 汎用的にプロパティを抽出する関数
func extractProperty<T, U>(from array: [T], using keyPath: KeyPath<T, U>) -> [U] {
    return array.map { $0[keyPath: keyPath] }
}

// 名前リストを抽出
let names = extractProperty(from: people, using: \.name)
print(names) // ["Alice", "Bob", "Charlie"]

// 年齢リストを抽出
let ages = extractProperty(from: people, using: \.age)
print(ages) // [30, 24, 28]

KeyPathを使わない例

もしKeyPathを使わなかったら以下のような実装になると思います。

// 名前リストを抽出する関数
func extractNames(from array: [Person]) -> [String] {
    return array.map { $0.name }
}

// 年齢リストを抽出する関数
func extractAges(from array: [Person]) -> [Int] {
    return array.map { $0.age }
}

let namesWithoutKeyPath = extractNames(from: people)
print(namesWithoutKeyPath) // ["Alice", "Bob", "Charlie"]

let agesWithoutKeyPath = extractAges(from: people)
print(agesWithoutKeyPath) // [30, 24, 28]

KeyPathを使った方が汎用性があって良さそうです。

KeyPathの特徴

ここまでKeyPathを説明してきましたが、ここで整理したいと思います。

KeyPathのメリット

  • 簡潔に表現できること
  • 再利用できること

KeyPathのデメリット

  • enumで使えない

なんとはenumでKeyPathありません。

enum UserAction {
  case home(String)
  case settings(Int)
}

\UserAction.home  //  key path cannot refer to static member 'home'

またUserAction.homeの値が欲しければif case .homeなどの処理が必要になります。つまり冒頭で僕が書いた実装になります。
そこで出てくるのがCasePathsライブラリなわけです。

CasePathsについて

Case paths extends the key path hierarchy to enum cases.

https://github.com/pointfreeco/swift-case-paths

Case pathsは列挙型のケースのKey pathの階層構造を拡張してくれるものになります。
TCAなどを開発しているpointfreeで開発されているものになります。

KeyPathではenumを扱えませんでしたが、CasePathsライブラリを使うことで使えるようになります。

@CasePathable
enum UserAction {
  case home(String)
  case settings(Int)
}

\UserAction.Cases.home      // CaseKeyPath<UserAction, String>
\UserAction.Cases.settings  // CaseKeyPath<UserAction, Int>

ほぼほぼKeyPathと同じように扱えるので便利なわけです。

CasePathsの表現

ここではCasePathsの表現とKeyPathとの比較をしていきます。
ここを見れば使い方が分かるようになるはずです。

user[keyPath: \User.name] = "Blob"
user[keyPath: \.name]  // "Blob"

userAction[case: \UserAction.Cases.home] = "home"
userAction[case: \.home]  // Optional("home")

以下のような使い方もできます。

let userActionToHome = \UserAction.Cases.home
userAction.is(\.home)      // true
userAction.is(\.settings)  // false

let actions: [UserAction] = []
let homeActionsCount = actions.count(where: { $0.is(\.home) })

modifyを使ってResultのSuccessの値を変更することもできるます。(これいつ使うん?🤔)

var result = Result<String, Error>.success("Blob")
result.modify(\.success) {
  $0 += ", Jr."
}
result  // Result.success("Blob, Jr.")

Key pathと同様にpathのappendもできます。

let highScoreToUser = \HighScore.user
let userToName = \User.name
let highScoreToUserName = highScoreToUser.append(path: userToName)
// WritableKeyPath<HighScore, String>

let appActionToUser = \AppAction.Cases.user
let userActionToHome = \UserAction.Cases.home
let appActionToHome = appActionToUser.append(path: userActionToHome)
// CaseKeyPath<AppAction, HomeAction>

Swift5.2からkey pathを直接mapなどにも渡せるようになったものはcase pathでも同じように使えます。

let userActions: [UserAction] = [.home(.onAppear), .settings(.purchaseButtonTapped)]
userActions.compactMap(\.home)  // [HomeAction.onAppear]

@dynamicMemberLookupマクロを使えば、ドット記法で動的にアクセスできます。

@CasePathable
@dynamicMemberLookup
enum UserAction {
  case home(HomeAction)
  case settings(SettingsAction)
}

let userAction: UserAction = .home(.onAppear)
userAction.home      // Optional(HomeAction.onAppear)
userAction.settings  // nil

@CasePathableの仕組み

さて、ここまでは公式ドキュメントの内容を紹介してきましたが、ここからはもう少し深ぼっていきたいと考えています。

@CasePathableマクロを使えばenumでもKeyPathのような使い方ができるようになりましたが、@CasePathableマクロがどんなコードを生成し、どんな処理してくれているのか気になったので調べてみました。

マクロについての説明は割愛しますが、ざっくり把握するのに以下記事が参考になると思います。
https://zenn.dev/iceman/articles/4613ef478c9691

CasePathableマクロで生成されるコード

マクロで生成されるコードはXcode(マクロ部分で右クリック -> Expand Macro)で表示できます。
以下、コメントで囲った部分がマクロで生成されたコードになります。

caseごとにボイラープレートが生成されており、embedはset,extractはgetの役割をしています。
ただし、これだけではいまいち全容が分かりません。とくにextension UserAction: CasePaths.CasePathableではサブスクリプトの処理がなされているんですが、そこも知りたいですね。
ということで、CasePathableマクロを使わずにenumでKeyPathが使えるように手元で実装して理解を深めていこうと思います。

@CasePathable
enum UserAction {
  case home(String)
  case settings(Int)
    // Macroで生成された実装
    public struct AllCasePaths {
        public var home: CasePaths.AnyCasePath<UserAction, String> {
            CasePaths.AnyCasePath<UserAction, String>(
                embed: UserAction.home,
                extract: {
                    guard case let .home(v0) = $0 else {
                        return nil
                    }
                    return v0
                }
            )
        }
        public var settings: CasePaths.AnyCasePath<UserAction, Int> {
            CasePaths.AnyCasePath<UserAction, Int>(
                embed: UserAction.settings,
                extract: {
                    guard case let .settings(v0) = $0 else {
                        return nil
                    }
                    return v0
                }
            )
        }
    }
    public static var allCasePaths: AllCasePaths { AllCasePaths() }
    // Macroで生成された実装
}

// Macroで生成された実装
extension UserAction: CasePaths.CasePathable {
}
// Macroで生成された実装

CasePathableを自力で実装してみる

ここでの目標はCasePathableマクロを使わずに、KeyPathを使ってenumの値を取得することです。
さきほどのUserActionと同じ構造でUserActionManualがあるとします。

enum UserActionManual {
  case home(String)
  case settings(Int)
}

当たり前ですが、今のままで以下のようにUserActionManualの値を取得しようとしたらエラーがでますね。これを解決していきます。

let manualAction: UserActionManual = .home("manual home")
let manualUserActionHomeValue = manualAction[case: \.home] // コンパイルエラー

ではその実装になります。コード上にコメントを記載していきます。

// emebed, extractを持った構造体を用意する。CathPathsのAnyCasePathを参考にしている
struct AnyCasePathhManual<Enum, Value> {
    let embed: (Value) -> Enum
    let extract: (Enum) -> Value?
}

enum UserActionManual {
  case home(String)
  case settings(Int)
    // Macroで生成された実装とほぼ同じ。CasePaths.AnyCasePathをAnyCasePathManualに変えただけ
    public struct AllCasePaths {
        public var home: AnyCasePathManual<UserActionManual, String> {
            AnyCasePathManual<UserActionManual, String>(
                embed: UserActionManual.home,
                extract: {
                    guard case let .home(v0) = $0 else {
                        return nil
                    }
                    return v0
                }
            )
        }
        public var settings: AnyCasePathManual<UserActionManual, Int> {
            AnyCasePathManual<UserActionManual, Int>(
                embed: UserActionManual.settings,
                extract: {
                    guard case let .settings(v0) = $0 else {
                        return nil
                    }
                    return v0
                }
            )
        }
    }
    public static var allCasePaths: AllCasePaths { AllCasePaths() }
    // ここまでMacroで生成された実装とほぼ同じ

    // 新しく追加したサブスクリプト。\.pathで取得できる
    subscript<Value>(case keyPath: KeyPath<AllCasePaths, AnyCasePathManual<UserActionManual, Value>>) -> Value
        let casePath = UserActionManual.allCasePaths[keyPath: keyPath] // keyPathからcasePathを取得
        return casePath.extract(self) // casePathから自身の値を返す
    }
}

これで値が取得できるようになりました👏

let manualAction: UserActionManual = .home("manual home")
let manualUserActionHomeValue = manualAction[case: \.home] // Optional("manual home")

まとめ

理解が深まってよかった。

GitHubで編集を提案

Discussion