golang

Go прощает меньше, чем кажется: восемь ошибок, на которых спотыкается каждый новичок

  • вторник, 4 августа 2026 г. в 00:00:11
https://habr.com/ru/companies/otus/articles/1065500/

Привет, Хабр! Go специально сделали маленьким языком. Ключевых слов немного, конструкций мало, за неделю осваиваешь синтаксис и начинаешь писать. Именно из-за этой простоты возникает ложное ощущение, что тут негде ошибиться — код компилируется, значит всё правильно.

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

Собрал восемь ошибок, через которые проходит примерно каждый, кто приходит в Go из другого языка. Все — реальные, все встречаются в чужом (и своём) коде постоянно.

nil-ошибка, которая почему-то не nil

Начнём с самой неприятной:

type MyError struct{}

func (e *MyError) Error() string { return "что-то пошло не так" }

func doWork(fail bool) error {
    var err *MyError
    if fail {
        err = &MyError{}
    }
    return err
}

func main() {
    if doWork(false) != nil {
        fmt.Println("ошибка!")      // и это напечатается
    }
}

Мы передали fail=false, ошибку не создавали, а проверка != nil всё равно сработала. Как так?

Интерфейс в Go — это пара из двух вещей: тип и значение. Переменная типа error равна nil, только когда nil и тип, и значение. А здесь мы вернули MyError, у которого значение — nil, но тип-то есть, он MyError. Пара «тип есть, значение nil» — это не nil целиком.

Отсюда правило: не объявляйте переменную конкретного типа ошибки, чтобы потом её вернуть.

Возвращайте nil напрямую или работайте сразу с типом error.

func doWork(fail bool) error {
    if fail {
        return &MyError{}
    }
    return nil          // честный nil
}

Переменная цикла, которая одна на всех

До недавнего времени это была ошибка номер один во всём Go.

funcs := []func(){}
for _, v := range []string{"a", "b", "c"} {
    funcs = append(funcs, func() {
        fmt.Println(v)
    })
}
for _, f := range funcs {
    f()
}

Старый Go напечатал бы c c c — три раза последнее значение. Причина в том, что переменная v была одна на все итерации: цикл переприсваивал ей значение, а все замыкания захватывали одну и ту же переменную. К моменту вызова в ней лежало c.

Хорошая новость: в Go 1.22 это починили. Теперь v создаётся заново на каждой итерации, и код печатает a b c, как и ожидаешь.

Но знать про старое поведение всё равно нужно. Во-первых, вы наверняка встретите код, написанный до 1.22, где под это подложены костыли:

for _, v := range items {
    v := v              // копия, чтобы замыкание захватило именно её
    go process(v)
}

Такая строчка v := v в чужом коде — не опечатка, а именно обход старого поведения. Во-вторых, та же ловушка до сих пор жива с горутинами, если запускать их без копии значения на версиях старше 1.22. Проверьте, на какой версии вы пишете — от этого зависит, нужен костыль или нет.

append, который испортил чужой слайс

Слайсы в Go — источник самых неожиданных багов, потому что они устроены не так, как кажется.

a := []int{1, 2, 3, 4, 5}
b := a[1:3]             // b это [2, 3]
b = append(b, 99)
fmt.Println(a)         // [1 2 3 99 5] — мы изменили a!

Слайс — это не сам массив, а окно в него: указатель на данные, длина и ёмкость. Когда мы взяли a[1:3], b стал смотреть на тот же массив, что и a, просто с другого места. append увидел, что в исходном массиве есть свободное место после 3, и записал туда 99 — прямо поверх четвёрки, которая принадлежала a.

Пока ёмкости хватает, append пишет в существующий массив. Как только не хватает — выделяет новый и копирует, и связь теряется. Из-за этого поведение append зависит от того, сколько там было свободного места, и предсказать его на глаз сложно.

Если нужно гарантированно независимый слайс, копию делают явно:

b := make([]int, 2)
copy(b, a[1:3])         // b это независимая копия

Либо используют полный трёхиндексный срез, который ограничивает ёмкость и заставляет append сразу выделять новый массив:

b := a[1:3:3]           // длина 2, ёмкость 2 — append не тронет a

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

Мапа, которую читают и пишут из разных горутин

Как только новичок осваивает горутины, он немедленно натыкается на это.

counts := map[string]int{}

for _, word := range words {
    go func(w string) {
        counts[w]++         // паника или порча данных
    }(word)
}

Встроенная мапа в Go не потокобезопасна, причём совсем. Одновременная запись из двух горутин либо портит внутреннюю структуру, либо роняет программу с сообщением fatal error: concurrent map writes. Причём это не обычная паника, которую можно перехватить через recover, — программа падает целиком, специально, чтобы вы не пропустили проблему.

Самый прямой способ — мьютекс вокруг доступа:

var mu sync.Mutex
counts := map[string]int{}

for _, word := range words {
    go func(w string) {
        mu.Lock()
        counts[w]++
        mu.Unlock()
    }(word)
}

Если чтений сильно больше, чем записей, вместо обычного мьютекса берут sync.RWMutex, который разрешает нескольким читателям работать одновременно. А для простых случаев вроде счётчиков в стандартной библиотеке есть sync.Map и атомарные операции из sync/atomic. Но начинать стоит с обычного мьютекса, он проще и почти всегда достаточен.

Горутина, у которой потеряли ошибку

Отдельная беда — думать, что запустил горутину, и она сама разберётся.

func process(items []Item) {
    for _, item := range items {
        go func(it Item) {
            if err := save(it); err != nil {
                // и что мы тут сделаем? вернуть некуда
            }
        }(item)
    }
}

Горутина не может ничего вернуть — у неё нет вызывающего, который бы забрал результат. Ошибка, случившаяся внутри, просто исчезает. Хуже того, функция process завершится сразу после запуска всех горутин, не дожидаясь, пока они доработают.

Чтобы дождаться завершения, есть sync.WaitGroup. А чтобы собрать ошибки — канал или готовая обёртка errgroup:

import "golang.org/x/sync/errgroup"

func process(items []Item) error {
    var g errgroup.Group
    for _, item := range items {
        it := item
        g.Go(func() error {
            return save(it)
        })
    }
    return g.Wait()         // дождётся всех и вернёт первую ошибку
}

errgroup дожидается всех горутин и возвращает первую случившуюся ошибку. Для большинства задач «запустить пачку параллельных операций и узнать, не упало ли что-то» это ровно то, что нужно.

defer внутри цикла, который копит ресурсы

defer — удобная штука, откладывает вызов до выхода из функции. Ключевое слово тут — из функции, а не из цикла.

func processFiles(paths []string) error {
    for _, path := range paths {
        f, err := os.Open(path)
        if err != nil {
            return err
        }
        defer f.Close()         // накапливается до конца processFiles!
    }
    return nil
}

Все defer f.Close() выполнятся не в конце каждой итерации, а в самом конце функции processFiles. Если файлов тысяча, у вас одновременно открыта тысяча файлов, и вы упрётесь в лимит операционной системы на количество дескрипторов.

Лечится выносом тела цикла в отдельную функцию, чтобы defer срабатывал на каждой итерации:

func processFiles(paths []string) error {
    for _, path := range paths {
        if err := processOne(path); err != nil {
            return err
        }
    }
    return nil
}

func processOne(path string) error {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer f.Close()             // закроется при выходе из processOne
    // работаем с f
    return nil
}

Теперь каждый файл закрывается сразу после обработки. Вынос куска цикла в отдельную функцию — вообще частый ответ на проблемы с defer, потому что он привязан именно к границе функции.

Указатель на элемент карты, которого не бывает

Мелочь, которая сбивает с толку при переходе с других языков.

type Counter struct{ n int }
m := map[string]Counter{"a": {n: 1}}

m["a"].n++                           // не компилируется

Go не даёт менять поле структуры прямо в мапе, и не просто так. Значения в мапе не адресуемы — у них нет стабильного адреса в памяти, потому что мапа может в любой момент перестроиться и передвинуть данные. А раз нет адреса, нельзя и присвоить полю.

Обходят это либо заменой всего значения целиком:

c := m["a"]
c.n++
m["a"] = c                           // положили обратно

либо, что чаще, хранением в мапе указателей вместо значений:

m := map[string]*Counter{"a": {n: 1}}
m["a"].n++                           // теперь работает — меняем по указателю

С указателями появляется свой нюанс: обращение к несуществующему ключу вернёт nil, и m["нет такого"].n уронит программу паникой. Поэтому перед работой с указателем из мапы проверяют, что он есть.

Проглоченная ошибка

Go заставляет обрабатывать ошибки явно — это его фишка и его же главное раздражение для новичков. И самое частое, что делают, лишь бы компилятор замолчал:

data, _ := os.ReadFile("config.json")      // а если файла нет?
json.Unmarshal(data, &config)              // тут будет мусор

Подчёркивание вместо переменной ошибки — это явное «мне всё равно». Иногда действительно всё равно, но чаще это отложенный баг: файл не прочитался, data пустой, json.Unmarshal отработал по пустышке, и config остался нулевым. Программа не упала, просто работает неправильно.

Ошибки в Go проверяют сразу после вызова:

data, err := os.ReadFile("config.json")
if err != nil {
    return fmt.Errorf("не смог прочитать конфиг: %w", err)
}

Обратите внимание на %w в Errorf — он оборачивает исходную ошибку, сохраняя её внутри новой. Дальше по цепочке её можно достать через errors.Is или errors.As и понять, что именно случилось в глубине. Просто склеивать текст через %v тоже можно, но тогда вы теряете возможность программно разобрать причину.

Range по массиву, который копируется целиком

Тонкость, которая на маленьких данных незаметна, а на больших внезапно бьёт по памяти и скорости.

type BigStruct struct {
    data [1024]byte
    id   int
}

items := []BigStruct{ /* тысяча элементов */ }
for _, item := range items {
    process(item.id)        // item — это КОПИЯ, целиком
}

range на каждой итерации копирует элемент в переменную item. Для среза из структур по килобайту это тысяча копирований по килобайту за один проход. Если структура большая, а нужен из неё только id, вы впустую гоняете память.

Обходят это индексом вместо значения:

for i := range items {
    process(items[i].id)    // без копирования, обращаемся напрямую
}

С массивами (не срезами) есть ещё один сюрприз: массив в Go — значимый тип, и передача его в функцию или присваивание копирует целиком.

var a [1000]int
b := a                      // b — полная копия всех 1000 элементов
modify(a)                   // функция получила копию, оригинал не тронут

Срез в этой же ситуации копировался бы как окно — три поля, без данных. Отсюда простое следствие: на бэкенде почти всегда работают со срезами, а не с массивами фиксированного размера, именно чтобы не таскать копии.

Сравнение через == там, где оно не работает

Go разрешает сравнивать через == не всё, и границы неочевидны.

a := []int{1, 2, 3}
b := []int{1, 2, 3}
fmt.Println(a == b)         // не компилируется

Срезы, мапы и функции сравнивать через == нельзя вообще — компилятор откажется.

Единственное, с чем == работает у них, — проверка на nil. Для сравнения содержимого срезов есть slices.Equal, для мап — maps.Equal:

import "slices"

fmt.Println(slices.Equal(a, b))     // true

А вот со структурами == работает, но с ловушкой: сравниваются они поле за полем, и если внутри есть срез или мапа, структура становится несравнимой и код не скомпилируется. Плюс структуры с float64 внутри сравниваются с обычными проблемами плавающей точки — NaN != NaN, и структура с NaN не равна сама себе.

Значения по умолчанию вместо отсутствия значения

В Go нет null в привычном смысле. У каждого типа есть нулевое значение: у чисел 0, у строк пустая строка, у булевых false, у указателей и слайсов nil. Из-за этого легко перепутать «значение ноль» и «значения нет».

type Config struct {
    Timeout int
    Debug   bool
}

func applyConfig(c Config) {
    if c.Timeout == 0 {
        c.Timeout = 30         // пользователь не задал — ставим дефолт
    }
}

А если пользователь специально хотел таймаут 0 — например, «без таймаута»? Мы не можем отличить «не задал» от «задал ноль», потому что оба выглядят как 0. То же с булевыми: false — это «выключено» или «не трогали»?

Когда важно различать эти случаи, используют указатель: nil означает «не задано», а конкретное значение — «задано именно это».

type Config struct {
    Timeout *int
    Debug   *bool
}

func applyConfig(c Config) {
    if c.Timeout == nil {
        def := 30
        c.Timeout = &def       // именно не задан
    }
    // а *c.Timeout == 0 теперь означает осознанный ноль
}

Указатели ради этого добавляют возни, поэтому городить их везде не нужно. Но для конфигов, для полей, приходящих из JSON, для параметров, где ноль — легитимное значение, это единственный честный способ отличить пустоту от нуля.

Канал, из-за которого горутина живёт вечно

Каналы — визитная карточка Go, и на них же приходится отдельный класс утечек.

func fetchAll(urls []string) []string {
    ch := make(chan string)          // небуферизованный канал
    for _, url := range urls {
        go func(u string) {
            ch <- fetch(u)           // блокируется, пока кто-то не прочитает
        }(url)
    }

    results := []string{}
    for range urls {
        results = append(results, <-ch)
    }
    return results
}

Пока всё хорошо: сколько запустили, столько и прочитали. Но представьте, что где-то в середине чтения функция вышла раньше — например, по ошибке или таймауту. Часть горутин осталась висеть на ch <- fetch(u), потому что читать из канала уже некому. Они не завершатся никогда, а вместе с ними утечёт всё, что они держат.

Небуферизованный канал требует, чтобы на каждую запись был готовый читатель. Нет читателя — запись блокируется навсегда. Горутина, застрявшая на такой записи, — самая частая утечка в Go.

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

ch := make(chan string, len(urls))   // места хватит на всех, никто не блокируется

А для случая «читатель может уйти раньше» используют context с отменой и select, чтобы горутина умела сдаться:

select {
case ch <- fetch(u):
case <-ctx.Done():                   // отменили — выходим, не виснем
    return
}

select берёт ту ветку, которая готова первой: либо запись прошла, либо пришла отмена. Так горутина не остаётся заблокированной, если её результат больше никому не нужен.

Суммарно

Если присмотреться, почти все восемь ошибок растут из двух особенностей Go. Первая: некоторые вещи устроены не так, как в других языках, интерфейс с nil внутри, слайс как окно в массив, нулевые значения вместо null. Вторая: компилятор строгий к синтаксису, но не к смыслу, и спокойно пропускает горутину без синхронизации или проглоченную через _ ошибку.

Расскажите, на чём спотыкались вы, когда переходили на Go.

Ошибки со слайсами, мапами и горутинами проще разбирать на практике, когда можно сразу проверить поведение кода и задать вопрос преподавателю. На этих бесплатных демо-уроках можно закрыть пробелы в темах, которые чаще всего вызывают сложности у начинающих Go-разработчиков.

  • 3 августа в 20:00. «Go: управляем памятью как профи. Массивы, слайсы и мапы». Записаться

  • 10 августа в 20:00. «Ваш первый код на Go за один вечер». Записаться

  • 18 августа в 20:00. «Горутины и каналы: базовые принципы параллелизма в Go». Записаться

Полный список бесплатных уроков августа смотрите в дайджесте.