pyway/tutor

Миникурс

Go: указатели, интерфейсы, структуры

Откуда берутся nil-паники, почему интерфейс с nil-указателем не равен nil и как сделать нулевое значение полезным.

Уровень
Средний
Время
~2,5 часа
Разделов
7
Обновлено

Главная идея курса: почти все паники в этой теме сводятся к одному — разыменованию nil (явному *p, неявному p.Field, вызову метода у nil-интерфейса) и к неудачному type assertion без ok. Отдельная ловушка, не паника, а логическая ошибка: интерфейс, хранящий nil-указатель, сам не равен nil.

Как проходить курс #

Все примеры проверены на Go 1.24 (go run). Каждый модуль: короткая теория → код с комментариями // вывод: или // PANIC: → типичные ошибки. В конце — шпаргалка по паникам и 8 задач «что выведет программа».

строка паникует в рантаймене компилируется или ругается go vet
  1. Указатели и разыменование — ~30 мин
  2. Интерфейсы и пустой интерфейс — ~40 мин
  3. Интерфейс в аргументах и в возвращаемом значении — ~30 мин
  4. Структуры, указатели на них, нулевые значения — ~30 мин
  5. Шпаргалка и самопроверка — ~20 мин

Совет: прогоняйте каждый пример сами и меняйте по одной строке — так поведение запоминается быстрее, чем по тексту.

Модуль 1. Указатели и разыменование #

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

1.1 Базовые операции #

  • &x — взять адрес переменной (тип *T).
  • *p — разыменовать: прочитать или записать значение по адресу.
  • new(T) — выделить нулевое значение T и вернуть *T. То же, что &T{} для структур.
  • Нулевое значение любого указателя — nil. Арифметики указателей нет (только через unsafe).
  • Escape analysis: вернуть &local из функции безопасно — компилятор перенесёт переменную в кучу.
go
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.

go
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]            // PANIC

1.3 Что НЕ паникует (частые сюрпризы) #

go
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 с переаллокацией старый указатель смотрит в старый массив:
go
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: кто реализует интерфейс #

go
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 Что паникует #

go
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 Что НЕ паникует #

go
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 #

go
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}

То же самое возникает неявно:

go
func concrete() *MyErr { return nil }var err error = concrete() // err != nil!func named() (err error) {    var e *MyErr    err = e    return       // named() != nil}

Как защищаться:

  1. В функции, возвращающей error, всегда пишите return nil литералом, а не переменную конкретного типа.
  2. Не объявляйте var err *MyErr — объявляйте var err error.
  3. Если функция возвращает конкретный *MyErr, проверяйте его на nil до присваивания в error.
  4. Для извлечения конкретной ошибки используйте errors.As(err, &target).
  5. Линтеры: staticcheck (проверка SA4023 — сравнение интерфейса с nil, которое никогда не истинно) ловит часть таких случаев; go vet — нет.

3.2 Интерфейс в параметрах #

Параметр-интерфейс ничего не гарантирует о «не-nil»: проверка x == nil внутри функции не отличит typed nil.

go
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 Нулевые значения полей #

go
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 Значение или указатель #

go
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 у nil
  • new(T), &T{} и var t T; &t дают одно и то же — указатель на нулевую структуру.
  • Присваивание структуры копирует поля, но слайсы и map внутри копируются как заголовки — данные общие:
go
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 Ловушки с коллекциями структур #

go
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-указателя #

go
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 Как делать нулевое значение полезным #

  1. Проектируйте тип так, чтобы var x T работал: ленивая инициализация map в методе (if c.Meta == nil { c.Meta = map[string]string{} }).
  2. Если без инициализации нельзя — конструктор NewT(...) *T и неэкспортируемые поля.
  3. Методы, меняющие состояние или содержащие мьютекс, — с 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).

Самопроверка: что выведет программа #

Сначала ответьте сами, потом раскройте ответ. Общие объявления:

go
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 и копирование мьютексов.

Это конец курса

Отметьте разделы пройденными, чтобы видеть прогресс в каталоге.

← Ко всем туториалам