DESAI.DEV
← ВЕРНУТЬСЯ

Справочник GO. Часть I

Справочник по языку Go (Golang) Часть I. Примитивы языка 1. Срезы и массивы 2. Словари (maps) 3. Строки 4. Структуры данных (структуры) 5. Интерфейсы 6. Деферы (defer) 7. Паники (panic / recover)

Часть I. Примитивы языка

1. Срезы и массивы

Массивы

Массив в Go — это пронумерованная последовательность элементов фиксированной длины одного типа. Длина является частью типа: [5]int и [10]int — это два разных, несовместимых типа. Размер массива должен быть известен на этапе компиляции.

Ключевое свойство массива — он является значимым типом (value type). При присваивании или передаче в функцию массив копируется целиком:

a := [3]int{1, 2, 3}
b := a       // полная копия всех элементов
b[0] = 100
// a == [1 2 3], b == [100 2 3] — массивы независимы

Из-за копирования массивы редко передают в функции напрямую (дорого для больших размеров) — обычно передают срез или указатель на массив. Массивы сравнимы оператором ==, если сравним тип их элементов.

Срезы

Срез (slice) — это дескриптор (заголовок), описывающий участок некоторого базового массива (backing array). Внутреннее представление среза — это структура из трёх полей:

type slice struct {
    array unsafe.Pointer // указатель на начало участка в базовом массиве
    len   int            // длина — число доступных элементов
    cap   int            // ёмкость — от array до конца базового массива
}

Сам срез — это маленькая структура (24 байта на 64-битной платформе), которая копируется при передаче, но указывает на общий базовый массив. Поэтому изменение элементов через один срез видно через другой срез того же массива.

s := []int{1, 2, 3, 4, 5}
sub := s[1:3]      // len=2, cap=4 (от индекса 1 до конца массива)
sub[0] = 99        // меняет s[1]
// s == [1 99 3 4 5]

Операция append и рост ёмкости

append добавляет элементы в конец среза. Если len < cap, запись идёт в существующий базовый массив (быстро). Если ёмкости не хватает, runtime аллоцирует новый, больший массив, копирует туда данные и возвращает срез, указывающий уже на новую память:

s := make([]int, 0, 2)
s = append(s, 1, 2)   // помещается, cap=2
s = append(s, 3)      // переполнение → новый массив, новый cap

Стратегия роста (упрощённо): для небольших срезов ёмкость примерно удваивается; начиная с порога (~256–1024 элементов в зависимости от версии) рост замедляется до коэффициента ~1.25, чтобы не тратить память. Точная формула задана в growslice рантайма и менялась между версиями.

Подводные камни

1. Общий базовый массив после среза. Два среза могут разделять память, и append в один может затереть данные другого:

a := []int{1, 2, 3, 4}
b := a[:2]                 // len=2, cap=4
b = append(b, 99)          // cap позволяет — пишем в a[2]!
// a == [1 2 99 4] — данные a испорчены

Чтобы изолировать срез, используйте full slice expression a[low:high:max], ограничивающую ёмкость, или copy в новый срез.

2. Утечка памяти. Маленький срез, полученный из большого массива, удерживает весь базовый массив в памяти, не давая GC его освободить:

func firstByte(huge []byte) []byte {
    return huge[:1] // удерживает весь huge!
}

Решение — скопировать нужную часть: r := make([]byte, 1); copy(r, huge[:1]).

3. nil-срез против пустого среза. var s []int даёт nil-срез (s == nil, len=cap=0). s := []int{} — не-nil пустой срез. Оба имеют длину 0, по обоим можно итерировать и делать append, но они по-разному ведут себя при сравнении с nil и при сериализации в JSON (null vs []).

---

2. Словари (maps)

map[K]V — встроенная хеш-таблица, неупорядоченная коллекция пар ключ–значение. Ключ должен быть сравнимого типа (нельзя использовать срез, map или функцию как ключ).

Внутреннее устройство

Классическая реализация (до Go 1.24) — хеш-таблица с бакетами. Заголовок описывается структурой hmap, а данные хранятся в массиве бакетов bmap. Каждый бакет вмещает до 8 пар ключ–значение. От хеша ключа младшие биты выбирают бакет, а старшие 8 бит (tophash) ускоряют поиск внутри бакета. При переполнении бакета создаётся цепочка overflow-бакетов.

Начиная с Go 1.24 реализация переписана на Swiss Tables — это даёт более компактную раскладку и заметно более быстрый поиск/вставку на больших словарях.

Рост и эвакуация

Когда заполненность превышает коэффициент загрузки (порядка 6.5 элементов на бакет) либо накапливается слишком много overflow-бакетов, словарь растёт — выделяется вдвое больше бакетов, а перенос данных (эвакуация) происходит инкрементально, по мере операций вставки/удаления, чтобы избежать одной долгой паузы.

Важные свойства и подводные камни

Случайный порядок итерации. range по map намеренно рандомизирован — нельзя полагаться на порядок. Для упорядоченного обхода соберите ключи в срез и отсортируйте.

Не потокобезопасны. Одновременная запись из нескольких горутин (или чтение во время записи) приводит к аварийному завершению программы:

fatal error: concurrent map writes

Это не обычная паника — её нельзя перехватить через recover. Для конкурентного доступа используйте sync.Mutex/sync.RWMutex или sync.Map.

Нельзя взять адрес элемента. Выражение &m[k] не компилируется, потому что при росте словаря элементы перемещаются в памяти. Если нужно менять поля структуры-значения, храните указатели (map[K]V) или перезаписывайте значение целиком.

Чтение отсутствующего ключа возвращает нулевое значение типа, без паники. Идиома «запятая, ok» различает «нет ключа» и «значение равно нулю»:

v, ok := m[key]   // ok == false, если ключа нет

Удаление делается через delete(m, key) — безопасно даже для отсутствующего ключа. Удаление во время range допустимо.

3. Строки

Строка в Go — это неизменяемая (immutable) последовательность байтов. Внутреннее представление — структура из двух полей:

type stringStruct struct {
    str unsafe.Pointer // указатель на байты
    len int            // длина в байтах
}

Строка хранит байты, обычно в кодировке UTF-8, но сам тип string ничего не гарантирует о валидности UTF-8 — это просто байты.

Байты против рун

Важно различать три понятия:

  • byte — псевдоним uint8, один байт. len(s) возвращает число байтов, а не символов.
  • rune — псевдоним int32, один код-поинт Unicode.
  • Индексирование s[i] даёт байт, а не символ.

Один символ Unicode (руна) в UTF-8 кодируется 1–4 байтами. Поэтому для русского текста len("привет") равно 12 (по 2 байта на символ), а не 6.

s := "héllo"
fmt.Println(len(s))            // 6: 'é' занимает 2 байта
for i, r := range s {          // range итерирует по РУНАМ
    fmt.Printf("%d:%c ", i, r) // i — байтовое смещение
}

range по строке декодирует UTF-8 и выдаёт руны вместе с их байтовыми смещениями. Невалидные байты дают RuneError (U+FFFD).

Конвертации

[]byte(s) и string(b) по умолчанию копируют данные, потому что строка неизменяема, а срез — нет. Компилятор оптимизирует ряд частных случаев (например, конвертацию во временном выражении в range или при сравнении), избегая копии.

[]rune(s) декодирует всю строку в срез рун (тоже с аллокацией). string(r) для одной руны кодирует её в UTF-8.

b := []byte("abc")  // копия в изменяемый срез
b[0] = 'X'
s2 := string(b)     // снова копия → "Xbc"

Эффективная сборка строк

Конкатенация + в цикле создаёт новую строку на каждой итерации — O(n²) по памяти. Для построения больших строк используйте strings.Builder, который накапливает байты в растущем буфере без лишних копий:

var b strings.Builder
for i := 0; i < 1000; i++ {
    b.WriteString("x")
}
result := b.String() // одна финальная строка

strings.Builder запрещает копирование себя после использования (проверка через noCopy), чтобы не нарушить инвариант общего буфера.

---

4. Структуры данных (структуры)

Структура (struct) — это составной тип, агрегирующий именованные поля. Структуры — значимые типы: присваивание и передача в функцию копируют все поля.

type User struct {
    ID   int
    Name string
    Age  uint8
}
u := User{ID: 1, Name: "Ann"}
v := u           // полная копия
v.Name = "Bob"   // u.Name остаётся "Ann"

Выравнивание и размер (padding)

Поля размещаются в памяти с учётом выравнивания (alignment): адрес поля должен быть кратен его размеру (8-байтовое поле — по адресу, кратному 8, и т. п.). Компилятор вставляет заполнитель (padding) между полями. Поэтому порядок полей влияет на размер структуры:

type Bad struct {
    a bool   // 1 байт
    b int64  // 8 байт → 7 байт padding перед ним
    c bool   // 1 байт + хвостовой padding
}                 // итого 24 байта

type Good struct {
    b int64  // 8
    a bool   // 1
    c bool   // 1 + 6 padding
}                 // итого 16 байт

Правило оптимизации: размещайте поля от больших к меньшим. Узнать размер можно через unsafe.Sizeof, смещения — через unsafe.Offsetof.

Встраивание (embedding)

Go использует композицию через встраивание вместо наследования. Анонимное поле «поднимает» свои методы и поля во внешнюю структуру:

type Animal struct{ Name string }
func (a Animal) Speak() string { return a.Name + " makes a sound" }

