Skip to content

Ошибка nil-указателя

Введение

Во время сеанса кодирования мне нужно было вызвать метод Close() для закрытия нескольких объектов, как в коде ниже:

go
type A struct {
  b B
  c C
  d D
}

func (a A) Close() error {
  if a.b != nil {
    if err := a.b.Close(); err != nil {
      return err
    }
  }

  if a.c != nil {
    if err := a.c.Close(); err != nil {
      return err
    }
  }

    if a.d != nil {
        if err := a.d.Close(); err != nil {
            return err
        }
    }

  return nil
}

Но написание стольких операторов if казалось неэлегантным. Поскольку B, C и D все реализуют метод Close, я подумал, что это можно сделать более лаконично. Поэтому я поместил их в срез и использовал цикл:

go
func (a A) Close() error {
  closers := []io.Closer{
    a.b,
    a.c,
    a.d,
  }

  for _, closer := range closers {
    if closer != nil {
      if err := closer.Close(); err != nil {
        return err
      }
    }
  }
  return nil
}

Это казалось лучше, поэтому давайте запустим и посмотрим:

go
func main() {
  var a A
  if err := a.Close(); err != nil {
    panic(err)
  }
  fmt.Println("success")
}

Результат был неожиданным — это фактически упало. Сообщение об ошибке указывало, что метод не может быть вызван на nil-получателе. if closer != nil в цикле, казалось, не имел эффекта фильтрации:

panic: value method main.B.Close called using nil *B pointer

Приведённый выше пример — это упрощённая версия ошибки, с которой я столкнулся. Многие новички могут совершить такую ошибку сначала. Позвольте мне объяснить, что происходит.

Интерфейс

В предыдущих разделах упоминалось, что nil является нулевым значением для ссылочных типов, таких как срезы, map-ы, каналы, функции, указатели и интерфейсы. Для срезов, map-ов, каналов и функций все они могут рассматриваться как указатели, указывающие на конкретные реализации.

Но интерфейсы отличаются. Интерфейс состоит из двух вещей: типа и значения.

При попытке присвоить nil переменной, это не компилируется и показывает следующее сообщение:

use of untyped nil in assignment

Содержание примерно указывает, что переменная со значением untyped nil не может быть объявлена. Поскольку существует untyped nil, должен быть и typed nil, и эта ситуация часто встречается с интерфейсами. Рассмотрим простой пример:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(pa)
  fmt.Println(pa == nil)
}

Вывод:

<nil>
true
<nil>
false

Результат очень странный. Очевидно, что pa выводит nil, но оно не равно nil. Мы можем использовать рефлексию, чтобы увидеть, что это на самом деле:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.TypeOf(pa))
  fmt.Println(reflect.ValueOf(pa))
}

Вывод:

<nil>
true
*int
<nil>

Из результата видно, что это на самом деле (*int)(nil). Это означает, что pa хранит тип *int, в то время как его фактическое значение равно nil. При выполнении проверки равенства на значениях интерфейсов, сначала проверяется, равны ли их типы. Если типы не равны, напрямую определяется, что они не равны, затем проверяется, равны ли значения. Эта логика сравнения интерфейсов может быть найдена в функции cmd/compile/internal/walk.walkCompare.

Поэтому, если вы хотите, чтобы интерфейс был равен nil, и его значение, и тип должны быть nil. Это потому, что тип в интерфейсе на самом деле также является указателем:

go
type iface struct {
  tab  *itab
  data unsafe.Pointer
}

Если вы хотите обойти тип и напрямую проверить, равно ли его значение nil, вы можете использовать рефлексию. Вот пример:

go
func main() {
  var p *int
  fmt.Println(p)
  fmt.Println(p == nil)
  var pa any
  pa = p
  fmt.Println(reflect.ValueOf(pa).IsNil())
}

Используя IsNil(), вы можете напрямую проверить, равно ли его значение nil, избегая вышеупомянутой проблемы. Поэтому в ежедневном использовании, если возвращаемое значение функции является типом интерфейса и вы хотите вернуть нулевое значение, лучше вернуть nil напрямую, а не нулевое значение какой-либо конкретной реализации. Даже если оно реализует интерфейс, оно никогда не будет равно nil, что может привести к ошибке в примере.

Сводка

После решения проблемы выше, давайте посмотрим на следующие примеры:

Когда получатель структуры является указательным получателем, nil usable. Рассмотрим этот пример:

go
type A struct {

}

func (a *A) Do()  {

}

func main() {
  var a *A
  a.Do()
}

Этот код работает нормально без ошибок nil-указателя.

Когда срез равен nil, вы можете получить доступ к его длине и ёмкости, и вы можете добавлять элементы к нему:

go
func main() {
  var s []int
  fmt.Println(len(s))
  fmt.Println(cap(s))
  s = append(s, 1)
}

Когда map равен nil, вы всё ещё можете получить доступ к нему, но nil-map доступен только для чтения. Попытка записи вызовет panic:

go
func main() {
  var s map[string]int
  i, ok := s[""]
  fmt.Println(i, ok)
  fmt.Println(len(s))

  // Попытка записи вызывает panic
  s["a"] = 1 // panic: assignment to entry in nil map

}

Эти характеристики nil в примерах выше могут быть запутанными, особенно для новичков Go. nil представляет нулевое значение для упомянутых выше типов, которое является значением по умолчанию. Значения по умолчанию должны демонстрировать поведение по умолчанию, что именно и предполагали дизайнеры Go: сделать nil более полезным, а не сразу выбрасывать ошибки nil-указателя. Эта философия также отражена в стандартной библиотеке. Например, запуск HTTP-сервера может быть записан так:

go
http.ListenAndServe(":8080", nil)

Мы можем напрямую передать nil Handler, и библиотека http будет использовать Handler по умолчанию для обработки HTTP-запросов.

TIP

Если интересно, вы можете посмотреть это видео Understanding nil - Gopher Conference 2016, которое очень ясное и легко понятное.

Golang by www.golangdev.cn edit