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

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

Комментарии (1)


  1. Bardakan
    18.07.2024 22:01

    выглядит интересно, но пока не понятно, чем это лучше существующих решений:

    • внедрять глобальные зависимости - можно же объявить протоколы со свойствами get и сделать для них extension

    • почему не сделать struct, в которой в произвольном формате описать все нужные цвета? Для alpha тоже есть системное решение для UIColor - withAlphaComponent(_:) . Аналогичное решение должно быть (или можно дописать) и для SwiftUI Color