KeyPathとCasePathsを丁寧に解説してみる
モチベーション
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とは
ある型のプロパティの値ににアクセスする参照(パス)です。
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の表現方法
では具体的にどう扱うのかここで紹介していきます。
ほとんどこちらの公式ドキュメントを参考にしています。
以下、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.
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マクロがどんなコードを生成し、どんな処理してくれているのか気になったので調べてみました。
マクロについての説明は割愛しますが、ざっくり把握するのに以下記事が参考になると思います。
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")
まとめ
理解が深まってよかった。
Discussion