Скрытая (на виду) сила 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/companies/raiffeisenbank/articles/828896/


Интересные статьи

Интересные статьи

Представьте: к вам приходит разработчик и просит повышения. Ты знаешь, что он уже давно работает в компании, отлично проявляет себя в проектах и обладает отличными навыками коммуникации. Однако дать о...
Внимание! Статья несёт исключительно информативный характер. Подобные действия преследуются по закону!В наше время цифровая безопасность все более актуальна, поскольку важность защиты конфиденциальной...
Jetpack Compose — это набор инструментов для построения современных UI (пользовательских интерфейсов) в Android‑приложениях. Компания Google анонсировала Jetpack Compose в 2019 год...
Много узкоспециализированных объектов или небольшое количество универсальных? Истина, как обычно, посередине. Справочники и документы в 1С - это пример удачного попадания в эту середину. Разумеется, ...
Игроки-стримеры не хотят тратить ресурсы на кодирование видео в ущерб производительности самой игры, да и современные GPU часто идут с аппаратными кодировщиками. Но все ли они одинаково полезны? Ведь ...