Привет. Меня зовут Максим Черноусов, и я занимаюсь 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, которые при этом не допускают возможности ошибиться.
Кстати есть английская версия статьи на моем сайте. Делитесь впечатлениями!
Bardakan
выглядит интересно, но пока не понятно, чем это лучше существующих решений:
внедрять глобальные зависимости - можно же объявить протоколы со свойствами get и сделать для них extension
почему не сделать struct, в которой в произвольном формате описать все нужные цвета? Для alpha тоже есть системное решение для UIColor -
withAlphaComponent(_:)
. Аналогичное решение должно быть (или можно дописать) и для SwiftUI Color