Тема 01

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).

Аналогия: значение — квартира, указатель — бумажка с её адресом. Скопировав бумажку, ты получил вторую бумажку, но квартира осталась одна. Придёшь по любой из двух бумажек, попадёшь в одну и ту же квартиру, и следы чужого ремонта увидишь по обеим.

Обычная переменная — копируются данные a 5 копия b 5 два разных кусочка памяти b = 5 → в a по-прежнему 5 Указатель — копируется адрес p 0x1040 копия q 0x1040 кусочек по адресу 0x1040 5 *q = 9 → через p тоже видно 9: адрес-то один Единственный вопрос, который всё это решает Поменяю копию — увидит ли оригинал? Зависит только от того, что лежало в скопированных байтах: сами данные или адрес.
Копия всегда происходит. Разница в том, что именно скопировали. Скопировали число — получили два независимых числа. Скопировали адрес — получили два указателя на одну и ту же память.
Где вообще лежат эти кусочки памяти

В двух местах. Стек — память функции: заводится при входе в функцию, исчезает сама при выходе, дёшево. Куча — общая память программы: живёт, пока на неё кто-то ссылается, убирается сборщиком мусора, дороже. Компилятор сам решает, куда положить переменную. Пока достаточно знать, что куча — это «долгоживущая память, до которой можно дотянуться по адресу откуда угодно». Подробно разбираем в теме «Память, 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 всегда копирует значение; но обычно ссылочными называют слайсы, мапы, каналы, функции и указатели, потому что внутри у них лежит адрес, и копия видит те же данные».

структура — копия целиком a X: 1 Y: 2 b X: 1 Y: 2 копия 16 байт b.X = 1 → a.X == 1 слайс — копия заголовка s1 ptr len cap s2 ptr len cap массив в куче заголовки разные — массив общий s2[0] = 9 → s1[0] == 9 мапа — копия указателя m1 ptr m2 ptr hmap buckets, count… два имени — одна хеш-таблица m2["k"]=9 → видно в m1 Общее правило Копируется всегда значение. Вопрос лишь в том, что это значение содержит: сами данные (struct, array) — или дескриптор с указателем на данные (slice, map, chan, func, interface).
Присваивание в 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, но разный»

Слово 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
Конверсия ≠ type assertion

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
Чем опасен iota в проде

Значения привязаны к порядку строк. Вставили новую константу в середину — все последующие значения сдвинулись. Если эти числа попадают в БД, в 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 не переупорядочивает поля сам: порядок в исходнике = порядок в памяти.

Bad type Bad struct { a bool; b int64; c bool } → 24 байта a padding 7 Б b — int64 c padding 7 Б Good type Good struct { b int64; a bool; c bool } → 16 байт b — int64 a c padding 6 Б 0 8 16 24 Правило укладки: поля от «широких» к «узким». Экономия 33 % на структуре, которой в проде миллион экземпляров, — это реальные мегабайты и меньше работы GC. Проверяется через structlayout / fieldalignment из go vet.
Padding. Один и тот же набор полей занимает 24 или 16 байт, и решает всё порядок объявления. Линтер 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
Суть: в Go всё копируется по значению; «ссылочность» значит лишь, что внутри скопированного значения лежит указатель.

Четыре группы типов:

  • Базовые — 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 — для битовых операций и протоколов, а не для «неотрицательных чисел».

Суть: рантайм обнуляет всю выделяемую память, поэтому у любой переменной сразу есть значение. Дизайн-принцип — «make the zero value useful».

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
Rune ≠ «символ»

Руна — это code point, а не grapheme cluster. Флаг «🇷🇺» — две руны, «é» может быть одной руной (U+00E9) или двумя (e + U+0301). Для «символов как их видит человек» нужна итерация по графемным кластерам, например библиотекой rivo/uniseg, а golang.org/x/text/unicode/norm только приводит «é» к одной форме. На собесе достаточно этот нюанс упомянуть: видно, что ты понимаешь границы модели.

Суть: неявных приведений нет вообще. Цель — убрать скрытые потери данных и сделать код читаемым без знания таблицы промоушенов.

Единственное «неявное» поведение — нетипизированные константы, которые подстраиваются под контекст. Всё остальное требует T(v).

Аргументы, которые стоит назвать

  1. Читаемость. Глядя на int64(x) * y, ты сразу видишь и стоимость, и намерение. В C всё это прошло бы молча.
  2. Именованные типы как защита. type UserID int64 и type OrderID int64 нельзя перепутать, компилятор поймает. С неявными приведениями от этой защиты ничего не остаётся.
  3. Нет signed/unsigned сюрпризов. В C при сравнении int с unsigned первое продвигается в беззнаковое, отсюда классические уязвимости.
  4. Простота спецификации. Меньше правил — меньше расхождений между компиляторами и меньше кейсов на ревью.

Что конверсия всё же делает молча

// От константы такие конверсии не скомпилируются,
// компилятор ловит их ещё при сборке:
//   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, вставка новой строки в середину меняет смысл уже сохранённых данных. Для внешних контрактов значения задают явно.

Суть: нет неявного fallthrough, работает с любыми сравнимыми типами, может быть без выражения (замена if/else) и умеет ветвиться по типу.
  1. Нет провала. Тело case отработало, switch закончился. Явный fallthrough передаёт управление в тело следующего case, не проверяя его условие, и обязан быть последним оператором в case.
  2. break не нужен для выхода из case. Внутри цикла break выйдет из switch, а чтобы прервать цикл, нужна метка.
  3. Любые типы: строки, структуры (если comparable), не только целые.
  4. Несколько значений в одном case: case "a", "b":.
  5. Switch без выражения — эквивалент switch true, идиоматичная замена лестнице if / else if.
  6. Инициализатор: switch v, err := f(); {, область видимости ограничена switch.
  7. Type switch: switch v := i.(type) — только по интерфейсам. Внутри каждого case v получает свой конкретный тип; в case с несколькими типами v остаётся интерфейсом.
  8. Порядок проверки — сверху вниз, первый совпавший 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 все поля (нет слайсов, мап, функций). Сравнение идёт поле за полем. Порядок полей влияет на размер из-за выравнивания, и Go поля не переставляет.

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] уже паника).

Массив в памяти: arr := [8]int{10,20,30,40,50,60,70,80} 0 1 2 3 4 5 6 7 10 20 30 40 50 60 70 80 len = 3 — сюда можно индексировать cap = 5 — досюда можно нарезать и расти без аллокации недостижимо Заголовок: s := arr[1:4:6] array len cap ptr 3 5 low=1 high=4 max=6 len = high - low = 3 cap = max - low = 5 Переменная типа []int — это ровно эти 24 байта. Копирование слайса копирует их, массив остаётся один. Третий индекс (max) обрезает cap: без него было бы cap = 7, и append залез бы в чужие элементы 4 и 5.
Slice header и окно в массиве. 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 не метод и не обычная функция, а встроенная: компилятор разворачивает её вызов в инлайновый код. Логика такая:

  1. Посчитать нужную длину: newLen = len(s) + len(добавляемого).
  2. Если newLen <= cap(s), то ничего не выделять: записать элементы в существующий массив начиная с индекса len(s) и вернуть заголовок с новым len.
  3. Иначе вызвать runtime.growslice: посчитать новый cap, выделить новый массив, скопировать в него старые элементы (memmove), дописать новые, вернуть новый заголовок.

Из шага 2 растёт вся «магия» слайсов: при достаточном cap append молча пишет в память, которую видят и другие слайсы. Из шага 3 растёт другое: после роста связь с исходным массивом теряется.

cap хватает: newLen <= cap до: len=3 cap=4 1 2 3 пусто append(s, 9) после: len=4 cap=4 — тот же массив 1 2 3 9 Все слайсы, смотрящие на этот массив, увидят девятку. Это и есть источник «таинственно изменившихся» данных. cap не хватает: growslice до: len=4 cap=4 1 2 3 9 append(s, 7) → новый массив после: len=5 cap=8 — ДРУГОЙ массив 1 2 3 9 7 Старые элементы скопированы (memmove). Связь со старым массивом разорвана: записи в новый слайс старому не видны. Вывод один и тот же в обе стороны: результат append надо ВСЕГДА присваивать — s = append(s, v).
Две ветки append. Пока вместимости хватает, массив общий и правка видна всем. Не хватило, значит массив новый, а старые владельцы остались со старыми данными. Классические задачи про слайсы сводятся к выбору между этими двумя картинками.

Стратегия роста capacity

Считает её runtime.growslice в runtime/slice.go. Наивное «всегда x2» уже несколько лет неверный ответ. Алгоритм двухступенчатый, а сверху ещё накладывается округление до классов размеров аллокатора.

