Скрытая (на виду) сила KeyPath'ов
![f7c54f173b638571d81bacfb61e6ed77.png](https://habrastorage.org/getpro/habr/upload_files/f7c/54f/173/f7c54f173b638571d81bacfb61e6ed77.png)
Привет. Меня зовут Максим Черноусов, и я занимаюсь iOS-разработкой в Райфе. Я обожаю использовать и дизайнить классные API. А один из самых часто используемых строительных блоков для хороших API в Swift — это KeyPath’ы. Сегодня о них и поговорим.
KeyPath`ы сегодня используются повсеместно. Давайте узнаем, как с их помощью проектировать лучшие API.
Линзы
Но прежде чем мы перейдем к KeyPath’ам, посмотрим на их предшественников, которые,
как и многие клевые вещи в программировании, пришли к нам из функциональных языков.
Речь пойдет о линзах. Как многие знают, в функциональных языках мы не можем изменять переменные и любые значения, которые мы определяем в коде — константы. Чтобы понять, почему линзы так удобны, давайте зададим такое же ограничение для нашего кода: все переменные, которые мы объявляем, будут константами (let).
Представим, что мы пишем примитивный игровой движок.
enum Event { /* ... */ }
struct Vector {
let x: Double
let y: Double
let z: Double
}
struct Player {
let location: Vector
let camera: Vector
}
func getNewState(player: Player, event: Event) -> Player {
/.../
}
У нас есть:
Event
— событие, которое влияет на состояние (например, пользователь нажал на кнопку);Vector
— структура с тремя координатами для представления точки в пространстве, или просто вектор;Player
— состояние нашего игрока, которое содержит в себе его положение (location
) и направление камеры (camera
);getNewState(player:event:)
— функция для получения нового состояния после обработки события.
Теперь давайте реализуем нашу функцию:
enum Event {
case left
case right
/.../
}
func getNewState(player: Player, event: Event) -> Player {
switch event {
case .left:
Player(
location: Vector(
x: player.location.x — 1,
y: player.location.y,
z: player.location.z
),
camera: player.camera
)
case .right:
Player(
location: Vector(
x: player.location.x + 1,
y: player.location.y,
z: player.location.z
),
camera: player.camera
)
}
}
Получилось очень много кода для простого изменения одной переменной. И тут на сцену выходят линзы.
struct Lens {
let get: (Root) -> Value
let set: (Root, Value) -> Root
}
Линза — это, по сути, две функции. Одна — для получения переменной типа Value
из значения типа Root
, и другая — для записи этой переменной, но из-за неизменяемости данных она возвращает новое значение Root
. Теперь мы можем определить линзу для того, чтобы изменять положение нашего игрока:
let locationXLens = Lens(
get: { $0.location.x },
set: { player, value in
Player(
location: Vector(
x: value,
y: player.location.y,
z: player.location.z
),
camera: player.camera
)
}
)
func getNewState(player: Player, event: Event) -> Player {
switch event {
case .left: locationXLens.set(player, locationXLens.get(player) - 1)
case .right: locationXLens.set(player, locationXLens.get(player) + 1)
}
}
Уже лучше, но самая интересная особенность линз в том, что их можно объединять:
extension Lens {
func compose(
with other: Lens
) -> Lens {
return .init(
get: { other.get(self.get($0)) },
set: { root, value in
self.set(root, other.set(self.get(root), value))
}
)
}
}
Теперь нам не нужно писать отдельную линзу под каждую переменную, которую мы хотим получить.
let player = Player(/.../)
let locationLens = Lens(
get: { $0.location },
set: { player, location in Player(location: location, camera: player.camera) }
)
let cameraLens = Lens(
get: { $0.camera },
set: { player, camera in Player(location: player.location, camera: camera) }
)
let xLens = Lens(
get: { $0.x },
set: { vector, x in Vector(x: x, y: vector.y, z: vector.z) }
)
// Линза для получения координаты x из камеры игрока
let cameraXLens = cameraLens.compose(with: xLens)
// Линза для получения координаты x из положения игрока
let locationXLens = locationLens.compose(with: xLens)
let cameraX = cameraXLens.get(player)
let newPlayer = locationXLens.set(player, locationXLens.get(player) + 1)
Теперь, когда мы узнали про линзы, поговорим о KeyPath’ах.
KeyPaths
В языке Swift KeyPath’ы, по сути, те же линзы (но некоторые из них read-only). Они также параметризованны типами Root
и Value
и позволяют читать (и записывать) переменные типа Value
в значения типа Root
.
KeyPath’ы представлены в виде классов и образуют следующую иерархию типов:
class AnyKeyPath: Hashable {}
class PartialKeyPath: AnyKeyPath {}
class KeyPath: PartialKeyPath {}
class WritableKeyPath: KeyPath {}
class ReferenceWritableKeyPath: WritableKeyPath {}
AnyKeyPath
— базовый класс для всех KeyPath«ов. Как подсказывает название, это type-erased версия KeyPath’а. Подписан наHashable
, что позволяет нам использовать KeyPath’ы, например, в качестве ключей в словарях.PartialKeyPath
— еще одна type-erased версия, имеет тип-параметрRoot
, но не имеетValue
. При использовании такого KeyPath«а мы получим значение для нужной переменной, но оно будет иметь типAny
.KeyPath
— самый часто используемый тип. Имеет все необходимые типы-параметры. Такой KeyPath позволяет читать значенияValue
из объекта типаRoot
.WritableKeyPath
— как подсказывает название, версия KeyPath’а, которая кроме чтения, позволяет записывать значения.ReferenceWritableKeyPath
— аналогично предыдущему, только теперь мы записываем значения с reference семантикой. Обычно это свойства классов, однако если у нас в структуре есть computed property, у которой естьnonmutating set
, то KeyPath к такой переменной тоже будетReferenceWritable
.
Основной способ получить KeyPath — это KeyPath-литерал:
let intDescriptionKeyPath = \Int.description
Если компилятору известен тип Root
, мы можем опустить его в литерале:
let d: KeyPath = \.description
У каждого типа в Swift есть набор специальных сабскриптов (subscripts), которые принимают KeyPath и возвращают значение Value
.
let int = 1
let anyKeyPathValue: Any? = int[keyPath: \Int.description as AnyKeyPath]
let partialKeyPathValue: Any = int[keyPath: \.description as PartialKeyPath<_>]
let keyPathValue: String = int[keyPath: \.description as KeyPath<_, _>]
Соответственно WritableKeyPath
и ReferenceWritableKeyPath
могут использоваться для записи свойств.
var globalInt = 0
struct Example {
var int = 0
var global: Int {
get { globalInt }
nonmutating set { globalInt = newValue }
}
}
var mutableExample = Example()
print(mutableExample.int) // prints 0
mutableExample[keyPath: \.int as WritableKeyPath<_, _>] = 1
print(mutableExample.int) // prints 1
// Обратите внимание - переменная константна (let)
let immutableExample = Example()
print(immutableExample.global) // prints 0
immutableExample[keyPath: \.global as ReferenceWritableKeyPath<_, _>] = 1
print(immutableExample.global) // prints 1
Теперь перейдем к особенностям KeyPath’ов.
Интересные особенности
Конвертация KeyPath-литерала в функцию
KeyPath-литералы могут быть автоматически конвертированы компилятором в функцию со следующей сигнатурой:
(Root) -> Value
Это очень удобно использовать в функциях высшего порядка.
let array = [0, 1, 2]
let arrayDescriptions = array.map(\.description) // ["0", "1", "2"]
Composability
KeyPath’ы, как и линзы, можно объединять, используя метод appending(path:)
.
let intDescriptionKeyPath = \Int.description
let intWidthKeyPath = intDescriptionKeyPath.appending(path: \.count)
Доступ по индексу
KeyPath’ы могут предоставлять доступ к любому сабскрипту при условии, что все параметры в этом сабскрипте — Hashable
.
let arrayFirst: KeyPath<[Int], Int?> = \.first
let arrayFirstUnwrapped: KeyPath<[Int], Int> = \.[0]
Атрибут @dynamicMemberLookup
В Swift есть специальный атрибут, который позволяет определять динамические свойства наших типов. Все, что нужно сделать, это определить специальный сабскрипт:
@dynamicMemberLookup
enum JSON {
case int
case string
/* ... */
subscript(dynamicMember key: String) -> JSON? {
/* ... */
}
}
Однако, мы можем использовать не только строки, но и KeyPath`ы.
@dynamicMemberLookup
struct Wrapper {
var wrapped: Wrapped
subscript(dynamicMember keyPath: KeyPath) -> T {
wrapped[keyPath: keyPath]
}
subscript(dynamicMember keyPath: WritableKeyPath) -> T {
get { wrapped[keyPath: keyPath] }
set { wrapped[keyPath: keyPath] = newValue }
}
subscript(dynamicMember keyPath: ReferenceWritableKeyPath) -> T {
get { wrapped[keyPath: keyPath] }
nonmutating set { wrapped[keyPath: keyPath] = newValue }
}
}
В этом примере мы создали структуру, которая оборачивает другую и предоставляет доступ ко всем ее переменным.
Type Inference
Type Inference для KeyPath’ов работает так же хорошо, как для переменных — мы можем даже менять типы в процессе, и компилятор все равно поймет, каким будет итоговый KeyPath.
let someStrangeKeyPath = \Int.description.count.description.count
Теперь поговорим о том, где KeyPath’ы могут нам пригодиться.
Примеры использования
Наследование @dynamicMemberLookup
Атрибут @dynamicMemberLookup
, объявленный в протоколе, ожидаемо наследуется типами, которые этот протокол реализуют. Это позволяет нам, например, внедрять глобальные зависимости во все компоненты нашей системы разом и без необходимости пробрасывать их в инициализаторы или как-либо еще.
public struct Dependencies {
@TaskLocal static var current: Dependencies = .init(
logger: .shared,
analytics: .shared
)
public var logger: Logger
public var analytics: Analytics
}
@dynamicMemberLookup
public protocol ViewModel: ObservableObject { /* ... */ }
public extension ViewModel {
subscript(dynamicMember keyPath: KeyPath) -> T {
Dependencies.current[keyPath: keyPath]
}
}
@dynamicMemberLookup
public protocol NavigationHandler { /* ... */ }
public extension NavigationHandler {
subscript(dynamicMember keyPath: KeyPath) -> T {
Dependencies.current[keyPath: keyPath]
}
}
И теперь все наши view-модели и NavigationHandler’ы могут использовать глобальные зависимости без необходимости хранить их или обращаться к синглтонам напрямую. А за счет TaskLocal
мы можем переопределять их в тестах.
final class VM: ViewModel {
func buttonTapped() {
self.logger.info("Tapped a button")
self.analytics.send("Opening a screen")
}
}
KeyPath’ы в качестве токенов
Представим на минуту, что мы пишем свою дизайн-систему и в какой-то момент нам становятся нужны токены, для цветов ли, картинок или ключей локализации, неважно.
В примере будут цвета. Итак, мы можем использовать KeyPath`ы в качестве токена в нашей дизайн-системе.
public struct ColorGuide {
public struct Backgrounds {
public let primary = Color.white
public let secondary = Color.gray
}
public var background: Backgrounds { .init() }
}
public typealias ColorToken = KeyPath
Этот код аналогичен такому использованию enum’ов:
public enum ColorToken {
public enum Background {
case primary
case secondary
var rawValue: Color {
switch self {
case .primary: .white
case .secondary: .gray
}
}
}
case background(Background)
}
Уже можно заметить, что у KeyPath’ов получается меньше кода, однако, все веселье только начинается.
Допустим, у нас есть два компонента:
public struct OurButton: View {
let text: String
let color: ColorToken
let action: () -> Void
public init(
_ text: String,
color: ColorToken,
action: @escaping () -> Void
) {
self.text = text
self.color = color
self.action = action
}
public var body: some View {
Button(text) {
action()
}
.background(ColorGuide()[keyPath: color])
}
}
public struct ButtonContainer: View {
public struct Model {
let text: String
let color: ColorToken
let action: () -> Void
}
let first: Model
let second: Model?
public var body: some View {
VStack {
OurButton(
first.text,
color: first.color,
action: first.action
)
if let second {
OurButton(
second.text,
color: second.color,
action: second.action
)
}
}
}
}
А теперь к нам приходит дизайнер и говорит, что в ButtonContainer
вторая кнопка всегда имеет дополнительное действие, а значит ее цвет должен отличаться (быть немного прозрачным). Как нам в рамках токенов задать прозрачность цвету?
Оказывается, с помощью KeyPath’ов сделать это довольно просто. Поскольку они позволяют получать доступ к значениям через сабскрипты, мы можем написать свой для изменения прозрачности:
extension Color {
subscript(opacity value: Double) -> Color {
self.opacity(value)
}
}
Это все, что нам нужно. Теперь перепишем наш компонент:
public struct ButtonContainer: View {
/* ... */
public var body: some View {
VStack {
OurButton(/* ... */)
if let second {
OurButton(
second.text,
color: second.color.appdending(path: \.[opacity: 0.85]), // <<<
action: second.action
)
}
}
}
}
Итоги
KeyPath’ы — важные строительные блоки современных API. Знание их особенностей и аспектов их использования позволит вам создавать удобные, приятные и простые API, которые при этом не допускают возможности ошибиться.
Кстати есть английская версия статьи на моем сайте. Делитесь впечатлениями!