Go: язык
Секция, на которой отсеивают. Здесь почти не спрашивают «как написать» — спрашивают «что лежит в памяти и что произойдёт». Поэтому каждая глава идёт от структуры данных в рантайме к наблюдаемому поведению, а не наоборот.
Умеешь ли ты рассуждать от модели памяти. Кандидат, который говорит «слайс — это динамический массив», и кандидат, который говорит «слайс — это структура из трёх полей, которая копируется при передаче, а данные шарятся» — на собесе это два разных уровня, даже если оба напишут рабочий код.
1.1Типы и базовые конструкции
Дальше в Go-секции всё упирается в эту главу. Если не уложится в голове, что такое значение и что копируется, поплывут и слайсы, и мапы, и интерфейсы.
Сначала — три вещи, без которых дальше будет каша
Считай, что вся глава про один вопрос: если я где-то поменяю значение, увидит ли это изменение кто-то другой? Чтобы отвечать не наугад, хватит трёх вещей. Договоримся о словах.
1. Переменная — это кусочек памяти с именем
Когда ты пишешь var x int64, программа откладывает в
памяти кусочек размером 8 байт и вешает на него ярлык
x. Размер кусочка задаёт тип:
bool занимает 1 байт, int64 — 8, структура из
двух int64 — 16. Компилятор знает размер заранее, ещё до
запуска, поэтому и отводит ровно столько места, сколько нужно.
2. Присваивание — это копирование байтов из одного кусочка в другой
b := a означает буквально: «взять байты, которые
лежат в кусочке a, и записать их в кусочек
b». Два разных кусочка памяти, побайтовое копирование
из первого во второй. С аргументом функции то же самое: у неё появляется свой кусочек
памяти под параметр, и туда копируются байты из аргумента.
Отсюда и ответ на главный вопрос: раз кусочки разные, изменение одного не трогает другой. Загвоздка в одном: всё зависит от того, что именно лежало в скопированных байтах.
3. Указатель — переменная, в которой лежит не значение, а адрес
Иногда в кусочке памяти лежат не «полезные» данные, а
адрес другого кусочка: число, которое говорит «нужное лежит вон
там». Такая переменная называется указателем (тип пишется
как *T, взять адрес — &x, сходить по
адресу — *p).
Аналогия: значение — квартира, указатель — бумажка с её адресом. Скопировав бумажку, ты получил вторую бумажку, но квартира осталась одна. Придёшь по любой из двух бумажек, попадёшь в одну и ту же квартиру, и следы чужого ремонта увидишь по обеим.
В двух местах. Стек — память функции: заводится при входе в функцию, исчезает сама при выходе, дёшево. Куча — общая память программы: живёт, пока на неё кто-то ссылается, убирается сборщиком мусора, дороже. Компилятор сам решает, куда положить переменную. Пока достаточно знать, что куча — это «долгоживущая память, до которой можно дотянуться по адресу откуда угодно». Подробно разбираем в теме «Память, GC и производительность».
Система типов: что вообще есть
Все типы Go делятся на четыре группы, и отличаются они ровно одним: что лежит в том самом кусочке памяти, который скопируется при присваивании.
| Группа | Типы | Что лежит в кусочке памяти | Что будет при b := a |
|---|---|---|---|
| Базовые |
bool, числовые (int*,
uint*, float*),
byte, rune
|
Само число или флаг — и всё | Полностью независимая копия |
| Составные | array, struct |
Все поля или элементы подряд, одним блоком | Копируется весь блок целиком. Структура из 100 полей — скопируются все 100 |
| С указателем внутри |
slice, map, chan,
func, *T
|
Маленькая служебная структурка, и внутри неё — адрес настоящих данных | Копируется структурка с адресом. Данные остаются одни на двоих |
| Строки | string |
Адрес байтов + длина. Сами байты лежат отдельно и неизменяемы | Копируется адрес и длина, но менять строку всё равно нельзя — поэтому «одни на двоих» тут безопасно |
| Интерфейсы | any, именованные интерфейсы |
Два адреса: «какой тут лежит тип» и «где само значение» | Копируются оба адреса. Разбирается подробно в главе 1.5 |
В Go нет передачи по ссылке. Вообще. Присваиваешь или передаёшь в функцию: байты уезжают в новый кусочек памяти. Так происходит каждый раз, без исключений. Вопрос только в том, что было в этих байтах: само значение, и копия независима; адрес, и две переменные смотрят на одни и те же данные.
Заодно про жаргон. В книгах и на собесах говорят «значимые типы» и «ссылочные типы»: термины пришли из C# и Java. В спецификации Go их нет. Если интервьюер спрашивает «какие типы в Go ссылочные», сильный ответ звучит так: «формально таких нет, Go всегда копирует значение; но обычно ссылочными называют слайсы, мапы, каналы, функции и указатели, потому что внутри у них лежит адрес, и копия видит те же данные».
Целые числа: размер и переполнение
int и uint —
платформозависимые типы: они шириной в машинное слово
целевой архитектуры. На всех современных сборках (amd64,
arm64) это 64 бита, на 32-битных (386, arm) — 32.
Смотри strconv.IntSize или
unsafe.Sizeof(int(0)). При этом int,
int64 и int32 — это
три разных типа, между которыми нет неявного приведения,
даже когда их размеры совпадают.
var a int = 5
var b int64 = 10
// _ = a + b // ошибка компиляции:
// // invalid operation: a + b (mismatched types int and int64)
_ = int64(a) + b // ок — явная конверсия
// размер int зависит от платформы:
fmt.Println(strconv.IntSize) // 64 на amd64/arm64
fmt.Println(unsafe.Sizeof(int(0))) // 8
Переполнение целых в Go не паникует и не проверяется в
рантайме: это арифметика по модулю
2^N («заворачивается»). Знаковые прыгают с максимума
на минимум, беззнаковые с нуля на максимум.
var i8 int8 = 127
i8++ // -128, без паники
var u uint8 = 0
u-- // 255
// В проде чаще всего подводит беззнаковая арифметика:
var n uint = 3
for i := n; i >= 0; i-- { // бесконечный цикл: uint никогда не станет < 0
_ = i
}
// И вторая: разность длин в uint
a, b := len(x), len(y) // len возвращает int, тут всё ок
var la, lb uint = 2, 5
_ = la - lb // 18446744073709551613, а не -3
«Где здесь бесконечный цикл?» И показывают
for i := uint(n); i >= 0; i--. Ответ:
беззнаковое всегда >= 0, условие никогда не
станет ложным. Второй вариант того же вопроса:
for i := 0; i < len(s)-1; i++. Если
s пустой, то len(s)-1 == -1, а
int знаковый, так что цикл просто не выполнится. А
будь длина беззнаковой, полезли бы по огромному индексу.
Компилятор ловит переполнение только у констант, потому что константы вычисляются с произвольной точностью на этапе компиляции:
const big = 1 << 62 // ок, нетипизированная константа: побитовый сдвиг "1 << 62" даёт 2⁶²
var x int8 = 200 // ошибка компиляции: constant 200 overflows int8
var y = big * 4 // ошибка: constant overflows int (при присвоении в int)
Zero values: почему в Go нет мусора в памяти
Любая объявленная переменная гарантированно инициализирована.
Рантайм обнуляет выделенную память (memclr), и на
стеке, и в куче. Это решение сознательное: оно убирает целый
класс багов «использование неинициализированной переменной»,
делает var mu sync.Mutex сразу готовым к работе, а
var buf bytes.Buffer — валидным. Формулировка для
собеса: в Go нулевое значение должно быть полезным (zero
value is useful), это принцип дизайна стандартной библиотеки.
| Тип | Zero value | Можно ли пользоваться сразу |
|---|---|---|
bool |
false |
да |
| числовые | 0 / 0.0 |
да |
string |
"" (не nil!) |
да, len == 0 |
*T, func, interface
|
nil |
нет — разыменование/вызов паникует |
slice |
nil |
да частично: len, cap,
range, append работают
|
map |
nil |
только чтение: чтение даёт zero value, запись — паника |
chan |
nil |
нет — чтение и запись блокируются навсегда |
array |
массив из zero values | да |
struct |
структура, все поля обнулены | да (если так спроектирована) |
Слово nil в Go не обозначает один объект. Это
нулевое значение для шести разных категорий типов, и ведут они
себя по-разному. Классический добивающий вопрос: «а
nil слайс и nil мапа одинаково себя
ведут?» Нет: в nil-слайс можно append,
в nil-мапу нельзя писать.
byte и rune
Оба — псевдонимы, не самостоятельные типы: type byte = uint8 и
type rune = int32 объявлены через =, то
есть это буквально одно и то же имя для одного типа. Поэтому
byte и uint8 взаимозаменяемы без
конверсии, а вот type MyByte uint8 (без
=) создал бы новый тип, требующий явного приведения.
-
byte— один октет. Из них состоят строки, содержимое[]byte, элементы I/O. -
rune— одна кодовая точка Unicode, 32 бита. Это не «символ», каким его видит человек: составные эмодзи и буквы с диакритикой могут состоять из нескольких рун.
s := "Привет"
fmt.Println(len(s)) // 12 — байты, кириллица по 2 байта в UTF-8
fmt.Println(utf8.RuneCountInString(s)) // 6 — руны
fmt.Printf("%T %v\n", s[0], s[0]) // uint8 208 — байт, не буква
fmt.Printf("%T %c\n", []rune(s)[0], []rune(s)[0]) // int32 П
Приведение типов: почему всё явно
В Go есть конверсия (T(v)) и нет неявного
приведения между разными типами, даже если базовое представление у
них одинаковое. Причина:
код предсказуем, скрытых потерь нет. В C/C++/Java
правила неявных промоушенов занимают страницы спецификации и
регулярно рождают баги вида «поделили int на int и получили 0»,
«сравнили signed с unsigned». Go эту область просто удалил.
type UserID int64
type OrderID int64
var u UserID = 7
var o OrderID = u // ошибка компиляции: cannot use u (variable of int64
// type UserID) as OrderID value in variable declaration
// ради этого отдельные типы и заводят
var o2 OrderID = OrderID(u) // явно, на ревью сразу видно
// Конверсия может молча терять данные:
var big int32 = 300
var small int8 = int8(big) // 44, старшие биты отброшены, ошибки нет
// Конверсия float→int обрезает, а не округляет:
f := 2.99
fmt.Println(int(f)) // 2
fmt.Println(int(-f)) // -2
// Но только для переменной. int(2.99) от нетипизированной константы —
// ошибка компиляции: cannot convert 2.99 (untyped float constant) to type int
T(v) — конверсия, работает на конкретных типах,
проверяется компилятором. v.(T) — type assertion,
работает только с интерфейсными значениями и проверяется в
рантайме. Путать их на собесе — заметный минус.
Константы и iota
Константы в Go вычисляются на этапе компиляции и бывают
типизированными и нетипизированными.
Нетипизированная константа хранится с произвольной точностью
(минимум 256 бит по спецификации) и получает тип только в момент
использования. Поэтому и можно писать math.MaxInt64 и
1 << 62 без гимнастики с приведениями.
const Pi = 3.14159 // нетипизированная float-константа
const TypedPi float64 = 3.14 // типизированная
var f float32 = Pi // ок: нетипизированная подстроится
// var g float32 = TypedPi // ошибка компиляции: cannot use TypedPi (constant
// // 3.14 of type float64) as float32 value
const Huge = 1 << 100 // ок, пока не присваиваем в переменную
fmt.Println(Huge >> 98) // 4 — вычислено компилятором
iota — счётчик внутри блока const. Он
равен индексу строки (ConstSpec) в блоке, начиная с нуля, и
сбрасывается на каждом новом const (. Тонкость: если
у строки нет своего выражения, повторяется выражение предыдущей
строки. Так и работают «пустые» строки в перечислении.
type Weekday int
const (
Sunday Weekday = iota // 0
Monday // 1 — выражение унаследовано
Tuesday // 2
)
// Пропуск значений через _
const (
_ = iota // 0 отбрасываем
KB = 1 << (10 * iota) // 1 << 10 = 1024
MB // 1 << 20
GB // 1 << 30
)
// Битовые флаги
type Perm uint8
const (
Read Perm = 1 << iota // 1
Write // 2
Exec // 4
)
p := Read | Write
fmt.Println(p&Write != 0) // true
Значения привязаны к порядку строк. Вставили новую
константу в середину — все последующие значения сдвинулись. Если
эти числа попадают в БД, в API или в сообщения брокера, это
тихая порча данных. Правило: для внешне видимых перечислений
задавай значения явно либо добавляй новые только в конец, и
обязательно закрывай тестом (например, golden-тест на
String() из stringer).
switch: чем отличается от C-подобных языков
| Отличие | Как в Go |
|---|---|
| Провал в следующий case |
Нет по умолчанию. Есть явный
fallthrough (передаёт управление в тело
следующего case без проверки его условия)
|
| Тип выражения |
Любой сравнимый тип, включая string, а не
только целые
|
| Несколько значений в case | case 1, 2, 3: |
| Switch без выражения |
switch { case x > 10: ... } — заменяет
цепочку if/else if
|
| Инициализатор |
switch v := f(); v { — v живёт
только внутри switch
|
| Type switch |
switch v := i.(type) { — работает только с
интерфейсами
|
break |
Не нужен для выхода из case. Нужен, чтобы прервать цикл извне switch — тогда с меткой |
// break внутри switch выйдет из switch, а не из цикла, тут часто ошибаются
loop:
for _, ev := range events {
switch ev.Kind {
case KindStop:
break loop // без метки прервали бы только switch
case KindSkip:
continue loop
}
handle(ev)
}
// switch без выражения идиоматично заменяет лестницу if
switch {
case age < 13:
return "child"
case age < 20:
return "teen"
default:
return "adult"
}
Структуры: сравнение и выравнивание
Структура comparable, если comparable все её поля. Не comparable: слайсы, мапы, функции, а значит и любая структура, которая их содержит. Массивы comparable, если comparable их элементы. Структуры сравниваются пополево, а не побайтово (иначе padding-байты давали бы ложные различия).
type Point struct {
X, Y int
}
type Bag struct {
Items []string
}
fmt.Println(Point{1, 2} == Point{1, 2}) // true
// _ = Bag{} == Bag{} // ошибка компиляции: invalid operation:
// // Bag{} == Bag{} (struct containing
// // []string cannot be compared)
// Обходной путь для «глубокого» сравнения:
reflect.DeepEqual(a, b) // медленно, но работает со всем
cmp.Equal(a, b) // github.com/google/go-cmp — стандарт де-факто в тестах
Выравнивание. Процессор читает память словами, поэтому
компилятор кладёт каждое поле по адресу, кратному его выравниванию
(int64 — по 8, int32 — по
4, bool — по 1), а между полями вставляет
padding. Размер всей структуры дополняется до кратного
максимальному выравниванию полей, чтобы в массиве структур каждый
следующий элемент тоже был выровнен. Go
не переупорядочивает поля сам: порядок в исходнике =
порядок в памяти.
fieldalignment находит такие структуры
автоматически.
$ go vet -vettool=$(which fieldalignment) ./...
$ fieldalignment -fix ./... # умеет переставлять поля автоматически
$ go run honnef.co/go/tools/cmd/structlayout@latest -json pkg Bad
Анонимные структуры и функции
// 1. Табличные тесты, самый частый случай
tests := []struct {
name string
in string
want int
err bool
}{
{"пусто", "", 0, true},
{"обычный", "42", 42, false},
}
// 2. Одноразовый JSON-ответ, ради которого нет смысла заводить тип
json.NewEncoder(w).Encode(struct {
Status string `json:"status"`
Count int `json:"count"`
}{"ok", n})
// 3. Множество (set) с нулевым оверхедом на значение
seen := map[string]struct{}{}
seen["a"] = struct{}{}
// 4. Анонимные функции: замыкания, defer, горутины, функциональные опции
defer func() {
if r := recover(); r != nil { log.Println("recovered:", r) }
}()
Пустая структура struct{} занимает 0 байт, а
все её экземпляры лежат по одному адресу (runtime.zerobase). Отсюда идиомы
map[T]struct{} для множества и
chan struct{} для сигнального канала: памяти под
значение не выделяется вообще.
Вопросы
10Четыре группы типов:
-
Базовые — bool, строки, числовые (
int,uint,float,complex), их псевдонимыbyteиrune. -
Составные —
arrayиstruct: фиксированный размер, элементы лежат подряд. -
Дескрипторные (их и называют «ссылочными») —
slice,map,chan,func, указатель*T. -
Интерфейсные —
anyи именованные интерфейсы: два слова (тип + данные).
Присвоил или передал в функцию: значение копируется
побитово. Для struct и
array копируются все данные, и получаются два
независимых объекта. Для слайса копируется заголовок из трёх
слов (ptr/len/cap), для мапы и канала — указатель на
hmap/hchan, поэтому данные
остаются общими.
type P struct {
X int
}
a := P{1}; b := a; b.X = 9
// a.X == 1 — копия независима
s1 := []int{1, 2, 3}; s2 := s1; s2[0] = 9
// s1[0] == 9 — общий массив
arr1 := [3]int{1, 2, 3}; arr2 := arr1; arr2[0] = 9
// arr1[0] == 1 — массив копируется целиком
«Термина reference type в спецификации Go нет, это жаргон. Формально Go всегда передаёт по значению, а
изменяемость через слайс или мапу — следствие того, что в
скопированном дескрипторе лежит указатель. Единственный
способ дать функции изменить саму переменную вызывающего —
передать *T.»
int — платформозависимый (64 бита
на amd64/arm64), и это отдельный тип от
int64, даже когда размеры совпадают.
Размер int/uint/uintptr
совпадает с машинным словом: 8 байт на amd64, arm64 и wasm, 4 байта на 386 и arm. Проверить:
strconv.IntSize или
unsafe.Sizeof(int(0)).
Смешивать нельзя даже при равном размере: компилятор
потребует явной конверсии. Совместимость определяет тип, а
не представление. На практике: в протоколах и БД фиксируй
int64/int32, а
int оставь для индексов, длин и внутренних
счётчиков.
Переполнение
Ни знаковое, ни беззнаковое переполнение не паникует: это
арифметика по модулю
2^N. Компилятор ловит только константное.
var i int8 = 127; i++ // -128
var u uint8 = 0; u-- // 255
// Ловушка №1: обратный цикл по uint
for i := uint(3); i >= 0; i-- { // бесконечный: uint всегда >= 0
}
// Ловушка №2: разность беззнаковых
var a, b uint = 2, 5
fmt.Println(a - b) // 18446744073709551613
// Ловушка №3: проверка переполнения после операции
c := math.MaxInt64
c++ // уже -9223372036854775808
if c < 0 { /* поздно, но так обычно и ловят */ }
Как правильно проверять переполнение
// знаковые — руками:
func addSafe(a, b int64) (int64, error) {
if (b > 0 && a > math.MaxInt64-b) || (b < 0 && a < math.MinInt64-b) {
return 0, errors.New("overflow")
}
return a + b, nil
}
// сейчас для беззнаковых есть math/bits:
sum, carry := bits.Add64(x, y, 0) // carry == 1 при переполнении
«Почему len() возвращает int, а
не uint, ведь длина не бывает отрицательной?»
Затем, чтобы выражения вида len(s)-1 и
обратные циклы вели себя предсказуемо. Роб Пайк объяснял
это так: беззнаковые типы в арифметике приносят больше
багов, чем экономят диапазона. uint в Go —
для битовых операций и протоколов, а не для
«неотрицательных чисел».
0 для чисел, false для bool,
"" для строк, nil для указателей,
функций, интерфейсов, слайсов, мап и каналов; для массивов и
структур — рекурсивно нулевые элементы/поля.
Почему так сделано
- Убирает класс UB из C: чтение мусора со стека невозможно.
-
Можно проектировать типы, готовые к работе «из коробки»:
var mu sync.Mutex,var buf bytes.Buffer,var wg sync.WaitGroup— все валидны без конструктора. - Упрощает JSON/десериализацию: отсутствующее поле = нулевое значение.
Цена и подводные камни
-
Неотличимо «не задано» от «задано нулём». Классика:
omitemptyвыброситfalseи0, хотя пользователь их явно прислал. Помогают указатели (*bool) илиsql.NullXxx/ собственный тип с флагом. -
nil-мапа читается, но не пишется: паникаassignment to entry in nil map. -
nil-канал блокирует навсегда (иногда это полезно: так «выключают» ветку в select).
var m map[string]int
fmt.Println(m["x"], len(m)) // 0 0 — чтение ок
m["x"] = 1 // panic: assignment to entry in nil map
var s []int
s = append(s, 1) // ок: append работает и с nil-слайсом
var ch chan int
<-ch // висит навсегда (deadlock, если спят все горутины)
byte = uint8,
rune = int32. byte — единица
хранения, rune — единица смысла (кодовая точка
Unicode).
Объявлены через =, то есть это те же самые типы
под другим именем, а не новые типы. Поэтому
var b byte = uint8(5) компилируется без
конверсии.
Go хранит строки в UTF-8. По индексу получишь
байт, а range по строке выдаст
руну и её байтовое смещение. Это главный источник
ошибок с не-ASCII текстом.
s := "Go♥"
fmt.Println(len(s)) // 5: 'G','o' по 1 байту, '♥' — 3 байта
fmt.Printf("%T\n", s[0]) // uint8
for i, r := range s {
fmt.Printf("%d:%c(%d) ", i, r, r) // 0:G(71) 1:o(111) 2:♥(9829)
}
fmt.Println(utf8.RuneCountInString(s)) // 3
rs := []rune(s) // аллоцирует 3 × 4 = 12 байт
fmt.Println(len(rs)) // 3
Руна — это code point, а не grapheme cluster. Флаг «🇷🇺» —
две руны, «é» может быть одной руной (U+00E9) или двумя (e
+ U+0301). Для «символов как их видит человек» нужна итерация по графемным кластерам, например библиотекой
rivo/uniseg, а golang.org/x/text/unicode/norm
только приводит «é» к одной форме. На собесе достаточно этот нюанс
упомянуть: видно, что ты понимаешь границы модели.
Единственное «неявное» поведение — нетипизированные
константы, которые подстраиваются под контекст. Всё
остальное требует T(v).
Аргументы, которые стоит назвать
-
Читаемость. Глядя на
int64(x) * y, ты сразу видишь и стоимость, и намерение. В C всё это прошло бы молча. -
Именованные типы как защита.
type UserID int64иtype OrderID int64нельзя перепутать, компилятор поймает. С неявными приведениями от этой защиты ничего не остаётся. -
Нет signed/unsigned сюрпризов. В C при сравнении
intсunsignedпервое продвигается в беззнаковое, отсюда классические уязвимости. - Простота спецификации. Меньше правил — меньше расхождений между компиляторами и меньше кейсов на ревью.
Что конверсия всё же делает молча
// От константы такие конверсии не скомпилируются,
// компилятор ловит их ещё при сборке:
// int8(300) → constant 300 overflows int8
// int(2.99) → cannot convert 2.99 (untyped float constant) to type int
// uint(-1) → constant -1 overflows uint
// А вот от переменной те же конверсии проходят молча:
var a int32 = 300
var b float64 = 2.99
var c int = -1
fmt.Println(int8(a)) // 44 — старшие биты отрезаны молча
fmt.Println(int(b)) // 2 — обрезает, а не округляет
fmt.Println(int(-b)) // -2 — к нулю, а не «вниз»
fmt.Println(uint(c)) // 18446744073709551615
string(65) // "A" — go vet ругается: подозрительная конверсия int→string
string([]byte{65}) // "A" — а так правильно
-
T(v)— конверсия, компайл-тайм, конкретные типы. -
i.(T)— type assertion, рантайм, только интерфейсы. -
switch v := i.(type)— type switch, рантайм, ветвление по динамическому типу.
iota — индекс строки внутри блока
const, считает с 0 и сбрасывается на каждом
новом блоке. Пропущенное выражение повторяется с предыдущей
строки.
const (
A = iota // 0
B // 1 — выражение "iota" унаследовано
C // 2
)
const (
D = iota // 0 — новый блок, счётчик сброшен
)
// iota считает строки, а не константы:
const (
X, Y = iota, iota * 10 // X=0, Y=0
Z, W // Z=1, W=10
)
Идиомы
// 1) Перечисление статусов, строки для них сгенерирует stringer
type Status int
const (
StatusUnknown Status = iota
StatusPending
StatusDone
)
//go:generate stringer -type=Status
// 2) Размеры
const (
_ = iota
KB = 1 << (10 * iota) // 1024
MB // 1048576
)
// 3) Битовые маски
type Flag uint
const (
FlagRead Flag = 1 << iota
FlagWrite
FlagExec
)
// 4) Пропуск ненужных значений
const (
_ = iota
First // 1 — чтобы zero value означал "не задано"
Second
)
Начинай перечисление с _ = iota или заводи
явный StatusUnknown = 0, чтобы нулевое
значение означало «не заполнено», а не какой-то
легальный статус. Иначе забытое поле в структуре молча
становится валидным первым статусом. Типичный баг на
проде.
Значения зависят от порядка строк. Если константы уезжают в БД/API/Kafka, вставка новой строки в середину меняет смысл уже сохранённых данных. Для внешних контрактов значения задают явно.
-
Нет провала. Тело case отработало, switch
закончился. Явный
fallthroughпередаёт управление в тело следующего case, не проверяя его условие, и обязан быть последним оператором в case. -
breakне нужен для выхода из case. Внутри циклаbreakвыйдет из switch, а чтобы прервать цикл, нужна метка. - Любые типы: строки, структуры (если comparable), не только целые.
-
Несколько значений в одном case:
case "a", "b":. -
Switch без выражения — эквивалент
switch true, идиоматичная замена лестницеif / else if. -
Инициализатор:
switch v, err := f(); {, область видимости ограничена switch. -
Type switch:
switch v := i.(type)— только по интерфейсам. Внутри каждого casevполучает свой конкретный тип; в case с несколькими типамиvостаётся интерфейсом. - Порядок проверки — сверху вниз, первый совпавший case побеждает (в отличие от C, где switch может компилироваться в jump table и порядок не важен).
switch v := x.(type) {
case nil:
fmt.Println("nil интерфейс")
case int, int64:
fmt.Printf("целое, но v тут ещё any: %T\n", v)
case fmt.Stringer:
fmt.Println(v.String()) // v уже fmt.Stringer
default:
fmt.Printf("%T\n", v)
}
switch { // без выражения
case n < 0: return "neg"
case n == 0: return "zero"
default: return "pos"
}
switch n {
case 1:
fmt.Println("один")
fallthrough // безусловно выполнит тело case 2
case 2:
fmt.Println("два")
}
// при n == 1 вывод:
// один
// два
for i := 0; i < 3; i++ {
switch i {
case 1:
break // выходит из switch, НЕ из цикла
}
fmt.Print(i)
}
012. Чтобы прервать цикл, нужен
break label.
Отличия от переменных
- Вычисляются компилятором; в бинарнике их «нет», они подставлены в места использования.
-
Нельзя взять адрес:
&MaxSize— ошибка компиляции. -
Могут быть только базовых типов: булев, строковый,
числовой. Нельзя
const s = []int{1}илиconst t = time.Now(). - Не участвуют в escape analysis и не создают аллокаций.
Нетипизированные константы
Константа без явного типа хранится как «идеальное» значение произвольной точности (спецификация требует минимум 256 бит мантиссы) и приобретает тип только там, где используется: по правилу «тип по умолчанию» или по контексту.
const c = 1 << 100 // ок, пока не присвоено переменной
fmt.Println(c >> 98) // 4
const half = 1 / 2 // 0 — целочисленное деление констант
const halfF = 1.0 / 2 // 0.5 — одна из констант вещественная
var f float64 = 3 // 3 подстроится под float64
var d time.Duration = 5 // ок: нетипизированная 5 станет Duration
var n int = 5
// var d2 time.Duration = n // ошибка: int → Duration неявно нельзя
// Типы по умолчанию, если контекста нет:
x := 42 // int
y := 4.2 // float64
z := 'a' // rune (int32)
s := "hi" // string
b := true // bool
Из-за нетипизированных констант
time.Second * 5 работает, а
time.Second * n (где n int) —
нет. И поэтому math.MaxUint64 можно объявить
константой, хотя она не влезает в int64: до
присваивания у неё просто нет типа.
Comparable
- Comparable: числа, строки, bool, указатели, каналы, интерфейсы, массивы из comparable, структуры из comparable.
-
Не comparable:
slice,map,funcи всё, что их содержит. Сравнение сnilдля них разрешено, друг с другом — нет. - Только comparable-типы могут быть ключами мапы, это то же ограничение.
type A struct {
X int
S string
} // comparable
type B struct {
Items []int
} // не comparable
_ = A{1,"a"} == A{1,"a"} // true
// _ = B{} == B{} // compile error
// С интерфейсным полем структура comparable, но в рантайме может запаниковать:
type C struct {
V any
}
_ = C{1} == C{1} // true
_ = C{[]int{1}} == C{[]int{1}} // panic: comparing uncomparable type []int
Выравнивание
Каждое поле кладётся по адресу, кратному его выравниванию
(обычно = размеру: int64→8, int32→4, bool→1). Между полями
появляется padding. Размер структуры округляется вверх до
кратного максимальному выравниванию её полей, чтобы в
[]T все элементы были выровнены.
type Bad struct { // 24 байта
a bool // 1 + 7 padding
b int64 // 8
c bool // 1 + 7 padding (добивка до кратного 8)
}
type Good struct { // 16 байт
b int64 // 8
a bool // 1
c bool // 1 + 6 padding
}
unsafe.Sizeof(Bad{}) // 24
unsafe.Sizeof(Good{}) // 16
unsafe.Alignof(Bad{}) // 8
unsafe.Offsetof(Bad{}.b) // 8
Когда это реально важно
- Структура живёт миллионами экземпляров (кэш, индекс в памяти, элементы большого слайса). Тогда −33 % размера дают и меньше давления на GC, и лучшую локальность в L1/L2.
-
False sharing в конкурентном коде: два часто изменяемых
поля попадают в одну 64-байтовую кэш-линию, ядра начинают
гонять её друг у друга. Лечат вручную: padding или
[64]byte-заполнитель между полями.
Клади поля от «широких» к «узким»: указатели и int64 →
int32 → int16 → bool. Такие структуры находит линтер
fieldalignment (входит в
golangci-lint как часть govet),
он же их чинит флагом -fix. Но
не начинай ответ с этого: сначала измерь, потом
переставляй поля.
Анонимные структуры
- Табличные тесты — самый частый и самый идиоматичный случай.
- Одноразовый JSON на входе или выходе хендлера, когда тип не нужен в домене.
-
Множества:
map[string]struct{}, где значение занимает 0 байт. -
Сигнальные каналы:
chan struct{}, где важен только факт события. -
Группировка полей внутри конфига:
DB struct{ Host string; Port int }прямо в структуреConfig.
cfg := struct {
Host string
Port int
}{"localhost", 8080}
var payload struct {
Email string `json:"email"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { ... }
Анонимные функции (литералы функций)
- Замыкания захватывают переменные окружения по ссылке на переменную.
-
deferс логикой:defer func(){ ... }(). - Горутины:
go func(){ ... }(). -
Функциональные опции:
func WithTimeout(d time.Duration) Option { return func(c *Client){ c.timeout = d } }. -
Middleware: обёртка, возвращающая
http.Handler. - Немедленный вызов ограничивает область видимости переменных.
// функциональные опции: на собесах по архитектуре их спрашивают чаще всего
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithLogger(l *slog.Logger) Option { return func(s *Server) { s.log = l } }
func New(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 5 * time.Second}
for _, o := range opts { o(s) }
return s
}
- У анонимной структуры нельзя объявить методы, только поля.
- Две анонимные структуры с одинаковым набором полей и тегов — один и тот же тип, их можно присваивать друг другу.
- Анонимные структуры в сигнатурах публичных функций — плохая идея: вызывающему негде записать этот тип.
1.2Массивы и слайсы
Тема номер один для отсева. По слайсам видно всё сразу: понимает ли человек, что лежит в памяти, умеет ли рассуждать про совместное владение данными и про то, где рантайм делает копию, а где нет. Заучивать тут нечего, надо уметь вывести ответ из устройства заголовка. Тогда любая задача «что выведет код» решается за десять секунд.
Зачем вообще лезть слайсу внутрь
На каждом собесе в разных обёртках спрашивают одно и то же: я передал
слайс дальше (в функцию, в горутину, положил в структуру), увидит ли он
мои изменения, и увижу ли я его? Списком случаев это не запомнить,
случаев десятки. Ответ выводится из того, что физически лежит в
переменной типа []int. Разобрался один раз, дальше не
гадаешь, а считаешь.
К концу главы ты объяснишь, почему запись s[0] = 1 внутри
функции видна снаружи, а append нет; почему два слайса иногда
«мешают» друг другу, а потом внезапно перестают; почему маленький кусочек
большого ответа сервера держит в памяти весь ответ целиком.
Массив: длина — часть типа
Массив в Go хранит фиксированное число элементов, они лежат в памяти
подряд. Дальше всё держится на одном: длина входит в тип.
[3]int и [4]int считаются разными типами, и
функция, принимающая [3]int, не примет [4]int.
Длина обязана быть константным выражением, известным на этапе компиляции.
var a [3]int // [0 0 0]
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3, 4} // [4]int — компилятор посчитал длину сам
d := [5]int{2: 9} // [0 0 9 0 0] — индексированный литерал
fmt.Printf("%T %T\n", b, c) // [3]int [4]int — разные типы
// b = c // ошибка компиляции:
// // cannot use c (variable of type [4]int) as [3]int value in assignment
e := b // копия всех 24 байт
e[0] = 99
fmt.Println(b[0], e[0]) // 1 99 — независимы
fmt.Println(b == [3]int{1,2,3}) // true — массивы comparable, если comparable элементы
Массивы в прикладном коде встречаются редко. Там, где они всё-таки единственно верны, мотив почти всегда один: обойтись без аллокации. Каждая аллокация в куче потом даёт работу сборщику мусора, поэтому их считают и стараются уменьшать. Три таких места:
-
Фиксированные буферы и хеши:
[32]byteдля SHA-256,[16]byteдля UUID. Значение целиком, без указателя, без аллокации, comparable, годится в ключи мапы. -
Ключи мапы:
map[[2]int]boolработает, аmap[[]int]boolнет. -
Стековые буферы:
var buf [64]byte; s := buf[:]. Массив остаётся на стеке, если не убегает, и слайс над ним не даёт аллокации в куче.
Слайс — это структура из трёх полей
1. Что такое «заголовок» и почему это не страшное слово
Заголовок сам данных не содержит. Это маленькая служебная структурка, она только описывает, где данные лежат и сколько их. Как наклейка на коробке: «полка 3, коробка 12, внутри 40 болтов». Наклейку можно скопировать, переписать, отдать другому человеку, а коробка от этого не раздвоится и не переедет.
Переменная типа []int и есть такая наклейка. Сами числа лежат
отдельно, в обычном массиве где-то в памяти. В переменной их нет.
2. Почему полей ровно три
Чтобы описать «окно» в чужом массиве, надо знать три вещи. Каждая отвечает на свой вопрос:
-
array: где начинается окно. Адрес первого элемента, который слайсу «виден». Без него до данных не добраться. -
len: сколько элементов сейчас в окне. Это вернётlen(s), по этому пройдётсяrange, а за этой границей индексация даёт панику. -
cap: сколько места есть в запасе, то есть сколько элементов помещается от начала окна до конца массива. Это поле отвечает на вопрос «переживёт ли следующийappendбез переезда в новую память».
Убери любое из трёх, и что-то отвалится. Без len не проверить
выход за границы, без cap append не знает, можно
ли дописать на месте. Три поля тут не от исторической случайности: меньше
просто не хватает.
3. Три слова, которые дальше встречаются на каждой странице
-
Ёмкость (
cap): сколько элементов слайс вместит без выделения новой памяти. Не путать с длиной. Длина считает элементы, которые реально есть; ёмкость показывает, сколько их влезет в уже выделенный кусок.make([]int, 3, 10)даёт три элемента и место ещё под семь. - Базовый массив: тот самый непрерывный кусок памяти с элементами, на который смотрит слайс. Владеет им слайс не единолично. На один массив смотрит сколько угодно слайсов, и запись через любой из них видна всем остальным.
-
Реаллокация (она же перевыделение): старый базовый массив перестал
вмещать данные, рантайм выделил кусок побольше, скопировал туда всё
содержимое и переставил указатель в заголовке. Слайсы, которые смотрели на
прежний массив, продолжают смотреть на прежний массив, и связь с
новым слайсом рвётся. Отсюда и берутся «магические» задачи про
append.
В рантайме всё выглядит буднично. Никакого «динамического массива» нет, есть заголовок (slice header): три машинных слова, описывающих окно в чужой памяти. Сама память лежит в отдельном массиве, слайсу он не принадлежит и бывает общим у нескольких слайсов.
// runtime/slice.go: вот что лежит в переменной типа []T
type slice struct {
array unsafe.Pointer // указатель на первый элемент окна
len int // сколько элементов видно
cap int // сколько элементов есть от array до конца массива
}
// на 64-битной платформе: 8 + 8 + 8 = 24 байта
fmt.Println(unsafe.Sizeof([]int{})) // 24
У слайса три инварианта, и из них выводится всё остальное поведение: 0 <=
len <= cap; len держит границу индексации (за ней
паника index out of range); cap держит границу
нарезки (s[:cap(s)] легально,
s[:cap(s)+1] уже паника).
len ограничивает
индексацию, cap ограничивает нарезку и «бесплатный» рост. Всё
поведение слайсов выводится из этой картинки.
«Слайс это значение из трёх полей, которое описывает окно в
массиве. Слайсы копируются как любое другое значение; поля у копий свои,
общий только массив, на который они смотрят. Поэтому запись в элемент
видна всем, а изменение len видит только владелец копии».
Три способа создать слайс (и ещё пара)
| Способ | Код | len / cap | Аллокация |
|---|---|---|---|
| Литерал | s := []int{1, 2, 3} |
3 / 3 | да (или на стеке, если не убегает) |
make с длиной |
s := make([]int, 5) |
5 / 5, нули | да |
make с длиной и ёмкостью |
s := make([]int, 0, 5) |
0 / 5 | да |
| Нарезка массива/слайса | s := arr[1:4] |
3 / cap(arr)-1 | нет — общий массив |
| Объявление (nil) | var s []int |
0 / 0, ptr=nil | нет |
new + нарезка |
s := new([5]int)[:] |
5 / 5 | да |
make([]T, 0, n) это преаллокация: «мне нужно место под
n элементов, но пока их ноль». Смысл в том, чтобы append не
пересоздавал массив по дороге. Разница видна на бенчмарке: без ёмкости на
10 000 элементов будет примерно 20 перевыделений и столько же копирований
всего содержимого, суммарно около 20 000 скопированных элементов вместо 10
000 записей.
// Плохо: 0 → 1 → 2 → 4 → 8 → ... → 16384, ~20 аллокаций и копирований
func bad(n int) []int {
var s []int
for i := 0; i < n; i++ { s = append(s, i) }
return s
}
// Хорошо: одна аллокация, append только пишет
func good(n int) []int {
s := make([]int, 0, n)
for i := 0; i < n; i++ { s = append(s, i) }
return s
}
// Частая ошибка: make([]T, n) вместо make([]T, 0, n)
func wrong(n int) []int {
s := make([]int, n) // len == n, слайс уже полон нулей
for i := 0; i < n; i++ { s = append(s, i) }
return s // длина 2n: n нулей, потом n значений
}
make([]T, n) и make([]T, 0, n) путают постоянно.
Первый вариант годится, когда ты пишешь по индексу (s[i] =
v), второй когда через append. Смешивать нельзя:
получишь слайс двойной длины с нулями в начале.
Передача в функцию: копируется заголовок
Слайс передаётся по значению, как и всё в Go. Копируются 24 байта
заголовка; поле array в копии указывает на тот же массив.
Отсюда правило, которое надо уметь проговорить одной фразой: функция
может изменить элементы, но не может изменить длину слайса у
вызывающего, потому что len лежит в копии.
func modify(s []int) { s[0] = 100 } // видно снаружи: общий массив
func grow(s []int) { s = append(s, 4) } // не видно: изменили копию заголовка
func growP(s *[]int) { *s = append(*s, 4) }// видно: пишем в саму переменную
s := []int{1, 2, 3}
modify(s); fmt.Println(s) // [100 2 3]
grow(s); fmt.Println(s) // [100 2 3] — длина не изменилась
growP(&s); fmt.Println(s) // [100 2 3 4]
Указатель на слайс (*[]T) в прикладном коде почти не нужен:
идиоматично вернуть новый слайс (s = f(s)), ровно так устроен
сам append. *[]T оправдан в горячих местах, где
надо наполнять переданный буфер без возврата, и когда пишешь
json.Unmarshaler / sql.Scanner.
append: единственное место, где массив может смениться
append не метод и не обычная функция, а
встроенная: компилятор разворачивает её вызов в инлайновый
код. Логика такая:
-
Посчитать нужную длину:
newLen = len(s) + len(добавляемого). -
Если
newLen <= cap(s), то ничего не выделять: записать элементы в существующий массив начиная с индексаlen(s)и вернуть заголовок с новымlen. -
Иначе вызвать
runtime.growslice: посчитать новый cap, выделить новый массив, скопировать в него старые элементы (memmove), дописать новые, вернуть новый заголовок.
Из шага 2 растёт вся «магия» слайсов: при достаточном cap
append молча пишет в память, которую видят и другие
слайсы. Из шага 3 растёт другое: после роста связь с исходным массивом теряется.
Стратегия роста capacity
Считает её runtime.growslice в runtime/slice.go.
Наивное «всегда x2» уже несколько лет неверный ответ. Алгоритм
двухступенчатый, а сверху ещё накладывается округление до классов размеров аллокатора.
+ (cap +
768)/4 появились в Go 1.18 взамен старого порога в 1024 элемента и
жёсткого множителя 1.25. Рост стал плавным, без скачка коэффициента.
| Версия | Порог | Ниже порога | Выше порога | Проблема, которую чинили |
|---|---|---|---|---|
| до Go 1.18 | 1024 элемента | cap × 2 | cap × 1.25 (жёстко) | резкий разрыв: на 1024 коэффициент падал с 2.0 до 1.25 одним шагом |
| Go 1.18 и новее | 256 элементов | cap × 2 | cap += (cap + 3*256) / 4 |
плавный переход: при cap=256 это ×1.75, при cap=1024 — ×1.44, при cap=100000 — ×1.25 |
// Быстрее всего проверить руками, что «всегда x2» неверно
s := make([]int, 0)
prev := 0
for i := 0; i < 5000; i++ {
s = append(s, i)
if cap(s) != prev {
fmt.Printf("len=%-5d cap=%-6d x%.2f\n", len(s), cap(s), float64(cap(s))/float64(max(prev,1)))
prev = cap(s)
}
}
// Прогон на go1.27, []int, пары len cap:
// 1 4 | 5 8 | 9 16 | 17 32 | 33 64 | 65 128 | 129 256 | 257 512
// 513 848 | 849 1280 | 1281 1792 | 1793 2560 | 2561 3408 | 3409 5120
// Первый же append даёт cap=4, а не 1: с Go 1.25 слайсу, который не убегает,
// компилятор даёт стартовый буфер в 32 байта на стеке, отсюда cap = 32/размер —
// []byte → 32, []int32 → 8, []int/[]int64 → 4, []string → 2, struct{4 int} → 1.
// До 256 элементов ровно x2, после 256 множитель падает
// (x1.66, x1.51, x1.40...), а «некруглые» цифры даёт roundupsize.
growslice считает байты, а не элементы, и отдаёт их
mallocgc, который умеет выделять только фиксированные классы
размеров (size classes), иначе фрагментация и медленный аллокатор.
Округление вверх до класса «возвращается» обратно в cap,
чтобы память не пропадала зря. Отсюда 848 вместо 640 и 3408 вместо 3200.
Ещё для элементов размера 1 байта ([]byte) и для
размеров-степеней двойки есть отдельные быстрые ветки, поэтому у
[]byte и []int прогрессии cap разные.
Full slice expression: третий индекс
s[low:high:max] задаёт len = high-low и
cap = max-low. Ограничение
low <= high <= max <= cap(s). Зачем: чтобы
отобрать у подслайса право писать в чужой хвост. Без
третьего индекса подслайс наследует cap до конца массива, и первый
же append в него молча затрёт элементы соседа.
base := []int{1, 2, 3, 4, 5}
a := base[0:2] // len=2 cap=5 — опасно
a = append(a, 99)
fmt.Println(base) // [1 2 99 4 5] ← затёрли base[2]
base = []int{1, 2, 3, 4, 5}
b := base[0:2:2] // len=2 cap=2 — безопасно
b = append(b, 99) // cap исчерпан → новый массив
fmt.Println(base, b) // [1 2 3 4 5] [1 2 99]
Если ты отдаёшь наружу подслайс своего буфера, всегда обрезай cap:
return buf[i:j:j]. Иначе вызывающий сделает
append и незаметно испортит твои данные. Это один из немногих
случаев, когда третий индекс обязателен, а не «на всякий случай». Ту же
роль играет slices.Clip(s) из стандартной библиотеки: он
возвращает s[:len(s):len(s)].
Утечки памяти через подслайсы
Сборщик мусора в Go работает с объектами целиком: он не
умеет освободить «хвост» массива, если на его начало кто-то
смотрит. Один живой слайс на пять элементов удерживает в куче весь
массив на десять миллионов. Ровно та же история со строками и с
[]byte из io.ReadAll.
slices.Clone,
copy) и отпустить исходный.
// Утечка: маленький результат держит гигантский вход
func firstLine(data []byte) []byte {
i := bytes.IndexByte(data, '\n')
return data[:i] // держит весь data
}
// Лечим
func firstLineOK(data []byte) []byte {
i := bytes.IndexByte(data, '\n')
return bytes.Clone(data[:i]) // Go 1.20+; до него — append([]byte(nil), data[:i]...)
}
// Вторая утечка: элементы-указатели остаются достижимыми после «удаления»
func pop(s []*Item) []*Item {
last := len(s) - 1
s[last] = nil // без этого Item жив, хоть и вне len
return s[:last]
}
Уменьшил len, а элементы никуда не делись: лежат в массиве
между len и cap. Если это указатели, интерфейсы,
строки или структуры с указателями внутри, GC продолжает считать их
живыми. Поэтому в очередях и стеках хвост явно обнуляют. В
стандартной библиотеке так делают slices.Delete (с Go 1.22) и
slices.Insert: они зануляют освободившиеся ячейки.
Удаление элементов и сложность операций
В таблице встретится слово амортизированно: это значит «в среднем
по длинной серии операций». Один append изредка стоит дорого
(реаллокация с копированием всего массива), но такие дорогие шаги
случаются всё реже по мере роста, и если размазать их стоимость по всем
вызовам, выходит константа.
| Операция | Идиома | Сложность | Комментарий |
|---|---|---|---|
| Добавить в конец | s = append(s, v) |
O(1) амортизированно | худший случай O(n) при росте массива |
| Удалить с конца | s = s[:len(s)-1] |
O(1) | обнули s[len(s)-1], если это указатель |
| Удалить из начала | s = s[1:] |
O(1) | но начало массива больше не освободить |
| Удалить из начала (с копией) | s = append(s[:0], s[1:]...) |
O(n) | сдвигает всё, массив тот же |
| Удалить из середины (порядок важен) | s = slices.Delete(s, i, i+1) |
O(n) |
эквивалент append(s[:i], s[i+1:]...) +
зануление
|
| Удалить из середины (порядок не важен) | s[i] = s[len(s)-1]; s = s[:len(s)-1] |
O(1) | «swap-remove», ломает порядок |
| Вставить в середину | s = slices.Insert(s, i, v) |
O(n) | сдвиг вправо + возможный рост |
| Фильтрация на месте | s = slices.DeleteFunc(s, pred) |
O(n) | без лишней аллокации |
| Поиск |
slices.Index /
slices.BinarySearch
|
O(n) / O(log n) | бинарный — только по отсортированному |
// Фильтрация без аллокации: пишем в тот же массив
s = s[:0]
for _, v := range src {
if keep(v) { s = append(s, v) }
}
// Очередь на слайсе: s = s[1:] в бесконечном цикле даёт утечку
q := make([]int, 0, 8)
for {
q = append(q, produce()) // хвост растёт вправо
head := q[0]
q = q[1:] // окно ползёт вправо, cap уменьшается
_ = head
// cap(q) убывает → append периодически выделяет новый массив и копирует,
// а «отрезанное» начало старого массива недостижимо, но живо до следующего роста.
// Память пилообразно растёт. Правильно: кольцевой буфер, container/list,
// или copy(q, q[1:]); q = q[:len(q)-1]
}
copy: семантика
n := copy(dst, src) копирует min(len(dst),
len(src)) элементов и возвращает это число. Обрати внимание:
смотрит на len, а не на cap, вот самая
частая ошибка. При перекрытии областей копирование тоже работает корректно
(внутри memmove). Особый случай: copy(dst []byte, src
string) разрешён.
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
fmt.Println(copy(dst, src), dst) // 3 [1 2 3]
bad := make([]int, 0, 10)
fmt.Println(copy(bad, src), bad) // 0 [] — len(dst)==0, ничего не скопировалось
ok := make([]int, len(src))
copy(ok, src) // 5
// Сдвиг влево с перекрытием работает как надо
copy(src, src[1:]) // [2 3 4 5 5]
n := copy(make([]byte, 4), "привет") // 4 байта строки
nil-слайс и пустой слайс
var s []int |
s := []int{} |
make([]int, 0) |
|
|---|---|---|---|
Поле array |
nil |
указатель на runtime.zerobase |
то же |
len / cap |
0 / 0 | 0 / 0 | 0 / 0 |
s == nil |
true | false | false |
append |
работает | работает | работает |
range, len |
работают | работают | работают |
json.Marshal |
null |
[] |
[] |
| Аллокация | нет | нет (нулевой размер) | нет |
Внутри программы разница не важна: nil-слайс это полноценный
пустой слайс, и идиоматично объявлять именно var s []T.
Разница вылезает на границе: в JSON nil сериализуется
в null, а пустой в [], и фронтенд от
null обычно падает. Поэтому в DTO-структурах поля-слайсы либо
инициализируют []T{}, либо помечают omitempty. И
никогда не сравнивай слайсы с nil для проверки «пусто», проверяй
len(s) == 0.
Сравнение и многомерность
Слайсы не comparable: == для них запрещён компилятором
(разрешено только сравнение с nil). Причина не техническая, а
семантическая: непонятно, что считать равенством, совпадение заголовков,
поэлементное сравнение или глубокое? Go отказался выбирать. Сравнивают
через slices.Equal (быстро, поэлементно, без рефлексии),
slices.EqualFunc, bytes.Equal для
[]byte или reflect.DeepEqual (медленно, но «глубоко»).
a := []int{1, 2, 3}
b := []int{1, 2, 3}
// _ = a == b // ошибка: slice can only be compared to nil
fmt.Println(slices.Equal(a, b)) // true (Go 1.21+)
fmt.Println(reflect.DeepEqual(a,b))// true, но в ~100 раз медленнее
fmt.Println(bytes.Equal([]byte("x"), []byte("x"))) // true
// DeepEqual считает nil и пустой слайс разными
fmt.Println(reflect.DeepEqual([]int(nil), []int{})) // false
fmt.Println(slices.Equal([]int(nil), []int{})) // true — сравнивает содержимое
Многомерного слайса в Go нет, есть слайс слайсов. Каждая строка это отдельный заголовок и отдельный массив, строки бывают разной длины (jagged), и памяти это стоит дороже, чем плоский массив.
// Обычный способ: n аллокаций + 1
grid := make([][]int, rows)
for i := range grid { grid[i] = make([]int, cols) }
// Ловушка: одна общая строка на всех
row := make([]int, cols)
bad := make([][]int, rows)
for i := range bad { bad[i] = row } // все строки — один массив
bad[0][0] = 7
fmt.Println(bad[1][0]) // 7
// Быстрый способ: одна аллокация, лучшая локальность
flat := make([]int, rows*cols)
grid2 := make([][]int, rows)
for i := range grid2 { grid2[i] = flat[i*cols : (i+1)*cols : (i+1)*cols] }
Что выведет код?
// (A)
s1 := []int{1, 2, 3, 4, 5}
s2 := s1[1:3]
s2[0] = 99
s2 = append(s2, 100)
fmt.Println(s1)
fmt.Println(s2, len(s2), cap(s2))
// (B)
a := make([]int, 3, 4)
b := append(a, 1)
c := append(a, 2)
b[0] = 7
fmt.Println(a, b, c)
fmt.Println(b[3], c[3])
(A) → [1 99 3 100 5], затем
[99 3 100] 3 4. s2 имеет
len=2, cap=4 (от индекса 1 до конца массива), поэтому
append не выделяет новый массив, а пишет в
s1[3].
(B) → [7 0 0] [7 0 0 2] [7 0 0 2], затем 2
2. У a есть запас cap=4, поэтому оба
append ничего не выделяют, а пишут в одну и ту же ячейку с
индексом 3: второй затёр единицу двойкой. b, c и
a смотрят в один массив тремя заголовками, поэтому b[0]
= 7 видно везде. Если бы a создали как
make([]int, 3) (то есть cap=3), каждый
append выделил бы свой массив и ответ стал бы [0 0 0]
[7 0 0 1] [0 0 0 2], а b[0] = 7 было бы видно только в
b. На этой развилке и ловят: решай по картинке заголовков, а
не по интуиции.
Вопросы
16Массив [N]T |
Слайс []T |
|
|---|---|---|
| Длина | часть типа, константа времени компиляции | поле len, меняется в рантайме |
| Что в переменной | сами элементы, подряд | 24 байта: указатель, len, cap |
| Копирование | все элементы | только заголовок, массив общий |
| Передача в функцию | дорого при больших N | всегда 24 байта |
Сравнение == |
да, если элементы comparable | нет, только с nil |
| Ключ мапы | да | нет |
| Рост | невозможен | append |
| Zero value | массив нулей | nil: ptr=nil, len=0, cap=0 |
arr := [3]int{1, 2, 3}
sl := []int{1, 2, 3}
fmt.Println(unsafe.Sizeof(arr), unsafe.Sizeof(sl)) // 24 24 — совпало случайно
arr2 := [10]int{}
sl2 := make([]int, 10)
fmt.Println(unsafe.Sizeof(arr2), unsafe.Sizeof(sl2)) // 80 24
Практически: массивы берут для фиксированных структур (хеши
[32]byte, UUID [16]byte, ключи мап, стековые
буферы), слайсы во всём остальном. Массив почти всегда живёт внутри слайса
или структуры, а не сам по себе.
«Слайс это не контейнер, а вид (view) на массив. Массив владеет
памятью, слайс только смотрит. Поэтому два слайса смотрят на одну память,
а один слайс после append может перестать смотреть на прежнюю».
unsafe.Pointer на первый элемент окна,
len (граница индексации) и
cap (граница нарезки и запас под append).
type slice struct { // runtime/slice.go
array unsafe.Pointer
len int
cap int
}
Инварианты
-
0 <= len <= cap. Нарушить нельзя:make([]int, 5, 3)не скомпилируется. -
Индексация проверяется по
len:s[len(s)]→ паникаindex out of range. -
Нарезка проверяется по
cap:s[:cap(s)]легально и «воскрешает» элементы за пределом len;s[:cap(s)+1]→ паникаslice bounds out of range. -
capсчитается от начала окна до конца массива, а не от начала массива. Поэтому уs[2:]cap меньше, чем уs.
s := make([]int, 3, 10)
fmt.Println(len(s), cap(s)) // 3 10
t := s[2:]
fmt.Println(len(t), cap(t)) // 1 8 — cap уменьшился на смещение
u := s[:cap(s)]
fmt.Println(len(u), cap(u)) // 10 10 — «расширили» окно до cap
// Посмотреть на указатель без reflect.SliceHeader (он deprecated с Go 1.21):
fmt.Printf("%p %p\n", unsafe.SliceData(s), unsafe.SliceData(t)) // t сдвинут на 16 байт
Заголовок это значение, значит он живёт на стеке вызывающего и
копируется при каждой передаче. Отсюда: передавать *[]T «ради
скорости» бессмысленно (24 байта против 8, разница в пределах шума, зато
появляется разыменование). Отсюда же и то, что слайс в структуре занимает
24 байта и содержит указатель, то есть структура становится «интересной»
GC: при сканировании кучи её придётся обходить.
make, нарезка
существующего массива/слайса. make([]T, 0, n) —
преаллокация: один раз выделили память, дальше
append только пишет, без перевыделений и
копирований.
a := []int{1, 2, 3} // 1) литерал: len=3 cap=3
b := make([]int, 5) // 2) make: len=5 cap=5, заполнен нулями
c := make([]int, 0, 5) // make с cap: len=0 cap=5
d := arr[1:4] // 3) нарезка: делит массив с arr
var e []int // + nil-слайс: len=0 cap=0, аллокации нет
f := new([5]int)[:] // + экзотика: массив в куче + слайс на него
Почему преаллокация важна
Каждое перевыделение это mallocgc плюс memmove
всего содержимого. Наполняешь слайс из 10 000 элементов без ёмкости,
получаешь около 20 аллокаций и суммарно около 20 000 скопированных
элементов; вдобавок промежуточные массивы становятся мусором и дают работу
GC. С make([]T, 0, n) будет одна аллокация и ноль копирований.
// Типичная разница на бенчмарке (n = 10 000, []int)
// BenchmarkNoPrealloc ~ 45 000 ns/op 386 KB/op 20 allocs/op
// BenchmarkPrealloc ~ 9 000 ns/op 81 KB/op 1 allocs/op
// Чаще всего это забывают при маппинге из БД/gRPC:
out := make([]DTO, 0, len(rows)) // длина известна заранее — грех не воспользоваться
for _, r := range rows { out = append(out, toDTO(r)) }
1) make([]T, n) вместо make([]T, 0, n):
получаешь 2n элементов, первые n нулевые. Линтер
makezero это ловит. 2) «Преаллокация всегда хорошо»: если
n приходит снаружи (из тела запроса), то make([]T, 0,
n) превращается в вектор DoS. Клиент пришлёт n = 1e9,
и сервер ляжет по OOM. Нужен верхний предел.
func setFirst(s []int) { s[0] = 100 } // видно снаружи
func appendOne(s []int) { s = append(s, 4) } // не видно
func clear(s []int) { s = nil } // не видно
func appendPtr(s *[]int) { *s = append(*s, 4) }// видно
s := []int{1, 2, 3}
setFirst(s); fmt.Println(s) // [100 2 3]
appendOne(s); fmt.Println(s) // [100 2 3]
clear(s); fmt.Println(s) // [100 2 3]
appendPtr(&s); fmt.Println(s) // [100 2 3 4]
Почему так
В функции появляется своя переменная s, копия
заголовка. Запись s[0] = 100 идёт по указателю в общий
массив, поэтому видна всем. А s = append(...) и s =
nil меняют только локальную копию заголовка, которая умирает вместе
с кадром функции.
Три способа отдать изменённый слайс наружу
-
Вернуть его. Идиоматично, так устроен сам
append:func add(s []int, v int) []int. -
Передать
*[]T, когда возврат неудобен (пишешь интерфейс, заполняешь через колбэк). -
Передать буфер и вернуть срез его же: паттерн
AppendXxx(dst []byte, ...) []byteиз стандартной библиотеки (strconv.AppendInt,time.Time.AppendFormat). Вызывающий владеет памятью, аллокаций ноль.
«Слайс всегда передаётся по значению, но это значение содержит указатель. Поэтому функция становится совладельцем массива, но не владельцем длины».
len+k > cap. Стратегия: удвоение до 256
элементов, дальше cap += (cap + 3*256)/4 —
плавный переход к 1.25x; сверху округление до size class
аллокатора.
Алгоритм
-
newLen = len(s) + количество добавляемых. -
Если
newLen <= cap(s), то записать элементы на место и вернуть заголовок с новымlen. Аллокаций нет, массив тот же, изменения видны всем совладельцам. -
Иначе
runtime.growslice: посчитатьnewcap, вызватьmallocgc,memmoveстарых элементов, дописать новые. Старый массив станет мусором, если на него больше никто не смотрит.
Как менялась стратегия
| Версия | Правило |
|---|---|
| до Go 1.18 |
если cap < 1024 →
cap*2, иначе cap*1.25 в
цикле. Порог 1024, коэффициент прыгал разом с 2.0 до
1.25
|
| Go 1.18+ |
если newLen > 2*oldCap →
newcap = newLen; иначе если
oldCap < 256 →
newcap = 2*oldCap; иначе в цикле
newcap += (newcap + 3*256) / 4, пока
newcap < newLen
|
Новая формула даёт множитель, который плавно спадает: примерно 1.75x при cap 256, 1.5x при 512, 1.44x при 1024 и приближается к 1.25x на больших размерах. Меняли ради того, чтобы убрать разрыв: раньше слайс на 1023 элемента рос вдвое, а на 1025 всего на четверть, и профиль потребления памяти скакал.
Округление до size class
Посчитанный newcap рантайм умножает на sizeof(T)
и округляет вверх до ближайшего класса размеров аллокатора Go (8, 16, 24,
32, 48, 64, 80, 96, 112, 128, 144…). Разницу он возвращает обратно в
cap. Поэтому в реальности видишь cap 848 вместо 640 и cap 8
вместо 6, а прогрессии для []byte, []int и
[]struct{...} отличаются.
Если не помнишь формулу точно, не говори «всегда x2». Скажи: «маленькие слайсы удваиваются, после нескольких сотен элементов коэффициент плавно снижается примерно к 1.25, точный порог 256 элементов с Go 1.18, раньше был 1024. Плюс рантайм округляет размер до класса аллокатора, поэтому cap бывает больше расчётного». Это ровно тот уровень, которого ждут от мидла.
cap. Хватает —
массив тот же, и исходный слайс «увидит» запись. Не хватает
— массив новый, исходный останется со старыми данными.
func addOne(s []int) []int {
s = append(s, 42)
s[0] = -1
return s
}
// Случай 1: cap хватает
a := make([]int, 3, 10) // len=3 cap=10
b := addOne(a)
fmt.Println(a, len(a), cap(a)) // [-1 0 0] 3 10 ← a[0] изменился
fmt.Println(b, len(b), cap(b)) // [-1 0 0 42] 4 10
fmt.Println(&a[0] == &b[0]) // true — один массив
// Случай 2: cap не хватает
c := make([]int, 3, 3) // len=3 cap=3
d := addOne(c)
fmt.Println(c, len(c), cap(c)) // [0 0 0] 3 3 ← c не тронут
fmt.Println(d, len(d), cap(d)) // [-1 0 0 42] 4 6
fmt.Println(&c[0] == &d[0]) // false — разные массивы
Почему это опасно на практике
Поведение функции зависит от cap аргумента, а cap вызывающий обычно не контролирует и даже не видит. Один и тот же код в одном месте работает «правильно», а в другом молча портит чужие данные. Баг противный: с виду недетерминированный, а по механике железно определённый.
Как правильно писать такие функции
-
Не мутировать вход без явного контракта. Нужна модификация, работай
с копией:
s = slices.Clone(in). -
Если функция обязана только дописывать, верни результат и обяжи
вызывающего присвоить (
s = f(s)), как делает самappend. -
Отдаёшь наружу подслайс, обрезай cap:
return buf[:n:n]илиslices.Clip. - Документируй в комментарии: «функция может изменить элементы аргумента». Это часть контракта.
«А как проверить, что массив тот же?» Сравнить адреса первых элементов
(&a[0] == &b[0]) или unsafe.SliceData(a) ==
unsafe.SliceData(b). Сравнивать cap недостаточно: он
может совпасть случайно.
append — изменится, если у
s2 остался запас cap (то есть если
s1 длиннее пяти элементов или у него есть
свободный хвост).
s1 := []int{0, 1, 2, 3, 4, 5, 6, 7}
s2 := s1[2:5] // len=3, cap=6 (от индекса 2 до конца массива)
s2[0] = 99
fmt.Println(s1) // [0 1 99 3 4 5 6 7] ← запись видна
s2 = append(s2, 100) // len 3→4, cap 6 — места хватает
fmt.Println(s1) // [0 1 99 3 4 100 6 7] ← затёрли s1[5]
fmt.Println(s2) // [99 3 4 100]
s2 это окно внутри массива s1. Первая запись
очевидна. Вторая главный подвох: append пишет в первую
ячейку за пределом len, но внутри cap, а эта ячейка принадлежит
s1. Ни ошибки, ни предупреждения, просто данные соседа заменились.
Когда s1 НЕ изменится
s1 := []int{0, 1, 2, 3, 4}
s2 := s1[2:5] // len=3, cap=3 — окно упирается в конец массива
s2 = append(s2, 100) // cap исчерпан → новый массив
fmt.Println(s1, s2) // [0 1 2 3 4] [2 3 4 100]
// Явная защита — full slice expression:
s3 := s1[2:5:5] // cap=3 принудительно
// или
s4 := slices.Clip(s1[2:5]) // то же самое, читаемее
Не отвечай «да» или «нет», отвечай механикой: «запись по индексу
всегда видна, потому что массив общий. append виден, если
cap(s2) > len(s2), то есть если в массиве за окном ещё
есть место. Проверяется одной строкой: cap(s2)».
encoding/json наполняет слайс
через append в том же массиве, пока хватает
cap, — значит запишет прямо поверх элементов
s1 начиная с индекса 30 и дальше. Хвост при этом
не зануляется — декодер просто укорачивает длину.
Что делает Unmarshal со слайсом
Декодер сначала пытается переиспользовать переданную память:
выставляет len = 0 и добавляет элементы через
append (в коде encoding/json это
growSlice/переиспользование v.SetLen). Пока
cap хватает, новый массив не выделяется, элементы ложатся в
тот же буфер. Когда входной массив короче, чем len слайса,
декодер просто укорачивает длину через SetLen. Хвост он не
зануляет, старые элементы так и остаются в массиве (проверено на
go1.27 и с GOEXPERIMENT=nojsonv2).
s1 := make([]int, 100)
for i := range s1 { s1[i] = i }
sub := s1[30:40] // len=10, cap=70
_ = json.Unmarshal([]byte(`[1,2,3]`), &sub)
fmt.Println(sub) // [1 2 3] — len стал 3, cap остался 70
fmt.Println(s1[28:44]) // [28 29 1 2 3 33 34 35 36 37 38 39 40 41 42 43]
// ^^^^^^ затёрли s1[30..32]: массив тот же, не копия
// Хвост (s1[33..39]) декодер не трогает: он просто ставит len=3,
// а старые элементы остаются лежать в массиве.
А если во входном JSON элементов больше, чем cap подслайса (в
примере больше 70), декодер выделит новый массив, скопирует туда и дальше
будет писать уже в него. Тогда s1 пострадает частично: первые
70 элементов затрутся, остальные уйдут в новую память.
Вопрос проверяет, понимаешь ли ты, что любая библиотека, которая пишет
в слайс через append, работает с твоим массивом. Это не особенность
json: то же самое сделают io.ReadFull в
buf[10:20], sql.Rows.Scan в подслайс,
protobuf. Правильный приём: отдать декодеру отдельный
слайс (var dst []int), а потом скопировать через
copy(s1[30:], dst) и контролировать длину самому.
sub := s1[30:40:40] задаёт cap 10, и при более длинном JSON
декодер выделит новую память вместо порчи s1[40:]. Полностью
проблему это не снимает: первые 10 элементов всё равно перезапишут.
s = s[1:] за O(1) (но
память начала не освобождается) или сдвигом за O(n). Из
середины — slices.Delete за O(n), либо
swap-remove за O(1), если порядок не важен.
// Начало, O(1): просто сдвигаем окно
s = s[1:] // массив тот же, начало недостижимо, но живо
// Начало, O(n): сдвигаем данные, окно остаётся на месте
s = append(s[:0], s[1:]...) // или copy(s, s[1:]); s = s[:len(s)-1]
// Середина с сохранением порядка, O(n)
s = append(s[:i], s[i+1:]...) // классика
s = slices.Delete(s, i, i+1) // Go 1.22+: то же самое, но зануляет хвост
// Середина без сохранения порядка, O(1)
s[i] = s[len(s)-1]
s[len(s)-1] = zero // обнулить, если элементы содержат указатели
s = s[:len(s)-1]
// Конец, O(1)
s[len(s)-1] = zero
s = s[:len(s)-1]
// Пачкой по предикату, O(n) и без аллокаций
s = slices.DeleteFunc(s, func(v T) bool { return !keep(v) })
Про зануление
Если элементы это указатели, интерфейсы, строки, слайсы, мапы или
структуры с такими полями, «удалённое» значение остаётся в массиве между
len и cap и держится сборщиком мусора. Поэтому в
аккуратном коде ячейку явно обнуляют. slices.Delete с Go 1.22
делает это сам.
Если операция «взять из начала» частая (очередь, буфер задач), слайс
плохой выбор: либо O(n) на каждый pop, либо утечка. Бери кольцевой
буфер,
container/list для настоящего связного списка или
буферизованный канал, если это межгорутинная очередь. Умение это назвать
отличает «знаю синтаксис» от «понимаю стоимость».
Что именно происходит
-
s = s[1:]двигаетarrayвперёд на один элемент и уменьшаетlenиcap. Освободившаяся ячейка в начале никуда не девается, она часть того же выделенного блока памяти. -
appendрасходует остатокcap. Черезcapитераций запас кончается и вызываетсяgrowslice: аллокация нового массива и копированиеlenэлементов. - Старый массив после этого становится мусором целиком, но только в момент роста. До этого он живёт полностью, включая уже «удалённые» элементы в начале.
- Если элементы указатели, всё, на что они ссылались, тоже остаётся живым. Это утечка уже не 8 байт на элемент, а целых объектов.
// Проверим на деле
s := make([]int, 0, 4)
for i := 0; i < 12; i++ {
s = append(s, i)
s = s[1:]
fmt.Println(i, len(s), cap(s))
}
// вывод (go1.27), i len cap:
// 0 0 3 | 1 0 2 | 2 0 1 | 3 0 0 ← окно доехало до конца исходного массива
// 4 0 3 | 5 0 2 | 6 0 1 | 7 0 0 ← append выделил новый массив (cap=4), и всё повторилось
// 8 0 0 | 9 0 0 | 10 0 0 | 11 0 0 ← дальше каждый append выделяет память заново
// Какой cap даст очередная переаллокация, решает аллокатор (тут 4, потом 1),
// но cap монотонно тает до нуля, и с этого момента каждая итерация
// это отдельная аллокация. Хуже некуда.
Как делать правильно
// 1) Кольцевой буфер: O(1) на push и pop, память не растёт
type Ring[T any] struct {
buf []T
head, tail int
size int
}
// 2) Остаёмся на слайсе, но сдвигаем данные, а не окно
head := q[0]
q[0] = zero
copy(q, q[1:])
q = q[:len(q)-1] // O(n) на pop, зато массив не «уползает»
// 3) Периодически уплотняем: голова ушла далеко, пора переезжать
if offset > len(q) {
q = append(q[:0:0], q...) // новый массив ровно по размеру
offset = 0
}
// 4) Для очереди между горутинами хватит канала
График RSS «пила с растущим средним»,
runtime.MemStats.Mallocs растёт линейно, в
профиле alloc_space первым номером стоит
runtime.growslice. Это классический симптом
«очередь на слайсе». Диагностируется за минуту через
go tool pprof -alloc_space.
cap = max-low — он не
лечит утечку, а защищает от того, чтобы подслайс писал в
чужой хвост.
Утечка
func loadAndKeepPrefix(path string) []byte {
data, _ := os.ReadFile(path) // 100 МБ
return data[:16] // держит все 100 МБ
}
func loadOK(path string) []byte {
data, _ := os.ReadFile(path)
return bytes.Clone(data[:16]) // 16 байт, остальное соберёт GC
}
Аллокатор Go выделяет объект одним куском в span; сборщик отмечает живым
весь объект, если на него есть хоть один указатель. «Частичное»
освобождение невозможно в принципе. То же со строками: substr :=
bigString[10:20] удерживает всю большую строку.
Третий индекс — про другое
buf := make([]byte, 0, 1024)
buf = append(buf, "header"...)
// Отдаём наружу кусок своего буфера
view := buf[0:6] // cap = 1024 — вызывающий сделает append и затрёт наш буфер
safe := buf[0:6:6] // cap = 6 — любой append у вызывающего выделит свою память
safe2 := slices.Clip(buf[0:6]) // то же самое, читаемее (Go 1.21+)
| Проблема | Лечение |
|---|---|
| Маленький подслайс держит большой массив |
slices.Clone /
bytes.Clone / copy в новый
слайс
|
| Подслайс через append портит соседние элементы |
s[low:high:high] или
slices.Clip
|
| «Удалённые» элементы-указатели держат объекты |
занулять ячейки; slices.Delete делает
это сам
|
| Подстрока держит большую строку | strings.Clone(sub) (Go 1.18+) |
go tool pprof -inuse_space покажет, что живая память сидит в
os.ReadFile / io.ReadAll, хотя логика давно
вернула «маленький» результат. Второй инструмент:
runtime.SetFinalizer в тесте на большой объект. Финализатор
не вызвался после runtime.GC(), значит кто-то держит ссылку.
copy(dst, src) копирует
min(len(dst), len(src)) элементов и возвращает
это число. Смотрит на len, а не на cap — отсюда самая
частая ошибка.
n := copy(dst, src)
dst := make([]int, 0, 100) // len=0
src := []int{1, 2, 3}
fmt.Println(copy(dst, src)) // 0 — ничего не скопировано
dst2 := make([]int, 3)
fmt.Println(copy(dst2, src)) // 3
dst3 := make([]int, 10)
fmt.Println(copy(dst3, src)) // 3 — остальные 7 остались нулями
Особенности
-
Перекрытие безопасно: под капотом
memmove, а неmemcpy.copy(s, s[1:])корректно сдвигает влево. -
Специальный случай
copy(dst []byte, src string)язык разрешает: строка не конвертируется в[]byteи аллокации не происходит. -
Типы должны совпадать (с точностью до алиасов элементов);
copy([]int, []int64)не скомпилируется. -
Копирование поверхностное: у
[]*Tскопируются указатели, объекты останутся общими. -
С Go 1.21 есть
slices.Clone(s): внутриappend(s[:0:0], s...), возвращает новый слайс той же длины.
// Идиома «клонировать»: три эквивалентных варианта
c1 := slices.Clone(s) // Go 1.21+, читаемо
c2 := append([]int(nil), s...) // работает везде
c3 := make([]int, len(s)); copy(c3, s) // самый явный
// Идиома «дописать строку в байтовый буфер без аллокации»
buf = append(buf, "hello"...) // да, так можно
dst := make([]T, 0, len(src)); copy(dst, src) компилируется,
ничего не делает, и потом «данные пропали». Нужно make([]T,
len(src)). Ловится линтером staticcheck (проверка
SA4006/SA4010) и тестом.
&s[i] легален, потому
что адрес элемента стабилен внутри массива. Опасно тем, что
после append с ростом слайс переезжает в новый
массив, а указатель продолжает смотреть на старый —
«мёртвый» — и изменения расходятся.
s := make([]int, 3, 3)
p := &s[0]
*p = 10
fmt.Println(s) // [10 0 0]
s = append(s, 4) // cap исчерпан → новый массив
*p = 99
fmt.Println(s, *p) // [10 0 0 4] 99 — запись ушла в старый массив, s её не видит
Где это стреляет в реальном коде
// 1) Цикл range даёт копию элемента: &v это адрес копии
for _, v := range items {
register(&v) // до Go 1.22 — один адрес на все вызовы; с 1.22 разные, но это копии
}
for i := range items {
register(&items[i]) // правильно
}
// 2) Указатели на элементы + append в том же цикле
var ptrs []*Item
for i := range items {
ptrs = append(ptrs, &items[i])
items = append(items, newItem()) // ← массив переехал, ptrs протухли
}
// 3) Метод с pointer receiver у элемента слайса
type Counter struct {
n int
}
func (c *Counter) Inc() { c.n++ }
cs := []Counter{{}, {}}
cs[0].Inc() // ок: компилятор берёт &cs[0]
for _, c := range cs { c.Inc() } // бесполезно: инкрементим копию
Что важно проговорить
- Элемент слайса адресуем, элемент мапы нет. Причина ровно в этом: мапа переселяет элементы при росте, слайс не переселяет (переезжает целиком, но старая память остаётся валидной).
- Указатель удерживает весь массив от сборки, ещё один канал утечки.
-
С Go 1.22 переменная цикла своя на каждой итерации, поэтому
&vвrangeперестал давать «один адрес на всех». Но это по-прежнему адрес копии, а не элемента: семантика не изменилась, изменилось только время жизни.
Держать *T на элемент слайса можно ровно до первого
append. Если время жизни указателя больше, храни либо индекс,
либо []*T (слайс указателей на самостоятельные объекты), а не []T.
len == 0 и
cap == 0, оба поддерживают append,
range и len. Отличаются только
указателем внутри заголовка, результатом
s == nil и сериализацией в JSON.
var a []int // nil-слайс: array=nil
b := []int{} // пустой: array=&runtime.zerobase
c := make([]int, 0) // пустой
fmt.Println(a == nil, b == nil, c == nil) // true false false
fmt.Println(len(a), cap(a), len(b), cap(b)) // 0 0 0 0
a = append(a, 1) // работает: append умеет растить nil
for range a {} // работает
fmt.Println(a[0]) // 1
j1, _ := json.Marshal(struct {
S []int
}{nil}) // {"S":null}
j2, _ := json.Marshal(struct {
S []int
}{[]int{}}) // {"S":[]}
Почему append работает с nil
append смотрит только на len и cap.
У nil-слайса они нулевые, значит сразу идёт ветка growslice,
которая выделяет новый массив и возвращает новый заголовок. Указатель
nil никогда не разыменовывается, потому что копировать
нечего. Та же логика в copy(nil, src): вернёт 0 без паники.
Где разница реально важна
-
JSON-контракты:
nullвместо[]ломает клиентов. Лечится так: инициализироватьout := []T{}в DTO или ставитьomitempty(тогда поля не будет вовсе). -
reflect.DeepEqualсчитает их разными, поэтому тесты на nil/пустой падают.slices.Equalсчитает равными. - Protobuf/gRPC: repeated-поля приходят как nil, если элементов нет.
Внутри кода объявляй var s []T: дешевле (нет даже нулевой
аллокации) и идиоматичнее. Проверяй пустоту через len(s) ==
0, а не через s == nil: первое верно для обоих
случаев. Явный []T{} оставь для границы наружу, для JSON и API-ответов.
== запрещён компилятором, потому
что нет единственно верного смысла равенства (заголовки?
элементы? глубоко?). Сравнивают slices.Equal,
bytes.Equal или reflect.DeepEqual.
Почему запрещено
- Побайтовое сравнение заголовков сравнивало бы адреса, а не данные. Почти всегда не то, чего ждёт человек, и вдобавок непереносимо.
-
Поэлементное сравнение это O(n) под видом оператора
==, который во всех остальных местах языка стоит O(1). Go принципиально не прячет дорогие операции за короткий синтаксис. -
Слайсы бывают циклическими (
s[0]содержитsчерезany), и «глубокое» сравнение потребовало бы обнаружения циклов в рантайме. -
Следствие того же ограничения: слайс не может быть ключом
мапы и не удовлетворяет
comparable.
a, b := []int{1,2,3}, []int{1,2,3}
// a == b // ошибка компиляции:
// // invalid operation: a == b (slice can only be compared to nil)
fmt.Println(a == nil) // false — с nil сравнивать можно
slices.Equal(a, b) // true, Go 1.21+, O(n), без рефлексии
slices.EqualFunc(a, b, func(x, y int) bool { return x == y })
bytes.Equal([]byte("a"), []byte("a")) // для []byte быстрее всего
reflect.DeepEqual(a, b) // true, но медленно и nil != []T{}
// Множества сортируем и сравниваем
slices.Sort(a); slices.Sort(b)
fmt.Println(slices.Equal(a, b)) // true
// В тестах
if diff := cmp.Diff(want, got); diff != "" { t.Errorf("mismatch (-want +got):\n%s", diff) }
| Способ | Скорость | nil vs []T{} | Где применять |
|---|---|---|---|
slices.Equal |
быстро, инлайнится | равны | прод-код, comparable элементы |
bytes.Equal |
очень быстро (asm) | равны | []byte |
reflect.DeepEqual |
медленно | не равны | тесты, произвольные структуры |
go-cmp |
медленно | настраивается | тесты — даёт читаемый diff |
// Правильно: rows+1 аллокаций
grid := make([][]int, rows)
for i := range grid {
grid[i] = make([]int, cols)
}
// Ловушка 1: одна строка на всех
row := make([]int, cols)
bad := make([][]int, rows)
for i := range bad { bad[i] = row }
bad[0][0] = 7
fmt.Println(bad[1][0]) // 7 — все строки указывают на один массив
// Ловушка 2: копия неглубокая
cp := make([][]int, len(grid))
copy(cp, grid) // скопировали заголовки, данные общие
cp[0][0] = 9
fmt.Println(grid[0][0]) // 9
// Ловушка 3: append к строке может её «отвязать»
grid[0] = append(grid[0], 1) // если cap исчерпан — новый массив только у этой строки
Многомерный массив — это другое
[3][4]int это настоящий двумерный массив: 12 int подряд, одно
значение, копируется целиком, comparable. [][]int это слайс
из трёх заголовков, каждый из которых указывает куда-то ещё. Разница в
памяти: [3][4]int занимает 96 байт одним куском,
[][]int берёт 24 байта заголовка + 3 × 24 байта заголовков
строк + 3 × 32 байта данных, и всё это в разных местах кучи.
Плоское представление — быстрый и правильный способ
type Matrix struct {
data []float64
rows, cols int
}
func New(rows, cols int) *Matrix {
return &Matrix{data: make([]float64, rows*cols), rows: rows, cols: cols}
}
func (m *Matrix) At(i, j int) float64 { return m.data[i*m.cols+j] }
func (m *Matrix) Set(i, j int, v float64) { m.data[i*m.cols+j] = v }
// Или гибрид: одна аллокация + удобная индексация grid[i][j]
flat := make([]int, rows*cols)
grid := make([][]int, rows)
for i := range grid {
grid[i] = flat[i*cols : (i+1)*cols : (i+1)*cols]
}
Одна аллокация вместо rows+1; данные лежат непрерывно, значит
обход по строкам идёт последовательно и попадает в предвыборку кэша; GC
обходит один объект вместо rows+1. На матрице 1000×1000
полный обход обычно быстрее в 2–4 раза, а давление на GC падает на
порядок. Именно так устроены image.RGBA (поле Pix
[]uint8 плюс Stride) и большинство численных библиотек.
1.3Мапы
Вторая по частоте тема после слайсов. Спрашивают не «как положить ключ», а почему нельзя взять адрес значения, почему порядок обхода случайный, что именно происходит при конкурентной записи и как мапа растёт. Ответы на всё это прячутся в устройстве хеш-таблицы, с него и начнём.
Что такое мапа и какой у неё контракт
Под map[K]V лежит хеш-таблица. Контракт языка:
амортизированные O(1) на вставку, поиск и удаление; ключи должны
быть comparable; порядок обхода не определён; мапа
не потокобезопасна. Переменная типа map[K]V
хранит один указатель (8 байт) на структуру
runtime.hmap в куче. Копируешь мапу, копируешь только
этот указатель, а nil-мапа и есть нулевой указатель.
m := map[string]int{"a": 1}
m2 := m // скопировали 8 байт указателя
m2["b"] = 2
fmt.Println(m) // map[a:1 b:2] — одна и та же таблица
fmt.Println(unsafe.Sizeof(m)) // 8
Зачем лезть внутрь и пять слов, без которых дальше не разобрать
Почти все «странности» мапы (запрет на &m[k],
случайный порядок обхода, падение всей программы при конкурентной
записи, память, которая не возвращается после удаления ключей)
растут из устройства хеш-таблицы:
это прямые следствия, а не капризы дизайнеров. Разберёшь устройство, и эти вопросы перестанут быть списком для
зубрёжки.
Но сначала пять слов. Дальше они встречаются в каждом абзаце.
1. Хеш-функция — превращает ключ в число
Хеш-функция берёт ключ любого размера (строку на 200 символов, структуру, число) и выдаёт одно число фиксированной длины, хеш. Требований два: одинаковые ключи всегда дают одинаковый хеш, разные дают по возможности разные и равномерно разбросанные. Обратно из хеша ключ не восстановить, да и не нужно: хеш работает как «номерок», по нему понятно, где искать.
2. Бакет (bucket) — корзина, в которую складывают ключи с одинаковым номерком
Внутри мапы массив корзин. Хеш ключа определяет номер корзины: взяли несколько младших бит хеша, получили индекс в массиве. Бакет и есть одна такая корзина; в классической реализации Go в неё помещается ровно 8 пар «ключ-значение».
Аналогия: камера хранения, где ячейки подписаны буквами. Фамилия «Петров» всегда идёт в ячейку «П», обходить все ячейки не надо, идёшь сразу в нужную. Отсюда и O(1) вместо O(n): ты не перебираешь ключи, а сразу прыгаешь в одну корзину.
3. Коллизия — два разных ключа попали в одну корзину
Коллизия случается, когда хеши двух разных ключей указали на один и тот же бакет. Не ошибка и не редкость: корзин конечное число, ключей сколько угодно. Хеш-таблица обязана уметь с ними жить: в бакете лежит несколько пар, и при поиске ключ придётся сравнить с теми, кто уже там лежит. Чем больше коллизий, тем ближе поиск к линейному перебору. Отсюда и все разговоры про «хорошую» хеш-функцию.
4. tophash — быстрый фильтр, чтобы не трогать сами ключи
Сравнивать длинные строки-ключи дорого. Поэтому рядом с восемью
слотами бакета Go держит массив из восьми байт:
tophash хранит старший байт хеша
каждого лежащего в слоте ключа, запомненный заранее. Поиск сначала
пробегает эти восемь байт (одна кэш-линия, никаких разыменований)
и сравнивает ключ целиком только там, где байт совпал. Значение
0 в tophash служебное, оно помечает
пустой слот.
5. Эвакуация — переезд элементов в новый массив бакетов
Когда корзин становится мало и они переполняются, мапа выделяет вдвое больший массив бакетов и постепенно перекладывает туда содержимое. Вот этот переезд и называют эвакуацией. Слово «постепенно» тут не для красоты: переложи десять миллионов ключей за одну вставку, и получишь фриз на сотни миллисекунд. Поэтому старый массив живёт рядом с новым, и каждая операция перевозит один-два бакета. Отсюда и случайный порядок обхода, и часть требований к тому, чего с мапой делать нельзя.
Классическое устройство: hmap и бакеты (Go до 1.24)
Исторически (а в первых версиях со Swiss-мапами ещё и с GOEXPERIMENT=noswissmap) мапа Go устроена как массив
бакетов с цепочками overflow-бакетов. Бакет вмещает ровно
8 пар ключ-значение.
tophash кэширует старший байт
хеша: неподходящие слоты отбрасываются, ключи никто не трогает,
в разбросанную по кэш-линиям память лезть не приходится.
Если бы бакет хранил [8]struct{K; V}, то между
K и V появился бы padding для
выравнивания. Для map[int64]int8 это дало бы 8 байт
padding на каждую пару, то есть бакет вырос бы почти вдвое.
Раздельные массивы [8]K и [8]V дают
padding максимум один раз на бакет. Мелочь, но именно такие
мелочи спрашивают, когда хотят проверить, читал ли ты рантайм.
Хеш-функция, seed и коллизии — подробности
Про хеш-функцию и коллизии договорились выше, дальше детали. Go выбирает хеш-функцию по типу ключа на этапе компиляции: для строк и байтовых срезов берётся memhash (на amd64 это вариант на инструкциях AES-NI, очень быстрый), числа прогоняются через простое смешивание, у структур комбинируются хеши полей.
Каждая мапа при создании получает свой случайный seed
(hash0). Это защита от hash flooding: без seed
злоумышленник, знающий алгоритм, подобрал бы ключи с одинаковыми
хешами и превратил все операции в O(n). Классическая DoS-атака на
веб-сервер, который кладёт заголовки запроса в мапу.
Коллизии Go разрешает методом цепочек (не по одному элементу, а бакетами по 8): сначала заполняются 8 слотов бакета, при переполнении выделяется overflow-бакет, дополнительная корзина на те же 8 слотов, она прицепляется к основной как вагон к вагону. Поиск идёт по цепочке, пока ключ не найдётся или вагоны не кончатся.
В Swiss Tables (Go 1.24+) подход другой, открытая адресация: лишних вагонов нет, при занятом месте элемент ложится в другую группу того же массива по заранее известному правилу («пробинг»). Разбросанных по куче overflow-бакетов и указателей между ними больше нет, значит и промахов кэша меньше.
Рост и эвакуация
Мапа растёт, когда срабатывает одно из двух условий:
-
Превышен load factor. Load factor считается как «сколько элементов лежит» на «сколько
корзин есть». Чем он выше, тем плотнее набиты бакеты и тем
длиннее цепочки при поиске. Порог в Go:
count > 6.5 * 2^B, то есть в среднем больше 6.5 элементов на бакет при вместимости 8. ТогдаBрастёт на единицу, а бакетов становится вдвое больше. -
Слишком много overflow-бакетов при нормальной
заполненности. Так бывает после массовых удалений. Тогда
включается
sameSizeGrow: массив бакетов того же размера, но элементы перекладываются плотно, и мусорные overflow-цепочки исчезают.
Переселение не происходит целиком за одну операцию. Разом
оно убило бы время отклика на больших мапах: одна
невезучая вставка встала бы на сотни миллисекунд. Вместо этого
старый массив остаётся в oldbuckets, а каждая
вставка/удаление эвакуирует один-два бакета. Пока эвакуация идёт,
чтение проверяет оба массива.
i
расщепляется ровно на два новых, i и
i + 2^oldB: добавился ещё один бит хеша. Из-за
этого вставка в мапу и получается амортизированной O(1), а не
гарантированной.
Go 1.24: Swiss Tables
В Go 1.24 мапу переписали целиком: вместо бакетов с
overflow-цепочками пришёл Swiss Table, дизайн из
absl::flat_hash_map (Google, 2017). Идея одна:
отделить метаданные от данных, чтобы за одно обращение к
памяти проверять сразу восемь слотов.
| Старая мапа (≤ Go 1.23) | Swiss Tables (Go 1.24+) | |
|---|---|---|
| Разрешение коллизий | цепочки overflow-бакетов | открытая адресация, квадратичный пробинг по группам |
| Метаданные | tophash [8]uint8 внутри бакета |
control word: 8 байт отдельно от данных |
| Проверка группы | цикл по 8 слотам | одно слово / SIMD (одна инструкция процессора обрабатывает сразу несколько значений), битовая маска совпадений |
| Большие мапы | один массив бакетов + инкрементальная эвакуация |
разбита на независимые таблицы (directory),
растёт по одной
|
| Выигрыш | — | чтение больших мап до ~30–60 % быстрее, вставка ~30 %, память примерно та же |
Изменилась только реализация: язык, семантика и гарантии
те же. Порядок обхода по-прежнему случайный, мапа по-прежнему не
потокобезопасна, &m[k] по-прежнему запрещён.
Разница видна в производительности и в том, что конкретные
последовательности итерации поменялись (тесты, которые на них
завязались, сломались, хотя некорректны они были и раньше).
Откатиться можно было через GOEXPERIMENT=noswissmap, пока опция жила: в Go 1.27 её уже нет. Заодно в 1.24 переписали
sync.Map на HashTrieMap.
Почему нельзя взять адрес значения: &m[key]
Потому что адрес нестабилен. Мапа растёт, начинается
эвакуация, и пары ключ-значение физически переезжают в другой
массив бакетов. Разреши язык &m[k], и после
первой же вставки указатель смотрел бы в старую память: тихие баги
ровно того сорта, которого Go старается не иметь. У слайса такой
проблемы нет, там переезжает весь массив сразу, а старый остаётся
валидным до сборки.
m := map[string]int{"a": 1}
// p := &m["a"] // ошибка компиляции: invalid operation: cannot take address of m["a"]
// m["a"]++ // а это можно: компилятор разворачивает в чтение + запись
// m["a"] += 5 // тоже можно
type S struct {
N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2 // ошибка: cannot assign to struct field ms["a"].N in map
v := ms["a"]; v.N = 2; ms["a"] = v // читаем, меняем, кладём обратно
// Или, что обычно правильнее, хранить указатели:
mp := map[string]*S{"a": {1}}
mp["a"].N = 2 // работает: адресуем то, на что указывает значение
«Почему m[k].Field = v не компилируется, а
m[k]++ компилируется?» Потому что значение в мапе
не адресуемо: инкремент компилятор разворачивает в
«прочитать значение, прибавить, записать обратно», а
присваивание полю потребовало бы адреса. Лечится просто:
прочитал в переменную, поменял, записал назад. Либо держи
map[K]*V.
Почему порядок итерации случайный
Это намеренная защита от неявной зависимости. Порядок
обхода и сам по себе зависит от размера таблицы, порядка вставок и
seed; он менялся бы от версии к версии и от запуска к запуску. А
окажись он стабильным «на практике», разработчики стали бы на него
полагаться, и любое улучшение рантайма ломало бы прод. Поэтому
команда Go пошла дальше и сделала рандомизацию явной:
mapiterinit выбирает
случайный стартовый бакет и случайное смещение внутри него
на каждый range.
m := map[int]string{1: "a", 2: "b", 3: "c"}
for k := range m { fmt.Print(k, " ") } // разный порядок при каждом запуске
// восемь запусков подряд дали: "1 2 3", "3 1 2", "1 2 3", "1 2 3",
// "1 2 3", "1 2 3", "2 3 1", "2 3 1" — на трёх ключах совпадения частые,
// но опираться на порядок нельзя
// Детерминированный обход: сортируем ключи
keys := make([]int, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }
// Go 1.23+: то же короче
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }
// оба цикла печатают одно и то же, всегда:
// 1 a
// 2 b
// 3 c
Компилятор оптимизирует конструкцию вида
for k, v := range m внутри
fmt.Println(m): fmt печатает мапу
отсортированной по ключам (с Go 1.12), чтобы вывод
воспроизводился в тестах и логах. Это поведение
fmt, а не мапы: сам range остаётся
случайным.
nil-мапа и мутации во время итерации
var m map[string]int // nil: указатель на hmap равен nil
fmt.Println(m == nil) // true
fmt.Println(len(m)) // 0
fmt.Println(m["x"]) // 0 — чтение из nil-мапы легально
v, ok := m["x"] // 0, false
for range m {} // ноль итераций, без паники
delete(m, "x") // no-op, без паники
m["x"] = 1 // panic: assignment to entry in nil map
Асимметрия объясняется просто: чтение честно вернёт нулевое
значение, не трогая память, а запись обязана эту память создать.
Указатель nil означает, что создавать негде.
Инициализировать мапу сам рантайм не может: переменная передана по
значению, и новый указатель некуда записать.
Мутация во время range легальна, но с оговорёнными в спецификации последствиями: удалённый и ещё не пройденный ключ гарантированно не будет посещён; добавленный во время обхода ключ может быть посещён, а может и нет, спецификация этого не определяет.
// Безопасно удалять во время обхода
for k, v := range m {
if v == 0 { delete(m, k) } // ок, спецификация это разрешает
}
// Опасно добавлять во время обхода: обход может зациклиться или выдать не всё
for k := range m {
m[k+1000] = 1 // попадёт ли новый ключ в обход, не определено
}
Конкурентный доступ: почему это fatal error, а не panic
Мапа не потокобезопасна, и рантайм за этим следит. В
hmap.flags есть бит hashWriting:
mapassign и mapdelete ставят его в
начале работы и снимают в конце. Любая другая операция, увидев
этот бит, немедленно валит процесс:
fatal error: concurrent map writes
fatal error: concurrent map read and map write
Это не паника, а throw из рантайма. Разницу
спрашивают почти всегда: recover() такое
не ловит, defer не выполняются, процесс
умирает целиком с дампом всех горутин. Логика простая: структуру
данных уже могло порвать, дальше выполнять небезопасно, лучше
упасть громко и сразу.
fatal error, это ещё не доказательство, что
гонки нет.
-
«Это паника, поймаю recover'ом». Нет,
fatal errorиз рантайма не перехватывается ничем. - «Параллельное чтение тоже падает». Нет. Много читателей без единого писателя работают законно и безопасно.
- «Если не падает, значит, потокобезопасно». Нет. Детектор срабатывает только при пересечении окон; без него можно получить порванный бакет и тихо неверные данные.
Чем защищаться: RWMutex, sync.Map, шардирование
Шардирование: одну большую структуру режут на N независимых кусков, у каждого свой замок, а ключ по хешу попадает ровно в один кусок. Две горутины с разными ключами чаще всего берут разные замки и не мешают друг другу.
// Вариант 1 — мьютекс рядом с мапой. Дефолт в 90 % случаев.
type Cache struct {
mu sync.RWMutex
m map[string]int
}
func (c *Cache) Get(k string) (int, bool) {
c.mu.RLock()
v, ok := c.m[k] // много читателей одновременно — ок
c.mu.RUnlock()
return v, ok
}
func (c *Cache) Set(k string, v int) {
c.mu.Lock()
c.m[k] = v
c.mu.Unlock()
}
// Вариант 2 — sync.Map. Только под два паттерна.
var sm sync.Map
sm.Store("a", 1)
v, ok := sm.Load("a")
actual, loaded := sm.LoadOrStore("b", 2) // атомарно: получить-или-положить
sm.Range(func(k, v any) bool { return true })
// Вариант 3 — шардирование. Когда RWMutex стал узким местом.
type Shard struct {
mu sync.RWMutex
m map[string]int
}
type Sharded [256]*Shard
func (s *Sharded) shard(k string) *Shard {
return s[fnv32(k)%256] // разные ключи — разные мьютексы
}
RWMutex + map |
sync.Map |
Шардирование | |
|---|---|---|---|
| Типизация | полная, дженерик по построению | any — приведения и боксинг |
полная |
| Когда выигрывает | почти всегда |
(1) пишем один раз, читаем много; (2) непересекающиеся ключи у разных горутин |
высокая конкуренция, много ядер |
| Когда проигрывает | десятки ядер бьются за один мьютекс | смешанная нагрузка read/write — dirty-мапа постоянно пересоздаётся | усложняет код, ломает атомарность операций «через все шарды» |
len() |
дёшево | нет вообще — только Range с подсчётом |
сумма по шардам, неатомарна |
| Аллокации | нет лишних | боксинг ключей и значений в any |
нет лишних |
До Go 1.24 это была пара мап. read, атомарный
неизменяемый снимок, читается вообще без блокировки;
dirty лежит под мьютексом, туда идут новые ключи.
Когда промахов по read накопилось достаточно,
dirty целиком становится новой read.
Отсюда и профиль: чтение существующих ключей почти бесплатно, а
вот поток новых ключей заставляет постоянно копировать всю мапу.
В Go 1.24 реализацию заменили на
HashTrieMap, конкурентное хеш-дерево без патологии
с пересозданием dirty. Спросят на собесе «почему sync.Map
медленная на записи», уточни, о какой версии речь.
Какие типы могут быть ключами
Ключ обязан быть comparable, то есть к нему применим
==. Это все базовые типы, указатели, каналы,
интерфейсы, массивы из comparable-элементов и структуры, у которых
comparable все поля. Нельзя: слайсы, мапы, функции и любая
структура, которая их содержит.
type Key struct {
UserID int64
Region string
} // ок — оба поля comparable
m := map[Key]int{{1, "ru"}: 10}
type BadKey struct {
Tags []string
}
// mb := map[BadKey]int{} // ошибка компиляции: invalid map key type BadKey
// Массив можно, слайс нельзя, поэтому так:
m2 := map[[16]byte]string{} // фиксированный размер = comparable
var id [16]byte
copy(id[:], someSlice)
m2[id] = "value"
// Или сериализуем ключ в строку (строка иммутабельна и comparable):
m3 := map[string]int{}
m3[strings.Join(tags, "\x00")] = 1
-
Интерфейс как ключ.
map[any]intкомпилируется, но положи туда значение с не-comparable динамическим типом, и получишь панику в рантайме:runtime error: hash of unhashable type []int. Компилятор здесь бессилен. -
NaN как ключ.
math.NaN() != math.NaN(), поэтому записанное по такому ключу значение невозможно ни прочитать, ни удалить. Каждая запись создаёт новый элемент,lenрастёт, память течёт. Классический вопрос-добивка про float-ключи.
m := map[float64]string{}
m[math.NaN()] = "a"
m[math.NaN()] = "b"
fmt.Println(len(m)) // 2 — две записи по "одному" ключу
fmt.Println(m[math.NaN()]) // "" — не найдено
delete(m, math.NaN()) // ничего не удалит
for k, v := range m { fmt.Println(k, v) } // обе записи видны, но только через range
// NaN a
// NaN b
// порядок между ними случайный: 5 прогонов дали 4 раза "a, b" и 1 раз "b, a"
Память: мапа не сжимается
delete помечает слот пустым и обнуляет ключ и
значение (чтобы GC мог собрать то, на что они указывали), но
массив бакетов не уменьшается никогда. Мапа, в которую
положили 10 млн ключей и потом всё удалили, продолжает держать
место под 10 млн, пока жива сама переменная.
m := make(map[int][128]byte)
for i := 0; i < 10_000_000; i++ { m[i] = [128]byte{} }
for i := 0; i < 10_000_000; i++ { delete(m, i) }
fmt.Println(len(m)) // 0 — а RSS процесса всё ещё гигабайты
// Вариант 1: пересоздать мапу и дать старой умереть
m = make(map[int][128]byte)
// Вариант 2 (Go 1.21+): clear() работает как delete всех ключей,
// то есть память тоже не отдаёт и пересоздание не заменяет.
clear(m)
// Вариант 3: хранить указатели, тогда «толстые» значения соберёт GC,
// а в бакетах останутся только 8-байтовые указатели
mp := make(map[int]*[128]byte)
«Мапа растёт, но не сжимается. delete и
clear освобождают значения для GC, но не
возвращают массив бакетов. Если мапа работает долгоживущим кэшем
с большим оборотом ключей, её надо либо периодически
пересоздавать, либо менять на кэш с вытеснением, либо
шардировать и пересоздавать шарды по очереди.» Заодно упомяни,
что до Go 1.21 вместо clear(m) писали
for k := range m { delete(m, k) }: компилятор
узнавал этот паттерн и разворачивал в быстрый
mapclear.
Множество (set) через мапу
Отдельного типа set в стандартной библиотеке нет, вместо него
пишут map[T]struct{}: struct{} занимает
0 байт, поэтому в бакете под массив значений память вообще
не выделяется.
// Идиоматичный set
seen := make(map[string]struct{}, 1024)
seen["a"] = struct{}{}
if _, ok := seen["a"]; ok { /* есть */ }
delete(seen, "a")
// Альтернатива map[string]bool — до Go 1.24 на байт больше на элемент,
// зато читается лучше и можно писать одно выражение вместо comma-ok:
seenB := map[string]bool{}
seenB["a"] = true
if seenB["a"] { /* есть */ } // false и для "нет ключа", и для "ключ есть, но false"
map[T]struct{} |
map[T]bool |
|
|---|---|---|
| Память на элемент | только ключ | ключ + 1 байт (плюс возможный padding) |
| Проверка | _, ok := s[k] |
s[k] — короче |
| Двусмысленность | нет: ключ либо есть, либо нет | есть: false ≠ «отсутствует» |
| Когда брать | большие множества, hot path | маленькие множества, читаемость важнее |
Сложность и деградация
| Операция | Средняя | Худшая | Комментарий |
|---|---|---|---|
Поиск m[k] |
O(1) | O(n) | худшая — при массовых коллизиях в один бакет |
| Вставка | амортизированная O(1) | O(n) | отдельная вставка может запустить рост |
| Удаление | O(1) | O(n) | память не возвращается |
Обход range |
O(n) | O(n + бакеты) | после массовых удалений обход дороже: пустые бакеты всё равно просматриваются |
len(m) |
O(1) | O(1) | это поле hmap.count |
Мапа деградирует в четырёх ситуациях:
- Плохой хеш / атака. Много ключей с одинаковыми низкими битами хеша → длинные overflow-цепочки → O(n) на операцию. От внешней атаки защищает случайный seed.
-
Разреженность после удалений. Бакеты остались, элементов
мало, обход и кэш-локальность проседают. Частично лечится
sameSizeGrow. -
Большие ключи и значения.
map[string][4096]byteкопирует значение целиком при каждом чтении и записи; бакеты огромные, кэш не помогает. Лучшеmap[string]*T. -
Рост без преаллокации. Залить 10 млн ключей без
make(map[..].., 10_000_000)значит пройти ~23 удвоения и переложить все элементы заново. Скажешь размер заранее, и почти вся эта работа отпадёт.
// Преаллоцируем: рантайм сразу выберет нужный B
m := make(map[string]int, len(rows)) // одна аллокация вместо цепочки ростов
for _, r := range rows { m[r.Key] = r.Val }
comma-ok
У чтения из мапы две формы. Односложная всегда возвращает
значение, нулевое, если ключа нет. Двухсложная
(comma-ok) добавляет флаг наличия. Это
единственный способ отличить «ключа нет» от «ключ есть и в
нём нулевое значение».
m := map[string]int{"a": 0}
v := m["a"] // 0
v2 := m["zzz"] // 0 — не отличить от предыдущего
v3, ok := m["a"] // 0, true
v4, ok2 := m["zzz"] // 0, false
// Типичная ошибка в проде: флаги в map[string]bool
flags := map[string]bool{"debug": false}
if flags["debug"] { } // false
if flags["typo_debug"] { } // тоже false — опечатка не заметна
if v, ok := flags["debug"]; ok && v { } // так честно
// Одна идиома языка, тот же синтаксис у трёх других конструкций:
v, ok := i.(MyType) // type assertion
v, ok := <-ch // чтение из канала: ok == false, если канал закрыт и пуст
_, ok := m[k] // мапа
Используй comma-ok везде, где нулевое значение это валидные
данные (счётчики, флаги, суммы). Односложную форму оставляй для
случаев «нулевое значение и есть ответ по умолчанию»:
counts[k]++, sum += m[k]. И помни, что
m[k]++ корректно работает даже для отсутствующего
ключа: он создастся с нулём и станет единицей.
Что выведет код?
// (A)
m := map[string][]int{}
m["a"] = append(m["a"], 1)
m["a"] = append(m["a"], 2)
fmt.Println(m, len(m["b"]))
type S struct {
N int
}
ms := map[string]S{"x": {1}}
v := ms["x"]
v.N++
fmt.Println(ms["x"].N)
// (B)
m := map[int]int{1: 1, 2: 2, 3: 3}
for k := range m {
delete(m, k)
}
fmt.Println(len(m))
var n map[string]int
fmt.Println(n == nil, len(n), n["q"])
n2 := map[string]int{}
fmt.Println(n2 == nil, len(n2))
(A) → map[a:[1 2]] 0, затем 1.
append(m["a"], 1) работает, потому что чтение
отсутствующего ключа даёт nil-слайс, а в него можно
аппендить. А вот v := ms["x"] копирует структуру:
инкремент ушёл в копию, в мапе осталась единица. Чтобы поменять,
нужно ms["x"] = v или map[string]*S.
(B) → 0, затем true 0 0 и
false 0. Удаление во время range
легально, и спецификация гарантирует, что удалённый непосещённый
ключ не будет посещён, поэтому мапа честно пустеет. Второй блок
про то, что nil-мапа и пустая мапа неотличимы по
len, но различимы по == nil, и в первую
нельзя писать.
Вопросы
12map[K]V — указатель на
hmap; внутри массив из 2^B бакетов
по 8 слотов; коллизии — цепочки overflow-бакетов; рост —
удвоение с инкрементальной эвакуацией.
Структура
hmap хранит count (это и есть
len(m)), B (логарифм числа
бакетов), hash0 (случайный seed), указатели
buckets и oldbuckets, счётчик
nevacuate и флаги. В бакете (bmap)
лежат tophash [8]uint8, затем
[8]K, затем [8]V, затем указатель
на overflow-бакет. Ключи и значения идут отдельными
массивами, чтобы не платить padding между K и
V.
Поиск
- Считаем
h = hash(key, hash0). - Низкие
Bбит дают номер бакета. -
Старший байт хеша (
tophash) сравниваем с восемью байтами в бакете. Дешёвый фильтр: сами ключи он не трогает. -
Только для совпавших
tophashсравниваем ключ полностью. - Не нашли, идём по цепочке overflow-бакетов.
Рост
Триггеров два. Load factor
count > 6.5 * 2^B даёт удвоение
(B++). А слишком много overflow-бакетов
при нормальном count включает
sameSizeGrow: перекладку в массив того же
размера, чтобы схлопнуть разреженность после массовых
удалений.
Эвакуация инкрементальная: старый массив остаётся в
oldbuckets, каждая вставка или удаление
переселяет один-два бакета. При удвоении старый бакет
i расщепляется ровно на два, i и
i + 2^oldB: в выборе бакета стал участвовать
ещё один бит хеша. Пока рост не закончен, чтение ищет в
обоих массивах, а память занята обоими.
«Именно из-за инкрементальной эвакуации вставка стоит амортизированную O(1), а не гарантированную: отдельная вставка может оказаться дорогой. И поэтому же в момент роста мапа занимает почти вдвое больше памяти, живы оба массива.» Дальше уместно добавить, что в Go 1.24 всё это заменили на Swiss Tables.
Что именно поменялось
- Хеш делится на h1 (старшие биты, они выбирают группу и последовательность проб) и h2 (младшие 7 бит, они ложатся в control-байт слота).
- Control word собирает 8 однобайтовых меток группы в одно 64-битное слово. Вопрос «есть ли в группе слот с таким h2» решается парой инструкций без ветвлений (на amd64 через SSE-инструкции, на остальных архитектурах битовыми трюками над тем же словом), а на выходе битовая маска кандидатов.
- Коллизии разрешаются открытой адресацией с квадратичным пробингом по группам, а не цепочками overflow-бакетов. Указателей между группами нет, значит и промахов кэша меньше.
-
Специальные значения control-байта:
EMPTY(можно прекратить поиск) иDELETED, надгробие. Через него поиск идёт дальше, иначе порвалась бы цепочка проб. - Большая мапа разбита на directory из независимых таблиц; растёт по одной таблице, а не целиком, и латентность выходит ровнее.
Что НЕ поменялось
Ни семантика, ни гарантии. Порядок обхода по-прежнему
случайный, мапа по-прежнему не потокобезопасна,
&m[k] по-прежнему запрещён, comma-ok тот
же. Отличие видно только в производительности: чтение
больших мап быстрее примерно на 30–60 %, вставка на треть,
при сопоставимой памяти. Плюс сломались тесты, которые
нелегально завязались на конкретный порядок итерации.
# вернуть старую реализацию (в Go 1.27 опции уже нет)
$ GOEXPERIMENT=noswissmap go build ./...
Переписали sync.Map: теперь под ним
HashTrieMap вместо пары read/dirty. Появились
generic type aliases. Добавили
testing.B.Loop(), он закрывает проблему
«компилятор выбросил тело бенчмарка». Если спрашивают «что
нового в Go», Swiss Tables и есть самый заметный пункт
1.24.
Свойства, которые от хеша нужны
- Детерминизм. Один ключ всегда даёт одно число (в пределах одной мапы: seed у каждой свой).
- Равномерность. Ключи размазываются по бакетам, иначе всё вырождается в список.
-
Скорость. Хеш считается на каждой операции. На
amd64 Go берёт
memhashна инструкциях AES-NI.
Функцию выбирает компилятор по типу ключа: строкам и
байтовым срезам достаётся memhash, числам
короткое смешивание, структурам комбинация хешей полей. У
типов с указателями внутри хешируется содержимое, а не
адрес.
Seed и hash flooding
Каждая мапа при создании получает случайный
hash0. Без него атакующий, знающий алгоритм,
подобрал бы тысячи ключей в один бакет и превратил бы O(1) в
O(n). Классическая DoS-атака на сервис, который складывает
заголовки или query-параметры запроса в мапу. Побочный
эффект: хеш одного и того же ключа различается между мапами
и между запусками, поэтому на порядок итерации нельзя
полагаться в принципе.
Способы разрешения коллизий вообще
| Способ | Идея | Где используется |
|---|---|---|
| Chaining | в ячейке список элементов | Java HashMap (список → дерево при 8+) |
| Bucket chaining | цепочка блоков по N элементов | Go ≤ 1.23: бакеты по 8 |
| Открытая адресация | ищем следующий свободный слот | Go 1.24+ (Swiss), Python dict |
| Robin Hood / Cuckoo | перекладываем элементы для выравнивания проб | Rust hashbrown (тоже Swiss), спец-хранилища |
При открытой адресации нельзя просто пометить слот пустым:
если ключ был вставлен «дальше по цепочке проб», поиск
оборвётся на дырке и не найдёт его. Поэтому вводят
надгробие (DELETED): слот свободен для
вставки, но поиск через него идёт дальше. Когда надгробий
набирается много, таблицу перестраивают. Хороший пункт из
серии «глубже, чем спросят».
Эвакуация копирует пары ключ-значение в новый массив
бакетов. Если бы язык разрешил сохранить
&m[k], то после первой же вставки,
запустившей рост, этот указатель смотрел бы в старую память:
запись через него молча терялась бы, а чтение возвращало
устаревшее значение. Go предпочитает запретить операцию на
этапе компиляции.
У слайса такой проблемы нет: там при росте выделяется новый
массив, но старый остаётся валидным, и указатель на
старый элемент продолжает указывать на живую память (просто
перестаёт быть связан со слайсом). Поэтому
&s[0] легален.
Что можно, а что нельзя
m := map[string]int{"a": 1}
// p := &m["a"] // ошибка: cannot take address of m["a"]
m["a"]++ // можно: разворачивается в чтение + сложение + запись
m["a"] += 5 // можно
m["new"]++ // можно: отсутствующий ключ = 0, станет 1
type S struct {
N int
}
ms := map[string]S{"a": {1}}
// ms["a"].N = 2 // ошибка: cannot assign to struct field in map
v := ms["a"]; v.N = 2; ms["a"] = v // читаем-меняем-пишем
mp := map[string]*S{"a": {1}}
mp["a"].N = 2 // работает: адресуем то, на что указывает значение
sl := map[string][]int{"a": {1}}
sl["a"] = append(sl["a"], 2) // слайс менять можно только через переприсваивание
sl["a"][0] = 9 // а элемент можно напрямую: индексация идёт
// по указателю внутри значения, а не по самому значению
«Почему m[k]++ компилируется, а
m[k].Field = v нет?» Потому что инкремент
компилятор разворачивает в три операции над копией, а
присваивание полю потребовало бы адреса значения. Отсюда
практическое правило: если в мапе лежат структуры, которые
надо часто менять, храни map[K]*V. Но помни,
что тогда GC придётся сканировать эти указатели, а сами
структуры разъедутся по куче.
Порядок обхода и так определяется числом бакетов, seed'ом и
историей вставок. Окажись он стабильным «на практике», код
начал бы на него опираться, и любое улучшение рантайма
(например, переход на Swiss Tables в 1.24) ломало бы прод.
Команда Go сделала недетерминированность явной и обязательной:
mapiterinit выбирает
случайный стартовый бакет и случайное смещение внутри
бакета
на каждый range.
Мотив номер два: безопасность. Вместе со случайным
hash0 это прячет внутреннее распределение
ключей.
Как получить детерминированный обход
keys := make([]string, 0, len(m))
for k := range m { keys = append(keys, k) }
slices.Sort(keys)
for _, k := range keys { fmt.Println(k, m[k]) }
// Go 1.23+: на итераторах это одна строка:
for _, k := range slices.Sorted(maps.Keys(m)) { fmt.Println(k, m[k]) }
// для m := map[string]int{"b": 2, "a": 1, "c": 3} оба варианта
// печатают одно и то же при любом запуске:
// a 1
// b 2
// c 3
-
fmt.Println(m)печатает мапу отсортированной по ключам начиная с Go 1.12. Это поведение пакетаfmt, сделанное ради воспроизводимых тестов и логов, а не свойство мапы. -
Одна мапа с одним и тем же содержимым даст разный
порядок при двух
rangeподряд в одном и том же запуске: рандомизируется каждая итерация, а не только процесс.
len, range и
delete на nil-мапе легальны и
безопасны; запись —
panic: assignment to entry in nil map.
var m map[string]int // nil: указатель на hmap равен nil
m == nil // true
len(m) // 0
m["x"] // 0 — чтение легально
v, ok := m["x"] // 0, false
for range m {} // ноль итераций
delete(m, "x") // no-op, без паники
m["x"] = 1 // panic: assignment to entry in nil map
Почему асимметрия
Чтение честно вернёт нулевое значение, вообще не обращаясь к
памяти: рантайм видит нулевой указатель и сразу отдаёт
zero, false. Запись обязана эту память создать,
а создавать негде. Аллоцировать hmap сам
рантайм не может: мапа передана по значению, и новый
указатель некуда записать так, чтобы его увидел вызывающий.
Сравни со слайсом, там append возвращает
новый заголовок, поэтому и работает с nil.
Где это стреляет в реальном коде
type Config struct {
Labels map[string]string // забыли инициализировать в конструкторе
}
c := &Config{}
c.Labels["env"] = "prod" // panic
// JSON: если в документе поля нет — мапа останется nil
var c2 Config
json.Unmarshal([]byte(`{}`), &c2)
c2.Labels["env"] = "prod" // panic
// Правильно:
func NewConfig() *Config { return &Config{Labels: map[string]string{}} }
// или ленивая инициализация в сеттере:
func (c *Config) Set(k, v string) {
if c.Labels == nil { c.Labels = make(map[string]string) }
c.Labels[k] = v
}
Если у структуры есть поле-мапа, инициализируй её в
конструкторе или лениво в методе-сеттере. Никогда не
рассчитывай, что вызывающий заполнит её сам, и тем более,
что её заполнит json.Unmarshal.
fatal error: concurrent map writes — это
throw, а не паника, его
не ловит recover. Дефолтная защита —
RWMutex; sync.Map — только под два
конкретных паттерна.
Механика детектора
В hmap.flags есть бит hashWriting.
mapassign и mapdelete ставят его
на входе и снимают на выходе. Любая операция, увидевшая
флаг, вызывает throw. Процесс падает целиком,
defer не выполняются, recover
бесполезен: состояние таблицы уже могло быть повреждено, и
продолжать небезопасно.
Детектор best-effort: он ловит только пересечение
окон операций. Гонку, где записи разошлись по времени, он не
заметит, а данные всё равно испортятся. Настоящий инструмент
тут go test -race. Параллельное чтение
многими горутинами без единого писателя законно и безопасно.
Чем защищаться
-
sync.RWMutex+ map, выбор по умолчанию. Полная типизация, предсказуемая стоимость,lenработает. Проседает, только когда десятки ядер бьются за один мьютекс. -
sync.Mapоправдана в двух случаях, прямо названных в документации: (1) ключ пишется один раз и много раз читается; (2) горутины работают с непересекающимися наборами ключей. Минусы:anyвместо типов, боксинг и лишние аллокации, нетlen. - Шардирование: массив из N структур «мьютекс + мапа», ключ ложится в шард по хешу. Снимает конкуренцию за один замок, но ломает атомарность операций «по всей мапе».
-
Иммутабельный снимок на
atomic.Pointer[map[K]V]: читатели берут указатель без блокировок, писатель копирует мапу целиком и подменяет указатель. Отлично для конфигов, которые обновляются раз в минуту.
// RLock не спасает от "проверил-и-записал"
c.mu.RLock()
_, ok := c.m[k]
c.mu.RUnlock()
if !ok {
c.mu.Lock()
c.m[k] = compute(k) // между RUnlock и Lock кто-то мог уже положить
c.mu.Unlock()
}
// Правильно: под Lock перепроверить (double-checked), либо singleflight
c.mu.Lock()
if _, ok := c.m[k]; !ok { c.m[k] = compute(k) }
c.mu.Unlock()
До Go 1.24 это была пара мап: атомарно читаемая
read и защищённая мьютексом
dirty; когда промахов накапливалось много,
dirty целиком становилась новой
read, отсюда и провал на потоке новых ключей.
В Go 1.24 внутренность заменили на
HashTrieMap, и эта патология ушла.
Рассказываешь про read/dirty, обязательно скажи «до 1.24»,
иначе это выглядит как устаревшие знания.
Список
| Тип | Ключ? | Комментарий |
|---|---|---|
числа, string, bool |
да | основной случай |
| указатель, канал | да | сравнивается адрес, а не содержимое |
массив [N]T |
да, если T comparable |
частый обход запрета на слайс |
struct |
да, если все поля comparable | составные ключи — идиоматично |
| интерфейс | компилируется | паника в рантайме, если динамический тип не comparable |
| слайс, мапа, функция | нет |
invalid map key type на компиляции
|
float64 |
формально да | но NaN ломает всё — см. ниже |
type Key struct {
UserID int64
Region string
}
m := map[Key]int{{1, "ru"}: 10}
fmt.Println(m[Key{1, "ru"}]) // 10 — сравнение пополевое
// Слайс нельзя, но можно массив фиксированной длины:
var id [16]byte
copy(id[:], uuidBytes)
byID := map[[16]byte]string{id: "user"}
// Или сериализовать ключ в строку:
byTags := map[string]int{}
byTags[strings.Join(tags, "\x00")] = 1 // разделитель, который не встречается в данных
// Интерфейс-ключ: компилируется, но падает
var anyMap = map[any]int{}
anyMap[42] = 1 // ок: int comparable
anyMap[[]int{1}] = 1 // panic: runtime error: hash of unhashable type []int
math.NaN() != math.NaN() по стандарту IEEE
754. Значит, записанное по NaN-ключу значение невозможно
ни прочитать, ни удалить, а каждая запись создаёт новый
элемент. len растёт, память течёт, увидеть
эти пары можно только через range. Отсюда
правило: не используй float как ключ мапы.
Если очень нужно, нормализуй заранее (округли и переведи в
целое или строку).
Констрейнт comparable в дженериках означает
ровно то же множество типов. Тонкость: до Go 1.20
any не удовлетворял
comparable, потому что интерфейс может
паниковать при сравнении. С Go 1.20 правило смягчили:
теперь интерфейсы удовлетворяют comparable, а
за возможную панику отвечает разработчик. Удобный пункт,
чтобы связать две темы.
delete обнуляет слот, чтобы GC
собрал значение, но массив бакетов
никогда не уменьшается. Единственный способ вернуть
память — пересоздать мапу.
Рантайм не умеет уменьшать B. Мапа, в которой
было 10 млн ключей, после delete всех ключей
продолжает держать бакеты под 10 млн: len(m)
равен нулю, а RSS процесса не меняется. Работает только
sameSizeGrow: он схлопывает overflow-цепочки,
но основной массив не трогает.
// Не помогает вернуть память:
delete(m, k) // обнуляет слот, бакеты остаются
clear(m) // Go 1.21+: как "удалить все ключи", память тоже не отдаёт
// Помогает:
m = make(map[K]V) // старая мапа становится мусором целиком
// Помогает частично: значения соберёт GC, в бакетах останутся указатели по 8 байт
mp := map[K]*V{}
Как это выглядит в проде
-
Кэш без вытеснения, который растёт до пика трафика
и остаётся на этом уровне навсегда. Симптом: RSS вырос и
не падает,
lenмаленький. -
Ключи-строки. Даже после
deleteстроки собираются, но если они были подстроками одного большого буфера, держится весь буфер (см. главу про строки). - Мапа как очередь: пишем тысячи ключей в секунду, удаляем сразу, а бакеты вырастают до пикового размера и остаются.
Что делать
-
Периодически пересоздавать: скопировать живые ключи в
новую мапу нужного размера (
make(map[K]V, len(old))) и заменить. - Взять кэш с вытеснением (LRU) вместо голой мапы.
- Шардировать и пересоздавать шарды по очереди, тогда паузы копирования размажутся.
-
Хранить
map[K]*V, если тяжёлые именно значения.
«Мапа в Go растёт, но не сжимается. delete и
clear освобождают значения для сборщика, но
массив бакетов остаётся раздутым до смерти самой мапы. Это
же верно и для слайса: s = s[:0] не уменьшает
cap.» Такую параллель ценят: видно, что тему
понимаешь, а не вызубрил.
map[T]struct{}, потому что
struct{} занимает 0 байт и под массив значений
в бакете память не выделяется.
set := make(map[string]struct{}, 1024)
set["a"] = struct{}{}
_, ok := set["a"] // проверка
delete(set, "a")
len(set) // размер
// Операции над множествами пишем руками:
func Union(a, b map[string]struct{}) map[string]struct{} {
r := make(map[string]struct{}, len(a)+len(b))
for k := range a { r[k] = struct{}{} }
for k := range b { r[k] = struct{}{} }
return r
}
Почему именно struct{}
unsafe.Sizeof(struct{}{}) == 0. Все экземпляры
пустой структуры лежат по одному адресу,
runtime.zerobase. В бакете мапы массив значений
[8]struct{} занимает ноль байт, то есть
накладных расходов на значение нет вообще. Для
map[T]bool это был бы [8]bool = 8
байт на бакет плюс возможный padding.
map[T]struct{} |
map[T]bool |
|
|---|---|---|
| Память | только ключи | +1 байт на элемент |
| Проверка | _, ok := s[k] |
s[k] — короче и читаемее |
Смысл false |
невозможен | двусмысленно: «нет ключа» или «есть, но false» |
| Запись | s[k] = struct{}{} — шумно |
s[k] = true |
Ещё две идиомы с пустой структурой
done := make(chan struct{}) // сигнальный канал: важен факт, а не значение
close(done) // broadcast всем читателям
type NoopLogger struct{} // тип без состояния — экземпляры бесплатны
func (NoopLogger) Log(string) {}
Для множеств в горячем пути и на миллионы элементов бери
map[T]struct{}. Для десятка флажков в конфиге
сойдёт map[T]bool, читаемость важнее байта. В
обоих случаях указывай ожидаемый размер в
make: это убирает цепочку удвоений. И помни
про slices.Contains из Go 1.21+: на маленьких
наборах (до ~10 элементов) линейный поиск по слайсу
быстрее мапы за счёт кэша.
| Операция | Средняя | Худшая | Почему худшая такая |
|---|---|---|---|
m[k] |
O(1) | O(n) | все ключи в одном бакете → длинная overflow-цепочка |
m[k] = v |
амортиз. O(1) | O(n) | эта вставка запустила рост и переселение |
delete |
O(1) | O(n) | та же цепочка коллизий |
range |
O(n) | O(n + бакеты) | пустые бакеты всё равно просматриваются |
len |
O(1) | O(1) | поле hmap.count |
Четыре сценария деградации
- Коллизии. В обычной жизни их мало благодаря хорошему хешу, но специально подобранные ключи (или очень плохой самописный хеш в другой реализации) дают O(n). В Go от внешней атаки защищает случайный seed, поэтому подобрать коллизии снаружи нельзя.
-
Разреженность. Положили миллион, удалили 990 тысяч,
а бакетов по-прежнему миллион.
rangeпроходит по всем, локальность кэша плохая. -
Тяжёлые значения.
map[string][4096]byte: каждое чтение и запись копирует 4 КБ, бакет огромный, кэш не работает. Решение:map[string]*T. -
Нет преаллокации. Насыпать 10 млн ключей «с нуля»
стоит ~23 удвоений, и каждое перекладывает всё заново.
make(map[K]V, n)сразу выбирает нужныйB.
// Разницу легко измерить:
func BenchmarkNoPrealloc(b *testing.B) {
for b.Loop() { // Go 1.24+: b.Loop вместо b.N
m := map[int]int{}
for i := 0; i < 1_000_000; i++ { m[i] = i }
}
}
func BenchmarkPrealloc(b *testing.B) {
for b.Loop() {
m := make(map[int]int, 1_000_000)
for i := 0; i < 1_000_000; i++ { m[i] = i }
}
}
// преаллоцированная версия обычно быстрее в 1.5–2 раза и делает
// на порядок меньше аллокаций
На маленьких наборах (до 8–16 элементов) линейный поиск по
слайсу обгоняет мапу: нет хеширования, данные лежат в
одной-двух кэш-линиях. Компилятор Go даже оптимизирует
switch по строкам лучше, чем поиск по мапе.
Если на собесе спросят «как ускорить», уместно предложить
измерить слайс против мапы, а не сразу тюнить мапу.
m := map[string]int{"a": 0}
v1 := m["a"] // 0
v2 := m["zzz"] // 0 — неотличимо
v3, ok := m["a"] // 0, true
v4, ok := m["zzz"] // 0, false
Синтаксическая форма, а не функция: компилятор видит
присваивание в две переменные и вызывает
mapaccess2 вместо mapaccess1.
Поэтому её нельзя «передать дальше»: f(m[k])
всегда односложная форма.
Тот же синтаксис у трёх других конструкций
v, ok := m[k] // мапа: ok == есть ли ключ
v, ok := i.(MyType) // type assertion: ok == совпал ли динамический тип
v, ok := <-ch // канал: ok == false, если канал закрыт и опустошён
for i, ok := it.Next(); ok; i, ok = it.Next() {} // ручной итератор
Где обязательно
- Значение это число или bool, и ноль/false валиден: счётчики, лимиты, фича-флаги.
- Нужно отличить «не настроено» от «настроено в ноль», например в конфиге.
-
Кэш: в
v, ok := cache[k]значениеok == falseозначает промах, а не «в кэше лежит ноль».
Где не нужно
counts[word]++ // отсутствующий ключ = 0, станет 1, comma-ok не нужен
total += prices[id] // «нет цены» и «цена 0» здесь одно и то же
sums[k] = append(sums[k], v) // nil-слайс аппендится нормально
Фича-флаги в map[string]bool и проверка
if flags["enable_new_flow"]. Опечатка в имени
флага даёт false, ровно то же, что
выключенный флаг, и баг живёт месяцами. Лечится либо
comma-ok с явной ошибкой на неизвестный ключ, либо
типизированным конфигом со структурой вместо мапы.
1.4Строки
Строка в Go: два слова в памяти плюс байтовый буфер, который нельзя
менять. Отсюда и всё остальное. Почему
len("Привет") == 12, почему конкатенация в цикле
квадратична, почему подстрока держит сто мегабайт, почему
[]byte(s) то аллоцирует, то нет.
String header: что физически лежит в переменной
Как и у слайса, у строки есть заголовок: маленькая служебная структурка. Байты она не хранит, только говорит, где они лежат и сколько их. Полей здесь не три, а два.
За типом string стоит runtime.stringStruct:
указатель на байты и длина в байтах. Ровно 16 байт на
64-битной платформе. Ёмкости нет, и она не нужна: строка не растёт.
// runtime/string.go
type stringStruct struct {
str unsafe.Pointer // указатель на первый байт
len int // длина в байтах, не в символах
}
s := "Привет"
fmt.Println(unsafe.Sizeof(s)) // 16 — размер заголовка, а не данных
fmt.Println(len(s)) // 12 — длина буфера в байтах
Сравни со слайсом: у []byte три слова (ptr, len, cap), а
у string только два. Различие единственное, но следствий
у него много. Конверсия string ↔ []byte в общем случае
копирует байты, потому что у типов разный контракт на изменяемость, а
не разная раскладка данных.
[]byte обязана
копировать, иначе изменяемый срез мог бы поменять «неизменяемую»
строку.
Почему строки иммутабельны
Спецификация прямо запрещает менять байты строки:
s[0] = 'X' не соберётся, компилятор скажет
cannot assign to s[0]. Иммутабельность означает вот что: значение после создания
уже нельзя переписать, рядом строят новое. На собесе назови хотя бы
три причины, зачем это строкам:
-
Подстроки без копии.
s[a:b]заводит новый заголовок на тот же буфер, ноль аллокаций. Будь строки изменяемыми, каждая подстрока требовала бы копии, иначе правки протекали бы наружу. - Безопасность в конкурентности. Неизменяемое значение читают из любого числа горутин без синхронизации. Среди «сложных» типов Go строка такая одна: безопасна по построению.
- Ключи мап и константы. Хеш строки можно считать, не боясь, что содержимое поменяется под ногами. Строковые литералы компилятор кладёт в read-only секцию бинаря и переиспользует один буфер для одинаковых литералов. Запись туда вызвала бы segfault на уровне ОС.
- Дешёвое копирование значения. Строка уходит в функцию копией в 16 байт, хоть там мегабайт, хоть два символа.
s := "hello"
// s[0] = 'H' // ошибка компиляции: cannot assign to s[0]
// Изменить можно через []byte или []rune:
b := []byte(s)
b[0] = 'H'
s2 := string(b) // "Hello" — новая строка, старая цела
// Литералы шарят память:
a1 := "hello"
a2 := "hello"
// unsafe: оба указывают в одну read-only область бинаря
// Через unsafe писать нельзя: сегфолт или порча памяти
// p := unsafe.StringData(s); *p = 'H' // SIGSEGV: write to read-only section
«Строка работает как immutable view на байты: указатель + длина.
Иммутабельность нужна, чтобы подстроки, ключи мап и передача между
горутинами были бесплатны и безопасны. Цена: любое изменение
содержимого требует новой аллокации, поэтому в горячем пути строки
собирают через strings.Builder, а не конкатенацией.»
UTF-8, руны и len
Go хранит строки в UTF-8, и это решение авторов языка: Кен Томпсон и Роб Пайк придумали саму кодировку. Длина у неё переменная. ASCII занимает 1 байт, кириллица и большинство европейских алфавитов по 2, CJK и большинство символов по 3, эмодзи и редкие знаки по 4.
Кодовая точка означает номер символа в
таблице Unicode, тот самый, что пишут как U+2665.
Кодировка говорит, как этот номер записать байтами. В Go тип
rune хранит именно кодовую точку (просто псевдоним
int32), а в строке лежат байты UTF-8.
| Диапазон кодовых точек | Байт | Схема битов | Примеры |
|---|---|---|---|
| U+0000 … U+007F | 1 | 0xxxxxxx |
ASCII: A, 9, пробел |
| U+0080 … U+07FF | 2 | 110xxxxx 10xxxxxx |
кириллица, греческий, иврит |
| U+0800 … U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
CJK, ♥ (U+2665) |
| U+10000 … U+10FFFF | 4 | 11110xxx 10xxxxxx ×3 |
эмодзи 😀 (U+1F600) |
Работает это так: первый байт последовательности всегда отличим от продолжающих (продолжающие начинаются с 10). Поэтому по UTF-8 можно
идти назад и находить границу символа из любой позиции. И
ASCII-подстрока никогда не совпадёт «случайно» с куском
многобайтового символа, так что strings.Contains(s, "/")
корректен без всякого Unicode-парсинга.
s := "Go♥"
fmt.Println(len(s)) // 5 — байты
fmt.Println(utf8.RuneCountInString(s)) // 3 — руны
fmt.Printf("%T %v\n", s[0], s[0]) // uint8 71 — s[i] это байт
fmt.Printf("%c\n", s[2]) // â — байт 0xE2 напечатан как руна U+00E2,
// это не «♥»: один байт из трёх
// По символам идём через range: он декодирует UTF-8
for i, r := range s {
fmt.Printf("байт %d: руна %c (U+%04X, %d байт)\n", i, r, r, utf8.RuneLen(r))
}
// байт 0: руна G (U+0047, 1 байт)
// байт 1: руна o (U+006F, 1 байт)
// байт 2: руна ♥ (U+2665, 3 байт)
// По байтам, когда нужны именно байты (парсеры, ASCII-протоколы):
for i := 0; i < len(s); i++ {
_ = s[i] // uint8
}
// По «символам» индексируем только через []rune, ценой аллокации:
rs := []rune(s)
fmt.Println(string(rs[2])) // "♥"
-
s[i]в цикле по «символам»: на кириллице получишь половинки букв. -
s[:10]для «обрезать до 10 символов» режет по байтам и легко разрубит руну пополам, а в выводе появитсяU+FFFD(replacement character). Режь по границе руны:string([]rune(s)[:10])или пройдисьrangeи запомни смещение. -
strings.ToUpperдля турецкогоiи немецкогоßдаёт не то, что ожидает носитель языка. Для локале-зависимых операций бериgolang.org/x/text/cases.
Руна хранит кодовую точку, а человек видит графемный
кластер, и это не одно и то же. Флаг «🇷🇺» собран из двух рун
(два regional indicator), «é» бывает одной руной (U+00E9) или двумя
(e + U+0301 combining acute), а семейное эмодзи «👨👩👧» склеено из
пяти рун zero-width joiner'ами. Поэтому len([]rune(s))
считает не «количество символов». За настоящими графемами иди
в библиотеку вроде rivo/uniseg. Упомянешь этот нюанс на собесе, зачтётся.
Конверсии string ↔ []byte ↔ []rune и их цена
| Конверсия | Что происходит | Стоимость |
|---|---|---|
[]byte(s) |
malloc + memmove len байт |
O(n), 1 аллокация (кроме оптимизируемых случаев) |
string(b) |
malloc + memmove len байт |
O(n), 1 аллокация |
[]rune(s) |
декодирование UTF-8 + новый массив int32
|
O(n), 1 аллокация, до 4× памяти |
string(rs) |
кодирование каждой руны в UTF-8 | O(n), 1 аллокация |
s[a:b] |
новый заголовок, тот же буфер | O(1), 0 аллокаций |
string(r), r — руна |
1–4 байта в новой строке | O(1), возможна аллокация |
string(i), i — int
|
не «число в строку»! это кодовая точка |
go vet ругается — нужен
strconv.Itoa
|
В нескольких частных случаях компилятор копию убирает, и на собесе про них любят добить:
// 1. Ключ мапы: []byte → string без аллокации
var m map[string]int
b := []byte("key")
_ = m[string(b)] // компилятор не аллоцирует: временная строка не утекает
// 2. Сравнение
if string(b) == "hello" { } // без аллокации
// 3. range по string(b)
for _, r := range string(b) { } // без аллокации
// 4. Конкатенация в маленькие строки: стековый буфер на 32 байта
s := "a" + string(b) // может уложиться в tmpBuf на стеке
// 5. Обратно: []byte(s) в аргументе Write, который не сохраняет срез —
// escape-анализ отрабатывает, но копия обычно всё равно делается,
// потому что компилятор не знает, что Write не запомнит срез
w.Write([]byte(s)) // аллокация есть
io.WriteString(w, s) // а вот так — без аллокации, если w реализует StringWriter
С Go 1.20 в пакете unsafe есть официальные
unsafe.String(ptr, len),
unsafe.StringData(s),
unsafe.Slice(ptr, len),
unsafe.SliceData(s). Они конвертируют без копии.
Законно это только тогда, когда ты гарантируешь, что байты
после превращения в строку никто не изменит (скажем, буфер сразу
выбрасывается). Так пишут декодеры и парсеры в горячем пути. В
обычном коде не надо: ошибка обойдётся тихой порчей данных, которую
не поймает даже race-детектор.
// Zero-copy, Go 1.20+. Использовать, только понимая последствия.
func bytesToString(b []byte) string {
if len(b) == 0 { return "" }
return unsafe.String(unsafe.SliceData(b), len(b))
}
func stringToBytes(s string) []byte {
if s == "" { return nil }
return unsafe.Slice(unsafe.StringData(s), len(s)) // писать в результат нельзя
}
Конкатенация: почему «плюс» в цикле квадратичен
Каждый a + b обязан создать новую строку:
выделить буфер длины len(a)+len(b) и скопировать туда
оба куска. В цикле на n итераций набегает
1 + 2 + 3 + … + n скопированных байт, то есть O(n²)
по времени и n аллокаций. Каждая тут же становится
мусором для GC.
[]byte с обычной стратегией роста append,
а String() делает unsafe-конверсию без копии: после неё
запись в буфер уже запрещена.
| Способ | Сложность | Аллокаций | Когда использовать |
|---|---|---|---|
a + b + c одним выражением |
O(n) | 1 |
всегда ок: компилятор вызывает
concatstrings один раз на всё выражение
|
s += x в цикле |
O(n²) | n | никогда при неизвестном n |
strings.Builder |
амортиз. O(n) | log n (или 1 с Grow) |
дефолт для сборки строки в цикле |
bytes.Buffer |
амортиз. O(n) | log n |
если нужен io.Writer и/или чтение из буфера
|
strings.Join |
O(n) | 1 | когда все куски уже лежат в слайсе |
fmt.Sprintf |
O(n) + рефлексия | 2–4 | форматирование, не конкатенация — в 5–10 раз медленнее |
append([]byte, ...) |
амортиз. O(n) | log n |
самый быстрый вариант, если итог всё равно нужен как
[]byte
|
// Плохо: O(n²)
func joinBad(parts []string) string {
s := ""
for _, p := range parts { s += p } // n аллокаций, n²/2 скопированных байт
return s
}
// Хорошо: один буфер, одна аллокация
func joinGood(parts []string) string {
var b strings.Builder
n := 0
for _, p := range parts { n += len(p) }
b.Grow(n) // сразу нужный размер, без перевыделений
for _, p := range parts { b.WriteString(p) }
return b.String() // без копии: unsafe-конверсия внутри
}
// Ещё лучше, если куски уже в слайсе
func joinBest(parts []string) string {
return strings.Join(parts, "") // ровно одна аллокация нужного размера
}
// У форматирования другая задача и другая цена:
_ = fmt.Sprintf("%s=%d", k, v) // рефлексия + интерфейсный боксинг
_ = k + "=" + strconv.Itoa(v) // в разы быстрее для двух-трёх кусков
// Бенчмарк, который стоит уметь написать на доске (Go 1.24+, b.Loop)
func BenchmarkPlus(b *testing.B) {
parts := make([]string, 1000)
for i := range parts { parts[i] = "chunk" }
for b.Loop() {
s := ""
for _, p := range parts { s += p }
_ = s
}
}
func BenchmarkBuilder(b *testing.B) {
parts := make([]string, 1000)
for i := range parts { parts[i] = "chunk" }
for b.Loop() {
var sb strings.Builder
sb.Grow(5000)
for _, p := range parts { sb.WriteString(p) }
_ = sb.String()
}
}
// Типичный порядок величин на 1000 кусков по 5 байт:
// BenchmarkPlus-8 ~ 1_300_000 ns/op 2_500_000 B/op 999 allocs/op
// BenchmarkBuilder-8 ~ 8_000 ns/op 5_000 B/op 1 allocs/op
// BenchmarkJoin-8 ~ 6_000 ns/op 5_000 B/op 1 allocs/op
// BenchmarkSprintf-8 ~ 70_000 ns/op 15_000 B/op ~5 allocs/op
-
Кусков заранее 2–3, хватит
+в одном выражении. - Куски уже в слайсе, бери
strings.Join. -
Собираешь в цикле, только
strings.Builderи сразуGrow. -
Когда нужен
io.Writer(шаблоны, HTTP-ответ), подойдётbytes.Buffer, а ещё лучше писать прямо вhttp.ResponseWriter. -
fmt.Sprintfоставь для настоящего форматирования и не тащи в горячий цикл.
strings.Builder хранит поле addr *Builder,
указатель на самого себя. При каждой записи он проверяет
b.addr == b; если структуру скопировали, проверка
падает с panic: strings: illegal use of non-zero Builder copied by
value. Так две копии не смогут писать в один буфер и портить данные.
Отсюда правило: Builder передавай только по указателю, а
go vet (copylocks) за этим следит. То же
самое, кстати, у sync.Mutex и
sync.WaitGroup.
Утечка памяти через подстроку
Подстрока делит буфер с оригиналом. Пока жива хотя бы одна короткая подстрока, весь исходный буфер держится в памяти. Механика ровно та же, что у слайсов, и вопрос задают парой.
// Читаем файл на 100 МБ, берём из него первые 10 символов и кладём в кэш
func loadKey(path string) string {
data, _ := os.ReadFile(path) // 100 МБ в куче
s := string(data) // ещё 100 МБ (копия)
return s[:10] // заголовок len=10 на буфер 100 МБ
}
// data соберётся, а вот буфер s — нет: на него смотрит возвращённая подстрока.
// 100 МБ живут ради 10 байт.
// Вариант 1: форсируем копию через конверсию туда-обратно
return string([]byte(s[:10])) // новый буфер ровно на 10 байт
// Вариант 2 (Go 1.18+): strings.Clone, явно и без лишней конверсии
return strings.Clone(s[:10])
// Вариант 3: не создавать большую строку вообще
f, _ := os.Open(path)
buf := make([]byte, 10)
io.ReadFull(f, buf)
return string(buf)
-
Парсинг больших ответов. Достали из JSON-строки один
idчерез слайсинг, удержали весь ответ. -
strings.Splitиstrings.Fieldsвозвращают подстроки, а не копии. Разобрали лог-строку на 10 КБ, положили одно поле в долгоживущую мапу и держим 10 КБ на каждую запись. -
bytes.Split: та же история со срезами. -
HTTP-заголовки и query.
net/httpчасто отдаёт подстроки буфера запроса.
Симптом: RSS растёт линейно с числом записей в кэше, хотя суммарная
длина хранимых строк копеечная. Смотри в
pprof -inuse_space: там видно, что живут гигантские
[]byte или строковые буферы, а не сами записи.
Сравнение строк
== для строк делает побайтовое сравнение содержимого,
а не адресов. Под капотом (runtime.memequal) сначала
идут длины (разошлись, мгновенный отказ), затем указатели (совпали,
строки идентичны, ноль работы), и только потом байты машинными
словами. Оператор < сравнивает лексикографически, по
байтам, и для UTF-8 это совпадает с порядком кодовых точек.
a, b := "hello", "hello"
fmt.Println(a == b) // true — сравнение содержимого
fmt.Println("apple" < "banana") // true — лексикографически по байтам
fmt.Println("Z" < "a") // true — 0x5A < 0x61, регистр влияет
fmt.Println("Ёж" < "Яблоко") // true, но по байтам UTF-8, а не по алфавиту:
// «Ё» это U+0401 (0xD0 0x81), «Я» — U+042F (0xD0 0xAF)
// strings.Compare есть, но обычно не нужен: он для sort-интерфейсов
fmt.Println(strings.Compare(a, b)) // 0 (-1, 0, 1)
// Без учёта регистра сравниваем не через ToLower:
fmt.Println(strings.EqualFold("Go", "GO")) // true
fmt.Println(strings.EqualFold("Straße", "STRASSE")) // false — case folding не разворачивает ß
fmt.Println(strings.EqualFold("ПРИВЕТ", "привет")) // true — работает и с кириллицей
| Задача | Как | Почему не иначе |
|---|---|---|
| Точное равенство | a == b |
быстрее всего, сравнивает длины первыми |
| Регистронезависимо | strings.EqualFold(a, b) |
ToLower(a) == ToLower(b) делает 2 аллокации и
неверен для Unicode case folding
|
| Сортировка | slices.Sort / < |
байтовый порядок; для «человеческого» алфавита нужен
x/text/collate
|
| Префикс/суффикс |
strings.HasPrefix / HasSuffix
|
s[:n] == p паникует, если
n > len(s)
|
| Секреты, токены, HMAC | subtle.ConstantTimeCompare |
== выходит на первом различии — таймингом
можно подобрать значение
|
| Юникод-эквивалентность |
norm.NFC.String(a) == norm.NFC.String(b)
|
«é» в NFC и NFD — разные байты при одинаковом смысле |
Три причины. Скорость: EqualFold идёт по строкам
параллельно и выходит на первом различии, ничего не аллоцируя, а
ToLower строит две новые строки целиком. Корректность:
Unicode задаёт отдельную операцию «simple case folding»,
спроектированную ровно под сравнение без учёта регистра, и она не
равна «привести к нижнему регистру» (у греческой сигмы две строчные
формы, турецкая I отображается по-своему). И третье,
честность имени: функция говорит, что сравнивает, а не
преобразует.
Оговорка: EqualFold делает именно simple
folding, поэтому "ß" и "ss" равными не
считает. Для полного folding бери golang.org/x/text.
Что выведет код?
// (A)
s := "мир"
fmt.Println(len(s))
fmt.Println(len([]rune(s)))
fmt.Println(s[0])
fmt.Println(string(s[0]))
fmt.Println(string(rune(1055)))
for i := range s {
fmt.Print(i, " ")
}
// (B)
s := "hello"
b := []byte(s)
b[0] = 'H'
fmt.Println(s, string(b))
x := 65
fmt.Println(string(rune(x)))
fmt.Println(strconv.Itoa(x))
fmt.Println("a"+"b" == "ab")
var e string
fmt.Println(e == "", len(e))
(A) → 6 (три кириллические буквы по 2 байта),
3, 208 (первый байт буквы «м» в UTF-8 равен
0xD0), затем "Ð" (конверсия
string(byte) трактует 208 как кодовую точку U+00D0),
затем "П" (U+041F = 1055). Индексы из range:
0 2 4, то есть байтовые смещения начал рун, а не 0 1 2.
(B) → hello Hello: конверсия скопировала байты,
оригинал цел. Затем "A" и "65":
string(rune(65)) даёт символ с кодом 65, а
strconv.Itoa(65) даёт текстовое представление числа.
Дальше true (конкатенация констант вычисляется
компилятором) и true 0: нулевое значение строки равно
"", а не nil; строка не может быть nil.
Вопросы
7Структура
type stringStruct struct {
str unsafe.Pointer // на первый байт
len int // длина в байтах
}
unsafe.Sizeof("любая строка") // 16 на amd64 — размер заголовка
От []byte отличие одно: нет cap. Ёмкость
не нужна, строка не растёт. Данные лежат в read-only секции бинаря
(для литералов), в куче (результат конкатенации, конверсии) или в
стеке, если escape-анализ решил, что значение не утекает.
Зачем иммутабельность
-
Подстрока без копии.
s[a:b]отдаёт новый заголовок на тот же буфер: O(1), ноль аллокаций. Будь строки изменяемыми, так было бы небезопасно. - Конкурентность. Неизменяемое значение читается любым числом горутин без синхронизации. Это единственный «составной» тип Go с такой гарантией.
- Ключи мапы. Хеш считается один раз и не может «протухнуть» из-за изменения содержимого.
- Дешёвая передача. Копия строки весит 16 байт независимо от длины данных.
- Интернирование литералов. Компилятор кладёт одинаковые литералы в одну read-only область; запись туда была бы SIGSEGV на уровне ОС.
s := "hello"
// s[0] = 'H' // ошибка компиляции: cannot assign to s[0]
b := []byte(s); b[0] = 'H'; s2 := string(b) // через копию
«Иммутабельность держится на контракте языка, а не на аппаратной
защите. Через unsafe.StringData байты формально
доступны на запись, но для литералов это сегфолт, а для строк из
кучи тихая порча, которую не поймает даже race-детектор. С Go
1.20 для zero-copy конверсий есть официальные
unsafe.String и unsafe.Slice; их
применяют в парсерах, где известно, что буфер сразу
выбрасывается.»
| Тип | Заголовок | Изменяемость | Элемент |
|---|---|---|---|
string |
ptr + len (16 Б) | нет | байт |
[]byte |
ptr + len + cap (24 Б) | да | байт (uint8) |
[]rune |
ptr + len + cap (24 Б) | да | кодовая точка (int32) |
Цена конверсий
-
[]byte(s)иstring(b)идут черезmallocgc+memmove: O(n), одна аллокация. Копия обязательна, иначе изменяемый срез поменял бы «неизменяемую» строку. -
[]rune(s)декодирует UTF-8 целиком и строит массивint32: O(n) по времени и до 4× памяти для ASCII. -
string(rs)кодирует каждую руну обратно: O(n), одна аллокация. -
s[a:b]стоит ноль аллокаций, только новый заголовок.
Где компилятор убирает копию
m[string(b)] // ключ мапы — без аллокации
if string(b) == "hello" { } // сравнение — без аллокации
for _, r := range string(b) { } // range — без аллокации
switch string(b) { case "a": } // switch — без аллокации
io.WriteString(w, s) // вместо w.Write([]byte(s)) — без аллокации
Правило одно: если временная строка не переживает выражение, компилятор её не материализует. Но как только результат сохраняется в переменную, попадает в структуру или уходит в интерфейс, копия неизбежна.
Как избегать конверсий на практике
-
Выбери один тип и веди его через весь путь данных:
[]byteдля I/O-пайплайнов,stringдля доменной логики. -
Пользуйся зеркальными API:
bytes.Containsвместоstrings.Contains(string(b), …);strconv.AppendIntвместо[]byte(strconv.Itoa(…)). -
В горячем пути выручат
unsafe.Stringиunsafe.Slice, но только когда профиль это доказал и инварианты понятны.
string(65) даёт не «65» в строку, а символ с
кодом 65, то есть "A". Числа переводит
strconv.Itoa или strconv.FormatInt. С
Go 1.15 go vet помечает string(int) как
подозрительное, а конверсию из нетипизированной константы (string(65))
новые версии без явного rune вообще запрещают.
len(s) — число байт. Число
кодовых точек даёт utf8.RuneCountInString(s).
Корректная итерация по символам — только
for i, r := range s, который декодирует UTF-8.
s := "Привет"
len(s) // 12 — кириллица по 2 байта
utf8.RuneCountInString(s) // 6
len([]rune(s)) // 6, но с аллокацией: ради счёта так не делают
Три способа пройти по строке
// 1) По рунам: range декодирует UTF-8, i это байтовое смещение
for i, r := range s {
fmt.Printf("%d:%c ", i, r) // 0:П 2:р 4:и 6:в 8:е 10:т
}
// 2) По байтам, когда работаешь с бинарём или ASCII-протоколом
for i := 0; i < len(s); i++ {
_ = s[i] // uint8
}
// 3) Через []rune, когда нужен произвольный доступ по индексу символа
rs := []rune(s)
_ = rs[3] // аллокация: оправдана, только если индексируешь много раз
Что важно про UTF-8
- Кодировка переменной длины: 1 байт на ASCII, 2 на кириллицу и греческий, 3 на CJK и большинство символов, 4 на эмодзи.
-
Продолжающие байты всегда начинаются с
10, поэтому границу символа можно найти из любой позиции и идти по строке назад. -
Подстрока ASCII не может «случайно» совпасть с куском
многобайтового символа, поэтому
strings.Split(s, "/")безопасен без Unicode-парсинга. -
Невалидные байты
rangeотдаёт какutf8.RuneError(U+FFFD) длиной 1 байт, не паникуя.
Обрезка строки по длине — типичная задача
// Плохо: разрежет руну пополам, в выводе появится U+FFFD
func truncBad(s string, n int) string { if len(s) > n { return s[:n] }; return s }
// Хорошо: режем по границе руны, без аллокации
func trunc(s string, n int) string {
if utf8.RuneCountInString(s) <= n { return s }
cnt := 0
for i := range s { // i — смещение начала очередной руны
if cnt == n { return s[:i] }
cnt++
}
return s
}
len([]rune(s)) считает кодовые точки, а не
то, что человек видит как символ. «é» бывает одной руной (U+00E9)
или двумя (e + combining acute). Флаг «🇷🇺»
разбирается на две руны. В семейном эмодзи их пять, склеенных
ZWJ. Для настоящего «сколько символов на экране» нужны графемные
кластеры, например библиотека
rivo/uniseg. Скажешь это на собесе, и станет видно,
что ты понимаешь границы модели.
byte, он же
uint8). Индексация строки — это индексация её
байтового буфера, никакого декодирования UTF-8 не
происходит.
s := "Привет"
fmt.Printf("%T %v\n", s[0], s[0]) // uint8 208 — первый байт буквы "П" (0xD0)
fmt.Printf("%c\n", s[0]) // Ð — 208 прочитан как кодовая точка U+00D0
fmt.Println(s[0:2]) // "П" — два байта вместе дают букву
// А range декодирует и даёт rune:
for i, r := range s {
fmt.Printf("%d %T %c\n", i, r, r) // 0 int32 П ; 2 int32 р ; ...
break
}
Почему так
Строка хранит байты, и len(s) считает именно их. Если
бы s[i] возвращало руну, индексация стала бы O(n)
(пришлось бы декодировать всё от начала), а len
перестал бы соответствовать диапазону индексов. Go выбрал прямую
байтовую модель, дешёвую и предсказуемую. Расплата в том, что за
символами надо ходить через range или
[]rune.
Практические следствия
-
Для ASCII разницы нет: там байт и есть символ, и
s[i]работает как ожидаешь. -
Для не-ASCII
s[i]в цикле даст мусор: половинки многобайтовых последовательностей. -
s[a:b]режет по байтам и легко разорвёт руну, а при печати вылезетU+FFFD. - «Первый символ» берут так:
r, size := utf8.DecodeRuneInString(s) // руна и её длина в байтах, без аллокации
fmt.Printf("%c занимает %d байт\n", r, size) // П занимает 2 байт
// Или через range с немедленным выходом:
for _, r := range s { first = r; break }
// А не так:
// first := rune(s[0]) // 208 — это не буква
[]byte(s)[i] даёт тот же байт, ничего не меняется.
[]rune(s)[i] даёт руну, но ценой аллокации всего
массива. Нужен доступ по индексу символа один раз, бери
utf8.DecodeRuneInString; нужен много раз,
сконвертируй в []rune и работай с ним.
+ создаёт новый буфер и копирует всё
накопленное — в цикле это O(n²) и n аллокаций.
strings.Builder копит в одном растущем
[]byte и отдаёт строку без копии.
Что происходит при s += x
Компилятор вызывает runtime.concatstrings: считает
суммарную длину, выделяет новый буфер, копирует в него оба куска.
Старая строка становится мусором. На n итерациях
копируется n(n+1)/2 байт и остаётся n
мусорных буферов, которые вдобавок нагружают GC.
Оговорка: a + b + c + d одним выражением
превращается в один вызов concatstrings, то есть в
одну аллокацию. Плохо именно накопление в цикле.
| Способ | Время | Аллокаций | Комментарий |
|---|---|---|---|
s += x в цикле |
O(n²) | n | худший вариант |
strings.Builder |
O(n) аморт. | log n | дефолт |
Builder + Grow(n) |
O(n) | 1 | лучший вариант при известном размере |
strings.Join |
O(n) | 1 | если куски уже в слайсе |
bytes.Buffer |
O(n) аморт. | log n |
+1 копия в String(), зато это
io.Writer
|
fmt.Sprintf |
O(n) + рефлексия | 2–4 | в 5–10 раз медленнее — это форматирование, а не склейка |
var b strings.Builder
b.Grow(totalLen) // одна аллокация на всё
for _, p := range parts {
b.WriteString(p) // append в []byte, без копии строки
}
return b.String() // unsafe-конверсия без копии буфера
Почему Builder быстрее bytes.Buffer для строк
bytes.Buffer.String() делает
string(b.buf[off:]), то есть честную копию.
strings.Builder.String() возвращает
unsafe.String(&b.buf[0], len(b.buf)) без копии, и
это безопасно: Builder гарантирует, что после
String() в буфер уже никто не пишет (запись после
копирования Builder ловится паникой). На мегабайтной строке одна
лишняя копия чувствуется.
Внутри лежит addr *Builder, указатель на самого
себя. При записи проверяется b.addr == b, и если
структуру скопировали, вылетит panic: strings: illegal use of non-zero Builder copied
by value. Передавай Builder только по указателю; go vet
ловит такие копии анализатором copylocks.
«Два-три куска, просто +. Всё уже в слайсе,
strings.Join. Собираем в цикле,
strings.Builder с Grow. Нужен
io.Writer, берём bytes.Buffer или пишем
прямо в ResponseWriter. Sprintf только
когда реально нужно форматирование.» И назови порядок цифр: на
1000 кусках Builder примерно в 150 раз быстрее плюса и делает 1
аллокацию вместо 1000.
func extractID(body string) string {
return body[10:20] // 10 байт, а держат весь body
}
cache[key] = extractID(hugeBody) // в кэше «10 байт», в куче — весь body
Почему GC не может помочь
Сборщик работает с гранулярностью объекта: буфер строки лежит в куче одним блоком. Если на него указывает хотя бы один живой указатель, жив весь блок. Обрезать его GC не умеет: в Go нет перемещающего сборщика и нет «частичного освобождения» объекта.
Три способа починить
// 1) strings.Clone — Go 1.18+, самый честный способ
small := strings.Clone(body[10:20])
// 2) Конверсия туда-обратно, так жили до Clone
small = string([]byte(body[10:20]))
// 3) Не создавать большую строку вообще: читать потоково
sc := bufio.NewScanner(r)
for sc.Scan() {
line := sc.Text() // Text() уже делает копию, Bytes() нет: буфер переиспользуется
}
Где это ловится в проде
-
strings.Split,strings.Fields,strings.TrimSpaceвозвращают подстроки, а не копии. Разобрали лог-строку на 8 КБ, сохранили одно поле в мапу, держим 8 КБ. -
bytes.Splitиbufio.Scanner.Bytes(): то же самое со срезами, причём у Scanner буфер ещё и переиспользуется между строками, так что данные протухнут. -
Парсинг HTTP-ответа: достали
idслайсингом из тела на мегабайт, удержали мегабайт на каждую запись. -
regexp.FindStringSubmatchтоже отдаёт подстроки исходной строки.
Как диагностировать
$ go tool pprof -inuse_space http://localhost:6060/debug/pprof/heap
(pprof) top
# симптом: почти вся живая память в одном месте аллокации большого буфера
# (os.ReadFile, io.ReadAll, http body), хотя логически хранится мало
Механика ровно та же, что у []T, и на собесе вопросы
часто идут парой. Отличие одно: у слайса есть full slice
expression s[low:high:max], которая ограничивает
cap, а у строки такого нет, потому что нет и самого
cap. Строку лечат только явной копией (strings.Clone).
== сравнивает
содержимое побайтово (сначала длины, потом указатели,
потом байты машинными словами). Регистронезависимо — только
strings.EqualFold, а не
ToLower(a) == ToLower(b).
Как устроено ==
Компилятор разворачивает сравнение строк в проверку длин
(разошлись, сразу false), затем в сравнение
указателей (совпали, сразу true), и только потом в
runtime.memequal, который идёт по машинным словам, с
SIMD там, где он доступен. Оператор < сравнивает
лексикографически по байтам; для UTF-8 это совпадает с порядком
кодовых точек, но не с алфавитным порядком языка.
"Z" < "a" // true: 0x5A < 0x61 — заглавные раньше строчных
"ёлка" < "яблоко" // сравниваются байты UTF-8, а не «русский алфавит»
// для человеческого порядка нужен golang.org/x/text/collate
Почему EqualFold, а не ToLower
-
Аллокации.
ToLowerсоздаёт две новые строки;EqualFoldидёт по обеим строкам параллельно и не аллоцирует вообще. -
Ранний выход. На первом различии сразу
false, остаток не читается. - Корректность. Unicode определяет отдельную операцию case folding, спроектированную именно для сравнения. Она не равна «привести к нижнему регистру»: у греческой сигмы две строчные формы (σ и ς), у турецкого I своя пара, у немецкого ß отдельные правила.
strings.EqualFold("Go", "GO") // true
strings.EqualFold("ПРИВЕТ", "привет") // true
strings.EqualFold("Σίσυφος", "ΣΊΣΥΦΟΣ") // true — обе сигмы свернутся правильно
strings.EqualFold("Straße", "STRASSE") // false — simple folding не разворачивает ß в ss
Когда нужен НЕ ==
| Задача | Инструмент |
|---|---|
| Регистронезависимо | strings.EqualFold |
| Пароль, токен, HMAC-подпись |
crypto/subtle.ConstantTimeCompare —
== выходит на первом различии, и по
времени ответа можно подобрать значение побайтово
|
| «é» в разных нормализациях |
norm.NFC.String(a) == norm.NFC.String(b)
|
| Человеческая сортировка | x/text/collate с нужной локалью |
| Префикс/суффикс |
strings.HasPrefix —
s[:n] паникует при
n > len(s)
|
Сравнение []byte |
bytes.Equal — == для
срезов не компилируется
|
Сравнение API-токена через token == expected
открывает timing attack: memequal выходит на первом
несовпавшем байте, и по разнице во времени ответа токен подбирают
за линейное число запросов вместо экспоненциального. Для секретов
всегда subtle.ConstantTimeCompare([]byte(a), []byte(b)) ==
1. На вопросе «как сравнить две строки» этот ответ отделяет мидла
от джуна.
1.5Интерфейсы и «ООП» в Go
Здесь проверяют, понимаешь ли ты, что интерфейсное значение состоит из двух половинок: тип и данные. Отсюда растёт всё остальное: знаменитый typed nil, правила method set, цена динамической диспетчеризации, привычка объявлять интерфейс у потребителя.
Сначала — что такое интерфейс и откуда взялись «две половинки»
Всё сводится к одному рабочему вопросу: как в языке без
наследования подставить одну реализацию вместо другой и что за это
платишь. Мокнуть базу в тесте, подменить платёжного
провайдера, написать функцию, которая одинаково пишет и в файл, и
в сеть, — всё это интерфейсы. И тут же рядом два самых частых
«убийственных» вопроса собеса: почему err != nil, когда
там явно nil, и почему метод не находится, хотя он написан.
Прежде чем лезть в рантайм, договоримся о четырёх словах.
1. Интерфейс — это список требований, а не хранилище данных
type Writer interface { Write([]byte) (int, error) } не
описывает полей и ничего не хранит. Это объявление вида «мне
подойдёт любой тип, у которого есть вот такой метод». Как
объявление в газете: «требуется тот, кто умеет писать байты». Кто
именно придёт, объявлению безразлично.
2. Duck typing — реализация без объявления
В Java или C# тип обязан сказать, что он реализует
интерфейс: implements Writer. В Go такого слова нет
вообще. Если у типа есть метод Write с нужной
сигнатурой, он уже реализует io.Writer, даже
если написан за пять лет до появления этого интерфейса и в другом
репозитории. Такой подход называют утиной типизацией, а строгий термин звучит как
структурная типизация: тип подходит по форме, а не по
родословной.
Плата за удобство: компилятор не скажет «класс X забыл реализовать интерфейс Y». Он скажет «X не подходит на место Y», причём там, где ты попытался его подставить, иногда в совсем другом пакете.
3. Method set — набор методов, которые «числятся» за типом
Method set типа — список методов,
которые интерфейс засчитывает именно этому типу. Звучит как
тавтология, но подвох реальный: у T и у *T
наборы разные. Методы с получателем-значением (func (t T) Foo()) входят в оба набора; методы с
получателем-указателем (func (t *T) Bar()) попадают
только в набор *T. Отсюда классическое «почему
T не реализует интерфейс, а &T{}
реализует». Об этом ниже отдельно.
4. Интерфейсная переменная — это две половинки: «какой тип» и «где данные»
Когда ты пишешь var w io.Writer = f, в переменную
w не «кладётся файл». В неё кладутся два
указателя:
-
левая половинка — тип: «здесь лежит
*os.File», плюс, для непустого интерфейса, таблица адресов его методов; - правая половинка — данные: адрес самого значения.
Аналогия: интерфейс — это ярлык на посылке. На ярлыке написано, что внутри («тип: коробка обуви») и по какому адресу посылка лежит. Сам ярлык не посылка. Его можно скопировать, передать, сравнить с другим ярлыком, и на нём вполне может быть написано «внутри: коробка обуви, содержимое: ничего». Ярлык всё равно существует, он не пустой. На этом и держится вся история с typed nil.
Три жаргонных слова, которые дальше встречаются постоянно и означают ровно это:
-
eface(empty interface) — как устроена переменная пустого интерфейсаany: «указатель на описание типа» + «указатель на данные». -
iface— как устроена переменная любого непустого интерфейса (error,io.Writer, свой): вместо описания типа в левой половинке лежит указатель наitab. -
itab(interface table) — маленькая табличка, которую рантайм заводит на каждую пару «этот интерфейс + этот конкретный тип». Внутри лежат описание типа и адреса его методов в порядке методов интерфейса, а они отсортированы по имени. Благодаря ей вызовw.Write(...)превращается в «взять адрес из ячейки номер 0 и прыгнуть туда».
Последнее и называется динамической диспетчеризацией: какой именно код выполнится, решает не компилятор, а таблица в момент вызова. Отсюда и цена интерфейса: прыжок по адресу из памяти вместо прямого вызова плюс невозможность заинлайнить метод.
ООП в Go: что есть и чего нет
| Механизм классического ООП | Есть ли в Go | Чем заменено |
|---|---|---|
| Класс | нет |
struct + методы, объявленные рядом на уровне
пакета
|
| Инкапсуляция | да | регистр первой буквы: экспортируется из пакета или нет |
| Наследование реализации | нет | встраивание (embedding) — это композиция с продвижением методов |
| Полиморфизм | да | интерфейсы с неявной реализацией |
| Абстрактный класс | нет | интерфейс + встроенная структура с общей реализацией |
| Конструктор | нет |
функция NewT(...) *T по соглашению; полезное
нулевое значение
|
| Перегрузка методов | нет | разные имена, вариативные параметры, функциональные опции |
| Дженерики | с Go 1.18 | type parameters, отдельная тема (1.7) |
| Исключения | нет |
error как значение; panic —
только для багов
|
super / base |
нет |
явный вызов метода встроенного типа:
t.Base.Method()
|
Формулировка для собеса: в Go нет наследования, есть
композиция и структурная типизация. Тип реализует интерфейс
автоматически, если у него есть нужные методы, без всяких
implements. Так разворачивается зависимость: интерфейс
не привязан к реализации, а реализация ничего не знает об
интерфейсе.
- Хрупкий базовый класс. Тронешь базовый класс, и наследники сломаются неочевидным образом; на глубокой иерархии трассировка вызова превращается в квест.
- Наследование связывает намертво. Подкласс зависит от реализации родителя, а не от одного лишь контракта. Сильнее связности не бывает.
- Ромбовидная проблема. Множественное наследование заставляет придумывать правила разрешения конфликтов (C++ virtual, MRO в Python). Go вместо этого объявляет конфликт имён при встраивании ошибкой компиляции, если его не разрешили явно.
- Композиция гибче. «Has-a» вместо «is-a»: поведение меняешь подстановкой зависимости, а не переопределением метода в наследнике.
Embedding: композиция с продвижением методов
Встраивание — это поле без имени. Имя типа становится именем поля, а все методы и поля встроенного типа продвигаются наружу: их можно вызывать так, будто они объявлены у внешнего типа. Это синтаксический сахар над делегированием, а не наследование.
type Animal struct {
Name string
}
func (a Animal) Speak() string { return a.Name + " издаёт звук" }
func (a Animal) Describe() string { return "Я " + a.Speak() }
type Dog struct {
Animal // встроили: поле называется Animal, методы продвинуты
Breed string
}
d := Dog{Animal{"Рекс"}, "лабрадор"}
fmt.Println(d.Name) // "Рекс" — продвинутое поле, сахар для d.Animal.Name
fmt.Println(d.Speak()) // "Рекс издаёт звук" — продвинутый метод
// (пока у Dog нет своего Speak; объявим его ниже,
// и эта же строка начнёт печатать "Рекс гавкает":
// порядок объявлений в файле роли не играет)
// Метод внешнего типа перекрывает продвинутый
func (d Dog) Speak() string { return d.Name + " гавкает" }
fmt.Println(d.Speak()) // "Рекс гавкает" — вызвался метод Dog
fmt.Println(d.Animal.Speak())// "Рекс издаёт звук" — явный доступ к встроенному
fmt.Println(d.Describe()) // "Я Рекс издаёт звук", а не "гавкает"
Describe() объявлен у Animal, его receiver —
Animal. Внутри a.Speak() вызывает метод
Animal, потому что a — это Animal,
и о существовании Dog он не знает и знать не может.
В Java/C++ здесь сработал бы полиморфизм и напечаталось бы
«гавкает». Встраивание не даёт виртуальных методов.
Хочешь такое поведение, объявляй интерфейс и передавай
зависимость явно.
d.Speak() превращается в вызов метода
нужного типа с нужным receiver'ом. Никакой таблицы виртуальных
функций у структуры нет.
// Встраивание интерфейса в структуру — идиома «частичная реализация»
type ReadWriter interface { io.Reader; io.Writer } // интерфейс в интерфейсе
type OnlyRead struct {
io.ReadWriter // встроен интерфейс: тип формально реализует ReadWriter,
} // но методы делегируются в nil, если поле не заполнено
var x OnlyRead
// x.Read(nil) // panic: nil pointer dereference — метод есть, реализации нет
// Та же идиома с пользой: обёртка перекрывает один метод
type LoggingStore struct {
Store // все методы Store продвинуты
log *slog.Logger
}
func (s LoggingStore) Get(id string) (User, error) { // перекрываем только Get
s.log.Info("get", "id", id)
return s.Store.Get(id) // делегируем дальше
}
// Мьютекс часто встраивают ради «наследования», но это спорно:
type Counter struct {
sync.Mutex // Lock/Unlock стали экспортируемыми методами Counter
n int
}
// лучше так: мьютекс остаётся деталью реализации
type Counter2 struct {
mu sync.Mutex
n int
}
- Декоратор/обёртка: встроил интерфейс, перекрыл один метод, остальные делегируются автоматически, и не надо писать 20 переходников.
-
Расширение чужого типа:
type MyRequest struct { *http.Request; TraceID string }. -
Общий кусок состояния:
type Base struct{ ID int; CreatedAt time.Time }во множестве моделей.
И когда неуместно: ради «наследования» поведения. Если
тянет написать «базовый класс с виртуальным методом», объявляй
интерфейс и передавай зависимость снаружи, иначе получишь тихий
баг из примера с Describe().
Внутреннее устройство: iface, eface, itab
Интерфейсное значение — всегда два машинных слова. Что именно в них лежит, зависит от того, пустой интерфейс или нет.
itab, где к
дескриптору типа добавлена таблица методов конкретной пары
«интерфейс + тип».
// Упрощённо из runtime/runtime2.go:
type eface struct { // interface{} / any
_type *_type
data unsafe.Pointer
}
type iface struct { // любой непустой интерфейс
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype // описание интерфейса
_type *_type // описание конкретного типа
hash uint32 // копия _type.hash для быстрого type switch
_ [4]byte
fun [1]uintptr // на самом деле переменной длины: адреса методов
}
Поле data — всегда указатель. Значит, чтобы
положить в интерфейс int, его надо разместить где-то в
памяти и взять адрес. Это escape в кучу, и именно поэтому
fmt.Println(i) для int в бенчмарке
показывает аллокации. Есть две оптимизации: значения-указатели
кладутся напрямую (лишней аллокации нет), а маленькие целые от 0
до 255 берутся из предвыделенного массива
runtime.staticuint64s. Раньше (до Go 1.4) была ещё
«direct interface» для однословных значений; сейчас она работает
только для типов, которые сами по себе указатель (указатель,
мапа, канал, функция, unsafe.Pointer, а также структура/массив
из одного такого поля).
Классика: typed nil в error
Самый частый «убийственный» вопрос секции. Ломает он ровно потому,
что «две половинки» казались абстракцией, а тут вдруг решают исход
if err != nil.
Что такое typed nil. Есть два разных «ничего», и в Go они не одно и то же:
-
Нулевой указатель —
var e *MyError. Переменная существует, её тип известен (*MyError), просто адрес внутри нулевой. Это «конкретное значение, которое ни на что не показывает». -
Пустой интерфейс-переменная —
var err error. Обе половинки нулевые: неизвестно ни какой тип, ни где данные. Это «вообще ничего не положили».
Когда ты пишешь return e из функции с типом результата
error, рантайм упаковывает значение: заполняет
левую половинку («тип: *MyError») и правую («данные:
nil»). Вернулся ярлык с надписью, что внутри, пусть внутри и
пусто. А сравнение err == nil проверяет, что пусты
обе половинки. Левая не пуста. Значит false.
Та же мысль короче, чтобы проговорить вслух на собесе: «nil-указатель, положенный в интерфейс, — это не nil-интерфейс. Интерфейс запомнил тип, а тип — это не nil».
Та же картинка, только схемой.
nil, что бы ни лежало в
правом.
type MyError struct {
Code int
}
func (e *MyError) Error() string { return fmt.Sprintf("code=%d", e.Code) }
// Плохо: возвращаем конкретный тип
func doBad(ok bool) error {
var e *MyError // e == nil
if !ok {
e = &MyError{Code: 500}
}
return e // в error упаковали (*MyError)(nil)
}
err := doBad(true)
fmt.Println(err == nil) // false, хотя e был nil
fmt.Println(err) // <nil>
fmt.Printf("%T %v\n", err, err) // *main.MyError <nil>
// Почему <nil>, а не паника: fmt вызывает Error(), тот разыменовывает e.Code
// и паникует на nil-получателе, а fmt ловит панику и подставляет <nil>.
// Если бы Error() не трогал поля (например, return "boom"), напечаталось бы "boom".
// Вот и подвох: в логе nil, а ветка ошибки выполняется.
// Хорошо, вариант 1: возвращать nil явно
func doGood1(ok bool) error {
if !ok { return &MyError{Code: 500} }
return nil // литеральный nil: обе половинки нулевые
}
// Хорошо, вариант 2: если переменная нужна, проверить её перед возвратом
func doGood2(ok bool) error {
var e *MyError
if !ok { e = &MyError{Code: 500} }
if e == nil { return nil } // e в интерфейс не упаковывается
return e
}
Чаще всего подводят именованные возвращаемые значения
конкретного типа и функции вида func parse() (*Result, *ParseError), которые потом оборачивают. Второй источник —
defer: он присваивает в именованный err error переменную конкретного типа. Ещё ловят на
json.Unmarshal в интерфейсное поле. Практическое
правило: функция, возвращающая error, должна возвращать
интерфейсный тип на всех путях и явный nil в
успешном случае. Линтер nilness из go vet и staticcheck (SA4023) часть таких случаев
ловят.
// Поймать typed nil можно рефлексией, но это лечит симптом
func isNil(i any) bool {
if i == nil { return true }
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface:
return v.IsNil()
}
return false
}
// Правильнее вообще не создавать typed nil.
Неявная реализация и проверка на этапе компиляции
В Go нет implements. Тип реализует интерфейс, если его
method set содержит все методы интерфейса. Это и есть
структурная типизация. Плюсы: можно написать интерфейс
после реализации, в том числе для чужого пакета;
реализация не зависит от абстракции. Минус: опечатка в имени или
сигнатуре метода даёт не «класс не реализует интерфейс», а
внезапную ошибку в месте использования, иногда далеко от
объявления.
// Идиома: компилятор проверяет, что тип реализует интерфейс
var _ io.Writer = (*MyWriter)(nil) // *MyWriter реализует io.Writer
var _ error = (*MyError)(nil)
var _ sort.Interface = (ByAge)(nil) // для слайс-типа
var _ Storage = (*PostgresStore)(nil)
// Создаём типизированный nil нужного типа
// и присваиваем пустой переменной интерфейсного типа.
// Значение никуда не идёт, аллокаций нет: работает только проверка типов.
// Если метода не хватает, компилятор скажет:
// cannot use (*MyWriter)(nil) as io.Writer value:
// *MyWriter does not implement io.Writer (missing method Write)
Рядом с объявлением типа, в том же файле. Это сразу и тест, и документация: читатель видит, какие контракты тип обязан выполнять. Особенно если тип публичный, а интерфейс из другого пакета: без такой строки удалишь метод при рефакторинге, компиляция пройдёт, а сломается у пользователей библиотеки.
Пустой интерфейс any и type assertion
any — псевдоним для interface{},
добавленный в Go 1.18 (type any = interface{}). Его
реализует любой тип, потому что требований ноль. Цена: ты теряешь
всю информацию о типе, и вернуть её можно только через
рантайм-проверку.
var x any = 42
// 1. Небезопасная форма паникует при несовпадении
n := x.(int) // 42
// s := x.(string) // panic: interface conversion: interface {} is int, not string
// 2. Безопасная форма (comma-ok) никогда не паникует
if s, ok := x.(string); ok {
fmt.Println("строка:", s)
} else {
fmt.Println("не строка") // ← сюда: x это int
}
// 3. Type switch ветвится по динамическому типу
switch v := x.(type) {
case nil:
fmt.Println("nil интерфейс") // отдельный case для nil
case int:
fmt.Println("int", v*2) // ← сюда, печатает: int 84
case string, []byte:
fmt.Println("текстовое", v) // а тут v имеет тип any
case error:
fmt.Println("ошибка", v.Error()) // можно проверять и на интерфейс
case fmt.Stringer:
fmt.Println("Stringer", v.String())
default:
fmt.Printf("неизвестный тип %T\n", v)
}
// весь блок при x = 42 печатает:
// не строка
// int 84
-
Несколько типов в одном case — переменная
vсохраняет интерфейсный тип, а не конкретный. Компилятор не может выбрать один. -
Порядок case имеет значение, если среди них есть
интерфейсы: первый подходящий выигрывает.
case errorпередcase *MyErrorперехватит всё. -
case nilсрабатывает только для настоящего nil-интерфейса. Typed nil попадёт в case своего типа, и это ещё один способ поймать проблему из предыдущего раздела.
Когда any оправдан: границы, где типы
принципиально неизвестны (fmt.Println,
json.Unmarshal, кэш общего назначения, логгер с
произвольными атрибутами). Когда плох: внутри доменной
логики. Он выключает проверку типов, добавляет аллокации на
упаковку, заставляет писать switch вместо
полиморфизма, и с Go 1.18 в большинстве таких мест правильнее
дженерики.
Method set: T против *T
Method set — множество методов, доступных для типа при проверке «реализует ли он интерфейс». Правило звучит асимметрично, и именно на нём чаще всего спотыкаются:
-
Method set типа
T— только методы с value receiver. -
Method set типа
*T— методы с value receiver и с pointer receiver.
t.Ptr() вызвать
можно, а положить t в интерфейс, требующий
Ptr(), — нельзя.
type Speaker interface{ Speak() }
type Dog struct {
name string
}
func (d *Dog) Speak() { fmt.Println(d.name) } // POINTER receiver
d := Dog{"Рекс"}
d.Speak() // ОК: компилятор пишет (&d).Speak(), d адресуема
// var s Speaker = d // ошибка компиляции:
// cannot use d (variable of struct type Dog) as Speaker value in variable
// declaration: Dog does not implement Speaker (method Speak has pointer receiver)
var s Speaker = &d // ОК
// Где ещё вылезает: элементы мапы не адресуемы
m := map[string]Dog{"a": {"Рекс"}}
// m["a"].Speak() // ошибка: cannot call pointer method Speak on Dog
// А элементы слайса адресуемы
sl := []Dog{{"Рекс"}}
sl[0].Speak() // ОК
// Возвращаемое значение функции не адресуемо
// newDog().Speak() // ошибка, если newDog() возвращает Dog, а не *Dog
Value receiver или pointer receiver
| Бери pointer receiver, если… | Бери value receiver, если… |
|---|---|
| метод изменяет получателя |
тип маленький и неизменяемый по смыслу
(time.Time, Point)
|
| структура большая — копия дорога |
тип — базовый или именованный поверх него
(type Celsius float64)
|
внутри есть sync.Mutex или другой noCopy
|
тип — уже дескриптор (слайс, мапа, канал, функция) |
| хотя бы один метод типа уже с указателем | нужна безопасность от случайных мутаций |
| нужно отличать nil-получателя | метод — чистая функция от значения |
Не смешивай receiver'ы у одного типа. Если хоть один
метод требует указателя, делай указателями все: иначе method set
типа T окажется неполным и сам тип перестанет
удовлетворять собственным интерфейсам. А ошибка вылезет где-то
далеко. Линтер golangci-lint проверяет это через
staticcheck и отдельный recvcheck.
type Counter struct {
n int
}
func (c Counter) IncBad() { c.n++ } // работает с копией, инкремент теряется
func (c *Counter) Inc() { c.n++ } // так правильно
c := Counter{}
c.IncBad(); c.IncBad()
fmt.Println(c.n) // 0 — классический вопрос «что выведет»
c.Inc(); c.Inc()
fmt.Println(c.n) // 2
// nil-receiver законен и иногда полезен
type List struct {
head *node
count int
}
func (l *List) Len() int {
if l == nil { return 0 } // метод на nil-указателе вызывается нормально:
return l.count // паника будет только при обращении к полю
}
var l *List
fmt.Println(l.Len()) // 0, без паники
Exported и unexported
Видимость в Go задаёт регистр первой буквы
идентификатора, и работает она на уровне пакета, а
не файла или типа. Name виден снаружи пакета,
name — только внутри. Это касается всего: типов,
функций, методов, полей структур, констант, переменных.
package store
type User struct {
ID int // виден снаружи
name string // виден только внутри пакета store
}
func (u *User) Name() string { return u.name } // геттер экспортирован
func (u *User) validate() error { return nil } // приватный метод
// Внутри пакета доступны любые поля любого его типа:
// приватность не «на объект», а «на пакет».
func compare(a, b User) bool { return a.name == b.name } // ок
Три следствия, которые проверяют:
-
JSON и рефлексия видят только экспортированные поля.
json.Marshalмолча пропуститname, аjson.Unmarshalне заполнит его. Отсюда DTO-структуры с экспортированными полями и тегами. - Встраивание не «отменяет» приватность. Встроив тип из другого пакета, ты получаешь только его экспортированные методы и поля.
- Приватный тип с публичными методами — законная и полезная конструкция: функция возвращает интерфейс, а конкретный тип наружу не виден вообще.
«Принимай интерфейсы, возвращай структуры»
Правило сообщества: accept interfaces, return structs. Смысл двухсторонний.
// Принимай минимальный интерфейс
// Функция объявляет только то, что реально
// использует. Тестируется тривиально:
// подставили strings.Builder или bytes.Buffer.
func Save(w io.Writer, u User) error {
return json.NewEncoder(w).Encode(u)
}
// А не так:
func SaveBad(f *os.File, u User) error {
return json.NewEncoder(f).Encode(u)
}
// теперь без файла на диске не протестировать
// Возвращай конкретный тип, структуру
// Вызывающий видит все методы и поля,
// может сам решить, какой интерфейс ему нужен.
func NewStore(db *sql.DB) *Store {
return &Store{db: db}
}
// А не так:
func NewStoreBad(db *sql.DB) Storage {
return &Store{db: db}
}
// теперь новый метод Store не виден
// пользователям, и легко создать typed nil
-
Это эвристика, а не догма. Возвращать интерфейс
уместно, когда у функции несколько реализаций по условию
(
NewCache(cfg)отдаёт то Redis, то in-memory), или когда конкретный тип принципиально приватный. -
Вернув интерфейс, легко нарваться на typed nil: достаточно
случайно отдать
(*T)(nil). -
Принимать интерфейс бессмысленно, если он раздутый:
Storageс 20 методами так же неподставляем в тест, как*sql.DB.
Где объявлять интерфейс: у потребителя
В Java интерфейс объявляют рядом с реализацией и импортируют оба. В Go принято наоборот: интерфейс живёт в пакете, который его использует. Это возможно именно из-за неявной реализации.
// package service — потребитель объявляет ровно то, что ему нужно
type UserGetter interface {
GetUser(ctx context.Context, id int64) (User, error)
}
type Service struct {
users UserGetter
}
func New(u UserGetter) *Service { return &Service{users: u} }
// package postgres — реализация ничего не знает про service
type Store struct {
db *sql.DB
}
func (s *Store) GetUser(ctx context.Context, id int64) (User, error) { /* ... */ }
func (s *Store) SaveUser(ctx context.Context, u User) error { /* ... */ }
func (s *Store) DeleteUser(ctx context.Context, id int64) error { /* ... */ }
// main.go — связывание
svc := service.New(postgres.New(db))
- Интерфейсы получаются маленькие. Потребителю нужен один метод, он и объявит интерфейс с одним методом, а не с двадцатью. «The bigger the interface, the weaker the abstraction» (Роб Пайк).
- Моки генерируются на маленький интерфейс: их проще писать и читать.
- Нет циклических импортов. Классическая проблема «service импортирует storage, storage импортирует модели service» решается сама собой.
-
Реализацию можно менять свободно: новый метод у
*Storeничего не ломает.
Сравнение интерфейсных значений и когда panic
Два интерфейсных значения равны, если совпадают динамические типы и равны динамические значения. Если динамический тип не comparable, будет паника в рантайме: компилятор в общем случае не знает, что там окажется.
var a any = 1
var b any = 1
var c any = int64(1)
var d any = "1"
fmt.Println(a == b) // true — одинаковый тип int и одинаковое значение
fmt.Println(a == c) // false — int != int64, значения даже не сравниваются
fmt.Println(a == d) // false — разные типы
var s1 any = []int{1}
var s2 any = []int{1}
// fmt.Println(s1 == s2) // panic: runtime error: comparing uncomparable type []int
// Сравнимость структуры зависит от полей
type P struct {
X int
}
type B struct {
S []int
}
var p1, p2 any = P{1}, P{1}
fmt.Println(p1 == p2) // true
var b1, b2 any = B{}, B{}
// fmt.Println(b1 == b2) // panic: runtime error: comparing uncomparable type main.B
// Чаще всего сравнивают с nil
var e error
fmt.Println(e == nil) // true
e = (*MyError)(nil)
fmt.Println(e == nil) // false — typed nil
-
Ключи мапы
map[any]V:hash of unhashable type []int. -
errors.Is: он сравнивает ошибки через==. Если ошибка — структура со слайсом внутри, будет паника. Поэтому кастомные ошибки либо делают указателями, либо реализуютIs(error) bool. -
slices.Containsдля[]any— та же история. -
Сравнение в тестах:
if got != wantдляany. Используйreflect.DeepEqualилиgo-cmp.
Рефлексия и её цена
reflect даёт доступ к тем самым двум половинкам прямо из
кода: reflect.TypeOf(x) достаёт _type,
reflect.ValueOf(x) — пару «тип + данные». Три закона
рефлексии (из блога Роба Пайка):
-
Из интерфейсного значения можно получить
reflect.Value. -
Из
reflect.Valueможно вернуться к интерфейсному значению (.Interface()). -
Чтобы менять значение через рефлексию, оно должно быть
settable, то есть получено через указатель
(
reflect.ValueOf(&x).Elem()).
type User struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name"`
}
u := User{1, "Аня"}
t := reflect.TypeOf(u)
v := reflect.ValueOf(u)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Printf("%s %s тег=%q значение=%v\n",
f.Name, f.Type, f.Tag.Get("db"), v.Field(i).Interface())
}
// ID int тег="user_id" значение=1
// Name string тег="" значение=Аня
// Менять можно только через указатель
p := reflect.ValueOf(&u).Elem()
p.Field(1).SetString("Боря") // u == {1 Боря}
// v.Field(1).SetString("X") // panic: reflect: reflect.Value.SetString using unaddressable value
| Операция | Порядок стоимости | Почему |
|---|---|---|
| Прямой доступ к полю | ~1 нс | смещение известно на компиляции |
reflect.ValueOf(x).Field(i) |
десятки нс | проверки типов, вычисление смещения в рантайме |
v.Interface() |
аллокация | упаковка обратно в интерфейс |
json.Marshal |
сотни нс — мкс | обход всей структуры рефлексией + парсинг тегов (кэшируется) |
Кодогенерация (easyjson,
protobuf)
|
в 3–10 раз быстрее | рефлексии нет вообще |
encoding/json, encoding/xml,
database/sql (сканирование в any),
text/template, fmt (глаголы
%v, %+v), sort.Slice (через
reflect.Swapper), практически все валидаторы и ORM.
Это осознанная сделка: рефлексия платит скоростью за то, что
один код работает с любыми типами. Когда она становится узким
местом, переходят на кодогенерацию (easyjson,
ffjson, protobuf, sqlc) или
на дженерики. Ещё деталь: json кэширует разобранную
структуру типа в sync.Map, поэтому первый Marshal
дороже последующих.
Что выведет код?
// (A)
type E struct{}
func (e *E) Error() string { return "e" }
func f() error {
var p *E
return p
}
func main() {
err := f()
fmt.Println(err == nil)
fmt.Println(err)
if err != nil {
fmt.Println("обработка ошибки")
}
}
// (B)
type A struct {
n int
}
func (a A) Show() { fmt.Println("A", a.n) }
func (a *A) Inc() { a.n++ }
type B struct {
A
}
func (b B) Show() { fmt.Println("B", b.n) }
func main() {
b := B{A{1}}
b.Show()
b.A.Show()
b.Inc()
b.Show()
var s interface{ Show() } = b
s.Show()
}
(A) → false, затем e, затем
обработка ошибки
(fmt.Printf("%T %v", err, err) дал бы
*main.E e). Классический typed nil: в интерфейсе
лежит тип *E и данные nil, поэтому
err != nil.
Печатается именно e, а не <nil>:
fmt вызывает Error() на nil-получателе, а
этот метод не трогает поля e и спокойно возвращает
строку. <nil> вы увидели бы, только если бы
Error() разыменовал получателя — тогда он бы паниковал,
а fmt перехватил бы панику и подставил
<nil>. Оба варианта одинаково коварны: сообщение в
логе выглядит безобидно, а ветка ошибки при этом выполняется.
(B) → B 1, A 1, затем
B 2, затем B 2.
b.Show() берёт перекрытый метод B;
b.A.Show() — явно метод A.
b.Inc() компилируется как
(&b.A).Inc(), потому что
b адресуема, и меняет встроенное поле. А вот
var s interface{ Show() } = b кладёт в интерфейс
копию b: если бы после этого мы вызвали
b.Inc(), s.Show() всё равно напечатал бы
старое значение.
Вопросы
14Что есть
-
Данные и поведение отдельно:
structхранит состояние, методы объявляются на уровне пакета с указанием receiver'а. Метод можно добавить любому именованному типу своего пакета, включаяtype Celsius float64. - Инкапсуляция на уровне пакета: регистр первой буквы. Внутри пакета видно всё.
- Полиморфизм: интерфейсы. Реализация неявная: методы совпали, тип подходит.
- Композиция: встраивание типов с продвижением методов.
Чего нет и чем заменено
| Нет | Вместо этого |
|---|---|
| класс | struct + методы пакета |
| наследование реализации | встраивание (композиция) |
виртуальные методы / super |
интерфейс + явная делегация |
| абстрактный класс | интерфейс + встроенная структура с общим кодом |
| конструктор |
NewT() по соглашению; полезное нулевое
значение
|
| перегрузка методов | разные имена, вариативные аргументы, функциональные опции |
| исключения | error как обычное значение |
Почему авторы отказались от наследования
- Хрупкий базовый класс. Поправил родителя, и наследники сломались, причём неочевидно; в глубокой иерархии ещё попробуй проследить вызов.
- Наследование — самая сильная связность. Подкласс зависит от реализации родителя, а не от одного контракта.
- Ромб. Множественное наследование заставляет придумывать правила разрешения конфликтов; Go объявляет неоднозначность ошибкой компиляции.
- Композиция гибче. Поведение меняешь подстановкой зависимости, а не переопределением метода.
«Go — не объектно-ориентированный язык в классическом смысле. Он даёт инкапсуляцию и полиморфизм, но не даёт иерархий типов. Полиморфизм здесь основан на поведении (структурная типизация), а не на происхождении (номинальная). Практический эффект: абстракции получаются маленькие и объявляются там, где нужны, а не там, где реализованы.»
type Animal struct {
Name string
}
func (a Animal) Speak() string { return a.Name + " звучит" }
func (a Animal) Describe() string { return "Я " + a.Speak() }
type Dog struct {
Animal // имя поля = имя типа
Breed string
}
func (d Dog) Speak() string { return d.Name + " гавкает" } // перекрытие
d := Dog{Animal{"Рекс"}, "лабрадор"}
d.Name // сахар для d.Animal.Name
d.Speak() // "Рекс гавкает" — метод Dog закрыл продвинутый
d.Animal.Speak()// "Рекс звучит" — явный доступ
d.Describe() // "Я Рекс звучит", а не "гавкает"
Правила разрешения имени
- Побеждает меньшая глубина. Метод внешнего типа закрывает продвинутый с глубины 1 и ниже.
-
Ничья на одной глубине — ошибка
ambiguous selector, но только в момент обращения к имени. Просто встроить два типа с одинаковым методом можно. -
Встраивать можно
T,*Tи интерфейс. Method set зависит от того, что встроено: уstruct{ T }продвигаются value-методыTв method set структуры и все методы в method set указателя на неё; уstruct{ *T }продвигаются все методыTв оба набора.
Чем это НЕ является
Наследованием. Внутри Animal.Describe receiver
имеет тип Animal, и о существовании Dog
он ничего не знает: вызов a.Speak() статически
связан с Animal.Speak. В Java здесь сработал бы
полиморфизм. Это самая частая ошибка приходящих из
ООП-языков: пишут «шаблонный метод» через встраивание и
получают тихо неправильное поведение.
Где встраивание правда полезно
// 1) Декоратор: встроили интерфейс, перекрыли один метод
type LoggingStore struct {
Storage // остальные методы делегируются автоматически
log *slog.Logger
}
func (s LoggingStore) Get(id string) (User, error) {
s.log.Info("get", "id", id)
return s.Storage.Get(id)
}
// 2) Расширяем чужой тип
type Request struct {
*http.Request
TraceID string
}
// 3) Общие поля моделей
type Base struct {
ID int64
CreatedAt time.Time
}
type User struct {
Base
Email string
}
-
Встроенный интерфейс = nil-поле.
struct{ io.Reader }формально реализуетio.Reader, но при вызове упадёт nil pointer dereference, если поле не заполнили. -
Встроенный
sync.MutexделаетLock/Unlockпубличными методами твоего типа, и снаружи смогут заблокировать твой объект. Лучше именованное полеmu sync.Mutex. -
JSON. Встроенная структура «разворачивается» в
плоский объект, а встроенная с тегом
json:"base"— вкладывается. Часто удивляет. -
Конфликт имён с
String()у двух встроенных типов ломаетfmtнеочевидным образом.
(*_type, data) —
eface; у непустого (*itab, data) —
iface, где itab содержит таблицу методов
для пары «интерфейс + конкретный тип».
type eface struct {
_type *_type
data unsafe.Pointer
}
type iface struct {
tab *itab
data unsafe.Pointer
}
type itab struct {
inter *interfacetype // какой интерфейс
_type *_type // какой конкретный тип
hash uint32 // копия _type.hash, ускоряет type switch
fun [1]uintptr // переменной длины: адреса методов
}
Как происходит вызов
- Берём
i.tab. -
Берём
tab.fun[k], гдеk— индекс метода, известный на этапе компиляции (методы вitabотсортированы по имени). -
Вызываем по этому адресу, передав
i.dataкак receiver.
Это косвенный вызов: инлайн невозможен, ветвление предсказывается хуже. Отсюда правило «интерфейс в горячем цикле стоит денег». Devirtualization в компиляторе Go есть, но работает только когда конкретный тип очевиден статически.
Откуда берётся itab
Для статически известной пары (интерфейс, тип)
itab создаёт компилятор, и таблица лежит прямо в
бинаре. Для динамических случаев (type assertion к
интерфейсу) он создаётся в рантайме и кэшируется в
глобальной хеш-таблице: по одному на пару, навсегда.
Что с данными
data — всегда указатель. Поэтому, когда
значение упаковывают в интерфейс, его обычно приходится
уводить в кучу (escape). Исключения:
-
значение само по себе указатель (
*T, map, chan, func) — кладётся напрямую; -
маленькие целые 0..255 берутся из
runtime.staticuint64s; nil-интерфейс — оба слова нулевые.
var x any = 42 // константа: лежит в read-only данных, аллокации нет
var y any = &s // указатель кладётся в data напрямую, без аллокации
var z any // eface{nil, nil}
fmt.Println(unsafe.Sizeof(x)) // 16 — два слова
«Из-за того, что data — указатель, интерфейс не
может хранить значение инлайн. До Go 1.4 существовала
оптимизация direct interface для любых однословных
значений, но она усложняла GC, и её оставили только для
типов, которые сами по себе указатель. Это же объясняет,
почему fmt.Println(i) для int
показывает аллокацию в бенчмарке, а для *T —
нет.»
nil, только если
обе половинки нулевые. При возврате
(*MyError)(nil) тип известен, поэтому
tab != nil — и err != nil.
type MyError struct {
Code int
}
func (e *MyError) Error() string { return "boom" }
func do() error {
var e *MyError // e == nil
return e // упаковали (*MyError)(nil) в интерфейс error
}
err := do()
fmt.Println(err == nil) // false
fmt.Println(err) // boom — Error() спокойно отработал на nil-получателе,
// потому что не трогает поля e. Если бы трогал (e.Code),
// он бы паниковал, fmt перехватил бы панику и напечатал <nil>
fmt.Printf("%T\n", err) // *main.MyError
if err != nil { /* сюда попадём */ }
В интерфейсе лежит {tab: itab(error, *MyError), data: nil}. Сравнение err == nil проверяет оба
слова, и первое непустое. Печатается <nil>, потому что Error()
на nil-получателе паникует, а fmt ловит эту панику и,
увидев нулевой указатель, подставляет <nil>.
Отсюда фирменный симптом: в логе написано nil, а ветка
ошибки выполняется.
Три источника в реальном коде
-
Функция возвращает конкретный тип ошибки и
результат присваивают в
error:func parse() *ParseError→var err error = parse(). -
Именованное возвращаемое значение конкретного типа
плюс
defer, который его трогает. - Поле-интерфейс в структуре, куда кладут указатель, который может быть nil (частый случай в конфигурациях и опциях).
Как правильно
// 1) Возвращать литеральный nil на успешном пути
func do() error {
if bad { return &MyError{500} }
return nil
}
// 2) Если переменная нужна, проверить перед возвратом
func do2() error {
var e *MyError
if bad { e = &MyError{500} }
if e == nil { return nil }
return e
}
// 3) Не возвращать из функций конкретный тип ошибки
// func parse() (*Result, *ParseError) // плохо
func parse() (*Result, error) // хорошо
-
«А если добавить в метод
Error()проверку на nil?» — не поможет: проблема не в вызове метода, а в сравнении интерфейса с nil. Метод вообще может не вызываться. -
«А
errors.Is(err, nil)?» — вернёт false по той же причине. -
«Как поймать?» —
go vet(анализnilness) иstaticcheckловят часть случаев; надёжнее договориться никогда не возвращать конкретный тип ошибки. -
Это касается не одного error: то же самое с
любым интерфейсом, включая
io.Readerиcontext.Context.
implements — тип
реализует интерфейс, если совпал method set. Проверить явно
можно строкой var _ I = (*T)(nil), которая
ничего не стоит в рантайме.
Что даёт неявная реализация
-
Интерфейс можно написать после реализации, в том
числе для чужого пакета, который ты не можешь менять.
*os.Fileреализует твойtype Syncer interface{ Sync() error }, ничего об этом не зная. -
Реализация не зависит от абстракции. Пакет
postgresне импортирует пакетservice, где объявлен интерфейс. Это инверсия зависимостей без boilerplate. - Интерфейсы получаются маленькими, потому что их пишет потребитель под свою нужду.
- Нет циклических импортов вида «абстракция ↔ реализация».
Цена
- Опечатка в сигнатуре не даёт понятной ошибки «не реализует» рядом с типом. Всплывёт она в месте присваивания, иногда в другом пакете.
-
Совпадение имён случайно: тип может «реализовать»
интерфейс, не имея такого намерения (в Go это встречается
редко, но, например, метод
String()нечаянно делает типfmt.Stringerи меняет вывод в логах). -
Инструментам сложнее показать «кто реализует этот
интерфейс», хотя
goplsумеет.
Проверка на этапе компиляции
var _ io.Writer = (*MyWriter)(nil) // указатель реализует
var _ error = (*MyError)(nil)
var _ Storage = (*PostgresStore)(nil)
var _ fmt.Stringer = Weekday(0) // для value receiver — значение
var _ sort.Interface = ByAge(nil) // для слайс-типа
_ — пустой идентификатор, значение никуда не
сохраняется и в бинарь не попадает. (*T)(nil) —
типизированный nil, аллокации нет. Работает только проверка
типов. Сообщение при ошибке максимально конкретное:
cannot use (*MyWriter)(nil) as io.Writer value in variable declaration:
*MyWriter does not implement io.Writer (missing method Write)
Ставь такую строку рядом с объявлением типа, в том же
файле, для всех интерфейсов, которые тип обязан выполнять.
Это дешёвая документация и защита от рефакторинга: без неё
удалишь метод, свой пакет соберётся, а сломается у
пользователей. Второй частый приём: var _ = (*T).Method проверяет сигнатуру конкретного метода.
any — алиас для
interface{} (Go 1.18), его реализует любой тип,
потому что требований ноль. Он выключает статическую
типизацию, поэтому уместен только на границах, где типы
принципиально неизвестны.
Где оправдан
-
Форматирование и логирование:
fmt.Println(a ...any),slog.Info(msg, args ...any). -
Сериализация:
json.Unmarshal(data, v any)— тип известен только вызывающему. -
Хранилища общего назначения:
sync.Map,context.WithValue. - Плагины и динамические данные: разбор конфигов неизвестной схемы.
Чем плох
-
Нет проверки типов. Ошибка переезжает из
компиляции в рантайм: паника при assertion или тихо
неверная ветка
switch. - Аллокации. Упаковка значения в интерфейс обычно уводит его в кучу.
-
Читаемость. Сигнатура
func Process(v any) anyне говорит ничего. -
Заражает код. Один
anyв глубине заставляет писать assertion'ы на всех уровнях выше. - Ключи мап и сравнения начинают паниковать на не-comparable типах.
Чем заменить
// Было (до 1.18):
func Max(a, b any) any { /* type switch, паники, боль */ }
// Стало — дженерики:
func Max[T cmp.Ordered](a, b T) T { if a > b { return a }; return b }
// Было: контейнер на any
type Stack struct {
items []any
}
// Стало:
type Stack[T any] struct {
items []T
}
// Было: фиксированный набор вариантов через any
func Handle(v any) { switch v.(type) { case A: ; case B: } }
// Стало: интерфейс с методом — полиморфизм вместо switch
type Handler interface{ Handle() }
-
anyиinterface{}— буквально один тип (type any = interface{}, алиас через=), а не два разных. Заменяются друг на друга без конверсий. -
С Go 1.18
anyработает и как констрейнт в дженериках, то есть «любой тип», в отличие отcomparable. -
[]any— не то же самое, что[]int: слайсы не ковариантны, конверсия требует поэлементного копирования с упаковкой.
i.(T) — извлечение конкретного
типа из интерфейса в рантайме. Односложная форма паникует
при несовпадении, двухсложная (comma-ok) — нет.
switch v := i.(type) — ветвление по
динамическому типу.
var i any = "привет"
s := i.(string) // "привет"
// n := i.(int) // panic: interface conversion: interface {} is string, not int
s, ok := i.(string) // "привет", true — безопасно
n, ok := i.(int) // 0, false — паники нет
// Assertion к интерфейсу проверяет, реализует ли динамический тип ещё и его
if st, ok := i.(fmt.Stringer); ok { fmt.Println(st.String()) }
// ok == false: у string нет метода String(), ничего не напечатается
if rc, ok := r.(io.ReadCloser); ok { defer rc.Close() }
Type switch
switch v := i.(type) {
case nil:
// только настоящий nil-интерфейс, typed nil сюда не попадёт
case int:
fmt.Println(v + 1) // v имеет тип int
case string:
fmt.Println(len(v)) // v имеет тип string
case []byte, [][]byte:
fmt.Println(v) // а тут v имеет тип any
case error:
fmt.Println(v.Error()) // можно матчить и на интерфейсы
default:
fmt.Printf("%T\n", v)
}
Что важно знать
-
Assertion работает
только на интерфейсных значениях.
x.(T)для конкретного типа — ошибка компиляции. Это не то же самое, что конверсияT(x). -
Порядок case важен, если среди них есть интерфейсы:
первый подходящий выигрывает.
case errorпередcase *MyErrorперехватит всё. -
Несколько типов в одном case —
vостаётся интерфейсом. -
switch i.(type)без присваивания тоже валиден, если значение не нужно. -
Стоимость: сравнение указателей на
_typeплюс, для assertion к интерфейсу, поиск и созданиеitab. Дёшево, но не бесплатно: в горячем цикле от assertion лучше избавиться архитектурно.
Где применяется в реальном коде
// 1) Опциональные интерфейсы, идиома стандартной библиотеки
func Copy(dst io.Writer, src io.Reader) (int64, error) {
if wt, ok := src.(io.WriterTo); ok { return wt.WriteTo(dst) } // быстрый путь
if rf, ok := dst.(io.ReaderFrom); ok { return rf.ReadFrom(src) }
// медленный путь через буфер
}
// 2) Разбор ошибок, но лучше errors.As
var pathErr *fs.PathError
if errors.As(err, &pathErr) { fmt.Println(pathErr.Path) } // умеет разворачивать цепочку
if pe, ok := err.(*fs.PathError); ok { } // не умеет — только верхний уровень
// 3) Разбор произвольного JSON
var data any
json.Unmarshal(b, &data)
m := data.(map[string]any) // числа будут float64
n := m["count"].(float64)
json.Unmarshal в any кладёт все
числа как float64, а объекты как
map[string]any. Assertion к int
паникует. Лечится либо json.Number через
UseNumber(), либо нормальной типизированной
структурой. Второе: err.(*MyError) вместо
errors.As не пробивает обёртки
fmt.Errorf("%w"), и проверка молча перестаёт
работать после первого же оборачивания.
T = только
value-методы; method set *T = value + pointer.
Причина: из указателя всегда можно получить значение, а из
значения указатель — только если оно адресуемо, а
копия внутри интерфейса не адресуема.
type T struct {
n int
}
func (t T) Val() {}
func (t *T) Ptr() {}
// method set T = { Val }
// method set *T = { Val, Ptr }
type I interface{ Ptr() }
var t T
t.Ptr() // компилируется: сахар для (&t).Ptr(), ведь t адресуема
// var i I = t // ошибка: T does not implement I (method Ptr has pointer receiver)
var i I = &t // ОК
Почему запрет именно на интерфейс, а не на вызов
Вызов t.Ptr() компилятор разрешает, потому что
видит переменную и может взять её адрес. А когда значение
кладут в интерфейс, оно копируется в кучу, и эта
копия не имеет стабильного имени. Разреши Go такое, метод с
pointer receiver менял бы копию внутри интерфейса, а не
исходную переменную, и мутация терялась бы молча. Go
предпочёл ошибку компиляции.
Где ещё срабатывает адресуемость
| Выражение | Адресуемо? | Можно вызвать pointer-метод? |
|---|---|---|
переменная t |
да | да |
поле структуры s.f (если
s адресуема)
|
да | да |
элемент слайса sl[0] |
да | да |
элемент массива-переменной arr[0]
|
да | да |
элемент мапы m["k"]
|
нет | нет |
результат функции f() |
нет | нет |
| значение внутри интерфейса | нет | нет |
константа, литерал T{} |
нет | нет (но (&T{}).Ptr() можно) |
m := map[string]T{"a": {}}
// m["a"].Ptr() // ошибка: cannot call pointer method Ptr on T
v := m["a"]; v.Ptr() // так — через переменную
sl := []T{{}}
sl[0].Ptr() // ОК: элемент слайса адресуем
// Обратное направление работает всегда:
p := &T{}
p.Val() // сахар для (*p).Val()
«Method set — это про удовлетворение интерфейса, а не про
возможность вызвать метод. Вызвать pointer-метод у
адресуемого значения можно всегда — компилятор сам
подставит
&. А вот положить значение в
интерфейс, требующий pointer-метод, нельзя, потому что
копия внутри интерфейса не адресуема и мутация потерялась
бы.»
Pointer receiver (t *T) |
Value receiver (t T) |
|---|---|
| метод изменяет поля | метод только читает |
| структура большая (копия дорога) |
тип маленький: time.Time,
Point, число
|
внутри sync.Mutex,
sync.WaitGroup,
strings.Builder
|
тип — уже дескриптор: слайс, мапа, канал, функция |
| надо отличать nil-получателя | нужна гарантия неизменяемости |
| хоть один метод типа уже с указателем | все методы типа уже value |
type Counter struct {
n int
}
func (c Counter) IncBad() { c.n++ } // меняет копию, инкремент теряется
func (c *Counter) Inc() { c.n++ } // правильно
c := Counter{}
c.IncBad(); fmt.Println(c.n) // 0
c.Inc(); fmt.Println(c.n) // 1
// Мьютекс + value receiver = тихий баг: каждый вызов блокирует свою копию
type Bad struct {
mu sync.Mutex
n int
}
func (b Bad) Lock() { b.mu.Lock() } // go vet: passes lock by value
Аргументы «за value», которые часто забывают
- Копия иногда дешевле, чем разыменование. Для структуры из одного-двух слов копия остаётся в регистрах, а указатель добавляет косвенность и может увести объект в кучу (escape analysis).
- Безопасность. Value receiver гарантирует, что метод не изменит получателя. Для конкурентного кода это ценно само по себе.
-
Method set шире. С value receiver и
T, и*Tудовлетворяют интерфейсу; с pointer receiver — только*T.
Про «копия дорогая» — где граница
Практический порог обычно называют в районе нескольких
машинных слов (примерно до 4–5 полей скалярных типов). Но
реальный ответ — померить: escape analysis может
свести разницу к нулю или, наоборот, указатель заставит
объект уехать в кучу и добавит работы GC. Смотри через
go build -gcflags='-m' и бенчмарк.
Консистентность важнее микрооптимизации. Если
хотя бы одному методу нужен указатель, делай указателями
все методы типа. Иначе method set T окажется
неполным, тип перестанет удовлетворять собственным
интерфейсам, а ошибка вылезет далеко от объявления.
Проверяется линтерами (staticcheck,
recvcheck в golangci-lint). И go vet
ловит копирование мьютексов анализатором
copylocks.
package store
type User struct {
ID int // экспортировано
name string // нет — видно только внутри пакета store
}
func New() *User { return &User{} } // экспортировано
func (u *User) Name() string{ return u.name } // геттер
func (u *User) validate() error { return nil } // приватный метод
// Внутри пакета видно всё, включая приватные поля чужих типов этого же пакета:
func eq(a, b User) bool { return a.name == b.name } // ок
Что важно знать
- Гранулярность — пакет, а не файл и не тип. Два файла одного пакета видят приватные идентификаторы друг друга.
-
Рефлексия видит только экспортированные поля.
json.Marshalмолча пропуститname,Unmarshalне заполнит.reflect.Value.Field(i).Interface()для приватного поля паникует. -
Регистр смотрится по первой руне, и это Unicode:
Имя— экспортировано (заглавная кириллическая),_xиимя— нет. - Приватный тип с публичными методами — законно и полезно: функция возвращает интерфейс, конкретный тип наружу не виден.
-
internal/— второй механизм: пакет внутриa/b/internal/cимпортируется только из поддереваa/b. Экспортированные имена там всё равно недоступны снаружи.
Идиомы вокруг видимости
// 1) Приватное поле + функциональные опции вместо публичных полей
type Server struct {
timeout time.Duration
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server){ s.timeout = d } }
func NewServer(opts ...Option) *Server { /* ... */ }
// 2) Приватный тип, публичный интерфейс
type storage struct {
db *sql.DB
}
func NewStorage(db *sql.DB) Storage { return &storage{db} } // тип наружу не виден
// 3) Запрет на литерал без имён полей — приватное поле-маркер: новые поля не сломают вызывающих
type Config struct {
Name string
_ struct{} // литерал Config{"x"} без имён полей не скомпилируется вне пакета
}
Всё экспортированное становится частью публичного API и попадает под гарантии совместимости (для стандартной библиотеки — Go 1 compatibility promise). Отсюда практика: экспортируй минимум, начинай с приватного и открывай по запросу. Спрятать публичное обратно уже не выйдет тихо: это ломающее изменение, оно требует новой major-версии модуля.
Почему принимать интерфейс
-
Функция объявляет минимум требований:
io.Writerвместо*os.File. В тесте подставишьbytes.Buffer, в проде туда пойдёт файл, сокет или gzip-обёртка. - Пропадает зависимость от конкретного пакета, а значит, легче ломать циклы импортов.
- Интерфейс документирует контракт: видно, что именно функции нужно.
Почему возвращать структуру
- Вызывающий видит все методы и поля и сам решает, какая абстракция ему нужна. Вернув интерфейс, ты навязываешь свой выбор и скрываешь остальное.
- Новый метод у конкретного типа ничего не ломает. Новый метод в интерфейсе ломает все реализации.
-
Возврат интерфейса — прямой путь к typed nil, если
внутри вернуть
(*T)(nil). - Конкретный тип позволяет компилятору инлайнить и девиртуализировать вызовы.
// Хорошо
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
func Save(w io.Writer, u User) error { return json.NewEncoder(w).Encode(u) }
// Плохо
func NewStore2(db *sql.DB) Storage { return &Store{db: db} } // скрыли тип
func Save2(f *os.File, u User) error { } // прибили к файлу
Когда правило нарушают осознанно
-
Фабрика с несколькими реализациями:
func NewCache(cfg) Cacheвозвращает то Redis, то in-memory, так что тип известен только в рантайме. - Конкретный тип принципиально приватный — тогда наружу отдают интерфейс.
-
Стандартная библиотека так делает:
net.Listenотдаётnet.Listener,sql.DB.Begin—*sql.Tx(тут как раз структура). То есть даже stdlib выбирает по ситуации.
Пакет объявляет интерфейс Storage с 15 методами
рядом со своей единственной реализацией и
возвращает его из конструктора «чтобы можно было
замокать». В итоге: интерфейс дублирует тип один-в-один,
мок на 15 методов, вызывающий не видит новых методов, а
тестируемость не выросла. Правильно так: интерфейс на 1–3
метода в пакете-потребителе, а конструктор возвращает
*Store.
// package service — потребитель объявляет ровно то, что нужно ему
type UserGetter interface {
GetUser(ctx context.Context, id int64) (User, error)
}
type Service struct {
users UserGetter
}
func New(u UserGetter) *Service { return &Service{users: u} }
// package postgres — про service не знает вообще
type Store struct {
db *sql.DB
}
func (s *Store) GetUser(ctx context.Context, id int64) (User, error) { /* ... */ }
func (s *Store) SaveUser(...) error { /* ... */ }
// main.go — единственное место, где они встречаются
svc := service.New(postgres.New(db))
Что это даёт
- Правильное направление зависимостей. Доменный пакет не импортирует пакет с инфраструктурой. Это Dependency Inversion Principle, только бесплатный: реализация-то структурная.
- Маленькие интерфейсы. Потребителю нужен один метод, он и объявит интерфейс с одним методом. «The bigger the interface, the weaker the abstraction» (Роб Пайк).
- Простые моки. Мок на один метод пишется руками за минуту; мок на 20-методовый интерфейс генерируют и никто не читает.
- Нет циклических импортов между слоем абстракции и слоем реализации.
-
Реализацию можно свободно расширять: новый метод у
*Storeничего не ломает, потому что интерфейсы объявлены отдельно.
Когда интерфейс всё-таки живёт у реализации
-
Контракт для внешнего мира:
io.Reader,error,http.Handler,sort.Interface— это интерфейсы, которые реализуют чужие типы, и объявить их у каждого потребителя было бы абсурдом. - Плагинная архитектура: пакет определяет точку расширения, и десятки внешних реализаций подключаются к ней.
- Несколько реализаций внутри одного пакета, между которыми переключаются конфигом.
«Интерфейс — это потребность потребителя, а не свойство
реализации. Поэтому в Go его объявляют там, где
используют. Технически это возможно из-за структурной
типизации: реализации не надо объявлять, что она что-то
реализует. Исключение — интерфейсы, которые задуманы как
точка расширения для чужого кода: io.Reader,
http.Handler, error.»
panic: comparing uncomparable type.
var a any = 1
var b any = 1
var c any = int64(1)
fmt.Println(a == b) // true
fmt.Println(a == c) // false — типы разные, значения даже не сравниваются
var s1 any = []int{1}
var s2 any = []int{1}
// s1 == s2 // panic: runtime error: comparing uncomparable type []int
type P struct {
X int
}
type B struct {
S []int
}
var p1, p2 any = P{1}, P{1}
fmt.Println(p1 == p2) // true — структура comparable
var b1, b2 any = B{}, B{}
// b1 == b2 // panic: runtime error: comparing uncomparable type main.B
Почему компилятор это не ловит
Потому что статически он видит только интерфейсный тип. Что
окажется внутри, выяснится в рантайме. Сравнение двух
конкретных слайсов (s1 == s2 для []int)
компилятор запрещает сразу, а сравнение интерфейсов
запретить не может.
Где всплывает неожиданно
-
map[any]V— при вставке ключа с не-comparable типом:hash of unhashable type. -
errors.Is— внутри сравнивает через==. Ошибка-структура со слайсом внутри уронит проверку. Поэтому кастомные ошибки делают указателями или реализуют методIs(error) bool. -
Сравнение в тестах:
if got != wantдляany. Нуженreflect.DeepEqualилиgo-cmp. -
slices.Containsдля[]any,sync.Mapс такими ключами.
Как сравнивать безопасно
// 1) Сначала проверить сравнимость
if reflect.TypeOf(a).Comparable() { _ = a == b }
// 2) Глубокое сравнение
reflect.DeepEqual(a, b) // медленно, но не паникует
cmp.Equal(a, b) // go-cmp: стандарт в тестах, diff покажет cmp.Diff
// 3) В дженериках: констрейнт comparable
func Index[T comparable](s []T, v T) int { /* компилятор проверит статически */ }
До Go 1.20 any не удовлетворял
констрейнту comparable именно потому, что
сравнение интерфейсов может паниковать. В 1.20 правило
смягчили: интерфейсные типы теперь удовлетворяют
comparable, а за возможную панику отвечает
разработчик. Это позволило писать
map[K]V-подобные дженерик-контейнеры с K = any. Деталь мелкая, но по ней видно, что ты следишь
за языком.
reflect.TypeOf достаёт
дескриптор типа, reflect.ValueOf — пару «тип +
данные». Платишь производительностью и потерей статических
проверок.
Три закона рефлексии
-
Из интерфейсного значения можно получить
reflect.Value. -
Из
reflect.Valueможно вернуться в интерфейс —.Interface(). -
Чтобы менять значение, оно должно быть
settable: получено через указатель
(
reflect.ValueOf(&x).Elem()) и относиться к экспортированному полю.
type User struct {
ID int `json:"id" db:"user_id"`
Name string `json:"name"`
age int // приватное: рефлексия видит, но не может ни прочитать через
} // Interface(), ни записать
u := User{1, "Аня", 30}
t, v := reflect.TypeOf(u), reflect.ValueOf(u)
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fmt.Println(f.Name, f.Type, f.Tag.Get("db"), f.IsExported())
}
// ID int user_id true
// Name string true
// age int false
p := reflect.ValueOf(&u).Elem()
p.FieldByName("Name").SetString("Боря") // ок — settable и экспортировано, u == {1 Боря 30}
// v.FieldByName("Name").SetString("X")
// panic: reflect: reflect.Value.SetString using unaddressable value
// p.FieldByName("age").SetInt(1)
// panic: reflect: reflect.Value.SetInt using value obtained using unexported field
Цена
| Что | Порядок | Почему |
|---|---|---|
| прямой доступ к полю | ~1 нс | смещение известно на компиляции |
v.Field(i) |
десятки нс | проверки, вычисление смещения в рантайме |
v.Interface() |
+ аллокация | упаковка обратно в интерфейс |
json.Marshal |
сотни нс — мкс |
обход структуры + разбор тегов (кэшируется в
sync.Map)
|
| кодогенерация | в 3–10 раз быстрее | рефлексии нет вообще |
Где применяется
-
encoding/json,encoding/xml,gopkg.in/yaml— маршалинг по тегам структур. -
database/sql—Scanв произвольные указатели; ORM целиком. -
text/template,html/template— доступ к полям по имени. -
fmt—%v,%+v,%#v. -
sort.Slice—reflect.Swapper;reflect.DeepEqual; валидаторы, DI-контейнеры, мок-генераторы.
Когда не надо
В горячем пути и там, где типы известны. С Go 1.18 большую
часть задач, ради которых раньше брали рефлексию
(контейнеры, утилиты над коллекциями), закрывают дженерики:
статически и без накладных расходов. Если рефлексия нужна
ради сериализации и стала узким местом, переходят на
кодогенерацию: easyjson, protobuf,
sqlc, go generate.
Сразу приводи цитату Роба Пайка: «Clear is better than
clever. Reflection is never clear.» И добавляй конкретику:
рефлексия убирает проверки компилятора, ломает рефакторинг
(переименовал поле, тег остался старым, тесты зелёные),
мешает escape analysis и инлайну, увеличивает бинарь. Но
она же единственный способ написать
json.Marshal, который работает с любым типом.
Это инженерный компромисс, а не «плохо».
1.6Функции, defer, panic/recover
Тема, где почти каждый вопрос сводится к «что выведет код».
Проверяют три вещи: что захватывает замыкание, когда вычисляются
аргументы defer и как recover связан с
раскруткой стека. Всё это про момент времени, а не про
синтаксис.
Сначала — где живут вызовы и что значит «выход из функции»
Глава крутится вокруг одного вопроса: что произойдёт, когда
управление покинет функцию, обычным return-ом или
аварийно, через панику. Отсюда растут и задачи «что выведет код» с
defer, и правило «recover работает
только внутри отложенной функции», и споры про цену
defer. Чтобы не заучивать это списком, договоримся о
трёх словах.
1. Стек вызовов и фрейм
Функцию вызвали: под её аргументы, локальные переменные и адрес
возврата отводится участок памяти, фрейм (в русских текстах ещё «кадр стека»). Вызовы вложены
друг в друга, поэтому фреймы лежат стопкой: каждый новый вызов
кладёт свой фрейм сверху, выход из функции его снимает. Стопка и
называется стеком вызовов. Отсюда следствие: «выход из
функции» не расплывчатое «когда-нибудь потом», а один конкретный
момент, после которого всё, что лежало во фрейме, недействительно.
Вот в этот момент и вклинивается defer.
2. Раскрутка стека
Раскрутка стека: фреймы снимаются
со стопки один за другим. Не по одному return, а
подряд, от функции, где случилась беда, к её вызывающему, потом к
вызывающему вызывающего, и так далее. Так работает паника. Она не
«выбрасывается» куда-то вбок, а последовательно сворачивает стек,
выполняя по дороге отложенные вызовы каждого фрейма. Если её никто
не остановит, сворачивать станет нечего, и процесс умрёт.
3. Именованные результаты
Результату функции можно дать имя: func f() (err error) вместо func f() error. Такие результаты
зовут именованными возвращаемыми значениями. Имя в списке
результатов заводит во фрейме обычную переменную, и живёт она до
самого конца вызова. Разница не косметическая: defer
эту переменную читает и переписывает, а значит, меняет то, что
функция в итоге вернёт. На этом держатся три идиомы ниже.
Функции как объекты первого класса
Функция в Go ведёт себя как обычное значение: её можно присвоить
переменной, передать аргументом, вернуть из другой функции,
положить в структуру, слайс или мапу. Тип функции задаёт её
сигнатура: func(int, string) error. Нулевое значение
nil, и вызов nil-функции паникует.
// 1. Значение и тип
var fn func(int) int
fmt.Println(fn == nil) // true
// fn(1) // panic: runtime error: invalid memory address or nil pointer dereference
// // [signal SIGSEGV: segmentation violation]
fn = func(x int) int { return x * 2 }
// 2. Именованный тип функции: идиома стандартной библиотеки
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }
// теперь обычная функция реализует http.Handler:
var _ http.Handler = HandlerFunc(myFunc)
// 3. Функция высшего порядка: middleware
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("req", "path", r.URL.Path, "dur", time.Since(start))
})
}
// 4. Функциональные опции: так делают «конструктор с параметрами»
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server){ s.timeout = d } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second} // дефолты
for _, o := range opts { o(s) }
return s
}
srv := NewServer(":8080", WithTimeout(5*time.Second))
// 5. Функция в поле структуры: поведение подменяется в тестах без интерфейса
type Client struct {
doRequest func(*http.Request) (*http.Response, error)
}
v.Method, или method value: связанное с
получателем замыкание типа func(args).
Получатель захватывается в момент создания, и это
отдельная ловушка. T.Method, или method
expression: обычная функция типа func(T, args),
где получатель стал первым параметром.
type C struct {
n int
}
func (c C) Get() int { return c.n }
c := C{1}
f := c.Get // method value: копия c захвачена сейчас
c.n = 99
fmt.Println(f()) // 1, а не 99
g := C.Get // method expression: func(C) int
fmt.Println(g(c)) // 99
// С pointer receiver захватывается указатель, а не копия:
type D struct {
n int
}
func (d *D) Get() int { return d.n }
d := D{1}
h := d.Get // захвачен &d
d.n = 99
fmt.Println(h()) // 99
Замыкания: что именно захватывается
Замыкание: функция, объявленная внутри другой функции и использующая её локальные переменные. Название отсюда: она замыкает на себе кусочек чужой области видимости и уносит его с собой. Внешняя функция давно вернулась, а переменная жива, пока жива функция-замыкание.
Из одного факта выводятся все задачи на эту тему: замыкание захватывает переменную, а не её значение. То есть ссылку на ячейку памяти, а не снимок того, что в ней лежало. Переменная переживает функцию, где объявлена? Тогда компилятор при escape-анализе переносит её в кучу, а замыкание держит на неё указатель. Все замыкания, созданные в одной области видимости, делят одну и ту же ячейку.
func counter() func() int {
n := 0 // уедет в кучу: живёт дольше counter()
return func() int {
n++ // изменяем ту самую ячейку
return n
}
}
c1, c2 := counter(), counter()
fmt.Println(c1(), c1(), c1()) // 1 2 3 — одна ячейка
fmt.Println(c2()) // 1 — у второго замыкания своя ячейка
// Два замыкания из одной области делят одну ячейку
func pair() (func(), func() int) {
n := 0
return func(){ n++ }, func() int { return n }
}
inc, get := pair()
inc(); inc()
fmt.Println(get()) // 2
i объявлялась
один раз на весь цикл; с 1.22 компилятор создаёт новую
переменную на каждой итерации, и классическая ловушка исчезла.
// Классика собеседований
for i := 0; i < 3; i++ {
go func() { fmt.Print(i, " ") }()
}
time.Sleep(time.Second)
// Go < 1.22: "3 3 3" (обычно) — одна переменная на весь цикл
// Go >= 1.22: "0 1 2" в произвольном порядке — своя переменная на итерацию
// То же с range:
for _, v := range []int{1, 2, 3} {
go func() { fmt.Print(v, " ") }()
}
// Go < 1.22: "3 3 3"; Go >= 1.22: 1 2 3 в любом порядке
// Как писали до 1.22 (и как всё ещё пишут для совместимости):
for i := 0; i < 3; i++ {
i := i // затеняем: новая переменная на итерацию
go func() { fmt.Print(i) }()
}
for i := 0; i < 3; i++ {
go func(i int) { fmt.Print(i) }(i) // передаём копию параметром
}
// оба варианта печатают 0, 1 и 2 ровно по разу, но в произвольном
// порядке: четыре прогона подряд дали "201", "210", "201", "210"
Переменные, объявленные в for ... := ... и в
for ... := range ..., теперь живут одну
итерацию, а не весь цикл. Включает это строка go 1.22 в go.mod, то есть решает модуль,
а не версия компилятора: старый модуль соберётся по старым
правилам даже свежим тулчейном. Найти такие места в старом коде
помогает go vet -loopclosure (в 1.22+ анализатор
учитывает языковую версию модуля).
Отдельно: go тут был не единственным случаем. Ещё
defer внутри цикла и сохранение &v
в слайс. Все три закрыло одно изменение.
// Переменная объявлена ВНЕ цикла — семантика прежняя, ловушка осталась
x := 0
for i := 0; i < 3; i++ {
x = i
go func(){ fmt.Print(x) }() // все смотрят в один x
}
// четыре прогона подряд на go1.27 дали "222": горутины стартуют после цикла,
// когда x уже равен 2. Гарантии нет — но именно так это обычно и выглядит.
// Захват элемента слайса по указателю — тоже осталось
var ptrs []*int
for _, v := range []int{1,2,3} { ptrs = append(ptrs, &v) }
// в Go 1.22+ здесь как раз всё хорошо: три разных v.
// А вот так — по-прежнему одна ячейка:
v2 := 0
for _, x := range []int{1,2,3} { v2 = x; ptrs = append(ptrs, &v2) }
Вариативные функции
...T в последнем параметре означает: внутри функции
это обычный слайс []T. Компилятор на месте
вызова собирает переданные аргументы в новый слайс.
func Sum(nums ...int) int { // внутри nums имеет тип []int
total := 0
for _, n := range nums { total += n }
return total
}
Sum() // 0 — nums == nil (не пустой слайс, а именно nil)
Sum(1, 2, 3) // 6 — компилятор создал []int{1,2,3}
s := []int{1, 2, 3}
// Sum(s) // ошибка компиляции:
// // cannot use s (variable of type []int) as int value in argument to Sum
Sum(s...) // 6 — передали существующий слайс, новый не создаётся
// При s... слайс не копируется: функция получает тот же массив
func Zero(nums ...int) { for i := range nums { nums[i] = 0 } }
Zero(s...)
fmt.Println(s) // [0 0 0] — исходный слайс изменён
Zero(1, 2, 3) // а тут менялся бы временный слайс
// Смешивать нельзя:
// Sum(1, s...) // ошибка компиляции: too many arguments in call to Sum
// // have (number, []int...)
// Идиома: вариативный интерфейс
func Printf(format string, args ...any) // каждый аргумент упаковывается в any
Каждый вызов f(a, b, c) создаёт слайс. Если
аргументы не утекают, компилятор положит его на стек, и
аллокации не будет. Но для ...any (как у
fmt.Printf) каждый аргумент ещё и упаковывается в
интерфейс, а это почти всегда escape в кучу. Отсюда практика: в
горячем пути и в логгерах берут типизированные варианты,
slog с slog.Int("n", 1) вместо
...any, strconv.Itoa вместо
fmt.Sprintf("%d").
defer: механика
defer регистрирует вызов, а выполнится тот при
выходе из функции, любом: return, паника,
конец тела. Не при выходе из блока и не при выходе из цикла.
Порядок LIFO: последний
зарегистрированный defer сработает первым.
func demo() {
for i := 0; i < 3; i++ {
defer fmt.Print(i, " ") // аргумент вычислен сразу: 0, 1, 2
}
}
// вывод: 2 1 0 — LIFO
func demo2() {
i := 0
defer fmt.Println("аргумент:", i) // скопировано 0
defer func() { fmt.Println("замыкание:", i) }()// прочтёт при выходе
i = 42
}
// замыкание: 42
// аргумент: 0
// Метод тоже вычисляется сразу, вместе с получателем:
var b bytes.Buffer
defer fmt.Println(b.String()) // "" — String() вызовется сейчас
b.WriteString("текст")
// напечатает пустую строку
// А так правильно:
defer func(){ fmt.Println(b.String()) }()
defer срабатывает при выходе из функции, а
не итерации. Цикл на 100 000 файлов накопит 100 000 отложенных
Close(), и выполнятся они только в конце.
Дескрипторы кончатся раньше. Лечения два: вынести тело итерации
в отдельную функцию (тогда defer сработает на каждой) или
закрывать вручную, без defer.
// Плохо: 100k открытых файлов одновременно
func processAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // выполнится только в конце processAll
process(f)
}
return nil
}
// Хорошо, вариант 1: у замыкания своя область видимости
func processAll1(paths []string) error {
for _, p := range paths {
err := func() error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // выполнится на выходе из этой функции
return process(f)
}()
if err != nil { return err }
}
return nil
}
// Хорошо, вариант 2: именованная функция нагляднее
func processOne(p string) error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
return process(f)
}
defer и именованные возвращаемые значения
return x в Go разворачивается в две операции:
сначала присвоить x в возвращаемое значение, потом
выполнить defer-ы и только потом вернуть управление.
Если возвращаемое значение именованное, defer его
перепишет: он видит переменную.
// Без имени: defer меняет копию, эффекта нет
func a() int {
x := 1
defer func() { x = 99 }()
return x // 1
}
// С именем: defer меняет саму возвращаемую переменную
func b() (x int) {
x = 1
defer func() { x = 99 }()
return x // 99
}
// То же с ошибкой, так чаще всего и делают
func doTx(db *sql.DB) (err error) {
tx, err := db.Begin()
if err != nil { return err }
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // пробрасываем дальше
} else if err != nil {
tx.Rollback() // видим ошибку, потому что err именованная
} else {
err = tx.Commit() // и можем вернуть ошибку коммита
}
}()
// ... работа ...
return nil
}
// Добавляем к ошибке контекст
func readConfig(path string) (err error) {
defer func() {
if err != nil { err = fmt.Errorf("readConfig(%s): %w", path, err) }
}()
// ... любые return err без обёртки ...
}
// На границе пакета паника становится ошибкой
func Parse(s string) (res AST, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse: %v", r)
}
}()
return mustParse(s), nil // внутри может паниковать
}
«return x не атомарен. Компилятор разворачивает его
в ret = x; выполнить defer'ы; вернуть ret.
Именованное возвращаемое значение и есть тот самый
ret, обычная переменная, поэтому defer её читает и
переписывает. Три главных применения: rollback транзакции,
обогащение ошибки контекстом и конверсия паники в error на
границе пакета.»
Сколько стоит defer
Вопрос «дорогой ли defer» проверяет, читал ли ты
release notes. Правильный ответ: раньше был дорогим, с Go 1.14 практически бесплатен в типичном
случае. Стоимость менялась три раза, и это хорошая история для
собеса.
Три слова из таблицы ниже расшифруем сразу. _defer:
служебная запись, которую рантайм заводит на каждый отложенный
вызов, там лежит что вызвать, с какими аргументами и кто следующий
в списке. Инлайнить: подставлять нужные
инструкции прямо в место вызова вместо настоящего вызова функции,
обычная оптимизация компилятора. Open-coded defer, дословно
«defer, раскрытый в код»: компилятор заранее знает, какие
отложенные вызовы есть в функции, и вписывает их перед каждым
return под проверку одного бита. Ни записей, ни
списка, ни обхода при выходе.
| Версия | Как реализовано | Порядок стоимости | Что происходит |
|---|---|---|---|
| до Go 1.12 | Запись _defer в куче |
~50 нс |
Каждый defer — аллокация структуры и вставка
в связный список горутины
|
| Go 1.13 | _defer на стеке |
~35 нс | Запись кладётся в стековый фрейм — аллокации в куче нет, список остался |
| Go 1.14+ | Open-coded defers | ~1 нс, почти ноль |
Компилятор инлайнит вызов прямо перед каждым
return. Списка нет вообще
|
defer перестал быть заметной статьёй расхода.
Компилятор откатывается к записям на стеке, если: defer стоит
в цикле (число вызовов неизвестно на этапе компиляции), в
функции больше 8 defer-ов, функция компилируется с отключёнными оптимизациями
(-gcflags=-N) или с -race, либо число
return, умноженное на число defer-ов, больше 15. Отсюда
практический вывод: defer в цикле плох дважды. Семантикой, то
есть накоплением до выхода из функции, и выключенной
оптимизацией.
// Замер: go test -bench=. -benchmem
func BenchmarkDirect(b *testing.B) {
for b.Loop() { // Go 1.24+: так теперь пишут бенчмарки
mu.Lock()
counter++
mu.Unlock()
}
}
func BenchmarkDefer(b *testing.B) {
for b.Loop() {
func() {
mu.Lock()
defer mu.Unlock() // open-coded: разница в единицы наносекунд
counter++
}()
}
}
Не убирай defer ради «производительности» без
профиля. Читаемость и гарантия, что ресурс освободится при любом
выходе, включая панику, стоят дороже наносекунды. Единственный
реальный повод отказаться: горячий цикл с миллионами итераций,
где профиль прямо показывает runtime.deferreturn.
panic и recover: механика
panic(v) это не «исключение» в смысле Java/C++.
Никакого catch-блока нет, вмешаться можно ровно одним способом:
recover() внутри отложенной функции. Механика по
шагам:
-
Рантайм создаёт запись
_panicи кладёт её в цепочку паник текущей горутины. - Текущая функция обрывается прямо в точке паники.
- Начинается раскрутка стека: фреймы снимаются со стопки один за другим, от того, где случилась паника, к вызывающим, и в каждом выполняются его отложенные функции в порядке LIFO.
-
Если какая-то из них вызывает
recover()и та возвращает неnil, паника погашена. Функция, которой принадлежал этот defer, завершается нормально и отдаёт свои возвращаемые значения, те, что лежат в них на этот момент. - Никто не сделал recover: раскрутка доходит до вершины стека горутины, рантайм печатает сообщение и стектрейс в stderr и убивает весь процесс с кодом выхода 2.
recover() в отложенной функции
гасит её, и владелец этого defer возвращается штатно.
recover(), вызванный не из defer, всегда возвращает
nil. Вызванный из функции, которую позвала
отложенная функция, тоже nil. Рантайм сверяет, что
кадр вызывающего именно тот, который сейчас выполняется как
defer текущей паники. Поэтому обёртка defer myRecover(), где внутри myRecover стоит
recover(), работает (она сама отложенная), а
defer func(){ helper() }() с recover внутри
helper нет.
// 1) Не работает: recover не в отложенной функции
func bad1() {
if r := recover(); r != nil { /* всегда nil */ }
panic("boom")
}
// 2) Не работает: recover на уровень глубже, чем defer
func helper() { recover() } // nil
func bad2() {
defer func() { helper() }() // helper — не отложенная функция
panic("boom")
}
// 3) Работает: recover прямо в теле отложенной функции
func good1() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
panic("boom")
}
// 4) Работает: сама именованная функция отложена
func catch(err *error) {
if r := recover(); r != nil { *err = fmt.Errorf("recovered: %v", r) }
}
func good2() (err error) {
defer catch(&err) // catch и есть отложенная функция
panic("boom")
return nil
}
Что recover НЕ ловит
Часть аварий в Go это вообще не panic, а fatal error рантайма. Их не перехватить ничем, процесс умирает сразу и без раскрутки стека:
-
fatal error: concurrent map writes/concurrent map read and map write: гонка на мапе, пойманная встроенной проверкой. -
fatal error: all goroutines are asleep - deadlock!: рантайм видит, что работать некому. -
fatal error: stack overflow: стек упёрся в лимит (по умолчанию 1 ГБ на 64-битных). -
fatal error: out of memory, ошибки в самом рантайме, сбой в CGO-коде. -
os.Exit()вообще не паника, defer-ы не выполняются. -
runtime.Goexit()завершает горутину и выполняет defer-ы, ноrecover()вернётnil: паники нет.
«panic это управляемое рантаймом сворачивание горутины: defer-ы выполняются, recover возможен. fatal error это отказ рантайма: ни defer-ов, ни recover. Гонка на мапе, дедлок и переполнение стека относятся ко второму, поэтому "у нас всё обёрнуто в recover, сервис не упадёт" неверно.»
Когда panic уместен, а когда нужен error
panic оправдан
-
Ошибка программиста, а не среды: nil там, где по
контракту не может быть nil; индекс за пределом; невозможная
ветка
switch, где уместенpanic("unreachable"). -
Инициализация:
regexp.MustCompile,template.Must,sql.Registerс дублем драйвера. Программа с битой конфигурацией не должна стартовать. - Нарушение инварианта, после которого продолжать опаснее, чем упасть (повреждённая структура данных, рассогласованный кэш).
-
Внутренний механизм внутри одного пакета: быстрый
выход из глубокой рекурсии, если паника гарантированно
ловится на границе пакета (так делает
encoding/json,text/template).
panic недопустим
-
Ожидаемые ошибки: файл не найден, сеть недоступна,
невалидный ввод пользователя, строка не парсится в число.
Это
error. - Как замена возврата ошибки в публичном API библиотеки: вызывающий не обязан оборачивать твои функции в recover.
- Как поток управления, чтобы «выйти из трёх циклов сразу». Для этого есть метки.
-
Через границу пакета: если внутри используешь панику
как механизм, обязан перехватить её на экспортируемой
функции и вернуть
error.
// Паттерн encoding/json: паника внутри, error снаружи
type parseError struct {
err error
}
func (p *parser) fail(format string, a ...any) {
panic(parseError{fmt.Errorf(format, a...)}) // свой тип, чтобы отличать чужие паники
}
func Parse(data []byte) (res Doc, err error) {
defer func() {
if r := recover(); r != nil {
pe, ok := r.(parseError)
if !ok { panic(r) } // чужую панику пробрасываем: она про баг, а не про ввод
err = pe.err
}
}()
p := &parser{data: data}
return p.parseDoc(), nil // внутри вызывается fail() на любой глубине
}
Пойманную панику всегда проверяй по типу и чужие
пробрасывай дальше. Глухой recover(), который молча съедает
любую панику, прячет настоящие баги: nil pointer
dereference превратится в невнятное «что-то пошло не так», и
причину искать будет нечем. И всегда логируй
debug.Stack(), иначе трейс потерян навсегда.
Паника в горутине
Самый частый вопрос по теме и самая частая ошибка в проде. Ответ короткий: recover ловит панику только в своей горутине. Стек у каждой горутины свой, раскрутка идёт по нему и до фреймов «родителя» не доходит. Родителя в стековом смысле вообще нет, горутины не вложены.
// Не работает: процесс упадёт
func main() {
defer func() {
if r := recover(); r != nil { fmt.Println("поймали:", r) } // не выполнится
}()
go func() { panic("из горутины") }()
time.Sleep(time.Second)
}
// panic: из горутины ... exit status 2
// Работает: recover внутри той же горутины
func safeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
// Если панику нужно обработать наверху, её передают в канал
func run(fn func() error) (err error) {
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic: %v", r)
}
}()
done <- fn()
}()
return <-done
}
Три подвоха. Первый: net/http действительно
перехватывает панику хендлера, но только в той горутине, где
выполняется хендлер; запустил хендлер go doWork(), и паника оттуда убьёт сервер целиком. Второй:
errgroup.Group ошибки собирает, а паники не
перехватывает, они убивают процесс (в свежих версиях
golang.org/x/sync паника пробрасывается в
Wait(), но полагаться на это без проверки версии
нельзя). Третий: паника в горутине, порождённой
defer-ом или коллбеком библиотеки, тоже требует
своего recover, так что обёртка вроде safeGo нужна
на каждой точке запуска.
os.Exit vs panic vs return из main
| Способ | defer-ы текущей функции | Стектрейс | Код выхода | Другие горутины |
|---|---|---|---|---|
return из main |
Да, выполнятся | Нет | 0 | Убиваются мгновенно, их defer-ы не выполняются |
panic(v) |
Да, по всей раскрутке | Да, в stderr | 2 | Убиваются, defer-ы не выполняются |
os.Exit(n) |
Нет | Нет | n |
Убиваются немедленно |
log.Fatal(...) |
Нет (это os.Exit(1)) |
Нет | 1 | Убиваются немедленно |
runtime.Goexit() |
Да, только своей горутины | Нет | — | Работают дальше; если это была последняя — fatal error |
Классическая ошибка: log.Fatalf("не смог подключиться: %v", err) в середине функции, где висят defer db.Close() и defer tracer.Shutdown(). Ничего
из этого не выполнится, буферы не сбросятся, спаны не улетят.
Лечится «тонким main»: вся логика в run() error, а
main занимает три строки.
// Канонический паттерн: os.Exit только в самом конце
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) // defer-ов здесь уже нет, можно
}
}
func run() error {
db, err := sql.Open("pgx", os.Getenv("DSN"))
if err != nil { return err }
defer db.Close() // выполнится при любом return из run
shutdown, err := initTracing()
if err != nil { return err }
defer shutdown(context.Background())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return serve(ctx, db)
}
main это обычная функция, запущенная в главной
горутине; когда она возвращается, рантайм вызывает
runtime.exit(0), не дожидаясь никого. В тестах
t.Fatal сделан через runtime.Goexit():
defer-ы теста выполнятся, t.Cleanup отработает
штатно, а код после t.Fatal нет. Паника,
случившаяся во время раскрутки другой паники, печатает
обе: panic: первая [recovered]. Частая картина,
когда неаккуратный defer сам падает.
Вопросы
10func(...)(...): её можно присвоить переменной,
передать аргументом, вернуть, положить в мапу или поле
структуры.
Что из этого следует
-
Есть тип функции:
type Handler func(w http.ResponseWriter, r *http.Request). Типы совместимы по сигнатуре, а не по имени. -
Функции тут значения, поэтому работают таблицы
функций:
map[string]func(int) intвместо длинногоswitch. -
Работают функции высшего порядка:
sort.Slice(s, less),http.HandlerFunc, middleware видаfunc(next Handler) Handler. - Есть анонимные функции и замыкания, то есть анонимные функции, захватившие переменные окружения.
// Функция как значение
var op func(int, int) int = func(a, b int) int { return a + b }
// Таблица вместо switch
var ops = map[string]func(int, int) int{
"+": func(a, b int) int { return a + b },
"*": func(a, b int) int { return a * b },
}
// Функция, возвращающая функцию (генератор middleware)
func WithTimeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Приём "тип-функция реализует интерфейс"
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }
Чего в Go нет
Нет перегрузки функций, нет параметров по умолчанию, нет
именованных аргументов при вызове. Обходят это тремя
способами: структура опций, functional options
(func(*Server)) или отдельные функции
NewX / NewXWithY.
Значение функционального типа это указатель на
funcval: структура, где лежит адрес кода, а следом (у
замыканий) захваченные переменные. Отсюда и запрет на
сравнение: f == g не компилируется, разрешено
только f == nil. Отсюда же аллокация в куче,
если замыкание захватило много всего.
i, а с
1.22 у каждой итерации своя.
Механика
Анонимная функция сослалась на локальную переменную внешней
функции: escape-анализ решит, что переменная убегает в
кучу, и замыкание получит на неё указатель. Все замыкания,
захватившие одну переменную, смотрят в одну ячейку памяти и
видят правки друг друга. Захват «по значению» получается
только явно: передать параметром или снять копию через
x := x.
func counter() func() int {
n := 0 // n убегает в кучу
return func() int { n++; return n }
}
c := counter()
c(); c(); fmt.Println(c()) // 3 — состояние живёт в замыкании
// Два замыкания делят одну переменную
func pair() (inc, get func() int) {
n := 0
return func() int { n++; return n }, func() int { return n }
}
Ловушка цикла и Go 1.22
До Go 1.22 переменная, объявленная в for i := 0; ... или for i, v := range ...,
создавалась один раз на весь цикл. Классика: for i := 0; i < 3; i++ { go func(){ print(i) }()
} печатала 3 3 3. С Go 1.22 такие переменные
живут одну итерацию, и код печатает 0 1 2 в произвольном порядке.
Новая семантика включается строкой go 1.22
(или выше) в go.mod, а не версией компилятора. Модуль
с go 1.21 собирается по старым правилам даже
тулчейном 1.25. Ровно этого уточнения и ждут: оно
показывает, что механизм языковых версий ты понимаешь, а
не выучил новость наизусть.
На что ещё ловят
-
Ловушка живёт не в одних горутинах: тот же эффект
при сохранении замыканий в слайс или при
defer func(){ use(v) }()в цикле. -
Изменение не касается
переменных, объявленных до цикла:
i := 0; for ; i < 3; i++ { ... }ведёт себя по-старому. - Цена небольшая, но есть: новая переменная на итерацию даёт аллокацию, если её захватило замыкание. В обычном цикле без замыканий компилятор её не делает.
-
Старый приём
v := vв 1.22+ безвреден, но избыточен;go vetв новых версиях больше не ругается на loopclosure для новых модулей.
...T внутри функции — это обычный
[]T; компилятор на месте вызова собирает слайс
из аргументов, а f(s...) передаёт
твой слайс без копирования.
Механика
Вариативный параметр обязан быть последним, и он только
один. При вызове sum(1,2,3) компилятор
аллоцирует временный массив из трёх элементов и передаёт
слайс на него. При sum(s...) копии нет: функция
получает заголовок исходного слайса и, изменив элемент,
изменит его у вызывающего.
func sum(nums ...int) int { // внутри nums имеет тип []int
t := 0
for _, n := range nums { t += n }
return t
}
sum() // nums не []int{} длины 0, а nil
sum(1, 2, 3) // компилятор собрал слайс
s := []int{1,2,3}
sum(s...) // тот же слайс, без копирования
// Подвох: функция может испортить твой слайс
func spoil(xs ...int) { if len(xs) > 0 { xs[0] = -1 } }
spoil(s...) // s[0] == -1
// Подвох: append в вариативный параметр пишет в твой массив, если у него есть запас cap
func addOne(xs ...int) []int { return append(xs, 1) }
buf := make([]int, 2, 10)
_ = addOne(buf...) // единица легла в массив buf: buf[:3][2] == 1
Частые ошибки
-
sum(s...)иsum(s)это разные вещи; второе не скомпилируется (кроме случая...[]int). -
Смешивать нельзя:
sum(1, s...)даёт ошибку компиляции. -
При
len(args) == 0параметр равенnil, а не пустому слайсу.lenиrangeс ним работают нормально, ноargs != nilбудет ложью. -
...any(как вfmt.Println) заставляет боксировать каждый аргумент в интерфейс, а это аллокации. Отсюда и совет не зватьfmt.Sprintfв горячем цикле.
append(dst, src...) тоже вариативный вызов,
только со встроенной функцией. А fmt.Errorf(format string, a ...any) в связке с %w умеет
обернуть несколько ошибок сразу (с Go 1.20): несколько
%w в одном формате дают ошибку, у которой
Unwrap() []error.
Три правила
- LIFO. Отложенные вызовы кладутся в стек: последний объявленный выполнится первым. Ровно это и нужно для парного захвата ресурсов: открыл A, открыл B, закроется B, потом A.
-
Аргументы вычисляются сразу.
defer fmt.Println(i)приi == 0напечатает 0, даже если дальшеi = 42. Откладывается только вызов, не вычисление операндов. Получатель метода тоже аргумент:defer b.String()вычислитb.String()немедленно. -
Работает на уровне функции. Ни
if, ниfor, ни блок{}defer не запускают. Толькоreturn, паника или конец тела.
func demo() {
for i := 0; i < 3; i++ { defer fmt.Print(i, " ") }
}
// печатает: 2 1 0
func trap() {
i := 0
defer fmt.Println("A:", i) // A: 0 — i скопирован сейчас
defer func(){ fmt.Println("B:", i) }() // B: 42 — замыкание видит переменную
i = 42
}
// вывод: B: 42, затем A: 0
Чем плох defer в цикле
Он копится: цикл на 100 000 файлов зарегистрирует 100 000
отложенных Close(), и все они выполнятся только
в конце функции. Ресурсы (дескрипторы, соединения,
блокировки) держатся всё это время, и приложение упирается в
too many open files. Плюс defer в цикле
отключает open-coded оптимизацию: количество вызовов
неизвестно на этапе компиляции, и компилятор откатывается на
записи _defer в стеке.
Лечение: вынести тело итерации в отдельную функцию, именованную или анонимную, вызванную тут же. Тогда defer сработает на каждой итерации. Второй путь: закрывать явно, без defer, аккуратно обработав все ветки выхода.
defer и
nil
var f func(); defer f() скомпилируется, но
упадёт паникой в момент выхода из функции, а не в
момент объявления. И ещё: defer resp.Body.Close() сразу после
http.Get, до проверки err, даст
nil pointer dereference, потому что при ошибке
resp равен nil. Сначала проверка ошибки,
потом defer.
return x компилируется в «положить
x в возвращаемую переменную → выполнить defer-ы → вернуть
управление»; если возвращаемое значение именованное, defer
видит эту переменную и может её переписать.
func a() int { x := 1; defer func(){ x = 99 }(); return x } // 1
func b() (x int) { x = 1; defer func(){ x = 99 }(); return x } // 99
func c() (x int) { defer func(){ x++ }(); return 5 } // 6
В a() defer меняет локальную x, но
значение уже скопировано в анонимный слот результата, и
эффекта нет. В b() и c()
именованная x и есть слот результата,
поэтому правка видна.
Три практических применения
-
Транзакции. Один defer решает, коммитить или
откатывать, потому что видит итоговую
err. И может вернуть ошибку самогоCommit(). -
Обогащение ошибки контекстом. Один defer
оборачивает любую ошибку функции, не трогая десяток
return err. - Конверсия паники в error на границе пакета: без именованного результата ошибку из recover не вернуть.
func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil { return fmt.Errorf("begin: %w", err) }
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // не глотаем чужую панику
}
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback: %w", rbErr))
}
return
}
err = tx.Commit() // ошибка коммита попадёт наружу
}()
if _, err = tx.ExecContext(ctx, `UPDATE accounts SET balance=balance-$1 WHERE id=$2`, amount, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err = tx.ExecContext(ctx, `UPDATE accounts SET balance=balance+$1 WHERE id=$2`, amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return nil
}
Напиши внутри tx, err := db.Begin() в новой
области видимости (например, внутри if), и
появится вторая переменная err,
затеняющая именованную. Defer будет смотреть на старую.
Отсюда правило: в функциях с именованным err
пиши для него =, а не :=.
go vet такого не ловит, ловит
shadow в golangci-lint.
return.
Три поколения
-
До 1.13. На каждый
deferаллоцируется структура_deferв куче и вставляется в связный список горутины (g._defer). При выходеdeferreturnобходит список. Порядок 50 нс плюс мусор для GC. -
1.13. Записи
_deferпереехали в стековый фрейм. Аллокации в куче нет, но список и его обход остались: ~35 нс. -
1.14, open-coded defers. Если число defer-ов
известно статически, компилятор инлайнит нужные
вызовы перед каждым
return, а какие из них успели «зарегистрироваться», отслеживает битовой маскойdeferBitsв регистре или на стеке. Никакого списка и обхода: overhead около одной наносекунды.
Когда оптимизация не включается
Компилятор откатывается к стековым записям, если
defer стоит в цикле, если их в функции
больше восьми или если оптимизации отключены (-gcflags="all=-N -l", то есть под отладчиком). Проверяется это через go build -gcflags=-m: в
выводе видно «open-coded defer».
Open-coded defers создают проблему: списка нет, а при
панике рантайму нужно знать, какие вызовы выполнить в
раскручиваемом фрейме. Выручают метаданные
funcdata, которые компилятор кладёт рядом с
функцией: рантайм при раскрутке читает их и
восстанавливает список отложенных вызовов. Так что паника
по-прежнему корректно выполняет все defer-ы, просто путь
этот медленный. И это нормально, паника не должна быть
горячей.
«Сегодня defer в обычной функции стоит
порядка наносекунды, экономить на нём незачем.
Отказываться стоит только по данным профиля, и почти
всегда настоящая проблема даже не defer, а defer в
цикле: он и ресурсы держит, и оптимизацию отключает.»
recover() действует,
только если вызван непосредственно из отложенной
функции, и тогда её владелец возвращается штатно.
Пошагово
-
panic(v)создаёт запись_panicи обрывает текущую функцию прямо в этой точке. - Рантайм начинает раскрутку: в каждом фрейме, снизу вверх, выполняются его отложенные функции. Все до одной, паника этому не мешает.
-
Если очередная отложенная функция вызвала
recover()и получила не nil, паника гаснет. Функция, которой принадлежал defer, завершается нормально и отдаёт то, что лежит в её (именованных) возвращаемых значениях. - Не перехватил никто: раскрутка доходит до корня горутины, рантайм печатает сообщение и стектрейс в stderr и завершает весь процесс с кодом 2.
Где именно должен стоять recover
Только внутри тела отложенной функции, той самой, что стоит
после defer. Вызов recover() из
обычного кода вернёт nil. Вызов из функции,
которую позвала отложенная функция, тоже nil.
Причина: рантайм сверяет, что кадр вызывающего
recover совпадает с кадром, который сейчас
выполняется как defer текущей паники.
func safe() (err error) {
defer func() { // ОК: recover прямо здесь
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
return work()
}
// Работает и так: сама именованная функция отложена
func catch(err *error) {
if r := recover(); r != nil { *err = fmt.Errorf("panic: %v", r) }
}
func safe2() (err error) { defer catch(&err); return work() }
Чего recover не ловит
Fatal error рантайма: concurrent map writes, дедлок («all goroutines are asleep»),
stack overflow, out of memory. Это не паника:
раскрутки нет, defer-ы не выполняются, процесс умирает
сразу. Мимо проходит и os.Exit(), а
runtime.Goexit() паникой не считается (defer-ы
выполнятся, но recover() вернёт nil).
1) recover() возвращает значение, переданное
в panic, типа any, поэтому его
приводят к типу и проверяют. 2) Панику можно
перебросить: panic(r) после проверки
типа, и с чужими паниками так и надо. 3) Паника во
время раскрутки другой паники не теряется: рантайм
печатает обе, помечая первую как [recovered].
error — для ожидаемых сбоев среды
и ввода; panic — для ошибок программиста и
нарушенных инвариантов, когда продолжать работу опаснее, чем
упасть.
panic оправдан
-
Баг в коде: невозможная ветка, нарушенный контракт,
nil там, где его быть не может.
panic("unreachable")честнее, чем молча вернуть ноль. -
Инициализация:
regexp.MustCompile,template.Must, парсинг зашитой конфигурации. Программа с битым конфигом не должна стартовать: лучше упасть на первой секунде, чем отдавать мусор. -
Внутренний механизм пакета: быстрый выход из
глубокой рекурсии парсера, но при условии, что паника
гарантированно ловится на экспортируемой функции. Так
устроены
encoding/jsonиtext/template.
panic недопустим
- Файл не найден, сеть недоступна, БД вернула ошибку, пользователь прислал мусор. Это обычные ошибки, их возвращают.
- В публичном API библиотеки: вызывающий не обязан оборачивать твои функции в recover.
-
Как способ выйти из вложенных циклов: для этого есть метки
(
outer: for { ... break outer }).
Правило границы
Раз внутри пакета паника работает как механизм, перехвати её
на границе и верни error, обязательно
отличая свою панику от чужой по типу и пробрасывая
чужие дальше. Иначе глухой recover проглотит настоящий nil
pointer dereference и спрячет баг.
«Вопрос не в тяжести ошибки, а в том, чья это ошибка. Ошибка окружения даёт error, и обработать её обязан вызывающий. Ошибка программиста даёт panic: чинить её в рантайме бессмысленно, чинят в коде. И отдельно: даже в сервисе с глобальным recover-middleware паника остаётся инцидентом, а не рабочим путём; ей место в алертах, а не в тишине.»
Почему так
Раскрутка идёт по стеку конкретной горутины. Горутины не
вложены друг в друга: у запущенной go f()
собственный стек, «родительского» фрейма в нём нет. Дойдя до
корня своего стека, паника упирается в тупик: рантайм
вызывает fatalpanic, печатает трейс и завершает
процесс. Ни один defer в других горутинах при этом не
выполнится.
// Обязательная обёртка для любой фоновой горутины
func Go(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
metrics.PanicsTotal.Inc()
log.Printf("panic: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
// Если результат нужен наверху, отдаём панику как ошибку
func RunSafe(fn func() error) (err error) {
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
done <- fn()
}()
return <-done
}
Три вещи, которые тут проверяют
-
net/httpставит recover вокруг вызова хендлера, но только в горутине соединения. Если хендлер сам запустилgoи там паника, сервер умрёт. Плюсhttp.ErrAbortHandler, специальная паника, которую сервер гасит молча. -
errgroupсобираетerror, но исторически не перехватывал паники; в свежих версияхx/syncпаника пробрасывается вWait(), но закладываться на это без проверки версии нельзя. - Даже с глобальным recover-middleware паника оставляет систему в неопределённом состоянии: незакрытые транзакции, недоснятые локи. Recover помогает не уронить соседние запросы, но баг за тебя не починит.
go func(){ ... }() без recover в фоновом
воркере, кроне или обработчике сообщений из брокера. Один
невалидный payload, и под падает, Kubernetes его
перезапускает, сообщение приходит снова, под падает снова:
crash loop. Правило простое: ни одного «голого»
go в проде, только через обёртку с
recover и метрикой.
os.Exit — не выполняет defer-ы
вообще; panic — выполняет по всей раскрутке и
печатает трейс, код 2; return из main —
выполняет defer-ы main и завершает процесс с кодом 0.
| Способ | defer | Трейс | Код | Типичное применение |
|---|---|---|---|---|
return из main |
Да (только main) | Нет | 0 | Штатное завершение |
panic(v) |
Да, все по пути | Да | 2 | Невосстановимый баг |
os.Exit(n) |
Нет | Нет | n | Свой код выхода для CLI |
log.Fatal |
Нет | Нет | 1 | Только в самом main |
runtime.Goexit() |
Да (своей горутины) | Нет | — | Внутри t.Fatal |
Главный практический вывод
log.Fatalf это os.Exit(1), поэтому
все defer db.Close(), defer tracer.Shutdown(), defer f.Sync() тихо
не выполнятся: буферы не сброшены, спаны не отправлены,
соединения не закрыты. Отсюда паттерн «тонкий main»: вся
работа в run() error с нормальными
return, а main занимает три строки
с единственным os.Exit(1) в самом конце, когда
defer-ов уже нет.
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Когда main возвращается, рантайм не ждёт
остальные горутины: они обрываются на месте, их defer-ы не
выполняются. Для graceful shutdown нужен явный
signal.NotifyContext +
server.Shutdown(ctx) +
wg.Wait(). Полноту стектрейса при панике
задаёт переменная окружения GOTRACEBACK:
none, single (по умолчанию,
только упавшая горутина), all,
system, crash (последний ещё и
роняет core dump).
1.7Дженерики
Самая молодая большая фича языка (Go 1.18, март 2022) и самая «модная» тема на собесах: по ней сразу видно, читал ли кандидат хоть что-то за пределами туториалов. Синтаксис знают почти все. Расходятся кандидаты в трёх местах: понимают ли, что констрейнт задаёт множество типов, а не «интерфейс с методами»; знают ли, что Go не делает мономорфизацию и почему из-за этого дженерик-код бывает медленнее обычного; и найдётся ли трезвый ответ на «когда дженерики хуже интерфейсов».
Зачем они вообще появились
До 1.18 у Go было ровно три способа написать «одно и то же для разных типов», и каждый чем-то плох. Дженерики закрывают конкретный, довольно узкий пробел: статически типизированный код, одинаковый по структуре, но разный по типу элементов. Не «ООП» и не «шаблоны на все случаи жизни». Именно это.
| Способ до 1.18 | Что плохо | Пример из stdlib |
|---|---|---|
| Копипаста на каждый тип | N копий кода, N мест для багов, правки не синхронизированы |
strings и bytes — почти
зеркальные пакеты
|
interface{} + type assertion |
Проверки уехали в рантайм, боксинг и аллокации, API теряет типы | sort.Slice, sync.Map |
Кодогенерация (go generate) |
Отдельный шаг сборки, сгенерированный код в репозитории, IDE плывёт | исторический genny, шаблоны в проектах |
| Рефлексия | Медленно, не проверяется компилятором, нечитаемо | reflect.DeepEqual, кодеки |
Дженерики придуманы не ради того, чтобы кода стало меньше. Они
переносят проверку типов из рантайма в компайл-тайм там, где раньше
приходилось использовать
interface{}. Если в вашем варианте
interface{} нет и копипасты нет, дженерики вам вряд ли
нужны.
Синтаксис: параметры типа
Параметры типа объявляются в квадратных скобках сразу после
имени функции или типа. Каждый параметр состоит из имени и констрейнта.
Констрейнт обязателен: даже «любой тип» пишется явно как any (это алиас для interface{},
добавленный в 1.18 ради читаемости).
// Дженерик-функция: два параметра типа, оба используются в обычной сигнатуре
func Map[E any, R any](s []E, f func(E) R) []R {
r := make([]R, 0, len(s)) // R можно использовать как обычный тип
for _, v := range s {
r = append(r, f(v))
}
return r
}
// Вызов: типы выводятся из аргументов
names := Map([]int{1, 2, 3}, strconv.Itoa) // []string
// Инстанцируем явно, когда вывести не из чего
parse := Map[string, int] // получили обычную func([]string, func(string) int) []int
// Дженерик-тип: параметры типа у объявления типа
type Stack[T any] struct {
items []T
}
// Методы дженерик-типа перечисляют параметры получателя.
// До Go 1.27 добавить к ним свои параметры типа было нельзя; с 1.27 можно (см. ниже).
func (s *Stack[T]) Push(v T) { s.items = append(s.items, v) }
func (s *Stack[T]) Pop() (T, bool) {
var zero T // нулевое значение параметра типа — только так
if len(s.items) == 0 {
return zero, false
}
v := s.items[len(s.items)-1]
s.items = s.items[:len(s.items)-1]
return v, true
}
// Дженерик-тип всегда используют с аргументами типа
var st Stack[string] // ок
// var bad Stack // ошибка: cannot use generic type Stack without instantiation
-
var zero Tостаётся единственным переносимым способом получить нулевое значение параметра типа. Написатьreturn nilилиreturn 0нельзя:Tможет быть чем угодно. С Go 1.21 есть ещё*new(T)и выражениеzero-значения черезvar; читаемее всего именованный результат:func Pop() (v T, ok bool), тогдаreturnсам вернёт нулевое. -
Собственные параметры типа у методов до Go 1.27 объявлять было
нельзя вообще, и это ограничение годами называли главным недостатком
дженериков в Go.
С Go 1.27 можно:
func (s *Stack[T]) MapTo[R any](f func(T) R) Stack[R]компилируется. Запрет остался только для методов интерфейса. Подробности ниже, отдельным разделом. - Дженерик-тип без аргументов типа ещё не тип, а «шаблон». Его нельзя положить в поле, объявить переменной или использовать в сигнатуре без инстанцирования.
Констрейнты: интерфейс как множество типов
Формулировка, которую стоит держать наготове: в Go 1.18
изменилось само определение интерфейса. Раньше интерфейс задавал
множество методов. Теперь он задаёт
множество типов, а список методов просто один
из способов это множество задать.
interface{ Read([]byte) (int, error) } задаёт множество
всех типов, у которых есть такой метод.
interface{ int | string } перечисляет ровно два типа.
Констрейнтом называют интерфейс, поставленный в позицию параметра типа.
| Запись | Множество типов | Можно ли использовать как обычный тип переменной |
|---|---|---|
any |
все типы | Да (это interface{}) |
comparable |
типы, у которых == не паникует |
Нет — только как констрейнт |
interface{ String() string } |
все типы с методом String |
Да |
interface{ int | int64 } |
ровно int и int64 |
Нет — интерфейс с элементами типа |
interface{ ~int } |
все типы с underlying int |
Нет |
interface{ ~string; String() string } |
пересечение: underlying string И метод |
Нет |
Интерфейс, содержащий элементы типа (|,
~) или comparable, называется
non-basic и может использоваться только как
констрейнт. Объявить var x Ordered нельзя: получите
ошибку компиляции «interface contains type constraints». Обычный
интерфейс с одними методами (basic) работает и как тип, и как
констрейнт.
Тильда ~: почему без неё всё ломается
int в констрейнте означает
ровно тип int, ни строчкой больше. А в реальном коде сплошь и рядом
встречаются именованные типы: type UserID int,
type Weekday int,
type Kilometers float64. Такой тип
не удовлетворяет констрейнту int, потому что
это другой тип. ~int читается как «любой тип, у
которого underlying type совпадает с int» и включает и
сам int, и все производные от него именованные типы.
type Strict interface{ int } // множество = {int}
type Loose interface{ ~int } // множество = {int, UserID, Weekday, ...}
type UserID int
func StrictSum[T Strict](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
func LooseSum[T Loose](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
ids := []UserID{1, 2, 3}
// StrictSum(ids) // ошибка: UserID does not satisfy Strict (possibly missing ~ for int)
LooseSum(ids) // ок → UserID(6)
// Ограничение тильды: справа от ~ должен стоять тип, чей underlying — он сам.
// type MyInt = ~int // нельзя: ~ вне интерфейса
// interface{ ~UserID } // ошибка: invalid use of ~ (underlying type of UserID is int)
possibly missing ~ for int in constraint переводится
буквально как «ты забыл тильду». Практическое правило: в собственных
числовых/строковых констрейнтах
почти всегда нужна тильда. Исключение одно: вы сознательно
хотите отсечь именованные типы (например, чтобы
Duration случайно не попал в арифметику для «сырых»
наносекунд).
T ровно те операции, которые допустимы для
каждого типа из множества. Отсюда прямое следствие: чем
шире констрейнт, тем меньше можно сделать внутри функции.
comparable и constraints.Ordered
comparable остаётся единственным встроенным констрейнтом,
который не выразить синтаксисом объединения: множество «типов, у
которых == определён и не паникует» включает все числа,
строки, булевы, указатели, каналы, интерфейсы, а ещё массивы и
структуры из таких типов. Набор выходит бесконечный, его задаёт
рекурсивное правило. Поэтому он зашит в компилятор.
// comparable нужен там, где значение идёт ключом мапы или сравнивается
func Unique[T comparable](s []T) []T {
seen := make(map[T]struct{}, len(s)) // ключ мапы обязан быть comparable
out := make([]T, 0, len(s))
for _, v := range s {
if _, ok := seen[v]; !ok {
seen[v] = struct{}{}
out = append(out, v)
}
}
return out
}
Unique([]int{1, 1, 2}) // ок
Unique([]struct {
A int
}{{1}, {1}}) // ок — структура из comparable полей
// Unique([][]byte{{1}}) // ошибка: []byte does not satisfy comparable
До 1.20 any не удовлетворял
comparable: интерфейсные типы сравнимы
синтаксически, но == на них может
паниковать в рантайме («comparing uncomparable type
[]int»), а comparable обещает именно строгую,
непаникующую сравнимость. В Go 1.20 правило смягчили: теперь
интерфейсные типы удовлетворяют
comparable как констрейнту, хотя формально под «strictly
comparable» не подходят. Практический вывод:
Unique[any](...) компилируется, но упадёт в
рантайме, если внутрь положить слайс. Это, пожалуй, единственное
место, где дженерик-код может паниковать «как
interface{}».
constraints.Ordered живёт в
golang.org/x/exp/constraints, то есть
вне стандартной библиотеки, и это осознанное решение.
Команда Go решила не фиксировать в stdlib набор «удобных»
констрейнтов, пока не станет ясно, какие из них действительно
нужны: однажды попав в std, они попадают под гарантию
совместимости навсегда. Практический ответ на собесе: «в проде я
не тяну x/exp ради трёх строк, объявляю свой
Ordered локально, это ровно один интерфейс».
// Это ровно x/exp/constraints, можно скопировать к себе
type Signed interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type Unsigned interface{ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr }
type Integer interface{ Signed | Unsigned } // объединение констрейнтов
type Float interface{ ~float32 | ~float64 }
type Ordered interface{ Integer | Float | ~string } // всё, где работают < и >
// Complex в Ordered не входит: комплексные числа не упорядочены.
func Max[T Ordered](a, b T) T { if a > b { return a }; return b }
func Clamp[T Ordered](v, lo, hi T) T {
if v < lo { return lo }
if v > hi { return hi }
return v
}
min, max и clear стали
встроенными функциями
(не дженериками, а билтинами: работают даже на нетипизированных
константах).
min(a, b) вместо своего Max,
clear(m) вместо цикла с delete. Плюс
появились slices.Max/slices.Min для
срезов. Если кандидат на вопрос «напиши Max» пишет дженерик и не
упоминает встроенный max, значит, за языком он не следит.
Собственные констрейнты через интерфейсы
Констрейнт остаётся обычным интерфейсом, поэтому собирать его можно из чего угодно: методов, объединений типов, других констрейнтов. Три рабочих приёма, которые реально встречаются в проде.
// 1. Метод + тип одновременно (пересечение множеств)
type Stringish interface {
~string
Len() int // тип обязан иметь underlying string и метод Len
}
// 2. Констрейнт «указатель на T»: иначе это не выразить.
// Классика: функция должна создать значение и вызвать на нём pointer-метод.
type Unmarshaler[T any] interface {
*T // один терм: указатель на T
json.Unmarshaler
}
func DecodeAll[T any, PT Unmarshaler[T]](raws [][]byte) ([]T, error) {
out := make([]T, 0, len(raws))
for _, raw := range raws {
var v T
if err := PT(&v).UnmarshalJSON(raw); err != nil { // &v имеет тип *T = PT
return nil, err
}
out = append(out, v)
}
return out, nil
}
// Вызов: DecodeAll[User](raws) — PT выводится из T автоматически
// 3. Констрейнт на «слайс своего типа» сохраняет именованный тип результата
type Slice[E any] interface{ ~[]E }
func Filter[S Slice[E], E any](s S, keep func(E) bool) S {
out := make(S, 0, len(s)) // тип результата S, а не []E
for _, v := range s {
if keep(v) { out = append(out, v) }
}
return out
}
type IDs []int
var ids IDs = []int{1, 2, 3}
r := Filter(ids, func(i int) bool { return i > 1 }) // r имеет тип IDs, а не []int
Если объявить func Filter[E any](s []E, ...) []E,
то на входе IDs примется (присваивание совместимо),
но вернётся уже голый []int: именованный тип
потерян, вызывающий код лишился методов IDs.
Именно так устроены сигнатуры в пакете slices:
func Sort[S ~[]E, E cmp.Ordered](x S). Это первое,
что стоит показать, когда просят «напиши generic-хелпер
по-взрослому».
Вывод типов: когда работает и когда нет
Вывод типов в Go намеренно ограничен. Он идёт только «снизу вверх» от типов аргументов (function argument type inference) и дополняется выводом из самих констрейнтов (constraint type inference). Из типа результата, из контекста присваивания и из тела функции Go не выводит ничего, в отличие от Rust или Haskell. Выбор сознательный: так проще, и сообщения об ошибках предсказуемее.
| Ситуация | Выводится? | Что делать |
|---|---|---|
Map(xs, f) — все параметры типа встречаются в
аргументах
|
Да | — |
S ~[]E, известен S → нужен
E
|
Да (constraint inference) | — |
Параметр типа только в результате:
func Zero[T any]() T
|
Нет | Zero[int]() |
Передаём функцию как значение: var f = Map
|
Нет | var f = Map[int, string] |
Аргумент — нетипизированная константа:
Max(1, 2.5)
|
Частично |
Go 1.21+ выводит float64; раньше — ошибка
|
| Аргумент — untyped nil | Нет | Указать явно |
| Литерал анонимной функции без типов параметров | Нет | Написать типы в литерале или указать явно |
// Классический промах вывода
func New[T any]() *T { return new(T) }
// p := New() // ошибка: cannot infer T
p := New[User]() // ок
// Ещё один: reduce, где аккумулятор другого типа
func Reduce[E, A any](s []E, init A, f func(A, E) A) A { ... }
sum := Reduce([]int{1,2,3}, 0, func(a, e int) int { return a + e }) // ок: A выведен из init
// А так не выведется, потому что тип литерала неизвестен до вывода A:
// Reduce([]int{1,2,3}, 0, func(a, e) int { return a + e }) // так нельзя: у литерала функции типы параметров обязательны
// Go 1.21 заметно усилил вывод: теперь работает передача дженерик-функции
// в дженерик-функцию без явных аргументов типа.
res := Map([]int{1,2,3}, strconv.Itoa) // 1.18+
srt := slices.SortedFunc(maps.Keys(m), cmp.Compare) // 1.23, вывод через несколько уровней
Ставьте «выводимые» параметры типа первыми и делайте так, чтобы
каждый параметр типа встречался хотя бы в одном обычном
аргументе. Параметр типа, который встречается только в результате,
сигналит: API неудобен, пользователю придётся писать скобки на каждом
вызове. Тогда передайте «образец» аргументом или верните значение
через указатель:
func Decode[T any](data []byte, dst *T) error
выводится. А вот
func Decode[T any](data []byte) (T, error) не выводится.
Дженерики или интерфейсы: аргументы обеих сторон
Тут начинается взрослая часть темы, и правильный ответ не «дженерики лучше». Инструменты решают разные задачи: интерфейс абстрагирует поведение (что объект умеет), дженерик абстрагирует тип данных (с чем работает алгоритм). Путаница начинается там, где обе абстракции формально применимы.
| Критерий | Дженерики | Интерфейсы |
|---|---|---|
| Что абстрагируем | Тип данных, форму контейнера | Поведение, реализацию |
| Где проверка | Компиляция | Компиляция (метод) + рантайм (assertion) |
| Типы на выходе | Сохраняются точно | Стираются до интерфейса |
| Боксинг/аллокации | Нет для non-pointer shape | Есть при укладке значения в интерфейс |
| Диспетчеризация | Прямой вызов или через словарь | Косвенный через itab |
| Расширяемость сторонним кодом | Плохая: список типов в union закрыт | Отличная: любой тип с методами подходит |
| Мокирование в тестах | Практически невозможно | Штатный способ |
| Размер бинаря | Растёт (стенсилы под каждую форму) | Не растёт |
| Читаемость сигнатур | Страдает при 3+ параметрах типа | Обычно лучше |
| Гетерогенная коллекция | Невозможна | []Shape — штатно |
Задайте вопрос:
меняется ли алгоритм в зависимости от типа? Если
нет, код один и тот же, отличается только тип элемента, берите
дженерик (Map, Filter,
Set[T], Cache[K,V], пул). Если
да, для каждого типа своя реализация, берите интерфейс (Storage, Payer,
io.Writer). Третий случай: тип нужен только внутри
функции и наружу не течёт. Тогда абстрагировать, скорее всего, нечего.
Когда дженерики — плохой выбор
-
Слой репозитория / storage.
Repository[T]выглядит красиво ровно до первого методаFindByEmail. Дальше начинается либо union-констрейнт из всех сущностей, либо рефлексия для имени таблицы, либо интерфейс сбоку. Выходит хуже, чем три честных репозитория. -
Там, где нужен мок. Подменить
Tв тесте нельзя: тип фиксируется на этапе компиляции. Границы с внешним миром должны быть интерфейсами. - Одна-две инстанциации. Дженерик ради двух типов крадёт читаемость, добавляет к размеру бинаря и ничего не даёт. Две функции честнее.
- Union из «всех типов, которые я знаю». Такой констрейнт нельзя расширить снаружи пакета: каждый новый тип означает правку библиотеки. Анти-паттерн, если только множество действительно не замкнуто (например, числовые типы).
-
Кодогенерация вместо дизайна. Если сигнатура выглядит как
func Do[A, B, C any, F ~func(A) (B, error)](...), читать это будет невозможно, и через полгода перепишут в первую очередь именно её.
Когда дженерики — явно правильный выбор
-
Контейнеры и структуры данных:
Set[T],Queue[T],OrderedMap[K,V], LRU-кэш, лок-фри очередь. -
Алгоритмы над коллекциями:
slices,maps, group-by, chunk, partition. -
Инфраструктурные обёртки:
Pool[T](типобезопасныйsync.Pool),Result[T],Optional[T],singleflightс типом. -
Конкурентные примитивы:
Fanout[T], типизированныйerrgroup-воркер, батчер. -
Устранение зеркальных пакетов: там, где раньше писали
stringsиbytesотдельно.
// Пример, где дженерик даёт настоящую пользу: типобезопасный sync.Pool.
// Без него на каждом Get стоит .(*Buffer), а такой assertion может паниковать.
type Pool[T any] struct {
p sync.Pool
new func() T
}
func NewPool[T any](new func() T) *Pool[T] {
return &Pool[T]{p: sync.Pool{New: func() any { return new() }}, new: new}
}
func (p *Pool[T]) Get() T { return p.p.Get().(T) } // assertion спрятана внутри, наружу — T
func (p *Pool[T]) Put(v T) { p.p.Put(v) }
// Пример, где дженерик — ошибка: «универсальный репозиторий»
type Repo[T any] interface {
Get(ctx context.Context, id int64) (T, error)
Save(ctx context.Context, v T) error
}
// Дальше понадобится ListActive, FindByEmail, UpdateBalance — и абстракция рассыпается.
// Плюс имя таблицы всё равно берут рефлексией или из отдельного метода, так что
// типобезопасность, ради которой всё затевалось, теряется в самом важном месте.
Как это работает под капотом: GC shape stenciling
Классических способов реализовать дженерики ровно два, и оба чего-то
стоят.
Мономорфизация (C++ templates, Rust): компилятор генерирует
отдельную копию машинного кода под каждый набор типов. Скорость
максимальная, зато бинарь и время сборки растут взрывом. Стирание типов (Java generics, Go до 1.18 через
interface{}): один код, все значения приводятся к общему
представлению. Бинарь маленький, зато боксинг, аллокации и косвенные
вызовы.
Go выбрал гибрид, «GC shape stenciling». Слово stencil переводится как трафарет: компилятор берёт тело дженерик-функции и «печатает» по нему готовую копию машинного кода; такую копию и называют стенсилом. Печатает он их не по одной на каждый тип, а по одной на каждую GC-форму, то есть на каждую группу типов, которые по размеру и раскладке указателей внутри выглядят для сборщика мусора одинаково. Всё, что у типов из одной группы всё-таки разное (описание типа, адреса методов, itab), передаётся отдельно, скрытым первым аргументом, который называют словарём. Правило группировки из design-документа звучит буквально так:
Два конкретных типа попадают в одну gcshape-группу тогда
и только тогда, когда у них одинаковый underlying type
или оба оказываются указательными типами.
Следствия: *User и *Order дают один
стенсил; type UserID int и int тоже один; а
вот int и int64 дадут
разные стенсилы, несмотря на одинаковый размер.
*T в горячем цикле может проигрывать и
мономорфному коду, и даже обычному интерфейсу.
Что лежит в словаре
Словарём зовут статическую структуру, которую компилятор кладёт в read-only секцию бинаря (по одной на каждую конкретную инстанциацию, не на форму) и передаёт скрытым первым аргументом. Внутри лежит всё, чего стенсил знать не может: он один на много типов.
| Содержимое словаря | Зачем нужно |
|---|---|
*runtime._type для каждого параметра типа
|
Аллокация (make, new), конверсия
в any, работа GC
|
Дескрипторы производных типов ([]T,
map[K]V…)
|
make([]T, n) внутри дженерика |
itab для методов констрейнта |
Вызов метода, объявленного в констрейнте |
| Ссылки на вложенные инстанциации |
Дженерик вызывает другой дженерик с теми же T
|
| Словари для замыканий внутри тела |
Замыкание тоже должно уметь работать с T
|
int,
string, структур дженерик даёт код, почти
неотличимый от рукописного. Для *T с методами он теряет
инлайнинг и девиртуализацию и может проиграть интерфейсу.
-
Нет рантайм-специализации. Инстанциация происходит на
компиляции, поэтому создать
Stack[T]с типом, известным только в рантайме, нельзя. - Время компиляции растёт. Go 1.18 заметно замедлил сборку (~15% на типичных проектах) из-за переделки компилятора под дженерики: нового тайпчекера и инстанциаций; к 1.20–1.21 это в основном отыграли.
- Инлайнинг дженериков ограничен. Компилятор умеет инлайнить простые дженерик-функции, но со словарём это работает хуже, чем с обычными.
-
Профилировать надо инстанциации. В pprof вы увидите
символы вида
pkg.Index[go.shape.int_0]: не баг, а имя стенсила.
Пакеты slices и maps
Главный продукт дженериков для повседневного кода. Оба пакета
переехали из
golang.org/x/exp в стандартную библиотеку в
Go 1.21, вместе с пакетом cmp (там живёт
cmp.Ordered, cmp.Compare,
cmp.Or). В Go 1.23 оба пакета получили вторую волну
функций, уже на итераторах.
import ("slices"; "maps"; "cmp")
// ── slices: поиск и сортировка ──────────────────────────────────────
xs := []int{5, 2, 9, 2}
slices.Sort(xs) // [2 2 5 9] — pdqsort, без рефлексии
slices.SortStableFunc(users, func(a, b User) int {
return cmp.Compare(a.Age, b.Age) // компаратор возвращает -1/0/+1, а не bool
})
i, found := slices.BinarySearch(xs, 5) // 2, true — только на отсортированном
idx := slices.Index(xs, 9) // 3, или -1
ok := slices.Contains(xs, 2) // true
j := slices.IndexFunc(users, func(u User) bool { return u.Admin })
// ── slices: сравнение и модификация ─────────────────────────────────
slices.Equal([]int{1,2}, []int{1,2}) // true — поэлементно, без reflect
slices.Compare(a, b) // лексикографически: -1/0/+1
xs = slices.Compact(xs) // убирает подряд идущие дубли: [2 5 9]
xs = slices.Insert(xs, 1, 7, 8) // вставляет несколько по индексу
xs = slices.Delete(xs, 1, 3) // удаляет полуинтервал [1:3)
slices.Reverse(xs)
c := slices.Clone(xs) // поверхностная копия
m := slices.Max(xs) // паникует на пустом слайсе
g := slices.Grow(xs, 100) // гарантирует cap для 100 доп. элементов
xs = slices.Clip(xs) // cap = len: append больше не пишет в общий хвост; массив тот же
// ── maps ────────────────────────────────────────────────────────────
mm := map[string]int{"a": 1, "b": 2}
maps.Clone(mm) // поверхностная копия
maps.Equal(mm, other) // сравнивает по значениям
maps.Copy(dst, src) // src поверх dst
maps.DeleteFunc(mm, func(k string, v int) bool { return v == 0 })
// ── Go 1.23: итераторы (iter.Seq) ───────────────────────────────────
for k := range maps.Keys(mm) { _ = k } // maps.Keys теперь возвращает итератор
keys := slices.Sorted(maps.Keys(mm)) // []string{"a","b"} — идиома «ключи по порядку»
for i, v := range slices.All(xs) { _, _ = i, v }
for v := range slices.Values(xs) { _ = v }
back := slices.Backward(xs) // итератор в обратном порядке
xs2 := slices.Collect(maps.Values(mm)) // итератор → слайс
-
slices.Compactудаляет только соседние дубли. Без предварительной сортировки это не «уникализация». -
slices.Max/Minпаникуют на пустом слайсе, в отличие от встроенныхmax/min, которые работают с аргументами. -
Delete,Compact,Insertмодифицируют исходный массив и возвращают новый заголовок. Старый слайс после них использовать нельзя. С Go 1.22Deleteещё и обнуляет освободившийся хвост, чтобы не держать ссылки для GC. -
Cloneв обоих пакетах поверхностный.slices.Cloneот[][]intдаст новый внешний слайс с теми же внутренними.
slices.Sort лучше
sort.Slice
sort.Slice работает через рефлексию: он
получает any, строит reflect.Value,
вызывает reflect.Swapper и на каждое сравнение
дёргает замыкание через интерфейс. slices.Sort работает
как инстанцированный pdqsort с прямыми сравнениями и без
единой аллокации. На срезах чисел разница обычно составляет
2–4×, а slices.SortFunc против
sort.Slice выигрывает раза в полтора. Плюс
типобезопасность: sort.Slice(42, ...) компилируется
и паникует в рантайме, slices.Sort(42) не
компилируется.
Go 1.26 и 1.27: что в дженериках изменилось
Материал по дженерикам стареет быстрее остального: за последние два релиза язык поменялся в трёх местах, и первое из них снимает самое известное ограничение.
Go 1.27: у методов появились собственные параметры типа
Годами правильным ответом на собесе было «параметр типа у метода объявить нельзя, выноси в свободную функцию». С Go 1.27 это неверно. Метод обычного или дженерик-типа теперь объявляет свои параметры типа ровно так же, как функция:
type Box struct {
v []int
}
// Go 1.27: метод объявляет собственный параметр типа T
func (b *Box) MapTo[T any](f func(int) T) []T {
out := make([]T, 0, len(b.v))
for _, x := range b.v { out = append(out, f(x)) }
return out
}
b := &Box{v: []int{1, 2, 3}}
fmt.Println(b.MapTo(func(i int) string { return strconv.Itoa(i * 2) })) // [2 4 6]
// Работает и для дженерик-типа: получатель перечисляет свои параметры, метод добавляет свои
type Stack[T any] struct {
items []T
}
func (s *Stack[T]) MapTo[R any](f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
Пример из стандартной библиотеки есть в math/rand/v2:
метод Rand.N() стал дженерик-методом, поэтому
r.N(100) возвращает int, а
r.N(time.Second) уже time.Duration, без
конверсий на месте вызова.
Метод интерфейса по-прежнему не может иметь своих параметров типа:
type Mapper interface {
MapTo[T any](f func(int) T) []T // ошибка: interface method must have no type parameters
}
Причина ровно та, что раньше приводили как аргумент против
дженерик-методов вообще. Интерфейс в Go проверяется
статически и структурно: чтобы решить, реализует ли тип
интерфейс, компилятор сверяет конечный список методов и
складывает их адреса в itab. У дженерик-метода конечного
списка нет: инстанциаций бесконечно много, и часть из них появится в
чужом пакете, которого компилятор в этот момент
даже не видит. Собрать под это таблицу методов невозможно. У
конкретного типа этой проблемы нет: вызов
b.MapTo(f) компилятор видит целиком и инстанцирует именно
то, что нужно, как обычный вызов дженерик-функции.
Отсюда и итоговая формулировка:
«ограничение снято там, где инстанциация известна на
компиляции, и осталось там, где она принципиально не
известна».
Go 1.26: дженерик-тип может ссылаться на себя в собственном констрейнте
Раньше компилятор отвергал «self-referential» констрейнт, когда параметр типа ограничен интерфейсом, параметризованным этим же параметром. С Go 1.26 это легально, и привычный по другим языкам паттерн «тип, который умеет складываться сам с собой» наконец выражается прямо:
// Go 1.26+
type Adder[A Adder[A]] interface{ Add(A) A }
type Num int
func (n Num) Add(o Num) Num { return n + o }
func Sum[A Adder[A]](xs []A) A {
var acc A
for _, x := range xs { acc = acc.Add(x) }
return acc
}
fmt.Println(Sum([]Num{1, 2, 3})) // 6
Читается это так: «A может быть любым типом, у которого
есть метод Add, принимающий и возвращающий
этот же самый A». Без самоссылки такой
контракт выражался только через any и
рантайм-проверки.
Go 1.26: new принимает выражение
Не про дженерики, но про тот же класс неудобств. Раньше
new принимал только тип, и чтобы получить указатель
на результат выражения, приходилось заводить временную переменную
ради &tmp. Теперь можно передать само выражение:
// До 1.26
tmp := compute()
p := &tmp
// Go 1.26+
p := new(compute()) // *T, где T — тип результата compute()
// Особенно заметно на литералах и константах, у которых нельзя взять адрес напрямую:
q := new(42) // *int
r := new(len(s) * 2) // *int
Применяют это в опциональных полях структур (Field *int) и в параметрах клиентов облачных API, где половина кода раньше
состояла из вызовов самописного помощника
func ptr[T any](v T) *T. Такой помощник теперь не
нужен.
Go 1.27: вывод типов стал общее
Раньше вывод типов работал главным образом в точке вызова: из аргументов. В 1.27 он обобщён на все контексты, где дженерик-функция присваивается переменной или конвертируется в совместимый тип функции. Практически это значит, что в таких местах реже приходится писать аргументы типа руками: компилятор берёт их из типа приёмника.
Чего дженерики в Go не умеют — и почему
Список ограничений сознательный: каждое из них выкинуто, чтобы не получить систему типов, которую невозможно ни быстро скомпилировать, ни объяснить. По этому списку сразу слышно, кто читал спецификацию, а кто смотрел туториал.
| Чего нет | Как выглядела бы попытка | Почему запрещено | Обходной путь |
|---|---|---|---|
|
Параметры типа у методов интерфейса (у методов конкретных типов — можно с Go 1.27) |
interface{ MapTo[R any](...) } |
Ломает статическую проверку интерфейсов: чтобы узнать, реализует ли тип интерфейс, компилятору пришлось бы инстанцировать бесконечно много методов и сложить их в itab |
Обычная дженерик-функция:
func MapStack[T, R any](s *Stack[T], f func(T) R)
*Stack[R], либо дженерик-метод на конкретном типе (Go 1.27+)
|
| Специализация |
«для T = string взять другую реализацию»
|
Нарушает принцип «одно тело — одно поведение»; в C++ это источник трудноуловимых багов и раздувания кода |
switch any(v).(type) внутри тела, либо
отдельная функция
|
| Ковариантность |
Передать []*Dog туда, где ждут
[]*Animal
|
В Go типы инвариантны: List[Dog] и
List[Animal] — разные несовместимые типы.
Ковариантность контейнеров небезопасна при записи
(классическая дыра массивов в Java)
|
Сделать функцию дженериком по элементу или конвертировать слайс явно |
| Поля в констрейнтах | interface{ ID int64 } |
Type set задаётся типами и методами; «структурные» констрейнты обсуждались и отложены | Констрейнт с геттером GetID() int64 |
| Перегрузка операторов | Свой + для Money |
Явный отказ от «магии» в дизайне Go | Методы Add/Sub |
| Дженерик-параметры у типа в интерфейсе | Динамический выбор T в рантайме |
Инстанциация — событие компиляции | Интерфейс + any на границе |
// Обход №1 (нужен только до Go 1.27 либо когда метод обязан жить в интерфейсе)
type Stack[T any] struct {
items []T
}
// func (s *Stack[T]) Map[R any](f func(T) R) *Stack[R] // до 1.27 не компилировалось,
// // с 1.27 вполне легально
func MapStack[T, R any](s *Stack[T], f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
// Обход №2: «специализация» руками, через type switch по any(v)
func Format[T any](v T) string {
switch x := any(v).(type) { // конверсия any(v) обязательна
case time.Time: return x.Format(time.RFC3339)
case error: return x.Error()
case fmt.Stringer: return x.String()
default: return fmt.Sprint(v)
}
}
// Цена: это рантайм-проверка. Если такой switch занимает всё тело, дженерик тут лишний:
// достаточно обычного func Format(v any) string.
// Обход №3: инвариантность — почему это не баг
type List[T any] struct {
xs []T
}
var dogs List[*Dog]
// var animals List[*Animal] = dogs // ошибка, и правильно:
// иначе через animals.Append(&Cat{}) в dogs попал бы кот.
В Go 1.24 появились generic type aliases:
type Set[T comparable] = map[T]struct{}. Раньше алиас не
мог иметь параметров типа. В 1.26 разрешили самоссылку дженерик-типа в
собственном констрейнте, в 1.27 добавили параметры типа у методов
конкретных типов и более общий вывод типов при присваивании
дженерик-функции. Обсуждают, но не приняли: sum types, структурные
констрейнты по полям. Логика
команды Go простая: фичу добавляют, только если она
не усложняет чтение чужого кода. Это же объясняет, почему
в языке нет Result[T] в качестве встроенного типа.
Вопросы
11func F[T Constraint](...));
констрейнт — это интерфейс, задающий
множество допустимых типов; инстанцирование
происходит на компиляции — либо явно, либо выводом из
аргументов.
Три части синтаксиса
-
Список параметров типа выглядит как
[T any, K comparable]. Каждый элемент: имя + констрейнт. Констрейнт обязателен, «любой» пишется какany. -
Использование. Внутри сигнатуры и тела
Tведёт себя как обычный тип:[]T,map[K]T,var zero T,make([]T, n). -
Инстанцирование записывается как
F[int](x)явно илиF(x)с выводом. Для дженерик-типов аргументы типа обязательны всегда:Stack[string], никогда простоStack.
// Функция
func Keys[K comparable, V any](m map[K]V) []K {
out := make([]K, 0, len(m))
for k := range m { out = append(out, k) }
return out
}
// Тип + методы: получатель перечисляет параметры типа, но не вводит новые
type Set[T comparable] struct{ m map[T]struct{} }
func NewSet[T comparable](vs ...T) *Set[T] {
s := &Set[T]{m: make(map[T]struct{}, len(vs))}
for _, v := range vs { s.m[v] = struct{}{} }
return s
}
func (s *Set[T]) Add(v T) { s.m[v] = struct{}{} }
func (s *Set[T]) Has(v T) bool { _, ok := s.m[v]; return ok }
func (s *Set[T]) Len() int { return len(s.m) }
// Нулевое значение параметра типа — через var, *new(T) или именованный результат
func (s *Set[T]) Any() (T, bool) {
for v := range s.m { return v, true }
var zero T // не nil и не 0, а var zero T
return zero, false
}
Что чаще всего спрашивают следом
-
Можно ли параметр типа у метода? Начиная с Go 1.27 да, у
метода конкретного типа (
func (b *Box) MapTo[T any](...) []T). До 1.27 было нельзя. У метода интерфейса по-прежнему нет. -
Что такое
any? Алиасinterface{}, добавленный в 1.18 (type any = interface{}). Именно алиас, а не новый тип. -
Можно ли дженерик-метод у интерфейса? Нет, и это
единственное место, где запрет сохранился после 1.27.
Интерфейс может быть дженерик-типом (
type Repo[T any] interface{...}), но его методы своих параметров типа не объявляют.
func (s *Set[T]) Add(v T): в нём
[T] у получателя не объявляет новый
параметр типа, а связывает имя с уже объявленным у типа.
Поэтому нельзя добавить сюда констрейнт:
func (s *Set[T comparable]) ... даст ошибку
компиляции. Констрейнт пишется один раз, у объявления типа.
| и
~ — явно. ~int = «любой тип, у
которого underlying — int»,
int | string = объединение множеств.
Почему понадобились элементы типа
Операции +, <,
range, индексация, конверсия через методы не
выражаются: в Go нет перегрузки операторов. Разрешить их для T можно
одним способом: сказать компилятору, из каких конкретно типов
состоит множество.
Компилятор разрешает операцию тогда и только тогда, когда
она допустима для каждого типа множества.
type Number interface{ ~int | ~int64 | ~float64 }
func Sum[T Number](xs []T) T {
var s T
for _, x := range xs { s += x } // + разрешён: он есть у всех типов множества
return s
}
// А вот так уже нельзя:
type Weird interface{ ~int | ~string }
func Bad[T Weird](a, b T) T {
return a - b // ошибка: оператор - не определён для string
// при этом a + b сработало бы: + есть и у чисел, и у строк
}
Тильда: суть в одном примере
type Celsius float64
func SumStrict[T interface{ float64 }](xs []T) T { ... }
func SumLoose [T interface{ ~float64 }](xs []T) T { ... }
temps := []Celsius{36.6, 37.1}
// SumStrict(temps) // ошибка: Celsius does not satisfy interface{float64}
// // (possibly missing ~ for float64)
SumLoose(temps) // ок
// Ограничение самой тильды: справа обязан стоять тип, чей underlying — он сам
// interface{ ~Celsius } // ошибка: invalid use of ~ (underlying type of Celsius is float64)
// Поэтому нельзя написать «любой тип, производный от Celsius».
| Запись | Читается как | Пример типа внутри |
|---|---|---|
int |
ровно int | int |
~int |
underlying = int |
int, time.Weekday,
UserID
|
~[]byte |
underlying = []byte |
[]byte, json.RawMessage
|
int | ~string |
объединение |
int, string,
MyStr
|
~int; Stringer |
пересечение (перевод строки = И) |
type Code int с методом
String()
|
Интерфейс с |, ~ или
comparable зовётся non-basic: его можно
использовать только как констрейнт.
var x Number не скомпилируется («cannot use
type Number outside a type constraint: interface contains
type constraints»). Обычный интерфейс с одними методами
по-прежнему работает и как тип, и как констрейнт, и это
единственный вид интерфейса, который годится и туда, и туда.
comparable — встроенный констрейнт
«типы, у которых == определён и
не может паниковать» (строгая сравнимость). Он нужен
для ключей мап. С Go 1.20 интерфейсные типы (включая
any) стали удовлетворять ему, хотя
строго сравнимыми не являются — из-за чего
== может упасть в рантайме.
Кто входит и кто нет
| Тип | comparable? |
Комментарий |
|---|---|---|
| Числа, строки, bool | Да | — |
Указатели, каналы, unsafe.Pointer
|
Да | Сравниваются как адреса |
| Массивы из comparable | Да |
[3]int — да, [3][]int —
нет
|
| Структуры со всеми comparable полями | Да | Одно поле-слайс убивает сравнимость всей структуры |
| Слайсы, мапы, функции | Нет | Сравнимы только с nil |
Интерфейсы, any |
С 1.20 — да как констрейнт |
Но == паникует, если внутри лежит
несравнимое
|
func Count[T comparable](s []T) map[T]int {
m := make(map[T]int, len(s))
for _, v := range s { m[v]++ }
return m
}
Count([]string{"a", "a"}) // ок
Count([][3]int{{1,2,3}}) // ок — массив из int
// Count([][]int{{1}}) // ошибка компиляции: []int does not satisfy comparable
// Go 1.20+: это компилируется...
vals := []any{1, "x", []int{1, 2}}
Count(vals) // ...и паникует в рантайме:
// panic: runtime error: hash of unhashable type []int
Почему comparable зашит в компилятор
Его нельзя выразить объединением: множество строго сравнимых
типов задаётся
рекурсивным правилом (структура сравнима, если
сравнимы все её поля; массив сравним, если сравним элемент), то есть
бесконечно. Поэтому comparable остался предопределённым
идентификатором, а не библиотечным типом. По
той же причине его нельзя использовать как тип переменной.
До 1.20 существовал раздражающий разрыв:
map[any]int написать можно (мапа принимает
любой сравнимый на вид ключ), а Count[any] уже нельзя,
потому что any не был строго сравним.
Получалось, что дженерик-версия собственного кода была
строже, чем встроенная мапа. В 1.20 правило
удовлетворения ослабили: теперь «satisfies comparable»
шире, чем «is strictly comparable». Цена: возможная паника в
рантайме, ровно такая же, как у обычной мапы с ключом any. Если
нужна гарантия без паник, принимайте не any, а конкретный тип или
собственный узкий констрейнт.
Ordered — объединение всех типов,
для которых определены < <= > >=:
целые, вещественные и строки. Живёт в
golang.org/x/exp/constraints; в stdlib с Go
1.21 попал только cmp.Ordered — ровно то же
множество, но под другим именем и в пакете cmp.
// x/exp/constraints целиком, тут нечего скрывать
type Signed interface{ ~int | ~int8 | ~int16 | ~int32 | ~int64 }
type Unsigned interface{ ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr }
type Integer interface{ Signed | Unsigned }
type Float interface{ ~float32 | ~float64 }
type Complex interface{ ~complex64 | ~complex128 }
type Ordered interface{ Integer | Float | ~string }
// В stdlib (Go 1.21) есть пакет cmp:
// type cmp.Ordered — то же множество
// func cmp.Compare[T Ordered](x, y T) int // -1 / 0 / +1
// func cmp.Less[T Ordered](x, y T) bool
// func cmp.Or[T comparable](vals ...T) T // Go 1.22: первое ненулевое
Почему не в stdlib
-
Гарантия совместимости навсегда. Всё, что попадает
в
std, нельзя потом изменить. Команда Go осознанно ждала, какие констрейнты реально окажутся нужны на практике, а не какие кажутся красивыми. -
Спорная семантика.
Orderedвключаетfloat64, гдеNaNломает все ожидания:NaN < x,NaN > xиNaN == xвсе три дают false. Из-за этогоcmp.Compareпришлось специально определить так, чтобыNaNсчитался меньше любого числа, иначеslices.Sortвёл бы себя недетерминированно. -
Констрейнты дёшево писать самому. Это буквально
одна строка, а тянуть
x/expв go.mod ради неё многим не хочется: пакет экспериментальный и не даёт гарантий совместимости.
«Для сравнения беру cmp.Ordered из stdlib, он есть с
1.21. Для min/max беру встроенные
min/max, тоже с 1.21, они даже
работают с нетипизированными константами. Свои
Number/Integer объявляю локально
одной строкой, x/exp в зависимости не
добавляю.»
[T any, PT interface{ *T; SomeMethod() }]
нужен, когда функция должна создать значение
T и вызвать на нём метод с указательным
получателем.
Четыре формы констрейнта
// 1) только методы: обычный интерфейс, годится и как тип
type Named interface{ Name() string }
// 2) только типы
type Numeric interface{ ~int | ~int64 | ~float64 }
// 3) пересечение: и underlying, и метод
type Code interface {
~int // underlying обязан быть int
String() string // и обязан быть метод String
}
type HTTPStatus int
func (s HTTPStatus) String() string { return http.StatusText(int(s)) }
// HTTPStatus удовлетворяет Code
// 4) композиция констрейнтов
type SignedNumeric interface{ constraints.Signed | Float }
Приём «указатель на параметр типа»
Задача: функция должна сама создать T и вызвать
на нём метод, объявленный с получателем *T.
Написать [T Unmarshaler] нельзя: тогда
T будет указателем, и var v T даст
nil. Решает второй параметр типа, который констрейнтом
привязан к первому.
type Setter[T any] interface {
*T // PT обязан быть именно *T
SetDefaults() // и иметь этот метод (он с pointer receiver)
}
func Build[T any, PT Setter[T]](n int) []T {
out := make([]T, n)
for i := range out {
PT(&out[i]).SetDefaults() // &out[i] имеет тип *T, конвертируем в PT
}
return out
}
type Config struct {
Timeout time.Duration
}
func (c *Config) SetDefaults() { c.Timeout = 30 * time.Second }
cfgs := Build[Config](3) // PT = *Config выводится из T автоматически
Работает благодаря constraint type inference: зная
T = Config и видя констрейнт *T,
компилятор выводит PT = *Config, так что писать оба
аргумента типа не нужно. Приём стал рабочей лошадкой типизированных
декодеров, ORM-обёрток, билдеров и фабрик.
В стандартной библиотеке аналогично устроен констрейнт
S ~[]E в slices: он привязывает
тип слайса к типу элемента и позволяет вернуть
именованный тип, а не голый []E.
Два механизма
-
Function argument type inference. Сопоставляет типы
фактических аргументов с типами параметров:
Map([]int{...}, strconv.Itoa)→E=int,R=string. -
Constraint type inference. Доводит остальное из
вида констрейнта: зная
S = IDsи констрейнтS ~[]E, выводитE = int. Именно поэтомуslices.Sort(myIDs)работает без скобок.
// ── Работает ─────────────────────────────────────────────
slices.Sort(ids) // S из аргумента, E из констрейнта
Keys(m) // K, V из типа мапы
Build[Config](3) // PT выведен из T
// ── Не работает ──────────────────────────────────────────
func Zero[T any]() T { var z T; return z }
// z := Zero() // cannot infer T — параметр только в результате
z := Zero[int]()
func New[T any]() *T { return new(T) }
// p := New() // то же самое
p := New[Repo]()
var f = Map // cannot use generic function Map without instantiation
var g = Map[int, string] // так — ок
// Литерал функции без типов параметров:
// Reduce(xs, 0, func(a, e) int { return a + e }) // не скомпилируется: у литерала функции типы параметров обязательны
Reduce(xs, 0, func(a, e int) int { return a + e }) // ок
// untyped nil:
// Ptr(nil) // cannot infer T
Ptr[*User](nil)
Что улучшилось в 1.21
-
Вывод, когда дженерик-функцию без скобок передают
аргументом или присваивают переменной с явным
функциональным типом:
var f func([]int, func(int) string) []string = Map(раньше требовались явные скобки). - Вывод из методов: если у аргумента есть метод, подходящий под констрейнт, типы выводятся точнее.
-
Аккуратнее работа с нетипизированными константами:
Max(1, 2.5)теперь выводитfloat64.
Каждый параметр типа должен встречаться
хотя бы в одном обычном аргументе. Если это не так,
поменяйте сигнатуру: не
func Decode[T any](b []byte) (T, error), а
func Decode[T any](b []byte, dst *T) error. Второй
вариант выводится и не заставляет пользователя писать скобки на
каждой строчке.
Аргументы за дженерики
-
Типы не теряются.
Filter([]User, pred)вернёт[]User, а не[]anyс assertion на каждом элементе. -
Нет боксинга. Уложить
intвinterface{}означает, как правило, аллокацию (кроме мелких значений из кэша рантайма). В дженерикеintостаётсяint. -
Ошибки на компиляции.
sort.Slice(42, less)компилируется и падает;slices.Sort(42)не компилируется. -
Убирает зеркальные пакеты. Классика тут
stringsvsbytes: два почти одинаковых пакета, которые нужно править синхронно.
Аргументы за интерфейсы
- Открытость. Любой чужой тип может реализовать ваш интерфейс. Union-констрейнт закрыт: чтобы добавить тип, нужно править саму библиотеку.
-
Тестируемость. Мок подставляется через интерфейс.
Подменить
Tв тесте нельзя: он фиксирован на компиляции. -
Гетерогенность.
[]Shapeс кругами и квадратами работает штатно.[]Tвсегда один тип. -
Читаемость.
func Save(ctx, s Storage)понятнее, чемfunc Save[S ~*T, T Entity](ctx, s S). - Размер бинаря. Интерфейс даёт одну копию кода, дженерик по стенсилу на форму.
| Задача | Правильный инструмент | Почему |
|---|---|---|
Set[T], LRU[K,V], очередь
|
Дженерик | Алгоритм один, тип элемента меняется |
Map/Filter/GroupBy
|
Дженерик | То же |
Storage, PaymentProvider
|
Интерфейс | У каждой реализации своя логика, нужен мок |
io.Reader-подобные абстракции |
Интерфейс | Расширяемость чужим кодом |
Типобезопасный sync.Pool |
Дженерик | Прячет assertion внутрь |
«Универсальный» Repository[T] |
Ни то, ни другое | Конкретные репозитории честнее — специфичные запросы всё равно появятся |
| Плагины, стратегии | Интерфейс | Выбор реализации в рантайме |
| Числовые утилиты | Дженерик | Множество типов замкнуто |
// Показательная пара. Дженерик — там, где меняется тип:
func GroupBy[T any, K comparable](s []T, key func(T) K) map[K][]T {
m := make(map[K][]T)
for _, v := range s { m[key(v)] = append(m[key(v)], v) }
return m
}
// Интерфейс — там, где меняется поведение:
type Notifier interface{ Notify(ctx context.Context, msg string) error }
type Email struct {
...
} // своя реализация
type SMS struct {
...
} // своя реализация
func Broadcast(ctx context.Context, ns []Notifier, msg string) { ... }
// Сделать это дженериком нельзя: []Notifier гетерогенен по определению.
«Дженерики не заменяют интерфейсы, это ортогональный инструмент.
Интерфейс отвечает на вопрос
что объект умеет, а дженерик на вопрос
с чем работает алгоритм. Ошибка, которую я стараюсь не
делать: тянуть дженерик в доменный слой. Там почти всегда меняется
поведение, а не тип, и через полгода абстракция трещит по швам. И отдельный сигнал: если вы
пишете дженерик и внутри у вас type switch по
any(v), дженерик тут не нужен, достаточно обычной
функции от any.»
Три возможных подхода и выбор Go
| Подход | Где | Скорость | Размер бинаря | Компиляция |
|---|---|---|---|---|
| Мономорфизация | C++, Rust | Максимум | Растёт с числом типов | Медленно |
| Стирание типов | Java, Go до 1.18 | Боксинг, косвенные вызовы | Минимум | Быстро |
| GC shape stenciling | Go 1.18+ | Между ними | Растёт с числом форм | Средне |
Правило группировки
Два конкретных типа получают один стенсил тогда и только тогда, когда у них одинаковый underlying type либо оба они указательные.
type UserID int
type Age int
// Index[int], Index[UserID], Index[Age] → один стенсил (underlying = int)
// Index[int64] → отдельный стенсил (другой underlying)
// Index[*User], Index[*Order], Index[*T] → один стенсил (все указатели)
// Index[string] → отдельный
// Index[struct{ A int }] → отдельный
// В pprof и в дизассемблере это выглядит так:
// main.Index[go.shape.int_0]
// main.Index[go.shape.*uint8_0]
Что попадает в словарь
Словарь лежит в бинаре read-only структурой, по одной на каждую конкретную инстанциацию (а не на форму), передаётся скрытым аргументом. Внутри:
-
*runtime._typeкаждого параметра типа: нужен дляmake,new, конверсии вanyи для GC; -
дескрипторы производных типов (
[]T,map[K]V,chan T), которые встречаются в теле; -
itabдля каждого метода, объявленного в констрейнте; - указатели на вложенные инстанциации, если дженерик вызывает другой дженерик;
- словари для замыканий, созданных в теле.
// Концептуально компилятор превращает
func Index[T comparable](s []T, v T) int { ... }
// в нечто вроде
func Index_shape_int(dict *dictionary, s []shapeInt, v shapeInt) int { ... }
// и на каждом вызове подставляет нужный словарь:
// Index_shape_int(&dict_Index_int, ints, 5)
// Index_shape_int(&dict_Index_UserID, ids, UserID(5))
-
Инстанциация только на компиляции. Создать
Stack[T]с типом, известным только в рантайме, невозможно. -
Бинарь растёт от форм, а не от типов. Тысяча
дженериков над
*Tдаёт одну копию кода, и это принципиальное отличие от C++. -
Символы в профиле выглядят странно.
go.shape.int_0в pprof это норма: имя стенсила, а не мусор. - Дженерики удлинили сборку. 1.18 замедлил компиляцию примерно на 15% из-за переделки компилятора под дженерики; к 1.20–1.21 регрессию в основном закрыли.
Откуда берётся замедление
- Чтение словаря. В прологе стенсила добавляется работа с ещё одним скрытым аргументом; для коротких функций это заметная доля.
- Потеря инлайнинга. Метод, объявленный в констрейнте, вызывается через itab из словаря. Компилятор не знает, какой именно код там окажется, и не инлайнит его. Для обычного интерфейса с единственной реализацией в модуле компилятор часто девиртуализирует вызов и инлайнит тело.
- Работа через указатели на pointer-форме. Один стенсил обслуживает все указательные типы, поэтому он не знает конкретную раскладку и вынужден быть максимально общим.
- Меньше оптимизаций «по месту». Границы слайса, размеры элементов, специфика типа: всё, что мономорфный код знает статически, стенсил знает не всегда.
Где дженерики, наоборот, выигрывают
-
Значимые типы (
int,float64,string, структуры): нет боксинга и нет аллокаций, которые обязательны дляinterface{}. -
Сортировка/поиск:
slices.Sortпротивsort.Sliceобычно выигрывает кратно, потому что уходит рефлексия. - Горячие циклы над числами: код почти неотличим от рукописного.
// Бенчмарк, который стоит уметь написать на доске
type Num interface{ ~int64 }
func SumConcrete(xs []int64) int64 { var s int64; for _, x := range xs { s += x }; return s }
func SumGeneric[T Num](xs []T) T { var s T; for _, x := range xs { s += x }; return s }
func SumIface(xs []any) int64 {
var s int64
for _, x := range xs { s += x.(int64) } // плюс аллокации при создании []any
return s
}
func BenchmarkSum(b *testing.B) {
xs := make([]int64, 1_000)
b.Run("concrete", func(b *testing.B) { for b.Loop() { _ = SumConcrete(xs) } })
b.Run("generic", func(b *testing.B) { for b.Loop() { _ = SumGeneric(xs) } })
ys := make([]any, len(xs))
for i, x := range xs { ys[i] = x }
b.Run("iface", func(b *testing.B) { for b.Loop() { _ = SumIface(ys) } })
}
// Типичный результат: concrete ≈ generic (разница в пределах шума),
// iface в разы хуже, и это ещё без учёта аллокаций на упаковку.
Плохой ответ: «дженерики быстрее интерфейсов, потому что
нет боксинга». Это верно только для значимых типов.
Хороший ответ: «на числах и строках дженерик практически
равен рукописному коду и заметно быстрее
interface{}. На указателях и типах с методами
из констрейнта он теряет инлайнинг и девиртуализацию и
может проиграть даже интерфейсу. Поэтому в горячем пути я
не выбираю по интуиции, а пишу бенчмарк с
-benchmem.»
cmp) и расширенные
итераторами в 1.23. slices.Sort —
инстанцированный pdqsort без рефлексии и аллокаций, обычно в
2–4 раза быстрее sort.Slice и проверяется на
компиляции.
Что чем заменяется
| Было | Стало | Что выиграли |
|---|---|---|
sort.Ints(x),
sort.Strings(x)
|
slices.Sort(x) |
Одна функция вместо трёх, работает на любых
cmp.Ordered
|
sort.Slice(x, less) |
slices.SortFunc(x, cmp) |
Нет рефлексии; компаратор возвращает
int, а не bool
|
sort.SearchInts |
slices.BinarySearch |
Возвращает (idx, found), а не «место
вставки»
|
reflect.DeepEqual(a, b) на слайсах
|
slices.Equal |
Без рефлексии, на порядок быстрее |
цикл по мапе + sort |
slices.Sorted(maps.Keys(m)) |
Одна строка, Go 1.23 |
ручной цикл с delete |
maps.DeleteFunc, clear(m)
|
Читаемость |
// Компаратор сменил форму: частая ошибка при миграции
sort.Slice(us, func(i, j int) bool { return us[i].Age < us[j].Age }) // bool, по индексам
slices.SortFunc(us, func(a, b User) int { return cmp.Compare(a.Age, b.Age) }) // int, по значениям
// Сортировка по нескольким полям читается лучше через cmp.Or (Go 1.22)
slices.SortFunc(us, func(a, b User) int {
return cmp.Or(
cmp.Compare(a.LastName, b.LastName),
cmp.Compare(a.FirstName, b.FirstName),
cmp.Compare(a.Age, b.Age),
)
})
// slices.Sort не стабилен (pdqsort). Для стабильности есть SortStableFunc.
// Детерминированный обход мапы:
for _, k := range slices.Sorted(maps.Keys(m)) { // 1.23: maps.Keys — итератор
fmt.Println(k, m[k])
}
// ключи идут по возрастанию при любом запуске, в отличие от голого range по мапе
Почему sort.Slice медленный
Он принимает any, строит
reflect.Value, получает
reflect.Swapper и вызывает переданное замыкание
через интерфейс на каждое сравнение. Плюс проверка «а слайс
ли это?» происходит в рантайме, отсюда классическая паника
sort.Slice(x) на неслайсе.
slices.Sort инстанцируется под конкретный тип:
сравнение сводится к машинной инструкции, свопы к прямым
присваиваниям, аллокаций ноль.
-
slices.Compactубирает только соседние дубли, так что сначала сортируйте. -
slices.Max/Minпаникуют на пустом слайсе. -
Insert/Delete/Compactпортят исходный массив и возвращают новый заголовок: старую переменную использовать нельзя. -
Cloneв обоих пакетах поверхностный. -
slices.Containsищет линейно. Для частых проверок нужна мапа илиSet[T], а не «универсальный хелпер».
Параметры типа у методов: что изменилось в 1.27
До Go 1.27 метод не мог объявить собственные параметры типа вообще, и это ограничение называли главным недостатком дженериков в Go. В 1.27 его сняли для методов конкретных типов:
type Stack[T any] struct {
items []T
}
// Go 1.27: легально
func (s *Stack[T]) MapTo[R any](f func(T) R) *Stack[R] {
out := &Stack[R]{items: make([]R, 0, len(s.items))}
for _, v := range s.items { out.items = append(out.items, f(v)) }
return out
}
// А вот это по-прежнему ошибка: interface method must have no type parameters
type Mapper[T any] interface {
MapTo[R any](f func(T) R) *Stack[R]
}
Почему разделение именно такое. Реализация интерфейсов в Go
структурная и проверяется статически: чтобы понять,
реализует ли тип интерфейс, компилятор сверяет конечный
список методов и складывает их адреса в itab.
Для дженерик-метода такого конечного списка не существует:
инстанциаций бесконечно много, и часть появится в чужих пакетах, которых компилятор сейчас не видит. Для
конкретного типа этой проблемы нет: место вызова
s.MapTo(f) компилятор видит целиком и
инстанцирует ровно нужный вариант, как обычную
дженерик-функцию. Формулировка на собес: «сняли там, где
инстанциация известна на компиляции; оставили там, где она
принципиально не известна».
Пример в стандартной библиотеке: дженерик-метод
Rand.N() в math/rand/v2. Старый
обход через свободную функцию (func MapStack[T, R any](s *Stack[T], f func(T) R)
*Stack[R]) остаётся актуальным для кода, который должен собираться
на Go до 1.27, и для случаев, когда метод обязан попасть в
интерфейс.
Специализация
// Так нельзя (C++ template specialization):
// func Print[T any](v T) { fmt.Println(v) }
// func Print[T string](v T) { fmt.Println("str:", v) }
// ошибка компиляции: Print redeclared in this block
// other declaration of Print
// Так можно, но это рантайм-проверка:
func Print[T any](v T) {
if s, ok := any(v).(fmt.Stringer); ok { fmt.Println(s.String()); return }
fmt.Println(v)
}
Print(42) // 42
Print("hi") // hi
Print(90 * time.Second) // 1m30s — у time.Duration есть String(), сработала первая ветка
Причина в читаемости. В C++ специализация означает, что по вызову f(x)
невозможно понять, какой код выполнится, не зная всех
специализаций в программе. Go сознательно требует: одно объявление,
одно поведение.
Ковариантность
type Animal interface{ Sound() string }
type Dog struct{}
func (Dog) Sound() string { return "woof" }
var dogs []Dog
// var animals []Animal = dogs // ошибка: cannot use dogs ([]Dog) as []Animal
type Box[T any] struct {
v T
}
var bd Box[Dog]
// var ba Box[Animal] = bd // ошибка: Box[Dog] и Box[Animal] — разные типы
Причина в типобезопасности. Если бы []Dog можно
было передать как []Animal, то в него можно
было бы записать Cat{}, и исходный слайс
перестал бы быть []Dog. Java разрешила это для
массивов и получила ArrayStoreException в
рантайме. Go выбрал строгую инвариантность:
List[Dog] и List[Animal] просто разные
типы, между ними нет никакого отношения. Плюс у
[]Dog и []Animal физически разная
раскладка в памяти: значения против пар (itab, data).
| Чего нет | Обход |
|---|---|
|
Параметр типа у метода интерфейса у метода конкретного типа — можно с Go 1.27 |
Свободная дженерик-функция |
| Специализация |
switch any(v).(type) либо отдельная
функция
|
| Ковариантность | Дженерик по элементу или явная конверсия слайса в цикле |
| Поля в констрейнтах | Констрейнт с методом-геттером |
| Перегрузка операторов |
Методы Add/Equal/Compare
|
| Динамическая инстанциация |
Интерфейс + any на границе, рефлексия
|
| Sum types (размеченные объединения) | Интерфейс с приватным методом + type switch |
Go 1.24 принёс generic type aliases:
type Set[T comparable] = map[T]struct{}
(раньше алиасы не могли иметь параметров типа). Go 1.26
разрешил дженерик-типу
ссылаться на себя в собственном констрейнте (type Adder[A Adder[A]] interface{ Add(A) A }) и научил new принимать выражение, а не один лишь
тип (p := new(compute())). Go 1.27
снял запрет на параметры типа у методов конкретных типов и
обобщил вывод типов на присваивание дженерик-функции в
переменную. Обсуждаются, но не приняты: sum types,
структурные констрейнты по полям. Общий принцип, который
стоит проговорить: команда Go добавляет фичу, только если
она не усложняет чтение чужого кода. Именно поэтому большая
часть списка выше так и останется в статусе «нельзя».
1.8Модули, пакеты, инструментарий
Раздел, который недооценивают: «ну go mod tidy, что тут спрашивать». А спрашивают ровно то, на чём видно разницу между тем, кто держал прод, и тем, кто прошёл туториал: почему v2 оказывается в пути импорта, чем MVS отличается от npm, что будет при потере проксей, почему нельзя закрыть цикл импортов и как выглядит полный порядок инициализации. Плюс микро-экзамен отдельно: следишь ли ты за релизами языка.
Сначала — зачем эта глава и четыре слова наперёд
Практический вопрос главы такой: откуда берётся код, который в итоге компилируется, и почему он одинаковый у тебя, у коллеги и на CI. Всё остальное (v2 в пути импорта, go.sum, прокси, вендоринг) отвечает на «как это гарантировать» и «что сломается, если гарантию убрать». Четыре слова понадобятся раньше, чем до них дойдёт разбор.
-
Тулчейн (toolchain): комплект инструментов конкретной версии Go, то есть компилятор, линкер, стандартная библиотека и сама команда
go. «Тулчейн go1.24.3» покрывает весь комплект, а не один компилятор. Версия тулчейна и языковая версия, которую модуль объявляет вgo.mod, это не одно и то же, и дальше это важно. - MVS (minimal version selection, «выбор минимальной версии»): алгоритм, которым Go решает, какую версию зависимости взять, если разные модули требуют разные. Коротко: берётся самая старшая из запрошенных минимальных, и ни версией выше. Никаких диапазонов и «возьмём последнюю подходящую», как в npm или Maven. Ниже про это отдельный раздел.
-
Прокси модулей: сервис, который хранит у себя копии уже опубликованных модулей, по умолчанию
proxy.golang.org.go getидёт не в GitHub, а к нему. -
Вендоринг (vendoring): сложить исходники всех зависимостей в папку
vendor/внутри своего репозитория и собираться из неё, не обращаясь в сеть вообще.
Модуль, пакет, репозиторий: три разные вещи
| Пакет | Модуль | |
|---|---|---|
| Что это | Единица компиляции и области видимости | Единица версионирования и распространения |
| Границы | Одна директория (без вложенных) | Дерево директорий от go.mod вниз |
| Объявляется | package name в каждом файле |
go.mod в корне |
| Имя | Короткое: http, json |
Путь: github.com/org/repo |
| Видимость | Экспорт по заглавной букве | Ограничение через internal/ |
| Импортируется | Да, по полному пути | Нет — импортируют пакеты внутри него |
| Версия | Нет | Есть, semver |
| Сколько | Много в модуле | Обычно один на репозиторий |
Формулировка на собес: модуль объединяет пакеты, которые версионируют и выпускают вместе. Импортируешь всегда пакет, а не модуль; путь импорта складывается из пути модуля и пути директории внутри него. Один репозиторий может держать несколько модулей (монорепо с подмодулями), но тогда начинаются теги вида subdir/v1.2.0 и ручная синхронизация версий, поэтому так делают редко.
go.mod, границу пакета задаёт директория. Путь импорта склеивается из пути модуля и относительного пути папки.
go.mod: что там лежит
module github.com/acme/billing // путь модуля = префикс всех путей импорта
go 1.24.0 // языковая версия: какие фичи разрешены и какая семантика
toolchain go1.24.3 // какой тулчейн скачать, если локальный младше (1.21+)
require (
github.com/jackc/pgx/v5 v5.6.0 // прямые зависимости
go.uber.org/zap v1.27.0
)
require (
github.com/pkg/errors v0.9.1 // indirect // косвенные: нужны зависимостям
)
replace github.com/broken/lib => ../local-fork // подмена: путь или другая версия
exclude github.com/bad/lib v1.2.3 // «эту версию не брать никогда»
retract [v1.0.0, v1.0.4] // автор отзывает свои версии (1.16+)
tool golang.org/x/tools/cmd/stringer // Go 1.24: инструменты как зависимости
godebug default=go1.23 // Go 1.23: зафиксировать старое поведение
go задаёт не только «минимальную версию компилятора»
Она задаёт языковую версию для всех пакетов модуля: какие фичи доступны и по какой семантике всё работает. Самый громкий пример: go 1.22 включает новую семантику переменной цикла, а модуль с go 1.21 в том же билде продолжит работать по-старому. С Go 1.21 директива стала жёсткой. Тулчейн младше указанного не предупредит, а откажется собирать, и (при разрешённом GOTOOLCHAIN) сам скачает нужный. Ещё тонкость: указать go 1.24 и собирать старым компилятором нельзя. Наоборот можно и нужно: новый компилятор соберёт модуль с go 1.19.
replace, exclude и
retract
-
replaceдействует только в главном модуле. Оставишьreplaceв своей библиотеке, и у потребителей он просто проигнорируется: классическое «у меня работает, у них нет». Для локальной разработки лучшеgo work(workspaces, Go 1.18): делает то же самое, но живёт вgo.work, который не коммитят. -
excludeубирает конкретную версию из рассмотрения MVS. Дальше берётся следующее по величине требование, а оно может оказаться ниже исключённой версии, а не выше. Выкинешь битыйv1.4.0и уедешь наv1.1.0, хотяv1.9.0лежит рядом. Поэтомуexcludeобычно дополняют явнымrequireна здоровую версию. -
retractостаётся единственным способом «отозвать» уже опубликованный тег. Удалить версию из прокси нельзя (immutable), зато можно пометить её отозванной, иgo getперестанет её предлагать.
go.sum, checksum database и приватные модули
go.sum работает не как lock-файл. Версии фиксирует go.mod плюс алгоритм MVS, а go.sum отвечает за целостность. В нём лежат криптографические хеши (SHA-256, кодировка h1:) для каждой версии, которая когда-либо участвовала в сборке, включая те, что в итоге не выбраны.
# Две строки на каждый модуль:
github.com/jackc/pgx/v5 v5.6.0 h1:SWJze... # хеш дерева файлов модуля
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/... # хеш только его go.mod
Вторая строка нужна, чтобы считать граф зависимостей, не скачивая сами модули: для MVS хватает go.mod-файлов. Поэтому в go.sum регулярно оказываются модули, чей код в сборку не попал. Руками их не чисти, для этого есть go mod tidy.
| Переменная | Что делает | Когда трогать |
|---|---|---|
GOPROXY |
Список прокси через ,; direct —
идти в VCS напрямую. По умолчанию
https://proxy.golang.org,direct
|
Свой Athens/Nexus, закрытый контур |
GOSUMDB |
Прозрачный лог контрольных сумм, по умолчанию
sum.golang.org
|
off — только в закрытом контуре |
GONOSUMDB |
Шаблоны путей, для которых не сверяться с checksum
database. Сам go.sum при этом продолжает
работать
|
Обычно выставляется автоматически из
GOPRIVATE
|
GONOSUMCHECK |
Несуществующее имя: ни в vgo, ни в одном релизе Go
такой переменной не было, тулчейн её не читает
|
Если увидели в CI — менять на GOPRIVATE
|
GOPRIVATE |
Главная ручка: шаблоны путей, которые считаются
приватными. Автоматически задаёт GONOPROXY и
GONOSUMDB
|
GOPRIVATE=github.com/acme/* |
GONOPROXY |
Шаблоны, которые не идут через прокси | Тонкая настройка отдельно от GOPRIVATE |
GOFLAGS |
Флаги по умолчанию для всех go-команд |
GOFLAGS=-mod=mod в CI |
GOINSECURE |
Разрешить HTTP или невалидный TLS для шаблонов путей | Внутренний Git без нормального сертификата |
GOMODCACHE |
Где лежит кэш модулей, по умолчанию
$GOPATH/pkg/mod
|
Кэш в CI, отдельный диск |
GOTOOLCHAIN |
Разрешено ли автоматически скачивать тулчейн под директиву
go (Go 1.21+)
|
local — запретить скачивание в закрытом
контуре
|
Приватный репозиторий + дефолтные настройки = go mod download уходит в proxy.golang.org, получает 404, потом пытается сверить хеш с sum.golang.org и падает с verifying module: ... 404 Not Found. Лечится одной строкой: GOPRIVATE=github.com/acme/* сразу выключает и прокси, и sumdb для этих путей, плюс git config --global url."git@github.com:".insteadOf
"https://github.com/" для аутентификации. А вот выкручивать GOFLAGS=-mod=mod и GONOSUMDB=* «чтобы просто заработало» не стоит: так ты теряешь защиту от подмены и для публичных зависимостей тоже.
sum.golang.org держит append-only прозрачный лог (по типу Certificate Transparency) на дереве Меркла. Он берёт на себя то, чего go.sum не умеет: go.sum ловит подмену кода после того, как ты один раз зафиксировал хеш, а sumdb страхует первый раз и гарантирует, что все в мире видят один и тот же контент для этой версии. Отсюда и запрет «перевыпускать» тег. Автор сделал git tag -f v1.2.3, запушил другой код, новый хеш не сошёлся с записанным в логе, и сборка сломалась у всех, кто берёт модуль мимо прокси (прокси отдаёт старую копию): checksum mismatch ... SECURITY ERROR. Реагировать надо не командой go clean -modcache и не флагом GOFLAGS=-mod=mod, а разбирательством: либо кто-то переписал тег, либо в кэше или прокси лежит подменённый артефакт.
Semver и major-версии в пути импорта
Версии модулей подчиняются строгому semver: vMAJOR.MINOR.PATCH, обязательно с префиксом v. MAJOR меняется при ломающих изменениях, MINOR при обратно совместимых добавлениях, PATCH при исправлениях. А дальше правило, которого нет больше нигде:
«Если старый и новый пакет имеют один и тот же путь импорта, новый пакет должен быть обратно совместим со старым.» Отсюда следствие: несовместимость обязана менять путь. Поэтому начиная с v2 мажорная версия входит в путь модуля: github.com/acme/lib/v2. Версии v0 и v1 вынесли в исключение: у v0 гарантий совместимости нет вообще, а v1 оставили без суффикса ради совместимости со старыми путями импорта.
// Обе версии могут жить в одной программе одновременно: это разные модули
import (
pgx4 "github.com/jackc/pgx/v4"
pgx5 "github.com/jackc/pgx/v5"
)
// На практике большой сервис можно мигрировать по одному пакету за раз,
// а не «большим взрывом» в один коммит.
# Как выпускается v2 (два рабочих подхода)
# 1) Major subdirectory: копия кода в подпапке, работает даже со старыми тулчейнами
mkdir v2 && cp -r *.go v2/ && cd v2 && go mod init github.com/acme/lib/v2
git add . && git commit -m "v2" && git tag v2.0.0 && git push origin main v2.0.0
# 2) Major branch: отдельная ветка, где go.mod уже содержит /v2. Компактнее, но
# требует аккуратности с тегами
git checkout -b v2
go mod edit -module github.com/acme/lib/v2
# Внутренние импорты модуля тоже надо переписать на .../v2/...
git commit -am "v2" && git tag v2.0.0 && git push origin v2 v2.0.0
# Для модуля без тегов Go сам делает псевдоверсию:
# v0.0.0-20240115143022-a1b2c3d4e5f6
# base-версия UTC-время коммита первые 12 символов хеша
# Псевдоверсии сортируются по времени между собой. Но «всегда ниже тега» — миф:
# базовая версия зависит от того, есть ли тег выше по истории.
# тегов нет вообще -> v0.0.0-<время>-<хеш> (ниже любого релиза)
# есть тег v1.5.2 -> v1.5.3-0.<время>-<хеш> (выше v1.5.2, ниже v1.5.3)
# То есть псевдоверсия перебивает тег, от которого построена, и MVS выберет её.
# Версия v2+ модуля без go.mod получает суффикс +incompatible:
# github.com/old/lib v2.0.0+incompatible
-
Поставили тег
v2.0.0, но забыли дописать/v2вmodule.go getотказывается ставить и ругается: «.../lib@v2.0.0: invalid version: module contains a go.mod file, so module path must match major version (".../lib/v2")». -
Переименовали модуль в
/v2, а внутренние импорты оставили старыми. Тогда в бинарь приедут обе копии пакета, с двумя независимыми наборами глобальных переменных иinit(). -
Считают, что
v0.xзащищён semver. Нет: вv0ломать можно в любом минорном релизе, и MVS это не считает нарушением.
MVS — minimal version selection
Go выбирает версии не так, как npm, Cargo и pip. Всё правило умещается в одну фразу: для каждого модуля берётся максимум из минимально требуемых версий. Никакого солвера, никаких SAT-задач, никакого «взять самое свежее, что подходит под ^1.2.0».
# Инструменты, чтобы увидеть решение MVS
go list -m all # итоговый выбор версий (build list)
go mod graph # весь граф требований, ребро = "кто кого требует"
go mod why -m github.com/x/y # зачем этот модуль вообще в сборке: цепочка импортов
go list -m -u all # какие обновления доступны
go mod tidy # привести require и go.sum к тому, что реально импортируется
# Обновления всегда явные:
go get github.com/x/y@v1.5.0 # конкретная версия
go get -u ./... # поднять минорные и патчи всех зависимостей, включая косвенные
go get -u=patch ./... # только патчи
go get github.com/x/y@none # выкинуть из графа
Из-за «высокоточности»: набор версий определяет только содержимое go.mod всех участников графа. Ни дата сборки, ни состояние реестра, ни наличие lock-файла на него не влияют. Атака «опубликовать вредоносный патч-релиз и ждать, пока CI сам его подтянет» в Go не работает: пока никто не поднял требование, версия не приедет. Обратная сторона в том, что за CVE придётся следить самому, поэтому в проде рядом с MVS всегда стоят govulncheck и автоматический бампер.
GOPROXY, кэш модулей и вендоринг
Модуль скачивается по цепочке: локальный кэш → прокси из GOPROXY → VCS (direct). Кэш лежит в $GOPATH/pkg/mod (или GOMODCACHE), общий на всю машину. Внутри распакованные модули в режиме read-only плюс cache/download, то есть «зеркало прокси» в том же формате.
| Режим | Как включить | Плюсы | Минусы |
|---|---|---|---|
| Прокси по умолчанию | ничего не делать | Быстро, immutable, есть даже удалённые из GitHub модули | Внешняя зависимость CI от сети |
| Свой прокси (Athens, Nexus, JFrog) | GOPROXY=https://athens.corp,direct |
Работает в закрытом контуре, кэш внутри периметра, аудит | Надо поддерживать |
| Вендоринг | go mod vendor |
Сборка вообще без сети, код зависимостей в ревью и в диффе | Репозиторий пухнет, конфликты при мерже, надо не забывать пересобирать |
| Кэш в CI | кэшировать GOMODCACHE |
Простое ускорение, никаких изменений в репо | Кэш может протухнуть или испортиться |
go mod vendor # создаёт ./vendor с кодом зависимостей + vendor/modules.txt
# Дальше go build автоматически использует -mod=vendor, если:
# директория vendor/ существует и директива go >= 1.14
go build -mod=mod # явно проигнорировать vendor
go mod verify # проверить, что кэш не меняли после скачивания (с go.sum не сверяет)
go clean -modcache # снести кэш целиком: read-only он снимет сам, rm -rf упрётся в права
# Воспроизводимая сборка в CI: три флага, которые стоит знать
go build -mod=readonly ./... # запретить менять go.mod (в 1.16+ это дефолт)
go mod download # только скачать: go.mod не трогает, но может дописать go.sum
go mod tidy -diff # Go 1.23: показать, что бы изменилось, и упасть если есть diff
Современный дефолт: не вендорить. MVS плюс go.sum уже дают воспроизводимость, а прокси даёт доступность. Вендоринг оправдан в трёх случаях: сборка в закрытом контуре без своего прокси, регуляторное требование держать весь код зависимостей в репозитории, обязанность ревьюить любые изменения в сторонних библиотеках (финтех, безопасность). Если вендоришь, запускай go mod vendor в CI и роняй сборку при расхождении, иначе vendor/ тихо разъедется с go.mod.
Циклические импорты: почему запрещены и как разруливать
Go не допускает циклов в графе импортов вообще: ни прямых (a → b → a), ни транзитивных. Попытка даёт ошибку компиляции import cycle not allowed со всей цепочкой. Причин три, и назвать лучше все:
-
Скорость компиляции. Пакет компилируется один раз, результат ложится в
.a-архив и переиспользуется. Ациклический граф даёт топологическую сортировку: каждый пакет собирается после всех, от кого зависит, и параллельно с независимыми. С циклами группу пакетов пришлось бы компилировать совместно, и любая правка пересобирала бы всю группу. -
Детерминированная инициализация. Порядок
init()задаёт топологическая сортировка графа. В цикле «кто первый» не определить. - Дизайн. Цикл почти всегда означает, что границы пакетов проведены неправильно: либо два пакета на самом деле составляют один, либо между ними не хватает третьего.
// ── Было: цикл ──────────────────────────────────────────────
// package order
import "app/user"
func (s *Service) Create(uid int64) error {
u, err := user.Get(uid) // order → user
...
}
// package user
import "app/order"
func Get(id int64) (User, error) {
last := order.LastFor(id) // user → order ← цикл
...
}
// ── Стало: интерфейс у потребителя ──────────────────────────
// package order объявляет то, что ему нужно, и ничего не импортирует
type UserGetter interface {
Get(ctx context.Context, id int64) (User, error)
}
type Service struct {
users UserGetter
}
func New(users UserGetter) *Service { return &Service{users: users} }
func (s *Service) Create(ctx context.Context, uid int64) error {
u, err := s.users.Get(ctx, uid) // никакого import "app/user"
...
}
// package user просто имеет подходящий метод, про интерфейс не знает
func (s *Store) Get(ctx context.Context, id int64) (order.User, error) { ... }
// package main — единственное место, где встречаются оба
svc := order.New(user.NewStore(db))
Тестовый файл foo_test.go с package foo живёт внутри пакета и участвует в графе импортов: из него нельзя импортировать пакет, который импортирует foo. А файл с package foo_test (external test package) компилируется отдельным пакетом и может импортировать что угодно, включая зависимых от foo. Так штатно проверяют связку двух пакетов, не создавая цикл, и заодно получают ответ на вопрос «зачем вообще нужен package x_test».
internal/: единственный механизм видимости на уровне
пакетов
Правило одно, и его проверяет тулчейн при сборке: пакет из директории internal/ можно импортировать только из кода, чей путь начинается с родителя этой internal/. Всё остальное получает ошибку use of internal package ... not allowed.
internal/. Единственный способ в Go сказать
«это деталь реализации, трогать снаружи нельзя», и единственный,
который проверяет сборка, а не код-ревью.
internal/
По умолчанию всё. Стандартная раскладка сервиса: /cmd/<binary> с тремя строками main, весь остальной код в /internal/..., а /pkg/... только для того, что ты намеренно публикуешь как API для чужих сервисов. Отсюда свобода рефакторинга: пока пакет в internal, меняй его сигнатуры сколько угодно, мажорную версию выпускать не придётся. В стандартной библиотеке internal работает по тому же правилу, например net/http/internal.
init() и полный порядок инициализации
Вопрос «в каком порядке всё инициализируется» задают часто, а полный ответ укладывается в пять шагов. Выучить стоит именно последовательность.
go build, и полагаться на неё нельзя.
// ── Полные правила ──────────────────────────────────────────
// 1. Пакет инициализируется ровно один раз, даже если импортирован из десяти мест.
// 2. Сначала все импортируемые пакеты, рекурсивно, в порядке топологической сортировки.
// 3. Внутри пакета: переменные уровня пакета в порядке зависимостей между ними.
// 4. Затем все init() этого пакета. Их может быть несколько в одном файле.
// 5. init() нельзя вызвать вручную, нельзя взять его адрес, нет параметров и результата.
// 6. Только после инициализации всех пакетов запускается main.main().
package config
var (
Timeout = defaultTimeout * 2 // зависит от defaultTimeout → будет вторым
defaultTimeout = 15 * time.Second // будет первым
)
func init() { fmt.Println("init 1") }
func init() { fmt.Println("init 2") } // обе выполнятся, в порядке появления в файле
// Импорт ради побочного эффекта — главное хорошее применение init:
import (
_ "github.com/lib/pq" // регистрирует драйвер в database/sql
_ "net/http/pprof" // вешает хендлеры на DefaultServeMux
_ "go.uber.org/automaxprocs" // правил GOMAXPROCS под cgroup;
// с Go 1.25 рантайм умеет это сам
)
init(): развёрнутый ответ
-
Невозможно вернуть ошибку. На сбой остаётся ответить только
panicилиlog.Fatal: процесс падает на старте без внятного контекста. -
Невозможно передать параметры. Конфиг берётся из глобальных переменных или прямо из
os.Getenv, и зависимость прячется. -
Ломает тесты.
initвыполняется при любом запуске тестов пакета, даже если тестируется не он. Пакет, который вinitлезет в базу или читает файл, делает свои тесты нелокальными. -
Порядок неочевиден. Читая файл, ты не видишь, что раньше отработали три
initиз импортов. Отладка «почему переменная уже не nil» превращается в квест. -
Транзитивные побочные эффекты. Классика: кто-то в глубине зависимостей импортировал
net/http/pprof, и твой публичный сервер на дефолтном мультиплексоре внезапно отдаёт/debug/pprof/в интернет. -
Замедляет старт. Всё, что в
init, выполняется доmainи до того, как заработает readiness-проба.
// ── Как надо вместо init ────────────────────────────────────
// Плохо: скрытая зависимость, паника на старте, нетестируемо
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", os.Getenv("DSN"))
if err != nil { log.Fatal(err) } // никакого контекста, defer-ы не отработают
}
// Хорошо: явный конструктор с ошибкой, вызывается из main
func NewDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil { return nil, fmt.Errorf("open db: %w", err) }
if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("ping db: %w", err) }
return db, nil
}
// Ленивая инициализация вместо init, если объект дорогой
var (
once sync.Once
client *http.Client
)
func Client() *http.Client {
once.Do(func() { client = buildClient() })
return client
}
// Go 1.21: sync.OnceValue / sync.OnceValues — то же самое, но короче
var Client = sync.OnceValue(buildClient)
init всё-таки уместен
Три случая: (1) регистрация в реестре, то есть драйверы database/sql, кодеки image, форматы encoding; (2) константы, которые нельзя выразить литералом и приходится собирать на старте: regexp.MustCompile, template.Must; (3) проверка инвариантов сборки, например что таблица переходов заполнена. Общее у них одно: работа детерминированная, локальная и без внешнего мира, то есть ни сети, ни файлов, ни базы, ни переменных окружения.
Инструментарий
| Инструмент | Что делает | Где живёт | Ловит примеры |
|---|---|---|---|
gofmt |
Канонический формат кода. Не настраивается — в этом весь смысл | В тулчейне | Споры о стиле в ревью |
goimports |
gofmt + автоматические импорты и их
группировка
|
x/tools |
Забытые/лишние импорты |
go vet |
Набор анализаторов на «подозрительное, но компилируемое» |
В тулчейне, часть проверок запускается сама при go test
|
Printf-формат, копирование мьютекса,
недостижимый код, потерянный cancel
|
staticcheck |
Самый мощный отдельный анализатор: ~150 проверок SA/S/ST/QF | honnef.co/go/tools |
Мёртвый код, неверные сравнения, неэффективные конструкции, деприкейты |
golangci-lint |
Раннер десятков линтеров с кэшем и параллелизмом. Сам ничего не анализирует | Отдельный бинарь | Всё вышеперечисленное разом + errcheck, revive, gosec, bodyclose… |
go generate |
Выполняет команды из комментариев
//go:generate. Не запускается сам при build
|
В тулчейне | mockgen, stringer, protoc, sqlc, ent |
go test -race |
Детектор гонок (ThreadSanitizer) | В тулчейне | Гонки данных — но только на исполненном пути |
govulncheck |
CVE в зависимостях, с проверкой достижимости уязвимой функции | x/vuln |
Уязвимости, которые реально вызываются вашим кодом |
pprof / trace |
Профили CPU/heap/block/mutex и трасса планировщика | В тулчейне | Горячие места, аллокации, блокировки |
# Минимальный набор в CI, который стоит уметь назвать
gofmt -l . # список неотформатированных файлов; пусто = ок
go vet ./...
go build ./...
go test -race -count=1 ./...
go mod tidy -diff # Go 1.23: упасть, если go.mod/go.sum не в порядке
golangci-lint run --timeout=5m
govulncheck ./...
# .golangci.yml — рабочий минимум без фанатизма (формат golangci-lint v2)
version: "2"
linters:
enable:
- errcheck # непроверенные ошибки
- govet
- staticcheck
- revive # замена golint: стиль и именование
- ineffassign # присваивания, которые никто не читает
- bodyclose # незакрытый resp.Body
- rowserrcheck # забытый rows.Err()
- sqlclosecheck
- errorlint # неправильные сравнения ошибок вместо errors.Is
- noctx # HTTP-запрос без context
exclusions:
rules:
- path: _test\.go
linters: [errcheck, gosec]
// go:generate — код рядом с тем, что он генерирует
//go:generate mockgen -source=$GOFILE -destination=mocks/store_mock.go -package=mocks
//go:generate stringer -type=Status -linecomment
type Status int
const (
StatusNew Status = iota // new
StatusPaid // paid
StatusShipped // shipped
)
// stringer сгенерирует Status.String() без рефлексии и без ручного switch.
// Go 1.24: инструменты фиксируются в go.mod, а не в файле tools.go с build-тегом
// go get -tool golang.org/x/tools/cmd/stringer
// go tool stringer -type=Status
// Раньше приходилось держать такое:
// //go:build tools
// package tools
// import _ "golang.org/x/tools/cmd/stringer"
«go vet встроенный, консервативный, почти без ложных срабатываний; часть его проверок запускается автоматически при go test, а остальные гоняю в CI, и его находки чиню всегда. staticcheck отдельный и гораздо более глубокий анализатор. А golangci-lint вообще не линтер, а раннер: гоняет и vet, и staticcheck, и ещё три десятка, переиспользуя один разбор AST и кэшируя результат, поэтому в CI это на порядок быстрее, чем запускать их по одному.»
Билд-теги, кросс-компиляция и CGO
//go:build linux && amd64 && !nocgo
// Синтаксис с Go 1.17. Старая форма // +build до сих пор встречается в чужом коде.
// Строка должна идти до package. Пустую строку после неё ставит gofmt, а для // +build она обязательна.
package storage
// Выражение поддерживает && || ! и скобки. Доступные теги: GOOS, GOARCH,
// имя компилятора (gc, gccgo), cgo, версия тулчейна (go1.21 и выше), и свои через -tags.
# Неявные теги по имени файла работают без строки //go:build
store_linux.go # GOOS=linux (и android)
store_windows_amd64.go # GOOS=windows и GOARCH=amd64
store_test.go # только при go test
export_test.go # обычный тестовый файл, приём «открыть приватное для тестов»
# Свои теги
go build -tags="integration,e2e" ./...
go test -tags=integration ./...
# Кросс-компиляция: обычно одна строка, без тулчейнов и sysroot
GOOS=linux GOARCH=amd64 go build -o app .
GOOS=darwin GOARCH=arm64 go build -o app-mac .
GOOS=windows GOARCH=amd64 go build -o app.exe .
go tool dist list # полный список поддерживаемых пар
# Прод-сборка контейнерного бинаря
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags)" -o /app .
# CGO_ENABLED=0 → полностью статический бинарь, работает в scratch/distroless
# -trimpath → убирает абсолютные пути из бинаря (воспроизводимость + не течёт /home/user)
# -s -w → выкинуть таблицу символов и DWARF: минус ~30% размера
# -X → вшить версию в переменную на этапе линковки
| Что даёт CGO | Чего стоит |
|---|---|
| Доступ к любым C-библиотекам (SQLite, libpq, OpenCV, ffmpeg) | Нужен C-компилятор → кросс-компиляция ломается, требуется тулчейн под каждую цель |
| Системные API, которых нет в стандартной библиотеке |
Бинарь становится динамически слинкованным:
scratch-образ больше не работает
|
Полная реализация os/user и DNS через
системный резолвер
|
Каждый вызов C — переход в состояние syscall (затянется вызов — sysmon отберёт у горутины P) и переключение на системный стек. Оверхед порядка десятков наносекунд |
| Постепенная миграция legacy C-кода | C-код не под наблюдением GC: утечки, отсутствие panic/recover, падения ломают весь процесс |
| — |
Ломается часть инструментов: -race не видит
гонки внутри C, профилировщик хуже показывает C-стеки
|
| — | Растёт время сборки, усложняется Dockerfile, появляется зависимость от glibc/musl |
CGO_ENABLED=0 для сервисов идёт дефолтом. Включай только под конкретную нужду и лучше выноси её в отдельный бинарь, а не тащи в основной. Самый частый случай: mattn/go-sqlite3 требует CGO, хотя есть чистая замена modernc.org/sqlite. Второй случай, DNS: с CGO_ENABLED=0 работает чистый Go-резолвер, который игнорирует часть настроек nsswitch.conf. В Kubernetes это обычно не проблема, в гибридных сетях иногда всплывает.
Что нового в свежих версиях Go
Этим вопросом проверяют одно: следишь ли ты за языком вообще. Достаточно назвать по 2–3 значимых пункта на релиз и объяснить, зачем их сделали. Релизы выходят строго дважды в год, в феврале и августе: 1.24 в феврале 2025, 1.25 в августе 2025, 1.26 в феврале 2026, 1.27 в августе 2026, текущая.
| Версия | Главное | Зачем |
|---|---|---|
|
1.22 фев 2024 |
Новая семантика переменной цикла: своя копия на каждую итерацию |
Убрана самая известная ловушка языка. Включается строкой
go 1.22 в go.mod — по модулю, а не по
тулчейну
|
Паттерны в http.ServeMux: методы и wildcard-сегменты
|
Роутер в stdlib наконец покрывает 80% случаев без chi/gorilla | |
for i := range 10 — range по целому |
Убирает шум for i := 0; i < 10; i++ |
|
math/rand/v2, slices.Concat,
PGO-сборки быстрее на 2–14%
|
Первый /v2 в самой стандартной библиотеке
|
|
|
1.23 авг 2024 |
Итераторы: range over func + пакет
iter
|
Единый протокол обхода для любых коллекций — до этого каждый писал свой |
Итераторные функции в slices/maps
|
slices.Sorted(maps.Keys(m)) вместо шести
строк
|
|
Пакет unique; таймеры стали GC-безопасными,
канал у Timer теперь без буфера
|
Интернирование строк; убрана классическая утечка
неостановленного time.After в
select
|
|
|
1.24 фев 2025 |
Swiss Tables — новая реализация мап | Поиск сразу по 8 контрольным байтам (на amd64 через SIMD): до 60% быстрее на микробенчмарках, меньше памяти. Прозрачно для кода |
Generic type aliases:
type Set[T comparable] = map[T]struct{}
|
Закрыт пробел дженериков: алиасы наконец могут иметь параметры типа | |
tool-директива в go.mod +
go tool
|
Хак с tools.go и build-тегом больше не нужен
|
|
b.Loop(), os.Root, weak,
runtime.AddCleanup
|
for b.Loop() чинит целый класс ошибок в
бенчмарках; os.Root — защита от path
traversal
|
|
|
1.25 авг 2025 |
WaitGroup.Go
|
Один метод вместо связки Add(1) +
go + defer Done() — убирает
забытый Done и рассинхрон счётчика
|
Container-aware GOMAXPROCS
|
Рантайм читает CPU-лимит cgroup и подстраивается на лету.
automaxprocs больше не нужен
|
|
testing/synctest — стабильный |
Детерминированные тесты конкурентного кода с «виртуальным» временем | |
GC Green Tea и encoding/json/v2 —
тогда ещё эксперименты, flight recorder в
runtime/trace
|
Оба эксперимента с тех пор доехали до дефолта: Green Tea — в 1.26, json/v2 — в 1.27 | |
|
1.26 фев 2026 |
Green Tea GC включён по умолчанию |
Минус 10–40% накладных расходов сборщика, плюс ещё около
10% на свежих amd64 за счёт векторных инструкций.
Выключить: GOEXPERIMENT=nogreenteagc
|
Два изменения в языке: new принимает
выражение (p := new(compute())) и
дженерик-тип может ссылаться на себя в констрейнте (type Adder[A Adder[A]] interface{ Add(A) A })
|
Первое убивает самописные ptr[T]-помощники,
второе позволяет выразить «тип, который складывается сам с
собой» без any
|
|
errors.AsType[T](err),
slog.NewMultiHandler,
io.ReadAll вдвое быстрее
|
AsType — дженерик-версия
errors.As: типобезопасно и без объявления
переменной заранее
|
|
Экспериментальный профиль goroutineleak; вызовы cgo дешевле на ~30%; редирект
ServeMux на слэш стал 307 вместо 301;
go fix переписан в набор «модернизаторов»
|
Профиль находит горутины, навсегда заблокированные на
недостижимых примитивах — до этого утечки ловили только
NumGoroutine и goleak
|
|
|
1.27 авг 2026 |
У методов появились собственные параметры типа |
Снято главное ограничение дженериков. Запрет остался на
стыке с интерфейсами: у метода интерфейса параметров
типа нет, а дженерик-метод не засчитывается в реализацию. В
stdlib пример — Rand.N() в
math/rand/v2
|
encoding/json/v2 как отдельный пакет
|
Строже старого: отвергает невалидный UTF-8 и дублирующиеся
ключи объекта, Unmarshal заметно быстрее. Сам
encoding/json теперь работает поверх v2, но ведёт себя по-старому (могут отличаться тексты ошибок) — строгость
включает только импорт v2. Рядом появился
низкоуровневый encoding/json/jsontext. Вернуть прежнюю реализацию:
GOEXPERIMENT=nojsonv2, пакеты v2 при этом пропадут
|
|
Пакет uuid в стандартной библиотеке
(RFC 9562)
|
NewV4(), NewV7(),
Parse. NewV7 сортируется по
времени — то, что нужно для первичных ключей. Внешний
github.com/google/uuid больше не обязателен
|
|
Профиль goroutineleak стал
общедоступным; strings.CutLast; часть мелких
аллокаций дешевле до 30%; метки горутин попадают в
трейсбеки; удалён GODEBUG=asynctimerchan
|
Утечки горутин теперь ищутся штатным профилем —
/debug/pprof/goroutineleak. Метки в трейсбеке
сразу показывают, какой запрос обрабатывала упавшая
горутина
|
// ── 1.22: ServeMux ──────────────────────────────────────────
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id") // новый метод для wildcard-сегментов
...
})
mux.HandleFunc("POST /users", createUser)
mux.HandleFunc("GET /files/{path...}", serveFiles) // многосегментный wildcard
// Выигрывает более «специфичный» паттерн, порядок регистрации не важен.
// Конфликтующие паттерны вызывают панику при регистрации, а не тихо перекрывают друг друга.
// ── 1.23: итераторы ─────────────────────────────────────────
// Три сигнатуры, которые понимает range:
// func(yield func() bool)
// func(yield func(V) bool) → iter.Seq[V]
// func(yield func(K, V) bool) → iter.Seq2[K, V]
func Lines(r io.Reader) iter.Seq2[string, error] {
return func(yield func(string, error) bool) {
sc := bufio.NewScanner(r)
for sc.Scan() {
if !yield(sc.Text(), nil) { return } // false = потребитель сделал break
}
if err := sc.Err(); err != nil { yield("", err) }
}
}
for line, err := range Lines(f) { // выглядит как обычный range
if err != nil { return err }
process(line)
}
// ── 1.24: b.Loop ────────────────────────────────────────────
func BenchmarkParse(b *testing.B) {
data := load()
for b.Loop() { // вместо for i := 0; i < b.N; i++
_ = Parse(data) // аргументы и результат не «оптимизируются» компилятором,
} // а setup вне цикла не попадает в замер
}
// ── 1.25: WaitGroup.Go ──────────────────────────────────────
var wg sync.WaitGroup
for _, u := range urls {
wg.Go(func() { fetch(u) }) // Add(1) + go + defer Done() внутри
}
wg.Wait()
// ── 1.26: new от выражения и errors.AsType ──────────────────
type Req struct {
Limit *int
}
r := Req{Limit: new(50)} // раньше: tmp := 50; r := Req{Limit: &tmp}
// errors.AsType — дженерик-версия errors.As: без объявления переменной заранее
if pe, ok := errors.AsType[*fs.PathError](err); ok {
log.Println(pe.Path)
}
// старая форма: var pe *fs.PathError; if errors.As(err, &pe) { ... }
// 1.26: дженерик-тип ссылается на себя в собственном констрейнте
type Adder[A Adder[A]] interface{ Add(A) A }
// ── 1.27: дженерик-метод и uuid в stdlib ────────────────────
type Box struct {
v []int
}
func (b *Box) MapTo[T any](f func(int) T) []T { // до 1.27 не компилировалось
out := make([]T, 0, len(b.v))
for _, x := range b.v { out = append(out, f(x)) }
return out
}
id := uuid.NewV7() // "uuid" теперь в стандартной библиотеке
fmt.Println(id.String()) // сортируемый по времени → годится в PK
// 1.27: поиск утечек горутин штатным профилем
// go tool pprof http://localhost:6060/debug/pprof/goroutineleak
range над функцией работает как синтаксический сахар: тело цикла становится колбэком yield, а break превращается в возврат false. Главное тут: циклом управляет итератор, а не потребитель.
Go 1.24 заменил классическую реализацию мапы (массив бакетов по 8 пар + цепочка overflow) на Swiss Table: группы по 8 слотов с отдельным control word из 8 байт, где каждый байт хранит 7 бит хеша плюс флаг занятости. Поиск сравнивает сразу все 8 контрольных байтов: на amd64 одной SIMD-инструкцией, на остальных архитектурах несколькими битовыми операциями над 64-битным словом. Поэтому промах определяется за один шаг, а не за обход цепочки. На микробенчмарках это до 60% ускорения, по реальным приложениям в среднем около 1,5% CPU, плюс меньше накладной памяти. Для кода не меняется ничего: порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, адрес значения по-прежнему нельзя взять.
Вопросы
13go.mod вниз). Модуль — это набор пакетов,
которые выпускаются вместе под одной версией.
| Пакет | Модуль | |
|---|---|---|
| Граница | Директория (без вложенных) | go.mod и всё дерево ниже |
| Объявление | package name |
module github.com/org/repo |
| Версия | Нет | Semver-тег |
| Что импортируют | Пакет, по полному пути | Модуль не импортируют |
| Видимость | Заглавная буква идентификатора | internal/ |
github.com/acme/billing/ ← модуль (тут go.mod)
├── go.mod go.sum
├── main.go ← пакет main
├── invoice/ ← пакет invoice
│ ├── invoice.go
│ ├── total.go ← тот же пакет: файлы в одной папке
│ └── pdf/ ← ДРУГОЙ пакет invoice/pdf
└── internal/store/ ← пакет store, виден только внутри модуля
# Путь импорта = путь модуля + относительный путь директории:
import "github.com/acme/billing/invoice/pdf"
Несколько модулей в репозитории
Технически можно: положи go.mod в подпапку, и она станет отдельным модулем (её содержимое автоматически исключается из родительского). Но цена высокая: теги превращаются в subdir/v1.2.0, версии расходятся, релиз усложняется. На практике так делают ради двух вещей: (1) вынести пакет с тяжёлыми зависимостями (например, gRPC-контракты), чтобы потребители не тянули лишнего; (2) отделить примеры или e2e-тесты. В остальных случаях: один репозиторий, один модуль.
Имя пакета в коде и последний сегмент пути импорта совпадать не обязаны. import "gopkg.in/yaml.v3" даёт идентификатор yaml, а не yaml.v3; github.com/mattn/go-sqlite3 даёт пакет sqlite3. Отсюда алиасы импорта и отсюда же промахи goimports, который иногда «угадывает» неверно.
go.mod хранит путь модуля,
языковую версию, список минимально требуемых версий
зависимостей и локальные корректировки графа. Директива
go — это языковая версия, а с Go 1.21 ещё и
минимальная версия тулчейна: от неё зависит доступная
семантика.
module github.com/acme/billing
go 1.24.0 // языковая версия для всех пакетов модуля
toolchain go1.24.3 // какой тулчейн скачать, если локальный младше
require (
github.com/jackc/pgx/v5 v5.6.0
go.uber.org/zap v1.27.0
)
require github.com/pkg/errors v0.9.1 // indirect
replace github.com/broken/lib => ../fork // подмена источника или версии
exclude github.com/bad/lib v1.2.3 // «эту версию не рассматривать»
retract [v1.0.0, v1.0.4] // отозвать свои выпущенные версии
tool golang.org/x/tools/cmd/stringer // Go 1.24: инструменты как зависимости
Директива go — три факта
-
Задаёт, какие языковые фичи разрешены и по какой семантике работает код. Пример:
go 1.22включает новую семантику переменной цикла, а модуль сgo 1.21в том же билде продолжает работать по-старому. Семантику определяет модуль, а не версия тулчейна. -
С Go 1.21 она жёсткая: тулчейн младше указанного
откажется собирать, а не выдаст предупреждение. При
GOTOOLCHAIN=autoнужная версия скачается сама. -
Собрать модуль с
go 1.19новым компилятором можно и нужно: обратная совместимость соблюдается.
replace vs exclude vs
retract
| Директива | Что делает | Кто видит | Типичное применение |
|---|---|---|---|
replace |
Подменяет модуль другим путём/версией | Только главный модуль | Локальный форк, отладка, монорепо |
exclude |
Убирает версию из рассмотрения MVS | Только главный модуль | Битый релиз с CVE |
retract |
Помечает собственную версию отозванной | Все потребители | Ошибочно выпущенный тег |
replace
В библиотеке replace не работает: он применяется, только когда модуль главный. Классический инцидент: разработчик добавил replace для локальной отладки, забыл убрать, выпустил тег. У него всё собирается, а потребителям приезжает оригинальная (сломанная) версия. Для локальной разработки бери workspaces: go work init . ../lib, файл go.work не коммитят, go.mod при этом вообще не трогают.
go.sum — не lock-файл.
Версии фиксирует go.mod + MVS;
go.sum отвечает за целостность: хранит
хеши контента каждой версии. Checksum database
(sum.golang.org) — глобальный append-only лог,
который гарантирует, что все в мире видят один и тот же
контент.
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexB... # хеш дерева файлов модуля
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlr... # хеш только его go.mod
Почему две строки
Чтобы посчитать граф зависимостей, MVS нужны только go.mod-файлы всех участников, сам код скачивать не обязательно. Поэтому хеш go.mod хранится отдельно. Отсюда же модули в go.sum, чей код в сборку не попал: их go.mod участвовал в вычислении версий. Чистить руками не надо, для этого есть go mod tidy.
Чем это отличается от package-lock.json
| go.sum | package-lock.json | |
|---|---|---|
| Фиксирует версии | Нет — это делает go.mod + MVS | Да, это его основная роль |
| Фиксирует хеши | Да | Да |
| Если файла нет | go build падает: missing go.sum entry |
Установка идёт, но версии плывут |
| Конфликты при мерже | Редко и легко: go mod tidy |
Регулярно и болезненно |
| Глобальная проверка | Да — sumdb | Нет |
Checksum database
sum.golang.org ведёт прозрачный лог на дереве Меркла, только на добавление, по устройству похожий на Certificate Transparency. Он закрывает дыру, которую go.sum не закрывает: go.sum защищает тебя после того, как ты один раз зафиксировал хеш, а sumdb защищает первое скачивание, чтобы никто не подсунул тебе одну версию контента, а всему миру другую. Побочный, но важный эффект: теги становятся неизменяемыми де-факто.
# Что делать при SECURITY ERROR: checksum mismatch
# 1. Не делать go clean -modcache и не стирать go.sum «чтобы заработало»
# 2. Понять, чей хеш не сошёлся:
go mod download -x github.com/x/y
curl -s https://sum.golang.org/lookup/github.com/x/y@v1.2.3 # хеш, который видят все
# 3. Три реальные причины по убыванию частоты:
# а) автор переписал тег (git tag -f + push --force), а модуль берут мимо прокси: писать автору, пинить другую версию
# б) в корпоративном прокси лежит подменённый/битый артефакт: чистить прокси
# в) go.sum поехал при мерже: восстановить и пересобрать (go mod tidy)
# Приватные модули настраиваются одной переменной:
go env -w GOPRIVATE=github.com/acme # выключает и прокси, и sumdb для этих путей
git config --global url."git@github.com:".insteadOf "https://github.com/"
«go.sum не про версии, а про то, что скачанный контент тот же, что был при первой сборке. Версии определяют go.mod и MVS, и они детерминированы сами по себе, поэтому Go обходится без lock-файла. И go.sum обязательно коммитим: без него теряется вся защита цепочки поставки.»
/v2, /v3. Побочный и очень
полезный эффект: две мажорные версии могут сосуществовать в
одной программе.
// v0 и v1 — без суффикса
import "github.com/acme/lib" // v0.x.x или v1.x.x
// v2+ — суффикс обязателен и в go.mod, и в импорте
import "github.com/acme/lib/v2" // module github.com/acme/lib/v2
// Побочный эффект: обе версии живут рядом, это разные модули
import (
pgx4 "github.com/jackc/pgx/v4"
pgx5 "github.com/jackc/pgx/v5"
)
// Так большой сервис переводят по пакету за раз, а не одним коммитом.
Три формы версии, которые надо узнавать
| Форма | Пример | Что означает |
|---|---|---|
| Обычный тег | v1.5.2 |
Semver-тег в репозитории |
| Псевдоверсия |
v0.0.0-20240115143022-a1b2c3d4e5f6
|
На коммите нет тега: базовая версия + UTC-время коммита
+ 12 символов хеша. Если тег есть выше по истории, базой
берётся следующий патч: коммит после v1.5.2
даёт v1.5.3-0.…, что выше
v1.5.2
|
+incompatible |
v2.0.0+incompatible |
У модуля тег v2+, но нет
go.mod — доисторический пакет, Go
делает исключение
|
| Пререлиз | v1.5.0-rc.1 |
Ниже v1.5.0; go get -u с релиза на него
не перейдёт, а с псевдоверсии или пререлиза пониже — может
|
Как выпускают v2
# Вариант 1 — подпапка (major subdirectory)
mkdir v2 && cp *.go v2/ && cd v2 # v1 остаётся в корне для старых импортов
go mod init github.com/acme/lib/v2 # плюс переписать все внутренние импорты на /v2
git add . && git commit -m "v2" && git tag v2.0.0 && git push origin main v2.0.0
# Вариант 2 — ветка (major branch)
git checkout -b v2
# в go.mod: module github.com/acme/lib/v2, внутренние импорты тоже на /v2
git commit -am "v2" && git tag v2.0.0 && git push origin v2 v2.0.0
-
Тег
v2.0.0есть, а/v2вmoduleнет →go getругается «module path must match major version» и версия просто не устанавливается. -
Путь модуля поменяли, а внутренние импорты забыли → в
бинарь попадут обе копии пакета: два набора
глобальных переменных, два
init(), две регистрации в реестрах, значения одного типа не присваиваются другому. -
Считать, что
v0.xзащищён semver. Не защищён: в нулевой мажорке ломать разрешено в любом минорном релизе, и это не нарушение правил.
Алгоритм в трёх шагах
-
Обойти граф:
go.modглавного модуля →go.modего зависимостей → и так далее (нужны толькоgo.mod-файлы, код не скачивается). - Для каждого пути модуля собрать множество требуемых версий.
- Выбрать из множества максимальную. На выходе build list.
# main → A v1.2.0 → C v1.1.0
# main → B v1.5.0 → C v1.3.0
# В репозитории уже есть C v1.9.0
#
# Go: требования на C = {v1.1.0, v1.3.0} → берём v1.3.0
# npm: диапазоны ^1.1.0 и ^1.3.0 → берём самое свежее в пересечении, v1.9.0
go list -m all # итоговый build list
go mod graph # весь граф требований
go mod why -m github.com/x/y # зачем модуль в сборке: цепочка импортов
go list -m -u all # какие обновления доступны
| Go (MVS) | npm / Cargo / pip | |
|---|---|---|
| Форма требования | Точная минимальная версия |
Диапазон (^1.2, ~1.2)
|
| Что выбирается | Максимум из минимумов | Максимум, попадающий в диапазоны |
| Алгоритм | Обход графа, линейный | Решатель ограничений (в пределе NP-полный) |
| Lock-файл | Не нужен | Обязателен |
| Сборка вчера и через год | Идентична | Может отличаться без lock-файла |
| Дубли версий | Одна версия на major | Дерево копий в node_modules |
| Патчи безопасности | Только явно | Часто приезжают сами |
Плюсы и минусы — оба надо назвать
-
+ High-fidelity builds. Набор версий определяет только содержимое
go.modв графе, ни дата сборки, ни состояние реестра на него не влияют. - + Нет «фантомных» апдейтов. Новая версия зависимости не приедет, пока кто-то явно не поднимет требование. Атака «выпустить вредоносный патч и ждать, пока CI сам подтянет» в Go не работает.
- + Простота. Нет NP-полного солвера, нет «resolution hell», нет протухшего лока.
-
− Обновления только вручную. Патчи безопасности сами не приезжают: нужен
go get -u, Renovate/Dependabot иgovulncheckв CI. -
− Ответственность распределена. Если популярная
библиотека требует старую версию с CVE, поднимать придётся
в каждом потребителе через
go getилиexclude.
Название сбивает с толку: берётся не самая маленькая версия из всех существующих, а самая большая из тех, что кто-то потребовал. «Минимальность» здесь значит «не берём больше, чем нужно»: v1.9.0 из репозитория не приедет, пока её никто не запросил. Ещё важно, что MVS работает по major-версиям раздельно, ведь lib и lib/v2 задают разные пути модуля, а значит и разные модули.
vendor/) и
позволяет собирать вообще без сети. Современный дефолт —
не вендорить: MVS + go.sum уже дают
воспроизводимость, а прокси — доступность.
Цепочка загрузки
-
Локальный кэш
$GOMODCACHE(по умолчанию$GOPATH/pkg/mod). Распакованные модули read-only плюс подкаталогcache/download, зеркало прокси в том же формате. -
Прокси из
GOPROXYзадаётся списком через запятую, по умолчаниюhttps://proxy.golang.org,direct. Прокси иммутабелен: раз опубликованную версию не изменить, а удаление тега в репозитории её из прокси обычно не убирает. -
directотправляет прямо в VCS (git clone). Срабатывает, если модуль не нашёлся в прокси или путь попал вGONOPROXY.
go mod vendor # ./vendor + vendor/modules.txt
# go build сам включает -mod=vendor, если vendor/ существует и go >= 1.14
go build -mod=mod # явно игнорировать vendor
go mod verify # проверить, что кэш не меняли после скачивания (с go.sum не сверяет)
go mod download # только скачать
go clean -modcache # снести кэш целиком
# Воспроизводимая сборка в CI
go build -mod=readonly ./... # запретить неявную правку go.mod (дефолт с 1.16)
go mod tidy -diff # Go 1.23: упасть, если tidy что-то изменил бы
| Подход | Когда оправдан | Цена |
|---|---|---|
| Публичный прокси | Дефолт для большинства | CI зависит от внешней сети |
| Свой прокси (Athens/Nexus/JFrog) | Закрытый контур, аудит, скорость | Надо поддерживать инфраструктуру |
| Вендоринг | Сборка без сети; регуляторное требование; ревью изменений в зависимостях | Репозиторий пухнет, конфликты при мерже, легко забыть пересобрать |
Кэш GOMODCACHE в CI |
Простое ускорение сборок | Кэш может протухнуть |
Разъезд версий ловится сам: если кто-то сделал go get и не запустил go mod vendor, сборка упадёт с «inconsistent vendoring». А вот vendor/ тихо разъезжается с go.mod, когда в vendor/ правят файлы руками: сборка молча берёт изменённый код, и go mod verify этого не заметит, он проверяет только кэш. Поэтому в CI обязательна проверка go mod vendor && git diff --exit-code
vendor/. Сам go сверяет с go.mod только vendor/modules.txt, а не код рядом с ним.
import cycle not allowed. Причины:
скорость компиляции (пакет собирается один раз, граф
топологически сортируется), детерминированный порядок
init() и качество дизайна. Разруливается тремя
приёмами, из которых главный —
интерфейс у потребителя.
Три причины запрета
- Компиляция. Каждый пакет компилируется ровно один раз, результат ложится в архив и переиспользуется. Ациклический граф даёт топологический порядок и параллельную сборку независимых веток. Циклы потребовали бы компилировать группу пакетов совместно, и любая правка пересобирала бы всю группу.
-
Инициализация. Порядок
init()задаёт топологическая сортировка. В цикле «кто первый» не определено. - Дизайн. Цикл почти всегда значит, что граница между пакетами проведена неправильно.
Четыре способа разорвать
| Приём | Когда подходит |
|---|---|
| Интерфейс у потребителя — объявить нужный контракт в том пакете, который его использует | Дефолт. Идиоматичный Go, попутно даёт тестируемость |
| Выделить общий пакет — вынести типы, нужные обоим, вниз по графу | Когда цикл из-за общих доменных типов |
Колбэк / DI — передать функцию, связывание в
main
|
Одна-две точки взаимодействия, интерфейс избыточен |
| Объединить пакеты | Когда они и правда об одном и том же и разделены искусственно |
// package order объявляет то, что ему нужно, и ничего не импортирует из user
type UserGetter interface {
Get(ctx context.Context, id int64) (User, error)
}
type Service struct {
users UserGetter
}
func New(users UserGetter) *Service { return &Service{users: users} }
func (s *Service) Create(ctx context.Context, uid int64) error {
u, err := s.users.Get(ctx, uid) // цикла нет: импорта user тут нет
...
}
// package user просто имеет подходящий метод, про интерфейс не знает
func (s *Store) Get(ctx context.Context, id int64) (order.User, error) { ... }
// package main — единственное место, где встречаются оба
svc := order.New(user.NewStore(db))
В Java интерфейс живёт рядом с реализацией, и потребитель импортирует пакет с интерфейсом. В Go реализация неявная: типу не нужно знать про интерфейс, чтобы ему удовлетворять. Поэтому интерфейс объявляют там, где его используют, и стрелка зависимости разворачивается: пакет-реализацию больше не импортируют вообще. Разом уходит цикл, интерфейс сужается до нужных методов, а в тестах его легко подменить. Короткая формулировка ходит в сообществе, в документации Go её нет: «Accept interfaces, return structs».
-
Схлопывать всё в общий пакет
models/на 200 типов: цикл уйдёт, модульность умрёт. -
Городить глобальный реестр с регистрацией через
init(): тот же цикл, только теперь во время выполнения и без проверки компилятором. -
Переходить на
anyи рефлексию, лишь бы не импортировать пакет.
foo_test.go с package foo входит в пакет foo, и запрет цикла на него распространяется. А файл с package foo_test (external test package) компилируется отдельным пакетом и может импортировать что угодно, включая тех, кто зависит от foo. Штатный способ проверить связку двух пакетов, не создавая цикл, и заодно ответ на вопрос «зачем вообще нужен package x_test».
.../X/internal/Y может
импортировать только код, чей путь начинается с
.../X — то есть из дерева, начинающегося с
родителя директории internal. Проверяет
тулчейн при сборке (команда go), а не соглашение и не линтер.
github.com/acme/app/
├── cmd/api/ → может импортировать internal/* (внутри acme/app)
├── pkg/client/ → может импортировать internal/* (внутри acme/app)
└── internal/
├── store/ ← родитель internal = github.com/acme/app
└── auth/
└── internal/jwt/ ← родитель = .../app/internal/auth
→ виден ТОЛЬКО из auth и его поддерева
# А так не скомпилируется:
# package github.com/other/svc
import "github.com/acme/app/internal/store"
# → use of internal package github.com/acme/app/internal/store not allowed
Что важно понимать
-
Уровень
internalможет быть любым и вложенным, поэтому внутри модуля строится настоящая иерархия видимости, а не один плоский «приватный» слой. -
Правило работает и в стандартной библиотеке, по тому же принципу:
net/http/internal,crypto/internal/.... -
Ограничение действует на уровне путей импорта, а не модулей. Если два модуля лежат в одном дереве путей,
internalмежду ними пропустит. Случай редкий, но в монорепо встречается. - Это единственный механизм видимости на уровне пакетов. Всё остальное сводится к заглавной или строчной букве идентификатора внутри одного пакета.
По умолчанию всё в internal/. Типичная структура: /cmd/<binary>/main.go на три строки, вся логика в /internal/..., а /pkg/... только для того, что ты намеренно публикуешь как API для чужих сервисов. Выгода: пока пакет в internal, его сигнатуры можно менять сколько угодно, не выпуская мажорную версию и не ломая чужой код. Сборка гарантирует, что снаружи на него никто не завязался.
«internal это как private». Не совсем: internal ограничивает импорт пакета, а не видимость идентификаторов. Внутри internal/store экспортированные имена так и остаются экспортированными, просто добраться до них может только код из того же дерева. И обратное: на рефлексию и линковку internal не влияет никак, это правило сборки для разрешения импортов.
init(), и только
потом main.main(). Каждый пакет
инициализируется ровно один раз.
Полный список правил
- Рантайм поднимается раньше всего: планировщик, аллокатор, GC, обработка сигналов.
- Импортируемые пакеты: рекурсивно и полностью, в порядке топологической сортировки графа импортов. Цикл невозможен, поэтому порядок всегда определён.
-
Переменные уровня пакета: в порядке зависимостей между ними. Порядок объявления в файле важен только между независимыми переменными: из готовых к инициализации берётся первая по объявлению. Цикл в зависимостях переменных даёт ошибку компиляции
initialization cycle. -
Все
init()пакета. Их может быть несколько в одном файле и несколько файлов. Порядок между файлами не гарантирован спецификацией, ноgo buildпередаёт файлы отсортированными по имени. -
init()нельзя вызвать вручную, нельзя взять его адрес, у него нет параметров и результата. -
Только после инициализации всех пакетов запускается
main.main().
package main
var a = b + 1 // третьей: зависит от b
var b = f() // второй: зависит от c через f()
func f() int { return c }
var c = 1 // первой: ни от чего не зависит
func init() { fmt.Println("init 1", a, b, c) }
func init() { fmt.Println("init 2") } // обе выполнятся, сверху вниз
func main() { fmt.Println("main", a) }
// вывод:
// init 1 2 1 1 (c = 1 → b = f() = 1 → a = b+1 = 2)
// init 2
// main 2
Чем плохи init()
-
Нельзя вернуть ошибку. На сбой остаётся только паника или
log.Fatal: падение на старте без контекста и без выполнения defer-ов. -
Нельзя передать параметры. Конфиг берётся из глобальных переменных или прямо из
os.Getenv, и зависимость становится скрытой. -
Ломают тесты.
initвыполняется при любом запуске тестов пакета. Пакет, который вinitходит в базу или читает файл, делает свои тесты нелокальными и нестабильными. -
Порядок неочевиден. Читая код, ты не видишь, что до него отработали три
initиз зависимостей. -
Транзитивные побочные эффекты. Классика: кто-то в глубине зависимостей импортировал
net/http/pprof, и твой публичный сервер на дефолтном мультиплексоре отдаёт/debug/pprof/наружу. -
Замедляют старт. Всё это происходит до
mainи до готовности readiness-пробы.
// Плохо
var db *sql.DB
func init() {
var err error
db, err = sql.Open("postgres", os.Getenv("DSN"))
if err != nil { log.Fatal(err) }
}
// Хорошо: явный конструктор, ошибка возвращается, вызывается из main
func NewDB(ctx context.Context, dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil { return nil, fmt.Errorf("open db: %w", err) }
if err := db.PingContext(ctx); err != nil { return nil, fmt.Errorf("ping db: %w", err) }
return db, nil
}
// Дорогой объект создают лениво через sync.Once, а не в init
var Client = sync.OnceValue(buildClient) // Go 1.21
init уместен
Три случая: регистрация в реестре (драйверы database/sql, кодеки image и encoding); «вычисляемые константы», которые нельзя выразить литералом (regexp.MustCompile, template.Must); проверка инвариантов сборки, например что таблица переходов заполнена целиком. Общее у них: работа детерминированная, локальная, без внешнего мира, то есть ни сети, ни файлов, ни базы, ни переменных окружения. И отдельно про import _ "...": единственная идиома, где init не костыль, а сам смысл импорта.
go vet — встроенный консервативный
анализатор (часть проверок запускается сама при go test);
staticcheck — внешний глубокий анализатор на
~150 проверок; golangci-lint — не линтер, а
раннер десятков линтеров с общим разбором AST и
кэшем.
| Инструмент | Роль | Примеры находок |
|---|---|---|
gofmt |
Канонический формат, не настраивается | Убирает споры о стиле из ревью как класс |
goimports |
gofmt + импорты: добавить, убрать,
сгруппировать
|
Забытые и лишние импорты |
go vet |
Встроенный, мало ложных срабатываний. Часть проверок
запускается автоматически при go test
|
Несовпадение Printf-формата и
аргументов, копирование структуры с мьютексом,
потерянный cancel, недостижимый код,
теги структур с опечаткой
|
staticcheck |
Внешний, самый глубокий одиночный анализатор | Мёртвый код, бесполезные присваивания, неверные сравнения, использование deprecated API, неэффективные конструкции |
golangci-lint |
Раннер: запускает vet, staticcheck и ещё десятки, переиспользуя один разбор | Всё вышеперечисленное + errcheck, revive, gosec, bodyclose, errorlint |
go generate |
Выполняет команды из //go:generate.
Не запускается при go build
|
mockgen, stringer, protoc, sqlc, ent |
govulncheck |
CVE с проверкой достижимости уязвимой функции из вашего кода | Отсеивает шум «уязвимость есть, но вы её не вызываете» |
# Минимальный набор в CI
gofmt -l . # пусто = ок
go vet ./...
go test -race -count=1 ./...
go mod tidy -diff # Go 1.23
golangci-lint run --timeout=5m
govulncheck ./...
//go:generate mockgen -source=$GOFILE -destination=mocks/store_mock.go -package=mocks
//go:generate stringer -type=Status -linecomment
type Status int
const (
StatusNew Status = iota // new
StatusPaid // paid
)
// Go 1.24: инструменты фиксируются прямо в go.mod
// go get -tool golang.org/x/tools/cmd/stringer
// go tool stringer -type=Status
// Раньше нужен был файл tools.go с build-тегом //go:build tools
// и пустыми импортами, только чтобы версии инструментов попали в go.mod.
gofmt
Главное, что он не настраивается, и это фича, а не недоработка. Один формат на всю экосистему: любой чужой код читается без адаптации, ревью не тратится на стиль, а инструменты (переписывалки, рефакторинги, go fix) безопасно меняют код. Отсюда и цитата из Go proverbs: «Gofmt's style is no one's favorite, yet gofmt is everyone's favorite». В CI проверка формата должна падать, а не предупреждать.
//go:build или суффиксом в
имени файла. Кросс-компиляция — это две переменные окружения
GOOS/GOARCH и ничего больше, пока
выключен CGO.
//go:build linux && amd64 && !nocgo
package storage
// Синтаксис с Go 1.17; старая форма // +build до сих пор встречается в чужом коде.
// Строка обязана идти до package; пустую строку после неё gofmt поставит сам.
// Поддерживает && || ! и скобки.
# Неявные теги по имени файла работают без строки //go:build
store_linux.go # GOOS=linux (и android)
store_windows_amd64.go # GOOS=windows и GOARCH=amd64
store_test.go # только при go test
# Свои теги
go build -tags="integration,e2e" ./...
go test -tags=integration ./...
# Кросс-компиляция
GOOS=linux GOARCH=amd64 go build -o app .
GOOS=darwin GOARCH=arm64 go build -o app-mac .
GOOS=windows GOARCH=amd64 go build -o app.exe .
go tool dist list # все поддерживаемые пары
# Прод-сборка контейнерного бинаря
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -trimpath -ldflags="-s -w -X main.version=$(git describe --tags)" -o /app .
| Флаг | Что делает | Зачем в проде |
|---|---|---|
CGO_ENABLED=0 |
Отключает CGO, бинарь статический |
Работает в scratch/distroless
|
-trimpath |
Убирает абсолютные пути сборки |
Воспроизводимость; в трейсах не течёт
/Users/...
|
-ldflags="-s -w" |
Выкидывает таблицу символов и DWARF | Минус ~30% размера. Но ломает читаемость стека в отладчике |
-ldflags="-X pkg.Var=val" |
Вшивает строку в переменную при линковке | Версия, коммит, дата сборки без правки кода |
-pgo=default.pgo |
Profile-guided optimization | 2–14% CPU бесплатно, если есть профиль с прода |
-buildvcs=false |
Не вшивать данные VCS | Сборка в контейнере, где git падает на чужом .git |
Ровно в одном месте, на CGO. С CGO_ENABLED=1 нужен C-компилятор и заголовки под целевую платформу, то есть полноценный кросс-тулчейн (zig cc, musl-cross, сборка в контейнере целевой платформы). Поэтому в 99% случаев ответ звучит так: «выключаю CGO, и кросс-компиляция становится одной строкой». Второй по частоте сюрприз: с CGO_ENABLED=0 работает чистый Go-резолвер DNS, который игнорирует часть настроек nsswitch.conf. В Kubernetes это обычно не проблема, в гибридных корпоративных сетях иногда всплывает.
Поставить //go:build integration в файлы, которым нужна база или брокер. Тогда go test ./... в обычном прогоне их вообще не видит (они не компилируются), а в ночном пайплайне идёт go test -tags=integration ./.... Выигрыш перед testing.Short() в том, что отсечение происходит на уровне компиляции: unit-прогон не тянет зависимости интеграционных тестов вообще.
/*
#cgo LDFLAGS: -lz
#include <zlib.h>
*/
import "C" // псевдопакет: строка import "C" должна идти сразу за комментарием
func Version() string {
return C.GoString(C.zlibVersion()) // конверсии C.GoString / C.CString
}
// C.CString выделяет память malloc'ом, освобождать её надо вручную:
// p := C.CString(s); defer C.free(unsafe.Pointer(p))
Цена — по пунктам
| Что ломается | Детали |
|---|---|
| Кросс-компиляция | Нужен C-тулчейн под каждую целевую платформу вместо двух переменных окружения |
| Статическая линковка |
Бинарь тянет glibc/musl → scratch-образ
не работает, появляется зависимость от базового
образа
|
| Производительность вызова | Каждый переход Go→C переводит горутину в состояние syscall (затянется вызов — sysmon отберёт у неё P) и переключает на системный стек. Порядок — десятки наносекунд против единиц у обычного вызова. В горячем цикле это разница в разы |
| Память и GC | Всё, что выделил C, GC не видит и не собирает. Правила передачи указателей (cgo pointer passing rules) жёсткие, нарушение ловится не всегда |
| Надёжность |
Segfault в C кладёт весь процесс,
recover тут бессилен. Стек C не растёт
как горутинный — переполнение возможно
|
| Инструменты |
-race не видит гонки внутри C,
профилировщик хуже показывает C-стеки, отладка
сложнее
|
| Сборка | Медленнее, Dockerfile сложнее, кэш хуже |
Когда CGO всё-таки нужен
- Библиотека существует только на C и переписывать её нереально: ffmpeg, OpenCV, librdkafka, ODBC-драйверы, аппаратные SDK.
- Системные API без Go-обёртки.
- Постепенная миграция legacy C-кодовой базы.
CGO_ENABLED=0 для сервисов идёт дефолтом. Если CGO действительно нужен, лучше изолировать его в отдельный бинарь или сайдкар, а не тащить в основной сервис. Два самых частых случая: mattn/go-sqlite3 требует CGO, но есть чистая замена modernc.org/sqlite; confluent-kafka-go тянет librdkafka, а segmentio/kafka-go и IBM/sarama написаны на чистом Go. Правило: прежде чем включать CGO, проверь, нет ли pure-Go альтернативы.
ServeMux; 1.23 — итераторы
range over func; 1.24 — Swiss Tables, generic
type aliases, tool в go.mod,
b.Loop; 1.25 — WaitGroup.Go и
container-aware GOMAXPROCS; 1.26 — Green Tea GC
по умолчанию, new от выражения,
errors.AsType; 1.27 — параметры типа у методов,
encoding/json/v2 отдельным пакетом,
uuid в stdlib.
Go 1.22 (февраль 2024)
-
Переменная цикла теперь своя на каждой итерации. Классическая ловушка с замыканиями и
&vисчезла. Включает её строкаgo 1.22вgo.mod: решает модуль, а не версия тулчейна, чтобы старый код не сломался молча. -
Паттерны в
http.ServeMux:"GET /users/{id}",r.PathValue("id"), многосегментный{path...}. Приоритет считается по специфичности, а конфликтующие паттерны паникуют при регистрации. -
for i := range 10: range по целому. -
math/rand/v2, первый/v2внутри самой stdlib;slices.Concat; PGO-сборки быстрее на 2–14%.
Go 1.23 (август 2024)
-
Итераторы:
rangeнад функцией + пакетiter(Seq[V],Seq2[K,V],Pull). Тело цикла становится колбэкомyield, аbreakпревращается вfalseизyield. -
Итераторные функции в
slices/maps:slices.Sorted(maps.Keys(m)),slices.All,slices.Values,slices.Collect. -
Таймеры переделаны: канал
Timer/Tickerтеперь без буфера, и неостановленные таймеры собираются GC, поэтому классическая утечкаtime.Afterвнутриselectв цикле больше не утекает. -
Пакет
uniqueдля интернирования значений.
Go 1.24 (февраль 2025)
-
Swiss Tables, новая реализация мап: группы слотов с control word, на amd64 поиск через SIMD. До 60% быстрее на микробенчмарках, около 1,5% по реальным приложениям, меньше памяти. Для кода прозрачно. Плюс новая реализация
sync.Mapи аллокатор мелких объектов. -
Generic type aliases:
type Set[T comparable] = map[T]struct{}. -
tool-директива в go.mod и командаgo tool: хак сtools.goи build-тегом больше не нужен. -
testing.B.Loop:for b.Loop()вместоfor i := 0; i < b.N; i++. Setup не попадает в замер, а аргументы и результат защищены от выбрасывания оптимизатором. -
os.Root: операции с файлами, ограниченные деревом директории (защита от path traversal); пакетweak;runtime.AddCleanupвместоSetFinalizer.
Go 1.25 (август 2025)
-
WaitGroup.Go:wg.Go(func(){...})вместо связкиAdd(1)+go+defer Done(). Убирает целый класс ошибок с забытымDoneи рассинхроном счётчика. -
Container-aware
GOMAXPROCS: рантайм читает CPU-лимит cgroup и подстраивается динамически. Библиотекаautomaxprocs, которую раньше тащили в каждый сервис, стала не нужна. -
testing/synctest, стабильный пакет для детерминированных тестов конкурентного кода с «виртуальным» временем. -
Экспериментальные на тот момент: сборщик Green Tea (
GOEXPERIMENT=greenteagc) иencoding/json/v2. Оба с тех пор стали дефолтными, см. 1.26 и 1.27. Плюс flight recorder вruntime/trace. -
net/http.CrossOriginProtection, штатная CSRF-защита на Fetch metadata (Sec-Fetch-Site), без токенов и кук.
Go 1.26 (февраль 2026)
-
Green Tea GC включён по умолчанию. Минус 10–40% накладных расходов сборщика, плюс примерно ещё 10% на свежих amd64 (Intel Ice Lake, AMD Zen 4+) за счёт векторных инструкций. Откат:
GOEXPERIMENT=nogreenteagc. -
Два изменения в самом языке. Первое:
newтеперь принимает выражение, а не один лишь тип:p := new(compute()),r := Req{Limit: new(50)}. Самописныйfunc ptr[T any](v T) *Tбольше не нужен. Второе: дженерик-тип может ссылаться на себя в собственном констрейнте:type Adder[A Adder[A]] interface{ Add(A) A }. -
errors.AsType[T](err) (T, bool), дженерик-версияerrors.As: типобезопасно, быстрее и без объявления переменной заранее. Плюсslog.NewMultiHandlerиio.ReadAllпримерно вдвое быстрее. -
Экспериментальный профиль
goroutineleak(GOEXPERIMENT=goroutineleakprofile) находит горутины, заблокированные на примитивах, до которых уже никто не дотянется. Вызовы cgo подешевели примерно на 30%. -
Мелочи, на которых ловят: редирект
ServeMuxна завершающий слэш стал 307 вместо 301;os/signal.NotifyContextотменяет контекст черезCancelCauseFunc, так чтоcontext.Causeпокажет, какой сигнал пришёл;go fixпереписан в набор «модернизаторов»;go mod initв 1.26.0 стал писать версию на минор ниже, но в 1.26.1 это откатили, и он снова пишет версию тулчейна целиком, вместе с патчем:go 1.27.1на go1.27.1.
Go 1.27 (август 2026) — текущая
-
У методов появились собственные параметры типа, то есть снято ограничение, которое годами называли главным недостатком дженериков в Go:
func (b *Box) MapTo[T any](f func(int) T) []T. Запрет остался только на стыке с интерфейсами: у метода интерфейса параметров типа быть не может, а дженерик-метод не засчитывается в реализацию интерфейса, иначе интерфейс нельзя проверить статически и собратьitab. Пример в stdlib: дженерик-методRand.N()вmath/rand/v2. -
encoding/json/v2стал отдельным пакетом, и на нём же теперь реализован старыйencoding/json(откат:GOEXPERIMENT=nojsonv2). Но поведение v1 сохранили специально: проверено на go1.27.0,json.Unmarshalпо-прежнему молча ест и{"a":1,"a":2}, и невалидный UTF-8. Строгость появляется только при явном импорте v2, поэтому апгрейд тулчейна ничего не ломает, а вот переход на v2 «сломает» приём кривого JSON, который раньше проглатывался. Unmarshal заметно быстрее. Рядом лежит низкоуровневыйencoding/json/jsontext; опции тегаformatиunknownубраны,inlineпереименован вembed. -
Пакет
uuidв стандартной библиотеке (RFC 9562):New(),NewV4(),NewV7(),Parse.NewV7сортируется по времени, ровно то, что нужно для первичных ключей в БД. Внешнийgithub.com/google/uuidбольше не обязателен. -
Профиль
goroutineleakстал общедоступным: виден вpprof.Profiles()и по/debug/pprof/goroutineleak. Штатный инструмент поиска утечек горутин в проде, а раньше обходилисьruntime.NumGoroutineиgoleakв тестах. -
Ещё из полезного:
strings.CutLast/bytes.CutLast;testing/synctest.Sleep;Server.MaxHeaderValueCountпротив заголовочного флуда; часть мелких аллокаций (<80 байт) дешевле до 30%; метки горутин изruntime/pprofпопадают в заголовки трейсбеков, и падение сразу показывает, какой запрос обрабатывала горутина. УдалёнGODEBUG=asynctimerchan: каналы таймеров теперь всегда небуферизованные.
Перечислять весь changelog не надо, это не проверяется. Достаточно назвать по 2–3 изменения, которые ты реально применил, и объяснить зачем: «в 1.22 убрали ловушку с переменной цикла, после апгрейда мы снесли из кода все v := v»; «в 1.23 появились итераторы, переписали обход пагинации, ушёл ручной курсор»; «в 1.25 GOMAXPROCS стал container-aware, выкинули automaxprocs из всех сервисов»; «в 1.27 перевели приём JSON на json/v2, а он отвергает дублирующиеся ключи, пришлось чинить одного поставщика данных». По такому ответу видно, что ты следишь за релизами и понимаешь, что они дают, а не заучил список.