Олег Марков
Слайс либы lib-slice - удобные утилиты для работы со срезами в Go
Введение
Срезы в Go дают большую гибкость, но при этом часто заставляют писать много однотипного кода: циклы, фильтрация, поиск элементов, преобразования типов, безопасное изменение коллекции. Каждый раз вы снова и снова реализуете одно и то же, только с разными типами.
Слайс либа lib-slice (далее просто lib-slice) решает эту проблему. Она предоставляет набор готовых утилит для работы со срезами, опираясь на дженерики Go. В результате вы получаете:
- менее шаблонный код;
- меньше ошибок в циклах и индексах;
- более читаемые функции бизнес-логики.
Давайте разберем, что именно предлагает lib-slice, как она устроена, какие функции есть “из коробки” и как безопасно использовать её в реальных проектах.
Обзор и принципы lib-slice
Что такое lib-slice
lib-slice — это небольшая утилитная библиотека для Go, которая добавляет к стандартным срезам:
- функции обхода и преобразования (
Map,Filter,Reduce,ForEach); - операции поиска (
Find,Contains,IndexOf,LastIndexOf); - операции модификации (
AppendIf,Unique,DeleteAt,DeleteFunc); - удобные конвертации и вспомогательные функции (
Chunk,GroupBy,Keys,Valuesи т.п. — в зависимости от конкретной реализации).
Главная идея — не заменять стандартные срезы собственной оберткой, а дать вам набор чистых функций, которые принимают и возвращают обычные Go-срезы.
Зачем нужна отдельная “слайс либа”
Смотрите, обычно код без lib-slice выглядит так:
// Фильтрация четных чисел без lib-slice
func filterEven(nums []int) []int {
res := make([]int, 0, len(nums))
for _, n := range nums {
if n%2 == 0 { // условие фильтрации
res = append(res, n) // добавляем подходящее значение
}
}
return res
}
С lib-slice та же логика может выглядеть так:
// Допустим, lib-slice лежит в пакете slice
import "github.com/your-org/lib-slice/slice"
func filterEven(nums []int) []int {
// Здесь мы передаем функцию-фильтр как параметр
return slice.Filter(nums, func(n int) bool {
return n%2 == 0
})
}
Код становится короче и понятнее: вы сразу видите, что происходит именно фильтрация, а детали реализации скрыты в библиотеке.
Основные принципы дизайна
Обычно lib-slice строится вокруг нескольких базовых идей:
Функциональный стиль поверх обычных срезов
Никаких собственных типов, ваших[]Tникто не забирает.Дженерики и строгая типизация
Каждая функция имеет видfunc[T any] ..., вы получаете проверку типов на этапе компиляции.Предсказуемая работа с памятью
- при необходимости создаются новые срезы;
- часто учитывается исходная длина/емкость для уменьшения аллокаций;
- мутабельные операции стараются быть очевидными.
Минимум магии
Внутри те же циклы и проверки, которые вы бы писали руками, только протестированные и переиспользуемые.
Базовые операции обхода и преобразования
Map — преобразование элементов среза
Функция Map применяет переданную функцию к каждому элементу и возвращает новый срез.
// Пример сигнатуры
func Map[T any, R any](in []T, fn func(T) R) []R
Давайте разберемся на примере.
package main
import (
"fmt"
"github.com/your-org/lib-slice/slice"
)
func main() {
nums := []int{1, 2, 3}
// Здесь мы превращаем []int в []string
strs := slice.Map(nums, func(n int) string {
// Комментарий поясняет, что мы делаем с элементом
return fmt.Sprintf("num=%d", n)
})
fmt.Println(strs)
// Ожидаемый вывод:
// [num=1 num=2 num=3]
}
Особенности, на которые стоит обратить внимание:
Mapне изменяет исходный срез;- тип результата может отличаться от типа входа;
- внутри обычно заранее выделяется срез нужной длины, чтобы не делать лишние
append.
Filter — выборка по условию
Filter отбирает только те элементы, для которых условие возвращает true.
// Пример сигнатуры
func Filter[T any](in []T, pred func(T) bool) []T
Теперь вы увидите, как это выглядит в коде:
// Фильтрация строк: оставляем только непустые
names := []string{"Ann", "", "Bob", "", "Kate"}
// Здесь мы создаем новый срез, исходный не меняется
nonEmpty := slice.Filter(names, func(s string) bool {
// Условие фильтрации - строка не пустая
return s != ""
})
Задачи, где Filter особенно удобен:
- отбор валидных сущностей перед сохранением в БД;
- фильтрация по статусу (
active,archived); - удаление
nilили пустых значений из структур.
Reduce — свертка среза к одному значению
Reduce последовательно “сворачивает” срез к одному значению, накапливая результат.
// Пример сигнатуры
func Reduce[T any, R any](in []T, init R, fn func(R, T) R) R
Покажу вам, как это реализовано на практике:
// Сумма чисел с помощью Reduce
nums := []int{1, 2, 3, 4}
// init = 0 - стартовое значение аккумулятора
sum := slice.Reduce(nums, 0, func(acc, n int) int {
// Здесь мы добавляем текущее число к аккумулятору
return acc + n
})
// sum == 10
Другой пример — конкатенация строк:
names := []string{"Ann", "Bob", "Kate"}
result := slice.Reduce(names, "", func(acc, name string) string {
if acc == "" {
return name
}
// Аккуратно добавляем разделитель между элементами
return acc + ", " + name
})
// result == "Ann, Bob, Kate"
ForEach — простой обход без создания новых срезов
ForEach выполняет функцию для каждого элемента, не возвращая результата.
// Пример сигнатуры
func ForEach[T any](in []T, fn func(T))
Давайте посмотрим, что происходит в следующем примере:
logs := []string{"start", "processing", "done"}
// Здесь мы просто печатаем элементы без изменения среза
slice.ForEach(logs, func(s string) {
fmt.Println("log:", s)
})
Эта функция особенно полезна, когда вам нужно:
- записать элементы в лог;
- отправить их по сети;
- провести какие-то побочные действия (side effects), не строя новый срез.
Поиск и проверка наличия
Contains — проверка, есть ли элемент в срезе
Contains отвечает на простой вопрос: “Содержит ли срез данный элемент?”.
// Пример сигнатуры
func Contains[T comparable](in []T, value T) bool
Важно, что здесь используется comparable, а не any: это значит, что тип должен поддерживать сравнение через ==.
Пример:
ids := []int{10, 20, 30}
// Проверяем, есть ли в срезе значение 20
has20 := slice.Contains(ids, 20) // true
has40 := slice.Contains(ids, 40) // false
Find — поиск первого подходящего элемента
Когда необходимо найти не просто конкретное значение, а элемент по условию, используется Find.
// Пример сигнатуры
func Find[T any](in []T, pred func(T) bool) (T, bool)
Функция возвращает:
- найденный элемент;
- флаг
ok, который показывает, был ли элемент найден.
Давайте разберемся на примере:
type User struct {
ID int
Name string
}
users := []User{
{ID: 1, Name: "Ann"},
{ID: 2, Name: "Bob"},
}
// Ищем пользователя по ID
u, ok := slice.Find(users, func(u User) bool {
// Условие поиска - нужный ID
return u.ID == 2
})
if ok {
fmt.Println("found user:", u.Name)
} else {
fmt.Println("user not found")
}
IndexOf и LastIndexOf — где именно находится элемент
Вместо полного перебора вручную можно использовать функции IndexOf и LastIndexOf:
// Возможные сигнатуры
func IndexOf[T comparable](in []T, value T) int
func LastIndexOf[T comparable](in []T, value T) int
Обычно:
- при отсутствии элемента возвращается
-1; - поиск идет слева направо (
IndexOf) или справа налево (LastIndexOf).
Пример:
nums := []int{1, 2, 3, 2, 1}
first := slice.IndexOf(nums, 2) // 1
last := slice.LastIndexOf(nums, 2) // 3
none := slice.IndexOf(nums, 5) // -1
Модификация срезов и безопасность
AppendIf — добавление при выполнении условия
Иногда нужно добавить элемент в срез только если выполнено условие. Вместо ручной проверки можно использовать AppendIf.
// Возможная сигнатура
func AppendIf[T any](in []T, value T, cond bool) []T
Теперь давайте перейдем к примеру:
nums := []int{1, 2}
// Условие истинно - элемент будет добавлен
nums = slice.AppendIf(nums, 3, 3 > 2)
// Условие ложно - элемент не добавится
nums = slice.AppendIf(nums, 4, 4 < 0)
// В итоге nums == []int{1, 2, 3}
В некоторых реализациях могут быть расширенные варианты:
AppendIfFunc— когда условие задается функцией;AppendUnique— добавить, только если элемента еще нет.
DeleteAt — удаление по индексу
Удалять элемент по индексу руками несложно, но код получается шаблонным:
// Стандартный код без lib-slice
func deleteAt[T any](in []T, i int) []T {
return append(in[:i], in[i+1:]...)
}
lib-slice часто предлагает готовую функцию DeleteAt:
// Возможная сигнатура
func DeleteAt[T any](in []T, index int) []T
Обратите внимание, как этот фрагмент кода решает задачу:
nums := []int{10, 20, 30, 40}
// Удаляем элемент с индексом 1 (значение 20)
nums = slice.DeleteAt(nums, 1)
// Теперь nums == []int{10, 30, 40}
Хорошо, когда библиотека дополнительно:
- проверяет индекс на выход за границы;
- определяет, нужно ли создавать новый срез или можно переиспользовать старый.
DeleteFunc — удаление по условию
Иногда хочется удалить все элементы, которые удовлетворяют какому-то предикату:
// Возможная сигнатура
func DeleteFunc[T any](in []T, pred func(T) bool) []T
Пример:
nums := []int{1, -2, 3, -4, 5}
// Удаляем все отрицательные числа
nums = slice.DeleteFunc(nums, func(n int) bool {
// Если возвращаем true - элемент будет удален
return n < 0
})
// Теперь nums == []int{1, 3, 5}
Это более “говорящий” код, чем ручная фильтрация через цикл.
Unique — удаление дубликатов
Одна из частых задач — сделать список уникальных значений. Часто lib-slice содержит функцию Unique:
// Возможная сигнатура
func Unique[T comparable](in []T) []T
Посмотрим на практический пример:
nums := []int{1, 2, 2, 3, 3, 3}
// Здесь мы получаем срез с уникальными значениями
unique := slice.Unique(nums)
// unique == []int{1, 2, 3} (порядок обычно сохраняется)
Обычно внутри используется map[T]struct{} для отслеживания уже встреченных значений.
Работа с группировками и “нарезкой” срезов
Chunk — разбиение на подмассивы фиксированного размера
Функция Chunk помогает разбить один большой срез на несколько меньших.
// Возможная сигнатура
func Chunk[T any](in []T, size int) [][]T
Давайте разберемся на примере:
nums := []int{1, 2, 3, 4, 5}
// Разбиваем на чанки размером 2
chunks := slice.Chunk(nums, 2)
// Ожидаемый результат:
// [][]int{
// {1, 2},
// {3, 4},
// {5},
// }
Такая функция полезна, когда нужно:
- отправлять данные “пакетами” (batch) в БД;
- ограничивать размер одной операции (например, при работе с API).
Важно понимать, как lib-slice ведет себя внутри:
- часто создаются новые срезы для чанков;
- иногда чанки могут ссылаться на исходный массив (через срезы
in[i:j]), если это устраивает контракт библиотеки.
GroupBy — группировка по ключу
GroupBy собирает элементы в группы по какому-то ключу.
// Возможная сигнатура
func GroupBy[T any, K comparable](in []T, keyFn func(T) K) map[K][]T
Покажу вам, как это реализовано на практике:
type Order struct {
ID int
Status string
}
orders := []Order{
{ID: 1, Status: "new"},
{ID: 2, Status: "paid"},
{ID: 3, Status: "new"},
}
// Группируем заказы по статусу
byStatus := slice.GroupBy(orders, func(o Order) string {
// В качестве ключа берем поле Status
return o.Status
})
// Теперь byStatus["new"] содержит заказы с ID 1 и 3
// а byStatus["paid"] - заказ с ID 2
Здесь вы получаете сразу map[Status][]Order, что сильно упрощает дальнейшую обработку.
Преобразование типов и вспомогательные утилиты
MapTo — преобразование с явным указанием типа результата
Иногда удобно разделять “обычный” Map и специальные функции преобразования. В некоторых вариантах lib-slice есть функции вроде MapToInt, MapToString и т.д. Но более универсальный вариант — дженериковый Map, о котором мы уже говорили.
Дополнительные варианты, которые часто встречаются:
ToPtrs— превращает[]Tв[]*T;Deref— превращает[]*Tв[]Tс учетомnil.
Давайте разберемся на примере с указателями.
// Превращаем значения в указатели
vals := []int{1, 2, 3}
ptrs := slice.Map(vals, func(v int) *int {
// Создаем переменную и возвращаем на нее указатель
vv := v
return &vv
})
// Теперь ptrs - это []*int
И обратное преобразование:
// Здесь мы создаем новый срез значений, игнорируя nil
vals2 := slice.Filter(
slice.Map(ptrs, func(p *int) (int, bool) {
if p == nil {
// В этом случае нам понадобится немного
// другая вспомогательная функция, если она есть в lib-slice
}
return *p, true
}),
func(v int) bool { return true },
)
Конкретные названия функций могут отличаться, но подход примерно один: вы комбинируете базовые кирпичики (Map, Filter) или используете более специализированные средства lib-slice, если они есть.
Производительность и работа с памятью
Когда lib-slice полезна, а когда лучше обойтись без нее
Lib-slice позволяет очень сильно упростить код бизнес-логики. Но важно понимать цену:
- любая дополнительная абстракция — это лишний вызов функции;
- дженерики работают эффективно, но по-прежнему могут иметь небольшие накладные расходы по сравнению с “плоским” циклом в некоторых случаях.
Где lib-slice особенно полезна:
- в коде, где важнее читаемость, чем предельная оптимизация;
- в слоях бизнес-логики, обработке данных, маппинге DTO <-> доменные модели;
- в тестах и вспомогательных утилитах.
Где стоит подумать, прежде чем использовать:
- в горячих участках (hot path), вызываемых миллионы раз в секунду;
- в низкоуровневых библиотеках, где важна каждая наносекунда.
Минимизация аллокаций
Многие функции lib-slice спроектированы так, чтобы:
- заранее знать длину результатов (
Map,Chunkпри точном размере); - по возможности не создавать лишних срезов.
Например, Map может быть реализован так:
func Map[T any, R any](in []T, fn func(T) R) []R {
// Здесь мы сразу создаем срез нужной длины,
// чтобы избежать динамического роста через append
out := make([]R, len(in))
for i, v := range in {
out[i] = fn(v)
}
return out
}
Filter, наоборот, не знает заранее, сколько элементов пройдет фильтр, поэтому часто делает:
- либо один
make(..., 0, len(in))(с запасом емкости); - либо более сложную стратегию, чтобы уменьшить перераспределения.
Работа с мутабельными и иммутабельными операциями
Важно понимать, какие функции:
- изменяют переданный срез (мутабельные);
- возвращают новый срез, не трогая старый (иммутабельные).
На практике большинство функций вроде Map, Filter, Unique, Chunk — иммутабельные: они создают новый срез. Это снижает риск неожиданных побочных эффектов.
Функции вида DeleteAt могут:
- либо возвращать новый срез (чаще всего так и есть);
- либо работать “на месте”, если так указано в документации.
Лучше всегда смотреть документацию lib-slice и придерживаться следующего стиля:
// Явно переопределяем переменную, чтобы не было двусмысленности
nums = slice.DeleteAt(nums, 2)
nums = slice.Unique(nums)
Примеры интеграции lib-slice в реальный код
Обработка HTTP-запроса с помощью lib-slice
Представим, что у вас есть API, которое принимает список ID и возвращает только активные сущности.
type Item struct {
ID int
Active bool
}
func filterActive(items []Item) []Item {
// Смотрите, я покажу вам, как с помощью Filter
// оставить только активные элементы
return slice.Filter(items, func(it Item) bool {
// Условие - флаг Active должен быть true
return it.Active
})
}
Дальше вы можете добавить преобразование:
type ItemDTO struct {
ID int `json:"id"`
}
func toDTO(items []Item) []ItemDTO {
// Здесь мы размещаем пример Map, чтобы было проще понять
return slice.Map(items, func(it Item) ItemDTO {
// Преобразуем доменную модель в DTO
return ItemDTO{ID: it.ID}
})
}
И итоговую сборку в хэндлере:
func handleItems(w http.ResponseWriter, r *http.Request) {
// Допустим, items вы получили из базы данных
items := loadItemsFromDB()
active := filterActive(items)
dtos := toDTO(active)
// Теперь просто отправляем dtos клиенту в JSON
// (здесь мы опускаем детали маршалинга и обработки ошибок)
}
Читая такую функцию, вы видите не детали циклов, а цепочку бизнес-операций.
Валидация и накопление ошибок
Допустим, вы валидируете список входных сущностей и хотите:
- собрать все ошибки;
- отбросить невалидные сущности.
lib-slice помогает сократить шаблонный код:
type Input struct {
Value int
}
type Validated struct {
Value int
}
func validateInput(in Input) (Validated, error) {
if in.Value < 0 {
// Сообщаем об ошибке, если условие нарушено
return Validated{}, fmt.Errorf("value must be non-negative")
}
return Validated{Value: in.Value}, nil
}
func validateAll(inputs []Input) ([]Validated, []error) {
// Сначала превращаем []Input в []результатов валидации
type res struct {
v Validated
err error
}
results := slice.Map(inputs, func(in Input) res {
// Проверяем каждый элемент
v, err := validateInput(in)
return res{v: v, err: err}
})
// Теперь разделяем успешные и ошибочные элементы
valids := make([]Validated, 0, len(results))
errs := make([]error, 0)
slice.ForEach(results, func(r res) {
if r.err != nil {
// Если есть ошибка - добавляем ее в список ошибок
errs = append(errs, r.err)
return
}
// Если все хорошо - добавляем валидированное значение
valids = append(valids, r.v)
})
return valids, errs
}
Такой подход делает структуру обработки ошибок более прозрачной и тестируемой.
Заключение
lib-slice — это набор утилит поверх стандартных срезов Go, который:
- уменьшает объем шаблонного кода;
- делает операции над коллекциями более выразительными;
- использует дженерики и строгую типизацию.
Вы можете комбинировать базовые функции (Map, Filter, Reduce, ForEach, Find, Contains, DeleteAt, DeleteFunc, Unique, Chunk, GroupBy) для решения почти любых задач обработки срезов.
Важно помнить о балансе:
- в бизнес-логике и обработке данных lib-slice заметно упрощает код;
- в горячих участках, где важна максимальная производительность, возможно, лучше остаться при классических циклах.
При грамотном использовании lib-slice помогает писать более чистый и поддерживаемый код, при этом оставаясь полностью совместимой со стандартной моделью срезов в Go.
Частозадаваемые технические вопросы по lib-slice
1. Можно ли использовать lib-slice для конкурентной обработки элементов
Да, но аккуратно. Сама lib-slice обычно не запускает горутины внутри. Вы можете обернуть вызовы в свою конкурентную логику:
- Разбейте срез на чанки через
Chunk. - Для каждого чанка запустите горутину, которая применяет
MapилиFilter. - Соберите результаты в один срез (убедитесь, что делаете это под мьютексом или заранее резервируете позиции).
Важно не модифицировать один и тот же срез одновременно из нескольких горутин.
2. Как избежать лишних аллокаций при использовании Map и Filter в цепочке
Есть два подхода:
- Разбивать цепочку:
- сначала
Filter, затемMap(или наоборот); - каждый шаг сохранять в переменную, чтобы компилятор мог оптимизировать.
- сначала
- Если производительность критична, подумайте о написании одного “ручного” цикла, который сразу и фильтрует, и мапит. В остальных случаях накладные расходы lib-slice, как правило, приемлемы.
3. Можно ли использовать lib-slice с кастомными типами, которые не comparable
Да. Большинство функций (например, Map, Filter, ForEach, Find) работают с any и не требуют comparable. Ограничение comparable нужно только для функций, где используется == (Contains, IndexOf, Unique и т.п.). Для сложных типов используйте функции с предикатами (Find с func(T) bool и т.д.).
4. Как правильно тестировать код, который опирается на lib-slice
Подход простой:
- Тестируйте свою бизнес-логику как “черный ящик” — подавайте срезы на вход, проверяйте срезы на выходе.
- Не нужно мокать lib-slice — это обычные функции, их можно считать доверенной зависимостью.
- Если вы используете сложные цепочки (
Map→Filter→GroupBy), разбейте код на несколько маленьких функций и тестируйте каждую по отдельности.
5. Что делать, если нужна функция, которой еще нет в lib-slice
Есть два варианта:
- Собрать нужное поведение из уже имеющихся примитивов (
Map,Filter,Reduce,GroupBy,Chunk). - Написать свою утилитную функцию по аналогии с существующими:
- сделать ее дженерик-функцией
func[T any] ...; - следовать тем же принципам работы с памятью (минимум аллокаций, понятное поведение);
- при необходимости внести ее обратно в lib-slice как contribution, если это open source библиотека.
- сделать ее дженерик-функцией