Сортировка структур в Go: как написать коротко и не сломать себе мозг
Представьте: у вас есть слайс структур, и вам нужно отсортировать его по любому полю. Имя, возраст, зарплата, дата рождения — да что угодно. На Stack Overflow вопрос «What is the shortest way to simply sort an array of structs by (arbitrary) field names?» собрал тысячи просмотров. И это не случайно. В Go нет встроенного «магического» метода вроде list.sort(key=lambda x: x.field) из Python. Но есть элегантный и мощный способ — sort.Slice. В этой статье мы не просто покажем код, а заглянем под капот: как работает сортировка, что делает рантайм и как избежать типичных граблей.
Проблема: «Почему в Go нельзя просто написать sort()?»
Если вы пришли из Python, JavaScript или Ruby, вы привыкли к одной строке:
users.sort(key=lambda u: u.age)
В Go такой роскоши нет. Строгая типизация и отсутствие дженериков (до Go 1.18) заставляли писать отдельную реализацию sort.Interface для каждого типа структуры. Это было громоздко. Например:
type ByAge []User
func (a ByAge) Len() int { return len(a) }
func (a ByAge) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByAge) Less(i, j int) bool { return a[i].Age < a[j].Age }
func main() {
users := []User{{"Alice", 30}, {"Bob", 25}}
sort.Sort(ByAge(users))
}
Работает, но для каждого поля — новый тип. А если полей 10? Код превращается в простыню. Хочется чего-то компактнее.
Решение: sort.Slice — ваш джентльменский набор
Начиная с Go 1.8, в пакете sort появилась функция Slice. Она принимает слайс и анонимную функцию (замыкание), которая определяет порядок. Вот он — самый короткий способ:
sort.Slice(users, func(i, j int) bool {
return users[i].Age < users[j].Age
})
Всё. Три строки. Без объявления новых типов, без лишних методов. Хотите отсортировать по имени? Меняем поле:
sort.Slice(users, func(i, j int) bool {
return users[i].Name < users[j].Name
})
А если нужно по убыванию? Просто меняем знак:
sort.Slice(users, func(i, j int) bool {
return users[i].Age > users[j].Age // теперь по убыванию
})
Красота. Но как это работает? И в чём подвох?
Под капотом: sort.Slice и анонимные функции
Давайте разберём, что происходит, когда вы вызываете sort.Slice. Функция объявлена так:
func Slice(slice interface{}, less func(i, j int) bool)
Первое, что бросается в глаза — interface{}. Функция принимает любой слайс. Как она узнает его длину и как переставляет элементы? Ответ — через reflection (пакет reflect).
Внутри sort.Slice делает следующее:
- Проверяет, что передан слайс. Если передать не слайс (например, массив или структуру), программа запаникует на этапе выполнения.
- Извлекает длину через
reflect.ValueOf(slice).Len(). - Создаёт вспомогательный объект, который реализует интерфейс
sort.Interfaceдинамически. Этот объект хранит ссылку на исходный слайс (через reflection) и на вашу функциюless. - Запускает стандартный алгоритм сортировки (quicksort из пакета
sort). Когда алгоритму нужно сравнить два элемента, он вызывает вашуless. Когда нужно поменять местами — используетreflect.Swapper(slice).
Ключевой момент: ваша функция less захватывает исходный слайс из внешнего контекста. Это замыкание (closure). Компилятор размещает переменную users в куче, чтобы она была доступна даже после выхода из функции main. Это небольшое, но важное отличие от передачи данных через аргументы.
Reflection: магия и плата за неё
Использование sort.Slice — это компромисс между удобством и производительностью. Reflection добавляет накладные расходы:
- Скорость:
sort.Sliceработает медленнее, чемsort.Sortс конкретным типом. Разница может составлять 20-50% на больших слайсах (сотни тысяч элементов). На 1000 элементах вы её не заметите. - Безопасность типов: компилятор не проверяет, что вы обращаетесь к существующему полю. Ошибка вроде
users[i].Aje(опечатка) проявится только в рантайме. - Читаемость кода: замыкание может быть сложно отладить, если внутри много логики. Но для простых сортировок это идеальный вариант.
Если вам нужна максимальная производительность (например, сортировка гигабайтных логов), лучше вернуться к классическому sort.Sort с конкретным типом. Но для 99% задач sort.Slice — это золотой стандарт.
Практический пример: сортировка по нескольким полям
А что если нужно отсортировать сначала по возрасту, а потом по имени (для одинаковых возрастов)? Легко:
sort.Slice(users, func(i, j int) bool {
if users[i].Age != users[j].Age {
return users[i].Age < users[j].Age
}
return users[i].Name < users[j].Name
})
Или сортировка по убыванию возраста, но по возрастанию имени:
sort.Slice(users, func(i, j int) bool {
if users[i].Age != users[j].Age {
return users[i].Age > users[j].Age // старше — первыми
}
return users[i].Name < users[j].Name
})
Такая гибкость — главное преимущество анонимной функции. Вы можете написать любую логику сравнения: регистронезависимое сравнение строк, сравнение вложенных структур, вычисляемые поля и т.д.
Альтернативы: когда sort.Slice не подходит
Иногда хочется передать имя поля строкой, как в Python: sort_by(users, "Age"). В Go это можно сделать, но код станет сложнее:
import "reflect"
func sortByField(slice interface{}, fieldName string, ascending bool) {
sort.Slice(slice,