Олег Марков
Слайс модели в Go - работа с model-slice
Введение
Слайс модели (часто его называют model-slice) — это типичная конструкция, с которой вы будете сталкиваться практически в каждом Go-проекте, где есть работа с данными: БД, внешние API, кэш или бизнес-логика.
Под слайсом модели обычно понимают срез структур одного типа. Например, список пользователей, список заказов или список товаров. В коде это выглядит как []User, []Order, []Product и так далее. По сути, это обычный слайс Go, но вокруг него возникает целый набор типичных задач:
- как объявлять и инициализировать слайсы моделей;
- как безопасно их изменять и передавать между функциями;
- как использовать
appendи управлять емкостью, чтобы избежать лишних аллокаций; - как маппить данные из БД в слайс моделей;
- как фильтровать, сортировать и преобразовывать слайс моделей;
- как избежать типичных ловушек с указателями и копированием.
Здесь вы увидите, как это выглядит на практике, и получите набор шаблонов, которые можно переносить в свои проекты.
Что такое слайс модели и зачем он нужен
Базовое определение
Смотрите, давайте начнем с простого примера модели:
// User описывает пользователя в системе
type User struct {
ID int64 // Идентификатор пользователя
Name string // Имя пользователя
Email string // Адрес электронной почты
}
// UserSlice - слайс модели User
type UserSlice []User
По сути, UserSlice — это alias для []User. Вы можете вообще не вводить отдельный тип и использовать просто []User, но отдельный тип часто помогает:
- сгруппировать методы, связанные со слайсом модели;
- сделать код более самодокументируемым;
- избежать путаницы, если есть несколько разных слайсов с одинаковым базовым типом.
Структура слайса в Go
Чтобы увереннее работать с model-slice, полезно понимать, как устроен слайс в Go внутри. Упрощенно слайс состоит из:
- указателя на массив (где реально лежат данные);
- длины (len);
- емкости (cap).
Это важно, потому что:
- при
appendможет произойти перераспределение массива; - разные слайсы могут ссылаться на один и тот же базовый массив;
- операции среза (
a[low:high]) не копируют данные, а создают "окно" поверх того же массива.
Давайте разберемся на примере:
users := []User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
}
// Здесь мы создаем срез от исходного слайса
activeUsers := users[:1]
// Меняем имя в activeUsers
activeUsers[0].Name = "Alice Updated"
// Изменение видно и в исходном слайсе, так как данные общие
fmt.Println(users[0].Name) // Выведет: Alice Updated
// Здесь обе переменные users и activeUsers указывают на один и тот же массив,
// поэтому изменение через один слайс видно в другом.
Это ключевой момент, который сильно влияет на работу со слайсом модели: вы часто фактически делитесь одними и теми же данными между разными частями кода.
Объявление и инициализация model-slice
Простое объявление
Чаще всего слайс модели объявляют так:
var users []User // Объявлен, но нулевой (nil) слайс
или сразу инициализируют:
users := []User{
{ID: 1, Name: "Alice"},
{ID: 2, Name: "Bob"},
}
// В первом случае у вас nil-слайс без данных. // Во втором - слайс длиной 2 с инициализированными элементами.
Nil-слайс и пустой слайс — важное различие:
var a []User // nil-слайс
b := []User{} // пустой, но не nil
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
// Это может быть важно при маршалинге в JSON, проверках на отсутствие данных, // и при работе с ORM/драйверами.
Инициализация через make
Если вы заранее знаете ожидаемый размер, удобно использовать make:
// Создаем слайс на 0 элементов, но с емкостью 100
users := make([]User, 0, 100)
// Здесь мы резервируем место под 100 пользователей
// Это уменьшит количество аллокаций при дальнейшем append
Такой подход полезен, если:
- вы читаете данные батчами;
- заранее знаете приблизительный размер выборки из БД;
- строите слайс в цикле.
Собственный тип для model-slice
Вводя собственный тип, вы получаете возможность "прикрутить" к нему методы:
type UserSlice []User
// FilterActive возвращает только активных пользователей
func (s UserSlice) FilterActive() UserSlice {
// Здесь мы создаем новый слайс для результата
out := make(UserSlice, 0, len(s))
for _, u := range s {
if u.IsActive() {
// Добавляем пользователя, если он активен
out = append(out, u)
}
}
return out
}
// Теперь вы можете писать: // users := UserSlice{...} // active := users.FilterActive()
Такой подход делает работу со слайсом модели более выразительной и структурированной.
Использование append с model-slice
Базовый сценарий
append — главный инструмент работы со слайсом модели. Давайте посмотрим, как он используется:
var users []User
// Добавляем одного пользователя
users = append(users, User{ID: 1, Name: "Alice"})
// Добавляем сразу несколько
users = append(users,
User{ID: 2, Name: "Bob"},
User{ID: 3, Name: "Charlie"},
)
// Важно - результат append всегда нужно присваивать обратно слайсу, // потому что при расширении может выделиться новый массив.
Обратите внимание: если вы забыли сделать присваивание, изменения могут потеряться.
Добавление одного слайса моделей в другой
Частый сценарий — объединить два слайда моделей:
var allUsers []User
admins := []User{{ID: 1, Name: "Admin"}}
guests := []User{{ID: 2, Name: "Guest"}}
// Здесь мы добавляем элементы слайса admins в конец allUsers
allUsers = append(allUsers, admins...)
// А потом добавляем гостей
allUsers = append(allUsers, guests...)
// Оператор ... раскрывает слайс поэлементно для append.
Управление емкостью и аллокациями
При работе с большими слайсами моделей важно избегать лишних аллокаций. Давайте посмотрим, что происходит при росте слайса:
users := make([]User, 0, 2) // capacity = 2
users = append(users, User{ID: 1})
users = append(users, User{ID: 2})
// Здесь все еще используется один массив
users = append(users, User{ID: 3})
// Здесь, скорее всего, произойдет перераспределение памяти
// После третьего append Go создаст новый массив большей емкости и скопирует туда данные.
Если вы заранее знаете максимальный размер, лучше заложить емкость с запасом:
expectedCount := 1000
users := make([]User, 0, expectedCount)
Это особенно полезно при:
- загрузке данных из БД;
- парсинге больших JSON/XML;
- обработке больших файлов.
Модификация элементов в процессе append
Иногда вы хотите модифицировать элемент перед добавлением. Покажу вам, как это реализовано на практике:
rawIDs := []int64{1, 2, 3}
users := make([]User, 0, len(rawIDs))
for _, id := range rawIDs {
u := User{
ID: id,
Name: fmt.Sprintf("User %d", id),
}
// Добавляем подготовленную модель в слайс
users = append(users, u)
}
// Здесь мы создаем User на каждой итерации, заполняем его и затем добавляем в слайс.
Главное — не хранить указатель на переменную цикла u между итерациями, если вы меняете ее в следующем шаге. Иначе все элементы могут указывать на одну и ту же область памяти. Для значимых типов (struct без указателя) это безопасно, но для указателей стоит быть внимательнее.
Model-slice и работа с базой данных
Пример с database/sql
Один из типичных случаев использования слайса модели — чтение данных из БД:
// User модель для БД
type User struct {
ID int64
Name string
Email string
}
// LoadUsers загружает пользователей из БД и возвращает слайс моделей
func LoadUsers(ctx context.Context, db *sql.DB) ([]User, error) {
// Выполняем запрос
rows, err := db.QueryContext(ctx, `
SELECT id, name, email
FROM users
WHERE deleted_at IS NULL
`)
if err != nil {
return nil, err
}
defer rows.Close() // Не забываем закрывать rows
users := make([]User, 0, 100) // Предположим, ожидаем до 100 пользователей
for rows.Next() {
var u User
// Сканируем данные строки в структуру
if err := rows.Scan(&u.ID, &u.Name, &u.Email); err != nil {
return nil, err
}
// Добавляем модель в слайс
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, err
}
return users, nil
}
// Здесь слайс моделей используется как контейнер для результатов SQL-запроса.
В этом коде вы видите типичный шаблон:
- Создаем слайс моделей с предварительной емкостью.
- В цикле создаем модель и сканируем значения из
rows. - Через
appendзаполняем слайс.
Пример с ORM (GORM)
Если вы используете ORM (например, GORM), работа с model-slice еще более прямолинейна:
type User struct {
ID uint
Name string
Email string
}
// LoadActiveUsers загружает активных пользователей через GORM
func LoadActiveUsers(db *gorm.DB) ([]User, error) {
var users []User
// Передаем указатель на слайс моделей в ORM
if err := db.
Where("deleted_at IS NULL").
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// ORM сама заполняет слайс моделей, создавая экземпляры User для каждой строки.
В таких случаях важно помнить:
- ORM ожидает указатель на слайс (
&users), чтобы его заполнять; - многие ORM корректно работают как с nil-слайсом, так и с пустым.
Обработка, фильтрация и преобразование model-slice
Фильтрация слайса модели
Допустим, у модели User есть поле Active. Давайте посмотрим, как можно отфильтровать только активных пользователей:
// User с признаком активности
type User struct {
ID int64
Name string
Active bool
}
// FilterActive возвращает новый слайс только с активными пользователями
func FilterActive(users []User) []User {
out := make([]User, 0, len(users))
for _, u := range users {
if u.Active {
// Добавляем в результат только активных
out = append(out, u)
}
}
return out
}
// Мы создаем новый слайс, не модифицируя исходный. Это безопаснее и предсказуемее.
Такой подход (создание нового слайса) упрощает понимание кода: вы точно знаете, что исходный набор данных не изменяется.
Проекция model-slice в другой тип
Частая задача — из слайса моделей получить слайс DTO или только часть данных:
// UserDTO - облегченная модель для ответа API
type UserDTO struct {
ID int64
Name string
}
// MapToDTO преобразует слайс User в слайс UserDTO
func MapToDTO(users []User) []UserDTO {
dtos := make([]UserDTO, 0, len(users))
for _, u := range users {
dto := UserDTO{
ID: u.ID,
Name: u.Name,
}
dtos = append(dtos, dto)
}
return dtos
}
// Здесь мы явно копируем нужные поля из модели в DTO и формируем новый слайс.
Это типичная операция на границе слоев: из слоя данных в слой API.
Поиск элементов в слайсе моделей
Типичные утилиты — найти элемент по ID или по какому-то полю:
// FindByID ищет пользователя по ID и возвращает указатель на него
func FindByID(users []User, id int64) *User {
for i := range users {
if users[i].ID == id {
// Возвращаем адрес элемента внутри слайса
return &users[i]
}
}
return nil
}
// Обратите внимание - мы берем адрес по индексу i, а не по переменной цикла. // Это важно, чтобы не получить адрес временной копии.
Теперь вы увидите, как это выглядит в использовании:
users := []User{{ID: 1}, {ID: 2}}
u := FindByID(users, 2)
if u != nil {
// Меняем поле прямо в слайсе
u.Name = "Updated Name"
}
// Изменение отражается в исходном слайсе, так как мы работаем с указателем на его элемент.
Сортировка слайса модели
Для сортировки удобно использовать пакет sort:
// SortUsersByName сортирует пользователей по имени
func SortUsersByName(users []User) {
sort.Slice(users, func(i, j int) bool {
return users[i].Name < users[j].Name
})
}
// Функция сортирует слайс "на месте" - без создания копии.
Если важно не менять исходный порядок, можно сначала сделать копию:
func SortedUsersByName(users []User) []User {
out := make([]User, len(users))
copy(out, users) // Копируем данные в новый слайс
sort.Slice(out, func(i, j int) bool {
return out[i].Name < out[j].Name
})
return out
}
// Теперь исходный слайс остается нетронутым.
Передача model-slice между функциями и слоями
Передача по значению и "по ссылке"
Слайсы в Go передаются по значению, но при этом содержат указатель на массив. Давайте посмотрим на эффект:
// AddUser добавляет пользователя в слайс (не сработает как ожидается)
func AddUser(users []User, u User) {
users = append(users, u)
// Здесь мы изменили только локальную копию переменной users
}
// AddUserPtr принимает указатель на слайс и изменяет его "снаружи"
func AddUserPtr(users *[]User, u User) {
*users = append(*users, u)
}
Теперь давайте разберемся на примере:
users := []User{}
AddUser(users, User{ID: 1})
// Здесь users все еще пустой, так как append был на копии
AddUserPtr(&users, User{ID: 2})
// Теперь в users будет один элемент
// Если вам нужно, чтобы функция могла изменить сам слайс (например, его длину или емкость), // передавайте указатель на слайс.
Чаще всего достаточно возвращать новый слайс из функции:
func AddUserReturn(users []User, u User) []User {
users = append(users, u)
return users
}
// Такой стиль более идиоматичен - вызывающий код явно видит, что нужно использовать результат.
Иммутабельный стиль
Для упрощения рассуждений о коде иногда используют "иммутабельный" подход: не изменять существующий слайс, а создавать новый. Например:
// WithoutUser возвращает новый слайс без пользователя с заданным ID
func WithoutUser(users []User, id int64) []User {
out := make([]User, 0, len(users))
for _, u := range users {
if u.ID == id {
// Пропускаем этого пользователя
continue
}
out = append(out, u)
}
return out
}
// Исходный слайс не меняется. Это упрощает отладку и снижает риск неожиданных побочных эффектов.
Типичные ошибки и подводные камни при работе с model-slice
Ошибка с указателем на переменную цикла
Очень частая проблема — сохранение адреса временной переменной:
type User struct {
ID int64
Name string
}
// Ошибочный код - все указатели будут указывать на одну и ту же переменную u
func BuildUserPointers(ids []int64) []*User {
result := make([]*User, 0, len(ids))
for _, id := range ids {
u := User{ID: id}
result = append(result, &u) // Здесь мы берем адрес переменной цикла
}
return result
}
// В этом примере на каждой итерации используется одна и та же переменная u, // поэтому все элементы result будут указывать на одно и то же место в памяти.
Правильный вариант — брать адрес элемента слайса или выделять новый объект с помощью new:
func BuildUserPointers(ids []int64) []*User {
result := make([]*User, 0, len(ids))
for _, id := range ids {
u := &User{ID: id} // Создаем новый объект в куче
result = append(result, u)
}
return result
}
// Теперь на каждой итерации создается новый User, и указатели разные.
Сохранение слайса после append в другом месте
Еще одна ловушка — сохранение ссылки на слайс, который затем "неожиданно" меняется при append:
users := make([]User, 0, 1)
users = append(users, User{ID: 1})
// Сохраняем ссылку на слайс
backup := users
// Делаем append, который может перераспределить массив
users = append(users, User{ID: 2})
// В зависимости от перераспределения backup может указывать на старый массив
// или на тот же самый, что и users
// Здесь важно понимать - backup и users могут разделять один массив или нет, // в зависимости от того, произошла ли аллокация.
Безопасный подход:
- если вам нужно "заморозить" состояние — делайте копию через
copy; - не полагайтесь на то, что два слайса будут навсегда разделять один массив.
backup := make([]User, len(users))
copy(backup, users)
// Теперь backup точно независим от users
Модификация данных в под-слайсе
Допустим, вы взяли часть слайса:
allUsers := []User{
{ID: 1, Name: "A"},
{ID: 2, Name: "B"},
{ID: 3, Name: "C"},
}
// Берем только первых двух
firstTwo := allUsers[:2]
// Меняем имя первого в под-слайсе
firstTwo[0].Name = "Updated"
// Изменение отразится и в allUsers, потому что данные общие.
Если вам нужен независимый фрагмент, скопируйте его:
safeCopy := append([]User(nil), allUsers[:2]...)
// safeCopy теперь отдельный слайс с собственным массивом
Практические паттерны работы с model-slice
Пагинация для API
Слайс моделей часто отдают наружу через API с поддержкой пагинации. Давайте посмотрим простой паттерн:
type Page struct {
Items []User // Слайс моделей
TotalCount int // Общее количество записей
Limit int // Размер страницы
Offset int // Смещение
}
// PaginateUsers возвращает структуру Page
func PaginateUsers(users []User, limit, offset int) Page {
// Вычисляем границы слайса
if offset > len(users) {
offset = len(users)
}
end := offset + limit
if end > len(users) {
end = len(users)
}
// Берем под-слайс с нужной частью
pageItems := users[offset:end]
return Page{
Items: pageItems,
TotalCount: len(users),
Limit: limit,
Offset: offset,
}
}
// Здесь мы используем модели в слайсе для формирования страницы данных.
Группировка слайса моделей по ключу
Еще одна распространенная операция — сгруппировать модели по какому-то полю:
// GroupUsersByEmailDomain группирует пользователей по домену email
func GroupUsersByEmailDomain(users []User) map[string][]User {
result := make(map[string][]User)
for _, u := range users {
parts := strings.SplitN(u.Email, "@", 2)
if len(parts) != 2 {
// Пропускаем некорректные адреса
continue
}
domain := parts[1]
// Добавляем пользователя в группу по домену
result[domain] = append(result[domain], u)
}
return result
}
// Здесь слайс модели внутри map выступает как "корзина" для каждой группы.
Обновление моделей по карте
Еще один практический пример — сопоставление данных из карты и слайса моделей:
// UpdateUserNames обновляет имена пользователей по карте ID -> Name
func UpdateUserNames(users []User, names map[int64]string) {
for i := range users {
if newName, ok := names[users[i].ID]; ok {
// Обновляем имя, если в карте есть новое значение
users[i].Name = newName
}
}
}
// Мы проходим по слайсу моделей и для каждого элемента смотрим, есть ли обновление в карте.
Заключение
Слайс модели (model-slice) — один из центральных инструментов, когда вы работаете с данными в Go. Он совмещает:
- простоту стандартного слайса;
- выразительность, когда вы добавляете к нему методы;
- гибкость при работе с БД, API и бизнес-логикой.
Ключевые моменты, которые стоит держать в голове:
appendвсегда возвращает новый слайс — результат нужно присваивать;- срезы (
a[low:high]) разделяют общий массив с исходным слайсом; - при необходимости изоляции делайте копию через
copyилиappendв новый слайс; - для изменения длины слайса внутри функции удобнее возвращать новый слайс или принимать указатель;
- аккуратно обращайтесь с указателями на элементы слайса и переменные цикла.
Используя эти принципы, вы сможете уверенно строить слой данных, писать читаемый код и избегать неприятных багов, связанных с общими массивами и неожиданными изменениями.
Частозадаваемые технические вопросы по теме статьи и ответы на них
Как правильно маршалить и анмаршалить слайс моделей в JSON
Если у вас есть слайс моделей []User, вы можете напрямую передавать его в json.Marshal:
data, err := json.Marshal(users)
// data - это []byte с JSON-массивом
// Для чтения из JSON делайте так:
var users []User
if err := json.Unmarshal(data, &users); err != nil {
// Обрабатываем ошибку
}
Главное — передавать указатель на слайс в Unmarshal, чтобы он мог заполнить его данными.
Как удалить элемент из слайса модели по индексу
Стандартный паттерн удаления по индексу:
func RemoveAt(users []User, i int) []User {
copy(users[i:], users[i+1:]) // Сдвигаем хвост влево
users = users[:len(users)-1] // Укорачиваем слайс на 1
return users
}
// Такой подход сохраняет порядок и не создает новый массив.
Как эффективно очистить слайс моделей, чтобы освободить память
Если вы хотите "забыть" данные и освободить память, одной обрезки длины мало. Лучше обнулить ссылки (если структура содержит указатели) и сбросить слайс:
users = nil
// Если нужно повторно использовать, можно сделать:
users = users[:0]
Так вы сохраните массив, но "забудете" элементы. Память под массив останется до сборки мусора или повторного использования.
Как избежать гонок данных при одновременном доступе к слайсу моделей
Слайсы не потокобезопасны. Если вы читаете и пишете один и тот же слайс из разных горутин, защищайте доступ mutex-ом или каналами:
type UserStore struct {
mu sync.RWMutex
users []User
}
func (s *UserStore) GetAll() []User {
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]User, len(s.users))
copy(out, s.users)
return out
}
func (s *UserStore) Add(u User) {
s.mu.Lock()
defer s.mu.Unlock()
s.users = append(s.users, u)
}
// Здесь мы защищаем слайс моделей с помощью мьютекса и отдаем копию для чтения.
Как преобразовать слайс моделей в карту для быстрого поиска по ID
Если вам нужно делать частые поиска по ID, удобно построить карту:
func IndexByID(users []User) map[int64]User {
m := make(map[int64]User, len(users))
for _, u := range users {
m[u.ID] = u
}
return m
}
// Теперь поиск по ID становится O(1) вместо линейного прохода по слайсу.