open atlas
↑ К треку
Go с нуля до senior GO · 01 · 01

Типы и структуры: семантика значений, нулевые значения и заголовок среза

Go по умолчанию копирует структуры — указатель это явный opt-in, а нулевые значения вроде sync.Mutex работают сразу. Встраивание — композиция, не наследование. Срез — 24-байтовый заголовок над общим массивом: append в пределах cap молча алиасит, за cap — переаллоцирует.

GO Middle ◷ 17 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Сервис отчётов сортировал транзакции за месяц, брал тройку лидеров через top := sorted[:3], а затем добавлял синтетическую строку «TOTAL» в top перед рендером. В продакшене каждый экспорт содержал одну испорченную транзакцию — четвёртая строка полного отчёта молча заменялась строкой TOTAL. Локально баг не воспроизводился: тестовый набор был так мал, что append случайно переаллоцировал. В продакшене у бэкинг-массива была запасная ёмкость, поэтому append(top, totalRow) писал прямо в sorted[3] — заголовок среза говорил len 3, cap 31, и Go сделал ровно то, что заголовок позволял. Баг был не в append; он был в вере, что срез — это массив, а не 24-байтовое окно в чужую память.

Семантика значений: всё копируется, пока вы не скажете иначе

Когда вы неправильно читаете семантику копирования Go, вы либо молча меняете не ту копию, либо берёте указатель, который не хотели разделять — оба бага могут стоить часа отладки. Дефолт Go противоположен Java и Python: присваивание, аргументы функций и переменные цикла range копируют значение. Присвоили структуру — получили вторую, независимую; меняете копию — оригинал ничего не замечает. Разделяемость — это явное решение, записанное как *T: указатель — способ включить алиасинг, а не то, что язык делает у вас за спиной.

type Account struct {
    Owner   string
    Balance int64
}

func drainCopy(a Account) { a.Balance = 0 }   // меняет копию; вызывающий не замечает
func drain(a *Account)    { a.Balance = 0 }   // меняет структуру вызывающего

func main() {
    acc := Account{Owner: "ada", Balance: 100}
    drainCopy(acc) // acc.Balance по-прежнему 100
    drain(&acc)    // acc.Balance теперь 0

    accounts := []Account{{Owner: "a"}, {Owner: "b"}}
    for _, a := range accounts {
        a.Balance = 50 // пишет в КОПИЮ цикла — accounts не изменился
    }
    for i := range accounts {
        accounts[i].Balance = 50 // обращение по индексу в срез — это сохранится
    }
}

То же правило определяет получатели методов. Получатель-значение func (a Account) X() работает с копией — годится для read-only методов на маленьких структурах. Получатель-указатель func (a *Account) X() работает с оригиналом — обязателен для мутаций и общепринят, когда структура большая (копировать 10-полевую структуру на каждый вызов дороже одного указателя) или содержит что-то некопируемое. Канонический некопируемый объект — sync.Mutex: скопируйте структуру с залоченным мьютексом — и у вас два мьютекса, не знающих друг о друге, и каждая «защищённая» секция выполняется без защиты. Проверка copylocks в go vet существует потому, что этот баг постоянно доезжает до продакшена.

Компромисс: семантика значений покупает локальность и свободу от багов алиасинга — скопированная структура живёт на стеке, не нагружает GC, и никто не изменит её под вами. Указатели покупают мутацию и экономят копирование больших данных, но каждый *T — потенциальный побег в кучу (компилятор должен доказать, что значение не переживёт фрейм) и потенциальная гонка данных. Привычка Go: маленькие, почти неизменяемые данные — по значению; данные с идентичностью или мьютексом — по указателю, и никогда не смешивать виды получателей у одного типа.

Нулевые значения спроектированы полезными

В Go объявленная, но не инициализированная память — не мусор, а нулевое значение: 0, "", nil, false, а для структур — все поля рекурсивно обнулены. Стандартная библиотека делает из этого контракт API: var mu sync.Mutex — разлоченный мьютекс, готовый к Lock(); var buf bytes.Buffer — пустой буфер, готовый к Write(); var wg sync.WaitGroup готов к Add(). Ни конструктора, ни церемонии init. Когда проектируете свои типы, сеньорский ход — сделать нулевое значение осмысленным («пустой конфиг» / «no-op логгер»), чтобы вызывающие могли встраивать ваш тип в свои структуры и не звать конструктор. Там, где нулевое значение в принципе не может работать (типу нужны map, канал или файловый дескриптор), дайте NewX() и задокументируйте, что нулевое значение невалидно — не оставляйте «полуработу».

type Counter struct {
    mu sync.Mutex     // нулевое значение: разлочен, готов к использованию
    n  map[string]int // нулевое значение: nil map — читать можно, запись паникует
}

func (c *Counter) Inc(k string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.n == nil {
        c.n = make(map[string]int) // ленивая инициализация сохраняет нулевое значение рабочим
    }
    c.n[k]++
}

Деталь про nil-map — классическая ловушка: чтение из nil-map возвращает нули, а запись паникует. Тип с полезным нулевым значением либо лениво инициализируется, либо избегает map на нулевом пути.

Встраивание — это композиция, не наследование