runtime.growslice: как считается newcap (Go 1.18+) newLen > 2 * oldCap ? newcap = newLen разовый большой append — берём ровно сколько надо да нет oldCap < 256 ? newcap = 2 * oldCap маленькие слайсы удваиваются, как раньше да нет newcap += (newcap + 3*256) / 4 повторять, пока newcap < newLen коэффициент плавно падает с 2x к 1.25x Последний шаг: roundupsize() newcap умножается на размер элемента и округляется вверх до ближайшего size class аллокатора (8, 16, 24, 32, 48, 64, 80, 96, 112, 128…). Поэтому реальный cap часто больше расчётного: append одного int к слайсу с cap=5 даст cap=8, а не 6 — так требует mallocgc.
growslice. Порог 256 элементов и формула + (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.

Плохо: small := big[:5] small len=5 cap=10M 9 999 995 элементов: недостижимы из кода, но живы для GC — примерно 80 МБ GC видит указатель внутрь массива → массив целиком остаётся в куче, пока жив small. Хорошо: small := slices.Clone(big[:5]) small len=5 cap=5 40 Б старый массив: ссылок больше нет → следующий цикл GC его заберёт Копия стоит O(k), но освобождает O(n). При k << n это всегда выгодно. Те же грабли: s = s[i:] в цикле (начало массива недостижимо, но живо), strings.Split большого файла, хранение sub := longString[10:20] в кэше, 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 и cap

Уменьшил 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
Суть: массив — значение фиксированного размера, длина входит в его тип. Слайс — заголовок из трёх полей (ptr/len/cap), описывающий окно в чужом массиве.
Массив [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. Нужен верхний предел.

Суть: по значению — копируется заголовок из 24 байт. Массив общий, поэтому изменения элементов видны снаружи, а изменения len/cap — нет.
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 меняют только локальную копию заголовка, которая умирает вместе с кадром функции.

Три способа отдать изменённый слайс наружу

  1. Вернуть его. Идиоматично, так устроен сам append: func add(s []int, v int) []int.
  2. Передать *[]T, когда возврат неудобен (пишешь интерфейс, заполняешь через колбэк).
  3. Передать буфер и вернуть срез его же: паттерн AppendXxx(dst []byte, ...) []byte из стандартной библиотеки (strconv.AppendInt, time.Time.AppendFormat). Вызывающий владеет памятью, аллокаций ноль.
Ответ одной фразой

«Слайс всегда передаётся по значению, но это значение содержит указатель. Поэтому функция становится совладельцем массива, но не владельцем длины».

Суть: новый массив выделяется, только когда len+k > cap. Стратегия: удвоение до 256 элементов, дальше cap += (cap + 3*256)/4 — плавный переход к 1.25x; сверху округление до size class аллокатора.

Алгоритм

  1. newLen = len(s) + количество добавляемых.
  2. Если newLen <= cap(s), то записать элементы на место и вернуть заголовок с новым len. Аллокаций нет, массив тот же, изменения видны всем совладельцам.
  3. Иначе runtime.growslice: посчитать newcap, вызвать mallocgc, memmove старых элементов, дописать новые. Старый массив станет мусором, если на него больше никто не смотрит.

Как менялась стратегия

Версия Правило
до Go 1.18 если cap < 1024cap*2, иначе cap*1.25 в цикле. Порог 1024, коэффициент прыгал разом с 2.0 до 1.25
Go 1.18+ если newLen > 2*oldCapnewcap = newLen; иначе если oldCap < 256newcap = 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 вызывающий обычно не контролирует и даже не видит. Один и тот же код в одном месте работает «правильно», а в другом молча портит чужие данные. Баг противный: с виду недетерминированный, а по механике железно определённый.

Как правильно писать такие функции

  1. Не мутировать вход без явного контракта. Нужна модификация, работай с копией: s = slices.Clone(in).
  2. Если функция обязана только дописывать, верни результат и обяжи вызывающего присвоить (s = f(s)), как делает сам append.
  3. Отдаёшь наружу подслайс, обрезай cap: return buf[:n:n] или slices.Clip.
  4. Документируй в комментарии: «функция может изменить элементы аргумента». Это часть контракта.
Добивающий вопрос

«А как проверить, что массив тот же?» Сравнить адреса первых элементов (&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 для настоящего связного списка или буферизованный канал, если это межгорутинная очередь. Умение это назвать отличает «знаю синтаксис» от «понимаю стоимость».

Суть: окно ползёт вправо, cap убывает, append периодически выделяет новый массив и копирует; при этом отрезанное начало старого массива недостижимо, но живо, пока жив слайс. Память пилообразно растёт, аллокации и копирования идут постоянно.

Что именно происходит

  1. s = s[1:] двигает array вперёд на один элемент и уменьшает len и cap. Освободившаяся ячейка в начале никуда не девается, она часть того же выделенного блока памяти.
  2. append расходует остаток cap. Через cap итераций запас кончается и вызывается growslice: аллокация нового массива и копирование len элементов.
  3. Старый массив после этого становится мусором целиком, но только в момент роста. До этого он живёт полностью, включая уже «удалённые» элементы в начале.
  4. Если элементы указатели, всё, на что они ссылались, тоже остаётся живым. Это утечка уже не 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.

Суть: GC освобождает объект целиком, поэтому подслайс из пяти элементов держит в памяти весь массив на миллион. Третий индекс задаёт 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
Суть: многомерных слайсов в Go нет — есть слайс слайсов. Каждая строка — это отдельный заголовок и, как правило, отдельный кусок памяти; строки могут быть разной длины.
// Правильно: 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 пар ключ-значение.

hmap — то, на что указывает переменная map[K]V count int flags uint8 B uint8 noverflow uint16 hash0 uint32 buckets ptr oldbuckets ptr nevacuate uintptr extra *mapextra count — len(m) B — 2^B бакетов hash0 — seed, свой у каждой мапы flags — в том числе hashWriting buckets: массив 2^B bucket 0 bucket 1 bucket 2 bucket 3 низкие B бит хеша выбирают бакет bmap — один бакет, 8 слотов tophash [8]uint8 — старший байт хеша 2e 91 0 a7 3c 0 0 0 keys [8]K — восемь ключей подряд values [8]V — восемь значений подряд overflow *bmap девятый ключ уедет в overflow-бакет Ключи и значения лежат отдельными блоками — чтобы не тратить padding между K и V. Поиск: hash(key, hash0) → низкие B бит дают бакет → сравниваем tophash всех 8 слотов (быстрый фильтр по 1 байту) → только для совпавших сравниваем сам ключ полностью → нет совпадения, идём в overflow-бакет по цепочке.
hmap и bmap. 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, а каждая вставка/удаление эвакуирует один-два бакета. Пока эвакуация идёт, чтение проверяет оба массива.

Рост B=2 → B=3: oldbuckets жив, элементы переезжают порциями oldbuckets (4 шт.) 0 — переехал 1 2 3 nevacuate=1 buckets (8 шт.) 0 (X) 1 2 3 4 (Y) 5 6 7 бит B старого хеша = 0 → X бит B = 1 → Y (i + 2^oldB) Каждая запись или удаление эвакуирует один-два старых бакета. Чтение при этом ищет и в oldbuckets, и в buckets. Пока идёт рост, память занята обоими массивами. Триггеры роста: count > 6.5 * 2^B (удвоение) — либо слишком много overflow-бакетов при нормальном count (sameSizeGrow: тот же размер, но плотная перекладка — так лечится «дырявая» мапа после массовых удалений).
Инкрементальный рост. Старый бакет i расщепляется ровно на два новых, i и i + 2^oldB: добавился ещё один бит хеша. Из-за этого вставка в мапу и получается амортизированной O(1), а не гарантированной.

Go 1.24: Swiss Tables

В Go 1.24 мапу переписали целиком: вместо бакетов с overflow-цепочками пришёл Swiss Table, дизайн из absl::flat_hash_map (Google, 2017). Идея одна: отделить метаданные от данных, чтобы за одно обращение к памяти проверять сразу восемь слотов.

Swiss Table: хеш делится на две части h1 — старшие 57 бит выбирают группу (и последовательность проб) h2 — младшие 7 бит это control-байт слота control word группы: 8 байт, читается ОДНИМ машинным словом 0x2e 0x91 EMPTY 0x2e 0xa7 DELETED EMPTY EMPTY слоты группы: ключи и значения k0 v0 k1 v1 k3 v3 k4 v4 Ищем ключ с h2 = 0x2e: одна операция сравнивает все 8 control-байт разом и даёт битовую маску {0, 3}. Полное сравнение ключей делаем только для этих двух слотов — остальные шесть даже не читаем из памяти. EMPTY в группе означает «дальше искать бессмысленно» — поиск отсутствующего ключа заканчивается сразу. DELETED — надгробие: слот свободен для вставки, но поиск через него продолжается (иначе порвалась бы цепочка проб). Нет overflow-бакетов и нет указателей между ними: группы лежат подряд в одном массиве — меньше промахов кэша.
Swiss Table. Трюк в том, что 8 однобайтовых меток укладываются в одно 64-битное слово (на amd64 — в SSE-регистр), и группа отфильтровывается буквально парой инструкций без ветвлений.
Старая мапа (≤ Go 1.23) Swiss Tables (Go 1.24+)
Разрешение коллизий цепочки overflow-бакетов открытая адресация, квадратичный пробинг по группам
Метаданные tophash [8]uint8 внутри бакета control word: 8 байт отдельно от данных
Проверка группы цикл по 8 слотам одно слово / SIMD (одна инструкция процессора обрабатывает сразу несколько значений), битовая маска совпадений
Большие мапы один массив бакетов + инкрементальная эвакуация разбита на независимые таблицы (directory), растёт по одной
Выигрыш чтение больших мап до ~30–60 % быстрее, вставка ~30 %, память примерно та же
Что важно сказать про 1.24 на собесе

Изменилась только реализация: язык, семантика и гарантии те же. Порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, &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 не выполняются, процесс умирает целиком с дампом всех горутин. Логика простая: структуру данных уже могло порвать, дальше выполнять небезопасно, лучше упасть громко и сразу.

Детектор конкурентной записи: флаг hashWriting в hmap.flags G1: m[k]=v G2: m[k2]=v2 hmap.flags mapassign: пишет в бакет set hashWriting clear mapassign видит флаг = 1 throw("concurrent map writes") — процесс мёртв flags |= hashWriting → 0 после выхода Детектор best-effort: он ловит пересечение окон записи, а не любую гонку. Две записи, разошедшиеся по времени, он не заметит — но данные всё равно испорчены. Настоящий инструмент — go test -race / go build -race.
hashWriting. Флаг стоит только на время самой операции записи, поэтому падение выходит вероятностным. Если тесты не поймали 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 нет лишних
Что внутри sync.Map

До 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, и в первую нельзя писать.

Вопросы

12
Суть: переменная map[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.

Поиск

  1. Считаем h = hash(key, hash0).
  2. Низкие B бит дают номер бакета.
  3. Старший байт хеша (tophash) сравниваем с восемью байтами в бакете. Дешёвый фильтр: сами ключи он не трогает.
  4. Только для совпавших tophash сравниваем ключ полностью.
  5. Не нашли, идём по цепочке 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.

Суть: бакеты с overflow-цепочками заменены на открытую адресацию с группами по 8 слотов и отдельным control word — метаданные фильтруются одним машинным словом.

Что именно поменялось

  • Хеш делится на 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 ./...
Заодно в 1.24

Переписали sync.Map: теперь под ним HashTrieMap вместо пары read/dirty. Появились generic type aliases. Добавили testing.B.Loop(), он закрывает проблему «компилятор выбросил тело бенчмарка». Если спрашивают «что нового в Go», Swiss Tables и есть самый заметный пункт 1.24.

Суть: хеш отображает ключ любого размера в фиксированное число; коллизия — совпадение номера бакета у разных ключей; Go разрешает их бакетами по 8 слотов с overflow-цепочкой (до 1.24) или открытой адресацией по группам (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()
Про sync.Map по версиям

До Go 1.24 это была пара мап: атомарно читаемая read и защищённая мьютексом dirty; когда промахов накапливалось много, dirty целиком становилась новой read, отсюда и провал на потоке новых ключей. В Go 1.24 внутренность заменили на HashTrieMap, и эта патология ушла. Рассказываешь про read/dirty, обязательно скажи «до 1.24», иначе это выглядит как устаревшие знания.

Суть: ключ должен быть comparable. Структура — да, если comparable все её поля. Слайс, мапа, функция — нет, ошибка компиляции.

Список

Тип Ключ? Комментарий
числа, 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
NaN — вопрос-добивка про float-ключи

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 строки собираются, но если они были подстроками одного большого буфера, держится весь буфер (см. главу про строки).
  • Мапа как очередь: пишем тысячи ключей в секунду, удаляем сразу, а бакеты вырастают до пикового размера и остаются.

Что делать

  1. Периодически пересоздавать: скопировать живые ключи в новую мапу нужного размера (make(map[K]V, len(old))) и заменить.
  2. Взять кэш с вытеснением (LRU) вместо голой мапы.
  3. Шардировать и пересоздавать шарды по очереди, тогда паузы копирования размажутся.
  4. Хранить map[K]*V, если тяжёлые именно значения.
Формулировка на собес

«Мапа в Go растёт, но не сжимается. delete и clear освобождают значения для сборщика, но массив бакетов остаётся раздутым до смерти самой мапы. Это же верно и для слайса: s = s[:0] не уменьшает cap.» Такую параллель ценят: видно, что тему понимаешь, а не вызубрил.

Суть: отдельного типа set в стандартной библиотеке нет; идиома — 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 элементов) линейный поиск по слайсу быстрее мапы за счёт кэша.

Суть: средняя O(1) на поиск/вставку/удаление, амортизированная для вставки; худшая — O(n). Деградация: коллизии, разреженность после удалений, огромные значения, рост без преаллокации.
Операция Средняя Худшая Почему худшая такая
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

Четыре сценария деградации

  1. Коллизии. В обычной жизни их мало благодаря хорошему хешу, но специально подобранные ключи (или очень плохой самописный хеш в другой реализации) дают O(n). В Go от внешней атаки защищает случайный seed, поэтому подобрать коллизии снаружи нельзя.
  2. Разреженность. Положили миллион, удалили 990 тысяч, а бакетов по-прежнему миллион. range проходит по всем, локальность кэша плохая.
  3. Тяжёлые значения. map[string][4096]byte: каждое чтение и запись копирует 4 КБ, бакет огромный, кэш не работает. Решение: map[string]*T.
  4. Нет преаллокации. Насыпать 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 в общем случае копирует байты, потому что у типов разный контракт на изменяемость, а не разная раскладка данных.

Заголовок строки и общий байтовый буфер s := "Привет мир" str ptr len = 19 sub := s[0:12] str ptr len = 12 байтовый буфер (read-only секция или куча) П р и в е т (12 байт) пробел + мир подстрока НЕ копирует: тот же указатель, другая длина поэтому весь буфер жив, пока жива подстрока Конверсия в []byte — копия, потому что срез изменяем b := []byte(s) ptr len 19 cap 24 НОВЫЙ буфер в куче — независимая копия 19 байт b[0] = 'X' не трогает s. Обратно: string(b) — тоже копия. Ровно поэтому конверсии в горячем пути видны в профиле аллокаций.
Строка, подстрока и срез. Слайсинг строки бесплатен и разделяет буфер; конверсия в []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♥8" — байты, индексы, руны байты (len(s) == 8) 47 6f e2 99 a5 f0 9f 98 80 0 1 2 3 4 5 6 7 8 байтовые индексы руны (RuneCountInString == 4) G o U+2665 (3 байта) U+1F600 (4 байта) for i, r := range s → i принимает значения 0, 1, 2, 5 — это БАЙТОВЫЕ смещения начал рун s[2] == 0xe2 — байт, а не символ. fmt.Printf("%c", s[2]) напечатает мусор []rune(s) — новый массив из 4 значений int32 = 16 байт, аллокация len — байты · RuneCountInString — кодовые точки · «символы как их видит человек» — вообще третье число
Три разные длины одной строки. Восемь байт, четыре руны, четыре графемы. Для эмодзи-флагов и букв с диакритикой третье число снова разойдётся со вторым.
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]))              // "♥"
Три ошибки, которые ловят на не-ASCII
  • 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), iint не «число в строку»! это кодовая точка 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
