сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Интерфейсы и основы Go

Ловушка nil-интерфейса в Go

Typed nil: любимая ловушка собеса

Функция вернула nil. Вызывающий проверил err != nil и вошёл в ветку обработки ошибки. Обоих строчек в коде достаточно, чтобы вопрос задавали на каждом втором собеседовании: как так, вернули nil, а проверка сработала? Ответ целиком в устройстве интерфейса.

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

// Формулировки: «почему err != nil, хотя вернули nil?», «что печатает такой err?», «как этого избежать?»

Пара, а не значение

Интерфейсная переменная состоит из двух слов: динамический тип и указатель на значение. Проверено размером - 16 байт против 8 у обычного указателя. Сравнение с nil проверяет оба слова сразу.

Когда в интерфейс кладут nil-указатель конкретного типа, первое слово заполняется: там записан *MyErr. Второе действительно нулевое. Итог: одно поле занято, значит интерфейс не пуст, значит err != nil истинно.

Проверено на живом примере: функция объявляет var p *MyErr, ничего в него не пишет и возвращает как error. Вызывающий получает err != nil равным true. При этом печать такого значения даёт что угодно - в моём случае метод Error отработал на nil-получателе и вернул свой текст.

// Тот же механизм объясняет, почему интерфейс с nil внутри не попадает в ветку case nil переключателя по типу: тип-то записан, и значение уходит в ветку своего типа.

type MyErr struct{ Code int }
func (e *MyErr) Error() string { return "err" }

func bad() error {
    var p *MyErr   // nil-указатель
    return p       // кладём в интерфейс: тип есть, значение nil
}

err := bad()
err != nil   // true !!!  хотя внутри nil

Как ловушка выглядит в реальном коде

В таком виде, как выше, её никто не пишет. В жизни она выглядит прилично: функция объявляет переменную конкретного типа ошибки в начале, по ходу заполняет её при некоторых условиях, а в конце возвращает.

Пока условие не сработало, переменная остаётся nil-указателем - и всё равно уезжает в интерфейс. Все вызывающие уходят в ветку обработки ошибки на совершенно успешном пути. Особенно неприятно то, что сообщение при этом может быть пустым или бессмысленным.

Лечение простое и механическое: не объявляй переменные конкретного типа ошибки заранее. Возвращай конкретный тип только там, где действительно есть ошибка, а на успешном пути пиши буквальный return nil. Проверено: правильная версия функции даёт err != nil равным false.

// Если конкретный тип всё-таки нужен по ходу функции, преобразуй его в интерфейс осознанно: проверь на nil и верни либо буквальный nil, либо значение. Молча возвращать переменную типа указателя как error нельзя.

// правильно
func good(fail bool) error {
    if fail {
        return &MyErr{Code: 500}
    }
    return nil          // буквальный nil, а не переменная типа *MyErr
}

Методы на nil-получателе

Смежный факт, который делает ловушку ещё коварнее: вызвать метод на nil-указателе можно, если тело не обращается к полям. Получатель - обычный аргумент, и ничто не мешает ему быть нулевым.

Поэтому typed nil не падает сразу, а спокойно живёт дальше: метод Error отрабатывает, текст печатается, программа идёт своим ходом по ветке ошибки, которой не было. Падение случится позже и в другом месте - когда кто-то обратится к полю.

У этого свойства есть и полезная сторона: на нём строят nil-безопасные методы. Метод Size у дерева, начинающийся с проверки if t == nil, возвращает 0 для пустого дерева, и рекурсия по узлам пишется без проверок на каждом шаге.

// Итог: nil-получатель - штатная возможность языка, а не случайность. Проблема typed nil не в нём, а в укладке nil-указателя в интерфейс.

Как отвечать: «Почему err != nil, хотя функция вернула nil?»

Потому что интерфейсная переменная - это пара из двух слов: динамический тип и указатель на значение. Проверить можно размером: any занимает 16 байт против 8 у обычного указателя. Интерфейс равен nil, только когда пусты оба слова. Когда функция возвращает переменную типа указателя на свою ошибку, а сама переменная нулевая, в интерфейс уезжает пара, где тип заполнен, а значение нет. Такой интерфейс уже не пуст, поэтому err != nil истинно, и вызывающий уходит в обработку ошибки на успешном пути. Хуже того, программа не падает: метод Error спокойно отрабатывает на nil-получателе, если не трогает поля, и в лог уходит какой-нибудь бессмысленный текст. В реальном коде это выглядит прилично - переменную конкретного типа объявляют в начале функции и заполняют по условию. Лечение механическое: на успешном пути писать буквальный return nil, а конкретный тип возвращать только там, где ошибка действительно есть. Я это проверял: правильная версия даёт nil, неправильная - нет.

Причина объяснена устройством интерфейса с числом, показано, почему программа не падает сразу, и дано правило, которое реально предотвращает ошибку.

На чём валят

  • Объявляют переменную конкретного типа ошибки в начале функции и возвращают её как error.
  • Считают, что nil-указатель внутри интерфейса делает интерфейс равным nil.
  • Ждут падения при вызове метода на nil-получателе - его не будет, пока не тронут поля.
  • Ищут ошибку в вызывающем коде, хотя дефект в сигнатуре возвращающей функции.
  • Пытаются лечить это проверкой err == nil в вызывающем - там уже поздно.
  • Ждут, что case nil в переключателе по типу поймает такое значение.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 6, остальные разбираются в тренажёре.

  1. #go_nil_iface1 / 5
    Функция возвращает error, а внутри return для nil-указателя типа (*MyErr)(nil). Чему равно err == nil у вызвавшего?
    A)true: nil-указатель, попав в интерфейс, остаётся nil
    B)Зависит от версии Go и оптимизаций компилятора
    C)false: интерфейс несёт тип *MyErr — он уже не nil
    D)Паника рантайма при сравнении интерфейса с nil
    показать ответ и разбор
    +C)false: интерфейс несёт тип *MyErr — он уже не nil

    // разбор: Классическая ловушка. Вернули типизированный nil-указатель как error: компонент типа интерфейса = *MyErr (не пуст), значение = nil. Интерфейс равен nil лишь когда пусты ОБА компонента — тип непустой, поэтому err == nil даёт false, и «if err != nil» ложно срабатывает. Фикс: возвращать именно nil (return nil), а не типизированный nil-указатель. Спот-чек: false.

  2. #go_nil_iface2 / 5
    Как избежать бага «typed nil» при возврате ошибок из функции?
    A)Сравнивать ошибку не с nil, а с пустой ошибкой errors.New("")
    B)Держать и возвращать переменную типа error, а не конкретного *MyErr
    C)Заворачивать возврат в fmt.Errorf даже в случае, когда ошибки нет
    D)Обнулять указатель через reflect непосредственно перед возвратом
    показать ответ и разбор
    +B)Держать и возвращать переменную типа error, а не конкретного *MyErr

    // разбор: Корень бага — переменная конкретного типа (*MyErr), попадающая в интерфейс error с непустым компонентом типа. Лечение: держать и возвращать значение типа error (var err error), присваивая конкретную ошибку только когда она реально есть, и делать return nil в успешном пути. Тогда компонент типа пуст и err == nil истинно. reflect и «пустые» ошибки — костыли мимо причины.

  3. #go_nil_iface3 / 5
    Когда переменная интерфейсного типа равна nil?
    A)Когда значение внутри неё нулевое для своего типа
    B)Когда внутри лежит нулевой указатель известного типа
    C)Когда в ней нет ни типа, ни значения
    D)Когда её объявили без явной инициализации выражением
    показать ответ и разбор
    +C)Когда в ней нет ни типа, ни значения

    // разбор: Интерфейс — пара (тип, значение), и nil он равен только когда пусты обе половины. Стоит положить туда типизированный nil-указатель — тип заполнен, и сравнение с nil даёт false, хотя данных нет. Отсюда правило: возвращая ошибку, отдавайте буквальный nil, а не переменную конкретного типа ошибки с нулевым значением.

  4. #go_nil_iface4 / 5
    Можно ли вызвать метод у интерфейса, внутри которого лежит nil-указатель?
    A)Нет: вызов метода на nil-указателе завершится паникой
    B)Да, если метод не разыменовывает получателя внутри себя
    C)Нет: рантайм проверяет получателя перед вызовом и отклоняет его
    D)Да, но только для методов, объявленных с получателем-значением
    показать ответ и разбор
    +B)Да, если метод не разыменовывает получателя внутри себя

    // разбор: Метод с получателем-указателем прекрасно вызывается на nil: получатель просто равен nil, и пока тело не трогает поля, всё работает. На этом построены приёмы вроде nil-безопасных методов у деревьев (func (t *Tree) Size() — при t == nil вернуть 0). Паника возникает не от самого вызова, а от разыменования внутри.

  5. #go_nil_iface5 / 5
    Переменная объявлена как var s []int и положена в переменную типа any. Будет ли сравнение с nil истинным?
    A)Да: nil-слайс внутри интерфейса делает и сам интерфейс пустым
    B)Да, потому что слайс — ссылочный тип и хранится напрямую
    C)Нет: тип в интерфейсе заполнен, поэтому он не равен nil
    D)Нет: слайс в переменную интерфейсного типа не кладут
    показать ответ и разбор
    +C)Нет: тип в интерфейсе заполнен, поэтому он не равен nil

    // разбор: Тот же механизм, что у typed nil с указателями: интерфейс запомнил тип []int, и пара перестала быть пустой — сравнение с nil даёт false, хотя сам слайс nil. Проверять надо распакованное значение: достать через ассерцию v, ok := x.([]int) и сравнить уже его, либо не заворачивать слайсы в any без нужды.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.