В Go нет иерархии классов. Встраивание — тип в структуре без имени поля — продвигает поля и методы внутреннего типа на поверхность вызовов внешнего, и это всё. s.Log() на встроенном *Logger — сахар для s.Logger.Log(). Виртуальной диспетчеризации нет: когда метод встроенного типа вызывает другой метод, он вызывает свой собственный, а не ваш «переопределённый» на внешней структуре. Если вы пришли из Java и ждёте полиморфизма по шаблонному методу — здесь он ломается: встроенный метод понятия не имеет о существовании вашего внешнего типа. Встраивание отвечает на «у этого типа есть логгер, и он выставляет его методы», и никогда — на «этот тип — разновидность логгера». Нужен полиморфизм? Для этого есть интерфейсы (следующие уроки).

Почему это работает

Почему Go намеренно отказался от наследования? Глубокие иерархии связывают код с деталями реализации родителя: изменение базового класса аукается в каждом наследнике, а переопределение создаёт «действие на расстоянии», когда чтение одного класса ничего не говорит о поведении в рантайме. Встраивание держит зависимость мелкой и механической: продвинутые методы разрешаются на этапе компиляции чтением двух структур, проблема «хрупкого базового класса» невозможна, потому что внутренний тип не может вызывать наружу, а переиспользование остаётся «а-ля карт»: встройте два типа — и вы скомпоновали оба набора методов, чего одиночное наследование выразить не может. Цена — разделяемое поведение приходится прокидывать явно, а не наследовать; Go считает эту явность достоинством.

Срезы: 24-байтовый заголовок над общим массивом

Срез — это не массив. Это заголовок из трёх машинных слов — указатель, len, cap — 24 байта на 64-битной платформе, указывающий в бэкинг-массив, который могут разделять другие срезы. s[2:5] ничего не аллоцирует: он строит новый заголовок над той же памятью. Поэтому срезание — O(1) и бесплатно, а мутация видна через каждый заголовок, разделяющий массив.

Кусается это в append. Если len равен cap, append выделяет массив побольше (примерно удвоение до 256 элементов и плавное снижение к ~1.25× для больших срезов в текущем рантайме), копирует и возвращает заголовок на новую память — оригинал теперь отвязан. Но если есть запасная ёмкость, append пишет на месте, в общий бэкинг-массив — это и есть испорченный отчёт из вступления: у sorted[:3] был cap 31, и добавленная строка легла в sorted[3].

a := make([]int, 3, 8)   // len 3, cap 8
b := append(a, 99)       // есть запасная ёмкость: пишет в бэкинг-массив a на индексе 3
a = append(a, 7)         // ТОТ ЖЕ слот: b[3] теперь 7, а не 99

safe := append([]int(nil), a...) // идиоматичное полное копирование: отвязанный заголовок
limited := a[:3:3]               // трёхиндексный срез: cap зажат до 3 → следующий append переаллоцирует

Две защиты: копируйте данные на границе владения (append([]T(nil), s...) или copy), либо зажмите ёмкость трёхиндексным срезом s[low:high:max], чтобы чужой append был вынужден переаллоцировать. Режим отказа помимо порчи данных: крошечный срез огромного массива держит весь массив живым — отрезали 64 байта из 100 МБ чтения и сохранили их в кэш — закэшировали 100 МБ, потому что GC видит бэкинг-массив достижимым через ваш 24-байтовый заголовок.

Викторина

Структура содержит sync.Mutex, а её метод с Lock объявлен с получателем-ЗНАЧЕНИЕМ: func (c Counter) Inc(). Несколько горутин зовут Inc конкурентно. Что происходит на самом деле?

Викторина

Дано a := make([]int, 3, 8), затем b := append(a, 99), затем a = append(a, 7). Чему равен b[3]?

Вспомните перед уходом
  1. 01
    Что такое срез на самом деле и какие два разных поведения есть у append?
  2. 02
    Как семантика значений, получатели-указатели и нулевые значения взаимодействуют при проектировании типа в Go?
Итог

Дефолт Go — семантика значений: присваивание, аргументы функций и переменные цикла range копируют, и скопированная структура полностью независима — разделение возникает только там, где написан указатель. Это определяет получатели: значения — для маленьких read-only методов, указатели — для мутаций, больших структур и всего, что держит sync.Mutex, потому что скопированный мьютекс — это два мьютекса и ноль взаимного исключения; проверка copylocks в vet существует ради именно этого доехавшего до прода бага. Нулевые значения — контракт API, а не мусорная память: неинициализированные sync.Mutex, bytes.Buffer или WaitGroup готовы к работе, и хорошо спроектированные типы следуют этому, делая нулевое значение осмысленным и лениво инициализируя nil-map (чтение возвращает нули, запись паникует). Встраивание продвигает поля и методы внутреннего типа на внешний и ничего больше — продвинутые методы нельзя переопределить, а внутренний тип никогда не зовёт наружу: это композиция «есть-у», а не наследование «является»; полиморфизм — дело интерфейсов. Срез — 24-байтовый заголовок (указатель, len, cap) над бэкинг-массивом, который могут разделять другие срезы: срезание бесплатно и алиасит, append пишет на месте всякий раз, когда есть запасная ёмкость (баг испорченного отчёта, где append к sorted[:3] перетёр sorted[3]), и переаллоцирует только когда len достигает cap — примерно удваивая маленькие массивы и снижаясь к ~1.25x для больших. На границах владения отвязывайтесь намеренно: копируйте данные или зажимайте ёмкость трёхиндексным срезом, чтобы следующий append был обязан переаллоцировать, — и помните, что крошечный срез огромного буфера держит весь буфер живым для GC (garbage collector, сборщика мусора). Теперь, когда вы видите срез, переданный через границу функции, первый вопрос должен быть: разделяют ли два заголовка один бэкинг-массив и заметит ли вызывающий то, что вы добавите через append?

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.