unsafe-конверсии: когда это законно

С 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.

s += x в цикле: каждая итерация — новый буфер и копия всего накопленного итерация 1: аллок 4 Б, копируем 4 Б итерация 2: аллок 8 Б, копируем 8 Б — первый буфер стал мусором итерация 3: аллок 12 Б, копируем 12 Б итерация 4: аллок 16 Б, копируем 16 Б всего скопировано 4+8+12+16 = O(n²) байт, создано n мусорных буферов strings.Builder: один растущий буфер, удвоение ёмкости, ноль копий строки cap 8: пишем, места хватает cap 16: одно удвоение с копией — как у append амортизированная O(n) по времени, log(n) аллокаций; String() отдаёт буфер БЕЗ копии
Плюс против Builder. Внутри Builder держит []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 оставь для настоящего форматирования и не тащи в горячий цикл.
Почему Builder нельзя копировать

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

Три причины. Скорость: 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-анализ решил, что значение не утекает.

Зачем иммутабельность

  1. Подстрока без копии. s[a:b] отдаёт новый заголовок на тот же буфер: O(1), ноль аллокаций. Будь строки изменяемыми, так было бы небезопасно.
  2. Конкурентность. Неизменяемое значение читается любым числом горутин без синхронизации. Это единственный «составной» тип Go с такой гарантией.
  3. Ключи мапы. Хеш считается один раз и не может «протухнуть» из-за изменения содержимого.
  4. Дешёвая передача. Копия строки весит 16 байт независимо от длины данных.
  5. Интернирование литералов. Компилятор кладёт одинаковые литералы в одну 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; их применяют в парсерах, где известно, что буфер сразу выбрасывается.»

