Миникурс
Go: указатели, интерфейсы, структуры
Откуда берутся nil-паники, почему интерфейс с nil-указателем не равен nil и как сделать нулевое значение полезным.
Главная идея курса: почти все паники в этой теме сводятся к одному — разыменованию nil (явному *p, неявному p.Field, вызову метода у nil-интерфейса) и к неудачному type assertion без ok. Отдельная ловушка, не паника, а логическая ошибка: интерфейс, хранящий nil-указатель, сам не равен nil.
Как проходить курс #
Все примеры проверены на Go 1.24 (go run). Каждый модуль: короткая теория → код с комментариями // вывод: или // PANIC: → типичные ошибки. В конце — шпаргалка по паникам и 8 задач «что выведет программа».
go vet- Указатели и разыменование — ~30 мин
- Интерфейсы и пустой интерфейс — ~40 мин
- Интерфейс в аргументах и в возвращаемом значении — ~30 мин
- Структуры, указатели на них, нулевые значения — ~30 мин
- Шпаргалка и самопроверка — ~20 мин
Совет: прогоняйте каждый пример сами и меняйте по одной строке — так поведение запоминается быстрее, чем по тексту.
Модуль 1. Указатели и разыменование #
В Go всё передаётся по значению, включая указатели: функция получает копию адреса. Через копию адреса можно менять данные, но не саму переменную-указатель вызывающего.
1.1 Базовые операции #
&x— взять адрес переменной (тип*T).*p— разыменовать: прочитать или записать значение по адресу.new(T)— выделить нулевое значениеTи вернуть*T. То же, что&T{}для структур.- Нулевое значение любого указателя —
nil. Арифметики указателей нет (только черезunsafe). - Escape analysis: вернуть
&localиз функции безопасно — компилятор перенесёт переменную в кучу.
func inc(x int) { x++ } // меняет копиюfunc incP(x *int) { *x++ } // меняет оригиналfunc reset(p *int) { p = nil } // меняет копию указателяa := 1inc(a) // a == 1incP(&a) // a == 2p := &areset(p) // p != nil, *p == 2 — указатель вызывающего не тронутq := new(int) // *q == 0Чтобы функция могла «обнулить» указатель вызывающего, нужен **T или возврат нового значения.
1.2 Что паникует #
Все случаи ниже дают runtime error: invalid memory address or nil pointer dereference.
var np *int_ = *np // PANIC: чтение через nil*np = 5 // PANIC: запись через nilvar pp **int_ = **pp // PANIC: pp == nilpp = &np_ = **pp // PANIC: *pp == nils := make([]*User, 2) // два nil-указателя внутри!_ = s[0].Name // PANICvar arr *[3]int_ = arr[0] // PANIC1.3 Что НЕ паникует (частые сюрпризы) #
type User struct{ Name string }func (u *User) Hello() string { if u == nil { return "hello, nil" } return "hello, " + u.Name}func (u User) ValHello() string { return u.Name }var u *Useru.Hello() // ок: метод с ресивером-указателем вызывается и с nilu.ValHello() // PANIC: для value-ресивера нужно скопировать *uu.Name // PANIC: неявное (*u).Namevar arr *[3]intlen(arr) // 3 — длина известна из типа, разыменования нетfor i := range arr {} // ок, итерация только по индексамВывод: nil-указатель паникует не в момент вызова метода, а в момент фактического доступа к памяти. Метод с pointer-ресивером можно сделать nil-безопасным.
1.4 Ловушки, не связанные с паникой #
- Адрес элемента слайса и
append. Послеappendс переаллокацией старый указатель смотрит в старый массив:
xs := []int{1}p0 := &xs[0]xs = append(xs, 2) // новый массив*p0 = 100fmt.Println(xs[0]) // 1, а не 100- Элемент map не адресуем:
&m["a"]иm["a"].Name = "B"— ошибки компиляции. Хранитеmap[string]*Userили переписывайте значение целиком. - Переменная цикла. С Go 1.22 у каждой итерации
for i := ...свояi, поэтомуappend(ptrs, &i)даёт0 1 2. До 1.22 все указатели смотрели на одну переменную (3 3 3).
Модуль 2. Интерфейсы и пустой интерфейс #
Значение интерфейса — это пара (динамический тип, значение). Интерфейс равен nil только когда оба поля пусты. Из этого следует почти всё поведение модуля.
2.1 Устройство и неявная реализация #
- Тип реализует интерфейс автоматически, если у него есть все методы — без
implements. any— псевдонимinterface{}: пустой набор методов, подходит любой тип.- Присваивание в интерфейс копирует значение. Для
var x any = dogпоследующее изменениеdogнаxне влияет (у указателя копируется адрес — тогда изменения видны). - Под капотом: непустой интерфейс —
iface(itab с типом и таблицей методов + указатель на данные), пустой —eface(тип + указатель на данные).
| Что присвоили | Тип внутри | Значение внутри | i == nil |
|---|---|---|---|
var i Speaker |
нет | нет | true |
i = (*Dog)(nil) |
*Dog |
nil |
false |
i = &Dog{"rex"} |
*Dog |
адрес | false |
i = Cat{} |
Cat |
копия | false |
2.2 Method set: кто реализует интерфейс #
type Speaker interface{ Speak() string }type Dog struct{ Name string }func (d *Dog) Speak() string { ... } // pointer-ресиверtype Cat struct{}func (Cat) Speak() string { return "meow" } // value-ресиверvar _ Speaker = Cat{} // окvar _ Speaker = &Cat{} // ок: у *T есть методы и T, и *Tvar _ Speaker = &Dog{} // окvar _ Speaker = Dog{} // ОШИБКА КОМПИЛЯЦИИ: у Dog нет Speak (он у *Dog)Правило: методы с value-ресивером есть и у T, и у *T; методы с pointer-ресивером — только у *T.
2.3 Что паникует #
var s Speakers.Speak() // PANIC: nil pointer dereference — нет типа, некуда диспетчеризоватьvar e any = 42_ = e.(string) // PANIC: interface conversion: interface {} is int, not stringvar e2 any_ = e2.(int) // PANIC: interface {} is nil, not int_ = e2.(fmt.Stringer) // PANIC: interface is nil, not fmt.Stringervar a1, a2 any = []int{1}, []int{1}_ = a1 == a2 // PANIC: comparing uncomparable type []int (компилируется!)m := map[any]int{}m[[]int{1}] = 1 // PANIC: hash of unhashable type []intvar sp Speaker = (*Dog)(nil)_ = sp.(*Dog).Name // assertion успешен, PANIC на доступе к полюСравнение и map-ключи через any проверяются только в рантайме. Несравнимые типы: slice, map, func и структуры, содержащие их.
2.4 Что НЕ паникует #
v, ok := e.(string) // "", false — форма comma-ok никогда не паникует_, ok = e2.(int) // false, даже для nil-интерфейсаvar d *Dogvar s Speaker = ds == nil // false!s.Speak() // ок, если метод проверяет d == nilvar x, y any = 1, int64(1)x == y // false: разные динамические типы, без паникиswitch t := v.(type) { // type switch тоже безопасенcase nil: // ловит только пустой интерфейсcase int, string: // t остаётся типа anycase Speaker: // t имеет тип Speaker}Правило для практики: в продуктовом коде используйте только v, ok := x.(T) или type switch. Однозначный x.(T) — только там, где неудача означает баг программиста.
Модуль 3. Интерфейс в аргументах и в возвращаемом значении #
Когда конкретное значение пересекает границу «конкретный тип → интерфейс» (передача в параметр-интерфейс или return из функции, возвращающей интерфейс), оно упаковывается в пару (тип, значение). Nil-указатель при этом превращается в непустой интерфейс. Это главная ловушка темы.
3.1 Ловушка typed nil в error #
type MyErr struct{ Code int }func (e *MyErr) Error() string { return fmt.Sprint("code ", e.Code) }// ПЛОХОfunc bad(fail bool) error { var e *MyErr if fail { e = &MyErr{500} } return e // при fail=false возвращается (*MyErr, nil), а не nil}err := bad(false)err == nil // false — вызывающий думает, что есть ошибкаerr.Error() // PANIC: Error() читает e.Code у nil-указателя// ХОРОШОfunc good(fail bool) error { if fail { return &MyErr{500} } return nil // явный нетипизированный nil}То же самое возникает неявно:
func concrete() *MyErr { return nil }var err error = concrete() // err != nil!func named() (err error) { var e *MyErr err = e return // named() != nil}Как защищаться:
- В функции, возвращающей
error, всегда пишитеreturn nilлитералом, а не переменную конкретного типа. - Не объявляйте
var err *MyErr— объявляйтеvar err error. - Если функция возвращает конкретный
*MyErr, проверяйте его наnilдо присваивания вerror. - Для извлечения конкретной ошибки используйте
errors.As(err, &target). - Линтеры:
staticcheck(проверка SA4023 — сравнение интерфейса с nil, которое никогда не истинно) ловит часть таких случаев;go vet— нет.
3.2 Интерфейс в параметрах #
Параметр-интерфейс ничего не гарантирует о «не-nil»: проверка x == nil внутри функции не отличит typed nil.
type Shape interface{ Area() float64 }type Sq struct{ S float64 }func (s *Sq) Area() float64 { return s.S * s.S }func Biggest(a, b Shape) Shape { if a.Area() > b.Area() { return a } return b}var sq *SqBiggest(sq, &Sq{2}) // PANIC внутри Area: s.S у nilBiggest(nil, &Sq{2}) // PANIC: вызов метода у nil-интерфейсаfunc describe(v any) string { if v == nil { return "nil" } return fmt.Sprintf("%T", v)}var p *intdescribe(nil) // "nil"describe(p) // "*int" — проверка на nil не сработала3.3 Интерфейс на входе и интерфейс на выходе #
Если функция принимает интерфейс и возвращает интерфейс, динамический тип проходит насквозь: wrap(r io.Reader) io.Reader { return r } вернёт ту же пару (тип, значение). Последствия:
- typed nil на входе остаётся typed nil на выходе:
wrap((*strings.Reader)(nil)) == nil→false, аReadпо нему паникует; - из результата можно достать исходный тип:
r.(*strings.Reader)сработает; - конвертация между интерфейсами (
io.ReadCloser→io.Reader) — проверка при компиляции, если набор методов совместим, и runtime-assertion (x.(io.Closer)), если нужно расширить набор методов.
| Сигнатура | Что получает вызывающий | Риск |
|---|---|---|
func F() *T |
конкретный указатель, nil проверяется честно |
низкий |
func F() I |
пару (тип, значение) | typed nil, если вернули переменную конкретного типа |
func F(x I) I |
то, что передали, с тем же динамическим типом | typed nil проходит насквозь; нужен assertion, чтобы вернуть конкретный тип |
func F(x I) *T |
конкретный тип | функция должна сама проверить, что внутри x |
Правило Go-идиоматики: принимайте интерфейсы, возвращайте конкретные типы. Исключения — error и случаи, когда возвращаемая реализация действительно должна быть скрыта (фабрики, io.Reader-обёртки).
Модуль 4. Структуры, указатели на них, нулевые значения #
Нулевое значение структуры — это структура, где каждое поле имеет своё нулевое значение. Опасны не сами поля, а ссылочные поля в nil: map, указатель, интерфейс, функция, канал.
4.1 Нулевые значения полей #
type Config struct { Name string // "" Port int // 0 Debug bool // false Tags []string // nil Meta map[string]string // nil Next *Config // nil Logger fmt.Stringer // nil-интерфейс Ch chan int // nil Handler func() // nil mu sync.Mutex // готов к использованию}var c Config| Поле в nil | Безопасно | Паника |
|---|---|---|
[]T |
len, range, append |
c.Tags[0] — index out of range |
map[K]V |
чтение m[k], len, range, delete |
запись m[k] = v — assignment to entry in nil map |
*T |
сравнение с nil, вызов pointer-метода с проверкой nil | доступ к полю, value-метод |
| интерфейс | сравнение с nil, comma-ok assertion | вызов метода |
func() |
сравнение с nil | вызов |
chan T |
— (send/recv блокируются навсегда) | close — close of nil channel |
sync.Mutex |
Lock/Unlock |
копирование структуры с мьютексом (не паника, а гонки; ловит go vet) |
Каналы вне nil тоже паникуют: close закрытого канала и отправка в закрытый канал. Чтение из закрытого — без паники: 0, false.
4.2 Значение или указатель #
type Counter struct{ n int }func (c Counter) IncV() { c.n++ } // меняет копиюfunc (c *Counter) IncP() { c.n++ } // меняет оригиналvar cnt Countercnt.IncV()cnt.IncP() // Go сам берёт &cnt, так как cnt адресуем// cnt.n == 1var pc *Counterpc.IncP() // PANIC: c.n у nilnew(T),&T{}иvar t T; &tдают одно и то же — указатель на нулевую структуру.- Присваивание структуры копирует поля, но слайсы и map внутри копируются как заголовки — данные общие:
a := Config{Name: "a", Tags: []string{"t"}, Meta: map[string]string{}}b := a // go vet: assignment copies lock value (из-за mu)b.Name = "b"; b.Tags[0] = "changed"; b.Meta["k"] = "v"// a.Name == "a", a.Tags[0] == "changed", a.Meta["k"] == "v"- Сравнение
==работает, если все поля сравнимы:P{1,2} == P{1,2}→true. Указатели сравниваются по адресу:&P{1,2} == &P{1,2}→false. Структура со слайсом или map — ошибка компиляции при==.
4.3 Ловушки с коллекциями структур #
ps := []P{{1, 1}, {2, 2}}for _, p := range ps { p.X = 100 } // p — копия, ps не меняетсяfor i := range ps { ps[i].X = 100 } // так меняетсяm := map[string]P{"a": {1, 1}}// m["a"].X = 5 // ошибка компиляцииpp := m["a"]; pp.X = 5; m["a"] = pp // прочитать — изменить — записатьmp := map[string]*P{}mp["z"].X = 1 // PANIC: отсутствующий ключ даёт nil-указатель4.4 Встраивание (embedding) nil-указателя #
type Base struct{ ID int }func (b *Base) GetID() int { return b.ID }type Child struct{ *Base; Name string }ch := Child{Name: "c"} // Base == nilch.GetID() // PANIC внутри методаch.ID // PANIC: это ch.Base.IDВстраивание по указателю удобно, но нулевое значение Child уже не «готово к использованию». Встраивайте по значению, если нет причины делать иначе.
4.5 Как делать нулевое значение полезным #
- Проектируйте тип так, чтобы
var x Tработал: ленивая инициализация map в методе (if c.Meta == nil { c.Meta = map[string]string{} }). - Если без инициализации нельзя — конструктор
NewT(...) *Tи неэкспортируемые поля. - Методы, меняющие состояние или содержащие мьютекс, — с pointer-ресивером; не смешивайте ресиверы без причины.
Шпаргалка: паникует или нет #
Все строки проверены на Go 1.24. «Компиляция» означает, что код не соберётся — это лучше паники.
| Конструкция | Результат | Сообщение |
|---|---|---|
*p, *p = v при p == nil |
паника | nil pointer dereference |
p.Field при p == nil |
паника | nil pointer dereference |
p.ValueMethod() при p == nil |
паника | nil pointer dereference |
p.PtrMethod() при p == nil |
ок, пока метод не трогает поля | — |
len(p) для nil *[N]T |
ок, вернёт N | — |
i.Method() при nil-интерфейсе |
паника | nil pointer dereference |
i.Method() при typed nil в интерфейсе |
зависит от метода | — |
x.(T) при неверном типе |
паника | interface conversion: … is int, not string |
x.(T) при nil-интерфейсе |
паника | interface conversion: … is nil, not T |
v, ok := x.(T) |
ок всегда | — |
| type switch | ок всегда | — |
a == b для any с []T / map / func внутри |
паника | comparing uncomparable type |
m[k] = v для map[any]V с несравнимым ключом |
паника | hash of unhashable type |
m[k] = v при nil map |
паника | assignment to entry in nil map |
чтение, len, range, delete для nil map |
ок | — |
append, len, range для nil slice |
ок | — |
s[i] вне длины |
паника | index out of range |
вызов nil func |
паника | nil pointer dereference |
close(nil chan) |
паника | close of nil channel |
close закрытого канала |
паника | close of closed channel |
| отправка в закрытый канал | паника | send on closed channel |
| чтение из закрытого канала | ок, 0, false |
— |
поле встроенного nil *Base |
паника | nil pointer dereference |
m["a"].X = 1 для map[string]P |
компиляция | cannot assign to struct field in map |
var _ I = T{} при pointer-методах |
компиляция | does not implement (method has pointer receiver) |
== для структур со слайсом |
компиляция | struct containing []T cannot be compared |
Не паника, но баг:
- typed nil в
errorили любом интерфейсе:err != nilпри «отсутствии» ошибки; - изменение копии в
rangeпо слайсу структур и в value-ресивере; - указатель на элемент слайса после
append; - копирование структуры с
sync.Mutex(ловитgo vet).
Самопроверка: что выведет программа #
Сначала ответьте сами, потом раскройте ответ. Общие объявления:
type E struct{}func (*E) Error() string { return "E" }func f1() error { var e *E; return e }type A struct{ v int }func (a A) Set(x int) { a.v = x }func (a *A) SetP(x int) { a.v = x }type Animal interface{ Sound() string }type Dog struct{}func (*Dog) Sound() string { return "woof" }func get(ok bool) Animal { var d *Dog if ok { d = &Dog{} } return d}Q1. fmt.Println(f1() == nil)
Показать ответ
Вывод: false
Typed nil: в error лежит (*E, nil).
Q2. a := A{}; a.Set(5); fmt.Print(a.v, " "); a.SetP(7); fmt.Println(a.v)
Показать ответ
Вывод: 0 7
Set меняет копию, SetP — оригинал.
Q3. an := get(false); fmt.Println(an == nil, an.Sound())
Показать ответ
Вывод: false woof
Интерфейс не nil; Sound не читает поля, поэтому nil-ресивер не паникует.
Q4. var x any = (*int)(nil); fmt.Println(x == nil, x.(*int) == nil)
Показать ответ
Вывод: false true
Интерфейс непустой, а значение внутри — nil-указатель.
Q5. type S struct{ m map[string]int }; var s S; fmt.Println(s.m["k"], len(s.m)) — а что будет при s.m["k"] = 1?
Показать ответ
Вывод: 0 0; запись — паника assignment to entry in nil map
Чтение nil map безопасно, запись нет.
Q6. err := fmt.Errorf("wrap: %w", f1()); var t *E; fmt.Println(errors.As(err, &t), t == nil)
Показать ответ
Вывод: true true
errors.As нашёл значение типа *E в цепочке, но это nil-указатель.
Q7. var i1, i2 any = 1, 1.0; fmt.Println(i1 == i2)
Показать ответ
Вывод: false
Динамические типы int и float64 различаются.
Q8. var s2 fmt.Stringer; _ = s2.(fmt.Stringer)
Показать ответ
Вывод: паника interface conversion: interface is nil, not fmt.Stringer
Однозначный assertion на nil-интерфейсе паникует даже к тому же интерфейсу.
Практические задания #
- Перепишите
bad()из модуля 3 так, чтобыerr == nilпри успехе, и напишите тест на это. - Сделайте тип
Registryс полемmap[string]Handler, у которого нулевое значение готово к работе (ленивая инициализация вRegister). - Напишите
SafeCall(f func()) (err error), которая превращает панику в ошибку черезrecover, и проверьте её на всех строках шпаргалки. - Прогоните
go vetиstaticcheckна своём проекте и найдите typed nil и копирование мьютексов.
Это конец курса
Отметьте разделы пройденными, чтобы видеть прогресс в каталоге.
← Ко всем туториалам