$ sudo teach IT

Зачем нужно ссылаться на свойство, а не читать его значение

Когда нужно отсортировать список людей по возрасту, посчитать средний балл по какому-то полю или применить одну и ту же операцию к разным свойствам объекта, обычно пишут отдельную функцию под каждый конкретный случай: «отсортировать по возрасту», «отсортировать по имени», «посчитать средний балл», «посчитать средний рост». Логика внутри почти одинаковая — отличается только то, какое свойство читается. Было бы удобнее передать в функцию не значение свойства, а сам «адрес» этого свойства — что-то вроде «третья полка, вторая коробка», — а функция уже сама заглянет по этому адресу у любого переданного объекта.

Именно для этого в Swift есть ключевые пути (key paths): значение, которое описывает конкретное свойство конкретного типа, но само по себе ничего не читает. Ключевой путь можно сохранить в переменную, передать параметром в функцию и применить к любому экземпляру этого типа позже.

Синтаксис \Тип.свойство и тип KeyPath

Ключевой путь создаётся обратным слэшем перед именем типа и точкой перед именем свойства:

struct Book {
    let title: String
    var pages: Int
}

let pagesPath = \Book.pages

pagesPath — это значение типа KeyPath<Book, Int>: путь, который начинается от Book и ведёт к свойству типа Int. Само по себе оно ничего не печатает и ничего не меняет — это просто описание маршрута, которое можно применить к любому конкретному экземпляру Book позже.

Чтение значения: сабскрипт [keyPath:]

Чтобы получить значение свойства по сохранённому пути, у экземпляра есть специальный сабскрипт:

let book = Book(title: "Свифт для начинающих", pages: 320)
print(book[keyPath: pagesPath]) // 320

Запись book[keyPath: pagesPath] равносильна book.pages, но путь к свойству здесь — отдельное значение, которое можно передавать и хранить, а не жёстко зашитое в код имя. Одну и ту же функцию, принимающую KeyPath<Book, Int> параметром, можно вызвать и с \Book.pages, и с любым другим целочисленным свойством Book, если оно появится.

WritableKeyPath: путь, по которому можно и записывать

Если свойство объявлено через var, путь к нему имеет более специфичный тип — WritableKeyPath, и через такой путь можно не только читать, но и записывать значение тем же сабскриптом:

var mutableBook = Book(title: "Свифт для начинающих", pages: 320)
let pagesWritablePath: WritableKeyPath<Book, Int> = \.pages
mutableBook[keyPath: pagesWritablePath] = 350
print(mutableBook.pages) // 350

Для свойств, объявленных через let (например title), компилятор выдаст обычный KeyPath, а не WritableKeyPath, — присвоить значение через такой путь нельзя, точно так же, как нельзя написать book.title = "Другое" напрямую. И конечно, писать через путь можно только в переменную, объявленную через var: у структур это, как обычно, связано со значимой семантикой — менять можно только изменяемую копию.

Заметьте: во втором примере путь записан короче — \.pages, без имени типа. Так можно, если компилятор уже знает тип из контекста (здесь — из аннотации WritableKeyPath<Book, Int>); это та же сокращённая форма, что и у замыканий с выводом типов.

Ключевой путь как готовая функция: map, filter, sorted

Там, где ожидается замыкание вида (Тип) -> Значение, ключевой путь можно передать напрямую вместо него — Swift сам превращает \.свойство в такую функцию:

let books = [Book(title: "Первая", pages: 120), Book(title: "Вторая", pages: 340)]
let pageCounts = books.map(\.pages)
print(pageCounts) // [120, 340]

Это работает и с filter, если свойство имеет тип Bool, а в сортировке тот же ключевой путь можно подставить в сравнение вручную через сабскрипт:

let sortedByPages = books.sorted { $0[keyPath: \Book.pages] < $1[keyPath: \Book.pages] }
print(sortedByPages.map(\.title)) // ["Первая", "Вторая"]

sorted(by:) по-прежнему ждёт замыкание с двумя параметрами, но внутри замыкания вместо $0.pages и $1.pages можно подставить любой сохранённый заранее ключевой путь — в том числе переданный параметром в обобщённую функцию, которая ничего заранее не знает о том, по какому именно свойству сортировать.

Частые ошибки

  • Пытаться записать значение через обычный KeyPath — компилятор пропустит его только для чтения. Присваивание доступно исключительно через WritableKeyPath, а он существует только для свойств, объявленных через var.
  • Забывать про сабскрипт и писать что-то вроде book.pagesPath — ключевой путь применяется не через точку, а через [keyPath: ...].
  • Путать \Book.pages (путь как значение) с обычным обращением book.pages (уже прочитанное значение) — это разные вещи с разными типами.
  • Пытаться писать через ключевой путь в структуру, сохранённую в let-переменной. Ключевой путь не отменяет значимую семантику структур: изменяемой должна быть сама переменная, а не только путь.

Резюме

  • \Тип.свойство создаёт ключевой путь — значение, описывающее свойство типа, но не читающее его само по себе.
  • Прочитать значение по пути можно сабскриптом экземпляр[keyPath: путь].
  • WritableKeyPath получается для свойств на var и, в отличие от обычного KeyPath, позволяет ещё и записывать значение тем же сабскриптом.
  • Там, где ожидается замыкание вида (Тип) -> Значение, ключевой путь \.свойство можно передать напрямую — это работает в map, filter и внутри сравнения для sorted(by:).
  • Ключевые пути особенно полезны, когда нужно передать «какое свойство обрабатывать» параметром в обобщённую функцию, а не жёстко прописывать его в теле.

Проверьте себя

3 вопроса

Работа со студентами через KeyPath

Дан тип Student с полями name, score и attempts. Объявление структуры менять не нужно — допишите две функции, использующие ключевые пути.

  • average(of:by:) — принимает массив студентов и KeyPath<Student, Int> на одно из целочисленных полей, возвращает среднее значение этого поля как Double. Для пустого массива возвращает 0.0.
  • applyBonus(to:keyPath:bonus:) — принимает массив студентов по inout, WritableKeyPath<Student, Int> на изменяемое поле и величину бонуса; прибавляет бонус к указанному полю у каждого студента в массиве.

Обе функции должны работать с любым переданным ключевым путём на Int-поле Student, а не только с конкретным полем score.