Суть: любая конверсия между этими тремя — O(n) с аллокацией, потому что у них разный контракт на изменяемость и разное представление. Исключения — несколько частных случаев, которые оптимизирует компилятор.
Тип Заголовок Изменяемость Элемент
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(число)

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 и []rune

[]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 ловится паникой). На мегабайтной строке одна лишняя копия чувствуется.

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.

Суть: подстрока — это новый заголовок на тот же буфер. Пока жива десятибайтовая подстрока, стомегабайтный буфер не может быть собран GC.
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

  1. Аллокации. ToLower создаёт две новые строки; EqualFold идёт по обеим строкам параллельно и не аллоцирует вообще.
  2. Ранний выход. На первом различии сразу false, остаток не читается.
  3. Корректность. 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.HasPrefixs[: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. Так разворачивается зависимость: интерфейс не привязан к реализации, а реализация ничего не знает об интерфейсе.

Почему в Go нет наследования: аргументы авторов
  • Хрупкий базовый класс. Тронешь базовый класс, и наследники сломаются неочевидным образом; на глубокой иерархии трассировка вызова превращается в квест.
  • Наследование связывает намертво. Подкласс зависит от реализации родителя, а не от одного лишь контракта. Сильнее связности не бывает.
  • Ромбовидная проблема. Множественное наследование заставляет придумывать правила разрешения конфликтов (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, его receiverAnimal. Внутри a.Speak() вызывает метод Animal, потому что a — это Animal, и о существовании Dog он не знает и знать не может. В Java/C++ здесь сработал бы полиморфизм и напечаталось бы «гавкает». Встраивание не даёт виртуальных методов. Хочешь такое поведение, объявляй интерфейс и передавай зависимость явно.

Встраивание — это обычное поле без имени Dog в памяти Animal{ Name string } Breed string поля лежат подряд, как у любой структуры; никакого указателя на «родителя» нет Таблица методов Dog (набор имён) d.Name d.Animal.Name продвинуто d.Describe() Animal.Describe(d.Animal) d.Speak() Dog.Speak(d) перекрыт d.Animal.Speak() Animal.Speak(d.Animal) явно Внутри Animal.Describe вызов a.Speak() — это ВСЕГДА Animal.Speak: виртуальности нет. Правила разрешения имени 1. Глубина побеждает: имя на внешнем уровне закрывает такое же имя на глубине 1 и ниже. 2. Ничья на одной глубине = ошибка компиляции «ambiguous selector» — но только при обращении к имени. 3. Встраивать можно и указатель (*Animal), и интерфейс — тогда продвигается его method set.
Механика встраивания. Компилятор просто дописывает переходники: 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

Интерфейсное значение — всегда два машинных слова. Что именно в них лежит, зависит от того, пустой интерфейс или нет.

eface — пустой интерфейс (any): 2 слова _type *rtype data unsafe.Pointer runtime._type size, kind, hash, equal сами данные в куче или inline iface — непустой интерфейс: 2 слова tab *itab data unsafe.Pointer itab inter *interfacetype _type *rtype hash uint32 fun [N]uintptr указатели на методы сами данные *T или копия T fun[0] → метод 1 fun[1] → метод 2 Вызов метода через интерфейс: i.Foo() 1. взять i.tab 2. fun[индекс метода] 3. вызвать с i.data inline невозможен itab кэшируется в глобальной хеш-таблице по паре (интерфейс, конкретный тип): создаётся один раз на пару. Статические конверсии компилятор резолвит на этапе компиляции — itab лежит прямо в бинаре. Цена интерфейса: косвенный вызов, отсутствие инлайна, часто — аллокация при упаковке значения.
eface и iface. У пустого интерфейса первое слово — просто дескриптор типа, у непустого — 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».

Та же картинка, только схемой.

var err error = (*MyError)(nil) интерфейсное значение err tab *itab(error, *MyError) data nil тип ЕСТЬ → интерфейс не nil настоящий nil-интерфейс: var err error tab = nil data = nil обе половинки nil → err == nil даёт true Правило сравнения i == nil ⟺ i.tab == nil И i.data == nil. Сравнение двух интерфейсов: равны типы И равны значения. Поэтому (*MyError)(nil) != nil, хотя внутри лежит нулевой указатель. Проверка внутри метода — тоже не помогает: метод вызовется на nil-receiver'е и, если он не разыменовывает поля, отработает штатно.
Две половинки. Пока в левом слоте лежит дескриптор типа, интерфейс не равен 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
Три подвоха в type switch
  • Несколько типов в одном 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.
type T struct{}; func (t T) Val(); func (t *T) Ptr() method set T = { Val } Val() — value recv Ptr() — НЕ входит var t T t.Ptr() КОМПИЛИРУЕТСЯ: сахар для (&t).Ptr(), t адресуема но var i I = t — ОШИБКА, если I требует Ptr() method set *T = { Val, Ptr } Val() — входит Ptr() — входит var p *T = &t p.Val() работает: сахар для (*p).Val() var i I = p — ОК для любого I Почему асимметрия Из *T всегда можно получить T — разыменовать. Из T получить *T можно, только если значение АДРЕСУЕМО. Копия в интерфейсе — не адресуема, брать её адрес было бы бессмысленно. Если бы разрешили: изменение через Ptr() ушло бы в копию внутри интерфейса и потерялось молча. Go предпочитает ошибку компиляции вместо тихо теряющейся мутации.
Правило method set. Компилятор запрещает не вызов метода, а удовлетворение интерфейса: 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 принято наоборот: интерфейс живёт в пакете, который его использует. Это возможно именно из-за неявной реализации.

Интерфейс у РЕАЛИЗАЦИИ — как в Java service использует Storage package storage interface Storage + *Postgres import service ЗАВИСИТ от пакета с конкретной реализацией Интерфейс у ПОТРЕБИТЕЛЯ — как в Go package service type Storage interface { Get(id) (User, error) } package postgres type Store struct{...} func (s *Store) Get(...) реализует неявно импорта НЕТ ни в одну сторону Связывает их только main/wire: postgres.New(db) передаётся в service.New(store). Это инверсия зависимостей по Мартину — но без единой строки boilerplate, потому что реализация интерфейса структурная.
Направление зависимости. Интерфейс у потребителя означает, что доменный пакет вообще не знает о существовании базы, драйвера и HTTP. Знает только про свои три метода.
// 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) — пару «тип + данные». Три закона рефлексии (из блога Роба Пайка):

  1. Из интерфейсного значения можно получить reflect.Value.
  2. Из reflect.Value можно вернуться к интерфейсному значению (.Interface()).
  3. Чтобы менять значение через рефлексию, оно должно быть 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
Суть: в Go есть инкапсуляция (регистр буквы) и полиморфизм (интерфейсы с неявной реализацией), но нет классов и нет наследования — вместо него композиция через встраивание.

Что есть

  • Данные и поведение отдельно: struct хранит состояние, методы объявляются на уровне пакета с указанием receiver'а. Метод можно добавить любому именованному типу своего пакета, включая type Celsius float64.
  • Инкапсуляция на уровне пакета: регистр первой буквы. Внутри пакета видно всё.
  • Полиморфизм: интерфейсы. Реализация неявная: методы совпали, тип подходит.
  • Композиция: встраивание типов с продвижением методов.

Чего нет и чем заменено

Нет Вместо этого
класс struct + методы пакета
наследование реализации встраивание (композиция)
виртуальные методы / super интерфейс + явная делегация
абстрактный класс интерфейс + встроенная структура с общим кодом
конструктор NewT() по соглашению; полезное нулевое значение
перегрузка методов разные имена, вариативные аргументы, функциональные опции
исключения error как обычное значение

Почему авторы отказались от наследования

  1. Хрупкий базовый класс. Поправил родителя, и наследники сломались, причём неочевидно; в глубокой иерархии ещё попробуй проследить вызов.
  2. Наследование — самая сильная связность. Подкласс зависит от реализации родителя, а не от одного контракта.
  3. Ромб. Множественное наследование заставляет придумывать правила разрешения конфликтов; Go объявляет неоднозначность ошибкой компиляции.
  4. Композиция гибче. Поведение меняешь подстановкой зависимости, а не переопределением метода.
Как это звучит на собесе

«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. Побеждает меньшая глубина. Метод внешнего типа закрывает продвинутый с глубины 1 и ниже.
  2. Ничья на одной глубине — ошибка ambiguous selector, но только в момент обращения к имени. Просто встроить два типа с одинаковым методом можно.
  3. Встраивать можно 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       // переменной длины: адреса методов
}

Как происходит вызов

  1. Берём i.tab.
  2. Берём tab.fun[k], где k — индекс метода, известный на этапе компиляции (методы в itab отсортированы по имени).
  3. Вызываем по этому адресу, передав 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, а ветка ошибки выполняется.

Три источника в реальном коде

  1. Функция возвращает конкретный тип ошибки и результат присваивают в error: func parse() *ParseErrorvar err error = parse().
  2. Именованное возвращаемое значение конкретного типа плюс defer, который его трогает.
  3. Поле-интерфейс в структуре, куда кладут указатель, который может быть 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.
Суть: в Go нет 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.
  • Плагины и динамические данные: разбор конфигов неизвестной схемы.

Чем плох

  1. Нет проверки типов. Ошибка переезжает из компиляции в рантайм: паника при assertion или тихо неверная ветка switch.
  2. Аллокации. Упаковка значения в интерфейс обычно уводит его в кучу.
  3. Читаемость. Сигнатура func Process(v any) any не говорит ничего.
  4. Заражает код. Один any в глубине заставляет писать assertion'ы на всех уровнях выше.
  5. Ключи мап и сравнения начинают паниковать на не-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"), и проверка молча перестаёт работать после первого же оборачивания.

Суть: method set 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 — если метод меняет получателя, структура большая или внутри есть мьютекс. Value — для маленьких неизменяемых типов. Главное — не смешивать у одного типа.
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.

Суть: видимость определяется регистром первой буквы и работает на уровне пакета: заглавная — видно снаружи пакета, строчная — только внутри. Промежуточных уровней (protected, friend) нет.
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-версии модуля.

Суть: параметр-интерфейс делает функцию максимально подставляемой и тестируемой; возврат конкретного типа не скрывает от вызывающего возможности и не создаёт typed nil.

Почему принимать интерфейс

  • Функция объявляет минимум требований: 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 { }                       // прибили к файлу

Когда правило нарушают осознанно

  1. Фабрика с несколькими реализациями: func NewCache(cfg) Cache возвращает то Redis, то in-memory, так что тип известен только в рантайме.
  2. Конкретный тип принципиально приватный — тогда наружу отдают интерфейс.
  3. Стандартная библиотека так делает: net.Listen отдаёт net.Listener, sql.DB.Begin*sql.Tx (тут как раз структура). То есть даже stdlib выбирает по ситуации.
Антипаттерн, который сразу видно

Пакет объявляет интерфейс Storage с 15 методами рядом со своей единственной реализацией и возвращает его из конструктора «чтобы можно было замокать». В итоге: интерфейс дублирует тип один-в-один, мок на 15 методов, вызывающий не видит новых методов, а тестируемость не выросла. Правильно так: интерфейс на 1–3 метода в пакете-потребителе, а конструктор возвращает *Store.

Суть: у потребителя. Это возможно благодаря неявной реализации и даёт инверсию зависимостей без единой строки boilerplate.
// 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))

Что это даёт

  1. Правильное направление зависимостей. Доменный пакет не импортирует пакет с инфраструктурой. Это Dependency Inversion Principle, только бесплатный: реализация-то структурная.
  2. Маленькие интерфейсы. Потребителю нужен один метод, он и объявит интерфейс с одним методом. «The bigger the interface, the weaker the abstraction» (Роб Пайк).
  3. Простые моки. Мок на один метод пишется руками за минуту; мок на 20-методовый интерфейс генерируют и никто не читает.
  4. Нет циклических импортов между слоем абстракции и слоем реализации.
  5. Реализацию можно свободно расширять: новый метод у *Store ничего не ломает, потому что интерфейсы объявлены отдельно.

Когда интерфейс всё-таки живёт у реализации

  • Контракт для внешнего мира: io.Reader, error, http.Handler, sort.Interface — это интерфейсы, которые реализуют чужие типы, и объявить их у каждого потребителя было бы абсурдом.
  • Плагинная архитектура: пакет определяет точку расширения, и десятки внешних реализаций подключаются к ней.
  • Несколько реализаций внутри одного пакета, между которыми переключаются конфигом.
Как это звучит на собесе

«Интерфейс — это потребность потребителя, а не свойство реализации. Поэтому в Go его объявляют там, где используют. Технически это возможно из-за структурной типизации: реализации не надо объявлять, что она что-то реализует. Исключение — интерфейсы, которые задуманы как точка расширения для чужого кода: io.Reader, http.Handler, error

Суть: равны, если совпали динамические типы и равны динамические значения. Если динамический тип не comparable (слайс, мапа, функция или структура с таким полем) — 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 { /* компилятор проверит статически */ }
Тонкость про comparable и Go 1.20

До Go 1.20 any не удовлетворял констрейнту comparable именно потому, что сравнение интерфейсов может паниковать. В 1.20 правило смягчили: интерфейсные типы теперь удовлетворяют comparable, а за возможную панику отвечает разработчик. Это позволило писать map[K]V-подобные дженерик-контейнеры с K = any. Деталь мелкая, но по ней видно, что ты следишь за языком.

Суть: рефлексия — доступ из кода к тем самым двум половинкам интерфейса: reflect.TypeOf достаёт дескриптор типа, reflect.ValueOf — пару «тип + данные». Платишь производительностью и потерей статических проверок.

Три закона рефлексии

  1. Из интерфейсного значения можно получить reflect.Value.
  2. Из reflect.Value можно вернуться в интерфейс — .Interface().
  3. Чтобы менять значение, оно должно быть 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/sqlScan в произвольные указатели; ORM целиком.
  • text/template, html/template — доступ к полям по имени.
  • fmt%v, %+v, %#v.
  • sort.Slicereflect.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
До Go 1.22: одна переменная i на весь цикл i (в куче) = 3 после цикла горутина 1 горутина 2 горутина 3 все три смотрят в ОДНУ ячейку печатают 3 3 3 (или что успеют) Go 1.22+: своя переменная i на каждой итерации i(0) = 0 i(1) = 1 i(2) = 2 горутина 1 горутина 2 горутина 3 у каждой итерации своя ячейка печатают 0 1 2 в каком-то порядке Семантика включается по строке go в go.mod: go 1.22 и выше. Старый модуль компилируется по-старому даже новым тулчейном.
Переменная цикла. До Go 1.22 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"
Что именно поменялось в Go 1.22: точная формулировка

Переменные, объявленные в for ... := ... и в for ... := range ..., теперь живут одну итерацию, а не весь цикл. Включает это строка go 1.22 в go.mod, то есть решает модуль, а не версия компилятора: старый модуль соберётся по старым правилам даже свежим тулчейном. Найти такие места в старом коде помогает go vet -loopclosure (в 1.22+ анализатор учитывает языковую версию модуля).

Отдельно: go тут был не единственным случаем. Ещё defer внутри цикла и сохранение &v в слайс. Все три закрыло одно изменение.

Что 1.22 НЕ исправила
// Переменная объявлена ВНЕ цикла — семантика прежняя, ловушка осталась
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 сработает первым.

Регистрация — сверху вниз, выполнение — снизу вверх (LIFO) тело функции defer fmt.Println("A") defer fmt.Println("B") defer fmt.Println("C") return / panic стек defer у горутины (g._defer) C ← вершина B A выполнится 1-м выполнится 3-м вывод: C B A Аргументы вычисляются В МОМЕНТ defer, тело — при выходе i := 0 defer fmt.Println(i) // 0 вычислено СЕЙЧАС defer func(){ fmt.Println(i) }() // i прочтётся потом i = 42 вывод: 42, затем 0 замыкание читает переменную в момент выполнения — поэтому 42; аргумент был скопирован при регистрации — 0
Две независимые вещи. Порядок LIFO; аргументы вычисляются при регистрации, а не при выполнении. Смешивают их постоянно, отсюда и неправильные ответы.
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 в цикле

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. Списка нет вообще
1) до 1.13 — запись _defer в куче, связный список у горутины g (горутина) _defer #2 _defer #1 malloc на каждый defer, обход списка при выходе 2) 1.13 — те же записи, но в стековом фрейме функции фрейм f() _defer #1 _defer #2 аллокации нет, но список и обход остались 3) 1.14+ — open-coded: вызовы вшиты в код перед каждым return ... тело функции ... if deferBits & 2 { mu.Unlock() } if deferBits & 1 { f.Close() } RET никакого списка: битовая маска deferBits говорит, какие defer успели зарегистрироваться на panic-пути рантайм читает их из funcdata
Эволюция defer. Open-coded defers превращают отложенный вызов в обычный вызов с проверкой бита. В горячем коде defer перестал быть заметной статьёй расхода.
Когда open-coded 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() внутри отложенной функции. Механика по шагам:

  1. Рантайм создаёт запись _panic и кладёт её в цепочку паник текущей горутины.
  2. Текущая функция обрывается прямо в точке паники.
  3. Начинается раскрутка стека: фреймы снимаются со стопки один за другим, от того, где случилась паника, к вызывающим, и в каждом выполняются его отложенные функции в порядке LIFO.
  4. Если какая-то из них вызывает recover() и та возвращает не nil, паника погашена. Функция, которой принадлежал этот defer, завершается нормально и отдаёт свои возвращаемые значения, те, что лежат в них на этот момент.
  5. Никто не сделал recover: раскрутка доходит до вершины стека горутины, рантайм печатает сообщение и стектрейс в stderr и убивает весь процесс с кодом выхода 2.
