Типизированный nil в Go: как ломается проверка на nil

Типизированный nil в Go

Есть в 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. Значение интерфейса — это не просто «что-то одно», а пара из двух частей:

  1. Тип — какой конкретно тип данных сейчас лежит внутри интерфейса (в нашем случае *bytes.Buffer).
  2. Значение — собственно само значение этого типа (в нашем случае 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 ловил тебя врасплох? Поделись историей в комментариях — расскажи, на чём споткнулся и сколько времени ушло на дебаг.

Понравилась статья? Поделиться с друзьями:
Добавить комментарий

;-) :| :x :twisted: :smile: :shock: :sad: :roll: :razz: :oops: :o :mrgreen: :lol: :idea: :grin: :evil: :cry: :cool: :arrow: :???: :?: :!: