Сортировка структур в Go: короткий путь и магия под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Сортировка структур в 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 делает следующее:

  1. Проверяет, что передан слайс. Если передать не слайс (например, массив или структуру), программа запаникует на этапе выполнения.
  2. Извлекает длину через reflect.ValueOf(slice).Len().
  3. Создаёт вспомогательный объект, который реализует интерфейс sort.Interface динамически. Этот объект хранит ссылку на исходный слайс (через reflection) и на вашу функцию less.
  4. Запускает стандартный алгоритм сортировки (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,                

Блоги

Рекомендаций по книгам