Стек горутины (рост вниз) и путь паники вверх main() defer: нет handle() defer func(){ recover() }() parse() defer: log.Print("вышли") decode() panic("bad input") 1. паника рождается здесь, оставшийся код decode() не выполняется 2. отработал defer у parse(), но recover нет — раскрутка идёт дальше 3. defer у handle() вызвал recover() != nil паника погашена, handle() возвращается НОРМАЛЬНО main() продолжает работу и ничего не заметил Если бы recover не было ни в одном фрейме: рантайм печатает "panic: bad input" + goroutine stack trace в stderr, процесс завершается с кодом 2. Другие горутины не получают шанса — умирает всё приложение. Полноту трейса регулирует GOTRACEBACK: none | single (по умолчанию) | all | system | crash.
Раскрутка стека. Паника идёт вверх по фреймам, выполняя их defer-ы; первый recover() в отложенной функции гасит её, и владелец этого defer возвращается штатно.
recover работает ТОЛЬКО при прямом вызове из отложенной функции

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.Fatal съедает твои defer-ы

Классическая ошибка: 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 сам падает.

Вопросы

10
Суть: функция в Go — обычное значение типа func(...)(...): её можно присвоить переменной, передать аргументом, вернуть, положить в мапу или поле структуры.

Что из этого следует

  • Есть тип функции: 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. Отсюда же аллокация в куче, если замыкание захватило много всего.

