Модуль: 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: то же самое имя для float64type Foo int (defined) | type Foo = int (alias) | |
|---|---|---|
| Новый тип? | Да, новая type identity | Нет, просто другое имя |
| Underlying type | int | int |
| Можно объявлять методы? | Да | Только если базовый тип в том же пакете |
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 = []intUnderlying type определяет:
- какие операции допустимы (можно ли индексировать, делать range, складывать),
- правила assignability и convertibility,
- удовлетворение constraint’ам в дженериках (
~[]intматчит любой тип с underlying[]int).
Assignability (присваиваемость)#
Значение x типа V присваивается переменной типа T, если хотя бы одно из (основные правила):
VиTидентичны.VиTимеют идентичный underlying type, и хотя бы один из них не является defined type.T— интерфейс, иVреализуетT.V— двунаправленный канал,T— канал с совместимым направлением и идентичным элементом, и один из них не defined.x— предобъявленныйnil, аT— pointer/func/slice/map/chan/interface.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, uint8 | 1 | 1 |
| int16, uint16 | 2 | 2 |
| int32, uint32, float32 | 4 | 4 |
| int64, uint64, float64, int, uint, pointer | 8 | 8 (на 64-бит) |
| string | 16 (ptr+len) | 8 |
| slice | 24 (ptr+len+cap) | 8 |
| interface | 16 (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 = 4unsafe.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 |
| bool | false |
| string | "" (не nil!) |
| pointer, slice, map, chan, func, interface | nil |
| struct | структура из zero value всех полей |
| array | массив из zero value элементов |
Важно: zero value слайса/мапы — nil, но nil-слайс можно читать/append/len/range; nil-мапу можно читать, но запись паникует.
Подводные камни / gotchas#
- Defined type не получает методы базового типа.
type MyInt intот типа с методами не унаследует их; alias — унаследует. - Правило assignability №2 легко спутать:
[]int↔MySliceработает (один не defined), ноA↔B(оба defined) — нет. - Числовая конверсия молча теряет данные (truncation), без паники и предупреждения.
byte(300)→ 44. string([]int{65})≠ “65”: конверсия среза int не определена; аstring(rune(65))→ “A” (Unicode code point).string(65)(с константой) тоже даёт “A”, иgo vetэто предупреждает.unsafe.Sizeofshallow: для slice даёт 24, не размер данных. Частая ошибка при оценке памяти.- Порядок полей в часто-аллоцируемых структурах: неоптимальный порядок раздувает память на десятки процентов.
- Сравнение интерфейсов с не-comparable динамикой компилируется, но паникует в рантайме.
stringzero value —"", а неnil; nil к string неприменим, в отличие от slice.- iota не сбрасывается между ConstSpec одного блока, только между блоками; и считает строки, а не идентификаторы.
- map со значением
struct{}vsbool: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.