Типы и структуры: семантика значений, нулевые значения и заголовок среза
Go по умолчанию копирует структуры — указатель это явный opt-in, а нулевые значения вроде sync.Mutex работают сразу. Встраивание — композиция, не наследование. Срез — 24-байтовый заголовок над общим массивом: append в пределах cap молча алиасит, за cap — переаллоцирует.
Сервис отчётов сортировал транзакции за месяц, брал тройку лидеров через 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]?
- 01Что такое срез на самом деле и какие два разных поведения есть у append?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.