Олег Марков
Генерация слайсов в Go - практическое руководство по slice generator
Введение
Генерация слайсов (slice-generator) чаще всего всплывает в двух контекстах:
- Как «генератор слайсов» — подход или утилита, которая создает, наполняет и преобразует срезы по заданным правилам.
- Как кодогенерация вокруг слайсов — когда вы генерируете вспомогательные функции и обертки, чтобы не писать однотипный код руками.
С ростом кода на Go, особенно до появления дженериков, разработчики часто уходили в генерацию кода для работы со слайсами: фильтрация, маппинг, поиск, уникализация и так далее. Сейчас, с появлением дженериков, часть таких задач упростилась, но генерация по-прежнему полезна: она позволяет избавиться от рутины и сделать работу со слайсами более предсказуемой и типобезопасной.
В этой статье вы увидите, как подойти к теме «slice-generator» системно:
- как проектировать функции-генераторы слайсов;
- как использовать шаблоны и
go generateдля автогенерации кода; - как собирать типичные операции над слайсами в единый «генераторский» подход;
- как контролировать выделения памяти и производительность;
- как применить это в реальных задачах.
Смотрите, я буду показывать примеры кода, каждый из которых снабжен комментариями. Это поможет вам шаг за шагом разобраться, что и зачем происходит.
Базовые принципы генерации слайсов
Что такое генерация слайсов
Под генерацией слайсов будем понимать:
- создание слайса по некоторому правилу;
- наполнение слайса значениями, иногда «лениво»;
- построение новых слайсов на основе существующих (map, filter, group, chunk и т.п.);
- автоматическую генерацию кода для таких операций.
Важно понимать, что в Go нет «ленивых генераторов» в духе Python yield. Зато есть:
- функции, которые возвращают готовый слайс;
- функции-итераторы, которые заполняют переданный слайс;
- кодогенерация с помощью
go generateи шаблонов.
Давайте разберемся на простых примерах, а потом перейдем к более продвинутым инструментам.
Напоминание — как устроен слайс в Go
Прежде чем строить генератор, важно помнить, из чего вообще состоит слайс:
- указатель на массив (под капотом);
- длина (len);
- емкость (cap).
Пример создания и расширения слайса:
package main
import "fmt"
func main() {
// Создаем пустой слайс целых чисел длиной 0 и емкостью 0
var a []int
// Добавляем элементы с помощью append
a = append(a, 1) // Здесь Go создаст массив под капотом
a = append(a, 2, 3, 4)
fmt.Println(a) // [1 2 3 4]
fmt.Println(len(a)) // 4 - текущее количество элементов
fmt.Println(cap(a)) // емкость может быть больше или равна длине
}
Когда вы строите генератор слайсов, вы почти всегда будете управлять длиной и емкостью. Это сильно влияет на производительность.
Простые функции-генераторы слайсов
Функции, которые создают слайс по правилу
Самый базовый вариант slice-generator — функция, которая по входным данным строит слайс.
Например, генератор арифметической прогрессии:
// IntRange генерирует слайс целых чисел от start до end с шагом step.
// Если step <= 0, функция вернет пустой слайс.
func IntRange(start, end, step int) []int {
// Проверяем аргументы
if step <= 0 || start >= end {
// В таких случаях удобно возвращать nil или пустой слайс
return nil
}
// Оцениваем количество элементов, чтобы заранее выделить память
size := (end - start + step - 1) / step // округление вверх
// Создаем слайс нужной длины
res := make([]int, 0, size)
// Наполняем слайс значениями
for v := start; v < end; v += step {
res = append(res, v)
}
return res
}
Здесь я заранее вычисляю size и создаю слайс с нужной емкостью. Это типичная часть любой «генерации» слайса: вы стараетесь избежать лишних realLOC.
Теперь вы увидите, как это выглядит в коде при использовании:
func main() {
// Сгенерируем числа от 0 до 10 с шагом 2
even := IntRange(0, 10, 2)
// even == []int{0, 2, 4, 6, 8}
// Сгенерируем пустой слайс при некорректном шаге
invalid := IntRange(0, 10, 0)
// invalid == nil
}
Генерация слайса из функции-источника
Иногда вы хотите сказать: «дай мне N значений, каждое получено вызовом некой функции». Например, сгенерировать N случайных чисел.
Показываю, как это реализовано на практике:
package main
import (
"fmt"
"math/rand"
"time"
)
// GenerateSlice создает слайс длиной n, заполняя его значениями,
// которые возвращает функция generator.
func GenerateSlice[T any](n int, generator func(i int) T) []T {
if n <= 0 {
return nil
}
// Создаем слайс нужного размера
res := make([]T, n)
// Заполняем каждый элемент
for i := 0; i < n; i++ {
// Передаем индекс, чтобы генератор мог им воспользоваться
res[i] = generator(i)
}
return res
}
func main() {
rand.Seed(time.Now().UnixNano())
// Генерируем 5 случайных чисел
randoms := GenerateSlice(5, func(i int) int {
// i здесь можно использовать, например, для смещения
return rand.Intn(100)
})
fmt.Println(randoms)
// Генерируем 10 строк по шаблону
strings := GenerateSlice(10, func(i int) string {
return fmt.Sprintf("item-%d", i)
})
fmt.Println(strings)
}
Смотрите, GenerateSlice — это универсальный slice-generator. Вы передаете:
- размер слайса;
- функцию, которая «порождет» каждый элемент.
Вы можете вынести такую функцию в отдельный пакет и использовать как строительный блок во многих местах.
Генерация на основе существующих слайсов
Очень часто «генерация» нового слайса происходит на основе старого. Например:
- фильтрация;
- преобразование типа;
- группировка.
Это по сути и есть генераторы: они берут исходные данные и порождают новую последовательность.
Map для слайсов
Давайте разберемся на примере функции Map, которая строит новый слайс, применяя функцию к каждому элементу исходного.
// Map применяет функцию f ко всем элементам src и возвращает новый слайс.
func Map[S ~[]E, E any, R any](src S, f func(E) R) []R {
// Предполагаем, что результирующий слайс будет той же длины
res := make([]R, len(src))
for i, v := range src {
// Применяем функцию-преобразователь
res[i] = f(v)
}
return res
}
Здесь:
S ~[]E— обобщенный тип, который «ведет себя как» слайс элементовE. Это удобно, если вы хотите работать с алиасами слайсов.E any— тип элемента исходного слайса;R any— тип элемента результирующего слайса.
Теперь давайте посмотрим, что происходит в следующем примере использования:
func main() {
ints := []int{1, 2, 3, 4}
// Умножаем каждый элемент на 10
tens := Map(ints, func(v int) int {
return v * 10
})
// tens == []int{10, 20, 30, 40}
// Преобразуем int в строку
strs := Map(ints, func(v int) string {
return fmt.Sprintf("val-%d", v)
})
// strs == []string{"val-1", "val-2", "val-3", "val-4"}
}
Это и есть типичный пример slice-generator «на основе» другого слайса.
Filter для слайсов
Теперь покажу генерацию слайса по условию — фильтрацию:
// Filter возвращает новый слайс с элементами src,
// для которых предикат pred вернул true.
func Filter[S ~[]E, E any](src S, pred func(E) bool) []E {
// Создаем слайс с нулевой длиной, но емкостью как у src.
// Это оптимизация: мы предполагаем, что отбор не будет очень жестким.
res := make([]E, 0, len(src))
for _, v := range src {
if pred(v) {
// Добавляем только подходящие элементы
res = append(res, v)
}
}
return res
}
Использование:
func main() {
ints := []int{1, 2, 3, 4, 5, 6}
// Оставим только четные
even := Filter(ints, func(v int) bool {
return v%2 == 0
})
// even == []int{2, 4, 6}
}
Обратите внимание, как этот фрагмент кода решает задачу генерации нового слайса:
- мы заранее задаем емкость;
- наполняем только тем, что проходит проверку.
Генерация слайсов через кодогенерацию
Когда дженериков не было, slice-generator чаще всего означал:
- набор шаблонов (
.gotmplилиtemplate.go); go generate, который по этим шаблонам создавал специализированные функции.
Сейчас дженерики позволяют многое сделать без генерации файлов, но кодогенерация все равно полезна:
- при необходимости избавиться от дженериков (например, для старых версий Go или специфичных окружений);
- для генерации кода с инлайном и ручной оптимизацией;
- для случаев, когда шаблоны легче поддерживать, чем сложные generic-конструкции.
Базовая схема go generate для слайс-генераторов
Давайте разберемся на примере.
Вы хотите сгенерировать набор функций Map, Filter, Reduce для конкретного типа int. Вместо того, чтобы писать руками, вы создаете шаблон и генератор.
Структура проекта:
slice_templates/— папка с шаблонами;slice_templates/int_slice.go.tmpl— файл-шаблон;cmd/slice-generator/main.go— утилита генерации;generated/int_slice_gen.go— результат генерации.
Пример шаблона для генерации кода над слайсом
Содержимое slice_templates/int_slice.go.tmpl:
package generated
// Этот файл сгенерирован автоматически.
// Не редактируйте его вручную.
type {{.TypeName}}Slice []{{.TypeName}}
// Map применяет функцию f ко всем элементам слайса
// и возвращает новый слайс.
func (s {{.TypeName}}Slice) Map(f func(v {{.TypeName}}) {{.TypeName}}) {{.TypeName}}Slice {
res := make({{.TypeName}}Slice, len(s))
for i, v := range s {
res[i] = f(v)
}
return res
}
// Filter фильтрует слайс по предикату pred.
func (s {{.TypeName}}Slice) Filter(pred func(v {{.TypeName}}) bool) {{.TypeName}}Slice {
res := make({{.TypeName}}Slice, 0, len(s))
for _, v := range s {
if pred(v) {
res = append(res, v)
}
}
return res
}
Здесь я размещаю пример, чтобы вам было проще понять:
{{.TypeName}}— подстановочное место, в которое генератор подставит, например,Int.- Из этого шаблона получится тип
IntSliceсо своими методами.
Пример утилиты slice-generator
Теперь сделаем простую утилиту, которая возьмет шаблон и создаст файл. Смотрите, я покажу вам, как это работает:
// cmd/slice-generator/main.go
package main
import (
"log"
"os"
"path/filepath"
"text/template"
)
// Config описывает параметры генерации.
type Config struct {
TypeName string // Например Int или String
GoType string // Соответствующий реальный тип - int, string
}
func main() {
// Здесь мы жестко задаем параметры.
// В реальном проекте их можно читать из флагов или конфигурации.
cfg := Config{
TypeName: "Int",
GoType: "int",
}
// Загружаем шаблон
tmplPath := filepath.Join("slice_templates", "int_slice.go.tmpl")
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
log.Fatalf("parse template: %v", err)
}
// Создаем выходной файл
outPath := filepath.Join("generated", "int_slice_gen.go")
outFile, err := os.Create(outPath)
if err != nil {
log.Fatalf("create output: %v", err)
}
defer outFile.Close()
// Выполняем шаблон с подстановкой данных
if err := tmpl.Execute(outFile, cfg); err != nil {
log.Fatalf("execute template: %v", err)
}
}
Это минимальный slice-generator в смысле «генератор кода для работы со слайсами». Он берет один шаблон, подставляет TypeName и GoType и создает конкретный файл.
Интеграция с go generate
Чтобы запускать этот генератор удобно, добавим комментарий //go:generate в нужный пакет.
Например, в generated/doc.go:
package generated
//go:generate go run ../cmd/slice-generator/main.go
Теперь команда:
go generate ./generated
запустит нашу утилиту и обновит файл int_slice_gen.go.
Практические паттерны для slice-generator
Теперь давайте перейдем к следующему шагу и посмотрим на конкретные паттерны, которые вам пригодятся в реальных задачах.
Паттерн: генерация «ленивого» набора данных в слайс
Иногда вам нужно получить слайс, но источник — это, по сути, поток: чтение файлов, запросы к БД, чтение каналов. Вы хотите инкапсулировать это в генератор.
Простой пример: чтение строк из канала в слайс до закрытия:
// CollectStrings читает все строки из канала ch и возвращает их как слайс.
func CollectStrings(ch <-chan string) []string {
// Начальная емкость можно оценить, если известна
res := make([]string, 0)
for s := range ch {
// Добавляем по мере поступления
res = append(res, s)
}
return res
}
Этот подход можно обобщить:
// Collect собирает значения из канала ch в слайс.
// Функция завершается, когда канал закрывается.
func Collect[T any](ch <-chan T) []T {
res := make([]T, 0)
for v := range ch {
res = append(res, v)
}
return res
}
Такой генератор полезен, когда вы хотите «материализовать» поток данных в слайс, чтобы дальше работать уже с обычным массивом.
Паттерн: chunk-генератор (разбиение на батчи)
Частая задача: разбить большой слайс на части по N элементов (батчи). Это генерация набора слайсов по исходному.
// Chunk разбивает слайс src на части размером size.
// Последний слайс может быть короче.
func Chunk[T any](src []T, size int) [][]T {
if size <= 0 {
return nil
}
// Оцениваем количество чанков
n := (len(src) + size - 1) / size
res := make([][]T, 0, n)
for i := 0; i < len(src); i += size {
end := i + size
if end > len(src) {
end = len(src)
}
// Создаем новый слайс и копируем данные
chunk := make([]T, end-i)
copy(chunk, src[i:end])
res = append(res, chunk)
}
return res
}
Здесь важно, что мы копируем данные в новый слайс, а не делаем срез оригинального. Это делает чанки независимыми: изменения в одном чанке не повлияют на исходный массив. При генерации таких вспомогательных функций вы всегда решаете, хотите ли вы «разделяемую» память или отдельные копии.
Производительность и память при генерации слайсов
Когда вы пишете slice-generator, вы фактически управляете:
- количеством realLOC через
makeиappend; - копированием памяти;
- количеством проходов по данным.
Предварительная емкость
Покажу вам, как это реализовано на практике, когда вы заранее знаете длину:
func GenerateSquares(n int) []int {
if n <= 0 {
return nil
}
// Создаем слайс сразу нужной длины
res := make([]int, n)
for i := 0; i < n; i++ {
res[i] = i * i
}
return res
}
Здесь нет append — мы просто присваиваем по индексу. Это самый предсказуемый вариант по памяти.
Если длину заранее оценить сложно, можно задать начальную емкость:
func GenerateUnknownCount(source []int) []int {
// Предполагаем, что половина значений нам подойдет
res := make([]int, 0, len(source)/2)
for _, v := range source {
if v%3 == 0 {
res = append(res, v)
}
}
return res
}
Избегание лишних копий
Иногда вы можете генерировать слайс, который ссылается на тот же массив, что и входной, без копирования.
Пример: срез по условию подряд идущих элементов (только до первого неподходящего):
// TakeWhile возвращает префикс src, в котором все элементы
// удовлетворяют предикату pred. Возвращаемый слайс разделяет
// базовый массив с src.
func TakeWhile[T any](src []T, pred func(T) bool) []T {
i := 0
for ; i < len(src); i++ {
if !pred(src[i]) {
break
}
}
// Здесь мы возвращаем "слайс от начала до i"
return src[:i]
}
Здесь генерация нового слайса происходит «логически», но физически создается лишь структура слайса, а не новый массив.
В генераторах кода вы можете явно документировать такие места, чтобы разработчики понимали, что изменение результата может повлиять на исходный слайс.
Проектирование собственного slice-generator пакета
Теперь давайте посмотрим, как можно организовать небольшой пакет, который будет выполнять роль универсального генератора операций над слайсами.
Структура пакета
Представим пакет slice, в котором будут:
- функции генерации (
Generate,Range,Repeat); - функции преобразования (
Map,Filter,Chunk); - возможно, обертка-тип с методами для удобной chaining-цепочки.
Примеры функций генерации
package slice
// Range генерирует целочисленный слайс от start до end (не включая end)
// с шагом step.
func Range(start, end, step int) []int {
if step <= 0 || start >= end {
return nil
}
size := (end - start + step - 1) / step
res := make([]int, 0, size)
for v := start; v < end; v += step {
res = append(res, v)
}
return res
}
// Repeat создает слайс длиной n, заполненный значением val.
func Repeat[T any](val T, n int) []T {
if n <= 0 {
return nil
}
res := make([]T, n)
for i := range res {
res[i] = val
}
return res
}
// FromFunc создает слайс длиной n, каждый элемент которого
// вычисляется с помощью функции f.
func FromFunc[T any](n int, f func(i int) T) []T {
if n <= 0 {
return nil
}
res := make([]T, n)
for i := 0; i < n; i++ {
res[i] = f(i)
}
return res
}
Примеры функций преобразования
// Map применяет функцию f к каждому элементу src.
func Map[S ~[]E, E any, R any](src S, f func(E) R) []R {
res := make([]R, len(src))
for i, v := range src {
res[i] = f(v)
}
return res
}
// Filter отбирает элементы по предикату pred.
func Filter[S ~[]E, E any](src S, pred func(E) bool) []E {
res := make([]E, 0, len(src))
for _, v := range src {
if pred(v) {
res = append(res, v)
}
}
return res
}
// Chunk делит слайс на чанки фиксированного размера.
func Chunk[T any](src []T, size int) [][]T {
if size <= 0 {
return nil
}
n := (len(src) + size - 1) / size
res := make([][]T, 0, n)
for i := 0; i < len(src); i += size {
end := i + size
if end > len(src) {
end = len(src)
}
chunk := make([]T, end-i)
copy(chunk, src[i:end])
res = append(res, chunk)
}
return res
}
Обертка для цепочек операций
Если вы хотите более «декларативный» стиль, можно сделать тип-обертку.
// Stream оборачивает слайс и позволяет вызывать цепочки методов.
type Stream[T any] struct {
data []T
}
// From создает Stream из слайса.
func From[T any](data []T) Stream[T] {
return Stream[T]{data: data}
}
// Map создает новый Stream с результатом применения функции f.
func (s Stream[T]) Map(f func(T) T) Stream[T] {
res := make([]T, len(s.data))
for i, v := range s.data {
res[i] = f(v)
}
return Stream[T]{data: res}
}
// Filter создает новый Stream, отфильтрованный по pred.
func (s Stream[T]) Filter(pred func(T) bool) Stream[T] {
res := make([]T, 0, len(s.data))
for _, v := range s.data {
if pred(v) {
res = append(res, v)
}
}
return Stream[T]{data: res}
}
// Collect возвращает внутренний слайс.
func (s Stream[T]) Collect() []T {
return s.data
}
Теперь вы увидите, как это выглядит в коде:
func main() {
src := []int{1, 2, 3, 4, 5, 6}
// Строим цепочку генерации нового слайса
res := From(src).
Filter(func(v int) bool { return v%2 == 0 }). // четные
Map(func(v int) int { return v * 10 }). // умножаем на 10
Collect()
// res == []int{20, 40, 60}
}
С точки зрения пользователя это выглядит как своеобразный slice-generator на уровне API пакета.
Когда использовать дженерики, а когда кодогенерацию
Тема «slice-generator» неизбежно пересекается с выбором: писать generic-функции или генерировать конкретный код.
Кратко:
Используйте дженерики, когда:
- вы поддерживаете свежие версии Go (1.18+);
- вам важна лаконичность и единый код для всех типов;
- производительности generic-вариантов достаточно.
Используйте кодогенерацию, когда:
- вы хотите сильной оптимизации под конкретные типы;
- необходимо встроить методы прямо в типы, а не использовать отдельные функции;
- у вас есть требования к совместимости с более старыми версиями Go;
- сам по себе сгенерированный код должен быть максимально простым для чтения, без generic-ограничений.
Часто хорошим компромиссом становится подход:
- проектировать API сначала как generic-пакет;
- только для «горячих» мест при необходимости включать slice-generator, который делает специализированные версии.
Пример полноценного workflow с slice-generator
Давайте разберемся на примере, который объединит многие идеи из статьи.
Задача:
- Есть доменный тип
Userс полемAge. - Нужно часто:
- фильтровать пользователей по возрасту;
- строить слайс возрастов;
- разбивать по возрастным группам.
Решение:
- Определяем generic-функции
Filter,Map,Chunkв общем пакете. - При необходимости создаем кодогенератор, который делает типизированные методы для
[]User.
Доменный тип
type User struct {
Name string
Age int
}
Использование generic slice-generator
func Adults(users []User) []User {
// Оставляем только пользователей старше или равных 18 лет
return Filter(users, func(u User) bool {
return u.Age >= 18
})
}
func Ages(users []User) []int {
// Преобразуем пользователей в их возраст
return Map(users, func(u User) int {
return u.Age
})
}
func AgeGroups(users []User, groupSize int) [][]User {
// Разбиваем список пользователей на группы по groupSize
return Chunk(users, groupSize)
}
Здесь slice-generator выражается в виде набора generic-функций, а код получается компактным и читаемым.
Если нужна кодогенерация
Если вы хотите вместо Filter(users, ...) писать users.Filter(...), вы делаете шаблон:
type UserSlice []User
func (s UserSlice) FilterAdults() UserSlice {
res := make(UserSlice, 0, len(s))
for _, u := range s {
if u.Age >= 18 {
res = append(res, u)
}
}
return res
}
И генерируете похожие методы по шаблону. Это уже «узкоспециализированный» slice-generator поверх доменной модели.
Заключение
Генерация слайсов в Go — это не одна конкретная функция или библиотека, а набор подходов:
- функции, которые создают и наполняют слайсы по заданным правилам;
- обобщенные операции
Map,Filter,Chunk, построенные поверх слайсов; - кодогенерация, когда вы из шаблонов собираете типизированные функции и методы работы со слайсами;
- утилиты уровня
go generateи собственные инструменты slice-generator, которые автоматизируют создание однотипного кода.
Важные моменты, которые стоит держать в голове, когда вы проектируете свои slice-generators:
- аккуратно работать с
lenиcap, чтобы минимизировать realLOC; - явно решать, копируете ли вы данные или делитесь базовым массивом;
- продумывать API так, чтобы он был простым: или через функции, или через обертки-структуры;
- использовать дженерики там, где это возможно, а кодогенерацию — там, где она реально дает выигрыш.
Если вы системно выстроите свой подход к генерации слайсов, то работа с коллекциями в Go станет более предсказуемой, а код — чище и проще для сопровождения.
Частозадаваемые технические вопросы по теме и ответы
1. Как избежать лишних realLOC при генерации слайсов неизвестной длины
Если длина итогового слайса заранее неизвестна, используйте несколько шагов:
- Сначала прикиньте приближенный верхний предел и задайте емкость через
make([]T, 0, estimatedCap). - Если оценка сильно неточная, можно:
- сначала собрать данные в буфер с грубой оценкой;
- после заполнения сделать
res = append([]T(nil), res...), чтобы обрезать лишнюю емкость, если это важно.
Главное — не создавать слайс с очень маленькой емкостью, если точно ожидается большое количество элементов.
2. Как сделать slice-generator, который не аллоцирует лишний раз при каждом вызове
Можно передавать слайс-буфер снаружи и заполнять его:
func GenerateInto(buf []int, n int) []int {
if cap(buf) < n {
buf = make([]int, 0, n)
} else {
buf = buf[:0]
}
for i := 0; i < n; i++ {
buf = append(buf, i*i)
}
return buf
}
Вы повторно используете один и тот же буфер между вызовами, уменьшая количество realLOC.
3. Как правильно организовать несколько шаблонов для разных типов в одном slice-generator
Создайте один шаблон с параметрами TypeName и GoType, а набор конфигураций ([]Config) обойдите циклом, вызывая tmpl.Execute несколько раз в разные файлы. В go generate можно либо:
- запускать генератор один раз, который создает все файлы;
- или использовать несколько
//go:generateс разными флагами.
Главное — не смешивать разные типы в одном сгенерированном файле, если это затрудняет чтение.
4. Как тестировать сгенерированный код для слайсов
Подход:
- Пишите тесты для шаблонов на уровне абстракции: создайте «эталонную» ручную реализацию и сравнивайте поведение.
- Для конкретных сгенерированных файлов добавляйте обычные тесты в тех же пакетах.
- В CI обязательно запускайте
go generateи проверяйте, что репозиторий чист после генерации, чтобы не забывать обновлять код.
5. Как совмещать дженерики и кодогенерацию в одном проекте
Один из рабочих подходов:
- Сначала пишете generic-версию функций работы со слайсами.
- Для узкого набора «горячих» типов делаете небольшой генератор, который вызывает generic-функции из сгенерированных оберток или инлайнит логику.
- В доменном коде используете типизированные обертки, а в менее критичных местах — generic-функции напрямую.