Скрытая (на виду) сила KeyPath’ов

от автора

Привет. Меня зовут Максим Черноусов, и я занимаюсь 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<Root, Value> {     let get: (Root) -> Value     let set: (Root, Value) -> Root }

Линза — это, по сути, две функции. Одна — для получения переменной типа Value из значения типа Root, и другая — для записи этой переменной, но из-за неизменяемости данных она возвращает новое значение Root. Теперь мы можем определить линзу для того, чтобы изменять положение нашего игрока:

let locationXLens = Lens<Player, Double>(     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<NewValue>(         with other: Lens<Value, NewValue>     ) -> Lens<Root, NewValue> {         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<Player, Vector>(     get: { $0.location },     set: { player, location in Player(location: location, camera: player.camera) } )  let cameraLens = Lens<Player, Vector>(     get: { $0.camera },     set: { player, camera in Player(location: player.location, camera: camera) } )  let xLens = Lens<Vector, Double>(     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<Root>: AnyKeyPath {}  class KeyPath<Root, Value>: PartialKeyPath<Root> {}  class WritableKeyPath<Root, Value>: KeyPath<Root, Value> {}  class ReferenceWritableKeyPath<Root, Value>: WritableKeyPath<Root, Value> {}
  • AnyKeyPath — базовый класс для всех KeyPath’ов. Как подсказывает название, это type-erased версия KeyPath’а. Подписан на Hashable, что позволяет нам использовать KeyPath’ы, например, в качестве ключей в словарях.

  • PartialKeyPath<Root> — еще одна type-erased версия, имеет тип-параметр Root, но не имеет Value. При использовании такого KeyPath’а мы получим значение для нужной переменной, но оно будет иметь тип Any.

  • KeyPath<Root, Value> — самый часто используемый тип. Имеет все необходимые типы-параметры. Такой KeyPath позволяет читать значения Value из объекта типа Root.

  • WritableKeyPath<Root, Value> — как подсказывает название, версия KeyPath’а, которая кроме чтения, позволяет записывать значения.

  • ReferenceWritableKeyPath<Root, Value> — аналогично предыдущему, только теперь мы записываем значения с reference семантикой. Обычно это свойства классов, однако если у нас в структуре есть computed property, у которой есть nonmutating set, то KeyPath к такой переменной тоже будет ReferenceWritable.

Основной способ получить KeyPath — это KeyPath-литерал:

let intDescriptionKeyPath = \Int.description

Если компилятору известен тип Root, мы можем опустить его в литерале:

let d: KeyPath<Int, String> = \.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<Wrapped> { var wrapped: Wrapped  subscript<T>(dynamicMember keyPath: KeyPath<Wrapped, T>) -> T { wrapped[keyPath: keyPath] }  subscript<T>(dynamicMember keyPath: WritableKeyPath<Wrapped, T>) -> T { get { wrapped[keyPath: keyPath] } set { wrapped[keyPath: keyPath] = newValue } }  subscript<T>(dynamicMember keyPath: ReferenceWritableKeyPath<Wrapped, T>) -> 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<T>(dynamicMember keyPath: KeyPath<Dependencies, T>) -> T { Dependencies.current[keyPath: keyPath] } }  @dynamicMemberLookup  public protocol NavigationHandler { /* ... */ }  public extension NavigationHandler { subscript<T>(dynamicMember keyPath: KeyPath<Dependencies, T>) -> 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<ColorGuide, Color>

Этот код аналогичен такому использованию 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, которые при этом не допускают возможности ошибиться.

Кстати есть английская версия статьи на моем сайте. Делитесь впечатлениями!


ссылка на оригинал статьи https://habr.com/ru/articles/828896/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *