Привет. Меня зовут Максим Черноусов, и я занимаюсь 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/
Добавить комментарий