Суть: замыкание захватывает переменную, а не её значение — фактически по ссылке; из-за этого до Go 1.22 все горутины цикла видели одну и ту же 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 (стек), аргументы вычисляются в момент объявления defer, а выполняется он при выходе из функции — не из блока и не из итерации.

Три правила

  1. LIFO. Отложенные вызовы кладутся в стек: последний объявленный выполнится первым. Ровно это и нужно для парного захвата ресурсов: открыл A, открыл B, закроется B, потом A.
  2. Аргументы вычисляются сразу. defer fmt.Println(i) при i == 0 напечатает 0, даже если дальше i = 42. Откладывается только вызов, не вычисление операндов. Получатель метода тоже аргумент: defer b.String() вычислит b.String() немедленно.
  3. Работает на уровне функции. Ни 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.

Суть: раньше ~50 нс с аллокацией в куче, с Go 1.14 — open-coded defers, порядка наносекунды: компилятор вставляет вызов прямо перед 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».

Что происходит на panic-пути

Open-coded defers создают проблему: списка нет, а при панике рантайму нужно знать, какие вызовы выполнить в раскручиваемом фрейме. Выручают метаданные funcdata, которые компилятор кладёт рядом с функцией: рантайм при раскрутке читает их и восстанавливает список отложенных вызовов. Так что паника по-прежнему корректно выполняет все defer-ы, просто путь этот медленный. И это нормально, паника не должна быть горячей.

Как отвечать

«Сегодня defer в обычной функции стоит порядка наносекунды, экономить на нём незачем. Отказываться стоит только по данным профиля, и почти всегда настоящая проблема даже не defer, а defer в цикле: он и ресурсы держит, и оптимизацию отключает.»

Суть: паника идёт вверх по фреймам горутины, выполняя их defer-ы в порядке LIFO; recover() действует, только если вызван непосредственно из отложенной функции, и тогда её владелец возвращается штатно.

Пошагово

  1. panic(v) создаёт запись _panic и обрывает текущую функцию прямо в этой точке.
  2. Рантайм начинает раскрутку: в каждом фрейме, снизу вверх, выполняются его отложенные функции. Все до одной, паника этому не мешает.
  3. Если очередная отложенная функция вызвала recover() и получила не nil, паника гаснет. Функция, которой принадлежал defer, завершается нормально и отдаёт то, что лежит в её (именованных) возвращаемых значениях.
  4. Не перехватил никто: раскрутка доходит до корня горутины, рантайм печатает сообщение и стектрейс в 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 паника остаётся инцидентом, а не рабочим путём; ей место в алертах, а не в тишине.»

Суть: нет — recover ловит панику только в своей горутине; неперехваченная паника в любой горутине убивает весь процесс с кодом 2.

Почему так

Раскрутка идёт по стеку конкретной горутины. Горутины не вложены друг в друга: у запущенной 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] компилируется. Запрет остался только для методов интерфейса. Подробности ниже, отдельным разделом.
  • Дженерик-тип без аргументов типа ещё не тип, а «шаблон». Его нельзя положить в поле, объявить переменной или использовать в сигнатуре без инстанцирования.
Анатомия объявления func Map [E any, R any] (s []E, f func(E) R) []R имя список параметров типа E, R — имена · any — констрейнт обычная сигнатура E и R уже можно использовать как типы Инстанцирование: компилятор подставляет конкретные типы Map(ints, strconv.Itoa) Map(users, User.Name) Map[string, int](ss, atoi) Map[int, string] Map[User, string] Map[string, int] выведено из аргументов выведено из аргументов задано явно Вывод идёт только от типов АРГУМЕНТОВ вниз; тип результата в выводе не участвует — из него ничего вывести нельзя.
Объявление и инстанцирование. Квадратные скобки образуют «второй уровень» сигнатуры: сначала подставляются типы, потом функция становится обычной. Инстанцирование случается на компиляции, в рантайме никакого «дженерика» уже нет.

Констрейнты: интерфейс как множество типов

Формулировка, которую стоит держать наготове: в 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 случайно не попал в арифметику для «сырых» наносекунд).

Констрейнт = множество типов (type set) any — все типы Go comparable — есть строгий == constraints.Ordered есть < <= > >= ~int | ~int64 и все производные string float64 struct{A int} [4]byte *User []byte map[k]v func() any == паникует или запрещён Как читать запись int — ровно тип int ~int — int и все type X int int | string — объединение ~int; Str() string — пересечение: и underlying, и метод Интерфейс с | или ~ или comparable — только констрейнт, не тип переменной. Почему множества, а не «список методов» Операции +, <, range, индексация не выражаются через методы. Единственный способ разрешить их для параметра типа — перечислить типы, где они есть.
Type set. Компилятор разрешает над значением типа 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
Тонкость Go 1.20, о которой знают немногие

До 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
}
С Go 1.21 половина этого не нужна

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
Зачем вообще приём «~[]E»

Если объявить 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 — штатно
Критерий, по которому решать за 5 секунд

