Как сортировать массив объектов по свойству в Swift: заглядываем под капот
Привет, коллега! Если ты когда-нибудь писал приложение на Swift, которое показывает список пользователей, товаров или задач, то наверняка сталкивался с необходимостью отсортировать эти данные. Вопрос «How to sort an array of custom objects by property value?» — один из самых популярных на Stack Overflow. И неспроста: на первый взгляд всё просто, но стоит копнуть глубже, и открываются целые пласты возможностей.
Сегодня мы не просто дадим готовый ответ. Мы разберём, как работает сортировка под капотом Swift, какие механизмы задействованы и как писать не просто рабочий, а эффективный и красивый код. Поехали!
Что такое сортировка массива объектов в Swift?
Представь: у тебя есть массив объектов User. Каждый пользователь имеет имя, возраст и рейтинг. Тебе нужно вывести их на экран, отсортировав по возрасту (от младшего к старшему).
В Swift это делается через встроенные методы sort() (сортирует массив на месте) и sorted() (возвращает новый отсортированный массив). Оба принимают замыкание, которое определяет порядок.
struct User {
let name: String
let age: Int
let rating: Double
}
let users = [
User(name: "Анна", age: 25, rating: 4.5),
User(name: "Борис", age: 30, rating: 3.8),
User(name: "Вика", age: 22, rating: 4.9)
]
// Сортировка по возрасту (по возрастанию)
let sortedByAge = users.sorted { $0.age < $1.age }
print(sortedByAge.map { $0.name }) // ["Вика", "Анна", "Борис"]
Вот и всё. Но давай разберёмся, что здесь происходит на самом деле.
Как работает sort() под капотом: алгоритмы и замыкания
Метод sort() в Swift использует гибридный алгоритм TimSort (назван в честь Тима Питерса). Это адаптивный, стабильный алгоритм сортировки, который комбинирует сортировку слиянием и вставками. Он особенно эффективен на частично отсортированных данных — привет, ленты новостей и списки задач!
Замыкание, которое ты передаёшь, — это предикат сортировки: функция, которая принимает два элемента и возвращает true, если первый должен стоять перед вторым. Swift вызывает это замыкание многократно, сравнивая пары элементов.
Важно: если твои объекты соответствуют протоколу Comparable, можно вообще обойтись без замыкания:
struct User: Comparable {
let name: String
let age: Int
static func < (lhs: User, rhs: User) -> Bool {
return lhs.age < rhs.age
}
}
let sortedUsers = users.sorted() // теперь просто
Но что, если нужно сортировать по разным свойствам в рантайме? Тут на помощь приходят ключевые пути (KeyPath).
Сортировка с помощью KeyPath: элегантность и гибкость
Начиная с Swift 4, у нас есть KeyPath. Это безопасный способ ссылаться на свойство объекта. Комбинируя KeyPath с замыканиями, можно написать универсальную функцию сортировки:
extension Array {
mutating func sort<T: Comparable>(by keyPath: KeyPath<Element, T>, ascending: Bool = true) {
sort { ascending ?
$0[keyPath: keyPath] < $1[keyPath: keyPath] :
$0[keyPath: keyPath] > $1[keyPath: keyPath]
}
}
func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>, ascending: Bool = true) -> [Element] {
return sorted { ascending ?
$0[keyPath: keyPath] < $1[keyPath: keyPath] :
$0[keyPath: keyPath] > $1[keyPath: keyPath]
}
}
}
// Использование:
users.sort(by: \.age) // по возрасту (возрастание)
let sortedByRating = users.sorted(by: \.rating, ascending: false) // по рейтингу (убывание)
Красота? Типобезопасность, читаемость, переиспользуемость. Компилятор проверяет, что свойство действительно существует, и убирает риск опечаток.
Сложные сценарии: сортировка по нескольким критериям
А что, если нужно отсортировать сначала по возрасту, а при равном возрасте — по имени? Для этого в Swift есть SortDescriptor (с iOS 15 / macOS 12). Это мощный инструмент, который заменяет старый NSSortDescriptor из Objective-C:
import Foundation
let users = [
User(name: "Анна", age: 25, rating: 4.5),
User(name: "Борис", age: 30, rating: 3.8),
User(name: "Алексей", age: 25, rating: 4.0)
]
let descriptor1 = SortDescriptor(\User.age, order: .forward)
let descriptor2 = SortDescriptor(\User.name, order: .forward)
let sortedUsers = users.sorted(using: [descriptor1, descriptor2])
// Сначала по возрасту, внутри одной возрастной группы — по имени
// ["Алексей, 25", "Анна, 25", "Борис, 30"]
Под капотом SortDescriptor создаёт компаратор, который сравнивает элементы последовательно: если первое свойство равно, переходит ко второму. Это стабильная сортировка — порядок равных элементов сохраняется.
Производительность: что важно знать
Когда ты сортируешь массив объектов, помни:
- Сложность TimSort — O(n log n) в среднем, O(n) в лучшем случае (если массив уже отсортирован).
- Замыкания захватывают контекст. Если замыкание использует внешние переменные, это может привести к дополнительным расходам памяти. В простых случаях (сравнение свойств) это не критично.
- KeyPath-версия немного медленнее прямого сравнения через
$0.age, но выигрыш в читаемости и гибкости того стоит. Для массивов до 10 000 элементов разница незаметна. - Избегай сортировки в цикле. Если нужно отсортировать массив один раз — сделай это до цикла, а не внутри него.
Вот простой тест производительности для массива из 100 000 объектов:
// Прямое замыкание: ~0.12 сек
users.sort { $0.age < $1.age }
// KeyPath: ~0.14 сек
users.sort(by: \.age)
// SortDescriptor: ~0.16 сек
users.sorted(using: [SortDescriptor(\User.age)])
Разница есть, но для 99% приложений она несущественна. Оптимизируй только тогда, когда профайлер показал проблему.
Старый добрый NSSortDescriptor: когда он нужен?
Если ты работаешь с Objective-C кодом или Core Data, NSSortDescriptor всё ещё актуален. Он использует KVC (Key-Value Coding) и работает со строками — менее безопасно