1. Зачем сортировать перед выводом
Когда вы пишете CLI, кажется, что «порядок строк — мелочь». Но ровно до момента, пока кто-то не начинает парсить ваш вывод скриптом, сравнивать результаты тестами или просто пытаться глазами найти «задачу №7». Если порядок прыгает, вы получаете ощущение, что программа живёт своей жизнью.
Сортировка перед выводом решает две практические задачи:
- делает результат детерминированным: одинаковые входные данные дают одинаковый вывод;
- делает вывод удобным: задачи можно искать, сравнивать, группировать.
И особенно важно: сортировать нужно данные, а не строки таблицы/JSON — потому что форматирование должно быть последним шагом, а не местом, где «параллельно решаем бизнес-логику».
Чтобы закрепить идею, представим наш пайплайн (очень упрощённо):
flowchart LR
A[Данные из storage] --> B[Подготовка списка]
B --> C[Сортировка]
C --> D[Рендер table/json]
D --> E[stdout]
Сортировка стоит прямо перед рендером: мы уже решили, что показываем, и теперь решаем, в каком порядке.
Что именно мы сортируем
Прежде чем сортировать, полезно на секунду притормозить и договориться: сортируем мы не «вывод», а слайс структур. Это важно, потому что структура — это единый источник правды для всех форматов (и таблицы, и JSON). В прошлых лекциях мы уже опирались на идею: один []Task → два рендера.
Минимальная модель задачи для нашего учебного CLI (условно назовём приложение tasker) выглядит так:
package main
import "time"
type Task struct {
ID int
Title string
Done bool
CreatedAt time.Time
}
Если у вас в текущей версии приложения пока нет CreatedAt, ничего страшного: сортировать можно и только по ID. Но поле времени удобно, чтобы показать типичный «реальный» критерий сортировки (и вы уже знакомы с time.Time из блока про время).
2. sort.Slice: базовый способ сортировки
Когда нужен быстрый и понятный старт, sort.Slice — отличный выбор. Он сортирует in-place, то есть меняет порядок элементов в исходном слайсе. Это удобно, но иногда неожиданно: вы думали, что «просто вывели», а на деле ещё и изменили данные.
Вот самая честная сортировка «по возрастанию ID»:
package main
import "sort"
func sortByID(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].ID < tasks[j].ID
})
}
Здесь есть два важных смысла:
- компаратор — это функция, которая отвечает на вопрос: «элемент i должен идти раньше, чем элемент j?». Она возвращает true, если да;
- если вы сортируете перед выводом, то сортировка по ID — хороший дефолт: простая, предсказуемая и объяснимая пользователю.
Проверить себя можно маленьким примером:
package main
import (
"fmt"
"sort"
)
func main() {
tasks := []Task{{ID: 10, Title: "B"}, {ID: 2, Title: "A"}}
sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID })
fmt.Println(tasks[0].ID, tasks[1].ID) // 2 10
}
3. Как выбрать критерий сортировки
Слово «критерий» звучит как что-то из экзамена, но в программировании это просто правило: по какому полю (или комбинации полей) мы сравниваем элементы.
Частичный порядок: почему одного Done мало
Представим, что вы хотите вывести сначала невыполненные, потом выполненные. Кажется, что достаточно вот так:
sort.Slice(tasks, func(i, j int) bool {
return !tasks[i].Done && tasks[j].Done
})
И вот тут начинается классика. Если обе задачи Done=false, компаратор вернёт false. Если обе Done=true, тоже вернёт false. То есть для большинства пар элементов компаратор говорит «я не знаю». В результате порядок внутри группы может стать… «как получится».
Иногда это «как получится» кажется стабильным на вашей машине, но по сути вы оставили дырку в контракте: порядок не определён полностью.
Полный порядок: вторичный ключ
Решение простое: если по основному полю элементы равны, сравниваем по вторичному. Это и есть «вторичный ключ».
Сортировка «UNDONE сначала, внутри групп по ID»:
package main
import "sort"
func sortByDoneThenID(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
a, b := tasks[i], tasks[j]
if a.Done != b.Done {
return !a.Done && b.Done // false < true, то есть UNDONE раньше DONE
}
return a.ID < b.ID
})
}
Вот это уже настоящий контракт: для любых двух задач мы можем однозначно сказать, какая раньше.
Шпаргалка: как сравнивать поля
Иногда новичку сложно вспомнить, как сравнивать time.Time или можно ли сравнивать строки через <. Держите короткую шпаргалку:
| Поле | Тип | Как сравнивать в Go | Комментарий |
|---|---|---|---|
|
|
|
Самый простой критерий |
|
|
|
Лексикографически, чувствительно к регистру |
|
|
|
Лучше явно, чтобы читалось |
|
|
|
Не сравнивать через < |
Пример сортировки по времени создания:
package main
import "sort"
func sortByCreatedAt(tasks []Task) {
sort.Slice(tasks, func(i, j int) bool {
return tasks[i].CreatedAt.Before(tasks[j].CreatedAt)
})
}
4. sort.Slice и sort.SliceStable
Стабильность сортировки означает: если два элемента «равны» по ключу сортировки, их взаимный порядок сохраняется таким, каким был до сортировки. Это полезно, когда вы делаете сортировку «в несколько шагов» или когда исходный порядок тоже несёт смысл.
У пакета sort для этого есть sort.SliceStable. По использованию он похож на sort.Slice, просто гарантирует стабильность:
package main
import "sort"
func sortByDoneStable(tasks []Task) {
sort.SliceStable(tasks, func(i, j int) bool {
return !tasks[i].Done && tasks[j].Done
})
}
Тонкий момент: даже стабильная сортировка не спасёт вас, если исходный порядок «случайный» (например, вы собрали задачи из map без сортировки ключей). Поэтому для CLI-контракта чаще всего проще и надёжнее: делать полный порядок (основной + вторичный ключ), чем надеяться на стабильность и «как там до этого повезло».
5. Пакет slices и slices.SortFunc — Go 1.21+
В стандартной библиотеке Go появился пакет slices, в котором есть готовые операции над слайсами, включая сортировку. Он был добавлен как стандартное решение для типичных задач со слайсами, и там же есть более удобные функции сортировки.
Для простых случаев (когда элементы упорядочиваемые) есть slices.Sort, а для кастомного сравнения — slices.SortFunc. В материалах про пакет slices часто показывают slices.Sort и slices.Clone как «обычные инструменты», которые теперь читаются проще, чем самописные велосипеды.
Почему slices.SortFunc читается проще
sort.Slice работает с индексами i, j. А slices.SortFunc даёт вам сравнение на уровне значений (через функцию-компаратор). Это часто читается проще: мы сравниваем a и b, а не «элементы под номерами i и j».
Типичный паттерн выглядит так (компаратор возвращает int: отрицательное/0/положительное):
package main
import (
"cmp"
"slices"
)
func sortByID2(tasks []Task) {
slices.SortFunc(tasks, func(a, b Task) int {
return cmp.Compare(a.ID, b.ID)
})
}
Пакет cmp здесь — стандартный способ сравнить упорядочиваемые значения (числа, строки) через cmp.Compare, чтобы не писать руками «если меньше — верни -1…». Он делает код более ровным по стилю.
Сложный критерий с вторичным ключом
Сделаем тот же контракт, что и раньше: UNDONE сначала, потом DONE; внутри групп сортируем по ID.
package main
import (
"cmp"
"slices"
)
func sortByDoneThenID2(tasks []Task) {
slices.SortFunc(tasks, func(a, b Task) int {
if a.Done != b.Done {
if !a.Done {
return -1
}
return 1
}
return cmp.Compare(a.ID, b.ID)
})
}
Да, тут чуть больше строк, чем в sort.Slice, но логика становится очень «табличной»: мы как будто пишем правила сравнения.
Сортировка на месте и копия слайса
И sort.Slice, и slices.SortFunc сортируют in-place. Это нормальная и ожидаемая семантика: вы переупорядочиваете элементы, а не создаёте «новую вселенную задач».
Кстати, в пакете slices есть инструменты, чтобы сделать копию перед сортировкой, если вам нужно сохранить исходный порядок. Типичный паттерн: s2 := slices.Clone(s) → сортируем s2, а исходный s остаётся как был.
6. Интеграция сортировки в CLI-пайплайн
Теперь соберём это в цельный фрагмент нашего приложения. Представим, что у нас уже есть команда list, которая получает задачи из хранилища, а затем вызывает рендер (таблица или JSON — это уже сделано в предыдущих лекциях).
Мы добавим одну функцию: prepareForOutput, которая делает сортировку. Фильтры мы здесь специально не трогаем: это отдельная тема, и ей посвящена следующая лекция.
package main
func prepareForOutput(tasks []Task) []Task {
// Если не хотим менять исходный слайс, копируем.
out := make([]Task, len(tasks))
copy(out, tasks)
sortByDoneThenID(out) // или sortByDoneThenID2(out)
return out
}
Теперь в обработчике команды list логика становится линейной и спокойной:
package main
import "os"
func runList() error {
tasks, err := loadTasks() // допустим, уже есть
if err != nil {
return err
}
tasks = prepareForOutput(tasks)
return render(os.Stdout, "table", tasks) // формат уже нормализован ранее
}
Здесь важна философия: render больше не обязан думать о порядке. Он получает готовый список и просто честно его печатает. Это заметно упрощает поддержку: когда у вас вдруг появится второй формат вывода или тесты (а они появятся), сортировка останется одной строкой в одном месте, а не размазанной по всему проекту.
7. Типичные ошибки при сортировке перед выводом
Ошибка №1: сортировка только по основному ключу без вторичного, из-за чего порядок “дрожит”.
Это самый коварный баг: вы сортируете по Done, вывод «вроде» выглядит нормальным, а потом внезапно в одном запуске задачи внутри группы идут так, в другом — иначе. Причина в том, что вы не задали полный порядок. Лечится добавлением вторичного ключа (например, ID), чтобы сравнение было определено для любых двух элементов.
Ошибка №2: сортировка внутри рендера (таблицы/JSON), а не перед ним.
Сначала кажется удобным: «я же всё равно там бегаю по циклу, вот там и отсортирую». На практике это смешивает два слоя: подготовку данных и форматирование. В итоге таблица сортируется, JSON забыли сортировать, тесты падают, пользователь злится. Правильнее держать сортировку в шаге подготовки данных и вызывать рендер уже на готовом []Task.
Ошибка №3: неожиданно меняется исходный слайс (in-place сортировка), и дальше логика начинает вести себя странно.
Обе сортировки (sort.Slice и slices.SortFunc) меняют порядок элементов в переданном слайсе. Если где-то ниже вы рассчитывали на исходный порядок (или повторно используете tasks), то получаете «побочный эффект». Если порядок нужно сохранить, делайте копию через copy или slices.Clone, а сортируйте копию.
Ошибка №4: компаратор написан так, что нарушает “здравый смысл” сравнения.
Иногда компаратор получается противоречивым: для трёх элементов A, B, C он может утверждать, что A < B, B < C, но при этом C < A. Сортировка от такого не обязана «красиво падать» — она просто перестаёт быть предсказуемой. Если критерий сложный, пишите сравнение по шагам: сначала главное поле, потом вторичное, и только потом (если надо) третье.
Ошибка №5: ожидание, что “JSON сам стабилизируется”.
JSON-вывод стабилен ровно настолько, насколько стабилен ваш []Task. Если порядок в слайсе плавает, массив в JSON тоже будет плавать. Поэтому сортировка — это обязательный шаг для обоих форматов: и для table, и для json. Пакет slices и его сортировки как раз и помогают держать этот шаг коротким и читаемым.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