Ловушка nil-интерфейса в Go
Функция вернула 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, остальные разбираются в тренажёре.
- Функция возвращает error, а внутри return для nil-указателя типа (*MyErr)(nil). Чему равно err == nil у вызвавшего?A)true: nil-указатель, попав в интерфейс, остаётся nilB)Зависит от версии Go и оптимизаций компилятораC)false: интерфейс несёт тип *MyErr — он уже не nilD)Паника рантайма при сравнении интерфейса с nil
показать ответ и разбор
+C)false: интерфейс несёт тип *MyErr — он уже не nil// разбор: Классическая ловушка. Вернули типизированный nil-указатель как error: компонент типа интерфейса = *MyErr (не пуст), значение = nil. Интерфейс равен nil лишь когда пусты ОБА компонента — тип непустой, поэтому err == nil даёт false, и «if err != nil» ложно срабатывает. Фикс: возвращать именно nil (return nil), а не типизированный nil-указатель. Спот-чек: false.
- Как избежать бага «typed nil» при возврате ошибок из функции?A)Сравнивать ошибку не с nil, а с пустой ошибкой errors.New("")B)Держать и возвращать переменную типа error, а не конкретного *MyErrC)Заворачивать возврат в fmt.Errorf даже в случае, когда ошибки нетD)Обнулять указатель через reflect непосредственно перед возвратом
показать ответ и разбор
+B)Держать и возвращать переменную типа error, а не конкретного *MyErr// разбор: Корень бага — переменная конкретного типа (*MyErr), попадающая в интерфейс error с непустым компонентом типа. Лечение: держать и возвращать значение типа error (var err error), присваивая конкретную ошибку только когда она реально есть, и делать return nil в успешном пути. Тогда компонент типа пуст и err == nil истинно. reflect и «пустые» ошибки — костыли мимо причины.
- Когда переменная интерфейсного типа равна nil?A)Когда значение внутри неё нулевое для своего типаB)Когда внутри лежит нулевой указатель известного типаC)Когда в ней нет ни типа, ни значенияD)Когда её объявили без явной инициализации выражением
показать ответ и разбор
+C)Когда в ней нет ни типа, ни значения// разбор: Интерфейс — пара (тип, значение), и nil он равен только когда пусты обе половины. Стоит положить туда типизированный nil-указатель — тип заполнен, и сравнение с nil даёт false, хотя данных нет. Отсюда правило: возвращая ошибку, отдавайте буквальный nil, а не переменную конкретного типа ошибки с нулевым значением.
- Можно ли вызвать метод у интерфейса, внутри которого лежит nil-указатель?A)Нет: вызов метода на nil-указателе завершится паникойB)Да, если метод не разыменовывает получателя внутри себяC)Нет: рантайм проверяет получателя перед вызовом и отклоняет егоD)Да, но только для методов, объявленных с получателем-значением
показать ответ и разбор
+B)Да, если метод не разыменовывает получателя внутри себя// разбор: Метод с получателем-указателем прекрасно вызывается на nil: получатель просто равен nil, и пока тело не трогает поля, всё работает. На этом построены приёмы вроде nil-безопасных методов у деревьев (func (t *Tree) Size() — при t == nil вернуть 0). Паника возникает не от самого вызова, а от разыменования внутри.
- Переменная объявлена как var s []int и положена в переменную типа any. Будет ли сравнение с nil истинным?A)Да: nil-слайс внутри интерфейса делает и сам интерфейс пустымB)Да, потому что слайс — ссылочный тип и хранится напрямуюC)Нет: тип в интерфейсе заполнен, поэтому он не равен nilD)Нет: слайс в переменную интерфейсного типа не кладут
показать ответ и разбор
+C)Нет: тип в интерфейсе заполнен, поэтому он не равен nil// разбор: Тот же механизм, что у typed nil с указателями: интерфейс запомнил тип []int, и пара перестала быть пустой — сравнение с nil даёт false, хотя сам слайс nil. Проверять надо распакованное значение: достать через ассерцию v, ok := x.([]int) и сравнить уже его, либо не заворачивать слайсы в any без нужды.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.