Есть в Go один баг, на который рано или поздно натыкается почти каждый — даже те, кто пишет на этом языке годами. Причём код при этом выглядит абсолютно логично, компилятор молчит, ревьюер кивает — а потом в проде вылетает паника на пустом месте.
В этой статье разберу его подробно, буквально по строчкам, на классическом примере из книги Донована и Кернигана «The Go Programming Language». Даже если ты пока не очень уверенно чувствуешь себя в интерфейсах Go — к концу статьи всё встанет на свои места.
С чего всё начинается: код, который выглядит правильным
Вот исходный код. Есть флаг debug. Если он включён — нужно собирать вывод программы в буфер, чтобы потом его посмотреть. Если выключен — вывод никуда не пишется.
package main
import (
"bytes"
"io"
)
const debug = true
func main() {
var buf *bytes.Buffer
if debug {
buf = new(bytes.Buffer) // Накопление вывода
}
f(buf) // Внимание: тонкая ошибка!
if debug {
// ...используем buf...
}
}
// Если out ненулевой, вывод записывается в него
func f(out io.Writer) {
// ... некоторые действия ...
if out != nil {
out.Write([]byte("done!\n"))
}
}
Разбираю построчно, что тут делает автор:
var buf *bytes.Buffer— объявляет переменнуюbufтип «указатель на bytes.Buffer». В этот момент она равнаnil, потому что её ещё ничем не наполнили.if debug { buf = new(bytes.Buffer) }— если режим отладки включён, создается настоящий буфер и кладётся вbuf. Еслиdebugвыключен, эта строка не выполняется, иbufтак и остаётсяnil.f(buf)—bufпередаётся в функциюf. Обрати внимание: параметрfимеет тип не*bytes.Buffer, аio.Writer— это интерфейс. То есть конкретный указатель тут «упаковывается» в интерфейсное значение.- Внутри
f:if out != nil { out.Write(...) }— по замыслу автора, если писать некуда (буфера нет), функция просто ничего не делает.
На первый взгляд всё логично: нет буфера — нет записи. Но именно в этом месте прячется хитрость.
Где именно ломается логика
Представь ситуацию: кто-то отрефакторил код и по ошибке убрал инициализацию буфера, либо просто забыл её добавить. Переменная buf так и осталась nil-указателем на *bytes.Buffer. Казалось бы, ничего страшного — внутри f есть проверка out != nil, которая должна отсеять пустой случай. Чтобы наглядно показать, что произойдёт на самом деле, я добавил в функцию автора одну строку с выводом:
func f(out io.Writer) {
fmt.Printf("out == nil inside f: %v\n", out == nil) // false!
if out != nil {
out.Write([]byte("done!\n")) // паника
}
}
Проверка out == nil внутри f возвращает false, хотя снаружи buf был честным nil. Условие out != nil оказывается истинным, выполнение заходит внутрь if, вызывается out.Write(...) — и получается паника nil pointer dereference, потому что писать реально некуда.
Разбираю, почему так происходит.
Почему интерфейс «обманывает» проверку на nil
Дело в устройстве интерфейсов в Go. Значение интерфейса — это не просто «что-то одно», а пара из двух частей:
- Тип — какой конкретно тип данных сейчас лежит внутри интерфейса (в нашем случае
*bytes.Buffer). - Значение — собственно само значение этого типа (в нашем случае
nil, потому что указатель пустой).
Когда я передаю buf в параметр out io.Writer, Go берёт эти две части — тип *bytes.Buffer и значение nil — и упаковывает их в интерфейс вместе. Получается пара (*bytes.Buffer, nil).
А теперь ключевой момент: сравнение out == nil в Go проверяет, пусты ли обе части этой пары одновременно — и тип, и значение. Если тип задан (пусть даже значение внутри nil), то весь интерфейс уже не считается пустым.
Вот аналогия, которая мне самому помогла разложить это по полочкам. Представь конверт с указанным адресом получателя. Даже если письмо внутри конверта пустое — сам конверт с адресом это уже не «ничего», потому что у него есть получатель. Проверка out == nil — это проверка, пуст ли конверт целиком, вместе с адресом. А не только «есть ли письмо внутри».
Именно поэтому out != nil возвращает true: конверт (тип *bytes.Buffer) есть, хоть и письмо (значение) внутри пустое.
Как это чинить: рабочее решение
Самый надежный способ — не оставлять переменную конкретного типа неинициализированной, если она потом попадёт в интерфейс. Вот исправленная версия:
func demoFixed() {
var buf *bytes.Buffer
if debug {
buf = new(bytes.Buffer)
}
fFixed(buf)
if debug {
fmt.Println("Buffer contents: ", buf.String())
}
}
func fFixed(out io.Writer) {
if out != nil {
out.Write([]byte("done!\n"))
}
}
Разбираю и этот вариант по шагам:
var buf *bytes.Buffer— снова объявляю указатель, пока пустой.if debug { buf = new(bytes.Buffer) }— критически важная строка: если отладка включена, я гарантированно создаю реальный буфер. Здесь уже нет шанса случайно забыть инициализацию, потому что вся логика привязана к одному явному условию.fFixed(buf)— передаю буфер в функцию. Еслиdebugбылtrue, внутри интерфейса окажется рабочий указатель, а не пустышка.fFixed: тело функции не изменилось —out != nilтут снова работает как задумано, потому что интерфейс либо пуст полностью (когдаdebug == falseиbufдаже не трогали), либо содержит настоящий, работающий буфер.fmt.Println("Buffer contents: ", buf.String())— после вызова я читаю, что записалось в буфер, и печатаю результат.
Более общий совет на будущее: если функция принимает интерфейсный тип (вроде io.Writer или error) и внутри планируется сравнение с nil, стоит держать в голове, что «пустой указатель» и «пустой интерфейс» — это разные вещи на уровне рантайма. Указатель может быть nil, а интерфейс, в который его положили, — уже нет.
Почему это стоит запомнить
У меня был похожий случай на практике. Мне дали задачку — найти, что не так в куске кода, очень похожем на пример выше. Я честно сел разбирать логику и сначала не увидел проблемы: код выглядел абсолютно рабочим, проверка на nil стояла на месте. Только когда я вычитал эту особенность интерфейсов — про пару (тип, значение) — всё встало на свои места, и разгадать задачку оказалось делом несложным.
Это одна из тех вещей в Go, которую не видно по синтаксису. Компилятор тут не подскажет, линтер тоже, скорее всего промолчит. Помогает только чёткое понимание того, что интерфейс — это всегда пара (тип, значение), и проверка на nil смотрит оба поля сразу, а не только на значение внутри.
А у тебя был случай, когда типизированный
nilловил тебя врасплох? Поделись историей в комментариях — расскажи, на чём споткнулся и сколько времени ушло на дебаг.