Модуль: Core Go · Уровень: Senior

TL;DR#

Go — статически типизированный язык со структурной концепцией underlying type, на которой строятся правила assignability и convertibility. type Foo int создаёт новый defined (named) type с тем же underlying type, но другой identity; type Foo = int — это alias, полностью идентичный int. Поверх системы типов лежит физический memory layout: компилятор выравнивает поля структур по их alignment, добавляя padding, поэтому порядок полей напрямую влияет на Sizeof. Знание zerobase для struct{}, правил comparable-типов и iota — обязательный senior-минимум.

Теория#

Defined (named) types vs type aliases#

В Go есть два разных механизма объявления типов, и их путают чаще всего.

type Celsius float64   // defined type: новый тип, identity != float64
type Alias   = float64 // alias: то же самое имя для float64
type Foo int (defined)type Foo = int (alias)
Новый тип?Да, новая type identityНет, просто другое имя
Underlying typeintint
Можно объявлять методы?ДаТолько если базовый тип в том же пакете
reflect.TypeOf().Name()"Foo""int"
Совместимость с int без конверсииНет (нужна явная конверсия в большинстве случаев)Полная, это одно и то же

Defined type получает собственную identity. Даже если underlying type совпадает, два defined-типа не присваиваются друг другу без явной конверсии:

type Meters int
type Feet int
var m Meters = 10
var f Feet = m       // ОШИБКА: cannot use m (Meters) as Feet
var f2 Feet = Feet(m) // OK, явная конверсия

Defined type не наследует методы базового типа (если базовый — defined type с методами), но наследует поля и методы, если underlying — структура или встроенный интерфейс через embedding. Важный нюанс: type T2 T1, где у T1 есть методы, — T2 не получает методы T1. А вот type T2 = T1 (alias) — получает, т.к. это тот же тип.

Type alias появился в Go 1.9 в первую очередь для постепенного рефакторинга больших кодовых баз (перенос типа между пакетами без слома кода). Самый известный пример из stdlib — byte = uint8 и rune = int32. Это встроенные алиасы, поэтому byte и uint8 взаимозаменяемы абсолютно.

type byte = uint8
type rune = int32

С Go 1.24 алиасы могут быть generic: type Set[T comparable] = map[T]struct{}.

Underlying type#

Underlying type — фундамент всей системы типов. Алгоритм определения:

  • Для предобъявленных типов (int, string, …) и литералов типов (struct{...}, []T, map[K]V) underlying type — это сам тип.
  • Для defined type underlying type — это underlying type типа, указанного в объявлении (рекурсивно).
type A []int        // underlying = []int
type B A            // underlying = []int (НЕ A!)
type C B            // underlying = []int

Underlying type определяет:

  • какие операции допустимы (можно ли индексировать, делать range, складывать),
  • правила assignability и convertibility,
  • удовлетворение constraint’ам в дженериках (~[]int матчит любой тип с underlying []int).

Assignability (присваиваемость)#

Значение x типа V присваивается переменной типа T, если хотя бы одно из (основные правила):

  1. V и T идентичны.
  2. V и T имеют идентичный underlying type, и хотя бы один из них не является defined type.
  3. T — интерфейс, и V реализует T.
  4. V — двунаправленный канал, T — канал с совместимым направлением и идентичным элементом, и один из них не defined.
  5. x — предобъявленный nil, а T — pointer/func/slice/map/chan/interface.
  6. x — нетипизированная константа, представимая значением типа T.

Правило 2 — ключевое и неочевидное:

type MySlice []int
var ms MySlice
var s []int = ms  // OK! []int не defined type, underlying совпадает
ms = s            // OK по той же причине

type A int
type B int
var a A
var b B = a       // ОШИБКА: оба defined types, underlying совпадает, но правило 2 не работает

Convertibility (конвертируемость)#

Конверсия T(x) допустима, если (основные случаи):

  • x присваивается T (assignability ⊂ convertibility);
  • V и T имеют идентичный underlying type (игнорируя теги структур);
  • оба — указатели на типы с идентичным underlying (игнорируя теги, с Go 1.8);
  • оба числовые (int/float/complex) — возможна потеря данных;
  • string[]byte / []rune / целое (rune → string даёт символ Unicode);
  • срез → массив или указатель на массив (Go 1.20: []T[N]T, до этого только *[N]T).

Конверсия числовых типов — это truncation/округление, а не паника:

var i int32 = 300
var b byte = byte(i) // 300 % 256 = 44, молча
var f float64 = 3.99
var n int = int(f)   // 3, отбрасывание дробной части (truncation к нулю)

Конверсия string ↔ []byte по умолчанию копирует данные (т.к. string иммутабельна). Компилятор оптимизирует частные случаи: []byte(s) в range, ключ map (m[string(b)]), сравнение — без аллокации. Для явного безопасного zero-copy в Go 1.20+ есть unsafe.StringData/unsafe.SliceData/unsafe.String/unsafe.Slice.

s := "hello"
b := []byte(s)   // копия, отдельная аллокация
b[0] = 'H'       // не затрагивает s, т.к. это копия

Alignment и memory layout структур#

Каждый тип имеет alignment (выравнивание) — адрес значения должен быть кратен ему. На amd64/arm64 типично:

ТипРазмерAlignment
bool, int8, uint811
int16, uint1622
int32, uint32, float3244
int64, uint64, float64, int, uint, pointer88 (на 64-бит)
string16 (ptr+len)8
slice24 (ptr+len+cap)8
interface16 (type+data)8

Компилятор раскладывает поля структуры в объявленном порядке, вставляя padding, чтобы каждое поле начиналось с адреса, кратного его alignment. Alignment всей структуры = max alignment её полей. Размер структуры округляется вверх до кратного её alignment (trailing padding) — это нужно, чтобы в массиве структур каждый элемент тоже был выровнен.

Пример: порядок полей влияет на размер#

type Bad struct {
    a bool   // 1 байт, offset 0
    // 7 байт padding (b требует выравнивания по 8)
    b int64  // 8 байт, offset 8
    c bool   // 1 байт, offset 16
    // 7 байт trailing padding (alignment структуры = 8)
} // Sizeof = 24

type Good struct {
    b int64  // offset 0
    a bool   // offset 8
    c bool   // offset 9
    // 6 байт trailing padding
} // Sizeof = 16

Правило оптимизации: сортируй поля от большего alignment/размера к меньшему. Это минимизирует padding. Экономия 8 байт на структуру = мегабайты в слайсе из миллионов элементов и меньше давления на кэш/GC.

Ещё пример с тремя int32:

type T struct {
    a int32 // offset 0
    b int32 // offset 4
    c int32 // offset 8
} // Sizeof = 12, alignment = 4

unsafe.Sizeof / Alignof / Offsetof#

import "unsafe"

unsafe.Sizeof(Bad{})           // 24 — размер в байтах (compile-time константа)
unsafe.Alignof(Bad{})          // 8
unsafe.Offsetof(Bad{}.b)       // 8 — смещение поля от начала структуры

Все три — compile-time константы (кроме случаев с variable-length, которых в Go нет). Sizeof возвращает shallow размер: для slice это 24 (заголовок), а не размер backing array; для string — 16, не длина данных.

Пустая структура struct{} и zerobase#

struct{} занимает 0 байт: unsafe.Sizeof(struct{}{}) == 0. Это используется когда нужен факт наличия, а не данные.

Под капотом: все нулевого размера значения (struct{}, [0]int) указывают на единственную глобальную переменную runtime.zerobase. Поэтому взятие адреса &struct{}{} валидно и обычно даёт один и тот же адрес — аллокации кучи не происходит для самих данных.

// Set на map: значение не занимает память
set := map[string]struct{}{}
set["a"] = struct{}{}
_, ok := set["a"] // ok == true

// Сигнальный канал
done := make(chan struct{})
close(done)

Нюанс: для полей структуры нулевого размера, стоящих последними, компилятор (с Go 1.5+) может добавить padding-байт, если структура иначе была бы нулевого размера, но имеет другие поля — чтобы взятие адреса последнего поля не выходило за границу аллокации. Для самостоятельного struct{} размер строго 0.

iota#

iota — встроенный счётчик константных деклараций внутри const-блока. Сбрасывается в 0 в начале каждого const (...) и увеличивается на 1 на каждой строке ConstSpec (не на каждый идентификатор).

const (
    A = iota // 0
    B        // 1 (выражение повторяется: = iota)
    C        // 2
)

Выражения с iota, пропуски через _, битовые флаги:

const (
    _  = iota             // 0, пропускаем
    KB = 1 << (10 * iota) // 1 << 10
    MB                    // 1 << 20
    GB                    // 1 << 30
)

// Битовые флаги
type Flag uint
const (
    Read Flag = 1 << iota // 1
    Write                 // 2
    Exec                  // 4
)
var perm = Read | Write   // 3

Тонкости:

  • iota увеличивается на каждой строке, даже если в ней несколько констант: const ( a, b = iota, iota+1; c, d ) → a=0,b=1,c=1,d=2.
  • В одной строке iota одинаков для всех идентификаторов.
  • Пропуск строки невозможен без объявления — используйте _.

Типы как значения, comparable, zero values#

Comparable типы (можно ==/!= и использовать как ключ map):

  • booleans, числа, strings, pointers, channels;
  • interface-типы (сравниваются по динамическому типу+значению — паника в рантайме, если динамический тип не comparable);
  • структуры, если все поля comparable;
  • массивы, если элемент comparable.

Не comparable: slices, maps, functions (только с nil). Сравнение паникует/не компилируется:

type Key struct{ a int; b string } // comparable, можно ключом map
type Bad struct{ s []int }         // НЕ comparable

var x any = []int{1}
var y any = []int{1}
_ = x == y // компилируется, но ПАНИКА в рантайме: comparing uncomparable type []int

В дженериках constraint comparable (Go 1.20+) допускает типы, чьё == может паниковать в рантайме (например any), — это намеренно расширили.

Zero value — значение по умолчанию при объявлении без инициализации. Память всегда обнуляется:

ТипZero value
числа0
boolfalse
string"" (не nil!)
pointer, slice, map, chan, func, interfacenil
structструктура из zero value всех полей
arrayмассив из zero value элементов

Важно: zero value слайса/мапы — nil, но nil-слайс можно читать/append/len/range; nil-мапу можно читать, но запись паникует.

Подводные камни / gotchas#

  • Defined type не получает методы базового типа. type MyInt int от типа с методами не унаследует их; alias — унаследует.
  • Правило assignability №2 легко спутать: []intMySlice работает (один не defined), но AB (оба defined) — нет.
  • Числовая конверсия молча теряет данные (truncation), без паники и предупреждения. byte(300) → 44.
  • string([]int{65}) ≠ “65”: конверсия среза int не определена; а string(rune(65)) → “A” (Unicode code point). string(65) (с константой) тоже даёт “A”, и go vet это предупреждает.
  • unsafe.Sizeof shallow: для slice даёт 24, не размер данных. Частая ошибка при оценке памяти.
  • Порядок полей в часто-аллоцируемых структурах: неоптимальный порядок раздувает память на десятки процентов.
  • Сравнение интерфейсов с не-comparable динамикой компилируется, но паникует в рантайме.
  • string zero value — "", а не nil; nil к string неприменим, в отличие от slice.
  • iota не сбрасывается между ConstSpec одного блока, только между блоками; и считает строки, а не идентификаторы.
  • map со значением struct{} vs bool: struct{} экономит память, но bool-set читаемее; не оптимизируйте преждевременно.
  • Padding виден в unsafe.Offsetof: можно проверять layout в тестах для performance-critical структур.

Вопросы на собеседовании#

В: В чём разница между type T1 int и type T2 = int? О: type T1 int создаёт новый defined type с собственной identity: его нельзя присвоить int без конверсии, можно объявлять методы, reflect покажет имя T1. type T2 = int — alias, полностью идентичен int: те же методы, та же identity, взаимозаменяемы. Алиасы введены в 1.9 для рефакторинга/переноса типов между пакетами.

В: Что такое underlying type и зачем он нужен? О: Underlying type определяется рекурсивно: для предобъявленных типов и литералов — это они сами, для defined type — underlying type его базы. Он управляет допустимыми операциями, правилами assignability/convertibility и удовлетворением constraint’ам ~T в дженериках. Например type B A; type C B — underlying у обоих []int, если A это []int.

В: Почему var s []int = mySlice работает, а var b B = a (оба defined) — нет? О: По правилу assignability: значения присваиваются, если underlying типы идентичны и хотя бы один не является defined type. []int — не defined, поэтому MySlice ↔ []int работает. Два defined типа A и B с одинаковым underlying не присваиваются без явной конверсии.

В: Почему перестановка полей структуры меняет её размер? Покажите пример. О: Компилятор выравнивает каждое поле по его alignment, вставляя padding. {bool; int64; bool} = 24 байта (7 байт padding после первого bool + 7 trailing). {int64; bool; bool} = 16. Правило: сортировать поля от большего alignment к меньшему. Alignment структуры = max по полям, общий размер округляется вверх до кратного alignment (чтобы массивы оставались выровненными).

В: Чему равен unsafe.Sizeof для slice и string? Почему? О: Slice — 24 байта (ptr 8 + len 8 + cap 8), string — 16 (ptr 8 + len 8) на 64-бит. Это размер заголовка, а не данных — Sizeof shallow. Backing array лежит отдельно в куче/стеке.

В: Сколько байт занимает struct{}{} и где это применяют? О: 0 байт. Все zero-size значения указывают на runtime.zerobase, поэтому самих данных не аллоцируют. Применяют для set’ов (map[T]struct{}), сигнальных каналов (chan struct{}), маркеров. Экономит память по сравнению с bool-значением.

В: Как работает iota? Что выведет блок с пропуском строки? О: iota — счётчик ConstSpec в const-блоке, сбрасывается в 0 в начале блока, +1 на каждую строку (не на каждый идентификатор). Пустых строк нет — пропуск делают через _ = iota. В одной строке у всех идентификаторов один iota. Классика — битовые флаги 1 << iota и единицы размера 1 << (10*iota).

В: Какие типы comparable? Что произойдёт при сравнении интерфейсов с разным содержимым? О: Comparable: числа, bool, string, pointer, chan, массивы и структуры из comparable полей, интерфейсы. Не comparable: slice, map, func (только с nil). Интерфейсы сравниваются по динамическому типу и значению; если динамический тип не comparable (например slice внутри any), сравнение компилируется, но паникует в рантайме.

В: Что вернёт string(65) и []byte("ab")? Есть ли аллокация? О: string(rune(65))"A" (Unicode code point U+0041), не “65”; go vet ругается на string(int). []byte("ab") обычно копирует данные (string иммутабельна) — отдельная аллокация. Компилятор оптимизирует zero-copy в частных случаях (ключ map, range, сравнение). Для явного zero-copy — unsafe.String/Slice (Go 1.20+).

На что копают на senior+#

  • Memory layout под нагрузкой. Senior не просто знает про padding, а умеет посчитать Sizeof руками, объяснить trailing padding (выравнивание элементов массива) и оценить выигрыш на слайсе из миллионов структур через призму cache lines (64 байта) и давления на GC.
  • False sharing и cache lines. Follow-up: как padding между полями (например _ [64]byte) защищает от false sharing в конкурентных счётчиках; почему sync.Pool/atomics иногда паддят структуры.
  • Атомарность 64-бит на 32-бит платформах. На 386/arm 64-битные поля должны быть выровнены по 8 для atomic; до Go 1.19 это требовало ручного размещения первым полем. Senior знает про atomic.Int64 (Go 1.19), который гарантирует выравнивание.
  • ~T в constraint’ах. Почему ~[]int использует именно underlying type, и как это связано с defined types в дженериках.
  • Comparable в дженериках (Go 1.20). Расширение: comparable теперь принимает типы вроде any, у которых == может паниковать. Follow-up — почему это сделали и какие риски.
  • unsafe и эволюция API. Знание unsafe.Add, unsafe.Slice, unsafe.String, SliceData/StringData вместо устаревших паттернов с reflect.SliceHeader; понимание правил garbage collector safety при работе с unsafe.Pointer.
  • Conversion edge cases. Срез → массив ([N]T(s) паникует если len < N), срез → *[N]T; конверсии указателей на структуры с разными тегами (Go 1.8); потеря точности float→int и поведение NaN/Inf.
  • reflect и identity. Как reflect.Type отличает defined type от alias, и почему DeepEqual ведёт себя по-разному для разных type identity.