nil Interface Trap
Один из самых коварных gotchas в Go: (*T)(nil) присвоенный интерфейсу — не nil. Эта особенность вызывает баги даже у опытных разработчиков. Разберём почему так происходит и как избежать.
TL;DR
| Сценарий | interface == nil |
|---|---|
| Никогда не присваивали | true |
Присвоили nil напрямую | true |
Присвоили typed nil (*T)(nil) | false |
Присвоили zero value struct T{} | false |
Правило: интерфейс nil, только если оба поля (tab/_type и data) равны nil.
Проблема: typed nil
Классический пример
func getError() error {
var err *MyError = nil // typed nil pointer
return err // присваиваем интерфейсу
}
func main() {
err := getError()
if err != nil {
fmt.Println("Error!") // Выведется! Хотя err "как бы" nil
}
}Почему err != nil возвращает true?
Визуализация проблемы
Два вида "nil" в интерфейсах:
True nil interface: Typed nil в интерфейсе:
┌──────────────────────┐ ┌──────────────────────┐
│ tab/type: nil │ │ tab/type: *MyError │ ← НЕ nil!
├──────────────────────┤ ├──────────────────────┤
│ data: nil │ │ data: nil │
└──────────────────────┘ └──────────────────────┘
err == nil: TRUE err == nil: FALSE
(потому что type != nil)Почему так работает
Сравнение interface == nil проверяет оба поля:
// Псевдокод сравнения interface с nil
func interfaceIsNil(i iface) bool {
return i.tab == nil && i.data == nil
// ИЛИ для eface:
return i._type == nil && i.data == nil
}Когда мы присваиваем (*MyError)(nil) интерфейсу:
data= nil (значение указателя)tab/_type= информация о типе*MyError— не nil!
Почему так сделано?
Reflection должен знать тип
var err error = (*MyError)(nil)
// reflect должен уметь определить тип:
fmt.Println(reflect.TypeOf(err)) // *main.MyErrorЕсли бы Go терял информацию о типе при nil pointer, reflect.TypeOf() не работал бы.
Method dispatch на nil receiver
В Go можно вызывать методы на nil receiver если receiver — указатель и сам метод это допускает. Это важно для понимания, почему интерфейс хранит тип даже при data == nil.
type MyError struct {
msg string
}
func (e *MyError) Error() string {
if e == nil {
return "nil error"
}
return e.msg
}
var err error = (*MyError)(nil)
fmt.Println(err.Error()) // "nil error" — работает!Что происходит:
- интерфейс хранит тип
*MyError(tab/_type ≠ nil) - data = nil, но itab.fun знает адрес метода
(*MyError).Error - dispatch вызывает метод и передаёт receiver = nil
Про receiver: у метода есть неявный первый параметр — это и есть receiver. В исходном коде он не пишется, но компилятор передаёт его как обычный аргумент:
var p *MyError = nil
// Синтаксический сахар:
_ = p.Error()
// Эквивалентно вызову метода как функции:
_ = (*MyError).Error(p) // receiver передан явноСхема dispatch для typed-nil:
interface value (err)
┌──────────────────────┐
│ tab -> itab │───┐
│ data = nil │ │
└──────────────────────┘ │
▼
itab.fun[0]
│
▼
call Error(rcv=nil)Если бы интерфейс становился полностью nil, method dispatch был бы невозможен.
Важно различать:
var p *MyError = nil
// 1) nil receiver, но интерфейс не nil
var e1 error = p
fmt.Println(e1 == nil) // false
_ = e1.Error() // вызов возможен, receiver = nil
// но поведение зависит от реализации:
// если метод разыменовывает receiver
// без проверки — будет panic
// 2) nil interface — вообще нет типа, нет dispatch
var e2 error = nil
fmt.Println(e2 == nil) // true
// e2.Error() // panic: вызов метода на nil interfaceОтдельно: value receiver на nil pointer работать не может — будет panic при неявном разыменовании:
type S struct{ msg string }
func (s S) String() string { return s.msg }
var ps *S = nil
// ps.String() // panic: nil pointer dereferenceПаттерны решения
1. Явный return nil
// ❌ Плохо: возвращаем typed nil
func getError() error {
var err *MyError
if !somethingWrong {
return err // (*MyError)(nil) — не nil интерфейс!
}
return err
}
// ✅ Хорошо: явно возвращаем nil
func getError() error {
if !somethingWrong {
return nil // untyped nil — будет nil интерфейс
}
return &MyError{msg: "something wrong"}
}2. Проверка перед return
// ❌ Плохо: typed nil error уходит в интерфейс
func findUser(id int) (*User, error) {
user := db.Find(id)
var err *DBError
if user == nil {
err = &DBError{msg: "not found"}
}
return user, err // err может быть (*DBError)(nil) → typed nil error
}// ✅ Хорошо: явный nil для interface
func findUser(id int) (*User, error) {
user := db.Find(id)
var err *DBError
if user == nil {
err = &DBError{msg: "not found"}
}
if err != nil {
return user, err
}
return user, nil
}3. Использование конкретных типов внутри
// ❌ Плохо: работаем с interface{} внутри функции
func process() error {
var result error
// ... логика, которая может установить result
return result // может быть typed nil!
}
// ✅ Хорошо: создаём error только когда нужно
func process() error {
// ... логика
if somethingWrong {
return &MyError{...}
}
return nil
}Проверка nil pointer в interface
Иногда нужно проверить, является ли значение внутри интерфейса nil pointer.
Через reflect
func isNilInterface(i interface{}) bool {
if i == nil {
return true
}
v := reflect.ValueOf(i)
switch v.Kind() {
case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func:
return v.IsNil()
}
return false
}
// Использование
var err error = (*MyError)(nil)
fmt.Println(err == nil) // false
fmt.Println(isNilInterface(err)) // trueIsNil разрешён только для ptr/map/slice/chan/func; для int/struct/string будет panic.
Осторожно с производительностью
// reflect.ValueOf/IsNil могут аллоцировать — не для hot path!
func BenchmarkReflectIsNil(b *testing.B) {
var err error = (*MyError)(nil)
for i := 0; i < b.N; i++ {
_ = isNilInterface(err) // порядок десятков ns/op, возможна аллокация
}
}Подходит для диагностики и тестов, не для hot path.
nil Interface Method Call
Panic на true nil
var w io.Writer // true nil interface
w.Write([]byte("hello")) // 💥 PANIC: nil pointer dereferenceПричина: у w нет типа и данных; tab == nil → panic.
Работает на typed nil (если метод обрабатывает nil)
type SafeWriter struct {
w io.Writer
}
func (s *SafeWriter) Write(p []byte) (int, error) {
if s == nil {
return 0, errors.New("SafeWriter is nil")
}
return s.w.Write(p)
}
var w io.Writer = (*SafeWriter)(nil)
n, err := w.Write([]byte("hello")) // err = "SafeWriter is nil"Интерфейс не nil, есть тип *SafeWriter, поэтому метод вызывается; дальше всё решает логика метода.
nil Interface Trap
var err error
// err == nil== nil только когда оба поля (tab/type и data) равны nil. Typed nil pointer присвоенный интерфейсу сохраняет type info →
!= nil. Распространённые ловушки
Ловушка 1: error return в функции
func readFile(path string) ([]byte, error) {
var fileErr *os.PathError // typed nil
data, err := os.ReadFile(path)
if err != nil {
if pe, ok := err.(*os.PathError); ok {
fileErr = pe
}
}
return data, fileErr // 🐞 BUG: fileErr может быть (*os.PathError)(nil)
}
// ✅ Фикс:
func readFile(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
if pe, ok := err.(*os.PathError); ok {
return nil, pe // конкретное значение
}
return nil, err
}
return data, nil // явный nil
}Суть ловушки: typed nil «прячется» в интерфейсе и делает err != nil истинным.
Ловушка 2: struct с interface полями
type Service struct {
logger Logger // interface
}
func NewService() *Service {
return &Service{
// logger не инициализирован — nil interface
}
}
func (s *Service) DoSomething() {
s.logger.Log("doing something") // 💥 PANIC если logger == nil
}
// ✅ Фикс (вариант 1): явная проверка
func (s *Service) DoSomething() {
if s.logger != nil {
s.logger.Log("doing something")
}
}
// ✅ Фикс (вариант 2): паттерн Null Object
type noopLogger struct{}
func (noopLogger) Log(string) {}
func NewService() *Service {
return &Service{
logger: noopLogger{}, // никогда не nil
}
}TIP
Выберите один вариант: либо всегда проверяйте s.logger перед вызовом, либо задайте default logger (Null Object) при создании Service, чтобы logger никогда не был nil.
Поле-интерфейс по умолчанию nil; вызов без проверки приводит к panic.
Ловушка 3: присваивание в условии
func getConnection() (net.Conn, error) {
var conn *net.TCPConn
if shouldUseTCP() {
conn = dial() // может вернуть nil
}
return conn, nil // 🐞 BUG: conn может быть (*net.TCPConn)(nil)
}
// ✅ Фикс:
func getConnection() (net.Conn, error) {
if shouldUseTCP() {
conn := dial()
if conn != nil {
return conn, nil
}
}
return nil, errors.New("no connection")
}Ловушка: typed nil *net.TCPConn становится ненулевым интерфейсом.
Ловушка 4: generic code
func First[T any](items []T) T {
if len(items) == 0 {
var zero T
return zero // Может быть проблема с interfaces!
}
return items[0]
}
// Если T = error:
var errs []error
first := First(errs) // first = nil (но правильный nil)
// Если T = io.Reader и items содержит (*os.File)(nil):
readers := []io.Reader{(*os.File)(nil)}
first := First(readers) // first != nil (typed nil)Если T — интерфейс, можно вернуть typed nil с сохранённым dynamic type. Проверяйте в месте формирования слайса.
Сравнение двух интерфейсов
var a error = (*MyError)(nil)
var b error = (*OtherError)(nil)
fmt.Println(a == nil) // false
fmt.Println(b == nil) // false
fmt.Println(a == b) // false — разные типы!Интерфейсы равны, только если:
- Оба nil
- Одинаковый dynamic type И равные значения
Checklist: избегаем nil interface trap
Не объявляйте typed nil переменные для возврата error
go// ❌ Плохо var err *MyError return err // ✅ Хорошо return nilПроверяйте конкретный тип перед присваиванием интерфейсу
goif concretePtr != nil { return concretePtr } return nilИспользуйте Null Object pattern для обязательных interface полей
В тестах проверяйте
err == nilиerr != nil, не полагайтесь на значенияПри работе с reflection помните о производительности
Выводы
Интерфейс != nil, если хранит информацию о типе, даже при nil data
Причина: Go сохраняет type info для reflection и method dispatch на nil receiver
Решение: явно возвращайте
nil, не typed nil переменныеПроверка через reflect возможна, но дорогая — не для hot path
Null Object pattern помогает избежать проверок на nil везде