type Dog struct {
    Animal       // встраивание
    Breed string
}
d := Dog{Animal{"Rex"}, "Lab"}
d.Speak()        // продвинутый метод, фактически d.Animal.Speak()
d.Name           // продвинутое поле

Встраивание интерфейса в структуру — частый приём для частичной реализации или обёрток (декораторов).

Пустая структура

struct{} занимает 0 байт. Применяется как «нулевая» нагрузка: множество на map (map[string]struct{}), сигнальный канал (chan struct{}), маркеры. Все значения struct{}{} указывают на один общий адрес zerobase.

Теги, сравнимость

Поля могут нести строковые теги, читаемые через рефлексию (используется JSON, ORM и т. п.): json:"name,omitempty".

Структура сравнима оператором ==, если сравнимы все её поля. Структура со срезом, map или функцией в поле несравнима — её нельзя использовать как ключ map.

5. Интерфейсы

Интерфейс задаёт набор методов (контракт поведения). Тип удовлетворяет интерфейсу неявно — достаточно реализовать все его методы, явного implements не требуется (структурная типизация, «duck typing»).

type Stringer interface {
    String() string
}

Внутреннее устройство

Значение интерфейса — это пара указателей (16 байт на 64-битной системе). Различают две формы:

  • eface (interface{} / any) — пустой интерфейс:
type eface struct {
    _type *_type         // дескриптор динамического типа
    data  unsafe.Pointer // указатель на данные (или сами данные)
}
  • iface — непустой интерфейс с методами:
type iface struct {
    tab  *itab           // таблица: тип + методы
    data unsafe.Pointer  // указатель на конкретное значение
}

itab (interface table) кэширует информацию о конкретном типе и таблицу указателей на методы, реализующие интерфейс. Вызов метода через интерфейс — это динамическая диспетчеризация: переход по соответствующему указателю в itab. Сами itab кэшируются рантаймом, чтобы не строиться повторно.

Typed nil — главный подвох

Интерфейс равен nil только если оба его поля нулевые (нет ни типа, ни данных). Если положить в интерфейс nil-указатель конкретного типа, интерфейс перестаёт быть nil, потому что поле типа заполнено:

func do() error {
    var p *MyError = nil
    return p          // возвращаем typed nil!
}
err := do()
if err != nil {       // ИСТИНА! err хранит (*MyError, nil)
    // сюда попадём, хотя «ошибки нет»
}

Это классический источник багов. Правило: возвращайте буквально nil, а не nil-указатель, упакованный в интерфейс.

Утверждение и переключение типа

v, ok := i.(string)      // безопасное утверждение типа
switch x := i.(type) {   // type switch
case int:    useInt(x)
case string: useStr(x)
default:     unknown()
}

Утверждение без ok (i.(string)) паникует при несовпадении типа. Пустой интерфейс any способен хранить значение любого типа и широко используется в обобщённом коде (хотя с появлением дженериков в Go 1.18 многие сценарии решаются типобезопасно через параметры типа).

Указатель или значение как приёмник

Если методы определены на указателе (func (t T) M()), то интерфейс удовлетворяет только T, но не T, так как нельзя взять адрес у не-адресуемого значения. Это влияет на то, какой набор методов попадает в itab.

---

6. Деферы (defer)

defer откладывает вызов функции до момента выхода из окружающей функции — при return, при достижении конца тела или при панике. Это идиоматичный способ освобождать ресурсы рядом с местом их захвата:

f, err := os.Open(name)
if err != nil { return err }
defer f.Close()   // выполнится при любом выходе из функции

Семантика

LIFO-порядок. Несколько defer выполняются в порядке, обратном объявлению (как стек):

defer fmt.Print("1")
defer fmt.Print("2")
defer fmt.Print("3")  // напечатает 3 2 1

Аргументы вычисляются сразу. Значения аргументов отложенного вызова фиксируются в момент выполнения defer, а не в момент фактического вызова:

i := 0
defer fmt.Println(i) // напечатает 0, хотя ниже i меняется
i = 10

Чтобы захватить актуальное значение, оборачивайте в замыкание: defer func() { fmt.Println(i) }().

Взаимодействие с именованными возвращаемыми значениями. Отложенная функция может изменить именованный результат, потому что выполняется после присваивания результата, но до фактического возврата:

func inc() (res int) {
    defer func() { res++ }()
    return 5          // вернёт 6
}

Это основной механизм, которым recover оборачивает ошибки в возвращаемое значение.

Производительность

Исторически defer был относительно дорогим (аллокация записи в связном списке деферов). В Go 1.13–1.14 появилась оптимизация open-coded defers: для функций с небольшим, статически известным числом деферов компилятор встраивает их прямо в код выхода, делая стоимость почти нулевой. В горячих циклах с динамическим числом деферов накладные расходы всё ещё заметны, поэтому в особо нагруженных местах иногда освобождают ресурсы вручную.

← ВСЕ ПОСТЫ