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