Задайте вопрос: меняется ли алгоритм в зависимости от типа? Если нет, код один и тот же, отличается только тип элемента, берите дженерик (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-документа звучит буквально так:

Правило GC shape

Два конкретных типа попадают в одну gcshape-группу тогда и только тогда, когда у них одинаковый underlying type или оба оказываются указательными типами. Следствия: *User и *Order дают один стенсил; type UserID int и int тоже один; а вот int и int64 дадут разные стенсилы, несмотря на одинаковый размер.

Один исходник func Index[T comparable](s []T, v T) int Мономорфизация (C++/Rust) — для сравнения 6 инстанциаций → 6 копий машинного кода + максимум скорости: всё известно, всё инлайнится − размер бинаря и время компиляции растут линейно Инстанциации в программе int UserID (=int) int64 string *User *Order 4 стенсила — по одному на GC shape Index[shape int] int + UserID Index[shape int64] только int64 Index[shape string] только string Index[shape *uint8] ВСЕ указательные типы словарь на инстанциацию Что это даёт и чего стоит + Бинарь растёт не от числа типов, а от числа ФОРМ: тысяча *T-инстанциаций даёт одну копию кода. + Для non-pointer форм (int, string, struct) значения лежат по значению — без боксинга и аллокаций. − Для pointer-формы код не знает конкретный тип: размеры, методы и типовые дескрипторы берутся из словаря — лишняя косвенность.
GC shape stenciling. Компромисс между размером бинаря и скоростью. Именно из-за объединения всех указателей в одну форму дженерик над *T в горячем цикле может проигрывать и мономорфному коду, и даже обычному интерфейсу.

Что лежит в словаре

Словарём зовут статическую структуру, которую компилятор кладёт в read-only секцию бинаря (по одной на каждую конкретную инстанциацию, не на форму) и передаёт скрытым первым аргументом. Внутри лежит всё, чего стенсил знать не может: он один на много типов.

Содержимое словаря Зачем нужно
*runtime._type для каждого параметра типа Аллокация (make, new), конверсия в any, работа GC
Дескрипторы производных типов ([]T, map[K]V…) make([]T, n) внутри дженерика
itab для методов констрейнта Вызов метода, объявленного в констрейнте
Ссылки на вложенные инстанциации Дженерик вызывает другой дженерик с теми же T
Словари для замыканий внутри тела Замыкание тоже должно уметь работать с T
Три способа сделать «одну и ту же» работу — и что происходит на вызове Конкретный тип SumInts(xs []int) · аргумент в регистре · размер элемента известен · инлайн и вектор-оптимизации · 0 аллокаций эталон скорости N копий кода при N типах Дженерик Sum[T Number](xs []T) · скрытый арг: *dict · non-ptr форма — как эталон · ptr-форма — всё через *T · метод констрейнта → dict → itab быстро на числах/строках двойная косвенность на указателях Интерфейс Sum(xs []Number) · значение упаковано: (itab, data) · укладка int в интерфейс — аллокация · вызов метода — косвенный · девиртуализация только иногда аллокации на каждом элементе 1 копия кода на все типы Почему дженерик иногда МЕДЛЕННЕЕ обоих Стенсил для pointer-формы не знает конкретный тип, поэтому: (1) не может заинлайнить метод констрейнта — только вызов через словарь; (2) теряет devirtualization, которую компилятор умеет делать для мономорфного интерфейса; (3) добавляет чтение словаря в прологе. Вывод для собеса: «дженерики всегда быстрее интерфейсов» — неверно. Быстрее на значимых типах, спорно на указателях, всегда — надо мерить.
Цена абстракции. Для 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.22 Delete ещё и обнуляет освободившийся хвост, чтобы не держать ссылки для 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] в качестве встроенного типа.

Вопросы

11
Суть: параметры типа объявляются в квадратных скобках после имени (func 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]) ... даст ошибку компиляции. Констрейнт пишется один раз, у объявления типа.

Суть: начиная с 1.18 интерфейс — это множество типов; методы задают множество неявно («все типы с такими методами»), а | и ~ — явно. ~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()
Basic vs non-basic интерфейс

Интерфейс с |, ~ или 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 — хороший ответ «а зачем это поменяли»

До 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.

Суть: Go выводит типы от типов аргументов и из констрейнтов, а с 1.21 ещё из функционального типа, которому присваивают саму дженерик-функцию. Из типа результата вызова и из тела вывод не идёт. Нет параметра типа среди аргументов — пишем скобки руками.

Два механизма

  • 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.
Правило проектирования API

Каждый параметр типа должен встречаться хотя бы в одном обычном аргументе. Если это не так, поменяйте сигнатуру: не 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) не компилируется.
  • Убирает зеркальные пакеты. Классика тут strings vs bytes: два почти одинаковых пакета, которые нужно править синхронно.

Аргументы за интерфейсы

  • Открытость. Любой чужой тип может реализовать ваш интерфейс. 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 не делает ни полной мономорфизации, ни полного стирания. Компилятор генерирует одну копию кода на GC-форму типа (все указатели — одна форма) и передаёт скрытым первым аргументом словарь с типовыми дескрипторами и itab-ами для того, что зависит от конкретного типа.

Три возможных подхода и выбор 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 регрессию в основном закрыли.
Суть: да, оба варианта возможны. Медленнее мономорфного кода — почти всегда на pointer-формах (лишняя косвенность через словарь, хуже инлайнинг). Медленнее интерфейса — реже, но бывает: компилятор умеет девиртуализировать вызов через интерфейс, когда тип известен, а вызов через словарь девиртуализовать сложнее.

Откуда берётся замедление

  • Чтение словаря. В прологе стенсила добавляется работа с ещё одним скрытым аргументом; для коротких функций это заметная доля.
  • Потеря инлайнинга. Метод, объявленный в констрейнте, вызывается через 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

Суть: два дженерик-пакета, переехавшие в stdlib в Go 1.21 (вместе с 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], а не «универсальный хелпер».
Суть: параметры типа у методов появились в Go 1.27 — но только у методов конкретных типов; у методов интерфейса их по-прежнему нет (это ломало бы статическую проверку интерфейсов). Нет специализации (нарушает «одно тело — одно поведение»), нет ковариантности (типы инвариантны — иначе через контейнер можно нарушить типобезопасность), нет полей в констрейнтах и перегрузки операторов.

Параметры типа у методов: что изменилось в 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 и ручная синхронизация версий, поэтому так делают редко.

Репозиторий github.com/acme/billing МОДУЛЬ — от go.mod и вниз по дереву go.mod go.sum граница модуля и версии / package main main.go, wire.go /invoice package invoice · 4 файла /internal/store виден только внутри модуля /invoice/pdf отдельный пакет! ПАКЕТ = ровно одна директория. Вложенная папка — это другой пакет, никакой «вложенности» и наследования видимости между ними нет. Как складывается путь импорта module path + dir import "github.com/acme/billing/invoice/pdf" это модуль это директория внутри него Имя пакета в коде и последний сегмент пути — РАЗНЫЕ вещи: import "gopkg.in/yaml.v3" yaml.Marshal(...) // не v3.Marshal Поэтому goimports иногда ставит алиас. Формулировка: модуль — набор пакетов, которые версионируются, тегируются и выпускаются ВМЕСТЕ. Импортируют всегда пакет. Один репозиторий обычно = один модуль. Несколько модулей в репо возможны, но тянут за собой теги вида 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=* «чтобы просто заработало» не стоит: так ты теряешь защиту от подмены и для публичных зависимостей тоже.

Что такое checksum database и зачем он вообще

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 при исправлениях. А дальше правило, которого нет больше нигде:

Import compatibility rule

«Если старый и новый пакет имеют один и тот же путь импорта, новый пакет должен быть обратно совместим со старым.» Отсюда следствие: несовместимость обязана менять путь. Поэтому начиная с 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+
  • Поставили тег 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».

Один и тот же граф зависимостей main A v1.2.0 B v1.5.0 C v1.1.0 C v1.3.0 В репозитории уже есть C v1.9.0 Go: MVS Собрать все требования на C: { v1.1.0, v1.3.0 } Взять МАКСИМУМ из них: C v1.3.0 v1.9.0 НЕ берётся: её никто не просил npm / Cargo Требования — диапазоны: ^1.1.0 и ^1.3.0 Решатель ищет свежайшее в пересечении: C v1.9.0 Сборка меняется без правки кода Что из этого следует практически + Воспроизводимость по построению: go.mod + go.sum дают ОДИН И ТОТ ЖЕ билд сегодня и через два года, без lock-файла. + Нет «фантомных» обновлений: новая версия зависимости не приедет, пока кто-то явно не поднимет требование. + Алгоритм линейный и понятный: нет NP-полного решателя, нет «npm resolution hell», нет ситуации «lock протух — пересобери». + Нет дублей версий в одном билде: ровно одна версия модуля на major (в отличие от node_modules с деревом копий). − Патчи безопасности НЕ приезжают сами: нужен go get -u или Dependabot/Renovate. Это осознанный обмен свежести на предсказуемость. − Ответственность на мейнтейнере: если модуль требует старую версию с CVE, поднимать её придётся вручную во всех потребителях.
MVS против решателя диапазонов. Go не спрашивает «какая самая новая версия подойдёт», он спрашивает «какая самая старая версия удовлетворяет всех», и берёт её. Отсюда воспроизводимость сборок без lock-файла.
# Инструменты, чтобы увидеть решение 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      # выкинуть из графа
Почему MVS считается более безопасным дизайном

Из-за «высокоточности»: набор версий определяет только содержимое 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() задаёт топологическая сортировка графа. В цикле «кто первый» не определить.
  • Дизайн. Цикл почти всегда означает, что границы пакетов проведены неправильно: либо два пакета на самом деле составляют один, либо между ними не хватает третьего.
Проблема order user import cycle not allowed Решение 1 — выделить общий пакет order user domain типы, которые нужны обоим, — вниз Решение 2 — интерфейс у ПОТРЕБИТЕЛЯ package order type Users interface{...} user (реализует) компилируемой зависимости больше нет Решение 3 — колбэк / DI order.New( getUser func(id) User) main связывает оба граф зависимостей поднят в main Почему «интерфейс у потребителя» — идиоматический Go В Java интерфейс объявляют рядом с реализацией и импортируют вниз. В Go реализация НЕЯВНАЯ, поэтому интерфейс объявляют там, где он ИСПОЛЬЗУЕТСЯ, и стрелка зависимости разворачивается: пакет-реализация больше не импортируется тем, кто его вызывает. Это же лечит и цикл, и тестируемость сразу. Чего делать НЕ надо · Схлопывать два пакета в один «god package» models/ на 200 типов — цикл уйдёт, архитектура развалится. · Городить глобальную переменную-реестр с init(), чтобы «зарегистрировать» реализацию — это скрытый цикл во время выполнения. · Использовать interface{} и рефлексию, лишь бы не импортировать пакет.
Разрыв цикла. Три рабочих приёма. В 90% случаев на собесе ждут второй: объявить интерфейс на стороне потребителя и тем самым инвертировать зависимость.
// ── Было: цикл ──────────────────────────────────────────────
// 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.

github.com/acme/app/ /cmd/api /pkg/client /internal — «родитель» = /acme/app /internal/store /internal/auth /internal/auth/ internal/jwt виден только auth оба — внутри /acme/app → можно Снаружи github.com/other/svc import "github.com/acme/app/internal/store" → use of internal package not allowed Проверяет СБОРКА (команда go), а не соглашение и не линтер. Правило: пакет .../X/internal/Y импортируется только из дерева, начинающегося с .../X. Уровень internal может быть любым и вложенным — так внутри модуля строится настоящая иерархия видимости: /internal — для всего модуля, /internal/auth/internal — только для auth.
internal/. Единственный способ в Go сказать «это деталь реализации, трогать снаружи нельзя», и единственный, который проверяет сборка, а не код-ревью.
Практика: что класть в internal/

По умолчанию всё. Стандартная раскладка сервиса: /cmd/<binary> с тремя строками main, весь остальной код в /internal/..., а /pkg/... только для того, что ты намеренно публикуешь как API для чужих сервисов. Отсюда свобода рефакторинга: пока пакет в internal, меняй его сигнатуры сколько угодно, мажорную версию выпускать не придётся. В стандартной библиотеке internal работает по тому же правилу, например net/http/internal.

init() и полный порядок инициализации

Вопрос «в каком порядке всё инициализируется» задают часто, а полный ответ укладывается в пять шагов. Выучить стоит именно последовательность.

Порядок инициализации — сверху вниз 1. Зависимости Импортируемые пакеты — полностью, до текущего 2. Переменные пакета В порядке ЗАВИСИМОСТЕЙ между ними, а не в порядке объявления в файле 3. init() пакета Файлы в порядке имён, внутри файла — сверху вниз 4. main.main() и только теперь Пример: почему b вычисляется раньше a package main var a = b + 1 // 3 var b = f() // 2 func f() int { return c } var c = 1 // 1 Компилятор строит граф: c → b → a Из готовых берётся первая по порядку объявления. Цикл в графе = ошибка компиляции. Порядок ФАЙЛОВ Спецификация не обязывает, но go build передаёт файлы компилятору отсортированными по имени: a.go → m.go → z.go. Закладываться на это — плохо. Что происходит ДО всего этого Рантайм: инициализация планировщика, аллокатора и GC, создание g0/m0, разбор argv и переменных окружения, настройка сигналов. Всё это уже работает к моменту первого init(), поэтому в 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"
Как отвечать про разницу vet / staticcheck / golangci-lint

«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 в проде

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 over func (Go 1.23): кто кого вызывает Потребитель for v := range Seq { fmt.Println(v) if v > 2 { break } } Компилятор превращает тело цикла в функцию yield. Seq(yield) yield(v) false ← был break Итератор (push-стиль) func(yield func(V) bool) { for _, v := range src { if !yield(v) { return // прибрать за собой } } } Итератор УПРАВЛЯЕТ циклом, не потребитель. Что даёт · ленивость: нечего материализовать · композиция: Filter(Map(Seq)) · один протокол для всех коллекций · break/return/panic корректно прерывают итератор · iter.Pull — если нужен pull-стиль Почему именно такой протокол Альтернатива — pull-итератор с методами Next()/Value(), как в sql.Rows. Он требует хранить состояние обхода вручную, что для дерева или рекурсивной структуры превращается в явный стек. Push-итератор пишется как обычный рекурсивный обход — состояние держит стек вызовов. Цена: yield вызывается на КАЖДЫЙ элемент — это вызов функции, который не всегда инлайнится. В самых горячих циклах обычный for по слайсу всё ещё быстрее. И обязательно: итератор должен корректно завершаться, когда yield вернул false, иначе утекут ресурсы и горутины.
Итераторы. range над функцией работает как синтаксический сахар: тело цикла становится колбэком yield, а break превращается в возврат false. Главное тут: циклом управляет итератор, а не потребитель.
Про Swiss Tables, если спросят подробнее

Go 1.24 заменил классическую реализацию мапы (массив бакетов по 8 пар + цепочка overflow) на Swiss Table: группы по 8 слотов с отдельным control word из 8 байт, где каждый байт хранит 7 бит хеша плюс флаг занятости. Поиск сравнивает сразу все 8 контрольных байтов: на amd64 одной SIMD-инструкцией, на остальных архитектурах несколькими битовыми операциями над 64-битным словом. Поэтому промах определяется за один шаг, а не за обход цепочки. На микробенчмарках это до 60% ускорения, по реальным приложениям в среднем около 1,5% CPU, плюс меньше накладной памяти. Для кода не меняется ничего: порядок обхода по-прежнему случайный, мапа по-прежнему не потокобезопасна, адрес значения по-прежнему нельзя взять.

Вопросы

13
Суть: пакет — единица компиляции и видимости (одна директория); модуль — единица версионирования и распространения (дерево от go.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 обязательно коммитим: без него теряется вся защита цепочки поставки

Суть: из-за import compatibility rule: одинаковый путь импорта обязан означать обратно совместимый код. Значит, ломающее изменение должно менять путь — отсюда суффикс /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. Не защищён: в нулевой мажорке ломать разрешено в любом минорном релизе, и это не нарушение правил.
Суть: Minimal Version Selection — для каждого модуля берётся максимум из минимально требуемых версий по всему графу. Никаких диапазонов, никакого солвера, «самая свежая подходящая» не берётся никогда. Отсюда воспроизводимость сборок без lock-файла.

Алгоритм в трёх шагах

  1. Обойти граф: go.mod главного модуля → go.mod его зависимостей → и так далее (нужны только go.mod-файлы, код не скачивается).
  2. Для каждого пути модуля собрать множество требуемых версий.
  3. Выбрать из множества максимальную. На выходе 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 задают разные пути модуля, а значит и разные модули.

Суть: скачивание идёт по цепочке кэш → GOPROXY → VCS. Вендоринг кладёт код зависимостей прямо в репозиторий (vendor/) и позволяет собирать вообще без сети. Современный дефолт — не вендорить: MVS + go.sum уже дают воспроизводимость, а прокси — доступность.

Цепочка загрузки

  1. Локальный кэш $GOMODCACHE (по умолчанию $GOPATH/pkg/mod). Распакованные модули read-only плюс подкаталог cache/download, зеркало прокси в том же формате.
  2. Прокси из GOPROXY задаётся списком через запятую, по умолчанию https://proxy.golang.org,direct. Прокси иммутабелен: раз опубликованную версию не изменить, а удаление тега в репозитории её из прокси обычно не убирает.
  3. 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(). Каждый пакет инициализируется ровно один раз.

Полный список правил

  1. Рантайм поднимается раньше всего: планировщик, аллокатор, GC, обработка сигналов.
  2. Импортируемые пакеты: рекурсивно и полностью, в порядке топологической сортировки графа импортов. Цикл невозможен, поэтому порядок всегда определён.
  3. Переменные уровня пакета: в порядке зависимостей между ними. Порядок объявления в файле важен только между независимыми переменными: из готовых к инициализации берётся первая по объявлению. Цикл в зависимостях переменных даёт ошибку компиляции initialization cycle.
  4. Все init() пакета. Их может быть несколько в одном файле и несколько файлов. Порядок между файлами не гарантирован спецификацией, но go build передаёт файлы отсортированными по имени.
  5. init() нельзя вызвать вручную, нельзя взять его адрес, у него нет параметров и результата.
  6. Только после инициализации всех пакетов запускается 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 — механизм вызова C-кода из Go (и обратно). Даёт доступ к любым C-библиотекам, но ломает кросс-компиляцию, делает бинарь динамическим, добавляет ощутимый оверхед на каждый вызов и выводит память C из-под контроля GC.
/*
#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 альтернативы.

Суть: 1.22 — семантика переменной цикла и паттерны в 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, а он отвергает дублирующиеся ключи, пришлось чинить одного поставщика данных». По такому ответу видно, что ты следишь за релизами и понимаешь, что они дают, а не заучил список.