В процессе знакомства с Go я нашел в документации пример:

func returnsError() error {
    var p *MyError = nil
    if bad() {
        p = ErrBad
    }
    return p // Will always return a non-nil error.
}

После его просмотра меня заинтересовало, почему returnsError всегда будет возвращать не non-nil error?

Переменные в Go всегда инициализируются значением. Это относится и к интерфейсам. Стоит отметить, что интерфейсы реализованы в виде двух элементов: тип(T) и значение(V). Это достаточно поверхностное определение, которое чуть дальше мы разберем. Значение интерфейса будет nilтолько в том случае, если Vи T оба будут nil.

Есть интересный момент, а именно случай, когда V=nil, а T!=nil. В таком случае никакие проверки интерфейса на nil нам не помогут. А ведь именно этот сценарий и происходит в returnsError.

Мне стало интересно, как именно эти проверки проходят в Go.

type Word struct {
	name     string
	priority uint
}

type Foo interface {
	foo()
}

func (w *Word) foo() {
	fmt.Println("call foo()")
}

func (w *Word) noFoo() {
	fmt.Println("call noFoo()")
}

func call(f Foo) {
	if f != nil {
		f.foo()
	} else {
		fmt.Println("f null")
	}
}

func main() {
	var f1 *Word
	call(f1)
}

В приведенном выше коде есть проверки. Все отрабатывает без ошибок. Вывод будет следующим:

call foo()

Кажется несколько странным, ведь передали nil и вообще ожидалось что либо не пройдет проверки на nil, либо выстрелит при выполнении.
Давайте разбираться.

Функция call принимает интерфейс Foo. Как вообще происходит преобразование указателя Word к интерфейсу Foo?
Упрощенно интерфейс выглядит следующим образом:

Интерфейс:
1. статический тип
2. динамическая информация:
- динамический тип
- динамическое значение

В нашем примере статическим типом будет Foo, динамическим типом Word, значением nil.

Посмотрим более подробно.

Языки с методами можно разделить на два вида: создают таблицы виртуальных методов (например C++) или выполняйте поиск метода при каждом вызове (Smalltalk). Во втором случае для оптимизаций используется кеширование. Go находится посередине: у него есть таблица методов, но вычисляется она во время выполнения.

Из чего же состоят интерфейсы в Go?

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

data - это непосредственно наш f1 из примера. Вся информация по методам, типам и прочему скрывается в tab - интерфейсной таблицей (interface table).

type itab struct {
	inter *interfacetype
	_type *_type
	hash  uint32 // copy of _type.hash. Used for type switches.
	_     [4]byte
	fun   [1]uintptr // variable sized. fun[0]==0 means _type does not implement inter.
}

Обратите внимание, что itab соответствует статическому типу интерфейса (Foo), а не динамическому (Word). Для большей точности она соответствует паре Foo-Word. Поэтому внутри itab будут только методы, которые удовлетворяют интерфейсу Foo и noFoo здесь не будет.

inter - содержит метаданные о статическом типе интерфейса.
_type - о динамическом типе
fun - набор указателей на методы интерфейса. является variable sized.

Откуда берется itable?

itab - представляет собой своего рода ключ значение, а именно соответствие статического типа динамическому (помните говорил выше проFoo-Word ?). В больших программах типов и интерфейсов великое множество. И не все комбинации нужны. Для этого в Go компилятор создают несколько таблиц описания типов. В первой содержится список методов для конкретного интерфейса . Во второй, какие методы содержат динамические типы. Наш itable - это соответствие между этими двумя таблицами. itable кэшируется после создания, так что это соответствие нужно вычислить только один раз.

Как же метод f.foo() будет вызываться и почему нет ошибки?

Будет некоторый аналог:

f.tab.fun[0](f.data)

Мы разобрали, что будет в fun[0] (единственный метод нашего интерфейса Foo) и что содержит в data (сам объект Word, в примере мы передали указатель на него неинициализированным - nil).
В некоторых языках (например C++) для методов есть такое понятие как this-call. Оно означает, что первым скрытым параметром любого метода идет указатель на сам объект(this). И если нужно изменять/читать поля объекта мы к ним обращаемся неявно через this. При этом есть статические методы, которые можно вызывать без создания объекта - для таких методов this не передается неявно т.к. не нужен.
В нашем примере как раз это и получается. Метод foo никак не взаимодействует с Word. И поэтом f.data (которая nil) не используется и программа корректно завершает работу.

Если изменим реализацию метода - то все упадет как надо:

func (w *Word) foo() {
	fmt.Printf("call foo(): %s\n", w.name)
}

Ранее было утверждение:
Значение интерфейса будет nilтолько в том случае, если Vи T оба будут nil. Если теперь говорить про код: значение интерфейса будет nil только в том случае, если data и _type равны nil одновременно, ведь вариант когда только data равно nil, вполне корректен. Нулевое значение интерфейса, которое не содержит значения как такового, не совпадает со значением интерфейса, содержащим нулевой указатель, в чем мы убедились, посмотрев структуры.

Спасибо за внимание.

Комментарии (6)


  1. DimNS
    07.10.2022 05:43

    Эм, ожидал что последним абзацем будет листинг кода с проверкой при которой результатом выполнения будет fmt.Println("f null")


    1. micronull
      07.10.2022 08:25

      Держите:

      package main
      
      import "fmt"
      
      type Word struct {
      	name     string
      	priority uint
      }
      
      type Foo interface {
      	foo()
      }
      
      func (w *Word) foo() {
      	fmt.Println("call foo()")
      }
      
      func (w *Word) noFoo() {
      	fmt.Println("call noFoo()")
      }
      
      func call(f Foo) {
      	if f != (*Word)(nil) {
      		f.foo()
      	} else {
      		fmt.Println("f null")
      	}
      }
      
      func main() {
      	var f1 *Word
      	call(f1)
      }
      


      1. Georg83
        07.10.2022 09:34

        Такой себе пример. А если у вас два типа реализующих интерфейс и в call передали указатель другой?


        1. micronull
          07.10.2022 10:12

          А код изначально не корректен. В функции call вообще не должно быть проверки на nil.
          Проверку на nil скорее стоит поставить внутри метода foo, например чтоб вернуть zero value, как вариант, если метод что-то должен вернуть. Либо ошибку, и вот её уже стоит проверять в функции call.


  1. Desprit
    07.10.2022 11:57
    +2

    Порекомендуйте, пожалуйста, какие-нибудь материалы на тему best-practices по избеганию null pointer dereference. Не так давно пишу на го и постоянно сталкиваюсь с этой бедой. Стараюсь поменьше использовать указатели, но все равно...


    1. beldeveloper
      09.10.2022 04:14

      Вряд ли есть много структурированой информации на эту тему. Думаю, если функция возвращает результат+error, всегда нужно проверять error, и, если error != nil, не использовать результат. И свой код писать так же, если возвращаете результат и пустой error, результат должен быть не nil. Также всегда стоит быть уверенным, что была выделена память под slice или map прежде чем писать туда данные, так как slice и map это всегда неявный указатель. Если не уверены, лучше сделать проверку на nil и вызвать make.